修改skill #259
|
|
@ -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
229
README.md
|
|
@ -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
|
||||
|
|
|
|||
260
README.zh-CN.md
260
README.zh-CN.md
|
|
@ -27,14 +27,19 @@
|
|||
| 分类 | 能力 |
|
||||
|------|------|
|
||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
||||
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
|
||||
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
||||
| 🐛 Issue | 创建、更新、关闭、评论 Issue,6 个批量操作,元数据查询,评论管理 |
|
||||
| 📖 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、Windows(x64/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 "issue1,issue2,issue3"
|
||||
|
||||
# 批量关闭
|
||||
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 使用指南
|
||||
|
|
|
|||
|
|
@ -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 是合法 JSON;PowerShell 调用 .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 == "" {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
39
cmd/root.go
39
cmd/root.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.exe(npm 全局安装路径)
|
||||
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 CLI(claude.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 CLI(claude.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.exe(Skills 功能不可用)"
|
||||
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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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` 格式
|
||||
```
|
||||
|
|
@ -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+
|
||||
File diff suppressed because one or more lines are too long
232
doc/design.md
232
doc/design.md
|
|
@ -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/description,PATCH 时只替换目标字段。Repo 批量更新先 GET 获取 name + identifier |
|
||||
| **参数容错** | 所有名称映射(状态、优先级、标签、负责人)同时支持字符串名和直接传数字 ID |
|
||||
|
||||
### 9.4 Issue 参数值映射
|
||||
|
||||
**状态**:`new`(1) / `in-progress`(2) / `resolved`(3) / `closed`(5) / `rejected`(6)
|
||||
|
||||
**优先级**:`low`(1) / `normal`(2) / `high`(3) / `urgent`(4)
|
||||
|
||||
**标记**:使用项目级中文标签名映射到大整数 ID(如 `缺陷`→315526、`功能`→315527)
|
||||
|
||||
**负责人**:传入 login 用户名,CLI 调用 `/users/{login}` API 转换为 `user_id`
|
||||
|
||||
### 9.5 Issue 批量创建模板系统
|
||||
|
||||
`+batch-create` 支持三种输入模式:
|
||||
|
||||
1. **CLI 直接输入**(`--titles`):逗号分隔标题,统一应用 `--priority`/`--label`/`--assignee`/`--state`
|
||||
2. **自由 CSV**(`--from`):自由指定 title/body/priority/label/assignee/status 列
|
||||
3. **模板 CSV**(`--from` + `--template`):
|
||||
- `--template bug`:自动生成 Bug 描述格式,自动设置缺陷标签
|
||||
- `--template feature`:自动生成功能描述格式,自动设置功能标签
|
||||
|
||||
### 9.6 Repo 批量操作注意事项
|
||||
|
||||
- **batch-create**:POST 路径 `/{login}/{name}` 中的 login 必须是当前登录用户,需先 `GET /users/me`
|
||||
- **batch-update**:PATCH 请求体必须包含从 GET 获取的 `name` 和 `identifier`,否则 API 报错
|
||||
- **--private/--public 互斥**:batch-update 不允许同时设置两个标志
|
||||
|
||||
### 9.7 关键 API 字段差异
|
||||
|
||||
GitLink 基于 Redmine 但修改了大量字段名:
|
||||
|
||||
| Redmine 标准字段 | GitLink 实际字段 | 格式 |
|
||||
|-----------------|-----------------|------|
|
||||
| `assigned_to_id` | `assigner_ids` | 数组 `[user_id]` |
|
||||
| `tracker_id` | `issue_tag_ids` | 数组 `[tag_id]`(项目级大整数) |
|
||||
|
||||
> GitLink API 对不认识的字段返回 200 而非报错,字段名错误会导致静默失败。必须通过浏览器 DevTools 抓取实际请求确认字段名和格式。
|
||||
|
||||
---
|
||||
|
||||
## 10 Wiki Shortcut 设计
|
||||
|
||||
Wiki 是独立的全新 Shortcut 领域,提供 5 个命令覆盖 Wiki 页面的 CRUD 操作。
|
||||
|
||||
### 10.1 双域名架构
|
||||
|
||||
Wiki API 部署在 gateway 域名上,与主站 API(www 域名)分离:
|
||||
|
||||
```
|
||||
┌─ www 域名 ─────────────────────┐
|
||||
resolveProjectID() → │ GET /{owner}/{repo}/detail.json │ → project_id
|
||||
└────────────────────────────────┘
|
||||
|
||||
┌─ gateway 域名 ───────────────────────────┐
|
||||
callWikiAPI*() → │ /wiki/open/* (不带 .json) │ → 解包 code/data
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **www 域名**(`https://www.gitlink.org.cn/api`):用于 detail API 获取 project_id,走默认 client
|
||||
- **gateway 域名**(`https://gateway.gitlink.org.cn/api`):用于所有 wiki CRUD API,走独立 client
|
||||
|
||||
### 10.2 Client 扩展
|
||||
|
||||
`internal/client/client.go` 新增 `SkipJSONSuffix` 字段:
|
||||
|
||||
```go
|
||||
type Client struct {
|
||||
// ... 原有字段 ...
|
||||
SkipJSONSuffix bool // 为 true 时不自动追加 .json 后缀(wiki gateway API 需要)
|
||||
}
|
||||
```
|
||||
|
||||
wiki 命令创建独立 client 实例,设置 `SkipJSONSuffix: true` 并使用 gateway BaseURL。
|
||||
|
||||
### 10.3 响应解包
|
||||
|
||||
Gateway API 使用不同的响应格式 `{code, data, msg}`(而非常规的 `{status, ...}`):
|
||||
|
||||
```go
|
||||
// unwrapGatewayResponse 解包 gateway 响应
|
||||
// 成功: code=200/201, 提取 data 字段
|
||||
// 失败: code=500/400/404, 返回 "[code] msg" 错误信息
|
||||
func unwrapGatewayResponse(raw []byte) ([]byte, error)
|
||||
```
|
||||
|
||||
### 10.4 命令详情
|
||||
|
||||
| 命令 | HTTP 方法 | API 路径 | 关键参数 |
|
||||
|------|----------|---------|---------|
|
||||
| `wiki +list` | GET | `/wiki/open/wikiPages` | 无额外参数 |
|
||||
| `wiki +view` | GET | `/wiki/open/getWiki` | `--title`(必填) |
|
||||
| `wiki +create` | POST | `/wiki/open/createWiki` | `--title`(必填)`--content/--file` `--message` |
|
||||
| `wiki +update` | PUT | `/wiki/open/updateWiki` | `--title`(必填)`--cover/--add` `--file` |
|
||||
| `wiki +delete` | DELETE | `/wiki/open/deleteWiki` | `--title`(必填) |
|
||||
|
||||
### 10.5 特殊处理
|
||||
|
||||
| 处理项 | 说明 |
|
||||
|--------|------|
|
||||
| **base64 编解码** | Wiki 内容在 API 中为 base64 编码,CLI 自动编解码,对用户透明 |
|
||||
| **project_id 缓存** | `resolveProjectID()` 使用 `sync.Map` 缓存,同一 owner/repo 只调一次 API |
|
||||
| **owner/repo 自动解析** | 在 git 仓库目录下可省略 `--owner`/`--repo` |
|
||||
| **--update --add 模式** | 先 GET 现有内容 → 解码 → 追加 → 重新编码 → PUT 提交 |
|
||||
| **嵌套对象过滤** | 表格输出时过滤无意义的嵌套对象字段(如 wiki_clone_link) |
|
||||
|
||||
### 10.6 已知限制
|
||||
|
||||
- **delete 后端 bug**:GitLink 平台 `deleteWiki` API 只清空内容,不删除侧边栏条目
|
||||
- **gateway 域名硬编码**:wiki API 仅部署在 gateway,detail API 在 www,不可互换
|
||||
- **create/update pageName 差异**:create 接受原始中文 pageName,update 需要 URL 编码
|
||||
|
||||
---
|
||||
|
||||
## 11 完整命令参考
|
||||
|
||||
```
|
||||
gitlink-cli
|
||||
|
|
@ -457,7 +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 shortcuts(list, view, create, update, delete) |
|
||||
| `shortcuts/webhook/*.go` | Webhook shortcuts(list, 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 验证方案
|
||||
|
||||
| 阶段 | 验证方式 |
|
||||
|------|----------|
|
||||
|
|
|
|||
|
|
@ -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 API(CLI 实际调用)
|
||||
|
||||
gitlink-cli 的 wiki shortcut 实际调用的是 **gateway 域名**下的 `/wiki/open/*` 端点,而非上述 www 域名的 `/api/wiki/*` 端点。两者存在以下差异:
|
||||
|
||||
| 差异项 | www 域名(APIfox 文档) | gateway 域名(CLI 实际使用) |
|
||||
|--------|------------------------|---------------------------|
|
||||
| Base URL | `https://www.gitlink.org.cn/api` | `https://gateway.gitlink.org.cn/api` |
|
||||
| URL 前缀 | `/api/wiki/` | `/wiki/open/` |
|
||||
| JSON 后缀 | 需要 `.json` | 不需要 `.json` |
|
||||
| 响应格式 | 直接返回 data | `{"code": 200, "data": {...}, "msg": ""}` 包一层 |
|
||||
| 错误判断 | `status` 字段 ≠ 200 | `code` 字段 ≠ 200/201 |
|
||||
|
||||
### 实际调用路径对比
|
||||
|
||||
| 操作 | www 文档路径 | gateway 实际路径 |
|
||||
|------|-------------|-----------------|
|
||||
| 创建 | `POST /api/wiki/createWiki.json` | `POST /wiki/open/createWiki` |
|
||||
| 删除 | `DELETE /api/wiki/deleteWiki.json` | `DELETE /wiki/open/deleteWiki` |
|
||||
| 查看 | `GET /api/wiki/getWiki.json` | `GET /wiki/open/getWiki` |
|
||||
| 更新 | `PUT /api/wiki/updateWiki.json` | `PUT /wiki/open/updateWiki` |
|
||||
| 列表 | `GET /api/wiki/wikiPages.json` | `GET /wiki/open/wikiPages` |
|
||||
|
||||
### 响应格式差异示例
|
||||
|
||||
**www 域名响应**(标准格式):
|
||||
```json
|
||||
{"data": {"title": "test", "content": "..."}}
|
||||
```
|
||||
|
||||
**gateway 域名响应**(包一层):
|
||||
```json
|
||||
{"code": 200, "data": {"title": "test", "content": "..."}, "msg": "success"}
|
||||
```
|
||||
|
||||
CLI 通过 `unwrapGatewayResponse()` 函数自动解包 gateway 格式,对用户透明。
|
||||
|
||||
> **注意**:project_id 仍需通过 www 域名的 `/api/{owner}/{repo}/detail.json` 获取,两个域名不可互换。
|
||||
|
||||
# 流水线
|
||||
|
||||
## GET 流水线列表
|
||||
|
|
|
|||
|
|
@ -0,0 +1,552 @@
|
|||
# shortcuts/common/types.go 阅读笔记(面向 Go 小白)
|
||||
|
||||
***
|
||||
|
||||
## 第 1 行:`package common`
|
||||
|
||||
**字面意思**:声明这个文件属于 `common` 包
|
||||
|
||||
**运行时作用**:这是一个通用工具包,里面定义的类型和函数可以被所有其他 shortcut 模块(wiki、webhook、issue 等)复用。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
- 包名 `common` 表示"公共的",说明这里的内容是大家都需要用的
|
||||
- 其他文件通过 `import "github.com/gitlink-org/gitlink-cli/shortcuts/common"` 来使用
|
||||
|
||||
***
|
||||
|
||||
## 第 3-18 行:import 导入依赖
|
||||
|
||||
```go
|
||||
import (
|
||||
"bufio" // 缓冲输入(用于读取用户确认)
|
||||
"encoding/json" // JSON 处理
|
||||
"fmt" // 格式化输出
|
||||
"net/http" // HTTP 客户端
|
||||
"net/url" // URL 处理
|
||||
"os" // 操作系统交互
|
||||
"strings" // 字符串操作
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil" // 命令行工具(全局变量)
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client" // HTTP 客户端
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config" // 配置管理
|
||||
"github.com/gitlink-org/gitlink-cli/internal/context" // 上下文解析(owner/repo)
|
||||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // 错误定义
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
|
||||
)
|
||||
```
|
||||
|
||||
**小白补充**:
|
||||
|
||||
| 包名 | 用途 |
|
||||
| ------------------ | ----------------------------------------------------------- |
|
||||
| `bufio` | 读取用户输入(比如确认操作时的 y/N) |
|
||||
| `cmd/cmdutil` | 存放全局变量(如 `cmdutil.Owner`, `cmdutil.Repo`, `cmdutil.Format`) |
|
||||
| `internal/config` | 加载配置文件 |
|
||||
| `internal/context` | 从 git remote 解析 owner/repo |
|
||||
|
||||
***
|
||||
|
||||
## 第 20-28 行:`Shortcut` 结构体(核心!)
|
||||
|
||||
```go
|
||||
type Shortcut struct {
|
||||
Name string
|
||||
Description string
|
||||
Flags []Flag
|
||||
DryRun bool
|
||||
DryRunHint func(ctx *RuntimeContext) (string, error)
|
||||
Run func(ctx *RuntimeContext) error
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:定义一个名为 `Shortcut` 的结构体类型
|
||||
|
||||
**运行时作用**:这是整个 CLI 命令系统的**核心数据结构**,每个 `Shortcut` 代表一个可执行的命令(如 `wiki +list`, `issue +create`)。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① 结构体是什么?
|
||||
|
||||
结构体(struct)是 Go 语言中用来**组织相关数据和函数**的方式。可以把它想象成一个"数据容器",里面装着各种属性。
|
||||
|
||||
### ② 每个字段的含义:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
| ------------- | ------------------------------------------- | -------------------------- |
|
||||
| `Name` | `string` | 命令名,用户通过 `+name` 调用 |
|
||||
| `Description` | `string` | 命令描述,`--help` 时显示 |
|
||||
| `Flags` | `[]Flag` | 命令行参数列表(如 `--title`, `-t`) |
|
||||
| `DryRun` | `bool` | 是否支持预览模式(`--dry-run`) |
|
||||
| `DryRunHint` | `func(ctx *RuntimeContext) (string, error)` | 预览时显示的提示信息 |
|
||||
| `Run` | `func(ctx *RuntimeContext) error` | **真正执行的函数**,命令的核心逻辑 |
|
||||
|
||||
### ③ 函数类型字段:
|
||||
|
||||
注意 `DryRunHint` 和 `Run` 的类型是**函数**!这在 Go 中是完全合法的,函数可以作为结构体的字段。
|
||||
|
||||
```go
|
||||
Run func(ctx *RuntimeContext) error
|
||||
```
|
||||
|
||||
- 这表示 `Run` 字段存储了一个**函数**
|
||||
- 这个函数接收 `*RuntimeContext` 类型的参数
|
||||
- 返回 `error` 类型的值(如果执行失败)
|
||||
|
||||
***
|
||||
|
||||
## 第 30-38 行:`Flag` 结构体
|
||||
|
||||
```go
|
||||
type Flag struct {
|
||||
Name string
|
||||
Short string
|
||||
Usage string
|
||||
Required bool
|
||||
Default string
|
||||
Bool bool
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:定义命令行参数的结构
|
||||
|
||||
**运行时作用**:描述一个命令行参数,比如 `--title "Home"` 或 `-t "Home"`。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
| 字段 | 含义 | 例子 |
|
||||
| ---------- | ------ | ------------------------- |
|
||||
| `Name` | 参数名 | `"title"` → `--title` |
|
||||
| `Short` | 短参数名 | `"t"` → `-t` |
|
||||
| `Usage` | 帮助说明 | `"Page title"` |
|
||||
| `Required` | 是否必填 | `true` → 用户必须提供 |
|
||||
| `Default` | 默认值 | `"1"` → 不提供时使用的默认值 |
|
||||
| `Bool` | 是否布尔类型 | `true` → `--dry-run` 不需要值 |
|
||||
|
||||
***
|
||||
|
||||
## 第 40-50 行:`RuntimeContext` 结构体(核心!)
|
||||
|
||||
```go
|
||||
type RuntimeContext struct {
|
||||
Client *client.Client
|
||||
Owner string
|
||||
Repo string
|
||||
Format string
|
||||
CommandName string
|
||||
Args map[string]string
|
||||
GatewayBaseURL string
|
||||
GatewayHTTPClient *http.Client
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:定义运行时上下文的结构
|
||||
|
||||
**运行时作用**:这是每个命令执行时的**全局环境**,包含了所有需要的信息。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① 为什么需要 RuntimeContext?
|
||||
|
||||
每个命令执行时都需要很多信息:
|
||||
|
||||
- 用哪个 HTTP 客户端发请求?
|
||||
- 当前操作的仓库是哪个(owner/repo)?
|
||||
- 输出格式是 JSON 还是 Table?
|
||||
- 用户传入了哪些参数?
|
||||
|
||||
`RuntimeContext` 把这些信息打包在一起,方便传递和使用。
|
||||
|
||||
### ② 每个字段的含义:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
| ------------------- | ------------------- | --------------------------- |
|
||||
| `Client` | `*client.Client` | HTTP 客户端,用来调用 GitLink API |
|
||||
| `Owner` | `string` | 仓库所有者(如 `zzx-coder`) |
|
||||
| `Repo` | `string` | 仓库名称(如 `gitlink-cli`) |
|
||||
| `Format` | `string` | 输出格式(`json`/`table`/`yaml`) |
|
||||
| `CommandName` | `string` | 当前命令名(如 `"wiki +list"`) |
|
||||
| `Args` | `map[string]string` | 用户传入的所有参数(key-value) |
|
||||
| `GatewayBaseURL` | `string` | Wiki Gateway API 的地址 |
|
||||
| `GatewayHTTPClient` | `*http.Client` | 可选的自定义 HTTP 客户端(主要用于测试) |
|
||||
|
||||
### ③ `*client.Client` 是什么?
|
||||
|
||||
- `*` 表示这是一个**指针**类型
|
||||
- `client.Client` 是 `internal/client` 包中定义的结构体
|
||||
- 指针的好处:避免拷贝大对象,多个地方共享同一个实例
|
||||
|
||||
***
|
||||
|
||||
## 第 52-80 行:`NewRuntimeContext` 函数
|
||||
|
||||
```go
|
||||
func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeContext, error) {
|
||||
// 1. 创建 HTTP 客户端
|
||||
cli, err := client.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cli.Debug = cmdutil.Debug // 设置调试模式
|
||||
|
||||
// 2. 确定输出格式
|
||||
format := cmdutil.Format
|
||||
if format == "" {
|
||||
format = "json" // 默认 JSON 格式
|
||||
}
|
||||
|
||||
// 3. 获取 Gateway URL
|
||||
gatewayBaseURL := config.DefaultGatewayBaseURL
|
||||
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
|
||||
gatewayBaseURL = cfg.GatewayBaseURL // 使用配置文件中的地址
|
||||
}
|
||||
|
||||
// 4. 创建并返回 RuntimeContext
|
||||
return &RuntimeContext{
|
||||
Client: cli,
|
||||
Owner: cmdutil.Owner,
|
||||
Repo: cmdutil.Repo,
|
||||
Format: format,
|
||||
CommandName: commandName,
|
||||
Args: args,
|
||||
GatewayBaseURL: gatewayBaseURL,
|
||||
GatewayHTTPClient: nil,
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:创建一个新的 RuntimeContext 实例
|
||||
|
||||
**运行时作用**:这是 `RuntimeContext` 的**构造函数**,负责初始化所有字段。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① 构造函数模式:
|
||||
|
||||
Go 没有专门的构造函数语法,通常约定用 `NewXXX()` 函数来创建结构体实例。
|
||||
|
||||
### ② `cmdutil` 是什么?
|
||||
|
||||
`cmdutil` 是 `cmd/cmdutil/globals.go` 中定义的全局变量模块:
|
||||
|
||||
```go
|
||||
// cmd/cmdutil/globals.go 中定义
|
||||
var (
|
||||
Owner string // 通过 --owner 参数设置
|
||||
Repo string // 通过 --repo 参数设置
|
||||
Format string // 通过 --format 参数设置
|
||||
Debug bool // 通过 --debug 参数设置
|
||||
)
|
||||
```
|
||||
|
||||
这些是**全局变量**,在命令行参数解析时被赋值,然后在这里被读取。
|
||||
|
||||
### ③ 配置加载:
|
||||
|
||||
```go
|
||||
gatewayBaseURL := config.DefaultGatewayBaseURL
|
||||
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
|
||||
gatewayBaseURL = cfg.GatewayBaseURL
|
||||
}
|
||||
```
|
||||
|
||||
- 先使用默认值 `config.DefaultGatewayBaseURL`
|
||||
- 尝试加载配置文件,如果配置文件中有自定义的 Gateway URL,就使用配置文件中的值
|
||||
|
||||
***
|
||||
|
||||
## 第 82-91 行:`ResolveOwnerRepo` 方法
|
||||
|
||||
```go
|
||||
func (ctx *RuntimeContext) ResolveOwnerRepo() error {
|
||||
owner, repo, err := context.ResolveOwnerRepo(ctx.Owner, ctx.Repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.Owner = owner
|
||||
ctx.Repo = repo
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:解析 owner 和 repo
|
||||
|
||||
**运行时作用**:这是 `RuntimeContext` 的**方法**,用来确定当前操作的仓库。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① 方法是什么?
|
||||
|
||||
方法是和结构体绑定的函数。在 Go 中:
|
||||
|
||||
```go
|
||||
func (ctx *RuntimeContext) ResolveOwnerRepo() error {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- `(ctx *RuntimeContext)` 表示这个函数绑定到 `RuntimeContext` 类型
|
||||
- `ctx` 是方法内部的**接收器**(receiver),类似其他语言的 `this` 或 `self`
|
||||
- 调用方式:`ctx.ResolveOwnerRepo()`
|
||||
|
||||
### ② 解析逻辑:
|
||||
|
||||
`context.ResolveOwnerRepo(ctx.Owner, ctx.Repo)` 的作用:
|
||||
|
||||
1. 如果用户通过 `--owner` 和 `--repo` 参数明确指定了,直接使用
|
||||
2. 如果没有指定,尝试从当前目录的 `git remote` 中自动解析
|
||||
|
||||
***
|
||||
|
||||
## 第 93-96 行:`CallAPI` 方法
|
||||
|
||||
```go
|
||||
func (ctx *RuntimeContext) CallAPI(method, path string, body interface{}) (*output.Envelope, error) {
|
||||
return ctx.Client.Do(method, path, body, nil)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:调用 API(无查询参数)
|
||||
|
||||
**运行时作用**:封装 HTTP 请求,是所有 API 调用的入口。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
- 这是一个**包装方法**,把 `ctx.Client.Do()` 包装一层
|
||||
- 其他模块只需要调用 `ctx.CallAPI()` 就能发送请求,不需要关心底层的 `client.Client`
|
||||
|
||||
***
|
||||
|
||||
## 第 98-101 行:`CallAPIWithQuery` 方法
|
||||
|
||||
```go
|
||||
func (ctx *RuntimeContext) CallAPIWithQuery(method, path string, query url.Values) (*output.Envelope, error) {
|
||||
return ctx.Client.Do(method, path, nil, query)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:调用 API(带查询参数)
|
||||
|
||||
**运行时作用**:和 `CallAPI` 类似,但支持 URL 查询参数(`?key=value`)。
|
||||
|
||||
***
|
||||
|
||||
## 第 103-106 行:`PaginateAll` 方法
|
||||
|
||||
```go
|
||||
func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) {
|
||||
return ctx.Client.PaginateAll(path, params)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:获取所有分页数据
|
||||
|
||||
**运行时作用**:处理分页 API,自动获取所有页的数据。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
- GitLink API 常用分页返回大量数据(如 `page=1&limit=20`)
|
||||
- `PaginateAll` 会自动遍历所有页,把结果合并成一个大列表
|
||||
|
||||
***
|
||||
|
||||
## 第 108-111 行:`Output` 方法
|
||||
|
||||
```go
|
||||
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
|
||||
return output.Print(env, ctx.Format)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:输出结果
|
||||
|
||||
**运行时作用**:根据用户指定的格式(JSON/Table/YAML)输出 API 响应。
|
||||
|
||||
***
|
||||
|
||||
## 第 113-116 行:`OutputData` 方法
|
||||
|
||||
```go
|
||||
func (ctx *RuntimeContext) OutputData(data interface{}) error {
|
||||
return output.Print(output.SuccessEnvelope(data, nil), ctx.Format)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:输出数据(自动包装成 Envelope)
|
||||
|
||||
**运行时作用**:如果只有数据,没有完整的 Envelope,可以用这个方法自动包装。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
- `output.SuccessEnvelope(data, nil)` 创建一个成功的包装结构:`{"ok": true, "data": ...}`
|
||||
|
||||
***
|
||||
|
||||
## 第 118-121 行:`RepoPath` 方法
|
||||
|
||||
```go
|
||||
func (ctx *RuntimeContext) RepoPath() string {
|
||||
return fmt.Sprintf("/%s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:返回仓库的 API 路径前缀
|
||||
|
||||
**运行时作用**:生成 `/owner/repo` 格式的路径,避免重复拼接。
|
||||
|
||||
***
|
||||
|
||||
## 第 123-129 行:`Arg` 方法
|
||||
|
||||
```go
|
||||
func (ctx *RuntimeContext) Arg(name string) string {
|
||||
if v, ok := ctx.Args[name]; ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:获取命令行参数值
|
||||
|
||||
**运行时作用**:从 `ctx.Args` map 中获取指定参数的值。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
- `ctx.Args` 是 `map[string]string` 类型
|
||||
- 调用方式:`ctx.Arg("title")` → 获取 `--title` 参数的值
|
||||
|
||||
***
|
||||
|
||||
## 第 131-145 行:`RequireArg` 方法(核心!)
|
||||
|
||||
```go
|
||||
func (ctx *RuntimeContext) RequireArg(name, example string) (string, error) {
|
||||
v := ctx.Arg(name)
|
||||
if v == "" {
|
||||
suggestion := fmt.Sprintf("请提供 --%s 参数", name)
|
||||
if example != "" {
|
||||
suggestion += fmt.Sprintf(",例如:%s", example)
|
||||
}
|
||||
return "", clierrors.InputError(
|
||||
fmt.Sprintf("required flag --%s is missing", name),
|
||||
suggestion,
|
||||
).WithCommand(ctx.CommandName)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:获取必填参数,如果缺失则返回错误
|
||||
|
||||
**运行时作用**:强制检查必填参数,确保用户提供了必要的输入。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① 使用场景:
|
||||
|
||||
```go
|
||||
title, err := ctx.RequireArg("title", `--title "Home Page"`)
|
||||
if err != nil {
|
||||
return err // 用户没提供 --title,直接返回错误
|
||||
}
|
||||
```
|
||||
|
||||
### ② 错误处理:
|
||||
|
||||
如果用户没提供参数,会返回一个 `CLIError`,包含:
|
||||
|
||||
- `Kind`: `KindInput`(输入错误)
|
||||
- `Message`: `"required flag --title is missing"`
|
||||
- `Suggestion`: `"请提供 --title 参数,例如:--title \"Home Page\""`
|
||||
|
||||
***
|
||||
|
||||
## 第 147-150 行:`IsDryRun` 方法
|
||||
|
||||
```go
|
||||
func (ctx *RuntimeContext) IsDryRun() bool {
|
||||
return ctx.Arg("dry-run") == "true"
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:检查是否是预览模式
|
||||
|
||||
**运行时作用**:判断用户是否传入了 `--dry-run` 参数。
|
||||
|
||||
***
|
||||
|
||||
## 第 152-166 行:`ConfirmAction` 函数
|
||||
|
||||
```go
|
||||
func ConfirmAction(ctx *RuntimeContext) (bool, error) {
|
||||
if !ctx.IsDryRun() {
|
||||
return true, nil // 不是预览模式,直接执行
|
||||
}
|
||||
|
||||
// 预览模式,提示用户确认
|
||||
fmt.Fprint(os.Stderr, "\nProceed? [y/N] ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
|
||||
if answer == "y" || answer == "yes" {
|
||||
return true, nil // 用户确认,继续执行
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "Aborted.")
|
||||
return false, nil // 用户取消,不执行
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:确认操作(预览模式下)
|
||||
|
||||
**运行时作用**:在 `--dry-run` 模式下,提示用户确认是否真的要执行操作。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① `fmt.Fprint(os.Stderr, ...)`:
|
||||
|
||||
- `os.Stderr` 是标准错误输出流
|
||||
- 把提示信息输出到 stderr 而不是 stdout,这样 stdout 可以保持干净(用于管道输出)
|
||||
|
||||
### ② `bufio.NewReader(os.Stdin)`:
|
||||
|
||||
- `os.Stdin` 是标准输入流(用户键盘输入)
|
||||
- `bufio.NewReader` 创建一个缓冲读取器,用来读取用户输入
|
||||
|
||||
***
|
||||
|
||||
## 调用关系图
|
||||
|
||||
```
|
||||
NewRuntimeContext(args, commandName)
|
||||
↓ 创建
|
||||
RuntimeContext{
|
||||
Client: client.New(), // HTTP 客户端
|
||||
Owner: cmdutil.Owner, // 全局变量
|
||||
Repo: cmdutil.Repo, // 全局变量
|
||||
Format: cmdutil.Format, // 全局变量
|
||||
Args: args, // 命令行参数
|
||||
}
|
||||
|
||||
RuntimeContext 的方法:
|
||||
├── ResolveOwnerRepo() → 解析 owner/repo(自动或手动)
|
||||
├── CallAPI() → 调用 API(无参数)
|
||||
├── CallAPIWithQuery() → 调用 API(带参数)
|
||||
├── PaginateAll() → 获取所有分页数据
|
||||
├── Output() → 输出结果
|
||||
├── OutputData() → 输出数据(自动包装)
|
||||
├── RepoPath() → 返回 /owner/repo 路径
|
||||
├── Arg() → 获取参数值
|
||||
├── RequireArg() → 获取必填参数(缺则报错)
|
||||
└── IsDryRun() → 检查预览模式
|
||||
|
||||
Shortcut 结构体:
|
||||
├── Name: "list"
|
||||
├── Flags: [{Name:"title", Short:"t", Required:true}]
|
||||
└── Run: func(ctx *RuntimeContext) error {
|
||||
// 命令执行逻辑
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -0,0 +1,560 @@
|
|||
# internal/client/client.go 阅读笔记(面向 Go 小白)
|
||||
|
||||
---
|
||||
|
||||
## 第 1 行:`package client`
|
||||
|
||||
**字面意思**:声明这个文件属于 `client` 包
|
||||
|
||||
**运行时作用**:这是项目的 HTTP 客户端模块,负责所有与 GitLink API 的通信。
|
||||
|
||||
---
|
||||
|
||||
## 第 3-16 行:import 导入依赖
|
||||
|
||||
```go
|
||||
import (
|
||||
"bytes" // 字节缓冲(用于构造请求体)
|
||||
"encoding/json" // JSON 序列化/反序列化
|
||||
"fmt" // 格式化输出
|
||||
"io" // 输入输出接口
|
||||
"net/http" // HTTP 协议
|
||||
"net/url" // URL 处理
|
||||
"strings" // 字符串操作
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块(带 Token 的 HTTP 客户端)
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config" // 配置管理
|
||||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // 错误定义
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
|
||||
)
|
||||
```
|
||||
|
||||
**小白补充**:
|
||||
|
||||
| 包名 | 用途 | 在本文件中的作用 |
|
||||
|------|------|-----------------|
|
||||
| `bytes` | 字节操作 | 把 JSON 数据转成 HTTP 请求体 |
|
||||
| `io` | 输入输出 | 读取 HTTP 响应体 |
|
||||
| `net/http` | HTTP 协议 | 创建和发送 HTTP 请求 |
|
||||
|
||||
---
|
||||
|
||||
## 第 18-23 行:`Client` 结构体(核心!)
|
||||
|
||||
```go
|
||||
type Client struct {
|
||||
HTTP *http.Client
|
||||
BaseURL string
|
||||
Debug bool
|
||||
SkipJSONSuffix bool
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:定义 HTTP 客户端的结构
|
||||
|
||||
**运行时作用**:这是项目封装的 HTTP 客户端,所有 API 调用都通过它来完成。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① 每个字段的含义:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
|------|------|------|
|
||||
| `HTTP` | `*http.Client` | Go 标准库的 HTTP 客户端(核心) |
|
||||
| `BaseURL` | `string` | API 基础地址(如 `https://www.gitlink.org.cn/api`) |
|
||||
| `Debug` | `bool` | 是否开启调试模式(打印请求/响应) |
|
||||
| `SkipJSONSuffix` | `bool` | 是否跳过自动添加 `.json` 后缀(Wiki Gateway 需要) |
|
||||
|
||||
### ② `*http.Client` 是什么?
|
||||
|
||||
`http.Client` 是 Go 标准库提供的 HTTP 客户端,它包含:
|
||||
- 连接池管理
|
||||
- 超时设置
|
||||
- Cookie 管理
|
||||
- 传输层配置(如 TLS、代理)
|
||||
|
||||
我们项目在 `internal/auth/transport.go` 中对它进行了扩展,自动添加认证 Token。
|
||||
|
||||
---
|
||||
|
||||
## 第 25-35 行:`APIError` 结构体
|
||||
|
||||
```go
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Code interface{}
|
||||
Message string
|
||||
Kind clierrors.ErrorKind
|
||||
Suggestion string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:定义 API 错误的结构
|
||||
|
||||
**运行时作用**:封装 API 返回的错误信息,包含错误码、消息和解决建议。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① `Error()` 方法:
|
||||
|
||||
```go
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
||||
}
|
||||
```
|
||||
|
||||
- 这是实现了 Go 的 `error` 接口
|
||||
- 任何实现了 `Error() string` 方法的类型都可以作为 `error` 返回
|
||||
- 这样 `APIError` 就可以像普通错误一样使用:`return apiErr`
|
||||
|
||||
### ② 为什么需要自定义错误类型?
|
||||
|
||||
普通的 `error` 只能包含一条消息,而我们需要:
|
||||
- `StatusCode`:HTTP 状态码(404/403/500 等)
|
||||
- `Code`:API 返回的业务错误码
|
||||
- `Kind`:错误分类(认证错误/输入错误/服务器错误等)
|
||||
- `Suggestion`:给用户的解决建议
|
||||
|
||||
---
|
||||
|
||||
## 第 37-46 行:`New` 函数(构造函数)
|
||||
|
||||
```go
|
||||
func New() (*Client, error) {
|
||||
// 1. 加载配置
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 创建并返回 Client
|
||||
return &Client{
|
||||
HTTP: auth.NewHTTPClient(), // 带认证的 HTTP 客户端
|
||||
BaseURL: cfg.BaseURL, // 从配置获取 API 地址
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:创建一个新的 Client 实例
|
||||
|
||||
**运行时作用**:这是 Client 的构造函数,自动加载配置并创建带认证的 HTTP 客户端。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① `auth.NewHTTPClient()` 做了什么?
|
||||
|
||||
这个函数在 `internal/auth/transport.go` 中,它创建了一个 HTTP 客户端,并且:
|
||||
- 自动从配置文件读取 Token
|
||||
- 在每个请求的 `Authorization` 头中添加 `Bearer {token}`
|
||||
- 处理 Token 过期等情况
|
||||
|
||||
### ② 配置文件的内容:
|
||||
|
||||
配置文件位于 `~/.config/gitlink-cli/config.yaml`,内容大致如下:
|
||||
|
||||
```yaml
|
||||
base_url: https://www.gitlink.org.cn/api
|
||||
gateway_base_url: https://gateway.gitlink.org.cn/api
|
||||
token: your-token-here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第 48-168 行:`Do` 方法(核心!)
|
||||
|
||||
这是整个文件中**最重要的函数**,负责发送 HTTP 请求并解析响应。
|
||||
|
||||
### ① 路径处理(第 48-67 行)
|
||||
|
||||
```go
|
||||
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
|
||||
// 自动添加 .json 后缀(GitLink API 约定)
|
||||
if c.shouldAppendJSONSuffix(path) {
|
||||
if idx := strings.Index(path, "?"); idx != -1 {
|
||||
// 路径已经包含查询参数,在 ? 前面加 .json
|
||||
basePath := path[:idx]
|
||||
queryStr := path[idx:]
|
||||
path = basePath + ".json" + queryStr
|
||||
} else {
|
||||
// 路径没有查询参数,直接加 .json
|
||||
path += ".json"
|
||||
}
|
||||
}
|
||||
|
||||
// 构建完整 URL
|
||||
fullURL := c.BaseURL + path
|
||||
if query != nil && len(query) > 0 {
|
||||
sep := "?"
|
||||
if strings.Contains(fullURL, "?") {
|
||||
sep = "&" // URL 已经有 ?,用 & 连接
|
||||
}
|
||||
fullURL += sep + query.Encode()
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:处理请求路径,构建完整 URL
|
||||
|
||||
**运行时作用**:GitLink API 约定所有路径都需要 `.json` 后缀,这里自动添加。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
- `c.BaseURL` 是 `https://www.gitlink.org.cn/api`
|
||||
- `path` 是 `/users/me`
|
||||
- 最终 `fullURL` 变成 `https://www.gitlink.org.cn/api/users/me.json`
|
||||
|
||||
### ② 请求体处理(第 69-77 行)
|
||||
|
||||
```go
|
||||
// 处理请求体
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
// 把 body 序列化成 JSON
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 转成 io.Reader(HTTP 请求需要的格式)
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:把请求体转成 HTTP 可以发送的格式
|
||||
|
||||
**运行时作用**:如果有请求体(如 POST/PUT 请求),把 Go 的 map 转成 JSON 字符串,再转成字节流。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
- `json.Marshal(body)`:把 Go 结构体/map 转成 JSON 字节数组
|
||||
- `bytes.NewReader(data)`:把字节数组包装成 `io.Reader`(HTTP 请求体需要这个接口)
|
||||
|
||||
### ③ 创建 HTTP 请求(第 79-87 行)
|
||||
|
||||
```go
|
||||
// 创建 HTTP 请求
|
||||
req, err := http.NewRequest(method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 调试模式:打印请求信息
|
||||
if c.Debug {
|
||||
fmt.Printf("→ %s %s\n", method, fullURL)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:创建一个 HTTP 请求对象
|
||||
|
||||
**运行时作用**:`http.NewRequest` 创建请求对象,包含方法、URL 和请求体。
|
||||
|
||||
### ④ 发送请求(第 89-92 行)
|
||||
|
||||
```go
|
||||
// 发送请求
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close() // 确保响应体被关闭
|
||||
```
|
||||
|
||||
**字面意思**:发送 HTTP 请求并获取响应
|
||||
|
||||
**运行时作用**:`c.HTTP.Do(req)` 发送请求,返回响应对象。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
- `defer resp.Body.Close()`:**非常重要!** 确保响应体被关闭,避免资源泄漏
|
||||
- `defer` 是 Go 的关键字,它会在函数返回前执行后面的语句
|
||||
- 如果不关闭 `resp.Body`,HTTP 连接池会被占满,导致后续请求失败
|
||||
|
||||
### ⑤ 读取响应体(第 94-101 行)
|
||||
|
||||
```go
|
||||
// 读取响应体
|
||||
respData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// 调试模式:打印响应信息
|
||||
if c.Debug {
|
||||
fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:把响应体读取成字节数组
|
||||
|
||||
**运行时作用**:`io.ReadAll(resp.Body)` 读取整个响应体内容。
|
||||
|
||||
**小白补充**:
|
||||
- `resp.StatusCode` 是 HTTP 状态码(200=成功,404=未找到,500=服务器错误)
|
||||
|
||||
### ⑥ HTTP 状态码检查(第 103-113 行)
|
||||
|
||||
```go
|
||||
// 检查 HTTP 状态码
|
||||
if resp.StatusCode >= 400 {
|
||||
info := lookupStatusInfo(resp.StatusCode)
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: resp.StatusCode,
|
||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
|
||||
Kind: info.kind,
|
||||
Suggestion: info.suggestion,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:如果状态码 >= 400,返回错误
|
||||
|
||||
**运行时作用**:HTTP 4xx/5xx 都是错误,这里封装成 `APIError` 返回。
|
||||
|
||||
**小白补充**:
|
||||
- `lookupStatusInfo(resp.StatusCode)` 根据状态码查找对应的错误分类和建议
|
||||
|
||||
### ⑦ JSON 解析(第 115-120 行)
|
||||
|
||||
```go
|
||||
// 解析 JSON 响应
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(respData, &raw); err != nil {
|
||||
// 不是 JSON,直接返回原始内容
|
||||
return output.SuccessEnvelope(string(respData), nil), nil
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:把响应体解析成 Go 的 map
|
||||
|
||||
**运行时作用**:`json.Unmarshal` 把 JSON 字符串转成 Go 的 `map[string]interface{}`。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
- `json.Unmarshal` 的第二个参数需要传递**指针**(`&raw`)
|
||||
- `interface{}` 是 Go 的"万能类型",可以存储任何值
|
||||
- 如果响应不是 JSON(比如返回的是 HTML 错误页面),就直接返回字符串
|
||||
|
||||
### ⑧ GitLink 业务错误检查(第 122-142 行)
|
||||
|
||||
```go
|
||||
// 检查 GitLink 业务错误(响应体中的 status 字段)
|
||||
if status, ok := raw["status"]; ok {
|
||||
var statusCode float64
|
||||
switch v := status.(type) {
|
||||
case float64:
|
||||
statusCode = v
|
||||
case int:
|
||||
statusCode = float64(v)
|
||||
}
|
||||
|
||||
// status 不为 0、1、200 都是错误
|
||||
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
|
||||
msg, _ := raw["message"].(string)
|
||||
info := lookupStatusInfo(int(statusCode))
|
||||
return output.ErrorEnvelope(int(statusCode), msg, info.suggestion), &APIError{
|
||||
StatusCode: int(statusCode),
|
||||
Code: int(statusCode),
|
||||
Message: msg,
|
||||
Kind: info.kind,
|
||||
Suggestion: info.suggestion,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:检查 GitLink API 返回的业务错误码
|
||||
|
||||
**运行时作用**:GitLink API 有时 HTTP 状态码是 200,但响应体中的 `status` 字段表示业务失败(如参数校验失败)。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
GitLink API 的响应格式:
|
||||
```json
|
||||
{
|
||||
"status": 0, // 0=失败, 1=成功, 200=成功
|
||||
"message": "...", // 错误信息
|
||||
"data": {...} // 数据
|
||||
}
|
||||
```
|
||||
|
||||
### ⑨ 自动解析 JSON 字符串数据(第 144-150 行)
|
||||
|
||||
```go
|
||||
// 自动解析 JSON 字符串数据(GitLink API 的一个特性)
|
||||
if dataStr, ok := raw["data"].(string); ok {
|
||||
var parsedData interface{}
|
||||
if err := json.Unmarshal([]byte(dataStr), &parsedData); err == nil {
|
||||
raw["data"] = json.RawMessage(dataStr)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:处理 data 字段是 JSON 字符串的情况
|
||||
|
||||
**运行时作用**:GitLink 某些 API 返回的 `data` 字段是字符串形式的 JSON,需要再次解析。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
比如响应是这样的:
|
||||
```json
|
||||
{
|
||||
"status": 1,
|
||||
"data": "{\"name\": \"test\"}" // data 是字符串!
|
||||
}
|
||||
```
|
||||
|
||||
这里需要把 `"{\"name\": \"test\"}"` 再解析成 `{"name": "test"}`。
|
||||
|
||||
### ⑩ 构建分页元数据(第 152-166 行)
|
||||
|
||||
```go
|
||||
// 构建分页元数据
|
||||
var meta *output.Meta
|
||||
if tc, ok := raw["total_count"]; ok {
|
||||
meta = &output.Meta{}
|
||||
if v, ok := tc.(float64); ok {
|
||||
meta.TotalCount = int(v)
|
||||
}
|
||||
if v, ok := raw["page"].(float64); ok {
|
||||
meta.Page = int(v)
|
||||
}
|
||||
if v, ok := raw["limit"].(float64); ok {
|
||||
meta.Limit = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回成功的 Envelope
|
||||
return output.SuccessEnvelope(raw, meta), nil
|
||||
```
|
||||
|
||||
**字面意思**:从响应中提取分页信息
|
||||
|
||||
**运行时作用**:如果 API 返回了分页信息(total_count/page/limit),提取出来作为 `Meta`。
|
||||
|
||||
---
|
||||
|
||||
## 第 170-184 行:便捷方法
|
||||
|
||||
```go
|
||||
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
|
||||
return c.Do("GET", path, nil, query)
|
||||
}
|
||||
|
||||
func (c *Client) Post(path string, body interface{}) (*output.Envelope, error) {
|
||||
return c.Do("POST", path, body, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Put(path string, body interface{}) (*output.Envelope, error) {
|
||||
return c.Do("PUT", path, body, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) {
|
||||
return c.Do("DELETE", path, nil, query)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:封装常见的 HTTP 方法
|
||||
|
||||
**运行时作用**:提供更简洁的调用方式,比如 `client.Get("/users/me", nil)` 而不是 `client.Do("GET", "/users/me", nil, nil)`。
|
||||
|
||||
---
|
||||
|
||||
## 第 186-225 行:错误信息映射
|
||||
|
||||
```go
|
||||
type statusInfo struct {
|
||||
kind clierrors.ErrorKind
|
||||
message string
|
||||
suggestion string
|
||||
}
|
||||
|
||||
var statusMessages = map[int]statusInfo{
|
||||
-2: {clierrors.KindAuth, "未登录或 Token 已过期",
|
||||
"运行 gitlink-cli auth login 重新登录"},
|
||||
-1: {clierrors.KindInput, "参数校验失败",
|
||||
"检查必填参数是否缺失"},
|
||||
401: {clierrors.KindAuth, "认证失败",
|
||||
"运行 gitlink-cli auth login 登录"},
|
||||
403: {clierrors.KindForbidden, "权限不足",
|
||||
"请确认账号有此仓库的访问权限"},
|
||||
404: {clierrors.KindNotFound, "资源不存在",
|
||||
"检查 owner/repo/id 是否正确"},
|
||||
// ... 更多状态码
|
||||
}
|
||||
|
||||
func lookupStatusInfo(code int) statusInfo {
|
||||
if info, ok := statusMessages[code]; ok {
|
||||
return info
|
||||
}
|
||||
return statusInfo{
|
||||
kind: clierrors.KindUnknown,
|
||||
message: fmt.Sprintf("API 返回错误码 %d", code),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:根据错误码查找对应的错误信息
|
||||
|
||||
**运行时作用**:把枯燥的错误码转换成人类可读的错误信息和解决建议。
|
||||
|
||||
---
|
||||
|
||||
## 第 227-246 行:`shouldAppendJSONSuffix` 方法
|
||||
|
||||
```go
|
||||
func (c *Client) shouldAppendJSONSuffix(path string) bool {
|
||||
// 1. 如果设置了 SkipJSONSuffix,不添加
|
||||
if c.SkipJSONSuffix {
|
||||
return false
|
||||
}
|
||||
// 2. 如果已经有 .json 后缀,不添加
|
||||
if strings.HasSuffix(path, ".json") {
|
||||
return false
|
||||
}
|
||||
// 3. 如果是 raw 内容路径,不添加
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == "raw" && i >= 2 && i+2 < len(parts) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 4. 其他情况,添加 .json 后缀
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:判断是否应该添加 `.json` 后缀
|
||||
|
||||
**运行时作用**:控制是否自动添加 `.json` 后缀。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
为什么需要这个方法?
|
||||
- Wiki Gateway API 不需要 `.json` 后缀(设置 `SkipJSONSuffix: true`)
|
||||
- 某些路径(如 `/owner/repo/raw/...`)返回的是原始文件内容,不是 JSON
|
||||
|
||||
---
|
||||
|
||||
## 完整调用流程
|
||||
|
||||
```
|
||||
ctx.CallAPI("GET", "/users/me", nil)
|
||||
↓
|
||||
Client.Do("GET", "/users/me", nil, nil)
|
||||
↓
|
||||
1. 路径处理:/users/me → /users/me.json
|
||||
2. 构建 URL:https://www.gitlink.org.cn/api/users/me.json
|
||||
3. 创建 HTTP 请求:http.NewRequest("GET", url, nil)
|
||||
4. 发送请求:c.HTTP.Do(req)
|
||||
↓ (auth.NewHTTPClient() 自动添加 Authorization 头)
|
||||
5. 读取响应体:io.ReadAll(resp.Body)
|
||||
6. 检查状态码:如果 >= 400,返回 APIError
|
||||
7. 解析 JSON:json.Unmarshal → map[string]interface{}
|
||||
8. 检查业务错误:判断 status 字段
|
||||
9. 返回 Envelope:output.SuccessEnvelope(raw, meta)
|
||||
```
|
||||
|
|
@ -0,0 +1,706 @@
|
|||
# shortcuts/wiki/wiki.go 阅读笔记(面向 Go 小白)
|
||||
|
||||
---
|
||||
|
||||
## 第 1 行:`package wiki`
|
||||
|
||||
**字面意思**:声明这个文件属于 `wiki` 包
|
||||
|
||||
**运行时作用**:Go 语言规定每个文件必须属于一个包。包名决定了其他文件如何引用这里的函数/变量。
|
||||
|
||||
**小白补充**:
|
||||
- 包就像"工具箱",`wiki` 包就是专门处理 Wiki 功能的工具箱
|
||||
- 同一个包下的文件可以直接互相调用函数,不需要导入
|
||||
- 包名一般和目录名一致(这里文件在 `shortcuts/wiki/` 目录下,所以包名是 `wiki`)
|
||||
|
||||
---
|
||||
|
||||
## 第 3-22 行:import 导入依赖
|
||||
|
||||
```go
|
||||
import (
|
||||
"encoding/base64" // Base64 编解码
|
||||
"encoding/json" // JSON 序列化/反序列化
|
||||
"errors" // 错误处理
|
||||
"fmt" // 格式化输出(类似 Python 的 print)
|
||||
"net/http" // HTTP 客户端
|
||||
"net/url" // URL 编码/解析
|
||||
"os" // 操作系统交互(读文件等)
|
||||
"regexp" // 正则表达式
|
||||
"strconv" // 字符串转数字
|
||||
"strings" // 字符串操作
|
||||
"sync" // 并发同步(锁、线程安全)
|
||||
"time" // 时间处理
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client" // HTTP 客户端封装
|
||||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // CLI 错误定义
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common" // 通用工具
|
||||
)
|
||||
```
|
||||
|
||||
**字面意思**:导入需要用到的外部库/包
|
||||
|
||||
**运行时作用**:告诉 Go 编译器,我需要使用这些包提供的功能。编译时会把这些包的代码链接进来。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
| 包名 | 一句话解释 | 在本文件中的用途 |
|
||||
|------|-----------|-----------------|
|
||||
| `encoding/base64` | 把文字转成 Base64 编码 | Wiki 内容需要用 Base64 编码后发送 |
|
||||
| `encoding/json` | 处理 JSON 数据 | 解析 API 返回的 JSON |
|
||||
| `errors` | Go 标准错误处理工具 | 判断错误类型 |
|
||||
| `fmt` | 格式化打印 | 输出错误信息、拼接字符串 |
|
||||
| `net/http` | HTTP 协议客户端 | 发送 HTTP 请求 |
|
||||
| `net/url` | URL 处理 | 构建查询参数、URL 编码 |
|
||||
| `os` | 操作系统接口 | 读取本地文件内容 |
|
||||
| `regexp` | 正则表达式 | 匹配 Markdown 链接和图片 |
|
||||
| `strconv` | 字符串转换 | 把字符串转成数字 |
|
||||
| `strings` | 字符串操作 | 切割、查找、替换字符串 |
|
||||
| `sync` | 并发同步 | 提供线程安全的缓存(`sync.Map`) |
|
||||
| `time` | 时间处理 | 设置 HTTP 请求超时 |
|
||||
| `internal/auth` | 项目内部认证模块 | 获取带 Token 的 HTTP 客户端 |
|
||||
| `internal/client` | 项目内部客户端模块 | 封装 API 调用逻辑 |
|
||||
| `internal/errors` | 项目内部错误定义 | 自定义错误类型 |
|
||||
| `internal/output` | 项目内部输出模块 | 格式化输出结果(JSON/Table) |
|
||||
| `shortcuts/common` | 通用工具模块 | 提供 RuntimeContext 等基础结构 |
|
||||
|
||||
---
|
||||
|
||||
## 第 24 行:`var projectIDCache sync.Map`
|
||||
|
||||
**字面意思**:声明一个全局变量 `projectIDCache`,类型是 `sync.Map`
|
||||
|
||||
**运行时作用**:这是一个**线程安全的缓存**,用来存储 `owner/repo -> projectID` 的映射关系,避免重复调用 API 获取项目 ID。
|
||||
|
||||
**小白补充**:
|
||||
- `var` 是 Go 声明变量的关键字
|
||||
- `sync.Map` 是 Go 标准库提供的**并发安全的 map**(普通 map 在多线程下读写会崩溃)
|
||||
- `projectIDCache` 是全局变量(在函数外面声明),整个包内都可以访问
|
||||
- 为什么需要缓存?因为每次操作 Wiki 都需要 projectID,但获取 projectID 需要调用一次 API,缓存可以节省网络请求
|
||||
|
||||
---
|
||||
|
||||
## 第 26-28 行:`wikiPath` 函数
|
||||
|
||||
```go
|
||||
func wikiPath(endpoint string) string {
|
||||
return "/wiki/open/" + endpoint
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:定义一个函数 `wikiPath`,接收一个字符串参数 `endpoint`,返回一个字符串Wiki 功能调用的是 Gateway API (网关 API),所有 Wiki 相关的接口都有一个固定的前缀 /wiki/open/
|
||||
|
||||
**运行时作用**:拼接 Wiki API 的路径前缀。比如传入 `"wikiPages"`,返回 `"/wiki/open/wikiPages"`。
|
||||
|
||||
**小白补充**:
|
||||
- `func` 是 Go 定义函数的关键字
|
||||
- `wikiPath(endpoint string)`:函数名是 `wikiPath`,参数名是 `endpoint`,参数类型是 `string`
|
||||
- `string`(返回类型):表示函数执行完返回一个字符串
|
||||
- 这是一个**工具函数**,用来避免重复写相同的路径前缀
|
||||
|
||||
---
|
||||
|
||||
## 第 30-49 行:`getGatewayClient` 函数
|
||||
|
||||
```go
|
||||
func getGatewayClient(ctx *common.RuntimeContext) *client.Client {
|
||||
baseURL := ctx.GatewayBaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://gateway.gitlink.org.cn/api"
|
||||
}
|
||||
httpClient := ctx.GatewayHTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = auth.NewHTTPClient()
|
||||
}
|
||||
return &client.Client{
|
||||
HTTP: httpClient,
|
||||
BaseURL: baseURL,
|
||||
SkipJSONSuffix: true,
|
||||
Debug: ctx.Client.Debug,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:定义一个函数 `getGatewayClient`,接收 `*common.RuntimeContext` 类型的指针参数 `ctx`,返回 `*client.Client` 类型的指针
|
||||
|
||||
**运行时作用**:创建一个专门访问 **Wiki Gateway API** 的客户端实例。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① 为什么需要单独的 Gateway 客户端?
|
||||
|
||||
GitLink 的 Wiki API 和主 API 不在同一个域名:
|
||||
- 主 API:`https://www.gitlink.org.cn/api`(用于获取项目信息等)
|
||||
- Wiki Gateway API:`https://gateway.gitlink.org.cn/api`(专门处理 Wiki 操作)
|
||||
|
||||
### ② 代码逐句解析:
|
||||
|
||||
```go
|
||||
baseURL := ctx.GatewayBaseURL // 从上下文获取 Gateway 地址
|
||||
if baseURL == "" { // 如果没配置,用默认地址
|
||||
baseURL = "https://gateway.gitlink.org.cn/api"
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
httpClient := ctx.GatewayHTTPClient // 获取自定义的 HTTP 客户端
|
||||
if httpClient == nil { // 如果没有自定义的,创建一个带认证的默认客户端
|
||||
httpClient = auth.NewHTTPClient()
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
return &client.Client{...} // 创建并返回 Client 结构体实例
|
||||
```
|
||||
|
||||
### ③ 结构体初始化语法:
|
||||
|
||||
```go
|
||||
&client.Client{
|
||||
HTTP: httpClient, // 使用上面创建的 HTTP 客户端
|
||||
BaseURL: baseURL, // Gateway API 地址
|
||||
SkipJSONSuffix: true, // 关键:Gateway API 不需要 .json 后缀
|
||||
Debug: ctx.Client.Debug, // 继承调试模式
|
||||
}
|
||||
```
|
||||
|
||||
- `&` 符号表示取地址,返回指针(Go 中结构体传参常用指针,避免拷贝)
|
||||
- `client.Client` 是一个**结构体类型**,里面定义了客户端的各种配置
|
||||
|
||||
---
|
||||
|
||||
## 第 51-58 行:`callWikiAPI` 函数
|
||||
|
||||
```go
|
||||
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
|
||||
gc := getGatewayClient(ctx)
|
||||
env, err := gc.Do(method, path, body, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unwrapGatewayResponse(env)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:定义函数 `callWikiAPI`,接收上下文、HTTP 方法、路径、请求体,返回 `(*output.Envelope, error)`
|
||||
|
||||
**运行时作用**:封装对 Wiki Gateway API 的调用流程。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① 参数说明:
|
||||
- `ctx *common.RuntimeContext`:运行时上下文,包含认证信息、owner/repo 等
|
||||
- `method string`:HTTP 方法(GET/POST/PUT/DELETE)
|
||||
- `path string`:API 路径
|
||||
- `body interface{}`:请求体(可以是任何类型,Go 中 `interface{}` 表示万能类型)
|
||||
|
||||
### ② 返回值说明:
|
||||
- `(*output.Envelope, error)`:Go 可以返回多个值!第一个是 API 返回的包装数据,第二个是错误
|
||||
|
||||
### ③ 执行流程:
|
||||
1. `gc := getGatewayClient(ctx)` → 获取 Gateway 客户端
|
||||
2. `gc.Do(...)` → 调用客户端的 Do 方法发送 HTTP 请求
|
||||
3. `unwrapGatewayResponse(env)` → 解析并处理响应(后面会讲)
|
||||
|
||||
---
|
||||
|
||||
## 第 60-67 行:`callWikiAPIWithQuery` 函数
|
||||
|
||||
```go
|
||||
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
|
||||
gc := getGatewayClient(ctx)
|
||||
env, err := gc.Do(method, path, nil, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unwrapGatewayResponse(env)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:和 `callWikiAPI` 类似,但专门用于带查询参数的请求
|
||||
|
||||
**运行时作用**:当需要发送带 `?key=value` 查询参数的 GET 请求时使用。
|
||||
|
||||
**小白补充**:
|
||||
- `url.Values` 是 Go 标准库类型,本质是 `map[string][]string`,用来存储 URL 查询参数
|
||||
- 比如 `?owner=zzx&repo=test` 会被表示为 `{"owner": ["zzx"], "repo": ["test"]}`
|
||||
|
||||
---
|
||||
|
||||
## 第 69-99 行:`unwrapGatewayResponse` 函数(核心!)
|
||||
|
||||
```go
|
||||
func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) {
|
||||
// 1. 尝试把响应数据转成 map
|
||||
resp, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return env, nil // 不是 map 格式,直接返回
|
||||
}
|
||||
|
||||
// 2. 检查响应中的 code 字段
|
||||
if code, ok := resp["code"]; ok {
|
||||
switch v := code.(type) {
|
||||
case float64:
|
||||
// HTTP 2xx 都算成功(包括 200/201/204 等)
|
||||
if v < 200 || v >= 300 {
|
||||
// 错误情况:提取错误信息
|
||||
msg, _ := resp["msg"].(string)
|
||||
kind := clierrors.KindServer
|
||||
if int(v) == 404 {
|
||||
kind = clierrors.KindNotFound
|
||||
} else if int(v) == 401 || int(v) == 403 {
|
||||
kind = clierrors.KindForbidden
|
||||
}
|
||||
// 返回自定义错误
|
||||
return nil, clierrors.New(kind, msg,
|
||||
"检查 owner/repo 是否正确,或确认仓库已在 GitLink 网页端开启 Wiki 功能")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果响应有 data 字段,提取出来作为新的响应数据
|
||||
if innerData, ok := resp["data"]; ok {
|
||||
return output.SuccessEnvelope(innerData, env.Meta), nil
|
||||
}
|
||||
|
||||
return env, nil
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:"拆开" Gateway API 的响应,提取真正的数据
|
||||
|
||||
**运行时作用**:处理 Gateway API 返回的特殊格式,统一成标准的 `Envelope` 结构。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① Gateway API 的响应格式:
|
||||
|
||||
Gateway API 返回的 JSON 格式是这样的:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": { "真正的数据在这里" }
|
||||
}
|
||||
```
|
||||
|
||||
而我们需要的是直接拿到 `data` 里面的内容。
|
||||
|
||||
### ② 类型断言(Go 的特色语法):
|
||||
|
||||
```go
|
||||
resp, ok := env.Data.(map[string]interface{})
|
||||
```
|
||||
|
||||
- 这是**类型断言**,把 `env.Data`(类型是 `interface{}`)转换成 `map[string]interface{}`
|
||||
- `ok` 是一个布尔值,表示转换是否成功
|
||||
- 如果转换失败(比如 `env.Data` 是个字符串而不是 map),`ok` 就是 `false`
|
||||
|
||||
### ③ switch type 语法:
|
||||
|
||||
```go
|
||||
switch v := code.(type) {
|
||||
case float64:
|
||||
// code 是浮点数类型时执行这里
|
||||
}
|
||||
```
|
||||
|
||||
- 这是 Go 的**类型 switch**,用来判断一个 `interface{}` 变量的具体类型
|
||||
- JSON 解析数字时,默认会转成 `float64` 类型
|
||||
|
||||
### ④ 为什么要判断 2xx 状态码?
|
||||
|
||||
```go
|
||||
if v < 200 || v >= 300 {
|
||||
// 错误处理
|
||||
}
|
||||
```
|
||||
|
||||
- HTTP 状态码中,200-299 表示成功
|
||||
- 之前的代码只判断了 200/201,导致 DELETE 返回 204(No Content)时被误判为失败
|
||||
- 现在扩展到所有 2xx 都算成功
|
||||
|
||||
---
|
||||
|
||||
## 第 101-135 行:`resolveProjectID` 函数(核心!)
|
||||
|
||||
```go
|
||||
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
|
||||
// 1. 生成缓存 key
|
||||
key := ctx.Owner + "/" + ctx.Repo
|
||||
|
||||
// 2. 先查缓存
|
||||
if cached, ok := projectIDCache.Load(key); ok {
|
||||
return cached.(string), nil // 缓存命中,直接返回
|
||||
}
|
||||
|
||||
// 3. 缓存没命中,调用主 API 获取项目详情
|
||||
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPI("GET", path, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch project details (needed for projectId): %w", err)
|
||||
}
|
||||
|
||||
// 4. 从响应中提取 project_id
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unexpected response from project detail API")
|
||||
}
|
||||
|
||||
pid, ok := data["project_id"]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("project_id not found in project detail response")
|
||||
}
|
||||
|
||||
// 5. 处理 project_id 的不同类型(可能是 float64 或 int)
|
||||
var pidStr string
|
||||
switch v := pid.(type) {
|
||||
case float64:
|
||||
pidStr = fmt.Sprintf("%.0f", v)
|
||||
case int:
|
||||
pidStr = fmt.Sprintf("%d", v)
|
||||
default:
|
||||
pidStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// 6. 存入缓存
|
||||
projectIDCache.Store(key, pidStr)
|
||||
return pidStr, nil
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:根据 owner/repo 解析出项目的数字 ID
|
||||
|
||||
**运行时作用**:Wiki API 需要 `projectId`(数字),但用户只知道 `owner/repo`(字符串),这个函数就是做转换的。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① 为什么需要 projectID?
|
||||
|
||||
GitLink 的 Wiki Gateway API 设计要求传入数字形式的 `projectId`,而不是字符串形式的 `owner/repo`。所以必须先调用主 API 获取项目详情,从中提取 `project_id`。
|
||||
|
||||
### ② 缓存机制:
|
||||
|
||||
```go
|
||||
if cached, ok := projectIDCache.Load(key); ok {
|
||||
return cached.(string), nil
|
||||
}
|
||||
```
|
||||
|
||||
- `projectIDCache.Load(key)` 从缓存中查找
|
||||
- 如果找到(`ok == true`),直接返回缓存的值,不需要再调用 API
|
||||
- 这是**性能优化**,避免重复请求
|
||||
|
||||
### ③ fmt.Sprintf 的用法:
|
||||
|
||||
```go
|
||||
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
|
||||
```
|
||||
|
||||
- 类似 Python 的 `"%s/%s/detail" % (owner, repo)`
|
||||
- `%s` 是占位符,会被后面的参数替换
|
||||
|
||||
### ④ ctx.CallAPI 是什么?
|
||||
|
||||
```go
|
||||
env, err := ctx.CallAPI("GET", path, nil)
|
||||
```
|
||||
|
||||
- `ctx` 是 `*common.RuntimeContext` 类型
|
||||
- `CallAPI` 是 `RuntimeContext` 结构体的**方法**(后面会详细讲)
|
||||
- 它内部调用 `ctx.Client.Do()` 发送 HTTP 请求
|
||||
|
||||
---
|
||||
|
||||
## 第 137-140 行:`parseProjectIDInt` 函数
|
||||
|
||||
```go
|
||||
func parseProjectIDInt(pid string) int {
|
||||
n, _ := strconv.Atoi(pid)
|
||||
return n
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:把字符串形式的 projectID 转成整数
|
||||
|
||||
**运行时作用**:Wiki API 的某些接口要求 `projectId` 是整数类型,所以需要转换。
|
||||
|
||||
**小白补充**:
|
||||
- `strconv.Atoi` 是 string convert to int 的缩写
|
||||
- `_` 是 Go 语言的"忽略符",表示忽略返回的错误(这里假设 pid 一定是合法数字)
|
||||
|
||||
---
|
||||
|
||||
## 第 142-154 行:`resolveUpdateContent` 函数
|
||||
|
||||
```go
|
||||
func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (string, error) {
|
||||
if text != "" {
|
||||
return text, nil // 直接使用提供的文本
|
||||
}
|
||||
if filePath != "" {
|
||||
data, err := os.ReadFile(filePath) // 从文件读取
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "", fmt.Errorf("no content provided")
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:解析更新 Wiki 时的内容来源
|
||||
|
||||
**运行时作用**:支持两种方式提供内容:直接文本(`--cover`)或文件路径(`--file`)。
|
||||
|
||||
---
|
||||
|
||||
## 第 156-179 行:`fetchPageContent` 函数(带自动重试)
|
||||
|
||||
```go
|
||||
func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (content string, actualPageName string, err error) {
|
||||
// 第一次尝试
|
||||
c, actual, err := fetchPageContentOnce(ctx, projectID, pageName)
|
||||
if err == nil {
|
||||
return c, actual, nil // 成功了,直接返回
|
||||
}
|
||||
|
||||
// 第一次失败,且 pageName 不带 ".-" 后缀,自动重试
|
||||
if !strings.HasSuffix(pageName, ".-") {
|
||||
c2, actual2, err2 := fetchPageContentOnce(ctx, projectID, pageName+".-")
|
||||
if err2 == nil {
|
||||
return c2, actual2, nil // 重试成功
|
||||
}
|
||||
}
|
||||
|
||||
// 两次都失败
|
||||
return "", "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:获取 Wiki 页面的明文内容,带自动重试机制
|
||||
|
||||
**运行时作用**:解决 GitLink 后端的一个命名问题。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① GitLink 后端的命名 bug:
|
||||
|
||||
GitLink 创建 Wiki 页面时,会自动给内部存储的 `sub_url` 追加 `".-"` 后缀。但 `wiki +list` 返回的 `title` 不带后缀。
|
||||
|
||||
比如:
|
||||
- 用户创建页面 "Home"
|
||||
- 后端实际存储的 key 是 "Home.-"
|
||||
- 但 list API 返回的 title 是 "Home"
|
||||
|
||||
所以用 "Home" 去查询会 404,必须用 "Home.-" 才能查到。
|
||||
|
||||
### ② 自动重试逻辑:
|
||||
1. 先用原始 `pageName` 尝试查询
|
||||
2. 如果失败,且 `pageName` 不带 `".-"` 后缀
|
||||
3. 自动用 `pageName+".-"` 重试一次
|
||||
|
||||
---
|
||||
|
||||
## 第 181-205 行:`fetchPageContentOnce` 函数(单次查询)
|
||||
|
||||
```go
|
||||
func fetchPageContentOnce(ctx *common.RuntimeContext, projectID, pageName string) (string, string, error) {
|
||||
// 构建查询参数
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
q.Set("projectId", projectID)
|
||||
q.Set("pageName", pageName)
|
||||
|
||||
// 调用 API
|
||||
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unexpected response from getWiki")
|
||||
}
|
||||
|
||||
// 提取 base64 编码的内容
|
||||
b64, _ := data["content_base64"].(string)
|
||||
if b64 == "" {
|
||||
return "", pageName, nil // 内容为空,返回空字符串
|
||||
}
|
||||
|
||||
// Base64 解码
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to decode page content: %w", err)
|
||||
}
|
||||
|
||||
return string(decoded), pageName, nil
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:单次尝试获取 Wiki 页面内容
|
||||
|
||||
**运行时作用**:发送 GET 请求到 `/wiki/open/getWiki`,获取页面数据并解码。
|
||||
|
||||
**小白补充**:
|
||||
|
||||
### ① URL 查询参数构建:
|
||||
|
||||
```go
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
```
|
||||
|
||||
- `url.Values` 是 map 类型,用来存储查询参数
|
||||
- 最终会变成 `?owner=zzx&repo=test&projectId=12345&pageName=Home`
|
||||
|
||||
### ② Base64 编解码:
|
||||
|
||||
```go
|
||||
b64, _ := data["content_base64"].(string) // 获取 base64 编码的内容
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64) // 解码
|
||||
return string(decoded), pageName, nil // 转成字符串返回
|
||||
```
|
||||
|
||||
- Wiki API 返回的内容是 Base64 编码的(可能是为了支持二进制文件)
|
||||
- 需要解码才能得到人类可读的文本
|
||||
|
||||
---
|
||||
|
||||
## 第 207-216 行:`fetchWikiPage` 函数
|
||||
|
||||
```go
|
||||
func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) {
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
q.Set("projectId", projectID)
|
||||
q.Set("pageName", pageName)
|
||||
return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:获取 Wiki 页面的完整响应(不解码)
|
||||
|
||||
**运行时作用**:和 `fetchPageContent` 类似,但返回完整的 `Envelope` 而不是解码后的文本。
|
||||
|
||||
---
|
||||
|
||||
## 第 218-230 行:`resolveContent` 函数
|
||||
|
||||
```go
|
||||
func resolveContent(ctx *common.RuntimeContext) (string, error) {
|
||||
if content := ctx.Arg("content"); content != "" {
|
||||
return content, nil
|
||||
}
|
||||
if filePath := ctx.Arg("file"); filePath != "" {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "", fmt.Errorf("--content or --file is required to provide wiki page content")
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:解析创建 Wiki 时的内容来源
|
||||
|
||||
**运行时作用**:支持 `--content` 直接传内容,或 `--file` 从文件读取。
|
||||
|
||||
**小白补充**:
|
||||
- `ctx.Arg("content")` 是从命令行参数中获取 `--content` 的值
|
||||
- 如果两个参数都没提供,返回错误
|
||||
|
||||
---
|
||||
|
||||
## 第 232-249 行:`cleanWikiList` 函数
|
||||
|
||||
```go
|
||||
func cleanWikiList(env *output.Envelope) {
|
||||
// 把 data 转成 slice
|
||||
items, ok := env.Data.([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 遍历每个 wiki 页面
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// 删除不需要的字段
|
||||
delete(m, "wiki_clone_link")
|
||||
|
||||
// URL 解码 sub_url
|
||||
if raw, ok := m["sub_url"].(string); ok {
|
||||
if decoded, err := url.QueryUnescape(raw); err == nil {
|
||||
m["sub_url"] = decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:清理 Wiki 列表数据
|
||||
|
||||
**运行时作用**:对 `wiki +list` 返回的数据进行清洗,去掉无用字段,解码 URL。
|
||||
|
||||
---
|
||||
|
||||
## 第 251-299 行:`outputWithDecodedContent` 函数
|
||||
|
||||
```go
|
||||
func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) error {
|
||||
data := env.Data
|
||||
|
||||
// 处理 JSON 字符串形式的 data
|
||||
if raw, ok := data.(json.RawMessage); ok {
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &m); err == nil {
|
||||
data = m
|
||||
env.Data = m
|
||||
}
|
||||
}
|
||||
|
||||
// 转成 map
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
// content_base64 → content(重命名并解码)
|
||||
if b64, ok := m["content_base64"].(string); ok && b64 != "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
|
||||
m["content"] = string(decoded)
|
||||
delete(m, "content_base64") // 删除原字段
|
||||
}
|
||||
}
|
||||
|
||||
// sidebar / footer 原地解码
|
||||
for _, field := range []string{"sidebar", "footer"} {
|
||||
if b64, ok := m[field].(string); ok && b64 != "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
|
||||
m[field] = string(decoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.Output(env)
|
||||
}
|
||||
```
|
||||
|
||||
**字面意思**:解码 Wiki 响应中的所有 Base64 字段,用明文替换
|
||||
|
||||
**运行时作用**:让返回的 Wiki 内容更易读,同时节省 token(Base64 编码会增加约 33% 的体积)。
|
||||
|
||||
---
|
||||
|
||||
## 第 301-526 行:Lint 相关
|
||||
|
|
@ -0,0 +1,510 @@
|
|||
# 逐行讲解 shortcuts/webhook/webhook.go(面向 Go 小白)
|
||||
|
||||
## 文件概述
|
||||
|
||||
这个文件实现了 **Webhook 管理**功能,可以对 GitLink 仓库的 Webhook 进行增删改查操作。
|
||||
|
||||
---
|
||||
|
||||
## 一、包声明和导入
|
||||
|
||||
```go
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
```
|
||||
|
||||
| 导入库 | 作用 |
|
||||
|-------|------|
|
||||
| `fmt` | 格式化输出,用于拼接字符串和格式化错误信息 |
|
||||
| `net/url` | URL 相关操作,用于构建查询参数 |
|
||||
| `strings` | 字符串处理,用于分割、修剪等操作 |
|
||||
| `clierrors` | 自定义 CLI 错误类型,用于返回友好的错误提示 |
|
||||
| `output` | 输出格式化,用于返回统一格式的结果 |
|
||||
| `common` | 公共工具包,包含 Shortcut、RuntimeContext 等核心类型 |
|
||||
|
||||
---
|
||||
|
||||
## 二、支持的 Webhook 事件类型
|
||||
|
||||
```go
|
||||
var supportedEvents = []string{
|
||||
"push",
|
||||
"pull_request",
|
||||
"issue",
|
||||
"issue_assign",
|
||||
"issue_comment",
|
||||
"pull_request_assign",
|
||||
"pull_request_comment",
|
||||
"merge_request",
|
||||
"repository",
|
||||
"branch",
|
||||
"tag",
|
||||
}
|
||||
```
|
||||
|
||||
这是一个**全局变量**,定义了 GitLink 支持的所有 Webhook 事件类型:
|
||||
- `push`:代码推送事件
|
||||
- `pull_request`:PR 事件
|
||||
- `issue`:Issue 事件
|
||||
- `issue_assign`:Issue 分配事件
|
||||
- `issue_comment`:Issue 评论事件
|
||||
- `pull_request_assign`:PR 分配事件
|
||||
- `pull_request_comment`:PR 评论事件
|
||||
- `merge_request`:合并请求事件
|
||||
- `repository`:仓库事件
|
||||
- `branch`:分支创建/删除事件
|
||||
- `tag`:标签创建/删除事件
|
||||
|
||||
---
|
||||
|
||||
## 三、事件验证函数
|
||||
|
||||
```go
|
||||
func isEventSupported(event string) bool {
|
||||
for _, supported := range supportedEvents {
|
||||
if event == supported {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:检查某个事件类型是否被支持
|
||||
|
||||
**工作原理**:遍历 `supportedEvents` 数组,逐一比对,如果找到匹配项就返回 `true`,否则返回 `false`
|
||||
|
||||
---
|
||||
|
||||
## 四、解析事件字符串
|
||||
|
||||
```go
|
||||
func parseEvents(eventsStr string) []string {
|
||||
if eventsStr == "" {
|
||||
return []string{"push"} // 默认事件
|
||||
}
|
||||
events := strings.Split(eventsStr, ",")
|
||||
var validEvents []string
|
||||
for _, event := range events {
|
||||
event = strings.TrimSpace(event)
|
||||
if isEventSupported(event) {
|
||||
validEvents = append(validEvents, event)
|
||||
}
|
||||
}
|
||||
return validEvents
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:把用户输入的逗号分隔的事件字符串(如 `"push,pull_request"`)解析成事件数组
|
||||
|
||||
**逐行解读**:
|
||||
1. 如果输入为空,返回默认值 `["push"]`
|
||||
2. 使用 `strings.Split` 按逗号分割字符串
|
||||
3. 遍历每个事件,用 `strings.TrimSpace` 去掉前后空格
|
||||
4. 用 `isEventSupported` 验证有效性,有效才加入结果数组
|
||||
5. 返回过滤后的有效事件数组
|
||||
|
||||
---
|
||||
|
||||
## 五、API 路径构建函数
|
||||
|
||||
```go
|
||||
func webhookRepoPath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:构建 Webhook API 的基础路径
|
||||
|
||||
**参数**:`ctx` 是运行时上下文,包含 `Owner`(仓库所有者)和 `Repo`(仓库名)
|
||||
|
||||
**返回值**:类似 `/v1/owner/repo` 的字符串
|
||||
|
||||
**注意**:注释说明了 BaseURL 已经包含 `/api` 前缀,所以这里不需要再加
|
||||
|
||||
---
|
||||
|
||||
## 六、Shortcuts 主函数
|
||||
|
||||
```go
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// list 命令
|
||||
// create 命令
|
||||
// update 命令
|
||||
// delete 命令
|
||||
// test 命令
|
||||
// info 命令
|
||||
// events 命令
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:返回所有 Webhook 相关的 CLI 命令列表
|
||||
|
||||
这个函数是整个文件的核心,它定义了7个命令:
|
||||
1. `list` - 列出所有 Webhook
|
||||
2. `create` - 创建新 Webhook
|
||||
3. `update` - 更新现有 Webhook
|
||||
4. `delete` - 删除 Webhook
|
||||
5. `test` - 测试 Webhook 发送
|
||||
6. `info` - 查看 Webhook 详情
|
||||
7. `events` - 列出所有支持的事件类型
|
||||
|
||||
---
|
||||
|
||||
## 七、命令详解
|
||||
|
||||
### 7.1 list 命令
|
||||
|
||||
```go
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List all webhooks for a repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", webhookRepoPath(ctx)+"/webhooks", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Flags 参数说明**:
|
||||
- `--page/-p`:页码,默认第1页
|
||||
- `--limit/-l`:每页条数,默认20条
|
||||
|
||||
**执行流程**:
|
||||
1. 调用 `ctx.ResolveOwnerRepo()` 解析仓库信息
|
||||
2. 创建 URL 查询参数 `url.Values{}`
|
||||
3. 设置 `page` 和 `limit` 参数
|
||||
4. 调用 `CallAPIWithQuery` 发送 GET 请求到 `/v1/owner/repo/webhooks`
|
||||
5. 返回结果给用户
|
||||
|
||||
---
|
||||
|
||||
### 7.2 create 命令
|
||||
|
||||
```go
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new webhook",
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Short: "u", Usage: "Webhook callback URL", Required: true},
|
||||
{Name: "events", Short: "e", Usage: "Trigger events", Default: "push"},
|
||||
{Name: "active", Usage: "Webhook active status", Default: "true"},
|
||||
{Name: "secret", Usage: "Webhook secret for HMAC verification"},
|
||||
{Name: "description", Short: "d", Usage: "Webhook description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webhookURL, err := ctx.RequireArg("url", "--url https://example.com/hook")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
events := parseEvents(ctx.Arg("events"))
|
||||
if len(events) == 0 {
|
||||
return clierrors.InputError(...)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"url": webhookURL,
|
||||
"http_method": "POST",
|
||||
"active": true,
|
||||
"content_type": "json",
|
||||
}
|
||||
|
||||
// 添加可选参数
|
||||
if len(events) > 0 {
|
||||
payload["events"] = events
|
||||
}
|
||||
if secret := ctx.Arg("secret"); secret != "" {
|
||||
payload["secret"] = secret
|
||||
}
|
||||
if description := ctx.Arg("description"); description != "" {
|
||||
payload["description"] = description
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPI("POST", webhookRepoPath(ctx)+"/webhooks", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 Webhook 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**执行流程**:
|
||||
1. 解析仓库信息
|
||||
2. 必须获取 `--url` 参数(用 `RequireArg`,如果没提供会报错)
|
||||
3. 解析事件类型
|
||||
4. 创建 payload 映射,包含必填字段
|
||||
5. 添加可选的 secret 和 description
|
||||
6. 发送 POST 请求创建 Webhook
|
||||
|
||||
---
|
||||
|
||||
### 7.3 update 命令
|
||||
|
||||
```go
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update an existing webhook",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
{Name: "url", Short: "u", Usage: "Webhook callback URL"},
|
||||
{Name: "events", Short: "e", Usage: "Trigger events"},
|
||||
{Name: "active", Usage: "Webhook active status"},
|
||||
{Name: "content_type", Usage: "Content type"},
|
||||
{Name: "secret", Usage: "Webhook secret"},
|
||||
{Name: "description", Short: "d", Usage: "Webhook description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
// ... 解析仓库和 ID
|
||||
|
||||
webhookURL := ctx.Arg("url")
|
||||
if webhookURL == "" {
|
||||
// 如果用户没提供 URL,先获取当前 URL
|
||||
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
|
||||
// ... 解析响应获取当前 URL
|
||||
webhookURL = currentURL
|
||||
}
|
||||
payload["url"] = webhookURL
|
||||
|
||||
// ... 发送 PUT 请求
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**亮点**:如果用户没有提供新的 URL,会自动调用 GET API 获取当前 URL,这样就不需要用户重复输入
|
||||
|
||||
---
|
||||
|
||||
### 7.4 delete 命令
|
||||
|
||||
```go
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a webhook",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
// ... 解析仓库和 ID
|
||||
|
||||
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
|
||||
if delErr != nil {
|
||||
// 验证是否真的删除成功
|
||||
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
|
||||
if viewErr != nil {
|
||||
// GET 也失败,说明 Webhook 确实不存在了,删除成功
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "Webhook deleted successfully",
|
||||
}, nil))
|
||||
}
|
||||
return fmt.Errorf("删除 Webhook 失败: %w", delErr)
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(...))
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**亮点**:删除操作有一个**双重验证**机制:
|
||||
1. 先调用 DELETE 请求
|
||||
2. 如果 DELETE 返回错误,再调用 GET 请求检查 Webhook 是否还存在
|
||||
3. 如果 GET 也失败,说明 Webhook 已经被删除了,视为成功
|
||||
|
||||
---
|
||||
|
||||
### 7.5 test 命令
|
||||
|
||||
```go
|
||||
{
|
||||
Name: "test",
|
||||
Description: "Test a webhook delivery",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
{Name: "event", Short: "e", Usage: "Event type to test", Default: "push"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
// ... 解析参数
|
||||
|
||||
eventType := ctx.Arg("event")
|
||||
if !isEventSupported(eventType) {
|
||||
return clierrors.InputError(...)
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)
|
||||
// ...
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:向指定的 Webhook 发送测试请求,验证 Webhook 是否正常工作
|
||||
|
||||
---
|
||||
|
||||
### 7.6 info 命令
|
||||
|
||||
```go
|
||||
{
|
||||
Name: "info",
|
||||
Description: "Show webhook details",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
// ... 调用 GET /v1/owner/repo/webhooks/{id}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:查看单个 Webhook 的详细信息
|
||||
|
||||
---
|
||||
|
||||
### 7.7 events 命令
|
||||
|
||||
```go
|
||||
{
|
||||
Name: "events",
|
||||
Description: "List all supported event types for webhooks",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
eventInfo := make([]map[string]interface{}, 0)
|
||||
for _, event := range supportedEvents {
|
||||
eventInfo = append(eventInfo, map[string]interface{}{
|
||||
"event": event,
|
||||
"supported": true,
|
||||
"description": getEventDescription(event),
|
||||
})
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(eventInfo, nil))
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:列出所有支持的 Webhook 事件类型及其描述
|
||||
|
||||
---
|
||||
|
||||
## 八、事件描述函数
|
||||
|
||||
```go
|
||||
func getEventDescription(event string) string {
|
||||
descriptions := map[string]string{
|
||||
"push": "Code push events",
|
||||
"pull_request": "Pull request events",
|
||||
"issue": "Issue events",
|
||||
// ... 其他事件描述
|
||||
}
|
||||
if desc, ok := descriptions[event]; ok {
|
||||
return desc
|
||||
}
|
||||
return "Custom event"
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:返回事件类型的英文描述
|
||||
|
||||
**工作原理**:使用 map 查找事件对应的描述,如果找不到就返回 "Custom event"
|
||||
|
||||
---
|
||||
|
||||
## 九、完整调用流程
|
||||
|
||||
```
|
||||
用户命令 (gitlink webhook list)
|
||||
↓
|
||||
解析命令行参数
|
||||
↓
|
||||
Shortcuts() 返回命令列表
|
||||
↓
|
||||
匹配到 "list" 命令
|
||||
↓
|
||||
执行 Run 函数
|
||||
↓
|
||||
ctx.ResolveOwnerRepo() → 解析仓库信息
|
||||
↓
|
||||
ctx.CallAPIWithQuery() → 调用 HTTP 客户端
|
||||
↓
|
||||
内部调用 client.Do() → 发送 GET 请求
|
||||
↓
|
||||
解析响应 → ctx.Output() → 格式化输出给用户
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、Go 语言知识点
|
||||
|
||||
### 1. map[string]interface{} 类型
|
||||
|
||||
```go
|
||||
payload := map[string]interface{}{
|
||||
"url": webhookURL,
|
||||
"http_method": "POST",
|
||||
"active": true,
|
||||
}
|
||||
```
|
||||
|
||||
这是一个**万能类型**,可以存储任意类型的值:
|
||||
- `"url"` 对应字符串
|
||||
- `"active"` 对应布尔值
|
||||
- `"events"` 对应字符串数组
|
||||
|
||||
### 2. 字符串拼接
|
||||
|
||||
```go
|
||||
fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||
```
|
||||
|
||||
类似 Python 的 `f"/v1/{owner}/{repo}"`,用 `%s` 占位符
|
||||
|
||||
### 3. 错误包装
|
||||
|
||||
```go
|
||||
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
|
||||
```
|
||||
|
||||
`%w` 是 Go 1.13+ 的错误包装语法,保留原始错误信息
|
||||
|
||||
### 4. 函数作为参数
|
||||
|
||||
```go
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
// 匿名函数
|
||||
}
|
||||
```
|
||||
|
||||
这是一个**匿名函数**,作为 `Shortcut` 结构体的 `Run` 字段值
|
||||
|
||||
### 5. 字符串分割
|
||||
|
||||
```go
|
||||
events := strings.Split(eventsStr, ",")
|
||||
```
|
||||
|
||||
按逗号分割字符串,返回字符串数组
|
||||
|
|
@ -0,0 +1,546 @@
|
|||
# 逐行讲解 shortcuts/issue/batch.go(面向 Go 小白)
|
||||
|
||||
## 文件概述
|
||||
|
||||
这个文件实现了 **Issue 批量操作**功能,可以对多个 Issue 进行批量关闭、修改状态、修改优先级、分配人和修改标签等操作。
|
||||
|
||||
---
|
||||
|
||||
## 一、包声明和导入
|
||||
|
||||
```go
|
||||
package issue
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
```
|
||||
|
||||
| 导入库 | 作用 |
|
||||
|-------|------|
|
||||
| `encoding/csv` | CSV 文件解析,用于从文件读取 Issue 编号 |
|
||||
| `fmt` | 格式化输出 |
|
||||
| `os` | 文件操作,用于打开 CSV 文件 |
|
||||
| `strconv` | 字符串和数字之间的转换 |
|
||||
| `strings` | 字符串处理 |
|
||||
| `common` | 公共工具包 |
|
||||
|
||||
---
|
||||
|
||||
## 二、常量定义
|
||||
|
||||
```go
|
||||
const (
|
||||
priorityLow = 1
|
||||
priorityNormal = 2
|
||||
priorityHigh = 3
|
||||
priorityUrgent = 4
|
||||
)
|
||||
```
|
||||
|
||||
**优先级常量**:定义了 Issue 优先级对应的数字 ID
|
||||
|
||||
```go
|
||||
const (
|
||||
statusNew = 1
|
||||
statusInProgress = 2
|
||||
statusResolved = 3
|
||||
statusClosed = 5
|
||||
statusRejected = 6
|
||||
)
|
||||
```
|
||||
|
||||
**状态常量**:定义了 Issue 状态对应的数字 ID
|
||||
|
||||
```go
|
||||
const (
|
||||
trackerBug = 1
|
||||
trackerFeature = 2
|
||||
trackerSupport = 3
|
||||
trackerDoc = 4
|
||||
trackerTest = 5
|
||||
trackerDuplicate = 6
|
||||
trackerQuestion = 7
|
||||
)
|
||||
```
|
||||
|
||||
**类型常量**:定义了 Issue 类型对应的数字 ID
|
||||
|
||||
---
|
||||
|
||||
## 三、名称映射表
|
||||
|
||||
```go
|
||||
var priorityNames = map[int]string{
|
||||
priorityLow: "low",
|
||||
priorityNormal: "normal",
|
||||
priorityHigh: "high",
|
||||
priorityUrgent: "urgent",
|
||||
}
|
||||
|
||||
var statusNames = map[int]string{
|
||||
statusNew: "new",
|
||||
statusInProgress: "in-progress",
|
||||
statusResolved: "resolved",
|
||||
statusClosed: "closed",
|
||||
statusRejected: "rejected",
|
||||
}
|
||||
|
||||
var trackerNames = map[int]string{
|
||||
trackerBug: "bug",
|
||||
trackerFeature: "feature",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:把数字 ID 转换成可读的英文名称,方便输出结果
|
||||
|
||||
---
|
||||
|
||||
## 四、标签 ID 映射
|
||||
|
||||
```go
|
||||
var tagIDs = map[string]int{
|
||||
"缺陷": 315526,
|
||||
"功能": 315527,
|
||||
"文档": 315533,
|
||||
"重复": 315525,
|
||||
"疑问": 315528,
|
||||
"支持": 315529,
|
||||
"任务": 315530,
|
||||
"测试": 315534,
|
||||
"协助": 315531,
|
||||
"搁置": 315532,
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:中文标签名称到 GitLink 标签 ID 的映射
|
||||
|
||||
**注意**:这些 ID 是从网页端 DevTools 抓包获取的,不同项目可能不同
|
||||
|
||||
---
|
||||
|
||||
## 五、结果结构体
|
||||
|
||||
```go
|
||||
type BatchResult struct {
|
||||
Number string `json:"number" yaml:"number"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Status string `json:"status" yaml:"status"`
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**单个操作结果**:记录单个 Issue 的操作结果
|
||||
|
||||
```go
|
||||
type BatchSummary struct {
|
||||
Repository string `json:"repository" yaml:"repository"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Value string `json:"value,omitempty" yaml:"value,omitempty"`
|
||||
DryRun bool `json:"dry_run" yaml:"dry_run"`
|
||||
Total int `json:"total" yaml:"total"`
|
||||
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
||||
Failed int `json:"failed" yaml:"failed"`
|
||||
Results []BatchResult `json:"results" yaml:"results"`
|
||||
}
|
||||
```
|
||||
|
||||
**批量操作汇总**:记录整个批量操作的统计信息
|
||||
|
||||
---
|
||||
|
||||
## 六、批量操作命令
|
||||
|
||||
### 6.1 batch-close 命令
|
||||
|
||||
```go
|
||||
func newBatchCloseShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-close",
|
||||
Description: "Close multiple issues by issue numbers or a CSV file",
|
||||
Flags: []common.Flag{
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers"},
|
||||
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
||||
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchClose,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**执行函数**:
|
||||
|
||||
```go
|
||||
func runBatchClose(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("no issue numbers provided")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := BatchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
Action: "close",
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]BatchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := BatchResult{Number: number, Action: "close"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusClosed}); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "closed"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**执行流程**:
|
||||
1. 解析仓库信息
|
||||
2. 收集 Issue 编号(从 `--numbers` 参数或 CSV 文件)
|
||||
3. 初始化 `BatchSummary` 汇总对象
|
||||
4. 遍历每个 Issue 编号:
|
||||
- 如果是 dry-run,直接标记为 planned
|
||||
- 否则调用 `updateIssueField` 更新状态为 closed
|
||||
5. 输出汇总结果
|
||||
|
||||
---
|
||||
|
||||
### 6.2 batch-status 命令
|
||||
|
||||
```go
|
||||
func runBatchStatus(ctx *common.RuntimeContext) error {
|
||||
// ... 解析参数
|
||||
state := ctx.Arg("state")
|
||||
statusID, err := parseStatus(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// ... 遍历更新
|
||||
updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID})
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:批量修改 Issue 状态
|
||||
|
||||
**参数**:`--state` 指定目标状态(new/in-progress/resolved/closed/rejected)
|
||||
|
||||
---
|
||||
|
||||
### 6.3 batch-priority 命令
|
||||
|
||||
```go
|
||||
func runBatchPriority(ctx *common.RuntimeContext) error {
|
||||
// ...
|
||||
priority := ctx.Arg("priority")
|
||||
priorityID, err := parsePriority(priority)
|
||||
// ...
|
||||
updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID})
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:批量修改 Issue 优先级
|
||||
|
||||
**参数**:`--priority` 指定目标优先级(low/normal/high/urgent)
|
||||
|
||||
---
|
||||
|
||||
### 6.4 batch-assign 命令
|
||||
|
||||
```go
|
||||
func runBatchAssign(ctx *common.RuntimeContext) error {
|
||||
// ...
|
||||
assignee := ctx.Arg("assignee")
|
||||
var assigneeID interface{}
|
||||
if !dryRun {
|
||||
id, err := resolveUserID(ctx, assignee)
|
||||
assigneeID = id
|
||||
}
|
||||
// ...
|
||||
updateIssueField(ctx, number, map[string]interface{}{"assigned_to_id": assigneeID})
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:批量分配 Issue 给指定用户
|
||||
|
||||
**亮点**:需要先把用户名转换成用户 ID
|
||||
|
||||
---
|
||||
|
||||
### 6.5 batch-label 命令
|
||||
|
||||
```go
|
||||
func runBatchLabel(ctx *common.RuntimeContext) error {
|
||||
// ...
|
||||
label := ctx.Arg("label")
|
||||
trackerID, err := parseTracker(label)
|
||||
// ...
|
||||
updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{trackerID}})
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:批量修改 Issue 的标签
|
||||
|
||||
**参数**:`--label` 可以是英文(bug/feature)或中文(缺陷/功能)
|
||||
|
||||
---
|
||||
|
||||
## 七、核心辅助函数
|
||||
|
||||
### 7.1 updateIssueField
|
||||
|
||||
```go
|
||||
func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error {
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch issue #%s: %w", number, err)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
}
|
||||
for k, v := range fields {
|
||||
body[k] = v
|
||||
}
|
||||
|
||||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||||
return fmt.Errorf("update issue #%s: %w", number, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:更新 Issue 的指定字段
|
||||
|
||||
**关键点**:
|
||||
1. 先调用 `fetchExistingIssue` 获取当前 Issue 的标题和描述
|
||||
2. 必须在请求体中包含 `subject` 和 `description`,否则会被清空
|
||||
3. 把要更新的字段合并到 body 中
|
||||
4. 发送 PATCH 请求
|
||||
|
||||
---
|
||||
|
||||
### 7.2 resolveUserID
|
||||
|
||||
```go
|
||||
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
|
||||
if id, err := strconv.Atoi(login); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup user %q: %w", login, err)
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response for user %q", login)
|
||||
}
|
||||
idFloat, ok := data["id"].(float64)
|
||||
if ok {
|
||||
return int(idFloat), nil
|
||||
}
|
||||
userIDFloat, ok := data["user_id"].(float64)
|
||||
if ok {
|
||||
return int(userIDFloat), nil
|
||||
}
|
||||
return nil, fmt.Errorf("cannot determine user ID for %q", login)
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:把用户名转换成用户 ID
|
||||
|
||||
**工作原理**:
|
||||
1. 如果输入已经是数字,直接返回
|
||||
2. 否则调用 `/users/{login}` API 获取用户信息
|
||||
3. 从响应中提取 `id` 或 `user_id` 字段
|
||||
4. API 返回的数字是 float64 类型,需要转换成 int
|
||||
|
||||
---
|
||||
|
||||
### 7.3 collectIssueNumbers
|
||||
|
||||
```go
|
||||
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||
numbers, err := parseIssueNumbers(numbersValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if csvPath == "" {
|
||||
return numbers, nil
|
||||
}
|
||||
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mergeIssueNumbers(numbers, csvNumbers), nil
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:从 `--numbers` 参数和 CSV 文件中收集 Issue 编号
|
||||
|
||||
---
|
||||
|
||||
### 7.4 readIssueNumbersFromCSV
|
||||
|
||||
```go
|
||||
func readIssueNumbersFromCSV(path string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CSV: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CSV: %w", err)
|
||||
}
|
||||
|
||||
numberColumn := -1
|
||||
startRow := 0
|
||||
for i, cell := range records[0] {
|
||||
switch strings.ToLower(strings.TrimSpace(cell)) {
|
||||
case "number", "issue_number", "project_issues_index":
|
||||
numberColumn = i
|
||||
startRow = 1
|
||||
}
|
||||
}
|
||||
if numberColumn == -1 {
|
||||
numberColumn = 0
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(records)-startRow)
|
||||
for _, record := range records[startRow:] {
|
||||
if numberColumn >= len(record) {
|
||||
continue
|
||||
}
|
||||
values = append(values, record[numberColumn])
|
||||
}
|
||||
return normalizeIssueNumbers(values)
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:从 CSV 文件读取 Issue 编号
|
||||
|
||||
**智能表头识别**:
|
||||
- 自动识别 `number`、`issue_number`、`project_issues_index` 列
|
||||
- 如果没有匹配的表头,默认使用第一列
|
||||
- 跳过表头行,从第二行开始读取
|
||||
|
||||
---
|
||||
|
||||
## 八、类型转换函数
|
||||
|
||||
```go
|
||||
func parseStatus(state string) (int, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "new":
|
||||
return statusNew, nil
|
||||
case "in-progress", "in_progress", "inprogress":
|
||||
return statusInProgress, nil
|
||||
// ...
|
||||
default:
|
||||
if id, err := strconv.Atoi(state); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid state %q", state)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:把用户输入的状态字符串转换成数字 ID
|
||||
|
||||
**容错处理**:
|
||||
- 支持多种写法:`in-progress`、`in_progress`、`inprogress`
|
||||
- 如果输入是数字,直接返回
|
||||
|
||||
`parsePriority` 和 `parseTracker` 函数类似
|
||||
|
||||
---
|
||||
|
||||
## 九、Go 语言知识点
|
||||
|
||||
### 1. const 常量定义
|
||||
|
||||
```go
|
||||
const (
|
||||
priorityLow = 1
|
||||
priorityNormal = 2
|
||||
)
|
||||
```
|
||||
|
||||
在 `const` 块中,后续常量会继承前一个常量的值并自动加1
|
||||
|
||||
### 2. defer 语句
|
||||
|
||||
```go
|
||||
file, err := os.Open(path)
|
||||
defer file.Close()
|
||||
```
|
||||
|
||||
`defer` 会在函数返回前执行,确保文件被关闭
|
||||
|
||||
### 3. map 遍历
|
||||
|
||||
```go
|
||||
for k, v := range fields {
|
||||
body[k] = v
|
||||
}
|
||||
```
|
||||
|
||||
遍历 map 的键值对
|
||||
|
||||
### 4. type assertion(类型断言)
|
||||
|
||||
```go
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response")
|
||||
}
|
||||
```
|
||||
|
||||
把接口类型转换成具体类型,`ok` 表示转换是否成功
|
||||
|
||||
### 5. strconv.Atoi
|
||||
|
||||
```go
|
||||
id, err := strconv.Atoi(login)
|
||||
```
|
||||
|
||||
把字符串转换成整数,如果失败返回错误
|
||||
|
|
@ -0,0 +1,673 @@
|
|||
# 逐行讲解 shortcuts/issue/batch_create.go(面向 Go 小白)
|
||||
|
||||
## 文件概述
|
||||
|
||||
这个文件实现了 **Issue 批量创建**功能,可以从命令行或 CSV 文件批量创建多个 Issue,并支持 bug 和 feature 两种模板。
|
||||
|
||||
---
|
||||
|
||||
## 一、包声明和导入
|
||||
|
||||
```go
|
||||
package issue
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
```
|
||||
|
||||
| 导入库 | 作用 |
|
||||
|-------|------|
|
||||
| `encoding/csv` | CSV 文件解析 |
|
||||
| `fmt` | 格式化输出 |
|
||||
| `net/url` | URL 查询参数构建 |
|
||||
| `os` | 文件操作 |
|
||||
| `strconv` | 字符串和数字转换 |
|
||||
| `strings` | 字符串处理 |
|
||||
| `sync` | 并发安全,用于缓存 |
|
||||
| `common` | 公共工具包 |
|
||||
|
||||
---
|
||||
|
||||
## 二、标签缓存
|
||||
|
||||
```go
|
||||
var issueTagCache sync.Map
|
||||
```
|
||||
|
||||
**作用**:缓存项目的标签列表,避免重复请求 API
|
||||
|
||||
**sync.Map**:Go 语言提供的并发安全的 map,可以在多个 goroutine 中安全地读写
|
||||
|
||||
---
|
||||
|
||||
## 三、resolveIssueTags 函数
|
||||
|
||||
```go
|
||||
func resolveIssueTags(ctx *common.RuntimeContext) (map[string]int, error) {
|
||||
key := ctx.Owner + "/" + ctx.Repo
|
||||
if cached, ok := issueTagCache.Load(key); ok {
|
||||
return cached.(map[string]int), nil
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo)
|
||||
q := url.Values{}
|
||||
q.Set("only_name", "true")
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取项目标签列表失败: %w", err)
|
||||
}
|
||||
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("标签列表响应格式异常")
|
||||
}
|
||||
|
||||
rawTags, ok := data["issue_tags"].([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("标签列表响应缺少 issue_tags 字段")
|
||||
}
|
||||
|
||||
tags := make(map[string]int, len(rawTags))
|
||||
for _, item := range rawTags {
|
||||
tag, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, _ := tag["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var id int
|
||||
switch v := tag["id"].(type) {
|
||||
case float64:
|
||||
id = int(v)
|
||||
case int:
|
||||
id = v
|
||||
default:
|
||||
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
|
||||
}
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
tags[name] = id
|
||||
}
|
||||
|
||||
if len(tags) == 0 {
|
||||
return nil, fmt.Errorf("项目没有配置任何标签,请先在 GitLink 网页端创建标签")
|
||||
}
|
||||
|
||||
issueTagCache.Store(key, tags)
|
||||
return tags, nil
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:获取项目的 Issue 标签列表,并缓存结果
|
||||
|
||||
**执行流程**:
|
||||
1. 构建缓存 key(owner/repo)
|
||||
2. 先从缓存中查找,如果有就直接返回
|
||||
3. 如果缓存中没有,调用 API 获取标签列表
|
||||
4. 解析 API 响应,提取标签名称和 ID
|
||||
5. 处理多种 ID 类型(float64、int、其他)
|
||||
6. 把结果存入缓存
|
||||
7. 返回标签映射
|
||||
|
||||
---
|
||||
|
||||
## 四、命令定义
|
||||
|
||||
```go
|
||||
func newBatchCreateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-create",
|
||||
Description: "Create multiple issues from CLI flags or a CSV file",
|
||||
Flags: []common.Flag{
|
||||
{Name: "titles", Usage: "Comma-separated issue titles"},
|
||||
{Name: "priority", Short: "p", Usage: "Priority: low, normal, high, urgent"},
|
||||
{Name: "label", Short: "l", Usage: "Label name"},
|
||||
{Name: "assignee", Short: "a", Usage: "Assignee login name"},
|
||||
{Name: "state", Short: "s", Usage: "Initial state", Default: "new"},
|
||||
{Name: "from", Usage: "CSV file path"},
|
||||
{Name: "template", Short: "t", Usage: "Template: bug or feature"},
|
||||
{Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchCreate,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Flags 参数说明**:
|
||||
- `--titles`:逗号分隔的 Issue 标题
|
||||
- `--priority/-p`:优先级
|
||||
- `--label/-l`:标签
|
||||
- `--assignee/-a`:分配人
|
||||
- `--state/-s`:初始状态
|
||||
- `--from`:CSV 文件路径
|
||||
- `--template/-t`:模板类型(bug/feature)
|
||||
- `--dry-run`:预览模式
|
||||
|
||||
---
|
||||
|
||||
## 五、输入结构体
|
||||
|
||||
```go
|
||||
type createIssueInput struct {
|
||||
Title string
|
||||
Body string
|
||||
Priority string
|
||||
Label string
|
||||
Assignee string
|
||||
Status string
|
||||
// template-specific fields
|
||||
Version string
|
||||
Severity string
|
||||
Steps string
|
||||
Expected string
|
||||
Actual string
|
||||
UserStory string
|
||||
Acceptance string
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:存储创建 Issue 的所有输入参数
|
||||
|
||||
**模板专用字段**:
|
||||
- `Version`、`Severity`、`Steps`、`Expected`、`Actual`:用于 bug 模板
|
||||
- `UserStory`、`Acceptance`:用于 feature 模板
|
||||
|
||||
---
|
||||
|
||||
## 六、runBatchCreate 主函数
|
||||
|
||||
```go
|
||||
func runBatchCreate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tags, err := resolveIssueTags(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
template := strings.ToLower(strings.TrimSpace(ctx.Arg("template")))
|
||||
|
||||
var inputs []createIssueInput
|
||||
if titlesStr := ctx.Arg("titles"); titlesStr != "" {
|
||||
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
|
||||
}
|
||||
if csvPath := ctx.Arg("from"); csvPath != "" {
|
||||
csvInputs, err := readCreateInputsFromCSV(csvPath, template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inputs = append(inputs, csvInputs...)
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
return fmt.Errorf("no issue titles provided")
|
||||
}
|
||||
|
||||
cliState := ctx.Arg("state")
|
||||
for i := range inputs {
|
||||
if inputs[i].Status == "" {
|
||||
inputs[i].Status = cliState
|
||||
}
|
||||
}
|
||||
|
||||
summary := BatchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
Action: "create",
|
||||
Value: template,
|
||||
DryRun: dryRun,
|
||||
Total: len(inputs),
|
||||
Results: make([]BatchResult, 0, len(inputs)),
|
||||
}
|
||||
|
||||
for i, input := range inputs {
|
||||
label := fmt.Sprintf("#%d", i+1)
|
||||
if input.Title != "" {
|
||||
label = truncate(input.Title, 40)
|
||||
}
|
||||
result := BatchResult{Number: label, Action: "create"}
|
||||
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
body := buildCreateBody(ctx, input, template, tags)
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||||
if err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "created"
|
||||
if data, ok := env.Data.(map[string]interface{}); ok {
|
||||
if num, ok := data["project_issues_index"]; ok {
|
||||
result.Number = fmt.Sprintf("%v", num)
|
||||
}
|
||||
}
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d issue(s) failed to create", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**执行流程**:
|
||||
1. 解析仓库信息
|
||||
2. 获取项目标签列表
|
||||
3. 收集输入(从 `--titles` 和/或 `--from`)
|
||||
4. 为没有指定状态的输入应用默认状态
|
||||
5. 遍历创建每个 Issue:
|
||||
- 如果是 dry-run,标记为 planned
|
||||
- 否则构建请求体并调用 API
|
||||
- 从响应中提取新创建的 Issue 编号
|
||||
6. 输出汇总结果
|
||||
|
||||
---
|
||||
|
||||
## 七、buildCreateBody 函数
|
||||
|
||||
```go
|
||||
func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, template string, tags map[string]int) map[string]interface{} {
|
||||
statusID := statusNew
|
||||
if input.Status != "" {
|
||||
if sid, err := parseStatus(input.Status); err == nil {
|
||||
statusID = sid
|
||||
}
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"subject": input.Title,
|
||||
"status_id": statusID,
|
||||
"priority_id": priorityNormal,
|
||||
"done_ratio": 0,
|
||||
}
|
||||
|
||||
if template != "" {
|
||||
body["description"] = buildTemplateDescription(input, template)
|
||||
if template == "bug" {
|
||||
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
|
||||
} else if template == "feature" {
|
||||
body["issue_tag_ids"] = []interface{}{tags["功能"]}
|
||||
}
|
||||
} else if input.Body != "" {
|
||||
body["description"] = input.Body
|
||||
}
|
||||
|
||||
if input.Priority != "" {
|
||||
if pid, err := parsePriority(input.Priority); err == nil {
|
||||
body["priority_id"] = pid
|
||||
}
|
||||
}
|
||||
if input.Label != "" {
|
||||
if tid, err := parseLabel(input.Label, tags); err == nil {
|
||||
body["issue_tag_ids"] = []interface{}{tid}
|
||||
}
|
||||
}
|
||||
if input.Assignee != "" {
|
||||
if id, err := resolveUserID(ctx, input.Assignee); err == nil {
|
||||
body["assigner_ids"] = []interface{}{id}
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:构建创建 Issue 的请求体
|
||||
|
||||
**逻辑**:
|
||||
1. 设置默认值(状态、优先级、完成比例)
|
||||
2. 如果指定了模板,构建模板描述并设置对应的标签
|
||||
3. 否则使用自定义描述
|
||||
4. 应用优先级、标签、分配人等可选参数
|
||||
|
||||
---
|
||||
|
||||
## 八、模板描述构建
|
||||
|
||||
### 8.1 buildTemplateDescription
|
||||
|
||||
```go
|
||||
func buildTemplateDescription(input createIssueInput, template string) string {
|
||||
switch template {
|
||||
case "bug":
|
||||
return buildBugDescription(input)
|
||||
case "feature":
|
||||
return buildFeatureDescription(input)
|
||||
default:
|
||||
return input.Body
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 buildBugDescription
|
||||
|
||||
```go
|
||||
func buildBugDescription(input createIssueInput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## Bug 描述\n")
|
||||
b.WriteString(input.Title)
|
||||
b.WriteString("\n")
|
||||
|
||||
if input.Version != "" {
|
||||
b.WriteString("\n## 版本\n")
|
||||
b.WriteString(input.Version)
|
||||
}
|
||||
if input.Severity != "" {
|
||||
b.WriteString("\n## 严重程度\n")
|
||||
b.WriteString(input.Severity)
|
||||
}
|
||||
if input.Steps != "" {
|
||||
b.WriteString("\n## 复现步骤\n")
|
||||
b.WriteString(input.Steps)
|
||||
}
|
||||
if input.Expected != "" {
|
||||
b.WriteString("\n## 期望结果\n")
|
||||
b.WriteString(input.Expected)
|
||||
}
|
||||
if input.Actual != "" {
|
||||
b.WriteString("\n## 实际结果\n")
|
||||
b.WriteString(input.Actual)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:构建标准化的 Bug 描述
|
||||
|
||||
**输出格式**:
|
||||
```markdown
|
||||
## Bug 描述
|
||||
标题内容
|
||||
|
||||
## 版本
|
||||
v1.0.0
|
||||
|
||||
## 严重程度
|
||||
高
|
||||
|
||||
## 复现步骤
|
||||
步骤1
|
||||
步骤2
|
||||
|
||||
## 期望结果
|
||||
期望的行为
|
||||
|
||||
## 实际结果
|
||||
实际的行为
|
||||
```
|
||||
|
||||
### 8.3 buildFeatureDescription
|
||||
|
||||
```go
|
||||
func buildFeatureDescription(input createIssueInput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 用户故事\n")
|
||||
if input.UserStory != "" {
|
||||
b.WriteString(input.UserStory)
|
||||
} else {
|
||||
b.WriteString(input.Title)
|
||||
}
|
||||
|
||||
if input.Body != "" {
|
||||
b.WriteString("\n## 描述\n")
|
||||
b.WriteString(input.Body)
|
||||
}
|
||||
if input.Acceptance != "" {
|
||||
b.WriteString("\n## 验收标准\n")
|
||||
b.WriteString(input.Acceptance)
|
||||
}
|
||||
if input.Priority != "" {
|
||||
b.WriteString("\n## 优先级\n")
|
||||
b.WriteString(input.Priority)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:构建标准化的 Feature 描述
|
||||
|
||||
**输出格式**:
|
||||
```markdown
|
||||
## 用户故事
|
||||
作为用户,我想...
|
||||
|
||||
## 描述
|
||||
详细描述
|
||||
|
||||
## 验收标准
|
||||
- 标准1
|
||||
- 标准2
|
||||
|
||||
## 优先级
|
||||
high
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、CSV 读取
|
||||
|
||||
```go
|
||||
func readCreateInputsFromCSV(path string, template string) ([]createIssueInput, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CSV: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CSV: %w", err)
|
||||
}
|
||||
if len(records) < 2 {
|
||||
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
|
||||
}
|
||||
|
||||
header := records[0]
|
||||
col := make(map[string]int)
|
||||
for i, h := range header {
|
||||
col[normalizeHeader(h)] = i
|
||||
}
|
||||
|
||||
if _, ok := col["title"]; !ok {
|
||||
return nil, fmt.Errorf("CSV must have a 'title' column")
|
||||
}
|
||||
|
||||
var inputs []createIssueInput
|
||||
for _, record := range records[1:] {
|
||||
input := createIssueInput{
|
||||
Title: getCol(record, col, "title"),
|
||||
Body: getCol(record, col, "body"),
|
||||
Priority: getCol(record, col, "priority"),
|
||||
Label: getCol(record, col, "label"),
|
||||
Assignee: getCol(record, col, "assignee"),
|
||||
Status: getCol(record, col, "status"),
|
||||
Version: getCol(record, col, "version"),
|
||||
Severity: getCol(record, col, "severity"),
|
||||
Steps: getCol(record, col, "steps"),
|
||||
Expected: getCol(record, col, "expected"),
|
||||
Actual: getCol(record, col, "actual"),
|
||||
UserStory: getCol(record, col, "user_story"),
|
||||
Acceptance: getCol(record, col, "acceptance"),
|
||||
}
|
||||
if input.UserStory == "" {
|
||||
input.UserStory = getCol(record, col, "user story")
|
||||
}
|
||||
if input.Title == "" {
|
||||
continue
|
||||
}
|
||||
inputs = append(inputs, input)
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
```
|
||||
|
||||
**CSV 列支持**:
|
||||
- `title`(必填):Issue 标题
|
||||
- `body`:描述内容
|
||||
- `priority`:优先级
|
||||
- `label`:标签
|
||||
- `assignee`:分配人
|
||||
- `status`:状态
|
||||
- `version`:版本(bug 模板)
|
||||
- `severity`:严重程度(bug 模板)
|
||||
- `steps`:复现步骤(bug 模板)
|
||||
- `expected`:期望结果(bug 模板)
|
||||
- `actual`:实际结果(bug 模板)
|
||||
- `user_story` / `user story`:用户故事(feature 模板)
|
||||
- `acceptance`:验收标准(feature 模板)
|
||||
|
||||
---
|
||||
|
||||
## 十、辅助函数
|
||||
|
||||
### 10.1 parseTitles
|
||||
|
||||
```go
|
||||
func parseTitles(titlesStr string, ctx *common.RuntimeContext) []createIssueInput {
|
||||
parts := strings.Split(titlesStr, ",")
|
||||
inputs := make([]createIssueInput, 0, len(parts))
|
||||
for _, title := range parts {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
inputs = append(inputs, createIssueInput{
|
||||
Title: title,
|
||||
Priority: ctx.Arg("priority"),
|
||||
Label: ctx.Arg("label"),
|
||||
Assignee: ctx.Arg("assignee"),
|
||||
Status: ctx.Arg("state"),
|
||||
})
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:从逗号分隔的标题字符串创建输入对象
|
||||
|
||||
### 10.2 normalizeHeader
|
||||
|
||||
```go
|
||||
func normalizeHeader(h string) string {
|
||||
return strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:标准化 CSV 表头(转小写、去空格)
|
||||
|
||||
### 10.3 getCol
|
||||
|
||||
```go
|
||||
func getCol(record []string, col map[string]int, name string) string {
|
||||
if idx, ok := col[name]; ok && idx < len(record) {
|
||||
return strings.TrimSpace(record[idx])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:从 CSV 记录中获取指定列的值
|
||||
|
||||
### 10.4 truncate
|
||||
|
||||
```go
|
||||
func truncate(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
return string(runes[:n]) + "..."
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:截断字符串到指定长度,超出部分用 `...` 表示
|
||||
|
||||
**注意**:使用 `[]rune` 处理,可以正确处理中文等多字节字符
|
||||
|
||||
---
|
||||
|
||||
## 十一、Go 语言知识点
|
||||
|
||||
### 1. sync.Map
|
||||
|
||||
```go
|
||||
var issueTagCache sync.Map
|
||||
|
||||
// 读取
|
||||
if cached, ok := issueTagCache.Load(key); ok {
|
||||
return cached.(map[string]int), nil
|
||||
}
|
||||
|
||||
// 写入
|
||||
issueTagCache.Store(key, tags)
|
||||
```
|
||||
|
||||
**作用**:并发安全的 map,用于多个 goroutine 同时读写
|
||||
|
||||
### 2. strings.Builder
|
||||
|
||||
```go
|
||||
var b strings.Builder
|
||||
b.WriteString("## Bug 描述\n")
|
||||
b.WriteString(input.Title)
|
||||
return b.String()
|
||||
```
|
||||
|
||||
**作用**:高效拼接字符串,避免产生大量临时字符串
|
||||
|
||||
### 3. []interface{}
|
||||
|
||||
```go
|
||||
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
|
||||
```
|
||||
|
||||
**作用**:创建一个包含任意类型的数组,用于 JSON 序列化
|
||||
|
||||
### 4. switch 类型断言
|
||||
|
||||
```go
|
||||
switch v := tag["id"].(type) {
|
||||
case float64:
|
||||
id = int(v)
|
||||
case int:
|
||||
id = v
|
||||
default:
|
||||
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:根据值的实际类型执行不同的处理逻辑
|
||||
|
||||
### 5. 可变参数
|
||||
|
||||
```go
|
||||
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
|
||||
```
|
||||
|
||||
`...` 表示把切片展开成多个参数
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
# 逐行讲解 shortcuts/repo/batch_create.go(面向 Go 小白)
|
||||
|
||||
## 文件概述
|
||||
|
||||
这个文件实现了 **仓库批量创建**功能,可以从命令行或 CSV 文件批量创建多个 GitLink 仓库。
|
||||
|
||||
---
|
||||
|
||||
## 一、包声明和导入
|
||||
|
||||
```go
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
```
|
||||
|
||||
| 导入库 | 作用 |
|
||||
|-------|------|
|
||||
| `encoding/csv` | CSV 文件解析 |
|
||||
| `fmt` | 格式化输出 |
|
||||
| `os` | 文件操作 |
|
||||
| `strings` | 字符串处理 |
|
||||
| `common` | 公共工具包 |
|
||||
|
||||
---
|
||||
|
||||
## 二、结构体定义
|
||||
|
||||
### 2.1 repoCreateInput
|
||||
|
||||
```go
|
||||
type repoCreateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
Private bool
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:存储创建单个仓库的输入参数
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| `Name` | string | 仓库名称 |
|
||||
| `Description` | string | 仓库描述 |
|
||||
| `Private` | bool | 是否私有仓库 |
|
||||
|
||||
### 2.2 repoBatchResult
|
||||
|
||||
```go
|
||||
type repoBatchResult struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Status string `json:"status" yaml:"status"`
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:存储单个仓库创建的结果
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| `Name` | string | 仓库名称 |
|
||||
| `Status` | string | 创建状态(planned/created/failed) |
|
||||
| `Error` | string | 错误信息(如果失败) |
|
||||
|
||||
### 2.3 repoBatchSummary
|
||||
|
||||
```go
|
||||
type repoBatchSummary struct {
|
||||
Owner string `json:"owner" yaml:"owner"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
DryRun bool `json:"dry_run" yaml:"dry_run"`
|
||||
Total int `json:"total" yaml:"total"`
|
||||
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
||||
Failed int `json:"failed" yaml:"failed"`
|
||||
Results []repoBatchResult `json:"results" yaml:"results"`
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:存储批量创建的汇总结果
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| `Owner` | string | 仓库所有者(用户名) |
|
||||
| `Action` | string | 操作类型(create) |
|
||||
| `DryRun` | bool | 是否是预览模式 |
|
||||
| `Total` | int | 总数量 |
|
||||
| `Succeeded` | int | 成功数量 |
|
||||
| `Failed` | int | 失败数量 |
|
||||
| `Results` | []repoBatchResult | 每个仓库的详细结果 |
|
||||
|
||||
---
|
||||
|
||||
## 三、命令定义
|
||||
|
||||
```go
|
||||
func newBatchCreateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-create",
|
||||
Description: "Create multiple repositories from CLI flags or a CSV file",
|
||||
Flags: []common.Flag{
|
||||
{Name: "names", Short: "n", Usage: "Comma-separated repository names"},
|
||||
{Name: "from", Usage: "CSV file path"},
|
||||
{Name: "description", Short: "d", Usage: "Shared description for all repos"},
|
||||
{Name: "private", Usage: "Make repos private", Bool: true, Default: "false"},
|
||||
{Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchCreate,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Flags 参数说明**:
|
||||
- `--names/-n`:逗号分隔的仓库名称
|
||||
- `--from`:CSV 文件路径
|
||||
- `--description/-d`:所有仓库共享的描述
|
||||
- `--private`:创建私有仓库
|
||||
- `--dry-run`:预览模式
|
||||
|
||||
---
|
||||
|
||||
## 四、runBatchCreate 主函数
|
||||
|
||||
```go
|
||||
func runBatchCreate(ctx *common.RuntimeContext) error {
|
||||
var inputs []repoCreateInput
|
||||
|
||||
if namesStr := ctx.Arg("names"); namesStr != "" {
|
||||
for _, name := range strings.Split(namesStr, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
inputs = append(inputs, repoCreateInput{
|
||||
Name: name,
|
||||
Description: ctx.Arg("description"),
|
||||
Private: ctx.Arg("private") == "true",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if csvPath := ctx.Arg("from"); csvPath != "" {
|
||||
csvInputs, err := readRepoInputsFromCSV(csvPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inputs = append(inputs, csvInputs...)
|
||||
}
|
||||
|
||||
if len(inputs) == 0 {
|
||||
return fmt.Errorf("no repository names provided")
|
||||
}
|
||||
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
|
||||
var login string
|
||||
var userID int
|
||||
if !dryRun {
|
||||
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current user: %w", err)
|
||||
}
|
||||
userData, _ := userEnv.Data.(map[string]interface{})
|
||||
login, _ = userData["login"].(string)
|
||||
if login == "" {
|
||||
return fmt.Errorf("cannot determine current user login")
|
||||
}
|
||||
if uid, ok := userData["user_id"].(float64); ok {
|
||||
userID = int(uid)
|
||||
}
|
||||
}
|
||||
|
||||
summary := repoBatchSummary{
|
||||
Owner: login,
|
||||
Action: "create",
|
||||
DryRun: dryRun,
|
||||
Total: len(inputs),
|
||||
Results: make([]repoBatchResult, 0, len(inputs)),
|
||||
}
|
||||
|
||||
for _, input := range inputs {
|
||||
result := repoBatchResult{Name: input.Name}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": input.Name,
|
||||
"repository_name": input.Name,
|
||||
"user_id": userID,
|
||||
}
|
||||
if input.Description != "" {
|
||||
body["description"] = input.Description
|
||||
}
|
||||
if input.Private {
|
||||
body["private"] = true
|
||||
}
|
||||
|
||||
if _, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, input.Name), body); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "created"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d repo(s) failed to create", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**执行流程**:
|
||||
1. 收集输入(从 `--names` 和/或 `--from`)
|
||||
2. 如果不是 dry-run,调用 `/users/me` 获取当前用户信息
|
||||
3. 初始化汇总对象
|
||||
4. 遍历每个仓库:
|
||||
- 如果是 dry-run,标记为 planned
|
||||
- 否则构建请求体并调用 API
|
||||
- 记录结果
|
||||
5. 输出汇总结果
|
||||
|
||||
---
|
||||
|
||||
## 五、readRepoInputsFromCSV 函数
|
||||
|
||||
```go
|
||||
func readRepoInputsFromCSV(path string) ([]repoCreateInput, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CSV: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CSV: %w", err)
|
||||
}
|
||||
if len(records) < 2 {
|
||||
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
|
||||
}
|
||||
|
||||
header := records[0]
|
||||
col := make(map[string]int)
|
||||
for i, h := range header {
|
||||
col[strings.ToLower(strings.TrimSpace(h))] = i
|
||||
}
|
||||
if _, ok := col["name"]; !ok {
|
||||
return nil, fmt.Errorf("CSV must have a 'name' column")
|
||||
}
|
||||
|
||||
var inputs []repoCreateInput
|
||||
for _, record := range records[1:] {
|
||||
name := getCol(record, col, "name")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
private := false
|
||||
if p := strings.ToLower(getCol(record, col, "private")); p == "true" || p == "1" {
|
||||
private = true
|
||||
}
|
||||
inputs = append(inputs, repoCreateInput{
|
||||
Name: name,
|
||||
Description: getCol(record, col, "description"),
|
||||
Private: private,
|
||||
})
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
```
|
||||
|
||||
**CSV 列支持**:
|
||||
- `name`(必填):仓库名称
|
||||
- `description`:仓库描述
|
||||
- `private`:是否私有(true/false 或 1/0)
|
||||
|
||||
---
|
||||
|
||||
## 六、getCol 函数
|
||||
|
||||
```go
|
||||
func getCol(record []string, col map[string]int, name string) string {
|
||||
if idx, ok := col[name]; ok && idx < len(record) {
|
||||
return strings.TrimSpace(record[idx])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:从 CSV 记录中获取指定列的值
|
||||
|
||||
**逻辑**:
|
||||
1. 查找列名对应的索引
|
||||
2. 检查索引是否有效
|
||||
3. 返回该位置的值(去除前后空格)
|
||||
4. 如果找不到,返回空字符串
|
||||
|
||||
---
|
||||
|
||||
## 七、完整调用流程
|
||||
|
||||
```
|
||||
用户命令 (gitlink repo batch-create -n repo-a,repo-b)
|
||||
↓
|
||||
解析命令行参数
|
||||
↓
|
||||
newBatchCreateShortcut() 返回命令定义
|
||||
↓
|
||||
执行 runBatchCreate 函数
|
||||
↓
|
||||
收集输入(解析 --names 参数)
|
||||
↓
|
||||
调用 /users/me 获取当前用户信息
|
||||
↓
|
||||
遍历每个仓库名称:
|
||||
↓
|
||||
构建请求体(name, repository_name, user_id)
|
||||
↓
|
||||
调用 POST /{login}/{repo_name} 创建仓库
|
||||
↓
|
||||
记录创建结果
|
||||
↓
|
||||
输出汇总结果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、Go 语言知识点
|
||||
|
||||
### 1. 结构体标签(Struct Tags)
|
||||
|
||||
```go
|
||||
type repoBatchResult struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Status string `json:"status" yaml:"status"`
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:告诉序列化库(如 JSON、YAML)如何给字段命名
|
||||
|
||||
- `json:"name"`:JSON 序列化时使用 `name` 作为字段名
|
||||
- `json:"error,omitempty"`:如果 `Error` 为空,JSON 中不包含这个字段
|
||||
|
||||
### 2. 布尔值判断
|
||||
|
||||
```go
|
||||
dryRun := ctx.Arg("dry-run") == "true"
|
||||
```
|
||||
|
||||
**注意**:`ctx.Arg()` 返回的是字符串,需要和字符串 `"true"` 比较,不能直接用 `bool()` 转换
|
||||
|
||||
### 3. interface{} 类型断言
|
||||
|
||||
```go
|
||||
userData, _ := userEnv.Data.(map[string]interface{})
|
||||
login, _ = userData["login"].(string)
|
||||
```
|
||||
|
||||
**作用**:把 `interface{}` 类型转换成具体类型
|
||||
|
||||
### 4. float64 转 int
|
||||
|
||||
```go
|
||||
if uid, ok := userData["user_id"].(float64); ok {
|
||||
userID = int(uid)
|
||||
}
|
||||
```
|
||||
|
||||
**原因**:JSON 解析后,数字默认是 `float64` 类型,需要手动转换成 `int`
|
||||
|
||||
### 5. defer 语句
|
||||
|
||||
```go
|
||||
file, err := os.Open(path)
|
||||
defer file.Close()
|
||||
```
|
||||
|
||||
**作用**:确保文件在函数返回前被关闭,防止资源泄漏
|
||||
|
||||
### 6. make 和预分配容量
|
||||
|
||||
```go
|
||||
Results: make([]repoBatchResult, 0, len(inputs))
|
||||
```
|
||||
|
||||
**作用**:创建一个初始长度为 0、容量为 `len(inputs)` 的切片,避免动态扩容的性能开销
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
ctx 就是 *common.RuntimeContext。一句话:它是每个 Shortcut
|
||||
命令的"工具箱",所有能力(发API请求、读参数、输出结果)都挂在这个对象上。
|
||||
|
||||
---
|
||||
它长什么样(shortcuts/common/types.go:41-50)
|
||||
|
||||
type RuntimeContext struct {
|
||||
Client *client.Client // ← 发 HTTP 请求的客户端
|
||||
Owner string // ← --owner 的值(如 "zzx-coder")
|
||||
Repo string // ← --repo 的值(如 "gitlink-cli")
|
||||
Format string // ← --format 的值("json" / "table" / "yaml")
|
||||
CommandName string // ← 当前命令名(如 "wiki +delete")
|
||||
Args map[string]string // ← 所有 flag 的键值对(如 {"title":"Home","dry-run":"false"})
|
||||
GatewayBaseURL string // ← Wiki/Webhook网关地址(跟标准API不同)
|
||||
GatewayHTTPClient *http.Client // ← 网关专用 HTTP 客户端(nil 则自动创建)
|
||||
}
|
||||
|
||||
---
|
||||
它怎么创建出来的(types.go:53-80)
|
||||
你敲 gitlink-cli wiki +delete --title "Home" 时:
|
||||
|
||||
第1步:cobra 解析命令行 → flagValues = {"title": "Home"}
|
||||
第2步:runner.go:34 → NewRuntimeContext(flagValues, "wiki +delete")
|
||||
第3步:NewRuntimeContext 内部:
|
||||
→ client.New() // 读取配置文件,拿到 BaseURL + 带 auth 的 HTTP Client
|
||||
→ 读取全局 flag // cmdutil.Owner, cmdutil.Repo, cmdutil.Format
|
||||
→ 组装成 RuntimeContext // 把所有东西塞进去
|
||||
第4步:传给 s.Run(ctx) // 你的业务逻辑拿到这个 ctx
|
||||
|
||||
---
|
||||
它上面的方法(你可以直接用 ctx.XXX() 调用的)
|
||||
|
||||
┌──────────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────┐
|
||||
│ 方法 │ 做什么 │ 例 │
|
||||
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
|
||||
│ ctx.Arg("title") │ 读用户传入的 flag 值 │ "Home" │
|
||||
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
|
||||
│ ctx.RequireArg("title", "提示") │ 读必填参数,为空就报 CLIError │ 同上,但自动校验 │
|
||||
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
|
||||
│ ctx.CallAPI("GET", path, body) │ 调 GitLink 标准 API │ ctx.CallAPI("GET", "/users/me", nil) │
|
||||
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
|
||||
│ ctx.CallAPIWithQuery("GET", path, │ 带查询参数的 API │ ctx.CallAPIWithQuery("GET", │
|
||||
│ query) │ │ "/issues", q) │
|
||||
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
|
||||
│ ctx.Output(env) │ 输出 API 响应(自动选 │ ctx.Output(env) │
|
||||
│ │ json/table/yaml) │ │
|
||||
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
|
||||
│ ctx.OutputData(data) │ 包装数据成成功 envelope 再输出 │ ctx.OutputData(myStruct) │
|
||||
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
|
||||
│ ctx.ResolveOwnerRepo() │ 从 git remote 自动推断 owner/repo │ 没传 --owner 时自动填充 │
|
||||
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
|
||||
│ ctx.RepoPath() │ 返回 "/owner/repo" 字符串 │ "/zzx-coder/gitlink-cli" │
|
||||
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
|
||||
│ ctx.IsDryRun() │ 判断用户是否传了 --dry-run │ true / false │
|
||||
└──────────────────────────────────────┴───────────────────────────────────┴──────────────────────────────────────┘
|
||||
|
||||
---
|
||||
一条命令里 ctx 的完整生命周期
|
||||
|
||||
以 wiki +delete --title "Home" 为例:
|
||||
|
||||
1. runner.go:20-31 收集 flag
|
||||
flagValues = {"title": "Home"}
|
||||
|
||||
2. runner.go:34 创建 ctx
|
||||
ctx = NewRuntimeContext({"title":"Home"}, "wiki +delete")
|
||||
→ ctx.Client = 带认证的HTTP客户端(BaseURL = https://gitlink.org.cn/api)
|
||||
→ ctx.Owner = "zzx-coder"(从 git remote 或 --owner 来的)
|
||||
→ ctx.Repo = "gitlink-cli"
|
||||
→ ctx.Format = "table"(默认值)
|
||||
→ ctx.Args = {"title": "Home"}
|
||||
|
||||
3. runner.go:60 调用你的业务逻辑
|
||||
err = s.Run(ctx)
|
||||
|
||||
4. wiki.go:747 你的 Run 函数里用 ctx
|
||||
title, _ = ctx.RequireArg("title", ...) // → "Home"
|
||||
projectID, _ = resolveProjectID(ctx) // → ctx 传进子函数
|
||||
body = {..., "pageName": actualPageName}
|
||||
callWikiAPI(ctx, "DELETE", ..., body) // → ctx 用于构造网关 Client
|
||||
ctx.OutputData(result) // → ctx.Format 决定输出格式
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,787 @@
|
|||
# gitlink-cli 子任务一代码逻辑说明
|
||||
|
||||
## ? 目录
|
||||
|
||||
- [系统架构概述](#系统架构概述)
|
||||
- [Wiki 管理功能](#wiki-管理功能)
|
||||
- [Webhook 管理功能](#webhook-管理功能)
|
||||
- [批量操作功能](#批量操作功能)
|
||||
- [Raw API 功能](#raw-api-功能)
|
||||
- [命令优化功能](#命令优化功能)
|
||||
- [跨平台兼容性](#跨平台兼容性)
|
||||
|
||||
## ?? 系统架构概述
|
||||
|
||||
### 核心设计模式
|
||||
|
||||
**Shortcut 架构模式**:
|
||||
- 每个功能模块(wiki、webhook、issue等)实现一个 `Shortcuts()` 函数
|
||||
- 返回 `[]*common.Shortcut` 切片,每个 Shortcut 代表一个命令
|
||||
- 命令执行通过 `Run: func(ctx *common.RuntimeContext) error` 实现
|
||||
|
||||
**RuntimeContext 上下文**:
|
||||
```go
|
||||
type RuntimeContext struct {
|
||||
Client *client.Client // HTTP 客户端
|
||||
Owner string // 仓库所有者
|
||||
Repo string // 仓库名称
|
||||
Format string // 输出格式 (json/table/yaml)
|
||||
Args map[string]string // 命令行参数
|
||||
}
|
||||
```
|
||||
|
||||
**API 调用流程**:
|
||||
1. `ctx.ResolveOwnerRepo()` - 解析 owner/repo(支持 git remote 自动解析)
|
||||
2. `ctx.CallAPI()` - 发送 HTTP 请求到 GitLink API
|
||||
3. `ctx.Output()` - 格式化输出结果
|
||||
|
||||
---
|
||||
|
||||
## ? Wiki 管理功能
|
||||
|
||||
### 核心架构
|
||||
|
||||
**双重 API 调用机制**:
|
||||
- `BaseURL`: `https://www.gitlink.org.cn/api` - 主 API(获取项目信息)
|
||||
- `GatewayBaseURL`: `https://gateway.gitlink.org.cn/api` - Gateway API(Wiki 操作)
|
||||
|
||||
### 关键代码逻辑
|
||||
|
||||
#### 1. Project ID 解析机制 (`resolveProjectID`)
|
||||
|
||||
**问题**: Wiki API 需要 `projectId` 数字 ID,而用户只知道 `owner/repo`
|
||||
|
||||
**解决方案**:
|
||||
```go
|
||||
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
|
||||
key := ctx.Owner + "/" + ctx.Repo
|
||||
|
||||
// 1. 检查缓存 (sync.Map 实现线程安全)
|
||||
if cached, ok := projectIDCache.Load(key); ok {
|
||||
return cached.(string), nil
|
||||
}
|
||||
|
||||
// 2. 调用主 API 获取项目详情
|
||||
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPI("GET", path, nil)
|
||||
|
||||
// 3. 提取 project_id 并缓存
|
||||
projectIDCache.Store(key, pidStr)
|
||||
return pidStr, nil
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Base64 编解码处理
|
||||
|
||||
**Wiki 内容编码**:
|
||||
```go
|
||||
// 创建 Wiki 时编码
|
||||
body["content_base64"] = base64.StdEncoding.EncodeToString([]byte(content))
|
||||
|
||||
// 查看 Wiki 时解码
|
||||
if b64, ok := data["content_base64"].(string); ok {
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
data["content_decoded"] = string(decoded) // 额外提供解码后的内容
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Wiki 更新策略 (`update` 命令)
|
||||
|
||||
**三种更新模式**:
|
||||
```go
|
||||
if coverText != "" || filePath != "" && coverText == "" && addText == "" {
|
||||
// --cover 或 --file: 完全覆盖内容
|
||||
finalContent = content
|
||||
} else if addText != "" {
|
||||
// --add: 追加到现有内容
|
||||
existing, err := fetchPageContent(ctx, projectID, pageName)
|
||||
finalContent = existing + newPart
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 删除验证机制
|
||||
|
||||
**问题**: API 删除操作可能返回错误但实际删除成功
|
||||
|
||||
**解决方案**:
|
||||
```go
|
||||
delErr := callWikiAPI(ctx, "DELETE", wikiPath("deleteWiki"), body)
|
||||
if delErr != nil {
|
||||
// 验证是否真的删除成功
|
||||
_, viewErr := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
|
||||
if viewErr != nil {
|
||||
// GET 也失败,说明已删除成功
|
||||
return ctx.OutputData(map[string]string{"message": "Wiki page deleted successfully"})
|
||||
}
|
||||
return delErr // GET 成功,说明删除确实失败
|
||||
}
|
||||
```
|
||||
|
||||
### API 路径设计
|
||||
|
||||
| 命令 | HTTP 方法 | Gateway API 路径 |
|
||||
|------|----------|-----------------|
|
||||
| list | GET | `/wiki/open/wikiPages` |
|
||||
| view | GET | `/wiki/open/getWiki` |
|
||||
| create | POST | `/wiki/open/createWiki` |
|
||||
| update | PUT | `/wiki/open/updateWiki` |
|
||||
| delete | DELETE | `/wiki/open/deleteWiki` |
|
||||
|
||||
---
|
||||
|
||||
## ? Webhook 管理功能
|
||||
|
||||
### 核心设计
|
||||
|
||||
**统一的 API 路径前缀**:
|
||||
```go
|
||||
func webhookRepoPath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
```
|
||||
|
||||
### 关键代码逻辑
|
||||
|
||||
#### 1. 事件类型管理
|
||||
|
||||
**支持的事件列表**:
|
||||
```go
|
||||
var supportedEvents = []string{
|
||||
"push", "pull_request", "issue", "issue_assign", "issue_comment",
|
||||
"pull_request_assign", "pull_request_comment", "merge_request",
|
||||
"repository", "branch", "tag",
|
||||
}
|
||||
|
||||
// 事件解析和验证
|
||||
func parseEvents(eventsStr string) []string {
|
||||
events := strings.Split(eventsStr, ",")
|
||||
for _, event := range events {
|
||||
if isEventSupported(event) {
|
||||
validEvents = append(validEvents, event)
|
||||
}
|
||||
}
|
||||
return validEvents
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Webhook 创建逻辑
|
||||
|
||||
**完整的 Payload 构造**:
|
||||
```go
|
||||
payload := map[string]interface{}{
|
||||
"url": webhookURL, // 必需
|
||||
"http_method": "POST", // 固定
|
||||
"active": true, // 默认激活
|
||||
"content_type": "json", // 默认 JSON
|
||||
"events": validEvents, // 事件列表
|
||||
}
|
||||
|
||||
// 可选字段
|
||||
if secret := ctx.Arg("secret"); secret != "" {
|
||||
payload["secret"] = secret // HMAC 验证密钥
|
||||
}
|
||||
if description := ctx.Arg("description"); description != "" {
|
||||
payload["description"] = description
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 智能 URL 获取(Update 命令)
|
||||
|
||||
**问题**: 更新 Webhook 时用户不记得当前 URL
|
||||
|
||||
**解决方案**:
|
||||
```go
|
||||
webhookURL := ctx.Arg("url")
|
||||
if webhookURL == "" {
|
||||
// 自动获取当前 Webhook 的 URL
|
||||
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
|
||||
webhookData := getEnv.Data.(map[string]interface{})
|
||||
currentURL := webhookData["url"].(string)
|
||||
webhookURL = currentURL // 使用现有 URL
|
||||
}
|
||||
payload["url"] = webhookURL
|
||||
```
|
||||
|
||||
#### 4. 删除验证机制
|
||||
|
||||
```go
|
||||
delErr := ctx.CallAPI("DELETE", webhookPath, nil)
|
||||
if delErr != nil {
|
||||
// 验证是否真的删除成功
|
||||
_, viewErr := ctx.CallAPI("GET", webhookPath, nil)
|
||||
if viewErr != nil {
|
||||
// GET 返回错误,说明已删除
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "Webhook deleted successfully",
|
||||
}, nil))
|
||||
}
|
||||
return delErr
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Test 端点修复
|
||||
|
||||
**正确路径**: `/webhooks/{id}/tests` (复数)
|
||||
```go
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)
|
||||
```
|
||||
|
||||
### API 路径设计
|
||||
|
||||
| 命令 | HTTP 方法 | API 路径 |
|
||||
|------|----------|---------|
|
||||
| list | GET | `/v1/{owner}/{repo}/webhooks` |
|
||||
| create | POST | `/v1/{owner}/{repo}/webhooks` |
|
||||
| update | PUT | `/v1/{owner}/{repo}/webhooks/{id}` |
|
||||
| delete | DELETE | `/v1/{owner}/{repo}/webhooks/{id}` |
|
||||
| test | POST | `/v1/{owner}/{repo}/webhooks/{id}/tests` |
|
||||
| info | GET | `/v1/{owner}/{repo}/webhooks/{id}` |
|
||||
| events | - | (本地静态列表,不调用 API) |
|
||||
|
||||
---
|
||||
|
||||
## ? 批量操作功能
|
||||
|
||||
### 核心设计模式
|
||||
|
||||
**统一的结果统计结构**:
|
||||
```go
|
||||
type BatchSummary struct {
|
||||
Repository string // 仓库标识
|
||||
Action string // 操作类型
|
||||
Value string // 操作值
|
||||
DryRun bool // 是否预览
|
||||
Total int // 总数
|
||||
Succeeded int // 成功数
|
||||
Failed int // 失败数
|
||||
Results []BatchResult // 详细结果
|
||||
}
|
||||
```
|
||||
|
||||
### 关键代码逻辑
|
||||
|
||||
#### 1. Issue 批量创建 (`batch_create.go`)
|
||||
|
||||
**输入源合并**:
|
||||
```go
|
||||
var inputs []createIssueInput
|
||||
|
||||
// 1. 从命令行参数收集
|
||||
if titlesStr := ctx.Arg("titles"); titlesStr != "" {
|
||||
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
|
||||
}
|
||||
|
||||
// 2. 从 CSV 文件收集
|
||||
if csvPath := ctx.Arg("from"); csvPath != "" {
|
||||
csvInputs, err := readCreateInputsFromCSV(csvPath, template)
|
||||
inputs = append(inputs, csvInputs...)
|
||||
}
|
||||
```
|
||||
|
||||
**模板支持**:
|
||||
```go
|
||||
func buildCreateBody(input createIssueInput, template string) map[string]interface{} {
|
||||
if template == "bug" {
|
||||
body["description"] = buildBugDescription(input)
|
||||
body["issue_tag_ids"] = []interface{}{tagIDs["缺陷"]}
|
||||
} else if template == "feature" {
|
||||
body["description"] = buildFeatureDescription(input)
|
||||
body["issue_tag_ids"] = []interface{}{tagIDs["功能"]}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Issue 批量操作 (`batch.go`)
|
||||
|
||||
**通用批量操作流程**:
|
||||
```go
|
||||
func runBatchClose(ctx *common.RuntimeContext) error {
|
||||
// 1. 收集 Issue 编号
|
||||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
|
||||
// 2. 初始化统计结构
|
||||
summary := BatchSummary{Total: len(numbers)}
|
||||
|
||||
// 3. 逐个处理
|
||||
for _, number := range numbers {
|
||||
if dryRun {
|
||||
result.Status = "planned" // 预览模式
|
||||
} else {
|
||||
err := updateIssueField(ctx, number, payload)
|
||||
if err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "closed"
|
||||
summary.Succeeded++
|
||||
}
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
// 4. 输出统计结果
|
||||
return ctx.OutputData(summary)
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 仓库批量创建 (`batch_create.go`)
|
||||
|
||||
**用户信息获取**:
|
||||
```go
|
||||
// 获取当前用户信息
|
||||
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||
login := userData["login"].(string) // 用于 API 路径
|
||||
userID := int(userData["user_id"].(float64)) // 用于请求体
|
||||
|
||||
// 创建仓库
|
||||
body := map[string]interface{}{
|
||||
"name": repoName,
|
||||
"repository_name": repoName,
|
||||
"user_id": userID,
|
||||
}
|
||||
ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, repoName), body)
|
||||
```
|
||||
|
||||
#### 4. CSV 文件处理
|
||||
|
||||
**通用 CSV 读取模式**:
|
||||
```go
|
||||
func readRepoInputsFromCSV(path string) ([]repoCreateInput, error) {
|
||||
file, err := os.Open(path)
|
||||
reader := csv.NewReader(file)
|
||||
records, err := reader.ReadAll()
|
||||
|
||||
// 1. 解析表头
|
||||
header := records[0]
|
||||
col := make(map[string]int)
|
||||
for i, h := range header {
|
||||
col[strings.ToLower(strings.TrimSpace(h))] = i
|
||||
}
|
||||
|
||||
// 2. 验证必需列
|
||||
if _, ok := col["name"]; !ok {
|
||||
return nil, fmt.Errorf("CSV must have a 'name' column")
|
||||
}
|
||||
|
||||
// 3. 读取数据行
|
||||
for _, record := range records[1:] {
|
||||
name := getCol(record, col, "name")
|
||||
inputs = append(inputs, repoCreateInput{Name: name})
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Issue 编号收集
|
||||
|
||||
**多源合并**:
|
||||
```go
|
||||
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||
// 1. 解析命令行参数
|
||||
numbers, err := parseIssueNumbers(numbersValue) // "1,2,3" -> []string{"1","2","3"}
|
||||
|
||||
// 2. 读取 CSV 文件
|
||||
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
|
||||
|
||||
// 3. 合并去重
|
||||
return mergeIssueNumbers(numbers, csvNumbers)
|
||||
}
|
||||
```
|
||||
|
||||
### 批量操作对比
|
||||
|
||||
| 功能 | 输入源 | 特殊处理 |
|
||||
|------|--------|----------|
|
||||
| repo +batch-create | names CSV | 获取当前用户 login/userID |
|
||||
| issue +batch-create | titles CSV | 支持模板 (bug/feature) |
|
||||
| issue +batch-close | numbers CSV | 状态 ID 转换 (closed=5) |
|
||||
| issue +batch-status | numbers CSV | 状态 ID 转换 |
|
||||
| issue +batch-priority | numbers CSV | 优先级 ID 转换 |
|
||||
| issue +batch-assign | numbers CSV | 用户名→用户ID解析 |
|
||||
| issue +batch-label | numbers CSV | 标签名→标签ID映射 |
|
||||
|
||||
---
|
||||
|
||||
## ? Raw API 功能
|
||||
|
||||
### 核心设计
|
||||
|
||||
**统一的 HTTP 客户端**:
|
||||
```go
|
||||
type Client struct {
|
||||
HTTP *http.Client
|
||||
BaseURL string
|
||||
Debug bool
|
||||
SkipJSONSuffix bool // Wiki Gateway 不需要 .json 后缀
|
||||
}
|
||||
```
|
||||
|
||||
### 关键代码逻辑
|
||||
|
||||
#### 1. 请求路径处理
|
||||
|
||||
**自动添加 .json 后缀**:
|
||||
```go
|
||||
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
|
||||
// GitLink API 约定: 所有路径需要 .json 后缀
|
||||
if !c.SkipJSONSuffix {
|
||||
if !strings.HasSuffix(path, ".json") {
|
||||
path += ".json"
|
||||
}
|
||||
}
|
||||
|
||||
// 构造完整 URL
|
||||
fullURL := c.BaseURL + path
|
||||
if query != nil {
|
||||
fullURL += "?" + query.Encode()
|
||||
}
|
||||
|
||||
// 发送 HTTP 请求
|
||||
req, _ := http.NewRequest(method, fullURL, bodyReader)
|
||||
resp, _ := c.HTTP.Do(req)
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 响应解析策略
|
||||
|
||||
**多层错误处理**:
|
||||
```go
|
||||
// 1. HTTP 状态码检查
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, body),
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 解析 JSON 响应
|
||||
var raw map[string]interface{}
|
||||
json.Unmarshal(respData, &raw)
|
||||
|
||||
// 3. GitLink 业务错误检查
|
||||
if status, ok := raw["status"]; ok {
|
||||
if statusCode != 0 && statusCode != 200 {
|
||||
msg := raw["message"].(string)
|
||||
suggestion := suggestFix(int(statusCode)) // 智能错误提示
|
||||
return ErrorEnvelope(code, msg, suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 处理 JSON 字符串数据 (GitLink API 特性)
|
||||
if dataStr, ok := raw["data"].(string); ok {
|
||||
var parsedData interface{}
|
||||
json.Unmarshal([]byte(dataStr), &parsedData)
|
||||
raw["data"] = parsedData // 自动解析嵌套 JSON
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 智能错误提示
|
||||
|
||||
```go
|
||||
func suggestFix(code int) string {
|
||||
switch code {
|
||||
case 401:
|
||||
return "请先运行 gitlink-cli auth login 登录"
|
||||
case 403:
|
||||
return "权限不足,请确认账户权限或联系项目管理员"
|
||||
case 404:
|
||||
return "资源不存在,请检查 owner/repo/id 是否正确"
|
||||
case 422:
|
||||
return "参数校验失败,请检查请求参数"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. HTTP 方法封装
|
||||
|
||||
```go
|
||||
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
|
||||
return c.Do("GET", path, nil, query)
|
||||
}
|
||||
|
||||
func (c *Client) Post(path string, body interface{}) (*output.Envelope, error) {
|
||||
return c.Do("POST", path, body, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Put(path string, body interface{}) (*output.Envelope, error) {
|
||||
return c.Do("PUT", path, body, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) {
|
||||
return c.Do("DELETE", path, nil, query)
|
||||
}
|
||||
```
|
||||
|
||||
### Raw API 使用示例
|
||||
|
||||
```bash
|
||||
# GET 请求
|
||||
./gitlink-cli.exe api GET /users/me
|
||||
|
||||
# POST 请求
|
||||
./gitlink-cli.exe api POST /zzx-coder/gitlink-cli/issues --body '{"subject":"测试"}'
|
||||
|
||||
# PUT 请求
|
||||
./gitlink-cli.exe api PUT /zzx-coder/gitlink-cli/issues/123 --body '{"status":"closed"}'
|
||||
|
||||
# DELETE 请求
|
||||
./gitlink-cli.exe api DELETE /zzx-coder/gitlink-cli/issues/123
|
||||
|
||||
# 带查询参数
|
||||
./gitlink-cli.exe api GET "/zzx-coder/gitlink-cli/issues" --query "status=open&limit=20"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ? 命令优化功能
|
||||
|
||||
### 1. 参数设计优化
|
||||
|
||||
**短参数支持**:
|
||||
```go
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Issue description"},
|
||||
}
|
||||
|
||||
// 用户可以使用:
|
||||
// --title "Bug" 或 -t "Bug"
|
||||
```
|
||||
|
||||
**参数别名和映射**:
|
||||
```go
|
||||
func parseStatus(state string) (int, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "open":
|
||||
return 1, nil
|
||||
case "closed":
|
||||
return 5, nil
|
||||
case "in-progress", "in_progress", "inprogress": // 支持多种格式
|
||||
return 2, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 输出格式优化
|
||||
|
||||
**Envelope 结构**:
|
||||
```go
|
||||
type Envelope struct {
|
||||
OK bool // 操作是否成功
|
||||
Data interface{} // 数据
|
||||
Error *ErrorInfo // 错误信息
|
||||
Meta *Meta // 元数据 (分页等)
|
||||
}
|
||||
```
|
||||
|
||||
**格式化输出**:
|
||||
```go
|
||||
// JSON 格式
|
||||
{
|
||||
"ok": true,
|
||||
"data": {...},
|
||||
"meta": {
|
||||
"total_count": 100,
|
||||
"page": 1,
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
|
||||
// Table 格式 (自动格式化)
|
||||
+----+-------------------+---------+
|
||||
| ID | Title | Status |
|
||||
+----+-------------------+---------+
|
||||
| 1 | Bug fix | open |
|
||||
+----+-------------------+---------+
|
||||
```
|
||||
|
||||
### 3. 错误提示优化
|
||||
|
||||
**友好的错误消息**:
|
||||
```go
|
||||
type ErrorInfo struct {
|
||||
Code interface{} // 错误代码
|
||||
Message string // 错误描述
|
||||
Suggestion string // 解决建议 (新增)
|
||||
}
|
||||
|
||||
// 示例:
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": "Authentication failed",
|
||||
"suggestion": "请先运行 gitlink-cli auth login 登录"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**智能错误处理**:
|
||||
```go
|
||||
// Wiki 创建时的错误处理
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "404") {
|
||||
return fmt.Errorf("Wiki page not found\n\nSuggestions:\n- Check if the wiki page exists\n- Verify you have the correct permissions\n- Use 'gitlink-cli wiki +list' to see available pages")
|
||||
}
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 4. CSV 编码错误提示
|
||||
|
||||
```go
|
||||
// 批量操作时的编码检查
|
||||
if !isUTF8CSV(file) {
|
||||
return fmt.Errorf(`? CSV 文件编码错误
|
||||
文件编码不是 UTF-8,当前编码: %s
|
||||
|
||||
解决方案:
|
||||
1. 使用支持 UTF-8 的编辑器重新保存文件
|
||||
2. 或使用以下命令创建 UTF-8 文件:
|
||||
cat > repos.csv << 'EOF'
|
||||
name,description,private
|
||||
test1,测试1,false
|
||||
EOF`, currentEncoding)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ? 跨平台兼容性
|
||||
|
||||
### 1. 路径处理
|
||||
|
||||
**配置目录解析**:
|
||||
```go
|
||||
func ConfigDir() string {
|
||||
// 优先使用环境变量
|
||||
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
|
||||
// 跨平台主目录
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli")
|
||||
}
|
||||
|
||||
// Windows: C:\Users\{user}\.config\gitlink-cli
|
||||
// Linux/Mac: /home/{user}/.config/gitlink-cli
|
||||
```
|
||||
|
||||
### 2. Git Remote 解析
|
||||
|
||||
**自动解析仓库路径**:
|
||||
```go
|
||||
// 1. 从 git remote 获取 owner/repo
|
||||
gitRemote := "https://gitlink.org.cn/zzx-coder/gitlink-cli.git"
|
||||
owner, repo := "zzx-coder", "gitlink-cli"
|
||||
|
||||
// 2. 支持多种 remote 格式
|
||||
// https://gitlink.org.cn/owner/repo.git
|
||||
// git@gitlink.org.cn:owner/repo.git
|
||||
// ssh://git@gitlink.org.cn/owner/repo.git
|
||||
```
|
||||
|
||||
### 3. 字符编码处理
|
||||
|
||||
**Base64 编解码**:
|
||||
```go
|
||||
// Wiki 内容处理 (支持多语言)
|
||||
content := "# 中文内容\n\nThis is English."
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
decoded := base64.StdEncoding.DecodeString(encoded)
|
||||
```
|
||||
|
||||
**Emoji 支持**:
|
||||
```go
|
||||
// 支持 Emoji 字符
|
||||
title := "? Feature request ?"
|
||||
body := "Add emoji support ?"
|
||||
```
|
||||
|
||||
### 4. 平台特定处理
|
||||
|
||||
**文件权限**:
|
||||
```go
|
||||
// 配置文件权限: 0600 (仅用户可读写)
|
||||
os.WriteFile(configPath, data, 0600)
|
||||
|
||||
// 目录权限: 0700 (仅用户可访问)
|
||||
os.MkdirAll(configDir, 0700)
|
||||
```
|
||||
|
||||
**二进制文件**:
|
||||
```bash
|
||||
# Windows: gitlink-cli.exe
|
||||
# Linux/Mac: gitlink-cli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ? 数据流程示例
|
||||
|
||||
### Issue 创建完整流程
|
||||
|
||||
```
|
||||
用户输入:
|
||||
./gitlink-cli.exe issue +create --owner zzx-coder --repo gitlink-cli --title "Bug" --body "Fix it"
|
||||
|
||||
1. 参数解析
|
||||
Args = {"owner": "zzx-coder", "repo": "gitlink-cli", "title": "Bug", "body": "Fix it"}
|
||||
|
||||
2. 创建 RuntimeContext
|
||||
ctx = RuntimeContext{
|
||||
Client: httpClient,
|
||||
Owner: "zzx-coder",
|
||||
Repo: "gitlink-cli",
|
||||
Format: "json",
|
||||
Args: Args
|
||||
}
|
||||
|
||||
3. API 调用
|
||||
path = "/v1/zzx-coder/gitlink-cli/issues.json"
|
||||
body = {
|
||||
"subject": "Bug",
|
||||
"description": "Fix it",
|
||||
"status_id": 1,
|
||||
"priority_id": 2
|
||||
}
|
||||
|
||||
4. HTTP 请求
|
||||
POST https://www.gitlink.org.cn/api/v1/zzx-coder/gitlink-cli/issues.json
|
||||
Authorization: Bearer {token}
|
||||
Content-Type: application/json
|
||||
|
||||
5. 响应处理
|
||||
解析 JSON → Envelope{OK: true, Data: {...}}
|
||||
|
||||
6. 输出格式化
|
||||
格式化为 JSON/Table → 输出到终端
|
||||
```
|
||||
|
||||
### Wiki 创建完整流程
|
||||
|
||||
```
|
||||
用户输入:
|
||||
./gitlink-cli.exe wiki +create --owner zzx-coder --repo gitlink-cli --title "Test" --content "# Test"
|
||||
|
||||
1. 第一次 API 调用 (获取 Project ID)
|
||||
GET https://www.gitlink.org.cn/api/zzx-coder/gitlink-cli/detail.json
|
||||
响应: {"project_id": 12345}
|
||||
|
||||
2. Project ID 缓存
|
||||
projectIDCache.Store("zzx-coder/gitlink-cli", "12345")
|
||||
|
||||
3. 第二次 API 调用 (创建 Wiki)
|
||||
POST https://gateway.gitlink.org.cn/api/wiki/open/createWiki
|
||||
body = {
|
||||
"owner": "zzx-coder",
|
||||
"repo": "gitlink-cli",
|
||||
"projectId": 12345,
|
||||
"pageName": "Test",
|
||||
"content_base64": "I1BUV\Q==" // Base64 编码
|
||||
}
|
||||
|
||||
4. Gateway 响应处理
|
||||
解析 {"code": 200, "data": {...}} → 提取 data 部分
|
||||
|
|
@ -0,0 +1,535 @@
|
|||
# GitLink CLI 命令系统优化报告
|
||||
|
||||
## 一、参数设计
|
||||
|
||||
### 问题:枚举参数无校验,错误信息延迟到 API 调用才暴露
|
||||
|
||||
命令 `pr +merge --method` 接受 `merge`、`rebase`、`squash` 三种值,`issue +list --state` 接受 `open`、`closed`、`all`,但输入非法值时没有任何拦截。用户输入 `--method unknown` 会直接发往 API,等到服务端返回 422 才知道参数错了,反馈链路长。
|
||||
|
||||
**修改前** — 校验逻辑散落在 Run 函数内部:
|
||||
|
||||
```go
|
||||
// shortcuts/issue/issue.go
|
||||
Flags: []common.Flag{
|
||||
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
|
||||
}
|
||||
|
||||
// 枚举值定义在 Usage 文本里(给人看),校验在 Run() 里手写(给机器做)
|
||||
// 两者没有关联,容易出现文本和代码不同步
|
||||
func normalizeIssueStatus(state string) (interface{}, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "open":
|
||||
return 1, nil
|
||||
case "closed":
|
||||
return 5, nil
|
||||
default:
|
||||
if id, err := strconv.Atoi(state); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**问题总结**:每个有枚举值的 flag 都需要在 `Run()` 里手写一个 validate 函数,重复劳动且容易遗漏;枚举值在 Usage 文本里写一遍、在代码里再写一遍,两处不同步时有发生。
|
||||
|
||||
**解决方案**:在 `Flag` 结构体中增加 `Choices` 字段,框架层在 parse 阶段自动校验,同时将可选值追加到 `--help` 输出。
|
||||
|
||||
**修改后**:
|
||||
|
||||
```go
|
||||
// ① 结构体扩展 — shortcuts/common/types.go
|
||||
type Flag struct {
|
||||
Name string
|
||||
Short string
|
||||
Usage string
|
||||
Required bool
|
||||
Default string
|
||||
Bool bool
|
||||
Choices []string // 新增:枚举校验
|
||||
Validate func(value string) error // 新增:自定义校验函数
|
||||
}
|
||||
|
||||
// ② 框架自动校验 — shortcuts/common/runner.go
|
||||
for _, f := range s.Flags {
|
||||
val := getFlagValue(cmd, f)
|
||||
// Choices 枚举校验
|
||||
if len(f.Choices) > 0 && val != "" && val != "false" {
|
||||
if !contains(f.Choices, val) {
|
||||
return clierrors.InputError(
|
||||
fmt.Sprintf("invalid value %q for --%s", val, f.Name),
|
||||
fmt.Sprintf("有效值: %s。运行 'gitlink-cli %s --help' 查看用法。",
|
||||
strings.Join(f.Choices, ", "), commandName),
|
||||
).WithCommand(commandName)
|
||||
}
|
||||
}
|
||||
// 自定义校验
|
||||
if f.Validate != nil && val != "" {
|
||||
if err := f.Validate(val); err != nil {
|
||||
return clierrors.InputError(
|
||||
fmt.Sprintf("invalid --%s: %v", f.Name, err),
|
||||
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
|
||||
).WithCommand(commandName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 命令行只需声明 Choices — shortcuts/pr/pr.go
|
||||
Flags: []common.Flag{
|
||||
{Name: "method", Short: "m", Usage: "Merge method", Default: "merge",
|
||||
Choices: []string{"merge", "rebase", "squash"}},
|
||||
}
|
||||
```
|
||||
|
||||
Choices 声明后,无需再写校验函数,`--help` 也会自动追加 `[merge|rebase|squash]`。
|
||||
|
||||
---
|
||||
|
||||
### 问题:跨参数约束靠手写 fmt.Errorf,格式不统一
|
||||
|
||||
`issue +update` 要求 "至少提供 --title、--body 或 --state 中的一个";`repo +update` 要求 "至少提供 --description 或 --private 中的一个"。这类跨参数约束都散落在 `Run()` 里用 `fmt.Errorf` 写死,每种错误格式各异、中英文混杂。
|
||||
|
||||
**修改前**:
|
||||
|
||||
```go
|
||||
// shortcuts/issue/issue.go — 在 Run() 内部手写校验
|
||||
if title == "" && description == "" && state == "" {
|
||||
return fmt.Errorf("at least one of --title, --body, or --state is required")
|
||||
}
|
||||
|
||||
// shortcuts/repo/repo.go — 同样手写,格式不同
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("at least one of --description, --private is required")
|
||||
}
|
||||
```
|
||||
|
||||
**解决方案**:在 `Shortcut` 结构体中增加 `Validate` 字段,支持声明式跨参数校验;同时引入 `clierrors.InputError` 统一错误格式(英文技术消息 + 中文操作建议)。
|
||||
|
||||
**修改后**:
|
||||
|
||||
```go
|
||||
// ① Shortcut 结构体新增 Validate 字段 — shortcuts/common/types.go
|
||||
type Shortcut struct {
|
||||
Name string
|
||||
Description string
|
||||
Flags []Flag
|
||||
Validate func(args map[string]string) error // 新增:跨参数校验
|
||||
Run func(ctx *RuntimeContext) error
|
||||
}
|
||||
|
||||
// ② MountShortcut 中自动执行 — shortcuts/common/runner.go
|
||||
if s.Validate != nil {
|
||||
if err := s.Validate(flagValues); err != nil {
|
||||
return clierrors.InputError(
|
||||
err.Error(),
|
||||
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
|
||||
).WithCommand(commandName)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 命令中统一使用 InputError — shortcuts/issue/issue.go
|
||||
if title == "" && description == "" && state == "" {
|
||||
return clierrors.InputError(
|
||||
"at least one of --title, --body, or --state is required",
|
||||
"至少需要提供 --title、--body 或 --state 中的一个参数",
|
||||
).WithCommand(ctx.CommandName)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、输出格式
|
||||
|
||||
### 问题:默认格式与帮助文本不一致
|
||||
|
||||
`cmd/root.go` 中 `--format` 帮助文本写明 "default: table",但 `shortcuts/common/types.go` 中 `NewRuntimeContext` 的代码默认值是 `"json"`。用户不传 `--format` 时拿到的是 JSON 而非表格。
|
||||
|
||||
**修改前**:
|
||||
|
||||
```go
|
||||
// cmd/root.go — 帮助文本说 "default: table"
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "",
|
||||
"Output format: json, table, yaml (default: table)")
|
||||
|
||||
// shortcuts/common/types.go — 代码实际默认 json
|
||||
format := cmdutil.Format
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
```
|
||||
|
||||
**解决方案**:将代码默认值改为 `"table"`,同时将 persistent flag 的默认值参数从空字符串改为 `"table"`,让 cobra 显示的默认值与实际行为一致。
|
||||
|
||||
**修改后**:
|
||||
|
||||
```go
|
||||
// cmd/root.go — 默认值显式化为 "table"
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "table",
|
||||
"Output format: json, table, yaml")
|
||||
|
||||
// shortcuts/common/types.go — 代码默认值与帮助文本一致
|
||||
format := cmdutil.Format
|
||||
if format == "" {
|
||||
format = "table"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题:表格输出无列过滤、长文本被硬截断、无可读性增强
|
||||
|
||||
现状:表格渲染时列全量输出、复杂值(JSON 嵌套、长文本)在 60 字符处硬截断后加 `...`、无色彩区分。用户想只看 id + title 两列也要扛着十几列的输出;想看完整描述被 `...` 截断无能为力。
|
||||
|
||||
**修改前**:
|
||||
|
||||
```go
|
||||
// internal/output/formatter.go — 截断硬编码,无列过滤
|
||||
func formatValue(v interface{}) string {
|
||||
// ...
|
||||
if len(s) > 60 {
|
||||
return s[:57] + "..." // 硬截断,不可配置
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func Print(envelope *Envelope, format string) error {
|
||||
// 无任何渲染选项
|
||||
return PrintTo(os.Stdout, envelope, format)
|
||||
}
|
||||
```
|
||||
|
||||
**解决方案**:引入 `PrintOptions` 结构体,支持 `Columns`(列过滤)、`NoTruncate`(关闭截断)、`UseColor`(彩色表头);新增 3 个全局 persistent flag(`--columns`、`--no-truncate`、`--no-color`);通过 `RuntimeContext` 透明传递到所有输出调用。
|
||||
|
||||
**修改后**:
|
||||
|
||||
```go
|
||||
// ① PrintOptions 结构体 — internal/output/formatter.go
|
||||
type PrintOptions struct {
|
||||
Columns []string // 要显示的列,nil = 全部
|
||||
NoTruncate bool // 禁用 60 字符截断
|
||||
UseColor bool // 启用 ANSI 颜色
|
||||
}
|
||||
|
||||
func PrintWithOpts(envelope *Envelope, format string, opts PrintOptions) error {
|
||||
// ...
|
||||
case "table":
|
||||
return printTableOpts(w, envelope, opts) // 传递 opts
|
||||
}
|
||||
|
||||
// ② 列过滤实现
|
||||
func filterColumns(all, wanted []string) []string {
|
||||
wantedSet := make(map[string]bool, len(wanted))
|
||||
for _, w := range wanted { wantedSet[w] = true }
|
||||
result := make([]string, 0, len(wanted))
|
||||
for _, h := range all {
|
||||
if wantedSet[h] { result = append(result, h) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ③ 可配置截断
|
||||
func formatValueOpts(v interface{}, noTruncate bool) string {
|
||||
if !noTruncate && len(s) > 60 {
|
||||
return s[:57] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ④ 彩色表头(仅终端且 --no-color 未设置)
|
||||
func isTerminal(w io.Writer) bool {
|
||||
if f, ok := w.(*os.File); ok {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ⑤ RuntimeContext 无缝传递 — shortcuts/common/types.go
|
||||
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
|
||||
opts := output.PrintOptions{
|
||||
NoTruncate: ctx.NoTruncate,
|
||||
UseColor: !ctx.NoColor,
|
||||
}
|
||||
if ctx.Columns != "" {
|
||||
// "id,title,state" → []string{"id", "title", "state"}
|
||||
for _, p := range strings.Split(ctx.Columns, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" { opts.Columns = append(opts.Columns, p) }
|
||||
}
|
||||
}
|
||||
return output.PrintWithOpts(env, ctx.Format, opts)
|
||||
}
|
||||
|
||||
// ⑥ 全局 flag 注册 — cmd/root.go
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.NoTruncate, "no-truncate", false,
|
||||
"Disable value truncation in table output")
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Columns, "columns", "",
|
||||
"Columns to show in table output (comma-separated)")
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.NoColor, "no-color", false,
|
||||
"Disable colored output")
|
||||
```
|
||||
|
||||
用法:
|
||||
|
||||
```
|
||||
gitlink-cli issue +list --columns id,subject,status
|
||||
gitlink-cli issue +list --no-truncate
|
||||
gitlink-cli issue +list --no-color
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、错误提示
|
||||
|
||||
### 问题:中英文混用,fmt.Errorf 不被框架识别
|
||||
|
||||
现状:各快捷命令中的错误用 `fmt.Errorf` 随意构造,中文和英文混用。`fmt.Errorf` 生成的错误不是 `CLIError` 类型,不被 `TryPrintError` 识别,只能走 `cmd.Execute` 的 stderr 兜底输出,无法享受 envelope 结构化错误格式。
|
||||
|
||||
**修改前** — 同一项目中三种不同风格:
|
||||
|
||||
```go
|
||||
// 风格 A:中文 — shortcuts/issue/issue.go
|
||||
return fmt.Errorf("获取 Issue 列表失败: %w", err)
|
||||
return fmt.Errorf("创建 Issue 失败: %w", err)
|
||||
|
||||
// 风格 B:英文 — shortcuts/repo/repo.go
|
||||
return fmt.Errorf("failed to list members for %s/%s: %w", ctx.Owner, ctx.Repo, err)
|
||||
return fmt.Errorf("cannot determine current user login")
|
||||
|
||||
// 风格 C:中英混合 — shortcuts/pr/pr.go
|
||||
return fmt.Errorf("获取 PR 列表失败: %w", err)
|
||||
return fmt.Errorf("添加 PR 评论失败: %w", err)
|
||||
```
|
||||
|
||||
**问题根源**:没有统一的错误构造入口,开发者各自手写 `fmt.Errorf`。
|
||||
|
||||
**解决方案**:新增 `OpError` 构造函数,入参只需动词和资源名,自动生成英文 `Message`(给脚本 / jq 解析)和中文 `Suggestion`(给用户阅读),且返回 `*CLIError` 类型可被框架自动识别为 envelope 格式。
|
||||
|
||||
**修改后**:
|
||||
|
||||
```go
|
||||
// ① 统一构造函数 — internal/errors/errors.go
|
||||
func OpError(kind ErrorKind, op, resource string, cause error) *CLIError {
|
||||
msg := fmt.Sprintf("failed to %s %s", op, resource)
|
||||
sugg := opSuggestion(op, resource)
|
||||
return Wrap(kind, msg, sugg, cause)
|
||||
}
|
||||
|
||||
func opSuggestion(op, resource string) string {
|
||||
suggestions := map[string]string{
|
||||
"list": "获取列表失败,请检查参数或网络连接,稍后重试",
|
||||
"create": "创建失败,请检查必填参数是否正确(--help 查看用法)或 API 权限",
|
||||
"view": "查看失败,请确认资源 ID 是否存在",
|
||||
"update": "更新失败,请检查参数值或资源 ID 是否正确",
|
||||
"delete": "删除失败,请确认资源是否存在或是否有删除权限",
|
||||
"close": "关闭失败,请确认资源是否存在或已被关闭",
|
||||
"merge": "合并失败,请检查是否有冲突或权限不足",
|
||||
"comment": "添加评论失败,请确认资源是否存在",
|
||||
"fork": "Fork 失败,请确认仓库存在或有权限",
|
||||
"invite": "邀请失败,请确认用户 ID 是否正确",
|
||||
"remove": "移除失败,请确认成员存在",
|
||||
}
|
||||
if s, ok := suggestions[op]; ok { return s }
|
||||
return "操作失败,请稍后重试或运行 --help 查看用法"
|
||||
}
|
||||
|
||||
// ② 命令中一行调用 — shortcuts/issue/issue.go
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||
if err != nil {
|
||||
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).
|
||||
WithCommand(ctx.CommandName)
|
||||
}
|
||||
|
||||
// ③ 框架自动识别 — shortcuts/common/error_print.go
|
||||
// TryPrintError 检测到 *CLIError 类型后自动输出为结构化 JSON:
|
||||
var cliErr *clierrors.CLIError
|
||||
if errors.As(err, &cliErr) {
|
||||
env := output.ErrorEnvelope(kindToCode(cliErr.Kind), cliErr.Message, cliErr.Suggestion)
|
||||
_ = output.Print(env, format)
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
输出效果:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": 500,
|
||||
"message": "failed to list issues",
|
||||
"suggestion": "获取列表失败,请检查参数或网络连接,稍后重试"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`message` 用英文保证脚本可解析,`suggestion` 用中文直接给人看。
|
||||
|
||||
---
|
||||
|
||||
## 四、帮助文档
|
||||
|
||||
### 问题:Shortcut 无详细帮助,Group 命令只有一行描述
|
||||
|
||||
现状:`Shortcut` 结构体只有 `Description` 一个短描述字段,没有 `Long`(详细说明)和 `Example`(使用示例)。`--help` 输出只有一个命令行和 flag 列表,用户看不到用法示例。
|
||||
|
||||
Group 命令(`repo`、`issue`、`pr` 等)同理,15 个 group 全部只有一行 `Short`:
|
||||
|
||||
```go
|
||||
descriptions := map[string]string{
|
||||
"repo": "Repository operations",
|
||||
"pr": "Pull request operations",
|
||||
// 14 个 group 完全一样...
|
||||
}
|
||||
```
|
||||
|
||||
**修改前**:
|
||||
|
||||
```go
|
||||
// shortcuts/common/types.go — 结构体缺少 Long 和 Example
|
||||
type Shortcut struct {
|
||||
Name string
|
||||
Description string // 仅此一个描述字段
|
||||
Flags []Flag
|
||||
Run func(ctx *RuntimeContext) error
|
||||
}
|
||||
|
||||
// shortcuts/common/runner.go — cobra 命令只有 Use 和 Short
|
||||
cmd := &cobra.Command{
|
||||
Use: "+" + s.Name,
|
||||
Short: s.Description,
|
||||
RunE: /* ... */,
|
||||
}
|
||||
|
||||
// shortcuts/register.go — group 命令也只有 Short
|
||||
groupCmd := &cobra.Command{
|
||||
Use: name,
|
||||
Short: descriptions[name],
|
||||
}
|
||||
```
|
||||
|
||||
**问题总结**:`--help` 输出仅包含一句话描述 + 参数列表,没有用法示例。对于 `pr +merge` 这类参数较多的命令,用户无从得知 `--method` 有哪些可选值、典型调用怎么写。
|
||||
|
||||
**解决方案**:`Shortcut` 结构体新增 `Long` 和 `Example` 字段,挂载到 cobra 的 `Long` 和 `Example`;`register.go` 中 15 个 Group 命令全部补充 Long 描述和使用示例;新增 `completion` 子命令支持 4 种 shell 的自动补全。
|
||||
|
||||
**修改后**:
|
||||
|
||||
```go
|
||||
// ① Shortcut 结构体扩展 — shortcuts/common/types.go
|
||||
type Shortcut struct {
|
||||
Name string
|
||||
Description string
|
||||
Long string // 新增:详细帮助文本
|
||||
Example string // 新增:使用示例
|
||||
Flags []Flag
|
||||
Run func(ctx *RuntimeContext) error
|
||||
}
|
||||
|
||||
// ② MountShortcut 挂载到 cobra — shortcuts/common/runner.go
|
||||
cmd := &cobra.Command{
|
||||
Use: "+" + s.Name,
|
||||
Short: s.Description,
|
||||
Long: s.Long, // 新增
|
||||
Example: s.Example, // 新增
|
||||
RunE: /* ... */,
|
||||
}
|
||||
|
||||
// ③ 命令中填写 — shortcuts/pr/pr.go
|
||||
{
|
||||
Name: "merge",
|
||||
Description: "Merge a pull request",
|
||||
Example: " gitlink-cli pr +merge --id 42\n" +
|
||||
" gitlink-cli pr +merge --id 42 --method rebase\n" +
|
||||
" gitlink-cli pr +merge --id 42 --method squash --dry-run",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "method", Short: "m", Usage: "Merge method", Default: "merge",
|
||||
Choices: []string{"merge", "rebase", "squash"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ④ Group 命令补充 Long 和 Example — shortcuts/register.go
|
||||
type groupInfo struct {
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
}
|
||||
|
||||
infos := map[string]groupInfo{
|
||||
"pr": {
|
||||
Short: "Pull request operations",
|
||||
Long: "Manage pull requests: list, create, view, merge, close, review, and view changed files.",
|
||||
Example: " gitlink-cli pr +list --state open\n" +
|
||||
" gitlink-cli pr +create --title \"Fix login\" --head feat-branch\n" +
|
||||
" gitlink-cli pr +merge --id 42",
|
||||
},
|
||||
// 其余 14 个 group 同上
|
||||
}
|
||||
|
||||
groupCmd := &cobra.Command{
|
||||
Use: name,
|
||||
Short: info.Short,
|
||||
Long: info.Long,
|
||||
Example: info.Example,
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:无 Shell 自动补全
|
||||
|
||||
cobra 框架原生支持 bash/zsh/fish/powershell 的补全生成,但 gitlink-cli 没有暴露这个能力。用户需要记忆 15 个 group 和 80+ 个子命令的完整名称。
|
||||
|
||||
**解决方案**:新增 `completion` 子命令。
|
||||
|
||||
```go
|
||||
// cmd/root.go
|
||||
var completionCmd = &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completion script",
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
shell := "bash"
|
||||
if len(args) > 0 { shell = args[0] }
|
||||
switch shell {
|
||||
case "bash":
|
||||
return cmd.Root().GenBashCompletion(os.Stdout)
|
||||
case "zsh":
|
||||
return cmd.Root().GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
return cmd.Root().GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
default:
|
||||
return fmt.Errorf("unsupported shell: %s (valid: bash, zsh, fish, powershell)", shell)
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
启用:
|
||||
|
||||
```bash
|
||||
source <(gitlink-cli completion bash) # Bash
|
||||
source <(gitlink-cli completion zsh) # Zsh
|
||||
gitlink-cli completion fish | source # fish
|
||||
gitlink-cli completion powershell | Out-String | Invoke-Expression # PowerShell
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、影响范围
|
||||
|
||||
| 文件 | 改动性质 |
|
||||
|------|----------|
|
||||
| `cmd/cmdutil/globals.go` | 新增 NoTruncate / Columns / NoColor 全局变量 |
|
||||
| `cmd/root.go` | 新增 3 个 persistent flag + completion 子命令 + format 默认值修正 |
|
||||
| `shortcuts/common/types.go` | Shortcut / Flag / RuntimeContext 结构体扩展(6 个新增字段) |
|
||||
| `shortcuts/common/runner.go` | Choices/Validate 校验逻辑 + Long/Example 挂载 + Choices→Usage 自动追加 |
|
||||
| `internal/output/formatter.go` | PrintOptions + PrintWithOpts + 列过滤 + 去截断 + 彩色表头 |
|
||||
| `internal/errors/errors.go` | OpError 统一构造函数 |
|
||||
| `shortcuts/register.go` | 15 个 Group 命令补充 Long / Example |
|
||||
| `shortcuts/issue/issue.go` | 错误统一 + Choices(state) + Long/Example(list, create, view, close, update) |
|
||||
| `shortcuts/pr/pr.go` | 错误统一 + Choices(state, method) + Long/Example(list, create, merge) |
|
||||
| `shortcuts/repo/repo.go` | 错误统一 + Choices(category) + Long/Example(list, create, update) |
|
||||
|
||||
所有新增字段零值安全,现有 80+ Shortcut 无需修改即可编译。`output.Print()` 签名不变,内部包装为 `PrintWithOpts`。Workflow 脚本显式使用 `--format json`,不受默认格式变化影响。
|
||||
|
||||
验证:`go build` 通过,`go vet` 通过(仅 pre-existing milestone 包有构建错误),`go test ./...` 7 个测试包全部通过。
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
# Board (看板) Shortcut 修改笔记
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
新增 `board` 快捷命令组,提供项目看板的查看、筛选、任务操作和统计分析功能。
|
||||
|
||||
| 命令 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `board +view` | 读 | 按状态分组显示看板全貌 |
|
||||
| `board +columns` | 读 | 列出各状态列及 issue 数量 |
|
||||
| `board +issues` | 读 | 按状态/指派人/优先级筛选任务 |
|
||||
| `board +move` | 写 | 移动任务状态(支持 dry-run) |
|
||||
| `board +assign` | 写 | 指派任务给用户(支持 dry-run) |
|
||||
| `board +stats` | 读 | 完成率、工作负载、瓶颈分析 |
|
||||
|
||||
---
|
||||
|
||||
## 二、解决思路
|
||||
|
||||
### 2.1 API 选型
|
||||
|
||||
最初计划使用 PM 看板 API (`GET /pm/dashboards?project_id=...`),但实际测试发现该端点不存在(返回 HTML 页面)。skill 参考文档 `pm-kanban.md` 中的 API 描述有误。
|
||||
|
||||
**最终方案**:基于已有的 issue list API (`GET /v1/{owner}/{repo}/issues`) 实现看板视图。每个 issue 带有 `status_id`、`status_name`、`assigners`、`priority` 等字段,按 `status_id` 分组即可构建看板。
|
||||
|
||||
### 2.2 看板列设计
|
||||
|
||||
按 `status_id` 映射为 5 列:
|
||||
|
||||
| status_id | 列名 |
|
||||
|-----------|------|
|
||||
| 1 | 待处理 |
|
||||
| 2 | 进行中 |
|
||||
| 3 | 已解决 |
|
||||
| 5 | 已关闭 |
|
||||
| 6 | 已拒绝 |
|
||||
|
||||
### 2.3 写操作复用
|
||||
|
||||
`board +move` 和 `board +assign` 复用 issue PATCH API (`PATCH /v1/{owner}/{repo}/issues/{id}`)。关键点:PATCH body 必须包含 `subject` + `description`,否则会被清空。
|
||||
|
||||
---
|
||||
|
||||
## 三、代码指令
|
||||
|
||||
### 3.1 文件变更
|
||||
|
||||
```
|
||||
新建:shortcuts/board/board.go # 6 个命令 + 辅助函数(约 580 行)
|
||||
修改:shortcuts/register.go # 添加 board 组的 import + 注册
|
||||
```
|
||||
|
||||
### 3.2 编译部署
|
||||
|
||||
```bash
|
||||
# 编译
|
||||
cd /c/Users/Lenovo/Desktop/soft运维/gitlink-cli
|
||||
go install .
|
||||
|
||||
# 同步到 npm(npm shim 调用同目录的 gitlink-cli.exe)
|
||||
cp ~/go/bin/gitlink-cli.exe ~/AppData/Roaming/npm/node_modules/@gitlink-ai/cli/bin/gitlink-cli.exe
|
||||
```
|
||||
|
||||
### 3.3 核心辅助函数
|
||||
|
||||
```go
|
||||
// fetchAllIssuesWithState — 分页获取所有 issue
|
||||
func fetchAllIssuesWithState(ctx *common.RuntimeContext, state string) ([]issueItem, error)
|
||||
|
||||
// groupByStatus — 按 status_id 分组
|
||||
func groupByStatus(issues []issueItem) map[int][]issueItem
|
||||
|
||||
// fetchExistingIssue — 获取 issue 的 subject+description(PATCH 前必须调用)
|
||||
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (string, string, error)
|
||||
|
||||
// resolveUserID — 用户名转用户 ID(调用 GET /users/{login})
|
||||
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error)
|
||||
```
|
||||
|
||||
### 3.4 register.go 变更
|
||||
|
||||
```go
|
||||
// 新增 import
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/board"
|
||||
|
||||
// groups map 新增
|
||||
"board": board.Shortcuts(),
|
||||
|
||||
// infos map 新增
|
||||
"board": {
|
||||
Short: "Board (kanban) operations",
|
||||
Long: "View and manage kanban boards: ...",
|
||||
Example: " gitlink-cli board +view\n ...",
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、输出参考
|
||||
|
||||
### 4.1 board --help
|
||||
|
||||
```
|
||||
View and manage kanban boards: view board layout, list columns, filter issues,
|
||||
move tasks between columns, assign people, and analyze workload.
|
||||
|
||||
Usage:
|
||||
gitlink-cli board [command]
|
||||
|
||||
Available Commands:
|
||||
+assign Assign an issue to someone
|
||||
+columns List status columns with issue counts
|
||||
+issues List issues with optional filters
|
||||
+move Move an issue to a different status
|
||||
+stats Show board analytics and statistics
|
||||
+view View kanban board layout
|
||||
```
|
||||
|
||||
### 4.2 board +view
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"repository": "zzx-coder/gitlink-cli",
|
||||
"total_issues": 44,
|
||||
"columns": [
|
||||
{
|
||||
"status_id": 1,
|
||||
"status_name": "待处理",
|
||||
"issue_count": 0,
|
||||
"issues": []
|
||||
},
|
||||
{
|
||||
"status_id": 2,
|
||||
"status_name": "进行中",
|
||||
"issue_count": 0,
|
||||
"issues": []
|
||||
},
|
||||
{
|
||||
"status_id": 3,
|
||||
"status_name": "已解决",
|
||||
"issue_count": 44,
|
||||
"issues": [
|
||||
{"number": 16, "id": 143115, "subject": "fix:增加相关test.go文件", "priority": "normal"},
|
||||
{"number": 25, "id": 143700, "subject": "fix: 统一错误提示优化", "priority": "normal"}
|
||||
]
|
||||
},
|
||||
{"status_id": 5, "status_name": "已关闭", "issue_count": 0, "issues": []},
|
||||
{"status_id": 6, "status_name": "已拒绝", "issue_count": 0, "issues": []}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 board +columns
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": [
|
||||
{"status_id": 1, "status_name": "待处理", "issue_count": 0},
|
||||
{"status_id": 2, "status_name": "进行中", "issue_count": 0},
|
||||
{"status_id": 3, "status_name": "已解决", "issue_count": 44},
|
||||
{"status_id": 5, "status_name": "已关闭", "issue_count": 0},
|
||||
{"status_id": 6, "status_name": "已拒绝", "issue_count": 0}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 board +issues --status resolved --limit 3
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": [
|
||||
{"number": 16, "id": 143115, "subject": "fix:增加相关test.go文件", "status": "已解决", "priority": "normal", "assigned_to": ""},
|
||||
{"number": 25, "id": 143700, "subject": "fix: 统一错误提示优化", "status": "已解决", "priority": "normal", "assigned_to": ""},
|
||||
{"number": 2, "id": 142700, "subject": "新增issue批量操作", "status": "已解决", "priority": "normal", "assigned_to": ""}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 board +move --number 16 --status in-progress --dry-run
|
||||
|
||||
```
|
||||
[dry-run] Move issue #16 to status "in-progress"
|
||||
|
||||
Proceed? [y/N] Aborted.
|
||||
```
|
||||
|
||||
### 4.6 board +stats
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"repository": "zzx-coder/gitlink-cli",
|
||||
"total_issues": 44,
|
||||
"completion_rate": 100,
|
||||
"column_breakdown": [
|
||||
{"status_name": "待处理", "count": 0, "percentage": 0},
|
||||
{"status_name": "进行中", "count": 0, "percentage": 0},
|
||||
{"status_name": "已解决", "count": 44, "percentage": 100},
|
||||
{"status_name": "已关闭", "count": 0, "percentage": 0},
|
||||
{"status_name": "已拒绝", "count": 0, "percentage": 0}
|
||||
],
|
||||
"assignee_load": [
|
||||
{"assignee": "(unassigned)", "count": 44}
|
||||
],
|
||||
"bottleneck": "已解决"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、踩坑记录
|
||||
|
||||
| 问题 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| `unknown command "board"` | npm shim (`cli.js`) 调用的是 npm 包内的 `gitlink-cli.exe`,不是 `go/bin` 的 | 编译后同步覆盖 npm 包内的 exe |
|
||||
| `failed to parse dashboard data` | `/pm/dashboards` API 端点不存在,返回 HTML | 改用 issue list API + 客户端分组 |
|
||||
| `json: cannot unmarshal string` | API 返回 HTML 字符串而非 JSON 对象 | 同上,放弃 PM API |
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
"""生成子赛题一报告 Word 文档"""
|
||||
from docx import Document
|
||||
from docx.shared import Pt, Inches, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
doc = Document()
|
||||
|
||||
# === 样式设置 ===
|
||||
style = doc.styles['Normal']
|
||||
style.font.name = '宋体'
|
||||
style.font.size = Pt(12)
|
||||
style.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
|
||||
|
||||
def add_heading(text, level=1):
|
||||
h = doc.add_heading(text, level=level)
|
||||
for run in h.runs:
|
||||
run.font.name = '黑体'
|
||||
run.element.rPr.rFonts.set(qn('w:eastAsia'), '黑体')
|
||||
return h
|
||||
|
||||
def add_para(text, bold=False):
|
||||
p = doc.add_paragraph()
|
||||
run = p.add_run(text)
|
||||
run.bold = bold
|
||||
run.font.name = '宋体'
|
||||
run.font.size = Pt(12)
|
||||
run.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
|
||||
return p
|
||||
|
||||
def add_table(headers, rows):
|
||||
table = doc.add_table(rows=1, cols=len(headers), style='Table Grid')
|
||||
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
for i, h in enumerate(headers):
|
||||
cell = table.rows[0].cells[i]
|
||||
cell.text = h
|
||||
for p in cell.paragraphs:
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
for run in p.runs:
|
||||
run.bold = True
|
||||
run.font.size = Pt(10)
|
||||
for row_data in rows:
|
||||
row = table.add_row()
|
||||
for i, val in enumerate(row_data):
|
||||
row.cells[i].text = str(val)
|
||||
for p in row.cells[i].paragraphs:
|
||||
for run in p.runs:
|
||||
run.font.size = Pt(10)
|
||||
return table
|
||||
|
||||
def add_code(text):
|
||||
p = doc.add_paragraph()
|
||||
run = p.add_run(text)
|
||||
run.font.name = 'Consolas'
|
||||
run.font.size = Pt(9)
|
||||
run.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
|
||||
return p
|
||||
|
||||
# ============================================================
|
||||
# 正文
|
||||
# ============================================================
|
||||
|
||||
add_heading('第二章 子赛题一:增强与完善 GitLink-CLI 能力', level=1)
|
||||
|
||||
# 2.1
|
||||
add_heading('2.1 任务目标与整体思路', level=2)
|
||||
add_para(
|
||||
'子赛题一的定位是扩展 gitlink-cli 的功能覆盖面和使用体验。本项目选择的切入方向有三个:'
|
||||
'新增 Shortcut 命令(填补功能空白)、优化命令系统框架(提升开发效率和用户体验)、'
|
||||
'补全 Raw API 封装(对齐 OpenAPI 接口)。'
|
||||
)
|
||||
add_para(
|
||||
'整体设计思路是"先框架后业务":先完善底层的 Shortcut 抽象层(参数校验、输出格式、错误处理、帮助文档),'
|
||||
'再基于这个框架快速新增业务命令。这样做的好处是,新增的命令天然继承框架能力'
|
||||
'(dry-run、枚举校验、结构化错误输出),不需要每个命令重复造轮子。'
|
||||
)
|
||||
|
||||
# 2.2
|
||||
add_heading('2.2 三层命令体系架构', level=2)
|
||||
add_para('gitlink-cli 采用三层命令体系:')
|
||||
|
||||
add_table(
|
||||
['层级', '名称', '说明', '示例'],
|
||||
[
|
||||
['第一层', 'Shortcuts(+前缀命令)', '人类和 AI Agent 直接使用,参数精简,结构化输出', 'issue +list --state open'],
|
||||
['第二层', 'API Commands(元数据驱动)', '自动从 OpenAPI spec 生成,覆盖所有 API 端点', 'api get /v1/{owner}/{repo}/issues'],
|
||||
['第三层', 'Raw API(HTTP 原始调用)', '完全透传,适合调试和边缘场景', 'raw GET /v1/owner/repo/issues'],
|
||||
]
|
||||
)
|
||||
|
||||
add_para('')
|
||||
add_para(
|
||||
'本次工作主要落在第一层(Shortcuts),同时涉及底层框架的增强。新增了 board、file、milestone '
|
||||
'三个 Shortcut 模块,以及对 issue、pr、repo 等已有模块的扩展。'
|
||||
)
|
||||
add_para(
|
||||
'架构说明:cmd/root.go 是 cobra 根命令,通过 shortcuts/register.go 挂载 15 个命令组,'
|
||||
'每个组的子命令通过 common/runner.go 的 MountShortcut() 自动注册 flags、校验逻辑和错误处理。'
|
||||
'业务模块只需定义 Shortcut 结构体数组。'
|
||||
)
|
||||
|
||||
# 2.3
|
||||
add_heading('2.3 新增 Shortcut 命令', level=2)
|
||||
|
||||
# 2.3.1 Wiki
|
||||
add_heading('2.3.1 Wiki 管理命令', level=3)
|
||||
add_para('新增 wiki 命令组,提供 6 个子命令:')
|
||||
add_table(
|
||||
['命令', '功能', 'DryRun'],
|
||||
[
|
||||
['wiki +list', '列出所有 Wiki 页面', '-'],
|
||||
['wiki +view', '查看指定页面内容', '-'],
|
||||
['wiki +create', '创建 Wiki 页面', 'Yes'],
|
||||
['wiki +update', '更新 Wiki 页面', 'Yes'],
|
||||
['wiki +delete', '删除 Wiki 页面(带二次验证)', 'Yes'],
|
||||
['wiki +lint', '检查 Wiki 内容质量', '-'],
|
||||
]
|
||||
)
|
||||
add_para('技术要点:')
|
||||
add_para('(1)Wiki API 走网关(gateway.gitlink.org.cn/api),而非主 API。')
|
||||
add_para('(2)内容使用 base64 编码传输,outputWithDecodedContent() 自动解码,节省 AI Agent token。')
|
||||
add_para('(3)+delete 有删除后验证机制,GET 确认页面真的被删除了。')
|
||||
add_para('(4)+lint 检查项:空内容、标题层级、内容过短、链接有效性、图片引用。')
|
||||
|
||||
# 2.3.2 Webhook
|
||||
add_heading('2.3.2 Webhook 配置命令', level=3)
|
||||
add_para('新增 webhook 命令组,提供 7 个子命令:')
|
||||
add_table(
|
||||
['命令', '功能', 'DryRun'],
|
||||
[
|
||||
['webhook +list', '列出所有 webhook', '-'],
|
||||
['webhook +create', '创建 webhook', 'Yes'],
|
||||
['webhook +update', '更新 webhook(智能获取当前 URL)', 'Yes'],
|
||||
['webhook +delete', '删除 webhook(双重验证)', 'Yes'],
|
||||
['webhook +test', '测试 webhook 触发', '-'],
|
||||
['webhook +info', '查看 webhook 详情', '-'],
|
||||
['webhook +events', '列出支持的事件类型', '-'],
|
||||
]
|
||||
)
|
||||
add_para('支持 11 种事件类型:push, pull_request, issue, issue_assign, issue_comment, pull_request_assign, pull_request_comment, merge_request, repository, branch, tag。')
|
||||
|
||||
# 2.3.3 Board
|
||||
add_heading('2.3.3 项目看板(Board)', level=3)
|
||||
add_para('新增 board 命令组,提供 6 个子命令:')
|
||||
add_table(
|
||||
['命令', '功能', '实现方式'],
|
||||
[
|
||||
['board +view', '按状态分组显示看板全貌', '基于 issue list API + 客户端按 status_id 分组'],
|
||||
['board +columns', '列出各状态列及 issue 数量', '同上'],
|
||||
['board +issues', '按状态/指派人/优先级筛选任务', '同上,增加过滤逻辑'],
|
||||
['board +move', '移动任务状态', '复用 issue PATCH API'],
|
||||
['board +assign', '指派任务给用户', '先 resolveUserID,再 PATCH'],
|
||||
['board +stats', '完成率、工作负载、瓶颈分析', '统计聚合'],
|
||||
]
|
||||
)
|
||||
add_para(
|
||||
'设计决策:最初计划使用 PM 看板 API(/pm/dashboards),但实际测试发现该端点不存在。'
|
||||
'最终方案基于已有的 issue list API 实现,按 status_id 映射为 5 列'
|
||||
'(待处理/进行中/已解决/已关闭/已拒绝)。'
|
||||
)
|
||||
|
||||
# 2.3.4 Milestone
|
||||
add_heading('2.3.4 里程碑管理(Milestone)', level=3)
|
||||
add_para('新增 milestone 命令组,提供 6 个子命令:')
|
||||
add_table(
|
||||
['命令', '功能'],
|
||||
[
|
||||
['milestone +list', '列出里程碑,支持按状态筛选和排序'],
|
||||
['milestone +create', '创建里程碑'],
|
||||
['milestone +view', '查看里程碑详情及关联 Issue'],
|
||||
['milestone +update', '更新里程碑(自动获取当前值,只覆盖指定字段)'],
|
||||
['milestone +delete', '删除里程碑(先 GET 获取必填字段再 DELETE)'],
|
||||
['milestone +status', '打开或关闭里程碑'],
|
||||
]
|
||||
)
|
||||
|
||||
# 2.3.5 File
|
||||
add_heading('2.3.5 文件操作(File)', level=3)
|
||||
add_para('新增 file 命令组,提供 11 个子命令:')
|
||||
add_table(
|
||||
['命令', '功能', 'DryRun'],
|
||||
[
|
||||
['file +ls', '列出根目录文件', '-'],
|
||||
['file +tree', '查看子目录/文件详情', '-'],
|
||||
['file +read', '读取文件内容', '-'],
|
||||
['file +readme', '读取 README', '-'],
|
||||
['file +search', '按文件名搜索', '-'],
|
||||
['file +create', '创建文件(自动 base64 编码)', 'Yes'],
|
||||
['file +update', '更新文件(自动获取 SHA)', 'Yes'],
|
||||
['file +delete', '删除文件(自动获取 SHA)', 'Yes'],
|
||||
['file +batch', '批量创建/更新/删除文件', 'Yes'],
|
||||
['file +commits', '提交历史', '-'],
|
||||
['file +diff', '查看 commit diff', '-'],
|
||||
]
|
||||
)
|
||||
|
||||
# 2.4
|
||||
add_heading('2.4 优化部分', level=2)
|
||||
|
||||
add_heading('2.4.1 参数设计优化', level=3)
|
||||
add_para(
|
||||
'问题:枚举参数无校验,错误信息延迟到 API 调用才暴露。'
|
||||
'pr +merge --method 接受 merge/rebase/squash,但输入非法值时直接发往 API,'
|
||||
'等到服务端返回 422 才知道参数错了。'
|
||||
)
|
||||
add_para('方案:在 Flag 结构体中增加 Choices 和 Validate 字段,框架层在 parse 阶段自动校验。')
|
||||
add_code(
|
||||
'type Flag struct {\n'
|
||||
' Name string\n'
|
||||
' Choices []string // 枚举校验\n'
|
||||
' Validate func(value string) error // 自定义校验\n'
|
||||
'}'
|
||||
)
|
||||
add_para('同时,Choices 声明后 --help 会自动追加 [merge|rebase|squash],无需手动维护。')
|
||||
add_para('跨参数约束:Shortcut 结构体新增 Validate 字段,支持声明式校验(如"至少提供 --title、--body 或 --state 中的一个")。')
|
||||
|
||||
add_heading('2.4.2 输出格式优化', level=3)
|
||||
add_para('问题:默认格式与帮助文本不一致(帮助说 table,代码默认 json);表格输出无列过滤、长文本被硬截断。')
|
||||
add_para('方案:')
|
||||
add_para('(1)修正默认值为 table。')
|
||||
add_para('(2)引入 PrintOptions 结构体,支持 --columns(列过滤)、--no-truncate(关闭截断)、--no-color(禁用彩色)。')
|
||||
add_para('(3)通过 RuntimeContext 透明传递到所有输出调用。')
|
||||
|
||||
add_heading('2.4.3 错误提示优化', level=3)
|
||||
add_para('问题:中英文混用,fmt.Errorf 不被框架识别,无法输出结构化错误。')
|
||||
add_para('方案:新增 OpError 统一构造函数,入参只需动词和资源名,自动生成英文 Message(给脚本解析)和中文 Suggestion(给用户阅读):')
|
||||
add_code('clierrors.OpError(clierrors.KindServer, "list", "issues", err)\n// 输出:failed to list issues / 获取列表失败,请检查参数或网络连接')
|
||||
|
||||
add_heading('2.4.4 帮助文档优化', level=3)
|
||||
add_para('问题:Shortcut 只有短描述,无使用示例;15 个 Group 命令全部只有一行 Short。')
|
||||
add_para('方案:Shortcut 结构体新增 Long 和 Example 字段,挂载到 cobra 的对应字段;15 个 Group 命令全部补充 Long 描述和使用示例;新增 completion 子命令支持 bash/zsh/fish/powershell 的自动补全。')
|
||||
|
||||
# 2.5
|
||||
add_heading('2.5 批量操作能力增强', level=2)
|
||||
|
||||
add_heading('Issue 批量操作(6 个)', level=3)
|
||||
add_table(
|
||||
['命令', '功能'],
|
||||
[
|
||||
['issue +batch-close', '批量关闭 Issue'],
|
||||
['issue +batch-status', '批量修改状态'],
|
||||
['issue +batch-priority', '批量修改优先级'],
|
||||
['issue +batch-assign', '批量指派负责人'],
|
||||
['issue +batch-label', '批量添加/移除标签'],
|
||||
['issue +batch-create', '批量创建 Issue'],
|
||||
]
|
||||
)
|
||||
|
||||
add_heading('Repo 批量操作(4 个)', level=3)
|
||||
add_table(
|
||||
['命令', '功能'],
|
||||
[
|
||||
['repo +batch-create', '批量创建仓库'],
|
||||
['repo +batch-update', '批量更新仓库设置'],
|
||||
['repo +batch-delete', '批量删除仓库'],
|
||||
['repo +batch-member', '批量邀请/移除成员'],
|
||||
]
|
||||
)
|
||||
|
||||
add_heading('Issue 增强(4 个元数据查询 + 3 个评论管理)', level=3)
|
||||
add_table(
|
||||
['命令', '功能'],
|
||||
[
|
||||
['issue +statuses', '获取所有可用的 Issue 状态'],
|
||||
['issue +authors', '获取发布过 Issue 的用户列表'],
|
||||
['issue +assigners', '获取可被指派的用户列表'],
|
||||
['issue +priorities', '获取所有可用的优先级'],
|
||||
['issue +comment-edit', '编辑 Issue 评论'],
|
||||
['issue +comment-delete', '删除 Issue 评论'],
|
||||
['issue +replies', '查看评论下的回复'],
|
||||
]
|
||||
)
|
||||
|
||||
add_heading('PR 增强(6 个命令 + 2 个评论管理)', level=3)
|
||||
add_table(
|
||||
['命令', '功能'],
|
||||
[
|
||||
['pr +reopen', '重新打开已关闭的 PR'],
|
||||
['pr +update', '更新 PR 标题/描述/分支'],
|
||||
['pr +commits', '查看 PR 中的所有提交'],
|
||||
['pr +versions', '查看 PR 版本历史'],
|
||||
['pr +vdiff', '查看 PR 某版本的 diff'],
|
||||
['pr +filesv1', '查看 PR 变更文件列表(v1 API)'],
|
||||
['pr +comment-edit', '编辑 PR 审查评论'],
|
||||
['pr +comment-delete', '删除 PR 审查评论'],
|
||||
]
|
||||
)
|
||||
|
||||
# 2.6
|
||||
add_heading('2.6 跨平台兼容性与安装体验', level=2)
|
||||
add_para('本项目支持 macOS、Linux、Windows(x64/arm64)三个平台。')
|
||||
add_para('安装方式:')
|
||||
add_para('(1)一键安装脚本:curl -sSL .../install.sh | bash,自动检测平台和架构。')
|
||||
add_para('(2)npm 安装:npm install -g @gitlink-ai/cli,postinstall 自动下载对应平台二进制。')
|
||||
add_para('(3)源码编译:go install . 后手动同步到 npm 包。')
|
||||
add_para('Windows 特殊处理:npm shim(cli.js)调用的是 npm 包内的 gitlink-cli.exe,编译后需要同步覆盖。路径处理兼容 Windows 反斜杠。')
|
||||
|
||||
# 2.7
|
||||
add_heading('2.7 Raw API 封装补全', level=2)
|
||||
add_para('对齐 GitLink OpenAPI,新封装的接口清单:')
|
||||
add_table(
|
||||
['模块', '接口数', '涉及 API 端点'],
|
||||
[
|
||||
['File 操作', '11', 'entries, sub_entries, readme, files, create_file, update_file, delete_file, batch, commits, diff'],
|
||||
['Issue 元数据', '4', 'issue_statues, issue_authors, issue_assigners, issue_priorities'],
|
||||
['Milestone', '6', 'milestones CRUD + update_status'],
|
||||
['PR 增强', '6', 'reopen, update, commits, versions, versions/diff, files(v1)'],
|
||||
['评论管理', '5', 'issue journals CRUD + children_journals, PR journals CRUD'],
|
||||
['合计', '32', '-'],
|
||||
]
|
||||
)
|
||||
|
||||
# 2.8
|
||||
add_heading('2.8 单元测试与命令帮助文档', level=2)
|
||||
|
||||
add_heading('测试文件位置', level=3)
|
||||
add_table(
|
||||
['模块', '测试文件'],
|
||||
[
|
||||
['board', 'shortcuts/board/board_test.go'],
|
||||
['file', 'shortcuts/file/file_test.go'],
|
||||
['milestone', 'shortcuts/milestone/milestone_test.go'],
|
||||
['wiki', 'shortcuts/wiki/wiki_test.go'],
|
||||
['webhook', 'shortcuts/webhook/webhook_test.go'],
|
||||
['issue', 'shortcuts/issue/issue_test.go, batch_test.go, batch_create_test.go'],
|
||||
['pr', 'shortcuts/pr/pr_test.go'],
|
||||
['repo', 'shortcuts/repo/batch_test.go, batch_delete_test.go'],
|
||||
['common', 'shortcuts/common/runner_test.go'],
|
||||
]
|
||||
)
|
||||
|
||||
add_heading('测试方法', level=3)
|
||||
add_para('使用 httptest.NewServer mock API,构造 RuntimeContext 直接调用 Run 函数,验证输出和 PATCH body 内容。')
|
||||
|
||||
add_heading('帮助文档更新', level=3)
|
||||
add_para('(1)所有 Shortcut 的 Long 和 Example 字段已填写。')
|
||||
add_para('(2)15 个 Group 命令补充了详细描述和使用示例。')
|
||||
add_para('(3)新增 completion 子命令支持 4 种 shell 自动补全。')
|
||||
|
||||
# 2.9
|
||||
add_heading('2.9 PR 提交记录与变更说明', level=2)
|
||||
add_table(
|
||||
['PR', '标题', '内容摘要', '状态'],
|
||||
[
|
||||
['#27', '实现场景1:社区运营自动化', 'Webhook 接收器 + 部署 + systemd', '已合并'],
|
||||
['-', 'feat(board): 新增项目看板 shortcut', '6 个看板命令 + 单元测试', '待提交'],
|
||||
['-', 'feat(file): 新增文件操作模块', '11 个文件命令 + 单元测试', '待提交'],
|
||||
['-', 'feat(milestone): 新增里程碑管理', '6 个里程碑命令 + 单元测试', '待提交'],
|
||||
['-', 'feat(wiki): 新增 Wiki 管理命令', '6 个 Wiki 命令 + lint', '待提交'],
|
||||
['-', 'feat(webhook): 新增 Webhook 配置', '7 个 Webhook 命令', '待提交'],
|
||||
['-', 'refactor(shortcuts): 优化命令框架', 'Choices/Validate/PrintOptions/OpError', '待提交'],
|
||||
['-', 'feat(issue): 批量操作 + 元数据查询 + 评论管理', '13 个命令', '待提交'],
|
||||
['-', 'feat(pr): PR 增强 + 评论管理', '8 个命令', '待提交'],
|
||||
]
|
||||
)
|
||||
|
||||
add_para('')
|
||||
add_para('涉及文件变更汇总:', bold=True)
|
||||
add_table(
|
||||
['文件', '改动性质'],
|
||||
[
|
||||
['shortcuts/common/types.go', '结构体扩展(6 个新增字段)'],
|
||||
['shortcuts/common/runner.go', '校验逻辑 + Long/Example 挂载'],
|
||||
['shortcuts/register.go', '注册 board/file/milestone 模块'],
|
||||
['internal/errors/errors.go', 'OpError 统一构造函数'],
|
||||
['internal/output/formatter.go', 'PrintOptions + 列过滤 + 彩色表头'],
|
||||
['cmd/root.go', '3 个 persistent flag + completion 子命令'],
|
||||
['cmd/cmdutil/globals.go', '新增全局变量'],
|
||||
['shortcuts/board/board.go', '新建,6 个命令'],
|
||||
['shortcuts/file/file.go', '新建,11 个命令'],
|
||||
['shortcuts/milestone/milestone.go', '新建,6 个命令'],
|
||||
['shortcuts/wiki/wiki.go', '新建,6 个命令'],
|
||||
['shortcuts/webhook/webhook.go', '新建,7 个命令'],
|
||||
['shortcuts/issue/issue.go', '新增 13 个命令'],
|
||||
['shortcuts/pr/pr.go', '新增 8 个命令'],
|
||||
]
|
||||
)
|
||||
|
||||
add_para('')
|
||||
add_para('验证结果:go build 通过,go test ./... 全部通过。')
|
||||
|
||||
# === 保存 ===
|
||||
output_path = r'C:\Users\Lenovo\Desktop\soft运维\gitlink-cli\doc\任务一\子赛题一报告.docx'
|
||||
doc.save(output_path)
|
||||
print(f'报告已生成: {output_path}')
|
||||
|
|
@ -0,0 +1,645 @@
|
|||
# Raw API 封装修改笔记
|
||||
|
||||
## 第一批:代码/文件操作模块(file)
|
||||
|
||||
### 1. file +ls — 根目录文件列表
|
||||
|
||||
**功能**:列出项目根目录下的文件和子目录。
|
||||
|
||||
**解决思路**:调用 `GET /{owner}/{repo}/entries` API,支持 `--ref` 指定分支。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli file +ls --owner <user> --repo <repo>
|
||||
gitlink-cli file +ls --ref develop
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"entries": [
|
||||
{"name": "README.md", "path": "README.md", "type": "file", "sha": "abc123"},
|
||||
{"name": "src", "path": "src", "type": "dir", "sha": "def456"}
|
||||
],
|
||||
"last_commit": {"message": "update readme", "author": {"name": "user"}},
|
||||
"commits_count": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. file +tree — 子目录/文件详情
|
||||
|
||||
**功能**:查看指定路径的目录结构或文件元信息。
|
||||
|
||||
**解决思路**:调用 `GET /{owner}/{repo}/sub_entries`,`filepath` 参数必填。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli file +tree --path src/
|
||||
gitlink-cli file +tree --path src/main.go --ref v1.0
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"entries": {
|
||||
"name": "main.go",
|
||||
"path": "src/main.go",
|
||||
"type": "file",
|
||||
"size": 1234,
|
||||
"sha": "abc123",
|
||||
"commit": {"message": "add main.go"}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. file +read — 读取文件内容
|
||||
|
||||
**功能**:读取指定文件的实际内容。
|
||||
|
||||
**解决思路**:调用 `GET /{owner}/{repo}/sub_entries`,响应的 `entries.content` 字段包含文件内容。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli file +read --path README.md
|
||||
gitlink-cli file +read --path go.mod --ref develop
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"entries": {
|
||||
"name": "README.md",
|
||||
"path": "README.md",
|
||||
"content": "# Project Title\n\nThis is the content...",
|
||||
"sha": "abc123",
|
||||
"size": 567
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. file +readme — 读取 README
|
||||
|
||||
**功能**:读取项目的 README 文件,支持子目录 README。
|
||||
|
||||
**解决思路**:调用 `GET /{owner}/{repo}/readme`,可选 `filepath` 和 `ref` 参数。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli file +readme
|
||||
gitlink-cli file +readme --path docs/
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"name": "README.md",
|
||||
"content": "# Project Title\n\nDescription...",
|
||||
"sha": "abc123",
|
||||
"encoding": "text"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. file +search — 搜索文件
|
||||
|
||||
**功能**:按文件名关键词搜索。
|
||||
|
||||
**解决思路**:调用 `GET /{owner}/{repo}/files`,`search` 查询参数。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli file +search --q "test"
|
||||
gitlink-cli file +search --q ".go" --ref main
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": [
|
||||
{"name": "main_test.go", "path": "main_test.go", "sha": "abc", "size": 234},
|
||||
{"name": "util_test.go", "path": "util/util_test.go", "sha": "def", "size": 567}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. file +create — 创建文件
|
||||
|
||||
**功能**:在指定分支创建新文件。
|
||||
|
||||
**解决思路**:调用 `POST /{owner}/{repo}/create_file`。注意 `content` 和 `base64_filepath` 都需要 Base64 编码,CLI 自动处理。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli file +create --path docs/new.md --content "# New Doc" --branch master --message "add doc"
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"name": "new.md",
|
||||
"sha": "abc123",
|
||||
"size": 9,
|
||||
"encoding": "base64",
|
||||
"commit": {"message": "add doc", "author": {"name": "user"}}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. file +update — 更新文件
|
||||
|
||||
**功能**:更新已有文件内容。如果未提供 `--sha`,CLI 自动通过 sub_entries API 获取。
|
||||
|
||||
**解决思路**:调用 `PUT /{owner}/{repo}/update_file`。`content` 传明文(非 Base64),需要文件当前 `sha`。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
# 自动获取 sha
|
||||
gitlink-cli file +update --path README.md --content "updated" --branch master --message "update readme"
|
||||
# 手动指定 sha
|
||||
gitlink-cli file +update --path README.md --content "updated" --branch master --sha abc123 --message "update"
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {"status": 1, "message": "更新成功"}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. file +delete — 删除文件
|
||||
|
||||
**功能**:删除指定文件。如果未提供 `--sha`,CLI 自动获取。
|
||||
|
||||
**解决思路**:调用 `DELETE /{owner}/{repo}/delete_file`,需要 `sha`。body 参数(非 query)。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli file +delete --path old-file.txt --branch master
|
||||
gitlink-cli file +delete --path old.txt --branch master --sha abc123
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {"status": 1, "message": "文件删除成功"}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. file +batch — 批量提交文件
|
||||
|
||||
**功能**:在一个 commit 中同时创建/更新/删除多个文件。
|
||||
|
||||
**解决思路**:调用 `POST /v1/{owner}/{repo}/contents/batch`。`--files` 接收 JSON 数组,每个元素含 `action_type`、`file_path`、`content`。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli file +batch --branch master --message "batch update" --files '[
|
||||
{"action_type":"create","file_path":"a.txt","content":"hello"},
|
||||
{"action_type":"update","file_path":"b.txt","content":"world"},
|
||||
{"action_type":"delete","file_path":"c.txt"}
|
||||
]'
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"commit": {"sha": "abc123", "message": "batch update"},
|
||||
"contents": [
|
||||
{"name": "a.txt", "path": "a.txt", "sha": "def"},
|
||||
{"name": "b.txt", "path": "b.txt", "sha": "ghi"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. file +commits — 提交历史
|
||||
|
||||
**功能**:查看项目的提交历史列表。
|
||||
|
||||
**解决思路**:调用 `GET /v1/{owner}/{repo}/commits`,支持分页和 `--ref` 过滤。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli file +commits
|
||||
gitlink-cli file +commits --ref main --page 2 --limit 10
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": [
|
||||
{"sha": "abc123", "message": "fix bug", "author": {"name": "user"}, "authored_date": "2026-07-09"},
|
||||
{"sha": "def456", "message": "add feature", "author": {"name": "user"}, "authored_date": "2026-07-08"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 11. file +diff — 提交 diff
|
||||
|
||||
**功能**:查看某次提交的文件变更 diff。
|
||||
|
||||
**解决思路**:调用 `GET /v1/{owner}/{repo}/commits/{sha}/diff`。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli file +diff --sha abc1234
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"files": [
|
||||
{"filename": "main.go", "status": "modified", "additions": 5, "deletions": 2, "patch": "@@ -10,3 +10,6 @@..."}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `shortcuts/file/file.go` | 新建 | file 模块,11 个 shortcut |
|
||||
| `shortcuts/register.go` | 修改 | 注册 file 模块 |
|
||||
|
||||
---
|
||||
|
||||
## 第二批:Issue 增强 + PR 增强 + 评论管理(21 命令)
|
||||
|
||||
### A. Issue 元数据查询(4 命令)
|
||||
|
||||
### 12. issue +statuses — 疑修状态列表
|
||||
|
||||
**功能**:获取项目所有可用的 Issue 状态(新增、正在解决、已解决、关闭、拒绝)。
|
||||
|
||||
**解决思路**:调用 `GET /v1/{owner}/{repo}/issue_statues`,无参数。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli issue +statuses
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{"ok":true,"data":{"total_count":5,"statues":[{"id":1,"name":"新增"},{"id":2,"name":"正在解决"},{"id":3,"name":"已解决"},{"id":5,"name":"关闭"},{"id":6,"name":"拒绝"}]}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 13. issue +authors — 发布人列表
|
||||
|
||||
**功能**:获取项目中发布过 Issue 的用户列表。
|
||||
|
||||
**解决思路**:调用 `GET /v1/{owner}/{repo}/issue_authors`,支持 `--keyword` 搜索。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli issue +authors
|
||||
gitlink-cli issue +authors --keyword zhang
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{"ok":true,"data":{"total_count":3,"authors":[{"id":1,"name":"张三","login":"zhangsan","type":"User"}]}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 14. issue +assigners — 负责人列表
|
||||
|
||||
**功能**:获取项目中可被指派为负责人的用户列表。
|
||||
|
||||
**解决思路**:调用 `GET /v1/{owner}/{repo}/issue_assigners`,支持 `--keyword` 搜索。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli issue +assigners
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 15. issue +priorities — 优先级列表
|
||||
|
||||
**功能**:获取项目所有可用的 Issue 优先级。
|
||||
|
||||
**解决思路**:调用 `GET /v1/{owner}/{repo}/issue_priorities`,无参数。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli issue +priorities
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{"ok":true,"data":{"total_count":5,"priorities":[{"id":1,"name":"低"},{"id":2,"name":"正常"},{"id":3,"name":"高"},{"id":4,"name":"紧急"}]}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### B. 里程碑管理(6 命令)
|
||||
|
||||
### 16. milestone +list — 里程碑列表
|
||||
|
||||
**功能**:列出项目的里程碑,支持按状态筛选和排序。
|
||||
|
||||
**解决思路**:调用 `GET /v1/{owner}/{repo}/milestones`,支持 `--category`、`--keyword`、`--sort-by`、分页。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli milestone +list
|
||||
gitlink-cli milestone +list --category opening
|
||||
gitlink-cli milestone +list --sort-by issues_count --limit 5
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{"ok":true,"data":{"total_count":3,"opening_milestone_count":2,"closed_milestone_count":1,"milestones":[{"id":1,"name":"v1.0","status":"open","issues_count":10,"percent":60}]}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 17. milestone +create — 创建里程碑
|
||||
|
||||
**功能**:创建新里程碑。
|
||||
|
||||
**解决思路**:调用 `POST /v1/{owner}/{repo}/milestones`,body 必填 `name`、`description`、`effective_date`。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli milestone +create --name "v1.0" --description "First release" --date 2026-12-31
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{"ok":true,"data":{"status":0,"message":"success"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 18. milestone +view — 里程碑详情
|
||||
|
||||
**功能**:查看里程碑详情及其关联的 Issue 列表。
|
||||
|
||||
**解决思路**:调用 `GET /v1/{owner}/{repo}/milestones/{id}`,支持 `--category` 过滤 Issue 状态。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli milestone +view --id 1
|
||||
gitlink-cli milestone +view --id 1 --category opened
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{"ok":true,"data":{"milestone":{"id":1,"name":"v1.0","percent":60},"total_issues_count":10,"issues":[{"id":42,"subject":"Fix login","status_name":"新增"}]}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 19. milestone +update — 更新里程碑
|
||||
|
||||
**功能**:更新里程碑的名称、描述或截止日期。自动获取当前值,只覆盖用户指定的字段。
|
||||
|
||||
**解决思路**:先 `GET` 获取当前里程碑,再 `PATCH` 提交修改后的完整数据。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli milestone +update --id 1 --name "v1.0-rc1"
|
||||
gitlink-cli milestone +update --id 1 --date 2027-01-31
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 20. milestone +delete — 删除里程碑
|
||||
|
||||
**功能**:删除指定里程碑。
|
||||
|
||||
**解决思路**:API 要求 body 中包含 `name`/`description`/`effective_date`,先 GET 获取再 DELETE。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli milestone +delete --id 1 --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 21. milestone +status — 里程碑状态变更
|
||||
|
||||
**功能**:打开或关闭里程碑。
|
||||
|
||||
**解决思路**:调用 `POST /{owner}/{repo}/milestones/{id}/update_status`(注意无 `/v1/` 前缀)。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli milestone +status --id 1 --status closed
|
||||
gitlink-cli milestone +status --id 1 --status opening
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C. PR 增强(6 命令)
|
||||
|
||||
### 22. pr +reopen — 重新打开 PR
|
||||
|
||||
**功能**:重新打开已关闭的 PR。
|
||||
|
||||
**解决思路**:调用 `POST /v1/{owner}/{repo}/pulls/{index}/reopen`,无 body。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli pr +reopen --id 42
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 23. pr +update — 更新 PR
|
||||
|
||||
**功能**:更新 PR 的标题、描述、源分支或目标分支。自动获取当前值,只覆盖用户指定的字段。
|
||||
|
||||
**解决思路**:先 GET 获取当前 PR 数据,再 PUT 提交(API 要求所有字段必填)。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli pr +update --id 42 --title "New title"
|
||||
gitlink-cli pr +update --id 42 --body "Updated description"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 24. pr +commits — PR 提交列表
|
||||
|
||||
**功能**:查看 PR 中的所有提交。
|
||||
|
||||
**解决思路**:调用 `GET /{owner}/{repo}/pulls/{id}/commits`(无 `/v1/` 前缀)。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli pr +commits --id 42
|
||||
```
|
||||
|
||||
**输出参考**:
|
||||
```json
|
||||
{"ok":true,"data":{"commits_count":3,"commits":[{"sha":"abc","message":"fix bug","author":{"name":"user"}}]}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 25. pr +versions — PR 版本列表
|
||||
|
||||
**功能**:查看 PR 的版本历史(每次 push 新提交会生成新版本)。
|
||||
|
||||
**解决思路**:调用 `GET /v1/{owner}/{repo}/pulls/{index}/versions`。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli pr +versions --id 42
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 26. pr +vdiff — PR 版本 diff
|
||||
|
||||
**功能**:查看 PR 某个版本的文件变更 diff。
|
||||
|
||||
**解决思路**:调用 `GET /v1/{owner}/{repo}/pulls/{index}/versions/{version_id}/diff`,支持 `--filepath` 过滤。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli pr +vdiff --id 42 --version 5
|
||||
gitlink-cli pr +vdiff --id 42 --version 5 --filepath main.go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 27. pr +filesv1 — PR 文件列表(v1)
|
||||
|
||||
**功能**:使用 v1 API 查看 PR 变更的文件列表,支持分页。
|
||||
|
||||
**解决思路**:调用 `GET /v1/{owner}/{repo}/pulls/{index}/files`,支持 `--filepath` 过滤和分页。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli pr +filesv1 --id 42
|
||||
gitlink-cli pr +filesv1 --id 42 --filepath src/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### D. 评论管理(5 命令)
|
||||
|
||||
### 28. issue +comment-edit — 编辑 Issue 评论
|
||||
|
||||
**功能**:修改已有 Issue 评论的内容。
|
||||
|
||||
**解决思路**:调用 `PATCH /v1/{owner}/{repo}/issues/{index}/journals/{id}`,body 需 `notes`(复数)和 `attachment_ids`。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli issue +comment-edit --number 42 --comment-id 100 --body "修正后的评论"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 29. issue +comment-delete — 删除 Issue 评论
|
||||
|
||||
**功能**:删除 Issue 的某条评论。
|
||||
|
||||
**解决思路**:调用 `DELETE /v1/{owner}/{repo}/issues/{index}/journals/{id}`。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli issue +comment-delete --number 42 --comment-id 100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 30. issue +replies — 子评论列表
|
||||
|
||||
**功能**:查看某条评论下的所有回复。
|
||||
|
||||
**解决思路**:调用 `GET /v1/{owner}/{repo}/issues/{index}/journals/{id}/children_journals`。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli issue +replies --number 42 --comment-id 100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 31. pr +comment-edit — 编辑 PR 评论
|
||||
|
||||
**功能**:修改 PR 审查评论。注意 body 字段是 `note`(单数,与 Issue 的 `notes` 不同)。
|
||||
|
||||
**解决思路**:调用 `PUT /v1/{owner}/{repo}/pulls/{index}/journals/{id}`,body 需 `note`、`commit_id`、`state`。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli pr +comment-edit --id 42 --comment-id 100 --body "updated" --state resolved
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 32. pr +comment-delete — 删除 PR 评论
|
||||
|
||||
**功能**:删除 PR 的某条审查评论。
|
||||
|
||||
**解决思路**:调用 `DELETE /v1/{owner}/{repo}/pulls/{index}/journals/{id}`。
|
||||
|
||||
**代码指令**:
|
||||
```bash
|
||||
gitlink-cli pr +comment-delete --id 42 --comment-id 100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `shortcuts/file/file.go` | 新建 | file 模块,11 个 shortcut |
|
||||
| `shortcuts/issue/issue.go` | 修改 | 新增 7 个命令(4 元数据 + 3 评论管理) |
|
||||
| `shortcuts/milestone/milestone.go` | 新建 | milestone 模块,6 个 shortcut |
|
||||
| `shortcuts/pr/pr.go` | 修改 | 新增 8 个命令(6 PR 增强 + 2 评论管理) |
|
||||
| `shortcuts/register.go` | 修改 | 注册 file + milestone 模块 |
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
子赛题一:增加和完善GitLink-CLI能力
|
||||
定位:扩展CLI功能丨难度:中高丨需要Go语言基础
|
||||
为gitlink-cli增加新功能或优化现有功能,包括但不限于:
|
||||
·新增Shortcut命令(如Wiki管理、Webhook配置、项目看板增强)
|
||||
·优化现有命令的参数设计、输出格式、错误提示和帮助文档
|
||||
·增加批量操作能力(如批量Issue操作、批量仓库管理、批量成员邀请)
|
||||
·提升跨平台兼容性和安装体验
|
||||
·补全RawAPI封装(对齐GitLinkOpenAPI中尚未封装的接口)
|
||||
交付要求:
|
||||
·向gitlink-cli主仓库提交PR(可以是多个)
|
||||
·每个PR包含:功能代码+单元测试+命令帮助文档更新
|
||||
·提供变更说明文档
|
||||
|
||||
|
||||
|
||||
模板:
|
||||
第二章 子赛题一:增强与完善 GitLink-CLI 能力
|
||||
2.1 任务目标与整体思路
|
||||
【占位】说明本子赛题的定位(扩展 CLI 功能)、你选择的切入方向、整体设计思路。
|
||||
2.2 三层命令体系架构
|
||||
【占位】用一段话+架构图说明:Shortcuts(+前缀)→ API Commands(元数据驱动)→ Raw API 三层结构,以及本次工作落在哪一层。
|
||||
【占位】此处插入架构图(三层命令体系)。
|
||||
2.3 新增 Shortcut 命令
|
||||
2.3.1 Wiki 管理命令
|
||||
【占位】列出 wiki +list/+view/+create/+update/+delete,说明参数设计、输出格式、使用示例。
|
||||
2.3.2 Webhook 配置命令
|
||||
【占位】列出 webhook +list/+create/+update/+test/+delete/+events/+info,说明设计要点与示例。
|
||||
2.3.3 项目看板
|
||||
【占位】列出 milestone 相关命令及使用示例
|
||||
2.4优化部分
|
||||
2.5批量操作能力增强 都有哪些,,按大类区分
|
||||
2.6 跨平台兼容性与安装体验
|
||||
【占位】Windows PowerShell 脚本、路径处理、一键安装脚本、npm 安装等改进点。
|
||||
2.7 Raw API 封装补全
|
||||
【占位】列出对齐 GitLink OpenAPI 新封装的接口清单。
|
||||
2.8 单元测试与命令帮助文档
|
||||
【占位】测试文件位置、覆盖率、关键用例;help 文本与示例更新说明。
|
||||
2.9 PR 提交记录与变更说明
|
||||
【占位】以表格列出各 PR:编号、标题、内容摘要、合并状态、链接。
|
||||
|
|
@ -0,0 +1,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
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 为 %s,npm/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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 —
|
||||
仓库:4 个 —
|
||||
贡献者:0 人 —
|
||||
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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">科研项目洞察报告 — 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 — 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>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "gitlink-cli",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
PR Test 2026年 4月 7日 星期二 11时45分56秒 CST
|
||||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,427 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// v1RepoPath returns /v1/{owner}/{repo}
|
||||
func v1RepoPath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// === 浏览类 ===
|
||||
{
|
||||
Name: "ls",
|
||||
Description: "List files in root directory",
|
||||
Flags: []common.Flag{
|
||||
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
if ref := ctx.Arg("ref"); ref != "" {
|
||||
q.Set("ref", ref)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/entries", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取目录列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tree",
|
||||
Description: "Show subdirectory or file details",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File or directory path", Required: true},
|
||||
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filepath, err := ctx.RequireArg("path", "--path src/main.go")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("filepath", filepath)
|
||||
if ref := ctx.Arg("ref"); ref != "" {
|
||||
q.Set("ref", ref)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取路径详情失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read",
|
||||
Description: "Read file content",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path", Required: true},
|
||||
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filepath, err := ctx.RequireArg("path", "--path README.md")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("filepath", filepath)
|
||||
if ref := ctx.Arg("ref"); ref != "" {
|
||||
q.Set("ref", ref)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "readme",
|
||||
Description: "Read README file",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Usage: "Subdirectory path for nested README"},
|
||||
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
if p := ctx.Arg("path"); p != "" {
|
||||
q.Set("filepath", p)
|
||||
}
|
||||
if ref := ctx.Arg("ref"); ref != "" {
|
||||
q.Set("ref", ref)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/readme", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 README 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "search",
|
||||
Description: "Search files by name",
|
||||
Flags: []common.Flag{
|
||||
{Name: "q", Short: "q", Usage: "Search keyword"},
|
||||
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
if kw := ctx.Arg("q"); kw != "" {
|
||||
q.Set("search", kw)
|
||||
}
|
||||
if ref := ctx.Arg("ref"); ref != "" {
|
||||
q.Set("ref", ref)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("搜索文件失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// === 文件 CRUD ===
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new file",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
return fmt.Sprintf("创建文件 %s", ctx.Arg("path")), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "File content (plain text)", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "new-branch", Usage: "Create on a new branch"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filepath, err := ctx.RequireArg("path", "--path docs/new.md")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content", `--content "# Hello"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
branch, err := ctx.RequireArg("branch", "--branch master")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := ctx.RequireArg("message", `--message "add new file"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"filepath": filepath,
|
||||
"base64_filepath": base64.StdEncoding.EncodeToString([]byte(filepath)),
|
||||
"branch": branch,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
"message": message,
|
||||
}
|
||||
if nb := ctx.Arg("new-branch"); nb != "" {
|
||||
body["new_branch"] = nb
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/create_file", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update an existing file",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
return fmt.Sprintf("更新文件 %s", ctx.Arg("path")), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "New file content (plain text)", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
|
||||
{Name: "sha", Usage: "File SHA (auto-fetched if omitted)"},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "new-branch", Usage: "Create on a new branch"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filepath, err := ctx.RequireArg("path", "--path README.md")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content", `--content "updated content"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
branch, err := ctx.RequireArg("branch", "--branch master")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := ctx.RequireArg("message", `--message "update file"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Auto-fetch sha if not provided
|
||||
sha := ctx.Arg("sha")
|
||||
if sha == "" {
|
||||
sha, err = fetchFileSha(ctx, filepath, branch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("自动获取文件 SHA 失败,请用 --sha 手动指定: %w", err)
|
||||
}
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"filepath": filepath,
|
||||
"branch": branch,
|
||||
"content": content,
|
||||
"sha": sha,
|
||||
"message": message,
|
||||
}
|
||||
if nb := ctx.Arg("new-branch"); nb != "" {
|
||||
body["new_branch"] = nb
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", ctx.RepoPath()+"/update_file", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新文件失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a file",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
return fmt.Sprintf("删除文件 %s", ctx.Arg("path")), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
|
||||
{Name: "sha", Usage: "File SHA (auto-fetched if omitted)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filepath, err := ctx.RequireArg("path", "--path old-file.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
branch, err := ctx.RequireArg("branch", "--branch master")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Auto-fetch sha if not provided
|
||||
sha := ctx.Arg("sha")
|
||||
if sha == "" {
|
||||
sha, err = fetchFileSha(ctx, filepath, branch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("自动获取文件 SHA 失败,请用 --sha 手动指定: %w", err)
|
||||
}
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"filepath": filepath,
|
||||
"branch": branch,
|
||||
"sha": sha,
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/delete_file", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除文件失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "batch",
|
||||
Description: "Batch create/update/delete files in one commit",
|
||||
DryRun: true,
|
||||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||||
return fmt.Sprintf("批量提交文件到分支 %s", ctx.Arg("branch")), nil
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "files", Short: "f", Usage: "JSON array: [{\"action_type\":\"create\",\"file_path\":\"x\",\"content\":\"y\"}]", Required: true},
|
||||
{Name: "new-branch", Usage: "Create on a new branch"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
branch, err := ctx.RequireArg("branch", "--branch master")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := ctx.RequireArg("message", `--message "batch update"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filesJSON, err := ctx.RequireArg("files", `--files '[{"action_type":"create","file_path":"a.txt","content":"hello"}]'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var files []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(filesJSON), &files); err != nil {
|
||||
return fmt.Errorf("--files JSON 解析失败: %w", err)
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"branch": branch,
|
||||
"message": message,
|
||||
"files": files,
|
||||
}
|
||||
if nb := ctx.Arg("new-branch"); nb != "" {
|
||||
body["new_branch"] = nb
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/contents/batch", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("批量提交失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// === Git 对象 ===
|
||||
{
|
||||
Name: "commits",
|
||||
Description: "List commit history",
|
||||
Flags: []common.Flag{
|
||||
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
if ref := ctx.Arg("ref"); ref != "" {
|
||||
q.Set("ref", ref)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/commits", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取提交历史失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "diff",
|
||||
Description: "Show diff of a commit",
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha", "--sha abc1234")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/commits/%s/diff", v1RepoPath(ctx), sha), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 diff 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// fetchFileSha retrieves the current SHA of a file via sub_entries API.
|
||||
func fetchFileSha(ctx *common.RuntimeContext, filepath, ref string) (string, error) {
|
||||
q := url.Values{}
|
||||
q.Set("filepath", filepath)
|
||||
if ref != "" {
|
||||
q.Set("ref", ref)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("响应格式异常")
|
||||
}
|
||||
entries, ok := data["entries"].(map[string]interface{})
|
||||
if !ok {
|
||||
// Try flat structure
|
||||
if sha, ok := data["sha"].(string); ok {
|
||||
return sha, nil
|
||||
}
|
||||
return "", fmt.Errorf("响应中未找到 entries 或 sha 字段")
|
||||
}
|
||||
sha, _ := entries["sha"].(string)
|
||||
if sha == "" {
|
||||
return "", fmt.Errorf("文件 SHA 为空")
|
||||
}
|
||||
return sha, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,459 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// === 浏览类测试 ===
|
||||
|
||||
func TestLsCallsEntriesEndpoint(t *testing.T) {
|
||||
var requestedPath string
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"entries": []interface{}{
|
||||
map[string]interface{}{"name": "README.md", "type": "file"},
|
||||
},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "ls", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("ls failed: %v", err)
|
||||
}
|
||||
assertPath(t, requestedPath, "/owner/repo/entries.json")
|
||||
}
|
||||
|
||||
func TestLsWithRef(t *testing.T) {
|
||||
var requestedRef string
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedRef = r.URL.Query().Get("ref")
|
||||
writeJSON(t, w, map[string]interface{}{"entries": []interface{}{}})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "ls", map[string]string{"ref": "develop"})
|
||||
if err != nil {
|
||||
t.Fatalf("ls with ref failed: %v", err)
|
||||
}
|
||||
assertEqual(t, requestedRef, "develop")
|
||||
}
|
||||
|
||||
func TestTreeCallsSubEntriesWithFilePath(t *testing.T) {
|
||||
var requestedPath, requestedFilepath string
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPath = r.URL.Path
|
||||
requestedFilepath = r.URL.Query().Get("filepath")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"entries": map[string]interface{}{
|
||||
"name": "main.go", "type": "file", "sha": "abc123",
|
||||
},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "tree", map[string]string{"path": "src/main.go"})
|
||||
if err != nil {
|
||||
t.Fatalf("tree failed: %v", err)
|
||||
}
|
||||
assertPath(t, requestedPath, "/owner/repo/sub_entries.json")
|
||||
assertEqual(t, requestedFilepath, "src/main.go")
|
||||
}
|
||||
|
||||
func TestReadCallsSubEntriesWithFilePath(t *testing.T) {
|
||||
var requestedFilepath string
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedFilepath = r.URL.Query().Get("filepath")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"entries": map[string]interface{}{
|
||||
"name": "main.go", "content": "package main", "sha": "abc123",
|
||||
},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "read", map[string]string{"path": "main.go"})
|
||||
if err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
assertEqual(t, requestedFilepath, "main.go")
|
||||
}
|
||||
|
||||
func TestReadmeCallsReadmeEndpoint(t *testing.T) {
|
||||
var requestedPath string
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"name": "README.md", "content": "# Hello",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "readme", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("readme failed: %v", err)
|
||||
}
|
||||
assertPath(t, requestedPath, "/owner/repo/readme.json")
|
||||
}
|
||||
|
||||
func TestSearchCallsFilesWithKeyword(t *testing.T) {
|
||||
var requestedPath, requestedSearch string
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPath = r.URL.Path
|
||||
requestedSearch = r.URL.Query().Get("search")
|
||||
writeJSON(t, w, []interface{}{
|
||||
map[string]interface{}{"name": "test.go"},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "search", map[string]string{"q": "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("search failed: %v", err)
|
||||
}
|
||||
assertPath(t, requestedPath, "/owner/repo/files.json")
|
||||
assertEqual(t, requestedSearch, "test")
|
||||
}
|
||||
|
||||
// === 文件 CRUD 测试 ===
|
||||
|
||||
func TestCreateEncodesContentBase64(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"name": "new.txt", "sha": "abc123",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "create", map[string]string{
|
||||
"path": "docs/new.txt",
|
||||
"content": "hello world",
|
||||
"branch": "master",
|
||||
"message": "add file",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify content is base64 encoded
|
||||
encodedContent, ok := payload["content"].(string)
|
||||
if !ok {
|
||||
t.Fatal("content should be a string")
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(encodedContent)
|
||||
if err != nil {
|
||||
t.Fatalf("content is not valid base64: %v", err)
|
||||
}
|
||||
assertEqual(t, string(decoded), "hello world")
|
||||
|
||||
// Verify filepath is base64 encoded
|
||||
encodedPath, ok := payload["base64_filepath"].(string)
|
||||
if !ok {
|
||||
t.Fatal("base64_filepath should be a string")
|
||||
}
|
||||
decodedPath, err := base64.StdEncoding.DecodeString(encodedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("base64_filepath is not valid base64: %v", err)
|
||||
}
|
||||
assertEqual(t, string(decodedPath), "docs/new.txt")
|
||||
|
||||
assertEqual(t, payload["filepath"], "docs/new.txt")
|
||||
assertEqual(t, payload["branch"], "master")
|
||||
assertEqual(t, payload["message"], "add file")
|
||||
}
|
||||
|
||||
func TestUpdateAutoFetchesSha(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/sub_entries.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"entries": map[string]interface{}{
|
||||
"name": "README.md", "sha": "old-sha-123",
|
||||
},
|
||||
})
|
||||
case r.Method == "PUT" && r.URL.Path == "/owner/repo/update_file.json":
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": float64(1), "message": "更新成功"})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "update", map[string]string{
|
||||
"path": "README.md",
|
||||
"content": "updated content",
|
||||
"branch": "master",
|
||||
"message": "update readme",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, updatePayload["sha"], "old-sha-123")
|
||||
assertEqual(t, updatePayload["content"], "updated content")
|
||||
assertEqual(t, updatePayload["filepath"], "README.md")
|
||||
}
|
||||
|
||||
func TestUpdateUsesProvidedSha(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "PUT" {
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": float64(1)})
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "update", map[string]string{
|
||||
"path": "README.md",
|
||||
"content": "new content",
|
||||
"branch": "master",
|
||||
"sha": "manual-sha",
|
||||
"message": "update",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update with sha failed: %v", err)
|
||||
}
|
||||
assertEqual(t, updatePayload["sha"], "manual-sha")
|
||||
}
|
||||
|
||||
func TestDeleteAutoFetchesSha(t *testing.T) {
|
||||
var deletePayload map[string]interface{}
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"entries": map[string]interface{}{
|
||||
"name": "old.txt", "sha": "file-sha-456",
|
||||
},
|
||||
})
|
||||
case r.Method == "DELETE":
|
||||
deletePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": float64(1), "message": "文件删除成功"})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "delete", map[string]string{
|
||||
"path": "old.txt",
|
||||
"branch": "master",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, deletePayload["sha"], "file-sha-456")
|
||||
assertEqual(t, deletePayload["filepath"], "old.txt")
|
||||
assertEqual(t, deletePayload["branch"], "master")
|
||||
}
|
||||
|
||||
func TestBatchSendsFilesArray(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"commit": map[string]interface{}{"sha": "abc"},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "batch", map[string]string{
|
||||
"branch": "master",
|
||||
"message": "batch commit",
|
||||
"files": `[{"action_type":"create","file_path":"a.txt","content":"hello"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch failed: %v", err)
|
||||
}
|
||||
|
||||
files, ok := payload["files"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("files should be array, got %T", payload["files"])
|
||||
}
|
||||
assertEqual(t, len(files), 1)
|
||||
assertEqual(t, payload["branch"], "master")
|
||||
assertEqual(t, payload["message"], "batch commit")
|
||||
}
|
||||
|
||||
func TestBatchRejectsInvalidJSON(t *testing.T) {
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, map[string]interface{}{})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "batch", map[string]string{
|
||||
"branch": "master",
|
||||
"message": "test",
|
||||
"files": "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// === Git 对象测试 ===
|
||||
|
||||
func TestCommitsCallsV1Endpoint(t *testing.T) {
|
||||
var requestedPath string
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPath = r.URL.Path
|
||||
writeJSON(t, w, []interface{}{
|
||||
map[string]interface{}{"sha": "abc", "message": "test"},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "commits", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("commits failed: %v", err)
|
||||
}
|
||||
assertPath(t, requestedPath, "/v1/owner/repo/commits.json")
|
||||
}
|
||||
|
||||
func TestCommitsPassesPagination(t *testing.T) {
|
||||
var page, limit string
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
page = r.URL.Query().Get("page")
|
||||
limit = r.URL.Query().Get("limit")
|
||||
writeJSON(t, w, []interface{}{})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "commits", map[string]string{"page": "3", "limit": "10"})
|
||||
if err != nil {
|
||||
t.Fatalf("commits with pagination failed: %v", err)
|
||||
}
|
||||
assertEqual(t, page, "3")
|
||||
assertEqual(t, limit, "10")
|
||||
}
|
||||
|
||||
func TestDiffCallsCommitDiffEndpoint(t *testing.T) {
|
||||
var requestedPath string
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"files": []interface{}{},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "diff", map[string]string{"sha": "abc1234"})
|
||||
if err != nil {
|
||||
t.Fatalf("diff failed: %v", err)
|
||||
}
|
||||
assertPath(t, requestedPath, "/v1/owner/repo/commits/abc1234/diff.json")
|
||||
}
|
||||
|
||||
// === 必填参数验证 ===
|
||||
|
||||
func TestCreateRequiresPath(t *testing.T) {
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "create", map[string]string{
|
||||
"content": "hello", "branch": "master", "message": "test",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeRequiresPath(t *testing.T) {
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "tree", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffRequiresSha(t *testing.T) {
|
||||
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "diff", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --sha")
|
||||
}
|
||||
}
|
||||
|
||||
// === helpers ===
|
||||
|
||||
func runFileShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findFileShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findFileShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
t.Helper()
|
||||
for _, s := range Shortcuts() {
|
||||
if s.Name == name {
|
||||
return s
|
||||
}
|
||||
}
|
||||
t.Fatalf("file shortcut %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newFileTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||
t.Helper()
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode JSON failed: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("write JSON failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, got, want interface{}) {
|
||||
t.Helper()
|
||||
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
|
||||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPath(t *testing.T, got, want string) {
|
||||
t.Helper()
|
||||
if got != want {
|
||||
t.Fatalf("path: got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]) + "..."
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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)或 --from(CSV文件路径)")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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` — 正则提取外部图片 URL,HEAD 请求检查可达性(超时 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`
|
||||
|
|
@ -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/repo,值:project_id(字符串)
|
||||
// 作用:避免重复调用项目详情 API 获取 project_id
|
||||
var projectIDCache sync.Map
|
||||
|
||||
// wikiPath 构建 Wiki API 的路径前缀
|
||||
// 参数: endpoint - API 端点(如 "pages", "getWiki")
|
||||
// 返回: 完整路径,如 "/wiki/open/pages"
|
||||
// 说明:Wiki 功能调用的是网关 API,所有接口都有固定前缀 "/wiki/open/"
|
||||
func wikiPath(endpoint string) string {
|
||||
return "/wiki/open/" + endpoint
|
||||
}
|
||||
|
||||
// getGatewayClient 创建 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% token(base64 编码膨胀部分)
|
||||
//
|
||||
// 已知字段映射:
|
||||
// - 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?://[^)]+)\)`) // 匹配 
|
||||
)
|
||||
|
||||
// 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. 使用正则匹配所有图片链接 
|
||||
// 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. 解析实际 pageName(fetchPageContent 内部自动重试 ".-" 后缀)
|
||||
// 不做这步直接用 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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.CLIError,code=400 对应 KindForbidden(401/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", "", httpClient); issues != nil {
|
||||
t.Errorf("200 image should not produce issues, got %+v", issues)
|
||||
}
|
||||
// Broken image (404)
|
||||
issues := checkImages("p1", "", 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", "", 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
- ✅ 自动执行代码审查
|
||||
|
|
|
|||
|
|
@ -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) — 认证和全局参数
|
||||
|
|
@ -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) — 认证和全局参数
|
||||
|
|
@ -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) — 认证和全局参数
|
||||
|
|
@ -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) — 认证和全局参数
|
||||
|
|
@ -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) — 认证和全局参数
|
||||
|
|
@ -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-changelog(Release 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 操作
|
||||
|
|
@ -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) — 生成和发布
|
||||
|
|
@ -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) — 分类规则速查表
|
||||
|
|
@ -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/Issue,commits 用全量最近提交
|
||||
- `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
Loading…
Reference in New Issue