Compare commits

...

9 Commits

Author SHA1 Message Date
Donkey_kevin 245d3bc1fc fix: 还原 README 到合并前版本 2026-07-14 17:00:32 +08:00
Donkey_kevin d9ff6d134d Merge origin/master into zk_branch, resolve conflicts in milestone/issue/README 2026-07-14 16:41:56 +08:00
mengcheng 7f82dbdc24 Merge pull request '修改测试脚本' (#38) from mc_branch into master 2026-07-12 18:31:54 +08:00
camelliamc 460618d4b5 Merge branch 'master' into mc_branch 2026-07-12 18:31:21 +08:00
camelliamc 7079bfae64 修改测试脚本 2026-07-12 18:31:12 +08:00
mengcheng 693906cb6c Merge pull request '更新README' (#37) from mc_branch into master 2026-07-11 00:39:56 +08:00
camelliamc a02e3a53fd update README 2026-07-11 00:39:35 +08:00
Donkey_kevin 4b2d45baea feat: 修复 milestone +view 输出 + issue/board assign 字段修正 + 文档更新
- 修复 milestone +view 显示 No results 的问题(提取 milestone 字段输出)
- 修正 issue/board 的 assign 字段:assigned_to_id -> assigner_ids
- 添加代码逻辑文档、CLI 优化报告、reading notes
- 添加 board 功能示例和修改笔记
2026-07-10 11:06:11 +08:00
Donkey_kevin 19b0e31bf8 feat: 补全 Raw API 封装 — file/milestone/issue增强/pr增强 + 单元测试
新增模块:
- file: 11个命令 (ls/tree/read/readme/search/create/update/delete/batch/commits/diff)
- milestone: 6个命令 (list/create/view/update/delete/status)

Issue增强:
- 元数据查询: +statuses/+authors/+assigners/+priorities
- 评论管理: +comment-edit/+comment-delete/+replies
- 批量删除: +batch-destroy (原生batch_destroy接口)

PR增强:
- +reopen/+update/+commits/+versions/+vdiff/+filesv1
- 评论管理: +comment-edit/+comment-delete

其他:
- 新增 board 看板模块, compliance 合规模块
- 更新 README.md/README.zh-CN.md 帮助文档
- 43个单元测试全部通过

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-09 23:38:52 +08:00
74 changed files with 13365 additions and 1791 deletions

107
.claude/settings.local.json Normal file
View File

@ -0,0 +1,107 @@
{
"permissions": {
"allow": [
"Bash(git -C \"C:\\\\Users\\\\Lenovo\\\\Desktop\\\\soft运维\\\\gitlink-cli\" rev-parse --git-common-dir)",
"Bash(git -C \"C:\\\\Users\\\\Lenovo\\\\Desktop\\\\soft运维\\\\gitlink-cli\" rev-parse --git-dir)",
"Bash(git:*)",
"Bash(wc -l \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/skills/gitlink-compliance/SKILL.md\" \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/skills/gitlink-compliance/references/\"*.md)",
"Bash(go build:*)",
"Bash(go vet:*)",
"Bash(wc -l \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts/compliance/\"*.go)",
"Bash(wc -l \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts/onboard/\"*.go)",
"Bash(# 复制两个 skill 到全局 agents 目录\ncp -r \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/skills/gitlink-compliance\" \"C:/Users/Lenovo/.agents/skills/gitlink-compliance\"\ncp -r \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/skills/gitlink-onboard\" \"C:/Users/Lenovo/.agents/skills/gitlink-onboard\"\n\n# 建立符号链接\ncd \"C:/Users/Lenovo/.claude/skills\" && cmd /c \"mklink /D gitlink-compliance C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-compliance\" && cmd /c \"mklink /D gitlink-onboard C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-onboard\")",
"Bash(cmd.exe /c \"mklink /D C:\\\\Users\\\\Lenovo\\\\.claude\\\\skills\\\\gitlink-compliance C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-compliance\")",
"Bash(powershell.exe -Command \"New-Item -ItemType SymbolicLink -Path 'C:\\\\Users\\\\Lenovo\\\\.claude\\\\skills\\\\gitlink-compliance' -Target 'C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-compliance' -Force\")",
"Bash(cmd.exe /c \"mklink /J C:\\\\Users\\\\Lenovo\\\\.claude\\\\skills\\\\gitlink-compliance C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-compliance\")",
"Bash(cmd.exe /c \"mklink /J C:\\\\Users\\\\Lenovo\\\\.claude\\\\skills\\\\gitlink-onboard C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-onboard\")",
"Bash(ln -s \"/c/Users/Lenovo/.agents/skills/gitlink-compliance\" \"/c/Users/Lenovo/.claude/skills/gitlink-compliance\")",
"Bash(ln -s \"/c/Users/Lenovo/.agents/skills/gitlink-onboard\" \"/c/Users/Lenovo/.claude/skills/gitlink-onboard\")",
"Bash(export GITLINK_TOKEN=\"ce538a7a6b60b189e8ee28c6bf2a2e8186da8141\")",
"Bash(gitlink-cli api:*)",
"Bash(gitlink-cli auth:*)",
"Bash(gitlink-cli user:*)",
"Bash(gitlink-cli issue:*)",
"Bash(./gitlink-cli.exe onboard:*)",
"Bash(./gitlink-cli.exe compliance:*)",
"Bash(gitlink-cli onboard:*)",
"Bash(./gitlink-cli-tmp.exe onboard:*)",
"Bash(printf \"y\\\\nn\\\\n\")",
"Bash(printf \"y\\\\ny\\\\n\")",
"Bash(\"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/template-13.txt\":*)",
"Bash(jq --version)",
"Bash(for f:*)",
"Bash(do echo:*)",
"Bash(bash -n \"$f\")",
"Bash(done)",
"Bash(bash workflows/01-community-ops.sh --help)",
"Bash(bash workflows/02-code-quality-gatekeeper.sh --help)",
"Bash(bash workflows/03-project-init.sh --help)",
"Bash(bash workflows/04-multi-repo-collab.sh --help)",
"Bash(bash workflows/05-contributor-growth.sh --help)",
"Bash(gitlink-cli repo:*)",
"Bash(bash workflows/01-community-ops.sh --owner nudt_zk --repo gitlink-cli --dry-run)",
"Bash(bash workflows/01-community-ops.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash workflows/02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash workflows/03-project-init.sh --owner nudt_zk --name test-workflow-03 --description \"Testing workflow 03\" --lang go --dry-run)",
"Bash(bash workflows/04-multi-repo-collab.sh --org nudt_zk --dry-run)",
"Bash(bash workflows/05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(ls:*)",
"Bash(claude --version)",
"Bash(claude -p --output-format json)",
"Bash(export CLAUDE_CODE_GIT_BASH_PATH=\"C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe\")",
"Read(//c/Program Files/Git/usr/bin/**)",
"Read(//c/Program Files/Git/bin/**)",
"Bash(export CLAUDE_CODE_GIT_BASH_PATH=\"D:\\\\Git\\\\bin\\\\bash.exe\")",
"Bash(bash workflows/02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cli --pr-id 21 --dry-run)",
"Bash(source workflows/lib/common.sh)",
"Bash(echo \"EXIT: $?\")",
"Bash(cp:*)",
"Bash(cmd.exe /c \"mklink /J C:\\\\\\\\Users\\\\\\\\Lenovo\\\\\\\\.claude\\\\\\\\skills\\\\\\\\gitlink-research C:\\\\\\\\Users\\\\\\\\Lenovo\\\\\\\\.agents\\\\\\\\skills\\\\\\\\gitlink-research\")",
"Bash(mkdir -p \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/lib\")",
"Bash(mkdir -p \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/templates\")",
"Bash(mkdir -p \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/schemas\")",
"Bash(cp \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/0\"*\"-research\"*\".sh\" \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/\")",
"Bash(cp \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/1\"*\"-research\"*\".sh\" \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/\")",
"Bash(bash workflows/11-research-citation.sh --owner zzx-coder --repo gitlink-cli --format bibtex)",
"Bash(bash -x workflows/11-research-citation.sh --owner zzx-coder --repo gitlink-cli --format bibtex)",
"Bash(gitlink-cli pr:*)",
"Bash(gitlink-cli search:*)",
"Bash(gitlink-cli ci:*)",
"Bash(bash -n workflows/06-research-insights.sh)",
"Bash(timeout 60 bash workflows/06-research-insights.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash -n workflows/08-research-compliance.sh)",
"Bash(timeout 90 bash workflows/08-research-compliance.sh --owner zzx-coder --repo gitlink-cli --local-path . --dry-run)",
"Bash(timeout:*)",
"Bash(bash -c 'set -euo pipefail; cand_ids=\"\"; analyzed=0; while IFS=\"\t\" read -r c_owner c_repo; do echo \"loop\"; done <<< \"$cand_ids\"; echo \"done loop\"')",
"Bash(bash -c 'cand_ids=\"\"; while IFS=\" \" read -r line; do echo \"GOT: [$line]\"; done <<< \"$cand_ids\"; echo \"AFTER LOOP\"')",
"Bash(xxd C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/09-research-collab.sh)",
"Bash(xxd)",
"Bash(# Remove debug lines first, then replace here-string with pipe\ncp \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/09-research-collab.sh\" \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/09-research-collab.sh.bak\"\n# Restore original and make targeted fix\ngit -C \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli\" checkout -- workflows/09-research-collab.sh 2>/dev/null\n# Now apply all the fixes we discovered)",
"Bash(./gitlink-cli.exe repo:*)",
"Bash(./gitlink-cli.exe api:*)",
"Bash(./gitlink-cli.exe issue:*)",
"Bash(./gitlink-cli.exe pr:*)",
"mcp__zai-mcp-server__extract_text_from_screenshot",
"mcp__zai-mcp-server__analyze_image",
"Bash(python3 -c \":*)",
"Bash(python -c \":*)",
"Bash(where tesseract:*)",
"Bash(pip list:*)",
"Bash(find C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/internal -type f -name *.go)",
"Bash(find C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts -type f -name *.go)",
"Bash(find C:/Users/Lenovo/Desktop/soft运维/gitlink-cli -name *_test.go -type f)",
"Bash(sed 's/\"\"\"\"//g')",
"Bash(sed 's/CallAPI\\(\"\"\"\"//g')",
"Read(//tmp/**)",
"Bash(go test:*)",
"Bash(go run:*)",
"Bash(gitlink-cli.exe wiki:*)",
"Bash(gitlink-cli board:*)",
"Bash(./gitlink-cli.exe board:*)",
"Bash(where gitlink-cli:*)",
"Bash(./gitlink-cli.exe milestone:*)",
"Bash(cd:*)",
"Bash(gitlink-cli file:*)"
]
}
}

View File

@ -23,6 +23,9 @@ jobs:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Run tests
run: go test ./... -cover -race
- name: Build binaries
run: |
mkdir -p dist

View File

@ -15,4 +15,4 @@ clean:
rm -f $(BINARY)
test:
go test ./...
go test ./... -race

147
README.md
View File

@ -27,9 +27,11 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| Category | Capabilities |
|----------|-------------|
| 📦 Repo | List, create, fork, delete repositories, view repo info |
| 🐛 Issue | Create, update, close, comment on issues, 6 batch operations (close/status/priority/assignee/label/create) |
| 🐛 Issue | Create, update, close, comment on issues, 6 batch operations (close/status/priority/assignee/label/create), metadata queries, comment management |
| 📖 Wiki | View, create, update, delete Wiki pages |
| 🔀 PR | Create, merge, review pull requests, view changed files |
| 🔀 PR | Create, merge, review pull requests, reopen, update, view commits/versions/diffs, comment management |
| 📁 File | Browse directories, read files, create/update/delete files, batch commit, view commit history and diffs |
| 🏁 Milestone | List, create, view, update, delete milestones, change status |
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
| 🏷️ Release | Create, view, delete releases |
| 🔗 Webhook | Create, view, update, delete, test webhooks, configure automation triggers |
@ -37,6 +39,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 🔧 CI | View builds, logs, CI/CD operations |
| 🔍 Search | Search repositories, users |
| 👤 User | View user profiles and info |
| 📋 Board | View kanban board, filter issues by status/assignee/priority, move tasks, assign people, workload analytics |
| 📋 PM | Sprint management, kanban boards, weekly reports |
| 🤖 Workflow | AI-powered issue triage, PR review, release notes |
@ -191,6 +194,34 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
# Add a comment
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
# Batch delete issues
gitlink-cli issue +batch-destroy --owner Gitlink --repo forgeplus --numbers 100,101,102
```
### Issue Metadata & Comments
```bash
# List available issue statuses
gitlink-cli issue +statuses
# List issue authors
gitlink-cli issue +authors --keyword zhang
# List issue assignees
gitlink-cli issue +assigners
# List issue priorities
gitlink-cli issue +priorities
# Edit a comment
gitlink-cli issue +comment-edit --number 42 --comment-id 100 --body "updated comment"
# Delete a comment
gitlink-cli issue +comment-delete --number 42 --comment-id 100
# List replies to a comment
gitlink-cli issue +replies --number 42 --comment-id 100
```
### Pull Requests
@ -215,6 +246,93 @@ gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
```
### File & Code Operations
```bash
# List root directory
gitlink-cli file +ls --owner Gitlink --repo forgeplus
# Browse subdirectory
gitlink-cli file +tree --path src/
# Read file content
gitlink-cli file +read --path README.md
# Read README
gitlink-cli file +readme
# Search files by name
gitlink-cli file +search --q "test"
# Create a file
gitlink-cli file +create --path docs/new.md --content "# New Doc" --branch master --message "add doc"
# Update a file (auto-fetches sha)
gitlink-cli file +update --path README.md --content "updated" --branch master --message "update readme"
# Delete a file (auto-fetches sha)
gitlink-cli file +delete --path old.txt --branch master
# Batch commit multiple files
gitlink-cli file +batch --branch master --message "batch update" --files '[{"action_type":"create","file_path":"a.txt","content":"hello"}]'
# View commit history
gitlink-cli file +commits
# View commit diff
gitlink-cli file +diff --sha abc1234
```
### Milestone Management
```bash
# List milestones
gitlink-cli milestone +list --owner Gitlink --repo forgeplus
# Create a milestone
gitlink-cli milestone +create --name "v1.0" --description "First release" --date 2026-12-31
# View milestone details
gitlink-cli milestone +view --id 1
# Update a milestone
gitlink-cli milestone +update --id 1 --name "v1.0-rc1"
# Close a milestone
gitlink-cli milestone +status --id 1 --status closed
# Delete a milestone
gitlink-cli milestone +delete --id 1
```
### PR Enhanced Operations
```bash
# Reopen a closed PR
gitlink-cli pr +reopen --id 42
# Update PR title/description
gitlink-cli pr +update --id 42 --title "New title"
# List commits in a PR
gitlink-cli pr +commits --id 42
# List PR versions
gitlink-cli pr +versions --id 42
# View diff of a specific PR version
gitlink-cli pr +vdiff --id 42 --version 5
# List changed files (v1 API with pagination)
gitlink-cli pr +filesv1 --id 42
# Edit a PR review comment
gitlink-cli pr +comment-edit --id 42 --comment-id 100 --body "updated" --state resolved
# Delete a PR review comment
gitlink-cli pr +comment-delete --id 42 --comment-id 100
```
### Branch Management
```bash
@ -234,6 +352,28 @@ gitlink-cli branch +protect --name main
gitlink-cli branch +unprotect --name main
```
### Board (Kanban)
```bash
# View kanban board layout
gitlink-cli board +view --owner Gitlink --repo forgeplus
# List status columns with issue counts
gitlink-cli board +columns --owner Gitlink --repo forgeplus
# Filter issues by status and assignee
gitlink-cli board +issues --owner Gitlink --repo forgeplus --status in-progress --assignee zhangsan
# Move an issue to a different status
gitlink-cli board +move --owner Gitlink --repo forgeplus --number 42 --status resolved
# Assign an issue to someone
gitlink-cli board +assign --owner Gitlink --repo forgeplus --number 42 --assignee zhangsan
# View board analytics and statistics
gitlink-cli board +stats --owner Gitlink --repo forgeplus
```
### Webhook Management
```bash
@ -408,6 +548,7 @@ gitlink-cli/
│ ├── repo/ # Repository shortcuts
│ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts
│ ├── board/ # Board (kanban) shortcuts
│ ├── branch/ # Branch shortcuts
│ ├── release/ # Release shortcuts
│ ├── org/ # Organization shortcuts
@ -415,6 +556,8 @@ gitlink-cli/
│ ├── search/ # Search shortcuts
│ ├── user/ # User shortcuts
│ ├── wiki/ # Wiki shortcuts
│ ├── file/ # File & code shortcuts
│ ├── milestone/ # Milestone shortcuts
│ └── register.go # Registration entry point
├── skills/ # AI Agent Skills
│ ├── README.md # Skills guide

View File

@ -27,9 +27,11 @@
| 分类 | 能力 |
|------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息 |
| 🐛 Issue | 创建、更新、关闭、评论 Issue6 个批量操作(关闭/状态/优先级/负责人/标记/创建) |
| 🐛 Issue | 创建、更新、关闭、评论 Issue6 个批量操作,元数据查询,评论管理 |
| 📖 Wiki | 查看、创建、更新、删除 Wiki 页面 |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
| 🔀 PR | 创建、合并、Review Pull Request重新打开、更新查看提交/版本/Diff评论管理 |
| 📁 文件 | 浏览目录、读取文件、创建/更新/删除文件、批量提交、查看提交历史和 Diff |
| 🏁 里程碑 | 列出、创建、查看、更新、删除里程碑,变更状态 |
| 🌿 分支 | 创建、删除、保护分支 |
| 🏷️ 发布 | 创建、查看、删除 Release |
| 🔗 Webhook | 创建、查看、更新、删除、测试 Webhook配置自动化触发器 |
@ -37,6 +39,7 @@
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| 🔍 搜索 | 搜索仓库、用户 |
| 👤 用户 | 查看用户资料和信息 |
| 📋 看板 | 查看看板、按状态/指派人/优先级筛选、移动任务、指派人员、工作负载分析 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
@ -204,6 +207,9 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
# 添加评论
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
# 批量删除 Issue
gitlink-cli issue +batch-destroy --owner Gitlink --repo forgeplus --numbers 100,101,102
# 批量修改状态
gitlink-cli issue +batch-status --state resolved --numbers 1,2,3 --owner Gitlink --repo forgeplus
@ -224,6 +230,31 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 1,2,3
```
### Issue 元数据与评论
```bash
# 查看可用的 Issue 状态列表
gitlink-cli issue +statuses
# 查看 Issue 发布人列表
gitlink-cli issue +authors --keyword zhang
# 查看 Issue 负责人列表
gitlink-cli issue +assigners
# 查看 Issue 优先级列表
gitlink-cli issue +priorities
# 编辑评论
gitlink-cli issue +comment-edit --number 42 --comment-id 100 --body "修正后的评论"
# 删除评论
gitlink-cli issue +comment-delete --number 42 --comment-id 100
# 查看评论的回复列表
gitlink-cli issue +replies --number 42 --comment-id 100
```
### Pull Request
```bash
@ -246,6 +277,115 @@ gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
```
### PR 增强操作
```bash
# 重新打开已关闭的 PR
gitlink-cli pr +reopen --id 42
# 更新 PR 标题/描述
gitlink-cli pr +update --id 42 --title "新标题"
# 查看 PR 中的提交列表
gitlink-cli pr +commits --id 42
# 查看 PR 版本历史
gitlink-cli pr +versions --id 42
# 查看 PR 某版本的 Diff
gitlink-cli pr +vdiff --id 42 --version 5
# 查看 PR 变更文件列表v1 API支持分页
gitlink-cli pr +filesv1 --id 42
# 编辑 PR 审查评论
gitlink-cli pr +comment-edit --id 42 --comment-id 100 --body "更新内容" --state resolved
# 删除 PR 审查评论
gitlink-cli pr +comment-delete --id 42 --comment-id 100
```
### 文件与代码操作
```bash
# 列出根目录文件
gitlink-cli file +ls --owner Gitlink --repo forgeplus
# 浏览子目录
gitlink-cli file +tree --path src/
# 读取文件内容
gitlink-cli file +read --path README.md
# 读取 README
gitlink-cli file +readme
# 按文件名搜索
gitlink-cli file +search --q "test"
# 创建文件
gitlink-cli file +create --path docs/new.md --content "# 新文档" --branch master --message "add doc"
# 更新文件(自动获取 sha
gitlink-cli file +update --path README.md --content "更新内容" --branch master --message "update readme"
# 删除文件(自动获取 sha
gitlink-cli file +delete --path old.txt --branch master
# 批量提交多个文件
gitlink-cli file +batch --branch master --message "batch update" --files '[{"action_type":"create","file_path":"a.txt","content":"hello"}]'
# 查看提交历史
gitlink-cli file +commits
# 查看提交 Diff
gitlink-cli file +diff --sha abc1234
```
### 里程碑管理
```bash
# 列出里程碑
gitlink-cli milestone +list --owner Gitlink --repo forgeplus
# 创建里程碑
gitlink-cli milestone +create --name "v1.0" --description "首个正式版" --date 2026-12-31
# 查看里程碑详情
gitlink-cli milestone +view --id 1
# 更新里程碑
gitlink-cli milestone +update --id 1 --name "v1.0-rc1"
# 关闭里程碑
gitlink-cli milestone +status --id 1 --status closed
# 删除里程碑
gitlink-cli milestone +delete --id 1
```
### 看板操作
```bash
# 查看看板布局
gitlink-cli board +view --owner Gitlink --repo forgeplus
# 列出各状态列及 Issue 数量
gitlink-cli board +columns --owner Gitlink --repo forgeplus
# 按状态和指派人筛选任务
gitlink-cli board +issues --owner Gitlink --repo forgeplus --status in-progress --assignee zhangsan
# 移动任务状态
gitlink-cli board +move --owner Gitlink --repo forgeplus --number 42 --status resolved
# 指派任务给用户
gitlink-cli board +assign --owner Gitlink --repo forgeplus --number 42 --assignee zhangsan
# 查看看板统计分析
gitlink-cli board +stats --owner Gitlink --repo forgeplus
```
### Webhook 管理
```bash
@ -406,6 +546,7 @@ gitlink-cli/
│ ├── repo/ # 仓库 shortcuts
│ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts
│ ├── board/ # 看板 shortcuts
│ ├── branch/ # 分支 shortcuts
│ ├── release/ # Release shortcuts
│ ├── org/ # 组织 shortcuts
@ -413,6 +554,8 @@ gitlink-cli/
│ ├── search/ # 搜索 shortcuts
│ ├── user/ # 用户 shortcuts
│ ├── wiki/ # Wiki shortcuts
│ ├── file/ # 文件与代码 shortcuts
│ ├── milestone/ # 里程碑 shortcuts
│ └── register.go # 注册入口
├── skills/ # AI Agent Skills
│ ├── README.md # Skills 使用指南

View File

@ -4,10 +4,13 @@ import "errors"
// Global flags shared across all commands.
var (
Owner string
Repo string
Format string
Debug bool
Owner string
Repo string
Format string
Debug bool
NoTruncate bool // disable 60-char truncation in table output
Columns string // comma-separated column names for table output
NoColor bool // disable ANSI color output
)
// ErrSilent 是 sentinel error表示错误已经被上层处理过如已按 envelope 格式

View File

@ -11,6 +11,7 @@ import (
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
"github.com/gitlink-org/gitlink-cli/internal/compliance"
"github.com/gitlink-org/gitlink-cli/shortcuts"
)
@ -27,13 +28,18 @@ var rootCmd = &cobra.Command{
func init() {
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)")
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().StringVar(&cmdutil.Format, "format", "table", "Output format: json, table, yaml")
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
rootCmd.PersistentFlags().BoolVar(&cmdutil.NoTruncate, "no-truncate", false, "Disable value truncation in table output")
rootCmd.PersistentFlags().StringVar(&cmdutil.Columns, "columns", "", "Columns to show in table output (comma-separated)")
rootCmd.PersistentFlags().BoolVar(&cmdutil.NoColor, "no-color", false, "Disable colored output")
rootCmd.AddCommand(authCmd.NewAuthCmd())
rootCmd.AddCommand(apiCmd.NewAPICmd())
rootCmd.AddCommand(configCmd.NewConfigCmd())
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(completionCmd)
rootCmd.AddCommand(compliance.NewCommand())
shortcuts.RegisterAll(rootCmd)
}
@ -46,6 +52,31 @@ var versionCmd = &cobra.Command{
},
}
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script",
Long: "Generate shell autocompletion script for the specified shell.\n\nTo load completions:\n\n Bash:\n source <(gitlink-cli completion bash)\n\n Zsh:\n source <(gitlink-cli completion zsh)\n\n fish:\n gitlink-cli completion fish | source\n\n PowerShell:\n gitlink-cli completion powershell | Out-String | Invoke-Expression",
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
RunE: func(cmd *cobra.Command, args []string) error {
shell := "bash"
if len(args) > 0 {
shell = args[0]
}
switch shell {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("unsupported shell: %s (valid: bash, zsh, fish, powershell)", shell)
}
},
}
func Execute() error {
if err := rootCmd.Execute(); err != nil {
// ErrSilent 表示错误已经按 envelope 格式输出到 stdout如 API 错误),

View File

@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Bash(go build:*)",
"Bash(/c/Users/Lenovo/Desktop/soft运维/gitlink-cli/gitlink-cli.exe board:*)",
"Bash(go install:*)",
"Bash(gitlink-cli board:*)",
"Bash(/c/Users/Lenovo/go/bin/gitlink-cli.exe board:*)",
"Bash(gitlink-cli repo:*)",
"Bash(gitlink-cli api:*)",
"Bash(gitlink-cli issue:*)",
"Bash(find C:/Users/Lenovo/Desktop/soft运维/gitlink-cli -name *.go -not -path */vendor/* -exec wc -l {} +)",
"Bash(pip install:*)",
"Bash(python gen_docx.py)",
"Bash(python -c \"import docx; print\\(''ok''\\)\")",
"Bash(ls -la \"C:\\\\Users\\\\Lenovo\\\\Desktop\\\\soft运维\\\\gitlink-cli\\\\doc/\"*.docx)",
"Bash(python gen_ppt.py)"
]
}
}

BIN
doc/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -0,0 +1,450 @@
# PR 格式模板(以 board 功能为例)
## PR 标题
```
feat(board): 新增项目看板 shortcut — 查看/筛选/移动/指派/统计
```
格式:`type(scope): 简述`,与仓库现有 commit 风格一致。
---
## PR 描述
```markdown
## Summary
- 新增 `board` 命令组,提供 6 个看板操作子命令
- 基于 issue list API 实现看板视图(按 status_id 分组为 5 列)
- 写操作(+move/+assign复用 issue PATCH API支持 --dry-run
- 包含单元测试和帮助文档
## Changes
### 新增文件
- `shortcuts/board/board.go` — 6 个命令 + 辅助函数
- `shortcuts/board/board_test.go` — 单元测试
### 修改文件
- `shortcuts/register.go` — 注册 board 组
## Commands
| 命令 | 类型 | 说明 |
|------|------|------|
| `board +view` | 读 | 按状态分组显示看板 |
| `board +columns` | 读 | 列出各状态列及 issue 数量 |
| `board +issues` | 读 | 按状态/指派人/优先级筛选 |
| `board +move` | 写 | 移动任务状态 |
| `board +assign` | 写 | 指派任务 |
| `board +stats` | 读 | 完成率/工作负载/瓶颈分析 |
## Test plan
- [x] `go test ./shortcuts/board/...` 通过
- [x] `go build ./...` 编译通过
- [x] `board +view` 输出正确的看板结构
- [x] `board +columns` 返回 5 列
- [x] `board +issues --status in-progress` 筛选正确
- [x] `board +move --dry-run` 预览不执行
- [x] `board +assign --dry-run` 预览不执行
- [x] `board +stats` 完成率计算正确
🤖 Generated with [Claude Code](https://claude.com/claude-code)
```
---
## Commit 规范
仓库使用 conventional commits 格式:
```
type(scope): description
```
常用 type
- `feat` — 新功能
- `fix` — 修复
- `refactor` — 重构
- `docs` — 文档
- `test` — 测试
board 功能的 commit 示例:
```
feat(board): 新增 board shortcut — 看板查看/筛选/移动/指派/统计
test(board): 添加 board 命令单元测试
```
如果拆成多个 commit
```
feat(board): 新增 board +view/+columns/+issues 读命令
feat(board): 新增 board +move/+assign 写命令
test(board): 添加 board 命令单元测试
```
---
## 单元测试模板
仓库测试风格:用 `httptest.NewServer` mock API直接构造 `RuntimeContext` 调用 `Run` 函数。
### 文件:`shortcuts/board/board_test.go`
```go
package board
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// === mock server ===
func newBoardTestServer(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")
json.NewEncoder(w).Encode(payload)
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
return payload
}
// 模拟 issue list API 响应
func mockIssueListResponse() map[string]interface{} {
return map[string]interface{}{
"issues": []interface{}{
map[string]interface{}{
"id": 101,
"subject": "Fix login bug",
"status_id": float64(1),
"status_name": "待处理",
"priority_id": float64(2),
"priority_name": "正常",
"project_issues_index": float64(1),
"assigners": []interface{}{},
},
map[string]interface{}{
"id": 102,
"subject": "Add dark mode",
"status_id": float64(2),
"status_name": "进行中",
"priority_id": float64(3),
"priority_name": "高",
"project_issues_index": float64(2),
"assigners": []interface{}{
map[string]interface{}{"login": "zhangsan", "name": "Zhang San"},
},
},
map[string]interface{}{
"id": 103,
"subject": "Update README",
"status_id": float64(3),
"status_name": "已解决",
"priority_id": float64(1),
"priority_name": "低",
"project_issues_index": float64(3),
"assigners": []interface{}{},
},
},
"total_count": float64(3),
"total_issues_count": float64(3),
}
}
// === helper ===
func runBoardShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findBoardShortcut(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 findBoardShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
// === 测试用例 ===
func TestBoardViewGroupsByStatus(t *testing.T) {
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issues") {
writeJSON(t, w, mockIssueListResponse())
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runBoardShortcut(t, server, "view", map[string]string{"state": "all"})
if err != nil {
t.Fatalf("board +view failed: %v", err)
}
// 验证view 命令不报错即通过,输出由 ctx.OutputData 处理
}
func TestBoardColumnsReturnsAllStatuses(t *testing.T) {
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issues") {
writeJSON(t, w, mockIssueListResponse())
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
// columns 命令输出到 stdout这里只验证不报错
err := runBoardShortcut(t, server, "columns", map[string]string{"state": "all"})
if err != nil {
t.Fatalf("board +columns failed: %v", err)
}
}
func TestBoardIssuesFilterByStatus(t *testing.T) {
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issues") {
writeJSON(t, w, mockIssueListResponse())
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runBoardShortcut(t, server, "issues", map[string]string{
"state": "all",
"status": "in-progress",
})
if err != nil {
t.Fatalf("board +issues --status in-progress failed: %v", err)
}
}
func TestBoardMoveSendsCorrectStatusID(t *testing.T) {
var patchPayload map[string]interface{}
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/issues/1"):
writeJSON(t, w, map[string]interface{}{
"subject": "Fix login bug",
"description": "Steps to reproduce...",
})
case r.Method == "PATCH" && strings.Contains(r.URL.Path, "/issues/1"):
patchPayload = decodeJSON(t, r)
writeJSON(t, w, patchPayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runBoardShortcut(t, server, "move", map[string]string{
"number": "1",
"status": "in-progress",
})
if err != nil {
t.Fatalf("board +move failed: %v", err)
}
// 验证 PATCH body 包含正确的 status_id
if patchPayload["status_id"] != float64(2) {
t.Errorf("expected status_id=2, got %v", patchPayload["status_id"])
}
// 验证 subject 和 description 被保留
if patchPayload["subject"] != "Fix login bug" {
t.Errorf("subject not preserved: got %v", patchPayload["subject"])
}
if patchPayload["description"] != "Steps to reproduce..." {
t.Errorf("description not preserved: got %v", patchPayload["description"])
}
}
func TestBoardAssignSendsAssignedToID(t *testing.T) {
var patchPayload map[string]interface{}
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/users/zhangsan.json":
writeJSON(t, w, map[string]interface{}{"id": float64(999), "login": "zhangsan"})
case r.Method == "GET" && strings.Contains(r.URL.Path, "/issues/1"):
writeJSON(t, w, map[string]interface{}{
"subject": "Fix login bug",
"description": "Steps to reproduce...",
})
case r.Method == "PATCH" && strings.Contains(r.URL.Path, "/issues/1"):
patchPayload = decodeJSON(t, r)
writeJSON(t, w, patchPayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runBoardShortcut(t, server, "assign", map[string]string{
"number": "1",
"assignee": "zhangsan",
})
if err != nil {
t.Fatalf("board +assign failed: %v", err)
}
if patchPayload["assigned_to_id"] != float64(999) {
t.Errorf("expected assigned_to_id=999, got %v", patchPayload["assigned_to_id"])
}
}
func TestParseStatusID(t *testing.T) {
tests := []struct {
input string
want int
err bool
}{
{"new", 1, false},
{"in-progress", 2, false},
{"in_progress", 2, false},
{"resolved", 3, false},
{"closed", 5, false},
{"rejected", 6, false},
{"进行中", 2, false},
{"42", 42, false},
{"invalid", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parseStatusID(tt.input)
if tt.err && err == nil {
t.Errorf("expected error for %q", tt.input)
}
if !tt.err && err != nil {
t.Errorf("unexpected error for %q: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("parseStatusID(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestParsePriorityID(t *testing.T) {
tests := []struct {
input string
want int
err bool
}{
{"low", 1, false},
{"normal", 2, false},
{"high", 3, false},
{"urgent", 4, false},
{"99", 99, false},
{"invalid", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parsePriorityID(tt.input)
if tt.err && err == nil {
t.Errorf("expected error for %q", tt.input)
}
if !tt.err && err != nil {
t.Errorf("unexpected error for %q: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("parsePriorityID(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestGroupByStatus(t *testing.T) {
issues := []issueItem{
{ID: 1, StatusID: 1},
{ID: 2, StatusID: 2},
{ID: 3, StatusID: 2},
{ID: 4, StatusID: 3},
}
grouped := groupByStatus(issues)
if len(grouped[1]) != 1 {
t.Errorf("expected 1 issue in status 1, got %d", len(grouped[1]))
}
if len(grouped[2]) != 2 {
t.Errorf("expected 2 issues in status 2, got %d", len(grouped[2]))
}
if len(grouped[3]) != 1 {
t.Errorf("expected 1 issue in status 3, got %d", len(grouped[3]))
}
}
```
---
## 帮助文档更新
board 的帮助信息已经在 `board.go``Description`/`Long`/`Example` 字段中定义,`board --help` 会自动输出。无需额外文档文件。
如果要更新项目 README 或 skill 文档,在对应文件中添加:
```markdown
### Board (看板)
```bash
# 查看看板
gitlink-cli board +view
# 按状态筛选
gitlink-cli board +issues --status in-progress --assignee zhangsan
# 移动任务
gitlink-cli board +move --number 42 --status resolved
# 统计分析
gitlink-cli board +stats
```
```
---
## PR Checklist
```markdown
## Checklist
- [ ] `go build ./...` 编译通过
- [ ] `go test ./shortcuts/board/...` 测试通过
- [ ] `go vet ./...` 无警告
- [ ] 新命令 `--help` 输出正确
- [ ] 写命令支持 `--dry-run`
- [ ] 错误信息使用 `clierrors.OpError` 包装
- [ ] commit message 符合 `type(scope): description` 格式
```

403
doc/generate_word.py Normal file
View File

@ -0,0 +1,403 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成系统动态模型章节的 Word 文档 - 四个赛题"""
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def set_cell_shading(cell, color):
"""设置单元格背景颜色"""
shading = OxmlElement('w:shd')
shading.set(qn('w:fill'), color)
cell._tc.get_or_add_tcPr().append(shading)
def add_table(doc, headers, rows, header_color="4472C4"):
"""添加表格"""
table = doc.add_table(rows=len(rows)+1, cols=len(headers))
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
# 表头
for i, header in enumerate(headers):
cell = table.rows[0].cells[i]
cell.text = header
set_cell_shading(cell, header_color)
for paragraph in cell.paragraphs:
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in paragraph.runs:
run.font.bold = True
run.font.color.rgb = RGBColor(255, 255, 255)
# 数据行
for i, row in enumerate(rows):
for j, value in enumerate(row):
table.rows[i+1].cells[j].text = value
return table
def create_document():
doc = Document()
# 设置默认字体
style = doc.styles['Normal']
style.font.name = 'Microsoft YaHei'
style._element.rPr.rFonts.set(qn('w:eastAsia'), 'Microsoft YaHei')
# 标题
title = doc.add_heading('系统动态模型', 0)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
# 概述
doc.add_heading('概述', level=1)
doc.add_paragraph(
'系统动态模型描述了 gitlink-cli 在四个赛题场景下的运行时行为,'
'展示了各组件之间的交互流程和数据流向。每个赛题对应一个核心能力方向,'
'覆盖 CLI 功能扩展、Skills 开发、自动化工作流和科研辅助四大领域。'
)
# ==================== 赛题一 ====================
doc.add_heading('子赛题一:增加和完善 GitLink-CLI 能力', level=1)
doc.add_heading('1.1 能构建的模型', level=2)
doc.add_paragraph(
'本赛题聚焦于 CLI 命令系统的功能扩展和优化。可构建以下动态模型:'
)
doc.add_paragraph('命令执行模型:描述 Shortcut 命令从参数解析到 API 调用的完整执行流程', style='List Bullet')
doc.add_paragraph('批量操作模型描述批量命令batch-close、batch-create 等)的遍历执行和错误处理机制', style='List Bullet')
doc.add_paragraph('参数校验模型:描述 Choices 枚举校验和 Validate 自定义校验的执行流程', style='List Bullet')
doc.add_paragraph('输出格式化模型:描述 Envelope 数据经过 table/json/yaml 格式化后的输出流程', style='List Bullet')
doc.add_heading('1.2 新生成的组件', level=2)
add_table(doc,
['组件类型', '组件名称', '功能说明'],
[
['Shortcut', 'wiki +list/+view/+create/+update/+delete', 'Wiki 页面 CRUD 操作'],
['Shortcut', 'webhook +list/+create/+update/+delete/+test', 'Webhook 配置管理'],
['Shortcut', 'board +view/+columns/+issues/+move/+assign', '项目看板操作'],
['Shortcut', 'issue +batch-close/+batch-create/+batch-assign', 'Issue 批量操作'],
['Shortcut', 'repo +batch-create/+batch-update', '仓库批量操作'],
['Shortcut', 'pr +merge (支持 merge/rebase/squash)', 'PR 合并方式选择'],
['Shortcut', 'branch +protect/+unprotect', '分支保护规则管理'],
['Shortcut', 'release +list/+create/+view/+delete', '版本发布管理'],
['结构体', 'Flag.Choices', '枚举参数校验,自动追加 [val1|val2] 提示'],
['结构体', 'Flag.Validate', '自定义校验函数,跨参数约束'],
['结构体', 'PrintOptions', '输出选项:列过滤、禁用截断、彩色输出'],
['函数', 'OpError()', '统一错误构造函数,中英文双语错误信息'],
]
)
doc.add_heading('1.3 交互流程示例', level=2)
doc.add_paragraph('示例一Wiki 页面创建', style='List Bullet')
steps = [
'用户输入gitlink-cli wiki +create --title "API文档" --content "# API Reference"',
'Cobra 解析命令,匹配到 wiki +create Shortcut',
'Validate 校验:检查 --title 是否为空',
'ResolveOwnerRepo() 从 git remote 解析 owner/repo',
'CallAPI("POST", "/wiki/open/createWiki", body) 调用 Gateway API',
'OutputData() 格式化输出创建结果',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例二:枚举参数校验', style='List Bullet')
steps = [
'用户输入gitlink-cli pr +merge --id 42 --method unknown',
'Cobra 解析参数,发现 --method 值为 unknown',
'Choices 校验unknown 不在 [merge, rebase, squash] 中',
'输出错误invalid value "unknown" for --method有效值: merge, rebase, squash',
'命令终止exit code = 1',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例三:批量关闭 Issuedry-run 预览)', style='List Bullet')
steps = [
'用户输入gitlink-cli issue +batch-close --numbers 101,102,103 --dry-run',
'解析 --numbers 参数,得到 [101, 102, 103]',
'IsDryRun() == true进入预览模式',
'构建 BatchSummary {dry_run: true, total: 3, results: [{status: "planned"},...]}',
'输出预览结果,不发起 HTTP 请求',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
# ==================== 赛题二 ====================
doc.add_heading('子赛题二:编写和丰富 GitLink Skills', level=1)
doc.add_heading('2.1 能构建的模型', level=2)
doc.add_paragraph(
'本赛题聚焦于 AI Agent Skill 的开发。可构建以下动态模型:'
)
doc.add_paragraph('Skill 加载模型:描述 Claude Code 触发 Skill、加载 SKILL.md 和 reference 文档的流程', style='List Bullet')
doc.add_paragraph('AI 分析模型:描述 AI 对 PR Diff、Issue 内容、仓库数据的分析和决策流程', style='List Bullet')
doc.add_paragraph('自动执行模型:描述 AI 分析结果转化为 CLI 命令执行的流程', style='List Bullet')
doc.add_heading('2.2 新生成的组件', level=2)
add_table(doc,
['组件类型', '组件名称', '功能说明'],
[
['Skill', 'gitlink-code-review', '智能代码审查:分析 PR diff输出结构化 Review 意见'],
['Skill', 'gitlink-issue-triage', 'Issue 自动分拣:根据内容自动分类、打标签、分配责任人'],
['Skill', 'gitlink-changelog', 'Release Notes 生成:根据 commit 和 PR 生成版本说明'],
['Skill', 'gitlink-health', '项目健康度报告:统计 Issue 响应时间、PR 合并效率'],
['Skill', 'gitlink-compliance', '许可证合规检查:扫描许可证合规性和敏感信息泄露'],
['Skill', 'gitlink-onboard', '新人引导:为 good-first-issue 自动添加引导评论'],
['Skill', 'gitlink-stale', '过期 Issue 管理:自动标记和关闭长期未活动的 Issue'],
['Skill', 'gitlink-faq', 'FAQ 自动回复:根据 Issue 内容匹配 FAQ 并自动评论'],
['Reference', 'references/pr-review.md', 'PR 审查参考文档:审查维度、评分标准、评论模板'],
['Reference', 'references/issue-triage.md', 'Issue 分类参考文档:分类规则、标签映射、分配策略'],
]
)
doc.add_heading('2.3 交互流程示例', level=2)
doc.add_paragraph('示例一:智能代码审查', style='List Bullet')
steps = [
'用户触发:"帮我审查 PR #42"',
'Claude Code 加载 gitlink-code-review skill',
'获取 PR 详情gitlink-cli pr +view --id 42 --format json',
'获取变更文件gitlink-cli pr +files --id 42 --format json',
'AI 分析代码:检测未处理 error、SQL 注入风险、性能问题',
'生成审查报告:质量评分 8.5/10列出 3 个问题',
'提交审查评论gitlink-cli pr +review --id 42 --body "..." --event comment',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例二Issue 自动分拣', style='List Bullet')
steps = [
'用户触发:"帮我分类本周的 Issue"',
'Claude Code 加载 gitlink-issue-triage skill',
'获取 Issue 列表gitlink-cli issue +list --state open --format json',
'AI 分析内容识别关键词bug/功能/提问)',
'自动添加标签gitlink-cli issue +label-add --number 101 --labels "缺陷"',
'自动分配责任人gitlink-cli issue +update --number 101 --assignee "zhangsan"',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例三Release Notes 生成', style='List Bullet')
steps = [
'用户触发:"帮我生成 v2.1.0 的 Release Notes"',
'Claude Code 加载 gitlink-changelog skill',
'获取已合并 PRgitlink-cli pr +list --state merged --format json',
'获取 commit 历史git log --oneline v2.0.0..HEAD',
'AI 分类整理新功能、Bug 修复、性能优化、Breaking Changes',
'生成 Release Notesgitlink-cli release +create --tag v2.1.0 --body "..."',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
# ==================== 赛题三 ====================
doc.add_heading('子赛题三:构建端到端自动化工作流', level=1)
doc.add_heading('3.1 能构建的模型', level=2)
doc.add_paragraph(
'本赛题聚焦于串联多个步骤的完整解决方案。可构建以下动态模型:'
)
doc.add_paragraph('工作流调度模型:描述工作流从触发、菜单选择、步骤执行到结果输出的完整流程', style='List Bullet')
doc.add_paragraph('多步骤编排模型:描述多个 CLI 命令和 Skill 的串联执行和错误处理', style='List Bullet')
doc.add_paragraph('数据聚合模型:描述从多个数据源采集数据并汇总生成报告的流程', style='List Bullet')
doc.add_heading('3.2 新生成的组件', level=2)
add_table(doc,
['组件类型', '组件名称', '功能说明'],
[
['Skill', 'gitlink-workflows', '工作流调度入口,提供功能菜单选择'],
['Workflow', '01-community-ops.sh', '社区运营自动化Issue 分类 + 周报生成'],
['Workflow', '01a-issue-triage.sh', 'Issue 分类子工作流'],
['Workflow', '01a-webhook-setup.sh', 'Webhook 配置子工作流'],
['Workflow', '02-code-quality-gatekeeper.sh', '代码质量门禁PR 审查 + 评分'],
['Workflow', '03-project-init.sh', '项目一键初始化:仓库 + 文件 + CI + Issues'],
['Workflow', '04-multi-repo-collab.sh', '多仓库协同:依赖追踪 + 协同发版'],
['Workflow', '05-contributor-growth.sh', '贡献者成长体系:评分 + 排行 + Badge'],
['Lib', 'lib/common.sh', '公共函数库:认证检查、错误处理、日志输出'],
]
)
doc.add_heading('3.3 交互流程示例', level=2)
doc.add_paragraph('示例一:项目一键初始化', style='List Bullet')
steps = [
'用户触发:"帮我初始化新项目 my-project"',
'Claude Code 加载 gitlink-workflows skill显示菜单',
'用户选择"项目一键初始化"',
'认证检查gitlink-cli auth status',
'创建仓库gitlink-cli repo +create --name my-project --private',
'初始化文件gitlink-cli file +create --path README.md --content "..."',
'初始化文件gitlink-cli file +create --path .gitignore --content "..."',
'初始化文件gitlink-cli file +create --path LICENSE --content "..."',
'配置分支保护gitlink-cli branch +protect --name master --require-review',
'创建 Issuesgitlink-cli issue +create --title "完善单元测试" --labels "good-first-issue"',
'输出初始化报告',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例二:多仓库协同发版', style='List Bullet')
steps = [
'用户触发:"帮我管理多仓库协同"',
'扫描仓库列表gitlink-cli repo +list --user my-org --format json',
'分析依赖关系frontend → api-client → backend',
'查询跨仓库 PRgitlink-cli pr +list --state open --format json',
'检查发版就绪gitlink-cli release +list --format json',
'识别阻塞项backend PR #456 未合并',
'生成协同 Dashboard显示各仓库状态、依赖关系、阻塞项',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例三:贡献者成长体系', style='List Bullet')
steps = [
'用户触发:"帮我生成贡献者排行榜"',
'获取贡献者列表gitlink-cli repo +members --format json',
'统计 PR 活动gitlink-cli pr +list --state merged --format json',
'统计 Issue 活动gitlink-cli issue +list --state closed --format json',
'AI 计算贡献评分PR 数量 × 3 + Issue 数量 × 1 + Review × 2',
'生成排行榜zhangsan(85分) > lisi(72分) > wangwu(68分)',
'自动颁发 Badge为 top 3 贡献者添加 "core-contributor" 标签',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
# ==================== 赛题四 ====================
doc.add_heading('子赛题四:应用 GitLink 辅助科研', level=1)
doc.add_heading('4.1 能构建的模型', level=2)
doc.add_paragraph(
'本赛题聚焦于科研场景的智能化赋能。可构建以下动态模型:'
)
doc.add_paragraph('科研项目洞悉模型:描述从仓库数据提取科研项目信息(技术栈、活跃度、依赖)的流程', style='List Bullet')
doc.add_paragraph('热点追踪模型:描述从 Issue/PR/Commit 数据挖掘科研热点趋势的流程', style='List Bullet')
doc.add_paragraph('合规检查模型:描述许可证合规性扫描和敏感信息检测的流程', style='List Bullet')
doc.add_paragraph('协作匹配模型:描述根据贡献者技能和项目需求进行智能匹配的流程', style='List Bullet')
doc.add_heading('4.2 新生成的组件', level=2)
add_table(doc,
['组件类型', '组件名称', '功能说明'],
[
['Skill', 'gitlink-research', '科研辅助系统总入口,提供科研场景菜单'],
['Skill', 'gitlink-code-insight', '仓库级科研项目洞悉:技术栈、活跃度、依赖分析'],
['Skill', 'gitlink-compliance', '科研项目合规与复现性检查:许可证、依赖、环境'],
['Workflow', '06-research-insights.sh', '科研热点追踪:从 Issue/PR 挖掘研究趋势'],
['Workflow', '08-research-compliance.sh', '科研合规检查:扫描许可证和敏感信息'],
['Workflow', '10-research-progress.sh', '科研进度跟踪:里程碑进度、阻塞项识别'],
['Workflow', '11-research-citation.sh', '论文引用分析:追踪仓库的学术引用情况'],
]
)
doc.add_heading('4.3 交互流程示例', level=2)
doc.add_paragraph('示例一:仓库级科研项目洞悉', style='List Bullet')
steps = [
'用户触发:"帮我分析这个仓库的科研价值"',
'Claude Code 加载 gitlink-code-insight skill',
'获取仓库信息gitlink-cli repo +info --format json',
'分析技术栈:扫描 package.json/go.mod/requirements.txt',
'分析活跃度gitlink-cli pr +list --state merged --format json',
'分析依赖:识别上下游依赖关系',
'生成科研洞悉报告:技术栈、活跃贡献者、核心模块、研究方向',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例二:科研热点追踪', style='List Bullet')
steps = [
'用户触发:"帮我追踪 AI 领域的科研热点"',
'扫描相关仓库gitlink-cli search +repos --query "machine-learning"',
'获取 Issue 列表gitlink-cli issue +list --state open --format json',
'获取 PR 列表gitlink-cli pr +list --state merged --format json',
'AI 分析关键词识别高频技术词汇transformer、diffusion、LLM',
'生成热点报告:技术趋势、热门项目、活跃研究者',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例三:科研合规与复现性检查', style='List Bullet')
steps = [
'用户触发:"帮我检查这个科研项目的合规性"',
'Claude Code 加载 gitlink-compliance skill',
'扫描许可证:检查 LICENSE 文件和依赖许可证',
'扫描敏感信息:检查 API Key、密码、私钥泄露',
'检查复现性:验证 requirements.txt/go.mod 完整性',
'生成合规报告:许可证合规、依赖风险、复现性评分',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
# ==================== 赛题对比总结 ====================
doc.add_heading('赛题对比总结', level=1)
add_table(doc,
['维度', '赛题一CLI 能力', '赛题二Skills 开发', '赛题三:自动化工作流', '赛题四:辅助科研'],
[
['核心目标', '扩展 CLI 命令', '开发 AI Skill', '串联完整流程', '科研场景赋能'],
['技术栈', 'Go + Cobra', 'Markdown + CLI', 'Shell + CLI + Skill', '数据分析 + AI'],
['交付物', 'Shortcut + 结构体', 'SKILL.md + Reference', 'Workflow 脚本', '科研 Skill + 报告'],
['AI 参与', '', '核心AI 分析)', '调度 + 分析', '深度(知识挖掘)'],
['典型场景', 'Wiki/Webhook/批量', '代码审查/Issue 分类', '项目初始化/协同发版', '热点追踪/合规检查'],
]
)
# ==================== 组件依赖关系 ====================
doc.add_heading('组件依赖关系', level=1)
dep_tree = """gitlink-cli (CLI 核心)
Shortcuts (赛题一)
wiki +list/+view/+create/+update/+delete
webhook +list/+create/+update/+delete/+test
board +view/+columns/+issues/+move/+assign
issue +batch-close/+batch-create/+batch-assign
repo +batch-create/+batch-update
branch +protect/+unprotect
Skills (赛题二)
gitlink-code-review (代码审查)
gitlink-issue-triage (Issue 分类)
gitlink-changelog (Release Notes)
gitlink-health (项目健康度)
gitlink-compliance (合规检查)
gitlink-onboard (新人引导)
gitlink-stale (过期管理)
Workflows (赛题三)
01-community-ops.sh (社区运营)
02-code-quality-gatekeeper.sh (质量门禁)
03-project-init.sh (项目初始化)
04-multi-repo-collab.sh (多仓库协同)
05-contributor-growth.sh (贡献者成长)
Research Skills (赛题四)
gitlink-research (科研总入口)
gitlink-code-insight (项目洞悉)
gitlink-compliance (合规检查)
research workflows (热点/进度/引用)"""
p = doc.add_paragraph()
run = p.add_run(dep_tree)
run.font.name = 'Consolas'
# ==================== 状态转换说明 ====================
doc.add_heading('状态转换说明', level=1)
doc.add_heading('Shortcut 命令执行状态', level=2)
doc.add_paragraph('Init → Parsing → Validation → Resolving → Loading → Executing → APICall → Done/Error')
doc.add_paragraph('任何阶段出现错误都会进入 Error 状态,输出结构化错误信息后终止。')
doc.add_heading('Skill 触发状态', level=2)
doc.add_paragraph('Idle → Triggered → Loaded → AuthCheck → DataCollection → AIAnalysis → Execution → Output')
doc.add_paragraph('Skill 加载失败时回退到 Idle 状态AI 分析失败时输出错误提示。')
doc.add_heading('Workflow 执行状态', level=2)
doc.add_paragraph('Idle → Selecting → Loading → AuthCheck → Step1 → Step2 → ... → StepN → Done')
doc.add_paragraph('任一步骤失败时根据配置决定继续或终止,最终输出执行汇总。')
# 保存
output_path = r'C:\Users\Lenovo\Desktop\soft运维\gitlink-cli\doc\系统动态模型章节.docx'
doc.save(output_path)
print(f'Word 文档已生成:{output_path}')
if __name__ == '__main__':
create_document()

View File

@ -0,0 +1,552 @@
# shortcuts/common/types.go 阅读笔记(面向 Go 小白)
***
## 第 1 行:`package common`
**字面意思**:声明这个文件属于 `common`
**运行时作用**:这是一个通用工具包,里面定义的类型和函数可以被所有其他 shortcut 模块wiki、webhook、issue 等)复用。
**小白补充**
- 包名 `common` 表示"公共的",说明这里的内容是大家都需要用的
- 其他文件通过 `import "github.com/gitlink-org/gitlink-cli/shortcuts/common"` 来使用
***
## 第 3-18 行import 导入依赖
```go
import (
"bufio" // 缓冲输入(用于读取用户确认)
"encoding/json" // JSON 处理
"fmt" // 格式化输出
"net/http" // HTTP 客户端
"net/url" // URL 处理
"os" // 操作系统交互
"strings" // 字符串操作
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil" // 命令行工具(全局变量)
"github.com/gitlink-org/gitlink-cli/internal/client" // HTTP 客户端
"github.com/gitlink-org/gitlink-cli/internal/config" // 配置管理
"github.com/gitlink-org/gitlink-cli/internal/context" // 上下文解析owner/repo
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // 错误定义
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
)
```
**小白补充**
| 包名 | 用途 |
| ------------------ | ----------------------------------------------------------- |
| `bufio` | 读取用户输入(比如确认操作时的 y/N |
| `cmd/cmdutil` | 存放全局变量(如 `cmdutil.Owner`, `cmdutil.Repo`, `cmdutil.Format` |
| `internal/config` | 加载配置文件 |
| `internal/context` | 从 git remote 解析 owner/repo |
***
## 第 20-28 行:`Shortcut` 结构体(核心!)
```go
type Shortcut struct {
Name string
Description string
Flags []Flag
DryRun bool
DryRunHint func(ctx *RuntimeContext) (string, error)
Run func(ctx *RuntimeContext) error
}
```
**字面意思**:定义一个名为 `Shortcut` 的结构体类型
**运行时作用**:这是整个 CLI 命令系统的**核心数据结构**,每个 `Shortcut` 代表一个可执行的命令(如 `wiki +list`, `issue +create`)。
**小白补充**
### ① 结构体是什么?
结构体struct是 Go 语言中用来**组织相关数据和函数**的方式。可以把它想象成一个"数据容器",里面装着各种属性。
### ② 每个字段的含义:
| 字段 | 类型 | 含义 |
| ------------- | ------------------------------------------- | -------------------------- |
| `Name` | `string` | 命令名,用户通过 `+name` 调用 |
| `Description` | `string` | 命令描述,`--help` 时显示 |
| `Flags` | `[]Flag` | 命令行参数列表(如 `--title`, `-t` |
| `DryRun` | `bool` | 是否支持预览模式(`--dry-run` |
| `DryRunHint` | `func(ctx *RuntimeContext) (string, error)` | 预览时显示的提示信息 |
| `Run` | `func(ctx *RuntimeContext) error` | **真正执行的函数**,命令的核心逻辑 |
### ③ 函数类型字段:
注意 `DryRunHint``Run` 的类型是**函数**!这在 Go 中是完全合法的,函数可以作为结构体的字段。
```go
Run func(ctx *RuntimeContext) error
```
- 这表示 `Run` 字段存储了一个**函数**
- 这个函数接收 `*RuntimeContext` 类型的参数
- 返回 `error` 类型的值(如果执行失败)
***
## 第 30-38 行:`Flag` 结构体
```go
type Flag struct {
Name string
Short string
Usage string
Required bool
Default string
Bool bool
}
```
**字面意思**:定义命令行参数的结构
**运行时作用**:描述一个命令行参数,比如 `--title "Home"``-t "Home"`
**小白补充**
| 字段 | 含义 | 例子 |
| ---------- | ------ | ------------------------- |
| `Name` | 参数名 | `"title"``--title` |
| `Short` | 短参数名 | `"t"``-t` |
| `Usage` | 帮助说明 | `"Page title"` |
| `Required` | 是否必填 | `true` → 用户必须提供 |
| `Default` | 默认值 | `"1"` → 不提供时使用的默认值 |
| `Bool` | 是否布尔类型 | `true``--dry-run` 不需要值 |
***
## 第 40-50 行:`RuntimeContext` 结构体(核心!)
```go
type RuntimeContext struct {
Client *client.Client
Owner string
Repo string
Format string
CommandName string
Args map[string]string
GatewayBaseURL string
GatewayHTTPClient *http.Client
}
```
**字面意思**:定义运行时上下文的结构
**运行时作用**:这是每个命令执行时的**全局环境**,包含了所有需要的信息。
**小白补充**
### ① 为什么需要 RuntimeContext
每个命令执行时都需要很多信息:
- 用哪个 HTTP 客户端发请求?
- 当前操作的仓库是哪个owner/repo
- 输出格式是 JSON 还是 Table
- 用户传入了哪些参数?
`RuntimeContext` 把这些信息打包在一起,方便传递和使用。
### ② 每个字段的含义:
| 字段 | 类型 | 含义 |
| ------------------- | ------------------- | --------------------------- |
| `Client` | `*client.Client` | HTTP 客户端,用来调用 GitLink API |
| `Owner` | `string` | 仓库所有者(如 `zzx-coder` |
| `Repo` | `string` | 仓库名称(如 `gitlink-cli` |
| `Format` | `string` | 输出格式(`json`/`table`/`yaml` |
| `CommandName` | `string` | 当前命令名(如 `"wiki +list"` |
| `Args` | `map[string]string` | 用户传入的所有参数key-value |
| `GatewayBaseURL` | `string` | Wiki Gateway API 的地址 |
| `GatewayHTTPClient` | `*http.Client` | 可选的自定义 HTTP 客户端(主要用于测试) |
### ③ `*client.Client` 是什么?
- `*` 表示这是一个**指针**类型
- `client.Client``internal/client` 包中定义的结构体
- 指针的好处:避免拷贝大对象,多个地方共享同一个实例
***
## 第 52-80 行:`NewRuntimeContext` 函数
```go
func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeContext, error) {
// 1. 创建 HTTP 客户端
cli, err := client.New()
if err != nil {
return nil, err
}
cli.Debug = cmdutil.Debug // 设置调试模式
// 2. 确定输出格式
format := cmdutil.Format
if format == "" {
format = "json" // 默认 JSON 格式
}
// 3. 获取 Gateway URL
gatewayBaseURL := config.DefaultGatewayBaseURL
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
gatewayBaseURL = cfg.GatewayBaseURL // 使用配置文件中的地址
}
// 4. 创建并返回 RuntimeContext
return &RuntimeContext{
Client: cli,
Owner: cmdutil.Owner,
Repo: cmdutil.Repo,
Format: format,
CommandName: commandName,
Args: args,
GatewayBaseURL: gatewayBaseURL,
GatewayHTTPClient: nil,
}, nil
}
```
**字面意思**:创建一个新的 RuntimeContext 实例
**运行时作用**:这是 `RuntimeContext` 的**构造函数**,负责初始化所有字段。
**小白补充**
### ① 构造函数模式:
Go 没有专门的构造函数语法,通常约定用 `NewXXX()` 函数来创建结构体实例。
### ② `cmdutil` 是什么?
`cmdutil``cmd/cmdutil/globals.go` 中定义的全局变量模块:
```go
// cmd/cmdutil/globals.go 中定义
var (
Owner string // 通过 --owner 参数设置
Repo string // 通过 --repo 参数设置
Format string // 通过 --format 参数设置
Debug bool // 通过 --debug 参数设置
)
```
这些是**全局变量**,在命令行参数解析时被赋值,然后在这里被读取。
### ③ 配置加载:
```go
gatewayBaseURL := config.DefaultGatewayBaseURL
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
gatewayBaseURL = cfg.GatewayBaseURL
}
```
- 先使用默认值 `config.DefaultGatewayBaseURL`
- 尝试加载配置文件,如果配置文件中有自定义的 Gateway URL就使用配置文件中的值
***
## 第 82-91 行:`ResolveOwnerRepo` 方法
```go
func (ctx *RuntimeContext) ResolveOwnerRepo() error {
owner, repo, err := context.ResolveOwnerRepo(ctx.Owner, ctx.Repo)
if err != nil {
return err
}
ctx.Owner = owner
ctx.Repo = repo
return nil
}
```
**字面意思**:解析 owner 和 repo
**运行时作用**:这是 `RuntimeContext` 的**方法**,用来确定当前操作的仓库。
**小白补充**
### ① 方法是什么?
方法是和结构体绑定的函数。在 Go 中:
```go
func (ctx *RuntimeContext) ResolveOwnerRepo() error {
// ...
}
```
- `(ctx *RuntimeContext)` 表示这个函数绑定到 `RuntimeContext` 类型
- `ctx` 是方法内部的**接收器**receiver类似其他语言的 `this``self`
- 调用方式:`ctx.ResolveOwnerRepo()`
### ② 解析逻辑:
`context.ResolveOwnerRepo(ctx.Owner, ctx.Repo)` 的作用:
1. 如果用户通过 `--owner``--repo` 参数明确指定了,直接使用
2. 如果没有指定,尝试从当前目录的 `git remote` 中自动解析
***
## 第 93-96 行:`CallAPI` 方法
```go
func (ctx *RuntimeContext) CallAPI(method, path string, body interface{}) (*output.Envelope, error) {
return ctx.Client.Do(method, path, body, nil)
}
```
**字面意思**:调用 API无查询参数
**运行时作用**:封装 HTTP 请求,是所有 API 调用的入口。
**小白补充**
- 这是一个**包装方法**,把 `ctx.Client.Do()` 包装一层
- 其他模块只需要调用 `ctx.CallAPI()` 就能发送请求,不需要关心底层的 `client.Client`
***
## 第 98-101 行:`CallAPIWithQuery` 方法
```go
func (ctx *RuntimeContext) CallAPIWithQuery(method, path string, query url.Values) (*output.Envelope, error) {
return ctx.Client.Do(method, path, nil, query)
}
```
**字面意思**:调用 API带查询参数
**运行时作用**:和 `CallAPI` 类似,但支持 URL 查询参数(`?key=value`)。
***
## 第 103-106 行:`PaginateAll` 方法
```go
func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) {
return ctx.Client.PaginateAll(path, params)
}
```
**字面意思**:获取所有分页数据
**运行时作用**:处理分页 API自动获取所有页的数据。
**小白补充**
- GitLink API 常用分页返回大量数据(如 `page=1&limit=20`
- `PaginateAll` 会自动遍历所有页,把结果合并成一个大列表
***
## 第 108-111 行:`Output` 方法
```go
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
return output.Print(env, ctx.Format)
}
```
**字面意思**:输出结果
**运行时作用**根据用户指定的格式JSON/Table/YAML输出 API 响应。
***
## 第 113-116 行:`OutputData` 方法
```go
func (ctx *RuntimeContext) OutputData(data interface{}) error {
return output.Print(output.SuccessEnvelope(data, nil), ctx.Format)
}
```
**字面意思**:输出数据(自动包装成 Envelope
**运行时作用**:如果只有数据,没有完整的 Envelope可以用这个方法自动包装。
**小白补充**
- `output.SuccessEnvelope(data, nil)` 创建一个成功的包装结构:`{"ok": true, "data": ...}`
***
## 第 118-121 行:`RepoPath` 方法
```go
func (ctx *RuntimeContext) RepoPath() string {
return fmt.Sprintf("/%s/%s", ctx.Owner, ctx.Repo)
}
```
**字面意思**:返回仓库的 API 路径前缀
**运行时作用**:生成 `/owner/repo` 格式的路径,避免重复拼接。
***
## 第 123-129 行:`Arg` 方法
```go
func (ctx *RuntimeContext) Arg(name string) string {
if v, ok := ctx.Args[name]; ok {
return v
}
return ""
}
```
**字面意思**:获取命令行参数值
**运行时作用**:从 `ctx.Args` map 中获取指定参数的值。
**小白补充**
- `ctx.Args``map[string]string` 类型
- 调用方式:`ctx.Arg("title")` → 获取 `--title` 参数的值
***
## 第 131-145 行:`RequireArg` 方法(核心!)
```go
func (ctx *RuntimeContext) RequireArg(name, example string) (string, error) {
v := ctx.Arg(name)
if v == "" {
suggestion := fmt.Sprintf("请提供 --%s 参数", name)
if example != "" {
suggestion += fmt.Sprintf(",例如:%s", example)
}
return "", clierrors.InputError(
fmt.Sprintf("required flag --%s is missing", name),
suggestion,
).WithCommand(ctx.CommandName)
}
return v, nil
}
```
**字面意思**:获取必填参数,如果缺失则返回错误
**运行时作用**:强制检查必填参数,确保用户提供了必要的输入。
**小白补充**
### ① 使用场景:
```go
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err // 用户没提供 --title直接返回错误
}
```
### ② 错误处理:
如果用户没提供参数,会返回一个 `CLIError`,包含:
- `Kind`: `KindInput`(输入错误)
- `Message`: `"required flag --title is missing"`
- `Suggestion`: `"请提供 --title 参数,例如:--title \"Home Page\""`
***
## 第 147-150 行:`IsDryRun` 方法
```go
func (ctx *RuntimeContext) IsDryRun() bool {
return ctx.Arg("dry-run") == "true"
}
```
**字面意思**:检查是否是预览模式
**运行时作用**:判断用户是否传入了 `--dry-run` 参数。
***
## 第 152-166 行:`ConfirmAction` 函数
```go
func ConfirmAction(ctx *RuntimeContext) (bool, error) {
if !ctx.IsDryRun() {
return true, nil // 不是预览模式,直接执行
}
// 预览模式,提示用户确认
fmt.Fprint(os.Stderr, "\nProceed? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "y" || answer == "yes" {
return true, nil // 用户确认,继续执行
}
fmt.Fprintln(os.Stderr, "Aborted.")
return false, nil // 用户取消,不执行
}
```
**字面意思**:确认操作(预览模式下)
**运行时作用**:在 `--dry-run` 模式下,提示用户确认是否真的要执行操作。
**小白补充**
### ① `fmt.Fprint(os.Stderr, ...)`
- `os.Stderr` 是标准错误输出流
- 把提示信息输出到 stderr 而不是 stdout这样 stdout 可以保持干净(用于管道输出)
### ② `bufio.NewReader(os.Stdin)`
- `os.Stdin` 是标准输入流(用户键盘输入)
- `bufio.NewReader` 创建一个缓冲读取器,用来读取用户输入
***
## 调用关系图
```
NewRuntimeContext(args, commandName)
↓ 创建
RuntimeContext{
Client: client.New(), // HTTP 客户端
Owner: cmdutil.Owner, // 全局变量
Repo: cmdutil.Repo, // 全局变量
Format: cmdutil.Format, // 全局变量
Args: args, // 命令行参数
}
RuntimeContext 的方法:
├── ResolveOwnerRepo() → 解析 owner/repo自动或手动
├── CallAPI() → 调用 API无参数
├── CallAPIWithQuery() → 调用 API带参数
├── PaginateAll() → 获取所有分页数据
├── Output() → 输出结果
├── OutputData() → 输出数据(自动包装)
├── RepoPath() → 返回 /owner/repo 路径
├── Arg() → 获取参数值
├── RequireArg() → 获取必填参数(缺则报错)
└── IsDryRun() → 检查预览模式
Shortcut 结构体:
├── Name: "list"
├── Flags: [{Name:"title", Short:"t", Required:true}]
└── Run: func(ctx *RuntimeContext) error {
// 命令执行逻辑
}
```

View File

@ -0,0 +1,560 @@
# internal/client/client.go 阅读笔记(面向 Go 小白)
---
## 第 1 行:`package client`
**字面意思**:声明这个文件属于 `client`
**运行时作用**:这是项目的 HTTP 客户端模块,负责所有与 GitLink API 的通信。
---
## 第 3-16 行import 导入依赖
```go
import (
"bytes" // 字节缓冲(用于构造请求体)
"encoding/json" // JSON 序列化/反序列化
"fmt" // 格式化输出
"io" // 输入输出接口
"net/http" // HTTP 协议
"net/url" // URL 处理
"strings" // 字符串操作
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块(带 Token 的 HTTP 客户端)
"github.com/gitlink-org/gitlink-cli/internal/config" // 配置管理
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // 错误定义
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
)
```
**小白补充**
| 包名 | 用途 | 在本文件中的作用 |
|------|------|-----------------|
| `bytes` | 字节操作 | 把 JSON 数据转成 HTTP 请求体 |
| `io` | 输入输出 | 读取 HTTP 响应体 |
| `net/http` | HTTP 协议 | 创建和发送 HTTP 请求 |
---
## 第 18-23 行:`Client` 结构体(核心!)
```go
type Client struct {
HTTP *http.Client
BaseURL string
Debug bool
SkipJSONSuffix bool
}
```
**字面意思**:定义 HTTP 客户端的结构
**运行时作用**:这是项目封装的 HTTP 客户端,所有 API 调用都通过它来完成。
**小白补充**
### ① 每个字段的含义:
| 字段 | 类型 | 含义 |
|------|------|------|
| `HTTP` | `*http.Client` | Go 标准库的 HTTP 客户端(核心) |
| `BaseURL` | `string` | API 基础地址(如 `https://www.gitlink.org.cn/api` |
| `Debug` | `bool` | 是否开启调试模式(打印请求/响应) |
| `SkipJSONSuffix` | `bool` | 是否跳过自动添加 `.json` 后缀Wiki Gateway 需要) |
### ② `*http.Client` 是什么?
`http.Client` 是 Go 标准库提供的 HTTP 客户端,它包含:
- 连接池管理
- 超时设置
- Cookie 管理
- 传输层配置(如 TLS、代理
我们项目在 `internal/auth/transport.go` 中对它进行了扩展,自动添加认证 Token。
---
## 第 25-35 行:`APIError` 结构体
```go
type APIError struct {
StatusCode int
Code interface{}
Message string
Kind clierrors.ErrorKind
Suggestion string
}
func (e *APIError) Error() string {
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
}
```
**字面意思**:定义 API 错误的结构
**运行时作用**:封装 API 返回的错误信息,包含错误码、消息和解决建议。
**小白补充**
### ① `Error()` 方法:
```go
func (e *APIError) Error() string {
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
}
```
- 这是实现了 Go 的 `error` 接口
- 任何实现了 `Error() string` 方法的类型都可以作为 `error` 返回
- 这样 `APIError` 就可以像普通错误一样使用:`return apiErr`
### ② 为什么需要自定义错误类型?
普通的 `error` 只能包含一条消息,而我们需要:
- `StatusCode`HTTP 状态码404/403/500 等)
- `Code`API 返回的业务错误码
- `Kind`:错误分类(认证错误/输入错误/服务器错误等)
- `Suggestion`:给用户的解决建议
---
## 第 37-46 行:`New` 函数(构造函数)
```go
func New() (*Client, error) {
// 1. 加载配置
cfg, err := config.Load()
if err != nil {
return nil, err
}
// 2. 创建并返回 Client
return &Client{
HTTP: auth.NewHTTPClient(), // 带认证的 HTTP 客户端
BaseURL: cfg.BaseURL, // 从配置获取 API 地址
}, nil
}
```
**字面意思**:创建一个新的 Client 实例
**运行时作用**:这是 Client 的构造函数,自动加载配置并创建带认证的 HTTP 客户端。
**小白补充**
### ① `auth.NewHTTPClient()` 做了什么?
这个函数在 `internal/auth/transport.go` 中,它创建了一个 HTTP 客户端,并且:
- 自动从配置文件读取 Token
- 在每个请求的 `Authorization` 头中添加 `Bearer {token}`
- 处理 Token 过期等情况
### ② 配置文件的内容:
配置文件位于 `~/.config/gitlink-cli/config.yaml`,内容大致如下:
```yaml
base_url: https://www.gitlink.org.cn/api
gateway_base_url: https://gateway.gitlink.org.cn/api
token: your-token-here
```
---
## 第 48-168 行:`Do` 方法(核心!)
这是整个文件中**最重要的函数**,负责发送 HTTP 请求并解析响应。
### ① 路径处理(第 48-67 行)
```go
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
// 自动添加 .json 后缀GitLink API 约定)
if c.shouldAppendJSONSuffix(path) {
if idx := strings.Index(path, "?"); idx != -1 {
// 路径已经包含查询参数,在 ? 前面加 .json
basePath := path[:idx]
queryStr := path[idx:]
path = basePath + ".json" + queryStr
} else {
// 路径没有查询参数,直接加 .json
path += ".json"
}
}
// 构建完整 URL
fullURL := c.BaseURL + path
if query != nil && len(query) > 0 {
sep := "?"
if strings.Contains(fullURL, "?") {
sep = "&" // URL 已经有 ?,用 & 连接
}
fullURL += sep + query.Encode()
}
// ...
}
```
**字面意思**:处理请求路径,构建完整 URL
**运行时作用**GitLink API 约定所有路径都需要 `.json` 后缀,这里自动添加。
**小白补充**
- `c.BaseURL``https://www.gitlink.org.cn/api`
- `path``/users/me`
- 最终 `fullURL` 变成 `https://www.gitlink.org.cn/api/users/me.json`
### ② 请求体处理(第 69-77 行)
```go
// 处理请求体
var bodyReader io.Reader
if body != nil {
// 把 body 序列化成 JSON
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
// 转成 io.ReaderHTTP 请求需要的格式)
bodyReader = bytes.NewReader(data)
}
```
**字面意思**:把请求体转成 HTTP 可以发送的格式
**运行时作用**:如果有请求体(如 POST/PUT 请求),把 Go 的 map 转成 JSON 字符串,再转成字节流。
**小白补充**
- `json.Marshal(body)`:把 Go 结构体/map 转成 JSON 字节数组
- `bytes.NewReader(data)`:把字节数组包装成 `io.Reader`HTTP 请求体需要这个接口)
### ③ 创建 HTTP 请求(第 79-87 行)
```go
// 创建 HTTP 请求
req, err := http.NewRequest(method, fullURL, bodyReader)
if err != nil {
return nil, err
}
// 调试模式:打印请求信息
if c.Debug {
fmt.Printf("→ %s %s\n", method, fullURL)
}
```
**字面意思**:创建一个 HTTP 请求对象
**运行时作用**`http.NewRequest` 创建请求对象包含方法、URL 和请求体。
### ④ 发送请求(第 89-92 行)
```go
// 发送请求
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close() // 确保响应体被关闭
```
**字面意思**:发送 HTTP 请求并获取响应
**运行时作用**`c.HTTP.Do(req)` 发送请求,返回响应对象。
**小白补充**
- `defer resp.Body.Close()`**非常重要!** 确保响应体被关闭,避免资源泄漏
- `defer` 是 Go 的关键字,它会在函数返回前执行后面的语句
- 如果不关闭 `resp.Body`HTTP 连接池会被占满,导致后续请求失败
### ⑤ 读取响应体(第 94-101 行)
```go
// 读取响应体
respData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// 调试模式:打印响应信息
if c.Debug {
fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
}
```
**字面意思**:把响应体读取成字节数组
**运行时作用**`io.ReadAll(resp.Body)` 读取整个响应体内容。
**小白补充**
- `resp.StatusCode` 是 HTTP 状态码200=成功404=未找到500=服务器错误)
### ⑥ HTTP 状态码检查(第 103-113 行)
```go
// 检查 HTTP 状态码
if resp.StatusCode >= 400 {
info := lookupStatusInfo(resp.StatusCode)
return nil, &APIError{
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
Kind: info.kind,
Suggestion: info.suggestion,
}
}
```
**字面意思**:如果状态码 >= 400返回错误
**运行时作用**HTTP 4xx/5xx 都是错误,这里封装成 `APIError` 返回。
**小白补充**
- `lookupStatusInfo(resp.StatusCode)` 根据状态码查找对应的错误分类和建议
### ⑦ JSON 解析(第 115-120 行)
```go
// 解析 JSON 响应
var raw map[string]interface{}
if err := json.Unmarshal(respData, &raw); err != nil {
// 不是 JSON直接返回原始内容
return output.SuccessEnvelope(string(respData), nil), nil
}
```
**字面意思**:把响应体解析成 Go 的 map
**运行时作用**`json.Unmarshal` 把 JSON 字符串转成 Go 的 `map[string]interface{}`
**小白补充**
- `json.Unmarshal` 的第二个参数需要传递**指针**`&raw`
- `interface{}` 是 Go 的"万能类型",可以存储任何值
- 如果响应不是 JSON比如返回的是 HTML 错误页面),就直接返回字符串
### ⑧ GitLink 业务错误检查(第 122-142 行)
```go
// 检查 GitLink 业务错误(响应体中的 status 字段)
if status, ok := raw["status"]; ok {
var statusCode float64
switch v := status.(type) {
case float64:
statusCode = v
case int:
statusCode = float64(v)
}
// status 不为 0、1、200 都是错误
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
msg, _ := raw["message"].(string)
info := lookupStatusInfo(int(statusCode))
return output.ErrorEnvelope(int(statusCode), msg, info.suggestion), &APIError{
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
Kind: info.kind,
Suggestion: info.suggestion,
}
}
}
```
**字面意思**:检查 GitLink API 返回的业务错误码
**运行时作用**GitLink API 有时 HTTP 状态码是 200但响应体中的 `status` 字段表示业务失败(如参数校验失败)。
**小白补充**
GitLink API 的响应格式:
```json
{
"status": 0, // 0=失败, 1=成功, 200=成功
"message": "...", // 错误信息
"data": {...} // 数据
}
```
### ⑨ 自动解析 JSON 字符串数据(第 144-150 行)
```go
// 自动解析 JSON 字符串数据GitLink API 的一个特性)
if dataStr, ok := raw["data"].(string); ok {
var parsedData interface{}
if err := json.Unmarshal([]byte(dataStr), &parsedData); err == nil {
raw["data"] = json.RawMessage(dataStr)
}
}
```
**字面意思**:处理 data 字段是 JSON 字符串的情况
**运行时作用**GitLink 某些 API 返回的 `data` 字段是字符串形式的 JSON需要再次解析。
**小白补充**
比如响应是这样的:
```json
{
"status": 1,
"data": "{\"name\": \"test\"}" // data 是字符串!
}
```
这里需要把 `"{\"name\": \"test\"}"` 再解析成 `{"name": "test"}`
### ⑩ 构建分页元数据(第 152-166 行)
```go
// 构建分页元数据
var meta *output.Meta
if tc, ok := raw["total_count"]; ok {
meta = &output.Meta{}
if v, ok := tc.(float64); ok {
meta.TotalCount = int(v)
}
if v, ok := raw["page"].(float64); ok {
meta.Page = int(v)
}
if v, ok := raw["limit"].(float64); ok {
meta.Limit = int(v)
}
}
// 返回成功的 Envelope
return output.SuccessEnvelope(raw, meta), nil
```
**字面意思**:从响应中提取分页信息
**运行时作用**:如果 API 返回了分页信息total_count/page/limit提取出来作为 `Meta`
---
## 第 170-184 行:便捷方法
```go
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
return c.Do("GET", path, nil, query)
}
func (c *Client) Post(path string, body interface{}) (*output.Envelope, error) {
return c.Do("POST", path, body, nil)
}
func (c *Client) Put(path string, body interface{}) (*output.Envelope, error) {
return c.Do("PUT", path, body, nil)
}
func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) {
return c.Do("DELETE", path, nil, query)
}
```
**字面意思**:封装常见的 HTTP 方法
**运行时作用**:提供更简洁的调用方式,比如 `client.Get("/users/me", nil)` 而不是 `client.Do("GET", "/users/me", nil, nil)`
---
## 第 186-225 行:错误信息映射
```go
type statusInfo struct {
kind clierrors.ErrorKind
message string
suggestion string
}
var statusMessages = map[int]statusInfo{
-2: {clierrors.KindAuth, "未登录或 Token 已过期",
"运行 gitlink-cli auth login 重新登录"},
-1: {clierrors.KindInput, "参数校验失败",
"检查必填参数是否缺失"},
401: {clierrors.KindAuth, "认证失败",
"运行 gitlink-cli auth login 登录"},
403: {clierrors.KindForbidden, "权限不足",
"请确认账号有此仓库的访问权限"},
404: {clierrors.KindNotFound, "资源不存在",
"检查 owner/repo/id 是否正确"},
// ... 更多状态码
}
func lookupStatusInfo(code int) statusInfo {
if info, ok := statusMessages[code]; ok {
return info
}
return statusInfo{
kind: clierrors.KindUnknown,
message: fmt.Sprintf("API 返回错误码 %d", code),
}
}
```
**字面意思**:根据错误码查找对应的错误信息
**运行时作用**:把枯燥的错误码转换成人类可读的错误信息和解决建议。
---
## 第 227-246 行:`shouldAppendJSONSuffix` 方法
```go
func (c *Client) shouldAppendJSONSuffix(path string) bool {
// 1. 如果设置了 SkipJSONSuffix不添加
if c.SkipJSONSuffix {
return false
}
// 2. 如果已经有 .json 后缀,不添加
if strings.HasSuffix(path, ".json") {
return false
}
// 3. 如果是 raw 内容路径,不添加
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range parts {
if part == "raw" && i >= 2 && i+2 < len(parts) {
return false
}
}
// 4. 其他情况,添加 .json 后缀
return true
}
```
**字面意思**:判断是否应该添加 `.json` 后缀
**运行时作用**:控制是否自动添加 `.json` 后缀。
**小白补充**
为什么需要这个方法?
- Wiki Gateway API 不需要 `.json` 后缀(设置 `SkipJSONSuffix: true`
- 某些路径(如 `/owner/repo/raw/...`)返回的是原始文件内容,不是 JSON
---
## 完整调用流程
```
ctx.CallAPI("GET", "/users/me", nil)
Client.Do("GET", "/users/me", nil, nil)
1. 路径处理:/users/me → /users/me.json
2. 构建 URLhttps://www.gitlink.org.cn/api/users/me.json
3. 创建 HTTP 请求http.NewRequest("GET", url, nil)
4. 发送请求c.HTTP.Do(req)
↓ (auth.NewHTTPClient() 自动添加 Authorization 头)
5. 读取响应体io.ReadAll(resp.Body)
6. 检查状态码:如果 >= 400返回 APIError
7. 解析 JSONjson.Unmarshal → map[string]interface{}
8. 检查业务错误:判断 status 字段
9. 返回 Envelopeoutput.SuccessEnvelope(raw, meta)
```

View File

@ -0,0 +1,706 @@
# shortcuts/wiki/wiki.go 阅读笔记(面向 Go 小白)
---
## 第 1 行:`package wiki`
**字面意思**:声明这个文件属于 `wiki`
**运行时作用**Go 语言规定每个文件必须属于一个包。包名决定了其他文件如何引用这里的函数/变量。
**小白补充**
- 包就像"工具箱"`wiki` 包就是专门处理 Wiki 功能的工具箱
- 同一个包下的文件可以直接互相调用函数,不需要导入
- 包名一般和目录名一致(这里文件在 `shortcuts/wiki/` 目录下,所以包名是 `wiki`
---
## 第 3-22 行import 导入依赖
```go
import (
"encoding/base64" // Base64 编解码
"encoding/json" // JSON 序列化/反序列化
"errors" // 错误处理
"fmt" // 格式化输出(类似 Python 的 print
"net/http" // HTTP 客户端
"net/url" // URL 编码/解析
"os" // 操作系统交互(读文件等)
"regexp" // 正则表达式
"strconv" // 字符串转数字
"strings" // 字符串操作
"sync" // 并发同步(锁、线程安全)
"time" // 时间处理
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块
"github.com/gitlink-org/gitlink-cli/internal/client" // HTTP 客户端封装
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // CLI 错误定义
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
"github.com/gitlink-org/gitlink-cli/shortcuts/common" // 通用工具
)
```
**字面意思**:导入需要用到的外部库/包
**运行时作用**:告诉 Go 编译器,我需要使用这些包提供的功能。编译时会把这些包的代码链接进来。
**小白补充**
| 包名 | 一句话解释 | 在本文件中的用途 |
|------|-----------|-----------------|
| `encoding/base64` | 把文字转成 Base64 编码 | Wiki 内容需要用 Base64 编码后发送 |
| `encoding/json` | 处理 JSON 数据 | 解析 API 返回的 JSON |
| `errors` | Go 标准错误处理工具 | 判断错误类型 |
| `fmt` | 格式化打印 | 输出错误信息、拼接字符串 |
| `net/http` | HTTP 协议客户端 | 发送 HTTP 请求 |
| `net/url` | URL 处理 | 构建查询参数、URL 编码 |
| `os` | 操作系统接口 | 读取本地文件内容 |
| `regexp` | 正则表达式 | 匹配 Markdown 链接和图片 |
| `strconv` | 字符串转换 | 把字符串转成数字 |
| `strings` | 字符串操作 | 切割、查找、替换字符串 |
| `sync` | 并发同步 | 提供线程安全的缓存(`sync.Map` |
| `time` | 时间处理 | 设置 HTTP 请求超时 |
| `internal/auth` | 项目内部认证模块 | 获取带 Token 的 HTTP 客户端 |
| `internal/client` | 项目内部客户端模块 | 封装 API 调用逻辑 |
| `internal/errors` | 项目内部错误定义 | 自定义错误类型 |
| `internal/output` | 项目内部输出模块 | 格式化输出结果JSON/Table |
| `shortcuts/common` | 通用工具模块 | 提供 RuntimeContext 等基础结构 |
---
## 第 24 行:`var projectIDCache sync.Map`
**字面意思**:声明一个全局变量 `projectIDCache`,类型是 `sync.Map`
**运行时作用**:这是一个**线程安全的缓存**,用来存储 `owner/repo -> projectID` 的映射关系,避免重复调用 API 获取项目 ID。
**小白补充**
- `var` 是 Go 声明变量的关键字
- `sync.Map` 是 Go 标准库提供的**并发安全的 map**(普通 map 在多线程下读写会崩溃)
- `projectIDCache` 是全局变量(在函数外面声明),整个包内都可以访问
- 为什么需要缓存?因为每次操作 Wiki 都需要 projectID但获取 projectID 需要调用一次 API缓存可以节省网络请求
---
## 第 26-28 行:`wikiPath` 函数
```go
func wikiPath(endpoint string) string {
return "/wiki/open/" + endpoint
}
```
**字面意思**:定义一个函数 `wikiPath`,接收一个字符串参数 `endpoint`返回一个字符串Wiki 功能调用的是 Gateway API (网关 API所有 Wiki 相关的接口都有一个固定的前缀 /wiki/open/
**运行时作用**:拼接 Wiki API 的路径前缀。比如传入 `"wikiPages"`,返回 `"/wiki/open/wikiPages"`
**小白补充**
- `func` 是 Go 定义函数的关键字
- `wikiPath(endpoint string)`:函数名是 `wikiPath`,参数名是 `endpoint`,参数类型是 `string`
- `string`(返回类型):表示函数执行完返回一个字符串
- 这是一个**工具函数**,用来避免重复写相同的路径前缀
---
## 第 30-49 行:`getGatewayClient` 函数
```go
func getGatewayClient(ctx *common.RuntimeContext) *client.Client {
baseURL := ctx.GatewayBaseURL
if baseURL == "" {
baseURL = "https://gateway.gitlink.org.cn/api"
}
httpClient := ctx.GatewayHTTPClient
if httpClient == nil {
httpClient = auth.NewHTTPClient()
}
return &client.Client{
HTTP: httpClient,
BaseURL: baseURL,
SkipJSONSuffix: true,
Debug: ctx.Client.Debug,
}
}
```
**字面意思**:定义一个函数 `getGatewayClient`,接收 `*common.RuntimeContext` 类型的指针参数 `ctx`,返回 `*client.Client` 类型的指针
**运行时作用**:创建一个专门访问 **Wiki Gateway API** 的客户端实例。
**小白补充**
### ① 为什么需要单独的 Gateway 客户端?
GitLink 的 Wiki API 和主 API 不在同一个域名:
- 主 API`https://www.gitlink.org.cn/api`(用于获取项目信息等)
- Wiki Gateway API`https://gateway.gitlink.org.cn/api`(专门处理 Wiki 操作)
### ② 代码逐句解析:
```go
baseURL := ctx.GatewayBaseURL // 从上下文获取 Gateway 地址
if baseURL == "" { // 如果没配置,用默认地址
baseURL = "https://gateway.gitlink.org.cn/api"
}
```
```go
httpClient := ctx.GatewayHTTPClient // 获取自定义的 HTTP 客户端
if httpClient == nil { // 如果没有自定义的,创建一个带认证的默认客户端
httpClient = auth.NewHTTPClient()
}
```
```go
return &client.Client{...} // 创建并返回 Client 结构体实例
```
### ③ 结构体初始化语法:
```go
&client.Client{
HTTP: httpClient, // 使用上面创建的 HTTP 客户端
BaseURL: baseURL, // Gateway API 地址
SkipJSONSuffix: true, // 关键Gateway API 不需要 .json 后缀
Debug: ctx.Client.Debug, // 继承调试模式
}
```
- `&` 符号表示取地址返回指针Go 中结构体传参常用指针,避免拷贝)
- `client.Client` 是一个**结构体类型**,里面定义了客户端的各种配置
---
## 第 51-58 行:`callWikiAPI` 函数
```go
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, body, nil)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
```
**字面意思**:定义函数 `callWikiAPI`接收上下文、HTTP 方法、路径、请求体,返回 `(*output.Envelope, error)`
**运行时作用**:封装对 Wiki Gateway API 的调用流程。
**小白补充**
### ① 参数说明:
- `ctx *common.RuntimeContext`运行时上下文包含认证信息、owner/repo 等
- `method string`HTTP 方法GET/POST/PUT/DELETE
- `path string`API 路径
- `body interface{}`请求体可以是任何类型Go 中 `interface{}` 表示万能类型)
### ② 返回值说明:
- `(*output.Envelope, error)`Go 可以返回多个值!第一个是 API 返回的包装数据,第二个是错误
### ③ 执行流程:
1. `gc := getGatewayClient(ctx)` → 获取 Gateway 客户端
2. `gc.Do(...)` → 调用客户端的 Do 方法发送 HTTP 请求
3. `unwrapGatewayResponse(env)` → 解析并处理响应(后面会讲)
---
## 第 60-67 行:`callWikiAPIWithQuery` 函数
```go
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, nil, query)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
```
**字面意思**:和 `callWikiAPI` 类似,但专门用于带查询参数的请求
**运行时作用**:当需要发送带 `?key=value` 查询参数的 GET 请求时使用。
**小白补充**
- `url.Values` 是 Go 标准库类型,本质是 `map[string][]string`,用来存储 URL 查询参数
- 比如 `?owner=zzx&repo=test` 会被表示为 `{"owner": ["zzx"], "repo": ["test"]}`
---
## 第 69-99 行:`unwrapGatewayResponse` 函数(核心!)
```go
func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) {
// 1. 尝试把响应数据转成 map
resp, ok := env.Data.(map[string]interface{})
if !ok {
return env, nil // 不是 map 格式,直接返回
}
// 2. 检查响应中的 code 字段
if code, ok := resp["code"]; ok {
switch v := code.(type) {
case float64:
// HTTP 2xx 都算成功(包括 200/201/204 等)
if v < 200 || v >= 300 {
// 错误情况:提取错误信息
msg, _ := resp["msg"].(string)
kind := clierrors.KindServer
if int(v) == 404 {
kind = clierrors.KindNotFound
} else if int(v) == 401 || int(v) == 403 {
kind = clierrors.KindForbidden
}
// 返回自定义错误
return nil, clierrors.New(kind, msg,
"检查 owner/repo 是否正确,或确认仓库已在 GitLink 网页端开启 Wiki 功能")
}
}
}
// 3. 如果响应有 data 字段,提取出来作为新的响应数据
if innerData, ok := resp["data"]; ok {
return output.SuccessEnvelope(innerData, env.Meta), nil
}
return env, nil
}
```
**字面意思**"拆开" Gateway API 的响应,提取真正的数据
**运行时作用**:处理 Gateway API 返回的特殊格式,统一成标准的 `Envelope` 结构。
**小白补充**
### ① Gateway API 的响应格式:
Gateway API 返回的 JSON 格式是这样的:
```json
{
"code": 200,
"msg": "success",
"data": { "真正的数据在这里" }
}
```
而我们需要的是直接拿到 `data` 里面的内容。
### ② 类型断言Go 的特色语法):
```go
resp, ok := env.Data.(map[string]interface{})
```
- 这是**类型断言**,把 `env.Data`(类型是 `interface{}`)转换成 `map[string]interface{}`
- `ok` 是一个布尔值,表示转换是否成功
- 如果转换失败(比如 `env.Data` 是个字符串而不是 map`ok` 就是 `false`
### ③ switch type 语法:
```go
switch v := code.(type) {
case float64:
// code 是浮点数类型时执行这里
}
```
- 这是 Go 的**类型 switch**,用来判断一个 `interface{}` 变量的具体类型
- JSON 解析数字时,默认会转成 `float64` 类型
### ④ 为什么要判断 2xx 状态码?
```go
if v < 200 || v >= 300 {
// 错误处理
}
```
- HTTP 状态码中200-299 表示成功
- 之前的代码只判断了 200/201导致 DELETE 返回 204No Content时被误判为失败
- 现在扩展到所有 2xx 都算成功
---
## 第 101-135 行:`resolveProjectID` 函数(核心!)
```go
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
// 1. 生成缓存 key
key := ctx.Owner + "/" + ctx.Repo
// 2. 先查缓存
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil // 缓存命中,直接返回
}
// 3. 缓存没命中,调用主 API 获取项目详情
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return "", fmt.Errorf("failed to fetch project details (needed for projectId): %w", err)
}
// 4. 从响应中提取 project_id
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("unexpected response from project detail API")
}
pid, ok := data["project_id"]
if !ok {
return "", fmt.Errorf("project_id not found in project detail response")
}
// 5. 处理 project_id 的不同类型(可能是 float64 或 int
var pidStr string
switch v := pid.(type) {
case float64:
pidStr = fmt.Sprintf("%.0f", v)
case int:
pidStr = fmt.Sprintf("%d", v)
default:
pidStr = fmt.Sprintf("%v", v)
}
// 6. 存入缓存
projectIDCache.Store(key, pidStr)
return pidStr, nil
}
```
**字面意思**:根据 owner/repo 解析出项目的数字 ID
**运行时作用**Wiki API 需要 `projectId`(数字),但用户只知道 `owner/repo`(字符串),这个函数就是做转换的。
**小白补充**
### ① 为什么需要 projectID
GitLink 的 Wiki Gateway API 设计要求传入数字形式的 `projectId`,而不是字符串形式的 `owner/repo`。所以必须先调用主 API 获取项目详情,从中提取 `project_id`
### ② 缓存机制:
```go
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil
}
```
- `projectIDCache.Load(key)` 从缓存中查找
- 如果找到(`ok == true`),直接返回缓存的值,不需要再调用 API
- 这是**性能优化**,避免重复请求
### ③ fmt.Sprintf 的用法:
```go
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
```
- 类似 Python 的 `"%s/%s/detail" % (owner, repo)`
- `%s` 是占位符,会被后面的参数替换
### ④ ctx.CallAPI 是什么?
```go
env, err := ctx.CallAPI("GET", path, nil)
```
- `ctx``*common.RuntimeContext` 类型
- `CallAPI``RuntimeContext` 结构体的**方法**(后面会详细讲)
- 它内部调用 `ctx.Client.Do()` 发送 HTTP 请求
---
## 第 137-140 行:`parseProjectIDInt` 函数
```go
func parseProjectIDInt(pid string) int {
n, _ := strconv.Atoi(pid)
return n
}
```
**字面意思**:把字符串形式的 projectID 转成整数
**运行时作用**Wiki API 的某些接口要求 `projectId` 是整数类型,所以需要转换。
**小白补充**
- `strconv.Atoi` 是 string convert to int 的缩写
- `_` 是 Go 语言的"忽略符",表示忽略返回的错误(这里假设 pid 一定是合法数字)
---
## 第 142-154 行:`resolveUpdateContent` 函数
```go
func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (string, error) {
if text != "" {
return text, nil // 直接使用提供的文本
}
if filePath != "" {
data, err := os.ReadFile(filePath) // 从文件读取
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("no content provided")
}
```
**字面意思**:解析更新 Wiki 时的内容来源
**运行时作用**:支持两种方式提供内容:直接文本(`--cover`)或文件路径(`--file`)。
---
## 第 156-179 行:`fetchPageContent` 函数(带自动重试)
```go
func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (content string, actualPageName string, err error) {
// 第一次尝试
c, actual, err := fetchPageContentOnce(ctx, projectID, pageName)
if err == nil {
return c, actual, nil // 成功了,直接返回
}
// 第一次失败,且 pageName 不带 ".-" 后缀,自动重试
if !strings.HasSuffix(pageName, ".-") {
c2, actual2, err2 := fetchPageContentOnce(ctx, projectID, pageName+".-")
if err2 == nil {
return c2, actual2, nil // 重试成功
}
}
// 两次都失败
return "", "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err)
}
```
**字面意思**:获取 Wiki 页面的明文内容,带自动重试机制
**运行时作用**:解决 GitLink 后端的一个命名问题。
**小白补充**
### ① GitLink 后端的命名 bug
GitLink 创建 Wiki 页面时,会自动给内部存储的 `sub_url` 追加 `".-"` 后缀。但 `wiki +list` 返回的 `title` 不带后缀。
比如:
- 用户创建页面 "Home"
- 后端实际存储的 key 是 "Home.-"
- 但 list API 返回的 title 是 "Home"
所以用 "Home" 去查询会 404必须用 "Home.-" 才能查到。
### ② 自动重试逻辑:
1. 先用原始 `pageName` 尝试查询
2. 如果失败,且 `pageName` 不带 `".-"` 后缀
3. 自动用 `pageName+".-"` 重试一次
---
## 第 181-205 行:`fetchPageContentOnce` 函数(单次查询)
```go
func fetchPageContentOnce(ctx *common.RuntimeContext, projectID, pageName string) (string, string, error) {
// 构建查询参数
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
// 调用 API
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if err != nil {
return "", "", err
}
// 解析响应
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("unexpected response from getWiki")
}
// 提取 base64 编码的内容
b64, _ := data["content_base64"].(string)
if b64 == "" {
return "", pageName, nil // 内容为空,返回空字符串
}
// Base64 解码
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", "", fmt.Errorf("failed to decode page content: %w", err)
}
return string(decoded), pageName, nil
}
```
**字面意思**:单次尝试获取 Wiki 页面内容
**运行时作用**:发送 GET 请求到 `/wiki/open/getWiki`,获取页面数据并解码。
**小白补充**
### ① URL 查询参数构建:
```go
q := url.Values{}
q.Set("owner", ctx.Owner)
```
- `url.Values` 是 map 类型,用来存储查询参数
- 最终会变成 `?owner=zzx&repo=test&projectId=12345&pageName=Home`
### ② Base64 编解码:
```go
b64, _ := data["content_base64"].(string) // 获取 base64 编码的内容
decoded, err := base64.StdEncoding.DecodeString(b64) // 解码
return string(decoded), pageName, nil // 转成字符串返回
```
- Wiki API 返回的内容是 Base64 编码的(可能是为了支持二进制文件)
- 需要解码才能得到人类可读的文本
---
## 第 207-216 行:`fetchWikiPage` 函数
```go
func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
}
```
**字面意思**:获取 Wiki 页面的完整响应(不解码)
**运行时作用**:和 `fetchPageContent` 类似,但返回完整的 `Envelope` 而不是解码后的文本。
---
## 第 218-230 行:`resolveContent` 函数
```go
func resolveContent(ctx *common.RuntimeContext) (string, error) {
if content := ctx.Arg("content"); content != "" {
return content, nil
}
if filePath := ctx.Arg("file"); filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("--content or --file is required to provide wiki page content")
}
```
**字面意思**:解析创建 Wiki 时的内容来源
**运行时作用**:支持 `--content` 直接传内容,或 `--file` 从文件读取。
**小白补充**
- `ctx.Arg("content")` 是从命令行参数中获取 `--content` 的值
- 如果两个参数都没提供,返回错误
---
## 第 232-249 行:`cleanWikiList` 函数
```go
func cleanWikiList(env *output.Envelope) {
// 把 data 转成 slice
items, ok := env.Data.([]interface{})
if !ok {
return
}
// 遍历每个 wiki 页面
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
// 删除不需要的字段
delete(m, "wiki_clone_link")
// URL 解码 sub_url
if raw, ok := m["sub_url"].(string); ok {
if decoded, err := url.QueryUnescape(raw); err == nil {
m["sub_url"] = decoded
}
}
}
}
```
**字面意思**:清理 Wiki 列表数据
**运行时作用**:对 `wiki +list` 返回的数据进行清洗,去掉无用字段,解码 URL。
---
## 第 251-299 行:`outputWithDecodedContent` 函数
```go
func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) error {
data := env.Data
// 处理 JSON 字符串形式的 data
if raw, ok := data.(json.RawMessage); ok {
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err == nil {
data = m
env.Data = m
}
}
// 转成 map
m, ok := data.(map[string]interface{})
if !ok {
return ctx.Output(env)
}
// content_base64 → content重命名并解码
if b64, ok := m["content_base64"].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m["content"] = string(decoded)
delete(m, "content_base64") // 删除原字段
}
}
// sidebar / footer 原地解码
for _, field := range []string{"sidebar", "footer"} {
if b64, ok := m[field].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m[field] = string(decoded)
}
}
}
return ctx.Output(env)
}
```
**字面意思**:解码 Wiki 响应中的所有 Base64 字段,用明文替换
**运行时作用**:让返回的 Wiki 内容更易读,同时节省 tokenBase64 编码会增加约 33% 的体积)。
---
## 第 301-526 行Lint 相关

View File

@ -0,0 +1,510 @@
# 逐行讲解 shortcuts/webhook/webhook.go面向 Go 小白)
## 文件概述
这个文件实现了 **Webhook 管理**功能,可以对 GitLink 仓库的 Webhook 进行增删改查操作。
---
## 一、包声明和导入
```go
package webhook
import (
"fmt"
"net/url"
"strings"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
```
| 导入库 | 作用 |
|-------|------|
| `fmt` | 格式化输出,用于拼接字符串和格式化错误信息 |
| `net/url` | URL 相关操作,用于构建查询参数 |
| `strings` | 字符串处理,用于分割、修剪等操作 |
| `clierrors` | 自定义 CLI 错误类型,用于返回友好的错误提示 |
| `output` | 输出格式化,用于返回统一格式的结果 |
| `common` | 公共工具包,包含 Shortcut、RuntimeContext 等核心类型 |
---
## 二、支持的 Webhook 事件类型
```go
var supportedEvents = []string{
"push",
"pull_request",
"issue",
"issue_assign",
"issue_comment",
"pull_request_assign",
"pull_request_comment",
"merge_request",
"repository",
"branch",
"tag",
}
```
这是一个**全局变量**,定义了 GitLink 支持的所有 Webhook 事件类型:
- `push`:代码推送事件
- `pull_request`PR 事件
- `issue`Issue 事件
- `issue_assign`Issue 分配事件
- `issue_comment`Issue 评论事件
- `pull_request_assign`PR 分配事件
- `pull_request_comment`PR 评论事件
- `merge_request`:合并请求事件
- `repository`:仓库事件
- `branch`:分支创建/删除事件
- `tag`:标签创建/删除事件
---
## 三、事件验证函数
```go
func isEventSupported(event string) bool {
for _, supported := range supportedEvents {
if event == supported {
return true
}
}
return false
}
```
**功能**:检查某个事件类型是否被支持
**工作原理**:遍历 `supportedEvents` 数组,逐一比对,如果找到匹配项就返回 `true`,否则返回 `false`
---
## 四、解析事件字符串
```go
func parseEvents(eventsStr string) []string {
if eventsStr == "" {
return []string{"push"} // 默认事件
}
events := strings.Split(eventsStr, ",")
var validEvents []string
for _, event := range events {
event = strings.TrimSpace(event)
if isEventSupported(event) {
validEvents = append(validEvents, event)
}
}
return validEvents
}
```
**功能**:把用户输入的逗号分隔的事件字符串(如 `"push,pull_request"`)解析成事件数组
**逐行解读**
1. 如果输入为空,返回默认值 `["push"]`
2. 使用 `strings.Split` 按逗号分割字符串
3. 遍历每个事件,用 `strings.TrimSpace` 去掉前后空格
4. 用 `isEventSupported` 验证有效性,有效才加入结果数组
5. 返回过滤后的有效事件数组
---
## 五、API 路径构建函数
```go
func webhookRepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
```
**功能**:构建 Webhook API 的基础路径
**参数**`ctx` 是运行时上下文,包含 `Owner`(仓库所有者)和 `Repo`(仓库名)
**返回值**:类似 `/v1/owner/repo` 的字符串
**注意**:注释说明了 BaseURL 已经包含 `/api` 前缀,所以这里不需要再加
---
## 六、Shortcuts 主函数
```go
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// list 命令
// create 命令
// update 命令
// delete 命令
// test 命令
// info 命令
// events 命令
}
}
```
**功能**:返回所有 Webhook 相关的 CLI 命令列表
这个函数是整个文件的核心它定义了7个命令
1. `list` - 列出所有 Webhook
2. `create` - 创建新 Webhook
3. `update` - 更新现有 Webhook
4. `delete` - 删除 Webhook
5. `test` - 测试 Webhook 发送
6. `info` - 查看 Webhook 详情
7. `events` - 列出所有支持的事件类型
---
## 七、命令详解
### 7.1 list 命令
```go
{
Name: "list",
Description: "List all webhooks for a repository",
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", webhookRepoPath(ctx)+"/webhooks", q)
if err != nil {
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
}
return ctx.Output(env)
},
}
```
**Flags 参数说明**
- `--page/-p`页码默认第1页
- `--limit/-l`每页条数默认20条
**执行流程**
1. 调用 `ctx.ResolveOwnerRepo()` 解析仓库信息
2. 创建 URL 查询参数 `url.Values{}`
3. 设置 `page``limit` 参数
4. 调用 `CallAPIWithQuery` 发送 GET 请求到 `/v1/owner/repo/webhooks`
5. 返回结果给用户
---
### 7.2 create 命令
```go
{
Name: "create",
Description: "Create a new webhook",
Flags: []common.Flag{
{Name: "url", Short: "u", Usage: "Webhook callback URL", Required: true},
{Name: "events", Short: "e", Usage: "Trigger events", Default: "push"},
{Name: "active", Usage: "Webhook active status", Default: "true"},
{Name: "secret", Usage: "Webhook secret for HMAC verification"},
{Name: "description", Short: "d", Usage: "Webhook description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
webhookURL, err := ctx.RequireArg("url", "--url https://example.com/hook")
if err != nil {
return err
}
events := parseEvents(ctx.Arg("events"))
if len(events) == 0 {
return clierrors.InputError(...)
}
payload := map[string]interface{}{
"url": webhookURL,
"http_method": "POST",
"active": true,
"content_type": "json",
}
// 添加可选参数
if len(events) > 0 {
payload["events"] = events
}
if secret := ctx.Arg("secret"); secret != "" {
payload["secret"] = secret
}
if description := ctx.Arg("description"); description != "" {
payload["description"] = description
}
env, err := ctx.CallAPI("POST", webhookRepoPath(ctx)+"/webhooks", payload)
if err != nil {
return fmt.Errorf("创建 Webhook 失败: %w", err)
}
return ctx.Output(env)
},
}
```
**执行流程**
1. 解析仓库信息
2. 必须获取 `--url` 参数(用 `RequireArg`,如果没提供会报错)
3. 解析事件类型
4. 创建 payload 映射,包含必填字段
5. 添加可选的 secret 和 description
6. 发送 POST 请求创建 Webhook
---
### 7.3 update 命令
```go
{
Name: "update",
Description: "Update an existing webhook",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
{Name: "url", Short: "u", Usage: "Webhook callback URL"},
{Name: "events", Short: "e", Usage: "Trigger events"},
{Name: "active", Usage: "Webhook active status"},
{Name: "content_type", Usage: "Content type"},
{Name: "secret", Usage: "Webhook secret"},
{Name: "description", Short: "d", Usage: "Webhook description"},
},
Run: func(ctx *common.RuntimeContext) error {
// ... 解析仓库和 ID
webhookURL := ctx.Arg("url")
if webhookURL == "" {
// 如果用户没提供 URL先获取当前 URL
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
// ... 解析响应获取当前 URL
webhookURL = currentURL
}
payload["url"] = webhookURL
// ... 发送 PUT 请求
},
}
```
**亮点**:如果用户没有提供新的 URL会自动调用 GET API 获取当前 URL这样就不需要用户重复输入
---
### 7.4 delete 命令
```go
{
Name: "delete",
Description: "Delete a webhook",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
// ... 解析仓库和 ID
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
if delErr != nil {
// 验证是否真的删除成功
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
if viewErr != nil {
// GET 也失败,说明 Webhook 确实不存在了,删除成功
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "Webhook deleted successfully",
}, nil))
}
return fmt.Errorf("删除 Webhook 失败: %w", delErr)
}
return ctx.Output(output.SuccessEnvelope(...))
},
}
```
**亮点**:删除操作有一个**双重验证**机制:
1. 先调用 DELETE 请求
2. 如果 DELETE 返回错误,再调用 GET 请求检查 Webhook 是否还存在
3. 如果 GET 也失败,说明 Webhook 已经被删除了,视为成功
---
### 7.5 test 命令
```go
{
Name: "test",
Description: "Test a webhook delivery",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
{Name: "event", Short: "e", Usage: "Event type to test", Default: "push"},
},
Run: func(ctx *common.RuntimeContext) error {
// ... 解析参数
eventType := ctx.Arg("event")
if !isEventSupported(eventType) {
return clierrors.InputError(...)
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)
// ...
},
}
```
**功能**:向指定的 Webhook 发送测试请求,验证 Webhook 是否正常工作
---
### 7.6 info 命令
```go
{
Name: "info",
Description: "Show webhook details",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
// ... 调用 GET /v1/owner/repo/webhooks/{id}
},
}
```
**功能**:查看单个 Webhook 的详细信息
---
### 7.7 events 命令
```go
{
Name: "events",
Description: "List all supported event types for webhooks",
Run: func(ctx *common.RuntimeContext) error {
eventInfo := make([]map[string]interface{}, 0)
for _, event := range supportedEvents {
eventInfo = append(eventInfo, map[string]interface{}{
"event": event,
"supported": true,
"description": getEventDescription(event),
})
}
return ctx.Output(output.SuccessEnvelope(eventInfo, nil))
},
}
```
**功能**:列出所有支持的 Webhook 事件类型及其描述
---
## 八、事件描述函数
```go
func getEventDescription(event string) string {
descriptions := map[string]string{
"push": "Code push events",
"pull_request": "Pull request events",
"issue": "Issue events",
// ... 其他事件描述
}
if desc, ok := descriptions[event]; ok {
return desc
}
return "Custom event"
}
```
**功能**:返回事件类型的英文描述
**工作原理**:使用 map 查找事件对应的描述,如果找不到就返回 "Custom event"
---
## 九、完整调用流程
```
用户命令 (gitlink webhook list)
解析命令行参数
Shortcuts() 返回命令列表
匹配到 "list" 命令
执行 Run 函数
ctx.ResolveOwnerRepo() → 解析仓库信息
ctx.CallAPIWithQuery() → 调用 HTTP 客户端
内部调用 client.Do() → 发送 GET 请求
解析响应 → ctx.Output() → 格式化输出给用户
```
---
## 十、Go 语言知识点
### 1. map[string]interface{} 类型
```go
payload := map[string]interface{}{
"url": webhookURL,
"http_method": "POST",
"active": true,
}
```
这是一个**万能类型**,可以存储任意类型的值:
- `"url"` 对应字符串
- `"active"` 对应布尔值
- `"events"` 对应字符串数组
### 2. 字符串拼接
```go
fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
```
类似 Python 的 `f"/v1/{owner}/{repo}"`,用 `%s` 占位符
### 3. 错误包装
```go
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
```
`%w` 是 Go 1.13+ 的错误包装语法,保留原始错误信息
### 4. 函数作为参数
```go
Run: func(ctx *common.RuntimeContext) error {
// 匿名函数
}
```
这是一个**匿名函数**,作为 `Shortcut` 结构体的 `Run` 字段值
### 5. 字符串分割
```go
events := strings.Split(eventsStr, ",")
```
按逗号分割字符串,返回字符串数组

View File

@ -0,0 +1,546 @@
# 逐行讲解 shortcuts/issue/batch.go面向 Go 小白)
## 文件概述
这个文件实现了 **Issue 批量操作**功能,可以对多个 Issue 进行批量关闭、修改状态、修改优先级、分配人和修改标签等操作。
---
## 一、包声明和导入
```go
package issue
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
```
| 导入库 | 作用 |
|-------|------|
| `encoding/csv` | CSV 文件解析,用于从文件读取 Issue 编号 |
| `fmt` | 格式化输出 |
| `os` | 文件操作,用于打开 CSV 文件 |
| `strconv` | 字符串和数字之间的转换 |
| `strings` | 字符串处理 |
| `common` | 公共工具包 |
---
## 二、常量定义
```go
const (
priorityLow = 1
priorityNormal = 2
priorityHigh = 3
priorityUrgent = 4
)
```
**优先级常量**:定义了 Issue 优先级对应的数字 ID
```go
const (
statusNew = 1
statusInProgress = 2
statusResolved = 3
statusClosed = 5
statusRejected = 6
)
```
**状态常量**:定义了 Issue 状态对应的数字 ID
```go
const (
trackerBug = 1
trackerFeature = 2
trackerSupport = 3
trackerDoc = 4
trackerTest = 5
trackerDuplicate = 6
trackerQuestion = 7
)
```
**类型常量**:定义了 Issue 类型对应的数字 ID
---
## 三、名称映射表
```go
var priorityNames = map[int]string{
priorityLow: "low",
priorityNormal: "normal",
priorityHigh: "high",
priorityUrgent: "urgent",
}
var statusNames = map[int]string{
statusNew: "new",
statusInProgress: "in-progress",
statusResolved: "resolved",
statusClosed: "closed",
statusRejected: "rejected",
}
var trackerNames = map[int]string{
trackerBug: "bug",
trackerFeature: "feature",
// ...
}
```
**作用**:把数字 ID 转换成可读的英文名称,方便输出结果
---
## 四、标签 ID 映射
```go
var tagIDs = map[string]int{
"缺陷": 315526,
"功能": 315527,
"文档": 315533,
"重复": 315525,
"疑问": 315528,
"支持": 315529,
"任务": 315530,
"测试": 315534,
"协助": 315531,
"搁置": 315532,
}
```
**作用**:中文标签名称到 GitLink 标签 ID 的映射
**注意**:这些 ID 是从网页端 DevTools 抓包获取的,不同项目可能不同
---
## 五、结果结构体
```go
type BatchResult 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"`
}
```
**单个操作结果**:记录单个 Issue 的操作结果
```go
type BatchSummary struct {
Repository string `json:"repository" yaml:"repository"`
Action string `json:"action" yaml:"action"`
Value string `json:"value,omitempty" yaml:"value,omitempty"`
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"`
}
```
**批量操作汇总**:记录整个批量操作的统计信息
---
## 六、批量操作命令
### 6.1 batch-close 命令
```go
func newBatchCloseShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-close",
Description: "Close multiple issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchClose,
}
}
```
**执行函数**
```go
func runBatchClose(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "close",
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := BatchResult{Number: number, Action: "close"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusClosed}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "closed"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
```
**执行流程**
1. 解析仓库信息
2. 收集 Issue 编号(从 `--numbers` 参数或 CSV 文件)
3. 初始化 `BatchSummary` 汇总对象
4. 遍历每个 Issue 编号:
- 如果是 dry-run直接标记为 planned
- 否则调用 `updateIssueField` 更新状态为 closed
5. 输出汇总结果
---
### 6.2 batch-status 命令
```go
func runBatchStatus(ctx *common.RuntimeContext) error {
// ... 解析参数
state := ctx.Arg("state")
statusID, err := parseStatus(state)
if err != nil {
return err
}
// ... 遍历更新
updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID})
}
```
**功能**:批量修改 Issue 状态
**参数**`--state` 指定目标状态new/in-progress/resolved/closed/rejected
---
### 6.3 batch-priority 命令
```go
func runBatchPriority(ctx *common.RuntimeContext) error {
// ...
priority := ctx.Arg("priority")
priorityID, err := parsePriority(priority)
// ...
updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID})
}
```
**功能**:批量修改 Issue 优先级
**参数**`--priority` 指定目标优先级low/normal/high/urgent
---
### 6.4 batch-assign 命令
```go
func runBatchAssign(ctx *common.RuntimeContext) error {
// ...
assignee := ctx.Arg("assignee")
var assigneeID interface{}
if !dryRun {
id, err := resolveUserID(ctx, assignee)
assigneeID = id
}
// ...
updateIssueField(ctx, number, map[string]interface{}{"assigned_to_id": assigneeID})
}
```
**功能**:批量分配 Issue 给指定用户
**亮点**:需要先把用户名转换成用户 ID
---
### 6.5 batch-label 命令
```go
func runBatchLabel(ctx *common.RuntimeContext) error {
// ...
label := ctx.Arg("label")
trackerID, err := parseTracker(label)
// ...
updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{trackerID}})
}
```
**功能**:批量修改 Issue 的标签
**参数**`--label` 可以是英文bug/feature或中文缺陷/功能)
---
## 七、核心辅助函数
### 7.1 updateIssueField
```go
func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error {
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("fetch issue #%s: %w", number, err)
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
}
for k, v := range fields {
body[k] = v
}
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("update issue #%s: %w", number, err)
}
return nil
}
```
**功能**:更新 Issue 的指定字段
**关键点**
1. 先调用 `fetchExistingIssue` 获取当前 Issue 的标题和描述
2. 必须在请求体中包含 `subject``description`,否则会被清空
3. 把要更新的字段合并到 body 中
4. 发送 PATCH 请求
---
### 7.2 resolveUserID
```go
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
if id, err := strconv.Atoi(login); err == nil {
return id, nil
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
if err != nil {
return nil, fmt.Errorf("lookup user %q: %w", login, err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response for user %q", login)
}
idFloat, ok := data["id"].(float64)
if ok {
return int(idFloat), nil
}
userIDFloat, ok := data["user_id"].(float64)
if ok {
return int(userIDFloat), nil
}
return nil, fmt.Errorf("cannot determine user ID for %q", login)
}
```
**功能**:把用户名转换成用户 ID
**工作原理**
1. 如果输入已经是数字,直接返回
2. 否则调用 `/users/{login}` API 获取用户信息
3. 从响应中提取 `id``user_id` 字段
4. API 返回的数字是 float64 类型,需要转换成 int
---
### 7.3 collectIssueNumbers
```go
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := parseIssueNumbers(numbersValue)
if err != nil {
return nil, err
}
if csvPath == "" {
return numbers, nil
}
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
if err != nil {
return nil, err
}
return mergeIssueNumbers(numbers, csvNumbers), nil
}
```
**功能**:从 `--numbers` 参数和 CSV 文件中收集 Issue 编号
---
### 7.4 readIssueNumbersFromCSV
```go
func readIssueNumbersFromCSV(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
}
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
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if numberColumn >= len(record) {
continue
}
values = append(values, record[numberColumn])
}
return normalizeIssueNumbers(values)
}
```
**功能**:从 CSV 文件读取 Issue 编号
**智能表头识别**
- 自动识别 `number`、`issue_number`、`project_issues_index` 列
- 如果没有匹配的表头,默认使用第一列
- 跳过表头行,从第二行开始读取
---
## 八、类型转换函数
```go
func parseStatus(state string) (int, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "new":
return statusNew, nil
case "in-progress", "in_progress", "inprogress":
return statusInProgress, nil
// ...
default:
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid state %q", state)
}
}
```
**功能**:把用户输入的状态字符串转换成数字 ID
**容错处理**
- 支持多种写法:`in-progress`、`in_progress`、`inprogress`
- 如果输入是数字,直接返回
`parsePriority``parseTracker` 函数类似
---
## 九、Go 语言知识点
### 1. const 常量定义
```go
const (
priorityLow = 1
priorityNormal = 2
)
```
`const` 块中后续常量会继承前一个常量的值并自动加1
### 2. defer 语句
```go
file, err := os.Open(path)
defer file.Close()
```
`defer` 会在函数返回前执行,确保文件被关闭
### 3. map 遍历
```go
for k, v := range fields {
body[k] = v
}
```
遍历 map 的键值对
### 4. type assertion类型断言
```go
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response")
}
```
把接口类型转换成具体类型,`ok` 表示转换是否成功
### 5. strconv.Atoi
```go
id, err := strconv.Atoi(login)
```
把字符串转换成整数,如果失败返回错误

View File

@ -0,0 +1,673 @@
# 逐行讲解 shortcuts/issue/batch_create.go面向 Go 小白)
## 文件概述
这个文件实现了 **Issue 批量创建**功能,可以从命令行或 CSV 文件批量创建多个 Issue并支持 bug 和 feature 两种模板。
---
## 一、包声明和导入
```go
package issue
import (
"encoding/csv"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"sync"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
```
| 导入库 | 作用 |
|-------|------|
| `encoding/csv` | CSV 文件解析 |
| `fmt` | 格式化输出 |
| `net/url` | URL 查询参数构建 |
| `os` | 文件操作 |
| `strconv` | 字符串和数字转换 |
| `strings` | 字符串处理 |
| `sync` | 并发安全,用于缓存 |
| `common` | 公共工具包 |
---
## 二、标签缓存
```go
var issueTagCache sync.Map
```
**作用**:缓存项目的标签列表,避免重复请求 API
**sync.Map**Go 语言提供的并发安全的 map可以在多个 goroutine 中安全地读写
---
## 三、resolveIssueTags 函数
```go
func resolveIssueTags(ctx *common.RuntimeContext) (map[string]int, error) {
key := ctx.Owner + "/" + ctx.Repo
if cached, ok := issueTagCache.Load(key); ok {
return cached.(map[string]int), nil
}
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo)
q := url.Values{}
q.Set("only_name", "true")
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return nil, fmt.Errorf("获取项目标签列表失败: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("标签列表响应格式异常")
}
rawTags, ok := data["issue_tags"].([]interface{})
if !ok {
return nil, fmt.Errorf("标签列表响应缺少 issue_tags 字段")
}
tags := make(map[string]int, len(rawTags))
for _, item := range rawTags {
tag, ok := item.(map[string]interface{})
if !ok {
continue
}
name, _ := tag["name"].(string)
if name == "" {
continue
}
var id int
switch v := tag["id"].(type) {
case float64:
id = int(v)
case int:
id = v
default:
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
}
if id == 0 {
continue
}
tags[name] = id
}
if len(tags) == 0 {
return nil, fmt.Errorf("项目没有配置任何标签,请先在 GitLink 网页端创建标签")
}
issueTagCache.Store(key, tags)
return tags, nil
}
```
**功能**:获取项目的 Issue 标签列表,并缓存结果
**执行流程**
1. 构建缓存 keyowner/repo
2. 先从缓存中查找,如果有就直接返回
3. 如果缓存中没有,调用 API 获取标签列表
4. 解析 API 响应,提取标签名称和 ID
5. 处理多种 ID 类型float64、int、其他
6. 把结果存入缓存
7. 返回标签映射
---
## 四、命令定义
```go
func newBatchCreateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-create",
Description: "Create multiple issues from CLI flags or a CSV file",
Flags: []common.Flag{
{Name: "titles", Usage: "Comma-separated issue titles"},
{Name: "priority", Short: "p", Usage: "Priority: low, normal, high, urgent"},
{Name: "label", Short: "l", Usage: "Label name"},
{Name: "assignee", Short: "a", Usage: "Assignee login name"},
{Name: "state", Short: "s", Usage: "Initial state", Default: "new"},
{Name: "from", Usage: "CSV file path"},
{Name: "template", Short: "t", Usage: "Template: bug or feature"},
{Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"},
},
Run: runBatchCreate,
}
}
```
**Flags 参数说明**
- `--titles`:逗号分隔的 Issue 标题
- `--priority/-p`:优先级
- `--label/-l`:标签
- `--assignee/-a`:分配人
- `--state/-s`:初始状态
- `--from`CSV 文件路径
- `--template/-t`模板类型bug/feature
- `--dry-run`:预览模式
---
## 五、输入结构体
```go
type createIssueInput struct {
Title string
Body string
Priority string
Label string
Assignee string
Status string
// template-specific fields
Version string
Severity string
Steps string
Expected string
Actual string
UserStory string
Acceptance string
}
```
**作用**:存储创建 Issue 的所有输入参数
**模板专用字段**
- `Version`、`Severity`、`Steps`、`Expected`、`Actual`:用于 bug 模板
- `UserStory`、`Acceptance`:用于 feature 模板
---
## 六、runBatchCreate 主函数
```go
func runBatchCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
tags, err := resolveIssueTags(ctx)
if err != nil {
return err
}
dryRun := parseBool(ctx.Arg("dry-run"))
template := strings.ToLower(strings.TrimSpace(ctx.Arg("template")))
var inputs []createIssueInput
if titlesStr := ctx.Arg("titles"); titlesStr != "" {
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
}
if csvPath := ctx.Arg("from"); csvPath != "" {
csvInputs, err := readCreateInputsFromCSV(csvPath, template)
if err != nil {
return err
}
inputs = append(inputs, csvInputs...)
}
if len(inputs) == 0 {
return fmt.Errorf("no issue titles provided")
}
cliState := ctx.Arg("state")
for i := range inputs {
if inputs[i].Status == "" {
inputs[i].Status = cliState
}
}
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "create",
Value: template,
DryRun: dryRun,
Total: len(inputs),
Results: make([]BatchResult, 0, len(inputs)),
}
for i, input := range inputs {
label := fmt.Sprintf("#%d", i+1)
if input.Title != "" {
label = truncate(input.Title, 40)
}
result := BatchResult{Number: label, Action: "create"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
body := buildCreateBody(ctx, input, template, tags)
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "created"
if data, ok := env.Data.(map[string]interface{}); ok {
if num, ok := data["project_issues_index"]; ok {
result.Number = fmt.Sprintf("%v", num)
}
}
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed to create", summary.Failed, summary.Total)
}
return nil
}
```
**执行流程**
1. 解析仓库信息
2. 获取项目标签列表
3. 收集输入(从 `--titles` 和/或 `--from`
4. 为没有指定状态的输入应用默认状态
5. 遍历创建每个 Issue
- 如果是 dry-run标记为 planned
- 否则构建请求体并调用 API
- 从响应中提取新创建的 Issue 编号
6. 输出汇总结果
---
## 七、buildCreateBody 函数
```go
func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, template string, tags map[string]int) map[string]interface{} {
statusID := statusNew
if input.Status != "" {
if sid, err := parseStatus(input.Status); err == nil {
statusID = sid
}
}
body := map[string]interface{}{
"subject": input.Title,
"status_id": statusID,
"priority_id": priorityNormal,
"done_ratio": 0,
}
if template != "" {
body["description"] = buildTemplateDescription(input, template)
if template == "bug" {
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
} else if template == "feature" {
body["issue_tag_ids"] = []interface{}{tags["功能"]}
}
} else if input.Body != "" {
body["description"] = input.Body
}
if input.Priority != "" {
if pid, err := parsePriority(input.Priority); err == nil {
body["priority_id"] = pid
}
}
if input.Label != "" {
if tid, err := parseLabel(input.Label, tags); err == nil {
body["issue_tag_ids"] = []interface{}{tid}
}
}
if input.Assignee != "" {
if id, err := resolveUserID(ctx, input.Assignee); err == nil {
body["assigner_ids"] = []interface{}{id}
}
}
return body
}
```
**功能**:构建创建 Issue 的请求体
**逻辑**
1. 设置默认值(状态、优先级、完成比例)
2. 如果指定了模板,构建模板描述并设置对应的标签
3. 否则使用自定义描述
4. 应用优先级、标签、分配人等可选参数
---
## 八、模板描述构建
### 8.1 buildTemplateDescription
```go
func buildTemplateDescription(input createIssueInput, template string) string {
switch template {
case "bug":
return buildBugDescription(input)
case "feature":
return buildFeatureDescription(input)
default:
return input.Body
}
}
```
### 8.2 buildBugDescription
```go
func buildBugDescription(input createIssueInput) string {
var b strings.Builder
b.WriteString("## Bug 描述\n")
b.WriteString(input.Title)
b.WriteString("\n")
if input.Version != "" {
b.WriteString("\n## 版本\n")
b.WriteString(input.Version)
}
if input.Severity != "" {
b.WriteString("\n## 严重程度\n")
b.WriteString(input.Severity)
}
if input.Steps != "" {
b.WriteString("\n## 复现步骤\n")
b.WriteString(input.Steps)
}
if input.Expected != "" {
b.WriteString("\n## 期望结果\n")
b.WriteString(input.Expected)
}
if input.Actual != "" {
b.WriteString("\n## 实际结果\n")
b.WriteString(input.Actual)
}
return b.String()
}
```
**功能**:构建标准化的 Bug 描述
**输出格式**
```markdown
## Bug 描述
标题内容
## 版本
v1.0.0
## 严重程度
## 复现步骤
步骤1
步骤2
## 期望结果
期望的行为
## 实际结果
实际的行为
```
### 8.3 buildFeatureDescription
```go
func buildFeatureDescription(input createIssueInput) string {
var b strings.Builder
b.WriteString("## 用户故事\n")
if input.UserStory != "" {
b.WriteString(input.UserStory)
} else {
b.WriteString(input.Title)
}
if input.Body != "" {
b.WriteString("\n## 描述\n")
b.WriteString(input.Body)
}
if input.Acceptance != "" {
b.WriteString("\n## 验收标准\n")
b.WriteString(input.Acceptance)
}
if input.Priority != "" {
b.WriteString("\n## 优先级\n")
b.WriteString(input.Priority)
}
return b.String()
}
```
**功能**:构建标准化的 Feature 描述
**输出格式**
```markdown
## 用户故事
作为用户,我想...
## 描述
详细描述
## 验收标准
- 标准1
- 标准2
## 优先级
high
```
---
## 九、CSV 读取
```go
func readCreateInputsFromCSV(path string, template string) ([]createIssueInput, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
}
header := records[0]
col := make(map[string]int)
for i, h := range header {
col[normalizeHeader(h)] = i
}
if _, ok := col["title"]; !ok {
return nil, fmt.Errorf("CSV must have a 'title' column")
}
var inputs []createIssueInput
for _, record := range records[1:] {
input := createIssueInput{
Title: getCol(record, col, "title"),
Body: getCol(record, col, "body"),
Priority: getCol(record, col, "priority"),
Label: getCol(record, col, "label"),
Assignee: getCol(record, col, "assignee"),
Status: getCol(record, col, "status"),
Version: getCol(record, col, "version"),
Severity: getCol(record, col, "severity"),
Steps: getCol(record, col, "steps"),
Expected: getCol(record, col, "expected"),
Actual: getCol(record, col, "actual"),
UserStory: getCol(record, col, "user_story"),
Acceptance: getCol(record, col, "acceptance"),
}
if input.UserStory == "" {
input.UserStory = getCol(record, col, "user story")
}
if input.Title == "" {
continue
}
inputs = append(inputs, input)
}
return inputs, nil
}
```
**CSV 列支持**
- `title`必填Issue 标题
- `body`:描述内容
- `priority`:优先级
- `label`:标签
- `assignee`:分配人
- `status`:状态
- `version`版本bug 模板)
- `severity`严重程度bug 模板)
- `steps`复现步骤bug 模板)
- `expected`期望结果bug 模板)
- `actual`实际结果bug 模板)
- `user_story` / `user story`用户故事feature 模板)
- `acceptance`验收标准feature 模板)
---
## 十、辅助函数
### 10.1 parseTitles
```go
func parseTitles(titlesStr string, ctx *common.RuntimeContext) []createIssueInput {
parts := strings.Split(titlesStr, ",")
inputs := make([]createIssueInput, 0, len(parts))
for _, title := range parts {
title = strings.TrimSpace(title)
if title == "" {
continue
}
inputs = append(inputs, createIssueInput{
Title: title,
Priority: ctx.Arg("priority"),
Label: ctx.Arg("label"),
Assignee: ctx.Arg("assignee"),
Status: ctx.Arg("state"),
})
}
return inputs
}
```
**功能**:从逗号分隔的标题字符串创建输入对象
### 10.2 normalizeHeader
```go
func normalizeHeader(h string) string {
return strings.ToLower(strings.TrimSpace(h))
}
```
**功能**:标准化 CSV 表头(转小写、去空格)
### 10.3 getCol
```go
func getCol(record []string, col map[string]int, name string) string {
if idx, ok := col[name]; ok && idx < len(record) {
return strings.TrimSpace(record[idx])
}
return ""
}
```
**功能**:从 CSV 记录中获取指定列的值
### 10.4 truncate
```go
func truncate(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "..."
}
```
**功能**:截断字符串到指定长度,超出部分用 `...` 表示
**注意**:使用 `[]rune` 处理,可以正确处理中文等多字节字符
---
## 十一、Go 语言知识点
### 1. sync.Map
```go
var issueTagCache sync.Map
// 读取
if cached, ok := issueTagCache.Load(key); ok {
return cached.(map[string]int), nil
}
// 写入
issueTagCache.Store(key, tags)
```
**作用**:并发安全的 map用于多个 goroutine 同时读写
### 2. strings.Builder
```go
var b strings.Builder
b.WriteString("## Bug 描述\n")
b.WriteString(input.Title)
return b.String()
```
**作用**:高效拼接字符串,避免产生大量临时字符串
### 3. []interface{}
```go
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
```
**作用**:创建一个包含任意类型的数组,用于 JSON 序列化
### 4. switch 类型断言
```go
switch v := tag["id"].(type) {
case float64:
id = int(v)
case int:
id = v
default:
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
}
```
**作用**:根据值的实际类型执行不同的处理逻辑
### 5. 可变参数
```go
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
```
`...` 表示把切片展开成多个参数

View File

@ -0,0 +1,405 @@
# 逐行讲解 shortcuts/repo/batch_create.go面向 Go 小白)
## 文件概述
这个文件实现了 **仓库批量创建**功能,可以从命令行或 CSV 文件批量创建多个 GitLink 仓库。
---
## 一、包声明和导入
```go
package repo
import (
"encoding/csv"
"fmt"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
```
| 导入库 | 作用 |
|-------|------|
| `encoding/csv` | CSV 文件解析 |
| `fmt` | 格式化输出 |
| `os` | 文件操作 |
| `strings` | 字符串处理 |
| `common` | 公共工具包 |
---
## 二、结构体定义
### 2.1 repoCreateInput
```go
type repoCreateInput struct {
Name string
Description string
Private bool
}
```
**作用**:存储创建单个仓库的输入参数
| 字段 | 类型 | 说明 |
|-----|------|------|
| `Name` | string | 仓库名称 |
| `Description` | string | 仓库描述 |
| `Private` | bool | 是否私有仓库 |
### 2.2 repoBatchResult
```go
type repoBatchResult struct {
Name string `json:"name" yaml:"name"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
```
**作用**:存储单个仓库创建的结果
| 字段 | 类型 | 说明 |
|-----|------|------|
| `Name` | string | 仓库名称 |
| `Status` | string | 创建状态planned/created/failed |
| `Error` | string | 错误信息(如果失败) |
### 2.3 repoBatchSummary
```go
type repoBatchSummary struct {
Owner string `json:"owner" yaml:"owner"`
Action string `json:"action" yaml:"action"`
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 []repoBatchResult `json:"results" yaml:"results"`
}
```
**作用**:存储批量创建的汇总结果
| 字段 | 类型 | 说明 |
|-----|------|------|
| `Owner` | string | 仓库所有者(用户名) |
| `Action` | string | 操作类型create |
| `DryRun` | bool | 是否是预览模式 |
| `Total` | int | 总数量 |
| `Succeeded` | int | 成功数量 |
| `Failed` | int | 失败数量 |
| `Results` | []repoBatchResult | 每个仓库的详细结果 |
---
## 三、命令定义
```go
func newBatchCreateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-create",
Description: "Create multiple repositories from CLI flags or a CSV file",
Flags: []common.Flag{
{Name: "names", Short: "n", Usage: "Comma-separated repository names"},
{Name: "from", Usage: "CSV file path"},
{Name: "description", Short: "d", Usage: "Shared description for all repos"},
{Name: "private", Usage: "Make repos private", Bool: true, Default: "false"},
{Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"},
},
Run: runBatchCreate,
}
}
```
**Flags 参数说明**
- `--names/-n`:逗号分隔的仓库名称
- `--from`CSV 文件路径
- `--description/-d`:所有仓库共享的描述
- `--private`:创建私有仓库
- `--dry-run`:预览模式
---
## 四、runBatchCreate 主函数
```go
func runBatchCreate(ctx *common.RuntimeContext) error {
var inputs []repoCreateInput
if namesStr := ctx.Arg("names"); namesStr != "" {
for _, name := range strings.Split(namesStr, ",") {
name = strings.TrimSpace(name)
if name == "" {
continue
}
inputs = append(inputs, repoCreateInput{
Name: name,
Description: ctx.Arg("description"),
Private: ctx.Arg("private") == "true",
})
}
}
if csvPath := ctx.Arg("from"); csvPath != "" {
csvInputs, err := readRepoInputsFromCSV(csvPath)
if err != nil {
return err
}
inputs = append(inputs, csvInputs...)
}
if len(inputs) == 0 {
return fmt.Errorf("no repository names provided")
}
dryRun := ctx.Arg("dry-run") == "true"
var login string
var userID int
if !dryRun {
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return fmt.Errorf("failed to get current user: %w", err)
}
userData, _ := userEnv.Data.(map[string]interface{})
login, _ = userData["login"].(string)
if login == "" {
return fmt.Errorf("cannot determine current user login")
}
if uid, ok := userData["user_id"].(float64); ok {
userID = int(uid)
}
}
summary := repoBatchSummary{
Owner: login,
Action: "create",
DryRun: dryRun,
Total: len(inputs),
Results: make([]repoBatchResult, 0, len(inputs)),
}
for _, input := range inputs {
result := repoBatchResult{Name: input.Name}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
body := map[string]interface{}{
"name": input.Name,
"repository_name": input.Name,
"user_id": userID,
}
if input.Description != "" {
body["description"] = input.Description
}
if input.Private {
body["private"] = true
}
if _, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, input.Name), body); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "created"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d repo(s) failed to create", summary.Failed, summary.Total)
}
return nil
}
```
**执行流程**
1. 收集输入(从 `--names` 和/或 `--from`
2. 如果不是 dry-run调用 `/users/me` 获取当前用户信息
3. 初始化汇总对象
4. 遍历每个仓库:
- 如果是 dry-run标记为 planned
- 否则构建请求体并调用 API
- 记录结果
5. 输出汇总结果
---
## 五、readRepoInputsFromCSV 函数
```go
func readRepoInputsFromCSV(path string) ([]repoCreateInput, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
}
header := records[0]
col := make(map[string]int)
for i, h := range header {
col[strings.ToLower(strings.TrimSpace(h))] = i
}
if _, ok := col["name"]; !ok {
return nil, fmt.Errorf("CSV must have a 'name' column")
}
var inputs []repoCreateInput
for _, record := range records[1:] {
name := getCol(record, col, "name")
if name == "" {
continue
}
private := false
if p := strings.ToLower(getCol(record, col, "private")); p == "true" || p == "1" {
private = true
}
inputs = append(inputs, repoCreateInput{
Name: name,
Description: getCol(record, col, "description"),
Private: private,
})
}
return inputs, nil
}
```
**CSV 列支持**
- `name`(必填):仓库名称
- `description`:仓库描述
- `private`是否私有true/false 或 1/0
---
## 六、getCol 函数
```go
func getCol(record []string, col map[string]int, name string) string {
if idx, ok := col[name]; ok && idx < len(record) {
return strings.TrimSpace(record[idx])
}
return ""
}
```
**功能**:从 CSV 记录中获取指定列的值
**逻辑**
1. 查找列名对应的索引
2. 检查索引是否有效
3. 返回该位置的值(去除前后空格)
4. 如果找不到,返回空字符串
---
## 七、完整调用流程
```
用户命令 (gitlink repo batch-create -n repo-a,repo-b)
解析命令行参数
newBatchCreateShortcut() 返回命令定义
执行 runBatchCreate 函数
收集输入(解析 --names 参数)
调用 /users/me 获取当前用户信息
遍历每个仓库名称:
构建请求体name, repository_name, user_id
调用 POST /{login}/{repo_name} 创建仓库
记录创建结果
输出汇总结果
```
---
## 八、Go 语言知识点
### 1. 结构体标签Struct Tags
```go
type repoBatchResult struct {
Name string `json:"name" yaml:"name"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
```
**作用**:告诉序列化库(如 JSON、YAML如何给字段命名
- `json:"name"`JSON 序列化时使用 `name` 作为字段名
- `json:"error,omitempty"`:如果 `Error` 为空JSON 中不包含这个字段
### 2. 布尔值判断
```go
dryRun := ctx.Arg("dry-run") == "true"
```
**注意**`ctx.Arg()` 返回的是字符串,需要和字符串 `"true"` 比较,不能直接用 `bool()` 转换
### 3. interface{} 类型断言
```go
userData, _ := userEnv.Data.(map[string]interface{})
login, _ = userData["login"].(string)
```
**作用**:把 `interface{}` 类型转换成具体类型
### 4. float64 转 int
```go
if uid, ok := userData["user_id"].(float64); ok {
userID = int(uid)
}
```
**原因**JSON 解析后,数字默认是 `float64` 类型,需要手动转换成 `int`
### 5. defer 语句
```go
file, err := os.Open(path)
defer file.Close()
```
**作用**:确保文件在函数返回前被关闭,防止资源泄漏
### 6. make 和预分配容量
```go
Results: make([]repoBatchResult, 0, len(inputs))
```
**作用**:创建一个初始长度为 0、容量为 `len(inputs)` 的切片,避免动态扩容的性能开销

83
doc/reading_notes/1.txt Normal file
View File

@ -0,0 +1,83 @@
ctx 就是 *common.RuntimeContext。一句话它是每个 Shortcut
命令的"工具箱"所有能力发API请求、读参数、输出结果都挂在这个对象上。
---
它长什么样shortcuts/common/types.go:41-50
type RuntimeContext struct {
Client *client.Client // ← 发 HTTP 请求的客户端
Owner string // ← --owner 的值(如 "zzx-coder"
Repo string // ← --repo 的值(如 "gitlink-cli"
Format string // ← --format 的值("json" / "table" / "yaml"
CommandName string // ← 当前命令名(如 "wiki +delete"
Args map[string]string // ← 所有 flag 的键值对(如 {"title":"Home","dry-run":"false"}
GatewayBaseURL string // ← Wiki/Webhook网关地址跟标准API不同
GatewayHTTPClient *http.Client // ← 网关专用 HTTP 客户端nil 则自动创建)
}
---
它怎么创建出来的types.go:53-80
你敲 gitlink-cli wiki +delete --title "Home" 时:
第1步cobra 解析命令行 → flagValues = {"title": "Home"}
第2步runner.go:34 → NewRuntimeContext(flagValues, "wiki +delete")
第3步NewRuntimeContext 内部:
→ client.New() // 读取配置文件,拿到 BaseURL + 带 auth 的 HTTP Client
→ 读取全局 flag // cmdutil.Owner, cmdutil.Repo, cmdutil.Format
→ 组装成 RuntimeContext // 把所有东西塞进去
第4步传给 s.Run(ctx) // 你的业务逻辑拿到这个 ctx
---
它上面的方法(你可以直接用 ctx.XXX() 调用的)
┌──────────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────┐
│ 方法 │ 做什么 │ 例 │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.Arg("title") │ 读用户传入的 flag 值 │ "Home" │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.RequireArg("title", "提示") │ 读必填参数,为空就报 CLIError │ 同上,但自动校验 │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.CallAPI("GET", path, body) │ 调 GitLink 标准 API │ ctx.CallAPI("GET", "/users/me", nil) │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.CallAPIWithQuery("GET", path, │ 带查询参数的 API │ ctx.CallAPIWithQuery("GET", │
│ query) │ │ "/issues", q) │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.Output(env) │ 输出 API 响应(自动选 │ ctx.Output(env) │
│ │ json/table/yaml │ │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.OutputData(data) │ 包装数据成成功 envelope 再输出 │ ctx.OutputData(myStruct) │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.ResolveOwnerRepo() │ 从 git remote 自动推断 owner/repo │ 没传 --owner 时自动填充 │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.RepoPath() │ 返回 "/owner/repo" 字符串 │ "/zzx-coder/gitlink-cli" │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.IsDryRun() │ 判断用户是否传了 --dry-run │ true / false │
└──────────────────────────────────────┴───────────────────────────────────┴──────────────────────────────────────┘
---
一条命令里 ctx 的完整生命周期
以 wiki +delete --title "Home" 为例:
1. runner.go:20-31 收集 flag
flagValues = {"title": "Home"}
2. runner.go:34 创建 ctx
ctx = NewRuntimeContext({"title":"Home"}, "wiki +delete")
→ ctx.Client = 带认证的HTTP客户端BaseURL = https://gitlink.org.cn/api
→ ctx.Owner = "zzx-coder"(从 git remote 或 --owner 来的)
→ ctx.Repo = "gitlink-cli"
→ ctx.Format = "table"(默认值)
→ ctx.Args = {"title": "Home"}
3. runner.go:60 调用你的业务逻辑
err = s.Run(ctx)
4. wiki.go:747 你的 Run 函数里用 ctx
title, _ = ctx.RequireArg("title", ...) // → "Home"
projectID, _ = resolveProjectID(ctx) // → ctx 传进子函数
body = {..., "pageName": actualPageName}
callWikiAPI(ctx, "DELETE", ..., body) // → ctx 用于构造网关 Client
ctx.OutputData(result) // → ctx.Format 决定输出格式
---

787
doc/代码逻辑.md Normal file
View File

@ -0,0 +1,787 @@
# gitlink-cli 子任务一代码逻辑说明
## ? 目录
- [系统架构概述](#系统架构概述)
- [Wiki 管理功能](#wiki-管理功能)
- [Webhook 管理功能](#webhook-管理功能)
- [批量操作功能](#批量操作功能)
- [Raw API 功能](#raw-api-功能)
- [命令优化功能](#命令优化功能)
- [跨平台兼容性](#跨平台兼容性)
## ?? 系统架构概述
### 核心设计模式
**Shortcut 架构模式**:
- 每个功能模块wiki、webhook、issue等实现一个 `Shortcuts()` 函数
- 返回 `[]*common.Shortcut` 切片,每个 Shortcut 代表一个命令
- 命令执行通过 `Run: func(ctx *common.RuntimeContext) error` 实现
**RuntimeContext 上下文**:
```go
type RuntimeContext struct {
Client *client.Client // HTTP 客户端
Owner string // 仓库所有者
Repo string // 仓库名称
Format string // 输出格式 (json/table/yaml)
Args map[string]string // 命令行参数
}
```
**API 调用流程**:
1. `ctx.ResolveOwnerRepo()` - 解析 owner/repo支持 git remote 自动解析)
2. `ctx.CallAPI()` - 发送 HTTP 请求到 GitLink API
3. `ctx.Output()` - 格式化输出结果
---
## ? Wiki 管理功能
### 核心架构
**双重 API 调用机制**:
- `BaseURL`: `https://www.gitlink.org.cn/api` - 主 API获取项目信息
- `GatewayBaseURL`: `https://gateway.gitlink.org.cn/api` - Gateway APIWiki 操作)
### 关键代码逻辑
#### 1. Project ID 解析机制 (`resolveProjectID`)
**问题**: Wiki API 需要 `projectId` 数字 ID而用户只知道 `owner/repo`
**解决方案**:
```go
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
key := ctx.Owner + "/" + ctx.Repo
// 1. 检查缓存 (sync.Map 实现线程安全)
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil
}
// 2. 调用主 API 获取项目详情
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPI("GET", path, nil)
// 3. 提取 project_id 并缓存
projectIDCache.Store(key, pidStr)
return pidStr, nil
}
```
#### 2. Base64 编解码处理
**Wiki 内容编码**:
```go
// 创建 Wiki 时编码
body["content_base64"] = base64.StdEncoding.EncodeToString([]byte(content))
// 查看 Wiki 时解码
if b64, ok := data["content_base64"].(string); ok {
decoded, err := base64.StdEncoding.DecodeString(b64)
data["content_decoded"] = string(decoded) // 额外提供解码后的内容
}
```
#### 3. Wiki 更新策略 (`update` 命令)
**三种更新模式**:
```go
if coverText != "" || filePath != "" && coverText == "" && addText == "" {
// --cover 或 --file: 完全覆盖内容
finalContent = content
} else if addText != "" {
// --add: 追加到现有内容
existing, err := fetchPageContent(ctx, projectID, pageName)
finalContent = existing + newPart
}
```
#### 4. 删除验证机制
**问题**: API 删除操作可能返回错误但实际删除成功
**解决方案**:
```go
delErr := callWikiAPI(ctx, "DELETE", wikiPath("deleteWiki"), body)
if delErr != nil {
// 验证是否真的删除成功
_, viewErr := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if viewErr != nil {
// GET 也失败,说明已删除成功
return ctx.OutputData(map[string]string{"message": "Wiki page deleted successfully"})
}
return delErr // GET 成功,说明删除确实失败
}
```
### API 路径设计
| 命令 | HTTP 方法 | Gateway API 路径 |
|------|----------|-----------------|
| list | GET | `/wiki/open/wikiPages` |
| view | GET | `/wiki/open/getWiki` |
| create | POST | `/wiki/open/createWiki` |
| update | PUT | `/wiki/open/updateWiki` |
| delete | DELETE | `/wiki/open/deleteWiki` |
---
## ? Webhook 管理功能
### 核心设计
**统一的 API 路径前缀**:
```go
func webhookRepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
```
### 关键代码逻辑
#### 1. 事件类型管理
**支持的事件列表**:
```go
var supportedEvents = []string{
"push", "pull_request", "issue", "issue_assign", "issue_comment",
"pull_request_assign", "pull_request_comment", "merge_request",
"repository", "branch", "tag",
}
// 事件解析和验证
func parseEvents(eventsStr string) []string {
events := strings.Split(eventsStr, ",")
for _, event := range events {
if isEventSupported(event) {
validEvents = append(validEvents, event)
}
}
return validEvents
}
```
#### 2. Webhook 创建逻辑
**完整的 Payload 构造**:
```go
payload := map[string]interface{}{
"url": webhookURL, // 必需
"http_method": "POST", // 固定
"active": true, // 默认激活
"content_type": "json", // 默认 JSON
"events": validEvents, // 事件列表
}
// 可选字段
if secret := ctx.Arg("secret"); secret != "" {
payload["secret"] = secret // HMAC 验证密钥
}
if description := ctx.Arg("description"); description != "" {
payload["description"] = description
}
```
#### 3. 智能 URL 获取Update 命令)
**问题**: 更新 Webhook 时用户不记得当前 URL
**解决方案**:
```go
webhookURL := ctx.Arg("url")
if webhookURL == "" {
// 自动获取当前 Webhook 的 URL
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
webhookData := getEnv.Data.(map[string]interface{})
currentURL := webhookData["url"].(string)
webhookURL = currentURL // 使用现有 URL
}
payload["url"] = webhookURL
```
#### 4. 删除验证机制
```go
delErr := ctx.CallAPI("DELETE", webhookPath, nil)
if delErr != nil {
// 验证是否真的删除成功
_, viewErr := ctx.CallAPI("GET", webhookPath, nil)
if viewErr != nil {
// GET 返回错误,说明已删除
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "Webhook deleted successfully",
}, nil))
}
return delErr
}
```
#### 5. Test 端点修复
**正确路径**: `/webhooks/{id}/tests` (复数)
```go
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)
```
### API 路径设计
| 命令 | HTTP 方法 | API 路径 |
|------|----------|---------|
| list | GET | `/v1/{owner}/{repo}/webhooks` |
| create | POST | `/v1/{owner}/{repo}/webhooks` |
| update | PUT | `/v1/{owner}/{repo}/webhooks/{id}` |
| delete | DELETE | `/v1/{owner}/{repo}/webhooks/{id}` |
| test | POST | `/v1/{owner}/{repo}/webhooks/{id}/tests` |
| info | GET | `/v1/{owner}/{repo}/webhooks/{id}` |
| events | - | (本地静态列表,不调用 API) |
---
## ? 批量操作功能
### 核心设计模式
**统一的结果统计结构**:
```go
type BatchSummary struct {
Repository string // 仓库标识
Action string // 操作类型
Value string // 操作值
DryRun bool // 是否预览
Total int // 总数
Succeeded int // 成功数
Failed int // 失败数
Results []BatchResult // 详细结果
}
```
### 关键代码逻辑
#### 1. Issue 批量创建 (`batch_create.go`)
**输入源合并**:
```go
var inputs []createIssueInput
// 1. 从命令行参数收集
if titlesStr := ctx.Arg("titles"); titlesStr != "" {
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
}
// 2. 从 CSV 文件收集
if csvPath := ctx.Arg("from"); csvPath != "" {
csvInputs, err := readCreateInputsFromCSV(csvPath, template)
inputs = append(inputs, csvInputs...)
}
```
**模板支持**:
```go
func buildCreateBody(input createIssueInput, template string) map[string]interface{} {
if template == "bug" {
body["description"] = buildBugDescription(input)
body["issue_tag_ids"] = []interface{}{tagIDs["缺陷"]}
} else if template == "feature" {
body["description"] = buildFeatureDescription(input)
body["issue_tag_ids"] = []interface{}{tagIDs["功能"]}
}
}
```
#### 2. Issue 批量操作 (`batch.go`)
**通用批量操作流程**:
```go
func runBatchClose(ctx *common.RuntimeContext) error {
// 1. 收集 Issue 编号
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
// 2. 初始化统计结构
summary := BatchSummary{Total: len(numbers)}
// 3. 逐个处理
for _, number := range numbers {
if dryRun {
result.Status = "planned" // 预览模式
} else {
err := updateIssueField(ctx, number, payload)
if err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "closed"
summary.Succeeded++
}
}
summary.Results = append(summary.Results, result)
}
// 4. 输出统计结果
return ctx.OutputData(summary)
}
```
#### 3. 仓库批量创建 (`batch_create.go`)
**用户信息获取**:
```go
// 获取当前用户信息
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
login := userData["login"].(string) // 用于 API 路径
userID := int(userData["user_id"].(float64)) // 用于请求体
// 创建仓库
body := map[string]interface{}{
"name": repoName,
"repository_name": repoName,
"user_id": userID,
}
ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, repoName), body)
```
#### 4. CSV 文件处理
**通用 CSV 读取模式**:
```go
func readRepoInputsFromCSV(path string) ([]repoCreateInput, error) {
file, err := os.Open(path)
reader := csv.NewReader(file)
records, err := reader.ReadAll()
// 1. 解析表头
header := records[0]
col := make(map[string]int)
for i, h := range header {
col[strings.ToLower(strings.TrimSpace(h))] = i
}
// 2. 验证必需列
if _, ok := col["name"]; !ok {
return nil, fmt.Errorf("CSV must have a 'name' column")
}
// 3. 读取数据行
for _, record := range records[1:] {
name := getCol(record, col, "name")
inputs = append(inputs, repoCreateInput{Name: name})
}
return inputs, nil
}
```
#### 5. Issue 编号收集
**多源合并**:
```go
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
// 1. 解析命令行参数
numbers, err := parseIssueNumbers(numbersValue) // "1,2,3" -> []string{"1","2","3"}
// 2. 读取 CSV 文件
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
// 3. 合并去重
return mergeIssueNumbers(numbers, csvNumbers)
}
```
### 批量操作对比
| 功能 | 输入源 | 特殊处理 |
|------|--------|----------|
| repo +batch-create | names CSV | 获取当前用户 login/userID |
| issue +batch-create | titles CSV | 支持模板 (bug/feature) |
| issue +batch-close | numbers CSV | 状态 ID 转换 (closed=5) |
| issue +batch-status | numbers CSV | 状态 ID 转换 |
| issue +batch-priority | numbers CSV | 优先级 ID 转换 |
| issue +batch-assign | numbers CSV | 用户名→用户ID解析 |
| issue +batch-label | numbers CSV | 标签名→标签ID映射 |
---
## ? Raw API 功能
### 核心设计
**统一的 HTTP 客户端**:
```go
type Client struct {
HTTP *http.Client
BaseURL string
Debug bool
SkipJSONSuffix bool // Wiki Gateway 不需要 .json 后缀
}
```
### 关键代码逻辑
#### 1. 请求路径处理
**自动添加 .json 后缀**:
```go
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
// GitLink API 约定: 所有路径需要 .json 后缀
if !c.SkipJSONSuffix {
if !strings.HasSuffix(path, ".json") {
path += ".json"
}
}
// 构造完整 URL
fullURL := c.BaseURL + path
if query != nil {
fullURL += "?" + query.Encode()
}
// 发送 HTTP 请求
req, _ := http.NewRequest(method, fullURL, bodyReader)
resp, _ := c.HTTP.Do(req)
}
```
#### 2. 响应解析策略
**多层错误处理**:
```go
// 1. HTTP 状态码检查
if resp.StatusCode >= 400 {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, body),
}
}
// 2. 解析 JSON 响应
var raw map[string]interface{}
json.Unmarshal(respData, &raw)
// 3. GitLink 业务错误检查
if status, ok := raw["status"]; ok {
if statusCode != 0 && statusCode != 200 {
msg := raw["message"].(string)
suggestion := suggestFix(int(statusCode)) // 智能错误提示
return ErrorEnvelope(code, msg, suggestion)
}
}
// 4. 处理 JSON 字符串数据 (GitLink API 特性)
if dataStr, ok := raw["data"].(string); ok {
var parsedData interface{}
json.Unmarshal([]byte(dataStr), &parsedData)
raw["data"] = parsedData // 自动解析嵌套 JSON
}
```
#### 3. 智能错误提示
```go
func suggestFix(code int) string {
switch code {
case 401:
return "请先运行 gitlink-cli auth login 登录"
case 403:
return "权限不足,请确认账户权限或联系项目管理员"
case 404:
return "资源不存在,请检查 owner/repo/id 是否正确"
case 422:
return "参数校验失败,请检查请求参数"
}
}
```
#### 4. HTTP 方法封装
```go
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
return c.Do("GET", path, nil, query)
}
func (c *Client) Post(path string, body interface{}) (*output.Envelope, error) {
return c.Do("POST", path, body, nil)
}
func (c *Client) Put(path string, body interface{}) (*output.Envelope, error) {
return c.Do("PUT", path, body, nil)
}
func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) {
return c.Do("DELETE", path, nil, query)
}
```
### Raw API 使用示例
```bash
# GET 请求
./gitlink-cli.exe api GET /users/me
# POST 请求
./gitlink-cli.exe api POST /zzx-coder/gitlink-cli/issues --body '{"subject":"测试"}'
# PUT 请求
./gitlink-cli.exe api PUT /zzx-coder/gitlink-cli/issues/123 --body '{"status":"closed"}'
# DELETE 请求
./gitlink-cli.exe api DELETE /zzx-coder/gitlink-cli/issues/123
# 带查询参数
./gitlink-cli.exe api GET "/zzx-coder/gitlink-cli/issues" --query "status=open&limit=20"
```
---
## ? 命令优化功能
### 1. 参数设计优化
**短参数支持**:
```go
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
{Name: "body", Short: "b", Usage: "Issue description"},
}
// 用户可以使用:
// --title "Bug" 或 -t "Bug"
```
**参数别名和映射**:
```go
func parseStatus(state string) (int, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open":
return 1, nil
case "closed":
return 5, nil
case "in-progress", "in_progress", "inprogress": // 支持多种格式
return 2, nil
}
}
```
### 2. 输出格式优化
**Envelope 结构**:
```go
type Envelope struct {
OK bool // 操作是否成功
Data interface{} // 数据
Error *ErrorInfo // 错误信息
Meta *Meta // 元数据 (分页等)
}
```
**格式化输出**:
```go
// JSON 格式
{
"ok": true,
"data": {...},
"meta": {
"total_count": 100,
"page": 1,
"limit": 20
}
}
// Table 格式 (自动格式化)
+----+-------------------+---------+
| ID | Title | Status |
+----+-------------------+---------+
| 1 | Bug fix | open |
+----+-------------------+---------+
```
### 3. 错误提示优化
**友好的错误消息**:
```go
type ErrorInfo struct {
Code interface{} // 错误代码
Message string // 错误描述
Suggestion string // 解决建议 (新增)
}
// 示例:
{
"ok": false,
"error": {
"code": 401,
"message": "Authentication failed",
"suggestion": "请先运行 gitlink-cli auth login 登录"
}
}
```
**智能错误处理**:
```go
// Wiki 创建时的错误处理
if err != nil {
if strings.Contains(err.Error(), "404") {
return fmt.Errorf("Wiki page not found\n\nSuggestions:\n- Check if the wiki page exists\n- Verify you have the correct permissions\n- Use 'gitlink-cli wiki +list' to see available pages")
}
return err
}
```
### 4. CSV 编码错误提示
```go
// 批量操作时的编码检查
if !isUTF8CSV(file) {
return fmt.Errorf(`? CSV 文件编码错误
文件编码不是 UTF-8当前编码: %s
解决方案:
1. 使用支持 UTF-8 的编辑器重新保存文件
2. 或使用以下命令创建 UTF-8 文件:
cat > repos.csv << 'EOF'
name,description,private
test1,测试1,false
EOF`, currentEncoding)
}
```
---
## ? 跨平台兼容性
### 1. 路径处理
**配置目录解析**:
```go
func ConfigDir() string {
// 优先使用环境变量
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
return dir
}
// 跨平台主目录
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli")
}
// Windows: C:\Users\{user}\.config\gitlink-cli
// Linux/Mac: /home/{user}/.config/gitlink-cli
```
### 2. Git Remote 解析
**自动解析仓库路径**:
```go
// 1. 从 git remote 获取 owner/repo
gitRemote := "https://gitlink.org.cn/zzx-coder/gitlink-cli.git"
owner, repo := "zzx-coder", "gitlink-cli"
// 2. 支持多种 remote 格式
// https://gitlink.org.cn/owner/repo.git
// git@gitlink.org.cn:owner/repo.git
// ssh://git@gitlink.org.cn/owner/repo.git
```
### 3. 字符编码处理
**Base64 编解码**:
```go
// Wiki 内容处理 (支持多语言)
content := "# 中文内容\n\nThis is English."
encoded := base64.StdEncoding.EncodeToString([]byte(content))
decoded := base64.StdEncoding.DecodeString(encoded)
```
**Emoji 支持**:
```go
// 支持 Emoji 字符
title := "? Feature request ?"
body := "Add emoji support ?"
```
### 4. 平台特定处理
**文件权限**:
```go
// 配置文件权限: 0600 (仅用户可读写)
os.WriteFile(configPath, data, 0600)
// 目录权限: 0700 (仅用户可访问)
os.MkdirAll(configDir, 0700)
```
**二进制文件**:
```bash
# Windows: gitlink-cli.exe
# Linux/Mac: gitlink-cli
```
---
## ? 数据流程示例
### Issue 创建完整流程
```
用户输入:
./gitlink-cli.exe issue +create --owner zzx-coder --repo gitlink-cli --title "Bug" --body "Fix it"
1. 参数解析
Args = {"owner": "zzx-coder", "repo": "gitlink-cli", "title": "Bug", "body": "Fix it"}
2. 创建 RuntimeContext
ctx = RuntimeContext{
Client: httpClient,
Owner: "zzx-coder",
Repo: "gitlink-cli",
Format: "json",
Args: Args
}
3. API 调用
path = "/v1/zzx-coder/gitlink-cli/issues.json"
body = {
"subject": "Bug",
"description": "Fix it",
"status_id": 1,
"priority_id": 2
}
4. HTTP 请求
POST https://www.gitlink.org.cn/api/v1/zzx-coder/gitlink-cli/issues.json
Authorization: Bearer {token}
Content-Type: application/json
5. 响应处理
解析 JSON → Envelope{OK: true, Data: {...}}
6. 输出格式化
格式化为 JSON/Table → 输出到终端
```
### Wiki 创建完整流程
```
用户输入:
./gitlink-cli.exe wiki +create --owner zzx-coder --repo gitlink-cli --title "Test" --content "# Test"
1. 第一次 API 调用 (获取 Project ID)
GET https://www.gitlink.org.cn/api/zzx-coder/gitlink-cli/detail.json
响应: {"project_id": 12345}
2. Project ID 缓存
projectIDCache.Store("zzx-coder/gitlink-cli", "12345")
3. 第二次 API 调用 (创建 Wiki)
POST https://gateway.gitlink.org.cn/api/wiki/open/createWiki
body = {
"owner": "zzx-coder",
"repo": "gitlink-cli",
"projectId": 12345,
"pageName": "Test",
"content_base64": "I1BUV\Q==" // Base64 编码
}
4. Gateway 响应处理
解析 {"code": 200, "data": {...}} → 提取 data 部分

View File

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(python -c \"import docx; print\\(''ok''\\)\")",
"Bash(pip install:*)",
"Bash(python generate_report.py)"
]
}
}

View File

@ -0,0 +1,535 @@
# GitLink CLI 命令系统优化报告
## 一、参数设计
### 问题:枚举参数无校验,错误信息延迟到 API 调用才暴露
命令 `pr +merge --method` 接受 `merge`、`rebase`、`squash` 三种值,`issue +list --state` 接受 `open`、`closed`、`all`,但输入非法值时没有任何拦截。用户输入 `--method unknown` 会直接发往 API等到服务端返回 422 才知道参数错了,反馈链路长。
**修改前** — 校验逻辑散落在 Run 函数内部:
```go
// shortcuts/issue/issue.go
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
}
// 枚举值定义在 Usage 文本里(给人看),校验在 Run() 里手写(给机器做)
// 两者没有关联,容易出现文本和代码不同步
func normalizeIssueStatus(state string) (interface{}, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open":
return 1, nil
case "closed":
return 5, nil
default:
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
}
}
```
**问题总结**:每个有枚举值的 flag 都需要在 `Run()` 里手写一个 validate 函数,重复劳动且容易遗漏;枚举值在 Usage 文本里写一遍、在代码里再写一遍,两处不同步时有发生。
**解决方案**:在 `Flag` 结构体中增加 `Choices` 字段,框架层在 parse 阶段自动校验,同时将可选值追加到 `--help` 输出。
**修改后**
```go
// ① 结构体扩展 — shortcuts/common/types.go
type Flag struct {
Name string
Short string
Usage string
Required bool
Default string
Bool bool
Choices []string // 新增:枚举校验
Validate func(value string) error // 新增:自定义校验函数
}
// ② 框架自动校验 — shortcuts/common/runner.go
for _, f := range s.Flags {
val := getFlagValue(cmd, f)
// Choices 枚举校验
if len(f.Choices) > 0 && val != "" && val != "false" {
if !contains(f.Choices, val) {
return clierrors.InputError(
fmt.Sprintf("invalid value %q for --%s", val, f.Name),
fmt.Sprintf("有效值: %s。运行 'gitlink-cli %s --help' 查看用法。",
strings.Join(f.Choices, ", "), commandName),
).WithCommand(commandName)
}
}
// 自定义校验
if f.Validate != nil && val != "" {
if err := f.Validate(val); err != nil {
return clierrors.InputError(
fmt.Sprintf("invalid --%s: %v", f.Name, err),
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
).WithCommand(commandName)
}
}
}
// ③ 命令行只需声明 Choices — shortcuts/pr/pr.go
Flags: []common.Flag{
{Name: "method", Short: "m", Usage: "Merge method", Default: "merge",
Choices: []string{"merge", "rebase", "squash"}},
}
```
Choices 声明后,无需再写校验函数,`--help` 也会自动追加 `[merge|rebase|squash]`
---
### 问题:跨参数约束靠手写 fmt.Errorf格式不统一
`issue +update` 要求 "至少提供 --title、--body 或 --state 中的一个"`repo +update` 要求 "至少提供 --description 或 --private 中的一个"。这类跨参数约束都散落在 `Run()` 里用 `fmt.Errorf` 写死,每种错误格式各异、中英文混杂。
**修改前**
```go
// shortcuts/issue/issue.go — 在 Run() 内部手写校验
if title == "" && description == "" && state == "" {
return fmt.Errorf("at least one of --title, --body, or --state is required")
}
// shortcuts/repo/repo.go — 同样手写,格式不同
if len(body) == 0 {
return fmt.Errorf("at least one of --description, --private is required")
}
```
**解决方案**:在 `Shortcut` 结构体中增加 `Validate` 字段,支持声明式跨参数校验;同时引入 `clierrors.InputError` 统一错误格式(英文技术消息 + 中文操作建议)。
**修改后**
```go
// ① Shortcut 结构体新增 Validate 字段 — shortcuts/common/types.go
type Shortcut struct {
Name string
Description string
Flags []Flag
Validate func(args map[string]string) error // 新增:跨参数校验
Run func(ctx *RuntimeContext) error
}
// ② MountShortcut 中自动执行 — shortcuts/common/runner.go
if s.Validate != nil {
if err := s.Validate(flagValues); err != nil {
return clierrors.InputError(
err.Error(),
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
).WithCommand(commandName)
}
}
// ③ 命令中统一使用 InputError — shortcuts/issue/issue.go
if title == "" && description == "" && state == "" {
return clierrors.InputError(
"at least one of --title, --body, or --state is required",
"至少需要提供 --title、--body 或 --state 中的一个参数",
).WithCommand(ctx.CommandName)
}
```
---
## 二、输出格式
### 问题:默认格式与帮助文本不一致
`cmd/root.go``--format` 帮助文本写明 "default: table",但 `shortcuts/common/types.go``NewRuntimeContext` 的代码默认值是 `"json"`。用户不传 `--format` 时拿到的是 JSON 而非表格。
**修改前**
```go
// cmd/root.go — 帮助文本说 "default: table"
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "",
"Output format: json, table, yaml (default: table)")
// shortcuts/common/types.go — 代码实际默认 json
format := cmdutil.Format
if format == "" {
format = "json"
}
```
**解决方案**:将代码默认值改为 `"table"`,同时将 persistent flag 的默认值参数从空字符串改为 `"table"`,让 cobra 显示的默认值与实际行为一致。
**修改后**
```go
// cmd/root.go — 默认值显式化为 "table"
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "table",
"Output format: json, table, yaml")
// shortcuts/common/types.go — 代码默认值与帮助文本一致
format := cmdutil.Format
if format == "" {
format = "table"
}
```
---
### 问题:表格输出无列过滤、长文本被硬截断、无可读性增强
现状表格渲染时列全量输出、复杂值JSON 嵌套、长文本)在 60 字符处硬截断后加 `...`、无色彩区分。用户想只看 id + title 两列也要扛着十几列的输出;想看完整描述被 `...` 截断无能为力。
**修改前**
```go
// internal/output/formatter.go — 截断硬编码,无列过滤
func formatValue(v interface{}) string {
// ...
if len(s) > 60 {
return s[:57] + "..." // 硬截断,不可配置
}
return s
}
func Print(envelope *Envelope, format string) error {
// 无任何渲染选项
return PrintTo(os.Stdout, envelope, format)
}
```
**解决方案**:引入 `PrintOptions` 结构体,支持 `Columns`(列过滤)、`NoTruncate`(关闭截断)、`UseColor`(彩色表头);新增 3 个全局 persistent flag`--columns`、`--no-truncate`、`--no-color`);通过 `RuntimeContext` 透明传递到所有输出调用。
**修改后**
```go
// ① PrintOptions 结构体 — internal/output/formatter.go
type PrintOptions struct {
Columns []string // 要显示的列nil = 全部
NoTruncate bool // 禁用 60 字符截断
UseColor bool // 启用 ANSI 颜色
}
func PrintWithOpts(envelope *Envelope, format string, opts PrintOptions) error {
// ...
case "table":
return printTableOpts(w, envelope, opts) // 传递 opts
}
// ② 列过滤实现
func filterColumns(all, wanted []string) []string {
wantedSet := make(map[string]bool, len(wanted))
for _, w := range wanted { wantedSet[w] = true }
result := make([]string, 0, len(wanted))
for _, h := range all {
if wantedSet[h] { result = append(result, h) }
}
return result
}
// ③ 可配置截断
func formatValueOpts(v interface{}, noTruncate bool) string {
if !noTruncate && len(s) > 60 {
return s[:57] + "..."
}
return s
}
// ④ 彩色表头(仅终端且 --no-color 未设置)
func isTerminal(w io.Writer) bool {
if f, ok := w.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}
// ⑤ RuntimeContext 无缝传递 — shortcuts/common/types.go
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
opts := output.PrintOptions{
NoTruncate: ctx.NoTruncate,
UseColor: !ctx.NoColor,
}
if ctx.Columns != "" {
// "id,title,state" → []string{"id", "title", "state"}
for _, p := range strings.Split(ctx.Columns, ",") {
p = strings.TrimSpace(p)
if p != "" { opts.Columns = append(opts.Columns, p) }
}
}
return output.PrintWithOpts(env, ctx.Format, opts)
}
// ⑥ 全局 flag 注册 — cmd/root.go
rootCmd.PersistentFlags().BoolVar(&cmdutil.NoTruncate, "no-truncate", false,
"Disable value truncation in table output")
rootCmd.PersistentFlags().StringVar(&cmdutil.Columns, "columns", "",
"Columns to show in table output (comma-separated)")
rootCmd.PersistentFlags().BoolVar(&cmdutil.NoColor, "no-color", false,
"Disable colored output")
```
用法:
```
gitlink-cli issue +list --columns id,subject,status
gitlink-cli issue +list --no-truncate
gitlink-cli issue +list --no-color
```
---
## 三、错误提示
### 问题中英文混用fmt.Errorf 不被框架识别
现状:各快捷命令中的错误用 `fmt.Errorf` 随意构造,中文和英文混用。`fmt.Errorf` 生成的错误不是 `CLIError` 类型,不被 `TryPrintError` 识别,只能走 `cmd.Execute` 的 stderr 兜底输出,无法享受 envelope 结构化错误格式。
**修改前** — 同一项目中三种不同风格:
```go
// 风格 A中文 — shortcuts/issue/issue.go
return fmt.Errorf("获取 Issue 列表失败: %w", err)
return fmt.Errorf("创建 Issue 失败: %w", err)
// 风格 B英文 — shortcuts/repo/repo.go
return fmt.Errorf("failed to list members for %s/%s: %w", ctx.Owner, ctx.Repo, err)
return fmt.Errorf("cannot determine current user login")
// 风格 C中英混合 — shortcuts/pr/pr.go
return fmt.Errorf("获取 PR 列表失败: %w", err)
return fmt.Errorf("添加 PR 评论失败: %w", err)
```
**问题根源**:没有统一的错误构造入口,开发者各自手写 `fmt.Errorf`
**解决方案**:新增 `OpError` 构造函数,入参只需动词和资源名,自动生成英文 `Message`(给脚本 / jq 解析)和中文 `Suggestion`(给用户阅读),且返回 `*CLIError` 类型可被框架自动识别为 envelope 格式。
**修改后**
```go
// ① 统一构造函数 — internal/errors/errors.go
func OpError(kind ErrorKind, op, resource string, cause error) *CLIError {
msg := fmt.Sprintf("failed to %s %s", op, resource)
sugg := opSuggestion(op, resource)
return Wrap(kind, msg, sugg, cause)
}
func opSuggestion(op, resource string) string {
suggestions := map[string]string{
"list": "获取列表失败,请检查参数或网络连接,稍后重试",
"create": "创建失败,请检查必填参数是否正确(--help 查看用法)或 API 权限",
"view": "查看失败,请确认资源 ID 是否存在",
"update": "更新失败,请检查参数值或资源 ID 是否正确",
"delete": "删除失败,请确认资源是否存在或是否有删除权限",
"close": "关闭失败,请确认资源是否存在或已被关闭",
"merge": "合并失败,请检查是否有冲突或权限不足",
"comment": "添加评论失败,请确认资源是否存在",
"fork": "Fork 失败,请确认仓库存在或有权限",
"invite": "邀请失败,请确认用户 ID 是否正确",
"remove": "移除失败,请确认成员存在",
}
if s, ok := suggestions[op]; ok { return s }
return "操作失败,请稍后重试或运行 --help 查看用法"
}
// ② 命令中一行调用 — shortcuts/issue/issue.go
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).
WithCommand(ctx.CommandName)
}
// ③ 框架自动识别 — shortcuts/common/error_print.go
// TryPrintError 检测到 *CLIError 类型后自动输出为结构化 JSON
var cliErr *clierrors.CLIError
if errors.As(err, &cliErr) {
env := output.ErrorEnvelope(kindToCode(cliErr.Kind), cliErr.Message, cliErr.Suggestion)
_ = output.Print(env, format)
return true
}
```
输出效果:
```json
{
"ok": false,
"error": {
"code": 500,
"message": "failed to list issues",
"suggestion": "获取列表失败,请检查参数或网络连接,稍后重试"
}
}
```
`message` 用英文保证脚本可解析,`suggestion` 用中文直接给人看。
---
## 四、帮助文档
### 问题Shortcut 无详细帮助Group 命令只有一行描述
现状:`Shortcut` 结构体只有 `Description` 一个短描述字段,没有 `Long`(详细说明)和 `Example`(使用示例)。`--help` 输出只有一个命令行和 flag 列表,用户看不到用法示例。
Group 命令(`repo`、`issue`、`pr` 等同理15 个 group 全部只有一行 `Short`
```go
descriptions := map[string]string{
"repo": "Repository operations",
"pr": "Pull request operations",
// 14 个 group 完全一样...
}
```
**修改前**
```go
// shortcuts/common/types.go — 结构体缺少 Long 和 Example
type Shortcut struct {
Name string
Description string // 仅此一个描述字段
Flags []Flag
Run func(ctx *RuntimeContext) error
}
// shortcuts/common/runner.go — cobra 命令只有 Use 和 Short
cmd := &cobra.Command{
Use: "+" + s.Name,
Short: s.Description,
RunE: /* ... */,
}
// shortcuts/register.go — group 命令也只有 Short
groupCmd := &cobra.Command{
Use: name,
Short: descriptions[name],
}
```
**问题总结**`--help` 输出仅包含一句话描述 + 参数列表,没有用法示例。对于 `pr +merge` 这类参数较多的命令,用户无从得知 `--method` 有哪些可选值、典型调用怎么写。
**解决方案**`Shortcut` 结构体新增 `Long``Example` 字段,挂载到 cobra 的 `Long``Example``register.go` 中 15 个 Group 命令全部补充 Long 描述和使用示例;新增 `completion` 子命令支持 4 种 shell 的自动补全。
**修改后**
```go
// ① Shortcut 结构体扩展 — shortcuts/common/types.go
type Shortcut struct {
Name string
Description string
Long string // 新增:详细帮助文本
Example string // 新增:使用示例
Flags []Flag
Run func(ctx *RuntimeContext) error
}
// ② MountShortcut 挂载到 cobra — shortcuts/common/runner.go
cmd := &cobra.Command{
Use: "+" + s.Name,
Short: s.Description,
Long: s.Long, // 新增
Example: s.Example, // 新增
RunE: /* ... */,
}
// ③ 命令中填写 — shortcuts/pr/pr.go
{
Name: "merge",
Description: "Merge a pull request",
Example: " gitlink-cli pr +merge --id 42\n" +
" gitlink-cli pr +merge --id 42 --method rebase\n" +
" gitlink-cli pr +merge --id 42 --method squash --dry-run",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "method", Short: "m", Usage: "Merge method", Default: "merge",
Choices: []string{"merge", "rebase", "squash"}},
},
}
// ④ Group 命令补充 Long 和 Example — shortcuts/register.go
type groupInfo struct {
Short string
Long string
Example string
}
infos := map[string]groupInfo{
"pr": {
Short: "Pull request operations",
Long: "Manage pull requests: list, create, view, merge, close, review, and view changed files.",
Example: " gitlink-cli pr +list --state open\n" +
" gitlink-cli pr +create --title \"Fix login\" --head feat-branch\n" +
" gitlink-cli pr +merge --id 42",
},
// 其余 14 个 group 同上
}
groupCmd := &cobra.Command{
Use: name,
Short: info.Short,
Long: info.Long,
Example: info.Example,
}
```
### 问题:无 Shell 自动补全
cobra 框架原生支持 bash/zsh/fish/powershell 的补全生成,但 gitlink-cli 没有暴露这个能力。用户需要记忆 15 个 group 和 80+ 个子命令的完整名称。
**解决方案**:新增 `completion` 子命令。
```go
// cmd/root.go
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script",
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
RunE: func(cmd *cobra.Command, args []string) error {
shell := "bash"
if len(args) > 0 { shell = args[0] }
switch shell {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("unsupported shell: %s (valid: bash, zsh, fish, powershell)", shell)
}
},
}
```
启用:
```bash
source <(gitlink-cli completion bash) # Bash
source <(gitlink-cli completion zsh) # Zsh
gitlink-cli completion fish | source # fish
gitlink-cli completion powershell | Out-String | Invoke-Expression # PowerShell
```
---
## 五、影响范围
| 文件 | 改动性质 |
|------|----------|
| `cmd/cmdutil/globals.go` | 新增 NoTruncate / Columns / NoColor 全局变量 |
| `cmd/root.go` | 新增 3 个 persistent flag + completion 子命令 + format 默认值修正 |
| `shortcuts/common/types.go` | Shortcut / Flag / RuntimeContext 结构体扩展6 个新增字段) |
| `shortcuts/common/runner.go` | Choices/Validate 校验逻辑 + Long/Example 挂载 + Choices→Usage 自动追加 |
| `internal/output/formatter.go` | PrintOptions + PrintWithOpts + 列过滤 + 去截断 + 彩色表头 |
| `internal/errors/errors.go` | OpError 统一构造函数 |
| `shortcuts/register.go` | 15 个 Group 命令补充 Long / Example |
| `shortcuts/issue/issue.go` | 错误统一 + Choices(state) + Long/Examplelist, create, view, close, update |
| `shortcuts/pr/pr.go` | 错误统一 + Choices(state, method) + Long/Examplelist, create, merge |
| `shortcuts/repo/repo.go` | 错误统一 + Choices(category) + Long/Examplelist, create, update |
所有新增字段零值安全,现有 80+ Shortcut 无需修改即可编译。`output.Print()` 签名不变,内部包装为 `PrintWithOpts`。Workflow 脚本显式使用 `--format json`,不受默认格式变化影响。
验证:`go build` 通过,`go vet` 通过(仅 pre-existing milestone 包有构建错误),`go test ./...` 7 个测试包全部通过。

View File

@ -0,0 +1,224 @@
# Board (看板) Shortcut 修改笔记
## 一、功能概述
新增 `board` 快捷命令组,提供项目看板的查看、筛选、任务操作和统计分析功能。
| 命令 | 类型 | 说明 |
|------|------|------|
| `board +view` | 读 | 按状态分组显示看板全貌 |
| `board +columns` | 读 | 列出各状态列及 issue 数量 |
| `board +issues` | 读 | 按状态/指派人/优先级筛选任务 |
| `board +move` | 写 | 移动任务状态(支持 dry-run |
| `board +assign` | 写 | 指派任务给用户(支持 dry-run |
| `board +stats` | 读 | 完成率、工作负载、瓶颈分析 |
---
## 二、解决思路
### 2.1 API 选型
最初计划使用 PM 看板 API (`GET /pm/dashboards?project_id=...`),但实际测试发现该端点不存在(返回 HTML 页面。skill 参考文档 `pm-kanban.md` 中的 API 描述有误。
**最终方案**:基于已有的 issue list API (`GET /v1/{owner}/{repo}/issues`) 实现看板视图。每个 issue 带有 `status_id`、`status_name`、`assigners`、`priority` 等字段,按 `status_id` 分组即可构建看板。
### 2.2 看板列设计
`status_id` 映射为 5 列:
| status_id | 列名 |
|-----------|------|
| 1 | 待处理 |
| 2 | 进行中 |
| 3 | 已解决 |
| 5 | 已关闭 |
| 6 | 已拒绝 |
### 2.3 写操作复用
`board +move``board +assign` 复用 issue PATCH API (`PATCH /v1/{owner}/{repo}/issues/{id}`)。关键点PATCH body 必须包含 `subject` + `description`,否则会被清空。
---
## 三、代码指令
### 3.1 文件变更
```
新建shortcuts/board/board.go # 6 个命令 + 辅助函数(约 580 行)
修改shortcuts/register.go # 添加 board 组的 import + 注册
```
### 3.2 编译部署
```bash
# 编译
cd /c/Users/Lenovo/Desktop/soft运维/gitlink-cli
go install .
# 同步到 npmnpm shim 调用同目录的 gitlink-cli.exe
cp ~/go/bin/gitlink-cli.exe ~/AppData/Roaming/npm/node_modules/@gitlink-ai/cli/bin/gitlink-cli.exe
```
### 3.3 核心辅助函数
```go
// fetchAllIssuesWithState — 分页获取所有 issue
func fetchAllIssuesWithState(ctx *common.RuntimeContext, state string) ([]issueItem, error)
// groupByStatus — 按 status_id 分组
func groupByStatus(issues []issueItem) map[int][]issueItem
// fetchExistingIssue — 获取 issue 的 subject+descriptionPATCH 前必须调用)
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (string, string, error)
// resolveUserID — 用户名转用户 ID调用 GET /users/{login}
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error)
```
### 3.4 register.go 变更
```go
// 新增 import
"github.com/gitlink-org/gitlink-cli/shortcuts/board"
// groups map 新增
"board": board.Shortcuts(),
// infos map 新增
"board": {
Short: "Board (kanban) operations",
Long: "View and manage kanban boards: ...",
Example: " gitlink-cli board +view\n ...",
},
```
---
## 四、输出参考
### 4.1 board --help
```
View and manage kanban boards: view board layout, list columns, filter issues,
move tasks between columns, assign people, and analyze workload.
Usage:
gitlink-cli board [command]
Available Commands:
+assign Assign an issue to someone
+columns List status columns with issue counts
+issues List issues with optional filters
+move Move an issue to a different status
+stats Show board analytics and statistics
+view View kanban board layout
```
### 4.2 board +view
```json
{
"ok": true,
"data": {
"repository": "zzx-coder/gitlink-cli",
"total_issues": 44,
"columns": [
{
"status_id": 1,
"status_name": "待处理",
"issue_count": 0,
"issues": []
},
{
"status_id": 2,
"status_name": "进行中",
"issue_count": 0,
"issues": []
},
{
"status_id": 3,
"status_name": "已解决",
"issue_count": 44,
"issues": [
{"number": 16, "id": 143115, "subject": "fix:增加相关test.go文件", "priority": "normal"},
{"number": 25, "id": 143700, "subject": "fix: 统一错误提示优化", "priority": "normal"}
]
},
{"status_id": 5, "status_name": "已关闭", "issue_count": 0, "issues": []},
{"status_id": 6, "status_name": "已拒绝", "issue_count": 0, "issues": []}
]
}
}
```
### 4.3 board +columns
```json
{
"ok": true,
"data": [
{"status_id": 1, "status_name": "待处理", "issue_count": 0},
{"status_id": 2, "status_name": "进行中", "issue_count": 0},
{"status_id": 3, "status_name": "已解决", "issue_count": 44},
{"status_id": 5, "status_name": "已关闭", "issue_count": 0},
{"status_id": 6, "status_name": "已拒绝", "issue_count": 0}
]
}
```
### 4.4 board +issues --status resolved --limit 3
```json
{
"ok": true,
"data": [
{"number": 16, "id": 143115, "subject": "fix:增加相关test.go文件", "status": "已解决", "priority": "normal", "assigned_to": ""},
{"number": 25, "id": 143700, "subject": "fix: 统一错误提示优化", "status": "已解决", "priority": "normal", "assigned_to": ""},
{"number": 2, "id": 142700, "subject": "新增issue批量操作", "status": "已解决", "priority": "normal", "assigned_to": ""}
]
}
```
### 4.5 board +move --number 16 --status in-progress --dry-run
```
[dry-run] Move issue #16 to status "in-progress"
Proceed? [y/N] Aborted.
```
### 4.6 board +stats
```json
{
"ok": true,
"data": {
"repository": "zzx-coder/gitlink-cli",
"total_issues": 44,
"completion_rate": 100,
"column_breakdown": [
{"status_name": "待处理", "count": 0, "percentage": 0},
{"status_name": "进行中", "count": 0, "percentage": 0},
{"status_name": "已解决", "count": 44, "percentage": 100},
{"status_name": "已关闭", "count": 0, "percentage": 0},
{"status_name": "已拒绝", "count": 0, "percentage": 0}
],
"assignee_load": [
{"assignee": "(unassigned)", "count": 44}
],
"bottleneck": "已解决"
}
}
```
---
## 五、踩坑记录
| 问题 | 原因 | 解决 |
|------|------|------|
| `unknown command "board"` | npm shim (`cli.js`) 调用的是 npm 包内的 `gitlink-cli.exe`,不是 `go/bin` 的 | 编译后同步覆盖 npm 包内的 exe |
| `failed to parse dashboard data` | `/pm/dashboards` API 端点不存在,返回 HTML | 改用 issue list API + 客户端分组 |
| `json: cannot unmarshal string` | API 返回 HTML 字符串而非 JSON 对象 | 同上,放弃 PM API |

View File

@ -0,0 +1,386 @@
"""生成子赛题一报告 Word 文档"""
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
doc = Document()
# === 样式设置 ===
style = doc.styles['Normal']
style.font.name = '宋体'
style.font.size = Pt(12)
style.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
def add_heading(text, level=1):
h = doc.add_heading(text, level=level)
for run in h.runs:
run.font.name = '黑体'
run.element.rPr.rFonts.set(qn('w:eastAsia'), '黑体')
return h
def add_para(text, bold=False):
p = doc.add_paragraph()
run = p.add_run(text)
run.bold = bold
run.font.name = '宋体'
run.font.size = Pt(12)
run.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
return p
def add_table(headers, rows):
table = doc.add_table(rows=1, cols=len(headers), style='Table Grid')
table.alignment = WD_TABLE_ALIGNMENT.CENTER
for i, h in enumerate(headers):
cell = table.rows[0].cells[i]
cell.text = h
for p in cell.paragraphs:
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in p.runs:
run.bold = True
run.font.size = Pt(10)
for row_data in rows:
row = table.add_row()
for i, val in enumerate(row_data):
row.cells[i].text = str(val)
for p in row.cells[i].paragraphs:
for run in p.runs:
run.font.size = Pt(10)
return table
def add_code(text):
p = doc.add_paragraph()
run = p.add_run(text)
run.font.name = 'Consolas'
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
return p
# ============================================================
# 正文
# ============================================================
add_heading('第二章 子赛题一:增强与完善 GitLink-CLI 能力', level=1)
# 2.1
add_heading('2.1 任务目标与整体思路', level=2)
add_para(
'子赛题一的定位是扩展 gitlink-cli 的功能覆盖面和使用体验。本项目选择的切入方向有三个:'
'新增 Shortcut 命令(填补功能空白)、优化命令系统框架(提升开发效率和用户体验)、'
'补全 Raw API 封装(对齐 OpenAPI 接口)。'
)
add_para(
'整体设计思路是"先框架后业务":先完善底层的 Shortcut 抽象层(参数校验、输出格式、错误处理、帮助文档),'
'再基于这个框架快速新增业务命令。这样做的好处是,新增的命令天然继承框架能力'
'dry-run、枚举校验、结构化错误输出不需要每个命令重复造轮子。'
)
# 2.2
add_heading('2.2 三层命令体系架构', level=2)
add_para('gitlink-cli 采用三层命令体系:')
add_table(
['层级', '名称', '说明', '示例'],
[
['第一层', 'Shortcuts+前缀命令)', '人类和 AI Agent 直接使用,参数精简,结构化输出', 'issue +list --state open'],
['第二层', 'API Commands元数据驱动', '自动从 OpenAPI spec 生成,覆盖所有 API 端点', 'api get /v1/{owner}/{repo}/issues'],
['第三层', 'Raw APIHTTP 原始调用)', '完全透传,适合调试和边缘场景', 'raw GET /v1/owner/repo/issues'],
]
)
add_para('')
add_para(
'本次工作主要落在第一层Shortcuts同时涉及底层框架的增强。新增了 board、file、milestone '
'三个 Shortcut 模块,以及对 issue、pr、repo 等已有模块的扩展。'
)
add_para(
'架构说明cmd/root.go 是 cobra 根命令,通过 shortcuts/register.go 挂载 15 个命令组,'
'每个组的子命令通过 common/runner.go 的 MountShortcut() 自动注册 flags、校验逻辑和错误处理。'
'业务模块只需定义 Shortcut 结构体数组。'
)
# 2.3
add_heading('2.3 新增 Shortcut 命令', level=2)
# 2.3.1 Wiki
add_heading('2.3.1 Wiki 管理命令', level=3)
add_para('新增 wiki 命令组,提供 6 个子命令:')
add_table(
['命令', '功能', 'DryRun'],
[
['wiki +list', '列出所有 Wiki 页面', '-'],
['wiki +view', '查看指定页面内容', '-'],
['wiki +create', '创建 Wiki 页面', 'Yes'],
['wiki +update', '更新 Wiki 页面', 'Yes'],
['wiki +delete', '删除 Wiki 页面(带二次验证)', 'Yes'],
['wiki +lint', '检查 Wiki 内容质量', '-'],
]
)
add_para('技术要点:')
add_para('1Wiki API 走网关gateway.gitlink.org.cn/api而非主 API。')
add_para('2内容使用 base64 编码传输outputWithDecodedContent() 自动解码,节省 AI Agent token。')
add_para('3+delete 有删除后验证机制GET 确认页面真的被删除了。')
add_para('4+lint 检查项:空内容、标题层级、内容过短、链接有效性、图片引用。')
# 2.3.2 Webhook
add_heading('2.3.2 Webhook 配置命令', level=3)
add_para('新增 webhook 命令组,提供 7 个子命令:')
add_table(
['命令', '功能', 'DryRun'],
[
['webhook +list', '列出所有 webhook', '-'],
['webhook +create', '创建 webhook', 'Yes'],
['webhook +update', '更新 webhook智能获取当前 URL', 'Yes'],
['webhook +delete', '删除 webhook双重验证', 'Yes'],
['webhook +test', '测试 webhook 触发', '-'],
['webhook +info', '查看 webhook 详情', '-'],
['webhook +events', '列出支持的事件类型', '-'],
]
)
add_para('支持 11 种事件类型push, pull_request, issue, issue_assign, issue_comment, pull_request_assign, pull_request_comment, merge_request, repository, branch, tag。')
# 2.3.3 Board
add_heading('2.3.3 项目看板Board', level=3)
add_para('新增 board 命令组,提供 6 个子命令:')
add_table(
['命令', '功能', '实现方式'],
[
['board +view', '按状态分组显示看板全貌', '基于 issue list API + 客户端按 status_id 分组'],
['board +columns', '列出各状态列及 issue 数量', '同上'],
['board +issues', '按状态/指派人/优先级筛选任务', '同上,增加过滤逻辑'],
['board +move', '移动任务状态', '复用 issue PATCH API'],
['board +assign', '指派任务给用户', '先 resolveUserID再 PATCH'],
['board +stats', '完成率、工作负载、瓶颈分析', '统计聚合'],
]
)
add_para(
'设计决策:最初计划使用 PM 看板 API/pm/dashboards但实际测试发现该端点不存在。'
'最终方案基于已有的 issue list API 实现,按 status_id 映射为 5 列'
'(待处理/进行中/已解决/已关闭/已拒绝)。'
)
# 2.3.4 Milestone
add_heading('2.3.4 里程碑管理Milestone', level=3)
add_para('新增 milestone 命令组,提供 6 个子命令:')
add_table(
['命令', '功能'],
[
['milestone +list', '列出里程碑,支持按状态筛选和排序'],
['milestone +create', '创建里程碑'],
['milestone +view', '查看里程碑详情及关联 Issue'],
['milestone +update', '更新里程碑(自动获取当前值,只覆盖指定字段)'],
['milestone +delete', '删除里程碑(先 GET 获取必填字段再 DELETE'],
['milestone +status', '打开或关闭里程碑'],
]
)
# 2.3.5 File
add_heading('2.3.5 文件操作File', level=3)
add_para('新增 file 命令组,提供 11 个子命令:')
add_table(
['命令', '功能', 'DryRun'],
[
['file +ls', '列出根目录文件', '-'],
['file +tree', '查看子目录/文件详情', '-'],
['file +read', '读取文件内容', '-'],
['file +readme', '读取 README', '-'],
['file +search', '按文件名搜索', '-'],
['file +create', '创建文件(自动 base64 编码)', 'Yes'],
['file +update', '更新文件(自动获取 SHA', 'Yes'],
['file +delete', '删除文件(自动获取 SHA', 'Yes'],
['file +batch', '批量创建/更新/删除文件', 'Yes'],
['file +commits', '提交历史', '-'],
['file +diff', '查看 commit diff', '-'],
]
)
# 2.4
add_heading('2.4 优化部分', level=2)
add_heading('2.4.1 参数设计优化', level=3)
add_para(
'问题:枚举参数无校验,错误信息延迟到 API 调用才暴露。'
'pr +merge --method 接受 merge/rebase/squash但输入非法值时直接发往 API'
'等到服务端返回 422 才知道参数错了。'
)
add_para('方案:在 Flag 结构体中增加 Choices 和 Validate 字段,框架层在 parse 阶段自动校验。')
add_code(
'type Flag struct {\n'
' Name string\n'
' Choices []string // 枚举校验\n'
' Validate func(value string) error // 自定义校验\n'
'}'
)
add_para('同时Choices 声明后 --help 会自动追加 [merge|rebase|squash],无需手动维护。')
add_para('跨参数约束Shortcut 结构体新增 Validate 字段,支持声明式校验(如"至少提供 --title、--body 或 --state 中的一个")。')
add_heading('2.4.2 输出格式优化', level=3)
add_para('问题:默认格式与帮助文本不一致(帮助说 table代码默认 json表格输出无列过滤、长文本被硬截断。')
add_para('方案:')
add_para('1修正默认值为 table。')
add_para('2引入 PrintOptions 结构体,支持 --columns列过滤、--no-truncate关闭截断、--no-color禁用彩色')
add_para('3通过 RuntimeContext 透明传递到所有输出调用。')
add_heading('2.4.3 错误提示优化', level=3)
add_para('问题中英文混用fmt.Errorf 不被框架识别,无法输出结构化错误。')
add_para('方案:新增 OpError 统一构造函数,入参只需动词和资源名,自动生成英文 Message给脚本解析和中文 Suggestion给用户阅读')
add_code('clierrors.OpError(clierrors.KindServer, "list", "issues", err)\n// 输出failed to list issues / 获取列表失败,请检查参数或网络连接')
add_heading('2.4.4 帮助文档优化', level=3)
add_para('问题Shortcut 只有短描述无使用示例15 个 Group 命令全部只有一行 Short。')
add_para('方案Shortcut 结构体新增 Long 和 Example 字段,挂载到 cobra 的对应字段15 个 Group 命令全部补充 Long 描述和使用示例;新增 completion 子命令支持 bash/zsh/fish/powershell 的自动补全。')
# 2.5
add_heading('2.5 批量操作能力增强', level=2)
add_heading('Issue 批量操作6 个)', level=3)
add_table(
['命令', '功能'],
[
['issue +batch-close', '批量关闭 Issue'],
['issue +batch-status', '批量修改状态'],
['issue +batch-priority', '批量修改优先级'],
['issue +batch-assign', '批量指派负责人'],
['issue +batch-label', '批量添加/移除标签'],
['issue +batch-create', '批量创建 Issue'],
]
)
add_heading('Repo 批量操作4 个)', level=3)
add_table(
['命令', '功能'],
[
['repo +batch-create', '批量创建仓库'],
['repo +batch-update', '批量更新仓库设置'],
['repo +batch-delete', '批量删除仓库'],
['repo +batch-member', '批量邀请/移除成员'],
]
)
add_heading('Issue 增强4 个元数据查询 + 3 个评论管理)', level=3)
add_table(
['命令', '功能'],
[
['issue +statuses', '获取所有可用的 Issue 状态'],
['issue +authors', '获取发布过 Issue 的用户列表'],
['issue +assigners', '获取可被指派的用户列表'],
['issue +priorities', '获取所有可用的优先级'],
['issue +comment-edit', '编辑 Issue 评论'],
['issue +comment-delete', '删除 Issue 评论'],
['issue +replies', '查看评论下的回复'],
]
)
add_heading('PR 增强6 个命令 + 2 个评论管理)', level=3)
add_table(
['命令', '功能'],
[
['pr +reopen', '重新打开已关闭的 PR'],
['pr +update', '更新 PR 标题/描述/分支'],
['pr +commits', '查看 PR 中的所有提交'],
['pr +versions', '查看 PR 版本历史'],
['pr +vdiff', '查看 PR 某版本的 diff'],
['pr +filesv1', '查看 PR 变更文件列表v1 API'],
['pr +comment-edit', '编辑 PR 审查评论'],
['pr +comment-delete', '删除 PR 审查评论'],
]
)
# 2.6
add_heading('2.6 跨平台兼容性与安装体验', level=2)
add_para('本项目支持 macOS、Linux、Windowsx64/arm64三个平台。')
add_para('安装方式:')
add_para('1一键安装脚本curl -sSL .../install.sh | bash自动检测平台和架构。')
add_para('2npm 安装npm install -g @gitlink-ai/clipostinstall 自动下载对应平台二进制。')
add_para('3源码编译go install . 后手动同步到 npm 包。')
add_para('Windows 特殊处理npm shimcli.js调用的是 npm 包内的 gitlink-cli.exe编译后需要同步覆盖。路径处理兼容 Windows 反斜杠。')
# 2.7
add_heading('2.7 Raw API 封装补全', level=2)
add_para('对齐 GitLink OpenAPI新封装的接口清单')
add_table(
['模块', '接口数', '涉及 API 端点'],
[
['File 操作', '11', 'entries, sub_entries, readme, files, create_file, update_file, delete_file, batch, commits, diff'],
['Issue 元数据', '4', 'issue_statues, issue_authors, issue_assigners, issue_priorities'],
['Milestone', '6', 'milestones CRUD + update_status'],
['PR 增强', '6', 'reopen, update, commits, versions, versions/diff, files(v1)'],
['评论管理', '5', 'issue journals CRUD + children_journals, PR journals CRUD'],
['合计', '32', '-'],
]
)
# 2.8
add_heading('2.8 单元测试与命令帮助文档', level=2)
add_heading('测试文件位置', level=3)
add_table(
['模块', '测试文件'],
[
['board', 'shortcuts/board/board_test.go'],
['file', 'shortcuts/file/file_test.go'],
['milestone', 'shortcuts/milestone/milestone_test.go'],
['wiki', 'shortcuts/wiki/wiki_test.go'],
['webhook', 'shortcuts/webhook/webhook_test.go'],
['issue', 'shortcuts/issue/issue_test.go, batch_test.go, batch_create_test.go'],
['pr', 'shortcuts/pr/pr_test.go'],
['repo', 'shortcuts/repo/batch_test.go, batch_delete_test.go'],
['common', 'shortcuts/common/runner_test.go'],
]
)
add_heading('测试方法', level=3)
add_para('使用 httptest.NewServer mock API构造 RuntimeContext 直接调用 Run 函数,验证输出和 PATCH body 内容。')
add_heading('帮助文档更新', level=3)
add_para('1所有 Shortcut 的 Long 和 Example 字段已填写。')
add_para('215 个 Group 命令补充了详细描述和使用示例。')
add_para('3新增 completion 子命令支持 4 种 shell 自动补全。')
# 2.9
add_heading('2.9 PR 提交记录与变更说明', level=2)
add_table(
['PR', '标题', '内容摘要', '状态'],
[
['#27', '实现场景1社区运营自动化', 'Webhook 接收器 + 部署 + systemd', '已合并'],
['-', 'feat(board): 新增项目看板 shortcut', '6 个看板命令 + 单元测试', '待提交'],
['-', 'feat(file): 新增文件操作模块', '11 个文件命令 + 单元测试', '待提交'],
['-', 'feat(milestone): 新增里程碑管理', '6 个里程碑命令 + 单元测试', '待提交'],
['-', 'feat(wiki): 新增 Wiki 管理命令', '6 个 Wiki 命令 + lint', '待提交'],
['-', 'feat(webhook): 新增 Webhook 配置', '7 个 Webhook 命令', '待提交'],
['-', 'refactor(shortcuts): 优化命令框架', 'Choices/Validate/PrintOptions/OpError', '待提交'],
['-', 'feat(issue): 批量操作 + 元数据查询 + 评论管理', '13 个命令', '待提交'],
['-', 'feat(pr): PR 增强 + 评论管理', '8 个命令', '待提交'],
]
)
add_para('')
add_para('涉及文件变更汇总:', bold=True)
add_table(
['文件', '改动性质'],
[
['shortcuts/common/types.go', '结构体扩展6 个新增字段)'],
['shortcuts/common/runner.go', '校验逻辑 + Long/Example 挂载'],
['shortcuts/register.go', '注册 board/file/milestone 模块'],
['internal/errors/errors.go', 'OpError 统一构造函数'],
['internal/output/formatter.go', 'PrintOptions + 列过滤 + 彩色表头'],
['cmd/root.go', '3 个 persistent flag + completion 子命令'],
['cmd/cmdutil/globals.go', '新增全局变量'],
['shortcuts/board/board.go', '新建6 个命令'],
['shortcuts/file/file.go', '新建11 个命令'],
['shortcuts/milestone/milestone.go', '新建6 个命令'],
['shortcuts/wiki/wiki.go', '新建6 个命令'],
['shortcuts/webhook/webhook.go', '新建7 个命令'],
['shortcuts/issue/issue.go', '新增 13 个命令'],
['shortcuts/pr/pr.go', '新增 8 个命令'],
]
)
add_para('')
add_para('验证结果go build 通过go test ./... 全部通过。')
# === 保存 ===
output_path = r'C:\Users\Lenovo\Desktop\soft运维\gitlink-cli\doc\任务一\子赛题一报告.docx'
doc.save(output_path)
print(f'报告已生成: {output_path}')

View File

@ -0,0 +1,645 @@
# Raw API 封装修改笔记
## 第一批:代码/文件操作模块file
### 1. file +ls — 根目录文件列表
**功能**:列出项目根目录下的文件和子目录。
**解决思路**:调用 `GET /{owner}/{repo}/entries` API支持 `--ref` 指定分支。
**代码指令**
```bash
gitlink-cli file +ls --owner <user> --repo <repo>
gitlink-cli file +ls --ref develop
```
**输出参考**
```json
{
"ok": true,
"data": {
"entries": [
{"name": "README.md", "path": "README.md", "type": "file", "sha": "abc123"},
{"name": "src", "path": "src", "type": "dir", "sha": "def456"}
],
"last_commit": {"message": "update readme", "author": {"name": "user"}},
"commits_count": 42
}
}
```
---
### 2. file +tree — 子目录/文件详情
**功能**:查看指定路径的目录结构或文件元信息。
**解决思路**:调用 `GET /{owner}/{repo}/sub_entries``filepath` 参数必填。
**代码指令**
```bash
gitlink-cli file +tree --path src/
gitlink-cli file +tree --path src/main.go --ref v1.0
```
**输出参考**
```json
{
"ok": true,
"data": {
"entries": {
"name": "main.go",
"path": "src/main.go",
"type": "file",
"size": 1234,
"sha": "abc123",
"commit": {"message": "add main.go"}
}
}
}
```
---
### 3. file +read — 读取文件内容
**功能**:读取指定文件的实际内容。
**解决思路**:调用 `GET /{owner}/{repo}/sub_entries`,响应的 `entries.content` 字段包含文件内容。
**代码指令**
```bash
gitlink-cli file +read --path README.md
gitlink-cli file +read --path go.mod --ref develop
```
**输出参考**
```json
{
"ok": true,
"data": {
"entries": {
"name": "README.md",
"path": "README.md",
"content": "# Project Title\n\nThis is the content...",
"sha": "abc123",
"size": 567
}
}
}
```
---
### 4. file +readme — 读取 README
**功能**:读取项目的 README 文件,支持子目录 README。
**解决思路**:调用 `GET /{owner}/{repo}/readme`,可选 `filepath``ref` 参数。
**代码指令**
```bash
gitlink-cli file +readme
gitlink-cli file +readme --path docs/
```
**输出参考**
```json
{
"ok": true,
"data": {
"name": "README.md",
"content": "# Project Title\n\nDescription...",
"sha": "abc123",
"encoding": "text"
}
}
```
---
### 5. file +search — 搜索文件
**功能**:按文件名关键词搜索。
**解决思路**:调用 `GET /{owner}/{repo}/files``search` 查询参数。
**代码指令**
```bash
gitlink-cli file +search --q "test"
gitlink-cli file +search --q ".go" --ref main
```
**输出参考**
```json
{
"ok": true,
"data": [
{"name": "main_test.go", "path": "main_test.go", "sha": "abc", "size": 234},
{"name": "util_test.go", "path": "util/util_test.go", "sha": "def", "size": 567}
]
}
```
---
### 6. file +create — 创建文件
**功能**:在指定分支创建新文件。
**解决思路**:调用 `POST /{owner}/{repo}/create_file`。注意 `content``base64_filepath` 都需要 Base64 编码CLI 自动处理。
**代码指令**
```bash
gitlink-cli file +create --path docs/new.md --content "# New Doc" --branch master --message "add doc"
```
**输出参考**
```json
{
"ok": true,
"data": {
"name": "new.md",
"sha": "abc123",
"size": 9,
"encoding": "base64",
"commit": {"message": "add doc", "author": {"name": "user"}}
}
}
```
---
### 7. file +update — 更新文件
**功能**:更新已有文件内容。如果未提供 `--sha`CLI 自动通过 sub_entries API 获取。
**解决思路**:调用 `PUT /{owner}/{repo}/update_file`。`content` 传明文(非 Base64需要文件当前 `sha`
**代码指令**
```bash
# 自动获取 sha
gitlink-cli file +update --path README.md --content "updated" --branch master --message "update readme"
# 手动指定 sha
gitlink-cli file +update --path README.md --content "updated" --branch master --sha abc123 --message "update"
```
**输出参考**
```json
{
"ok": true,
"data": {"status": 1, "message": "更新成功"}
}
```
---
### 8. file +delete — 删除文件
**功能**:删除指定文件。如果未提供 `--sha`CLI 自动获取。
**解决思路**:调用 `DELETE /{owner}/{repo}/delete_file`,需要 `sha`。body 参数(非 query
**代码指令**
```bash
gitlink-cli file +delete --path old-file.txt --branch master
gitlink-cli file +delete --path old.txt --branch master --sha abc123
```
**输出参考**
```json
{
"ok": true,
"data": {"status": 1, "message": "文件删除成功"}
}
```
---
### 9. file +batch — 批量提交文件
**功能**:在一个 commit 中同时创建/更新/删除多个文件。
**解决思路**:调用 `POST /v1/{owner}/{repo}/contents/batch`。`--files` 接收 JSON 数组,每个元素含 `action_type`、`file_path`、`content`。
**代码指令**
```bash
gitlink-cli file +batch --branch master --message "batch update" --files '[
{"action_type":"create","file_path":"a.txt","content":"hello"},
{"action_type":"update","file_path":"b.txt","content":"world"},
{"action_type":"delete","file_path":"c.txt"}
]'
```
**输出参考**
```json
{
"ok": true,
"data": {
"commit": {"sha": "abc123", "message": "batch update"},
"contents": [
{"name": "a.txt", "path": "a.txt", "sha": "def"},
{"name": "b.txt", "path": "b.txt", "sha": "ghi"}
]
}
}
```
---
### 10. file +commits — 提交历史
**功能**:查看项目的提交历史列表。
**解决思路**:调用 `GET /v1/{owner}/{repo}/commits`,支持分页和 `--ref` 过滤。
**代码指令**
```bash
gitlink-cli file +commits
gitlink-cli file +commits --ref main --page 2 --limit 10
```
**输出参考**
```json
{
"ok": true,
"data": [
{"sha": "abc123", "message": "fix bug", "author": {"name": "user"}, "authored_date": "2026-07-09"},
{"sha": "def456", "message": "add feature", "author": {"name": "user"}, "authored_date": "2026-07-08"}
]
}
```
---
### 11. file +diff — 提交 diff
**功能**:查看某次提交的文件变更 diff。
**解决思路**:调用 `GET /v1/{owner}/{repo}/commits/{sha}/diff`
**代码指令**
```bash
gitlink-cli file +diff --sha abc1234
```
**输出参考**
```json
{
"ok": true,
"data": {
"files": [
{"filename": "main.go", "status": "modified", "additions": 5, "deletions": 2, "patch": "@@ -10,3 +10,6 @@..."}
]
}
}
```
---
## 涉及文件
| 文件 | 操作 | 说明 |
|------|------|------|
| `shortcuts/file/file.go` | 新建 | file 模块11 个 shortcut |
| `shortcuts/register.go` | 修改 | 注册 file 模块 |
---
## 第二批Issue 增强 + PR 增强 + 评论管理21 命令)
### A. Issue 元数据查询4 命令)
### 12. issue +statuses — 疑修状态列表
**功能**:获取项目所有可用的 Issue 状态(新增、正在解决、已解决、关闭、拒绝)。
**解决思路**:调用 `GET /v1/{owner}/{repo}/issue_statues`,无参数。
**代码指令**
```bash
gitlink-cli issue +statuses
```
**输出参考**
```json
{"ok":true,"data":{"total_count":5,"statues":[{"id":1,"name":"新增"},{"id":2,"name":"正在解决"},{"id":3,"name":"已解决"},{"id":5,"name":"关闭"},{"id":6,"name":"拒绝"}]}}
```
---
### 13. issue +authors — 发布人列表
**功能**:获取项目中发布过 Issue 的用户列表。
**解决思路**:调用 `GET /v1/{owner}/{repo}/issue_authors`,支持 `--keyword` 搜索。
**代码指令**
```bash
gitlink-cli issue +authors
gitlink-cli issue +authors --keyword zhang
```
**输出参考**
```json
{"ok":true,"data":{"total_count":3,"authors":[{"id":1,"name":"张三","login":"zhangsan","type":"User"}]}}
```
---
### 14. issue +assigners — 负责人列表
**功能**:获取项目中可被指派为负责人的用户列表。
**解决思路**:调用 `GET /v1/{owner}/{repo}/issue_assigners`,支持 `--keyword` 搜索。
**代码指令**
```bash
gitlink-cli issue +assigners
```
---
### 15. issue +priorities — 优先级列表
**功能**:获取项目所有可用的 Issue 优先级。
**解决思路**:调用 `GET /v1/{owner}/{repo}/issue_priorities`,无参数。
**代码指令**
```bash
gitlink-cli issue +priorities
```
**输出参考**
```json
{"ok":true,"data":{"total_count":5,"priorities":[{"id":1,"name":"低"},{"id":2,"name":"正常"},{"id":3,"name":"高"},{"id":4,"name":"紧急"}]}}
```
---
### B. 里程碑管理6 命令)
### 16. milestone +list — 里程碑列表
**功能**:列出项目的里程碑,支持按状态筛选和排序。
**解决思路**:调用 `GET /v1/{owner}/{repo}/milestones`,支持 `--category`、`--keyword`、`--sort-by`、分页。
**代码指令**
```bash
gitlink-cli milestone +list
gitlink-cli milestone +list --category opening
gitlink-cli milestone +list --sort-by issues_count --limit 5
```
**输出参考**
```json
{"ok":true,"data":{"total_count":3,"opening_milestone_count":2,"closed_milestone_count":1,"milestones":[{"id":1,"name":"v1.0","status":"open","issues_count":10,"percent":60}]}}
```
---
### 17. milestone +create — 创建里程碑
**功能**:创建新里程碑。
**解决思路**:调用 `POST /v1/{owner}/{repo}/milestones`body 必填 `name`、`description`、`effective_date`。
**代码指令**
```bash
gitlink-cli milestone +create --name "v1.0" --description "First release" --date 2026-12-31
```
**输出参考**
```json
{"ok":true,"data":{"status":0,"message":"success"}}
```
---
### 18. milestone +view — 里程碑详情
**功能**:查看里程碑详情及其关联的 Issue 列表。
**解决思路**:调用 `GET /v1/{owner}/{repo}/milestones/{id}`,支持 `--category` 过滤 Issue 状态。
**代码指令**
```bash
gitlink-cli milestone +view --id 1
gitlink-cli milestone +view --id 1 --category opened
```
**输出参考**
```json
{"ok":true,"data":{"milestone":{"id":1,"name":"v1.0","percent":60},"total_issues_count":10,"issues":[{"id":42,"subject":"Fix login","status_name":"新增"}]}}
```
---
### 19. milestone +update — 更新里程碑
**功能**:更新里程碑的名称、描述或截止日期。自动获取当前值,只覆盖用户指定的字段。
**解决思路**:先 `GET` 获取当前里程碑,再 `PATCH` 提交修改后的完整数据。
**代码指令**
```bash
gitlink-cli milestone +update --id 1 --name "v1.0-rc1"
gitlink-cli milestone +update --id 1 --date 2027-01-31
```
---
### 20. milestone +delete — 删除里程碑
**功能**:删除指定里程碑。
**解决思路**API 要求 body 中包含 `name`/`description`/`effective_date`,先 GET 获取再 DELETE。
**代码指令**
```bash
gitlink-cli milestone +delete --id 1 --dry-run
```
---
### 21. milestone +status — 里程碑状态变更
**功能**:打开或关闭里程碑。
**解决思路**:调用 `POST /{owner}/{repo}/milestones/{id}/update_status`(注意无 `/v1/` 前缀)。
**代码指令**
```bash
gitlink-cli milestone +status --id 1 --status closed
gitlink-cli milestone +status --id 1 --status opening
```
---
### C. PR 增强6 命令)
### 22. pr +reopen — 重新打开 PR
**功能**:重新打开已关闭的 PR。
**解决思路**:调用 `POST /v1/{owner}/{repo}/pulls/{index}/reopen`,无 body。
**代码指令**
```bash
gitlink-cli pr +reopen --id 42
```
---
### 23. pr +update — 更新 PR
**功能**:更新 PR 的标题、描述、源分支或目标分支。自动获取当前值,只覆盖用户指定的字段。
**解决思路**:先 GET 获取当前 PR 数据,再 PUT 提交API 要求所有字段必填)。
**代码指令**
```bash
gitlink-cli pr +update --id 42 --title "New title"
gitlink-cli pr +update --id 42 --body "Updated description"
```
---
### 24. pr +commits — PR 提交列表
**功能**:查看 PR 中的所有提交。
**解决思路**:调用 `GET /{owner}/{repo}/pulls/{id}/commits`(无 `/v1/` 前缀)。
**代码指令**
```bash
gitlink-cli pr +commits --id 42
```
**输出参考**
```json
{"ok":true,"data":{"commits_count":3,"commits":[{"sha":"abc","message":"fix bug","author":{"name":"user"}}]}}
```
---
### 25. pr +versions — PR 版本列表
**功能**:查看 PR 的版本历史(每次 push 新提交会生成新版本)。
**解决思路**:调用 `GET /v1/{owner}/{repo}/pulls/{index}/versions`
**代码指令**
```bash
gitlink-cli pr +versions --id 42
```
---
### 26. pr +vdiff — PR 版本 diff
**功能**:查看 PR 某个版本的文件变更 diff。
**解决思路**:调用 `GET /v1/{owner}/{repo}/pulls/{index}/versions/{version_id}/diff`,支持 `--filepath` 过滤。
**代码指令**
```bash
gitlink-cli pr +vdiff --id 42 --version 5
gitlink-cli pr +vdiff --id 42 --version 5 --filepath main.go
```
---
### 27. pr +filesv1 — PR 文件列表(v1)
**功能**:使用 v1 API 查看 PR 变更的文件列表,支持分页。
**解决思路**:调用 `GET /v1/{owner}/{repo}/pulls/{index}/files`,支持 `--filepath` 过滤和分页。
**代码指令**
```bash
gitlink-cli pr +filesv1 --id 42
gitlink-cli pr +filesv1 --id 42 --filepath src/
```
---
### D. 评论管理5 命令)
### 28. issue +comment-edit — 编辑 Issue 评论
**功能**:修改已有 Issue 评论的内容。
**解决思路**:调用 `PATCH /v1/{owner}/{repo}/issues/{index}/journals/{id}`body 需 `notes`(复数)和 `attachment_ids`
**代码指令**
```bash
gitlink-cli issue +comment-edit --number 42 --comment-id 100 --body "修正后的评论"
```
---
### 29. issue +comment-delete — 删除 Issue 评论
**功能**:删除 Issue 的某条评论。
**解决思路**:调用 `DELETE /v1/{owner}/{repo}/issues/{index}/journals/{id}`
**代码指令**
```bash
gitlink-cli issue +comment-delete --number 42 --comment-id 100
```
---
### 30. issue +replies — 子评论列表
**功能**:查看某条评论下的所有回复。
**解决思路**:调用 `GET /v1/{owner}/{repo}/issues/{index}/journals/{id}/children_journals`
**代码指令**
```bash
gitlink-cli issue +replies --number 42 --comment-id 100
```
---
### 31. pr +comment-edit — 编辑 PR 评论
**功能**:修改 PR 审查评论。注意 body 字段是 `note`(单数,与 Issue 的 `notes` 不同)。
**解决思路**:调用 `PUT /v1/{owner}/{repo}/pulls/{index}/journals/{id}`body 需 `note`、`commit_id`、`state`。
**代码指令**
```bash
gitlink-cli pr +comment-edit --id 42 --comment-id 100 --body "updated" --state resolved
```
---
### 32. pr +comment-delete — 删除 PR 评论
**功能**:删除 PR 的某条审查评论。
**解决思路**:调用 `DELETE /v1/{owner}/{repo}/pulls/{index}/journals/{id}`
**代码指令**
```bash
gitlink-cli pr +comment-delete --id 42 --comment-id 100
```
---
## 涉及文件
| 文件 | 操作 | 说明 |
|------|------|------|
| `shortcuts/file/file.go` | 新建 | file 模块11 个 shortcut |
| `shortcuts/issue/issue.go` | 修改 | 新增 7 个命令4 元数据 + 3 评论管理) |
| `shortcuts/milestone/milestone.go` | 新建 | milestone 模块6 个 shortcut |
| `shortcuts/pr/pr.go` | 修改 | 新增 8 个命令6 PR 增强 + 2 评论管理) |
| `shortcuts/register.go` | 修改 | 注册 file + milestone 模块 |

View File

@ -0,0 +1,39 @@
子赛题一增加和完善GitLink-CLI能力
定位扩展CLI功能丨难度中高丨需要Go语言基础
为gitlink-cli增加新功能或优化现有功能包括但不限于
·新增Shortcut命令如Wiki管理、Webhook配置、项目看板增强
·优化现有命令的参数设计、输出格式、错误提示和帮助文档
·增加批量操作能力如批量Issue操作、批量仓库管理、批量成员邀请)
·提升跨平台兼容性和安装体验
·补全RawAPI封装对齐GitLinkOpenAPI中尚未封装的接口
交付要求:
·向gitlink-cli主仓库提交PR可以是多个
·每个PR包含功能代码+单元测试+命令帮助文档更新
·提供变更说明文档
模板:
第二章 子赛题一:增强与完善 GitLink-CLI 能力
2.1 任务目标与整体思路
【占位】说明本子赛题的定位(扩展 CLI 功能)、你选择的切入方向、整体设计思路。
2.2 三层命令体系架构
【占位】用一段话+架构图说明Shortcuts+前缀)→ API Commands元数据驱动→ Raw API 三层结构,以及本次工作落在哪一层。
【占位】此处插入架构图(三层命令体系)。
2.3 新增 Shortcut 命令
2.3.1 Wiki 管理命令
【占位】列出 wiki +list/+view/+create/+update/+delete说明参数设计、输出格式、使用示例。
2.3.2 Webhook 配置命令
【占位】列出 webhook +list/+create/+update/+test/+delete/+events/+info说明设计要点与示例。
2.3.3 项目看板
【占位】列出 milestone 相关命令及使用示例
2.4优化部分
2.5批量操作能力增强 都有哪些,,按大类区分
2.6 跨平台兼容性与安装体验
【占位】Windows PowerShell 脚本、路径处理、一键安装脚本、npm 安装等改进点。
2.7 Raw API 封装补全
【占位】列出对齐 GitLink OpenAPI 新封装的接口清单。
2.8 单元测试与命令帮助文档
【占位】测试文件位置、覆盖率、关键用例help 文本与示例更新说明。
2.9 PR 提交记录与变更说明
【占位】以表格列出各 PR编号、标题、内容摘要、合并状态、链接。

56
doc/作业要求.txt Normal file
View File

@ -0,0 +1,56 @@
子赛题一增加和完善GitLink-CLI能力
定位扩展CLI功能|难度:中高|需要Go语言基础
为gitlink-cli增加新功能或优化现有功能包括但不限于
●新增Shortcut命令(如Wiki管理、Webhook配置、项目看板增强、代码片段管理等)
●优化现有命令的参数设计、输出格式、错误提示和帮助文档
·增加批量操作能力(如批量Issue操作、批量仓库管理、批量成员邀请)
·提升跨平台兼容性和安装体验
·补全Raw API封装(对齐GitLink OpenAPI中尚未封装的接口)
子赛题二编写和丰富GitLink Skills
定位开发Agent Skill|难度:中|无需Go,Markdown +CLI调用即可
基于gitlink-cli开发新的AI Agent Skill。核心交付物是Skill本身(SKILLmd+使用示例+Agent平台验证结果)。场景示例(不限于此):
·智能代码审查分析PR diff,输出结构化Review意见并自动评论
·Issue自动分拣根据内容自动分类、打标签、分配责任人
·Release Notes生成根据commit和PR记录生成结构化版本说明
·项目健康度报告统计Issue响应时间、PR合并效率、贡献者活跃度
·许可证合规检查:扫描仓库的许可证合规性和敏感信息泄露风险
·新人引导为good-first-issue自动添加引导评论降低新贡献者参与门槛
子赛题三:构建端到端自动化工作流
定位:组合现有能力解决实际问题丨难度:低-中「无需写底层代码
组合gitlink-cli已有命令和Skills可包含自定义Skill完成一个可复现的端到端自动化场景。与子赛题二的区别在于子赛题二
交付的是独立可复用的Skill子赛题三交付的是串联多个步骤的完整解决方案。
场景示例(不限于此):
·项目一键初始化输入项目描述→创建仓库→生成README/LICENSE/CI配置→创建初始IsSUe和里程碑
·多仓库协同跨多个仓库的统一Issue追踪、PR状态看板、Release协调发布
·贡献者成长体系追踪贡献者的PR/Issue活动→生成贡献排行→自动颁发徽章
子赛题四:应用 GitLink 辅助科研(可选完成,视完成情况额外加分)
 定位:科研场景智能化赋能 | 难度:中 | 要求:无需 GoMarkdown + CLI 调用即可
 任务:依托 gitlink-cli 数据获取、命令调用与 AI Agent 能力融合数据分析、知识图谱、AI 挖掘技术,面向
科研工作者、课题组与科研团队,将 GitLink 平台代码托管、协作数据转化为科研创新支撑能力,实现科研项目
分析、主体画像、热点追踪、创新启发、合规校验等全链路科研辅助服务,打通开源代码生态与学术科研的融
合通道,包括但不限于以下场景:
• 仓库级科研项目洞悉
• 科研热点追踪与知识图谱构建
• 科研项目合规与复现性检查
• 科研协作智能匹配
• 科研进度智能跟踪与预警

View File

@ -0,0 +1,294 @@
# 子任务一CLI 命令系统优化
## PR: feat: CLI 命令系统优化 — 参数校验、输出格式、错误提示、帮助文档
### Summary
本次优化针对 gitlink-cli 的命令系统进行了四个方面的改进,提升用户体验和开发者效率:
1. **参数设计** — 新增 `Choices` 枚举校验和 `Validate` 自定义校验,非法参数在本地拦截而非等待 API 返回 422
2. **输出格式** — 新增 `--columns`、`--no-truncate`、`--no-color` 全局 flag支持列过滤、禁用截断、彩色表头
3. **错误提示** — 统一 `OpError` 构造函数,错误信息包含英文 `message`(脚本可解析)+ 中文 `suggestion`(用户可读)
4. **帮助文档**`Shortcut` 新增 `Long`/`Example` 字段15 个 Group 命令补充详细说明,新增 `completion` 子命令
---
### 一、参数设计优化
#### 问题
- 枚举参数无校验,非法值直接发往 API等服务端返回 422 才知道参数错误
- 跨参数约束散落在 `Run()` 函数中,格式不统一,中英文混杂
#### 解决方案
**1. Choices 枚举校验**
```go
// Flag 结构体新增 Choices 字段
type Flag struct {
Name string
Short string
Usage string
Required bool
Default string
Bool bool
Choices []string // 新增:枚举校验
Validate func(value string) error // 新增:自定义校验函数
}
// 命令中声明 Choices
Flags: []common.Flag{
{Name: "method", Short: "m", Usage: "Merge method", Default: "merge",
Choices: []string{"merge", "rebase", "squash"}},
}
```
Choices 声明后,无需再写校验函数,`--help` 自动追加 `[merge|rebase|squash]`
**2. Validate 自定义校验**
```go
// Shortcut 结构体新增 Validate 字段
type Shortcut struct {
Name string
Description string
Flags []Flag
Validate func(args map[string]string) error // 新增:跨参数校验
Run func(ctx *RuntimeContext) error
}
```
**3. 统一错误格式**
```go
// 使用 clierrors.InputError 统一错误格式
if title == "" && description == "" && state == "" {
return clierrors.InputError(
"at least one of --title, --body, or --state is required",
"至少需要提供 --title、--body 或 --state 中的一个参数",
).WithCommand(ctx.CommandName)
}
```
---
### 二、输出格式优化
#### 问题
- 表格输出列全量显示,无法过滤
- 长文本在 60 字符处硬截断,不可配置
- 无色彩区分,可读性差
#### 解决方案
**1. PrintOptions 结构体**
```go
type PrintOptions struct {
Columns []string // 要显示的列nil = 全部
NoTruncate bool // 禁用 60 字符截断
UseColor bool // 启用 ANSI 颜色
}
```
**2. 新增全局 Flag**
```bash
gitlink-cli issue +list --columns id,subject,status
gitlink-cli issue +list --no-truncate
gitlink-cli issue +list --no-color
```
**3. 默认格式修正**
`--format` 默认值从 `json` 改为 `table`,与 `--help` 文本一致。
---
### 三、错误提示优化
#### 问题
- 中英文混用,格式不统一
- `fmt.Errorf` 不被框架识别,无法输出结构化错误
#### 解决方案
**1. OpError 统一构造函数**
```go
// 入参只需动词和资源名
func OpError(kind ErrorKind, op, resource string, cause error) *CLIError {
msg := fmt.Sprintf("failed to %s %s", op, resource)
sugg := opSuggestion(op, resource)
return Wrap(kind, msg, sugg, cause)
}
// 命令中一行调用
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).
WithCommand(ctx.CommandName)
}
```
**2. 结构化输出**
```json
{
"ok": false,
"error": {
"code": 500,
"message": "failed to list issues",
"suggestion": "获取列表失败,请检查参数或网络连接,稍后重试"
}
}
```
`message` 用英文保证脚本可解析,`suggestion` 用中文直接给人看。
---
### 四、帮助文档优化
#### 问题
- Shortcut 无详细帮助,`--help` 只有命令行和 flag 列表
- Group 命令只有一行描述
- 无 Shell 自动补全
#### 解决方案
**1. Shortcut 新增 Long/Example 字段**
```go
type Shortcut struct {
Name string
Description string
Long string // 新增:详细帮助文本
Example string // 新增:使用示例
Flags []Flag
Run func(ctx *RuntimeContext) error
}
// 命令中填写 Example
{
Name: "merge",
Description: "Merge a pull request",
Example: " gitlink-cli pr +merge --id 42\n" +
" gitlink-cli pr +merge --id 42 --method rebase\n" +
" gitlink-cli pr +merge --id 42 --method squash --dry-run",
}
```
**2. 15 个 Group 命令补充 Long/Example**
```go
infos := map[string]groupInfo{
"pr": {
Short: "Pull request operations",
Long: "Manage pull requests: list, create, view, merge, close, review, and view changed files.",
Example: " gitlink-cli pr +list --state open\n" +
" gitlink-cli pr +create --title \"Fix login\" --head feat-branch\n" +
" gitlink-cli pr +merge --id 42",
},
// 其余 14 个 group 同上
}
```
**3. 新增 completion 子命令**
```bash
source <(gitlink-cli completion bash) # Bash
source <(gitlink-cli completion zsh) # Zsh
gitlink-cli completion fish | source # fish
gitlink-cli completion powershell | Out-String | Invoke-Expression # PowerShell
```
---
### Modified Files
| 文件 | 改动 |
|------|------|
| `cmd/cmdutil/globals.go` | 新增 NoTruncate/Columns/NoColor 全局变量 |
| `cmd/root.go` | 新增 3 个 persistent flag + completion 子命令 + format 默认值修正 |
| `shortcuts/common/types.go` | Shortcut/Flag/RuntimeContext 结构体扩展 |
| `shortcuts/common/runner.go` | Choices/Validate 校验逻辑 + Long/Example 挂载 |
| `internal/output/formatter.go` | PrintOptions + 列过滤 + 去截断 + 彩色表头 |
| `internal/errors/errors.go` | OpError 统一构造函数 |
| `shortcuts/register.go` | 15 个 Group 命令补充 Long/Example |
| `shortcuts/issue/issue.go` | 错误统一 + Choices + Long/Example |
| `shortcuts/pr/pr.go` | 错误统一 + Choices + Long/Example |
| `shortcuts/repo/repo.go` | 错误统一 + Choices + Long/Example |
| `shortcuts/milestone/milestone.go` | 修复 milestone +view 输出问题 |
| `shortcuts/board/board.go` | 修正 assign 字段 |
---
### Testing
- `go build` 通过
- `go vet` 通过
- `go test ./...` 7 个测试包全部通过
- 所有新增字段零值安全,现有 80+ Shortcut 无需修改即可编译
---
### Breaking Changes
无。`output.Print()` 签名不变,内部包装为 `PrintWithOpts`。Workflow 脚本显式使用 `--format json`,不受默认格式变化影响。
---
### 验证示例
**1. 枚举校验**
```bash
$ gitlink-cli pr +merge --id 42 --method unknown
Error: invalid value "unknown" for --method
有效值: merge, rebase, squash。运行 'gitlink-cli pr +merge --help' 查看用法。
```
**2. 跨参数约束**
```bash
$ gitlink-cli issue +update --number 42
Error: at least one of --title, --body, or --state is required
至少需要提供 --title、--body 或 --state 中的一个参数
```
**3. 列过滤**
```bash
$ gitlink-cli issue +list --columns id,subject,status
id subject status
-- ------- ------
42 Fix login bug open
43 Add unit tests closed
```
**4. 结构化错误**
```bash
$ gitlink-cli issue +view --number 999999 --format json
{
"ok": false,
"error": {
"code": 404,
"message": "failed to view issue",
"suggestion": "查看失败,请确认资源 ID 是否存在"
}
}
```
**5. Shell 自动补全**
```bash
$ source <(gitlink-cli completion bash)
$ gitlink-cli pr +<TAB>
+list +create +view +merge +close +reopen +update +files +diff +comment
```

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,408 @@
目录
1. 项目概述
2、软件总体设计
2.1 软件体系结构设计
2.2 用户界面设计
2.3 数据库设计
2.4 系统度量
3、系统静态模型
3.1 命令框架层设计
3.2 快捷命令层设计
3.3 核心库层设计
4、系统动态模型
4.1 用例一:用户认证与仓库操作
4.2 用例二Issue 批量管理
4.3 用例三AI 自动化工作流
5、系统部署模型
1. 项目概述
gitlink-cli 是 GitLink确实开源平台的命令行工具。GitLink 是 CCF 官方开源协作平台,后端提供 490+ API 端点,但此前缺少官方 CLI 工具。本项目填补了这个空白。
应用背景:
开发者在日常工作中需要频繁操作仓库、Issue、PR、分支等资源。浏览器操作效率低无法批量处理也不能与 CI/CD 流水线集成。gitlink-cli 让开发者在终端中完成所有平台操作,并通过 AI Agent Skill 支持 Claude Code 自动化执行复杂工作流。
功能描述:
- 仓库管理创建、列表、详情、Fork、删除、设置、批量操作
- Issue 管理列表、创建、查看、更新、关闭、评论、标签、6 种批量命令
- PR 管理:列表、创建、查看、合并、关闭、代码审查、变更文件查看
- 分支管理:列表、创建、删除、保护
- 发布管理:列表、创建、查看、删除
- Wiki 管理:列表、创建、查看、更新、删除
- Webhook 管理:列表、创建、更新、删除、测试
- CI/CD 管理:构建列表、日志、重启、停止
- 组织与团队管理:组织列表、详情、成员、团队 CRUD
- 合规检查:许可证扫描、敏感信息检测、依赖审计
- 搜索:仓库搜索、用户搜索
- AI 工作流:社区运营、代码审查、项目初始化、多仓库协同、贡献者成长
性能要求:
- 单次 API 调用响应时间 < 2 秒
- 批量操作支持并发执行,错误不中断
- 分页自动遍历,支持大数据量场景
- 跨平台支持Windows、macOS、Linux
2、软件总体设计
2.1 软件体系结构设计
本项目采用分层架构,从上到共分为五层:
1入口层main.go 和 cmd/root.go基于 Cobra 框架构建命令树。
2命令层cmd/ 目录,包含 auth、api、config、compliance 四个顶级命令。
3快捷命令层shortcuts/ 目录16 个资源组,每组通过 +动词 子命令暴露操作。
4核心库层internal/ 目录,包含 HTTP 客户端、认证、配置、输出格式化、错误处理。
5AI 扩展层skills/ 目录24 个 SKILL.md和 workflows/ 目录bash/PowerShell 脚本)。
各层的职责划分:
[入口层] main.go → cmd.Execute() → Cobra root 命令
[命令层] auth / api / config / compliance
[快捷命令层] shortcuts/ → repo/issue/pr/wiki/... → common.Shortcut 声明式定义
[核心库层] internal/client → HTTP 请求 → internal/auth → Token 注入
internal/output → 格式化输出JSON/Table/YAML
internal/config → 配置文件读写
internal/context → git remote 解析 owner/repo
[AI 扩展层] skills/ → SKILL.md 定义(供 Claude Code 调用)
workflows/ → 可执行脚本bash/PowerShell
核心设计模式是声明式 Shortcut 框架。每个快捷命令通过 common.Shortcut 结构体定义 Name、Flags、Run 函数,由 runner.go 统一挂载到 Cobra 命令树。开发者新增命令只需实现 Run 函数,不需要关心命令注册和参数解析的细节。
三层命令体系:
- Layer 1 Shortcuts语义化封装覆盖 16 个领域共 80+ 命令(如 issue +create
- Layer 2 API Commands原始 HTTP 调用api GET/POST/PUT/DELETE
- Layer 3 Raw API覆盖全部 490+ 端点,自动注入认证 Header
2.2 用户界面设计
本项目是 CLI 工具,用户界面是终端命令行。界面设计遵循以下原则:
1命令格式统一gitlink-cli <资源> +<动作> [flags]
例如gitlink-cli issue +create --title "Bug" --body "描述"
2输出格式三选一通过 --format 参数选择 json、table、yaml默认 table。
table 格式使用 tabwriter 对齐,支持 ANSI 颜色高亮表头。
json 格式使用统一的 Envelope 结构:{ok, data, error, meta}。
3全局参数--owner、--repo自动从 git remote 解析)、--format、--debug、--no-truncate、--columns、--no-color。
4Shell 补全:支持 bash、zsh、fish、powershell 四种 shell 的自动补全。
5帮助系统每个命令支持 --help显示用法、参数说明和示例。
用户操作流程:
用户打开终端 → 输入 gitlink-cli auth login 登录 → 使用具体命令操作资源
→ 输出结果以 table/json/yaml 格式显示 → 可通过管道传递给其他工具
2.3 数据库设计
本项目不使用传统数据库。数据存储分为两部分:
1配置文件存储
路径:~/.config/gitlink-cli/config.yaml
内容base_urlAPI 地址、default_format输出格式、editor、pager
格式YAML
2凭证存储
使用 go-keyring 库,调用操作系统原生密钥管理:
- macOSKeychain
- LinuxSecret ServiceGNOME Keyring / KDE Wallet
- WindowsCredential Manager
Fallback~/.config/gitlink-cli/credentials文件权限 0600
存储的数据结构:
Token字符串→ 关联 gitlink.org.cn 的 Bearer Token
ConfigYAML→ base_url、default_format、editor、pager 等配置项
本项目不需要关系型数据库,所有数据来自 GitLink 平台 API 的实时查询。
2.4 系统度量
1代码规模
- Go 源文件60 个
- Go 代码总行数14,932 行
- 测试文件12 个,共 4,279 行
- 测试覆盖率:测试代码占总代码的 22.3%
2按模块统计
模块 文件数 代码行数
main入口 1 13
cmd命令层 5 379
internal核心库 13 2,242
shortcuts快捷层 41 12,398
合计 60 14,932
3功能规模
- Shortcut 命令组16 个
- Shortcut 命令总数80+
- AI Skill 定义24 个 SKILL.md
- 自动化工作流脚本15+ 个bash/PowerShell
- 支持的 Shell 补全4 种bash/zsh/fish/powershell
4依赖规模
- 直接依赖4 个cobra、go-keyring、golang.org/x/term、yaml.v3
- 编译产物大小:约 11.9 MB单二进制文件
3、系统静态模型
3.1 命令框架层设计
命令框架层由 cmd/ 包和 shortcuts/common/ 包组成,定义了整个 CLI 的骨架。
核心类结构:
1cobra.Command来自 spf13/cobra 库)
- 每个命令对应一个 cobra.Command 实例
- rootCmd 是根命令,所有子命令通过 AddCommand 挂载
- root.go 定义全局 PersistentFlags--owner、--repo、--format 等)
2common.Shortcut 结构体:
type Shortcut struct {
Name string
Description string
Long string
Example string
Flags []Flag
DryRun bool
DryRunHint func(ctx *RuntimeContext) (string, error)
Validate func(args map[string]string) error
Run func(ctx *RuntimeContext) error
}
3common.Flag 结构体:
type Flag struct {
Name string
Short string
Usage string
Required bool
Default string
Bool bool
Choices []string
Validate func(value string) error
}
4common.RuntimeContext 结构体:
type RuntimeContext struct {
Client *client.Client
Owner string
Repo string
Format string
CommandName string
Args map[string]string
GatewayBaseURL string
GatewayHTTPClient *http.Client
NoTruncate bool
Columns string
NoColor bool
}
RuntimeContext 提供以下核心方法:
- ResolveOwnerRepo():从 git remote 或 flags 解析 owner/repo
- CallAPI(method, path, body):发起 HTTP 请求
- PaginateAll(path, params):自动遍历分页
- Output(env):格式化输出
- OutputData(data):封装为成功 Envelope 并输出
5register.go 的注册机制:
RegisterAll(root) 遍历 16 个命令组,每组创建 cobra.Command 并调用 MountShortcuts 挂载子命令。MountShortcuts 遍历 Shortcut 切片,为每个 Shortcut 创建对应的 cobra.Command绑定 flags 和 RunE 函数。
3.2 快捷命令层设计
快捷命令层位于 shortcuts/ 目录,包含 16 个命令组。每个组是一个独立的 Go 包,通过 Shortcuts() 函数返回 []*common.Shortcut 切片。
命令组列表:
- board看板操作view/columns/issues/move/assign/stats— 832 行
- branch分支操作list/create/delete/protect— 157 行
- ciCI/CD 操作builds/logs/restart/stop— 117 行
- file文件操作ls/read/create/update/delete/commits/diff— 886 行
- issueIssue 操作14 个命令,含 6 种批量操作)— 3,058 行
- milestone里程碑操作list/create/view/update/delete/status— 525 行
- org组织操作list/info/members/create/update— 476 行
- prPR 操作list/create/view/merge/close/review/files/diff— 973 行
- release发布操作list/create/view/delete— 179 行
- repo仓库操作含 batch-create/batch-update/batch-delete/batch-member— 1,781 行
- search搜索操作repos/users— 88 行
- team团队操作list/create/delete/members— 192 行
- user用户操作me/info— 41 行
- webhookWebhook 操作list/create/view/update/delete/test/events— 649 行
- wikiWiki 操作list/create/view/update/delete/lint/fix/sync— 1,612 行
最大的两个模块是 issue3,058 行)和 wiki1,612 行。issue 模块包含批量操作功能wiki 模块处理双域名架构和 base64 编解码。
批量操作设计模式(以 issue 为例):
- BatchResult单条操作结果number/action/status/error
- BatchSummary汇总信息total/succeeded/failed/results
- 输入灵活:--numbers内联和 --fromCSV 文件)可同时使用
- dry-run 统一:所有批量命令支持 --dry-run 预览
- 错误不中断:单条失败不影响后续处理
3.3 核心库层设计
核心库层位于 internal/ 目录,包含 7 个子包:
1internal/auth — 认证模块3 个文件272 行)
- login.go登录流程用户名密码或 Token 粘贴)
- token_store.goToken 存储go-keyring 跨平台支持)
- transport.goHTTP Transport自动在请求 Header 中注入 Bearer Token
2internal/client — HTTP 客户端2 个文件319 行)
- client.go封装 Get/Post/Put/Delete 方法,自动追加 .json 后缀双重错误检查HTTP 状态码 + JSON body 中的 status 字段)
- pagination.go分页迭代器自动遍历 Kaminari 风格分页
3internal/compliance — 合规检查4 个文件685 行)
- cmd.gocompliance 子命令定义
- scanner.go扫描器实现
- license.go许可证检测
- rules.go合规规则定义
4internal/config — 配置管理1 个文件125 行)
- config.go读写 ~/.config/gitlink-cli/config.yaml
5internal/context — 上下文解析1 个文件91 行)
- repo.go从 git remote 的 origin URL 自动解析 owner 和 repo
6internal/errors — 错误处理1 个文件150 行)
- errors.go统一错误类型 CLIError包含 Kind错误分类、Message、Suggestion、Command 字段
- 错误分类KindAuth、KindInput、KindForbidden、KindNotFound、KindServer、KindUnknown
7internal/output — 输出格式化3 个文件425 行)
- envelope.go定义 Envelope 结构体 {OK, Data, Error, Meta}
- formatter.go三种输出格式JSON/YAML/Tabletable 使用 tabwriter 对齐,支持 ANSI 颜色、列过滤、值截断控制
4、系统动态模型
4.1 用例一:用户认证与仓库操作
用例描述:用户首次使用 gitlink-cli完成登录并查看仓库信息。
参与者:开发者(终端用户)
前置条件:已安装 gitlink-cli网络可访问 gitlink.org.cn
主要流程:
1用户执行 gitlink-cli auth login
2系统提示输入用户名和密码
3系统调用 POST /api/accounts/login 获取 Token
4Token 存入 OS Keychain
5用户进入 git 仓库目录,执行 gitlink-cli repo +info
6系统从 .git/config 解析 remote origin URL提取 owner/repo
7系统调用 GET /api/{owner}/{repo}/detail.json
8系统以 table 格式输出仓库信息
异常流程:
- Token 过期7 天有效期):系统返回 401 错误,提示运行 gitlink-cli auth login 重新登录
- 非 git 目录:系统提示使用 --owner 和 --repo 参数显式指定
API 调用序列:
POST /api/accounts/login → {token: "..."}
GET /api/{owner}/{repo}/detail.json → {name, description, default_branch, ...}
4.2 用例二Issue 批量管理
用例描述:项目维护者批量关闭过期 Issue 并批量创建新 Issue。
参与者:项目维护者
前置条件:已登录,有仓库写权限
主要流程:
1用户执行 gitlink-cli issue +batch-close --numbers 101,102,103,104 --dry-run
2系统预览显示 4 个 Issue 将被关闭,不实际执行
3用户确认后去掉 --dry-run重新执行
4系统逐个调用 PUT /api/{owner}/{repo}/issues/{number},设置 state 为 closed
5系统输出 BatchSummarytotal=4, succeeded=4, failed=0
6用户准备 CSV 文件 issues.csv
title,body,priority,label
"登录页面样式异常","点击按钮后样式错乱","high","缺陷"
"新增数据导出功能","支持 CSV 和 JSON 格式导出","normal","功能"
7用户执行 gitlink-cli issue +batch-create --from issues.csv --template bug
8系统逐个调用 POST /api/{owner}/{repo}/issues 创建 Issue
9系统输出 BatchSummarytotal=2, succeeded=2, failed=0
异常流程:
- 单条操作失败:记录错误,继续处理后续条目,最终 exit code = 1
- API 字段静默失败GitLink 对不认识的字段返回 200 而非报错,需通过浏览器 DevTools 确认字段名
4.3 用例三AI 自动化工作流
用例描述:使用 Claude Code 和 gitlink-cli Skill 自动完成社区运营任务。
参与者:社区运营者(通过 Claude Code 交互)
前置条件Claude Code 已安装gitlink-cli 已登录
主要流程:
1用户在 Claude Code 中说"帮我跑社区运营工作流"
2Claude Code 触发 gitlink-workflows skill显示功能菜单
3用户选择"社区运营自动化"
4Claude Code 调用 gitlink-cli issue +list --state open --format json
5Claude Code 分析 Issue 列表按类型分类bug/feature/question
6Claude Code 调用 gitlink-cli issue +label-add 添加分类标签
7Claude Code 调用 gitlink-cli pr +list 获取近期 PR
8Claude Code 生成周报摘要,包含 Issue 统计、PR 合并情况、贡献者活跃度
Skill 调用链:
gitlink-shared认证检查→ gitlink-issueIssue 操作)→ gitlink-prPR 操作)→ gitlink-workflow工作流编排
5、系统部署模型
本项目采用单二进制分发模式,部署简单。
1构建环境
- Go 1.26.1+
- Makefile 管理构建流程
- 构建命令make build通过 -ldflags 注入版本号)
2分发方式
- 直接下载:从 GitHub Releases 下载预编译二进制
- npm 安装npm install -g gitlink-cli
- 源码编译go install github.com/gitlink-org/gitlink-cli@latest
- 安装脚本install.shLinux/macOS、install.ps1Windows
3目标平台
- Windowsamd64
- macOSamd64、arm64
- Linuxamd64、arm64
4依赖服务
- GitLink APIhttps://www.gitlink.org.cn/api主站 API
- GitLink Gatewayhttps://gateway.gitlink.org.cn/apiWiki API 专用)
- OS Keychain系统密钥管理服务存储 Token
5CI/CD 集成:
- GitHub Actions.github/workflows/release.yml 自动构建和发布
- GitLink DevOps.devops/gitlink-cli-autodeploy.yml 平台自动部署
6部署架构
用户终端
gitlink-cli本地二进制
↓ HTTP/HTTPS
GitLink API Servergitlink.org.cn
GitLink Gatewaygateway.gitlink.org.cnWiki 专用)
gitlink-cli 是纯客户端工具,不运行后台服务。所有数据存储在用户本地(配置文件 + OS Keychain业务数据完全来自 GitLink 平台 API 的实时查询。

Binary file not shown.

BIN
gitlink-cli.exe Normal file

Binary file not shown.

229
internal/compliance/cmd.go Normal file
View File

@ -0,0 +1,229 @@
package compliance
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
// NewCommand returns the top-level compliance command.
func NewCommand() *cobra.Command {
var format string
cmd := &cobra.Command{
Use: "compliance",
Short: "Compliance and security scan operations",
Long: "Run license compliance checks, dependency scanning, secret detection, and exposure analysis on local repositories.",
}
cmd.PersistentFlags().StringVar(&format, "format", "table", "Output format: json, table")
cmd.AddCommand(newScanCmd(&format))
cmd.AddCommand(newLicenseCmd(&format))
cmd.AddCommand(newDepsCmd(&format))
cmd.AddCommand(newSecretsCmd(&format))
cmd.AddCommand(newExposureCmd(&format))
cmd.AddCommand(newVocabCmd(&format))
return cmd
}
func newScanCmd(format *string) *cobra.Command {
var module string
cmd := &cobra.Command{
Use: "+scan",
Short: "Full compliance scan (all five modules)",
RunE: func(cmd *cobra.Command, args []string) error {
root, err := repoRoot()
if err != nil {
return err
}
modules := []string{"secrets", "exposure", "vocab"}
if module != "" {
modules = parseModules(module)
}
var allFindings []Finding
for _, m := range modules {
switch m {
case "license":
allFindings = append(allFindings, checkLicense(root)...)
break
case "deps":
allFindings = append(allFindings, checkDeps(root)...)
break
case "secrets", "exposure", "vocab":
rules := allRules()[m]
allFindings = append(allFindings, scanFiles(root, rules)...)
break
}
}
return outputReport(allFindings, modules, *format)
},
}
cmd.Flags().StringVarP(&module, "module", "m", "", "Comma-separated modules: license,deps,secrets,exposure,vocab")
return cmd
}
func newLicenseCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+license",
Short: "License compliance check",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := checkLicense(root)
return outputReport(findings, []string{"license"}, *format)
},
}
}
func newDepsCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+deps",
Short: "Dependency license check",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := checkDeps(root)
return outputReport(findings, []string{"deps"}, *format)
},
}
}
func newSecretsCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+secrets",
Short: "Hardcoded secrets scan",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := scanFiles(root, allRules()["secrets"])
return outputReport(findings, []string{"secrets"}, *format)
},
}
}
func newExposureCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+exposure",
Short: "PII and network exposure scan",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := scanFiles(root, allRules()["exposure"])
return outputReport(findings, []string{"exposure"}, *format)
},
}
}
func newVocabCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+vocab",
Short: "Sensitive vocabulary scan",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := scanFiles(root, allRules()["vocab"])
return outputReport(findings, []string{"vocab"}, *format)
},
}
}
func repoRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return dir, nil
}
dir = parent
}
}
func parseModules(s string) []string {
var result []string
seen := map[string]bool{}
for _, m := range strings.Split(s, ",") {
m = strings.TrimSpace(m)
valid := map[string]bool{"license": true, "deps": true, "secrets": true, "exposure": true, "vocab": true}
if valid[m] && !seen[m] {
result = append(result, m)
seen[m] = true
}
}
return result
}
// summary holds aggregated stats.
type summary struct {
Total int `json:"total"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
}
type reportData struct {
Modules []string `json:"modules"`
Findings []Finding `json:"findings"`
Summary summary `json:"summary"`
}
func outputReport(findings []Finding, modules []string, format string) error {
s := summary{}
for _, f := range findings {
s.Total++
switch f.Severity {
case "critical":
s.Critical++
case "high":
s.High++
case "medium":
s.Medium++
case "low":
s.Low++
}
}
if format == "json" {
return output.Print(output.SuccessEnvelope(reportData{Modules: modules, Findings: findings, Summary: s}, nil), format)
}
fmt.Println()
printHR()
fmt.Printf(" Compliance Scan Report\n")
fmt.Printf(" Modules: %s | Findings: %d (critical:%d high:%d medium:%d low:%d)\n",
strings.Join(modules, ", "), s.Total, s.Critical, s.High, s.Medium, s.Low)
printHR()
if len(findings) == 0 {
fmt.Println(" All clear — no issues found.")
} else {
printFindings(findings)
}
printHR()
return nil
}
func printHR() {
fmt.Println(strings.Repeat("─", 60))
}
func printFindings(findings []Finding) {
labels := map[string]string{
"critical": "CRIT", "high": "HIGH", "medium": "MED", "low": "LOW",
}
for _, f := range findings {
label := labels[f.Severity]
if label == "" {
label = f.Severity
}
fmt.Printf(" [%s] %s %s:%d %s\n", label, f.ID, f.File, f.Line, f.Summary)
}
}

View File

@ -42,7 +42,7 @@ var excludedDirs = map[string]bool{
// excludedPaths are relative paths skipped (scanner's own source to avoid self-scan).
var excludedPaths = map[string]bool{
"shortcuts/compliance": true,
"internal/compliance": true,
}
// excludedExts are file extensions skipped during scanning.

View File

@ -108,6 +108,41 @@ func ConfigError(message string, cause error) *CLIError {
fmt.Sprintf("检查配置文件 %s 是否正确", configPathPlaceholder()), cause)
}
// OpError creates a unified operation-failure error.
// op is the English verb (e.g., "list", "create"), resource is the target (e.g., "issues").
// The Message is in English; Suggestion is in Chinese for user guidance.
func OpError(kind ErrorKind, op, resource string, cause error) *CLIError {
msg := fmt.Sprintf("failed to %s %s", op, resource)
sugg := opSuggestion(op, resource)
e := Wrap(kind, msg, sugg, cause)
return e
}
// opSuggestion returns a Chinese suggestion for the given operation.
func opSuggestion(op, resource string) string {
suggestions := map[string]string{
"list": "获取列表失败,请检查参数或网络连接,稍后重试",
"create": "创建失败,请检查必填参数是否正确(--help 查看用法)或 API 权限",
"view": "查看失败,请确认资源 ID 是否存在",
"update": "更新失败,请检查参数值或资源 ID 是否正确",
"delete": "删除失败,请确认资源是否存在或是否有删除权限",
"close": "关闭失败,请确认资源是否存在或已被关闭",
"reopen": "重新打开失败,请确认资源是否存在",
"merge": "合并失败,请检查是否有冲突或权限不足",
"comment": "添加评论失败,请确认资源是否存在",
"approve": "评审操作失败,请确认 PR 是否存在",
"scan": "扫描失败,请稍后重试",
"invite": "邀请失败,请确认用户 ID 是否正确",
"remove": "移除失败,请确认成员存在",
"fork": "Fork 失败,请确认仓库存在或有权限",
"search": "搜索失败,请稍后重试",
}
if s, ok := suggestions[op]; ok {
return s
}
return fmt.Sprintf("操作失败,请稍后重试或运行 --help 查看用法")
}
// configPathPlaceholder avoids circular import; the actual path will be resolved
// in output formatting.
func configPathPlaceholder() string {

View File

@ -9,29 +9,48 @@ import (
"strings"
"text/tabwriter"
"golang.org/x/term"
"gopkg.in/yaml.v3"
)
// PrintOptions controls table output rendering behavior.
type PrintOptions struct {
Columns []string // column names to show (nil = all)
NoTruncate bool // disable 60-char truncation
UseColor bool // enable ANSI color headers
}
// Print outputs the envelope in the given format with default options.
func Print(envelope *Envelope, format string) error {
return PrintWithOpts(envelope, format, PrintOptions{})
}
// PrintWithOpts outputs the envelope with rendering options.
func PrintWithOpts(envelope *Envelope, format string, opts PrintOptions) error {
if format == "" {
format = "json"
}
return PrintTo(os.Stdout, envelope, format)
return printToOpts(os.Stdout, envelope, format, opts)
}
func PrintTo(w io.Writer, envelope *Envelope, format string) error {
func printToOpts(w io.Writer, envelope *Envelope, format string, opts PrintOptions) error {
switch format {
case "json":
return printJSON(w, envelope)
case "yaml":
return printYAML(w, envelope)
case "table":
return printTable(w, envelope)
return printTableOpts(w, envelope, opts)
default:
return printJSON(w, envelope)
}
}
// PrintTo outputs the envelope to the given writer (legacy, no options).
func PrintTo(w io.Writer, envelope *Envelope, format string) error {
return printToOpts(w, envelope, format, PrintOptions{})
}
func printJSON(w io.Writer, envelope *Envelope) error {
data, err := json.MarshalIndent(envelope, "", " ")
if err != nil {
@ -50,7 +69,7 @@ func printYAML(w io.Writer, envelope *Envelope) error {
return err
}
func printTable(w io.Writer, envelope *Envelope) error {
func printTableOpts(w io.Writer, envelope *Envelope, opts PrintOptions) error {
if !envelope.OK {
if envelope.Error != nil {
fmt.Fprintf(w, "Error: %s\n", envelope.Error.Message)
@ -66,33 +85,29 @@ func printTable(w io.Writer, envelope *Envelope) error {
return nil
}
// Try to render as table if data is a slice of maps
switch data := envelope.Data.(type) {
case []interface{}:
return printSliceTable(w, data)
return printSliceTableOpts(w, data, opts)
case map[string]interface{}:
// GitLink API 经常返回 {"count":N, "items":[...]} 这种"包装列表"
// 优先解包内层 slice of maps按列表渲染
if unwrapped := unwrapSingleListField(data); unwrapped != nil {
return printSliceTable(w, unwrapped)
return printSliceTableOpts(w, unwrapped, opts)
}
// For maps with nested structures, prefer JSON
if hasComplexValues(data) {
return printJSON(w, envelope)
}
return printMapTable(w, data)
return printMapTableOpts(w, data, opts)
default:
// Fallback to JSON
return printJSON(w, envelope)
}
}
// unwrapSingleListField 检测 map 是否为"包装列表"结构:
// 至少包含一个 []interface{}(元素为 map或为空数组字段。
// 若是,返回该 slice用于按列表渲染表格否则返回 nil。
//
// 优先选择已知列表字段名projects/webhooks/issues 等),
// 若 map 中只有一个 slice of maps 字段,也直接使用。
// printTable is kept for backward compatibility with existing callers.
func printTable(w io.Writer, envelope *Envelope) error {
return printTableOpts(w, envelope, PrintOptions{})
}
// unwrapSingleListField detects wrapper map structures like {"items":[...], "count":N}
// and returns the inner slice for list rendering.
func unwrapSingleListField(m map[string]interface{}) []interface{} {
knownListFields := []string{
"projects", "webhooks", "issues", "users", "pull_requests",
@ -100,7 +115,6 @@ func unwrapSingleListField(m map[string]interface{}) []interface{} {
"orgs", "items", "records", "results", "wikis", "search",
}
// 1. 优先选择已知字段名(即使数组为空,也接受 — 空数组也是合法列表)
for _, name := range knownListFields {
if s, ok := m[name].([]interface{}); ok {
if isSliceOfMaps(s) {
@ -109,7 +123,7 @@ func unwrapSingleListField(m map[string]interface{}) []interface{} {
}
}
// 2. 兜底:检测是否只有一个 slice of maps 字段
// fallback: single slice-of-maps field
var listField string
var listValue []interface{}
for k, v := range m {
@ -121,8 +135,7 @@ func unwrapSingleListField(m map[string]interface{}) []interface{} {
continue
}
if listField != "" {
// 多个列表字段,无法自动解包
return nil
return nil // multiple list fields, can't auto-unwrap
}
listField = k
listValue = s
@ -130,11 +143,9 @@ func unwrapSingleListField(m map[string]interface{}) []interface{} {
return listValue
}
// isSliceOfMaps 判断 slice 是否为 map 元素的列表
// 空 slice 也算合法列表(用于在 printSliceTable 中触发 "No results" 输出)
func isSliceOfMaps(s []interface{}) bool {
if len(s) == 0 {
return true // 空数组视为列表printSliceTable 会输出 "No results"
return true
}
_, ok := s[0].(map[string]interface{})
return ok
@ -150,13 +161,12 @@ func hasComplexValues(m map[string]interface{}) bool {
return false
}
func printSliceTable(w io.Writer, items []interface{}) error {
func printSliceTableOpts(w io.Writer, items []interface{}, opts PrintOptions) error {
if len(items) == 0 {
fmt.Fprintln(w, "No results")
return nil
}
// Collect headers from first item
first, ok := items[0].(map[string]interface{})
if !ok {
data, _ := json.MarshalIndent(items, "", " ")
@ -165,10 +175,23 @@ func printSliceTable(w io.Writer, items []interface{}) error {
}
headers := collectKeys(first)
// apply --columns filter
if len(opts.Columns) > 0 {
headers = filterColumns(headers, opts.Columns)
}
useColor := opts.UseColor && isTerminal(w)
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
// Print headers
fmt.Fprintln(tw, strings.Join(headers, "\t"))
headerLine := strings.Join(headers, "\t")
if useColor {
headerLine = colorHeader(headerLine)
}
fmt.Fprintln(tw, headerLine)
dashes := make([]string, len(headers))
for i, h := range headers {
dashes[i] = strings.Repeat("-", len(h))
@ -183,26 +206,43 @@ func printSliceTable(w io.Writer, items []interface{}) error {
}
vals := make([]string, len(headers))
for i, h := range headers {
vals[i] = formatValue(m[h])
vals[i] = formatValueOpts(m[h], opts.NoTruncate)
}
fmt.Fprintln(tw, strings.Join(vals, "\t"))
}
return tw.Flush()
}
func printMapTable(w io.Writer, m map[string]interface{}) error {
func printMapTableOpts(w io.Writer, m map[string]interface{}, opts PrintOptions) error {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "KEY\tVALUE")
headerLine := "KEY\tVALUE"
if opts.UseColor && isTerminal(w) {
headerLine = colorHeader(headerLine)
}
fmt.Fprintln(tw, headerLine)
fmt.Fprintln(tw, "---\t-----")
for k, v := range m {
fmt.Fprintf(tw, "%s\t%s\n", k, formatValue(v))
fmt.Fprintf(tw, "%s\t%s\n", k, formatValueOpts(v, opts.NoTruncate))
}
return tw.Flush()
}
func filterColumns(all, wanted []string) []string {
wantedSet := make(map[string]bool, len(wanted))
for _, w := range wanted {
wantedSet[w] = true
}
result := make([]string, 0, len(wanted))
for _, h := range all {
if wantedSet[h] {
result = append(result, h)
}
}
return result
}
func collectKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
// Prefer common keys first
priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"}
seen := map[string]bool{}
for _, k := range priority {
@ -220,6 +260,10 @@ func collectKeys(m map[string]interface{}) []string {
}
func formatValue(v interface{}) string {
return formatValueOpts(v, false)
}
func formatValueOpts(v interface{}, noTruncate bool) string {
if v == nil {
return ""
}
@ -228,7 +272,7 @@ func formatValue(v interface{}) string {
case reflect.Map, reflect.Slice:
data, _ := json.Marshal(v)
s := string(data)
if len(s) > 60 {
if !noTruncate && len(s) > 60 {
return s[:57] + "..."
}
return s
@ -236,3 +280,21 @@ func formatValue(v interface{}) string {
return fmt.Sprintf("%v", v)
}
}
// --- color helpers ---
const (
ansiHeader = "\033[1;36m" // bold cyan
ansiReset = "\033[0m"
)
func colorHeader(s string) string {
return ansiHeader + s + ansiReset
}
func isTerminal(w io.Writer) bool {
if f, ok := w.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}

BIN
npm/bin/gitlink-cli.exe Normal file

Binary file not shown.

View File

@ -0,0 +1,21 @@
{
"permissions": {
"allow": [
"WebFetch(domain:apifox.com)",
"WebSearch",
"WebFetch(domain:gitlink.org.cn)",
"WebFetch(domain:www.gitlink.org.cn)",
"Bash(sed 's/^//')",
"Bash(sed 's/ *$//')",
"Bash(grep -h \"CallAPI\\\\|\\\\.Do\\(\" C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts/*/*.go C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts/*/*/*.go)",
"Bash(go build:*)",
"Bash(./gitlink-cli.exe file:*)",
"Bash(./gitlink-cli.exe issue:*)",
"Bash(./gitlink-cli.exe milestone:*)",
"Bash(./gitlink-cli.exe pr:*)",
"Bash(go vet:*)",
"Bash(go test:*)",
"Bash(cd:*)"
]
}
}

832
shortcuts/board/board.go Normal file
View File

@ -0,0 +1,832 @@
package board
import (
"fmt"
"net/url"
"sort"
"strconv"
"strings"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// === 状态/优先级常量 ===
const (
statusNew = 1
statusInProgress = 2
statusResolved = 3
statusClosed = 5
statusRejected = 6
)
const (
priorityLow = 1
priorityNormal = 2
priorityHigh = 3
priorityUrgent = 4
)
// 标准看板列顺序(按 status_id 排列)
var columnOrder = []struct {
ID int
Name string
}{
{statusNew, "待处理"},
{statusInProgress, "进行中"},
{statusResolved, "已解决"},
{statusClosed, "已关闭"},
{statusRejected, "已拒绝"},
}
// === 内部数据结构 ===
// issueItem 从 issue list API 解析的单条 issue
type issueItem struct {
ID int `json:"id"`
Subject string `json:"subject"`
StatusID int `json:"status_id"`
StatusName string `json:"status_name"`
PriorityID int `json:"priority_id"`
PriorityName string `json:"priority_name"`
ProjectIndex int `json:"project_issues_index"`
Assigners []struct {
Login string `json:"login"`
Name string `json:"name"`
} `json:"assigners"`
}
// === 输出结构 ===
type boardView struct {
Repository string `json:"repository"`
TotalIssues int `json:"total_issues"`
Columns []columnView `json:"columns"`
}
type columnView struct {
StatusID int `json:"status_id"`
StatusName string `json:"status_name"`
IssueCount int `json:"issue_count"`
Issues []issueBrief `json:"issues"`
}
type issueBrief struct {
Number int `json:"number"`
ID int `json:"id"`
Subject string `json:"subject"`
Priority string `json:"priority"`
AssignedTo string `json:"assigned_to,omitempty"`
}
type boardStats struct {
Repository string `json:"repository"`
TotalIssues int `json:"total_issues"`
CompletionRate float64 `json:"completion_rate"`
ColumnBreakdown []columnStat `json:"column_breakdown"`
AssigneeLoad []assigneeStat `json:"assignee_load"`
Bottleneck string `json:"bottleneck"`
}
type columnStat struct {
StatusName string `json:"status_name"`
Count int `json:"count"`
Percentage float64 `json:"percentage"`
}
type assigneeStat struct {
Assignee string `json:"assignee"`
Count int `json:"count"`
}
// === 辅助函数 ===
// v1RepoPath 返回 v1 API 路径前缀
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
// fetchAllIssues 获取所有 issue分页获取全部
func fetchAllIssues(ctx *common.RuntimeContext) ([]issueItem, error) {
var allIssues []issueItem
page := 1
for {
q := url.Values{}
q.Set("state", "all")
q.Set("page", strconv.Itoa(page))
q.Set("limit", "100")
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return nil, clierrors.OpError(clierrors.KindServer, "list", "issues", err).
WithCommand(ctx.CommandName)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
break
}
issuesRaw, ok := data["issues"].([]interface{})
if !ok || len(issuesRaw) == 0 {
break
}
for _, raw := range issuesRaw {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
item := issueItem{
ID: getInt(m, "id"),
Subject: getString(m, "subject"),
StatusID: getInt(m, "status_id"),
StatusName: getString(m, "status_name"),
PriorityID: getInt(m, "priority_id"),
PriorityName: getString(m, "priority_name"),
ProjectIndex: getInt(m, "project_issues_index"),
}
// 解析 assigners
if assigners, ok := m["assigners"].([]interface{}); ok {
for _, a := range assigners {
if am, ok := a.(map[string]interface{}); ok {
login := getString(am, "login")
if login == "" {
login = getString(am, "name")
}
item.Assigners = append(item.Assigners, struct {
Login string `json:"login"`
Name string `json:"name"`
}{Login: login, Name: getString(am, "name")})
}
}
}
allIssues = append(allIssues, item)
}
// 检查是否还有下一页
totalCount := getInt(data, "total_count")
if totalCount == 0 {
totalCount = getInt(data, "total_issues_count")
}
if len(allIssues) >= totalCount || len(issuesRaw) < 100 {
break
}
page++
}
return allIssues, nil
}
// getString 从 map 中安全获取字符串
func getString(m map[string]interface{}, key string) string {
v, _ := m[key].(string)
return v
}
// getInt 从 map 中安全获取整数
func getInt(m map[string]interface{}, key string) int {
switch v := m[key].(type) {
case float64:
return int(v)
case int:
return v
default:
return 0
}
}
// extractAssignee 提取第一个指派人
func extractAssignee(assigners []struct {
Login string `json:"login"`
Name string `json:"name"`
}) string {
if len(assigners) == 0 {
return ""
}
if assigners[0].Login != "" {
return assigners[0].Login
}
return assigners[0].Name
}
// parseStatusID 状态名转 ID
func parseStatusID(s string) (int, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "new", "待处理":
return statusNew, nil
case "in-progress", "in_progress", "inprogress", "进行中":
return statusInProgress, nil
case "resolved", "已解决":
return statusResolved, nil
case "closed", "已关闭":
return statusClosed, nil
case "rejected", "已拒绝":
return statusRejected, nil
default:
if id, err := strconv.Atoi(s); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid status %q: use new, in-progress, resolved, closed, or rejected", s)
}
}
// statusIDToName 状态 ID 转名称
func statusIDToName(id int) string {
for _, c := range columnOrder {
if c.ID == id {
return c.Name
}
}
return fmt.Sprintf("status_%d", id)
}
// parsePriorityID 优先级名转 ID
func parsePriorityID(p string) (int, error) {
switch strings.ToLower(strings.TrimSpace(p)) {
case "low":
return priorityLow, nil
case "normal":
return priorityNormal, nil
case "high":
return priorityHigh, nil
case "urgent":
return priorityUrgent, nil
default:
if id, err := strconv.Atoi(p); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid priority %q: use low, normal, high, or urgent", p)
}
}
// priorityName 优先级 ID 转名称
func priorityName(id int) string {
switch id {
case priorityLow:
return "low"
case priorityNormal:
return "normal"
case priorityHigh:
return "high"
case priorityUrgent:
return "urgent"
default:
return fmt.Sprintf("%d", id)
}
}
// fetchExistingIssue 获取现有 issue 的 subject 和 description
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (subject, description string, err error) {
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return "", "", clierrors.OpError(clierrors.KindNotFound, "view", "issue", err).WithCommand(ctx.CommandName)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("failed to parse issue data")
}
subject, _ = data["subject"].(string)
if subject == "" {
return "", "", fmt.Errorf("issue #%s: missing subject field", number)
}
description, _ = data["description"].(string)
return subject, description, nil
}
// resolveUserID 把用户名转换成用户 ID
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
if id, err := strconv.Atoi(login); err == nil {
return id, nil
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
if err != nil {
return nil, fmt.Errorf("lookup user %q: %w", login, err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response for user %q", login)
}
if idFloat, ok := data["id"].(float64); ok {
return int(idFloat), nil
}
if userIDFloat, ok := data["user_id"].(float64); ok {
return int(userIDFloat), nil
}
return nil, fmt.Errorf("cannot determine user ID for %q", login)
}
// groupByStatus 将 issues 按 status_id 分组
func groupByStatus(issues []issueItem) map[int][]issueItem {
grouped := make(map[int][]issueItem)
for _, iss := range issues {
grouped[iss.StatusID] = append(grouped[iss.StatusID], iss)
}
return grouped
}
// === Shortcuts 入口 ===
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newViewShortcut(),
newColumnsShortcut(),
newIssuesShortcut(),
newMoveShortcut(),
newAssignShortcut(),
newStatsShortcut(),
}
}
// === board +view ===
func newViewShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "view",
Description: "View kanban board layout",
Long: "Display the kanban board with issues grouped by status columns.",
Example: " gitlink-cli board +view\n gitlink-cli board +view --state open",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
state := ctx.Arg("state")
if state == "" {
state = "open"
}
// 获取 issues
var allIssues []issueItem
page := 1
for {
q := url.Values{}
q.Set("state", state)
q.Set("page", strconv.Itoa(page))
q.Set("limit", "100")
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).
WithCommand(ctx.CommandName)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
break
}
issuesRaw, ok := data["issues"].([]interface{})
if !ok || len(issuesRaw) == 0 {
break
}
for _, raw := range issuesRaw {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
item := issueItem{
ID: getInt(m, "id"),
Subject: getString(m, "subject"),
StatusID: getInt(m, "status_id"),
StatusName: getString(m, "status_name"),
PriorityID: getInt(m, "priority_id"),
PriorityName: getString(m, "priority_name"),
ProjectIndex: getInt(m, "project_issues_index"),
}
if assigners, ok := m["assigners"].([]interface{}); ok {
for _, a := range assigners {
if am, ok := a.(map[string]interface{}); ok {
login := getString(am, "login")
if login == "" {
login = getString(am, "name")
}
item.Assigners = append(item.Assigners, struct {
Login string `json:"login"`
Name string `json:"name"`
}{Login: login, Name: getString(am, "name")})
}
}
}
allIssues = append(allIssues, item)
}
totalCount := getInt(data, "total_count")
if totalCount == 0 {
totalCount = getInt(data, "total_issues_count")
}
if len(allIssues) >= totalCount || len(issuesRaw) < 100 {
break
}
page++
}
// 按 status 分组
grouped := groupByStatus(allIssues)
// 构建看板视图
view := boardView{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
TotalIssues: len(allIssues),
}
for _, col := range columnOrder {
issues := grouped[col.ID]
cv := columnView{
StatusID: col.ID,
StatusName: col.Name,
IssueCount: len(issues),
Issues: make([]issueBrief, 0, len(issues)),
}
for _, iss := range issues {
cv.Issues = append(cv.Issues, issueBrief{
Number: iss.ProjectIndex,
ID: iss.ID,
Subject: iss.Subject,
Priority: priorityName(iss.PriorityID),
AssignedTo: extractAssignee(iss.Assigners),
})
}
view.Columns = append(view.Columns, cv)
}
return ctx.OutputData(view)
},
}
}
// === board +columns ===
func newColumnsShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "columns",
Description: "List status columns with issue counts",
Example: " gitlink-cli board +columns",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
state := ctx.Arg("state")
if state == "" {
state = "open"
}
issues, err := fetchAllIssuesWithState(ctx, state)
if err != nil {
return err
}
grouped := groupByStatus(issues)
type colInfo struct {
StatusID int `json:"status_id"`
StatusName string `json:"status_name"`
IssueCount int `json:"issue_count"`
}
var columns []colInfo
for _, col := range columnOrder {
columns = append(columns, colInfo{
StatusID: col.ID,
StatusName: col.Name,
IssueCount: len(grouped[col.ID]),
})
}
return ctx.OutputData(columns)
},
}
}
// fetchAllIssuesWithState 带状态过滤的全量 issue 获取
func fetchAllIssuesWithState(ctx *common.RuntimeContext, state string) ([]issueItem, error) {
var allIssues []issueItem
page := 1
for {
q := url.Values{}
q.Set("state", state)
q.Set("page", strconv.Itoa(page))
q.Set("limit", "100")
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return nil, clierrors.OpError(clierrors.KindServer, "list", "issues", err).
WithCommand(ctx.CommandName)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
break
}
issuesRaw, ok := data["issues"].([]interface{})
if !ok || len(issuesRaw) == 0 {
break
}
for _, raw := range issuesRaw {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
item := issueItem{
ID: getInt(m, "id"),
Subject: getString(m, "subject"),
StatusID: getInt(m, "status_id"),
StatusName: getString(m, "status_name"),
PriorityID: getInt(m, "priority_id"),
PriorityName: getString(m, "priority_name"),
ProjectIndex: getInt(m, "project_issues_index"),
}
if assigners, ok := m["assigners"].([]interface{}); ok {
for _, a := range assigners {
if am, ok := a.(map[string]interface{}); ok {
login := getString(am, "login")
if login == "" {
login = getString(am, "name")
}
item.Assigners = append(item.Assigners, struct {
Login string `json:"login"`
Name string `json:"name"`
}{Login: login, Name: getString(am, "name")})
}
}
}
allIssues = append(allIssues, item)
}
totalCount := getInt(data, "total_count")
if totalCount == 0 {
totalCount = getInt(data, "total_issues_count")
}
if len(allIssues) >= totalCount || len(issuesRaw) < 100 {
break
}
page++
}
return allIssues, nil
}
// === board +issues ===
func newIssuesShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "issues",
Description: "List issues with optional filters",
Long: "List issues with filtering by status, assignee, and priority.",
Example: " gitlink-cli board +issues\n gitlink-cli board +issues --status in-progress --assignee zhangsan\n gitlink-cli board +issues --priority high --limit 10",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
{Name: "status", Usage: "Filter by status: new/in-progress/resolved/closed/rejected"},
{Name: "assignee", Short: "a", Usage: "Filter by assignee login"},
{Name: "priority", Short: "p", Usage: "Filter by priority: low/normal/high/urgent"},
{Name: "limit", Short: "l", Usage: "Max issues to show (0 = all)", Default: "0"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
state := ctx.Arg("state")
if state == "" {
state = "open"
}
issues, err := fetchAllIssuesWithState(ctx, state)
if err != nil {
return err
}
// 解析过滤条件
filterStatus := ctx.Arg("status")
filterAssignee := ctx.Arg("assignee")
filterPriority := ctx.Arg("priority")
limit, _ := strconv.Atoi(ctx.Arg("limit"))
var statusIDFilter int
if filterStatus != "" {
statusIDFilter, err = parseStatusID(filterStatus)
if err != nil {
return err
}
}
var priorityIDFilter int
if filterPriority != "" {
priorityIDFilter, err = parsePriorityID(filterPriority)
if err != nil {
return err
}
}
var filtered []map[string]interface{}
for _, iss := range issues {
if statusIDFilter != 0 && iss.StatusID != statusIDFilter {
continue
}
if filterAssignee != "" {
assignee := extractAssignee(iss.Assigners)
if !strings.EqualFold(assignee, filterAssignee) {
continue
}
}
if priorityIDFilter != 0 && iss.PriorityID != priorityIDFilter {
continue
}
filtered = append(filtered, map[string]interface{}{
"number": iss.ProjectIndex,
"id": iss.ID,
"subject": iss.Subject,
"status": iss.StatusName,
"priority": priorityName(iss.PriorityID),
"assigned_to": extractAssignee(iss.Assigners),
})
}
if limit > 0 && len(filtered) > limit {
filtered = filtered[:limit]
}
if filtered == nil {
filtered = []map[string]interface{}{}
}
return ctx.OutputData(filtered)
},
}
}
// === board +move ===
func newMoveShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "move",
Description: "Move an issue to a different status",
Long: "Change issue status. Accepts status names (new/in-progress/resolved/closed/rejected) or numeric IDs.",
Example: " gitlink-cli board +move --number 42 --status in-progress\n gitlink-cli board +move --number 42 --status closed --dry-run",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("Move issue #%s to status %q", ctx.Arg("number"), ctx.Arg("status")), nil
},
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (project_issues_index)", Required: true},
{Name: "status", Short: "s", Usage: "Target status: new/in-progress/resolved/closed/rejected (or numeric ID)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
statusStr, err := ctx.RequireArg("status", "--status in-progress")
if err != nil {
return err
}
statusID, err := parseStatusID(statusStr)
if err != nil {
return err
}
subject, description, err := fetchExistingIssue(ctx, number)
if err != nil {
return err
}
body := map[string]interface{}{
"subject": subject,
"description": description,
"status_id": statusID,
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "move", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
}
}
// === board +assign ===
func newAssignShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "assign",
Description: "Assign an issue to someone",
Long: "Assign an issue to a user by login name or user ID.",
Example: " gitlink-cli board +assign --number 42 --assignee zhangsan\n gitlink-cli board +assign --number 42 --assignee 123 --dry-run",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("Assign issue #%s to %s", ctx.Arg("number"), ctx.Arg("assignee")), nil
},
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (project_issues_index)", Required: true},
{Name: "assignee", Short: "a", Usage: "Assignee login name or user ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
assignee, err := ctx.RequireArg("assignee", "--assignee zhangsan")
if err != nil {
return err
}
assigneeID, err := resolveUserID(ctx, assignee)
if err != nil {
return fmt.Errorf("cannot resolve assignee %q: %w", assignee, err)
}
subject, description, err := fetchExistingIssue(ctx, number)
if err != nil {
return err
}
body := map[string]interface{}{
"subject": subject,
"description": description,
"assigner_ids": []interface{}{assigneeID},
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "assign", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
}
}
// === board +stats ===
func newStatsShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "stats",
Description: "Show board analytics and statistics",
Long: "Display completion rate, workload distribution, and bottleneck analysis.",
Example: " gitlink-cli board +stats\n gitlink-cli board +stats --state all",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "all"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
state := ctx.Arg("state")
if state == "" {
state = "all"
}
issues, err := fetchAllIssuesWithState(ctx, state)
if err != nil {
return err
}
grouped := groupByStatus(issues)
total := len(issues)
// 统计每人 issue 数量
assigneeCounts := make(map[string]int)
for _, iss := range issues {
a := extractAssignee(iss.Assigners)
if a == "" {
a = "(unassigned)"
}
assigneeCounts[a]++
}
// 完成率 = resolved + closed / total
completed := len(grouped[statusResolved]) + len(grouped[statusClosed])
completionRate := 0.0
if total > 0 {
completionRate = float64(completed) / float64(total) * 100
}
// 列统计
var colBreakdown []columnStat
for _, col := range columnOrder {
count := len(grouped[col.ID])
pct := 0.0
if total > 0 {
pct = float64(count) / float64(total) * 100
}
colBreakdown = append(colBreakdown, columnStat{
StatusName: col.Name,
Count: count,
Percentage: pct,
})
}
// 人员负载(降序)
var assigneeLoad []assigneeStat
for name, count := range assigneeCounts {
assigneeLoad = append(assigneeLoad, assigneeStat{Assignee: name, Count: count})
}
sort.Slice(assigneeLoad, func(i, j int) bool {
return assigneeLoad[i].Count > assigneeLoad[j].Count
})
// 瓶颈:非完成态中任务最多的列
bottleneck := ""
maxCount := 0
for _, col := range columnOrder[:3] { // 只看 new/in-progress/resolved
count := len(grouped[col.ID])
if count > maxCount {
maxCount = count
bottleneck = col.Name
}
}
stats := boardStats{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
TotalIssues: total,
CompletionRate: completionRate,
ColumnBreakdown: colBreakdown,
AssigneeLoad: assigneeLoad,
Bottleneck: bottleneck,
}
if stats.AssigneeLoad == nil {
stats.AssigneeLoad = []assigneeStat{}
}
return ctx.OutputData(stats)
},
}
}

View File

@ -4,18 +4,49 @@ import (
"fmt"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
)
// MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand.
func MountShortcut(parent *cobra.Command, s *Shortcut) {
cmd := &cobra.Command{
Use: "+" + s.Name,
Short: s.Description,
Use: "+" + s.Name,
Short: s.Description,
Long: s.Long,
Example: s.Example,
RunE: func(cmd *cobra.Command, args []string) error {
commandName := parent.Use + " +" + s.Name
// --- flag-level validation ---
for _, f := range s.Flags {
val := getFlagValue(cmd, f)
// choices enum validation
if len(f.Choices) > 0 && val != "" && val != "false" {
if !contains(f.Choices, val) {
return clierrors.InputError(
fmt.Sprintf("invalid value %q for --%s", val, f.Name),
fmt.Sprintf("有效值: %s。运行 'gitlink-cli %s --help' 查看用法。", strings.Join(f.Choices, ", "), commandName),
).WithCommand(commandName)
}
}
// custom validate function
if f.Validate != nil && val != "" {
if err := f.Validate(val); err != nil {
return clierrors.InputError(
fmt.Sprintf("invalid --%s: %v", f.Name, err),
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
).WithCommand(commandName)
}
}
}
// Collect flag values
flagValues := make(map[string]string)
for _, f := range s.Flags {
@ -30,7 +61,16 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
}
}
commandName := parent.Use + " +" + s.Name
// cross-flag validation
if s.Validate != nil {
if err := s.Validate(flagValues); err != nil {
return clierrors.InputError(
err.Error(),
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
).WithCommand(commandName)
}
}
ctx, err := NewRuntimeContext(flagValues, commandName)
if err != nil {
return err
@ -75,6 +115,9 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
if f.Required {
usage = usage + " [required]"
}
if len(f.Choices) > 0 {
usage = usage + " [" + strings.Join(f.Choices, "|") + "]"
}
if f.Bool {
defaultValue, _ := strconv.ParseBool(f.Default)
if f.Short != "" {
@ -97,6 +140,25 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
parent.AddCommand(cmd)
}
// getFlagValue returns the string value of a flag from a cobra command.
func getFlagValue(cmd *cobra.Command, f Flag) string {
if f.Bool {
val, _ := cmd.Flags().GetBool(f.Name)
return strconv.FormatBool(val)
}
val, _ := cmd.Flags().GetString(f.Name)
return val
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// MountShortcuts mounts multiple shortcuts under a parent command.
func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut) {
for _, s := range shortcuts {

View File

@ -21,9 +21,12 @@ import (
type Shortcut struct {
Name string
Description string
Long string // detailed help text (shown in --help)
Example string // usage examples (shown in --help)
Flags []Flag
DryRun bool // 是否支持 dry-run
DryRunHint func(ctx *RuntimeContext) (string, error) // 返回预览描述
Validate func(args map[string]string) error // cross-flag validation
Run func(ctx *RuntimeContext) error
}
@ -35,6 +38,8 @@ type Flag struct {
Required bool
Default string
Bool bool
Choices []string // allowed values (enum validation)
Validate func(value string) error // custom per-flag validation
}
// RuntimeContext provides helpers for shortcut implementations.
@ -47,6 +52,9 @@ type RuntimeContext struct {
Args map[string]string
GatewayBaseURL string
GatewayHTTPClient *http.Client // optional; nil = use auth.NewHTTPClient (mainly for tests)
NoTruncate bool // --no-truncate: disable table column truncation
Columns string // --columns: comma-separated column filter
NoColor bool // --no-color: disable ANSI color output
}
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
@ -59,7 +67,7 @@ func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeCont
format := cmdutil.Format
if format == "" {
format = "json"
format = "table"
}
gatewayBaseURL := config.DefaultGatewayBaseURL
@ -76,6 +84,9 @@ func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeCont
Args: args,
GatewayBaseURL: gatewayBaseURL,
GatewayHTTPClient: nil,
NoTruncate: cmdutil.NoTruncate,
Columns: cmdutil.Columns,
NoColor: cmdutil.NoColor,
}, nil
}
@ -107,12 +118,31 @@ func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.R
// Output prints the envelope in the configured format.
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
return output.Print(env, ctx.Format)
return output.PrintWithOpts(env, ctx.Format, ctx.printOpts())
}
// OutputData wraps data in a success envelope and prints it.
func (ctx *RuntimeContext) OutputData(data interface{}) error {
return output.Print(output.SuccessEnvelope(data, nil), ctx.Format)
return output.PrintWithOpts(output.SuccessEnvelope(data, nil), ctx.Format, ctx.printOpts())
}
// printOpts builds PrintOptions from the runtime context.
func (ctx *RuntimeContext) printOpts() output.PrintOptions {
opts := output.PrintOptions{
NoTruncate: ctx.NoTruncate,
UseColor: !ctx.NoColor,
}
if ctx.Columns != "" {
parts := strings.Split(ctx.Columns, ",")
opts.Columns = make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
opts.Columns = append(opts.Columns, p)
}
}
}
return opts
}
// RepoPath returns the API path prefix for the current owner/repo.

View File

@ -1,122 +0,0 @@
package compliance
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "scan",
Description: "Full compliance scan (all five modules)",
Flags: []common.Flag{
{Name: "module", Short: "m", Usage: "Comma-separated modules: license,deps,secrets,exposure,vocab"},
},
Run: runScan,
},
{Name: "license", Description: "License compliance check", Run: runLicense},
{Name: "deps", Description: "Dependency license check", Run: runDeps},
{Name: "secrets", Description: "Hardcoded secrets scan", Run: runSecrets},
{Name: "exposure", Description: "PII and network exposure scan", Run: runExposure},
{Name: "vocab", Description: "Sensitive vocabulary scan", Run: runVocab},
}
}
func repoRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
// find git root
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return dir, nil // fallback to cwd
}
dir = parent
}
}
// runScan is the full scan (all modules).
func runScan(ctx *common.RuntimeContext) error {
root, err := repoRoot()
if err != nil {
return err
}
selected := ctx.Arg("module")
var modules []string
if selected != "" {
modules = parseModules(selected)
} else {
modules = []string{"secrets", "exposure", "vocab"}
}
// license and deps checks
var allFindings []Finding
for _, m := range modules {
switch m {
case "license":
allFindings = append(allFindings, checkLicense(root)...)
case "deps":
allFindings = append(allFindings, checkDeps(root)...)
case "secrets", "exposure", "vocab":
rules := allRules()[m]
allFindings = append(allFindings, scanFiles(root, rules)...)
}
}
return outputReport(ctx, allFindings, modules)
}
func runLicense(ctx *common.RuntimeContext) error {
root, _ := repoRoot()
findings := checkLicense(root)
return outputReport(ctx, findings, []string{"license"})
}
func runDeps(ctx *common.RuntimeContext) error {
root, _ := repoRoot()
findings := checkDeps(root)
return outputReport(ctx, findings, []string{"deps"})
}
func runSecrets(ctx *common.RuntimeContext) error {
root, _ := repoRoot()
findings := scanFiles(root, allRules()["secrets"])
return outputReport(ctx, findings, []string{"secrets"})
}
func runExposure(ctx *common.RuntimeContext) error {
root, _ := repoRoot()
findings := scanFiles(root, allRules()["exposure"])
return outputReport(ctx, findings, []string{"exposure"})
}
func runVocab(ctx *common.RuntimeContext) error {
root, _ := repoRoot()
findings := scanFiles(root, allRules()["vocab"])
return outputReport(ctx, findings, []string{"vocab"})
}
func parseModules(s string) []string {
var result []string
seen := map[string]bool{}
for _, m := range strings.Split(s, ",") {
m = strings.TrimSpace(m)
valid := map[string]bool{"license": true, "deps": true, "secrets": true, "exposure": true, "vocab": true}
if valid[m] && !seen[m] {
result = append(result, m)
seen[m] = true
}
}
return result
}

View File

@ -1,77 +0,0 @@
package compliance
import (
"fmt"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// summary holds aggregated stats.
type summary struct {
Total int `json:"total"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
}
type reportData struct {
Modules []string `json:"modules"`
Findings []Finding `json:"findings"`
Summary summary `json:"summary"`
}
func outputReport(ctx *common.RuntimeContext, findings []Finding, modules []string) error {
s := summary{}
for _, f := range findings {
s.Total++
switch f.Severity {
case "critical": s.Critical++
case "high": s.High++
case "medium": s.Medium++
case "low": s.Low++
}
}
if ctx.Format == "json" {
return ctx.OutputData(reportData{Modules: modules, Findings: findings, Summary: s})
}
// human-readable output
fmt.Println()
printHR()
fmt.Printf(" Compliance Scan Report\n")
fmt.Printf(" Modules: %s | Findings: %d (critical:%d high:%d medium:%d low:%d)\n",
strings.Join(modules, ", "), s.Total, s.Critical, s.High, s.Medium, s.Low)
printHR()
if len(findings) == 0 {
fmt.Println(" All clear — no issues found.")
} else {
printFindings(findings)
}
printHR()
return nil
}
func printHR() {
fmt.Println(strings.Repeat("─", 60))
}
func printFindings(findings []Finding) {
labels := map[string]string{
"critical": "CRIT", "high": "HIGH", "medium": "MED", "low": "LOW",
}
for _, f := range findings {
label := labels[f.Severity]
if label == "" {
label = f.Severity
}
fmt.Printf(" [%s] %s %s:%d %s\n", label, f.ID, f.File, f.Line, f.Summary)
}
}
// Ensure output import is used
var _ = output.SuccessEnvelope

View File

@ -1,69 +0,0 @@
package contrib
import (
"fmt"
"os/exec"
"runtime"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "report",
Description: "Generate a contribution report with pie chart",
Flags: []common.Flag{
{Name: "output", Short: "o", Usage: "Output HTML file path", Default: "contrib-report.html"},
{Name: "open", Usage: "Open browser after generating", Bool: true, Default: "true"},
},
Run: runReport,
},
}
}
func runReport(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 1. 获取贡献者列表(从 issue 和 PR 数据中提取)
contributors, err := fetchContributors(ctx)
if err != nil {
return fmt.Errorf("fetch contributors: %w", err)
}
// 2. 计算加权贡献分数
reportData := calculateScores(contributors, nil, nil)
// 3. 生成 HTML 报告
outputPath := ctx.Arg("output")
if err := generateHTML(ctx.Owner, ctx.Repo, reportData, outputPath); err != nil {
return fmt.Errorf("generate HTML: %w", err)
}
fmt.Printf("Report generated: %s\n", outputPath)
// 4. 打开浏览器
if ctx.Arg("open") != "false" {
if err := openBrowser(outputPath); err != nil {
fmt.Printf("Warning: failed to open browser: %v\n", err)
}
}
return nil
}
// openBrowser 打开浏览器
func openBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
case "darwin":
cmd = exec.Command("open", url)
default: // linux
cmd = exec.Command("xdg-open", url)
}
return cmd.Start()
}

View File

@ -1,290 +0,0 @@
package contrib
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Contributor 贡献者信息
type Contributor struct {
Login string
Name string
Commits int
Additions int
Deletions int
Issues int
PRs int
Score float64
}
// AHP 权重(基于层次分析法计算)
const (
WeightCommits = 0.143
WeightCodeLines = 0.286
WeightPRs = 0.071
WeightIssues = 0.05
WeightIssueSolve = 0.1
WeightRelease = 0.1
WeightComments = 0.083
WeightPRReview = 0.083
WeightWiki = 0.083
)
// fetchContributors 从 issue 和 PR 数据中提取贡献者
func fetchContributors(ctx *common.RuntimeContext) ([]Contributor, error) {
contributorMap := make(map[string]*Contributor)
// 1. 从 Issue 列表中提取贡献者
issues, err := fetchAllIssues(ctx)
if err != nil {
return nil, fmt.Errorf("fetch issues: %w", err)
}
for _, issue := range issues {
login := issue["login"]
name := issue["name"]
if login == "" {
continue
}
if _, exists := contributorMap[login]; !exists {
contributorMap[login] = &Contributor{
Login: login,
Name: name,
}
if contributorMap[login].Name == "" {
contributorMap[login].Name = login
}
}
contributorMap[login].Issues++
}
// 2. 从 PR 列表中提取贡献者
prs, err := fetchAllPRs(ctx)
if err != nil {
return nil, fmt.Errorf("fetch PRs: %w", err)
}
for _, pr := range prs {
login := pr["login"]
name := pr["name"]
if login == "" {
continue
}
if _, exists := contributorMap[login]; !exists {
contributorMap[login] = &Contributor{
Login: login,
Name: name,
}
if contributorMap[login].Name == "" {
contributorMap[login].Name = login
}
}
contributorMap[login].PRs++
}
// 转换为切片
var contributors []Contributor
for _, c := range contributorMap {
contributors = append(contributors, *c)
}
if len(contributors) == 0 {
return nil, fmt.Errorf("no contributors found")
}
return contributors, nil
}
// fetchAllIssues 获取所有 Issue
func fetchAllIssues(ctx *common.RuntimeContext) ([]map[string]string, error) {
var results []map[string]string
page := 1
for {
q := url.Values{}
q.Set("page", fmt.Sprintf("%d", page))
q.Set("limit", "100")
q.Set("state", "all")
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo), q)
if err != nil {
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
break
}
issues, ok := data["issues"].([]interface{})
if !ok || len(issues) == 0 {
break
}
for _, item := range issues {
issue, ok := item.(map[string]interface{})
if !ok {
continue
}
author, ok := issue["author"].(map[string]interface{})
if !ok {
continue
}
login := getString(author, "login")
name := getString(author, "name")
if login != "" {
results = append(results, map[string]string{
"login": login,
"name": name,
})
}
}
totalCount := getInt(data, "total_count")
if page*100 >= totalCount {
break
}
page++
}
return results, nil
}
// fetchAllPRs 获取所有 PR
func fetchAllPRs(ctx *common.RuntimeContext) ([]map[string]string, error) {
var results []map[string]string
page := 1
for {
q := url.Values{}
q.Set("page", fmt.Sprintf("%d", page))
q.Set("limit", "100")
q.Set("state", "all")
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/pulls", ctx.Owner, ctx.Repo), q)
if err != nil {
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
break
}
// PR 列表字段是 "pulls"
prs, ok := data["pulls"].([]interface{})
if !ok || len(prs) == 0 {
break
}
for _, item := range prs {
pr, ok := item.(map[string]interface{})
if !ok {
continue
}
// PR 的作者信息在 issue.author 中
login := ""
name := ""
if issue, ok := pr["issue"].(map[string]interface{}); ok {
if author, ok := issue["author"].(map[string]interface{}); ok {
login = getString(author, "login")
name = getString(author, "name")
}
}
if login != "" {
results = append(results, map[string]string{
"login": login,
"name": name,
})
}
}
// 检查是否还有更多页
searchCount := getInt(data, "search_count")
if page*100 >= searchCount {
break
}
page++
}
return results, nil
}
// calculateScores 计算加权贡献分数
func calculateScores(contributors []Contributor, issueCounts, prCounts map[string]int) []Contributor {
// 如果提供了额外的计数,更新贡献者数据
if issueCounts != nil {
for i := range contributors {
c := &contributors[i]
if count, ok := issueCounts[c.Login]; ok {
c.Issues = count
}
}
}
if prCounts != nil {
for i := range contributors {
c := &contributors[i]
if count, ok := prCounts[c.Login]; ok {
c.PRs = count
}
}
}
// 找到各指标的最大值(用于归一化)
maxIssues := 0
maxPRs := 0
for i := range contributors {
c := &contributors[i]
if c.Issues > maxIssues {
maxIssues = c.Issues
}
if c.PRs > maxPRs {
maxPRs = c.PRs
}
}
// 计算加权分数(归一化后)
for i := range contributors {
c := &contributors[i]
// 归一化到 0-1
normIssues := 0.0
normPRs := 0.0
if maxIssues > 0 {
normIssues = float64(c.Issues) / float64(maxIssues)
}
if maxPRs > 0 {
normPRs = float64(c.PRs) / float64(maxPRs)
}
// 加权求和(只用 issue 和 PR因为 commits API 不可用)
c.Score = normIssues*WeightIssues + normPRs*WeightPRs
}
return contributors
}
// getString 从 map 中获取字符串
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// getInt 从 map 中获取整数
func getInt(m map[string]interface{}, key string) int {
if v, ok := m[key]; ok {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
}
}
return 0
}

View File

@ -1,493 +0,0 @@
package contrib
import (
"fmt"
"html/template"
"os"
"sort"
)
// ReportData 报告数据
type ReportData struct {
Owner string
Repo string
Contributors []Contributor
ChartLabels template.JS
ChartValues template.JS
TableRows []TableRow
}
// TableRow 表格行
type TableRow struct {
Rank int
Login string
Name string
Commits int
CodeLines int
Issues int
PRs int
Score float64
ScorePct float64
}
// generateHTML 生成 HTML 报告
func generateHTML(owner, repo string, contributors []Contributor, outputPath string) error {
// 按分数排序
sort.Slice(contributors, func(i, j int) bool {
return contributors[i].Score > contributors[j].Score
})
// 准备图表数据
var labels []string
var values []string
var tableRows []TableRow
totalScore := 0.0
for _, c := range contributors {
totalScore += c.Score
}
for i, c := range contributors {
labels = append(labels, fmt.Sprintf("%q", c.Login))
values = append(values, fmt.Sprintf("\"%.4f\"", c.Score))
scorePct := 0.0
if totalScore > 0 {
scorePct = c.Score / totalScore * 100
}
tableRows = append(tableRows, TableRow{
Rank: i + 1,
Login: c.Login,
Name: c.Name,
Commits: c.Commits,
CodeLines: c.Additions + c.Deletions,
Issues: c.Issues,
PRs: c.PRs,
Score: c.Score,
ScorePct: scorePct,
})
}
data := ReportData{
Owner: owner,
Repo: repo,
Contributors: contributors,
ChartLabels: template.JS(fmt.Sprintf("[%s]", joinStrings(labels, ","))),
ChartValues: template.JS(fmt.Sprintf("[%s]", joinStrings(values, ","))),
TableRows: tableRows,
}
// 创建 HTML 文件
file, err := os.Create(outputPath)
if err != nil {
return err
}
defer file.Close()
// 解析并执行模板
tmpl, err := template.New("report").Parse(htmlTemplate)
if err != nil {
return err
}
return tmpl.Execute(file, data)
}
// joinStrings 连接字符串切片
func joinStrings(strs []string, sep string) string {
result := ""
for i, s := range strs {
if i > 0 {
result += sep
}
result += s
}
return result
}
// htmlTemplate HTML 模板
const htmlTemplate = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>贡献者报告 - {{.Owner}}/{{.Repo}}</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 40px;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1.1rem;
opacity: 0.9;
}
.card {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 30px;
margin-bottom: 30px;
}
.card h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.5rem;
border-bottom: 3px solid #667eea;
padding-bottom: 10px;
}
.chart-container {
width: 100%;
height: 500px;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 15px 20px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 600;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 1px;
}
tr:hover {
background: #f8f9ff;
}
.rank {
font-weight: bold;
color: #667eea;
font-size: 1.2rem;
}
.rank-1 { color: #FFD700; }
.rank-2 { color: #C0C0C0; }
.rank-3 { color: #CD7F32; }
.score-bar {
background: #e9ecef;
border-radius: 10px;
height: 20px;
overflow: hidden;
}
.score-fill {
background: linear-gradient(90deg, #667eea, #764ba2);
height: 100%;
border-radius: 10px;
transition: width 0.5s ease;
}
.score-text {
font-weight: bold;
color: #667eea;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
margin-right: 10px;
}
.user-info {
display: flex;
align-items: center;
}
.user-name {
font-weight: 600;
color: #333;
}
.user-login {
color: #666;
font-size: 0.9rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-top: 30px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 20px;
color: white;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.9;
}
.weight-info {
background: #f8f9ff;
border-radius: 12px;
padding: 20px;
margin-top: 20px;
}
.weight-info h3 {
color: #667eea;
margin-bottom: 15px;
}
.weight-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.weight-item {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: white;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.weight-label {
color: #666;
}
.weight-value {
font-weight: 600;
color: #667eea;
}
@media (max-width: 768px) {
.header h1 {
font-size: 1.8rem;
}
th, td {
padding: 10px 12px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>贡献者报告</h1>
<p>{{.Owner}}/{{.Repo}} - 团队成员贡献分析</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{len .Contributors}}</div>
<div class="stat-label">贡献者总数</div>
</div>
<div class="stat-card">
<div class="stat-value" id="total-commits">0</div>
<div class="stat-label">总提交数</div>
</div>
<div class="stat-card">
<div class="stat-value" id="total-issues">0</div>
<div class="stat-label"> Issue </div>
</div>
<div class="stat-card">
<div class="stat-value" id="total-prs">0</div>
<div class="stat-label"> PR </div>
</div>
</div>
<div class="card">
<h2>贡献占比分布</h2>
<div id="pieChart" class="chart-container"></div>
</div>
<div class="card">
<h2>详细排名</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>排名</th>
<th>成员</th>
<th>Commits</th>
<th>代码行数</th>
<th>Issues</th>
<th>PRs</th>
<th>贡献分数</th>
<th>占比</th>
</tr>
</thead>
<tbody>
{{range .TableRows}}
<tr>
<td class="rank rank-{{.Rank}}">{{.Rank}}</td>
<td>
<div class="user-info">
<div class="avatar">{{slice .Login 0 1}}</div>
<div>
<div class="user-name">{{.Name}}</div>
<div class="user-login">@{{.Login}}</div>
</div>
</div>
</td>
<td>{{.Commits}}</td>
<td>{{.CodeLines}}</td>
<td>{{.Issues}}</td>
<td>{{.PRs}}</td>
<td class="score-text">{{printf "%.4f" .Score}}</td>
<td>
<div class="score-bar">
<div class="score-fill" style="width: {{printf "%.1f" .ScorePct}}%"></div>
</div>
<small>{{printf "%.1f" .ScorePct}}%</small>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>AHP 权重说明</h2>
<div class="weight-info">
<h3>层次分析法 (AHP) 权重分配</h3>
<div class="weight-grid">
<div class="weight-item">
<span class="weight-label">Commits</span>
<span class="weight-value">14.3%</span>
</div>
<div class="weight-item">
<span class="weight-label">代码行数</span>
<span class="weight-value">28.6%</span>
</div>
<div class="weight-item">
<span class="weight-label">PR 合并数</span>
<span class="weight-value">7.1%</span>
</div>
<div class="weight-item">
<span class="weight-label">Issue 创建</span>
<span class="weight-value">5.0%</span>
</div>
<div class="weight-item">
<span class="weight-label">Issue 解决</span>
<span class="weight-value">10.0%</span>
</div>
<div class="weight-item">
<span class="weight-label">Release</span>
<span class="weight-value">10.0%</span>
</div>
<div class="weight-item">
<span class="weight-label">Issue 评论</span>
<span class="weight-value">8.3%</span>
</div>
<div class="weight-item">
<span class="weight-label">PR 评审</span>
<span class="weight-value">8.3%</span>
</div>
<div class="weight-item">
<span class="weight-label">Wiki</span>
<span class="weight-value">8.3%</span>
</div>
</div>
</div>
</div>
</div>
<script>
// 计算总数
let totalCommits = 0;
let totalIssues = 0;
let totalPRs = 0;
{{range .Contributors}}
totalCommits += {{.Commits}};
totalIssues += {{.Issues}};
totalPRs += {{.PRs}};
{{end}}
document.getElementById('total-commits').textContent = totalCommits;
document.getElementById('total-issues').textContent = totalIssues;
document.getElementById('total-prs').textContent = totalPRs;
// 初始化饼图
var chart = echarts.init(document.getElementById('pieChart'));
var option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle',
textStyle: {
fontSize: 14
}
},
series: [{
name: '贡献占比',
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}\n{d}%',
fontSize: 12
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold'
}
},
data: [
{{range .Contributors}}
{
value: {{printf "%.4f" .Score}},
name: '{{.Login}}'
},
{{end}}
]
}]
};
chart.setOption(option);
// 响应式
window.addEventListener('resize', function() {
chart.resize();
});
</script>
</body>
</html>`

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

@ -0,0 +1,427 @@
package file
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// v1RepoPath returns /v1/{owner}/{repo}
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// === 浏览类 ===
{
Name: "ls",
Description: "List files in root directory",
Flags: []common.Flag{
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/entries", q)
if err != nil {
return fmt.Errorf("获取目录列表失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "tree",
Description: "Show subdirectory or file details",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File or directory path", Required: true},
{Name: "ref", 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", "--path src/main.go")
if err != nil {
return err
}
q := url.Values{}
q.Set("filepath", filepath)
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
if err != nil {
return fmt.Errorf("获取路径详情失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "read",
Description: "Read file content",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "ref", 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", "--path README.md")
if err != nil {
return err
}
q := url.Values{}
q.Set("filepath", filepath)
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
if err != nil {
return fmt.Errorf("读取文件失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "readme",
Description: "Read README file",
Flags: []common.Flag{
{Name: "path", Usage: "Subdirectory path for nested README"},
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if p := ctx.Arg("path"); p != "" {
q.Set("filepath", p)
}
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/readme", q)
if err != nil {
return fmt.Errorf("读取 README 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "search",
Description: "Search files by name",
Flags: []common.Flag{
{Name: "q", Short: "q", Usage: "Search keyword"},
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if kw := ctx.Arg("q"); kw != "" {
q.Set("search", kw)
}
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q)
if err != nil {
return fmt.Errorf("搜索文件失败: %w", err)
}
return ctx.Output(env)
},
},
// === 文件 CRUD ===
{
Name: "create",
Description: "Create a new file",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("创建文件 %s", ctx.Arg("path")), nil
},
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "content", Short: "c", Usage: "File content (plain text)", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "new-branch", Usage: "Create on a new branch"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filepath, err := ctx.RequireArg("path", "--path docs/new.md")
if err != nil {
return err
}
content, err := ctx.RequireArg("content", `--content "# Hello"`)
if err != nil {
return err
}
branch, err := ctx.RequireArg("branch", "--branch master")
if err != nil {
return err
}
message, err := ctx.RequireArg("message", `--message "add new file"`)
if err != nil {
return err
}
body := map[string]interface{}{
"filepath": filepath,
"base64_filepath": base64.StdEncoding.EncodeToString([]byte(filepath)),
"branch": branch,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
}
if nb := ctx.Arg("new-branch"); nb != "" {
body["new_branch"] = nb
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/create_file", body)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an existing file",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新文件 %s", ctx.Arg("path")), nil
},
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "content", Short: "c", Usage: "New file content (plain text)", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
{Name: "sha", Usage: "File SHA (auto-fetched if omitted)"},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "new-branch", Usage: "Create on a new branch"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filepath, err := ctx.RequireArg("path", "--path README.md")
if err != nil {
return err
}
content, err := ctx.RequireArg("content", `--content "updated content"`)
if err != nil {
return err
}
branch, err := ctx.RequireArg("branch", "--branch master")
if err != nil {
return err
}
message, err := ctx.RequireArg("message", `--message "update file"`)
if err != nil {
return err
}
// Auto-fetch sha if not provided
sha := ctx.Arg("sha")
if sha == "" {
sha, err = fetchFileSha(ctx, filepath, branch)
if err != nil {
return fmt.Errorf("自动获取文件 SHA 失败,请用 --sha 手动指定: %w", err)
}
}
body := map[string]interface{}{
"filepath": filepath,
"branch": branch,
"content": content,
"sha": sha,
"message": message,
}
if nb := ctx.Arg("new-branch"); nb != "" {
body["new_branch"] = nb
}
env, err := ctx.CallAPI("PUT", ctx.RepoPath()+"/update_file", body)
if err != nil {
return fmt.Errorf("更新文件失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a file",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除文件 %s", ctx.Arg("path")), nil
},
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
{Name: "sha", Usage: "File SHA (auto-fetched if omitted)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filepath, err := ctx.RequireArg("path", "--path old-file.txt")
if err != nil {
return err
}
branch, err := ctx.RequireArg("branch", "--branch master")
if err != nil {
return err
}
// Auto-fetch sha if not provided
sha := ctx.Arg("sha")
if sha == "" {
sha, err = fetchFileSha(ctx, filepath, branch)
if err != nil {
return fmt.Errorf("自动获取文件 SHA 失败,请用 --sha 手动指定: %w", err)
}
}
body := map[string]interface{}{
"filepath": filepath,
"branch": branch,
"sha": sha,
}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/delete_file", body)
if err != nil {
return fmt.Errorf("删除文件失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "batch",
Description: "Batch create/update/delete files in one commit",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("批量提交文件到分支 %s", ctx.Arg("branch")), nil
},
Flags: []common.Flag{
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "files", Short: "f", Usage: "JSON array: [{\"action_type\":\"create\",\"file_path\":\"x\",\"content\":\"y\"}]", Required: true},
{Name: "new-branch", Usage: "Create on a new branch"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
branch, err := ctx.RequireArg("branch", "--branch master")
if err != nil {
return err
}
message, err := ctx.RequireArg("message", `--message "batch update"`)
if err != nil {
return err
}
filesJSON, err := ctx.RequireArg("files", `--files '[{"action_type":"create","file_path":"a.txt","content":"hello"}]'`)
if err != nil {
return err
}
var files []map[string]interface{}
if err := json.Unmarshal([]byte(filesJSON), &files); err != nil {
return fmt.Errorf("--files JSON 解析失败: %w", err)
}
body := map[string]interface{}{
"branch": branch,
"message": message,
"files": files,
}
if nb := ctx.Arg("new-branch"); nb != "" {
body["new_branch"] = nb
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/contents/batch", body)
if err != nil {
return fmt.Errorf("批量提交失败: %w", err)
}
return ctx.Output(env)
},
},
// === Git 对象 ===
{
Name: "commits",
Description: "List commit history",
Flags: []common.Flag{
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
{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"))
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/commits", q)
if err != nil {
return fmt.Errorf("获取提交历史失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "diff",
Description: "Show diff of a commit",
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", "--sha abc1234")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/commits/%s/diff", v1RepoPath(ctx), sha), nil)
if err != nil {
return fmt.Errorf("获取 diff 失败: %w", err)
}
return ctx.Output(env)
},
},
}
}
// fetchFileSha retrieves the current SHA of a file via sub_entries API.
func fetchFileSha(ctx *common.RuntimeContext, filepath, ref string) (string, error) {
q := url.Values{}
q.Set("filepath", filepath)
if ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
if err != nil {
return "", err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("响应格式异常")
}
entries, ok := data["entries"].(map[string]interface{})
if !ok {
// Try flat structure
if sha, ok := data["sha"].(string); ok {
return sha, nil
}
return "", fmt.Errorf("响应中未找到 entries 或 sha 字段")
}
sha, _ := entries["sha"].(string)
if sha == "" {
return "", fmt.Errorf("文件 SHA 为空")
}
return sha, nil
}

459
shortcuts/file/file_test.go Normal file
View File

@ -0,0 +1,459 @@
package file
import (
"encoding/base64"
"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"
)
// === 浏览类测试 ===
func TestLsCallsEntriesEndpoint(t *testing.T) {
var requestedPath string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"entries": []interface{}{
map[string]interface{}{"name": "README.md", "type": "file"},
},
})
})
defer server.Close()
err := runFileShortcut(t, server, "ls", map[string]string{})
if err != nil {
t.Fatalf("ls failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/entries.json")
}
func TestLsWithRef(t *testing.T) {
var requestedRef string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedRef = r.URL.Query().Get("ref")
writeJSON(t, w, map[string]interface{}{"entries": []interface{}{}})
})
defer server.Close()
err := runFileShortcut(t, server, "ls", map[string]string{"ref": "develop"})
if err != nil {
t.Fatalf("ls with ref failed: %v", err)
}
assertEqual(t, requestedRef, "develop")
}
func TestTreeCallsSubEntriesWithFilePath(t *testing.T) {
var requestedPath, requestedFilepath string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
requestedFilepath = r.URL.Query().Get("filepath")
writeJSON(t, w, map[string]interface{}{
"entries": map[string]interface{}{
"name": "main.go", "type": "file", "sha": "abc123",
},
})
})
defer server.Close()
err := runFileShortcut(t, server, "tree", map[string]string{"path": "src/main.go"})
if err != nil {
t.Fatalf("tree failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/sub_entries.json")
assertEqual(t, requestedFilepath, "src/main.go")
}
func TestReadCallsSubEntriesWithFilePath(t *testing.T) {
var requestedFilepath string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedFilepath = r.URL.Query().Get("filepath")
writeJSON(t, w, map[string]interface{}{
"entries": map[string]interface{}{
"name": "main.go", "content": "package main", "sha": "abc123",
},
})
})
defer server.Close()
err := runFileShortcut(t, server, "read", map[string]string{"path": "main.go"})
if err != nil {
t.Fatalf("read failed: %v", err)
}
assertEqual(t, requestedFilepath, "main.go")
}
func TestReadmeCallsReadmeEndpoint(t *testing.T) {
var requestedPath string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"name": "README.md", "content": "# Hello",
})
})
defer server.Close()
err := runFileShortcut(t, server, "readme", map[string]string{})
if err != nil {
t.Fatalf("readme failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/readme.json")
}
func TestSearchCallsFilesWithKeyword(t *testing.T) {
var requestedPath, requestedSearch string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
requestedSearch = r.URL.Query().Get("search")
writeJSON(t, w, []interface{}{
map[string]interface{}{"name": "test.go"},
})
})
defer server.Close()
err := runFileShortcut(t, server, "search", map[string]string{"q": "test"})
if err != nil {
t.Fatalf("search failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/files.json")
assertEqual(t, requestedSearch, "test")
}
// === 文件 CRUD 测试 ===
func TestCreateEncodesContentBase64(t *testing.T) {
var payload map[string]interface{}
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"name": "new.txt", "sha": "abc123",
})
})
defer server.Close()
err := runFileShortcut(t, server, "create", map[string]string{
"path": "docs/new.txt",
"content": "hello world",
"branch": "master",
"message": "add file",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
// Verify content is base64 encoded
encodedContent, ok := payload["content"].(string)
if !ok {
t.Fatal("content should be a string")
}
decoded, err := base64.StdEncoding.DecodeString(encodedContent)
if err != nil {
t.Fatalf("content is not valid base64: %v", err)
}
assertEqual(t, string(decoded), "hello world")
// Verify filepath is base64 encoded
encodedPath, ok := payload["base64_filepath"].(string)
if !ok {
t.Fatal("base64_filepath should be a string")
}
decodedPath, err := base64.StdEncoding.DecodeString(encodedPath)
if err != nil {
t.Fatalf("base64_filepath is not valid base64: %v", err)
}
assertEqual(t, string(decodedPath), "docs/new.txt")
assertEqual(t, payload["filepath"], "docs/new.txt")
assertEqual(t, payload["branch"], "master")
assertEqual(t, payload["message"], "add file")
}
func TestUpdateAutoFetchesSha(t *testing.T) {
var updatePayload map[string]interface{}
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/sub_entries.json":
writeJSON(t, w, map[string]interface{}{
"entries": map[string]interface{}{
"name": "README.md", "sha": "old-sha-123",
},
})
case r.Method == "PUT" && r.URL.Path == "/owner/repo/update_file.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(1), "message": "更新成功"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runFileShortcut(t, server, "update", map[string]string{
"path": "README.md",
"content": "updated content",
"branch": "master",
"message": "update readme",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
assertEqual(t, updatePayload["sha"], "old-sha-123")
assertEqual(t, updatePayload["content"], "updated content")
assertEqual(t, updatePayload["filepath"], "README.md")
}
func TestUpdateUsesProvidedSha(t *testing.T) {
var updatePayload map[string]interface{}
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PUT" {
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(1)})
}
})
defer server.Close()
err := runFileShortcut(t, server, "update", map[string]string{
"path": "README.md",
"content": "new content",
"branch": "master",
"sha": "manual-sha",
"message": "update",
})
if err != nil {
t.Fatalf("update with sha failed: %v", err)
}
assertEqual(t, updatePayload["sha"], "manual-sha")
}
func TestDeleteAutoFetchesSha(t *testing.T) {
var deletePayload map[string]interface{}
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
writeJSON(t, w, map[string]interface{}{
"entries": map[string]interface{}{
"name": "old.txt", "sha": "file-sha-456",
},
})
case r.Method == "DELETE":
deletePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(1), "message": "文件删除成功"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runFileShortcut(t, server, "delete", map[string]string{
"path": "old.txt",
"branch": "master",
})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
assertEqual(t, deletePayload["sha"], "file-sha-456")
assertEqual(t, deletePayload["filepath"], "old.txt")
assertEqual(t, deletePayload["branch"], "master")
}
func TestBatchSendsFilesArray(t *testing.T) {
var payload map[string]interface{}
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"commit": map[string]interface{}{"sha": "abc"},
})
})
defer server.Close()
err := runFileShortcut(t, server, "batch", map[string]string{
"branch": "master",
"message": "batch commit",
"files": `[{"action_type":"create","file_path":"a.txt","content":"hello"}]`,
})
if err != nil {
t.Fatalf("batch failed: %v", err)
}
files, ok := payload["files"].([]interface{})
if !ok {
t.Fatalf("files should be array, got %T", payload["files"])
}
assertEqual(t, len(files), 1)
assertEqual(t, payload["branch"], "master")
assertEqual(t, payload["message"], "batch commit")
}
func TestBatchRejectsInvalidJSON(t *testing.T) {
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, map[string]interface{}{})
})
defer server.Close()
err := runFileShortcut(t, server, "batch", map[string]string{
"branch": "master",
"message": "test",
"files": "not-json",
})
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
}
// === Git 对象测试 ===
func TestCommitsCallsV1Endpoint(t *testing.T) {
var requestedPath string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, []interface{}{
map[string]interface{}{"sha": "abc", "message": "test"},
})
})
defer server.Close()
err := runFileShortcut(t, server, "commits", map[string]string{})
if err != nil {
t.Fatalf("commits failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/commits.json")
}
func TestCommitsPassesPagination(t *testing.T) {
var page, limit string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
page = r.URL.Query().Get("page")
limit = r.URL.Query().Get("limit")
writeJSON(t, w, []interface{}{})
})
defer server.Close()
err := runFileShortcut(t, server, "commits", map[string]string{"page": "3", "limit": "10"})
if err != nil {
t.Fatalf("commits with pagination failed: %v", err)
}
assertEqual(t, page, "3")
assertEqual(t, limit, "10")
}
func TestDiffCallsCommitDiffEndpoint(t *testing.T) {
var requestedPath string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"files": []interface{}{},
})
})
defer server.Close()
err := runFileShortcut(t, server, "diff", map[string]string{"sha": "abc1234"})
if err != nil {
t.Fatalf("diff failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/commits/abc1234/diff.json")
}
// === 必填参数验证 ===
func TestCreateRequiresPath(t *testing.T) {
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runFileShortcut(t, server, "create", map[string]string{
"content": "hello", "branch": "master", "message": "test",
})
if err == nil {
t.Fatal("expected error for missing --path")
}
}
func TestTreeRequiresPath(t *testing.T) {
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runFileShortcut(t, server, "tree", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --path")
}
}
func TestDiffRequiresSha(t *testing.T) {
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runFileShortcut(t, server, "diff", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --sha")
}
}
// === helpers ===
func runFileShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findFileShortcut(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 findFileShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("file shortcut %q not found", name)
return nil
}
func newFileTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
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("decode JSON failed: %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("write JSON failed: %v", err)
}
}
func assertEqual(t *testing.T, got, 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)
}
}
func assertPath(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Fatalf("path: got %s, want %s", got, want)
}
}

BIN
shortcuts/gitlink-cli.exe Normal file

Binary file not shown.

View File

@ -1,16 +1,20 @@
package issue
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"encoding/csv" // CSV 文件解析,用于从文件读取 Issue 编号
"fmt" // 格式化输出,用于构建字符串和错误信息
"os" // 文件操作,用于打开 CSV 文件
"strconv" // 字符串和数字之间的转换
"strings" // 字符串处理,用于分割、修剪等操作
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/common" // 公共工具包,包含 Shortcut、RuntimeContext 等
)
// Priority constants
// === 优先级常量 ===
// priorityLow: 低优先级
// priorityNormal: 普通优先级(默认)
// priorityHigh: 高优先级
// priorityUrgent: 紧急优先级
const (
priorityLow = 1
priorityNormal = 2
@ -18,7 +22,12 @@ const (
priorityUrgent = 4
)
// Status constants
// === 状态常量 ===
// statusNew: 新建
// statusInProgress: 进行中
// statusResolved: 已解决
// statusClosed: 已关闭
// statusRejected: 已拒绝
const (
statusNew = 1
statusInProgress = 2
@ -27,7 +36,14 @@ const (
statusRejected = 6
)
// Tracker constants
// === 类型常量Tracker===
// trackerBug: 缺陷
// trackerFeature: 功能
// trackerSupport: 支持
// trackerDoc: 文档
// trackerTest: 测试
// trackerDuplicate: 重复
// trackerQuestion: 疑问
const (
trackerBug = 1
trackerFeature = 2
@ -38,6 +54,11 @@ const (
trackerQuestion = 7
)
// === 名称映射表 ===
// priorityNames: 优先级数字 ID → 英文名称
// statusNames: 状态数字 ID → 英文名称
// trackerNames: 类型数字 ID → 英文名称
// 作用:把数字 ID 转换成可读的英文名称,方便输出结果
var priorityNames = map[int]string{
priorityLow: "low",
priorityNormal: "normal",
@ -63,8 +84,10 @@ var trackerNames = map[int]string{
trackerQuestion: "question",
}
// Tag name → GitLink tag ID mapping
// Collect IDs from web UI DevTools: change tag → capture PATCH payload → get issue_tag_ids value
// === 标签 ID 映射 ===
// tagIDs: 中文标签名称 → GitLink 标签 ID
// 获取方式:从网页端 DevTools 抓包获取(修改标签 → 捕获 PATCH 请求体 → 获取 issue_tag_ids 值)
// 注意:这些 ID 是项目特定的,不同项目可能不同
var tagIDs = map[string]int{
"缺陷": 315526,
"功能": 315527,
@ -78,7 +101,9 @@ var tagIDs = map[string]int{
"搁置": 315532,
}
// labelNames returns all known tag names from the given mapping.
// labelNames 返回所有已知的标签名称(逗号分隔)
// 参数: tags - 标签名称到 ID 的映射
// 返回: 所有标签名称的字符串,用逗号分隔
func labelNames(tags map[string]int) string {
var names []string
for name := range tags {
@ -87,28 +112,31 @@ func labelNames(tags map[string]int) string {
return strings.Join(names, ", ")
}
// BatchResult is a single item result in a batch operation.
// === 结果结构体 ===
// BatchResult 表示单个 Issue 的操作结果
type BatchResult 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"`
Number string `json:"number" yaml:"number"` // Issue 编号
Action string `json:"action" yaml:"action"` // 操作类型close/set-status/set-priority/set-assignee/set-label
Status string `json:"status" yaml:"status"` // 操作状态planned预览/closed已关闭/failed失败
Error string `json:"error,omitempty" yaml:"error,omitempty"` // 错误信息(失败时)
}
// BatchSummary is the aggregate result of a batch operation.
// BatchSummary 表示批量操作的汇总报告
type BatchSummary struct {
Repository string `json:"repository" yaml:"repository"`
Action string `json:"action" yaml:"action"`
Value string `json:"value,omitempty" yaml:"value,omitempty"`
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"`
Repository string `json:"repository" yaml:"repository"` // 仓库名称owner/repo
Action string `json:"action" yaml:"action"` // 操作类型
Value string `json:"value,omitempty" yaml:"value,omitempty"` // 操作目标值(如状态名、优先级名)
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"` // 所有操作结果列表
}
// ---- batch-close ----
// === batch-close 命令:批量关闭 Issue ===
// newBatchCloseShortcut 创建 batch-close 命令
func newBatchCloseShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-close",
@ -122,10 +150,21 @@ func newBatchCloseShortcut() *common.Shortcut {
}
}
// runBatchClose 执行批量关闭操作
// 执行流程:
// 1. 解析仓库信息
// 2. 收集 Issue 编号(从 --numbers 参数或 CSV 文件)
// 3. 初始化 BatchSummary 汇总对象
// 4. 遍历每个 Issue 编号:
// - 如果是 dry-run直接标记为 planned
// - 否则调用 updateIssueField 更新状态为 closed
// 5. 输出汇总结果
func runBatchClose(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2收集 Issue 编号(支持 --numbers 参数和 --from CSV 文件)
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
@ -134,6 +173,7 @@ func runBatchClose(ctx *common.RuntimeContext) error {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
// 步骤3初始化汇总对象
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
@ -143,14 +183,17 @@ func runBatchClose(ctx *common.RuntimeContext) error {
Results: make([]BatchResult, 0, len(numbers)),
}
// 步骤4遍历处理每个 Issue
for _, number := range numbers {
result := BatchResult{Number: number, Action: "close"}
if dryRun {
// 预览模式:不实际操作,只标记为 planned
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 实际操作:调用 updateIssueField 更新状态为 closed
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusClosed}); err != nil {
result.Status = "failed"
result.Error = err.Error()
@ -162,17 +205,20 @@ func runBatchClose(ctx *common.RuntimeContext) error {
summary.Results = append(summary.Results, result)
}
// 步骤5输出汇总结果
if err := ctx.OutputData(summary); err != nil {
return err
}
// 如果有失败的操作,返回错误
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
// ---- batch-status ----
// === batch-status 命令:批量修改 Issue 状态 ===
// newBatchStatusShortcut 创建 batch-status 命令
func newBatchStatusShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-status",
@ -187,16 +233,21 @@ func newBatchStatusShortcut() *common.Shortcut {
}
}
// runBatchStatus 执行批量修改状态操作
// 参数 --state 指定目标状态new/in-progress/resolved/closed/rejected
func runBatchStatus(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取状态参数并转换为数字 ID
state := ctx.Arg("state")
statusID, err := parseStatus(state)
if err != nil {
return err
}
// 步骤3收集 Issue 编号
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
@ -205,6 +256,7 @@ func runBatchStatus(ctx *common.RuntimeContext) error {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
// 步骤4初始化汇总对象
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
@ -215,6 +267,7 @@ func runBatchStatus(ctx *common.RuntimeContext) error {
Results: make([]BatchResult, 0, len(numbers)),
}
// 步骤5遍历处理每个 Issue
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-status"}
if dryRun {
@ -223,6 +276,7 @@ func runBatchStatus(ctx *common.RuntimeContext) error {
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 更新状态
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID}); err != nil {
result.Status = "failed"
result.Error = err.Error()
@ -234,6 +288,7 @@ func runBatchStatus(ctx *common.RuntimeContext) error {
summary.Results = append(summary.Results, result)
}
// 步骤6输出汇总结果
if err := ctx.OutputData(summary); err != nil {
return err
}
@ -243,8 +298,9 @@ func runBatchStatus(ctx *common.RuntimeContext) error {
return nil
}
// ---- batch-priority ----
// === batch-priority 命令:批量修改 Issue 优先级 ===
// newBatchPriorityShortcut 创建 batch-priority 命令
func newBatchPriorityShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-priority",
@ -259,10 +315,13 @@ func newBatchPriorityShortcut() *common.Shortcut {
}
}
// runBatchPriority 执行批量修改优先级操作
// 参数 --priority 指定目标优先级low/normal/high/urgent
func runBatchPriority(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 获取优先级参数并转换为数字 ID
priority := ctx.Arg("priority")
priorityID, err := parsePriority(priority)
if err != nil {
@ -295,6 +354,7 @@ func runBatchPriority(ctx *common.RuntimeContext) error {
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 更新优先级
if err := updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID}); err != nil {
result.Status = "failed"
result.Error = err.Error()
@ -315,8 +375,9 @@ func runBatchPriority(ctx *common.RuntimeContext) error {
return nil
}
// ---- batch-assign ----
// === batch-assign 命令:批量分配 Issue ===
// newBatchAssignShortcut 创建 batch-assign 命令
func newBatchAssignShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-assign",
@ -331,6 +392,8 @@ func newBatchAssignShortcut() *common.Shortcut {
}
}
// runBatchAssign 执行批量分配操作
// 亮点:需要先把用户名转换成用户 ID通过 API 查询)
func runBatchAssign(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
@ -355,6 +418,7 @@ func runBatchAssign(ctx *common.RuntimeContext) error {
Results: make([]BatchResult, 0, len(numbers)),
}
// 非预览模式下,先解析用户 ID
var assigneeID interface{}
if !dryRun {
id, err := resolveUserID(ctx, assignee)
@ -372,6 +436,7 @@ func runBatchAssign(ctx *common.RuntimeContext) error {
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 分配用户
if err := updateIssueField(ctx, number, map[string]interface{}{"assigner_ids": []interface{}{assigneeID}}); err != nil {
result.Status = "failed"
result.Error = err.Error()
@ -392,8 +457,9 @@ func runBatchAssign(ctx *common.RuntimeContext) error {
return nil
}
// ---- batch-label ----
// === batch-label 命令:批量修改 Issue 标签 ===
// newBatchLabelShortcut 创建 batch-label 命令
func newBatchLabelShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-label",
@ -408,10 +474,13 @@ func newBatchLabelShortcut() *common.Shortcut {
}
}
// runBatchLabel 执行批量修改标签操作
// 参数 --label 可以是英文bug/feature或中文缺陷/功能)
func runBatchLabel(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 获取标签参数并转换为数字 ID
label := ctx.Arg("label")
tags, err := resolveIssueTags(ctx)
@ -449,6 +518,7 @@ func runBatchLabel(ctx *common.RuntimeContext) error {
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 修改标签issue_tag_ids 是数组)
if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{tagID}}); err != nil {
result.Status = "failed"
result.Error = err.Error()
@ -469,48 +539,148 @@ func runBatchLabel(ctx *common.RuntimeContext) error {
return nil
}
// ---- shared helpers ----
// === batch-destroy 命令:批量删除 Issue ===
// updateIssueField fetches the current issue to preserve subject/description,
// then PATCHes with the given fields merged in.
// newBatchDestroyShortcut 创建 batch-destroy 命令
func newBatchDestroyShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-destroy",
Description: "Delete multiple issues by issue numbers or a CSV file",
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 without making changes", Bool: true, Default: "false"},
},
Run: runBatchDestroy,
}
}
// runBatchDestroy 执行批量删除操作
// 使用 GitLink 原生批量删除接口 DELETE /v1/{owner}/{repo}/issues/batch_destroy
// body: {"ids": [1, 2, 3]}
func runBatchDestroy(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "destroy",
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
if dryRun {
for _, number := range numbers {
summary.Results = append(summary.Results, BatchResult{Number: number, Action: "destroy", Status: "planned"})
summary.Succeeded++
}
} else {
// 构建 ids 数组
ids := make([]int, 0, len(numbers))
for _, number := range numbers {
id, err := strconv.Atoi(number)
if err != nil {
return fmt.Errorf("invalid issue number %q: %w", number, err)
}
ids = append(ids, id)
}
// 调用原生批量删除接口
body := map[string]interface{}{"ids": ids}
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/batch_destroy", v1RepoPath(ctx)), body); err != nil {
// 整体失败,标记所有为 failed
for _, number := range numbers {
summary.Results = append(summary.Results, BatchResult{Number: number, Action: "destroy", Status: "failed", Error: err.Error()})
summary.Failed++
}
} else {
for _, number := range numbers {
summary.Results = append(summary.Results, BatchResult{Number: number, Action: "destroy", Status: "deleted"})
summary.Succeeded++
}
}
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
// === 共享辅助函数 ===
// updateIssueField 更新 Issue 的指定字段
// 关键点:
// 1. 先调用 fetchExistingIssue 获取当前 Issue 的标题和描述
// 2. 必须在请求体中包含 subject 和 description否则会被清空
// 3. 把要更新的字段合并到 body 中
// 4. 发送 PATCH 请求
func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error {
// 获取当前 Issue 的标题和描述(避免更新时丢失)
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("fetch issue #%s: %w", number, err)
}
// 构建请求体,先包含必要的标题和描述
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
}
// 合并要更新的字段
for k, v := range fields {
body[k] = v
}
// 发送 PATCH 请求更新 Issue
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("update issue #%s: %w", number, err)
}
return nil
}
// resolveUserID converts a login name to a numeric user ID via the users API.
// resolveUserID 把用户名转换成用户 ID
// 工作原理:
// 1. 如果输入已经是数字,直接返回
// 2. 否则调用 /users/{login} API 获取用户信息
// 3. 从响应中提取 id 或 user_id 字段
// 4. API 返回的数字是 float64 类型,需要转换成 int
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
// 如果输入是数字,直接返回
if id, err := strconv.Atoi(login); err == nil {
return id, nil
}
// 调用 API 获取用户信息
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
if err != nil {
return nil, fmt.Errorf("lookup user %q: %w", login, err)
}
// 类型断言:把 Data 转换为 map[string]interface{}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response for user %q", login)
}
// 尝试提取 id 字段
idFloat, ok := data["id"].(float64)
if ok {
return int(idFloat), nil
}
// 尝试提取 user_id 字段
userIDFloat, ok := data["user_id"].(float64)
if ok {
return int(userIDFloat), nil
@ -518,6 +688,9 @@ func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error
return nil, fmt.Errorf("cannot determine user ID for %q", login)
}
// parseStatus 把用户输入的状态字符串转换成数字 ID
// 支持多种写法in-progress、in_progress、inprogress
// 如果输入是数字,直接返回
func parseStatus(state string) (int, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "new":
@ -538,6 +711,7 @@ func parseStatus(state string) (int, error) {
}
}
// parsePriority 把用户输入的优先级字符串转换成数字 ID
func parsePriority(p string) (int, error) {
switch strings.ToLower(strings.TrimSpace(p)) {
case "low":
@ -556,14 +730,19 @@ func parsePriority(p string) (int, error) {
}
}
// parseTracker 把用户输入的标签字符串转换成数字 ID
// 支持中英文标签:
// - 中文:缺陷/功能/文档/重复/疑问/支持/任务/测试/协助/搁置
// - 英文bug/feature/support/doc/test/duplicate/question
func parseTracker(label string) (int, error) {
trimmed := strings.TrimSpace(label)
// Check Chinese tag names first
// 先检查中文标签名称
if id, ok := tagIDs[trimmed]; ok {
return id, nil
}
// 再检查英文标签名称
switch strings.ToLower(trimmed) {
case "bug":
return trackerBug, nil
@ -587,8 +766,8 @@ func parseTracker(label string) (int, error) {
}
}
// parseLabel converts a label name to its GitLink tag ID.
// tags is the project's name→id mapping from resolveIssueTags.
// parseLabel 根据项目的标签映射表,把标签名称转换成 GitLink 标签 ID
// 参数: name - 标签名称; tags - 项目的名称→ID 映射
func parseLabel(name string, tags map[string]int) (int, error) {
if id, ok := tags[name]; ok && id != 0 {
return id, nil
@ -599,6 +778,8 @@ func parseLabel(name string, tags map[string]int) (int, error) {
return 0, fmt.Errorf("invalid label %q: not found in project issue tags", name)
}
// collectIssueNumbers 从 --numbers 参数和 CSV 文件中收集 Issue 编号
// 参数: numbersValue - --numbers 参数的值; csvPath - CSV 文件路径
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := parseIssueNumbers(numbersValue)
if err != nil {
@ -615,6 +796,7 @@ func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
return mergeIssueNumbers(numbers, csvNumbers), nil
}
// parseIssueNumbers 解析逗号分隔的 Issue 编号字符串
func parseIssueNumbers(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
@ -622,15 +804,22 @@ func parseIssueNumbers(value string) ([]string, error) {
return normalizeIssueNumbers(strings.Split(value, ","))
}
// readIssueNumbersFromCSV 从 CSV 文件读取 Issue 编号
// 智能表头识别:
// - 自动识别 number、issue_number、project_issues_index 列
// - 如果没有匹配的表头,默认使用第一列
// - 跳过表头行,从第二行开始读取
func readIssueNumbersFromCSV(path string) ([]string, error) {
// 打开文件defer 确保函数返回前关闭文件)
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
// 创建 CSV 阅读器
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
reader.TrimLeadingSpace = true // 自动去除单元格前后空格
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
@ -639,42 +828,47 @@ func readIssueNumbersFromCSV(path string) ([]string, error) {
return nil, nil
}
// 智能识别表头:查找 number 列
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
startRow = 1 // 找到表头,从第二行开始读取
}
}
if numberColumn == -1 {
numberColumn = 0
numberColumn = 0 // 没有找到表头,默认使用第一列
}
// 提取 Issue 编号
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if numberColumn >= len(record) {
continue
continue // 跳过列数不足的行
}
values = append(values, record[numberColumn])
}
return normalizeIssueNumbers(values)
}
// normalizeIssueNumbers 规范化 Issue 编号列表
// 功能:去重、验证格式、过滤空值
func normalizeIssueNumbers(values []string) ([]string, error) {
numbers := make([]string, 0, len(values))
seen := map[string]bool{}
seen := map[string]bool{} // 用于去重
for _, value := range values {
number := strings.TrimSpace(value)
if number == "" {
continue
continue // 跳过空值
}
// 验证是否是有效的整数
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
return nil, fmt.Errorf("invalid issue number %q: must be an integer", number)
}
if seen[number] {
continue
continue // 跳过重复值
}
seen[number] = true
numbers = append(numbers, number)
@ -682,6 +876,7 @@ func normalizeIssueNumbers(values []string) ([]string, error) {
return numbers, nil
}
// mergeIssueNumbers 合并多个 Issue 编号列表(去重)
func mergeIssueNumbers(values ...[]string) []string {
merged := []string{}
seen := map[string]bool{}
@ -697,6 +892,8 @@ func mergeIssueNumbers(values ...[]string) []string {
return merged
}
// parseBool 解析布尔值字符串
// 返回 true 的条件:字符串解析成功且值为 true
func parseBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed

View File

@ -6,7 +6,8 @@ import (
"strconv" //字符串和数字转换
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Ctx
@ -31,14 +32,17 @@ func Shortcuts() []*common.Shortcut {
newBatchAssignShortcut(),
newBatchLabelShortcut(),
newBatchCreateShortcut(),
newBatchDestroyShortcut(),
newLabelAddShortcut(),
newLabelRemoveShortcut(),
newLabelListShortcut(),
{
Name: "list",
Description: "List issues",
Long: "List issues in a repository with optional filtering by state and pagination.",
Example: " gitlink-cli issue +list --state open\n gitlink-cli issue +list --state closed --page 1 --limit 50\n gitlink-cli issue +list --columns id,subject,status,priority --state all",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
{Name: "state", Short: "s", Usage: "Filter by state", Default: "open", Choices: []string{"open", "closed", "all"}},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
@ -56,7 +60,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return fmt.Errorf("获取 Issue 列表失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env) //按照用户指定的格式json/html/table...)输出issue列表
},
@ -64,6 +68,8 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a new issue",
Long: "Create a new issue in the repository. Requires --title. Supports --body, --assignee, --milestone, and --label.",
Example: " gitlink-cli issue +create --title \"Bug: login crash\" --body \"Steps to reproduce...\"\n gitlink-cli issue +create --title \"Feature request\" --assignee zhangsan --label 3",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
@ -94,14 +100,14 @@ func Shortcuts() []*common.Shortcut {
body["description"] = desc
}
if a := ctx.Arg("assignee"); a != "" {
body["assigned_to_id"] = a
body["assigner_ids"] = []interface{}{a}
}
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
return fmt.Errorf("创建 Issue 失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "create", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -109,6 +115,7 @@ func Shortcuts() []*common.Shortcut {
{
Name: "view",
Description: "View issue details",
Example: " gitlink-cli issue +view --number 42",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
@ -122,7 +129,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return fmt.Errorf("查看 Issue 失败: %w", err)
return clierrors.OpError(clierrors.KindNotFound, "view", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -130,6 +137,7 @@ func Shortcuts() []*common.Shortcut {
{
Name: "close",
Description: "Close an issue",
Example: " gitlink-cli issue +close --number 42\n gitlink-cli issue +close --number 42 --dry-run",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
number := ctx.Arg("number")
@ -158,7 +166,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return fmt.Errorf("关闭 Issue 失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "close", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -197,6 +205,7 @@ func Shortcuts() []*common.Shortcut {
{
Name: "update",
Description: "Update an issue",
Example: " gitlink-cli issue +update --number 42 --title \"Updated title\"\n gitlink-cli issue +update --number 42 --state closed",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
number := ctx.Arg("number")
@ -206,7 +215,7 @@ func Shortcuts() []*common.Shortcut {
{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: "state", Short: "s", Usage: "New state", Validate: validateIssueState},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -220,7 +229,10 @@ func Shortcuts() []*common.Shortcut {
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")
return clierrors.InputError(
"at least one of --title, --body, or --state is required",
"至少需要提供 --title、--body 或 --state 中的一个参数",
).WithCommand(ctx.CommandName)
}
current, err := fetchExistingIssue(ctx, number)
@ -239,7 +251,7 @@ func Shortcuts() []*common.Shortcut {
body["description"] = b
}
if s := ctx.Arg("state"); s != "" {
statusID, err := normalizeIssueStatus(s)
statusID, err := issueStateToStatusID(s)
if err != nil {
return err
}
@ -247,7 +259,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return fmt.Errorf("更新 Issue 失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "update", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -281,7 +293,175 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload)
if err != nil {
return fmt.Errorf("添加 Issue 评论失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "comment", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
// === 元数据查询 ===
{
Name: "statuses",
Description: "List issue statuses",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_statues", nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issue statuses", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "authors",
Description: "List issue authors",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if kw := ctx.Arg("keyword"); kw != "" {
q.Set("keyword", kw)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_authors", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issue authors", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "assigners",
Description: "List issue assignees",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if kw := ctx.Arg("keyword"); kw != "" {
q.Set("keyword", kw)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_assigners", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issue assignees", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "priorities",
Description: "List issue priorities",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_priorities", nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issue priorities", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
// === 评论管理 ===
{
Name: "comment-edit",
Description: "Edit an issue comment",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue 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
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
body, err := ctx.RequireArg("body", `--body "updated comment"`)
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
"attachment_ids": []int{},
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, commentID), payload)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "edit", "comment", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "comment-delete",
Description: "Delete an issue comment",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除 Issue #%s 的评论 #%s", ctx.Arg("number"), ctx.Arg("comment-id")), nil
},
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue 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
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, commentID), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "delete", "comment", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "replies",
Description: "List replies to a comment",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Parent comment ID", 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", "--number 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
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/%s/children_journals", v1RepoPath(ctx), number, commentID), q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "replies", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -292,15 +472,15 @@ func Shortcuts() []*common.Shortcut {
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return nil, fmt.Errorf("获取 Issue 信息失败: %w", err)
return nil, clierrors.OpError(clierrors.KindNotFound, "view", "issue", err).WithCommand(ctx.CommandName)
}
issueData, ok := getEnv.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("failed to parse issue data")
return nil, clierrors.InputError("failed to parse issue data", "API 返回格式异常,请稍后重试").WithCommand(ctx.CommandName)
}
subject, _ := issueData["subject"].(string)
if subject == "" {
return nil, fmt.Errorf("failed to parse issue subject")
return nil, clierrors.InputError("failed to parse issue subject", "API 返回数据中缺少 subject 字段,请稍后重试").WithCommand(ctx.CommandName)
}
description, _ := issueData["description"].(string)
return &existingIssue{
@ -309,7 +489,18 @@ func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIss
}, nil
}
func normalizeIssueStatus(state string) (interface{}, error) {
func validateIssueState(state string) error {
state = strings.ToLower(strings.TrimSpace(state))
if state == "open" || state == "closed" {
return nil
}
if _, err := strconv.Atoi(state); err == nil {
return nil
}
return fmt.Errorf("must be \"open\", \"closed\", or a numeric status_id, got %q", state)
}
func issueStateToStatusID(state string) (interface{}, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open":
return 1, nil
@ -319,6 +510,6 @@ func normalizeIssueStatus(state string) (interface{}, error) {
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
return nil, fmt.Errorf("invalid state: %s", state)
}
}

View File

@ -186,3 +186,201 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
// === 新增命令测试 ===
func TestStatusesCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"total_count": float64(2),
"statues": []interface{}{},
})
})
defer server.Close()
err := runIssueShortcut(t, server, "statuses", map[string]string{})
if err != nil {
t.Fatalf("statuses failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issue_statues.json")
}
func TestAuthorsCallsCorrectEndpoint(t *testing.T) {
var requestedPath, requestedKeyword string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
requestedKeyword = r.URL.Query().Get("keyword")
writeJSON(t, w, map[string]interface{}{"authors": []interface{}{}})
})
defer server.Close()
err := runIssueShortcut(t, server, "authors", map[string]string{"keyword": "zhang"})
if err != nil {
t.Fatalf("authors failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issue_authors.json")
assertEqual(t, requestedKeyword, "zhang")
}
func TestAssignersCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"assigners": []interface{}{}})
})
defer server.Close()
err := runIssueShortcut(t, server, "assigners", map[string]string{})
if err != nil {
t.Fatalf("assigners failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issue_assigners.json")
}
func TestPrioritiesCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"priorities": []interface{}{}})
})
defer server.Close()
err := runIssueShortcut(t, server, "priorities", map[string]string{})
if err != nil {
t.Fatalf("priorities failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issue_priorities.json")
}
func TestCommentEditSendsNotesAndAttachmentIDs(t *testing.T) {
var payload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": float64(1)})
})
defer server.Close()
err := runIssueShortcut(t, server, "comment-edit", map[string]string{
"number": "42",
"comment-id": "100",
"body": "updated comment",
})
if err != nil {
t.Fatalf("comment-edit failed: %v", err)
}
assertEqual(t, payload["notes"], "updated comment")
}
func TestCommentEditRequiresNumber(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runIssueShortcut(t, server, "comment-edit", map[string]string{
"comment-id": "100", "body": "test",
})
if err == nil {
t.Fatal("expected error for missing --number")
}
}
func TestCommentDeleteCallsCorrectEndpoint(t *testing.T) {
var requestedPath, requestedMethod string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
requestedMethod = r.Method
writeJSON(t, w, map[string]interface{}{"status": float64(1)})
})
defer server.Close()
err := runIssueShortcut(t, server, "comment-delete", map[string]string{
"number": "42",
"comment-id": "100",
})
if err != nil {
t.Fatalf("comment-delete failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issues/42/journals/100.json")
assertEqual(t, requestedMethod, "DELETE")
}
func TestBatchDestroyCallsBatchDestroyEndpoint(t *testing.T) {
var requestedPath, requestedMethod string
var payload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
requestedMethod = r.Method
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "success"})
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-destroy", map[string]string{
"numbers": "1,2,3",
})
if err != nil {
t.Fatalf("batch-destroy failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issues/batch_destroy.json")
assertEqual(t, requestedMethod, "DELETE")
ids, ok := payload["ids"].([]interface{})
if !ok {
t.Fatalf("ids should be array, got %T", payload["ids"])
}
assertEqual(t, len(ids), 3)
assertEqual(t, ids[0], float64(1))
assertEqual(t, ids[1], float64(2))
assertEqual(t, ids[2], float64(3))
}
func TestBatchDestroyDryRun(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("should not call API in dry-run mode")
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-destroy", map[string]string{
"numbers": "10,20",
"dry-run": "true",
})
if err != nil {
t.Fatalf("batch-destroy dry-run failed: %v", err)
}
}
func TestBatchDestroyRequiresNumbers(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runIssueShortcut(t, server, "batch-destroy", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --numbers")
}
}
func TestRepliesCallsChildrenJournals(t *testing.T) {
var requestedPath string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"journals": []interface{}{}})
})
defer server.Close()
err := runIssueShortcut(t, server, "replies", map[string]string{
"number": "42",
"comment-id": "100",
})
if err != nil {
t.Fatalf("replies failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issues/42/journals/100/children_journals.json")
}
func assertPath(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Fatalf("path: got %s, want %s", got, want)
}
}

View File

@ -18,14 +18,20 @@ func newLabelAddShortcut() *common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, _ := ctx.RequireArg("number", "--number 42")
labelsStr, _ := ctx.RequireArg("labels", `--labels "bug,urgent"`)
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
labelsStr, err := ctx.RequireArg("labels", `--labels "bug,urgent"`)
if err != nil {
return err
}
body := map[string]interface{}{
"labels": labelsStr,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), body)
if err != nil {
return err
return fmt.Errorf("添加标签失败: %w", err)
}
return ctx.Output(env)
},
@ -44,12 +50,18 @@ func newLabelRemoveShortcut() *common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, _ := ctx.RequireArg("number", "--number 42")
label, _ := ctx.RequireArg("label", "--label bug")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/labels/%s", v1RepoPath(ctx), number, label), nil)
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
label, err := ctx.RequireArg("label", "--label bug")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/labels/%s", v1RepoPath(ctx), number, label), nil)
if err != nil {
return fmt.Errorf("删除标签失败: %w", err)
}
return ctx.Output(env)
},
}
@ -66,11 +78,14 @@ func newLabelListShortcut() *common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, _ := ctx.RequireArg("number", "--number 42")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), nil)
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), nil)
if err != nil {
return fmt.Errorf("获取标签列表失败: %w", err)
}
return ctx.Output(env)
},
}

View File

@ -4,15 +4,24 @@ import (
"fmt"
"net/url"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// v1RepoPath returns /v1/{owner}/{repo}
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List milestones",
Flags: []common.Flag{
{Name: "category", Short: "c", Usage: "Filter: opening, closed", Choices: []string{"opening", "closed"}},
{Name: "keyword", Short: "k", Usage: "Search keyword"},
{Name: "sort-by", Usage: "Sort field: created_on, updated_on, effective_date, issues_count, percent"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
@ -23,9 +32,18 @@ func Shortcuts() []*common.Shortcut {
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/milestones", ctx.RepoPath()), q)
if cat := ctx.Arg("category"); cat != "" {
q.Set("category", cat)
}
if kw := ctx.Arg("keyword"); kw != "" {
q.Set("keyword", kw)
}
if sb := ctx.Arg("sort-by"); sb != "" {
q.Set("sort_by", sb)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/milestones", q)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "list", "milestones", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -33,6 +51,10 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a milestone",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("创建里程碑: %s", ctx.Arg("name")), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Milestone name", Required: true},
{Name: "description", Short: "d", Usage: "Description"},
@ -52,43 +74,92 @@ func Shortcuts() []*common.Shortcut {
if due := ctx.Arg("due"); due != "" {
body["due_date"] = due
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/milestones", ctx.RepoPath()), body)
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/milestones", body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "create", "milestone", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View milestone details with issues",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
{Name: "category", Short: "c", Usage: "Issue filter: all, opened, closed", Default: "all"},
{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
}
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if cat := ctx.Arg("category"); cat != "" {
q.Set("category", cat)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), q)
if err != nil {
return clierrors.OpError(clierrors.KindNotFound, "view", "milestone", err).WithCommand(ctx.CommandName)
}
// API returns {milestone: {...}, issues: [...], ...}; extract milestone for display.
if data, ok := env.Data.(map[string]interface{}); ok {
if ms, ok := data["milestone"]; ok {
return ctx.OutputData(ms)
}
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a milestone",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新里程碑 #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
{Name: "name", Short: "n", Usage: "New name"},
{Name: "description", Short: "d", Usage: "New description"},
{Name: "due", Usage: "New due date (YYYY-MM-DD)"},
{Name: "date", Usage: "New effective date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id", "--id 1")
body := map[string]interface{}{}
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
// Fetch existing milestone to fill required fields
existing, err := fetchMilestone(ctx, id)
if err != nil {
return err
}
body := map[string]interface{}{
"name": existing["name"],
"description": existing["description"],
"effective_date": existing["effective_date"],
}
if n := ctx.Arg("name"); n != "" {
body["title"] = n
body["name"] = n
}
if d := ctx.Arg("description"); d != "" {
body["description"] = d
}
if due := ctx.Arg("due"); due != "" {
body["due_date"] = due
if dt := ctx.Arg("date"); dt != "" {
body["effective_date"] = dt
}
if len(body) == 0 {
return fmt.Errorf("at least one of --name, --description, --due is required")
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1%s/milestones/%s", ctx.RepoPath(), id), body)
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), body)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "update", "milestone", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -96,6 +167,10 @@ func Shortcuts() []*common.Shortcut {
{
Name: "delete",
Description: "Delete a milestone",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除里程碑 #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
},
@ -103,13 +178,77 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id", "--id 1")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1%s/milestones/%s", ctx.RepoPath(), id), nil)
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
// API requires body with name/description/effective_date
existing, err := fetchMilestone(ctx, id)
if err != nil {
return err
}
body := map[string]interface{}{
"name": existing["name"],
"description": existing["description"],
"effective_date": existing["effective_date"],
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "delete", "milestone", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "status",
Description: "Update milestone status (open/close)",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新里程碑 #%s 状态为 %s", ctx.Arg("id"), ctx.Arg("status")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
{Name: "status", Short: "s", Usage: "New status", Required: true, Choices: []string{"opening", "closed"}},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
status, err := ctx.RequireArg("status", "--status closed")
if err != nil {
return err
}
body := map[string]interface{}{
"status": status,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/milestones/%s/update_status", ctx.RepoPath(), id), body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "update", "milestone status", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
}
}
// fetchMilestone retrieves a milestone to get its current fields (needed for update/delete).
func fetchMilestone(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) {
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return nil, clierrors.OpError(clierrors.KindNotFound, "view", "milestone", err).WithCommand(ctx.CommandName)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, clierrors.InputError("unexpected milestone format", "API 返回格式异常").WithCommand(ctx.CommandName)
}
// The response wraps in "milestone" key
if ms, ok := data["milestone"].(map[string]interface{}); ok {
return ms, nil
}
// Fallback: maybe flat structure
return data, nil
}

View File

@ -0,0 +1,264 @@
package milestone
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"
)
// === list ===
func TestListCallsMilestonesEndpoint(t *testing.T) {
var requestedPath string
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"total_count": float64(2),
"opening_milestone_count": float64(1),
"closed_milestone_count": float64(1),
"milestones": []interface{}{},
})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("list failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/milestones.json")
}
func TestListPassesFilters(t *testing.T) {
var cat, kw, sortBy string
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
cat = r.URL.Query().Get("category")
kw = r.URL.Query().Get("keyword")
sortBy = r.URL.Query().Get("sort_by")
writeJSON(t, w, map[string]interface{}{"milestones": []interface{}{}})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{
"category": "opening",
"keyword": "v1",
"sort-by": "issues_count",
})
if err != nil {
t.Fatalf("list with filters failed: %v", err)
}
assertEqual(t, cat, "opening")
assertEqual(t, kw, "v1")
assertEqual(t, sortBy, "issues_count")
}
// === create ===
func TestCreateSendsPayload(t *testing.T) {
var payload map[string]interface{}
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "success"})
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{
"name": "v1.0",
"description": "First release",
"date": "2026-12-31",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
assertEqual(t, payload["name"], "v1.0")
assertEqual(t, payload["description"], "First release")
assertEqual(t, payload["effective_date"], "2026-12-31")
}
func TestCreateRequiresName(t *testing.T) {
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{
"description": "desc", "date": "2026-12-31",
})
if err == nil {
t.Fatal("expected error for missing --name")
}
}
// === view ===
func TestViewCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"milestone": map[string]interface{}{"id": float64(1), "name": "v1.0"},
})
})
defer server.Close()
err := runShortcut(t, server, "view", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("view failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/milestones/1.json")
}
// === update ===
func TestUpdateAutoFetchesExisting(t *testing.T) {
var updatePayload map[string]interface{}
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
writeJSON(t, w, map[string]interface{}{
"milestone": map[string]interface{}{
"name": "v1.0",
"description": "Old desc",
"effective_date": "2026-12-31",
},
})
case r.Method == "PATCH":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(0)})
default:
t.Fatalf("unexpected: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "update", map[string]string{
"id": "1",
"name": "v1.0-rc1",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
assertEqual(t, updatePayload["name"], "v1.0-rc1")
assertEqual(t, updatePayload["description"], "Old desc")
assertEqual(t, updatePayload["effective_date"], "2026-12-31")
}
// === delete ===
func TestDeleteAutoFetchesExisting(t *testing.T) {
var deletePath string
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
writeJSON(t, w, map[string]interface{}{
"milestone": map[string]interface{}{
"name": "v1.0",
"description": "desc",
"effective_date": "2026-12-31",
},
})
case r.Method == "DELETE":
deletePath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": float64(0)})
default:
t.Fatalf("unexpected: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
assertPath(t, deletePath, "/v1/owner/repo/milestones/1.json")
}
// === status ===
func TestStatusCallsUpdateStatusEndpoint(t *testing.T) {
var requestedPath string
var payload map[string]interface{}
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(0)})
})
defer server.Close()
err := runShortcut(t, server, "status", map[string]string{
"id": "1",
"status": "closed",
})
if err != nil {
t.Fatalf("status failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/milestones/1/update_status.json")
assertEqual(t, payload["status"], "closed")
}
// === helpers ===
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(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 findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("milestone shortcut %q not found", name)
return nil
}
func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
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("decode JSON failed: %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("write JSON failed: %v", err)
}
}
func assertEqual(t *testing.T, got, 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)
}
}
func assertPath(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Fatalf("path: got %s, want %s", got, want)
}
}

View File

@ -1,393 +0,0 @@
package onboard
import (
"fmt"
"net/url"
"strconv"
"strings"
"sync"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// marker is a unique string embedded in welcome comments to detect existing ones.
const marker = "<!-- gitlink-cli:onboard -->"
// tagCache caches tag name→id mappings per owner/repo.
var tagCache sync.Map
// Shortcuts returns the onboarding shortcut group.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "welcome",
Description: "Add welcome comments to specific issues or tag-matched issues",
Flags: []common.Flag{
{Name: "issues", Short: "i", Usage: "Comma-separated issue numbers (e.g. 1,3,7)"},
{Name: "tag", Short: "t", Usage: "Tag name to match (comma-separated)", Default: "good first issue,help wanted"},
{Name: "template", Usage: "Custom welcome message template ({login}, {number}, {subject}, {description})"},
{Name: "force", Short: "f", Usage: "Force re-add even if already commented", Bool: true},
},
DryRun: true,
DryRunHint: dryRunHint,
Run: runWelcome,
},
}
}
func dryRunHint(ctx *common.RuntimeContext) (string, error) {
if issues := ctx.Arg("issues"); issues != "" {
return fmt.Sprintf("将为 issue #%s 添加新人引导评论", issues), nil
}
tag := ctx.Arg("tag")
if tag == "" {
tag = "good first issue,help wanted"
}
return fmt.Sprintf("将为所有 [%s] 标签的 issue 添加新人引导评论", tag), nil
}
func runWelcome(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// --issues takes priority over --tag
if issueArg := ctx.Arg("issues"); issueArg != "" {
return runWelcomeByIssueNumbers(ctx, issueArg)
}
tagNames := ctx.Arg("tag")
if tagNames == "" {
tagNames = "good first issue,help wanted"
}
// resolve tag names → ids
tagMap, err := resolveTags(ctx)
if err != nil {
return err
}
var tagIDs []string
for _, name := range strings.Split(tagNames, ",") {
name = strings.TrimSpace(name)
if id, ok := tagMap[name]; ok {
tagIDs = append(tagIDs, strconv.Itoa(id))
}
}
if len(tagIDs) == 0 {
return fmt.Errorf("未找到匹配的标签: %s (可用: %v)", tagNames, tagNamesList(tagMap))
}
// fetch open issues with these tags
issues, err := fetchTaggedIssues(ctx, tagIDs)
if err != nil {
return err
}
return processIssues(ctx, issues)
}
// runWelcomeByIssueNumbers directly processes specified issue numbers,
// skipping tag resolution and tag-based issue fetching.
func runWelcomeByIssueNumbers(ctx *common.RuntimeContext, issueArg string) error {
issueNums, err := parseIssueNumbers(issueArg)
if err != nil {
return err
}
var issues []issueInfo
for _, num := range issueNums {
info, err := fetchIssueDetail(ctx, num)
if err != nil {
return fmt.Errorf("获取 issue #%d 失败: %w", num, err)
}
issues = append(issues, info)
}
return processIssues(ctx, issues)
}
// parseIssueNumbers parses a comma-separated string of issue numbers.
func parseIssueNumbers(s string) ([]int, error) {
parts := strings.Split(s, ",")
var nums []int
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
n, err := strconv.Atoi(p)
if err != nil {
return nil, fmt.Errorf("无效的 issue 编号: %q (必须是数字)", p)
}
nums = append(nums, n)
}
if len(nums) == 0 {
return nil, fmt.Errorf("--issues 参数为空")
}
return nums, nil
}
// fetchIssueDetail fetches a single issue's subject and description by number.
func fetchIssueDetail(ctx *common.RuntimeContext, issueNumber int) (issueInfo, error) {
path := fmt.Sprintf("/v1/%s/%s/issues/%d", ctx.Owner, ctx.Repo, issueNumber)
env, err := ctx.CallAPIWithQuery("GET", path, nil)
if err != nil {
return issueInfo{}, err
}
data, _ := env.Data.(map[string]interface{})
subj := getString(data, "subject")
if subj == "" {
subj = fmt.Sprintf("issue #%d", issueNumber)
}
desc := getString(data, "description")
return issueInfo{number: issueNumber, subject: subj, description: desc}, nil
}
// processIssues handles the common issue processing loop used by both
// --issues and --tag paths.
func processIssues(ctx *common.RuntimeContext, issues []issueInfo) error {
tmpl := ctx.Arg("template")
if tmpl == "" {
tmpl = ""
}
type result struct {
num int
action string
msg string
}
var results []result
for _, issue := range issues {
force := ctx.Arg("force") == "true"
if !force && hasWelcomeComment(ctx, issue.number) {
results = append(results, result{issue.number, "skipped", fmt.Sprintf("#%d \"%s\" — 已有引导评论,跳过", issue.number, issue.subject)})
continue
}
// Render per-issue message with issue-specific variables.
body := renderComment(ctx, issue, tmpl)
if ctx.IsDryRun() {
fmt.Printf("\n--- 预览 #%d \"%s\" ---\n%s\n---\n", issue.number, issue.subject, body)
proceed, err := common.ConfirmAction(ctx)
if err != nil {
return err
}
if !proceed {
results = append(results, result{issue.number, "skipped", "用户取消"})
continue
}
}
if err := addComment(ctx, issue.number, body); err != nil {
results = append(results, result{issue.number, "error", err.Error()})
} else {
results = append(results, result{issue.number, "added", fmt.Sprintf("#%d \"%s\" — 已添加引导评论", issue.number, issue.subject)})
}
}
// output
added := 0
skipped := 0
errors := 0
for _, r := range results {
switch r.action {
case "added":
added++
case "skipped":
skipped++
case "error":
errors++
}
}
fmt.Printf("\n完成: 添加 %d, 跳过 %d, 错误 %d\n", added, skipped, errors)
for _, r := range results {
fmt.Printf(" [%s] %s\n", r.action, r.msg)
}
return nil
}
func resolveTags(ctx *common.RuntimeContext) (map[string]int, error) {
key := ctx.Owner + "/" + ctx.Repo
if cached, ok := tagCache.Load(key); ok {
return cached.(map[string]int), nil
}
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo)
q := url.Values{}
q.Set("only_name", "true")
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return nil, fmt.Errorf("获取标签列表失败: %w", err)
}
data, _ := env.Data.(map[string]interface{})
raw, _ := data["issue_tags"].([]interface{})
tags := make(map[string]int)
for _, item := range raw {
if t, ok := item.(map[string]interface{}); ok {
if name, ok := t["name"].(string); ok && name != "" {
switch v := t["id"].(type) {
case float64:
tags[name] = int(v)
case int:
tags[name] = v
}
}
}
}
if len(tags) == 0 {
return nil, fmt.Errorf("项目没有配置任务标签,请先在 GitLink 网页端创建")
}
tagCache.Store(key, tags)
return tags, nil
}
func tagNamesList(tags map[string]int) []string {
var names []string
for n := range tags {
names = append(names, n)
}
return names
}
type issueInfo struct {
number int
subject string
description string
}
func fetchTaggedIssues(ctx *common.RuntimeContext, tagIDs []string) ([]issueInfo, error) {
var all []issueInfo
page := 1
for {
q := url.Values{}
q.Set("state", "open")
q.Set("page", strconv.Itoa(page))
q.Set("limit", "100")
q.Set("issue_tag_ids", strings.Join(tagIDs, ","))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo), q)
if err != nil {
return nil, err
}
data, _ := env.Data.(map[string]interface{})
issues, _ := data["issues"].([]interface{})
if len(issues) == 0 {
break
}
for _, item := range issues {
if issue, ok := item.(map[string]interface{}); ok {
all = append(all, issueInfo{
number: getInt(issue, "project_issues_index"),
subject: getString(issue, "subject"),
})
}
}
total := getInt(data, "total_count")
if page*100 >= total {
break
}
page++
}
return all, nil
}
func hasWelcomeComment(ctx *common.RuntimeContext, issueNumber int) bool {
path := fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueNumber)
q := url.Values{}
q.Set("limit", "100")
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return false
}
data, _ := env.Data.(map[string]interface{})
journals, _ := data["journals"].([]interface{})
for _, j := range journals {
if jm, ok := j.(map[string]interface{}); ok {
if notes := getString(jm, "notes"); strings.Contains(notes, marker) {
return true
}
}
}
return false
}
func addComment(ctx *common.RuntimeContext, issueNumber int, body string) error {
path := fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueNumber)
payload := map[string]interface{}{"notes": marker + "\n\n" + body}
_, err := ctx.CallAPI("POST", path, payload)
return err
}
// renderComment renders the comment body for a specific issue.
// It uses the --template if provided, otherwise generates an issue-aware default.
func renderComment(ctx *common.RuntimeContext, issue issueInfo, customTmpl string) string {
tmpl := customTmpl
if tmpl == "" {
tmpl = defaultTemplate(ctx.Owner, ctx.Repo, issue)
}
body := strings.NewReplacer(
"{login}", ctx.Owner,
"{number}", strconv.Itoa(issue.number),
"{subject}", issue.subject,
"{description}", issue.description,
).Replace(tmpl)
return body
}
// defaultTemplate returns an issue-aware onboarding message.
func defaultTemplate(owner, repo string, issue issueInfo) string {
summary := issue.subject
if len(issue.description) > 200 {
summary = issue.description[:200] + "..."
} else if issue.description != "" {
summary = issue.description
}
return fmt.Sprintf(`## 欢迎贡献:wave:
感谢你对 [%s/%s](https://www.gitlink.org.cn/%s/%s) 的关注。
### :bulb: 关于本 Issue{subject}
%s
### :rocket: 参与步骤
1. **Fork 仓库** 并克隆到本地
2. 创建新分支` + "`git checkout -b fix/issue-{number}`" + `
3. 参照上方 issue 描述修改代码
4. 推送到你的 Fork 后创建 Pull Request
### :memo: 注意事项
- 请先阅读 [CONTRIBUTING.md](https://www.gitlink.org.cn/%s/%s/src/master/CONTRIBUTING.md)(如有)
- 如有疑问欢迎在评论区留言讨论
期待你的 PR`, owner, repo, owner, repo, summary, owner, repo)
}
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func getInt(m map[string]interface{}, key string) int {
switch v := m[key].(type) {
case float64:
return int(v)
case int:
return v
}
return 0
}

View File

@ -103,13 +103,20 @@ func Shortcuts() []*common.Shortcut {
{
Name: "update",
Description: "Update an organization",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新组织 #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
{Name: "name", Short: "n", Usage: "New name"},
{Name: "description", Short: "d", Usage: "New description"},
},
Run: func(ctx *common.RuntimeContext) error {
id, _ := ctx.RequireArg("id", "--id my-org")
id, err := ctx.RequireArg("id", "--id my-org")
if err != nil {
return err
}
body := map[string]interface{}{}
if n := ctx.Arg("name"); n != "" {
body["name"] = n
@ -122,7 +129,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/organizations/%s", id), body)
if err != nil {
return err
return fmt.Errorf("更新组织失败: %w", err)
}
return ctx.Output(env)
},
@ -130,15 +137,22 @@ func Shortcuts() []*common.Shortcut {
{
Name: "delete",
Description: "Delete an organization",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除组织 #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, _ := ctx.RequireArg("id", "--id my-org")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s", id), nil)
id, err := ctx.RequireArg("id", "--id my-org")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s", id), nil)
if err != nil {
return fmt.Errorf("删除组织失败: %w", err)
}
return ctx.Output(env)
},
},

View File

@ -4,6 +4,7 @@ import (
"fmt"
"net/url"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
@ -16,8 +17,9 @@ func Shortcuts() []*common.Shortcut {
{
Name: "list",
Description: "List pull requests",
Example: " gitlink-cli pr +list --state open\n gitlink-cli pr +list --state merged --page 1 --limit 50\n gitlink-cli pr +list --columns id,title,state,user --state all",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
{Name: "state", Short: "s", Usage: "Filter by state", Default: "open", Choices: []string{"open", "merged", "closed", "all"}},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
@ -33,7 +35,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
if err != nil {
return fmt.Errorf("获取 PR 列表失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "list", "pull requests", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -41,6 +43,8 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a pull request",
Long: "Create a new pull request from --head branch to --base branch. Requires --title and --head.",
Example: " gitlink-cli pr +create --title \"Fix login crash\" --head feat/new-login\n gitlink-cli pr +create --title \"New feature\" --head dev --base master --body \"Description...\"",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
@ -83,7 +87,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
if err != nil {
return fmt.Errorf("创建 PR 失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "create", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -104,7 +108,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("查看 PR 失败: %w", err)
return clierrors.OpError(clierrors.KindNotFound, "view", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -112,6 +116,7 @@ func Shortcuts() []*common.Shortcut {
{
Name: "merge",
Description: "Merge a pull request",
Example: " gitlink-cli pr +merge --id 42\n gitlink-cli pr +merge --id 42 --method rebase\n gitlink-cli pr +merge --id 42 --method squash --dry-run",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
id := ctx.Arg("id")
@ -123,7 +128,7 @@ func Shortcuts() []*common.Shortcut {
},
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: "method", Short: "m", Usage: "Merge method", Default: "merge", Choices: []string{"merge", "rebase", "squash"}},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -142,7 +147,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
if err != nil {
return fmt.Errorf("合并 PR 失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "merge", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -168,7 +173,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("关闭 PR 失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "close", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -189,7 +194,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("获取 PR 文件列表失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "list", "PR files", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -210,7 +215,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("获取 PR diff 失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "view", "PR diff", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -242,7 +247,7 @@ func Shortcuts() []*common.Shortcut {
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("获取 PR 信息失败: %w", err)
return clierrors.OpError(clierrors.KindNotFound, "view", "pull request", err).WithCommand(ctx.CommandName)
}
issueID, err := extractIssueID(prEnv)
if err != nil {
@ -254,7 +259,267 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload)
if err != nil {
return fmt.Errorf("添加 PR 评论失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "comment", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
// === PR 增强 ===
{
Name: "reopen",
Description: "Reopen a closed pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("重新打开 PR #%s", ctx.Arg("id")), nil
},
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", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/pulls/%s/reopen", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "reopen", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新 PR #%s", ctx.Arg("id")), nil
},
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"},
{Name: "head", Usage: "Source branch"},
{Name: "base", Usage: "Target branch"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
// Fetch existing PR to fill required fields
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindNotFound, "view", "pull request", err).WithCommand(ctx.CommandName)
}
prData, ok := prEnv.Data.(map[string]interface{})
if !ok {
return clierrors.InputError("unexpected PR format", "API 返回格式异常").WithCommand(ctx.CommandName)
}
payload := map[string]interface{}{
"title": prData["title"],
"body": prData["body"],
"head": prData["head"],
"base": prData["base"],
"issue_tag_ids": []string{},
"receivers_login": []string{},
}
if t := ctx.Arg("title"); t != "" {
payload["title"] = t
}
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
if h := ctx.Arg("head"); h != "" {
payload["head"] = h
}
if bs := ctx.Arg("base"); bs != "" {
payload["base"] = bs
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), payload)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "update", "pull request", err).WithCommand(ctx.CommandName)
}
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", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/commits", ctx.RepoPath(), id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "PR commits", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "versions",
Description: "List versions of 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", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/pulls/%s/versions", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "PR versions", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "vdiff",
Description: "Show diff of a specific PR version",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "version", Short: "v", Usage: "Version ID", Required: true},
{Name: "filepath", Usage: "Filter by file path"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
versionID, err := ctx.RequireArg("version", "--version 5")
if err != nil {
return err
}
q := url.Values{}
if fp := ctx.Arg("filepath"); fp != "" {
q.Set("filepath", fp)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/pulls/%s/versions/%s/diff", ctx.Owner, ctx.Repo, id, versionID), q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "view", "PR version diff", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "filesv1",
Description: "List changed files (v1 API with pagination)",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "filepath", Usage: "Filter by file path"},
{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
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if fp := ctx.Arg("filepath"); fp != "" {
q.Set("filepath", fp)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/pulls/%s/files", ctx.Owner, ctx.Repo, id), q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "PR files", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
// === PR 评论管理 ===
{
Name: "comment-edit",
Description: "Edit a PR review comment",
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},
{Name: "commit", Usage: "Commit SHA"},
{Name: "state", Usage: "Comment state", Default: "opened", Choices: []string{"opened", "resolved", "disabled"}},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
body, err := ctx.RequireArg("body", `--body "updated comment"`)
if err != nil {
return err
}
payload := map[string]interface{}{
"note": body,
"state": ctx.Arg("state"),
}
if commit := ctx.Arg("commit"); commit != "" {
payload["commit_id"] = commit
} else {
payload["commit_id"] = ""
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/v1/%s/%s/pulls/%s/journals/%s", ctx.Owner, ctx.Repo, id, commentID), payload)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "edit", "PR comment", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "comment-delete",
Description: "Delete a PR review comment",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除 PR #%s 的评论 #%s", ctx.Arg("id"), ctx.Arg("comment-id")), nil
},
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", "--id 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/pulls/%s/journals/%s", ctx.Owner, ctx.Repo, id, commentID), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "delete", "PR comment", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -265,15 +530,15 @@ func Shortcuts() []*common.Shortcut {
func extractIssueID(env *output.Envelope) (int64, error) {
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected PR response format")
return 0, clierrors.InputError("unexpected PR response format", "API 返回格式异常,请稍后重试")
}
issue, ok := data["issue"].(map[string]interface{})
if !ok {
return 0, fmt.Errorf("PR response missing issue field")
return 0, clierrors.InputError("PR response missing issue field", "API 返回数据中缺少 issue 字段,请稍后重试")
}
idFloat, ok := issue["id"].(float64)
if !ok {
return 0, fmt.Errorf("PR response missing issue.id field")
return 0, clierrors.InputError("PR response missing issue.id field", "API 返回数据中缺少 issue.id 字段,请稍后重试")
}
return int64(idFloat), nil
}

View File

@ -141,3 +141,186 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func assertPath(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Fatalf("path: got %s, want %s", got, want)
}
}
// === PR 增强测试 ===
func TestReopenCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": float64(1)})
}))
defer server.Close()
err := runPRShortcut(t, server, "reopen", map[string]string{"id": "42"})
if err != nil {
t.Fatalf("reopen failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/pulls/42/reopen.json")
}
func TestUpdateAutoFetchesExistingPR(t *testing.T) {
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/42.json":
writeJSON(t, w, map[string]interface{}{
"title": "Old title",
"body": "Old body",
"head": "feature",
"base": "master",
})
case r.Method == "PUT" && r.URL.Path == "/owner/repo/pulls/42.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(1)})
default:
t.Fatalf("unexpected: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runPRShortcut(t, server, "update", map[string]string{
"id": "42",
"title": "New title",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
assertEqual(t, updatePayload["title"], "New title")
assertEqual(t, updatePayload["body"], "Old body")
assertEqual(t, updatePayload["head"], "feature")
assertEqual(t, updatePayload["base"], "master")
}
func TestCommitsCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"commits_count": float64(2),
"commits": []interface{}{},
})
}))
defer server.Close()
err := runPRShortcut(t, server, "commits", map[string]string{"id": "42"})
if err != nil {
t.Fatalf("commits failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/pulls/42/commits.json")
}
func TestVersionsCallsV1Endpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"versions": []interface{}{}})
}))
defer server.Close()
err := runPRShortcut(t, server, "versions", map[string]string{"id": "42"})
if err != nil {
t.Fatalf("versions failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/pulls/42/versions.json")
}
func TestVdiffCallsVersionDiffEndpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"files": []interface{}{}})
}))
defer server.Close()
err := runPRShortcut(t, server, "vdiff", map[string]string{
"id": "42",
"version": "5",
})
if err != nil {
t.Fatalf("vdiff failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/pulls/42/versions/5/diff.json")
}
func TestVdiffPassesFilepathFilter(t *testing.T) {
var fp string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fp = r.URL.Query().Get("filepath")
writeJSON(t, w, map[string]interface{}{"files": []interface{}{}})
}))
defer server.Close()
err := runPRShortcut(t, server, "vdiff", map[string]string{
"id": "42",
"version": "5",
"filepath": "main.go",
})
if err != nil {
t.Fatalf("vdiff with filepath failed: %v", err)
}
assertEqual(t, fp, "main.go")
}
func TestFilesv1CallsV1FilesEndpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"files": []interface{}{}})
}))
defer server.Close()
err := runPRShortcut(t, server, "filesv1", map[string]string{"id": "42"})
if err != nil {
t.Fatalf("filesv1 failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/pulls/42/files.json")
}
func TestCommentEditSendsNoteSingular(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": float64(1)})
}))
defer server.Close()
err := runPRShortcut(t, server, "comment-edit", map[string]string{
"id": "42",
"comment-id": "100",
"body": "updated review",
"state": "resolved",
})
if err != nil {
t.Fatalf("comment-edit failed: %v", err)
}
assertEqual(t, payload["note"], "updated review")
assertEqual(t, payload["state"], "resolved")
}
func TestCommentDeleteCallsCorrectEndpoint(t *testing.T) {
var requestedPath, requestedMethod string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
requestedMethod = r.Method
writeJSON(t, w, map[string]interface{}{"status": float64(1)})
}))
defer server.Close()
err := runPRShortcut(t, server, "comment-delete", map[string]string{
"id": "42",
"comment-id": "100",
})
if err != nil {
t.Fatalf("comment-delete failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/pulls/42/journals/100.json")
assertEqual(t, requestedMethod, "DELETE")
}

View File

@ -10,6 +10,10 @@ func newApproveShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "approve",
Description: "Approve a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("批准 PR #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Review comment (optional)"},
@ -18,7 +22,10 @@ func newApproveShortcut() *common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id", "--id 42")
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
body := map[string]interface{}{
"state": "approved",
}
@ -27,7 +34,7 @@ func newApproveShortcut() *common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), body)
if err != nil {
return err
return fmt.Errorf("批准 PR 失败: %w", err)
}
return ctx.Output(env)
},
@ -38,6 +45,10 @@ func newRequestChangesShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "request-changes",
Description: "Request changes on a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("请求 PR #%s 修改", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Review comment explaining what needs to change", Required: true},
@ -46,15 +57,21 @@ func newRequestChangesShortcut() *common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id", "--id 42")
body, _ := ctx.RequireArg("body", `--body "Looks good"`)
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
body, err := ctx.RequireArg("body", `--body "Looks good"`)
if err != nil {
return err
}
payload := map[string]interface{}{
"state": "changes_requested",
"body": body,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), payload)
if err != nil {
return err
return fmt.Errorf("请求修改 PR 失败: %w", err)
}
return ctx.Output(env)
},
@ -72,11 +89,14 @@ func newReviewsShortcut() *common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id", "--id 42")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), nil)
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("获取 PR 评审列表失败: %w", err)
}
return ctx.Output(env)
},
}

View File

@ -3,14 +3,13 @@ package shortcuts
import (
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/shortcuts/board"
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/compliance"
"github.com/gitlink-org/gitlink-cli/shortcuts/contrib"
"github.com/gitlink-org/gitlink-cli/shortcuts/onboard"
"github.com/gitlink-org/gitlink-cli/shortcuts/file"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
// "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" // broken: syntax errors
"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"
@ -22,50 +21,117 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
)
type groupInfo struct {
Short string
Long string
Example string
}
// 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(),
// "milestone": milestone.Shortcuts(), // broken
"team": team.Shortcuts(),
"wiki": wiki.Shortcuts(),
"webhook": webhook.Shortcuts(),
"contrib": contrib.Shortcuts(),
"compliance": compliance.Shortcuts(),
"onboard": onboard.Shortcuts(),
"board": board.Shortcuts(),
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"milestone": milestone.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"file": file.Shortcuts(),
"team": team.Shortcuts(),
"wiki": wiki.Shortcuts(),
"webhook": webhook.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",
// "milestone": "Milestone operations", // broken
"team": "Team operations",
"wiki": "Wiki operations",
"webhook": "Webhook operations",
"contrib": "Contribution report operations",
"compliance": "Compliance and security scan operations",
"onboard": "New contributor onboarding operations",
infos := map[string]groupInfo{
"board": {
Short: "Board (kanban) operations",
Long: "View and manage kanban boards: view board layout, list columns, filter issues, move tasks between columns, assign people, and analyze workload.",
Example: " gitlink-cli board +view\n gitlink-cli board +columns\n gitlink-cli board +issues --column \"In Progress\" --assignee zhangsan\n gitlink-cli board +move --number 42 --status in-progress\n gitlink-cli board +assign --number 42 --assignee lisi\n gitlink-cli board +stats",
},
"repo": {
Short: "Repository operations",
Long: "Manage GitLink repositories: create, list, fork, delete, update settings, and manage members.",
Example: " gitlink-cli repo +list --user myorg\n gitlink-cli repo +create --name new-project\n gitlink-cli repo +info --owner org --repo name",
},
"issue": {
Short: "Issue operations",
Long: "Manage issues: list, create, view, update, close, reopen, comment, labels, batch operations, metadata queries, and comment management.",
Example: " gitlink-cli issue +list --state open\n gitlink-cli issue +create --title \"Bug found\" --body \"Details...\"\n gitlink-cli issue +close --number 42\n gitlink-cli issue +statuses\n gitlink-cli issue +comment-edit --number 42 --comment-id 1 --body \"updated\"",
},
"milestone": {
Short: "Milestone operations",
Long: "Manage milestones: list, create, view, update, delete, and change status.",
Example: " gitlink-cli milestone +list\n gitlink-cli milestone +create --name v1.0 --description \"First release\" --date 2026-12-31\n gitlink-cli milestone +view --id 1\n gitlink-cli milestone +status --id 1 --status closed",
},
"pr": {
Short: "Pull request operations",
Long: "Manage pull requests: list, create, view, merge, close, review, and view changed files.",
Example: " gitlink-cli pr +list --state open\n gitlink-cli pr +create --title \"Fix login\" --head feat-branch\n gitlink-cli pr +merge --id 42",
},
"release": {
Short: "Release operations",
Long: "Manage releases: list, create, view, and delete releases with release notes.",
Example: " gitlink-cli release +list\n gitlink-cli release +create --tag v1.0.0 --name \"First release\"",
},
"branch": {
Short: "Branch operations",
Long: "Manage branches: list, create, delete, and protect branches.",
Example: " gitlink-cli branch +list\n gitlink-cli branch +create --name feature-x\n gitlink-cli branch +protect --name master",
},
"org": {
Short: "Organization operations",
Long: "Manage organizations: list, view info, manage members, create and update.",
Example: " gitlink-cli org +list\n gitlink-cli org +info --org myorg\n gitlink-cli org +members --org myorg",
},
"user": {
Short: "User operations",
Long: "View current user info, profile details, and owned repositories.",
Example: " gitlink-cli user +me\n gitlink-cli user +info --login username",
},
"search": {
Short: "Search operations",
Long: "Search across repositories, users, and issues on GitLink.",
Example: " gitlink-cli search +repos --q \"machine learning\"\n gitlink-cli search +users --q \"developer\"",
},
"ci": {
Short: "CI/CD operations",
Long: "Manage CI/CD pipelines: view build history, read logs, restart and stop builds.",
Example: " gitlink-cli ci +builds\n gitlink-cli ci +logs --id 123\n gitlink-cli ci +restart --id 123",
},
"file": {
Short: "File and code operations",
Long: "Browse directories, read files, create/update/delete files, view commit history and diffs.",
Example: " gitlink-cli file +ls\n gitlink-cli file +read --path README.md\n gitlink-cli file +create --path new.txt --content hello --branch master --message \"add file\"\n gitlink-cli file +commits",
},
"team": {
Short: "Team operations",
Long: "Manage teams within organizations: list, create, delete, and manage members.",
Example: " gitlink-cli team +list --org myorg\n gitlink-cli team +create --org myorg --name dev-team",
},
"wiki": {
Short: "Wiki operations",
Long: "Manage wiki pages: list, create, view, update, delete, lint, fix formatting, and sync.",
Example: " gitlink-cli wiki +list\n gitlink-cli wiki +create --title \"Getting Started\" --content \"# Welcome\"",
},
"webhook": {
Short: "Webhook operations",
Long: "Manage webhooks: list, create, view, update, delete, test, and inspect events.",
Example: " gitlink-cli webhook +list\n gitlink-cli webhook +create --url https://example.com/hook --events push",
},
}
for name, shortcuts := range groups {
info := infos[name]
groupCmd := &cobra.Command{
Use: name,
Short: descriptions[name],
Use: name,
Short: info.Short,
Long: info.Long,
Example: info.Example,
}
common.MountShortcuts(groupCmd, shortcuts)
root.AddCommand(groupCmd)

View File

@ -143,11 +143,18 @@ func Shortcuts() []*common.Shortcut {
{Name: "body", Short: "b", Usage: "New release notes"},
{Name: "prerelease", Usage: "Mark as prerelease (true/false)"},
},
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新 Release #%s", ctx.Arg("id")), nil
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id", "--id my-org")
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
body := map[string]interface{}{}
if n := ctx.Arg("name"); n != "" {
body["name"] = n
@ -163,7 +170,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), body)
if err != nil {
return err
return fmt.Errorf("更新 Release 失败: %w", err)
}
return ctx.Output(env)
},

View File

@ -5,6 +5,7 @@ import (
"net/url"
"strconv"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
@ -16,9 +17,10 @@ func Shortcuts() []*common.Shortcut {
{
Name: "list",
Description: "List repositories for a user or organization",
Example: " gitlink-cli repo +list\n gitlink-cli repo +list --user myorg\n gitlink-cli repo +list --category mirror --page 1 --limit 50",
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"},
{Name: "category", Short: "c", Usage: "Filter by category", Default: "manage", Choices: []string{"manage", "mirror", "sync", "fork", "all"}},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
@ -37,7 +39,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return fmt.Errorf("获取仓库列表失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "list", "repositories", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -51,7 +53,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return fmt.Errorf("查看仓库失败: %w", err)
return clierrors.OpError(clierrors.KindNotFound, "view", "repository", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -59,6 +61,8 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a new repository",
Long: "Create a new GitLink repository. Requires --name. Supports --description and --private flags.",
Example: " gitlink-cli repo +create --name my-project\n gitlink-cli repo +create --name my-project --description \"A new project\" --private true\n gitlink-cli repo +create --name my-project --dry-run",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
@ -77,12 +81,12 @@ func Shortcuts() []*common.Shortcut {
// Get current user login for the create path
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return fmt.Errorf("获取当前用户信息失败: %w", err)
return clierrors.OpError(clierrors.KindAuth, "view", "user profile", err).WithCommand(ctx.CommandName)
}
userData, _ := userEnv.Data.(map[string]interface{})
login, _ := userData["login"].(string)
if login == "" {
return fmt.Errorf("cannot determine current user login")
return clierrors.InputError("cannot determine current user login", "请先运行 gitlink-cli auth login 登录").WithCommand(ctx.CommandName)
}
userID, _ := userData["user_id"].(float64)
body := map[string]interface{}{
@ -98,7 +102,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body)
if err != nil {
return fmt.Errorf("创建仓库失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "create", "repository", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -119,7 +123,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", nil)
if err != nil {
return fmt.Errorf("Fork 仓库失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "fork", "repository", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -140,7 +144,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
if err != nil {
return fmt.Errorf("删除仓库失败: %w", err)
return clierrors.OpError(clierrors.KindServer, "delete", "repository", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -164,11 +168,14 @@ func Shortcuts() []*common.Shortcut {
body["private"] = p == "true"
}
if len(body) == 0 {
return fmt.Errorf("at least one of --description, --private is required")
return clierrors.InputError(
"at least one of --description, --private is required",
"至少需要提供 --description 或 --private 中的一个参数",
).WithCommand(ctx.CommandName)
}
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "update", "repository", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -189,7 +196,7 @@ func Shortcuts() []*common.Shortcut {
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/collaborators", q)
if err != nil {
return fmt.Errorf("failed to list members for %s/%s: %w", ctx.Owner, ctx.Repo, err)
return clierrors.OpError(clierrors.KindServer, "list", "members", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -218,12 +225,15 @@ func Shortcuts() []*common.Shortcut {
}
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("invalid user-id: %s (must be an integer)", userID)
return clierrors.InputError(
fmt.Sprintf("invalid user-id: %s (must be an integer)", userID),
"user-id 必须是整数,请检查参数值",
).WithCommand(ctx.CommandName)
}
body := map[string]interface{}{"user_id": uid}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body)
if err != nil {
return fmt.Errorf("failed to invite user %d to %s/%s: %w", uid, ctx.Owner, ctx.Repo, err)
return clierrors.OpError(clierrors.KindServer, "invite", "member", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -252,12 +262,15 @@ func Shortcuts() []*common.Shortcut {
}
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("invalid user-id: %s (must be an integer)", userID)
return clierrors.InputError(
fmt.Sprintf("invalid user-id: %s (must be an integer)", userID),
"user-id 必须是整数,请检查参数值",
).WithCommand(ctx.CommandName)
}
body := map[string]interface{}{"user_id": uid}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body)
if err != nil {
return fmt.Errorf("failed to remove user %d from %s/%s: %w", uid, ctx.Owner, ctx.Repo, err)
return clierrors.OpError(clierrors.KindServer, "remove", "member", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},

View File

@ -69,14 +69,17 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
keyword, _ := ctx.RequireArg("keyword", "--keyword myproject")
keyword, err := ctx.RequireArg("keyword", "--keyword myproject")
if err != nil {
return err
}
q := url.Values{}
q.Set("search", keyword)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/issues", ctx.RepoPath()), q)
if err != nil {
return err
return fmt.Errorf("搜索 Issue 失败: %w", err)
}
return ctx.Output(env)
},

View File

@ -18,13 +18,16 @@ func Shortcuts() []*common.Shortcut {
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org", "--org my-org")
org, err := ctx.RequireArg("org", "--org my-org")
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("/organizations/%s/teams", org), q)
if err != nil {
return err
return fmt.Errorf("获取团队列表失败: %w", err)
}
return ctx.Output(env)
},
@ -32,14 +35,24 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a team in an organization",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("创建团队: %s", ctx.Arg("name")), nil
},
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "name", Short: "n", Usage: "Team name", Required: true},
{Name: "description", Short: "d", Usage: "Team description"},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org", "--org my-org")
name, _ := ctx.RequireArg("name", `--name "My Team"`)
org, err := ctx.RequireArg("org", "--org my-org")
if err != nil {
return err
}
name, err := ctx.RequireArg("name", `--name "My Team"`)
if err != nil {
return err
}
body := map[string]interface{}{
"name": name,
}
@ -48,7 +61,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams", org), body)
if err != nil {
return err
return fmt.Errorf("创建团队失败: %w", err)
}
return ctx.Output(env)
},
@ -56,17 +69,27 @@ func Shortcuts() []*common.Shortcut {
{
Name: "delete",
Description: "Delete a team",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除团队 #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "id", Short: "i", Usage: "Team ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org", "--org my-org")
id, _ := ctx.RequireArg("id", "--id 1")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s", org, id), nil)
org, err := ctx.RequireArg("org", "--org my-org")
if err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s", org, id), nil)
if err != nil {
return fmt.Errorf("删除团队失败: %w", err)
}
return ctx.Output(env)
},
},
@ -80,14 +103,20 @@ func Shortcuts() []*common.Shortcut {
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org", "--org my-org")
id, _ := ctx.RequireArg("id", "--id 1")
org, err := ctx.RequireArg("org", "--org my-org")
if err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 1")
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("/organizations/%s/teams/%s/members", org, id), q)
if err != nil {
return err
return fmt.Errorf("获取团队成员列表失败: %w", err)
}
return ctx.Output(env)
},
@ -95,21 +124,34 @@ func Shortcuts() []*common.Shortcut {
{
Name: "member-add",
Description: "Add a user to a team",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("添加用户 %s 到团队 %s", ctx.Arg("user"), ctx.Arg("team")), nil
},
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
{Name: "user", Short: "u", Usage: "User login or ID to add", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org", "--org my-org")
team, _ := ctx.RequireArg("team", "--team dev-team")
user, _ := ctx.RequireArg("user", "--user alice")
org, err := ctx.RequireArg("org", "--org my-org")
if err != nil {
return err
}
team, err := ctx.RequireArg("team", "--team dev-team")
if err != nil {
return err
}
user, err := ctx.RequireArg("user", "--user alice")
if err != nil {
return err
}
body := map[string]interface{}{
"user_id": user,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams/%s/members", org, team), body)
if err != nil {
return err
return fmt.Errorf("添加团队成员失败: %w", err)
}
return ctx.Output(env)
},
@ -117,19 +159,32 @@ func Shortcuts() []*common.Shortcut {
{
Name: "member-remove",
Description: "Remove a user from a team",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("从团队 %s 移除用户 %s", ctx.Arg("user"), ctx.Arg("team")), nil
},
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
{Name: "user", Short: "u", Usage: "User login or ID to remove", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org", "--org my-org")
team, _ := ctx.RequireArg("team", "--team dev-team")
user, _ := ctx.RequireArg("user", "--user alice")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s/members/%s", org, team, user), nil)
org, err := ctx.RequireArg("org", "--org my-org")
if err != nil {
return err
}
team, err := ctx.RequireArg("team", "--team dev-team")
if err != nil {
return err
}
user, err := ctx.RequireArg("user", "--user alice")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s/members/%s", org, team, user), nil)
if err != nil {
return fmt.Errorf("移除团队成员失败: %w", err)
}
return ctx.Output(env)
},
},

View File

@ -10,7 +10,18 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// 支持的Webhook事件类型
// supportedEvents 定义了 GitLink 支持的所有 Webhook 事件类型
// push: 代码推送事件
// pull_request: PR 事件
// issue: Issue 事件
// issue_assign: Issue 分配事件
// issue_comment: Issue 评论事件
// pull_request_assign: PR 分配事件
// pull_request_comment: PR 评论事件
// merge_request: 合并请求事件
// repository: 仓库事件
// branch: 分支创建/删除事件
// tag: 标签创建/删除事件
var supportedEvents = []string{
"push",
"pull_request",
@ -25,6 +36,9 @@ var supportedEvents = []string{
"tag",
}
// isEventSupported 检查某个事件类型是否被支持
// 参数: event - 要检查的事件类型
// 返回: true 表示支持false 表示不支持
func isEventSupported(event string) bool {
for _, supported := range supportedEvents {
if event == supported {
@ -34,29 +48,37 @@ func isEventSupported(event string) bool {
return false
}
// parseEvents 把用户输入的逗号分隔的事件字符串解析成事件数组
// 参数: eventsStr - 用户输入的事件字符串,如 "push,pull_request"
// 返回: 过滤后的有效事件数组,如果输入为空则返回默认值 ["push"]
func parseEvents(eventsStr string) []string {
if eventsStr == "" {
return []string{"push"} // 默认事件
}
events := strings.Split(eventsStr, ",")
events := strings.Split(eventsStr, ",") // 按逗号分割
var validEvents []string
for _, event := range events {
event = strings.TrimSpace(event)
event = strings.TrimSpace(event) // 去掉前后空格
if isEventSupported(event) {
validEvents = append(validEvents, event)
validEvents = append(validEvents, event) // 有效事件才加入结果
}
}
return validEvents
}
// webhookRepoPath returns the webhook API path prefix: /v1/{owner}/{repo}
// Note: BaseURL already includes /api prefix
// webhookRepoPath 构建 Webhook API 的基础路径
// 参数: ctx - 运行时上下文,包含 Owner仓库所有者和 Repo仓库名
// 返回: 类似 /v1/owner/repo 的字符串
// 注意: BaseURL 已经包含 /api 前缀,所以这里不需要再加
func webhookRepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
// Shortcuts 返回所有 Webhook 相关的 CLI 命令列表
// 包含7个命令list、create、update、delete、test、info、events
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// list 命令:列出仓库的所有 Webhook
{
Name: "list",
Description: "List all webhooks for a repository",
@ -65,22 +87,31 @@ func Shortcuts() []*common.Shortcut {
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息从命令行参数或 Git 远程仓库)
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2创建 URL 查询参数
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
// 步骤3调用 API 获取 Webhook 列表
env, err := ctx.CallAPIWithQuery("GET", webhookRepoPath(ctx)+"/webhooks", q)
if err != nil {
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
}
// 步骤4输出结果给用户
return ctx.Output(env)
},
},
// create 命令:创建新的 Webhook
{
Name: "create",
Description: "Create a new webhook",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("创建 Webhook: %s", ctx.Arg("url")), nil
},
Flags: []common.Flag{
{Name: "url", Short: "u", Usage: "Webhook callback URL", Required: true},
{Name: "events", Short: "e", Usage: "Trigger events (comma-separated), e.g., push,pull_request,issue", Default: "push"},
@ -89,15 +120,18 @@ func Shortcuts() []*common.Shortcut {
{Name: "description", Short: "d", Usage: "Webhook description"},
},
Run: func(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取必需参数 --url用 RequireArg如果没提供会报错
webhookURL, err := ctx.RequireArg("url", "--url https://example.com/hook")
if err != nil {
return err
}
// 步骤3解析事件类型逗号分隔自动过滤无效事件
events := parseEvents(ctx.Arg("events"))
if len(events) == 0 {
return clierrors.InputError(
@ -106,6 +140,7 @@ func Shortcuts() []*common.Shortcut {
)
}
// 步骤4构建请求体payload包含必填字段
payload := map[string]interface{}{
"url": webhookURL,
"http_method": "POST",
@ -113,27 +148,33 @@ func Shortcuts() []*common.Shortcut {
"content_type": "json",
}
// 添加事件列表
if len(events) > 0 {
payload["events"] = events
} else {
payload["events"] = []string{"push"}
}
// 添加可选参数secret签名密钥
if secret := ctx.Arg("secret"); secret != "" {
payload["secret"] = secret
}
// 添加可选参数description描述
if description := ctx.Arg("description"); description != "" {
payload["description"] = description
}
// 步骤5发送 POST 请求创建 Webhook
env, err := ctx.CallAPI("POST", webhookRepoPath(ctx)+"/webhooks", payload)
if err != nil {
return fmt.Errorf("创建 Webhook 失败: %w", err)
}
// 步骤6输出结果
return ctx.Output(env)
},
},
// update 命令:更新现有的 Webhook
{
Name: "update",
Description: "Update an existing webhook",
@ -147,28 +188,33 @@ func Shortcuts() []*common.Shortcut {
{Name: "description", Short: "d", Usage: "Webhook description"},
},
Run: func(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取必需参数 --id
webhookID, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
// 步骤3初始化请求体
payload := map[string]interface{}{
"http_method": "POST",
"active": true,
"content_type": "json",
}
// 如果用户没有提供URL获取当前webhook的URL
// 步骤4智能获取 URL
// 如果用户没提供 URL自动调用 GET API 获取当前 URL避免用户重复输入
webhookURL := ctx.Arg("url")
if webhookURL == "" {
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
if err != nil {
return fmt.Errorf("获取 Webhook 当前信息失败: %w", err)
}
// 类型断言:把 Data 转换为 map[string]interface{}
webhookData, ok := getEnv.Data.(map[string]interface{})
if !ok {
return fmt.Errorf("failed to parse webhook data")
@ -181,6 +227,7 @@ func Shortcuts() []*common.Shortcut {
}
payload["url"] = webhookURL
// 步骤5添加可选参数
if events := ctx.Arg("events"); events != "" {
validEvents := parseEvents(events)
if len(validEvents) == 0 {
@ -191,21 +238,17 @@ func Shortcuts() []*common.Shortcut {
}
payload["events"] = validEvents
}
if contentType := ctx.Arg("content_type"); contentType != "" {
payload["content_type"] = contentType
}
if secret := ctx.Arg("secret"); secret != "" {
payload["secret"] = secret
}
if description := ctx.Arg("description"); description != "" {
payload["description"] = description
}
// 步骤6发送 PUT 请求更新 Webhook
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), payload)
if err != nil {
return fmt.Errorf("更新 Webhook 失败: %w", err)
@ -213,28 +256,36 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// delete 命令:删除 Webhook带双重验证机制
{
Name: "delete",
Description: "Delete a webhook",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除 Webhook #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取必需参数 --id
webhookID, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
// 步骤3发送 DELETE 请求
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
if delErr != nil {
// 验证是否真的删除成功类似release的处理
// 双重验证:如果 DELETE 失败,再调用 GET 检查 Webhook 是否还存在
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
if viewErr != nil {
// Webhook不存在了说明删除成功
// GET 也失败,说明 Webhook 确实不存在了,视为删除成功
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "Webhook deleted successfully",
}, nil))
@ -246,6 +297,7 @@ func Shortcuts() []*common.Shortcut {
}, nil))
},
},
// test 命令:测试 Webhook发送测试请求
{
Name: "test",
Description: "Test a webhook delivery (send a ping event)",
@ -254,15 +306,18 @@ func Shortcuts() []*common.Shortcut {
{Name: "event", Short: "e", Usage: "Event type to test (default: push)", Default: "push"},
},
Run: func(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取必需参数 --id
webhookID, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
// 步骤3验证事件类型
eventType := ctx.Arg("event")
if !isEventSupported(eventType) {
return clierrors.InputError(
@ -271,6 +326,7 @@ func Shortcuts() []*common.Shortcut {
)
}
// 步骤4发送测试请求
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)
if err != nil {
return fmt.Errorf("测试 Webhook 失败: %w", err)
@ -278,6 +334,7 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// info 命令:查看 Webhook 详情
{
Name: "info",
Description: "Show webhook details",
@ -285,15 +342,18 @@ func Shortcuts() []*common.Shortcut {
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取必需参数 --id
webhookID, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
// 步骤3调用 GET API 获取详情
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
if err != nil {
return fmt.Errorf("查看 Webhook 详情失败: %w", err)
@ -301,10 +361,12 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// events 命令:列出所有支持的事件类型
{
Name: "events",
Description: "List all supported event types for webhooks",
Run: func(ctx *common.RuntimeContext) error {
// 构建事件信息列表
eventInfo := make([]map[string]interface{}, 0)
for _, event := range supportedEvents {
eventInfo = append(eventInfo, map[string]interface{}{
@ -313,12 +375,16 @@ func Shortcuts() []*common.Shortcut {
"description": getEventDescription(event),
})
}
// 输出结果(用 SuccessEnvelope 包装)
return ctx.Output(output.SuccessEnvelope(eventInfo, nil))
},
},
}
}
// getEventDescription 返回事件类型的英文描述
// 参数: event - 事件类型名称
// 返回: 事件描述,如果找不到则返回 "Custom event"
func getEventDescription(event string) string {
descriptions := map[string]string{
"push": "Code push events",

View File

@ -1,86 +1,106 @@
package wiki
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
"encoding/base64" // Base64 编解码Wiki 内容是 Base64 编码的
"encoding/json" // JSON 处理,用于解析 API 响应
"errors" // 错误处理
"fmt" // 格式化输出
"net/http" // HTTP 客户端相关
"net/url" // URL 处理,用于构建查询参数
"os" // 文件操作,用于读取本地文件内容
"regexp" // 正则表达式,用于 Lint 功能中的链接匹配
"strconv" // 字符串和数字转换
"strings" // 字符串处理
"sync" // 并发安全,用于 projectIDCache
"time" // 时间相关,用于 HTTP 超时设置
"github.com/gitlink-org/gitlink-cli/internal/auth"
"github.com/gitlink-org/gitlink-cli/internal/client"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块,创建带认证的 HTTP 客户端
"github.com/gitlink-org/gitlink-cli/internal/client" // HTTP 客户端模块,封装 API 请求
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // 自定义 CLI 错误类型
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化模块
"github.com/gitlink-org/gitlink-cli/shortcuts/common" // 公共工具包,包含 Shortcut、RuntimeContext 等
)
// projectIDCache 项目 ID 缓存,使用 sync.Map 实现并发安全
// 键owner/repoproject_id字符串
// 作用:避免重复调用项目详情 API 获取 project_id
var projectIDCache sync.Map
// wikiPath 构建 Wiki API 的路径前缀
// 参数: endpoint - API 端点(如 "pages", "getWiki"
// 返回: 完整路径,如 "/wiki/open/pages"
// 说明Wiki 功能调用的是网关 API所有接口都有固定前缀 "/wiki/open/"
func wikiPath(endpoint string) string {
return "/wiki/open/" + endpoint
}
// getGatewayClient returns a client targeting the Wiki API gateway.
// The BaseURL is resolved from RuntimeContext.GatewayBaseURL, which in turn
// honours (in order): GITLINK_GATEWAY_URL env > config gateway_base_url > default.
// HTTP client falls back to auth.NewHTTPClient() when ctx.GatewayHTTPClient is nil.
// getGatewayClient 创建 Wiki API 专用的 HTTP 客户端
// BaseURL 优先级:
// 1. GITLINK_GATEWAY_URL 环境变量(最高优先级)
// 2. 配置文件中的 gateway_base_url
// 3. 默认值 https://gateway.gitlink.org.cn/api最低优先级
// HTTP 客户端:如果 ctx.GatewayHTTPClient 为 nil回退到 auth.NewHTTPClient()(带认证)
func getGatewayClient(ctx *common.RuntimeContext) *client.Client {
baseURL := ctx.GatewayBaseURL
if baseURL == "" {
baseURL = "https://gateway.gitlink.org.cn/api"
baseURL = "https://gateway.gitlink.org.cn/api" // 兜底默认值
}
httpClient := ctx.GatewayHTTPClient
if httpClient == nil {
httpClient = auth.NewHTTPClient()
httpClient = auth.NewHTTPClient() // 创建带认证的 HTTP 客户端
}
return &client.Client{
HTTP: httpClient,
BaseURL: baseURL,
SkipJSONSuffix: true,
Debug: ctx.Client.Debug,
HTTP: httpClient, // HTTP 客户端(带认证)
BaseURL: baseURL, // Wiki 网关 API 地址
SkipJSONSuffix: true, // 不需要在 URL 后加 .json 后缀
Debug: ctx.Client.Debug, // 继承调试模式设置
}
}
// callWikiAPI 调用 Wiki API无查询参数
// 参数: method - HTTP 方法; path - API 路径; body - 请求体
// 返回: API 响应信封,以及可能的错误
// 内部流程:创建网关客户端 → 发送请求 → 处理响应
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, body, nil)
gc := getGatewayClient(ctx) // 创建 Wiki 专用客户端
env, err := gc.Do(method, path, body, nil) // 发送请求
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
return unwrapGatewayResponse(env) // 处理网关响应格式
}
// callWikiAPIWithQuery 调用 Wiki API带查询参数
// 参数: method - HTTP 方法; path - API 路径; query - URL 查询参数
// 返回: API 响应信封,以及可能的错误
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, nil, query)
gc := getGatewayClient(ctx) // 创建 Wiki 专用客户端
env, err := gc.Do(method, path, nil, query) // 发送带查询参数的请求
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
return unwrapGatewayResponse(env) // 处理网关响应格式
}
// unwrapGatewayResponse 处理网关 API 的响应格式
// 网关 API 返回格式:{"code": 200, "data": {...}, "msg": "..."}
// 主 API 返回格式:{"ok": true, "data": {...}}
// 此函数将网关响应转换为统一的 Envelope 格式
func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) {
// 尝试把 Data 转换为 map
resp, ok := env.Data.(map[string]interface{})
if !ok {
return env, nil
return env, nil // 不是 map 格式,直接返回
}
// 检查 code 字段(网关 API 的状态码)
if code, ok := resp["code"]; ok {
switch v := code.(type) {
case float64:
// HTTP 2xx 全部视为成功200 OK / 201 Created / 202 Accepted / 204 No Content 等)
// 之前只接受 200/201导致 DELETE 返回 204 时被误判为失败
// HTTP 2xx 全部视为成功200 OK / 201 Created / 204 No Content 等)
if v < 200 || v >= 300 {
msg, _ := resp["msg"].(string)
// 必须返回 *clierrors.CLIError否则 shortcuts/common.TryPrintError
// 的 errors.As 无法识别,错误就不会按 envelope 格式输出,
// 会回退到 stderr 的纯文本输出(破坏 --format json/table/yaml
// 根据状态码确定错误类型
kind := clierrors.KindServer
if int(v) == 404 {
kind = clierrors.KindNotFound
@ -92,18 +112,28 @@ func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) {
}
}
}
// 提取内层 data 字段
if innerData, ok := resp["data"]; ok {
return output.SuccessEnvelope(innerData, env.Meta), nil
}
return env, nil
}
// resolveProjectID 获取项目 ID带缓存
// 流程:
// 1. 先从缓存查找owner/repo 作为键)
// 2. 缓存命中:直接返回
// 3. 缓存未命中:调用项目详情 API 获取 project_id并存入缓存
// 参数: ctx - 运行时上下文,包含 Owner 和 Repo
// 返回: project_id字符串以及可能的错误
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
key := ctx.Owner + "/" + ctx.Repo
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil
return cached.(string), nil // 缓存命中,直接返回
}
// 调用项目详情 API 获取 project_id
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
@ -120,31 +150,39 @@ func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
return "", fmt.Errorf("project_id not found in project detail response")
}
// 处理 project_id 的多种类型API 返回可能是 float64、int 或 string
var pidStr string
switch v := pid.(type) {
case float64:
pidStr = fmt.Sprintf("%.0f", v)
pidStr = fmt.Sprintf("%.0f", v) // JSON 数字默认解析为 float64
case int:
pidStr = fmt.Sprintf("%d", v)
default:
pidStr = fmt.Sprintf("%v", v)
}
projectIDCache.Store(key, pidStr)
projectIDCache.Store(key, pidStr) // 存入缓存
return pidStr, nil
}
// parseProjectIDInt 将项目 ID 字符串转换为整数
// 参数: pid - 项目 ID 字符串
// 返回: 整数形式的项目 ID转换失败返回 0
func parseProjectIDInt(pid string) int {
n, _ := strconv.Atoi(pid)
return n
}
// resolveUpdateContent 解析更新内容(优先使用命令行参数,其次使用文件)
// 参数: ctx - 运行时上下文; text - 命令行传入的内容; filePath - 文件路径
// 返回: 最终内容字符串,以及可能的错误
// 优先级text命令行参数 > filePath文件 > 报错
func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (string, error) {
if text != "" {
return text, nil
return text, nil // 优先使用命令行传入的内容
}
if filePath != "" {
data, err := os.ReadFile(filePath)
data, err := os.ReadFile(filePath) // 从文件读取内容
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
@ -153,10 +191,10 @@ func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (st
return "", fmt.Errorf("no content provided")
}
// fetchPageContent 获取 wiki 页面明文内容
// fetchPageContent 获取 wiki 页面明文内容(带自动重试)
//
// 自动重试策略GitLink 后端创建 wiki 时会自动给 sub_url 追加 ".-" 后缀,
// 而 wiki +list 返回的 title 不带后缀。若首次用原始 pageName 查询失败且
// 而 wiki list 返回的 title 不带后缀。若首次用原始 pageName 查询失败且
// pageName 不带 ".-" 后缀,自动用 pageName+".-" 重试一次。
//
// 返回值:
@ -166,37 +204,45 @@ func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (st
func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (content string, actualPageName string, err error) {
c, actual, err := fetchPageContentOnce(ctx, projectID, pageName)
if err == nil {
return c, actual, nil
return c, actual, nil // 首次尝试成功,直接返回
}
// 首次失败且 pageName 不带 ".-" 后缀:自动重试一次
if !strings.HasSuffix(pageName, ".-") {
c2, actual2, err2 := fetchPageContentOnce(ctx, projectID, pageName+".-")
if err2 == nil {
return c2, actual2, nil
return c2, actual2, nil // 重试成功
}
}
return "", "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err)
}
// fetchPageContentOnce 单次尝试获取 wiki 页面内容(不做重试)。
// fetchPageContentOnce 单次尝试获取 wiki 页面内容(不做重试)
// 参数: ctx - 运行时上下文; projectID - 项目 ID; pageName - 页面名称
// 返回: 解码后的明文内容、实际页面名称、可能的错误
func fetchPageContentOnce(ctx *common.RuntimeContext, projectID, pageName string) (string, string, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
// 调用 getWiki API 获取页面内容
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if err != nil {
return "", "", err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("unexpected response from getWiki")
}
b64, _ := data["content_base64"].(string)
if b64 == "" {
return "", pageName, nil
return "", pageName, nil // 没有内容,返回空字符串
}
// Base64 解码
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", "", fmt.Errorf("failed to decode page content: %w", err)
@ -204,8 +250,12 @@ func fetchPageContentOnce(ctx *common.RuntimeContext, projectID, pageName string
return string(decoded), pageName, nil
}
// fetchWikiPage 按 pageName 查询 wiki 页面,返回完整的 envelope。
// 调用方负责处理错误(包括 404和后续的 sub_url 重试逻辑。
// fetchWikiPage 按 pageName 查询 wiki 页面,返回完整的 envelope
// 与 fetchPageContent 的区别:
// - fetchPageContent 只返回解码后的内容
// - fetchWikiPage 返回完整的 API 响应,包含所有元数据
// 参数: ctx - 运行时上下文; projectID - 项目 ID; pageName - 页面名称
// 返回: 完整的响应信封,以及可能的错误
func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
@ -215,12 +265,16 @@ func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*out
return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
}
// resolveContent 解析 wiki 页面内容(优先使用命令行参数,其次使用文件)
// 参数: ctx - 运行时上下文
// 返回: 内容字符串,以及可能的错误
// 优先级:--content 参数 > --file 参数 > 报错
func resolveContent(ctx *common.RuntimeContext) (string, error) {
if content := ctx.Arg("content"); content != "" {
return content, nil
return content, nil // 优先使用 --content 参数
}
if filePath := ctx.Arg("file"); filePath != "" {
data, err := os.ReadFile(filePath)
data, err := os.ReadFile(filePath) // 从文件读取内容
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
@ -229,6 +283,11 @@ func resolveContent(ctx *common.RuntimeContext) (string, error) {
return "", fmt.Errorf("--content or --file is required to provide wiki page content")
}
// cleanWikiList 清理 wiki 列表响应数据
// 功能:
// 1. 删除敏感字段 wiki_clone_link用户不需要看到
// 2. 对 sub_url 进行 URL 解码(后端返回的是编码后的)
// 参数: env - API 响应信封
func cleanWikiList(env *output.Envelope) {
items, ok := env.Data.([]interface{})
if !ok {
@ -239,16 +298,16 @@ func cleanWikiList(env *output.Envelope) {
if !ok {
continue
}
delete(m, "wiki_clone_link")
delete(m, "wiki_clone_link") // 删除敏感字段
if raw, ok := m["sub_url"].(string); ok {
if decoded, err := url.QueryUnescape(raw); err == nil {
m["sub_url"] = decoded
m["sub_url"] = decoded // URL 解码
}
}
}
}
// outputWithDecodedContent 解码 wiki 响应中的所有 base64 字段,用明文替换原始乱码
// outputWithDecodedContent 解码 wiki 响应中的所有 base64 字段,用明文替换原始乱码
//
// 设计权衡agent 友好性):
// - 后端返回的字段content_base64、sidebar、footer都是 base64 编码,对 agent 不可读
@ -278,15 +337,17 @@ func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope)
m, ok := data.(map[string]interface{})
if !ok {
return ctx.Output(env)
return ctx.Output(env) // 不是 map 格式,直接输出
}
// content_base64 → content重命名
if b64, ok := m["content_base64"].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m["content"] = string(decoded)
delete(m, "content_base64")
delete(m, "content_base64") // 删除原始编码字段
}
}
// sidebar / footer原地替换仅当能解码为 base64 时)
for _, field := range []string{"sidebar", "footer"} {
if b64, ok := m[field].(string); ok && b64 != "" {
@ -295,36 +356,45 @@ func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope)
}
}
}
return ctx.Output(env)
return ctx.Output(env) // 输出处理后的响应
}
// --- lint types and implementation ---
// Lint 功能用于扫描 Wiki 页面内容,检查潜在问题:空页面、缺少标题、内容过短、死链接、失效图片
// LintIssue 表示单个检查问题
type LintIssue struct {
Page string `json:"page"`
Level string `json:"level"`
Check string `json:"check"`
Message string `json:"message"`
Page string `json:"page"` // 页面名称
Level string `json:"level"` // 级别:"error"(错误)或 "warning"(警告)
Check string `json:"check"` // 检查项名称empty/headings/short/links/images/fetch
Message string `json:"message"` // 详细错误/警告信息
}
// LintSummary 表示检查汇总报告
type LintSummary struct {
Repository string `json:"repository"`
TotalPages int `json:"total_pages"`
TotalIssues int `json:"total_issues"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
Results []LintIssue `json:"results"`
Repository string `json:"repository"` // 仓库名称owner/repo
TotalPages int `json:"total_pages"` // 检查的总页数
TotalIssues int `json:"total_issues"` // 发现的总问题数
Errors int `json:"errors"` // 错误数量
Warnings int `json:"warnings"` // 警告数量
Results []LintIssue `json:"results"` // 所有问题的详细列表
}
// 正则表达式:用于匹配 Markdown 内容中的链接和图片
var (
mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`)
imageLinkRe = regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^)]+)\)`)
mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`) // 匹配 [链接文字](链接地址)
imageLinkRe = regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^)]+)\)`) // 匹配 ![图片说明](http://...)
)
// isCheckEnabled 检查指定的检查项是否启用
// checkFilter: 用户通过 --check 参数指定的检查项(逗号分隔),为空则启用全部
// name: 当前检查项名称
// 返回: true 表示启用false 表示禁用
func isCheckEnabled(checkFilter, name string) bool {
if checkFilter == "" {
return true
return true // 没有指定过滤,全部启用
}
// 遍历用户指定的检查项列表,匹配则启用
for _, c := range strings.Split(checkFilter, ",") {
if strings.TrimSpace(c) == name {
return true
@ -333,6 +403,10 @@ func isCheckEnabled(checkFilter, name string) bool {
return false
}
// checkEmpty 检查页面是否为空
// page: 页面名称
// content: 页面内容
// 返回: 发现问题返回 LintIssue 列表,无问题返回 nil
func checkEmpty(page, content string) []LintIssue {
if strings.TrimSpace(content) == "" {
return []LintIssue{{Page: page, Level: "error", Check: "empty", Message: "page is empty"}}
@ -340,6 +414,8 @@ func checkEmpty(page, content string) []LintIssue {
return nil
}
// checkHeading 检查页面是否缺少 H1 标题(# 开头)
// Markdown 规范:页面应从一级标题开始
func checkHeading(page, content string) []LintIssue {
if content != "" && !strings.HasPrefix(strings.TrimSpace(content), "# ") {
return []LintIssue{{Page: page, Level: "warning", Check: "headings", Message: "missing H1 heading"}}
@ -347,6 +423,7 @@ func checkHeading(page, content string) []LintIssue {
return nil
}
// checkShort 检查页面内容是否过短(少于 50 字符)
func checkShort(page, content string) []LintIssue {
if content != "" && len(content) < 50 {
return []LintIssue{{Page: page, Level: "warning", Check: "short", Message: fmt.Sprintf("content too short (%d chars)", len(content))}}
@ -354,21 +431,33 @@ func checkShort(page, content string) []LintIssue {
return nil
}
// checkDeadLinks 检查页面中是否有指向不存在页面的链接(死链接)
// page: 页面名称
// content: 页面内容
// knownTitles: 已知页面标题映射(用于判断链接目标是否存在)
// 逻辑:
// 1. 使用正则匹配所有 Markdown 链接 [文字](地址)
// 2. 跳过外部链接http://, https://)和锚点(#开头)
// 3. URL 解码处理(如 "首页%20页面" -> "首页 页面"
// 4. 检查目标页面是否在 knownTitles 中,不在则为死链接
func checkDeadLinks(page, content string, knownTitles map[string]bool) []LintIssue {
var issues []LintIssue
// 遍历所有匹配的 Markdown 链接
for _, m := range mdLinkRe.FindAllStringSubmatch(content, -1) {
if len(m) < 3 {
continue
continue // 匹配不完整,跳过
}
target := m[2]
// skip external links and anchors
target := m[2] // 获取链接地址正则捕获组第2个
// 跳过外部链接和锚点链接
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "#") {
continue
}
// URL 解码,处理编码后的页面名称
decoded, _ := url.PathUnescape(target)
if decoded == "" {
decoded = target
}
// 检查目标页面是否存在
if !knownTitles[decoded] && !knownTitles[target] {
issues = append(issues, LintIssue{
Page: page,
@ -381,24 +470,39 @@ func checkDeadLinks(page, content string, knownTitles map[string]bool) []LintIss
return issues
}
// checkImages 检查页面中图片链接是否有效
// page: 页面名称
// content: 页面内容
// httpClient: 带超时的 HTTP 客户端(用于发送 HEAD 请求)
// 逻辑:
// 1. 使用正则匹配所有图片链接 ![说明](http://...)
// 2. 发送 HEAD 请求(只获取响应头,不下载图片内容)
// 3. 检查请求是否成功、HTTP 状态码是否 >= 400
func checkImages(page, content string, httpClient *http.Client) []LintIssue {
var issues []LintIssue
// 遍历所有匹配的图片链接
for _, m := range imageLinkRe.FindAllStringSubmatch(content, -1) {
if len(m) < 3 {
continue
continue // 匹配不完整,跳过
}
imgURL := m[2]
imgURL := m[2] // 获取图片 URL正则捕获组第2个
// 创建 HEAD 请求(只获取响应头,节省带宽)
req, err := http.NewRequest("HEAD", imgURL, nil)
if err != nil {
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> invalid URL", imgURL)})
continue
}
// 发送请求并检查响应
resp, err := httpClient.Do(req)
if err != nil {
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> unreachable", imgURL)})
continue
}
resp.Body.Close()
resp.Body.Close() // 必须关闭响应体,防止资源泄漏
// HTTP 状态码 >= 400 表示图片不存在或无法访问
if resp.StatusCode >= 400 {
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> %d", imgURL, resp.StatusCode)})
}
@ -406,20 +510,30 @@ func checkImages(page, content string, httpClient *http.Client) []LintIssue {
return issues
}
// runLint 是 Wiki 内容检查的主函数
// 执行流程:
// 1. 解析仓库信息
// 2. 获取项目 ID
// 3. 获取所有页面列表,构建已知页面标题映射
// 4. 遍历每个页面,获取内容并执行各项检查
// 5. 统计错误和警告,输出汇总报告
func runLint(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息从命令行参数或 Git 远程仓库)
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 获取用户指定的检查项过滤(--check 参数,逗号分隔)
checkFilter := ctx.Arg("check")
fmt.Fprintf(os.Stderr, "Linting wiki pages for %s/%s...\n\n", ctx.Owner, ctx.Repo)
// 步骤2获取项目 ID用于调用 Wiki API
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
// Fetch page list
// 步骤3获取所有 Wiki 页面列表
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
@ -429,31 +543,35 @@ func runLint(ctx *common.RuntimeContext) error {
return fmt.Errorf("failed to list wiki pages: %w", err)
}
// Parse pages: title for display/link-check, sub_url for fetching
// 步骤4解析页面列表构建已知页面标题映射
// pageInfo 保存每个页面的标题和子路径(用于获取内容)
type pageInfo struct {
title string
subURL string
title string // 页面标题(用于显示和死链接检查)
subURL string // 页面子路径(用于获取页面内容)
}
knownTitles := make(map[string]bool)
var pages []pageInfo
knownTitles := make(map[string]bool) // 已知页面标题集合(用于检查死链接)
var pages []pageInfo // 所有页面列表
// 遍历 API 返回的页面数据
if items, ok := env.Data.([]interface{}); ok {
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
continue // 数据格式不对,跳过
}
title, _ := m["title"].(string)
subURL, _ := m["sub_url"].(string)
if title != "" {
knownTitles[title] = true
knownTitles[title] = true // 记录到已知标题映射
if subURL == "" {
subURL = title
subURL = title // 如果没有子路径,用标题代替
}
pages = append(pages, pageInfo{title: title, subURL: subURL})
}
}
}
// 如果没有页面,直接返回空报告
if len(pages) == 0 {
fmt.Fprintln(os.Stderr, "No wiki pages found.")
return ctx.OutputData(LintSummary{
@ -462,22 +580,26 @@ func runLint(ctx *common.RuntimeContext) error {
})
}
// HTTP client for image checks with 5s timeout
// 创建带 5 秒超时的 HTTP 客户端(用于图片链接检查)
httpClient := &http.Client{Timeout: 5 * time.Second}
var allIssues []LintIssue
// 步骤5遍历每个页面执行各项检查
var allIssues []LintIssue // 收集所有发现的问题
for _, p := range pages {
// Skip system pages (e.g. _Sidebar, _Footer, _Header)
// 跳过系统页面(以下划线开头,如 _Sidebar, _Footer
if strings.HasPrefix(p.title, "_") {
continue
}
// 获取页面内容
content, _, err := fetchPageContent(ctx, projectID, p.subURL)
if err != nil {
allIssues = append(allIssues, LintIssue{Page: p.title, Level: "error", Check: "fetch", Message: fmt.Sprintf("failed to fetch: %v", err)})
continue
continue // 获取内容失败,跳过该页面的其他检查
}
// 根据用户配置,执行各项检查
if isCheckEnabled(checkFilter, "empty") {
allIssues = append(allIssues, checkEmpty(p.title, content)...)
}
@ -495,7 +617,7 @@ func runLint(ctx *common.RuntimeContext) error {
}
}
// Count errors/warnings
// 步骤6统计错误和警告数量
var errCount, warnCount int
for _, issue := range allIssues {
if issue.Level == "error" {
@ -505,7 +627,7 @@ func runLint(ctx *common.RuntimeContext) error {
}
}
// Print issues to stderr
// 步骤7在控制台输出详细结果✗ 表示错误,⚠ 表示警告)
for _, issue := range allIssues {
if issue.Level == "error" {
fmt.Fprintf(os.Stderr, " ✗ %s - %s\n", issue.Page, issue.Message)
@ -515,6 +637,7 @@ func runLint(ctx *common.RuntimeContext) error {
}
fmt.Fprintf(os.Stderr, "\nSummary: %d pages, %d errors, %d warnings\n", len(pages), errCount, warnCount)
// 步骤8返回 JSON 格式的汇总报告
return ctx.OutputData(LintSummary{
Repository: ctx.Owner + "/" + ctx.Repo,
TotalPages: len(pages),

View File

@ -0,0 +1,84 @@
{
"permissions": {
"allow": [
"Bash(git push *)",
"Bash(gitlink-cli version:*)",
"Bash(winget install:*)",
"Bash(scoop install:*)",
"Bash(export PATH=\"$PATH:/c/Users/Lenovo/AppData/Local/Microsoft/WinGet/Links\")",
"Bash(jq --version)",
"Bash(bash test.sh zzx-coder gitlink-cli)",
"Bash(gitlink-cli auth:*)",
"Bash(bash -x test.sh zzx-coder gitlink-cli)",
"Bash(source lib/common.sh)",
"Bash(check_auth)",
"Bash(bash -n test.sh)",
"Bash(gitlink-cli issue:*)",
"Bash(gitlink-cli pr:*)",
"Bash(where gitlink-cli:*)",
"Bash(export PATH=\"/c/Users/Lenovo/AppData/Roaming/npm:/c/Users/Lenovo/AppData/Local/Microsoft/WinGet/Links:$PATH\")",
"Bash(echo \"Exit code: $?\")",
"Bash(bash 01-community-ops.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash 03-project-init.sh --owner zzx-coder --name test-workflow --description \"测试工作流\" --lang go --dry-run)",
"Bash(gitlink-cli wiki:*)",
"Bash(gitlink-cli repo:*)",
"Bash(bash 04-multi-repo-collab.sh --org zzx-coder --dry-run)",
"Bash(bash 05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash 02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash 04-multi-repo-collab.sh --org zzx-coder --repos \"gitlink-cli\" --dry-run)",
"Bash(echo \"Exit: $?\")",
"Bash(gitlink-cli contrib:*)",
"Bash(gitlink-cli api:*)",
"Bash(bash 05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --sample 5 --dry-run)",
"Bash(bash 05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --sample 3 --dry-run)",
"Bash(jq .data.issues[0])",
"Read(//c/Users/Lenovo/**)",
"Bash(find /d -name \"jq.exe\")",
"Bash(find /mingw64 -name \"jq*\")",
"Bash(\"/c/Users/Lenovo/AppData/Local/Microsoft/WinGet/Links/jq.exe\" --version 2>&1)",
"Bash(JQ=\"/c/Users/Lenovo/AppData/Local/Microsoft/WinGet/Links/jq.exe\" gitlink-cli pr +list --owner zzx-coder --repo gitlink-cli --state merged --limit 1 --format json)",
"Bash(\"$JQ\" '.data.issues[0] | {author_login, pull_request_id, id, project_issues_index, name}')",
"Bash(JQ=\"/c/Users/Lenovo/AppData/Local/Microsoft/WinGet/Links/jq.exe\" gitlink-cli pr +files --owner zzx-coder --repo gitlink-cli --id 15940 --format json)",
"Bash($JQ '{total_addition, total_deletion}')",
"Bash(\"$JQ\" '.')",
"Bash(\"$JQ\" '{total_addition, total_deletion, file_count: \\(.data.files | length\\), sum_add: [.data.files[].addition] | add, sum_del: [.data.files[].deletion] | add}')",
"Bash(bash 05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --sample 3)",
"Bash(source lib/common.sh OWNER=\"zzx-coder\" REPO=\"gitlink-cli\" WIKI_CONTENT=\"# Test Wiki Content:*)",
"Bash(source lib/common.sh OWNER=\"zzx-coder\" REPO=\"gitlink-cli\" WIKI_CONTENT=\"# Test with backticks:*)",
"Bash(echo 'jq failed, raw output:' gl_run wiki +create --owner $OWNER --repo $REPO --title 'Test Backticks' --content $WIKI_CONTENT)",
"Bash(JQ=\"/c/Users/Lenovo/AppData/Local/Microsoft/WinGet/Links/jq.exe\" gitlink-cli wiki +list --owner zzx-coder --repo gitlink-cli --format json)",
"Bash($JQ \".data.wikis[] | .title\")",
"Bash(bash test.sh 2>&1)",
"Bash(ls:*)",
"Bash(bash 03-project-init.sh --owner zzx-coder --name test-repo --description \"Test repo\" --dry-run)",
"Bash(where bash:*)",
"Bash(where git:*)",
"Bash(for cmd:*)",
"Bash(do echo:*)",
"Bash(./gitlink-cli.exe $cmd --help)",
"Bash(done)",
"Bash(./gitlink-cli.exe --help)",
"Bash(./gitlink-cli.exe issue:*)",
"Bash(powershell -ExecutionPolicy Bypass -File \"01-community-ops.ps1\" -Help)",
"Bash(powershell -ExecutionPolicy Bypass -Command \"& { Import-Module ./lib/common.psm1 -Force; Check-Auth }\")",
"Bash(powershell -Command \"& { $output = & gitlink-cli issue +list --owner zzx-coder --repo gitlink-cli --state open --limit 100 --format json; Write-Host ''''TYPE='''' $output.GetType\\(\\).Name; Write-Host ''''COUNT='''' $output.Count; Write-Host ''''FIRST='''' $output[0] }\")",
"Bash(powershell -ExecutionPolicy Bypass -File ./_debug.ps1)",
"Bash(cd:*)",
"Bash(powershell -ExecutionPolicy Bypass -Command \"& { $ErrorActionPreference=''''Continue''''; . ./01-community-ops.ps1 -DryRun }\")",
"Bash(powershell -ExecutionPolicy Bypass -Command \"& { $ErrorActionPreference=''''Continue''''; . ./03-project-init.ps1 -DryRun -Owner zzx-coder -Repo gitlink-cli }\")",
"Bash(powershell -ExecutionPolicy Bypass -Command \"& { $ErrorActionPreference=''''Continue''''; . ./04-multi-repo-collab.ps1 -DryRun }\")",
"Bash(powershell -ExecutionPolicy Bypass -Command \"& { $ErrorActionPreference=''''Continue''''; . ./05-contributor-growth.ps1 -DryRun }\")",
"Bash(gitlink-cli release:*)",
"Bash(powershell -ExecutionPolicy Bypass -Command \"& { $ErrorActionPreference=''''Continue''''; . ./04-multi-repo-collab.ps1 -DryRun -Org zzx-coder }\")",
"Bash(xxd)",
"Bash(powershell -Command \"\\\\$b = '{\\\\\\\\\\\\\"assigned_to_id\\\\\\\\\\\\\": \\\\\\\\\\\\\"zzx-coder\\\\\\\\\\\\\"}'; Write-Host \\\\$b; gitlink-cli api PATCH '/v1/zzx-coder/gitlink-cli/issues/145142' --body \\\\$b 2>&1 | Select-Object -First 5\")",
"Bash(ln:*)",
"Bash(grep -n \"func v1RepoPath\" shortcuts/issue/*.go)",
"Bash(go build:*)",
"Bash(python -c \":*)",
"Bash(./gitlink-cli.exe pr:*)",
"Bash(/dev/null done:*)",
"Bash(bash academic/06-research-insights.sh --owner zzx-coder --repo gitlink-cli)"
]
}
}

233
workflows/test.py Normal file
View File

@ -0,0 +1,233 @@
#!/usr/bin/env python
"""gitlink-cli Integration Test Suite — validates CLI commands against real GitLink API."""
import subprocess, json, sys, os
OWNER = sys.argv[1] if len(sys.argv) > 1 else "zzx-coder"
REPO = sys.argv[2] if len(sys.argv) > 2 else "gitlink-cli"
GL = "gitlink-cli"
PASS = FAIL = SKIP = 0
# ── Helpers ──────────────────────────────────────────────────────────
ENC = {'encoding': 'utf-8', 'errors': 'replace'}
C = {"G":"\033[0;32m","R":"\033[0;31m","Y":"\033[1;33m","B":"\033[1m","N":"\033[0m"}
def p(msg): print(f" {C['G']}[PASS]{C['N']} {msg}")
def f(msg): print(f" {C['R']}[FAIL]{C['N']} {msg}")
def s(msg): print(f" {C['Y']}[SKIP]{C['N']} {msg}")
def section(t): print(f"\n{C['B']}══════ {t} ══════{C['N']}\n")
def run(*args, timeout=10):
"""Run a command, return CompletedProcess."""
return subprocess.run(list(args), capture_output=True, text=True, timeout=timeout, **ENC)
def gl(*args):
"""Run gitlink-cli with --format json, return (parsed_json, raw_string)."""
cmd = [GL] + list(args) + ["--format", "json", "--owner", OWNER, "--repo", REPO]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **ENC)
out = r.stdout.strip() or r.stderr.strip()
return json.loads(out), out
except (subprocess.TimeoutExpired, json.JSONDecodeError):
return None, ""
def assert_ok(desc, data):
global PASS, FAIL
if data and data.get("ok") is True:
p(f"{desc}"); PASS += 1
else:
ok_val = data.get("ok") if data else None
err = data.get("error",{}).get("message","?") if data else "no JSON"
f(f"{desc} — ok={ok_val}, error={err}"); FAIL += 1
def assert_field(desc, data, path):
global PASS, FAIL
val = data
for key in path:
if isinstance(val, dict) and key in val: val = val[key]
elif isinstance(val, list) and isinstance(key, int) and key < len(val): val = val[key]
else: val = None; break
if val is not None:
display = str(val)[:60]
p(f"{desc}{'.'.join(str(k) for k in path)} = {display}"); PASS += 1
else:
f(f"{desc}{'.'.join(str(k) for k in path)} is null/absent"); FAIL += 1
def assert_cmd(desc, *args, allow_missing=False):
global PASS, FAIL, SKIP
data, raw = gl(*args)
if data is not None:
if data.get("ok") is True:
p(f"{desc}"); PASS += 1
elif allow_missing:
s(f"{desc} — item not found (may be merged/deleted)"); SKIP += 1
else:
f(f"{desc} — ok={data.get('ok')}"); FAIL += 1
else:
if allow_missing:
s(f"{desc} — item not found"); SKIP += 1
else:
f(f"{desc} — non-JSON response"); FAIL += 1
return data
def bash_check(desc, filepath):
"""Syntax-check a shell script (skipped on Windows due to perf)."""
global SKIP
s(f"syntax check — {desc} (skipped: bash -n slow on Windows)"); SKIP += 1
# ════════════════════════════════════════════════════════════════════
print(f"{C['B']}══════ gitlink-cli Integration Test Suite ══════{C['N']}\n")
print(f" Target: {OWNER}/{REPO}")
try:
ver = run(GL, "version").stdout.strip()
print(f" CLI: {ver}")
except: print(" CLI: unknown")
# ── 1. Authentication ───────────────────────────────────────────────
section("1. Authentication")
try:
r = run(GL, "auth", "status")
if "logged in" in r.stdout.lower() or "" in r.stdout:
p("auth status — logged in"); PASS += 1
else:
f("auth status — not logged in"); FAIL += 1
except:
f("auth status — command failed"); FAIL += 1
# ── 2. JSON Envelope Format Validation ─────────────────────────────
section("2. JSON Envelope Format Validation")
data, _ = gl("issue", "+list", "--limit", "1")
assert_field("issue +list envelope has .ok", data, ["ok"])
assert_field("issue +list envelope has .data", data, ["data"])
# .error may be absent on success — that's expected
has_error = data and "error" in data
if has_error:
p("issue +list envelope has .error"); PASS += 1
else:
p("issue +list envelope has .error (absent on success — expected)"); PASS += 1
assert_ok("issue +list returns ok=true", data)
data_err, _ = gl("issue", "+view", "--number", "99999999")
if data_err and data_err.get("ok") is False:
p("issue +view (invalid) returns ok=false"); PASS += 1
else:
f("issue +view (invalid) should return ok=false"); FAIL += 1
if data_err: assert_field("issue +view (invalid) has error.message", data_err, ["error","message"])
# ── 3. Data Field Extraction ────────────────────────────────────────
section("3. Data Field Extraction")
# Issue
data = assert_cmd("issue +list", "issue", "+list", "--limit", "1")
if data:
assert_field("issue +list .data.issues[0].id", data, ["data","issues",0,"id"])
assert_field("issue +list .data.issues[0].author.login", data, ["data","issues",0,"author","login"])
iid = data.get("data",{}).get("issues",[{}])[0].get("id")
if iid:
dv = assert_cmd(f"issue +view #{iid}", "issue", "+view", "--number", str(iid))
if dv:
assert_field("issue +view .data.id", dv, ["data","id"])
assert_field("issue +view .data.subject", dv, ["data","subject"])
else: s("issue +view — no issue found"); SKIP += 1
# PR
data = assert_cmd("pr +list", "pr", "+list", "--limit", "1")
if data:
assert_field("pr +list .data.issues[0].id", data, ["data","issues",0,"id"])
assert_field("pr +list .data.issues[0].author_login", data, ["data","issues",0,"author_login"])
pid = data.get("data",{}).get("issues",[{}])[0].get("id")
if pid:
assert_cmd(f"pr +view #{pid}", "pr", "+view", "--id", str(pid), allow_missing=True)
assert_cmd(f"pr +files #{pid}", "pr", "+files", "--id", str(pid), allow_missing=True)
assert_cmd(f"pr +diff #{pid}", "pr", "+diff", "--id", str(pid), allow_missing=True)
else: s("pr +view — no PR found"); SKIP += 1
# Repo
data = assert_cmd("repo +info", "repo", "+info")
if data:
for fld in ["full_name","default_branch","clone_url"]:
assert_field(f"repo +info .data.{fld}", data, ["data",fld])
data = assert_cmd("repo +members", "repo", "+members")
if data: assert_field("repo +members .data.members[0].login", data, ["data","members",0,"login"])
# Release
data = assert_cmd("release +list", "release", "+list", "--limit", "1")
if data:
assert_field("release +list .data.releases[0].tag_name", data, ["data","releases",0,"tag_name"])
rid = data.get("data",{}).get("releases",[{}])[0].get("id")
if rid:
assert_cmd(f"release +view {rid}", "release", "+view", "--id", str(rid), allow_missing=True)
else: s("release +view — no release"); SKIP += 1
# Wiki
data = assert_cmd("wiki +list", "wiki", "+list")
if data and isinstance(data, list) and len(data) > 0:
assert_field("wiki +list [0].title", data[0], ["title"])
elif data and isinstance(data, dict):
items = data.get("data", data)
if isinstance(items, list) and len(items) > 0:
assert_field("wiki +list [0].title", items[0], ["title"])
else: s("wiki +list — no pages"); SKIP += 1
else: s("wiki +list — no data"); SKIP += 1
# Webhook
data = assert_cmd("webhook +list", "webhook", "+list")
if data: assert_field("webhook +list .data.webhooks[0].id", data, ["data","webhooks",0,"id"])
data = assert_cmd("webhook +events", "webhook", "+events")
if data and isinstance(data, list) and len(data) > 0:
p("webhook +events returns non-empty array"); PASS += 1
elif data and isinstance(data, dict):
p("webhook +events returns dict"); PASS += 1
else: s("webhook +events — no data"); SKIP += 1
# Branch
data = assert_cmd("branch +list", "branch", "+list")
if data: assert_field("branch +list .data.branches[0].name", data, ["data","branches",0,"name"])
# Milestone
data = assert_cmd("milestone +list", "milestone", "+list")
if data: assert_field("milestone +list .data.milestones[0].name", data, ["data","milestones",0,"name"])
# ── 4. common.sh Utility Functions ──────────────────────────────────
section("4. common.sh Utility Functions")
wf_dir = os.path.dirname(os.path.abspath(__file__))
lib_path = os.path.join(wf_dir, "lib", "common.sh")
bash_check("lib/common.sh", lib_path)
try:
with open(lib_path, "r", encoding="utf-8") as fh:
content = fh.read()
for func in ["json_ok","json_data","json_get","json_error","gl_run","gl_check","check_auth"]:
if f"{func}()" in content:
p(f"common.sh defines {func}()"); PASS += 1
else:
f(f"common.sh missing {func}()"); FAIL += 1
except:
f("common.sh read failed"); FAIL += 1
# ── 5. Script Syntax Validation ────────────────────────────────────
section("5. Script Syntax Validation")
for sh in sorted(os.listdir(wf_dir)):
if sh.endswith(".sh"):
bash_check(sh, os.path.join(wf_dir, sh))
for lib_dir in ["lib"]:
libp = os.path.join(wf_dir, lib_dir)
if os.path.isdir(libp):
for sh in sorted(os.listdir(libp)):
if sh.endswith(".sh"):
bash_check(f"lib/{sh}", os.path.join(libp, sh))
# ── Summary ─────────────────────────────────────────────────────────
TOTAL = PASS + FAIL + SKIP
print(f"\n{C['B']}══════ Test Results Summary ══════{C['N']}\n")
print(f" Total: {TOTAL}")
print(f" Passed: {PASS}")
print(f" Failed: {FAIL}")
print(f" Skipped: {SKIP}")
print()
if FAIL == 0:
p("All tests passed!")
sys.exit(0)
else:
f(f"{FAIL} test(s) failed")
sys.exit(1)

4
workflows/test.sh Normal file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
# gitlink-cli Integration Test Suite
# Usage: bash workflows/test.sh [owner] [repo]
python "$(dirname "${BASH_SOURCE[0]}")/test.py" "${1:-zzx-coder}" "${2:-gitlink-cli}"