diff --git a/.devops/gitlink-cli-autodeploy.yml b/.devops/gitlink-cli-autodeploy.yml new file mode 100644 index 0000000..c1a3d81 --- /dev/null +++ b/.devops/gitlink-cli-autodeploy.yml @@ -0,0 +1,78 @@ +version: 2 +name: gitlink-cli-autodeploy +description: "每次push到master时自动在服务器上构建并部署gitlink-cli" +global: + concurrent: 1 + +trigger: + webhook: gitlink@1.0.0 + event: + - ref: push + ruleset-operator: AND + condition: + param: ref + operator: include_regex + value: "^refs/heads/master$" + +workflow: + - ref: start + name: 开始 + task: start + + # 1. 准备服务器环境 + - ref: prepare_server_0 + name: 准备服务器环境 + task: ssh_cmd@1.1.1 + input: + ssh_ip: '"121.41.222.0"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key)) + ssh_cmd: '"/opt/gitlink-deploy/prepare.sh"' + needs: + - start + + # 2. 在服务器上构建 + - ref: build_on_server_0 + name: 在服务器上构建 + task: ssh_cmd@1.1.1 + input: + ssh_ip: '"121.41.222.0"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key)) + ssh_cmd: '"/opt/gitlink-deploy/build.sh"' + needs: + - prepare_server_0 + + # 3. 部署到服务器 + - ref: deploy_on_server_0 + name: 部署到服务器 + task: ssh_cmd@1.1.1 + input: + ssh_ip: '"121.41.222.0"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key)) + ssh_cmd: '"/opt/gitlink-deploy/deploy.sh"' + needs: + - build_on_server_0 + + # 4. 验证部署 + - ref: verify_deployment_0 + name: 验证部署 + task: ssh_cmd@1.1.1 + input: + ssh_ip: '"121.41.222.0"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key)) + ssh_cmd: '"gitlink-cli version && ls -lh /opt/gitlink-cli/gitlink-cli"' + needs: + - deploy_on_server_0 + + - ref: end + name: 结束 + task: end + needs: + - verify_deployment_0 diff --git a/README.md b/README.md index cb2663d..e137990 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,12 @@ 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, batch close, comment on issues | +| 🐛 Issue | Create, update, close, comment on issues, 6 batch operations (close/status/priority/assignee/label/create) | +| 📖 Wiki | View, create, update, delete Wiki pages | | 🔀 PR | Create, merge, review pull requests, view changed files | | 🌿 Branch | Create, delete, list, protect, unprotect branches | | 🏷️ Release | Create, view, delete releases | +| 🔗 Webhook | Create, view, update, delete, test webhooks, configure automation triggers | | 🏢 Org | Manage organizations, members, teams | | 🔧 CI | View builds, logs, CI/CD operations | | 🔍 Search | Search repositories, users | @@ -209,6 +211,34 @@ gitlink-cli branch +protect --name main gitlink-cli branch +unprotect --name main ``` +### Webhook Management + +```bash +# List all webhooks +gitlink-cli webhook +list --owner Gitlink --repo forgeplus + +# Create a webhook +gitlink-cli webhook +create --owner Gitlink --repo forgeplus --url https://ci.example.com/webhook --events push,pull_request + +# Create webhook with secret +gitlink-cli webhook +create --url https://jenkins.example.com/webhook --secret my-secret-key --events push --description "CI/CD trigger" + +# View webhook details +gitlink-cli webhook +info --id 456 + +# Update webhook +gitlink-cli webhook +update --id 456 --url https://new-url.example.com/webhook --events push,pull_request,issue + +# Test webhook +gitlink-cli webhook +test --id 456 + +# Delete webhook +gitlink-cli webhook +delete --id 456 + +# List supported event types +gitlink-cli webhook +events +``` + ### Release Management ```bash @@ -235,6 +265,28 @@ gitlink-cli ci +log --owner Gitlink --repo forgeplus -i gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i ``` +### Wiki Management + +```bash +# List wiki pages +gitlink-cli wiki +list --owner Gitlink --repo forgeplus + +# View a wiki page +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "Getting Started" + +# Create a wiki page +gitlink-cli wiki +create --title "New Page" --content "Page content" + +# Update a wiki page (overwrite) +gitlink-cli wiki +update --title "New Page" --cover "Updated content" --owner Gitlink --repo forgeplus + +# Append content to a wiki page +gitlink-cli wiki +update --title "New Page" --add "Appended content" --owner Gitlink --repo forgeplus + +# Delete a wiki page +gitlink-cli wiki +delete --title "New Page" --owner Gitlink --repo forgeplus +``` + ### Search ```bash @@ -339,6 +391,7 @@ gitlink-cli/ │ ├── ci/ # CI shortcuts │ ├── search/ # Search shortcuts │ ├── user/ # User shortcuts +│ ├── wiki/ # Wiki shortcuts │ └── register.go # Registration entry point ├── skills/ # AI Agent Skills │ ├── README.md # Skills guide diff --git a/README.zh-CN.md b/README.zh-CN.md index 4531992..0eb0fcd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,10 +27,12 @@ | 分类 | 能力 | |------|------| | 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 | -| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | +| 🐛 Issue | 创建、更新、关闭、评论 Issue,6 个批量操作(关闭/状态/优先级/负责人/标记/创建) | +| 📖 Wiki | 查看、创建、更新、删除 Wiki 页面 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 🌿 分支 | 创建、删除、保护分支 | | 🏷️ 发布 | 创建、查看、删除 Release | +| 🔗 Webhook | 创建、查看、更新、删除、测试 Webhook,配置自动化触发器 | | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | | 🔍 搜索 | 搜索仓库、用户 | @@ -153,6 +155,12 @@ gitlink-cli repo +create -n my-project -d "项目描述" # Fork 仓库 gitlink-cli repo +fork --owner Gitlink --repo forgeplus + +# 批量创建仓库(默认公开,--private设置为私有) +gitlink-cli repo +batch-create -n "repo1,repo2" -d "项目描述" + +# 批量更新仓库信息(--private设置为私有,--public设置为公开,注意要指定仓库所有者) +gitlink-cli repo +batch-update -n "repo1,repo2" -d "更新描述" ``` ### Issue 管理 @@ -178,6 +186,25 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs # 添加评论 gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" + +# 批量修改状态 +gitlink-cli issue +batch-status --state resolved --numbers 1,2,3 --owner Gitlink --repo forgeplus + +# 批量修改优先级 +gitlink-cli issue +batch-priority --priority urgent --numbers 1,2,3 --owner Gitlink --repo forgeplus + +# 批量修改标签 +gitlink-cli issue +batch-label --numbers 1,2,3 --label 功能 --owner Gitlink --repo forgeplus + +# 批量修改负责人 +gitlink-cli issue +batch-assignee --numbers 1,2,3 --assignee zhangsan --owner Gitlink --repo forgeplus + +# 批量创建 +gitlink-cli issue --owner Gitlink --repo forgeplus +batch-create --titles "issue1,issue2,issue3" + +# 批量关闭 +gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 1,2,3 + ``` ### Pull Request @@ -202,6 +229,34 @@ gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42 gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42 ``` +### Webhook 管理 + +```bash +# 列出所有 Webhook +gitlink-cli webhook +list --owner Gitlink --repo forgeplus + +# 创建 Webhook +gitlink-cli webhook +create --owner Gitlink --repo forgeplus --url https://ci.example.com/webhook --events push,pull_request + +# 创建带密钥的 Webhook +gitlink-cli webhook +create --url https://jenkins.example.com/webhook --secret my-secret-key --events push --description "CI/CD trigger" + +# 查看 Webhook 详情 +gitlink-cli webhook +info --id 456 + +# 更新 Webhook +gitlink-cli webhook +update --id 456 --url https://new-url.example.com/webhook --events push,pull_request,issue + +# 测试 Webhook +gitlink-cli webhook +test --id 456 + +# 删除 Webhook +gitlink-cli webhook +delete --id 456 + +# 查看支持的事件类型 +gitlink-cli webhook +events +``` + ### 发布管理 ```bash @@ -215,6 +270,28 @@ gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0. gitlink-cli release +view --owner Gitlink --repo forgeplus -i ``` +### Wiki 管理 + +```bash +# 列出 Wiki 页面 +gitlink-cli wiki +list --owner Gitlink --repo forgeplus + +# 查看 Wiki 页面 +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "快速开始" + +# 创建 Wiki 页面 +gitlink-cli wiki +create --title "新页面" --content "页面正文" + +# 更新 Wiki 页面(覆盖内容) +gitlink-cli wiki +update --title "新页面" --cover "更新后的内容" --owner Gitlink --repo forgeplus + +# 追加内容到 Wiki 页面 +gitlink-cli wiki +update --title "新页面" --add "追加的内容" --owner Gitlink --repo forgeplus + +# 删除 Wiki 页面 +gitlink-cli wiki +delete --title "新页面" --owner Gitlink --repo forgeplus +``` + ### 搜索 ```bash @@ -318,6 +395,7 @@ gitlink-cli/ │ ├── ci/ # CI shortcuts │ ├── search/ # 搜索 shortcuts │ ├── user/ # 用户 shortcuts +│ ├── wiki/ # Wiki shortcuts │ └── register.go # 注册入口 ├── skills/ # AI Agent Skills │ ├── README.md # Skills 使用指南 diff --git a/cmd/api/api.go b/cmd/api/api.go index af0593d..20c09ee 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -67,10 +67,10 @@ func runAPI(c *cobra.Command, args []string) error { env, err := cli.Do(method, path, body, query) if err != nil { if apiErr, ok := err.(*client.APIError); ok { - errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "") + errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion) return output.Print(errEnv, resolveFormat()) } - return err + return fmt.Errorf("API 请求失败 [%s %s]: %w", method, path, err) } return output.Print(env, resolveFormat()) diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index ed9dc22..13437de 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -44,19 +44,25 @@ func newLoginCmd() *cobra.Command { } func loginWithPassword() error { - reader := bufio.NewReader(os.Stdin) + // Check for credentials from environment variables for non-interactive login + username := os.Getenv("GITLINK_USERNAME") + password := os.Getenv("GITLINK_PASSWORD") - fmt.Print("Username/Email/Phone: ") - username, _ := reader.ReadString('\n') - username = strings.TrimSpace(username) + if username == "" || password == "" { + reader := bufio.NewReader(os.Stdin) - fmt.Print("Password: ") - passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return fmt.Errorf("failed to read password: %w", err) + fmt.Print("Username/Email/Phone: ") + usernameInput, _ := reader.ReadString('\n') + username = strings.TrimSpace(usernameInput) + + fmt.Print("Password: ") + passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read password: %w", err) + } + fmt.Println() + password = string(passwordBytes) } - fmt.Println() - password := string(passwordBytes) result, err := internalAuth.Login(username, password) if err != nil { diff --git a/doc/design.md b/doc/design.md index d25295b..0776803 100644 --- a/doc/design.md +++ b/doc/design.md @@ -40,8 +40,9 @@ gitlink-cli/ │ ├── common/ │ │ ├── types.go # Shortcut / Flag / RuntimeContext 定义 │ │ └── runner.go # CallAPI / PaginateAll / ResolveOwnerRepo -│ ├── repo/ # repo +create / +clone / +fork / +list / +info -│ ├── issue/ # issue +list / +create / +view / +close / +comment +│ ├── repo/ # repo +create / +clone / +fork / +list / +info / +batch-create / +batch-update +│ ├── issue/ # issue +list / +create / +view / +close / +comment / +batch-* (6 个批量命令) +│ ├── wiki/ # wiki +list / +view / +create / +update / +delete │ ├── pr/ # pr +list / +create / +view / +merge / +review │ ├── release/ # release +list / +create / +download │ ├── branch/ # branch +list / +protect / +unprotect @@ -78,8 +79,9 @@ gitlink-cli/ | 领域 | Shortcuts | 数量 | |------|-----------|------| -| repo | `+create` `+clone` `+fork` `+list` `+info` `+delete` `+settings` | 7 | -| issue | `+list` `+create` `+view` `+update` `+close` `+comment` `+assign` `+label` | 8 | +| repo | `+create` `+clone` `+fork` `+list` `+info` `+delete` `+settings` `+batch-create` `+batch-update` | 9 | +| issue | `+list` `+create` `+view` `+update` `+close` `+comment` `+assign` `+label` `+batch-close` `+batch-status` `+batch-priority` `+batch-assign` `+batch-label` `+batch-create` | 14 | +| wiki | `+list` `+view` `+create` `+update` `+delete` | 5 | | pr | `+list` `+create` `+view` `+merge` `+close` `+review` `+files` `+diff` | 8 | | release | `+list` `+create` `+view` `+delete` `+download` | 5 | | branch | `+list` `+create` `+delete` `+protect` `+unprotect` | 5 | @@ -437,7 +439,169 @@ skills/ --- -## 9 完整命令参考 +## 9 批量操作设计模式 + +Issue 和 Repo 两个领域均实现了批量操作命令,遵循统一的设计模式。 + +### 9.1 命令清单 + +| 领域 | 命令 | 用途 | 输入方式 | +|------|------|------|----------| +| issue | `+batch-close` | 批量关闭 | `--numbers` 或 `--from` CSV | +| issue | `+batch-status` | 批量更换状态 | `--state` + `--numbers`/`--from` | +| issue | `+batch-priority` | 批量更换优先级 | `--priority` + `--numbers`/`--from` | +| issue | `+batch-assign` | 批量更换负责人 | `--assignee` + `--numbers`/`--from` | +| issue | `+batch-label` | 批量更换标记 | `--label` + `--numbers`/`--from` | +| issue | `+batch-create` | 批量创建 Issue | `--titles` 或 `--from` CSV(支持 Bug/Feature 模板) | +| repo | `+batch-create` | 批量创建仓库 | `--names` 或 `--from` CSV | +| repo | `+batch-update` | 批量更新仓库 | `--names` 或 `--from` CSV | + +### 9.2 核心类型 + +```go +type BatchResult struct { + Number string `json:"number"` // Issue 编号或仓库名 + Action string `json:"action"` // 操作类型 + Status string `json:"status"` // 执行结果 + Error string `json:"error,omitempty"` +} + +type BatchSummary struct { + Repository string `json:"repository"` + Action string `json:"action"` + Value string `json:"value,omitempty"` + DryRun bool `json:"dry_run"` + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Results []BatchResult `json:"results"` +} +``` + +### 9.3 统一设计原则 + +| 原则 | 说明 | +|------|------| +| **输入灵活** | `--numbers`/`--names`(内联逗号分隔)和 `--from`(CSV 文件)可同时使用,自动去重合并 | +| **dry-run 统一** | 所有批量命令支持 `--dry-run`,预览模式下 status 为 `"planned"`,不发起写请求 | +| **错误不中断** | 单条失败不影响后续处理,全部执行完后返回完整汇总。有任何失败则 exit code = 1 | +| **输出统一** | 所有命令输出相同结构的 `BatchSummary` JSON | +| **修改前先 GET** | Issue 批量修改先 GET 当前 Issue 保留 subject/description,PATCH 时只替换目标字段。Repo 批量更新先 GET 获取 name + identifier | +| **参数容错** | 所有名称映射(状态、优先级、标签、负责人)同时支持字符串名和直接传数字 ID | + +### 9.4 Issue 参数值映射 + +**状态**:`new`(1) / `in-progress`(2) / `resolved`(3) / `closed`(5) / `rejected`(6) + +**优先级**:`low`(1) / `normal`(2) / `high`(3) / `urgent`(4) + +**标记**:使用项目级中文标签名映射到大整数 ID(如 `缺陷`→315526、`功能`→315527) + +**负责人**:传入 login 用户名,CLI 调用 `/users/{login}` API 转换为 `user_id` + +### 9.5 Issue 批量创建模板系统 + +`+batch-create` 支持三种输入模式: + +1. **CLI 直接输入**(`--titles`):逗号分隔标题,统一应用 `--priority`/`--label`/`--assignee`/`--state` +2. **自由 CSV**(`--from`):自由指定 title/body/priority/label/assignee/status 列 +3. **模板 CSV**(`--from` + `--template`): + - `--template bug`:自动生成 Bug 描述格式,自动设置缺陷标签 + - `--template feature`:自动生成功能描述格式,自动设置功能标签 + +### 9.6 Repo 批量操作注意事项 + +- **batch-create**:POST 路径 `/{login}/{name}` 中的 login 必须是当前登录用户,需先 `GET /users/me` +- **batch-update**:PATCH 请求体必须包含从 GET 获取的 `name` 和 `identifier`,否则 API 报错 +- **--private/--public 互斥**:batch-update 不允许同时设置两个标志 + +### 9.7 关键 API 字段差异 + +GitLink 基于 Redmine 但修改了大量字段名: + +| Redmine 标准字段 | GitLink 实际字段 | 格式 | +|-----------------|-----------------|------| +| `assigned_to_id` | `assigner_ids` | 数组 `[user_id]` | +| `tracker_id` | `issue_tag_ids` | 数组 `[tag_id]`(项目级大整数) | + +> GitLink API 对不认识的字段返回 200 而非报错,字段名错误会导致静默失败。必须通过浏览器 DevTools 抓取实际请求确认字段名和格式。 + +--- + +## 10 Wiki Shortcut 设计 + +Wiki 是独立的全新 Shortcut 领域,提供 5 个命令覆盖 Wiki 页面的 CRUD 操作。 + +### 10.1 双域名架构 + +Wiki API 部署在 gateway 域名上,与主站 API(www 域名)分离: + +``` + ┌─ www 域名 ─────────────────────┐ +resolveProjectID() → │ GET /{owner}/{repo}/detail.json │ → project_id + └────────────────────────────────┘ + + ┌─ gateway 域名 ───────────────────────────┐ +callWikiAPI*() → │ /wiki/open/* (不带 .json) │ → 解包 code/data + └──────────────────────────────────────────┘ +``` + +- **www 域名**(`https://www.gitlink.org.cn/api`):用于 detail API 获取 project_id,走默认 client +- **gateway 域名**(`https://gateway.gitlink.org.cn/api`):用于所有 wiki CRUD API,走独立 client + +### 10.2 Client 扩展 + +`internal/client/client.go` 新增 `SkipJSONSuffix` 字段: + +```go +type Client struct { + // ... 原有字段 ... + SkipJSONSuffix bool // 为 true 时不自动追加 .json 后缀(wiki gateway API 需要) +} +``` + +wiki 命令创建独立 client 实例,设置 `SkipJSONSuffix: true` 并使用 gateway BaseURL。 + +### 10.3 响应解包 + +Gateway API 使用不同的响应格式 `{code, data, msg}`(而非常规的 `{status, ...}`): + +```go +// unwrapGatewayResponse 解包 gateway 响应 +// 成功: code=200/201, 提取 data 字段 +// 失败: code=500/400/404, 返回 "[code] msg" 错误信息 +func unwrapGatewayResponse(raw []byte) ([]byte, error) +``` + +### 10.4 命令详情 + +| 命令 | HTTP 方法 | API 路径 | 关键参数 | +|------|----------|---------|---------| +| `wiki +list` | GET | `/wiki/open/wikiPages` | 无额外参数 | +| `wiki +view` | GET | `/wiki/open/getWiki` | `--title`(必填) | +| `wiki +create` | POST | `/wiki/open/createWiki` | `--title`(必填)`--content/--file` `--message` | +| `wiki +update` | PUT | `/wiki/open/updateWiki` | `--title`(必填)`--cover/--add` `--file` | +| `wiki +delete` | DELETE | `/wiki/open/deleteWiki` | `--title`(必填) | + +### 10.5 特殊处理 + +| 处理项 | 说明 | +|--------|------| +| **base64 编解码** | Wiki 内容在 API 中为 base64 编码,CLI 自动编解码,对用户透明 | +| **project_id 缓存** | `resolveProjectID()` 使用 `sync.Map` 缓存,同一 owner/repo 只调一次 API | +| **owner/repo 自动解析** | 在 git 仓库目录下可省略 `--owner`/`--repo` | +| **--update --add 模式** | 先 GET 现有内容 → 解码 → 追加 → 重新编码 → PUT 提交 | +| **嵌套对象过滤** | 表格输出时过滤无意义的嵌套对象字段(如 wiki_clone_link) | + +### 10.6 已知限制 + +- **delete 后端 bug**:GitLink 平台 `deleteWiki` API 只清空内容,不删除侧边栏条目 +- **gateway 域名硬编码**:wiki API 仅部署在 gateway,detail API 在 www,不可互换 +- **create/update pageName 差异**:create 接受原始中文 pageName,update 需要 URL 编码 + +--- + +## 11 完整命令参考 ``` gitlink-cli @@ -457,7 +621,9 @@ gitlink-cli │ ├── +list # 仓库列表 │ ├── +info # 仓库详情 │ ├── +delete # 删除仓库 -│ └── +settings # 仓库设置 +│ ├── +settings # 仓库设置 +│ ├── +batch-create # 批量创建仓库 +│ └── +batch-update # 批量更新仓库 ├── issue │ ├── +list # Issue 列表 │ ├── +create # 创建 Issue @@ -466,7 +632,13 @@ gitlink-cli │ ├── +close # 关闭 Issue │ ├── +comment # 添加评论 │ ├── +assign # 指派 -│ └── +label # 标签管理 +│ ├── +label # 标签管理 +│ ├── +batch-close # 批量关闭 Issue +│ ├── +batch-status # 批量更换状态 +│ ├── +batch-priority # 批量更换优先级 +│ ├── +batch-assign # 批量更换负责人 +│ ├── +batch-label # 批量更换标记 +│ └── +batch-create # 批量创建 Issue(含 Bug/Feature 模板) ├── pr │ ├── +list # PR 列表 │ ├── +create # 创建 PR @@ -501,6 +673,12 @@ gitlink-cli ├── user │ ├── +me # 当前用户 │ └── +info # 用户详情 +├── wiki +│ ├── +list # Wiki 页面列表 +│ ├── +view # 查看 Wiki 页面 +│ ├── +create # 创建 Wiki 页面 +│ ├── +update # 更新 Wiki 页面 +│ └── +delete # 删除 Wiki 页面 ├── search │ ├── +repos # 搜索仓库 │ ├── +issues # 搜索 Issue @@ -518,7 +696,7 @@ gitlink-cli --- -## 10 关键文件清单 +## 12 关键文件清单 实现时需要修改/创建的核心文件: @@ -541,8 +719,9 @@ gitlink-cli | `internal/registry/meta_data.json` | API 元数据 | | `shortcuts/common/types.go` | Shortcut 核心类型 | | `shortcuts/common/runner.go` | RuntimeContext | -| `shortcuts/repo/*.go` | 仓库 shortcuts | -| `shortcuts/issue/*.go` | Issue shortcuts | +| `shortcuts/repo/*.go` | 仓库 shortcuts(含 batch_create/batch_update) | +| `shortcuts/issue/*.go` | Issue shortcuts(含 batch.go + batch_create.go 批量操作) | +| `shortcuts/wiki/*.go` | Wiki shortcuts | | `shortcuts/pr/*.go` | PR shortcuts | | `shortcuts/register.go` | Shortcut 注册 | | `skills/gitlink-shared/SKILL.md` | 共享 Skill | @@ -550,7 +729,7 @@ gitlink-cli --- -## 11 开发计划 +## 13 开发计划 ### Phase 1: Foundation(第 1-2 周) @@ -609,7 +788,7 @@ gitlink-cli --- -## 12 验证方案 +## 14 验证方案 | 阶段 | 验证方式 | |------|----------| diff --git a/doc/gitlink_api_reference.md b/doc/gitlink_api_reference.md index fc54084..968ace4 100644 --- a/doc/gitlink_api_reference.md +++ b/doc/gitlink_api_reference.md @@ -15206,6 +15206,46 @@ GET /api/wikiExport/wikiExport-wrapper |» data|object|false|none||none| |» message|string|false|none||none| +--- + +## Gateway Wiki API(CLI 实际调用) + +gitlink-cli 的 wiki shortcut 实际调用的是 **gateway 域名**下的 `/wiki/open/*` 端点,而非上述 www 域名的 `/api/wiki/*` 端点。两者存在以下差异: + +| 差异项 | www 域名(APIfox 文档) | gateway 域名(CLI 实际使用) | +|--------|------------------------|---------------------------| +| Base URL | `https://www.gitlink.org.cn/api` | `https://gateway.gitlink.org.cn/api` | +| URL 前缀 | `/api/wiki/` | `/wiki/open/` | +| JSON 后缀 | 需要 `.json` | 不需要 `.json` | +| 响应格式 | 直接返回 data | `{"code": 200, "data": {...}, "msg": ""}` 包一层 | +| 错误判断 | `status` 字段 ≠ 200 | `code` 字段 ≠ 200/201 | + +### 实际调用路径对比 + +| 操作 | www 文档路径 | gateway 实际路径 | +|------|-------------|-----------------| +| 创建 | `POST /api/wiki/createWiki.json` | `POST /wiki/open/createWiki` | +| 删除 | `DELETE /api/wiki/deleteWiki.json` | `DELETE /wiki/open/deleteWiki` | +| 查看 | `GET /api/wiki/getWiki.json` | `GET /wiki/open/getWiki` | +| 更新 | `PUT /api/wiki/updateWiki.json` | `PUT /wiki/open/updateWiki` | +| 列表 | `GET /api/wiki/wikiPages.json` | `GET /wiki/open/wikiPages` | + +### 响应格式差异示例 + +**www 域名响应**(标准格式): +```json +{"data": {"title": "test", "content": "..."}} +``` + +**gateway 域名响应**(包一层): +```json +{"code": 200, "data": {"title": "test", "content": "..."}, "msg": "success"} +``` + +CLI 通过 `unwrapGatewayResponse()` 函数自动解包 gateway 格式,对用户透明。 + +> **注意**:project_id 仍需通过 www 域名的 `/api/{owner}/{repo}/detail.json` 获取,两个域名不可互换。 + # 流水线 ## GET 流水线列表 diff --git a/internal/auth/login.go b/internal/auth/login.go index 4b035e7..032f107 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -71,7 +71,7 @@ func Login(username, password string) (*LoginResult, error) { // Collect auth cookies from response (GitLink uses autologin_trustie for session persistence) var authCookies []string for _, cookie := range resp.Cookies() { - if cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" { + if cookie.Name == "Authorization" || cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" || cookie.Name == "autologin" || cookie.Name == "login" { authCookies = append(authCookies, cookie.Name+"="+cookie.Value) } } @@ -79,7 +79,7 @@ func Login(username, password string) (*LoginResult, error) { if len(authCookies) == 0 { if u, err := url.Parse(loginURL); err == nil { for _, cookie := range jar.Cookies(u) { - if cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" { + if cookie.Name == "Authorization" || cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" || cookie.Name == "autologin" || cookie.Name == "login" { authCookies = append(authCookies, cookie.Name+"="+cookie.Value) } } diff --git a/internal/client/client.go b/internal/client/client.go index d5b2340..7db76fb 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -11,6 +11,7 @@ import ( "github.com/gitlink-org/gitlink-cli/internal/auth" "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" ) @@ -25,6 +26,8 @@ type APIError struct { StatusCode int Code interface{} Message string + Kind clierrors.ErrorKind + Suggestion string } func (e *APIError) Error() string { @@ -101,10 +104,13 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o // Check HTTP-level errors 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, } } @@ -126,11 +132,13 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } if statusCode != 0 && statusCode != 200 && statusCode != 1 { msg, _ := raw["message"].(string) - suggestion := suggestFix(int(statusCode)) - return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{ + 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, } } } @@ -177,17 +185,43 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) return c.Do("DELETE", path, nil, query) } -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 "参数校验失败,请检查请求参数" - default: - return "" +type statusInfo struct { + kind clierrors.ErrorKind + message string + suggestion string +} + +var statusMessages = map[int]statusInfo{ + -2: {clierrors.KindAuth, "未登录或 Token 已过期", + "运行 gitlink-cli auth login 重新登录,或检查 GITLINK_TOKEN 环境变量"}, + -1: {clierrors.KindInput, "参数校验失败", + "检查必填参数是否缺失、参数格式是否正确,运行 gitlink-cli <命令> --help 查看用法"}, + 0: {clierrors.KindUnknown, "操作失败", ""}, + // Standard HTTP codes + 401: {clierrors.KindAuth, "认证失败", + "运行 gitlink-cli auth login 登录,或检查 GITLINK_TOKEN 环境变量"}, + 403: {clierrors.KindForbidden, "权限不足", + "请确认账号有此仓库的访问权限,或联系项目管理员"}, + 404: {clierrors.KindNotFound, "资源不存在", + "检查 owner/repo/id 是否正确,资源可能已被删除"}, + 422: {clierrors.KindInput, "参数校验失败", + "检查请求参数格式,运行 gitlink-cli <命令> --help 查看用法"}, + 429: {clierrors.KindServer, "请求过于频繁", + "稍等片刻后重试"}, + 500: {clierrors.KindServer, "服务器内部错误", + "稍等后重试,如持续出现请联系平台管理员"}, + 502: {clierrors.KindServer, "网关错误", + "服务器暂时不可用,稍等后重试"}, + 503: {clierrors.KindServer, "服务暂时不可用", + "服务器正在维护,稍等后重试"}, +} + +func lookupStatusInfo(code int) statusInfo { + if info, ok := statusMessages[code]; ok { + return info + } + return statusInfo{ + kind: clierrors.KindUnknown, + message: fmt.Sprintf("API 返回错误码 %d", code), } } diff --git a/internal/context/repo.go b/internal/context/repo.go index f3a7085..03ab176 100644 --- a/internal/context/repo.go +++ b/internal/context/repo.go @@ -17,7 +17,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) { owner, repo, err := fromGitRemote() if err != nil { if flagOwner == "" || flagRepo == "" { - return "", "", fmt.Errorf("cannot detect owner/repo from git remote: %w\nUse --owner and --repo flags to specify explicitly", err) + return "", "", fmt.Errorf("无法自动检测 owner/repo: %w\n 请使用 --owner 和 --repo 参数显式指定,或切换到 git 仓库目录下执行", err) } } @@ -59,11 +59,33 @@ func parseRemoteURL(remote string) (string, string, error) { } func parsePathSegments(path string) (string, string, error) { - path = strings.TrimPrefix(path, "/") + // 处理URL路径格式:可能包含域名前缀 + // 例如://www.gitlink.org.cn/zzx-coder/gitlink-cli 或 /zzx-coder/gitlink-cli + + // 先去掉域名部分(如果存在) + if strings.HasPrefix(path, "//www.gitlink.org.cn/") { + path = strings.TrimPrefix(path, "//www.gitlink.org.cn/") + } else if strings.HasPrefix(path, "//") { + // 处理其他可能的域名格式:找到第二个斜杠后的内容 + if idx := strings.Index(path[2:], "/"); idx != -1 { + path = path[2+idx+1:] + } else { + path = path[2:] + } + } else if strings.HasPrefix(path, "/") { + // 去掉单个前导斜杠 + path = strings.TrimPrefix(path, "/") + } + + // 去掉.git后缀 path = strings.TrimSuffix(path, ".git") - parts := strings.SplitN(path, "/", 3) + + // 现在应该得到 "zzx-coder/gitlink-cli" 格式 + parts := strings.Split(path, "/") if len(parts) < 2 { return "", "", fmt.Errorf("cannot extract owner/repo from path: %s", path) } + + // 第一个部分是owner,第二个是repo(可能还有更多部分但忽略) return parts[0], parts[1], nil } diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..d239c9d --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,115 @@ +package errors + +import ( + "fmt" + "strings" +) + +// ErrorKind categorizes errors by user-actionability. +type ErrorKind string + +const ( + KindAuth ErrorKind = "auth" // Login/token issues — user can re-login + KindInput ErrorKind = "input" // Parameter issues — user can fix arguments + KindConfig ErrorKind = "config" // Config file issues — user can edit config + KindNetwork ErrorKind = "network" // Network issues — user can check/retry + KindGit ErrorKind = "git" // Git repo issues — user needs correct directory + KindServer ErrorKind = "server" // Server-side error — user should wait or contact admin + KindNotFound ErrorKind = "not_found" // Resource not found — user can check ID + KindForbidden ErrorKind = "forbidden" // Permission denied — user can request access + KindUnknown ErrorKind = "unknown" // Unclassified error +) + +// CLIError is the unified CLI error type with multi-layered information. +type CLIError struct { + Kind ErrorKind // Error category for programmatic handling + Message string // Human-readable description of what went wrong + Detail string // Low-level technical detail (shown in debug mode) + Suggestion string // Actionable advice for the user + Command string // The command that triggered the error (e.g., "issue +create") + Cause error // The underlying error +} + +func (e *CLIError) Error() string { + var b strings.Builder + + // Header line: kind + command + b.WriteString(string(e.Kind)) + b.WriteString(" error") + if e.Command != "" { + b.WriteString(" — ") + b.WriteString(e.Command) + } + + // Body: message + if e.Message != "" { + b.WriteString("\n\n reason: ") + b.WriteString(e.Message) + } + + // Suggestion + if e.Suggestion != "" { + b.WriteString("\n suggestion: ") + b.WriteString(e.Suggestion) + } + + // Detail (always included in Error() so users see the raw cause) + if e.Detail != "" { + b.WriteString("\n detail: ") + b.WriteString(e.Detail) + } + + return b.String() +} + +func (e *CLIError) Unwrap() error { + return e.Cause +} + +// New creates a CLIError with the given parameters. +func New(kind ErrorKind, message, suggestion string) *CLIError { + return &CLIError{ + Kind: kind, + Message: message, + Suggestion: suggestion, + } +} + +// Wrap creates a CLIError that wraps an underlying cause. +func Wrap(kind ErrorKind, message, suggestion string, cause error) *CLIError { + return &CLIError{ + Kind: kind, + Message: message, + Suggestion: suggestion, + Cause: cause, + Detail: cause.Error(), + } +} + +// WithCommand sets the command context on the error. +func (e *CLIError) WithCommand(cmd string) *CLIError { + e.Command = cmd + return e +} + +// InputError is a convenience constructor for parameter errors. +func InputError(message, suggestion string) *CLIError { + return New(KindInput, message, suggestion) +} + +// AuthError is a convenience constructor for authentication errors. +func AuthError(message, suggestion string) *CLIError { + return New(KindAuth, message, suggestion) +} + +// ConfigError creates a config-related error with the config file path in the suggestion. +func ConfigError(message string, cause error) *CLIError { + return Wrap(KindConfig, message, + fmt.Sprintf("检查配置文件 %s 是否正确", configPathPlaceholder()), cause) +} + +// configPathPlaceholder avoids circular import; the actual path will be resolved +// in output formatting. +func configPathPlaceholder() string { + return "~/.config/gitlink-cli/config.yaml" +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..28e1fcb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "gitlink-cli", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 1112062..3f51635 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut { q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q) if err != nil { - return err + return fmt.Errorf("获取分支列表失败: %w", err) } return ctx.Output(env) }, @@ -50,7 +50,10 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, _ := ctx.RequireArg("name") + name, err := ctx.RequireArg("name", "--name feature/new-thing") + if err != nil { + return err + } from := ctx.Arg("from") if from == "" { from = "master" @@ -61,7 +64,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload) if err != nil { - return err + return fmt.Errorf("创建分支失败: %w", err) } return ctx.Output(env) }, @@ -81,13 +84,16 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, _ := ctx.RequireArg("name") + name, err := ctx.RequireArg("name", "--name feature/new-thing") + if err != nil { + return err + } payload := map[string]interface{}{ "branch_name": name, } env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload) if err != nil { - return err + return fmt.Errorf("删除分支失败: %w", err) } return ctx.Output(env) }, @@ -107,13 +113,16 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, _ := ctx.RequireArg("name") + name, err := ctx.RequireArg("name", "--name feature/new-thing") + if err != nil { + return err + } payload := map[string]interface{}{ "branch_name": name, } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload) if err != nil { - return err + return fmt.Errorf("设置分支保护失败: %w", err) } return ctx.Output(env) }, @@ -133,11 +142,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, _ := ctx.RequireArg("name") - env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil) + name, err := ctx.RequireArg("name", "--name feature/new-thing") if err != nil { return err } + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil) + if err != nil { + return fmt.Errorf("取消分支保护失败: %w", err) + } return ctx.Output(env) }, }, diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index f9a5641..eb14639 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut { q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q) if err != nil { - return err + return fmt.Errorf("获取 CI 构建列表失败: %w", err) } return ctx.Output(env) }, @@ -42,7 +42,10 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - build, _ := ctx.RequireArg("build") + build, err := ctx.RequireArg("build", "--build 42") + if err != nil { + return err + } stage := ctx.Arg("stage") step := ctx.Arg("step") if stage == "" { @@ -53,7 +56,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil) if err != nil { - return err + return fmt.Errorf("获取构建日志失败: %w", err) } return ctx.Output(env) }, @@ -73,11 +76,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - build, _ := ctx.RequireArg("build") - env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil) + build, err := ctx.RequireArg("build", "--build 42") if err != nil { return err } + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil) + if err != nil { + return fmt.Errorf("重启构建失败: %w", err) + } return ctx.Output(env) }, }, @@ -96,11 +102,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - build, _ := ctx.RequireArg("build") - env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil) + build, err := ctx.RequireArg("build", "--build 42") if err != nil { return err } + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil) + if err != nil { + return fmt.Errorf("停止构建失败: %w", err) + } return ctx.Output(env) }, }, diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 4e565c7..5fa9398 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -28,7 +28,8 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } } - ctx, err := NewRuntimeContext(flagValues) + commandName := parent.Use + " +" + s.Name + ctx, err := NewRuntimeContext(flagValues, commandName) if err != nil { return err } @@ -59,20 +60,21 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } for _, f := range s.Flags { + usage := f.Usage + if f.Required { + usage = usage + " [required]" + } if f.Bool { defaultValue, _ := strconv.ParseBool(f.Default) if f.Short != "" { - cmd.Flags().BoolP(f.Name, f.Short, defaultValue, f.Usage) + cmd.Flags().BoolP(f.Name, f.Short, defaultValue, usage) } else { - cmd.Flags().Bool(f.Name, defaultValue, f.Usage) + cmd.Flags().Bool(f.Name, defaultValue, usage) } } else if f.Short != "" { - cmd.Flags().StringP(f.Name, f.Short, f.Default, f.Usage) + cmd.Flags().StringP(f.Name, f.Short, f.Default, usage) } else { - cmd.Flags().String(f.Name, f.Default, f.Usage) - } - if f.Required { - cmd.MarkFlagRequired(f.Name) + cmd.Flags().String(f.Name, f.Default, usage) } } diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 893206a..029c5cc 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -11,6 +11,7 @@ import ( "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/internal/context" + clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" "github.com/gitlink-org/gitlink-cli/internal/output" ) @@ -36,15 +37,16 @@ type Flag struct { // RuntimeContext provides helpers for shortcut implementations. type RuntimeContext struct { - Client *client.Client - Owner string - Repo string - Format string - Args map[string]string + Client *client.Client + Owner string + Repo string + Format string + CommandName string + Args map[string]string } // NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo. -func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { +func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeContext, error) { cli, err := client.New() if err != nil { return nil, err @@ -53,15 +55,16 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { format := cmdutil.Format if format == "" { - format = "table" + format = "json" } return &RuntimeContext{ - Client: cli, - Owner: cmdutil.Owner, - Repo: cmdutil.Repo, - Format: format, - Args: args, + Client: cli, + Owner: cmdutil.Owner, + Repo: cmdutil.Repo, + Format: format, + CommandName: commandName, + Args: args, }, nil } @@ -114,11 +117,18 @@ func (ctx *RuntimeContext) Arg(name string) string { return "" } -// RequireArg returns a flag value or an error if not set. -func (ctx *RuntimeContext) RequireArg(name string) (string, error) { +// RequireArg returns a flag value or a CLIError if not set. +func (ctx *RuntimeContext) RequireArg(name, example string) (string, error) { v := ctx.Arg(name) if v == "" { - return "", fmt.Errorf("required flag --%s is missing", name) + 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 } diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 48d67af..429a12a 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -27,6 +27,42 @@ const ( statusRejected = 6 ) +// Tracker constants +const ( + trackerBug = 1 + trackerFeature = 2 + trackerSupport = 3 + trackerDoc = 4 + trackerTest = 5 + trackerDuplicate = 6 + trackerQuestion = 7 +) + +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", + trackerSupport: "support", + trackerDoc: "doc", + trackerTest: "test", + trackerDuplicate: "duplicate", + trackerQuestion: "question", +} + // Tag name → GitLink tag ID mapping // Collect IDs from web UI DevTools: change tag → capture PATCH payload → get issue_tag_ids value var tagIDs = map[string]int{ @@ -42,6 +78,15 @@ var tagIDs = map[string]int{ "搁置": 315532, } +// labelNames returns all known tag names from the given mapping. +func labelNames(tags map[string]int) string { + var names []string + for name := range tags { + names = append(names, name) + } + return strings.Join(names, ", ") +} + // BatchResult is a single item result in a batch operation. type BatchResult struct { Number string `json:"number" yaml:"number"` @@ -327,8 +372,7 @@ func runBatchAssign(ctx *common.RuntimeContext) error { summary.Results = append(summary.Results, result) continue } - body := map[string]interface{}{"assigner_ids": []interface{}{assigneeID}} - if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { + if err := updateIssueField(ctx, number, map[string]interface{}{"assigned_to_id": assigneeID}); err != nil { result.Status = "failed" result.Error = err.Error() summary.Failed++ @@ -369,7 +413,7 @@ func runBatchLabel(ctx *common.RuntimeContext) error { return err } label := ctx.Arg("label") - trackerID, err := parseLabel(label) + trackerID, err := parseTracker(label) if err != nil { return err } @@ -400,7 +444,7 @@ func runBatchLabel(ctx *common.RuntimeContext) error { summary.Results = append(summary.Results, result) continue } - if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []interface{}{trackerID}}); err != nil { + if err := updateIssueField(ctx, number, map[string]interface{}{"tracker_id": trackerID}); err != nil { result.Status = "failed" result.Error = err.Error() summary.Failed++ @@ -507,23 +551,40 @@ func parsePriority(p string) (int, error) { } } -func parseLabel(name string) (int, error) { - name = strings.TrimSpace(name) - if id, ok := tagIDs[name]; ok && id != 0 { +func parseTracker(label string) (int, error) { + switch strings.ToLower(strings.TrimSpace(label)) { + case "bug": + return trackerBug, nil + case "feature": + return trackerFeature, nil + case "support": + return trackerSupport, nil + case "doc": + return trackerDoc, nil + case "test": + return trackerTest, nil + case "duplicate": + return trackerDuplicate, nil + case "question": + return trackerQuestion, nil + default: + if id, err := strconv.Atoi(label); err == nil { + return id, nil + } + return 0, fmt.Errorf("invalid label %q: use bug, feature, support, doc, test, duplicate, or question", label) + } +} + +// parseLabel converts a label name to its GitLink tag ID. +// tags is the project's name→id mapping from resolveIssueTags. +func parseLabel(name string, tags map[string]int) (int, error) { + if id, ok := tags[name]; ok && id != 0 { return id, nil } if id, err := strconv.Atoi(name); err == nil { return id, nil } - return 0, fmt.Errorf("label %q not found or tag ID not configured; valid names: %s", name, labelNames()) -} - -func labelNames() string { - names := make([]string, 0, len(tagIDs)) - for n := range tagIDs { - names = append(names, n) - } - return strings.Join(names, ", ") + return 0, fmt.Errorf("invalid label %q: not found in project issue tags", name) } func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { diff --git a/shortcuts/issue/batch_create.go b/shortcuts/issue/batch_create.go index aecd997..0c59c1d 100644 --- a/shortcuts/issue/batch_create.go +++ b/shortcuts/issue/batch_create.go @@ -3,12 +3,76 @@ package issue import ( "encoding/csv" "fmt" + "net/url" "os" + "strconv" "strings" + "sync" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +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 + } + + 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 +} + func newBatchCreateShortcut() *common.Shortcut { return &common.Shortcut{ Name: "batch-create", @@ -49,6 +113,11 @@ func runBatchCreate(ctx *common.RuntimeContext) error { 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"))) @@ -99,7 +168,7 @@ func runBatchCreate(ctx *common.RuntimeContext) error { continue } - body := buildCreateBody(ctx, input, template) + body := buildCreateBody(ctx, input, template, tags) env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) if err != nil { result.Status = "failed" @@ -126,7 +195,7 @@ func runBatchCreate(ctx *common.RuntimeContext) error { return nil } -func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, template string) map[string]interface{} { +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 { @@ -143,9 +212,9 @@ func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, templat if template != "" { body["description"] = buildTemplateDescription(input, template) if template == "bug" { - body["issue_tag_ids"] = []interface{}{tagIDs["缺陷"]} + body["issue_tag_ids"] = []interface{}{tags["缺陷"]} } else if template == "feature" { - body["issue_tag_ids"] = []interface{}{tagIDs["功能"]} + body["issue_tag_ids"] = []interface{}{tags["功能"]} } } else if input.Body != "" { body["description"] = input.Body @@ -157,7 +226,7 @@ func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, templat } } if input.Label != "" { - if tid, err := parseLabel(input.Label); err == nil { + if tid, err := parseLabel(input.Label, tags); err == nil { body["issue_tag_ids"] = []interface{}{tid} } } diff --git a/shortcuts/issue/batch_create_test.go b/shortcuts/issue/batch_create_test.go new file mode 100644 index 0000000..b91680e --- /dev/null +++ b/shortcuts/issue/batch_create_test.go @@ -0,0 +1,678 @@ +package issue + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// testTags is a static name→id mapping used by unit tests. +var testTags = map[string]int{ + "缺陷": 315526, + "功能": 315527, + "文档": 315533, + "任务": 315530, + "测试": 315534, +} + +// ---- helpers ---- + +func findShortcut(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 +} + +// mockTagsHandler returns a handler that responds to the issue_tags API. +func mockTagsHandler(t *testing.T) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + tags := make([]map[string]interface{}, 0, len(testTags)) + for name, id := range testTags { + tags = append(tags, map[string]interface{}{ + "id": float64(id), + "name": name, + }) + } + writeJSONResp(t, w, map[string]interface{}{"issue_tags": tags}) + } +} + +func runBatchCreateShortcut(t *testing.T, server *httptest.Server, args map[string]string) error { + t.Helper() + s := findShortcut(t, "batch-create") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return s.Run(ctx) +} + +func writeJSONResp(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func decodeReqBody(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 body: %v", err) + } + return payload +} + +// ---- runBatchCreate tests ---- + +func TestBatchCreate_DryRun(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") { + mockTagsHandler(t)(w, r) + return + } + t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "titles": "标题1,标题2", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBatchCreate_FromTitles(t *testing.T) { + var createdBodies []map[string]interface{} + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags"): + mockTagsHandler(t)(w, r) + case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues"): + callCount++ + body := decodeReqBody(t, r) + createdBodies = append(createdBodies, body) + writeJSONResp(t, w, map[string]interface{}{ + "project_issues_index": float64(100 + callCount), + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "titles": "Bug修复,功能开发", + "state": "new", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if callCount != 2 { + t.Fatalf("expected 2 API calls, got %d", callCount) + } + if createdBodies[0]["subject"] != "Bug修复" { + t.Fatalf("first title: got %q, want %q", createdBodies[0]["subject"], "Bug修复") + } + if createdBodies[1]["subject"] != "功能开发" { + t.Fatalf("second title: got %q, want %q", createdBodies[1]["subject"], "功能开发") + } + // Verify required fields — values come through JSON as float64 + for i, body := range createdBodies { + if body["done_ratio"] != float64(0) { + t.Fatalf("body[%d]: done_ratio = %v (type %T), want 0", i, body["done_ratio"], body["done_ratio"]) + } + } +} + +func TestBatchCreate_NoTitles(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") { + mockTagsHandler(t)(w, r) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "dry-run": "false", + }) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestBatchCreate_FromCSV(t *testing.T) { + csvPath := writeTempCSV(t, "title,priority,label,status\nCSV标题1,high,缺陷,new\nCSV标题2,normal,功能,new\n") + + var created []map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags"): + mockTagsHandler(t)(w, r) + case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues"): + created = append(created, decodeReqBody(t, r)) + writeJSONResp(t, w, map[string]interface{}{"project_issues_index": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "from": csvPath, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(created) != 2 { + t.Fatalf("expected 2 creates, got %d", len(created)) + } + if created[0]["subject"] != "CSV标题1" { + t.Fatalf("first subject: got %q", created[0]["subject"]) + } + if created[1]["subject"] != "CSV标题2" { + t.Fatalf("second subject: got %q", created[1]["subject"]) + } +} + +func TestBatchCreate_CSVMissingTitleColumn(t *testing.T) { + csvPath := writeTempCSV(t, "name,description\nval1,desc1\n") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") { + mockTagsHandler(t)(w, r) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{"from": csvPath}) + if err == nil { + t.Fatal("expected error for missing title column, got nil") + } +} + +func TestBatchCreate_CSVOnlyHeader(t *testing.T) { + csvPath := writeTempCSV(t, "title,description\n") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") { + mockTagsHandler(t)(w, r) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{"from": csvPath}) + if err == nil { + t.Fatal("expected error for header-only CSV, got nil") + } +} + +func TestBatchCreate_PartialFailure(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags"): + mockTagsHandler(t)(w, r) + case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues"): + callCount++ + if callCount == 2 { + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + writeJSONResp(t, w, map[string]interface{}{"project_issues_index": float64(callCount)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "titles": "ok1,fail1,ok2", + }) + if err == nil { + t.Fatal("expected error from partial failure, got nil") + } + if !strings.Contains(err.Error(), "failed to create") { + t.Fatalf("error should mention failed count, got: %v", err) + } +} + +// ---- buildCreateBody tests (direct call, values retain Go types) ---- + +func intVal(v interface{}) int { + switch n := v.(type) { + case int: + return n + case float64: + return int(n) + } + return -999 +} + +func TestBuildCreateBody_Basic(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}} + input := createIssueInput{Title: "Test issue", Status: "new"} + body := buildCreateBody(ctx, input, "", testTags) + if body["subject"] != "Test issue" { + t.Fatalf("subject: got %v", body["subject"]) + } + if intVal(body["done_ratio"]) != 0 { + t.Fatalf("done_ratio: got %v (%T), want 0", body["done_ratio"], body["done_ratio"]) + } + if intVal(body["status_id"]) != 1 { + t.Fatalf("status_id: got %v (%T), want 1", body["status_id"], body["status_id"]) + } + if intVal(body["priority_id"]) != 2 { + t.Fatalf("priority_id: got %v (%T), want 2", body["priority_id"], body["priority_id"]) + } +} + +func TestBuildCreateBody_BugTemplate(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}} + input := createIssueInput{ + Title: "登录报错", + Version: "v2.0", + Severity: "严重", + Steps: "1. 打开页面\n2. 点击登录", + Expected: "正常登录", + Actual: "报错 500", + } + body := buildCreateBody(ctx, input, "bug", testTags) + if body["subject"] != "登录报错" { + t.Fatalf("subject: got %v", body["subject"]) + } + desc, _ := body["description"].(string) + if !strings.Contains(desc, "## Bug 描述") { + t.Fatal("bug description missing header") + } + if !strings.Contains(desc, "v2.0") { + t.Fatal("bug description missing version") + } + if !strings.Contains(desc, "严重") { + t.Fatal("bug description missing severity") + } + if rawTags, ok := body["issue_tag_ids"]; !ok { + t.Fatal("bug template missing issue_tag_ids") + } else { + ids := rawTags.([]interface{}) + if intVal(ids[0]) != testTags["缺陷"] { + t.Fatalf("tag: got %v (type %T), want %v", ids[0], ids[0], testTags["缺陷"]) + } + } +} + +func TestBuildCreateBody_FeatureTemplate(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}} + input := createIssueInput{ + Title: "用户搜索", + UserStory: "作为用户,我想搜索内容", + Acceptance: "搜索结果正确显示", + } + body := buildCreateBody(ctx, input, "feature", testTags) + desc, _ := body["description"].(string) + if !strings.Contains(desc, "## 用户故事") { + t.Fatal("feature description missing user story header") + } + if !strings.Contains(desc, "作为用户") { + t.Fatal("feature description missing user story content") + } + if !strings.Contains(desc, "## 验收标准") { + t.Fatal("feature description missing acceptance criteria") + } +} + +func TestBuildCreateBody_WithPriorityLabel(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}} + input := createIssueInput{ + Title: "紧急修复", + Priority: "high", + Label: "缺陷", + } + body := buildCreateBody(ctx, input, "", testTags) + if intVal(body["priority_id"]) != 3 { + t.Fatalf("priority_id: got %v (type %T), want 3 (high)", body["priority_id"], body["priority_id"]) + } + if rawTags, ok := body["issue_tag_ids"]; ok { + ids := rawTags.([]interface{}) + if intVal(ids[0]) != testTags["缺陷"] { + t.Fatalf("tag: got %v (type %T), want %v", ids[0], ids[0], testTags["缺陷"]) + } + } else { + t.Fatal("missing issue_tag_ids") + } +} + +// ---- buildBugDescription tests ---- + +func TestBuildBugDescription_AllFields(t *testing.T) { + input := createIssueInput{ + Title: "登录报错", + Version: "v2.0", + Severity: "严重", + Steps: "1. 打开", + Expected: "正常", + Actual: "500错误", + } + result := buildBugDescription(input) + if !strings.Contains(result, "## Bug 描述") { + t.Fatal("missing Bug 描述") + } + if !strings.Contains(result, "登录报错") { + t.Fatal("missing title") + } + if !strings.Contains(result, "## 版本") { + t.Fatal("missing 版本") + } + if !strings.Contains(result, "## 严重程度") { + t.Fatal("missing 严重程度") + } + if !strings.Contains(result, "## 复现步骤") { + t.Fatal("missing 复现步骤") + } + if !strings.Contains(result, "## 期望结果") { + t.Fatal("missing 期望结果") + } + if !strings.Contains(result, "## 实际结果") { + t.Fatal("missing 实际结果") + } +} + +func TestBuildBugDescription_PartialFields(t *testing.T) { + input := createIssueInput{Title: "小问题"} + result := buildBugDescription(input) + if !strings.Contains(result, "## Bug 描述") { + t.Fatal("missing header") + } + if strings.Contains(result, "## 版本") { + t.Fatal("should not have version section") + } + if strings.Contains(result, "## 严重程度") { + t.Fatal("should not have severity section") + } +} + +// ---- buildFeatureDescription tests ---- + +func TestBuildFeatureDescription_AllFields(t *testing.T) { + input := createIssueInput{ + Title: "搜索功能", + UserStory: "作为用户想搜索", + Body: "详细描述", + Acceptance: "搜索结果正确", + Priority: "high", + } + result := buildFeatureDescription(input) + if !strings.Contains(result, "## 用户故事") { + t.Fatal("missing user story") + } + if !strings.Contains(result, "作为用户想搜索") { + t.Fatal("missing user story content") + } + if !strings.Contains(result, "## 描述") { + t.Fatal("missing description") + } + if !strings.Contains(result, "## 验收标准") { + t.Fatal("missing acceptance criteria") + } + if !strings.Contains(result, "## 优先级") { + t.Fatal("missing priority") + } +} + +func TestBuildFeatureDescription_FallbackToTitleAsUserStory(t *testing.T) { + input := createIssueInput{Title: "搜索功能"} + result := buildFeatureDescription(input) + if !strings.Contains(result, "搜索功能") { + t.Fatal("should fall back to title as user story") + } +} + +// ---- readCreateInputsFromCSV tests ---- + +func TestReadCreateInputsFromCSV_Normal(t *testing.T) { + path := writeTempCSV(t, "title,priority,label,status,version,severity,steps,expected,actual\n标题1,high,缺陷,new,v1,严重,,,\n标题2,normal,功能,new,,,,,\n") + + inputs, err := readCreateInputsFromCSV(path, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(inputs) != 2 { + t.Fatalf("got %d inputs, want 2", len(inputs)) + } + if inputs[0].Title != "标题1" { + t.Fatalf("first title: got %q", inputs[0].Title) + } + if inputs[0].Severity != "严重" { + t.Fatalf("severity: got %q", inputs[0].Severity) + } + if inputs[1].Label != "功能" { + t.Fatalf("label: got %q", inputs[1].Label) + } +} + +func TestReadCreateInputsFromCSV_MissingTitleColumn(t *testing.T) { + path := writeTempCSV(t, "name,description\nval1,desc1\n") + _, err := readCreateInputsFromCSV(path, "") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadCreateInputsFromCSV_OnlyHeader(t *testing.T) { + path := writeTempCSV(t, "title,priority\n") + _, err := readCreateInputsFromCSV(path, "") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadCreateInputsFromCSV_SkipsEmptyTitle(t *testing.T) { + path := writeTempCSV(t, "title,priority\n标题1,high\n,normal\n标题2,low\n") + inputs, err := readCreateInputsFromCSV(path, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(inputs) != 2 { + t.Fatalf("got %d inputs, want 2 (empty row skipped)", len(inputs)) + } +} + +// ---- parseTitles tests ---- + +func TestParseTitles_CommaSeparated(t *testing.T) { + ctx := &common.RuntimeContext{ + Owner: "o", Repo: "r", + Args: map[string]string{"priority": "normal"}, + } + inputs := parseTitles("标题1, 标题2, , 标题3", ctx) + if len(inputs) != 3 { + t.Fatalf("got %d inputs, want 3", len(inputs)) + } + if inputs[0].Title != "标题1" { + t.Fatalf("got %q", inputs[0].Title) + } + if inputs[2].Title != "标题3" { + t.Fatalf("got %q", inputs[2].Title) + } + if inputs[0].Priority != "normal" { + t.Fatalf("priority not propagated: got %q", inputs[0].Priority) + } +} + +// ---- normalizeHeader tests ---- + +func TestNormalizeHeader(t *testing.T) { + cases := []struct{ in, want string }{ + {"Title", "title"}, + {" PRIORITY ", "priority"}, + {"user_story", "user_story"}, + {"User Story", "user story"}, + } + for _, c := range cases { + got := normalizeHeader(c.in) + if got != c.want { + t.Fatalf("normalizeHeader(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// ---- getCol tests ---- + +func TestGetCol(t *testing.T) { + col := map[string]int{"title": 0, "priority": 1} + record := []string{"测试标题", "high"} + if got := getCol(record, col, "title"); got != "测试标题" { + t.Fatalf("got %q", got) + } + if got := getCol(record, col, "missing"); got != "" { + t.Fatalf("got %q, want empty", got) + } + if got := getCol(record, col, "priority"); got != "high" { + t.Fatalf("got %q", got) + } +} + +// ---- truncate tests ---- + +func TestTruncate(t *testing.T) { + if got := truncate("short", 40); got != "short" { + t.Fatalf("got %q", got) + } + long := "这是一个很长的标题用来测试截断功能一二三四五六七八九十" + got := truncate(long, 10) + if len([]rune(got)) > 13 { + t.Fatalf("truncated too long: %q (%d runes)", got, len([]rune(got))) + } + if !strings.HasSuffix(got, "...") { + t.Fatal("truncated string should end with ...") + } +} + +// ---- priority/label/status parse helpers ---- + +func TestParsePriorityStrings(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"low", 1}, {"normal", 2}, {"high", 3}, {"urgent", 4}, + {"LOW", 1}, {"High", 3}, + } + for _, c := range cases { + got, err := parsePriority(c.in) + if err != nil { + t.Fatalf("parsePriority(%q): %v", c.in, err) + } + if got != c.want { + t.Fatalf("parsePriority(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestParsePriorityNumeric(t *testing.T) { + got, err := parsePriority("5") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != 5 { + t.Fatalf("got %d, want 5", got) + } +} + +func TestParsePriorityInvalid(t *testing.T) { + if _, err := parsePriority("invalid"); err == nil { + t.Fatal("expected error") + } +} + +func TestParseLabelValid(t *testing.T) { + id, err := parseLabel("缺陷", testTags) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != testTags["缺陷"] { + t.Fatalf("got %d, want %d", id, testTags["缺陷"]) + } +} + +func TestParseLabelInvalid(t *testing.T) { + if _, err := parseLabel("不存在的标签", testTags); err == nil { + t.Fatal("expected error") + } +} + +func TestParseLabelNumeric(t *testing.T) { + id, err := parseLabel("999", testTags) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != 999 { + t.Fatalf("got %d, want 999", id) + } +} + +func TestLabelNamesReturnsAll(t *testing.T) { + names := labelNames(testTags) + if !strings.Contains(names, "缺陷") { + t.Fatal("missing 缺陷 in label names") + } + if !strings.Contains(names, "功能") { + t.Fatal("missing 功能 in label names") + } +} + +// ---- buildCreateBody status tests ---- + +func TestBuildCreateBody_DefaultStatus(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}} + input := createIssueInput{Title: "t", Status: ""} + body := buildCreateBody(ctx, input, "", testTags) + if intVal(body["status_id"]) != 1 { + t.Fatalf("default status_id: got %v (type %T), want 1", body["status_id"], body["status_id"]) + } +} + +func TestBuildCreateBody_ClosedStatus(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}} + input := createIssueInput{Title: "t", Status: "closed"} + body := buildCreateBody(ctx, input, "", testTags) + if intVal(body["status_id"]) != 5 { + t.Fatalf("closed status_id: got %v (type %T), want 5", body["status_id"], body["status_id"]) + } +} + +// ---- regression ---- + +func TestCollectIssueNumbers_FromBatchCreatePerspective(t *testing.T) { + got, err := collectIssueNumbers("1,2,3", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"1", "2", "3"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 4264686..9c3fc2a 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -50,7 +50,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q) if err != nil { - return err + return fmt.Errorf("获取 Issue 列表失败: %w", err) } return ctx.Output(env) }, @@ -74,7 +74,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title, err := ctx.RequireArg("title") + title, err := ctx.RequireArg("title", `--title "Bug: 登录页崩溃"`) if err != nil { return err } @@ -95,7 +95,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) if err != nil { - return err + return fmt.Errorf("创建 Issue 失败: %w", err) } return ctx.Output(env) }, @@ -110,13 +110,13 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := ctx.RequireArg("number", "--number 42") if err != nil { return err } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) if err != nil { - return err + return fmt.Errorf("查看 Issue 失败: %w", err) } return ctx.Output(env) }, @@ -136,7 +136,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := ctx.RequireArg("number", "--number 42") if err != nil { return err } @@ -152,7 +152,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { - return err + return fmt.Errorf("关闭 Issue 失败: %w", err) } return ctx.Output(env) }, @@ -206,7 +206,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := ctx.RequireArg("number", "--number 42") if err != nil { return err } @@ -241,7 +241,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { - return err + return fmt.Errorf("更新 Issue 失败: %w", err) } return ctx.Output(env) }, @@ -262,11 +262,11 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := ctx.RequireArg("number", "--number 42") if err != nil { return err } - body, err := ctx.RequireArg("body") + body, err := ctx.RequireArg("body", `--body "可以这样复现..."`) if err != nil { return err } @@ -275,7 +275,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload) if err != nil { - return err + return fmt.Errorf("添加 Issue 评论失败: %w", err) } return ctx.Output(env) }, @@ -286,7 +286,7 @@ 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, err + return nil, fmt.Errorf("获取 Issue 信息失败: %w", err) } issueData, ok := getEnv.Data.(map[string]interface{}) if !ok { diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index 6719e40..00295a8 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -24,7 +24,7 @@ func Shortcuts() []*common.Shortcut { q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", "/organizations", q) if err != nil { - return err + return fmt.Errorf("获取组织列表失败: %w", err) } return ctx.Output(env) }, @@ -36,11 +36,14 @@ func Shortcuts() []*common.Shortcut { {Name: "id", Short: "i", Usage: "Organization ID or login", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil) + id, err := ctx.RequireArg("id", "--id my-org") if err != nil { return err } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil) + if err != nil { + return fmt.Errorf("查看组织失败: %w", err) + } return ctx.Output(env) }, }, @@ -53,13 +56,16 @@ func Shortcuts() []*common.Shortcut { {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") + id, err := ctx.RequireArg("id", "--id 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/organization_users", id), q) if err != nil { - return err + return fmt.Errorf("获取组织成员失败: %w", err) } return ctx.Output(env) }, @@ -77,7 +83,10 @@ func Shortcuts() []*common.Shortcut { {Name: "description", Short: "d", Usage: "Description"}, }, Run: func(ctx *common.RuntimeContext) error { - name, _ := ctx.RequireArg("name") + name, err := ctx.RequireArg("name", `--name "My Organization"`) + if err != nil { + return err + } payload := map[string]interface{}{ "name": name, } @@ -86,7 +95,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", "/organizations", payload) if err != nil { - return err + return fmt.Errorf("创建组织失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 7a55243..951c71f 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -33,7 +33,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q) if err != nil { - return err + return fmt.Errorf("获取 PR 列表失败: %w", err) } return ctx.Output(env) }, @@ -61,8 +61,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title, _ := ctx.RequireArg("title") - head, _ := ctx.RequireArg("head") + title, err := ctx.RequireArg("title", `--title "Fix login crash"`) + if err != nil { + return err + } + head, err := ctx.RequireArg("head", `--head feat/new-login`) + if err != nil { + return err + } base := ctx.Arg("base") if base == "" { base = "master" @@ -77,7 +83,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload) if err != nil { - return err + return fmt.Errorf("创建 PR 失败: %w", err) } return ctx.Output(env) }, @@ -92,11 +98,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", 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", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("查看 PR 失败: %w", err) + } return ctx.Output(env) }, }, @@ -120,7 +129,10 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") + id, err := ctx.RequireArg("id", "--id 42") + if err != nil { + return err + } method := ctx.Arg("method") if method == "" { method = "merge" @@ -130,7 +142,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 err + return fmt.Errorf("合并 PR 失败: %w", err) } return ctx.Output(env) }, @@ -150,11 +162,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id", "--id 42") if err != nil { return err } + 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 ctx.Output(env) }, }, @@ -168,11 +183,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id", "--id 42") if err != nil { return err } + 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 ctx.Output(env) }, }, @@ -186,11 +204,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id", "--id 42") if err != nil { return err } + 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 ctx.Output(env) }, }, @@ -210,12 +231,18 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - body, _ := ctx.RequireArg("body") + id, err := ctx.RequireArg("id", "--id 42") + if err != nil { + return err + } + body, err := ctx.RequireArg("body", `--body "Looks good to me"`) + if err != nil { + return err + } prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) if err != nil { - return fmt.Errorf("fetch PR: %w", err) + return fmt.Errorf("获取 PR 信息失败: %w", err) } issueID, err := extractIssueID(prEnv) if err != nil { @@ -227,7 +254,7 @@ 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 err + return fmt.Errorf("添加 PR 评论失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 07893ec..9bd9f08 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -26,7 +26,7 @@ func Shortcuts() []*common.Shortcut { q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q) if err != nil { - return err + return fmt.Errorf("获取 Release 列表失败: %w", err) } return ctx.Output(env) }, @@ -51,8 +51,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - tag, _ := ctx.RequireArg("tag") - name, _ := ctx.RequireArg("name") + tag, err := ctx.RequireArg("tag", "--tag v1.0.0") + if err != nil { + return err + } + name, err := ctx.RequireArg("name", `--name "Version 1.0.0"`) + if err != nil { + return err + } payload := map[string]interface{}{ "tag_name": tag, "name": name, @@ -68,7 +74,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload) if err != nil { - return err + return fmt.Errorf("创建 Release 失败: %w", err) } return ctx.Output(env) }, @@ -83,11 +89,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id", "--id 1") if err != nil { return err } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("查看 Release 失败: %w", err) + } return ctx.Output(env) }, }, @@ -106,7 +115,10 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") + id, err := ctx.RequireArg("id", "--id 1") + if err != nil { + return err + } _, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) if delErr != nil { _, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) @@ -115,7 +127,7 @@ func Shortcuts() []*common.Shortcut { "message": "删除成功", }, nil)) } - return delErr + return fmt.Errorf("删除 Release 失败: %w", delErr) } return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ "message": "删除成功", diff --git a/shortcuts/repo/batch_create.go b/shortcuts/repo/batch_create.go new file mode 100644 index 0000000..cf72a36 --- /dev/null +++ b/shortcuts/repo/batch_create.go @@ -0,0 +1,196 @@ +package repo + +import ( + "encoding/csv" + "fmt" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type repoCreateInput struct { + Name string + Description string + Private bool +} + +type repoBatchResult struct { + Name string `json:"name" yaml:"name"` + Status string `json:"status" yaml:"status"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` +} + +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"` +} + +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, e.g. repo-a,repo-b"}, + {Name: "from", Usage: "CSV file path"}, + {Name: "description", Short: "d", Usage: "Shared description for all repos (inline mode)"}, + {Name: "private", Usage: "Make repos private", Bool: true, Default: "false"}, + {Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"}, + }, + Run: runBatchCreate, + } +} + +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; use -n repo-a,repo-b or --from repos.csv") + } + + 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 +} + +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 +} + +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 "" +} diff --git a/shortcuts/repo/batch_test.go b/shortcuts/repo/batch_test.go new file mode 100644 index 0000000..633c728 --- /dev/null +++ b/shortcuts/repo/batch_test.go @@ -0,0 +1,837 @@ +package repo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// ---- helpers ---- + +func findShortcut(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 runBatchCreateShortcut(t *testing.T, server *httptest.Server, args map[string]string) error { + t.Helper() + s := findShortcut(t, "batch-create") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return s.Run(ctx) +} + +func runBatchUpdateShortcut(t *testing.T, server *httptest.Server, args map[string]string) error { + t.Helper() + s := findShortcut(t, "batch-update") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return s.Run(ctx) +} + +func writeJSONResp(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("writeJSON: %v", err) + } +} + +func decodeReqBody(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 body: %v", err) + } + return payload +} + +func writeTempCSV(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "repos.csv") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp csv: %v", err) + } + return path +} + +// ---- runBatchCreate tests ---- + +func TestBatchCreate_DryRun(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "repo1,repo2", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBatchCreate_FromNames(t *testing.T) { + var createdBodies []map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/users/me.json": + writeJSONResp(t, w, map[string]interface{}{ + "login": "testuser", + "user_id": float64(42), + }) + case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/testuser/"): + createdBodies = append(createdBodies, decodeReqBody(t, r)) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "repo-a,repo-b", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(createdBodies) != 2 { + t.Fatalf("expected 2 API calls, got %d", len(createdBodies)) + } + if createdBodies[0]["name"] != "repo-a" { + t.Fatalf("first name: got %q, want %q", createdBodies[0]["name"], "repo-a") + } + if createdBodies[1]["name"] != "repo-b" { + t.Fatalf("second name: got %q, want %q", createdBodies[1]["name"], "repo-b") + } + for _, body := range createdBodies { + if body["repository_name"] != body["name"] { + t.Fatalf("repository_name should match name: %v vs %v", body["repository_name"], body["name"]) + } + if body["user_id"] != float64(42) { + t.Fatalf("user_id: got %v, want 42", body["user_id"]) + } + } +} + +func TestBatchCreate_NoNames(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{}) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestBatchCreate_NoNamesDryRun(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{"dry-run": "true"}) + if err == nil { + t.Fatal("expected error for no names even in dry-run, got nil") + } +} + +func TestBatchCreate_WithPrivate(t *testing.T) { + var createdBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/users/me.json": + writeJSONResp(t, w, map[string]interface{}{ + "login": "testuser", + "user_id": float64(42), + }) + case r.Method == "POST": + createdBody = decodeReqBody(t, r) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "private-repo", + "private": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if createdBody["private"] != true { + t.Fatalf("private: got %v, want true", createdBody["private"]) + } +} + +func TestBatchCreate_WithDescription(t *testing.T) { + var createdBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/users/me.json": + writeJSONResp(t, w, map[string]interface{}{ + "login": "testuser", + "user_id": float64(42), + }) + case r.Method == "POST": + createdBody = decodeReqBody(t, r) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "desc-repo", + "description": "shared description", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if createdBody["description"] != "shared description" { + t.Fatalf("description: got %q, want %q", createdBody["description"], "shared description") + } +} + +func TestBatchCreate_FromCSV(t *testing.T) { + csvPath := writeTempCSV(t, "name,description,private\ncsv-repo1,desc one,false\ncsv-repo2,desc two,true\n") + + var createdBodies []map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/users/me.json": + writeJSONResp(t, w, map[string]interface{}{ + "login": "testuser", + "user_id": float64(42), + }) + case r.Method == "POST": + createdBodies = append(createdBodies, decodeReqBody(t, r)) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{"from": csvPath}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(createdBodies) != 2 { + t.Fatalf("expected 2 creates, got %d", len(createdBodies)) + } + if createdBodies[0]["name"] != "csv-repo1" { + t.Fatalf("first name: got %q", createdBodies[0]["name"]) + } + if createdBodies[0]["description"] != "desc one" { + t.Fatalf("first description: got %q", createdBodies[0]["description"]) + } + if createdBodies[1]["name"] != "csv-repo2" { + t.Fatalf("second name: got %q", createdBodies[1]["name"]) + } + if createdBodies[1]["private"] != true { + t.Fatalf("second private: got %v, want true", createdBodies[1]["private"]) + } +} + +func TestBatchCreate_PartialFailure(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/users/me.json": + writeJSONResp(t, w, map[string]interface{}{ + "login": "testuser", + "user_id": float64(42), + }) + case r.Method == "POST": + callCount++ + if callCount == 2 { + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + writeJSONResp(t, w, map[string]interface{}{"id": float64(callCount)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "ok1,fail1,ok2", + }) + if err == nil { + t.Fatal("expected error from partial failure, got nil") + } + if !strings.Contains(err.Error(), "failed to create") { + t.Fatalf("error should mention failed count, got: %v", err) + } +} + +func TestBatchCreate_UserLookupFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "repo1", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to get current user") { + t.Fatalf("error should mention user lookup, got: %v", err) + } +} + +func TestBatchCreate_UserMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSONResp(t, w, map[string]interface{}{ + "user_id": float64(42), + }) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "repo1", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "cannot determine current user login") { + t.Fatalf("error should mention missing login, got: %v", err) + } +} + +func TestBatchCreate_CSVAndNamesCombined(t *testing.T) { + csvPath := writeTempCSV(t, "name\ndual-repo\n") + + var createdNames []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/users/me.json": + writeJSONResp(t, w, map[string]interface{}{ + "login": "testuser", + "user_id": float64(42), + }) + case r.Method == "POST": + body := decodeReqBody(t, r) + createdNames = append(createdNames, body["name"].(string)) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "inline-repo", + "from": csvPath, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(createdNames) != 2 { + t.Fatalf("expected 2 creates, got %d", len(createdNames)) + } + if createdNames[0] != "inline-repo" { + t.Fatalf("first: got %q", createdNames[0]) + } + if createdNames[1] != "dual-repo" { + t.Fatalf("second: got %q", createdNames[1]) + } +} + +// ---- readRepoInputsFromCSV tests ---- + +func TestReadRepoInputsFromCSV_Normal(t *testing.T) { + path := writeTempCSV(t, "name,description,private\nrepo1,desc1,true\nrepo2,desc2,false\nrepo3,,0\n") + + inputs, err := readRepoInputsFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(inputs) != 3 { + t.Fatalf("got %d inputs, want 3", len(inputs)) + } + if inputs[0].Name != "repo1" || inputs[0].Description != "desc1" || !inputs[0].Private { + t.Fatalf("input[0]: %+v", inputs[0]) + } + if inputs[1].Name != "repo2" || inputs[1].Private { + t.Fatalf("input[1]: %+v", inputs[1]) + } + if inputs[2].Name != "repo3" || inputs[2].Private { + t.Fatalf("input[2]: %+v", inputs[2]) + } +} + +func TestReadRepoInputsFromCSV_MissingNameColumn(t *testing.T) { + path := writeTempCSV(t, "title,description\nval1,desc1\n") + _, err := readRepoInputsFromCSV(path) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadRepoInputsFromCSV_OnlyHeader(t *testing.T) { + path := writeTempCSV(t, "name,description\n") + _, err := readRepoInputsFromCSV(path) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadRepoInputsFromCSV_SkipsEmptyName(t *testing.T) { + path := writeTempCSV(t, "name,description\nrepo1,desc1\n,desc2\nrepo2,desc3\n") + + inputs, err := readRepoInputsFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(inputs) != 2 { + t.Fatalf("got %d inputs, want 2", len(inputs)) + } +} + +func TestReadRepoInputsFromCSV_PrivateParsing(t *testing.T) { + path := writeTempCSV(t, "name,private\nr1,true\nr2,false\nr3,1\nr4,0\nr5,\n") + + inputs, err := readRepoInputsFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(inputs) != 5 { + t.Fatalf("got %d inputs, want 5", len(inputs)) + } + if !inputs[0].Private { + t.Fatal("r1 (true) should be private") + } + if inputs[1].Private { + t.Fatal("r2 (false) should not be private") + } + if !inputs[2].Private { + t.Fatal("r3 (1) should be private") + } + if inputs[3].Private { + t.Fatal("r4 (0) should not be private") + } + if inputs[4].Private { + t.Fatal("r5 (empty) should not be private") + } +} + +func TestReadRepoInputsFromCSV_FileNotFound(t *testing.T) { + _, err := readRepoInputsFromCSV("/nonexistent/path.csv") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadRepoInputsFromCSV_CaseInsensitiveHeader(t *testing.T) { + path := writeTempCSV(t, "NAME,Description,Private\nrepo1,desc1,TRUE\n") + + inputs, err := readRepoInputsFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(inputs) != 1 { + t.Fatalf("got %d inputs, want 1", len(inputs)) + } + if inputs[0].Name != "repo1" { + t.Fatalf("name: got %q", inputs[0].Name) + } + if !inputs[0].Private { + t.Fatal("should be private") + } +} + +// ---- getCol tests ---- + +func TestGetCol_Found(t *testing.T) { + col := map[string]int{"name": 0, "description": 1} + record := []string{"my-repo", "my desc"} + if got := getCol(record, col, "name"); got != "my-repo" { + t.Fatalf("got %q", got) + } + if got := getCol(record, col, "description"); got != "my desc" { + t.Fatalf("got %q", got) + } +} + +func TestGetCol_Missing(t *testing.T) { + col := map[string]int{"name": 0} + record := []string{"my-repo"} + if got := getCol(record, col, "missing"); got != "" { + t.Fatalf("got %q, want empty", got) + } +} + +func TestGetCol_IndexOutOfRange(t *testing.T) { + col := map[string]int{"name": 5} + record := []string{"my-repo"} + if got := getCol(record, col, "name"); got != "" { + t.Fatalf("got %q, want empty", got) + } +} + +// ---- runBatchUpdate tests ---- + +func TestBatchUpdate_DryRun(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "repo1,repo2", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBatchUpdate_FromNames(t *testing.T) { + var fetchedRepos []string + var patchedBodies []map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/owner/"), ".json") + fetchedRepos = append(fetchedRepos, name) + writeJSONResp(t, w, map[string]interface{}{ + "name": name, + "identifier": "ident-" + name, + }) + case r.Method == "PATCH": + patchedBodies = append(patchedBodies, decodeReqBody(t, r)) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "repo-a,repo-b", + "private": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(fetchedRepos) != 2 { + t.Fatalf("expected 2 fetches, got %d", len(fetchedRepos)) + } + if len(patchedBodies) != 2 { + t.Fatalf("expected 2 patches, got %d", len(patchedBodies)) + } + for _, body := range patchedBodies { + if body["private"] != true { + t.Fatalf("private should be true: %+v", body) + } + } +} + +func TestBatchUpdate_NoNames(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{}) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestBatchUpdate_PrivatePublicConflict(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "repo1", + "private": "true", + "public": "true", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "cannot use both --private and --public") { + t.Fatalf("error should mention conflict, got: %v", err) + } +} + +func TestBatchUpdate_SetPublic(t *testing.T) { + var patchedBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + writeJSONResp(t, w, map[string]interface{}{ + "name": "repo1", + "identifier": "abc123", + }) + case r.Method == "PATCH": + patchedBody = decodeReqBody(t, r) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "repo1", + "public": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if patchedBody["private"] != false { + t.Fatalf("public should set private=false, got %v", patchedBody["private"]) + } +} + +func TestBatchUpdate_WithDescription(t *testing.T) { + var patchedBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + writeJSONResp(t, w, map[string]interface{}{ + "name": "repo1", + "identifier": "abc123", + }) + case r.Method == "PATCH": + patchedBody = decodeReqBody(t, r) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "repo1", + "description": "updated description", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if patchedBody["description"] != "updated description" { + t.Fatalf("description: got %q", patchedBody["description"]) + } +} + +func TestBatchUpdate_FromCSV(t *testing.T) { + csvPath := writeTempCSV(t, "name\ncsv-repo1\ncsv-repo2\n") + + var fetchedRepos []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/owner/"), ".json") + fetchedRepos = append(fetchedRepos, name) + writeJSONResp(t, w, map[string]interface{}{ + "name": name, + "identifier": "ident-" + name, + }) + case r.Method == "PATCH": + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{"from": csvPath}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(fetchedRepos) != 2 { + t.Fatalf("expected 2 fetches, got %d", len(fetchedRepos)) + } +} + +func TestBatchUpdate_PartialFailure(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + switch { + case r.Method == "GET" && strings.Contains(r.URL.Path, "fail-repo"): + w.WriteHeader(http.StatusNotFound) + case r.Method == "GET": + writeJSONResp(t, w, map[string]interface{}{ + "name": "ok-repo", + "identifier": "abc123", + }) + case r.Method == "PATCH": + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "ok-repo,fail-repo", + }) + if err == nil { + t.Fatal("expected error from partial failure, got nil") + } + if !strings.Contains(err.Error(), "failed to update") { + t.Fatalf("error should mention failed count, got: %v", err) + } +} + +func TestBatchUpdate_PreservesIdentifier(t *testing.T) { + var patchedBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + writeJSONResp(t, w, map[string]interface{}{ + "name": "my-repo", + "identifier": "xyz-789", + "description": "old desc", + }) + case r.Method == "PATCH": + patchedBody = decodeReqBody(t, r) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "my-repo", + "description": "new desc", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if patchedBody["name"] != "my-repo" { + t.Fatalf("name: got %q", patchedBody["name"]) + } + if patchedBody["identifier"] != "xyz-789" { + t.Fatalf("identifier: got %q", patchedBody["identifier"]) + } + if patchedBody["description"] != "new desc" { + t.Fatalf("description: got %q", patchedBody["description"]) + } +} + +// ---- readNamesFromCSV tests ---- + +func TestReadNamesFromCSV_Normal(t *testing.T) { + path := writeTempCSV(t, "name\nrepo1\nrepo2\nrepo3\n") + + names, err := readNamesFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 3 { + t.Fatalf("got %d names, want 3", len(names)) + } + if names[0] != "repo1" || names[1] != "repo2" || names[2] != "repo3" { + t.Fatalf("got %v", names) + } +} + +func TestReadNamesFromCSV_MissingNameColumn(t *testing.T) { + path := writeTempCSV(t, "title,description\nval1,desc1\nval2,desc2\n") + + names, err := readNamesFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 2 { + t.Fatalf("got %d names, want 2 (falls back to col 0)", len(names)) + } + if names[0] != "val1" || names[1] != "val2" { + t.Fatalf("got %v", names) + } +} + +func TestReadNamesFromCSV_OnlyHeader(t *testing.T) { + path := writeTempCSV(t, "name\n") + _, err := readNamesFromCSV(path) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadNamesFromCSV_SkipsEmptyName(t *testing.T) { + path := writeTempCSV(t, "name\nrepo1\n\nrepo2\n") + + names, err := readNamesFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 2 { + t.Fatalf("got %d names, want 2", len(names)) + } +} + +func TestReadNamesFromCSV_FileNotFound(t *testing.T) { + _, err := readNamesFromCSV("/nonexistent/path.csv") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadNamesFromCSV_CaseInsensitiveHeader(t *testing.T) { + path := writeTempCSV(t, "NAME\nrepo1\nrepo2\n") + + names, err := readNamesFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 2 { + t.Fatalf("got %d names, want 2", len(names)) + } +} + +func TestReadNamesFromCSV_ExtraColumns(t *testing.T) { + path := writeTempCSV(t, "name,extra,another\nrepo1,x,y\nrepo2,a,b\n") + + names, err := readNamesFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 2 { + t.Fatalf("got %d names, want 2", len(names)) + } + if names[0] != "repo1" || names[1] != "repo2" { + t.Fatalf("got %v", names) + } +} diff --git a/shortcuts/repo/batch_update.go b/shortcuts/repo/batch_update.go new file mode 100644 index 0000000..98c7469 --- /dev/null +++ b/shortcuts/repo/batch_update.go @@ -0,0 +1,165 @@ +package repo + +import ( + "encoding/csv" + "fmt" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func newBatchUpdateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-update", + Description: "Update settings for multiple repositories", + Flags: []common.Flag{ + {Name: "names", Short: "n", Usage: "Comma-separated repository names, e.g. repo-a,repo-b"}, + {Name: "from", Usage: "CSV file path"}, + {Name: "description", Short: "d", Usage: "Shared description for all repos"}, + {Name: "private", Usage: "Set repos to private", Bool: true, Default: "false"}, + {Name: "public", Usage: "Set repos to public", Bool: true, Default: "false"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: runBatchUpdate, + } +} + +func runBatchUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + var repoNames []string + if namesStr := ctx.Arg("names"); namesStr != "" { + for _, name := range strings.Split(namesStr, ",") { + name = strings.TrimSpace(name) + if name != "" { + repoNames = append(repoNames, name) + } + } + } + if csvPath := ctx.Arg("from"); csvPath != "" { + csvNames, err := readNamesFromCSV(csvPath) + if err != nil { + return err + } + repoNames = append(repoNames, csvNames...) + } + if len(repoNames) == 0 { + return fmt.Errorf("no repository names provided; use -n repo-a,repo-b or --from repos.csv") + } + + setPrivate := ctx.Arg("private") == "true" + setPublic := ctx.Arg("public") == "true" + if setPrivate && setPublic { + return fmt.Errorf("cannot use both --private and --public") + } + changeVisibility := setPrivate || setPublic + + dryRun := ctx.Arg("dry-run") == "true" + desc := ctx.Arg("description") + + summary := repoBatchSummary{ + Owner: ctx.Owner, + Action: "update", + DryRun: dryRun, + Total: len(repoNames), + Results: make([]repoBatchResult, 0, len(repoNames)), + } + + for _, name := range repoNames { + result := repoBatchResult{Name: name} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + // Fetch current repo info to get required fields for PATCH + current, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s", ctx.Owner, name), nil) + if err != nil { + result.Status = "failed" + result.Error = fmt.Sprintf("fetch repo: %v", err) + summary.Failed++ + summary.Results = append(summary.Results, result) + continue + } + + curData, _ := current.Data.(map[string]interface{}) + curName, _ := curData["name"].(string) + identifier, _ := curData["identifier"].(string) + + body := map[string]interface{}{ + "name": curName, + "identifier": identifier, + } + if desc != "" { + body["description"] = desc + } + if changeVisibility { + body["private"] = setPrivate + } + + if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("/%s/%s", ctx.Owner, name), body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "updated" + 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 update", summary.Failed, summary.Total) + } + return nil +} + +func readNamesFromCSV(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) + } + if len(records) < 2 { + return nil, fmt.Errorf("CSV must have a header row and at least one data row") + } + + header := records[0] + nameCol := -1 + for i, h := range header { + if strings.ToLower(strings.TrimSpace(h)) == "name" { + nameCol = i + break + } + } + if nameCol == -1 { + nameCol = 0 + } + + var names []string + for _, record := range records[1:] { + if nameCol >= len(record) { + continue + } + name := strings.TrimSpace(record[nameCol]) + if name != "" { + names = append(names, name) + } + } + return names, nil +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index b448996..8fae2ce 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -9,7 +9,9 @@ import ( ) func Shortcuts() []*common.Shortcut { - shortcuts := []*common.Shortcut{ + shortcuts := []*common.Shortcut{ + newBatchCreateShortcut(), + newBatchUpdateShortcut(), { Name: "list", Description: "List repositories for a user or organization", @@ -34,7 +36,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", path, q) if err != nil { - return err + return fmt.Errorf("获取仓库列表失败: %w", err) } return ctx.Output(env) }, @@ -48,7 +50,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) if err != nil { - return err + return fmt.Errorf("查看仓库失败: %w", err) } return ctx.Output(env) }, @@ -67,14 +69,14 @@ func Shortcuts() []*common.Shortcut { {Name: "private", Usage: "Make repository private (true/false)", Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { - name, err := ctx.RequireArg("name") + name, err := ctx.RequireArg("name", `--name "my-project"`) if err != nil { return err } // Get current user login for the create path userEnv, err := ctx.CallAPI("GET", "/users/me", nil) if err != nil { - return fmt.Errorf("failed to get current user: %w", err) + return fmt.Errorf("获取当前用户信息失败: %w", err) } userData, _ := userEnv.Data.(map[string]interface{}) login, _ := userData["login"].(string) @@ -95,7 +97,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body) if err != nil { - return err + return fmt.Errorf("创建仓库失败: %w", err) } return ctx.Output(env) }, @@ -116,7 +118,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", nil) if err != nil { - return err + return fmt.Errorf("Fork 仓库失败: %w", err) } return ctx.Output(env) }, @@ -137,7 +139,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil) if err != nil { - return err + return fmt.Errorf("删除仓库失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index 1c7b89a..c147c71 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -18,14 +18,17 @@ func Shortcuts() []*common.Shortcut { {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { - keyword, _ := ctx.RequireArg("keyword") + keyword, err := ctx.RequireArg("keyword", "--keyword my-project") + 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", "/projects", q) if err != nil { - return err + return fmt.Errorf("搜索仓库失败: %w", err) } return ctx.Output(env) }, @@ -39,14 +42,17 @@ func Shortcuts() []*common.Shortcut { {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { - keyword, _ := ctx.RequireArg("keyword") + keyword, err := ctx.RequireArg("keyword", "--keyword zhangsan") + 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", "/users/list", q) if err != nil { - return err + return fmt.Errorf("搜索用户失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index cfcaa2a..a59eae4 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -14,7 +14,7 @@ func Shortcuts() []*common.Shortcut { Run: func(ctx *common.RuntimeContext) error { env, err := ctx.CallAPI("GET", "/users/me", nil) if err != nil { - return err + return fmt.Errorf("获取当前用户信息失败: %w", err) } return ctx.Output(env) }, @@ -26,13 +26,13 @@ func Shortcuts() []*common.Shortcut { {Name: "login", Short: "l", Usage: "User login name", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - login, err := ctx.RequireArg("login") + login, err := ctx.RequireArg("login", "--login zhangsan") if err != nil { return err } env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil) if err != nil { - return err + return fmt.Errorf("查看用户信息失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go new file mode 100644 index 0000000..10fcbe5 --- /dev/null +++ b/shortcuts/webhook/webhook.go @@ -0,0 +1,330 @@ +package webhook + +import ( + "fmt" + "net/url" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// 支持的Webhook事件类型 +var supportedEvents = []string{ + "push", + "pull_request", + "issue", + "issue_assign", + "issue_comment", + "pull_request_assign", + "pull_request_comment", + "merge_request", + "repository", + "branch", + "tag", +} + +func isEventSupported(event string) bool { + for _, supported := range supportedEvents { + if event == supported { + return true + } + } + return false +} + +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 +} + +// webhookRepoPath returns the webhook API path prefix: /v1/{owner}/{repo} +// Note: BaseURL already includes /api prefix +func webhookRepoPath(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 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) + }, + }, + { + 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 (comma-separated), e.g., push,pull_request,issue", Default: "push"}, + {Name: "active", Usage: "Webhook active status (true/false)", 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 fmt.Errorf("no valid events specified. Supported events: %s", strings.Join(supportedEvents, ", ")) + } + + payload := map[string]interface{}{ + "url": webhookURL, + "http_method": "POST", + "active": true, + "content_type": "json", + } + + if len(events) > 0 { + payload["events"] = events + } else { + payload["events"] = []string{"push"} + } + + 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) + }, + }, + { + 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 (comma-separated)"}, + {Name: "active", Usage: "Webhook active status (true/false)"}, + {Name: "content_type", Usage: "Content type (json/form)"}, + {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 + } + + webhookID, err := ctx.RequireArg("id", "--id 1") + if err != nil { + return err + } + + payload := map[string]interface{}{ + "http_method": "POST", + "active": true, + "content_type": "json", + } + + // 如果用户没有提供URL,获取当前webhook的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) + } + webhookData, ok := getEnv.Data.(map[string]interface{}) + if !ok { + return fmt.Errorf("failed to parse webhook data") + } + currentURL, ok := webhookData["url"].(string) + if !ok || currentURL == "" { + return fmt.Errorf("failed to get current webhook URL") + } + webhookURL = currentURL + } + payload["url"] = webhookURL + + if events := ctx.Arg("events"); events != "" { + validEvents := parseEvents(events) + if len(validEvents) == 0 { + return fmt.Errorf("no valid events specified. Supported events: %s", strings.Join(supportedEvents, ", ")) + } + 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 + } + + + env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), payload) + if err != nil { + return fmt.Errorf("更新 Webhook 失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a webhook", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + webhookID, err := ctx.RequireArg("id", "--id 1") + if err != nil { + return err + } + + _, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil) + if delErr != nil { + // 验证是否真的删除成功(类似release的处理) + _, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil) + if viewErr != nil { + // 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(map[string]interface{}{ + "message": "Webhook deleted successfully", + }, nil)) + }, + }, + { + Name: "test", + Description: "Test a webhook delivery (send a ping event)", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "event", Short: "e", Usage: "Event type to test (default: push)", Default: "push"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + webhookID, err := ctx.RequireArg("id", "--id 1") + if err != nil { + return err + } + + eventType := ctx.Arg("event") + if !isEventSupported(eventType) { + return fmt.Errorf("unsupported event type: %s. Supported events: %s", eventType, strings.Join(supportedEvents, ", ")) + } + + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil) + if err != nil { + return fmt.Errorf("测试 Webhook 失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "info", + Description: "Show webhook details", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + webhookID, err := ctx.RequireArg("id", "--id 1") + if err != nil { + return err + } + + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil) + if err != nil { + return fmt.Errorf("查看 Webhook 详情失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + 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)) + }, + }, + } +} + +func getEventDescription(event string) string { + descriptions := map[string]string{ + "push": "Code push events", + "pull_request": "Pull request events", + "issue": "Issue events", + "issue_assign": "Issue assignment events", + "issue_comment": "Issue comment events", + "pull_request_assign": "Pull request assignment events", + "pull_request_comment":"Pull request comment events", + "merge_request": "Merge request events", + "repository": "Repository events", + "branch": "Branch creation/deletion events", + "tag": "Tag creation/deletion events", + } + if desc, ok := descriptions[event]; ok { + return desc + } + return "Custom event" +} diff --git a/shortcuts/webhook/webhook_test.go b/shortcuts/webhook/webhook_test.go new file mode 100644 index 0000000..a2f6305 --- /dev/null +++ b/shortcuts/webhook/webhook_test.go @@ -0,0 +1,243 @@ +package webhook + +import ( + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestIsEventSupported(t *testing.T) { + tests := []struct { + name string + event string + expected bool + }{ + { + name: "supported event - push", + event: "push", + expected: true, + }, + { + name: "supported event - pull_request", + event: "pull_request", + expected: true, + }, + { + name: "supported event - issue", + event: "issue", + expected: true, + }, + { + name: "unsupported event", + event: "unsupported_event", + expected: false, + }, + { + name: "empty event", + event: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isEventSupported(tt.event) + if result != tt.expected { + t.Errorf("isEventSupported(%q) = %v, want %v", tt.event, result, tt.expected) + } + }) + } +} + +func TestParseEvents(t *testing.T) { + tests := []struct { + name string + events string + expected []string + }{ + { + name: "single event", + events: "push", + expected: []string{"push"}, + }, + { + name: "multiple events", + events: "push,pull_request,issue", + expected: []string{"push", "pull_request", "issue"}, + }, + { + name: "events with spaces", + events: "push, pull_request, issue", + expected: []string{"push", "pull_request", "issue"}, + }, + { + name: "mixed valid and invalid events", + events: "push,invalid_event,pull_request", + expected: []string{"push", "pull_request"}, + }, + { + name: "empty string - default to push", + events: "", + expected: []string{"push"}, + }, + { + name: "all invalid events", + events: "invalid1,invalid2", + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseEvents(tt.events) + if len(result) != len(tt.expected) { + t.Errorf("parseEvents(%q) returned %d events, want %d", tt.events, len(result), len(tt.expected)) + return + } + for i, event := range result { + if event != tt.expected[i] { + t.Errorf("parseEvents(%q)[%d] = %q, want %q", tt.events, i, event, tt.expected[i]) + } + } + }) + } +} + +func TestGetEventDescription(t *testing.T) { + tests := []struct { + name string + event string + expected string + }{ + { + name: "push event description", + event: "push", + expected: "Code push events", + }, + { + name: "pull_request event description", + event: "pull_request", + expected: "Pull request events", + }, + { + name: "issue event description", + event: "issue", + expected: "Issue events", + }, + { + name: "unknown event description", + event: "unknown_event", + expected: "Custom event", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getEventDescription(tt.event) + if result != tt.expected { + t.Errorf("getEventDescription(%q) = %q, want %q", tt.event, result, tt.expected) + } + }) + } +} + +func TestShortcuts(t *testing.T) { + shortcuts := Shortcuts() + + if len(shortcuts) == 0 { + t.Fatal("Shortcuts() returned empty slice") + } + + // 验证所有必需的shortcuts都存在 + expectedShortcuts := []string{ + "list", "create", "update", "delete", "test", "info", "events", + } + + shortcutNames := make(map[string]bool) + for _, sc := range shortcuts { + shortcutNames[sc.Name] = true + } + + for _, expected := range expectedShortcuts { + if !shortcutNames[expected] { + t.Errorf("Missing shortcut: %s", expected) + } + } + + // 验证每个shortcut的基本属性 + for _, sc := range shortcuts { + if sc.Name == "" { + t.Error("Shortcut has empty Name") + } + if sc.Description == "" { + t.Errorf("Shortcut %q has empty Description", sc.Name) + } + if sc.Run == nil { + t.Errorf("Shortcut %q has nil Run function", sc.Name) + } + } +} + +// TestWebhookEventsList tests the events shortcut to ensure it returns valid event information +func TestWebhookEventsList(t *testing.T) { + shortcuts := Shortcuts() + var eventsShortcut *common.Shortcut + for _, sc := range shortcuts { + if sc.Name == "events" { + eventsShortcut = sc + break + } + } + + if eventsShortcut == nil { + t.Fatal("Events shortcut not found") + } + + // 验证所有支持的事件都有描述 + for _, event := range supportedEvents { + desc := getEventDescription(event) + if desc == "" { + t.Errorf("Event %q has empty description", event) + } + } +} + +// TestEventValidationIntegration tests event validation in an integrated manner +func TestEventValidationIntegration(t *testing.T) { + // 测试所有支持的事件都能被正确识别 + for _, event := range supportedEvents { + if !isEventSupported(event) { + t.Errorf("Supported event %q is not recognized by isEventSupported", event) + } + // 确保描述不为空 + desc := getEventDescription(event) + if desc == "" { + t.Errorf("Event %q has empty description", event) + } + } + + // 测试解析包含所有支持的事件字符串 + allEvents := strings.Join(supportedEvents, ",") + parsed := parseEvents(allEvents) + if len(parsed) != len(supportedEvents) { + t.Errorf("Parsing all events returned %d results, expected %d", len(parsed), len(supportedEvents)) + } +} + +// BenchmarkParseEvents benchmarks the event parsing function +func BenchmarkParseEvents(b *testing.B) { + eventsStr := "push,pull_request,issue,issue_assign,issue_comment,pull_request_assign,pull_request_comment" + for i := 0; i < b.N; i++ { + parseEvents(eventsStr) + } +} + +// BenchmarkIsEventSupported benchmarks the event validation function +func BenchmarkIsEventSupported(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, event := range supportedEvents { + isEventSupported(event) + } + } +} diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index 992d42e..31777eb 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -144,7 +144,7 @@ func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (s q.Set("pageName", pageName) env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q) if err != nil { - return "", err + return "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err) } data, ok := env.Data.(map[string]interface{}) if !ok { @@ -454,7 +454,7 @@ func Shortcuts() []*common.Shortcut { q.Set("projectId", projectID) env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("wikiPages"), q) if err != nil { - return err + return fmt.Errorf("获取 Wiki 页面列表失败: %w", err) } cleanWikiList(env) return ctx.Output(env) @@ -470,7 +470,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title, err := ctx.RequireArg("title") + title, err := ctx.RequireArg("title", `--title "Home Page"`) if err != nil { return err } @@ -485,7 +485,7 @@ func Shortcuts() []*common.Shortcut { q.Set("pageName", title) env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q) if err != nil { - return err + return fmt.Errorf("查看 Wiki 页面失败: %w", err) } return outputWithDecodedContent(ctx, env) }, @@ -508,7 +508,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title, err := ctx.RequireArg("title") + title, err := ctx.RequireArg("title", `--title "Home Page"`) if err != nil { return err } @@ -536,7 +536,7 @@ func Shortcuts() []*common.Shortcut { env, err := callWikiAPI(ctx, "POST", wikiPath("createWiki"), body) if err != nil { - return err + return fmt.Errorf("创建 Wiki 页面失败: %w", err) } return ctx.Output(env) }, @@ -561,7 +561,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title, err := ctx.RequireArg("title") + title, err := ctx.RequireArg("title", `--title "Home Page"`) if err != nil { return err } @@ -614,7 +614,7 @@ func Shortcuts() []*common.Shortcut { env, err := callWikiAPI(ctx, "PUT", wikiPath("updateWiki"), body) if err != nil { - return err + return fmt.Errorf("更新 Wiki 页面失败: %w", err) } return ctx.Output(env) }, @@ -634,7 +634,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title, err := ctx.RequireArg("title") + title, err := ctx.RequireArg("title", `--title "Home Page"`) if err != nil { return err } @@ -664,7 +664,7 @@ func Shortcuts() []*common.Shortcut { "message": "Wiki page deleted successfully", }) } - return delErr + return fmt.Errorf("删除 Wiki 页面失败: %w", delErr) } return ctx.OutputData(map[string]string{ "message": "Wiki page deleted successfully", diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go new file mode 100644 index 0000000..6304f48 --- /dev/null +++ b/shortcuts/wiki/wiki_test.go @@ -0,0 +1,236 @@ +package wiki + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func resetProjectIDCache() { + projectIDCache = sync.Map{} +} + +func newMockServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("writeJSON: %v", err) + } +} + +// ---- unwrapGatewayResponse tests ---- + +func TestUnwrapGatewayResponse_Success(t *testing.T) { + env := output.SuccessEnvelope(map[string]interface{}{ + "code": float64(200), + "data": map[string]interface{}{"id": float64(1)}, + }, nil) + + result, err := unwrapGatewayResponse(env) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, ok := result.Data.(map[string]interface{}) + if !ok { + t.Fatal("expected map data") + } + if data["id"] != float64(1) { + t.Fatalf("got %v, want 1", data["id"]) + } +} + +func TestUnwrapGatewayResponse_Code201(t *testing.T) { + env := output.SuccessEnvelope(map[string]interface{}{ + "code": float64(201), + "data": "ok", + }, nil) + + result, err := unwrapGatewayResponse(env) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Data != "ok" { + t.Fatalf("got %v, want ok", result.Data) + } +} + +func TestUnwrapGatewayResponse_BusinessError(t *testing.T) { + env := output.SuccessEnvelope(map[string]interface{}{ + "code": float64(500), + "msg": "内部错误", + }, nil) + + _, err := unwrapGatewayResponse(env) + if err == nil { + t.Fatal("expected error, got nil") + } + if err.Error() != "[500] 内部错误" { + t.Fatalf("got %q, want %q", err.Error(), "[500] 内部错误") + } +} + +func TestUnwrapGatewayResponse_NoDataField(t *testing.T) { + env := output.SuccessEnvelope(map[string]interface{}{ + "code": float64(200), + }, nil) + + result, err := unwrapGatewayResponse(env) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should return original envelope since no "data" field to extract + if result != env { + t.Fatal("expected original envelope when no data field") + } +} + +func TestUnwrapGatewayResponse_NonMapData(t *testing.T) { + env := output.SuccessEnvelope("plain text", nil) + + result, err := unwrapGatewayResponse(env) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Data != "plain text" { + t.Fatalf("got %v, want plain text", result.Data) + } +} + +// ---- resolveProjectID tests ---- + +func TestResolveProjectID_Success(t *testing.T) { + resetProjectIDCache() + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/owner1/repo1/detail.json" { + writeJSON(t, w, map[string]interface{}{"project_id": float64(123)}) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner1", + Repo: "repo1", + } + pid, err := resolveProjectID(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pid != "123" { + t.Fatalf("got %q, want %q", pid, "123") + } +} + +func TestResolveProjectID_Float64(t *testing.T) { + resetProjectIDCache() + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/owner2/repo2/detail.json" { + writeJSON(t, w, map[string]interface{}{"project_id": float64(456)}) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner2", + Repo: "repo2", + } + pid, err := resolveProjectID(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pid != "456" { + t.Fatalf("got %q, want %q", pid, "456") + } +} + +func TestResolveProjectID_CacheHit(t *testing.T) { + resetProjectIDCache() + callCount := 0 + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + callCount++ + if r.URL.Path == "/owner3/repo3/detail.json" { + writeJSON(t, w, map[string]interface{}{"project_id": float64(789)}) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner3", + Repo: "repo3", + } + + pid1, err := resolveProjectID(ctx) + if err != nil { + t.Fatalf("first call: %v", err) + } + if pid1 != "789" { + t.Fatalf("first call: got %q, want %q", pid1, "789") + } + + pid2, err := resolveProjectID(ctx) + if err != nil { + t.Fatalf("second call: %v", err) + } + if pid2 != "789" { + t.Fatalf("second call: got %q, want %q", pid2, "789") + } + + if callCount != 1 { + t.Fatalf("API called %d times, want 1 (cache miss)", callCount) + } +} + +func TestResolveProjectID_MissingProjectID(t *testing.T) { + resetProjectIDCache() + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]interface{}{"name": "no-project-id"}) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner4", + Repo: "repo4", + } + _, err := resolveProjectID(ctx) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestResolveProjectID_APIError(t *testing.T) { + resetProjectIDCache() + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner5", + Repo: "repo5", + } + _, err := resolveProjectID(ctx) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/skills/gitlink-branch/references/branch-create.md b/skills/gitlink-branch/references/branch-create.md new file mode 100644 index 0000000..dbf935f --- /dev/null +++ b/skills/gitlink-branch/references/branch-create.md @@ -0,0 +1,100 @@ +# branch +create + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +从现有分支或 commit 创建新分支。 + +## 命令 + +```bash +# 从 master 创建分支 +gitlink-cli branch +create --name feature/new-feature + +# 从指定分支创建 +gitlink-cli branch +create --name hotfix/bug-123 --from develop + +# 从指定 commit 创建 +gitlink-cli branch +create --name feature/x --from abc123def + +# 指定仓库创建分支 +gitlink-cli branch +create --name feature/x --owner someone --repo myrepo +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name, -n` | **是** | 新分支名称 | +| `--from, -f` | 否 | 源分支或 commit(默认 `master`) | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +POST /v1/{owner}/{repo}/branches +Body: { "new_branch_name": name, "old_branch_name": from } +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "name": "feature/new-feature", + "commit_id": "abc123...", + "commit_message": "Create feature branch", + "committed_time": "2026-01-01T00:00:00Z" + } +} +``` + +## Workflow + +1. **Confirm** the branch name and source branch with the user. +2. **Execute** `gitlink-cli branch +create --name --from `. +3. **Report** the created branch information. + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent before executing. + +## Use Cases + +- **功能开发**:为新功能创建独立分支 +- **Bug 修复**:从稳定分支创建 hotfix 分支 +- **实验性功能**:创建实验分支进行尝试 +- **版本发布**:为发布版本创建分支 + +## Best Practices + +- **命名规范**:使用有意义的分支名,如 `feature/xxx`、`hotfix/xxx`、`release/xxx` +- **源分支选择**:通常从 `develop` 或 `master` 创建功能分支 +- **分支描述**:创建后可以添加描述说明分支用途 + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 仓库不存在 | 检查 `--owner` 和 `--repo` 是否正确 | +| `409` | 分支已存在 | 使用不同的分支名或删除现有分支 | +| `404` | 源分支不存在 | 确认 `--from` 指定的分支或 commit 存在 | + +## Tips + +- 默认从 `master` 分支创建,如需从其他分支创建需明确指定 +- 分支名支持 `/` 分隔符,便于组织分支结构 +- 创建后可以立即使用 `gitlink-cli branch +list` 验证 + +## References + +- [branch +list](branch-list.md) — 列出分支 +- [branch +delete](branch-delete.md) — 删除分支 +- [gitlink-branch](../SKILL.md) — 分支操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-branch/references/branch-delete.md b/skills/gitlink-branch/references/branch-delete.md new file mode 100644 index 0000000..46b3e84 --- /dev/null +++ b/skills/gitlink-branch/references/branch-delete.md @@ -0,0 +1,119 @@ +# branch +delete + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +删除指定的分支。**此操作不可逆,请谨慎使用。** + +## 命令 + +```bash +# 删除分支 +gitlink-cli branch +delete --name feature/old-feature + +# 指定仓库删除分支 +gitlink-cli branch +delete --name feature/old-feature --owner someone --repo myrepo + +# 删除带路径的分支 +gitlink-cli branch +delete --name feature/my-feature +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name, -n` | **是** | 要删除的分支名称 | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +POST /v1/{owner}/{repo}/branches/delete +Body: { "branch_name": name } +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "message": "Branch deleted successfully", + "branch_name": "feature/old-feature" + } +} +``` + +## Workflow + +1. **Confirm** the user really wants to delete this branch (emphasize this is **irreversible**). +2. **Check** if the branch exists using `branch +list` if needed. +3. **Execute** `gitlink-cli branch +delete --name `. +4. **Report** the deletion result. + +> [!CAUTION] +> This is a **Destructive Operation** — confirm user intent before executing. This action **cannot be undone**. + +## Use Cases + +- **清理已完成的功能分支**:功能合并后删除功能分支 +- **清理错误的分支**:删除创建错误或不再需要的分支 +- **维护分支整洁**:定期清理无用分支保持仓库整洁 + +## Warnings + +- ⚠️ **不可逆操作**:删除分支后无法恢复 +- ⚠️ **受保护分支**:无法删除受保护的分支 +- ⚠️ **默认分支**:无法删除默认分支(通常是 master) +- ⚠️ **未合并更改**:删除包含未合并更改的分支可能导致代码丢失 + +## Best Practices + +1. **确认合并状态**:删除前确认分支的更改已经合并 +2. **备份重要更改**:如果有重要更改未合并,先备份或合并 +3. **沟通确认**:团队协作时先沟通确认再删除 +4. **使用描述性名称**:避免删除错误的分支 + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 分支不存在 | 使用 `branch +list` 确认分支存在 | +| `403` | 权限不足 | 确认有删除分支的权限 | +| `400` | 受保护分支 | 无法删除受保护的分支 | +| `400` | 默认分支 | 无法删除默认分支 | + +## Safety Checks + +建议在删除前执行以下检查: + +```bash +# 1. 检查分支是否存在 +gitlink-cli branch +list | grep branch-name + +# 2. 确认不是保护分支 +gitlink-cli branch +list --format json | jq '.data.branches[] | select(.name=="branch-name") | .protected' + +# 3. 确认不是默认分支 +gitlink-cli api GET /{owner}/{repo} | jq '.data.default_branch' +``` + +## Tips + +- 删除前建议使用 `gitlink-cli branch +list` 确认分支名称 +- 对于重要分支,建议先检查是否有未合并的 PR +- 团队协作时,删除公共分支前先通知团队成员 + +## References + +- [branch +list](branch-list.md) — 列出分支 +- [branch +create](branch-create.md) — 创建分支 +- [branch +protect](branch-protect.md) — 保护分支 +- [gitlink-branch](../SKILL.md) — 分支操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-branch/references/branch-list.md b/skills/gitlink-branch/references/branch-list.md new file mode 100644 index 0000000..a9eb3db --- /dev/null +++ b/skills/gitlink-branch/references/branch-list.md @@ -0,0 +1,103 @@ +# branch +list + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +列出仓库的所有分支,支持分页查询。 + +## 命令 + +```bash +# 列出当前仓库的分支 +gitlink-cli branch +list + +# 指定仓库并分页 +gitlink-cli branch +list --owner Gitlink --repo forgeplus --page 1 --limit 10 + +# 输出为 JSON +gitlink-cli branch +list --format json + +# 输出为 YAML +gitlink-cli branch +list --format yaml +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--page, -p` | 否 | 页码(默认 `1`) | +| `--limit, -l` | 否 | 每页条数(默认 `20`) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +GET /v1/{owner}/{repo}/branches?page=1&limit=20 +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "branches": [ + { + "name": "master", + "commit_id": "abc123...", + "commit_message": "Initial commit", + "committed_time": "2026-01-01T00:00:00Z", + "is_default": true, + "protected": false + }, + { + "name": "develop", + "commit_id": "def456...", + "commit_message": "Develop branch", + "committed_time": "2026-01-02T00:00:00Z", + "is_default": false, + "protected": true + } + ], + "total_count": 15 + }, + "meta": { + "page": 1, + "limit": 20, + "total_count": 15 + } +} +``` + +## Workflow + +1. **Resolve** owner and repo (from git remote or flags). +2. **Execute** `gitlink-cli branch +list`. +3. **Display** branches in the requested format. + +> [!NOTE] +> This is a **Read Operation** — no confirmation needed. + +## Use Cases + +- **查看可用分支**:在创建 PR 前查看所有分支 +- **检查分支保护状态**:查看哪些分支被保护 +- **分支浏览**:探索仓库的分支结构 +- **自动化脚本**:结合 JSON 格式输出进行批量操作 + +## Tips + +- 使用 `--format json` 可以更好地解析分支信息 +- 分支列表包含保护状态,可以快速识别受保护的分支 +- 支持分页,适合分支较多的仓库 + +## References + +- [branch +create](branch-create.md) — 创建分支 +- [branch +protect](branch-protect.md) — 保护分支 +- [gitlink-branch](../SKILL.md) — 分支操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-branch/references/branch-protect.md b/skills/gitlink-branch/references/branch-protect.md new file mode 100644 index 0000000..f686be9 --- /dev/null +++ b/skills/gitlink-branch/references/branch-protect.md @@ -0,0 +1,142 @@ +# branch +protect + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +设置分支保护规则,防止重要分支被意外修改或删除。 + +## 命令 + +```bash +# 保护分支 +gitlink-cli branch +protect --name main + +# 保护 master 分支 +gitlink-cli branch +protect --name master + +# 指定仓库保护分支 +gitlink-cli branch +protect --name main --owner someone --repo myrepo + +# 保护开发分支 +gitlink-cli branch +protect --name develop +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name, -n` | **是** | 要保护的分支名称 | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +POST /{owner}/{repo}/protected_branches +Body: { "branch_name": name } +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "branch_name": "main", + "protected": true, + "message": "Branch protection enabled successfully" + } +} +``` + +## Workflow + +1. **Confirm** the branch name to protect with the user. +2. **Execute** `gitlink-cli branch +protect --name `. +3. **Report** the protection result. + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent before executing. + +## Use Cases + +- **保护主分支**:保护 `main` 或 `master` 分支,防止直接推送 +- **保护发布分支**:保护 `release` 分支,确保发布版本稳定性 +- **保护开发分支**:保护 `develop` 分支,维护开发主线稳定 +- **合规要求**:满足团队管理或合规性要求 + +## What Protection Means + +分支保护后,以下操作将被限制: +- ✅ **仍可操作**:通过 Pull Request 合并更改 +- ❌ **受限操作**:直接推送代码 +- ❌ **受限操作**:强制推送 +- ❌ **受限操作**:删除分支 +- ❌ **受限操作**:修改历史 + +## Best Practices + +1. **保护关键分支**:至少保护 `main` 和 `develop` 分支 +2. **配合 PR 工作流**:强制通过 PR 进行代码审查 +3. **定期审查**:定期检查和保护重要的分支 +4. **团队协作**:团队协商确定保护策略 + +## Common Protected Branches + +| 分支名 | 用途 | 建议保护 | +|--------|------|----------| +| `main` / `master` | 主分支 | ✅ 强烈建议 | +| `develop` | 开发分支 | ✅ 建议 | +| `release/*` | 发布分支 | ✅ 建议 | +| `hotfix/*` | 紧急修复分支 | ⚠️ 可选 | + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 分支不存在 | 使用 `branch +list` 确认分支存在 | +| `403` | 权限不足 | 确认有管理权限 | +| `409` | 已经被保护 | 分支已经处于保护状态 | + +## Safety Considerations + +- ⚠️ **权限要求**:需要管理员或协作者权限 +- ⚠️ **团队影响**:保护分支影响整个团队的协作流程 +- ⚠️ **CI/CD 集成**:确保 CI/CD 流程兼容保护规则 + +## Tips + +- 保护前先确认分支名称正确 +- 可以使用 `branch +list --format json` 查看分支保护状态 +- 设置保护后,团队成员需要通过 PR 贡献代码 +- 建议在设置保护前通知团队成员 + +## Workflow Example + +典型的分支保护工作流: + +```bash +# 1. 查看分支列表 +gitlink-cli branch +list + +# 2. 确认要保护的分支 +gitlink-cli branch +list --format json | jq '.data.branches[] | select(.name=="main")' + +# 3. 设置分支保护 +gitlink-cli branch +protect --name main + +# 4. 验证保护设置 +gitlink-cli branch +list --format json | jq '.data.branchs[] | select(.name=="main") | .protected' +``` + +## References + +- [branch +unprotect](branch-unprotect.md) — 移除分支保护 +- [branch +list](branch-list.md) — 列出分支并查看保护状态 +- [gitlink-branch](../SKILL.md) — 分支操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-branch/references/branch-unprotect.md b/skills/gitlink-branch/references/branch-unprotect.md new file mode 100644 index 0000000..8a9aa88 --- /dev/null +++ b/skills/gitlink-branch/references/branch-unprotect.md @@ -0,0 +1,164 @@ +# branch +unprotect + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +移除分支保护规则,允许分支被直接修改。 + +## 命令 + +```bash +# 移除分支保护 +gitlink-cli branch +unprotect --name main + +# 指定仓库移除分支保护 +gitlink-cli branch +unprotect --name main --owner someone --repo myrepo + +# 移除开发分支保护 +gitlink-cli branch +unprotect --name develop +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name, -n` | **是** | 要移除保护的分支名称 | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +DELETE /{owner}/{repo}/protected_branches/{branch_name} +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "branch_name": "main", + "protected": false, + "message": "Branch protection removed successfully" + } +} +``` + +## Workflow + +1. **Confirm** the branch name to unprotect with the user. +2. **Execute** `gitlink-cli branch +unprotect --name `. +3. **Report** the unprotection result. + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent before executing. This will allow direct pushes to the branch. + +## Use Cases + +- **紧急修复**:临时允许直接推送紧急修复 +- **分支重组**:调整分支保护策略 +- **迁移工作流**:从 PR 工作流切换到直接推送 +- **权限调整**:根据团队需求调整保护规则 + +## What Unprotection Means + +移除分支保护后,以下操作将被允许: +- ✅ **允许操作**:直接推送代码 +- ✅ **允许操作**:强制推送 +- ✅ **允许操作**:删除分支 +- ✅ **允许操作**:修改历史 + +## Risks and Considerations + +⚠️ **风险提醒**: +- 失去 PR 代码审查机制 +- 可能直接推送到关键分支 +- 增加代码冲突和错误风险 +- 影响代码质量和稳定性 + +## Best Practices + +1. **谨慎使用**:仅在确有需要时移除保护 +2. **临时移除**:考虑临时移除后重新保护 +3. **团队沟通**:移除保护前通知团队成员 +4. **重新保护**:完成操作后及时恢复保护 + +## When to Use + +**适合移除保护的情况:** +- 紧急修复需要快速部署 +- 仓库结构重组或迁移 +- 测试和验证工作流 +- 小团队内部协作 + +**不适合移除保护的情况:** +- 有 PR 审查需求 +- 多人协作的大型项目 +- 需要严格代码质量控制 +- 生产环境的关键分支 + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 分支不存在 | 使用 `branch +list` 确认分支存在 | +| `404` | 未被保护 | 分支当前没有保护规则 | +| `403` | 权限不足 | 确认有管理权限 | +| `400` | 路径问题 | 含 `/` 的分支名可能需要通过 Web 操作 | + +## Limitations + +- ⚠️ **路径限制**:含 `/` 的分支名(如 `feature/my-branch`)可能无法通过 CLI 移除保护 +- ⚠️ **API 限制**:某些特殊分支可能需要通过 Web 页面操作 +- ⚠️ **权限要求**:需要管理员或协作者权限 + +## Safety Workflow + +推荐的移除保护工作流: + +```bash +# 1. 查看当前保护状态 +gitlink-cli branch +list --format json | jq '.data.branches[] | select(.protected)' + +# 2. 确认要移除保护的分支 +gitlink-cli branch +list --format json | jq '.data.branches[] | select(.name=="main") | .protected' + +# 3. 移除分支保护 +gitlink-cli branch +unprotect --name main + +# 4. 验证移除结果 +gitlink-cli branch +list --format json | jq '.data.branches[] | select(.name=="main") | .protected' + +# 5. 完成操作后重新保护 +gitlink-cli branch +protect --name main +``` + +## Tips + +- 移除保护前,建议先检查当前的保护状态 +- 考虑设置定时提醒,确保及时恢复保护 +- 对于重要分支,建议使用 Web UI 确认移除保护 +- 记录移除保护的原因和时间,便于审计 + +## Team Collaboration + +团队协作时的建议: + +1. **提前沟通**:在移除保护前通知所有团队成员 +2. **说明原因**:向团队解释为什么需要移除保护 +3. **时间限制**:设定移除保护的时间限制 +4. **操作文档**:记录移除保护的操作和原因 +5. **及时恢复**:完成操作后立即恢复保护 + +## References + +- [branch +protect](branch-protect.md) — 设置分支保护 +- [branch +list](branch-list.md) — 列出分支并查看保护状态 +- [gitlink-branch](../SKILL.md) — 分支操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-ci/references/ci-list.md b/skills/gitlink-ci/references/ci-list.md new file mode 100644 index 0000000..9c11d54 --- /dev/null +++ b/skills/gitlink-ci/references/ci-list.md @@ -0,0 +1,180 @@ +# ci +builds + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +列出仓库的所有 CI/CD 构建记录,支持分页查询。 + +## 命令 + +```bash +# 查看当前仓库的构建列表 +gitlink-cli ci +builds + +# 指定仓库查看构建 +gitlink-cli ci +builds --owner myuser --repo myrepo + +# 分页查询 +gitlink-cli ci +builds --page 2 --limit 10 + +# 输出为 JSON 格式 +gitlink-cli ci +builds --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--page, -p` | 否 | 页码(默认 `1`) | +| `--limit, -l` | 否 | 每页条数(默认 `20`) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +GET /{owner}/{repo}/builds?page=1&limit=20 +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "builds": [ + { + "id": 42, + "build_number": 42, + "status": "success", + "started_at": "2026-01-01T10:00:00Z", + "duration": 125, + "commit": { + "sha": "abc123...", + "message": "Fix bug in authentication", + "author": "developer@example.com" + }, + "branch": "feature/auth-fix", + "stages": [ + { + "stage_number": 1, + "stage_name": "build", + "status": "success" + }, + { + "stage_number": 2, + "stage_name": "test", + "status": "success" + } + ] + }, + { + "id": 41, + "build_number": 41, + "status": "failed", + "started_at": "2026-01-01T09:30:00Z", + "duration": 45, + "commit": { + "sha": "def456...", + "message": "Add new feature", + "author": "developer@example.com" + }, + "branch": "develop", + "stages": [ + { + "stage_number": 1, + "stage_name": "build", + "status": "failed" + } + ] + } + ], + "total_count": 156 + }, + "meta": { + "page": 1, + "limit": 20, + "total_count": 156 + } +} +``` + +## Workflow + +1. **Resolve** owner and repo (from git remote or flags). +2. **Execute** `gitlink-cli ci +builds`. +3. **Display** builds in the requested format. + +> [!NOTE] +> This is a **Read Operation** — no confirmation needed. + +## Use Cases + +- **构建历史查看**:查看仓库的构建历史和状态 +- **问题排查**:查找失败的构建进行分析 +- **构建监控**:监控 CI/CD 系统的运行状态 +- **自动化脚本**:结合 JSON 格式输出进行构建分析 + +## Build Status + +构建状态类型: + +| 状态 | 说明 | +|------|------| +| `pending` | 等待执行 | +| `running` | 正在执行 | +| `success` | 构建成功 | +| `failed` | 构建失败 | +| `cancelled` | 构建取消 | +| `skipped` | 构建跳过 | + +## Data Analysis + +使用 JSON 输出进行构建分析: + +```bash +# 查看最近10次构建的成功率 +gitlink-cli ci +builds --format json --limit 10 | \ + jq '[.data.builds[] | select(.status=="success")] | length / 10 * 100' + +# 查看失败的构建 +gitlink-cli ci +builds --format json | \ + jq '.data.builds[] | select(.status=="failed")' + +# 查看平均构建时间 +gitlink-cli ci +builds --format json | \ + jq '[.data.builds[].duration] | add / length' +``` + +## Tips + +- 使用 `--format json` 可以更好地解析和分析构建数据 +- 构建列表包含详细的提交信息和分支信息 +- 支持分页,适合构建历史较多的仓库 +- 结合 `ci +logs` 可以深入分析构建失败原因 + +## CI/CD Integration + +结合其他 CI 命令的典型工作流: + +```bash +# 1. 查看构建列表 +gitlink-cli ci +builds + +# 2. 查看失败构建的日志 +gitlink-cli ci +logs --build 42 + +# 3. 重启失败的构建 +gitlink-cli ci +restart --build 42 +``` + +## References + +- [ci +logs](ci-logs.md) — 查看构建日志 +- [ci +restart](ci-restart.md) — 重启构建 +- [ci +stop](ci-stop.md) — 停止构建 +- [gitlink-ci](../SKILL.md) — CI/CD 操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-ci/references/ci-logs.md b/skills/gitlink-ci/references/ci-logs.md new file mode 100644 index 0000000..4599c72 --- /dev/null +++ b/skills/gitlink-ci/references/ci-logs.md @@ -0,0 +1,204 @@ +# ci +logs + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +查看指定 CI 构建的详细日志输出。 + +## 命令 + +```bash +# 查看构建日志 +gitlink-cli ci +logs --build 42 + +# 查看特定阶段的日志 +gitlink-cli ci +logs --build 42 --stage 2 + +# 查看特定步骤的日志 +gitlink-cli ci +logs --build 42 --stage 2 --step 3 + +# 指定仓库查看日志 +gitlink-cli ci +logs --build 42 --owner myuser --repo myrepo +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--build, -b` | **是** | 构建编号 | +| `--stage, -s` | 否 | 阶段编号(默认 `1`) | +| `--step` | 否 | 步骤编号(默认 `1`) | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +GET /{owner}/{repo}/builds/{build}/logs/{stage}/{step} +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "build_number": 42, + "stage_number": 2, + "step_number": 1, + "log_content": "Running tests...\nTest suite started...\n[OK] Test authentication\n[OK] Test database connection\n[FAILED] Test API endpoint\n\nTests completed: 2/3 passed", + "stage_name": "test", + "step_name": "run_tests", + "timestamp": "2026-01-01T10:05:30Z" + } +} +``` + +## Workflow + +1. **Confirm** the build number with the user (can use `ci +builds` to list). +2. **Execute** `gitlink-cli ci +logs --build [--stage ] [--step ]`. +3. **Display** the log content. + +> [!NOTE] +> This is a **Read Operation** — no confirmation needed. + +## Use Cases + +- **问题排查**:查看构建失败的具体原因 +- **性能分析**:分析构建过程中的性能瓶颈 +- **调试输出**:查看代码运行时的调试信息 +- **监控执行**:实时跟踪构建执行状态 + +## CI Pipeline Structure + +典型的 CI/CD 流水线结构: + +``` +Stage 1: Build + ├── Step 1: Install dependencies + ├── Step 2: Build application + └── Step 3: Run linters + +Stage 2: Test + ├── Step 1: Run unit tests + ├── Step 2: Run integration tests + └── Step 3: Generate coverage report + +Stage 3: Deploy + ├── Step 1: Build deployment package + └── Step 2: Deploy to server +``` + +## Log Analysis + +日志分析技巧: + +```bash +# 查看构建日志 +gitlink-cli ci +logs --build 42 --stage 2 --step 1 + +# 结合 grep 过滤关键错误 +gitlink-cli ci +logs --build 42 --format json | \ + jq '.data.log_content' | grep "ERROR" + +# 查看完整日志流 +gitlink-cli ci +logs --build 42 --format json | \ + jq -r '.data.log_content' +``` + +## Stage and Step Navigation + +查看不同阶段的日志: + +```bash +# Stage 1: Build stage +gitlink-cli ci +logs --build 42 --stage 1 --step 1 + +# Stage 2: Test stage +gitlink-cli ci +logs --build 42 --stage 2 --step 1 + +# Stage 3: Deploy stage +gitlink-cli ci +logs --build 42 --stage 3 --step 1 +``` + +## Common Log Patterns + +常见日志模式: + +| 模式 | 含义 | +|------|------| +| `[ERROR]` | 错误信息 | +| `[FAILED]` | 测试或步骤失败 | +| `[WARN]` | 警告信息 | +| `[OK]` | 操作成功 | +| `Running...` | 正在执行 | +| `Completed` | 执行完成 | + +## Tips + +- 先使用 `ci +builds` 确认构建编号 +- 构建通常包含多个阶段,需要指定正确的阶段编号 +- 日志内容可能很长,建议使用 `--format json` 便于解析 +- 结合构建状态可以快速定位问题 + +## Troubleshooting Workflow + +典型的故障排查工作流: + +```bash +# 1. 查看构建列表,找到失败的构建 +gitlink-cli ci +builds | grep "failed" + +# 2. 查看失败构建的详细状态 +gitlink-cli ci +builds --build 42 --format json | \ + jq '.data.builds[] | .stages[]' + +# 3. 查看失败阶段的日志 +gitlink-cli ci +logs --build 42 --stage 2 --step 1 + +# 4. 根据日志信息修复问题 + +# 5. 重启构建 +gitlink-cli ci +restart --build 42 +``` + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 构建不存在 | 检查构建编号是否正确 | +| `404` | 阶段或步骤不存在 | 确认阶段和步骤编号 | +| `403` | 权限不足 | 确认有查看该仓库构建的权限 | + +## Advanced Usage + +高级用法示例: + +```bash +# 导出构建日志到文件 +gitlink-cli ci +logs --build 42 --format json | \ + jq -r '.data.log_content' > build_42_logs.txt + +# 分析日志中的错误模式 +gitlink-cli ci +logs --build 42 --format json | \ + jq -r '.data.log_content' | grep -c "ERROR" + +# 查看所有阶段的日志(循环) +for stage in {1..3}; do + echo "=== Stage $stage ===" + gitlink-cli ci +logs --build 42 --stage $stage --step 1 +done +``` + +## References + +- [ci +builds](ci-list.md) — 查看构建列表 +- [ci +restart](ci-restart.md) — 重启构建 +- [gitlink-ci](../SKILL.md) — CI/CD 操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-ci/references/ci-restart.md b/skills/gitlink-ci/references/ci-restart.md new file mode 100644 index 0000000..a169745 --- /dev/null +++ b/skills/gitlink-ci/references/ci-restart.md @@ -0,0 +1,201 @@ +# ci +restart + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +重新启动失败的或取消的 CI 构建。 + +## 命令 + +```bash +# 重启构建 +gitlink-cli ci +restart --build 42 + +# 指定仓库重启构建 +gitlink-cli ci +restart --build 42 --owner myuser --repo myrepo + +# 重启失败的构建(JSON 输出) +gitlink-cli ci +restart --build 42 --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--build, -b` | **是** | 构建编号 | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +POST /{owner}/{repo}/builds/{build}/restart +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "old_build_number": 42, + "new_build_number": 43, + "status": "pending", + "message": "Build restarted successfully", + "triggered_at": "2026-01-01T11:00:00Z" + } +} +``` + +## Workflow + +1. **Confirm** the build number to restart with the user. +2. **Check** the current build status (optional, using `ci +builds`). +3. **Execute** `gitlink-cli ci +restart --build `. +4. **Report** the restart result and new build number. + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent before executing. + +## Use Cases + +- **失败重试**:构建因临时问题失败后重试 +- **取消后重新执行**:构建被取消后需要重新执行 +- **代码修复后验证**:修复代码后重新验证构建 +- **环境问题恢复**:CI 环境问题恢复后重新构建 + +## When to Restart + +**适合重启的情况:** +- ✅ 构建因临时网络问题失败 +- ✅ 依赖服务暂时不可用 +- ✅ 代码修复后需要重新验证 +- ✅ CI 环境问题已解决 + +**不适合重启的情况:** +- ❌ 代码存在严重错误 +- ❌ 测试用例本身有问题 +- ❌ 构建配置需要修改 +- ❌ 依赖库版本不兼容 + +## Restart Behavior + +重启构建的行为特点: + +| 方面 | 说明 | +|------|------| +| **新构建编号** | 重启会创建新的构建编号 | +| **相同代码** | 使用相同的提交代码 | +| **相同环境** | 使用相同的构建环境 | +| **独立日志** | 新构建有独立的日志记录 | +| **状态继承** | 不会继承原构建的状态 | + +## Best Practices + +1. **查看日志**:重启前先查看失败原因 +2. **修复问题**:如果是代码问题,先修复再重启 +3. **监控新构建**:重启后监控新构建的执行状态 +4. **资源考虑**:频繁重启会消耗 CI 资源 + +## Troubleshooting Workflow + +典型的故障排查和重启流程: + +```bash +# 1. 查看构建列表,找到失败的构建 +gitlink-cli ci +builds | grep "failed" + +# 2. 查看失败构建的详细状态 +gitlink-cli ci +builds --format json | \ + jq '.data.builds[] | select(.build_number==42)' + +# 3. 查看失败阶段的日志 +gitlink-cli ci +logs --build 42 --stage 2 --step 1 + +# 4. 分析日志,确定失败原因 + +# 5. 如果是临时问题,重启构建 +gitlink-cli ci +restart --build 42 + +# 6. 如果是代码问题,修复后重启 +# (先修复代码,然后) +gitlink-cli ci +restart --build 42 +``` + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 构建不存在 | 检查构建编号是否正确 | +| `400` | 构建正在运行 | 正在运行的构建无法重启 | +| `403` | 权限不足 | 确认有操作该仓库构建的权限 | +| `429` | 重启次数过多 | 短时间内重启次数过多,等待后重试 | + +## Pre-Restart Checklist + +重启前检查清单: + +- [ ] 确认构建编号正确 +- [ ] 查看失败日志,了解失败原因 +- [ ] 确认问题已解决(如果是代码问题) +- [ ] 检查 CI 系统状态 +- [ ] 确认有足够的 CI 资源 +- [ ] 考虑是否需要修改构建配置 + +## Post-Restart Actions + +重启后的后续操作: + +```bash +# 1. 重启构建 +gitlink-cli ci +restart --build 42 + +# 2. 获取新构建编号 +gitlink-cli ci +restart --build 42 --format json | \ + jq '.data.new_build_number' + +# 3. 监控新构建状态 +gitlink-cli ci +builds --format json | \ + jq '.data.builds[0]' + +# 4. 查看新构建的日志(如需要) +gitlink-cli ci +logs --build 43 --stage 1 --step 1 +``` + +## Team Collaboration + +团队协作时的建议: + +1. **沟通确认**:重启构建前通知相关团队成员 +2. **记录原因**:记录重启的原因和时间 +3. **状态更新**:及时更新构建状态给团队 +4. **结果分享**:重启完成后分享结果 + +## Tips + +- 重启会创建新的构建编号,原构建历史仍保留 +- 重启前建议先查看日志,确认问题性质 +- 对于重复失败的情况,建议先修复根本原因 +- 可以通过 `ci +builds` 查看重启后的新构建状态 + +## Cost Considerations + +使用注意事项: + +- ⚠️ **资源消耗**:每次重启都会消耗 CI 资源 +- ⚠️ **时间成本**:重新执行完整的构建流程 +- ⚠️ **排队时间**:新构建可能需要排队等待 +- ⚠️ **频繁重启**:避免无意义的频繁重启 + +## References + +- [ci +builds](ci-list.md) — 查看构建列表 +- [ci +logs](ci-logs.md) — 查看构建日志 +- [ci +stop](ci-stop.md) — 停止构建 +- [gitlink-ci](../SKILL.md) — CI/CD 操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-ci/references/ci-stop.md b/skills/gitlink-ci/references/ci-stop.md new file mode 100644 index 0000000..af7f7e6 --- /dev/null +++ b/skills/gitlink-ci/references/ci-stop.md @@ -0,0 +1,245 @@ +# ci +stop + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +停止正在运行的 CI 构建。 + +## 命令 + +```bash +# 停止构建 +gitlink-cli ci +stop --build 42 + +# 指定仓库停止构建 +gitlink-cli ci +stop --build 42 --owner myuser --repo myrepo + +# 停止构建(JSON 输出) +gitlink-cli ci +stop --build 42 --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--build, -b` | **是** | 构建编号 | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +DELETE /{owner}/{repo}/builds/{build}/stop +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "build_number": 42, + "status": "cancelled", + "message": "Build stopped successfully", + "stopped_at": "2026-01-01T11:30:00Z", + "duration": 180 + } +} +``` + +## Workflow + +1. **Confirm** the build number to stop with the user. +2. **Check** the current build status (ensure it's running). +3. **Execute** `gitlink-cli ci +stop --build `. +4. **Report** the stop result. + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent before executing. This will terminate a running build. + +## Use Cases + +- **错误停止**:构建出现错误需要立即停止 +- **资源释放**:释放 CI 资源给其他构建 +- **配置错误**:构建配置错误需要停止 +- **测试中止**:测试过程中发现问题需要中止 +- **时间限制**:构建时间过长需要停止 + +## When to Stop + +**适合停止的情况:** +- ✅ 构建明显出现错误,继续执行无意义 +- ✅ 发现严重bug,需要立即停止 +- ✅ 构建配置错误,需要修改后重新执行 +- ✅ 误触发构建,需要立即取消 +- ✅ 构建时间过长,超出预期 + +**不适合停止的情况:** +- ❌ 构建接近完成 +- ❌ 仅为节省时间而停止正常构建 +- ❌ 不确定构建是否有问题 + +## Stop Behavior + +停止构建的行为特点: + +| 方面 | 说明 | +|------|------| +| **立即停止** | 通常会立即中断构建执行 | +| **状态变更** | 构建状态变为 `cancelled` | +| **资源释放** | 释放 CI 计算资源 | +| **日志保留** | 已执行的日志会保留 | +| **不可恢复** | 停止的构建无法恢复执行 | + +## Safety Considerations + +停止构建前考虑: + +- ⚠️ **进度损失**:已执行的进度会丢失 +- ⚠️ **资源浪费**:已消耗的资源无法回收 +- ⚠️ **团队影响**:可能影响其他依赖此构建的任务 +- ⚠️ **重新执行**:需要重新启动完整的构建 + +## Best Practices + +1. **确认状态**:停止前确认构建确实在运行 +2. **评估影响**:考虑停止对其他流程的影响 +3. **记录原因**:记录停止构建的原因 +4. **后续处理**:计划停止后的后续操作 + +## Stop Workflow + +典型的停止构建工作流: + +```bash +# 1. 查看运行中的构建 +gitlink-cli ci +builds --format json | \ + jq '.data.builds[] | select(.status=="running")' + +# 2. 确认要停止的构建编号 +gitlink-cli ci +builds | grep "running" + +# 3. 停止构建 +gitlink-cli ci +stop --build 42 + +# 4. 验证停止状态 +gitlink-cli ci +builds --format json | \ + jq '.data.builds[] | select(.build_number==42) | .status' +``` + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 构建不存在 | 检查构建编号是否正确 | +| `400` | 构建已完成 | 构建已经完成或停止,无法再停止 | +| `403` | 权限不足 | 确认有操作该仓库构建的权限 | +| `409` | 构建已完成 | 构建已经自然结束 | + +## Pre-Stop Checklist + +停止前检查清单: + +- [ ] 确认构建编号正确 +- [ ] 确认构建正在运行 +- [ ] 评估停止的影响范围 +- [ ] 确认停止原因合理 +- [ ] 考虑后续处理方案 +- [ ] 通知相关团队成员 + +## Post-Stop Actions + +停止后的后续操作: + +```bash +# 1. 停止构建 +gitlink-cli ci +stop --build 42 + +# 2. 查看停止状态 +gitlink-cli ci +builds --format json | \ + jq '.data.builds[] | select(.build_number==42)' + +# 3. 查看已执行的日志 +gitlink-cli ci +logs --build 42 --stage 1 --step 1 + +# 4. 根据需要重启构建 +gitlink-cli ci +restart --build 42 +``` + +## Common Scenarios + +常见使用场景: + +### 场景1:发现严重错误 +```bash +# 查看运行中的构建 +gitlink-cli ci +builds | grep "running" + +# 查看日志发现严重错误 +gitlink-cli ci +logs --build 42 --stage 2 --step 1 + +# 立即停止构建 +gitlink-cli ci +stop --build 42 +``` + +### 场景2:误触发构建 +```bash +# 发现误触发了构建 +gitlink-cli ci +builds | grep "running" + +# 立即停止误触发的构建 +gitlink-cli ci +stop --build 42 +``` + +### 场景3:配置错误 +```bash +# 发现构建配置错误 +gitlink-cli ci +logs --build 42 --stage 1 --step 1 + +# 停止当前构建 +gitlink-cli ci +stop --build 42 + +# 修复配置后重新构建 +# (修复配置) +gitlink-cli ci +restart --build 42 +``` + +## Team Collaboration + +团队协作时的建议: + +1. **及时通知**:停止构建前通知相关团队成员 +2. **说明原因**:向团队解释为什么需要停止构建 +3. **状态同步**:更新项目管理系统中的构建状态 +4. **后续计划**:告知团队停止后的处理计划 + +## Tips + +- 停止前建议先确认构建状态,避免重复操作 +- 查看构建日志可以帮助判断是否值得停止 +- 停止后可以考虑是否需要重启或修复后重新构建 +- 对于长时间运行的构建,定期检查状态可能更合适 + +## Alternatives + +替代方案考虑: + +| 情况 | 停止 | 等待完成 | 其他方案 | +|------|------|----------|----------| +| 严重错误 | ✅ 推荐 | ❌ 不推荐 | 修复后重启 | +| 临时问题 | ⚠️ 可选 | ✅ 推荐 | 等待自动恢复 | +| 配置错误 | ✅ 推荐 | ❌ 不推荐 | 修复配置后重启 | +| 时间过长 | ⚠️ 可选 | ✅ 推荐 | 优化构建流程 | + +## References + +- [ci +builds](ci-list.md) — 查看构建列表 +- [ci +logs](ci-logs.md) — 查看构建日志 +- [ci +restart](ci-restart.md) — 重启构建 +- [gitlink-ci](../SKILL.md) — CI/CD 操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-pm/references/pm-kanban.md b/skills/gitlink-pm/references/pm-kanban.md new file mode 100644 index 0000000..ad4c9d2 --- /dev/null +++ b/skills/gitlink-pm/references/pm-kanban.md @@ -0,0 +1,254 @@ +# PM 看板管理 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **注意**:PM 功能需要项目开启 PM 模块,并通过项目 ID 访问。 + +GitLink PM 看板功能提供项目任务的可视化管理,支持任务的拖拽、状态管理和团队协作。 + +## 命令 + +```bash +# 查看项目看板 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' + +# 查看当前仓库的项目 ID +gitlink-cli repo +info --format json | jq '.data.project_id' + +# 组合命令:自动获取项目 ID 并查看看板 +PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') +gitlink-cli api GET /pm/dashboards --query "project_id=$PROJECT_ID" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `project_id` | **是** | 项目 ID(通过 `repo +info` 获取) | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +GET /api/pm/dashboards?project_id={project_id} +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "dashboards": [ + { + "id": 1, + "name": "开发看板", + "description": "主开发任务看板", + "project_id": 123, + "columns": [ + { + "id": 1, + "name": "待处理", + "position": 1, + "issue_count": 5 + }, + { + "id": 2, + "name": "进行中", + "position": 2, + "issue_count": 3 + }, + { + "id": 3, + "name": "已完成", + "position": 3, + "issue_count": 8 + } + ], + "issues": [ + { + "id": 456, + "subject": "实现用户认证功能", + "status_id": 1, + "priority_id": 2, + "assigned_to": "developer1", + "column_id": 2, + "position": 1 + } + ] + } + ], + "total_count": 1 + } +} +``` + +## Workflow + +1. **Get Project ID** using `repo +info`. +2. **Execute** `gitlink-cli api GET /pm/dashboards --query 'project_id='`. +3. **Display** kanban board information. + +> [!NOTE] +> This is a **Read Operation** — no confirmation needed. + +## Use Cases + +- **任务可视化**:直观查看项目任务分布 +- **进度跟踪**:实时监控任务进展情况 +- **资源分配**:查看团队成员的工作负载 +- **流程管理**:管理任务的流转和状态变更 + +## Kanban Board Structure + +典型看板结构: + +``` +┌─────────────┬─────────────┬─────────────┐ +│ 待处理 │ 进行中 │ 已完成 │ +│ [5 tasks] │ [3 tasks] │ [8 tasks] │ +├─────────────┼─────────────┼─────────────┤ +│ Task 1 │ Task 6 │ Task 11 │ +│ Task 2 │ Task 7 │ Task 12 │ +│ Task 3 │ Task 8 │ Task 13 │ +│ Task 4 │ Task 9 │ Task 14 │ +│ Task 5 │ Task 10 │ Task 15 │ +└─────────────┴─────────────┴─────────────┘ +``` + +## Common Operations + +看板常用操作: + +### 查看任务分布 +```bash +# 查看各列的任务数量 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' --format json | \ + jq '.data.dashboards[0].columns[] | {name: .name, count: .issue_count}' +``` + +### 查看特定任务 +```bash +# 查看"进行中"的任务 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' --format json | \ + jq '.data.dashboards[0].issues[] | select(.column_id==2)' +``` + +### 统计工作负载 +```bash +# 按人员统计任务数量 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' --format json | \ + jq '.data.dashboards[0].issues[] | group_by(.assigned_to) | map({assigned_to: .[0].assigned_to, count: length})' +``` + +## Task Management + +任务管理最佳实践: + +1. **列管理**:合理设置任务列(如:待处理、进行中、已完成) +2. **限制数量**:对"进行中"列设置 WIP 限制 +3. **定期清理**:及时移动已完成任务到相应列 +4. **优先级标记**:使用标签和优先级标识重要任务 + +## Analysis Examples + +看板数据分析示例: + +```bash +# 1. 获取项目 ID +PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + +# 2. 查看完整看板数据 +gitlink-cli api GET /pm/dashboards --query "project_id=$PROJECT_ID" --format json + +# 3. 分析任务瓶颈(找出任务最多的列) +gitlink-cli api GET /pm/dashboards --query "project_id=$PROJECT_ID" --format json | \ + jq '.data.dashboards[0].columns | sort_by(.issue_count) | reverse | .[0]' + +# 4. 计算完成率 +gitlink-cli api GET /pm/dashboards --query "project_id=$PROJECT_ID" --format json | \ + jq '[.data.dashboards[0].columns[] | select(.name=="已完成")] | .[0].issue_count / + [.data.dashboards[0].columns[].issue_count] | add * 100' +``` + +## Tips + +- 看板数据可以帮助识别项目瓶颈 +- 定期查看看板可以保持项目进度的可视化 +- 结合 Issue 操作可以实现完整的任务管理流程 +- 使用 JSON 格式输出便于自动化分析 + +## Integration with Other Features + +与其他功能集成: + +```bash +# 看板 + Issue 操作 +# 1. 查看看板中的任务 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' --format json + +# 2. 查看特定任务详情 +gitlink-cli issue +view --issue 456 + +# 3. 更新任务状态 +gitlink-cli issue +update --issue 456 --status_id 3 +``` + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `400` | PM 模块未开启 | 联系项目管理员开启 PM 模块 | +| `404` | 项目不存在 | 检查 project_id 是否正确 | +| `403` | 权限不足 | 确认有查看该项目的权限 | +| `404` | 看板不存在 | 该项目可能没有配置看板 | + +## Prerequisites + +使用 PM 功能的前置条件: + +1. **PM 模块开启**:项目需要开启 PM 功能模块 +2. **有效项目 ID**:需要正确的项目 ID +3. **访问权限**:需要该项目的访问权限 +4. **看板配置**:项目需要有配置的看板 + +## Setup Workflow + +PM 功能设置流程: + +```bash +# 1. 检查项目是否开启 PM +gitlink-cli repo +info --format json | jq '.data.has_pm' + +# 2. 获取项目 ID +gitlink-cli repo +info --format json | jq '.data.project_id' + +# 3. 查看看板配置 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' + +# 4. 如需配置看板,通过 GitLink 网页端操作 +# https://www.gitlink.org.cn/{owner}/{repo}/project_modules +``` + +## Team Collaboration + +团队协作建议: + +1. **定期更新**:团队成员定期更新任务状态 +2. **明确规范**:制定看板使用规范和列定义 +3. **WIP 限制**:设置进行中任务的数量限制 +4. **定期回顾**:定期回顾看板数据,优化流程 + +## References + +- [pm-sprint](pm-sprint.md) — Sprint 管理 +- [pm-report](pm-report.md) — 周报生成 +- [gitlink-pm](../SKILL.md) — 项目管理总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [repo +info](../../gitlink-repo/references/repo-info.md) — 获取仓库信息 diff --git a/skills/gitlink-pm/references/pm-report.md b/skills/gitlink-pm/references/pm-report.md new file mode 100644 index 0000000..a630eee --- /dev/null +++ b/skills/gitlink-pm/references/pm-report.md @@ -0,0 +1,400 @@ +# PM 周报生成 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **注意**:PM 功能需要项目开启 PM 模块,并通过项目 ID 访问。 + +GitLink PM 周报功能提供项目一周工作情况的自动汇总,包括 Issue、Pull Request、提交记录等数据。 + +## 命令 + +```bash +# 查看周报数据 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' + +# 查看当前仓库的项目 ID +gitlink-cli repo +info --format json | jq '.data.project_id' + +# 组合命令:自动获取项目 ID 并查看周报 +PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') +gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" + +# 查看特定日期范围的周报 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123&start_date=2026-01-01&end_date=2026-01-07' + +# 查看 Issue 标签统计 +gitlink-cli api GET /pm/issue_tags --query 'project_id=123' +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `project_id` | **是** | 项目 ID(通过 `repo +info` 获取) | +| `start_date` | 否 | 开始日期(格式:YYYY-MM-DD) | +| `end_date` | 否 | 结束日期(格式:YYYY-MM-DD) | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +GET /api/pm/weekly_issues?project_id={project_id}&start_date={start_date}&end_date={end_date} +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "period": { + "start_date": "2026-01-01", + "end_date": "2026-01-07", + "week_number": 1 + }, + "summary": { + "total_issues": 15, + "new_issues": 8, + "closed_issues": 5, + "in_progress_issues": 2, + "total_prs": 6, + "merged_prs": 4, + "total_commits": 42 + }, + "issues": [ + { + "id": 456, + "subject": "实现用户认证功能", + "status": "closed", + "author": "developer1", + "assigned_to": "developer2", + "created_at": "2026-01-02T10:00:00Z", + "closed_at": "2026-01-05T15:30:00Z", + "labels": ["feature", "authentication"] + } + ], + "pull_requests": [ + { + "id": 123, + "title": "Feature: User authentication", + "status": "merged", + "author": "developer1", + "merged_at": "2026-01-05T16:00:00Z", + "additions": 245, + "deletions": 18 + } + ], + "commits": [ + { + "id": "abc123", + "message": "Implement user login", + "author": "developer1", + "committed_date": "2026-01-03T14:20:00Z" + } + ], + "team_contributions": [ + { + "developer": "developer1", + "issues_created": 3, + "issues_closed": 2, + "prs_created": 2, + "prs_merged": 2, + "commits_count": 15 + } + ] + } +} +``` + +## Workflow + +1. **Get Project ID** using `repo +info`. +2. **Execute** `gitlink-cli api GET /pm/weekly_issues --query 'project_id='`. +3. **Display** weekly report data. + +> [!NOTE] +> This is a **Read Operation** — no confirmation needed. + +## Use Cases + +- **工作汇报**:自动生成周工作总结 +- **进度跟踪**:监控项目一周的进展情况 +- **团队管理**:了解团队成员的工作贡献 +- **数据分析**:分析项目发展趋势和效率 + +## Weekly Report Structure + +典型周报结构: + +``` +周报(2026-01-01 至 2026-01-07) + +## 概览统计 +- 新增 Issue:8 个 +- 关闭 Issue:5 个 +- 进行中 Issue:2 个 +- 合并 PR:4 个 +- 代码提交:42 次 + +## 详细内容 +### Issue 活动 +- 新建:8 个 Issue +- 完成:5 个 Issue +- 持续工作:2 个 Issue + +### Pull Request 活动 +- 创建:6 个 PR +- 合并:4 个 PR + +### 团队贡献 +- developer1:15 次提交,2 个合并 PR +- developer2:12 次提交,1 个合并 PR +``` + +## Common Operations + +周报常用操作: + +### 生成简明周报 +```bash +# 生成简明周报摘要 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' --format json | \ + jq '# 周报摘要 + "\n## 项目周报 (\(.data.period.start_date) 至 \(.data.period.end_date))", + "\n### 统计概览", + "- 新增 Issue: \(.data.summary.new_issues) 个", + "- 完成 Issue: \(.data.summary.closed_issues) 个", + "- 合并 PR: \(.data.summary.merged_prs) 个", + "- 代码提交: \(.data.summary.total_commits) 次"' +``` + +### 分析团队贡献 +```bash +# 按贡献度排序团队成员 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' --format json | \ + jq '.data.team_contributions | sort_by(.commits_count) | reverse | + .[] | "\(.developer): \(.commits_count) 次提交, \(.prs_merged) 个合并 PR"' +``` + +### 查看活动趋势 +```bash +# 按日期统计活动 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' --format json | \ + jq '[.data.commits[] | .committed_date | split("T")[0]] | + group_by(.) | + map({date: .[0], count: length}) | + sort_by(.date)' +``` + +## Report Metrics + +周报关键指标: + +| 指标 | 说明 | 用途 | +|------|------|------| +| **新增 Issue 数** | 一周内新建的 Issue 数量 | 反映新需求产生速度 | +| **关闭 Issue 数** | 一周内关闭的 Issue 数量 | 反映问题解决速度 | +| **合并 PR 数** | 一周内合并的 PR 数量 | 反映代码集成速度 | +| **提交次数** | 一周内的代码提交次数 | 反映开发活跃度 | +| **参与人数** | 有贡献活动的团队成员数 | 反映团队参与度 | + +## Analysis Examples + +周报数据分析示例: + +```bash +# 1. 获取项目 ID +PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + +# 2. 查看完整周报 +gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" --format json + +# 3. 生成团队贡献排名 +gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '.data.team_contributions | sort_by(.commits_count) | reverse | + +# 4. 计算完成率 +gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '(.data.summary.closed_issues / .data.summary.new_issues * 100) | + "本周完成率: \(.)%"' + +# 5. 分析代码变更量 +gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '{additions: [.data.pull_requests[].additions] | add, + deletions: [.data.pull_requests[].deletions] | add, + net_change: ([.data.pull_requests[].additions] | add) - ([.data.pull_requests[].deletions] | add)}' +``` + +## Custom Report Generation + +自定义报告生成: + +```bash +# 生成 Markdown 格式的周报 +generate_weekly_report() { + PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + END_DATE=$(date +%Y-%m-%d) + START_DATE=$(date -d "7 days ago" +%Y-%m-%d) + + echo "# 项目周报 ($START_DATE 至 $END_DATE)" + echo "" + + gitlink-cli api GET /pm/weekly_issues \ + --query "project_id=$PROJECT_ID&start_date=$START_DATE&end_date=$END_DATE" \ + --format json | \ + jq -r ' + "## 概览统计", + "- 新增 Issue: \(.data.summary.new_issues) 个", + "- 完成 Issue: \(.data.summary.closed_issues) 个", + "- 合并 PR: \(.data.summary.merged_prs) 个", + "- 代码提交: \(.data.summary.total_commits) 次", + "", + "## 团队贡献", + (.data.team_contributions | sort_by(.commits_count) | reverse | + .[] | "- **\(.developer)**: \(.commits_count) 次提交, \(.prs_merged) 个合并 PR"), + "", + "## 主要完成", + (.data.issues[] | select(.status == "closed") | + "- [\(.subject)](#issue/\(.id)) - \(.assigned_to)"), + "", + "## 代码合并", + (.data.pull_requests[] | select(.status == "merged") | + "- [\(.title)](#pr/\(.id)) - \(.author) (+\(.additions) -\(.deletions))") + ' +} +``` + +## Tips + +- 周报数据可以帮助团队了解工作进展 +- 定期生成周报可以保持项目进度的可视化 +- 结合其他 PM 数据可以实现完整的项目管理 +- 使用 JSON 格式输出便于自动化报告生成 + +## Integration with Other Features + +与其他功能集成: + +```bash +# 周报 + 详细操作 +# 1. 生成周报概览 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' --format json + +# 2. 查看特定 Issue 详情 +gitlink-cli issue +view --issue 456 + +# 3. 查看特定 PR 详情 +gitlink-cli pr +view --pr 123 +``` + +## Best Practices + +周报生成最佳实践: + +1. **定期生成**:每周固定时间生成周报 +2. **数据验证**:生成后验证数据的准确性 +3. **格式统一**:使用统一的报告格式 +4. **趋势分析**:比较不同周报的数据趋势 +5. **团队分享**:及时分享周报给团队成员 + +## Advanced Usage + +高级用法示例: + +```bash +# 比较两周的数据 +compare_weeks() { + PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + + # 本周数据 + THIS_WEEK=$(gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" --format json) + + # 上周数据(计算日期) + LAST_START=$(date -d "14 days ago" +%Y-%m-%d) + LAST_END=$(date -d "8 days ago" +%Y-%m-%d) + LAST_WEEK=$(gitlink-cli api GET /pm/weekly_issues \ + --query "project_id=$PROJECT_ID&start_date=$LAST_START&end_date=$LAST_END" --format json) + + # 比较输出 + echo "## 周环比分析" + echo "新增 Issue: $THIS_WEEK ↓ $LAST_WEEK" + echo "完成 Issue: $THIS_WEEK ↓ $LAST_WEEK" +} + +# 导出为文件 +export_weekly_report() { + PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + DATE=$(date +%Y-%m-%d) + + gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '.' > "weekly_report_$DATE.json" +} +``` + +## Team Collaboration + +团队协作建议: + +1. **定期分享**:每周固定时间分享周报 +2. **数据透明**:保持团队对项目进度的了解 +3. **问题讨论**:基于周报数据讨论问题和改进 +4. **成果认可**:认可和庆祝团队成就 +5. **持续改进**:基于周报分析优化工作流程 + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `400` | PM 模块未开启 | 联系项目管理员开启 PM 模块 | +| `404` | 项目不存在 | 检查 project_id 是否正确 | +| `400` | 日期格式错误 | 确保日期格式为 YYYY-MM-DD | +| `403` | 权限不足 | 确认有查看该项目的权限 | +| `404` | 数据不存在 | 指定日期范围内可能没有活动数据 | + +## Report Templates + +报告模板示例: + +```markdown +# 项目周报(第{{week_number}}周) + +**时间范围**:{{start_date}} 至 {{end_date}} + +## 📊 核心指标 +- ✅ 完成 Issue:{{closed_issues}} 个 +- 🆕 新增 Issue:{{new_issues}} 个 +- 🔀 合并 PR:{{merged_prs}} 个 +- 💻 代码提交:{{total_commits}} 次 + +## 👥 团队贡献 +{{#each team_contributions}} +### {{developer}} +- 提交:{{commits_count}} 次 +- 合并 PR:{{prs_merged}} 个 +- 完成 Issue:{{issues_closed}} 个 +{{/each}} + +## 🎯 主要成果 +{{#each closed_issues}} +- {{subject}} ({{assigned_to}}) +{{/each}} + +## 🔄 进行中工作 +{{#each in_progress_issues}} +- {{subject}} ({{assigned_to}}) +{{/each}} +``` + +## References + +- [pm-kanban](pm-kanban.md) — 看板管理 +- [pm-sprint](pm-sprint.md) — Sprint 管理 +- [gitlink-pm](../SKILL.md) — 项目管理总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [repo +info](../../gitlink-repo/references/repo-info.md) — 获取仓库信息 diff --git a/skills/gitlink-pm/references/pm-sprint.md b/skills/gitlink-pm/references/pm-sprint.md new file mode 100644 index 0000000..435e626 --- /dev/null +++ b/skills/gitlink-pm/references/pm-sprint.md @@ -0,0 +1,296 @@ +# PM Sprint 管理 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **注意**:PM 功能需要项目开启 PM 模块,并通过项目 ID 访问。 + +GitLink PM Sprint 功能支持敏捷开发的迭代管理,帮助团队组织和管理特定时间段内的开发任务。 + +## 命令 + +```bash +# 查看 Sprint Issue 列表 +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' + +# 查看当前仓库的项目 ID +gitlink-cli repo +info --format json | jq '.data.project_id' + +# 组合命令:自动获取项目 ID 并查看 Sprint +PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') +gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" + +# 查看特定 Sprint 的信息 +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123&sprint_id=1' + +# 查看 Issue 标签 +gitlink-cli api GET /pm/issue_tags --query 'project_id=123' +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `project_id` | **是** | 项目 ID(通过 `repo +info` 获取) | +| `sprint_id` | 否 | Sprint ID(可选,用于查看特定 Sprint) | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +GET /api/pm/sprint_issues?project_id={project_id}&sprint_id={sprint_id} +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "sprint_issues": [ + { + "id": 789, + "subject": "完成用户管理模块", + "sprint_id": 1, + "sprint_name": "Sprint 1 - 基础功能", + "status": "open", + "priority": "high", + "assigned_to": "developer1", + "estimated_hours": 40, + "spent_hours": 28, + "completion_percentage": 70, + "start_date": "2026-01-01", + "end_date": "2026-01-14", + "tags": ["backend", "user-management"] + }, + { + "id": 790, + "subject": "实现权限控制", + "sprint_id": 1, + "sprint_name": "Sprint 1 - 基础功能", + "status": "in_progress", + "priority": "high", + "assigned_to": "developer2", + "estimated_hours": 32, + "spent_hours": 15, + "completion_percentage": 47, + "start_date": "2026-01-01", + "end_date": "2026-01-14", + "tags": ["backend", "security"] + } + ], + "total_count": 12 + } +} +``` + +## Workflow + +1. **Get Project ID** using `repo +info`. +2. **Execute** `gitlink-cli api GET /pm/sprint_issues --query 'project_id='`. +3. **Display** sprint issues and progress. + +> [!NOTE] +> This is a **Read Operation** — no confirmation needed. + +## Use Cases + +- **Sprint 规划**:查看和规划 Sprint 中的任务 +- **进度跟踪**:监控 Sprint 的执行进度 +- **资源分配**:合理分配团队成员到 Sprint 任务 +- **性能分析**:分析团队的开发速度和效率 + +## Sprint Lifecycle + +典型的 Sprint 生命周期: + +``` +1. Sprint 规划 + ├── 确定 Sprint 目标 + ├── 选择要处理的 Issue + └── 估算工作量 + +2. Sprint 执行 + ├── 开发团队实现功能 + ├── 每日站会同步进度 + └── 处理阻塞问题 + +3. Sprint 评审 + ├── 演示完成的功能 + ├── 收集反馈意见 + └── 确定验收结果 + +4. Sprint 回顾 + ├── 总结经验教训 + ├── 优化工作流程 + └── 制定改进计划 +``` + +## Common Operations + +Sprint 常用操作: + +### 查看 Sprint 概览 +```bash +# 查看 Sprint 统计信息 +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' --format json | \ + jq '{total: .data.total_count, + high_priority: [.data.sprint_issues[] | select(.priority=="high")] | length, + completed: [.data.sprint_issues[] | select(.status=="closed")] | length}' +``` + +### 查看 Sprint 进度 +```bash +# 计算 Sprint 完成百分比 +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' --format json | \ + jq '[.data.sprint_issues[].completion_percentage] | add / length' +``` + +### 分析工作负载 +```bash +# 按人员统计工作负载 +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' --format json | \ + jq '[.data.sprint_issues[] | {assigned_to: .assigned_to, estimated: .estimated_hours}] | + group_by(.assigned_to) | + map({developer: .[0].assigned_to, total_hours: (map(.estimated) | add)})' +``` + +## Sprint Metrics + +Sprint 关键指标: + +| 指标 | 说明 | 计算方式 | +|------|------|----------| +| **Sprint 速度** | 团队在一个 Sprint 中完成的工作量 | 完成的 Issue 数 × 复杂度权重 | +| **完成率** | Sprint 中已完成任务的百分比 | 已完成数 / 总数 × 100% | +| **剩余工作量** | Sprint 中未完成的工作量 | 未完成任务的估算小时数 | +| **工作负载** | 团队成员的工作分布 | 每人分配的估算小时数 | +| **延期风险** | 可能无法按时完成的任务 | 接近截止日期但未完成的任务 | + +## Analysis Examples + +Sprint 数据分析示例: + +```bash +# 1. 获取项目 ID +PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + +# 2. 查看 Sprint 概览 +gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json + +# 3. 分析高优先级任务 +gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '.data.sprint_issues[] | select(.priority=="high") | {subject, status, completion_percentage}' + +# 4. 识别延期风险 +gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '.data.sprint_issues[] | + select(.status != "closed" and .end_date < (now | todate)) | + {subject, end_date, completion_percentage}' + +# 5. 计算团队效率 +gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '{total_estimated: [.data.sprint_issues[].estimated_hours] | add, + total_spent: [.data.sprint_issues[].spent_hours] | add, + efficiency: ([.data.sprint_issues[].spent_hours] | add) / + ([.data.sprint_issues[].estimated_hours] | add) * 100}' +``` + +## Sprint Planning + +Sprint 规划建议: + +1. **合理估算**:基于历史数据估算工作量 +2. **优先级排序**:优先处理高价值和高优先级任务 +3. **负载均衡**:合理分配任务给团队成员 +4. **预留缓冲**:为不可预见的问题预留时间 + +## Tips + +- Sprint 数据可以帮助团队了解开发进度 +- 定期查看 Sprint 统计可以及时发现问题 +- 结合 Issue 操作可以实现完整的任务管理 +- 使用 JSON 格式输出便于自动化分析 + +## Integration with Other Features + +与其他功能集成: + +```bash +# Sprint + Issue 操作 +# 1. 查看 Sprint 中的任务 +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' --format json + +# 2. 查看特定任务详情 +gitlink-cli issue +view --issue 789 + +# 3. 更新任务状态 +gitlink-cli issue +update --issue 789 --status_id 3 --done_ratio 80 +``` + +## Best Practices + +Sprint 管理最佳实践: + +1. **时间盒固定**:Sprint 时长通常为 2-4 周 +2. **目标明确**:每个 Sprint 应有明确的目标 +3. **任务可衡量**:Sprint 任务应该是可衡量和可完成的 +4. **定期回顾**:每个 Sprint 结束后进行回顾总结 +5. **持续改进**:基于回顾结果优化工作流程 + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `400` | PM 模块未开启 | 联系项目管理员开启 PM 模块 | +| `404` | 项目不存在 | 检查 project_id 是否正确 | +| `404` | Sprint 不存在 | 检查 sprint_id 是否正确 | +| `403` | 权限不足 | 确认有查看该项目的权限 | + +## Advanced Usage + +高级用法示例: + +```bash +# 生成 Sprint 报告 +generate_sprint_report() { + PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + + echo "# Sprint Report" + echo "## Overview" + gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json | \ + jq -r '"Total: \(.data.total_count) issues"' + + echo "## Priority Distribution" + gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '.data.sprint_issues | group_by(.priority) | map({priority: .[0].priority, count: length})' + + echo "## Team Workload" + gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '[.data.sprint_issues[] | {assigned_to: .assigned_to, hours: .estimated_hours}] | + group_by(.assigned_to) | + map({developer: .[0].assigned_to, total_hours: (map(.hours) | add)})' +} +``` + +## Team Collaboration + +团队协作建议: + +1. **Sprint 规划会议**:全团队参与 Sprint 规划 +2. **每日站会**:简短同步进度和问题 +3. **Sprint 评审**:演示和验收完成的功能 +4. **Sprint 回顾**:总结经验,持续改进 + +## References + +- [pm-kanban](pm-kanban.md) — 看板管理 +- [pm-report](pm-report.md) — 周报生成 +- [gitlink-pm](../SKILL.md) — 项目管理总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [repo +info](../../gitlink-repo/references/repo-info.md) — 获取仓库信息 diff --git a/skills/gitlink-webhook/SKILL.md b/skills/gitlink-webhook/SKILL.md new file mode 100644 index 0000000..d75035f --- /dev/null +++ b/skills/gitlink-webhook/SKILL.md @@ -0,0 +1,233 @@ +--- +name: gitlink-webhook +version: 1.0.0 +description: "Webhook 管理:创建、查看、更新、删除、测试 Webhook,配置自动化触发器。当用户需要配置 GitLink 仓库的 Webhook 自动化通知时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli webhook --help" +--- + +# gitlink-webhook(Webhook 操作) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +## Shortcuts + +| Shortcut | 说明 | 需要认证 | +|----------|------|----------| +| `webhook +list` | 列出仓库的所有 Webhook | 是 | +| `webhook +create` | 创建新 Webhook | 是 | +| `webhook +update` | 更新 Webhook 配置 | 是 | +| `webhook +delete` | 删除 Webhook | 是 | +| `webhook +test` | 测试 Webhook 推送(发送 ping 事件) | 是 | +| `webhook +info` | 查看 Webhook 详情 | 是 | +| `webhook +events` | 列出所有支持的事件类型 | 否 | + +## 支持的事件类型 + +| 事件类型 | 说明 | 触发时机 | +|----------|------|----------| +| `push` | 代码推送事件 | 向仓库推送代码时 | +| `pull_request` | Pull 请求事件 | 创建、更新、关闭 PR 时 | +| `issue` | Issue 事件 | 创建、更新、关闭 Issue 时 | +| `issue_assign` | Issue 指派事件 | Issue 被指派给用户时 | +| `issue_comment` | Issue 评论事件 | Issue 添加评论时 | +| `pull_request_assign` | PR 指派事件 | PR 被指派给审查者时 | +| `pull_request_comment` | PR 评论事件 | PR 添加评论时 | +| `merge_request` | 合并请求事件 | PR 被合并时 | +| `repository` | 仓库事件 | 仓库设置变更时 | +| `branch` | 分支事件 | 创建或删除分支时 | +| `tag` | 标签事件 | 创建或删除标签时 | + +## 使用示例 + +### 基本操作 + +```bash +# 列出仓库的所有 Webhook +gitlink-cli webhook +list --owner myuser --repo myrepo + +# 查看 Webhook 详情 +gitlink-cli webhook +info --owner myuser --repo myrepo --id 123 + +# 列出所有支持的事件类型 +gitlink-cli webhook +events +``` + +### 创建 Webhook + +```bash +# 创建基本的 Webhook(仅监听 push 事件) +gitlink-cli webhook +create --owner myuser --repo myrepo --url https://example.com/webhook + +# 创建多事件 Webhook +gitlink-cli webhook +create --owner myuser --repo myrepo --url https://example.com/webhook --events push,pull_request,issue + +# 创建带密钥的 Webhook +gitlink-cli webhook +create --owner myuser --repo myrepo --url https://example.com/webhook --secret my-secret-key --events push + +# 创建带描述的 Webhook +gitlink-cli webhook +create --owner myuser --repo myrepo --url https://example.com/webhook --description "CI/CD automation" +``` + +### 更新 Webhook + +```bash +# 更新 Webhook URL +gitlink-cli webhook +update --owner myuser --repo myrepo --id 123 --url https://new-url.com/webhook + +# 更新监听事件 +gitlink-cli webhook +update --owner myuser --repo myrepo --id 123 --events push,pull_request + +# 激活/停用 Webhook +gitlink-cli webhook +update --owner myuser --repo myrepo --id 123 --active false + +# 更新多个属性 +gitlink-cli webhook +update --owner myuser --repo myrepo --id 123 --url https://new-url.com/webhook --events push,pull_request --secret new-secret +``` + +### 测试和删除 + +```bash +# 测试 Webhook(发送 ping 事件) +gitlink-cli webhook +test --owner myuser --repo myrepo --id 123 + +# 测试特定事件类型 +gitlink-cli webhook +test --owner myuser --repo myrepo --id 123 --event push + +# 删除 Webhook +gitlink-cli webhook +delete --owner myuser --repo myrepo --id 123 +``` + +## 典型使用场景 + +### 场景1: 配置 CI/CD 自动化 + +```bash +# 为 CI/CD 系统创建 Webhook +gitlink-cli webhook +create \ + --owner myuser --repo myrepo \ + --url https://ci.example.com/gitlink/webhook \ + --events push,pull_request \ + --secret ci-secret-key \ + --description "Trigger CI/CD pipeline" +``` + +### 场景2: 配置 Issue 通知 + +```bash +# 创建 Issue 通知 Webhook +gitlink-cli webhook +create \ + --owner myuser --repo myrepo \ + --url https://notification.example.com/issues \ + --events issue,issue_comment,issue_assign \ + --description "Issue notifications" +``` + +### 场景3: 配置 PR 审查通知 + +```bash +# 创建 PR 审查 Webhook +gitlink-cli webhook +create \ + --owner myuser --repo myrepo \ + --url https://review.example.com/prs \ + --events pull_request,pull_request_comment,pull_request_assign \ + --description "PR review notifications" +``` + +## 错误处理 + +### 常见错误 + +#### 1. 认证错误 +```bash +Error: [401] Authentication failed +``` +**解决方案**: 运行 `gitlink-cli auth login` 重新认证 + +#### 2. 权限不足 +```bash +Error: [403] You are not authorized to manage webhooks +``` +**解决方案**: 确认您对仓库有管理员权限 + +#### 3. 无效的事件类型 +```bash +Error: no valid events specified +``` +**解决方案**: 使用 `gitlink-cli webhook +events` 查看支持的事件类型 + +#### 4. Webhook 不存在 +```bash +Error: [404] Webhook not found +``` +**解决方案**: 使用 `gitlink-cli webhook +list` 确认 Webhook ID 是否正确 + +## AI Agent 使用指南 + +### 检查现有 Webhook +```bash +# 1. 列出所有 Webhook +gitlink-cli webhook +list --owner $OWNER --repo $REPO --format json + +# 2. 检查是否有特定类型的 Webhook +gitlink-cli webhook +list --owner $OWNER --repo $REPO --format json | jq '.data.webhooks[] | select(.hook_url | contains("ci-system"))' +``` + +### 创建 Webhook 的最佳实践 +```bash +# 1. 先查看支持的事件 +gitlink-cli webhook +events + +# 2. 创建 Webhook 并验证 +gitlink-cli webhook +create --owner $OWNER --repo $REPO --url $URL --events $EVENTS + +# 3. 测试 Webhook 是否正常工作 +gitlink-cli webhook +test --owner $OWNER --repo $REPO --id $WEBHOOK_ID +``` + +### 安全建议 +- **使用密钥**: 为 Webhook 设置密钥以验证请求来源 +- **HTTPS**: 始终使用 HTTPS URL 作为 Webhook 回调地址 +- **最小权限**: 只监听必要的事件类型 +- **定期轮换**: 定期更新 Webhook 密钥 + +## 参考文档 + +- [`webhook-list.md`](references/webhook-list.md) - 列出 Webhook 详细说明 +- [`webhook-create.md`](references/webhook-create.md) - 创建 Webhook 详细说明 +- [`webhook-update.md`](references/webhook-update.md) - 更新 Webhook 详细说明 +- [`webhook-delete.md`](references/webhook-delete.md) - 删除 Webhook 详细说明 +- [`webhook-test.md`](references/webhook-test.md) - 测试 Webhook 详细说明 +- [`webhook-info.md`](references/webhook-info.md) - 查看 Webhook 详细说明 +- [`examples/webhook-workflow.md`](examples/webhook-workflow.md) - 完整工作流示例 + +## 注意事项 + +1. **API 限制**: GitLink 对 Webhook 数量有限制,通常每个仓库不超过 20 个 +2. **URL 要求**: Webhook URL 必须是公网可访问的 HTTPS 地址 +3. **超时设置**: Webhook 请求超时时间为 10 秒 +4. **重试机制**: GitLink 会在失败时重试 3 次,间隔分别为 1s、5s、10s +5. **事件顺序**: 同一事件的多个 Webhook 按创建顺序依次触发 +6. **测试限制**: 测试 Webhook 不会触发实际的业务逻辑,仅验证连通性 + +## 故障排除 + +### Webhook 未触发 +1. 检查 Webhook 是否激活:`gitlink-cli webhook +info --id --active true` +2. 验证事件类型是否正确:`gitlink-cli webhook +info --id ` +3. 测试 Webhook 连通性:`gitlink-cli webhook +test --id ` + +### Webhook 响应异常 +1. 检查回调服务器是否正常运行 +2. 验证 Webhook URL 是否可访问 +3. 查看 GitLink 服务器日志确认请求是否发送 + +### 权限问题 +1. 确认当前用户是仓库管理员或所有者 +2. 检查 Token 是否有足够权限:`gitlink-cli auth status` diff --git a/skills/gitlink-webhook/examples/webhook-workflow.md b/skills/gitlink-webhook/examples/webhook-workflow.md new file mode 100644 index 0000000..bcb1b61 --- /dev/null +++ b/skills/gitlink-webhook/examples/webhook-workflow.md @@ -0,0 +1,694 @@ +# Webhook 完整工作流示例 + +本文档提供了 GitLink Webhook 的完整使用场景和最佳实践示例。 + +## 目录 + +- [场景1: CI/CD 自动化](#场景1-cicd-自动化) +- [场景2: Issue 和 PR 通知](#场景2-issue-和-pr-通知) +- [场景3: 多环境部署](#场景3-多环境部署) +- [场景4: Webhook 迁移](#场景4-webhook-迁移) +- [场景5: 故障排查](#场景5-故障排查) +- [场景6: 安全最佳实践](#场景6-安全最佳实践) + +--- + +## 场景1: CI/CD 自动化 + +### 目标 +为 Jenkins CI/CD 系统配置 Webhook,实现代码推送时自动触发构建。 + +### 完整流程 + +```bash +#!/bin/bash +# cicd-webhook-setup.sh + +PROJECT_OWNER="mycompany" +PROJECT_REPO="main-app" +JENKINS_URL="https://jenkins.example.com/gitlink-webhook" +WEBHOOK_SECRET="jenkins-secret-key-2024" + +echo "=== Setting up CI/CD Webhook for $PROJECT_OWNER/$PROJECT_REPO ===" + +# 1. 检查是否已存在 CI/CD Webhook +echo "1. Checking existing webhooks..." +existing=$(gitlink-cli webhook +list \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --format json | \ + jq -r ".data.webhooks[] | select(.hook_url | contains(\"jenkins\")) | .id") + +if [ -n "$existing" ]; then + echo "Found existing CI/CD webhook: $existing" + read -p "Delete existing webhook? (y/n) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + gitlink-cli webhook +delete --owner $PROJECT_OWNER --repo $PROJECT_REPO --id $existing + echo "Existing webhook deleted" + else + echo "Aborting setup" + exit 1 + fi +fi + +# 2. 创建新的 Webhook +echo "2. Creating new CI/CD webhook..." +WEBHOOK_INFO=$(gitlink-cli webhook +create \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --url "$JENKINS_URL" \ + --events push,pull_request \ + --secret "$WEBHOOK_SECRET" \ + --description "Jenkins CI/CD automation" \ + --format json) + +if [ $? -eq 0 ]; then + WEBHOOK_ID=$(echo $WEBHOOK_INFO | jq -r '.data.id') + echo "✓ Webhook created successfully: $WEBHOOK_ID" +else + echo "✗ Failed to create webhook" + exit 1 +fi + +# 3. 测试 Webhook +echo "3. Testing webhook..." +if gitlink-cli webhook +test --owner $PROJECT_OWNER --repo $PROJECT_REPO --id $WEBHOOK_ID; then + echo "✓ Webhook test successful" +else + echo "⚠ Webhook test failed, please check Jenkins server" + read -p "Continue anyway? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + gitlink-cli webhook +delete --id $WEBHOOK_ID + echo "Webhook deleted due to test failure" + exit 1 + fi +fi + +# 4. 验证配置 +echo "4. Verifying configuration..." +gitlink-cli webhook +info --owner $PROJECT_OWNER --repo $PROJECT_REPO --id $WEBHOOK_ID + +echo "=== CI/CD Webhook Setup Complete ===" +echo "Webhook ID: $WEBHOOK_ID" +echo "Jenkins URL: $JENKINS_URL" +echo "Events: push, pull_request" +``` + +### 使用说明 + +```bash +# 1. 设置脚本权限 +chmod +x cicd-webhook-setup.sh + +# 2. 运行脚本 +./cicd-webhook-setup.sh + +# 3. 验证 Webhook 是否正常工作 +# 在 Jenkins 中检查是否收到 Webhook 事件 +``` + +--- + +## 场景2: Issue 和 PR 通知 + +### 目标 +配置 Slack 通知,在 Issue 和 PR 活动时发送消息到团队频道。 + +### 完整流程 + +```bash +#!/bin/bash +# notification-webhook-setup.sh + +PROJECT_OWNER="myteam" +PROJECT_REPO="project-x" +SLACK_WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + +echo "=== Setting up Notification Webhooks ===" + +# Issue 通知 Webhook +echo "1. Creating Issue notification webhook..." +ISSUE_WEBHOOK_ID=$(gitlink-cli webhook +create \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --url "$SLACK_WEBHOOK_URL" \ + --events issue,issue_comment,issue_assign \ + --description "Issue notifications to #dev-team" \ + --format json | jq -r '.data.id') + +echo "✓ Issue webhook created: $ISSUE_WEBHOOK_ID" + +# PR 通知 Webhook +echo "2. Creating PR notification webhook..." +PR_WEBHOOK_ID=$(gitlink-cli webhook +create \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --url "$SLACK_WEBHOOK_URL" \ + --events pull_request,pull_request_comment,pull_request_assign \ + --description "PR notifications to #dev-team" \ + --format json | jq -r '.data.id') + +echo "✓ PR webhook created: $PR_WEBHOOK_ID" + +# 测试两个 Webhook +echo "3. Testing webhooks..." +gitlink-cli webhook +test --id $ISSUE_WEBHOOK_ID --event issue +gitlink-cli webhook +test --id $PR_WEBHOOK_ID --event pull_request + +# 查看配置 +echo "4. Webhook summary:" +echo "Issue Webhook: $ISSUE_WEBHOOK_ID" +gitlink-cli webhook +info --id $ISSUE_WEBHOOK_ID +echo +echo "PR Webhook: $PR_WEBHOOK_ID" +gitlink-cli webhook +info --id $PR_WEBHOOK_ID + +echo "=== Notification Setup Complete ===" +``` + +### 多团队通知 + +```bash +#!/bin/bash +# multi-team-notifications.sh + +# 为不同团队配置不同的通知 +declare -A TEAM_WEBHOOKS=( + ["dev-team"]="https://hooks.slack.com/services/DEV/TEAM/WEBHOOK" + ["ops-team"]="https://hooks.slack.com/services/OPS/TEAM/WEBHOOK" + ["security-team"]="https://hooks.slack.com/services/SECURITY/TEAM/WEBHOOK" +) + +for team in "${!TEAM_WEBHOOKS[@]}"; do + webhook_url="${TEAM_WEBHOOKS[$team]}" + + echo "Setting up webhook for $team..." + + gitlink-cli webhook +create \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --url "$webhook_url" \ + --events push,pull_request,issue \ + --description "Notifications for #$team" +done +``` + +--- + +## 场景3: 多环境部署 + +### 目标 +为不同环境(开发、测试、生产)配置独立的 Webhook。 + +### 完整流程 + +```bash +#!/bin/bash +# multi-env-webhook-setup.sh + +PROJECT_OWNER="mycompany" +PROJECT_REPO="main-app" + +# 环境配置 +declare -A ENVIRONMENTS=( + ["development"]="https://ci-dev.example.com/webhook" + ["testing"]="https://ci-test.example.com/webhook" + ["production"]="https:ci-prod.example.com/webhook" +) + +# 为每个环境创建 Webhook +for env in "${!ENVIRONMENTS[@]}"; do + webhook_url="${ENVIRONMENTS[$env]}" + secret="${env}-secret-$(date +%Y%m%d)" + + echo "=== Setting up $env environment webhook ===" + + # 创建 Webhook + webhook_id=$(gitlink-cli webhook +create \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --url "$webhook_url" \ + --events push,pull_request \ + --secret "$secret" \ + --description "$env environment CI/CD" \ + --format json | jq -r '.data.id') + + echo "✓ $env webhook created: $webhook_id" + + # 根据环境设置不同的激活状态 + if [ "$env" = "production" ]; then + # 生产环境默认激活 + echo "Production webhook is active" + else + # 其他环境暂时停用,需要时手动激活 + gitlink-cli webhook +update --id $webhook_id --active false + echo "$env webhook created but inactive (activate manually when needed)" + fi + + echo +done + +echo "=== Multi-environment setup complete ===" +echo "Review created webhooks:" +gitlink-cli webhook +list +``` + +### 环境切换 + +```bash +#!/bin/bash +# switch-active-environment.sh + +# 切换激活的环境 +TARGET_ENV=$1 + +if [ -z "$TARGET_ENV" ]; then + echo "Usage: $0 " + echo "Available environments: development, testing, production" + exit 1 +fi + +echo "=== Switching to $TARGET_ENV environment ===" + +# 停用所有环境 Webhook +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + webhook_url=$(gitlink-cli webhook +info --id $id --format json | jq -r '.data.hook_url') + + if [[ "$webhook_url" == *"ci-"* ]]; then + echo "Deactivating webhook $id..." + gitlink-cli webhook +update --id $id --active false + fi +done + +# 激活目标环境 Webhook +target_webhook_id=$(gitlink-cli webhook +list --format json | \ + jq -r ".data.webhooks[] | select(.hook_url | contains(\"$TARGET_ENV\")) | .id") + +if [ -n "$target_webhook_id" ]; then + echo "Activating $TARGET_ENV webhook: $target_webhook_id" + gitlink-cli webhook +update --id $target_webhook_id --active true + + # 测试激活的 Webhook + gitlink-cli webhook +test --id $target_webhook_id + + echo "✓ Switched to $TARGET_ENV environment" +else + echo "✗ No webhook found for $TARGET_ENV environment" + exit 1 +fi +``` + +--- + +## 场景4: Webhook 迁移 + +### 目标 +将 Webhook 从旧服务器迁移到新服务器。 + +### 完整流程 + +```bash +#!/bin/bash +# webhook-migration.sh + +OLD_SERVER="old-ci.example.com" +NEW_SERVER="new-ci.example.com" +PROJECT_OWNER="mycompany" +PROJECT_REPO="main-app" + +echo "=== Webhook Migration: $OLD_SERVER → $NEW_SERVER ===" + +# 1. 查找需要迁移的 Webhook +echo "1. Finding webhooks to migrate..." +webhooks_to_migrate=$(gitlink-cli webhook +list \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --format json | \ + jq -r ".data.webhooks[] | select(.hook_url | contains(\"$OLD_SERVER\"))") + +webhook_count=$(echo "$webhooks_to_migrate" | jq -r '. | length') + +if [ "$webhook_count" -eq 0 ]; then + echo "No webhooks found for $OLD_SERVER" + exit 0 +fi + +echo "Found $webhook_count webhook(s) to migrate" + +# 2. 为每个 Webhook 创建迁移记录 +echo "$webhooks_to_migrate" | jq -c '.[]' | while read -r webhook; do + old_id=$(echo $webhook | jq -r '.id') + old_url=$(echo $webhook | jq -r '.hook_url') + events=$(echo $webhook | jq -r '.events | join(",")') + description=$(echo $webhook | jq -r '.description') + + # 生成新 URL + new_url=$(echo $old_url | sed "s/$OLD_SERVER/$NEW_SERVER/g") + + echo "=== Migrating webhook $old_id ===" + echo "Old URL: $old_url" + echo "New URL: $new_url" + echo "Events: $events" + + # 创建新 Webhook + echo "Creating new webhook..." + new_id=$(gitlink-cli webhook +create \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --url "$new_url" \ + --events "$events" \ + --description "$description (migrated)" \ + --format json | jq -r '.data.id') + + if [ $? -eq 0 ]; then + echo "✓ New webhook created: $new_id" + + # 测试新 Webhook + echo "Testing new webhook..." + if gitlink-cli webhook +test --id $new_id; then + echo "✓ New webhook test successful" + + # 备份旧 Webhook 配置 + echo "$webhook" > "webhook_backup_${old_id}.json" + + # 删除旧 Webhook + echo "Deleting old webhook: $old_id" + gitlink-cli webhook +delete --id $old_id + + echo "✓ Migration complete for webhook $old_id" + else + echo "⚠ New webhook test failed, keeping old webhook" + gitlink-cli webhook +delete --id $new_id + fi + else + echo "✗ Failed to create new webhook" + fi + + echo +done + +echo "=== Migration Complete ===" +echo "Current webhooks:" +gitlink-cli webhook +list --owner $PROJECT_OWNER --repo $PROJECT_REPO +``` + +### 回滚迁移 + +```bash +#!/bin/bash +# rollback-migration.sh + +echo "=== Webhook Migration Rollback ===" + +# 从备份文件恢复 Webhook +for backup_file in webhook_backup_*.json; do + old_id=$(echo $backup_file | sed 's/webhook_backup_\([0-9]*\)\.json/\1/') + + echo "Restoring webhook: $old_id" + + # 读取备份配置 + webhook_config=$(cat "$backup_file") + old_url=$(echo $webhook_config | jq -r '.hook_url') + events=$(echo $webhook_config | jq -r '.events | join(",")') + description=$(echo $webhook_config | jq -r '.description') + + # 重新创建 Webhook + restored_id=$(gitlink-cli webhook +create \ + --url "$old_url" \ + --events "$events" \ + --description "$description (restored)" \ + --format json | jq -r '.data.id') + + echo "✓ Webhook restored: $restored_id" +done + +echo "=== Rollback Complete ===" +``` + +--- + +## 场景5: 故障排查 + +### 目标 +诊断和修复 Webhook 问题。 + +### 故障排查脚本 + +```bash +#!/bin/bash +# webhook-troubleshooting.sh + +WEBHOOK_ID=$1 + +if [ -z "$WEBHOOK_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "=== Webhook Troubleshooting for ID: $WEBHOOK_ID ===" +echo + +# 1. 检查 Webhook 是否存在 +echo "1. Checking webhook existence..." +if ! gitlink-cli webhook +info --id $WEBHOOK_ID >/dev/null 2>&1; then + echo "✗ Webhook not found" + echo "Available webhooks:" + gitlink-cli webhook +list + exit 1 +fi +echo "✓ Webhook exists" + +# 2. 获取 Webhook 详细信息 +echo "2. Webhook configuration:" +webhook_info=$(gitlink-cli webhook +info --id $WEBHOOK_ID --format json) +echo "$webhook_info" | jq -r '.data | { + URL: .hook_url, + Active: .is_active, + Events: .events | join(", "), + "Last Delivery": .last_delivery.timestamp, + "Success Rate": (.delivery_statistics.success_rate // "N/A") +}' + +# 3. 检查 Webhook 是否激活 +is_active=$(echo $webhook_info | jq -r '.data.is_active') +if [ "$is_active" != "true" ]; then + echo "⚠ Webhook is not active" + read -p "Activate webhook now? (y/n) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + gitlink-cli webhook +update --id $WEBHOOK_ID --active true + echo "✓ Webhook activated" + fi +fi + +# 4. 测试网络连通性 +echo "3. Testing network connectivity..." +webhook_url=$(echo $webhook_info | jq -r '.data.hook_url') +if curl -s -o /dev/null -w "%{http_code}" "$webhook_url" | grep -q "200\|301\|302"; then + echo "✓ URL is accessible (HTTP $(curl -s -o /dev/null -w "%{http_code}" "$webhook_url"))" +else + echo "✗ URL is not accessible" + echo "Testing with curl:" + curl -v "$webhook_url" 2>&1 | head -20 +fi + +# 5. 测试 Webhook +echo "4. Testing webhook delivery..." +if gitlink-cli webhook +test --id $WEBHOOK_ID; then + echo "✓ Webhook test successful" +else + echo "✗ Webhook test failed" + echo "Common issues:" + echo " - URL is not reachable" + echo " - Server is not responding" + echo " - Firewall blocking requests" + echo " - SSL certificate issues" +fi + +# 6. 检查成功率 +echo "5. Checking delivery statistics..." +success_rate=$(echo $webhook_info | jq -r '.data.delivery_statistics.success_rate // "N/A"') +if [ "$success_rate" != "N/A" ]; then + if (( $(echo "$success_rate < 90" | bc -l) )); then + echo "⚠ Low success rate: $success_rate%" + echo "Recommendation: Check webhook server logs for errors" + else + echo "✓ Good success rate: $success_rate%" + fi +else + echo "No delivery statistics available (webhook may be new)" +fi + +# 7. 诊断建议 +echo "6. Troubleshooting recommendations:" +echo " - Check webhook server logs: tail -f /var/log/webhook-server.log" +echo " - Test webhook URL manually: curl -X POST $webhook_url" +echo " - Verify SSL certificate: openssl s_client -connect $(echo $webhook_url | sed 's/https:\/\///' | sed 's/:443//')" + +echo "=== Troubleshooting Complete ===" +``` + +### 常见问题解决 + +```bash +#!/bin/bash +# common-webhook-fixes.sh + +# 问题1: Webhook 未触发 +fix_inactive_webhook() { + WEBHOOK_ID=$1 + echo "Fixing inactive webhook: $WEBHOOK_ID" + gitlink-cli webhook +update --id $WEBHOOK_ID --active true + gitlink-cli webhook +test --id $WEBHOOK_ID +} + +# 问题2: URL 配置错误 +fix_webhook_url() { + WEBHOOK_ID=$1 + CORRECT_URL=$2 + echo "Fixing webhook URL for: $WEBHOOK_ID" + gitlink-cli webhook +update --id $WEBHOOK_ID --url "$CORRECT_URL" + gitlink-cli webhook +test --id $WEBHOOK_ID +} + +# 问题3: 事件配置不完整 +fix_webhook_events() { + WEBHOOK_ID=$1 + DESIRED_EVENTS=$2 + echo "Updating webhook events for: $WEBHOOK_ID" + gitlink-cli webhook +update --id $WEBHOOK_ID --events "$DESIRED_EVENTS" +} + +# 问题4: 密钥过期 +rotate_webhook_secret() { + WEBHOOK_ID=$1 + NEW_SECRET=$(openssl rand -hex 32) + echo "Rotating secret for webhook: $WEBHOOK_ID" + gitlink-cli webhook +update --id $WEBHOOK_ID --secret "$NEW_SECRET" + echo "New secret: $NEW_SECRET" + echo "Please update the receiving server with the new secret" +} +``` + +--- + +## 场景6: 安全最佳实践 + +### 目标 +确保 Webhook 配置符合安全最佳实践。 + +### 安全配置检查 + +```bash +#!/bin/bash +# webhook-security-audit.sh + +echo "=== Webhook Security Audit ===" + +# 1. 检查所有 Webhook 是否使用 HTTPS +echo "1. Checking HTTPS usage..." +insecure_count=0 +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + url=$(gitlink-cli webhook +info --id $id --format json | jq -r '.data.hook_url') + if [[ ! $url =~ ^https:// ]]; then + echo "⚠ Insecure URL found: $url (webhook $id)" + insecure_count=$((insecure_count + 1)) + fi +done +if [ $insecure_count -eq 0 ]; then + echo "✓ All webhooks use HTTPS" +else + echo "✗ Found $insecure_count webhook(s) using non-HTTPS URLs" +fi + +# 2. 检查是否设置了密钥 +echo "2. Checking secret usage..." +no_secret_count=0 +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + has_secret=$(gitlink-cli webhook +info --id $id --format json | jq -r '.data.has_secret // false') + if [ "$has_secret" = "false" ]; then + echo "⚠ Webhook without secret: $id" + no_secret_count=$((no_secret_count + 1)) + fi +done +if [ $no_secret_count -eq 0 ]; then + echo "✓ All webhooks have secrets configured" +else + echo "⚠ $no_secret_count webhook(s) without secrets" +fi + +# 3. 检查 Webhook 数量 +echo "3. Checking webhook count..." +webhook_count=$(gitlink-cli webhook +list --format json | jq -r '.data.total_count') +if [ $webhook_count -gt 15 ]; then + echo "⚠ High webhook count: $webhook_count (consider cleanup)" +else + echo "✓ Reasonable webhook count: $webhook_count" +fi + +# 4. 检查不活跃的 Webhook +echo "4. Checking inactive webhooks..." +inactive_count=$(gitlink-cli webhook +list --format json | jq -r '[.data.webhooks[] | select(.is_active == false)] | length') +if [ $inactive_count -gt 0 ]; then + echo "⚠ Found $inactive_count inactive webhook(s)" + echo "Consider removing inactive webhooks:" + gitlink-cli webhook +list --format json | jq -r '.data.webhooks[] | select(.is_active == false) | "\(.id): \(.hook_url)"' +else + echo "✓ All webhooks are active" +fi + +echo "=== Security Audit Complete ===" +``` + +### 安全加固脚本 + +```bash +#!/bin/bash +# webhook-security-hardening.sh + +echo "=== Webhook Security Hardening ===" + +# 1. 为所有 Webhook 添加密钥 +echo "1. Adding secrets to webhooks without them..." +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + has_secret=$(gitlink-cli webhook +info --id $id --format json | jq -r '.data.has_secret // false') + if [ "$has_secret" = "false" ]; then + echo "Adding secret to webhook $id..." + new_secret=$(openssl rand -hex 32) + gitlink-cli webhook +update --id $id --secret "$new_secret" + echo "✓ Secret added. Save this secret: $new_secret" + fi +done + +# 2. 停用不必要的 Webhook +echo "2. Reviewing webhooks for necessity..." +gitlink-cli webhook +list --format json | jq -r '.data.webhooks[] | "\(.id): \(.description"' | while read -r webhook; do + echo "Webhook: $webhook" + read -p "Is this webhook still needed? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + webhook_id=$(echo $webhook | cut -d':' -f1) + gitlink-cli webhook +delete --id $webhook_id + echo "✓ Webhook deleted" + fi +done + +echo "=== Security Hardening Complete ===" +``` + +--- + +## 总结 + +这些工作流示例涵盖了 Webhook 管理的主要场景: + +1. **CI/CD 自动化** - 配置持续集成/部署 +2. **通知系统** - Issue 和 PR 消息通知 +3. **多环境部署** - 为不同环境配置独立 Webhook +4. **Webhook 迁移** - 安全地迁移 Webhook 配置 +5. **故障排查** - 诊断和修复 Webhook 问题 +6. **安全最佳实践** - 确保 Webhook 配置安全 + +使用这些示例作为起点,根据您的具体需求进行调整和扩展。 diff --git a/skills/gitlink-webhook/references/webhook-create.md b/skills/gitlink-webhook/references/webhook-create.md new file mode 100644 index 0000000..2fb8951 --- /dev/null +++ b/skills/gitlink-webhook/references/webhook-create.md @@ -0,0 +1,274 @@ +# gitlink-cli webhook +create + +创建新的 Webhook,用于自动化通知和集成。 + +## 命令格式 + +```bash +gitlink-cli webhook +create \ + --owner OWNER \ + --repo REPO \ + --url URL \ + [--events EVENTS] \ + [--active ACTIVE] \ + [--content_type CONTENT_TYPE] \ + [--secret SECRET] \ + [--description DESCRIPTION] +``` + +## 参数说明 + +| 参数 | 短参数 | 说明 | 是否必须 | 默认值 | +|------|--------|------|----------|--------| +| `--owner` | `-o` | 仓库所有者 | 否 | 自动从 git remote 解析 | +| `--repo` | `-r` | 仓库名称 | 否 | 自动从 git remote 解析 | +| `--url` | `-u` | Webhook 回调 URL | **是** | - | +| `--events` | `-e` | 触发事件(逗号分隔) | 否 | `push` | +| `--active` | - | 是否激活 | 否 | `true` | +| `--content_type` | - | 内容类型 | 否 | `json` | +| `--secret` | - | HMAC 验证密钥 | 否 | 空 | +| `--description` | `-d` | Webhook 描述 | 否 | 空 | + +### 事件类型 +支持的事件类型(多个事件用逗号分隔): +- `push` - 代码推送 +- `pull_request` - Pull 请求 +- `issue` - Issue 事件 +- `issue_assign` - Issue 指派 +- `issue_comment` - Issue 评论 +- `pull_request_assign` - PR 指派 +- `pull_request_comment` - PR 评论 +- `merge_request` - 合并请求 +- `repository` - 仓库事件 +- `branch` - 分支事件 +- `tag` - 标签事件 + +## 返回值 + +### 成功返回 +```json +{ + "ok": true, + "data": { + "id": "456", + "hook_url": "https://example.com/webhook", + "events": ["push", "pull_request"], + "is_active": true, + "content_type": "json", + "description": "CI/CD webhook", + "created_at": "2024-01-01T00:00:00Z", + "project": { + "owner": "myuser", + "repo": "myrepo" + } + }, + "meta": { + "identity": "user:myuser" + } +} +``` + +### 错误返回 +```json +{ + "ok": false, + "error": { + "code": 400, + "message": "Invalid webhook URL", + "suggestion": "Please provide a valid HTTPS URL" + } +} +``` + +## 使用示例 + +### 基本 Webhook +```bash +# 创建最简单的 Webhook(仅监听 push 事件) +gitlink-cli webhook +create --owner myuser --repo myrepo --url https://example.com/webhook + +# 在 git 仓库目录中创建(自动解析 owner/repo) +gitlink-cli webhook +create --url https://example.com/webhook +``` + +### 多事件 Webhook +```bash +# 监听多个事件 +gitlink-cli webhook +create \ + --owner myuser --repo myrepo \ + --url https://ci.example.com/webhook \ + --events push,pull_request,issue + +# 监听所有 PR 相关事件 +gitlink-cli webhook +create \ + --url https://review.example.com/webhook \ + --events pull_request,pull_request_assign,pull_request_comment +``` + +### 带密钥的 Webhook +```bash +# 创建带 HMAC 验证密钥的 Webhook +gitlink-cli webhook +create \ + --url https://ci.example.com/webhook \ + --events push \ + --secret my-secret-key-12345 + +# CI/CD 系统的 Webhook(推荐) +gitlink-cli webhook +create \ + --url https://jenkins.example.com/gitlink-webhook \ + --events push,pull_request \ + --secret jenkins-webhook-secret \ + --description "Jenkins CI trigger" +``` + +### 带描述的 Webhook +```bash +# 创建带描述的 Webhook +gitlink-cli webhook +create \ + --url https://notification.example.com/webhook \ + --events issue,issue_comment \ + --description "Issue notifications to Slack" +``` + +### 不激活的 Webhook +```bash +# 创建 Webhook 但暂时不激活 +gitlink-cli webhook +create \ + --url https://example.com/webhook \ + --events push \ + --active false \ + --description "Webhook for testing" +``` + +### 不同内容类型 +```bash +# JSON 格式(默认) +gitlink-cli webhook +create --url https://example.com/webhook --content-type json + +# Form 格式 +gitlink-cli webhook +create --url https://example.com/webhook --content-type form +``` + +## 错误处理 + +### 常见错误 + +#### 1. URL 无效 +```bash +Error: Invalid webhook URL format +``` +**原因**: URL 格式不正确或不是 HTTPS +**解决方案**: +```bash +# 使用 HTTPS URL +gitlink-cli webhook +create --url https://example.com/webhook +``` + +#### 2. 无效的事件类型 +```bash +Error: no valid events specified. Supported events: push, pull_request, issue, ... +``` +**原因**: 指定了不支持的事件类型 +**解决方案**: +```bash +# 查看支持的事件 +gitlink-cli webhook +events + +# 使用正确的事件类型 +gitlink-cli webhook +create --url https://example.com/webhook --events push,pull_request +``` + +#### 3. 权限不足 +```bash +Error: [403] You don't have permission to create webhooks +``` +**原因**: 用户不是仓库管理员 +**解决方案**: 确认您有仓库管理员权限 + +#### 4. Webhook 数量超限 +```bash +Error: [400] Webhook limit reached (maximum 20 webhooks per repository) +``` +**原因**: 仓库的 Webhook 数量已达上限 +**解决方案**: +```bash +# 删除不需要的 Webhook +gitlink-cli webhook +delete --id +``` + +## 最佳实践 + +### 1. 安全性 +```bash +# 始终为 Webhook 设置密钥 +gitlink-cli webhook +create \ + --url https://ci.example.com/webhook \ + --events push \ + --secret $(openssl rand -hex 32) + +# 使用 HTTPS URL +gitlink-cli webhook +create --url https://example.com/webhook +``` + +### 2. 事件选择 +```bash +# 只监听必要的事件 +gitlink-cli webhook +create \ + --url https://ci.example.com/webhook \ + --events push # CI 只需要 push 事件 +``` + +### 3. 描述清晰 +```bash +# 添加清晰的描述便于管理 +gitlink-cli webhook +create \ + --url https://jenkins.example.com/webhook \ + --events push,pull_request \ + --description "Production CI - Jenkins Pipeline" +``` + +## AI Agent 使用建议 + +### 验证 Webhook 创建 +```bash +# 创建后立即测试 +WEBHOOK_ID=$(gitlink-cli webhook +create --url $URL --events $EVENTS --format json | jq -r '.data.id') +gitlink-cli webhook +test --id $WEBHOOK_ID + +# 验证 Webhook 配置 +gitlink-cli webhook +info --id $WEBHOOK_ID +``` + +### 检查重复 Webhook +```bash +# 检查是否已存在相同 URL 的 Webhook +existing=$(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[] | select(.hook_url == "https://example.com/webhook") | .id') +if [ -n "$existing" ]; then + echo "Webhook already exists: $existing" +else + gitlink-cli webhook +create --url https://example.com/webhook +fi +``` + +## 安全建议 + +1. **使用密钥**: 始终设置 `--secret` 参数以验证请求来源 +2. **HTTPS**: 确保使用 HTTPS URL 保护数据传输 +3. **最小权限**: 只监听必要的事件类型 +4. **定期轮换**: 定期更新 Webhook 密钥 +5. **监控日志**: 监控 Webhook 请求日志以发现异常活动 + +## 注意事项 + +1. **URL 要求**: Webhook URL 必须是公网可访问的 HTTPS 地址 +2. **数量限制**: 每个仓库最多 20 个 Webhook +3. **权限要求**: 需要仓库管理员权限 +4. **事件格式**: 多个事件用逗号分隔,不要有空格 +5. **立即生效**: 创建后立即可用,除非设置 `--active false` + +## 相关命令 + +- `webhook +list` - 列出所有 Webhook +- `webhook +update` - 更新 Webhook 配置 +- `webhook +test` - 测试 Webhook +- `webhook +events` - 查看支持的事件类型 diff --git a/skills/gitlink-webhook/references/webhook-delete.md b/skills/gitlink-webhook/references/webhook-delete.md new file mode 100644 index 0000000..9f77f25 --- /dev/null +++ b/skills/gitlink-webhook/references/webhook-delete.md @@ -0,0 +1,282 @@ +# gitlink-cli webhook +delete + +删除指定的 Webhook。 + +## 命令格式 + +```bash +gitlink-cli webhook +delete [--owner OWNER] [--repo REPO] --id WEBHOOK_ID +``` + +## 参数说明 + +| 参数 | 短参数 | 说明 | 是否必须 | 默认值 | +|------|--------|------|----------|--------| +| `--owner` | `-o` | 仓库所有者 | 否 | 自动从 git remote 解析 | +| `--repo` | `-r` | 仓库名称 | 否 | 自动从 git remote 解析 | +| `--id` | `-i` | Webhook ID | **是** | - | + +## 返回值 + +### 成功返回 +```json +{ + "ok": true, + "data": { + "message": "Webhook deleted successfully" + }, + "meta": { + "identity": "user:myuser" + } +} +``` + +### 错误返回 +```json +{ + "ok": false, + "error": { + "code": 404, + "message": "Webhook not found", + "suggestion": "Please check the webhook ID" + } +} +``` + +## 使用示例 + +### 基本用法 +```bash +# 删除指定 Webhook +gitlink-cli webhook +delete --owner myuser --repo myrepo --id 456 + +# 在 git 仓库目录中删除(自动解析 owner/repo) +gitlink-cli webhook +delete --id 456 + +# 使用短参数 +gitlink-cli webhook +delete -i 456 +``` + +### 删除多个 Webhook +```bash +# 批量删除多个 Webhook +for id in 123 456 789; do + gitlink-cli webhook +delete --id $id +done +``` + +### 交互式删除 +```bash +# 先查看 Webhook 详情确认 +gitlink-cli webhook +info --id 456 + +# 确认后删除 +gitlink-cli webhook +delete --id 456 +``` + +## 错误处理 + +### 常见错误 + +#### 1. Webhook 不存在 +```bash +Error: [404] Webhook not found +``` +**原因**: 指定的 Webhook ID 不存在或已被删除 +**解决方案**: +```bash +# 先列出所有 Webhook 确认 ID +gitlink-cli webhook +list +``` + +#### 2. 权限不足 +```bash +Error: [403] You don't have permission to delete webhooks +``` +**原因**: 用户不是仓库管理员 +**解决方案**: 确认您有仓库管理员权限 + +#### 3. ID 参数缺失 +```bash +Error: required flag --id is missing +``` +**原因**: 没有提供 Webhook ID +**解决方案**: 指定要删除的 Webhook ID + +## 最佳实践 + +### 1. 删除前确认 +```bash +# 删除前先查看 Webhook 详情 +WEBHOOK_ID=456 +echo "About to delete webhook:" +gitlink-cli webhook +info --id $WEBHOOK_ID + +# 确认后删除 +read -p "Confirm deletion? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + gitlink-cli webhook +delete --id $WEBHOOK_ID +fi +``` + +### 2. 记录删除的 Webhook +```bash +# 删除前记录 Webhook 配置 +WEBHOOK_ID=456 +BACKUP_FILE="webhook_backup_$WEBHOOK_ID.json" +gitlink-cli webhook +info --id $WEBHOOK_ID --format json > $BACKUP_FILE +echo "Webhook config backed up to $BACKUP_FILE" + +# 然后删除 +gitlink-cli webhook +delete --id $WEBHOOK_ID +``` + +### 3. 批量清理不活跃的 Webhook +```bash +# 列出所有不活跃的 Webhook 并删除 +inactive_webhooks=$(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[] | select(.is_active == false) | .id') +for id in $inactive_webhooks; do + echo "Deleting inactive webhook: $id" + gitlink-cli webhook +delete --id $id +done +``` + +## AI Agent 使用建议 + +### 安全删除流程 +```bash +# AI Agent 删除 Webhook 的安全流程 +delete_webhook_safely() { + WEBHOOK_ID=$1 + + # 1. 检查 Webhook 是否存在 + if ! gitlink-cli webhook +info --id $WEBHOOK_ID --format json >/dev/null 2>&1; then + echo "Webhook $WEBHOOK_ID not found" + return 1 + fi + + # 2. 备份配置 + gitlink-cli webhook +info --id $WEBHOOK_ID --format json > "webhook_backup_$WEBHOOK_ID.json" + + # 3. 删除 Webhook + if gitlink-cli webhook +delete --id $WEBHOOK_ID; then + echo "Webhook $WEBHOOK_ID deleted successfully" + return 0 + else + echo "Failed to delete webhook $WEBHOOK_ID" + return 1 + fi +} +``` + +### 批量删除 Webhook +```bash +# 删除所有匹配特定条件的 Webhook +delete_webhooks_by_url() { + URL_PATTERN=$1 + + # 找到匹配的 Webhook + webhook_ids=$(gitlink-cli webhook +list --format json | \ + jq -r ".data.webhooks[] | select(.hook_url | contains(\"$URL_PATTERN\")) | .id") + + # 逐个删除 + for id in $webhook_ids; do + echo "Deleting webhook $id with URL matching $URL_PATTERN" + gitlink-cli webhook +delete --id $id + done +} + +# 使用示例:删除所有指向旧服务器的 Webhook +delete_webhooks_by_url "old-server.example.com" +``` + +### 验证删除 +```bash +# 删除 Webhook 并验证 +WEBHOOK_ID=456 + +# 删除前检查 +if gitlink-cli webhook +info --id $WEBHOOK_ID >/dev/null 2>&1; then + echo "Webhook exists, deleting..." + gitlink-cli webhook +delete --id $WEBHOOK_ID + + # 验证删除成功 + if ! gitlink-cli webhook +info --id $WEBHOOK_ID >/dev/null 2>&1; then + echo "Webhook deleted successfully" + else + echo "Webhook still exists after deletion" + fi +else + echo "Webhook not found" +fi +``` + +## 注意事项 + +1. **不可恢复**: 删除操作不可逆,请谨慎操作 +2. **立即生效**: 删除后立即停止接收事件 +3. **权限要求**: 需要仓库管理员权限 +4. **API 特性**: GitLink API 在删除时可能返回错误信息,但实际删除成功 +5. **验证删除**: 建议删除后验证 Webhook 是否已删除 + +## 常见使用场景 + +### 场景1: 清理测试 Webhook +```bash +# 删除所有测试环境的 Webhook +test_webhooks=$(gitlink-cli webhook +list --format json | \ + jq -r '.data.webhooks[] | select(.description | contains("test")) | .id') + +for id in $test_webhooks; do + echo "Deleting test webhook: $id" + gitlink-cli webhook +delete --id $id +done +``` + +### 场景2: 迁移到新 URL +```bash +# 迁移 Webhook 到新 URL +OLD_WEBHOOK_ID=456 +OLD_URL=$(gitlink-cli webhook +info --id $OLD_WEBHOOK_ID --format json | jq -r '.data.hook_url') +NEW_URL="https://new-server.example.com/webhook" + +# 创建新 Webhook +NEW_WEBHOOK_ID=$(gitlink-cli webhook +create --url $NEW_URL --events push --format json | jq -r '.data.id') + +# 测试新 Webhook +gitlink-cli webhook +test --id $NEW_WEBHOOK_ID + +# 确认新 Webhook 工作后删除旧 Webhook +gitlink-cli webhook +delete --id $OLD_WEBHOOK_ID +``` + +### 场景3: 批量重构 Webhook +```bash +# 重构所有 Webhook,重新创建后删除旧的 +# 1. 备份现有配置 +gitlink-cli webhook +list --format json > webhook_config_backup.json + +# 2. 根据备份创建新配置(可能使用不同的 URL 或事件) + +# 3. 删除旧的 Webhook +old_ids=$(jq -r '.data.webhooks[].id' webhook_config_backup.json) +for id in $old_ids; do + gitlink-cli webhook +delete --id $id +done +``` + +## 安全建议 + +1. **删除前备份**: 删除前备份 Webhook 配置 +2. **确认操作**: 删除前确认 Webhook ID 和配置 +3. **逐步删除**: 批量删除时逐步进行,避免误删 +4. **验证删除**: 删除后验证 Webhook 已被删除 +5. **权限控制**: 限制删除权限给授权用户 + +## 相关命令 + +- `webhook +list` - 列出所有 Webhook +- `webhook +info` - 查看 Webhook 详情 +- `webhook +create` - 创建新 Webhook +- `webhook +update` - 更新 Webhook(可以先用 `--active false` 停用) diff --git a/skills/gitlink-webhook/references/webhook-info.md b/skills/gitlink-webhook/references/webhook-info.md new file mode 100644 index 0000000..16833f4 --- /dev/null +++ b/skills/gitlink-webhook/references/webhook-info.md @@ -0,0 +1,382 @@ +# gitlink-cli webhook +info + +查看指定 Webhook 的详细信息。 + +## 命令格式 + +```bash +gitlink-cli webhook +info [--owner OWNER] [--repo REPO] --id WEBHOOK_ID +``` + +## 参数说明 + +| 参数 | 短参数 | 说明 | 是否必须 | 默认值 | +|------|--------|------|----------|--------| +| `--owner` | `-o` | 仓库所有者 | 否 | 自动从 git remote 解析 | +| `--repo` | `-r` | 仓库名称 | 否 | 自动从 git remote 解析 | +| `--id` | `-i` | Webhook ID | **是** | - | + +## 返回值 + +### 成功返回 +```json +{ + "ok": true, + "data": { + "id": "456", + "hook_url": "https://ci.example.com/webhook", + "events": ["push", "pull_request", "issue"], + "is_active": true, + "content_type": "json", + "description": "CI/CD automation webhook", + "project": { + "owner": "myuser", + "repo": "myrepo", + "identifier": "myuser/myrepo" + }, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-15T12:30:00Z", + "last_delivery": { + "timestamp": "2024-01-15T14:25:00Z", + "status": "success", + "event": "push", + "duration_ms": 245 + }, + "delivery_statistics": { + "total_deliveries": 1523, + "successful_deliveries": 1498, + "failed_deliveries": 25, + "success_rate": 98.36 + } + }, + "meta": { + "identity": "user:myuser" + } +} +``` + +### 错误返回 +```json +{ + "ok": false, + "error": { + "code": 404, + "message": "Webhook not found", + "suggestion": "Please check the webhook ID" + } +} +``` + +## 使用示例 + +### 基本用法 +```bash +# 查看 Webhook 详情 +gitlink-cli webhook +info --owner myuser --repo myrepo --id 456 + +# 在 git 仓库目录中查看(自动解析 owner/repo) +gitlink-cli webhook +info --id 456 + +# 使用短参数 +gitlink-cli webhook +info -i 456 +``` + +### 不同输出格式 +```bash +# JSON 格式(默认,便于解析) +gitlink-cli webhook +info --id 456 --format json + +# Table 格式(更易阅读) +gitlink-cli webhook +info --id 456 --format table + +# YAML 格式 +gitlink-cli webhook +info --id 456 --format yaml +``` + +### 提取特定信息 +```bash +# 使用 jq 提取 Webhook URL +gitlink-cli webhook +info --id 456 --format json | jq -r '.data.hook_url' + +# 查看 Webhook 是否激活 +gitlink-cli webhook +info --id 456 --format json | jq -r '.data.is_active' + +# 查看监听的事件类型 +gitlink-cli webhook +info --id 456 --format json | jq -r '.data.events[]' + +# 查看统计信息 +gitlink-cli webhook +info --id 456 --format json | jq '.data.delivery_statistics' +``` + +### 比较两个 Webhook +```bash +# 比较两个 Webhook 的配置 +echo "=== Webhook 456 ===" +gitlink-cli webhook +info --id 456 + +echo "=== Webhook 789 ===" +gitlink-cli webhook +info --id 789 +``` + +## 错误处理 + +### 常见错误 + +#### 1. Webhook 不存在 +```bash +Error: [404] Webhook not found +``` +**原因**: 指定的 Webhook ID 不存在 +**解决方案**: +```bash +# 先列出所有 Webhook 找到正确 ID +gitlink-cli webhook +list +``` + +#### 2. 权限不足 +```bash +Error: [403] You don't have permission to view webhook details +``` +**原因**: 用户没有仓库访问权限 +**解决方案**: 确认您是仓库成员 + +#### 3. ID 参数缺失 +```bash +Error: required flag --id is missing +``` +**原因**: 没有提供 Webhook ID +**解决方案**: 指定要查看的 Webhook ID + +## 最佳实践 + +### 1. 更新前查看 +```bash +# 更新 Webhook 前先查看当前配置 +WEBHOOK_ID=456 +echo "Current configuration:" +gitlink-cli webhook +info --id $WEBHOOK_ID + +# 然后进行更新 +gitlink-cli webhook +update --id $WEBHOOK_ID --url $NEW_URL +``` + +### 2. 批量查看 Webhook 信息 +```bash +# 查看所有 Webhook 的简要信息 +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + echo "=== Webhook $id ===" + gitlink-cli webhook +info --id $WEBHOOK_ID --format json | jq -r '.data | "\(.hook_url) - \(.description)"' +done +``` + +### 3. 验证 Webhook 配置 +```bash +# 检查 Webhook 是否正确配置 +check_webhook_config() { + WEBHOOK_ID=$1 + + # 获取 Webhook 信息 + info=$(gitlink-cli webhook +info --id $WEBHOOK_ID --format json) + + # 检查是否激活 + is_active=$(echo $info | jq -r '.data.is_active') + if [ "$is_active" != "true" ]; then + echo "WARNING: Webhook is not active" + fi + + # 检查是否有事件 + events=$(echo $info | jq -r '.data.events | length') + if [ "$events" -eq 0 ]; then + echo "WARNING: No events configured" + fi + + # 检查 URL 是否有效 + url=$(echo $info | jq -r '.data.hook_url') + if [[ ! $url =~ ^https:// ]]; then + echo "WARNING: URL does not use HTTPS" + fi + + # 显示成功率 + success_rate=$(echo $info | jq -r '.data.delivery_statistics.success_rate') + echo "Success rate: $success_rate%" +} +``` + +## AI Agent 使用建议 + +### 自动化 Webhook 配置检查 +```bash +# AI Agent 检查 Webhook 配置的自动化脚本 +analyze_webhook() { + WEBHOOK_ID=$1 + OUTPUT_FORMAT="${2:-json}" + + info=$(gitlink-cli webhook +info --id $WEBHOOK_ID --format $OUTPUT_FORMAT) + + if [ "$OUTPUT_FORMAT" = "json" ]; then + # JSON 格式便于解析 + echo "$info" | jq '.data | { + id, + url: .hook_url, + active: .is_active, + events: .events, + success_rate: .delivery_statistics.success_rate, + last_delivery: .last_delivery.timestamp + }' + else + # 其他格式直接输出 + echo "$info" + fi +} + +# 批量分析所有 Webhook +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + analyze_webhook $id +done +``` + +### Webhook 健康检查 +```bash +# 检查 Webhook 健康状态 +check_webhook_health() { + WEBHOOK_ID=$1 + + info=$(gitlink-cli webhook +info --id $WEBHOOK_ID --format json) + + # 提取关键指标 + is_active=$(echo $info | jq -r '.data.is_active') + success_rate=$(echo $info | jq -r '.data.delivery_statistics.success_rate') + last_delivery=$(echo $info | jq -r '.data.last_delivery.timestamp') + + # 健康评分 + health_score=100 + issues=() + + if [ "$is_active" != "true" ]; then + health_score=$((health_score - 50)) + issues+=("Webhook is not active") + fi + + if (( $(echo "$success_rate < 95" | bc -l) )); then + health_score=$((health_score - 30)) + issues+=("Success rate below 95%: $success_rate%") + fi + + if [ -z "$last_delivery" ] || [ "$last_delivery" = "null" ]; then + health_score=$((health_score - 20)) + issues+=("No recent deliveries") + fi + + # 输出结果 + echo "Webhook $WEBHOOK_ID Health Check" + echo "Health Score: $health_score/100" + if [ ${#issues[@]} -gt 0 ]; then + echo "Issues found:" + printf '%s\n' "${issues[@]}" + else + echo "✓ Webhook is healthy" + fi +} +``` + +### 配置差异分析 +```bash +# 比较两个 Webhook 的配置差异 +compare_webhooks() { + ID1=$1 + ID2=$2 + + info1=$(gitlink-cli webhook +info --id $ID1 --format json) + info2=$(gitlink-cli webhook +info --id $ID2 --format json) + + echo "=== Webhook Comparison ===" + echo "Webhook 1: $ID1" + echo "Webhook 2: $ID2" + echo + + # 比较 URL + url1=$(echo $info1 | jq -r '.data.hook_url') + url2=$(echo $info2 | jq -r '.data.hook_url') + echo "URL:" + echo " $ID1: $url1" + echo " $ID2: $url2" + [ "$url1" = "$url2" ] && echo " Status: Same" || echo " Status: Different" + echo + + # 比较事件 + events1=$(echo $info1 | jq -r '.data.events | sort | join(",")') + events2=$(echo $info2 | jq -r '.data.events | sort | join(",")') + echo "Events:" + echo " $ID1: $events1" + echo " $ID2: $events2" + [ "$events1" = "$events2" ] && echo " Status: Same" || echo " Status: Different" + echo + + # 比较激活状态 + active1=$(echo $info1 | jq -r '.data.is_active') + active2=$(echo $info2 | jq -r '.data.is_active') + echo "Active Status:" + echo " $ID1: $active1" + echo " $ID2: $active2" + [ "$active1" = "$active2" ] && echo " Status: Same" || echo " Status: Different" +} +``` + +## 注意事项 + +1. **权限要求**: 至少需要仓库读取权限 +2. **详细信息**: 包含 Webhook 的所有配置和统计信息 +3. **统计数据**: 部分统计信息可能为空,特别是新创建的 Webhook +4. **时间格式**: 所有时间戳均为 ISO 8601 格式(UTC) +5. **敏感信息**: 输出可能包含敏感信息,注意保护 + +## 常见使用场景 + +### 场景1: 确认 Webhook 配置 +```bash +# 确认 Webhook 配置是否正确 +gitlink-cli webhook +info --id 456 + +# 检查关键配置 +gitlink-cli webhook +info --id 456 --format json | jq -r '{ + url: .data.hook_url, + events: .data.events, + active: .data.is_active, + success_rate: .data.delivery_statistics.success_rate +}' +``` + +### 场景2: 故障排查 +```bash +# Webhook 出问题时查看详细信息 +gitlink-cli webhook +info --id 456 + +# 检查最近一次投递情况 +gitlink-cli webhook +info --id 456 --format json | jq '.data.last_delivery' + +# 查看失败统计 +gitlink-cli webhook +info --id 456 --format json | jq '.data.delivery_statistics' +``` + +### 场景3: 配置审计 +```bash +# 审计所有 Webhook 配置 +echo "=== Webhook Configuration Audit ===" +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + echo "Webhook $id:" + gitlink-cli webhook +info --id $id --format json | jq -r '{ + url: .data.hook_url, + events: .data.events | join(","), + active: .data.is_active, + description: .description + }' + echo +done +``` + +## 相关命令 + +- `webhook +list` - 列出所有 Webhook +- `webhook +create` - 创建新 Webhook +- `webhook +update` - 更新 Webhook 配置 +- `webhook +test` - 测试 Webhook diff --git a/skills/gitlink-webhook/references/webhook-list.md b/skills/gitlink-webhook/references/webhook-list.md new file mode 100644 index 0000000..e08e7c0 --- /dev/null +++ b/skills/gitlink-webhook/references/webhook-list.md @@ -0,0 +1,162 @@ +# gitlink-cli webhook +list + +列出仓库的所有 Webhook。 + +## 命令格式 + +```bash +gitlink-cli webhook +list [--owner OWNER] [--repo REPO] [--page PAGE] [--limit LIMIT] +``` + +## 参数说明 + +| 参数 | 短参数 | 说明 | 是否必须 | 默认值 | +|------|--------|------|----------|--------| +| `--owner` | `-o` | 仓库所有者 | 否 | 自动从 git remote 解析 | +| `--repo` | `-r` | 仓库名称 | 否 | 自动从 git remote 解析 | +| `--page` | `-p` | 页码 | 否 | 1 | +| `--limit` | `-l` | 每页数量 | 否 | 20 | + +## 返回值 + +### 成功返回 +```json +{ + "ok": true, + "data": { + "webhooks": [ + { + "id": "123", + "hook_url": "https://example.com/webhook", + "events": ["push", "pull_request"], + "is_active": true, + "content_type": "json", + "description": "CI/CD webhook", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + ], + "total_count": 5, + "page": 1, + "limit": 20 + }, + "meta": { + "page": 1, + "limit": 20, + "total_count": 5 + } +} +``` + +### 错误返回 +```json +{ + "ok": false, + "error": { + "code": 401, + "message": "Authentication failed", + "suggestion": "Please run 'gitlink-cli auth login' to authenticate" + } +} +``` + +## 使用示例 + +### 基本用法 +```bash +# 列出当前仓库的 Webhook(需要在 git 仓库目录中) +gitlink-cli webhook +list + +# 列出指定仓库的 Webhook +gitlink-cli webhook +list --owner myuser --repo myrepo + +# 分页显示 +gitlink-cli webhook +list --owner myuser --repo myrepo --page 2 --limit 10 +``` + +### JSON 格式输出(AI Agent 使用) +```bash +# 获取 JSON 格式输出便于解析 +gitlink-cli webhook +list --owner myuser --repo myrepo --format json + +# 使用 jq 处理输出 +gitlink-cli webhook +list --format json | jq '.data.webhooks[] | select(.is_active == true)' + +# 统计 Webhook 数量 +gitlink-cli webhook +list --format json | jq '.data.total_count' +``` + +### Table 格式输出 +```bash +# 表格格式更易阅读(默认) +gitlink-cli webhook +list --format table + +# 指定表格格式 +gitlink-cli webhook +list --owner myuser --repo myrepo --format table +``` + +## 错误处理 + +### 常见错误 + +#### 1. 认证失败 +```bash +Error: [401] Authentication failed +``` +**原因**: Token 过期或无效 +**解决方案**: +```bash +gitlink-cli auth login +``` + +#### 2. 权限不足 +```bash +Error: [403] You don't have permission to view webhooks +``` +**原因**: 用户没有仓库访问权限 +**解决方案**: 确认您是仓库成员或公开项目 + +#### 3. 仓库不存在 +```bash +Error: [404] Repository not found +``` +**原因**: 仓库名称或所有者错误 +**解决方案**: 使用 `gitlink-cli repo +list` 确认仓库名称 + +## AI Agent 使用建议 + +### 检查 Webhook 配置 +```bash +# 检查是否已配置特定类型的 Webhook +gitlink-cli webhook +list --format json | jq '.data.webhooks[] | select(.hook_url | contains("ci-system"))' + +# 检查是否有激活的 Webhook +gitlink-cli webhook +list --format json | jq '.data.webhooks[] | select(.is_active == true)' + +# 获取所有 Webhook 的 URL +gitlink-cli webhook +list --format json | jq '.data.webhooks[].hook_url' +``` + +### 批量操作 +```bash +# 获取所有 Webhook ID +webhook_ids=$(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id') + +# 批量测试所有 Webhook +for id in $webhook_ids; do + gitlink-cli webhook +test --id $id +done +``` + +## 注意事项 + +1. **分页查询**: 默认每页显示 20 个,使用 `--limit` 可调整 +2. **权限要求**: 至少需要仓库读取权限 +3. **自动解析**: 在 git 仓库目录中可省略 `--owner` 和 `--repo` +4. **格式选择**: AI Agent 建议使用 `--format json` 便于解析 + +## 相关命令 + +- `webhook +create` - 创建新 Webhook +- `webhook +info` - 查看特定 Webhook 详情 +- `webhook +events` - 查看支持的事件类型 diff --git a/skills/gitlink-webhook/references/webhook-test.md b/skills/gitlink-webhook/references/webhook-test.md new file mode 100644 index 0000000..eec922c --- /dev/null +++ b/skills/gitlink-webhook/references/webhook-test.md @@ -0,0 +1,378 @@ +# gitlink-cli webhook +test + +测试 Webhook 连接性,发送测试事件验证 Webhook 是否正常工作。 + +## 命令格式 + +```bash +gitlink-cli webhook +test \ + [--owner OWNER] \ + [--repo REPO] \ + --id WEBHOOK_ID \ + [--event EVENT_TYPE] +``` + +## 参数说明 + +| 参数 | 短参数 | 说明 | 是否必须 | 默认值 | +|------|--------|------|----------|--------| +| `--owner` | `-o` | 仓库所有者 | 否 | 自动从 git remote 解析 | +| `--repo` | `-r` | 仓库名称 | 否 | 自动从 git remote 解析 | +| `--id` | `-i` | Webhook ID | **是** | - | +| `--event` | `-e` | 测试的事件类型 | 否 | `push` | + +### 支持的测试事件 +- `push` - 推送事件(默认) +- `pull_request` - Pull 请求事件 +- `issue` - Issue 事件 +- 其他支持的事件类型 + +## 返回值 + +### 成功返回 +```json +{ + "ok": true, + "data": { + "message": "Webhook test triggered successfully", + "webhook_id": "456", + "event_type": "push", + "delivered": true, + "response_status": 200, + "response_body": "Webhook received" + }, + "meta": { + "identity": "user:myuser" + } +} +``` + +### Webhook 不可达 +```json +{ + "ok": true, + "data": { + "message": "Webhook test completed with warnings", + "webhook_id": "456", + "event_type": "push", + "delivered": false, + "error": "Connection timeout", + "suggestion": "Please check if the webhook URL is accessible" + } +} +``` + +### 错误返回 +```json +{ + "ok": false, + "error": { + "code": 404, + "message": "Webhook not found", + "suggestion": "Please check the webhook ID" + } +} +``` + +## 使用示例 + +### 基本测试 +```bash +# 测试 Webhook(默认使用 push 事件) +gitlink-cli webhook +test --owner myuser --repo myrepo --id 456 + +# 在 git 仓库目录中测试 +gitlink-cli webhook +test --id 456 + +# 使用短参数 +gitlink-cli webhook +test -i 456 +``` + +### 测试特定事件类型 +```bash +# 测试 pull_request 事件 +gitlink-cli webhook +test --id 456 --event pull_request + +# 测试 issue 事件 +gitlink-cli webhook +test --id 456 --event issue + +# 测试多种事件类型 +for event in push pull_request issue; do + echo "Testing event: $event" + gitlink-cli webhook +test --id 456 --event $event +done +``` + +### 批量测试所有 Webhook +```bash +# 测试仓库的所有 Webhook +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + echo "Testing webhook: $id" + gitlink-cli webhook +test --id $id +done +``` + +### 测试新创建的 Webhook +```bash +# 创建后立即测试 +WEBHOOK_ID=$(gitlink-cli webhook +create --url https://example.com/webhook --events push --format json | jq -r '.data.id') +echo "Testing new webhook: $WEBHOOK_ID" +gitlink-cli webhook +test --id $WEBHOOK_ID +``` + +## 错误处理 + +### 常见错误 + +#### 1. Webhook 不存在 +```bash +Error: [404] Webhook not found +``` +**原因**: 指定的 Webhook ID 不存在 +**解决方案**: +```bash +# 先列出所有 Webhook 找到正确 ID +gitlink-cli webhook +list +``` + +#### 2. 无效的事件类型 +```bash +Error: unsupported event type: custom_event +``` +**原因**: 指定了不支持的事件类型 +**解决方案**: +```bash +# 查看支持的事件类型 +gitlink-cli webhook +events + +# 使用支持的事件类型 +gitlink-cli webhook +test --id 456 --event push +``` + +#### 3. Webhook URL 不可达 +```bash +Warning: Webhook delivery failed - Connection timeout +``` +**原因**: Webhook URL 无法访问或服务器无响应 +**解决方案**: +```bash +# 1. 检查 URL 是否正确 +gitlink-cli webhook +info --id 456 + +# 2. 手动测试 URL +curl -X POST https://your-webhook-url.com/test + +# 3. 检查服务器防火墙和网络设置 +``` + +#### 4. SSL 证书问题 +```bash +Warning: Webhook delivery failed - SSL certificate verify failed +``` +**原因**: Webhook 服务器的 SSL 证书有问题 +**解决方案**: +```bash +# 检查 SSL 证书 +curl -v https://your-webhook-url.com/test + +# 更新服务器的 SSL 证书 +``` + +## 最佳实践 + +### 1. 创建后测试 +```bash +# 创建 Webhook 后立即测试 +WEBHOOK_ID=$(gitlink-cli webhook +create --url $URL --events $EVENTS --format json | jq -r '.data.id') +if gitlink-cli webhook +test --id $WEBHOOK_ID; then + echo "Webhook created and tested successfully" +else + echo "Webhook test failed, please check configuration" + gitlink-cli webhook +delete --id $WEBHOOK_ID +fi +``` + +### 2. 更新后测试 +```bash +# 更新 Webhook 后测试 +gitlink-cli webhook +update --id 456 --url $NEW_URL +gitlink-cli webhook +test --id 456 +``` + +### 3. 定期测试 +```bash +# 定期测试所有 Webhook 确保正常工作 +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + if ! gitlink-cli webhook +test --id $id; then + echo "WARNING: Webhook $id test failed" + fi +done +``` + +## AI Agent 使用建议 + +### 自动化测试流程 +```bash +# AI Agent 测试 Webhook 的完整流程 +test_and_fix_webhook() { + WEBHOOK_ID=$1 + MAX_RETRIES=3 + RETRY_COUNT=0 + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + echo "Testing webhook $WEBHOOK_ID (attempt $((RETRY_COUNT + 1))/$MAX_RETRIES)" + + # 测试 Webhook + if gitlink-cli webhook +test --id $WEBHOOK_ID; then + echo "✓ Webhook test successful" + return 0 + fi + + # 测试失败,等待后重试 + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then + echo "Test failed, waiting 5 seconds before retry..." + sleep 5 + fi + done + + echo "✗ Webhook test failed after $MAX_RETRIES attempts" + return 1 +} +``` + +### 监控 Webhook 健康 +```bash +# 定期检查所有 Webhook 的健康状态 +check_all_webhooks_health() { + REPORT_FILE="webhook_health_report_$(date +%Y%m%d_%H%M%S).txt" + + echo "Webhook Health Check Report - $(date)" > $REPORT_FILE + echo "=================================" >> $REPORT_FILE + + for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + webhook_info=$(gitlink-cli webhook +info --id $id --format json) + webhook_url=$(echo $webhook_info | jq -r '.data.hook_url') + webhook_status=$(echo $webhook_info | jq -r '.data.is_active') + + echo -e "\nWebhook ID: $id" >> $REPORT_FILE + echo "URL: $webhook_url" >> $REPORT_FILE + echo "Active: $webhook_status" >> $REPORT_FILE + echo "Test Result:" >> $REPORT_FILE + + if gitlink-cli webhook +test --id $id >> $REPORT_FILE 2>&1; then + echo "Status: HEALTHY ✓" >> $REPORT_FILE + else + echo "Status: UNHEALTHY ✗" >> $REPORT_FILE + fi + done + + cat $REPORT_FILE +} +``` + +### 故障诊断 +```bash +# 诊断 Webhook 问题 +diagnose_webhook() { + WEBHOOK_ID=$1 + + echo "=== Webhook Diagnosis ===" + echo "Webhook ID: $WEBHOOK_ID" + echo + + # 1. 检查 Webhook 是否存在 + echo "1. Checking webhook existence..." + if ! gitlink-cli webhook +info --id $WEBHOOK_ID >/dev/null 2>&1; then + echo " ✗ Webhook not found" + return 1 + fi + echo " ✓ Webhook exists" + + # 2. 获取 Webhook 配置 + echo "2. Webhook configuration:" + gitlink-cli webhook +info --id $WEBHOOK_ID + + # 3. 测试网络连通性 + echo "3. Testing network connectivity..." + WEBHOOK_URL=$(gitlink-cli webhook +info --id $WEBHOOK_ID --format json | jq -r '.data.hook_url') + if curl -s -o /dev/null -w "%{http_code}" "$WEBHOOK_URL" | grep -q "200\|301\|302"; then + echo " ✓ URL is accessible" + else + echo " ✗ URL is not accessible" + fi + + # 4. 测试 Webhook + echo "4. Testing webhook delivery..." + if gitlink-cli webhook +test --id $WEBHOOK_ID; then + echo " ✓ Webhook test successful" + else + echo " ✗ Webhook test failed" + fi +} +``` + +## 注意事项 + +1. **测试限制**: 测试事件不会触发实际的业务逻辑,仅验证连通性 +2. **请求格式**: 测试请求的格式与真实事件略有不同 +3. **响应时间**: Webhook 应在 10 秒内响应,否则超时 +4. **重试机制**: 测试失败不会触发 GitLink 的重试机制 +5. **权限要求**: 需要仓库管理员权限 + +## 常见使用场景 + +### 场景1: 验证新 Webhook +```bash +# 创建 Webhook 后验证配置 +WEBHOOK_ID=$(gitlink-cli webhook +create \ + --url https://ci.example.com/webhook \ + --events push,pull_request \ + --format json | jq -r '.data.id') + +# 测试各种事件类型 +for event in push pull_request; do + echo "Testing $event event..." + gitlink-cli webhook +test --id $WEBHOOK_ID --event $event +done +``` + +### 场景2: 故障排查 +```bash +# Webhook 未触发时进行测试 +# 1. 检查 Webhook 是否激活 +gitlink-cli webhook +info --id 456 + +# 2. 测试 Webhook 连通性 +gitlink-cli webhook +test --id 456 + +# 3. 查看详细错误信息 +gitlink-cli webhook +test --id 456 --debug +``` + +### 场景3: 批量验证 +```bash +# 验证所有 Webhook 在服务器迁移后是否正常 +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + echo "Testing webhook $id..." + if ! gitlink-cli webhook +test --id $id; then + echo "WARNING: Webhook $id needs attention" + # 可以在这里添加自动修复逻辑 + fi +done +``` + +## 安全建议 + +1. **避免敏感数据**: 测试事件可能包含真实数据,注意隐私保护 +2. **测试频率**: 不要过于频繁测试,避免对服务器造成压力 +3. **错误信息**: 测试失败时的错误信息可能暴露系统细节 +4. **访问控制**: 确保测试 URL 只暴露必要的信息 + +## 相关命令 + +- `webhook +list` - 列出所有 Webhook +- `webhook +info` - 查看 Webhook 详情 +- `webhook +create` - 创建新 Webhook +- `webhook +update` - 更新 Webhook 配置 +- `webhook +events` - 查看支持的事件类型 diff --git a/skills/gitlink-webhook/references/webhook-update.md b/skills/gitlink-webhook/references/webhook-update.md new file mode 100644 index 0000000..7958419 --- /dev/null +++ b/skills/gitlink-webhook/references/webhook-update.md @@ -0,0 +1,277 @@ +# gitlink-cli webhook +update + +更新现有 Webhook 的配置。 + +## 命令格式 + +```bash +gitlink-cli webhook +update \ + --owner OWNER \ + --repo REPO \ + --id WEBHOOK_ID \ + [--url URL] \ + [--events EVENTS] \ + [--active ACTIVE] \ + [--content_type CONTENT_TYPE] \ + [--secret SECRET] \ + [--description DESCRIPTION] +``` + +## 参数说明 + +| 参数 | 短参数 | 说明 | 是否必须 | 默认值 | +|------|--------|------|----------|--------| +| `--owner` | `-o` | 仓库所有者 | 否 | 自动从 git remote 解析 | +| `--repo` | `-r` | 仓库名称 | 否 | 自动从 git remote 解析 | +| `--id` | `-i` | Webhook ID | **是** | - | +| `--url` | `-u` | 新的 Webhook URL | 否 | 不修改 | +| `--events` | `-e` | 新的触发事件 | 否 | 不修改 | +| `--active` | - | 是否激活 | 否 | 不修改 | +| `--content_type` | - | 内容类型 | 否 | 不修改 | +| `--secret` | - | 新的密钥 | 否 | 不修改 | +| `--description` | `-d` | 新的描述 | 否 | 不修改 | + +## 返回值 + +### 成功返回 +```json +{ + "ok": true, + "data": { + "id": "456", + "hook_url": "https://new-url.example.com/webhook", + "events": ["push", "pull_request", "issue"], + "is_active": false, + "content_type": "json", + "description": "Updated webhook description", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T12:00:00Z" + }, + "meta": { + "identity": "user:myuser" + } +} +``` + +### 错误返回 +```json +{ + "ok": false, + "error": { + "code": 404, + "message": "Webhook not found", + "suggestion": "Please check the webhook ID" + } +} +``` + +## 使用示例 + +### 更新 URL +```bash +# 修改 Webhook 回调地址 +gitlink-cli webhook +update --owner myuser --repo myrepo --id 456 --url https://new-url.example.com/webhook + +# 在 git 仓库目录中更新 +gitlink-cli webhook +update --id 456 --url https://new-url.example.com/webhook +``` + +### 更新事件 +```bash +# 添加更多事件类型 +gitlink-cli webhook +update --id 456 --events push,pull_request,issue,issue_comment + +# 减少事件类型(只监听 push) +gitlink-cli webhook +update --id 456 --events push +``` + +### 激活/停用 Webhook +```bash +# 停用 Webhook +gitlink-cli webhook +update --id 456 --active false + +# 重新激活 Webhook +gitlink-cli webhook +update --id 456 --active true +``` + +### 更新密钥 +```bash +# 更新 Webhook 密钥(推荐定期轮换) +gitlink-cli webhook +update --id 456 --secret new-secret-key-2024 +``` + +### 更新描述 +```bash +# 更新 Webhook 描述 +gitlink-cli webhook +update --id 456 --description "Updated for new CI/CD pipeline" +``` + +### 批量更新多个属性 +```bash +# 同时更新多个属性 +gitlink-cli webhook +update \ + --id 456 \ + --url https://new-url.example.com/webhook \ + --events push,pull_request,issue \ + --secret new-secret \ + --description "Comprehensive webhook update" +``` + +## 错误处理 + +### 常见错误 + +#### 1. Webhook 不存在 +```bash +Error: [404] Webhook not found +``` +**原因**: 指定的 Webhook ID 不存在 +**解决方案**: +```bash +# 先列出所有 Webhook 找到正确 ID +gitlink-cli webhook +list +``` + +#### 2. 无效的事件类型 +```bash +Error: no valid events specified +``` +**原因**: 指定了不支持的事件类型 +**解决方案**: +```bash +# 查看支持的事件 +gitlink-cli webhook +events +``` + +#### 3. 权限不足 +```bash +Error: [403] You don't have permission to update webhooks +``` +**原因**: 用户不是仓库管理员 +**解决方案**: 确认您有仓库管理员权限 + +#### 4. 没有指定更新字段 +```bash +Error: no fields specified for update +``` +**原因**: 没有提供任何要更新的字段 +**解决方案**: 至少指定一个要更新的字段 + +## 最佳实践 + +### 1. 密钥轮换 +```bash +# 定期更新密钥(建议每3个月) +gitlink-cli webhook +update --id 456 --secret $(openssl rand -hex 32) +``` + +### 2. 临时停用 +```bash +# 临时停用 Webhook 进行维护 +gitlink-cli webhook +update --id 456 --active false + +# 维护完成后重新激活 +gitlink-cli webhook +update --id 456 --active true +``` + +### 3. 渐进式更新 +```bash +# 先测试新配置 +gitlink-cli webhook +update --id 456 --url https://new-url.example.com/webhook --active false +gitlink-cli webhook +test --id 456 + +# 确认无误后激活 +gitlink-cli webhook +update --id 456 --active true +``` + +## AI Agent 使用建议 + +### 批量更新 Webhook +```bash +# 为所有 Webhook 添加新事件 +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + # 获取当前事件 + current_events=$(gitlink-cli webhook +info --id $id --format json | jq -r '.data.events | join(",")') + # 添加新事件 + gitlink-cli webhook +update --id $id --events "$current_events,issue_comment" +done +``` + +### 验证更新 +```bash +# 更新后立即验证 +WEBHOOK_ID=456 +gitlink-cli webhook +update --id $WEBHOOK_ID --url $NEW_URL +gitlink-cli webhook +info --id $WEBHOOK_ID +gitlink-cli webhook +test --id $WEBHOOK_ID +``` + +### 检查更新前后差异 +```bash +# 查看更新前后配置差异 +BEFORE=$(gitlink-cli webhook +info --id 456 --format json) +gitlink-cli webhook +update --id 456 --url $NEW_URL +AFTER=$(gitlink-cli webhook +info --id 456 --format json) + +# 对比差异(需要 jq 工具) +echo "Before:" && echo "$BEFORE" | jq '.data' +echo "After:" && echo "$AFTER" | jq '.data' +``` + +## 安全建议 + +1. **密钥轮换**: 定期更新 Webhook 密钥,建议每3个月一次 +2. **测试新配置**: 更新重要配置前先停用,测试后再激活 +3. **备份配置**: 更新前记录原配置,便于回滚 +4. **权限验证**: 确保只有授权用户能修改 Webhook +5. **审计日志**: 记录所有 Webhook 配置变更 + +## 注意事项 + +1. **部分更新**: 只更新指定的字段,未指定的字段保持不变 +2. **ID 不变**: 更新不会改变 Webhook ID +3. **立即生效**: 更新后立即生效,除非停用 Webhook +4. **测试验证**: 建议更新后测试 Webhook 是否正常工作 +5. **权限要求**: 需要仓库管理员权限 + +## 常见使用场景 + +### 场景1: 迁移 Webhook 到新服务器 +```bash +# 更新 Webhook URL 到新服务器 +gitlink-cli webhook +update --id 456 --url https://new-server.example.com/webhook +# 测试新地址 +gitlink-cli webhook +test --id 456 +``` + +### 场景2: 调整事件监听 +```bash +# 原来只监听 push,现在增加 PR 监听 +gitlink-cli webhook +update --id 456 --events push,pull_request +``` + +### 场景3: 安全密钥轮换 +```bash +# 定期更新密钥提高安全性 +NEW_SECRET=$(openssl rand -hex 32) +gitlink-cli webhook +update --id 456 --secret "$NEW_SECRET" +# 更新接收服务器的密钥配置 +# 然后测试 +gitlink-cli webhook +test --id 456 +``` + +### 场景4: 临时维护 +```bash +# 临时停用 Webhook +gitlink-cli webhook +update --id 456 --active false --description "Maintenance in progress" + +# 维护完成后重新激活 +gitlink-cli webhook +update --id 456 --active true --description "Production webhook" +``` + +## 相关命令 + +- `webhook +list` - 列出所有 Webhook +- `webhook +create` - 创建新 Webhook +- `webhook +info` - 查看 Webhook 详情 +- `webhook +test` - 测试 Webhook diff --git a/skills/gitlink-wiki/examples/wiki-workflow.md b/skills/gitlink-wiki/examples/wiki-workflow.md new file mode 100644 index 0000000..18138f7 --- /dev/null +++ b/skills/gitlink-wiki/examples/wiki-workflow.md @@ -0,0 +1,959 @@ +# Wiki 工作流示例 + +本文档提供了使用 `gitlink-cli wiki` 命令的完整工作流示例,涵盖从简单到复杂的各种场景。 + +## 目录 + +- [基础工作流](#基础工作流) +- [项目文档初始化](#项目文档初始化) +- [文档维护工作流](#文档维护工作流) +- [批量操作](#批量操作) +- [AI Agent 集成](#ai-agent-集成) +- [故障排除](#故障排除) + +--- + +## 基础工作流 + +### 工作流 1: 创建单个 Wiki 页面 + +**场景**: 为项目创建首页 + +```bash +#!/bin/bash +# 1. 创建首页 +gitlink-cli wiki +create --title "Home" --content '# Project Home + +## Overview +This project is a CLI tool for GitLink platform. + +## Features +- Repository management +- Issue tracking +- Pull requests + +## Documentation +- [Getting Started](Getting-Started) +- [API Reference](API-Reference) +- [Contributing](Contributing) + +## Support +- [FAQ](FAQ) +- [Contact Us](Contact-Us)' + +# 2. 验证创建结果 +gitlink-cli wiki +view --title "Home" + +# 3. 列出所有页面 +gitlink-cli wiki +list +``` + +**预期结果**: +- 创建了标题为 "Home" 的 Wiki 页面 +- 页面包含导航链接和项目概述 +- 可通过 `wiki +list` 和 `wiki +view` 验证 + +--- + +## 项目文档初始化 + +### 工作流 2: 创建完整项目文档结构 + +**场景**: 为新项目创建完整的 Wiki 文档体系 + +```bash +#!/bin/bash +# init-project-wiki.sh + +set -e # 遇到错误立即退出 + +echo "=== Initializing Project Wiki ===" + +# 1. 创建首页 +echo "Creating Home page..." +gitlink-cli wiki +create --title "Home" --content '# Project Documentation + +Welcome to the project documentation! + +## Quick Links +- 📚 [Getting Started](Getting-Started) - New user guide +- 📖 [API Reference](API-Reference) - API documentation +- 🤝 [Contributing](Contributing) - Contribution guide +- ❓ [FAQ](FAQ) - Frequently asked questions + +## Overview +This project provides a comprehensive CLI tool for GitLink platform management. + +## Status +- Version: 1.0.0 +- License: MIT +- Support: See [Contact Us](Contact-Us)' + +# 2. 创建入门指南 +echo "Creating Getting Started guide..." +gitlink-cli wiki +create --title "Getting-Started" --content '# Getting Started + +## Installation + +### Prerequisites +- Node.js 14+ +- GitLink account + +### Install via npm +\`\`\`bash +npm install -g gitlink-cli +\`\`\` + +### Install from source +\`\`\`bash +git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git +cd gitlink-cli +make install +\`\`\` + +## Configuration + +### Initialize config +\`\`\`bash +gitlink-cli config init +\`\`\` + +### Login +\`\`\`bash +gitlink-cli auth login +\`\`\` + +## Verify Installation +\`\`\`bash +gitlink-cli --version +gitlink-cli user +me +\`\`\`' + +# 3. 创建 API 文档 +echo "Creating API Reference..." +gitlink-cli wiki +create --title "API-Reference" --content '# API Reference + +## Repository Operations + +### List repositories +\`\`\`bash +gitlink-cli repo +list +\`\`\` + +### Create repository +\`\`\`bash +gitlink-cli repo +create -n my-project -d "Project description" +\`\`\` + +## Issue Operations + +### List issues +\`\`\`bash +gitlink-cli issue +list --owner user --repo project +\`\`\` + +### Create issue +\`\`\`bash +gitlink-cli issue +create -t "Bug title" -b "Bug description" +\`\`\` + +## Pull Request Operations + +### List PRs +\`\`\`bash +gitlink-cli pr +list --owner user --repo project +\`\`\` + +### Create PR +\`\`\`bash +gitlink-cli pr +create --head feature --base main -t "Feature title" +\`\`\`' + +# 4. 创建贡献指南 +echo "Creating Contributing guide..." +gitlink-cli wiki +create --title "Contributing" --content '# Contributing + +Thank you for your interest in contributing! + +## How to Contribute + +### Report Bugs +Create an issue with the bug report template. + +### Submit Changes +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request + +## Development Workflow + +### Setup Development Environment +\`\`\`bash +git clone https://www.gitlink.org.cn/YOUR_USERNAME/gitlink-cli.git +cd gitlink-cli +make install +\`\`\` + +### Run Tests +\`\`\`bash +make test +\`\`\` + +### Code Style +- Follow Go conventions +- Add tests for new features +- Update documentation + +## Pull Request Guidelines + +### PR Title Format +- \`feat: add new feature\` +- \`fix: fix bug description\` +- \`docs: update documentation\` + +### PR Description +Include: +- Problem statement +- Solution approach +- Testing performed +- Related issues' + +# 5. 创建 FAQ +echo "Creating FAQ..." +gitlink-cli wiki +create --title "FAQ" --content '# Frequently Asked Questions + +## General Questions + +### Q: What is gitlink-cli? +A: GitLink CLI is a command-line tool for managing GitLink platform resources. + +### Q: How do I install gitlink-cli? +A: Run \`npm install -g gitlink-cli\` or build from source. + +## Authentication + +### Q: How do I authenticate? +A: Run \`gitlink-cli auth login\` and provide your credentials. + +### Q: How long does the token last? +A: Tokens expire after 7 days. Re-authenticate when expired. + +## Troubleshooting + +### Q: Command not found +A: Ensure npm global bin is in your PATH: \`export PATH=\$PATH:\$(npm config get prefix)/bin\` + +### Q: Permission denied +A: Run \`gitlink-cli auth login\` to re-authenticate. + +## More Help +- See [Getting Started](Getting-Started) +- Check [API Reference](API-Reference) +- Contact: [Contact Us](Contact-Us)' + +# 6. 创建联系我们页面 +echo "Creating Contact Us page..." +gitlink-cli wiki +create --title "Contact-Us" --content '# Contact Us + +## Get Help + +### Documentation +- [Getting Started](Getting-Started) +- [API Reference](API-Reference) +- [FAQ](FAQ) + +### Community +- Forum: [GitLink Forum](https://forum.gitlink.org.cn) +- Chat: [Gitter Channel](https://gitter.im/gitlink-cli) + +### Report Issues +- Bug Reports: [Issue Tracker](https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues) +- Feature Requests: [Issue Tracker](https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues) + +## Development Team + +### Maintainers +- @maintainer1 - Project lead +- @maintainer2 - Core development + +### Contributors +See [CONTRIBUTORS.md](https://www.gitlink.org.cn/Gitlink/gitlink-cli/blob/master/CONTRIBUTORS.md) + +## License +This project is licensed under the MulanPSL-2.0 License. + +## Acknowledgments +Thanks to all contributors who have helped improve this project!' + +echo "=== Wiki Initialization Complete ===" +echo "Created 6 documentation pages:" +gitlink-cli wiki +list +``` + +**关键特性**: +- ✅ 创建了完整的文档结构 +- ✅ 页面之间有交叉引用链接 +- ✅ 包含代码示例和命令 +- ✅ 覆盖了项目的所有主要方面 + +--- + +## 文档维护工作流 + +### 工作流 3: 更新文档内容 + +**场景**: 文档需要定期更新以反映项目变化 + +```bash +#!/bin/bash +# update-documentation.sh + +page_title="API-Reference" +backup_file="wiki-backup-$(date '+%Y%m%d-%H%M%S').md" + +echo "=== Safe Wiki Update Workflow ===" + +# 1. 备份当前内容 +echo "Step 1: Backing up current content..." +gitlink-cli wiki +view --title "$page_title" --format json | \ + jq -r ".data.content_decoded" > "$backup_file" +echo "✓ Backup saved: $backup_file" + +# 2. 显示当前内容预览 +echo "" +echo "Step 2: Current content preview:" +head -n 10 "$backup_file" +echo "..." + +# 3. 编辑内容(使用临时文件) +temp_file="temp-wiki-update.md" +cp "$backup_file" "$temp_file" + +echo "" +echo "Step 3: Edit the content in: $temp_file" +echo "Press Enter when done editing..." +read + +# 4. 确认更新 +echo "" +echo "Step 4: Review changes:" +echo "--- Old content (first 5 lines) ---" +head -n 5 "$backup_file" +echo "--- New content (first 5 lines) ---" +head -n 5 "$temp_file" +echo "---" + +read -p "Apply changes? (y/N) " -n 1 -r +echo + +if [[ $REPLY =~ ^[Yy]$ ]]; then + # 5. 执行更新 + echo "Step 5: Applying update..." + gitlink-cli wiki +update --title "$page_title" --file "$temp_file" + + # 6. 验证结果 + echo "Step 6: Verifying update..." + gitlink-cli wiki +view --title "$page_title" --format json | \ + jq -r ".data.content_decoded" > "updated-content.md" + + if diff -q "$temp_file" "updated-content.md" >/dev/null; then + echo "✓ Update successful!" + rm "$temp_file" "updated-content.md" + else + echo "✗ Update verification failed!" + echo "Backup available at: $backup_file" + fi +else + echo "✗ Update cancelled." + echo "Backup available at: $backup_file" + rm "$temp_file" +fi +``` + +--- + +### 工作流 4: 追加更新日志 + +**场景**: 在文档末尾追加更新日志 + +```bash +#!/bin/bash +# append-changelog.sh + +page_title="Home" +changelog_content=" + +--- + +## Changelog + +### v$(date '+%Y.%m.%d') +- Updated documentation structure +- Added new examples +- Fixed typos and errors +- Improved API references" + +echo "=== Appending Changelog to $page_title ===" + +# 1. 查看当前末尾内容 +echo "Current page ending:" +gitlink-cli wiki +view --title "$page_title" --format json | \ + jq -r ".data.content_decoded" | tail -n 5 + +# 2. 确认追加 +echo "" +echo "Content to append:" +echo "$changelog_content" + +read -p "Append changelog? (y/N) " -n 1 -r +echo + +if [[ $REPLY =~ ^[Yy]$ ]]; then + # 3. 追加内容 + gitlink-cli wiki +update --title "$page_title" --add "$changelog_content" + echo "✓ Changelog appended successfully!" + + # 4. 验证 + echo "" + echo "Updated page ending:" + gitlink-cli wiki +view --title "$page_title" --format json | \ + jq -r ".data.content_decoded" | tail -n 10 +else + echo "✗ Append cancelled." +fi +``` + +--- + +## 批量操作 + +### 工作流 5: 从本地目录批量导入 Wiki + +**场景**: 将本地的 Markdown 文档批量导入到 Wiki + +```bash +#!/bin/bash +# batch-import-wiki.sh + +wiki_docs_dir="./wiki-docs" +backup_dir="wiki-import-backup-$(date '+%Y%m%d-%H%M%S')" + +echo "=== Batch Wiki Import ===" + +# 1. 检查目录 +if [ ! -d "$wiki_docs_dir" ]; then + echo "Error: Directory '$wiki_docs_dir' not found." + echo "Please create it and add your Markdown files." + exit 1 +fi + +# 2. 创建备份目录 +mkdir -p "$backup_dir" + +# 3. 统计文件 +md_files=("$wiki_docs_dir"/*.md) +total_files=${#md_files[@]} + +echo "Found $total_files Markdown files in '$wiki_docs_dir'" + +# 4. 遍历导入 +success_count=0 +skip_count=0 +error_count=0 + +for mdfile in "${md_files[@]}"; do + # 从文件名提取标题(去掉 .md 后缀) + filename=$(basename "$mdfile") + title="${filename%.md}" + + echo "" + echo "Processing: $filename" + + # 检查页面是否已存在 + if gitlink-cli wiki +view --title "$title" >/dev/null 2>&1; then + echo " ⚠️ Page '$title' already exists. Skipping." + ((skip_count++)) + + # 备份现有页面 + gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "$backup_dir/$filename" + continue + fi + + # 创建新页面 + if gitlink-cli wiki +create --title "$title" --file "$mdfile" 2>/dev/null; then + echo " ✓ Created: $title" + ((success_count++)) + else + echo " ✗ Failed: $title" + ((error_count++)) + + # 失败时备份文件 + cp "$mdfile" "$backup_dir/" + fi +done + +# 5. 显示统计 +echo "" +echo "=== Import Summary ===" +echo "Total files: $total_files" +echo "✓ Created: $success_count" +echo "⚠️ Skipped: $skip_count (already exists)" +echo "✗ Failed: $error_count" + +if [ $error_count -gt 0 ]; then + echo "" + echo "Failed files backed up to: $backup_dir" +fi + +# 6. 列出当前所有页面 +echo "" +echo "Current Wiki pages:" +gitlink-cli wiki +list +``` + +--- + +### 工作流 6: 批量导出 Wiki 为本地文件 + +**场景**: 将所有 Wiki 页面导出为本地 Markdown 文件 + +```bash +#!/bin/bash +# batch-export-wiki.sh + +export_dir="wiki-export-$(date '+%Y%m%d-%H%M%S')" + +echo "=== Batch Wiki Export ===" + +# 1. 创建导出目录 +mkdir -p "$export_dir" +echo "Export directory: $export_dir" + +# 2. 获取所有页面标题 +titles=$(gitlink-cli wiki +list --format json | jq -r '.data[].title') +total_titles=$(echo "$titles" | wc -l) + +echo "Found $total_titles Wiki pages" + +# 3. 遍历导出 +success_count=0 +error_count=0 + +for title in $titles; do + # 清理文件名(替换特殊字符) + filename=$(echo "$title" | sed 's/[^a-zA-Z0-9_-]/_/g').md + + echo "Exporting: $title -> $filename" + + # 导出页面内容 + if gitlink-cli wiki +view --title "$title" --format json | \ + jq -r '.data.content_decoded' > "$export_dir/$filename" 2>/dev/null; then + echo " ✓ Exported: $filename" + ((success_count++)) + else + echo " ✗ Failed: $title" + ((error_count++)) + fi +done + +# 4. 显示统计 +echo "" +echo "=== Export Summary ===" +echo "Total pages: $total_titles" +echo "✓ Exported: $success_count" +echo "✗ Failed: $error_count" + +# 5. 创建索引文件 +echo "# Wiki Export Index" > "$export_dir/README.md" +echo "" >> "$export_dir/README.md" +echo "Export Date: $(date)" >> "$export_dir/README.md" +echo "" >> "$export_dir/README.md" +echo "## Pages" >> "$export_dir/README.md" +echo "" >> "$export_dir/README.md" + +for title in $titles; do + filename=$(echo "$title" | sed 's/[^a-zA-Z0-9_-]/_/g').md + echo "- [$title]($filename)" >> "$export_dir/README.md" +done + +echo "" +echo "✓ Index created: $export_dir/README.md" +echo "Export completed: $export_dir" +``` + +--- + +### 工作流 7: 批量重命名页面 + +**场景**: 统一 Wiki 页面命名规范 + +```bash +#!/bin/bash +# batch-rename-wiki.sh + +# 定义重命名规则(旧标题 -> 新标题) +declare -A rename_rules=( + ["api"]="API-Reference" + ["getting started"]="Getting-Started" + ["user guide"]="User-Guide" + ["faq"]="FAQ" + ["home"]="Home" +) + +echo "=== Batch Wiki Rename ===" + +# 1. 显示重命名计划 +echo "Planned renames:" +for old_title in "${!rename_rules[@]}"; do + new_title="${rename_rules[$old_title]}" + echo " '$old_title' -> '$new_title'" +done + +# 2. 确认执行 +read -p "Proceed with renaming? (yes/NO) " -r +echo + +if [[ ! "$REPLY" == "yes" ]]; then + echo "✗ Renaming cancelled." + exit 0 +fi + +# 3. 执行重命名 +success_count=0 +skip_count=0 +error_count=0 + +for old_title in "${!rename_rules[@]}"; do + new_title="${rename_rules[$old_title]}" + + echo "" + echo "Renaming: '$old_title' -> '$new_title'" + + # 检查旧页面是否存在 + if ! gitlink-cli wiki +view --title "$old_title" >/dev/null 2>&1; then + echo " ⚠️ Old page '$old_title' not found. Skipping." + ((skip_count++)) + continue + fi + + # 检查新页面是否已存在 + if gitlink-cli wiki +view --title "$new_title" >/dev/null 2>&1; then + echo " ⚠️ Target page '$new_title' already exists. Skipping." + ((skip_count++)) + continue + fi + + # 执行重命名 + if gitlink-cli wiki +update --page "$old_title" --title "$new_title" 2>/dev/null; then + echo " ✓ Renamed successfully" + ((success_count++)) + else + echo " ✗ Rename failed" + ((error_count++)) + fi +done + +# 4. 显示统计 +echo "" +echo "=== Rename Summary ===" +echo "Total planned: ${#rename_rules[@]}" +echo "✓ Renamed: $success_count" +echo "⚠️ Skipped: $skip_count" +echo "✗ Failed: $error_count" + +# 5. 列出当前所有页面 +echo "" +echo "Current Wiki pages:" +gitlink-cli wiki +list +``` + +--- + +## AI Agent 集成 + +### 工作流 8: AI Agent 自动文档管理 + +**场景**: AI Agent 自动维护项目文档 + +```python +#!/usr/bin/env python3 +# ai_wiki_manager.py - AI Agent Wiki 管理示例 + +import subprocess +import json +import os +from datetime import datetime + +class WikiManager: + """GitLink Wiki 管理器 - 为 AI Agent 设计""" + + def __init__(self, owner, repo): + self.owner = owner + self.repo = repo + self.base_cmd = ["gitlink-cli", "--owner", owner, "--repo", repo] + + def run_command(self, command): + """执行 gitlink-cli 命令并返回结果""" + try: + full_cmd = self.base_cmd + command + result = subprocess.run( + full_cmd, + capture_output=True, + text=True, + check=True + ) + return result.stdout + except subprocess.CalledProcessError as e: + print(f"Command failed: {' '.join(full_cmd)}") + print(f"Error: {e.stderr}") + return None + + def list_pages(self): + """列出所有 Wiki 页面""" + output = self.run_command(["wiki", "+list", "--format", "json"]) + if output: + data = json.loads(output) + return data.get("data", []) + return [] + + def get_page_content(self, title): + """获取指定页面的内容""" + output = self.run_command( + ["wiki", "+view", "--title", title, "--format", "json"] + ) + if output: + data = json.loads(output) + return data.get("data", {}).get("content_decoded", "") + return None + + def create_page(self, title, content): + """创建新页面""" + # 创建临时文件 + temp_file = f"/tmp/wiki_{title}.md" + with open(temp_file, 'w') as f: + f.write(content) + + # 从文件创建 + result = self.run_command( + ["wiki", "+create", "--title", title, "--file", temp_file] + ) + + # 清理临时文件 + os.remove(temp_file) + return result is not None + + def update_page(self, title, content, mode="cover"): + """更新页面内容 + + Args: + title: 页面标题 + content: 新内容 + mode: 更新模式 ("cover" 或 "add") + """ + temp_file = f"/tmp/wiki_update_{title}.md" + with open(temp_file, 'w') as f: + f.write(content) + + if mode == "cover": + result = self.run_command( + ["wiki", "+update", "--title", title, "--file", temp_file] + ) + else: # add mode + result = self.run_command( + ["wiki", "+update", "--title", title, "--add", "", + "--file", temp_file] + ) + + os.remove(temp_file) + return result is not None + + def delete_page(self, title): + """删除页面""" + result = self.run_command(["wiki", "+delete", "--title", title]) + return result is not None + + def search_in_pages(self, keyword): + """在所有页面中搜索关键词""" + pages = self.list_pages() + results = [] + + for page in pages: + title = page.get("title", "") + content = self.get_page_content(title) + + if content and keyword.lower() in content.lower(): + results.append({ + "title": title, + "url": page.get("sub_url", ""), + "preview": self.get_preview(content, keyword) + }) + + return results + + def get_preview(self, content, keyword, context=50): + """获取关键词周围的预览文本""" + index = content.lower().find(keyword.lower()) + if index == -1: + return "" + + start = max(0, index - context) + end = min(len(content), index + len(keyword) + context) + return content[start:end] + + +# AI Agent 使用示例 +def ai_agent_example(): + """AI Agent 自动维护文档的示例""" + + # 初始化 Wiki 管理器 + wiki = WikiManager("Gitlink", "forgeplus") + + print("=== AI Agent Wiki Manager ===") + + # 1. 检查文档完整性 + print("\n1. Checking documentation completeness...") + required_pages = ["Home", "Getting-Started", "API-Reference", "FAQ"] + current_pages = [p.get("title") for p in wiki.list_pages()] + + missing_pages = set(required_pages) - set(current_pages) + if missing_pages: + print(f" ⚠️ Missing pages: {missing_pages}") + # AI Agent 可以自动创建缺失的页面 + else: + print(" ✓ All required pages exist") + + # 2. 检查过时内容 + print("\n2. Checking for outdated content...") + outdated_keywords = ["version 0.9", "deprecated", "coming soon"] + for keyword in outdated_keywords: + results = wiki.search_in_pages(keyword) + if results: + print(f" ⚠️ Found '{keyword}' in:") + for result in results: + print(f" - {result['title']}") + # AI Agent 可以标记这些页面需要更新 + + # 3. 自动更新版本信息 + print("\n3. Auto-updating version information...") + home_content = wiki.get_page_content("Home") + if home_content and "Version: 1.0.0" in home_content: + new_version = "1.0.1" + updated_content = home_content.replace("1.0.0", new_version) + if wiki.update_page("Home", updated_content, "cover"): + print(f" ✓ Updated version to {new_version}") + + # 4. 生成统计报告 + print("\n4. Generating statistics...") + pages = wiki.list_pages() + total_pages = len(pages) + + print(f" Total pages: {total_pages}") + print(f" Last updated: {datetime.now().strftime('%Y-%m-%d')}") + + # 计算每个页面的字符数 + for page in pages: + title = page['title'] + content = wiki.get_page_content(title) + if content: + char_count = len(content) + print(f" - {title}: {char_count} characters") + + +if __name__ == "__main__": + ai_agent_example() +``` + +--- + +## 故障排除 + +### 工作流 9: 常见问题诊断 + +```bash +#!/bin/bash +# wiki-diagnose.sh - Wiki 问题诊断工具 + +echo "=== Wiki Diagnostic Tool ===" + +# 1. 检查认证状态 +echo "1. Checking authentication..." +if gitlink-cli auth status 2>/dev/null | grep -q "Logged in"; then + echo " ✓ Authentication OK" +else + echo " ✗ Authentication failed" + echo " Solution: Run 'gitlink-cli auth login'" + exit 1 +fi + +# 2. 检查网络连接 +echo "2. Checking network connectivity..." +if curl -s -o /dev/null -w "%{http_code}" https://www.gitlink.org.cn | grep -q "200\|301\|302"; then + echo " ✓ Network connectivity OK" +else + echo " ✗ Network connectivity failed" + echo " Solution: Check your internet connection" +fi + +# 3. 检查 Gateway API 可用性 +echo "3. Checking Gateway API..." +if curl -s -o /dev/null -w "%{http_code}" https://gateway.gitlink.org.cn/api | grep -q "200\|301\|302"; then + echo " ✓ Gateway API available" +else + echo " ✗ Gateway API unavailable" + echo " Solution: Gateway API may be down, try again later" +fi + +# 4. 检查项目权限 +echo "4. Checking project permissions..." +if gitlink-cli repo +info >/dev/null 2>&1; then + echo " ✓ Project access OK" +else + echo " ✗ Project access failed" + echo " Solution: Check if --owner and --repo are correct" +fi + +# 5. 测试 Wiki 功能 +echo "5. Testing Wiki functionality..." +page_count=$(gitlink-cli wiki +list --format json 2>/dev/null | jq '.meta.total_count // 0') +if [ "$page_count" -ge 0 ]; then + echo " ✓ Wiki功能正常 (当前页面数: $page_count)" +else + echo " ✗ Wiki功能异常" + echo " Solution: Wiki may not be enabled for this project" +fi + +# 6. 显示诊断总结 +echo "" +echo "=== Diagnostic Summary ===" +echo "如果以上检查都通过,Wiki 功能应该可以正常使用。" +echo "如果仍有问题,请检查:" +echo " 1. 页面标题是否正确(区分大小写)" +echo " 2. 是否有足够的权限操作 Wiki" +echo " 3. 网络连接是否稳定" +echo " 4. GitLink 平台是否正常运行" +``` + +--- + +## 总结 + +本文档提供了从基础到高级的 Wiki 工作流示例,涵盖: + +- ✅ **基础操作**: 创建、查看、更新、删除 +- ✅ **项目初始化**: 完整的文档结构建立 +- ✅ **文档维护**: 安全的更新和追加工作流 +- ✅ **批量处理**: 导入、导出、重命名批量操作 +- ✅ **AI 集成**: Python 实现的自动化管理 +- ✅ **故障排除**: 诊断和问题解决 + +这些工作流可以直接使用或根据具体需求调整。 + +## 相关文档 + +- [gitlink-wiki](../SKILL.md) — Wiki 功能总览 +- [wiki +list](../references/wiki-list.md) — 列出页面 +- [wiki +create](../references/wiki-create.md) — 创建页面 +- [wiki +update](../references/wiki-update.md) — 更新页面 +- [wiki +delete](../references/wiki-delete.md) — 删除页面 diff --git a/skills/gitlink-wiki/references/wiki-create.md b/skills/gitlink-wiki/references/wiki-create.md new file mode 100644 index 0000000..bc0b071 --- /dev/null +++ b/skills/gitlink-wiki/references/wiki-create.md @@ -0,0 +1,423 @@ +# wiki +create + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **⚠️ 写入操作** — 执行前必须确认用户意图。 + +创建新的 Wiki 页面。支持直接提供内容或从文件读取。 + +## 命令 + +```bash +# 创建简单页面(使用 --content) +gitlink-cli wiki +create --title "Home" --content "# Welcome\n\nThis is the home page." + +# 创建页面(从文件读取) +gitlink-cli wiki +create --title "API Reference" --file api.md + +# 创建页面并添加提交消息 +gitlink-cli wiki +create --title "Getting Started" \ + --content "# Getting Started\n\n..." \ + --message "Initial documentation" + +# 创建多行内容页面 +gitlink-cli wiki +create --title "Guide" --content "# User Guide + +## Installation +Run the following command: + +\`\`\`bash +npm install +\`\`\` + +## Usage +\`\`\`bash +npm start +\`\`\`" +``` + +## 参数 + +| 参数 | Short | 必填 | 说明 | +|------|-------|------|------| +| `--title` | `-t` | **是** | Wiki 页面标题 | +| `--content` | `-c` | **是*** | Wiki 页面内容(纯文本,与 `--file` 二选一) | +| `--file` | `-f` | **是*** | 从文件读取内容(与 `--content` 二选一) | +| `--message` | `-m` | 否 | 提交消息(可选) | +| `--owner` | | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | | 否 | 仓库名称(自动从 git remote 解析) | +| `--format` | | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | | 否 | 开启调试输出 | + +* `--content` 和 `--file` 必须指定其中一个 + +## 返回字段 + +### Table 格式 + +| 列名 | 说明 | +|------|------| +| `title` | 创建的页面标题 | +| `message` | 操作结果消息 | + +### JSON 格式 + +```json +{ + "ok": true, + "data": { + "title": "Home", + "message": "Wiki page created successfully" + } +} +``` + +## Workflow + +1. **Confirm** the page title and content with the user. +2. **Check** if the page already exists (optional, use `wiki +view`). +3. **Execute** `gitlink-cli wiki +create --title "" --content "<content>"`. +4. **Report** the creation result and page URL. + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent before executing. + +## API + +``` +POST https://gateway.gitlink.org.cn/api/wiki/open/createWiki +Body: { + "owner": "...", + "repo": "...", + "projectId": 123, + "pageName": "<title>", + "title": "<title>", + "content_base64": "<base64-encoded-content>", + "message": "<optional-message>" +} +``` + +**工作流程**: +1. CLI 获取 `project_id` +2. 将内容 Base64 编码为 `content_base64` +3. 调用 Gateway API 创建页面 +4. 返回创建结果 + +## 使用场景 + +### 场景 1: 创建首页 + +当用户请求"创建项目首页"时: + +```bash +gitlink-cli wiki +create --title "Home" \ + --content "# Project Home + +## Overview +This project is a CLI tool for GitLink platform. + +## Features +- Repository management +- Issue tracking +- Pull requests + +## Getting Started +See the [Getting Started](Getting-Started) page." +``` + +### 场景 2: 从现有文件创建 + +```bash +# 从 README.md 创建 Wiki +gitlink-cli wiki +create --title "Home" --file README.md + +# 从多个文件创建多个页面 +gitlink-cli wiki +create --title "API Reference" --file docs/api.md +gitlink-cli wiki +create --title "User Guide" --file docs/guide.md +``` + +### 场景 3: 创建代码文档 + +```bash +gitlink-cli wiki +create --title "CLI Reference" --content "# CLI Commands + +## Repository Commands +\`\`\`bash +gitlink-cli repo +list +gitlink-cli repo +create -n my-project +\`\`\` + +## Issue Commands +\`\`\`bash +gitlink-cli issue +list +gitlink-cli issue +create -t \"Bug: ...\" +\`\`\`" +``` + +### 场景 4: 批量创建 Wiki 页面 + +```bash +#!/bin/bash +# 从 docs/ 目录批量创建 Wiki 页面 + +for mdfile in docs/*.md; do + # 从文件名提取标题(去掉 .md 后缀) + title=$(basename "$mdfile" .md) + + echo "Creating Wiki page: $title" + gitlink-cli wiki +create --title "$title" --file "$mdfile" +done +``` + +## 内容编码 + +### Base64 自动处理 + +**无需手动编码** — CLI 自动处理: + +```bash +# CLI 会自动将以下内容 Base64 编码 +gitlink-cli wiki +create --title "Test" --content "Hello, World!" + +# 等效于手动编码(不推荐) +gitlink-cli api POST "https://gateway.gitlink.org.cn/api/wiki/open/createWiki" \ + --body '{ + "owner": "...", + "repo": "...", + "projectId": 123, + "pageName": "Test", + "title": "Test", + "content_base64": "SGVsbG8sIFdvcmxkIQ==" + }' +``` + +### 多行内容处理 + +```bash +# 方法 1: 使用 \n 换行 +gitlink-cli wiki +create --title "Test" \ + --content "Line 1\nLine 2\nLine 3" + +# 方法 2: 使用 $'' 引号(支持 \n) +gitlink-cli wiki +create --title "Test" --content $'Line 1\nLine 2\nLine 3' + +# 方法 3: 从文件读取(推荐) +cat << 'EOF' > temp.md +Line 1 +Line 2 +Line 3 +EOF +gitlink-cli wiki +create --title "Test" --file temp.md +``` + +## 常见问题 + +### Q: 创建失败提示 "page already exists"? + +**A:** 页面标题已存在。解决方法: +```bash +# 查看现有页面 +gitlink-cli wiki +list + +# 使用不同的标题,或先删除现有页面 +gitlink-cli wiki +delete --title "Old Title" +gitlink-cli wiki +create --title "New Title" --content "..." +``` + +### Q: 内容显示格式错误? + +**A:** 确保: +1. Markdown 语法正确 +2. 使用 `\n` 表示换行(单行字符串) +3. 或从文件读取(保留原始格式) + +### Q: 如何创建包含代码块的页面? + +**A:** 使用正确的 Markdown 语法: +```bash +gitlink-cli wiki +create --title "Code Examples" \ + --content '# Code Examples + +## JavaScript +\`\`\`javascript +console.log("Hello"); +\`\`\` + +## Python +\`\`\`python +print("Hello") +\`\`\`' +``` + +### Q: 支持哪些 Markdown 语法? + +**A:** GitLink Wiki 支持: +- 标题 (`#`, `##`, `###`) +- 列表(有序、无序) +- 代码块(```) +- 链接 (`[text](url)`) +- 图片 (`![alt](url)`) +- 表格 +- 粗体、斜体、引用 + +### Q: 可以创建 HTML 内容吗? + +**A:** GitLink Wiki 主要支持 Markdown,部分 HTML 可能被过滤。建议使用标准 Markdown 语法。 + +## 错误处理 + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `required flag --title is missing` | 未指定标题 | 添加 `--title "Page Title"` | +| `--content or --file is required` | 未提供内容 | 添加 `--content "..."` 或 `--file file.md` | +| `failed to read file` | 文件不存在或无权限 | 检查文件路径和权限 | +| `page already exists` | 标题已存在 | 使用不同标题或先删除现有页面 | +| `401 Unauthorized` | 未登录或 Token 过期 | 运行 `gitlink-cli auth login` | +| `403 Forbidden` | 无权限创建 Wiki | 检查是否有项目写入权限 | + +## 最佳实践 + +### 1. 标题命名规范 + +```bash +# 推荐:使用连字符连接单词 +"Getting-Started" +"API-Reference" +"User-Guide" + +# 避免:空格和特殊字符 +"Getting Started" # 需要引号 +"API/Reference" # 斜杠可能被误解 +``` + +### 2. 内容模板 + +创建文档时使用标准模板: + +```bash +gitlink-cli wiki +create --title "Page Title" --content '# Page Title + +## Overview +Brief description of the page. + +## Details +Detailed content. + +## Examples +\`\`\`bash +Example code +\`\`\` + +## See Also +- [Related Page 1](Related-Page-1) +- [Related Page 2](Related-Page-2)' +``` + +### 3. 从文件创建 + +对于复杂内容,先创建文件再导入: + +```bash +# 1. 创建本地 Markdown 文件 +cat > home.md << 'EOF' +# Home + +Welcome to the project! +EOF + +# 2. 从文件创建 Wiki +gitlink-cli wiki +create --title "Home" --file home.md + +# 3. 清理临时文件 +rm home.md +``` + +### 4. 批量创建工作流 + +```bash +#!/bin/bash +# 批量创建项目文档 + +# 定义页面列表 +declare -A pages=( + ["Home"]="home.md" + ["Getting-Started"]="getting-started.md" + ["API-Reference"]="api.md" + ["FAQ"]="faq.md" +) + +# 遍历创建 +for title in "${!pages[@]}"; do + file="${pages[$title]}" + if [ -f "$file" ]; then + echo "Creating: $title from $file" + gitlink-cli wiki +create --title "$title" --file "$file" + else + echo "Warning: $file not found, skipping $title" + fi +done +``` + +## 完整示例 + +### 示例:创建完整项目 Wiki + +```bash +#!/bin/bash +# 为新项目创建完整的 Wiki 文档结构 + +# 1. 创建首页 +gitlink-cli wiki +create --title "Home" --content '# Project Home + +## Overview +This is a demonstration project for gitlink-cli Wiki. + +## Documentation +- [Getting Started](Getting-Started) +- [API Reference](API-Reference) +- [Contributing](Contributing) + +## Support +- [FAQ](FAQ) +- [Contact Us](Contact-Us)' + +# 2. 创建入门指南 +gitlink-cli wiki +create --title "Getting-Started" --content '# Getting Started + +## Installation +\`\`\`bash +npm install +\`\`\` + +## Configuration +\`\`\`bash +cp .env.example .env +\`\`\` + +## Running +\`\`\`bash +npm start +\`\`\`' + +# 3. 创建 API 文档 +gitlink-cli wiki +create --title "API-Reference" --content '# API Reference + +## Endpoints + +### GET /api/users +Get user information. + +### POST /api/issues +Create a new issue. + +## Examples +See the [Examples](Examples) page.' + +echo "Wiki documentation structure created successfully!" +``` + +## References + +- [gitlink-wiki](../SKILL.md) +- [wiki +update](wiki-update.md) — 更新 Wiki 页面 +- [wiki +delete](wiki-delete.md) — 删除 Wiki 页面 +- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-wiki/references/wiki-delete.md b/skills/gitlink-wiki/references/wiki-delete.md new file mode 100644 index 0000000..75f0109 --- /dev/null +++ b/skills/gitlink-wiki/references/wiki-delete.md @@ -0,0 +1,539 @@ +# wiki +delete + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **⚠️ 危险操作** — 删除操作**无法撤销**,执行前必须确认用户意图。 + +删除指定的 Wiki 页面。⚠️ **此操作不可逆!** + +## 命令 + +```bash +# 删除 Wiki 页面 +gitlink-cli wiki +delete --title "Old Page" + +# 删除指定仓库的 Wiki 页面 +gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --title "Outdated" + +# 删除前先确认(推荐) +gitlink-cli wiki +view --title "Page to Delete" # 先查看内容 +gitlink-cli wiki +delete --title "Page to Delete" # 再删除 +``` + +## 参数 + +| 参数 | Short | 必填 | 说明 | +|------|-------|------|------| +| `--title` | `-t` | **是** | 要删除的 Wiki 页面标题 | +| `--owner` | | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | | 否 | 仓库名称(自动从 git remote 解析) | +| `--format` | | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | | 否 | 开启调试输出 | + +## 返回字段 + +### Table 格式 + +| 列名 | 说明 | +|------|------| +| `message` | 操作结果消息 | + +### JSON 格式 + +```json +{ + "ok": true, + "data": { + "message": "Wiki page deleted successfully" + } +} +``` + +## Workflow + +1. **Confirm** the page title to delete. +2. **Warning** that this operation is **irreversible**. +3. **Optional**: View current content with `wiki +view` for final verification. +4. **Execute** `gitlink-cli wiki +delete --title "<page title>"`. +5. **Report** the deletion result. + +> [!DANGER] +> **删除操作无法撤销!** 建议执行前先备份内容: +> ```bash +> gitlink-cli wiki +view --title "Page" > backup.md +> gitlink-cli wiki +delete --title "Page" +> ``` + +## API + +``` +DELETE https://gateway.gitlink.org.cn/api/wiki/open/deleteWiki +Body: { + "owner": "...", + "repo": "...", + "projectId": 123, + "pageName": "<title>", + "message": "" +} +``` + +**工作流程**: +1. CLI 获取 `project_id` +2. 调用 Gateway API 删除页面 +3. 验证删除是否成功(尝试获取页面) +4. 返回删除结果 + +**删除验证逻辑**: +- 如果删除 API 返回成功 → 删除成功 +- 如果删除 API 失败,尝试获取页面: + - 页面不存在 → 删除成功 + - 页面仍存在 → 删除失败 + +## 使用场景 + +### 场景 1: 删除过时文档 + +```bash +# 查看过时内容 +gitlink-cli wiki +view --title "Old API Reference" + +# 确认后删除 +gitlink-cli wiki +delete --title "Old API Reference" +``` + +### 场景 2: 清理测试页面 + +```bash +# 列出所有页面 +gitlink-cli wiki +list + +# 删除测试页面 +gitlink-cli wiki +delete --title "Test Page 1" +gitlink-cli wiki +delete --title "Test Page 2" +gitlink-cli wiki +delete --title "Test Page 3" +``` + +### 场景 3: 批量删除(谨慎!) + +```bash +#!/bin/bash +# 批量删除包含特定关键词的页面 + +for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + # 删除包含 "Draft" 的页面 + if [[ "$title" == *"Draft"* ]]; then + echo "Deleting draft page: $title" + gitlink-cli wiki +delete --title "$title" + fi +done +``` + +### 场景 4: 删除前备份 + +```bash +#!/bin/bash +# 安全删除工作流:先备份再删除 + +title="Page to Delete" + +# 1. 备份内容 +gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "${title}-backup.md" + +# 2. 确认删除 +read -p "Backup created at ${title}-backup.md. Delete now? (y/N) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + gitlink-cli wiki +delete --title "$title" + echo "Page deleted. Backup saved at ${title}-backup.md" +else + echo "Deletion cancelled." +fi +``` + +### 场景 5: 条件删除 + +```bash +#!/bin/bash +# 根据页面内容决定是否删除 + +title="Deprecated Feature" + +# 获取页面内容 +content=$(gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded") + +# 检查是否包含"已废弃"标记 +if [[ "$content" == *"此功能已废弃"* ]]; then + echo "Page is deprecated. Deleting..." + gitlink-cli wiki +delete --title "$title" +else + echo "Page is still active. Not deleting." +fi +``` + +## 删除验证 + +### 方法 1: 尝试查看页面 + +```bash +# 删除后验证 +gitlink-cli wiki +delete --title "Test Page" + +# 尝试查看(应该返回 404) +gitlink-cli wiki +view --title "Test Page" +# 预期输出:Error: 404 Not Found +``` + +### 方法 2: 列出所有页面 + +```bash +# 删除前列出 +gitlink-cli wiki +list +# 包含: "Test Page" + +gitlink-cli wiki +delete --title "Test Page" + +# 删除后列出 +gitlink-cli wiki +list +# 不包含: "Test Page" +``` + +### 方法 3: 统计页面数量 + +```bash +# 删除前 +before=$(gitlink-cli wiki +list --format json | jq ".meta.total_count") +echo "Pages before: $before" + +# 删除 +gitlink-cli wiki +delete --title "Old Page" + +# 删除后 +after=$(gitlink-cli wiki +list --format json | jq ".meta.total_count") +echo "Pages after: $after" +echo "Deleted: $((before - after)) page(s)" +``` + +## 常见问题 + +### Q: 删除后能否恢复? + +**A:** **不能**。GitLink Wiki 不提供版本历史或回收站功能。 + +**建议**: +1. 删除前务必备份:`gitlink-cli wiki +view --title "Page" > backup.md` +2. 考虑使用重命名代替删除:`gitlink-cli wiki +update --page "Old" --title "Old-Archived"` +3. 如有备份,可重新创建:`gitlink-cli wiki +create --title "Page" --file backup.md` + +### Q: 删除失败提示 "page not found"? + +**A:** 页面不存在或已被删除。 + +**解决方法**: +```bash +# 查看现有页面 +gitlink-cli wiki +list + +# 确认页面标题正确(区分大小写) +gitlink-cli wiki +delete --title "Correct-Title" +``` + +### Q: 删除后页面链接还能访问吗? + +**A:** 访问已删除页面会返回 **404 Not Found**。 + +如果有外部链接指向该页面,需要: +1. 更新外部链接 +2. 或创建同名新页面 +3. 或设置重定向(GitLink Wiki 不支持,需手动更新) + +### Q: 能否批量删除所有页面? + +**A:** **可以,但极其危险!** + +```bash +#!/bin/bash +# ⚠️ 危险操作:删除所有 Wiki 页面 + +read -p "⚠️ This will delete ALL wiki pages. Continue? (yes/NO) " -r +if [[ "$REPLY" == "yes" ]]; then + for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + echo "Deleting: $title" + gitlink-cli wiki +delete --title "$title" + done + echo "All pages deleted." +else + echo "Cancelled." +fi +``` + +### Q: 删除操作需要什么权限? + +**A:** 需要: +- **项目写入权限**(Maintainer 或 Owner 角色) +- **有效的认证 Token** + +如果权限不足: +```bash +# 403 Forbidden → 检查权限 +# 401 Unauthorized → 运行 gitlink-cli auth login +``` + +### Q: 如何防止误删除? + +**A:** 建议: +1. **删除前备份**:总是先备份内容 +2. **使用别名**:创建安全删除别名 +3. **确认操作**:删除前再次确认 +4. **文档规范**:制定删除流程文档 + +## 错误处理 + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `required flag --title is missing` | 未指定页面标题 | 添加 `--title "Page Title"` | +| `page not found` | 页面不存在 | 使用 `wiki +list` 查看可用页面 | +| `401 Unauthorized` | 未登录或 Token 过期 | 运行 `gitlink-cli auth login` | +| `403 Forbidden` | 无权限删除 Wiki | 检查是否有项目写入权限 | +| `failed to delete` | 删除操作失败 | 检查网络连接和 API 可用性 | + +## 安全措施 + +### 1. 删除前备份脚本 + +```bash +#!/bin/bash +# safe-delete.sh - 安全删除 Wiki 页面 + +title="$1" + +if [ -z "$title" ]; then + echo "Usage: ./safe-delete.sh '<Page Title>'" + exit 1 +fi + +# 1. 检查页面是否存在 +if ! gitlink-cli wiki +view --title "$title" >/dev/null 2>&1; then + echo "Error: Page '$title' does not exist." + exit 1 +fi + +# 2. 备份内容 +backup_file="${title}-backup-$(date '+%Y%m%d-%H%M%S').md" +gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "$backup_file" + +echo "✓ Backup created: $backup_file" + +# 3. 显示预览 +echo "" +echo "Page content preview:" +head -n 10 "$backup_file" +echo "..." + +# 4. 确认删除 +read -p "Delete '$title' now? (yes/NO) " -r +echo +if [[ "$REPLY" == "yes" ]]; then + gitlink-cli wiki +delete --title "$title" + if [ $? -eq 0 ]; then + echo "✓ Page deleted. Backup saved at: $backup_file" + else + echo "✗ Deletion failed. Backup available at: $backup_file" + fi +else + echo "✗ Deletion cancelled. Backup saved at: $backup_file" +fi +``` + +### 2. 创建删除日志 + +```bash +#!/bin/bash +# deleted-pages.log - 记录所有删除操作 + +log_file="wiki-deletion-log.txt" +title="$1" + +# 记录删除操作 +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deleted: $title" >> "$log_file" + +# 执行删除 +gitlink-cli wiki +delete --title "$title" + +echo "Deletion logged to: $log_file" +``` + +### 3. 使用 Git 追踪删除 + +如果 Wiki 内容也在 Git 中管理: + +```bash +# 1. Git commit 删除前的状态 +git add docs/ +git commit -m "Backup before wiki deletion: $title" + +# 2. 执行删除 +gitlink-cli wiki +delete --title "$title" + +# 3. 记录删除 +echo "Deleted $title on $(date)" >> wiki-deletions.log +``` + +## 最佳实践 + +### 1. 删除前检查清单 + +在删除 Wiki 页面前,确保: + +- [ ] 已备份页面内容 +- [ ] 确认页面不再需要 +- [ ] 更新了相关链接 +- [ ] 通知了相关团队成员 +- [ ] 有权限执行删除操作 + +### 2. 替代删除的方案 + +**考虑使用重命名代替删除**: + +```bash +# 不删除,而是重命名为"已归档" +gitlink-cli wiki +update --page "Old Feature" --title "Archived-Old-Feature" + +# 或在页面顶部添加废弃标记 +gitlink-cli wiki +update --title "Old Feature" \ + --add "\n\n---\n\n⚠️ **此页面已废弃,请勿使用。**" +``` + +### 3. 批量删除的安全流程 + +```bash +#!/bin/bash +# 安全批量删除工作流 + +# 1. 列出待删除页面 +declare -a pages_to_delete=( + "Test-Page-1" + "Test-Page-2" + "Draft-Document" +) + +# 2. 创建备份目录 +backup_dir="wiki-backup-$(date '+%Y%m%d-%H%M%S')" +mkdir -p "$backup_dir" + +# 3. 备份所有页面 +for title in "${pages_to_delete[@]}"; do + filename=$(echo "$title" | sed 's/[^a-zA-Z0-9_-]/_/g').md + gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "$backup_dir/$filename" + echo "Backed up: $title" +done + +# 4. 确认删除 +echo "" +echo "Backup created at: $backup_dir" +read -p "Delete all ${#pages_to_delete[@]} pages now? (yes/NO) " -r +echo + +if [[ "$REPLY" == "yes" ]]; then + # 5. 执行删除 + for title in "${pages_to_delete[@]}"; do + gitlink-cli wiki +delete --title "$title" + echo "Deleted: $title" + done + echo "✓ All pages deleted. Backups saved at: $backup_dir" +else + echo "✗ Cancelled. Backups available at: $backup_dir" +fi +``` + +### 4. 监控删除操作 + +```bash +#!/bin/bash +# 监控 Wiki 页面数量变化 + +# 记录当前页面数量 +current_count=$(gitlink-cli wiki +list --format json | jq ".meta.total_count") +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Current page count: $current_count" >> wiki-monitor.log + +# 如果页面数量异常减少,发出警告 +if [ -f "previous-count.txt" ]; then + previous_count=$(cat previous-count.txt) + if [ "$current_count" -lt "$previous_count" ]; then + echo "⚠️ Warning: Page count decreased from $previous_count to $current_count" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: Count decreased: $previous_count -> $current_count" >> wiki-monitor.log + fi +fi + +echo "$current_count" > previous-count.txt +``` + +## 完整示例 + +### 示例:清理过时文档 + +```bash +#!/bin/bash +# 完整的文档清理工作流 + +# 1. 定义过时页面列表 +declare -a outdated_pages=( + "Legacy-API-v1" + "Deprecated-Feature-X" + "Old-Installation-Guide" +) + +# 2. 创建备份 +backup_dir="wiki-cleanup-backup-$(date '+%Y%m%d')" +mkdir -p "$backup_dir" + +echo "=== Wiki Cleanup Process ===" +echo "Backing up outdated pages..." + +for title in "${outdated_pages[@]}"; do + if gitlink-cli wiki +view --title "$title" >/dev/null 2>&1; then + filename=$(echo "$title" | sed 's/[^a-zA-Z0-9_-]/_/g').md + gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "$backup_dir/$filename" + echo "✓ Backed up: $title" + else + echo "✗ Skipped (not found): $title" + fi +done + +# 3. 确认删除 +echo "" +echo "Outdated pages to delete:" +printf " - %s\n" "${outdated_pages[@]}" +echo "" +read -p "Proceed with deletion? (yes/NO) " -r +echo + +if [[ "$REPLY" == "yes" ]]; then + echo "Deleting outdated pages..." + + for title in "${outdated_pages[@]}"; do + if gitlink-cli wiki +delete --title "$title" 2>/dev/null; then + echo "✓ Deleted: $title" + else + echo "✗ Failed (already deleted?): $title" + fi + done + + echo "" + echo "✓ Cleanup completed. Backups saved at: $backup_dir" + echo "Remaining pages: $(gitlink-cli wiki +list --format json | jq '.meta.total_count')" +else + echo "✗ Cleanup cancelled. Backups available at: $backup_dir" +fi +``` + +## References + +- [gitlink-wiki](../SKILL.md) +- [wiki +create](wiki-create.md) — 创建 Wiki 页面 +- [wiki +update](wiki-update.md) — 更新 Wiki 页面 +- [wiki +list](wiki-list.md) — 列出 Wiki 页面 +- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-wiki/references/wiki-list.md b/skills/gitlink-wiki/references/wiki-list.md new file mode 100644 index 0000000..e3b6ba4 --- /dev/null +++ b/skills/gitlink-wiki/references/wiki-list.md @@ -0,0 +1,136 @@ +# wiki +list + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +列出仓库的所有 Wiki 页面。返回页面标题、URL、更新时间等元信息。 + +## 命令 + +```bash +# 列出当前仓库的所有 Wiki 页面 +gitlink-cli wiki +list + +# 列出指定仓库的 Wiki 页面 +gitlink-cli wiki +list --owner Gitlink --repo forgeplus + +# 使用 JSON 格式输出 +gitlink-cli wiki +list --format json + +# 查看 Wiki 页面总数 +gitlink-cli wiki +list --format json | jq ".data | length" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--owner` | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否 | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 开启调试输出 | + +## 返回字段 + +### Table 格式 + +| 列名 | 说明 | +|------|------| +| `title` | Wiki 页面标题 | +| `sub_url` | 页面访问路径(URL 编码) | +| `updated_at` | 最后更新时间 | + +### JSON 格式 + +```json +{ + "ok": true, + "data": [ + { + "title": "Home", + "sub_url": "https://www.gitlink.org.cn/Gitlink/forgeplus/wiki/Home", + "updated_at": "2026-06-01T10:30:00Z" + }, + { + "title": "API-Reference", + "sub_url": "https://www.gitlink.org.cn/Gitlink/forgeplus/wiki/API-Reference", + "updated_at": "2026-06-01T11:15:00Z" + } + ], + "meta": { + "total_count": 2 + } +} +``` + +## Workflow + +1. **Check** if `--owner` and `--repo` are provided or can be auto-resolved. +2. **Execute** `gitlink-cli wiki +list`. +3. **Display** the list of Wiki pages with titles and URLs. + +## API + +``` +GET https://gateway.gitlink.org.cn/api/wiki/open/wikiPages +Query: owner={owner}&repo={repo}&projectId={project_id} +``` + +**注意**: +- CLI 自动获取 `project_id` +- 响应会被清理:移除 `wiki_clone_link` 字段 +- `sub_url` 会被 URL 解码以便阅读 + +## 使用场景 + +### 场景 1: 发现项目文档 + +当用户询问"这个项目有什么文档"时: + +```bash +gitlink-cli wiki +list +``` + +### 场景 2: 检查 Wiki 是否启用 + +当返回空列表时,说明项目未启用 Wiki 或没有创建任何页面。 + +### 场景 3: 批量处理所有 Wiki 页面 + +```bash +# 获取所有 Wiki 页面标题 +titles=$(gitlink-cli wiki +list --format json | jq -r ".data[].title") + +# 遍历每个页面 +for title in $titles; do + echo "Processing: $title" + gitlink-cli wiki +view --title "$title" +done +``` + +## 常见问题 + +### Q: 返回空列表? + +**A:** 可能原因: +1. 项目没有创建任何 Wiki 页面 +2. 项目未启用 Wiki 功能 +3. `owner/repo` 指定错误 + +### Q: `sub_url` 字段是什么? + +**A:** Wiki 页面的完整访问 URL,格式为: +``` +https://www.gitlink.org.cn/{owner}/{repo}/wiki/{page_title} +``` + +### Q: 如何获取页面总数? + +**A:** 使用 JSON 格式并查看 `meta.total_count`: +```bash +gitlink-cli wiki +list --format json | jq ".meta.total_count" +``` + +## References + +- [gitlink-wiki](../SKILL.md) +- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-wiki/references/wiki-update.md b/skills/gitlink-wiki/references/wiki-update.md new file mode 100644 index 0000000..f0f8aa1 --- /dev/null +++ b/skills/gitlink-wiki/references/wiki-update.md @@ -0,0 +1,492 @@ +# wiki +update + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **⚠️ 写入操作** — 执行前必须确认用户意图。 + +更新现有 Wiki 页面。支持三种模式:**覆盖**、**追加**、**重命名**。 + +## 命令 + +### 覆盖模式(完全替换内容) + +```bash +# 覆盖整个页面内容 +gitlink-cli wiki +update --title "Home" --cover "# New Content\n\nThis replaces everything." + +# 从文件覆盖 +gitlink-cli wiki +update --title "API Reference" --file new-api.md +``` + +### 追加模式(在现有内容后追加) + +```bash +# 追加内容到现有页面 +gitlink-cli wiki +update --title "Home" --add "\n\n## New Section\n\nAdditional content." + +# 从文件追加 +gitlink-cli wiki +update --title "Guide" --add "" --file appendix.md +``` + +### 重命名模式(更改页面标题) + +```bash +# 重命名页面(保留原内容) +gitlink-cli wiki +update --page "Old-Title" --title "New-Title" + +# 重命名并更新内容 +gitlink-cli wiki +update --page "Old-Title" --title "New-Title" --cover "Updated content" +``` + +## 参数 + +| 参数 | Short | 必填 | 说明 | +|------|-------|------|------| +| `--title` | `-t` | **是** | 目标页面标题(更新后的标题,用于重命名) | +| `--page` | `-p` | 否 | 当前页面标题(用于查找和重命名,默认同 `--title`) | +| `--cover` | `-c` | 否* | 覆盖整个页面内容(纯文本) | +| `--add` | `-a` | 否* | 追加内容到现有页面(纯文本) | +| `--file` | `-f` | 否 | 从文件读取内容(配合 `--cover` 或 `--add` 使用) | +| `--message` | `-m` | 否 | 提交消息(可选) | +| `--owner` | | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | | 否 | 仓库名称(自动从 git remote 解析) | +| `--format` | | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | | 否 | 开启调试输出 | + +* `--cover` 或 `--add` 必须指定其中一个,或都不指定(仅重命名) + +## 返回字段 + +### Table 格式 + +| 列名 | 说明 | +|------|------| +| `title` | 更新后的页面标题 | +| `message` | 操作结果消息 | + +### JSON 格式 + +```json +{ + "ok": true, + "data": { + "title": "Updated Title", + "message": "Wiki page updated successfully" + } +} +``` + +## 三种更新模式 + +### 1. 覆盖模式 (`--cover`) + +**完全替换页面内容**: + +```bash +gitlink-cli wiki +update --title "Home" --cover "# New Home Page + +This completely replaces the old content." +``` + +**工作流程**: +1. 用户提供新内容 +2. CLI 直接用新内容替换整个页面 +3. 旧内容**完全丢失** + +**使用场景**: +- 完全重写页面 +- 修正错误内容 +- 大规模内容更新 + +### 2. 追加模式 (`--add`) + +**在现有内容基础上追加**: + +```bash +gitlink-cli wiki +update --title "Home" --add "\n\n## Changelog + +### v1.0.0 (2026-06-01) +- Initial release" +``` + +**工作流程**: +1. CLI 获取当前页面内容 +2. 将新内容追加到现有内容后 +3. 提交更新后的完整内容 + +**使用场景**: +- 添加新章节 +- 追加更新日志 +- 补充补充说明 + +### 3. 重命名模式 (`--page` + `--title`) + +**更改页面标题**: + +```bash +# 仅重命名(保留原内容) +gitlink-cli wiki +update --page "Old-Title" --title "New-Title" + +# 重命名并更新内容 +gitlink-cli wiki +update --page "Old-Title" --title "New-Title" --cover "Updated" +``` + +**工作流程**: +1. `--page` 指定当前页面标题(用于查找) +2. `--title` 指定新标题 +3. 如果指定 `--cover` 或 `--add`,同时更新内容 + +**使用场景**: +- 修正页面标题拼写 +- 调整命名规范 +- 页面重组 + +## Workflow + +### 覆盖模式 + +1. **Confirm** the new content with the user. +2. **Warning** that this will replace all existing content. +3. **Execute** `gitlink-cli wiki +update --title "<title>" --cover "<new content>"`. +4. **Report** the update result. + +### 追加模式 + +1. **Confirm** the content to append. +2. **Execute** `gitlink-cli wiki +update --title "<title>" --add "<content>"`. +3. **Report** the update result. + +### 重命名模式 + +1. **Confirm** the old title (`--page`) and new title (`--title`). +2. **Execute** `gitlink-cli wiki +update --page "<old>" --title "<new>"`. +3. **Report** the rename result. + +> [!CAUTION] +> **覆盖模式** 会完全替换页面内容,无法撤销!建议先使用 `wiki +view` 查看当前内容,必要时手动备份。 + +## API + +``` +PUT https://gateway.gitlink.org.cn/api/wiki/open/updateWiki +Body: { + "owner": "...", + "repo": "...", + "projectId": 123, + "pageName": "<current-title>", + "title": "<new-title>", + "content_base64": "<base64-encoded-content>", + "message": "<optional-message>" +} +``` + +**工作流程**: +1. CLI 获取 `project_id` +2. 如果是追加模式,先获取当前页面内容 +3. 将内容 Base64 编码 +4. 调用 Gateway API 更新页面 +5. 返回更新结果 + +## 使用场景 + +### 场景 1: 修正文档错误 + +```bash +# 查看当前内容 +gitlink-cli wiki +view --title "API Reference" + +# 修正错误 +gitlink-cli wiki +update --title "API Reference" \ + --file corrected-api.md +``` + +### 场景 2: 添加更新日志 + +```bash +# 追加更新日志到首页 +gitlink-cli wiki +update --title "Home" --add ' +## Changelog + +### v2.0.0 (2026-06-01) +- Added new feature X +- Fixed bug Y +- Improved performance Z' +``` + +### 场景 3: 重命名页面 + +```bash +# 将 "api" 重命名为 "API Reference" +gitlink-cli wiki +update --page "api" --title "API Reference" + +# 重命名并更新内容 +gitlink-cli wiki +update --page "old-guide" --title "User-Guide" \ + --cover "# User Guide\n\nUpdated content" +``` + +### 场景 4: 批量更新多个页面 + +```bash +#!/bin/bash +# 批量更新所有页面的页脚 + +declare -A pages=( + ["Home"]="home.md" + ["API-Reference"]="api.md" + ["Guide"]="guide.md" +) + +for title in "${!pages[@]}"; do + file="${pages[$title]}" + + # 读取文件内容作为覆盖内容 + echo "Updating: $title from $file" + gitlink-cli wiki +update --title "$title" --file "$file" +done +``` + +### 场景 5: 增量更新文档 + +```bash +#!/bin/bash +# 为所有页面添加"最后更新"时间戳 + +for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + current_date=$(date "+%Y-%m-%d") + + gitlink-cli wiki +update --title "$title" \ + --add "\n\n---\n\n*Last updated: $current_date*" +done +``` + +## 模式选择指南 + +### 何时使用覆盖模式? + +✅ **使用覆盖模式**: +- 完全重写页面内容 +- 修正严重错误 +- 大规模内容更新 +- 从文件导入新版本 + +❌ **避免使用覆盖模式**: +- 只需添加小段内容 +- 需要保留部分现有内容 +- 不确定要修改的具体内容 + +### 何时使用追加模式? + +✅ **使用追加模式**: +- 添加新章节 +- 追加更新日志 +- 补充补充说明 +- 保持历史内容 + +❌ **避免使用追加模式**: +- 需要修正现有内容 +- 页面内容过长 +- 需要结构性修改 + +### 何时使用重命名模式? + +✅ **使用重命名模式**: +- 修正拼写错误 +- 统一命名规范 +- 页面重组 +- 调整文档结构 + +## 常见问题 + +### Q: 覆盖模式能否撤销? + +**A:** **不能**。覆盖模式会完全替换内容,无法自动撤销。 + +**建议**: +1. 先使用 `wiki +view` 查看当前内容 +2. 必要时手动备份:`gitlink-cli wiki +view --title "Page" > backup.md` +3. 再执行覆盖更新 + +### Q: 追加模式的内容位置? + +**A:** 追加的内容会添加到现有内容的**末尾**。 + +如果需要精确控制位置: +1. 先查看当前内容 +2. 手动编辑(合并旧内容 + 新内容) +3. 使用覆盖模式更新 + +### Q: 重命名后旧标题还能访问吗? + +**A:** **不能**。重命名后: +- 旧标题页面不存在 +- 使用旧标题访问会返回 404 +- 需要更新所有指向旧页面的链接 + +### Q: 如何同时修改标题和内容? + +**A:** 使用 `--page` + `--title` + `--cover`: +```bash +gitlink-cli wiki +update \ + --page "Old-Title" \ + --title "New-Title" \ + --cover "New content" +``` + +### Q: 更新失败提示 "page not found"? + +**A:** 可能原因: +1. `--title` 指定的页面不存在 +2. 如果使用 `--page`,当前页面不存在 +3. `owner/repo` 指定错误 + +**解决方法**: +```bash +# 先列出所有页面 +gitlink-cli wiki +list + +# 确认页面标题正确(区分大小写) +gitlink-cli wiki +update --title "Correct-Title" --cover "..." +``` + +### Q: 追加模式获取旧内容失败? + +**A:** 可能原因: +1. 页面不存在 +2. 网络问题 +3. 权限不足 + +**解决方法**: +```bash +# 检查页面是否存在 +gitlink-cli wiki +view --title "Page-Name" + +# 如果页面不存在,先创建 +gitlink-cli wiki +create --title "Page-Name" --content "..." +``` + +## 错误处理 + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `required flag --title is missing` | 未指定目标标题 | 添加 `--title "Page Title"` | +| `--cover or --file is required` | 未提供更新内容 | 添加 `--cover "..."` 或 `--file file.md` | +| `failed to fetch current page content` | 追加模式下页面不存在 | 先创建页面或检查标题 | +| `page not found` | 指定页面不存在 | 使用 `wiki +list` 查看可用页面 | +| `401 Unauthorized` | 未登录或 Token 过期 | 运行 `gitlink-cli auth login` | +| `403 Forbidden` | 无权限更新 Wiki | 检查是否有项目写入权限 | + +## 最佳实践 + +### 1. 更新前备份 + +```bash +# 更新前先备份当前内容 +gitlink-cli wiki +view --title "Important Page" --format json | \ + jq -r ".data.content_decoded" > backup.md + +# 然后执行更新 +gitlink-cli wiki +update --title "Important Page" --file new-content.md +``` + +### 2. 验证更新结果 + +```bash +# 更新后查看新内容 +gitlink-cli wiki +view --title "Page" --format json | \ + jq -r ".data.content_decoded" +``` + +### 3. 使用文件进行复杂更新 + +```bash +# 1. 导出当前内容 +gitlink-cli wiki +view --title "Page" --format json | \ + jq -r ".data.content_decoded" > temp.md + +# 2. 手动编辑 temp.md + +# 3. 更新回 Wiki +gitlink-cli wiki +update --title "Page" --file temp.md + +# 4. 清理 +rm temp.md +``` + +### 4. 批量重命名规范 + +```bash +#!/bin/bash +# 统一命名规范:将空格替换为连字符 + +for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + # 如果标题包含空格 + if [[ "$title" =~ " " ]]; then + # 生成新标题(空格替换为连字符) + new_title=$(echo "$title" | sed 's/ /-/g') + + echo "Renaming: '$title' -> '$new_title'" + gitlink-cli wiki +update --page "$title" --title "$new_title" + fi +done +``` + +### 5. 增量更新工作流 + +```bash +#!/bin/bash +# 安全的追加模式工作流 + +title="Home" +new_content="## New Section\n\nNew content here." + +# 1. 先检查页面是否存在 +if gitlink-cli wiki +view --title "$title" >/dev/null 2>&1; then + # 2. 追加内容 + gitlink-cli wiki +update --title "$title" --add "\n\n$new_content" + echo "Content appended to $title" +else + # 3. 页面不存在,创建新页面 + gitlink-cli wiki +create --title "$title" --content "$new_content" + echo "New page $title created" +fi +``` + +## 完整示例 + +### 示例:重构项目文档 + +```bash +#!/bin/bash +# 文档重构工作流 + +# 1. 备份所有页面 +mkdir -p wiki-backup +for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + filename=$(echo "$title" | sed 's/[^a-zA-Z0-9_-]/_/g').md + gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "wiki-backup/$filename" + echo "Backed up: $title -> $filename" +done + +# 2. 重命名页面(统一命名规范) +gitlink-cli wiki +update --page "api" --title "API-Reference" +gitlink-cli wiki +update --page "user guide" --title "User-Guide" + +# 3. 更新首页内容 +gitlink-cli wiki +update --title "Home" --file new-home.md + +# 4. 为所有页面添加页脚 +for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + gitlink-cli wiki +update --title "$title" \ + --add "\n\n---\n\n*Updated: $(date '+%Y-%m-%d')*" +done + +echo "Wiki restructuring completed!" +``` + +## References + +- [gitlink-wiki](../SKILL.md) +- [wiki +create](wiki-create.md) — 创建 Wiki 页面 +- [wiki +view](wiki-view.md) — 查看 Wiki 页面 +- [wiki +delete](wiki-delete.md) — 删除 Wiki 页面 +- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-wiki/references/wiki-view.md b/skills/gitlink-wiki/references/wiki-view.md new file mode 100644 index 0000000..073b29e --- /dev/null +++ b/skills/gitlink-wiki/references/wiki-view.md @@ -0,0 +1,213 @@ +# wiki +view + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +查看指定 Wiki 页面的完整内容,包括 Markdown 源文本。 + +## 命令 + +```bash +# 查看 Wiki 页面内容 +gitlink-cli wiki +view --title "Home" + +# 查看指定仓库的 Wiki 页面 +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "API-Reference" + +# 使用 JSON 格式查看(包含 base64 和 decoded 内容) +gitlink-cli wiki +view --title "Home" --format json + +# 将 Wiki 内容保存到文件 +gitlink-cli wiki +view --title "Home" --format json | jq -r ".data.content_decoded" > home.md +``` + +## 参数 + +| 参数 | Short | 必填 | 说明 | +|------|-------|------|------| +| `--title` | `-t` | **是** | Wiki 页面标题(区分大小写) | +| `--owner` | | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | | 否 | 仓库名称(自动从 git remote 解析) | +| `--format` | | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | | 否 | 开启调试输出 | + +## 返回字段 + +### Table 格式 + +| 列名 | 说明 | +|------|------| +| `title` | Wiki 页面标题 | +| `content` | 页面内容(自动解码后的文本) | +| `updated_at` | 最后更新时间 | + +### JSON 格式 + +```json +{ + "ok": true, + "data": { + "title": "Home", + "content_base64": "I0hvbWUKCisqKldlbGNvbWUgdG8gdGhlIHByb2plY3QgIWJqKio=", + "content_decoded": "# Home\n\n**Welcome to the project!**\n\n## Getting Started\n...", + "updated_at": "2026-06-01T10:30:00Z" + } +} +``` + +**字段说明**: +- `content_base64` - 原始 Base64 编码内容(API 返回) +- `content_decoded` - 自动解码后的文本内容(CLI 提供) + +## Workflow + +1. **Confirm** the page title with the user. +2. **Execute** `gitlink-cli wiki +view --title "<page title>"`. +3. **Display** the page content (auto-decoded). +4. **Optional**: Save content to file if requested. + +## API + +``` +GET https://gateway.gitlink.org.cn/api/wiki/open/getWiki +Query: owner={owner}&repo={repo}&projectId={project_id}&pageName={title} +``` + +**工作流程**: +1. CLI 获取 `project_id` +2. 调用 Gateway API 获取页面 +3. 解码 `content_base64` 为 `content_decoded` +4. 返回解码后的内容 + +## 使用场景 + +### 场景 1: 查看单个页面 + +当用户询问"查看 API 文档页面"时: + +```bash +gitlink-cli wiki +view --title "API Reference" +``` + +### 场景 2: 导出 Wiki 页面 + +```bash +# 导出为 Markdown 文件 +gitlink-cli wiki +view --title "Home" --format json | \ + jq -r ".data.content_decoded" > home.md +``` + +### 场景 3: 批量导出所有 Wiki 页面 + +```bash +#!/bin/bash +# 导出所有 Wiki 页面为 Markdown 文件 + +titles=$(gitlink-cli wiki +list --format json | jq -r ".data[].title") + +for title in $titles; do + # 清理文件名(替换空格和特殊字符) + filename=$(echo "$title" | sed 's/[^a-zA-Z0-9_-]/_/g').md + + echo "Exporting: $title -> $filename" + gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "$filename" +done +``` + +### 场景 4: 检查页面是否存在 + +```bash +# 检查页面是否存在(退出码 0=存在,非0=不存在) +if gitlink-cli wiki +view --title "Some Page" >/dev/null 2>&1; then + echo "Page exists" +else + echo "Page does not exist" +fi +``` + +## 常见问题 + +### Q: 提示 "page not found"? + +**A:** 可能原因: +1. 页面标题不匹配(区分大小写) +2. 页面不存在 +3. `owner/repo` 指定错误 + +**解决方法**: +```bash +# 先列出所有页面确认标题 +gitlink-cli wiki +list +``` + +### Q: 内容显示为乱码? + +**A:** 确保: +1. 内容是有效的 UTF-8 编码 +2. 使用 `--format json` 查看 `content_decoded` 字段 +3. 终端支持 UTF-8 显示 + +### Q: 如何获取原始 Base64 内容? + +**A:** 使用 JSON 格式查看 `content_base64` 字段: +```bash +gitlink-cli wiki +view --title "Home" --format json | jq ".data.content_base64" +``` + +### Q: 支持哪些 Markdown 语法? + +**A:** GitLink Wiki 支持 CommonMark 标准,包括: +- 标题、列表、代码块 +- 链接、图片、表格 +- 粗体、斜体、引用 + +## 错误处理 + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `required flag --title is missing` | 未指定页面标题 | 添加 `--title "Page Title"` | +| `failed to fetch project_id` | 项目不存在或无权限 | 检查 `--owner` 和 `--repo` | +| `failed to decode content` | Base64 解码失败 | 检查内容是否为有效 Base64 | +| `404 Not Found` | 页面不存在 | 使用 `wiki +list` 查看可用页面 | + +## 最佳实践 + +### 1. 页面标题规范 + +使用一致的命名规范: +```bash +# 推荐:使用连字符 +"Getting-Started" +"API-Reference" + +# 避免:空格和特殊字符 +"Getting Started" # 需要引号 +"API/Reference" # 斜杠可能被误解为路径 +``` + +### 2. 内容验证 + +查看页面后验证内容完整性: +```bash +gitlink-cli wiki +view --title "Home" --format json | \ + jq -r ".data.content_decoded" | wc -l +``` + +### 3. 批量操作 + +结合其他命令批量处理 Wiki: +```bash +# 查看所有页面的行数 +for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + lines=$(gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" | wc -l) + echo "$title: $lines lines" +done +``` + +## References + +- [gitlink-wiki](../SKILL.md) +- [wiki +list](wiki-list.md) — 列出 Wiki 页面 +- [wiki +create](wiki-create.md) — 创建 Wiki 页面 +- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-workflow/examples/full-automation-workflow.md b/skills/gitlink-workflow/examples/full-automation-workflow.md new file mode 100644 index 0000000..ff1cc98 --- /dev/null +++ b/skills/gitlink-workflow/examples/full-automation-workflow.md @@ -0,0 +1,579 @@ +# AI Agent 自动化工作流完整示例 + +本文档展示如何组合使用多个 gitlink-cli 工作流,实现完整的 AI Agent 自动化项目管理。 + +## 🤖 AI Agent 完整工作流 + +### 场景:新项目从创建到发布的完整自动化 + +这个示例展示 AI Agent 如何自动化管理一个软件项目的完整生命周期,从仓库创建到版本发布。 + +## 工作流组合 + +### 1. 项目初始化阶段 + +```python +# AI Agent 项目初始化 +def initialize_new_project(project_name, description): + """完整的项目初始化工作流""" + + # 1. 创建仓库 + repo = create_repository(project_name, description) + + # 2. 初始化项目结构 + setup_project_structure(repo) + + # 3. 配置 CI/CD + configure_ci_cd(repo) + + # 4. 创建初始 Issue + create_initial_issues(repo) + + return repo + +# 执行 +project = initialize_new_project( + "my-awesome-project", + "一个很棒的项目,用于演示自动化工作流" +) +``` + +### 2. 开发阶段自动化 + +```python +# AI Agent 开发管理 +def manage_development_workflow(repo): + """开发阶段的自动化管理""" + + while development_in_progress: + # 1. 自动分类新 Issue + triage_new_issues(repo) + + # 2. 审查新的 PR + review_pull_requests(repo) + + # 3. 更新项目进度 + update_project_status(repo) + + # 4. 检查是否需要发布 + if should_release(repo): + generate_release_notes(repo) + create_release(repo) + + sleep(cycle_interval) +``` + +### 3. Sprint 自动化 + +```python +# AI Agent Sprint 管理 +def automate_sprint_management(repo): + """完整的 Sprint 自动化管理""" + + # Sprint 开始 + sprint_number = start_new_sprint(repo) + + # Sprint 监控 + monitor_sprint_progress(repo, sprint_number) + + # Sprint 结束 + end_sprint(repo, sprint_number) + generate_sprint_report(repo, sprint_number) + +# 执行 Sprint 工作流 +sprint_result = automate_sprint_management(project) +``` + +## 完整的端到端示例 + +### 示例:自动化 Issue 到 Release 流程 + +```bash +#!/bin/bash + +# 完整的自动化工作流脚本 + +OWNER="ai-agent" +REPO="demo-project" +PROJECT_NAME="AI Agent Demo" + +echo "🤖 启动 AI Agent 自动化工作流..." + +# 阶段 1: 项目创建 +echo "📦 阶段 1: 创建项目" +gitlink-cli repo +create \ + --name "$REPO" \ + --description "$PROJECT_NAME" \ + --private false + +# 初始化本地仓库 +cd "$REPO" +git init +git remote add gitlink "https://www.gitlink.org.cn/$OWNER/$REPO.git" + +# 创建基础文件 +echo "# $PROJECT_NAME" > README.md +echo "MIT License" > LICENSE +git add . +git commit -m "Initial commit" +git push -u gitlink master:master + +# 设置分支保护 +gitlink-cli branch +protect --owner "$OWNER" --repo "$REPO" --name master + +# 阶段 2: 创建开发 Issue +echo "🎯 阶段 2: 创建开发 Issue" +FEATURES=( + "用户认证系统" + "数据管理模块" + "API 接口开发" + "前端界面设计" + "测试框架搭建" +) + +for feature in "${FEATURES[@]}"; do + gitlink-cli issue +create \ + --owner "$OWNER" \ + --repo "$REPO" \ + --title "开发 $feature" \ + --body "## 任务描述 + +实现 $feature 功能 + +## 技术要求 +- 代码规范 +- 单元测试 +- 文档完整 + +## 验收标准 +- 功能正常工作 +- 测试通过 +- 代码审查通过" +done + +# 阶段 3: 模拟开发和 PR 创建 +echo "🔧 阶段 3: 模拟开发工作" + +# 创建功能分支 +for feature in "${FEATURES[@]}"; do + # 模拟分支名(将中文转为拼音) + branch_name="feature-$(echo $feature | md5sum | cut -c1-8)" + + git checkout -b "$branch_name" + + # 模拟开发工作 + echo "// $feature 实现" > "${feature}.js" + git add . + git commit -m "Implement $feature" + git push gitlink "$branch_name" + + # 创建 PR + gitlink-cli pr +create \ + --owner "$OWNER" \ + --repo "$REPO" \ + --title "Feature: $feature" \ + --head "$branch_name" \ + --base master \ + --body "## 功能说明 + +实现 $feature 功能 + +## 变更内容 +- 添加核心功能 +- 实现相关测试 +- 更新文档 + +## 测试情况 +- 单元测试通过 +- 集成测试通过 +- 手工测试完成" + + git checkout master +done + +# 阶段 4: 自动 Issue 分类 +echo "🏷️ 阶段 4: 自动分类 Issue" + +# 获取所有开放 Issue +ISSUES=$(gitlink-cli issue +list --owner "$OWNER" --repo "$REPO" --state open --format json) + +# 为 Issue 添加标签 +echo "$ISSUES" | jq -r '.data.issues[].id' | while read issue_id; do + echo "处理 Issue #$issue_id" + + # 获取 Issue 详情 + ISSUE_DETAIL=$(gitlink-cli issue +view --owner "$OWNER" --repo "$REPO" --id "$issue_id" --format json) + TITLE=$(echo "$ISSUE_DETAIL" | jq -r '.data.subject') + + # 基于标题分类 + if echo "$TITLE" | grep -iq "认证"; then + echo " → 分类为: feature + security" + # 实际执行时取消注释 + # gitlink-cli api POST "/$OWNER/$REPO/issues/$issue_id" --body '{"issue_tag_ids":[1,5]}' + else + echo " → 分类为: feature" + # gitlink-cli api POST "/$OWNER/$REPO/issues/$issue_id" --body '{"issue_tag_ids":[1]}' + fi +done + +# 阶段 5: PR 审查 +echo "🔍 阶段 5: 自动 PR 审查" + +# 获取开放 PR +PRS=$(gitlink-cli pr +list --owner "$OWNER" --repo "$REPO" --state open --format json) + +echo "$PRS" | jq -r '.data.prs[].id' | while read pr_id; do + echo "审查 PR #$pr_id" + + # 获取 PR 详情 + PR_DETAIL=$(gitlink-cli pr +view --owner "$OWNER" --repo "$REPO" --id "$pr_id" --format json) + PR_AUTHOR=$(echo "$PR_DETAIL" | jq -r '.data.author.login') + PR_TITLE=$(echo "$PR_DETAIL" | jq -r '.data.title') + + # 简单的代码检查(这里只是模拟) + REVIEW_COMMENTS="# 🔍 自动审查结果 + +## PR 信息 +- **标题**: $PR_TITLE +- **作者**: $PR_AUTHOR +- **状态**: 待审查 + +## ✅ 自动检查 +- 代码提交正常 +- 变更描述清晰 +- 符合项目规范 + +## 💡 建议 +- 添加单元测试 +- 更新相关文档 +- 确认向后兼容性 + +## 📋 审查结论 +代码质量良好,建议合并。" + + echo " → 添加审查评论" + # 实际执行时取消注释 + # gitlink-cli api POST "/$OWNER/$REPO/pulls/$pr_id/reviews" --body "{\"body\":\"$REVIEW_COMMENTS\",\"event\":\"APPROVE\"}" +done + +# 阶段 6: 生成 Release Notes +echo "📝 阶段 6: 生成 Release Notes" + +# 合并所有 PR(模拟) +echo "合并所有功能 PR..." +MERGED_PRS=$(gitlink-cli pr +list --owner "$OWNER" --repo "$REPO" --state merged --format json) + +# 生成 Release Notes +RELEASE_NOTES="# 🎉 v1.0.0 首个版本发布 + +## 📊 版本概述 +这是 $PROJECT_NAME 的首个稳定版本,包含了核心功能的完整实现。 + +## ✨ 新功能 +- 用户认证系统:完整的登录注册功能 +- 数据管理模块:高效的数据存储和检索 +- API 接口:RESTful API 设计 +- 前端界面:现代化的用户界面 +- 测试框架:完整的自动化测试 + +## 🐛 Bug 修复 +- 修复认证过程中的边界问题 +- 解决数据一致性问题 +- 优化 API 响应性能 + +## 🔧 技术改进 +- 代码结构优化 +- 性能提升 30% +- 安全性增强 + +## 📚 文档更新 +- 用户手册完善 +- API 文档更新 +- 开发指南补充 + +## 🙏 贡献者 +感谢所有参与开发的贡献者! + +## 📥 安装方法 +\`\`\`bash +# 使用 npm 安装 +npm install $OWNER/$REPO@v1.0.0 + +# 或使用 yarn +yarn add $OWNER/$REPO@v1.0.0 +\`\`\` + +## 🔄 升级指南 +从之前的版本升级,请参考迁移指南。 + +## 📚 完整文档 +- 用户指南: https://www.gitlink.org.cn/$OWNER/$REPO/wiki +- API 文档: https://www.gitlink.org.cn/$OWNER/$REPO/api-docs + +--- +**发布日期**: $(date +%Y-%m-%d) +**下一版本**: v1.1.0 (计划于 $(date -d "1 month" +%Y-%m-%d) 发布)" + +# 创建 Release +echo "创建 Release v1.0.0..." +gitlink-cli release +create \ + --owner "$OWNER" \ + --repo "$REPO" \ + --tag "v1.0.0" \ + --name "v1.0.0" \ + --body "$RELEASE_NOTES" + +# 阶段 7: Sprint 报告 +echo "📊 阶段 7: 生成 Sprint 报告" + +SPRINT_START=$(date -d "14 days ago" +%Y-%m-%d) +SPRINT_END=$(date +%Y-%m-%d) + +SPRINT_REPORT="# 📊 Sprint 1 完成报告 + +## 📅 时间信息 +- **Sprint 周期**: $SPRINT_START 至 $SPRINT_END +- **团队规模**: AI Agent x 1 +- **工作模式**: 自动化开发 + +## 🎯 目标达成 +### 计划完成度 +- **计划 Issue**: 5 个 +- **实际完成**: 5 个 +- **完成率**: 100% + +### 质量指标 +- **代码质量**: 优秀 +- **测试覆盖率**: 95% +- **文档完整度**: 100% + +## 💻 工作统计 +### 代码提交 +- **总提交数**: 42 次 +- **日均提交**: 3 次/天 +- **代码行数**: +2,450 -180 行 + +### Issue 处理 +- **关闭 Issue**: 5 个 +- **新建 Issue**: 0 个 +- **平均处理时间**: 2.5 天 + +### PR 合并 +- **合并 PR**: 5 个 +- **平均审查时间**: 1 小时 +- **平均合并时间**: 2 小时 + +## 🎉 主要成就 +1. ✅ 完成用户认证系统开发 +2. ✅ 实现数据管理模块 +3. ✅ 构建 RESTful API +4. ✅ 设计现代化前端界面 +5. ✅ 建立完整测试体系 + +## 📈 性能指标 +- **开发效率**: 高 +- **代码质量**: 优秀 +- **自动化程度**: 95% +- **文档完整度**: 100% + +## 🔮 下期规划 +- 性能优化和改进 +- 新功能模块开发 +- 国际化支持 +- 移动端适配 + +--- +**AI Agent 自动化工作流演示** +**报告生成**: $(date +%Y-%m-%d %H:%M:%S)" + +# 保存 Sprint 报告 +REPORT_FILE="sprint_reports/sprint_1_$(date +%Y%m%d).md" +mkdir -p sprint_reports +echo "$SPRINT_REPORT" > "$REPORT_FILE" + +echo "🎉 AI Agent 自动化工作流完成!" +echo "" +echo "📊 项目统计:" +echo " 仓库: https://www.gitlink.org.cn/$OWNER/$REPO" +echo " Issue: 5 个全部完成" +echo " PR: 5 个全部合并" +echo " Release: v1.0.0 已发布" +echo "" +echo "📄 生成的文档:" +echo " - Release Notes: https://www.gitlink.org.cn/$OWNER/$REPO/releases/v1.0.0" +echo " - Sprint 报告: $REPORT_FILE" +``` + +## Claude Code 集成示例 + +### 在 Claude Code 中使用工作流 + +```markdown +# 用户指令 + +帮助我创建一个新的项目并完成首个版本的发布。 + +# Claude Code 执行 + +我会使用 gitlink-cli 的自动化工作流来完成这个任务: + +1. **创建仓库** - 使用 workflow-repo-setup +2. **管理 Issue** - 使用 workflow-issue-triage +3. **审查 PR** - 使用 workflow-pr-review +4. **生成 Release** - 使用 workflow-release-notes +5. **总结报告** - 使用 workflow-sprint-report + +让我开始执行... +``` + +### 技能组合使用 + +```python +# AI Agent 多技能组合 +class GitLinkAgent: + def __init__(self, owner, repo): + self.owner = owner + self.repo = repo + self.cli = "gitlink-cli" + + def complete_project_workflow(self): + """完整的项目工作流""" + + # 阶段 1: 初始化 + self.setup_repository() + + # 阶段 2: 开发管理 + self.manage_development() + + # 阶段 3: 质量控制 + self.automated_review() + + # 阶段 4: 发布管理 + self.create_release() + + # 阶段 5: 报告总结 + self.generate_reports() + + def setup_repository(self): + """仓库初始化""" + # 使用 workflow-repo-setup + create_repo_cmd = f"{self.cli} repo +create --name {self.repo}" + subprocess.run(create_repo_cmd.split()) + + protect_branch_cmd = f"{self.cli} branch +protect --name master" + subprocess.run(protect_branch_cmd.split()) + + def manage_development(self): + """开发管理""" + # 监控新 Issue 并自动分类 + issues = self.get_new_issues() + for issue in issues: + self.classify_issue(issue) + + # 监控新 PR 并审查 + prs = self.get_new_prs() + for pr in prs: + self.review_pr(pr) + + def automated_review(self): + """自动化审查""" + # 获取待审查的 PR + pending_prs = self.get_pending_prs() + + for pr in pending_prs: + review_result = self.analyze_pr(pr) + self.submit_review(pr, review_result) +``` + +## 最佳实践 + +### 1. 工作流选择 +- **项目创建**: 使用 workflow-repo-setup +- **日常维护**: 使用 workflow-issue-triage 和 workflow-pr-review +- **版本发布**: 使用 workflow-release-notes +- **团队管理**: 使用 workflow-sprint-report + +### 2. 执行顺序 +典型的执行顺序: +1. 项目初始化 → 2. Issue 管理 → 3. PR 审查 → 4. Release 发布 → 5. Sprint 报告 + +### 3. 错误处理 +```python +def safe_workflow_execution(workflow_func, *args, **kwargs): + """安全执行工作流""" + try: + return workflow_func(*args, **kwargs) + except Exception as e: + # 记录错误 + log_error(e) + # 尝试恢复 + return handle_workflow_error(e, workflow_func, *args, **kwargs) +``` + +### 4. 进度跟踪 +```python +class WorkflowProgress: + def __init__(self): + self.current_step = 0 + self.total_steps = 5 + self.completed_steps = [] + self.failed_steps = [] + + def update_progress(self, step_name, success=True): + if success: + self.completed_steps.append(step_name) + else: + self.failed_steps.append(step_name) + self.current_step += 1 + + def get_progress_report(self): + progress = self.current_step / self.total_steps * 100 + return { + 'progress': f'{progress:.1f}%', + 'completed': self.completed_steps, + 'failed': self.failed_steps + } +``` + +## 扩展工作流 + +### 自定义工作流 + +```python +# 创建自定义工作流 +def custom_workflow(owner, repo, custom_config): + """自定义工作流模板""" + + # 1. 预检查 + if not validate_environment(): + return False + + # 2. 执行自定义步骤 + for step in custom_config['steps']: + execute_step(step) + + # 3. 后处理 + cleanup_environment() + + return True +``` + +## 故障排除 + +### 常见问题 + +| 问题 | 解决方案 | +|------|----------| +| 权限不足 | 检查 Token 权限 | +| API 限制 | 添加重试机制 | +| 数据格式错误 | 验证输入数据 | +| 执行超时 | 增加超时时间 | + +## References + +- [workflow-issue-triage](../references/workflow-issue-triage.md) — Issue 分类 +- [workflow-pr-review](../references/workflow-pr-review.md) — PR 审查 +- [workflow-release-notes](../references/workflow-release-notes.md) — Release Notes +- [workflow-repo-setup](../references/workflow-repo-setup.md) — 仓库初始化 +- [workflow-sprint-report](../references/workflow-sprint-report.md) — Sprint 报告 +- [gitlink-workflow](../SKILL.md) — 工作流总览 diff --git a/skills/gitlink-workflow/references/workflow-issue-triage.md b/skills/gitlink-workflow/references/workflow-issue-triage.md new file mode 100644 index 0000000..66b5339 --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-issue-triage.md @@ -0,0 +1,313 @@ +# Workflow: Issue Triage(Issue 自动分类) + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于自动化 Issue 分类和管理。 + +AI Agent 自动为新建的 Issue 添加标签和分类,提高项目管理效率。 + +## 工作流概述 + +Issue Triage 工作流通过分析 Issue 的标题和描述内容,自动为 Issue 分配合适的标签,帮助项目维护者更好地组织和管理 Issue。 + +## 适用场景 + +- **项目维护**:自动分类新提交的 Issue +- **标签管理**:确保 Issue 有正确的分类标签 +- **优先级排序**:基于分类快速识别高优先级 Issue +- **团队协作**:减少手动分类工作量,提高效率 + +## 工作流步骤 + +### 步骤 1:获取未标记的 Issue 列表 + +```bash +# 获取所有开放的 Issue +gitlink-cli issue +list --state open --format json + +# 筛选出没有标签的 Issue +gitlink-cli issue +list --state open --format json | \ + jq '.data.issues[] | select(.issue_tags == null or .issue_tags == [])' +``` + +### 步骤 2:分析 Issue 内容 + +```bash +# 获取特定 Issue 的详细信息 +gitlink-cli issue +view --id 123 --format json + +# 分析标题和描述 +gitlink-cli issue +view --id 123 --format json | \ + jq '{subject: .data.subject, description: .data.description}' +``` + +### 步骤 3:智能分类 + +基于 Issue 内容的分析,应用以下分类规则: + +**Bug 分类规则**: +- 标题/描述包含关键词:`bug`、`错误`、`失败`、`异常`、`crash`、`issue`、`problem` +- 行为模式:描述功能失效或异常行为 +- 示例:`登录时遇到错误`、`页面加载失败` + +**Feature 分类规则**: +- 标题/描述包含关键词:`feature`、`新增`、`建议`、`request`、`enhancement`、`improve` +- 行为模式:建议新功能或改进 +- 示例:`添加用户权限管理`、`建议支持暗色主题` + +**Question 分类规则**: +- 标题/描述包含关键词:`question`、`如何`、`怎么`、`how`、`帮助`、`help`、`疑问` +- 行为模式:询问使用方法或寻求帮助 +- 示例:`如何配置环境变量`、`怎么部署到服务器` + +**Documentation 分类规则**: +- 标题/描述包含关键词:`doc`、`文档`、`README`、`tutorial`、`guide`、`example` +- 行为模式:与文档相关的问题或建议 +- 示例:`更新安装文档`、`添加使用示例` + +### 步骤 4:添加标签 + +```bash +# 获取项目的标签列表 +gitlink-cli api GET /:owner/:repo/issue_tags --format json + +# 为 Issue 添加标签 +gitlink-cli api POST /:owner/:repo/issues/123 --body \ + '{"issue_tag_ids":[1, 2]}' + +# 添加单个标签 +gitlink-cli api POST /:owner/:repo/issues/123 --body \ + '{"issue_tag_ids":[1]}' +``` + +## 完整工作流示例 + +```bash +#!/bin/bash + +# Issue Triage 自动化脚本 + +OWNER="myuser" +REPO="myproject" + +# 1. 获取所有开放的 Issue +ISSUES=$(gitlink-cli issue +list --owner $OWNER --repo $REPO --state open --format json) + +# 2. 遍历每个 Issue +echo "$ISSUES" | jq -c '.data.issues[]' | while read -r issue; do + ISSUE_ID=$(echo "$issue" | jq -r '.id') + SUBJECT=$(echo "$issue" | jq -r '.subject') + DESCRIPTION=$(echo "$issue" | jq -r '.description') + TAGS=$(echo "$issue" | jq -r '.issue_tags // []') + + # 跳过已有标签的 Issue + if [ "$TAGS" != "[]" ]; then + echo "Issue $ISSUE_ID 已有标签,跳过" + continue + fi + + echo "分析 Issue $ISSUE_ID: $SUBJECT" + + # 分析内容并确定标签 + TAG_IDS=() + CONTENT="$SUBJECT $DESCRIPTION" + + # 分类逻辑 + if echo "$CONTENT" | grep -iqE "bug|错误|失败|异常|crash|issue|problem"; then + TAG_IDS+=("1") # 假设 1 是 bug 标签 + echo " → 分类为: bug" + fi + + if echo "$CONTENT" | grep -iqE "feature|新增|建议|request|enhancement|improve"; then + TAG_IDS+=("2") # 假设 2 是 enhancement 标签 + echo " → 分类为: enhancement" + fi + + if echo "$CONTENT" | grep -iqE "question|如何|怎么|how|帮助|help|疑问"; then + TAG_IDS+=("3") # 假设 3 是 question 标签 + echo " → 分类为: question" + fi + + if echo "$CONTENT" | grep -iqE "doc|文档|README|tutorial|guide|example"; then + TAG_IDS+=("4") # 假设 4 是 documentation 标签 + echo " → 分类为: documentation" + fi + + # 添加标签到 Issue + if [ ${#TAG_IDS[@]} -gt 0 ]; then + echo " → 为 Issue $ISSUE_ID 添加标签: ${TAG_IDS[*]}" + # 实际执行时取消注释 + # gitlink-cli api POST "/$OWNER/$REPO/issues/$ISSUE_ID" --body \ + # "{\"issue_tag_ids\":[${TAG_IDS[*]}]}" + else + echo " → 无法自动分类,需要人工处理" + fi + + echo "" +done +``` + +## AI Agent 集成示例 + +Claude Code 等 AI Agent 可以直接执行此工作流: + +```python +# AI Agent 执行 Issue Triage +def issue_triage(owner, repo): + """AI Agent 自动分类 Issue""" + + # 1. 获取开放的 Issue + issues = gitlink_cli_issue_list(owner, repo, state="open") + + for issue in issues: + # 2. 跳过已有标签的 + if issue.get('issue_tags'): + continue + + # 3. AI 分析内容 + content = f"{issue['subject']} {issue.get('description', '')}" + classification = analyze_issue_content(content) + + # 4. 添加标签 + if classification: + add_issue_tags(owner, repo, issue['id'], classification) + +def analyze_issue_content(content): + """AI 分析 Issue 内容""" + # 使用 AI 模型分析文本 + labels = [] + + if any(word in content.lower() for word in ['bug', 'error', 'fail']): + labels.append('bug') + + if any(word in content.lower() for word in ['feature', 'enhancement']): + labels.append('enhancement') + + return labels +``` + +## 高级分类策略 + +### 多标签分类 +一个 Issue 可以有多个标签: + +```bash +# 同时添加多个标签 +gitlink-cli api POST /:owner/:repo/issues/123 --body \ + '{"issue_tag_ids":[1, 2, 5]}' + +# 示例:既严重又是功能请求 +# bug + enhancement + high-priority +``` + +### 优先级分类 +基于紧急程度添加优先级标签: + +**高优先级关键词**: +- `urgent`、`紧急`、`严重`、`critical`、`blocking`、`阻塞` + +**中优先级关键词**: +- `moderate`、`中等`、`normal`、`常规` + +**低优先级关键词**: +- `low`、`较低`、`minor`、`次要`、`nice-to-have` + +### 复杂度分类 +基于实现难度分类: + +**简单**: +- 关键词:`简单`、`easy`、`quick`、`minor` +- 预估时间:1-2 天 + +**中等**: +- 关键词:`中等`、`moderate`、`normal` +- 预估时间:3-7 天 + +**复杂**: +- 关键词:`复杂`、`complex`、`hard`、`major`、`重构` +- 预估时间:8+ 天 + +## 自定义分类规则 + +根据项目特点定制分类规则: + +```bash +# Web 项目特定分类 +WEB_KEYWORDS=("前端" "frontend" "UI" "界面" "页面") +if grep -qE "${WEB_KEYWORDS[*]}" <<< "$CONTENT"; then + TAG_IDS+=("10") # frontend 标签 +fi + +# 后端项目特定分类 +BACKEND_KEYWORDS=("后端" "backend" "API" "接口" "数据库") +if grep -qE "${BACKEND_KEYWORDS[*]}" <<< "$CONTENT"; then + TAG_IDS+=("11") # backend 标签 +fi + +# DevOps 相关分类 +DEVOPS_KEYWORDS=("部署" "deploy" "CI" "CD" "Docker" "Kubernetes") +if grep -qE "${DEVOPS_KEYWORDS[*]}" <<< "$CONTENT"; then + TAG_IDS+=("12") # devops 标签 +fi +``` + +## 错误处理 + +常见问题处理: + +| 问题 | 原因 | 解决方案 | +|------|------|----------| +| 标签 ID 不存在 | 标签未创建 | 先创建项目标签 | +| 权限不足 | 无修改 Issue 权限 | 联系项目管理员 | +| 分类不准确 | 关键词匹配失败 | 优化分类规则或人工审核 | + +## 质量保证 + +确保分类质量的措施: + +1. **定期审查**:定期审查自动分类结果 +2. **反馈学习**:根据反馈调整分类规则 +3. **人工确认**:对不确定的分类进行人工确认 +4. **规则优化**:持续优化关键词匹配规则 + +## 最佳实践 + +1. **渐进式部署**:先小范围测试,再全面应用 +2. **规则透明**:记录分类规则,便于团队理解和调整 +3. **性能监控**:监控分类准确率和效率 +4. **用户反馈**:收集用户反馈,持续改进 + +## 扩展功能 + +### 自动分配 +基于分类自动分配给合适的开发者: + +```bash +# Bug 分配给核心开发者 +if [[ " ${TAG_IDS[@]} " =~ " 1 " ]]; then + ASSIGNEE="senior_developer" +fi + +# 文档问题分配给技术写作 +if [[ " ${TAG_IDS[@]} " =~ " 4 " ]]; then + ASSIGNEE="tech_writer" +fi +``` + +### 自动设置优先级 +基于分类和关键词自动设置优先级: + +```bash +# 严重 bug 设置为高优先级 +if [[ " ${TAG_IDS[@]} " =~ " 1 " ]] && echo "$CONTENT" | grep -iq "严重"; then + PRIORITY_ID="1" # 高优先级 +fi +``` + +## References + +- [workflow-pr-review](workflow-pr-review.md) — PR 审查工作流 +- [workflow-release-notes](workflow-release-notes.md) — Release Notes 生成 +- [gitlink-workflow](../SKILL.md) — 工作流总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [issue +list](../../gitlink-issue/references/gitlink-issue-list.md) — Issue 列表 +- [issue +view](../../gitlink-issue/references/gitlink-issue-view.md) — 查看 Issue diff --git a/skills/gitlink-workflow/references/workflow-pr-review.md b/skills/gitlink-workflow/references/workflow-pr-review.md new file mode 100644 index 0000000..1faed03 --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-pr-review.md @@ -0,0 +1,397 @@ +# Workflow: PR Review(代码审查辅助) + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于辅助代码审查。 + +AI Agent 获取 PR 变更内容,分析代码质量,自动添加 Review 评论,提高代码审查效率。 + +## 工作流概述 + +PR Review 工作流通过分析 Pull Request 的代码变更,自动识别潜在问题、提出改进建议,并生成结构化的审查意见。 + +## 适用场景 + +- **代码审查**:自动化 PR 初步审查 +- **质量检查**:检查代码质量和规范合规性 +- **安全审查**:识别潜在的安全问题 +- **性能分析**:评估性能相关代码变更 +- **文档检查**:验证代码注释和文档完整性 + +## 工作流步骤 + +### 步骤 1:获取 PR 详情 + +```bash +# 获取 PR 基本信息 +gitlink-cli pr +view --id 42 --format json + +# 提取关键信息 +PR_INFO=$(gitlink-cli pr +view --id 42 --format json | jq '.data') +PR_TITLE=$(echo "$PR_INFO" | jq -r '.title') +PR_AUTHOR=$(echo "$PR_INFO" | jq -r '.author.login') +SOURCE_BRANCH=$(echo "$PR_INFO" | jq -r '.head_ref') +TARGET_BRANCH=$(echo "$PR_INFO" | jq -r '.base_ref') +``` + +### 步骤 2:获取变更文件列表 + +```bash +# 获取 PR 变更的文件列表 +gitlink-cli pr +files --id 42 --format json + +# 分析文件变更 +FILES_CHANGED=$(gitlink-cli pr +files --id 42 --format json | \ + jq '.data.files[] | + {filename: .filename, + status: .status, + additions: .additions, + deletions: .deletions}') +``` + +### 步骤 3:获取代码差异 + +```bash +# 获取 PR 的完整代码差异 +gitlink-cli pr +diff --id 42 --format json + +# 提取特定文件的差异 +gitlink-cli pr +diff --id 42 --format json | \ + jq '.data.diff | split("diff --git")' +``` + +### 步骤 4:代码质量分析 + +分析代码变更的多个维度: + +**安全性分析**: +```bash +# 检查敏感信息泄露 +if echo "$DIFF" | grep -iE "password|secret|api_key|token"; then + SECURITY_ISSUES+=("可能包含敏感信息") +fi + +# 检查 SQL 注入风险 +if echo "$DIFF" | grep -iE "SELECT.*FROM.*WHERE.*\$"; then + SECURITY_ISSUES+=("可能的 SQL 注入风险") +fi +``` + +**代码规范检查**: +```bash +# 检查代码风格 +if echo "$DIFF" | grep -P "\t"; then + STYLE_ISSUES+=("包含 Tab 字符,建议使用空格") +fi + +# 检查长行 +if echo "$DIFF" | grep ".\{120,\}"; then + STYLE_ISSUES+=("包含超过 120 字符的长行") +fi +``` + +**性能分析**: +```bash +# 检查可能的性能问题 +if echo "$DIFF" | grep -iE "N\+1|SELECT.*\*|foreach.*query"; then + PERF_ISSUES+=("可能的 N+1 查询问题") +fi +``` + +### 步骤 5:生成 Review 评论 + +```bash +# 生成结构化的 Review 评论 +REVIEW_BODY="# 🔍 代码审查结果 + +## ✅ 优点 +- 代码结构清晰 +- 逻辑正确 +- 遵循项目规范 + +## ⚠️ 需要改进 +${STYLE_ISSUES[@]+($(printf -- "- %s\n" "${STYLE_ISSUES[@]}"))} + +## 🔒 安全问题 +${SECURITY_ISSUES[@]+($(printf -- "- %s\n" "${SECURITY_ISSUES[@]}"))} + +## 🚀 性能建议 +${PERF_ISSUES[@]+($(printf -- "- %s\n" "${PERF_ISSUES[@]}"))} + +## 📝 总体评价 +代码整体质量良好,建议修改上述问题后合并。" + +# 添加 Review 评论 +gitlink-cli api POST /:owner/:repo/pulls/42/reviews --body \ + "{\"body\":\"$REVIEW_BODY\",\"event\":\"COMMENT\"}" +``` + +## 完整工作流示例 + +```bash +#!/bin/bash + +# PR Review 自动化脚本 + +PR_ID=$1 +OWNER="myuser" +REPO="myproject" + +echo "开始审查 PR #$PR_ID..." + +# 1. 获取 PR 详情 +PR_INFO=$(gitlink-cli pr +view --owner $OWNER --repo $REPO --id $PR_ID --format json) +PR_TITLE=$(echo "$PR_INFO" | jq -r '.data.title') +PR_AUTHOR=$(echo "$PR_INFO" | jq -r '.data.author.login') +ADDITIONS=$(echo "$PR_INFO" | jq -r '.data.additions') +DELETIONS=$(echo "$PR_INFO" | jq -r '.data.deletions') + +echo "PR 标题: $PR_TITLE" +echo "PR 作者: $PR_AUTHOR" +echo "代码变更: +$ADDITIONS -$DELETIONS" + +# 2. 获取变更文件 +FILES=$(gitlink-cli pr +files --owner $OWNER --repo $REPO --id $PR_ID --format json) + +# 3. 获取代码差异 +DIFF=$(gitlink-cli pr +diff --owner $OWNER --repo $REPO --id $PR_ID --format json | \ + jq -r '.data.diff') + +# 4. 分析代码 +ISSUES=() +SUGGESTIONS=() + +# 安全性检查 +if echo "$DIFF" | grep -iE "password|secret|api_key|token.*="; then + ISSUES+=("🔒 安全:可能包含硬编码的敏感信息") +fi + +# 代码规范检查 +if echo "$DIFF" | grep -P "\t"; then + SUGGESTIONS+=("📝 规范:建议使用空格代替 Tab") +fi + +# 性能检查 +if echo "$DIFF" | grep -iE "SELECT.*\*.*FROM"; then + SUGGESTIONS+=("🚀 性能:建议明确指定字段而不是使用 *") +fi + +# 5. 生成 Review 评论 +if [ ${#ISSUES[@]} -eq 0 ] && [ ${#SUGGESTIONS[@]} -eq 0 ]; then + REVIEW_BODY="# ✅ 审查通过 + +代码质量良好,没有发现明显问题。可以合并。" + EVENT="APPROVE" +else + REVIEW_BODY="# 🔍 代码审查结果 + +## PR 信息 +- **标题**: $PR_TITLE +- **作者**: $PR_AUTHOR +- **变更**: +$ADDITIONS -$DELETIONS 行 + +## ❌ 需要修复 +$(printf -- "- %s\n" "${ISSUES[@]}") + +## 💡 改进建议 +$(printf -- "- %s\n" "${SUGGESTIONS[@]}") + +## 📋 后续步骤 +1. 修复上述问题 +2. 确保所有测试通过 +3. 更新相关文档" + EVENT="REQUEST_CHANGES" +fi + +# 6. 提交 Review +echo "提交 Review 评论..." +gitlink-cli api POST "/$OWNER/$REPO/pulls/$PR_ID/reviews" --body \ + "{\"body\":\"$REVIEW_BODY\",\"event\":\"$EVENT\"}" + +echo "审查完成!" +``` + +## AI Agent 集成示例 + +Claude Code 等 AI Agent 可以深度集成此工作流: + +```python +# AI Agent 执行 PR Review +def pr_review(owner, repo, pr_id): + """AI Agent 自动代码审查""" + + # 1. 获取 PR 信息 + pr_info = get_pr_details(owner, repo, pr_id) + files_changed = get_pr_files(owner, repo, pr_id) + diff_content = get_pr_diff(owner, repo, pr_id) + + # 2. AI 分析代码 + review_results = { + 'security': analyze_security(diff_content), + 'performance': analyze_performance(diff_content), + 'style': analyze_code_style(diff_content), + 'documentation': analyze_documentation(files_changed), + 'testing': analyze_test_coverage(files_changed) + } + + # 3. 生成审查意见 + review_comment = generate_review_comment(pr_info, review_results) + + # 4. 提交 Review + submit_review(owner, repo, pr_id, review_comment, review_results) + +def analyze_security(diff_content): + """AI 安全性分析""" + issues = [] + + # 检查常见安全问题 + security_patterns = { + 'hardcoded_secrets': r'password\s*=\s*["\'].*["\']', + 'sql_injection': r'SELECT.*FROM.*WHERE.*\${', + 'xss_risk': r'innerHTML\s*=', + 'command_injection': r'system\(|exec\(.*\$' + } + + for issue_name, pattern in security_patterns.items(): + if re.search(pattern, diff_content, re.IGNORECASE): + issues.append({ + 'type': 'security', + 'severity': 'high', + 'issue': issue_name, + 'description': f'检测到 {issue_name} 风险' + }) + + return issues + +def generate_review_comment(pr_info, review_results): + """AI 生成结构化审查意见""" + comment = f"""# 🔍 AI 代码审查报告 + +## PR 概览 +- **标题**: {pr_info['title']} +- **作者**: {pr_info['author']} +- **变更**: +{pr_info['additions']} -{pr_info['deletions']} 行 +- **文件数**: {len(pr_info['files'])} + +## 🔒 安全审查 +""" + + if review_results['security']: + for issue in review_results['security']: + comment += f"- ❌ **{issue['issue']}**: {issue['description']}\n" + else: + comment += "✅ 未发现安全问题\n" + + comment += "\n## 🚀 性能审查\n" + # 类似地添加其他审查结果... + + return comment +``` + +## 审查维度 + +### 1. 安全性审查 +- **敏感信息泄露**:检查硬编码的密码、API 密钥 +- **注入攻击**:SQL 注入、命令注入、XSS 风险 +- **权限控制**:检查权限验证逻辑 +- **数据验证**:输入验证和输出编码 + +### 2. 性能审查 +- **数据库查询**:N+1 查询、缺少索引 +- **内存使用**:内存泄漏、大对象处理 +- **算法复杂度**:时间复杂度和空间复杂度 +- **缓存策略**:缓存命中率和使用合理性 + +### 3. 代码质量审查 +- **代码规范**:命名规范、格式风格 +- **代码结构**:模块化、可读性、可维护性 +- **错误处理**:异常处理完整性 +- **注释文档**:代码注释和文档质量 + +### 4. 测试审查 +- **测试覆盖**:单元测试和集成测试 +- **测试质量**:测试用例的有效性 +- **边界条件**:边界值和异常情况测试 + +### 5. 文档审查 +- **API 文档**:接口文档完整性 +- **用户文档**:用户指南更新 +- **变更日志**:CHANGELOG 更新 + +## 审查决策 + +基于分析结果做出审查决策: + +**APPROVE(通过)**: +- 无严重问题 +- 建议性问题可接受 +- 测试覆盖充分 + +**REQUEST_CHANGES(请求修改)**: +- 存在严重安全问题 +- 重要功能缺失 +- 测试覆盖不足 + +**COMMENT(评论)**: +- 一般性建议 +- 文档改进 +- 代码优化建议 + +## 自动化规则 + +常见问题的自动检测规则: + +```python +AUTO_REVIEW_RULES = { + 'security': { + 'hardcoded_password': { + 'pattern': r'password\s*=\s*["\'][^"\']{8,}["\']', + 'severity': 'high', + 'message': '检测到硬编码密码,请使用环境变量或配置文件' + }, + 'sql_injection': { + 'pattern': r'SELECT.*FROM.*WHERE.*\$[a-z_]+', + 'severity': 'high', + 'message': '可能的 SQL 注入风险,请使用参数化查询' + } + }, + 'performance': { + 'n_plus_one': { + 'pattern': r'for\s+\$.*\{\s*.*SELECT', + 'severity': 'medium', + 'message': '可能的 N+1 查询问题,考虑使用预加载' + }, + 'missing_index': { + 'pattern': r'WHERE.*LIKE.*%.*%', + 'severity': 'low', + 'message': '前缀模糊搜索可能无法使用索引' + } + } +} +``` + +## 最佳实践 + +1. **逐步审查**:先检查严重问题,再检查一般问题 +2. **建设性反馈**:提供具体的改进建议 +3. **平衡严格**:平衡代码质量和开发效率 +4. **学习改进**:从审查中学习,提高代码质量 +5. **团队协作**:与开发者沟通,达成共识 + +## 质量保证 + +确保审查质量: + +1. **双重检查**:重要 PR 进行二次审查 +2. **审查标准**:建立统一的审查标准 +3. **审查培训**:培训审查人员 +4. **反馈收集**:收集对审查质量的反馈 +5. **持续改进**:优化审查流程和规则 + +## References + +- [workflow-issue-triage](workflow-issue-triage.md) — Issue 分类工作流 +- [workflow-release-notes](workflow-release-notes.md) — Release Notes 生成 +- [gitlink-workflow](../SKILL.md) — 工作流总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [pr +view](../../gitlink-pr/references/gitlink-pr-view.md) — 查看 PR +- [pr +files](../../gitlink-pr/references/gitlink-pr-files.md) — 查看 PR 文件变更 diff --git a/skills/gitlink-workflow/references/workflow-release-notes.md b/skills/gitlink-workflow/references/workflow-release-notes.md new file mode 100644 index 0000000..91d3438 --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-release-notes.md @@ -0,0 +1,513 @@ +# Workflow: Release Notes 生成 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于自动生成版本发布说明。 + +AI Agent 从提交历史、Issue 和 PR 数据自动生成结构化的 Release Notes,确保发布文档的完整性和准确性。 + +## 工作流概述 + +Release Notes 生成工作流自动收集版本间的所有变更信息,整理成结构化的发布说明,包含新功能、Bug 修复、破坏性变更等重要信息。 + +## 适用场景 + +- **版本发布**:为每个新版本生成发布说明 +- **变更追踪**:追踪版本间的具体变更 +- **用户沟通**:向用户清晰传达版本更新内容 +- **历史记录**:维护项目变更历史 + +## 工作流步骤 + +### 步骤 1:确定版本范围 + +```bash +# 获取最新标签 +LATEST_TAG=$(gitlink-cli release +list --format json | \ + jq -r '.data.releases[0].tag_name') + +# 确定新版本号 +NEW_TAG="v1.2.0" + +# 或者获取两个标签之间的差异 +BASE_TAG="v1.1.0" +HEAD_TAG="v1.2.0" +``` + +### 步骤 2:获取提交历史 + +```bash +# 获取版本间的提交比较 +gitlink-cli api GET /:owner/:repo/compare/$BASE_TAG...$HEAD_TAG --format json + +# 提取提交信息 +COMMITS=$(gitlink-cli api GET /:owner/:repo/compare/$BASE_TAG...$HEAD_TAG --format json | \ + jq '.data.commits[] | + {message: .commit.message, + author: .commit.author.name, + date: .commit.author.date, + sha: .sha}') +``` + +### 步骤 3:获取已关闭的 Issue + +```bash +# 获取已关闭的 Issue +CLOSED_ISSUES=$(gitlink-cli issue +list --state closed --format json | \ + jq '.data.issues[] | + select(.closed_at >= "'$START_DATE'") | + {id: .id, + subject: .subject, + labels: [.issue_tags[].name], + closed_at: .closed_at}') +``` + +### 步骤 4:获取合并的 PR + +```bash +# 获取已合并的 PR +MERGED_PRS=$(gitlink-cli pr +list --state merged --format json | \ + jq '.data.prs[] | + select(.merged_at >= "'$START_DATE'") | + {id: .id, + title: .title, + number: .number, + author: .author.login, + merged_at: .merged_at}') +``` + +### 步骤 5:分类和整理变更 + +```bash +# 按变更类型分类 +FEATURES=() +BUG_FIXES=() +ENHANCEMENTS=() +BREAKING_CHANGES=() + +# 分析 Issue 标签分类 +while read -r issue; do + SUBJECT=$(echo "$issue" | jq -r '.subject') + LABELS=$(echo "$issue" | jq -r '.labels[]') + + if echo "$LABELS" | grep -q "feature"; then + FEATURES+=("$SUBJECT") + elif echo "$LABELS" | grep -q "bug"; then + BUG_FIXES+=("$SUBJECT") + elif echo "$LABELS" | grep -q "enhancement"; then + ENHANCEMENTS+=("$SUBJECT") + fi +done <<< "$CLOSED_ISSUES" + +# 分析提交信息 +while read -r commit; do + MESSAGE=$(echo "$commit" | jq -r '.message') + + if echo "$MESSAGE" | grep -iq "BREAKING"; then + BREAKING_CHANGES+=("$MESSAGE") + fi +done <<< "$COMMITS" +``` + +### 步骤 6:生成 Release Notes + +```bash +# 生成结构化的 Release Notes +RELEASE_NOTES="# 🚀 Release Notes for $NEW_TAG + +## 📝 What's Changed + +### ✨ New Features +$(for feature in "${FEATURES[@]}"; do + echo "- $feature" +done) + +### 🐛 Bug Fixes +$(for fix in "${BUG_FIXES[@]}"; do + echo "- $fix" +done) + +### 🔧 Enhancements +$(for enhancement in "${ENHANCEMENTS[@]}"; do + echo "- $enhancement" +done) + +### ⚠️ Breaking Changes +$(for breaking in "${BREAKING_CHANGES[@]}"; do + echo "- $breaking" +done)" + +# 创建 Release +gitlink-cli release +create --tag $NEW_TAG --name "$NEW_TAG" --body "$RELEASE_NOTES" +``` + +## 完整工作流示例 + +```bash +#!/bin/bash + +# Release Notes 自动生成脚本 + +OWNER="myuser" +REPO="myproject" +NEW_VERSION=$1 + +if [ -z "$NEW_VERSION" ]; then + echo "使用方法: $0 <version>" + exit 1 +fi + +echo "为版本 $NEW_VERSION 生成 Release Notes..." + +# 1. 获取上一个版本 +PREV_VERSION=$(gitlink-cli release +list --owner $OWNER --repo $REPO --format json | \ + jq -r '.data.releases[0].tag_name') + +echo "上一个版本: $PREV_VERSION" +echo "新版本: $NEW_VERSION" + +# 2. 获取提交比较 +COMPARE_DATA=$(gitlink-cli api GET "/$OWNER/$REPO/compare/$PREV_VERSION...$NEW_VERSION" --format json) +COMMITS=$(echo "$COMPARE_DATA" | jq -r '.data.commits') + +# 3. 获取已关闭的 Issue +ISSUES=$(gitlink-cli issue +list --owner $OWNER --repo $REPO --state closed --format json | \ + jq '.data.issues[]') + +# 4. 获取已合并的 PR +PRS=$(gitlink-cli pr +list --owner $OWNER --repo $REPO --state merged --format json | \ + jq '.data.prs[]') + +# 5. 分析变更数据 +FEATURE_COUNT=0 +BUG_FIX_COUNT=0 +ENHANCEMENT_COUNT=0 +BREAKING_COUNT=0 + +# 分析 Issue +FEATURES=$(echo "$ISSUES" | jq -r 'select(.issue_tags[]?.name == "feature") | "- \(.subject) (#\(.id))"') +BUG_FIXES=$(echo "$ISSUES" | jq -r 'select(.issue_tags[]?.name == "bug") | "- \(.subject) (#\(.id))"') +ENHANCEMENTS=$(echo "$ISSUES" | jq -r 'select(.issue_tags[]?.name == "enhancement") | "- \(.subject) (#\(.id))"') + +# 分析提交中的破坏性变更 +BREAKING_CHANGES=$(echo "$COMMITS" | jq -r 'select(.commit.message | contains("BREAKING")) | "- \(.commit.message | split("\n")[0])"') + +# 统计数量 +FEATURE_COUNT=$(echo "$FEATURES" | grep -c "^-" || echo "0") +BUG_FIX_COUNT=$(echo "$BUG_FIXES" | grep -c "^-" || echo "0") +ENHANCEMENT_COUNT=$(echo "$ENHANCEMENTS" | grep -c "^-" || echo "0") +BREAKING_COUNT=$(echo "$BREAKING_CHANGES" | grep -c "^-" || echo "0") + +# 6. 生成 Release Notes +RELEASE_NOTES="# 🎉 Release $NEW_VERSION + +## 📊 变更统计 +- **新功能**: $FEATURE_COUNT 个 +- **Bug 修复**: $BUG_FIX_COUNT 个 +- **功能改进**: $ENHANCEMENT_COUNT 个 +- **破坏性变更**: $BREAKING_COUNT 个 + +## ✨ 新功能 +$FEATURES + +## 🐛 Bug 修复 +$BUG_FIXES + +## 🔧 功能改进 +$ENHANCEMENTS + +## ⚠️ 破坏性变更 +$BREAKING_CHANGES + +## 🙏 贡献者 +感谢所有参与此版本开发的贡献者! + +## 📥 安装 +\`\`\`bash +npm install $OWNER/$REPO@$NEW_VERSION +\`\`\` + +## 📚 文档 +完整文档请查看: https://www.gitlink.org.cn/$OWNER/$REPO/wiki + +--- +**完整变更日志**: https://www.gitlink.org.cn/$OWNER/$REPO/compare/$PREV_VERSION...$NEW_VERSION" + +# 7. 创建 Release +echo "创建 Release $NEW_VERSION..." +gitlink-cli release +create --owner $OWNER --repo $REPO \ + --tag $NEW_VERSION --name "$NEW_VERSION" --body "$RELEASE_NOTES" + +echo "Release $NEW_VERSION 创建完成!" +``` + +## AI Agent 集成示例 + +Claude Code 等 AI Agent 可以深度集成此工作流: + +```python +# AI Agent 生成 Release Notes +def generate_release_notes(owner, repo, new_version): + """AI Agent 自动生成发布说明""" + + # 1. 获取版本信息 + prev_version = get_latest_release(owner, repo) + commits = compare_revisions(owner, repo, prev_version, new_version) + issues = get_closed_issues(owner, repo, since=prev_version) + prs = get_merged_prs(owner, repo, since=prev_version) + + # 2. AI 分析变更 + changes = analyze_changes(commits, issues, prs) + + # 3. 生成发布说明 + release_notes = format_release_notes(new_version, changes, prev_version) + + # 4. 创建 Release + create_release(owner, repo, new_version, release_notes) + + return release_notes + +def analyze_changes(commits, issues, prs): + """AI 智能分析变更内容""" + + changes = { + 'features': [], + 'bug_fixes': [], + 'enhancements': [], + 'breaking_changes': [], + 'contributors': set(), + 'performance_improvements': [], + 'security_fixes': [] + } + + # 分析 Issue + for issue in issues: + labels = [label['name'] for label in issue.get('issue_tags', [])] + subject = issue['subject'] + + if 'feature' in labels: + changes['features'].append(format_issue_reference(issue)) + elif 'bug' in labels: + changes['bug_fixes'].append(format_issue_reference(issue)) + elif 'enhancement' in labels: + changes['enhancements'].append(format_issue_reference(issue)) + elif 'security' in labels: + changes['security_fixes'].append(format_issue_reference(issue)) + + # 分析提交信息 + for commit in commits: + message = commit['commit']['message'] + + # 使用 AI 分析提交消息 + analysis = analyze_commit_message(message) + + if analysis.get('breaking_change'): + changes['breaking_changes'].append(message) + elif analysis.get('performance'): + changes['performance_improvements'].append(message) + + # 收集贡献者 + changes['contributors'].add(commit['author']['name']) + + return changes + +def format_release_notes(version, changes, prev_version): + """AI 生成结构化发布说明""" + + notes = f"""# 🎉 Release {version} + +## 📊 变更统计 +- **新功能**: {len(changes['features'])} 个 +- **Bug 修复**: {len(changes['bug_fixes'])} 个 +- **功能改进**: {len(changes['enhancements'])} 个 +- **破坏性变更**: {len(changes['breaking_changes'])} 个 +""" + + if changes['features']: + notes += "\n## ✨ 新功能\n" + notes += "\n".join(f"- {feature}" for feature in changes['features']) + notes += "\n" + + if changes['bug_fixes']: + notes += "\n## 🐛 Bug 修复\n" + notes += "\n".join(f"- {fix}" for fix in changes['bug_fixes']) + notes += "\n" + + if changes['breaking_changes']: + notes += "\n## ⚠️ 破坏性变更\n" + notes += "\n".join(f"- {change}" for change in changes['breaking_changes']) + notes += "\n" + + if changes['contributors']: + notes += f"\n## 🙏 贡献者\n" + notes += ", ".join(sorted(changes['contributors'])) + notes += "\n" + + notes += f"\n---\n**完整变更日志**: https://www.gitlink.org.cn/{owner}/{repo}/compare/{prev_version}...{version}" + + return notes + +def analyze_commit_message(message): + """AI 分析提交消息""" + return { + 'breaking_change': bool(re.search(r'BREAKING|breaking|!', message)), + 'performance': bool(re.search(r'performance|优化|提升', message, re.I)), + 'security': bool(re.search(r'security|安全|漏洞', message, re.I)) + } +``` + +## Release Notes 模板 + +### 标准模板 + +```markdown +# 🎉 Release {VERSION} + +## 📊 变更统计 +- **新功能**: {FEATURE_COUNT} 个 +- **Bug 修复**: {BUG_FIX_COUNT} 个 +- **功能改进**: {ENHANCEMENT_COUNT} 个 +- **破坏性变更**: {BREAKING_COUNT} 个 + +## ✨ 新功能 +{FEATURES_LIST} + +## 🐛 Bug 修复 +{BUG_FIXES_LIST} + +## 🔧 功能改进 +{ENHANCEMENTS_LIST} + +## ⚠️ 破坏性变更 +{BREAKING_CHANGES_LIST} + +## 🙏 贡献者 +{CONTRIBUTORS_LIST} + +## 📥 安装 +```bash +# 使用 npm +npm install {PACKAGE}@{VERSION} + +# 使用 yarn +yarn add {PACKAGE}@{VERSION} + +# 使用 pnpm +pnpm add {PACKAGE}@{VERSION} +``` + +## 🔄 升级指南 +{UPGRADE_GUIDE} + +## 📚 文档 +完整文档请查看: https://www.gitlink.org.cn/{OWNER}/{REPO}/wiki + +--- +**完整变更日志**: https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV_VERSION}...{VERSION} +``` + +### 简化模板 + +```markdown +# {VERSION} + +## 新增 +{FEATURES} + +## 修复 +{BUG_FIXES} + +## 改进 +{ENHANCEMENTS} + +## 贡献者 +{CONTRIBUTORS} + +## 链接 +- [完整变更](https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV_VERSION}...{VERSION}) +- [问题追踪](https://www.gitlink.org.cn/{OWNER}/{REPO}/issues) +``` + +## 自动化分类规则 + +基于提交信息和 Issue 标签的自动分类: + +```python +RELEASE_CATEGORIES = { + 'features': { + 'labels': ['feature', 'enhancement'], + 'commit_keywords': ['feat:', 'add', 'new'], + 'icon': '✨', + 'title': '新功能' + }, + 'bug_fixes': { + 'labels': ['bug', 'fix'], + 'commit_keywords': ['fix:', 'bugfix'], + 'icon': '🐛', + 'title': 'Bug 修复' + }, + 'enhancements': { + 'labels': ['improvement', 'optimize'], + 'commit_keywords': ['improve:', 'optimize:', 'refactor:'], + 'icon': '🔧', + 'title': '功能改进' + }, + 'breaking_changes': { + 'labels': ['breaking', 'major'], + 'commit_keywords': ['BREAKING', 'breaking:', '!'], + 'icon': '⚠️', + 'title': '破坏性变更' + }, + 'security': { + 'labels': ['security', 'vulnerability'], + 'commit_keywords': ['security:', 'fix security'], + 'icon': '🔒', + 'title': '安全修复' + } +} +``` + +## 版本号规范 + +遵循语义化版本 (Semantic Versioning): + +``` +MAJOR.MINOR.PATCH + +MAJOR: 不兼容的 API 变更 +MINOR: 向后兼容的功能新增 +PATCH: 向后兼容的 Bug 修复 +``` + +版本号示例: +- `1.0.0` → `1.1.0`:新增功能 +- `1.1.0` → `1.1.1`:Bug 修复 +- `1.1.1` → `2.0.0`:破坏性变更 + +## 质量检查 + +发布前检查清单: + +- [ ] Release Notes 完整性检查 +- [ ] 变更统计准确性验证 +- [ ] 破坏性变更标识 +- [ ] 升级指南完整性 +- [ ] 文档链接正确性 +- [ ] 安装指令有效性 +- [ ] 贡献者列表完整性 + +## 最佳实践 + +1. **定期发布**:建立定期发布节奏 +2. **变更追踪**:确保所有变更都被记录 +3. **清晰分类**:使用明确的分类和标签 +4. **用户友好**:提供升级指南和迁移说明 +5. **版本规范**:遵循语义化版本规范 + +## References + +- [workflow-issue-triage](workflow-issue-triage.md) — Issue 分类工作流 +- [workflow-pr-review](workflow-pr-review.md) — PR 审查工作流 +- [gitlink-workflow](../SKILL.md) — 工作流总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [release +create](../../gitlink-release/references/gitlink-release-create.md) — 创建 Release +- [release +list](../../gitlink-release/references/gitlink-release-list.md) — 列出 Release diff --git a/skills/gitlink-workflow/references/workflow-repo-setup.md b/skills/gitlink-workflow/references/workflow-repo-setup.md new file mode 100644 index 0000000..f887ff3 --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-repo-setup.md @@ -0,0 +1,646 @@ +# Workflow: Repo Setup(仓库初始化) + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于自动化仓库初始化设置。 + +AI Agent 自动创建新仓库并完成基础配置,包括分支保护、初始文档、Issue 模板等,确保新项目快速启动。 + +## 工作流概述 + +Repo Setup 工作流自动化创建新 GitLink 仓库,并进行标准化初始化配置,包括 README、LICENSE、分支保护、CI 配置等项目必需的设置。 + +## 适用场景 + +- **项目创建**:快速创建新的项目仓库 +- **标准化设置**:确保所有仓库配置一致 +- **模板应用**:应用组织或团队的项目模板 +- **批量创建**:批量创建多个相关项目 + +## 工作流步骤 + +### 步骤 1:创建仓库 + +```bash +# 创建新仓库 +gitlink-cli repo +create \ + --name my-awesome-project \ + --description "一个很棒的项目" \ + --private false + +# 或者创建私有仓库 +gitlink-cli repo +create \ + --name internal-project \ + --description "内部项目" \ + --private true +``` + +### 步骤 2:初始化本地仓库 + +```bash +# 本地初始化 +cd my-awesome-project +git init +git remote add gitlink https://www.gitlink.org.cn/username/my-awesome-project.git + +# 创建初始文件 +echo "# My Awesome Project" > README.md +echo "MIT License" > LICENSE +git add . +git commit -m "Initial commit" +git push -u gitlink master:master +``` + +### 步骤 3:设置分支保护 + +```bash +# 保护主分支 +gitlink-cli branch +protect \ + --owner username \ + --repo my-awesome-project \ + --name master + +# 如果使用 main 分支 +gitlink-cli branch +protect \ + --owner username \ + --repo my-awesome-project \ + --name main +``` + +### 步骤 4:创建配置文件 + +```bash +# 创建 .gitignore +cat > .gitignore << 'EOF' +# 依赖 +node_modules/ +vendor/ + +# 构建输出 +dist/ +build/ +*.log + +# IDE +.vscode/ +.idea/ + +# 环境变量 +.env +.env.local +EOF + +# 创建配置文件(根据项目类型) +if [ "$PROJECT_TYPE" = "node" ]; then + echo '{"name":"my-awesome-project","version":"1.0.0"}' > package.json +elif [ "$PROJECT_TYPE" = "python" ]; then + echo "[project]\nname = 'my-awesome-project'\nversion = '1.0.0'" > pyproject.toml +fi + +git add .gitignore package.json pyproject.toml +git commit -m "Add project configuration files" +git push gitlink master:master +``` + +### 步骤 5:创建 Issue 和 PR 模板 + +```bash +# 创建 Issue 模板 +cat > .github/ISSUE_TEMPLATE/bug_report.md << 'EOF' +--- +name: Bug 报告 +about: 报告项目中的问题 +title: '[Bug] ' +--- + +## Bug 描述 +简要描述遇到的问题。 + +## 复现步骤 +1. +2. +3. + +## 预期行为 +描述你期望发生的行为。 + +## 实际行为 +描述实际发生的行为。 + +## 环境 +- 操作系统: +- 版本: +- 其他信息: +EOF + +# 创建 PR 模板 +cat > .github/PULL_REQUEST_TEMPLATE.md << 'EOF' +## 变更描述 +简要描述这个 PR 的目的和内容。 + +## 变更类型 +- [ ] Bug 修复 +- [ ] 新功能 +- [ ] 功能改进 +- [ ] 文档更新 +- [ ] 性能优化 +- [ ] 代码重构 + +## 测试 +描述你如何测试这些变更: + +## 检查清单 +- [ ] 代码遵循项目规范 +- [ ] 已添加必要的测试 +- [ ] 已更新相关文档 +- [ ] 所有测试通过 +- [ ] 无合并冲突 +EOF + +git add .github/ +git commit -m "Add issue and PR templates" +git push gitlink master:master +``` + +### 步骤 6:创建初始 Issue + +```bash +# 创建项目初始化 Issue +gitlink-cli issue +create \ + --owner username \ + --repo my-awesome-project \ + --title "完成项目初始化" \ + --body "## 初始化任务清单 + +- [x] 创建仓库 +- [x] 添加 README.md +- [x] 添加 LICENSE +- [x] 设置分支保护 +- [x] 添加配置文件 +- [x] 创建 Issue 模板 +- [x] 创建 PR 模板 +- [ ] 配置 CI/CD +- [ ] 添加项目文档 +- [ ] 设置开发指南 + +## 下一步 +1. 配置 CI/CD 流程 +2. 编写项目文档 +3. 设置开发环境指南 +4. 创建贡献指南" +``` + +### 步骤 7:配置 CI/CD(可选) + +```bash +# 创建 CI 配置文件 +cat > .gitlab-ci.yml << 'EOF' +stages: + - test + - build + - deploy + +test: + stage: test + script: + - echo "Running tests..." + - npm test + +build: + stage: build + script: + - echo "Building..." + - npm run build + +deploy: + stage: deploy + script: + - echo "Deploying..." + - npm run deploy + only: + - master +EOF + +git add .gitlab-ci.yml +git commit -m "Add CI/CD configuration" +git push gitlink master:master +``` + +## 完整工作流示例 + +```bash +#!/bin/bash + +# 仓库初始化自动化脚本 + +PROJECT_NAME=$1 +PROJECT_DESC=$2 +IS_PRIVATE=${3:-false} +OWNER="username" + +if [ -z "$PROJECT_NAME" ]; then + echo "使用方法: $0 <project_name> [description] [private]" + exit 1 +fi + +echo "开始初始化项目: $PROJECT_NAME" + +# 1. 创建仓库 +echo "创建仓库..." +REPO_INFO=$(gitlink-cli repo +create \ + --name "$PROJECT_NAME" \ + --description "$PROJECT_DESC" \ + --private "$IS_PRIVATE" \ + --format json) + +if echo "$REPO_INFO" | jq -e '.ok' > /dev/null; then + echo "✅ 仓库创建成功" +else + echo "❌ 仓库创建失败" + exit 1 +fi + +# 2. 本地初始化 +echo "初始化本地仓库..." +mkdir -p "$PROJECT_NAME" +cd "$PROJECT_NAME" +git init +git remote add gitlink "https://www.gitlink.org.cn/$OWNER/$PROJECT_NAME.git" + +# 3. 创建基础文件 +echo "创建项目文件..." + +# README.md +cat > README.md << EOF +# $PROJECT_NAME + +$PROJECT_DESC + +## 快速开始 + +\`\`\`bash +# 安装依赖 +npm install + +# 开发模式运行 +npm run dev + +# 构建项目 +npm run build + +# 运行测试 +npm test +\`\`\` + +## 项目结构 + +\`\`\` +$PROJECT_NAME/ +├── src/ # 源代码 +├── tests/ # 测试文件 +├── docs/ # 文档 +├── scripts/ # 脚本 +└── package.json # 项目配置 +\`\`\` + +## 贡献指南 + +欢迎提交 Issue 和 Pull Request! + +## 许可证 + +MIT License +EOF + +# LICENSE +cat > LICENSE << 'EOF' +MIT License + +Copyright (c) 2026 + +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. +EOF + +# package.json +cat > package.json << EOF +{ + "name": "$PROJECT_NAME", + "version": "1.0.0", + "description": "$PROJECT_DESC", + "main": "src/index.js", + "scripts": { + "dev": "echo 'Development mode'", + "build": "echo 'Building project'", + "test": "echo 'Running tests'", + "lint": "echo 'Linting code'" + }, + "keywords": [], + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://www.gitlink.org.cn/$OWNER/$PROJECT_NAME.git" + } +} +EOF + +# .gitignore +cat > .gitignore << 'EOF' +node_modules/ +dist/ +build/ +*.log +.env +.env.local +.DS_Store +*.swp +*.swo +.vscode/ +.idea/ +coverage/ +.nyc_output/ +EOF + +# 创建目录结构 +mkdir -p src tests docs scripts + +# 4. 提交初始文件 +echo "提交初始文件..." +git add . +git commit -m "Initial commit" +git push -u gitlink master:master + +# 5. 设置分支保护 +echo "设置分支保护..." +gitlink-cli branch +protect \ + --owner "$OWNER" \ + --repo "$PROJECT_NAME" \ + --name master + +# 6. 创建项目模板 +echo "创建 Issue 模板..." +mkdir -p .github/ISSUE_TEMPLATE + +cat > .github/ISSUE_TEMPLATE/bug_report.md << 'EOF' +--- +name: Bug 报告 +about: 报告项目中的问题 +title: '[Bug] ' +--- + +## Bug 描述 +简要描述遇到的问题。 + +## 复现步骤 +1. +2. +3. + +## 预期行为 +描述你期望发生的行为。 + +## 实际行为 +描述实际发生的行为。 + +## 环境 +- 操作系统: +- 版本: +- 其他信息: +EOF + +# 7. 创建初始化 Issue +echo "创建初始化 Issue..." +gitlink-cli issue +create \ + --owner "$OWNER" \ + --repo "$PROJECT_NAME" \ + --title "完成项目初始化设置" \ + --body "## 项目初始化任务 + +### 基础配置 +- [x] 创建仓库 +- [x] 添加 README.md +- [x] 添加 LICENSE +- [x] 设置分支保护 +- [x] 添加配置文件 +- [x] 创建目录结构 + +### 下一步任务 +- [ ] 配置 CI/CD +- [ ] 编写项目文档 +- [ ] 设置开发指南 +- [ ] 创建贡献指南 +- [ ] 添加代码规范 +- [ ] 配置代码检查 + +## 开发环境设置 +\`\`\`bash +# 克隆仓库 +git clone https://www.gitlink.org.cn/$OWNER/$PROJECT_NAME.git + +# 安装依赖 +cd $PROJECT_NAME +npm install + +# 开发模式 +npm run dev +\`\`\` + +## 贡献流程 +1. Fork 本仓库 +2. 创建功能分支 (\`git checkout -b feature/AmazingFeature\`) +3. 提交更改 (\`git commit -m 'Add some AmazingFeature'\`) +4. 推送到分支 (\`git push origin feature/AmazingFeature\`) +5. 创建 Pull Request" + +echo "✅ 项目初始化完成!" +echo "" +echo "项目信息:" +echo " 名称: $PROJECT_NAME" +echo " 仓库: https://www.gitlink.org.cn/$OWNER/$PROJECT_NAME" +echo " 状态: $([ "$IS_PRIVATE" = "true" ] && echo "私有" || echo "公开")" +echo "" +echo "下一步:" +echo " 1. cd $PROJECT_NAME" +echo " 2. 配置开发环境" +echo " 3. 开始开发" +``` + +## AI Agent 集成示例 + +Claude Code 等 AI Agent 可以深度集成此工作流: + +```python +# AI Agent 仓库初始化 +def setup_repository(owner, repo_name, description, private=False): + """AI Agent 自动化仓库初始化""" + + # 1. 创建仓库 + repo = create_repository(owner, repo_name, description, private) + + # 2. 初始化项目结构 + project_structure = generate_project_structure(repo_name) + initialize_project_files(repo_name, project_structure) + + # 3. 配置分支保护 + protect_branch(owner, repo_name, 'master') + + # 4. 创建 Issue 模板 + create_issue_templates(owner, repo_name) + + # 5. 配置 CI/CD + setup_cicd(owner, repo_name, project_structure['type']) + + # 6. 创建初始化 Issue + create_setup_issue(owner, repo_name) + + return repo + +def generate_project_structure(repo_name): + """AI 生成项目结构""" + + # 分析项目名称和描述,确定项目类型 + project_type = analyze_project_type(repo_name) + + structures = { + 'node': { + 'directories': ['src', 'tests', 'docs', 'scripts'], + 'files': { + 'package.json': generate_package_json(repo_name), + '.gitignore': generate_gitignore('node'), + 'README.md': generate_readme(repo_name) + } + }, + 'python': { + 'directories': ['src', 'tests', 'docs', 'scripts'], + 'files': { + 'pyproject.toml': generate_pyproject(repo_name), + '.gitignore': generate_gitignore('python'), + 'README.md': generate_readme(repo_name) + } + } + } + + return structures.get(project_type, structures['node']) + +def create_issue_templates(owner, repo): + """创建 Issue 模板""" + + templates = { + 'bug_report.md': generate_bug_template(), + 'feature_request.md': generate_feature_template(), + 'question.md': generate_question_template() + } + + for template_name, content in templates.items(): + # 使用 API 创建模板文件 + create_file_via_api(owner, repo, + f'.github/ISSUE_TEMPLATE/{template_name}', + content) +``` + +## 项目模板 + +### 前端项目模板 + +```bash +# 创建前端项目结构 +create_frontend_project() { + mkdir -p src/{components,pages,hooks,utils} + mkdir -p public/{images,fonts} + mkdir -p tests/{unit,integration} + + # package.json + cat > package.json << 'EOF' +{ + "name": "my-frontend-project", + "version": "1.0.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "vitest", + "lint": "eslint src/" + }, + "dependencies": { + "react": "^18.0.0" + } +} +EOF +} +``` + +### 后端项目模板 + +```bash +# 创建后端项目结构 +create_backend_project() { + mkdir -p src/{controllers,models,routes,middleware} + mkdir -p tests/{unit,integration} + mkdir -p config + mkdir -m migrations + + # package.json + cat > package.json << 'EOF' +{ + "name": "my-backend-project", + "version": "1.0.0", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js", + "test": "jest", + "migrate": "knex migrate:latest" + }, + "dependencies": { + "express": "^4.18.0", + "knex": "^2.0.0" + } +} +EOF +} +``` + +## 配置检查清单 + +仓库初始化完成后检查: + +- [ ] 仓库创建成功 +- [ ] README.md 完整 +- [ ] LICENSE 文件存在 +- [ ] 分支保护已设置 +- [ ] .gitignore 配置正确 +- [ ] Issue 模板创建 +- [ ] PR 模板创建 +- [ ] CI/CD 配置(可选) +- [ ] 初始化 Issue 已创建 +- [ ] 本地仓库可正常推送 + +## 最佳实践 + +1. **标准化模板**:使用统一的项目模板 +2. **配置管理**:统一配置文件格式 +3. **文档完整**:确保 README 和文档完整 +4. **安全设置**:合理设置分支保护 +5. **CI/CD 配置**:早期建立 CI/CD 流程 + +## References + +- [workflow-issue-triage](workflow-issue-triage.md) — Issue 分类工作流 +- [workflow-sprint-report](workflow-sprint-report.md) — Sprint 报告工作流 +- [gitlink-workflow](../SKILL.md) — 工作流总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [repo +create](../../gitlink-repo/references/repo-create.md) — 创建仓库 +- [branch +protect](../../gitlink-branch/references/branch-protect.md) — 保护分支 diff --git a/skills/gitlink-workflow/references/workflow-sprint-report.md b/skills/gitlink-workflow/references/workflow-sprint-report.md new file mode 100644 index 0000000..b362796 --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-sprint-report.md @@ -0,0 +1,549 @@ +# Workflow: Sprint Report(Sprint 报告) + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于自动生成 Sprint 进度报告。 + +AI Agent 自动汇总 Sprint 期间的 Issue、PR、提交记录等数据,生成结构化的进度报告,帮助团队了解项目进展。 + +## 工作流概述 + +Sprint Report 工作流收集 Sprint 期间的所有活动数据,包括 Issue 完成情况、PR 合并状态、代码提交统计、团队成员贡献等,自动生成团队进度报告。 + +## 适用场景 + +- **Sprint 回顾**:为 Sprint 回顾会议提供数据支持 +- **进度汇报**:向管理层汇报项目进展 +- **团队协调**:协调团队工作计划和资源分配 +- **绩效评估**:评估团队和个人的工作效率 + +## 工作流步骤 + +### 步骤 1:确定报告时间范围 + +```bash +# 设置 Sprint 时间范围(通常为 2 周) +SPRINT_START="2026-01-01" +SPRINT_END="2026-01-14" + +# 或者基于当前时间计算 +SPRINT_END=$(date +%Y-%m-%d) +SPRINT_START=$(date -d "14 days ago" +%Y-%m-%d) +``` + +### 步骤 2:获取 Issue 统计 + +```bash +# 获取 Sprint 期间关闭的 Issue +CLOSED_ISSUES=$(gitlink-cli issue +list --state closed --format json | \ + jq '.data.issues[] | + select(.closed_at >= "'$SPRINT_START'" and .closed_at <= "'$SPRINT_END'")') + +# 获取新增 Issue +NEW_ISSUES=$(gitlink-cli issue +list --state open --format json | \ + jq '.data.issues[] | + select(.created_at >= "'$SPRINT_START'" and .created_at <= "'$SPRINT_END'")') + +# 统计 Issue 数据 +CLOSED_COUNT=$(echo "$CLOSED_ISSUES" | jq -s 'length') +NEW_COUNT=$(echo "$NEW_ISSUES" | jq -s 'length') +``` + +### 步骤 3:获取 PR 统计 + +```bash +# 获取 Sprint 期间合并的 PR +MERGED_PRS=$(gitlink-cli pr +list --state merged --format json | \ + jq '.data.prs[] | + select(.merged_at >= "'$SPRINT_START'" and .merged_at <= "'$SPRINT_END'")') + +# 获取新建的 PR +NEW_PRS=$(gitlink-cli pr +list --state open --format json | \ + jq '.data.prs[] | + select(.created_at >= "'$SPRINT_START'" and .created_at <= "'$SPRINT_END'")') + +# 统计 PR 数据 +MERGED_COUNT=$(echo "$MERGED_PRS" | jq -s 'length') +NEW_PR_COUNT=$(echo "$NEW_PRS" | jq -s 'length') +``` + +### 步骤 4:获取提交统计 + +```bash +# 获取项目活动数据 +ACTIVITY=$(gitlink-cli api GET /:owner/:repo/activity --format json | \ + jq ".data.activity[] | + select(.created_at >= \"$SPRINT_START\" and .created_at <= \"$SPRINT_END\")") + +# 提取提交数据 +COMMITS=$(echo "$ACTIVITY" | jq 'select(.type == "commit")') +COMMIT_COUNT=$(echo "$COMMITS" | jq -s 'length') +``` + +### 步骤 5:分析团队贡献 + +```bash +# 按团队成员统计贡献 +CONTRIBUTORS=$(gitlink-cli api GET /:owner/:repo/activity --format json | \ + jq ".data.activity[] | + select(.created_at >= \"$SPRINT_START\" and .created_at <= \"$SPRINT_END\") | + .author | + group_by(.) | + map({developer: .[0], count: length}) | + sort_by(.count) | reverse") +``` + +### 步骤 6:生成 Sprint 报告 + +```bash +# 生成结构化的 Sprint 报告 +SPRINT_REPORT="# 📊 Sprint 进度报告 + +**时间范围**: $SPRINT_START 至 $SPRINT_END +**Sprint 周期**: 14 天 + +## 🎯 目标达成情况 + +### Issue 统计 +- ✅ **完成 Issue**: $CLOSED_COUNT 个 +- 🆕 **新增 Issue**: $NEW_COUNT 个 +- 📈 **完成率**: $(($CLOSED_COUNT * 100 / ($CLOSED_COUNT + $NEW_COUNT)))% + +### Pull Request 统计 +- 🔀 **合并 PR**: $MERGED_COUNT 个 +- 🆕 **新建 PR**: $NEW_PR_COUNT 个 +- ✅ **合并率**: $(($MERGED_COUNT * 100 / ($MERGED_COUNT + $NEW_PR_COUNT)))% + +### 代码提交统计 +- 💻 **提交次数**: $COMMIT_COUNT 次 +- 📊 **日均提交**: $(($COMMIT_COUNT / 14)) 次/天 + +## 👥 团队贡献 +$(echo "$CONTRIBUTORS" | jq -r '.[] | "- **\(.developer)**: \(.count) 次贡献"') + +## 🎉 主要成就 +$(echo "$CLOSED_ISSUES" | jq -r '.[] | "- 完成 Issue: \(.subject)"') + +## 🔄 进行中工作 +$(echo "$NEW_ISSUES" | jq -r '.[] | "- 新建 Issue: \(.subject)"') + +## 📈 下期计划 +1. 继续进行中的 Issue 开发 +2. 新功能规划和设计 +3. 技术债务清理 +4. 性能优化工作" + +# 输出报告 +echo "$SPRINT_REPORT" +``` + +## 完整工作流示例 + +```bash +#!/bin/bash + +# Sprint 报告自动化生成脚本 + +OWNER="username" +REPO="myproject" +REPORT_DIR="sprint_reports" + +# 获取时间范围 +SPRINT_NUMBER=$1 +if [ -z "$SPRINT_NUMBER" ]; then + # 计算当前是第几个 Sprint(假设每 Sprint 2 周,从项目开始计算) + PROJECT_START="2026-01-01" + CURRENT_DATE=$(date +%Y-%m-%d) + DAYS_DIFF=$(( ($(date -d "$CURRENT_DATE" +%s) - $(date -d "$PROJECT_START" +%s)) / 86400 )) + SPRINT_NUMBER=$((DAYS_DIFF / 14 + 1)) +fi + +SPRINT_START=$(date -d "$((SPRINT_NUMBER - 1)) weeks ago" +%Y-%m-%d) +SPRINT_END=$(date -d "$((SPRINT_NUMBER - 1)) weeks ago +14 days" +%Y-%m-%d) + +echo "生成 Sprint $SPRINT_NUMBER 报告 ($SPRINT_START - $SPRINT_END)" + +# 创建报告目录 +mkdir -p "$REPORT_DIR" + +# 1. 获取 Issue 数据 +echo "收集 Issue 数据..." +ISSUE_DATA=$(gitlink-cli issue +list --owner $OWNER --repo $REPO --format json) + +CLOSED_ISSUES=$(echo "$ISSUE_DATA" | jq -r ".data.issues[] | + select(.closed_at >= \"$SPRINT_START\" and .closed_at <= \"$SPRINT_END\")") + +NEW_ISSUES=$(echo "$ISSUE_DATA" | jq -r ".data.issues[] | + select(.created_at >= \"$SPRINT_START\" and .created_at <= \"$SPRINT_END\")") + +CLOSED_COUNT=$(echo "$CLOSED_ISSUES" | jq -s 'length') +NEW_COUNT=$(echo "$NEW_ISSUES" | jq -s 'length') + +# 2. 获取 PR 数据 +echo "收集 PR 数据..." +PR_DATA=$(gitlink-cli pr +list --owner $OWNER --repo $REPO --format json) + +MERGED_PRS=$(echo "$PR_DATA" | jq -r ".data.prs[] | + select(.merged_at >= \"$SPRINT_START\" and .merged_at <= \"$SPRINT_END\")") + +NEW_PRS=$(echo "$PR_DATA" | jq -r ".data.prs[] | + select(.created_at >= \"$SPRINT_START\" and .created_at <= \"$SPRINT_END\")") + +MERGED_COUNT=$(echo "$MERGED_PRS" | jq -s 'length') +NEW_PR_COUNT=$(echo "$NEW_PRS" | jq -s 'length') + +# 3. 获取提交数据 +echo "收集提交数据..." +COMMITS=$(gitlink-cli api GET "/$OWNER/$REPO/commits" --format json | \ + jq -r ".data[] | + select(.committed_date >= \"$SPRINT_START\" and .committed_date <= \"$SPRINT_END\")") + +COMMIT_COUNT=$(echo "$COMMITS" | jq -s 'length') + +# 4. 分析团队贡献 +echo "分析团队贡献..." +CONTRIBUTORS=$(echo "$COMMITS" | jq -r '.author | group_by(.) | + map({developer: .[0], count: length}) | + sort_by(.count) | reverse') + +# 5. 分析 Issue 标签 +echo "分析 Issue 分类..." +FEATURES=$(echo "$CLOSED_ISSUES" | jq -r '[.[] | select(.issue_tags[]?.name == "feature")] | length') +BUGS=$(echo "$CLOSED_ISSUES" | jq -r '[.[] | select(.issue_tags[]?.name == "bug")] | length') +ENHANCEMENTS=$(echo "$CLOSED_ISSUES" | jq -r '[.[] | select(.issue_tags[]?.name == "enhancement")] | length') + +# 6. 计算完成率 +COMPLETION_RATE=0 +if [ $((CLOSED_COUNT + NEW_COUNT)) -gt 0 ]; then + COMPLETION_RATE=$((CLOSED_COUNT * 100 / (CLOSED_COUNT + NEW_COUNT))) +fi + +# 7. 生成报告 +echo "生成 Sprint 报告..." +REPORT_FILE="$REPORT_DIR/sprint_${SPRINT_NUMBER}_$(date +%Y%m%d).md" + +cat > "$REPORT_FILE" << EOF +# 📊 Sprint $SPRINT_NUMBER 进度报告 + +**时间范围**: $SPRINT_START 至 $SPRINT_END +**生成时间**: $(date +%Y-%m-%d) +**报告周期**: 14 天 + +## 🎯 Sprint 目标达成情况 + +### 总体概览 +| 指标 | 数量 | 说明 | +|------|------|------| +| ✅ 完成 Issue | $CLOSED_COUNT 个 | Sprint 期间关闭的 Issue | +| 🆕 新增 Issue | $NEW_COUNT 个 | Sprint 期间新建的 Issue | +| 🔀 合并 PR | $MERGED_COUNT 个 | Sprint 期间合并的 PR | +| 🆕 新建 PR | $NEW_PR_COUNT 个 | Sprint 期间新建的 PR | +| 💻 代码提交 | $COMMIT_COUNT 次 | Sprint 期间的提交次数 | + +### 完成率分析 +- **Issue 完成率**: ${COMPLETION_RATE}% +- **PR 合并率**: $((MERGED_COUNT * 100 / (MERGED_COUNT + NEW_PR_COUNT)))% +- **平均日提交**: $((COMMIT_COUNT / 14)) 次/天 + +## 📊 Issue 分类统计 + +| 分类 | 数量 | 占比 | +|------|------|------| +| 新功能 | $FEATURES 个 | $((FEATURES * 100 / CLOSED_COUNT))% | +| Bug 修复 | $BUGS 个 | $((BUGS * 100 / CLOSED_COUNT))% | +| 功能改进 | $ENHANCEMENTS 个 | $((ENHANCEMENTS * 100 / CLOSED_COUNT))% | + +## 👥 团队贡献统计 + +$(echo "$CONTRIBUTORS" | jq -r '.[] | | + "| **\(.developer)** | \(.count) 次提交 | $((.count * 100 / COMMIT_COUNT))% |"') + +## 🎉 主要成就 + +### 完成的 Issue +$(echo "$CLOSED_ISSUES" | jq -r '"- [\(.subject)](#issue/\(.id)) - \(.assigned_to // "未分配")"') + +### 合并的 PR +$(echo "$MERGED_PRS" | jq -r '"- [\(.title)](#pr/\(.id)) - \(.author.login)"') + +## 🔄 进行中的工作 + +### 未完成的 Issue +$(echo "$NEW_ISSUES" | jq -r '"- [\(.subject)](#issue/\(.id)) - \(.assigned_to // "未分配")"') + +### 待合并的 PR +$(echo "$NEW_PRS" | jq -r '"- [\(.title)](#pr/\(.id)) - \(.author.login)"') + +## 📈 趋势分析 + +### 代码活动趋势 +- 本 Sprint 共有 **$COMMIT_COUNT 次提交**,日均 **$((COMMIT_COUNT / 14)) 次** +- 比上 Sprint $([[ $SPRINT_NUMBER -gt 1 ]] && echo "增长了/减少了 XX%" || echo "为基线数据") + +### 团队效率分析 +- 团队成员积极参与,贡献分布较为均匀 +- 代码审查及时,PR 合并率良好 + +## ⚠️ 风险和问题 + +### 当前风险 +- 高优先级 Issue 积压:$(echo "$NEW_ISSUES" | jq '[.[] | select(.priority_id == 1)] | length') 个 +- 长期未解决的 Issue:$(echo "$NEW_ISSUES" | jq '[.[] | select(.created_at < "'$SPRINT_START'")] | length') 个 + +### 技术债务 +- 代码复用待改进 +- 测试覆盖率需要提升 +- 文档需要更新 + +## 📋 下期计划 + +### 主要目标 +1. 继续完成当前进行中的 Issue +2. 优化代码质量和测试覆盖 +3. 更新项目文档 +4. 技术债务清理 + +### 资源规划 +- 开发资源:保持当前团队配置 +- 时间规划:重点关注高优先级 Issue +- 技术重点:性能优化和代码重构 + +## 🙏 致谢 + +感谢所有团队成员在 Sprint $SPRINT_NUMBER 期间的辛勤工作! + +--- +**报告生成**: $(date +%Y-%m-%d %H:%M:%S) +**数据来源**: GitLink API +**报告类型**: 自动化 Sprint 报告 +EOF + +echo "✅ Sprint 报告已生成: $REPORT_FILE" + +# 8. 可选:创建 Issue 讨论报告 +echo "创建 Sprint 回顾 Issue..." +REVIEW_ISSUE_BODY="## Sprint $SPRINT_NUMBER 回顾 + +### Sprint 报告 +完整的 Sprint 报告请查看: [Sprint $SPRINT_NUMBER 报告](../../blob/master/$REPORT_FILE) + +### 讨论要点 +1. 目标达成情况分析 +2. 团队协作效果评估 +3. 流程改进建议 +4. 下 Sprint 目标规划 + +### 问题跟踪 +- 需要解决的问题 +- 改进建议 +- 风险识别" + +gitlink-cli issue +create \ + --owner $OWNER \ + --repo $REPO \ + --title "Sprint $SPRINT_NUMBER 回顾" \ + --body "$REVIEW_ISSUE_BODY" + +echo "Sprint 报告工作流完成!" +``` + +## AI Agent 集成示例 + +Claude Code 等 AI Agent 可以深度集成此工作流: + +```python +# AI Agent 生成 Sprint 报告 +def generate_sprint_report(owner, repo, sprint_number): + """AI Agent 自动生成 Sprint 进度报告""" + + # 1. 确定 Sprint 时间范围 + sprint_start, sprint_end = calculate_sprint_period(sprint_number) + + # 2. 收集数据 + sprint_data = collect_sprint_data(owner, repo, sprint_start, sprint_end) + + # 3. AI 分析数据 + analysis = analyze_sprint_performance(sprint_data) + + # 4. 生成报告 + report = generate_report_content(sprint_number, sprint_data, analysis) + + # 5. 保存报告并创建回顾 Issue + save_report(report, sprint_number) + create_review_issue(owner, repo, sprint_number, report) + + return report + +def collect_sprint_data(owner, repo, start_date, end_date): + """收集 Sprint 数据""" + + return { + 'issues': { + 'closed': get_closed_issues(owner, repo, start_date, end_date), + 'new': get_new_issues(owner, repo, start_date, end_date) + }, + 'pull_requests': { + 'merged': get_merged_prs(owner, repo, start_date, end_date), + 'new': get_new_prs(owner, repo, start_date, end_date) + }, + 'commits': get_commits(owner, repo, start_date, end_date), + 'contributors': get_contributor_stats(owner, repo, start_date, end_date) + } + +def analyze_sprint_performance(data): + """AI 分析 Sprint 表现""" + + analysis = { + 'velocity': calculate_velocity(data), + 'trends': identify_trends(data), + 'risks': identify_risks(data), + 'recommendations': generate_recommendations(data) + } + + # AI 分析完成率趋势 + completion_rate = len(data['issues']['closed']) / ( + len(data['issues']['closed']) + len(data['issues']['new']) + ) * 100 + + if completion_rate > 80: + analysis['performance'] = 'excellent' + elif completion_rate > 60: + analysis['performance'] = 'good' + else: + analysis['performance'] = 'needs_improvement' + + return analysis + +def generate_report_content(sprint_number, data, analysis): + """AI 生成报告内容""" + + report = f"""# 📊 Sprint {sprint_number} 进度报告 + +## 🎯 目标达成情况 + +### 总体概览 +- **完成 Issue**: {len(data['issues']['closed'])} 个 +- **新增 Issue**: {len(data['issues']['new'])} 个 +- **合并 PR**: {len(data['pull_requests']['merged'])} 个 +- **代码提交**: {len(data['commits'])} 次 + +### AI 分析结果 +- **表现评级**: {analysis['performance']} +- **团队速度**: {analysis['velocity']} story points +- **主要趋势**: {analysis['trends']} + +## 🎉 主要成就 +""" + + # 添加主要成就 + for issue in data['issues']['closed'][:5]: + report += f"- {issue['subject']} (#{issue['id']})\n" + + # 添加风险和建议 + report += "\n## ⚠️ 风险识别\n" + for risk in analysis['risks']: + report += f"- {risk}\n" + + report += "\n## 💡 改进建议\n" + for recommendation in analysis['recommendations']: + report += f"- {recommendation}\n" + + return report +``` + +## 报告模板 + +### 标准报告结构 + +```markdown +# Sprint {NUMBER} 进度报告 + +## 元信息 +- **时间范围**: {START_DATE} - {END_DATE} +- **Sprint 周期**: 14 天 +- **生成时间**: {TIMESTAMP} + +## 目标达成 +### 完成情况 +- 计划完成: X 个 Issue +- 实际完成: Y 个 Issue +- 完成率: Z% + +## 工作统计 +### Issue 统计 +- 关闭: N 个 +- 新建: M 个 +- 分类统计 + +### PR 统计 +- 合并: N 个 +- 新建: M 个 +- 合并率: X% + +### 提交统计 +- 总提交: N 次 +- 日均: X 次 + +## 团队贡献 +- 成员A: N 次贡献 +- 成员B: M 次贡献 + +## 风险和问题 +- 当前风险 +- 技术债务 +- 阻塞问题 + +## 下期计划 +- 主要目标 +- 资源规划 +- 时间安排 +``` + +## 数据分析维度 + +### 1. 速度分析 +- Story Points 完成 +- Issue 完成数量 +- PR 合并数量 + +### 2. 质量分析 +- Bug 修复比例 +- 代码审查通过率 +- 测试覆盖率变化 + +### 3. 效率分析 +- 平均 Issue 解决时间 +- 平均 PR 合并时间 +- 代码审查周期 + +### 4. 团队分析 +- 成员贡献分布 +- 协作效率 +- 沟通成本 + +## 最佳实践 + +1. **定期生成**:每个 Sprint 结束后及时生成报告 +2. **数据准确**:确保收集的数据完整准确 +3. **客观分析**:基于数据进行客观分析 +4. **行动导向**:报告应包含可执行的改进建议 +5. **团队参与**:让团队成员参与报告讨论 + +## 质量保证 + +报告质量检查: + +- [ ] 数据完整性检查 +- [ ] 计算准确性验证 +- [ ] 格式一致性检查 +- [ ] 语法和拼写检查 +- [ ] 链接有效性验证 +- [ ] 客观性审查 + +## References + +- [workflow-issue-triage](workflow-issue-triage.md) — Issue 分类工作流 +- [workflow-pr-review](workflow-pr-review.md) — PR 审查工作流 +- [gitlink-workflow](../SKILL.md) — 工作流总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [pm-sprint](../../gitlink-pm/references/pm-sprint.md) — Sprint 管理 +- [pm-report](../../gitlink-pm/references/pm-report.md) — 周报生成