Compare commits

...

53 Commits

Author SHA1 Message Date
刘焱 7e84f89b2b remove probe 2026-06-08 20:45:32 +08:00
liuyan688 2a3333b19d probe 2026-06-08 20:43:00 +08:00
赵昌 d1cc68b6d9 docs: add workflow examples for issue-triage, project-health, newcomer-guide
CI / test (push) Failing after 2m9s Details
2026-06-08 11:44:09 +08:00
赵昌 6c0ad3b7ae feat: add three new skills - issue triage, project health, newcomer guide
CI / test (push) Has been cancelled Details
2026-06-08 11:39:29 +08:00
赵昌 9a51db0989 feat: add wiki and snippet skills, update webhook skill with new commands
CI / test (push) Waiting to run Details
2026-06-08 11:35:45 +08:00
wqer a4587f95c4 添加Python示例
CI / test (push) Failing after 2m5s Details
2026-06-08 11:07:01 +08:00
赵昌 38658fb7b1 feat: task-view supports both numeric ID and UUID lookup
CI / test (push) Failing after 2m8s Details
2026-06-08 10:01:56 +08:00
赵昌 5eb67055fd feat: add webhook +failed and +task-view commands
CI / test (push) Failing after 2m4s Details
- webhook +failed: list failed deliveries with --limit support
- webhook +task-view: view detailed delivery content
- Original webhook +tasks unchanged
2026-06-08 09:57:13 +08:00
赵昌 dd905fb045 docs: update report - remove hook-runner, update webhook section
CI / test (push) Failing after 2m3s Details
2026-06-04 17:39:03 +08:00
赵昌 e60e876fad merge: integrate hook-runner into webhook commands
CI / test (push) Failing after 2m6s Details
- Add webhook +failed: list failed deliveries
- Add webhook +task-view: view task details
- Enhance webhook +tasks: add --limit, show UUID
- Remove standalone hook-runner package
- Update register.go
2026-06-04 17:33:26 +08:00
赵昌 a115c97dde fix: show uuid instead of numeric id in hook-runner list output
CI / test (push) Failing after 2m6s Details
2026-06-04 17:23:06 +08:00
赵昌 66ec432036 fix: simplify hook-runner list output to essential fields only
CI / test (push) Failing after 2m7s Details
2026-06-04 17:09:59 +08:00
赵昌 cb238f132c feat: add --limit flag to hook-runner list/failed commands
CI / test (push) Failing after 1m3s Details
2026-06-04 16:23:17 +08:00
赵昌 240a4ff6b7 test: add hook-runner unit tests (list/view/failed)
CI / test (push) Failing after 2m3s Details
2026-06-04 16:21:12 +08:00
赵昌 d46a0cc48b feat: add hook-runner shortcut for webhook delivery monitoring
CI / test (push) Failing after 2m7s Details
hook-runner +list   - List webhook delivery tasks
hook-runner +view   - View specific delivery task details
hook-runner +failed - List only failed deliveries

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:15:30 +08:00
赵昌 59552d9286 remove: project board shortcut (duplicated with issue commands)
CI / test (push) Failing after 2m7s Details
2026-06-04 16:04:00 +08:00
s2_cc 811bae1cd5 触发流水线
CI / test (push) Failing after 1m2s Details
2026-06-04 14:33:58 +08:00
赵昌 015a30caa5 chore: remove unused testdata file
CI / test (push) Failing after 2m6s Details
2026-06-04 11:42:23 +08:00
赵昌 e3470ba592 fix: delete _Sidebar.md on wiki +delete to prevent orphaned references
CI / test (push) Failing after 2m6s Details
When a wiki page is deleted, also remove _Sidebar.md so that GitLink
regenerates it from the current file list on next web visit. This
prevents orphaned page references from accumulating in the sidebar.

Use git add -A to safely stage all changes (deleted page + sidebar)
without failing when _Sidebar.md doesn't exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:01:10 +08:00
s2_cc 167a2ff12c fix: set GOPROXY to goproxy.cn for China network
CI / test (push) Failing after 1m2s Details
2026-06-04 10:52:46 +08:00
s2_cc 63cbb2cef8 feat: add DevOps CI pipeline for build and verification
CI / test (push) Failing after 2m6s Details
2026-06-04 10:45:32 +08:00
s2_cc 9f479a27db feat: optimize existing commands (P1-P4)
P1 Bug fixes:
- Fix issue +create --label not being sent to API
- Replace hardcoded "master" default branch with dynamic API query
- Add GetDefaultBranch() helper (common/types.go)

P2 Complete missing command parameters (10 items, 39 new flags):
- issue +list: --milestone, --assignee, --label, --keyword, --sort, --sort-direction
- issue +update: --milestone, --assignee, --label, --priority (expanded validation)
- issue +close: --comment (post journal on close)
- pr +list: --keyword, --milestone, --tag, --reviewer, --assignee, --sort, --sort-direction
- pr +create: --priority, --assignee, --milestone, --label (multi-label support)
- pr +merge: validate --method values, add --title, --body
- ci +builds: --status, --branch, --event, --since, --until
- search +repos: --language, --sort, --order
- search +users: --sort, --order
- release +list: --prerelease-only, --latest (client-side filtering)

P3 Batch operations (9 new commands with generic batch framework):
- Add common/batch.go: BatchSummary, CollectNumbers, ProcessBatch, CSV parsing
- Add common/confirm.go: ConfirmAction with --yes skip
- issue +batch-update: native batch_update API with ID mapping
- issue +batch-destroy: native batch_destroy API
- Refactor issue +batch-close to use common batch utilities
- pr +batch-close, pr +batch-merge
- collaborator +batch-add, +batch-remove, +batch-role
- branch +batch-delete, +batch-protect

P4 Help docs, confirm prompts, and error messages:
- Add Long description and Example to all 18 command groups
- Add confirmation prompt to destructive operations (repo/branch/release delete, ci stop)
- Improve HTTP error messages with contextual hints (401/403/404/422/500)
- Replace hardcoded Chinese strings with English in release +delete
2026-06-04 10:45:32 +08:00
赵昌 f18eea273f revert: remove sidebar cleanup from wiki +delete
CI / test (push) Failing after 2m3s Details
Modifying _Sidebar.md causes wiki page rendering to break on the
web UI. Delete now only removes the .md file and leaves sidebar
management to GitLink's web interface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:38:29 +08:00
赵昌 6fda83bf38 fix: clean _Sidebar.md reference on wiki +delete
CI / test (push) Failing after 1m2s Details
Add safe sidebar cleanup using a separate helper file (sidebar.go)
to avoid encoding issues. Only removes the page reference, never
adds or modifies other content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:24:20 +08:00
赵昌 92a953ba56 test: update snippet tests for Git-based operations
CI / test (push) Failing after 2m7s Details
Rewrite tests to use local Git repositories for list/view (Git-based)
and HTTP test servers for create/delete (API-based). Add
repoGitURLOverride variable for test isolation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:13:57 +08:00
wqer 9a2e6a3280 Delete snippet "hello.py"
CI / test (push) Failing after 1m2s Details
2026-06-04 08:54:21 +08:00
wqer 3a47936f9f 创建测试
CI / test (push) Has been cancelled Details
2026-06-04 08:53:36 +08:00
赵昌 15110df6f1 fix: use Git operations for snippet list/view/delete
CI / test (push) Failing after 2m5s Details
Snippet list and view now clone the repo (read-only) instead of
using broken API endpoints. Delete auto-fetches file SHA via
git hash-object for proper API verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:53:55 +08:00
wqer b9638fe0b6 Delete snippet "hello-cli.go"
CI / test (push) Has been cancelled Details
2026-06-04 08:51:27 +08:00
wqer 1762a24851 Delete snippet "test.py"
CI / test (push) Has been cancelled Details
2026-06-04 08:51:15 +08:00
wqer 5ae0a134bb test
CI / test (push) Failing after 2m6s Details
2026-06-04 08:41:36 +08:00
赵昌 2669c632d0 revert: remove automatic _Sidebar.md management
CI / test (push) Failing after 2m7s Details
Automatic sidebar updates caused wiki rendering issues on the web UI.
The sidebar is best left managed by GitLink's web interface directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:21:28 +08:00
赵昌 3ee71a1ffc fix: update _Sidebar.md on wiki page create/delete
CI / test (push) Failing after 2m6s Details
When creating a wiki page, add [[PageName]] to _Sidebar.md.
When deleting a wiki page, remove [[PageName]] from _Sidebar.md.
This ensures the web UI properly reflects page changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:11:08 +08:00
赵昌 87277cbf4f feat: add code snippet management shortcut (snippet +list/+view/+create/+delete)
CI / test (push) Failing after 11m14s Details
Store snippets as files in the snippets/ directory of the repository
using the repo file API. Supports language tagging in content format
and branch selection for commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:19:11 +08:00
wqer fef44dc5e3 Add hello snippet via CLI
CI / test (push) Has been cancelled Details
2026-06-03 21:06:13 +08:00
赵昌 d1e44867c3 feat: add project board shortcut (board +view/+columns/+move)
CI / test (push) Failing after 2m2s Details
Implement a kanban-style project board shortcut group that groups
issues by status. Supports filtering by state and milestone, moving
issues between columns with English or Chinese status names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:55:21 +08:00
刘焱 8d3d6cc5ce ci: add PR testing workflow and auto-update package manager configs on release
CI / test (push) Failing after 10m48s Details
2026-06-02 08:57:53 +08:00
刘焱 9598142c2f infra: add Homebrew, Scoop, Chocolatey package manager configs 2026-06-02 08:57:52 +08:00
刘焱 52dc8a9967 improve: npm install checksum verification, error messages, and version check 2026-06-02 08:57:52 +08:00
刘焱 2ddad05646 feat: add color detection and CJK width support for cross-platform terminals 2026-06-02 08:57:52 +08:00
刘焱 44b184723e feat: add user account settings shortcuts and OAuth2 authentication 2026-06-02 08:57:52 +08:00
刘焱 c7d374ab32 feat: add CI pipeline shortcuts, wiki import/export, repo join/quit/migrate/topics 2026-06-02 08:57:51 +08:00
刘焱 aebb24d51c feat: add util, dataset, template, attachment shortcut groups 2026-06-02 08:56:34 +08:00
刘焱 9f99247422 feat(user): add 22 shortcuts for messages, stats, transfers, settings, feedback 2026-06-02 08:56:34 +08:00
刘焱 637be3cb4b feat(repo): add 22 new shortcuts for settings, stats, social, transfer, invite 2026-06-02 08:56:34 +08:00
刘焱 4121524100 feat: add branch all/default/restore, release edit/update, org team-projects, file batch/entries/replace 2026-06-02 08:56:33 +08:00
刘焱 c2e0b0da18 feat(pr): add comments CRUD, commits, reopen, update shortcuts 2026-06-02 08:56:33 +08:00
刘焱 a94088e4ee feat(issue): add comments CRUD, delete, batch-update, batch-destroy shortcuts 2026-06-02 08:56:33 +08:00
刘焱 94661010ac docs: add implementation plan for full API coverage and cross-platform improvements 2026-06-02 08:56:33 +08:00
刘焱 ca7f5aacce docs: add design spec for full API coverage and cross-platform improvements 2026-06-02 08:56:32 +08:00
赵昌 667f28a618 refactor: reimplement wiki commands using Git operations
Replace REST API-based wiki implementation with direct Git operations
on the wiki repository (https://gitlink.org.cn/{owner}/{repo}.wiki.git).
This fixes wiki management which was broken due to REST API limitations.

List and view work without authentication (public repo).
Create, update, delete require a Personal Access Token for Git push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:53:32 +08:00
赵昌 8df76dcb7a feat: add wiki shortcut commands (list/view/create/update/delete)
Implement wiki management shortcuts with full CRUD operations.
Includes comprehensive unit tests covering success paths, error
handling, partial updates, project ID auto-resolution, and
parameter validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:08:58 +08:00
刘焱 b64a5104a8 feat: improve cross-platform compatibility and expand API shortcut coverage
Cross-platform improvements:
- Config/credential paths now use platform-aware directories:
  Windows (%APPDATA%), macOS (~/Library/Application Support),
  Linux (XDG_CONFIG_HOME) instead of hardcoded ~/.config
- npm install script: add retry (3x), HTTP proxy support (HTTP_PROXY/HTTPS_PROXY),
  download progress indicator, Windows tar fallback extraction
- Makefile: add cross-build (6 platforms), checksums (SHA256), lint targets
- Release workflow: auto-detect Go version from go.mod, upload SHA256 checksums

New API shortcut groups (36 sub-commands across 7 domains):
- collaborator: +list, +add, +remove, +change-role
- tag: +list, +view, +delete
- milestone: +list, +create, +view, +update, +delete, +close
- file: +get, +readme, +create, +update, +delete, +tree, +blob
- commit: +list, +view, +diff, +blame, +compare
- label: +list, +create, +update, +delete, +priorities, +statuses, +authors, +assigners
- sshkey: +list, +create, +delete

Total shortcut commands increased from ~50 to ~86 (+72% coverage).
2026-05-28 15:11:25 +08:00
73 changed files with 14999 additions and 254 deletions

View File

@ -0,0 +1,37 @@
version: 2
name: 构建流水线
description: "gitlink-cli CI 流水线:拉取代码、构建、验证"
trigger:
webhook: gitlink@1.0.0
event:
- ref: push
ruleset-operator: AND
global:
concurrent: 1
param:
- ref: remote_url
name: ""
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
required: false
type: STRING
hidden: false
workflow:
- ref: start
name: 开始
task: start
- ref: ssh_cmd_0
name: 拉取代码并构建验证
task: ssh_cmd@1.1.1
input:
ssh_pass: ((work_together.ssh_key))
ssh_ip: '"121.41.216.243"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_cmd: '"export GOPROXY=https://goproxy.cn,direct && cd /root/gitlink-cli || git clone https://gitlink.org.cn/z2_cc/gitlink-cli.git /root/gitlink-cli; cd /root/gitlink-cli && git pull origin master && go build -o gitlink-cli . && ./gitlink-cli version"'
needs:
- start
- ref: end
name: 结束
task: end
needs:
- ssh_cmd_0

20
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
- name: Build
run: go build -ldflags "-s -w" .

View File

@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version-file: 'go.mod'
- uses: actions/setup-node@v4
with:
@ -57,6 +57,10 @@ jobs:
rm -rf "$BUILD_DIR"
done
# Generate SHA256 checksums
cd dist
sha256sum *.tar.gz *.zip > checksums-sha256.txt
cd ..
ls -lh dist
- name: Create Release
@ -65,8 +69,44 @@ jobs:
files: |
dist/*.tar.gz
dist/*.zip
dist/checksums-sha256.txt
generate_release_notes: true
- name: Update Scoop manifest
run: |
VERSION=${GITHUB_REF#refs/tags/v}
cd dist
# Parse SHA256 hashes from checksums file
declare -A HASHES
while read -r hash file; do
HASHES["$file"]="$hash"
done < checksums-sha256.txt
# Update bucket/gitlink-cli.json
cd ..
SCOOP_FILE="bucket/gitlink-cli.json"
if [ -f "$SCOOP_FILE" ]; then
# Update version
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" "$SCOOP_FILE"
# Update amd64 hash and URL
AMD64_FILE="gitlink-cli_${VERSION}_windows_amd64.zip"
if [ -n "${HASHES[$AMD64_FILE]}" ]; then
sed -i "/64bit/,/}/ s|\"url\": \".*\"|\"url\": \"https://github.com/gitlink-org/gitlink-cli/releases/download/v${VERSION}/${AMD64_FILE}\"|" "$SCOOP_FILE"
sed -i "/64bit/,/}/ s|\"hash\": \".*\"|\"hash\": \"${HASHES[$AMD64_FILE]}\"|" "$SCOOP_FILE"
fi
# Update arm64 hash and URL
ARM64_FILE="gitlink-cli_${VERSION}_windows_arm64.zip"
if [ -n "${HASHES[$ARM64_FILE]}" ]; then
sed -i "/arm64/,/}/ s|\"url\": \".*\"|\"url\": \"https://github.com/gitlink-org/gitlink-cli/releases/download/v${VERSION}/${ARM64_FILE}\"|" "$SCOOP_FILE"
sed -i "/arm64/,/}/ s|\"hash\": \".*\"|\"hash\": \"${HASHES[$ARM64_FILE]}\"|" "$SCOOP_FILE"
fi
echo "Updated $SCOOP_FILE for version $VERSION"
fi
- name: Build npm package
run: |
VERSION=${GITHUB_REF#refs/tags/v}

20
.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
# 编译输出
*.exe
# IDE
.vscode/
.idea/
# 系统
.DS_Store
Thumbs.db
# 临时文件
*.tmp
*.log
# 个人工作区
my_docs/
CLAUDE.md
.claude/
docs/

View File

@ -3,7 +3,9 @@ BINARY := gitlink-cli
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)'
.PHONY: build install clean test
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
.PHONY: build install clean test lint cross-build dist checksums
build:
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
@ -13,6 +15,43 @@ install:
clean:
rm -f $(BINARY)
rm -rf dist/
test:
go test ./...
lint:
go vet ./...
# Cross-compile for all supported platforms
cross-build:
@mkdir -p dist
@for pair in $(PLATFORMS); do \
GOOS=$${pair%/*}; \
GOARCH=$${pair#*/}; \
OUT=$(BINARY); \
if [ "$$GOOS" = "windows" ]; then OUT=$(BINARY).exe; fi; \
echo "Building $$GOOS/$$GOARCH..."; \
BUILD_DIR="dist/$(BINARY)_$(VERSION)_$${GOOS}_$${GOARCH}"; \
mkdir -p "$$BUILD_DIR"; \
CGO_ENABLED=0 GOOS=$$GOOS GOARCH=$$GOARCH \
go build -ldflags "$(LDFLAGS)" -o "$$BUILD_DIR/$$OUT" .; \
if [ "$$GOOS" = "windows" ]; then \
(cd "$$BUILD_DIR" && zip -q "../$(BINARY)_$(VERSION)_$${GOOS}_$${GOARCH}.zip" "$$OUT"); \
else \
tar -czf "dist/$(BINARY)_$(VERSION)_$${GOOS}_$${GOARCH}.tar.gz" -C "$$BUILD_DIR" "$$OUT"; \
fi; \
rm -rf "$$BUILD_DIR"; \
done
@echo "Build complete:"
@ls -lh dist/*.tar.gz dist/*.zip 2>/dev/null
# Generate SHA256 checksums for all archives in dist/
checksums: cross-build
@cd dist && \
sha256sum *.tar.gz *.zip 2>/dev/null > checksums-sha256.txt && \
echo "Checksums written to dist/checksums-sha256.txt" && \
cat checksums-sha256.txt
# Full distribution build: cross-compile + checksums
dist: checksums

33
bucket/gitlink-cli.json Normal file
View File

@ -0,0 +1,33 @@
{
"version": "0.1.13",
"description": "CLI tool for GitLink platform",
"homepage": "https://www.gitlink.org.cn/Gitlink/gitlink-cli",
"license": "MulanPSL-2.0",
"architecture": {
"64bit": {
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_windows_amd64.zip",
"hash": "PLACEHOLDER_SHA256"
},
"arm64": {
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_windows_arm64.zip",
"hash": "PLACEHOLDER_SHA256"
}
},
"bin": "gitlink-cli.exe",
"checkver": {
"github": "https://github.com/gitlink-org/gitlink-cli"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/gitlink-cli_$version_windows_amd64.zip"
},
"arm64": {
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/gitlink-cli_$version_windows_arm64.zip"
}
},
"hash": {
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/checksums-sha256.txt"
}
}
}

16
choco/gitlink-cli.nuspec Normal file
View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>gitlink-cli</id>
<version>0.1.13</version>
<title>GitLink CLI</title>
<authors>GitLink</authors>
<projectUrl>https://www.gitlink.org.cn/Gitlink/gitlink-cli</projectUrl>
<licenseUrl>https://opensource.org/licenses/MulanPSL-2.0</licenseUrl>
<description>CLI tool for GitLink platform</description>
<tags>gitlink cli devops</tags>
</metadata>
<files>
<file src="tools/**" target="tools" />
</files>
</package>

View File

@ -0,0 +1,14 @@
$ErrorActionPreference = 'Stop'
$packageName = 'gitlink-cli'
$version = '0.1.13'
$url = "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/gitlink-cli_$version_windows_amd64.zip"
$checksum = 'PLACEHOLDER_SHA256'
$installDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
$packageArgs = @{
packageName = $packageName
url = $url
checksum = $checksum
checksumType = 'sha256'
unzipLocation = $installDir
}
Install-ChocolateyZipPackage @packageArgs

View File

@ -23,11 +23,13 @@ func NewAuthCmd() *cobra.Command {
cmd.AddCommand(newLoginCmd())
cmd.AddCommand(newLogoutCmd())
cmd.AddCommand(newStatusCmd())
cmd.AddCommand(newRefreshCmd())
return cmd
}
func newLoginCmd() *cobra.Command {
var tokenMode bool
var oauthMode bool
cmd := &cobra.Command{
Use: "login",
@ -36,10 +38,14 @@ func newLoginCmd() *cobra.Command {
if tokenMode {
return loginWithToken()
}
if oauthMode {
return loginWithOAuth()
}
return loginWithPassword()
},
}
cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token")
cmd.Flags().BoolVar(&oauthMode, "oauth", false, "Login using OAuth2 password grant")
return cmd
}
@ -140,3 +146,42 @@ func newStatusCmd() *cobra.Command {
},
}
}
func loginWithOAuth() error {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Username: ")
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
fmt.Print("Password: ")
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
fmt.Println()
password := string(passwordBytes)
result, err := internalAuth.LoginOAuth(username, password)
if err != nil {
return fmt.Errorf("OAuth2 login failed: %w", err)
}
fmt.Printf("✓ Logged in as %s\n", result.Login)
return nil
}
func newRefreshCmd() *cobra.Command {
return &cobra.Command{
Use: "refresh",
Short: "Refresh OAuth2 token",
RunE: func(cmd *cobra.Command, args []string) error {
result, err := internalAuth.RefreshOAuthToken()
if err != nil {
return fmt.Errorf("failed to refresh token: %w", err)
}
fmt.Printf("✓ Token refreshed for %s (expires in %d seconds)\n", result.Login, result.ExpiresIn)
return nil
},
}
}

View File

@ -2,8 +2,10 @@ package cmdutil
// Global flags shared across all commands.
var (
Owner string
Repo string
Format string
Debug bool
Owner string
Repo string
Format string
Debug bool
ColorMode string
AutoConfirm bool
)

View File

@ -15,12 +15,17 @@ import (
var Version = "dev"
var colorMode string
var rootCmd = &cobra.Command{
Use: "gitlink-cli",
Short: "GitLink CLI — command-line tool for gitlink.org.cn",
Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`,
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
cmdutil.ColorMode = colorMode
},
}
func init() {
@ -28,6 +33,8 @@ func init() {
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)")
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)")
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
rootCmd.PersistentFlags().StringVar(&colorMode, "color", "auto", "Color output: auto, always, never")
rootCmd.PersistentFlags().BoolVar(&cmdutil.AutoConfirm, "yes", false, "Skip confirmation prompts")
rootCmd.AddCommand(authCmd.NewAuthCmd())
rootCmd.AddCommand(apiCmd.NewAPICmd())

View File

@ -1,6 +1,6 @@
# Webhook Shortcut
新增 `webhook` Shortcut 组,支持
新增 `webhook` Shortcut 组,支持
- `webhook +list`
- `webhook +create`

View File

@ -0,0 +1,127 @@
# Shortcut 命令实现报告
## 总体设计模式
三个 Shortcut 组(`wiki`、`snippet`、`hook-runner`)虽然功能不同,但整体架构遵循一致的设计模式:
1. **命令注册**:每个 Shortcut 组通过 `Shortcuts()` 函数返回 `[]*common.Shortcut`,在 `shortcuts/register.go` 中统一挂载到 cobra 根命令
2. **上下文注入**:通过 `RuntimeContext` 提供 API 客户端、owner/repo 解析、输出格式化等基础设施
3. **owner/repo 解析**:每个命令先调用 `ctx.ResolveOwnerRepo()` 从 flags 或 git remote 自动解析
4. **数据访问**:读操作用公开方式(无需认证),写操作用 Token 认证
5. **输出格式**:统一支持 `json` / `table` 格式,通过 `ctx.Output()` / `ctx.OutputData()` 输出
6. **错误处理**:参数校验置前(必填 flag 检查API 错误直接传播
> **注意**:以下所有演示命令假设在 `gitlink-cli` 仓库目录下运行。`--owner` 和 `--repo` 参数会自动从 Git remote 解析,无需手动传入。
---
## 1. Wiki 管理(`wiki`
### 实现思路
GitLink 的 Wiki 本质是一个**独立的 Git 仓库**`https://gitlink.org.cn/{owner}/{repo}.wiki.git`),每个页面是一个 `.md` 文件,侧边栏由 `_Sidebar.md` 控制。
| 操作 | 方式 | 认证 |
|------|------|------|
| 读list/view | `git clone` 到临时目录,读取文件 | ❌ 公开仓库,无需认证 |
| 写create/update/delete | `git clone` → 修改文件 → `git commit/push` | ✅ Personal Access Token |
删除时额外删除 `_Sidebar.md`,让 GitLink 网页端自动从文件列表重建侧边栏,防止残留引用。
### 遇到的问题
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 修改 `_Sidebar.md` 内容导致网页渲染崩溃 | `_Sidebar.md` 的编码/格式与 GitLink 网页端的预期不一致 | 不修改内容,直接删除文件,让 GitLink 自动重建 |
| `git add _Sidebar.md` 文件不存在时报错 | `_Sidebar.md` 尚未被网页端生成时不存在 | 用 `git add -A` 替代,自动识别所有变更 |
### 演示指令
```powershell
# 列出 Wiki 页面Git 克隆 → 读取目录 → 输出)
./gitlink-cli.exe wiki +list --format table
# 查看 Wiki 页面内容Git 克隆 → 读取文件内容 + Git log
./gitlink-cli.exe wiki +view --page-name "Wiki测试" --format json
# 创建 Wiki 页面Git 克隆 → 写 .md 文件 → git add/commit/push
./gitlink-cli.exe wiki +create --page-name "demo" --title "Demo" --content "Hello World" --message "创建Wiki页面"
# 更新 Wiki 页面Git 克隆 → 修改文件 → git add/commit/push
./gitlink-cli.exe wiki +update --page-name "demo" --title "Updated" --content "New content"
# 删除 Wiki 页面Git 克隆 → 删文件+删_Sidebar.md → git add -A/commit/push
./gitlink-cli.exe wiki +delete --page-name "demo"
```
---
## 2. 代码片段(`snippet`
### 实现思路
代码片段存放在项目仓库的 `snippets/` 目录下,每个片段是一个文件,读操作用 Git 克隆,写操作用文件 API。
| 操作 | 方式 | 说明 |
|------|------|------|
| 列出片段 | `git clone` → 读取 `snippets/` 目录 | 读操作无需认证 |
| 查看片段 | `git clone` → 读取指定文件内容 | 读操作无需认证 |
| 创建片段 | `POST /{owner}/{repo}/create_file` API | 写操作需 Token |
| 删除片段 | `git hash-object` 获取 SHA → `DELETE /delete_file` API | 先获取文件 SHA 再删除 |
创建时自动用代码块语法包裹内容(支持 `--language` 指定语言)。
### 演示指令
```powershell
# 创建代码片段POST 文件 API → 写入 snippets/ 目录)
./gitlink-cli.exe snippet +create --name "hello.py" --content 'print("Hello World!")' --language python --message "添加Python示例"
# 列出代码片段Git 克隆 → 读取 snippets/ 目录 → 输出)
./gitlink-cli.exe snippet +list --format table
# 查看代码片段Git 克隆 → 读取指定文件内容 → 输出)
./gitlink-cli.exe snippet +view --name "hello.py"
# 删除代码片段Git 获取 SHA → DELETE 文件 API
./gitlink-cli.exe snippet +delete --name "hello.py"
```
---
## 3. Webhook 投递监控(`webhook`
> 以下功能已合并到 `webhook` 命令组中,`hook-runner` 已删除。
### 实现思路
Webhook 每次触发事件push、Issue 操作、PR 操作等GitLink 会向配置的 URL 发送 HTTP 请求,这个过程称为"投递"。通过 Webhook 历史推送列表 API 获取投递记录。
| 命令 | API 路径 | 说明 |
|------|---------|------|
| `webhook +tasks` | `GET /v1/{owner}/{repo}/webhooks/{id}/hooktasks` | 查看投递历史(原有命令,保持不变) |
| `webhook +failed` | 从投递列表中过滤失败项 | 仅查看投递失败的记录 |
| `webhook +task-view` | 从投递列表中按 ID 筛选 | 查看某次投递的请求体、响应状态等 |
### 演示指令
```powershell
# 查看投递历史(原有命令不变)
./gitlink-cli.exe webhook +tasks --id 51102
# 查看投递失败的记录
./gitlink-cli.exe webhook +failed --id 51102
# 查看某次投递的详细内容
./gitlink-cli.exe webhook +task-view --id 51102 --task-id 4775684
```
---
## 测试
```powershell
# 运行全部 Shortcut 测试
cd E:/gitlink-cli/gitlink-cli
go test ./shortcuts/... -count=1
```

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,363 @@
# gitlink-cli API 全量封装 + 跨平台兼容性设计
**日期:** 2026-06-01
**状态:** 已批准
**范围:** 补全 Raw API 封装(对齐 GitLink OpenAPI 约 200 个端点)、提升跨平台兼容性和安装体验
---
## 背景
gitlink-cli 当前封装了约 90 个 GitLink OpenAPI 端点18 个快捷方式域),但 API 参考文档记录了约 200 个端点。同时,跨平台体验存在改进空间:终端输出在 Windows 上可能渲染不佳、npm 安装缺少 checksum 验证、缺少常见包管理器支持。
## 目标
1. 补全所有 GitLink OpenAPI 端点的快捷方式封装
2. 提升跨平台兼容性(终端输出、路径处理、字符宽度)
3. 改善安装体验npm 安装健壮性、包管理器分发、CI 改进)
## 实现策略
采用**分层渐进式**方案,按 L1→L2→L3→L4 四层推进,每层在功能分支上逐步提交。
---
## L1补全已有域的缺失操作~35 个端点)
### 1.1 Issue 评论 CRUD 补全
文件:`shortcuts/issue/issue.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `issue +comments` | GET | `/v1/{owner}/{repo}/issues/{index}/journals` | 评论/活动列表 |
| `issue +update-comment` | PATCH | `/v1/{owner}/{repo}/issues/{index}/journals/{id}` | 更新评论 |
| `issue +delete-comment` | DELETE | `/v1/{owner}/{repo}/issues/{index}/journals/{id}` | 删除评论 |
| `issue +reply-comment` | GET | `/v1/{owner}/{repo}/issues/{index}/journals/{id}/children_journals` | 子评论列表 |
| `issue +delete` | DELETE | `/v1/{owner}/{repo}/issues/{index}` | 删除 issue |
| `issue +batch-update` | PATCH | `/v1/{owner}/{repo}/issues/batch_update` | 批量更新 |
| `issue +batch-destroy` | DELETE | `/v1/{owner}/{repo}/issues/batch_destroy` | 批量删除 |
### 1.2 PR 评论 CRUD 补全
文件:`shortcuts/pr/pr.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `pr +comments` | GET | `/v1/{owner}/{repo}/pulls/{index}/journals` | 审查评论列表 |
| `pr +create-comment` | POST | `/v1/{owner}/{repo}/pulls/{index}/journals` | 创建审查评论 |
| `pr +update-comment` | PUT | `/v1/{owner}/{repo}/pulls/{index}/journals/{id}` | 更新审查评论 |
| `pr +delete-comment` | DELETE | `/v1/{owner}/{repo}/pulls/{index}/journals/{id}` | 删除审查评论 |
| `pr +commits` | GET | `/{owner}/{repo}/pulls/{id}/commits` | PR 提交列表 |
| `pr +reopen` | POST | `/v1/{owner}/{repo}/pulls/{index}/reopen` | 重新打开 PR |
| `pr +update` | PUT | `/{owner}/{repo}/pulls/{index}` | 更新 PR |
### 1.3 分支补全
文件:`shortcuts/branch/branch.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `branch +all` | GET | `/v1/{owner}/{repo}/branches/all` | 所有分支(无分页) |
| `branch +default` | PATCH | `/v1/{owner}/{repo}/branches/update_default_branch` | 更新默认分支 |
| `branch +restore` | POST | `/v1/{owner}/{repo}/branches/restore` | 恢复已删除分支 |
### 1.4 Release 补全
文件:`shortcuts/release/release.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `release +edit` | GET | `/{owner}/{repo}/releases/{id}/edit` | 编辑元数据 |
| `release +update` | PUT | `/{owner}/{repo}/releases/{id}` | 更新 release |
### 1.5 项目补全
文件:`shortcuts/repo/repo.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `repo +update` | PATCH | `/{owner}/{repo}` | 更新项目设置 |
| `repo +about` | GET | `/{owner}/{repo}/about` | 项目主页/关于 |
| `repo +menu` | GET | `/{owner}/{repo}/menu_list` | 项目导航列表 |
| `repo +units-get` | GET | `/{owner}/{repo}/project_units` | 导航设置 |
| `repo +units-set` | POST | `/{owner}/{repo}/project_units` | 更新导航设置 |
| `repo +code-stats` | GET | `/v1/{owner}/{repo}/code_stats` | 代码统计 |
| `repo +languages` | GET | `/{owner}/{repo}/languages` | 项目语言 |
| `repo +contributors` | GET | `/{owner}/{repo}/contributors` | 贡献者列表 |
| `repo +contributors-stat` | GET | `/v1/{owner}/{repo}/contributors/stat` | 贡献者代码统计 |
| `repo +recommend` | GET | `/projects/recommend` | 推荐项目 |
| `repo +star` | POST | `/projects/{id}/praise_tread/like` | 点赞 |
| `repo +unstar` | DELETE | `/projects/{id}/praise_tread/unlike` | 取消点赞 |
| `repo +watch` | POST | `/watchers/follow` | 关注 |
| `repo +unwatch` | DELETE | `/watchers/unfollow` | 取消关注 |
| `repo +stargazers` | GET | `/{owner}/{repo}/stargazers` | 点赞者列表 |
| `repo +watchers` | GET | `/{owner}/{repo}/watchers` | 关注者列表 |
| `repo +transfer` | POST | `/{owner}/{repo}/applied_transfer_projects` | 传输项目 |
| `repo +cancel-transfer` | POST | `/{owner}/{repo}/applied_transfer_projects/cancel` | 取消传输 |
| `repo +transfer-orgs` | GET | `/{owner}/{repo}/applied_transfer_projects/organizations` | 可传输的组织 |
| `repo +invite-link` | GET | `/{owner}/{repo}/project_invite_links/current_link` | 邀请链接 |
| `repo +invite-info` | GET | `/{owner}/{repo}/project_invite_links/show_link` | 邀请链接信息 |
| `repo +edit-detail` | GET | `/{owner}/{repo}/edit` | 编辑详情 |
| `repo +simple` | GET | `/{owner}/{repo}/simple` | 简化详情 |
### 1.6 用户消息与统计
文件:`shortcuts/user/user.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `user +update` | PUT | `/users/{owner}` | 更新用户信息 |
| `user +messages` | GET | `/users/{owner}/messages` | 消息列表 |
| `user +delete-message` | DELETE | `/users/{owner}/messages` | 删除消息 |
| `user +create-message` | POST | `/users/{owner}/messages` | 创建消息 |
| `user +read-message` | POST | `/users/{owner}/messages/read` | 标记已读 |
| `user +msg-settings` | GET | `/users/{owner}/template_message_settings` | 消息设置 |
| `user +update-msg-settings` | POST | `/users/{owner}/template_message_settings/update_setting` | 更新消息设置 |
| `user +pinned-projects` | GET | `/users/{owner}/is_pinned_projects` | 置顶项目 |
| `user +pin-project` | POST | `/users/{owner}/is_pinned_projects/pin` | 置顶 |
| `user +reorder-pinned` | PUT | `/users/{owner}/is_pinned_projects/{id}` | 重排置顶 |
| `user +activity` | GET | `/users/{owner}/statistics/activity` | 活动统计 |
| `user +headmap` | GET | `/users/{owner}/headmaps` | 贡献热力图 |
| `user +develop-stats` | GET | `/users/{owner}/statistics/develop` | 开发能力 |
| `user +role-stats` | GET | `/users/{owner}/statistics/role` | 角色定位 |
| `user +major-stats` | GET | `/users/{owner}/statistics/major` | 专业定位 |
| `user +applied-transfers` | GET | `/users/{owner}/applied_transfer_projects` | 待处理传输 |
| `user +accept-transfer` | POST | `/users/{owner}/applied_transfer_projects/{id}/accept` | 接受传输 |
| `user +refuse-transfer` | POST | `/users/{owner}/applied_transfer_projects/{id}/refuse` | 拒绝传输 |
| `user +applied-projects` | GET | `/users/{owner}/applied_projects` | 待处理加入申请 |
| `user +accept-join` | POST | `/users/{owner}/applied_projects/{id}/accept` | 接受加入 |
| `user +refuse-join` | POST | `/users/{owner}/applied_projects/{id}/refuse` | 拒绝加入 |
| `user +feedback` | POST | `/v1/{owner}/feedbacks` | 反馈建议 |
### 1.7 组织补全
文件:`shortcuts/org/org.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `org +add-team-projects` | POST | `/organizations/{org}/teams/{id}/team_projects/create_all` | 添加所有项目到团队 |
| `org +remove-team-projects` | DELETE | `/organizations/{org}/teams/{id}/team_projects/destroy_all` | 从团队删除所有项目 |
### 1.8 文件操作补全
文件:`shortcuts/file/file.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `file +batch` | POST | `/v1/{owner}/{repo}/contents/batch` | 批量提交文件 |
| `file +entries` | GET | `/{owner}/{repo}/entries` | 目录列表 |
| `file +sub-entries` | GET | `/{owner}/{repo}/sub_entries` | 子目录/文件 |
| `file +replace` | POST | `/{owner}/{repo}/replace_file` | 替换文件 |
### 1.9 辅助工具
文件:`shortcuts/util/util.go`(新建)
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `util +licenses` | GET | `/licenses` | 许可证模板列表 |
| `util +ignores` | GET | `/ignores` | 忽略文件模板列表 |
| `util +platform-msg-settings` | GET | `/template_message_settings` | 平台消息设置 |
---
## L2新增领域快捷方式~19 个端点)
### 2.1 数据集
新建文件:`shortcuts/dataset/dataset.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `dataset +create` | POST | `/v1/{owner}/{repo}/dataset` | 创建数据集 |
| `dataset +update` | PUT | `/v1/{owner}/{repo}/dataset` | 更新数据集 |
| `dataset +view` | GET | `/v1/{owner}/{repo}/dataset` | 数据集详情 + 附件 |
| `dataset +list` | GET | `/v1/project_datasets` | 全局数据集列表 |
### 2.2 模板
新建文件:`shortcuts/template/template.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `template +list` | GET | `/v1/{owner}/project_templates` | 模板列表 |
| `template +create` | POST | `/v1/{owner}/project_templates` | 创建模板 |
| `template +view` | GET | `/v1/{owner}/{repo}/project_templates/{id}` | 模板详情 |
| `template +update` | PUT | `/v1/{owner}/project_templates/{id}` | 更新模板 |
| `template +delete` | DELETE | `/v1/{owner}/project_templates/{id}` | 删除模板 |
### 2.3 CI 流水线补全
文件:`shortcuts/ci/ci.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `ci +pipelines` | GET | `/pm/pipelines` | 流水线模板列表 |
| `ci +run-pipeline` | POST | `/v1/{owner}/{repo}/actions/runs` | 运行流水线 |
| `ci +runs` | GET | `/v1/{owner}/{repo}/actions/runs` | 运行历史 |
| `ci +save-yaml` | POST | `/v1/{owner}/{repo}/pipelines/save_yaml` | 保存 YAML |
| `ci +pipeline-detail` | GET | `/v1/{owner}/{repo}/pipelines/{id}` | 流水线详情 |
| `ci +delete-pipeline` | DELETE | `/v1/{owner}/{repo}/pipelines/{id}` | 删除流水线 |
| `ci +disable` | POST | `/v1/{owner}/{repo}/actions/disable` | 禁用流水线 |
| `ci +enable` | POST | `/v1/{owner}/{repo}/actions/enable` | 启用流水线 |
| `ci +run-log` | POST | `/v1/{owner}/{repo}/actions/runs/{run_id}/jobs/0` | 运行日志 |
| `ci +run-results` | GET | `/v1/{owner}/{repo}/pipelines/run_results` | 运行报告 |
### 2.4 Wiki 导入/导出
文件:`shortcuts/wiki/wiki.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `wiki +export` | GET | `/wikiExport/wikiExport-wrapper` | 导出 Wiki |
| `wiki +import` | POST | `/wikiExport/uploadWiki/{owner}/{repoName}/{projectId}` | 导入 Wiki |
### 2.5 附件
新建文件:`shortcuts/attachment/attachment.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `attachment +upload` | POST | `/attachments` | 上传文件 |
| `attachment +delete` | DELETE | `/attachments/{uuid}` | 删除附件 |
---
## L3用户/平台管理(~30 个端点)
### 3.1 用户个人设置
文件:`shortcuts/user/user.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `user +change-password` | POST | `/accounts/change_password` | 修改密码 |
| `user +update-email` | PATCH | `/v1/{owner}/update_email` | 更改邮箱 |
| `user +update-phone` | PATCH | `/v1/{owner}/update_phone` | 更改手机 |
| `user +send-verify` | GET | `/v1/{owner}/send_email_vefify_code` | 发送验证码 |
| `user +check-verify` | POST | `/v1/{owner}/check_email_verify_code` | 验证验证码 |
| `user +check-password` | POST | `/v1/{owner}/check_password` | 验证密码 |
| `user +check-email` | POST | `/v1/{owner}/check_email` | 验证邮箱 |
| `user +can-delete` | POST | `/v1/{owner}/check_user_can_delete` | 检查可否删除 |
| `user +delete` | DELETE | `/v1/{owner}` | 停用用户 |
### 3.2 OAuth2 认证
文件:`cmd/auth/auth.go`、`internal/auth/login.go`
| 功能 | 方法 | 端点 | 说明 |
|------|------|------|------|
| `auth login --oauth` | POST | `/oauth/token` (password grant) | OAuth2 密码模式登录 |
| `auth refresh` | POST | `/oauth/token` (refresh_token grant) | 刷新令牌 |
认证流程:`--oauth` 标志触发 OAuth2 路径而非 cookie 路径。获取到的 access_token 存储到 keyring后续请求使用 token 认证。
### 3.3 项目管理增强
文件:`shortcuts/repo/repo.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `repo +join` | POST | `/applied_projects` | 申请加入项目 |
| `repo +quit` | POST | `/{owner}/{repo}/quit` | 退出项目 |
| `repo +migrate` | POST | `/projects/migrate` | 创建镜像项目 |
| `repo +sync-mirror` | POST | `/repositories/{id}/sync_mirror` | 同步镜像 |
| `repo +topics` | GET | `/v1/project_topics` | 项目主题列表 |
| `repo +create-topic` | POST | `/v1/project_topics` | 创建主题 |
| `repo +delete-topic` | DELETE | `/v1/project_topics/{id}` | 删除主题 |
---
## L4跨平台兼容性和安装体验
### 4.1 终端输出兼容性
**改动文件:** `internal/output/formatter.go`、`cmd/root.go`
**颜色处理:**
- 添加 `--color` / `--no-color` 全局标志
- 自动检测终端颜色支持:检查 `NO_COLOR` 环境变量、`COLORTERM`、Windows Terminal (`WT_SESSION`)、`ConEmuANSI`
- 不支持颜色时自动降级为纯文本输出
**表格渲染:**
- 使用 `golang.org/x/term`(已有依赖)获取终端宽度
- 列过多时自动截断长值,保证不换行
- 中文字符宽度计算:检测 rune 是否在 CJK 范围 `一-鿿`,是则宽度为 2
### 4.2 npm 安装体验
**改动文件:** `npm/scripts/install.js`
- 下载后验证 SHA256 checksum从 GitHub Release 的 checksums.txt 获取)
- 更好的错误分类和消息:
- `ENOTFOUND` → "DNS 解析失败,请检查网络连接"
- `ECONNREFUSED` → "连接被拒绝,请检查代理设置"
- `ETIMEDOUT` → "连接超时,请尝试设置 HTTP_PROXY"
- `EACCES` → "权限不足,请使用管理员权限运行或检查安装目录"
- 离线模式:检查 `npm_config_cache` 中是否已有匹配版本
- `--verbose` 日志支持
**改动文件:** `npm/bin/cli.js`
- 启动时检查新版本(可配置关闭),提示 "New version X.Y.Z available"
- 二进制不存在时输出安装指引而非报错
### 4.3 包管理器分发
新增以下配置文件:
| 平台 | 文件路径 | 说明 |
|------|----------|------|
| Homebrew | `formula/gitlink-cli.rb` | Ruby formula从 GitHub Release 下载 |
| Scoop | `bucket/gitlink-cli.json` | JSON manifest |
| Chocolatey | `choco/gitlink-cli.nuspec` + `choco/tools/chocolateyinstall.ps1` | NuGet 包 |
每个配置文件中的版本号和 SHA256 由 release workflow 自动更新。
### 4.4 GitHub Actions CI 改进
**新增:** `.github/workflows/ci.yml`
- 触发PR 和 push 到 master
- 步骤:`go test ./...`、`go vet`、`golangci-lint run`
**改进:** `.github/workflows/release.yml`
- 添加 SHA256 checksum 文件上传到 Release
- 自动更新 Homebrew formula / Scoop manifest / Chocolatey nuspec 中的版本和 hash
### 4.5 构建优化
**改动文件:** `Makefile`
- 确保 `CGO_ENABLED=0`(已有)
- 添加 `-ldflags "-s -w"` 减小二进制体积(已有 `-s -w` 在部分目标)
- 统一所有构建目标使用相同的 ldflags
---
## 架构约束
1. **所有新快捷方式遵循现有声明式框架**:使用 `common.Shortcut`、`common.Flag`、`common.RuntimeContext`
2. **不引入新的 Go 依赖**颜色检测、终端宽度、CJK 宽度均用标准库或已有依赖实现)
3. **每个新快捷方式组注册到 `shortcuts/register.go`**
4. **测试覆盖**:每个新快捷方式的非平凡逻辑都需要测试(使用 `httptest` 模拟服务器)
5. **错误处理**:遵循现有 `client.go` 的错误模式(检查 HTTP 200 + `status` 字段)
## 不在范围内
- 不修改现有快捷方式的行为(只添加新的)
- 不添加交互式向导模式
- 不实现图形界面
- 不添加国际化(保持中文/英文双语 README
---
## 预估工作量
| 层 | 新增快捷方式数 | 新增文件 | 预估工作量 |
|-----|--------------|---------|-----------|
| L1 | ~70 | 1 (`shortcuts/util/`) | 大 |
| L2 | ~19 | 3 (`dataset/`, `template/`, `attachment/`) | 中 |
| L3 | ~16 | 0 | 中 |
| L4 | N/A | ~5 (包管理器配置 + CI) | 中 |
| **合计** | ~105 个操作 | ~9 个新文件/目录 | — |

35
formula/gitlink-cli.rb Normal file
View File

@ -0,0 +1,35 @@
class GitlinkCli < Formula
desc "CLI tool for GitLink platform"
homepage "https://www.gitlink.org.cn/Gitlink/gitlink-cli"
version "0.1.13"
on_macos do
on_arm do
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_darwin_arm64.tar.gz"
sha256 "PLACEHOLDER_SHA256"
end
on_intel do
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_darwin_amd64.tar.gz"
sha256 "PLACEHOLDER_SHA256"
end
end
on_linux do
on_arm do
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_linux_arm64.tar.gz"
sha256 "PLACEHOLDER_SHA256"
end
on_intel do
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_linux_amd64.tar.gz"
sha256 "PLACEHOLDER_SHA256"
end
end
def install
bin.install "gitlink-cli"
end
test do
system "#{bin}/gitlink-cli", "version"
end
end

2
go.mod
View File

@ -1,6 +1,6 @@
module github.com/gitlink-org/gitlink-cli
go 1.26.1
go 1.25.0
require (
github.com/spf13/cobra v1.10.2

View File

@ -150,3 +150,157 @@ func GetCurrentUser() (map[string]interface{}, error) {
return result, nil
}
// OAuthTokenResult holds the response from an OAuth2 token request.
type OAuthTokenResult struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Login string `json:"login"`
}
// LoginOAuth authenticates via OAuth2 password grant and stores the access token.
func LoginOAuth(username, password string) (*OAuthTokenResult, error) {
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
// Derive the base URL by stripping the /api suffix
baseURL := strings.TrimSuffix(cfg.BaseURL, "/api")
tokenURL := baseURL + "/oauth/token"
data := url.Values{}
data.Set("grant_type", "password")
data.Set("username", username)
data.Set("password", password)
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("OAuth2 token request failed (HTTP %d): %s", resp.StatusCode, string(body))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
if tokenResp.AccessToken == "" {
return nil, fmt.Errorf("no access_token in OAuth2 response")
}
// Store the access token
if err := StoreToken(tokenResp.AccessToken); err != nil {
return nil, fmt.Errorf("failed to store token: %w", err)
}
// Get current user info to display login name
user, err := GetCurrentUser()
if err != nil {
return nil, fmt.Errorf("token stored but failed to fetch user info: %w", err)
}
login, _ := user["login"].(string)
return &OAuthTokenResult{
AccessToken: tokenResp.AccessToken,
TokenType: tokenResp.TokenType,
ExpiresIn: tokenResp.ExpiresIn,
Login: login,
}, nil
}
// RefreshOAuthToken refreshes the current OAuth2 token using a refresh_token grant.
// Note: This requires the server to have issued a refresh_token during the initial login.
func RefreshOAuthToken() (*OAuthTokenResult, error) {
currentToken, err := LoadToken()
if err != nil || currentToken == "" {
return nil, fmt.Errorf("no stored token found; please login first")
}
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
baseURL := strings.TrimSuffix(cfg.BaseURL, "/api")
tokenURL := baseURL + "/oauth/token"
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", currentToken)
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("OAuth2 token refresh failed (HTTP %d): %s", resp.StatusCode, string(body))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
if tokenResp.AccessToken == "" {
return nil, fmt.Errorf("no access_token in OAuth2 response")
}
// Store the new access token
if err := StoreToken(tokenResp.AccessToken); err != nil {
return nil, fmt.Errorf("failed to store token: %w", err)
}
// Get current user info
user, err := GetCurrentUser()
if err != nil {
return nil, fmt.Errorf("token refreshed but failed to fetch user info: %w", err)
}
login, _ := user["login"].(string)
return &OAuthTokenResult{
AccessToken: tokenResp.AccessToken,
TokenType: tokenResp.TokenType,
ExpiresIn: tokenResp.ExpiresIn,
Login: login,
}, nil
}

View File

@ -3,6 +3,7 @@ package auth
import (
"os"
"path/filepath"
"runtime"
"github.com/zalando/go-keyring"
)
@ -41,8 +42,30 @@ func DeleteToken() error {
// File-based fallback
func credentialPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
return filepath.Join(configDir(), "credentials")
}
func configDir() string {
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
return dir
}
switch runtime.GOOS {
case "windows":
if appData := os.Getenv("APPDATA"); appData != "" {
return filepath.Join(appData, "gitlink-cli")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, "AppData", "Roaming", "gitlink-cli")
case "darwin":
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "Application Support", "gitlink-cli")
default:
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "gitlink-cli")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli")
}
}
func storeTokenFile(token string) error {

View File

@ -24,9 +24,14 @@ type APIError struct {
StatusCode int
Code interface{}
Message string
Method string
Path string
}
func (e *APIError) Error() string {
if e.Method != "" && e.Path != "" {
return fmt.Sprintf("%s %s: [%v] %s", e.Method, e.Path, e.Code, e.Message)
}
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
}
@ -102,6 +107,8 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
Method: method,
Path: path,
}
}
@ -128,6 +135,8 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
Method: method,
Path: path,
}
}
}
@ -177,13 +186,15 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error)
func suggestFix(code int) string {
switch code {
case 401:
return "请先运行 gitlink-cli auth login 登录"
return "Run 'gitlink auth login' to authenticate"
case 403:
return "权限不足,请确认账户权限或联系项目管理员"
return "Permission denied. Check your account permissions or contact the project admin"
case 404:
return "资源不存在,请检查 owner/repo/id 是否正确"
return "Resource not found. Verify --owner, --repo, and the resource ID"
case 422:
return "参数校验失败,请检查请求参数"
return "Validation failed. Check the request parameters"
case 500:
return "Server error. Try again later or contact the platform admin"
default:
return ""
}

View File

@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"runtime"
"gopkg.in/yaml.v3"
)
@ -26,12 +27,32 @@ func DefaultConfig() *Config {
}
}
// ConfigDir returns the platform-appropriate configuration directory.
// - Windows: %APPDATA%\gitlink-cli
// - macOS: ~/Library/Application Support/gitlink-cli
// - Linux: ~/.config/gitlink-cli (XDG_CONFIG_HOME respected)
func ConfigDir() string {
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
return dir
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli")
switch runtime.GOOS {
case "windows":
if appData := os.Getenv("APPDATA"); appData != "" {
return filepath.Join(appData, "gitlink-cli")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, "AppData", "Roaming", "gitlink-cli")
case "darwin":
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "Application Support", "gitlink-cli")
default:
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "gitlink-cli")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli")
}
}
func ConfigPath() string {

View File

@ -8,6 +8,7 @@ import (
"reflect"
"strings"
"text/tabwriter"
"unicode"
"gopkg.in/yaml.v3"
)
@ -178,3 +179,53 @@ func formatValue(v interface{}) string {
return fmt.Sprintf("%v", v)
}
}
// ShouldUseColor returns true if colorized output should be used.
func ShouldUseColor(mode string) bool {
switch mode {
case "always":
return true
case "never":
return false
default: // "auto"
if os.Getenv("NO_COLOR") != "" {
return false
}
if os.Getenv("TERM") == "dumb" {
return false
}
if os.Getenv("WT_SESSION") != "" || os.Getenv("ConEmuANSI") == "ON" {
return true
}
if os.Getenv("COLORTERM") != "" {
return true
}
if os.Getenv("TERM") != "" {
return true
}
return false
}
}
// runeWidth returns the display width of a rune (2 for CJK, 1 for others).
func runeWidth(r rune) int {
if unicode.Is(unicode.Han, r) {
return 2
}
if r >= 0xFF01 && r <= 0xFF60 {
return 2
}
if r >= 0x3000 && r <= 0x303F {
return 2
}
return 1
}
// StringWidth returns the display width of a string accounting for CJK characters.
func StringWidth(s string) int {
w := 0
for _, r := range s {
w += runeWidth(r)
}
return w
}

View File

@ -4,9 +4,13 @@
const fs = require("fs");
const path = require("path");
const https = require("https");
const http = require("http");
const { execFileSync } = require("child_process");
const BINARY_NAME = "gitlink-cli";
const PACKAGE = require("../package.json");
const CURRENT_VERSION = PACKAGE.version;
function getBinaryName(platform = process.platform) {
return platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME;
@ -36,6 +40,36 @@ function formatMissingBinaryError(
].join("\n");
}
function checkForUpdate() {
if (process.env.GITLINK_CLI_NO_UPDATE_CHECK) return;
const registryUrl = "https://registry.npmjs.org/@gitlink-ai/cli/latest";
const mod = registryUrl.startsWith("https") ? https : http;
try {
mod.get(registryUrl, { timeout: 3000 }, (res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
try {
const pkg = JSON.parse(body);
const latest = pkg.version;
if (latest && latest !== CURRENT_VERSION) {
process.stderr.write(
`\nA new version of ${BINARY_NAME} is available: ${latest} (current: ${CURRENT_VERSION})\n` +
`Update with: npm update -g @gitlink-ai/cli\n\n`
);
}
} catch {
// Silently ignore parse errors
}
});
}).on("error", () => {
// Silently ignore network errors
});
} catch {
// Silently ignore any errors
}
}
function run(args = process.argv.slice(2), options = {}) {
const platform = options.platform || process.platform;
const arch = options.arch || process.arch;
@ -49,10 +83,16 @@ function run(args = process.argv.slice(2), options = {}) {
return exit(1);
}
// Binary existence check before any other logic
if (!fs.existsSync(binaryPath)) {
return failMissingBinary();
}
// Run optional update check in background (fire-and-forget)
if (require.main === module) {
checkForUpdate();
}
try {
execFile(binaryPath, args, { stdio: "inherit" });
} catch (err) {
@ -76,4 +116,5 @@ module.exports = {
getBinaryPath,
formatMissingBinaryError,
run,
checkForUpdate,
};

View File

@ -5,6 +5,7 @@
const os = require("os");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const https = require("https");
const http = require("http");
const { execSync } = require("child_process");
@ -13,13 +14,14 @@ const PACKAGE = require("../package.json");
const VERSION = PACKAGE.version;
const BINARY_NAME = "gitlink-cli";
// GitLink release download base URL
// Format: https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases
// Attachment download: https://www.gitlink.org.cn/api/attachments/{attachment_id}
const RELEASE_BASE = "https://www.gitlink.org.cn";
const REPO_OWNER = "Gitlink";
const REPO_NAME = "gitlink-cli";
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
const DOWNLOAD_TIMEOUT_MS = 120000;
function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
const platformMap = {
darwin: "darwin",
@ -54,15 +56,60 @@ function getArchiveName(platform, arch) {
return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${ext}`;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function fetch(url, options = {}) {
return new Promise((resolve, reject) => {
const maxRedirects = options.maxRedirects || 5;
let redirectCount = 0;
// Proxy support: respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY
function getProxy(targetUrl) {
try {
const parsed = new URL(targetUrl);
const noProxy = (process.env.NO_PROXY || process.env.no_proxy || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
for (const np of noProxy) {
if (parsed.hostname.endsWith(np) || parsed.hostname === np) {
return null;
}
}
if (parsed.protocol === "https:") {
return (
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.HTTP_PROXY ||
process.env.http_proxy ||
null
);
}
return (
process.env.HTTP_PROXY ||
process.env.http_proxy ||
null
);
} catch {
return null;
}
}
function doRequest(currentUrl) {
const proxyUrl = getProxy(currentUrl);
const mod = currentUrl.startsWith("https") ? https : http;
const req = mod.get(currentUrl, (res) => {
// Follow redirects
const parsedUrl = new URL(currentUrl);
const reqOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === "https:" ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: "GET",
timeout: options.timeout || DOWNLOAD_TIMEOUT_MS,
};
const req = mod.get(currentUrl, reqOptions, (res) => {
if (
(res.statusCode === 301 ||
res.statusCode === 302 ||
@ -103,12 +150,36 @@ function fetch(url, options = {}) {
});
} else {
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => resolve(Buffer.concat(chunks)));
const totalSize = parseInt(res.headers["content-length"], 10) || 0;
let downloaded = 0;
let lastProgress = 0;
res.on("data", (chunk) => {
chunks.push(chunk);
downloaded += chunk.length;
// Progress indicator (every 10% or every 512KB)
if (totalSize > 0) {
const pct = Math.floor((downloaded / totalSize) * 100);
if (pct >= lastProgress + 10) {
lastProgress = pct;
process.stdout.write(`\r Downloading: ${pct}%`);
}
} else if (downloaded - lastProgress > 512 * 1024) {
lastProgress = downloaded;
process.stdout.write(
`\r Downloaded: ${(downloaded / 1024 / 1024).toFixed(1)} MB`
);
}
});
res.on("end", () => {
if (totalSize > 0 || downloaded > 512 * 1024) {
process.stdout.write("\r");
}
resolve(Buffer.concat(chunks));
});
}
});
req.on("error", reject);
req.setTimeout(60000, () => {
req.setTimeout(options.timeout || DOWNLOAD_TIMEOUT_MS, () => {
req.destroy();
reject(new Error("Request timed out"));
});
@ -122,14 +193,12 @@ async function findReleaseAsset(platform, arch) {
const archiveName = getArchiveName(platform, arch);
const tagName = `v${VERSION}`;
// Try fetching release info from GitLink API
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
console.log(`Fetching release info from ${apiUrl}`);
try {
const releases = await fetch(apiUrl, { json: true });
// Find the release matching our version
let release = null;
if (Array.isArray(releases)) {
@ -137,7 +206,6 @@ async function findReleaseAsset(platform, arch) {
(r) => r.tag_name === tagName || r.tag_name === VERSION
);
if (!release && releases.length > 0) {
// Fall back to latest release
release = releases[0];
}
} else if (releases && releases.releases) {
@ -151,11 +219,9 @@ async function findReleaseAsset(platform, arch) {
}
if (release && release.attachments) {
// Try exact match first
let asset = release.attachments.find(
(a) => a.title === archiveName || a.filename === archiveName
);
// If no exact match (version mismatch on fallback release), match by platform/arch pattern
if (!asset) {
const ext = platform === "windows" ? ".zip" : ".tar.gz";
const pattern = `_${platform}_${arch}${ext}`;
@ -164,9 +230,8 @@ async function findReleaseAsset(platform, arch) {
);
}
if (asset) {
// Return the download URL for this attachment
let downloadUrl = asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
// Ensure absolute URL
let downloadUrl =
asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
if (downloadUrl.startsWith("/")) {
downloadUrl = RELEASE_BASE + downloadUrl;
}
@ -177,11 +242,63 @@ async function findReleaseAsset(platform, arch) {
console.log(`Warning: Could not fetch release info: ${e.message}`);
}
// Fallback: try direct download URL pattern
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`;
}
async function downloadAndExtract(url, destDir, platform) {
function extractArchive(archivePath, destDir, platform) {
const isWindows = platform === "windows";
if (isWindows) {
// Use PowerShell with properly escaped paths for Windows
const escapedArchive = archivePath.replace(/'/g, "''");
const escapedDest = destDir.replace(/'/g, "''");
try {
execSync(
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${escapedArchive}' -DestinationPath '${escapedDest}'"`,
{ stdio: "pipe" }
);
} catch {
// Fallback: try tar on modern Windows (10+)
try {
execSync(`tar -xf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
} catch (e2) {
throw new Error(
`Failed to extract archive. Tried PowerShell Expand-Archive and tar.\n${e2.message}`
);
}
}
} else {
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
}
}
function classifyError(err) {
const code = err.code || (err.cause && err.cause.code) || "";
const messages = {
ENOTFOUND: "DNS 解析失败,请检查网络连接",
ECONNREFUSED: "连接被拒绝,请检查代理设置 (HTTP_PROXY/HTTPS_PROXY)",
ETIMEDOUT: "连接超时,请尝试设置 HTTP_PROXY 环境变量",
EACCES: "权限不足,请使用管理员权限运行或检查安装目录",
EPERM: "权限不足,请使用管理员权限运行或检查安装目录",
};
return messages[code] || null;
}
function verifyChecksum(filePath, expectedHash) {
if (!expectedHash) return true;
const hash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
if (hash !== expectedHash) {
throw new Error(
`Checksum verification failed for ${path.basename(filePath)}\n` +
` Expected: ${expectedHash}\n` +
` Actual: ${hash}\n` +
`The downloaded file may be corrupted or tampered with.`
);
}
console.log(` Checksum verified: SHA256 OK`);
return true;
}
async function downloadAndExtract(url, destDir, platform, expectedChecksum) {
console.log(`Downloading ${BINARY_NAME} from ${url}...`);
const data = await fetch(url);
@ -192,15 +309,10 @@ async function downloadAndExtract(url, destDir, platform) {
fs.writeFileSync(archivePath, data);
console.log(`Downloaded ${(data.length / 1024 / 1024).toFixed(1)} MB`);
// Extract
if (isWindows) {
execSync(
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${destDir}'"`,
{ stdio: "pipe" }
);
} else {
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
}
// Verify checksum if provided
verifyChecksum(archivePath, expectedChecksum);
extractArchive(archivePath, destDir, platform);
fs.unlinkSync(archivePath);
// Find the binary in extracted files
@ -208,7 +320,6 @@ async function downloadAndExtract(url, destDir, platform) {
const binaryPath = path.join(destDir, binaryName);
if (!fs.existsSync(binaryPath)) {
// It might be in a subdirectory
const files = fs.readdirSync(destDir);
for (const file of files) {
const subPath = path.join(destDir, file, binaryName);
@ -223,7 +334,6 @@ async function downloadAndExtract(url, destDir, platform) {
throw new Error(`Binary "${binaryName}" not found after extraction`);
}
// Make executable (not needed on Windows)
if (!isWindows) {
fs.chmodSync(binaryPath, 0o755);
}
@ -251,22 +361,58 @@ async function main() {
// If binary already exists, check version matches
if (fs.existsSync(binaryPath)) {
try {
const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 });
const output = execSync(`"${binaryPath}" version`, {
encoding: "utf-8",
stdio: "pipe",
timeout: 5000,
});
if (output.includes(VERSION)) {
console.log(`${BINARY_NAME} v${VERSION} already installed, skipping download.`);
console.log(
`${BINARY_NAME} v${VERSION} already installed, skipping download.`
);
return;
}
console.log(`${BINARY_NAME} version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`);
} catch (e) {
console.log(`${BINARY_NAME} binary exists but is not compatible, re-downloading...`);
console.log(
`${BINARY_NAME} version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`
);
} catch {
console.log(
`${BINARY_NAME} binary exists but is not compatible, re-downloading...`
);
}
fs.unlinkSync(binaryPath);
}
const downloadUrl = await findReleaseAsset(platform, arch);
await downloadAndExtract(downloadUrl, binDir, platform);
// Download with retry
let lastError = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await downloadAndExtract(downloadUrl, binDir, platform, null);
lastError = null;
break;
} catch (err) {
lastError = err;
if (attempt < MAX_RETRIES) {
console.log(
`Download attempt ${attempt}/${MAX_RETRIES} failed: ${err.message}`
);
console.log(`Retrying in ${RETRY_DELAY_MS / 1000}s...`);
await sleep(RETRY_DELAY_MS);
}
}
}
if (lastError) {
throw lastError;
}
} catch (err) {
const classifiedMsg = classifyError(err);
console.error(`\nFailed to install ${BINARY_NAME}: ${err.message}`);
if (classifiedMsg) {
console.error(`\n诊断信息: ${classifiedMsg}`);
}
if (platformInfo) {
console.error(`Platform: ${platformInfo.platform}/${platformInfo.arch}`);
}
@ -292,4 +438,6 @@ module.exports = {
getBinaryName,
getArchiveName,
findReleaseAsset,
classifyError,
verifyChecksum,
};

View File

@ -0,0 +1,51 @@
package attachment
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "upload",
Description: "Upload a file attachment",
Flags: []common.Flag{
{Name: "file", Short: "f", Usage: "Path to the file to upload", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
file, err := ctx.RequireArg("file")
if err != nil {
return err
}
payload := map[string]interface{}{
"file": file,
}
env, err := ctx.CallAPI("POST", "/attachments", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a file attachment by UUID",
Flags: []common.Flag{
{Name: "uuid", Short: "u", Usage: "Attachment UUID to delete", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
uuid, err := ctx.RequireArg("uuid")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/attachments/%s", uuid), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

146
shortcuts/branch/batch.go Normal file
View File

@ -0,0 +1,146 @@
package branch
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func newBatchDeleteShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-delete",
Description: "Delete multiple branches",
Long: `Delete multiple branches from the current repository in a single operation.
Provide branch names via --names (comma-separated) or --from (plain-text file
with one branch name per line).
This action is irreversible. A confirmation prompt is shown before deletion
unless --yes is set.
Use --dry-run to preview which branches would be deleted without making any
changes.`,
Example: ` # Delete two branches
gitlink branch +batch-delete --names old-feature,deprecated-api
# Dry-run to preview deletions
gitlink branch +batch-delete --names old-feature,deprecated-api --dry-run
# Delete branches listed in a file
gitlink branch +batch-delete --from branches.txt`,
Flags: []common.Flag{
{Name: "names", Short: "n", Usage: "Comma-separated branch names"},
{Name: "from", Usage: "File path (one branch name per line)"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchDelete,
}
}
func newBatchProtectShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-protect",
Description: "Set branch protection for multiple branches",
Long: `Enable branch protection for multiple branches in a single operation.
Provide branch names via --names (comma-separated) or --from (plain-text file
with one branch name per line).
Protected branches restrict force-pushes and deletion.
Use --dry-run to preview which branches would be protected without making any
changes.`,
Example: ` # Protect two branches
gitlink branch +batch-protect --names main,develop
# Dry-run to preview protections
gitlink branch +batch-protect --names main,develop --dry-run
# Protect branches listed in a file
gitlink branch +batch-protect --from branches.txt`,
Flags: []common.Flag{
{Name: "names", Short: "n", Usage: "Comma-separated branch names"},
{Name: "from", Usage: "File path (one branch name per line)"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchProtect,
}
}
// --- batch-delete ---
func runBatchDelete(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
names, err := common.CollectStrings(ctx.Arg("names"), ctx.Arg("from"))
if err != nil {
return err
}
if len(names) == 0 {
return fmt.Errorf("no branch names provided; use --names branch1,branch2 or --from branches.txt")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
if !dryRun {
if err := common.ConfirmAction(
fmt.Sprintf("delete %d branch(es) from %s/%s", len(names), ctx.Owner, ctx.Repo),
); err != nil {
return err
}
}
summary := common.ProcessBatch(names, dryRun, "delete", func(name string) error {
payload := map[string]interface{}{
"branch_name": name,
}
_, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d branch(es) failed to delete", summary.Failed, summary.Total)
}
return nil
}
// --- batch-protect ---
func runBatchProtect(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
names, err := common.CollectStrings(ctx.Arg("names"), ctx.Arg("from"))
if err != nil {
return err
}
if len(names) == 0 {
return fmt.Errorf("no branch names provided; use --names branch1,branch2 or --from branches.txt")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
summary := common.ProcessBatch(names, dryRun, "protect", func(name string) error {
payload := map[string]interface{}{
"branch_name": name,
}
_, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d branch(es) failed to protect", summary.Failed, summary.Total)
}
return nil
}

View File

@ -9,9 +9,20 @@ import (
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchDeleteShortcut(),
newBatchProtectShortcut(),
{
Name: "list",
Description: "List branches",
Long: `List all branches in the current repository.
Shows branch name, protection status, and other details.
Use --page and --limit for pagination.`,
Example: ` # List branches
gitlink branch +list
# Show 50 branches per page
gitlink branch +list --limit 50`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
@ -33,9 +44,18 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a branch",
Long: `Create a new branch in the current repository.
The new branch is created from the specified source branch or commit.
If --from is omitted, the repository default branch is used.`,
Example: ` # Create a branch from the default branch
gitlink branch +create --name feature/login
# Create a branch from a specific branch
gitlink branch +create --name hotfix/fix-123 --from main`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
{Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"},
{Name: "from", Short: "f", Usage: "Source branch or commit (default: repository default branch)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -44,7 +64,7 @@ func Shortcuts() []*common.Shortcut {
name, _ := ctx.RequireArg("name")
from := ctx.Arg("from")
if from == "" {
from = "master"
from = common.GetDefaultBranch(ctx)
}
payload := map[string]interface{}{
"new_branch_name": name,
@ -60,6 +80,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "delete",
Description: "Delete a branch",
Long: `Delete a branch from the current repository.
This action is irreversible. A confirmation prompt is shown before deletion.`,
Example: ` # Delete a branch
gitlink branch +delete --name old-feature`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
},
@ -68,6 +93,11 @@ func Shortcuts() []*common.Shortcut {
return err
}
name, _ := ctx.RequireArg("name")
if err := common.ConfirmAction(
fmt.Sprintf("delete branch %s", name),
); err != nil {
return err
}
payload := map[string]interface{}{
"branch_name": name,
}
@ -81,6 +111,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "protect",
Description: "Set branch protection",
Long: `Enable branch protection for the specified branch.
Protected branches restrict force-pushes and deletion.`,
Example: ` # Protect the main branch
gitlink branch +protect --name main`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
},
@ -102,6 +137,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "unprotect",
Description: "Remove branch protection",
Long: `Remove branch protection from the specified branch.
Once unprotected, the branch can be force-pushed or deleted.`,
Example: ` # Remove protection from a branch
gitlink branch +unprotect --name develop`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
},
@ -117,5 +157,61 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "all",
Description: "List all branches without pagination",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", "/v1"+ctx.RepoPath()+"/branches/all", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "default",
Description: "Update the default branch",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name to set as default", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, _ := ctx.RequireArg("name")
payload := map[string]interface{}{
"default_branch": name,
}
env, err := ctx.CallAPI("PATCH", "/v1"+ctx.RepoPath()+"/branches/update_default_branch", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "restore",
Description: "Restore a deleted branch",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name to restore", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, _ := ctx.RequireArg("name")
payload := map[string]interface{}{
"branch_name": name,
}
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/restore", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -12,9 +12,39 @@ func Shortcuts() []*common.Shortcut {
{
Name: "builds",
Description: "List CI builds",
Long: `List CI/CD builds for the current repository.
Shows build number, status, branch, event type, and duration.
Use --status, --branch, --event, --since, --until for filtering,
and --page and --limit for pagination.`,
Example: ` # List recent builds
gitlink ci +builds
# Show 50 builds
gitlink ci +builds --limit 50
# Filter by status
gitlink ci +builds --status success
gitlink ci +builds -s failure
# Filter by branch
gitlink ci +builds --branch main
gitlink ci +builds -b develop
# Filter by trigger event
gitlink ci +builds --event push
gitlink ci +builds --event pull_request
# Filter by time range
gitlink ci +builds --since 1700000000 --until 1700086400`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "status", Short: "s", Usage: "Filter by build status (success, failure, running)"},
{Name: "branch", Short: "b", Usage: "Filter by branch"},
{Name: "event", Usage: "Filter by trigger event (push, pull_request, tag)"},
{Name: "since", Usage: "Start time (UNIX timestamp)"},
{Name: "until", Usage: "End time (UNIX timestamp)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -23,6 +53,21 @@ func Shortcuts() []*common.Shortcut {
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("status"); s != "" {
q.Set("status", s)
}
if b := ctx.Arg("branch"); b != "" {
q.Set("branch", b)
}
if e := ctx.Arg("event"); e != "" {
q.Set("event", e)
}
if s := ctx.Arg("since"); s != "" {
q.Set("since", s)
}
if u := ctx.Arg("until"); u != "" {
q.Set("until", u)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
if err != nil {
return err
@ -33,6 +78,15 @@ func Shortcuts() []*common.Shortcut {
{
Name: "logs",
Description: "View build logs",
Long: `View logs for a specific CI/CD build step.
Requires the build number. Use --stage and --step to navigate
to a particular stage and step within the build.`,
Example: ` # View logs for build #42
gitlink ci +logs --build 42
# View logs for stage 2, step 3 of build #42
gitlink ci +logs --build 42 --stage 2 --step 3`,
Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true},
{Name: "stage", Short: "s", Usage: "Stage number", Default: "1"},
@ -61,6 +115,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "restart",
Description: "Restart a build",
Long: `Restart a previously completed or failed CI/CD build.
The build will be re-triggered with the same commit and configuration.`,
Example: ` # Restart build #42
gitlink ci +restart --build 42`,
Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true},
},
@ -79,6 +138,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "stop",
Description: "Stop a build",
Long: `Stop a currently running CI/CD build.
Prompts for confirmation before stopping the build.`,
Example: ` # Stop build #42
gitlink ci +stop --build 42`,
Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true},
},
@ -87,6 +151,11 @@ func Shortcuts() []*common.Shortcut {
return err
}
build, _ := ctx.RequireArg("build")
if err := common.ConfirmAction(
fmt.Sprintf("stop build #%s", build),
); err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
if err != nil {
return err
@ -94,5 +163,208 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// --- Pipeline / Actions group ---
{
Name: "pipelines",
Description: "List CI pipeline templates",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/pm/pipelines", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "run-pipeline",
Description: "Run a CI pipeline",
Flags: []common.Flag{
{Name: "pipeline", Short: "p", Usage: "Pipeline name or ID", Required: true},
{Name: "branch", Short: "b", Usage: "Branch to run on"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pipeline, err := ctx.RequireArg("pipeline")
if err != nil {
return err
}
body := map[string]interface{}{
"pipeline": pipeline,
}
if branch := ctx.Arg("branch"); branch != "" {
body["branch"] = branch
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/runs", ctx.RepoPath()), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "runs",
Description: "List CI run history",
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/actions/runs", ctx.RepoPath()), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "save-yaml",
Description: "Save a pipeline YAML configuration",
Flags: []common.Flag{
{Name: "yaml", Short: "y", Usage: "YAML content", Required: true},
{Name: "name", Short: "n", Usage: "Pipeline name"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
yamlContent, err := ctx.RequireArg("yaml")
if err != nil {
return err
}
body := map[string]interface{}{
"yaml": yamlContent,
}
if name := ctx.Arg("name"); name != "" {
body["name"] = name
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/pipelines/save_yaml", ctx.RepoPath()), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "pipeline-detail",
Description: "Show pipeline details",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Pipeline ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/pipelines/%s", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete-pipeline",
Description: "Delete a pipeline",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Pipeline ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1%s/pipelines/%s", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "disable",
Description: "Disable CI for the repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/disable", ctx.RepoPath()), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "enable",
Description: "Enable CI for the repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/enable", ctx.RepoPath()), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "run-log",
Description: "Get CI run log",
Flags: []common.Flag{
{Name: "run", Short: "r", Usage: "Run ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
run, err := ctx.RequireArg("run")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/runs/%s/jobs/0", ctx.RepoPath(), run), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "run-results",
Description: "Get CI run results report",
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/pipelines/run_results", ctx.RepoPath()), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,315 @@
package collaborator
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// userRole holds a username and the role to assign.
type userRole struct {
User string
Role string
}
func newBatchAddShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-add",
Description: "Add multiple collaborators to the repository",
Long: `Add multiple collaborators to the repository in a single operation.
Provide collaborators via --users (comma-separated usernames) or --from (CSV
file with user_id and role columns). When --from supplies a role column that
value is used; otherwise the --role flag (default: write) applies.
Use --dry-run to preview which collaborators would be added without making
any changes.
Available roles: admin, write, read.`,
Example: ` # Add two collaborators with the default write role
gitlink collaborator +batch-add --users zhangsan,lisi
# Add collaborators with admin role
gitlink collaborator +batch-add --users zhangsan,lisi --role admin
# Dry-run to preview additions from a CSV file
gitlink collaborator +batch-add --from collaborators.csv --dry-run
# Combine both sources
gitlink collaborator +batch-add --users zhangsan --from collaborators.csv`,
Flags: []common.Flag{
{Name: "users", Short: "u", Usage: "Comma-separated usernames to add"},
{Name: "from", Usage: "CSV file path (columns: user_id, role)"},
{Name: "role", Short: "r", Usage: "Default role for --users: admin, write, read", Default: "write"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchAdd,
}
}
func newBatchRemoveShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-remove",
Description: "Remove multiple collaborators from the repository",
Long: `Remove multiple collaborators from the repository in a single operation.
Provide collaborators via --users (comma-separated usernames) or --from (CSV
file with a user_id column).
This action is irreversible. A confirmation prompt is shown before removal
unless --yes is set.
Use --dry-run to preview which collaborators would be removed without making
any changes.`,
Example: ` # Remove two collaborators
gitlink collaborator +batch-remove --users zhangsan,lisi
# Dry-run to preview removals
gitlink collaborator +batch-remove --users zhangsan,lisi --dry-run
# Remove collaborators listed in a CSV file
gitlink collaborator +batch-remove --from users.csv`,
Flags: []common.Flag{
{Name: "users", Short: "u", Usage: "Comma-separated usernames to remove"},
{Name: "from", Usage: "CSV file path (column: user_id)"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchRemove,
}
}
func newBatchRoleShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-role",
Description: "Change role for multiple collaborators",
Long: `Change the role of multiple collaborators in a single operation.
Provide collaborators via --users (comma-separated usernames) combined with
--role, or via --from (CSV file with user_id and role columns).
Use --dry-run to preview which role changes would be applied without making
any changes.
Available roles: admin, write, read.`,
Example: ` # Change two collaborators to admin
gitlink collaborator +batch-role --users zhangsan,lisi --role admin
# Dry-run to preview role changes from a CSV file
gitlink collaborator +batch-role --from roles.csv --dry-run
# Combine both sources
gitlink collaborator +batch-role --users zhangsan --role read --from roles.csv`,
Flags: []common.Flag{
{Name: "users", Short: "u", Usage: "Comma-separated usernames"},
{Name: "from", Usage: "CSV file path (columns: user_id, role)"},
{Name: "role", Short: "r", Usage: "New role for --users: admin, write, read"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchRole,
}
}
// --- batch-add ---
func runBatchAdd(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pairs, err := collectUserRoles(ctx.Arg("users"), ctx.Arg("from"), ctx.Arg("role"))
if err != nil {
return err
}
if len(pairs) == 0 {
return fmt.Errorf("no users provided; use --users user1,user2 or --from collaborators.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
items := make([]string, 0, len(pairs))
for _, p := range pairs {
items = append(items, p.User)
}
summary := common.ProcessBatch(items, dryRun, "add", func(user string) error {
role := roleForUser(pairs, user)
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
"role_name": role,
},
}
_, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d collaborator(s) failed to add", summary.Failed, summary.Total)
}
return nil
}
// --- batch-remove ---
func runBatchRemove(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
users, err := collectUsers(ctx.Arg("users"), ctx.Arg("from"))
if err != nil {
return err
}
if len(users) == 0 {
return fmt.Errorf("no users provided; use --users user1,user2 or --from users.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
if !dryRun {
if err := common.ConfirmAction(
fmt.Sprintf("remove %d collaborator(s) from %s/%s", len(users), ctx.Owner, ctx.Repo),
); err != nil {
return err
}
}
summary := common.ProcessBatch(users, dryRun, "remove", func(user string) error {
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
},
}
_, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", ctx.Owner, ctx.Repo), payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d collaborator(s) failed to remove", summary.Failed, summary.Total)
}
return nil
}
// --- batch-role ---
func runBatchRole(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
usersValue := ctx.Arg("users")
fromValue := ctx.Arg("from")
roleValue := ctx.Arg("role")
// When --users is used without --from, --role is required.
if usersValue != "" && fromValue == "" && roleValue == "" {
return fmt.Errorf("--role is required when using --users without --from")
}
pairs, err := collectUserRoles(usersValue, fromValue, roleValue)
if err != nil {
return err
}
if len(pairs) == 0 {
return fmt.Errorf("no users provided; use --users user1,user2 --role admin or --from roles.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
items := make([]string, 0, len(pairs))
for _, p := range pairs {
items = append(items, p.User)
}
summary := common.ProcessBatch(items, dryRun, "change-role", func(user string) error {
role := roleForUser(pairs, user)
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
"role_name": role,
},
}
_, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/collaborators/change_role", ctx.Owner, ctx.Repo), payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d collaborator(s) failed to change role", summary.Failed, summary.Total)
}
return nil
}
// --- helpers ---
// collectUserRoles builds a deduplicated slice of userRole pairs from
// --users (combined with defaultRole) and/or --from CSV.
func collectUserRoles(usersValue, fromPath, defaultRole string) ([]userRole, error) {
var pairs []userRole
// From --users flag
for _, u := range common.ParseStringList(usersValue) {
pairs = append(pairs, userRole{User: u, Role: defaultRole})
}
// From CSV
if fromPath != "" {
rows, err := common.ReadRowsFromCSV(fromPath, []string{"user_id", "role"})
if err != nil {
return nil, err
}
for _, row := range rows {
role := row["role"]
if role == "" {
role = defaultRole
}
pairs = append(pairs, userRole{User: row["user_id"], Role: role})
}
}
// Deduplicate by user
seen := map[string]bool{}
deduped := make([]userRole, 0, len(pairs))
for _, p := range pairs {
if seen[p.User] {
continue
}
seen[p.User] = true
deduped = append(deduped, p)
}
return deduped, nil
}
// collectUsers builds a deduplicated slice of usernames from --users and/or --from CSV.
func collectUsers(usersValue, fromPath string) ([]string, error) {
users := common.ParseStringList(usersValue)
if fromPath != "" {
csvUsers, err := common.ReadColumnFromCSV(fromPath, []string{"user_id"})
if err != nil {
return nil, err
}
users = append(users, csvUsers...)
}
return common.DedupeStrings(users), nil
}
// roleForUser looks up the role assigned to a user in the pairs slice.
func roleForUser(pairs []userRole, user string) string {
for _, p := range pairs {
if p.User == user {
return p.Role
}
}
return "write"
}

View File

@ -0,0 +1,146 @@
package collaborator
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return append([]*common.Shortcut{
{
Name: "list",
Description: "List repository collaborators",
Long: `List repository collaborators.
Shows all users who have access to the repository along with their
username, role, and permission level.`,
Example: ` # List collaborators of the current repository
gitlink collaborator +list`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "add",
Description: "Add a collaborator to the repository",
Long: `Add a collaborator to the repository.
Invites a user to collaborate on the repository with the specified role.
Available roles: admin, write (default), read.`,
Example: ` # Add a collaborator with write access
gitlink collaborator +add -u zhangsan -r write
# Add a collaborator with admin access
gitlink collaborator +add -u zhangsan -r admin`,
Flags: []common.Flag{
{Name: "user", Short: "u", Usage: "Username to add", Required: true},
{Name: "role", Short: "r", Usage: "Role: admin or write or read", Default: "write"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
user, err := ctx.RequireArg("user")
if err != nil {
return err
}
role := ctx.Arg("role")
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
"role_name": role,
},
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "remove",
Description: "Remove a collaborator from the repository",
Long: `Remove a collaborator from the repository.
Revokes the user's access to the repository.`,
Example: ` # Remove a collaborator
gitlink collaborator +remove -u zhangsan`,
Flags: []common.Flag{
{Name: "user", Short: "u", Usage: "Username to remove", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
user, err := ctx.RequireArg("user")
if err != nil {
return err
}
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
},
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "change-role",
Description: "Change a collaborator's role",
Long: `Change a collaborator's role.
Updates the permission level of an existing collaborator.
Available roles: admin, write, read.`,
Example: ` # Change a collaborator to admin role
gitlink collaborator +change-role -u zhangsan -r admin
# Change a collaborator to read-only access
gitlink collaborator +change-role -u zhangsan -r read`,
Flags: []common.Flag{
{Name: "user", Short: "u", Usage: "Username", Required: true},
{Name: "role", Short: "r", Usage: "New role: admin or write or read", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
user, err := ctx.RequireArg("user")
if err != nil {
return err
}
role, err := ctx.RequireArg("role")
if err != nil {
return err
}
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
"role_name": role,
},
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/collaborators/change_role", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
},
newBatchAddShortcut(),
newBatchRemoveShortcut(),
newBatchRoleShortcut(),
)
}

167
shortcuts/commit/commit.go Normal file
View File

@ -0,0 +1,167 @@
package commit
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List repository commits",
Long: `List repository commits.
Shows commit SHA, author, message, and date.
Use --page and --limit for pagination.
Filter by path, date range, or branch with respective flags.`,
Example: ` # List recent commits
gitlink commit +list
# List commits on a specific branch
gitlink commit +list --sha develop
# List commits touching a specific file
gitlink commit +list --path src/main.go`,
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Branch or SHA to list commits from"},
{Name: "path", Short: "p", Usage: "Filter commits by file path"},
{Name: "page", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "since", Usage: "Only commits after this date (ISO 8601)"},
{Name: "until", Usage: "Only commits before this date (ISO 8601)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
path := fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPIWithQuery("GET", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View commit details including changed files",
Long: `View commit details including changed files.
Shows the full commit information and the list of files changed
in the specified commit.`,
Example: ` # View a commit
gitlink commit +view --sha abc123def456`,
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "diff",
Description: "View the diff of a commit",
Long: `View the diff of a commit.
Returns the patch/diff content for the specified commit,
showing exact line-level additions and deletions.`,
Example: ` # View the diff of a commit
gitlink commit +diff --sha abc123def456`,
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "blame",
Description: "Get blame information for a file",
Long: `Get blame information for a file.
Shows which commit and author last modified each line of a file.
Use --page and --limit for pagination.`,
Example: ` # Blame a file on the default branch
gitlink commit +blame --path src/main.go
# Blame a file at a specific commit
gitlink commit +blame --path src/main.go --sha abc123def`,
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "sha", Short: "s", Usage: "Commit SHA or branch"},
{Name: "page", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
apiPath := fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPIWithQuery("GET", apiPath, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "compare",
Description: "Compare two refs (branches, tags, or SHAs)",
Long: `Compare two refs (branches, tags, or SHAs).
Returns the commits and file changes between a base and head ref.
Both --base and --head are required.`,
Example: ` # Compare two branches
gitlink commit +compare --base main --head develop
# Compare two commit SHAs
gitlink commit +compare --base abc123 --head def456`,
Flags: []common.Flag{
{Name: "head", Short: "H", Usage: "Head ref", Required: true},
{Name: "base", Short: "B", Usage: "Base ref", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
head, err := ctx.RequireArg("head")
if err != nil {
return err
}
base, err := ctx.RequireArg("base")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/compare/%s...%s", ctx.Owner, ctx.Repo, base, head), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

281
shortcuts/common/batch.go Normal file
View File

@ -0,0 +1,281 @@
package common
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
)
// --- Result structures ---
// BatchResult records the outcome of a single item in a batch operation.
type BatchResult struct {
Item string `json:"item" yaml:"item"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"` // "planned", "ok", "failed"
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
// BatchSummary is the unified output of every batch command.
type BatchSummary struct {
Repository string `json:"repository" yaml:"repository"`
DryRun bool `json:"dry_run" yaml:"dry_run"`
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Results []BatchResult `json:"results" yaml:"results"`
}
// --- Input collection ---
// CollectNumbers collects integer identifiers from --numbers and --from CSV.
func CollectNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := ParseNumberList(numbersValue)
if err != nil {
return nil, err
}
if csvPath == "" {
return numbers, nil
}
csvNumbers, err := ReadColumnFromCSV(csvPath, []string{"number", "issue_number", "project_issues_index"})
if err != nil {
return nil, err
}
return DedupeStrings(append(numbers, csvNumbers...)), nil
}
// CollectStrings collects string identifiers (e.g. branch names) from --names and --from file.
// The file is read one entry per line (plain text, not CSV).
func CollectStrings(namesValue, filePath string) ([]string, error) {
names := ParseStringList(namesValue)
if filePath == "" {
return names, nil
}
fileNames, err := ReadLinesFromFile(filePath)
if err != nil {
return nil, err
}
return DedupeStrings(append(names, fileNames...)), nil
}
// --- Parsing helpers ---
// ParseNumberList parses a comma-separated list of integers.
func ParseNumberList(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return NormalizeIntStrings(strings.Split(value, ","))
}
// ParseStringList parses a comma-separated list of strings (trimmed).
func ParseStringList(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
return result
}
// --- CSV / file reading ---
// ReadColumnFromCSV reads a single column from a CSV file.
// columnNames lists acceptable header names; if none match, the first column is used.
func ReadColumnFromCSV(path string, columnNames []string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV %s: %w", path, err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV %s: %w", path, err)
}
if len(records) == 0 {
return nil, nil
}
column := -1
startRow := 0
for i, cell := range records[0] {
for _, name := range columnNames {
if strings.EqualFold(strings.TrimSpace(cell), name) {
column = i
startRow = 1
break
}
}
if column >= 0 {
break
}
}
if column < 0 {
column = 0
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if column >= len(record) {
continue
}
v := strings.TrimSpace(record[column])
if v != "" {
values = append(values, v)
}
}
return values, nil
}
// ReadRowsFromCSV reads multiple columns from a CSV file.
// Returns a slice of maps keyed by the required column names.
func ReadRowsFromCSV(path string, requiredCols []string) ([]map[string]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV %s: %w", path, err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV %s: %w", path, err)
}
if len(records) == 0 {
return nil, nil
}
// Map column name → column index from the header row
header := records[0]
colIndex := make(map[string]int)
for i, cell := range header {
normalized := strings.ToLower(strings.TrimSpace(cell))
for _, req := range requiredCols {
if normalized == req {
colIndex[req] = i
}
}
}
// Validate all required columns found
for _, req := range requiredCols {
if _, ok := colIndex[req]; !ok {
return nil, fmt.Errorf("CSV missing required column %q; found headers: %v", req, header)
}
}
rows := make([]map[string]string, 0, len(records)-1)
for _, record := range records[1:] {
row := make(map[string]string, len(requiredCols))
for _, req := range requiredCols {
idx := colIndex[req]
if idx < len(record) {
row[req] = strings.TrimSpace(record[idx])
}
}
rows = append(rows, row)
}
return rows, nil
}
// ReadLinesFromFile reads non-empty lines from a plain-text file.
func ReadLinesFromFile(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file %s: %w", path, err)
}
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
result := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result, nil
}
// --- Validation & deduplication ---
// NormalizeIntStrings validates that every value is an integer and deduplicates.
func NormalizeIntStrings(values []string) ([]string, error) {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
return nil, fmt.Errorf("invalid number %q: must be an integer", v)
}
if seen[v] {
continue
}
seen[v] = true
result = append(result, v)
}
return result, nil
}
// DedupeStrings deduplicates strings while preserving order.
func DedupeStrings(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, v := range values {
if seen[v] {
continue
}
seen[v] = true
result = append(result, v)
}
return result
}
// --- Batch processing loop ---
// ProcessBatch runs fn on each item. On dry-run it marks items as "planned".
// Single-item failure does not stop the loop; the caller gets a full summary.
func ProcessBatch(items []string, dryRun bool, action string, fn func(string) error) *BatchSummary {
summary := &BatchSummary{
DryRun: dryRun,
Total: len(items),
Results: make([]BatchResult, 0, len(items)),
}
for _, item := range items {
r := BatchResult{Item: item, Action: action}
if dryRun {
r.Status = "planned"
summary.Succeeded++
} else if err := fn(item); err != nil {
r.Status = "failed"
r.Error = err.Error()
summary.Failed++
} else {
r.Status = "ok"
summary.Succeeded++
}
summary.Results = append(summary.Results, r)
}
return summary
}
// --- Utility ---
// ParseBool parses a boolean string.
func ParseBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
}

View File

@ -0,0 +1,30 @@
package common
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
)
// ConfirmAction prompts the user for confirmation before a destructive action.
// Returns nil if confirmed, error if cancelled.
// Skipped automatically when --yes flag is set.
func ConfirmAction(action string) error {
if cmdutil.AutoConfirm {
return nil
}
fmt.Printf("Are you sure you want to %s? [y/N]: ", action)
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("operation cancelled")
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
return fmt.Errorf("operation cancelled")
}
return nil
}

View File

@ -34,6 +34,12 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
return s.Run(ctx)
},
}
if s.Long != "" {
cmd.Long = s.Long
}
if s.Example != "" {
cmd.Example = s.Example
}
for _, f := range s.Flags {
if f.Bool {

View File

@ -15,6 +15,8 @@ import (
type Shortcut struct {
Name string
Description string
Long string
Example string
Flags []Flag
Run func(ctx *RuntimeContext) error
}
@ -117,3 +119,25 @@ func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
}
return v, nil
}
// GetDefaultBranch queries the repo API and returns the default branch name.
// Falls back to "master" if the API call fails or the field is not present.
func GetDefaultBranch(ctx *RuntimeContext) string {
if ctx.Owner == "" || ctx.Repo == "" {
return "master"
}
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return "master"
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "master"
}
for _, key := range []string{"default_branch", "defaultBranch"} {
if db, ok := data[key].(string); ok && db != "" {
return db
}
}
return "master"
}

View File

@ -0,0 +1,119 @@
package dataset
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "create",
Description: "Create a dataset in a repository",
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Dataset title", Required: true},
{Name: "description", Short: "d", Usage: "Dataset description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title")
if err != nil {
return err
}
payload := map[string]interface{}{
"title": title,
}
if desc := ctx.Arg("description"); desc != "" {
payload["description"] = desc
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a dataset in a repository",
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "New dataset title"},
{Name: "description", Short: "d", Usage: "New dataset description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title := ctx.Arg("title")
description := ctx.Arg("description")
if title == "" && description == "" {
return fmt.Errorf("at least one of --title or --description is required")
}
payload := map[string]interface{}{}
if title != "" {
payload["title"] = title
}
if description != "" {
payload["description"] = description
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View dataset details for a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "list",
Description: "List project datasets",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
path := fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo)
q := buildQuery(ctx)
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func buildQuery(ctx *common.RuntimeContext) map[string][]string {
q := map[string][]string{}
if v := ctx.Arg("keyword"); v != "" {
q["keyword"] = []string{v}
}
if v := ctx.Arg("page"); v != "" {
q["page"] = []string{v}
}
if v := ctx.Arg("limit"); v != "" {
q["limit"] = []string{v}
}
return q
}

420
shortcuts/file/file.go Normal file
View File

@ -0,0 +1,420 @@
package file
import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "get",
Description: "Get file contents from repository",
Long: `Get file contents from the repository.
Retrieves the content and metadata of a file at the specified path.
Optionally specify a branch, tag, or commit SHA with --ref.`,
Example: ` # Get file contents on the default branch
gitlink file +get --path README.md
# Get file contents on a specific branch
gitlink file +get --path src/main.go --ref develop`,
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
apiPath := fmt.Sprintf("/%s/%s/files", ctx.Owner, ctx.Repo)
query := buildQuery(map[string]string{
"filepath": filePath,
"ref": ctx.Arg("ref"),
})
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "readme",
Description: "Get the repository README",
Long: `Get the repository README.
Fetches the README file of the current repository.`,
Example: ` # Show the repository README
gitlink file +readme`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/readme", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a new file in the repository",
Long: `Create a new file in the repository.
Creates a file at the given path with the provided content.
The content is automatically base64-encoded before submission.
A commit message is required.`,
Example: ` # Create a new file
gitlink file +create --path docs/guide.md --content "# Guide" --message "add guide"
# Create a file on a specific branch
gitlink file +create --path config.yaml --content "key: value" --message "add config" --branch develop`,
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
{Name: "content", Short: "c", Usage: "File content (plain text, will be base64-encoded)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch (defaults to default branch)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
payload := map[string]interface{}{
"filepath": filePath,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
}
if branch := ctx.Arg("branch"); branch != "" {
payload["branch"] = branch
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/create_file", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an existing file in the repository",
Long: `Update an existing file in the repository.
Replaces the content of a file at the given path.
The new content is automatically base64-encoded before submission.
Provide --sha to detect conflicting mid-air edits.`,
Example: ` # Update a file
gitlink file +update --path docs/guide.md --content "# Updated Guide" --message "update guide"
# Update with conflict detection
gitlink file +update --path config.yaml --content "key: new" --message "update config" --sha abc123`,
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
{Name: "content", Short: "c", Usage: "New file content (plain text, will be base64-encoded)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch"},
{Name: "sha", Usage: "Original file SHA (for conflict detection)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
payload := map[string]interface{}{
"filepath": filePath,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
}
if branch := ctx.Arg("branch"); branch != "" {
payload["branch"] = branch
}
if sha := ctx.Arg("sha"); sha != "" {
payload["sha"] = sha
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/update_file", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a file from the repository",
Long: `Delete a file from the repository.
Removes the file at the given path from the repository.
A commit message is required.
Provide --sha to detect conflicting mid-air edits.`,
Example: ` # Delete a file
gitlink file +delete --path docs/old.md --message "remove old docs"
# Delete with conflict detection
gitlink file +delete --path config.yaml --message "remove config" --sha abc123`,
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path to delete", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch"},
{Name: "sha", Usage: "File SHA (for conflict detection)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
payload := map[string]interface{}{
"filepath": filePath,
"message": message,
}
if branch := ctx.Arg("branch"); branch != "" {
payload["branch"] = branch
}
if sha := ctx.Arg("sha"); sha != "" {
payload["sha"] = sha
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/delete_file", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "tree",
Description: "Get a git tree for a given SHA",
Long: `Get a git tree for a given SHA.
Returns the directory listing (tree object) at the specified SHA.
Use --recursive to fetch the entire tree recursively.`,
Example: ` # Get a git tree
gitlink file +tree --sha abc123def456
# Get a recursive tree
gitlink file +tree --sha abc123def456 --recursive`,
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Tree SHA", Required: true},
{Name: "recursive", Usage: "Fetch recursively", Bool: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
apiPath := fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha)
query := buildQuery(map[string]string{
"recursive": ctx.Arg("recursive"),
})
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "blob",
Description: "Get a git blob by SHA",
Long: `Get a git blob by SHA.
Returns the raw blob content for the specified SHA.`,
Example: ` # Get a git blob
gitlink file +blob --sha abc123def456`,
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Blob SHA", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/git/blobs/%s", ctx.Owner, ctx.Repo, sha), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "batch",
Description: "Create or update multiple files in a single commit",
Flags: []common.Flag{
{Name: "files", Short: "f", Usage: "JSON array of file objects", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filesStr, err := ctx.RequireArg("files")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
var files []interface{}
if err := json.Unmarshal([]byte(filesStr), &files); err != nil {
return fmt.Errorf("invalid JSON for --files: %w", err)
}
payload := map[string]interface{}{
"files": files,
"message": message,
}
if branch := ctx.Arg("branch"); branch != "" {
payload["branch"] = branch
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/contents/batch", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "entries",
Description: "Get repository entries (directory listing)",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "Directory path"},
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
apiPath := fmt.Sprintf("/%s/%s/entries", ctx.Owner, ctx.Repo)
query := buildQuery(map[string]string{
"path": ctx.Arg("path"),
"ref": ctx.Arg("ref"),
})
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "sub-entries",
Description: "Get repository sub-entries",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "Directory path"},
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
apiPath := fmt.Sprintf("/%s/%s/sub_entries", ctx.Owner, ctx.Repo)
query := buildQuery(map[string]string{
"path": ctx.Arg("path"),
"ref": ctx.Arg("ref"),
})
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "replace",
Description: "Replace a file in the repository",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
{Name: "content", Short: "c", Usage: "File content (will be base64-encoded)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
payload := map[string]interface{}{
"filepath": filePath,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
}
if branch := ctx.Arg("branch"); branch != "" {
payload["branch"] = branch
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/replace_file", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func buildQuery(params map[string]string) map[string][]string {
query := map[string][]string{}
for k, v := range params {
if v != "" {
if v == "true" {
query[k] = []string{"true"}
} else {
query[k] = []string{v}
}
}
}
return query
}

View File

@ -1,37 +1,90 @@
package issue
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const closedIssueStatusID = 5
type batchCloseResult struct {
Number string `json:"number" yaml:"number"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
// ---------------------------------------------------------------------------
// Shared: resolve database IDs from user-facing issue numbers
// ---------------------------------------------------------------------------
// resolveIssueDBIDs fetches all issues and maps user-facing project_issues_index
// values to their internal database IDs. Returns a slice of interface{} values
// suitable for JSON serialization in batch API calls.
func resolveIssueDBIDs(ctx *common.RuntimeContext, numbers []string) ([]interface{}, error) {
q := url.Values{}
q.Set("limit", "200")
q.Set("page", "1")
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return nil, fmt.Errorf("fetch issues for ID mapping: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected issues response format")
}
issues, ok := data["issues"].([]interface{})
if !ok {
return nil, fmt.Errorf("unexpected issues list format")
}
// Build mapping: project_issues_index (as string) → database id
indexToID := make(map[string]interface{}, len(issues))
for _, item := range issues {
issue, ok := item.(map[string]interface{})
if !ok {
continue
}
idx, ok := issue["project_issues_index"]
if !ok {
continue
}
id, ok := issue["id"]
if !ok {
continue
}
indexToID[fmt.Sprintf("%v", idx)] = id
}
dbIDs := make([]interface{}, 0, len(numbers))
for _, n := range numbers {
id, found := indexToID[n]
if !found {
return nil, fmt.Errorf("issue #%s not found in repository", n)
}
dbIDs = append(dbIDs, id)
}
return dbIDs, nil
}
type batchCloseSummary struct {
Repository string `json:"repository" yaml:"repository"`
DryRun bool `json:"dry_run" yaml:"dry_run"`
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Results []batchCloseResult `json:"results" yaml:"results"`
}
// ---------------------------------------------------------------------------
// batch-close
// ---------------------------------------------------------------------------
func newBatchCloseShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-close",
Description: "Close multiple issues by issue numbers or a CSV file",
Long: `Close multiple issues in a single operation.
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
Use --dry-run to preview which issues would be closed without making changes.
The CSV file may have a "number", "issue_number", or "project_issues_index"
header column; otherwise the first column is used.`,
Example: ` # Close issues 1, 2, and 3
gitlink issue +batch-close --numbers 1,2,3
# Dry-run to preview
gitlink issue +batch-close --numbers 1,2,3 --dry-run
# Close issues listed in a CSV file
gitlink issue +batch-close --from issues.csv`,
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
@ -46,7 +99,7 @@ func runBatchClose(ctx *common.RuntimeContext) error {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
@ -54,33 +107,11 @@ func runBatchClose(ctx *common.RuntimeContext) error {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchCloseSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchCloseResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchCloseResult{Number: number, Action: "close"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := closeIssue(ctx, number); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "closed"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
summary := common.ProcessBatch(numbers, dryRun, "close", func(number string) error {
return closeIssue(ctx, number)
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
@ -108,105 +139,187 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
return nil
}
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := parseIssueNumbers(numbersValue)
// ---------------------------------------------------------------------------
// batch-update
// ---------------------------------------------------------------------------
func newBatchUpdateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-update",
Description: "Update multiple issues at once using the native batch API",
Long: `Update multiple issues in a single API call.
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
At least one update field (--state, --milestone, --assignee, --label, or --priority)
must be specified. Use --dry-run to preview changes without applying them.`,
Example: ` # Close multiple issues
gitlink issue +batch-update --numbers 1,2,3 --state closed
# Set milestone for issues from a CSV file
gitlink issue +batch-update --from issues.csv --milestone 5
# Assign issues to a user and add labels
gitlink issue +batch-update --numbers 10,11,12 --assignee 3 --label 7,8
# Dry-run to preview
gitlink issue +batch-update --numbers 1,2,3 --priority 1 --dry-run`,
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers (e.g. 1,2,3)"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "state", Short: "s", Usage: "Set state: open, closed, or numeric status_id"},
{Name: "milestone", Short: "m", Usage: "Set milestone ID"},
{Name: "assignee", Short: "a", Usage: "Set assignee user ID"},
{Name: "label", Short: "l", Usage: "Set label ID(s), comma-separated for multiple"},
{Name: "priority", Short: "p", Usage: "Set priority ID"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchUpdate,
}
}
func runBatchUpdate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return nil, err
return err
}
if csvPath == "" {
return numbers, nil
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
state := ctx.Arg("state")
milestone := ctx.Arg("milestone")
assignee := ctx.Arg("assignee")
label := ctx.Arg("label")
priority := ctx.Arg("priority")
if state == "" && milestone == "" && assignee == "" && label == "" && priority == "" {
return fmt.Errorf("at least one of --state, --milestone, --assignee, --label, or --priority is required")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
dbIDs, err := resolveIssueDBIDs(ctx, numbers)
if err != nil {
return nil, err
return err
}
return mergeIssueNumbers(numbers, csvNumbers), nil
}
func parseIssueNumbers(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
body := map[string]interface{}{
"ids": dbIDs,
}
if state != "" {
statusID, err := normalizeIssueStatus(state)
if err != nil {
return err
}
body["status_id"] = statusID
}
if milestone != "" {
body["fixed_version_id"] = milestone
}
if assignee != "" {
body["assigned_to_id"] = assignee
}
if label != "" {
tagIDs := common.ParseStringList(label)
body["issue_tag_ids"] = tagIDs
}
if priority != "" {
body["priority_id"] = priority
}
return normalizeIssueNumbers(strings.Split(value, ","))
}
func readIssueNumbersFromCSV(path string) ([]string, error) {
file, err := os.Open(path)
if dryRun {
plan := map[string]interface{}{
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"dry_run": true,
"issues": numbers,
"updates": body,
}
return ctx.OutputData(plan)
}
env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body)
if err != nil {
return nil, fmt.Errorf("read issue numbers from CSV: %w", err)
return fmt.Errorf("batch update: %w", err)
}
defer file.Close()
return ctx.Output(env)
}
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
// ---------------------------------------------------------------------------
// batch-destroy
// ---------------------------------------------------------------------------
func newBatchDestroyShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-destroy",
Description: "Delete multiple issues at once using the native batch API",
Long: `Permanently delete multiple issues in a single API call.
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
This action is irreversible. Use --dry-run to preview which issues would be deleted.
You will be prompted for confirmation unless --yes is set.`,
Example: ` # Delete issues 7, 8, and 9
gitlink issue +batch-destroy --numbers 7,8,9
# Dry-run to preview
gitlink issue +batch-destroy --numbers 7,8,9 --dry-run
# Delete issues listed in a CSV file
gitlink issue +batch-destroy --from old_issues.csv`,
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers (e.g. 1,2,3)"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview deletions without executing them", Bool: true, Default: "false"},
},
Run: runBatchDestroy,
}
}
func runBatchDestroy(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return nil, fmt.Errorf("parse issue numbers from CSV: %w", err)
return err
}
if len(records) == 0 {
return nil, nil
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
numberColumn := -1
startRow := 0
for i, cell := range records[0] {
switch strings.ToLower(strings.TrimSpace(cell)) {
case "number", "issue_number", "project_issues_index":
numberColumn = i
startRow = 1
}
}
if numberColumn == -1 {
numberColumn = 0
dryRun := common.ParseBool(ctx.Arg("dry-run"))
dbIDs, err := resolveIssueDBIDs(ctx, numbers)
if err != nil {
return err
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if numberColumn >= len(record) {
continue
if dryRun {
plan := map[string]interface{}{
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"dry_run": true,
"issues": numbers,
"database_ids": dbIDs,
}
values = append(values, record[numberColumn])
return ctx.OutputData(plan)
}
return normalizeIssueNumbers(values)
}
func normalizeIssueNumbers(values []string) ([]string, error) {
numbers := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
number := strings.TrimSpace(value)
if number == "" {
continue
}
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number)
}
if seen[number] {
continue
}
seen[number] = true
numbers = append(numbers, number)
}
return numbers, nil
}
func mergeIssueNumbers(values ...[]string) []string {
merged := []string{}
seen := map[string]bool{}
for _, numbers := range values {
for _, number := range numbers {
if seen[number] {
continue
}
seen[number] = true
merged = append(merged, number)
}
}
return merged
}
func parseBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
if err := common.ConfirmAction(fmt.Sprintf("batch delete %d issue(s)", len(numbers))); err != nil {
return err
}
body := map[string]interface{}{
"ids": dbIDs,
}
// Use CallAPIWithQuery for DELETE since the body needs to be sent.
// The client.Do method sends body for any method, so CallAPI works.
env, err := ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", body)
if err != nil {
return fmt.Errorf("batch destroy: %w", err)
}
return ctx.Output(env)
}

View File

@ -5,79 +5,82 @@ import (
"path/filepath"
"reflect"
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestParseIssueNumbers(t *testing.T) {
got, err := parseIssueNumbers("1, 2,2, 3")
func TestParseNumberList(t *testing.T) {
got, err := common.ParseNumberList("1, 2,2, 3")
if err != nil {
t.Fatalf("parseIssueNumbers returned error: %v", err)
t.Fatalf("ParseNumberList returned error: %v", err)
}
want := []string{"1", "2", "3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("parseIssueNumbers() = %#v, want %#v", got, want)
t.Fatalf("ParseNumberList() = %#v, want %#v", got, want)
}
}
func TestParseIssueNumbersRejectsInvalidNumber(t *testing.T) {
if _, err := parseIssueNumbers("1,abc"); err == nil {
t.Fatal("parseIssueNumbers() expected an error for a non-integer issue number")
func TestParseNumberListRejectsInvalidNumber(t *testing.T) {
if _, err := common.ParseNumberList("1,abc"); err == nil {
t.Fatal("ParseNumberList() expected an error for a non-integer issue number")
}
}
func TestReadIssueNumbersFromCSVWithHeader(t *testing.T) {
func TestReadColumnFromCSVWithHeader(t *testing.T) {
path := writeTempCSV(t, "title,number,state\nfirst,12,open\nsecond,13,open\n")
got, err := readIssueNumbersFromCSV(path)
got, err := common.ReadColumnFromCSV(path, []string{"number", "issue_number", "project_issues_index"})
if err != nil {
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
}
want := []string{"12", "13"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
t.Fatalf("ReadColumnFromCSV() = %#v, want %#v", got, want)
}
}
func TestReadIssueNumbersFromCSVWithProjectIssuesIndexHeader(t *testing.T) {
func TestReadColumnFromCSVWithProjectIssuesIndexHeader(t *testing.T) {
path := writeTempCSV(t, "title,project_issues_index,state\nfirst,12,open\nsecond,13,open\n")
got, err := readIssueNumbersFromCSV(path)
got, err := common.ReadColumnFromCSV(path, []string{"number", "issue_number", "project_issues_index"})
if err != nil {
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
}
want := []string{"12", "13"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
t.Fatalf("ReadColumnFromCSV() = %#v, want %#v", got, want)
}
}
func TestReadIssueNumbersFromCSVWithoutHeaderUsesFirstColumn(t *testing.T) {
func TestReadColumnFromCSVWithoutHeaderUsesFirstColumn(t *testing.T) {
path := writeTempCSV(t, "21,open\n22,closed\n21,duplicate\n")
got, err := readIssueNumbersFromCSV(path)
got, err := common.ReadColumnFromCSV(path, []string{"number", "issue_number", "project_issues_index"})
if err != nil {
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
}
want := []string{"21", "22"}
// ReadColumnFromCSV does not deduplicate; CollectNumbers handles that.
want := []string{"21", "22", "21"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
t.Fatalf("ReadColumnFromCSV() = %#v, want %#v", got, want)
}
}
func TestCollectIssueNumbersMergesCLIAndCSV(t *testing.T) {
func TestCollectNumbersMergesCLIAndCSV(t *testing.T) {
path := writeTempCSV(t, "number\n2\n3\n")
got, err := collectIssueNumbers("1,2", path)
got, err := common.CollectNumbers("1,2", path)
if err != nil {
t.Fatalf("collectIssueNumbers returned error: %v", err)
t.Fatalf("CollectNumbers returned error: %v", err)
}
want := []string{"1", "2", "3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("collectIssueNumbers() = %#v, want %#v", got, want)
t.Fatalf("CollectNumbers() = %#v, want %#v", got, want)
}
}
func TestParseBool(t *testing.T) {
if !parseBool("true") {
t.Fatal("parseBool(true) = false, want true")
if !common.ParseBool("true") {
t.Fatal("ParseBool(true) = false, want true")
}
if parseBool("") {
t.Fatal("parseBool(empty) = true, want false")
if common.ParseBool("") {
t.Fatal("ParseBool(empty) = true, want false")
}
}

View File

@ -23,13 +23,46 @@ type existingIssue struct {
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchUpdateShortcut(),
newBatchDestroyShortcut(),
{
Name: "list",
Description: "List issues",
Long: `List issues in the current repository.
Shows issue number, title, status, and other details.
Use --state to filter by status, --milestone, --assignee, --label,
--keyword for additional filtering, and --page and --limit for pagination.`,
Example: ` # List open issues (default)
gitlink issue +list
# List closed issues
gitlink issue +list --state closed
# Show 50 issues per page
gitlink issue +list --limit 50
# Filter by milestone and assignee
gitlink issue +list --milestone 3 --assignee 5
# Search by keyword
gitlink issue +list -k "login bug"
# Sort by created date
gitlink issue +list --sort created_on
# Sort by updated date descending
gitlink issue +list --sort updated_on --sort-direction desc`,
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "milestone", Short: "m", Usage: "Filter by milestone ID"},
{Name: "assignee", Short: "a", Usage: "Filter by assignee user ID"},
{Name: "label", Usage: "Filter by label ID"},
{Name: "keyword", Short: "k", Usage: "Search by keyword"},
{Name: "sort", Usage: "Sort field (e.g. created_on, updated_on)"},
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -41,6 +74,24 @@ func Shortcuts() []*common.Shortcut {
if s := ctx.Arg("state"); s != "" {
q.Set("state", s)
}
if m := ctx.Arg("milestone"); m != "" {
q.Set("fixed_version_id", m)
}
if a := ctx.Arg("assignee"); a != "" {
q.Set("assigned_to_id", a)
}
if l := ctx.Arg("label"); l != "" {
q.Set("issue_tag_id", l)
}
if k := ctx.Arg("keyword"); k != "" {
q.Set("keyword", k)
}
if s := ctx.Arg("sort"); s != "" {
q.Set("sort", s)
}
if d := ctx.Arg("sort-direction"); d != "" {
q.Set("sort_direction", d)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return err
@ -52,6 +103,18 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a new issue",
Long: `Create a new issue in the current repository.
Requires a title (--title). You may optionally set a description,
assignee, milestone, and label at creation time.`,
Example: ` # Create an issue with just a title
gitlink issue +create --title "Fix login bug"
# Create with description and assignee
gitlink issue +create --title "Fix login bug" --body "Steps to reproduce..." --assignee zhangsan
# Create with milestone and label
gitlink issue +create --title "New feature" --milestone 3 --label 5`,
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
{Name: "body", Short: "b", Usage: "Issue description"},
@ -82,6 +145,9 @@ func Shortcuts() []*common.Shortcut {
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m
}
if l := ctx.Arg("label"); l != "" {
body["issue_tag_id"] = l
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
return err
@ -92,6 +158,15 @@ func Shortcuts() []*common.Shortcut {
{
Name: "view",
Description: "View issue details",
Long: `View detailed information about a specific issue.
Displays the issue title, description, status, assignee,
and other metadata. Use the issue number as shown in the web URL.`,
Example: ` # View issue #42
gitlink issue +view --number 42
# Short form
gitlink issue +view -n 42`,
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
@ -113,8 +188,22 @@ func Shortcuts() []*common.Shortcut {
{
Name: "close",
Description: "Close an issue",
Long: `Close an existing issue by setting its status to closed.
The issue number is the one shown in the web URL (project-level index).
This preserves the existing title and description.
Use --comment to leave an explanation when closing.`,
Example: ` # Close issue #42
gitlink issue +close --number 42
# Short form
gitlink issue +close -n 42
# Close with a comment
gitlink issue +close -n 42 --comment "Fixed in commit abc123"`,
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "comment", Short: "c", Usage: "Comment to add when closing"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -138,17 +227,49 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
// Optionally post a closing comment
if comment := ctx.Arg("comment"); comment != "" {
_, _ = ctx.CallAPI("POST",
fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number),
map[string]interface{}{"notes": comment})
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an issue",
Long: `Update the title, description, status, or other fields of an existing issue.
At least one of --title, --body, --state, --milestone, --assignee,
--label, or --priority must be provided.
The --state flag accepts "open", "closed", or a numeric status_id.`,
Example: ` # Change the title
gitlink issue +update --number 42 --title "Updated title"
# Change description
gitlink issue +update -n 42 --body "New description"
# Reopen a closed issue
gitlink issue +update -n 42 --state open
# Update title and close at the same time
gitlink issue +update -n 42 --title "Fixed" --state closed
# Assign to a user and set milestone
gitlink issue +update -n 42 --assignee 5 --milestone 3
# Set label and priority
gitlink issue +update -n 42 --label 7 --priority 1`,
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "title", Short: "t", Usage: "New title"},
{Name: "body", Short: "b", Usage: "New description"},
{Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"},
{Name: "milestone", Short: "m", Usage: "Milestone ID"},
{Name: "assignee", Short: "a", Usage: "Assignee user ID"},
{Name: "label", Usage: "Label ID"},
{Name: "priority", Usage: "Priority ID"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -161,8 +282,13 @@ func Shortcuts() []*common.Shortcut {
title := ctx.Arg("title")
description := ctx.Arg("body")
state := ctx.Arg("state")
if title == "" && description == "" && state == "" {
return fmt.Errorf("at least one of --title, --body, or --state is required")
milestone := ctx.Arg("milestone")
assignee := ctx.Arg("assignee")
label := ctx.Arg("label")
priority := ctx.Arg("priority")
if title == "" && description == "" && state == "" &&
milestone == "" && assignee == "" && label == "" && priority == "" {
return fmt.Errorf("at least one of --title, --body, --state, --milestone, --assignee, --label, or --priority is required")
}
current, err := fetchExistingIssue(ctx, number)
@ -187,6 +313,18 @@ func Shortcuts() []*common.Shortcut {
}
body["status_id"] = statusID
}
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m
}
if a := ctx.Arg("assignee"); a != "" {
body["assigned_to_id"] = a
}
if l := ctx.Arg("label"); l != "" {
body["issue_tag_id"] = l
}
if p := ctx.Arg("priority"); p != "" {
body["priority_id"] = p
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
@ -197,6 +335,14 @@ func Shortcuts() []*common.Shortcut {
{
Name: "comment",
Description: "Add a comment to an issue",
Long: `Add a comment (journal entry) to an existing issue.
Both --number and --body are required.`,
Example: ` # Comment on issue #42
gitlink issue +comment --number 42 --body "This is now fixed."
# Short form
gitlink issue +comment -n 42 -b "LGTM"`,
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
@ -223,6 +369,199 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "comments",
Description: "List comments and activity on an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update-comment",
Description: "Update a comment on an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "id", Usage: "Comment journal ID", Required: true},
{Name: "body", Short: "b", Usage: "New comment body", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete-comment",
Description: "Delete a comment on an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "id", Usage: "Comment journal ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "reply-comment",
Description: "List child replies of a comment",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "id", Usage: "Parent comment journal ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/journals/%s/children_journals", v1RepoPath(ctx), number, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "batch-update",
Description: "Batch update multiple issues",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
{Name: "state", Short: "s", Usage: "New state: open, closed"},
{Name: "assignee", Short: "a", Usage: "Assignee login"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
idsStr, err := ctx.RequireArg("ids")
if err != nil {
return err
}
body := map[string]interface{}{
"ids": parseIDList(idsStr),
}
if s := ctx.Arg("state"); s != "" {
statusID, err := normalizeIssueStatus(s)
if err != nil {
return err
}
body["status_id"] = statusID
}
if a := ctx.Arg("assignee"); a != "" {
body["assigned_to_id"] = a
}
env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "batch-destroy",
Description: "Batch delete multiple issues",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
idsStr, err := ctx.RequireArg("ids")
if err != nil {
return err
}
body := map[string]interface{}{
"ids": parseIDList(idsStr),
}
env, err := ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
@ -289,3 +628,19 @@ func normalizeIssueStatus(state string) (interface{}, error) {
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
}
}
// parseIDList splits a comma-separated string into an int slice.
func parseIDList(s string) []int {
parts := strings.Split(s, ",")
ids := make([]int, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if id, err := strconv.Atoi(p); err == nil {
ids = append(ids, id)
}
}
return ids
}

View File

@ -186,3 +186,182 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func TestIssueDelete(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{"ok": true})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "delete", map[string]string{"number": "42"})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func TestIssueComments(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42/journals.json":
if r.URL.Query().Get("page") != "2" || r.URL.Query().Get("limit") != "10" {
t.Fatalf("unexpected query params: %v", r.URL.Query())
}
writeJSON(t, w, map[string]interface{}{
"journals": []interface{}{
map[string]interface{}{"id": 1, "notes": "first comment"},
map[string]interface{}{"id": 2, "notes": "second comment"},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "comments", map[string]string{
"number": "42",
"page": "2",
"limit": "10",
})
if err != nil {
t.Fatalf("comments shortcut failed: %v", err)
}
}
func TestIssueUpdateComment(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "update-comment", map[string]string{
"number": "42",
"id": "99",
"body": "updated comment text",
})
if err != nil {
t.Fatalf("update-comment shortcut failed: %v", err)
}
assertEqual(t, updatePayload["notes"], "updated comment text")
}
func TestIssueDeleteComment(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99.json":
writeJSON(t, w, map[string]interface{}{"ok": true})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "delete-comment", map[string]string{
"number": "42",
"id": "99",
})
if err != nil {
t.Fatalf("delete-comment shortcut failed: %v", err)
}
}
func TestIssueReplyComment(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99/children_journals.json":
writeJSON(t, w, map[string]interface{}{
"journals": []interface{}{
map[string]interface{}{"id": 100, "notes": "reply"},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "reply-comment", map[string]string{
"number": "42",
"id": "99",
})
if err != nil {
t.Fatalf("reply-comment shortcut failed: %v", err)
}
}
func TestIssueBatchUpdate(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/batch_update.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-update", map[string]string{
"ids": "1,2,3",
"state": "closed",
"assignee": "someone",
})
if err != nil {
t.Fatalf("batch-update shortcut failed: %v", err)
}
ids, ok := updatePayload["ids"].([]interface{})
if !ok {
t.Fatalf("expected ids to be a slice, got %T", updatePayload["ids"])
}
assertEqual(t, len(ids), 3)
assertEqual(t, ids[0], float64(1))
assertEqual(t, ids[1], float64(2))
assertEqual(t, ids[2], float64(3))
assertEqual(t, updatePayload["status_id"], float64(5))
assertEqual(t, updatePayload["assigned_to_id"], "someone")
}
func TestIssueBatchDestroy(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/batch_destroy.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-destroy", map[string]string{
"ids": "10,20,30",
})
if err != nil {
t.Fatalf("batch-destroy shortcut failed: %v", err)
}
ids, ok := updatePayload["ids"].([]interface{})
if !ok {
t.Fatalf("expected ids to be a slice, got %T", updatePayload["ids"])
}
assertEqual(t, len(ids), 3)
assertEqual(t, ids[0], float64(10))
assertEqual(t, ids[1], float64(20))
assertEqual(t, ids[2], float64(30))
}

210
shortcuts/label/label.go Normal file
View File

@ -0,0 +1,210 @@
package label
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List repository issue labels (tags)",
Long: `List repository issue labels (tags).
Returns all labels defined in the repository.`,
Example: ` # List all labels
gitlink label +list`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create an issue label",
Long: `Create an issue label.
Creates a new label with the given name.
Optionally specify a color in hex format (without #).`,
Example: ` # Create a label
gitlink label +create --name bug
# Create a label with color
gitlink label +create --name enhancement --color 00ff00`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Label name", Required: true},
{Name: "color", Short: "c", Usage: "Label color (hex without #, e.g. ff0000)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
payload := map[string]interface{}{
"name": name,
}
if color := ctx.Arg("color"); color != "" {
payload["color"] = color
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an issue label",
Long: `Update an issue label.
Updates the name and/or color of an existing label.
At least one of --name or --color should be provided.`,
Example: ` # Rename a label
gitlink label +update --id 42 --name "new name"
# Change label color
gitlink label +update --id 42 --color ff0000`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
{Name: "name", Short: "n", Usage: "New label name"},
{Name: "color", Short: "c", Usage: "New label color (hex without #)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
payload := map[string]interface{}{}
if name := ctx.Arg("name"); name != "" {
payload["name"] = name
}
if color := ctx.Arg("color"); color != "" {
payload["color"] = color
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/%s/issue_tags/%s", ctx.Owner, ctx.Repo, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete an issue label",
Long: `Delete an issue label.
Permanently removes the label from the repository.`,
Example: ` # Delete a label
gitlink label +delete --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/issue_tags/%s", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "priorities",
Description: "List available issue priorities",
Long: `List available issue priorities.
Returns the priority levels that can be assigned to issues.`,
Example: ` # List issue priorities
gitlink label +priorities`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_priorities", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "statuses",
Description: "List available issue statuses",
Long: `List available issue statuses.
Returns the status values that issues can have
(e.g., open, closed, in progress).`,
Example: ` # List issue statuses
gitlink label +statuses`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_statues", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "authors",
Description: "List available issue authors",
Long: `List available issue authors.
Returns the users who have authored issues in the repository.`,
Example: ` # List issue authors
gitlink label +authors`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_authors", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "assigners",
Description: "List available issue assigners",
Long: `List available issue assigners.
Returns the users who can be assigned to issues in the repository.`,
Example: ` # List issue assigners
gitlink label +assigners`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_assigners", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,218 @@
package milestone
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List repository milestones",
Long: `List repository milestones.
Displays all milestones for the repository with optional filtering by state.
Supports pagination with --page and --limit flags.`,
Example: ` # List open milestones
gitlink milestone +list
# List closed milestones
gitlink milestone +list -s closed
# List all milestones with pagination
gitlink milestone +list -s all -p 2 -l 10`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "state", Short: "s", Usage: "Filter: open or closed or all", Default: "open"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
path := fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPIWithQuery("GET", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a milestone",
Long: `Create a milestone.
Creates a new milestone with the given title. Optionally add a description
and due date.`,
Example: ` # Create a basic milestone
gitlink milestone +create -t "v1.0"
# Create a milestone with description and due date
gitlink milestone +create -t "v2.0" -d "Second major release" --due-date 2025-12-31`,
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Milestone title", Required: true},
{Name: "description", Short: "d", Usage: "Milestone description"},
{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title")
if err != nil {
return err
}
payload := map[string]interface{}{
"name": title,
}
if desc := ctx.Arg("description"); desc != "" {
payload["description"] = desc
}
if dueDate := ctx.Arg("due-date"); dueDate != "" {
payload["effective_date"] = dueDate
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View milestone details",
Long: `View milestone details.
Shows detailed information about a specific milestone including its
title, description, status, due date, and completion progress.`,
Example: ` # View milestone details by ID
gitlink milestone +view -i 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/milestones/%s", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a milestone",
Long: `Update a milestone.
Modifies an existing milestone's title, description, due date, or status.
Only the fields you specify will be changed.`,
Example: ` # Update a milestone title
gitlink milestone +update -i 42 -t "v1.0 Final"
# Update description and due date
gitlink milestone +update -i 42 -d "Final release" --due-date 2025-06-30
# Close a milestone via update
gitlink milestone +update -i 42 --status close`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
{Name: "title", Short: "t", Usage: "New title"},
{Name: "description", Short: "d", Usage: "New description"},
{Name: "due-date", Usage: "New due date (YYYY-MM-DD)"},
{Name: "status", Usage: "Status: open or close"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
payload := map[string]interface{}{}
if title := ctx.Arg("title"); title != "" {
payload["name"] = title
}
if desc := ctx.Arg("description"); desc != "" {
payload["description"] = desc
}
if dueDate := ctx.Arg("due-date"); dueDate != "" {
payload["effective_date"] = dueDate
}
if status := ctx.Arg("status"); status != "" {
payload["status"] = status
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/%s/milestones/%s", ctx.Owner, ctx.Repo, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a milestone",
Long: `Delete a milestone.
Permanently removes a milestone from the repository. This action cannot be undone.`,
Example: ` # Delete a milestone by ID
gitlink milestone +delete -i 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/milestones/%s", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "close",
Description: "Close a milestone",
Long: `Close a milestone.
Marks a milestone as closed. Closed milestones are still visible but
can no longer receive new issues.`,
Example: ` # Close a milestone by ID
gitlink milestone +close -i 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
payload := map[string]interface{}{
"status": "close",
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/milestones/%s/update_status", ctx.Owner, ctx.Repo, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -12,6 +12,15 @@ func Shortcuts() []*common.Shortcut {
{
Name: "list",
Description: "List organizations",
Long: `List all organizations visible to the authenticated user.
Returns organization ID, name, and description for each entry.
Use --page and --limit for pagination.`,
Example: ` # List organizations
gitlink org +list
# Show 50 organizations per page
gitlink org +list --limit 50`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
@ -30,6 +39,15 @@ func Shortcuts() []*common.Shortcut {
{
Name: "info",
Description: "Show organization details",
Long: `Show detailed information about an organization.
Returns the full organization profile including name, description,
member count, and repository list.`,
Example: ` # Show details for an organization by ID
gitlink org +info --id 123
# Show details for an organization by login
gitlink org +info --id my-org`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
},
@ -45,6 +63,15 @@ func Shortcuts() []*common.Shortcut {
{
Name: "members",
Description: "List organization members",
Long: `List all members of an organization.
Shows each member's login name, role, and join date.
Use --page and --limit for pagination.`,
Example: ` # List members of an organization
gitlink org +members --id 123
# Show 50 members per page
gitlink org +members --id 123 --limit 50`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
@ -65,6 +92,15 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create an organization",
Long: `Create a new organization on GitLink.
Requires a name for the organization. An optional description
can be provided with --description.`,
Example: ` # Create an organization
gitlink org +create --name my-org
# Create with a description
gitlink org +create --name my-org --description "My team organization"`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Organization name", Required: true},
{Name: "description", Short: "d", Usage: "Description"},
@ -84,5 +120,39 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "add-team-projects",
Description: "Add all projects to a team",
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization login", Required: true},
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org")
team, _ := ctx.RequireArg("team")
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams/%s/team_projects/create_all", org, team), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "remove-team-projects",
Description: "Remove all projects from a team",
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization login", Required: true},
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org")
team, _ := ctx.RequireArg("team")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s/team_projects/destroy_all", org, team), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

158
shortcuts/pr/batch.go Normal file
View File

@ -0,0 +1,158 @@
package pr
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func newBatchCloseShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-close",
Description: "Close multiple pull requests",
Long: `Close multiple pull requests in a single operation.
Provide PR numbers via --numbers (comma-separated) or --from (CSV file).
Use --dry-run to preview which pull requests would be closed without
making changes.
The CSV file may have a "number", "issue_number", or "project_issues_index"
header column; otherwise the first column is used.`,
Example: ` # Close pull requests 1, 2, and 3
gitlink pr +batch-close --numbers 1,2,3
# Dry-run to preview
gitlink pr +batch-close --numbers 1,2,3 --dry-run
# Close pull requests listed in a CSV file
gitlink pr +batch-close --from prs.csv`,
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated PR numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read PR numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "dry-run", Usage: "Preview the pull requests that would be closed without changing them", Bool: true, Default: "false"},
},
Run: runBatchClose,
}
}
func runBatchClose(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no PR numbers provided; use --numbers 1,2,3 or --from prs.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
summary := common.ProcessBatch(numbers, dryRun, "close", func(number string) error {
_, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), number), nil)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d pull request(s) failed to close", summary.Failed, summary.Total)
}
return nil
}
func newBatchMergeShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-merge",
Description: "Merge multiple pull requests",
Long: `Merge multiple pull requests in a single operation.
Provide PR numbers via --numbers (comma-separated) or --from (CSV file).
Use --method to choose the merge strategy: merge (default), rebase, or squash.
Use --dry-run to preview which pull requests would be merged without
making changes.
WARNING: Merging is irreversible. A confirmation prompt is shown before
execution (use --yes to auto-confirm).
The CSV file may have a "number", "issue_number", or "project_issues_index"
header column; otherwise the first column is used.`,
Example: ` # Merge pull requests 1, 2, and 3 using the default method
gitlink pr +batch-merge --numbers 1,2,3
# Squash-merge multiple pull requests
gitlink pr +batch-merge --numbers 1,2,3 --method squash
# Rebase-merge pull requests listed in a CSV file
gitlink pr +batch-merge --from prs.csv --method rebase
# Dry-run to preview
gitlink pr +batch-merge --numbers 1,2,3 --dry-run
# Auto-confirm without prompt
gitlink pr +batch-merge --numbers 1,2,3 --yes`,
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated PR numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read PR numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
{Name: "dry-run", Usage: "Preview the pull requests that would be merged without changing them", Bool: true, Default: "false"},
},
Run: runBatchMerge,
}
}
func runBatchMerge(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
method := ctx.Arg("method")
if method == "" {
method = "merge"
}
if err := validateMergeMethod(method); err != nil {
return err
}
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no PR numbers provided; use --numbers 1,2,3 or --from prs.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
if dryRun {
summary := common.ProcessBatch(numbers, true, "merge", nil)
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
return ctx.OutputData(summary)
}
// Merging is irreversible — require explicit confirmation
if err := common.ConfirmAction(fmt.Sprintf("batch merge %d pull requests", len(numbers))); err != nil {
return err
}
summary := common.ProcessBatch(numbers, false, "merge", func(number string) error {
payload := map[string]interface{}{
"do": method,
}
_, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), number), payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d pull request(s) failed to merge", summary.Failed, summary.Total)
}
return nil
}

View File

@ -3,6 +3,7 @@ package pr
import (
"fmt"
"net/url"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -10,15 +11,50 @@ import (
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchMergeShortcut(),
{
Name: "list",
Description: "List pull requests",
Long: `List pull requests in the current repository.
Shows PR number, title, status, author, and other details.
Use --state to filter by status (open, merged, closed).
Use --keyword, --milestone, --tag, --reviewer, --assignee for
additional filtering, and --sort to control ordering.`,
Example: ` # List open pull requests
gitlink pr +list
# List merged pull requests
gitlink pr +list --state merged
# List closed pull requests with pagination
gitlink pr +list --state closed --page 2 --limit 10
# Search by keyword
gitlink pr +list -k "feature"
# Filter by milestone and assignee
gitlink pr +list --milestone 3 --assignee 5
# Sort by created date
gitlink pr +list --sort created_on
# Sort by updated date descending
gitlink pr +list --sort updated_on --sort-direction desc`,
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
{Name: "keyword", Short: "k", Usage: "Search by keyword"},
{Name: "milestone", Short: "m", Usage: "Filter by milestone ID"},
{Name: "tag", Usage: "Filter by tag ID"},
{Name: "reviewer", Usage: "Filter by reviewer ID"},
{Name: "assignee", Short: "a", Usage: "Filter by assignee ID"},
{Name: "sort", Usage: "Sort field (e.g. created_on, updated_on)"},
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
@ -28,6 +64,27 @@ func Shortcuts() []*common.Shortcut {
if s := ctx.Arg("state"); s != "" {
q.Set("state", s)
}
if k := ctx.Arg("keyword"); k != "" {
q.Set("keyword", k)
}
if m := ctx.Arg("milestone"); m != "" {
q.Set("version_id", m)
}
if t := ctx.Arg("tag"); t != "" {
q.Set("issue_tag_id", t)
}
if r := ctx.Arg("reviewer"); r != "" {
q.Set("reviewer_id", r)
}
if a := ctx.Arg("assignee"); a != "" {
q.Set("assign_user_id", a)
}
if s := ctx.Arg("sort"); s != "" {
q.Set("sort", s)
}
if d := ctx.Arg("sort-direction"); d != "" {
q.Set("sort_direction", d)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
if err != nil {
return err
@ -38,11 +95,32 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a pull request",
Long: `Create a new pull request in the current repository.
Requires a title and source branch (--head). The target branch
(--base) defaults to the repository default branch if not specified.
Use --assignee, --milestone, --label, and --priority to set
additional fields at creation time.`,
Example: ` # Create a pull request
gitlink pr +create --title "Add new feature" --head feature-branch
# Create a PR with description and target branch
gitlink pr +create --title "Fix bug" --body "Fixes #123" --head fix-branch --base main
# Create with assignee and milestone
gitlink pr +create --title "Feature" --head feat --assignee 5 --milestone 3
# Create with labels and priority
gitlink pr +create --title "Feature" --head feat --label 7 --priority 1`,
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "PR title", Required: true},
{Name: "body", Short: "b", Usage: "PR description"},
{Name: "head", Usage: "Source branch", Required: true},
{Name: "base", Usage: "Target branch", Default: "master"},
{Name: "base", Usage: "Target branch (default: repository default branch)"},
{Name: "priority", Usage: "Priority ID"},
{Name: "assignee", Short: "a", Usage: "Assignee user ID"},
{Name: "milestone", Short: "m", Usage: "Milestone ID"},
{Name: "label", Short: "l", Usage: "Label ID (comma-separated for multiple)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -52,7 +130,7 @@ func Shortcuts() []*common.Shortcut {
head, _ := ctx.RequireArg("head")
base := ctx.Arg("base")
if base == "" {
base = "master"
base = common.GetDefaultBranch(ctx)
}
payload := map[string]interface{}{
"title": title,
@ -62,6 +140,28 @@ func Shortcuts() []*common.Shortcut {
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
if p := ctx.Arg("priority"); p != "" {
payload["priority_id"] = p
}
if a := ctx.Arg("assignee"); a != "" {
payload["assigned_to_id"] = a
}
if m := ctx.Arg("milestone"); m != "" {
payload["fixed_version_id"] = m
}
if l := ctx.Arg("label"); l != "" {
// Support comma-separated label IDs
if strings.Contains(l, ",") {
parts := strings.Split(l, ",")
ids := make([]interface{}, len(parts))
for i, p := range parts {
ids[i] = strings.TrimSpace(p)
}
payload["issue_tag_ids"] = ids
} else {
payload["issue_tag_ids"] = []interface{}{l}
}
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
if err != nil {
return err
@ -72,6 +172,12 @@ func Shortcuts() []*common.Shortcut {
{
Name: "view",
Description: "View pull request details",
Long: `View detailed information about a specific pull request.
Displays PR title, description, status, author, branches,
and other metadata.`,
Example: ` # View details of pull request #42
gitlink pr +view --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
@ -90,9 +196,26 @@ func Shortcuts() []*common.Shortcut {
{
Name: "merge",
Description: "Merge a pull request",
Long: `Merge a pull request using the specified merge method.
Supported merge methods: merge (default), rebase, squash.
Use --title and --body to customize the merge commit message.`,
Example: ` # Merge a pull request using the default method
gitlink pr +merge --id 42
# Squash-merge a pull request
gitlink pr +merge --id 42 --method squash
# Rebase-merge a pull request
gitlink pr +merge --id 42 --method rebase
# Merge with custom commit title and description
gitlink pr +merge --id 42 --title "Merge feature X" --body "Combines feature X into main"`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
{Name: "title", Usage: "Merge commit title"},
{Name: "body", Usage: "Merge commit description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -103,9 +226,18 @@ func Shortcuts() []*common.Shortcut {
if method == "" {
method = "merge"
}
if err := validateMergeMethod(method); err != nil {
return err
}
payload := map[string]interface{}{
"do": method,
}
if t := ctx.Arg("title"); t != "" {
payload["title"] = t
}
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
if err != nil {
return err
@ -116,6 +248,12 @@ func Shortcuts() []*common.Shortcut {
{
Name: "close",
Description: "Close a pull request",
Long: `Close a pull request without merging.
The PR will be marked as closed and cannot be merged afterwards
unless it is reopened.`,
Example: ` # Close pull request #42
gitlink pr +close --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
@ -134,24 +272,12 @@ func Shortcuts() []*common.Shortcut {
{
Name: "files",
Description: "List changed files in a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "diff",
Description: "Show diff for a pull request",
Long: `List all files changed in a pull request.
Shows the file path, change type (added, modified, deleted),
and the number of additions and deletions for each file.`,
Example: ` # List files changed in pull request #42
gitlink pr +files --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
@ -170,6 +296,12 @@ func Shortcuts() []*common.Shortcut {
{
Name: "versions",
Description: "List pull request patchset versions",
Long: `List all patchset versions of a pull request.
Each time a PR is updated with new commits, a new patchset version
is created. This command shows the version history.`,
Example: ` # List patchset versions for pull request #42
gitlink pr +versions --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
@ -191,6 +323,14 @@ func Shortcuts() []*common.Shortcut {
{
Name: "version-diff",
Description: "Show diff for a pull request patchset version",
Long: `Show the diff for a specific patchset version of a pull request.
Use --file to filter the diff to a specific file path.`,
Example: ` # Show diff for version 3 of pull request #42
gitlink pr +version-diff --id 42 --version-id 3
# Show diff for a specific file in a patchset version
gitlink pr +version-diff --id 42 --version-id 3 --file src/main.go`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true},
@ -228,6 +368,15 @@ func Shortcuts() []*common.Shortcut {
{
Name: "reviews",
Description: "List pull request reviews",
Long: `List all reviews submitted for a pull request.
Use --status to filter reviews by their status:
common, approved, or rejected.`,
Example: ` # List all reviews for pull request #42
gitlink pr +reviews --id 42
# List only approved reviews
gitlink pr +reviews --id 42 --status approved`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"},
@ -257,6 +406,19 @@ func Shortcuts() []*common.Shortcut {
{
Name: "review",
Description: "Create a pull request review",
Long: `Submit a review on a pull request.
Supported review statuses: common (comment), approved, rejected.
Use --commit to attach the review to a specific commit.
Use --dry-run to preview the review without submitting it.`,
Example: ` # Submit an approving review
gitlink pr +review --id 42 --status approved --content "Looks good!"
# Request changes on a PR
gitlink pr +review --id 42 --status rejected --content "Needs fixes"
# Preview a review without creating it
gitlink pr +review --id 42 --status common --content "Note" --dry-run`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"},
@ -306,9 +468,210 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "comments",
Description: "List review comments on a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/journals", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create-comment",
Description: "Create a review comment on a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
{Name: "line", Short: "l", Usage: "Line number"},
{Name: "path", Short: "p", Usage: "File path"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
}
if line := ctx.Arg("line"); line != "" {
payload["line"] = line
}
if path := ctx.Arg("path"); path != "" {
payload["path"] = path
}
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/journals", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update-comment",
Description: "Update a review comment on a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
{Name: "body", Short: "b", Usage: "New comment body", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id")
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/journals/%s", prV1Path(ctx, id), commentID), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete-comment",
Description: "Delete a review comment on a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/journals/%s", prV1Path(ctx, id), commentID), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "commits",
Description: "List commits in a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/commits", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "reopen",
Description: "Reopen a closed pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/reopen", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update pull request title and/or description",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "title", Short: "t", Usage: "New title"},
{Name: "body", Short: "b", Usage: "New description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
title := ctx.Arg("title")
body := ctx.Arg("body")
if title == "" && body == "" {
return fmt.Errorf("at least one of --title or --body is required")
}
payload := map[string]interface{}{}
if title != "" {
payload["title"] = title
}
if body != "" {
payload["body"] = body
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "comment",
Description: "Add a comment to a pull request",
Long: `Add a comment to a pull request.
The comment is posted as a journal entry on the underlying issue
associated with the pull request.`,
Example: ` # Add a comment to pull request #42
gitlink pr +comment --id 42 --body "This looks great, thanks!"`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
@ -355,6 +718,15 @@ func validatePRReviewStatus(status string) error {
}
}
func validateMergeMethod(method string) error {
switch method {
case "merge", "rebase", "squash":
return nil
default:
return fmt.Errorf("invalid --method %q: use merge, rebase, or squash", method)
}
}
func extractIssueID(env *output.Envelope) (int64, error) {
data, ok := env.Data.(map[string]interface{})
if !ok {

View File

@ -263,6 +263,283 @@ func TestPRReviewRejectsInvalidStatus(t *testing.T) {
}
}
// --- Task 2: PR Comments CRUD, Commits, Reopen, Update tests ---
func TestPRCommentsListsReviewComments(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls/13/journals.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"total_count": float64(1),
"journals": []map[string]interface{}{
{
"id": float64(501),
"notes": "Looks good here",
"line": float64(42),
"path": "main.go",
},
},
})
}))
defer server.Close()
err := runPRShortcut(t, server, "comments", map[string]string{
"id": "13",
})
if err != nil {
t.Fatalf("comments shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals.json")
}
func TestPRCreateCommentPostsJournal(t *testing.T) {
var journalPayload map[string]interface{}
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/journals.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
journalPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(502),
"notes": "nit: use camelCase",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "create-comment", map[string]string{
"id": "13",
"body": "nit: use camelCase",
"line": "42",
"path": "main.go",
})
if err != nil {
t.Fatalf("create-comment shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals.json")
assertEqual(t, journalPayload["notes"], "nit: use camelCase")
assertEqual(t, journalPayload["line"], float64(42))
assertEqual(t, journalPayload["path"], "main.go")
}
func TestPRCreateCommentRequiresBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when body is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "create-comment", map[string]string{
"id": "13",
})
if err == nil {
t.Fatal("expected error when body is missing, got nil")
}
}
func TestPRUpdateCommentPutsJournal(t *testing.T) {
var journalPayload map[string]interface{}
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" || r.URL.Path != "/v1/owner/repo/pulls/13/journals/501.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
journalPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(501),
"notes": "updated comment text",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "update-comment", map[string]string{
"id": "13",
"comment-id": "501",
"body": "updated comment text",
})
if err != nil {
t.Fatalf("update-comment shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals/501.json")
assertEqual(t, journalPayload["notes"], "updated comment text")
}
func TestPRUpdateCommentRequiresCommentID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when comment-id is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "update-comment", map[string]string{
"id": "13",
"body": "some text",
})
if err == nil {
t.Fatal("expected error when comment-id is missing, got nil")
}
}
func TestPRDeleteCommentDeletesJournal(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/pulls/13/journals/501.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"status": 0,
})
}))
defer server.Close()
err := runPRShortcut(t, server, "delete-comment", map[string]string{
"id": "13",
"comment-id": "501",
})
if err != nil {
t.Fatalf("delete-comment shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals/501.json")
}
func TestPRDeleteCommentRequiresCommentID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when comment-id is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "delete-comment", map[string]string{
"id": "13",
})
if err == nil {
t.Fatal("expected error when comment-id is missing, got nil")
}
}
func TestPRCommitsListsCommits(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/13/commits.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"total_count": float64(2),
"commits": []map[string]interface{}{
{
"sha": "abc123",
"author": "dev1",
},
{
"sha": "def456",
"author": "dev2",
},
},
})
}))
defer server.Close()
err := runPRShortcut(t, server, "commits", map[string]string{
"id": "13",
})
if err != nil {
t.Fatalf("commits shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/pulls/13/commits.json")
}
func TestPRReopenReopensPR(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/reopen.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"id": float64(13),
"status": "open",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "reopen", map[string]string{
"id": "13",
})
if err != nil {
t.Fatalf("reopen shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/reopen.json")
}
func TestPRUpdateUpdatesTitle(t *testing.T) {
var updatePayload map[string]interface{}
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" || r.URL.Path != "/owner/repo/pulls/13.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(13),
"title": "new title",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "update", map[string]string{
"id": "13",
"title": "new title",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/pulls/13.json")
assertEqual(t, updatePayload["title"], "new title")
}
func TestPRUpdateUpdatesBody(t *testing.T) {
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" || r.URL.Path != "/owner/repo/pulls/13.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(13),
"body": "new body",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "update", map[string]string{
"id": "13",
"body": "new body",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, updatePayload["body"], "new body")
}
func TestPRUpdateRequiresTitleOrBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when both title and body are missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "update", map[string]string{
"id": "13",
})
if err == nil {
t.Fatal("expected error when both title and body are missing, got nil")
}
}
func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findPRShortcut(t, name)

View File

@ -3,45 +3,84 @@ package shortcuts
import (
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/shortcuts/attachment"
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/collaborator"
"github.com/gitlink-org/gitlink-cli/shortcuts/commit"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/dataset"
"github.com/gitlink-org/gitlink-cli/shortcuts/file"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
"github.com/gitlink-org/gitlink-cli/shortcuts/snippet"
"github.com/gitlink-org/gitlink-cli/shortcuts/sshkey"
"github.com/gitlink-org/gitlink-cli/shortcuts/tag"
"github.com/gitlink-org/gitlink-cli/shortcuts/template"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/util"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
)
// RegisterAll mounts all shortcut groups onto the root command.
func RegisterAll(root *cobra.Command) {
groups := map[string][]*common.Shortcut{
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"webhook": webhook.Shortcuts(),
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"webhook": webhook.Shortcuts(),
"wiki": wiki.Shortcuts(),
"snippet": snippet.Shortcuts(),
"collaborator": collaborator.Shortcuts(),
"tag": tag.Shortcuts(),
"milestone": milestone.Shortcuts(),
"file": file.Shortcuts(),
"commit": commit.Shortcuts(),
"label": label.Shortcuts(),
"sshkey": sshkey.Shortcuts(),
"util": util.Shortcuts(),
"dataset": dataset.Shortcuts(),
"template": template.Shortcuts(),
"attachment": attachment.Shortcuts(),
}
descriptions := map[string]string{
"repo": "Repository operations",
"issue": "Issue operations",
"pr": "Pull request operations",
"release": "Release operations",
"branch": "Branch operations",
"org": "Organization operations",
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"webhook": "Webhook operations",
"repo": "Repository operations",
"issue": "Issue operations",
"pr": "Pull request operations",
"release": "Release operations",
"branch": "Branch operations",
"org": "Organization operations",
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"webhook": "Webhook operations",
"wiki": "Wiki operations",
"snippet": "Code snippet management",
"collaborator": "Collaborator management",
"tag": "Git tag operations",
"milestone": "Milestone management",
"file": "File content operations",
"commit": "Commit operations",
"label": "Issue label management",
"sshkey": "SSH key management",
"util": "Utility operations (licenses, ignores, settings)",
"dataset": "Dataset management",
"template": "Project template management",
"attachment": "File attachment operations",
}
for name, shortcuts := range groups {

View File

@ -13,9 +13,28 @@ func Shortcuts() []*common.Shortcut {
{
Name: "list",
Description: "List releases",
Long: `List all releases in the current repository.
Shows release tag, name, prerelease status, and creation date.
Use --page and --limit for pagination.
Use --prerelease-only to show only pre-release versions.
Use --latest to show only the most recent release.`,
Example: ` # List releases
gitlink release +list
# Show 50 releases per page
gitlink release +list --limit 50
# Show only pre-release versions
gitlink release +list --prerelease-only
# Show only the latest release
gitlink release +list --latest`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "prerelease-only", Usage: "Show only pre-release versions", Bool: true, Default: "false"},
{Name: "latest", Usage: "Show only the latest release", Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -28,17 +47,36 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
// --prerelease-only: client-side filter
if ctx.Arg("prerelease-only") == "true" {
filterReleasesByPrerelease(env)
}
// --latest: keep only the first (most recent) release
if ctx.Arg("latest") == "true" {
keepOnlyLatestRelease(env)
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a release",
Long: `Create a new release for the current repository.
A release is associated with a Git tag. If the tag does not exist,
it will be created on the target branch. Use --body for release notes
and --prerelease to mark it as a pre-release version.`,
Example: ` # Create a release
gitlink release +create --tag v1.0.0 --name "Version 1.0.0"
# Create a prerelease with notes targeting a specific branch
gitlink release +create --tag v2.0.0-beta --name "v2.0 Beta" \
--body "Beta release for testing" --target develop --prerelease true`,
Flags: []common.Flag{
{Name: "tag", Short: "t", Usage: "Tag name", Required: true},
{Name: "name", Short: "n", Usage: "Release name", Required: true},
{Name: "body", Short: "b", Usage: "Release notes"},
{Name: "target", Usage: "Target branch", Default: "master"},
{Name: "target", Usage: "Target branch (default: repository default branch)"},
{Name: "prerelease", Usage: "Mark as prerelease (true/false)", Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
@ -56,6 +94,8 @@ func Shortcuts() []*common.Shortcut {
}
if t := ctx.Arg("target"); t != "" {
payload["target_commitish"] = t
} else {
payload["target_commitish"] = common.GetDefaultBranch(ctx)
}
if ctx.Arg("prerelease") == "true" {
payload["prerelease"] = true
@ -70,6 +110,14 @@ func Shortcuts() []*common.Shortcut {
{
Name: "view",
Description: "View release details",
Long: `Show detailed information about a specific release.
Provide the release ID or tag name to view its details.`,
Example: ` # View a release by ID
gitlink release +view --id 42
# View a release by tag
gitlink release +view --id v1.0.0`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID or tag", Required: true},
},
@ -88,6 +136,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "delete",
Description: "Delete a release",
Long: `Delete a release from the current repository.
This action is irreversible. A confirmation prompt is shown before deletion.`,
Example: ` # Delete a release
gitlink release +delete --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
},
@ -96,6 +149,11 @@ func Shortcuts() []*common.Shortcut {
return err
}
id, _ := ctx.RequireArg("id")
if err := common.ConfirmAction(
fmt.Sprintf("delete release #%s", id),
); err != nil {
return err
}
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if delErr != nil {
// GitLink API bug: delete succeeds but returns error status.
@ -104,16 +162,118 @@ func Shortcuts() []*common.Shortcut {
if viewErr != nil {
// Release no longer exists — delete actually succeeded
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功",
"message": "Release deleted successfully",
}, nil))
}
// Release still exists — delete truly failed
return delErr
}
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功",
"message": "Release deleted successfully",
}, nil))
},
},
{
Name: "edit",
Description: "Get release edit page details",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/releases/%s/edit", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a release",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
{Name: "name", Short: "n", Usage: "New release name"},
{Name: "body", Short: "b", Usage: "New release body"},
{Name: "prerelease", Usage: "Mark as prerelease (true/false)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
payload := map[string]interface{}{}
if n := ctx.Arg("name"); n != "" {
payload["name"] = n
}
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
if p := ctx.Arg("prerelease"); p != "" {
payload["prerelease"] = p == "true"
}
if len(payload) == 0 {
return fmt.Errorf("at least one of --name, --body, or --prerelease is required")
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/releases/%s", ctx.Owner, ctx.Repo, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
// filterReleasesByPrerelease removes non-prerelease entries from the envelope data.
func filterReleasesByPrerelease(env *output.Envelope) {
data, ok := env.Data.(map[string]interface{})
if !ok {
return
}
releases, ok := data["releases"].([]interface{})
if !ok {
return
}
filtered := make([]interface{}, 0, len(releases))
for _, r := range releases {
m, ok := r.(map[string]interface{})
if !ok {
continue
}
if isPrerelease(m) {
filtered = append(filtered, m)
}
}
data["releases"] = filtered
}
// keepOnlyLatestRelease keeps only the first (most recent) release.
func keepOnlyLatestRelease(env *output.Envelope) {
data, ok := env.Data.(map[string]interface{})
if !ok {
return
}
releases, ok := data["releases"].([]interface{})
if !ok || len(releases) == 0 {
return
}
data["releases"] = []interface{}{releases[0]}
}
// isPrerelease checks whether a release map has prerelease=true.
func isPrerelease(m map[string]interface{}) bool {
v, ok := m["prerelease"]
if !ok {
return false
}
switch val := v.(type) {
case bool:
return val
default:
return fmt.Sprint(val) == "true"
}
}

View File

@ -1,8 +1,10 @@
package repo
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
@ -12,6 +14,19 @@ func Shortcuts() []*common.Shortcut {
{
Name: "list",
Description: "List repositories for a user or organization",
Long: `List repositories visible to the current user.
By default shows repositories you manage. Use --user to list
another user's repositories. Use --category to filter by type
(manage, mirror, sync, fork, or all).`,
Example: ` # List your managed repositories
gitlink repo +list
# List all repositories for a specific user
gitlink repo +list --user zhangsan --category all
# List forked repositories with pagination
gitlink repo +list --category fork --page 2 --limit 10`,
Flags: []common.Flag{
{Name: "user", Short: "u", Usage: "User login (default: current user)"},
{Name: "category", Short: "c", Usage: "Filter: manage/mirror/sync/fork/all (default: manage)", Default: "manage"},
@ -41,6 +56,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "info",
Description: "Show repository details",
Long: `Display detailed information about the current repository.
Shows name, description, visibility, default branch, and other metadata.`,
Example: ` # Show details of the current repository
gitlink repo +info`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
@ -55,6 +75,15 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a new repository",
Long: `Create a new repository under your account.
The repository name is required. Optionally provide a description
and set visibility to private.`,
Example: ` # Create a public repository
gitlink repo +create --name my-project
# Create a private repository with a description
gitlink repo +create --name my-project --description "A cool project" --private true`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
{Name: "description", Short: "d", Usage: "Repository description"},
@ -97,6 +126,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "fork",
Description: "Fork a repository",
Long: `Fork the current repository into your account.
This creates a copy of the repository under your namespace.`,
Example: ` # Fork the current repository
gitlink repo +fork`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
@ -111,10 +145,21 @@ func Shortcuts() []*common.Shortcut {
{
Name: "delete",
Description: "Delete a repository",
Long: `Delete the current repository permanently.
This action is irreversible and removes all data including issues,
pull requests, and wiki. A confirmation prompt is shown before deletion.`,
Example: ` # Delete the current repository
gitlink repo +delete`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
if err := common.ConfirmAction(
fmt.Sprintf("delete repository %s/%s", ctx.Owner, ctx.Repo),
); err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
if err != nil {
return err
@ -122,5 +167,529 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// --- Settings/Info group ---
{
Name: "update",
Description: "Update repository settings",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "New repository name"},
{Name: "description", Short: "d", Usage: "Repository description"},
{Name: "private", Usage: "Make repository private (true/false)"},
{Name: "website", Short: "w", Usage: "Repository website URL"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
body := map[string]interface{}{}
if v := ctx.Arg("name"); v != "" {
body["name"] = v
}
if v := ctx.Arg("description"); v != "" {
body["description"] = v
}
if v := ctx.Arg("private"); v != "" {
body["private"] = v == "true"
}
if v := ctx.Arg("website"); v != "" {
body["website"] = v
}
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "about",
Description: "Show repository about page",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/about", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "menu",
Description: "List repository navigation menu",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/menu_list", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "units-get",
Description: "Get repository navigation settings",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/project_units", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "units-set",
Description: "Update repository navigation settings",
Flags: []common.Flag{
{Name: "units", Short: "u", Usage: "JSON array of unit settings", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
unitsRaw, err := ctx.RequireArg("units")
if err != nil {
return err
}
var units []interface{}
if err := json.Unmarshal([]byte(unitsRaw), &units); err != nil {
return fmt.Errorf("invalid JSON for --units: %w", err)
}
body := map[string]interface{}{
"units": units,
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/project_units", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "edit-detail",
Description: "Get repository edit details",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/edit", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "simple",
Description: "Get repository simplified details",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/simple", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Stats group ---
{
Name: "code-stats",
Description: "Get repository code statistics",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/code_stats", ctx.RepoPath()), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "languages",
Description: "Get repository language breakdown",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/languages", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "contributors",
Description: "List repository contributors",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/contributors", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "contributors-stat",
Description: "Get contributor code statistics",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/contributors/stat", ctx.RepoPath()), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Social/Interaction group ---
{
Name: "recommend",
Description: "Get recommended projects",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/projects/recommend", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "star",
Description: "Star (like) a project",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/projects/%s/praise_tread/like", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "unstar",
Description: "Unstar (unlike) a project",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/projects/%s/praise_tread/unlike", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "watch",
Description: "Watch (follow) a project",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
idInt, err := strconv.Atoi(id)
if err != nil {
return fmt.Errorf("invalid project id: %w", err)
}
body := map[string]interface{}{
"project_id": idInt,
}
env, err := ctx.CallAPI("POST", "/watchers/follow", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "unwatch",
Description: "Unwatch (unfollow) a project",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/watchers/unfollow?project_id=%s", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "stargazers",
Description: "List users who starred the repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/stargazers", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "watchers",
Description: "List users watching the repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/watchers", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Transfer group ---
{
Name: "transfer",
Description: "Transfer repository to another owner",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Target owner/organization", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
body := map[string]interface{}{
"owner": owner,
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "cancel-transfer",
Description: "Cancel a pending repository transfer",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects/cancel", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "transfer-orgs",
Description: "List organizations available for transfer",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/applied_transfer_projects/organizations", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "invite-link",
Description: "Get current repository invite link",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/project_invite_links/current_link", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- L3 Operations: Join/Quit/Mirror/Topics ---
{
Name: "join",
Description: "Apply to join a project",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
{Name: "role", Short: "r", Usage: "Desired role"},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body := map[string]interface{}{
"project_id": id,
}
if role := ctx.Arg("role"); role != "" {
body["role"] = role
}
env, err := ctx.CallAPI("POST", "/applied_projects", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "quit",
Description: "Quit a project",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/quit", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "migrate",
Description: "Create a mirrored project",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
{Name: "clone-url", Short: "u", Usage: "Upstream clone URL", Required: true},
{Name: "private", Usage: "Make repository private (true/false)", Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
cloneURL, err := ctx.RequireArg("clone-url")
if err != nil {
return err
}
body := map[string]interface{}{
"repository_name": name,
"clone_addr": cloneURL,
}
if ctx.Arg("private") == "true" {
body["private"] = true
}
env, err := ctx.CallAPI("POST", "/projects/migrate", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "sync-mirror",
Description: "Sync a mirrored repository",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Repository ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/repositories/%s/sync_mirror", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "topics",
Description: "List project topics",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/v1/project_topics", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create-topic",
Description: "Create a project topic",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Topic name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
body := map[string]interface{}{
"name": name,
}
env, err := ctx.CallAPI("POST", "/v1/project_topics", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete-topic",
Description: "Delete a project topic",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Topic ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/project_topics/%s", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

543
shortcuts/repo/repo_test.go Normal file
View File

@ -0,0 +1,543 @@
package repo
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// --- Helpers ---
func runRepoShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findRepoShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findRepoShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return payload
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
// --- Required tests ---
func TestRepoUpdateSendsPATCH(t *testing.T) {
var calledMethod string
var calledPath string
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"name": "new-name",
"description": "updated desc",
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "update", map[string]string{
"name": "new-name",
"description": "updated desc",
"private": "true",
"website": "https://example.com",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "PATCH")
assertEqual(t, calledPath, "/owner/repo.json")
assertEqual(t, payload["name"], "new-name")
assertEqual(t, payload["description"], "updated desc")
assertEqual(t, payload["private"], true)
assertEqual(t, payload["website"], "https://example.com")
}
func TestRepoAboutCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"name": "test-repo",
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "about", nil)
if err != nil {
t.Fatalf("about shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "GET")
assertEqual(t, calledPath, "/owner/repo/about.json")
}
func TestRepoStarCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"status": 0,
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "star", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("star shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/projects/42/praise_tread/like.json")
}
func TestRepoLanguagesCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"Go": float64(80),
"Java": float64(20),
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "languages", nil)
if err != nil {
t.Fatalf("languages shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "GET")
assertEqual(t, calledPath, "/owner/repo/languages.json")
}
// --- Additional coverage tests ---
func TestRepoMenuCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"menus": []interface{}{}})
}))
defer server.Close()
err := runRepoShortcut(t, server, "menu", nil)
if err != nil {
t.Fatalf("menu shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/menu_list.json")
}
func TestRepoUnitsGetCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"units": []interface{}{}})
}))
defer server.Close()
err := runRepoShortcut(t, server, "units-get", nil)
if err != nil {
t.Fatalf("units-get shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/project_units.json")
}
func TestRepoUnitsSetPostsCorrectPayload(t *testing.T) {
var calledMethod string
var calledPath string
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
body = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runRepoShortcut(t, server, "units-set", map[string]string{
"units": `[{"type":"code","enabled":true}]`,
})
if err != nil {
t.Fatalf("units-set shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/owner/repo/project_units.json")
// The units JSON string should be unmarshaled into a slice
units, ok := body["units"].([]interface{})
if !ok || len(units) == 0 {
t.Fatalf("expected units to be a non-empty array, got %v", body["units"])
}
}
func TestRepoCodeStatsUsesV1Endpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"additions": float64(100),
"deletions": float64(20),
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "code-stats", nil)
if err != nil {
t.Fatalf("code-stats shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/code_stats.json")
}
func TestRepoContributorsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"contributors": []interface{}{},
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "contributors", nil)
if err != nil {
t.Fatalf("contributors shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/contributors.json")
}
func TestRepoContributorsStatUsesV1Endpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"contributors": []interface{}{},
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "contributors-stat", nil)
if err != nil {
t.Fatalf("contributors-stat shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/contributors/stat.json")
}
func TestRepoRecommendCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"projects": []interface{}{},
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "recommend", nil)
if err != nil {
t.Fatalf("recommend shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/projects/recommend.json")
}
func TestRepoUnstarCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runRepoShortcut(t, server, "unstar", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("unstar shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "DELETE")
assertEqual(t, calledPath, "/projects/42/praise_tread/unlike.json")
}
func TestRepoWatchCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
body = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runRepoShortcut(t, server, "watch", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("watch shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/watchers/follow.json")
assertEqual(t, body["project_id"], float64(42))
}
func TestRepoUnwatchCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runRepoShortcut(t, server, "unwatch", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("unwatch shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "DELETE")
assertEqual(t, calledPath, "/watchers/unfollow.json")
}
func TestRepoStargazersCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"stargazers": []interface{}{},
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "stargazers", nil)
if err != nil {
t.Fatalf("stargazers shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/stargazers.json")
}
func TestRepoWatchersCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"watchers": []interface{}{},
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "watchers", nil)
if err != nil {
t.Fatalf("watchers shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/watchers.json")
}
func TestRepoTransferCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runRepoShortcut(t, server, "transfer", map[string]string{
"owner": "new-org",
})
if err != nil {
t.Fatalf("transfer shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/owner/repo/applied_transfer_projects.json")
}
func TestRepoCancelTransferCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runRepoShortcut(t, server, "cancel-transfer", nil)
if err != nil {
t.Fatalf("cancel-transfer shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/owner/repo/applied_transfer_projects/cancel.json")
}
func TestRepoTransferOrgsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"organizations": []interface{}{},
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "transfer-orgs", nil)
if err != nil {
t.Fatalf("transfer-orgs shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/applied_transfer_projects/organizations.json")
}
func TestRepoInviteLinkCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"link": "https://example.com/invite/abc",
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "invite-link", nil)
if err != nil {
t.Fatalf("invite-link shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/project_invite_links/current_link.json")
}
func TestRepoEditDetailCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "edit-detail", nil)
if err != nil {
t.Fatalf("edit-detail shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/edit.json")
}
func TestRepoSimpleCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "simple", nil)
if err != nil {
t.Fatalf("simple shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/simple.json")
}
func TestRepoStarRequiresID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when id is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "star", nil)
if err == nil {
t.Fatal("expected error when id is missing, got nil")
}
}
func TestRepoWatchRequiresID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when id is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "watch", nil)
if err == nil {
t.Fatal("expected error when id is missing, got nil")
}
}
func TestRepoUnitsSetRequiresUnits(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when units is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "units-set", nil)
if err == nil {
t.Fatal("expected error when units is missing, got nil")
}
}
func TestRepoTransferRequiresOwner(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when owner is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "transfer", nil)
if err == nil {
t.Fatal("expected error when owner is missing, got nil")
}
}

View File

@ -11,10 +11,29 @@ func Shortcuts() []*common.Shortcut {
{
Name: "repos",
Description: "Search repositories",
Long: `Search for repositories on GitLink by keyword.
Returns matching repository names, owners, and descriptions.
Use --language to filter by programming language, --sort and --order
to control ordering, and --page and --limit for pagination.`,
Example: ` # Search for repositories
gitlink search +repos --keyword "cli"
# Show 50 results per page
gitlink search +repos --keyword "cli" --limit 50
# Filter by language
gitlink search +repos --keyword "web" --language Go
# Sort by update time descending
gitlink search +repos --keyword "cli" --sort updated --order desc`,
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "language", Usage: "Filter by programming language"},
{Name: "sort", Usage: "Sort field (e.g. stars, updated)"},
{Name: "order", Usage: "Sort direction: asc or desc"},
},
Run: func(ctx *common.RuntimeContext) error {
keyword, _ := ctx.RequireArg("keyword")
@ -22,6 +41,15 @@ func Shortcuts() []*common.Shortcut {
q.Set("search", keyword)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if l := ctx.Arg("language"); l != "" {
q.Set("language", l)
}
if s := ctx.Arg("sort"); s != "" {
q.Set("sort", s)
}
if o := ctx.Arg("order"); o != "" {
q.Set("order", o)
}
env, err := ctx.CallAPIWithQuery("GET", "/projects", q)
if err != nil {
return err
@ -32,10 +60,25 @@ func Shortcuts() []*common.Shortcut {
{
Name: "users",
Description: "Search users",
Long: `Search for users on GitLink by keyword.
Returns matching user login names, display names, and profile summaries.
Use --sort and --order to control ordering, and --page and --limit
for pagination.`,
Example: ` # Search for users
gitlink search +users --keyword "zhang"
# Show 50 results per page
gitlink search +users --keyword "zhang" --limit 50
# Sort results
gitlink search +users --keyword "zhang" --sort created --order desc`,
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "sort", Usage: "Sort field"},
{Name: "order", Usage: "Sort direction: asc or desc"},
},
Run: func(ctx *common.RuntimeContext) error {
keyword, _ := ctx.RequireArg("keyword")
@ -43,6 +86,12 @@ func Shortcuts() []*common.Shortcut {
q.Set("search", keyword)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("sort"); s != "" {
q.Set("sort", s)
}
if o := ctx.Arg("order"); o != "" {
q.Set("order", o)
}
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
if err != nil {
return err

View File

@ -0,0 +1,267 @@
package snippet
import (
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const snippetsDir = "snippets"
// Shortcuts returns code snippet management shortcuts.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List code snippets in the repository",
Run: runList,
},
{
Name: "view",
Description: "View a code snippet",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Snippet name", Required: true},
},
Run: runView,
},
{
Name: "create",
Description: "Create a new code snippet",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Snippet name (used as filename)", Required: true},
{Name: "content", Short: "c", Usage: "Snippet content (code)", Required: true},
{Name: "language", Short: "l", Usage: "Programming language (e.g. go, python, js)"},
{Name: "message", Short: "m", Usage: "Commit message"},
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
},
Run: runCreate,
},
{
Name: "delete",
Description: "Delete a code snippet",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Snippet name", Required: true},
{Name: "message", Short: "m", Usage: "Commit message"},
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
},
Run: runDelete,
},
}
}
// Override repoGitURLOverride in tests to point to a local repo.
var repoGitURLOverride string
// repoGitURL returns the public Git URL for the main repository.
func repoGitURL(owner, repo string) string {
if repoGitURLOverride != "" {
return repoGitURLOverride
}
return fmt.Sprintf("https://gitlink.org.cn/%s/%s.git", owner, repo)
}
// runGit runs a git command in the specified directory.
func runGit(dir string, args ...string) (stdout, stderr string, err error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
var outBuf, errBuf strings.Builder
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err = cmd.Run()
return outBuf.String(), errBuf.String(), err
}
// snippetFileName returns the full file path in the repo for a snippet.
func snippetFileName(name string) string {
if strings.Contains(name, ".") {
return fmt.Sprintf("%s/%s", snippetsDir, name)
}
return fmt.Sprintf("%s/%s.md", snippetsDir, name)
}
// buildSnippetContent builds the markdown content for a snippet.
func buildSnippetContent(language, code string) string {
var b strings.Builder
b.WriteString("```")
b.WriteString(language)
b.WriteString("\n")
b.WriteString(code)
if !strings.HasSuffix(code, "\n") {
b.WriteString("\n")
}
b.WriteString("```\n")
return b.String()
}
// repoFilePath returns the API path prefix for repo file operations.
func repoFilePath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/%s/%s", ctx.Owner, ctx.Repo)
}
func runList(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// Clone repo to temp dir (read-only for public repos)
tmpDir, err := os.MkdirTemp("", "gitlink-snippet-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
_, stderr, err := runGit("", "clone", "--depth", "1", repoGitURL(ctx.Owner, ctx.Repo), tmpDir)
if err != nil {
return fmt.Errorf("clone repo: %s%s", stderr, err)
}
// List files in the snippets directory
snippetPath := filepath.Join(tmpDir, snippetsDir)
entries, err := os.ReadDir(snippetPath)
if err != nil {
return ctx.OutputData(map[string]interface{}{
"snippets": []interface{}{},
"total": 0,
})
}
snippets := make([]map[string]interface{}, 0)
for _, e := range entries {
if !e.IsDir() {
snippets = append(snippets, map[string]interface{}{
"name": e.Name(),
})
}
}
return ctx.OutputData(map[string]interface{}{
"snippets": snippets,
"total": len(snippets),
})
}
func runView(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
// Clone repo to temp dir (read-only for public repos)
tmpDir, err := os.MkdirTemp("", "gitlink-snippet-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
_, stderr, err := runGit("", "clone", "--depth", "1", repoGitURL(ctx.Owner, ctx.Repo), tmpDir)
if err != nil {
return fmt.Errorf("clone repo: %s%s", stderr, err)
}
filePath := snippetFileName(name)
content, err := os.ReadFile(filepath.Join(tmpDir, filePath))
if err != nil {
return fmt.Errorf("snippet %q not found", name)
}
return ctx.OutputData(map[string]interface{}{
"name": name,
"content": string(content),
})
}
func runCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
language := ctx.Arg("language")
message := ctx.Arg("message")
if message == "" {
message = fmt.Sprintf("Add snippet %q", name)
}
filePath := snippetFileName(name)
snippetContent := buildSnippetContent(language, content)
payload := map[string]interface{}{
"filepath": filePath,
"content": base64.StdEncoding.EncodeToString([]byte(snippetContent)),
"message": message,
"branch": ctx.Arg("branch"),
}
if _, err = ctx.CallAPI("POST", repoFilePath(ctx)+"/create_file", payload); err != nil {
return fmt.Errorf("create snippet: %w", err)
}
return ctx.OutputData(map[string]interface{}{
"name": name,
"message": "Snippet created",
})
}
func runDelete(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
message := ctx.Arg("message")
if message == "" {
message = fmt.Sprintf("Delete snippet %q", name)
}
filePath := snippetFileName(name)
// Auto-fetch file SHA using Git (public repo)
tmpDir, err := os.MkdirTemp("", "gitlink-snippet-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
_, stderr, err := runGit("", "clone", "--depth", "1", repoGitURL(ctx.Owner, ctx.Repo), tmpDir)
if err != nil {
return fmt.Errorf("clone repo: %s%s", stderr, err)
}
// Get blob SHA of the file
shaOut, _, err := runGit(tmpDir, "hash-object", filePath)
sha := strings.TrimSpace(shaOut)
payload := map[string]interface{}{
"filepath": filePath,
"message": message,
"branch": ctx.Arg("branch"),
"sha": sha,
}
if _, err = ctx.CallAPI("DELETE", repoFilePath(ctx)+"/delete_file", payload); err != nil {
return fmt.Errorf("delete snippet: %w", err)
}
return ctx.OutputData(map[string]interface{}{
"name": name,
"message": "Snippet deleted",
})
}

View File

@ -0,0 +1,404 @@
package snippet
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// --- Git repo test helpers ---
// setupSnippetRepo creates a bare Git repo with snippets, returns the repo path.
func setupSnippetRepo(t *testing.T, files map[string]string) (bareRepo string, cleanup func()) {
t.Helper()
remoteDir, err := os.MkdirTemp("", "gitlink-snippet-remote-*")
if err != nil {
t.Fatalf("mkdir remote: %v", err)
}
runGitClean("", "init", "--bare", remoteDir)
workDir, err := os.MkdirTemp("", "gitlink-snippet-work-*")
if err != nil {
os.RemoveAll(remoteDir)
t.Fatalf("mkdir work: %v", err)
}
runGitClean("", "clone", remoteDir, workDir)
runGitClean(workDir, "config", "user.email", "test@test.com")
runGitClean(workDir, "config", "user.name", "Test User")
// Create snippets directory and files
snippetPath := filepath.Join(workDir, "snippets")
os.MkdirAll(snippetPath, 0755)
if len(files) == 0 {
os.WriteFile(filepath.Join(snippetPath, ".gitkeep"), []byte(""), 0644)
}
for name, content := range files {
os.WriteFile(filepath.Join(snippetPath, name), []byte(content), 0644)
}
runGitClean(workDir, "add", ".")
runGitClean(workDir, "commit", "-m", "Add snippets")
runGitClean(workDir, "push", "origin", "master")
os.RemoveAll(workDir)
return remoteDir, func() { os.RemoveAll(remoteDir) }
}
func runGitClean(dir string, args ...string) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
panic("git " + strings.Join(args, " ") + ": " + string(out) + ": " + err.Error())
}
}
// --- HTTP test helpers ---
func runSnippetShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findSnippetShortcut(t, name)
var cli *client.Client
if server != nil {
cli = &client.Client{HTTP: server.Client(), BaseURL: server.URL}
} else {
cli = &client.Client{HTTP: http.DefaultClient, BaseURL: ""}
}
ctx := &common.RuntimeContext{
Client: cli,
Owner: "test-owner",
Repo: "test-repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findSnippetShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func newSnippetTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return payload
}
// --- Git-based tests (list, view) ---
func TestSnippetList(t *testing.T) {
files := map[string]string{
"hello.go": "package main\nfunc main() {}\n",
"sort.py": "def sort():\n pass\n",
"readme.md": "# Snippets\n",
}
repo, cleanup := setupSnippetRepo(t, files)
defer cleanup()
repoGitURLOverride = repo
defer func() { repoGitURLOverride = "" }()
if err := runSnippetShortcut(t, nil, "list", nil); err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestSnippetListEmpty(t *testing.T) {
repo, cleanup := setupSnippetRepo(t, map[string]string{})
defer cleanup()
repoGitURLOverride = repo
defer func() { repoGitURLOverride = "" }()
if err := runSnippetShortcut(t, nil, "list", nil); err != nil {
t.Fatalf("list empty failed: %v", err)
}
}
func TestSnippetListNoSnippetsDir(t *testing.T) {
// Create a bare repo with no snippets directory
remoteDir, err := os.MkdirTemp("", "gitlink-snippet-empty-*")
if err != nil {
t.Fatalf("mkdir: %v", err)
}
defer os.RemoveAll(remoteDir)
runGitClean("", "init", "--bare", remoteDir)
workDir, err := os.MkdirTemp("", "gitlink-snippet-work-*")
if err != nil {
t.Fatalf("mkdir: %v", err)
}
defer os.RemoveAll(workDir)
runGitClean("", "clone", remoteDir, workDir)
runGitClean(workDir, "config", "user.email", "test@test.com")
runGitClean(workDir, "config", "user.name", "Test User")
os.WriteFile(filepath.Join(workDir, ".gitkeep"), []byte(""), 0644)
runGitClean(workDir, "add", ".")
runGitClean(workDir, "commit", "-m", "init")
runGitClean(workDir, "push", "origin", "master")
os.RemoveAll(workDir)
repoGitURLOverride = remoteDir
defer func() { repoGitURLOverride = "" }()
if err := runSnippetShortcut(t, nil, "list", nil); err != nil {
t.Fatalf("list no snippets dir failed: %v", err)
}
}
func TestSnippetView(t *testing.T) {
files := map[string]string{
"hello.go": "package main\nfunc main() {}\n",
}
repo, cleanup := setupSnippetRepo(t, files)
defer cleanup()
repoGitURLOverride = repo
defer func() { repoGitURLOverride = "" }()
if err := runSnippetShortcut(t, nil, "view", map[string]string{"name": "hello.go"}); err != nil {
t.Fatalf("view failed: %v", err)
}
}
func TestSnippetViewNotFound(t *testing.T) {
files := map[string]string{
"hello.go": "package main\n",
}
repo, cleanup := setupSnippetRepo(t, files)
defer cleanup()
repoGitURLOverride = repo
defer func() { repoGitURLOverride = "" }()
err := runSnippetShortcut(t, nil, "view", map[string]string{"name": "nope.go"})
if err == nil {
t.Fatal("expected error for non-existent snippet")
}
}
// --- API-based tests (create, delete) ---
func TestSnippetCreate(t *testing.T) {
var payload map[string]interface{}
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "created"})
})
defer server.Close()
err := runSnippetShortcut(t, server, "create", map[string]string{
"name": "hello.go",
"content": "package main\n\nfunc main() {}",
"language": "go",
"message": "Add hello.go snippet",
"branch": "master",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
if payload["filepath"] != "snippets/hello.go" {
t.Fatalf("filepath = %q, want snippets/hello.go", payload["filepath"])
}
if payload["branch"] != "master" {
t.Fatalf("branch = %q, want master", payload["branch"])
}
}
func TestSnippetCreateWithoutExtension(t *testing.T) {
var payload map[string]interface{}
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "created"})
})
defer server.Close()
err := runSnippetShortcut(t, server, "create", map[string]string{
"name": "myscript",
"content": "#!/bin/bash\necho hello",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
if payload["filepath"] != "snippets/myscript.md" {
t.Fatalf("filepath = %q, want snippets/myscript.md", payload["filepath"])
}
}
func TestSnippetDelete(t *testing.T) {
// Delete needs a Git repo to fetch SHA, plus an API server for the delete call
files := map[string]string{
"hello.go": "package main\nfunc main() {}\n",
}
repo, cleanup := setupSnippetRepo(t, files)
defer cleanup()
repoGitURLOverride = repo
defer func() { repoGitURLOverride = "" }()
// API server for delete
var payload map[string]interface{}
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
})
defer server.Close()
err := runSnippetShortcut(t, server, "delete", map[string]string{
"name": "hello.go",
"message": "Remove snippet",
})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
if payload["filepath"] != "snippets/hello.go" {
t.Fatalf("filepath = %q, want snippets/hello.go", payload["filepath"])
}
if payload["sha"] == "" {
t.Fatal("sha should not be empty")
}
}
func TestSnippetDeleteDefaultMessage(t *testing.T) {
files := map[string]string{"old.py": "print('old')\n"}
repo, cleanup := setupSnippetRepo(t, files)
defer cleanup()
repoGitURLOverride = repo
defer func() { repoGitURLOverride = "" }()
var payload map[string]interface{}
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{})
})
defer server.Close()
if err := runSnippetShortcut(t, server, "delete", map[string]string{"name": "old.py"}); err != nil {
t.Fatalf("delete failed: %v", err)
}
if msg, ok := payload["message"].(string); !ok || !strings.Contains(msg, "old.py") {
t.Fatalf("message = %q, should mention old.py", payload["message"])
}
}
// --- Validation tests ---
func TestSnippetCreateRejectsMissingName(t *testing.T) {
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("should not call API")
})
defer server.Close()
err := runSnippetShortcut(t, server, "create", map[string]string{"content": "code"})
if err == nil {
t.Fatal("expected error when --name is missing")
}
}
func TestSnippetCreateRejectsMissingContent(t *testing.T) {
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("should not call API")
})
defer server.Close()
err := runSnippetShortcut(t, server, "create", map[string]string{"name": "test.go"})
if err == nil {
t.Fatal("expected error when --content is missing")
}
}
func TestSnippetViewRejectsMissingName(t *testing.T) {
err := findSnippetShortcut(t, "view").Run(&common.RuntimeContext{
Owner: "test",
Repo: "test",
Format: "json",
Args: map[string]string{},
})
if err == nil {
t.Fatal("expected error when --name is missing")
}
}
func TestSnippetDeleteRejectsMissingName(t *testing.T) {
err := findSnippetShortcut(t, "delete").Run(&common.RuntimeContext{
Owner: "test",
Repo: "test",
Format: "json",
Args: map[string]string{},
})
if err == nil {
t.Fatal("expected error when --name is missing")
}
}
// --- Unit tests ---
func TestSnippetFileName(t *testing.T) {
tests := []struct{ input, want string }{
{"hello.go", "snippets/hello.go"},
{"myscript.py", "snippets/myscript.py"},
{"myscript", "snippets/myscript.md"},
{"subdir/file.js", "snippets/subdir/file.js"},
}
for _, tt := range tests {
got := snippetFileName(tt.input)
if got != tt.want {
t.Errorf("snippetFileName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestBuildSnippetContent(t *testing.T) {
got := buildSnippetContent("go", "package main\nfunc main() {}")
if !strings.Contains(got, "```go") {
t.Errorf("expected ```go in output, got: %s", got)
}
if !strings.Contains(got, "package main") {
t.Errorf("expected content in output, got: %s", got)
}
got2 := buildSnippetContent("", "echo hello")
if !strings.Contains(got2, "```\n") {
t.Errorf("expected ``` without lang, got: %s", got2)
}
}

View File

@ -0,0 +1,93 @@
package sshkey
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List your SSH public keys",
Long: `List your SSH public keys.
Shows all SSH public keys associated with your account, including
key ID, title, fingerprint, and creation date.
Supports pagination with --page and --limit flags.`,
Example: ` # List all SSH keys
gitlink sshkey +list
# List with pagination
gitlink sshkey +list -p 2 -l 10`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/public_keys", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Add a new SSH public key",
Long: `Add a new SSH public key.
Registers a new SSH public key to your account. The key content must
be a valid public key in ssh-rsa or ssh-ed25519 format.`,
Example: ` # Add an SSH key
gitlink sshkey +create -t "My Laptop" -k "ssh-rsa AAAAB3..."`,
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Key title", Required: true},
{Name: "key", Short: "k", Usage: "Public key content (ssh-rsa/ed25519 ...)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
title, err := ctx.RequireArg("title")
if err != nil {
return err
}
key, err := ctx.RequireArg("key")
if err != nil {
return err
}
payload := map[string]string{
"title": title,
"key": key,
}
env, err := ctx.CallAPI("POST", "/public_keys", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete an SSH public key",
Long: `Delete an SSH public key.
Removes an SSH public key from your account by its ID. This action cannot be undone.`,
Example: ` # Delete an SSH key by ID
gitlink sshkey +delete -i 123`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Key ID to delete", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/public_keys/%s", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

101
shortcuts/tag/tag.go Normal file
View File

@ -0,0 +1,101 @@
package tag
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List repository tags",
Long: `List all tags in the current repository.
Shows tag name, commit SHA, and creation date.
Use --search to filter tags by name pattern.`,
Example: ` # List all tags
gitlink tag +list
# Search for tags matching a pattern
gitlink tag +list --search "v1."
# Paginate through tags
gitlink tag +list --page 2 --limit 50`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "search", Short: "s", Usage: "Filter by tag name"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
path := fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPIWithQuery("GET", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View tag details",
Long: `Show detailed information about a specific tag.
Displays the tag name, associated commit, message, and tagger details.`,
Example: ` # View details of a tag
gitlink tag +view --name v1.0.0`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Tag name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/tags/%s", ctx.Owner, ctx.Repo, name), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a tag",
Long: `Delete a tag from the current repository.
This action is irreversible. A confirmation prompt is shown before deletion.`,
Example: ` # Delete a tag
gitlink tag +delete --name v0.9-beta`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Tag name to delete", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
if err := common.ConfirmAction(
fmt.Sprintf("delete tag %s", name),
); err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/tags/%s", ctx.Owner, ctx.Repo, name), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,145 @@
package template
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List project templates for an owner",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/project_templates", owner), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a project template",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
{Name: "name", Short: "n", Usage: "Template name", Required: true},
{Name: "description", Short: "d", Usage: "Template description"},
},
Run: func(ctx *common.RuntimeContext) error {
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
payload := map[string]interface{}{
"name": name,
}
if desc := ctx.Arg("description"); desc != "" {
payload["description"] = desc
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/project_templates", owner), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View a project template",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
{Name: "repo", Short: "r", Usage: "Repository name (optional, for repo-scoped templates)"},
},
Run: func(ctx *common.RuntimeContext) error {
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
var path string
if repo := ctx.Arg("repo"); repo != "" {
path = fmt.Sprintf("/v1/%s/%s/project_templates/%s", owner, repo, id)
} else {
path = fmt.Sprintf("/v1/%s/project_templates/%s", owner, id)
}
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a project template",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
{Name: "name", Short: "n", Usage: "New template name"},
{Name: "description", Short: "d", Usage: "New template description"},
},
Run: func(ctx *common.RuntimeContext) error {
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
payload := map[string]interface{}{}
if name := ctx.Arg("name"); name != "" {
payload["name"] = name
}
if desc := ctx.Arg("description"); desc != "" {
payload["description"] = desc
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/v1/%s/project_templates/%s", owner, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a project template",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/project_templates/%s", owner, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -1,7 +1,9 @@
package user
import (
"encoding/json"
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
@ -11,6 +13,12 @@ func Shortcuts() []*common.Shortcut {
{
Name: "me",
Description: "Show current authenticated user",
Long: `Show profile information for the currently authenticated user.
Displays login name, email, and account details based on the
configured API token.`,
Example: ` # Show current user profile
gitlink user +me`,
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
@ -22,6 +30,12 @@ func Shortcuts() []*common.Shortcut {
{
Name: "info",
Description: "Show user profile",
Long: `Show public profile information for a GitLink user.
Looks up the user by their login name and displays their
username, bio, and other public profile fields.`,
Example: ` # Show profile for a specific user
gitlink user +info --login zhangsan`,
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
@ -37,5 +51,732 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// --- Message management ---
{
Name: "messages",
Description: "List user messages",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "page", Usage: "Page number", Default: "1"},
{Name: "limit", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
page := ctx.Arg("page")
if page == "" {
page = "1"
}
limit := ctx.Arg("limit")
if limit == "" {
limit = "20"
}
query := url.Values{}
query.Set("page", page)
query.Set("limit", limit)
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/messages", login), query)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete-message",
Description: "Delete a message",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Message ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
query := url.Values{}
query.Set("id", id)
env, err := ctx.CallAPIWithQuery("DELETE", fmt.Sprintf("/users/%s/messages", login), query)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create-message",
Description: "Create a message",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "content", Usage: "Message content", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/messages", login), map[string]interface{}{
"content": content,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "read-message",
Description: "Mark a message as read",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Message ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/messages/read", login), map[string]interface{}{
"id": id,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "msg-settings",
Description: "Get message notification settings",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/template_message_settings", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update-msg-settings",
Description: "Update message notification settings",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "settings", Usage: "Settings JSON array", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
settingsStr, err := ctx.RequireArg("settings")
if err != nil {
return err
}
var settings []interface{}
if err := json.Unmarshal([]byte(settingsStr), &settings); err != nil {
return fmt.Errorf("invalid settings JSON: %w", err)
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/template_message_settings/update_setting", login), map[string]interface{}{
"setting": settings,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Pinned projects ---
{
Name: "pinned-projects",
Description: "List pinned projects",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/is_pinned_projects", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "pin-project",
Description: "Pin a project",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Project ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/is_pinned_projects/pin", login), map[string]interface{}{
"id": id,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "reorder-pinned",
Description: "Reorder a pinned project",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Pinned project ID", Required: true},
{Name: "position", Usage: "New position", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
position, err := ctx.RequireArg("position")
if err != nil {
return err
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/users/%s/is_pinned_projects/%s", login, id), map[string]interface{}{
"position": position,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Statistics ---
{
Name: "activity",
Description: "Get user activity statistics",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/activity", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "headmap",
Description: "Get user heatmap data",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/headmaps", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "develop-stats",
Description: "Get user development statistics",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/develop", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "role-stats",
Description: "Get user role statistics",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/role", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "major-stats",
Description: "Get user major statistics",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/major", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Transfer / Apply ---
{
Name: "applied-transfers",
Description: "List applied transfer projects",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/applied_transfer_projects", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "accept-transfer",
Description: "Accept a project transfer",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Transfer ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/applied_transfer_projects/%s/accept", login, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "refuse-transfer",
Description: "Refuse a project transfer",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Transfer ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/applied_transfer_projects/%s/refuse", login, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "applied-projects",
Description: "List applied projects (join requests)",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/applied_projects", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "accept-join",
Description: "Accept a project join request",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Application ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/applied_projects/%s/accept", login, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "refuse-join",
Description: "Refuse a project join request",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Application ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/applied_projects/%s/refuse", login, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Feedback / Update ---
{
Name: "feedback",
Description: "Submit feedback",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "content", Usage: "Feedback content", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/feedbacks", login), map[string]interface{}{
"content": content,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update user profile",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "nickname", Usage: "New nickname"},
{Name: "description", Usage: "New description"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
body := map[string]interface{}{}
if v := ctx.Arg("nickname"); v != "" {
body["nickname"] = v
}
if v := ctx.Arg("description"); v != "" {
body["description"] = v
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/users/%s", login), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Account settings ---
{
Name: "change-password",
Description: "Change password",
Flags: []common.Flag{
{Name: "old-password", Usage: "Current password", Required: true},
{Name: "new-password", Usage: "New password", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
oldPwd, err := ctx.RequireArg("old-password")
if err != nil {
return err
}
newPwd, err := ctx.RequireArg("new-password")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", "/accounts/change_password", map[string]interface{}{
"old_password": oldPwd,
"new_password": newPwd,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update-email",
Description: "Update email address",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "email", Usage: "New email address", Required: true},
{Name: "code", Usage: "Verification code"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
email, err := ctx.RequireArg("email")
if err != nil {
return err
}
body := map[string]interface{}{
"email": email,
}
if v := ctx.Arg("code"); v != "" {
body["code"] = v
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/update_email", login), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update-phone",
Description: "Update phone number",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "phone", Usage: "New phone number", Required: true},
{Name: "code", Usage: "Verification code"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
phone, err := ctx.RequireArg("phone")
if err != nil {
return err
}
body := map[string]interface{}{
"phone": phone,
}
if v := ctx.Arg("code"); v != "" {
body["code"] = v
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/update_phone", login), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "send-verify",
Description: "Send email verification code",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "email", Usage: "Email address"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
query := url.Values{}
if v := ctx.Arg("email"); v != "" {
query.Set("email", v)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/send_email_vefify_code", login), query)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "check-verify",
Description: "Check email verification code",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "code", Usage: "Verification code", Required: true},
{Name: "email", Usage: "Email address"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
code, err := ctx.RequireArg("code")
if err != nil {
return err
}
body := map[string]interface{}{
"code": code,
}
if v := ctx.Arg("email"); v != "" {
body["email"] = v
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/check_email_verify_code", login), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "check-password",
Description: "Verify password",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "password", Usage: "Password to verify", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
password, err := ctx.RequireArg("password")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/check_password", login), map[string]interface{}{
"password": password,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "check-email",
Description: "Verify email",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "email", Usage: "Email to verify", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
email, err := ctx.RequireArg("email")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/check_email", login), map[string]interface{}{
"email": email,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "can-delete",
Description: "Check if user account can be deleted",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/check_user_can_delete", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete (deactivate) user account",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

544
shortcuts/user/user_test.go Normal file
View File

@ -0,0 +1,544 @@
package user
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// --- Helpers ---
func runUserShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findUserShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findUserShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return payload
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
// --- Required tests ---
func TestUserMessagesListsMessages(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"messages": []interface{}{},
})
}))
defer server.Close()
err := runUserShortcut(t, server, "messages", map[string]string{
"login": "testuser",
"page": "1",
"limit": "20",
})
if err != nil {
t.Fatalf("messages shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "GET")
assertEqual(t, calledPath, "/users/testuser/messages.json")
}
func TestUserFeedbackSendsPOST(t *testing.T) {
var calledMethod string
var calledPath string
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "feedback", map[string]string{
"login": "testuser",
"content": "Great platform!",
})
if err != nil {
t.Fatalf("feedback shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/v1/testuser/feedbacks.json")
assertEqual(t, payload["content"], "Great platform!")
}
// --- Additional coverage tests ---
func TestUserDeleteMessageCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "delete-message", map[string]string{
"login": "testuser",
"id": "99",
})
if err != nil {
t.Fatalf("delete-message shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "DELETE")
assertEqual(t, calledPath, "/users/testuser/messages.json")
}
func TestUserCreateMessageSendsPOST(t *testing.T) {
var calledMethod string
var calledPath string
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "create-message", map[string]string{
"login": "testuser",
"content": "Hello!",
})
if err != nil {
t.Fatalf("create-message shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/messages.json")
assertEqual(t, payload["content"], "Hello!")
}
func TestUserReadMessageCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "read-message", map[string]string{
"login": "testuser",
"id": "42",
})
if err != nil {
t.Fatalf("read-message shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/messages/read.json")
}
func TestUserMsgSettingsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"settings": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "msg-settings", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("msg-settings shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/template_message_settings.json")
}
func TestUserUpdateMsgSettingsSendsPOST(t *testing.T) {
var calledMethod string
var calledPath string
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "update-msg-settings", map[string]string{
"login": "testuser",
"settings": `[{"id":1,"enabled":true}]`,
})
if err != nil {
t.Fatalf("update-msg-settings shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/template_message_settings/update_setting.json")
settings, ok := payload["setting"].([]interface{})
if !ok || len(settings) == 0 {
t.Fatalf("expected setting to be a non-empty array, got %v", payload["setting"])
}
}
func TestUserPinnedProjectsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"projects": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "pinned-projects", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("pinned-projects shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/is_pinned_projects.json")
}
func TestUserPinProjectCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "pin-project", map[string]string{
"login": "testuser",
"id": "10",
})
if err != nil {
t.Fatalf("pin-project shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/is_pinned_projects/pin.json")
}
func TestUserReorderPinnedCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "reorder-pinned", map[string]string{
"login": "testuser",
"id": "10",
"position": "1",
})
if err != nil {
t.Fatalf("reorder-pinned shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "PUT")
assertEqual(t, calledPath, "/users/testuser/is_pinned_projects/10.json")
}
func TestUserActivityCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"activity": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "activity", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("activity shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/statistics/activity.json")
}
func TestUserHeadmapCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"headmaps": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "headmap", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("headmap shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/headmaps.json")
}
func TestUserDevelopStatsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"develop": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "develop-stats", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("develop-stats shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/statistics/develop.json")
}
func TestUserRoleStatsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"role": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "role-stats", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("role-stats shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/statistics/role.json")
}
func TestUserMajorStatsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"major": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "major-stats", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("major-stats shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/statistics/major.json")
}
func TestUserAppliedTransfersCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"projects": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "applied-transfers", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("applied-transfers shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/applied_transfer_projects.json")
}
func TestUserAcceptTransferCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "accept-transfer", map[string]string{
"login": "testuser",
"id": "5",
})
if err != nil {
t.Fatalf("accept-transfer shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/applied_transfer_projects/5/accept.json")
}
func TestUserRefuseTransferCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "refuse-transfer", map[string]string{
"login": "testuser",
"id": "5",
})
if err != nil {
t.Fatalf("refuse-transfer shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/applied_transfer_projects/5/refuse.json")
}
func TestUserAppliedProjectsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"projects": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "applied-projects", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("applied-projects shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/applied_projects.json")
}
func TestUserAcceptJoinCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "accept-join", map[string]string{
"login": "testuser",
"id": "8",
})
if err != nil {
t.Fatalf("accept-join shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/applied_projects/8/accept.json")
}
func TestUserRefuseJoinCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "refuse-join", map[string]string{
"login": "testuser",
"id": "8",
})
if err != nil {
t.Fatalf("refuse-join shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/applied_projects/8/refuse.json")
}
func TestUserUpdateSendsPUT(t *testing.T) {
var calledMethod string
var calledPath string
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "update", map[string]string{
"login": "testuser",
"nickname": "NewNick",
"description": "Updated bio",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "PUT")
assertEqual(t, calledPath, "/users/testuser.json")
assertEqual(t, payload["nickname"], "NewNick")
assertEqual(t, payload["description"], "Updated bio")
}
// --- Required-flag validation tests ---
func TestUserMessagesRequiresLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when login is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runUserShortcut(t, server, "messages", nil)
if err == nil {
t.Fatal("expected error when login is missing, got nil")
}
}
func TestUserFeedbackRequiresContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when content is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runUserShortcut(t, server, "feedback", map[string]string{
"login": "testuser",
})
if err == nil {
t.Fatal("expected error when content is missing, got nil")
}
}

43
shortcuts/util/util.go Normal file
View File

@ -0,0 +1,43 @@
package util
import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "licenses",
Description: "List available open source license templates",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/licenses", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "ignores",
Description: "List available .gitignore templates",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/ignores", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "platform-msg-settings",
Description: "Show platform-wide message notification settings",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/template_message_settings", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -2,6 +2,7 @@ package webhook
import (
"fmt"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -27,6 +28,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "list",
Description: "List repository webhooks",
Long: `List repository webhooks.
Returns all webhooks configured for the current repository.`,
Example: ` # List all webhooks
gitlink webhook +list`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
@ -41,6 +47,12 @@ func Shortcuts() []*common.Shortcut {
{
Name: "view",
Description: "View webhook details",
Long: `View webhook details.
Shows the full configuration of a specific webhook including
URL, events, type, and active status.`,
Example: ` # View a webhook
gitlink webhook +view --id 10`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
@ -62,6 +74,16 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a repository webhook",
Long: `Create a repository webhook.
Creates a new webhook that sends HTTP requests to the specified URL
when certain events occur. Supported types: gitea, slack, discord,
dingtalk, telegram, msteams, feishu, matrix, jianmu, softbot.`,
Example: ` # Create a push webhook
gitlink webhook +create --url https://example.com/hook --events push
# Create a webhook for multiple events
gitlink webhook +create --url https://example.com/hook --events push,issues_only --secret mysecret`,
Flags: []common.Flag{
{Name: "url", Short: "u", Usage: "Webhook target URL", Required: true},
{Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only", Required: true},
@ -77,6 +99,16 @@ func Shortcuts() []*common.Shortcut {
{
Name: "update",
Description: "Update a repository webhook while preserving unspecified fields when available",
Long: `Update a repository webhook while preserving unspecified fields when available.
Fetches the current webhook configuration, merges changes from the provided
flags, and updates the webhook. Fields not specified are preserved from the
current configuration.`,
Example: ` # Update a webhook URL
gitlink webhook +update --id 10 --url https://new.example.com/hook
# Update webhook events
gitlink webhook +update --id 10 --events push,pull_request_only`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
{Name: "url", Short: "u", Usage: "Webhook target URL"},
@ -93,6 +125,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "delete",
Description: "Delete a repository webhook",
Long: `Delete a repository webhook.
Permanently removes the webhook from the repository.`,
Example: ` # Delete a webhook
gitlink webhook +delete --id 10`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
@ -114,6 +151,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "test",
Description: "Trigger a test delivery for a webhook",
Long: `Trigger a test delivery for a webhook.
Sends a test payload to the webhook URL to verify the configuration.`,
Example: ` # Test a webhook
gitlink webhook +test --id 10`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
@ -135,6 +177,12 @@ func Shortcuts() []*common.Shortcut {
{
Name: "tasks",
Description: "List webhook delivery tasks",
Long: `List webhook delivery tasks.
Returns the history of webhook deliveries including status,
response code, and timing information.`,
Example: ` # List delivery tasks for a webhook
gitlink webhook +tasks --id 10`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
@ -153,6 +201,113 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "failed",
Description: "List failed webhook deliveries",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
{Name: "limit", Short: "l", Usage: "Number of recent failed tasks", Default: "10"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil)
if err != nil {
return err
}
raw, ok := env.Data.(map[string]interface{})
if !ok {
return ctx.Output(env)
}
tasks, ok := raw["hooktasks"].([]interface{})
if !ok {
return ctx.OutputData(map[string]interface{}{
"hooktasks": []interface{}{},
"total_count": 0,
})
}
limit := 0
if limitStr := ctx.Arg("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
failedTasks := make([]interface{}, 0)
for _, rawTask := range tasks {
task, ok := rawTask.(map[string]interface{})
if !ok {
continue
}
if succeed, ok := task["is_succeed"].(bool); ok && !succeed {
failedTasks = append(failedTasks, map[string]interface{}{
"uuid": task["uuid"],
"event_type": task["event_type"],
"delivered_time": task["delivered_time"],
"status": "failed",
})
if limit > 0 && len(failedTasks) >= limit {
break
}
}
}
return ctx.OutputData(map[string]interface{}{
"hooktasks": failedTasks,
"total_count": len(failedTasks),
})
},
},
{
Name: "task-view",
Description: "View webhook delivery task details",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
{Name: "task-id", Usage: "Task ID (numeric)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
taskID, err := ctx.RequireArg("task-id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil)
if err != nil {
return err
}
raw, ok := env.Data.(map[string]interface{})
if !ok {
return fmt.Errorf("failed to parse hook tasks")
}
tasks, ok := raw["hooktasks"].([]interface{})
if !ok {
return fmt.Errorf("no hook tasks found")
}
for _, rawTask := range tasks {
task, ok := rawTask.(map[string]interface{})
if !ok {
continue
}
// Support both numeric ID and UUID
if tid, ok := task["id"].(float64); ok && fmt.Sprintf("%.0f", tid) == taskID {
return ctx.OutputData(task)
}
if uuid, ok := task["uuid"].(string); ok && uuid == taskID {
return ctx.OutputData(task)
}
}
return fmt.Errorf("task %s not found", taskID)
},
},
}
}

548
shortcuts/wiki/wiki.go Normal file
View File

@ -0,0 +1,548 @@
package wiki
import (
"bytes"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/auth"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// --- Overridable for testing ---
// wikiRepoURL returns the Git URL for the wiki repository.
// Override wikiRepoURLOverride in tests to point to a local repo.
var wikiRepoURLOverride string
func wikiRepoURL(owner, repo string) string {
if wikiRepoURLOverride != "" {
return wikiRepoURLOverride
}
return fmt.Sprintf("https://gitlink.org.cn/%s/%s.wiki.git", owner, repo)
}
// --- Helpers ---
// encodePageName encodes a page name for use as a Git filename (URL-encoded + .md).
func encodePageName(name string) string {
return url.PathEscape(name) + ".md"
}
// decodePageName decodes a wiki filename back to a display name.
func decodePageName(filename string) string {
name := strings.TrimSuffix(filename, ".md")
decoded, err := url.PathUnescape(name)
if err != nil {
return name
}
return decoded
}
// isWikiFile returns true if the filename is a regular wiki page (not _Sidebar, _Footer, etc.).
func isWikiFile(name string) bool {
return strings.HasSuffix(name, ".md") && !strings.HasPrefix(name, "_")
}
// wikiFiles lists all wiki page files in a cloned repo directory, sorted.
func wikiFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var files []string
for _, e := range entries {
if !e.IsDir() && isWikiFile(e.Name()) {
files = append(files, e.Name())
}
}
sort.Strings(files)
return files, nil
}
// runGit runs a git command in the specified directory.
func runGit(dir string, args ...string) (stdout, stderr string, err error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err = cmd.Run()
return outBuf.String(), errBuf.String(), err
}
// getAuthToken retrieves the auth token for Git push operations.
func getAuthToken() (string, error) {
if t := os.Getenv("GITLINK_TOKEN"); t != "" {
return t, nil
}
t, err := auth.LoadToken()
if err != nil {
return "", fmt.Errorf("not authenticated: run 'gitlink-cli auth login' first")
}
if t == "" {
return "", fmt.Errorf("not authenticated: run 'gitlink-cli auth login' first")
}
return t, nil
}
// authWikiRepoURL returns an authenticated Git URL for push operations.
func authWikiRepoURL(owner, repo, token string) (string, error) {
if strings.HasPrefix(token, "cookie:") {
return "", fmt.Errorf("cookie-based login does not support Git push. Generate a Personal Access Token on GitLink and use:\n gitlink-cli auth login --token")
}
return fmt.Sprintf("https://token:%s@gitlink.org.cn/%s/%s.wiki.git",
url.QueryEscape(token), owner, repo), nil
}
// cloneWikiReadOnly clones the wiki repo (read-only, no auth needed for public repos).
func cloneWikiReadOnly(owner, repo, dest string) error {
_, stderr, err := runGit("", "clone", "--depth", "1", wikiRepoURL(owner, repo), dest)
if err != nil {
return fmt.Errorf("clone wiki repo: %s%s", stderr, err)
}
return nil
}
// cloneWikiWithAuth clones the wiki repo with authentication for push.
func cloneWikiWithAuth(authURL, dest string) error {
_, stderr, err := runGit("", "clone", "--depth", "1", authURL, dest)
if err != nil {
return fmt.Errorf("clone wiki repo: %s%s", stderr, err)
}
return nil
}
// resolveContent reads content from --content or --file.
func resolveContent(ctx *common.RuntimeContext) (string, error) {
content := ctx.Arg("content")
file := ctx.Arg("file")
if content != "" && file != "" {
return "", fmt.Errorf("use either --content or --file, not both")
}
if file != "" {
data, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("read file: %w", err)
}
content = string(data)
}
return content, nil
}
// buildPageContent builds the markdown content, optionally prepending a title heading.
func buildPageContent(title, content string) string {
if title != "" {
return "# " + title + "\n\n" + content
}
return content
}
// --- Shortcuts ---
// Shortcuts returns wiki management shortcuts backed by Git operations.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List wiki pages in a repository",
Run: runList,
},
{
Name: "view",
Description: "View wiki page content",
Flags: []common.Flag{
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
},
Run: runView,
},
{
Name: "create",
Description: "Create a wiki page",
Flags: []common.Flag{
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
{Name: "title", Short: "t", Usage: "Page title (used as H1 heading)"},
{Name: "content", Short: "c", Usage: "Page content (markdown)"},
{Name: "file", Short: "f", Usage: "Read page content from file"},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: runCreate,
},
{
Name: "update",
Description: "Update a wiki page",
Flags: []common.Flag{
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
{Name: "title", Short: "t", Usage: "Page title (used as H1 heading)"},
{Name: "content", Short: "c", Usage: "Page content (markdown)"},
{Name: "file", Short: "f", Usage: "Read page content from file"},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: runUpdate,
},
{
Name: "delete",
Description: "Delete a wiki page",
Flags: []common.Flag{
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
},
Run: runDelete,
},
{
Name: "export",
Description: "Export wiki pages",
Flags: []common.Flag{
{Name: "project-id", Usage: "Project ID (numeric)"},
},
Run: runExport,
},
{
Name: "import",
Description: "Import wiki pages from file",
Flags: []common.Flag{
{Name: "project-id", Usage: "Project ID", Required: true},
},
Run: runImport,
},
}
}
// --- Command implementations ---
func runList(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := cloneWikiReadOnly(ctx.Owner, ctx.Repo, tmpDir); err != nil {
return err
}
files, err := wikiFiles(tmpDir)
if err != nil {
return err
}
var pages []map[string]string
for _, f := range files {
pages = append(pages, map[string]string{
"name": decodePageName(f),
})
}
data := map[string]interface{}{
"total_count": len(pages),
"pages": pages,
}
return ctx.Output(output.SuccessEnvelope(data, nil))
}
func runView(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pageName, err := ctx.RequireArg("page-name")
if err != nil {
return err
}
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := cloneWikiReadOnly(ctx.Owner, ctx.Repo, tmpDir); err != nil {
return err
}
fileName := encodePageName(pageName)
content, err := os.ReadFile(filepath.Join(tmpDir, fileName))
if err != nil {
return fmt.Errorf("page %q not found", pageName)
}
// Get last commit info for this file
log, _, _ := runGit(tmpDir, "log", "-1", "--format=%H|%an|%ae|%aI", "--", fileName)
data := map[string]interface{}{
"name": pageName,
"content": string(content),
}
if log != "" {
parts := strings.SplitN(strings.TrimSpace(log), "|", 4)
if len(parts) >= 1 {
data["last_commit_sha"] = parts[0]
}
if len(parts) >= 2 {
data["last_author"] = parts[1]
}
if len(parts) >= 4 {
data["last_commit_date"] = parts[3]
}
}
return ctx.Output(output.SuccessEnvelope(data, nil))
}
func runCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pageName, err := ctx.RequireArg("page-name")
if err != nil {
return err
}
content, err := resolveContent(ctx)
if err != nil {
return err
}
if content == "" {
return fmt.Errorf("either --content or --file is required")
}
token, err := getAuthToken()
if err != nil {
return err
}
authURL, err := authWikiRepoURL(ctx.Owner, ctx.Repo, token)
if err != nil {
return err
}
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := cloneWikiWithAuth(authURL, tmpDir); err != nil {
return err
}
fileName := encodePageName(pageName)
filePath := filepath.Join(tmpDir, fileName)
if _, err := os.Stat(filePath); err == nil {
return fmt.Errorf("page %q already exists", pageName)
}
pageContent := buildPageContent(ctx.Arg("title"), content)
if err := os.WriteFile(filePath, []byte(pageContent), 0644); err != nil {
return fmt.Errorf("write page: %w", err)
}
msg := ctx.Arg("message")
if msg == "" {
msg = fmt.Sprintf("Create wiki page %q", pageName)
}
if _, stderr, err := runGit(tmpDir, "add", fileName); err != nil {
return fmt.Errorf("git add: %s%s", stderr, err)
}
if _, stderr, err := runGit(tmpDir, "commit", "-m", msg); err != nil {
return fmt.Errorf("git commit: %s%s", stderr, err)
}
if _, stderr, err := runGit(tmpDir, "push", "origin", "master"); err != nil {
return fmt.Errorf("git push: %s%s", stderr, err)
}
return ctx.OutputData(map[string]string{
"name": pageName,
"message": "Wiki page created",
})
}
func runUpdate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pageName, err := ctx.RequireArg("page-name")
if err != nil {
return err
}
token, err := getAuthToken()
if err != nil {
return err
}
authURL, err := authWikiRepoURL(ctx.Owner, ctx.Repo, token)
if err != nil {
return err
}
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := cloneWikiWithAuth(authURL, tmpDir); err != nil {
return err
}
fileName := encodePageName(pageName)
filePath := filepath.Join(tmpDir, fileName)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return fmt.Errorf("page %q not found", pageName)
}
content, err := resolveContent(ctx)
if err != nil {
return err
}
if content != "" {
pageContent := buildPageContent(ctx.Arg("title"), content)
if err := os.WriteFile(filePath, []byte(pageContent), 0644); err != nil {
return fmt.Errorf("write page: %w", err)
}
} else if title := ctx.Arg("title"); title != "" {
// Only updating title: read existing content and replace/re-add the heading
existing, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read page: %w", err)
}
lines := strings.SplitN(string(existing), "\n", 2)
newContent := "# " + title + "\n"
if len(lines) > 1 {
// Skip old heading if present
body := lines[1]
if strings.HasPrefix(lines[0], "# ") {
newContent += body
} else {
newContent += strings.Join(lines, "\n")
}
}
if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("write page: %w", err)
}
}
msg := ctx.Arg("message")
if msg == "" {
msg = fmt.Sprintf("Update wiki page %q", pageName)
}
if _, stderr, err := runGit(tmpDir, "add", fileName); err != nil {
return fmt.Errorf("git add: %s%s", stderr, err)
}
if _, stderr, err := runGit(tmpDir, "commit", "-m", msg); err != nil {
return fmt.Errorf("git commit: %s%s", stderr, err)
}
if _, stderr, err := runGit(tmpDir, "push", "origin", "master"); err != nil {
return fmt.Errorf("git push: %s%s", stderr, err)
}
return ctx.OutputData(map[string]string{
"name": pageName,
"message": "Wiki page updated",
})
}
func runDelete(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pageName, err := ctx.RequireArg("page-name")
if err != nil {
return err
}
token, err := getAuthToken()
if err != nil {
return err
}
authURL, err := authWikiRepoURL(ctx.Owner, ctx.Repo, token)
if err != nil {
return err
}
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := cloneWikiWithAuth(authURL, tmpDir); err != nil {
return err
}
fileName := encodePageName(pageName)
filePath := filepath.Join(tmpDir, fileName)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return fmt.Errorf("page %q not found", pageName)
}
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("delete page: %w", err)
}
// Remove _Sidebar.md so GitLink regenerates it from file list
os.Remove(filepath.Join(tmpDir, "_Sidebar.md"))
msg := ctx.Arg("message")
if msg == "" {
msg = fmt.Sprintf("Delete wiki page %q", pageName)
}
if _, stderr, err := runGit(tmpDir, "add", "-A"); err != nil {
return fmt.Errorf("git add: %s%s", stderr, err)
}
if _, stderr, err := runGit(tmpDir, "commit", "-m", msg); err != nil {
return fmt.Errorf("git commit: %s%s", stderr, err)
}
if _, stderr, err := runGit(tmpDir, "push", "origin", "master"); err != nil {
return fmt.Errorf("git push: %s%s", stderr, err)
}
return ctx.OutputData(map[string]string{
"name": pageName,
"message": "Wiki page deleted",
})
}
func runExport(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
if v := ctx.Arg("project-id"); v != "" {
q.Set("projectId", v)
}
env, err := ctx.CallAPIWithQuery("GET", "/wikiExport/wikiExport-wrapper", q)
if err != nil {
return err
}
return ctx.Output(env)
}
func runImport(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := ctx.RequireArg("project-id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/wikiExport/uploadWiki/%s/%s/%s", ctx.Owner, ctx.Repo, projectID), nil)
if err != nil {
return err
}
return ctx.Output(env)
}

274
shortcuts/wiki/wiki_test.go Normal file
View File

@ -0,0 +1,274 @@
package wiki
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// setupWikiRepo creates a bare Git repository to simulate the remote wiki repo,
// initializes it with sample wiki pages, and returns the path to the bare repo.
// The caller must set wikiRepoURLOverride to this path before running shortcuts.
func setupWikiRepo(t *testing.T) (bareRepo string, cleanup func()) {
t.Helper()
// Create a temp directory for the "remote" bare repo
remoteDir, err := os.MkdirTemp("", "gitlink-wiki-remote-*")
if err != nil {
t.Fatalf("mkdir remote: %v", err)
}
// Initialize bare repo
runGitClean("", "init", "--bare", remoteDir)
// Clone it to a working directory to add initial content
workDir, err := os.MkdirTemp("", "gitlink-wiki-work-*")
if err != nil {
os.RemoveAll(remoteDir)
t.Fatalf("mkdir work: %v", err)
}
runGitClean("", "clone", remoteDir, workDir)
// Configure git user for commits
runGitClean(workDir, "config", "user.email", "test@test.com")
runGitClean(workDir, "config", "user.name", "Test User")
// Create initial wiki pages
pages := map[string]string{
"Home.md": "# Home\n\nWelcome to the wiki!",
"Guide.md": "# Getting Started Guide\n\nThis is a guide.",
"API-Reference.md": "# API Reference\n\nAPI documentation.",
}
for name, content := range pages {
os.WriteFile(filepath.Join(workDir, name), []byte(content), 0644)
}
// Commit and push initial content
runGitClean(workDir, "add", ".")
runGitClean(workDir, "commit", "-m", "Initial wiki pages")
runGitClean(workDir, "push", "origin", "master")
// Clean up working directory
os.RemoveAll(workDir)
return remoteDir, func() {
os.RemoveAll(remoteDir)
}
}
// runGitClean runs a git command and panics on error (for test setup).
func runGitClean(dir string, args ...string) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
panic("git " + strings.Join(args, " ") + ": " + string(out) + ": " + err.Error())
}
}
// runWikiShortcut runs a wiki shortcut with the given args using a local test repo.
func runWikiShortcut(t *testing.T, name string, args map[string]string) error {
t.Helper()
shortcut := findWikiShortcut(t, name)
ctx := &common.RuntimeContext{
Owner: "test-owner",
Repo: "test-repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findWikiShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
// --- Tests ---
func TestWikiList(t *testing.T) {
repo, cleanup := setupWikiRepo(t)
defer cleanup()
wikiRepoURLOverride = repo
defer func() { wikiRepoURLOverride = "" }()
if err := runWikiShortcut(t, "list", nil); err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestWikiView(t *testing.T) {
repo, cleanup := setupWikiRepo(t)
defer cleanup()
wikiRepoURLOverride = repo
defer func() { wikiRepoURLOverride = "" }()
if err := runWikiShortcut(t, "view", map[string]string{
"page-name": "Home",
}); err != nil {
t.Fatalf("view failed: %v", err)
}
}
func TestWikiViewNotFound(t *testing.T) {
repo, cleanup := setupWikiRepo(t)
defer cleanup()
wikiRepoURLOverride = repo
defer func() { wikiRepoURLOverride = "" }()
err := runWikiShortcut(t, "view", map[string]string{
"page-name": "NonExistent",
})
if err == nil {
t.Fatal("expected error for non-existent page")
}
}
func TestWikiViewMissingPageName(t *testing.T) {
_, cleanup := setupWikiRepo(t)
defer cleanup()
err := runWikiShortcut(t, "view", nil)
if err == nil {
t.Fatal("expected error when --page-name is missing")
}
}
func TestWikiListWithSpecialChars(t *testing.T) {
repo, cleanup := setupWikiRepo(t)
defer cleanup()
// Add a page with Chinese characters
workDir, err := os.MkdirTemp("", "gitlink-wiki-work-*")
if err != nil {
t.Fatalf("mkdir work: %v", err)
}
defer os.RemoveAll(workDir)
runGitClean("", "clone", repo, workDir)
runGitClean(workDir, "config", "user.email", "test@test.com")
runGitClean(workDir, "config", "user.name", "Test User")
pageName := "Wiki测试"
fileName := encodePageName(pageName)
os.WriteFile(filepath.Join(workDir, fileName), []byte("# Wiki测试\nHello"), 0644)
runGitClean(workDir, "add", ".")
runGitClean(workDir, "commit", "-m", "Add Chinese page")
runGitClean(workDir, "push", "origin", "master")
wikiRepoURLOverride = repo
defer func() { wikiRepoURLOverride = "" }()
if err := runWikiShortcut(t, "list", nil); err != nil {
t.Fatalf("list with special chars failed: %v", err)
}
}
func TestEncodeDecodePageName(t *testing.T) {
tests := []struct {
raw string
expected string
}{
{"Home", "Home.md"},
{"Getting Started", "Getting%20Started.md"},
{"API-Reference", "API-Reference.md"},
{"Wiki测试", "Wiki%E6%B5%8B%E8%AF%95.md"},
}
for _, tt := range tests {
encoded := encodePageName(tt.raw)
if encoded != tt.expected {
t.Errorf("encodePageName(%q) = %q, want %q", tt.raw, encoded, tt.expected)
}
decoded := decodePageName(encoded)
if decoded != tt.raw {
t.Errorf("decodePageName(%q) = %q, want %q", encoded, decoded, tt.raw)
}
}
}
func TestIsWikiFile(t *testing.T) {
tests := []struct {
name string
expected bool
}{
{"Home.md", true},
{"Guide.md", true},
{"API-Reference.md", true},
{"_Sidebar.md", false},
{"_Footer.md", false},
{"README.txt", false},
{"image.png", false},
}
for _, tt := range tests {
got := isWikiFile(tt.name)
if got != tt.expected {
t.Errorf("isWikiFile(%q) = %v, want %v", tt.name, got, tt.expected)
}
}
}
func TestWikiListHandlesEmptyRepo(t *testing.T) {
// Create a bare repo with no wiki pages
remoteDir, err := os.MkdirTemp("", "gitlink-wiki-empty-*")
if err != nil {
t.Fatalf("mkdir: %v", err)
}
defer os.RemoveAll(remoteDir)
runGitClean("", "init", "--bare", remoteDir)
// Clone and make an initial commit (so that clone works)
workDir, err := os.MkdirTemp("", "gitlink-wiki-work-*")
if err != nil {
t.Fatalf("mkdir work: %v", err)
}
defer os.RemoveAll(workDir)
runGitClean("", "clone", remoteDir, workDir)
runGitClean(workDir, "config", "user.email", "test@test.com")
runGitClean(workDir, "config", "user.name", "Test User")
// Add a .gitkeep or similar non-wiki file to make the repo non-empty
os.WriteFile(filepath.Join(workDir, ".gitkeep"), []byte(""), 0644)
runGitClean(workDir, "add", ".")
runGitClean(workDir, "commit", "-m", "init")
runGitClean(workDir, "push", "origin", "master")
os.RemoveAll(workDir)
wikiRepoURLOverride = remoteDir
defer func() { wikiRepoURLOverride = "" }()
if err := runWikiShortcut(t, "list", nil); err != nil {
t.Fatalf("list empty repo failed: %v", err)
}
}
func TestWikiDeleteMissingPageName(t *testing.T) {
_, cleanup := setupWikiRepo(t)
defer cleanup()
err := runWikiShortcut(t, "delete", nil)
if err == nil {
t.Fatal("expected error when --page-name is missing")
}
}

View File

@ -0,0 +1,87 @@
---
name: gitlink-issue-triage
version: 1.0.0
description: "Issue 自动分拣:根据 Issue 内容自动分类、打标签、分配责任人、添加引导评论。当用户需要自动处理新提交的 Issue 时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli issue --help"
---
# gitlink-issue-triageIssue 自动分拣)
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
## 说明
本 Skill 组合多个 gitlink-cli 命令,实现 Issue 的自动化分类和处理流程:
1. 获取新提交的 Issue 列表
2. 根据 Issue 内容(标题 + 描述)自动判断类别
3. 为 Issue 打上对应标签、分配责任人
4. 添加引导评论
## 依赖的 Shortcuts
| Shortcut | 用途 |
|----------|------|
| `issue +list` | 获取待处理的 Issue 列表 |
| `issue +view` | 查看 Issue 详细内容 |
| `issue +update` | 修改 Issue 状态/标签 |
| `issue +comment` | 添加评论 |
| `label +list` | 查看可用标签 |
| `user +info` | 查询用户信息 |
## 处理流程
```
1. issue +list --state open → 获取所有打开的 Issue
2. issue +view --number {id} → 查看 Issue 详情
3. 分析 subject + description → 判断 Issue 类别
4. label +list → 查看可用标签
5. issue +update --label {label} → 打标签
6. issue +comment --body "..." → 添加引导评论
```
## 分类规则参考
| 类别 | 标题关键词 | 建议标签 | 建议负责人 |
|------|-----------|---------|-----------|
| Bug | 错误、失败、异常、bug、crash、报错 | bug | 项目维护者 |
| 功能需求 | 建议、希望、需要、feature、支持 | enhancement | PM |
| 文档 | 文档、README、文档缺失、拼写 | documentation | 文档负责人 |
| 问题咨询 | 请问、怎么、如何、help、question | question | 社区支持 |
| 性能 | 慢、卡顿、性能、优化、performance | performance | 核心开发者 |
## 使用示例
```bash
# 1. 获取所有打开的 Issue
gitlink-cli issue +list --owner myuser --repo myrepo --state open --format json
# 2. 查看某个 Issue 的详细内容
gitlink-cli issue +view --owner myuser --repo myrepo --number 5 --format json
# 3. 查看仓库可用标签
gitlink-cli label +list --owner myuser --repo myrepo
# 4. 为 Issue 打标签(假设判断为 bug
gitlink-cli issue +update --owner myuser --repo myrepo --number 5 --state open
# 5. 添加分类引导评论
gitlink-cli issue +comment --owner myuser --repo myrepo --number 5 --body "感谢提交 Issue已自动分类为 **Bug**,请补充以下信息:
- 运行环境(操作系统、版本)
- 复现步骤
- 期望行为与实际行为
项目维护者会尽快处理。"
```
## 注意事项
- 每次操作前先列出当前待处理的 Issue评估数量。
- 添加评论前先确认 Issue 内容,避免误分类。
- 标签名称需要先在项目中确认是否存在,不存在则跳过。
- 分配责任人前需确认该用户是否为项目成员。

View File

@ -0,0 +1,81 @@
# Issue 自动分拣完整工作流示例
**场景**:项目收到一个新 Issue需要自动判断类别、打标签、添加引导评论。
## 前置条件
- `gitlink-cli` 已安装并登录
- 对目标仓库有写入权限
## 工作流步骤
### Step 1查看待处理的 Issue
```bash
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open --format json
```
**输出示例:**
```json
{
"issues": [
{
"number": 10,
"subject": "登录页面报错500 Internal Server Error",
"status_id": 1,
"author": {"login": "newuser"}
}
]
}
```
### Step 2查看 Issue 详细内容
```bash
gitlink-cli issue +view --owner z2_cc --repo gitlink-cli --number 10 --format json
```
### Step 3分析 Issue 内容判断类别
根据标题和描述中的关键词判断:
| 关键词 | 类别 |
|--------|------|
| 报错、错误、bug | Bug |
| 建议、希望 | 功能需求 |
| 文档 | 文档 |
| 请问、怎么 | 问题咨询 |
### Step 4添加分类评论
```bash
gitlink-cli issue +comment --owner z2_cc --repo gitlink-cli --number 10 --body "感谢提交 Issue已自动分类为 **Bug**
请补充以下信息以便排查:
- 运行环境(操作系统、浏览器版本)
- 复现步骤
- 错误截图或日志
项目维护者会尽快处理。"
```
### Step 5确认处理结果
```bash
gitlink-cli issue +view --owner z2_cc --repo gitlink-cli --number 10 --format json
```
---
## 完整命令速览
```bash
# 1. 获取待处理 Issue
gitlink-cli issue +list --state open
# 2. 查看详情
gitlink-cli issue +view --number <id>
# 3. 添加分类评论
gitlink-cli issue +comment --number <id> --body "<评论内容>"
```

View File

@ -0,0 +1,137 @@
---
name: gitlink-newcomer-guide
version: 1.0.0
description: "新人引导:为 good-first-issue 自动添加引导评论,降低新贡献者参与门槛。当项目收到新贡献者或标记了 good-first-issue 时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli issue --help"
---
# gitlink-newcomer-guide新人引导
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
## 说明
本 Skill 帮助项目维护者为新贡献者创建友好的入门体验。自动识别标记为 good-first-issue 的 Issue并为其添加结构化的引导评论降低新贡献者的参与门槛。
## 依赖的 Shortcuts
| Shortcut | 用途 |
|----------|------|
| `issue +list` | 获取标记了 good-first-issue 的 Issue |
| `issue +view` | 查看 Issue 详细内容 |
| `issue +comment` | 添加引导评论 |
| `label +list` | 查看可用标签 |
| `repo +info` | 获取仓库基本信息 |
## 工作流程
```
1. issue +list → 获取所有打开的 Issue
2. 筛选含 good-first-issue 标签的 Issue
3. issue +view --number {id} → 查看 Issue 详情
4. 分析 Issue 内容 → 判断适合的引导信息
5. issue +comment --body "..." → 添加引导评论
6. 可选issue +update → 分配 mentor
```
## 引导评论模板
根据 Issue 类型选择合适的引导评论:
### Bug 类 Issue
```markdown
你好!欢迎参与贡献 🎉
这个 Issue 被标记为 **good first issue**,非常适合作为你的第一个贡献。
## 如何开始
1. 在评论区留言"我想认领这个 Issue",我们会把你添加到 Assignee
2. 查看 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解开发环境搭建
3. Fork 本仓库并创建你的功能分支
4. 修复问题后提交 Pull Request
## 需要帮助?
- 如果对问题描述有疑问,可以在下方留言
- 在开发过程中遇到任何问题,也欢迎随时提问
祝你编码愉快!
```
### 功能需求类 Issue
```markdown
你好!欢迎参与贡献 🎉
这个 Issue 被标记为 **good first issue**,适合作为你了解本项目的起点。
## 实现思路
1. 建议先阅读相关模块的源代码,了解现有实现
2. 可以参考已有的类似功能实现
3. 记得添加对应的单元测试
## 提交流程
1. Fork 仓库并创建分支
2. 实现功能并编写测试
3. 确保 `go test ./...` 全部通过
4. 提交 Pull Request描述你的改动
如有任何问题,随时留言!
```
### 文档类 Issue
```markdown
你好!欢迎参与贡献 🎉
感谢你对改进文档的兴趣!
## 文档贡献指南
1. 文档位于项目中的 `docs/` 目录下
2. 使用 Markdown 格式编写
3. 修改后请预览效果,确保格式正确
4. 提交 Pull Request描述你的改动
## 注意事项
- 保持文档风格一致
- 中英文之间保留空格
- 代码块请标注语言类型
期待你的贡献!
```
## 使用示例
```bash
# 1. 查找所有标记了 good-first-issue 的 Issue
gitlink-cli issue +list --owner myuser --repo myrepo --state open --format json
# 2. 查看某个 Issue 的详细内容(判断类型)
gitlink-cli issue +view --owner myuser --repo myrepo --number 10 --format json
# 3. 查看仓库可用标签
gitlink-cli label +list --owner myuser --repo myrepo
# 4. 添加引导评论
gitlink-cli issue +comment --owner myuser --repo myrepo --number 10 --body "你好!欢迎参与贡献..."
# 5. 可选:将 Issue 标记为已分配
gitlink-cli issue +update --owner myuser --repo myrepo --number 10
```
## 注意事项
- 添加引导评论前,先检查该 Issue 是否已有引导评论(避免重复)。
- 如果 Issue 已经被 Assignee 认领,不需要再添加引导评论。
- 不要修改 good-first-issue 标签,由项目维护者管理。
- 引导评论建议使用温和、鼓励的语气。

View File

@ -0,0 +1,71 @@
# 新人引导完整工作流示例
**场景**:项目标记了一个 good-first-issue需要为新贡献者添加引导评论帮助 TA 迈出第一步。
## 前置条件
- `gitlink-cli` 已安装并登录
- 对目标仓库有评论权限
## 工作流步骤
### Step 1查找 good-first-issue
```bash
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open --format json
```
查看 Issue 列表中哪些标记了适合新人的任务。
### Step 2查看 Issue 详情
```bash
gitlink-cli issue +view --owner z2_cc --repo gitlink-cli --number 10 --format json
```
分析 Issue 内容:
- 是否是 Bug 修复?→ 使用 Bug 模板
- 是否是文档改进?→ 使用文档模板
- 是否是功能需求?→ 使用功能模板
### Step 3添加引导评论
```bash
gitlink-cli issue +comment --owner z2_cc --repo gitlink-cli --number 10 --body "你好!欢迎参与贡献 🎉
这个 Issue 被标记为 **good first issue**,非常适合作为你的第一个贡献。
### 如何开始
1. 在评论区留言"我想认领",我们会把你添加到 Assignee
2. Fork 本仓库并创建你的功能分支
3. 修改代码后提交 Pull Request
### 需要帮助?
- 如果对问题描述有疑问,可以在下方留言
- 开发过程中遇到任何问题,也欢迎随时提问
祝你编码愉快!"
```
### Step 4确认引导已生效
```bash
gitlink-cli issue +view --owner z2_cc --repo gitlink-cli --number 10 --format json
```
检查 `comment_journals_count` 是否增加了。
---
## 完整命令速览
```bash
# 1. 查找 good-first-issue
gitlink-cli issue +list --state open
# 2. 查看详情
gitlink-cli issue +view --number <id>
# 3. 添加引导评论
gitlink-cli issue +comment --number <id> --body "<引导内容>"
```

View File

@ -0,0 +1,136 @@
---
name: gitlink-project-health
version: 1.0.0
description: "项目健康度报告:统计 Issue 响应时间、PR 合并效率、贡献者活跃度,生成项目健康度分析报告。当用户需要了解项目整体运行状况时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli --help"
---
# gitlink-project-health项目健康度报告
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
## 说明
本 Skill 组合多个 gitlink-cli 命令,从不同维度采集项目数据,生成结构化的项目健康度报告。
## 数据来源
| Shortcut | 采集的数据 |
|----------|-----------|
| `issue +list` | Issue 总数、打开/关闭数量、平均响应时间 |
| `pr +list` | PR 总数、打开/合并/关闭数量、平均合并时间 |
| `repo +info` | 仓库基本信息、fork 数、watch 数 |
| `commit +list` | 近期提交频率、活跃开发者数 |
| `label +list` | 标签使用情况 |
| `milestone +list` | 里程碑完成进度 |
## 报告维度
### 1. Issue 健康度
```bash
# 所有 Issue 统计
gitlink-cli issue +list --owner myuser --repo myrepo --state all --format json
# 已关闭的 Issue用于统计平均解决时间
gitlink-cli issue +list --owner myuser --repo myrepo --state closed --limit 30
# 待处理的 Issue
gitlink-cli issue +list --owner myuser --repo myrepo --state open
```
### 2. PR 健康度
```bash
# 所有 PR 统计
gitlink-cli pr +list --owner myuser --repo myrepo --state all --format json
# 待合并的 PR
gitlink-cli pr +list --owner myuser --repo myrepo --state open
# 已合并的 PR统计平均合并时间
gitlink-cli pr +list --owner myuser --repo myrepo --state merged --limit 30
```
### 3. 仓库基本信息
```bash
# 仓库概况
gitlink-cli repo +info --owner myuser --repo myrepo --format json
```
### 4. 里程碑进度
```bash
# 查看里程碑
gitlink-cli milestone +list --owner myuser --repo myrepo
```
### 5. 提交活跃度
```bash
# 最近提交
gitlink-cli commit +list --owner myuser --repo myrepo --limit 20
```
## 报告模板
收集完数据后,按以下模板生成报告:
```markdown
# 项目健康度报告
## 基本信息
- 项目:{repo_name}
- 所有者:{owner}
- Stars{stars} | Forks{forks}
## Issue 状况
- 总 Issue 数:{total_issues}
- 打开:{open_issues}(占比 {open_percent}%
- 已关闭:{closed_issues}(占比 {closed_percent}%
## PR 状况
- 总 PR 数:{total_prs}
- 打开:{open_prs}
- 已合并:{merged_prs}
## 活跃度
- 近期提交:{recent_commits} 次
- 活跃开发者:{active_devs} 人
```
## 使用示例
```bash
# 完整报告生成流程
# 步骤1获取仓库信息
gitlink-cli repo +info --owner myuser --repo myrepo --format json
# 步骤2获取 Issue 统计
gitlink-cli issue +list --owner myuser --repo myrepo --state open
gitlink-cli issue +list --owner myuser --repo myrepo --state all --format json
# 步骤3获取 PR 统计
gitlink-cli pr +list --owner myuser --repo myrepo --state open
gitlink-cli pr +list --owner myuser --repo myrepo --state merged --limit 20
# 步骤4获取提交活跃度
gitlink-cli commit +list --owner myuser --repo myrepo --limit 30
# 步骤5获取里程碑进度
gitlink-cli milestone +list --owner myuser --repo myrepo
# 步骤6汇总数据生成报告
```
## 注意事项
- 使用 `--format json` 获取结构化数据,便于 Agent 解析。
- 指定 `--limit` 控制数据量,避免输出过大。
- 不同项目的数据量级不同,可根据实际情况调整采样数量。
- 报告中的"健康状况"建议使用颜色标识(绿色=健康,黄色=一般,红色=需关注)。

View File

@ -0,0 +1,92 @@
# 项目健康度报告生成工作流示例
**场景**:项目维护者需要生成一份项目健康度周报,了解 Issue 处理情况、PR 合并效率和整体活跃度。
## 前置条件
- `gitlink-cli` 已安装并登录
## 工作流步骤
### Step 1获取项目基本信息
```bash
gitlink-cli repo +info --owner z2_cc --repo gitlink-cli --format json
```
### Step 2统计 Issue 数据
```bash
# 所有 Issue
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state all --format json
# 打开的 Issue
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open
# 已关闭的 Issue最近 30 条)
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state closed --limit 30
```
### Step 3统计 PR 数据
```bash
# 所有 PR
gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state all --format json
# 待合并的 PR
gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state open
# 已合并的 PR
gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state merged --limit 30
```
### Step 4查看近期提交活跃度
```bash
gitlink-cli commit +list --owner z2_cc --repo gitlink-cli --limit 20
```
### Step 5查看里程碑进度
```bash
gitlink-cli milestone +list --owner z2_cc --repo gitlink-cli
```
### Step 6生成报告
汇总以上数据,生成结构化报告:
```markdown
## 项目健康度周报
### Issue 状况
- 总 Issue 数12打开 3 / 关闭 9
- 本周新增2
- 本周解决3
### PR 状况
- 总 PR 数5打开 1 / 已合并 4
- 本周新增1
- 本周合并2
### 活跃度
- 近 20 次提交涉及3 位贡献者
- 最近提交时间2 小时前
### 里程碑
- 进行中v2.0(完成 60%
🟢 总体评价:项目健康
```
---
## 完整命令速览
```bash
gitlink-cli repo +info
gitlink-cli issue +list --state all --format json
gitlink-cli pr +list --state all --format json
gitlink-cli commit +list --limit 20
gitlink-cli milestone +list
```

View File

@ -0,0 +1,57 @@
---
name: gitlink-snippet
version: 1.0.0
description: "代码片段管理:在仓库的 snippets/ 目录下管理代码片段,支持列出、查看、创建、删除。当用户需要管理或共享代码片段时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli snippet --help"
---
# gitlink-snippet代码片段管理
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## 说明
代码片段存放在项目仓库的 `snippets/` 目录下,每个片段是一个独立的文件。
读操作list/view通过 Git 克隆实现无需认证写操作create/delete通过文件 REST API 实现,需要 Token。
## Shortcuts
| Shortcut | 说明 | 需要认证 |
|----------|------|----------|
| `snippet +list` | 列出代码片段 | 否(公开项目) |
| `snippet +view` | 查看代码片段内容 | 否(公开项目) |
| `snippet +create` | 创建代码片段 | 是 |
| `snippet +delete` | 删除代码片段 | 是 |
## 使用示例
```bash
# 列出代码片段
gitlink-cli snippet +list --owner myuser --repo myrepo
# 查看代码片段
gitlink-cli snippet +view --owner myuser --repo myrepo --name "hello.py"
# 创建代码片段(需要认证)
gitlink-cli snippet +create --owner myuser --repo myrepo --name "hello.py" --content 'print("Hello World!")' --language python --message "添加Python示例"
# 删除代码片段(确认后再执行)
gitlink-cli snippet +delete --owner myuser --repo myrepo --name "old.py"
```
## 注意事项
- 片段名带扩展名(如 `.py`、`.go`)会按原样存储;不带扩展名会自动追加 `.md`
- `--language` 参数用于代码块语法高亮,不影响文件名。
- 执行写入/删除前务必确认用户意图。
## 相关链接
- [gitlink-repo](../gitlink-repo/SKILL.md) — 仓库管理

View File

@ -21,6 +21,8 @@ metadata:
| `webhook +delete` | 删除 webhook |
| `webhook +test` | 触发 webhook 测试投递 |
| `webhook +tasks` | 查看 webhook 投递任务历史 |
| `webhook +failed` | 查看投递失败的记录 |
| `webhook +task-view` | 查看单次投递的详细内容 |
## 使用示例
@ -30,4 +32,6 @@ gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
--url https://example.com/hook --events push,create
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +failed --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +task-view --owner Gitlink --repo forgeplus --id 68 --task-id 4775684
```

View File

@ -0,0 +1,58 @@
---
name: gitlink-wiki
version: 1.0.0
description: "Wiki 管理:基于 Git 仓库操作,支持 Wiki 页面的列出、查看、创建、更新、删除。当用户需要管理 GitLink 项目 Wiki 时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli wiki --help"
---
# gitlink-wikiWiki 管理)
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## 说明
GitLink 的 Wiki 是一个独立的 Git 仓库(`{owner}/{repo}.wiki.git`),每个 Wiki 页面是一个 `.md` 文件。
读操作list/view无需认证写操作create/update/delete需要 Personal Access Token。
## Shortcuts
| Shortcut | 说明 | 需要认证 |
|----------|------|----------|
| `wiki +list` | 列出 Wiki 页面 | 否(公开项目) |
| `wiki +view` | 查看 Wiki 页面内容 | 否(公开项目) |
| `wiki +create` | 创建 Wiki 页面 | 是 |
| `wiki +update` | 更新 Wiki 页面 | 是 |
| `wiki +delete` | 删除 Wiki 页面 | 是 |
## 使用示例
```bash
# 列出 Wiki 页面
gitlink-cli wiki +list --owner myuser --repo myrepo
# 查看 Wiki 页面内容
gitlink-cli wiki +view --owner myuser --repo myrepo --page-name "Home"
# 创建 Wiki 页面(需要认证)
gitlink-cli wiki +create --owner myuser --repo myrepo --page-name "guide" --title "使用指南" --content "这是使用指南" --message "创建新页面"
# 更新 Wiki 页面
gitlink-cli wiki +update --owner myuser --repo myrepo --page-name "guide" --title "更新标题"
# 删除 Wiki 页面(确认后再执行)
gitlink-cli wiki +delete --owner myuser --repo myrepo --page-name "guide"
```
## 注意事项
- 更改页面名或删除页面后,自动生成的 _Sidebar.md 不会立即更新,等待 GitLink 网页端重建。
- 中文页面名会进行 URL 编码(如 `Wiki测试``Wiki%E6%B5%8B%E8%AF%95.md`)。
- `--content``--file` 不能同时使用。
- 如果不在仓库目录内运行,需要 `--owner``--repo` 参数。

3
snippets/hello.py Normal file
View File

@ -0,0 +1,3 @@
```python
print(Hello World!)
```