修改skill #259

Open
nudt_zk wants to merge 97 commits from zzx-coder/gitlink-cli:zk_branch into master
225 changed files with 60240 additions and 426 deletions

View File

@ -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"'
needs:
- deploy_on_server_0
- ref: end
name: 结束
task: end
needs:
- verify_deployment_0

229
README.md
View File

@ -27,14 +27,19 @@ 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 |
| 🔀 PR | Create, merge, review pull requests, view changed files |
| 🐛 Issue | Create, update, close, comment on issues, 6 batch operations (close/status/priority/assignee/label/create), metadata queries, comment management |
| 📖 Wiki | View, create, update, delete Wiki pages |
| 🔀 PR | Create, merge, review pull requests, reopen, update, view commits/versions/diffs, comment management |
| 📁 File | Browse directories, read files, create/update/delete files, batch commit, view commit history and diffs |
| 🏁 Milestone | List, create, view, update, delete milestones, change status |
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
| 🏷️ Release | Create, view, delete releases |
| 🔗 Webhook | Create, view, update, delete, test webhooks, configure automation triggers |
| 🏢 Org | Manage organizations, members, teams |
| 🔧 CI | View builds, logs, CI/CD operations |
| 🔍 Search | Search repositories, users |
| 👤 User | View user profiles and info |
| 📋 Board | View kanban board, filter issues by status/assignee/priority, move tasks, assign people, workload analytics |
| 📋 PM | Sprint management, kanban boards, weekly reports |
| 🤖 Workflow | AI-powered issue triage, PR review, release notes |
@ -42,7 +47,6 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
### Requirements
- Node.js 14+ (`npm`/`npx`) — for npm installation
- Supported platforms: macOS, Linux, Windows (x64/arm64)
- Go 1.26+ — only required for building from source
@ -52,10 +56,15 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
#### Install
**From npm (recommended):**
**一键安装(推荐) — 无需 npm、无需 Go:**
```bash
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
```
**From npm:**
```bash
# One command: installs CLI binary + all 12 AI Agent Skills
npm install -g @gitlink-ai/cli
```
@ -125,6 +134,25 @@ export GITLINK_TOKEN="your-private-token"
gitlink-cli user +me
```
## Installation & Uninstallation
**Detailed install guide**: [doc/INSTALL.md](./doc/INSTALL.md)
**Detailed uninstall guide**: [doc/UNINSTALL.md](./doc/UNINSTALL.md)
### Quick Install
**Linux/macOS**: `curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash`
**Windows PowerShell**: `powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1`
**npm**: `npm install -g @gitlink-ai/cli`
### Quick Uninstall
**Linux/macOS**: `bash uninstall.sh`
**Windows PowerShell**: `.\uninstall.ps1`
**npm**: `npm uninstall -g @gitlink-ai/cli`
> 💡 **Tip**: See detailed guides: [doc/INSTALL.md](./doc/INSTALL.md) | [doc/UNINSTALL.md](./doc/UNINSTALL.md)
## Usage Examples
### Repository Operations
@ -166,6 +194,34 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
# Add a comment
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
# Batch delete issues
gitlink-cli issue +batch-destroy --owner Gitlink --repo forgeplus --numbers 100,101,102
```
### Issue Metadata & Comments
```bash
# List available issue statuses
gitlink-cli issue +statuses
# List issue authors
gitlink-cli issue +authors --keyword zhang
# List issue assignees
gitlink-cli issue +assigners
# List issue priorities
gitlink-cli issue +priorities
# Edit a comment
gitlink-cli issue +comment-edit --number 42 --comment-id 100 --body "updated comment"
# Delete a comment
gitlink-cli issue +comment-delete --number 42 --comment-id 100
# List replies to a comment
gitlink-cli issue +replies --number 42 --comment-id 100
```
### Pull Requests
@ -190,6 +246,93 @@ gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
```
### File & Code Operations
```bash
# List root directory
gitlink-cli file +ls --owner Gitlink --repo forgeplus
# Browse subdirectory
gitlink-cli file +tree --path src/
# Read file content
gitlink-cli file +read --path README.md
# Read README
gitlink-cli file +readme
# Search files by name
gitlink-cli file +search --q "test"
# Create a file
gitlink-cli file +create --path docs/new.md --content "# New Doc" --branch master --message "add doc"
# Update a file (auto-fetches sha)
gitlink-cli file +update --path README.md --content "updated" --branch master --message "update readme"
# Delete a file (auto-fetches sha)
gitlink-cli file +delete --path old.txt --branch master
# Batch commit multiple files
gitlink-cli file +batch --branch master --message "batch update" --files '[{"action_type":"create","file_path":"a.txt","content":"hello"}]'
# View commit history
gitlink-cli file +commits
# View commit diff
gitlink-cli file +diff --sha abc1234
```
### Milestone Management
```bash
# List milestones
gitlink-cli milestone +list --owner Gitlink --repo forgeplus
# Create a milestone
gitlink-cli milestone +create --name "v1.0" --description "First release" --date 2026-12-31
# View milestone details
gitlink-cli milestone +view --id 1
# Update a milestone
gitlink-cli milestone +update --id 1 --name "v1.0-rc1"
# Close a milestone
gitlink-cli milestone +status --id 1 --status closed
# Delete a milestone
gitlink-cli milestone +delete --id 1
```
### PR Enhanced Operations
```bash
# Reopen a closed PR
gitlink-cli pr +reopen --id 42
# Update PR title/description
gitlink-cli pr +update --id 42 --title "New title"
# List commits in a PR
gitlink-cli pr +commits --id 42
# List PR versions
gitlink-cli pr +versions --id 42
# View diff of a specific PR version
gitlink-cli pr +vdiff --id 42 --version 5
# List changed files (v1 API with pagination)
gitlink-cli pr +filesv1 --id 42
# Edit a PR review comment
gitlink-cli pr +comment-edit --id 42 --comment-id 100 --body "updated" --state resolved
# Delete a PR review comment
gitlink-cli pr +comment-delete --id 42 --comment-id 100
```
### Branch Management
```bash
@ -209,6 +352,56 @@ gitlink-cli branch +protect --name main
gitlink-cli branch +unprotect --name main
```
### Board (Kanban)
```bash
# View kanban board layout
gitlink-cli board +view --owner Gitlink --repo forgeplus
# List status columns with issue counts
gitlink-cli board +columns --owner Gitlink --repo forgeplus
# Filter issues by status and assignee
gitlink-cli board +issues --owner Gitlink --repo forgeplus --status in-progress --assignee zhangsan
# Move an issue to a different status
gitlink-cli board +move --owner Gitlink --repo forgeplus --number 42 --status resolved
# Assign an issue to someone
gitlink-cli board +assign --owner Gitlink --repo forgeplus --number 42 --assignee zhangsan
# View board analytics and statistics
gitlink-cli board +stats --owner Gitlink --repo forgeplus
```
### Webhook Management
```bash
# 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 +428,28 @@ gitlink-cli ci +log --owner Gitlink --repo forgeplus -i <build_id>
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
```
### 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
@ -333,12 +548,16 @@ gitlink-cli/
│ ├── repo/ # Repository shortcuts
│ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts
│ ├── board/ # Board (kanban) shortcuts
│ ├── branch/ # Branch shortcuts
│ ├── release/ # Release shortcuts
│ ├── org/ # Organization shortcuts
│ ├── ci/ # CI shortcuts
│ ├── search/ # Search shortcuts
│ ├── user/ # User shortcuts
│ ├── wiki/ # Wiki shortcuts
│ ├── file/ # File & code shortcuts
│ ├── milestone/ # Milestone shortcuts
│ └── register.go # Registration entry point
├── skills/ # AI Agent Skills
│ ├── README.md # Skills guide

View File

@ -27,14 +27,19 @@
| 分类 | 能力 |
|------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息 |
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
| 🐛 Issue | 创建、更新、关闭、评论 Issue6 个批量操作,元数据查询,评论管理 |
| 📖 Wiki | 查看、创建、更新、删除 Wiki 页面 |
| 🔀 PR | 创建、合并、Review Pull Request重新打开、更新查看提交/版本/Diff评论管理 |
| 📁 文件 | 浏览目录、读取文件、创建/更新/删除文件、批量提交、查看提交历史和 Diff |
| 🏁 里程碑 | 列出、创建、查看、更新、删除里程碑,变更状态 |
| 🌿 分支 | 创建、删除、保护分支 |
| 🏷️ 发布 | 创建、查看、删除 Release |
| 🔗 Webhook | 创建、查看、更新、删除、测试 Webhook配置自动化触发器 |
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| 🔍 搜索 | 搜索仓库、用户 |
| 👤 用户 | 查看用户资料和信息 |
| 📋 看板 | 查看看板、按状态/指派人/优先级筛选、移动任务、指派人员、工作负载分析 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
@ -42,7 +47,6 @@
### 前置条件
- Node.js 14+`npm`/`npx`)— 用于 npm 安装
- 支持平台macOS、Linux、Windowsx64/arm64
- Go 1.26+ — 仅从源码构建时需要
@ -54,17 +58,16 @@
选择以下**任一**方式:
**方式 1 — 从 npm 安装(推荐):**
**方式 1 — 一键安装(推荐,无需 npm、无需 Go**
```bash
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
```
**方式 2 — 从 npm 安装:**
```bash
# 安装 CLI
npm install -g @gitlink-ai/cli
# 安装 CLI Skill必须全平台通用
gitlink-cli-install-skills
# 也可使用 npx 安装 Skill
npx skills add ccfos/gitlink-cli/skills -y -g
```
**方式 2 — 从源码构建:**
@ -137,6 +140,25 @@ export GITLINK_TOKEN="your-private-token"
gitlink-cli user +me
```
## 安装与卸载
**详细安装指南**: [doc/INSTALL.md](./doc/INSTALL.md)
**详细卸载指南**: [doc/UNINSTALL.md](./doc/UNINSTALL.md)
### 快速安装
**Linux/macOS**: `curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash`
**Windows PowerShell**: `powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1`
**npm**: `npm install -g @gitlink-ai/cli`
### 快速卸载
**Linux/macOS**: `bash uninstall.sh`
**Windows PowerShell**: `.\uninstall.ps1`
**npm**: `npm uninstall -g @gitlink-ai/cli`
> 💡 **提示**: 查看详细指南:[doc/INSTALL.md](./doc/INSTALL.md) | [doc/UNINSTALL.md](./doc/UNINSTALL.md)
## 使用示例
### 仓库操作
@ -153,6 +175,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 +206,53 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
# 添加评论
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
# 批量删除 Issue
gitlink-cli issue +batch-destroy --owner Gitlink --repo forgeplus --numbers 100,101,102
# 批量修改状态
gitlink-cli issue +batch-status --state resolved --numbers 1,2,3 --owner Gitlink --repo forgeplus
# 批量修改优先级
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 "issue1issue2issue3"
# 批量关闭
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 1,2,3
```
### Issue 元数据与评论
```bash
# 查看可用的 Issue 状态列表
gitlink-cli issue +statuses
# 查看 Issue 发布人列表
gitlink-cli issue +authors --keyword zhang
# 查看 Issue 负责人列表
gitlink-cli issue +assigners
# 查看 Issue 优先级列表
gitlink-cli issue +priorities
# 编辑评论
gitlink-cli issue +comment-edit --number 42 --comment-id 100 --body "修正后的评论"
# 删除评论
gitlink-cli issue +comment-delete --number 42 --comment-id 100
# 查看评论的回复列表
gitlink-cli issue +replies --number 42 --comment-id 100
```
### Pull Request
@ -202,6 +277,143 @@ gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
```
### PR 增强操作
```bash
# 重新打开已关闭的 PR
gitlink-cli pr +reopen --id 42
# 更新 PR 标题/描述
gitlink-cli pr +update --id 42 --title "新标题"
# 查看 PR 中的提交列表
gitlink-cli pr +commits --id 42
# 查看 PR 版本历史
gitlink-cli pr +versions --id 42
# 查看 PR 某版本的 Diff
gitlink-cli pr +vdiff --id 42 --version 5
# 查看 PR 变更文件列表v1 API支持分页
gitlink-cli pr +filesv1 --id 42
# 编辑 PR 审查评论
gitlink-cli pr +comment-edit --id 42 --comment-id 100 --body "更新内容" --state resolved
# 删除 PR 审查评论
gitlink-cli pr +comment-delete --id 42 --comment-id 100
```
### 文件与代码操作
```bash
# 列出根目录文件
gitlink-cli file +ls --owner Gitlink --repo forgeplus
# 浏览子目录
gitlink-cli file +tree --path src/
# 读取文件内容
gitlink-cli file +read --path README.md
# 读取 README
gitlink-cli file +readme
# 按文件名搜索
gitlink-cli file +search --q "test"
# 创建文件
gitlink-cli file +create --path docs/new.md --content "# 新文档" --branch master --message "add doc"
# 更新文件(自动获取 sha
gitlink-cli file +update --path README.md --content "更新内容" --branch master --message "update readme"
# 删除文件(自动获取 sha
gitlink-cli file +delete --path old.txt --branch master
# 批量提交多个文件
gitlink-cli file +batch --branch master --message "batch update" --files '[{"action_type":"create","file_path":"a.txt","content":"hello"}]'
# 查看提交历史
gitlink-cli file +commits
# 查看提交 Diff
gitlink-cli file +diff --sha abc1234
```
### 里程碑管理
```bash
# 列出里程碑
gitlink-cli milestone +list --owner Gitlink --repo forgeplus
# 创建里程碑
gitlink-cli milestone +create --name "v1.0" --description "首个正式版" --date 2026-12-31
# 查看里程碑详情
gitlink-cli milestone +view --id 1
# 更新里程碑
gitlink-cli milestone +update --id 1 --name "v1.0-rc1"
# 关闭里程碑
gitlink-cli milestone +status --id 1 --status closed
# 删除里程碑
gitlink-cli milestone +delete --id 1
```
### 看板操作
```bash
# 查看看板布局
gitlink-cli board +view --owner Gitlink --repo forgeplus
# 列出各状态列及 Issue 数量
gitlink-cli board +columns --owner Gitlink --repo forgeplus
# 按状态和指派人筛选任务
gitlink-cli board +issues --owner Gitlink --repo forgeplus --status in-progress --assignee zhangsan
# 移动任务状态
gitlink-cli board +move --owner Gitlink --repo forgeplus --number 42 --status resolved
# 指派任务给用户
gitlink-cli board +assign --owner Gitlink --repo forgeplus --number 42 --assignee zhangsan
# 查看看板统计分析
gitlink-cli board +stats --owner Gitlink --repo forgeplus
```
### Webhook 管理
```bash
# 列出所有 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 +427,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 <version_id>
```
### 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
@ -312,12 +546,16 @@ gitlink-cli/
│ ├── repo/ # 仓库 shortcuts
│ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts
│ ├── board/ # 看板 shortcuts
│ ├── branch/ # 分支 shortcuts
│ ├── release/ # Release shortcuts
│ ├── org/ # 组织 shortcuts
│ ├── ci/ # CI shortcuts
│ ├── search/ # 搜索 shortcuts
│ ├── user/ # 用户 shortcuts
│ ├── wiki/ # Wiki shortcuts
│ ├── file/ # 文件与代码 shortcuts
│ ├── milestone/ # 里程碑 shortcuts
│ └── register.go # 注册入口
├── skills/ # AI Agent Skills
│ ├── README.md # Skills 使用指南

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
"github.com/spf13/cobra"
@ -20,12 +21,14 @@ func NewAPICmd() *cobra.Command {
Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`,
Example: ` gitlink-cli api GET /users/me
gitlink-cli api GET /projects --query 'page=1&limit=10'
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`,
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
gitlink-cli api POST /:owner/:repo/issues --body-file ./issue.json`,
Args: cobra.ExactArgs(2),
RunE: runAPI,
}
apiCmd.Flags().String("body", "", "Request body (JSON string)")
apiCmd.Flags().String("body-file", "", "Read JSON body from a file (avoids shell quoting issues)")
apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)")
apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)")
@ -48,9 +51,20 @@ func runAPI(c *cobra.Command, args []string) error {
var body interface{}
bodyStr, _ := c.Flags().GetString("body")
bodyFile, _ := c.Flags().GetString("body-file")
if bodyStr != "" && bodyFile != "" {
return printAPIError(400, "cannot use both --body and --body-file", "请只用其中一个:--body 用于内联 JSON--body-file 用于从文件读取")
}
if bodyFile != "" {
raw, err := os.ReadFile(bodyFile)
if err != nil {
return printAPIError(400, fmt.Sprintf("read --body-file failed: %v", err), "检查 --body-file 路径是否正确、文件是否存在且有读权限")
}
bodyStr = string(raw)
}
if bodyStr != "" {
if err := json.Unmarshal([]byte(bodyStr), &body); err != nil {
return fmt.Errorf("invalid JSON body: %w", err)
return printAPIError(400, fmt.Sprintf("invalid JSON body: %v", err), "确认 body 是合法 JSONPowerShell 调用 .exe 时会剥离内嵌双引号,推荐改用 --body-file 从文件读取")
}
}
@ -60,22 +74,34 @@ func runAPI(c *cobra.Command, args []string) error {
var err error
query, err = url.ParseQuery(queryStr)
if err != nil {
return fmt.Errorf("invalid query string: %w", err)
return printAPIError(400, fmt.Sprintf("invalid query string: %v", err), "query 应为 key=value&key2=value2 形式,注意值需要 URL 编码")
}
}
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, "")
return output.Print(errEnv, resolveFormat())
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion)
_ = output.Print(errEnv, resolveFormat())
return cmdutil.ErrSilent
}
return err
// 网络错误 / DNS 失败 / 超时等非 API 错误,也按 envelope 输出保持一致
return printAPIError(503, fmt.Sprintf("API 请求失败 [%s %s]: %v", method, path, err), "检查网络连接、GitLink 主机可达性、token 是否有效")
}
return output.Print(env, resolveFormat())
}
// printAPIError 把本地校验/IO/网络错误统一按标准 envelope 输出到 stdout
// 并返回 cmdutil.ErrSilent 让 cmd.Execute 跳过 stderr 重复打印,仅保留非零退出码。
// 设计意图:让 `api` 命令的所有错误(包括 JSON 解析、参数冲突、读文件失败、APIError、
// 网络错误)输出格式与 shortcut 一致,便于 `--format json` + jq 自动化解析。
func printAPIError(code int, message, suggestion string) error {
env := output.ErrorEnvelope(code, message, suggestion)
_ = output.Print(env, resolveFormat())
return cmdutil.ErrSilent
}
func resolveFormat() string {
f := cmdutil.Format
if f == "" {

View File

@ -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 {

View File

@ -1,9 +1,25 @@
package cmdutil
import "errors"
// Global flags shared across all commands.
var (
Owner string
Repo string
Format string
Debug bool
Owner string
Repo string
Format string
Debug bool
NoTruncate bool // disable 60-char truncation in table output
Columns string // comma-separated column names for table output
NoColor bool // disable ANSI color output
)
// ErrSilent 是 sentinel error表示错误已经被上层处理过如已按 envelope 格式
// 输出到 stdout调用方cmd.Execute只需返回非零退出码不要再把消息
// 打印到 stderr。
//
// 使用方式:
//
// if TryPrintError(err, format) {
// return cmdutil.ErrSilent
// }
var ErrSilent = errors.New("silent error: already reported via envelope")

View File

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

1262
demo/index.html Normal file

File diff suppressed because it is too large Load Diff

352
demo/server.py Normal file
View File

@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""
GitLink CLI Demo Server 子任务一 交互式展示
启动后访问 http://127.0.0.1:8765
"""
import http.server
import json
import subprocess
import os
import sys
import tempfile
import threading
import shutil
import uuid
from pathlib import Path
PORT = 8765
WORKING_DIR = r"D:\code\SE\Evolution_and_Maintenance_of_SE\Mission2\gitlink-cli"
HTML_DIR = Path(__file__).parent
# ── 定位 Claude Code CLI ─────────────────────────
def _find_claude() -> str | None:
"""查找 claude 可执行文件,优先返回 .exe 以避开 .cmd 的编码问题"""
# 1) 直接找 claude.exenpm 全局安装路径)
candidates = [
# npm global on Windows
Path(os.environ.get("APPDATA", "")) / r"npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe",
# 直接 which可能返回 .cmd
shutil.which("claude"),
shutil.which("claude.exe"),
]
for p in candidates:
if p and Path(str(p)).is_file():
return str(p)
# 2) 尝试 shutil.which 返回的 .cmd 对应的 .exe
cmd = shutil.which("claude.cmd")
if cmd:
exe = Path(cmd).with_suffix(".exe")
if exe.is_file():
return str(exe)
return None
CLAUDE_EXE = _find_claude()
# 全局:当前正在运行的进程(用于取消)
_proc_lock = threading.Lock()
_current_proc = None
class DemoHandler(http.server.BaseHTTPRequestHandler):
"""HTTP 请求处理器:静态文件 + API 端点"""
# ── 日志 ────────────────────────────────────────────
def log_message(self, fmt, *args):
print(f"[{self.log_date_time_string()}] {args[0]}")
# ── GET ─────────────────────────────────────────────
def do_GET(self):
path = self.path.split("?")[0]
if path in ("/", "/index.html"):
self._serve_file("index.html", "text/html; charset=utf-8")
else:
self.send_error(404)
# ── POST ────────────────────────────────────────────
def do_POST(self):
if self.path == "/api/exec":
self._handle_exec()
elif self.path == "/api/claude":
self._handle_claude()
elif self.path == "/api/cancel":
self._handle_cancel()
else:
self.send_error(404)
# ── OPTIONS (CORS preflight) ────────────────────────
def do_OPTIONS(self):
self._send_cors_headers()
self.send_response(204)
self.end_headers()
# ── 取消当前命令 ────────────────────────────────────
def _handle_cancel(self):
global _current_proc
with _proc_lock:
proc = _current_proc
if proc is None or proc.poll() is not None:
self._send_json({"ok": True, "message": "没有正在运行的命令"})
return
try:
proc.kill()
self._send_json({"ok": True, "message": "已发送终止信号"})
except Exception as e:
self._send_json({"ok": False, "error": str(e)})
# ── Claude Code 执行 ────────────────────────────────
def _handle_claude(self):
global _current_proc
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
data = json.loads(body)
except json.JSONDecodeError:
self._send_json({"ok": False, "error": "Invalid JSON body"})
return
prompt = data.get("prompt", "").strip()
if not prompt:
self._send_json({"ok": False, "error": "Empty prompt", "session_id": data.get("session_id", "")})
return
timeout = min(data.get("timeout", 300), 600) # max 10 min for Claude
if not CLAUDE_EXE:
self._send_json({
"ok": False, "stdout": "",
"stderr": "❌ 找不到 Claude Code CLIclaude.exe。请确认已通过 npm 安装npm install -g @anthropic-ai/claude-code",
"exit_code": -1,
"session_id": "",
})
return
# ── 会话管理:支持多轮对话 ──
session_id = data.get("session_id", "").strip()
new_session = data.get("new_session", False)
if new_session or not session_id:
# 新会话:生成 UUID
session_id = str(uuid.uuid4())
args = [CLAUDE_EXE, "-p", prompt, "--session-id", session_id, "--permission-mode", "bypassPermissions"]
else:
# 继续已有会话
args = [CLAUDE_EXE, "-p", prompt, "--resume", session_id, "--permission-mode", "bypassPermissions"]
proc = None
try:
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
cwd=WORKING_DIR,
env={**os.environ, "NO_COLOR": "1"},
)
# 注册为当前进程(允许取消)
with _proc_lock:
_current_proc = proc
try:
stdout, stderr = proc.communicate(timeout=timeout)
exit_code = proc.returncode
killed = False
except subprocess.TimeoutExpired:
proc.kill()
stdout, stderr = proc.communicate()
exit_code = -1
killed = True
if killed:
self._send_json({
"ok": False,
"stdout": stdout or "",
"stderr": f"❌ Claude Code 执行超时(超过 {timeout} 秒)已被终止",
"exit_code": -1,
"session_id": session_id,
})
else:
self._send_json({
"ok": exit_code == 0,
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"session_id": session_id,
})
except FileNotFoundError:
self._send_json({
"ok": False, "stdout": "",
"stderr": "❌ 找不到 Claude Code CLIclaude.exe。请确认已通过 npm 安装npm install -g @anthropic-ai/claude-code",
"exit_code": -1,
"session_id": session_id,
})
except Exception as e:
self._send_json({
"ok": False, "stdout": "",
"stderr": f"{e}", "exit_code": -1,
"session_id": session_id,
})
finally:
with _proc_lock:
if _current_proc is proc:
_current_proc = None
# ── 命令执行 ────────────────────────────────────────
def _handle_exec(self):
global _current_proc
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
data = json.loads(body)
except json.JSONDecodeError:
self._send_json({"ok": False, "error": "Invalid JSON body"})
return
command = data.get("command", "").strip()
if not command:
self._send_json({"ok": False, "error": "Empty command"})
return
timeout = min(data.get("timeout", 120), 300) # max 5 min
tmp = None
proc = None
try:
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".ps1", delete=False, encoding="utf-8-sig"
)
tmp.write(command)
tmp.close()
proc = subprocess.Popen(
[
"powershell",
"-ExecutionPolicy", "Bypass",
"-NoProfile",
"-File", tmp.name,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
cwd=WORKING_DIR,
env={**os.environ, "NO_COLOR": "1"},
)
# 注册为当前进程(允许取消)
with _proc_lock:
_current_proc = proc
try:
stdout, stderr = proc.communicate(timeout=timeout)
exit_code = proc.returncode
killed = False
except subprocess.TimeoutExpired:
proc.kill()
stdout, stderr = proc.communicate()
exit_code = -1
killed = True
if killed:
self._send_json({
"ok": False,
"stdout": stdout or "",
"stderr": f"❌ 命令执行超时(超过 {timeout} 秒)已被终止",
"exit_code": -1,
})
else:
self._send_json({
"ok": exit_code == 0,
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
})
except FileNotFoundError:
self._send_json({
"ok": False, "stdout": "",
"stderr": "❌ 找不到 PowerShell请确认系统已安装 PowerShell",
"exit_code": -1,
})
except Exception as e:
self._send_json({
"ok": False, "stdout": "",
"stderr": f"{e}", "exit_code": -1,
})
finally:
with _proc_lock:
if _current_proc is proc:
_current_proc = None
if tmp:
try:
os.unlink(tmp.name)
except OSError:
pass
# ── 辅助方法 ────────────────────────────────────────
def _serve_file(self, filename: str, content_type: str):
filepath = HTML_DIR / filename
try:
with open(filepath, "rb") as f:
content = f.read()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-cache")
self._send_cors_headers()
self.end_headers()
self.wfile.write(content)
except FileNotFoundError:
self.send_error(404)
def _send_json(self, data: dict):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self._send_cors_headers()
self.end_headers()
self.wfile.write(body)
def _send_cors_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def main():
if not os.path.isdir(WORKING_DIR):
print(f"⚠️ 警告:工作目录不存在 — {WORKING_DIR}")
print(" 请修改 server.py 中的 WORKING_DIR 变量")
sys.exit(1)
server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), DemoHandler)
claude_status = f"{CLAUDE_EXE}" if CLAUDE_EXE else "❌ 未找到 claude.exeSkills 功能不可用)"
print(f"""
GitLink CLI 作品展示 · 交互式平台
打开浏览器访问: http://127.0.0.1:{PORT}
工作目录: {WORKING_DIR}
Claude Code: {claude_status:<55}
Ctrl+C 停止服务器
""")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n👋 服务器已停止。")
if __name__ == "__main__":
main()

715
doc/INSTALL.md Normal file
View File

@ -0,0 +1,715 @@
# GitLink CLI 安装指南
> **更新时间**: 2026-06-04
> **适用版本**: gitlink-cli v0.2.0+
> **支持平台**: macOS、Linux、Windows (x64/arm64)
---
## 📋 安装方式
GitLink CLI 提供多种安装方式,根据您的环境和需求选择最合适的方式。
### 方式对比
| 方式 | 优点 | 缺点 | 适用场景 |
|------|------|------|----------|
| **一键安装脚本** | 无需依赖、自动检测平台、安装Skills | 需要管理员权限 | 大部分用户(推荐) |
| **npm安装** | 熟悉的包管理器、自动更新 | 需要Node.js 14+ | Node.js开发者 |
| **源码构建** | 完全可控、适合开发 | 需要Go 1.26+、编译慢 | 开发者和定制需求 |
---
## 🚀 方式1: 一键安装脚本(推荐)
> **最新改进**: 已增强环境检测、智能目录选择、下载重试等功能安装成功率提升30%+
### Linux/macOS
```bash
# 一键安装(自动选择最佳目录)
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 指定版本安装
VERSION=v0.2.0 curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 安装到用户目录无需sudo
INSTALL_DIR=$HOME/.local/bin curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 调试模式
DEBUG=true curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
```
### Windows PowerShell
```powershell
# 在线安装(推荐)
powershell -NoProfile -ExecutionPolicy Bypass -Command "iex (irm https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.ps1)"
# 本地脚本安装
.\install.ps1
# 指定版本
.\install.ps1 -Version "0.2.0"
# 指定安装目录
.\install.ps1 -InstallDir "C:\Tools\gitlink-cli"
```
### 安装内容
- ✅ 预编译二进制文件
- ✅ 完整Skills包13个AI Agent Skills
- ✅ 自动配置PATH
- ✅ 跨平台支持x64/arm64
- ✅ 环境检测(命令/磁盘/网络)
- ✅ 下载重试机制最多3次
- ✅ 智能目录选择(优先用户目录)
### 安装位置
**默认安装目录**(按优先级选择):
- Linux/macOS: `/usr/local/bin``$HOME/.local/bin`
- Windows: `$HOME\.gitlink-cli\bin`
### 自定义安装
```bash
# 指定安装目录
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
INSTALL_DIR=$HOME/.local/bin bash
# 指定版本
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
VERSION=v0.2.0 bash
```
### 🎯 新增功能
#### 1. 环境检测
安装前自动检查:
- ✅ 必需命令curl, tar
- ✅ 磁盘空间至少50MB
- ✅ 网络连接
#### 2. 智能目录选择
优先级顺序:
1. `~/.local/bin` (用户目录,优先)
2. `~/bin` (用户目录)
3. `/usr/local/bin` 系统目录需sudo
#### 3. 下载重试机制
- 最多重试3次
- 智能等待时间2s, 4s, 6s
- 详细失败提示
#### 4. 版本管理
```bash
# 列出可用版本
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- list
# 卸载
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- uninstall
# 回滚到指定版本
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- rollback v0.1.0
# 显示帮助
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- help
```
---
## 📦 方式2: npm安装
### 安装
```bash
npm install -g @gitlink-ai/cli
```
### 安装内容
- ✅ 二进制文件(自动下载对应平台版本)
- ✅ 完整Skills包
- ✅ npm命令集成
- ✅ 自动配置PATH
### npm命令
```bash
# 查看已安装版本
npm list -g @gitlink-ai/cli
# 更新到最新版本
npm update -g @gitlink-ai/cli
# 卸载
npm uninstall -g @gitlink-ai/cli
```
---
## 🔧 方式3: 源码构建
### 前置要求
- Go 1.26+
- Make或使用`go install`
### 构建步骤
```bash
# 1. 克隆仓库
git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git
cd gitlink-cli
# 2. 构建
make install
# 3. 安装Skills
npx skills add ./skills -y -g
# 4. 验证安装
gitlink-cli version
```
### Windows源码构建
```bash
# 使用go install代替make
go install .
# 安装Skills
npx skills add ./skills -y -g
```
---
## ✅ 验证安装
### 检查版本
```bash
gitlink-cli version
```
### 运行诊断
```bash
# 检查安装状态
gitlink-cli auth status
# 测试基本命令
gitlink-cli user +me
```
### 验证Skills
```bash
# 检查Skills目录
ls ~/.gitlink/skills/
# 应该看到13个Skills
# gitlink-shared gitlink-repo gitlink-issue gitlink-pr
# gitlink-release gitlink-branch gitlink-ci gitlink-org
# gitlink-search gitlink-user gitlink-wiki gitlink-webhook
# gitlink-workflow
```
---
## 🔐 配置与认证
### 初始化配置
```bash
# 交互式配置
gitlink-cli config init
```
配置文件位置:`~/.config/gitlink-cli/config.yaml`
### 登录认证
#### 方式1: 用户名密码(推荐)
```bash
gitlink-cli auth login
```
#### 方式2: 私人令牌
```bash
gitlink-cli auth login --token
```
#### 方式3: 环境变量CI/CD
```bash
export GITLINK_TOKEN="your-private-token"
```
**获取私人令牌**: GitLink网页 → 个人设置 → 私人令牌
### Token存储
- macOS: Keychain
- Linux: Secret Service (GNOME Keyring/KDE Wallet)
- Windows: Credential Manager
- Fallback: `~/.config/gitlink-cli/credentials`
---
## 🌍 平台特定说明
### macOS
#### Homebrew安装即将支持
```bash
# 添加tap
brew tap gitlink/gitlink
# 安装
brew install gitlink-cli
# 更新
brew upgrade gitlink-cli
# 卸载
brew uninstall gitlink-cli
```
#### 权限处理
```bash
# 如果遇到权限问题,安装到用户目录
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
INSTALL_DIR=$HOME/.local/bin bash
# 添加到PATH
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
```
---
### Linux
#### 包管理器安装(即将支持)
**Debian/Ubuntu**:
```bash
# 添加GitLink APT仓库即将支持
sudo apt install gitlink-cli
```
**CentOS/RHEL**:
```bash
# 添加GitLink YUM仓库即将支持
sudo yum install gitlink-cli
```
#### 权限处理
```bash
# 无sudo安装到用户目录
mkdir -p $HOME/.local/bin
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
INSTALL_DIR=$HOME/.local/bin bash
# 添加到PATH
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
```
---
### Windows
#### Scoop安装即将支持
```powershell
# 添加bucket
scoop bucket add gitlink
# 安装
scoop install gitlink-cli
# 更新
scoop update gitlink-cli
# 卸载
scoop uninstall gitlink-cli
```
#### Chocolatey安装即将支持
```powershell
# 安装
choco install gitlink-cli
# 更新
choco upgrade gitlink-cli
# 卸载
choco uninstall gitlink-cli
```
#### PATH配置
PowerShell安装完成后需要重启终端使PATH生效或手动添加
```powershell
# 临时添加到当前会话
$env:PATH += ";$env:USERPROFILE\.gitlink-cli\bin"
# 永久添加到用户PATH
[Environment]::SetEnvironmentVariable("Path", $env:PATH + ";$env:USERPROFILE\.gitlink-cli\bin", "User")
```
---
## 🔧 高级配置
### 配置文件
位置:`~/.config/gitlink-cli/config.yaml`
```yaml
# API配置
base_url: https://www.gitlink.org.cn/api
gateway_url: https://gateway.gitlink.org.cn/api
# 输出格式
default_format: table # json | table | yaml
# 编辑器配置
editor: vim # Issue/PR编辑器
pager: less # 长输出分页器
# 超时设置
timeout: 30 # 请求超时(秒)
# 调试模式
debug: false # 启用调试输出
```
### 环境变量
| 变量名 | 说明 | 示例 |
|--------|------|------|
| `GITLINK_TOKEN` | 私人令牌 | `export GITLINK_TOKEN="xxx"` |
| `GITLINK_GATEWAY_URL` | Gateway API地址 | `export GITLINK_GATEWAY_URL="https://gateway.gitlink.org.cn/api"` |
| `GITLINK_AUTO_UPDATE` | 自动检查更新 | `export GITLINK_AUTO_UPDATE=true` |
| `GITLINK_DEBUG` | 调试模式 | `export GITLINK_DEBUG=true` |
---
## 🚨 故障排除
### 问题1: 缺少必需命令
**症状**:
```
[ERROR] 缺少必需命令: curl
```
**解决方案**:
```bash
# Ubuntu/Debian
sudo apt-get install curl tar
# CentOS/RHEL
sudo yum install curl tar
# macOS
brew install curl
```
---
### 问题2: 磁盘空间不足
**症状**:
```
[ERROR] 磁盘空间不足需要至少50MB
```
**解决方案**:
```bash
# 检查可用空间
df -h $HOME
# 清理磁盘空间
# 清理包管理器缓存
sudo apt-get clean
brew cleanup
# 清理临时文件
rm -rf /tmp/*
```
---
### 问题3: 网络连接失败
**症状**:
```
[ERROR] 无法连接到 GitLink 服务器
```
**解决方案**:
```bash
# 检查网络连接
curl -I https://www.gitlink.org.cn
# 使用代理
export https_proxy=http://127.0.0.1:7890
# 或手动下载
# 访问 https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases
```
---
### 问题4: 权限被拒绝
**症状**:
```
Permission denied: /usr/local/bin/gitlink-cli
```
**解决方案**:
```bash
# 方案1: 使用sudo
sudo bash install.sh
# 方案2: 安装到用户目录
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
INSTALL_DIR=$HOME/.local/bin bash
```
---
### 问题2: 命令未找到
**症状**:
```
bash: gitlink-cli: command not found
```
**解决方案**:
```bash
# 检查PATH
echo $PATH | grep gitlink-cli
# 手动添加到PATH
export PATH="$HOME/.local/bin:$PATH"
# 永久添加
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
```
---
### 问题3: 下载失败
**症状**:
```
curl: (7) Failed to connect
```
**解决方案**:
```bash
# 检查网络连接
curl -I https://www.gitlink.org.cn
# 使用代理
export https_proxy=http://127.0.0.1:7890
# 手动下载安装
# 1. 访问 https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases
# 2. 下载对应平台的压缩包
# 3. 解压并添加到PATH
```
---
### 问题4: npm安装失败
**症状**:
```
npm ERR! EACCES
```
**解决方案**:
```bash
# 方案1: 修复npm权限
mkdir -p ~/.npm-global
npm config set prefix '~/.npm-global'
echo 'export PATH="~/.npm-global/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
# 方案2: 使用sudo不推荐
sudo npm install -g @gitlink-ai/cli
```
---
### 问题5: Skills未安装
**症状**:
```
Skills目录不存在或为空
```
**解决方案**:
```bash
# 手动安装Skills
npx skills add https://www.gitlink.org.cn/Gitlink/gitlink-cli.git -y -g
# 或从本地安装
npx skills add ./skills -y -g
# 使用专用命令
gitlink-cli-install-skills
```
---
## 📊 安装成功标准
### 检查清单
完成安装后,请验证以下内容:
- [ ] `gitlink-cli --version` 显示版本信息
- [ ] `gitlink-cli auth status` 可查看登录状态
- [ ] `gitlink-cli user +me` 可获取用户信息
- [ ] `~/.gitlink/skills/` 目录包含13个Skills
- [ ] 二进制文件在PATH中
- [ ] 配置文件已创建
### 快速测试
```bash
# 1. 查看版本
gitlink-cli version
# 2. 配置
gitlink-cli config init
# 3. 登录
gitlink-cli auth login
# 4. 测试命令
gitlink-cli user +me
# 5. 查看仓库列表
gitlink-cli repo +list
```
---
## 🔄 更新
### npm安装
```bash
# 更新到最新版本
npm update -g @gitlink-ai/cli
# 或重新安装
npm uninstall -g @gitlink-ai/cli
npm install -g @gitlink-ai/cli
```
### 脚本安装
```bash
# 重新运行安装脚本(会覆盖旧版本)
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
```
---
## ❓ 常见问题
### Q1: 需要哪些系统权限?
**A**:
- **一键脚本**: 需要`sudo`权限(安装到系统目录)
- **npm**: 需要全局npm写入权限
- **源码构建**: 需要Go环境和写入权限
### Q2: 可以安装多个版本吗?
**A**: 不建议。CLI工具通常会覆盖安装。如需多版本可以使用Docker或版本管理工具。
### Q3: 离线环境如何安装?
**A**:
```bash
# 在在线环境下载完整包
wget https://releases.gitlink.org.cn/gitlink-cli/gitlink-cli-full-v0.2.0.tar.gz
# 在离线环境安装
tar -xzf gitlink-cli-full-v0.2.0.tar.gz
cd gitlink-cli
./install.sh --offline
```
### Q4: 安装后如何配置默认编辑器?
**A**:
```bash
# 方法1: 配置文件
vim ~/.config/gitlink-cli/config.yaml
# 添加: editor: vim
# 方法2: 环境变量
export EDITOR=vim
# 方法3: 命令行参数
gitlink-cli issue +create --editor vim
```
### Q5: Skills占多少空间
**A**: Skills大约占用5-10MB空间包含12个完整的AI Agent技能包。
---
## 📞 获取帮助
如有安装问题,请:
1. 🐛 提交Issue: https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues
2. 📖 查看文档: https://www.gitlink.org.cn/Gitlink/gitlink-cli
3. 💬 查看卸载指南: [doc/UNINSTALL.md](./UNINSTALL.md)
4. 📧 联系支持: support@gitlink.org.cn
---
## 🎯 下一步
安装完成后,建议:
1. ✅ 运行 `gitlink-cli config init` 初始化配置
2. ✅ 运行 `gitlink-cli auth login` 登录账号
3. ✅ 查看 [README.md](../README.md) 了解基本使用
4. ✅ 浏览 [Skills指南](../skills/README.md) 了解AI功能
---
**最后更新**: 2026-06-04
**相关文档**: [UNINSTALL.md](./UNINSTALL.md) | [README.md](../README.md)

View File

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

317
doc/UNINSTALL.md Normal file
View File

@ -0,0 +1,317 @@
# GitLink CLI 卸载指南
本文档介绍如何完全卸载 GitLink CLI 及其相关文件。
## 📋 卸载方式
### 一、Linux/macOS
#### 方式1: 使用卸载脚本(推荐)
```bash
# 交互式卸载(保留配置)
bash uninstall.sh
# 完全删除所有文件(包括配置)
bash uninstall.sh --purge
# 自动确认,不询问
bash uninstall.sh -y
```
#### 方式2: 在线执行
```bash
# 保留配置
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/uninstall.sh | bash
# 完全删除
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/uninstall.sh | bash -s -- --purge
```
#### 方式3: 手动删除
```bash
# 删除二进制
sudo rm -f /usr/local/bin/gitlink-cli
# 或
sudo rm -f /usr/bin/gitlink-cli
# 删除Skills
rm -rf ~/.gitlink/skills
# 删除配置(可选)
rm -rf ~/.gitlink-cli
rm -rf ~/.gitlink
rm -rf ~/.config/gitlink-cli
```
---
### 二、Windows
#### 方式1: PowerShell 脚本(推荐)
```powershell
# 交互式卸载
powershell -NoProfile -ExecutionPolicy Bypass -File uninstall.ps1
# 完全删除
powershell -NoProfile -ExecutionPolicy Bypass -File uninstall.ps1 -Purge
# 自动确认
.\uninstall.ps1 -Yes
```
#### 方式2: 手动删除
```powershell
# 删除二进制(根据安装位置)
Remove-Item "$env:USERPROFILE\.gitlink-cli\bin\gitlink-cli.exe"
# 删除Skills
Remove-Item -Recurse -Force "$env:USERPROFILE\.gitlink\skills"
# 删除配置(可选)
Remove-Item -Recurse -Force "$env:USERPROFILE\.gitlink-cli"
Remove-Item -Recurse -Force "$env:USERPROFILE\.gitlink"
```
---
### 三、npm 安装
#### 方式1: npm 卸载(推荐)
```bash
# 标准卸载(保留配置)
npm uninstall -g @gitlink-ai/cli
# 完全删除(包括配置)
npm uninstall -g @gitlink-ai/cli --purge
```
#### 方式2: 使用卸载命令
```bash
# 保留配置
gitlink-cli-uninstall
# 完全删除
gitlink-cli-uninstall --purge
```
#### 方式3: 手动删除
```bash
# 卸载npm包
npm uninstall -g @gitlink-ai/cli
# 删除Skills
rm -rf ~/.gitlink/skills
# 删除配置(可选)
rm -rf ~/.gitlink-cli
rm -rf ~/.gitlink
```
---
## 🔧 卸载选项说明
### `--purge` 参数
完全删除所有文件,包括:
- ✅ 二进制文件
- ✅ Skills目录
- ✅ 配置文件
- ✅ 用户数据
- ✅ 缓存文件
### `--yes` / `-y` 参数
自动确认所有操作,不询问用户。
### 交互模式(默认)
卸载过程中会询问:
1. 是否删除配置文件和数据
2. npm安装时是否卸载npm包
3. 确认卸载操作
---
## 📂 卸载内容清单
### 始终删除
- ✅ 二进制文件 (`gitlink-cli` 或 `gitlink-cli.exe`)
- ✅ Skills目录 (`~/.gitlink/skills/`)
### 条件删除(需要确认或 `--purge`
- 🔸 配置目录 (`~/.gitlink-cli/`)
- 🔸 数据目录 (`~/.gitlink/`)
- 🔸 配置文件 (`~/.config/gitlink-cli/`)
- 🔸 npm全局包 (`@gitlink-ai/cli`)
### 保留文件
- 📌 用户的项目文件
- 📌 Git仓库
- 📌 系统环境变量(需手动清理)
---
## 🧹 清理剩余文件
### Linux/macOS
```bash
# 检查剩余文件
find ~ -name "*gitlink*" -type f 2>/dev/null
find ~ -name "*gitlink*" -type d 2>/dev/null
# 清理环境变量(如果手动添加过)
# 编辑 ~/.bashrc, ~/.zshrc 等,删除相关行
```
### Windows
```powershell
# 检查剩余文件
Get-ChildItem -Path $env:USERPROFILE -Recurse -Filter "*gitlink*" -ErrorAction SilentlyContinue
# 清理PATH环境变量
# 1. 打开"系统属性" > "环境变量"
# 2. 在"用户变量"或"系统变量"的Path中删除gitlink-cli路径
```
---
## ❓ 常见问题
### Q1: 卸载后命令仍然可用?
**A**: 可能是PATH缓存问题解决方法
**Linux/macOS**:
```bash
# 刷新shell
hash -r gitlink-cli
# 或重启终端
```
**Windows**:
```powershell
# 重启PowerShell或CMD
```
### Q2: 提示权限不足?
**A**: 使用管理员权限或删除到用户目录:
**Linux/macOS**:
```bash
# 使用sudo
sudo bash uninstall.sh
# 或安装到用户目录
INSTALL_DIR=$HOME/.local/bin bash uninstall.sh
```
**Windows**:
```powershell
# 以管理员身份运行PowerShell
```
### Q3: 如何重新安装?
**A**: 重新运行安装脚本即可:
```bash
# Linux/macOS
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# Windows
powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1
# npm
npm install -g @gitlink-ai/cli
```
### Q4: 配置文件会自动删除吗?
**A**: 不会,除非使用 `--purge` 参数或确认删除。这是为了保护用户数据。
### Q5: Skills会被删除吗
**A**: 会的Skills目录会被自动删除。如需保留请手动备份
```bash
# 备份Skills
cp -r ~/.gitlink/skills ~/gitlink-skills-backup
```
---
## 🔍 故障排除
### 卸载脚本找不到
```bash
# 确保在项目目录中
cd /path/to/gitlink-cli
# 检查脚本是否存在
ls -la uninstall.sh uninstall.ps1
```
### 删除失败
```bash
# 检查文件权限
ls -la /usr/local/bin/gitlink-cli
# 强制删除
sudo rm -f /usr/local/bin/gitlink-cli
```
### npm卸载后仍有残留
```bash
# 手动清理npm缓存
npm cache clean --force
# 检查全局安装位置
npm root -g
npm list -g --depth=0
# 手动删除残留
```
---
## 📞 获取帮助
如有任何问题,请:
1. 🐛 提交Issue: https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues
2. 💬 查看文档: https://www.gitlink.org.cn/Gitlink/gitlink-cli
3. 📧 联系支持: support@gitlink.org.cn
---
## ✅ 卸载检查清单
完成卸载后,可以检查以下内容:
- [ ] 命令不可用(运行 `gitlink-cli --version` 应该报错)
- [ ] 二进制文件已删除
- [ ] Skills目录已删除
- [ ] 配置文件已删除(如需要)
- [ ] PATH环境变量已清理
- [ ] 没有残留进程(`ps aux | grep gitlink` 或任务管理器)
---
**最后更新**: 2026-06-04
**适用版本**: gitlink-cli v0.2.0+

64
doc/dashboard.html Normal file

File diff suppressed because one or more lines are too long

View File

@ -40,28 +40,33 @@ 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
│ ├── pr/ # pr +list / +create / +view / +merge / +review
│ ├── release/ # release +list / +create / +download
│ ├── branch/ # branch +list / +protect / +unprotect
│ ├── org/ # org +list / +info / +members
│ ├── repo/ # repo +create / +clone / +fork / +list / +info / +delete / +settings / +batch-create / +batch-update
│ ├── issue/ # issue +list / +create / +view / +update / +close / +comment / +assign / +label / +batch-* (6 个批量命令)
│ ├── wiki/ # wiki +list / +view / +create / +update / +delete
│ ├── pr/ # pr +list / +create / +view / +merge / +close / +review / +files / +diff
│ ├── release/ # release +list / +create / +view / +delete / +download
│ ├── branch/ # branch +list / +create / +delete / +protect / +unprotect
│ ├── webhook/ # webhook +list / +create / +update / +delete / +test / +info
│ ├── org/ # org +list / +info / +members / +create
│ ├── user/ # user +me / +info
│ ├── search/ # search +repos / +issues / +users
│ ├── ci/ # ci +builds / +logs / +restart
│ ├── ci/ # ci +builds / +logs / +restart / +stop
│ └── register.go # 注册所有 shortcuts 到 cobra
├── skills/
│ ├── gitlink-shared/ # SKILL.md — 认证、全局参数、安全规则
│ ├── gitlink-repo/ # SKILL.md + references/ — 仓库操作
│ ├── gitlink-issue/ # SKILL.md + references/ — Issue 操作
│ ├── gitlink-pr/ # SKILL.md + references/ — PR 操作
│ ├── gitlink-release/ # SKILL.md + references/ — 发布管理
│ ├── gitlink-branch/ # SKILL.md + references/ — 分支操作
│ ├── gitlink-ci/ # SKILL.md + references/ — CI/CD 操作
│ ├── gitlink-org/ # SKILL.md + references/ — 组织管理
│ ├── gitlink-release/ # SKILL.md + references/ — 发布管理
│ ├── gitlink-search/ # SKILL.md + references/ — 搜索
│ ├── gitlink-user/ # SKILL.md + references/ — 用户管理
│ ├── gitlink-pm/ # SKILL.md + references/ — 项目管理
│ └── gitlink-workflow/ # SKILL.md — AI 自动化工作流Issue 分类、PR Review 等)
│ ├── gitlink-wiki/ # SKILL.md + references/ + examples/ — Wiki 操作
│ ├── gitlink-webhook/ # SKILL.md + references/ + examples/ — Webhook 管理
│ └── gitlink-workflow/ # SKILL.md + references/ + examples/ — AI 自动化工作流Issue 分类、PR Review 等)
├── go.mod
├── go.sum
├── Makefile
@ -74,18 +79,20 @@ gitlink-cli/
### 2.1 Layer 1: Shortcuts快捷命令`+` 前缀)
面向高频场景的语义化封装,MVP 覆盖 ~43 个
面向高频场景的语义化封装,覆盖 13 个领域共 62 个命令
| 领域 | 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 |
| org | `+list` `+info` `+members` `+create` | 4 |
| ci | `+builds` `+logs` `+restart` `+stop` | 4 |
| user | `+me` `+info` | 2 |
| webhook | `+list` `+create` `+update` `+delete` `+test` `+info` | 6 |
**Shortcut 声明式定义**
@ -437,7 +444,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/descriptionPATCH 时只替换目标字段。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 域名上,与主站 APIwww 域名)分离:
```
┌─ 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 仅部署在 gatewaydetail API 在 www不可互换
- **create/update pageName 差异**create 接受原始中文 pageNameupdate 需要 URL 编码
---
## 11 完整命令参考
```
gitlink-cli
@ -457,7 +626,9 @@ gitlink-cli
│ ├── +list # 仓库列表
│ ├── +info # 仓库详情
│ ├── +delete # 删除仓库
│ └── +settings # 仓库设置
│ ├── +settings # 仓库设置
│ ├── +batch-create # 批量创建仓库
│ └── +batch-update # 批量更新仓库
├── issue
│ ├── +list # Issue 列表
│ ├── +create # 创建 Issue
@ -466,7 +637,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 +678,19 @@ gitlink-cli
├── user
│ ├── +me # 当前用户
│ └── +info # 用户详情
├── webhook
│ ├── +list # Webhook 列表
│ ├── +create # 创建 Webhook
│ ├── +update # 更新 Webhook
│ ├── +delete # 删除 Webhook
│ ├── +test # 测试 Webhook
│ └── +info # Webhook 详情
├── wiki
│ ├── +list # Wiki 页面列表
│ ├── +view # 查看 Wiki 页面
│ ├── +create # 创建 Wiki 页面
│ ├── +update # 更新 Wiki 页面
│ └── +delete # 删除 Wiki 页面
├── search
│ ├── +repos # 搜索仓库
│ ├── +issues # 搜索 Issue
@ -518,7 +708,7 @@ gitlink-cli
---
## 10 关键文件清单
## 12 关键文件清单
实现时需要修改/创建的核心文件:
@ -541,8 +731,10 @@ 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 shortcutslist, view, create, update, delete |
| `shortcuts/webhook/*.go` | Webhook shortcutslist, create, update, delete, test, info |
| `shortcuts/pr/*.go` | PR shortcuts |
| `shortcuts/register.go` | Shortcut 注册 |
| `skills/gitlink-shared/SKILL.md` | 共享 Skill |
@ -550,7 +742,7 @@ gitlink-cli
---
## 11 开发计划
## 13 开发计划
### Phase 1: Foundation第 1-2 周)
@ -609,7 +801,7 @@ gitlink-cli
---
## 12 验证方案
## 14 验证方案
| 阶段 | 验证方式 |
|------|----------|

View File

@ -23,6 +23,159 @@
- HTTP Authentication, scheme: bearer
---
# GitLink API 使用注意事项
> **重要提示**: 以下注意事项基于实际使用经验总结使用GitLink API时请特别注意这些行为和限制。
## 已知问题和特殊行为
### API响应格式
| 问题 | 说明 | 影响 | 解决方案 |
|------|------|------|----------|
| **双重错误码** | HTTP 200 + body.status 非200 | 错误判断复杂 | 需要检查HTTP状态码和body.status |
| **错误格式不一致** | 有`{status, message}`也有`{code, msg}` | 错误解析困难 | 兼容处理两种格式 |
| **静默失败** | 字段名错误可能返回200但不生效 | 调试困难 | 通过GET验证实际修改 |
### Issue API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| Issue创建 | 必须包含`done_ratio: 0`,否则数据库报错 | 自动添加该字段 | ✅ shortcuts已处理 |
| Issue更新 | 需保留`subject`/`description`,否则可能清空描述 | 先GET再提交保留字段 | ⚠️ 需手动处理 |
| Issue列表 | 分页参数可能不返回完整统计 | 客户端需分页处理 | ✅ 正常 |
### Release API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| Release查看 | 需要`version_id`不能用`tag_name` | 使用`release +list`获取ID | ⚠️ 注意参数 |
| Release删除 | 需要`version_id` | 使用`release +delete -i <version_id>` | ✅ shortcuts已处理 |
### 分支API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| 分支操作 | 需要`/v1/`前缀 | 端点使用`/v1/:owner/:repo/branches` | ✅ shortcuts已处理 |
| 分支删除 | `DELETE`分支API始终返回"分支不存在" | GitLink平台Bug暂不支持 | ❌ API不可用 |
### 文件操作API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| 创建文件 | `content`字段必须base64编码 | 不编码会返回"文件已存在"错误 | ⚠️ 需手动处理 |
| 更新文件 | 需要`sha`参数,通过`sub_entries`获取 | 先GET获取SHA再PUT | ⚠️ 复杂操作 |
### Pull Request API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| PR合并 | 需要`do`参数指定合并方式 | `pr +merge`已内置处理 | ✅ shortcuts已处理 |
| PR列表 | `--state`参数只影响统计,列表可能包含所有状态 | 客户端需按`pull_request_status`过滤 | ⚠️ 需手动处理 |
| PR创建 | 分支内容必须与目标分支不同 | 需要先有实际提交差异 | ⚠️ API限制 |
### Wiki API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| Wiki域名 | 使用Gateway域名而非主域名 | Wiki使用独立client处理 | ✅ shortcuts已处理 |
| Wiki内容 | base64编码传输 | CLI自动编解码 | ✅ shortcuts已处理 |
| Wiki删除 | API只清空内容不删除侧边栏条目 | GitLink平台限制 | ⚠️ 部分功能 |
### Webhook API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| Webhook创建 | 需要完整的URL和事件配置 | 按文档格式提交 | ✅ shortcuts已处理 |
| Webhook测试 | 测试推送可能延迟 | 等待异步处理 | ✅ shortcuts已处理 |
## 推荐使用方式
### 优先级顺序
1. **Shortcuts** - 最简单,自动处理特殊情况
```bash
gitlink-cli issue +create -t "Bug" -b "详细描述"
gitlink-cli wiki +create --title "Home" --content "# 欢迎"
```
2. **Raw API** - Shortcuts未覆盖时使用
```bash
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"test","done_ratio":0}'
```
3. **直接HTTP** - 仅用于调试或特殊需求
```bash
curl -X POST "https://www.gitlink.org.cn/api/:owner/:repo/issues.json?access_token=xxx"
```
### 错误处理建议
**推荐错误处理流程**:
1. 检查HTTP状态码
2. 检查body中的status/code字段
3. 验证实际修改是否生效GET验证
4. 使用shortcuts避免直接处理复杂情况
**错误处理示例**:
```python
def check_gitlink_error(response):
# 1. 检查HTTP状态码
if response.status_code >= 400:
return f"HTTP错误: {response.status_code}"
# 2. 检查body中的错误字段
data = response.json()
if 'status' in data and data['status'] != 200:
return f"API错误: {data.get('message', '未知错误')}"
if 'code' in data and data['code'] != 200:
return f"Gateway错误: [{data['code']}] {data.get('msg', '未知错误')}"
# 3. 验证实际修改
return None
```
### 认证相关
**Token获取方式**:
1. 用户名密码登录: `gitlink-cli auth login`
2. 直接Token: `gitlink-cli auth login --token`
3. 环境变量: `export GITLINK_TOKEN="your-token"`
**Token有效期**: 7天过期需重新登录
**认证优先级**: 环境变量 > Keychain存储 > 交互式登录
### 请求限制
**速率限制**: GitLink API有基本的速率限制建议
- 批量操作使用专门的batch命令
- 避免短时间内大量请求
- 使用`--dry-run`预览批量操作
**分页处理**: 大量数据建议:
- 使用shortcuts的自动分页功能
- 或者使用Raw API手动处理分页参数
## 开发建议
### 使用gitlink-cli的优势
1. **自动处理特殊情况** - 如base64编码、双重错误码等
2. **统一的错误处理** - 标准化的错误信息和建议
3. **AI Agent友好** - 完整的Skills文档支持
4. **跨平台支持** - macOS、Linux、Windows
### 调试技巧
1. **使用`--debug`参数** 查看详细的请求响应
```bash
gitlink-cli --debug issue +list
```
2. **使用`--format json`** 获取结构化输出
```bash
gitlink-cli --format json issue +list
```
3. **使用`--dry-run`** 预览危险操作
```bash
gitlink-cli issue +batch-close --numbers 1,2,3 --dry-run
```
---
# 附件
## POST 上传文件
@ -15206,6 +15359,46 @@ GET /api/wikiExport/wikiExport-wrapper
|» data|object|false|none||none|
|» message|string|false|none||none|
---
## Gateway Wiki APICLI 实际调用)
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 流水线列表

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

441
install.ps1 Normal file
View File

@ -0,0 +1,441 @@
# GitLink CLI Windows 安装脚本
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1
# 支持: .\install.ps1 -Version 0.1.0 -InstallDir "C:\GitLink"
param(
[string]$Version = "latest",
[string]$InstallDir = "$HOME\.gitlink-cli\bin",
[switch]$Help = $false,
[switch]$Uninstall = $false,
[switch]$ListVersions = $false,
[switch]$Debug = $false
)
$ErrorActionPreference = "Stop"
$REPO_OWNER = "Gitlink"
$REPO_NAME = "gitlink-cli"
$BINARY_NAME = "gitlink-cli"
$API_BASE = "https://www.gitlink.org.cn"
$SKILLS_DIR = "$HOME\.gitlink\skills"
# 颜色输出
function Info {
param([string]$msg)
Write-Host "[INFO] $msg" -ForegroundColor Green
}
function Warn {
param([string]$msg)
Write-Host "[WARN] $msg" -ForegroundColor Yellow
}
function ErrorMsg {
param([string]$msg)
Write-Host "[ERROR] $msg" -ForegroundColor Red
}
function Step {
param([string]$msg)
Write-Host "[STEP] $msg" -ForegroundColor Cyan
}
function DebugMsg {
param([string]$msg)
if ($Debug) {
Write-Host "[DEBUG] $msg" -ForegroundColor Blue
}
}
# 显示帮助
function Show-Help {
Write-Host @"
GitLink CLI Windows 安装脚本
用法:
.\install.ps1 [选项]
选项:
-Version <version> 指定版本 (默认: latest)
-InstallDir <path> 安装目录 (默认: $HOME\.gitlink-cli\bin)
-Uninstall 卸载
-ListVersions 列出可用版本
-Debug 显示调试信息
-Help 显示此帮助
示例:
# 安装最新版本
.\install.ps1
# 安装指定版本
.\install.ps1 -Version "0.1.0"
# 安装到指定目录
.\install.ps1 -InstallDir "C:\Tools\gitlink-cli"
# 列出可用版本
.\install.ps1 -ListVersions
# 卸载
.\install.ps1 -Uninstall
# 在线安装
powershell -NoProfile -ExecutionPolicy Bypass -Command "iex (irm https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.ps1)"
"@
}
# 检测架构
function Get-PlatformInfo {
$arch = switch ([System.Runtime.InteropServices::RuntimeInformation]::OSArchitecture) {
"X64" { "amd64" }
"Arm64" { "arm64" }
"X86" { "386"; Warn "32位系统支持有限" }
default { throw "不支持的架构: $arch" }
}
return @{ Platform = "windows"; Arch = $arch }
}
# 检查环境
function Test-Environment {
Step "检查安装环境..."
# 检查磁盘空间至少50MB
$drive = (Get-Item $InstallDir).PSDrive.Name
$driveInfo = Get-PSDrive $drive
$freeSpaceMB = [math]::Round($driveInfo.Free / 1MB, 2)
if ($freeSpaceMB -lt 50) {
ErrorMsg "磁盘空间不足需要至少50MB可用: ${freeSpaceMB}MB"
exit 1
}
DebugMsg "磁盘空间检查通过: ${freeSpaceMB}MB"
# 检查网络连接
try {
$response = Invoke-WebRequest -Uri $API_BASE -UseBasicParsing -TimeoutSec 5 -Method Head
DebugMsg "网络连接检查通过"
}
catch {
ErrorMsg "无法连接到 GitLink 服务器: $API_BASE"
ErrorMsg "请检查网络连接"
exit 1
}
Info "环境检查通过"
}
# 下载文件(带重试)
function Download-File {
param(
[string]$Url,
[string]$Output,
[int]$MaxAttempts = 3
)
$attempt = 1
while ($attempt -le $MaxAttempts) {
try {
Step "下载 (尝试 $attempt/$MaxAttempts): $(Split-Path $Url -Leaf)"
DebugMsg "URL: $Url"
# 使用WebClient支持进度显示
$webClient = New-Object System.Net.WebClient
# 注册下载进度事件
Register-ObjectEvent -InputObject $webClient -EventName DownloadProgressChanged -SourceIdentifier WebClient.DownloadProgressChanged -Action {
global:Progress = $EventArgs.ProgressPercentage
Write-Progress -Activity "下载中" -Status "$($EventArgs.ProgressPercentage)% 完成" -PercentComplete $EventArgs.ProgressPercentage
} | Out-Null
# 注册下载完成事件
Register-ObjectEvent -InputObject $webClient -EventName DownloadFileCompleted -SourceIdentifier WebClient.DownloadFileCompleted -Action {
global:DownloadComplete = $true
} | Out-Null
# 开始下载
$webClient.DownloadFileAsync($Url, $Output)
# 等待下载完成
while (-not $global:DownloadComplete) {
Start-Sleep -Milliseconds 100
}
Write-Progress -Activity "下载中" -Completed
# 清理事件
Unregister-Event -SourceIdentifier WebClient.DownloadProgressChanged -ErrorAction SilentlyContinue
Unregister-Event -SourceIdentifier WebClient.DownloadFileCompleted -ErrorAction SilentlyContinue
$webClient.Dispose()
if (Test-Path $Output) {
$size = [math]::Round((Get-Item $Output).Length / 1MB, 2)
Info "下载成功: $(Split-Path $Output -Leaf) (${size}MB)"
return $true
}
else {
Warn "下载文件为空"
}
}
catch {
Warn "下载失败: $_"
}
if ($attempt -lt $MaxAttempts) {
$waitTime = $attempt * 2
Warn "等待 ${waitTime}s 后重试..."
Start-Sleep -Seconds $waitTime
}
$attempt++
}
ErrorMsg "下载失败,已尝试 $MaxAttempts"
ErrorMsg "URL: $Url"
return $false
}
# 获取最新版本
function Get-LatestVersion {
Step "获取最新版本..."
try {
$releasesUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases.json"
$releases = Invoke-RestMethod -Uri $releasesUrl -TimeoutSec 30 -UseBasicParsing
if ($releases -and $releases.Count -gt 0) {
return $releases[0].tag_name
}
else {
Warn "无法获取版本信息,使用默认版本"
return "v0.1.0"
}
}
catch {
Warn "获取版本失败: $_"
return "v0.1.0"
}
}
# 列出可用版本
function Show-AvailableVersions {
Step "查询可用版本..."
try {
$releasesUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases.json"
$releases = Invoke-RestMethod -Uri $releasesUrl -TimeoutSec 30 -UseBasicParsing
if ($releases -and $releases.Count -gt 0) {
Info "可用版本:"
foreach ($release in $releases | Select-Object -First 10) {
Write-Host " - $($release.tag_name)" -ForegroundColor Cyan
}
}
else {
Warn "无法获取版本列表"
}
}
catch {
ErrorMsg "获取版本列表失败: $_"
}
}
# 解压zip文件
function Expand-ZipFile {
param(
[string]$ZipPath,
[string]$DestDir
)
Step "解压..."
DebugMsg "解压 $ZipPath$DestDir"
try {
Expand-Archive -Force -Path $ZipPath -DestinationPath $DestDir
Info "解压完成"
}
catch {
ErrorMsg "解压失败: $_"
throw
}
}
# 添加到PATH
function Add-ToPath {
param([string]$Dir)
$path = [Environment]::GetEnvironmentVariable("Path", "User")
if ($path -notlike "*$Dir*") {
Step "添加到PATH: $Dir"
[Environment]::SetEnvironmentVariable("Path", "$path;$Dir", "User")
Warn "请重启终端使PATH生效"
}
else {
DebugMsg "已在PATH中: $Dir"
}
}
# 验证安装
function Test-Installation {
Step "验证安装..."
$binaryPath = Join-Path $InstallDir "$BINARY_NAME.exe"
if (Test-Path $binaryPath) {
try {
$versionOutput = & $binaryPath version 2>$null
Info "安装成功! $versionOutput"
# 添加到PATH
Add-ToPath $InstallDir
Write-Host ""
Info "快速开始:"
Info " gitlink-cli auth login # 登录账号"
Info " gitlink-cli --help # 查看所有命令"
Info " gitlink-cli version # 查看版本信息"
Write-Host ""
return $true
}
catch {
ErrorMsg "执行二进制文件失败: $_"
return $false
}
}
else {
ErrorMsg "安装验证失败: $binaryPath 不存在"
return $false
}
}
# 主安装流程
function Install-CLI {
Write-Host ""
Write-Host " ╔══════════════════════════════════════╗"
Write-Host " ║ GitLink CLI 一键安装 (Windows) ║"
Write-Host " ╚══════════════════════════════════════╝"
Write-Host ""
# 检测平台
$platform = Get-PlatformInfo
Info "检测到平台: $($platform.Platform)-$($platform.Arch)"
# 创建安装目录
if (!(Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
Info "创建安装目录: $InstallDir"
}
# 检查环境
Test-Environment
# 获取版本
if ($Version -eq "latest") {
$Version = Get-LatestVersion
Info "最新版本: $Version"
}
else {
Info "指定版本: $Version"
}
# 下载
$zipName = "$BINARY_NAME_${Version}_windows_$($platform.Arch).zip"
$zipUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases/$Version/assets/$zipName"
$zipPath = Join-Path $env:TEMP $zipName
if (-not (Download-File -Url $zipUrl -Output $zipPath)) {
ErrorMsg "请确认以下版本已发布: $Version"
ErrorMsg "发布页: $API_BASE/$REPO_OWNER/$REPO_NAME/releases"
exit 1
}
# 解压
Expand-ZipFile -ZipPath $zipPath -DestDir $InstallDir
Remove-Item $zipPath
# 安装skills
Step "安装 skills..."
if (!(Test-Path $SKILLS_DIR)) {
New-Item -ItemType Directory -Path $SKILLS_DIR -Force | Out-Null
}
$skillsZipName = "$BINARY_NAME_${Version}_skills.zip"
$skillsUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases/$Version/assets/$skillsZipName"
$skillsZipPath = Join-Path $env:TEMP $skillsZipName
if (Download-File -Url $skillsUrl -Output $skillsZipPath) {
try {
Expand-Archive -Force -Path $skillsZipPath -DestinationPath $SKILLS_DIR
Info "Skills 安装到: $SKILLS_DIR"
}
catch {
Warn "Skills 解压失败(可稍后手动安装)"
}
Remove-Item $skillsZipPath -ErrorAction SilentlyContinue
}
else {
Warn "Skills 包不可用(可稍后手动安装)"
}
# 验证
if (-not (Test-Installation)) {
exit 1
}
}
# 卸载
function Uninstall-CLI {
Step "卸载 $BINARY_NAME..."
# 删除二进制
$binaryPath = Join-Path $InstallDir "$BINARY_NAME.exe"
if (Test-Path $binaryPath) {
Remove-Item $binaryPath -Force
Info "已删除: $binaryPath"
}
# 删除skills
if (Test-Path $SKILLS_DIR) {
Remove-Item $SKILLS_DIR -Recurse -Force
Info "已删除: $SKILLS_DIR"
}
# 删除配置(可选)
$response = Read-Host "是否删除配置文件? [y/N]"
if ($response -eq 'y' -or $response -eq 'Y') {
$configDir = Join-Path $env:APPDATA "gitlink-cli"
if (Test-Path $configDir) {
Remove-Item $configDir -Recurse -Force
Info "已删除配置文件"
}
}
Info "卸载完成"
}
# 主函数
function Main {
if ($Help) {
Show-Help
exit 0
}
if ($ListVersions) {
Show-AvailableVersions
exit 0
}
if ($Uninstall) {
Uninstall-CLI
exit 0
}
try {
Install-CLI
}
catch {
ErrorMsg "安装失败: $_"
exit 1
}
}
Main

435
install.sh Executable file
View File

@ -0,0 +1,435 @@
#!/bin/bash
# GitLink CLI 一键安装脚本(改进版)
# 自动检测平台,下载预编译二进制,安装 skills
# 用法: curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 支持: VERSION=0.1.0 INSTALL_DIR=$HOME/.local/bin bash install.sh
set -e
REPO_OWNER="Gitlink"
REPO_NAME="gitlink-cli"
BINARY_NAME="gitlink-cli"
API_BASE="https://www.gitlink.org.cn"
INSTALL_DIR="${INSTALL_DIR:-auto}"
SKILLS_DIR="${HOME}/.gitlink/skills"
VERSION="${VERSION:-latest}"
MAX_ATTEMPTS=3
DEBUG="${DEBUG:-false}"
# ---------- 颜色输出 ----------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; }
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
debug() { [[ "${DEBUG}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $*"; }
# ---------- 环境检测 ----------
check_environment() {
step "检查安装环境..."
# 检查必需命令
local required_commands=("curl" "tar")
for cmd in "${required_commands[@]}"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
error "缺少必需命令: $cmd"
error "请安装后再试:"
error " Ubuntu/Debian: sudo apt-get install $cmd"
error " CentOS/RHEL: sudo yum install $cmd"
error " macOS: brew install $cmd"
exit 1
fi
done
debug "必需命令检查通过"
# 检查磁盘空间至少50MB
local available_space
available_space=$(df -m "$HOME" | tail -1 | awk '{print $4}')
if [ "$available_space" -lt 50 ]; then
error "磁盘空间不足需要至少50MB可用: ${available_space}MB"
exit 1
fi
debug "磁盘空间检查通过: ${available_space}MB"
# 检查网络连接
if ! curl -sSL --connect-timeout 5 "${API_BASE}" >/dev/null 2>&1; then
error "无法连接到 GitLink 服务器: ${API_BASE}"
error "请检查网络连接"
exit 1
fi
debug "网络连接检查通过"
info "环境检查通过"
}
# ---------- 智能权限处理 ----------
select_install_dir() {
local dirs=("$HOME/.local/bin" "$HOME/bin" "/usr/local/bin")
local selected_dir=""
if [ "${INSTALL_DIR}" != "auto" ]; then
echo "${INSTALL_DIR}"
return
fi
step "选择安装目录..."
# 优先级:用户目录 > 系统目录
for dir in "${dirs[@]}"; do
if [ -w "$dir" ] 2>/dev/null || mkdir -p "$dir" 2>/dev/null; then
selected_dir="$dir"
info "选择用户目录: $selected_dir"
break
fi
done
# 如果用户目录都不可写,尝试系统目录
if [ -z "$selected_dir" ]; then
warn "无法写入用户目录将使用系统目录需要sudo权限"
selected_dir="/usr/local/bin"
fi
echo "$selected_dir"
}
# ---------- 下载重试机制 ----------
download_with_retry() {
local url="$1"
local output="$2"
local attempt=1
while [ $attempt -le $MAX_ATTEMPTS ]; do
step "下载 (尝试 $attempt/$MAX_ATTEMPTS): $(basename "$url")"
if curl -sSL --connect-timeout 10 --max-time 120 --progress-bar "$url" -o "$output" 2>&1; then
if [ -s "$output" ]; then
info "下载成功: $(basename "$output") ($(du -h "$output" | cut -f1))"
return 0
else
warn "下载文件为空"
fi
else
warn "下载失败"
fi
if [ $attempt -lt $MAX_ATTEMPTS ]; then
local wait_time=$((attempt * 2))
warn "等待 ${wait_time}s 后重试..."
sleep $wait_time
fi
attempt=$((attempt + 1))
done
error "下载失败,已尝试 $MAX_ATTEMPTS"
error "URL: $url"
error "请检查网络连接或手动下载"
return 1
}
# ---------- 平台检测 ----------
detect_platform() {
local os arch
case "$(uname -s)" in
Linux) os="linux" ;;
Darwin) os="darwin" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
*) error "不支持的操作系统: $(uname -s)"; exit 1 ;;
esac
case "$(uname -m)" in
x86_64|amd64) arch="amd64" ;;
aarch64|arm64) arch="arm64" ;;
*) error "不支持的架构: $(uname -m)"; exit 1 ;;
esac
echo "${os}/${arch}"
}
# ---------- 获取最新版本 ----------
fetch_latest_version() {
local releases_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json"
info "获取最新版本..."
local releases
releases=$(curl -sSL --connect-timeout 10 --max-time 30 "${releases_url}" 2>/dev/null || true)
if [ -z "$releases" ]; then
error "无法获取发布列表: ${releases_url}"
exit 1
fi
# 尝试解析第一个 release 的 tag_name
local tag
tag=$(echo "$releases" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
if [ -z "$tag" ]; then
# 备选:尝试直接匹配数组第一个元素的 tag_name
tag=$(echo "$releases" | grep -oP '"tag_name"\s*:\s*"\K[^"]+' | head -1)
fi
echo "${tag:-v0.1.0}"
}
# ---------- 显示已安装版本 ----------
show_installed_version() {
if command -v "${BINARY_NAME}" >/dev/null 2>&1; then
"${BINARY_NAME}" version 2>/dev/null || echo "未知版本"
elif [ -x "${INSTALL_DIR}/${BINARY_NAME}" ]; then
"${INSTALL_DIR}/${BINARY_NAME}" version 2>/dev/null || echo "未知版本"
else
echo "未安装"
fi
}
# ---------- 列出可用版本 ----------
list_versions() {
step "查询可用版本..."
local releases_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json"
curl -sSL "$releases_url" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' | head -10
}
# ---------- 回滚功能 ----------
rollback_version() {
local target_version="$1"
if [ -z "$target_version" ]; then
error "请指定要回滚到的版本"
exit 1
fi
step "回滚到版本: ${target_version}"
# 重新安装指定版本
VERSION="$target_version"
main
}
# ---------- 卸载功能 ----------
uninstall() {
step "卸载 ${BINARY_NAME}..."
# 删除二进制
if [ -f "${INSTALL_DIR}/${BINARY_NAME}" ]; then
if [ -w "${INSTALL_DIR}" ]; then
rm -f "${INSTALL_DIR}/${BINARY_NAME}"
info "已删除: ${INSTALL_DIR}/${BINARY_NAME}"
else
sudo rm -f "${INSTALL_DIR}/${BINARY_NAME}"
info "已删除: ${INSTALL_DIR}/${BINARY_NAME} (使用sudo)"
fi
fi
# 删除skills
if [ -d "${SKILLS_DIR}" ]; then
rm -rf "${SKILLS_DIR}"
info "已删除: ${SKILLS_DIR}"
fi
# 删除配置(可选)
echo -n "是否删除配置文件? [y/N] "
read -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
rm -rf "$HOME/.config/gitlink-cli"
rm -rf "$HOME/.gitlink"
info "已删除配置文件"
fi
info "卸载完成"
exit 0
}
# ---------- 下载二进制 ----------
download_binary() {
local platform="$1"
local version="$2"
local os="${platform%/*}"
local arch="${platform#*/}"
local archive_name="${BINARY_NAME}_${version}_${os}_${arch}.tar.gz"
local download_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${archive_name}"
info "下载: ${archive_name}"
debug "URL: ${download_url}"
local tmpdir
tmpdir="$(mktemp -d)"
trap "rm -rf ${tmpdir}" EXIT
if ! download_with_retry "${download_url}" "${tmpdir}/${archive_name}"; then
error "请确认以下版本已发布: ${version}"
error "发布页: ${API_BASE}/${REPO_OWNER}/${REPO_NAME}/releases"
exit 1
fi
step "解压..."
tar -xzf "${tmpdir}/${archive_name}" -C "${tmpdir}"
local binary_path="${tmpdir}/${BINARY_NAME}"
if [ ! -f "${binary_path}" ]; then
# 可能在子目录中
binary_path=$(find "${tmpdir}" -name "${BINARY_NAME}" -type f 2>/dev/null | head -1)
fi
if [ ! -f "${binary_path}" ]; then
error "解压后未找到二进制文件"
exit 1
fi
chmod +x "${binary_path}"
# ---------- 安装 ----------
step "安装到 ${INSTALL_DIR}/${BINARY_NAME}"
if [ ! -w "${INSTALL_DIR}" ]; then
warn "需要管理员权限写入 ${INSTALL_DIR}"
sudo mkdir -p "${INSTALL_DIR}"
sudo mv "${binary_path}" "${INSTALL_DIR}/${BINARY_NAME}"
sudo chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
else
mkdir -p "${INSTALL_DIR}"
mv "${binary_path}" "${INSTALL_DIR}/${BINARY_NAME}"
fi
info "二进制: ${INSTALL_DIR}/${BINARY_NAME}"
}
# ---------- 安装 skills ----------
install_skills() {
local version="$1"
step "安装 skills..."
mkdir -p "${SKILLS_DIR}"
local skills_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${BINARY_NAME}_${version}_skills.tar.gz"
local tmpdir
tmpdir="$(mktemp -d)"
if download_with_retry "${skills_url}" "${tmpdir}/skills.tar.gz" 2>/dev/null; then
tar -xzf "${tmpdir}/skills.tar.gz" -C "${SKILLS_DIR}" 2>/dev/null || true
info "Skills 安装到: ${SKILLS_DIR}"
else
warn "Skills 包不可用(可稍后通过 gitlink-cli-install-skills 安装)"
fi
rm -rf "${tmpdir}"
}
# ---------- 验证安装 ----------
verify() {
step "验证安装..."
if command -v "${BINARY_NAME}" >/dev/null 2>&1; then
info "安装成功! $("${BINARY_NAME}" version 2>/dev/null || echo "${BINARY_NAME}")"
elif [ -x "${INSTALL_DIR}/${BINARY_NAME}" ]; then
info "安装成功! $("${INSTALL_DIR}/${BINARY_NAME}" version 2>/dev/null || echo "${INSTALL_DIR}/${BINARY_NAME}")"
warn "请将 ${INSTALL_DIR} 添加到 PATH: export PATH=${INSTALL_DIR}:\$PATH"
else
error "安装验证失败"
exit 1
fi
}
# ---------- 显示帮助 ----------
show_help() {
cat << EOF
GitLink CLI 安装脚本
用法:
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash [选项]
选项:
VERSION=0.1.0 指定版本
INSTALL_DIR=/path 指定安装目录
DEBUG=true 显示调试信息
环境变量:
INSTALL_DIR 安装目录(默认: auto自动选择
VERSION 版本(默认: latest
命令:
list 列出可用版本
uninstall 卸载
rollback VERSION 回滚到指定版本
示例:
# 安装最新版本
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 安装指定版本
VERSION=0.1.0 curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 安装到用户目录
INSTALL_DIR=$HOME/.local/bin curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 列出可用版本
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- list
# 卸载
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- uninstall
EOF
}
# ---------- main ----------
main() {
echo ""
echo " ╔══════════════════════════════════════╗"
echo " ║ GitLink CLI 一键安装 ║"
echo " ╚══════════════════════════════════════╝"
echo ""
# 处理命令行参数
case "${1:-}" in
list)
list_versions
exit 0
;;
uninstall)
INSTALL_DIR="${INSTALL_DIR:-$(select_install_dir)}"
uninstall
;;
rollback)
rollback_version "$2"
;;
help|--help|-h)
show_help
exit 0
;;
esac
# 环境检测
check_environment
# 选择安装目录
INSTALL_DIR=$(select_install_dir)
export INSTALL_DIR
local platform
platform=$(detect_platform)
info "检测到平台: ${platform}"
local version
if [ "${VERSION}" = "latest" ]; then
version=$(fetch_latest_version)
info "最新版本: ${version}"
else
version="${VERSION}"
info "指定版本: ${version}"
fi
download_binary "${platform}" "${version}"
install_skills "${version}"
verify
echo ""
info "快速开始:"
info " gitlink-cli auth login # 登录账号"
info " gitlink-cli --help # 查看所有命令"
info " gitlink-cli version # 查看版本信息"
echo ""
}
main "$@"

View File

@ -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)
}
}

View File

@ -11,19 +11,23 @@ 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"
)
type Client struct {
HTTP *http.Client
BaseURL string
Debug bool
HTTP *http.Client
BaseURL string
Debug bool
SkipJSONSuffix bool
}
type APIError struct {
StatusCode int
Code interface{}
Message string
Kind clierrors.ErrorKind
Suggestion string
}
func (e *APIError) Error() string {
@ -44,14 +48,14 @@ func New() (*Client, error) {
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
// Append .json suffix if not already present (GitLink API convention)
// Handle paths that may already contain query strings (e.g., /path?key=val)
if idx := strings.Index(path, "?"); idx != -1 {
basePath := path[:idx]
queryStr := path[idx:]
if !strings.HasSuffix(basePath, ".json") {
if c.shouldAppendJSONSuffix(path) {
if idx := strings.Index(path, "?"); idx != -1 {
basePath := path[:idx]
queryStr := path[idx:]
path = basePath + ".json" + queryStr
} else {
path += ".json"
}
} else if !strings.HasSuffix(path, ".json") {
path += ".json"
}
fullURL := c.BaseURL + path
if query != nil && len(query) > 0 {
@ -98,10 +102,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,
}
}
@ -123,11 +130,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,
}
}
}
@ -174,17 +183,64 @@ 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),
}
}
// shouldAppendJSONSuffix reports whether the .json suffix should be appended to path.
// Returns false (skip append) when:
// - c.SkipJSONSuffix is set (explicit opt-out for non-JSON endpoints such as gateway)
// - path already ends with .json
// - path matches the raw content pattern (e.g., /api/:owner/:repo/raw/...)
func (c *Client) shouldAppendJSONSuffix(path string) bool {
if c.SkipJSONSuffix {
return false
}
if strings.HasSuffix(path, ".json") {
return false
}
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range parts {
if part == "raw" && i >= 2 && i+2 < len(parts) {
return false
}
}
return true
}

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

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

View File

@ -0,0 +1,188 @@
package compliance
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
// checkLicense performs static license compliance checks (no regex scanning needed).
func checkLicense(root string) []Finding {
var findings []Finding
// L-001: LICENSE file existence
files := []string{"LICENSE", "LICENSE.md", "LICENSE.txt"}
found := false
for _, name := range files {
if _, err := os.Stat(filepath.Join(root, name)); err == nil {
found = true
break
}
}
if !found {
findings = append(findings, Finding{
ID: "L-001", Severity: "medium", Module: "license",
File: "-", Line: 0,
Summary: "缺少 LICENSE 文件",
})
}
// L-002: check npm/package.json license vs root LICENSE
rootLicense := detectLicense(root)
npmLicense := detectNpmLicense(root)
if rootLicense != "" && npmLicense != "" && !strings.EqualFold(rootLicense, npmLicense) {
findings = append(findings, Finding{
ID: "L-002", Severity: "medium", Module: "license",
File: "npm/package.json", Line: 1,
Summary: fmt.Sprintf("许可证声明不一致:根 LICENSE 为 %snpm/package.json 声明 %s", rootLicense, npmLicense),
})
}
// L-004: placeholder check in LICENSE file
for _, name := range files {
path := filepath.Join(root, name)
if f, err := os.Open(path); err == nil {
sc := bufio.NewScanner(f)
line := 0
for sc.Scan() {
line++
t := sc.Text()
if strings.Contains(t, "[year]") || strings.Contains(t, "[Year]") ||
strings.Contains(t, "[name of copyright holder]") || strings.Contains(t, "[yyyy]") {
findings = append(findings, Finding{
ID: "L-004", Severity: "low", Module: "license",
File: name, Line: line,
Summary: "LICENSE 中占位符未填写([Year] / [name of copyright holder]",
})
break
}
}
f.Close()
break
}
}
return findings
}
func detectLicense(root string) string {
for _, name := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt"} {
path := filepath.Join(root, name)
data, err := os.ReadFile(path)
if err != nil {
continue
}
text := string(data)
switch {
case strings.Contains(text, "Mulan Permissive Software License"):
return "MulanPSL-2.0"
case strings.Contains(text, "Apache License") && strings.Contains(text, "Version 2.0"):
return "Apache-2.0"
case strings.Contains(text, "MIT License") || strings.Contains(text, "Permission is hereby granted, free of charge"):
return "MIT"
case strings.Contains(text, "GNU AFFERO GENERAL PUBLIC LICENSE"):
return "AGPL-3.0"
case strings.Contains(text, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(text, "Version 3"):
return "GPL-3.0"
case strings.Contains(text, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(text, "Version 2"):
return "GPL-2.0"
case strings.Contains(text, "GNU LESSER GENERAL PUBLIC LICENSE"):
return "LGPL"
case strings.Contains(text, "BSD") && strings.Count(text, "Redistribution") >= 3:
return "BSD-3-Clause"
case strings.Contains(text, "BSD"):
return "BSD-2-Clause"
case strings.Contains(text, "Mozilla Public License"):
return "MPL-2.0"
default:
return "unknown"
}
}
return ""
}
func detectNpmLicense(root string) string {
path := filepath.Join(root, "npm", "package.json")
data, err := os.ReadFile(path)
if err != nil {
return ""
}
// simple string search for "license": "xxx"
for _, line := range strings.Split(string(data), "\n") {
if strings.Contains(line, "\"license\"") {
line = strings.TrimSpace(line)
// "license": "Apache-2.0",
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
v := strings.TrimSpace(parts[1])
v = strings.Trim(v, "\",")
return v
}
}
}
return ""
}
// checkDeps inspects go.mod for copyleft dependencies.
func checkDeps(root string) []Finding {
var findings []Finding
path := filepath.Join(root, "go.mod")
f, err := os.Open(path)
if err != nil {
// no go.mod — not a Go project
return nil
}
defer f.Close()
// GPL/AGPL keywords in module names
copyleft := []string{"gpl", "agpl", "gnu"}
sc := bufio.NewScanner(f)
line := 0
for sc.Scan() {
line++
text := strings.ToLower(sc.Text())
if !strings.Contains(text, "require") && !strings.Contains(text, "require") {
continue
}
// Check lines after "require" block until blank
}
f.Close()
// re-read go.mod and check require block
data, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(data), "\n")
inRequire := false
for _, l := range lines {
trimmed := strings.TrimSpace(l)
if strings.HasPrefix(trimmed, "require") && !strings.Contains(trimmed, "// indirect") {
inRequire = true
continue
}
if inRequire && trimmed == "" {
break
}
if inRequire && strings.HasPrefix(trimmed, ")") {
break
}
if inRequire {
lower := strings.ToLower(trimmed)
for _, kw := range copyleft {
if strings.Contains(lower, kw) {
findings = append(findings, Finding{
ID: "D-003", Severity: "high", Module: "deps",
File: "go.mod", Line: 0,
Summary: fmt.Sprintf("Copyleft 依赖风险: %s", trimmed),
})
break
}
}
}
}
return findings
}

View File

@ -0,0 +1,58 @@
package compliance
import "regexp"
// allRules returns the complete set of scan rules grouped by module.
func allRules() map[string][]scanRule {
return map[string][]scanRule{
"secrets": secretRules(),
"exposure": exposureRules(),
"vocab": vocabRules(),
}
}
func compileRE(expr string) *regexp.Regexp {
return regexp.MustCompile(expr)
}
// ----- secrets (S-001 ~ S-010) -----
func secretRules() []scanRule {
return []scanRule{
{SID("001"), "high", compileRE(`(?i)access_token|private_token`), "Token 作为 URL 查询参数泄露风险", nil},
{SID("002"), "critical", compileRE(`(?i)password\s*[:=]\s*"[^"]+"`), "硬编码密码", nil},
{SID("003"), "critical", compileRE(`(?i)api[_-]?key\s*[:=]\s*"[a-zA-Z0-9_-]{8,}"`), "硬编码 API Key", nil},
{SID("004"), "critical", compileRE(`BEGIN.*PRIVATE KEY`), "私钥文件内容", []string{"*"}},
{SID("005"), "high", compileRE(`token\s*[:=]\s*"[A-Za-z0-9+/=_-]{32,}"`), "长 Token 硬编码", nil},
{SID("006"), "high", compileRE(`(?i)secret\s*[:=]\s*"[^"]{8,}"`), "Secret 硬编码", nil},
{SID("008"), "medium", compileRE(`(?i)(fmt|log)\.(Print|Debug|Info).*[Tt]oken`), "Debug 输出可能泄露 Token", []string{"*.go"}},
{SID("010"), "high", compileRE(`(?i)(mongodb|mysql|postgres|redis)://[^@]*@`), "数据库连接串含凭据", nil},
}
}
// ----- exposure (P-001 ~ E-005) -----
func exposureRules() []scanRule {
return []scanRule{
{PID("001"), "low", compileRE(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`), "邮箱地址泄露", nil},
{PID("002"), "low", compileRE(`\b1[3-9]\d{9}\b`), "手机号泄露", nil},
{EID("001"), "medium", compileRE(`\b(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+)\b`), "内网 IP 暴露", nil},
{EID("002"), "low", compileRE(`(localhost|127\.0\.0\.1):\d+`), "本地开发地址残留", nil},
{EID("003"), "low", compileRE(`\b\w+\.(local|internal|test)\b`), "内部域名暴露", nil},
}
}
// ----- vocab (C-001 ~ C-006) -----
func vocabRules() []scanRule {
return []scanRule{
{CID("001"), "high", compileRE(`军|部队|军区|武装|国防|武器|弹药|导弹|雷达|舰艇|战机|潜艇|航母|核武器|火箭军|军事基地|作战指挥|军事演习|战备|动员令|驻地|番号`), "军事相关敏感词汇", nil},
{CID("002"), "high", compileRE(`中央委员会|国务院|中央军委|部委|党政机关|机要局|保密局|国家安全|公安内网|政务内网|红头文件|绝密|机密文件|内参|机要文件`), "党政机关敏感词汇", nil},
{CID("003"), "medium", compileRE(`内部系统|内部平台|内网地址|专网|涉密|非密|脱密|密码机|加密机|堡垒机|入侵检测|安全监测`), "内部系统标识泄露", nil},
{CID("004"), "medium", compileRE(`反洗钱|征信系统|个人隐私数据|数据出境|跨境传输|敏感个人信息|涉密数据|关键信息基础设施|网络安全等级|等保|密评|商用密码`), "监管合规敏感词", nil},
{CID("005"), "low", compileRE(`内部代号|项目代号|内部项目|未公开|NDA|保密协议|客户名录|内部API|私有接口|内部对接`), "组织内部敏感信息", nil},
{CID("006"), "medium", compileRE(`国密|SM2|SM3|SM4|SM9|密码卡|防火墙设备|入侵防御|WAF|DLP|上网行为|日志审计|终端管控`), "安全产品/密码学敏感词", nil},
}
}
func SID(num string) string { return "S-" + num }
func PID(num string) string { return "P-" + num }
func EID(num string) string { return "E-" + num }
func CID(num string) string { return "C-" + num }

View File

@ -0,0 +1,210 @@
package compliance
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// Finding represents a single scan result.
type Finding struct {
ID string `json:"id"`
Severity string `json:"severity"` // critical, high, medium, low
Module string `json:"module"` // license, deps, secrets, exposure, vocab
File string `json:"file"`
Line int `json:"line"`
Summary string `json:"summary"`
}
// ScanResult holds all findings for a module.
type ScanResult struct {
Module string `json:"module"`
Findings []Finding `json:"findings"`
}
// scanRule defines a pattern to search for.
type scanRule struct {
ID string
Severity string
Pattern *regexp.Regexp
Summary string
Globs []string // file globs to include, empty = all text files
}
// excludedDirs are directories skipped during scanning.
var excludedDirs = map[string]bool{
"vendor": true, "node_modules": true, ".git": true, ".claude": true,
"skills": true, // skill documentation, not project source
}
// excludedPaths are relative paths skipped (scanner's own source to avoid self-scan).
var excludedPaths = map[string]bool{
"internal/compliance": true,
}
// excludedExts are file extensions skipped during scanning.
var excludedExts = map[string]bool{
".exe": true, ".dll": true, ".so": true, ".dylib": true,
".bin": true, ".jpg": true, ".jpeg": true, ".png": true,
".gif": true, ".ico": true, ".svg": true, ".pdf": true,
".zip": true, ".gz": true, ".tgz": true,
}
// excludeFiles are specific files skipped during scanning.
var excludeFiles = map[string]bool{
"go.sum": true, "package-lock.json": true,
}
// textExts are extensions treated as text files.
var textExts = map[string]bool{
".go": true, ".js": true, ".ts": true, ".tsx": true, ".jsx": true,
".py": true, ".rb": true, ".java": true, ".c": true, ".h": true,
".cpp": true, ".hpp": true, ".rs": true, ".swift": true, ".kt": true,
".yaml": true, ".yml": true, ".json": true, ".xml": true, ".toml": true,
".md": true, ".txt": true, ".sh": true, ".bash": true, ".ps1": true,
".css": true, ".html": true, ".htm": true, ".sql": true, ".proto": true,
".cfg": true, ".conf": true, ".ini": true, ".env": true, ".lock": true,
".mod": true,
}
func isTextFile(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return textExts[ext]
}
func shouldSkip(path string, info os.FileInfo) bool {
name := info.Name()
if info.IsDir() {
if excludedDirs[name] {
return true
}
return false
}
if excludedExts[strings.ToLower(filepath.Ext(name))] {
return true
}
if excludeFiles[name] {
return true
}
return false
}
// walkFiles walks the repo and yields text file paths (relative to root).
func walkFiles(root string) ([]string, error) {
var files []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if shouldSkip(path, info) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
if !info.IsDir() && isTextFile(path) {
rel, _ := filepath.Rel(root, path)
rel = filepath.ToSlash(rel)
// skip excluded paths (scanner's own source)
for prefix := range excludedPaths {
if strings.HasPrefix(rel, prefix) {
return nil
}
}
files = append(files, rel)
}
return nil
})
return files, err
}
// scanFiles scans files against rules and returns deduplicated findings.
// When a line matches multiple rules, only the highest-severity rule is reported.
func scanFiles(root string, rules []scanRule) []Finding {
files, err := walkFiles(root)
if err != nil {
return []Finding{{ID: "ERR", Severity: "critical", Module: "scanner", Summary: fmt.Sprintf("walk error: %v", err)}}
}
// dedup by file+line, keeping the highest severity
sevRank := map[string]int{"critical": 4, "high": 3, "medium": 2, "low": 1}
seen := make(map[string]Finding) // key: "file:line"
for _, f := range files {
for _, rule := range rules {
if !ruleMatchesFile(f, rule.Globs) {
continue
}
for _, m := range scanFile(filepath.Join(root, f), rule) {
key := fmt.Sprintf("%s:%d", m.File, m.Line)
if prev, ok := seen[key]; !ok || sevRank[m.Severity] > sevRank[prev.Severity] {
seen[key] = m
}
}
}
}
var findings []Finding
for _, f := range seen {
findings = append(findings, f)
}
return findings
}
func ruleMatchesFile(file string, globs []string) bool {
if len(globs) == 0 {
return true
}
for _, g := range globs {
matched, _ := filepath.Match(g, filepath.Base(file))
if matched {
return true
}
}
return false
}
func scanFile(path string, rule scanRule) []Finding {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var findings []Finding
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
if rule.Pattern.MatchString(scanner.Text()) {
findings = append(findings, Finding{
ID: rule.ID,
Severity: rule.Severity,
Module: ruleMod(rule.ID),
File: path,
Line: lineNum,
Summary: rule.Summary,
})
}
}
return findings
}
func ruleMod(id string) string {
switch {
case strings.HasPrefix(id, "L-"):
return "license"
case strings.HasPrefix(id, "D-"):
return "deps"
case strings.HasPrefix(id, "S-"):
return "secrets"
case strings.HasPrefix(id, "P-"), strings.HasPrefix(id, "E-"):
return "exposure"
case strings.HasPrefix(id, "C-"):
return "vocab"
}
return "unknown"
}

View File

@ -8,21 +8,27 @@ import (
)
const (
DefaultBaseURL = "https://www.gitlink.org.cn/api"
DefaultFormat = "table"
DefaultBaseURL = "https://www.gitlink.org.cn/api"
DefaultGatewayBaseURL = "https://gateway.gitlink.org.cn/api"
DefaultFormat = "table"
// EnvGatewayBaseURL overrides GatewayBaseURL when set.
EnvGatewayBaseURL = "GITLINK_GATEWAY_URL"
)
type Config struct {
BaseURL string `yaml:"base_url"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
BaseURL string `yaml:"base_url"`
GatewayBaseURL string `yaml:"gateway_base_url,omitempty"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
}
func DefaultConfig() *Config {
return &Config{
BaseURL: DefaultBaseURL,
Format: DefaultFormat,
BaseURL: DefaultBaseURL,
GatewayBaseURL: DefaultGatewayBaseURL,
Format: DefaultFormat,
}
}
@ -53,6 +59,12 @@ func Load() (*Config, error) {
if cfg.BaseURL == "" {
cfg.BaseURL = DefaultBaseURL
}
if cfg.GatewayBaseURL == "" {
cfg.GatewayBaseURL = DefaultGatewayBaseURL
}
if v := os.Getenv(EnvGatewayBaseURL); v != "" {
cfg.GatewayBaseURL = v
}
if cfg.Format == "" {
cfg.Format = DefaultFormat
}
@ -79,6 +91,8 @@ func Get(key string) (string, error) {
switch key {
case "base_url":
return cfg.BaseURL, nil
case "gateway_base_url":
return cfg.GatewayBaseURL, nil
case "default_format":
return cfg.Format, nil
case "editor":
@ -98,6 +112,8 @@ func Set(key, value string) error {
switch key {
case "base_url":
cfg.BaseURL = value
case "gateway_base_url":
cfg.GatewayBaseURL = value
case "default_format":
cfg.Format = value
case "editor":

View File

@ -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
}

150
internal/errors/errors.go Normal file
View File

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

View File

@ -9,29 +9,48 @@ import (
"strings"
"text/tabwriter"
"golang.org/x/term"
"gopkg.in/yaml.v3"
)
// PrintOptions controls table output rendering behavior.
type PrintOptions struct {
Columns []string // column names to show (nil = all)
NoTruncate bool // disable 60-char truncation
UseColor bool // enable ANSI color headers
}
// Print outputs the envelope in the given format with default options.
func Print(envelope *Envelope, format string) error {
return PrintWithOpts(envelope, format, PrintOptions{})
}
// PrintWithOpts outputs the envelope with rendering options.
func PrintWithOpts(envelope *Envelope, format string, opts PrintOptions) error {
if format == "" {
format = "json"
}
return PrintTo(os.Stdout, envelope, format)
return printToOpts(os.Stdout, envelope, format, opts)
}
func PrintTo(w io.Writer, envelope *Envelope, format string) error {
func printToOpts(w io.Writer, envelope *Envelope, format string, opts PrintOptions) error {
switch format {
case "json":
return printJSON(w, envelope)
case "yaml":
return printYAML(w, envelope)
case "table":
return printTable(w, envelope)
return printTableOpts(w, envelope, opts)
default:
return printJSON(w, envelope)
}
}
// PrintTo outputs the envelope to the given writer (legacy, no options).
func PrintTo(w io.Writer, envelope *Envelope, format string) error {
return printToOpts(w, envelope, format, PrintOptions{})
}
func printJSON(w io.Writer, envelope *Envelope) error {
data, err := json.MarshalIndent(envelope, "", " ")
if err != nil {
@ -50,7 +69,7 @@ func printYAML(w io.Writer, envelope *Envelope) error {
return err
}
func printTable(w io.Writer, envelope *Envelope) error {
func printTableOpts(w io.Writer, envelope *Envelope, opts PrintOptions) error {
if !envelope.OK {
if envelope.Error != nil {
fmt.Fprintf(w, "Error: %s\n", envelope.Error.Message)
@ -66,22 +85,72 @@ func printTable(w io.Writer, envelope *Envelope) error {
return nil
}
// Try to render as table if data is a slice of maps
switch data := envelope.Data.(type) {
case []interface{}:
return printSliceTable(w, data)
return printSliceTableOpts(w, data, opts)
case map[string]interface{}:
// For maps with nested structures, prefer JSON
if unwrapped := unwrapSingleListField(data); unwrapped != nil {
return printSliceTableOpts(w, unwrapped, opts)
}
if hasComplexValues(data) {
return printJSON(w, envelope)
}
return printMapTable(w, data)
return printMapTableOpts(w, data, opts)
default:
// Fallback to JSON
return printJSON(w, envelope)
}
}
// printTable is kept for backward compatibility with existing callers.
func printTable(w io.Writer, envelope *Envelope) error {
return printTableOpts(w, envelope, PrintOptions{})
}
// unwrapSingleListField detects wrapper map structures like {"items":[...], "count":N}
// and returns the inner slice for list rendering.
func unwrapSingleListField(m map[string]interface{}) []interface{} {
knownListFields := []string{
"projects", "webhooks", "issues", "users", "pull_requests",
"builds", "releases", "branches", "teams", "members",
"orgs", "items", "records", "results", "wikis", "search",
}
for _, name := range knownListFields {
if s, ok := m[name].([]interface{}); ok {
if isSliceOfMaps(s) {
return s
}
}
}
// fallback: single slice-of-maps field
var listField string
var listValue []interface{}
for k, v := range m {
s, ok := v.([]interface{})
if !ok {
continue
}
if !isSliceOfMaps(s) {
continue
}
if listField != "" {
return nil // multiple list fields, can't auto-unwrap
}
listField = k
listValue = s
}
return listValue
}
func isSliceOfMaps(s []interface{}) bool {
if len(s) == 0 {
return true
}
_, ok := s[0].(map[string]interface{})
return ok
}
func hasComplexValues(m map[string]interface{}) bool {
for _, v := range m {
switch v.(type) {
@ -92,13 +161,12 @@ func hasComplexValues(m map[string]interface{}) bool {
return false
}
func printSliceTable(w io.Writer, items []interface{}) error {
func printSliceTableOpts(w io.Writer, items []interface{}, opts PrintOptions) error {
if len(items) == 0 {
fmt.Fprintln(w, "No results")
return nil
}
// Collect headers from first item
first, ok := items[0].(map[string]interface{})
if !ok {
data, _ := json.MarshalIndent(items, "", " ")
@ -107,10 +175,23 @@ func printSliceTable(w io.Writer, items []interface{}) error {
}
headers := collectKeys(first)
// apply --columns filter
if len(opts.Columns) > 0 {
headers = filterColumns(headers, opts.Columns)
}
useColor := opts.UseColor && isTerminal(w)
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
// Print headers
fmt.Fprintln(tw, strings.Join(headers, "\t"))
headerLine := strings.Join(headers, "\t")
if useColor {
headerLine = colorHeader(headerLine)
}
fmt.Fprintln(tw, headerLine)
dashes := make([]string, len(headers))
for i, h := range headers {
dashes[i] = strings.Repeat("-", len(h))
@ -125,26 +206,43 @@ func printSliceTable(w io.Writer, items []interface{}) error {
}
vals := make([]string, len(headers))
for i, h := range headers {
vals[i] = formatValue(m[h])
vals[i] = formatValueOpts(m[h], opts.NoTruncate)
}
fmt.Fprintln(tw, strings.Join(vals, "\t"))
}
return tw.Flush()
}
func printMapTable(w io.Writer, m map[string]interface{}) error {
func printMapTableOpts(w io.Writer, m map[string]interface{}, opts PrintOptions) error {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "KEY\tVALUE")
headerLine := "KEY\tVALUE"
if opts.UseColor && isTerminal(w) {
headerLine = colorHeader(headerLine)
}
fmt.Fprintln(tw, headerLine)
fmt.Fprintln(tw, "---\t-----")
for k, v := range m {
fmt.Fprintf(tw, "%s\t%s\n", k, formatValue(v))
fmt.Fprintf(tw, "%s\t%s\n", k, formatValueOpts(v, opts.NoTruncate))
}
return tw.Flush()
}
func filterColumns(all, wanted []string) []string {
wantedSet := make(map[string]bool, len(wanted))
for _, w := range wanted {
wantedSet[w] = true
}
result := make([]string, 0, len(wanted))
for _, h := range all {
if wantedSet[h] {
result = append(result, h)
}
}
return result
}
func collectKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
// Prefer common keys first
priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"}
seen := map[string]bool{}
for _, k := range priority {
@ -162,6 +260,10 @@ func collectKeys(m map[string]interface{}) []string {
}
func formatValue(v interface{}) string {
return formatValueOpts(v, false)
}
func formatValueOpts(v interface{}, noTruncate bool) string {
if v == nil {
return ""
}
@ -170,7 +272,7 @@ func formatValue(v interface{}) string {
case reflect.Map, reflect.Slice:
data, _ := json.Marshal(v)
s := string(data)
if len(s) > 60 {
if !noTruncate && len(s) > 60 {
return s[:57] + "..."
}
return s
@ -178,3 +280,21 @@ func formatValue(v interface{}) string {
return fmt.Sprintf("%v", v)
}
}
// --- color helpers ---
const (
ansiHeader = "\033[1;36m" // bold cyan
ansiReset = "\033[0m"
)
func colorHeader(s string) string {
return ansiHeader + s + ansiReset
}
func isTerminal(w io.Writer) bool {
if f, ok := w.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}

View File

@ -0,0 +1,81 @@
package output
import (
"bytes"
"strings"
"testing"
)
func TestPrintTable_WrappedEmptyList(t *testing.T) {
env := &Envelope{OK: true, Data: map[string]interface{}{
"count": 0,
"projects": []interface{}{},
}}
var buf bytes.Buffer
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo failed: %v", err)
}
got := buf.String()
if !strings.Contains(got, "No results") {
t.Errorf("expected 'No results', got: %q", got)
}
}
func TestPrintTable_WrappedList(t *testing.T) {
env := &Envelope{OK: true, Data: map[string]interface{}{
"count": 2,
"projects": []interface{}{
map[string]interface{}{"id": 1.0, "name": "alpha"},
map[string]interface{}{"id": 2.0, "name": "beta"},
},
}}
var buf bytes.Buffer
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo failed: %v", err)
}
got := buf.String()
if !strings.Contains(got, "alpha") || !strings.Contains(got, "beta") {
t.Errorf("expected alpha/beta in output, got: %q", got)
}
if !strings.Contains(got, "id") || !strings.Contains(got, "name") {
t.Errorf("expected header id/name, got: %q", got)
}
}
func TestUnwrapSingleListField_KnownName(t *testing.T) {
m := map[string]interface{}{
"count": 2.0,
"projects": []interface{}{map[string]interface{}{"id": 1.0}},
}
got := unwrapSingleListField(m)
if got == nil || len(got) != 1 {
t.Fatalf("expected slice len=1, got %v", got)
}
}
func TestUnwrapSingleListField_MultipleUnknownListsReturnsNil(t *testing.T) {
// 两个未知名字的 list 字段 — 无法自动选择,返回 nil
m := map[string]interface{}{
"foo_list": []interface{}{map[string]interface{}{"id": 1.0}},
"bar_list": []interface{}{map[string]interface{}{"id": 2.0}},
}
if got := unwrapSingleListField(m); got != nil {
t.Errorf("expected nil for multiple unknown list fields, got len=%d", len(got))
}
}
func TestUnwrapSingleListField_KnownNamePreferred(t *testing.T) {
// 已知 name 优先 — 即使有其他 list 字段也用 known name
m := map[string]interface{}{
"projects": []interface{}{map[string]interface{}{"id": 1.0}},
"users": []interface{}{map[string]interface{}{"id": 2.0}},
}
got := unwrapSingleListField(m)
if got == nil || len(got) != 1 {
t.Fatalf("expected projects slice len=1, got %v", got)
}
first := got[0].(map[string]interface{})
if first["id"] != 1.0 {
t.Errorf("expected projects[0].id=1, got %v", first["id"])
}
}

175
npm/bin/uninstall.js Normal file
View File

@ -0,0 +1,175 @@
#!/usr/bin/env node
/**
* GitLink CLI 卸载命令
* 用法: gitlink-cli-uninstall [--purge]
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
// 颜色输出
const colors = {
green: '\x1b[32m',
yellow: '\x1b[33m',
red: '\x1b[31m',
cyan: '\x1b[36m',
blue: '\x1b[34m',
reset: '\x1b[0m'
};
function info(msg) {
console.log(`${colors.green}[INFO]${colors.reset} ${msg}`);
}
function warn(msg) {
console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`);
}
function error(msg) {
console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`);
}
function step(msg) {
console.log(`${colors.cyan}[STEP]${colors.reset} ${msg}`);
}
function ask(msg) {
console.log(`${colors.blue}[ASK]${colors.reset} ${msg}`);
}
// ---------- 删除文件/目录 ----------
function removeSync(target) {
try {
if (fs.existsSync(target)) {
const stat = fs.statSync(target);
if (stat.isDirectory()) {
fs.rmdirSync(target, { recursive: true });
return true;
} else {
fs.unlinkSync(target);
return true;
}
}
return false;
} catch (err) {
return false;
}
}
// ---------- 删除Skills ----------
function removeSkills() {
step('删除 Skills...');
const skillsDir = path.join(os.homedir(), '.gitlink', 'skills');
if (removeSync(skillsDir)) {
info('Skills已删除');
} else {
warn('Skills目录不存在或删除失败');
}
}
// ---------- 删除配置 ----------
function removeConfig(purgeAll = false) {
if (!purgeAll) {
info('保留配置文件');
return;
}
step('删除配置文件...');
const configPaths = [
path.join(os.homedir(), '.gitlink-cli'),
path.join(os.homedir(), '.config', 'gitlink-cli'),
path.join(os.homedir(), '.gitlink')
];
let removedCount = 0;
for (const configPath of configPaths) {
if (removeSync(configPath)) {
info(`已删除: ${configPath}`);
removedCount++;
}
}
if (removedCount > 0) {
info('配置文件已删除');
}
}
// ---------- 主流程 ----------
function main() {
const args = process.argv.slice(2);
const purgeAll = args.includes('--purge') || args.includes('-p');
const help = args.includes('--help') || args.includes('-h');
if (help) {
console.log('');
console.log('GitLink CLI 卸载命令');
console.log('');
console.log('用法: gitlink-cli-uninstall [选项]');
console.log('');
console.log('选项:');
console.log(' --purge, -p 删除所有文件(包括配置)');
console.log(' --help, -h 显示此帮助');
console.log('');
console.log('示例:');
console.log(' gitlink-cli-uninstall # 保留配置');
console.log(' gitlink-cli-uninstall --purge # 完全删除');
console.log('');
process.exit(0);
}
console.log('');
console.log('========================================');
info('GitLink CLI 卸载');
console.log('========================================');
console.log('');
step('卸载npm包...');
try {
// 执行npm uninstall
if (process.platform === 'win32') {
execSync('npm uninstall -g @gitlink-ai/cli', { stdio: 'inherit' });
} else {
execSync('npm uninstall -g @gitlink-ai/cli', { stdio: 'inherit' });
}
} catch (err) {
warn('npm卸载命令执行失败请手动运行: npm uninstall -g @gitlink-ai/cli');
}
// 删除skills
removeSkills();
// 删除配置
removeConfig(purgeAll);
console.log('');
console.log('========================================');
info('卸载完成!');
console.log('========================================');
console.log('');
if (!purgeAll) {
info('以下文件可能需要手动清理:');
console.log(` - ${path.join(os.homedir(), '.gitlink-cli')}`);
console.log(` - ${path.join(os.homedir(), '.gitlink')}`);
console.log('');
info('如需删除,请运行: gitlink-cli-uninstall --purge');
}
console.log('');
info('感谢使用 GitLink CLI');
console.log('');
}
// 运行
try {
main();
} catch (err) {
error(`卸载失败: ${err.message}`);
process.exit(1);
}

View File

@ -1,13 +1,16 @@
{
"name": "@gitlink-ai/cli",
"version": "0.1.13",
"version": "0.2.0",
"description": "GitLink 平台官方命令行工具 — 代码托管、协作开发和自动化",
"bin": {
"gitlink-cli": "bin/cli.js",
"gitlink-cli-install-skills": "bin/install-skills.js"
"gitlink-cli-install-skills": "bin/install-skills.js",
"gitlink-cli-uninstall": "bin/uninstall.js"
},
"scripts": {
"postinstall": "node scripts/install.js",
"preuninstall": "node scripts/uninstall.js",
"uninstall": "node scripts/uninstall.js",
"test": "node test/install.test.js && node test/cli.test.js"
},
"keywords": [

162
npm/scripts/uninstall.js Normal file
View File

@ -0,0 +1,162 @@
#!/usr/bin/env node
/**
* GitLink CLI npm 卸载脚本
* npm preuninstall 钩子自动运行
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
// 颜色输出(支持跨平台)
const colors = {
green: '\x1b[32m',
yellow: '\x1b[33m',
red: '\x1b[31m',
cyan: '\x1b[36m',
blue: '\x1b[34m',
reset: '\x1b[0m'
};
function info(msg) {
console.log(`${colors.green}[INFO]${colors.reset} ${msg}`);
}
function warn(msg) {
console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`);
}
function error(msg) {
console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`);
}
function step(msg) {
console.log(`${colors.cyan}[STEP]${colors.reset} ${msg}`);
}
// ---------- 删除文件/目录 ----------
function removeSync(target) {
try {
if (fs.existsSync(target)) {
const stat = fs.statSync(target);
if (stat.isDirectory()) {
fs.rmdirSync(target, { recursive: true });
info(`已删除目录: ${target}`);
} else {
fs.unlinkSync(target);
info(`已删除文件: ${target}`);
}
return true;
}
return false;
} catch (err) {
warn(`删除失败: ${target} - ${err.message}`);
return false;
}
}
// ---------- 删除Skills ----------
function removeSkills() {
step('删除 Skills...');
const skillsDir = path.join(os.homedir(), '.gitlink', 'skills');
if (!fs.existsSync(skillsDir)) {
warn('Skills目录不存在');
return;
}
// 统计skills数量
let skillCount = 0;
try {
const items = fs.readdirSync(skillsDir);
skillCount = items.filter(item => {
const itemPath = path.join(skillsDir, item);
return fs.statSync(itemPath).isDirectory();
}).length;
} catch (err) {
// 忽略错误
}
info(`找到 ${skillCount} 个Skills`);
if (removeSync(skillsDir)) {
info('Skills已删除');
}
}
// ---------- 删除配置(可选)----------
function removeConfig(purgeAll = false) {
if (!purgeAll) {
// npm卸载通常不删除配置
info('保留配置文件(用户数据)');
return;
}
step('删除配置文件...');
const configPaths = [
path.join(os.homedir(), '.gitlink-cli'),
path.join(os.homedir(), '.config', 'gitlink-cli'),
path.join(os.homedir(), '.gitlink')
];
let removedCount = 0;
for (const configPath of configPaths) {
if (removeSync(configPath)) {
removedCount++;
}
}
if (removedCount > 0) {
info('配置文件已删除');
} else {
warn('未找到配置文件');
}
}
// ---------- 主流程 ----------
function main() {
console.log('');
console.log('========================================');
info('GitLink CLI npm 卸载');
console.log('========================================');
console.log('');
// 检查环境变量
const purgeAll = process.env.GITLINK_UNINSTALL_PURGE === 'true' || process.argv.includes('--purge');
step('开始清理npm安装的文件...');
// 删除skills
removeSkills();
// 删除配置(如果指定--purge
removeConfig(purgeAll);
console.log('');
console.log('========================================');
info('卸载完成!');
console.log('========================================');
console.log('');
if (!purgeAll) {
info('以下文件可能需要手动清理:');
console.log(` - ${path.join(os.homedir(), '.gitlink-cli')}`);
console.log(` - ${path.join(os.homedir(), '.gitlink')}`);
console.log('');
info('如需删除,请运行: npm uninstall -g @gitlink-ai/cli --purge');
}
console.log('');
info('感谢使用 GitLink CLI');
console.log('');
}
// 运行
try {
main();
} catch (err) {
error(`卸载失败: ${err.message}`);
process.exit(1);
}

429
npm/scripts/update.js Normal file
View File

@ -0,0 +1,429 @@
#!/usr/bin/env node
"use strict";
const os = require("os");
const path = require("path");
const fs = require("fs");
const https = require("https");
const http = require("http");
const { execSync } = require("child_process");
const PACKAGE = require("../package.json");
const VERSION = PACKAGE.version;
const BINARY_NAME = "gitlink-cli";
const RELEASE_BASE = "https://www.gitlink.org.cn";
const REPO_OWNER = "Gitlink";
const REPO_NAME = "gitlink-cli";
// 颜色输出
const colors = {
reset: "\x1b[0m",
green: "\x1b[32m",
yellow: "\x1b[33m",
red: "\x1b[31m",
cyan: "\x1b[36m",
blue: "\x1b[34m",
};
function info(msg) {
console.log(`${colors.green}[INFO]${colors.reset} ${msg}`);
}
function warn(msg) {
console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`);
}
function error(msg) {
console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`);
}
function step(msg) {
console.log(`${colors.cyan}[STEP]${colors.reset} ${msg}`);
}
function debug(msg) {
if (process.env.DEBUG === "true") {
console.log(`${colors.blue}[DEBUG]${colors.reset} ${msg}`);
}
}
function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap = {
x64: "amd64",
arm64: "arm64",
};
const goPlatform = platformMap[platform];
const goArch = archMap[arch];
if (!goPlatform || !goArch) {
throw new Error(
`Unsupported platform: ${platform}-${arch}. ` +
`Supported: darwin-x64, darwin-arm64, linux-x64, linux-arm64, win32-x64, win32-arm64`
);
}
return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" };
}
function getBinaryName(platform) {
return platform === "windows" ? `${BINARY_NAME}.exe` : BINARY_NAME;
}
function getArchiveName(platform, arch) {
const ext = platform === "windows" ? ".zip" : ".tar.gz";
return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${ext}`;
}
function fetch(url, options = {}) {
return new Promise((resolve, reject) => {
const maxRedirects = options.maxRedirects || 5;
let redirectCount = 0;
function doRequest(currentUrl) {
const mod = currentUrl.startsWith("https") ? https : http;
const req = mod.get(currentUrl, (res) => {
// Follow redirects
if (
(res.statusCode === 301 ||
res.statusCode === 302 ||
res.statusCode === 307 ||
res.statusCode === 308) &&
res.headers.location
) {
redirectCount++;
if (redirectCount > maxRedirects) {
reject(new Error(`Too many redirects (max ${maxRedirects})`));
return;
}
let redirectUrl = res.headers.location;
if (redirectUrl.startsWith("/")) {
const parsed = new URL(currentUrl);
redirectUrl = `${parsed.protocol}//${parsed.host}${redirectUrl}`;
}
doRequest(redirectUrl);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode} when downloading ${currentUrl}`));
return;
}
if (options.json) {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(e);
}
});
} else {
res.pipe(resolve);
}
});
req.on("error", reject);
req.setTimeout(options.timeout || 30000, () => {
req.destroy();
reject(new Error(`Request timeout: ${currentUrl}`));
});
}
doRequest(url);
});
}
// 下载文件(带重试)
async function downloadFile(url, outputPath, maxAttempts = 3) {
let attempt = 1;
while (attempt <= maxAttempts) {
try {
step(`下载 (尝试 ${attempt}/${maxAttempts}): ${path.basename(url)}`);
debug(`URL: ${url}`);
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(outputPath);
const mod = url.startsWith("https") ? https : http;
const req = mod.get(url, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
const totalSize = parseInt(res.headers["content-length"], 10);
let downloadedSize = 0;
res.on("data", (chunk) => {
downloadedSize += chunk.length;
if (totalSize) {
const progress = ((downloadedSize / totalSize) * 100).toFixed(1);
process.stdout.write(`\r下载进度: ${progress}%`);
}
});
res.pipe(file);
file.on("finish", () => {
file.close();
process.stdout.write("\r");
resolve();
});
file.on("error", (err) => {
fs.unlink(outputPath, () => {});
reject(err);
});
});
req.on("error", (err) => {
file.destroy();
fs.unlink(outputPath, () => {});
reject(err);
});
req.setTimeout(120000, () => {
req.destroy();
file.destroy();
fs.unlink(outputPath, () => {});
reject(new Error("下载超时"));
});
});
if (fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) {
const sizeMB = (fs.statSync(outputPath).size / (1024 * 1024)).toFixed(2);
info(`下载成功: ${path.basename(outputPath)} (${sizeMB}MB)`);
return true;
} else {
warn("下载文件为空");
}
} catch (err) {
warn(`下载失败: ${err.message}`);
}
if (attempt < maxAttempts) {
const waitTime = attempt * 2;
warn(`等待 ${waitTime}s 后重试...`);
await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
}
attempt++;
}
error("下载失败,已尝试 ${maxAttempts} 次");
return false;
}
// 获取最新版本
async function getLatestVersion() {
try {
step("检查更新...");
const releasesUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
const releases = await fetch(releasesUrl, { json: true, timeout: 30000 });
if (releases && releases.length > 0) {
return releases[0].tag_name;
}
} catch (err) {
debug(`获取版本失败: ${err.message}`);
}
return null;
}
// 检查是否有更新
async function checkForUpdate() {
try {
const latestVersion = await getLatestVersion();
if (!latestVersion) {
info("无法获取最新版本信息");
return;
}
const currentVersion = VERSION.startsWith("v") ? VERSION : `v${VERSION}`;
const latest = latestVersion.startsWith("v") ? latestVersion : `v${latestVersion}`;
info(`当前版本: ${currentVersion}`);
info(`最新版本: ${latest}`);
if (currentVersion === latest) {
info("已经是最新版本");
return;
}
// 简单的版本比较
if (latest > currentVersion) {
warn(`发现新版本: ${latest}`);
warn("运行 'npm update -g @gitlink-ai/cli' 更新");
} else if (latest < currentVersion) {
info("当前版本比最新发布版本更新(开发版本)");
}
} catch (err) {
debug(`检查更新失败: ${err.message}`);
}
}
// 安装二进制
async function installBinary() {
const platform = getPlatformInfo();
info(`平台: ${platform.platform}-${platform.arch}`);
const binaryName = getBinaryName(platform.platform);
const archiveName = getArchiveName(platform.platform, platform.arch);
const npmBinDir = path.dirname(process.execPath);
const installDir = path.join(npmBinDir, "..");
step(`安装二进制到: ${installDir}`);
const version = VERSION.startsWith("v") ? VERSION : `v${VERSION}`;
const binaryUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${archiveName}`;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlink-cli-"));
const archivePath = path.join(tmpDir, archiveName);
try {
// 下载
const success = await downloadFile(binaryUrl, archivePath);
if (!success) {
throw new Error("下载失败");
}
// 解压
step("解压...");
if (platform.isWindows) {
const AdmZip = require("adm-zip");
const zip = new AdmZip(archivePath);
zip.extractAllTo(installDir, true);
} else {
const tar = require("tar");
await tar.x({
file: archivePath,
cwd: installDir,
strip: 1,
});
}
// 设置执行权限Unix
if (!platform.isWindows) {
const binaryPath = path.join(installDir, binaryName);
if (fs.existsSync(binaryPath)) {
fs.chmodSync(binaryPath, "755");
}
}
info(`二进制安装成功: ${binaryName}`);
} finally {
// 清理临时文件
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
// 安装skills
async function installSkills() {
const version = VERSION.startsWith("v") ? VERSION : `v${VERSION}`;
const skillsArchive = `${BINARY_NAME}_${version}_skills.zip`;
const skillsUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${skillsArchive}`;
const skillsDir = path.join(os.homedir(), ".gitlink", "skills");
if (!fs.existsSync(skillsDir)) {
fs.mkdirSync(skillsDir, { recursive: true });
}
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlink-skills-"));
const archivePath = path.join(tmpDir, skillsArchive);
try {
step("安装 skills...");
const success = await downloadFile(skillsUrl, archivePath);
if (!success) {
warn("Skills 包下载失败(可稍后手动安装)");
return;
}
// 解压
const AdmZip = require("adm-zip");
const zip = new AdmZip(archivePath);
zip.extractAllTo(skillsDir, true);
info("Skills 安装成功");
} catch (err) {
warn(`Skills 安装失败: ${err.message}`);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
// 验证安装
function verifyInstallation() {
step("验证安装...";
try {
const result = execSync("gitlink-cli version", { encoding: "utf8" });
info(`安装成功! ${result.trim()}`);
return true;
} catch (err) {
warn("验证命令失败(可能需要重启终端)");
return false;
}
}
// 主函数
async function main() {
console.log("");
console.log(" ╔══════════════════════════════════════╗");
console.log(" ║ GitLink CLI npm 安装脚本 ║");
console.log(" ╚══════════════════════════════════════╝");
console.log("");
try {
await installBinary();
await installSkills();
verifyInstallation();
console.log("");
info("快速开始:");
info(" gitlink-cli auth login # 登录账号");
info(" gitlink-cli --help # 查看所有命令");
info(" gitlink-cli version # 查看版本信息");
console.log("");
} catch (err) {
error(`安装失败: ${err.message}`);
process.exit(1);
}
}
// 如果直接运行此脚本
if (require.main === module || process.argv[1].endsWith("update.js")) {
// 检查更新
if (process.argv.includes("--check")) {
(async () => {
await checkForUpdate();
})();
} else {
// 安装
(async () => {
await main();
})();
}
}
module.exports = {
main,
checkForUpdate,
installBinary,
installSkills,
};

View File

@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>科研知识图谱 — 2026-07-06</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
.header { background: linear-gradient(135deg, #1a237e 0%, #3949ab 100%); color: #fff; padding: 36px 30px; }
.header h1 { font-size: 26px; margin-bottom: 6px; }
.header .subtitle { opacity: 0.8; font-size: 14px; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
.card { background: #fff; border-radius: 12px; padding: 18px; box-shadow: 0 2px 8px rgba(0,0,0,.08); text-align: center; }
.card .value { font-size: 32px; font-weight: 700; color: #1a237e; }
.card .label { font-size: 12px; color: #888; margin-top: 4px; }
.panel { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); margin-bottom: 24px; }
.panel h2 { font-size: 18px; margin-bottom: 16px; color: #1a237e; border-bottom: 2px solid #3949ab; padding-bottom: 8px; }
#graphChart { width: 100%; height: 600px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
th { background: #f5f7fa; color: #555; font-weight: 600; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; margin: 2px; }
.tag.rising { background: #e8f5e9; color: #2e7d32; }
.tag.stable { background: #e3f2fd; color: #1565c0; }
.tag.declining { background: #fce4ec; color: #c62828; }
.footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>科研知识图谱</h1>
<div class="subtitle">
关键词LLM,Agent &mdash;
仓库4 个 &mdash;
贡献者0 人 &mdash;
2026-07-06
</div>
</div>
<div class="container">
<div class="cards">
<div class="card"><div class="value">4</div><div class="label">仓库节点</div></div>
<div class="card"><div class="value">0</div><div class="label">贡献者节点</div></div>
<div class="card"><div class="value">2</div><div class="label">主题节点</div></div>
<div class="card"><div class="value">3</div><div class="label">关系边</div></div>
<div class="card"><div class="value">N/A</div><div class="label">最热仓库</div></div>
</div>
<div class="panel">
<h2>知识图谱 — 力导向布局</h2>
<div id="graphChart"></div>
</div>
<div class="panel">
<h2>热度排行榜</h2>
<table id="hotnessTable">
<thead><tr><th>排名</th><th>仓库</th><th>热度</th><th>语言</th><th>Stars</th><th>趋势</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="footer">Generated by GitLink Research Assistant — 2026-07-06</div>
<script>
var graph = echarts.init(document.getElementById('graphChart'));
graph.setOption({
tooltip: {
formatter: function(p) {
if (p.dataType === 'edge') return p.data.source + ' → ' + p.data.target + '<br/>' + p.data.evidence;
var d = p.data;
return '<b>' + d.label + '</b><br/>' + (d.desc || '') + '<br/>' +
(d.stars ? 'Stars: ' + d.stars : '') + (d.repo_count ? ' 关联仓库: ' + d.repo_count : '');
}
},
legend: [{
data: ['仓库', '贡献者', '主题', '论文', '组织'],
orient: 'vertical', right: 10, top: 20
}],
series: [{
type: 'graph',
layout: 'force',
roam: true,
draggable: true,
force: {
repulsion: 200,
edgeLength: [80, 300],
layoutAnimation: true
},
categories: [
{ name: '仓库', itemStyle: { color: '#5470c6' }, symbol: 'roundRect' },
{ name: '贡献者', itemStyle: { color: '#91cc75' }, symbol: 'circle' },
{ name: '主题', itemStyle: { color: '#fac858' }, symbol: 'diamond' },
{ name: '论文', itemStyle: { color: '#ee6666' }, symbol: 'triangle' },
{ name: '组织', itemStyle: { color: '#73c0de' }, symbol: 'pin' }
],
data: [{"id":"topic:llm","type":"topic","label":"LLM\n","symbolSize":30,"category":2},{"id":"topic:agent","type":"topic","label":"Agent\n","symbolSize":30,"category":2}],
links: [{"source":"repo:agent","target":"repo:ribo-agent","type":"related_to","weight":0.5,"evidence":"共同主题: Agent\n"},{"source":"repo:doutrip","target":"repo:agent","type":"related_to","weight":0.5,"evidence":"共同主题: Agent\n"},{"source":"repo:ribo-agent","target":"repo:wow-agent","type":"related_to","weight":0.5,"evidence":"共同主题: Agent\n"}],
label: { show: true, fontSize: 11, formatter: '{b}' },
emphasis: { focus: 'adjacency', label: { fontSize: 14 } },
lineStyle: { color: '#ccc', curveness: 0.1 }
}]
});
window.addEventListener('resize', function() { graph.resize(); });
</script>
</body>
</html>

View File

@ -0,0 +1,51 @@
{
"metadata": {
"generated_at": "2026-07-06T11:51:35+08:00",
"search_keywords": [
"LLM",
"Agent"
],
"total_repos_scanned": 4,
"total_contributors_found": 0,
"total_edges_inferred": 3
},
"nodes": [
{
"id": "topic:llm",
"type": "topic",
"label": "LLM\n",
"symbolSize": 30,
"category": 2
},
{
"id": "topic:agent",
"type": "topic",
"label": "Agent\n",
"symbolSize": 30,
"category": 2
}
],
"edges": [
{
"source": "repo:agent",
"target": "repo:ribo-agent",
"type": "related_to",
"weight": 0.5,
"evidence": "共同主题: Agent\n"
},
{
"source": "repo:doutrip",
"target": "repo:agent",
"type": "related_to",
"weight": 0.5,
"evidence": "共同主题: Agent\n"
},
{
"source": "repo:ribo-agent",
"target": "repo:wow-agent",
"type": "related_to",
"weight": 0.5,
"evidence": "共同主题: Agent\n"
}
]
}

View File

@ -0,0 +1,170 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>zzx-coder/gitlink-cli — 复现性评分卡</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
.header { background: linear-gradient(135deg, #1a237e 0%, #3949ab 100%); color: #fff; padding: 40px 30px; }
.header h1 { font-size: 26px; margin-bottom: 6px; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; margin-bottom: 24px; }
.panel { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.panel h2 { font-size: 18px; margin-bottom: 16px; color: #1a237e; border-bottom: 2px solid #3949ab; padding-bottom: 8px; }
.chart { width: 100%; height: 350px; }
.grade-circle { text-align: center; padding: 20px; }
.grade-letter { font-size: 72px; font-weight: 900; }
.grade-A { color: #2e7d32; }
.grade-B { color: #558b2f; }
.grade-C { color: #f57c00; }
.grade-D { color: #e65100; }
.grade-F { color: #c62828; }
.grade-score { font-size: 24px; color: #888; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
th { background: #f5f7fa; color: #555; }
.bar { height: 8px; border-radius: 4px; background: #e0e0e0; margin-top: 4px; }
.bar-fill { height: 100%; border-radius: 4px; }
.footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
@media (max-width: 768px) { .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="header">
<h1>zzx-coder/gitlink-cli — 科研复现性评分卡</h1>
<div style="opacity:0.8;font-size:14px;">2026-07-06</div>
</div>
<div class="container">
<div class="row">
<div class="panel grade-circle">
<div class="grade-letter grade-F">F</div>
<div class="grade-score">5.0 / 100</div>
<div style="margin-top:12px;color:#888;">
差 — 几乎不可复现
</div>
</div>
<div class="panel">
<h2>维度雷达图</h2>
<div id="radarChart" class="chart"></div>
</div>
<div class="panel">
<h2>维度明细</h2>
<table>
<tr><th>维度</th><th>评分</th><th>权重</th></tr>
<tr><td>许可证</td><td>0%</td><td>15%</td></tr>
<tr><td>无密钥/PII</td><td>0%</td><td>15%</td></tr>
<tr><td>README 完整</td><td>0%</td><td>15%</td></tr>
<tr><td>依赖声明</td><td>0%</td><td>15%</td></tr>
<tr><td>构建说明</td><td>50%</td><td>10%</td></tr>
<tr><td>CI 配置</td><td>0%</td><td>10%</td></tr>
<tr><td>测试证据</td><td>0%</td><td>10%</td></tr>
<tr><td>数据可用性</td><td>0%</td><td>10%</td></tr>
</table>
</div>
</div>
<div class="panel">
<h2>详细评估与改进建议</h2>
<table>
<tr><th>维度</th><th>评分</th><th>证据</th><th>建议</th></tr>
<tr>
<td>许可证</td>
<td></td>
<td>未扫描(无本地仓库)</td>
<td>建议添加 MIT/Apache-2.0/GPL-3.0 许可证</td>
</tr>
<tr>
<td>无密钥/PII</td>
<td>⚠️</td>
<td>未扫描(无本地仓库)</td>
<td>立即移除泄露的密钥,使用环境变量管理敏感信息</td>
</tr>
<tr>
<td>README 完整</td>
<td></td>
<td>README 缺失或过于简略</td>
<td>补充项目目的、安装、使用、许可和引用章节</td>
</tr>
<tr>
<td>依赖声明</td>
<td></td>
<td>无依赖声明</td>
<td>添加 package.json/go.mod/requirements.txt 等标准依赖文件</td>
</tr>
<tr>
<td>构建说明</td>
<td>⚠️</td>
<td>部分构建说明</td>
<td>添加 Makefile/Dockerfile + README 中的构建步骤</td>
</tr>
<tr>
<td>CI 配置</td>
<td></td>
<td>无 CI 配置</td>
<td>配置 GitLink CI 或 GitHub Actions 自动构建和测试</td>
</tr>
<tr>
<td>测试证据</td>
<td></td>
<td>无测试证据</td>
<td>添加单元测试和集成测试,在 README 中说明如何运行</td>
</tr>
<tr>
<td>数据可用性</td>
<td></td>
<td>无数据可用性声明</td>
<td>说明数据集来源,提供 Zenodo/Figshare 链接或生成脚本</td>
</tr>
</table>
</div>
</div>
<div class="footer">Generated by GitLink Research Assistant — 2026-07-06</div>
<script>
var radarChart = echarts.init(document.getElementById('radarChart'));
radarChart.setOption({
radar: {
indicator: [
{ name: '许可证', max: 100 },
{ name: '无密钥', max: 100 },
{ name: 'README', max: 100 },
{ name: '依赖', max: 100 },
{ name: '构建', max: 100 },
{ name: 'CI', max: 100 },
{ name: '测试', max: 100 },
{ name: '数据', max: 100 }
],
center: ['50%', '55%'],
radius: '70%'
},
series: [{
type: 'radar',
data: [{
value: [
0,
0,
0,
0,
50,
0,
0,
0
],
name: '复现性',
areaStyle: { color: 'rgba(57,73,171,0.3)' },
lineStyle: { color: '#3949ab' }
}]
}]
});
</script>
</body>
</html>

View File

@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>zzx-coder/gitlink-cli — 科研项目洞察报告</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
.header { background: linear-gradient(135deg, #1a237e 0%, #283593 50%, #3949ab 100%); color: #fff; padding: 40px 30px; }
.header h1 { font-size: 28px; margin-bottom: 8px; }
.header .subtitle { opacity: 0.85; font-size: 14px; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.card .label { font-size: 12px; color: #888; text-transform: uppercase; margin-bottom: 6px; }
.card .value { font-size: 28px; font-weight: 700; }
.card .value.hot { color: #e53935; }
.card .value.warm { color: #f57c00; }
.card .value.cool { color: #1565c0; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
.panel { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.panel h2 { font-size: 18px; margin-bottom: 16px; color: #1a237e; border-bottom: 2px solid #3949ab; padding-bottom: 8px; }
.chart { width: 100%; height: 350px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 14px; }
th { background: #f5f7fa; color: #555; font-weight: 600; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; margin: 2px; }
.tag.lang { background: #e3f2fd; color: #1565c0; }
.tag.research { background: #e8f5e9; color: #2e7d32; }
.tag.warn { background: #fff3e0; color: #e65100; }
.footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
@media (max-width: 768px) { .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="header">
<h1>zzx-coder/gitlink-cli</h1>
<div class="subtitle">科研项目洞察报告 &mdash; 2026-07-06</div>
</div>
<div class="container">
<div class="cards">
<div class="card">
<div class="label">热度评分</div>
<div class="value hot">54.3</div>
<div class="label">Hot</div>
</div>
<div class="card">
<div class="label">Stars</div>
<div class="value">0</div>
</div>
<div class="card">
<div class="label">Forks</div>
<div class="value">0</div>
</div>
<div class="card">
<div class="label">贡献者</div>
<div class="value">3</div>
</div>
<div class="card">
<div class="label">开放 Issues</div>
<div class="value">44</div>
</div>
<div class="card">
<div class="label">PR 合并率</div>
<div class="value">50.0%</div>
</div>
</div>
<div class="row">
<div class="panel">
<h2>项目概况</h2>
<table>
<tr><th>项目名称</th><td>gitlink-cli</td></tr>
<tr><th>描述</th><td>No description</td></tr>
<tr><th>主要语言</th><td><span class="tag lang">Unknown</span></td></tr>
<tr><th>技术栈</th><td><span class="tag lang">Unknown</span></td></tr>
<tr><th>创建时间</th><td></td></tr>
<tr><th>最后更新</th><td> (365 天前)</td></tr>
<tr><th>科研特征</th><td></td></tr>
</table>
</div>
<div class="panel">
<h2>活动概览</h2>
<div id="activityChart" class="chart"></div>
</div>
</div>
<div class="row">
<div class="panel">
<h2>健康指标</h2>
<table>
<tr><th>指标</th><th>数值</th><th>状态</th></tr>
<tr><td>Issue 总量</td><td>44 开放 / 44 已关闭</td><td><span class="tag warn">需关注</span></td></tr>
<tr><td>PR 合并率</td><td>50.0%</td><td><span class="tag warn">需改进</span></td></tr>
<tr><td>Release 数</td><td>6</td><td><span class="tag research">已发布</span></td></tr>
<tr><td>CI 通过率</td><td>0% (0 次构建)</td><td><span class="tag warn">不稳定</span></td></tr>
<tr><td>贡献者数</td><td>3 人</td><td><span class="tag warn">单人项目</span></td></tr>
<tr><td>活跃度</td><td>365 天前更新</td><td><span class="tag warn">不活跃</span></td></tr>
</table>
</div>
<div class="panel">
<h2>热度构成</h2>
<div id="hotnessChart" class="chart"></div>
</div>
</div>
</div>
<div class="footer">Generated by GitLink Research Assistant &mdash; 2026-07-06</div>
<script>
var hotnessChart = echarts.init(document.getElementById('hotnessChart'));
hotnessChart.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [{
type: 'pie',
radius: ['45%', '75%'],
label: { formatter: '{b}\n{d}%' },
data: [
{ name: 'Stars', value: 0.0, itemStyle: { color: '#5470c6' } },
{ name: 'Forks', value: 0.0, itemStyle: { color: '#91cc75' } },
{ name: 'Issues', value: 88.0, itemStyle: { color: '#fac858' } },
{ name: 'PRs', value: 133.3, itemStyle: { color: '#ee6666' } },
{ name: 'Releases', value: 60.0, itemStyle: { color: '#73c0de' } },
{ name: 'Recency', value: 10, itemStyle: { color: '#fc8452' } }
]
}]
});
var activityChart = echarts.init(document.getElementById('activityChart'));
activityChart.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: ['Issues', 'PRs', 'Releases', 'CI Builds'] },
yAxis: { type: 'value' },
series: [
{ name: '开放/进行中', type: 'bar', data: [44, 20, 0, 0], itemStyle: { color: '#fac858' } },
{ name: '已完成', type: 'bar', data: [44, 20, 6, 0], itemStyle: { color: '#91cc75' } }
]
});
</script>
</body>
</html>

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "gitlink-cli",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@ -1 +0,0 @@
PR Test 2026年 4月 7日 星期二 11时45分56秒 CST

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

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

View File

@ -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)
},
@ -33,6 +33,15 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a branch",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
from := ctx.Arg("from")
if from == "" {
from = "master"
}
return fmt.Sprintf("Create branch: %s (from %s)", name, from), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
{Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"},
@ -41,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"
@ -52,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)
},
@ -60,6 +72,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "delete",
Description: "Delete a branch",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
return fmt.Sprintf("Delete branch: %s", name), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
},
@ -67,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)
},
@ -81,6 +101,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "protect",
Description: "Set branch protection",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
return fmt.Sprintf("Protect branch: %s", name), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
},
@ -88,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)
},
@ -102,6 +130,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "unprotect",
Description: "Remove branch protection",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
return fmt.Sprintf("Unprotect branch: %s", name), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
},
@ -109,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)
},
},

View File

@ -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)
},
@ -61,6 +64,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "restart",
Description: "Restart a build",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
build := ctx.Arg("build")
return fmt.Sprintf("Restart build #%s", build), nil
},
Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true},
},
@ -68,17 +76,25 @@ 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)
},
},
{
Name: "stop",
Description: "Stop a build",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
build := ctx.Arg("build")
return fmt.Sprintf("Stop build #%s", build), nil
},
Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true},
},
@ -86,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)
},
},

View File

@ -0,0 +1,68 @@
package common
import (
"errors"
"github.com/gitlink-org/gitlink-cli/internal/client"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
// TryPrintError 尝试把 error 转换为 envelope 格式并输出到 stdout。
// 返回 true 表示已识别并处理false 表示该 error 类型不被识别,应由调用方走原有路径。
//
// 设计意图:让 shortcut 的错误处理路径与 api 命令对齐,
// 使 `--format json` 输出可被 jq 解析的标准 envelope
//
// {"ok":false, "error":{"code":N, "message":"...", "suggestion":"..."}}
//
// 支持的 error 类型:
// - *client.APIError : HTTP 错误404/403/401 等code = HTTP 状态码
// - *clierrors.CLIError : 业务级错误(输入/认证/网络等code 由 kind 映射
//
// 注:放在 common 包(而非 output 包)以避免与 client 包形成导入循环。
func TryPrintError(err error, format string) bool {
if err == nil {
return false
}
var apiErr *client.APIError
if errors.As(err, &apiErr) {
env := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion)
_ = output.Print(env, format)
return true
}
var cliErr *clierrors.CLIError
if errors.As(err, &cliErr) {
env := output.ErrorEnvelope(kindToCode(cliErr.Kind), cliErr.Message, cliErr.Suggestion)
_ = output.Print(env, format)
return true
}
return false
}
// kindToCode 把 CLIError.Kind 映射到近似的 HTTP 状态码,用于 envelope.error.code 字段
func kindToCode(kind clierrors.ErrorKind) int {
switch kind {
case clierrors.KindAuth:
return 401
case clierrors.KindInput:
return 400
case clierrors.KindNotFound:
return 404
case clierrors.KindForbidden:
return 403
case clierrors.KindNetwork:
return 503
case clierrors.KindServer:
return 500
case clierrors.KindConfig:
return 500
case clierrors.KindGit:
return 500
default:
return 500
}
}

View File

@ -1,17 +1,52 @@
package common
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
)
// MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand.
func MountShortcut(parent *cobra.Command, s *Shortcut) {
cmd := &cobra.Command{
Use: "+" + s.Name,
Short: s.Description,
Use: "+" + s.Name,
Short: s.Description,
Long: s.Long,
Example: s.Example,
RunE: func(cmd *cobra.Command, args []string) error {
commandName := parent.Use + " +" + s.Name
// --- flag-level validation ---
for _, f := range s.Flags {
val := getFlagValue(cmd, f)
// choices enum validation
if len(f.Choices) > 0 && val != "" && val != "false" {
if !contains(f.Choices, val) {
return clierrors.InputError(
fmt.Sprintf("invalid value %q for --%s", val, f.Name),
fmt.Sprintf("有效值: %s。运行 'gitlink-cli %s --help' 查看用法。", strings.Join(f.Choices, ", "), commandName),
).WithCommand(commandName)
}
}
// custom validate function
if f.Validate != nil && val != "" {
if err := f.Validate(val); err != nil {
return clierrors.InputError(
fmt.Sprintf("invalid --%s: %v", f.Name, err),
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
).WithCommand(commandName)
}
}
}
// Collect flag values
flagValues := make(map[string]string)
for _, f := range s.Flags {
@ -26,36 +61,104 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
}
}
ctx, err := NewRuntimeContext(flagValues)
// cross-flag validation
if s.Validate != nil {
if err := s.Validate(flagValues); err != nil {
return clierrors.InputError(
err.Error(),
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
).WithCommand(commandName)
}
}
ctx, err := NewRuntimeContext(flagValues, commandName)
if err != nil {
return err
}
return s.Run(ctx)
// Dry-run interception
if s.DryRun {
if dryRunVal, _ := cmd.Flags().GetBool("dry-run"); dryRunVal {
flagValues["dry-run"] = "true"
if s.DryRunHint != nil {
hint, err := s.DryRunHint(ctx)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "[dry-run] %s\n", hint)
}
proceed, err := ConfirmAction(ctx)
if err != nil {
return err
}
if !proceed {
return nil
}
}
}
err = s.Run(ctx)
if err != nil {
// 当错误为已识别的 API/CLI 错误时,按 envelope 格式输出到 stdout
// 让 `--format json` 输出可被 jq 解析的标准结构。
// 已识别后返回 ErrSilent保留非零退出码但 cmd.Execute 不会再 stderr 重复输出。
if TryPrintError(err, ctx.Format) {
return cmdutil.ErrSilent
}
}
return err
},
}
for _, f := range s.Flags {
usage := f.Usage
if f.Required {
usage = usage + " [required]"
}
if len(f.Choices) > 0 {
usage = usage + " [" + strings.Join(f.Choices, "|") + "]"
}
if f.Bool {
defaultValue, _ := strconv.ParseBool(f.Default)
if f.Short != "" {
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)
}
}
// Auto-register --dry-run flag for shortcuts that support it
if s.DryRun {
cmd.Flags().Bool("dry-run", false, "Preview the operation without executing")
}
parent.AddCommand(cmd)
}
// getFlagValue returns the string value of a flag from a cobra command.
func getFlagValue(cmd *cobra.Command, f Flag) string {
if f.Bool {
val, _ := cmd.Flags().GetBool(f.Name)
return strconv.FormatBool(val)
}
val, _ := cmd.Flags().GetString(f.Name)
return val
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// MountShortcuts mounts multiple shortcuts under a parent command.
func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut) {
for _, s := range shortcuts {

View File

@ -1,13 +1,19 @@
package common
import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/config"
"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"
)
@ -15,7 +21,12 @@ import (
type Shortcut struct {
Name string
Description string
Long string // detailed help text (shown in --help)
Example string // usage examples (shown in --help)
Flags []Flag
DryRun bool // 是否支持 dry-run
DryRunHint func(ctx *RuntimeContext) (string, error) // 返回预览描述
Validate func(args map[string]string) error // cross-flag validation
Run func(ctx *RuntimeContext) error
}
@ -27,19 +38,27 @@ type Flag struct {
Required bool
Default string
Bool bool
Choices []string // allowed values (enum validation)
Validate func(value string) error // custom per-flag validation
}
// RuntimeContext provides helpers for shortcut implementations.
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
GatewayBaseURL string
GatewayHTTPClient *http.Client // optional; nil = use auth.NewHTTPClient (mainly for tests)
NoTruncate bool // --no-truncate: disable table column truncation
Columns string // --columns: comma-separated column filter
NoColor bool // --no-color: disable ANSI color output
}
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
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
@ -48,15 +67,26 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
format := cmdutil.Format
if format == "" {
format = "json"
format = "table"
}
gatewayBaseURL := config.DefaultGatewayBaseURL
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
gatewayBaseURL = cfg.GatewayBaseURL
}
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,
GatewayBaseURL: gatewayBaseURL,
GatewayHTTPClient: nil,
NoTruncate: cmdutil.NoTruncate,
Columns: cmdutil.Columns,
NoColor: cmdutil.NoColor,
}, nil
}
@ -88,12 +118,31 @@ func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.R
// Output prints the envelope in the configured format.
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
return output.Print(env, ctx.Format)
return output.PrintWithOpts(env, ctx.Format, ctx.printOpts())
}
// OutputData wraps data in a success envelope and prints it.
func (ctx *RuntimeContext) OutputData(data interface{}) error {
return output.Print(output.SuccessEnvelope(data, nil), ctx.Format)
return output.PrintWithOpts(output.SuccessEnvelope(data, nil), ctx.Format, ctx.printOpts())
}
// printOpts builds PrintOptions from the runtime context.
func (ctx *RuntimeContext) printOpts() output.PrintOptions {
opts := output.PrintOptions{
NoTruncate: ctx.NoTruncate,
UseColor: !ctx.NoColor,
}
if ctx.Columns != "" {
parts := strings.Split(ctx.Columns, ",")
opts.Columns = make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
opts.Columns = append(opts.Columns, p)
}
}
}
return opts
}
// RepoPath returns the API path prefix for the current owner/repo.
@ -109,11 +158,39 @@ 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
}
// IsDryRun checks the --dry-run flag.
func (ctx *RuntimeContext) IsDryRun() bool {
return ctx.Arg("dry-run") == "true"
}
// ConfirmAction prompts the user for confirmation when --dry-run is set.
func ConfirmAction(ctx *RuntimeContext) (bool, error) {
if !ctx.IsDryRun() {
return true, nil
}
fmt.Fprint(os.Stderr, "\nProceed? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "y" || answer == "yes" {
return true, nil
}
fmt.Fprintln(os.Stderr, "Aborted.")
return false, nil
}

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

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

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

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

View File

@ -1,47 +1,559 @@
package issue
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"encoding/csv" // CSV 文件解析,用于从文件读取 Issue 编号
"fmt" // 格式化输出,用于构建字符串和错误信息
"os" // 文件操作,用于打开 CSV 文件
"strconv" // 字符串和数字之间的转换
"strings" // 字符串处理,用于分割、修剪等操作
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/common" // 公共工具包,包含 Shortcut、RuntimeContext 等
)
const closedIssueStatusID = 5
// === 优先级常量 ===
// priorityLow: 低优先级
// priorityNormal: 普通优先级(默认)
// priorityHigh: 高优先级
// priorityUrgent: 紧急优先级
const (
priorityLow = 1
priorityNormal = 2
priorityHigh = 3
priorityUrgent = 4
)
type batchCloseResult struct {
Number string `json:"number" yaml:"number"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
// === 状态常量 ===
// statusNew: 新建
// statusInProgress: 进行中
// statusResolved: 已解决
// statusClosed: 已关闭
// statusRejected: 已拒绝
const (
statusNew = 1
statusInProgress = 2
statusResolved = 3
statusClosed = 5
statusRejected = 6
)
// === 类型常量Tracker===
// trackerBug: 缺陷
// trackerFeature: 功能
// trackerSupport: 支持
// trackerDoc: 文档
// trackerTest: 测试
// trackerDuplicate: 重复
// trackerQuestion: 疑问
const (
trackerBug = 1
trackerFeature = 2
trackerSupport = 3
trackerDoc = 4
trackerTest = 5
trackerDuplicate = 6
trackerQuestion = 7
)
// === 名称映射表 ===
// priorityNames: 优先级数字 ID → 英文名称
// statusNames: 状态数字 ID → 英文名称
// trackerNames: 类型数字 ID → 英文名称
// 作用:把数字 ID 转换成可读的英文名称,方便输出结果
var priorityNames = map[int]string{
priorityLow: "low",
priorityNormal: "normal",
priorityHigh: "high",
priorityUrgent: "urgent",
}
type batchCloseSummary struct {
Repository string `json:"repository" yaml:"repository"`
DryRun bool `json:"dry_run" yaml:"dry_run"`
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Results []batchCloseResult `json:"results" yaml:"results"`
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",
}
// === 标签 ID 映射 ===
// tagIDs: 中文标签名称 → GitLink 标签 ID
// 获取方式:从网页端 DevTools 抓包获取(修改标签 → 捕获 PATCH 请求体 → 获取 issue_tag_ids 值)
// 注意:这些 ID 是项目特定的,不同项目可能不同
var tagIDs = map[string]int{
"缺陷": 315526,
"功能": 315527,
"文档": 315533,
"重复": 315525,
"疑问": 315528,
"支持": 315529,
"任务": 315530,
"测试": 315534,
"协助": 315531,
"搁置": 315532,
}
// labelNames 返回所有已知的标签名称(逗号分隔)
// 参数: tags - 标签名称到 ID 的映射
// 返回: 所有标签名称的字符串,用逗号分隔
func labelNames(tags map[string]int) string {
var names []string
for name := range tags {
names = append(names, name)
}
return strings.Join(names, ", ")
}
// === 结果结构体 ===
// BatchResult 表示单个 Issue 的操作结果
type BatchResult struct {
Number string `json:"number" yaml:"number"` // Issue 编号
Action string `json:"action" yaml:"action"` // 操作类型close/set-status/set-priority/set-assignee/set-label
Status string `json:"status" yaml:"status"` // 操作状态planned预览/closed已关闭/failed失败
Error string `json:"error,omitempty" yaml:"error,omitempty"` // 错误信息(失败时)
}
// BatchSummary 表示批量操作的汇总报告
type BatchSummary struct {
Repository string `json:"repository" yaml:"repository"` // 仓库名称owner/repo
Action string `json:"action" yaml:"action"` // 操作类型
Value string `json:"value,omitempty" yaml:"value,omitempty"` // 操作目标值(如状态名、优先级名)
DryRun bool `json:"dry_run" yaml:"dry_run"` // 是否是预览模式
Total int `json:"total" yaml:"total"` // 总数量
Succeeded int `json:"succeeded" yaml:"succeeded"` // 成功数量
Failed int `json:"failed" yaml:"failed"` // 失败数量
Results []BatchResult `json:"results" yaml:"results"` // 所有操作结果列表
}
// === batch-close 命令:批量关闭 Issue ===
// newBatchCloseShortcut 创建 batch-close 命令
func newBatchCloseShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-close",
Description: "Close multiple issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "dry-run", Usage: "Preview the issues that would be closed without changing them", Bool: true, Default: "false"},
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchClose,
}
}
// runBatchClose 执行批量关闭操作
// 执行流程:
// 1. 解析仓库信息
// 2. 收集 Issue 编号(从 --numbers 参数或 CSV 文件)
// 3. 初始化 BatchSummary 汇总对象
// 4. 遍历每个 Issue 编号:
// - 如果是 dry-run直接标记为 planned
// - 否则调用 updateIssueField 更新状态为 closed
// 5. 输出汇总结果
func runBatchClose(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2收集 Issue 编号(支持 --numbers 参数和 --from CSV 文件)
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
// 步骤3初始化汇总对象
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "close",
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
// 步骤4遍历处理每个 Issue
for _, number := range numbers {
result := BatchResult{Number: number, Action: "close"}
if dryRun {
// 预览模式:不实际操作,只标记为 planned
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 实际操作:调用 updateIssueField 更新状态为 closed
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusClosed}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "closed"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
// 步骤5输出汇总结果
if err := ctx.OutputData(summary); err != nil {
return err
}
// 如果有失败的操作,返回错误
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
// === batch-status 命令:批量修改 Issue 状态 ===
// newBatchStatusShortcut 创建 batch-status 命令
func newBatchStatusShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-status",
Description: "Change status for multiple issues",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Target state: new, in-progress, resolved, closed, rejected", Required: true},
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchStatus,
}
}
// runBatchStatus 执行批量修改状态操作
// 参数 --state 指定目标状态new/in-progress/resolved/closed/rejected
func runBatchStatus(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取状态参数并转换为数字 ID
state := ctx.Arg("state")
statusID, err := parseStatus(state)
if err != nil {
return err
}
// 步骤3收集 Issue 编号
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
// 步骤4初始化汇总对象
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "set-status",
Value: state,
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
// 步骤5遍历处理每个 Issue
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-status"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 更新状态
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = state
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
// 步骤6输出汇总结果
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
// === batch-priority 命令:批量修改 Issue 优先级 ===
// newBatchPriorityShortcut 创建 batch-priority 命令
func newBatchPriorityShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-priority",
Description: "Change priority for multiple issues",
Flags: []common.Flag{
{Name: "priority", Short: "p", Usage: "Target priority: low, normal, high, urgent", Required: true},
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchPriority,
}
}
// runBatchPriority 执行批量修改优先级操作
// 参数 --priority 指定目标优先级low/normal/high/urgent
func runBatchPriority(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 获取优先级参数并转换为数字 ID
priority := ctx.Arg("priority")
priorityID, err := parsePriority(priority)
if err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "set-priority",
Value: priority,
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-priority"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 更新优先级
if err := updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = priority
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
// === batch-assign 命令:批量分配 Issue ===
// newBatchAssignShortcut 创建 batch-assign 命令
func newBatchAssignShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-assign",
Description: "Change assignee for multiple issues",
Flags: []common.Flag{
{Name: "assignee", Short: "a", Usage: "Assignee login name or user ID", Required: true},
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchAssign,
}
}
// runBatchAssign 执行批量分配操作
// 亮点:需要先把用户名转换成用户 ID通过 API 查询)
func runBatchAssign(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
assignee := ctx.Arg("assignee")
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "set-assignee",
Value: assignee,
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
// 非预览模式下,先解析用户 ID
var assigneeID interface{}
if !dryRun {
id, err := resolveUserID(ctx, assignee)
if err != nil {
return fmt.Errorf("cannot resolve assignee %q: %w", assignee, err)
}
assigneeID = id
}
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-assignee"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 分配用户
if err := updateIssueField(ctx, number, map[string]interface{}{"assigner_ids": []interface{}{assigneeID}}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "assigned"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
// === batch-label 命令:批量修改 Issue 标签 ===
// newBatchLabelShortcut 创建 batch-label 命令
func newBatchLabelShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-label",
Description: "Change tracker label for multiple issues",
Flags: []common.Flag{
{Name: "label", Short: "l", Usage: "Target label: bug, feature, support, doc, test, duplicate, question, or Chinese names (缺陷/功能/文档/重复/疑问/支持/任务/测试/协助/搁置)", Required: true},
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchLabel,
}
}
// runBatchLabel 执行批量修改标签操作
// 参数 --label 可以是英文bug/feature或中文缺陷/功能)
func runBatchLabel(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 获取标签参数并转换为数字 ID
label := ctx.Arg("label")
trackerID, err := parseTracker(label)
if err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "set-label",
Value: label,
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-label"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 修改标签issue_tag_ids 是数组)
if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{trackerID}}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = label
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
// === batch-destroy 命令:批量删除 Issue ===
// newBatchDestroyShortcut 创建 batch-destroy 命令
func newBatchDestroyShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-destroy",
Description: "Delete multiple issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchDestroy,
}
}
// runBatchDestroy 执行批量删除操作
// 使用 GitLink 原生批量删除接口 DELETE /v1/{owner}/{repo}/issues/batch_destroy
// body: {"ids": [1, 2, 3]}
func runBatchDestroy(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
@ -55,59 +567,214 @@ func runBatchClose(ctx *common.RuntimeContext) error {
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchCloseSummary{
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "destroy",
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchCloseResult, 0, len(numbers)),
Results: make([]BatchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchCloseResult{Number: number, Action: "close"}
if dryRun {
result.Status = "planned"
if dryRun {
for _, number := range numbers {
summary.Results = append(summary.Results, BatchResult{Number: number, Action: "destroy", Status: "planned"})
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
} else {
// 构建 ids 数组
ids := make([]int, 0, len(numbers))
for _, number := range numbers {
id, err := strconv.Atoi(number)
if err != nil {
return fmt.Errorf("invalid issue number %q: %w", number, err)
}
ids = append(ids, id)
}
if err := closeIssue(ctx, number); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
// 调用原生批量删除接口
body := map[string]interface{}{"ids": ids}
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/batch_destroy", v1RepoPath(ctx)), body); err != nil {
// 整体失败,标记所有为 failed
for _, number := range numbers {
summary.Results = append(summary.Results, BatchResult{Number: number, Action: "destroy", Status: "failed", Error: err.Error()})
summary.Failed++
}
} else {
result.Status = "closed"
summary.Succeeded++
for _, number := range numbers {
summary.Results = append(summary.Results, BatchResult{Number: number, Action: "destroy", Status: "deleted"})
summary.Succeeded++
}
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total)
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
func closeIssue(ctx *common.RuntimeContext, number string) error {
// === 共享辅助函数 ===
// updateIssueField 更新 Issue 的指定字段
// 关键点:
// 1. 先调用 fetchExistingIssue 获取当前 Issue 的标题和描述
// 2. 必须在请求体中包含 subject 和 description否则会被清空
// 3. 把要更新的字段合并到 body 中
// 4. 发送 PATCH 请求
func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error {
// 获取当前 Issue 的标题和描述(避免更新时丢失)
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("fetch issue: %w", err)
return fmt.Errorf("fetch issue #%s: %w", number, err)
}
// 构建请求体,先包含必要的标题和描述
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": closedIssueStatusID,
}
// 合并要更新的字段
for k, v := range fields {
body[k] = v
}
// 发送 PATCH 请求更新 Issue
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("close issue: %w", err)
return fmt.Errorf("update issue #%s: %w", number, err)
}
return nil
}
// resolveUserID 把用户名转换成用户 ID
// 工作原理:
// 1. 如果输入已经是数字,直接返回
// 2. 否则调用 /users/{login} API 获取用户信息
// 3. 从响应中提取 id 或 user_id 字段
// 4. API 返回的数字是 float64 类型,需要转换成 int
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
// 如果输入是数字,直接返回
if id, err := strconv.Atoi(login); err == nil {
return id, nil
}
// 调用 API 获取用户信息
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
if err != nil {
return nil, fmt.Errorf("lookup user %q: %w", login, err)
}
// 类型断言:把 Data 转换为 map[string]interface{}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response for user %q", login)
}
// 尝试提取 id 字段
idFloat, ok := data["id"].(float64)
if ok {
return int(idFloat), nil
}
// 尝试提取 user_id 字段
userIDFloat, ok := data["user_id"].(float64)
if ok {
return int(userIDFloat), nil
}
return nil, fmt.Errorf("cannot determine user ID for %q", login)
}
// parseStatus 把用户输入的状态字符串转换成数字 ID
// 支持多种写法in-progress、in_progress、inprogress
// 如果输入是数字,直接返回
func parseStatus(state string) (int, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "new":
return statusNew, nil
case "in-progress", "in_progress", "inprogress":
return statusInProgress, nil
case "resolved":
return statusResolved, nil
case "closed":
return statusClosed, nil
case "rejected":
return statusRejected, nil
default:
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid state %q: use new, in-progress, resolved, closed, or rejected", state)
}
}
// parsePriority 把用户输入的优先级字符串转换成数字 ID
func parsePriority(p string) (int, error) {
switch strings.ToLower(strings.TrimSpace(p)) {
case "low":
return priorityLow, nil
case "normal":
return priorityNormal, nil
case "high":
return priorityHigh, nil
case "urgent":
return priorityUrgent, nil
default:
if id, err := strconv.Atoi(p); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid priority %q: use low, normal, high, or urgent", p)
}
}
// parseTracker 把用户输入的标签字符串转换成数字 ID
// 支持中英文标签:
// - 中文:缺陷/功能/文档/重复/疑问/支持/任务/测试/协助/搁置
// - 英文bug/feature/support/doc/test/duplicate/question
func parseTracker(label string) (int, error) {
trimmed := strings.TrimSpace(label)
// 先检查中文标签名称
if id, ok := tagIDs[trimmed]; ok {
return id, nil
}
// 再检查英文标签名称
switch strings.ToLower(trimmed) {
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, question, or Chinese names (%s)", label, labelNames(tagIDs))
}
}
// parseLabel 根据项目的标签映射表,把标签名称转换成 GitLink 标签 ID
// 参数: name - 标签名称; tags - 项目的名称→ID 映射
func parseLabel(name string, tags map[string]int) (int, error) {
if id, ok := tags[name]; ok && id != 0 {
return id, nil
}
if id, err := strconv.Atoi(name); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid label %q: not found in project issue tags", name)
}
// collectIssueNumbers 从 --numbers 参数和 CSV 文件中收集 Issue 编号
// 参数: numbersValue - --numbers 参数的值; csvPath - CSV 文件路径
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := parseIssueNumbers(numbersValue)
if err != nil {
@ -124,6 +791,7 @@ func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
return mergeIssueNumbers(numbers, csvNumbers), nil
}
// parseIssueNumbers 解析逗号分隔的 Issue 编号字符串
func parseIssueNumbers(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
@ -131,59 +799,71 @@ func parseIssueNumbers(value string) ([]string, error) {
return normalizeIssueNumbers(strings.Split(value, ","))
}
// readIssueNumbersFromCSV 从 CSV 文件读取 Issue 编号
// 智能表头识别:
// - 自动识别 number、issue_number、project_issues_index 列
// - 如果没有匹配的表头,默认使用第一列
// - 跳过表头行,从第二行开始读取
func readIssueNumbersFromCSV(path string) ([]string, error) {
// 打开文件defer 确保函数返回前关闭文件)
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read issue numbers from CSV: %w", err)
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
// 创建 CSV 阅读器
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
reader.TrimLeadingSpace = true // 自动去除单元格前后空格
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse issue numbers from CSV: %w", err)
return nil, fmt.Errorf("parse CSV: %w", err)
}
if len(records) == 0 {
return nil, nil
}
// 智能识别表头:查找 number 列
numberColumn := -1
startRow := 0
for i, cell := range records[0] {
switch strings.ToLower(strings.TrimSpace(cell)) {
case "number", "issue_number", "project_issues_index":
numberColumn = i
startRow = 1
startRow = 1 // 找到表头,从第二行开始读取
}
}
if numberColumn == -1 {
numberColumn = 0
numberColumn = 0 // 没有找到表头,默认使用第一列
}
// 提取 Issue 编号
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if numberColumn >= len(record) {
continue
continue // 跳过列数不足的行
}
values = append(values, record[numberColumn])
}
return normalizeIssueNumbers(values)
}
// normalizeIssueNumbers 规范化 Issue 编号列表
// 功能:去重、验证格式、过滤空值
func normalizeIssueNumbers(values []string) ([]string, error) {
numbers := make([]string, 0, len(values))
seen := map[string]bool{}
seen := map[string]bool{} // 用于去重
for _, value := range values {
number := strings.TrimSpace(value)
if number == "" {
continue
continue // 跳过空值
}
// 验证是否是有效的整数
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number)
return nil, fmt.Errorf("invalid issue number %q: must be an integer", number)
}
if seen[number] {
continue
continue // 跳过重复值
}
seen[number] = true
numbers = append(numbers, number)
@ -191,6 +871,7 @@ func normalizeIssueNumbers(values []string) ([]string, error) {
return numbers, nil
}
// mergeIssueNumbers 合并多个 Issue 编号列表(去重)
func mergeIssueNumbers(values ...[]string) []string {
merged := []string{}
seen := map[string]bool{}
@ -206,6 +887,8 @@ func mergeIssueNumbers(values ...[]string) []string {
return merged
}
// parseBool 解析布尔值字符串
// 返回 true 的条件:字符串解析成功且值为 true
func parseBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed

View File

@ -0,0 +1,407 @@
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",
Description: "Create multiple issues from CLI flags or a CSV file",
Flags: []common.Flag{
{Name: "titles", Usage: "Comma-separated issue titles, e.g. 标题1,标题2"},
{Name: "priority", Short: "p", Usage: "Priority: low, normal, high, urgent (default: normal)"},
{Name: "label", Short: "l", Usage: "Label name, e.g. 缺陷"},
{Name: "assignee", Short: "a", Usage: "Assignee login name"},
{Name: "state", Short: "s", Usage: "Initial state: new, in-progress, resolved, closed, rejected (default: new)", Default: "new"},
{Name: "from", Usage: "CSV file path"},
{Name: "template", Short: "t", Usage: "Template: bug or feature (only with --from)"},
{Name: "dry-run", Usage: "Preview without creating issues", Bool: true, Default: "false"},
},
Run: runBatchCreate,
}
}
type createIssueInput struct {
Title string
Body string
Priority string
Label string
Assignee string
Status string
// template-specific fields
Version string
Severity string
Steps string
Expected string
Actual string
UserStory string
Acceptance string
}
func runBatchCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
tags, err := resolveIssueTags(ctx)
if err != nil {
return err
}
dryRun := parseBool(ctx.Arg("dry-run"))
template := strings.ToLower(strings.TrimSpace(ctx.Arg("template")))
// Collect inputs from --titles and/or --from
var inputs []createIssueInput
if titlesStr := ctx.Arg("titles"); titlesStr != "" {
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
}
if csvPath := ctx.Arg("from"); csvPath != "" {
csvInputs, err := readCreateInputsFromCSV(csvPath, template)
if err != nil {
return err
}
inputs = append(inputs, csvInputs...)
}
if len(inputs) == 0 {
return fmt.Errorf("no issue titles provided; use --titles 标题1,标题2 or --from issues.csv")
}
// Apply CLI --state as fallback for inputs without an explicit status
cliState := ctx.Arg("state")
for i := range inputs {
if inputs[i].Status == "" {
inputs[i].Status = cliState
}
}
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "create",
Value: template,
DryRun: dryRun,
Total: len(inputs),
Results: make([]BatchResult, 0, len(inputs)),
}
for i, input := range inputs {
label := fmt.Sprintf("#%d", i+1)
if input.Title != "" {
label = truncate(input.Title, 40)
}
result := BatchResult{Number: label, Action: "create"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
body := buildCreateBody(ctx, input, template, tags)
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "created"
if data, ok := env.Data.(map[string]interface{}); ok {
if num, ok := data["project_issues_index"]; ok {
result.Number = fmt.Sprintf("%v", num)
}
}
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed to create", summary.Failed, summary.Total)
}
return nil
}
func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, template string, tags map[string]int) map[string]interface{} {
statusID := statusNew
if input.Status != "" {
if sid, err := parseStatus(input.Status); err == nil {
statusID = sid
}
}
body := map[string]interface{}{
"subject": input.Title,
"status_id": statusID,
"priority_id": priorityNormal,
"done_ratio": 0,
}
if template != "" {
body["description"] = buildTemplateDescription(input, template)
if template == "bug" {
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
} else if template == "feature" {
body["issue_tag_ids"] = []interface{}{tags["功能"]}
}
} else if input.Body != "" {
body["description"] = input.Body
}
if input.Priority != "" {
if pid, err := parsePriority(input.Priority); err == nil {
body["priority_id"] = pid
}
}
if input.Label != "" {
if tid, err := parseLabel(input.Label, tags); err == nil {
body["issue_tag_ids"] = []interface{}{tid}
}
}
if input.Assignee != "" {
if id, err := resolveUserID(ctx, input.Assignee); err == nil {
body["assigner_ids"] = []interface{}{id}
}
}
return body
}
func buildTemplateDescription(input createIssueInput, template string) string {
switch template {
case "bug":
return buildBugDescription(input)
case "feature":
return buildFeatureDescription(input)
default:
return input.Body
}
}
func buildBugDescription(input createIssueInput) string {
var b strings.Builder
b.WriteString("## Bug 描述\n")
b.WriteString(input.Title)
b.WriteString("\n")
if input.Version != "" {
b.WriteString("\n## 版本\n")
b.WriteString(input.Version)
b.WriteString("\n")
}
if input.Severity != "" {
b.WriteString("\n## 严重程度\n")
b.WriteString(input.Severity)
b.WriteString("\n")
}
if input.Steps != "" {
b.WriteString("\n## 复现步骤\n")
b.WriteString(input.Steps)
b.WriteString("\n")
}
if input.Expected != "" {
b.WriteString("\n## 期望结果\n")
b.WriteString(input.Expected)
b.WriteString("\n")
}
if input.Actual != "" {
b.WriteString("\n## 实际结果\n")
b.WriteString(input.Actual)
b.WriteString("\n")
}
return b.String()
}
func buildFeatureDescription(input createIssueInput) string {
var b strings.Builder
b.WriteString("## 用户故事\n")
if input.UserStory != "" {
b.WriteString(input.UserStory)
} else {
b.WriteString(input.Title)
}
b.WriteString("\n")
if input.Body != "" {
b.WriteString("\n## 描述\n")
b.WriteString(input.Body)
b.WriteString("\n")
}
if input.Acceptance != "" {
b.WriteString("\n## 验收标准\n")
b.WriteString(input.Acceptance)
b.WriteString("\n")
}
if input.Priority != "" {
b.WriteString("\n## 优先级\n")
b.WriteString(input.Priority)
b.WriteString("\n")
}
return b.String()
}
func readCreateInputsFromCSV(path string, template string) ([]createIssueInput, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
}
header := records[0]
col := make(map[string]int)
for i, h := range header {
col[normalizeHeader(h)] = i
}
if _, ok := col["title"]; !ok {
return nil, fmt.Errorf("CSV must have a 'title' column")
}
var inputs []createIssueInput
for _, record := range records[1:] {
input := createIssueInput{
Title: getCol(record, col, "title"),
Body: getCol(record, col, "body"),
Priority: getCol(record, col, "priority"),
Label: getCol(record, col, "label"),
Assignee: getCol(record, col, "assignee"),
Status: getCol(record, col, "status"),
Version: getCol(record, col, "version"),
Severity: getCol(record, col, "severity"),
Steps: getCol(record, col, "steps"),
Expected: getCol(record, col, "expected"),
Actual: getCol(record, col, "actual"),
// Support alternate heading for feature template
UserStory: getCol(record, col, "user_story"),
Acceptance: getCol(record, col, "acceptance"),
}
if input.UserStory == "" {
input.UserStory = getCol(record, col, "user story")
}
if input.Title == "" {
continue
}
inputs = append(inputs, input)
}
return inputs, nil
}
func parseTitles(titlesStr string, ctx *common.RuntimeContext) []createIssueInput {
parts := strings.Split(titlesStr, ",")
inputs := make([]createIssueInput, 0, len(parts))
for _, title := range parts {
title = strings.TrimSpace(title)
if title == "" {
continue
}
inputs = append(inputs, createIssueInput{
Title: title,
Priority: ctx.Arg("priority"),
Label: ctx.Arg("label"),
Assignee: ctx.Arg("assignee"),
Status: ctx.Arg("state"),
})
}
return inputs
}
func normalizeHeader(h string) string {
return strings.ToLower(strings.TrimSpace(h))
}
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 ""
}
func truncate(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "..."
}

View File

@ -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)
}
}

View File

@ -6,6 +6,7 @@ import (
"strconv"
"strings"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
@ -22,11 +23,22 @@ type existingIssue struct {
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchStatusShortcut(),
newBatchPriorityShortcut(),
newBatchAssignShortcut(),
newBatchLabelShortcut(),
newBatchCreateShortcut(),
newBatchDestroyShortcut(),
newLabelAddShortcut(),
newLabelRemoveShortcut(),
newLabelListShortcut(),
{
Name: "list",
Description: "List issues",
Long: "List issues in a repository with optional filtering by state and pagination.",
Example: " gitlink-cli issue +list --state open\n gitlink-cli issue +list --state closed --page 1 --limit 50\n gitlink-cli issue +list --columns id,subject,status,priority --state all",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
{Name: "state", Short: "s", Usage: "Filter by state", Default: "open", Choices: []string{"open", "closed", "all"}},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
@ -42,7 +54,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -50,6 +62,13 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a new issue",
Long: "Create a new issue in the repository. Requires --title. Supports --body, --assignee, --milestone, and --label.",
Example: " gitlink-cli issue +create --title \"Bug: login crash\" --body \"Steps to reproduce...\"\n gitlink-cli issue +create --title \"Feature request\" --assignee zhangsan --label 3",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
return fmt.Sprintf("Create issue: %s", title), nil
},
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
{Name: "body", Short: "b", Usage: "Issue description"},
@ -61,7 +80,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
}
@ -75,14 +94,14 @@ func Shortcuts() []*common.Shortcut {
body["description"] = desc
}
if a := ctx.Arg("assignee"); a != "" {
body["assigned_to_id"] = a
body["assigner_ids"] = []interface{}{a}
}
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "create", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -90,6 +109,7 @@ func Shortcuts() []*common.Shortcut {
{
Name: "view",
Description: "View issue details",
Example: " gitlink-cli issue +view --number 42",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
@ -97,13 +117,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 clierrors.OpError(clierrors.KindNotFound, "view", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -111,6 +131,12 @@ func Shortcuts() []*common.Shortcut {
{
Name: "close",
Description: "Close an issue",
Example: " gitlink-cli issue +close --number 42\n gitlink-cli issue +close --number 42 --dry-run",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
number := ctx.Arg("number")
return fmt.Sprintf("Close issue #%s", number), nil
},
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
@ -118,7 +144,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
}
@ -133,6 +159,37 @@ func Shortcuts() []*common.Shortcut {
"status_id": 5, // 5 = closed
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "close", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "reopen",
Description: "Reopen a closed issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return err
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": 1, // 1 = open
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
}
@ -142,17 +199,23 @@ func Shortcuts() []*common.Shortcut {
{
Name: "update",
Description: "Update an issue",
Example: " gitlink-cli issue +update --number 42 --title \"Updated title\"\n gitlink-cli issue +update --number 42 --state closed",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
number := ctx.Arg("number")
return fmt.Sprintf("Update issue #%s", number), nil
},
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "title", Short: "t", Usage: "New title"},
{Name: "body", Short: "b", Usage: "New description"},
{Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"},
{Name: "state", Short: "s", Usage: "New state", Validate: validateIssueState},
},
Run: func(ctx *common.RuntimeContext) error {
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
}
@ -160,7 +223,10 @@ func Shortcuts() []*common.Shortcut {
description := ctx.Arg("body")
state := ctx.Arg("state")
if title == "" && description == "" && state == "" {
return fmt.Errorf("at least one of --title, --body, or --state is required")
return clierrors.InputError(
"at least one of --title, --body, or --state is required",
"至少需要提供 --title、--body 或 --state 中的一个参数",
).WithCommand(ctx.CommandName)
}
current, err := fetchExistingIssue(ctx, number)
@ -179,7 +245,7 @@ func Shortcuts() []*common.Shortcut {
body["description"] = b
}
if s := ctx.Arg("state"); s != "" {
statusID, err := normalizeIssueStatus(s)
statusID, err := issueStateToStatusID(s)
if err != nil {
return err
}
@ -187,7 +253,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 clierrors.OpError(clierrors.KindServer, "update", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -195,6 +261,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "comment",
Description: "Add a comment to an issue",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
number := ctx.Arg("number")
return fmt.Sprintf("Add comment to issue #%s", number), nil
},
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
@ -203,11 +274,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
}
@ -216,8 +287,176 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "comment", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
// === 元数据查询 ===
{
Name: "statuses",
Description: "List issue statuses",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_statues", nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issue statuses", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "authors",
Description: "List issue authors",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if kw := ctx.Arg("keyword"); kw != "" {
q.Set("keyword", kw)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_authors", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issue authors", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "assigners",
Description: "List issue assignees",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if kw := ctx.Arg("keyword"); kw != "" {
q.Set("keyword", kw)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_assigners", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issue assignees", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "priorities",
Description: "List issue priorities",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_priorities", nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issue priorities", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
// === 评论管理 ===
{
Name: "comment-edit",
Description: "Edit an issue comment",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
{Name: "body", Short: "b", Usage: "New comment body", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
body, err := ctx.RequireArg("body", `--body "updated comment"`)
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
"attachment_ids": []int{},
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, commentID), payload)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "edit", "comment", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "comment-delete",
Description: "Delete an issue comment",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除 Issue #%s 的评论 #%s", ctx.Arg("number"), ctx.Arg("comment-id")), nil
},
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, commentID), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "delete", "comment", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "replies",
Description: "List replies to a comment",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Parent comment ID", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/issues/%s/journals/%s/children_journals", v1RepoPath(ctx), number, commentID), q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "replies", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
@ -227,15 +466,15 @@ func Shortcuts() []*common.Shortcut {
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return nil, err
return nil, clierrors.OpError(clierrors.KindNotFound, "view", "issue", err).WithCommand(ctx.CommandName)
}
issueData, ok := getEnv.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("failed to parse issue data")
return nil, clierrors.InputError("failed to parse issue data", "API 返回格式异常,请稍后重试").WithCommand(ctx.CommandName)
}
subject, _ := issueData["subject"].(string)
if subject == "" {
return nil, fmt.Errorf("failed to parse issue subject")
return nil, clierrors.InputError("failed to parse issue subject", "API 返回数据中缺少 subject 字段,请稍后重试").WithCommand(ctx.CommandName)
}
description, _ := issueData["description"].(string)
return &existingIssue{
@ -244,7 +483,18 @@ func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIss
}, nil
}
func normalizeIssueStatus(state string) (interface{}, error) {
func validateIssueState(state string) error {
state = strings.ToLower(strings.TrimSpace(state))
if state == "open" || state == "closed" {
return nil
}
if _, err := strconv.Atoi(state); err == nil {
return nil
}
return fmt.Errorf("must be \"open\", \"closed\", or a numeric status_id, got %q", state)
}
func issueStateToStatusID(state string) (interface{}, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open":
return 1, nil
@ -254,6 +504,6 @@ func normalizeIssueStatus(state string) (interface{}, error) {
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
return nil, fmt.Errorf("invalid state: %s", state)
}
}

View File

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

92
shortcuts/issue/label.go Normal file
View File

@ -0,0 +1,92 @@
package issue
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func newLabelAddShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "label-add",
Description: "Add labels to an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
{Name: "labels", Short: "l", Usage: "Comma-separated label names or IDs", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
labelsStr, err := ctx.RequireArg("labels", `--labels "bug,urgent"`)
if err != nil {
return err
}
body := map[string]interface{}{
"labels": labelsStr,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), body)
if err != nil {
return fmt.Errorf("添加标签失败: %w", err)
}
return ctx.Output(env)
},
}
}
func newLabelRemoveShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "label-remove",
Description: "Remove a label from an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
{Name: "label", Short: "l", Usage: "Label ID to remove", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
label, err := ctx.RequireArg("label", "--label bug")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/labels/%s", v1RepoPath(ctx), number, label), nil)
if err != nil {
return fmt.Errorf("删除标签失败: %w", err)
}
return ctx.Output(env)
},
}
}
func newLabelListShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "label-list",
Description: "List labels on an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), nil)
if err != nil {
return fmt.Errorf("获取标签列表失败: %w", err)
}
return ctx.Output(env)
},
}
}

View File

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

View File

@ -0,0 +1,264 @@
package milestone
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// === list ===
func TestListCallsMilestonesEndpoint(t *testing.T) {
var requestedPath string
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"total_count": float64(2),
"opening_milestone_count": float64(1),
"closed_milestone_count": float64(1),
"milestones": []interface{}{},
})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("list failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/milestones.json")
}
func TestListPassesFilters(t *testing.T) {
var cat, kw, sortBy string
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
cat = r.URL.Query().Get("category")
kw = r.URL.Query().Get("keyword")
sortBy = r.URL.Query().Get("sort_by")
writeJSON(t, w, map[string]interface{}{"milestones": []interface{}{}})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{
"category": "opening",
"keyword": "v1",
"sort-by": "issues_count",
})
if err != nil {
t.Fatalf("list with filters failed: %v", err)
}
assertEqual(t, cat, "opening")
assertEqual(t, kw, "v1")
assertEqual(t, sortBy, "issues_count")
}
// === create ===
func TestCreateSendsPayload(t *testing.T) {
var payload map[string]interface{}
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "success"})
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{
"name": "v1.0",
"description": "First release",
"date": "2026-12-31",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
assertEqual(t, payload["name"], "v1.0")
assertEqual(t, payload["description"], "First release")
assertEqual(t, payload["effective_date"], "2026-12-31")
}
func TestCreateRequiresName(t *testing.T) {
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{
"description": "desc", "date": "2026-12-31",
})
if err == nil {
t.Fatal("expected error for missing --name")
}
}
// === view ===
func TestViewCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"milestone": map[string]interface{}{"id": float64(1), "name": "v1.0"},
})
})
defer server.Close()
err := runShortcut(t, server, "view", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("view failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/milestones/1.json")
}
// === update ===
func TestUpdateAutoFetchesExisting(t *testing.T) {
var updatePayload map[string]interface{}
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
writeJSON(t, w, map[string]interface{}{
"milestone": map[string]interface{}{
"name": "v1.0",
"description": "Old desc",
"effective_date": "2026-12-31",
},
})
case r.Method == "PATCH":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(0)})
default:
t.Fatalf("unexpected: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "update", map[string]string{
"id": "1",
"name": "v1.0-rc1",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
assertEqual(t, updatePayload["name"], "v1.0-rc1")
assertEqual(t, updatePayload["description"], "Old desc")
assertEqual(t, updatePayload["effective_date"], "2026-12-31")
}
// === delete ===
func TestDeleteAutoFetchesExisting(t *testing.T) {
var deletePath string
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
writeJSON(t, w, map[string]interface{}{
"milestone": map[string]interface{}{
"name": "v1.0",
"description": "desc",
"effective_date": "2026-12-31",
},
})
case r.Method == "DELETE":
deletePath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": float64(0)})
default:
t.Fatalf("unexpected: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
assertPath(t, deletePath, "/v1/owner/repo/milestones/1.json")
}
// === status ===
func TestStatusCallsUpdateStatusEndpoint(t *testing.T) {
var requestedPath string
var payload map[string]interface{}
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(0)})
})
defer server.Close()
err := runShortcut(t, server, "status", map[string]string{
"id": "1",
"status": "closed",
})
if err != nil {
t.Fatalf("status failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/milestones/1/update_status.json")
assertEqual(t, payload["status"], "closed")
}
// === helpers ===
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("milestone shortcut %q not found", name)
return nil
}
func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode JSON failed: %v", err)
}
return payload
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("write JSON failed: %v", err)
}
}
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func assertPath(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Fatalf("path: got %s, want %s", got, want)
}
}

147
shortcuts/org/batch.go Normal file
View File

@ -0,0 +1,147 @@
package org
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// BatchShortcuts 返回组织级批量成员管理 Shortcut
func BatchShortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "batch-invite",
Description: "Batch invite members to a project (from --users list or --from CSV file)",
Flags: []common.Flag{
{Name: "users", Usage: "Comma-separated list of user IDs to invite"},
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
},
Run: runOrgBatchInvite,
},
{
Name: "batch-remove",
Description: "Batch remove members from a project (from --users list or --from CSV file)",
Flags: []common.Flag{
{Name: "users", Usage: "Comma-separated list of user IDs to remove"},
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
},
Run: runOrgBatchRemove,
},
}
}
// runOrgBatchInvite 组织级批量邀请
func runOrgBatchInvite(ctx *common.RuntimeContext) error {
userIDs, err := parseBatchUserIDs(ctx)
if err != nil {
return err
}
dryRun := ctx.Arg("dry-run") == "true"
return inviteToOrgProjects(ctx, userIDs, dryRun)
}
// runOrgBatchRemove 组织级批量移除
func runOrgBatchRemove(ctx *common.RuntimeContext) error {
userIDs, err := parseBatchUserIDs(ctx)
if err != nil {
return err
}
dryRun := ctx.Arg("dry-run") == "true"
return removeFromOrgProjects(ctx, userIDs, dryRun)
}
// parseBatchUserIDs 从 --users 或 --from CSV 解析用户 ID 列表
func parseBatchUserIDs(ctx *common.RuntimeContext) ([]int, error) {
usersStr := ctx.Arg("users")
csvFile := ctx.Arg("from")
if usersStr == "" && csvFile == "" {
return nil, fmt.Errorf("must specify --users (comma-separated user IDs) or --from (CSV file path)")
}
var userIDs []int
if usersStr != "" {
ids, err := parseUserIDList(usersStr)
if err != nil {
return nil, err
}
userIDs = append(userIDs, ids...)
}
if csvFile != "" {
ids, err := parseCSVUserIDs(csvFile)
if err != nil {
return nil, err
}
userIDs = append(userIDs, ids...)
}
if len(userIDs) == 0 {
return nil, fmt.Errorf("no valid user IDs found")
}
return userIDs, nil
}
// parseCSVUserIDs 从 CSV 文件读取 user_id 列
func parseCSVUserIDs(filePath string) ([]int, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open CSV file %s: %w", filePath, err)
}
defer f.Close()
reader := csv.NewReader(f)
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("failed to read CSV file: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV file must have a header row and at least one data row")
}
// 查找 user_id 列
header := records[0]
colIdx := -1
for i, h := range header {
if strings.TrimSpace(h) == "user_id" {
colIdx = i
break
}
}
if colIdx == -1 {
return nil, fmt.Errorf("CSV file must have a 'user_id' column")
}
var ids []int
for _, row := range records[1:] {
if len(row) <= colIdx {
continue
}
s := strings.TrimSpace(row[colIdx])
if s == "" {
continue
}
uid, err := strconv.Atoi(s)
if err != nil {
return nil, fmt.Errorf("invalid user ID in CSV: %s", s)
}
ids = append(ids, uid)
}
return ids, nil
}

View File

@ -3,12 +3,14 @@ package org
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
shortcuts := []*common.Shortcut{
{
Name: "list",
Description: "List organizations",
@ -22,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)
},
@ -34,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)
},
},
@ -51,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)
},
@ -65,12 +73,20 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create an organization",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
return fmt.Sprintf("Create organization: %s", name), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Organization name", Required: true},
{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,
}
@ -79,10 +95,235 @@ 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)
},
},
{
Name: "update",
Description: "Update an organization",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新组织 #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
{Name: "name", Short: "n", Usage: "New name"},
{Name: "description", Short: "d", Usage: "New description"},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id", "--id my-org")
if err != nil {
return err
}
body := map[string]interface{}{}
if n := ctx.Arg("name"); n != "" {
body["name"] = n
}
if d := ctx.Arg("description"); d != "" {
body["description"] = d
}
if len(body) == 0 {
return fmt.Errorf("at least one of --name, --description is required")
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/organizations/%s", id), body)
if err != nil {
return fmt.Errorf("更新组织失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete an organization",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除组织 #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id", "--id my-org")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s", id), nil)
if err != nil {
return fmt.Errorf("删除组织失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "invite",
Description: "Invite a member to a project",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
userID := ctx.Arg("user-id")
owner := ctx.Arg("owner")
repo := ctx.Arg("repo")
return fmt.Sprintf("Invite user %s to %s/%s", userID, owner, repo), nil
},
Flags: []common.Flag{
{Name: "user-id", Usage: "User ID to invite (required)", Required: true},
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
userID, err := ctx.RequireArg("user-id", "--user-id 42")
if err != nil {
return err
}
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("invalid user-id: %s (must be an integer)", userID)
}
return inviteToOrgProjects(ctx, []int{uid}, false)
},
},
{
Name: "remove-member",
Description: "Remove a member from a project",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
userID := ctx.Arg("user-id")
owner := ctx.Arg("owner")
repo := ctx.Arg("repo")
return fmt.Sprintf("Remove user %s from %s/%s", userID, owner, repo), nil
},
Flags: []common.Flag{
{Name: "user-id", Usage: "User ID to remove (required)", Required: true},
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
userID, err := ctx.RequireArg("user-id", "--user-id 42")
if err != nil {
return err
}
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("invalid user-id: %s (must be an integer)", userID)
}
return removeFromOrgProjects(ctx, []int{uid}, false)
},
},
}
// 合并批量成员管理命令
shortcuts = append(shortcuts, BatchShortcuts()...)
return shortcuts
}
// inviteToOrgProjects 向指定项目邀请用户
func inviteToOrgProjects(ctx *common.RuntimeContext, userIDs []int, dryRun bool) error {
owner, repo, err := resolveOrgProject(ctx)
if err != nil {
return err
}
results := make([]map[string]interface{}, 0, len(userIDs))
for _, uid := range userIDs {
if dryRun {
results = append(results, map[string]interface{}{
"user_id": uid,
"project": fmt.Sprintf("%s/%s", owner, repo),
"action": "invite",
"status": "would execute (dry-run)",
})
continue
}
body := map[string]interface{}{"user_id": uid}
_, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", owner, repo), body)
status := "success"
msg := ""
if err != nil {
status = "failed"
msg = err.Error()
}
results = append(results, map[string]interface{}{
"user_id": uid,
"project": fmt.Sprintf("%s/%s", owner, repo),
"action": "invite",
"status": status,
"message": msg,
})
}
return ctx.OutputData(results)
}
// removeFromOrgProjects 从指定项目移除用户
func removeFromOrgProjects(ctx *common.RuntimeContext, userIDs []int, dryRun bool) error {
owner, repo, err := resolveOrgProject(ctx)
if err != nil {
return err
}
results := make([]map[string]interface{}, 0, len(userIDs))
for _, uid := range userIDs {
if dryRun {
results = append(results, map[string]interface{}{
"user_id": uid,
"project": fmt.Sprintf("%s/%s", owner, repo),
"action": "remove",
"status": "would execute (dry-run)",
})
continue
}
body := map[string]interface{}{"user_id": uid}
_, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", owner, repo), body)
status := "success"
msg := ""
if err != nil {
status = "failed"
msg = err.Error()
}
results = append(results, map[string]interface{}{
"user_id": uid,
"project": fmt.Sprintf("%s/%s", owner, repo),
"action": "remove",
"status": status,
"message": msg,
})
}
return ctx.OutputData(results)
}
// resolveOrgProject 从 --owner/--repo 解析目标项目
func resolveOrgProject(ctx *common.RuntimeContext) (owner, repo string, err error) {
owner = ctx.Arg("owner")
repo = ctx.Arg("repo")
if owner == "" || repo == "" {
return "", "", fmt.Errorf("must specify --owner and --repo (e.g., --owner zzx-coder --repo gitlink-cli)")
}
return owner, repo, nil
}
// parseUserIDList 解析逗号分隔的用户ID字符串
func parseUserIDList(input string) ([]int, error) {
var ids []int
for _, s := range strings.Split(input, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
uid, err := strconv.Atoi(s)
if err != nil {
return nil, fmt.Errorf("invalid user ID: %s", s)
}
ids = append(ids, uid)
}
if len(ids) == 0 {
return nil, fmt.Errorf("no valid user IDs found")
}
return ids, nil
}

View File

@ -4,17 +4,22 @@ import (
"fmt"
"net/url"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newApproveShortcut(),
newRequestChangesShortcut(),
newReviewsShortcut(),
{
Name: "list",
Description: "List pull requests",
Example: " gitlink-cli pr +list --state open\n gitlink-cli pr +list --state merged --page 1 --limit 50\n gitlink-cli pr +list --columns id,title,state,user --state all",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
{Name: "state", Short: "s", Usage: "Filter by state", Default: "open", Choices: []string{"open", "merged", "closed", "all"}},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
@ -30,7 +35,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "list", "pull requests", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -38,6 +43,18 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a pull request",
Long: "Create a new pull request from --head branch to --base branch. Requires --title and --head.",
Example: " gitlink-cli pr +create --title \"Fix login crash\" --head feat/new-login\n gitlink-cli pr +create --title \"New feature\" --head dev --base master --body \"Description...\"",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
head := ctx.Arg("head")
base := ctx.Arg("base")
if base == "" {
base = "master"
}
return fmt.Sprintf("Create PR: %s (%s -> %s)", title, head, base), nil
},
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "PR title", Required: true},
{Name: "body", Short: "b", Usage: "PR description"},
@ -48,8 +65,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"
@ -64,7 +87,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "create", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -79,26 +102,42 @@ 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 clierrors.OpError(clierrors.KindNotFound, "view", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "merge",
Description: "Merge a pull request",
Example: " gitlink-cli pr +merge --id 42\n gitlink-cli pr +merge --id 42 --method rebase\n gitlink-cli pr +merge --id 42 --method squash --dry-run",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
id := ctx.Arg("id")
method := ctx.Arg("method")
if method == "" {
method = "merge"
}
return fmt.Sprintf("Merge PR #%s (method: %s)", id, method), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
{Name: "method", Short: "m", Usage: "Merge method", Default: "merge", Choices: []string{"merge", "rebase", "squash"}},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
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"
@ -108,7 +147,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "merge", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -116,6 +155,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "close",
Description: "Close a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
id := ctx.Arg("id")
return fmt.Sprintf("Close PR #%s", id), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
@ -123,11 +167,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 clierrors.OpError(clierrors.KindServer, "close", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
@ -141,11 +188,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 clierrors.OpError(clierrors.KindServer, "list", "PR files", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
@ -159,17 +209,25 @@ 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 clierrors.OpError(clierrors.KindServer, "view", "PR diff", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "comment",
Description: "Add a comment to a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
id := ctx.Arg("id")
return fmt.Sprintf("Add comment to PR #%s", id), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
@ -178,12 +236,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 clierrors.OpError(clierrors.KindNotFound, "view", "pull request", err).WithCommand(ctx.CommandName)
}
issueID, err := extractIssueID(prEnv)
if err != nil {
@ -195,8 +259,268 @@ 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 clierrors.OpError(clierrors.KindServer, "comment", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
// === PR 增强 ===
{
Name: "reopen",
Description: "Reopen a closed pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("重新打开 PR #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/pulls/%s/reopen", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "reopen", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新 PR #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "title", Short: "t", Usage: "New title"},
{Name: "body", Short: "b", Usage: "New description"},
{Name: "head", Usage: "Source branch"},
{Name: "base", Usage: "Target branch"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
// Fetch existing PR to fill required fields
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindNotFound, "view", "pull request", err).WithCommand(ctx.CommandName)
}
prData, ok := prEnv.Data.(map[string]interface{})
if !ok {
return clierrors.InputError("unexpected PR format", "API 返回格式异常").WithCommand(ctx.CommandName)
}
payload := map[string]interface{}{
"title": prData["title"],
"body": prData["body"],
"head": prData["head"],
"base": prData["base"],
"issue_tag_ids": []string{},
"receivers_login": []string{},
}
if t := ctx.Arg("title"); t != "" {
payload["title"] = t
}
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
if h := ctx.Arg("head"); h != "" {
payload["head"] = h
}
if bs := ctx.Arg("base"); bs != "" {
payload["base"] = bs
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), payload)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "update", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "commits",
Description: "List commits in a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/commits", ctx.RepoPath(), id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "PR commits", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "versions",
Description: "List versions of a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/pulls/%s/versions", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "PR versions", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "vdiff",
Description: "Show diff of a specific PR version",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "version", Short: "v", Usage: "Version ID", Required: true},
{Name: "filepath", Usage: "Filter by file path"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
versionID, err := ctx.RequireArg("version", "--version 5")
if err != nil {
return err
}
q := url.Values{}
if fp := ctx.Arg("filepath"); fp != "" {
q.Set("filepath", fp)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/pulls/%s/versions/%s/diff", ctx.Owner, ctx.Repo, id, versionID), q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "view", "PR version diff", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "filesv1",
Description: "List changed files (v1 API with pagination)",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "filepath", Usage: "Filter by file path"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if fp := ctx.Arg("filepath"); fp != "" {
q.Set("filepath", fp)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/pulls/%s/files", ctx.Owner, ctx.Repo, id), q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "PR files", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
// === PR 评论管理 ===
{
Name: "comment-edit",
Description: "Edit a PR review comment",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
{Name: "body", Short: "b", Usage: "New comment body", Required: true},
{Name: "commit", Usage: "Commit SHA"},
{Name: "state", Usage: "Comment state", Default: "opened", Choices: []string{"opened", "resolved", "disabled"}},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
body, err := ctx.RequireArg("body", `--body "updated comment"`)
if err != nil {
return err
}
payload := map[string]interface{}{
"note": body,
"state": ctx.Arg("state"),
}
if commit := ctx.Arg("commit"); commit != "" {
payload["commit_id"] = commit
} else {
payload["commit_id"] = ""
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/v1/%s/%s/pulls/%s/journals/%s", ctx.Owner, ctx.Repo, id, commentID), payload)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "edit", "PR comment", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "comment-delete",
Description: "Delete a PR review comment",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除 PR #%s 的评论 #%s", ctx.Arg("id"), ctx.Arg("comment-id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/pulls/%s/journals/%s", ctx.Owner, ctx.Repo, id, commentID), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "delete", "PR comment", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
@ -206,15 +530,15 @@ func Shortcuts() []*common.Shortcut {
func extractIssueID(env *output.Envelope) (int64, error) {
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected PR response format")
return 0, clierrors.InputError("unexpected PR response format", "API 返回格式异常,请稍后重试")
}
issue, ok := data["issue"].(map[string]interface{})
if !ok {
return 0, fmt.Errorf("PR response missing issue field")
return 0, clierrors.InputError("PR response missing issue field", "API 返回数据中缺少 issue 字段,请稍后重试")
}
idFloat, ok := issue["id"].(float64)
if !ok {
return 0, fmt.Errorf("PR response missing issue.id field")
return 0, clierrors.InputError("PR response missing issue.id field", "API 返回数据中缺少 issue.id 字段,请稍后重试")
}
return int64(idFloat), nil
}

View File

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

103
shortcuts/pr/review.go Normal file
View File

@ -0,0 +1,103 @@
package pr
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func newApproveShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "approve",
Description: "Approve a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("批准 PR #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Review comment (optional)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
body := map[string]interface{}{
"state": "approved",
}
if b := ctx.Arg("body"); b != "" {
body["body"] = b
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), body)
if err != nil {
return fmt.Errorf("批准 PR 失败: %w", err)
}
return ctx.Output(env)
},
}
}
func newRequestChangesShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "request-changes",
Description: "Request changes on a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("请求 PR #%s 修改", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Review comment explaining what needs to change", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
body, err := ctx.RequireArg("body", `--body "Looks good"`)
if err != nil {
return err
}
payload := map[string]interface{}{
"state": "changes_requested",
"body": body,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), payload)
if err != nil {
return fmt.Errorf("请求修改 PR 失败: %w", err)
}
return ctx.Output(env)
},
}
}
func newReviewsShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "reviews",
Description: "List reviews for a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("获取 PR 评审列表失败: %w", err)
}
return ctx.Output(env)
},
}
}

View File

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

View File

@ -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)
},
@ -34,6 +34,12 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a release",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
tag := ctx.Arg("tag")
return fmt.Sprintf("Create release: %s (tag: %s)", name, tag), nil
},
Flags: []common.Flag{
{Name: "tag", Short: "t", Usage: "Tag name", Required: true},
{Name: "name", Short: "n", Usage: "Release name", Required: true},
@ -45,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,
@ -62,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)
},
@ -77,17 +89,25 @@ 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)
},
},
{
Name: "delete",
Description: "Delete a release",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
id := ctx.Arg("id")
return fmt.Sprintf("Delete release #%s", id), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
},
@ -95,25 +115,65 @@ 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 {
// GitLink API bug: delete succeeds but returns error status.
// Verify by checking if the release still exists.
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if viewErr != nil {
// Release no longer exists — delete actually succeeded
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功",
}, nil))
}
// Release still exists — delete truly failed
return delErr
return fmt.Errorf("删除 Release 失败: %w", delErr)
}
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功",
}, nil))
},
},
{
Name: "update",
Description: "Update a release",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
{Name: "name", Short: "n", Usage: "New release name"},
{Name: "body", Short: "b", Usage: "New release notes"},
{Name: "prerelease", Usage: "Mark as prerelease (true/false)"},
},
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新 Release #%s", ctx.Arg("id")), nil
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
body := map[string]interface{}{}
if n := ctx.Arg("name"); n != "" {
body["name"] = n
}
if b := ctx.Arg("body"); b != "" {
body["body"] = b
}
if p := ctx.Arg("prerelease"); p != "" {
body["prerelease"] = p == "true"
}
if len(body) == 0 {
return fmt.Errorf("at least one of --name, --body, --prerelease is required")
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), body)
if err != nil {
return fmt.Errorf("更新 Release 失败: %w", err)
}
return ctx.Output(env)
},
},
}
}

View File

@ -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 ""
}

View File

@ -0,0 +1,94 @@
package repo
import (
"fmt"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// newBatchDeleteShortcut 实现 repo +batch-delete 命令。
//
// 设计参考 issue +batch-close 与 repo +batch-update
// - --names : 内联逗号分隔的仓库名列表
// - --from : CSV 文件路径(只读 name 列,复用 readNamesFromCSV
// - --dry-run : 仅预览不实际删除
//
// 与单条 repo +delete 的区别:批量操作下没有"当前仓库"语义,
// 因此只要求 --owner不需要 --repo所有要删除的仓库都位于该 owner 名下。
func newBatchDeleteShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-delete",
Description: "Delete multiple repositories by names 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: "dry-run", Usage: "Preview without deleting", Bool: true, Default: "false"},
},
Run: runBatchDelete,
}
}
func runBatchDelete(ctx *common.RuntimeContext) error {
owner := ctx.Owner
if owner == "" {
return fmt.Errorf("--owner is required; pass --owner <login> to specify the account that owns the repos")
}
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")
}
dryRun := ctx.Arg("dry-run") == "true"
summary := repoBatchSummary{
Owner: owner,
Action: "delete",
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
}
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s", owner, name), nil); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "deleted"
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 delete", summary.Failed, summary.Total)
}
return nil
}

View File

@ -0,0 +1,220 @@
package repo
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runBatchDeleteShortcut(t *testing.T, server *httptest.Server, owner string, args map[string]string) error {
t.Helper()
s := findShortcut(t, "batch-delete")
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: owner,
Format: "json",
Args: args,
}
return s.Run(ctx)
}
func TestBatchDelete_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 := runBatchDeleteShortcut(t, server, "testuser", map[string]string{
"names": "repo1,repo2",
"dry-run": "true",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestBatchDelete_FromNames(t *testing.T) {
var deletedPaths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Fatalf("expected DELETE, got %s", r.Method)
}
deletedPaths = append(deletedPaths, r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{
"names": "test-batch-1,test-batch-2,test-batch-3",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(deletedPaths) != 3 {
t.Fatalf("expected 3 DELETE calls, got %d", len(deletedPaths))
}
expected := []string{"/zzx-coder/test-batch-1.json", "/zzx-coder/test-batch-2.json", "/zzx-coder/test-batch-3.json"}
for i, p := range deletedPaths {
if p != expected[i] {
t.Fatalf("path[%d]: got %q, want %q", i, p, expected[i])
}
}
}
func TestBatchDelete_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 := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "no repository names provided") {
t.Fatalf("error should mention no names, got: %v", err)
}
}
func TestBatchDelete_NoOwner(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 := runBatchDeleteShortcut(t, server, "", map[string]string{
"names": "repo1,repo2",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "--owner is required") {
t.Fatalf("error should mention --owner required, got: %v", err)
}
}
func TestBatchDelete_DryRunNoNamesErrors(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 := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{"dry-run": "true"})
if err == nil {
t.Fatal("expected error for no names even in dry-run, got nil")
}
}
func TestBatchDelete_FromCSV(t *testing.T) {
csvPath := writeTempCSV(t, "name\ncsv-repo1\ncsv-repo2\n")
var deletedPaths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Fatalf("expected DELETE, got %s", r.Method)
}
deletedPaths = append(deletedPaths, r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{"from": csvPath})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(deletedPaths) != 2 {
t.Fatalf("expected 2 DELETE calls, got %d", len(deletedPaths))
}
if deletedPaths[0] != "/zzx-coder/csv-repo1.json" {
t.Fatalf("first path: got %q", deletedPaths[0])
}
if deletedPaths[1] != "/zzx-coder/csv-repo2.json" {
t.Fatalf("second path: got %q", deletedPaths[1])
}
}
func TestBatchDelete_CSVAndNamesCombined(t *testing.T) {
csvPath := writeTempCSV(t, "name\ncsv-repo\n")
var deletedPaths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
deletedPaths = append(deletedPaths, r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{
"names": "inline-repo",
"from": csvPath,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(deletedPaths) != 2 {
t.Fatalf("expected 2 deletes (inline + csv), got %d", len(deletedPaths))
}
if deletedPaths[0] != "/zzx-coder/inline-repo.json" {
t.Fatalf("first: got %q", deletedPaths[0])
}
if deletedPaths[1] != "/zzx-coder/csv-repo.json" {
t.Fatalf("second: got %q", deletedPaths[1])
}
}
func TestBatchDelete_PartialFailure(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
if callCount == 2 {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"message":"repo not found"}`))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
err := runBatchDeleteShortcut(t, server, "zzx-coder", 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 delete") {
t.Fatalf("error should mention failed count, got: %v", err)
}
}
func TestBatchDelete_TrimsWhitespace(t *testing.T) {
var deletedPaths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
deletedPaths = append(deletedPaths, r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{
"names": " repo-a , repo-b ,",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(deletedPaths) != 2 {
t.Fatalf("expected 2 deletes after trimming, got %d", len(deletedPaths))
}
if deletedPaths[0] != "/zzx-coder/repo-a.json" {
t.Fatalf("first: got %q", deletedPaths[0])
}
if deletedPaths[1] != "/zzx-coder/repo-b.json" {
t.Fatalf("second: got %q", deletedPaths[1])
}
}

View File

@ -0,0 +1,205 @@
package repo
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// BatchMemberShortcuts 返回批量成员管理相关的 Shortcut由 repo.Shortcuts() 调用合并)
func BatchMemberShortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "batch-invite",
Description: "Batch invite members to a repository (from --users list or --from CSV file)",
Flags: []common.Flag{
{Name: "users", Usage: "Comma-separated list of user IDs to invite"},
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
},
Run: runBatchInvite,
},
{
Name: "batch-remove",
Description: "Batch remove members from a repository (from --users list or --from CSV file)",
Flags: []common.Flag{
{Name: "users", Usage: "Comma-separated list of user IDs to remove"},
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
},
Run: runBatchRemove,
},
}
}
// runBatchInvite 执行批量邀请
func runBatchInvite(ctx *common.RuntimeContext) error {
userIDs, err := parseUserIDs(ctx)
if err != nil {
return err
}
dryRun := ctx.Arg("dry-run") == "true"
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
results := make([]map[string]interface{}, 0, len(userIDs))
for _, uid := range userIDs {
if dryRun {
results = append(results, map[string]interface{}{
"user_id": uid,
"project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"action": "invite",
"status": "would execute (dry-run)",
})
continue
}
body := map[string]interface{}{"user_id": uid}
_, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body)
status := "success"
msg := ""
if err != nil {
status = "failed"
msg = err.Error()
}
results = append(results, map[string]interface{}{
"user_id": uid,
"project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"action": "invite",
"status": status,
"message": msg,
})
}
return ctx.OutputData(results)
}
// runBatchRemove 执行批量移除
func runBatchRemove(ctx *common.RuntimeContext) error {
userIDs, err := parseUserIDs(ctx)
if err != nil {
return err
}
dryRun := ctx.Arg("dry-run") == "true"
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
results := make([]map[string]interface{}, 0, len(userIDs))
for _, uid := range userIDs {
if dryRun {
results = append(results, map[string]interface{}{
"user_id": uid,
"project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"action": "remove",
"status": "would execute (dry-run)",
})
continue
}
body := map[string]interface{}{"user_id": uid}
_, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body)
status := "success"
msg := ""
if err != nil {
status = "failed"
msg = err.Error()
}
results = append(results, map[string]interface{}{
"user_id": uid,
"project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"action": "remove",
"status": status,
"message": msg,
})
}
return ctx.OutputData(results)
}
// parseUserIDs 从 --users 或 --from 参数中解析用户 ID 列表
func parseUserIDs(ctx *common.RuntimeContext) ([]int, error) {
usersStr := ctx.Arg("users")
csvFile := ctx.Arg("from")
if usersStr == "" && csvFile == "" {
return nil, fmt.Errorf("必须指定 --users逗号分隔的用户ID或 --fromCSV文件路径")
}
var userIDs []int
if usersStr != "" {
for _, s := range strings.Split(usersStr, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
uid, err := strconv.Atoi(s)
if err != nil {
return nil, fmt.Errorf("invalid user ID: %s", s)
}
userIDs = append(userIDs, uid)
}
}
if csvFile != "" {
f, err := os.Open(csvFile)
if err != nil {
return nil, fmt.Errorf("failed to open CSV file %s: %w", csvFile, err)
}
defer f.Close()
reader := csv.NewReader(f)
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("failed to read CSV file: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV file must have a header row and at least one data row")
}
// 查找 user_id 列
header := records[0]
colIdx := -1
for i, h := range header {
if strings.TrimSpace(h) == "user_id" {
colIdx = i
break
}
}
if colIdx == -1 {
return nil, fmt.Errorf("CSV file must have a 'user_id' column")
}
for _, row := range records[1:] {
if len(row) <= colIdx {
continue
}
s := strings.TrimSpace(row[colIdx])
if s == "" {
continue
}
uid, err := strconv.Atoi(s)
if err != nil {
return nil, fmt.Errorf("invalid user ID in CSV: %s", s)
}
userIDs = append(userIDs, uid)
}
}
if len(userIDs) == 0 {
return nil, fmt.Errorf("no valid user IDs found")
}
return userIDs, nil
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -3,18 +3,24 @@ package repo
import (
"fmt"
"net/url"
"strconv"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
shortcuts := []*common.Shortcut{
newBatchCreateShortcut(),
newBatchUpdateShortcut(),
newBatchDeleteShortcut(),
{
Name: "list",
Description: "List repositories for a user or organization",
Example: " gitlink-cli repo +list\n gitlink-cli repo +list --user myorg\n gitlink-cli repo +list --category mirror --page 1 --limit 50",
Flags: []common.Flag{
{Name: "user", Short: "u", Usage: "User login (default: current user)"},
{Name: "category", Short: "c", Usage: "Filter: manage/mirror/sync/fork/all (default: manage)", Default: "manage"},
{Name: "category", Short: "c", Usage: "Filter by category", Default: "manage", Choices: []string{"manage", "mirror", "sync", "fork", "all"}},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
@ -33,7 +39,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "list", "repositories", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -47,7 +53,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return err
return clierrors.OpError(clierrors.KindNotFound, "view", "repository", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -55,25 +61,32 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a new repository",
Long: "Create a new GitLink repository. Requires --name. Supports --description and --private flags.",
Example: " gitlink-cli repo +create --name my-project\n gitlink-cli repo +create --name my-project --description \"A new project\" --private true\n gitlink-cli repo +create --name my-project --dry-run",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
return fmt.Sprintf("Create repository: %s", name), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
{Name: "description", Short: "d", Usage: "Repository description"},
{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 clierrors.OpError(clierrors.KindAuth, "view", "user profile", err).WithCommand(ctx.CommandName)
}
userData, _ := userEnv.Data.(map[string]interface{})
login, _ := userData["login"].(string)
if login == "" {
return fmt.Errorf("cannot determine current user login")
return clierrors.InputError("cannot determine current user login", "请先运行 gitlink-cli auth login 登录").WithCommand(ctx.CommandName)
}
userID, _ := userData["user_id"].(float64)
body := map[string]interface{}{
@ -89,7 +102,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "create", "repository", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -97,13 +110,20 @@ func Shortcuts() []*common.Shortcut {
{
Name: "fork",
Description: "Fork a repository",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
if err := ctx.ResolveOwnerRepo(); err != nil {
return "", err
}
return fmt.Sprintf("Fork repository %s/%s", ctx.Owner, ctx.Repo), nil
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", nil)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "fork", "repository", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -111,16 +131,154 @@ func Shortcuts() []*common.Shortcut {
{
Name: "delete",
Description: "Delete a repository",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
if err := ctx.ResolveOwnerRepo(); err != nil {
return "", err
}
return fmt.Sprintf("DELETE repository %s/%s", ctx.Owner, ctx.Repo), nil
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "delete", "repository", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update repository settings",
Flags: []common.Flag{
{Name: "description", Short: "d", Usage: "New description"},
{Name: "private", Usage: "Set private (true/false)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
body := map[string]interface{}{}
if d := ctx.Arg("description"); d != "" {
body["description"] = d
}
if p := ctx.Arg("private"); p != "" {
body["private"] = p == "true"
}
if len(body) == 0 {
return clierrors.InputError(
"at least one of --description, --private is required",
"至少需要提供 --description 或 --private 中的一个参数",
).WithCommand(ctx.CommandName)
}
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "update", "repository", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "members",
Description: "List repository members (collaborators)",
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", ctx.RepoPath()+"/collaborators", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "members", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "invite",
Description: "Invite a member to a repository",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
if err := ctx.ResolveOwnerRepo(); err != nil {
return "", err
}
userID := ctx.Arg("user-id")
return fmt.Sprintf("Invite user %s to %s/%s", userID, ctx.Owner, ctx.Repo), nil
},
Flags: []common.Flag{
{Name: "user-id", Usage: "User ID to invite (required)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
userID, err := ctx.RequireArg("user-id", "--user-id 42")
if err != nil {
return err
}
uid, err := strconv.Atoi(userID)
if err != nil {
return clierrors.InputError(
fmt.Sprintf("invalid user-id: %s (must be an integer)", userID),
"user-id 必须是整数,请检查参数值",
).WithCommand(ctx.CommandName)
}
body := map[string]interface{}{"user_id": uid}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "invite", "member", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "remove-member",
Description: "Remove a member from a repository",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
if err := ctx.ResolveOwnerRepo(); err != nil {
return "", err
}
userID := ctx.Arg("user-id")
return fmt.Sprintf("Remove user %s from %s/%s", userID, ctx.Owner, ctx.Repo), nil
},
Flags: []common.Flag{
{Name: "user-id", Usage: "User ID to remove (required)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
userID, err := ctx.RequireArg("user-id", "--user-id 42")
if err != nil {
return err
}
uid, err := strconv.Atoi(userID)
if err != nil {
return clierrors.InputError(
fmt.Sprintf("invalid user-id: %s (must be an integer)", userID),
"user-id 必须是整数,请检查参数值",
).WithCommand(ctx.CommandName)
}
body := map[string]interface{}{"user_id": uid}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "remove", "member", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
}
// 合并批量成员管理命令
shortcuts = append(shortcuts, BatchMemberShortcuts()...)
return shortcuts
}

View File

@ -1,6 +1,7 @@
package search
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -17,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)
},
@ -38,15 +42,45 @@ 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 fmt.Errorf("搜索用户失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "issues",
Description: "Search issues",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
keyword, err := ctx.RequireArg("keyword", "--keyword myproject")
if err != nil {
return err
}
q := url.Values{}
q.Set("search", keyword)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/issues", ctx.RepoPath()), q)
if err != nil {
return fmt.Errorf("搜索 Issue 失败: %w", err)
}
return ctx.Output(env)
},
},

192
shortcuts/team/team.go Normal file
View File

@ -0,0 +1,192 @@
package team
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List teams in an organization",
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
org, err := ctx.RequireArg("org", "--org my-org")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams", org), q)
if err != nil {
return fmt.Errorf("获取团队列表失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a team in an organization",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("创建团队: %s", ctx.Arg("name")), nil
},
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "name", Short: "n", Usage: "Team name", Required: true},
{Name: "description", Short: "d", Usage: "Team description"},
},
Run: func(ctx *common.RuntimeContext) error {
org, err := ctx.RequireArg("org", "--org my-org")
if err != nil {
return err
}
name, err := ctx.RequireArg("name", `--name "My Team"`)
if err != nil {
return err
}
body := map[string]interface{}{
"name": name,
}
if desc := ctx.Arg("description"); desc != "" {
body["description"] = desc
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams", org), body)
if err != nil {
return fmt.Errorf("创建团队失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a team",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除团队 #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "id", Short: "i", Usage: "Team ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, err := ctx.RequireArg("org", "--org my-org")
if err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s", org, id), nil)
if err != nil {
return fmt.Errorf("删除团队失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "members",
Description: "List members of a team",
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "id", Short: "i", Usage: "Team ID", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
org, err := ctx.RequireArg("org", "--org my-org")
if err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams/%s/members", org, id), q)
if err != nil {
return fmt.Errorf("获取团队成员列表失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "member-add",
Description: "Add a user to a team",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("添加用户 %s 到团队 %s", ctx.Arg("user"), ctx.Arg("team")), nil
},
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
{Name: "user", Short: "u", Usage: "User login or ID to add", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, err := ctx.RequireArg("org", "--org my-org")
if err != nil {
return err
}
team, err := ctx.RequireArg("team", "--team dev-team")
if err != nil {
return err
}
user, err := ctx.RequireArg("user", "--user alice")
if err != nil {
return err
}
body := map[string]interface{}{
"user_id": user,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams/%s/members", org, team), body)
if err != nil {
return fmt.Errorf("添加团队成员失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "member-remove",
Description: "Remove a user from a team",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("从团队 %s 移除用户 %s", ctx.Arg("user"), ctx.Arg("team")), nil
},
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization ID or login", Required: true},
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
{Name: "user", Short: "u", Usage: "User login or ID to remove", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, err := ctx.RequireArg("org", "--org my-org")
if err != nil {
return err
}
team, err := ctx.RequireArg("team", "--team dev-team")
if err != nil {
return err
}
user, err := ctx.RequireArg("user", "--user alice")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s/members/%s", org, team, user), nil)
if err != nil {
return fmt.Errorf("移除团队成员失败: %w", err)
}
return ctx.Output(env)
},
},
}
}

View File

@ -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)
},

View File

@ -0,0 +1,406 @@
package webhook
import (
"fmt"
"net/url"
"strings"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// supportedEvents 定义了 GitLink 支持的所有 Webhook 事件类型
// push: 代码推送事件
// pull_request: PR 事件
// issue: Issue 事件
// issue_assign: Issue 分配事件
// issue_comment: Issue 评论事件
// pull_request_assign: PR 分配事件
// pull_request_comment: PR 评论事件
// merge_request: 合并请求事件
// repository: 仓库事件
// branch: 分支创建/删除事件
// tag: 标签创建/删除事件
var supportedEvents = []string{
"push",
"pull_request",
"issue",
"issue_assign",
"issue_comment",
"pull_request_assign",
"pull_request_comment",
"merge_request",
"repository",
"branch",
"tag",
}
// isEventSupported 检查某个事件类型是否被支持
// 参数: event - 要检查的事件类型
// 返回: true 表示支持false 表示不支持
func isEventSupported(event string) bool {
for _, supported := range supportedEvents {
if event == supported {
return true
}
}
return false
}
// parseEvents 把用户输入的逗号分隔的事件字符串解析成事件数组
// 参数: eventsStr - 用户输入的事件字符串,如 "push,pull_request"
// 返回: 过滤后的有效事件数组,如果输入为空则返回默认值 ["push"]
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 构建 Webhook API 的基础路径
// 参数: ctx - 运行时上下文,包含 Owner仓库所有者和 Repo仓库名
// 返回: 类似 /v1/owner/repo 的字符串
// 注意: BaseURL 已经包含 /api 前缀,所以这里不需要再加
func webhookRepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
// Shortcuts 返回所有 Webhook 相关的 CLI 命令列表
// 包含7个命令list、create、update、delete、test、info、events
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// list 命令:列出仓库的所有 Webhook
{
Name: "list",
Description: "List all webhooks for a repository",
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 {
// 步骤1解析仓库信息从命令行参数或 Git 远程仓库)
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2创建 URL 查询参数
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
// 步骤3调用 API 获取 Webhook 列表
env, err := ctx.CallAPIWithQuery("GET", webhookRepoPath(ctx)+"/webhooks", q)
if err != nil {
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
}
// 步骤4输出结果给用户
return ctx.Output(env)
},
},
// create 命令:创建新的 Webhook
{
Name: "create",
Description: "Create a new webhook",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("创建 Webhook: %s", ctx.Arg("url")), nil
},
Flags: []common.Flag{
{Name: "url", Short: "u", Usage: "Webhook callback URL", Required: true},
{Name: "events", Short: "e", Usage: "Trigger events (comma-separated), e.g., push,pull_request,issue", Default: "push"},
{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 {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取必需参数 --url用 RequireArg如果没提供会报错
webhookURL, err := ctx.RequireArg("url", "--url https://example.com/hook")
if err != nil {
return err
}
// 步骤3解析事件类型逗号分隔自动过滤无效事件
events := parseEvents(ctx.Arg("events"))
if len(events) == 0 {
return clierrors.InputError(
"no valid events specified",
fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")),
)
}
// 步骤4构建请求体payload包含必填字段
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"}
}
// 添加可选参数secret签名密钥
if secret := ctx.Arg("secret"); secret != "" {
payload["secret"] = secret
}
// 添加可选参数description描述
if description := ctx.Arg("description"); description != "" {
payload["description"] = description
}
// 步骤5发送 POST 请求创建 Webhook
env, err := ctx.CallAPI("POST", webhookRepoPath(ctx)+"/webhooks", payload)
if err != nil {
return fmt.Errorf("创建 Webhook 失败: %w", err)
}
// 步骤6输出结果
return ctx.Output(env)
},
},
// update 命令:更新现有的 Webhook
{
Name: "update",
Description: "Update an existing webhook",
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 {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取必需参数 --id
webhookID, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
// 步骤3初始化请求体
payload := map[string]interface{}{
"http_method": "POST",
"active": true,
"content_type": "json",
}
// 步骤4智能获取 URL
// 如果用户没提供 URL自动调用 GET API 获取当前 URL避免用户重复输入
webhookURL := ctx.Arg("url")
if webhookURL == "" {
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
if err != nil {
return fmt.Errorf("获取 Webhook 当前信息失败: %w", err)
}
// 类型断言:把 Data 转换为 map[string]interface{}
webhookData, ok := getEnv.Data.(map[string]interface{})
if !ok {
return fmt.Errorf("failed to parse webhook data")
}
currentURL, ok := webhookData["url"].(string)
if !ok || currentURL == "" {
return fmt.Errorf("failed to get current webhook URL")
}
webhookURL = currentURL
}
payload["url"] = webhookURL
// 步骤5添加可选参数
if events := ctx.Arg("events"); events != "" {
validEvents := parseEvents(events)
if len(validEvents) == 0 {
return clierrors.InputError(
"no valid events specified",
fmt.Sprintf("支持的事件类型: %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
}
// 步骤6发送 PUT 请求更新 Webhook
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), payload)
if err != nil {
return fmt.Errorf("更新 Webhook 失败: %w", err)
}
return ctx.Output(env)
},
},
// delete 命令:删除 Webhook带双重验证机制
{
Name: "delete",
Description: "Delete a webhook",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除 Webhook #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取必需参数 --id
webhookID, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
// 步骤3发送 DELETE 请求
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
if delErr != nil {
// 双重验证:如果 DELETE 失败,再调用 GET 检查 Webhook 是否还存在
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
if viewErr != nil {
// GET 也失败,说明 Webhook 确实不存在了,视为删除成功
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "Webhook deleted successfully",
}, nil))
}
return fmt.Errorf("删除 Webhook 失败: %w", delErr)
}
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "Webhook deleted successfully",
}, nil))
},
},
// test 命令:测试 Webhook发送测试请求
{
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 {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取必需参数 --id
webhookID, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
// 步骤3验证事件类型
eventType := ctx.Arg("event")
if !isEventSupported(eventType) {
return clierrors.InputError(
fmt.Sprintf("unsupported event type: %s", eventType),
fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")),
)
}
// 步骤4发送测试请求
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)
if err != nil {
return fmt.Errorf("测试 Webhook 失败: %w", err)
}
return ctx.Output(env)
},
},
// info 命令:查看 Webhook 详情
{
Name: "info",
Description: "Show webhook details",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取必需参数 --id
webhookID, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
// 步骤3调用 GET API 获取详情
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
if err != nil {
return fmt.Errorf("查看 Webhook 详情失败: %w", err)
}
return ctx.Output(env)
},
},
// events 命令:列出所有支持的事件类型
{
Name: "events",
Description: "List all supported event types for webhooks",
Run: func(ctx *common.RuntimeContext) error {
// 构建事件信息列表
eventInfo := make([]map[string]interface{}, 0)
for _, event := range supportedEvents {
eventInfo = append(eventInfo, map[string]interface{}{
"event": event,
"supported": true,
"description": getEventDescription(event),
})
}
// 输出结果(用 SuccessEnvelope 包装)
return ctx.Output(output.SuccessEnvelope(eventInfo, nil))
},
},
}
}
// getEventDescription 返回事件类型的英文描述
// 参数: event - 事件类型名称
// 返回: 事件描述,如果找不到则返回 "Custom event"
func getEventDescription(event string) string {
descriptions := map[string]string{
"push": "Code push events",
"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"
}

View File

@ -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)
}
}
}

View File

@ -0,0 +1,84 @@
# wiki 模块变更日志
## 2026-05-31 新增 `wiki +lint` 文档质量检查命令
> **注意:`+lint` 命令当前仅在本地编译版本中可用**(全局安装的 `gitlink-cli` 暂未包含)。需先 `go build -o gitlink-cli.exe .` 然后使用 `./gitlink-cli.exe wiki +lint`
### 使用方法
```bash
# 在仓库目录下owner/repo 自动从 git remote 解析
gitlink-cli wiki +lint
# 只运行指定检查项(逗号分隔)
gitlink-cli wiki +lint --check links
gitlink-cli wiki +lint --check links,headings
# JSON 格式输出
gitlink-cli wiki +lint --format json
```
### 检查项
| 检查名称 | 级别 | 说明 |
|----------|------|------|
| empty | error | 页面内容为空 |
| headings | warning | 缺少 H1 标题(不以 `# ` 开头) |
| short | warning | 内容不足 50 字符 |
| links | error | wiki 内链指向不存在的页面 |
| images | warning | 外部图片 URL 返回 404 或不可达 |
### 主要修改
**文件**: `shortcuts/wiki/wiki.go`
#### 1. 新增类型
```go
type LintIssue struct {
Page string `json:"page"`
Level string `json:"level"` // "error" / "warning"
Check string `json:"check"`
Message string `json:"message"`
}
type LintSummary struct {
Repository string `json:"repository"`
TotalPages int `json:"total_pages"`
TotalIssues int `json:"total_issues"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
Results []LintIssue `json:"results"`
}
```
#### 2. Bug 修复
- 修复 lint 获取页面内容时使用 `title` 而非 `sub_url` 导致部分页面 500 错误的问题
#### 3. 新增函数(含 sub_url/title 双字段支持)
- `isCheckEnabled(checkFilter, name string) bool` — 判断指定检查是否被 `--check` 参数选中
- `checkEmpty(page, content string) []LintIssue` — 检测空页面
- `checkHeading(page, content string) []LintIssue` — 检测缺少 H1
- `checkShort(page, content string) []LintIssue` — 检测内容过短
- `checkDeadLinks(page, content string, knownTitles map[string]bool) []LintIssue` — 正则提取内链,在已知标题集合中查找死链
- `checkImages(page, content string, httpClient *http.Client) []LintIssue` — 正则提取外部图片 URLHEAD 请求检查可达性(超时 5s
- `runLint(ctx *common.RuntimeContext) error` — lint 主流程:解析 owner/repo → 获取页面列表 → 逐页检查 → 汇总输出
#### 4. 新增 Shortcut 注册
```go
{
Name: "lint",
Description: "Check wiki pages for quality issues",
Flags: []common.Flag{
{Name: "check", Usage: "Specific checks to run (comma-separated): links,headings,images,empty. Default: all"},
},
Run: runLint,
}
```
#### 5. 新增 import
`net/http`, `regexp`, `strings`, `time`

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

@ -0,0 +1,949 @@
package wiki
import (
"encoding/base64" // Base64 编解码Wiki 内容是 Base64 编码的
"encoding/json" // JSON 处理,用于解析 API 响应
"errors" // 错误处理
"fmt" // 格式化输出
"net/http" // HTTP 客户端相关
"net/url" // URL 处理,用于构建查询参数
"os" // 文件操作,用于读取本地文件内容
"regexp" // 正则表达式,用于 Lint 功能中的链接匹配
"strconv" // 字符串和数字转换
"strings" // 字符串处理
"sync" // 并发安全,用于 projectIDCache
"time" // 时间相关,用于 HTTP 超时设置
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块,创建带认证的 HTTP 客户端
"github.com/gitlink-org/gitlink-cli/internal/client" // HTTP 客户端模块,封装 API 请求
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // 自定义 CLI 错误类型
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化模块
"github.com/gitlink-org/gitlink-cli/shortcuts/common" // 公共工具包,包含 Shortcut、RuntimeContext 等
)
// projectIDCache 项目 ID 缓存,使用 sync.Map 实现并发安全
// 键owner/repoproject_id字符串
// 作用:避免重复调用项目详情 API 获取 project_id
var projectIDCache sync.Map
// wikiPath 构建 Wiki API 的路径前缀
// 参数: endpoint - API 端点(如 "pages", "getWiki"
// 返回: 完整路径,如 "/wiki/open/pages"
// 说明Wiki 功能调用的是网关 API所有接口都有固定前缀 "/wiki/open/"
func wikiPath(endpoint string) string {
return "/wiki/open/" + endpoint
}
// getGatewayClient 创建 Wiki API 专用的 HTTP 客户端
// BaseURL 优先级:
// 1. GITLINK_GATEWAY_URL 环境变量(最高优先级)
// 2. 配置文件中的 gateway_base_url
// 3. 默认值 https://gateway.gitlink.org.cn/api最低优先级
// HTTP 客户端:如果 ctx.GatewayHTTPClient 为 nil回退到 auth.NewHTTPClient()(带认证)
func getGatewayClient(ctx *common.RuntimeContext) *client.Client {
baseURL := ctx.GatewayBaseURL
if baseURL == "" {
baseURL = "https://gateway.gitlink.org.cn/api" // 兜底默认值
}
httpClient := ctx.GatewayHTTPClient
if httpClient == nil {
httpClient = auth.NewHTTPClient() // 创建带认证的 HTTP 客户端
}
return &client.Client{
HTTP: httpClient, // HTTP 客户端(带认证)
BaseURL: baseURL, // Wiki 网关 API 地址
SkipJSONSuffix: true, // 不需要在 URL 后加 .json 后缀
Debug: ctx.Client.Debug, // 继承调试模式设置
}
}
// callWikiAPI 调用 Wiki API无查询参数
// 参数: method - HTTP 方法; path - API 路径; body - 请求体
// 返回: API 响应信封,以及可能的错误
// 内部流程:创建网关客户端 → 发送请求 → 处理响应
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
gc := getGatewayClient(ctx) // 创建 Wiki 专用客户端
env, err := gc.Do(method, path, body, nil) // 发送请求
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env) // 处理网关响应格式
}
// callWikiAPIWithQuery 调用 Wiki API带查询参数
// 参数: method - HTTP 方法; path - API 路径; query - URL 查询参数
// 返回: API 响应信封,以及可能的错误
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
gc := getGatewayClient(ctx) // 创建 Wiki 专用客户端
env, err := gc.Do(method, path, nil, query) // 发送带查询参数的请求
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env) // 处理网关响应格式
}
// unwrapGatewayResponse 处理网关 API 的响应格式
// 网关 API 返回格式:{"code": 200, "data": {...}, "msg": "..."}
// 主 API 返回格式:{"ok": true, "data": {...}}
// 此函数将网关响应转换为统一的 Envelope 格式
func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) {
// 尝试把 Data 转换为 map
resp, ok := env.Data.(map[string]interface{})
if !ok {
return env, nil // 不是 map 格式,直接返回
}
// 检查 code 字段(网关 API 的状态码)
if code, ok := resp["code"]; ok {
switch v := code.(type) {
case float64:
// HTTP 2xx 全部视为成功200 OK / 201 Created / 204 No Content 等)
if v < 200 || v >= 300 {
msg, _ := resp["msg"].(string)
// 根据状态码确定错误类型
kind := clierrors.KindServer
if int(v) == 404 {
kind = clierrors.KindNotFound
} else if int(v) == 401 || int(v) == 403 {
kind = clierrors.KindForbidden
}
return nil, clierrors.New(kind, msg,
"检查 owner/repo 是否正确,或确认仓库已在 GitLink 网页端开启 Wiki 功能")
}
}
}
// 提取内层 data 字段
if innerData, ok := resp["data"]; ok {
return output.SuccessEnvelope(innerData, env.Meta), nil
}
return env, nil
}
// resolveProjectID 获取项目 ID带缓存
// 流程:
// 1. 先从缓存查找owner/repo 作为键)
// 2. 缓存命中:直接返回
// 3. 缓存未命中:调用项目详情 API 获取 project_id并存入缓存
// 参数: ctx - 运行时上下文,包含 Owner 和 Repo
// 返回: project_id字符串以及可能的错误
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
key := ctx.Owner + "/" + ctx.Repo
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil // 缓存命中,直接返回
}
// 调用项目详情 API 获取 project_id
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return "", fmt.Errorf("failed to fetch project details (needed for projectId): %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("unexpected response from project detail API")
}
pid, ok := data["project_id"]
if !ok {
return "", fmt.Errorf("project_id not found in project detail response")
}
// 处理 project_id 的多种类型API 返回可能是 float64、int 或 string
var pidStr string
switch v := pid.(type) {
case float64:
pidStr = fmt.Sprintf("%.0f", v) // JSON 数字默认解析为 float64
case int:
pidStr = fmt.Sprintf("%d", v)
default:
pidStr = fmt.Sprintf("%v", v)
}
projectIDCache.Store(key, pidStr) // 存入缓存
return pidStr, nil
}
// parseProjectIDInt 将项目 ID 字符串转换为整数
// 参数: pid - 项目 ID 字符串
// 返回: 整数形式的项目 ID转换失败返回 0
func parseProjectIDInt(pid string) int {
n, _ := strconv.Atoi(pid)
return n
}
// resolveUpdateContent 解析更新内容(优先使用命令行参数,其次使用文件)
// 参数: ctx - 运行时上下文; text - 命令行传入的内容; filePath - 文件路径
// 返回: 最终内容字符串,以及可能的错误
// 优先级text命令行参数 > filePath文件 > 报错
func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (string, error) {
if text != "" {
return text, nil // 优先使用命令行传入的内容
}
if filePath != "" {
data, err := os.ReadFile(filePath) // 从文件读取内容
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("no content provided")
}
// fetchPageContent 获取 wiki 页面明文内容(带自动重试)
//
// 自动重试策略GitLink 后端创建 wiki 时会自动给 sub_url 追加 ".-" 后缀,
// 而 wiki list 返回的 title 不带后缀。若首次用原始 pageName 查询失败且
// pageName 不带 ".-" 后缀,自动用 pageName+".-" 重试一次。
//
// 返回值:
// - content: 解码后的明文 markdown
// - actualPageName: 实际查询成功的 pageName可能带 ".-" 后缀),供调用方做后续写操作
// - err: 错误信息
func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (content string, actualPageName string, err error) {
c, actual, err := fetchPageContentOnce(ctx, projectID, pageName)
if err == nil {
return c, actual, nil // 首次尝试成功,直接返回
}
// 首次失败且 pageName 不带 ".-" 后缀:自动重试一次
if !strings.HasSuffix(pageName, ".-") {
c2, actual2, err2 := fetchPageContentOnce(ctx, projectID, pageName+".-")
if err2 == nil {
return c2, actual2, nil // 重试成功
}
}
return "", "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err)
}
// fetchPageContentOnce 单次尝试获取 wiki 页面内容(不做重试)
// 参数: ctx - 运行时上下文; projectID - 项目 ID; pageName - 页面名称
// 返回: 解码后的明文内容、实际页面名称、可能的错误
func fetchPageContentOnce(ctx *common.RuntimeContext, projectID, pageName string) (string, string, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
// 调用 getWiki API 获取页面内容
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if err != nil {
return "", "", err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("unexpected response from getWiki")
}
b64, _ := data["content_base64"].(string)
if b64 == "" {
return "", pageName, nil // 没有内容,返回空字符串
}
// Base64 解码
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", "", fmt.Errorf("failed to decode page content: %w", err)
}
return string(decoded), pageName, nil
}
// fetchWikiPage 按 pageName 查询 wiki 页面,返回完整的 envelope
// 与 fetchPageContent 的区别:
// - fetchPageContent 只返回解码后的内容
// - fetchWikiPage 返回完整的 API 响应,包含所有元数据
// 参数: ctx - 运行时上下文; projectID - 项目 ID; pageName - 页面名称
// 返回: 完整的响应信封,以及可能的错误
func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
}
// resolveContent 解析 wiki 页面内容(优先使用命令行参数,其次使用文件)
// 参数: ctx - 运行时上下文
// 返回: 内容字符串,以及可能的错误
// 优先级:--content 参数 > --file 参数 > 报错
func resolveContent(ctx *common.RuntimeContext) (string, error) {
if content := ctx.Arg("content"); content != "" {
return content, nil // 优先使用 --content 参数
}
if filePath := ctx.Arg("file"); filePath != "" {
data, err := os.ReadFile(filePath) // 从文件读取内容
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("--content or --file is required to provide wiki page content")
}
// cleanWikiList 清理 wiki 列表响应数据
// 功能:
// 1. 删除敏感字段 wiki_clone_link用户不需要看到
// 2. 对 sub_url 进行 URL 解码(后端返回的是编码后的)
// 参数: env - API 响应信封
func cleanWikiList(env *output.Envelope) {
items, ok := env.Data.([]interface{})
if !ok {
return
}
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
delete(m, "wiki_clone_link") // 删除敏感字段
if raw, ok := m["sub_url"].(string); ok {
if decoded, err := url.QueryUnescape(raw); err == nil {
m["sub_url"] = decoded // URL 解码
}
}
}
}
// outputWithDecodedContent 解码 wiki 响应中的所有 base64 字段,用明文替换原始乱码
//
// 设计权衡agent 友好性):
// - 后端返回的字段content_base64、sidebar、footer都是 base64 编码,对 agent 不可读
// - 解码后用明文替换/移除原始字段agent 可直接阅读、抽取、总结
// - 节省 ~33% tokenbase64 编码膨胀部分)
//
// 已知字段映射:
// - content_base64 → content重命名删除原字段
// - sidebar → sidebar原地替换仅当解码成功
// - footer → footer原地替换仅当解码成功
//
// 安全策略:仅当 base64.StdEncoding.DecodeString 成功时才替换;
// 若后端某天改为明文,解码失败会自动跳过,不影响兼容性。
func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) error {
data := env.Data
// 后端某些端点(如 createWiki/updateWiki把 data 返回为 JSON 字符串,
// client.go 会把它解析为 json.RawMessage而非 map这里先转回 map 再处理。
// view/getWiki 端点直接返回 JSON 对象data 已是 map[string]interface{}。
if raw, ok := data.(json.RawMessage); ok {
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err == nil {
data = m
env.Data = m
}
}
m, ok := data.(map[string]interface{})
if !ok {
return ctx.Output(env) // 不是 map 格式,直接输出
}
// content_base64 → content重命名
if b64, ok := m["content_base64"].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m["content"] = string(decoded)
delete(m, "content_base64") // 删除原始编码字段
}
}
// sidebar / footer原地替换仅当能解码为 base64 时)
for _, field := range []string{"sidebar", "footer"} {
if b64, ok := m[field].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m[field] = string(decoded)
}
}
}
return ctx.Output(env) // 输出处理后的响应
}
// --- lint types and implementation ---
// Lint 功能用于扫描 Wiki 页面内容,检查潜在问题:空页面、缺少标题、内容过短、死链接、失效图片
// LintIssue 表示单个检查问题
type LintIssue struct {
Page string `json:"page"` // 页面名称
Level string `json:"level"` // 级别:"error"(错误)或 "warning"(警告)
Check string `json:"check"` // 检查项名称empty/headings/short/links/images/fetch
Message string `json:"message"` // 详细错误/警告信息
}
// LintSummary 表示检查汇总报告
type LintSummary struct {
Repository string `json:"repository"` // 仓库名称owner/repo
TotalPages int `json:"total_pages"` // 检查的总页数
TotalIssues int `json:"total_issues"` // 发现的总问题数
Errors int `json:"errors"` // 错误数量
Warnings int `json:"warnings"` // 警告数量
Results []LintIssue `json:"results"` // 所有问题的详细列表
}
// 正则表达式:用于匹配 Markdown 内容中的链接和图片
var (
mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`) // 匹配 [链接文字](链接地址)
imageLinkRe = regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^)]+)\)`) // 匹配 ![图片说明](http://...)
)
// isCheckEnabled 检查指定的检查项是否启用
// checkFilter: 用户通过 --check 参数指定的检查项(逗号分隔),为空则启用全部
// name: 当前检查项名称
// 返回: true 表示启用false 表示禁用
func isCheckEnabled(checkFilter, name string) bool {
if checkFilter == "" {
return true // 没有指定过滤,全部启用
}
// 遍历用户指定的检查项列表,匹配则启用
for _, c := range strings.Split(checkFilter, ",") {
if strings.TrimSpace(c) == name {
return true
}
}
return false
}
// checkEmpty 检查页面是否为空
// page: 页面名称
// content: 页面内容
// 返回: 发现问题返回 LintIssue 列表,无问题返回 nil
func checkEmpty(page, content string) []LintIssue {
if strings.TrimSpace(content) == "" {
return []LintIssue{{Page: page, Level: "error", Check: "empty", Message: "page is empty"}}
}
return nil
}
// checkHeading 检查页面是否缺少 H1 标题(# 开头)
// Markdown 规范:页面应从一级标题开始
func checkHeading(page, content string) []LintIssue {
if content != "" && !strings.HasPrefix(strings.TrimSpace(content), "# ") {
return []LintIssue{{Page: page, Level: "warning", Check: "headings", Message: "missing H1 heading"}}
}
return nil
}
// checkShort 检查页面内容是否过短(少于 50 字符)
func checkShort(page, content string) []LintIssue {
if content != "" && len(content) < 50 {
return []LintIssue{{Page: page, Level: "warning", Check: "short", Message: fmt.Sprintf("content too short (%d chars)", len(content))}}
}
return nil
}
// checkDeadLinks 检查页面中是否有指向不存在页面的链接(死链接)
// page: 页面名称
// content: 页面内容
// knownTitles: 已知页面标题映射(用于判断链接目标是否存在)
// 逻辑:
// 1. 使用正则匹配所有 Markdown 链接 [文字](地址)
// 2. 跳过外部链接http://, https://)和锚点(#开头)
// 3. URL 解码处理(如 "首页%20页面" -> "首页 页面"
// 4. 检查目标页面是否在 knownTitles 中,不在则为死链接
func checkDeadLinks(page, content string, knownTitles map[string]bool) []LintIssue {
var issues []LintIssue
// 遍历所有匹配的 Markdown 链接
for _, m := range mdLinkRe.FindAllStringSubmatch(content, -1) {
if len(m) < 3 {
continue // 匹配不完整,跳过
}
target := m[2] // 获取链接地址正则捕获组第2个
// 跳过外部链接和锚点链接
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "#") {
continue
}
// URL 解码,处理编码后的页面名称
decoded, _ := url.PathUnescape(target)
if decoded == "" {
decoded = target
}
// 检查目标页面是否存在
if !knownTitles[decoded] && !knownTitles[target] {
issues = append(issues, LintIssue{
Page: page,
Level: "error",
Check: "links",
Message: fmt.Sprintf("dead link: [%s](%s) -> page %q not found", m[1], target, decoded),
})
}
}
return issues
}
// checkImages 检查页面中图片链接是否有效
// page: 页面名称
// content: 页面内容
// httpClient: 带超时的 HTTP 客户端(用于发送 HEAD 请求)
// 逻辑:
// 1. 使用正则匹配所有图片链接 ![说明](http://...)
// 2. 发送 HEAD 请求(只获取响应头,不下载图片内容)
// 3. 检查请求是否成功、HTTP 状态码是否 >= 400
func checkImages(page, content string, httpClient *http.Client) []LintIssue {
var issues []LintIssue
// 遍历所有匹配的图片链接
for _, m := range imageLinkRe.FindAllStringSubmatch(content, -1) {
if len(m) < 3 {
continue // 匹配不完整,跳过
}
imgURL := m[2] // 获取图片 URL正则捕获组第2个
// 创建 HEAD 请求(只获取响应头,节省带宽)
req, err := http.NewRequest("HEAD", imgURL, nil)
if err != nil {
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> invalid URL", imgURL)})
continue
}
// 发送请求并检查响应
resp, err := httpClient.Do(req)
if err != nil {
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> unreachable", imgURL)})
continue
}
resp.Body.Close() // 必须关闭响应体,防止资源泄漏
// HTTP 状态码 >= 400 表示图片不存在或无法访问
if resp.StatusCode >= 400 {
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> %d", imgURL, resp.StatusCode)})
}
}
return issues
}
// runLint 是 Wiki 内容检查的主函数
// 执行流程:
// 1. 解析仓库信息
// 2. 获取项目 ID
// 3. 获取所有页面列表,构建已知页面标题映射
// 4. 遍历每个页面,获取内容并执行各项检查
// 5. 统计错误和警告,输出汇总报告
func runLint(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息从命令行参数或 Git 远程仓库)
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 获取用户指定的检查项过滤(--check 参数,逗号分隔)
checkFilter := ctx.Arg("check")
fmt.Fprintf(os.Stderr, "Linting wiki pages for %s/%s...\n\n", ctx.Owner, ctx.Repo)
// 步骤2获取项目 ID用于调用 Wiki API
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
// 步骤3获取所有 Wiki 页面列表
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("wikiPages"), q)
if err != nil {
return fmt.Errorf("failed to list wiki pages: %w", err)
}
// 步骤4解析页面列表构建已知页面标题映射
// pageInfo 保存每个页面的标题和子路径(用于获取内容)
type pageInfo struct {
title string // 页面标题(用于显示和死链接检查)
subURL string // 页面子路径(用于获取页面内容)
}
knownTitles := make(map[string]bool) // 已知页面标题集合(用于检查死链接)
var pages []pageInfo // 所有页面列表
// 遍历 API 返回的页面数据
if items, ok := env.Data.([]interface{}); ok {
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue // 数据格式不对,跳过
}
title, _ := m["title"].(string)
subURL, _ := m["sub_url"].(string)
if title != "" {
knownTitles[title] = true // 记录到已知标题映射
if subURL == "" {
subURL = title // 如果没有子路径,用标题代替
}
pages = append(pages, pageInfo{title: title, subURL: subURL})
}
}
}
// 如果没有页面,直接返回空报告
if len(pages) == 0 {
fmt.Fprintln(os.Stderr, "No wiki pages found.")
return ctx.OutputData(LintSummary{
Repository: ctx.Owner + "/" + ctx.Repo,
Results: []LintIssue{},
})
}
// 创建带 5 秒超时的 HTTP 客户端(用于图片链接检查)
httpClient := &http.Client{Timeout: 5 * time.Second}
// 步骤5遍历每个页面执行各项检查
var allIssues []LintIssue // 收集所有发现的问题
for _, p := range pages {
// 跳过系统页面(以下划线开头,如 _Sidebar, _Footer
if strings.HasPrefix(p.title, "_") {
continue
}
// 获取页面内容
content, _, err := fetchPageContent(ctx, projectID, p.subURL)
if err != nil {
allIssues = append(allIssues, LintIssue{Page: p.title, Level: "error", Check: "fetch", Message: fmt.Sprintf("failed to fetch: %v", err)})
continue // 获取内容失败,跳过该页面的其他检查
}
// 根据用户配置,执行各项检查
if isCheckEnabled(checkFilter, "empty") {
allIssues = append(allIssues, checkEmpty(p.title, content)...)
}
if isCheckEnabled(checkFilter, "headings") {
allIssues = append(allIssues, checkHeading(p.title, content)...)
}
if isCheckEnabled(checkFilter, "short") {
allIssues = append(allIssues, checkShort(p.title, content)...)
}
if isCheckEnabled(checkFilter, "links") {
allIssues = append(allIssues, checkDeadLinks(p.title, content, knownTitles)...)
}
if isCheckEnabled(checkFilter, "images") {
allIssues = append(allIssues, checkImages(p.title, content, httpClient)...)
}
}
// 步骤6统计错误和警告数量
var errCount, warnCount int
for _, issue := range allIssues {
if issue.Level == "error" {
errCount++
} else {
warnCount++
}
}
// 步骤7在控制台输出详细结果✗ 表示错误,⚠ 表示警告)
for _, issue := range allIssues {
if issue.Level == "error" {
fmt.Fprintf(os.Stderr, " ✗ %s - %s\n", issue.Page, issue.Message)
} else {
fmt.Fprintf(os.Stderr, " ⚠ %s - %s\n", issue.Page, issue.Message)
}
}
fmt.Fprintf(os.Stderr, "\nSummary: %d pages, %d errors, %d warnings\n", len(pages), errCount, warnCount)
// 步骤8返回 JSON 格式的汇总报告
return ctx.OutputData(LintSummary{
Repository: ctx.Owner + "/" + ctx.Repo,
TotalPages: len(pages),
TotalIssues: len(allIssues),
Errors: errCount,
Warnings: warnCount,
Results: allIssues,
})
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List all wiki pages",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("wikiPages"), q)
if err != nil {
// wiki +list 的 404 几乎总是意味着仓库未在 GitLink 网页端开启 Wiki 功能。
// 这种情况下不要再提示"用 +list 查看"(循环引用),改为引导用户去开启 Wiki。
var cliErr *clierrors.CLIError
if errors.As(err, &cliErr) && cliErr.Kind == clierrors.KindNotFound {
return clierrors.New(clierrors.KindNotFound,
fmt.Sprintf("仓库 %s/%s 没有 Wiki 页面", ctx.Owner, ctx.Repo),
fmt.Sprintf("请前往 GitLink 网页端 → 仓库 %s/%s → 设置 → 开启 Wiki 功能,开启后再创建页面", ctx.Owner, ctx.Repo))
}
return fmt.Errorf("获取 Wiki 页面列表失败: %w", err)
}
cleanWikiList(env)
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View a wiki page",
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Page title", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
env, err := fetchWikiPage(ctx, projectID, title)
if err != nil {
// GitLink 后端命名规则: 创建 wiki 时会自动给 sub_url 追加 ".-" 后缀,
// 而 wiki +list 返回的 title 不带后缀。若 title 不带后缀且首次查询失败,
// 自动用 title + ".-" 重试一次。
if !strings.HasSuffix(title, ".-") {
env, err = fetchWikiPage(ctx, projectID, title+".-")
}
}
if err != nil {
// 用 CLIError 包装,保留底层错误类型,让 TryPrintError 能按 envelope 输出。
// suggestion 同时涵盖两种常见根因:(1) 仓库未开启 Wiki(2) 页面名拼错。
return clierrors.Wrap(clierrors.KindNotFound,
fmt.Sprintf("Wiki 页面 %q 不存在", title),
fmt.Sprintf("请确认:(1) 仓库 %s/%s 已在 GitLink 网页端开启 Wiki 功能;(2) 页面名拼写正确。可用 `gitlink-cli wiki +list --owner %s --repo %s` 查看实际存在的页面",
ctx.Owner, ctx.Repo, ctx.Owner, ctx.Repo),
err)
}
return outputWithDecodedContent(ctx, env)
},
},
{
Name: "create",
Description: "Create a wiki page",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
return fmt.Sprintf("Create wiki page: %s", title), nil
},
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Page title", Required: true},
{Name: "content", Short: "c", Usage: "Wiki page content (plain text, will be base64-encoded)"},
{Name: "file", Short: "f", Usage: "Read content from file"},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
content, err := resolveContent(ctx)
if err != nil {
return err
}
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": parseProjectIDInt(projectID),
"pageName": title,
"title": title,
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
}
if msg := ctx.Arg("message"); msg != "" {
body["message"] = msg
}
env, err := callWikiAPI(ctx, "POST", wikiPath("createWiki"), body)
if err != nil {
return fmt.Errorf("创建 Wiki 页面失败: %w", err)
}
return outputWithDecodedContent(ctx, env)
},
},
{
Name: "update",
Description: "Update a wiki page",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
return fmt.Sprintf("Update wiki page: %s", title), nil
},
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Current page title to find (defaults to --title)"},
{Name: "title", Short: "t", Usage: "New page title", Required: true},
{Name: "cover", Short: "c", Usage: "Replace entire page content with this text"},
{Name: "add", Short: "a", Usage: "Append text to existing page content"},
{Name: "file", Short: "f", Usage: "Read content from file (used with --cover or --add)"},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err
}
pageName := ctx.Arg("page")
if pageName == "" {
pageName = title
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
coverText := ctx.Arg("cover")
addText := ctx.Arg("add")
filePath := ctx.Arg("file")
message := ctx.Arg("message")
var finalContent string
if coverText != "" || filePath != "" && coverText == "" && addText == "" {
// --cover or --file alone: overwrite
content, err := resolveUpdateContent(ctx, coverText, filePath)
if err != nil {
return err
}
finalContent = content
} else if addText != "" {
// --add: append to existing content
newPart, err := resolveUpdateContent(ctx, addText, filePath)
if err != nil {
return err
}
existing, actualPageName, err := fetchPageContent(ctx, projectID, pageName)
if err != nil {
return fmt.Errorf("failed to fetch existing page content for append: %w", err)
}
// fetchPageContent 可能因 GitLink 后端 ".-" 命名规则触发自动重试,
// 用实际成功的 pageName可能带 .- 后缀)作为 PUT 目标,否则后端会再次 404。
pageName = actualPageName
finalContent = existing + newPart
}
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": parseProjectIDInt(projectID),
"pageName": url.QueryEscape(pageName),
"title": title,
"message": message,
}
if finalContent != "" {
body["content_base64"] = base64.StdEncoding.EncodeToString([]byte(finalContent))
}
env, err := callWikiAPI(ctx, "PUT", wikiPath("updateWiki"), body)
if err != nil {
return fmt.Errorf("更新 Wiki 页面失败: %w", err)
}
return outputWithDecodedContent(ctx, env)
},
},
{
Name: "delete",
Description: "Delete a wiki page",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
return fmt.Sprintf("Delete wiki page: %s", title), nil
},
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Page title", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
// 1. 解析实际 pageNamefetchPageContent 内部自动重试 ".-" 后缀)
// 不做这步直接用 title DELETE会导致后端"接受了请求但没真删"
// (后端期望的 pageName 是 "X.-" 而非 "X"
_, actualPageName, err := fetchPageContent(ctx, projectID, title)
if err != nil {
// 页面查不到 — 后端 getWiki API 返回 404。
// suggestion 同时涵盖两种常见根因:(1) 仓库未开启 Wiki(2) 页面名拼错。
// 注意GitLink 网页端对任意 ?wiki=xxx 都会渲染 SPA 壳子,
// 不代表页面真实存在;以 wiki +list 的结果为准。
return clierrors.New(clierrors.KindNotFound,
fmt.Sprintf("Wiki 页面 %q 不存在", title),
fmt.Sprintf("请确认:(1) 仓库 %s/%s 已在 GitLink 网页端开启 Wiki 功能;(2) 页面名拼写正确。可用 `gitlink-cli wiki +list --owner %s --repo %s` 查看实际存在的页面",
ctx.Owner, ctx.Repo, ctx.Owner, ctx.Repo))
}
// 2. 用实际 pageName 调用 DELETE
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": parseProjectIDInt(projectID),
"pageName": actualPageName,
"message": "",
}
if _, delErr := callWikiAPI(ctx, "DELETE", wikiPath("deleteWiki"), body); delErr != nil {
return fmt.Errorf("删除 Wiki 页面失败: %w", delErr)
}
// 3. 删除后强制验证GitLink deleteWiki 端点不可靠:即使返回 200
// 页面有时仍然存在。GET 一次确认页面真的没了。
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", actualPageName)
if _, viewErr := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q); viewErr == nil {
// GET 还能查到 — 后端接受了 DELETE 但没真删
return fmt.Errorf("删除 Wiki 页面失败:后端接受了请求但页面仍存在 (pageName=%s)", actualPageName)
}
// 删除成功 — 返回结构化数据,让用户/agent 能自助验证。
// 说明三种用户常见的"以为没删干净"的现象:
// 1) 网页端 ?wiki=xxx 仍可访问 → GitLink SPA 占位符(任何参数都渲染壳子)
// 2) git log 仍能看到删除 commit → git 设计就是保留历史
// 3) edit 跳转到 wiki=undefined → 网页端前端未正确处理 404
// 这些都不是 CLI 删除不彻底,是 GitLink 网页端的 UX 问题。
return ctx.OutputData(map[string]interface{}{
"message": "Wiki page deleted successfully",
"deleted_title": title,
"actual_page_name": actualPageName,
"wiki_repo": fmt.Sprintf("https://gitlink.org.cn/%s/%s.wiki.git", ctx.Owner, ctx.Repo),
"notes": []string{
"页面已从 wiki 仓库 HEAD 彻底删除git 工作树无残留)",
"网页端 ?wiki=xxx URL 仍可访问是 SPA 占位符,不代表页面存在",
"git 历史 commits 仍保留删除记录git 的正常行为,非残留)",
},
"verify_commands": []string{
fmt.Sprintf("gitlink-cli wiki +list --owner %s --repo %s", ctx.Owner, ctx.Repo),
fmt.Sprintf("git clone https://gitlink.org.cn/%s/%s.wiki.git /tmp/wiki-check && git -C /tmp/wiki-check ls-files", ctx.Owner, ctx.Repo),
},
})
},
},
{
Name: "lint",
Description: "Check wiki pages for quality issues",
Flags: []common.Flag{
{Name: "check", Usage: "Specific checks to run (comma-separated): links,headings,images,empty. Default: all"},
},
Run: runLint,
},
}
}

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

@ -0,0 +1,663 @@
package wiki
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
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")
}
// 必须是 *clierrors.CLIError否则 TryPrintError 无法按 envelope 输出
var cliErr *clierrors.CLIError
if !errors.As(err, &cliErr) {
t.Fatalf("expected *clierrors.CLIError, got %T: %v", err, err)
}
if cliErr.Kind != clierrors.KindServer {
t.Errorf("Kind = %q, want %q", cliErr.Kind, clierrors.KindServer)
}
if cliErr.Message != "内部错误" {
t.Errorf("Message = %q, want %q", cliErr.Message, "内部错误")
}
}
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")
}
}
// ---- callWikiAPI HTTP request path tests ----
func TestCallWikiAPI_Success(t *testing.T) {
resetProjectIDCache()
var receivedPath, receivedMethod string
var receivedBody []byte
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
receivedMethod = r.Method
if r.Body != nil {
buf := make([]byte, 1024)
n, _ := r.Body.Read(buf)
receivedBody = buf[:n]
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":200,"msg":"ok","data":{"id":42}}`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
env, err := callWikiAPI(ctx, "POST", "/wiki/open/test", map[string]string{"foo": "bar"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedMethod != "POST" {
t.Errorf("method = %q, want POST", receivedMethod)
}
if receivedPath != "/wiki/open/test" {
t.Errorf("path = %q, want /wiki/open/test", receivedPath)
}
if !strings.Contains(string(receivedBody), `"foo"`) {
t.Errorf("body should contain foo: %s", string(receivedBody))
}
data, ok := env.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map data, got %T", env.Data)
}
if data["id"] != float64(42) {
t.Errorf("data[id] = %v, want 42", data["id"])
}
}
func TestCallWikiAPI_BusinessError(t *testing.T) {
resetProjectIDCache()
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":400,"msg":"bad request","data":null}`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
_, err := callWikiAPI(ctx, "GET", "/test", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
// 必须是 *clierrors.CLIErrorcode=400 对应 KindForbidden401/403或 KindServer
// (其他非 200/404 错误),这里 400 走 KindServer 分支
var cliErr *clierrors.CLIError
if !errors.As(err, &cliErr) {
t.Fatalf("expected *clierrors.CLIError, got %T: %v", err, err)
}
if cliErr.Message != "bad request" {
t.Errorf("Message = %q, want %q", cliErr.Message, "bad request")
}
if cliErr.Suggestion == "" {
t.Errorf("Suggestion should not be empty (helps user recover)")
}
}
func TestCallWikiAPI_GatewayHTTPError(t *testing.T) {
resetProjectIDCache()
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(`upstream error`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
_, err := callWikiAPI(ctx, "GET", "/test", nil)
if err == nil {
t.Fatal("expected error on 502")
}
}
func TestCallWikiAPI_SkipsJSONSuffix(t *testing.T) {
// Verifies the SkipJSONSuffix path is correctly taken for gateway:
// the URL should NOT have a .json appended.
resetProjectIDCache()
var receivedPath string
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
w.Write([]byte(`{"code":200,"data":{}}`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
_, err := callWikiAPI(ctx, "GET", "/wiki/open/list", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedPath != "/wiki/open/list" {
t.Errorf("path = %q, want /wiki/open/list (no .json suffix)", receivedPath)
}
if strings.HasSuffix(receivedPath, ".json") {
t.Errorf("path %q should NOT have .json suffix (gateway expects no suffix)", receivedPath)
}
}
func TestCallWikiAPI_ConnectionRefused(t *testing.T) {
resetProjectIDCache()
// Use an unbound port to simulate connection failure
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: &http.Client{}, BaseURL: "http://127.0.0.1:1"},
GatewayBaseURL: "http://127.0.0.1:1",
GatewayHTTPClient: &http.Client{},
}
_, err := callWikiAPI(ctx, "GET", "/test", nil)
if err == nil {
t.Fatal("expected connection error")
}
}
// ---- runLint / check rules tests ----
func TestIsCheckEnabled_Empty(t *testing.T) {
if !isCheckEnabled("", "any") {
t.Error("empty filter should enable all checks")
}
if !isCheckEnabled("empty,headings", "empty") {
t.Error("should enable 'empty' in filter list")
}
if isCheckEnabled("headings", "empty") {
t.Error("should not enable 'empty' when not in filter list")
}
if !isCheckEnabled(" empty , headings ", "empty") {
t.Error("should trim whitespace")
}
}
func TestCheckEmpty(t *testing.T) {
issues := checkEmpty("p1", "")
if len(issues) != 1 || issues[0].Level != "error" || issues[0].Check != "empty" {
t.Errorf("expected 1 error-level 'empty' issue, got %+v", issues)
}
if issues := checkEmpty("p1", "some content"); issues != nil {
t.Errorf("non-empty content should not produce issues, got %+v", issues)
}
if issues := checkEmpty("p1", " \n\t "); len(issues) != 1 {
t.Errorf("whitespace-only content should be empty, got %+v", issues)
}
}
func TestCheckHeading(t *testing.T) {
// missing H1
if issues := checkHeading("p1", "Some text without heading"); len(issues) != 1 {
t.Errorf("expected 1 missing-heading issue, got %+v", issues)
}
// has H1
if issues := checkHeading("p1", "# Title\nbody"); issues != nil {
t.Errorf("H1 should not produce issues, got %+v", issues)
}
// empty content skipped
if issues := checkHeading("p1", ""); issues != nil {
t.Errorf("empty content should be skipped, got %+v", issues)
}
// whitespace prefix
if issues := checkHeading("p1", " \n# Real Title"); issues != nil {
t.Errorf("H1 after whitespace should not produce issues, got %+v", issues)
}
}
func TestCheckShort(t *testing.T) {
if issues := checkShort("p1", ""); issues != nil {
t.Errorf("empty content should be skipped, got %+v", issues)
}
if issues := checkShort("p1", "short"); len(issues) != 1 {
t.Errorf("expected 1 short issue, got %+v", issues)
}
long := strings.Repeat("a", 100)
if issues := checkShort("p1", long); issues != nil {
t.Errorf("long content should not produce issues, got %+v", issues)
}
// exactly 49 chars triggers
if issues := checkShort("p1", strings.Repeat("a", 49)); len(issues) != 1 {
t.Errorf("49-char content should be 'short', got %+v", issues)
}
}
func TestCheckDeadLinks(t *testing.T) {
known := map[string]bool{"Home": true, "Guide": true}
// All known: no issues
if issues := checkDeadLinks("p1", "[Home](Home) and [Guide](Guide)", known); issues != nil {
t.Errorf("all-known should not produce issues, got %+v", issues)
}
// Unknown link
issues := checkDeadLinks("p1", "[Unknown](Unknown)", known)
if len(issues) != 1 || issues[0].Check != "links" {
t.Errorf("expected 1 dead link issue, got %+v", issues)
}
// External links skipped
if issues := checkDeadLinks("p1", "[ext](https://example.com)", known); issues != nil {
t.Errorf("external links should be skipped, got %+v", issues)
}
// Anchor links skipped
if issues := checkDeadLinks("p1", "[anchor](#section)", known); issues != nil {
t.Errorf("anchor links should be skipped, got %+v", issues)
}
// Mixed
issues = checkDeadLinks("p1", "[Home](Home) and [Bad](BadPage)", known)
if len(issues) != 1 {
t.Errorf("expected 1 dead link in mixed, got %+v", issues)
}
// Empty content
if issues := checkDeadLinks("p1", "", known); issues != nil {
t.Errorf("empty content should not produce issues, got %+v", issues)
}
}
func TestCheckImages(t *testing.T) {
// Mock image server
imgServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "HEAD" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
})
defer imgServer.Close()
brokenServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
defer brokenServer.Close()
httpClient := imgServer.Client()
// Valid image (200)
if issues := checkImages("p1", "![ok]("+imgServer.URL+"/img.png)", httpClient); issues != nil {
t.Errorf("200 image should not produce issues, got %+v", issues)
}
// Broken image (404)
issues := checkImages("p1", "![bad]("+brokenServer.URL+"/missing.png)", httpClient)
if len(issues) != 1 {
t.Errorf("expected 1 broken image issue, got %+v", issues)
}
// No images
if issues := checkImages("p1", "no images here", httpClient); issues != nil {
t.Errorf("no images should not produce issues, got %+v", issues)
}
// Malformed HTTP URL (regex matches https?:// but http.NewRequest fails to parse)
if issues := checkImages("p1", "![bad](http://[)", httpClient); len(issues) != 1 {
t.Errorf("expected 1 invalid-URL issue, got %+v", issues)
}
}
func TestRunLint_Integration(t *testing.T) {
resetProjectIDCache()
// Mock main API (project detail) and gateway (wiki list + get)
mainServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/detail.json") {
writeJSON(t, w, map[string]interface{}{"project_id": float64(999)})
return
}
t.Errorf("unexpected main API call: %s %s", r.Method, r.URL.Path)
})
defer mainServer.Close()
// Page content (base64 encoded)
goodContent := base64.StdEncoding.EncodeToString([]byte("# Good Page\n\n" + strings.Repeat("This is a well-formed page with enough content to pass the short check. ", 3)))
wikiServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/wiki/open/wikiPages":
writeJSON(t, w, map[string]interface{}{
"code": 200,
"data": []map[string]interface{}{
{"title": "Good", "sub_url": "Good"},
{"title": "Empty", "sub_url": "Empty"},
{"title": "_Sidebar", "sub_url": "_Sidebar"}, // system page - skipped
},
})
case "/wiki/open/getWiki":
pageName := r.URL.Query().Get("pageName")
var content string
if pageName == "Empty" {
content = "" // empty page
} else {
content = goodContent
}
writeJSON(t, w, map[string]interface{}{
"code": 200,
"data": map[string]interface{}{"content_base64": content},
})
default:
t.Errorf("unexpected wiki path: %s", r.URL.Path)
}
})
defer wikiServer.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: mainServer.Client(), BaseURL: mainServer.URL},
Owner: "owner1",
Repo: "repo1",
Format: "json",
GatewayBaseURL: wikiServer.URL,
GatewayHTTPClient: wikiServer.Client(),
}
if err := runLint(ctx); err != nil {
t.Fatalf("runLint: %v", err)
}
// _Sidebar is skipped, so TotalPages=2 (Good, Empty)
// Empty page produces 1 "empty" error
// We can't directly inspect the output envelope, but if no error, the function ran end-to-end
}
// ---- resolveContent tests ----
func TestResolveContent_FromArg(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{"content": "inline content"},
}
got, err := resolveContent(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "inline content" {
t.Errorf("got %q, want %q", got, "inline content")
}
}
func TestResolveContent_FromFile(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "wiki.md")
want := "# Title\n\nBody content from file"
if err := os.WriteFile(path, []byte(want), 0600); err != nil {
t.Fatalf("setup: %v", err)
}
ctx := &common.RuntimeContext{
Args: map[string]string{"file": path},
}
got, err := resolveContent(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestResolveContent_ArgTakesPrecedence(t *testing.T) {
// When both --content and --file are set, --content wins
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "wiki.md")
if err := os.WriteFile(path, []byte("from file"), 0600); err != nil {
t.Fatalf("setup: %v", err)
}
ctx := &common.RuntimeContext{
Args: map[string]string{
"content": "from arg",
"file": path,
},
}
got, err := resolveContent(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "from arg" {
t.Errorf("arg should take precedence; got %q", got)
}
}
func TestResolveContent_Missing(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{},
}
_, err := resolveContent(ctx)
if err == nil {
t.Fatal("expected error when neither --content nor --file is provided")
}
if !strings.Contains(err.Error(), "required") {
t.Errorf("err = %q, want to mention 'required'", err.Error())
}
}
func TestResolveContent_FileNotFound(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{"file": "/nonexistent/path/to/wiki.md"},
}
_, err := resolveContent(ctx)
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}

View File

@ -92,6 +92,22 @@ skills/
│ ├── REFERENCE.md # Release API 参考
│ └── examples/
│ └── release-workflow.md # Release 工作流
├── gitlink-changelog/ # Release Notes / Changelog 生成
│ ├── SKILL.md # Changelog 操作指南
│ ├── references/
│ │ ├── collect-data.md # 收集变更数据
│ │ ├── classify-rules.md # 变更分类规则
│ │ └── generate-and-publish.md # 生成并发布
│ └── examples/
│ └── full-workflow.md # 完整生成示例
├── gitlink-health/ # 项目健康度报告
│ ├── SKILL.md # 健康度报告操作指南
│ ├── references/
│ │ ├── collect-data.md # 收集项目数据
│ │ ├── health-metrics.md # 指标计算和评分规则
│ │ └── generate-report.md # 报告生成和输出
│ └── examples/
│ └── full-workflow.md # 完整生成示例
├── gitlink-search/ # 搜索功能
│ ├── SKILL.md # 搜索操作指南
│ └── examples/
@ -106,10 +122,29 @@ skills/
│ ├── SKILL.md # CI 操作指南
│ └── examples/
│ └── ci-workflow.md # CI 工作流
├── gitlink-wiki/ # Wiki 管理
│ └── SKILL.md # Wiki 操作指南
├── gitlink-pm/ # 项目管理
│ └── SKILL.md # PM 操作指南
└── gitlink-workflow/ # AI 自动化工作流
└── SKILL.md # 工作流模板Issue 分类、PR Review、Release Notes
├── gitlink-workflow/ # AI 自动化工作流
│ └── SKILL.md # 工作流模板Issue 分类、PR Review、Release Notes
├── gitlink-issue-triage/ # Issue 自动分类
│ ├── SKILL.md # AI Agent 主入口
│ ├── README.md # 使用说明
│ ├── references/ # 分析算法 + 应用手册
│ └── examples/ # 批量/单 Issue 工作流示例
├── gitlink-webhook/ # Webhook 管理
│ └── SKILL.md # Webhook 操作指南
├── gitlink-compliance/ # 安全与合规
│ └── SKILL.md # 许可证、敏感信息、PII 扫描
├── gitlink-onboard/ # 新人引导
│ └── SKILL.md # Good First Issue 识别与欢迎评论
├── gitlink-team/ # 团队管理
│ └── SKILL.md # 团队操作指南
├── gitlink-contrib/ # 贡献报告
│ └── SKILL.md # 贡献统计与报告
└── gitlink-code-insight/ # 功能全景
└── SKILL.md # 全部 Shortcuts 分类展示
```
---
@ -126,6 +161,7 @@ skills/
| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +review` |
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` |
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` |
| **gitlink-health** | 项目健康度报告 | Issue 响应时间、PR 合并效率、贡献者活跃度统计 |
### 辅助 Skills
@ -135,8 +171,17 @@ skills/
| **gitlink-user** | 用户管理 | `user +me`, `user +info` |
| **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` |
| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` |
| **gitlink-wiki** | Wiki 管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` |
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
| **gitlink-changelog** | Release Notes / Changelog 生成 | 自动收集 commits/PR/Issue生成结构化版本说明 |
| **gitlink-issue-triage** | Issue 自动分类 | 自动判定 tracker/priority/labels关联 Issue生成审计报告 |
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、仓库初始化、Sprint 报告 |
| **gitlink-webhook** | Webhook 管理 | `webhook +list`, `webhook +create`, `webhook +test` |
| **gitlink-compliance** | 安全与合规 | `compliance +scan`, `compliance +secrets`, `compliance +license` |
| **gitlink-onboard** | 新人引导 | `onboard +welcome` |
| **gitlink-team** | 团队管理 | `team +list`, `team +create`, `team +add-member` |
| **gitlink-contrib** | 贡献报告 | `contrib +report` |
| **gitlink-code-insight** | 功能全景 | 全部 Shortcuts 分类索引,含说明和示例 |
---
@ -210,6 +255,27 @@ gitlink-cli org +info -i Gitlink
详见: [gitlink-search/examples/search-workflow.md](gitlink-search/examples/search-workflow.md)
### 场景 5管理 Wiki 文档
```bash
# 列出 Wiki 页面
gitlink-cli wiki +list --owner myuser --repo myrepo
# 查看页面内容
gitlink-cli wiki +view --owner myuser --repo myrepo --title "Home"
# 创建页面(从文件)
gitlink-cli wiki +create --owner myuser --repo myrepo --title "API 文档" --file ./api.md
# 追加内容到现有页面
gitlink-cli wiki +update --owner myuser --repo myrepo --title "API 文档" --add "\n\n## 新增接口"
# 预览删除(不实际执行)
gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面" --dry-run
```
详见: [gitlink-wiki/SKILL.md](gitlink-wiki/SKILL.md)
---
## 📚 文档导航
@ -298,6 +364,7 @@ AI 代理可以:
- ✅ 自动创建和管理 Issue
- ✅ 自动创建和合并 PR
- ✅ 自动发布 Release
- ✅ 自动管理 Wiki 文档
- ✅ 自动分类 Issue
- ✅ 自动生成 Release Notes
- ✅ 自动执行代码审查

View File

@ -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 <name> --from <source>`.
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) — 认证和全局参数

View File

@ -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 <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) — 认证和全局参数

View File

@ -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) — 认证和全局参数

View File

@ -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 <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) — 认证和全局参数

View File

@ -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 <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) — 认证和全局参数

View File

@ -0,0 +1,142 @@
---
name: gitlink-changelog
version: 1.0.0
description: "Release Notes 生成:根据 commit 和 PR 记录自动生成结构化版本发布说明。当用户需要生成 Release Notes、发版说明时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli release --help"
---
# gitlink-changelogRelease Notes 生成)
**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) 了解认证和全局参数。
## 工作流
Release Notes 生成分三步:收集 → 分类 → 发布。
| 步骤 | 说明 | 所用命令 |
|------|------|----------|
| 1. 收集数据 | 获取版本间的 commits、已合并 PR、已关闭 Issue | `release +list`, `api GET compare`, `pr +list`, `issue +list` |
| 2. 分类整理 | 按类型归类变更(新功能/Bug修复/改进/破坏性变更) | AI 分析 |
| 3. 生成发布 | 套用模板生成 Notes创建或更新 Release | `release +create`, `release +update` |
## 命令参考
### 收集数据
```bash
# 确定版本范围:获取已有 release 列表,找上一个 tag
gitlink-cli release +list --format json
# 获取两个版本间的 commit 差异(平台 compare API
gitlink-cli api GET /:owner/:repo/compare/v1.0.0...v1.1.0 --format json
# 获取已合并的 PR
gitlink-cli pr +list --state merged --format json
# 获取已关闭的 Issue
gitlink-cli issue +list --state closed --format json
```
### 发布 Release Notes
```bash
# 创建 Release 并附带 Notes
gitlink-cli release +create --tag v1.1.0 --name "v1.1.0" --body "<Markdown 格式的 Release Notes>"
# 更新已有 Release 的 Notes
gitlink-cli release +update --id <version_id> --body "<更新后的 Notes>"
```
## 分类规则
| 类型 | 图标 | Issue 标签 | Commit 关键词 |
|------|------|------------|---------------|
| 新功能 | ✨ | `feature`, `enhancement` | `feat:`, `add`, `新增` |
| Bug 修复 | 🐛 | `bug`, `fix` | `fix:`, `bugfix`, `修复` |
| 功能改进 | 🔧 | `improvement`, `optimize` | `improve:`, `optimize:`, `refactor:` |
| 破坏性变更 | ⚠️ | `breaking`, `major` | `BREAKING`, `breaking:`, `!` |
| 文档 | 📝 | `docs`, `documentation` | `docs:`, `doc` |
| 安全修复 | 🔒 | `security`, `vulnerability` | `security:`, `安全` |
## Release Notes 模板
### 标准模板
```markdown
# 🎉 Release {VERSION}
## 📊 变更统计
- **新功能**: {FEATURE_COUNT} 个
- **Bug 修复**: {BUG_FIX_COUNT} 个
- **功能改进**: {ENHANCEMENT_COUNT} 个
- **破坏性变更**: {BREAKING_COUNT} 个
## ✨ 新功能
{FEATURES}
## 🐛 Bug 修复
{BUG_FIXES}
## 🔧 功能改进
{ENHANCEMENTS}
## ⚠️ 破坏性变更
{BREAKING_CHANGES}
## 🙏 贡献者
{CONTRIBUTORS}
---
**完整变更日志**: https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION}
```
> **条目格式要求**:每个变更条目必须在末尾标注作者,格式为 `- 变更描述 (#编号) (@作者)`。commit 无 PR 编号时格式为 `- 变更描述 (作者名)`
### 简化模板
```markdown
# {VERSION}
## 新增
{FEATURES}
## 修复
{BUG_FIXES}
## 改进
{ENHANCEMENTS}
```
## 版本号规范
遵循语义化版本Semantic Versioning`MAJOR.MINOR.PATCH`
| 变更类型 | 版本变化 | 示例 |
|----------|----------|------|
| 破坏性变更 | MAJOR +1 | `1.2.0``2.0.0` |
| 向后兼容的新功能 | MINOR +1 | `1.1.0``1.2.0` |
| 向后兼容的 Bug 修复 | PATCH +1 | `1.1.0``1.1.1` |
## API 注意事项
- `compare` API 无专用 shortcut通过 `gitlink-cli api GET /:owner/:repo/compare/{head}...{base}` 调用
- `compare` API 另支持查询参数格式:`GET /v1/:owner/:repo/compare.json?from=&to=`
- `release +create``--body` 接受完整 Markdown支持多行文本
- `release +view` / `release +delete` 必须使用 `version_id`(从 `release +list` 获取),不可用 `tag_name`
- 创建 Release 前务必让用户审核生成的 Notes 内容
## References
- [collect-data](references/collect-data.md) — 收集 commits、PR、Issue 数据
- [classify-rules](references/classify-rules.md) — 变更分类规则详解
- [generate-and-publish](references/generate-and-publish.md) — 生成 Notes 并发布
- [full-workflow](examples/full-workflow.md) — 完整端到端示例
- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证和全局参数
- [gitlink-release](../gitlink-release/SKILL.md) — Release 操作

View File

@ -0,0 +1,152 @@
# Release Notes 完整生成示例
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
> **适用场景:** AI Agent 端到端生成 Release Notes从数据收集到发布。
`zzx-coder/gitlink-cli` 项目从 `v1.0.0``v1.1.0` 为例。
## 完整流程
### 第一步:确定版本范围
```bash
# 获取已有 release
gitlink-cli release +list --format json
# 返回示例(截取关键字段):
# {
# "ok": true,
# "data": {
# "releases": [
# { "tag_name": "v1.0.0", "created_at": "2026-05-01", ... },
# ...
# ]
# }
# }
# AI 据此确定PREV_VERSION = "v1.0.0"NEW_VERSION = "v1.1.0"
```
### 第二步:收集 commits
```bash
gitlink-cli api GET /:owner/:repo/compare/v1.0.0...v1.1.0 --format json
# 从返回中提取 commits 列表,每个 commit 含:
# - commit.message (提交信息)
# - commit.author.name (作者)
# - sha (提交 SHA)
```
### 第三步:收集已合并 PR
```bash
gitlink-cli pr +list --state merged --format json
# AI 筛选 merged_at >= "2026-05-01"v1.0.0 发布时间)的 PR
# 提取每个 PR 的 title、number、author.login
```
### 第四步:收集已关闭 Issue
```bash
gitlink-cli issue +list --state closed --format json
# AI 筛选 closed_at >= "2026-05-01" 的 Issue
# 提取每个 Issue 的 subject、project_issues_index、issue_tags
```
### 第五步AI 分类
AI 根据 [分类规则](../references/classify-rules.md) 对收集到的数据分类:
```
新功能:
- 支持批量 Issue 操作 (#12) (@zhangsan)
- 新增 uninstall 命令 (#11) (@lisi)
Bug 修复:
- 修复 URL 解析异常 (#10) (@wangwu)
功能改进:
- 重构自动部署配置 (camelliamc)
文档:
- 更新分支映射说明 (camelliamc)
```
### 第六步:生成 Notes 并确认
AI 套用标准模板生成草稿并展示给用户:
```markdown
# 🎉 Release v1.1.0
## 📊 变更统计
- **新功能**: 2 个
- **Bug 修复**: 1 个
- **功能改进**: 1 个
- **破坏性变更**: 0 个
## ✨ 新功能
- 支持批量 Issue 操作 (#12) (@zhangsan)
- 新增 uninstall 命令 (#11) (@lisi)
## 🐛 Bug 修复
- 修复 URL 解析异常 (#10) (@wangwu)
## 🔧 功能改进
- 重构自动部署配置 (camelliamc)
## 🙏 贡献者
zzx-coder, camelliamc
---
**完整变更日志**: https://www.gitlink.org.cn/zzx-coder/gitlink-cli/compare/v1.0.0...v1.1.0
```
### 第七步:用户确认后发布
```bash
gitlink-cli release +create \
--tag v1.1.0 \
--name "v1.1.0" \
--body "# 🎉 Release v1.1.0
## 📊 变更统计
- **新功能**: 2 个
- **Bug 修复**: 1 个
- **功能改进**: 1 个
- **破坏性变更**: 0 个
## ✨ 新功能
- 支持批量 Issue 操作 (#12) (@zhangsan)
- 新增 uninstall 命令 (#11) (@lisi)
## 🐛 Bug 修复
- 修复 URL 解析异常 (#10) (@wangwu)
## 🔧 功能改进
- 重构自动部署配置 (camelliamc)
## 🙏 贡献者
zhangsan, lisi, wangwu, camelliamc
---
**完整变更日志**: https://www.gitlink.org.cn/zzx-coder/gitlink-cli/compare/v1.0.0...v1.1.0"
```
## AI Agent 执行要点
1. **自动解析 `--owner` / `--repo`**在仓库目录下执行CLI 自动从 git remote 解析
2. **始终使用 `--format json`**:所有命令加此参数,便于 AI 解析返回值
3. **时间筛选**:用上一个 Release 的 `created_at` 作为 PR/Issue 的时间筛选基线
4. **去重**PR 和 Issue 描述同一变更时合并为一条
5. **确认优先**:生成 Notes 后必须展示给用户,收到确认才执行 `release +create`
## References
- [SKILL.md](../SKILL.md) — 工作流和模板总览
- [collect-data](../references/collect-data.md) — 数据收集详细说明
- [classify-rules](../references/classify-rules.md) — 分类规则
- [generate-and-publish](../references/generate-and-publish.md) — 生成和发布

View File

@ -0,0 +1,110 @@
# 变更分类规则
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
将收集到的 commits、PRs 和 Issues 按类型归类,为生成结构化 Release Notes 做准备。
## 分类维度
变更按以下维度分类:
| 类型 | 图标 | 标题 |
|------|------|------|
| 新功能 | ✨ | 新功能 |
| Bug 修复 | 🐛 | Bug 修复 |
| 功能改进 | 🔧 | 功能改进 |
| 破坏性变更 | ⚠️ | 破坏性变更 |
| 文档 | 📝 | 文档 |
| 安全修复 | 🔒 | 安全修复 |
## 分类依据
### 按 Issue 标签分类(最可靠)
`issue +list` 返回的 `issue_tags` 字段匹配:
| Issue 标签 | 对应类型 |
|------------|----------|
| `feature`, `enhancement` | 新功能 |
| `bug`, `fix` | Bug 修复 |
| `improvement`, `optimize` | 功能改进 |
| `breaking`, `major` | 破坏性变更 |
| `docs`, `documentation` | 文档 |
| `security`, `vulnerability` | 安全修复 |
### 按 Commit 关键词分类(辅助)
`compare` API 返回的 `commit.message` 第一行匹配:
| 关键词 | 对应类型 |
|--------|----------|
| `feat:`, `add`, `新增` | 新功能 |
| `fix:`, `bugfix`, `修复` | Bug 修复 |
| `improve:`, `optimize:`, `refactor:`, `优化`, `重构` | 功能改进 |
| `BREAKING`, `breaking:`, `!` | 破坏性变更 |
| `docs:`, `doc` | 文档 |
| `security:`, `安全` | 安全修复 |
### 按 PR 标题分类(辅助)
PR 标题通常遵循 Conventional Commits 格式,按前缀匹配:
| PR 标题前缀 | 对应类型 |
|-------------|----------|
| `feat:` / `feature:` | 新功能 |
| `fix:` | Bug 修复 |
| `refactor:` / `perf:` | 功能改进 |
| `docs:` | 文档 |
## 分类优先级
1. **Issue 标签**(最准确,优先采用)
2. **PR 标题前缀**(次之)
3. **Commit 关键词**(兜底)
对于同一个变更,如果 Issue 标签和 commit 关键词都在,以 Issue 标签为准。
## 去重
以下情况会产生重复条目,需去重:
- PR 和 Issue 关联同一个变更 → 合并为一条:`变更描述 (#PR编号, #Issue编号)`
- 同一变更的多个 commit → 只保留摘要最清晰的一条
- PR 标题与关联 Issue 主题高度相似 → 合并,优先使用 Issue 的 subject
## 输出格式
分类完成后,整理为结构化数据供模板填充:
```
新功能:
- 支持批量关闭 Issue (#123)
- 新增 Wiki 管理命令 (#130)
Bug 修复:
- 修复 Windows 登录 token 存储失败 (#118)
功能改进:
- 优化 API 请求性能 (#125)
破坏性变更:
- 重构认证模块接口(不向下兼容)(#140)
文档:
- 补充分支映射说明 (#115)
```
## 贡献者收集
从 commit 和 PR 数据中提取贡献者列表:
- Commit: `author.name``author.login`
- PR: `author.login`
去重后生成贡献者名单,写入 Release Notes 末尾。
## References
- [collect-data](collect-data.md) — 数据收集步骤
- [generate-and-publish](generate-and-publish.md) — 生成 Notes 并发布
- [SKILL.md](../SKILL.md) — 分类规则速查表

View File

@ -0,0 +1,84 @@
# 收集变更数据
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
收集 Release Notes 所需的三类数据版本范围、commits、PRs 和 Issues。
## 命令
### 步骤 1确定版本范围
```bash
# 获取已有 release 列表,找到上一个版本 tag
gitlink-cli release +list --format json
# 从返回的 releases 中提取最后一个 tag_name 作为 PREV_VERSION
# 用户指定或 AI 推断新版本号 NEW_VERSION
```
### 步骤 2获取 commits平台 compare API
```bash
# 获取两个 tag 之间的 commit 比较
gitlink-cli api GET /:owner/:repo/compare/{PREV_VERSION}...{NEW_VERSION} --format json
# 也可用查询参数格式
gitlink-cli api GET /v1/:owner/:repo/compare.json --query "from={PREV_VERSION}&to={NEW_VERSION}" --format json
```
返回数据包含:`commits`(提交列表含 message/author/date/sha、`total_commits`(提交总数)、`files`(变更文件)等。
### 步骤 3获取已合并的 PR
```bash
# 获取已合并的 PR 列表
gitlink-cli pr +list --state merged --format json
# 从返回的 PRs 中按 merged_at 时间筛选:
# 只保留 merged_at >= 上一个版本发布时间的 PR
```
返回数据包含:每个 PR 的 `title`、`number`、`author`、`merged_at`、`pull_request_number` 等。
### 步骤 4获取已关闭的 Issue
```bash
# 获取已关闭的 Issue 列表
gitlink-cli issue +list --state closed --format json
# 从返回的 Issues 中按 closed_at 时间筛选:
# 只保留 closed_at >= 上一个版本发布时间的 Issue
```
返回数据包含:每个 Issue 的 `subject`、`project_issues_index`、`issue_tags`(标签)、`author`、`closed_at` 等。
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--format` | 否 | 始终建议 `json`,便于 AI 解析 |
| `--state` | 否 | PR: `merged`Issue: `closed` |
| `--page` | 否 | 大量数据时分页获取 |
| `--limit` | 否 | 每页条数 |
## 数据整合
收集完成后AI 整合三类数据:
1. **Commits** → 提取 commit message 第一行作为变更摘要,附作者名
2. **PRs** → 用 `title`、`number`、`author.login` 生成条目:`- 功能描述 (#PR编号) (@作者)`
3. **Issues** → 用 `subject`、`project_issues_index`、`author.login` 生成条目:`- Issue 描述 (#编号) (@作者)`
时间筛选逻辑:从 `release +list` 获取上一个版本的发布时间,只取该时间之后的 PR/Issue。
## 注意事项
- 如果是**第一个版本**(无上一版本),只收集当前版本时间范围内的 PR/Issuecommits 用全量最近提交
- `compare` API 的 tag 需要真实存在,否则返回 404
- PR 和 Issue 的返回可能超过单页,注意分页获取全部数据
- `--owner` / `--repo` 在仓库目录下可自动解析
## References
- [classify-rules](classify-rules.md) — 收集完成后对变更进行分类
- [generate-and-publish](generate-and-publish.md) — 生成 Notes 并发布
- [gitlink-release](../gitlink-release/SKILL.md) — Release 操作

Some files were not shown because too many files have changed in this diff Show More