forked from Gitlink/gitlink-cli
Compare commits
36 Commits
weekly-202
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
245d3bc1fc | |
|
|
d9ff6d134d | |
|
|
7f82dbdc24 | |
|
|
460618d4b5 | |
|
|
7079bfae64 | |
|
|
693906cb6c | |
|
|
a02e3a53fd | |
|
|
59535c72c1 | |
|
|
8a2c4d3bd9 | |
|
|
e893efa817 | |
|
|
3988802e2c | |
|
|
5b3af9d978 | |
|
|
4b2d45baea | |
|
|
9f0973f80a | |
|
|
19b0e31bf8 | |
|
|
b476963fde | |
|
|
007bbe4da3 | |
|
|
e26160a335 | |
|
|
80c7554fd8 | |
|
|
17a876b0bd | |
|
|
dde0a6d0b4 | |
|
|
d19b4c2722 | |
|
|
86eff95244 | |
|
|
2a75e68df9 | |
|
|
f44333f9e1 | |
|
|
152cb108d6 | |
|
|
88dc671bc3 | |
|
|
a5e9fe8199 | |
|
|
609a62c146 | |
|
|
de9bca30bb | |
|
|
08037ca60b | |
|
|
fe4341fede | |
|
|
2420579ba9 | |
|
|
8ecbf8a203 | |
|
|
0b6d176a45 | |
|
|
411403ac5a |
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -15,4 +15,4 @@ clean:
|
|||
rm -f $(BINARY)
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
go test ./... -race
|
||||
|
|
|
|||
147
README.md
147
README.md
|
|
@ -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
|
||||
|
|
|
|||
147
README.zh-CN.md
147
README.zh-CN.md
|
|
@ -27,9 +27,11 @@
|
|||
| 分类 | 能力 |
|
||||
|------|------|
|
||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
||||
| 🐛 Issue | 创建、更新、关闭、评论 Issue,6 个批量操作(关闭/状态/优先级/负责人/标记/创建) |
|
||||
| 🐛 Issue | 创建、更新、关闭、评论 Issue,6 个批量操作,元数据查询,评论管理 |
|
||||
| 📖 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 使用指南
|
||||
|
|
|
|||
|
|
@ -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 格式
|
||||
|
|
|
|||
33
cmd/root.go
33
cmd/root.go
|
|
@ -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 错误),
|
||||
|
|
|
|||
521
demo/index.html
521
demo/index.html
|
|
@ -119,6 +119,18 @@
|
|||
.sidebar-section .skills-header {
|
||||
background: #fdf8ff;
|
||||
}
|
||||
.sidebar-section .mission3-header {
|
||||
background: #eef5fc;
|
||||
}
|
||||
.sidebar-section .mission3-header:hover {
|
||||
background: #dfeaf6;
|
||||
}
|
||||
.sidebar-section .mission4-header {
|
||||
background: #eef7f0;
|
||||
}
|
||||
.sidebar-section .mission4-header:hover {
|
||||
background: #dceee2;
|
||||
}
|
||||
/* ── Section collapse (top-level toggle) ── */
|
||||
.sidebar-section .sidebar-header {
|
||||
cursor: pointer;
|
||||
|
|
@ -162,6 +174,24 @@
|
|||
.mode-toggle button:hover:not(.active) {
|
||||
background:var(--bg-hover); border-color:var(--border-accent);
|
||||
}
|
||||
/* ── Agent selector buttons ── */
|
||||
.agent-selector {
|
||||
display:flex; gap:0; margin-top:6px;
|
||||
}
|
||||
.agent-selector button {
|
||||
flex:1; padding:5px 8px; border:1px solid var(--border);
|
||||
background:#fff; color:var(--text-dim); cursor:pointer;
|
||||
font-size:11px; font-weight:500; transition: all .12s;
|
||||
}
|
||||
.agent-selector button:first-child { border-radius:var(--radius-sm) 0 0 var(--radius-sm); }
|
||||
.agent-selector button:last-child { border-radius:0 var(--radius-sm) var(--radius-sm) 0; }
|
||||
.agent-selector button:hover:not(.active) {
|
||||
background:var(--bg-hover); border-color:var(--border-accent);
|
||||
}
|
||||
.agent-selector button.active { color:#fff; z-index:1; }
|
||||
.agent-selector button.agent-claude.active { background:var(--purple); border-color:var(--purple); }
|
||||
.agent-selector button.agent-codex.active { background:#0ea5e9; border-color:#0ea5e9; }
|
||||
.agent-selector button.agent-zcode.active { background:#f59e0b; border-color:#f59e0b; }
|
||||
#sidebar .modules {
|
||||
padding:4px 0;
|
||||
}
|
||||
|
|
@ -377,8 +407,8 @@
|
|||
<div class="sidebar-header" onclick="toggleSection('cli-section')">
|
||||
<span class="section-arrow">▶</span> 📋 CLI 命令 · 预设指令
|
||||
<div class="mode-toggle" onclick="event.stopPropagation()">
|
||||
<button id="cli-mode-direct" class="active" onclick="setCliExecMode('direct')">⚡ 直接运行</button>
|
||||
<button id="cli-mode-input" onclick="setCliExecMode('input')">✎ 编辑模式</button>
|
||||
<button id="cli-mode-direct" class="active" onclick="setSectionExecMode('cli','direct')">⚡ 直接运行</button>
|
||||
<button id="cli-mode-input" onclick="setSectionExecMode('cli','input')">✎ 编辑模式</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modules" id="cli-modules-container">
|
||||
|
|
@ -386,14 +416,18 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Section 2: Claude Code Skills ── -->
|
||||
<!-- ── Section 2: Agent Skills ── -->
|
||||
<div class="sidebar-section" id="skills-section">
|
||||
<div class="sidebar-header skills-header" onclick="toggleSection('skills-section')">
|
||||
<span class="section-arrow">▶</span> 🤖 Claude Code Skills
|
||||
<span id="session-badge" style="display:none;font-size:10px;background:var(--purple);color:#fff;padding:1px 6px;border-radius:8px;margin-left:6px;" title="会话进行中">● 会话中</span>
|
||||
<span class="section-arrow">▶</span> 🤖 AGENT SKILLS
|
||||
<div class="agent-selector" onclick="event.stopPropagation()">
|
||||
<button class="agent-btn agent-claude active" data-agent="claude" onclick="setAgent('claude')">🤖 Claude Code</button>
|
||||
<button class="agent-btn agent-codex" data-agent="codex" onclick="setAgent('codex')">📟 Codex</button>
|
||||
<button class="agent-btn agent-zcode" data-agent="zcode" onclick="setAgent('zcode')">⚡ ZCode</button>
|
||||
</div>
|
||||
<div class="mode-toggle" onclick="event.stopPropagation()">
|
||||
<button id="skills-mode-direct" class="active" onclick="setSkillsExecMode('direct')">⚡ 直接运行</button>
|
||||
<button id="skills-mode-input" onclick="setSkillsExecMode('input')">✎ 编辑模式</button>
|
||||
<button id="skills-mode-direct" class="active" onclick="setSectionExecMode('skills','direct')">⚡ 直接运行</button>
|
||||
<button id="skills-mode-input" onclick="setSectionExecMode('skills','input')">✎ 编辑模式</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modules" id="skills-modules-container">
|
||||
|
|
@ -401,6 +435,44 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Section 3: 自动化控制流(子任务三) ── -->
|
||||
<div class="sidebar-section" id="mission3-section">
|
||||
<div class="sidebar-header mission3-header" onclick="toggleSection('mission3-section')">
|
||||
<span class="section-arrow">▶</span> 🔧 自动化控制流
|
||||
<div class="agent-selector" onclick="event.stopPropagation()">
|
||||
<button class="agent-btn agent-claude active" data-agent="claude" onclick="setAgent('claude')">🤖 Claude Code</button>
|
||||
<button class="agent-btn agent-codex" data-agent="codex" onclick="setAgent('codex')">📟 Codex</button>
|
||||
<button class="agent-btn agent-zcode" data-agent="zcode" onclick="setAgent('zcode')">⚡ ZCode</button>
|
||||
</div>
|
||||
<div class="mode-toggle" onclick="event.stopPropagation()">
|
||||
<button id="mission3-mode-direct" class="active" onclick="setSectionExecMode('mission3','direct')">⚡ 直接运行</button>
|
||||
<button id="mission3-mode-input" onclick="setSectionExecMode('mission3','input')">✎ 编辑模式</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modules" id="mission3-modules-container">
|
||||
<!-- filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Section 4: GitLink辅助科研场景(子任务四) ── -->
|
||||
<div class="sidebar-section" id="mission4-section">
|
||||
<div class="sidebar-header mission4-header" onclick="toggleSection('mission4-section')">
|
||||
<span class="section-arrow">▶</span> 🔬 GitLink 辅助科研场景
|
||||
<div class="agent-selector" onclick="event.stopPropagation()">
|
||||
<button class="agent-btn agent-claude active" data-agent="claude" onclick="setAgent('claude')">🤖 Claude Code</button>
|
||||
<button class="agent-btn agent-codex" data-agent="codex" onclick="setAgent('codex')">📟 Codex</button>
|
||||
<button class="agent-btn agent-zcode" data-agent="zcode" onclick="setAgent('zcode')">⚡ ZCode</button>
|
||||
</div>
|
||||
<div class="mode-toggle" onclick="event.stopPropagation()">
|
||||
<button id="mission4-mode-direct" class="active" onclick="setSectionExecMode('mission4','direct')">⚡ 直接运行</button>
|
||||
<button id="mission4-mode-input" onclick="setSectionExecMode('mission4','input')">✎ 编辑模式</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modules" id="mission4-modules-container">
|
||||
<!-- filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- RESIZER -->
|
||||
|
|
@ -593,7 +665,7 @@ var SKILLS = [
|
|||
{
|
||||
id: 'health', icon: '📊', name: '项目健康度报告 (gitlink-health)',
|
||||
prompts: [
|
||||
{ label: '基础健康度报告', prompt: '请使用 gitlink-health Skill,为 zzx-coder/test-repo 仓库生成项目健康度报告。' },
|
||||
{ label: '基础健康度报告', prompt: '请使用 gitlink-health Skill,为 zzx-coder/test-repo 仓库生成项目健康度报告。\n效率要求(重要):整份报告须在 2 分钟内完成。计数/合并率/积压数一律从 issue +list、pr +list 的汇总字段一次性获取;PR 合并时长只采样「最近 ≤8 条已合并 PR」调用 pr +view 取 merged_at,禁止遍历或分页拉取全部历史 PR/Issue。' },
|
||||
{ label: '安全规则验证:保存路径', prompt: '请把报告保存到桌面上。\n测试仓库:zzx-coder/test-repo' },
|
||||
]
|
||||
},
|
||||
|
|
@ -640,6 +712,309 @@ var SKILLS = [
|
|||
];
|
||||
|
||||
|
||||
var MISSION3 = [
|
||||
{
|
||||
id: "m3-env",
|
||||
icon: "🔧",
|
||||
name: "测试环境准备",
|
||||
commands: [
|
||||
{
|
||||
label: "设置 UTF-8 控制台编码",
|
||||
engine: "powershell",
|
||||
cmd: "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\nWrite-Host \"当前控制台编码: $([Console]::OutputEncoding.WebName)\"\nWrite-Host \"(应为 utf-8,否则含中文 JSON 解析会失败)\""
|
||||
},
|
||||
{
|
||||
label: "确认 gitlink-cli 版本",
|
||||
engine: "powershell",
|
||||
cmd: ".\\gitlink-cli.exe version"
|
||||
},
|
||||
{
|
||||
label: "确认认证状态",
|
||||
engine: "powershell",
|
||||
cmd: ".\\gitlink-cli.exe auth status"
|
||||
},
|
||||
{
|
||||
label: "确认 JSON 输出正常",
|
||||
engine: "powershell",
|
||||
cmd: ".\\gitlink-cli.exe issue +list --owner zzx-coder --repo test-repo --state open --limit 3 --format json | ConvertFrom-Json"
|
||||
},
|
||||
{
|
||||
label: "加载公共库 common.psm1",
|
||||
engine: "powershell",
|
||||
cmd: "Import-Module \".\\workflows\\lib\\common.psm1\" -Force -WarningAction SilentlyContinue\nCheck-Auth"
|
||||
},
|
||||
{
|
||||
label: "确认测试仓库可访问",
|
||||
engine: "powershell",
|
||||
cmd: ".\\gitlink-cli.exe repo +info --owner zzx-coder --repo test-repo --format json | ConvertFrom-Json"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "m3-community",
|
||||
icon: "🤝",
|
||||
name: "社区运营自动化 (01-community-ops)",
|
||||
commands: [
|
||||
{
|
||||
label: "基础只读模式验证 (DryRun)",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\01-community-ops.ps1 -Owner zzx-coder -Repo test-repo -DryRun"
|
||||
},
|
||||
{
|
||||
label: "查看命令行参数 (Help)",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\01-community-ops.ps1 -Help"
|
||||
},
|
||||
{
|
||||
label: "Issue 数据拉取验证",
|
||||
engine: "powershell",
|
||||
cmd: "# 拉取 open / closed Issue 与已合并 PR(来自测试 2.1.3)\n$openResult = .\\gitlink-cli.exe issue +list --owner zzx-coder --repo test-repo --state open --limit 100 --format json | ConvertFrom-Json\n$closedResult = .\\gitlink-cli.exe issue +list --owner zzx-coder --repo test-repo --state closed --limit 100 --format json | ConvertFrom-Json\n$prResult = .\\gitlink-cli.exe pr +list --owner zzx-coder --repo test-repo --state merged --limit 100 --format json | ConvertFrom-Json\n$prData = if ($prResult.data.issues) { @($prResult.data.issues) } else { @($prResult.data.pulls) }\nWrite-Host \"Open issues: $(@($openResult.data.issues).Count)\"\nWrite-Host \"Closed issues: $(@($closedResult.data.issues).Count)\"\nWrite-Host \"Merged PRs: $($prData.Count)\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "m3-gate",
|
||||
icon: "🛡",
|
||||
name: "代码质量看门人 (02-code-quality-gatekeeper)",
|
||||
commands: [
|
||||
{
|
||||
label: "审查所有 open PR (DryRun)",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\02-code-quality-gatekeeper.ps1 -Owner zzx-coder -Repo test-repo -DryRun"
|
||||
},
|
||||
{
|
||||
label: "审查指定 PR #13 (DryRun)",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\02-code-quality-gatekeeper.ps1 -Owner zzx-coder -Repo test-repo -PrId 13 -DryRun"
|
||||
},
|
||||
{
|
||||
label: "自定义质量阈值 70 (DryRun)",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\02-code-quality-gatekeeper.ps1 -Owner zzx-coder -Repo test-repo -Threshold 70 -DryRun"
|
||||
},
|
||||
{
|
||||
label: "查看帮助信息 (Help)",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\02-code-quality-gatekeeper.ps1 -Help"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "m3-init",
|
||||
icon: "🚀",
|
||||
name: "项目一键初始化 (03-project-init)",
|
||||
commands: [
|
||||
{
|
||||
label: "预览模式 (DryRun,推荐首选)",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\03-project-init.ps1 -Owner zzx-coder -Name test-init-dryrun -Description \"自动化测试仓库\" -Lang go -DryRun"
|
||||
},
|
||||
{
|
||||
label: "底层命令逐步骤验证 (只读安全)",
|
||||
engine: "powershell",
|
||||
cmd: "# 底层命令逐步骤验证(只读安全,来自测试 2.3.2)\nWrite-Host \"=== Step 1: List repos ===\" -ForegroundColor White\n$repoList = .\\gitlink-cli.exe repo +list --user zzx-coder --limit 5 --format json | ConvertFrom-Json\n$repoCount = if ($repoList.data.projects) { @($repoList.data.projects).Count } else { @($repoList.data).Count }\nWrite-Host \"Repos: $repoCount\" -ForegroundColor Green\n\nWrite-Host \"`n=== Step 2: Check Wiki access ===\" -ForegroundColor White\n$wikiResult = .\\gitlink-cli.exe wiki +list --owner zzx-coder --repo test-repo --format json | ConvertFrom-Json\nWrite-Host \"Wiki accessible: $($wikiResult.ok)\" -ForegroundColor Green\n\nWrite-Host \"`n=== Step 3: Check branch help ===\" -ForegroundColor White\n.\\gitlink-cli.exe branch --help\n\nWrite-Host \"`n=== Step 4: Check releases ===\" -ForegroundColor White\n$relResult = .\\gitlink-cli.exe release +list --owner zzx-coder --repo test-repo --limit 5 --format json | ConvertFrom-Json\nWrite-Host \"Release API accessible: $($relResult.ok)\" -ForegroundColor Green\n\nWrite-Host \"`n=== Step 5: Check milestone ===\" -ForegroundColor White\n.\\gitlink-cli.exe milestone --help"
|
||||
},
|
||||
{
|
||||
label: "实际创建仓库 (可选·需谨慎)",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\03-project-init.ps1 -Owner zzx-coder -Name \"test-init-real\" -Description \"测试仓库-可安全删除\" -Lang go"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "m3-multirepo",
|
||||
icon: "🔗",
|
||||
name: "多仓库协同 (04-multi-repo-collab)",
|
||||
commands: [
|
||||
{
|
||||
label: "生成组织仪表盘",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\04-multi-repo-collab.ps1 -Org zzx-coder -Output dashboard.html"
|
||||
},
|
||||
{
|
||||
label: "指定仓库列表过滤",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\04-multi-repo-collab.ps1 -Org zzx-coder -Repos \"test-repo\" -Output dashboard-filtered.html"
|
||||
},
|
||||
{
|
||||
label: "查看帮助和参数",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\04-multi-repo-collab.ps1 -Help"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "m3-contrib",
|
||||
icon: "🏆",
|
||||
name: "贡献者成长体系 (05-contributor-growth)",
|
||||
commands: [
|
||||
{
|
||||
label: "Dry-Run 预览模式",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\05-contributor-growth.ps1 -Owner zzx-coder -Repo test-repo -Sample 5 -DryRun"
|
||||
},
|
||||
{
|
||||
label: "底层数据采集验证",
|
||||
engine: "powershell",
|
||||
cmd: "# 底层数据采集验证(来自测试 2.5.2)\n$openIssues = .\\gitlink-cli.exe issue +list --owner zzx-coder --repo test-repo --state open --limit 100 --format json | ConvertFrom-Json\n$closedIssues = .\\gitlink-cli.exe issue +list --owner zzx-coder --repo test-repo --state closed --limit 100 --format json | ConvertFrom-Json\n$prsMerged = .\\gitlink-cli.exe pr +list --owner zzx-coder --repo test-repo --state merged --limit 100 --format json | ConvertFrom-Json\n$members = .\\gitlink-cli.exe repo +members --owner zzx-coder --repo test-repo --limit 100 --format json | ConvertFrom-Json\n$prData = if ($prsMerged.data.issues) { @($prsMerged.data.issues) } else { @($prsMerged.data.pulls) }\n$memberData = if ($members.data.members) { @($members.data.members) } else { @($members.data) }\nWrite-Host \"Issues - Open: $(@($openIssues.data.issues).Count), Closed: $(@($closedIssues.data.issues).Count)\" -ForegroundColor Green\nWrite-Host \"Merged PRs: $($prData.Count)\" -ForegroundColor Green\nWrite-Host \"Members: $($memberData.Count)\" -ForegroundColor Green\n$allIssues = @($openIssues.data.issues) + @($closedIssues.data.issues)\n$authors = @{}\nforeach ($issue in $allIssues) {\n $author = if ($issue.author.login) { $issue.author.login } elseif ($issue.author.username) { $issue.author.username } else { $null }\n if ($author) { if (-not $authors.ContainsKey($author)) { $authors[$author] = 0 }; $authors[$author]++ }\n}\nWrite-Host \"Unique contributors: $($authors.Count)\" -ForegroundColor Green\nforeach ($kv in ($authors.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 5)) {\n Write-Host \" @$($kv.Key): $($kv.Value) issues\"\n}"
|
||||
},
|
||||
{
|
||||
label: "颁发徽章 (可选·需谨慎)",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\05-contributor-growth.ps1 -Owner zzx-coder -Repo test-repo -Sample 5 -Award"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "m3-skill",
|
||||
icon: "🤖",
|
||||
name: "技能工作流 (Agent · gitlink-workflow)",
|
||||
prompts: [
|
||||
{
|
||||
label: "工作流总入口(触发 7 选项菜单)",
|
||||
engine: "claude",
|
||||
prompt: "请帮我执行自动化工作流\n测试仓库:zzx-coder/test-repo"
|
||||
},
|
||||
{
|
||||
label: "快捷触发:社区运营",
|
||||
engine: "claude",
|
||||
prompt: "帮我跑一下社区运营\n测试仓库:zzx-coder/test-repo"
|
||||
},
|
||||
{
|
||||
label: "项目健康度报告 (gitlink-health)",
|
||||
engine: "claude",
|
||||
prompt: "请使用 gitlink-health Skill,为 zzx-coder/test-repo 仓库生成项目健康度报告。"
|
||||
},
|
||||
{
|
||||
label: "健康度路径规则验证",
|
||||
engine: "claude",
|
||||
prompt: "请把健康度报告保存到桌面上。\n测试仓库:zzx-coder/test-repo"
|
||||
},
|
||||
{
|
||||
label: "新人引导 AI 分析模式 (onboard)",
|
||||
engine: "claude",
|
||||
prompt: "请使用 gitlink-onboard Skill 的 AI 分析模式,扫描 zzx-coder/test-repo 的所有 open Issue,\n识别适合新手贡献的 Good First Issue。\n展示分析结果表格后,让我选择要对哪些 Issue 添加引导评论。"
|
||||
},
|
||||
{
|
||||
label: "新人引导直接模式 (#11 dry-run)",
|
||||
engine: "claude",
|
||||
prompt: "请为 Issue #11 添加新人引导评论,先用 dry-run 预览。\n测试仓库:zzx-coder/test-repo"
|
||||
},
|
||||
{
|
||||
label: "FAQ 模式 A:生成知识库",
|
||||
engine: "claude",
|
||||
prompt: "请使用 gitlink-faq Skill 的模式 A,为 zzx-coder/test-repo 生成 Issue 知识库。\n同时采集 open + closed 所有 Issue,合并去重,逐批读取详情,按类型分类和聚类,\n生成知识库 Markdown 预览后等我确认再发布到 Wiki。"
|
||||
},
|
||||
{
|
||||
label: "FAQ 模式 B:重复检测",
|
||||
engine: "claude",
|
||||
prompt: "请检查 zzx-coder/test-repo 有没有和 \"登录功能\" 相关的已有 Issue,看看是否有重复的。\n对找到的候选 Issue,自动读取详情和 journals,给出分析结论。"
|
||||
},
|
||||
{
|
||||
label: "FAQ 模式 C:Wiki 增量更新",
|
||||
engine: "claude",
|
||||
prompt: "请把 Issue #11 补充到已有的 Issue 知识库 Wiki 页面中。\n测试仓库:zzx-coder/test-repo"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "m3-smoke",
|
||||
icon: "🧪",
|
||||
name: "端到端冒烟测试 (15 步)",
|
||||
commands: [
|
||||
{
|
||||
label: "运行冒烟测试(连通性验证)",
|
||||
engine: "powershell",
|
||||
cmd: "# 端到端冒烟测试(来自测试 5.1,15 步连通性验证)\n$ErrorActionPreference = \"Continue\"\n$owner = \"zzx-coder\"; $repo = \"test-repo\"\n$pass = 0; $fail = 0\nfunction Test-Step {\n param([string]$Name, [ScriptBlock]$Script)\n Write-Host \"[TEST] $Name ... \" -NoNewline -ForegroundColor Cyan\n try { $result = & $Script; if ($result) { Write-Host \"PASS\" -ForegroundColor Green; $global:pass++ } else { Write-Host \"FAIL\" -ForegroundColor Red; $global:fail++ } }\n catch { Write-Host \"FAIL ($($_.Exception.Message))\" -ForegroundColor Red; $global:fail++ }\n}\nWrite-Host \"`n====== GitLink CLI 子任务三冒烟测试 ======\" -ForegroundColor White\nWrite-Host \"Target: $owner/$repo`n\"\nTest-Step \"Auth status\" { $r = .\\gitlink-cli.exe auth status 2>&1 | Out-String; $r -match \"logged in\" -or $r -match \"token\" }\nTest-Step \"01-Community: issue +list open\" { $r = .\\gitlink-cli.exe issue +list --owner $owner --repo $repo --state open --limit 5 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"01-Community: pr +list merged\" { $r = .\\gitlink-cli.exe pr +list --owner $owner --repo $repo --state merged --limit 5 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"02-Gatekeeper: issue +view #11\" { $r = .\\gitlink-cli.exe issue +view --owner $owner --repo $repo --number 11 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"03-Init: repo +list\" { $r = .\\gitlink-cli.exe repo +list --user $owner --limit 5 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"03-Init: wiki +list\" { $r = .\\gitlink-cli.exe wiki +list --owner $owner --repo $repo --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"04-MultiRepo: org repos\" { $r = .\\gitlink-cli.exe repo +list --user $owner --limit 10 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"04-MultiRepo: release +list\" { $r = .\\gitlink-cli.exe release +list --owner $owner --repo $repo --limit 5 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"05-Contrib: repo +members\" { $r = .\\gitlink-cli.exe repo +members --owner $owner --repo $repo --limit 50 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"CI: ci +builds\" { $r = .\\gitlink-cli.exe ci +builds --owner $owner --repo $repo --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"Branch: branch help\" { $r = .\\gitlink-cli.exe branch --help 2>&1 | Out-String; $r -match \"Usage\" -or $r -match \"branch\" }\nTest-Step \"Milestone: milestone help\" { $r = .\\gitlink-cli.exe milestone --help 2>&1 | Out-String; $r -match \"Usage\" -or $r -match \"milestone\" }\nTest-Step \"Webhook: webhook help\" { $r = .\\gitlink-cli.exe webhook --help 2>&1 | Out-String; $r -match \"Usage\" -or $r -match \"webhook\" }\nTest-Step \"Research: search +repos\" { $r = .\\gitlink-cli.exe search +repos --q \"agent\" --limit 5 --format json | ConvertFrom-Json; $r.ok }\nWrite-Host \"`n====== Results ======\" -ForegroundColor White\nWrite-Host \" PASS: $pass\" -ForegroundColor Green\nWrite-Host \" FAIL: $fail\" -ForegroundColor Red\nWrite-Host \" TOTAL: $($pass + $fail)\" -ForegroundColor Cyan"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
var MISSION4 = [
|
||||
{
|
||||
id: "m4-insights",
|
||||
icon: "🔭",
|
||||
name: "项目洞察 (06-research-insights)",
|
||||
commands: [
|
||||
{
|
||||
label: "生成项目洞悉报告(热度/技术栈/活动)",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\academic\\06-research-insights.ps1 -Owner zzx-coder -Repo test-repo"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "m4-compliance",
|
||||
icon: "✅",
|
||||
name: "合规与复现性检查 (08-research-compliance)",
|
||||
commands: [
|
||||
{
|
||||
label: "生成 8 维复现性评分卡",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\academic\\08-research-compliance.ps1 -Owner zzx-coder -Repo test-repo"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "m4-progress",
|
||||
icon: "📈",
|
||||
name: "进度跟踪与预警 (10-research-progress)",
|
||||
commands: [
|
||||
{
|
||||
label: "生成周度进度报告(含异常预警)",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\academic\\10-research-progress.ps1 -Owner zzx-coder -Repo test-repo"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "m4-citation",
|
||||
icon: "📄",
|
||||
name: "论文引用生成 (11-research-citation)",
|
||||
commands: [
|
||||
{
|
||||
label: "生成全部 5 种引用格式 (all)",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\academic\\11-research-citation.ps1 -Owner zzx-coder -Repo test-repo -Format all"
|
||||
},
|
||||
{
|
||||
label: "BibTeX 格式",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\academic\\11-research-citation.ps1 -Owner zzx-coder -Repo test-repo -Format bibtex"
|
||||
},
|
||||
{
|
||||
label: "APA 格式",
|
||||
engine: "powershell",
|
||||
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\academic\\11-research-citation.ps1 -Owner zzx-coder -Repo test-repo -Format apa"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "m4-research",
|
||||
icon: "🔬",
|
||||
name: "科研辅助 Agent (gitlink-research)",
|
||||
prompts: [
|
||||
{
|
||||
label: "科研功能总入口(6 选项菜单)",
|
||||
engine: "claude",
|
||||
prompt: "请使用科研辅助功能\n(请展示 6 选项菜单:项目洞察 / 热点追踪 / 合规复现 / 协作匹配 / 进度预警 / 论文引用,等我选择)\n测试仓库:zzx-coder/test-repo"
|
||||
},
|
||||
{
|
||||
label: "合规与复现性检查",
|
||||
engine: "claude",
|
||||
prompt: "请检查 zzx-coder/test-repo 项目的合规与复现性"
|
||||
},
|
||||
{
|
||||
label: "论文引用生成(BibTeX)",
|
||||
engine: "claude",
|
||||
prompt: "请生成 zzx-coder/test-repo 项目的论文引用(BibTeX 格式)"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// DOM refs
|
||||
// ═══════════════════════════════════════════════
|
||||
|
|
@ -650,14 +1025,21 @@ var btnRun = document.getElementById('btn-run');
|
|||
var btnCancel = document.getElementById('btn-cancel');
|
||||
var cliModsContainer = document.getElementById('cli-modules-container');
|
||||
var skillsModsContainer = document.getElementById('skills-modules-container');
|
||||
var mission3ModsContainer = document.getElementById('mission3-modules-container');
|
||||
var mission4ModsContainer = document.getElementById('mission4-modules-container');
|
||||
var statusDot = document.getElementById('status-dot');
|
||||
var statusText = document.getElementById('status-text');
|
||||
var toast = document.getElementById('toast');
|
||||
var isRunning = false;
|
||||
var currentEngine = 'powershell'; // 'powershell' | 'claude'
|
||||
var cliExecMode = 'direct'; // CLI section mode
|
||||
var skillsExecMode = 'direct'; // Skills section mode
|
||||
var execModes = { cli:'direct', skills:'direct', mission3:'direct', mission4:'direct' }; // 每个 section 的执行模式
|
||||
var claudeSessionId = null; // Claude Code session ID for multi-turn
|
||||
var currentAgent = 'claude'; // 'claude' | 'codex' | 'zcode'
|
||||
var AGENT_META = {
|
||||
claude: { icon: '🤖', name: 'Claude Code', btnColor: 'var(--purple)' },
|
||||
codex: { icon: '📟', name: 'Codex', btnColor: '#0ea5e9' },
|
||||
zcode: { icon: '⚡', name: 'ZCode', btnColor: '#f59e0b' }
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Section Toggle (top-level collapse)
|
||||
|
|
@ -670,7 +1052,7 @@ function toggleSection(sectionId) {
|
|||
// ═══════════════════════════════════════════════
|
||||
// Build Sidebar (CLI + Skills sections)
|
||||
// ═══════════════════════════════════════════════
|
||||
function buildModuleGroup(container, modules, section, isSkill) {
|
||||
function buildModuleGroup(container, modules, section) {
|
||||
if (!container) return;
|
||||
modules.forEach(function(mod, idx) {
|
||||
var div = document.createElement('div');
|
||||
|
|
@ -703,23 +1085,19 @@ function buildModuleGroup(container, modules, section, isSkill) {
|
|||
}
|
||||
|
||||
function buildSidebar() {
|
||||
buildModuleGroup(cliModsContainer, MODULES, 'cli', false);
|
||||
buildModuleGroup(skillsModsContainer, SKILLS, 'skills', true);
|
||||
buildModuleGroup(cliModsContainer, MODULES, 'cli');
|
||||
buildModuleGroup(skillsModsContainer, SKILLS, 'skills');
|
||||
buildModuleGroup(mission3ModsContainer, MISSION3, 'mission3');
|
||||
buildModuleGroup(mission4ModsContainer, MISSION4, 'mission4');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Execution Mode Toggles
|
||||
// ═══════════════════════════════════════════════
|
||||
function setCliExecMode(mode) {
|
||||
cliExecMode = mode;
|
||||
document.getElementById('cli-mode-direct').className = (mode === 'direct') ? 'active' : '';
|
||||
document.getElementById('cli-mode-input').className = (mode === 'input') ? 'active' : '';
|
||||
}
|
||||
|
||||
function setSkillsExecMode(mode) {
|
||||
skillsExecMode = mode;
|
||||
document.getElementById('skills-mode-direct').className = (mode === 'direct') ? 'active' : '';
|
||||
document.getElementById('skills-mode-input').className = (mode === 'input') ? 'active' : '';
|
||||
function setSectionExecMode(section, mode) {
|
||||
execModes[section] = mode;
|
||||
document.getElementById(section + '-mode-direct').className = (mode === 'direct') ? 'active' : '';
|
||||
document.getElementById(section + '-mode-input').className = (mode === 'input') ? 'active' : '';
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
|
|
@ -728,11 +1106,12 @@ function setSkillsExecMode(mode) {
|
|||
function setEngine(engine) {
|
||||
currentEngine = engine;
|
||||
if (engine === 'claude') {
|
||||
inputPrompt.innerHTML = '🤖>';
|
||||
var meta = AGENT_META[currentAgent];
|
||||
inputPrompt.innerHTML = meta.icon + '>';
|
||||
inputPrompt.title = '点击切换为 PowerShell';
|
||||
cmdInput.placeholder = '输入 Claude Code 提示词或从左侧 Skills 选择...';
|
||||
btnRun.textContent = '▶ 执行 (Claude)';
|
||||
btnRun.style.background = 'var(--purple)';
|
||||
btnRun.textContent = '▶ 执行 (' + meta.name + ')';
|
||||
btnRun.style.background = meta.btnColor;
|
||||
} else {
|
||||
inputPrompt.innerHTML = 'PS>';
|
||||
inputPrompt.title = '点击切换为 Claude Code';
|
||||
|
|
@ -746,6 +1125,49 @@ function toggleEngine() {
|
|||
setEngine(currentEngine === 'powershell' ? 'claude' : 'powershell');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Prompt helper: update input prompt from AGENT_META
|
||||
// ═══════════════════════════════════════════════
|
||||
function updateInputPrompt() {
|
||||
if (currentEngine === 'claude') {
|
||||
var meta = AGENT_META[currentAgent];
|
||||
inputPrompt.innerHTML = meta.icon + '>';
|
||||
} else {
|
||||
inputPrompt.innerHTML = 'PS>';
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Agent Selector (Skills section)
|
||||
// ═══════════════════════════════════════════════
|
||||
function setAgent(agent) {
|
||||
if (agent === currentAgent) return;
|
||||
|
||||
currentAgent = agent;
|
||||
|
||||
// Update button active states (支持多 section 共享同一 currentAgent)
|
||||
var btns = document.querySelectorAll('.agent-btn');
|
||||
for (var i = 0; i < btns.length; i++) {
|
||||
btns[i].classList.toggle('active', btns[i].getAttribute('data-agent') === agent);
|
||||
}
|
||||
|
||||
// Clear terminal output (but keep session)
|
||||
terminal.innerHTML = '';
|
||||
|
||||
// Show switch message
|
||||
var meta = AGENT_META[agent];
|
||||
appendEntry('info', 'agent已切换为 ' + meta.name);
|
||||
|
||||
// Switch engine to Claude mode if not already
|
||||
if (currentEngine !== 'claude') {
|
||||
setEngine('claude');
|
||||
} else {
|
||||
updateInputPrompt();
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Module Toggle
|
||||
// ═══════════════════════════════════════════════
|
||||
|
|
@ -764,14 +1186,15 @@ function onPresetSelect(selectEl, moduleId) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Determine section: 'cli' or 'skills'
|
||||
var isSkill = moduleId.indexOf('skills-') === 0;
|
||||
// Determine section: 'cli' | 'skills' | 'mission3' | 'mission4'
|
||||
var section = moduleId.substring(0, moduleId.indexOf('-mod-'));
|
||||
var dataMap = { cli: MODULES, skills: SKILLS, mission3: MISSION3, mission4: MISSION4 };
|
||||
var list = dataMap[section] || MODULES;
|
||||
|
||||
// Find module
|
||||
var list = isSkill ? SKILLS : MODULES;
|
||||
var mod = null;
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if ((isSkill ? 'skills-mod-' : 'cli-mod-') + list[i].id === moduleId) { mod = list[i]; break; }
|
||||
if (section + '-mod-' + list[i].id === moduleId) { mod = list[i]; break; }
|
||||
}
|
||||
if (!mod) return;
|
||||
|
||||
|
|
@ -779,18 +1202,20 @@ function onPresetSelect(selectEl, moduleId) {
|
|||
if (!item) return;
|
||||
|
||||
var content = item.cmd || item.prompt;
|
||||
// 每个 item 可自带 engine;默认 skills 走 claude,其余走 powershell
|
||||
var engine = item.engine || (section === 'skills' ? 'claude' : 'powershell');
|
||||
|
||||
// Show preview
|
||||
var previewEl = document.getElementById('preview-' + moduleId);
|
||||
if (previewEl) { previewEl.textContent = content; }
|
||||
|
||||
var mode = isSkill ? skillsExecMode : cliExecMode;
|
||||
var mode = execModes[section] || 'direct';
|
||||
|
||||
if (mode === 'direct') {
|
||||
// Execute immediately
|
||||
selectEl.value = '';
|
||||
if (previewEl) { previewEl.textContent = ''; }
|
||||
if (isSkill) {
|
||||
if (engine === 'claude') {
|
||||
setEngine('claude');
|
||||
executeDirectClaude(content);
|
||||
} else {
|
||||
|
|
@ -799,7 +1224,7 @@ function onPresetSelect(selectEl, moduleId) {
|
|||
}
|
||||
} else {
|
||||
// Fill input for editing
|
||||
if (isSkill) { setEngine('claude'); } else { setEngine('powershell'); }
|
||||
setEngine(engine);
|
||||
cmdInput.value = content;
|
||||
autoResizeTextarea();
|
||||
cmdInput.focus();
|
||||
|
|
@ -832,8 +1257,9 @@ function executeDirectClaude(prompt) {
|
|||
btnCancel.classList.add('visible');
|
||||
cmdInput.value = '';
|
||||
|
||||
var meta = AGENT_META[currentAgent];
|
||||
appendEntry('cmd-line',
|
||||
'<span class="prompt">⚡ 🤖</span> <span class="cmd-text">' + escHtml(prompt) + '</span>');
|
||||
'<span class="prompt">⚡ ' + meta.icon + '</span> <span class="cmd-text">' + escHtml(prompt) + '</span>');
|
||||
|
||||
sendClaudeRequest(prompt);
|
||||
}
|
||||
|
|
@ -852,7 +1278,8 @@ function executeCommand() {
|
|||
btnRun.classList.add('running');
|
||||
btnCancel.classList.add('visible');
|
||||
|
||||
var promptHtml = isClaude ? '🤖>' : 'PS>';
|
||||
var meta = AGENT_META[currentAgent];
|
||||
var promptHtml = isClaude ? meta.icon + '>' : 'PS>';
|
||||
appendEntry('cmd-line',
|
||||
'<span class="prompt">' + promptHtml + '</span> <span class="cmd-text">' + escHtml(command) + '</span>');
|
||||
|
||||
|
|
@ -1040,7 +1467,6 @@ function sendClaudeRequest(prompt) {
|
|||
// ── 会话管理:保存 session_id 并提示用户 ──
|
||||
if (data.session_id) {
|
||||
claudeSessionId = data.session_id;
|
||||
updateSessionBadge();
|
||||
var shortId = data.session_id.substring(0, 8);
|
||||
if (data.ok) {
|
||||
appendEntry('info', '💬 会话持续中 · ID: <code>' + shortId + '</code> · <span style="color:var(--accent);cursor:pointer;text-decoration:underline;" onclick="newClaudeSession()">点击开始新会话</span> · 下一条 Claude 指令将自动延续本会话');
|
||||
|
|
@ -1074,8 +1500,8 @@ function sendClaudeRequest(prompt) {
|
|||
resetExecState();
|
||||
};
|
||||
|
||||
// ── 构建请求体:带上会话信息 ──
|
||||
var reqBody = { prompt: prompt, timeout: 300 };
|
||||
// ── 构建请求体:带上会话信息(超时 600s = 10min)──
|
||||
var reqBody = { prompt: prompt, timeout: 600 };
|
||||
if (claudeSessionId) {
|
||||
reqBody.session_id = claudeSessionId;
|
||||
}
|
||||
|
|
@ -1085,24 +1511,10 @@ function sendClaudeRequest(prompt) {
|
|||
// ── Start a fresh Claude Code session ──
|
||||
function newClaudeSession() {
|
||||
claudeSessionId = null;
|
||||
updateSessionBadge();
|
||||
appendEntry('info', '🆕 已开始新会话 — 下一条 Claude 指令将创建全新对话');
|
||||
showToast('Claude 会话已重置', 'ok');
|
||||
}
|
||||
|
||||
// ── Update session badge in sidebar ──
|
||||
function updateSessionBadge() {
|
||||
var badge = document.getElementById('session-badge');
|
||||
if (badge) {
|
||||
if (claudeSessionId) {
|
||||
badge.style.display = 'inline';
|
||||
badge.textContent = '● 会话中';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reset after execution ──
|
||||
function resetExecState() {
|
||||
isRunning = false;
|
||||
|
|
@ -1112,8 +1524,9 @@ function resetExecState() {
|
|||
cmdInput.focus();
|
||||
// Restore button text based on current engine
|
||||
if (currentEngine === 'claude') {
|
||||
btnRun.textContent = '▶ 执行 (Claude)';
|
||||
btnRun.style.background = 'var(--purple)';
|
||||
var meta = AGENT_META[currentAgent];
|
||||
btnRun.textContent = '▶ 执行 (' + meta.name + ')';
|
||||
btnRun.style.background = meta.btnColor;
|
||||
} else {
|
||||
btnRun.textContent = '▶ 执行';
|
||||
btnRun.style.background = '';
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ class DemoHandler(http.server.BaseHTTPRequestHandler):
|
|||
self._send_json({"ok": False, "error": "Empty prompt", "session_id": data.get("session_id", "")})
|
||||
return
|
||||
|
||||
timeout = min(data.get("timeout", 300), 600) # max 10 min for Claude
|
||||
timeout = min(data.get("timeout", 600), 600) # 10 min for Claude
|
||||
|
||||
if not CLAUDE_EXE:
|
||||
self._send_json({
|
||||
|
|
@ -220,6 +220,10 @@ class DemoHandler(http.server.BaseHTTPRequestHandler):
|
|||
tmp = None
|
||||
proc = None
|
||||
try:
|
||||
# 注入 UTF-8 编码设置:避免 PowerShell 默认 GBK 解码含中文 JSON 导致
|
||||
# ConvertFrom-Json 报 ArgumentException。对已有该设置的命令重复无害。
|
||||
command = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n" + command
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".ps1", delete=False, encoding="utf-8-sig"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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` 格式
|
||||
```
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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('示例三:批量关闭 Issue(dry-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',
|
||||
'获取已合并 PR:gitlink-cli pr +list --state merged --format json',
|
||||
'获取 commit 历史:git log --oneline v2.0.0..HEAD',
|
||||
'AI 分类整理:新功能、Bug 修复、性能优化、Breaking Changes',
|
||||
'生成 Release Notes:gitlink-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',
|
||||
'创建 Issues:gitlink-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',
|
||||
'查询跨仓库 PR:gitlink-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()
|
||||
|
|
@ -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 {
|
||||
// 命令执行逻辑
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -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.Reader(HTTP 请求需要的格式)
|
||||
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. 构建 URL:https://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. 解析 JSON:json.Unmarshal → map[string]interface{}
|
||||
8. 检查业务错误:判断 status 字段
|
||||
9. 返回 Envelope:output.SuccessEnvelope(raw, meta)
|
||||
```
|
||||
|
|
@ -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 返回 204(No 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 内容更易读,同时节省 token(Base64 编码会增加约 33% 的体积)。
|
||||
|
||||
---
|
||||
|
||||
## 第 301-526 行:Lint 相关
|
||||
|
|
@ -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, ",")
|
||||
```
|
||||
|
||||
按逗号分割字符串,返回字符串数组
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
||||
把字符串转换成整数,如果失败返回错误
|
||||
|
|
@ -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. 构建缓存 key(owner/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)...)
|
||||
```
|
||||
|
||||
`...` 表示把切片展开成多个参数
|
||||
|
|
@ -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)` 的切片,避免动态扩容的性能开销
|
||||
|
|
@ -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 决定输出格式
|
||||
|
||||
---
|
||||
|
|
@ -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 API(Wiki 操作)
|
||||
|
||||
### 关键代码逻辑
|
||||
|
||||
#### 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 部分
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python -c \"import docx; print\\(''ok''\\)\")",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(python generate_report.py)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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/Example(list, create, view, close, update) |
|
||||
| `shortcuts/pr/pr.go` | 错误统一 + Choices(state, method) + Long/Example(list, create, merge) |
|
||||
| `shortcuts/repo/repo.go` | 错误统一 + Choices(category) + Long/Example(list, create, update) |
|
||||
|
||||
所有新增字段零值安全,现有 80+ Shortcut 无需修改即可编译。`output.Print()` 签名不变,内部包装为 `PrintWithOpts`。Workflow 脚本显式使用 `--format json`,不受默认格式变化影响。
|
||||
|
||||
验证:`go build` 通过,`go vet` 通过(仅 pre-existing milestone 包有构建错误),`go test ./...` 7 个测试包全部通过。
|
||||
|
|
@ -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 .
|
||||
|
||||
# 同步到 npm(npm 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+description(PATCH 前必须调用)
|
||||
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 |
|
||||
|
|
@ -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 API(HTTP 原始调用)', '完全透传,适合调试和边缘场景', '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('(1)Wiki 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、Windows(x64/arm64)三个平台。')
|
||||
add_para('安装方式:')
|
||||
add_para('(1)一键安装脚本:curl -sSL .../install.sh | bash,自动检测平台和架构。')
|
||||
add_para('(2)npm 安装:npm install -g @gitlink-ai/cli,postinstall 自动下载对应平台二进制。')
|
||||
add_para('(3)源码编译:go install . 后手动同步到 npm 包。')
|
||||
add_para('Windows 特殊处理:npm shim(cli.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('(2)15 个 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}')
|
||||
|
|
@ -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 模块 |
|
||||
|
|
@ -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:编号、标题、内容摘要、合并状态、链接。
|
||||
|
|
@ -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 辅助科研(可选完成,视完成情况额外加分)
|
||||
定位:科研场景智能化赋能 | 难度:中 | 要求:无需 Go,Markdown + CLI 调用即可
|
||||
任务:依托 gitlink-cli 数据获取、命令调用与 AI Agent 能力,融合数据分析、知识图谱、AI 挖掘技术,面向
|
||||
科研工作者、课题组与科研团队,将 GitLink 平台代码托管、协作数据转化为科研创新支撑能力,实现科研项目
|
||||
分析、主体画像、热点追踪、创新启发、合规校验等全链路科研辅助服务,打通开源代码生态与学术科研的融
|
||||
合通道,包括但不限于以下场景:
|
||||
• 仓库级科研项目洞悉
|
||||
• 科研热点追踪与知识图谱构建
|
||||
• 科研项目合规与复现性检查
|
||||
• 科研协作智能匹配
|
||||
• 科研进度智能跟踪与预警
|
||||
|
||||
|
|
@ -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.
|
|
@ -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 客户端、认证、配置、输出格式化、错误处理。
|
||||
(5)AI 扩展层: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。
|
||||
|
||||
(4)Shell 补全:支持 bash、zsh、fish、powershell 四种 shell 的自动补全。
|
||||
|
||||
(5)帮助系统:每个命令支持 --help,显示用法、参数说明和示例。
|
||||
|
||||
用户操作流程:
|
||||
用户打开终端 → 输入 gitlink-cli auth login 登录 → 使用具体命令操作资源
|
||||
→ 输出结果以 table/json/yaml 格式显示 → 可通过管道传递给其他工具
|
||||
|
||||
|
||||
2.3 数据库设计
|
||||
|
||||
本项目不使用传统数据库。数据存储分为两部分:
|
||||
|
||||
(1)配置文件存储:
|
||||
路径:~/.config/gitlink-cli/config.yaml
|
||||
内容:base_url(API 地址)、default_format(输出格式)、editor、pager
|
||||
格式:YAML
|
||||
|
||||
(2)凭证存储:
|
||||
使用 go-keyring 库,调用操作系统原生密钥管理:
|
||||
- macOS:Keychain
|
||||
- Linux:Secret Service(GNOME Keyring / KDE Wallet)
|
||||
- Windows:Credential Manager
|
||||
Fallback:~/.config/gitlink-cli/credentials(文件权限 0600)
|
||||
|
||||
存储的数据结构:
|
||||
Token(字符串)→ 关联 gitlink.org.cn 的 Bearer Token
|
||||
Config(YAML)→ 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 的骨架。
|
||||
|
||||
核心类结构:
|
||||
|
||||
(1)cobra.Command(来自 spf13/cobra 库)
|
||||
- 每个命令对应一个 cobra.Command 实例
|
||||
- rootCmd 是根命令,所有子命令通过 AddCommand 挂载
|
||||
- root.go 定义全局 PersistentFlags(--owner、--repo、--format 等)
|
||||
|
||||
(2)common.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
|
||||
}
|
||||
|
||||
(3)common.Flag 结构体:
|
||||
type Flag struct {
|
||||
Name string
|
||||
Short string
|
||||
Usage string
|
||||
Required bool
|
||||
Default string
|
||||
Bool bool
|
||||
Choices []string
|
||||
Validate func(value string) error
|
||||
}
|
||||
|
||||
(4)common.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 并输出
|
||||
|
||||
(5)register.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 行
|
||||
- ci:CI/CD 操作(builds/logs/restart/stop)— 117 行
|
||||
- file:文件操作(ls/read/create/update/delete/commits/diff)— 886 行
|
||||
- issue:Issue 操作(14 个命令,含 6 种批量操作)— 3,058 行
|
||||
- milestone:里程碑操作(list/create/view/update/delete/status)— 525 行
|
||||
- org:组织操作(list/info/members/create/update)— 476 行
|
||||
- pr:PR 操作(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 行
|
||||
- webhook:Webhook 操作(list/create/view/update/delete/test/events)— 649 行
|
||||
- wiki:Wiki 操作(list/create/view/update/delete/lint/fix/sync)— 1,612 行
|
||||
|
||||
最大的两个模块是 issue(3,058 行)和 wiki(1,612 行)。issue 模块包含批量操作功能,wiki 模块处理双域名架构和 base64 编解码。
|
||||
|
||||
批量操作设计模式(以 issue 为例):
|
||||
- BatchResult:单条操作结果(number/action/status/error)
|
||||
- BatchSummary:汇总信息(total/succeeded/failed/results)
|
||||
- 输入灵活:--numbers(内联)和 --from(CSV 文件)可同时使用
|
||||
- dry-run 统一:所有批量命令支持 --dry-run 预览
|
||||
- 错误不中断:单条失败不影响后续处理
|
||||
|
||||
|
||||
3.3 核心库层设计
|
||||
|
||||
核心库层位于 internal/ 目录,包含 7 个子包:
|
||||
|
||||
(1)internal/auth — 认证模块(3 个文件,272 行)
|
||||
- login.go:登录流程(用户名密码或 Token 粘贴)
|
||||
- token_store.go:Token 存储(go-keyring 跨平台支持)
|
||||
- transport.go:HTTP Transport,自动在请求 Header 中注入 Bearer Token
|
||||
|
||||
(2)internal/client — HTTP 客户端(2 个文件,319 行)
|
||||
- client.go:封装 Get/Post/Put/Delete 方法,自动追加 .json 后缀,双重错误检查(HTTP 状态码 + JSON body 中的 status 字段)
|
||||
- pagination.go:分页迭代器,自动遍历 Kaminari 风格分页
|
||||
|
||||
(3)internal/compliance — 合规检查(4 个文件,685 行)
|
||||
- cmd.go:compliance 子命令定义
|
||||
- scanner.go:扫描器实现
|
||||
- license.go:许可证检测
|
||||
- rules.go:合规规则定义
|
||||
|
||||
(4)internal/config — 配置管理(1 个文件,125 行)
|
||||
- config.go:读写 ~/.config/gitlink-cli/config.yaml
|
||||
|
||||
(5)internal/context — 上下文解析(1 个文件,91 行)
|
||||
- repo.go:从 git remote 的 origin URL 自动解析 owner 和 repo
|
||||
|
||||
(6)internal/errors — 错误处理(1 个文件,150 行)
|
||||
- errors.go:统一错误类型 CLIError,包含 Kind(错误分类)、Message、Suggestion、Command 字段
|
||||
- 错误分类:KindAuth、KindInput、KindForbidden、KindNotFound、KindServer、KindUnknown
|
||||
|
||||
(7)internal/output — 输出格式化(3 个文件,425 行)
|
||||
- envelope.go:定义 Envelope 结构体 {OK, Data, Error, Meta}
|
||||
- formatter.go:三种输出格式(JSON/YAML/Table),table 使用 tabwriter 对齐,支持 ANSI 颜色、列过滤、值截断控制
|
||||
|
||||
|
||||
4、系统动态模型
|
||||
|
||||
4.1 用例一:用户认证与仓库操作
|
||||
|
||||
用例描述:用户首次使用 gitlink-cli,完成登录并查看仓库信息。
|
||||
|
||||
参与者:开发者(终端用户)
|
||||
|
||||
前置条件:已安装 gitlink-cli,网络可访问 gitlink.org.cn
|
||||
|
||||
主要流程:
|
||||
|
||||
(1)用户执行 gitlink-cli auth login
|
||||
(2)系统提示输入用户名和密码
|
||||
(3)系统调用 POST /api/accounts/login 获取 Token
|
||||
(4)Token 存入 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)系统输出 BatchSummary:total=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)系统输出 BatchSummary:total=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 中说"帮我跑社区运营工作流"
|
||||
(2)Claude Code 触发 gitlink-workflows skill,显示功能菜单
|
||||
(3)用户选择"社区运营自动化"
|
||||
(4)Claude Code 调用 gitlink-cli issue +list --state open --format json
|
||||
(5)Claude Code 分析 Issue 列表,按类型分类(bug/feature/question)
|
||||
(6)Claude Code 调用 gitlink-cli issue +label-add 添加分类标签
|
||||
(7)Claude Code 调用 gitlink-cli pr +list 获取近期 PR
|
||||
(8)Claude Code 生成周报摘要,包含 Issue 统计、PR 合并情况、贡献者活跃度
|
||||
|
||||
Skill 调用链:
|
||||
gitlink-shared(认证检查)→ gitlink-issue(Issue 操作)→ gitlink-pr(PR 操作)→ 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.sh(Linux/macOS)、install.ps1(Windows)
|
||||
|
||||
(3)目标平台:
|
||||
- Windows(amd64)
|
||||
- macOS(amd64、arm64)
|
||||
- Linux(amd64、arm64)
|
||||
|
||||
(4)依赖服务:
|
||||
- GitLink API:https://www.gitlink.org.cn/api(主站 API)
|
||||
- GitLink Gateway:https://gateway.gitlink.org.cn/api(Wiki API 专用)
|
||||
- OS Keychain:系统密钥管理服务(存储 Token)
|
||||
|
||||
(5)CI/CD 集成:
|
||||
- GitHub Actions:.github/workflows/release.yml 自动构建和发布
|
||||
- GitLink DevOps:.devops/gitlink-cli-autodeploy.yml 平台自动部署
|
||||
|
||||
(6)部署架构:
|
||||
|
||||
用户终端
|
||||
↓
|
||||
gitlink-cli(本地二进制)
|
||||
↓ HTTP/HTTPS
|
||||
GitLink API Server(gitlink.org.cn)
|
||||
↓
|
||||
GitLink Gateway(gateway.gitlink.org.cn,Wiki 专用)
|
||||
|
||||
gitlink-cli 是纯客户端工具,不运行后台服务。所有数据存储在用户本地(配置文件 + OS Keychain),业务数据完全来自 GitLink 平台 API 的实时查询。
|
||||
BIN
gitlink-cli
BIN
gitlink-cli
Binary file not shown.
Binary file not shown.
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>`
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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,7 +436,8 @@ func runBatchAssign(ctx *common.RuntimeContext) error {
|
|||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"assigned_to_id": assigneeID}); err != nil {
|
||||
// 调用 updateIssueField 分配用户
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"assigner_ids": []interface{}{assigneeID}}); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
|
|
@ -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,12 +474,20 @@ 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")
|
||||
trackerID, err := parseTracker(label)
|
||||
|
||||
tags, err := resolveIssueTags(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve issue tags: %w", err)
|
||||
}
|
||||
tagID, err := parseLabel(label, tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -444,7 +518,8 @@ func runBatchLabel(ctx *common.RuntimeContext) error {
|
|||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{trackerID}}); err != nil {
|
||||
// 调用 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()
|
||||
summary.Failed++
|
||||
|
|
@ -464,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
|
||||
|
|
@ -513,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":
|
||||
|
|
@ -533,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":
|
||||
|
|
@ -551,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
|
||||
|
|
@ -582,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
|
||||
|
|
@ -594,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 {
|
||||
|
|
@ -610,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
|
||||
|
|
@ -617,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)
|
||||
|
|
@ -634,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)
|
||||
|
|
@ -677,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{}
|
||||
|
|
@ -692,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
|
||||
|
|
|
|||
|
|
@ -12,20 +12,21 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var issueTagCache sync.Map
|
||||
var issueTagCache sync.Map //全局缓存变量
|
||||
|
||||
// 获取项目标签映射
|
||||
// resolveIssueTags fetches the project's issue tags and returns a name→id mapping.
|
||||
// Results are cached per owner/repo.
|
||||
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
|
||||
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)
|
||||
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) //api路径
|
||||
q := url.Values{}
|
||||
q.Set("only_name", "true")
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q) // 发送GET请求获取项目标签列表
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取项目标签列表失败: %w", err)
|
||||
}
|
||||
|
|
@ -157,7 +158,7 @@ func runBatchCreate(ctx *common.RuntimeContext) error {
|
|||
for i, input := range inputs {
|
||||
label := fmt.Sprintf("#%d", i+1)
|
||||
if input.Title != "" {
|
||||
label = truncate(input.Title, 40)
|
||||
label = truncate(input.Title, 40) // 用标题的前40个字符
|
||||
}
|
||||
result := BatchResult{Number: label, Action: "create"}
|
||||
|
||||
|
|
@ -168,6 +169,7 @@ func runBatchCreate(ctx *common.RuntimeContext) error {
|
|||
continue
|
||||
}
|
||||
|
||||
//把 createIssueInput 转换成 API 需要的 JSON map
|
||||
body := buildCreateBody(ctx, input, template, tags)
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,29 @@
|
|||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fmt" //格式化字符串
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strconv" //字符串和数字转换
|
||||
"strings"
|
||||
|
||||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Ctx
|
||||
// v1RepoPath returns the v1 API path prefix: /v1/{owner}/{repo}
|
||||
func v1RepoPath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
|
||||
//内部数据结构,标题+描述,保存从 API 取回来的 Issue 原始数据
|
||||
type existingIssue struct {
|
||||
Subject string
|
||||
Description string
|
||||
}
|
||||
|
||||
// 所有issue命令的注册入口,返回所有issue命令
|
||||
// 在终端敲入issue +list命令时,遍历匹配到列表里的{Name: "list", ...}
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
|
|
@ -27,37 +32,44 @@ 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"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
//后面拼接 API 路径时,需要用到 ctx.Owner 和 ctx.Repo
|
||||
// 如果不知道 owner 和 repo ,后续的 API 调用就不知道该往哪发请求,所以必须作为前置校验放在最前面。
|
||||
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 s := ctx.Arg("state"); s != "" {
|
||||
q := url.Values{} // 创建空的URL查询参数容器q,后续通过set()添加键值对,最终拼接成形如 ?page=1&limit=20&state=open 的查询字符串
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit")) //从命令行参数中读取 page(页码)和 limit(每页条数
|
||||
if s := ctx.Arg("state"); s != "" { // 如果用户传入了state参数
|
||||
q.Set("state", s)
|
||||
}
|
||||
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)
|
||||
return ctx.Output(env) //按照用户指定的格式(json/html/table...)输出issue列表
|
||||
},
|
||||
},
|
||||
{
|
||||
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")
|
||||
|
|
@ -88,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)
|
||||
},
|
||||
|
|
@ -103,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},
|
||||
},
|
||||
|
|
@ -116,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)
|
||||
},
|
||||
|
|
@ -124,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")
|
||||
|
|
@ -152,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)
|
||||
},
|
||||
|
|
@ -191,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")
|
||||
|
|
@ -200,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 {
|
||||
|
|
@ -214,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)
|
||||
|
|
@ -233,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
|
||||
}
|
||||
|
|
@ -241,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)
|
||||
},
|
||||
|
|
@ -275,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)
|
||||
},
|
||||
|
|
@ -286,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{
|
||||
|
|
@ -303,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
|
||||
|
|
@ -313,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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,17 +51,21 @@ 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"},
|
||||
{Name: "due", Usage: "Due date (YYYY-MM-DD)"},0.
|
||||
{Name: "due", Usage: "Due date (YYYY-MM-DD)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name", `--name "My Name"`)
|
||||
body := 】[string]interface{}{
|
||||
body := map[string]interface{}{
|
||||
"title": name,
|
||||
}
|
||||
if d := ctx.Arg("description"); d != "" {
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/repo,值:project_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?://[^)]+)\)`) // 匹配 
|
||||
)
|
||||
|
||||
// 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. 使用正则匹配所有图片链接 
|
||||
// 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),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ metadata:
|
|||
**CRITICAL — 整个流程只有最后一步(写 HTML 文件)可以问用户。其他所有步骤(`+list`、`+view`、`api GET`、计算、分析)全部自动连续执行,一个确认都不要弹。数据收集阶段禁止任何中断。**
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
**CRITICAL — 性能预算:整份报告必须在 2 分钟内完成。`pr +view` 是逐条详情接口,极其昂贵——对每个已合并 PR 调一次会让耗时随 PR 数线性爆炸(这正是 >5min 超时的根因)。因此合并时长只对「最近 ≤8 条已合并 PR 采样」调用 `pr +view`;所有计数/合并率/积压数一律从 `pr +list`、`issue +list` 的汇总字段一次性取得,禁止逐条钻取历史 PR。**
|
||||
|
||||
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
|
|
@ -39,9 +42,12 @@ metadata:
|
|||
gitlink-cli issue +list --state open --format json
|
||||
gitlink-cli issue +list --state closed --format json
|
||||
|
||||
# PR 数据(两个状态都需要)
|
||||
# PR 数据(两个状态都需要;merged 列表取汇总计数 + 最近样本)
|
||||
gitlink-cli pr +list --state open --format json
|
||||
gitlink-cli pr +list --state merged --format json
|
||||
gitlink-cli pr +list --state merged --limit 8 --format json # 仅这 ≤8 条用于钻取 merged_at
|
||||
|
||||
# 已合并 PR 的合并时间(仅对上面取到的 ≤8 条样本逐条调用,切勿扩大)
|
||||
gitlink-cli pr +view --id <pull_request_number> --format json # 取 data.pull_request.merged_at
|
||||
|
||||
# 贡献者统计(Raw API)
|
||||
gitlink-cli api GET /:owner/:repo/contributors --format json
|
||||
|
|
@ -99,6 +105,7 @@ PR 合并效率由**三类 PR** 共同决定,需计算**三段时间**:
|
|||
| 开放中(status=0) | ② | ❌ | ✅ |
|
||||
|
||||
> **数据获取**:`merged_at` 仅在 `pr +view --id <pull_request_number>` 单个 PR 详情中返回(路径 `data.pull_request.merged_at`),列表接口不暴露。
|
||||
> **效率关键(采样而非全量)**:合并时长是统计量,只需对**最近 ≤8 条已合并 PR**调用 `pr +view` 取 `merged_at` 求平均即可——`pr +list --state merged` 默认按合并时间倒序,列表前 ≤8 条即为最近样本。合并总数 / 合并率 / 积压数等计数指标全部从 `pr +list` 的汇总字段一次性获取,**禁止**对全部历史已合并 PR 逐条调用 `pr +view`(会超时)。
|
||||
> **绝对禁止**:用「当前时间 - 创建时间」估算已合并 PR 的合并时间。开放 PR 用此公式是允许的(且必要),因为它们没有合并时间点,等待时长反映积压。
|
||||
|
||||
### 3. 贡献者活跃度
|
||||
|
|
|
|||
|
|
@ -24,19 +24,20 @@ gitlink-cli issue +list --state closed --format json
|
|||
### 第二步:收集 PR 数据
|
||||
|
||||
```bash
|
||||
# 获取未合并 PR
|
||||
# 获取未合并 PR(计数 + 积压)
|
||||
gitlink-cli pr +list --state open --format json
|
||||
|
||||
# 获取已合并 PR
|
||||
gitlink-cli pr +list --state merged --format json
|
||||
# 获取已合并 PR 汇总 + 最近样本(--limit 8 限定钻取范围,避免超时)
|
||||
gitlink-cli pr +list --state merged --limit 8 --format json
|
||||
|
||||
# 仅对上面取到的 ≤8 条样本逐条取 merged_at(切勿对全部历史 PR 调用)
|
||||
gitlink-cli pr +view --id <pull_request_number> --format json
|
||||
```
|
||||
|
||||
# AI 从返回中提取:
|
||||
# - merged count: 10
|
||||
# - open count: 0
|
||||
# - 合并率: 10/11 = 91%
|
||||
# - 最新合并 PR: #11 (2026-06-04)
|
||||
# - PR 平均合并时间需逐条计算
|
||||
```
|
||||
# - merged count / open count:从 pr +list 汇总字段
|
||||
# - 合并率: merged / (merged + closed)
|
||||
# - 已合并 PR 平均合并时长:仅基于这 ≤8 条样本的 merged_at - created_at 求平均
|
||||
|
||||
### 第三步:收集贡献者统计
|
||||
|
||||
|
|
@ -90,7 +91,7 @@ AI 读取 `skills/gitlink-health/template.html`,将计算后的指标替换所
|
|||
## AI Agent 执行要点
|
||||
|
||||
1. **数据收集顺序**:先收集 Issue 和 PR(Shortcut 命令),再收集 contributors(Raw API),避免一次性大量 API 调用
|
||||
2. **分页处理**:Issue 和 PR 数量超过单页限制时,用 `--page` 逐页获取
|
||||
2. **效率优先于全量**:报告是趋势快照,不是审计。计数/合并率从 `+list` 汇总字段一次取得;合并时长只采样最近 ≤8 条已合并 PR(`pr +view`)。**禁止**分页遍历全部历史 Issue/PR,也禁止对每个已合并 PR 都调 `pr +view`——这是 >5min 超时的根因
|
||||
3. **时间计算**:ISO 8601 格式解析优先,Unix 时间戳更可靠但 PR 的 `pr_created_unix` 仅部分返回
|
||||
4. **指标计算容错**:样本不足时标注而非报错,新项目可能仅有少量数据
|
||||
5. **评分可按需调整**:新项目无 Release 时,"无近期发布"项自动跳过
|
||||
|
|
|
|||
|
|
@ -43,14 +43,16 @@ PR 返回的关键字段:
|
|||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `pull_request_number` | PR 编号 |
|
||||
| `pull_request_number` | PR 编号(用于 `pr +view --id` 钻取) |
|
||||
| `name` (title) | PR 标题 |
|
||||
| `pull_request_status` | 状态:0=open, 1=merged, 2=closed |
|
||||
| `author_login` | 作者 |
|
||||
| `pr_full_time` | 创建时间 (ISO 8601) |
|
||||
| `pr_merged_at` | 合并时间 |
|
||||
| `pr_created_unix` | 创建时间 (Unix timestamp) |
|
||||
| `merged_issues_size` / `open_count` / `close_count` | 合并/开放/关闭未合并计数(汇总字段,与 `--limit` 无关,一次性取得) |
|
||||
|
||||
> ⚠️ `pr +list` **不返回** `merged_at`(合并时间)字段,只有创建时间。`merged_at` 仅在 `pr +view --id <pull_request_number>` 的 `data.pull_request.merged_at` 中暴露。
|
||||
> ⚠️ **为控制耗时,只对最近 ≤8 条已合并 PR 调用 `pr +view` 取 `merged_at`**(列表默认按合并时间倒序,前 ≤8 条即最近样本)。`pr +list --state merged --limit 8` 一条命令即可同时拿到汇总计数(`merged_issues_size` 等不受 limit 影响)和 8 条样本。禁止遍历全部历史已合并 PR。
|
||||
> ⚠️ `--state` 参数仅影响 `merged_count`/`open_count`/`closed_count` 汇总计数,API 返回的 issues 列表可能包含所有状态的 PR。需按 `pull_request_status` 客户端过滤。
|
||||
|
||||
### 步骤 3:收集贡献者统计
|
||||
|
|
@ -100,17 +102,17 @@ gitlink-cli api GET /:owner/:repo/activity --format json
|
|||
|
||||
数据收集覆盖以下时间范围:
|
||||
|
||||
- **Issue**: 所有未关闭 + 近期已关闭(默认取最近 100 条)
|
||||
- **PR**: 所有未合并 + 近期已合并(默认取最近 100 条)
|
||||
- **Issue**: 所有未关闭 + 近期已关闭(计数从汇总字段取;时间计算用近期限定条数即可)
|
||||
- **PR**: 所有未合并 + 近期已合并(计数/合并率从 `pr +list` 汇总字段取;**合并时长仅采样最近 ≤8 条已合并 PR** 钻取 `merged_at`)
|
||||
- **Contributors**: 项目全量历史数据
|
||||
- **Activity**: 最近 30 天
|
||||
|
||||
对于大型项目,可通过 `--page` 分页获取更多数据。
|
||||
大型项目也**不要**通过 `--page` 分页遍历全部历史数据——健康度是趋势快照,计数取汇总字段、合并时长取最近 ≤8 条样本即可,避免超时。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 计算响应时间需要 Issue 有 `closed_at` 字段,仅已关闭 Issue 才会返回此字段
|
||||
- PR 合并时间需通过 `pr_full_time` 与当前时间对比估算,或结合 PM `/weekly_issues` API
|
||||
- PR 合并时间**不能**用「当前时间 - `pr_full_time`」估算(已合并 PR 必须取真实 `merged_at`);`merged_at` 通过 `pr +view --id` 获取,**仅采样最近 ≤8 条已合并 PR**
|
||||
- `contributors` 端点统计基于默认分支(通常为 `master`),不含其他分支
|
||||
- `--owner` / `--repo` 在仓库目录下可自动解析
|
||||
- 时间计算使用 Unix 时间戳(`pr_created_unix`)比解析字符串格式(`pr_full_time`)更可靠
|
||||
|
|
|
|||
|
|
@ -73,9 +73,9 @@ PR 合并效率由**三类 PR** 共同决定,需计算**三段时间**:
|
|||
### 计算公式
|
||||
|
||||
```
|
||||
# ① 已合并 PR 平均合并时长
|
||||
# ① 已合并 PR 平均合并时长(基于最近 ≤8 条样本)
|
||||
time_merged_i = merged_at_i - created_at_i
|
||||
avg_merged = sum(time_merged_i) / merged_count
|
||||
avg_merged = sum(time_merged_i) / sample_count # sample_count = 实际采样的 PR 条数(≤8),不是历史合并总数
|
||||
|
||||
# ② 开放 PR 平均等待时长
|
||||
time_open_i = now - created_at_i
|
||||
|
|
@ -123,28 +123,16 @@ merge_rate = merged_count / (merged_count + closed_count) × 100%
|
|||
|
||||
### 数据可用性
|
||||
|
||||
| 字段 | 来源命令 | 路径 |
|
||||
|------|---------|------|
|
||||
| `created_at` | `pr +list` | `data.issues[].pr_full_time` 或 `pr_created_unix` |
|
||||
| `merged_at` | `pr +view --id <pull_request_number>` | `data.pull_request.merged_at` |
|
||||
|
||||
> **绝对禁止**:用「当前时间 - 创建时间」估算已合并 PR 的合并时间。已合并 PR 必须用真实的 `merged_at`。
|
||||
> 开放 PR 用「当前时间 - 创建时间」是允许的(且必要),因为它们没有合并时间点,等待时长反映积压。
|
||||
|
||||
### 数据可用性
|
||||
|
||||
PR 合并时间**只与 PR 自身时间相关**(创建时间 + 合并时间),**与当前时间无关**。
|
||||
|
||||
实际数据源:
|
||||
|
||||
| 字段 | 来源命令 | 路径 |
|
||||
|------|---------|------|
|
||||
| `created_at` | `pr +list --state merged` | `data.issues[].pr_full_time` 或 `pr_created_unix` |
|
||||
| `merged_at` | `pr +view --id <pull_request_id>` | `data.pull_request.merged_at` |
|
||||
| 合并总数 / 合并率 / 积压数 | `pr +list` 汇总字段 | `data.close_count` 等(一次性获取,勿逐条钻取) |
|
||||
| `merged_at` | `pr +view --id <pull_request_number>` | `data.pull_request.merged_at` |
|
||||
|
||||
**注意**:`merged_at` 只在 `pr +view` 单个 PR 详情中暴露,列表接口不返回。需要对每个已合并 PR 调用一次详情接口。
|
||||
|
||||
> **绝对禁止**:使用「当前时间 - 创建时间」作为合并时间估算。这种做法会把开放中或近期合并的 PR 误判为"超长合并时间",与 PR 合并效率的真实含义相悖。
|
||||
> **效率关键(采样而非全量)**:`merged_at` 只在 `pr +view` 单个 PR 详情中暴露,列表接口不返回。但合并时长是统计量——**只对最近 ≤8 条已合并 PR 采样调用 `pr +view`** 即可求得稳定平均,无需遍历全部历史 PR。`pr +list --state merged` 默认按合并时间倒序,取列表前 ≤8 条作为样本。合并总数/合并率从 `pr +list` 汇总字段一次性取得。
|
||||
> **绝对禁止**:使用「当前时间 - 创建时间」作为已合并 PR 的合并时间估算(会把开放中或近期合并的 PR 误判为"超长合并时间")。开放 PR 用「当前时间 - 创建时间」是允许且必要的,等待时长反映积压。
|
||||
|
||||
## 指标 3:贡献者活跃度
|
||||
|
||||
|
|
|
|||
BIN
webhook-test.txt
BIN
webhook-test.txt
Binary file not shown.
|
|
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,26 @@
|
|||
# ----------------------------------------------------------------
|
||||
# Scenario 1: Community Operations Automation
|
||||
# Flow: Issue auto-classify -> Assign responsible -> Weekly report -> Release notes
|
||||
# Scenario 1: Community Operations Automation (定时批量链路)
|
||||
# Flow: 收集周期数据 → 10类关键词分类 → 生成 Release Notes + 社区周报
|
||||
# 遵循 gitlink-changelog skill 的收集→分类→发布流程
|
||||
# 条目格式: - 描述 (#编号) (@作者) [分类]
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
|
||||
param(
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[int]$WeeksAgo = 0,
|
||||
[int]$PeriodHours = 6,
|
||||
[string]$ReleaseVersion = "",
|
||||
[switch]$DryRun,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 01-community-ops.ps1 -Owner OWNER -Repo REPO [-WeeksAgo N] [-DryRun]"
|
||||
Write-Host "Usage: powershell 01-community-ops.ps1 [-Owner O] [-Repo R] [-PeriodHours N] [-ReleaseVersion TAG] [-DryRun]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
|
|
@ -24,302 +28,474 @@ Check-Auth
|
|||
$r = Resolve-OwnerRepo $Owner $Repo
|
||||
$Owner = $r.Owner; $Repo = $r.Repo
|
||||
|
||||
$BugKw = @('bug','error','crash','fault','fix')
|
||||
$FeatureKw = @('feature','enhancement','add','support','request')
|
||||
$QuestionKw = @('how','question','help')
|
||||
$DocsKw = @('doc','readme','guide','tutorial','example')
|
||||
# ================================================================
|
||||
# Phase 1: 时间窗口和基线
|
||||
# ================================================================
|
||||
Log-Title "Phase 1: Time Window"
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
Log-Title "Phase 1: Issue Auto-Classification"
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
Log-Step "Fetching open issues..."
|
||||
$issuesJson = Invoke-GLCheck "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "100"
|
||||
if (-not $issuesJson) { Log-Err "Failed to fetch issues"; exit 1 }
|
||||
$periodStart = (Get-Date).AddHours(-$PeriodHours)
|
||||
$periodEnd = Get-Date
|
||||
$periodStartStr = $periodStart.ToString("yyyy-MM-dd HH:mm")
|
||||
$periodEndStr = $periodEnd.ToString("yyyy-MM-dd HH:mm")
|
||||
|
||||
$issues = @($issuesJson.data.issues)
|
||||
$issueCount = $issues.Count
|
||||
Log-Ok "Found $issueCount open issues"
|
||||
|
||||
$BugIds = @(); $FeatureIds = @(); $QuestionIds = @(); $DocsIds = @()
|
||||
Log-Step "Finding previous release..."
|
||||
$prevTag = ""
|
||||
$releasesJson = Invoke-GL release,+list,--owner,$Owner,--repo,$Repo,--limit,5
|
||||
if ($releasesJson) {
|
||||
try {
|
||||
$relData = ($releasesJson | ConvertFrom-Json).data
|
||||
$releases = if ($relData.releases) { @($relData.releases) } elseif ($relData -is [array]) { @($relData) } else { @() }
|
||||
if ($releases.Count -gt 0) {
|
||||
$prevTag = if ($releases[0].tag_name) { $releases[0].tag_name } else { "" }
|
||||
|
||||
if ($issueCount -gt 0) {
|
||||
Log-Step "Classifying issues by content..."
|
||||
foreach ($issue in $issues) {
|
||||
$id = $issue.id
|
||||
$title = if ($issue.subject) { $issue.subject } elseif ($issue.title) { $issue.title } else { "" }
|
||||
$desc = if ($issue.description) { $issue.description } else { "" }
|
||||
$combined = "$title $desc".ToLower()
|
||||
|
||||
$classified = $false
|
||||
foreach ($kw in $BugKw) {
|
||||
if ($combined -match [regex]::Escape($kw)) { $BugIds += $id; Log-Info " #$id -> BUG: $title"; $classified = $true; break }
|
||||
}
|
||||
if (-not $classified) {
|
||||
foreach ($kw in $FeatureKw) {
|
||||
if ($combined -match [regex]::Escape($kw)) { $FeatureIds += $id; Log-Info " #$id -> FEATURE: $title"; $classified = $true; break }
|
||||
} catch {}
|
||||
}
|
||||
if (-not $prevTag) { $prevTag = "initial" }
|
||||
|
||||
$newVersion = if ($ReleaseVersion) { $ReleaseVersion } else { "weekly-$(Get-Date -Format 'yyyyMMdd')" }
|
||||
Log-Ok "Period: $periodStartStr ~ $periodEndStr"
|
||||
Log-Ok "Release: $prevTag -> $newVersion"
|
||||
|
||||
# ================================================================
|
||||
# Phase 2: 收集数据
|
||||
# ================================================================
|
||||
Log-Title "Phase 2: Collect Data"
|
||||
|
||||
# -- Commits --
|
||||
Log-Step "Collecting commits..."
|
||||
$commitCount = 0
|
||||
$commitList = "(无 commit 数据)"
|
||||
if ($prevTag -ne "initial") {
|
||||
$compareJson = Invoke-GL api,GET,"/v1/$Owner/$Repo/compare/$prevTag...master"
|
||||
if ($compareJson) {
|
||||
try {
|
||||
$cData = ($compareJson | ConvertFrom-Json).data
|
||||
if ($cData.commits) {
|
||||
$commits = @($cData.commits)
|
||||
$commitCount = $commits.Count
|
||||
$lines = @()
|
||||
$show = [Math]::Min($commitCount, 50)
|
||||
for ($i = 0; $i -lt $show; $i++) {
|
||||
$msg = if ($commits[$i].commit.message) { ($commits[$i].commit.message -split "`n")[0] } else { "N/A" }
|
||||
$author = if ($commits[$i].commit.author.name) { $commits[$i].commit.author.name } else { "unknown" }
|
||||
$lines += "- $msg ($author)"
|
||||
}
|
||||
$commitList = $lines -join "`n"
|
||||
}
|
||||
} catch {}
|
||||
|
||||
}
|
||||
}
|
||||
Log-Ok "Commits: $commitCount"
|
||||
|
||||
# -- Merged PRs --
|
||||
Log-Step "Collecting merged PRs..."
|
||||
$prItems = @()
|
||||
$prContributors = @{}
|
||||
$prsJson = Invoke-GL pr,+list,--owner,$Owner,--repo,$Repo,--state,merged,--limit,100
|
||||
if ($prsJson) {
|
||||
try {
|
||||
$prData = ($prsJson | ConvertFrom-Json).data
|
||||
$prs = if ($prData.issues) { @($prData.issues) } elseif ($prData.pulls) { @($prData.pulls) } elseif ($prData -is [array]) { @($prData) } else { @() }
|
||||
foreach ($pr in $prs) {
|
||||
$prTitle = if ($pr.name) { $pr.name } elseif ($pr.subject) { $pr.subject } elseif ($pr.title) { $pr.title } else { "N/A" }
|
||||
$prNum = if ($pr.pull_request_number) { $pr.pull_request_number } elseif ($pr.number) { $pr.number } else { "?" }
|
||||
$prAuthor = if ($pr.author_login) { $pr.author_login } elseif ($pr.author.login) { $pr.author.login } else { "?" }
|
||||
$prItems += "- $prTitle (#$prNum) (@$prAuthor)"
|
||||
$prContributors[$prAuthor] = $true
|
||||
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$prCount = $prItems.Count
|
||||
$prText = if ($prCount -gt 0) { ($prItems -join "`n") } else { "(本周期无 PR 合并)" }
|
||||
Log-Ok "Merged PRs: $prCount"
|
||||
|
||||
# -- Issues (新增 + 关闭) --
|
||||
Log-Step "Collecting issues..."
|
||||
$bugLines = @()
|
||||
$featLines = @()
|
||||
$docLines = @()
|
||||
$otherLines = @()
|
||||
$issContributors = @{}
|
||||
$issCount = 0
|
||||
# Dedupe by issue index: the same issue can be returned by both the open
|
||||
# and closed state queries, which would otherwise double-count it.
|
||||
$seenIssues = @{}
|
||||
$today = (Get-Date -Format "yyyy-MM-dd")
|
||||
$yesterday = ((Get-Date).AddDays(-1)).ToString("yyyy-MM-dd")
|
||||
|
||||
foreach ($state in @("open","closed")) {
|
||||
$issJson = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,100
|
||||
if (-not $issJson) { continue }
|
||||
try {
|
||||
$issData = ($issJson | ConvertFrom-Json).data
|
||||
$issues = if ($issData.issues) { @($issData.issues) } elseif ($issData -is [array]) { @($issData) } else { @() }
|
||||
foreach ($iss in $issues) {
|
||||
$num = if ($iss.project_issues_index) { $iss.project_issues_index } elseif ($iss.number) { $iss.number } else { "?" }
|
||||
if ($num -ne "?" -and $seenIssues.ContainsKey($num)) { continue }
|
||||
if ($num -ne "?") { $seenIssues[$num] = $true }
|
||||
$title = if ($iss.subject) { $iss.subject } elseif ($iss.title) { $iss.title } else { "N/A" }
|
||||
$desc = if ($iss.description) { $iss.description } else { "" }
|
||||
$author = if ($iss.author.login) { $iss.author.login } elseif ($iss.author.username) { $iss.author.username } else { "?" }
|
||||
$created = if ($iss.created_at) { $iss.created_at } else { "" }
|
||||
$closed = if ($iss.closed_at) { $iss.closed_at } else { "" }
|
||||
$stateName = if ($iss.status.name) { $iss.status.name } elseif ($iss.state) { $iss.state } else { "?" }
|
||||
|
||||
# 时间筛选:created 或 closed >= periodStart
|
||||
$inPeriod = $false
|
||||
if ($created) { try { if (([DateTime]$created) -ge $periodStart) { $inPeriod = $true } } catch {} }
|
||||
if (-not $inPeriod -and $closed) { try { if (([DateTime]$closed) -ge $periodStart) { $inPeriod = $true } } catch {} }
|
||||
if (-not $inPeriod) { continue }
|
||||
|
||||
$line = "- $title (#$num) (@$author)"
|
||||
if ($stateName -match "关闭|closed") { $line += " [已关闭]" }
|
||||
|
||||
# 标题+描述 关键词分类 (10 个标准类别)
|
||||
$combined = "$title $desc".ToLower()
|
||||
if ($combined -match '(?i)bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障') {
|
||||
$line += " [缺陷]"; $bugLines += $line
|
||||
} elseif ($combined -match '(?i)feature|enhancement|add|新增|建议|功能|特性|新功能|支持|request') {
|
||||
$line += " [功能]"; $featLines += $line
|
||||
} elseif ($combined -match '(?i)doc|readme|guide|wiki|tutorial|文档|说明|教程|手册') {
|
||||
$line += " [文档]"; $docLines += $line
|
||||
} elseif ($combined -match '(?i)test|测试|用例|覆盖|验证') {
|
||||
$line += " [测试]"; $docLines += $line
|
||||
} elseif ($combined -match '(?i)duplicate|重复|重复的') {
|
||||
$line += " [重复]"; $docLines += $line
|
||||
} elseif ($combined -match '(?i)question|疑问|不确定|讨论|澄清|是否|可否') {
|
||||
$line += " [疑问]"; $docLines += $line
|
||||
} elseif ($combined -match '(?i)help|协助|帮助|协作|请求帮助|互助') {
|
||||
$line += " [协助]"; $docLines += $line
|
||||
} elseif ($combined -match '(?i)postpone|wontfix|暂缓|搁置|低优|不重要|不紧急|暂不|delay') {
|
||||
$line += " [搁置]"; $docLines += $line
|
||||
} elseif ($combined -match '(?i)task|todo|任务|待办|计划|安排') {
|
||||
$line += " [任务]"; $docLines += $line
|
||||
} elseif ($combined -match '(?i)support|兼容|环境|依赖|平台|适配') {
|
||||
$line += " [支持]"; $docLines += $line
|
||||
} else {
|
||||
$line += " [其他]"; $otherLines += $line
|
||||
}
|
||||
|
||||
if ($author -ne "?") { $issContributors[$author] = $true }
|
||||
$issCount++
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$bugSection = if ($bugLines.Count -gt 0) { ($bugLines -join "`n") } else { "_无_" }
|
||||
$featSection = if ($featLines.Count -gt 0) { ($featLines -join "`n") } else { "_无_" }
|
||||
$docSection = if ($docLines.Count -gt 0) { ($docLines -join "`n") } else { "_无_" }
|
||||
$otherSection = if ($otherLines.Count -gt 0) { ($otherLines -join "`n") } else { "_无_" }
|
||||
Log-Ok "Issues in period: $issCount"
|
||||
|
||||
# -- 动态收集标签 ID 映射 (与 01a-issue-triage.ps1 一致) --
|
||||
Log-Step "Discovering label IDs..."
|
||||
$tagIdMap = @{}
|
||||
|
||||
# 优先从项目 issue_tags API 获取
|
||||
$tagsApi = Invoke-GL api,GET,"/v1/$Owner/$Repo/issue_tags"
|
||||
if ($tagsApi) {
|
||||
try {
|
||||
$tData = ($tagsApi | ConvertFrom-Json).data
|
||||
$tagList = if ($tData.issue_tags) { @($tData.issue_tags) } elseif ($tData -is [array]) { @($tData) } else { @() }
|
||||
foreach ($t in $tagList) {
|
||||
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# 补充:从已有 issue 的 tags 字段收集
|
||||
foreach ($state in @("open","closed")) {
|
||||
$sample = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,50
|
||||
if ($sample) {
|
||||
try {
|
||||
$sData = ($sample | ConvertFrom-Json).data
|
||||
$sIssues = if ($sData.issues) { @($sData.issues) } elseif ($sData -is [array]) { @($sData) } else { @() }
|
||||
foreach ($iss in $sIssues) {
|
||||
if ($iss.tags) {
|
||||
foreach ($t in $iss.tags) {
|
||||
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
$tagFound = @()
|
||||
foreach ($k in $tagIdMap.Keys) { $tagFound += "$k($($tagIdMap[$k]))" }
|
||||
if ($tagFound.Count -gt 0) { Log-Ok "Found tags: $($tagFound -join ' ')" } else { Log-Warn "No tags found in repo" }
|
||||
|
||||
# -- 打标签 & 分配责任人 (自动分类后的实际写入) --
|
||||
# 收集需要处理的 Issue 信息 (编号, issue ID, 标签名, 作者)
|
||||
$triageItems = @()
|
||||
$seenTriage = @{}
|
||||
|
||||
foreach ($state in @("open","closed")) {
|
||||
$issJson2 = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,100
|
||||
if (-not $issJson2) { continue }
|
||||
try {
|
||||
$issData2 = ($issJson2 | ConvertFrom-Json).data
|
||||
$issues2 = if ($issData2.issues) { @($issData2.issues) } elseif ($issData2 -is [array]) { @($issData2) } else { @() }
|
||||
foreach ($iss in $issues2) {
|
||||
$num = if ($iss.project_issues_index) { $iss.project_issues_index } elseif ($iss.number) { $iss.number } else { "?" }
|
||||
if ($num -eq "?" -or $seenTriage.ContainsKey($num)) { continue }
|
||||
$created = if ($iss.created_at) { $iss.created_at } else { "" }
|
||||
$closed = if ($iss.closed_at) { $iss.closed_at } else { "" }
|
||||
$inPeriod = $false
|
||||
if ($created) { try { if (([DateTime]$created) -ge $periodStart) { $inPeriod = $true } } catch {} }
|
||||
if (-not $inPeriod -and $closed) { try { if (([DateTime]$closed) -ge $periodStart) { $inPeriod = $true } } catch {} }
|
||||
if (-not $inPeriod) { continue }
|
||||
|
||||
$seenTriage[$num] = $true
|
||||
$title2 = if ($iss.subject) { $iss.subject } elseif ($iss.title) { $iss.title } else { "" }
|
||||
$desc2 = if ($iss.description) { $iss.description } else { "" }
|
||||
$author2 = if ($iss.author.login) { $iss.author.login } elseif ($iss.author.username) { $iss.author.username } else { "" }
|
||||
$issueId2 = if ($iss.id) { $iss.id } else { $num }
|
||||
|
||||
# 检查是否已有标签
|
||||
$hasLabel = $false
|
||||
if ($iss.tags -and @($iss.tags).Count -gt 0) { $hasLabel = $true }
|
||||
|
||||
# 关键词分类
|
||||
$combined2 = "$title2 $desc2".ToLower()
|
||||
$chosenLabel = ""
|
||||
if ($combined2 -match '(?i)bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障') {
|
||||
$chosenLabel = "缺陷"
|
||||
} elseif ($combined2 -match '(?i)feature|enhancement|add|新增|建议|功能|特性|新功能|支持|request') {
|
||||
$chosenLabel = "功能"
|
||||
} elseif ($combined2 -match '(?i)doc|readme|guide|wiki|tutorial|文档|说明|教程|手册') {
|
||||
$chosenLabel = "文档"
|
||||
} elseif ($combined2 -match '(?i)test|测试|用例|覆盖|验证') {
|
||||
$chosenLabel = "测试"
|
||||
} elseif ($combined2 -match '(?i)duplicate|重复|重复的') {
|
||||
$chosenLabel = "重复"
|
||||
} elseif ($combined2 -match '(?i)question|疑问|不确定|讨论|澄清|是否|可否') {
|
||||
$chosenLabel = "疑问"
|
||||
} elseif ($combined2 -match '(?i)help|协助|帮助|协作|请求帮助|互助') {
|
||||
$chosenLabel = "协助"
|
||||
} elseif ($combined2 -match '(?i)postpone|wontfix|暂缓|搁置|低优|不重要|不紧急|暂不|delay') {
|
||||
$chosenLabel = "搁置"
|
||||
} elseif ($combined2 -match '(?i)task|todo|任务|待办|计划|安排') {
|
||||
$chosenLabel = "任务"
|
||||
} elseif ($combined2 -match '(?i)support|兼容|环境|依赖|平台|适配') {
|
||||
$chosenLabel = "支持"
|
||||
}
|
||||
|
||||
# 检查是否已有负责人
|
||||
$hasAssignee = $false
|
||||
if ($iss.assigned_to_id -and "$($iss.assigned_to_id)" -ne "0" -and "$($iss.assigned_to_id)" -ne "") { $hasAssignee = $true }
|
||||
if ($iss.assigned_to -and $iss.assigned_to.login) { $hasAssignee = $true }
|
||||
|
||||
if ($chosenLabel -or -not $hasAssignee) {
|
||||
$triageItems += @{ num=$num; id=$issueId2; title=$title2; desc=$desc2; author=$author2; label=$chosenLabel; hasLabel=$hasLabel; hasAssignee=$hasAssignee }
|
||||
}
|
||||
}
|
||||
if (-not $classified) {
|
||||
foreach ($kw in $QuestionKw) {
|
||||
if ($combined -match [regex]::Escape($kw)) { $QuestionIds += $id; Log-Info " #$id -> QUESTION: $title"; $classified = $true; break }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$labelOk = 0; $labelFail = 0; $labelSkip = 0
|
||||
$assignOk = 0; $assignFail = 0; $assignSkip = 0
|
||||
|
||||
if ($triageItems.Count -gt 0) {
|
||||
Log-Step "Applying labels and assignees to $($triageItems.Count) issue(s)..."
|
||||
foreach ($item in $triageItems) {
|
||||
# -- 打标签 --
|
||||
if (-not $item.label) {
|
||||
$labelSkip++
|
||||
} elseif ($item.hasLabel) {
|
||||
Log-Info " #$($item.num) already has label, skipping"
|
||||
$labelSkip++
|
||||
} elseif ($DryRun) {
|
||||
Log-Warn " [DRY RUN] Would tag #$($item.num) with '$($item.label)'"
|
||||
$labelSkip++
|
||||
} else {
|
||||
$tgtId = $tagIdMap[$item.label]
|
||||
if (-not $tgtId) {
|
||||
Log-Warn " #$($item.num): label '$($item.label)' not found in repo tags — create on website first"
|
||||
$labelFail++
|
||||
} else {
|
||||
$bodyJson = "{`"issue_tag_ids`":[$tgtId]}"
|
||||
$tagResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$($item.id)",--body,$bodyJson
|
||||
if ($tagResult) {
|
||||
try {
|
||||
$tagOkFlag = (($tagResult | ConvertFrom-Json).ok -eq $true)
|
||||
} catch { $tagOkFlag = $false }
|
||||
if ($tagOkFlag) { Log-Ok " #$($item.num) tagged: $($item.label)"; $labelOk++ } else { Log-Warn " #$($item.num) tag failed"; $labelFail++ }
|
||||
} else { Log-Warn " #$($item.num) tag failed (no response)"; $labelFail++ }
|
||||
}
|
||||
}
|
||||
if (-not $classified) {
|
||||
foreach ($kw in $DocsKw) {
|
||||
if ($combined -match [regex]::Escape($kw)) { $DocsIds += $id; Log-Info " #$id -> DOCS: $title"; $classified = $true; break }
|
||||
|
||||
# -- 分配责任人 (默认分配给 Issue 作者) --
|
||||
if ($item.hasAssignee) {
|
||||
$assignSkip++
|
||||
} elseif (-not $item.author -or $item.author -eq "?") {
|
||||
Log-Info " #$($item.num): no author info, skipping assign"
|
||||
$assignSkip++
|
||||
} elseif ($DryRun) {
|
||||
Log-Warn " [DRY RUN] Would assign #$($item.num) to @$($item.author)"
|
||||
$assignSkip++
|
||||
} else {
|
||||
# Resolve login name to numeric user ID
|
||||
$authorID = $null
|
||||
$isNum = $false
|
||||
try { [void][int]$item.author; $isNum = $true } catch {}
|
||||
if ($isNum) {
|
||||
$authorID = $item.author
|
||||
} else {
|
||||
$userJson = Invoke-GL api,GET,"/users/$($item.author)"
|
||||
if ($userJson) {
|
||||
try {
|
||||
$userData = ($userJson | ConvertFrom-Json).data
|
||||
if ($userData.id) { $authorID = [int]$userData.id }
|
||||
elseif ($userData.user_id) { $authorID = [int]$userData.user_id }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (-not $authorID) {
|
||||
Log-Warn " #$($item.num): cannot resolve user ID for '@$($item.author)'"
|
||||
$assignFail++
|
||||
} else {
|
||||
$bodyJson = "{`"assigner_ids`":[$authorID]}"
|
||||
$assignResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$($item.id)",--body,$bodyJson
|
||||
if ($assignResult) {
|
||||
try {
|
||||
$assignOkFlag = (($assignResult | ConvertFrom-Json).ok -eq $true)
|
||||
} catch { $assignOkFlag = $false }
|
||||
if ($assignOkFlag) { Log-Ok " #$($item.num) assigned to @$($item.author) (ID:$authorID)"; $assignOk++ } else { Log-Warn " #$($item.num) assign failed"; $assignFail++ }
|
||||
} else { Log-Warn " #$($item.num) assign failed (no response)"; $assignFail++ }
|
||||
}
|
||||
}
|
||||
if (-not $classified) { Log-Info " #$id -> UNCATEGORIZED: $title" }
|
||||
}
|
||||
|
||||
Divider
|
||||
Log-Info "Classification summary:"
|
||||
Log-Info " Bugs: $($BugIds.Count)"
|
||||
Log-Info " Features: $($FeatureIds.Count)"
|
||||
Log-Info " Questions: $($QuestionIds.Count)"
|
||||
Log-Info " Docs: $($DocsIds.Count)"
|
||||
|
||||
$labelGroups = @(
|
||||
@{ Ids = $BugIds; Label = "bug" },
|
||||
@{ Ids = $FeatureIds; Label = "feature" },
|
||||
@{ Ids = $QuestionIds; Label = "question" },
|
||||
@{ Ids = $DocsIds; Label = "documentation" }
|
||||
)
|
||||
foreach ($g in $labelGroups) {
|
||||
if ($g.Ids.Count -gt 0) {
|
||||
Log-Step "Labeling $($g.Label) issues..."
|
||||
foreach ($id in $g.Ids) {
|
||||
Invoke-GL "issue", "+label-add", "--owner", $Owner, "--repo", $Repo, "--number", $id, "--labels", $g.Label | Out-Null
|
||||
}
|
||||
Log-Ok "Labeled $($g.Ids.Count) $($g.Label) issues"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
Log-Title "Phase 2: Assign Responsible Persons"
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
Log-Step "Fetching repo members..."
|
||||
$membersJson = Invoke-GL "repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "50"
|
||||
$members = @()
|
||||
if ($membersJson) {
|
||||
$md = $membersJson.data
|
||||
if ($md.members) { $members = @($md.members) }
|
||||
elseif ($md -is [array]) { $members = $md }
|
||||
}
|
||||
|
||||
$memberLogins = @()
|
||||
foreach ($m in $members) {
|
||||
$login = if ($m.login) { $m.login } elseif ($m.username) { $m.username } else { $null }
|
||||
if ($login) { $memberLogins += $login }
|
||||
}
|
||||
|
||||
if ($memberLogins.Count -gt 0) {
|
||||
$assignIds = @($BugIds + $FeatureIds)
|
||||
if ($assignIds.Count -gt 0) {
|
||||
Log-Step "Assigning issues to members (round-robin)..."
|
||||
$idx = 0
|
||||
foreach ($id in $assignIds) {
|
||||
$assignee = $memberLogins[$idx % $memberLogins.Count]
|
||||
$bodyJson = "{`"assigned_to_id`": `"$assignee`"}"
|
||||
Invoke-GL "api", "PATCH", "/v1/$Owner/$Repo/issues/$id", "--body", $bodyJson | Out-Null
|
||||
Log-Info " Assigned #$id -> @$assignee"
|
||||
$idx++
|
||||
}
|
||||
Log-Ok "Assignment complete"
|
||||
}
|
||||
Log-Ok "Labels: $labelOk applied, $labelFail failed, $labelSkip skipped"
|
||||
Log-Ok "Assign: $assignOk applied, $assignFail failed, $assignSkip skipped"
|
||||
} else {
|
||||
Log-Warn "No repo members found, skipping assignment"
|
||||
Log-Info "No new issues require triage in this period"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
Log-Title "Phase 3: Generate Community Weekly Report"
|
||||
# ----------------------------------------------------------------
|
||||
# -- 贡献者汇总 --
|
||||
$allContribs = @($prContributors.Keys; $issContributors.Keys) | Select-Object -Unique | Sort-Object
|
||||
$contribText = if ($allContribs.Count -gt 0) { ($allContribs | ForEach-Object { "- @$_" }) -join "`n" } else { "(无活跃贡献者)" }
|
||||
|
||||
$weekStart = (Get-Date).AddDays(-$WeeksAgo * 7).ToString("yyyy-MM-dd")
|
||||
$weekEnd = Get-DateToday
|
||||
# ================================================================
|
||||
# Phase 3: 生成 Release Notes (changelog skill 模板)
|
||||
# ================================================================
|
||||
Log-Title "Phase 3: Generate Release Notes"
|
||||
|
||||
Log-Step "Collecting weekly data (week of $weekStart)..."
|
||||
$releaseBody = @"
|
||||
# 🎉 Release $newVersion
|
||||
|
||||
$closedJson = Invoke-GL "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100"
|
||||
$closedCount = if ($closedJson) { @($closedJson.data.issues).Count } else { 0 }
|
||||
## 📊变更统计
|
||||
- **周期**: $periodStartStr ~ $periodEndStr
|
||||
- **已合并 PR**: $prCount 个
|
||||
- **Issue 活动**: $issCount 条(新增/关闭)
|
||||
- **Commits**: $commitCount 条
|
||||
|
||||
$mergedJson = Invoke-GL "pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100"
|
||||
$mergedData = @()
|
||||
$mergedCount = 0
|
||||
if ($mergedJson) {
|
||||
$d = $mergedJson.data
|
||||
if ($d.issues) { $mergedData = @($d.issues) }
|
||||
elseif ($d.pulls) { $mergedData = @($d.pulls) }
|
||||
elseif ($d -is [array]) { $mergedData = $d }
|
||||
$mergedCount = $mergedData.Count
|
||||
}
|
||||
## 📝 Issue 活动
|
||||
|
||||
$newIssuesCount = $issueCount
|
||||
$totalClassified = $BugIds.Count + $FeatureIds.Count + $QuestionIds.Count + $DocsIds.Count
|
||||
### 🐛 缺陷 / Bug
|
||||
$bugSection
|
||||
|
||||
$reportTitle = "Community Weekly Report: $weekStart ~ $weekEnd"
|
||||
$reportBody = "# $reportTitle" + "`n`n"
|
||||
$reportBody += "## 概览" + "`n"
|
||||
$reportBody += "- 仓库: **$Owner/$Repo**" + "`n"
|
||||
$reportBody += "- 当前开放 Issue: **$newIssuesCount**" + "`n"
|
||||
$reportBody += "- 已关闭 Issue: **$closedCount**" + "`n"
|
||||
$reportBody += "- 已合并 PR: **$mergedCount**" + "`n`n"
|
||||
### ✨ 新功能 / Feature
|
||||
$featSection
|
||||
|
||||
# 分类汇总
|
||||
$reportBody += "## Issue 分类汇总" + "`n"
|
||||
$reportBody += "| 类型 | 数量 |" + "`n"
|
||||
$reportBody += "|------|------|" + "`n"
|
||||
$reportBody += "| Bug | $($BugIds.Count) |" + "`n"
|
||||
$reportBody += "| Feature | $($FeatureIds.Count) |" + "`n"
|
||||
$reportBody += "| Question | $($QuestionIds.Count) |" + "`n"
|
||||
$reportBody += "| Docs | $($DocsIds.Count) |" + "`n`n"
|
||||
### 📖 文档 / 其他
|
||||
$docSection
|
||||
|
||||
# 开放 Issue 清单(含分类标记)
|
||||
$reportBody += "## 当前开放 Issue 清单" + "`n"
|
||||
$reportBody += "| # | 标题 | 分类 | 创建时间 |" + "`n"
|
||||
$reportBody += "|---|------|------|----------|" + "`n"
|
||||
foreach ($issue in $issues) {
|
||||
$id = $issue.id
|
||||
$title = if ($issue.subject) { $issue.subject } elseif ($issue.title) { $issue.title } else { "-" }
|
||||
$title = ($title -replace '\|', '\\|')
|
||||
$cat = if ($BugIds -contains $id) { "Bug" }
|
||||
elseif ($FeatureIds -contains $id) { "Feature" }
|
||||
elseif ($QuestionIds -contains $id) { "Question" }
|
||||
elseif ($DocsIds -contains $id) { "Docs" }
|
||||
else { "-" }
|
||||
$created = if ($issue.created_at) { $issue.created_at } else { "-" }
|
||||
$reportBody += "| #$id | $title | $cat | $created |" + "`n"
|
||||
}
|
||||
$reportBody += "`n"
|
||||
### 💡 其他
|
||||
$otherSection
|
||||
|
||||
# 已关闭 Issue 清单
|
||||
$reportBody += "## 近期已关闭 Issue" + "`n"
|
||||
if ($closedCount -gt 0) {
|
||||
$closedIssues = @($closedJson.data.issues)
|
||||
$closedLimit = [Math]::Min($closedCount, 15)
|
||||
$reportBody += "| # | 标题 |" + "`n"
|
||||
$reportBody += "|---|------|" + "`n"
|
||||
for ($i = 0; $i -lt $closedLimit; $i++) {
|
||||
$it = $closedIssues[$i]
|
||||
$cid = if ($it.id) { $it.id } elseif ($it.number) { $it.number } else { "-" }
|
||||
$ctitle = if ($it.subject) { $it.subject } elseif ($it.title) { $it.title } else { "-" }
|
||||
$ctitle = ($ctitle -replace '\|', '\\|')
|
||||
$reportBody += "| #$cid | $ctitle |" + "`n"
|
||||
}
|
||||
if ($closedCount -gt $closedLimit) {
|
||||
$reportBody += "| ... | 还有 $($closedCount - $closedLimit) 条 |`n"
|
||||
}
|
||||
} else {
|
||||
$reportBody += "_本周无关闭记录_" + "`n"
|
||||
}
|
||||
$reportBody += "`n"
|
||||
## 🔀 已合并 PR
|
||||
$prText
|
||||
|
||||
# 已合并 PR 清单(含作者)
|
||||
$reportBody += "## 近期已合并 PR" + "`n"
|
||||
if ($mergedCount -gt 0) {
|
||||
$prLimit = [Math]::Min($mergedCount, 15)
|
||||
$reportBody += "| # | 标题 | 作者 |" + "`n"
|
||||
$reportBody += "|---|------|------|" + "`n"
|
||||
for ($i = 0; $i -lt $prLimit; $i++) {
|
||||
$pr = $mergedData[$i]
|
||||
$prId = if ($pr.id) { $pr.id } elseif ($pr.number) { $pr.number } else { "-" }
|
||||
$ptitle = if ($pr.subject) { $pr.subject } elseif ($pr.title) { $pr.title } else { "-" }
|
||||
$ptitle = ($ptitle -replace '\|', '\\|')
|
||||
$pauthor = if ($pr.author -and $pr.author.login) { $pr.author.login } elseif ($pr.user -and $pr.user.login) { $pr.user.login } else { "-" }
|
||||
$reportBody += "| #$prId | $ptitle | @$pauthor |" + "`n"
|
||||
}
|
||||
if ($mergedCount -gt $prLimit) {
|
||||
$reportBody += "| ... | 还有 $($mergedCount - $prLimit) 条合并 PR |`n"
|
||||
}
|
||||
} else {
|
||||
$reportBody += "_本周无合并记录_" + "`n"
|
||||
}
|
||||
$reportBody += "`n"
|
||||
## 🙏 贡献者
|
||||
$contribText
|
||||
|
||||
# 贡献者排行(按合并 PR 数)
|
||||
$reportBody += "## 贡献者排行(按合并 PR 数)" + "`n"
|
||||
if ($mergedCount -gt 0) {
|
||||
$contributorMap = @{}
|
||||
foreach ($pr in $mergedData) {
|
||||
$login = if ($pr.author -and $pr.author.login) { $pr.author.login } elseif ($pr.user -and $pr.user.login) { $pr.user.login } else { $null }
|
||||
if ($login) {
|
||||
if ($contributorMap.ContainsKey($login)) { $contributorMap[$login]++ }
|
||||
else { $contributorMap[$login] = 1 }
|
||||
}
|
||||
}
|
||||
$reportBody += "| 排名 | 贡献者 | 合并 PR 数 |" + "`n"
|
||||
$reportBody += "|------|--------|------------|" + "`n"
|
||||
$rank = 1
|
||||
foreach ($kv in ($contributorMap.GetEnumerator() | Sort-Object Value -Descending)) {
|
||||
$reportBody += "| $rank | @$($kv.Name) | $($kv.Value) |" + "`n"
|
||||
$rank++
|
||||
}
|
||||
} else {
|
||||
$reportBody += "_本周无合并记录_" + "`n"
|
||||
}
|
||||
$reportBody += "`n"
|
||||
---
|
||||
**完整变更日志**: https://www.gitlink.org.cn/$Owner/$Repo/compare/$prevTag...$newVersion
|
||||
|
||||
$reportBody += "## 本周自动化执行" + "`n"
|
||||
$reportBody += "- 自动分类并打标 Issue: **$totalClassified** 条" + "`n"
|
||||
$reportBody += "- 已为 Bug/Feature 类 Issue 指派负责人" + "`n`n"
|
||||
$reportBody += "---" + "`n"
|
||||
$reportBody += "*Auto-generated by gitlink-cli community-ops workflow*"
|
||||
*Auto-generated by gitlink-cli community-ops workflow (gitlink-changelog skill)*
|
||||
"@
|
||||
|
||||
Log-Ok "Weekly report generated"
|
||||
Write-Host $releaseBody
|
||||
Write-Host ""
|
||||
Write-Host $reportBody
|
||||
|
||||
Log-Step "Publishing weekly report to Wiki..."
|
||||
$wikiResult = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $reportTitle, "--content", $reportBody
|
||||
if ($wikiResult -and $wikiResult.ok) { Log-Ok "Weekly report published to Wiki" } else { Log-Warn "Wiki publish may have failed" }
|
||||
# ================================================================
|
||||
# Phase 4: 发布 Release
|
||||
# ================================================================
|
||||
Log-Title "Phase 4: Publish Release"
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
Log-Title "Phase 4: Auto-Publish Release Notes"
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
Log-Step "Collecting recent changes for release notes..."
|
||||
|
||||
$tagName = "weekly-$(Get-Date -Format 'yyyyMMdd')"
|
||||
$releaseName = "Weekly Release $(Get-Date -Format 'yyyy-MM-dd')"
|
||||
|
||||
$releaseBody = "# Release Notes - $(Get-Date -Format 'yyyy-MM-dd')" + "`n`n"
|
||||
$releaseBody += "## Merged PRs ($mergedCount)"
|
||||
|
||||
if ($mergedCount -gt 0) {
|
||||
$limit = [Math]::Min($mergedCount, 10)
|
||||
for ($i = 0; $i -lt $limit; $i++) {
|
||||
$prTitle = if ($mergedData[$i].subject) { $mergedData[$i].subject } elseif ($mergedData[$i].title) { $mergedData[$i].title } else { "" }
|
||||
$prNum = if ($mergedData[$i].id) { $mergedData[$i].id } elseif ($mergedData[$i].number) { $mergedData[$i].number } else { "" }
|
||||
$releaseBody += "`n- #$prNum $prTitle"
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would create release: $newVersion"
|
||||
} else {
|
||||
Log-Step "Creating release $newVersion..."
|
||||
$relResult = Invoke-GL release,+create,--owner,$Owner,--repo,$Repo,--tag,$newVersion,--name,"Release $newVersion",--body,$releaseBody
|
||||
if ($relResult -and (Get-JsonOk ($relResult | ConvertFrom-Json))) {
|
||||
Log-Ok "Release $newVersion published!"
|
||||
Log-Info "View: https://www.gitlink.org.cn/$Owner/$Repo/releases"
|
||||
} else {
|
||||
Log-Warn "Release may have failed (tag might exist)"
|
||||
}
|
||||
}
|
||||
|
||||
$releaseBody += "`n`n## Closed Issues ($closedCount)"
|
||||
if ($closedCount -gt 0) {
|
||||
$closedIssues = @($closedJson.data.issues)
|
||||
$limit = [Math]::Min($closedCount, 10)
|
||||
for ($i = 0; $i -lt $limit; $i++) {
|
||||
$issueTitle = if ($closedIssues[$i].subject) { $closedIssues[$i].subject } elseif ($closedIssues[$i].title) { $closedIssues[$i].title } else { "" }
|
||||
$issueNum = if ($closedIssues[$i].number) { $closedIssues[$i].number } elseif ($closedIssues[$i].id) { $closedIssues[$i].id } else { "" }
|
||||
if ($issueTitle) { $releaseBody += "`n- #$issueNum $issueTitle" }
|
||||
# ================================================================
|
||||
# Phase 5: 发布社区周报 (覆盖更新同一天)
|
||||
# ================================================================
|
||||
Log-Title "Phase 5: Publish Weekly Report"
|
||||
|
||||
$wikiTitle = "社区周报"
|
||||
$wikiBody = @"
|
||||
# 社区周报 - $Owner/$Repo
|
||||
|
||||
**$periodStartStr ~ $periodEndStr**
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据总览
|
||||
- 已合并 PR: **$prCount** 个
|
||||
- Issue 活动: **$issCount** 条
|
||||
- 贡献者: $($allContribs.Count)
|
||||
|
||||
## 🔀 合并的 PR
|
||||
$prText
|
||||
|
||||
## 📝 Issue 动态
|
||||
$bugSection
|
||||
$featSection
|
||||
$docSection
|
||||
$otherSection
|
||||
|
||||
---
|
||||
|
||||
*Auto-generated by gitlink-cli | $periodStartStr ~ $periodEndStr | Next report in ~$PeriodHours h*
|
||||
"@
|
||||
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would publish Wiki"
|
||||
} else {
|
||||
Log-Step "Publishing to Wiki..."
|
||||
# 先尝试覆盖更新,不存在则新建
|
||||
$wikiResult = Invoke-GL wiki,+update,--owner,$Owner,--repo,$Repo,--title,$wikiTitle,--cover,$wikiBody
|
||||
if ($wikiResult -and (Get-JsonOk ($wikiResult | ConvertFrom-Json))) {
|
||||
Log-Ok "Weekly report updated on Wiki"
|
||||
} else {
|
||||
Log-Info "Page not found, creating new..."
|
||||
$wikiResult = Invoke-GL wiki,+create,--owner,$Owner,--repo,$Repo,--title,$wikiTitle,--content,$wikiBody
|
||||
if ($wikiResult -and (Get-JsonOk ($wikiResult | ConvertFrom-Json))) {
|
||||
Log-Ok "Weekly report created on Wiki"
|
||||
} else {
|
||||
Log-Warn "Wiki publish failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$releaseBody += "`n`n---`n*Auto-generated by gitlink-cli community-ops workflow*"
|
||||
|
||||
Log-Step "Creating release: $tagName..."
|
||||
$releaseResult = Invoke-GL "release", "+create", "--owner", $Owner, "--repo", $Repo, "--tag", $tagName, "--name", $releaseName, "--body", $releaseBody
|
||||
if ($releaseResult -and $releaseResult.ok) { Log-Ok "Release $tagName created successfully" } else { Log-Warn "Release creation may have failed (tag might already exist)" }
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
Log-Title "Community Operations Complete"
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
Write-Host " Issues classified: $totalClassified" -ForegroundColor Green
|
||||
Write-Host " Closed this week: $closedCount" -ForegroundColor Green
|
||||
Write-Host " Merged PRs: $mergedCount" -ForegroundColor Green
|
||||
Write-Host " Weekly report: Published to Wiki" -ForegroundColor Green
|
||||
Write-Host " Release notes: $tagName" -ForegroundColor Green
|
||||
Log-Title "Complete"
|
||||
Write-Host " Release: $newVersion" -ForegroundColor Green
|
||||
Write-Host " PRs merged: $prCount" -ForegroundColor Green
|
||||
Write-Host " Issues: $issCount" -ForegroundColor Green
|
||||
Write-Host " Labels: $labelOk applied / $labelFail failed / $labelSkip skipped" -ForegroundColor Green
|
||||
Write-Host " Assigned: $assignOk applied / $assignFail failed / $assignSkip skipped" -ForegroundColor Green
|
||||
Write-Host " Contributors: $($allContribs.Count)" -ForegroundColor Green
|
||||
Write-Host " Wiki: $wikiTitle" -ForegroundColor Green
|
||||
|
|
|
|||
|
|
@ -1,302 +1,327 @@
|
|||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Scenario 1: Community Operations Automation
|
||||
# Flow: Issue auto-classify → Assign responsible → Weekly report → Release notes
|
||||
# ================================================================
|
||||
# Scenario 1: Community Operations (定时批量链路, 48h周期)
|
||||
# Flow: 收集周期数据 → 分类 → 生成 Release Notes → 发布 Release + Wiki
|
||||
#
|
||||
# Commands/Skills chained:
|
||||
# 1. issue +list -- fetch open issues
|
||||
# 2. issue +view -- read issue details
|
||||
# 3. issue +batch-label -- add classification labels
|
||||
# 4. issue +batch-assign -- assign responsible person
|
||||
# 5. pr +list -- collect merged PRs for weekly report
|
||||
# 6. wiki +create -- publish community weekly report
|
||||
# 7. release +create -- publish release notes
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# 遵循 gitlink-changelog skill 的收集→分类→发布流程
|
||||
# 条目格式: - 描述 (#编号) (@作者)
|
||||
# ================================================================
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
PERIOD_HOURS=48
|
||||
RELEASE_VERSION=""
|
||||
OWNER=""
|
||||
REPO=""
|
||||
DRY_RUN=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --owner OWNER --repo REPO [--week WEEKS_AGO] [--dry-run]"
|
||||
echo "Usage: $0 [--owner O] [--repo R] [--period-hours N] [--release-version TAG] [--dry-run]"
|
||||
echo ""
|
||||
echo " --owner OWNER Repository owner (org or user)"
|
||||
echo " --repo REPO Repository name"
|
||||
echo " --week N Generate report for N weeks ago (default: 0 = this week)"
|
||||
echo " --dry-run Preview actions without executing"
|
||||
echo " 定时链路 — 收集周期内数据,按 gitlink-changelog skill 生成 Release Notes 和社区周报。"
|
||||
exit 1
|
||||
}
|
||||
|
||||
WEEKS_AGO=0
|
||||
DRY_RUN=false
|
||||
OWNER=""
|
||||
REPO=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--week) WEEKS_AGO="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) log_err "Unknown arg: $1"; usage ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--period-hours) PERIOD_HOURS="$2"; shift 2 ;;
|
||||
--release-version) RELEASE_VERSION="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) log_err "Unknown arg: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
check_auth
|
||||
require_owner_repo
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
log_title "Phase 1: Issue Auto-Classification"
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# ================================================================
|
||||
# Phase 1: 确定时间窗口和基线
|
||||
# ================================================================
|
||||
log_title "Phase 1: Time Window"
|
||||
|
||||
log_step "Fetching open issues..."
|
||||
ISSUES_JSON=$(gl_check issue +list --owner "$OWNER" --repo "$REPO" --state open --limit 100)
|
||||
ISSUE_COUNT=$(echo "$ISSUES_JSON" | jq '.data.issues | length')
|
||||
log_ok "Found $ISSUE_COUNT open issues"
|
||||
PERIOD_START=$(date -d "$PERIOD_HOURS hours ago" +"%Y-%m-%d %H:%M" 2>/dev/null || date -v-${PERIOD_HOURS}H +"%Y-%m-%d %H:%M")
|
||||
PERIOD_END=$(date +"%Y-%m-%d %H:%M")
|
||||
PERIOD_START_TS=$(date -d "$PERIOD_START" +%s 2>/dev/null || date -j -f "%Y-%m-%d %H:%M" "$PERIOD_START" +%s)
|
||||
|
||||
if [[ "$ISSUE_COUNT" -gt 0 ]]; then
|
||||
# Classify each issue by keywords in title/description
|
||||
declare -A LABEL_MAP=()
|
||||
BUG_IDS=()
|
||||
FEATURE_IDS=()
|
||||
QUESTION_IDS=()
|
||||
DOCS_IDS=()
|
||||
# 找上一版本
|
||||
log_step "Finding previous release..."
|
||||
PREV_TAG=""
|
||||
PREV_DATE=""
|
||||
RELEASES_JSON=$(gl_run release +list --owner "$OWNER" --repo "$REPO" --limit 5 2>/dev/null || true)
|
||||
if [[ -n "$RELEASES_JSON" ]]; then
|
||||
PREV_TAG=$(echo "$RELEASES_JSON" | jq -r '(.data.releases // .data // [])[0].tag_name // ""' 2>/dev/null || echo "")
|
||||
PREV_DATE=$(echo "$RELEASES_JSON" | jq -r '(.data.releases // .data // [])[0].created_at // ""' 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
log_step "Classifying issues by content..."
|
||||
if [[ -z "$PREV_TAG" || "$PREV_TAG" == "null" ]]; then
|
||||
log_info "No previous release — this will be the first"
|
||||
PREV_TAG="initial"
|
||||
fi
|
||||
|
||||
for i in $(seq 0 $((ISSUE_COUNT - 1))); do
|
||||
ISSUE_ID=$(echo "$ISSUES_JSON" | jq -r ".data.issues[$i].id")
|
||||
ISSUE_TITLE=$(echo "$ISSUES_JSON" | jq -r ".data.issues[$i].subject // .data.issues[$i].title // \"\"")
|
||||
ISSUE_DESC=$(echo "$ISSUES_JSON" | jq -r ".data.issues[$i].description // \"\"" | head -c 500)
|
||||
COMBINED="$ISSUE_TITLE $ISSUE_DESC"
|
||||
NEW_VERSION="${RELEASE_VERSION:-weekly-$(date +%Y%m%d)}"
|
||||
log_ok "Period: ${PERIOD_START} ~ ${PERIOD_END}"
|
||||
log_ok "Release: ${PREV_TAG} → ${NEW_VERSION}"
|
||||
|
||||
# Keyword-based classification
|
||||
if echo "$COMBINED" | grep -qiE 'bug|error|crash|fault|fix|修复|错误|异常|崩溃'; then
|
||||
BUG_IDS+=("$ISSUE_ID")
|
||||
log_info " #$ISSUE_ID → BUG: $ISSUE_TITLE"
|
||||
elif echo "$COMBINED" | grep -qiE 'feature|新增|建议|enhancement|add|support|功能'; then
|
||||
FEATURE_IDS+=("$ISSUE_ID")
|
||||
log_info " #$ISSUE_ID → FEATURE: $ISSUE_TITLE"
|
||||
elif echo "$COMBINED" | grep -qiE 'how|怎么|如何|question|help|\?|?'; then
|
||||
QUESTION_IDS+=("$ISSUE_ID")
|
||||
log_info " #$ISSUE_ID → QUESTION: $ISSUE_TITLE"
|
||||
elif echo "$COMBINED" | grep -qiE 'doc|文档|readme|说明|guide'; then
|
||||
DOCS_IDS+=("$ISSUE_ID")
|
||||
log_info " #$ISSUE_ID → DOCS: $ISSUE_TITLE"
|
||||
# ================================================================
|
||||
# Phase 2: 收集数据 (changelog skill: collect-data)
|
||||
# ================================================================
|
||||
log_title "Phase 2: Collect Data"
|
||||
|
||||
# ── Commits ──
|
||||
log_step "Collecting commits..."
|
||||
COMMIT_LIST=""
|
||||
COMMIT_COUNT=0
|
||||
if [[ "$PREV_TAG" != "initial" ]]; then
|
||||
COMPARE_JSON=$(gl_run api GET "/v1/$OWNER/$REPO/compare/$PREV_TAG...master" 2>/dev/null || true)
|
||||
if [[ -n "$COMPARE_JSON" ]]; then
|
||||
COMMIT_COUNT=$(echo "$COMPARE_JSON" | jq '.data.total_commits // 0' 2>/dev/null || echo "0")
|
||||
COMMIT_LIST=$(echo "$COMPARE_JSON" | jq -r '[.data.commits[]? | "- \(.commit.message | split("\n")[0]) (\(.commit.author.name // "unknown"))"] | .[:50] | join("\n")' 2>/dev/null || echo "")
|
||||
fi
|
||||
fi
|
||||
[[ -z "$COMMIT_LIST" ]] && COMMIT_LIST="(无 commit 数据)"
|
||||
log_ok "Commits: $COMMIT_COUNT"
|
||||
|
||||
# ── Merged PRs (用临时文件避免 pipefail 静默退出) ──
|
||||
log_step "Collecting merged PRs..."
|
||||
PR_ITEMS=""
|
||||
PR_COUNT=0
|
||||
PR_CONTRIBUTORS=""
|
||||
PR_TMP=$(mktemp)
|
||||
PRS_JSON=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state merged --limit 100 2>/dev/null || true)
|
||||
if [[ -n "$PRS_JSON" ]]; then
|
||||
echo "$PRS_JSON" | jq -r '(.data.issues // .data.pulls // .data // [])[]? | "\(.name // .subject // .title // "N/A")\t\(.pull_request_number // .number // .id)\t\(.author_login // .author.login // "?")"' 2>/dev/null > "$PR_TMP" || true
|
||||
while IFS=$'\t' read -r pr_title pr_num pr_author || [[ -n "$pr_title" ]]; do
|
||||
PR_ITEMS+="- ${pr_title} (#${pr_num}) (@${pr_author})"$'\n'
|
||||
PR_CONTRIBUTORS+="@${pr_author} "
|
||||
PR_COUNT=$((PR_COUNT + 1)) || true
|
||||
done < "$PR_TMP"
|
||||
fi
|
||||
rm -f "$PR_TMP"
|
||||
[[ -z "$PR_ITEMS" ]] && PR_ITEMS="(本周期无 PR 合并)"
|
||||
log_ok "Merged PRs in period: $PR_COUNT"
|
||||
|
||||
# ── Issues (新增 + 关闭) — 用临时文件避免子 shell 变量丢失 ──
|
||||
log_step "Collecting issues..."
|
||||
|
||||
ISS_FEAT_FILE=$(mktemp)
|
||||
ISS_BUG_FILE=$(mktemp)
|
||||
ISS_DOC_FILE=$(mktemp)
|
||||
ISS_OTHER_FILE=$(mktemp)
|
||||
ISS_AUTHORS_FILE=$(mktemp)
|
||||
ISS_COUNT_FILE=$(mktemp)
|
||||
echo "0" > "$ISS_COUNT_FILE"
|
||||
|
||||
for state in open closed; do
|
||||
ISS_JSON=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state "$state" --limit 100 2>/dev/null || true)
|
||||
[[ -z "$ISS_JSON" ]] && continue
|
||||
echo "$ISS_JSON" | jq -c '(.data.issues // .data // [])[]' 2>/dev/null | while read -r issue; do
|
||||
NUM=$(echo "$issue" | jq -r '.project_issues_index // .number // .id')
|
||||
TITLE=$(echo "$issue" | jq -r '.subject // .title // "N/A"')
|
||||
DESC=$(echo "$issue" | jq -r '.description // ""')
|
||||
AUTHOR=$(echo "$issue" | jq -r '.author.login // .author.username // "?"')
|
||||
CREATED=$(echo "$issue" | jq -r '.created_at // ""')
|
||||
CLOSED=$(echo "$issue" | jq -r '.closed_at // ""')
|
||||
STATE=$(echo "$issue" | jq -r '.status.name // .state // "?"')
|
||||
|
||||
# 时间筛选:created 或 closed >= PERIOD_START
|
||||
IN_PERIOD=false
|
||||
if [[ -n "$CREATED" ]]; then CT_TS=$(date -d "$CREATED" +%s 2>/dev/null || echo "0"); [[ "$CT_TS" -ge "$PERIOD_START_TS" ]] && IN_PERIOD=true; fi
|
||||
[[ "$IN_PERIOD" != "true" && -n "$CLOSED" ]] && { CL_TS=$(date -d "$CLOSED" +%s 2>/dev/null || echo "0"); [[ "$CL_TS" -ge "$PERIOD_START_TS" ]] && IN_PERIOD=true; }
|
||||
[[ "$IN_PERIOD" != "true" ]] && continue
|
||||
|
||||
LINE="- ${TITLE} (#${NUM}) (@${AUTHOR})"
|
||||
[[ "$STATE" =~ 关闭|closed ]] && LINE+=" [已关闭]"
|
||||
|
||||
# 关键词分类 — 标题 + 描述 都参与匹配, 10 个标准类别
|
||||
COMBINED=$(echo "$TITLE $DESC" | tr '[:upper:]' '[:lower:]')
|
||||
if echo "$COMBINED" | grep -qiE 'bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障'; then
|
||||
CATEGORY="缺陷"
|
||||
echo "${LINE} [缺陷]" >> "$ISS_BUG_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'feature|enhancement|add|新增|建议|功能|特性|新功能|支持|request'; then
|
||||
CATEGORY="功能"
|
||||
echo "${LINE} [功能]" >> "$ISS_FEAT_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'doc|readme|guide|wiki|tutorial|文档|说明|教程|手册'; then
|
||||
CATEGORY="文档"
|
||||
echo "${LINE} [文档]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'test|测试|用例|覆盖|验证'; then
|
||||
CATEGORY="测试"
|
||||
echo "${LINE} [测试]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'duplicate|重复|重复的|和.*一样'; then
|
||||
CATEGORY="重复"
|
||||
echo "${LINE} [重复]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'question|疑问|不确定|讨论|澄清|是否|可否'; then
|
||||
CATEGORY="疑问"
|
||||
echo "${LINE} [疑问]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'help|协助|帮助|协作|请求帮助|互助'; then
|
||||
CATEGORY="协助"
|
||||
echo "${LINE} [协助]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'postpone|wontfix|暂缓|搁置|低优|不重要|不紧急|暂不|delay'; then
|
||||
CATEGORY="搁置"
|
||||
echo "${LINE} [搁置]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'task|todo|任务|待办|计划|安排'; then
|
||||
CATEGORY="任务"
|
||||
echo "${LINE} [任务]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'support|兼容|环境|依赖|平台|适配'; then
|
||||
CATEGORY="支持"
|
||||
echo "${LINE} [支持]" >> "$ISS_DOC_FILE"
|
||||
else
|
||||
log_info " #$ISSUE_ID → UNCATEGORIZED: $ISSUE_TITLE"
|
||||
echo "${LINE} [其他]" >> "$ISS_OTHER_FILE"
|
||||
fi
|
||||
echo "@${AUTHOR}" >> "$ISS_AUTHORS_FILE"
|
||||
# 计数
|
||||
CNT=$(cat "$ISS_COUNT_FILE"); echo $((CNT + 1)) > "$ISS_COUNT_FILE"
|
||||
done
|
||||
done
|
||||
|
||||
divider
|
||||
log_info "Classification summary:"
|
||||
log_info " Bugs: ${#BUG_IDS[@]}"
|
||||
log_info " Features: ${#FEATURE_IDS[@]}"
|
||||
log_info " Questions: ${#QUESTION_IDS[@]}"
|
||||
log_info " Docs: ${#DOCS_IDS[@]}"
|
||||
ISSUE_FEATURES=$(cat "$ISS_FEAT_FILE" 2>/dev/null || echo "")
|
||||
ISSUE_BUGS=$(cat "$ISS_BUG_FILE" 2>/dev/null || echo "")
|
||||
ISSUE_DOCS=$(cat "$ISS_DOC_FILE" 2>/dev/null || echo "")
|
||||
ISSUE_OTHER=$(cat "$ISS_OTHER_FILE" 2>/dev/null || echo "")
|
||||
ISSUE_COUNT=$(cat "$ISS_COUNT_FILE" 2>/dev/null || echo "0")
|
||||
ISSUE_CONTRIBUTORS=$(sort -u "$ISS_AUTHORS_FILE" 2>/dev/null | tr '\n' ' ' || echo "")
|
||||
rm -f "$ISS_FEAT_FILE" "$ISS_BUG_FILE" "$ISS_DOC_FILE" "$ISS_OTHER_FILE" "$ISS_AUTHORS_FILE" "$ISS_COUNT_FILE"
|
||||
|
||||
# Apply labels via batch-label
|
||||
if [[ ${#BUG_IDS[@]} -gt 0 ]]; then
|
||||
log_step "Labeling bug issues..."
|
||||
for bid in "${BUG_IDS[@]}"; do
|
||||
gl_run issue +label-add --owner "$OWNER" --repo "$REPO" --number "$bid" --labels "bug" > /dev/null 2>&1 || true
|
||||
done
|
||||
log_ok "Labeled ${#BUG_IDS[@]} bug issues"
|
||||
fi
|
||||
log_ok "Issues in period: $ISSUE_COUNT"
|
||||
|
||||
if [[ ${#FEATURE_IDS[@]} -gt 0 ]]; then
|
||||
log_step "Labeling feature issues..."
|
||||
for fid in "${FEATURE_IDS[@]}"; do
|
||||
gl_run issue +label-add --owner "$OWNER" --repo "$REPO" --number "$fid" --labels "feature" > /dev/null 2>&1 || true
|
||||
done
|
||||
log_ok "Labeled ${#FEATURE_IDS[@]} feature issues"
|
||||
fi
|
||||
# ── 贡献者汇总 ──
|
||||
ALL_CONTRIBUTORS=$(echo "$PR_CONTRIBUTORS $ISSUE_CONTRIBUTORS" | tr ' ' '\n' | sort -u | grep -v '^$' | sed 's/^/- /' | tr '\n' ' ')
|
||||
[[ -z "$ALL_CONTRIBUTORS" ]] && ALL_CONTRIBUTORS="(无活跃贡献者)"
|
||||
|
||||
if [[ ${#QUESTION_IDS[@]} -gt 0 ]]; then
|
||||
log_step "Labeling question issues..."
|
||||
for qid in "${QUESTION_IDS[@]}"; do
|
||||
gl_run issue +label-add --owner "$OWNER" --repo "$REPO" --number "$qid" --labels "question" > /dev/null 2>&1 || true
|
||||
done
|
||||
log_ok "Labeled ${#QUESTION_IDS[@]} question issues"
|
||||
fi
|
||||
# ================================================================
|
||||
# Phase 3: 生成 Release Notes (changelog skill: 标准模板)
|
||||
# ================================================================
|
||||
log_title "Phase 3: Generate Release Notes"
|
||||
|
||||
if [[ ${#DOCS_IDS[@]} -gt 0 ]]; then
|
||||
log_step "Labeling docs issues..."
|
||||
for did in "${DOCS_IDS[@]}"; do
|
||||
gl_run issue +label-add --owner "$OWNER" --repo "$REPO" --number "$did" --labels "documentation" > /dev/null 2>&1 || true
|
||||
done
|
||||
log_ok "Labeled ${#DOCS_IDS[@]} docs issues"
|
||||
fi
|
||||
fi
|
||||
FEAT_SECTION="${ISSUE_FEATURES:-_无_}"
|
||||
BUG_SECTION="${ISSUE_BUGS:-_无_}"
|
||||
DOC_SECTION="${ISSUE_DOCS:-_无_}"
|
||||
OTHER_SECTION="${ISSUE_OTHER:-_无_}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
log_title "Phase 2: Assign Responsible Persons"
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
FEAT_COUNT=$(echo "$ISSUE_FEATURES" | grep -c '^-' 2>/dev/null || echo "0")
|
||||
BUG_COUNT=$(echo "$ISSUE_BUGS" | grep -c '^-' 2>/dev/null || echo "0")
|
||||
|
||||
log_step "Fetching repo members for assignment..."
|
||||
MEMBERS_JSON=$(gl_run repo +members --owner "$OWNER" --repo "$REPO" --limit 50)
|
||||
MEMBER_COUNT=$(echo "$MEMBERS_JSON" | jq '(.data.members // .data | if type == "array" then . else [] end) | length' 2>/dev/null || echo "0")
|
||||
RELEASE_BODY="# 🎉 Release ${NEW_VERSION}
|
||||
|
||||
if [[ "$MEMBER_COUNT" -gt 0 ]]; then
|
||||
# Assign bugs to first member, features to second, etc. (round-robin)
|
||||
MEMBER_LOGINS=()
|
||||
MEMBERS_DATA_PATH='(.data.members // .data | if type == "array" then . else [] end)'
|
||||
for i in $(seq 0 $((MEMBER_COUNT - 1))); do
|
||||
LOGIN=$(echo "$MEMBERS_JSON" | jq -r "$MEMBERS_DATA_PATH[$i].login // $MEMBERS_DATA_PATH[$i].username // empty")
|
||||
[[ -n "$LOGIN" ]] && MEMBER_LOGINS+=("$LOGIN")
|
||||
done
|
||||
## 📊 变更统计
|
||||
- **周期**: ${PERIOD_START} ~ ${PERIOD_END}
|
||||
- **已合并 PR**: ${PR_COUNT} 个
|
||||
- **Issue 活动**: ${ISSUE_COUNT} 条(新增/关闭)
|
||||
- **Commits**: ${COMMIT_COUNT} 条
|
||||
|
||||
if [[ ${#MEMBER_LOGINS[@]} -gt 0 ]]; then
|
||||
assign_issues() {
|
||||
local label="$1"
|
||||
shift
|
||||
local ids=("$@")
|
||||
local member_idx=0
|
||||
for id in "${ids[@]}"; do
|
||||
local assignee="${MEMBER_LOGINS[$((member_idx % ${#MEMBER_LOGINS[@]}))]}"
|
||||
# issue +update doesn't support --assignee, use raw API
|
||||
gl_run api PATCH "/v1/$OWNER/$REPO/issues/$id" \
|
||||
--body "{\"assigned_to_id\": \"$assignee\"}" > /dev/null 2>&1 || true
|
||||
log_info " Assigned #$id → @$assignee"
|
||||
((member_idx++))
|
||||
done
|
||||
}
|
||||
## 📝 Issue 活动
|
||||
|
||||
log_step "Assigning bug issues..."
|
||||
assign_issues "bug" "${BUG_IDS[@]}" 2>/dev/null || true
|
||||
log_step "Assigning feature issues..."
|
||||
assign_issues "feature" "${FEATURE_IDS[@]}" 2>/dev/null || true
|
||||
log_ok "Assignment complete"
|
||||
fi
|
||||
else
|
||||
log_warn "No repo members found, skipping assignment"
|
||||
fi
|
||||
### 🐛 缺陷 / Bug
|
||||
${BUG_SECTION}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
log_title "Phase 3: Generate Community Weekly Report"
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
### ✨ 新功能 / Feature
|
||||
${FEAT_SECTION}
|
||||
|
||||
WEEK_START=$(date -d "$((WEEKS_AGO * 7)) days ago" +%Y-%m-%d 2>/dev/null || date_today)
|
||||
WEEK_END=$(date_today)
|
||||
### 📖 文档
|
||||
${DOC_SECTION}
|
||||
|
||||
log_step "Collecting weekly data (week of $WEEK_START)..."
|
||||
### 💡 其他
|
||||
${OTHER_SECTION}
|
||||
|
||||
# Closed issues this week
|
||||
CLOSED_ISSUES=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state closed --limit 100)
|
||||
CLOSED_COUNT=$(echo "$CLOSED_ISSUES" | jq '.data.issues | length' 2>/dev/null || echo "0")
|
||||
## 🔀 已合并 PR
|
||||
${PR_ITEMS}
|
||||
|
||||
# Merged PRs this week
|
||||
MERGED_PRS=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state merged --limit 100)
|
||||
# PR list may return .data[], .data.pulls[], or .data.issues[]
|
||||
MERGED_COUNT=$(echo "$MERGED_PRS" | jq '
|
||||
(.data.issues // .data.pulls // .data | if type == "array" then . else [] end) | length
|
||||
' 2>/dev/null || echo "0")
|
||||
|
||||
# New issues
|
||||
NEW_ISSUES_COUNT=$ISSUE_COUNT
|
||||
|
||||
# Build weekly report
|
||||
REPORT_TITLE="Community Weekly Report: $WEEK_START ~ $WEEK_END"
|
||||
REPORT_BODY="# $REPORT_TITLE
|
||||
|
||||
## Summary
|
||||
- New Issues: **$NEW_ISSUES_COUNT**
|
||||
- Closed Issues: **$CLOSED_COUNT**
|
||||
- Merged PRs: **$MERGED_COUNT**
|
||||
|
||||
## Issue Classification
|
||||
| Type | Count |
|
||||
|------|-------|
|
||||
| Bug | ${#BUG_IDS[@]} |
|
||||
| Feature | ${#FEATURE_IDS[@]} |
|
||||
| Question | ${#QUESTION_IDS[@]} |
|
||||
| Docs | ${#DOCS_IDS[@]} |
|
||||
|
||||
## Highlights
|
||||
- Auto-classified and labeled $(( ${#BUG_IDS[@]} + ${#FEATURE_IDS[@]} + ${#QUESTION_IDS[@]} + ${#DOCS_IDS[@]} )) issues
|
||||
- Assigned responsible persons for bug and feature issues
|
||||
## 🙏 贡献者
|
||||
${ALL_CONTRIBUTORS}
|
||||
|
||||
---
|
||||
*Auto-generated by gitlink-cli community-ops workflow*"
|
||||
**完整变更日志**: https://www.gitlink.org.cn/${OWNER}/${REPO}/compare/${PREV_TAG}...${NEW_VERSION}
|
||||
|
||||
*Auto-generated by gitlink-cli community-ops workflow (gitlink-changelog skill)*"
|
||||
|
||||
log_ok "Weekly report generated"
|
||||
echo ""
|
||||
echo "$REPORT_BODY"
|
||||
echo "$RELEASE_BODY"
|
||||
echo ""
|
||||
|
||||
# Publish to Wiki
|
||||
log_step "Publishing weekly report to Wiki..."
|
||||
WIKI_RESULT=$(gl_run wiki +create --owner "$OWNER" --repo "$REPO" \
|
||||
--title "$REPORT_TITLE" \
|
||||
--content "$REPORT_BODY" 2>&1) || true
|
||||
# ================================================================
|
||||
# Phase 4: 发布 Release
|
||||
# ================================================================
|
||||
log_title "Phase 4: Publish Release"
|
||||
|
||||
if [[ "$(json_ok "$WIKI_RESULT")" == "true" ]]; then
|
||||
log_ok "Weekly report published to Wiki"
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would create release: $NEW_VERSION"
|
||||
else
|
||||
log_warn "Wiki publish may have failed (wiki module might not be enabled)"
|
||||
log_step "Creating release $NEW_VERSION..."
|
||||
RELEASE_RESULT=$(gl_run release +create \
|
||||
--owner "$OWNER" --repo "$REPO" \
|
||||
--tag "$NEW_VERSION" \
|
||||
--name "Release $NEW_VERSION" \
|
||||
--body "$RELEASE_BODY" 2>&1) || true
|
||||
|
||||
if echo "$RELEASE_RESULT" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Release $NEW_VERSION published!"
|
||||
log_info "View: https://www.gitlink.org.cn/$OWNER/$REPO/releases"
|
||||
else
|
||||
log_warn "Release may have failed (tag might exist):"
|
||||
echo "$RELEASE_RESULT" | jq -r '.error.message // "unknown"' 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
log_title "Phase 4: Auto-Publish Release Notes"
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# ================================================================
|
||||
# Phase 5: 发布社区周报到 Wiki
|
||||
# ================================================================
|
||||
log_title "Phase 5: Publish Weekly Report"
|
||||
|
||||
log_step "Collecting recent changes for release notes..."
|
||||
WIKI_TITLE="社区周报"
|
||||
WIKI_BODY="# 社区周报 - ${OWNER}/${REPO}
|
||||
|
||||
# Get recent commits via compare API
|
||||
TAG_NAME="weekly-$(date +%Y%m%d)"
|
||||
RELEASE_NAME="Weekly Release $(date +%Y-%m-%d)"
|
||||
|
||||
# Build release notes from closed issues and merged PRs
|
||||
RELEASE_BODY="# Release Notes - $(date +%Y-%m-%d)
|
||||
|
||||
## Merged PRs ($MERGED_COUNT)"
|
||||
|
||||
if [[ "$MERGED_COUNT" -gt 0 ]]; then
|
||||
PR_DATA_PATH='(.data.issues // .data.pulls // .data | if type == "array" then . else [] end)'
|
||||
for i in $(seq 0 $((MERGED_COUNT > 10 ? 9 : MERGED_COUNT - 1))); do
|
||||
PR_TITLE=$(echo "$MERGED_PRS" | jq -r "$PR_DATA_PATH[$i].subject // $PR_DATA_PATH[$i].title // \"\"")
|
||||
PR_NUM=$(echo "$MERGED_PRS" | jq -r "$PR_DATA_PATH[$i].id // $PR_DATA_PATH[$i].number // \"\"")
|
||||
RELEASE_BODY+=$'\n'"- #$PR_NUM $PR_TITLE"
|
||||
done
|
||||
fi
|
||||
|
||||
RELEASE_BODY+="
|
||||
|
||||
## Closed Issues ($CLOSED_COUNT)"
|
||||
|
||||
if [[ "$CLOSED_COUNT" -gt 0 ]]; then
|
||||
for i in $(seq 0 $((CLOSED_COUNT > 10 ? 9 : CLOSED_COUNT - 1))); do
|
||||
ISSUE_TITLE=$(echo "$CLOSED_ISSUES" | jq -r ".data.issues[$i].subject // .data.issues[$i].title // empty")
|
||||
ISSUE_NUM=$(echo "$CLOSED_ISSUES" | jq -r ".data.issues[$i].number // .data.issues[$i].id // empty")
|
||||
[[ -n "$ISSUE_TITLE" ]] && RELEASE_BODY+=$'\n'"- #${ISSUE_NUM:-?} $ISSUE_TITLE"
|
||||
done
|
||||
fi
|
||||
|
||||
RELEASE_BODY+="
|
||||
**${PERIOD_START} ~ ${PERIOD_END}**
|
||||
|
||||
---
|
||||
*Auto-generated by gitlink-cli community-ops workflow*"
|
||||
|
||||
log_step "Creating release: $TAG_NAME..."
|
||||
RELEASE_RESULT=$(gl_run release +create --owner "$OWNER" --repo "$REPO" \
|
||||
--tag "$TAG_NAME" \
|
||||
--name "$RELEASE_NAME" \
|
||||
--body "$RELEASE_BODY" 2>&1) || true
|
||||
## 📊 数据总览
|
||||
- 已合并 PR: **${PR_COUNT}** 个
|
||||
- Issue 活动: **${ISSUE_COUNT}** 条
|
||||
- 活跃贡献者: $(echo "$ALL_CONTRIBUTORS" | tr '\n' ' ')
|
||||
|
||||
if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then
|
||||
log_ok "Release $TAG_NAME created successfully"
|
||||
## 🔀 合并的 PR
|
||||
${PR_ITEMS}
|
||||
|
||||
## 📝 Issue 动态
|
||||
${BUG_SECTION}
|
||||
${FEAT_SECTION}
|
||||
${DOC_SECTION}
|
||||
${OTHER_SECTION}
|
||||
|
||||
---
|
||||
|
||||
*Auto-generated by gitlink-cli | ${PERIOD_START} ~ ${PERIOD_END} | Next report in ~${PERIOD_HOURS}h*"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would publish Wiki"
|
||||
else
|
||||
log_warn "Release creation may have failed (tag might already exist)"
|
||||
log_info "You can manually create with: gitlink-cli release +create --owner $OWNER --repo $REPO --tag $TAG_NAME --name '$RELEASE_NAME'"
|
||||
log_step "Publishing to Wiki..."
|
||||
# 先尝试覆盖更新,页面不存在则新建
|
||||
WIKI_RESULT=$(gl_run wiki +update --owner "$OWNER" --repo "$REPO" \
|
||||
--title "$WIKI_TITLE" --cover "$WIKI_BODY" 2>&1) || true
|
||||
if echo "$WIKI_RESULT" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Weekly report updated on Wiki"
|
||||
else
|
||||
# 页面可能还不存在,新建
|
||||
log_info "Page not found, creating new..."
|
||||
WIKI_RESULT=$(gl_run wiki +create --owner "$OWNER" --repo "$REPO" \
|
||||
--title "$WIKI_TITLE" --content "$WIKI_BODY" 2>&1) || true
|
||||
if echo "$WIKI_RESULT" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Weekly report created on Wiki"
|
||||
else
|
||||
log_warn "Wiki publish failed"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
log_title "Community Operations Complete"
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo -e "${GREEN}Summary:${NC}"
|
||||
echo " Issues classified: $(( ${#BUG_IDS[@]} + ${#FEATURE_IDS[@]} + ${#QUESTION_IDS[@]} + ${#DOCS_IDS[@]} ))"
|
||||
echo " Closed this week: $CLOSED_COUNT"
|
||||
echo " Merged PRs: $MERGED_COUNT"
|
||||
echo " Weekly report: Published to Wiki"
|
||||
echo " Release notes: $TAG_NAME"
|
||||
echo ""
|
||||
# ================================================================
|
||||
log_title "Complete"
|
||||
echo -e "${GREEN} Release: $NEW_VERSION${NC}"
|
||||
echo -e "${GREEN} PRs merged: $PR_COUNT${NC}"
|
||||
echo -e "${GREEN} Issues: $ISSUE_COUNT${NC}"
|
||||
echo -e "${GREEN} Contributors: $(echo "$ALL_CONTRIBUTORS" | wc -w)${NC}"
|
||||
echo -e "${GREEN} Wiki: ${WIKI_TITLE}${NC}"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
# ----------------------------------------------------------------
|
||||
# Scenario 1a: Real-Time Issue Triage (Webhook触发的单条Issue分类)
|
||||
# Flow: Webhook触发 → 拉取Issue详情 → 动态收集标签ID → 分类 → 打tags → 分配
|
||||
#
|
||||
# 10 个标准中文分类: 缺陷/功能/文档/任务/测试/支持/重复/疑问/协助/搁置
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
|
||||
param(
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[string]$IssueNumber = "",
|
||||
[string]$Assignee = "",
|
||||
[switch]$DryRun,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 01a-issue-triage.ps1 -IssueNumber N [-Owner OWNER] [-Repo REPO] [-Assignee USER] [-DryRun]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $IssueNumber) { Log-Err "IssueNumber is required (use -Help for usage)"; exit 1 }
|
||||
|
||||
Check-Auth
|
||||
$r = Resolve-OwnerRepo $Owner $Repo
|
||||
$Owner = $r.Owner; $Repo = $r.Repo
|
||||
|
||||
# 10 个标准中文分类标签
|
||||
$STANDARD_LABELS = @("缺陷","功能","文档","任务","测试","支持","重复","疑问","协助","搁置")
|
||||
|
||||
# ================================================================
|
||||
Log-Title "Issue Triage: #$IssueNumber ($Owner/$Repo)"
|
||||
|
||||
# -- Phase 1: 拉取Issue详情 --
|
||||
Log-Step "Fetching issue #$IssueNumber details..."
|
||||
$issueJson = Invoke-GLCheck issue,+view,--owner,$Owner,--repo,$Repo,--number,$IssueNumber
|
||||
if (-not $issueJson) { Log-Err "Failed to fetch issue #$IssueNumber"; exit 1 }
|
||||
|
||||
$issueData = $issueJson.data
|
||||
$issueId = if ($issueData.id) { $issueData.id } else { $IssueNumber }
|
||||
$issueTitle = if ($issueData.subject) { $issueData.subject } elseif ($issueData.title) { $issueData.title } else { "N/A" }
|
||||
$issueDesc = if ($issueData.description) { $issueData.description } else { "" }
|
||||
$issueAuthor = if ($issueData.author.login) { $issueData.author.login } elseif ($issueData.author.username) { $issueData.author.username } else { "" }
|
||||
|
||||
$existingTags = @()
|
||||
if ($issueData.tags) { $existingTags = @($issueData.tags | ForEach-Object { $_.name }) }
|
||||
if ($existingTags.Count -gt 0) { Log-Info "Already tagged: $($existingTags -join ', ')" }
|
||||
|
||||
Log-Ok "Issue `"$issueTitle`" by @$issueAuthor"
|
||||
|
||||
# -- Phase 2: 动态收集标签 ID 映射 --
|
||||
Log-Step "Discovering label IDs..."
|
||||
$tagIdMap = @{}
|
||||
|
||||
# 优先从项目 issue_tags API 获取
|
||||
$tagsApi = Invoke-GL api,GET,"/v1/$Owner/$Repo/issue_tags"
|
||||
if ($tagsApi) {
|
||||
try {
|
||||
$tData = ($tagsApi | ConvertFrom-Json).data
|
||||
$tagList = if ($tData.issue_tags) { @($tData.issue_tags) } elseif ($tData -is [array]) { @($tData) } else { @() }
|
||||
foreach ($t in $tagList) {
|
||||
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# 补充:从已有 issue 的 tags 字段收集
|
||||
foreach ($state in @("open","closed")) {
|
||||
$sample = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,50
|
||||
if ($sample) {
|
||||
try {
|
||||
$sData = ($sample | ConvertFrom-Json).data
|
||||
$issues = if ($sData.issues) { @($sData.issues) } elseif ($sData -is [array]) { @($sData) } else { @() }
|
||||
foreach ($iss in $issues) {
|
||||
if ($iss.tags) {
|
||||
foreach ($t in $iss.tags) {
|
||||
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
$found = @(); $missing = @()
|
||||
foreach ($lbl in $STANDARD_LABELS) {
|
||||
if ($tagIdMap.ContainsKey($lbl)) { $found += "$lbl($($tagIdMap[$lbl]))" } else { $missing += $lbl }
|
||||
}
|
||||
Log-Ok "Found tags: $($found -join ' ')"
|
||||
if ($missing.Count -gt 0) { Log-Warn "Not in repo yet: $($missing -join ' ')" }
|
||||
|
||||
# -- Phase 3: 关键词分类 (标题+描述) --
|
||||
Log-Step "Analyzing issue content..."
|
||||
|
||||
$combined = "$issueTitle $issueDesc".ToLower()
|
||||
$chosenLabel = ""
|
||||
|
||||
if ($combined -match '(?i)bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障') {
|
||||
$chosenLabel = "缺陷"
|
||||
} elseif ($combined -match '(?i)feature|enhancement|add|新增|建议|功能|特性|新功能') {
|
||||
$chosenLabel = "功能"
|
||||
} elseif ($combined -match '(?i)doc|readme|guide|wiki|文档|说明|教程') {
|
||||
$chosenLabel = "文档"
|
||||
} elseif ($combined -match '(?i)test|测试|用例|覆盖') {
|
||||
$chosenLabel = "测试"
|
||||
} elseif ($combined -match '(?i)duplicate|重复|重复的|和.*重复') {
|
||||
$chosenLabel = "重复"
|
||||
} elseif ($combined -match '(?i)question|疑问|不确定|讨论|澄清') {
|
||||
$chosenLabel = "疑问"
|
||||
} elseif ($combined -match '(?i)help|协助|帮助|协作|请求帮助') {
|
||||
$chosenLabel = "协助"
|
||||
} elseif ($combined -match '(?i)postpone|wontfix|暂缓|搁置|低优|不重要|不紧急') {
|
||||
$chosenLabel = "搁置"
|
||||
} elseif ($combined -match '(?i)support|兼容|环境|依赖|支持') {
|
||||
$chosenLabel = "支持"
|
||||
} elseif ($combined -match '(?i)task|todo|任务|待办|计划') {
|
||||
$chosenLabel = "任务"
|
||||
}
|
||||
|
||||
if ($chosenLabel) {
|
||||
Log-Ok "Classified: $chosenLabel"
|
||||
} else {
|
||||
Log-Info "No matching label — skipping"
|
||||
}
|
||||
|
||||
# -- Phase 4: 打标签 (raw API PATCH tags) --
|
||||
if (-not $chosenLabel) {
|
||||
Log-Info "No label assigned"
|
||||
} elseif ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would tag #$IssueNumber with '$chosenLabel'"
|
||||
} else {
|
||||
$tgtId = $tagIdMap[$chosenLabel]
|
||||
if (-not $tgtId) {
|
||||
Log-Warn "Label '$chosenLabel' not found in repo tags — create it on website first"
|
||||
Log-Info " https://www.gitlink.org.cn/$Owner/$Repo/settings/labels"
|
||||
} else {
|
||||
Log-Step "Tagging with '$chosenLabel' (ID:$tgtId)..."
|
||||
|
||||
# GitLink v1 API 使用 issue_tag_ids (整数ID数组),不是 tags (对象数组)
|
||||
$bodyJson = "{`"issue_tag_ids`":[$tgtId]}"
|
||||
|
||||
$tagResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$issueId",--body,$bodyJson
|
||||
if ($tagResult) {
|
||||
try {
|
||||
$tagOk = (($tagResult | ConvertFrom-Json).ok -eq $true)
|
||||
} catch { $tagOk = $false }
|
||||
if ($tagOk) { Log-Ok "Tagged: $chosenLabel" } else { Log-Warn "Tag failed" }
|
||||
} else { Log-Warn "Tag failed" }
|
||||
}
|
||||
}
|
||||
|
||||
# -- Phase 5: 分配责任人 --
|
||||
$targetAssignee = if ($Assignee) { $Assignee } else { $issueAuthor }
|
||||
|
||||
Log-Step "Assigning..."
|
||||
if (-not $targetAssignee) {
|
||||
Log-Warn "No assignee, skipping"
|
||||
} elseif ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would assign @$targetAssignee"
|
||||
} else {
|
||||
# assigned_to_id 需要数字用户 ID,不是登录名
|
||||
# 先尝试把输入当纯数字;否则通过 /users/{login} 查询
|
||||
$assigneeID = $null
|
||||
$isNumeric = $false
|
||||
try { [void][int]$targetAssignee; $isNumeric = $true } catch {}
|
||||
if ($isNumeric) {
|
||||
$assigneeID = $targetAssignee
|
||||
} else {
|
||||
Log-Info "Resolving user ID for '$targetAssignee'..."
|
||||
$userJson = Invoke-GL api,GET,"/users/$targetAssignee"
|
||||
if ($userJson) {
|
||||
try {
|
||||
$userData = ($userJson | ConvertFrom-Json).data
|
||||
if ($userData.id) {
|
||||
$assigneeID = [int]$userData.id
|
||||
} elseif ($userData.user_id) {
|
||||
$assigneeID = [int]$userData.user_id
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $assigneeID) {
|
||||
Log-Warn "Cannot resolve user ID for '$targetAssignee' — skipping assign"
|
||||
} else {
|
||||
$escTitle = $issueTitle -replace '"','\"'
|
||||
$escDesc = $issueDesc -replace '"','\"'
|
||||
$bodyJson = "{`"subject`":`"$escTitle`",`"description`":`"$escDesc`",`"assigner_ids`":[$assigneeID]}"
|
||||
$result = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$issueId",--body,$bodyJson
|
||||
if ($result -and ((($result | ConvertFrom-Json).ok -eq $true))) {
|
||||
Log-Ok "Assigned: @$targetAssignee (ID:$assigneeID)"
|
||||
} else {
|
||||
Log-Warn "Assign failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log-Title "Triage Complete"
|
||||
Write-Host " Issue: #$IssueNumber - $issueTitle" -ForegroundColor Green
|
||||
Write-Host " Author: @$issueAuthor" -ForegroundColor Green
|
||||
Write-Host " Label: $(if($chosenLabel){$chosenLabel}else{'无'})" -ForegroundColor Green
|
||||
Write-Host " Assignee: @$targetAssignee" -ForegroundColor Green
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
#!/usr/bin/env bash
|
||||
# ================================================================
|
||||
# Scenario 1a: Real-Time Issue Triage (Linux Webhook 版)
|
||||
# Flow: 接收 Issue 编号 → 拉取详情 → 分类 → 打 tags → 分配人
|
||||
# ================================================================
|
||||
set -eo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
ISSUE_NUMBER=""
|
||||
OWNER=""
|
||||
REPO=""
|
||||
ASSIGNEE=""
|
||||
DRY_RUN=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --issue-number N [--owner OWNER] [--repo REPO] [--assignee USER] [--dry-run]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--issue-number) ISSUE_NUMBER="$2"; shift 2 ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--assignee) ASSIGNEE="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) log_err "Unknown arg: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$ISSUE_NUMBER" ]] && { log_err "--issue-number is required"; usage; }
|
||||
|
||||
check_auth
|
||||
require_owner_repo
|
||||
|
||||
# 10 个标准中文分类标签(必须和仓库实际标签名一致)
|
||||
STANDARD_LABELS="缺陷 功能 文档 任务 测试 支持 重复 疑问 协助 搁置"
|
||||
|
||||
# ================================================================
|
||||
log_title "Issue Triage: #$ISSUE_NUMBER ($OWNER/$REPO)"
|
||||
|
||||
# ── Phase 1: 拉取 Issue 详情 ─────────────────────────────────────
|
||||
log_step "Fetching issue #$ISSUE_NUMBER details..."
|
||||
ISSUE_JSON=$(gl_check issue +view --owner "$OWNER" --repo "$REPO" --number "$ISSUE_NUMBER")
|
||||
[[ -z "$ISSUE_JSON" ]] && { log_err "Failed to fetch issue #$ISSUE_NUMBER"; exit 1; }
|
||||
|
||||
ISSUE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.data.subject // .data.title // "N/A"')
|
||||
ISSUE_DESC=$(echo "$ISSUE_JSON" | jq -r '.data.description // ""' | head -c 2000)
|
||||
ISSUE_AUTHOR=$(echo "$ISSUE_JSON" | jq -r '.data.author.login // .data.author.username // ""')
|
||||
ISSUE_STATE=$(echo "$ISSUE_JSON" | jq -r '.data.status.name // .data.state.name // .data.state // "N/A"')
|
||||
|
||||
EXISTING_TAGS=$(echo "$ISSUE_JSON" | jq -r '[.data.tags[]?.name // ""] | join(", ")' 2>/dev/null || echo "")
|
||||
[[ -n "$EXISTING_TAGS" ]] && log_info "Already tagged: $EXISTING_TAGS"
|
||||
|
||||
log_ok "Issue \"$ISSUE_TITLE\" by @$ISSUE_AUTHOR"
|
||||
|
||||
# ── Phase 2: 动态收集标签 ID 映射 ─────────────────────────────────
|
||||
log_step "Discovering label IDs..."
|
||||
|
||||
ID_MAP_FILE=$(mktemp)
|
||||
|
||||
# 优先从项目 issue_tags API 获取
|
||||
TAGS_JSON=$(gl_run api GET "/v1/$OWNER/$REPO/issue_tags" 2>/dev/null || true)
|
||||
if [[ -n "$TAGS_JSON" ]]; then
|
||||
echo "$TAGS_JSON" | jq -r '(.data.issue_tags // .data // [])[]? | "\(.id)|\(.name)"' 2>/dev/null >> "$ID_MAP_FILE" || true
|
||||
fi
|
||||
|
||||
# 补充:从已有 issue 的 tags 字段收集(覆盖 issue_tags API 未返回的情况)
|
||||
for state in open closed; do
|
||||
SAMPLE=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state "$state" --limit 50 2>/dev/null || true)
|
||||
echo "$SAMPLE" | jq -r '(.data.issues // .data // [])[]?.tags[]? | "\(.id)|\(.name)"' 2>/dev/null >> "$ID_MAP_FILE" || true
|
||||
done
|
||||
|
||||
declare -A TAG_ID
|
||||
while IFS='|' read -r tid tname || [[ -n "$tid" ]]; do
|
||||
[[ -n "$tid" && -n "$tname" ]] && TAG_ID["$tname"]="$tid"
|
||||
done < "$ID_MAP_FILE"
|
||||
rm -f "$ID_MAP_FILE"
|
||||
|
||||
FOUND=""; MISSING=""
|
||||
for lbl in $STANDARD_LABELS; do
|
||||
if [[ -n "${TAG_ID[$lbl]:-}" ]]; then
|
||||
FOUND="$FOUND $lbl(${TAG_ID[$lbl]})"
|
||||
else
|
||||
MISSING="$MISSING $lbl"
|
||||
fi
|
||||
done
|
||||
log_ok "Found tags:${FOUND:- (none)}"
|
||||
[[ -n "$MISSING" ]] && log_warn "Not in repo yet:${MISSING}"
|
||||
|
||||
# ── Phase 3: 分类(关键词 → 中文标签)─────────────────────────────
|
||||
log_step "Analyzing issue content..."
|
||||
|
||||
COMBINED=$(echo "$ISSUE_TITLE $ISSUE_DESC" | tr '[:upper:]' '[:lower:]')
|
||||
CHOSEN_LABEL=""
|
||||
CONFIDENCE=""
|
||||
|
||||
# 优先匹配具体意图,再匹配通用
|
||||
if echo "$COMBINED" | grep -qiE 'bug|error|crash|fault|fix|错误|异常|崩溃|缺陷|修复|故障'; then
|
||||
CHOSEN_LABEL="缺陷"
|
||||
elif echo "$COMBINED" | grep -qiE 'feature|enhancement|add|新增|建议|功能|特性|新功能'; then
|
||||
CHOSEN_LABEL="功能"
|
||||
elif echo "$COMBINED" | grep -qiE 'doc|readme|guide|wiki|文档|说明|教程'; then
|
||||
CHOSEN_LABEL="文档"
|
||||
elif echo "$COMBINED" | grep -qiE 'test|测试|用例|覆盖'; then
|
||||
CHOSEN_LABEL="测试"
|
||||
elif echo "$COMBINED" | grep -qiE 'duplicate|重复|重复的|和.*重复'; then
|
||||
CHOSEN_LABEL="重复"
|
||||
elif echo "$COMBINED" | grep -qiE 'question|疑问|不确定|讨论|澄清'; then
|
||||
CHOSEN_LABEL="疑问"
|
||||
elif echo "$COMBINED" | grep -qiE 'help|协助|帮助|协作|请求帮助'; then
|
||||
CHOSEN_LABEL="协助"
|
||||
elif echo "$COMBINED" | grep -qiE 'postpone|wontfix|暂缓|搁置|低优|不重要|不紧急'; then
|
||||
CHOSEN_LABEL="搁置"
|
||||
elif echo "$COMBINED" | grep -qiE 'support|兼容|环境|依赖|支持'; then
|
||||
CHOSEN_LABEL="支持"
|
||||
elif echo "$COMBINED" | grep -qiE 'task|todo|任务|待办|计划'; then
|
||||
CHOSEN_LABEL="任务"
|
||||
elif echo "$COMBINED" | grep -qiE 'how|怎么|如何|求助|使用|用法'; then
|
||||
CHOSEN_LABEL="疑问"
|
||||
fi
|
||||
|
||||
if [[ -n "$CHOSEN_LABEL" ]]; then
|
||||
CONFIDENCE="keyword"
|
||||
log_ok "Classified: $CHOSEN_LABEL"
|
||||
else
|
||||
log_info "No matching label — skipping"
|
||||
fi
|
||||
|
||||
# ── Phase 4: 打标签(raw API PATCH tags)─────────────────────────
|
||||
if [[ -z "$CHOSEN_LABEL" ]]; then
|
||||
log_info "No label assigned"
|
||||
elif [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would tag #$ISSUE_NUMBER with '$CHOSEN_LABEL'"
|
||||
else
|
||||
TGT_ID="${TAG_ID[$CHOSEN_LABEL]:-}"
|
||||
if [[ -z "$TGT_ID" ]]; then
|
||||
log_warn "Label '$CHOSEN_LABEL' not found in repo tags — create it on website first"
|
||||
log_info " https://www.gitlink.org.cn/$OWNER/$REPO/settings/labels"
|
||||
else
|
||||
log_step "Tagging with '$CHOSEN_LABEL' (ID:$TGT_ID)..."
|
||||
|
||||
|
||||
TAG_RESP=$(gl_run api PATCH "/v1/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
||||
--body "{\"issue_tag_ids\":[$TGT_ID]}" 2>&1) || true
|
||||
|
||||
if echo "$TAG_RESP" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Tagged: $CHOSEN_LABEL"
|
||||
else
|
||||
ERR=$(echo "$TAG_RESP" | jq -r '.error.message // "unknown"' 2>/dev/null || echo "unknown")
|
||||
log_warn "Tag failed: $ERR"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 5: 分配责任人 ──────────────────────────────────────────
|
||||
TARGET_ASSIGNEE="${ASSIGNEE:-$ISSUE_AUTHOR}"
|
||||
|
||||
log_step "Assigning..."
|
||||
if [[ -z "$TARGET_ASSIGNEE" ]]; then
|
||||
log_warn "No assignee, skipping"
|
||||
elif [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would assign @$TARGET_ASSIGNEE"
|
||||
else
|
||||
# Resolve login name to numeric user ID
|
||||
ASSIGNEE_ID=""
|
||||
if [[ "$TARGET_ASSIGNEE" =~ ^[0-9]+$ ]]; then
|
||||
ASSIGNEE_ID="$TARGET_ASSIGNEE"
|
||||
else
|
||||
USER_JSON=$(gl_run api GET "/users/$TARGET_ASSIGNEE" 2>/dev/null || true)
|
||||
if [[ -n "$USER_JSON" ]]; then
|
||||
ASSIGNEE_ID=$(echo "$USER_JSON" | jq -r '.data.id // .data.user_id // ""' 2>/dev/null || echo "")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$ASSIGNEE_ID" ]]; then
|
||||
log_warn "Cannot resolve user ID for '$TARGET_ASSIGNEE' — skipping assign"
|
||||
else
|
||||
BODY="{\"assigner_ids\":[$ASSIGNEE_ID]}"
|
||||
ARESP=$(gl_run api PATCH "/v1/$OWNER/$REPO/issues/$ISSUE_NUMBER" --body "$BODY" 2>&1) || true
|
||||
if echo "$ARESP" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Assigned: @$TARGET_ASSIGNEE (ID:$ASSIGNEE_ID)"
|
||||
else
|
||||
ERR2=$(echo "$ARESP" | jq -r '.error.message // "unknown"' 2>/dev/null || echo "unknown")
|
||||
log_warn "Assign failed: $ERR2"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Complete ──────────────────────────────────────────────────────
|
||||
log_title "Triage Complete"
|
||||
echo -e "${GREEN} Issue: #$ISSUE_NUMBER - $ISSUE_TITLE${NC}"
|
||||
echo -e "${GREEN} Author: @$ISSUE_AUTHOR${NC}"
|
||||
echo -e "${GREEN} Label: ${CHOSEN_LABEL:-无}${NC}"
|
||||
echo -e "${GREEN} Assignee: @$TARGET_ASSIGNEE${NC}"
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
# ----------------------------------------------------------------
|
||||
# Scenario 1a: Webhook HTTP Listener (实时链路接收器)
|
||||
# Role: HTTP server listening for GitLink webhook events (Issue created)
|
||||
# → Parse payload → Call 01a-issue-triage.ps1 to classify & assign
|
||||
#
|
||||
# 这是社区运营自动化"实时链路"的入口,负责接收 GitLink 平台推送的
|
||||
# Webhook 事件,解析 Issue 编号,然后调用分类脚本。
|
||||
#
|
||||
# 架构:
|
||||
# GitLink 平台 (Issue 创建)
|
||||
# → POST https://<YOUR_URL>:<PORT>/webhook
|
||||
# → 01a-webhook-listener.ps1 (本脚本,HTTP 服务器)
|
||||
# → 01a-issue-triage.ps1 (分类+打标签+分配)
|
||||
#
|
||||
# 部署方式:
|
||||
# A. 本地 + ngrok 内网穿透:
|
||||
# 1. 启动本脚本: powershell 01a-webhook-listener.ps1
|
||||
# 2. 启动 ngrok: ngrok http 8080
|
||||
# 3. 运行注册: powershell 01a-webhook-setup.ps1 -WebhookUrl "https://xxx.ngrok.io/webhook"
|
||||
#
|
||||
# B. 部署到公网服务器:
|
||||
# 1. 上传脚本到服务器
|
||||
# 2. 启动本脚本: powershell 01a-webhook-listener.ps1 -Port 443 -Ssl
|
||||
# 3. 运行注册: powershell 01a-webhook-setup.ps1 -WebhookUrl "https://your-server.com/webhook"
|
||||
#
|
||||
# C. 仅手动触发 (无需 webhook):
|
||||
# powershell 01a-issue-triage.ps1 -IssueNumber 42
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
param(
|
||||
[int]$Port = 8080,
|
||||
[string]$Secret = "",
|
||||
[string]$HostPrefix = "+",
|
||||
[switch]$Ssl,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ScriptDir = $PSScriptRoot
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 01a-webhook-listener.ps1 [-Port PORT] [-Secret SECRET] [-HostPrefix +] [-Ssl]"
|
||||
Write-Host ""
|
||||
Write-Host " Webhook HTTP 接收器 — 监听 GitLink 平台的 Issue 事件,自动触发分类。"
|
||||
Write-Host ""
|
||||
Write-Host " -Port PORT 监听端口 (默认: 8080)"
|
||||
Write-Host " -Secret SECRET HMAC 密钥,用于验证 GitLink 请求来源(需与注册时一致)"
|
||||
Write-Host " -HostPrefix PREFIX 监听主机前缀 (默认: + 表示所有IP,也可用 localhost)"
|
||||
Write-Host " -Ssl 启用 HTTPS (需要已导入的 SSL 证书)"
|
||||
Write-Host ""
|
||||
Write-Host " 部署前准备:"
|
||||
Write-Host " 如需公网访问,请使用 ngrok 或部署到有公网IP的服务器:"
|
||||
Write-Host " ngrok http $Port"
|
||||
Write-Host " 然后运行 01a-webhook-setup.ps1 在 GitLink 平台注册 webhook"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Color Helpers (no dependency on common.psm1 since this is a server)
|
||||
# ================================================================
|
||||
function Log-Step { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [STEP] $Msg" -ForegroundColor Blue }
|
||||
function Log-Ok { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [ OK] $Msg" -ForegroundColor Green }
|
||||
function Log-Warn { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [WARN] $Msg" -ForegroundColor Yellow }
|
||||
function Log-Err { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [ ERR] $Msg" -ForegroundColor Red }
|
||||
function Log-Info { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [INFO] $Msg" -ForegroundColor Cyan }
|
||||
|
||||
# ================================================================
|
||||
# HMAC Signature Verification
|
||||
# ================================================================
|
||||
function Test-WebhookSignature {
|
||||
param(
|
||||
[string]$RequestBody,
|
||||
[string]$SignatureHeader,
|
||||
[string]$Secret
|
||||
)
|
||||
if (-not $Secret) { return $true } # No secret configured, skip verification
|
||||
|
||||
if (-not $SignatureHeader) {
|
||||
Log-Warn "No signature header in request (expected X-GitLink-Signature or X-Hub-Signature-256)"
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$hmac = New-Object System.Security.Cryptography.HMACSHA256
|
||||
$hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
|
||||
$hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($RequestBody))
|
||||
$computed = "sha256=" + [System.BitConverter]::ToString($hash).Replace("-", "").ToLower()
|
||||
|
||||
# Support both X-GitLink-Signature and X-Hub-Signature-256 (GitHub-compatible)
|
||||
# Strip prefix if present (e.g., "sha256=abc123..." → "abc123...")
|
||||
$received = $SignatureHeader
|
||||
if ($received -match '^sha256=') {
|
||||
$received = $received
|
||||
} else {
|
||||
$received = "sha256=$received"
|
||||
}
|
||||
|
||||
return $computed -eq $received
|
||||
} catch {
|
||||
Log-Err "HMAC verification error: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Extract Issue Number from Webhook Payload
|
||||
# ================================================================
|
||||
function Get-IssueNumberFromPayload {
|
||||
param([string]$Body, [string]$EventType)
|
||||
|
||||
try {
|
||||
$payload = $Body | ConvertFrom-Json
|
||||
|
||||
# Try multiple known payload structures
|
||||
# GitLink format: { action: "opened", issue: { id: ..., number: ..., project_issues_index: ... } }
|
||||
if ($payload.issue) {
|
||||
$num = $payload.issue.project_issues_index
|
||||
if (-not $num) { $num = $payload.issue.number }
|
||||
if (-not $num) { $num = $payload.issue.id }
|
||||
if ($num) {
|
||||
Log-Info "Extracted issue number: #$num (event: $EventType)"
|
||||
return $num.ToString()
|
||||
}
|
||||
}
|
||||
|
||||
# GitHub-compatible format: { action: "opened", issue: { number: ... } }
|
||||
if ($payload.issue -and $payload.issue.number) {
|
||||
Log-Info "Extracted issue number (GitHub format): #$($payload.issue.number)"
|
||||
return $payload.issue.number.ToString()
|
||||
}
|
||||
|
||||
# Direct format: { number: ..., id: ... }
|
||||
if ($payload.number) { return $payload.number.ToString() }
|
||||
if ($payload.id) {
|
||||
Log-Info "Extracted issue id: $($payload.id)"
|
||||
return $payload.id.ToString()
|
||||
}
|
||||
|
||||
Log-Warn "Could not extract issue number from payload"
|
||||
Log-Info "Payload keys: $($payload.PSObject.Properties.Name -join ', ')"
|
||||
if ($payload.issue) {
|
||||
Log-Info "Issue keys: $($payload.issue.PSObject.Properties.Name -join ', ')"
|
||||
}
|
||||
return $null
|
||||
} catch {
|
||||
Log-Err "Failed to parse webhook payload: $_"
|
||||
Log-Info "Raw body (first 500 chars): $($Body.Substring(0, [Math]::Min(500, $Body.Length)))"
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Extract Owner/Repo from payload or git remote
|
||||
# ================================================================
|
||||
function Get-RepoInfoFromPayload {
|
||||
param([string]$Body)
|
||||
|
||||
try {
|
||||
$payload = $Body | ConvertFrom-Json
|
||||
$owner = $null
|
||||
$repo = $null
|
||||
|
||||
# GitLink format
|
||||
if ($payload.repository) {
|
||||
if ($payload.repository.owner) {
|
||||
$owner = if ($payload.repository.owner.login) { $payload.repository.owner.login }
|
||||
elseif ($payload.repository.owner.username) { $payload.repository.owner.username }
|
||||
else { $payload.repository.owner }
|
||||
}
|
||||
if ($payload.repository.name) { $repo = $payload.repository.name }
|
||||
}
|
||||
|
||||
# GitHub-compatible format
|
||||
if ((-not $owner) -and $payload.repository -and $payload.repository.full_name) {
|
||||
$parts = $payload.repository.full_name -split '/'
|
||||
$owner = $parts[0]
|
||||
$repo = $parts[1]
|
||||
}
|
||||
|
||||
return @{ Owner = $owner; Repo = $repo }
|
||||
} catch {
|
||||
return @{ Owner = $null; Repo = $null }
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Process Incoming Webhook
|
||||
# ================================================================
|
||||
function Invoke-WebhookHandler {
|
||||
param(
|
||||
[string]$Body,
|
||||
[string]$EventType,
|
||||
[string]$EventHeader,
|
||||
[string]$SignatureHeader
|
||||
)
|
||||
|
||||
# Validate secret if configured
|
||||
if ($Secret -and -not (Test-WebhookSignature -RequestBody $Body -SignatureHeader $SignatureHeader -Secret $Secret)) {
|
||||
Log-Err "HMAC signature verification FAILED — request rejected"
|
||||
return @{ StatusCode = 403; Body = '{"error":"Invalid signature"}' }
|
||||
}
|
||||
|
||||
# Only process issue events
|
||||
if ($EventType -notmatch '^issue' -and $EventHeader -notmatch 'issue') {
|
||||
Log-Info "Ignoring non-issue event: $EventType"
|
||||
return @{ StatusCode = 200; Body = '{"status":"ignored","reason":"non-issue event"}' }
|
||||
}
|
||||
|
||||
# Only process "opened" action (new issue created)
|
||||
try {
|
||||
$payload = $Body | ConvertFrom-Json
|
||||
if ($payload.action -and $payload.action -ne 'opened') {
|
||||
Log-Info "Ignoring issue event with action: $($payload.action)"
|
||||
return @{ StatusCode = 200; Body = '{"status":"ignored","reason":"action is not opened"}' }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
# Extract issue number
|
||||
$issueNumber = Get-IssueNumberFromPayload -Body $Body -EventType $EventType
|
||||
if (-not $issueNumber) {
|
||||
Log-Err "Cannot extract issue number — skipping triage"
|
||||
return @{ StatusCode = 400; Body = '{"error":"Cannot extract issue number from payload"}' }
|
||||
}
|
||||
|
||||
Log-Ok "=== New Issue #$issueNumber — dispatching to triage ==="
|
||||
|
||||
# Extract owner/repo to pass to triage script
|
||||
$repoInfo = Get-RepoInfoFromPayload -Body $Body
|
||||
|
||||
# Dispatch triage script asynchronously so we can respond to webhook quickly
|
||||
$jobScript = {
|
||||
param($ScriptDir, $IssueNum, $Owner, $Repo, $Body)
|
||||
$argList = @("-File", "$ScriptDir\01a-issue-triage.ps1", "-IssueNumber", $IssueNum)
|
||||
if ($Owner) { $argList += @("-Owner", $Owner) }
|
||||
if ($Repo) { $argList += @("-Repo", $Repo) }
|
||||
$result = & powershell.exe -NoProfile -ExecutionPolicy Bypass @argList 2>&1
|
||||
$result | Out-File "$ScriptDir\webhook-triage-$IssueNum-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" -Encoding UTF8
|
||||
}
|
||||
|
||||
Start-Job -ScriptBlock $jobScript -ArgumentList $ScriptDir, $issueNumber, $repoInfo.Owner, $repoInfo.Repo, $Body | Out-Null
|
||||
Log-Ok "Triage job started for #$issueNumber (running in background)"
|
||||
|
||||
return @{ StatusCode = 200; Body = '{"status":"accepted","issue_number":' + $issueNumber + '}' }
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Main: Start HTTP Listener
|
||||
# ================================================================
|
||||
Clear-Host
|
||||
Write-Host ""
|
||||
Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
|
||||
Write-Host "║ GitLink Community Ops — Webhook Listener ║" -ForegroundColor Cyan
|
||||
Write-Host "║ 实时链路接收器: Issue 创建 → 自动分类 → 分配责任人 ║" -ForegroundColor Cyan
|
||||
Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
$protocol = if ($Ssl) { "https" } else { "http" }
|
||||
$listenUrl = "$($protocol)://$($HostPrefix):$Port/"
|
||||
|
||||
Log-Info "Starting listener on: $listenUrl"
|
||||
Log-Info "Triage script: $ScriptDir\01a-issue-triage.ps1"
|
||||
if ($Secret) {
|
||||
Log-Info "HMAC verification: ENABLED"
|
||||
} else {
|
||||
Log-Warn "HMAC verification: DISABLED (set -Secret to enable)"
|
||||
}
|
||||
|
||||
# Try to register URL ACL if not running as admin for non-localhost
|
||||
if ($HostPrefix -ne "localhost" -and $HostPrefix -ne "127.0.0.1") {
|
||||
Write-Host ""
|
||||
Log-Warn "Listening on $HostPrefix requires URL ACL registration."
|
||||
Log-Info "If you get 'Access Denied', run as Administrator OR use -HostPrefix localhost"
|
||||
}
|
||||
|
||||
# Create HttpListener
|
||||
$listener = $null
|
||||
try {
|
||||
$listener = New-Object System.Net.HttpListener
|
||||
$listener.Prefixes.Add($listenUrl + "webhook/")
|
||||
$listener.Prefixes.Add($listenUrl) # Also listen on root path
|
||||
$listener.Start()
|
||||
Log-Ok "HTTP listener started successfully"
|
||||
} catch {
|
||||
Log-Err "Failed to start HTTP listener: $_"
|
||||
Write-Host ""
|
||||
Write-Host "Troubleshooting:" -ForegroundColor Yellow
|
||||
Write-Host " 1. Run as Administrator"
|
||||
Write-Host " 2. Or register URL ACL manually:"
|
||||
Write-Host " netsh http add urlacl url=$listenUrl user=Everyone"
|
||||
Write-Host " 3. Or use localhost only: -HostPrefix localhost"
|
||||
Write-Host " 4. Check if port $Port is already in use: netstat -ano | findstr $Port"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Log-Ok "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
Log-Ok " Listening on: $listenUrl"
|
||||
Log-Ok " Webhook URL: ${listenUrl}webhook"
|
||||
Log-Ok " Health check: ${listenUrl}"
|
||||
Log-Ok ""
|
||||
Log-Ok " 按 Ctrl+C 停止服务"
|
||||
Log-Ok "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
Write-Host ""
|
||||
|
||||
# Handle Ctrl+C gracefully
|
||||
$keepRunning = $true
|
||||
$null = Register-EngineEvent -SourceIdentifier "WebhookListenerStop" -Forward -SupportEvent
|
||||
try {
|
||||
[Console]::TreatControlCAsInput = $false
|
||||
} catch { }
|
||||
|
||||
# Main event loop
|
||||
while ($keepRunning) {
|
||||
try {
|
||||
$context = $listener.GetContext()
|
||||
$request = $context.Request
|
||||
$response = $context.Response
|
||||
|
||||
$requestMethod = $request.HttpMethod
|
||||
$requestUrl = $request.Url.ToString()
|
||||
$remoteIp = $request.RemoteEndPoint.Address.ToString()
|
||||
|
||||
Log-Step "$requestMethod $requestUrl (from $remoteIp)"
|
||||
|
||||
if ($requestMethod -eq "GET" -and ($requestUrl -notmatch '/webhook$')) {
|
||||
# Health check / root page
|
||||
$html = @"
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><title>GitLink Webhook Listener</title>
|
||||
<style>body{font-family:sans-serif;max-width:800px;margin:40px auto;padding:20px}
|
||||
h1{color:#333}.status{color:green;font-weight:bold}code{background:#f0f0f0;padding:2px 6px;border-radius:3px}</style>
|
||||
</head><body>
|
||||
<h1>GitLink Community Ops — Webhook Listener</h1>
|
||||
<p class="status">✓ Running</p>
|
||||
<p>Listening for <code>issue</code> events at <code>${listenUrl}webhook</code></p>
|
||||
<p>When a new Issue is created, this server will:</p>
|
||||
<ol>
|
||||
<li>Receive the webhook payload from GitLink</li>
|
||||
<li>Validate HMAC signature (if secret is configured)</li>
|
||||
<li>Extract the issue number</li>
|
||||
<li>Call <code>01a-issue-triage.ps1</code> to AI-classify and assign</li>
|
||||
</ol>
|
||||
<p><small>Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Port: $Port | HMAC: $(if($Secret){'enabled'}else{'disabled'})</small></p>
|
||||
</body></html>
|
||||
"@
|
||||
$buffer = [System.Text.Encoding]::UTF8.GetBytes($html)
|
||||
$response.ContentType = "text/html; charset=utf-8"
|
||||
$response.ContentLength64 = $buffer.Length
|
||||
$response.OutputStream.Write($buffer, 0, $buffer.Length)
|
||||
$response.OutputStream.Close()
|
||||
Log-Ok "Health check OK"
|
||||
continue
|
||||
}
|
||||
|
||||
# Read request body
|
||||
$reader = New-Object System.IO.StreamReader($request.InputStream, $request.ContentEncoding)
|
||||
$body = $reader.ReadToEnd()
|
||||
$reader.Close()
|
||||
|
||||
# Get event headers
|
||||
$eventType = $request.Headers.Get("X-GitLink-Event")
|
||||
if (-not $eventType) {
|
||||
$eventType = $request.Headers.Get("X-GitHub-Event") # GitHub-compatible
|
||||
}
|
||||
if (-not $eventType) {
|
||||
$eventType = $request.Headers.Get("X-Event-Type")
|
||||
}
|
||||
|
||||
$signatureHeader = $request.Headers.Get("X-GitLink-Signature")
|
||||
if (-not $signatureHeader) {
|
||||
$signatureHeader = $request.Headers.Get("X-Hub-Signature-256") # GitHub-compatible
|
||||
}
|
||||
|
||||
Log-Info "Event: $eventType | Body length: $($body.Length) bytes"
|
||||
|
||||
# Process the webhook
|
||||
$result = Invoke-WebhookHandler -Body $body -EventType $eventType -EventHeader $eventType -SignatureHeader $signatureHeader
|
||||
|
||||
# Send response
|
||||
$response.StatusCode = $result.StatusCode
|
||||
$responseBuffer = [System.Text.Encoding]::UTF8.GetBytes($result.Body)
|
||||
$response.ContentType = "application/json; charset=utf-8"
|
||||
$response.ContentLength64 = $responseBuffer.Length
|
||||
$response.OutputStream.Write($responseBuffer, 0, $responseBuffer.Length)
|
||||
$response.OutputStream.Close()
|
||||
|
||||
} catch [System.Net.HttpListenerException] {
|
||||
if ($_.Exception.ErrorCode -eq 995) {
|
||||
# Operation aborted — likely shutting down
|
||||
Log-Info "Listener shutting down..."
|
||||
$keepRunning = $false
|
||||
} else {
|
||||
Log-Err "HTTP error: $_"
|
||||
}
|
||||
} catch {
|
||||
Log-Err "Unexpected error: $_"
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
}
|
||||
|
||||
# Cleanup
|
||||
if ($listener -and $listener.IsListening) {
|
||||
$listener.Stop()
|
||||
$listener.Close()
|
||||
Log-Ok "HTTP listener stopped"
|
||||
}
|
||||
|
||||
Log-Ok "Webhook listener exited"
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
#!/usr/bin/env python3
|
||||
# ================================================================
|
||||
# Scenario 1a: Webhook HTTP Listener (Linux 版)
|
||||
# Role: HTTP server 监听 GitLink webhook → 解析 Issue 编号 → 调用分类脚本
|
||||
#
|
||||
# 部署: systemd 管理,端口 8080,无需 root(用 systemd socket activation 或 sudo)
|
||||
# 依赖: Python 3.6+ (无需额外 pip 包)
|
||||
# ================================================================
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import subprocess
|
||||
import threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from datetime import datetime
|
||||
|
||||
# ── 配置 ────────────────────────────────────────────────────────
|
||||
PORT = int(os.environ.get("WEBHOOK_PORT", "8080"))
|
||||
SECRET = os.environ.get("WEBHOOK_SECRET", "")
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
TRIAGE_SCRIPT = os.path.join(SCRIPT_DIR, "01a-issue-triage.sh")
|
||||
GATEKEEPER_SCRIPT = os.path.join(SCRIPT_DIR, "02a-pr-gatekeeper.sh")
|
||||
LOG_DIR = os.path.join(SCRIPT_DIR, "webhook-logs")
|
||||
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def log(msg, level="INFO"):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
color = {"INFO": "\033[36m", "OK": "\033[32m", "WARN": "\033[33m", "ERR": "\033[31m"}.get(level, "")
|
||||
reset = "\033[0m"
|
||||
print(f"[{ts}] {color}[{level:>4}]{reset} {msg}", flush=True)
|
||||
# Also append to log file
|
||||
logfile = os.path.join(LOG_DIR, datetime.now().strftime("webhook-%Y%m%d.log"))
|
||||
with open(logfile, "a", encoding="utf-8") as f:
|
||||
f.write(f"[{ts}] [{level:>4}] {msg}\n")
|
||||
|
||||
|
||||
def verify_signature(body: bytes, signature_header: str) -> bool:
|
||||
"""HMAC-SHA256 签名验证"""
|
||||
if not SECRET:
|
||||
return True # 未配置密钥,跳过验证
|
||||
if not signature_header:
|
||||
log("No signature header in request", "WARN")
|
||||
return False
|
||||
|
||||
try:
|
||||
expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
|
||||
# Support both "sha256=abc..." and plain "abc..." formats
|
||||
received = signature_header
|
||||
if received.startswith("sha256="):
|
||||
received = received[7:]
|
||||
return hmac.compare_digest(expected, received)
|
||||
except Exception as e:
|
||||
log(f"HMAC error: {e}", "ERR")
|
||||
return False
|
||||
|
||||
|
||||
def extract_issue_number(payload: dict) -> str:
|
||||
"""从 webhook payload 提取 Issue 编号"""
|
||||
issue = payload.get("issue", {})
|
||||
if issue:
|
||||
num = issue.get("project_issues_index") or issue.get("number") or issue.get("id")
|
||||
if num:
|
||||
log(f"Extracted issue number: #{num}", "OK")
|
||||
return str(num)
|
||||
if "number" in payload:
|
||||
return str(payload["number"])
|
||||
if "id" in payload:
|
||||
return str(payload["id"])
|
||||
log("Could not extract issue number from payload", "WARN")
|
||||
return None
|
||||
|
||||
|
||||
def extract_pr_number(payload: dict) -> str:
|
||||
"""从 webhook payload 提取 PR 编号"""
|
||||
pr = payload.get("pull_request", {}) or payload.get("pullrequest", {})
|
||||
if pr:
|
||||
num = pr.get("number") or pr.get("pull_request_number") or pr.get("id")
|
||||
if num:
|
||||
log(f"Extracted PR number: #{num}", "OK")
|
||||
return str(num)
|
||||
if "pull_request_number" in payload:
|
||||
return str(payload["pull_request_number"])
|
||||
log("Could not extract PR number from payload", "WARN")
|
||||
return None
|
||||
|
||||
|
||||
def extract_repo_info(payload: dict):
|
||||
"""从 payload 提取 owner/repo"""
|
||||
repo = payload.get("repository", {})
|
||||
owner = repo.get("owner", {})
|
||||
owner_name = owner.get("login") or owner.get("username") or str(owner) if isinstance(owner, dict) else str(owner)
|
||||
repo_name = repo.get("name", "")
|
||||
return owner_name, repo_name
|
||||
|
||||
|
||||
def run_triage_async(issue_number: str, owner: str = "", repo: str = ""):
|
||||
"""异步调用分类脚本,另起线程避免阻塞 webhook 响应"""
|
||||
def _run():
|
||||
cmd = ["bash", TRIAGE_SCRIPT, "--issue-number", issue_number]
|
||||
if owner:
|
||||
cmd += ["--owner", owner]
|
||||
if repo:
|
||||
cmd += ["--repo", repo]
|
||||
|
||||
log(f"Dispatching triage: {' '.join(cmd)}", "INFO")
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, cwd=SCRIPT_DIR)
|
||||
# Save output to log
|
||||
outfile = os.path.join(LOG_DIR, f"triage-{issue_number}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log")
|
||||
with open(outfile, "w", encoding="utf-8") as f:
|
||||
f.write(f"=== STDOUT ===\n{result.stdout}\n=== STDERR ===\n{result.stderr}\n")
|
||||
if result.returncode == 0:
|
||||
log(f"Triage #{issue_number} completed successfully → {outfile}", "OK")
|
||||
else:
|
||||
log(f"Triage #{issue_number} failed (exit={result.returncode}) → {outfile}", "ERR")
|
||||
except subprocess.TimeoutExpired:
|
||||
log(f"Triage #{issue_number} TIMEOUT after 120s", "ERR")
|
||||
except Exception as e:
|
||||
log(f"Triage #{issue_number} error: {e}", "ERR")
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
def run_gatekeeper_async(pr_number: str, owner: str = "", repo: str = ""):
|
||||
"""异步调用 PR gatekeeper 脚本"""
|
||||
def _run():
|
||||
cmd = ["bash", GATEKEEPER_SCRIPT, "--pr-id", pr_number]
|
||||
if owner:
|
||||
cmd += ["--owner", owner]
|
||||
if repo:
|
||||
cmd += ["--repo", repo]
|
||||
|
||||
log(f"Dispatching gatekeeper: {' '.join(cmd)}", "INFO")
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, cwd=SCRIPT_DIR)
|
||||
outfile = os.path.join(LOG_DIR, f"gatekeeper-{pr_number}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log")
|
||||
with open(outfile, "w", encoding="utf-8") as f:
|
||||
f.write(f"=== STDOUT ===\n{result.stdout}\n=== STDERR ===\n{result.stderr}\n")
|
||||
if result.returncode == 0:
|
||||
log(f"Gatekeeper PR #{pr_number} completed successfully -> {outfile}", "OK")
|
||||
else:
|
||||
log(f"Gatekeeper PR #{pr_number} failed (exit={result.returncode}) -> {outfile}", "ERR")
|
||||
except subprocess.TimeoutExpired:
|
||||
log(f"Gatekeeper PR #{pr_number} TIMEOUT after 300s", "ERR")
|
||||
except Exception as e:
|
||||
log(f"Gatekeeper PR #{pr_number} error: {e}", "ERR")
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
class WebhookHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
log(f"{self.client_address[0]} - {format % args}", "INFO")
|
||||
|
||||
def do_GET(self):
|
||||
"""健康检查 / 根页面"""
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><title>GitLink Webhook Listener</title>
|
||||
<style>body{{font-family:sans-serif;max-width:800px;margin:40px auto;padding:20px}}
|
||||
h1{{color:#333}}.ok{{color:green;font-weight:bold}}code{{background:#f0f0f0;padding:2px 6px;border-radius:3px}}</style>
|
||||
</head><body>
|
||||
<h1>GitLink Community Ops — Webhook Listener</h1>
|
||||
<p class="ok">✓ Running</p>
|
||||
<p>Listening for <code>issue</code> and <code>pull_request</code> events at <code>/webhook</code></p>
|
||||
<p><small>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Port: {PORT} | HMAC: {'enabled' if SECRET else 'disabled'}</small></p>
|
||||
</body></html>"""
|
||||
self._respond(200, html, "text/html")
|
||||
|
||||
def do_POST(self):
|
||||
"""接收 webhook"""
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length) if content_length > 0 else b""
|
||||
|
||||
event_type = self.headers.get("X-GitLink-Event") or self.headers.get("X-GitHub-Event") or ""
|
||||
signature = self.headers.get("X-GitLink-Signature") or self.headers.get("X-Hub-Signature-256") or ""
|
||||
|
||||
log(f"POST {self.path} | Event: {event_type} | Size: {content_length}B | From: {self.client_address[0]}")
|
||||
|
||||
# HMAC 验证
|
||||
if SECRET and not verify_signature(body, signature):
|
||||
self._respond(403, '{"error":"Invalid signature"}')
|
||||
return
|
||||
|
||||
# 解析 payload
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
log("Failed to parse JSON payload", "ERR")
|
||||
self._respond(400, '{"error":"Invalid JSON"}')
|
||||
return
|
||||
|
||||
action = payload.get("action", "")
|
||||
is_issue = "issue" in event_type.lower()
|
||||
is_pr = "pull_request" in event_type.lower() or "pullrequest" in event_type.lower()
|
||||
|
||||
if not is_issue and not is_pr:
|
||||
log(f"Ignoring unsupported event: {event_type}", "INFO")
|
||||
self._respond(200, '{"status":"ignored","reason":"unsupported event type"}')
|
||||
return
|
||||
|
||||
# 只处理 "opened" 动作
|
||||
if action and action != "opened":
|
||||
log(f"Ignoring {event_type} event with action: {action}", "INFO")
|
||||
self._respond(200, f'{{"status":"ignored","reason":"action={action}"}}')
|
||||
return
|
||||
|
||||
# 提取 owner/repo
|
||||
owner, repo = extract_repo_info(payload)
|
||||
|
||||
# ── Issue 事件 → 调起分类 ──
|
||||
if is_issue:
|
||||
issue_number = extract_issue_number(payload)
|
||||
if not issue_number:
|
||||
self._respond(400, '{"error":"Cannot extract issue number"}')
|
||||
return
|
||||
log(f"=== New Issue #{issue_number} — dispatching to triage ===", "OK")
|
||||
run_triage_async(issue_number, owner or "", repo or "")
|
||||
self._respond(200, f'{{"status":"accepted","issue_number":{issue_number}}}')
|
||||
return
|
||||
|
||||
# ── PR 事件 → 调起 gatekeeper ──
|
||||
if is_pr:
|
||||
pr_number = extract_pr_number(payload)
|
||||
if not pr_number:
|
||||
self._respond(400, '{"error":"Cannot extract PR number"}')
|
||||
return
|
||||
log(f"=== New PR #{pr_number} — dispatching to gatekeeper ===", "OK")
|
||||
run_gatekeeper_async(pr_number, owner or "", repo or "")
|
||||
self._respond(200, f'{{"status":"accepted","pr_number":{pr_number}}}')
|
||||
return
|
||||
|
||||
def _respond(self, code, body, content_type="application/json"):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body.encode("utf-8"))
|
||||
|
||||
|
||||
def main():
|
||||
print()
|
||||
print("\033[36m╔══════════════════════════════════════════════════════════════╗\033[0m")
|
||||
print("\033[36m║ GitLink Community Ops — Webhook Listener (Linux) ║\033[0m")
|
||||
print("\033[36m╚══════════════════════════════════════════════════════════════╝\033[0m")
|
||||
print()
|
||||
|
||||
log(f"Starting on port {PORT}")
|
||||
log(f" Issue -> {os.path.basename(TRIAGE_SCRIPT)}")
|
||||
log(f" PR -> {os.path.basename(GATEKEEPER_SCRIPT)}")
|
||||
log(f"HMAC verification: {'ENABLED' if SECRET else 'DISABLED (set WEBHOOK_SECRET env var)'}")
|
||||
print()
|
||||
|
||||
server = HTTPServer(("0.0.0.0", PORT), WebhookHandler)
|
||||
log(f"Listening on http://0.0.0.0:{PORT}/webhook", "OK")
|
||||
log(f"Health check: http://0.0.0.0:{PORT}/", "OK")
|
||||
print()
|
||||
log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "OK")
|
||||
log(" Press Ctrl+C to stop", "OK")
|
||||
log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "OK")
|
||||
print()
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
log("Shutting down...", "INFO")
|
||||
server.shutdown()
|
||||
log("Stopped", "OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
# ----------------------------------------------------------------
|
||||
# Scenario 1a: Webhook Setup — Register on GitLink Platform
|
||||
# Role: 在 GitLink 平台上注册 webhook,连接"平台事件"到"本地接收器"
|
||||
#
|
||||
# 这是整个实时链路的最后一块拼图——把 GitLink 平台的 Issue 创建事件
|
||||
# 和本地的 01a-webhook-listener.ps1 接收器连接起来。
|
||||
#
|
||||
# 完整链路:
|
||||
# GitLink Issue 创建
|
||||
# → Webhook POST 到 <WebhookUrl>
|
||||
# → 01a-webhook-listener.ps1 (接收 HTTP 请求)
|
||||
# → 01a-issue-triage.ps1 (AI 分类 + 打标签 + 分配)
|
||||
#
|
||||
# 前置条件:
|
||||
# 方案A (本地开发): 先启动 ngrok → 再启动 01a-webhook-listener.ps1 → 再运行本脚本
|
||||
# 方案B (公网服务器): 先启动 01a-webhook-listener.ps1 → 再运行本脚本(直接给公网URL)
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
|
||||
param(
|
||||
[string]$WebhookUrl = "",
|
||||
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[string]$Secret = "",
|
||||
[string]$Events = "issue",
|
||||
[string]$Description = "Community Ops — Issue Auto-Triage (created by gitlink-cli)",
|
||||
[switch]$DryRun,
|
||||
[switch]$ListExisting,
|
||||
[switch]$DeleteExisting,
|
||||
[switch]$Force,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 01a-webhook-setup.ps1 -WebhookUrl URL [-Owner OWNER] [-Repo REPO] [-Secret SECRET] [-Events EVENTS] [-DryRun]"
|
||||
Write-Host ""
|
||||
Write-Host " 在 GitLink 平台上注册 webhook,将 Issue 创建事件连接到本地接收器。"
|
||||
Write-Host ""
|
||||
Write-Host " -WebhookUrl URL (必填) Webhook 回调地址,GitLink 会向此 URL POST 事件"
|
||||
Write-Host " - 本地开发: https://xxx.ngrok-free.app/webhook"
|
||||
Write-Host " - 公网服务器: https://your-server.com:8080/webhook"
|
||||
Write-Host " -Owner OWNER 仓库所有者(在git仓库内可自动检测)"
|
||||
Write-Host " -Repo REPO 仓库名称(在git仓库内可自动检测)"
|
||||
Write-Host " -Secret SECRET HMAC 密钥(需与 01a-webhook-listener.ps1 的 -Secret 一致)"
|
||||
Write-Host " -Events EVENTS 触发事件类型(默认: issue)"
|
||||
Write-Host " -Description DESC Webhook 描述"
|
||||
Write-Host " -ListExisting 列出当前仓库已有的 webhook"
|
||||
Write-Host " -DeleteExisting 删除当前仓库所有非 gitlink-cli 创建的 issue 类 webhook(需配合 -Force)"
|
||||
Write-Host " -Force 配合 -DeleteExisting 使用"
|
||||
Write-Host " -DryRun 预览模式"
|
||||
Write-Host ""
|
||||
Write-Host " 部署步骤:"
|
||||
Write-Host " # 终端 1: 启动 ngrok(本地开发)"
|
||||
Write-Host " ngrok http 8080"
|
||||
Write-Host ""
|
||||
Write-Host " # 终端 2: 启动接收器"
|
||||
Write-Host " powershell workflows/01a-webhook-listener.ps1 -Port 8080 -Secret 'your-secret'"
|
||||
Write-Host ""
|
||||
Write-Host " # 终端 3: 注册 webhook(ngrok 提供的 URL)"
|
||||
Write-Host " powershell workflows/01a-webhook-setup.ps1 -WebhookUrl 'https://xxx.ngrok-free.app/webhook' -Secret 'your-secret'"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $WebhookUrl) { Log-Err "WebhookUrl is required (use -Help for usage)"; exit 1 }
|
||||
|
||||
Check-Auth
|
||||
$r = Resolve-OwnerRepo $Owner $Repo
|
||||
$Owner = $r.Owner; $Repo = $r.Repo
|
||||
|
||||
# ================================================================
|
||||
# Validation
|
||||
# ================================================================
|
||||
Log-Title "Webhook Setup: $Owner/$Repo"
|
||||
|
||||
# Validate URL format
|
||||
if ($WebhookUrl -notmatch '^https?://') {
|
||||
Log-Err "Webhook URL must start with http:// or https://"
|
||||
Log-Info "For GitLink platform, HTTPS is required. Use ngrok for local dev: ngrok http 8080"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($WebhookUrl -notmatch '^https://') {
|
||||
Log-Warn "GitLink requires HTTPS URLs for webhooks. Your URL uses HTTP which may be rejected."
|
||||
Log-Info "Consider using ngrok to get an HTTPS URL: ngrok http <port>"
|
||||
}
|
||||
|
||||
Write-Host " Owner: $Owner"
|
||||
Write-Host " Repo: $Repo"
|
||||
Write-Host " Webhook URL: $WebhookUrl"
|
||||
Write-Host " Events: $Events"
|
||||
Write-Host " Secret: $(if ($Secret) { '***configured***' } else { '(not set)' })"
|
||||
Write-Host " Description: $Description"
|
||||
Divider
|
||||
|
||||
# ================================================================
|
||||
# List existing webhooks
|
||||
# ================================================================
|
||||
if ($ListExisting -or $DeleteExisting) {
|
||||
Log-Title "Existing Webhooks"
|
||||
|
||||
$listResult = Invoke-GL webhook,+list,--owner,$Owner,--repo,$Repo
|
||||
if (-not $listResult) {
|
||||
Log-Warn "Could not list webhooks (API may not be available or no webhooks)"
|
||||
} else {
|
||||
try {
|
||||
$listData = $listResult | ConvertFrom-Json
|
||||
if ($listData.ok -and $listData.data) {
|
||||
$webhooks = @()
|
||||
if ($listData.data.webhooks) { $webhooks = @($listData.data.webhooks) }
|
||||
elseif ($listData.data -is [array]) { $webhooks = $listData.data }
|
||||
|
||||
if ($webhooks.Count -eq 0) {
|
||||
Log-Info "No webhooks configured for this repo"
|
||||
} else {
|
||||
Write-Host ""
|
||||
foreach ($wh in $webhooks) {
|
||||
$whId = if ($wh.id) { $wh.id } else { "?" }
|
||||
$whUrl = if ($wh.hook_url) { $wh.hook_url } elseif ($wh.url) { $wh.url } else { "?" }
|
||||
$whActive = if ($wh.is_active -ne $null) { $wh.is_active } elseif ($wh.active -ne $null) { $wh.active } else { "?" }
|
||||
$whEvents = if ($wh.events) { ($wh.events -join ',') } else { "?" }
|
||||
$whDesc = if ($wh.description) { $wh.description } else { "(no description)" }
|
||||
Write-Host " [#$whId] $whUrl" -ForegroundColor Cyan
|
||||
Write-Host " Active: $whActive | Events: $whEvents"
|
||||
Write-Host " $whDesc"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log-Warn "Could not parse webhook list: $_"
|
||||
Log-Info "Raw output: $listResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Delete existing issue-related webhooks (cleanup before create)
|
||||
# ================================================================
|
||||
if ($DeleteExisting) {
|
||||
if (-not $Force) {
|
||||
Log-Warn "-DeleteExisting requires -Force flag for safety. Add -Force to confirm deletion."
|
||||
} else {
|
||||
Log-Warn "Removing existing webhooks that match issue events..."
|
||||
|
||||
$listResult = Invoke-GL webhook,+list,--owner,$Owner,--repo,$Repo
|
||||
if ($listResult) {
|
||||
try {
|
||||
$listData = $listResult | ConvertFrom-Json
|
||||
$webhooks = @()
|
||||
if ($listData.data.webhooks) { $webhooks = @($listData.data.webhooks) }
|
||||
elseif ($listData.data -is [array]) { $webhooks = $listData.data }
|
||||
|
||||
foreach ($wh in $webhooks) {
|
||||
$whId = if ($wh.id) { $wh.id } else { $null }
|
||||
$whEvents = if ($wh.events) { $wh.events } else { @() }
|
||||
$whUrl = if ($wh.hook_url) { $wh.hook_url } else { "" }
|
||||
|
||||
# Only delete issue-related ones that point to gitlink-cli created URLs
|
||||
$isIssueWebhook = ($whEvents -contains "issue") -or ($whEvents -is [string] -and $whEvents -match "issue")
|
||||
if ($isIssueWebhook) {
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would delete webhook #$whId ($whUrl)"
|
||||
} else {
|
||||
Log-Step "Deleting webhook #$whId..."
|
||||
$delResult = Invoke-GL webhook,+delete,--owner,$Owner,--repo,$Repo,--id,$whId
|
||||
if ($delResult) {
|
||||
Log-Ok "Deleted webhook #$whId"
|
||||
} else {
|
||||
Log-Warn "Failed to delete webhook #$whId"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $ListExisting) {
|
||||
Log-Info "Cleanup complete. Proceeding to create new webhook..."
|
||||
} else {
|
||||
# User just wanted to list, exit
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
if ($ListExisting) { exit 0 }
|
||||
|
||||
# ================================================================
|
||||
# Check for existing webhook with same URL
|
||||
# ================================================================
|
||||
Log-Step "Checking for existing webhooks with same URL..."
|
||||
$listResult = Invoke-GL webhook,+list,--owner,$Owner,--repo,$Repo
|
||||
$existingId = $null
|
||||
if ($listResult) {
|
||||
try {
|
||||
$listData = $listResult | ConvertFrom-Json
|
||||
$webhooks = @()
|
||||
if ($listData.data.webhooks) { $webhooks = @($listData.data.webhooks) }
|
||||
elseif ($listData.data -is [array]) { $webhooks = $listData.data }
|
||||
|
||||
foreach ($wh in $webhooks) {
|
||||
if ($wh.hook_url -eq $WebhookUrl -or $wh.url -eq $WebhookUrl) {
|
||||
$existingId = $wh.id
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if ($existingId) {
|
||||
Log-Warn "A webhook with URL '$WebhookUrl' already exists (ID: #$existingId)"
|
||||
Log-Info "To replace it, delete the existing one first with:"
|
||||
Log-Info " gitlink-cli webhook +delete --owner $Owner --repo $Repo --id $existingId"
|
||||
Log-Info "Or run this script with -DeleteExisting -Force to clean up"
|
||||
exit 1
|
||||
}
|
||||
Log-Ok "No duplicate webhook found"
|
||||
|
||||
# ================================================================
|
||||
# Register Webhook on GitLink
|
||||
# ================================================================
|
||||
Log-Title "Registering Webhook"
|
||||
|
||||
# Build command arguments
|
||||
$createArgs = @(
|
||||
"webhook", "+create",
|
||||
"--owner", $Owner,
|
||||
"--repo", $Repo,
|
||||
"--url", $WebhookUrl,
|
||||
"--events", $Events,
|
||||
"--description", $Description
|
||||
)
|
||||
|
||||
if ($Secret) {
|
||||
$createArgs += @("--secret", $Secret)
|
||||
}
|
||||
|
||||
Log-Step "Creating webhook on GitLink platform..."
|
||||
Log-Info "Command: gitlink-cli $($createArgs -join ' ')"
|
||||
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would create webhook with above parameters"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$createResult = Invoke-GLCheck @createArgs
|
||||
if (-not $createResult) {
|
||||
Log-Err "Webhook creation failed"
|
||||
Log-Info "Common issues:"
|
||||
Log-Info " 1. URL must be HTTPS (GitLink requirement)"
|
||||
Log-Info " 2. URL must be publicly accessible from GitLink's servers"
|
||||
Log-Info " 3. You may need admin permissions on the repo"
|
||||
Log-Info " 4. Max 20 webhooks per repo — use -ListExisting to check"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$webhookId = ""
|
||||
if ($createResult.data.id) { $webhookId = $createResult.data.id }
|
||||
elseif ($createResult.data.webhook.id) { $webhookId = $createResult.data.webhook.id }
|
||||
Log-Ok "Webhook created! ID: #$webhookId"
|
||||
|
||||
# ================================================================
|
||||
# Test Webhook
|
||||
# ================================================================
|
||||
Log-Step "Testing webhook connectivity..."
|
||||
$testResult = Invoke-GL webhook,+test,--owner,$Owner,--repo,$Repo,--id,$webhookId,--event,issue
|
||||
if ($testResult) {
|
||||
try {
|
||||
$testOk = (($testResult | ConvertFrom-Json).ok -eq $true)
|
||||
} catch { $testOk = $false }
|
||||
|
||||
if ($testOk) {
|
||||
Log-Ok "Webhook test ping sent successfully"
|
||||
Log-Info "Check the listener console for the test event"
|
||||
} else {
|
||||
Log-Warn "Webhook test may have failed — check that your listener is running and accessible"
|
||||
Log-Info "Verify: curl -X POST $WebhookUrl -H 'Content-Type: application/json' -d '{}'"
|
||||
}
|
||||
} else {
|
||||
Log-Warn "Could not test webhook — check that your listener is running"
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Complete
|
||||
# ================================================================
|
||||
Log-Title "Webhook Setup Complete"
|
||||
Write-Host ""
|
||||
|
||||
$checkmark = [char]0x2714
|
||||
Write-Host " ${checkmark} GitLink Platform: Webhook registered" -ForegroundColor Green
|
||||
Write-Host " → When a new Issue is created in $Owner/$Repo" -ForegroundColor Gray
|
||||
Write-Host " → GitLink POSTs to: $WebhookUrl" -ForegroundColor Gray
|
||||
Write-Host " → Webhook ID: #$webhookId" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host " ${checkmark} Local Listener: 01a-webhook-listener.ps1" -ForegroundColor Green
|
||||
Write-Host " → Receives HTTP POST from GitLink" -ForegroundColor Gray
|
||||
Write-Host " → Validates HMAC signature" -ForegroundColor Gray
|
||||
Write-Host " → Calls 01a-issue-triage.ps1" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host " ${checkmark} Triage Script: 01a-issue-triage.ps1" -ForegroundColor Green
|
||||
Write-Host " → AI analyzes issue content" -ForegroundColor Gray
|
||||
Write-Host " → Selects matching label from repo's existing labels" -ForegroundColor Gray
|
||||
Write-Host " → Assigns to issue creator" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
|
||||
Write-Host " 验证方式:" -ForegroundColor Cyan
|
||||
Write-Host " 1. 在 GitLink 网页端 $Owner/$Repo 创建一个新 Issue" -ForegroundColor White
|
||||
Write-Host " 2. 观察 01a-webhook-listener.ps1 的控制台输出" -ForegroundColor White
|
||||
Write-Host " 3. 检查 Issue 是否自动被打上标签并分配了负责人" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host " 手动测试(不走 webhook,直接触发分类):" -ForegroundColor Cyan
|
||||
Write-Host " powershell workflows/01a-issue-triage.ps1 -IssueNumber <N>" -ForegroundColor White
|
||||
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host " Webhook 管理:" -ForegroundColor Cyan
|
||||
Write-Host " - 查看: gitlink-cli webhook +list"
|
||||
Write-Host " - 详情: gitlink-cli webhook +info --id $webhookId"
|
||||
Write-Host " - 删除: gitlink-cli webhook +delete --id $webhookId"
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env bash
|
||||
# ================================================================
|
||||
# Scenario 1a: Webhook Setup — 在 GitLink 平台注册 webhook
|
||||
# ================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
WEBHOOK_URL=""
|
||||
OWNER=""
|
||||
REPO=""
|
||||
SECRET=""
|
||||
EVENTS="issue"
|
||||
DESCRIPTION="Community Ops - Issue Auto-Triage (gitlink-cli)"
|
||||
DRY_RUN=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --webhook-url URL [--owner OWNER] [--repo REPO] [--secret SECRET] [--events EVENTS] [--dry-run]"
|
||||
echo ""
|
||||
echo " 在 GitLink 平台注册 webhook,连接 Issue 创建事件到服务器接收器。"
|
||||
echo ""
|
||||
echo " --webhook-url URL (必填) Webhook 回调地址"
|
||||
echo " 例: https://your-server.com:8080/webhook"
|
||||
echo " --owner OWNER 仓库所有者"
|
||||
echo " --repo REPO 仓库名称"
|
||||
echo " --secret SECRET HMAC 密钥(需与监听器环境变量一致)"
|
||||
echo " --events EVENTS 触发事件(默认: issue)"
|
||||
echo " --dry-run 预览模式"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--webhook-url) WEBHOOK_URL="$2"; shift 2 ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--secret) SECRET="$2"; shift 2 ;;
|
||||
--events) EVENTS="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) log_err "Unknown arg: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$WEBHOOK_URL" ]] && { log_err "--webhook-url is required"; usage; }
|
||||
|
||||
check_auth
|
||||
require_owner_repo
|
||||
|
||||
log_title "Webhook Setup: $OWNER/$REPO"
|
||||
echo " Webhook URL: $WEBHOOK_URL"
|
||||
echo " Events: $EVENTS"
|
||||
echo " Secret: $( [[ -n "$SECRET" ]] && echo '***configured***' || echo '(not set)' )"
|
||||
|
||||
# 检查重复
|
||||
log_step "Checking for existing webhooks with same URL..."
|
||||
EXISTING_ID=$(gl_run webhook +list --owner "$OWNER" --repo "$REPO" 2>/dev/null | \
|
||||
jq -r --arg url "$WEBHOOK_URL" '(.data.webhooks // .data // [])[] | select(.url == $url or .hook_url == $url) | .id' 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$EXISTING_ID" && "$EXISTING_ID" != "null" ]]; then
|
||||
log_warn "A webhook with URL '$WEBHOOK_URL' already exists (ID: #$EXISTING_ID)"
|
||||
log_info "Delete it first: gitlink-cli webhook +delete --id $EXISTING_ID"
|
||||
exit 1
|
||||
fi
|
||||
log_ok "No duplicate found"
|
||||
|
||||
# 注册
|
||||
log_step "Creating webhook on GitLink..."
|
||||
|
||||
CREATE_ARGS=(webhook +create --owner "$OWNER" --repo "$REPO" --url "$WEBHOOK_URL" --events "$EVENTS" --description "$DESCRIPTION")
|
||||
[[ -n "$SECRET" ]] && CREATE_ARGS+=(--secret "$SECRET")
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would run: gitlink-cli ${CREATE_ARGS[*]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CREATE_RESULT=$(gl_check "${CREATE_ARGS[@]}" 2>&1) || {
|
||||
log_err "Webhook creation failed"
|
||||
log_info "Make sure the URL is HTTPS and publicly accessible"
|
||||
exit 1
|
||||
}
|
||||
|
||||
WEBHOOK_ID=$(echo "$CREATE_RESULT" | jq -r '.data.id // .data.webhook.id')
|
||||
log_ok "Webhook created! ID: #$WEBHOOK_ID"
|
||||
|
||||
# 测试
|
||||
log_step "Testing webhook connectivity..."
|
||||
gl_run webhook +test --owner "$OWNER" --repo "$REPO" --id "$WEBHOOK_ID" --event issue > /dev/null 2>&1 && {
|
||||
log_ok "Webhook test ping sent successfully"
|
||||
} || {
|
||||
log_warn "Webhook test failed — check that the listener is running"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN} ✓ Webhook registered: https://www.gitlink.org.cn/$OWNER/$REPO${NC}"
|
||||
echo " → When a new Issue is created"
|
||||
echo " → GitLink POSTs to: $WEBHOOK_URL"
|
||||
echo " → Webhook ID: #$WEBHOOK_ID"
|
||||
|
|
@ -0,0 +1,567 @@
|
|||
# ----------------------------------------------------------------
|
||||
# Scenario 2: Code Quality Gatekeeper
|
||||
# Flow: PR submit -> AI Review -> Check CI -> Auto-merge if pass
|
||||
#
|
||||
# Commands chained:
|
||||
# 1. pr +list -- list open PRs
|
||||
# 2. pr +view -- get PR details
|
||||
# 3. pr +files -- get changed files
|
||||
# 4. pr +diff -- get diff content
|
||||
# 5. gitlink-code-review -- AI code review (claude CLI or keyword fallback)
|
||||
# 6. api POST .../reviews -- post review comment
|
||||
# 7. ci +builds -- check CI status
|
||||
# 8. pr +merge -- auto-merge if quality threshold met
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
|
||||
param(
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[string]$PrId = "",
|
||||
[int]$Threshold = 80,
|
||||
[switch]$DryRun,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 02-code-quality-gatekeeper.ps1 -Owner OWNER -Repo REPO [-PrId ID] [-Threshold SCORE] [-DryRun]"
|
||||
Write-Host ""
|
||||
Write-Host " -Owner OWNER Repository owner"
|
||||
Write-Host " -Repo REPO Repository name"
|
||||
Write-Host " -PrId ID Specific PR to review (default: all open PRs)"
|
||||
Write-Host " -Threshold SCORE Min quality score to auto-merge (default: 80)"
|
||||
Write-Host " -DryRun Preview actions without executing"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Check-Auth
|
||||
$r = Resolve-OwnerRepo $Owner $Repo
|
||||
$Owner = $r.Owner; $Repo = $r.Repo
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Default fallback strings (defined at script scope to avoid indented here-string issue)
|
||||
$DefaultSkillDimensions = "1. 代码质量: 复杂度、命名、注释、格式`n2. 安全性: SQL注入、XSS、敏感信息、认证、输入验证`n3. 性能: 循环效率、资源泄漏、N+1查询、内存`n4. 可维护性: 代码重复、职责单一、依赖耦合、测试覆盖"
|
||||
|
||||
$DefaultReviewPrompt = @'
|
||||
你是代码审查专家。请按 gitlink-code-review skill 的审查维度分析以下 PR。
|
||||
|
||||
## 审查维度与检查项
|
||||
|
||||
{SKILL_DIMENSIONS}
|
||||
|
||||
## 评分标准
|
||||
- 90-100: 优秀,可直接合并
|
||||
- 75-89: 良好,建议合并
|
||||
- 60-74: 一般,需要改进
|
||||
- <60: 较差,不建议合并
|
||||
|
||||
## 问题严重级别
|
||||
- CRITICAL: 阻止合并
|
||||
- HIGH: 强烈建议修复
|
||||
- MEDIUM: 建议修复
|
||||
- LOW: 可选修复
|
||||
|
||||
## PR 数据
|
||||
|
||||
PR 标题: {PR_TITLE}
|
||||
变更文件:
|
||||
{FILE_LIST}
|
||||
代码差异:
|
||||
{DIFF_CONTENT}
|
||||
|
||||
## 输出要求
|
||||
|
||||
请严格按以下 JSON 格式输出,不要输出其他内容:
|
||||
{"total": <0-100>, "quality": <0-25>, "security": <0-25>, "performance": <0-25>, "maintainability": <0-25>, "issues": [{"severity": "HIGH/MEDIUM/LOW", "category": "quality/security/performance/maintainability", "file": "文件路径", "rule": "规则名", "description": "问题描述", "suggestion": "修复建议"}], "positive_notes": [{"description": "优秀实践描述"}], "recommendations": ["改进建议1"], "verdict": "PASS或FAIL"}
|
||||
'@
|
||||
|
||||
function Review-PR {
|
||||
param([string]$PrId)
|
||||
|
||||
Log-Title "Reviewing PR #$PrId"
|
||||
|
||||
# -- Step 1: Get PR details --
|
||||
Log-Step "Fetching PR details..."
|
||||
$prJson = Invoke-GLCheck pr,+view,--owner,$Owner,--repo,$Repo,--id,$PrId
|
||||
if (-not $prJson) {
|
||||
Log-Warn "Failed to fetch PR #$PrId, skipping"
|
||||
return
|
||||
}
|
||||
$prData = $prJson.data
|
||||
$prTitle = if ($prData.title) { $prData.title }
|
||||
elseif ($prData.subject) { $prData.subject }
|
||||
elseif ($prData.issue.subject) { $prData.issue.subject }
|
||||
else { "N/A" }
|
||||
$prState = if ($prData.state) { $prData.state }
|
||||
elseif ($prData.status) { $prData.status }
|
||||
else { "N/A" }
|
||||
$prAuthor = if ($prData.author.login) { $prData.author.login }
|
||||
elseif ($prData.author.username) { $prData.author.username }
|
||||
elseif ($prData.issue.author.login) { $prData.issue.author.login }
|
||||
else { "N/A" }
|
||||
Log-Ok "PR #${PrId}: `"$prTitle`" by @$prAuthor (state: $prState)"
|
||||
|
||||
# -- Step 2: Get changed files --
|
||||
Log-Step "Fetching changed files..."
|
||||
$filesJson = Invoke-GL pr,+files,--owner,$Owner,--repo,$Repo,--id,$PrId
|
||||
$fileNames = @()
|
||||
$fileCount = 0
|
||||
if ($filesJson) {
|
||||
try {
|
||||
$filesData = $filesJson | ConvertFrom-Json
|
||||
if ($filesData.data.files) {
|
||||
$fileNames = @($filesData.data.files | ForEach-Object {
|
||||
if ($_.name) { $_.name } elseif ($_.filename) { $_.filename } else { "unknown" }
|
||||
})
|
||||
$fileCount = $fileNames.Count
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
Log-Ok "Changed files: $fileCount"
|
||||
foreach ($f in $fileNames) { Write-Host " $f" }
|
||||
|
||||
# -- Step 3: Get diff --
|
||||
Log-Step "Fetching diff..."
|
||||
$diffContent = ""
|
||||
$diffLines = 0
|
||||
$diffJson = Invoke-GL pr,+diff,--owner,$Owner,--repo,$Repo,--id,$PrId
|
||||
if ($diffJson) {
|
||||
try {
|
||||
$diffData = $diffJson | ConvertFrom-Json
|
||||
$diffLines_arr = @()
|
||||
if ($diffData.data.files) {
|
||||
foreach ($f in $diffData.data.files) {
|
||||
if ($f.sections) {
|
||||
foreach ($s in $f.sections) {
|
||||
if ($s.lines) {
|
||||
foreach ($l in $s.lines) {
|
||||
if ($l.content) { $diffLines_arr += $l.content }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$diffContent = ($diffLines_arr -join "`n")
|
||||
if ($diffContent.Length -gt 5000) { $diffContent = $diffContent.Substring(0, 5000) }
|
||||
$diffLines = $diffLines_arr.Count
|
||||
} catch { }
|
||||
}
|
||||
Log-Ok "Diff: $diffLines lines"
|
||||
|
||||
# -- Step 4: AI-powered code review --
|
||||
Log-Step "AI analyzing code quality..."
|
||||
|
||||
$fileListText = ""
|
||||
foreach ($f in $fileNames) { $fileListText += "- $f`n" }
|
||||
|
||||
$diffTruncated = $diffContent
|
||||
if ($diffTruncated.Length -gt 4000) { $diffTruncated = $diffTruncated.Substring(0, 4000) }
|
||||
|
||||
# Load skill dimensions from SKILL.md
|
||||
$skillDir = "$PSScriptRoot/../skills/gitlink-code-review"
|
||||
$skillDimensions = ""
|
||||
if (Test-Path "$skillDir/SKILL.md") {
|
||||
$skillMd = Get-Content "$skillDir/SKILL.md" -Raw -Encoding UTF8
|
||||
if ($skillMd -match '(?s)## 📊 审查维度(.+?)## 🔧 使用方式') {
|
||||
$section = $Matches[1]
|
||||
$dimLines = ($section -split "`n" | Where-Object { $_ -match '^- \*\*' } | Select-Object -First 20)
|
||||
$skillDimensions = $dimLines -join "`n"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $skillDimensions) {
|
||||
$skillDimensions = $DefaultSkillDimensions
|
||||
}
|
||||
|
||||
$reviewPrompt = $DefaultReviewPrompt -replace '\{PR_TITLE\}', $prTitle -replace '\{FILE_LIST\}', $fileListText -replace '\{DIFF_CONTENT\}', $diffTruncated -replace '\{SKILL_DIMENSIONS\}', $skillDimensions
|
||||
|
||||
$aiAvailable = $false
|
||||
$totalScore = 0
|
||||
$scoreQuality = 25
|
||||
$scoreSecurity = 25
|
||||
$scorePerformance = 25
|
||||
$scoreMaintainability = 25
|
||||
$issuesFound = @()
|
||||
$aiPositive = @()
|
||||
$aiRecommendations = @()
|
||||
$aiVerdict = "PASS"
|
||||
|
||||
$claudePath = (Get-Command claude -ErrorAction SilentlyContinue).Source
|
||||
if ($claudePath) {
|
||||
Log-Info "Calling AI agent for code review (may take 30-60s)..."
|
||||
|
||||
try {
|
||||
$promptFile = [System.IO.Path]::GetTempFileName()
|
||||
$outFile = [System.IO.Path]::GetTempFileName()
|
||||
[System.IO.File]::WriteAllText($promptFile, $reviewPrompt, [System.Text.Encoding]::UTF8)
|
||||
|
||||
$aiResult = $null
|
||||
$proc = Start-Process -FilePath $claudePath `
|
||||
-ArgumentList @("-p", "--output-format", "json") `
|
||||
-RedirectStandardInput $promptFile `
|
||||
-RedirectStandardOutput $outFile `
|
||||
-NoNewWindow -Wait -PassThru
|
||||
|
||||
if ($proc.ExitCode -eq 0 -and (Test-Path $outFile)) {
|
||||
$aiOutput = [System.IO.File]::ReadAllText($outFile, [System.Text.Encoding]::UTF8)
|
||||
try {
|
||||
$aiOutputJson = $aiOutput | ConvertFrom-Json
|
||||
$aiResult = $aiOutputJson.result
|
||||
} catch {
|
||||
$aiResult = $aiOutput
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item $promptFile -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $outFile -Force -ErrorAction SilentlyContinue
|
||||
|
||||
if ($aiResult) {
|
||||
# Extract JSON from AI response (may contain markdown wrapping)
|
||||
$aiJson = $null
|
||||
$jsonCandidate = Extract-JsonBlock $aiResult
|
||||
if ($jsonCandidate) {
|
||||
try {
|
||||
$testJson = $jsonCandidate | ConvertFrom-Json
|
||||
if ($testJson.total -ne $null -and $testJson.verdict) {
|
||||
$aiJson = $testJson
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
# Fallback: try direct parse
|
||||
if (-not $aiJson) {
|
||||
try {
|
||||
$testJson = $aiResult | ConvertFrom-Json
|
||||
if ($testJson.total -ne $null -and $testJson.verdict) {
|
||||
$aiJson = $testJson
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if ($aiJson) {
|
||||
$totalScore = [int]($aiJson.total -as [int])
|
||||
$scoreQuality = [int]($aiJson.quality -as [int])
|
||||
$scoreSecurity = [int]($aiJson.security -as [int])
|
||||
$scorePerformance = [int]($aiJson.performance -as [int])
|
||||
$scoreMaintainability = [int]($aiJson.maintainability -as [int])
|
||||
$aiVerdict = if ($aiJson.verdict) { $aiJson.verdict } else { "PASS" }
|
||||
|
||||
if ($aiJson.issues) {
|
||||
foreach ($issue in $aiJson.issues) {
|
||||
if ($issue -is [string]) {
|
||||
$issuesFound += $issue
|
||||
} else {
|
||||
$sev = if ($issue.severity) { $issue.severity } else { "?" }
|
||||
$cat = if ($issue.category) { $issue.category } else { "?" }
|
||||
$desc = if ($issue.description) { $issue.description }
|
||||
elseif ($issue.rule) { $issue.rule } else { "unknown" }
|
||||
$file = if ($issue.file) { " ($($issue.file))" } else { "" }
|
||||
$sug = if ($issue.suggestion) { " -> $($issue.suggestion)" } else { "" }
|
||||
$issuesFound += "[$sev] ${cat}: $desc${file}${sug}"
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($aiJson.positive_notes) {
|
||||
foreach ($note in $aiJson.positive_notes) {
|
||||
if ($note.description) { $aiPositive += $note.description }
|
||||
elseif ($note -is [string]) { $aiPositive += $note }
|
||||
}
|
||||
}
|
||||
if ($aiJson.recommendations) {
|
||||
foreach ($rec in $aiJson.recommendations) {
|
||||
if ($rec -is [string]) { $aiRecommendations += $rec }
|
||||
}
|
||||
}
|
||||
|
||||
$aiAvailable = $true
|
||||
Log-Ok "AI review complete (verdict: $aiVerdict)"
|
||||
} else {
|
||||
Log-Warn "Could not parse AI response JSON, falling back to keyword-based"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log-Warn "AI call failed: $_"
|
||||
}
|
||||
}
|
||||
|
||||
# -- Fallback: keyword-based heuristics --
|
||||
if (-not $aiAvailable) {
|
||||
Log-Warn "AI not available, falling back to keyword-based analysis"
|
||||
|
||||
$scoreQuality = 25
|
||||
$scoreSecurity = 25
|
||||
$scorePerformance = 25
|
||||
$scoreMaintainability = 25
|
||||
$issuesFound = @()
|
||||
|
||||
if ($diffContent -match '(?i)password|secret|token|api_key|apikey|private_key') {
|
||||
$scoreSecurity -= 15
|
||||
$issuesFound += "SECURITY: 检测到可能的硬编码凭证"
|
||||
}
|
||||
if ($diffContent -match '(?i)eval\(|exec\(|system\(|shell_exec|os\.system|subprocess\.call') {
|
||||
$scoreSecurity -= 10
|
||||
$issuesFound += "SECURITY: 检测到危险函数调用"
|
||||
}
|
||||
if ($diffContent -match '(?i)TODO|FIXME|HACK|XXX') {
|
||||
$scoreQuality -= 5
|
||||
$issuesFound += "QUALITY: 存在 TODO/FIXME/HACK 注释"
|
||||
}
|
||||
if ($diffContent -match '(?i)SELECT \*|\.findAll\(\)|\.all\(\)') {
|
||||
$scorePerformance -= 10
|
||||
$issuesFound += "PERFORMANCE: 可能的全表查询"
|
||||
}
|
||||
if ($diffContent -match '(?i)sleep\(|time\.sleep|Thread\.sleep') {
|
||||
$scorePerformance -= 5
|
||||
$issuesFound += "PERFORMANCE: 检测到阻塞式 sleep"
|
||||
}
|
||||
if ($fileCount -gt 20) {
|
||||
$scoreMaintainability -= 10
|
||||
$issuesFound += "MAINTAINABILITY: 变更文件数量过多 ($fileCount)"
|
||||
}
|
||||
|
||||
$totalScore = [Math]::Max(0, $scoreQuality + $scoreSecurity + $scorePerformance + $scoreMaintainability)
|
||||
}
|
||||
|
||||
# -- Print review report --
|
||||
Divider
|
||||
if ($aiAvailable) {
|
||||
Log-Info "AI Review Report for PR #$PrId"
|
||||
} else {
|
||||
Log-Info "Review Report for PR #$PrId (keyword-based)"
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host " Overall Score: $totalScore / 100"
|
||||
Write-Host " Code Quality: $scoreQuality / 25"
|
||||
Write-Host " Security: $scoreSecurity / 25"
|
||||
Write-Host " Performance: $scorePerformance / 25"
|
||||
Write-Host " Maintainability: $scoreMaintainability / 25"
|
||||
Write-Host ""
|
||||
|
||||
if ($issuesFound.Count -gt 0) {
|
||||
Write-Host " Issues Found:"
|
||||
foreach ($issue in $issuesFound) {
|
||||
Write-Host " - $issue"
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if ($aiPositive.Count -gt 0) {
|
||||
Write-Host " Positive Notes:"
|
||||
foreach ($note in $aiPositive) {
|
||||
Write-Host " + $note"
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if ($aiRecommendations.Count -gt 0) {
|
||||
Write-Host " Recommendations:"
|
||||
foreach ($rec in $aiRecommendations) {
|
||||
Write-Host " > $rec"
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# -- Step 5: Post review comment --
|
||||
if ($aiAvailable) {
|
||||
$reviewHeader = "## AI Code Quality Review - PR #$PrId"
|
||||
} else {
|
||||
$reviewHeader = "## Code Quality Review - PR #$PrId (keyword-based)"
|
||||
}
|
||||
|
||||
$reviewBody = "$reviewHeader`n`n### Scores`n"
|
||||
$reviewBody += "| Dimension | Score | Max |`n"
|
||||
$reviewBody += "|-----------|-------|-----|`n"
|
||||
$reviewBody += "| Code Quality | $scoreQuality | 25 |`n"
|
||||
$reviewBody += "| Security | $scoreSecurity | 25 |`n"
|
||||
$reviewBody += "| Performance | $scorePerformance | 25 |`n"
|
||||
$reviewBody += "| Maintainability | $scoreMaintainability | 25 |`n"
|
||||
$reviewBody += "| **Total** | **$totalScore** | **100** |`n`n"
|
||||
$reviewBody += "### Issues Found`n"
|
||||
|
||||
if ($issuesFound.Count -gt 0) {
|
||||
foreach ($issue in $issuesFound) {
|
||||
$reviewBody += "- $issue`n"
|
||||
}
|
||||
} else {
|
||||
$reviewBody += "No issues found.`n"
|
||||
}
|
||||
|
||||
if ($aiPositive.Count -gt 0) {
|
||||
$reviewBody += "`n### Positive Notes`n"
|
||||
foreach ($note in $aiPositive) {
|
||||
$reviewBody += "- $note`n"
|
||||
}
|
||||
}
|
||||
|
||||
if ($aiRecommendations.Count -gt 0) {
|
||||
$reviewBody += "`n### Recommendations`n"
|
||||
foreach ($rec in $aiRecommendations) {
|
||||
$reviewBody += "- $rec`n"
|
||||
}
|
||||
}
|
||||
|
||||
$verdictText = if ($totalScore -ge $Threshold) {
|
||||
"**PASS** - Score $totalScore >= threshold $Threshold. Ready to merge."
|
||||
} else {
|
||||
"**FAIL** - Score $totalScore < threshold $Threshold. Please address the issues above."
|
||||
}
|
||||
$reviewBody += "`n### Verdict`n${verdictText}`n`n---`n*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*"
|
||||
|
||||
Log-Step "Posting review comment..."
|
||||
$reviewEvent = if ($totalScore -ge $Threshold) { "APPROVE" } else { "COMMENT" }
|
||||
$reviewPayload = @{
|
||||
body = $reviewBody
|
||||
event = $reviewEvent
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
$reviewResult = Invoke-GL api,POST,"/$Owner/$Repo/pulls/$PrId/reviews",--body,$reviewPayload
|
||||
if ($reviewResult) {
|
||||
try {
|
||||
$reviewOk = (($reviewResult | ConvertFrom-Json).ok -eq $true)
|
||||
} catch { $reviewOk = $false }
|
||||
if ($reviewOk) {
|
||||
Log-Ok "Review posted"
|
||||
} else {
|
||||
Log-Warn "Review post may have failed (review API might not be available)"
|
||||
}
|
||||
} else {
|
||||
Log-Warn "Review post may have failed"
|
||||
}
|
||||
|
||||
# -- Step 6: Check CI --
|
||||
Log-Step "Checking CI build status..."
|
||||
$ciPresent = $false
|
||||
$ciPassed = $true
|
||||
$ciJson = Invoke-GL ci,+builds,--owner,$Owner,--repo,$Repo
|
||||
if ($ciJson) {
|
||||
try {
|
||||
$ciData = $ciJson | ConvertFrom-Json
|
||||
$builds = @()
|
||||
if ($ciData.data.builds) { $builds = @($ciData.data.builds) }
|
||||
elseif ($ciData.data -is [array]) { $builds = @($ciData.data) }
|
||||
if ($builds.Count -gt 0) {
|
||||
$ciPresent = $true
|
||||
foreach ($b in $builds) {
|
||||
$status = if ($b.status) { $b.status } elseif ($b.state) { $b.state } else { "unknown" }
|
||||
$name = if ($b.name) { $b.name } else { "build" }
|
||||
if ($status -notin @("success", "passed", "completed")) {
|
||||
$ciPassed = $false
|
||||
Log-Warn "CI '$name' status: $status"
|
||||
} else {
|
||||
Log-Ok "CI '$name' status: $status"
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
if (-not $ciPresent) { Log-Info "No CI builds found" }
|
||||
|
||||
# -- Step 7: Auto-merge --
|
||||
if ($totalScore -ge $Threshold -and $ciPassed) {
|
||||
Log-Step "Quality score $totalScore >= $Threshold and CI passed"
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would auto-merge PR #$PrId"
|
||||
} else {
|
||||
Log-Step "Auto-merging PR #$PrId..."
|
||||
$mergeResult = Invoke-GL pr,+merge,--owner,$Owner,--repo,$Repo,--id,$PrId,--method,merge
|
||||
if ($mergeResult) {
|
||||
try {
|
||||
$mergeOk = (($mergeResult | ConvertFrom-Json).ok -eq $true)
|
||||
} catch { $mergeOk = $false }
|
||||
if ($mergeOk) {
|
||||
Log-Ok "PR #$PrId merged successfully!"
|
||||
} else {
|
||||
Log-Err "Auto-merge failed"
|
||||
}
|
||||
} else {
|
||||
Log-Err "Auto-merge failed"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log-Warn "PR #$PrId not auto-merged (score: $totalScore, threshold: $Threshold, CI passed: $ciPassed)"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
function Extract-JsonBlock {
|
||||
param([string]$Text)
|
||||
# Find JSON by balanced brace matching
|
||||
$depth = 0
|
||||
$start = -1
|
||||
$results = @()
|
||||
for ($i = 0; $i -lt $Text.Length; $i++) {
|
||||
$c = $Text[$i]
|
||||
if ($c -eq '{') {
|
||||
if ($depth -eq 0) { $start = $i }
|
||||
$depth++
|
||||
} elseif ($c -eq '}') {
|
||||
$depth--
|
||||
if ($depth -eq 0 -and $start -ge 0) {
|
||||
$results += $Text.Substring($start, $i - $start + 1)
|
||||
$start = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
# Return the last valid JSON object (usually the most complete)
|
||||
for ($i = $results.Count - 1; $i -ge 0; $i--) {
|
||||
try {
|
||||
$obj = $results[$i] | ConvertFrom-Json
|
||||
if ($obj.total -ne $null -and $obj.verdict) {
|
||||
return $results[$i]
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Main
|
||||
# ================================================================
|
||||
Log-Title "Code Quality Gatekeeper"
|
||||
|
||||
if ($PrId) {
|
||||
Review-PR $PrId
|
||||
} else {
|
||||
Log-Step "Fetching open PRs..."
|
||||
$prsResult = Invoke-GL pr,+list,--owner,$Owner,--repo,$Repo,--state,open,--limit,50
|
||||
$prList = @()
|
||||
if ($prsResult) {
|
||||
try {
|
||||
$prsJson = $prsResult | ConvertFrom-Json
|
||||
$prData = $prsJson.data
|
||||
if ($prData.issues) { $prList = @($prData.issues) }
|
||||
elseif ($prData.pulls) { $prList = @($prData.pulls) }
|
||||
elseif ($prData -is [array]) { $prList = $prData }
|
||||
} catch { }
|
||||
}
|
||||
$prCount = $prList.Count
|
||||
Log-Ok "Found $prCount open PRs"
|
||||
|
||||
if ($prCount -eq 0) {
|
||||
Log-Info "No open PRs to review"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$reviewed = 0
|
||||
$passed = 0
|
||||
$failed = 0
|
||||
|
||||
foreach ($pr in $prList) {
|
||||
$prNum = if ($pr.pull_request_number) { $pr.pull_request_number }
|
||||
elseif ($pr.number) { $pr.number }
|
||||
elseif ($pr.id) { $pr.id }
|
||||
else { $null }
|
||||
if (-not $prNum) { continue }
|
||||
Review-PR $prNum
|
||||
$reviewed++
|
||||
}
|
||||
|
||||
Log-Title "Gatekeeper Summary"
|
||||
Write-Host " PRs Reviewed: $reviewed" -ForegroundColor Green
|
||||
Write-Host " Threshold: $Threshold" -ForegroundColor Green
|
||||
}
|
||||
|
|
@ -403,9 +403,9 @@ $(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "**PASS** - Score $TOTAL_SCORE
|
|||
*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*"
|
||||
|
||||
log_step "Posting review comment..."
|
||||
REVIEW_EVENT=$(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "APPROVE"; else echo "COMMENT"; fi)
|
||||
REVIEW_JSON=$(jq -n --arg body "$REVIEW_BODY" --arg event "$REVIEW_EVENT" \
|
||||
'{body: $body, event: $event}')
|
||||
REVIEW_STATE=$(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "approved"; else echo "commented"; fi)
|
||||
REVIEW_JSON=$(jq -n --arg body "$REVIEW_BODY" --arg state "$REVIEW_STATE" \
|
||||
'{body: $body, state: $state}')
|
||||
REVIEW_RESULT=$(gl_run api POST "/$OWNER/$REPO/pulls/$pr_id/reviews" \
|
||||
--body "$REVIEW_JSON" 2>&1) || true
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,413 @@
|
|||
# ----------------------------------------------------------------
|
||||
# Scenario 2a: Real-Time PR Gatekeeper (Webhook 触发)
|
||||
# Flow: Webhook 推送 PR → 拉取详情 → AI Review → 查 CI → 发评论 → 达标自动合并
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
|
||||
param(
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[string]$PrId = "",
|
||||
[int]$Threshold = 80,
|
||||
[switch]$DryRun,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 02a-pr-gatekeeper.ps1 -PrId ID [-Owner OWNER] [-Repo REPO] [-Threshold SCORE] [-DryRun]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $PrId) { Log-Err "PrId is required (use -Help for usage)"; exit 1 }
|
||||
|
||||
Check-Auth
|
||||
$r = Resolve-OwnerRepo $Owner $Repo
|
||||
$Owner = $r.Owner; $Repo = $r.Repo
|
||||
|
||||
# Default fallback strings
|
||||
$DefaultSkillDimensions = "1. 代码质量: 复杂度、命名、注释、格式`n2. 安全性: SQL注入、XSS、敏感信息、认证、输入验证`n3. 性能: 循环效率、资源泄漏、N+1查询、内存`n4. 可维护性: 代码重复、职责单一、依赖耦合、测试覆盖"
|
||||
|
||||
$DefaultReviewPrompt = @'
|
||||
你是代码审查专家。请按 gitlink-code-review skill 的审查维度分析以下 PR。
|
||||
|
||||
## 审查维度与检查项
|
||||
|
||||
{SKILL_DIMENSIONS}
|
||||
|
||||
## 评分标准
|
||||
- 90-100: 优秀,可直接合并
|
||||
- 75-89: 良好,建议合并
|
||||
- 60-74: 一般,需要改进
|
||||
- <60: 较差,不建议合并
|
||||
|
||||
## 问题严重级别
|
||||
- CRITICAL: 阻止合并
|
||||
- HIGH: 强烈建议修复
|
||||
- MEDIUM: 建议修复
|
||||
- LOW: 可选修复
|
||||
|
||||
## PR 数据
|
||||
|
||||
PR 标题: {PR_TITLE}
|
||||
变更文件:
|
||||
{FILE_LIST}
|
||||
代码差异:
|
||||
{DIFF_CONTENT}
|
||||
|
||||
## 输出要求
|
||||
|
||||
请严格按以下 JSON 格式输出,不要输出其他内容:
|
||||
{"total": <0-100>, "quality": <0-25>, "security": <0-25>, "performance": <0-25>, "maintainability": <0-25>, "issues": [{"severity": "HIGH/MEDIUM/LOW", "category": "quality/security/performance/maintainability", "file": "文件路径", "rule": "规则名", "description": "问题描述", "suggestion": "修复建议"}], "positive_notes": [{"description": "优秀实践描述"}], "recommendations": ["改进建议1"], "verdict": "PASS或FAIL"}
|
||||
'@
|
||||
|
||||
function Extract-JsonBlock {
|
||||
param([string]$Text)
|
||||
$depth = 0; $start = -1; $results = @()
|
||||
for ($i = 0; $i -lt $Text.Length; $i++) {
|
||||
$c = $Text[$i]
|
||||
if ($c -eq '{') {
|
||||
if ($depth -eq 0) { $start = $i }
|
||||
$depth++
|
||||
} elseif ($c -eq '}') {
|
||||
$depth--
|
||||
if ($depth -eq 0 -and $start -ge 0) {
|
||||
$results += $Text.Substring($start, $i - $start + 1)
|
||||
$start = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
for ($i = $results.Count - 1; $i -ge 0; $i--) {
|
||||
try {
|
||||
$obj = $results[$i] | ConvertFrom-Json
|
||||
if ($obj.total -ne $null -and $obj.verdict) { return $results[$i] }
|
||||
} catch { }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
Log-Title "PR Gatekeeper: #$PrId ($Owner/$Repo)"
|
||||
|
||||
# -- Step 1: 拉取 PR 详情 --
|
||||
Log-Step "Fetching PR details..."
|
||||
$prJson = Invoke-GLCheck pr,+view,--owner,$Owner,--repo,$Repo,--id,$PrId
|
||||
if (-not $prJson) { Log-Err "Failed to fetch PR #$PrId"; exit 1 }
|
||||
|
||||
$prData = $prJson.data
|
||||
$prTitle = if ($prData.title) { $prData.title }
|
||||
elseif ($prData.subject) { $prData.subject }
|
||||
elseif ($prData.issue.subject) { $prData.issue.subject }
|
||||
else { "N/A" }
|
||||
$prState = if ($prData.state) { $prData.state }
|
||||
elseif ($prData.status) { $prData.status }
|
||||
else { "N/A" }
|
||||
$prAuthor = if ($prData.author.login) { $prData.author.login }
|
||||
elseif ($prData.author.username) { $prData.author.username }
|
||||
elseif ($prData.issue.author.login) { $prData.issue.author.login }
|
||||
else { "N/A" }
|
||||
Log-Ok "PR #${PrId}: `"$prTitle`" by @$prAuthor (state: $prState)"
|
||||
|
||||
# -- Step 2: 获取变更文件列表 --
|
||||
Log-Step "Fetching changed files..."
|
||||
$filesJson = Invoke-GL pr,+files,--owner,$Owner,--repo,$Repo,--id,$PrId
|
||||
$fileNames = @(); $fileCount = 0
|
||||
if ($filesJson) {
|
||||
try {
|
||||
$filesData = $filesJson | ConvertFrom-Json
|
||||
if ($filesData.data.files) {
|
||||
$fileNames = @($filesData.data.files | ForEach-Object {
|
||||
if ($_.name) { $_.name } elseif ($_.filename) { $_.filename } else { "unknown" }
|
||||
})
|
||||
$fileCount = $fileNames.Count
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
Log-Ok "Changed files: $fileCount"
|
||||
foreach ($f in $fileNames) { Write-Host " $f" }
|
||||
|
||||
# -- Step 3: 获取 diff --
|
||||
Log-Step "Fetching diff..."
|
||||
$diffContent = ""; $diffLines = 0
|
||||
$diffJson = Invoke-GL pr,+diff,--owner,$Owner,--repo,$Repo,--id,$PrId
|
||||
if ($diffJson) {
|
||||
try {
|
||||
$diffData = $diffJson | ConvertFrom-Json
|
||||
$diffLines_arr = @()
|
||||
if ($diffData.data.files) {
|
||||
foreach ($f in $diffData.data.files) {
|
||||
if ($f.sections) {
|
||||
foreach ($s in $f.sections) {
|
||||
if ($s.lines) {
|
||||
foreach ($l in $s.lines) {
|
||||
if ($l.content) { $diffLines_arr += $l.content }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$diffContent = ($diffLines_arr -join "`n")
|
||||
if ($diffContent.Length -gt 5000) { $diffContent = $diffContent.Substring(0, 5000) }
|
||||
$diffLines = $diffLines_arr.Count
|
||||
} catch { }
|
||||
}
|
||||
Log-Ok "Diff: $diffLines lines"
|
||||
|
||||
# -- Step 4: AI 代码审查 --
|
||||
Log-Step "AI analyzing code quality..."
|
||||
|
||||
$fileListText = ""
|
||||
foreach ($f in $fileNames) { $fileListText += "- $f`n" }
|
||||
|
||||
$diffTruncated = $diffContent
|
||||
if ($diffTruncated.Length -gt 4000) { $diffTruncated = $diffTruncated.Substring(0, 4000) }
|
||||
|
||||
$skillDir = "$PSScriptRoot/../skills/gitlink-code-review"
|
||||
$skillDimensions = ""
|
||||
if (Test-Path "$skillDir/SKILL.md") {
|
||||
$skillMd = Get-Content "$skillDir/SKILL.md" -Raw -Encoding UTF8
|
||||
if ($skillMd -match '(?s)## 📊 审查维度(.+?)## 🔧 使用方式') {
|
||||
$section = $Matches[1]
|
||||
$dimLines = ($section -split "`n" | Where-Object { $_ -match '^- \*\*' } | Select-Object -First 20)
|
||||
$skillDimensions = $dimLines -join "`n"
|
||||
}
|
||||
}
|
||||
if (-not $skillDimensions) { $skillDimensions = $DefaultSkillDimensions }
|
||||
|
||||
$reviewPrompt = $DefaultReviewPrompt -replace '\{PR_TITLE\}', $prTitle -replace '\{FILE_LIST\}', $fileListText -replace '\{DIFF_CONTENT\}', $diffTruncated -replace '\{SKILL_DIMENSIONS\}', $skillDimensions
|
||||
|
||||
$aiAvailable = $false
|
||||
$totalScore = 0
|
||||
$scoreQuality = 25; $scoreSecurity = 25; $scorePerformance = 25; $scoreMaintainability = 25
|
||||
$issuesFound = @(); $aiPositive = @(); $aiRecommendations = @(); $aiVerdict = "PASS"
|
||||
|
||||
$claudePath = (Get-Command claude -ErrorAction SilentlyContinue).Source
|
||||
if ($claudePath) {
|
||||
Log-Info "Calling AI agent for code review (may take 30-60s)..."
|
||||
try {
|
||||
$promptFile = [System.IO.Path]::GetTempFileName()
|
||||
$outFile = [System.IO.Path]::GetTempFileName()
|
||||
[System.IO.File]::WriteAllText($promptFile, $reviewPrompt, [System.Text.Encoding]::UTF8)
|
||||
|
||||
$proc = Start-Process -FilePath $claudePath `
|
||||
-ArgumentList @("-p", "--output-format", "json") `
|
||||
-RedirectStandardInput $promptFile `
|
||||
-RedirectStandardOutput $outFile `
|
||||
-NoNewWindow -Wait -PassThru
|
||||
|
||||
if ($proc.ExitCode -eq 0 -and (Test-Path $outFile)) {
|
||||
$aiOutput = [System.IO.File]::ReadAllText($outFile, [System.Text.Encoding]::UTF8)
|
||||
try { $aiResult = ($aiOutput | ConvertFrom-Json).result } catch { $aiResult = $aiOutput }
|
||||
}
|
||||
|
||||
Remove-Item $promptFile -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $outFile -Force -ErrorAction SilentlyContinue
|
||||
|
||||
if ($aiResult) {
|
||||
$aiJson = $null
|
||||
$jsonCandidate = Extract-JsonBlock $aiResult
|
||||
if ($jsonCandidate) {
|
||||
try {
|
||||
$testJson = $jsonCandidate | ConvertFrom-Json
|
||||
if ($testJson.total -ne $null -and $testJson.verdict) { $aiJson = $testJson }
|
||||
} catch { }
|
||||
}
|
||||
if (-not $aiJson) {
|
||||
try {
|
||||
$testJson = $aiResult | ConvertFrom-Json
|
||||
if ($testJson.total -ne $null -and $testJson.verdict) { $aiJson = $testJson }
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if ($aiJson) {
|
||||
$totalScore = [int]($aiJson.total -as [int])
|
||||
$scoreQuality = [int]($aiJson.quality -as [int])
|
||||
$scoreSecurity = [int]($aiJson.security -as [int])
|
||||
$scorePerformance = [int]($aiJson.performance -as [int])
|
||||
$scoreMaintainability = [int]($aiJson.maintainability -as [int])
|
||||
$aiVerdict = if ($aiJson.verdict) { $aiJson.verdict } else { "PASS" }
|
||||
|
||||
if ($aiJson.issues) {
|
||||
foreach ($issue in $aiJson.issues) {
|
||||
if ($issue -is [string]) { $issuesFound += $issue }
|
||||
else {
|
||||
$sev = if ($issue.severity) { $issue.severity } else { "?" }
|
||||
$cat = if ($issue.category) { $issue.category } else { "?" }
|
||||
$desc = if ($issue.description) { $issue.description }
|
||||
elseif ($issue.rule) { $issue.rule } else { "unknown" }
|
||||
$file = if ($issue.file) { " ($($issue.file))" } else { "" }
|
||||
$sug = if ($issue.suggestion) { " -> $($issue.suggestion)" } else { "" }
|
||||
$issuesFound += "[$sev] ${cat}: $desc${file}${sug}"
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($aiJson.positive_notes) {
|
||||
foreach ($note in $aiJson.positive_notes) {
|
||||
if ($note.description) { $aiPositive += $note.description }
|
||||
elseif ($note -is [string]) { $aiPositive += $note }
|
||||
}
|
||||
}
|
||||
if ($aiJson.recommendations) {
|
||||
foreach ($rec in $aiJson.recommendations) {
|
||||
if ($rec -is [string]) { $aiRecommendations += $rec }
|
||||
}
|
||||
}
|
||||
$aiAvailable = $true
|
||||
Log-Ok "AI review complete (verdict: $aiVerdict)"
|
||||
} else {
|
||||
Log-Warn "Could not parse AI response JSON, falling back to keyword-based"
|
||||
}
|
||||
}
|
||||
} catch { Log-Warn "AI call failed: $_" }
|
||||
}
|
||||
|
||||
# -- Fallback: keyword-based --
|
||||
if (-not $aiAvailable) {
|
||||
Log-Warn "AI not available, falling back to keyword-based analysis"
|
||||
$scoreQuality = 25; $scoreSecurity = 25; $scorePerformance = 25; $scoreMaintainability = 25
|
||||
$issuesFound = @()
|
||||
|
||||
if ($diffContent -match '(?i)password|secret|token|api_key|apikey|private_key') {
|
||||
$scoreSecurity -= 15; $issuesFound += "SECURITY: 检测到可能的硬编码凭证"
|
||||
}
|
||||
if ($diffContent -match '(?i)eval\(|exec\(|system\(|shell_exec|os\.system|subprocess\.call') {
|
||||
$scoreSecurity -= 10; $issuesFound += "SECURITY: 检测到危险函数调用"
|
||||
}
|
||||
if ($diffContent -match '(?i)TODO|FIXME|HACK|XXX') {
|
||||
$scoreQuality -= 5; $issuesFound += "QUALITY: 存在 TODO/FIXME/HACK 注释"
|
||||
}
|
||||
if ($diffContent -match '(?i)SELECT \*|\.findAll\(\)|\.all\(\)') {
|
||||
$scorePerformance -= 10; $issuesFound += "PERFORMANCE: 可能的全表查询"
|
||||
}
|
||||
if ($diffContent -match '(?i)sleep\(|time\.sleep|Thread\.sleep') {
|
||||
$scorePerformance -= 5; $issuesFound += "PERFORMANCE: 检测到阻塞式 sleep"
|
||||
}
|
||||
if ($fileCount -gt 20) {
|
||||
$scoreMaintainability -= 10; $issuesFound += "MAINTAINABILITY: 变更文件数量过多 ($fileCount)"
|
||||
}
|
||||
$totalScore = [Math]::Max(0, $scoreQuality + $scoreSecurity + $scorePerformance + $scoreMaintainability)
|
||||
}
|
||||
|
||||
# -- 打印审查报告 --
|
||||
Divider
|
||||
if ($aiAvailable) { Log-Info "AI Review Report for PR #$PrId" }
|
||||
else { Log-Info "Review Report for PR #$PrId (keyword-based)" }
|
||||
Write-Host ""
|
||||
Write-Host " Overall Score: $totalScore / 100"
|
||||
Write-Host " Code Quality: $scoreQuality / 25"
|
||||
Write-Host " Security: $scoreSecurity / 25"
|
||||
Write-Host " Performance: $scorePerformance / 25"
|
||||
Write-Host " Maintainability: $scoreMaintainability / 25"
|
||||
Write-Host ""
|
||||
|
||||
if ($issuesFound.Count -gt 0) {
|
||||
Write-Host " Issues Found:"
|
||||
foreach ($issue in $issuesFound) { Write-Host " - $issue" }
|
||||
Write-Host ""
|
||||
}
|
||||
if ($aiPositive.Count -gt 0) {
|
||||
Write-Host " Positive Notes:"
|
||||
foreach ($note in $aiPositive) { Write-Host " + $note" }
|
||||
Write-Host ""
|
||||
}
|
||||
if ($aiRecommendations.Count -gt 0) {
|
||||
Write-Host " Recommendations:"
|
||||
foreach ($rec in $aiRecommendations) { Write-Host " > $rec" }
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# -- Step 5: 发布审查评论 --
|
||||
if ($aiAvailable) { $reviewHeader = "## AI Code Quality Review - PR #$PrId" }
|
||||
else { $reviewHeader = "## Code Quality Review - PR #$PrId (keyword-based)" }
|
||||
|
||||
$reviewBody = "$reviewHeader`n`n### Scores`n"
|
||||
$reviewBody += "| Dimension | Score | Max |`n"
|
||||
$reviewBody += "|-----------|-------|-----|`n"
|
||||
$reviewBody += "| Code Quality | $scoreQuality | 25 |`n"
|
||||
$reviewBody += "| Security | $scoreSecurity | 25 |`n"
|
||||
$reviewBody += "| Performance | $scorePerformance | 25 |`n"
|
||||
$reviewBody += "| Maintainability | $scoreMaintainability | 25 |`n"
|
||||
$reviewBody += "| **Total** | **$totalScore** | **100** |`n`n"
|
||||
$reviewBody += "### Issues Found`n"
|
||||
|
||||
if ($issuesFound.Count -gt 0) {
|
||||
foreach ($issue in $issuesFound) { $reviewBody += "- $issue`n" }
|
||||
} else { $reviewBody += "No issues found.`n" }
|
||||
|
||||
if ($aiPositive.Count -gt 0) {
|
||||
$reviewBody += "`n### Positive Notes`n"
|
||||
foreach ($note in $aiPositive) { $reviewBody += "- $note`n" }
|
||||
}
|
||||
if ($aiRecommendations.Count -gt 0) {
|
||||
$reviewBody += "`n### Recommendations`n"
|
||||
foreach ($rec in $aiRecommendations) { $reviewBody += "- $rec`n" }
|
||||
}
|
||||
|
||||
$verdictText = if ($totalScore -ge $Threshold) {
|
||||
"**PASS** - Score $totalScore >= threshold $Threshold. Ready to merge."
|
||||
} else {
|
||||
"**FAIL** - Score $totalScore < threshold $Threshold. Please address the issues above."
|
||||
}
|
||||
$reviewBody += "`n### Verdict`n${verdictText}`n`n---`n*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*"
|
||||
|
||||
Log-Step "Posting review comment..."
|
||||
$reviewEvent = if ($totalScore -ge $Threshold) { "APPROVE" } else { "COMMENT" }
|
||||
$reviewPayload = @{ body = $reviewBody; event = $reviewEvent } | ConvertTo-Json -Compress
|
||||
$reviewResult = Invoke-GL api,POST,"/$Owner/$Repo/pulls/$PrId/reviews",--body,$reviewPayload
|
||||
if ($reviewResult) {
|
||||
try { $reviewOk = (($reviewResult | ConvertFrom-Json).ok -eq $true) } catch { $reviewOk = $false }
|
||||
if ($reviewOk) { Log-Ok "Review posted" }
|
||||
else { Log-Warn "Review post may have failed (review API might not be available)" }
|
||||
} else { Log-Warn "Review post may have failed" }
|
||||
|
||||
# -- Step 6: 检查 CI 状态 --
|
||||
Log-Step "Checking CI build status..."
|
||||
$ciPresent = $false; $ciPassed = $true
|
||||
$ciJson = Invoke-GL ci,+builds,--owner,$Owner,--repo,$Repo
|
||||
if ($ciJson) {
|
||||
try {
|
||||
$ciData = $ciJson | ConvertFrom-Json
|
||||
$builds = @()
|
||||
if ($ciData.data.builds) { $builds = @($ciData.data.builds) }
|
||||
elseif ($ciData.data -is [array]) { $builds = @($ciData.data) }
|
||||
if ($builds.Count -gt 0) {
|
||||
$ciPresent = $true
|
||||
foreach ($b in $builds) {
|
||||
$status = if ($b.status) { $b.status } elseif ($b.state) { $b.state } else { "unknown" }
|
||||
$name = if ($b.name) { $b.name } else { "build" }
|
||||
if ($status -notin @("success", "passed", "completed")) {
|
||||
$ciPassed = $false; Log-Warn "CI '$name' status: $status"
|
||||
} else { Log-Ok "CI '$name' status: $status" }
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
if (-not $ciPresent) { Log-Info "No CI builds found" }
|
||||
|
||||
# -- Step 7: 质量达标 → 自动合并 --
|
||||
if ($totalScore -ge $Threshold -and $ciPassed) {
|
||||
Log-Step "Quality score $totalScore >= $Threshold and CI passed"
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would auto-merge PR #$PrId"
|
||||
} else {
|
||||
Log-Step "Auto-merging PR #$PrId..."
|
||||
$mergeResult = Invoke-GL pr,+merge,--owner,$Owner,--repo,$Repo,--id,$PrId,--method,merge
|
||||
if ($mergeResult) {
|
||||
try { $mergeOk = (($mergeResult | ConvertFrom-Json).ok -eq $true) } catch { $mergeOk = $false }
|
||||
if ($mergeOk) { Log-Ok "PR #$PrId merged successfully!" }
|
||||
else { Log-Err "Auto-merge failed" }
|
||||
} else { Log-Err "Auto-merge failed" }
|
||||
}
|
||||
} else {
|
||||
Log-Warn "PR #$PrId not auto-merged (score: $totalScore, threshold: $Threshold, CI passed: $ciPassed)"
|
||||
}
|
||||
|
||||
# ── Complete ──────────────────────────────────────────────────────
|
||||
Log-Title "Gatekeeper Complete"
|
||||
Write-Host " PR: #$PrId - $prTitle" -ForegroundColor Green
|
||||
Write-Host " Author: @$prAuthor" -ForegroundColor Green
|
||||
Write-Host " Score: $totalScore / 100" -ForegroundColor Green
|
||||
Write-Host " CI Passed: $ciPassed" -ForegroundColor Green
|
||||
|
|
@ -0,0 +1,603 @@
|
|||
#!/usr/bin/env bash
|
||||
# ================================================================
|
||||
# Scenario 2a: Real-Time PR Gatekeeper (Webhook 触发)
|
||||
# Flow: Webhook 推送 PR → 拉取详情 → AI Review → 查 CI → 发评论 → 达标自动合并
|
||||
# ================================================================
|
||||
set -eo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
PR_ID=""
|
||||
OWNER=""
|
||||
REPO=""
|
||||
THRESHOLD=80
|
||||
DRY_RUN=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --pr-id N [--owner OWNER] [--repo REPO] [--threshold SCORE] [--dry-run]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pr-id) PR_ID="$2"; shift 2 ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--threshold) THRESHOLD="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) log_err "Unknown arg: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$PR_ID" ]] && { log_err "--pr-id is required"; usage; }
|
||||
|
||||
check_auth
|
||||
require_owner_repo
|
||||
|
||||
# ================================================================
|
||||
log_title "PR Gatekeeper: #$PR_ID ($OWNER/$REPO)"
|
||||
|
||||
# ── Step 1: 拉取 PR 详情 ─────────────────────────────────────────
|
||||
log_step "Fetching PR details..."
|
||||
PR_JSON=$(gl_check pr +view --owner "$OWNER" --repo "$REPO" --id "$PR_ID")
|
||||
[[ -z "$PR_JSON" ]] && { log_err "Failed to fetch PR #$PR_ID"; exit 1; }
|
||||
|
||||
PR_TITLE=$(echo "$PR_JSON" | jq -r '.data.title // .data.subject // .data.issue.subject // "N/A"')
|
||||
PR_STATE=$(echo "$PR_JSON" | jq -r '.data.state // .data.status // "N/A"')
|
||||
PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.data.author.login // .data.author.username // .data.issue.author.login // "N/A"')
|
||||
ISSUE_ID=$(echo "$PR_JSON" | jq -r '.data.id // .data.issue.id // empty')
|
||||
PR_HEAD=$(echo "$PR_JSON" | jq -r '.data.pull_request.head // empty')
|
||||
log_ok "PR #$PR_ID: \"$PR_TITLE\" by @$PR_AUTHOR (state: $PR_STATE, branch: $PR_HEAD)"
|
||||
|
||||
# ── Step 2: 获取变更文件列表 ──────────────────────────────────────
|
||||
log_step "Fetching changed files..."
|
||||
FILES_JSON=$(gl_run pr +files --owner "$OWNER" --repo "$REPO" --id "$PR_ID")
|
||||
FILE_COUNT=0
|
||||
if echo "$FILES_JSON" | jq empty 2>/dev/null; then
|
||||
FILE_COUNT=$(echo "$FILES_JSON" | jq '.data.files | length' 2>/dev/null || echo "0")
|
||||
fi
|
||||
log_ok "Changed files: $FILE_COUNT"
|
||||
|
||||
FILE_LIST=""
|
||||
if [[ "$FILE_COUNT" -gt 0 ]] && [[ "$FILE_COUNT" != "null" ]]; then
|
||||
for i in $(seq 0 $((FILE_COUNT - 1))); do
|
||||
FNAME=$(echo "$FILES_JSON" | jq -r ".data.files[$i].name // .data.files[$i].filename // \"unknown\"" 2>/dev/null || echo "unknown")
|
||||
echo " $FNAME"
|
||||
FILE_LIST+="- $FNAME"$'\n'
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Step 3: 获取 diff ────────────────────────────────────────────
|
||||
log_step "Fetching diff..."
|
||||
DIFF_JSON=$(gl_run pr +diff --owner "$OWNER" --repo "$REPO" --id "$PR_ID")
|
||||
DIFF_CONTENT=""
|
||||
if echo "$DIFF_JSON" | jq empty 2>/dev/null; then
|
||||
DIFF_CONTENT=$(echo "$DIFF_JSON" | jq -r '
|
||||
[.data.files[]?.sections[]?.lines[]?.content // empty] | join("\n")
|
||||
' 2>/dev/null | head -c 5000 || true)
|
||||
fi
|
||||
DIFF_LINES=$(echo "$DIFF_CONTENT" | wc -l)
|
||||
log_ok "Diff: $DIFF_LINES lines"
|
||||
|
||||
# ── Step 4: AI 代码审查 ───────────────────────────────────────────
|
||||
log_step "AI analyzing code quality..."
|
||||
|
||||
DIFF_TRUNCATED=$(echo "$DIFF_CONTENT" | head -c 4000)
|
||||
|
||||
# Load skill dimensions from SKILL.md
|
||||
SKILL_DIR="$SCRIPT_DIR/../skills/gitlink-code-review"
|
||||
SKILL_DIMENSIONS=""
|
||||
if [[ -f "$SKILL_DIR/SKILL.md" ]]; then
|
||||
SKILL_DIMENSIONS=$(sed -n '/^## 📊 审查维度/,/^## 🔧 使用方式/p' "$SKILL_DIR/SKILL.md" | grep '^\- \*\*' | head -20)
|
||||
fi
|
||||
|
||||
REVIEW_PROMPT="你是代码审查专家。请按 gitlink-code-review skill 的审查维度分析以下 PR。
|
||||
|
||||
## 审查维度与检查项
|
||||
|
||||
${SKILL_DIMENSIONS:-1. 代码质量: 复杂度、命名、注释、格式
|
||||
2. 安全性: SQL注入、XSS、敏感信息、认证、输入验证
|
||||
3. 性能: 循环效率、资源泄漏、N+1查询、内存
|
||||
4. 可维护性: 代码重复、职责单一、依赖耦合、测试覆盖}
|
||||
|
||||
## 评分标准
|
||||
- 90-100: 优秀,可直接合并
|
||||
- 75-89: 良好,建议合并
|
||||
- 60-74: 一般,需要改进
|
||||
- <60: 较差,不建议合并
|
||||
|
||||
## 问题严重级别
|
||||
- CRITICAL: 阻止合并
|
||||
- HIGH: 强烈建议修复
|
||||
- MEDIUM: 建议修复
|
||||
- LOW: 可选修复
|
||||
|
||||
## PR 数据
|
||||
|
||||
PR 标题: $PR_TITLE
|
||||
变更文件:
|
||||
$FILE_LIST
|
||||
代码差异:
|
||||
$DIFF_TRUNCATED
|
||||
|
||||
## 输出要求
|
||||
|
||||
请严格按以下 JSON 格式输出,不要输出其他内容:
|
||||
{\"total\": <0-100>, \"quality\": <0-25>, \"security\": <0-25>, \"performance\": <0-25>, \"maintainability\": <0-25>, \"issues\": [{\"severity\": \"HIGH/MEDIUM/LOW\", \"category\": \"quality/security/performance/maintainability\", \"file\": \"文件路径\", \"rule\": \"规则名\", \"description\": \"问题描述\", \"suggestion\": \"修复建议\"}], \"positive_notes\": [{\"description\": \"优秀实践描述\"}], \"recommendations\": [\"改进建议1\"], \"verdict\": \"PASS或FAIL\"}"
|
||||
|
||||
AI_AVAILABLE=false
|
||||
SCORE_QUALITY=25
|
||||
SCORE_SECURITY=25
|
||||
SCORE_PERFORMANCE=25
|
||||
SCORE_MAINTAINABILITY=25
|
||||
ISSUES_FOUND=()
|
||||
AI_POSITIVE=()
|
||||
AI_RECOMMENDATIONS=()
|
||||
TOTAL_SCORE=0
|
||||
|
||||
if command -v claude &>/dev/null; then
|
||||
log_info "Calling AI agent for code review (may take 30-60s)..."
|
||||
PROMPT_FILE=$(mktemp)
|
||||
AI_OUT_FILE=$(mktemp)
|
||||
echo "$REVIEW_PROMPT" > "$PROMPT_FILE"
|
||||
|
||||
if [[ -z "${CLAUDE_CODE_GIT_BASH_PATH:-}" ]] && command -v cygpath &>/dev/null; then
|
||||
export CLAUDE_CODE_GIT_BASH_PATH="$(cygpath -w "$(which bash)")"
|
||||
fi
|
||||
|
||||
AI_EXIT=0
|
||||
(cat "$PROMPT_FILE" | timeout 300 claude -p --output-format json > "$AI_OUT_FILE" 2>/dev/null) || AI_EXIT=$?
|
||||
|
||||
if [[ $AI_EXIT -eq 0 ]] && [[ -s "$AI_OUT_FILE" ]]; then
|
||||
AI_RESULT=$(jq -r '.result // empty' "$AI_OUT_FILE" 2>/dev/null)
|
||||
else
|
||||
log_warn "AI call failed (exit: $AI_EXIT), falling back to keyword-based"
|
||||
AI_RESULT=""
|
||||
fi
|
||||
rm -f "$PROMPT_FILE" "$AI_OUT_FILE"
|
||||
|
||||
if [[ -n "$AI_RESULT" ]]; then
|
||||
AI_JSON=""
|
||||
if command -v python3 &>/dev/null; then
|
||||
AI_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
text = sys.stdin.read()
|
||||
depth = 0
|
||||
start = -1
|
||||
results = []
|
||||
for i, c in enumerate(text):
|
||||
if c == '{':
|
||||
if depth == 0: start = i
|
||||
depth += 1
|
||||
elif c == '}':
|
||||
depth -= 1
|
||||
if depth == 0 and start >= 0:
|
||||
results.append(text[start:i+1])
|
||||
start = -1
|
||||
for m in reversed(results):
|
||||
try:
|
||||
obj = json.loads(m)
|
||||
if 'total' in obj and 'verdict' in obj:
|
||||
print(json.dumps(obj))
|
||||
break
|
||||
except: pass
|
||||
" <<< "$AI_RESULT" 2>/dev/null)
|
||||
fi
|
||||
if [[ -z "$AI_JSON" ]]; then
|
||||
AI_JSON=$(echo "$AI_RESULT" | grep -oP '\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}' | tail -1)
|
||||
fi
|
||||
|
||||
if [[ -n "$AI_JSON" ]] && echo "$AI_JSON" | jq empty 2>/dev/null; then
|
||||
TOTAL_SCORE=$(echo "$AI_JSON" | jq -r '.total // 0' 2>/dev/null)
|
||||
SCORE_QUALITY=$(echo "$AI_JSON" | jq -r '.quality // 0' 2>/dev/null)
|
||||
SCORE_SECURITY=$(echo "$AI_JSON" | jq -r '.security // 0' 2>/dev/null)
|
||||
SCORE_PERFORMANCE=$(echo "$AI_JSON" | jq -r '.performance // 0' 2>/dev/null)
|
||||
SCORE_MAINTAINABILITY=$(echo "$AI_JSON" | jq -r '.maintainability // 0' 2>/dev/null)
|
||||
AI_VERDICT=$(echo "$AI_JSON" | jq -r '.verdict // "PASS"' 2>/dev/null)
|
||||
|
||||
ISSUE_COUNT=$(echo "$AI_JSON" | jq '.issues | length' 2>/dev/null || echo "0")
|
||||
if [[ "$ISSUE_COUNT" -gt 0 ]] && [[ "$ISSUE_COUNT" != "null" ]]; then
|
||||
for idx in $(seq 0 $((ISSUE_COUNT - 1))); do
|
||||
issue=$(echo "$AI_JSON" | jq -r '
|
||||
if .issues['"$idx"'] | type == "object" then
|
||||
"[" + (.issues['"$idx"'].severity // "?") + "] " +
|
||||
(.issues['"$idx"'].category // "?") + ": " +
|
||||
(.issues['"$idx"'].description // .issues['"$idx"'].rule // "unknown") +
|
||||
(if .issues['"$idx"'].file then " (" + .issues['"$idx"'].file + ")" else "" end) +
|
||||
(if .issues['"$idx"'].suggestion then " -> " + .issues['"$idx"'].suggestion else "" end)
|
||||
else .issues['"$idx"'] // empty end' 2>/dev/null)
|
||||
[[ -n "$issue" ]] && ISSUES_FOUND+=("$issue")
|
||||
done
|
||||
fi
|
||||
|
||||
POS_COUNT=$(echo "$AI_JSON" | jq '.positive_notes | length' 2>/dev/null || echo "0")
|
||||
if [[ "$POS_COUNT" -gt 0 ]] && [[ "$POS_COUNT" != "null" ]]; then
|
||||
for idx in $(seq 0 $((POS_COUNT - 1))); do
|
||||
note=$(echo "$AI_JSON" | jq -r '.positive_notes['"$idx"'].description // empty' 2>/dev/null)
|
||||
[[ -n "$note" ]] && AI_POSITIVE+=("$note")
|
||||
done
|
||||
fi
|
||||
|
||||
REC_COUNT=$(echo "$AI_JSON" | jq '.recommendations | length' 2>/dev/null || echo "0")
|
||||
if [[ "$REC_COUNT" -gt 0 ]] && [[ "$REC_COUNT" != "null" ]]; then
|
||||
for idx in $(seq 0 $((REC_COUNT - 1))); do
|
||||
rec=$(echo "$AI_JSON" | jq -r '.recommendations['"$idx"'] // empty' 2>/dev/null)
|
||||
[[ -n "$rec" ]] && AI_RECOMMENDATIONS+=("$rec")
|
||||
done
|
||||
fi
|
||||
|
||||
AI_AVAILABLE=true
|
||||
log_ok "AI review complete (verdict: $AI_VERDICT)"
|
||||
else
|
||||
log_warn "Could not parse AI response JSON, falling back to keyword-based"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: keyword-based heuristics
|
||||
if [[ "$AI_AVAILABLE" != "true" ]]; then
|
||||
log_warn "AI not available, falling back to keyword-based analysis"
|
||||
|
||||
SCORE_QUALITY=25
|
||||
SCORE_SECURITY=25
|
||||
SCORE_PERFORMANCE=25
|
||||
SCORE_MAINTAINABILITY=25
|
||||
ISSUES_FOUND=()
|
||||
|
||||
if echo "$DIFF_CONTENT" | grep -qiE 'password|secret|token|api_key|apikey|private_key'; then
|
||||
SCORE_SECURITY=$((SCORE_SECURITY - 15))
|
||||
ISSUES_FOUND+=("SECURITY: 检测到可能的硬编码凭证")
|
||||
fi
|
||||
if echo "$DIFF_CONTENT" | grep -qiE 'eval\(|exec\(|system\(|shell_exec|os\.system|subprocess\.call'; then
|
||||
SCORE_SECURITY=$((SCORE_SECURITY - 10))
|
||||
ISSUES_FOUND+=("SECURITY: 检测到危险函数调用")
|
||||
fi
|
||||
if echo "$DIFF_CONTENT" | grep -qiE 'TODO|FIXME|HACK|XXX'; then
|
||||
SCORE_QUALITY=$((SCORE_QUALITY - 5))
|
||||
ISSUES_FOUND+=("QUALITY: 存在 TODO/FIXME/HACK 注释")
|
||||
fi
|
||||
if echo "$DIFF_CONTENT" | grep -qiE 'SELECT \*|\.findAll\(\)|\.all\(\)'; then
|
||||
SCORE_PERFORMANCE=$((SCORE_PERFORMANCE - 10))
|
||||
ISSUES_FOUND+=("PERFORMANCE: 可能的全表查询")
|
||||
fi
|
||||
if echo "$DIFF_CONTENT" | grep -qiE 'sleep\(|time\.sleep|Thread\.sleep'; then
|
||||
SCORE_PERFORMANCE=$((SCORE_PERFORMANCE - 5))
|
||||
ISSUES_FOUND+=("PERFORMANCE: 检测到阻塞式 sleep")
|
||||
fi
|
||||
if [[ "$FILE_COUNT" -gt 20 ]]; then
|
||||
SCORE_MAINTAINABILITY=$((SCORE_MAINTAINABILITY - 10))
|
||||
ISSUES_FOUND+=("MAINTAINABILITY: 变更文件数量过多 ($FILE_COUNT)")
|
||||
fi
|
||||
|
||||
TOTAL_SCORE=$((SCORE_QUALITY + SCORE_SECURITY + SCORE_PERFORMANCE + SCORE_MAINTAINABILITY))
|
||||
TOTAL_SCORE=$((TOTAL_SCORE < 0 ? 0 : TOTAL_SCORE))
|
||||
fi
|
||||
|
||||
# ── 打印审查报告 ──────────────────────────────────────────────────
|
||||
divider
|
||||
if [[ "$AI_AVAILABLE" == "true" ]]; then
|
||||
log_info "AI Review Report for PR #$PR_ID"
|
||||
else
|
||||
log_info "Review Report for PR #$PR_ID (keyword-based)"
|
||||
fi
|
||||
echo ""
|
||||
echo " Overall Score: $TOTAL_SCORE / 100"
|
||||
echo " Code Quality: $SCORE_QUALITY / 25"
|
||||
echo " Security: $SCORE_SECURITY / 25"
|
||||
echo " Performance: $SCORE_PERFORMANCE / 25"
|
||||
echo " Maintainability: $SCORE_MAINTAINABILITY / 25"
|
||||
echo ""
|
||||
|
||||
if [[ ${#ISSUES_FOUND[@]} -gt 0 ]]; then
|
||||
echo " Issues Found:"
|
||||
for issue in "${ISSUES_FOUND[@]}"; do
|
||||
echo " - $issue"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "${#AI_POSITIVE[@]}" -gt 0 ]]; then
|
||||
echo " Positive Notes:"
|
||||
for note in "${AI_POSITIVE[@]}"; do
|
||||
echo " + $note"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "${#AI_RECOMMENDATIONS[@]}" -gt 0 ]]; then
|
||||
echo " Recommendations:"
|
||||
for rec in "${AI_RECOMMENDATIONS[@]}"; do
|
||||
echo " > $rec"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Step 5: 发布审查评论 ──────────────────────────────────────────
|
||||
if [[ "$AI_AVAILABLE" == "true" ]]; then
|
||||
REVIEW_HEADER="## AI Code Quality Review - PR #$PR_ID"
|
||||
else
|
||||
REVIEW_HEADER="## Code Quality Review - PR #$PR_ID (keyword-based)"
|
||||
fi
|
||||
|
||||
REVIEW_BODY="$REVIEW_HEADER
|
||||
|
||||
### Scores
|
||||
| Dimension | Score | Max |
|
||||
|-----------|-------|-----|
|
||||
| Code Quality | $SCORE_QUALITY | 25 |
|
||||
| Security | $SCORE_SECURITY | 25 |
|
||||
| Performance | $SCORE_PERFORMANCE | 25 |
|
||||
| Maintainability | $SCORE_MAINTAINABILITY | 25 |
|
||||
| **Total** | **$TOTAL_SCORE** | **100** |
|
||||
|
||||
### Issues Found"
|
||||
|
||||
if [[ ${#ISSUES_FOUND[@]} -gt 0 ]]; then
|
||||
for issue in "${ISSUES_FOUND[@]}"; do
|
||||
REVIEW_BODY+=$'\n'"- $issue"
|
||||
done
|
||||
else
|
||||
REVIEW_BODY+=$'\n'"No issues found."
|
||||
fi
|
||||
|
||||
if [[ "${#AI_POSITIVE[@]}" -gt 0 ]]; then
|
||||
REVIEW_BODY+=$'\n'$'\n'"### Positive Notes"
|
||||
for note in "${AI_POSITIVE[@]}"; do
|
||||
REVIEW_BODY+=$'\n'"- $note"
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "${#AI_RECOMMENDATIONS[@]}" -gt 0 ]]; then
|
||||
REVIEW_BODY+=$'\n'$'\n'"### Recommendations"
|
||||
for rec in "${AI_RECOMMENDATIONS[@]}"; do
|
||||
REVIEW_BODY+=$'\n'"- $rec"
|
||||
done
|
||||
fi
|
||||
|
||||
REVIEW_BODY+="
|
||||
|
||||
### Verdict
|
||||
$(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "**PASS** - Score $TOTAL_SCORE >= threshold $THRESHOLD. Ready to merge."; else echo "**FAIL** - Score $TOTAL_SCORE < threshold $THRESHOLD. Please address the issues above."; fi)
|
||||
|
||||
---
|
||||
*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*"
|
||||
|
||||
log_step "Posting review comment..."
|
||||
REVIEW_JSON=$(jq -n --arg notes "$REVIEW_BODY" '{notes: $notes}')
|
||||
REVIEW_RESULT=$(gl_run api POST "/v1/$OWNER/$REPO/issues/$ISSUE_ID/journals" \
|
||||
--body "$REVIEW_JSON" 2>&1) || true
|
||||
|
||||
if [[ "$(json_ok "$REVIEW_RESULT")" == "true" ]]; then
|
||||
log_ok "Review comment posted"
|
||||
else
|
||||
log_warn "Review comment may have failed"
|
||||
fi
|
||||
|
||||
# ── Step 6: 本地 CI (编译 + 测试) ─────────────────────────────────
|
||||
log_step "Running local CI: build & test..."
|
||||
|
||||
REPOS_DIR="/opt/gitlink-webhook/repos"
|
||||
REPO_CLONE_DIR="$REPOS_DIR/$REPO"
|
||||
GIT_URL="https://www.gitlink.org.cn/${OWNER}/${REPO}.git"
|
||||
|
||||
BUILD_LOG=$(mktemp)
|
||||
TEST_LOG=$(mktemp)
|
||||
CI_BUILD_PASSED=false
|
||||
CI_TEST_PASSED=false
|
||||
CI_PASSED=false
|
||||
|
||||
if [[ -z "$PR_HEAD" ]]; then
|
||||
log_warn "Cannot determine PR head branch, skipping CI"
|
||||
CI_BUILD_OUTPUT="CI skipped: unknown PR branch"
|
||||
CI_TEST_OUTPUT="CI skipped: unknown PR branch"
|
||||
else
|
||||
# Clone repo if not exists, otherwise fetch latest
|
||||
if [[ ! -d "$REPO_CLONE_DIR/.git" ]]; then
|
||||
mkdir -p "$REPOS_DIR"
|
||||
log_info "Cloning $GIT_URL..."
|
||||
git clone "$GIT_URL" "$REPO_CLONE_DIR" 2>&1 || true
|
||||
fi
|
||||
|
||||
{
|
||||
cd "$REPO_CLONE_DIR"
|
||||
log_info "Fetching and checking out branch '$PR_HEAD'..."
|
||||
git fetch origin 2>&1 || true
|
||||
git checkout "$PR_HEAD" 2>&1 || true
|
||||
git pull origin "$PR_HEAD" 2>&1 || true
|
||||
|
||||
log_info "Running: go build ./..."
|
||||
if go build ./... >> "$BUILD_LOG" 2>&1; then
|
||||
CI_BUILD_PASSED=true
|
||||
log_ok "Build passed"
|
||||
else
|
||||
log_err "Build failed"
|
||||
fi
|
||||
|
||||
log_info "Running: go test ./..."
|
||||
if go test ./... >> "$TEST_LOG" 2>&1; then
|
||||
CI_TEST_PASSED=true
|
||||
log_ok "Tests passed"
|
||||
else
|
||||
log_err "Tests failed"
|
||||
fi
|
||||
}
|
||||
|
||||
CI_BUILD_OUTPUT=$(tail -c 3000 "$BUILD_LOG" 2>/dev/null || echo "no output")
|
||||
CI_TEST_OUTPUT=$(tail -c 3000 "$TEST_LOG" 2>/dev/null || echo "no output")
|
||||
fi
|
||||
|
||||
if $CI_BUILD_PASSED && $CI_TEST_PASSED; then
|
||||
CI_PASSED=true
|
||||
CI_SUMMARY="| Build | PASSED | | |\n| Tests | PASSED | | |"
|
||||
elif $CI_BUILD_PASSED && ! $CI_TEST_PASSED; then
|
||||
CI_SUMMARY="| Build | PASSED | | |\n| Tests | **FAILED** | | |"
|
||||
elif ! $CI_BUILD_PASSED && $CI_TEST_PASSED; then
|
||||
CI_SUMMARY="| Build | **FAILED** | | |\n| Tests | PASSED | | |"
|
||||
else
|
||||
CI_SUMMARY="| Build | **FAILED** | | |\n| Tests | **FAILED** | | |"
|
||||
fi
|
||||
|
||||
# Post CI results as a follow-up comment
|
||||
log_step "Posting CI results..."
|
||||
CI_BODY="## CI Results - PR #$PR_ID
|
||||
|
||||
### Build & Test
|
||||
| Stage | Status |
|
||||
|-------|--------|
|
||||
$CI_SUMMARY
|
||||
|
||||
### Build Output
|
||||
\`\`\`
|
||||
$(echo "$CI_BUILD_OUTPUT" | tail -20)
|
||||
\`\`\`
|
||||
|
||||
### Test Output
|
||||
\`\`\`
|
||||
$(echo "$CI_TEST_OUTPUT" | tail -20)
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
*Local CI executed on server*"
|
||||
|
||||
CI_COMMENT_JSON=$(jq -n --arg notes "$CI_BODY" '{notes: $notes}')
|
||||
CI_RESULT=$(gl_run api POST "/v1/$OWNER/$REPO/issues/$ISSUE_ID/journals" \
|
||||
--body "$CI_COMMENT_JSON" 2>&1) || true
|
||||
|
||||
if [[ "$(json_ok "$CI_RESULT")" == "true" ]]; then
|
||||
log_ok "CI results posted"
|
||||
else
|
||||
log_warn "CI results post may have failed"
|
||||
fi
|
||||
|
||||
rm -f "$BUILD_LOG" "$TEST_LOG"
|
||||
|
||||
# ── Step 7: 冲突检测 + 自动合并 ───────────────────────────────────
|
||||
MERGED=false
|
||||
MERGE_RESULT_MSG=""
|
||||
CONFLICT_DETECTED=false
|
||||
|
||||
# Build merge summary: determine why merge can/cannot proceed
|
||||
MERGE_BLOCK_REASONS=()
|
||||
if [[ $TOTAL_SCORE -lt $THRESHOLD ]]; then
|
||||
MERGE_BLOCK_REASONS+=("score $TOTAL_SCORE < threshold $THRESHOLD")
|
||||
fi
|
||||
if [[ "$CI_PASSED" != "true" ]]; then
|
||||
MERGE_BLOCK_REASONS+=("CI not passed")
|
||||
fi
|
||||
|
||||
if [[ ${#MERGE_BLOCK_REASONS[@]} -gt 0 ]]; then
|
||||
# Cannot merge: log reasons
|
||||
BLOCK_LIST=$(printf '; %s' "${MERGE_BLOCK_REASONS[@]}")
|
||||
BLOCK_LIST="${BLOCK_LIST:2}"
|
||||
log_warn "Merge blocked: $BLOCK_LIST"
|
||||
else
|
||||
log_step "Score $TOTAL_SCORE >= $THRESHOLD and CI passed — checking mergeability..."
|
||||
|
||||
# Check for merge conflicts
|
||||
if [[ -d "$REPO_CLONE_DIR/.git" ]]; then
|
||||
log_info "Checking merge conflicts with base branch..."
|
||||
(
|
||||
cd "$REPO_CLONE_DIR"
|
||||
# Ensure git identity is set for merge test
|
||||
git config user.email "gatekeeper@gitlink-cli.local" 2>&1 || true
|
||||
git config user.name "gitlink-gatekeeper" 2>&1 || true
|
||||
git fetch origin 2>&1 || true
|
||||
# Determine base branch from PR data
|
||||
BASE_BRANCH=$(echo "$PR_JSON" | jq -r '.data.pull_request.base // .data.base // "master"')
|
||||
git checkout "$BASE_BRANCH" 2>&1 || true
|
||||
git pull origin "$BASE_BRANCH" 2>&1 || true
|
||||
# Test merge without committing
|
||||
if git merge --no-commit --no-ff "$PR_HEAD" 2>&1; then
|
||||
log_ok "No merge conflicts detected"
|
||||
git merge --abort 2>&1 || true
|
||||
CONFLICT_DETECTED=false
|
||||
else
|
||||
CONFLICT_DETECTED=true
|
||||
CONFLICT_FILES=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "unknown")
|
||||
log_err "Merge conflict detected in: $CONFLICT_FILES"
|
||||
git merge --abort 2>&1 || true
|
||||
fi
|
||||
git checkout "$PR_HEAD" 2>&1 || true
|
||||
)
|
||||
else
|
||||
log_info "Skipping conflict check (repo not cloned)"
|
||||
fi
|
||||
|
||||
if [[ "$CONFLICT_DETECTED" == "true" ]]; then
|
||||
MERGE_RESULT_MSG="Merge blocked: conflicts detected"
|
||||
log_err "$MERGE_RESULT_MSG"
|
||||
elif [[ "$DRY_RUN" == "true" ]]; then
|
||||
MERGE_RESULT_MSG="[DRY RUN] Would auto-merge"
|
||||
log_warn "$MERGE_RESULT_MSG"
|
||||
else
|
||||
log_step "Auto-merging PR #$PR_ID..."
|
||||
MERGE_OUT=$(gl_run pr +merge --owner "$OWNER" --repo "$REPO" --id "$PR_ID" --method merge 2>&1) || true
|
||||
if [[ "$(json_ok "$MERGE_OUT")" == "true" ]]; then
|
||||
MERGED=true
|
||||
MERGE_RESULT_MSG="Merge succeeded"
|
||||
log_ok "PR #$PR_ID merged successfully!"
|
||||
else
|
||||
MERGE_RESULT_MSG="Merge failed: $(json_error "$MERGE_OUT")"
|
||||
log_err "$MERGE_RESULT_MSG"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Step 8: 发布最终汇总评论 ──────────────────────────────────────
|
||||
log_step "Posting final summary..."
|
||||
|
||||
FINAL_BODY="## Gatekeeper Summary - PR #$PR_ID
|
||||
|
||||
| Item | Detail |
|
||||
|------|--------|
|
||||
| Score | **$TOTAL_SCORE / 100** (threshold: $THRESHOLD) |
|
||||
| CI Build | $([[ "$CI_BUILD_PASSED" == "true" ]] && echo "PASSED" || echo "FAILED") |
|
||||
| CI Test | $([[ "$CI_TEST_PASSED" == "true" ]] && echo "PASSED" || echo "FAILED") |
|
||||
| Merge Conflict | $([[ "$CONFLICT_DETECTED" == "true" ]] && echo "**DETECTED**" || echo "None") |
|
||||
| Result | $([[ "$MERGED" == "true" ]] && echo "**MERGED**" || echo "$MERGE_RESULT_MSG") |
|
||||
"
|
||||
|
||||
if [[ ${#MERGE_BLOCK_REASONS[@]} -gt 0 ]]; then
|
||||
FINAL_BODY+="
|
||||
### Blocked Reasons
|
||||
"
|
||||
for reason in "${MERGE_BLOCK_REASONS[@]}"; do
|
||||
FINAL_BODY+="- $reason
|
||||
"
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "$CONFLICT_DETECTED" == "true" ]]; then
|
||||
FINAL_BODY+="
|
||||
### Conflict Files
|
||||
\`\`\`
|
||||
$CONFLICT_FILES
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
FINAL_BODY+="
|
||||
---
|
||||
*Gatekeeper workflow completed at $(date '+%Y-%m-%d %H:%M:%S')*"
|
||||
|
||||
FINAL_JSON=$(jq -n --arg notes "$FINAL_BODY" '{notes: $notes}')
|
||||
FINAL_RESULT=$(gl_run api POST "/v1/$OWNER/$REPO/issues/$ISSUE_ID/journals" \
|
||||
--body "$FINAL_JSON" 2>&1) || true
|
||||
|
||||
if [[ "$(json_ok "$FINAL_RESULT")" == "true" ]]; then
|
||||
log_ok "Final summary posted"
|
||||
else
|
||||
log_warn "Final summary post may have failed"
|
||||
fi
|
||||
|
||||
# ── Complete ──────────────────────────────────────────────────────
|
||||
log_title "Gatekeeper Complete"
|
||||
echo -e "${GREEN} PR: #$PR_ID - $PR_TITLE${NC}"
|
||||
echo -e "${GREEN} Author: @$PR_AUTHOR${NC}"
|
||||
echo -e "${GREEN} Score: $TOTAL_SCORE / 100${NC}"
|
||||
echo -e "${GREEN} CI Passed: $CI_PASSED${NC}"
|
||||
echo -e "${GREEN} Conflict: $CONFLICT_DETECTED${NC}"
|
||||
echo -e "${GREEN} Merged: $( [[ "$MERGED" == "true" ]] && echo 'YES' || echo 'NO')${NC}"
|
||||
|
|
@ -1,25 +1,25 @@
|
|||
# ----------------------------------------------------------------
|
||||
# ----------------------------------------------------------------
|
||||
# Scenario 3: One-Click Project Initialization
|
||||
# Flow: Input description -> Create repo -> README/CONTRIBUTING/CI config ->
|
||||
# Initial Issues -> Branch protection -> Initial Release
|
||||
# Flow: Input description -> Create repo -> Generate files (README/LICENSE/
|
||||
# CONTRIBUTING/CI) -> Push to repo -> Milestones -> Issues ->
|
||||
# Branch protection -> Initial Release
|
||||
#
|
||||
# Commands chained:
|
||||
# 1. repo +create -- create repository
|
||||
# 2. wiki +create -- create README wiki page
|
||||
# 3. wiki +create -- create CONTRIBUTING guide
|
||||
# 4. wiki +create -- create CI/CD config guide
|
||||
# 5. issue +create -- create initial issues
|
||||
# 6. branch +protect -- protect master branch
|
||||
# 7. release +create -- create initial release
|
||||
# 2. git clone -- clone the empty repo locally
|
||||
# 3. file generation -- README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml
|
||||
# 4. git add/commit/push -- push scaffold files to repo
|
||||
# 5. milestone +create -- create project milestones
|
||||
# 6. issue +create -- create initial issues
|
||||
# 7. branch +protect -- protect default branch
|
||||
# 8. release +create -- create initial release
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
|
||||
param(
|
||||
[string]$Owner = "",
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Description,
|
||||
[string]$Name = "",
|
||||
[string]$Description = "",
|
||||
[string]$Lang = "go",
|
||||
[switch]$Private,
|
||||
[switch]$DryRun,
|
||||
|
|
@ -27,13 +27,16 @@ param(
|
|||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 03-project-init.ps1 -Owner OWNER -Name REPO_NAME -Description DESC [-Lang go|python|node|java] [-Private] [-DryRun]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $Name) { Log-Err "Name is required (use -Help for usage)"; exit 1 }
|
||||
if (-not $Description) { Log-Err "Description is required (use -Help for usage)"; exit 1 }
|
||||
|
||||
Check-Auth
|
||||
if (-not $Owner) {
|
||||
$detected = Detect-OwnerRepo
|
||||
|
|
@ -51,166 +54,470 @@ Write-Host " Private: $($Private.IsPresent)"
|
|||
Divider
|
||||
|
||||
# -- Step 1: Create Repository --
|
||||
Log-Step "Creating repository..."
|
||||
$privateStr = if ($Private) { "true" } else { "false" }
|
||||
$repoResult = Invoke-GLCheck "repo", "+create", "--owner", $Owner, "--name", $Name, "--description", $Description, "--private", $privateStr
|
||||
if ($repoResult) {
|
||||
Log-Ok "Repository created: $Owner/$Name"
|
||||
Log-Step "Step 1/8: Creating repository..."
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would create repository: $Owner/$Name"
|
||||
} else {
|
||||
Log-Err "Repository creation failed"
|
||||
exit 1
|
||||
$privateStr = if ($Private) { "true" } else { "false" }
|
||||
$repoResult = Invoke-GLCheck "repo", "+create", "--owner", $Owner, "--name", $Name, "--description", $Description, "--private", $privateStr
|
||||
if ($repoResult) {
|
||||
Log-Ok "Repository created: $Owner/$Name"
|
||||
} else {
|
||||
Log-Err "Repository creation failed"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# -- Step 2: Create README --
|
||||
Log-Step "Creating README wiki page..."
|
||||
Start-Sleep -Seconds 2
|
||||
# -- Step 2: Clone empty repo --
|
||||
Log-Step "Step 2/8: Cloning repository..."
|
||||
$repoUrl = "https://gitlink.org.cn/$Owner/$Name.git"
|
||||
if ($env:GITLINK_TOKEN) {
|
||||
$cloneUrl = "https://oauth2:$($env:GITLINK_TOKEN)@gitlink.org.cn/$Owner/$Name.git"
|
||||
} else {
|
||||
$cloneUrl = $repoUrl
|
||||
}
|
||||
$workDir = Join-Path $env:TEMP "gitlink-init-$([System.Guid]::NewGuid().ToString('N').Substring(0, 8))"
|
||||
|
||||
$langSection = switch ($Lang) {
|
||||
"go" {
|
||||
"### Prerequisites`n- Go 1.21+`n- Git`n`n### Installation`n``````bash`ngit clone https://gitlink.org.cn/$Owner/$Name.git`ncd $Name`ngo mod download`ngo build ./...`n```````n`n### Usage`n``````bash`ngo run main.go`n```````n`n### Testing`n``````bash`ngo test ./...`n``````"
|
||||
}
|
||||
"python" {
|
||||
"### Prerequisites`n- Python 3.9+`n- pip`n`n### Installation`n``````bash`ngit clone https://gitlink.org.cn/$Owner/$Name.git`ncd $Name`npip install -r requirements.txt`n```````n`n### Usage`n``````bash`npython main.py`n```````n`n### Testing`n``````bash`npytest`n``````"
|
||||
}
|
||||
"node" {
|
||||
"### Prerequisites`n- Node.js 18+`n- npm or yarn`n`n### Installation`n``````bash`ngit clone https://gitlink.org.cn/$Owner/$Name.git`ncd $Name`nnpm install`n```````n`n### Usage`n``````bash`nnpm start`n```````n`n### Testing`n``````bash`nnpm test`n``````"
|
||||
}
|
||||
"java" {
|
||||
"### Prerequisites`n- JDK 17+`n- Maven 3.8+`n`n### Installation`n``````bash`ngit clone https://gitlink.org.cn/$Owner/$Name.git`ncd $Name`nmvn clean install`n```````n`n### Usage`n``````bash`nmvn exec:java`n```````n`n### Testing`n``````bash`nmvn test`n``````"
|
||||
}
|
||||
default { "" }
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would clone: $repoUrl -> $workDir"
|
||||
} else {
|
||||
git clone $cloneUrl $workDir 2>&1 | Select-Object -Last 1
|
||||
Log-Ok "Cloned to $workDir"
|
||||
}
|
||||
|
||||
$readmeContent = "# $Name" + "`n`n"
|
||||
$readmeContent += "$Description" + "`n`n"
|
||||
$readmeContent += "## Getting Started" + "`n`n"
|
||||
$readmeContent += $langSection + "`n`n"
|
||||
$readmeContent += "## Contributing" + "`n`n"
|
||||
$readmeContent += "See [CONTRIBUTING](./CONTRIBUTING) for guidelines." + "`n`n"
|
||||
$readmeContent += "## License" + "`n`n"
|
||||
$readmeContent += "This project is licensed under the MIT License."
|
||||
# -- Step 3: Generate project files --
|
||||
Log-Step "Step 3/8: Generating project scaffold files..."
|
||||
|
||||
$wikiOk = $false
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
$wikiResult = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Name, "--title", "README", "--content", $readmeContent
|
||||
if ($wikiResult -and (Get-JsonOk $wikiResult)) {
|
||||
Log-Ok "README created"
|
||||
$wikiOk = $true
|
||||
break
|
||||
function Get-ReadmeContent {
|
||||
$langSection = switch ($Lang) {
|
||||
"go" {
|
||||
@"
|
||||
### 环境要求
|
||||
|
||||
- Go 1.21+
|
||||
- Git
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
git clone https://gitlink.org.cn/$Owner/$Name.git
|
||||
cd $Name
|
||||
go mod download
|
||||
go build ./...
|
||||
```
|
||||
|
||||
### 使用
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### 测试
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
"@
|
||||
}
|
||||
"python" {
|
||||
@"
|
||||
### 环境要求
|
||||
|
||||
- Python 3.9+
|
||||
- pip
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
git clone https://gitlink.org.cn/$Owner/$Name.git
|
||||
cd $Name
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 使用
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 测试
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
"@
|
||||
}
|
||||
"node" {
|
||||
@"
|
||||
### 环境要求
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
git clone https://gitlink.org.cn/$Owner/$Name.git
|
||||
cd $Name
|
||||
npm install
|
||||
```
|
||||
|
||||
### 使用
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
### 测试
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
"@
|
||||
}
|
||||
"java" {
|
||||
@"
|
||||
### 环境要求
|
||||
|
||||
- JDK 17+
|
||||
- Maven 3.8+
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
git clone https://gitlink.org.cn/$Owner/$Name.git
|
||||
cd $Name
|
||||
mvn clean install
|
||||
```
|
||||
|
||||
### 使用
|
||||
|
||||
```bash
|
||||
mvn exec:java
|
||||
```
|
||||
|
||||
### 测试
|
||||
|
||||
```bash
|
||||
mvn test
|
||||
```
|
||||
"@
|
||||
}
|
||||
default { "" }
|
||||
}
|
||||
if ($attempt -lt 3) { Start-Sleep -Seconds 2 }
|
||||
|
||||
return @"
|
||||
# $Name
|
||||
|
||||
$Description
|
||||
|
||||
## 快速开始
|
||||
|
||||
$langSection
|
||||
|
||||
## 贡献指南
|
||||
|
||||
详见 [CONTRIBUTING](./CONTRIBUTING.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 MIT 许可证开源 — 详见 [LICENSE](./LICENSE)。
|
||||
"@
|
||||
}
|
||||
if (-not $wikiOk) { Log-Warn "README wiki creation may have failed" }
|
||||
|
||||
# -- Step 3: Create CONTRIBUTING Guide --
|
||||
Log-Step "Creating CONTRIBUTING guide..."
|
||||
function Get-LicenseContent {
|
||||
$year = (Get-Date).Year
|
||||
return @"
|
||||
MIT License
|
||||
|
||||
$contribContent = "# Contributing to $Name" + "`n`n"
|
||||
$contribContent += "Thank you for your interest in contributing!" + "`n`n"
|
||||
$contribContent += "## How to Contribute" + "`n`n"
|
||||
$contribContent += "1. Fork the repository" + "`n"
|
||||
$contribContent += "2. Create a feature branch: ``git checkout -b feature/my-feature```n"
|
||||
$contribContent += "3. Make your changes" + "`n"
|
||||
$contribContent += "4. Run tests to ensure everything passes" + "`n"
|
||||
$contribContent += "5. Commit your changes: ``git commit -m 'feat: add my feature'```n"
|
||||
$contribContent += "6. Push to your fork: ``git push origin feature/my-feature```n"
|
||||
$contribContent += "7. Create a Pull Request" + "`n`n"
|
||||
$contribContent += "## Code Style" + "`n`n"
|
||||
$contribContent += "- Follow the existing code style" + "`n"
|
||||
$contribContent += "- Write meaningful commit messages" + "`n"
|
||||
$contribContent += "- Add tests for new features" + "`n"
|
||||
$contribContent += "- Update documentation as needed" + "`n`n"
|
||||
$contribContent += "## Reporting Issues" + "`n`n"
|
||||
$contribContent += "- Use the issue tracker" + "`n"
|
||||
$contribContent += "- Include reproduction steps" + "`n"
|
||||
$contribContent += "- Include environment details"
|
||||
Copyright (c) $year $Owner
|
||||
|
||||
$wikiOk = $false
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
$wikiContrib = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Name, "--title", "CONTRIBUTING", "--content", $contribContent
|
||||
if ($wikiContrib -and (Get-JsonOk $wikiContrib)) {
|
||||
Log-Ok "CONTRIBUTING guide created"
|
||||
$wikiOk = $true
|
||||
break
|
||||
}
|
||||
if ($attempt -lt 3) { Start-Sleep -Seconds 2 }
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"@
|
||||
}
|
||||
if (-not $wikiOk) { Log-Warn "CONTRIBUTING wiki creation may have failed" }
|
||||
|
||||
# -- Step 4: Create CI Config Guide --
|
||||
Log-Step "Creating CI/CD configuration guide..."
|
||||
function Get-ContributingContent {
|
||||
return @"
|
||||
# Contributing to $Name
|
||||
|
||||
$ciContent = "# CI/CD Configuration" + "`n`n"
|
||||
$ciContent += "## GitLink CI Setup" + "`n`n"
|
||||
$ciContent += "This project uses GitLink CI for continuous integration." + "`n`n"
|
||||
$ciContent += "### Pipeline Stages" + "`n`n"
|
||||
$ciContent += "1. **Test**: Run unit tests" + "`n"
|
||||
$ciContent += "2. **Build**: Build the project" + "`n"
|
||||
$ciContent += "3. **Deploy**: Deploy to staging (master branch only)" + "`n`n"
|
||||
$ciContent += "### Configuration" + "`n`n"
|
||||
$ciContent += "Create a ``.gitlink-ci.yml`` file in the repository root."
|
||||
Thank you for your interest in contributing!
|
||||
|
||||
Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Name, "--title", "CI/CD Configuration", "--content", $ciContent | Out-Null
|
||||
Log-Ok "CI/CD configuration guide created"
|
||||
## How to Contribute
|
||||
|
||||
# -- Step 5: Create Initial Issues --
|
||||
Log-Step "Creating initial issues..."
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b feature/my-feature`
|
||||
3. Make your changes
|
||||
4. Run tests to ensure everything passes
|
||||
5. Commit your changes: `git commit -m 'feat: add my feature'`
|
||||
6. Push to your fork: `git push origin feature/my-feature`
|
||||
7. Create a Pull Request
|
||||
|
||||
$issuesToCreate = @(
|
||||
@{ Title = "Setup CI/CD Pipeline"; Body = "Configure continuous integration and deployment for the project.`n`n## Tasks`n- [ ] Create .gitlink-ci.yml configuration`n- [ ] Setup test stage`n- [ ] Setup build stage`n- [ ] Setup deploy stage`n- [ ] Add status badge to README"; Label = "feature" },
|
||||
@{ Title = "Write Project Documentation"; Body = "Complete project documentation including API docs and architecture guide.`n`n## Tasks`n- [ ] Write API documentation`n- [ ] Create architecture diagram`n- [ ] Add usage examples`n- [ ] Document configuration options"; Label = "documentation" },
|
||||
@{ Title = "Setup Code Review Process"; Body = "Establish code review guidelines and automation.`n`n## Tasks`n- [ ] Define review checklist`n- [ ] Setup branch protection rules`n- [ ] Configure required reviewers`n- [ ] Document review process"; Label = "enhancement" },
|
||||
@{ Title = "Add Unit Tests"; Body = "Add comprehensive unit test coverage for core modules.`n`n## Tasks`n- [ ] Setup test framework`n- [ ] Write tests for core modules`n- [ ] Achieve 80 percent code coverage`n- [ ] Add CI test integration"; Label = "enhancement" },
|
||||
@{ Title = "Setup Dependency Management"; Body = "Configure dependency scanning and updates.`n`n## Tasks`n- [ ] Setup dependency scanner`n- [ ] Configure automatic updates`n- [ ] Add license compliance check`n- [ ] Document dependency policy"; Label = "security" }
|
||||
)
|
||||
## Code Style
|
||||
|
||||
foreach ($entry in $issuesToCreate) {
|
||||
$issueResult = Invoke-GL "issue", "+create", "--owner", $Owner, "--repo", $Name, "--title", $entry.Title, "--body", $entry.Body
|
||||
if ($issueResult) {
|
||||
try {
|
||||
$issueJson = $issueResult
|
||||
$issueNum = if ($issueJson.data.id) { $issueJson.data.id } elseif ($issueJson.data.number) { $issueJson.data.number } else { $null }
|
||||
if ($issueNum) {
|
||||
Invoke-GL "issue", "+label-add", "--owner", $Owner, "--repo", $Name, "--number", $issueNum, "--labels", $entry.Label | Out-Null
|
||||
Log-Ok "Issue created: #$issueNum - $($entry.Title)"
|
||||
}
|
||||
} catch {
|
||||
Log-Warn "Issue creation may have failed: $($entry.Title)"
|
||||
- Follow the existing code style
|
||||
- Write meaningful commit messages following [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- Add tests for new features
|
||||
- Update documentation as needed
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
- Use the [issue tracker](https://gitlink.org.cn/$Owner/$Name/issues)
|
||||
- Include steps to reproduce
|
||||
- Include environment details (OS, language version, etc.)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please be respectful and constructive in all interactions.
|
||||
"@
|
||||
}
|
||||
|
||||
function Get-CIConfigContent {
|
||||
switch ($Lang) {
|
||||
"go" {
|
||||
return @"
|
||||
image: golang:1.21
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- go mod download
|
||||
- go test ./... -v -cover
|
||||
|
||||
build:
|
||||
stage: build
|
||||
script:
|
||||
- go build ./...
|
||||
"@
|
||||
}
|
||||
"python" {
|
||||
return @"
|
||||
image: python:3.9
|
||||
|
||||
stages:
|
||||
- test
|
||||
- lint
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- pip install -r requirements.txt
|
||||
- pytest -v --cov
|
||||
|
||||
lint:
|
||||
stage: lint
|
||||
script:
|
||||
- pip install flake8
|
||||
- flake8 .
|
||||
"@
|
||||
}
|
||||
"node" {
|
||||
return @"
|
||||
image: node:18
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- npm ci
|
||||
- npm test -- --coverage
|
||||
|
||||
build:
|
||||
stage: build
|
||||
script:
|
||||
- npm ci
|
||||
- npm run build
|
||||
"@
|
||||
}
|
||||
"java" {
|
||||
return @"
|
||||
image: maven:3.8-openjdk-17
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- mvn test
|
||||
|
||||
build:
|
||||
stage: build
|
||||
script:
|
||||
- mvn package -DskipTests
|
||||
"@
|
||||
}
|
||||
default {
|
||||
return "# TODO: configure CI/CD pipeline for $Lang"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# -- Step 6: Protect Default Branch --
|
||||
Log-Step "Protecting master branch..."
|
||||
$protectResult = Invoke-GL "branch", "+protect", "--owner", $Owner, "--repo", $Name, "--name", "master"
|
||||
if ($protectResult -and (Get-JsonOk $protectResult)) {
|
||||
Log-Ok "Branch 'master' protected"
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would generate: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml"
|
||||
} else {
|
||||
Log-Warn "Branch protection may have failed (may require admin permissions)"
|
||||
$utf8 = [System.Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::WriteAllText("$workDir\README.md", (Get-ReadmeContent), $utf8)
|
||||
[System.IO.File]::WriteAllText("$workDir\LICENSE", (Get-LicenseContent), $utf8)
|
||||
[System.IO.File]::WriteAllText("$workDir\CONTRIBUTING.md", (Get-ContributingContent), $utf8)
|
||||
[System.IO.File]::WriteAllText("$workDir\.gitlink-ci.yml", (Get-CIConfigContent), $utf8)
|
||||
Log-Ok "Generated: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml"
|
||||
}
|
||||
|
||||
# -- Step 7: Create Initial Release --
|
||||
Log-Step "Creating initial release v0.1.0..."
|
||||
|
||||
$releaseBody = "# v0.1.0 - Initial Release" + "`n`n"
|
||||
$releaseBody += "## What's New" + "`n"
|
||||
$releaseBody += "- Project initialized with $Lang template" + "`n"
|
||||
$releaseBody += "- README and CONTRIBUTING guides created" + "`n"
|
||||
$releaseBody += "- CI/CD configuration guide created" + "`n"
|
||||
$releaseBody += "- 5 initial issues filed" + "`n"
|
||||
$releaseBody += "- Branch protection enabled" + "`n`n"
|
||||
$releaseBody += "## Next Steps" + "`n"
|
||||
$releaseBody += "- [ ] Setup CI/CD pipeline" + "`n"
|
||||
$releaseBody += "- [ ] Write comprehensive tests" + "`n"
|
||||
$releaseBody += "- [ ] Complete documentation" + "`n"
|
||||
$releaseBody += "- [ ] First feature implementation" + "`n`n"
|
||||
$releaseBody += "---`n*Auto-initialized by gitlink-cli project-init workflow*"
|
||||
|
||||
$releaseResult = Invoke-GL "release", "+create", "--owner", $Owner, "--repo", $Name, "--tag", "v0.1.0", "--name", "Initial Release", "--body", $releaseBody
|
||||
if ($releaseResult -and (Get-JsonOk $releaseResult)) {
|
||||
Log-Ok "Release v0.1.0 created"
|
||||
# -- Step 4: Push files to repo --
|
||||
Log-Step "Step 4/8: Pushing scaffold files to repository..."
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would git add/commit/push to $repoUrl"
|
||||
} else {
|
||||
Log-Warn "Release creation may have failed"
|
||||
Push-Location $workDir
|
||||
try {
|
||||
$commitMsg = "chore: initialize project scaffold`n`n- Add README.md with getting-started guide`n- Add MIT LICENSE`n- Add CONTRIBUTING.md with guidelines`n- Add .gitlink-ci.yml CI pipeline configuration"
|
||||
git add README.md LICENSE CONTRIBUTING.md .gitlink-ci.yml 2>&1 | Out-Null
|
||||
git -c user.name="$Owner" -c user.email="$Owner@gitlink.org.cn" commit -m $commitMsg 2>&1 | Select-Object -Last 1
|
||||
git push -u origin master 2>&1 | Select-Object -Last 1
|
||||
Log-Ok "Scaffold files pushed to master"
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
# -- Step 5: Create Milestones --
|
||||
Log-Step "Step 5/8: Creating initial milestone..."
|
||||
|
||||
$milestones = @(
|
||||
@{ Name = "v1.0.0 - First Release"; Description = "First official release milestone" }
|
||||
)
|
||||
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would create $($milestones.Count) milestones"
|
||||
} else {
|
||||
foreach ($ms in $milestones) {
|
||||
$msResult = Invoke-GL "milestone", "+create", "--owner", $Owner, "--repo", $Name, "--name", $ms.Name, "--description", $ms.Description
|
||||
if ($msResult) {
|
||||
try {
|
||||
$msJson = $msResult | ConvertFrom-Json
|
||||
if ($msJson.ok) {
|
||||
Log-Ok "Milestone created: $($ms.Name)"
|
||||
} else {
|
||||
Log-Warn "Milestone creation failed: $($ms.Name)"
|
||||
}
|
||||
} catch {
|
||||
Log-Warn "Milestone creation may have failed: $($ms.Name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# -- Step 6: Create Initial Issues --
|
||||
Log-Step "Step 6/8: Creating welcome issue..."
|
||||
|
||||
$issuesToCreate = @(
|
||||
@{ Title = "Welcome to $Name"; Body = "欢迎来到 **${Name}** 的仓库,这是本仓库的第一条issue!!"; Label = "welcome" }
|
||||
)
|
||||
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would create $($issuesToCreate.Count) initial issues"
|
||||
} else {
|
||||
foreach ($entry in $issuesToCreate) {
|
||||
$issueResult = Invoke-GL "issue", "+create", "--owner", $Owner, "--repo", $Name, "--title", $entry.Title, "--body", $entry.Body
|
||||
if ($issueResult) {
|
||||
try {
|
||||
$issueJson = $issueResult | ConvertFrom-Json
|
||||
$issueNum = if ($issueJson.ok -and $issueJson.data.id) { $issueJson.data.id } elseif ($issueJson.ok -and $issueJson.data.number) { $issueJson.data.number } else { $null }
|
||||
if ($issueNum) {
|
||||
Invoke-GL "issue", "+label-add", "--owner", $Owner, "--repo", $Name, "--number", $issueNum, "--labels", $entry.Label | Out-Null
|
||||
Log-Ok "Issue created: #$issueNum - $($entry.Title)"
|
||||
}
|
||||
} catch {
|
||||
Log-Warn "Issue creation may have failed: $($entry.Title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# -- Step 7: Protect Default Branch --
|
||||
Log-Step "Step 7/8: Protecting master branch..."
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would protect branch 'master'"
|
||||
} else {
|
||||
$protectResult = Invoke-GL "branch", "+protect", "--owner", $Owner, "--repo", $Name, "--name", "master"
|
||||
if ($protectResult) {
|
||||
try {
|
||||
$protectJson = $protectResult | ConvertFrom-Json
|
||||
if ($protectJson.ok) {
|
||||
Log-Ok "Branch 'master' protected"
|
||||
} else {
|
||||
Log-Warn "Branch protection may have failed (may require admin permissions)"
|
||||
}
|
||||
} catch {
|
||||
Log-Warn "Branch protection may have failed (may require admin permissions)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# -- Step 8: Create Initial Release --
|
||||
Log-Step "Step 8/8: Creating initial release v0.1.0..."
|
||||
|
||||
$releaseBody = @"
|
||||
# v0.1.0 - Initial Release
|
||||
|
||||
## What's New
|
||||
- Project initialized with $Lang template
|
||||
- README, LICENSE, CONTRIBUTING guide in repository
|
||||
- CI/CD pipeline configuration (`.gitlink-ci.yml`)
|
||||
- $($milestones.Count) project milestone created
|
||||
- $($issuesToCreate.Count) initial issues filed
|
||||
- Branch protection enabled
|
||||
|
||||
## Next Steps
|
||||
- [ ] Setup CI/CD pipeline
|
||||
- [ ] Write comprehensive tests
|
||||
- [ ] Complete documentation
|
||||
- [ ] First feature implementation
|
||||
|
||||
---
|
||||
*Auto-initialized by gitlink-cli project-init workflow*
|
||||
"@
|
||||
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would create release v0.1.0"
|
||||
} else {
|
||||
$releaseResult = Invoke-GL "release", "+create", "--owner", $Owner, "--repo", $Name, "--tag", "v0.1.0", "--name", "Initial Release", "--body", $releaseBody
|
||||
if ($releaseResult) {
|
||||
try {
|
||||
$releaseJson = $releaseResult | ConvertFrom-Json
|
||||
if ($releaseJson.ok) {
|
||||
Log-Ok "Release v0.1.0 created"
|
||||
} else {
|
||||
Log-Warn "Release creation may have failed"
|
||||
}
|
||||
} catch {
|
||||
Log-Warn "Release creation may have failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
|
|
@ -218,14 +525,18 @@ Log-Title "Project Initialization Complete"
|
|||
# ----------------------------------------------------------------
|
||||
|
||||
Write-Host " Repository: $Owner/$Name" -ForegroundColor Green
|
||||
Write-Host " README: Wiki page" -ForegroundColor Green
|
||||
Write-Host " CONTRIBUTING: Wiki page" -ForegroundColor Green
|
||||
Write-Host " CI/CD Guide: Wiki page" -ForegroundColor Green
|
||||
Write-Host " Issues: 5 initial issues" -ForegroundColor Green
|
||||
Write-Host " Files: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml" -ForegroundColor Green
|
||||
Write-Host " Milestones: $($milestones.Count) milestone" -ForegroundColor Green
|
||||
Write-Host " Issues: $($issuesToCreate.Count) initial issues" -ForegroundColor Green
|
||||
Write-Host " Branch: master (protected)" -ForegroundColor Green
|
||||
Write-Host " Release: v0.1.0" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Next steps:" -ForegroundColor Cyan
|
||||
Write-Host " 1. Clone: git clone https://gitlink.org.cn/$Owner/$Name.git"
|
||||
Write-Host " 2. Add your code and push"
|
||||
Write-Host " 3. Setup CI/CD by closing the first issue"
|
||||
Write-Host " 1. Clone: git clone $repoUrl"
|
||||
Write-Host " 2. Start coding and close the first issue"
|
||||
Write-Host " 3. CI/CD will trigger on your first push"
|
||||
|
||||
# Cleanup temp directory
|
||||
if (-not $DryRun -and (Test-Path $workDir)) {
|
||||
Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Scenario 3: One-Click Project Initialization
|
||||
# Flow: Input description → Create repo → README/LICENSE/CI → Issues → Release
|
||||
# Flow: Input description → Create repo → Generate files (README/LICENSE/
|
||||
# CONTRIBUTING/CI) → Push to repo → Milestones → Issues →
|
||||
# Branch protection → Initial Release
|
||||
#
|
||||
# Commands/Skills chained:
|
||||
# Commands chained:
|
||||
# 1. repo +create -- create repository
|
||||
# 2. wiki +create -- create README wiki page
|
||||
# 3. wiki +create -- create CONTRIBUTING guide
|
||||
# 4. issue +create -- create initial issues
|
||||
# 5. branch +protect -- protect default branch
|
||||
# 6. release +create -- create initial release
|
||||
# 2. git clone -- clone the empty repo locally
|
||||
# 3. file generation -- README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml
|
||||
# 4. git add/commit/push -- push scaffold files to repo
|
||||
# 5. milestone +create -- create project milestones
|
||||
# 6. issue +create -- create initial issues
|
||||
# 7. branch +protect -- protect default branch
|
||||
# 8. release +create -- create initial release
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
|
@ -65,33 +69,49 @@ echo " Private: $PRIVATE"
|
|||
divider
|
||||
|
||||
# ── Step 1: Create Repository ────────────────────────────────────────
|
||||
log_step "Creating repository..."
|
||||
REPO_RESULT=$(gl_check repo +create --owner "$OWNER" --name "$REPO_NAME" --description "$DESCRIPTION" --private "$PRIVATE")
|
||||
REPO_ID=$(echo "$REPO_RESULT" | jq -r '.data.id // .data.project_id // empty')
|
||||
log_ok "Repository created: $OWNER/$REPO_NAME (id: $REPO_ID)"
|
||||
log_step "Step 1/8: Creating repository..."
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would create repository: $OWNER/$REPO_NAME"
|
||||
else
|
||||
REPO_RESULT=$(gl_check repo +create --owner "$OWNER" --name "$REPO_NAME" --description "$DESCRIPTION" --private "$PRIVATE")
|
||||
REPO_ID=$(echo "$REPO_RESULT" | jq -r '.data.id // .data.project_id // empty')
|
||||
log_ok "Repository created: $OWNER/$REPO_NAME (id: $REPO_ID)"
|
||||
fi
|
||||
|
||||
# ── Step 2: Create README ────────────────────────────────────────────
|
||||
log_step "Creating README wiki page..."
|
||||
# ── Step 2: Clone empty repo ─────────────────────────────────────────
|
||||
log_step "Step 2/8: Cloning repository..."
|
||||
# Build auth URL using token so clone/push don't prompt for password
|
||||
if [[ -n "${GITLINK_TOKEN:-}" ]]; then
|
||||
CLONE_URL="https://oauth2:${GITLINK_TOKEN}@gitlink.org.cn/$OWNER/$REPO_NAME.git"
|
||||
else
|
||||
CLONE_URL="https://gitlink.org.cn/$OWNER/$REPO_NAME.git"
|
||||
fi
|
||||
REPO_URL="https://gitlink.org.cn/$OWNER/$REPO_NAME.git"
|
||||
WORK_DIR=$(mktemp -d /tmp/gitlink-init-XXXXXX)
|
||||
cleanup() { rm -rf "$WORK_DIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Wait for repo to be fully initialized
|
||||
sleep 2
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would clone: $REPO_URL → $WORK_DIR"
|
||||
else
|
||||
git clone "$CLONE_URL" "$WORK_DIR" 2>&1 | tail -1 || true
|
||||
log_ok "Cloned to $WORK_DIR"
|
||||
fi
|
||||
|
||||
README_CONTENT="# $REPO_NAME
|
||||
# ── Step 3: Generate project files ───────────────────────────────────
|
||||
log_step "Step 3/8: Generating project scaffold files..."
|
||||
|
||||
$DESCRIPTION
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites"
|
||||
|
||||
case "$PROJ_LANG" in
|
||||
go)
|
||||
README_CONTENT+="
|
||||
# --- README.md ---
|
||||
gen_readme() {
|
||||
local lang_section
|
||||
case "$PROJ_LANG" in
|
||||
go)
|
||||
lang_section="### 环境要求
|
||||
|
||||
- Go 1.21+
|
||||
- Git
|
||||
|
||||
### Installation
|
||||
### 安装
|
||||
|
||||
\`\`\`bash
|
||||
git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git
|
||||
|
|
@ -100,25 +120,25 @@ go mod download
|
|||
go build ./...
|
||||
\`\`\`
|
||||
|
||||
### Usage
|
||||
### 使用
|
||||
|
||||
\`\`\`bash
|
||||
go run main.go
|
||||
\`\`\`
|
||||
|
||||
### Testing
|
||||
### 测试
|
||||
|
||||
\`\`\`bash
|
||||
go test ./...
|
||||
\`\`\`"
|
||||
;;
|
||||
python)
|
||||
README_CONTENT+="
|
||||
;;
|
||||
python)
|
||||
lang_section="### 环境要求
|
||||
|
||||
- Python 3.9+
|
||||
- pip
|
||||
|
||||
### Installation
|
||||
### 安装
|
||||
|
||||
\`\`\`bash
|
||||
git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git
|
||||
|
|
@ -126,25 +146,25 @@ cd $REPO_NAME
|
|||
pip install -r requirements.txt
|
||||
\`\`\`
|
||||
|
||||
### Usage
|
||||
### 使用
|
||||
|
||||
\`\`\`bash
|
||||
python main.py
|
||||
\`\`\`
|
||||
|
||||
### Testing
|
||||
### 测试
|
||||
|
||||
\`\`\`bash
|
||||
pytest
|
||||
\`\`\`"
|
||||
;;
|
||||
node)
|
||||
README_CONTENT+="
|
||||
;;
|
||||
node)
|
||||
lang_section="### 环境要求
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
### 安装
|
||||
|
||||
\`\`\`bash
|
||||
git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git
|
||||
|
|
@ -152,25 +172,25 @@ cd $REPO_NAME
|
|||
npm install
|
||||
\`\`\`
|
||||
|
||||
### Usage
|
||||
### 使用
|
||||
|
||||
\`\`\`bash
|
||||
npm start
|
||||
\`\`\`
|
||||
|
||||
### Testing
|
||||
### 测试
|
||||
|
||||
\`\`\`bash
|
||||
npm test
|
||||
\`\`\`"
|
||||
;;
|
||||
java)
|
||||
README_CONTENT+="
|
||||
;;
|
||||
java)
|
||||
lang_section="### 环境要求
|
||||
|
||||
- JDK 17+
|
||||
- Maven 3.8+
|
||||
|
||||
### Installation
|
||||
### 安装
|
||||
|
||||
\`\`\`bash
|
||||
git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git
|
||||
|
|
@ -178,48 +198,72 @@ cd $REPO_NAME
|
|||
mvn clean install
|
||||
\`\`\`
|
||||
|
||||
### Usage
|
||||
### 使用
|
||||
|
||||
\`\`\`bash
|
||||
mvn exec:java
|
||||
\`\`\`
|
||||
|
||||
### Testing
|
||||
### 测试
|
||||
|
||||
\`\`\`bash
|
||||
mvn test
|
||||
\`\`\`"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
README_CONTENT+="
|
||||
cat <<READMEEOF
|
||||
# $REPO_NAME
|
||||
|
||||
## Contributing
|
||||
$DESCRIPTION
|
||||
|
||||
See [CONTRIBUTING](./CONTRIBUTING) for guidelines.
|
||||
## 快速开始
|
||||
|
||||
## License
|
||||
$lang_section
|
||||
|
||||
This project is licensed under the MIT License."
|
||||
## 贡献指南
|
||||
|
||||
# Retry wiki creation up to 3 times
|
||||
WIKI_OK=false
|
||||
for attempt in 1 2 3; do
|
||||
WIKI_RESULT=$(gl_run wiki +create --owner "$OWNER" --repo "$REPO_NAME" \
|
||||
--title "README" --content "$README_CONTENT" 2>&1) || true
|
||||
if [[ "$(json_ok "$WIKI_RESULT")" == "true" ]]; then
|
||||
log_ok "README created"
|
||||
WIKI_OK=true
|
||||
break
|
||||
fi
|
||||
[[ $attempt -lt 3 ]] && sleep 2
|
||||
done
|
||||
[[ "$WIKI_OK" == "false" ]] && log_warn "README wiki creation may have failed"
|
||||
详见 [CONTRIBUTING](./CONTRIBUTING.md)。
|
||||
|
||||
# ── Step 3: Create CONTRIBUTING Guide ────────────────────────────────
|
||||
log_step "Creating CONTRIBUTING guide..."
|
||||
## 许可证
|
||||
|
||||
CONTRIB_CONTENT="# Contributing to $REPO_NAME
|
||||
本项目基于 MIT 许可证开源 — 详见 [LICENSE](./LICENSE)。
|
||||
READMEEOF
|
||||
}
|
||||
|
||||
# --- LICENSE (MIT) ---
|
||||
gen_license() {
|
||||
local year
|
||||
year=$(date +%Y)
|
||||
cat <<LICENSEEOF
|
||||
MIT License
|
||||
|
||||
Copyright (c) $year $OWNER
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
LICENSEEOF
|
||||
}
|
||||
|
||||
# --- CONTRIBUTING.md ---
|
||||
gen_contributing() {
|
||||
cat <<CONTRIBEOF
|
||||
# Contributing to $REPO_NAME
|
||||
|
||||
Thank you for your interest in contributing!
|
||||
|
||||
|
|
@ -236,78 +280,208 @@ Thank you for your interest in contributing!
|
|||
## Code Style
|
||||
|
||||
- Follow the existing code style
|
||||
- Write meaningful commit messages
|
||||
- Write meaningful commit messages following [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- Add tests for new features
|
||||
- Update documentation as needed
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
- Use the issue tracker
|
||||
- Include reproduction steps
|
||||
- Include environment details
|
||||
- Use the [issue tracker](https://gitlink.org.cn/$OWNER/$REPO_NAME/issues)
|
||||
- Include steps to reproduce
|
||||
- Include environment details (OS, language version, etc.)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please be respectful and constructive in all interactions."
|
||||
Please be respectful and constructive in all interactions.
|
||||
CONTRIBEOF
|
||||
}
|
||||
|
||||
# Retry wiki creation up to 3 times
|
||||
WIKI_OK=false
|
||||
for attempt in 1 2 3; do
|
||||
WIKI_CONTRIB=$(gl_run wiki +create --owner "$OWNER" --repo "$REPO_NAME" \
|
||||
--title "CONTRIBUTING" --content "$CONTRIB_CONTENT" 2>&1) || true
|
||||
if [[ "$(json_ok "$WIKI_CONTRIB")" == "true" ]]; then
|
||||
log_ok "CONTRIBUTING guide created"
|
||||
WIKI_OK=true
|
||||
break
|
||||
fi
|
||||
[[ $attempt -lt 3 ]] && sleep 2
|
||||
done
|
||||
[[ "$WIKI_OK" == "false" ]] && log_warn "CONTRIBUTING wiki creation may have failed"
|
||||
# --- .gitlink-ci.yml ---
|
||||
gen_ci_config() {
|
||||
case "$PROJ_LANG" in
|
||||
go)
|
||||
cat <<CIEOF
|
||||
image: golang:1.21
|
||||
|
||||
# ── Step 4: Create Initial Issues ────────────────────────────────────
|
||||
log_step "Creating initial issues..."
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
|
||||
ISSUES_TO_CREATE=(
|
||||
"Setup CI/CD Pipeline|Configure continuous integration and deployment for the project.|feature"
|
||||
"Write Project Documentation|Complete project documentation including API docs and architecture guide.|documentation"
|
||||
"Setup Code Review Process|Establish code review guidelines and automation.|enhancement"
|
||||
"Add Unit Tests|Add comprehensive unit test coverage for core modules.|enhancement"
|
||||
"Setup Dependency Management|Configure dependency scanning and updates.|security"
|
||||
)
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- go mod download
|
||||
- go test ./... -v -cover
|
||||
|
||||
for entry in "${ISSUES_TO_CREATE[@]}"; do
|
||||
IFS='|' read -r title body label <<< "$entry"
|
||||
ISSUE_RESULT=$(gl_run issue +create --owner "$OWNER" --repo "$REPO_NAME" \
|
||||
--title "$title" --body "$body" 2>&1) || true
|
||||
ISSUE_NUM=$(echo "$ISSUE_RESULT" | jq -r '.data.id // .data.number // empty')
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
# Add label
|
||||
gl_run issue +label-add --owner "$OWNER" --repo "$REPO_NAME" --number "$ISSUE_NUM" --labels "$label" > /dev/null 2>&1 || true
|
||||
log_ok "Issue created: #$ISSUE_NUM - $title"
|
||||
else
|
||||
log_warn "Issue creation may have failed: $title"
|
||||
fi
|
||||
done
|
||||
build:
|
||||
stage: build
|
||||
script:
|
||||
- go build ./...
|
||||
CIEOF
|
||||
;;
|
||||
python)
|
||||
cat <<CIEOF
|
||||
image: python:3.9
|
||||
|
||||
# ── Step 5: Protect Default Branch ───────────────────────────────────
|
||||
log_step "Protecting master branch..."
|
||||
PROTECT_RESULT=$(gl_run branch +protect --owner "$OWNER" --repo "$REPO_NAME" --name master 2>&1) || true
|
||||
stages:
|
||||
- test
|
||||
- lint
|
||||
|
||||
if [[ "$(json_ok "$PROTECT_RESULT")" == "true" ]]; then
|
||||
log_ok "Branch 'master' protected"
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- pip install -r requirements.txt
|
||||
- pytest -v --cov
|
||||
|
||||
lint:
|
||||
stage: lint
|
||||
script:
|
||||
- pip install flake8
|
||||
- flake8 .
|
||||
CIEOF
|
||||
;;
|
||||
node)
|
||||
cat <<CIEOF
|
||||
image: node:18
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- npm ci
|
||||
- npm test -- --coverage
|
||||
|
||||
build:
|
||||
stage: build
|
||||
script:
|
||||
- npm ci
|
||||
- npm run build
|
||||
CIEOF
|
||||
;;
|
||||
java)
|
||||
cat <<CIEOF
|
||||
image: maven:3.8-openjdk-17
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- mvn test
|
||||
|
||||
build:
|
||||
stage: build
|
||||
script:
|
||||
- mvn package -DskipTests
|
||||
CIEOF
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would generate: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml"
|
||||
else
|
||||
log_warn "Branch protection may have failed (may require admin permissions)"
|
||||
gen_readme > "$WORK_DIR/README.md"
|
||||
gen_license > "$WORK_DIR/LICENSE"
|
||||
gen_contributing > "$WORK_DIR/CONTRIBUTING.md"
|
||||
gen_ci_config > "$WORK_DIR/.gitlink-ci.yml"
|
||||
log_ok "Generated: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml"
|
||||
fi
|
||||
|
||||
# ── Step 6: Create Initial Release ───────────────────────────────────
|
||||
log_step "Creating initial release v0.1.0..."
|
||||
# ── Step 4: Push files to repo ───────────────────────────────────────
|
||||
log_step "Step 4/8: Pushing scaffold files to repository..."
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would git add/commit/push to $REPO_URL"
|
||||
else
|
||||
pushd "$WORK_DIR" > /dev/null
|
||||
git add README.md LICENSE CONTRIBUTING.md .gitlink-ci.yml
|
||||
git -c user.name="$OWNER" -c user.email="$OWNER@gitlink.org.cn" \
|
||||
commit -m "chore: initialize project scaffold
|
||||
|
||||
- Add README.md with getting-started guide
|
||||
- Add MIT LICENSE
|
||||
- Add CONTRIBUTING.md with guidelines
|
||||
- Add .gitlink-ci.yml CI pipeline configuration" 2>&1 | tail -1
|
||||
git push -u origin master 2>&1 | tail -1
|
||||
popd > /dev/null
|
||||
log_ok "Scaffold files pushed to master"
|
||||
fi
|
||||
|
||||
# ── Step 5: Create Milestone ─────────────────────────────────────────
|
||||
log_step "Step 5/8: Creating initial milestone..."
|
||||
|
||||
MILESTONES=(
|
||||
"v1.0.0 - First Release|First official release milestone"
|
||||
)
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would create ${#MILESTONES[@]} milestone"
|
||||
else
|
||||
for entry in "${MILESTONES[@]}"; do
|
||||
IFS='|' read -r ms_title ms_desc <<< "$entry"
|
||||
MS_RESULT=$(gl_run milestone +create --owner "$OWNER" --repo "$REPO_NAME" \
|
||||
--name "$ms_title" --description "$ms_desc" 2>&1) || true
|
||||
if [[ "$(json_ok "$MS_RESULT")" == "true" ]]; then
|
||||
log_ok "Milestone created: $ms_title"
|
||||
else
|
||||
log_warn "Milestone creation may have failed: $ms_title"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Step 6: Create Welcome Issue ─────────────────────────────────────
|
||||
log_step "Step 6/8: Creating welcome issue..."
|
||||
|
||||
ISSUES_TO_CREATE=(
|
||||
"Welcome to $REPO_NAME|欢迎来到 **${REPO_NAME}** 的仓库,这是本仓库的第一条issue!!|welcome"
|
||||
)
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would create ${#ISSUES_TO_CREATE[@]} initial issues"
|
||||
else
|
||||
for entry in "${ISSUES_TO_CREATE[@]}"; do
|
||||
IFS='|' read -r title body label <<< "$entry"
|
||||
ISSUE_RESULT=$(gl_run issue +create --owner "$OWNER" --repo "$REPO_NAME" \
|
||||
--title "$title" --body "$body" 2>&1) || true
|
||||
ISSUE_NUM=$(echo "$ISSUE_RESULT" | jq -r '.data.id // .data.number // empty')
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
gl_run issue +label-add --owner "$OWNER" --repo "$REPO_NAME" --number "$ISSUE_NUM" --labels "$label" > /dev/null 2>&1 || true
|
||||
log_ok "Issue created: #$ISSUE_NUM - $title"
|
||||
else
|
||||
log_warn "Issue creation may have failed: $title"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Step 7: Protect Default Branch ───────────────────────────────────
|
||||
log_step "Step 7/8: Protecting master branch..."
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would protect branch 'master'"
|
||||
else
|
||||
PROTECT_RESULT=$(gl_run branch +protect --owner "$OWNER" --repo "$REPO_NAME" --name master 2>&1) || true
|
||||
if [[ "$(json_ok "$PROTECT_RESULT")" == "true" ]]; then
|
||||
log_ok "Branch 'master' protected"
|
||||
else
|
||||
log_warn "Branch protection may have failed (may require admin permissions)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Step 8: Create Initial Release ───────────────────────────────────
|
||||
log_step "Step 8/8: Creating initial release v0.1.0..."
|
||||
|
||||
RELEASE_BODY="# v0.1.0 - Initial Release
|
||||
|
||||
## What's New
|
||||
- Project initialized with $PROJ_LANG template
|
||||
- README and CONTRIBUTING guides created
|
||||
- CI/CD pipeline issues filed
|
||||
- README, LICENSE, CONTRIBUTING guide in repository
|
||||
- CI/CD pipeline configuration (.gitlink-ci.yml)
|
||||
- ${#MILESTONES[@]} project milestone created
|
||||
- ${#ISSUES_TO_CREATE[@]} initial issues filed
|
||||
- Branch protection enabled
|
||||
|
||||
## Next Steps
|
||||
|
|
@ -319,13 +493,16 @@ RELEASE_BODY="# v0.1.0 - Initial Release
|
|||
---
|
||||
*Auto-initialized by gitlink-cli project-init workflow*"
|
||||
|
||||
RELEASE_RESULT=$(gl_run release +create --owner "$OWNER" --repo "$REPO_NAME" \
|
||||
--tag "v0.1.0" --name "Initial Release" --body "$RELEASE_BODY" 2>&1) || true
|
||||
|
||||
if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then
|
||||
log_ok "Release v0.1.0 created"
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would create release v0.1.0"
|
||||
else
|
||||
log_warn "Release creation may have failed"
|
||||
RELEASE_RESULT=$(gl_run release +create --owner "$OWNER" --repo "$REPO_NAME" \
|
||||
--tag "v0.1.0" --name "Initial Release" --body "$RELEASE_BODY" 2>&1) || true
|
||||
if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then
|
||||
log_ok "Release v0.1.0 created"
|
||||
else
|
||||
log_warn "Release creation may have failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -334,14 +511,14 @@ log_title "Project Initialization Complete"
|
|||
|
||||
echo -e "${GREEN}Created:${NC}"
|
||||
echo " Repository: $OWNER/$REPO_NAME"
|
||||
echo " README: Wiki page"
|
||||
echo " CONTRIBUTING: Wiki page"
|
||||
echo " Files: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml"
|
||||
echo " Milestones: ${#MILESTONES[@]} milestone"
|
||||
echo " Issues: ${#ISSUES_TO_CREATE[@]} initial issues"
|
||||
echo " Branch: master (protected)"
|
||||
echo " Release: v0.1.0"
|
||||
echo ""
|
||||
echo -e "${CYAN}Next steps:${NC}"
|
||||
echo " 1. Clone: git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git"
|
||||
echo " 2. Add your code and push"
|
||||
echo " 3. Setup CI/CD by closing the first issue"
|
||||
echo " 1. Clone: git clone $REPO_URL"
|
||||
echo " 2. Start coding and close the first issue"
|
||||
echo " 3. CI/CD will trigger on your first push"
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -1,55 +1,99 @@
|
|||
# ----------------------------------------------------------------
|
||||
# ----------------------------------------------------------------
|
||||
# Scenario 4: Multi-Repo Collaboration
|
||||
# Flow: Cross-repo issue tracking -> PR status dashboard -> Coordinated release
|
||||
#
|
||||
# Commands chained:
|
||||
# 1. repo +list -- list all repos in org
|
||||
# 2. issue +list -- fetch issues from each repo
|
||||
# 3. pr +list -- fetch PRs from each repo
|
||||
# 4. release +list -- check release status across repos
|
||||
# 5. release +create -- coordinated release (optional)
|
||||
# 6. Generate HTML dashboard
|
||||
# 1. repo +list -- list all repos in org
|
||||
# 2. issue +list -- fetch open/closed issues from each repo
|
||||
# 3. pr +list -- fetch open/merged PRs from each repo
|
||||
# 4. release +list -- check latest release across repos
|
||||
# 5. Generate HTML dashboard (summary cards + table + per-repo details)
|
||||
# 6. release +create -- (optional) coordinated release across all repos
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Org,
|
||||
[string]$Org = "",
|
||||
[string]$Repos = "",
|
||||
[string]$Release = "",
|
||||
[string]$Output = "dashboard.html",
|
||||
[int]$DetailLimit = 10,
|
||||
[switch]$DryRun,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 04-multi-repo-collab.ps1 -Org ORG [-Repos 'repo1,repo2'] [-Release TAG] [-Output FILE] [-DryRun]"
|
||||
Write-Host "Usage: powershell 04-multi-repo-collab.ps1 -Org ORG [-Repos 'repo1,repo2'] [-Release TAG] [-Output FILE] [-DetailLimit N] [-DryRun]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $Org) { Log-Err "Org is required (use -Help for usage)"; exit 1 }
|
||||
|
||||
Check-Auth
|
||||
|
||||
# -- Step 1: List Repositories --
|
||||
Log-Title "Multi-Repo Collaboration Dashboard"
|
||||
# -- Helpers --
|
||||
function Html-Escape([string]$s) {
|
||||
if ($null -eq $s) { return "" }
|
||||
$s = $s -replace '&', '&'
|
||||
$s = $s -replace '<', '<'
|
||||
$s = $s -replace '>', '>'
|
||||
$s = $s -replace '"', '"'
|
||||
return $s
|
||||
}
|
||||
|
||||
Log-Step "Fetching repositories for org: $Org..."
|
||||
# Health score (0-100) aligned with gitlink-health skill.
|
||||
# Core 75 (issue efficiency / PR efficiency / activity, -25 each),
|
||||
# auxiliary 25 (issue backlog -10, PR backlog -10, no recent release -5).
|
||||
# releaseAge: days since latest release, -1 = none.
|
||||
function Compute-Health([int]$oi, [int]$ci, [int]$op, [int]$mp, [int]$age) {
|
||||
$health = 100
|
||||
# 核心指标 (75)
|
||||
$ti = $oi + $ci
|
||||
$res = if ($ti -gt 0) { $ci / $ti } else { 1 }
|
||||
if ($res -lt 0.5) { $health -= 25 } # Issue 处理效率 (响应慢代理)
|
||||
$tp = $op + $mp
|
||||
$mr = if ($tp -gt 0) { $mp / $tp } else { 1 }
|
||||
if ($mr -lt 0.5) { $health -= 25 } # PR 合并效率 (合并慢代理)
|
||||
if (($mp + $ci) -eq 0) { $health -= 25 } # 活跃度低: 无已完成工作
|
||||
# 辅助指标 (25)
|
||||
if ($oi -gt 20) { $health -= 10 } # Issue 积压
|
||||
if ($op -gt 10) { $health -= 10 } # PR 积压
|
||||
if ($age -lt 0 -or $age -gt 30) { $health -= 5 } # 无近期发布
|
||||
if ($health -lt 0) { $health = 0 }
|
||||
if ($health -gt 100) { $health = 100 }
|
||||
return [int]$health
|
||||
}
|
||||
|
||||
# Map score -> @{ Label; Class } (5 bands per gitlink-health skill)
|
||||
function Get-HealthGrade([int]$score) {
|
||||
if ($score -ge 90) { return @{ Label = "优秀"; Class = "excellent" } }
|
||||
elseif ($score -ge 70) { return @{ Label = "良好"; Class = "good" } }
|
||||
elseif ($score -ge 50) { return @{ Label = "一般"; Class = "moderate" } }
|
||||
elseif ($score -ge 30) { return @{ Label = "需关注"; Class = "attention" } }
|
||||
else { return @{ Label = "严重"; Class = "critical" } }
|
||||
}
|
||||
|
||||
# -- Step 1: List Repositories --
|
||||
Log-Title "多仓库协同看板 (Multi-Repo Collaboration Dashboard)"
|
||||
|
||||
Log-Step "获取组织 $Org 下的仓库列表..."
|
||||
$reposJson = Invoke-GLCheck "repo", "+list", "--user", $Org, "--limit", "100"
|
||||
if (-not $reposJson) { Log-Err "Failed to fetch repos"; exit 1 }
|
||||
if (-not $reposJson) { Log-Err "获取仓库列表失败"; exit 1 }
|
||||
|
||||
$allRepos = @()
|
||||
$rd = $reposJson.data
|
||||
if ($rd.projects) { $allRepos = @($rd.projects) }
|
||||
elseif ($rd -is [array]) { $allRepos = $rd }
|
||||
|
||||
Log-Ok "Found $($allRepos.Count) repositories"
|
||||
Log-Ok "组织下共有 $($allRepos.Count) 个仓库"
|
||||
|
||||
$repoList = @()
|
||||
if ($Repos) {
|
||||
$repoList = $Repos -split ','
|
||||
Log-Info "Filtering to specified repos: $($repoList -join ', ')"
|
||||
Log-Info "仅处理指定仓库: $($repoList -join ', ')"
|
||||
} else {
|
||||
foreach ($r in $allRepos) {
|
||||
$rname = if ($r.name) { $r.name } elseif ($r.identifier) { $r.identifier } else { $null }
|
||||
|
|
@ -57,153 +101,259 @@ if ($Repos) {
|
|||
}
|
||||
}
|
||||
|
||||
Log-Ok "Will process $($repoList.Count) repositories"
|
||||
if ($repoList.Count -eq 0) { Log-Err "没有可处理的仓库。请检查 -Org 或用 -Repos 指定。"; exit 1 }
|
||||
Log-Ok "将处理 $($repoList.Count) 个仓库"
|
||||
|
||||
# -- Step 2-3: Collect Issues and PRs from each repo --
|
||||
Log-Title "Collecting Data Across Repos"
|
||||
# -- Step 2-4: Collect data across repos --
|
||||
Log-Title "跨仓库数据采集"
|
||||
|
||||
$totalIssues = 0; $totalOpenIssues = 0; $totalPRs = 0; $totalOpenPRs = 0
|
||||
$totalActivity = 0; $totalOpenIssues = 0; $totalOpenPRs = 0; $totalMergedPRs = 0; $healthSum = 0
|
||||
$dashboardRows = ""
|
||||
$repoDetails = ""
|
||||
|
||||
foreach ($repo in $repoList) {
|
||||
Divider
|
||||
Log-Step "Processing $Org/$repo..."
|
||||
Log-Step "处理 $Org/$repo..."
|
||||
|
||||
$issuesJson = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "open", "--limit", "50"
|
||||
$openIssues = if ($issuesJson) { @($issuesJson.data.issues).Count } else { 0 }
|
||||
# Open issues
|
||||
$issuesObj = $null; $openIssues = 0; $openIssueItems = @()
|
||||
$issuesRaw = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "open", "--limit", "50"
|
||||
if ($issuesRaw) { try {
|
||||
$issuesObj = $issuesRaw | ConvertFrom-Json
|
||||
if ($issuesObj.ok -and $issuesObj.data.issues) { $openIssueItems = @($issuesObj.data.issues); $openIssues = $openIssueItems.Count }
|
||||
} catch {} }
|
||||
|
||||
$closedJson = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "closed", "--limit", "50"
|
||||
$closedIssues = if ($closedJson) { @($closedJson.data.issues).Count } else { 0 }
|
||||
# Closed issues
|
||||
$closedIssues = 0
|
||||
$closedRaw = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "closed", "--limit", "50"
|
||||
if ($closedRaw) { try {
|
||||
$closedObj = $closedRaw | ConvertFrom-Json
|
||||
if ($closedObj.ok -and $closedObj.data.issues) { $closedIssues = @($closedObj.data.issues).Count }
|
||||
} catch {} }
|
||||
|
||||
$prsJson = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "open", "--limit", "50"
|
||||
$openPRs = 0
|
||||
if ($prsJson) {
|
||||
$pd = $prsJson.data
|
||||
if ($pd.issues) { $openPRs = @($pd.issues).Count }
|
||||
elseif ($pd.pulls) { $openPRs = @($pd.pulls).Count }
|
||||
elseif ($pd -is [array]) { $openPRs = $pd.Count }
|
||||
}
|
||||
|
||||
$mergedJson = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "merged", "--limit", "50"
|
||||
$mergedPRs = 0
|
||||
if ($mergedJson) {
|
||||
$md = $mergedJson.data
|
||||
if ($md.issues) { $mergedPRs = @($md.issues).Count }
|
||||
elseif ($md.pulls) { $mergedPRs = @($md.pulls).Count }
|
||||
elseif ($md -is [array]) { $mergedPRs = $md.Count }
|
||||
}
|
||||
|
||||
$releaseJson = Invoke-GL "release", "+list", "--owner", $Org, "--repo", $repo, "--limit", "1"
|
||||
$latestRelease = "none"
|
||||
if ($releaseJson -and $releaseJson.data.releases) {
|
||||
$releases = @($releaseJson.data.releases)
|
||||
if ($releases.Count -gt 0) {
|
||||
$latestRelease = if ($releases[0].tag_name) { $releases[0].tag_name } elseif ($releases[0].name) { $releases[0].name } else { "none" }
|
||||
# Open PRs
|
||||
$openPRs = 0; $openPRItems = @()
|
||||
$prsRaw = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "open", "--limit", "50"
|
||||
if ($prsRaw) { try {
|
||||
$prsObj = $prsRaw | ConvertFrom-Json
|
||||
if ($prsObj.ok) {
|
||||
if ($prsObj.data.issues) { $openPRItems = @($prsObj.data.issues) }
|
||||
elseif ($prsObj.data.pulls) { $openPRItems = @($prsObj.data.pulls) }
|
||||
elseif ($prsObj.data -is [array]) { $openPRItems = @($prsObj.data) }
|
||||
$openPRs = $openPRItems.Count
|
||||
}
|
||||
} catch {} }
|
||||
|
||||
# Merged PRs
|
||||
$mergedPRs = 0
|
||||
$mergedRaw = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "merged", "--limit", "50"
|
||||
if ($mergedRaw) { try {
|
||||
$mergedObj = $mergedRaw | ConvertFrom-Json
|
||||
if ($mergedObj.ok) {
|
||||
if ($mergedObj.data.issues) { $mergedPRs = @($mergedObj.data.issues).Count }
|
||||
elseif ($mergedObj.data.pulls) { $mergedPRs = @($mergedObj.data.pulls).Count }
|
||||
elseif ($mergedObj.data -is [array]) { $mergedPRs = @($mergedObj.data).Count }
|
||||
}
|
||||
} catch {} }
|
||||
|
||||
# Latest release + age
|
||||
$latestRelease = "none"; $releaseAge = -1
|
||||
$releaseRaw = Invoke-GL "release", "+list", "--owner", $Org, "--repo", $repo, "--limit", "1"
|
||||
if ($releaseRaw) { try {
|
||||
$releaseObj = $releaseRaw | ConvertFrom-Json
|
||||
$rels = $null
|
||||
if ($releaseObj.ok -and $releaseObj.data.releases) { $rels = @($releaseObj.data.releases) }
|
||||
elseif ($releaseObj.ok -and $releaseObj.data -is [array]) { $rels = @($releaseObj.data) }
|
||||
if ($rels -and $rels.Count -gt 0) {
|
||||
$latestRelease = if ($rels[0].tag_name) { $rels[0].tag_name } elseif ($rels[0].name) { $rels[0].name } else { "none" }
|
||||
$relDate = if ($rels[0].created_at) { $rels[0].created_at } elseif ($rels[0].created_on) { $rels[0].created_on } elseif ($rels[0].published_at) { $rels[0].published_at } else { $null }
|
||||
if ($relDate) {
|
||||
try { $releaseAge = [int]((Get-Date) - [datetime]$relDate).TotalDays } catch { $releaseAge = 0 }
|
||||
} else { $releaseAge = 0 }
|
||||
}
|
||||
} catch {} }
|
||||
|
||||
$health = Compute-Health $openIssues $closedIssues $openPRs $mergedPRs $releaseAge
|
||||
$grade = Get-HealthGrade $health
|
||||
|
||||
Log-Ok "$repo : Issue(开:$openIssues 闭:$closedIssues) PR(开:$openPRs 合:$mergedPRs) Release:$latestRelease 健康度:$health($($grade.Label))"
|
||||
|
||||
# Open issue detail rows
|
||||
$issueRows = ""
|
||||
$limit = [Math]::Min($openIssues, $DetailLimit)
|
||||
for ($i = 0; $i -lt $limit; $i++) {
|
||||
$it = $openIssueItems[$i]
|
||||
$iid = if ($it.id) { $it.id } elseif ($it.number) { $it.number } else { "" }
|
||||
$ititle = if ($it.name) { $it.name } elseif ($it.subject) { $it.subject } elseif ($it.title) { $it.title } else { "" }
|
||||
$iauthor = if ($it.author_name) { $it.author_name } elseif ($it.author_login) { $it.author_login } elseif ($it.author.login) { $it.author.login } else { "unknown" }
|
||||
$issueRows += "<tr><td>#$(Html-Escape ([string]$iid))</td><td>$(Html-Escape ([string]$ititle))</td><td>@$(Html-Escape ([string]$iauthor))</td></tr>"
|
||||
}
|
||||
if (-not $issueRows) { $issueRows = '<tr><td colspan="3" class="empty">无未关闭 Issue</td></tr>' }
|
||||
|
||||
Log-Ok "$repo : Issues(open:$openIssues closed:$closedIssues) PRs(open:$openPRs merged:$mergedPRs) Release:$latestRelease"
|
||||
# Open PR detail rows
|
||||
$prRows = ""
|
||||
$limit = [Math]::Min($openPRs, $DetailLimit)
|
||||
for ($i = 0; $i -lt $limit; $i++) {
|
||||
$pt = $openPRItems[$i]
|
||||
$pid = if ($pt.pull_request_number) { $pt.pull_request_number } elseif ($pt.number) { $pt.number } elseif ($pt.id) { $pt.id } else { "" }
|
||||
$ptitle = if ($pt.subject) { $pt.subject } elseif ($pt.title) { $pt.title } elseif ($pt.name) { $pt.name } else { "" }
|
||||
$pauthor = if ($pt.author_name) { $pt.author_name } elseif ($pt.author_login) { $pt.author_login } elseif ($pt.author.login) { $pt.author.login } else { "unknown" }
|
||||
$prRows += "<tr><td>#$(Html-Escape ([string]$pid))</td><td>$(Html-Escape ([string]$ptitle))</td><td>@$(Html-Escape ([string]$pauthor))</td></tr>"
|
||||
}
|
||||
if (-not $prRows) { $prRows = '<tr><td colspan="3" class="empty">无未合并 PR</td></tr>' }
|
||||
|
||||
$statusColor = "green"
|
||||
$healthText = "Healthy"
|
||||
if ($openIssues -gt 10) { $statusColor = "orange"; $healthText = "Moderate" }
|
||||
if ($openIssues -gt 20) { $statusColor = "red"; $healthText = "Needs Attention" }
|
||||
$repoDetails += @"
|
||||
<details><summary><span class="badge $($grade.Class)">$health</span> $(Html-Escape $repo) — Issue 开 $openIssues / PR 开 $openPRs</summary>
|
||||
<div class="detail-grid">
|
||||
<div><h4>未关闭 Issue(最多 $DetailLimit 条)</h4>
|
||||
<table class="detail-table"><thead><tr><th>编号</th><th>标题</th><th>作者</th></tr></thead><tbody>$issueRows</tbody></table></div>
|
||||
<div><h4>未合并 PR(最多 $DetailLimit 条)</h4>
|
||||
<table class="detail-table"><thead><tr><th>编号</th><th>标题</th><th>作者</th></tr></thead><tbody>$prRows</tbody></table></div>
|
||||
</div>
|
||||
</details>
|
||||
"@
|
||||
|
||||
$dashboardRows += "<tr>`n"
|
||||
$dashboardRows += " <td><a href=`"https://gitlink.org.cn/$Org/$repo`">$repo</a></td>`n"
|
||||
$dashboardRows += " <td>$openIssues</td><td>$closedIssues</td>`n"
|
||||
$dashboardRows += " <td>$openPRs</td><td>$mergedPRs</td><td>$latestRelease</td>`n"
|
||||
$dashboardRows += " <td style=`"color:$statusColor;font-weight:bold;`">$healthText</td>`n"
|
||||
$dashboardRows += "</tr>`n"
|
||||
|
||||
$totalIssues += $openIssues + $closedIssues
|
||||
$totalActivity += $openIssues + $closedIssues + $openPRs + $mergedPRs
|
||||
$totalOpenIssues += $openIssues
|
||||
$totalPRs += $openPRs + $mergedPRs
|
||||
$totalOpenPRs += $openPRs
|
||||
$totalMergedPRs += $mergedPRs
|
||||
$healthSum += $health
|
||||
|
||||
$relDisplay = if ($latestRelease -eq "none") { "—" } else { Html-Escape $latestRelease }
|
||||
|
||||
$dashboardRows += @"
|
||||
<tr>
|
||||
<td><a href="https://gitlink.org.cn/$Org/$repo" target="_blank">$(Html-Escape $repo)</a></td>
|
||||
<td>$openIssues</td><td>$closedIssues</td>
|
||||
<td>$openPRs</td><td>$mergedPRs</td><td>$relDisplay</td>
|
||||
<td><span class="badge $($grade.Class)">$health</span> <span class="grade $($grade.Class)">$($grade.Label)</span></td>
|
||||
</tr>
|
||||
"@
|
||||
}
|
||||
|
||||
# -- Step 4: Generate HTML Dashboard --
|
||||
Log-Title "Generating Dashboard"
|
||||
$avgHealth = if ($repoList.Count -gt 0) { [int]($healthSum / $repoList.Count) } else { 0 }
|
||||
|
||||
# -- Step 5: Generate HTML Dashboard --
|
||||
Log-Title "生成 HTML 看板"
|
||||
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$repoCount = $repoList.Count
|
||||
|
||||
$html = '<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
$html = @"
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Multi-Repo Collaboration Dashboard</title>
|
||||
<title>多仓库协同看板 - $Org</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; padding: 20px; }
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
h1 { color: #333; margin-bottom: 20px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 30px; }
|
||||
.card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.card h3 { color: #666; font-size: 14px; margin-bottom: 8px; }
|
||||
.card .value { font-size: 32px; font-weight: bold; color: #333; }
|
||||
.card.blue .value { color: #2196F3; }
|
||||
.card.green .value { color: #4CAF50; }
|
||||
.card.orange .value { color: #FF9800; }
|
||||
.card.purple .value { color: #9C27B0; }
|
||||
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
th { background: #2196F3; color: white; padding: 12px 16px; text-align: left; }
|
||||
td { padding: 12px 16px; border-bottom: 1px solid #eee; }
|
||||
tr:hover { background: #f9f9f9; }
|
||||
a { color: #2196F3; text-decoration: none; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif; background: #f0f2f5; padding: 24px; color: #1f2329; }
|
||||
.container { max-width: 1280px; margin: 0 auto; }
|
||||
h1 { color: #1f2329; margin-bottom: 6px; font-size: 26px; }
|
||||
.timestamp { color: #8a919f; font-size: 13px; margin-bottom: 24px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 28px; }
|
||||
.card { background: #fff; border-radius: 10px; padding: 18px 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.card h3 { color: #8a919f; font-size: 13px; margin-bottom: 8px; font-weight: 500; }
|
||||
.card .value { font-size: 30px; font-weight: 700; }
|
||||
.card.blue .value { color: #2f80ed; }
|
||||
.card.orange .value { color: #f2994a; }
|
||||
.card.purple .value { color: #9b51e0; }
|
||||
.card.green .value { color: #27ae60; }
|
||||
.card.teal .value { color: #17a2b8; }
|
||||
h2 { font-size: 18px; margin: 24px 0 14px; color: #1f2329; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
th { background: #2f80ed; color: #fff; padding: 12px 16px; text-align: left; font-weight: 500; font-size: 14px; }
|
||||
td { padding: 11px 16px; border-bottom: 1px solid #eef0f3; font-size: 14px; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f7f9fc; }
|
||||
a { color: #2f80ed; text-decoration: none; font-weight: 500; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.timestamp { color: #999; font-size: 14px; margin-bottom: 20px; }
|
||||
.badge { display: inline-block; min-width: 34px; text-align: center; padding: 2px 8px; border-radius: 12px; color: #fff; font-weight: 700; font-size: 13px; }
|
||||
.grade { font-size: 13px; margin-left: 4px; }
|
||||
.badge.excellent { background: #27ae60; } .grade.excellent { color: #27ae60; }
|
||||
.badge.good { background: #2f80ed; } .grade.good { color: #2f80ed; }
|
||||
.badge.moderate { background: #f2c94c; } .grade.moderate { color: #d9a400; }
|
||||
.badge.attention { background: #f2994a; } .grade.attention { color: #f2994a; }
|
||||
.badge.critical { background: #eb5757; } .grade.critical { color: #eb5757; }
|
||||
details { background: #fff; border-radius: 10px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
|
||||
summary { padding: 14px 18px; cursor: pointer; font-weight: 600; font-size: 15px; user-select: none; }
|
||||
summary:hover { background: #f7f9fc; }
|
||||
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding: 0 18px 18px; }
|
||||
.detail-table th { background: #eef2f8; color: #4a5568; padding: 8px 12px; font-size: 13px; }
|
||||
.detail-table td { padding: 8px 12px; font-size: 13px; }
|
||||
.detail-grid h4 { font-size: 14px; margin-bottom: 8px; color: #4a5568; }
|
||||
.empty { color: #b0b7c3; text-align: center; font-style: italic; }
|
||||
@media (max-width: 900px) { .summary { grid-template-columns: repeat(2, 1fr); } .detail-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Multi-Repo Collaboration Dashboard</h1>
|
||||
<p class="timestamp">Generated: ' + $timestamp + ' | Organization: ' + $Org + '</p>
|
||||
<h1>多仓库协同看板</h1>
|
||||
<p class="timestamp">生成时间:$timestamp | 组织:$Org</p>
|
||||
<div class="summary">
|
||||
<div class="card blue"><h3>Total Repos</h3><div class="value">' + $repoCount + '</div></div>
|
||||
<div class="card orange"><h3>Open Issues</h3><div class="value">' + $totalOpenIssues + '</div></div>
|
||||
<div class="card purple"><h3>Open PRs</h3><div class="value">' + $totalOpenPRs + '</div></div>
|
||||
<div class="card green"><h3>Total Activity</h3><div class="value">' + $totalIssues + '</div></div>
|
||||
<div class="card blue"><h3>仓库总数</h3><div class="value">$repoCount</div></div>
|
||||
<div class="card orange"><h3>未关闭 Issue</h3><div class="value">$totalOpenIssues</div></div>
|
||||
<div class="card purple"><h3>未合并 PR</h3><div class="value">$totalOpenPRs</div></div>
|
||||
<div class="card green"><h3>已合并 PR</h3><div class="value">$totalMergedPRs</div></div>
|
||||
<div class="card teal"><h3>平均健康度</h3><div class="value">$avgHealth</div></div>
|
||||
</div>
|
||||
|
||||
<h2>仓库概览</h2>
|
||||
<table>
|
||||
<thead><tr><th>Repository</th><th>Open Issues</th><th>Closed Issues</th><th>Open PRs</th><th>Merged PRs</th><th>Latest Release</th><th>Health</th></tr></thead>
|
||||
<thead><tr><th>仓库</th><th>未关闭 Issue</th><th>已关闭 Issue</th><th>未合并 PR</th><th>已合并 PR</th><th>最新 Release</th><th>健康度</th></tr></thead>
|
||||
<tbody>
|
||||
' + $dashboardRows + '
|
||||
$dashboardRows
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>仓库详情</h2>
|
||||
$repoDetails
|
||||
</div>
|
||||
</body>
|
||||
</html>'
|
||||
</html>
|
||||
"@
|
||||
|
||||
$html | Out-File -FilePath $Output -Encoding UTF8
|
||||
Log-Ok "Dashboard saved to: $Output"
|
||||
$utf8 = [System.Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::WriteAllText((Join-Path (Get-Location) $Output), $html, $utf8)
|
||||
Log-Ok "看板已保存: $Output"
|
||||
|
||||
# -- Step 5: Coordinated Release --
|
||||
# -- Step 6: Coordinated Release --
|
||||
if ($Release) {
|
||||
Log-Title "Coordinated Release: $Release"
|
||||
Log-Title "协调发版: $Release"
|
||||
|
||||
foreach ($repo in $repoList) {
|
||||
Log-Step "Creating release for $Org/$repo..."
|
||||
$relBody = "Coordinated release $Release for $Org/$repo"
|
||||
$relResult = Invoke-GL "release", "+create", "--owner", $Org, "--repo", $repo, "--tag", $Release, "--name", "Release $Release", "--body", $relBody
|
||||
if ($relResult -and (Get-JsonOk $relResult)) {
|
||||
Log-Ok "Release $Release created for $repo"
|
||||
} else {
|
||||
Log-Warn "Release creation failed for $repo (tag may already exist)"
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] 将为以下 $($repoList.Count) 个仓库创建 Release ${Release}:"
|
||||
foreach ($repo in $repoList) { Log-Warn " [DRY RUN] $Org/$repo -> $Release" }
|
||||
} else {
|
||||
$relOk = 0; $relFail = 0
|
||||
foreach ($repo in $repoList) {
|
||||
Log-Step "为 $Org/$repo 创建 Release..."
|
||||
$relBody = "Coordinated release $Release for $Org/$repo"
|
||||
$relResult = Invoke-GL "release", "+create", "--owner", $Org, "--repo", $repo, "--tag", $Release, "--name", "Release $Release", "--body", $relBody
|
||||
$ok = $false
|
||||
if ($relResult) { try { $ok = ($relResult | ConvertFrom-Json).ok } catch { $ok = $false } }
|
||||
if ($ok) { Log-Ok "Release $Release 已在 $repo 创建"; $relOk++ }
|
||||
else { Log-Warn "Release 创建失败: $repo (tag 可能已存在)"; $relFail++ }
|
||||
}
|
||||
Log-Info "协调发版结果: 成功 $relOk / 失败 $relFail"
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
Log-Title "Multi-Repo Dashboard Complete"
|
||||
Log-Title "多仓库看板完成"
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
Write-Host " Repos processed: $repoCount" -ForegroundColor Green
|
||||
Write-Host " Total issues: $totalIssues (open: $totalOpenIssues)" -ForegroundColor Green
|
||||
Write-Host " Total PRs: $totalPRs (open: $totalOpenPRs)" -ForegroundColor Green
|
||||
Write-Host " Dashboard: $Output" -ForegroundColor Green
|
||||
if ($Release) { Write-Host " Coordinated release: $Release" -ForegroundColor Green }
|
||||
Write-Host " 处理仓库: $repoCount" -ForegroundColor Green
|
||||
Write-Host " 未关闭 Issue: $totalOpenIssues" -ForegroundColor Green
|
||||
Write-Host " 未合并 PR: $totalOpenPRs" -ForegroundColor Green
|
||||
Write-Host " 已合并 PR: $totalMergedPRs" -ForegroundColor Green
|
||||
Write-Host " 平均健康度: $avgHealth / 100" -ForegroundColor Green
|
||||
Write-Host " 看板文件: $Output" -ForegroundColor Green
|
||||
if ($Release) {
|
||||
$suffix = if ($DryRun) { " (dry-run)" } else { "" }
|
||||
Write-Host " 协调发版: $Release$suffix" -ForegroundColor Green
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Open dashboard:" -ForegroundColor Cyan
|
||||
Write-Host "打开看板:" -ForegroundColor Cyan
|
||||
Write-Host " Start-Process $Output"
|
||||
|
|
|
|||
|
|
@ -3,27 +3,27 @@
|
|||
# Scenario 4: Multi-Repo Collaboration
|
||||
# Flow: Cross-repo issue tracking → PR status dashboard → Coordinated release
|
||||
#
|
||||
# Commands/Skills chained:
|
||||
# 1. repo +list -- list all repos in org
|
||||
# 2. issue +list -- fetch issues from each repo
|
||||
# 3. pr +list -- fetch PRs from each repo
|
||||
# 4. pr +view -- get PR details for dashboard
|
||||
# 5. release +list -- check release status across repos
|
||||
# 6. release +create -- coordinated release
|
||||
# 7. Generate HTML dashboard
|
||||
# Commands chained:
|
||||
# 1. repo +list -- list all repos in org
|
||||
# 2. issue +list -- fetch open/closed issues from each repo
|
||||
# 3. pr +list -- fetch open/merged PRs from each repo
|
||||
# 4. release +list -- check latest release across repos
|
||||
# 5. Generate HTML dashboard (summary cards + table + per-repo details)
|
||||
# 6. release +create -- (optional) coordinated release across all repos
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --org ORG [--repos REPO1,REPO2,...] [--release TAG] [--dry-run]"
|
||||
echo "Usage: $0 --org ORG [--repos REPO1,REPO2,...] [--release TAG] [--output FILE] [--dry-run]"
|
||||
echo ""
|
||||
echo " --org ORG Organization name"
|
||||
echo " --org ORG Organization / user name (required)"
|
||||
echo " --repos REPO1,REPO2 Comma-separated repo list (default: all repos in org)"
|
||||
echo " --release TAG Coordinated release tag to create"
|
||||
echo " --release TAG Coordinated release tag to create across all repos"
|
||||
echo " --output FILE Output HTML dashboard file (default: dashboard.html)"
|
||||
echo " --dry-run Preview actions without executing"
|
||||
echo " --detail-limit N Max open issues/PRs listed per repo in details (default: 10)"
|
||||
echo " --dry-run Preview actions without creating releases"
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
|
@ -32,16 +32,18 @@ ORG=""
|
|||
REPOS=""
|
||||
RELEASE_TAG=""
|
||||
OUTPUT_FILE="dashboard.html"
|
||||
DETAIL_LIMIT=10
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--org) ORG="$2"; shift 2 ;;
|
||||
--repos) REPOS="$2"; shift 2 ;;
|
||||
--release) RELEASE_TAG="$2"; shift 2 ;;
|
||||
--output) OUTPUT_FILE="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) log_err "Unknown arg: $1"; usage ;;
|
||||
--org) ORG="$2"; shift 2 ;;
|
||||
--repos) REPOS="$2"; shift 2 ;;
|
||||
--release) RELEASE_TAG="$2"; shift 2 ;;
|
||||
--output) OUTPUT_FILE="$2"; shift 2 ;;
|
||||
--detail-limit) DETAIL_LIMIT="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) log_err "Unknown arg: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
|
@ -52,214 +54,330 @@ fi
|
|||
|
||||
check_auth
|
||||
|
||||
# ── Step 1: List Repositories ────────────────────────────────────────
|
||||
log_title "Multi-Repo Collaboration Dashboard"
|
||||
# ── HTML escape helper ───────────────────────────────────────────────
|
||||
html_escape() {
|
||||
local s="$1"
|
||||
s="${s//&/&}"
|
||||
s="${s//</<}"
|
||||
s="${s//>/>}"
|
||||
s="${s//\"/"}"
|
||||
echo "$s"
|
||||
}
|
||||
|
||||
log_step "Fetching repositories for org: $ORG..."
|
||||
# ── Health score (0-100) — aligned with gitlink-health skill ─────────
|
||||
# Model: start 100, deduct. Core 75 (issue efficiency / PR efficiency /
|
||||
# activity, -25 each), auxiliary 25 (issue backlog -10, PR backlog -10,
|
||||
# no recent release -5). Time-based core metrics are approximated by
|
||||
# list-level ratios for a fast multi-repo scan.
|
||||
# release_age: days since latest release, -1 = no release.
|
||||
compute_health() {
|
||||
local open_issues="$1" closed_issues="$2" open_prs="$3" merged_prs="$4" release_age="$5"
|
||||
awk -v oi="$open_issues" -v ci="$closed_issues" -v op="$open_prs" \
|
||||
-v mp="$merged_prs" -v age="$release_age" 'BEGIN {
|
||||
health = 100
|
||||
# ── 核心指标 (75) ──
|
||||
# Issue 处理效率 (proxy for 响应慢): 解决率 < 50% 扣 25
|
||||
ti = oi + ci
|
||||
res = (ti > 0) ? ci / ti : 1
|
||||
if (res < 0.5) health -= 25
|
||||
# PR 合并效率 (proxy for 合并慢): 合并率 < 50% 扣 25
|
||||
tp = op + mp
|
||||
mr = (tp > 0) ? mp / tp : 1
|
||||
if (mr < 0.5) health -= 25
|
||||
# 活跃度低: 无任何已完成工作 (合并 PR + 关闭 Issue == 0) 扣 25
|
||||
if ((mp + ci) == 0) health -= 25
|
||||
# ── 辅助指标 (25) ──
|
||||
if (oi > 20) health -= 10 # Issue 积压
|
||||
if (op > 10) health -= 10 # PR 积压
|
||||
if (age < 0 || age > 30) health -= 5 # 无近期发布 (>30天或无)
|
||||
if (health < 0) health = 0
|
||||
if (health > 100) health = 100
|
||||
printf "%d", health
|
||||
}'
|
||||
}
|
||||
|
||||
# Map score → (grade, css class) — 5 bands per gitlink-health skill
|
||||
health_grade() {
|
||||
local score="$1"
|
||||
if [[ "$score" -ge 90 ]]; then echo "优秀|excellent"
|
||||
elif [[ "$score" -ge 70 ]]; then echo "良好|good"
|
||||
elif [[ "$score" -ge 50 ]]; then echo "一般|moderate"
|
||||
elif [[ "$score" -ge 30 ]]; then echo "需关注|attention"
|
||||
else echo "严重|critical"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Step 1: List Repositories ────────────────────────────────────────
|
||||
log_title "多仓库协同看板 (Multi-Repo Collaboration Dashboard)"
|
||||
|
||||
log_step "获取组织 $ORG 下的仓库列表..."
|
||||
REPOS_JSON=$(gl_check repo +list --user "$ORG" --limit 100)
|
||||
# Response may have .data.projects[] or .data[]
|
||||
ALL_REPO_COUNT=$(echo "$REPOS_JSON" | jq '(.data.projects // .data | if type == "array" then . else [] end) | length')
|
||||
log_ok "Found $ALL_REPO_COUNT repositories"
|
||||
REPOS_DATA_PATH='(.data.projects // .data | if type == "array" then . else [] end)'
|
||||
ALL_REPO_COUNT=$(echo "$REPOS_JSON" | jq "$REPOS_DATA_PATH | length")
|
||||
log_ok "组织下共有 $ALL_REPO_COUNT 个仓库"
|
||||
|
||||
# Filter repos if --repos specified
|
||||
REPO_LIST=()
|
||||
if [[ -n "$REPOS" ]]; then
|
||||
IFS=',' read -ra REPO_LIST <<< "$REPOS"
|
||||
log_info "Filtering to specified repos: ${REPO_LIST[*]}"
|
||||
log_info "仅处理指定仓库: ${REPO_LIST[*]}"
|
||||
else
|
||||
REPOS_DATA_PATH='(.data.projects // .data | if type == "array" then . else [] end)'
|
||||
for i in $(seq 0 $((ALL_REPO_COUNT - 1))); do
|
||||
RNAME=$(echo "$REPOS_JSON" | jq -r "$REPOS_DATA_PATH[$i].name // $REPOS_DATA_PATH[$i].identifier // empty")
|
||||
[[ -n "$RNAME" ]] && REPO_LIST+=("$RNAME")
|
||||
done
|
||||
fi
|
||||
|
||||
log_ok "Will process ${#REPO_LIST[@]} repositories"
|
||||
if [[ "${#REPO_LIST[@]}" -eq 0 ]]; then
|
||||
log_err "没有可处理的仓库。请检查 --org 是否正确,或用 --repos 指定。"
|
||||
exit 1
|
||||
fi
|
||||
log_ok "将处理 ${#REPO_LIST[@]} 个仓库"
|
||||
|
||||
# ── Step 2-3: Collect Issues and PRs from each repo ──────────────────
|
||||
log_title "Collecting Data Across Repos"
|
||||
# ── Step 2-4: Collect data across repos ──────────────────────────────
|
||||
log_title "跨仓库数据采集"
|
||||
|
||||
# Data arrays for dashboard
|
||||
DASHBOARD_ROWS=""
|
||||
TOTAL_ISSUES=0
|
||||
TOTAL_PRS=0
|
||||
REPO_DETAILS=""
|
||||
TOTAL_ACTIVITY=0
|
||||
TOTAL_OPEN_ISSUES=0
|
||||
TOTAL_OPEN_PRS=0
|
||||
TOTAL_MERGED_PRS=0
|
||||
HEALTH_SUM=0
|
||||
|
||||
ISSUE_DATA_PATH='(.data.issues // .data | if type == "array" then . else [] end)'
|
||||
PR_DATA_PATH='(.data.issues // .data.pulls // .data | if type == "array" then . else [] end)'
|
||||
|
||||
for repo in "${REPO_LIST[@]}"; do
|
||||
divider
|
||||
log_step "Processing $ORG/$repo..."
|
||||
log_step "处理 $ORG/$repo..."
|
||||
|
||||
# Fetch open issues
|
||||
ISSUES_JSON=$(gl_run issue +list --owner "$ORG" --repo "$repo" --state open --limit 50)
|
||||
OPEN_ISSUES=$(echo "$ISSUES_JSON" | jq '.data.issues | length' 2>/dev/null || echo "0")
|
||||
OPEN_ISSUES=$(echo "$ISSUES_JSON" | jq "$ISSUE_DATA_PATH | length" 2>/dev/null || echo "0")
|
||||
[[ "$OPEN_ISSUES" =~ ^[0-9]+$ ]] || OPEN_ISSUES=0
|
||||
|
||||
# Fetch closed issues (recent)
|
||||
CLOSED_JSON=$(gl_run issue +list --owner "$ORG" --repo "$repo" --state closed --limit 50)
|
||||
CLOSED_ISSUES=$(echo "$CLOSED_JSON" | jq '.data.issues | length' 2>/dev/null || echo "0")
|
||||
CLOSED_ISSUES=$(echo "$CLOSED_JSON" | jq "$ISSUE_DATA_PATH | length" 2>/dev/null || echo "0")
|
||||
[[ "$CLOSED_ISSUES" =~ ^[0-9]+$ ]] || CLOSED_ISSUES=0
|
||||
|
||||
# Fetch open PRs
|
||||
PRS_JSON=$(gl_run pr +list --owner "$ORG" --repo "$repo" --state open --limit 50)
|
||||
OPEN_PRS=$(echo "$PRS_JSON" | jq '(.data.issues // .data.pulls // .data | if type == "array" then . else [] end) | length' 2>/dev/null || echo "0")
|
||||
OPEN_PRS=$(echo "$PRS_JSON" | jq "$PR_DATA_PATH | length" 2>/dev/null || echo "0")
|
||||
[[ "$OPEN_PRS" =~ ^[0-9]+$ ]] || OPEN_PRS=0
|
||||
|
||||
# Fetch merged PRs (recent)
|
||||
MERGED_JSON=$(gl_run pr +list --owner "$ORG" --repo "$repo" --state merged --limit 50)
|
||||
MERGED_PRS=$(echo "$MERGED_JSON" | jq '(.data.issues // .data.pulls // .data | if type == "array" then . else [] end) | length' 2>/dev/null || echo "0")
|
||||
MERGED_PRS=$(echo "$MERGED_JSON" | jq "$PR_DATA_PATH | length" 2>/dev/null || echo "0")
|
||||
[[ "$MERGED_PRS" =~ ^[0-9]+$ ]] || MERGED_PRS=0
|
||||
|
||||
# Fetch latest release
|
||||
RELEASES_JSON=$(gl_run release +list --owner "$ORG" --repo "$repo" --limit 1)
|
||||
LATEST_RELEASE=$(echo "$RELEASES_JSON" | jq -r '.data.releases[0].tag_name // .data.releases[0].name // "none"' 2>/dev/null)
|
||||
REL_ARR='(.data.releases // .data | if type == "array" then . else [] end)'
|
||||
LATEST_RELEASE=$(echo "$RELEASES_JSON" | jq -r "$REL_ARR[0].tag_name // $REL_ARR[0].name // \"none\"" 2>/dev/null)
|
||||
[[ -z "$LATEST_RELEASE" || "$LATEST_RELEASE" == "null" ]] && LATEST_RELEASE="none"
|
||||
|
||||
log_ok "$repo: Issues(open:$OPEN_ISSUES closed:$CLOSED_ISSUES) PRs(open:$OPEN_PRS merged:$MERGED_PRS) Release:$LATEST_RELEASE"
|
||||
# Release age in days (-1 if none / unparseable)
|
||||
RELEASE_AGE=-1
|
||||
if [[ "$LATEST_RELEASE" != "none" ]]; then
|
||||
REL_DATE=$(echo "$RELEASES_JSON" | jq -r "$REL_ARR[0].created_at // $REL_ARR[0].created_on // $REL_ARR[0].published_at // empty" 2>/dev/null)
|
||||
REL_DATE="${REL_DATE%% *}"; REL_DATE="${REL_DATE%%T*}"
|
||||
if [[ "$REL_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||
RELEASE_AGE=$(days_between "$REL_DATE" "$(date_today)")
|
||||
else
|
||||
RELEASE_AGE=0 # has release but no parseable date → treat as recent
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get PR details for open PRs
|
||||
PR_DETAILS=""
|
||||
PR_DATA_PATH='(.data.issues // .data.pulls // .data | if type == "array" then . else [] end)'
|
||||
if [[ "$OPEN_PRS" -gt 0 ]] && [[ "$OPEN_PRS" != "null" ]]; then
|
||||
for pi in $(seq 0 $((OPEN_PRS > 5 ? 4 : OPEN_PRS - 1))); do
|
||||
PR_ID=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].pull_request_number // $PR_DATA_PATH[$pi].number // $PR_DATA_PATH[$pi].id // empty")
|
||||
PR_TITLE=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].subject // $PR_DATA_PATH[$pi].title // empty")
|
||||
PR_AUTHOR=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].author_login // $PR_DATA_PATH[$pi].author.login // \"unknown\"")
|
||||
PR_DETAILS+="<tr><td>#$PR_ID</td><td>$PR_TITLE</td><td>@$PR_AUTHOR</td><td>open</td></tr>"
|
||||
# Health score (aligned with gitlink-health skill)
|
||||
HEALTH=$(compute_health "$OPEN_ISSUES" "$CLOSED_ISSUES" "$OPEN_PRS" "$MERGED_PRS" "$RELEASE_AGE")
|
||||
IFS='|' read -r GRADE_LABEL GRADE_CLASS <<< "$(health_grade "$HEALTH")"
|
||||
|
||||
log_ok "$repo: Issue(开:$OPEN_ISSUES 闭:$CLOSED_ISSUES) PR(开:$OPEN_PRS 合:$MERGED_PRS) Release:$LATEST_RELEASE 健康度:$HEALTH($GRADE_LABEL)"
|
||||
|
||||
# ── Collect open issue details ──
|
||||
ISSUE_ROWS=""
|
||||
if [[ "$OPEN_ISSUES" -gt 0 ]]; then
|
||||
limit=$(( OPEN_ISSUES < DETAIL_LIMIT ? OPEN_ISSUES : DETAIL_LIMIT ))
|
||||
for ii in $(seq 0 $((limit - 1))); do
|
||||
I_ID=$(echo "$ISSUES_JSON" | jq -r "$ISSUE_DATA_PATH[$ii].id // $ISSUE_DATA_PATH[$ii].number // empty")
|
||||
I_TITLE=$(echo "$ISSUES_JSON" | jq -r "$ISSUE_DATA_PATH[$ii].name // $ISSUE_DATA_PATH[$ii].subject // $ISSUE_DATA_PATH[$ii].title // empty")
|
||||
I_AUTHOR=$(echo "$ISSUES_JSON" | jq -r "$ISSUE_DATA_PATH[$ii].author_name // $ISSUE_DATA_PATH[$ii].author_login // $ISSUE_DATA_PATH[$ii].author.login // \"unknown\"")
|
||||
ISSUE_ROWS+="<tr><td>#$(html_escape "$I_ID")</td><td>$(html_escape "$I_TITLE")</td><td>@$(html_escape "$I_AUTHOR")</td></tr>"
|
||||
done
|
||||
fi
|
||||
|
||||
# Accumulate totals
|
||||
TOTAL_ISSUES=$((TOTAL_ISSUES + OPEN_ISSUES + CLOSED_ISSUES))
|
||||
TOTAL_OPEN_ISSUES=$((TOTAL_OPEN_ISSUES + OPEN_ISSUES))
|
||||
TOTAL_PRS=$((TOTAL_PRS + OPEN_PRS + MERGED_PRS))
|
||||
TOTAL_OPEN_PRS=$((TOTAL_OPEN_PRS + OPEN_PRS))
|
||||
# ── Collect open PR details ──
|
||||
PR_ROWS=""
|
||||
if [[ "$OPEN_PRS" -gt 0 ]]; then
|
||||
limit=$(( OPEN_PRS < DETAIL_LIMIT ? OPEN_PRS : DETAIL_LIMIT ))
|
||||
for pi in $(seq 0 $((limit - 1))); do
|
||||
P_ID=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].pull_request_number // $PR_DATA_PATH[$pi].number // $PR_DATA_PATH[$pi].id // empty")
|
||||
P_TITLE=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].subject // $PR_DATA_PATH[$pi].title // $PR_DATA_PATH[$pi].name // empty")
|
||||
P_AUTHOR=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].author_name // $PR_DATA_PATH[$pi].author_login // $PR_DATA_PATH[$pi].author.login // \"unknown\"")
|
||||
PR_ROWS+="<tr><td>#$(html_escape "$P_ID")</td><td>$(html_escape "$P_TITLE")</td><td>@$(html_escape "$P_AUTHOR")</td></tr>"
|
||||
done
|
||||
fi
|
||||
|
||||
# Add to dashboard rows
|
||||
STATUS_COLOR="green"
|
||||
[[ "$OPEN_ISSUES" -gt 10 ]] && STATUS_COLOR="orange"
|
||||
[[ "$OPEN_ISSUES" -gt 20 ]] && STATUS_COLOR="red"
|
||||
# ── Per-repo collapsible detail section ──
|
||||
[[ -z "$ISSUE_ROWS" ]] && ISSUE_ROWS="<tr><td colspan=\"3\" class=\"empty\">无未关闭 Issue</td></tr>"
|
||||
[[ -z "$PR_ROWS" ]] && PR_ROWS="<tr><td colspan=\"3\" class=\"empty\">无未合并 PR</td></tr>"
|
||||
REPO_DETAILS+="<details><summary><span class=\"badge $GRADE_CLASS\">$HEALTH</span> $(html_escape "$repo") — Issue 开 $OPEN_ISSUES / PR 开 $OPEN_PRS</summary>
|
||||
<div class=\"detail-grid\">
|
||||
<div><h4>未关闭 Issue(最多 $DETAIL_LIMIT 条)</h4>
|
||||
<table class=\"detail-table\"><thead><tr><th>编号</th><th>标题</th><th>作者</th></tr></thead><tbody>$ISSUE_ROWS</tbody></table></div>
|
||||
<div><h4>未合并 PR(最多 $DETAIL_LIMIT 条)</h4>
|
||||
<table class=\"detail-table\"><thead><tr><th>编号</th><th>标题</th><th>作者</th></tr></thead><tbody>$PR_ROWS</tbody></table></div>
|
||||
</div>
|
||||
</details>"
|
||||
|
||||
# Accumulate totals
|
||||
TOTAL_ACTIVITY=$((TOTAL_ACTIVITY + OPEN_ISSUES + CLOSED_ISSUES + OPEN_PRS + MERGED_PRS))
|
||||
TOTAL_OPEN_ISSUES=$((TOTAL_OPEN_ISSUES + OPEN_ISSUES))
|
||||
TOTAL_OPEN_PRS=$((TOTAL_OPEN_PRS + OPEN_PRS))
|
||||
TOTAL_MERGED_PRS=$((TOTAL_MERGED_PRS + MERGED_PRS))
|
||||
HEALTH_SUM=$((HEALTH_SUM + HEALTH))
|
||||
|
||||
REL_DISPLAY="$LATEST_RELEASE"
|
||||
[[ "$REL_DISPLAY" == "none" ]] && REL_DISPLAY="—"
|
||||
|
||||
DASHBOARD_ROWS+="<tr>
|
||||
<td><a href=\"https://gitlink.org.cn/$ORG/$repo\">$repo</a></td>
|
||||
<td><a href=\"https://gitlink.org.cn/$ORG/$repo\" target=\"_blank\">$(html_escape "$repo")</a></td>
|
||||
<td>$OPEN_ISSUES</td>
|
||||
<td>$CLOSED_ISSUES</td>
|
||||
<td>$OPEN_PRS</td>
|
||||
<td>$MERGED_PRS</td>
|
||||
<td>$LATEST_RELEASE</td>
|
||||
<td style=\"color:$STATUS_COLOR;font-weight:bold;\">$(
|
||||
[[ "$OPEN_ISSUES" -le 5 ]] && echo "Healthy" || \
|
||||
[[ "$OPEN_ISSUES" -le 15 ]] && echo "Moderate" || echo "Needs Attention"
|
||||
)</td>
|
||||
<td>$(html_escape "$REL_DISPLAY")</td>
|
||||
<td><span class=\"badge $GRADE_CLASS\">$HEALTH</span> <span class=\"grade $GRADE_CLASS\">$GRADE_LABEL</span></td>
|
||||
</tr>"
|
||||
done
|
||||
|
||||
# ── Step 4: Generate HTML Dashboard ──────────────────────────────────
|
||||
log_title "Generating Dashboard"
|
||||
# Average health
|
||||
AVG_HEALTH=0
|
||||
[[ "${#REPO_LIST[@]}" -gt 0 ]] && AVG_HEALTH=$((HEALTH_SUM / ${#REPO_LIST[@]}))
|
||||
|
||||
log_step "Creating HTML dashboard..."
|
||||
# ── Step 5: Generate HTML Dashboard ──────────────────────────────────
|
||||
log_title "生成 HTML 看板"
|
||||
log_step "写入 $OUTPUT_FILE..."
|
||||
|
||||
cat > "$OUTPUT_FILE" << 'HTMLEOF'
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
cat > "$OUTPUT_FILE" << HTMLEOF
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Multi-Repo Collaboration Dashboard</title>
|
||||
<title>多仓库协同看板 - $ORG</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; padding: 20px; }
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
h1 { color: #333; margin-bottom: 20px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 30px; }
|
||||
.card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.card h3 { color: #666; font-size: 14px; margin-bottom: 8px; }
|
||||
.card .value { font-size: 32px; font-weight: bold; color: #333; }
|
||||
.card.blue .value { color: #2196F3; }
|
||||
.card.green .value { color: #4CAF50; }
|
||||
.card.orange .value { color: #FF9800; }
|
||||
.card.purple .value { color: #9C27B0; }
|
||||
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
th { background: #2196F3; color: white; padding: 12px 16px; text-align: left; }
|
||||
td { padding: 12px 16px; border-bottom: 1px solid #eee; }
|
||||
tr:hover { background: #f9f9f9; }
|
||||
a { color: #2196F3; text-decoration: none; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif; background: #f0f2f5; padding: 24px; color: #1f2329; }
|
||||
.container { max-width: 1280px; margin: 0 auto; }
|
||||
h1 { color: #1f2329; margin-bottom: 6px; font-size: 26px; }
|
||||
.timestamp { color: #8a919f; font-size: 13px; margin-bottom: 24px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 28px; }
|
||||
.card { background: #fff; border-radius: 10px; padding: 18px 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.card h3 { color: #8a919f; font-size: 13px; margin-bottom: 8px; font-weight: 500; }
|
||||
.card .value { font-size: 30px; font-weight: 700; }
|
||||
.card.blue .value { color: #2f80ed; }
|
||||
.card.orange .value { color: #f2994a; }
|
||||
.card.purple .value { color: #9b51e0; }
|
||||
.card.green .value { color: #27ae60; }
|
||||
.card.teal .value { color: #17a2b8; }
|
||||
h2 { font-size: 18px; margin: 24px 0 14px; color: #1f2329; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
th { background: #2f80ed; color: #fff; padding: 12px 16px; text-align: left; font-weight: 500; font-size: 14px; }
|
||||
td { padding: 11px 16px; border-bottom: 1px solid #eef0f3; font-size: 14px; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f7f9fc; }
|
||||
a { color: #2f80ed; text-decoration: none; font-weight: 500; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.timestamp { color: #999; font-size: 14px; margin-bottom: 20px; }
|
||||
.badge { display: inline-block; min-width: 34px; text-align: center; padding: 2px 8px; border-radius: 12px; color: #fff; font-weight: 700; font-size: 13px; }
|
||||
.grade { font-size: 13px; margin-left: 4px; }
|
||||
.excellent, .badge.excellent { background: #27ae60; } .grade.excellent { color: #27ae60; background: none; }
|
||||
.good, .badge.good { background: #2f80ed; } .grade.good { color: #2f80ed; background: none; }
|
||||
.moderate, .badge.moderate { background: #f2c94c; } .grade.moderate { color: #d9a400; background: none; }
|
||||
.attention, .badge.attention { background: #f2994a; } .grade.attention { color: #f2994a; background: none; }
|
||||
.critical, .badge.critical { background: #eb5757; } .grade.critical { color: #eb5757; background: none; }
|
||||
details { background: #fff; border-radius: 10px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
|
||||
summary { padding: 14px 18px; cursor: pointer; font-weight: 600; font-size: 15px; user-select: none; }
|
||||
summary:hover { background: #f7f9fc; }
|
||||
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding: 0 18px 18px; }
|
||||
.detail-table th { background: #eef2f8; color: #4a5568; padding: 8px 12px; font-size: 13px; }
|
||||
.detail-table td { padding: 8px 12px; font-size: 13px; }
|
||||
.detail-table h4, .detail-grid h4 { font-size: 14px; margin-bottom: 8px; color: #4a5568; }
|
||||
.empty { color: #b0b7c3; text-align: center; font-style: italic; }
|
||||
@media (max-width: 900px) { .summary { grid-template-columns: repeat(2, 1fr); } .detail-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Multi-Repo Collaboration Dashboard</h1>
|
||||
<p class="timestamp">Generated: TIMESTAMP_PLACEHOLDER | Organization: ORG_PLACEHOLDER</p>
|
||||
<h1>多仓库协同看板</h1>
|
||||
<p class="timestamp">生成时间:$TIMESTAMP | 组织:$ORG</p>
|
||||
<div class="summary">
|
||||
<div class="card blue"><h3>Total Repos</h3><div class="value">REPOS_COUNT</div></div>
|
||||
<div class="card orange"><h3>Open Issues</h3><div class="value">OPEN_ISSUES_COUNT</div></div>
|
||||
<div class="card purple"><h3>Open PRs</h3><div class="value">OPEN_PRS_COUNT</div></div>
|
||||
<div class="card green"><h3>Total Activity</h3><div class="value">TOTAL_ACTIVITY</div></div>
|
||||
<div class="card blue"><h3>仓库总数</h3><div class="value">${#REPO_LIST[@]}</div></div>
|
||||
<div class="card orange"><h3>未关闭 Issue</h3><div class="value">$TOTAL_OPEN_ISSUES</div></div>
|
||||
<div class="card purple"><h3>未合并 PR</h3><div class="value">$TOTAL_OPEN_PRS</div></div>
|
||||
<div class="card green"><h3>已合并 PR</h3><div class="value">$TOTAL_MERGED_PRS</div></div>
|
||||
<div class="card teal"><h3>平均健康度</h3><div class="value">$AVG_HEALTH</div></div>
|
||||
</div>
|
||||
|
||||
<h2>仓库概览</h2>
|
||||
<table>
|
||||
<thead><tr><th>Repository</th><th>Open Issues</th><th>Closed Issues</th><th>Open PRs</th><th>Merged PRs</th><th>Latest Release</th><th>Health</th></tr></thead>
|
||||
<tbody>DASHBOARD_ROWS_PLACEHOLDER</tbody>
|
||||
<thead><tr><th>仓库</th><th>未关闭 Issue</th><th>已关闭 Issue</th><th>未合并 PR</th><th>已合并 PR</th><th>最新 Release</th><th>健康度</th></tr></thead>
|
||||
<tbody>$DASHBOARD_ROWS</tbody>
|
||||
</table>
|
||||
|
||||
<h2>仓库详情</h2>
|
||||
$REPO_DETAILS
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTMLEOF
|
||||
|
||||
# Replace placeholders using temp file approach for complex content
|
||||
TEMP_HTML=$(mktemp)
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
log_ok "看板已保存: $OUTPUT_FILE"
|
||||
|
||||
while IFS= read -r line; do
|
||||
line="${line//TIMESTAMP_PLACEHOLDER/$TIMESTAMP}"
|
||||
line="${line//ORG_PLACEHOLDER/$ORG}"
|
||||
line="${line//REPOS_COUNT/${#REPO_LIST[@]}}"
|
||||
line="${line//OPEN_ISSUES_COUNT/$TOTAL_OPEN_ISSUES}"
|
||||
line="${line//OPEN_PRS_COUNT/$TOTAL_OPEN_PRS}"
|
||||
line="${line//TOTAL_ACTIVITY/$TOTAL_ISSUES}"
|
||||
line="${line//DASHBOARD_ROWS_PLACEHOLDER/$DASHBOARD_ROWS}"
|
||||
echo "$line"
|
||||
done < "$OUTPUT_FILE" > "$TEMP_HTML"
|
||||
|
||||
mv "$TEMP_HTML" "$OUTPUT_FILE"
|
||||
|
||||
log_ok "Dashboard saved to: $OUTPUT_FILE"
|
||||
|
||||
# ── Step 5: Coordinated Release ──────────────────────────────────────
|
||||
# ── Step 6: Coordinated Release ──────────────────────────────────────
|
||||
if [[ -n "$RELEASE_TAG" ]]; then
|
||||
log_title "Coordinated Release: $RELEASE_TAG"
|
||||
log_title "协调发版: $RELEASE_TAG"
|
||||
|
||||
RELEASE_BODY="# Coordinated Release: $RELEASE_TAG
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] 将为以下 ${#REPO_LIST[@]} 个仓库创建 Release $RELEASE_TAG:"
|
||||
for repo in "${REPO_LIST[@]}"; do
|
||||
log_warn " [DRY RUN] $ORG/$repo → $RELEASE_TAG"
|
||||
done
|
||||
else
|
||||
RELEASE_OK=0
|
||||
RELEASE_FAIL=0
|
||||
for repo in "${REPO_LIST[@]}"; do
|
||||
log_step "为 $ORG/$repo 创建 Release..."
|
||||
RELEASE_RESULT=$(gl_run release +create --owner "$ORG" --repo "$repo" \
|
||||
--tag "$RELEASE_TAG" --name "Release $RELEASE_TAG" \
|
||||
--body "Coordinated release $RELEASE_TAG for $ORG/$repo" 2>&1) || true
|
||||
|
||||
## Repos Included
|
||||
"
|
||||
|
||||
for repo in "${REPO_LIST[@]}"; do
|
||||
log_step "Creating release for $ORG/$repo..."
|
||||
RELEASE_BODY+="- $ORG/$repo"$'\n'
|
||||
|
||||
RELEASE_RESULT=$(gl_run release +create --owner "$ORG" --repo "$repo" \
|
||||
--tag "$RELEASE_TAG" --name "Release $RELEASE_TAG" \
|
||||
--body "Coordinated release $RELEASE_TAG for $ORG/$repo" 2>&1) || true
|
||||
|
||||
if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then
|
||||
log_ok "Release $RELEASE_TAG created for $repo"
|
||||
else
|
||||
log_warn "Release creation failed for $repo (tag may already exist)"
|
||||
fi
|
||||
done
|
||||
|
||||
RELEASE_BODY+=$'\n'"---"$'\n'"*Coordinated release by gitlink-cli multi-repo-collab workflow*"
|
||||
if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then
|
||||
log_ok "Release $RELEASE_TAG 已在 $repo 创建"
|
||||
RELEASE_OK=$((RELEASE_OK + 1))
|
||||
else
|
||||
log_warn "Release 创建失败: $repo (tag 可能已存在)"
|
||||
RELEASE_FAIL=$((RELEASE_FAIL + 1))
|
||||
fi
|
||||
done
|
||||
log_info "协调发版结果: 成功 $RELEASE_OK / 失败 $RELEASE_FAIL"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
log_title "Multi-Repo Dashboard Complete"
|
||||
log_title "多仓库看板完成"
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo -e "${GREEN}Summary:${NC}"
|
||||
echo " Repos processed: ${#REPO_LIST[@]}"
|
||||
echo " Total issues: $TOTAL_ISSUES (open: $TOTAL_OPEN_ISSUES)"
|
||||
echo " Total PRs: $TOTAL_PRS (open: $TOTAL_OPEN_PRS)"
|
||||
echo " Dashboard: $OUTPUT_FILE"
|
||||
[[ -n "$RELEASE_TAG" ]] && echo " Coordinated release: $RELEASE_TAG"
|
||||
echo -e "${GREEN}汇总:${NC}"
|
||||
echo " 处理仓库: ${#REPO_LIST[@]}"
|
||||
echo " 未关闭 Issue: $TOTAL_OPEN_ISSUES"
|
||||
echo " 未合并 PR: $TOTAL_OPEN_PRS"
|
||||
echo " 已合并 PR: $TOTAL_MERGED_PRS"
|
||||
echo " 平均健康度: $AVG_HEALTH / 100"
|
||||
echo " 看板文件: $OUTPUT_FILE"
|
||||
[[ -n "$RELEASE_TAG" ]] && echo " 协调发版: $RELEASE_TAG$([[ "$DRY_RUN" == "true" ]] && echo " (dry-run)")"
|
||||
echo ""
|
||||
echo -e "${CYAN}Open dashboard:${NC}"
|
||||
echo -e "${CYAN}打开看板:${NC}"
|
||||
echo " start $OUTPUT_FILE # Windows"
|
||||
echo " xdg-open $OUTPUT_FILE # Linux"
|
||||
echo " open $OUTPUT_FILE # macOS"
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ param(
|
|||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 05-contributor-growth.ps1 -Owner OWNER -Repo REPO [-Sample N] [-Award] [-DryRun]"
|
||||
|
|
@ -55,11 +55,11 @@ Log-Title "Contributor Growth System: $Owner/$Repo"
|
|||
Log-Step "Collecting data..."
|
||||
|
||||
$issuesOpen = Invoke-GLCheck "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "100"
|
||||
$issuesClosed = Invoke-GL "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100"
|
||||
$issuesClosed = Invoke-GLCheck "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100"
|
||||
$openCount = if ($issuesOpen) { @($issuesOpen.data.issues).Count } else { 0 }
|
||||
$closedCount = if ($issuesClosed) { @($issuesClosed.data.issues).Count } else { 0 }
|
||||
|
||||
$prsMerged = Invoke-GL "pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100"
|
||||
$prsMerged = Invoke-GLCheck "pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100"
|
||||
$prMergedData = @()
|
||||
if ($prsMerged) {
|
||||
$pd = $prsMerged.data
|
||||
|
|
@ -69,7 +69,7 @@ if ($prsMerged) {
|
|||
}
|
||||
$prMergedCount = $prMergedData.Count
|
||||
|
||||
$membersJson = Invoke-GL "repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "100"
|
||||
$membersJson = Invoke-GLCheck "repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "100"
|
||||
$memberData = @()
|
||||
if ($membersJson) {
|
||||
$md = $membersJson.data
|
||||
|
|
@ -119,7 +119,7 @@ for ($i = 0; $i -lt $prMergedCount; $i++) {
|
|||
$contribData[$author].Merged++
|
||||
}
|
||||
if ($i -lt $prSample -and $prId -and $author) {
|
||||
$filesJson = Invoke-GL "pr", "+files", "--owner", $Owner, "--repo", $Repo, "--id", $prId
|
||||
$filesJson = Invoke-GLCheck "pr", "+files", "--owner", $Owner, "--repo", $Repo, "--id", $prId
|
||||
if ($filesJson -and $filesJson.data.files) {
|
||||
foreach ($f in $filesJson.data.files) {
|
||||
$add = if ($f.additions) { $f.additions } elseif ($f.addition) { $f.addition } else { 0 }
|
||||
|
|
@ -148,9 +148,9 @@ if ($issuesOpen) {
|
|||
for ($i = 0; $i -lt $commentSample; $i++) {
|
||||
$id = $openIssuesArr[$i].id
|
||||
if (-not $id) { continue }
|
||||
$detail = Invoke-GL "issue", "+view", "--owner", $Owner, "--repo", $Repo, "--number", $id
|
||||
if ($detail) {
|
||||
$commentCount = if ($detail.data.comment_journals_count) { $detail.data.comment_journals_count } else { 0 }
|
||||
$detailJson = Invoke-GLCheck "issue", "+view", "--owner", $Owner, "--repo", $Repo, "--number", $id
|
||||
if ($detailJson) {
|
||||
$commentCount = if ($detailJson.data.comment_journals_count) { $detailJson.data.comment_journals_count } else { 0 }
|
||||
if ($commentCount -gt 0) {
|
||||
$author = $openIssuesArr[$i].author.login
|
||||
if ($author) {
|
||||
|
|
@ -371,11 +371,27 @@ $wikiContent += $wikiRankRows + "`n"
|
|||
$wikiContent += "---" + "`n"
|
||||
$wikiContent += "*Auto-generated by gitlink-cli*"
|
||||
|
||||
$wikiResult = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $wikiTitle, "--content", $wikiContent
|
||||
if ($wikiResult -and (Get-JsonOk $wikiResult)) {
|
||||
Log-Ok "Published to Wiki: $wikiTitle"
|
||||
$wikiResult = Invoke-GL "wiki", "+update", "--owner", $Owner, "--repo", $Repo, "--title", $wikiTitle, "--cover", $wikiContent
|
||||
if ($wikiResult -and (Get-JsonOk ($wikiResult | ConvertFrom-Json))) {
|
||||
Log-Ok "Published to Wiki (updated): $wikiTitle"
|
||||
} else {
|
||||
Log-Warn "Wiki publish failed"
|
||||
Log-Info "Update failed (page may not exist), trying create..."
|
||||
$wikiResult = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $wikiTitle, "--content", $wikiContent
|
||||
if ($wikiResult) {
|
||||
try {
|
||||
$wikiObj = $wikiResult | ConvertFrom-Json
|
||||
if ($wikiObj.ok) {
|
||||
Log-Ok "Published to Wiki (created): $wikiTitle"
|
||||
} else {
|
||||
$wikiErr = if ($wikiObj.error.message) { $wikiObj.error.message } elseif ($wikiObj.message) { $wikiObj.message } else { "unknown error" }
|
||||
Log-Warn "Wiki publish failed: $wikiErr"
|
||||
}
|
||||
} catch {
|
||||
Log-Warn "Wiki publish failed: non-JSON response from API"
|
||||
}
|
||||
} else {
|
||||
Log-Warn "Wiki publish failed: no response from API"
|
||||
}
|
||||
}
|
||||
|
||||
# -- Step 7: Award Badges (optional) --
|
||||
|
|
@ -409,8 +425,8 @@ if ($Award) {
|
|||
$issueResult = Invoke-GL "issue", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $issueTitle, "--body", $issueBody
|
||||
if ($issueResult) {
|
||||
try {
|
||||
$issueJson = $issueResult
|
||||
$issueNum = if ($issueJson.data.id) { $issueJson.data.id } elseif ($issueJson.data.number) { $issueJson.data.number } else { $null }
|
||||
$issueJson = $issueResult | ConvertFrom-Json
|
||||
$issueNum = if ($issueJson.ok -and $issueJson.data.id) { $issueJson.data.id } elseif ($issueJson.ok -and $issueJson.data.number) { $issueJson.data.number } else { $null }
|
||||
if ($issueNum) {
|
||||
Invoke-GL "issue", "+label-add", "--owner", $Owner, "--repo", $Repo, "--number", $issueNum, "--labels", "badge" | Out-Null
|
||||
Log-Ok "Badge issue created: #$issueNum - $issueTitle ($($users.Count) recipients)"
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ gitlink-cli issue +list --owner zzx-coder --repo gitlink-cli --state open --limi
|
|||
|---|------|------|---------|-------------|
|
||||
| 1 | 社区运营自动化 | `01-community-ops.sh` | 7 个 | Issue 积压无人处理、周报手写、Release Notes 手动整理 |
|
||||
| 2 | 代码质量看门人 | `02-code-quality-gatekeeper.sh` | 7 个 | PR 审查效率低、质量标准不统一、AI 代码审查(基于 gitlink-code-review skill) |
|
||||
| 3 | 项目一键初始化 | `03-project-init.sh` | 6 个 | 新建项目重复劳动多、Issue/文档/分支保护手动配 |
|
||||
| 3 | 项目一键初始化 | `03-project-init.sh` | 8 个 | 新建项目重复劳动多、README/许可证/CI/Issue/里程碑手动配 |
|
||||
| 4 | 多仓库协同 | `04-multi-repo-collab.sh` | 7 个 | 跨仓库状态分散、缺乏统一视图 |
|
||||
| 5 | 贡献者成长体系 | `05-contributor-growth.sh` | 6 个 | 贡献者活跃度难追踪、缺乏激励机制 |
|
||||
|
||||
|
|
@ -236,21 +236,29 @@ bash workflows/02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cl
|
|||
|
||||
## 场景三:项目一键初始化
|
||||
|
||||
**脚本**: `03-project-init.sh`
|
||||
**脚本**: `03-project-init.sh` / `03-project-init.ps1`
|
||||
|
||||
### 解决什么问题
|
||||
|
||||
新建项目仓库后,还要手动创建 README、写 CONTRIBUTING 指南、创建初始 Issue、设置分支保护、打初始 Release。一条命令搞定全部。
|
||||
新建项目仓库后,还要手动创建 README、写 LICENSE、配置 CI/CD、创建 Issue 和里程碑、设置分支保护、打初始 Release。一条命令搞定全部。
|
||||
|
||||
### 工作流程
|
||||
|
||||
```
|
||||
repo +create → 创建仓库
|
||||
↓
|
||||
wiki +create → 生成 README(根据语言模板)
|
||||
wiki +create → 生成 CONTRIBUTING 贡献指南
|
||||
git clone → 克隆空仓库到本地
|
||||
↓
|
||||
issue +create × 5 → 创建初始待办 Issue:
|
||||
生成文件 → README.md (语言模板) + LICENSE (MIT) + CONTRIBUTING.md + .gitlink-ci.yml
|
||||
↓
|
||||
git add/commit/push → 将脚手架文件推送到仓库
|
||||
↓
|
||||
milestone +create × 3 → 创建项目里程碑:
|
||||
- v0.1.0 - MVP
|
||||
- v0.2.0 - Feature Complete
|
||||
- v1.0.0 - Production Ready
|
||||
↓
|
||||
issue +create × 5 → 创建初始待办 Issue (打标签):
|
||||
- 搭建 CI/CD 流水线
|
||||
- 编写项目文档
|
||||
- 建立代码审查流程
|
||||
|
|
@ -267,15 +275,19 @@ release +create → 创建 v0.1.0 初始版本
|
|||
| 步骤 | 命令 | 作用 |
|
||||
|------|------|------|
|
||||
| 1 | `repo +create` | 创建新仓库 |
|
||||
| 2 | `wiki +create` | 生成 README(支持 Go/Python/Node/Java) |
|
||||
| 3 | `wiki +create` | 生成 CONTRIBUTING 贡献指南 |
|
||||
| 4 | `issue +create` | 创建 5 个初始 Issue 并打标签 |
|
||||
| 5 | `branch +protect` | 设置 master 分支保护规则 |
|
||||
| 6 | `release +create` | 创建 v0.1.0 初始版本 |
|
||||
| 2 | `git clone` | 克隆空仓库到本地临时目录 |
|
||||
| 3 | 文件生成 | 生成 README.md / LICENSE / CONTRIBUTING.md / .gitlink-ci.yml |
|
||||
| 4 | `git add/commit/push` | 推送脚手架文件到 master 分支 |
|
||||
| 5 | `milestone +create` | 创建 3 个项目里程碑 (MVP → Production) |
|
||||
| 6 | `issue +create` | 创建 5 个初始 Issue 并打标签 |
|
||||
| 7 | `branch +protect` | 设置 master 分支保护规则 |
|
||||
| 8 | `release +create` | 创建 v0.1.0 初始版本 |
|
||||
|
||||
### 输出有什么用
|
||||
|
||||
- **开箱即用**: 新成员克隆后就知道怎么构建、测试、贡献
|
||||
- **开箱即用**: 克隆后仓库已有 README、LICENSE、CI 配置,可直接开始开发
|
||||
- **仓库内文件**: README/LICENSE/CONTRIBUTING/CI 是仓库里的真实文件,不是 Wiki 页面
|
||||
- **里程碑规划**: 从 MVP 到正式版的路线图已建立
|
||||
- **标准化 Issue**: 关键待办已创建好,团队可直接认领
|
||||
- **分支保护**: 防止直接 push 到 master,强制走 PR 流程
|
||||
- **首个 Release**: 项目从创建之初就有版本管理
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ metadata:
|
|||
→ PR Review、AI 四维度评分、自动合并
|
||||
|
||||
3. 项目一键初始化
|
||||
→ 创建仓库、README、CI 配置、初始 Issues、分支保护
|
||||
→ 创建仓库、README/LICENSE/CI 文件、里程碑、初始 Issues、分支保护
|
||||
|
||||
4. 多仓库协同
|
||||
→ 跨仓库 Issue/PR 追踪、状态 Dashboard、协同发版
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue