forked from chroe/gitlink-cli
Compare commits
2 Commits
master
...
feat/task-
| Author | SHA1 | Date |
|---|---|---|
|
|
b9f54cb50a | |
|
|
b9f4d13a90 |
|
|
@ -1,31 +0,0 @@
|
|||
version: 2
|
||||
name: 构建部署Showcase
|
||||
description: "代码提交自动触发:在服务器上拉取代码、构建Docker镜像并部署"
|
||||
global:
|
||||
concurrent: 1
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: push
|
||||
ruleset-operator: AND
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
- ref: ssh_cmd_0
|
||||
name: SSH部署到服务器
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_pass: ((deploy_server.server_password))
|
||||
ssh_ip: '"118.31.4.168"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_cmd: >-
|
||||
"cd /opt/gitlink-cli && git fetch origin && git reset --hard origin/master && docker build -f showcase/Dockerfile -t gitlink-cli-showcase . && docker stop gitlink-cli-showcase || true && docker rm gitlink-cli-showcase || true && docker run -d -p 9090:9090 --name gitlink-cli-showcase --restart unless-stopped -e GITLINK_TOKEN='cookie:autologin_trustie=56c6d2b4378588465c97d48679eed7bd2495ff1a' gitlink-cli-showcase"
|
||||
needs:
|
||||
- start
|
||||
- ref: end
|
||||
name: 结束
|
||||
task: end
|
||||
needs:
|
||||
- ssh_cmd_0
|
||||
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26'
|
||||
go-version: '1.22'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
|
@ -36,9 +36,7 @@ jobs:
|
|||
"linux amd64" \
|
||||
"linux arm64" \
|
||||
"windows amd64" \
|
||||
"windows arm64" \
|
||||
"freebsd amd64" \
|
||||
"freebsd arm64"; do
|
||||
"windows arm64"; do
|
||||
GOOS=$(echo "$pair" | cut -d' ' -f1)
|
||||
GOARCH=$(echo "$pair" | cut -d' ' -f2)
|
||||
OUT="gitlink-cli"
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
bin/
|
||||
dist/
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Output of go coverage
|
||||
*.out
|
||||
|
||||
# Go workspace
|
||||
go.work
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
# 实验踩坑记录
|
||||
|
||||
> 课程:《软件演化与运维》进阶任务 — 子任务二:编写和丰富 GitLink Skills
|
||||
> 记录时间:2026-06-09 ~
|
||||
> 截止日期:2026-06-18
|
||||
|
||||
---
|
||||
|
||||
## 一、开发环境信息
|
||||
|
||||
| 项目 | 信息 |
|
||||
|------|------|
|
||||
| OS | Windows 11 Home China 10.0.26200 |
|
||||
| Go | 1.26.1 |
|
||||
| gitlink-cli | dev 版(本地构建) |
|
||||
| Git 安装路径 | `D:/自用/奇奇怪怪的软件/Git/` |
|
||||
| 认证方式 | cookie (auth login) |
|
||||
| 测试项目 | chroe/gitlink-cli |
|
||||
|
||||
---
|
||||
|
||||
## 二、踩坑记录
|
||||
|
||||
### ✅ 坑 1:`api` 子命令 URL 污染(已修复)
|
||||
|
||||
**发现时间**:2026-06-09,**修复时间**:2026-06-13
|
||||
**影响范围**:所有使用 `gitlink-cli api GET/POST` 的 Skill
|
||||
**现象**:
|
||||
```bash
|
||||
gitlink-cli --debug api GET /v1/chroe/gitlink-cli/issues/1 --format json
|
||||
# 实际请求 URL:
|
||||
# https://www.gitlink.org.cn/api/D:/自用/奇奇怪怪的软件/Git/v1/chroe/gitlink-cli/issues/1.json
|
||||
# ^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
# git exec-path 被注入!
|
||||
```
|
||||
**根因**:CLI 的 `api` 子命令在构建 URL 时,错误地把 `git --exec-path` 的输出拼入了 API 路径。Shortcut 命令(如 `issue +list`)内部自行构建路径所以不受影响。
|
||||
**根因**:Windows Git Bash (MSYS2) 的路径自动转换。用户输入 `/v1/owner/repo`,MSYS2 把以 `/` 开头的参数当成 Unix 绝对路径,自动转换成 git 安装目录 `D:/自用/奇奇怪怪的软件/Git/v1/owner/repo`。
|
||||
**修复**:在 `cmd/api/api.go` 的 `runAPI` 中加 MSYS2 路径检测——如果 path 以 Windows 盘符开头(如 `D:/`),则查找 `/v1/`、`/api/` 等常见 API 前缀并截断还原。已修复,`api` 命令现在正常工作。
|
||||
**额外发现**:Issue 更新接口要用 **PATCH** 方法,POST 返回 404。
|
||||
|
||||
---
|
||||
|
||||
### ✅ 坑 2:`issue +update` 不支持标签/责任人/优先级(已有替代方案)
|
||||
|
||||
**发现时间**:2026-06-09,**修复时间**:2026-06-13
|
||||
|
||||
**发现时间**:2026-06-09
|
||||
**现象**:`issue +update` 只有 `--title`、`--body`、`--state` 三个参数,不支持 `--tags`、`--assignee`、`--priority`
|
||||
**影响**:triage Skill 的核心写入操作(打标签、分配责任人)无法通过 shortcut 执行
|
||||
**替代方案**(已验证可用):用 `api PATCH /v1/<owner>/<repo>/issues/<num>` 配合 `--body` 传递 JSON,可同时打标签/分配责任人/设置优先级。坑 1 修复后此方案完全可用。
|
||||
**实测**:对 chroe/gitlink-cli Issue #7 执行 `api PATCH` 成功打标签「缺陷」+ 分配 chroe + 优先级「高」。
|
||||
**永久修复(可选)**:扩展 `issue +update` 命令添加 `--tags`、`--assignee`、`--priority-id` 参数更易用。
|
||||
|
||||
---
|
||||
|
||||
### ✅ 坑 2.5:责任人字段名是 `assigner_ids` 不是 `assigned_to_id`(关键发现)
|
||||
|
||||
**发现时间**:2026-06-14
|
||||
**现象**:项目记录里写"GitLink API 不支持 assign",实测发现是**字段名错了**:
|
||||
- ❌ `assigned_to_id`(单个数字)→ PATCH 返回 ok=true 但 assigners 仍为空
|
||||
- ✅ `assigner_ids`(数组)→ PATCH 后 assigners 立即生效
|
||||
**教训**:之前因为旧记录说"不支持"就放弃了,没深入测试。GitLink 其实完全支持分配责任人,只是字段名跟常见 REST API 不同。
|
||||
**修复**:triage Skill 中所有 `assigned_to_id` 改为 `assigner_ids`。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 坑 3:`issue +list --state open` 过滤不生效
|
||||
|
||||
**发现时间**:2026-06-09
|
||||
**现象**:`--state open` 返回的 `issues` 列表包含已关闭的 Issue(status_id=5),但 `opened_count` 字段是准确的
|
||||
**影响**:AI 执行 triage 时可能处理已关闭的 Issue
|
||||
**解决方案**:客户端过滤 `status.id !== 5`(status_id=5 是关闭状态)
|
||||
|
||||
---
|
||||
|
||||
### 🟡 坑 4:compare API 全面返回 HTML
|
||||
|
||||
**发现时间**:2026-06-09
|
||||
**现象**:不仅是部分项目,所有项目的 compare API 都返回 HTML 而非 JSON
|
||||
**影响**:changelog Skill 无法使用 compare API 对比版本差异
|
||||
**解决方案**:改用 `commit +list` 按时间戳筛选版本区间内的提交
|
||||
|
||||
---
|
||||
|
||||
### 🟡 坑 5:journals API 全面返回 HTML
|
||||
|
||||
**发现时间**:2026-06-09(之前以为是"部分项目")
|
||||
**现象**:所有项目的 journals API(`/v1/:owner/:repo/issues/:number/journals`)都返回 HTML
|
||||
**影响**:无法获取 Issue 评论的详细内容和时间
|
||||
**解决方案**:统一使用 `comment_journals_count` 字段判断是否有响应(>0 表示有人响应)
|
||||
|
||||
---
|
||||
|
||||
### 🟢 坑 6:changelog 样式分类与输出格式不一致
|
||||
|
||||
**发现时间**:2026-06-09
|
||||
**现象**:SKILL.md 分类规则表有"💄 样式"类,但 Release Notes 输出模板没有样式节
|
||||
**解决方案**:标注样式类 commit 归入"改进优化"分类
|
||||
|
||||
---
|
||||
|
||||
## 三、验证记录
|
||||
|
||||
### gitlink-changelog 验证(2026-06-09 二次复测)
|
||||
|
||||
| # | 命令 | 结果 | 备注 |
|
||||
|---|------|------|------|
|
||||
| 1 | `release +list --format json` | ✅ | 1 个版本 v0.1.13-freebsd |
|
||||
| 2 | `commit +list --format json` | ✅ | 20 条,基线后 20 条 |
|
||||
| 3 | `pr +list --state merged` | ✅ | 0 个(Fork 仓库) |
|
||||
| 4 | `issue +list --state closed` | ✅ | 6 个已关闭 |
|
||||
| 5 | `release +view --id 1986` | ✅ | 正常返回 |
|
||||
| 6 | `release +create --help` | ✅ | 参数完整可用 |
|
||||
| 7 | ~~`api GET /compare/...`~~ | ❌ 返回 HTML | 已在 SKILL.md 标注不可用 |
|
||||
| 8 | Commit 分类 20 条 | ✅ 20/20 正确 | 含 4 条正确跳过 |
|
||||
| 9 | Release Notes 生成 | ✅ | 格式完整,含真实数据 |
|
||||
|
||||
### gitlink-triage 验证(2026-06-09 二次复测,上游仓库)
|
||||
|
||||
| # | 命令 | 结果 | 备注 |
|
||||
|---|------|------|------|
|
||||
| 1 | `issue +list --state open`(上游 gitlink/gitlink-cli) | ⚠️ | 列表 18 条含已关闭,需客户端过滤 |
|
||||
| 2 | `issue +view --number N`(6 个 Issue) | ✅ | 全部返回详情 |
|
||||
| 3 | `label +list`(上游) | ✅ | 10 个中文标签 |
|
||||
| 4 | `member +list`(上游) | ✅ | 46 个成员 |
|
||||
| 5 | `label +create --help` | ✅ | 可用 |
|
||||
| 6 | `label +update --help` | ✅ | 可用 |
|
||||
| 7 | `issue +comment --help` | ✅ | 可用 |
|
||||
| 8 | `issue +update --state` | ✅ | 仅 title/body/state |
|
||||
| 9 | ~~`api GET /journals`~~ | ❌ 返回 HTML | 已在 SKILL.md 标注 |
|
||||
| 10 | ~~`api POST /issues/N`~~ | ❌ 404 | URL 污染 bug,已标注 |
|
||||
| 11 | Issue 分类 6 条 | ✅ 6/6 正确 | 上游真实 Issue |
|
||||
| 12 | Good First Issue 评估 | ✅ | 识别出 2 个 GFI(#14, #18) |
|
||||
|
||||
---
|
||||
|
||||
## 四、开发 Skill 的正确流程(血的教训)
|
||||
|
||||
```
|
||||
1. 确定 Skill 场景
|
||||
2. 写 SKILL.md
|
||||
3. ⚠️ 在终端中逐条执行 SKILL.md 中的每一条命令
|
||||
- 确认返回 JSON 而非 HTML
|
||||
- 确认写入操作 API 路径正确
|
||||
- 确认 Raw API URL 不被 git 路径污染
|
||||
4. 修复发现的问题
|
||||
5. 写 REFERENCE.md
|
||||
6. 写 examples(用真实数据)
|
||||
7. 在 Claude Code 中运行验证
|
||||
8. 写 VERIFICATION.md
|
||||
```
|
||||
|
||||
> **核心原则**:「AI 看着合理」≠「实际能跑」。必须真机验证。
|
||||
|
|
@ -1,404 +0,0 @@
|
|||
# GitLink-CLI 项目状态总结
|
||||
|
||||
> 最后更新:2026-06-08
|
||||
> 仓库:`D:\自用\self\word\大三下\软件演化\gitlink-cli`
|
||||
> 远程:`https://gitlink.org.cn/chroe/gitlink-cli.git`
|
||||
|
||||
---
|
||||
|
||||
## 一、课程任务背景
|
||||
|
||||
**课程**:《软件演化与运维》课程实践
|
||||
**进阶任务**:GitLink 智能化能力提升项目
|
||||
**当前阶段**:子任务二 — 编写和丰富 GitLink Skills(20%,截止 6月18日)
|
||||
|
||||
### 子任务一交付要求(已完成)
|
||||
- 向 gitlink-cli 主仓库提交 PR(可多个)
|
||||
- 每个 PR 包含:功能代码 + 单元测试 + 命令帮助文档更新
|
||||
- 提供变更说明文档
|
||||
- 撰写《软件分析及建模报告》、《新需求构思报告》、《变更影响分析及测试报告》
|
||||
|
||||
### 子任务二交付要求(进行中)
|
||||
- 遵循 gitlink-cli Skills 规范(参考 `skills/README.md`)
|
||||
- 每个 Skill 包含:SKILL.md + 使用示例 + 至少在一个 Agent 平台上验证通过
|
||||
- 兼容至少一个主流 AI Agent(如 Claude Code、OpenClaw、Cursor 等)
|
||||
- 撰写《新需求构思报告》和《变更影响分析及测试报告》
|
||||
|
||||
---
|
||||
|
||||
## 二、技术栈与架构
|
||||
|
||||
- **语言**:Go 1.26.1
|
||||
- **CLI 框架**:spf13/cobra
|
||||
- **密钥存储**:zalando/go-keyring(OS keychain + 文件 fallback)
|
||||
- **配置**:gopkg.in/yaml.v3,存放于 `~/.config/gitlink-cli/config.yaml`
|
||||
- **三层命令架构**:
|
||||
1. **基础命令**(cmd/):`auth`, `api`, `config`, `version`
|
||||
2. **Shortcut 命令**(shortcuts/):`repo +list`, `issue +create` 等 94 个命令
|
||||
3. **Raw API**:`api GET /path`
|
||||
|
||||
### Shortcut 模块开发模式
|
||||
|
||||
每个模块位于 `shortcuts/<name>/<name>.go`,结构固定:
|
||||
|
||||
```go
|
||||
package <name>
|
||||
|
||||
import "github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "Description here",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil { return err }
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/something", nil)
|
||||
if err != nil { return err }
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注册到 `shortcuts/register.go`:
|
||||
```go
|
||||
import "github.com/gitlink-org/gitlink-cli/shortcuts/<name>"
|
||||
// 在 RegisterAll() 中添加:
|
||||
// "<name>": <name>.Shortcuts(),
|
||||
// descriptions["<name>"] = "Description",
|
||||
```
|
||||
|
||||
### 单元测试模式(httptest mock)
|
||||
|
||||
```go
|
||||
func TestXxx(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 校验 method/path/query,返回 mock JSON
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner", Repo: "repo", Format: "json", Args: args,
|
||||
}
|
||||
err := findShortcut(t, "list").Run(ctx)
|
||||
// 断言
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、已实现的 18 个 Shortcut 模块(94 个命令)
|
||||
|
||||
| 模块 | 命令数 | 文件 | 测试 |
|
||||
|------|--------|------|------|
|
||||
| repo | 9(含 batch-delete/fork) | shortcuts/repo/ | ✅ |
|
||||
| issue | 12(含 batch-close/assign/label/milestone) | shortcuts/issue/ | ✅ |
|
||||
| pr | 9 | shortcuts/pr/ | ✅ |
|
||||
| release | 5 | shortcuts/release/ | ✅ |
|
||||
| branch | 5 | shortcuts/branch/ | ✅ |
|
||||
| ci | 4 | shortcuts/ci/ | ✅ |
|
||||
| commit | 4 | shortcuts/commit/ | ✅ |
|
||||
| file | 5 | shortcuts/file/ | ✅ |
|
||||
| member | 4 | shortcuts/member/ | ✅ |
|
||||
| star | 3 | shortcuts/star/ | ✅ |
|
||||
| watch | 3 | shortcuts/watch/ | ✅ |
|
||||
| label | 4 | shortcuts/label/ | ✅ |
|
||||
| milestone | 6 | shortcuts/milestone/ | ✅ |
|
||||
| webhook | 6 | shortcuts/webhook/ | ✅ |
|
||||
| org | 5(含 batch-invite) | shortcuts/org/ | ✅ |
|
||||
| search | 3 | shortcuts/search/ | ✅ |
|
||||
| user | 2 | shortcuts/user/ | ✅ |
|
||||
| wiki 🆕 | 5 | shortcuts/wiki/ | ✅ 12个测试 |
|
||||
|
||||
### 批量操作汇总(7个)
|
||||
|
||||
| 批量命令 | 所属模块 | 输入方式 | 预览模式 |
|
||||
|----------|----------|----------|----------|
|
||||
| batch-close | issue | --numbers 逗号 / --from CSV | --dry-run |
|
||||
| batch-assign | issue | 同上 | --dry-run |
|
||||
| batch-label | issue | 同上 | --dry-run |
|
||||
| batch-milestone | issue | 同上 | --dry-run |
|
||||
| batch-delete | repo | --repos owner/repo 列表 / --from CSV | --dry-run |
|
||||
| batch-fork | repo | 同上 | --dry-run |
|
||||
| batch-invite | org | --users 用户名列表 / --from CSV | --dry-run |
|
||||
|
||||
---
|
||||
|
||||
## 四、Wiki 模块开发记录
|
||||
|
||||
### Wiki API 关键发现
|
||||
|
||||
| 发现 | 说明 |
|
||||
|------|------|
|
||||
| API 域名 | `gateway.gitlink.org.cn` 而非 `www.gitlink.org.cn` |
|
||||
| API 路径 | `/wiki/open/...`(有 `/open/` 中间段) |
|
||||
| 无 `.json` 后缀 | Gateway 不接受 `.json`,需要 `client.DoRaw()` |
|
||||
| Sidebar 名称 | `_Sidebar`(大写 S),非小写 `_sidebar` |
|
||||
| projectId 字段 | 仓库返回 `project_id` 而非 `id` |
|
||||
| create/update 需要 `title` | API 必填字段 |
|
||||
| delete 异步重建 sidebar | 需延迟 2 秒后再清理 sidebar |
|
||||
| `data.data` 格式 | `DoRaw` 返回 dict 而非 string,解析需兼容 |
|
||||
|
||||
### delete 命令的 sidebar 自动清理流程
|
||||
1. 删除 wiki 页面(DELETE)
|
||||
2. 等待 2 秒(让 GitLink 完成异步 sidebar 重建)
|
||||
3. 获取 `_Sidebar` 内容
|
||||
4. 移除已删页面的 `[[PageName]]` 链接
|
||||
5. 更新 `_Sidebar`
|
||||
|
||||
---
|
||||
|
||||
## 五、开发踩坑记录
|
||||
|
||||
### 修改 CLI 代码后必须同时重建 showcase
|
||||
showcase 通过 `findCLIBinary()` 在运行时查找 `gitlink-cli.exe`。每次修改 CLI 代码后需要:
|
||||
```bash
|
||||
cd gitlink-cli
|
||||
go build -o gitlink-cli.exe . # 重建 CLI
|
||||
cd showcase && go build -o showcase.exe . # 重建 showcase(更新嵌入的 HTML)
|
||||
# 然后重启 showcase.exe
|
||||
```
|
||||
如果只重建 showcase 不重建 CLI,showcase 用的还是旧版二进制。
|
||||
|
||||
### Showcase 参数传递坑
|
||||
showcase 后端曾用 `strings.Split(args, " ")` 按空格切割参数,导致 `"Hello Wiki!"` 被截断为 `"Hello"`。
|
||||
修复:前端给含空格的值加引号,后端用支持引号解析的 `parseShellArgs()` 替代简单 Split。
|
||||
|
||||
### Showcase 布尔参数坑(已修复)
|
||||
Cobra 的 BoolP 标志:`--dry-run false`(空格分隔)会被解析为 `--dry-run=true` + 多余参数 "false"。
|
||||
所以批量操作 `--dry-run false` 实际上是在预览模式下运行,不会真正执行。
|
||||
修复:布尔参数改用 `<select>` 下拉框,JS 只在选"是"时才传 `--flag`,选"否"时不传。
|
||||
|
||||
### Showcase 重复参数坑(已修复)
|
||||
服务器后端自动添加 `--owner chroe --repo gitlink-cli`。如果前端卡片也传 `--owner`/`--repo`,
|
||||
会导致参数重复。修复:watch +watchers 和 star +stars 移除了多余的 owner/repo 输入框。
|
||||
|
||||
### Showcase null 显示(已修复)
|
||||
CLI 命令返回空数据时,前端显示 "null"。修复:JS 层对 null/空对象/空字符串统一显示"操作完成,无返回数据"。
|
||||
|
||||
---
|
||||
|
||||
## 六、CI/CD 流水线
|
||||
|
||||
### 部署架构
|
||||
```
|
||||
代码 push → GitLink 触发流水线 → SSH 到服务器 → git fetch + reset → docker build → 重启容器
|
||||
```
|
||||
|
||||
### 流水线文件
|
||||
- `.devops/构建部署Showcase.yml` — GitLink DevOps 流水线(push 自动触发)
|
||||
|
||||
### 流水线关键配置
|
||||
- 使用 `git fetch + git reset --hard origin/master` 避免 git pull 的本地修改冲突
|
||||
- 使用 `docker build --no-cache` 确保每次用最新代码构建
|
||||
- 容器启动时注入 `-e GITLINK_TOKEN=cookie:autologin_trustie=...` 解决认证问题
|
||||
- GitLink 密钥管理:`deploy_server.server_password` = 服务器密码
|
||||
|
||||
### 服务器信息
|
||||
- **IP**: 118.31.4.168
|
||||
- **用户**: root
|
||||
- **部署路径**: /opt/gitlink-cli
|
||||
- **容器名**: gitlink-cli-showcase
|
||||
- **端口**: 9090
|
||||
- **访问地址**: http://118.31.4.168:9090
|
||||
- **Showcase 展示模块数**: 12 个模块卡片(milestone/webhook/label/commit/wiki/file/member/watch/star/issue批量/repo批量/org批量)
|
||||
|
||||
### Docker 构建注意
|
||||
Dockerfile 已配置 `ENV GOPROXY=https://goproxy.cn,direct`,解决国内网络 Go 模块下载问题。
|
||||
服务器 Docker daemon 已配置国内镜像加速器(`/etc/docker/daemon.json`)。
|
||||
|
||||
---
|
||||
|
||||
## 七、已完成工作
|
||||
|
||||
### ✅ 子任务一完成项
|
||||
- [x] 基础命令框架(auth/api/config/version)
|
||||
- [x] 18 个 Shortcut 模块(94 个命令),其中 wiki 为新增
|
||||
- [x] 7 个批量操作命令(batch-close/assign/label/milestone/delete/fork/invite)
|
||||
- [x] 12 个 AI Agent Skills(基础版,部分需要丰富)
|
||||
- [x] Showcase Dashboard 在线展示(12 个模块卡片,支持真实运行)
|
||||
- [x] GitHub CI/CD(release + npm publish + FreeBSD 支持)
|
||||
- [x] GitLink DevOps 流水线(push 自动部署,已配置密钥,正常运行)
|
||||
- [x] 单元测试(wiki 12个 + 其他模块 47+)
|
||||
- [x] client.DoRaw 方法(不带 .json 后缀的原始 API 调用)
|
||||
- [x] Showcase 布尔参数/重复参数/null显示 修复
|
||||
|
||||
---
|
||||
|
||||
## 七-A、子任务二:编写和丰富 GitLink Skills
|
||||
|
||||
### 📋 总体策略
|
||||
1. **先做新 Skill**:开发课程建议的新场景 Skill
|
||||
2. **后完善老 Skill**:丰富已有的 12 个 Skill,补充 REFERENCE.md、examples、验证记录
|
||||
|
||||
### 新 Skill 开发进度
|
||||
|
||||
| 优先级 | Skill 名称 | 场景 | 状态 | 说明 |
|
||||
|--------|-----------|------|------|------|
|
||||
| 🥇 | `gitlink-health` | 项目健康度报告 | ✅ 已完成 | 评分算法验证通过,含 fallback 策略 |
|
||||
| 🥈 | `gitlink-changelog` | Release Notes 自动生成 | ✅ 已完成 | commit 分类规则完整,含 PR 过滤注意事项 |
|
||||
| 🥉 | `gitlink-triage` | Issue 智能分拣 + 新人引导 | ✅ 已完成 | 含 subject/description 保留警告、标签映射说明 |
|
||||
|
||||
### Skill 开发指南(给队友看的)
|
||||
|
||||
> 完整踩坑记录见 `EXPERIMENT_LOG.md`,这里只写怎么动手。
|
||||
|
||||
#### 每个 Skill 的标准交付物(3 个文件)
|
||||
|
||||
```
|
||||
skills/gitlink-<name>/
|
||||
├── SKILL.md # 主文件:触发条件、数据采集命令、输出格式、使用场景
|
||||
├── REFERENCE.md # 技术参考:字段映射、API 注意事项、Q&A
|
||||
└── examples/
|
||||
└── <scenario>.md # 使用示例(= Agent 验证证明)
|
||||
```
|
||||
|
||||
**不需要** VERIFICATION.md — example 里放真实命令输出,本身就是验证记录。
|
||||
|
||||
#### 开发步骤(按顺序做)
|
||||
|
||||
```
|
||||
第一步:确定场景
|
||||
- 这个 Skill 解决什么问题?什么时候触发?
|
||||
|
||||
第二步:写 SKILL.md
|
||||
- 照着已有的 Skill 抄格式(推荐抄 gitlink-issue/SKILL.md)
|
||||
- 必须有的内容:
|
||||
· frontmatter(name/version/description/metadata)
|
||||
· CRITICAL 块引用 gitlink-shared/SKILL.md
|
||||
· 数据采集命令(分步骤,每步一个代码块)
|
||||
· 输出格式模板
|
||||
· 2-3 个使用场景
|
||||
· 最佳实践
|
||||
|
||||
第三步:⚠️ 在终端里逐条跑 SKILL.md 中的命令(最重要!)
|
||||
- 每条命令都要跑,看返回的是 JSON 还是 HTML
|
||||
- 写入命令确认参数完整、能执行
|
||||
- 记录每条命令的真实输出(后面写 example 要用)
|
||||
|
||||
第四步:写 REFERENCE.md
|
||||
- API 字段映射(命令返回的 JSON 长什么样)
|
||||
- 注意事项和限制
|
||||
- 常见问题 Q&A
|
||||
|
||||
第五步:写 examples/
|
||||
- 用第三步记录的真实输出
|
||||
- 格式:命令序列 + 真实返回数据 + AI 生成的结果
|
||||
- 不准编造数据!
|
||||
```
|
||||
|
||||
#### ⚠️ 绝对不能踩的坑
|
||||
|
||||
| 坑 | 说明 | 怎么避 |
|
||||
|----|------|--------|
|
||||
| `api` 子命令不可用 | `gitlink-cli api GET/POST` 存在 URL 污染 bug,会把 git 安装路径拼进 URL | **不要用** `api` 子命令,只用 shortcut 命令(如 `issue +list`、`repo +info`) |
|
||||
| `--state open` 过滤不准 | `issue +list --state open` 可能返回已关闭的 Issue | 客户端检查 `status_id`,`5` = 关闭 |
|
||||
| journals API 返回 HTML | `/v1/:owner/:repo/issues/:number/journals` 返回网页 | 用 `issue +view` 的 `comment_journals_count` 字段代替 |
|
||||
| compare API 返回 HTML | `/owner/:repo/compare/...` 返回网页 | 用 `commit +list` 按时间筛选代替 |
|
||||
| Issue 更新会清空字段 | 更新时不带 subject/description 会导致标题和描述被清空 | 先 `issue +view` 获取当前内容,更新时一并提交 |
|
||||
| 标签名是中文 | 项目已有标签是"缺陷/功能/疑问",不是英文 | 用 `label +list` 获取已有标签,优先匹配 |
|
||||
| `issue +update` 不支持打标签/分配 | 只有 title/body/state 三个参数 | 写入 CRITICAL 警告,引导用户网页操作 |
|
||||
|
||||
#### 已有 Skill 完善任务分配
|
||||
|
||||
| Skill | 当前状态 | 要做的事 | 分配给 |
|
||||
|-------|---------|---------|--------|
|
||||
| **gitlink-issue** | 有 SKILL.md + references/(7个文件) | 补 examples/(用 chroe/gitlink-cli 真实数据) | — |
|
||||
| **gitlink-pr** | 有 SKILL.md + references/(6个文件) | 补 examples/ | — |
|
||||
| **gitlink-release** | 有 SKILL.md + references/(4个文件) | 补 examples/ | — |
|
||||
| **gitlink-workflow** | 仅有空壳 SKILL.md | 重写 SKILL.md + 补 REFERENCE.md + examples | — |
|
||||
| **gitlink-ci** | 仅有 SKILL.md | 补 REFERENCE.md + examples | — |
|
||||
| **gitlink-pm** | 仅有 SKILL.md | 补 REFERENCE.md + examples | — |
|
||||
| **gitlink-repo** | 有 SKILL.md + references/(9个文件) | 补 examples/ | — |
|
||||
| **gitlink-search** | 有 SKILL.md + references/(2个文件) | 补 examples/ | — |
|
||||
| **gitlink-user** | 有 SKILL.md + references/(2个文件) | 补 examples/ | — |
|
||||
| **gitlink-org** | 有 SKILL.md + references/(3个文件) | 补 examples/ | — |
|
||||
| **gitlink-branch** | 有 SKILL.md + examples/(1个文件) | 补 REFERENCE.md | — |
|
||||
|
||||
> **完善标准**:每个 Skill 至少有 SKILL.md + examples/(含真实命令输出)。REFERENCE.md 如果已有 references/ 目录可以不写。
|
||||
|
||||
### 待写文档 📄
|
||||
- [ ] 《新需求构思报告》(子任务一 + 子任务二共用)
|
||||
- [ ] 《变更影响分析及测试报告》(子任务一 + 子任务二共用)
|
||||
|
||||
---
|
||||
|
||||
## 八、小组分工
|
||||
|
||||
| 角色 | 负责人 | 负责内容 |
|
||||
|------|--------|----------|
|
||||
| 组长 A | — | commit(4命令)、milestone(6)、webhook(6)、label(4)、Showcase |
|
||||
| 同学 B | — | file(5)、member(4)、watch(3)、star(3) |
|
||||
| 同学 C | — | 批量操作增强(batch-assign/label/milestone/delete/fork/invite)、table 输出优化、测试覆盖、FreeBSD构建 |
|
||||
| 全员 | — | wiki(5命令)、流水线部署 |
|
||||
|
||||
---
|
||||
|
||||
## 九、快速开始
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
cd gitlink-cli && make build
|
||||
|
||||
# 运行测试
|
||||
go test ./shortcuts/... -v
|
||||
|
||||
# 登录
|
||||
./gitlink-cli auth login
|
||||
|
||||
# 使用示例
|
||||
./gitlink-cli repo +list --owner gitlink
|
||||
./gitlink-cli issue +list --owner gitlink --repo gitlink-cli
|
||||
./gitlink-cli wiki +list --owner chroe --repo gitlink_help_center
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、当前进度 & 下一步
|
||||
|
||||
### 子任务一 ✅ 已完成
|
||||
1. 全部 18 个 Shortcut 模块 + 94 个命令
|
||||
2. 7 个批量操作命令
|
||||
3. Showcase 展示页(12 个模块,可在线运行)
|
||||
4. GitLink DevOps 流水线(push 自动部署)
|
||||
5. 单元测试 47+ 个
|
||||
6. Showcase 各种 bug 修复(布尔参数、null 显示、重复参数、member ID 提示)
|
||||
7. 12 个 AI Agent Skills(基础版)
|
||||
|
||||
### 子任务二 🔄 进行中(截止 6月18日)
|
||||
|
||||
#### 第一步:开发新 Skill ✅ 已完成
|
||||
- [x] `gitlink-health` — 项目健康度报告 Skill
|
||||
- [x] SKILL.md(主文件 + CRITICAL 读 REFERENCE)
|
||||
- [x] REFERENCE.md(评分算法 + fallback 策略 + 社区关注度标准)
|
||||
- [x] examples/(健康度报告 + 周报,2026-06-08 真实数据)
|
||||
- [x] VERIFICATION.md(Claude Code 验证通过)
|
||||
- [x] 公平评分验证:77/100(严格按公式计算)
|
||||
- [x] `gitlink-changelog` — Release Notes 自动生成 Skill
|
||||
- [x] SKILL.md(CRITICAL 读 REFERENCE + 完整分类规则)
|
||||
- [x] REFERENCE.md(API 路径修正 + PR 过滤注意事项)
|
||||
- [x] examples/(changelog 生成示例,真实数据)
|
||||
- [x] VERIFICATION.md(Claude Code 验证通过)
|
||||
- [x] `gitlink-triage` — Issue 智能分拣 + 新人引导 Skill
|
||||
- [x] SKILL.md(CRITICAL 读 REFERENCE + subject 保留警告)
|
||||
- [x] REFERENCE.md(颜色码修正 + 标签映射说明)
|
||||
- [x] examples/(6 Issue 批量分拣示例,真实数据)
|
||||
- [x] VERIFICATION.md(Claude Code 验证通过)
|
||||
|
||||
#### 第二步:完善已有 Skill
|
||||
- [ ] gitlink-workflow 升级为独立完整 Skill(当前仅为简单骨架)
|
||||
- [ ] 补充 gitlink-ci、gitlink-pm 的 REFERENCE.md + examples
|
||||
- [ ] 为所有 Skill 补充 VERIFICATION.md
|
||||
|
||||
#### 第三步:文档撰写
|
||||
- [ ] 向 gitlink-cli 主仓库提交 Skills PR
|
||||
- [ ] 撰写《软件分析及建模报告》
|
||||
- [ ] 撰写《新需求构思报告》
|
||||
- [ ] 撰写《变更影响分析及测试报告》
|
||||
- [ ] 变更说明文档
|
||||
|
|
@ -428,5 +428,3 @@ See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md).
|
|||
## License
|
||||
|
||||
[MulanPSL-2.0](https://license.coscl.org.cn/MulanPSL2)
|
||||
|
||||
> This line is for PR workflow testing, can be reverted after merge.
|
||||
|
|
|
|||
335
README_TASKB.md
335
README_TASKB.md
|
|
@ -1,335 +0,0 @@
|
|||
# gitlink-cli 新增功能使用指南
|
||||
|
||||
> 任务B — file / member / watch / star 四大模块,15 个新命令
|
||||
|
||||
---
|
||||
|
||||
## 一、file — 仓库文件操作
|
||||
|
||||
### file +list — 列出仓库文件
|
||||
|
||||
```bash
|
||||
gitlink-cli file +list --owner <owner> --repo <repo> [--ref <分支>] [--search <关键词>]
|
||||
```
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--owner` | 否* | 仓库拥有者 |
|
||||
| `--repo` | 否* | 仓库名 |
|
||||
| `--ref`, `-r` | 否 | 分支/标签/commit SHA |
|
||||
| `--search`, `-s` | 否 | 搜索关键词 |
|
||||
|
||||
\* 在 git 仓库目录下运行时自动解析,无需手动指定
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 列出根目录文件
|
||||
gitlink-cli file +list --owner chroe --repo gitlink-cli
|
||||
|
||||
# 列出 dev 分支的文件
|
||||
gitlink-cli file +list --owner chroe --repo gitlink-cli --ref dev
|
||||
|
||||
# 搜索包含 "test" 的文件
|
||||
gitlink-cli file +list --owner chroe --repo gitlink-cli --search test
|
||||
```
|
||||
|
||||
### file +tree — 查看文件树
|
||||
|
||||
```bash
|
||||
gitlink-cli file +tree --owner <owner> --repo <repo> [--sha <分支>] [--recursive] [--page <n>] [--limit <n>]
|
||||
```
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--sha`, `-s` | 否 | 分支/标签/commit,默认 `master` |
|
||||
| `--recursive` | 否 | 递归展开所有子目录(`true`/`false`) |
|
||||
| `--page`, `-p` | 否 | 页码,默认 `1` |
|
||||
| `--limit`, `-l` | 否 | 每页条数,默认 `20` |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 默认 tree 输出(table 格式)
|
||||
gitlink-cli file +tree --owner chroe --repo gitlink-cli --sha master
|
||||
|
||||
# 递归列出所有文件(JSON 格式)
|
||||
gitlink-cli file +tree --owner chroe --repo gitlink-cli --sha master --recursive true --format json
|
||||
```
|
||||
|
||||
### file +get — 查看文件/目录内容
|
||||
|
||||
```bash
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path <路径> [--ref <分支>]
|
||||
```
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--path`, `-p` | **是** | 文件或目录路径 |
|
||||
| `--ref`, `-r` | 否 | 分支/标签/commit,默认 `master` |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 查看 README.md 内容
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path README.md
|
||||
|
||||
# 查看 src 目录的子条目
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path src
|
||||
|
||||
# 查看 dev 分支上的文件
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path main.go --ref dev
|
||||
```
|
||||
|
||||
### file +create — 新建文件
|
||||
|
||||
```bash
|
||||
gitlink-cli file +create --owner <owner> --repo <repo> --path <路径> --content <内容> --message <提交信息> [--branch <分支>]
|
||||
```
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--path`, `-p` | **是** | 文件路径(含文件名) |
|
||||
| `--content`, `-c` | **是** | 文件内容(明文,自动 Base64 编码) |
|
||||
| `--message`, `-m` | **是** | Git 提交信息 |
|
||||
| `--branch`, `-b` | 否 | 目标分支,默认 `master` |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
gitlink-cli file +create \
|
||||
--owner caoweiqiong --repo Aether \
|
||||
--path docs/readme.txt \
|
||||
--content "Hello GitLink!" \
|
||||
--message "添加文档" \
|
||||
--branch master
|
||||
```
|
||||
|
||||
### file +delete — 删除文件
|
||||
|
||||
```bash
|
||||
gitlink-cli file +delete --owner <owner> --repo <repo> --path <路径> --sha <BlobSHA> --message <提交信息> [--branch <分支>]
|
||||
```
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--path`, `-p` | **是** | 要删除的文件路径 |
|
||||
| `--sha`, `-s` | **是** | 文件的 blob SHA(从 `file +list` 获取) |
|
||||
| `--message`, `-m` | **是** | Git 提交信息 |
|
||||
| `--branch`, `-b` | 否 | 目标分支,默认 `master` |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 先列出文件获取 SHA
|
||||
gitlink-cli file +list --owner caoweiqiong --repo Aether
|
||||
|
||||
# 然后删除指定文件
|
||||
gitlink-cli file +delete \
|
||||
--owner caoweiqiong --repo Aether \
|
||||
--path docs/old.txt \
|
||||
--sha abc123def456 \
|
||||
--message "删除过期文档"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、member — 项目成员管理
|
||||
|
||||
### member +list — 列出成员
|
||||
|
||||
```bash
|
||||
gitlink-cli member +list --owner <owner> --repo <repo> [--keyword <搜索>]
|
||||
```
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--keyword`, `-k` | 否 | 按用户名搜索成员 |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
gitlink-cli member +list --owner chroe --repo gitlink-cli --format table
|
||||
|
||||
# 搜索特定成员
|
||||
gitlink-cli member +list --owner chroe --repo gitlink-cli --keyword caoweiqiong
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```
|
||||
id login role_name
|
||||
-- ----- ---------
|
||||
149027 chroe Manager
|
||||
141645 caoweiqiong Developer
|
||||
148915 yetja Developer
|
||||
```
|
||||
|
||||
### member +add — 添加成员
|
||||
|
||||
```bash
|
||||
gitlink-cli member +add --owner <owner> --repo <repo> --user-id <数字ID>
|
||||
```
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--user-id`, `-u` | **是** | 用户的数字 ID(从 `user +info` 获取) |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 先查到目标用户的 user_id
|
||||
gitlink-cli user +info --login zhangsan
|
||||
|
||||
# 然后添加
|
||||
gitlink-cli member +add --owner caoweiqiong --repo Aether --user-id 123456
|
||||
```
|
||||
|
||||
### member +remove — 移除成员
|
||||
|
||||
```bash
|
||||
gitlink-cli member +remove --owner <owner> --repo <repo> --user-id <数字ID>
|
||||
```
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
gitlink-cli member +remove --owner caoweiqiong --repo Aether --user-id 123456
|
||||
```
|
||||
|
||||
### member +update — 修改成员角色
|
||||
|
||||
```bash
|
||||
gitlink-cli member +update --owner <owner> --repo <repo> --user-id <数字ID> --role <角色>
|
||||
```
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--user-id`, `-u` | **是** | 用户数字 ID |
|
||||
| `--role`, `-r` | **是** | 三种角色之一:`Manager` / `Developer` / `Reporter` |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
gitlink-cli member +update --owner caoweiqiong --repo Aether --user-id 123456 --role Developer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、watch — 关注仓库
|
||||
|
||||
### watch +watch — 关注仓库
|
||||
|
||||
```bash
|
||||
gitlink-cli watch +watch --owner <owner> --repo <repo>
|
||||
```
|
||||
|
||||
无需手动传 project-id,命令内部自动解析。
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
gitlink-cli watch +watch --owner chroe --repo gitlink-cli
|
||||
# → 返回 {"watched": true}
|
||||
```
|
||||
|
||||
### watch +unwatch — 取消关注
|
||||
|
||||
```bash
|
||||
gitlink-cli watch +unwatch --owner <owner> --repo <repo>
|
||||
```
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
gitlink-cli watch +unwatch --owner chroe --repo gitlink-cli
|
||||
# → 返回 {"watched": false}
|
||||
```
|
||||
|
||||
### watch +watchers — 查看关注者列表
|
||||
|
||||
```bash
|
||||
gitlink-cli watch +watchers --owner <owner> --repo <repo>
|
||||
```
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
gitlink-cli watch +watchers --owner chroe --repo gitlink-cli --format table
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、star — 点赞仓库
|
||||
|
||||
### star +star — 点赞仓库
|
||||
|
||||
```bash
|
||||
gitlink-cli star +star --owner <owner> --repo <repo>
|
||||
```
|
||||
|
||||
无需手动传 project-id,命令内部自动解析。
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
gitlink-cli star +star --owner chroe --repo gitlink-cli
|
||||
```
|
||||
|
||||
### star +unstar — 取消点赞
|
||||
|
||||
```bash
|
||||
gitlink-cli star +unstar --owner <owner> --repo <repo>
|
||||
```
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
gitlink-cli star +unstar --owner chroe --repo gitlink-cli
|
||||
```
|
||||
|
||||
### star +stars — 查看点赞者列表
|
||||
|
||||
```bash
|
||||
gitlink-cli star +stars --owner <owner> --repo <repo>
|
||||
```
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
gitlink-cli star +stars --owner chroe --repo gitlink-cli --format table
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 全局参数
|
||||
|
||||
所有命令都支持以下全局参数:
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `--owner` | 仓库拥有者(git 目录下自动解析) | `--owner chroe` |
|
||||
| `--repo` | 仓库名(git 目录下自动解析) | `--repo gitlink-cli` |
|
||||
| `--format` | 输出格式:`json` / `table` / `yaml` | `--format table` |
|
||||
| `--debug` | 开启调试输出 | `--debug` |
|
||||
|
||||
## 使用技巧
|
||||
|
||||
1. **利用自动解析**:在 git 克隆的目录下直接运行,无需写 `--owner` 和 `--repo`:
|
||||
|
||||
```bash
|
||||
cd my-project
|
||||
gitlink-cli file +list # 自动识别当前仓库
|
||||
gitlink-cli member +list # 同上
|
||||
```
|
||||
|
||||
2. **JSON 输出用于脚本**:
|
||||
|
||||
```bash
|
||||
gitlink-cli file +tree --owner chroe --repo gitlink-cli --sha master --format json | jq '.data.entries[] | .name'
|
||||
```
|
||||
|
||||
3. **file 创建自动 Base64**:传 `--content` 时直接写明文,命令会自动转为 Base64,不用自己编码。
|
||||
|
||||
4. **watch/star 透明化**:`watch` 和 `star` 命令会自动查 project-id,你只需关心 owner/repo,与其他命令体验一致。
|
||||
|
||||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -14,12 +13,6 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
// msysPathRe matches Windows drive paths like "D:/something" that MSYS2
|
||||
// auto-converts from Unix paths like "/something". When the api command
|
||||
// receives "/v1/owner/repo", Git Bash on Windows may convert it to
|
||||
// "C:/Program Files/Git/v1/owner/repo". We detect and revert this.
|
||||
var msysPathRe = regexp.MustCompile(`^[A-Za-z]:/`)
|
||||
|
||||
func NewAPICmd() *cobra.Command {
|
||||
apiCmd := &cobra.Command{
|
||||
Use: "api <METHOD> <PATH>",
|
||||
|
|
@ -43,20 +36,6 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
method := strings.ToUpper(args[0])
|
||||
path := args[1]
|
||||
|
||||
// Fix MSYS2/Git Bash path auto-conversion on Windows.
|
||||
// "/v1/owner/repo" gets converted to "C:/Program Files/Git/v1/owner/repo".
|
||||
// Detect Windows drive letter prefix and strip it, then restore leading "/".
|
||||
if msysPathRe.MatchString(path) {
|
||||
// Find where the original API path starts (after the git install dir)
|
||||
// by looking for common API prefixes like /v1/, /api/, /users/, etc.
|
||||
for _, prefix := range []string{"/v1/", "/v2/", "/api/", "/users/", "/projects/"} {
|
||||
if idx := strings.Index(path, prefix); idx >= 0 {
|
||||
path = path[idx:]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, _ := config.Load()
|
||||
fmt.Printf("BaseURL: [%s]\n", cfg.BaseURL)
|
||||
fmt.Printf("len: %d\n", len(cfg.BaseURL))
|
||||
path := "/v1/chroe/gitlink-cli/issues/1"
|
||||
fmt.Printf("fullURL: [%s]\n", cfg.BaseURL + path)
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ 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"
|
||||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
)
|
||||
|
||||
|
|
@ -48,10 +47,7 @@ var versionCmd = &cobra.Command{
|
|||
|
||||
func Execute() error {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
|
||||
if s := clierrors.FindSuggestion(err); s != "" {
|
||||
fmt.Fprintf(os.Stderr, "建议: %s\n", s)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -1,767 +0,0 @@
|
|||
# gitlink-review(智能代码审查)Skill 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 新增一个纯 Markdown skill `gitlink-review`,引导 AI agent 对指定 GitLink PR 做多视角 + 对抗式自检的结构化代码审查,并把报告卡作为评论发布。
|
||||
|
||||
**Architecture:** 交付物是 `skills/gitlink-review/` 下的 SKILL.md + REFERENCE.md + examples/,无 Go 代码。智能来自 agent 按管道执行(取 diff → 降噪 → 6 视角 → 对抗自检 → 综合 → 预览 → 发布)。发布主路径用已验证可用的 `pr +comment`;行内评论为"reviews API 可用时增强"。用"植入缺陷测试 PR"做端到端真实验证。
|
||||
|
||||
**Tech Stack:** Markdown + gitlink-cli(`pr +view` / `pr +files` / `pr +diff` / `pr +comment` / `api POST /pulls/:id/reviews`)。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 前置约束(所有任务适用)
|
||||
|
||||
- **`master` 受保护**:所有改动在特性分支 `feat/gitlink-review-skill` 上提交;测试用 fixture 在独立分支 `test/review-fixture` 上(仅供测试,不合并)。
|
||||
- **平台**:Windows + Git Bash。路径用正斜杠。
|
||||
- **认证**:`gitlink-cli` 已登录(`user +me` 可用)。
|
||||
|
||||
---
|
||||
|
||||
## Task 1:建立特性分支并纳入 spec/plan
|
||||
|
||||
**Files:**
|
||||
- Track: `docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md`
|
||||
- Track: `docs/superpowers/plans/2026-06-18-gitlink-review-skill.md`
|
||||
|
||||
- [ ] **Step 1:从最新 master 建分支**
|
||||
|
||||
```bash
|
||||
cd "C:/Users/CWQ98/Desktop/演化与运维/gitlink-cli"
|
||||
git fetch origin
|
||||
git checkout master
|
||||
git pull --ff-only origin master
|
||||
git checkout -b feat/gitlink-review-skill
|
||||
```
|
||||
|
||||
预期:位于 `feat/gitlink-review-skill`,与 master 同步。
|
||||
|
||||
- [ ] **Step 2:确认 CLI 可用**
|
||||
|
||||
```bash
|
||||
gitlink-cli version
|
||||
gitlink-cli user +me --format json
|
||||
```
|
||||
|
||||
预期:打印版本;`user +me` 返回 `login: caoweiqiong`。
|
||||
|
||||
- [ ] **Step 3:把 spec 和 plan 纳入版本控制**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md \
|
||||
docs/superpowers/plans/2026-06-18-gitlink-review-skill.md
|
||||
git commit -m "docs(review): 添加 gitlink-review 设计 spec 与实现计划"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2:创建"植入缺陷"测试 PR(端到端验证载体)
|
||||
|
||||
**Files:**
|
||||
- Create(在 `test/review-fixture` 分支上,不合并):`review_fixture.go`
|
||||
|
||||
- [ ] **Step 1:从 master 建测试分支**
|
||||
|
||||
```bash
|
||||
git checkout master
|
||||
git checkout -b test/review-fixture
|
||||
```
|
||||
|
||||
- [ ] **Step 2:写 fixture 文件(含 4 个植入点)**
|
||||
|
||||
创建 `review_fixture.go`:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
// 本文件为 gitlink-review skill 的测试夹具,故意植入缺陷。DO NOT MERGE。
|
||||
package reviewtest
|
||||
|
||||
import "os"
|
||||
|
||||
var _ = os.Getenv // 仅占位引入,避免 unused 报错(build ignore 下不影响)
|
||||
|
||||
// User 占位类型
|
||||
type User struct{ Name string }
|
||||
|
||||
// 1. 正确性:空指针未判
|
||||
func CurrentUser(token string) *User {
|
||||
if token == "" {
|
||||
return nil // token 无效返回 nil
|
||||
}
|
||||
return &User{Name: "cwq"}
|
||||
}
|
||||
|
||||
func Greet(token string) string {
|
||||
u := CurrentUser(token)
|
||||
return "hello " + u.Name // ← BUG: u 可能为 nil,解引用会 panic
|
||||
}
|
||||
|
||||
// 2. 安全:硬编码 Token(明显伪造字符串,避免触发真实密钥扫描)
|
||||
var AdminToken = "glpat-FAKEFAKEFAKE0000000000"
|
||||
|
||||
// 3. 测试:新函数 Welcome,本 PR 无对应 _test.go
|
||||
func Welcome(name string) string {
|
||||
if name == "" {
|
||||
return "guest"
|
||||
}
|
||||
return "hi " + name
|
||||
}
|
||||
|
||||
// 4. 伪阳性:看似除零,但调用方 DoMath 已保证除数非 0
|
||||
func Divide(a, b int) int {
|
||||
return a / b
|
||||
}
|
||||
|
||||
func DoMath(x int) int {
|
||||
if x == 0 {
|
||||
return 0 // 上游保证 x != 0
|
||||
}
|
||||
return Divide(100, x) // 因此 Divide 的除零在真实调用路径上是伪阳性
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:提交并推送**
|
||||
|
||||
```bash
|
||||
git add review_fixture.go
|
||||
git commit -m "test(review): 植入缺陷夹具(正确性/安全/测试/伪阳性)"
|
||||
git push origin test/review-fixture
|
||||
```
|
||||
|
||||
- [ ] **Step 4:创建 PR**
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +create \
|
||||
--owner chroe --repo gitlink-cli \
|
||||
--head test/review-fixture --base master \
|
||||
--title "test: gitlink-review 植入缺陷夹具(请勿合并)" \
|
||||
--body "gitlink-review skill 端到端验证用 PR。含 4 个植入点:空指针、硬编码 Token、缺测试、伪阳性。验证完成后将关闭不合并。" \
|
||||
--format json
|
||||
```
|
||||
|
||||
预期:`pull_request_number` 返回一个数(记为 `<PRN>`,如 3)。记录到 `review_fixture_notes.txt`(本地临时):
|
||||
|
||||
```
|
||||
TEST_PR_NUMBER=<PRN>
|
||||
```
|
||||
|
||||
- [ ] **Step 5:验证 diff 可取**
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +files --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||
gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id <PRN> --format json | head -c 800
|
||||
```
|
||||
|
||||
预期:files 含 `review_fixture.go`;diff 含上述代码片段。
|
||||
|
||||
---
|
||||
|
||||
## Task 3:实测 reviews API(决定行内评论能力)
|
||||
|
||||
**Files:** 无(结论写入 Task 5 的 REFERENCE.md)
|
||||
|
||||
- [ ] **Step 1:在测试 PR 上探测 POST reviews**
|
||||
|
||||
```bash
|
||||
gitlink-cli api POST /chroe/gitlink-cli/pulls/<PRN>/reviews \
|
||||
--body '{"body":"gitlink-review probe: 行内评论能力探测","event":"COMMENT"}' 2>&1
|
||||
```
|
||||
|
||||
- [ ] **Step 2:判断并记录结论**
|
||||
|
||||
- 若返回 `{"ok":true,...}`(或含 review id)→ **行内评论可用**,记录实际请求体格式。
|
||||
- 若返回 404 / URL 被注入 git exec-path → **不可用(与 triage 记录的 `api POST` bug 一致)**。
|
||||
|
||||
把结论写入本地 `review_api_probe.txt`:
|
||||
|
||||
```
|
||||
REVIEWS_API=<available|unavailable>
|
||||
NOTES=<观察到的响应或错误>
|
||||
```
|
||||
|
||||
- [ ] **Step 3:若可用,再试带行号的行内评论**
|
||||
|
||||
```bash
|
||||
gitlink-cli api POST /chroe/gitlink-cli/pulls/<PRN>/reviews \
|
||||
--body '{"event":"COMMENT","body":"行内探测","line":22,"path":"review_fixture.go","side":"RIGHT"}' 2>&1
|
||||
```
|
||||
|
||||
记录是否支持 `line/path/side`(行内定位)。结果并入 `review_api_probe.txt`。
|
||||
|
||||
> 此 task 无需 commit(结论是数据,将在 Task 5 固化进 REFERENCE.md)。
|
||||
|
||||
---
|
||||
|
||||
## Task 4:写 SKILL.md(主管道与方法论)
|
||||
|
||||
**Files:**
|
||||
- Create: `skills/gitlink-review/SKILL.md`
|
||||
|
||||
- [ ] **Step 1:切回特性分支并建目录**
|
||||
|
||||
```bash
|
||||
git checkout feat/gitlink-review-skill
|
||||
```
|
||||
|
||||
- [ ] **Step 2:写 SKILL.md**
|
||||
|
||||
创建 `skills/gitlink-review/SKILL.md`,**完整内容**如下:
|
||||
|
||||
````markdown
|
||||
---
|
||||
name: gitlink-review
|
||||
version: 1.0.0
|
||||
description: "智能代码审查:分析 PR diff,多视角评审 + 对抗式自检,输出结构化 Review 意见并作为评论发布。当用户需要审查 GitLink PR、做代码 review、自动生成审查意见时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli pr --help"
|
||||
---
|
||||
|
||||
# gitlink-review(智能代码审查)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有写入操作(发布评论)前默认先预览、确认后再执行;`--auto` 跳过确认但仍受置信度门控。**
|
||||
**CRITICAL — 绝不自动 approve / merge PR。审查只是评论,不做合并决策。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md);详细检查清单与字段映射见 [`REFERENCE.md`](REFERENCE.md)。
|
||||
|
||||
## 概述
|
||||
|
||||
本 Skill 引导 AI 对指定 GitLink PR 做结构化、多视角、低误报的代码审查,并把"报告卡"作为评论发布。核心特点:
|
||||
|
||||
1. **多视角评审团**:6 个视角并行扫 diff
|
||||
2. **对抗式自检**:每条候选发现先自我反驳,成立的才留下(直击 AI 审查误报老大难)
|
||||
3. **降噪**:自动跳过生成代码 / vendor / lock / 纯重命名
|
||||
4. **可执行**:每条发现带 `file:line` + 修复建议 + 理由
|
||||
|
||||
## 命令接口(skill 约定参数,非 gitlink-cli 新增 flag)
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `--owner/--repo` | 自动从 cwd 解析 | 目标仓库 |
|
||||
| `--id` | 必填 | PR 编号(`pull_request_number`,网页 `/pulls/N`) |
|
||||
| `--lenses` | 全 6 视角 | 子集,如 `correctness,security` |
|
||||
| `--auto` | 关 | 跳过预览确认直接发布(仍受置信度门控) |
|
||||
| `--inline` | 关 | 尝试附行内评论(reviews API 可用时) |
|
||||
| `--max-findings` | 12 | 报告卡上限 |
|
||||
| `--refresh` | 关 | 即使存在旧审查哨兵也重审并更新 |
|
||||
|
||||
## 管道(8 步)
|
||||
|
||||
### ① 取上下文
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +view --owner <owner> --repo <repo> --id <N> --format json # 元数据
|
||||
gitlink-cli pr +files --owner <owner> --repo <repo> --id <N> --format json # 文件 +/-
|
||||
gitlink-cli pr +diff --owner <owner> --repo <repo> --id <N> --format json # 核心:diff 内容
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json # 语言校准
|
||||
```
|
||||
|
||||
### ② 降噪过滤
|
||||
|
||||
按下述"降噪规则"跳过噪音文件;在报告卡"范围"行注明跳过数量,不静默吞掉。
|
||||
|
||||
### ③ 多视角分析
|
||||
|
||||
对每个启用的视角扫 diff,产出候选发现,每条含:
|
||||
|
||||
```json
|
||||
{ "lens": "correctness", "severity": "high", "likelihood": "likely",
|
||||
"file": "auth/login.go", "line": 42, "what": "空指针未判",
|
||||
"why": "...", "fix": "...", "confidence": "high" }
|
||||
```
|
||||
|
||||
### ④ 对抗式自检(质量门)
|
||||
|
||||
逐条自问(任一成立即丢弃或降级):
|
||||
|
||||
1. 语境里是否已有保护(外层判空、上游校验、框架机制)?
|
||||
2. 是真缺陷还是风格偏好?偏好 → 降级 🔵 nit 或丢弃。
|
||||
3. 引用的 API/签名/语言行为是否真实存在?(不确定则不报)
|
||||
4. 是否与另一视角重复?
|
||||
|
||||
**置信度门控**:`confidence=low` 且 `severity≠high` → 丢弃。
|
||||
|
||||
### ⑤ 综合
|
||||
|
||||
跨视角去重 → 按 `严重度×likelihood` 排序 → 封顶 `--max-findings`。
|
||||
|
||||
### ⑥ 预览(默认)
|
||||
|
||||
展示报告卡给用户;确认后发布。`--auto` 跳过。
|
||||
|
||||
### ⑦ 发布
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +comment --owner <owner> --repo <repo> --id <N> \
|
||||
--body "<报告卡全文,含哨兵>"
|
||||
```
|
||||
|
||||
`--inline` 且 reviews API 可用 → 对 🔴/🟡 发现附行内评论(见 REFERENCE.md 的 API 结论)。
|
||||
|
||||
### ⑧ 幂等
|
||||
|
||||
发布前检查该 PR 是否已有哨兵 `<!-- gitlink-review v1 ... -->`;有则默认提议"更新"(`--refresh` 才覆盖)。
|
||||
|
||||
## 评审团 6 视角
|
||||
|
||||
| 视角 | 盯什么 |
|
||||
|------|--------|
|
||||
| 🔴 正确性 (correctness) | 逻辑错、边界、空值、并发竞态、资源泄漏、错误处理、类型转换 |
|
||||
| 🔒 安全 (security) | 注入(SQL/命令/XSS)、鉴权越权、密钥/Token 泄露、路径穿越、不安全反序列化、弱加密 |
|
||||
| ⚡ 性能 (performance) | N+1 查询、无谓拷贝、O(n²)/嵌套循环、热路径分配、缺索引 |
|
||||
| 🧪 测试 (tests) | 新增/改动代码有无测试、边界用例、断言有效性 |
|
||||
| 🧹 可维护性 (maintainability) | 命名、重复、圈复杂度、抽象边界、与既有约定一致 |
|
||||
| 🚨 生产风险 (prod-risk) | "合并后凌晨3点哪会炸"——可观测性/日志、回滚、破坏性变更、配置依赖、降级 |
|
||||
|
||||
> 各视角的展开检查清单见 [`REFERENCE.md`](REFERENCE.md)。
|
||||
|
||||
## 报告卡格式(作为评论发布)
|
||||
|
||||
```markdown
|
||||
🤖 **gitlink-review 报告**
|
||||
|
||||
**结论**:<总体:✅LGTM / ⚠️建议修改 / 🛑有阻塞>(A 🔴阻塞 / B 🟡建议 / C 🔵nit)
|
||||
**范围**:<X> 文件,+<A>/-<D>(跳过 <Y> 噪音文件)|视角:<L>|自检丢弃:<R>
|
||||
|
||||
| 严重度 | 视角 | 位置 | 问题 |
|
||||
|:---:|---|---|---|
|
||||
| 🔴 | correctness | path/file.go:42 | <一句话> |
|
||||
|
||||
### 🔴 阻塞(合并前需处理)
|
||||
1. **[correctness] path/file.go:42** — <what>
|
||||
- **为什么**:<why>
|
||||
- **建议**:<fix>
|
||||
|
||||
### 🟡 建议
|
||||
…
|
||||
|
||||
### 🔵 nit
|
||||
…
|
||||
|
||||
### ✅ 未发现问题的方面
|
||||
- <视角>:<结论>
|
||||
|
||||
---
|
||||
<!-- gitlink-review v1 | pr:<N> | lenses:<L> | refuted:<R> | sha:<head-sha> -->
|
||||
*由 gitlink-review skill 生成。*
|
||||
```
|
||||
|
||||
## 降噪规则(自动跳过)
|
||||
|
||||
生成代码(`*.gen.go`/`*.pb.go`/`*_generated.*`/`*.min.js`/dist//build//target/)、第三方(vendor//third_party//node_modules/)、锁文件(go.sum/package-lock.json/yarn.lock/pnpm-lock.yaml/Cargo.lock)、纯重命名/移动、二进制/图片/字体。
|
||||
|
||||
## 安全护栏
|
||||
|
||||
- 默认预览确认;`--auto` 跳过但仍受置信度门控
|
||||
- **绝不自动 approve / merge**
|
||||
- 行内评论仅 `--inline` 且 reviews API 可用时发
|
||||
- `--max-findings` 封顶防刷屏
|
||||
- 评论失败 → 把报告卡原文交用户手动粘贴
|
||||
|
||||
## 错误处理与降级
|
||||
|
||||
| 情况 | 处理 |
|
||||
|------|------|
|
||||
| 无 diff | 报"无可审查变更",不发评论 |
|
||||
| PR 不存在/无权限 | 清晰错误,不发评论 |
|
||||
| reviews API 404 | 跳过行内,总结评论照发 |
|
||||
| diff 过大 | 抽样 + 标注"部分审查(仅 X/Y 文件)" |
|
||||
| 评论发布失败 | 输出报告卡原文供手动粘贴 |
|
||||
| 全部 low 置信 | 报"未发现高置信问题",列待人工确认项 |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- **先预览再发布**(默认),降低对外噪音
|
||||
- **置信度优先**:宁可少报,不要误报
|
||||
- **可执行**:每条发现必须带"建议修复"
|
||||
- **不越权**:只评论,不合并
|
||||
````
|
||||
|
||||
- [ ] **Step 3:提交**
|
||||
|
||||
```bash
|
||||
git add skills/gitlink-review/SKILL.md
|
||||
git commit -m "feat(review): 新增 gitlink-review SKILL.md(管道+6视角+对抗自检+报告卡)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5:写 REFERENCE.md(清单 + 评级 + API 结论)
|
||||
|
||||
**Files:**
|
||||
- Create: `skills/gitlink-review/REFERENCE.md`
|
||||
|
||||
- [ ] **Step 1:读 Task 3 的探测结论**
|
||||
|
||||
```bash
|
||||
cat review_api_probe.txt
|
||||
```
|
||||
|
||||
把 `<REVIEWS_API>` 与 `<REVIEWS_NOTES>`(下文占位)替换为真实结论。
|
||||
|
||||
- [ ] **Step 2:写 REFERENCE.md**
|
||||
|
||||
创建 `skills/gitlink-review/REFERENCE.md`,完整内容(**把 `<REVIEWS_API>` 等占位替换为 Task 3 实测结果**):
|
||||
|
||||
````markdown
|
||||
# gitlink-review 参考手册
|
||||
|
||||
> 各视角检查清单、严重度评级、降噪规则、reviews API 实测结论、字段映射。
|
||||
|
||||
---
|
||||
|
||||
## 一、各视角检查清单
|
||||
|
||||
### 🔴 正确性 (correctness)
|
||||
- 空值/nil 解引用未判
|
||||
- 边界:off-by-one、数组越界、空集合
|
||||
- 错误未处理或被吞(`err != nil` 缺失、`_ = err`)
|
||||
- 并发:数据竞态、缺锁、死锁
|
||||
- 资源泄漏:文件/连接/goroutine 未关闭或回收
|
||||
- 类型转换/断言未检查
|
||||
- 逻辑分支遗漏、return 路径不全
|
||||
|
||||
### 🔒 安全 (security)
|
||||
- 注入:SQL 拼接、命令、XSS、模板未转义
|
||||
- 鉴权/越权:缺权限校验、IDOR
|
||||
- 密钥/Token 硬编码或写入日志
|
||||
- 路径穿越(`../`、用户输入拼路径)
|
||||
- 不安全反序列化、弱加密/弱随机
|
||||
- 危险默认值(debug 开关、CORS *)
|
||||
|
||||
### ⚡ 性能 (performance)
|
||||
- N+1 查询、循环内 IO/查询
|
||||
- 无谓拷贝大对象、字符串反复拼接
|
||||
- O(n²)/深层嵌套循环
|
||||
- 热路径分配、缺缓存
|
||||
- 缺索引/全表扫描
|
||||
|
||||
### 🧪 测试 (tests)
|
||||
- 新增/改动函数有无对应测试
|
||||
- 边界与异常用例覆盖
|
||||
- 断言是否有效(非 `assert true`)
|
||||
- mock 是否合理、是否过度
|
||||
|
||||
### 🧹 可维护性 (maintainability)
|
||||
- 命名是否达意
|
||||
- 重复代码(DRY)
|
||||
- 圈复杂度过高、函数过长
|
||||
- 抽象边界模糊、职责混杂
|
||||
- 与既有代码风格/约定不一致
|
||||
|
||||
### 🚨 生产风险 (prod-risk)
|
||||
- 破坏性变更(API/DB schema/配置格式)
|
||||
- 可观测性:关键路径有无日志/指标
|
||||
- 回滚能力:是否可安全回退
|
||||
- 配置/环境依赖、启动顺序
|
||||
- 降级与限流缺失
|
||||
|
||||
---
|
||||
|
||||
## 二、严重度 × likelihood 评级
|
||||
|
||||
| severity | 含义 | 处理 |
|
||||
|----------|------|------|
|
||||
| 🔴 high | 阻塞:会导致 bug/安全问题/线上故障 | 合并前需处理 |
|
||||
| 🟡 medium | 建议:应修复但不强制阻塞 | 建议处理 |
|
||||
| 🔵 low | nit:风格/可读性 | 可选 |
|
||||
|
||||
| likelihood | 含义 |
|
||||
|------------|------|
|
||||
| likely | 真实路径上会发生 |
|
||||
| possible | 特定条件下发生 |
|
||||
| unlikely | 罕见但可能 |
|
||||
|
||||
排序权重:`high×likely` > `high×possible` > `medium×likely` > …。
|
||||
|
||||
置信度 `confidence`:high/medium/low。**门控**:`confidence=low && severity≠high → 丢弃`。
|
||||
|
||||
---
|
||||
|
||||
## 三、降噪规则
|
||||
|
||||
跳过:`*.gen.go`、`*.pb.go`、`*_generated.*`、`*.min.js`、`dist/`、`build/`、`target/`、`vendor/`、`third_party/`、`node_modules/`、`go.sum`、`package-lock.json`、`yarn.lock`、`pnpm-lock.yaml`、`Cargo.lock`、纯重命名/移动、二进制/图片/字体。跳过时在报告卡"范围"行计数。
|
||||
|
||||
---
|
||||
|
||||
## 四、reviews API 实测结论(决定行内评论)
|
||||
|
||||
**实测命令**(于测试 PR `<PRN>`):
|
||||
|
||||
```bash
|
||||
gitlink-cli api POST /<owner>/<repo>/pulls/<N>/reviews \
|
||||
--body '{"body":"...","event":"COMMENT"}'
|
||||
```
|
||||
|
||||
**结论**:<REVIEWS_API>(可用 / 不可用)
|
||||
|
||||
<REVIEWS_NOTES>(实测观察到的响应或错误)
|
||||
|
||||
**设计影响**:
|
||||
- 若 **不可用**(如返回 404 / git exec-path 注入 URL)→ 行内评论关闭,仅用 `pr +comment` 发总结评论。`--inline` 被忽略并提示原因。
|
||||
- 若 **可用** → `--inline` 时对 🔴/🟡 发现调用上述 API 发行内评论;请求体按实测支持的格式(是否含 `line/path/side`)构造。
|
||||
|
||||
---
|
||||
|
||||
## 五、字段映射
|
||||
|
||||
### pr +view 关键字段
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `issue.subject` / `issue.description` | 理解 PR 意图,校准审查重点 |
|
||||
| `pull_request.base` / `pull_request.head` | 目标/源分支 |
|
||||
| `author.login` | 作者(自审盲区提示) |
|
||||
| `files_count` / `commits_count` | 范围概览 |
|
||||
|
||||
### pr +diff 关键字段
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `files[].name` | 文件路径 |
|
||||
| `files[].addition` / `deletion` | 增删行数 |
|
||||
| `files[].sha` | 文件 sha(哨兵可选) |
|
||||
| diff 正文 | 分析输入 |
|
||||
|
||||
**行号映射规则**:报告卡里的 `file:line` 用**文件中的实际行号**(非 diff hunks 的 `+n` 相对行号)。从 diff hunk 头(`@@ -a,b +c,d @@`)推算:实际行号 = `c + (hunk 内相对行)`。
|
||||
|
||||
---
|
||||
|
||||
## 六、幂等哨兵
|
||||
|
||||
```
|
||||
<!-- gitlink-review v1 | pr:<N> | lenses:<L> | refuted:<R> | sha:<head-sha> -->
|
||||
```
|
||||
|
||||
- 发布前用 `pr +view`(或取评论)检查是否已含该哨兵
|
||||
- `sha` = 审查所基于的 head commit;PR 有新提交时提示"审查已过期,建议重审"
|
||||
- 默认不覆盖;`--refresh` 才重发
|
||||
````
|
||||
|
||||
- [ ] **Step 3:提交**
|
||||
|
||||
```bash
|
||||
git add skills/gitlink-review/REFERENCE.md
|
||||
git commit -m "docs(review): 新增 REFERENCE.md(视角清单+评级+API实测结论+字段映射)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6:端到端验证——对测试 PR 跑审查
|
||||
|
||||
**Files:** 无(验证 + 捕获输出)
|
||||
|
||||
- [ ] **Step 1:按 SKILL.md 对 `<PRN>` 执行审查**
|
||||
|
||||
人工/agent 依 SKILL.md 管道执行:取 view/files/diff → 降噪 → 6 视角 → 对抗自检 → 综合 → 生成报告卡。把生成的报告卡全文存到本地 `review_output_<PRN>.md`。
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +view --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||
gitlink-cli pr +files --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||
gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||
```
|
||||
|
||||
- [ ] **Step 2:验证 4 个植入点**
|
||||
|
||||
断言(必须全部满足,否则回改 SKILL.md/REFERENCE.md 后重跑):
|
||||
|
||||
| # | 植入点 | 期望 | 校验 |
|
||||
|---|--------|------|------|
|
||||
| 1 | `Greet` 空指针 | 🔴 correctness 命中,含 `review_fixture.go:<行>` 与 `u.Name` | 报告卡中能找到 |
|
||||
| 2 | `AdminToken` 硬编码 | 🔒 security 命中 | 报告卡中能找到 |
|
||||
| 3 | `Welcome` 缺测试 | 🧪 tests 命中(无 _test.go) | 报告卡中能找到 |
|
||||
| 4 | `Divide` 除零(DoMath 已保护) | **被对抗自检丢弃**,`refuted` 计数 ≥1,**不应**出现在发现列表 | 报告卡中无此项 |
|
||||
|
||||
- [ ] **Step 3:发布到测试 PR 并验证哨兵**
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +comment --owner chroe --repo gitlink-cli --id <PRN> \
|
||||
--body "$(cat review_output_<PRN>.md)" --format json
|
||||
```
|
||||
|
||||
预期:`ok: true`。再 `pr +view` 确认 `comments_count` 增加。
|
||||
|
||||
- [ ] **Step 4:若验证不通过,回改并重跑**
|
||||
|
||||
任何断言失败 → 修正 SKILL.md 的检查清单/自检规则 → 重新执行 Step 1-3,直到 4 项全过。
|
||||
|
||||
---
|
||||
|
||||
## Task 7:写 examples/review-workflow.md(真实走查)
|
||||
|
||||
**Files:**
|
||||
- Create: `skills/gitlink-review/examples/review-workflow.md`
|
||||
|
||||
- [ ] **Step 1:基于 Task 6 的真实输出写示例**
|
||||
|
||||
创建 `skills/gitlink-review/examples/review-workflow.md`:
|
||||
|
||||
````markdown
|
||||
# 示例:对植入缺陷 PR 的智能审查(真实数据)
|
||||
|
||||
> 基于 `chroe/gitlink-cli` 测试 PR #<PRN>(`test/review-fixture`)于 2026-06-18 实际执行。
|
||||
> 该 PR 故意植入 4 个问题用于验证 gitlink-review skill。
|
||||
|
||||
---
|
||||
|
||||
## Step 1:取上下文
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +view --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||
gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||
```
|
||||
|
||||
**范围**:1 文件 `review_fixture.go`,+38/-0,无噪音文件需跳过。
|
||||
|
||||
## Step 2:6 视角扫描 + 对抗自检(关键过程)
|
||||
|
||||
| 候选发现 | 视角 | 自检结果 |
|
||||
|----------|------|----------|
|
||||
| `Greet` 中 `u.Name` 解引用 nil | correctness | ✅ 成立(无外层判空)→ 保留 🔴 |
|
||||
| `AdminToken` 硬编码 | security | ✅ 成立 → 保留 🔴 |
|
||||
| `Welcome` 无对应测试 | tests | ✅ 成立(本 PR 无 _test.go)→ 保留 🟡 |
|
||||
| `Divide(a,b)` 除零 | correctness | ❌ **被反驳**:`DoMath` 已保证 `x≠0`,真实调用路径除数非 0 → 丢弃 |
|
||||
|
||||
**自检丢弃:1 条(refuted=1)**。
|
||||
|
||||
## Step 3:发布报告卡(真实输出)
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +comment --owner chroe --repo gitlink-cli --id <PRN> \
|
||||
--body "<报告卡全文>"
|
||||
```
|
||||
|
||||
<此处粘贴 Task 6 生成的真实报告卡全文>
|
||||
|
||||
**发布结果**:`ok: true`,PR 评论数 +1。
|
||||
|
||||
## Step 4:幂等验证
|
||||
|
||||
再次运行(不带 `--refresh`)→ 检测到哨兵 `<!-- gitlink-review v1 | pr:<PRN> | ... -->` → 提示"已存在审查,使用 --refresh 更新",未重复发布。
|
||||
|
||||
## 关键结论
|
||||
|
||||
- 3 个真实缺陷被对应视角命中,1 个伪阳性被对抗自检丢弃(refuted=1)
|
||||
- 行内评论:<根据 Task 3 结论写"可用已附行内" 或 "API 不可用,仅总结评论">
|
||||
````
|
||||
|
||||
- [ ] **Step 2:提交**
|
||||
|
||||
```bash
|
||||
git add skills/gitlink-review/examples/review-workflow.md
|
||||
git commit -m "docs(review): 新增真实走查示例 review-workflow.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8:更新 README 与 workflow 链接
|
||||
|
||||
**Files:**
|
||||
- Modify: `skills/README.md`
|
||||
- Modify: `skills/gitlink-workflow/SKILL.md`
|
||||
|
||||
- [ ] **Step 1:README 智能 skill 表加一行**
|
||||
|
||||
在 `skills/README.md` 的"智能 Skills"表追加:
|
||||
|
||||
```markdown
|
||||
| **gitlink-review** | 智能代码审查 | 分析 PR diff,多视角评审 + 对抗式自检,结构化 Review 意见自动评论 |
|
||||
```
|
||||
|
||||
并在文档导航/按需处补一行指向 `gitlink-review/SKILL.md`。
|
||||
|
||||
- [ ] **Step 2:workflow 的浅层 Code Review 链回本 skill**
|
||||
|
||||
在 `skills/gitlink-workflow/SKILL.md` 的"AI 在 PR 流程中的角色 → Code Review"处补一句:
|
||||
|
||||
```markdown
|
||||
> 深度代码审查请使用 [`../gitlink-review/SKILL.md`](../gitlink-review/SKILL.md)(多视角 + 对抗式自检 + 自动评论)。
|
||||
```
|
||||
|
||||
- [ ] **Step 3:提交**
|
||||
|
||||
```bash
|
||||
git add skills/README.md skills/gitlink-workflow/SKILL.md
|
||||
git commit -m "docs(review): README 智能表登记 gitlink-review,workflow 链回深度审查"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9:清理测试 PR + 推送特性分支 + 建 PR
|
||||
|
||||
**Files:** 无
|
||||
|
||||
- [ ] **Step 1:关闭测试 PR(不合并)**
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +close --owner chroe --repo gitlink-cli --id <PRN>
|
||||
```
|
||||
|
||||
预期:`ok: true`,PR 状态变 closed(未合并,植入缺陷不进 master)。
|
||||
|
||||
- [ ] **Step 2:删本地/远端测试分支**
|
||||
|
||||
```bash
|
||||
git branch -D test/review-fixture
|
||||
git push origin --delete test/review-fixture
|
||||
```
|
||||
|
||||
- [ ] **Step 3:清理本地临时文件**
|
||||
|
||||
```bash
|
||||
rm -f review_fixture_notes.txt review_api_probe.txt review_output_*.md
|
||||
```
|
||||
|
||||
- [ ] **Step 4:推送特性分支**
|
||||
|
||||
```bash
|
||||
git checkout feat/gitlink-review-skill
|
||||
git push origin feat/gitlink-review-skill
|
||||
```
|
||||
|
||||
- [ ] **Step 5:创建合并到 master 的 PR**
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +create \
|
||||
--owner chroe --repo gitlink-cli \
|
||||
--head feat/gitlink-review-skill --base master \
|
||||
--title "feat: 新增 gitlink-review 智能代码审查 Skill" \
|
||||
--body "## 目的
|
||||
子任务二核心场景:智能代码审查(分析 PR diff,多视角+对抗自检,结构化 Review 自动评论)。
|
||||
|
||||
## 交付
|
||||
- skills/gitlink-review/:SKILL.md + REFERENCE.md + examples/review-workflow.md
|
||||
- skills/README.md、skills/gitlink-workflow/SKILL.md:登记与链接
|
||||
- docs/superpowers/:设计 spec + 实现计划
|
||||
|
||||
## 验证(真实数据)
|
||||
植入缺陷测试 PR 已验证:3 个真实缺陷被对应视角命中,1 个伪阳性被对抗自检丢弃(refuted=1),哨兵幂等,pr +comment 发布成功。reviews API:<填 Task 3 结论>。
|
||||
|
||||
## 设计要点
|
||||
- 多视角评审团(正确性/安全/性能/测试/可维护/生产风险)
|
||||
- 对抗式自检质量门(直击误报)
|
||||
- 默认预览确认,绝不自动合并" \
|
||||
--format json
|
||||
```
|
||||
|
||||
- [ ] **Step 6:记录 PR 号并通知用户**
|
||||
|
||||
把返回的 `pull_request_number` 报告给用户,等待其合并(master 受保护)。
|
||||
|
||||
---
|
||||
|
||||
## 验收(全部满足才算完成)
|
||||
|
||||
- [ ] `skills/gitlink-review/` 三件套齐全,结构与 triage/health 一致
|
||||
- [ ] Task 6 四个植入点断言全过(3 命中 + 1 丢弃)
|
||||
- [ ] REFERENCE.md 的 reviews API 结论为实测结果(非猜测)
|
||||
- [ ] 报告卡含哨兵,幂等生效
|
||||
- [ ] README 与 workflow 链接已更新
|
||||
- [ ] 测试 PR 已关闭未合并,测试分支已删
|
||||
- [ ] 特性分支已推送,PR 已创建
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
# 智能代码审查 Skill(gitlink-review)设计
|
||||
|
||||
- **日期**:2026-06-18
|
||||
- **状态**:已批准,待编写实现计划
|
||||
- **作者**:CWQ + Claude
|
||||
- **定位**:子任务二核心场景之一——智能代码审查;纯 Markdown skill(SKILL.md + REFERENCE.md + examples/),无需 Go 代码
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
gitlink-cli 现有 16 个 skill 中**没有专门的代码审查 skill**:`gitlink-workflow` 的"PR 全流程"仅在 Step 5 浅层提及(`pr +files` + "给出审查意见")。README 列的智能 skill 只有 health / changelog / triage 三件套。
|
||||
|
||||
本 skill 填补该缺口:引导 AI agent 对指定 GitLink PR 执行**结构化、多视角、低误报**的代码审查,并把结构化审查意见作为评论发布到 PR。
|
||||
|
||||
**实用性目标**:信噪比高、误报少、每条发现可执行(带 `file:line` + 修复建议 + 理由)、能稳定落地到 GitLink(`pr +comment` 已验证可用)。
|
||||
|
||||
**创意性目标**:多视角"评审团" + 对抗式自检(自带质量门,直击 AI code review 的误报老大难)+ 生产风险(oncall)视角。
|
||||
|
||||
## 2. 非目标(YAGNI)
|
||||
|
||||
- 不做 CI / webhook 自动触发——按需由 agent 调用
|
||||
- **绝不自动 approve / merge**
|
||||
- 不做跨仓库批量(单 PR 为主;批量留作可选入口,不在首版实现)
|
||||
- 不修改 gitlink-cli 的 Go 代码——纯 skill 交付物
|
||||
|
||||
## 3. 关键决策(用户确认)
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|--------|------|
|
||||
| 评论形式 | 总结评论为主(`pr +comment`,可靠)+ 行内评论增强(reviews API 可用时附加,否则降级) |
|
||||
| 发表策略 | 默认预览、确认后发;`--auto` 跳过确认(仍受置信度门控) |
|
||||
| 审查引擎 | 多视角评审团(6 视角全量)+ 对抗式自检 |
|
||||
|
||||
## 4. 文件清单与改动范围
|
||||
|
||||
| 文件 | 动作 | 内容 |
|
||||
|------|------|------|
|
||||
| `skills/gitlink-review/SKILL.md` | 新增 | 主管道、6 视角、对抗自检、降噪、输出格式、安全护栏、命令接口 |
|
||||
| `skills/gitlink-review/REFERENCE.md` | 新增 | 各视角检查清单、严重度评级表、字段映射、reviews API 实测结论与降级、噪音文件规则 |
|
||||
| `skills/gitlink-review/examples/review-workflow.md` | 新增 | 真实 PR 走查(含植入缺陷验证、真实输出) |
|
||||
| `skills/README.md` | 修改 | 智能技能表新增 gitlink-review |
|
||||
| `skills/gitlink-workflow/SKILL.md` | 修改 | 浅层 Code Review 步骤链回本 skill |
|
||||
|
||||
## 5. 命令接口
|
||||
|
||||
skill 由 agent 调用,参数(约定,非 CLI 子命令):
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `--owner/--repo` | 自动从 cwd 解析 | 目标仓库 |
|
||||
| `--id` | 必填 | PR 编号(`pull_request_number`,网页 URL `/pulls/N`) |
|
||||
| `--lenses` | 全 6 视角 | 指定子集,如 `correctness,security` |
|
||||
| `--auto` | 关 | 跳过预览确认,直接发布(仍受置信度门控) |
|
||||
| `--inline` | 关 | 尝试附行内评论(reviews API 可用时) |
|
||||
| `--max-findings` | 12 | 报告卡上限,防刷屏 |
|
||||
| `--refresh` | 关 | 即使检测到旧审查哨兵也重审并更新 |
|
||||
|
||||
## 6. 数据流(8 步管道)
|
||||
|
||||
```
|
||||
① 取上下文 pr +view / pr +files / pr +diff(核心输入)/ repo +info(语言校准、约定)
|
||||
② 降噪过滤 跳过生成代码 / vendor / lock / 纯重命名 / 二进制 / minified
|
||||
③ 多视角分析 6 视角并行扫 diff → 候选发现
|
||||
④ 对抗式自检 逐条尝试反驳 → 丢弃不成立/低置信
|
||||
⑤ 综合 跨视角去重、按 严重度×likelihood 排序、封顶 → 报告卡
|
||||
⑥ 预览 默认展示给用户,确认后发(--auto 跳过)
|
||||
⑦ 发布 pr +comment 发总结评论(可靠);--inline 且 reviews API 可用 → 附行内评论,否则降级
|
||||
⑧ 幂等 评论内嵌哨兵 <!-- gitlink-review v1 -->,重跑检测旧审查 → 提议更新而非刷屏
|
||||
```
|
||||
|
||||
每条候选发现的数据结构(内部):
|
||||
|
||||
```json
|
||||
{
|
||||
"lens": "correctness",
|
||||
"severity": "high", // high(🔴) | medium(🟡) | low(🔵)
|
||||
"likelihood": "likely", // likely | possible | unlikely
|
||||
"file": "auth/login.go",
|
||||
"line": 42,
|
||||
"what": "空指针未判",
|
||||
"why": "GetUser 在 token 无效时返回 nil,此处直接解引用",
|
||||
"fix": "if u := GetUser(t); u != nil { ... }",
|
||||
"confidence": "high" // high | medium | low
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 评审团 6 视角
|
||||
|
||||
| 视角 | 盯什么 |
|
||||
|------|--------|
|
||||
| 🔴 正确性 (correctness) | 逻辑错、边界、空值、并发竞态、资源泄漏、错误处理、类型转换 |
|
||||
| 🔒 安全 (security) | 注入(SQL/命令/XSS)、鉴权越权、密钥/Token 泄露、路径穿越、不安全反序列化、弱加密 |
|
||||
| ⚡ 性能 (performance) | N+1 查询、无谓拷贝、O(n²)/嵌套循环、热路径分配、缺索引、大对象常驻 |
|
||||
| 🧪 测试 (tests) | 新增/改动代码有无对应测试、边界用例、断言是否有效、mock 是否合理 |
|
||||
| 🧹 可维护性 (maintainability) | 命名、重复代码、圈复杂度、抽象边界、与既有约定一致性 |
|
||||
| 🚨 生产风险 (prod-risk) | "合并后凌晨3点哪会炸"——可观测性/日志、回滚能力、破坏性变更(API/DB/schema)、配置依赖、降级路径 |
|
||||
|
||||
各视角的展开检查清单写入 `REFERENCE.md`。
|
||||
|
||||
## 8. 对抗式自检(质量门,创意核心)
|
||||
|
||||
对每条候选发现,agent 自我问以下问题,任一成立即丢弃或降级:
|
||||
|
||||
1. **语境已处理**:完整上下文里是否已有保护(外层判空、上游校验、框架机制)?
|
||||
2. **风格冒充 bug**:这是真缺陷还是个人风格偏好?若是偏好,降级为 🔵 nit 或丢弃。
|
||||
3. **幻觉检查**:我引用的 API/函数签名/语言行为是否真实存在?(不确定则不报,或标注"待确认")
|
||||
4. **重复/已被覆盖**:是否与另一视角的发现其实是同一问题?
|
||||
|
||||
**置信度门控**:`confidence=low` 且 `severity≠high` → 丢弃。这保证只发高信号发现。
|
||||
|
||||
## 9. 降噪规则(自动跳过,不审查)
|
||||
|
||||
- 生成代码:`*.gen.go`、`*.pb.go`、`*_generated.*`、`*.min.js`、dist/、build/、target/
|
||||
- 第三方:`vendor/`、`third_party/`、`node_modules/`
|
||||
- 锁文件:`go.sum`、`package-lock.json`、`yarn.lock`、`pnpm-lock.yaml`、`Cargo.lock`
|
||||
- 纯重命名/移动(无内容变更)
|
||||
- 二进制文件、图片、字体
|
||||
|
||||
跳过时在报告卡"范围"行注明跳过数量,不静默吞掉。
|
||||
|
||||
## 10. 输出格式(报告卡,作为 PR 评论发布)
|
||||
|
||||
```markdown
|
||||
🤖 **gitlink-review 报告**
|
||||
|
||||
**结论**:⚠️ 建议修改(2 🔴阻塞 / 4 🟡建议 / 3 🔵nit)
|
||||
**范围**:5 文件,+120/-30(跳过 2 噪音文件)|视角:6|自检丢弃:3
|
||||
|
||||
| 严重度 | 视角 | 位置 | 问题 |
|
||||
|:---:|---|---|---|
|
||||
| 🔴 | 正确性 | auth/login.go:42 | 空指针未判 |
|
||||
| 🔴 | 安全 | config.go:8 | 硬编码 Token |
|
||||
| 🟡 | 性能 | list.go:88 | 循环内重复查询 |
|
||||
|
||||
### 🔴 阻塞(合并前需处理)
|
||||
1. **[正确性] auth/login.go:42** — `GetUser(token)` 在 token 无效时返回 nil,此处直接解引用会 panic。
|
||||
- **建议**:`if u := GetUser(t); u != nil { ... }`
|
||||
2. **[安全] config.go:8** — 硬编码 Token,存在泄露风险。
|
||||
- **建议**:改从环境变量读取 `os.Getenv("GITLINK_TOKEN")`
|
||||
|
||||
### 🟡 建议
|
||||
…
|
||||
|
||||
### 🔵 nit
|
||||
…
|
||||
|
||||
### ✅ 未发现问题的方面
|
||||
- 安全:未发现注入点
|
||||
- 生产风险:无破坏性 API 变更
|
||||
|
||||
---
|
||||
<!-- gitlink-review v1 | pr:5 | lenses:6 | refuted:3 | sha:<head-sha> -->
|
||||
*由 gitlink-review skill 生成;本评论为预览确认后发布。*
|
||||
```
|
||||
|
||||
哨兵 `<!-- gitlink-review v1 | pr:N | lenses | refuted | sha -->` 用于幂等与"针对哪个 commit"标识。
|
||||
|
||||
## 11. 安全护栏
|
||||
|
||||
- 默认预览确认;`--auto` 跳过确认但**仍受置信度门控**
|
||||
- **绝不自动 approve / merge**(即使 `--auto`)
|
||||
- 行内评论仅在显式 `--inline` 且 reviews API 实测可用时发,否则不发
|
||||
- `--max-findings` 封顶,防刷屏
|
||||
- 所有写操作需认证(遵循 `gitlink-shared`)
|
||||
- 跳过自己作者本人的 PR 时可提示(避免自审盲区),但不强制阻断
|
||||
|
||||
## 12. 错误处理与降级
|
||||
|
||||
| 情况 | 处理 |
|
||||
|------|------|
|
||||
| `pr +diff` 无差异 | 报告"无可审查变更",不发评论 |
|
||||
| PR 不存在/无权限 | 清晰错误信息,不发评论 |
|
||||
| reviews API 404(已知 `api POST` bug) | 记录、跳过行内、总结评论照发 |
|
||||
| diff 过大(> 阈值) | 抽样审查 + 标注"部分审查(仅 X/Y 文件)" |
|
||||
| `pr +comment` 发布失败 | 把报告卡原文输出给用户手动粘贴 |
|
||||
| 置信度全部 low | 报告"未发现高置信问题",列出待人工确认项 |
|
||||
|
||||
## 13. 幂等与去重
|
||||
|
||||
- 评论内嵌哨兵;重跑时先 `pr +view`/取评论检测哨兵
|
||||
- 已存在旧审查:默认提议"更新"(`--refresh` 才覆盖),避免重复发
|
||||
- `sha` 字段记录审查所基于的 head commit;PR 有新提交时提示"审查已过期,建议重审"
|
||||
|
||||
## 14. 验证计划(真实数据,沿用其他 skill 惯例)
|
||||
|
||||
建一个**植入已知缺陷的小测试 PR**,包含:
|
||||
|
||||
1. 一处空指针/越界(验证 🔴 正确性视角命中)
|
||||
2. 一处硬编码密钥(验证 🔒 安全视角命中)
|
||||
3. 一处缺测试的新函数(验证 🧪 测试视角命中)
|
||||
4. 一处"看起来像 bug 但语境已处理"的伪阳性(验证对抗自检丢弃)
|
||||
|
||||
验证项:
|
||||
- [ ] 每个植入缺陷被对应视角命中
|
||||
- [ ] 伪阳性被对抗自检丢弃(refuted 计数 +1)
|
||||
- [ ] 哨兵正确内嵌,重跑不刷屏
|
||||
- [ ] 总结评论通过 `pr +comment` 成功发布
|
||||
- [ ] reviews API 实测:记录可用/不可用结论写入 REFERENCE.md
|
||||
- [ ] `--auto` 与预览两种模式均验证
|
||||
|
||||
结果(真实输出)写入 `examples/review-workflow.md`。
|
||||
|
||||
## 15. 风险与未决
|
||||
|
||||
- **reviews API 可用性**:`gitlink-triage` 记录 `api POST` 有 URL 注入 bug 导致 404。本 skill 的行内评论依赖 `POST /:owner/:repo/pulls/:id/reviews`,实现时**必须实测**;不可用则设计已内建降级(仅总结评论)。结论写入 REFERENCE.md。
|
||||
- **token 消耗**:6 视角全量较重;通过 `--lenses` 子集和 `--max-findings` 控制。
|
||||
- **行号偏移**:diff 行号 vs 文件行号的映射需在 REFERENCE.md 给出规则,保证 `file:line` 准确。
|
||||
|
||||
## 16. 验收标准
|
||||
|
||||
1. `skills/gitlink-review/` 三件套齐全,结构与 triage/health 等智能 skill 一致
|
||||
2. 测试 PR 的 4 个植入点验证全部通过(3 命中 + 1 丢弃)
|
||||
3. 总结评论在真实 PR 成功发布,含哨兵
|
||||
4. reviews API 可用性有明确实测结论
|
||||
5. README 与 workflow 链接更新
|
||||
BIN
gitlink-cli.exe
BIN
gitlink-cli.exe
Binary file not shown.
|
|
@ -4,7 +4,6 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
|
|
@ -42,7 +41,8 @@ func DeleteToken() error {
|
|||
// File-based fallback
|
||||
|
||||
func credentialPath() string {
|
||||
return filepath.Join(config.ConfigDir(), "credentials")
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
||||
}
|
||||
|
||||
func storeTokenFile(token string) error {
|
||||
|
|
|
|||
|
|
@ -24,17 +24,12 @@ type APIError struct {
|
|||
StatusCode int
|
||||
Code interface{}
|
||||
Message string
|
||||
Suggestion string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func (e *APIError) Suggest() string {
|
||||
return e.Suggestion
|
||||
}
|
||||
|
||||
func New() (*Client, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
|
|
@ -82,10 +77,6 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
if c.Debug {
|
||||
fmt.Printf("→ %s %s\n", method, fullURL)
|
||||
}
|
||||
|
|
@ -107,12 +98,10 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
|
||||
// Check HTTP-level errors
|
||||
if resp.StatusCode >= 400 {
|
||||
suggestion := suggestFix(resp.StatusCode)
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: resp.StatusCode,
|
||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
|
||||
Suggestion: suggestion,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -139,7 +128,6 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
StatusCode: int(statusCode),
|
||||
Code: int(statusCode),
|
||||
Message: msg,
|
||||
Suggestion: suggestion,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -186,100 +174,16 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error)
|
|||
return c.Do("DELETE", path, nil, query)
|
||||
}
|
||||
|
||||
// DoRaw sends a request without appending the .json suffix.
|
||||
func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
|
||||
fullURL := c.BaseURL + path
|
||||
if query != nil && len(query) > 0 {
|
||||
fullURL += "?" + query.Encode()
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
if c.Debug {
|
||||
fmt.Printf("→ %s %s\n", method, fullURL)
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
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)]))
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: resp.StatusCode,
|
||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
|
||||
}
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(respData, &raw); err != nil {
|
||||
return output.SuccessEnvelope(string(respData), nil), nil
|
||||
}
|
||||
|
||||
// Check error-in-body pattern
|
||||
if status, ok := raw["status"]; ok {
|
||||
var statusCode float64
|
||||
switch v := status.(type) {
|
||||
case float64:
|
||||
statusCode = v
|
||||
case int:
|
||||
statusCode = float64(v)
|
||||
}
|
||||
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
|
||||
msg, _ := raw["message"].(string)
|
||||
return output.ErrorEnvelope(int(statusCode), msg, ""), &APIError{
|
||||
StatusCode: int(statusCode),
|
||||
Code: int(statusCode),
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output.SuccessEnvelope(raw, nil), nil
|
||||
}
|
||||
|
||||
func suggestFix(code int) string {
|
||||
switch code {
|
||||
case 400:
|
||||
return "请求参数格式错误,请检查参数值是否正确"
|
||||
case 401:
|
||||
return "请先运行 gitlink-cli auth login 登录"
|
||||
case 403:
|
||||
return "权限不足,请确认账户权限或联系项目管理员"
|
||||
case 404:
|
||||
return "资源不存在,请检查 owner/repo/id 是否正确"
|
||||
case 409:
|
||||
return "资源冲突,可能存在同名资源"
|
||||
case 422:
|
||||
return "参数校验失败,请检查请求参数"
|
||||
case 500, 502, 503:
|
||||
return "服务器内部错误,请稍后重试或联系平台管理员"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package config
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
|
@ -14,11 +13,10 @@ const (
|
|||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Format string `yaml:"default_format"`
|
||||
Editor string `yaml:"editor,omitempty"`
|
||||
Pager string `yaml:"pager,omitempty"`
|
||||
AnthropicAPIKey string `yaml:"anthropic_api_key,omitempty"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Format string `yaml:"default_format"`
|
||||
Editor string `yaml:"editor,omitempty"`
|
||||
Pager string `yaml:"pager,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
|
|
@ -32,11 +30,6 @@ func ConfigDir() string {
|
|||
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if appData := os.Getenv("AppData"); appData != "" {
|
||||
return filepath.Join(appData, "gitlink-cli")
|
||||
}
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("无法从 git remote 自动检测 owner/repo: %w\n请使用 --owner 和 --repo 参数手动指定", err)
|
||||
return "", "", fmt.Errorf("cannot detect owner/repo from git remote: %w\nUse --owner and --repo flags to specify explicitly", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) {
|
|||
func fromGitRemote() (string, string, error) {
|
||||
out, err := exec.Command("git", "remote", "get-url", "origin").Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("当前目录不是 git 仓库或未配置 remote 'origin'")
|
||||
return "", "", fmt.Errorf("not a git repository or no remote 'origin'")
|
||||
}
|
||||
remote := strings.TrimSpace(string(out))
|
||||
return parseRemoteURL(remote)
|
||||
|
|
@ -45,7 +45,7 @@ func parseRemoteURL(remote string) (string, string, error) {
|
|||
if strings.HasPrefix(remote, "git@") {
|
||||
parts := strings.SplitN(remote, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", fmt.Errorf("无法解析 SSH 远程地址: %s", remote)
|
||||
return "", "", fmt.Errorf("cannot parse SSH remote: %s", remote)
|
||||
}
|
||||
return parsePathSegments(parts[1])
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ func parseRemoteURL(remote string) (string, string, error) {
|
|||
// HTTPS format: https://www.gitlink.org.cn/owner/repo.git
|
||||
u, err := url.Parse(remote)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("无法解析远程地址 URL: %s", remote)
|
||||
return "", "", fmt.Errorf("cannot parse remote URL: %s", remote)
|
||||
}
|
||||
return parsePathSegments(u.Path)
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ func parsePathSegments(path string) (string, string, error) {
|
|||
path = strings.TrimSuffix(path, ".git")
|
||||
parts := strings.SplitN(path, "/", 3)
|
||||
if len(parts) < 2 {
|
||||
return "", "", fmt.Errorf("无法从路径提取 owner/repo: %s", path)
|
||||
return "", "", fmt.Errorf("cannot extract owner/repo from path: %s", path)
|
||||
}
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Suggester is implemented by errors that carry a user-facing suggestion.
|
||||
type Suggester interface {
|
||||
Suggest() string
|
||||
}
|
||||
|
||||
// Error is a CLI error with an optional suggestion for the user.
|
||||
type Error struct {
|
||||
Message string
|
||||
Suggestion string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("%s: %v", e.Message, e.Err)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (e *Error) Suggest() string {
|
||||
return e.Suggestion
|
||||
}
|
||||
|
||||
// New creates a new Error with an optional suggestion.
|
||||
func New(message, suggestion string) *Error {
|
||||
return &Error{Message: message, Suggestion: suggestion}
|
||||
}
|
||||
|
||||
// Wrapf wraps an error with a formatted message and optional suggestion.
|
||||
func Wrapf(err error, suggestion, format string, args ...interface{}) *Error {
|
||||
return &Error{
|
||||
Message: fmt.Sprintf(format, args...),
|
||||
Suggestion: suggestion,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap wraps an error with a message and optional suggestion.
|
||||
func Wrap(err error, message, suggestion string) *Error {
|
||||
return &Error{Message: message, Suggestion: suggestion, Err: err}
|
||||
}
|
||||
|
||||
// FindSuggestion walks the error chain and returns the first suggestion found.
|
||||
func FindSuggestion(err error) string {
|
||||
var s Suggester
|
||||
if errors.As(err, &s) {
|
||||
return s.Suggest()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -66,68 +66,22 @@ func printTable(w io.Writer, envelope *Envelope) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Convert struct types to generic map/slice via JSON round-trip
|
||||
data := envelope.Data
|
||||
if _, ok := data.(map[string]interface{}); !ok {
|
||||
if _, ok := data.([]interface{}); !ok {
|
||||
generic, err := toGeneric(data)
|
||||
if err != nil {
|
||||
return printJSON(w, envelope)
|
||||
}
|
||||
data = generic
|
||||
}
|
||||
}
|
||||
|
||||
switch data := data.(type) {
|
||||
// Try to render as table if data is a slice of maps
|
||||
switch data := envelope.Data.(type) {
|
||||
case []interface{}:
|
||||
return printSliceTable(w, data)
|
||||
case map[string]interface{}:
|
||||
if slice := findSliceInMap(data); slice != nil {
|
||||
return printSliceTable(w, slice)
|
||||
}
|
||||
// For maps with nested structures, prefer JSON
|
||||
if hasComplexValues(data) {
|
||||
return printJSON(w, envelope)
|
||||
}
|
||||
return printMapTable(w, data)
|
||||
default:
|
||||
// Fallback to JSON
|
||||
return printJSON(w, envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func toGeneric(v interface{}) (interface{}, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(b, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func findSliceInMap(m map[string]interface{}) []interface{} {
|
||||
for _, key := range []string{
|
||||
"issues", "pull_requests", "milestones", "webhooks", "issue_tags",
|
||||
"commits", "files", "members", "collaborators", "users", "branches",
|
||||
"releases", "entries", "tags", "watchers", "results",
|
||||
} {
|
||||
if v, ok := m[key]; ok {
|
||||
if slice, ok := v.([]interface{}); ok && len(slice) > 0 {
|
||||
return slice
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, v := range m {
|
||||
if slice, ok := v.([]interface{}); ok && len(slice) > 0 {
|
||||
if _, isMap := slice[0].(map[string]interface{}); isMap {
|
||||
return slice
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasComplexValues(m map[string]interface{}) bool {
|
||||
for _, v := range m {
|
||||
switch v.(type) {
|
||||
|
|
@ -144,6 +98,7 @@ func printSliceTable(w io.Writer, items []interface{}) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Collect headers from first item
|
||||
first, ok := items[0].(map[string]interface{})
|
||||
if !ok {
|
||||
data, _ := json.MarshalIndent(items, "", " ")
|
||||
|
|
@ -154,6 +109,7 @@ func printSliceTable(w io.Writer, items []interface{}) error {
|
|||
headers := collectKeys(first)
|
||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
|
||||
// Print headers
|
||||
fmt.Fprintln(tw, strings.Join(headers, "\t"))
|
||||
dashes := make([]string, len(headers))
|
||||
for i, h := range headers {
|
||||
|
|
@ -161,6 +117,7 @@ func printSliceTable(w io.Writer, items []interface{}) error {
|
|||
}
|
||||
fmt.Fprintln(tw, strings.Join(dashes, "\t"))
|
||||
|
||||
// Print rows
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
|
|
@ -187,6 +144,7 @@ func printMapTable(w io.Writer, m map[string]interface{}) error {
|
|||
|
||||
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 {
|
||||
|
|
@ -212,21 +170,10 @@ func formatValue(v interface{}) string {
|
|||
case reflect.Map, reflect.Slice:
|
||||
data, _ := json.Marshal(v)
|
||||
s := string(data)
|
||||
if len(s) > 50 {
|
||||
return s[:47] + "..."
|
||||
if len(s) > 60 {
|
||||
return s[:57] + "..."
|
||||
}
|
||||
return s
|
||||
case reflect.Bool:
|
||||
if v.(bool) {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
case reflect.Float64:
|
||||
f := v.(float64)
|
||||
if f == float64(int64(f)) {
|
||||
return fmt.Sprintf("%d", int64(f))
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
|
|||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
freebsd: "freebsd",
|
||||
};
|
||||
|
||||
const archMap = {
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ PLATFORMS=(
|
|||
"linux/arm64"
|
||||
"windows/amd64"
|
||||
"windows/arm64"
|
||||
"freebsd/amd64"
|
||||
"freebsd/arm64"
|
||||
)
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
#!/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";
|
||||
|
||||
function getPlatformInfo() {
|
||||
const platform = os.platform();
|
||||
const 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}`);
|
||||
}
|
||||
return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" };
|
||||
}
|
||||
|
||||
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) => {
|
||||
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")); 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}`));
|
||||
return;
|
||||
}
|
||||
if (options.json) {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () => {
|
||||
try { resolve(JSON.parse(body)); } catch (e) { reject(new Error("JSON parse failed")); }
|
||||
});
|
||||
} else {
|
||||
const chunks = [];
|
||||
res.on("data", (chunk) => chunks.push(chunk));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.setTimeout(30000, () => { req.destroy(); reject(new Error("Timeout")); });
|
||||
}
|
||||
doRequest(url);
|
||||
});
|
||||
}
|
||||
|
||||
async function findReleaseAsset(platform, arch) {
|
||||
const archiveName = `gitlink-cli_${VERSION}_${platform}_${arch}.tar.gz`;
|
||||
const tagName = `v${VERSION}`;
|
||||
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
|
||||
|
||||
try {
|
||||
const releases = await fetch(apiUrl, { json: true });
|
||||
const rlist = Array.isArray(releases) ? releases : (releases && releases.releases ? releases.releases : []);
|
||||
let release = rlist.find(r => r.tag_name === tagName || r.tag_name === VERSION);
|
||||
if (!release && rlist.length > 0) release = rlist[0];
|
||||
|
||||
if (release && release.attachments) {
|
||||
let asset = release.attachments.find(a => a.title === archiveName || a.filename === archiveName);
|
||||
if (!asset) {
|
||||
const pattern = `_${platform}_${arch}.tar.gz`;
|
||||
asset = release.attachments.find(a => (a.title || a.filename || "").endsWith(pattern));
|
||||
}
|
||||
if (asset) {
|
||||
let url = asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
|
||||
if (url.startsWith("/")) url = RELEASE_BASE + url;
|
||||
return url;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`;
|
||||
}
|
||||
|
||||
async function downloadAndExtract(url, destDir, platform) {
|
||||
const data = await fetch(url);
|
||||
const archivePath = path.join(destDir, "download.tar.gz");
|
||||
fs.writeFileSync(archivePath, data);
|
||||
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
|
||||
fs.unlinkSync(archivePath);
|
||||
|
||||
const binaryPath = path.join(destDir, BINARY_NAME);
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
const files = fs.readdirSync(destDir);
|
||||
for (const file of files) {
|
||||
const subPath = path.join(destDir, file, BINARY_NAME);
|
||||
if (fs.existsSync(subPath)) { fs.renameSync(subPath, binaryPath); break; }
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync(binaryPath)) throw new Error("Binary not found after extraction");
|
||||
fs.chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { platform, arch } = getPlatformInfo();
|
||||
const binDir = path.join(__dirname, "..", "bin");
|
||||
if (!fs.existsSync(binDir)) { fs.mkdirSync(binDir, { recursive: true }); }
|
||||
|
||||
const binaryPath = path.join(binDir, BINARY_NAME);
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
try {
|
||||
const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 });
|
||||
if (output.includes(VERSION)) {
|
||||
console.log(`${BINARY_NAME} v${VERSION} already installed.`);
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
fs.unlinkSync(binaryPath);
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadUrl = await findReleaseAsset(platform, arch);
|
||||
await downloadAndExtract(downloadUrl, binDir, platform);
|
||||
console.log(`${BINARY_NAME} v${VERSION} installed.`);
|
||||
} catch (err) {
|
||||
// Don't fail npm install — binary can be installed later
|
||||
console.warn(`⚠ ${BINARY_NAME} binary download failed: ${err.message}`);
|
||||
console.warn(` Skills are installed. You can install the binary manually later:`);
|
||||
console.warn(` npm run postinstall`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Link skills/gitlink-* into ~/.claude/skills/ so Claude Code can load and
|
||||
# invoke them through the Skill tool (e.g. `Skill gitlink-issue`).
|
||||
#
|
||||
# Cross-platform:
|
||||
# - Windows (Git Bash / MSYS): directory junction (mklink /J, no admin needed)
|
||||
# - macOS / Linux: symlink
|
||||
#
|
||||
# Links point at the in-repo skills/ directory, so updating the repo keeps the
|
||||
# Skill content in sync. The script is idempotent and safe to re-run.
|
||||
#
|
||||
# CRITICAL (Windows): an existing link is removed with `rmdir` (no /s), never
|
||||
# `rm -rf` — `rm -rf` would follow the junction and DELETE THE SOURCE FILES.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SRC="$PROJECT_DIR/skills"
|
||||
DST="$HOME/.claude/skills"
|
||||
|
||||
# --- collect gitlink-* skills ---
|
||||
shopt -s nullglob
|
||||
SKILLS=("$SRC"/gitlink-*/)
|
||||
shopt -u nullglob
|
||||
if [ ${#SKILLS[@]} -eq 0 ]; then
|
||||
echo "No gitlink-* skills found under $SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DST"
|
||||
|
||||
# --- detect platform ---
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) PLATFORM=windows ;;
|
||||
*) PLATFORM=unix ;;
|
||||
esac
|
||||
|
||||
OK=0
|
||||
FAIL=0
|
||||
|
||||
for skill in "${SKILLS[@]}"; do
|
||||
skill="${skill%/}" # strip trailing slash for clean paths
|
||||
name="$(basename "$skill")"
|
||||
link="$DST/$name"
|
||||
|
||||
if [ "$PLATFORM" = "windows" ]; then
|
||||
win_link="$(cygpath -w "$link")"
|
||||
win_src="$(cygpath -w "$skill")"
|
||||
|
||||
if [ -e "$link" ] || [ -L "$link" ]; then
|
||||
# rmdir (no /s) removes only the junction/symlink, never the target.
|
||||
if ! cmd //c rmdir "$win_link" >/dev/null 2>&1; then
|
||||
echo "SKIP $name (existing path is not a link — left untouched)"
|
||||
FAIL=$((FAIL+1)); continue
|
||||
fi
|
||||
fi
|
||||
|
||||
if powershell -NoProfile -Command \
|
||||
"New-Item -ItemType Junction -Path '$win_link' -Target '$win_src' -ErrorAction Stop" \
|
||||
>/dev/null 2>&1; then
|
||||
echo "OK $name"; OK=$((OK+1))
|
||||
else
|
||||
echo "FAIL $name"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
else
|
||||
# ln -sfn replaces an existing symlink safely (does not follow it).
|
||||
if ln -sfn "$skill" "$link"; then
|
||||
echo "OK $name"; OK=$((OK+1))
|
||||
else
|
||||
echo "FAIL $name"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done: $OK linked, $FAIL failed."
|
||||
echo "Target: $DST"
|
||||
echo "Skills are now invocable via the Claude Code Skill tool."
|
||||
|
|
@ -18,14 +18,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取分支列表失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -39,12 +39,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
from := ctx.Arg("from")
|
||||
if from == "" {
|
||||
from = "master"
|
||||
|
|
@ -55,7 +52,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建分支失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -68,18 +65,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除分支失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -92,18 +86,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("设置分支保护失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -116,15 +107,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("取消分支保护失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
package branch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBranchList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/branches.json" {
|
||||
writeJSON(t, w, []map[string]interface{}{
|
||||
{"name": "master", "protected": true},
|
||||
{"name": "develop", "protected": false},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBranchShortcut(t, server, "list", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("branch list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCreate(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/branches.json" {
|
||||
body = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"name": "feature-x"})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBranchShortcut(t, server, "create", map[string]string{
|
||||
"name": "feature-x",
|
||||
"from": "master",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("branch create failed: %v", err)
|
||||
}
|
||||
if body["new_branch_name"] != "feature-x" {
|
||||
t.Fatalf("expected new_branch_name=feature-x, got %v", body["new_branch_name"])
|
||||
}
|
||||
if body["old_branch_name"] != "master" {
|
||||
t.Fatalf("expected old_branch_name=master, got %v", body["old_branch_name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchDelete(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/branches/delete.json" {
|
||||
body = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBranchShortcut(t, server, "delete", map[string]string{"name": "feature-x"})
|
||||
if err != nil {
|
||||
t.Fatalf("branch delete failed: %v", err)
|
||||
}
|
||||
if body["branch_name"] != "feature-x" {
|
||||
t.Fatalf("expected branch_name=feature-x, got %v", body["branch_name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchProtect(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/owner/repo/protected_branches.json" {
|
||||
body = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "protected"})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runBranchShortcut(t, server, "protect", map[string]string{"name": "master"})
|
||||
if err != nil {
|
||||
t.Fatalf("branch protect failed: %v", err)
|
||||
}
|
||||
if body["branch_name"] != "master" {
|
||||
t.Fatalf("expected branch_name=master, got %v", body["branch_name"])
|
||||
}
|
||||
}
|
||||
|
||||
func runBranchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findBranchShortcut(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 findBranchShortcut(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 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("failed to decode request body: %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("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,14 +18,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取构建列表失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -40,12 +40,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
build, err := ctx.RequireArg("build")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
stage := ctx.Arg("stage")
|
||||
step := ctx.Arg("step")
|
||||
if stage == "" {
|
||||
|
|
@ -56,7 +53,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 fmt.Errorf("获取构建日志失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -69,15 +66,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
build, err := ctx.RequireArg("build")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重启构建失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -90,15 +84,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
build, err := ctx.RequireArg("build")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("停止构建失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
|
|
@ -29,7 +29,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo), q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取提交列表失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -44,7 +44,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
|
|
@ -55,7 +55,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看提交详情失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -68,7 +68,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
|
|
@ -76,7 +76,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取提交 Diff 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -90,7 +90,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
|
|
@ -101,7 +101,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("sha", ctx.Arg("sha"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo), q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Blame 信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ type RuntimeContext struct {
|
|||
Repo string
|
||||
Format string
|
||||
Args map[string]string
|
||||
AIMode string
|
||||
}
|
||||
|
||||
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repository files",
|
||||
Flags: []common.Flag{
|
||||
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
|
||||
{Name: "search", Short: "s", Usage: "Search keyword"},
|
||||
},
|
||||
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)
|
||||
}
|
||||
if search := ctx.Arg("search"); search != "" {
|
||||
q.Set("search", search)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tree",
|
||||
Description: "List file tree for a branch or commit",
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"},
|
||||
{Name: "recursive", Usage: "Recursively list all files", Bool: true, Default: "false"},
|
||||
{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
|
||||
}
|
||||
sha := ctx.Arg("sha")
|
||||
if sha == "" {
|
||||
sha = "master"
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
if ctx.Arg("recursive") == "true" {
|
||||
q.Set("recursive", "true")
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET",
|
||||
fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get",
|
||||
Description: "Get file or directory contents",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File or directory path", Required: true},
|
||||
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("filepath", filePath)
|
||||
q.Set("ref", ctx.Arg("ref"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new file in the repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "File content (plain text, auto Base64 encoded)", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := ctx.RequireArg("message")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
branch := ctx.Arg("branch")
|
||||
if branch == "" {
|
||||
branch = "master"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
"message": message,
|
||||
"branch": branch,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/create_file", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a file from the repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path", Required: true},
|
||||
{Name: "sha", Short: "s", Usage: "File blob SHA (from file +list)", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := ctx.RequireArg("message")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
branch := ctx.Arg("branch")
|
||||
if branch == "" {
|
||||
branch = "master"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"sha": sha,
|
||||
"message": message,
|
||||
"branch": branch,
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/delete_file", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestFileList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/owner/repo/files.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, []map[string]interface{}{
|
||||
{"name": "README.md", "path": "README.md", "type": "file"},
|
||||
{"name": "src", "path": "src", "type": "dir"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runFileShortcut(t, server, "list", map[string]string{}); err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileListWithRef(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("ref") != "dev" {
|
||||
t.Fatalf("expected ref=dev, got %s", r.URL.Query().Get("ref"))
|
||||
}
|
||||
writeJSON(t, w, []map[string]interface{}{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runFileShortcut(t, server, "list", map[string]string{"ref": "dev"}); err != nil {
|
||||
t.Fatalf("list with ref failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTree(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/git/trees/master.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 1,
|
||||
"entries": []map[string]interface{}{{"name": "main.go", "type": "file"}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runFileShortcut(t, server, "tree", map[string]string{}); err != nil {
|
||||
t.Fatalf("tree failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTreeRecursive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("recursive") != "true" {
|
||||
t.Fatalf("expected recursive=true")
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"entries": []map[string]interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runFileShortcut(t, server, "tree", map[string]string{"recursive": "true"}); err != nil {
|
||||
t.Fatalf("tree recursive failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileGet(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/owner/repo/sub_entries.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("filepath") != "README.md" {
|
||||
t.Fatalf("expected filepath=README.md, got %s", r.URL.Query().Get("filepath"))
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"name": "README.md", "type": "file"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "get", map[string]string{"path": "README.md"})
|
||||
if err != nil {
|
||||
t.Fatalf("get failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileGetRequiresPath(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made without --path")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "get", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCreate(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/owner/repo/create_file.json" {
|
||||
var payload map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["filepath"] != "test.txt" {
|
||||
t.Fatalf("expected filepath=test.txt, got %v", payload["filepath"])
|
||||
}
|
||||
if payload["message"] != "add test" {
|
||||
t.Fatalf("expected message=add test, got %v", payload["message"])
|
||||
}
|
||||
if payload["branch"] != "master" {
|
||||
t.Fatalf("expected branch=master, got %v", payload["branch"])
|
||||
}
|
||||
if _, ok := payload["content"].(string); !ok || payload["content"] == "" {
|
||||
t.Fatal("content should be a non-empty Base64 string")
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"name": "test.txt", "sha": "abc123"})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "create", map[string]string{
|
||||
"path": "test.txt", "content": "hello world", "message": "add test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDelete(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "DELETE" && r.URL.Path == "/owner/repo/delete_file.json" {
|
||||
var payload map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["filepath"] != "old.txt" {
|
||||
t.Fatalf("expected filepath=old.txt, got %v", payload["filepath"])
|
||||
}
|
||||
if payload["sha"] != "def456" {
|
||||
t.Fatalf("expected sha=def456, got %v", payload["sha"])
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
return
|
||||
}
|
||||
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", "sha": "def456", "message": "remove old",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDeleteRequiresPath(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made without --path")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "delete", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --path")
|
||||
}
|
||||
}
|
||||
|
||||
// === 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("shortcut %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
|
@ -6,29 +6,26 @@ import (
|
|||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const closedIssueStatusID = 5
|
||||
const openIssueStatusID = 1
|
||||
|
||||
type batchResult struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
type batchSummary 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"`
|
||||
Duration string `json:"duration" yaml:"duration"`
|
||||
Results []batchResult `json:"results" yaml:"results"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func newBatchCloseShortcut() *common.Shortcut {
|
||||
|
|
@ -46,29 +43,27 @@ func newBatchCloseShortcut() *common.Shortcut {
|
|||
|
||||
func runBatchClose(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchSummary{
|
||||
summary := batchCloseSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]batchResult, 0, len(numbers)),
|
||||
Results: make([]batchCloseResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := batchResult{Number: number, Action: "close"}
|
||||
result := batchCloseResult{Number: number, Action: "close"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
|
|
@ -87,13 +82,11 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
|||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
summary.Duration = time.Since(start).String()
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d / %d 个 Issue 关闭失败", summary.Failed, summary.Total)
|
||||
return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -101,7 +94,7 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
|||
func closeIssue(ctx *common.RuntimeContext, number string) error {
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||||
return fmt.Errorf("fetch issue: %w", err)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
|
|
@ -110,466 +103,11 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
|
|||
"status_id": closedIssueStatusID,
|
||||
}
|
||||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||||
return fmt.Errorf("关闭 Issue: %w", err)
|
||||
return fmt.Errorf("close issue: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newBatchReopenShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-reopen",
|
||||
Description: "Reopen multiple closed 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 reopened without changing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchReopen,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchReopen(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]batchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := batchResult{Number: number, Action: "reopen"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := reopenIssue(ctx, number); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "reopened"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
summary.Duration = time.Since(start).String()
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d / %d 个 Issue 重新打开失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reopenIssue(ctx *common.RuntimeContext, number string) error {
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
"status_id": openIssueStatusID,
|
||||
}
|
||||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||||
return fmt.Errorf("重新打开 Issue: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newBatchAssignShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-assign",
|
||||
Description: "Assign multiple issues to a user 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: "user", Short: "u", Usage: "Assignee user ID (numeric)", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview the issues that would be assigned without changing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchAssign,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchAssign(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
|
||||
user, err := ctx.RequireArg("user")
|
||||
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("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]batchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := batchResult{Number: number, Action: "assign"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := assignIssue(ctx, number, user); 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 / %d 个 Issue 分配失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assignIssue(ctx *common.RuntimeContext, number, user string) error {
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||||
}
|
||||
|
||||
userID, err := strconv.Atoi(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user 参数必须是数字 ID,而不是用户名")
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
"assigned_to_id": userID,
|
||||
}
|
||||
if current.StatusID != nil {
|
||||
body["status_id"] = current.StatusID
|
||||
}
|
||||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||||
return fmt.Errorf("分配 Issue: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newBatchLabelShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-label",
|
||||
Description: "Add or remove labels on 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: "add", Short: "a", Usage: "Comma-separated label IDs to add"},
|
||||
{Name: "remove", Short: "r", Usage: "Comma-separated label IDs to remove"},
|
||||
{Name: "dry-run", Usage: "Preview the issues that would be labeled without changing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchLabel,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchLabel(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
|
||||
addIDs := ctx.Arg("add")
|
||||
removeIDs := ctx.Arg("remove")
|
||||
if addIDs == "" && removeIDs == "" {
|
||||
return fmt.Errorf("至少需要指定 --add 或 --remove 中的一个")
|
||||
}
|
||||
|
||||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]batchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
addLabels := parseCommaSeparated(addIDs)
|
||||
removeLabels := parseCommaSeparated(removeIDs)
|
||||
|
||||
for _, number := range numbers {
|
||||
result := batchResult{Number: number, Action: "label"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := labelIssue(ctx, number, addLabels, removeLabels); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "labeled"
|
||||
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 / %d 个 Issue 标签操作失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func labelIssue(ctx *common.RuntimeContext, number string, addLabels, removeLabels []string) error {
|
||||
var errs []string
|
||||
for _, labelID := range addLabels {
|
||||
body := map[string]interface{}{
|
||||
"tag_id": labelID,
|
||||
}
|
||||
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/tags", v1RepoPath(ctx), number), body); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("添加标签 %s: %v", labelID, err))
|
||||
}
|
||||
}
|
||||
for _, labelID := range removeLabels {
|
||||
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("移除标签 %s: %v", labelID, err))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("%s", strings.Join(errs, "\n"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newBatchMilestoneShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-milestone",
|
||||
Description: "Set milestone on 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: "milestone", Short: "m", Usage: "Milestone ID", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview the issues that would be updated without changing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchMilestone,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchMilestone(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
|
||||
milestoneID, err := ctx.RequireArg("milestone")
|
||||
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("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]batchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := batchResult{Number: number, Action: "milestone"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := setMilestone(ctx, number, milestoneID); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "milestoned"
|
||||
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 / %d 个 Issue 设置里程碑失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setMilestone(ctx *common.RuntimeContext, number, milestoneID string) error {
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
"fixed_version_id": milestoneID,
|
||||
}
|
||||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||||
return fmt.Errorf("设置里程碑: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newBatchCommentShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-comment",
|
||||
Description: "Add a comment to 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: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview the issues that would be commented on without changing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchComment,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchComment(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]batchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := batchResult{Number: number, Action: "comment"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := commentIssue(ctx, number, body); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "commented"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
summary.Duration = time.Since(start).String()
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d / %d 个 Issue 评论失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func commentIssue(ctx *common.RuntimeContext, number, body string) error {
|
||||
payload := map[string]interface{}{
|
||||
"notes": body,
|
||||
}
|
||||
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload); err != nil {
|
||||
return fmt.Errorf("添加评论: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCommaSeparated(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
var result []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||
numbers, err := parseIssueNumbers(numbersValue)
|
||||
if err != nil {
|
||||
|
|
@ -596,7 +134,7 @@ func parseIssueNumbers(value string) ([]string, error) {
|
|||
func readIssueNumbersFromCSV(path string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
|
||||
return nil, fmt.Errorf("read issue numbers from CSV: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
|
|
@ -604,7 +142,7 @@ func readIssueNumbersFromCSV(path string) ([]string, error) {
|
|||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
|
||||
return nil, fmt.Errorf("parse issue numbers from CSV: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -642,7 +180,7 @@ func normalizeIssueNumbers(values []string) ([]string, error) {
|
|||
continue
|
||||
}
|
||||
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
|
||||
return nil, fmt.Errorf("无效的 Issue 编号 %q: Issue 编号必须是整数", number)
|
||||
return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number)
|
||||
}
|
||||
if seen[number] {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
|
|
@ -18,17 +17,11 @@ func v1RepoPath(ctx *common.RuntimeContext) string {
|
|||
type existingIssue struct {
|
||||
Subject string
|
||||
Description string
|
||||
StatusID interface{}
|
||||
}
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
newBatchReopenShortcut(),
|
||||
newBatchAssignShortcut(),
|
||||
newBatchLabelShortcut(),
|
||||
newBatchMilestoneShortcut(),
|
||||
newBatchCommentShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List issues",
|
||||
|
|
@ -39,7 +32,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
|
|
@ -49,7 +42,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Issue 列表失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -66,7 +59,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
title, err := ctx.RequireArg("title")
|
||||
if err != nil {
|
||||
|
|
@ -89,7 +82,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 Issue 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -102,7 +95,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
|
|
@ -110,7 +103,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看 Issue 详情失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -123,7 +116,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
|
|
@ -131,7 +124,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("关闭 Issue 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
|
|
@ -140,40 +133,9 @@ 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 fmt.Errorf("关闭 Issue 失败: %w", err)
|
||||
}
|
||||
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 fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重新打开 Issue 失败: %w", 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 fmt.Errorf("重新打开 Issue 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
@ -188,7 +150,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
|
|
@ -198,12 +160,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
description := ctx.Arg("body")
|
||||
state := ctx.Arg("state")
|
||||
if title == "" && description == "" && state == "" {
|
||||
return fmt.Errorf("至少需要指定 --title、--body 或 --state 中的一个")
|
||||
return fmt.Errorf("at least one of --title, --body, or --state is required")
|
||||
}
|
||||
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新 Issue 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
|
|
@ -225,109 +187,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新 Issue 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "assign",
|
||||
Description: "Assign an issue to a user",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "user", Short: "u", Usage: "Assignee user ID (numeric)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("分配 Issue 失败: %w", err)
|
||||
}
|
||||
userID, err := strconv.Atoi(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user 参数必须是数字 ID,而不是用户名")
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
"assigned_to_id": userID,
|
||||
}
|
||||
if current.StatusID != nil {
|
||||
body["status_id"] = current.StatusID
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("分配 Issue 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "label",
|
||||
Description: "Add or remove labels on an issue",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "add", Short: "a", Usage: "Comma-separated label IDs to add"},
|
||||
{Name: "remove", Short: "r", Usage: "Comma-separated label IDs to remove"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addIDs := ctx.Arg("add")
|
||||
removeIDs := ctx.Arg("remove")
|
||||
if addIDs == "" && removeIDs == "" {
|
||||
return fmt.Errorf("至少需要指定 --add 或 --remove 中的一个")
|
||||
}
|
||||
|
||||
var errs []string
|
||||
if addIDs != "" {
|
||||
for _, labelID := range strings.Split(addIDs, ",") {
|
||||
labelID = strings.TrimSpace(labelID)
|
||||
if labelID == "" {
|
||||
continue
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"tag_id": labelID,
|
||||
}
|
||||
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/tags", v1RepoPath(ctx), number), body); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("添加标签 %s: %v", labelID, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
if removeIDs != "" {
|
||||
for _, labelID := range strings.Split(removeIDs, ",") {
|
||||
labelID = strings.TrimSpace(labelID)
|
||||
if labelID == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("移除标签 %s: %v", labelID, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("标签操作失败:\n%s", strings.Join(errs, "\n"))
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "标签操作成功",
|
||||
}, nil))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "comment",
|
||||
Description: "Add a comment to an issue",
|
||||
|
|
@ -337,7 +201,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
|
|
@ -352,7 +216,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("添加评论失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -363,28 +227,20 @@ func Shortcuts() []*common.Shortcut {
|
|||
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
|
||||
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 Issue 详情: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
issueData, ok := getEnv.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("解析 Issue 数据失败")
|
||||
return nil, fmt.Errorf("failed to parse issue data")
|
||||
}
|
||||
subject, _ := issueData["subject"].(string)
|
||||
if subject == "" {
|
||||
return nil, fmt.Errorf("解析 Issue 标题失败")
|
||||
return nil, fmt.Errorf("failed to parse issue subject")
|
||||
}
|
||||
description, _ := issueData["description"].(string)
|
||||
statusID := issueData["status_id"]
|
||||
if statusID == nil {
|
||||
// Try nested status object
|
||||
if status, ok := issueData["status"].(map[string]interface{}); ok {
|
||||
statusID = status["id"]
|
||||
}
|
||||
}
|
||||
return &existingIssue{
|
||||
Subject: subject,
|
||||
Description: description,
|
||||
StatusID: statusID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -398,6 +254,6 @@ func normalizeIssueStatus(state string) (interface{}, error) {
|
|||
if id, err := strconv.Atoi(state); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
return nil, fmt.Errorf("无效的 --state 值 %q: 请使用 open、closed 或数字 status_id", state)
|
||||
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,357 +186,3 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
|||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueList(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"issues": []map[string]interface{}{
|
||||
{"id": float64(1), "subject": "bug", "status_id": float64(1)},
|
||||
{"id": float64(2), "subject": "feature", "status_id": float64(5)},
|
||||
},
|
||||
"total_count": float64(2),
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "list", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("issue list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCreate(t *testing.T) {
|
||||
var createPayload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues.json" {
|
||||
createPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(99), "subject": "new bug",
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "create", map[string]string{
|
||||
"title": "new bug",
|
||||
"body": "steps to reproduce",
|
||||
"assignee": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue create failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, createPayload["subject"], "new bug")
|
||||
assertEqual(t, createPayload["description"], "steps to reproduce")
|
||||
assertEqual(t, createPayload["assigned_to_id"], "42")
|
||||
}
|
||||
|
||||
func TestIssueView(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1), "subject": "test issue", "status_id": float64(1),
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "view", map[string]string{"number": "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("issue view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueComment(t *testing.T) {
|
||||
var commentPayload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/1/journals.json" {
|
||||
commentPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1), "notes": "looks good",
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "comment", map[string]string{
|
||||
"number": "1",
|
||||
"body": "looks good",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue comment failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, commentPayload["notes"], "looks good")
|
||||
}
|
||||
|
||||
func TestIssueAssignSendsCorrectUser(t *testing.T) {
|
||||
var assignPath string
|
||||
var assignPayload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/issues/42/assignees.json":
|
||||
assignPath = r.URL.Path
|
||||
assignPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"message": "指派成功",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "assign", map[string]string{
|
||||
"number": "42",
|
||||
"user": "zhangsan",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("assign shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
if assignPath == "" {
|
||||
t.Fatal("assign endpoint was not called")
|
||||
}
|
||||
assertEqual(t, assignPayload["assigned_to_id"], "zhangsan")
|
||||
}
|
||||
|
||||
func TestIssueAssignRequiresNumberAndUser(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"message": "ok",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "assign", map[string]string{"number": ""})
|
||||
if err == nil {
|
||||
t.Fatal("assign shortcut should error when required flags are missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueLabelAddSendsCorrectTagIDs(t *testing.T) {
|
||||
var capturedPaths []string
|
||||
var capturedPayloads []map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/42/tags.json":
|
||||
capturedPaths = append(capturedPaths, r.URL.Path)
|
||||
capturedPayloads = append(capturedPayloads, decodeJSON(t, r))
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"message": "标签添加成功",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "label", map[string]string{
|
||||
"number": "42",
|
||||
"add": "5,8",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("label shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
if len(capturedPaths) != 2 {
|
||||
t.Fatalf("expected 2 tag add calls, got %d", len(capturedPaths))
|
||||
}
|
||||
assertEqual(t, capturedPayloads[0]["tag_id"], "5")
|
||||
assertEqual(t, capturedPayloads[1]["tag_id"], "8")
|
||||
}
|
||||
|
||||
func TestIssueLabelRemoveSendsDeleteRequests(t *testing.T) {
|
||||
var capturedPaths []string
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42/tags/3.json":
|
||||
capturedPaths = append(capturedPaths, r.URL.Path)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"message": "标签删除成功",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "label", map[string]string{
|
||||
"number": "42",
|
||||
"remove": "3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("label shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
if len(capturedPaths) != 1 {
|
||||
t.Fatalf("expected 1 tag delete call, got %d", len(capturedPaths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchAssign(t *testing.T) {
|
||||
var assignPath string
|
||||
var assignPayload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/issues/1/assignees.json":
|
||||
assignPath = r.URL.Path
|
||||
assignPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "指派成功"})
|
||||
case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/issues/2/assignees.json":
|
||||
writeJSON(t, w, map[string]interface{}{"message": "指派成功"})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "batch-assign", map[string]string{
|
||||
"numbers": "1,2",
|
||||
"user": "zhangsan",
|
||||
"dry-run": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-assign failed: %v", err)
|
||||
}
|
||||
|
||||
if assignPath == "" {
|
||||
t.Fatal("assign endpoint was not called")
|
||||
}
|
||||
assertEqual(t, assignPayload["assigned_to_id"], "zhangsan")
|
||||
}
|
||||
|
||||
func TestBatchAssignDryRun(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made in dry-run mode")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "batch-assign", map[string]string{
|
||||
"numbers": "1,2,3",
|
||||
"user": "zhangsan",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-assign dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchLabelAddAndRemove(t *testing.T) {
|
||||
var capturedPaths []string
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/1/tags.json":
|
||||
capturedPaths = append(capturedPaths, r.URL.Path)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "标签添加成功"})
|
||||
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/1/tags/3.json":
|
||||
capturedPaths = append(capturedPaths, r.URL.Path)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "标签删除成功"})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "batch-label", map[string]string{
|
||||
"numbers": "1",
|
||||
"add": "5",
|
||||
"remove": "3",
|
||||
"dry-run": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-label failed: %v", err)
|
||||
}
|
||||
|
||||
if len(capturedPaths) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(capturedPaths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchLabelDryRun(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made in dry-run mode")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "batch-label", map[string]string{
|
||||
"numbers": "1,2",
|
||||
"add": "5,8",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-label dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMilestone(t *testing.T) {
|
||||
var patchPayload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"subject": "Test issue", "description": "desc",
|
||||
})
|
||||
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/1.json":
|
||||
patchPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "success"})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "batch-milestone", map[string]string{
|
||||
"numbers": "1",
|
||||
"milestone": "5",
|
||||
"dry-run": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-milestone failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, patchPayload["fixed_version_id"], "5")
|
||||
assertEqual(t, patchPayload["subject"], "Test issue")
|
||||
assertEqual(t, patchPayload["description"], "desc")
|
||||
}
|
||||
|
||||
func TestBatchMilestoneDryRun(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made in dry-run mode")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "batch-milestone", map[string]string{
|
||||
"numbers": "1,2,3",
|
||||
"milestone": "5",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-milestone dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueLabelRequiresAddOrRemove(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("should not make any API calls")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "label", map[string]string{
|
||||
"number": "42",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when neither --add nor --remove is provided")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
|
|
@ -33,7 +33,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_tags", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取标签列表失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -48,7 +48,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
|
|
@ -63,7 +63,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issue_tags", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建标签失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -79,7 +79,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
|
|
@ -89,7 +89,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
color := ctx.Arg("color")
|
||||
desc := ctx.Arg("description")
|
||||
if name == "" && color == "" && desc == "" {
|
||||
return fmt.Errorf("至少需要指定 --name、--color 或 --description 中的一个")
|
||||
return fmt.Errorf("at least one of --name, --color, or --description is required")
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if name != "" {
|
||||
|
|
@ -103,7 +103,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issue_tags/%s", v1RepoPath(ctx), id), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新标签失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -116,7 +116,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
|
|
@ -124,7 +124,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issue_tags/%s", v1RepoPath(ctx), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除标签失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,204 +0,0 @@
|
|||
package member
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type batchAddResult struct {
|
||||
User string `json:"user" yaml:"user"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Status string `json:"status" yaml:"status"`
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
}
|
||||
|
||||
type batchAddSummary struct {
|
||||
Owner string `json:"owner" yaml:"owner"`
|
||||
Repo string `json:"repo" yaml:"repo"`
|
||||
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"`
|
||||
Duration string `json:"duration" yaml:"duration"`
|
||||
Results []batchAddResult `json:"results" yaml:"results"`
|
||||
}
|
||||
|
||||
func batchAddShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-add",
|
||||
Description: "批量添加成员到项目,支持逗号分隔列表或 CSV 文件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Short: "u", Usage: "逗号分隔的用户数字 ID,例如: 42,99,105"},
|
||||
{Name: "from", Usage: "从 CSV 文件读取用户 ID。支持 user_id/id/user 列名或无表头首列"},
|
||||
{Name: "dry-run", Usage: "仅预览将要添加的成员,不实际执行", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchAdd,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchAdd(ctx *common.RuntimeContext) error {
|
||||
start := time.Now()
|
||||
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userIDs, err := collectUserIDs(ctx.Arg("users"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return fmt.Errorf("未提供用户 ID,请使用 --users 42,99 或 --from users.csv")
|
||||
}
|
||||
|
||||
dryRun := parseMemberBool(ctx.Arg("dry-run"))
|
||||
|
||||
summary := batchAddSummary{
|
||||
Owner: ctx.Owner,
|
||||
Repo: ctx.Repo,
|
||||
DryRun: dryRun,
|
||||
Total: len(userIDs),
|
||||
Results: make([]batchAddResult, 0, len(userIDs)),
|
||||
}
|
||||
|
||||
for _, uid := range userIDs {
|
||||
result := batchAddResult{User: uid, Action: "add"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
id, _ := strconv.ParseInt(uid, 10, 64)
|
||||
body := map[string]interface{}{"user_id": id}
|
||||
if _, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "added"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
summary.Duration = time.Since(start).String()
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d / %d 个成员添加失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectUserIDs(usersValue, csvPath string) ([]string, error) {
|
||||
ids, err := parseUserIDList(usersValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if csvPath == "" {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
csvIDs, err := readUserIDsFromCSV(csvPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mergeUserIDLists(ids, csvIDs), nil
|
||||
}
|
||||
|
||||
func parseUserIDList(value string) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return normalizeUserIDs(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
func readUserIDsFromCSV(path string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
idCol := -1
|
||||
startRow := 0
|
||||
for i, cell := range records[0] {
|
||||
switch strings.ToLower(strings.TrimSpace(cell)) {
|
||||
case "user_id", "id", "user", "uid":
|
||||
idCol = i
|
||||
startRow = 1
|
||||
}
|
||||
}
|
||||
if idCol == -1 {
|
||||
idCol = 0
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(records)-startRow)
|
||||
for _, record := range records[startRow:] {
|
||||
if idCol >= len(record) {
|
||||
continue
|
||||
}
|
||||
values = append(values, record[idCol])
|
||||
}
|
||||
return normalizeUserIDs(values)
|
||||
}
|
||||
|
||||
func normalizeUserIDs(values []string) ([]string, error) {
|
||||
ids := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
id := strings.TrimSpace(value)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
|
||||
return nil, fmt.Errorf("无效的用户 ID %q: 必须是整数", id)
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func mergeUserIDLists(values ...[]string) []string {
|
||||
merged := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, ids := range values {
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
merged = append(merged, id)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func parseMemberBool(value string) bool {
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
package member
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
batchAddShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List project members",
|
||||
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 k := ctx.Arg("keyword"); k != "" {
|
||||
q.Set("keyword", k)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET",
|
||||
fmt.Sprintf("/v1/%s/%s/collaborators", ctx.Owner, ctx.Repo), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "add",
|
||||
Description: "Add a member to the project",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Short: "u", Usage: "Numeric user ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
userIDStr, _ := ctx.RequireArg("user-id")
|
||||
userID, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid user-id %q: must be an integer", userIDStr)
|
||||
}
|
||||
body := map[string]interface{}{"user_id": userID}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Description: "Remove a member from the project",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Short: "u", Usage: "Numeric user ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
userIDStr, _ := ctx.RequireArg("user-id")
|
||||
userID, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid user-id %q: must be an integer", userIDStr)
|
||||
}
|
||||
body := map[string]interface{}{"user_id": userID}
|
||||
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Change a member's role",
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Short: "u", Usage: "Numeric user ID", Required: true},
|
||||
{Name: "role", Short: "r", Usage: "Role: Manager, Developer, Reporter", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
userIDStr, _ := ctx.RequireArg("user-id")
|
||||
role, _ := ctx.RequireArg("role")
|
||||
userID, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid user-id %q: must be an integer", userIDStr)
|
||||
}
|
||||
switch role {
|
||||
case "Manager", "Developer", "Reporter":
|
||||
default:
|
||||
return fmt.Errorf("invalid role %q: must be Manager, Developer, or Reporter", role)
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"role": role,
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", ctx.RepoPath()+"/collaborators/change_role", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
package member
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestMemberList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/collaborators.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 2,
|
||||
"collaborators": []map[string]interface{}{
|
||||
{"id": 1, "login": "alice", "role_name": "Manager"},
|
||||
{"id": 2, "login": "bob", "role_name": "Developer"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runMemberShortcut(t, server, "list", map[string]string{}); err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberAdd(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/owner/repo/collaborators.json" {
|
||||
var payload map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["user_id"].(float64) != 42 {
|
||||
t.Fatalf("expected user_id=42, got %v", payload["user_id"])
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "42"}); err != nil {
|
||||
t.Fatalf("add failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberAddRejectsInvalidID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made with invalid ID")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "abc"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid user-id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberRemove(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "DELETE" && r.URL.Path == "/owner/repo/collaborators/remove.json" {
|
||||
var payload map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["user_id"].(float64) != 42 {
|
||||
t.Fatalf("expected user_id=42")
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runMemberShortcut(t, server, "remove", map[string]string{"user-id": "42"}); err != nil {
|
||||
t.Fatalf("remove failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberUpdate(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "PUT" && r.URL.Path == "/owner/repo/collaborators/change_role.json" {
|
||||
var payload map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["user_id"].(float64) != 42 {
|
||||
t.Fatalf("expected user_id=42")
|
||||
}
|
||||
if payload["role"] != "Developer" {
|
||||
t.Fatalf("expected role=Developer, got %v", payload["role"])
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runMemberShortcut(t, server, "update", map[string]string{
|
||||
"user-id": "42", "role": "Developer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberUpdateRejectsInvalidRole(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made with invalid role")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runMemberShortcut(t, server, "update", map[string]string{
|
||||
"user-id": "42", "role": "Admin",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid role")
|
||||
}
|
||||
}
|
||||
|
||||
// === helpers ===
|
||||
|
||||
func runMemberShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findMemberShortcut(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 findMemberShortcut(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 writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
|
|
@ -45,7 +45,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/milestones", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取里程碑列表失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -58,7 +58,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
|
|
@ -66,7 +66,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看里程碑详情失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -81,7 +81,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
|
|
@ -98,7 +98,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/milestones", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建里程碑失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -114,7 +114,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
|
|
@ -124,7 +124,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
desc := ctx.Arg("description")
|
||||
due := ctx.Arg("due")
|
||||
if name == "" && desc == "" && due == "" {
|
||||
return fmt.Errorf("至少需要指定 --name、--description 或 --due 中的一个")
|
||||
return fmt.Errorf("at least one of --name, --description, or --due is required")
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if name != "" {
|
||||
|
|
@ -138,7 +138,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新里程碑失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -151,7 +151,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
|
|
@ -162,7 +162,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/milestones/%s/update_status", ctx.Owner, ctx.Repo, id), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("关闭里程碑失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -175,7 +175,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
|
|
@ -183,7 +183,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除里程碑失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -137,24 +137,6 @@ func TestMilestoneDelete(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMilestoneView(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/milestones/1.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1), "name": "v1.0", "status": "open",
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runMilestoneShortcut(t, server, "view", map[string]string{"id": "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("milestone view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilestoneUpdateRequiresAtLeastOneField(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made when no fields are provided")
|
||||
|
|
|
|||
|
|
@ -1,336 +0,0 @@
|
|||
package org
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type batchInviteResult struct {
|
||||
User string `json:"user" yaml:"user"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Status string `json:"status" yaml:"status"`
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
}
|
||||
|
||||
type batchInviteSummary struct {
|
||||
Org string `json:"org" yaml:"org"`
|
||||
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"`
|
||||
Duration string `json:"duration" yaml:"duration"`
|
||||
Results []batchInviteResult `json:"results" yaml:"results"`
|
||||
}
|
||||
|
||||
func newBatchInviteShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-invite",
|
||||
Description: "批量邀请成员加入组织,支持逗号分隔列表或 CSV 文件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "组织 ID 或 login", Required: true},
|
||||
{Name: "users", Short: "u", Usage: "逗号分隔的用户名或 ID,例如: alice,bob,charlie"},
|
||||
{Name: "from", Usage: "从 CSV 文件读取用户名。支持 user/login/user_id 列名或无表头首列"},
|
||||
{Name: "role", Short: "r", Usage: "成员角色: member 或 admin", Default: "member"},
|
||||
{Name: "dry-run", Usage: "仅预览将要邀请的成员,不实际执行", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchInvite,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchInvite(ctx *common.RuntimeContext) error {
|
||||
start := time.Now()
|
||||
|
||||
orgID, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
role := ctx.Arg("role")
|
||||
if role == "" {
|
||||
role = "member"
|
||||
}
|
||||
if role != "member" && role != "admin" {
|
||||
return fmt.Errorf("无效的角色 %q: 必须为 member 或 admin", role)
|
||||
}
|
||||
|
||||
userInputs, err := collectUsers(ctx.Arg("users"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(userInputs) == 0 {
|
||||
return fmt.Errorf("未提供用户名,请使用 --users alice,bob 或 --from users.csv")
|
||||
}
|
||||
|
||||
// 将用户名解析为数字 ID(如果传入的已经是数字则直接使用)
|
||||
resolvedUsers, resolveErrors := resolveUserIDs(ctx, userInputs)
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
|
||||
summary := batchInviteSummary{
|
||||
Org: orgID,
|
||||
DryRun: dryRun,
|
||||
Total: len(userInputs),
|
||||
Results: make([]batchInviteResult, 0, len(userInputs)),
|
||||
}
|
||||
|
||||
// 先记录解析失败的
|
||||
for input, errMsg := range resolveErrors {
|
||||
summary.Results = append(summary.Results, batchInviteResult{
|
||||
User: input,
|
||||
Action: "invite",
|
||||
Status: "failed",
|
||||
Error: errMsg,
|
||||
})
|
||||
summary.Failed++
|
||||
}
|
||||
|
||||
for _, ru := range resolvedUsers {
|
||||
displayName := ru.Input
|
||||
if ru.Input != ru.UserID {
|
||||
displayName = fmt.Sprintf("%s (ID:%s)", ru.Input, ru.UserID)
|
||||
}
|
||||
result := batchInviteResult{User: displayName, Action: "invite"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
userIDInt, _ := strconv.ParseInt(ru.UserID, 10, 64)
|
||||
body := map[string]interface{}{
|
||||
"user_id": userIDInt,
|
||||
"role": role,
|
||||
}
|
||||
path := fmt.Sprintf("/organizations/%s/organization_users", orgID)
|
||||
if _, err := ctx.CallAPI("POST", path, body); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "invited"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
summary.Duration = time.Since(start).String()
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d / %d 个成员邀请失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectUsers(usersValue, csvPath string) ([]string, error) {
|
||||
users, err := parseUserList(usersValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if csvPath == "" {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
csvUsers, err := readUsersFromCSV(csvPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mergeUserLists(users, csvUsers), nil
|
||||
}
|
||||
|
||||
func parseUserList(value string) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return normalizeUserIDs(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
func readUsersFromCSV(path string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
userCol := -1
|
||||
startRow := 0
|
||||
for i, cell := range records[0] {
|
||||
switch strings.ToLower(strings.TrimSpace(cell)) {
|
||||
case "user", "login", "user_id", "username":
|
||||
userCol = i
|
||||
startRow = 1
|
||||
}
|
||||
}
|
||||
if userCol == -1 {
|
||||
userCol = 0
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(records)-startRow)
|
||||
for _, record := range records[startRow:] {
|
||||
if userCol >= len(record) {
|
||||
continue
|
||||
}
|
||||
values = append(values, record[userCol])
|
||||
}
|
||||
return normalizeUserIDs(values)
|
||||
}
|
||||
|
||||
func normalizeUserIDs(values []string) ([]string, error) {
|
||||
users := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
user := strings.TrimSpace(value)
|
||||
if user == "" {
|
||||
continue
|
||||
}
|
||||
if seen[user] {
|
||||
continue
|
||||
}
|
||||
seen[user] = true
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func parseBool(value string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(value), "true") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeUserLists(values ...[]string) []string {
|
||||
merged := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, users := range values {
|
||||
for _, u := range users {
|
||||
if seen[u] {
|
||||
continue
|
||||
}
|
||||
seen[u] = true
|
||||
merged = append(merged, u)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// resolvedUser holds the mapping from user input to numeric ID.
|
||||
type resolvedUser struct {
|
||||
Input string // original input (username or numeric string)
|
||||
UserID string // resolved numeric user ID
|
||||
}
|
||||
|
||||
// resolveUserIDs converts usernames to numeric user IDs via the search API.
|
||||
// If an input is already numeric, it is used directly.
|
||||
func resolveUserIDs(ctx *common.RuntimeContext, inputs []string) ([]resolvedUser, map[string]string) {
|
||||
results := make([]resolvedUser, 0, len(inputs))
|
||||
errors := make(map[string]string)
|
||||
|
||||
for _, input := range inputs {
|
||||
// 如果已经是纯数字,直接使用
|
||||
if _, err := strconv.Atoi(input); err == nil {
|
||||
results = append(results, resolvedUser{Input: input, UserID: input})
|
||||
continue
|
||||
}
|
||||
|
||||
// 通过搜索 API 查找用户名对应的数字 ID
|
||||
q := url.Values{}
|
||||
q.Set("search", input)
|
||||
q.Set("limit", "5")
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
|
||||
if err != nil {
|
||||
errors[input] = fmt.Sprintf("查找用户失败: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// env.Data 是 {"total_count":N, "users":[...]} 的 map 结构
|
||||
users := extractUsers(env.Data)
|
||||
if len(users) == 0 {
|
||||
errors[input] = fmt.Sprintf("未找到用户 %q", input)
|
||||
continue
|
||||
}
|
||||
|
||||
// 精确匹配用户名
|
||||
matched := users[0]
|
||||
for _, u := range users {
|
||||
if u.Login == input {
|
||||
matched = u
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, resolvedUser{
|
||||
Input: input,
|
||||
UserID: strconv.Itoa(matched.UserID),
|
||||
})
|
||||
}
|
||||
|
||||
return results, errors
|
||||
}
|
||||
|
||||
// searchUser holds a parsed user from search results.
|
||||
type searchUser struct {
|
||||
Login string
|
||||
UserID int
|
||||
}
|
||||
|
||||
// extractUsers extracts the user list from the search API response data.
|
||||
// data is expected to be map[string]interface{} with a "users" key containing a slice.
|
||||
func extractUsers(data interface{}) []searchUser {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rawUsers, ok := m["users"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
usersSlice, ok := rawUsers.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var result []searchUser
|
||||
for _, item := range usersSlice {
|
||||
um, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
login, _ := um["login"].(string)
|
||||
userID := 0
|
||||
switch v := um["user_id"].(type) {
|
||||
case float64:
|
||||
userID = int(v)
|
||||
case int:
|
||||
userID = v
|
||||
case string:
|
||||
userID, _ = strconv.Atoi(v)
|
||||
}
|
||||
if login != "" && userID > 0 {
|
||||
result = append(result, searchUser{Login: login, UserID: userID})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchInviteShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List organizations",
|
||||
|
|
@ -23,7 +22,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/organizations", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取组织列表失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -35,13 +34,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看组织详情失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -55,16 +51,13 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
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 fmt.Errorf("获取组织成员列表失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -77,10 +70,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "description", Short: "d", Usage: "Description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
|
|
@ -89,7 +79,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", "/organizations", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建组织失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
package org
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestOrgList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/organizations.json" {
|
||||
writeJSON(t, w, []map[string]interface{}{
|
||||
{"id": float64(1), "name": "org1"},
|
||||
{"id": float64(2), "name": "org2"},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runOrgShortcut(t, server, "list", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("org list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgInfo(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/organizations/1.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1), "name": "myorg", "description": "A test org",
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runOrgShortcut(t, server, "info", map[string]string{"id": "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("org info failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgMembers(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/organizations/1/organization_users.json" {
|
||||
writeJSON(t, w, []map[string]interface{}{
|
||||
{"id": float64(1), "login": "user1", "role": "admin"},
|
||||
{"id": float64(2), "login": "user2", "role": "member"},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runOrgShortcut(t, server, "members", map[string]string{"id": "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("org members failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgCreate(t *testing.T) {
|
||||
var createPayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/organizations.json" {
|
||||
createPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(99), "name": "new-org",
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runOrgShortcut(t, server, "create", map[string]string{
|
||||
"name": "new-org",
|
||||
"description": "My new organization",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("org create failed: %v", err)
|
||||
}
|
||||
|
||||
if createPayload["name"] != "new-org" {
|
||||
t.Fatalf("expected name=new-org, got %v", createPayload["name"])
|
||||
}
|
||||
if createPayload["description"] != "My new organization" {
|
||||
t.Fatalf("expected description='My new organization', got %v", createPayload["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func runOrgShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findOrgShortcut(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 findOrgShortcut(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 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("failed to decode request body: %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("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
|
|
@ -30,7 +30,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 PR 列表失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -46,16 +46,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
title, err := ctx.RequireArg("title")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
head, err := ctx.RequireArg("head")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title, _ := ctx.RequireArg("title")
|
||||
head, _ := ctx.RequireArg("head")
|
||||
base := ctx.Arg("base")
|
||||
if base == "" {
|
||||
base = "master"
|
||||
|
|
@ -70,7 +64,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 PR 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -83,15 +77,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看 PR 详情失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -105,12 +96,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
method := ctx.Arg("method")
|
||||
if method == "" {
|
||||
method = "merge"
|
||||
|
|
@ -120,7 +108,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("合并 PR 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -133,15 +121,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("关闭 PR 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -154,15 +139,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 PR 变更文件失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -175,53 +157,13 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 PR Diff 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "review",
|
||||
Description: "Submit a review on a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Review comment body", Required: true},
|
||||
{Name: "action", Short: "a", Usage: "Review action: approve, comment, request-changes", Default: "comment"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
action := ctx.Arg("action")
|
||||
if action == "" {
|
||||
action = "comment"
|
||||
}
|
||||
|
||||
reviewAction := mapAction(action)
|
||||
payload := map[string]interface{}{
|
||||
"body": body,
|
||||
"action": reviewAction,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交 PR Review 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
@ -234,20 +176,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
body, _ := ctx.RequireArg("body")
|
||||
|
||||
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 PR 详情: %w", err)
|
||||
return fmt.Errorf("fetch PR: %w", err)
|
||||
}
|
||||
issueID, err := extractIssueID(prEnv)
|
||||
if err != nil {
|
||||
|
|
@ -259,7 +195,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("添加 PR 评论失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -267,29 +203,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
}
|
||||
|
||||
func mapAction(action string) string {
|
||||
switch action {
|
||||
case "approve":
|
||||
return "approved"
|
||||
case "request-changes":
|
||||
return "changes_requested"
|
||||
default:
|
||||
return "commented"
|
||||
}
|
||||
}
|
||||
|
||||
func extractIssueID(env *output.Envelope) (int64, error) {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("PR 响应格式异常")
|
||||
return 0, fmt.Errorf("unexpected PR response format")
|
||||
}
|
||||
issue, ok := data["issue"].(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("PR 响应缺少 issue 字段")
|
||||
return 0, fmt.Errorf("PR response missing issue field")
|
||||
}
|
||||
idFloat, ok := issue["id"].(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("PR 响应缺少 issue.id 字段")
|
||||
return 0, fmt.Errorf("PR response missing issue.id field")
|
||||
}
|
||||
return int64(idFloat), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,238 +141,3 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
|||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo/pulls.json" {
|
||||
writeJSON(t, w, []map[string]interface{}{
|
||||
{"id": float64(1), "title": "fix bug", "state": "open"},
|
||||
{"id": float64(2), "title": "add feature", "state": "merged"},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "list", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("pr list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCreate(t *testing.T) {
|
||||
var createPayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/owner/repo/pulls.json" {
|
||||
createPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1), "title": "new feature",
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "create", map[string]string{
|
||||
"title": "new feature",
|
||||
"head": "feature-branch",
|
||||
"base": "master",
|
||||
"body": "This adds a new feature",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("pr create failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, createPayload["title"], "new feature")
|
||||
assertEqual(t, createPayload["head"], "feature-branch")
|
||||
assertEqual(t, createPayload["base"], "master")
|
||||
assertEqual(t, createPayload["body"], "This adds a new feature")
|
||||
}
|
||||
|
||||
func TestPRView(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/1.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1), "title": "fix bug", "state": "open",
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "view", map[string]string{"id": "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("pr view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRMerge(t *testing.T) {
|
||||
var mergePayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/1/pr_merge.json" {
|
||||
mergePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "merged"})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "merge", map[string]string{
|
||||
"id": "1",
|
||||
"method": "squash",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("pr merge failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, mergePayload["do"], "squash")
|
||||
}
|
||||
|
||||
func TestPRClose(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/1/refuse_merge.json" {
|
||||
writeJSON(t, w, map[string]interface{}{"message": "closed"})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "close", map[string]string{"id": "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("pr close failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRFiles(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/1/files.json" {
|
||||
writeJSON(t, w, []map[string]interface{}{
|
||||
{"filename": "src/main.go", "status": "modified"},
|
||||
{"filename": "src/test.go", "status": "added"},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "files", map[string]string{"id": "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("pr files failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRDiff(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/1/files.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"filename": "main.go", "patch": "@@ -1,3 +1,4 @@"},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "diff", map[string]string{"id": "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("pr diff failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRReviewSubmitsApproveAction(t *testing.T) {
|
||||
var reviewPath string
|
||||
var reviewPayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/13/reviews.json":
|
||||
reviewPath = r.URL.Path
|
||||
reviewPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"state": "approved",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review", map[string]string{
|
||||
"id": "13",
|
||||
"body": "LGTM!",
|
||||
"action": "approve",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
if reviewPath == "" {
|
||||
t.Fatal("review endpoint was not called")
|
||||
}
|
||||
assertEqual(t, reviewPayload["body"], "LGTM!")
|
||||
assertEqual(t, reviewPayload["action"], "approved")
|
||||
}
|
||||
|
||||
func TestPRReviewDefaultsToCommentAction(t *testing.T) {
|
||||
var reviewPayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/13/reviews.json":
|
||||
reviewPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(2),
|
||||
"state": "commented",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review", map[string]string{
|
||||
"id": "13",
|
||||
"body": "Looks good",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, reviewPayload["action"], "commented")
|
||||
}
|
||||
|
||||
func TestPRReviewSubmitsRequestChangesAction(t *testing.T) {
|
||||
var reviewPayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/13/reviews.json":
|
||||
reviewPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(3),
|
||||
"state": "changes_requested",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review", map[string]string{
|
||||
"id": "13",
|
||||
"body": "Please fix the null check",
|
||||
"action": "request-changes",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, reviewPayload["action"], "changes_requested")
|
||||
assertEqual(t, reviewPayload["body"], "Please fix the null check")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,23 +7,16 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/commit"
|
||||
"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/label"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
|
||||
"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/star"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/watch"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
_ "github.com/gitlink-org/gitlink-cli/shortcuts/workflow/rules"
|
||||
)
|
||||
|
||||
// RegisterAll mounts all shortcut groups onto the root command.
|
||||
|
|
@ -42,12 +35,6 @@ func RegisterAll(root *cobra.Command) {
|
|||
"milestone": milestone.Shortcuts(),
|
||||
"webhook": webhook.Shortcuts(),
|
||||
"label": label.Shortcuts(),
|
||||
"file": file.Shortcuts(),
|
||||
"member": member.Shortcuts(),
|
||||
"watch": watch.Shortcuts(),
|
||||
"star": star.Shortcuts(),
|
||||
"wiki": wiki.Shortcuts(),
|
||||
"workflow": workflow.Shortcuts(),
|
||||
}
|
||||
|
||||
descriptions := map[string]string{
|
||||
|
|
@ -64,12 +51,6 @@ func RegisterAll(root *cobra.Command) {
|
|||
"milestone": "Milestone operations",
|
||||
"webhook": "Webhook operations",
|
||||
"label": "Issue label operations",
|
||||
"file": "Repository file operations",
|
||||
"member": "Project member management",
|
||||
"watch": "Watch repository operations",
|
||||
"star": "Star repository operations",
|
||||
"wiki": "Wiki page operations",
|
||||
"workflow": "Automated workflow operations",
|
||||
}
|
||||
|
||||
for name, shortcuts := range groups {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ package release
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -22,14 +19,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Release 列表失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -46,16 +43,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
tag, err := ctx.RequireArg("tag")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, _ := ctx.RequireArg("tag")
|
||||
name, _ := ctx.RequireArg("name")
|
||||
payload := map[string]interface{}{
|
||||
"tag_name": tag,
|
||||
"name": name,
|
||||
|
|
@ -71,7 +62,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 Release 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -84,120 +75,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
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: "download",
|
||||
Description: "Download release assets",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID or tag", Required: true},
|
||||
{Name: "asset", Short: "a", Usage: "Asset file name (omit to list all assets)"},
|
||||
{Name: "dir", Short: "d", Usage: "Download directory", Default: "."},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assetName := ctx.Arg("asset")
|
||||
dlDir := ctx.Arg("dir")
|
||||
if dlDir == "" {
|
||||
dlDir = "."
|
||||
}
|
||||
|
||||
releaseEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Release 信息失败: %w", err)
|
||||
}
|
||||
|
||||
releaseData, ok := releaseEnv.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("Release 响应格式异常")
|
||||
}
|
||||
|
||||
assets, ok := releaseData["assets"].([]interface{})
|
||||
if !ok || len(assets) == 0 {
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "该 Release 没有附件资源",
|
||||
}, nil))
|
||||
}
|
||||
|
||||
if assetName == "" {
|
||||
var assetList []map[string]interface{}
|
||||
for _, a := range assets {
|
||||
asset, _ := a.(map[string]interface{})
|
||||
assetList = append(assetList, map[string]interface{}{
|
||||
"name": asset["name"],
|
||||
"size": asset["size"],
|
||||
})
|
||||
}
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"assets": assetList,
|
||||
})
|
||||
}
|
||||
|
||||
var downloadURL string
|
||||
for _, a := range assets {
|
||||
asset, _ := a.(map[string]interface{})
|
||||
name, _ := asset["name"].(string)
|
||||
if name == assetName {
|
||||
downloadURL, _ = asset["download_url"].(string)
|
||||
if downloadURL == "" {
|
||||
downloadURL, _ = asset["url"].(string)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if downloadURL == "" {
|
||||
return fmt.Errorf("Release 中未找到附件 %q", assetName)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dlDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建目录 %s 失败: %w", dlDir, err)
|
||||
}
|
||||
|
||||
resp, err := ctx.Client.HTTP.Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
destPath := filepath.Join(dlDir, assetName)
|
||||
file, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件 %s 失败: %w", destPath, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
written, err := io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "下载完成",
|
||||
"path": destPath,
|
||||
"size": written,
|
||||
}, nil))
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -208,21 +93,22 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
_, 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))
|
||||
}
|
||||
return fmt.Errorf("删除 Release 失败: %w", delErr)
|
||||
// Release still exists — delete truly failed
|
||||
return delErr
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "删除成功",
|
||||
|
|
|
|||
|
|
@ -1,277 +0,0 @@
|
|||
package release
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestReleaseDownloadListsAssetsWhenNoAssetFlag(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"tag": "v1.0",
|
||||
"assets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "app-linux-amd64",
|
||||
"size": float64(1048576),
|
||||
"download_url": "https://example.com/dl/app-linux-amd64",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "app-darwin-amd64",
|
||||
"size": float64(2097152),
|
||||
"download_url": "https://example.com/dl/app-darwin-amd64",
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runReleaseShortcut(t, server, "download", map[string]string{
|
||||
"id": "v1.0",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("download shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDownloadNoAssetsReturnsMessage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"tag": "v1.0",
|
||||
"assets": []interface{}{},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runReleaseShortcut(t, server, "download", map[string]string{
|
||||
"id": "v1.0",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("download shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDownloadFetchesAssetURL(t *testing.T) {
|
||||
assetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Write([]byte("fake-binary-content"))
|
||||
}))
|
||||
defer assetServer.Close()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"tag": "v1.0",
|
||||
"assets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "app-linux-amd64",
|
||||
"size": float64(1048576),
|
||||
"download_url": assetServer.URL + "/dl/app-linux-amd64",
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
err := runReleaseShortcut(t, server, "download", map[string]string{
|
||||
"id": "v1.0",
|
||||
"asset": "app-linux-amd64",
|
||||
"dir": tmpDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("download shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
destPath := filepath.Join(tmpDir, "app-linux-amd64")
|
||||
data, err := os.ReadFile(destPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read downloaded file: %v", err)
|
||||
}
|
||||
if string(data) != "fake-binary-content" {
|
||||
t.Fatalf("got %q, want %q", string(data), "fake-binary-content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDownloadAssetNotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"tag": "v1.0",
|
||||
"assets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "app-linux-amd64",
|
||||
"size": float64(1048576),
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runReleaseShortcut(t, server, "download", map[string]string{
|
||||
"id": "v1.0",
|
||||
"asset": "nonexistent.tar.gz",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent asset, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json" {
|
||||
writeJSON(t, w, []map[string]interface{}{
|
||||
{"id": float64(1), "tag_name": "v1.0", "name": "First release"},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runReleaseShortcut(t, server, "list", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("release list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCreate(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/owner/repo/releases.json" {
|
||||
body = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1), "tag_name": "v2.0",
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runReleaseShortcut(t, server, "create", map[string]string{
|
||||
"tag": "v2.0",
|
||||
"name": "Version 2.0",
|
||||
"body": "Release notes here",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("release create failed: %v", err)
|
||||
}
|
||||
assertEqual(t, body["tag_name"], "v2.0")
|
||||
assertEqual(t, body["name"], "Version 2.0")
|
||||
assertEqual(t, body["body"], "Release notes here")
|
||||
}
|
||||
|
||||
func TestReleaseView(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1), "tag_name": "v1.0", "name": "First Release",
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runReleaseShortcut(t, server, "view", map[string]string{"id": "v1.0"})
|
||||
if err != nil {
|
||||
t.Fatalf("release view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDelete(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "DELETE" && r.URL.Path == "/owner/repo/releases/1.json" {
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("release delete failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runReleaseShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findReleaseShortcut(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 findReleaseShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
t.Helper()
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Name == name {
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
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("failed to decode request body: %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("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, got interface{}, 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,378 +0,0 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type batchRepoResult struct {
|
||||
Repo string `json:"repo" yaml:"repo"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Status string `json:"status" yaml:"status"`
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
}
|
||||
|
||||
type batchRepoSummary struct {
|
||||
Total int `json:"total" yaml:"total"`
|
||||
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
||||
Failed int `json:"failed" yaml:"failed"`
|
||||
Duration string `json:"duration" yaml:"duration"`
|
||||
Results []batchRepoResult `json:"results" yaml:"results"`
|
||||
}
|
||||
|
||||
func newBatchCreateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-create",
|
||||
Description: "批量创建仓库,支持逗号分隔列表或 CSV 文件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库名称,例如: repo1,repo2,repo3"},
|
||||
{Name: "from", Usage: "从 CSV 文件读取仓库名称。支持 name/repo/repository 列名或无表头首列"},
|
||||
{Name: "description", Short: "d", Usage: "仓库描述(所有仓库共用一个描述)"},
|
||||
{Name: "private", Usage: "设为私有仓库 (true/false)", Default: "false"},
|
||||
{Name: "dry-run", Usage: "仅预览将要创建的仓库,不实际执行", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchCreate,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchCreate(ctx *common.RuntimeContext) error {
|
||||
start := time.Now()
|
||||
|
||||
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(repos) == 0 {
|
||||
return fmt.Errorf("未提供仓库名称,请使用 --repos repo1,repo2 或 --from repos.csv")
|
||||
}
|
||||
|
||||
// 仅取仓库名,不需要 owner/repo 格式
|
||||
names := make([]string, len(repos))
|
||||
for i, r := range repos {
|
||||
parts := strings.SplitN(r, "/", 2)
|
||||
names[i] = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchRepoSummary{
|
||||
Total: len(names),
|
||||
Results: make([]batchRepoResult, 0, len(names)),
|
||||
}
|
||||
|
||||
var userLogin string
|
||||
var userID int
|
||||
if !dryRun {
|
||||
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取当前用户信息失败: %w", err)
|
||||
}
|
||||
userData, _ := userEnv.Data.(map[string]interface{})
|
||||
login, _ := userData["login"].(string)
|
||||
if login == "" {
|
||||
return fmt.Errorf("无法获取当前用户名")
|
||||
}
|
||||
userLogin = login
|
||||
uid, _ := userData["user_id"].(float64)
|
||||
userID = int(uid)
|
||||
}
|
||||
|
||||
private := ctx.Arg("private") == "true"
|
||||
desc := ctx.Arg("description")
|
||||
|
||||
for _, name := range names {
|
||||
result := batchRepoResult{Repo: name, Action: "create"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": name,
|
||||
"repository_name": name,
|
||||
"user_id": userID,
|
||||
}
|
||||
if desc != "" {
|
||||
body["description"] = desc
|
||||
}
|
||||
if private {
|
||||
body["private"] = true
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/%s/%s", userLogin, name)
|
||||
if _, err := ctx.CallAPI("POST", path, body); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "created"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
summary.Duration = time.Since(start).String()
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d / %d 个仓库创建失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newBatchForkShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-fork",
|
||||
Description: "批量 Fork 仓库,支持逗号分隔列表或 CSV 文件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo,例如: alice/proj1,bob/proj2"},
|
||||
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo 单列或 owner、repo 双列格式"},
|
||||
{Name: "dry-run", Usage: "仅预览将要 Fork 的仓库,不实际执行", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchFork,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchFork(ctx *common.RuntimeContext) error {
|
||||
start := time.Now()
|
||||
|
||||
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(repos) == 0 {
|
||||
return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchRepoSummary{
|
||||
Total: len(repos),
|
||||
Results: make([]batchRepoResult, 0, len(repos)),
|
||||
}
|
||||
|
||||
for _, repoID := range repos {
|
||||
parts := strings.SplitN(repoID, "/", 2)
|
||||
result := batchRepoResult{Repo: repoID, Action: "fork"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/%s/%s/forks", parts[0], parts[1])
|
||||
if _, err := ctx.CallAPI("POST", path, nil); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "forked"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
summary.Duration = time.Since(start).String()
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d / %d 个仓库 Fork 失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newBatchDeleteShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-delete",
|
||||
Description: "批量删除仓库,支持逗号分隔列表或 CSV 文件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo,例如: alice/proj1,bob/proj2"},
|
||||
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo 单列或 owner、repo 双列格式"},
|
||||
{Name: "dry-run", Usage: "仅预览将要删除的仓库,不实际执行", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchDelete,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchDelete(ctx *common.RuntimeContext) error {
|
||||
start := time.Now()
|
||||
|
||||
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(repos) == 0 {
|
||||
return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchRepoSummary{
|
||||
Total: len(repos),
|
||||
Results: make([]batchRepoResult, 0, len(repos)),
|
||||
}
|
||||
|
||||
for _, repoID := range repos {
|
||||
parts := strings.SplitN(repoID, "/", 2)
|
||||
result := batchRepoResult{Repo: repoID, Action: "delete"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/%s/%s", parts[0], parts[1])
|
||||
if _, err := ctx.CallAPI("DELETE", path, nil); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "deleted"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
summary.Duration = time.Since(start).String()
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d / %d 个仓库删除失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- CSV / list helpers ---
|
||||
|
||||
func collectRepos(reposValue, csvPath string) ([]string, error) {
|
||||
repos, err := parseRepoList(reposValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if csvPath == "" {
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
csvRepos, err := readReposFromCSV(csvPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mergeRepoStrings(repos, csvRepos), nil
|
||||
}
|
||||
|
||||
func parseRepoList(value string) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return normalizeRepoIDs(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
func readReposFromCSV(path string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
singleCol, ownerCol, repoCol := -1, -1, -1
|
||||
startRow := 0
|
||||
for i, cell := range records[0] {
|
||||
switch strings.ToLower(strings.TrimSpace(cell)) {
|
||||
case "owner/repo", "full_name":
|
||||
singleCol = i
|
||||
startRow = 1
|
||||
case "owner":
|
||||
ownerCol = i
|
||||
startRow = 1
|
||||
case "repo", "repository", "name":
|
||||
if repoCol == -1 {
|
||||
repoCol = i
|
||||
}
|
||||
startRow = 1
|
||||
}
|
||||
}
|
||||
|
||||
if ownerCol == -1 || repoCol == -1 {
|
||||
// Not dual-column: use single-column mode
|
||||
if singleCol == -1 {
|
||||
singleCol = 0
|
||||
}
|
||||
ownerCol = -1
|
||||
repoCol = -1
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(records)-startRow)
|
||||
for _, record := range records[startRow:] {
|
||||
var repoID string
|
||||
if singleCol >= 0 && singleCol < len(record) {
|
||||
repoID = record[singleCol]
|
||||
} else if ownerCol >= 0 && repoCol >= 0 && ownerCol < len(record) && repoCol < len(record) {
|
||||
repoID = record[ownerCol] + "/" + record[repoCol]
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
values = append(values, repoID)
|
||||
}
|
||||
return normalizeRepoIDs(values)
|
||||
}
|
||||
|
||||
func normalizeRepoIDs(values []string) ([]string, error) {
|
||||
repos := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
repoID := strings.TrimSpace(value)
|
||||
if repoID == "" {
|
||||
continue
|
||||
}
|
||||
if seen[repoID] {
|
||||
continue
|
||||
}
|
||||
seen[repoID] = true
|
||||
repos = append(repos, repoID)
|
||||
}
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
func mergeRepoStrings(values ...[]string) []string {
|
||||
merged := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, repos := range values {
|
||||
for _, r := range repos {
|
||||
if seen[r] {
|
||||
continue
|
||||
}
|
||||
seen[r] = true
|
||||
merged = append(merged, r)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func parseBool(value string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(value), "true") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -3,18 +3,12 @@ package repo
|
|||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCreateShortcut(),
|
||||
newBatchForkShortcut(),
|
||||
newBatchDeleteShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repositories for a user or organization",
|
||||
|
|
@ -38,61 +32,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
path = fmt.Sprintf("/users/%s/projects", user)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取仓库列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "clone",
|
||||
Description: "Clone a repository from GitLink",
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Short: "u", Usage: "Repository URL or owner/repo format", Required: true},
|
||||
{Name: "dir", Short: "d", Usage: "Target directory (default: repo name)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
rawURL, err := ctx.RequireArg("url")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetDir := ctx.Arg("dir")
|
||||
|
||||
var cloneURL string
|
||||
var repoName string
|
||||
|
||||
if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") {
|
||||
cloneURL = rawURL
|
||||
if !strings.HasSuffix(cloneURL, ".git") {
|
||||
cloneURL += ".git"
|
||||
}
|
||||
parts := strings.Split(strings.TrimSuffix(rawURL, ".git"), "/")
|
||||
repoName = parts[len(parts)-1]
|
||||
} else {
|
||||
parts := strings.SplitN(rawURL, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("无效的仓库格式 %q: 请使用 owner/repo 格式", rawURL)
|
||||
}
|
||||
webBase := strings.TrimSuffix(strings.TrimSuffix(ctx.Client.BaseURL, "/"), "/api")
|
||||
cloneURL = fmt.Sprintf("%s/%s/%s.git", webBase, parts[0], parts[1])
|
||||
repoName = parts[1]
|
||||
}
|
||||
|
||||
if targetDir == "" {
|
||||
targetDir = repoName
|
||||
}
|
||||
|
||||
fmt.Printf("正在克隆 %s 到 %s...\n", cloneURL, targetDir)
|
||||
|
||||
cmd := exec.Command("git", "clone", cloneURL, targetDir)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("克隆失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -100,11 +43,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
Description: "Show repository details",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取仓库详情失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -122,14 +65,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
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("获取当前用户信息失败: %w", err)
|
||||
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("无法获取当前用户名")
|
||||
return fmt.Errorf("cannot determine current user login")
|
||||
}
|
||||
userID, _ := userData["user_id"].(float64)
|
||||
body := map[string]interface{}{
|
||||
|
|
@ -145,7 +89,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建仓库失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -155,57 +99,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
Description: "Fork a repository",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Fork 仓库失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "settings",
|
||||
Description: "View or update repository settings",
|
||||
Flags: []common.Flag{
|
||||
{Name: "visibility", Usage: "Set visibility: public, private"},
|
||||
{Name: "default-branch", Usage: "Set default branch"},
|
||||
{Name: "description", Short: "d", Usage: "Update repository description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
|
||||
visibility := ctx.Arg("visibility")
|
||||
defaultBranch := ctx.Arg("default-branch")
|
||||
description := ctx.Arg("description")
|
||||
|
||||
if visibility == "" && defaultBranch == "" && description == "" {
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取仓库设置失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{}
|
||||
if visibility != "" {
|
||||
if visibility != "public" && visibility != "private" {
|
||||
return fmt.Errorf("无效的 visibility 值 %q: 必须为 public 或 private", visibility)
|
||||
}
|
||||
body["visibility"] = visibility
|
||||
}
|
||||
if defaultBranch != "" {
|
||||
body["default_branch"] = defaultBranch
|
||||
}
|
||||
if description != "" {
|
||||
body["description"] = description
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新仓库设置失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -215,11 +113,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
Description: "Delete a repository",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除仓库失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,446 +0,0 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestRepoSettingsViewShowsCurrentSettings(t *testing.T) {
|
||||
var capturedMethod string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedMethod = r.Method
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"name": "repo",
|
||||
"visibility": "public",
|
||||
"default_branch": "master",
|
||||
"description": "A test repo",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "settings", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("settings shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
if capturedMethod != "GET" {
|
||||
t.Fatalf("expected GET for viewing settings, got %s", capturedMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoSettingsUpdateVisibility(t *testing.T) {
|
||||
var updateMethod string
|
||||
var updatePayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
|
||||
updateMethod = r.Method
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"name": "repo",
|
||||
"visibility": "private",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "settings", map[string]string{
|
||||
"visibility": "private",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("settings shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
if updateMethod != "PATCH" {
|
||||
t.Fatalf("expected PATCH for updating settings, got %s", updateMethod)
|
||||
}
|
||||
assertEqual(t, updatePayload["visibility"], "private")
|
||||
}
|
||||
|
||||
func TestRepoSettingsUpdateDescription(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"description": "Updated description",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "settings", map[string]string{
|
||||
"description": "Updated description",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("settings shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, updatePayload["description"], "Updated description")
|
||||
}
|
||||
|
||||
func TestRepoSettingsUpdateDefaultBranch(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"default_branch": "main",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "settings", map[string]string{
|
||||
"default-branch": "main",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("settings shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, updatePayload["default_branch"], "main")
|
||||
}
|
||||
|
||||
func TestRepoSettingsRejectsInvalidVisibility(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("should not make API call with invalid visibility")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "settings", map[string]string{
|
||||
"visibility": "invalid",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid visibility, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoSettingsUpdateMultipleFields(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"visibility": "private",
|
||||
"default_branch": "main",
|
||||
"description": "new desc",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "settings", map[string]string{
|
||||
"visibility": "private",
|
||||
"default-branch": "main",
|
||||
"description": "new desc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("settings shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, updatePayload["visibility"], "private")
|
||||
assertEqual(t, updatePayload["default_branch"], "main")
|
||||
assertEqual(t, updatePayload["description"], "new desc")
|
||||
}
|
||||
|
||||
func runRepoShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findRepoShortcut(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 findRepoShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
t.Helper()
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Name == name {
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
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("failed to decode request body: %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("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, got interface{}, 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 TestRepoCloneParsesOwnerRepoFormat(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
fakeGit := createFakeGit(t, tmpDir)
|
||||
|
||||
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
|
||||
"url": "myorg/myrepo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("clone shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCloneParsesFullURL(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
fakeGit := createFakeGit(t, tmpDir)
|
||||
|
||||
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
|
||||
"url": "https://www.gitlink.org.cn/myorg/myrepo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("clone shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCloneCustomTargetDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
fakeGit := createFakeGit(t, tmpDir)
|
||||
|
||||
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
|
||||
"url": "myorg/myrepo",
|
||||
"dir": "/custom/dir",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("clone shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCloneInvalidFormat(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
fakeGit := createFakeGit(t, tmpDir)
|
||||
|
||||
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
|
||||
"url": "invalidformat",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid format, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "owner/repo") {
|
||||
t.Fatalf("expected error mentioning owner/repo, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCloneURLWithGitSuffix(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
fakeGit := createFakeGit(t, tmpDir)
|
||||
|
||||
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
|
||||
"url": "https://www.gitlink.org.cn/myorg/myrepo.git",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("clone shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createFakeGit(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
fakeGitPath := filepath.Join(dir, "git")
|
||||
script := `#!/bin/bash
|
||||
# Record arguments for test verification
|
||||
echo "git $@" > ` + filepath.Join(dir, "git_args.txt") + `
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(fakeGitPath, []byte(script), 0755); err != nil {
|
||||
t.Fatalf("failed to create fake git: %v", err)
|
||||
}
|
||||
return fakeGitPath
|
||||
}
|
||||
|
||||
func runRepoCloneShortcut(t *testing.T, fakeGitPath string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findRepoShortcut(t, "clone")
|
||||
|
||||
// Set up PATH to include fake git
|
||||
origPath := os.Getenv("PATH")
|
||||
gitDir := filepath.Dir(fakeGitPath)
|
||||
os.Setenv("PATH", gitDir+":"+origPath)
|
||||
defer os.Setenv("PATH", origPath)
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: nil,
|
||||
BaseURL: "https://www.gitlink.org.cn/api",
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func TestRepoListWithoutUser(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/projects.json" {
|
||||
writeJSON(t, w, []map[string]interface{}{
|
||||
{"id": float64(1), "name": "test-repo"},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "list", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("repo list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoListWithUser(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/users/testuser/projects.json" {
|
||||
writeJSON(t, w, []map[string]interface{}{
|
||||
{"id": float64(1), "name": "test-repo"},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "list", map[string]string{"user": "testuser"})
|
||||
if err != nil {
|
||||
t.Fatalf("repo list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoInfo(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1), "name": "test-repo", "visibility": "public",
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "info", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("repo info failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCreate(t *testing.T) {
|
||||
var createPath string
|
||||
var createBody 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":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"login": "testuser",
|
||||
"user_id": float64(42),
|
||||
})
|
||||
case r.Method == "POST" && r.URL.Path == "/testuser/myrepo.json":
|
||||
createPath = r.URL.Path
|
||||
createBody = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(99), "name": "myrepo",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "create", map[string]string{
|
||||
"name": "myrepo",
|
||||
"description": "test desc",
|
||||
"private": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("repo create failed: %v", err)
|
||||
}
|
||||
if createPath == "" {
|
||||
t.Fatal("create endpoint was not called")
|
||||
}
|
||||
assertEqual(t, createBody["name"], "myrepo")
|
||||
assertEqual(t, createBody["description"], "test desc")
|
||||
assertEqual(t, createBody["private"], true)
|
||||
}
|
||||
|
||||
func TestRepoFork(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/owner/repo/forks.json" {
|
||||
writeJSON(t, w, map[string]interface{}{"message": "forked"})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "fork", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("repo fork failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoDelete(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "DELETE" && r.URL.Path == "/owner/repo.json" {
|
||||
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "delete", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("repo delete failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -18,17 +17,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
keyword, err := ctx.RequireArg("keyword")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyword, _ := ctx.RequireArg("keyword")
|
||||
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 fmt.Errorf("搜索仓库失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -42,50 +38,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
keyword, err := ctx.RequireArg("keyword")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyword, _ := ctx.RequireArg("keyword")
|
||||
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 across repositories",
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
|
||||
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "all"},
|
||||
{Name: "owner", Short: "o", Usage: "Limit to owner's repositories"},
|
||||
{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 {
|
||||
keyword, err := ctx.RequireArg("keyword")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
if s := ctx.Arg("state"); s != "" && s != "all" {
|
||||
q.Set("state", s)
|
||||
}
|
||||
if o := ctx.Arg("owner"); o != "" {
|
||||
q.Set("owner", o)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/issues", q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("搜索 Issue 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,209 +0,0 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestSearchIssuesSendsKeywordAndState(t *testing.T) {
|
||||
var capturedPath string
|
||||
var capturedQuery url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedPath = r.URL.Path
|
||||
capturedQuery = r.URL.Query()
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"data": []map[string]interface{}{
|
||||
{"id": float64(1), "subject": "bug found"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runSearchShortcut(t, server, "issues", map[string]string{
|
||||
"keyword": "bug",
|
||||
"state": "open",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issues shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
if capturedPath != "/issues.json" {
|
||||
t.Fatalf("expected path /issues.json, got %s", capturedPath)
|
||||
}
|
||||
if capturedQuery.Get("search") != "bug" {
|
||||
t.Fatalf("expected search=bug, got %s", capturedQuery.Get("search"))
|
||||
}
|
||||
if capturedQuery.Get("state") != "open" {
|
||||
t.Fatalf("expected state=open, got %s", capturedQuery.Get("state"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchIssuesOmitsStateWhenAll(t *testing.T) {
|
||||
var capturedQuery url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedQuery = r.URL.Query()
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"data": []map[string]interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runSearchShortcut(t, server, "issues", map[string]string{
|
||||
"keyword": "bug",
|
||||
"state": "all",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issues shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
if capturedQuery.Get("state") != "" {
|
||||
t.Fatalf("expected no state filter, got %s", capturedQuery.Get("state"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchIssuesSendsOwnerWhenProvided(t *testing.T) {
|
||||
var capturedQuery url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedQuery = r.URL.Query()
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"data": []map[string]interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runSearchShortcut(t, server, "issues", map[string]string{
|
||||
"keyword": "bug",
|
||||
"owner": "myorg",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issues shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
if capturedQuery.Get("owner") != "myorg" {
|
||||
t.Fatalf("expected owner=myorg, got %s", capturedQuery.Get("owner"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchIssuesDefaultsPageAndLimit(t *testing.T) {
|
||||
var capturedQuery url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedQuery = r.URL.Query()
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"data": []map[string]interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runSearchShortcut(t, server, "issues", map[string]string{
|
||||
"keyword": "bug",
|
||||
"page": "1",
|
||||
"limit": "20",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issues shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
if capturedQuery.Get("page") != "1" {
|
||||
t.Fatalf("expected page=1, got %s", capturedQuery.Get("page"))
|
||||
}
|
||||
if capturedQuery.Get("limit") != "20" {
|
||||
t.Fatalf("expected limit=20, got %s", capturedQuery.Get("limit"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchRepos(t *testing.T) {
|
||||
var capturedQuery url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedQuery = r.URL.Query()
|
||||
writeJSON(t, w, []map[string]interface{}{
|
||||
{"id": float64(1), "name": "search-result"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runSearchShortcut(t, server, "repos", map[string]string{
|
||||
"keyword": "machine-learning",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("search repos failed: %v", err)
|
||||
}
|
||||
if capturedQuery.Get("search") != "machine-learning" {
|
||||
t.Fatalf("expected search=machine-learning, got %s", capturedQuery.Get("search"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchUsers(t *testing.T) {
|
||||
var capturedPath string
|
||||
var capturedQuery url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedPath = r.URL.Path
|
||||
capturedQuery = r.URL.Query()
|
||||
writeJSON(t, w, []map[string]interface{}{
|
||||
{"id": float64(1), "login": "zhangsan"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runSearchShortcut(t, server, "users", map[string]string{
|
||||
"keyword": "zhangsan",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("search users failed: %v", err)
|
||||
}
|
||||
if capturedPath != "/users/list.json" {
|
||||
t.Fatalf("expected path /users/list.json, got %s", capturedPath)
|
||||
}
|
||||
if capturedQuery.Get("search") != "zhangsan" {
|
||||
t.Fatalf("expected search=zhangsan, got %s", capturedQuery.Get("search"))
|
||||
}
|
||||
}
|
||||
|
||||
func runSearchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findSearchShortcut(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 findSearchShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
t.Helper()
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Name == name {
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
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("failed to decode request body: %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("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
package star
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "star",
|
||||
Description: "Star (like) a repository",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := resolveProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST",
|
||||
fmt.Sprintf("/projects/%d/praise_tread/like", projectID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unstar",
|
||||
Description: "Unstar (unlike) a repository",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := resolveProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE",
|
||||
fmt.Sprintf("/projects/%d/praise_tread/unlike", projectID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "stars",
|
||||
Description: "List stargazers of a repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Repository owner", Required: true},
|
||||
{Name: "repo", Short: "r", Usage: "Repository name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
owner, _ := ctx.RequireArg("owner")
|
||||
repo, _ := ctx.RequireArg("repo")
|
||||
env, err := ctx.CallAPI("GET",
|
||||
fmt.Sprintf("/%s/%s/stargazers", owner, repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveProjectID(ctx *common.RuntimeContext) (int64, error) {
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get project info: %w", err)
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unexpected project info response")
|
||||
}
|
||||
for _, key := range []string{"id", "project_id", "repo_id"} {
|
||||
if id, ok := data[key].(float64); ok {
|
||||
return int64(id), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("cannot find project id in response")
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
package star
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestStar(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
|
||||
case r.Method == "POST" && r.URL.Path == "/projects/100/praise_tread/like.json":
|
||||
writeJSON(t, w, map[string]interface{}{})
|
||||
default:
|
||||
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runStarShortcut(t, server, "star", map[string]string{}); err != nil {
|
||||
t.Fatalf("star failed: %v", err)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnstar(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
|
||||
case r.Method == "DELETE" && r.URL.Path == "/projects/100/praise_tread/unlike.json":
|
||||
writeJSON(t, w, map[string]interface{}{})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runStarShortcut(t, server, "unstar", map[string]string{}); err != nil {
|
||||
t.Fatalf("unstar failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStars(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/owner/repo/stargazers.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"count": 1,
|
||||
"users": []map[string]interface{}{{"login": "alice"}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runStarShortcut(t, server, "stars", map[string]string{
|
||||
"owner": "owner", "repo": "repo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stars failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runStarShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findStarShortcut(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 findStarShortcut(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 writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
|
@ -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 fmt.Errorf("获取当前用户信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -32,7 +32,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看用户详情失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestUserMe(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/users/me.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"login": "testuser",
|
||||
"name": "Test User",
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "me", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("user me failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserInfo(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/users/zhangsan.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(42),
|
||||
"login": "zhangsan",
|
||||
"name": "Zhang San",
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runUserShortcut(t, server, "info", map[string]string{"login": "zhangsan"})
|
||||
if err != nil {
|
||||
t.Fatalf("user info failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runUserShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findUserShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findUserShortcut(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 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("failed to decode request body: %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("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
package watch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "watch",
|
||||
Description: "Watch a repository",
|
||||
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("target_type", "project")
|
||||
q.Set("id", fmt.Sprintf("%d", projectID))
|
||||
env, err := ctx.CallAPIWithQuery("POST", "/watchers/follow", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unwatch",
|
||||
Description: "Unwatch a repository",
|
||||
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("target_type", "project")
|
||||
q.Set("id", fmt.Sprintf("%d", projectID))
|
||||
env, err := ctx.CallAPIWithQuery("DELETE", "/watchers/unfollow", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "watchers",
|
||||
Description: "List watchers of a repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Repository owner", Required: true},
|
||||
{Name: "repo", Short: "r", Usage: "Repository name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
owner, _ := ctx.RequireArg("owner")
|
||||
repo, _ := ctx.RequireArg("repo")
|
||||
ctx.Owner = owner
|
||||
ctx.Repo = repo
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/watchers", owner, repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveProjectID(ctx *common.RuntimeContext) (int64, error) {
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get project info: %w", err)
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unexpected project info response")
|
||||
}
|
||||
for _, key := range []string{"id", "project_id", "repo_id"} {
|
||||
if id, ok := data[key].(float64); ok {
|
||||
return int64(id), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("cannot find project id in response")
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
package watch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestWatch(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(100), "name": "repo"})
|
||||
case r.Method == "POST" && r.URL.Path == "/watchers/follow.json":
|
||||
if r.URL.Query().Get("target_type") != "project" {
|
||||
t.Fatal("expected target_type=project")
|
||||
}
|
||||
if r.URL.Query().Get("id") != "100" {
|
||||
t.Fatalf("expected id=100, got %s", r.URL.Query().Get("id"))
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"watched": true})
|
||||
default:
|
||||
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runWatchShortcut(t, server, "watch", map[string]string{}); err != nil {
|
||||
t.Fatalf("watch failed: %v", err)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Fatalf("expected 2 calls (GET project + POST watch), got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwatch(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
|
||||
case r.Method == "DELETE" && r.URL.Path == "/watchers/unfollow.json":
|
||||
writeJSON(t, w, map[string]interface{}{"watched": false})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runWatchShortcut(t, server, "unwatch", map[string]string{}); err != nil {
|
||||
t.Fatalf("unwatch failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchers(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/owner/repo/watchers.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"count": 1,
|
||||
"users": []map[string]interface{}{{"login": "alice", "is_watch": true}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWatchShortcut(t, server, "watchers", map[string]string{
|
||||
"owner": "owner", "repo": "repo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("watchers failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// === helpers ===
|
||||
|
||||
func runWatchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findWatchShortcut(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 findWatchShortcut(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 writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
|
@ -18,11 +18,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
Description: "List webhooks",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", v1RepoPath(ctx)+"/webhooks", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -40,7 +40,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
webhookURL, err := ctx.RequireArg("url")
|
||||
if err != nil {
|
||||
|
|
@ -68,7 +68,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/webhooks", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 Webhook 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -81,7 +81,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
|
|
@ -89,7 +89,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看 Webhook 详情失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -108,7 +108,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
|
|
@ -141,11 +141,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
body["active"] = active == "true"
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("至少需要指定一个要更新的字段")
|
||||
return fmt.Errorf("at least one update field is required")
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新 Webhook 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -158,7 +158,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
|
|
@ -166,7 +166,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除 Webhook 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -179,7 +179,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
|
|
@ -187,7 +187,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", v1RepoPath(ctx), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("测试 Webhook 失败: %w", err)
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -113,24 +113,6 @@ func TestWebhookTest(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestWebhookView(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks/1.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1), "url": "https://example.com/hook", "is_active": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWebhookShortcut(t, server, "view", map[string]string{"id": "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("webhook view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookUpdateRequiresAtLeastOneField(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made when no fields are provided")
|
||||
|
|
|
|||
|
|
@ -1,302 +0,0 @@
|
|||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const wikiBaseURL = "https://gateway.gitlink.org.cn/api"
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List wiki pages",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := fetchProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
q.Set("projectId", fmt.Sprintf("%d", projectID))
|
||||
return callWikiAPI(ctx, "GET", "/wiki/open/wikiPages", nil, q)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View a wiki page",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := fetchProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
q.Set("projectId", fmt.Sprintf("%d", projectID))
|
||||
q.Set("pageName", name)
|
||||
return callWikiAPI(ctx, "GET", "/wiki/open/getWiki", nil, q)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a wiki page",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "Page content (will be base64 encoded)", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := fetchProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"owner": ctx.Owner,
|
||||
"repo": ctx.Repo,
|
||||
"projectId": projectID,
|
||||
"pageName": name,
|
||||
"title": name,
|
||||
"message": ctx.Arg("message"),
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
}
|
||||
return callWikiAPI(ctx, "POST", "/wiki/open/createWiki", body, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a wiki page",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "New page content (will be base64 encoded)", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := fetchProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"owner": ctx.Owner,
|
||||
"repo": ctx.Repo,
|
||||
"projectId": projectID,
|
||||
"pageName": name,
|
||||
"title": name,
|
||||
"message": ctx.Arg("message"),
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
}
|
||||
return callWikiAPI(ctx, "PUT", "/wiki/open/updateWiki", body, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a wiki page and remove it from sidebar",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := fetchProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Delete the wiki page
|
||||
body := map[string]interface{}{
|
||||
"owner": ctx.Owner,
|
||||
"repo": ctx.Repo,
|
||||
"projectId": projectID,
|
||||
"pageName": name,
|
||||
}
|
||||
if err := callWikiAPISilent(ctx, "DELETE", "/wiki/open/deleteWiki", body, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: Wait for GitLink async sidebar rebuild, then clean up
|
||||
time.Sleep(2 * time.Second)
|
||||
cleanSidebar(ctx, projectID, name)
|
||||
|
||||
fmt.Printf("Wiki page %q deleted successfully.\n", name)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// callWikiAPI sends a request to the wiki gateway.
|
||||
// It switches the client BaseURL to the wiki gateway for the duration of the call,
|
||||
// but skips the switch during tests (local httptest server).
|
||||
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error {
|
||||
origBase := ctx.Client.BaseURL
|
||||
if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") {
|
||||
ctx.Client.BaseURL = wikiBaseURL
|
||||
}
|
||||
defer func() { ctx.Client.BaseURL = origBase }()
|
||||
|
||||
env, err := ctx.Client.DoRaw(method, path, body, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
// fetchProjectID resolves the numeric project ID from the repo info API.
|
||||
func fetchProjectID(ctx *common.RuntimeContext) (int64, error) {
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get project info: %w", err)
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unexpected project info response")
|
||||
}
|
||||
for _, key := range []string{"project_id", "repo_id", "id"} {
|
||||
if id, ok := data[key].(float64); ok {
|
||||
return int64(id), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("project id not found in response")
|
||||
}
|
||||
|
||||
// callWikiAPISilent is like callWikiAPI but does not print output.
|
||||
func callWikiAPISilent(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error {
|
||||
origBase := ctx.Client.BaseURL
|
||||
if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") {
|
||||
ctx.Client.BaseURL = wikiBaseURL
|
||||
}
|
||||
defer func() { ctx.Client.BaseURL = origBase }()
|
||||
|
||||
_, err := ctx.Client.DoRaw(method, path, body, query)
|
||||
return err
|
||||
}
|
||||
|
||||
const sidebarPageName = "_Sidebar" // GitLink uses capital S for the sidebar page
|
||||
|
||||
// cleanSidebar fetches the wiki sidebar, removes the deleted page link, and updates it.
|
||||
func cleanSidebar(ctx *common.RuntimeContext, projectID int64, pageName string) {
|
||||
// Fetch sidebar
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
q.Set("projectId", fmt.Sprintf("%d", projectID))
|
||||
q.Set("pageName", sidebarPageName)
|
||||
|
||||
origBase := ctx.Client.BaseURL
|
||||
if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") {
|
||||
ctx.Client.BaseURL = wikiBaseURL
|
||||
}
|
||||
defer func() { ctx.Client.BaseURL = origBase }()
|
||||
|
||||
env, err := ctx.Client.DoRaw("GET", "/wiki/open/getWiki", nil, q)
|
||||
if err != nil {
|
||||
return // sidebar might not exist, silently skip
|
||||
}
|
||||
|
||||
// Extract content_base64 from response.
|
||||
// DoRaw auto-parses JSON, so env.Data is a map with "data" as either
|
||||
// a nested dict (already parsed) or a JSON string (needs parsing).
|
||||
outer, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var inner map[string]interface{}
|
||||
switch v := outer["data"].(type) {
|
||||
case map[string]interface{}:
|
||||
inner = v
|
||||
case string:
|
||||
if err := json.Unmarshal([]byte(v), &inner); err != nil {
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
contentB64, ok := inner["content_base64"].(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
contentBytes, err := base64.StdEncoding.DecodeString(contentB64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sidebar := string(contentBytes)
|
||||
|
||||
// Remove the line containing [[pageName]]
|
||||
target := "[[" + pageName + "]]"
|
||||
lines := strings.Split(sidebar, "\n")
|
||||
var newLines []string
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != target {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
newSidebar := strings.Join(newLines, "\n")
|
||||
|
||||
// No change needed
|
||||
if newSidebar == sidebar {
|
||||
return
|
||||
}
|
||||
|
||||
// Update sidebar
|
||||
body := map[string]interface{}{
|
||||
"owner": ctx.Owner,
|
||||
"repo": ctx.Repo,
|
||||
"projectId": projectID,
|
||||
"pageName": sidebarPageName,
|
||||
"title": sidebarPageName,
|
||||
"message": "Remove deleted page " + pageName + " from sidebar",
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte(newSidebar)),
|
||||
}
|
||||
ctx.Client.DoRaw("PUT", "/wiki/open/updateWiki", body, nil)
|
||||
}
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- list ---
|
||||
|
||||
func TestWikiList(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
|
||||
case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages":
|
||||
if r.URL.Query().Get("projectId") != "42" {
|
||||
t.Fatalf("expected projectId=42, got %s", r.URL.Query().Get("projectId"))
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"title": "Home", "sub_url": "Home"},
|
||||
map[string]interface{}{"title": "Guide", "sub_url": "Guide"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runWikiShortcut(t, server, "list", map[string]string{}); err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiListWithProjectID(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
writeJSON(t, w, map[string]interface{}{"project_id": float64(99)})
|
||||
case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages":
|
||||
if r.URL.Query().Get("projectId") != "99" {
|
||||
t.Fatalf("expected projectId=99, got %s", r.URL.Query().Get("projectId"))
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"data": []interface{}{}})
|
||||
default:
|
||||
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runWikiShortcut(t, server, "list", map[string]string{}); err != nil {
|
||||
t.Fatalf("list with project_id failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- view ---
|
||||
|
||||
func TestWikiView(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
|
||||
case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki":
|
||||
if r.URL.Query().Get("pageName") != "Home" {
|
||||
t.Fatalf("expected pageName=Home, got %s", r.URL.Query().Get("pageName"))
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"title": "Home",
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte("Welcome to wiki")),
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWikiShortcut(t, server, "view", map[string]string{"name": "Home"})
|
||||
if err != nil {
|
||||
t.Fatalf("view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiViewRequiresName(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made without --name")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWikiShortcut(t, server, "view", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --name")
|
||||
}
|
||||
}
|
||||
|
||||
// --- create ---
|
||||
|
||||
func TestWikiCreate(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
|
||||
case r.Method == "POST" && r.URL.Path == "/wiki/open/createWiki":
|
||||
var payload map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["pageName"] != "NewPage" {
|
||||
t.Fatalf("expected pageName=NewPage, got %v", payload["pageName"])
|
||||
}
|
||||
if payload["title"] != "NewPage" {
|
||||
t.Fatalf("expected title=NewPage, got %v", payload["title"])
|
||||
}
|
||||
if payload["owner"] != "owner" {
|
||||
t.Fatalf("expected owner=owner, got %v", payload["owner"])
|
||||
}
|
||||
if payload["repo"] != "repo" {
|
||||
t.Fatalf("expected repo=repo, got %v", payload["repo"])
|
||||
}
|
||||
if payload["projectId"].(float64) != 42 {
|
||||
t.Fatalf("expected projectId=42, got %v", payload["projectId"])
|
||||
}
|
||||
expectedContent := base64.StdEncoding.EncodeToString([]byte("Hello Wiki!"))
|
||||
if payload["content_base64"] != expectedContent {
|
||||
t.Fatalf("content_base64 mismatch: got %v", payload["content_base64"])
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"code": 201,
|
||||
"data": map[string]interface{}{"title": "NewPage"},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWikiShortcut(t, server, "create", map[string]string{
|
||||
"name": "NewPage", "content": "Hello Wiki!", "message": "create page",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiCreateRequiresName(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made without --name")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWikiShortcut(t, server, "create", map[string]string{"content": "test"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiCreateRequiresContent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made without --content")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWikiShortcut(t, server, "create", map[string]string{"name": "Test"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --content")
|
||||
}
|
||||
}
|
||||
|
||||
// --- update ---
|
||||
|
||||
func TestWikiUpdate(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
|
||||
case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki":
|
||||
var payload map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["pageName"] != "Home" {
|
||||
t.Fatalf("expected pageName=Home, got %v", payload["pageName"])
|
||||
}
|
||||
if payload["title"] != "Home" {
|
||||
t.Fatalf("expected title=Home, got %v", payload["title"])
|
||||
}
|
||||
if payload["message"] != "update page" {
|
||||
t.Fatalf("expected message=update page, got %v", payload["message"])
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"code": 200})
|
||||
default:
|
||||
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWikiShortcut(t, server, "update", map[string]string{
|
||||
"name": "Home", "content": "Updated content", "message": "update page",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiUpdateRequiresName(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made without --name")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWikiShortcut(t, server, "update", map[string]string{"content": "test"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --name")
|
||||
}
|
||||
}
|
||||
|
||||
// --- delete ---
|
||||
|
||||
func TestWikiDelete(t *testing.T) {
|
||||
callCount := 0
|
||||
sidebarContent := "[[Home]]\n[[OldPage]]\n[[Guide]]"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
|
||||
case r.Method == "DELETE" && r.URL.Path == "/wiki/open/deleteWiki":
|
||||
var payload map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["pageName"] != "OldPage" {
|
||||
t.Fatalf("expected pageName=OldPage, got %v", payload["pageName"])
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"code": 200})
|
||||
case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki":
|
||||
if r.URL.Query().Get("pageName") != "_Sidebar" {
|
||||
t.Fatalf("expected pageName=_Sidebar, got %s", r.URL.Query().Get("pageName"))
|
||||
}
|
||||
// Return sidebar with the page still in it
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"code": 200,
|
||||
"data": fmt.Sprintf(`{"content_base64":"%s"}`, base64.StdEncoding.EncodeToString([]byte(sidebarContent))),
|
||||
})
|
||||
case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki":
|
||||
var payload map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["pageName"] != "_Sidebar" {
|
||||
t.Fatalf("expected pageName=_Sidebar, got %v", payload["pageName"])
|
||||
}
|
||||
// Verify OldPage is removed from sidebar
|
||||
updated, _ := base64.StdEncoding.DecodeString(payload["content_base64"].(string))
|
||||
if strings.Contains(string(updated), "[[OldPage]]") {
|
||||
t.Fatal("sidebar should not contain [[OldPage]] after delete")
|
||||
}
|
||||
if !strings.Contains(string(updated), "[[Home]]") {
|
||||
t.Fatal("sidebar should still contain [[Home]]")
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"code": 200})
|
||||
default:
|
||||
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWikiShortcut(t, server, "delete", map[string]string{"name": "OldPage"})
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiDeleteRequiresName(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made without --name")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWikiShortcut(t, server, "delete", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --name")
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func runWikiShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findWikiShortcut(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 findWikiShortcut(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 writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
)
|
||||
|
||||
const anthropicBaseURL = "https://api.anthropic.com/v1/messages"
|
||||
const defaultModel = "claude-sonnet-4-6"
|
||||
|
||||
// AIClient wraps the Anthropic Messages API for skill step execution.
|
||||
type AIClient struct {
|
||||
apiKey string
|
||||
model string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// AIRequest bundles the data needed for an AI skill step call.
|
||||
type AIRequest struct {
|
||||
SystemPrompt string
|
||||
UserData string
|
||||
}
|
||||
|
||||
// AIResponse is the parsed structured output from an AI skill step.
|
||||
type AIResponse struct {
|
||||
Analysis interface{} `json:"analysis"`
|
||||
Actions []AIAction `json:"actions"`
|
||||
}
|
||||
|
||||
// NewAIClient resolves the API key (env → config) and returns a client, or nil if unavailable.
|
||||
func NewAIClient() *AIClient {
|
||||
key := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if key == "" {
|
||||
cfg, err := config.Load()
|
||||
if err == nil {
|
||||
key = cfg.AnthropicAPIKey
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
return &AIClient{
|
||||
apiKey: key,
|
||||
model: defaultModel,
|
||||
http: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze sends the skill prompt + upstream data to the Anthropic API and parses the response.
|
||||
func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("AI client not configured: set ANTHROPIC_API_KEY or configure anthropic_api_key")
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": c.model,
|
||||
"max_tokens": 4096,
|
||||
"system": req.SystemPrompt,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": req.UserData},
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", anthropicBaseURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("x-api-key", c.apiKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API call: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("Anthropic API returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Content) == 0 {
|
||||
return nil, fmt.Errorf("empty response from Anthropic API")
|
||||
}
|
||||
|
||||
text := result.Content[0].Text
|
||||
var aiResp AIResponse
|
||||
if err := json.Unmarshal([]byte(text), &aiResp); err != nil {
|
||||
return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text)
|
||||
}
|
||||
|
||||
return &aiResp, nil
|
||||
}
|
||||
|
||||
// HasKey reports whether the AI client is configured.
|
||||
func (c *AIClient) HasKey() bool {
|
||||
return c != nil && c.apiKey != ""
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package workflow
|
||||
|
||||
func registerCodeQuality() {
|
||||
register(&WorkflowDef{
|
||||
Name: "code-quality",
|
||||
Category: "质量",
|
||||
Description: "代码质量看门人:PR 提交 → Review → CI 检查 → 结果汇总",
|
||||
Trigger: TriggerDef{
|
||||
Type: "poll",
|
||||
On: "pr.opened",
|
||||
Interval: "5m",
|
||||
},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
|
||||
{Type: StepTypeCommand, Name: "ci-builds", Purpose: "获取 CI 构建状态", Target: "ci +list --limit 10"},
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
|
||||
{Type: StepTypeCommand, Name: "commits", Purpose: "获取最近提交供 CI 关联分析", Target: "commit +list --limit 30"},
|
||||
{Type: StepTypeCommand, Name: "branches", Purpose: "获取分支列表检查保护状态", Target: "branch +list"},
|
||||
{Type: StepTypeSkill, Name: "review", Purpose: "AI 审查 PR 代码质量", Target: "gitlink-review", DependsOn: []string{"open-prs"}},
|
||||
{Type: StepTypeSkill, Name: "ci-diagnosis", Purpose: "AI 诊断 CI 构建失败并给出建议", Target: "gitlink-ci", DependsOn: []string{"ci-builds", "commits"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package workflow
|
||||
|
||||
func registerCommunityOps() {
|
||||
register(&WorkflowDef{
|
||||
Name: "community-ops",
|
||||
Category: "运营",
|
||||
Description: "社区运营自动化:Issue 智能分拣 → 生成周报 → 生成 Release Notes",
|
||||
Trigger: TriggerDef{
|
||||
Type: "poll",
|
||||
On: "issue.created",
|
||||
Interval: "5m",
|
||||
},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "open-issues", Purpose: "获取所有开放 Issue 供 AI 分类", Target: "issue +list --state open --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "labels", Purpose: "获取标签库供 AI 匹配", Target: "label +list"},
|
||||
{Type: StepTypeCommand, Name: "members", Purpose: "获取成员列表供 AI 分配责任人", Target: "member +list"},
|
||||
{Type: StepTypeSkill, Name: "triage", Purpose: "AI 分析前三步数据,输出分拣表格并执行打标签/分配", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}},
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取项目基础信息", Target: "repo +info"},
|
||||
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "获取已合并 PR 计算合并效率", Target: "pr +list --state merged --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "commits", Purpose: "获取提交历史分析活跃度", Target: "commit +list --limit 50"},
|
||||
{Type: StepTypeSkill, Name: "health-report", Purpose: "AI 根据指标生成周报", Target: "gitlink-health", DependsOn: []string{"repo-info", "merged-prs", "commits", "open-issues"}},
|
||||
{Type: StepTypeCommand, Name: "releases", Purpose: "获取版本发布记录", Target: "release +list"},
|
||||
{Type: StepTypeSkill, Name: "changelog", Purpose: "AI 分类 commit 生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package workflow
|
||||
|
||||
func registerContributorGrowth() {
|
||||
register(&WorkflowDef{
|
||||
Name: "contributor-growth",
|
||||
Category: "成长",
|
||||
Description: "贡献者成长体系:追踪贡献者活动 → 生成排行 → 识别活跃与流失",
|
||||
Trigger: TriggerDef{
|
||||
Type: "cron",
|
||||
On: "0 9 * * 1",
|
||||
Interval: "24h",
|
||||
},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "commits", Purpose: "提交历史统计代码贡献", Target: "commit +list --limit 100"},
|
||||
{Type: StepTypeCommand, Name: "open-issues", Purpose: "开放 Issue 统计 Issue 贡献", Target: "issue +list --state open --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "closed-issues", Purpose: "已关闭 Issue 统计解决贡献", Target: "issue +list --state closed --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "已合并 PR 统计代码贡献", Target: "pr +list --state merged --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "members", Purpose: "项目成员列表统计参与度", Target: "member +list"},
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "项目基础数据(Fork/Star/Watch)", Target: "repo +info"},
|
||||
{Type: StepTypeSkill, Name: "contributor-ranking", Purpose: "AI 分析贡献者排行并识别活跃与流失", Target: "gitlink-health", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// StartDaemon launches a workflow as a background daemon process.
|
||||
func StartDaemon(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration, aiMode string) error {
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot find executable: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"workflow", "+run", "--name", wf.Name,
|
||||
"--owner", ctx.Owner, "--repo", ctx.Repo,
|
||||
"--format", "json", "--daemon-loop",
|
||||
"--interval", interval.String(),
|
||||
}
|
||||
if aiMode == "ai" {
|
||||
args = append(args, "--ai")
|
||||
} else if aiMode == "no-ai" {
|
||||
args = append(args, "--no-ai")
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
// Redirect output to log file instead of discarding.
|
||||
logPath := daemonLogPath(wf.Name)
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create log file: %w", err)
|
||||
}
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.Stdin = nil
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return fmt.Errorf("start daemon: %w", err)
|
||||
}
|
||||
// logFile is owned by child process; it will be closed when child exits.
|
||||
|
||||
pid := cmd.Process.Pid
|
||||
if err := savePID(wf.Name, pid); err != nil {
|
||||
return fmt.Errorf("save pid: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Daemon started for %q (PID: %d)\n", wf.Name, pid)
|
||||
fmt.Printf("Log: %s\n", logPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopDaemon stops a running workflow daemon by name.
|
||||
func StopDaemon(name string) error {
|
||||
pid, err := readPID(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
cleanPID(name)
|
||||
return fmt.Errorf("daemon %q not running (PID %d not found)", name, pid)
|
||||
}
|
||||
|
||||
if err := proc.Signal(os.Interrupt); err != nil {
|
||||
// Process might already be dead; clean up pid file anyway.
|
||||
cleanPID(name)
|
||||
return fmt.Errorf("failed to stop daemon %q: %w", name, err)
|
||||
}
|
||||
|
||||
cleanPID(name)
|
||||
fmt.Printf("Daemon %q stopped (PID %d)\n", name, pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StatusDaemon prints the current daemon status for a workflow.
|
||||
func StatusDaemon(name string) error {
|
||||
state, err := LoadState(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load state: %w", err)
|
||||
}
|
||||
|
||||
pid, pidErr := readPID(name)
|
||||
running := pidErr == nil && processRunning(pid)
|
||||
|
||||
fmt.Printf("工作流: %s\n", name)
|
||||
if running {
|
||||
fmt.Printf("状态: 运行中 (PID: %d)\n", pid)
|
||||
} else {
|
||||
fmt.Println("状态: 已停止")
|
||||
}
|
||||
if state.LastRun != "" {
|
||||
t, err := time.Parse(time.RFC3339, state.LastRun)
|
||||
if err == nil {
|
||||
fmt.Printf("上次运行: %s\n", t.Format("2006-01-02 15:04"))
|
||||
} else {
|
||||
fmt.Printf("上次运行: %s\n", state.LastRun)
|
||||
}
|
||||
}
|
||||
fmt.Printf("累计运行: %d 次\n", state.TotalRuns)
|
||||
fmt.Printf("快照步骤: %d 个\n", len(state.Snapshots))
|
||||
fmt.Printf("日志文件: %s\n", daemonLogPath(name))
|
||||
return nil
|
||||
}
|
||||
|
||||
// DaemonLoop runs the workflow repeatedly in a loop (used by the daemon subprocess).
|
||||
func DaemonLoop(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration) error {
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
|
||||
// Run immediately on start (dry-run to establish baseline).
|
||||
doDaemonCycle(ctx, wf)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-tick.C:
|
||||
doDaemonCycle(ctx, wf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
|
||||
state, _ := LoadState(wf.Name)
|
||||
|
||||
// Phase 1: cheap dry-run — collect data without AI.
|
||||
dryResult, err := Run(ctx, wf, true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[%s] error: %v\n", time.Now().Format(time.RFC3339), err)
|
||||
return
|
||||
}
|
||||
|
||||
changed := state.Diff(dryResult.Steps)
|
||||
if len(changed) == 0 && state.TotalRuns > 0 {
|
||||
// No data changes — save state, skip expensive run.
|
||||
state.TotalRuns++
|
||||
state.Save()
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[%s] 🔔 检测到变更: %v\n", time.Now().Format(time.RFC3339), changed)
|
||||
|
||||
// Phase 2: full run (AI or rules based on context.AIMode).
|
||||
fullResult, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[%s] run error: %v\n", time.Now().Format(time.RFC3339), err)
|
||||
return
|
||||
}
|
||||
|
||||
state.TotalRuns++
|
||||
state.Diff(fullResult.Steps)
|
||||
state.Save()
|
||||
|
||||
ok, total := 0, len(fullResult.Steps)
|
||||
for _, sr := range fullResult.Steps {
|
||||
if sr.OK {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[%s] ✅ %d/%d steps OK\n", time.Now().Format(time.RFC3339), ok, total)
|
||||
}
|
||||
|
||||
// daemonLogPath returns the log file path for a workflow daemon.
|
||||
func daemonLogPath(name string) string {
|
||||
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.log", name))
|
||||
}
|
||||
|
||||
// tailDaemonLog reads and optionally follows a daemon log file.
|
||||
func tailDaemonLog(name string, follow bool) error {
|
||||
path := daemonLogPath(name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取日志文件 %s: %w (daemon 可能尚未启动)", path, err)
|
||||
}
|
||||
fmt.Print(string(data))
|
||||
|
||||
if !follow {
|
||||
return nil
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
offset := int64(len(data))
|
||||
for {
|
||||
select {
|
||||
case <-sig:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if fi.Size() > offset {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
f.Seek(offset, 0)
|
||||
buf := make([]byte, fi.Size()-offset)
|
||||
n, _ := f.Read(buf)
|
||||
if n > 0 {
|
||||
fmt.Print(string(buf[:n]))
|
||||
}
|
||||
offset = fi.Size()
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installSystemdUnit generates a systemd service unit file for a workflow daemon.
|
||||
func installSystemdUnit(ctx *common.RuntimeContext, wf *WorkflowDef, interval, aiMode string) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("resolve repo: %w", err)
|
||||
}
|
||||
|
||||
bin, _ := os.Executable()
|
||||
extraArgs := ""
|
||||
if aiMode == "ai" {
|
||||
extraArgs = " --ai"
|
||||
} else if aiMode == "no-ai" {
|
||||
extraArgs = " --no-ai"
|
||||
}
|
||||
|
||||
unit := fmt.Sprintf(`[Unit]
|
||||
Description=GitLink CLI Workflow: %s (%s/%s)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%s workflow +run --name %s --owner %s --repo %s --format json --daemon-loop --interval %s%s
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
StandardOutput=append:%s
|
||||
StandardError=append:%s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`,
|
||||
wf.Name, ctx.Owner, ctx.Repo,
|
||||
bin, wf.Name, ctx.Owner, ctx.Repo, interval, extraArgs,
|
||||
daemonLogPath(wf.Name), daemonLogPath(wf.Name),
|
||||
)
|
||||
|
||||
unitPath := filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.service", wf.Name))
|
||||
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
|
||||
return fmt.Errorf("写入 unit 文件: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Systemd unit 已写入: %s\n\n", unitPath)
|
||||
fmt.Println("安装步骤:")
|
||||
fmt.Printf(" sudo cp %s /etc/systemd/system/\n", unitPath)
|
||||
fmt.Println(" sudo systemctl daemon-reload")
|
||||
fmt.Printf(" sudo systemctl enable workflow-%s\n", wf.Name)
|
||||
fmt.Printf(" sudo systemctl start workflow-%s\n", wf.Name)
|
||||
fmt.Println()
|
||||
fmt.Printf("查看日志: journalctl -u workflow-%s -f\n", wf.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func savePID(name string, pid int) error {
|
||||
dir := config.ConfigDir()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(pidPath(name), []byte(strconv.Itoa(pid)), 0600)
|
||||
}
|
||||
|
||||
func readPID(name string) (int, error) {
|
||||
data, err := os.ReadFile(pidPath(name))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, fmt.Errorf("daemon %q is not running (no PID file)", name)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(string(data))
|
||||
}
|
||||
|
||||
func cleanPID(name string) {
|
||||
os.Remove(pidPath(name))
|
||||
}
|
||||
|
||||
func processRunning(pid int) bool {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return proc.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
func pidPath(name string) string {
|
||||
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.pid", name))
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// WorkflowResult holds the outcome of a full workflow run.
|
||||
type WorkflowResult struct {
|
||||
Workflow string `json:"workflow"`
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Steps []StepResult `json:"steps"`
|
||||
}
|
||||
|
||||
// Run executes every step in a workflow sequentially.
|
||||
// Steps later in the sequence receive data from their DependsOn predecessors
|
||||
// via ctx.Args (keyed by step name, stored as JSON).
|
||||
// Set dryRun to true to skip AI API calls for skill steps.
|
||||
func Run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowResult, error) {
|
||||
return RunWithMode(ctx, wf, dryRun, "")
|
||||
}
|
||||
|
||||
// RunWithMode executes a workflow with explicit AI mode control.
|
||||
// aiMode must be "auto", "ai", "no-ai", or "" (equivalent to "auto").
|
||||
func RunWithMode(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) (*WorkflowResult, error) {
|
||||
if aiMode != "" {
|
||||
ctx.AIMode = aiMode
|
||||
}
|
||||
return run(ctx, wf, dryRun)
|
||||
}
|
||||
|
||||
func run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowResult, error) {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return nil, fmt.Errorf("resolve repo: %w", err)
|
||||
}
|
||||
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = make(map[string]string)
|
||||
}
|
||||
if _, ok := ctx.Args["dry_run"]; !ok && dryRun {
|
||||
ctx.Args["dry_run"] = "true"
|
||||
}
|
||||
|
||||
results := make([]StepResult, 0, len(wf.Steps))
|
||||
for _, step := range wf.Steps {
|
||||
sr := ExecuteStep(ctx, step, dryRun)
|
||||
results = append(results, *sr)
|
||||
|
||||
// Feed output of this step as input to downstream steps via Args.
|
||||
if sr.OK && sr.Data != nil {
|
||||
raw, err := json.Marshal(sr.Data)
|
||||
if err == nil {
|
||||
ctx.Args[step.Name] = string(raw)
|
||||
} else {
|
||||
ctx.Args[step.Name] = fmt.Sprint(sr.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &WorkflowResult{
|
||||
Workflow: wf.Name,
|
||||
Owner: ctx.Owner,
|
||||
Repo: ctx.Repo,
|
||||
Steps: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolvePath replaces template placeholders in a path string.
|
||||
//
|
||||
// {base} → /owner/repo
|
||||
// {v1} → /v1/owner/repo
|
||||
func resolvePath(template, owner, repo string) string {
|
||||
base := fmt.Sprintf("/%s/%s", owner, repo)
|
||||
v1 := fmt.Sprintf("/v1/%s/%s", owner, repo)
|
||||
s := strings.Replace(template, "{v1}", v1, 1)
|
||||
s = strings.Replace(s, "{base}", base, 1)
|
||||
return s
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
package workflow
|
||||
|
||||
// manifest.go — registration center for all workflow definitions.
|
||||
// Each scenario file defines a register*() function; init() calls them all.
|
||||
// To add a new workflow:
|
||||
// 1. Create a new file in this package (e.g., my_scenario.go)
|
||||
// 2. Define func registerMyScenario() { register(&WorkflowDef{...}) }
|
||||
// 3. Add registerMyScenario() to the init() list below
|
||||
|
||||
func init() {
|
||||
registerCommunityOps()
|
||||
registerCodeQuality()
|
||||
registerProjectInit()
|
||||
registerMultiRepo()
|
||||
registerContributorGrowth()
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package workflow
|
||||
|
||||
func registerMultiRepo() {
|
||||
register(&WorkflowDef{
|
||||
Name: "multi-repo",
|
||||
Category: "协同",
|
||||
Description: "多仓库协同:跨仓库 Issue/PR 状态看板、Release 协调发布",
|
||||
Trigger: TriggerDef{
|
||||
Type: "cron",
|
||||
On: "0 9 * * 1",
|
||||
Interval: "24h",
|
||||
},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取主仓库信息", Target: "repo +info"},
|
||||
{Type: StepTypeCommand, Name: "open-issues", Purpose: "获取开放 Issue 列表", Target: "issue +list --state open --limit 50"},
|
||||
{Type: StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
|
||||
{Type: StepTypeCommand, Name: "releases", Purpose: "获取版本信息协调跨仓库发布", Target: "release +list"},
|
||||
{Type: StepTypeCommand, Name: "milestones", Purpose: "获取里程碑跨仓库对齐", Target: "milestone +list"},
|
||||
{Type: StepTypeCommand, Name: "members", Purpose: "获取成员跨仓库协作", Target: "member +list"},
|
||||
{Type: StepTypeSkill, Name: "repo-health", Purpose: "AI 综合评估多仓库健康与活跃度", Target: "gitlink-health", DependsOn: []string{"repo-info", "open-issues", "open-prs"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package workflow
|
||||
|
||||
func registerProjectInit() {
|
||||
register(&WorkflowDef{
|
||||
Name: "project-init",
|
||||
Category: "初始化",
|
||||
Description: "项目一键初始化:仓库检查 → 文件/Issue/里程碑初始 → CI 配置",
|
||||
Trigger: TriggerDef{
|
||||
Type: "manual",
|
||||
On: "manual",
|
||||
},
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeCommand, Name: "repo-info", Purpose: "确认仓库存在并获取基础信息", Target: "repo +info"},
|
||||
{Type: StepTypeCommand, Name: "existing-files", Purpose: "检查 README/LICENSE 是否已存在", Target: "file +list"},
|
||||
{Type: StepTypeCommand, Name: "labels", Purpose: "检查标签库是否齐全", Target: "label +list"},
|
||||
{Type: StepTypeSkill, Name: "license-check", Purpose: "AI 检查许可证合规并扫描敏感信息泄露", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
|
||||
{Type: StepTypeCommand, Name: "milestones", Purpose: "检查里程碑是否已创建", Target: "milestone +list"},
|
||||
{Type: StepTypeCommand, Name: "existing-issues", Purpose: "检查是否已有初始 Issue", Target: "issue +list --state all --limit 10"},
|
||||
{Type: StepTypeCommand, Name: "branches", Purpose: "检查分支结构", Target: "branch +list"},
|
||||
{Type: StepTypeSkill, Name: "repo-audit", Purpose: "AI 综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "labels", "milestones", "branches"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// commitGroup holds category → commits mapping for changelog generation.
|
||||
type commitGroup struct {
|
||||
Category string
|
||||
Emoji string
|
||||
Commits []map[string]interface{}
|
||||
}
|
||||
|
||||
var changelogRules = []struct {
|
||||
emoji string
|
||||
name string
|
||||
patterns []*regexp.Regexp
|
||||
}{
|
||||
{"✨", "新功能", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(feat|feature|add|新增|支持)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"🐛", "Bug 修复", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(fix|bugfix|hotfix|修复|解决)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"🔧", "改进优化", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(refactor|perf|improve|enhance|style|fmt|optimize|优化|增强|完善|调整|格式化)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"📚", "文档", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(docs|doc|文档|README|注释)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"🧪", "测试", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(test|tests|测试)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"🏗️", "构建/CI", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(build|ci|chore|构建|部署|Docker)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
}
|
||||
|
||||
var breakingRe = regexp.MustCompile(`!:`)
|
||||
var breakingBodyRe = regexp.MustCompile(`BREAKING[ -]CHANGE`)
|
||||
|
||||
// ChangelogRule classifies commits and generates release notes.
|
||||
func ChangelogRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
commits := extractCommits(upstream)
|
||||
releases := extractList(upstream, "releases")
|
||||
mergedPRs := extractPRs(upstream, "merged-prs")
|
||||
|
||||
groups := []commitGroup{}
|
||||
breaking := []map[string]interface{}{}
|
||||
uncategorized := []map[string]interface{}{}
|
||||
|
||||
for _, c := range commits {
|
||||
msg := str(c, "title", "message", "commit", "subject")
|
||||
body := str(c, "body", "description")
|
||||
if msg == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check breaking change.
|
||||
if breakingRe.MatchString(msg) || breakingBodyRe.MatchString(body) {
|
||||
breaking = append(breaking, c)
|
||||
continue
|
||||
}
|
||||
|
||||
categorized := false
|
||||
for i, rule := range changelogRules {
|
||||
for _, re := range rule.patterns {
|
||||
if re.MatchString(msg) {
|
||||
// Extend existing group or create new.
|
||||
found := false
|
||||
for j, g := range groups {
|
||||
if g.Category == rule.name {
|
||||
groups[j].Commits = append(groups[j].Commits, c)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
groups = append(groups, commitGroup{
|
||||
Category: rule.name,
|
||||
Emoji: rule.emoji,
|
||||
Commits: []map[string]interface{}{c},
|
||||
})
|
||||
}
|
||||
_ = i // suppress unused
|
||||
categorized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if categorized {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !categorized {
|
||||
uncategorized = append(uncategorized, c)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort groups: features first, then bug fixes, then rest.
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
return orderOf(groups[i].Category) < orderOf(groups[j].Category)
|
||||
})
|
||||
|
||||
// Add breaking changes group at top if present.
|
||||
if len(breaking) > 0 {
|
||||
groups = append([]commitGroup{{
|
||||
Category: "破坏性变更",
|
||||
Emoji: "⚠️",
|
||||
Commits: breaking,
|
||||
}}, groups...)
|
||||
}
|
||||
|
||||
// Add uncategorized at end.
|
||||
if len(uncategorized) > 0 {
|
||||
groups = append(groups, commitGroup{
|
||||
Category: "其他",
|
||||
Emoji: "🔀",
|
||||
Commits: uncategorized,
|
||||
})
|
||||
}
|
||||
|
||||
// Build analysis.
|
||||
sections := []map[string]interface{}{}
|
||||
for _, g := range groups {
|
||||
items := []string{}
|
||||
for _, c := range g.Commits {
|
||||
msg := str(c, "title", "message", "commit", "subject")
|
||||
sha := str(c, "sha", "id", "commit_id")
|
||||
if sha != "" && len(sha) > 7 {
|
||||
sha = sha[:7]
|
||||
}
|
||||
items = append(items, fmt.Sprintf("%s %s", sha, msg))
|
||||
}
|
||||
sections = append(sections, map[string]interface{}{
|
||||
"category": g.Emoji + " " + g.Category,
|
||||
"count": len(g.Commits),
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"total_commits": len(commits),
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
// Build release creation action if there are categorized commits.
|
||||
var actions []workflow.AIAction
|
||||
if len(commits) > 0 {
|
||||
// Determine next version tag.
|
||||
latestTag := "v0.0.0"
|
||||
for _, rel := range releases {
|
||||
if t := str(rel, "tag_name", "tag", "name"); t != "" {
|
||||
if compareTags(t, latestTag) > 0 {
|
||||
latestTag = t
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check merged PRs for version hints.
|
||||
for _, pr := range mergedPRs {
|
||||
labels := str(pr, "labels")
|
||||
if strings.Contains(labels, "release") || strings.Contains(labels, "version") {
|
||||
// PR merged with release label — bump version.
|
||||
}
|
||||
}
|
||||
|
||||
nextTag := bumpTag(latestTag)
|
||||
if len(breaking) > 0 {
|
||||
nextTag = bumpMajor(latestTag)
|
||||
}
|
||||
|
||||
body := buildChangelogBody(sections, nextTag)
|
||||
|
||||
if nextTag != latestTag {
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "cli",
|
||||
Module: "release",
|
||||
Command: "+create",
|
||||
Args: map[string]string{
|
||||
"tag": nextTag,
|
||||
"name": nextTag,
|
||||
"body": body,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||
}
|
||||
|
||||
func orderOf(cat string) int {
|
||||
order := map[string]int{
|
||||
"破坏性变更": 0,
|
||||
"新功能": 1,
|
||||
"Bug 修复": 2,
|
||||
"改进优化": 3,
|
||||
"文档": 4,
|
||||
"测试": 5,
|
||||
"构建/CI": 6,
|
||||
"其他": 7,
|
||||
}
|
||||
if o, ok := order[cat]; ok {
|
||||
return o
|
||||
}
|
||||
return 99
|
||||
}
|
||||
|
||||
func compareTags(a, b string) int {
|
||||
an := normalizeTag(a)
|
||||
bn := normalizeTag(b)
|
||||
if an > bn {
|
||||
return 1
|
||||
} else if an < bn {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeTag(t string) string {
|
||||
t = strings.TrimPrefix(t, "v")
|
||||
parts := strings.Split(t, ".")
|
||||
for len(parts) < 3 {
|
||||
parts = append(parts, "0")
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
func bumpTag(tag string) string {
|
||||
parts := strings.Split(normalizeTag(tag), ".")
|
||||
if len(parts) < 3 {
|
||||
return "v0.1.0"
|
||||
}
|
||||
minor := atoi(parts[1])
|
||||
return fmt.Sprintf("v%s.%d.0", parts[0], minor+1)
|
||||
}
|
||||
|
||||
func bumpMajor(tag string) string {
|
||||
parts := strings.Split(normalizeTag(tag), ".")
|
||||
if len(parts) < 1 {
|
||||
return "v1.0.0"
|
||||
}
|
||||
major := atoi(parts[0])
|
||||
return fmt.Sprintf("v%d.0.0", major+1)
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
var n int
|
||||
fmt.Sscanf(s, "%d", &n)
|
||||
return n
|
||||
}
|
||||
|
||||
func buildChangelogBody(sections []map[string]interface{}, tag string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n\n", tag)
|
||||
for _, sec := range sections {
|
||||
fmt.Fprintf(&b, "## %s (%d)\n\n", sec["category"], sec["count"])
|
||||
if items, ok := sec["items"].([]string); ok {
|
||||
for _, item := range items {
|
||||
fmt.Fprintf(&b, "- %s\n", item)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestChangelogConventionalCommits(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"sha": "abc12345", "title": "feat: add user login"},
|
||||
map[string]interface{}{"sha": "def12345", "title": "fix: resolve null pointer"},
|
||||
map[string]interface{}{"sha": "ghi12345", "title": "docs: update README"},
|
||||
},
|
||||
},
|
||||
"releases": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := ChangelogRule(upstream, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
sections := analysis["sections"].([]map[string]interface{})
|
||||
if len(sections) < 3 {
|
||||
t.Fatalf("expected at least 3 sections (features, bugs, docs), got %d", len(sections))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogBreakingChange(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"sha": "abc12345", "title": "feat!: drop support for v1"},
|
||||
},
|
||||
},
|
||||
"releases": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := ChangelogRule(upstream, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
sections := analysis["sections"].([]map[string]interface{})
|
||||
if len(sections) == 0 {
|
||||
t.Fatal("expected breaking changes section")
|
||||
}
|
||||
first := sections[0]
|
||||
if cat := first["category"]; cat == nil {
|
||||
t.Fatal("first section missing category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogChineseKeywords(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"sha": "aaa11111", "title": "新增:用户管理模块"},
|
||||
map[string]interface{}{"sha": "bbb11111", "title": "修复:登录页面报错"},
|
||||
},
|
||||
},
|
||||
"releases": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := ChangelogRule(upstream, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
// Should have at least 2 sections.
|
||||
sections := analysis["sections"].([]map[string]interface{})
|
||||
if len(sections) < 2 {
|
||||
t.Fatalf("expected at least 2 sections, got %d", len(sections))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogNoCommits(t *testing.T) {
|
||||
resp, err := ChangelogRule(map[string]interface{}{}, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["total_commits"]; v.(int) != 0 {
|
||||
t.Fatalf("expected 0 total_commits, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogOutputFormat(t *testing.T) {
|
||||
resp, err := ChangelogRule(map[string]interface{}{}, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
var _ *workflow.AIResponse = resp
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// CIDiagnosisRule matches CI build error logs against known patterns.
|
||||
func CIDiagnosisRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
builds := extractList(upstream, "ci-builds")
|
||||
commits := extractCommits(upstream)
|
||||
|
||||
type diagnosis struct {
|
||||
BuildNumber interface{} `json:"build_number"`
|
||||
Status string `json:"status"`
|
||||
Pattern string `json:"matched_pattern"`
|
||||
Diagnosis string `json:"diagnosis"`
|
||||
Suggestion string `json:"suggestion"`
|
||||
RelatedSHA string `json:"related_commit"`
|
||||
}
|
||||
var diagnoses []diagnosis
|
||||
var actions []workflow.AIAction
|
||||
|
||||
for _, build := range builds {
|
||||
status := str(build, "status", "state", "result")
|
||||
if status != "failed" && status != "failure" && status != "error" && status != "3" {
|
||||
// Check nested: some APIs use "build" wrapper.
|
||||
if inner, ok := build["build"].(map[string]interface{}); ok {
|
||||
build = inner
|
||||
status = str(build, "status", "state", "result")
|
||||
if status != "failed" && status != "failure" && status != "error" && status != "3" {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
log := str(build, "log", "logs", "output", "build_log")
|
||||
if log == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
d := diagnoseLog(log)
|
||||
buildNum := build["build_number"]
|
||||
if buildNum == nil {
|
||||
buildNum = build["id"]
|
||||
}
|
||||
|
||||
// Find related commit.
|
||||
relatedSHA := ""
|
||||
for _, c := range commits {
|
||||
cSha := str(c, "sha", "id", "commit_id")
|
||||
if cSha != "" && containsAny(str(c, "title", "message", "commit"), d.Pattern) {
|
||||
relatedSHA = cSha
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
diagnoses = append(diagnoses, diagnosis{
|
||||
BuildNumber: buildNum,
|
||||
Status: status,
|
||||
Pattern: d.Pattern,
|
||||
Diagnosis: d.Diagnosis,
|
||||
Suggestion: d.Suggestion,
|
||||
RelatedSHA: relatedSHA,
|
||||
})
|
||||
|
||||
// Auto-retry for transient failures.
|
||||
if d.Transient {
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "api",
|
||||
Method: "POST",
|
||||
Path: fmt.Sprintf("{v1}/builds/%v/retry", buildNum),
|
||||
Body: map[string]interface{}{},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(diagnoses) == 0 {
|
||||
return &workflow.AIResponse{
|
||||
Analysis: map[string]interface{}{"diagnoses": nil, "message": "no failed builds found"},
|
||||
Actions: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"diagnoses": diagnoses,
|
||||
"total_failures": len(diagnoses),
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||
}
|
||||
|
||||
type logPattern struct {
|
||||
Re *regexp.Regexp
|
||||
Pattern string
|
||||
Diagnosis string
|
||||
Suggestion string
|
||||
Transient bool
|
||||
}
|
||||
|
||||
var ciPatterns = []logPattern{
|
||||
{regexp.MustCompile(`cannot find package|package .* is not in`), "cannot find package",
|
||||
"依赖缺失",
|
||||
"检查 go.mod/package.json 确认依赖已声明",
|
||||
false},
|
||||
{regexp.MustCompile(`syntax error|unexpected token|unexpected EOF`), "syntax error",
|
||||
"语法错误",
|
||||
"检查最近提交中的语法问题",
|
||||
false},
|
||||
{regexp.MustCompile(`permission denied|access denied|forbidden|401|403`), "permission denied",
|
||||
"权限不足",
|
||||
"检查密钥配置和访问权限",
|
||||
false},
|
||||
{regexp.MustCompile(`connection refused|connection reset|no route to host|dial tcp`), "connection refused",
|
||||
"服务不可达",
|
||||
"检查外部服务状态和网络连接",
|
||||
true},
|
||||
{regexp.MustCompile(`out of memory|OOM|killed|signal: killed`), "out of memory",
|
||||
"资源不足(内存溢出)",
|
||||
"优化内存使用或增加构建资源",
|
||||
false},
|
||||
{regexp.MustCompile(`No such file|file not found|not found`), "No such file",
|
||||
"文件缺失",
|
||||
"检查 .devops/ 路径和依赖文件配置",
|
||||
false},
|
||||
{regexp.MustCompile(`docker:.*not found|docker.*command not found`), "docker not found",
|
||||
"Docker 环境缺失",
|
||||
"构建环境未配置 Docker,检查 CI 配置",
|
||||
false},
|
||||
{regexp.MustCompile(`FAIL|exit status [1-9]|Test.*failed`), "exit status 1",
|
||||
"测试失败",
|
||||
"查看测试输出定位失败用例",
|
||||
false},
|
||||
{regexp.MustCompile(`timeout|timed out|deadline exceeded`), "timeout",
|
||||
"构建超时",
|
||||
"优化构建脚本或增加超时时间",
|
||||
true},
|
||||
{regexp.MustCompile(`undefined:|undefined symbol|cannot use|type mismatch`), "undefined:",
|
||||
"编译错误(未定义符号)",
|
||||
"检查导入和类型定义",
|
||||
false},
|
||||
}
|
||||
|
||||
func diagnoseLog(log string) logPattern {
|
||||
for _, p := range ciPatterns {
|
||||
if p.Re.MatchString(log) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return logPattern{
|
||||
Pattern: "unknown",
|
||||
Diagnosis: "未知错误",
|
||||
Suggestion: "请人工查看 CI 日志进行诊断",
|
||||
Transient: false,
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(s string, patterns ...string) bool {
|
||||
for _, p := range patterns {
|
||||
if p != "" && len(s) > 0 && len(p) > 0 {
|
||||
// Simple substring check.
|
||||
if len(s) >= len(p) {
|
||||
for i := 0; i <= len(s)-len(p); i++ {
|
||||
if s[i:i+len(p)] == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCIDiagnosisPatterns(t *testing.T) {
|
||||
tests := []struct {
|
||||
log string
|
||||
pattern string
|
||||
transient bool
|
||||
}{
|
||||
{"cannot find package github.com/foo/bar", "cannot find package", false},
|
||||
{"syntax error: unexpected token at line 42", "syntax error", false},
|
||||
{"permission denied: unable to access /tmp/build", "permission denied", false},
|
||||
{"connection refused: dial tcp 10.0.0.1:8080", "connection refused", true},
|
||||
{"out of memory: process killed", "out of memory", false},
|
||||
{"No such file or directory: .devops/build.yml", "No such file", false},
|
||||
{"FAIL: TestLogin (0.23s)", "exit status 1", false},
|
||||
{"timeout: deadline exceeded after 300s", "timeout", true},
|
||||
{"undefined: UserService in main.go:15", "undefined:", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
d := diagnoseLog(tc.log)
|
||||
if d.Pattern != tc.pattern {
|
||||
t.Errorf("log=%q: expected pattern %q, got %q", tc.log, tc.pattern, d.Pattern)
|
||||
}
|
||||
if d.Transient != tc.transient {
|
||||
t.Errorf("log=%q: expected transient=%v, got %v", tc.log, tc.transient, d.Transient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDiagnosisNoFailures(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"ci-builds": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"id": "1", "status": "success", "log": "build passed"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := CIDiagnosisRule(upstream, "ci-diagnosis")
|
||||
if err != nil {
|
||||
t.Fatalf("CIDiagnosisRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if msg := analysis["message"]; msg != "no failed builds found" {
|
||||
t.Fatalf("expected 'no failed builds found', got %v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDiagnosisWithFailures(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"ci-builds": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"id": "1", "status": "failed", "log": "connection refused"},
|
||||
},
|
||||
},
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := CIDiagnosisRule(upstream, "ci-diagnosis")
|
||||
if err != nil {
|
||||
t.Fatalf("CIDiagnosisRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["total_failures"]; v.(int) != 1 {
|
||||
t.Fatalf("expected 1 failure, got %v", v)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// contributorEntry holds per-contributor aggregate data.
|
||||
type contributorEntry struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Commits int `json:"commits"`
|
||||
Issues int `json:"issues"`
|
||||
PRs int `json:"prs"`
|
||||
Total int `json:"total"`
|
||||
Trend float64 `json:"trend"`
|
||||
LastActivity string `json:"last_activity"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// ContributorRankingRule produces a contributor ranking report.
|
||||
func ContributorRankingRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
commits := extractCommits(upstream)
|
||||
issues := append(extractIssues(upstream, "open-issues"), extractIssues(upstream, "closed-issues")...)
|
||||
prs := extractPRs(upstream, "merged-prs")
|
||||
members := extractMembers(upstream)
|
||||
|
||||
// Aggregate per login.
|
||||
stats := map[string]*contributorEntry{}
|
||||
for _, c := range commits {
|
||||
login := authorLogin(c)
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
e := ensureEntry(stats, login, members)
|
||||
e.Commits++
|
||||
e.Total++
|
||||
if ts := commitTimestamp(c); ts != "" && ts > e.LastActivity {
|
||||
e.LastActivity = ts
|
||||
}
|
||||
}
|
||||
|
||||
for _, i := range issues {
|
||||
login := authorLogin(i)
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
e := ensureEntry(stats, login, members)
|
||||
e.Issues++
|
||||
e.Total++
|
||||
if ts := issueTimestamp(i); ts != "" && ts > e.LastActivity {
|
||||
e.LastActivity = ts
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range prs {
|
||||
login := authorLogin(p)
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
e := ensureEntry(stats, login, members)
|
||||
e.PRs++
|
||||
e.Total++
|
||||
if ts := prTimestamp(p); ts != "" && ts > e.LastActivity {
|
||||
e.LastActivity = ts
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate trends using 30-day windows.
|
||||
now := time.Now()
|
||||
cutoff30 := now.Add(-30 * 24 * time.Hour)
|
||||
cutoff60 := now.Add(-60 * 24 * time.Hour)
|
||||
|
||||
recent := countInWindow(commits, cutoff30, now)
|
||||
prev := countInWindow(commits, cutoff60, cutoff30)
|
||||
for login := range stats {
|
||||
rc := recent[login]
|
||||
pc := prev[login]
|
||||
if pc > 0 {
|
||||
stats[login].Trend = float64(rc-pc) / float64(pc) * 100
|
||||
} else if rc > 0 {
|
||||
stats[login].Trend = 100
|
||||
}
|
||||
// Tagging.
|
||||
if stats[login].Trend > 50 {
|
||||
stats[login].Tags = append(stats[login].Tags, "new-star")
|
||||
}
|
||||
if stats[login].LastActivity != "" {
|
||||
t, err := time.Parse(time.RFC3339, stats[login].LastActivity)
|
||||
if err == nil && now.Sub(t) > 30*24*time.Hour {
|
||||
stats[login].Tags = append(stats[login].Tags, "churn-risk")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by total desc.
|
||||
entries := make([]contributorEntry, 0, len(stats))
|
||||
for _, e := range stats {
|
||||
entries = append(entries, *e)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Total > entries[j].Total })
|
||||
|
||||
// Build analysis.
|
||||
rankings := make([]map[string]interface{}, len(entries))
|
||||
for i, e := range entries {
|
||||
rankings[i] = map[string]interface{}{
|
||||
"rank": i + 1,
|
||||
"login": e.Login,
|
||||
"name": e.Name,
|
||||
"commits": e.Commits,
|
||||
"issues": e.Issues,
|
||||
"prs": e.PRs,
|
||||
"total": e.Total,
|
||||
"trend": e.Trend,
|
||||
"last_activity": e.LastActivity,
|
||||
"tags": e.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"title": "贡献者排行榜",
|
||||
"rankings": rankings,
|
||||
"churn_risk": filterByTag(rankings, "churn-risk"),
|
||||
"new_stars": filterByTag(rankings, "new-star"),
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||||
}
|
||||
|
||||
func ensureEntry(stats map[string]*contributorEntry, login string, members map[string]string) *contributorEntry {
|
||||
if e, ok := stats[login]; ok {
|
||||
return e
|
||||
}
|
||||
e := &contributorEntry{Login: login, Name: members[login]}
|
||||
stats[login] = e
|
||||
return e
|
||||
}
|
||||
|
||||
func filterByTag(rankings []map[string]interface{}, tag string) []map[string]interface{} {
|
||||
var out []map[string]interface{}
|
||||
for _, r := range rankings {
|
||||
if tags, ok := r["tags"].([]string); ok {
|
||||
for _, t := range tags {
|
||||
if t == tag {
|
||||
out = append(out, r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countInWindow(commits []map[string]interface{}, start, end time.Time) map[string]int {
|
||||
m := map[string]int{}
|
||||
for _, c := range commits {
|
||||
ts := commitTimestamp(c)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if t.After(start) && t.Before(end) {
|
||||
m[authorLogin(c)]++
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func extractCommits(upstream map[string]interface{}) []map[string]interface{} {
|
||||
return extractList(upstream, "commits")
|
||||
}
|
||||
|
||||
func extractIssues(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||||
return extractList(upstream, key)
|
||||
}
|
||||
|
||||
func extractPRs(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||||
return extractList(upstream, key)
|
||||
}
|
||||
|
||||
func extractMembers(upstream map[string]interface{}) map[string]string {
|
||||
raw, ok := upstream["members"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
members := map[string]string{}
|
||||
list, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return members
|
||||
}
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
login := str(m, "login", "username", "name")
|
||||
name := str(m, "name", "full_name", "display_name")
|
||||
if login != "" {
|
||||
members[login] = name
|
||||
}
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
func extractList(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||||
raw, ok := upstream[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// upstream values may be stored as an envelope: {"ok": true, "data": [...]}
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if data, ok := m["data"]; ok {
|
||||
raw = data
|
||||
}
|
||||
}
|
||||
list, _ := raw.([]interface{})
|
||||
var out []map[string]interface{}
|
||||
for _, item := range list {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func authorLogin(m map[string]interface{}) string {
|
||||
return str(m, "author", "login", "username", "committer", "user")
|
||||
}
|
||||
|
||||
func commitTimestamp(m map[string]interface{}) string {
|
||||
// Commits and issues may be nested under author/committer.
|
||||
for _, key := range []string{"created_at", "committed_date", "updated_at", "authored_date"} {
|
||||
if s := str(m, key); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
// Try nested author.
|
||||
if a, ok := m["author"].(map[string]interface{}); ok {
|
||||
return str(a, "date", "created_at")
|
||||
}
|
||||
if a, ok := m["committer"].(map[string]interface{}); ok {
|
||||
return str(a, "date", "created_at")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func issueTimestamp(m map[string]interface{}) string {
|
||||
return str(m, "created_at", "updated_at", "closed_at")
|
||||
}
|
||||
|
||||
func prTimestamp(m map[string]interface{}) string {
|
||||
return str(m, "created_at", "merged_at", "updated_at")
|
||||
}
|
||||
|
||||
// str returns the first non-empty string value for the given keys.
|
||||
func str(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
v, _ := m[k].(string)
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContributorRanking(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"created_at": "2025-06-01T00:00:00Z", "author": "dev1"},
|
||||
map[string]interface{}{"created_at": "2025-06-05T00:00:00Z", "author": "dev1"},
|
||||
map[string]interface{}{"created_at": "2025-06-10T00:00:00Z", "author": "dev2"},
|
||||
},
|
||||
},
|
||||
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"closed-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||
map[string]interface{}{"login": "dev2", "name": "Dev Two"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
rankings := analysis["rankings"].([]map[string]interface{})
|
||||
if len(rankings) != 2 {
|
||||
t.Fatalf("expected 2 rankings, got %d", len(rankings))
|
||||
}
|
||||
|
||||
// dev1 should be ranked #1 (2 commits vs 1).
|
||||
first := rankings[0]
|
||||
if first["login"] != "dev1" {
|
||||
t.Errorf("expected dev1 as #1, got %v", first["login"])
|
||||
}
|
||||
if first["rank"] != 1 {
|
||||
t.Errorf("expected rank 1, got %v", first["rank"])
|
||||
}
|
||||
if first["commits"] != 2 {
|
||||
t.Errorf("expected 2 commits, got %v", first["commits"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChurnRiskDetection(t *testing.T) {
|
||||
// 35 days ago — should trigger churn risk.
|
||||
oldDate := "2025-01-01T00:00:00Z"
|
||||
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"created_at": oldDate, "author": "dev1"},
|
||||
},
|
||||
},
|
||||
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"closed-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
rankings := analysis["rankings"].([]map[string]interface{})
|
||||
if len(rankings) > 0 {
|
||||
tags := rankings[0]["tags"].([]string)
|
||||
for _, tag := range tags {
|
||||
if tag == "churn-risk" {
|
||||
return // success
|
||||
}
|
||||
}
|
||||
t.Errorf("expected churn-risk tag for old activity, got tags: %v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContributorOutputFormat(t *testing.T) {
|
||||
resp, err := ContributorRankingRule(map[string]interface{}{}, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||
}
|
||||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
if resp.Actions != nil {
|
||||
t.Fatal("expected nil Actions (read-only report)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStarDetection(t *testing.T) {
|
||||
// Use very recent dates so the commits appear in the last 30 days.
|
||||
now := time.Now()
|
||||
d1 := now.Add(-2 * 24 * time.Hour).Format(time.RFC3339)
|
||||
d2 := now.Add(-3 * 24 * time.Hour).Format(time.RFC3339)
|
||||
d3 := now.Add(-4 * 24 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"created_at": d1, "author": "dev1"},
|
||||
map[string]interface{}{"created_at": d2, "author": "dev1"},
|
||||
map[string]interface{}{"created_at": d3, "author": "dev1"},
|
||||
},
|
||||
},
|
||||
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"closed-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
// Should have new-star or at least ranking.
|
||||
rankings := analysis["rankings"].([]map[string]interface{})
|
||||
if len(rankings) == 0 {
|
||||
t.Fatal("expected at least 1 ranking entry")
|
||||
}
|
||||
tags, _ := rankings[0]["tags"].([]string)
|
||||
t.Logf("tags for dev1: %v", tags)
|
||||
// With only recent commits (no previous period), trend should be 100%, triggering new-star.
|
||||
found := false
|
||||
for _, tag := range tags {
|
||||
if tag == "new-star" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected new-star tag, got: %v", tags)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package rules
|
||||
|
||||
import "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
|
||||
// HealthDispatchRule routes "gitlink-health" skill calls to the correct engine
|
||||
// based on step name and upstream data shape.
|
||||
func HealthDispatchRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
// contributor-ranking: from contributor_growth workflow.
|
||||
if stepName == "contributor-ranking" {
|
||||
return ContributorRankingRule(upstream, stepName)
|
||||
}
|
||||
|
||||
// repo-health: from multi_repo workflow.
|
||||
if stepName == "repo-health" {
|
||||
return HealthReportRule(upstream, stepName)
|
||||
}
|
||||
|
||||
// health-report: from community_ops workflow.
|
||||
if stepName == "health-report" {
|
||||
return HealthReportRule(upstream, stepName)
|
||||
}
|
||||
|
||||
// Default: inspect upstream shape to decide.
|
||||
// If upstream has "members" and "merged-prs" but no "repo-info", it's contributor ranking.
|
||||
_, hasRepoInfo := upstream["repo-info"]
|
||||
_, hasMembers := upstream["members"]
|
||||
if hasMembers && !hasRepoInfo {
|
||||
return ContributorRankingRule(upstream, stepName)
|
||||
}
|
||||
return HealthReportRule(upstream, stepName)
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// HealthReportRule computes a 4-dimension weighted health score.
|
||||
func HealthReportRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
repoInfo := extractFirst(upstream, "repo-info")
|
||||
mergedPRs := extractPRs(upstream, "merged-prs")
|
||||
commits := extractCommits(upstream)
|
||||
openIssues := extractIssues(upstream, "open-issues")
|
||||
|
||||
// Compute raw metrics.
|
||||
totalIssues := len(openIssues) // approximation
|
||||
totalPRs := len(mergedPRs)
|
||||
now := time.Now()
|
||||
recentCommits := countRecent(commits, now, 30)
|
||||
releaseCount := 0
|
||||
if repoInfo != nil {
|
||||
if v, ok := repoInfo["release_count"].(float64); ok {
|
||||
releaseCount = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Dimension 1: Issue Health (30%)
|
||||
issueScore := scoreIssueHealth(totalIssues, openIssues, now)
|
||||
|
||||
// Dimension 2: PR Health (30%)
|
||||
prScore := scorePRHealth(totalPRs, mergedPRs, now)
|
||||
|
||||
// Dimension 3: Contributor Health (20%)
|
||||
contributorScore := scoreContributorHealth(commits, now)
|
||||
|
||||
// Dimension 4: Activity (20%)
|
||||
activityScore := scoreActivity(recentCommits, releaseCount, repoInfo)
|
||||
|
||||
// Composite score.
|
||||
composite := issueScore*0.30 + prScore*0.30 + contributorScore*0.20 + activityScore*0.20
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"title": "项目健康度报告",
|
||||
"composite": math.Round(composite*10) / 10,
|
||||
"grade": grade(composite),
|
||||
"dimensions": map[string]interface{}{
|
||||
"issue_health": map[string]interface{}{
|
||||
"score": math.Round(issueScore*10) / 10,
|
||||
"weight": 0.30,
|
||||
"grade": grade(issueScore),
|
||||
},
|
||||
"pr_health": map[string]interface{}{
|
||||
"score": math.Round(prScore*10) / 10,
|
||||
"weight": 0.30,
|
||||
"grade": grade(prScore),
|
||||
},
|
||||
"contributor_health": map[string]interface{}{
|
||||
"score": math.Round(contributorScore*10) / 10,
|
||||
"weight": 0.20,
|
||||
"grade": grade(contributorScore),
|
||||
},
|
||||
"activity": map[string]interface{}{
|
||||
"score": math.Round(activityScore*10) / 10,
|
||||
"weight": 0.20,
|
||||
"grade": grade(activityScore),
|
||||
"recent_commits": recentCommits,
|
||||
"releases": releaseCount,
|
||||
},
|
||||
},
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||||
}
|
||||
|
||||
func scoreIssueHealth(total int, openIssues []map[string]interface{}, now time.Time) float64 {
|
||||
if total == 0 {
|
||||
return 80 // neutral
|
||||
}
|
||||
// Stale issues: open for >30 days.
|
||||
stale := 0
|
||||
for _, iss := range openIssues {
|
||||
ts := issueTimestamp(iss)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(t) > 30*24*time.Hour {
|
||||
stale++
|
||||
}
|
||||
}
|
||||
ratio := float64(stale) / float64(max(total, 1))
|
||||
score := (1 - ratio) * 100
|
||||
return clamp(score)
|
||||
}
|
||||
|
||||
func scorePRHealth(total int, mergedPRs []map[string]interface{}, now time.Time) float64 {
|
||||
if total == 0 {
|
||||
return 80 // neutral
|
||||
}
|
||||
// Avg merge time from creation.
|
||||
var totalHours float64
|
||||
count := 0
|
||||
for _, pr := range mergedPRs {
|
||||
created := prTimestamp(pr)
|
||||
merged := str(pr, "merged_at")
|
||||
if created == "" || merged == "" {
|
||||
continue
|
||||
}
|
||||
ct, err1 := time.Parse(time.RFC3339, created)
|
||||
mt, err2 := time.Parse(time.RFC3339, merged)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
totalHours += mt.Sub(ct).Hours()
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return 80
|
||||
}
|
||||
avgDays := totalHours / float64(count) / 24
|
||||
// <3 days = excellent (100), 3-7 = good (80), >7 = needs improvement (50).
|
||||
if avgDays < 3 {
|
||||
return 100
|
||||
} else if avgDays < 7 {
|
||||
return 80
|
||||
}
|
||||
return 50
|
||||
}
|
||||
|
||||
func scoreContributorHealth(commits []map[string]interface{}, now time.Time) float64 {
|
||||
// Unique authors in last 30 days.
|
||||
authors := map[string]bool{}
|
||||
for _, c := range commits {
|
||||
ts := commitTimestamp(c)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(t) <= 30*24*time.Hour {
|
||||
authors[authorLogin(c)] = true
|
||||
}
|
||||
}
|
||||
n := len(authors)
|
||||
// >3 active = 100, 1-3 = 60, 0 = 30.
|
||||
if n > 3 {
|
||||
return 100
|
||||
} else if n >= 1 {
|
||||
return 60
|
||||
}
|
||||
return 30
|
||||
}
|
||||
|
||||
func scoreActivity(recentCommits int, releaseCount int, repoInfo map[string]interface{}) float64 {
|
||||
score := 0.0
|
||||
if recentCommits >= 10 {
|
||||
score += 50
|
||||
} else if recentCommits > 0 {
|
||||
score += float64(recentCommits) / 10 * 50
|
||||
}
|
||||
if releaseCount >= 3 {
|
||||
score += 50
|
||||
} else if releaseCount > 0 {
|
||||
score += float64(releaseCount) / 3 * 50
|
||||
}
|
||||
if score == 0 {
|
||||
score = 30 // bare minimum if repo exists
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func countRecent(commits []map[string]interface{}, now time.Time, days int) int {
|
||||
n := 0
|
||||
for _, c := range commits {
|
||||
ts := commitTimestamp(c)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(t) <= time.Duration(days)*24*time.Hour {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// extractFirst returns the first map from upstream by key (some upstream data is wrapped).
|
||||
func extractFirst(upstream map[string]interface{}, key string) map[string]interface{} {
|
||||
list := extractList(upstream, key)
|
||||
if len(list) > 0 {
|
||||
return list[0]
|
||||
}
|
||||
// Try direct map.
|
||||
if m, ok := upstream[key].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func grade(score float64) string {
|
||||
if score >= 80 {
|
||||
return "优秀"
|
||||
} else if score >= 60 {
|
||||
return "良好"
|
||||
}
|
||||
return "需改进"
|
||||
}
|
||||
|
||||
func clamp(v float64) float64 {
|
||||
return min(max(v, 0), 100)
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestHealthReportScoring(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"repo-info": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"description": "test repo", "open_issues_count": 5, "release_count": 2},
|
||||
},
|
||||
},
|
||||
"merged-prs": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"merged_at": "2025-01-03T00:00:00Z",
|
||||
"author": "dev1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"created_at": "2025-06-01T00:00:00Z", "author": "dev1"},
|
||||
map[string]interface{}{"created_at": "2025-06-05T00:00:00Z", "author": "dev1"},
|
||||
map[string]interface{}{"created_at": "2025-06-10T00:00:00Z", "author": "dev2"},
|
||||
map[string]interface{}{"created_at": "2025-06-15T00:00:00Z", "author": "dev3"},
|
||||
},
|
||||
},
|
||||
"open-issues": map[string]interface{}{
|
||||
"data": []interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := HealthReportRule(upstream, "health-report")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthReportRule failed: %v", err)
|
||||
}
|
||||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if _, ok := analysis["composite"]; !ok {
|
||||
t.Fatal("missing composite score")
|
||||
}
|
||||
if _, ok := analysis["grade"]; !ok {
|
||||
t.Fatal("missing grade")
|
||||
}
|
||||
if dims, ok := analysis["dimensions"].(map[string]interface{}); !ok {
|
||||
t.Fatal("missing dimensions")
|
||||
} else {
|
||||
for _, dim := range []string{"issue_health", "pr_health", "contributor_health", "activity"} {
|
||||
if _, ok := dims[dim]; !ok {
|
||||
t.Errorf("missing dimension: %s", dim)
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp.Actions != nil {
|
||||
t.Fatal("expected nil Actions (read-only report)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthReportNoData(t *testing.T) {
|
||||
resp, err := HealthReportRule(map[string]interface{}{}, "health-report")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthReportRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
// Should still produce a score (neutral defaults).
|
||||
if v := analysis["composite"]; v == nil {
|
||||
t.Fatal("expected composite score even with no data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthReportOutputFormat(t *testing.T) {
|
||||
resp, err := HealthReportRule(map[string]interface{}{}, "health-report")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthReportRule failed: %v", err)
|
||||
}
|
||||
var _ *workflow.AIResponse = resp
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// riskEntry describes a single license/security risk finding.
|
||||
type riskEntry struct {
|
||||
File string `json:"file"`
|
||||
Risk string `json:"risk"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// LicenseCheckRule scans file lists and content for license compliance and sensitive data.
|
||||
func LicenseCheckRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
files := extractList(upstream, "existing-files")
|
||||
if len(files) == 0 {
|
||||
files = extractList(upstream, "files")
|
||||
}
|
||||
|
||||
var findings []riskEntry
|
||||
hasLicense := false
|
||||
licenseType := ""
|
||||
|
||||
for _, f := range files {
|
||||
name := str(f, "name", "filename", "path", "file_name")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// License file detection.
|
||||
if isLicenseFile(name) {
|
||||
hasLicense = true
|
||||
content := str(f, "content", "body", "text")
|
||||
if content != "" {
|
||||
licenseType = detectLicenseType(content)
|
||||
}
|
||||
}
|
||||
|
||||
// Sensitive file name detection.
|
||||
for _, fp := range filePatterns {
|
||||
if fp.re.MatchString(strings.ToLower(name)) {
|
||||
findings = append(findings, riskEntry{
|
||||
File: name,
|
||||
Risk: fp.risk,
|
||||
Message: fp.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sensitive content detection.
|
||||
content := str(f, "content", "body", "text")
|
||||
if content != "" {
|
||||
for _, cp := range contentPatterns {
|
||||
if cp.re.MatchString(content) {
|
||||
// Apply exclusion rules.
|
||||
matches := cp.re.FindAllString(content, -1)
|
||||
for _, match := range matches {
|
||||
if isPlaceholder(match) {
|
||||
continue
|
||||
}
|
||||
findings = append(findings, riskEntry{
|
||||
File: name,
|
||||
Risk: "high",
|
||||
Message: cp.message + " → `" + truncate(match, 40) + "`",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute scores.
|
||||
licenseScore := 0.0
|
||||
if hasLicense {
|
||||
licenseScore = 100
|
||||
if licenseType != "" {
|
||||
licenseScore = 100
|
||||
} else {
|
||||
licenseScore = 70
|
||||
}
|
||||
}
|
||||
|
||||
sensitiveScore := 100.0
|
||||
highCount := 0
|
||||
for _, f := range findings {
|
||||
if f.Risk == "high" {
|
||||
highCount++
|
||||
}
|
||||
}
|
||||
if highCount > 0 {
|
||||
sensitiveScore = max(0, 100-float64(highCount)*20)
|
||||
}
|
||||
|
||||
composite := licenseScore*0.35 + sensitiveScore*0.40 + 50*0.15 + 50*0.10
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"has_license": hasLicense,
|
||||
"license_type": licenseType,
|
||||
"license_score": licenseScore,
|
||||
"sensitive_score": sensitiveScore,
|
||||
"composite_score": composite,
|
||||
"grade": grade(composite),
|
||||
"findings": findings,
|
||||
"total_findings": len(findings),
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||||
}
|
||||
|
||||
// --- license file detection ---
|
||||
|
||||
func isLicenseFile(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
for _, pattern := range []string{"license", "copying", "notice", "licence"} {
|
||||
if strings.Contains(lower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func detectLicenseType(content string) string {
|
||||
for _, lp := range licensePatterns {
|
||||
if lp.re.MatchString(content) {
|
||||
return lp.name
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
var licensePatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
name string
|
||||
}{
|
||||
{regexp.MustCompile(`(?i)MIT\s+License|Permission is hereby granted`), "MIT"},
|
||||
{regexp.MustCompile(`(?i)Apache\s+License.*Version\s+2\.0|http://www\.apache\.org/licenses`), "Apache 2.0"},
|
||||
{regexp.MustCompile(`(?i)GNU GENERAL PUBLIC LICENSE.*Version 3|GPL\s*v3`), "GPL v3"},
|
||||
{regexp.MustCompile(`(?i)GNU GENERAL PUBLIC LICENSE.*Version 2|GPL\s*v2`), "GPL v2"},
|
||||
{regexp.MustCompile(`(?i)BSD\s+(3-Clause|2-Clause|License)`), "BSD"},
|
||||
{regexp.MustCompile(`(?i)Mulan\s+Permissive|木兰宽松许可证`), "Mulan PSL v2"},
|
||||
{regexp.MustCompile(`(?i)Mozilla Public License|MPL`), "MPL"},
|
||||
{regexp.MustCompile(`(?i)ISC\s+License`), "ISC"},
|
||||
{regexp.MustCompile(`(?i)Creative Commons|CC-BY`), "Creative Commons"},
|
||||
{regexp.MustCompile(`(?i)Unlicense|public\s+domain`), "Unlicense"},
|
||||
}
|
||||
|
||||
// --- file pattern scanning ---
|
||||
|
||||
type fileRiskPattern struct {
|
||||
re *regexp.Regexp
|
||||
risk string
|
||||
message string
|
||||
}
|
||||
|
||||
var filePatterns = []fileRiskPattern{
|
||||
{regexp.MustCompile(`\.pem$|\.key$|\.p12$|\.pfx$`), "high", "私钥/证书文件,确认是否应纳入版本控制"},
|
||||
{regexp.MustCompile(`id_rsa|id_dsa|id_ecdsa|id_ed25519`), "high", "SSH 私钥文件,不应提交到仓库"},
|
||||
{regexp.MustCompile(`^\.env$|\.env\.`), "high", "环境变量文件,可能包含敏感凭据"},
|
||||
{regexp.MustCompile(`credentials\.|\.secret$|secret\.yml`), "high", "凭据文件,可能包含敏感信息"},
|
||||
{regexp.MustCompile(`serviceAccount\.json|\.service-account\.json`), "high", "服务账号密钥文件"},
|
||||
{regexp.MustCompile(`.*token.*|.*secret.*`), "medium", "文件名包含 token/secret,检查内容"},
|
||||
{regexp.MustCompile(`coverage\.out$`), "low", "覆盖率输出文件,建议添加到 .gitignore"},
|
||||
{regexp.MustCompile(`\.exe$|\.bin$|\.dll$|\.so$`), "low", "二进制文件,检查是否应纳入版本控制"},
|
||||
{regexp.MustCompile(`\.log$|\.tmp$`), "low", "日志/临时文件,建议添加到 .gitignore"},
|
||||
}
|
||||
|
||||
// --- content pattern scanning ---
|
||||
|
||||
type contentRiskPattern struct {
|
||||
re *regexp.Regexp
|
||||
message string
|
||||
}
|
||||
|
||||
var contentPatterns = []contentRiskPattern{
|
||||
{regexp.MustCompile(`(?i)(token|api[_-]?key|apikey|secret|password|passwd|authorization)\s*[:=]\s*['"][^\s'"]{8,}['"]`),
|
||||
"检测到硬编码凭据赋值"},
|
||||
{regexp.MustCompile(`-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----`),
|
||||
"检测到私钥头部"},
|
||||
{regexp.MustCompile(`(?i)GITLINK_TOKEN\s*[:=]\s*['"][^\s'"]+['"]`),
|
||||
"检测到 GitLink Token"},
|
||||
{regexp.MustCompile(`(?i)(mongodb|mysql|postgres|redis|jdbc)://[^\s'"]+@`),
|
||||
"检测到数据库连接字符串"},
|
||||
{regexp.MustCompile(`AKIA[0-9A-Z]{16}`),
|
||||
"检测到 AWS Access Key"},
|
||||
{regexp.MustCompile(`ghp_[a-zA-Z0-9]{36}`),
|
||||
"检测到 GitHub 个人访问令牌"},
|
||||
{regexp.MustCompile(`(?i)eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}`),
|
||||
"检测到 JWT 令牌格式"},
|
||||
{regexp.MustCompile(`(?i)(\d{1,3}\.){3}\d{1,3}`),
|
||||
"检测到硬编码 IP 地址"},
|
||||
{regexp.MustCompile(`(?i)password\s*[:=]\s*['"]['"]`),
|
||||
"检测到空密码"},
|
||||
}
|
||||
|
||||
func isPlaceholder(s string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
for _, p := range []string{"((variable))", "<token>", "your_token_here", "xxx", "replace_me", "<your", "placeholder"} {
|
||||
if strings.Contains(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Skip if it looks like a format variable: {{.Var}} or ${VAR}.
|
||||
if matched, _ := regexp.MatchString(`\{\{\.?\w+\}\}|\$\{\w+\}`, s); matched {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLicenseFileDetection(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "src/main.go", "content": "package main"},
|
||||
map[string]interface{}{"name": "LICENSE", "content": "MIT License\n\nPermission is hereby granted..."},
|
||||
map[string]interface{}{"name": "README.md", "content": "# Project"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := LicenseCheckRule(upstream, "license-check")
|
||||
if err != nil {
|
||||
t.Fatalf("LicenseCheckRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["has_license"]; v != true {
|
||||
t.Fatal("expected has_license=true")
|
||||
}
|
||||
if v := analysis["license_type"]; v != "MIT" {
|
||||
t.Fatalf("expected license_type=MIT, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensitiveContentDetection(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "config.go", "content": `api_key = "sk-1234567890abcdef"`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := LicenseCheckRule(upstream, "license-check")
|
||||
if err != nil {
|
||||
t.Fatalf("LicenseCheckRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
findings := analysis["findings"].([]riskEntry)
|
||||
if len(findings) == 0 {
|
||||
t.Fatal("expected findings for hardcoded key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceholderExclusion(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "config.go", "content": `api_key = "your_token_here"`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := LicenseCheckRule(upstream, "license-check")
|
||||
if err != nil {
|
||||
t.Fatalf("LicenseCheckRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
findings := analysis["findings"].([]riskEntry)
|
||||
// Placeholder should not generate riskEntry findings.
|
||||
// But the api_key pattern may still match — check that it's excluded.
|
||||
for _, f := range findings {
|
||||
if f.File == "config.go" {
|
||||
t.Logf("finding: %+v", f)
|
||||
// Should NOT be a high risk for the api_key pattern.
|
||||
}
|
||||
}
|
||||
// The placeholder exclusion should filter out the match from content patterns.
|
||||
// But filename-based detections may still fire. Let's just check there are no
|
||||
// high risk findings for the placeholder content.
|
||||
for _, f := range findings {
|
||||
if f.Risk == "high" && f.File == "config.go" {
|
||||
t.Errorf("placeholder should be excluded, but got high risk finding: %s", f.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseFileMissing(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "README.md"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := LicenseCheckRule(upstream, "license-check")
|
||||
if err != nil {
|
||||
t.Fatalf("LicenseCheckRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["has_license"]; v != false {
|
||||
t.Fatal("expected has_license=false")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
package rules
|
||||
|
||||
import "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
|
||||
func init() {
|
||||
workflow.RegisterRuleEngine("gitlink-triage", TriageRule)
|
||||
workflow.RegisterRuleEngine("gitlink-health", HealthDispatchRule)
|
||||
workflow.RegisterRuleEngine("gitlink-changelog", ChangelogRule)
|
||||
workflow.RegisterRuleEngine("gitlink-review", CodeReviewRule)
|
||||
workflow.RegisterRuleEngine("gitlink-ci", CIDiagnosisRule)
|
||||
workflow.RegisterRuleEngine("gitlink-license", LicenseCheckRule)
|
||||
workflow.RegisterRuleEngine("gitlink-repo", RepoAuditRule)
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestRegistryAllRegistered(t *testing.T) {
|
||||
expected := []string{
|
||||
"gitlink-triage",
|
||||
"gitlink-health",
|
||||
"gitlink-changelog",
|
||||
"gitlink-review",
|
||||
"gitlink-ci",
|
||||
"gitlink-license",
|
||||
"gitlink-repo",
|
||||
}
|
||||
for _, target := range expected {
|
||||
if _, ok := workflow.RuleEngines[target]; !ok {
|
||||
t.Errorf("rule engine not registered for target %q", target)
|
||||
}
|
||||
}
|
||||
if len(workflow.RuleEngines) != len(expected) {
|
||||
t.Fatalf("expected %d rule engines, got %d", len(expected), len(workflow.RuleEngines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthDispatchRule(t *testing.T) {
|
||||
// contributor-ranking step should delegate to ContributorRankingRule.
|
||||
resp, err := HealthDispatchRule(map[string]interface{}{}, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthDispatchRule failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response")
|
||||
}
|
||||
|
||||
// health-report step should delegate to HealthReportRule.
|
||||
resp, err = HealthDispatchRule(map[string]interface{}{}, "health-report")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthDispatchRule for health-report failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response for health-report")
|
||||
}
|
||||
|
||||
// repo-health step should delegate to HealthReportRule too.
|
||||
resp, err = HealthDispatchRule(map[string]interface{}{}, "repo-health")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthDispatchRule for repo-health failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response for repo-health")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// RepoAuditRule evaluates a repository against a 6-dimension checklist.
|
||||
func RepoAuditRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
repoInfo := extractFirst(upstream, "repo-info")
|
||||
labels := extractLabels(upstream)
|
||||
milestones := extractList(upstream, "milestones")
|
||||
branches := extractList(upstream, "branches")
|
||||
|
||||
type dimScore struct {
|
||||
Name string `json:"name"`
|
||||
Score float64 `json:"score"`
|
||||
Weight float64 `json:"weight"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
var dims []dimScore
|
||||
missing := []string{}
|
||||
|
||||
// Readme check.
|
||||
desc := ""
|
||||
hasReadme := false
|
||||
if repoInfo != nil {
|
||||
desc = str(repoInfo, "description")
|
||||
if v, ok := repoInfo["has_readme"].(bool); ok {
|
||||
hasReadme = v
|
||||
}
|
||||
}
|
||||
readmeScore := 0.0
|
||||
if hasReadme || desc != "" {
|
||||
readmeScore = 100
|
||||
dims = append(dims, dimScore{Name: "README", Score: readmeScore, Weight: 0.25, Status: "ok", Detail: "README 已存在"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "README", Score: 0, Weight: 0.25, Status: "missing", Detail: "缺少 README 文件"})
|
||||
missing = append(missing, "README")
|
||||
}
|
||||
|
||||
// License check.
|
||||
if detectLicenseInFiles(upstream) {
|
||||
dims = append(dims, dimScore{Name: "LICENSE", Score: 100, Weight: 0.20, Status: "ok", Detail: "LICENSE 文件已存在"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "LICENSE", Score: 0, Weight: 0.20, Status: "missing", Detail: "缺少 LICENSE 文件"})
|
||||
missing = append(missing, "LICENSE")
|
||||
}
|
||||
|
||||
// Labels check.
|
||||
if len(labels) > 5 {
|
||||
dims = append(dims, dimScore{Name: "标签库", Score: 100, Weight: 0.15, Status: "ok", Detail: "标签配置完善"})
|
||||
} else if len(labels) > 0 {
|
||||
dims = append(dims, dimScore{Name: "标签库", Score: 50, Weight: 0.15, Status: "partial", Detail: "标签较少,建议补充"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "标签库", Score: 0, Weight: 0.15, Status: "missing", Detail: "未配置标签"})
|
||||
missing = append(missing, "labels")
|
||||
}
|
||||
|
||||
// Milestones check.
|
||||
if len(milestones) > 0 {
|
||||
dims = append(dims, dimScore{Name: "里程碑", Score: 100, Weight: 0.15, Status: "ok", Detail: "已配置里程碑"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "里程碑", Score: 0, Weight: 0.15, Status: "missing", Detail: "未配置里程碑"})
|
||||
missing = append(missing, "milestones")
|
||||
}
|
||||
|
||||
// Branches check.
|
||||
if len(branches) > 2 {
|
||||
dims = append(dims, dimScore{Name: "分支结构", Score: 100, Weight: 0.10, Status: "ok", Detail: "分支结构完善"})
|
||||
} else if len(branches) > 1 {
|
||||
dims = append(dims, dimScore{Name: "分支结构", Score: 70, Weight: 0.10, Status: "ok", Detail: "至少有一个开发分支"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "分支结构", Score: 40, Weight: 0.10, Status: "partial", Detail: "仅主分支,建议创建 develop 分支"})
|
||||
}
|
||||
|
||||
// DevOps check.
|
||||
devops := false
|
||||
if repoInfo != nil {
|
||||
if v, ok := repoInfo["open_devops"].(bool); ok {
|
||||
devops = v
|
||||
}
|
||||
if v, ok := repoInfo["devops_enabled"].(bool); ok {
|
||||
devops = v
|
||||
}
|
||||
}
|
||||
if devops {
|
||||
dims = append(dims, dimScore{Name: "DevOps", Score: 100, Weight: 0.15, Status: "ok", Detail: "DevOps 已开启"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "DevOps", Score: 0, Weight: 0.15, Status: "missing", Detail: "DevOps 未开启"})
|
||||
missing = append(missing, "DevOps")
|
||||
}
|
||||
|
||||
// Composite.
|
||||
var composite float64
|
||||
for _, d := range dims {
|
||||
composite += d.Score * d.Weight
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"composite_score": composite,
|
||||
"grade": grade(composite),
|
||||
"dimensions": dims,
|
||||
"missing_items": missing,
|
||||
"recommendation": strings.Join(missing, "、") + " 需要补充",
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
analysis["recommendation"] = "项目初始化完善,所有检查项均已通过"
|
||||
}
|
||||
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||||
}
|
||||
|
||||
func detectLicenseInFiles(upstream map[string]interface{}) bool {
|
||||
files := extractList(upstream, "existing-files")
|
||||
if len(files) == 0 {
|
||||
files = extractList(upstream, "files")
|
||||
}
|
||||
for _, f := range files {
|
||||
name := str(f, "name", "filename", "path", "file_name")
|
||||
if isLicenseFile(name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepoAuditComplete(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"repo-info": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{
|
||||
"description": "A well-maintained project",
|
||||
"has_readme": true,
|
||||
"open_devops": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "LICENSE", "content": "MIT"},
|
||||
},
|
||||
},
|
||||
"labels": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "bug"},
|
||||
map[string]interface{}{"name": "enhancement"},
|
||||
map[string]interface{}{"name": "question"},
|
||||
map[string]interface{}{"name": "docs"},
|
||||
map[string]interface{}{"name": "security"},
|
||||
map[string]interface{}{"name": "refactor"},
|
||||
},
|
||||
},
|
||||
"milestones": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"title": "v1.0"},
|
||||
},
|
||||
},
|
||||
"branches": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "master"},
|
||||
map[string]interface{}{"name": "develop"},
|
||||
map[string]interface{}{"name": "feature/x"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := RepoAuditRule(upstream, "repo-audit")
|
||||
if err != nil {
|
||||
t.Fatalf("RepoAuditRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
|
||||
// Fully complete repo should have a high score.
|
||||
composite := analysis["composite_score"].(float64)
|
||||
if composite < 80 {
|
||||
t.Errorf("expected composite >= 80 for complete repo, got %.1f", composite)
|
||||
}
|
||||
|
||||
// Should have no missing items.
|
||||
missing := analysis["missing_items"].([]string)
|
||||
if len(missing) != 0 {
|
||||
t.Errorf("expected 0 missing items, got %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoAuditEmpty(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"repo-info": map[string]interface{}{"data": []interface{}{}},
|
||||
"labels": map[string]interface{}{"data": []interface{}{}},
|
||||
"milestones": map[string]interface{}{"data": []interface{}{}},
|
||||
"branches": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := RepoAuditRule(upstream, "repo-audit")
|
||||
if err != nil {
|
||||
t.Fatalf("RepoAuditRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
|
||||
// Empty repo should have a low score.
|
||||
composite := analysis["composite_score"].(float64)
|
||||
if composite >= 50 {
|
||||
t.Errorf("expected composite < 50 for empty repo, got %.1f", composite)
|
||||
}
|
||||
|
||||
// Should have missing items.
|
||||
missing := analysis["missing_items"].([]string)
|
||||
if len(missing) == 0 {
|
||||
t.Fatal("expected missing items for empty repo")
|
||||
}
|
||||
t.Logf("missing items: %v", missing)
|
||||
}
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// finding holds a single code review finding.
|
||||
type finding struct {
|
||||
PRNumber interface{} `json:"pr_number"`
|
||||
PRTitle string `json:"pr_title"`
|
||||
Lens string `json:"lens"`
|
||||
Severity string `json:"severity"`
|
||||
What string `json:"what"`
|
||||
Why string `json:"why"`
|
||||
Fix string `json:"fix"`
|
||||
}
|
||||
|
||||
// CodeReviewRule performs static analysis on PR metadata.
|
||||
func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
prs := extractPRs(upstream, "open-prs")
|
||||
if len(prs) == 0 {
|
||||
return &workflow.AIResponse{
|
||||
Analysis: map[string]interface{}{"findings": nil, "message": "no open PRs to review"},
|
||||
Actions: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var findings []finding
|
||||
var actions []workflow.AIAction
|
||||
prFindingsMap := make(map[string][]finding)
|
||||
reviewedCount := 0
|
||||
|
||||
// Diffs may have been pre-fetched by the workflow engine.
|
||||
prDiffsMap := extractDiffsFromUpstream(upstream)
|
||||
|
||||
for _, pr := range prs {
|
||||
// Only review open PRs.
|
||||
status := str(pr, "pull_request_status", "pull_request_staus", "status", "state")
|
||||
if status != "" && status != "open" {
|
||||
continue
|
||||
}
|
||||
reviewedCount++
|
||||
|
||||
title := str(pr, "title", "name")
|
||||
body := str(pr, "body", "description")
|
||||
prNum := interfaceToString(pr["pull_request_number"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["id"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["number"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["pull_request_id"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if prNum == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use pre-fetched diff from upstream if available.
|
||||
diffText := prDiffsMap[prNum]
|
||||
|
||||
text := title + " " + body + " " + diffText
|
||||
|
||||
// Security scan.
|
||||
for _, p := range reviewSecurityPatterns {
|
||||
if p.re.MatchString(text) {
|
||||
f := finding{
|
||||
PRNumber: prNum,
|
||||
PRTitle: title,
|
||||
Lens: "security",
|
||||
Severity: p.severity,
|
||||
What: p.what,
|
||||
Why: "PR 标题/描述中包含可能存在安全风险的代码模式",
|
||||
Fix: p.fix,
|
||||
}
|
||||
findings = append(findings, f)
|
||||
prFindingsMap[prNum] = append(prFindingsMap[prNum], f)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainability scan.
|
||||
filesCount := 0
|
||||
if v, ok := pr["files_count"].(float64); ok {
|
||||
filesCount = int(v)
|
||||
}
|
||||
if filesCount > 50 {
|
||||
f := finding{
|
||||
PRNumber: prNum,
|
||||
PRTitle: title,
|
||||
Lens: "maintainability",
|
||||
Severity: "medium",
|
||||
What: fmt.Sprintf("PR 包含 %d 个文件,建议拆分为更小的 PR", filesCount),
|
||||
Why: "大 PR 难以审查,增加合并风险和回滚难度",
|
||||
Fix: "将改动按功能模块拆分为多个小 PR",
|
||||
}
|
||||
findings = append(findings, f)
|
||||
prFindingsMap[prNum] = append(prFindingsMap[prNum], f)
|
||||
}
|
||||
|
||||
if body == "" && len(title) < 10 {
|
||||
f := finding{
|
||||
PRNumber: prNum,
|
||||
PRTitle: title,
|
||||
Lens: "maintainability",
|
||||
Severity: "low",
|
||||
What: "PR 缺少描述信息",
|
||||
Why: "不清晰的 PR 描述增加审查时间,降低代码质量",
|
||||
Fix: "添加 PR 描述,说明改动原因、影响范围和测试方式",
|
||||
}
|
||||
findings = append(findings, f)
|
||||
prFindingsMap[prNum] = append(prFindingsMap[prNum], f)
|
||||
}
|
||||
}
|
||||
|
||||
// Always post a summary comment for every open PR.
|
||||
for _, pr := range prs {
|
||||
status := str(pr, "pull_request_status", "pull_request_staus", "status", "state")
|
||||
if status != "" && status != "open" {
|
||||
continue
|
||||
}
|
||||
prNum := interfaceToString(pr["pull_request_number"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["id"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["number"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["pull_request_id"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if prNum == "" {
|
||||
continue
|
||||
}
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "cli",
|
||||
Module: "issue",
|
||||
Command: "+comment",
|
||||
Args: map[string]string{
|
||||
"number": prNum,
|
||||
"body": buildReviewComment(pr, prFindingsMap[prNum]),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Merge AI findings if available.
|
||||
aiSummary := ""
|
||||
if aiRaw, ok := upstream["_ai_analysis"].(string); ok && aiRaw != "" {
|
||||
aiJson := workflow.ExtractAIJsonBlock(aiRaw)
|
||||
if aiFindings, ok := aiJson["findings"].([]interface{}); ok {
|
||||
for _, af := range aiFindings {
|
||||
afMap, ok := af.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
f := finding{
|
||||
PRNumber: interfaceToString(afMap["pr_number"]),
|
||||
Lens: str(afMap, "lens"),
|
||||
Severity: str(afMap, "severity"),
|
||||
What: str(afMap, "what"),
|
||||
Why: str(afMap, "why"),
|
||||
Fix: str(afMap, "fix"),
|
||||
}
|
||||
if f.What == "" || f.Severity == "" {
|
||||
continue
|
||||
}
|
||||
// Deduplicate: skip if same what+pr already exists from regex scan.
|
||||
dup := false
|
||||
for _, existing := range findings {
|
||||
if existing.What == f.What && fmt.Sprint(existing.PRNumber) == fmt.Sprint(f.PRNumber) {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
findings = append(findings, f)
|
||||
prFindingsMap[fmt.Sprint(f.PRNumber)] = append(prFindingsMap[fmt.Sprint(f.PRNumber)], f)
|
||||
}
|
||||
}
|
||||
}
|
||||
if s, ok := aiJson["summary"].(string); ok {
|
||||
aiSummary = s
|
||||
}
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"reviewed_prs": reviewedCount,
|
||||
"total_findings": len(findings),
|
||||
"diffs": prDiffsMap,
|
||||
"findings": findings,
|
||||
"summary": fmt.Sprintf("审查了 %d 个 PR,发现 %d 个问题", reviewedCount, len(findings)),
|
||||
}
|
||||
if aiSummary != "" {
|
||||
analysis["ai_summary"] = aiSummary
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||
}
|
||||
|
||||
// buildReviewComment generates a Markdown summary comment for a PR review.
|
||||
func buildReviewComment(pr map[string]interface{}, prFindings []finding) string {
|
||||
title := str(pr, "title", "name")
|
||||
prNum := interfaceToString(pr["pull_request_number"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["id"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["number"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["pull_request_id"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if prNum == "" {
|
||||
prNum = "?"
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04")
|
||||
|
||||
hasSecurity := false
|
||||
hasMaint := false
|
||||
hasHighSeverity := false
|
||||
for _, f := range prFindings {
|
||||
if f.Lens == "security" {
|
||||
hasSecurity = true
|
||||
if f.Severity == "high" {
|
||||
hasHighSeverity = true
|
||||
}
|
||||
}
|
||||
if f.Lens == "maintainability" {
|
||||
hasMaint = true
|
||||
}
|
||||
}
|
||||
|
||||
securityIcon := "✅ 通过"
|
||||
if hasHighSeverity {
|
||||
securityIcon = "❌ 未通过(高危)"
|
||||
} else if hasSecurity {
|
||||
securityIcon = "⚠️ 存在警告"
|
||||
}
|
||||
|
||||
maintIcon := "✅ 合理"
|
||||
if hasMaint {
|
||||
maintIcon = "⚠️ 存在建议"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("## 🤖 Code Quality 自动审查报告\n\n")
|
||||
b.WriteString(fmt.Sprintf("- 审查时间: %s\n", now))
|
||||
b.WriteString(fmt.Sprintf("- 审查 PR: #%s — %s\n", prNum, title))
|
||||
b.WriteString(fmt.Sprintf("- 发现问题: %d 个\n", len(prFindings)))
|
||||
b.WriteString(fmt.Sprintf("- 安全扫描: %s\n", securityIcon))
|
||||
b.WriteString(fmt.Sprintf("- 代码质量: %s\n", maintIcon))
|
||||
|
||||
b.WriteString(fmt.Sprintf("- 代码 Diff 分析: %s\n", "✅ 已分析"))
|
||||
if len(prFindings) > 0 {
|
||||
b.WriteString("\n### 发现详情\n\n")
|
||||
for _, f := range prFindings {
|
||||
b.WriteString(fmt.Sprintf("- **[%s][%s]** %s → %s\n",
|
||||
f.Severity, f.Lens, f.What, f.Fix))
|
||||
}
|
||||
b.WriteString("\n---\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n> 此评论由 gitlink-cli code-quality 工作流自动生成\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// EnsureReviewComment guarantees a review summary comment action is present.
|
||||
// If the AI response already contains issue +comment actions, it is left unchanged.
|
||||
// Otherwise, the rule engine is invoked to produce the missing comment actions.
|
||||
// This is used as a safety net for the AI path.
|
||||
func EnsureReviewComment(aiResp *workflow.AIResponse, upstream map[string]interface{}) {
|
||||
for _, a := range aiResp.Actions {
|
||||
if a.Type == "cli" && a.Module == "issue" && a.Command == "+comment" {
|
||||
return // already has a comment action
|
||||
}
|
||||
}
|
||||
ruleResp, err := CodeReviewRule(upstream, "review")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, a := range ruleResp.Actions {
|
||||
if a.Type == "cli" && a.Module == "issue" && a.Command == "+comment" {
|
||||
aiResp.Actions = append(aiResp.Actions, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractDiffsFromUpstream retrieves pre-fetched PR diffs from the upstream data.
|
||||
func extractDiffsFromUpstream(upstream map[string]interface{}) map[string]string {
|
||||
diffs := make(map[string]string)
|
||||
raw, ok := upstream["_pr_diffs"]
|
||||
if !ok {
|
||||
return diffs
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return diffs
|
||||
}
|
||||
for k, v := range m {
|
||||
if s, ok := v.(string); ok {
|
||||
diffs[k] = s
|
||||
}
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
// fetchPRDiff retrieves the unified diff for a PR by calling gitlink-cli.
|
||||
func fetchPRDiff(owner, repo, prNum string) string {
|
||||
if owner == "" || repo == "" || prNum == "" {
|
||||
return ""
|
||||
}
|
||||
bin, err := exec.LookPath("gitlink-cli")
|
||||
if err != nil {
|
||||
bin = ""
|
||||
}
|
||||
if bin == "" {
|
||||
return ""
|
||||
}
|
||||
cmd := exec.Command(bin, "pr", "+diff", "--id", prNum, "--owner", owner, "--repo", repo, "--format", "json")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Diff string `json:"diff"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &resp); err != nil {
|
||||
return ""
|
||||
}
|
||||
if !resp.OK || resp.Data.Diff == "" {
|
||||
return ""
|
||||
}
|
||||
return resp.Data.Diff
|
||||
}
|
||||
|
||||
type reviewPattern struct {
|
||||
re *regexp.Regexp
|
||||
severity string
|
||||
what string
|
||||
fix string
|
||||
}
|
||||
|
||||
var reviewSecurityPatterns = []reviewPattern{
|
||||
{
|
||||
regexp.MustCompile(`(?i)(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*['"][^\s'"]{8,}['"]`),
|
||||
"high",
|
||||
"检测到硬编码凭据(密码/Token/密钥)",
|
||||
"将凭据移至环境变量或密钥管理服务,使用占位符替换",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)SELECT\s.*\sFROM\s.*WHERE\s.*\+`),
|
||||
"high",
|
||||
"检测到潜在 SQL 注入模式(字符串拼接构建 SQL)",
|
||||
"使用参数化查询或 ORM 框架",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)innerHTML\s*=|document\.write\(|eval\(`),
|
||||
"medium",
|
||||
"检测到潜在 XSS 风险(innerHTML / eval 使用)",
|
||||
"使用 textContent 替代 innerHTML,避免使用 eval",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----`),
|
||||
"high",
|
||||
"检测到私钥明文",
|
||||
"立即删除私钥,使用密钥管理服务",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)ghp_[a-zA-Z0-9]{36}`),
|
||||
"high",
|
||||
"检测到 GitHub 个人访问令牌",
|
||||
"撤销此令牌,使用环境变量存储新令牌",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)os\.system\(|exec\(|subprocess\.call\(`),
|
||||
"medium",
|
||||
"检测到潜在命令注入风险",
|
||||
"避免将用户输入直接拼接到系统命令中,使用参数列表形式",
|
||||
},
|
||||
}
|
||||
|
||||
func interfaceToString(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", val)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", val)
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
// test: password='hardcoded12345678' - should trigger security scan
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCodeReviewStaticAnalysis(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-prs": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "1",
|
||||
"title": "add feature",
|
||||
"body": "password = 'hardcoded12345678'",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "2",
|
||||
"title": "wip",
|
||||
"body": "",
|
||||
"files_count": 60.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := CodeReviewRule(upstream, "review")
|
||||
if err != nil {
|
||||
t.Fatalf("CodeReviewRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
|
||||
total := analysis["total_findings"].(int)
|
||||
if total == 0 {
|
||||
t.Fatal("expected findings for hardcoded password and large PR")
|
||||
}
|
||||
// Should have at least one high-severity security finding.
|
||||
findings := analysis["findings"].([]finding)
|
||||
hasSecurity := false
|
||||
hasMaint := false
|
||||
for _, f := range findings {
|
||||
if f.Lens == "security" {
|
||||
hasSecurity = true
|
||||
}
|
||||
if f.Lens == "maintainability" {
|
||||
hasMaint = true
|
||||
}
|
||||
}
|
||||
if !hasSecurity {
|
||||
t.Error("expected security finding for hardcoded password")
|
||||
}
|
||||
if !hasMaint {
|
||||
t.Error("expected maintainability finding for large PR or missing body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeReviewNoPRs(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := CodeReviewRule(upstream, "review")
|
||||
if err != nil {
|
||||
t.Fatalf("CodeReviewRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if msg := analysis["message"]; msg != "no open PRs to review" {
|
||||
t.Fatalf("expected 'no open PRs to review', got %v", msg)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// TriageRule classifies issues, assigns priorities, matches labels, and distributes work.
|
||||
func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
issues := extractIssues(upstream, "open-issues")
|
||||
labels := extractLabels(upstream)
|
||||
members := extractMembers(upstream)
|
||||
|
||||
if len(issues) == 0 {
|
||||
return &workflow.AIResponse{
|
||||
Analysis: map[string]interface{}{"classified": 0, "message": "no open issues to triage"},
|
||||
Actions: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var actions []workflow.AIAction
|
||||
memberLoad := map[string]int{}
|
||||
classified := []map[string]interface{}{}
|
||||
gfi := []map[string]interface{}{}
|
||||
|
||||
for _, issue := range issues {
|
||||
num := issueNumber(issue)
|
||||
title := str(issue, "title")
|
||||
body := str(issue, "body", "description")
|
||||
text := title + " " + body
|
||||
|
||||
cat := classifyIssue(text)
|
||||
pri := assignPriority(text)
|
||||
labelIDs := matchLabels(cat, labels)
|
||||
assignee := leastLoaded(memberLoad, members)
|
||||
|
||||
if assignee != "" {
|
||||
memberLoad[assignee]++
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"number": num,
|
||||
"title": title,
|
||||
"category": cat,
|
||||
"priority": pri,
|
||||
"assignee": assignee,
|
||||
}
|
||||
classified = append(classified, result)
|
||||
|
||||
// Build PATCH action if we have labels or assignee.
|
||||
body2 := map[string]interface{}{}
|
||||
if len(labelIDs) > 0 {
|
||||
body2["issue_tag_ids"] = labelIDs
|
||||
}
|
||||
if assignee != "" {
|
||||
body2["assigner_ids"] = []string{assignee}
|
||||
}
|
||||
if pri > 0 {
|
||||
body2["priority_id"] = pri
|
||||
}
|
||||
if len(body2) > 0 && num != "" {
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "api", Method: "PATCH",
|
||||
Path: fmt.Sprintf("{v1}/issues/%s", num),
|
||||
Body: body2,
|
||||
})
|
||||
}
|
||||
|
||||
if isGoodFirstIssue(text) {
|
||||
gfi = append(gfi, result)
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "cli", Module: "issue", Command: "+comment",
|
||||
Args: map[string]string{
|
||||
"number": num,
|
||||
"body": "👋 感谢提交 Issue!这个 Issue 已被标记为 **Good First Issue**,适合新贡献者参与。欢迎提交 PR!",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"classified": len(classified),
|
||||
"results": classified,
|
||||
"good_first_issues": gfi,
|
||||
}
|
||||
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||
}
|
||||
|
||||
// --- classification ---
|
||||
|
||||
var catPatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
category string
|
||||
}{
|
||||
{regexp.MustCompile(`(?i)错误|失败|异常|崩溃|crash|error|bug|broken|404|500`), "bug"},
|
||||
{regexp.MustCompile(`(?i)安全|漏洞|泄露|vulnerability|CVE|敏感`), "security"},
|
||||
{regexp.MustCompile(`(?i)性能|慢|卡顿|优化|performance|speed`), "performance"},
|
||||
{regexp.MustCompile(`(?i)重构|代码质量|refactor|clean\s*up|tech\s*debt`), "refactor"},
|
||||
{regexp.MustCompile(`(?i)建议|希望|新增|支持|feature|enhancement|add|improve`), "enhancement"},
|
||||
{regexp.MustCompile(`(?i)文档|README|帮助|doc|documentation|typo`), "docs"},
|
||||
{regexp.MustCompile(`(?i)如何|怎么|请问|how\s*to|question|help|求助`), "question"},
|
||||
}
|
||||
|
||||
var priorityPatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
pri int
|
||||
}{
|
||||
{regexp.MustCompile(`(?i)紧急|urgent|critical|崩溃|crash|严重|安全|漏洞|CVE|P0`), 4},
|
||||
{regexp.MustCompile(`(?i)重要|high|important|P1|阻断`), 3},
|
||||
{regexp.MustCompile(`(?i)低|low|trivial|minor|P3`), 1},
|
||||
}
|
||||
|
||||
func classifyIssue(text string) string {
|
||||
for _, p := range catPatterns {
|
||||
if p.re.MatchString(text) {
|
||||
return p.category
|
||||
}
|
||||
}
|
||||
return "enhancement" // default
|
||||
}
|
||||
|
||||
func assignPriority(text string) int {
|
||||
for _, p := range priorityPatterns {
|
||||
if p.re.MatchString(text) {
|
||||
return p.pri
|
||||
}
|
||||
}
|
||||
return 2 // default: medium
|
||||
}
|
||||
|
||||
func isGoodFirstIssue(text string) bool {
|
||||
gfiRe := regexp.MustCompile(`(?i)good\s*first\s*issue|beginner|easy|简单|新手|入门`)
|
||||
if gfiRe.MatchString(text) {
|
||||
return true
|
||||
}
|
||||
// Also mark simple enhancements/docs as GFI.
|
||||
cat := classifyIssue(text)
|
||||
pri := assignPriority(text)
|
||||
return (cat == "docs" || cat == "enhancement") && pri <= 2 &&
|
||||
len(strings.Fields(text)) < 200
|
||||
}
|
||||
|
||||
// --- label matching ---
|
||||
|
||||
func extractLabels(upstream map[string]interface{}) []map[string]interface{} {
|
||||
return extractList(upstream, "labels")
|
||||
}
|
||||
|
||||
func matchLabels(category string, labels []map[string]interface{}) []interface{} {
|
||||
catLower := strings.ToLower(category)
|
||||
var ids []interface{}
|
||||
for _, l := range labels {
|
||||
name := strings.ToLower(str(l, "name", "title", "label"))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// Direct match or contains.
|
||||
if name == catLower || strings.Contains(name, catLower) || strings.Contains(catLower, name) {
|
||||
if id := labelID(l); id != nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also match sub-categories for bug.
|
||||
if catLower == "bug" {
|
||||
for _, l := range labels {
|
||||
name := strings.ToLower(str(l, "name", "title", "label"))
|
||||
if strings.Contains(name, "bug") || strings.Contains(name, "fix") {
|
||||
if id := labelID(l); id != nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func labelID(l map[string]interface{}) interface{} {
|
||||
for _, k := range []string{"id", "tag_id", "label_id"} {
|
||||
if v := l[k]; v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- assignment ---
|
||||
|
||||
func leastLoaded(load map[string]int, members map[string]string) string {
|
||||
if len(members) == 0 {
|
||||
return ""
|
||||
}
|
||||
best := ""
|
||||
bestN := -1
|
||||
for login := range members {
|
||||
n := load[login]
|
||||
if bestN < 0 || n < bestN {
|
||||
bestN = n
|
||||
best = login
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func issueNumber(issue map[string]interface{}) string {
|
||||
for _, k := range []string{"project_issues_index", "number", "iid", "id"} {
|
||||
s := fmt.Sprint(issue[k])
|
||||
if s != "" && s != "0" && s != "<nil>" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestTriageRuleClassifiesByKeyword(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-issues": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"project_issues_index": "1", "title": "fix crash on startup", "body": "应用启动时崩溃"},
|
||||
map[string]interface{}{"project_issues_index": "2", "title": "新增导出功能", "body": ""},
|
||||
map[string]interface{}{"project_issues_index": "3", "title": "如何配置SSO", "body": "请问怎么配"},
|
||||
},
|
||||
},
|
||||
"labels": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"id": 1, "name": "bug"},
|
||||
map[string]interface{}{"id": 2, "name": "enhancement"},
|
||||
map[string]interface{}{"id": 3, "name": "question"},
|
||||
},
|
||||
},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := TriageRule(upstream, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed: %v", err)
|
||||
}
|
||||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
|
||||
analysis, ok := resp.Analysis.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Analysis is not a map")
|
||||
}
|
||||
if v := analysis["classified"]; v.(int) != 3 {
|
||||
t.Fatalf("expected 3 classified, got %v", v)
|
||||
}
|
||||
if len(resp.Actions) == 0 {
|
||||
t.Fatal("expected actions for issue triage")
|
||||
}
|
||||
|
||||
// Verify each action type.
|
||||
for _, a := range resp.Actions {
|
||||
if a.Type == "api" && a.Method != "PATCH" {
|
||||
t.Errorf("unexpected API method: %s", a.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriageRuleEmptyIssues(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"labels": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
resp, err := TriageRule(upstream, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed: %v", err)
|
||||
}
|
||||
if len(resp.Actions) != 0 {
|
||||
t.Fatalf("expected 0 actions for empty issues, got %d", len(resp.Actions))
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["classified"]; v.(int) != 0 {
|
||||
t.Fatalf("expected 0 classified, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriageRuleGoodFirstIssue(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-issues": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"project_issues_index": "10", "title": "good first issue: add docs", "body": "easy task for beginners"},
|
||||
},
|
||||
},
|
||||
"labels": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"id": 1, "name": "docs"},
|
||||
},
|
||||
},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := TriageRule(upstream, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed: %v", err)
|
||||
}
|
||||
hasComment := false
|
||||
for _, a := range resp.Actions {
|
||||
if a.Type == "cli" && a.Module == "issue" && a.Command == "+comment" {
|
||||
hasComment = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasComment {
|
||||
t.Fatal("expected a cli comment action for good first issue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriageRulePriority(t *testing.T) {
|
||||
cases := []struct {
|
||||
title string
|
||||
expected int
|
||||
}{
|
||||
{"紧急: 安全漏洞", 4},
|
||||
{"重要功能", 3},
|
||||
{"普通建议", 2},
|
||||
{"低优先级改进", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
upstream := map[string]interface{}{
|
||||
"open-issues": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"project_issues_index": "1", "title": tc.title, "body": ""},
|
||||
},
|
||||
},
|
||||
"labels": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
resp, err := TriageRule(upstream, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed for %q: %v", tc.title, err)
|
||||
}
|
||||
if len(resp.Actions) > 0 {
|
||||
body := resp.Actions[0].Body
|
||||
if v, ok := body["priority_id"]; ok {
|
||||
if v.(int) != tc.expected {
|
||||
t.Errorf("title=%q: priority_id=%v, want %d", tc.title, v, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriageRuleOutputFormat(t *testing.T) {
|
||||
resp, err := TriageRule(map[string]interface{}{}, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response")
|
||||
}
|
||||
// Verify it's a valid workflow.AIResponse.
|
||||
var _ *workflow.AIResponse = resp
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
)
|
||||
|
||||
// LoadState reads the persisted workflow state from disk.
|
||||
func LoadState(name string) (*WorkflowState, error) {
|
||||
path := statePath(name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &WorkflowState{
|
||||
Workflow: name,
|
||||
Snapshots: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var s WorkflowState
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("parse state file %s: %w", path, err)
|
||||
}
|
||||
if s.Snapshots == nil {
|
||||
s.Snapshots = make(map[string]string)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// Save persists the workflow state to disk.
|
||||
func (s *WorkflowState) Save() error {
|
||||
s.LastRun = time.Now().Format(time.RFC3339)
|
||||
path := statePath(s.Workflow)
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
// Diff compares current step results against stored snapshots.
|
||||
// Returns the names of steps whose data changed since the last run.
|
||||
func (s *WorkflowState) Diff(results []StepResult) []string {
|
||||
changed := []string{}
|
||||
for _, sr := range results {
|
||||
if !sr.OK || sr.Data == nil {
|
||||
continue
|
||||
}
|
||||
hash := hashData(sr.Data)
|
||||
if prev, ok := s.Snapshots[sr.Step]; ok && prev != hash {
|
||||
changed = append(changed, sr.Step)
|
||||
}
|
||||
s.Snapshots[sr.Step] = hash
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// hashData computes an MD5 hash of the JSON-encoded data.
|
||||
func hashData(data interface{}) string {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%x", md5.Sum(b))
|
||||
}
|
||||
|
||||
// statePath returns the file path for a workflow's state file.
|
||||
func statePath(name string) string {
|
||||
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s-state.json", name))
|
||||
}
|
||||
|
|
@ -1,381 +0,0 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// StepResult holds the outcome of executing one step.
|
||||
type StepResult struct {
|
||||
Step string `json:"step"`
|
||||
Purpose string `json:"purpose"`
|
||||
Type StepType `json:"type"`
|
||||
OK bool `json:"ok"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ExecuteStep dispatches a step to the right executor based on its Type.
|
||||
func ExecuteStep(ctx *common.RuntimeContext, step StepDef, dryRun bool) *StepResult {
|
||||
sr := &StepResult{
|
||||
Step: step.Name,
|
||||
Purpose: step.Purpose,
|
||||
Type: step.Type,
|
||||
}
|
||||
|
||||
switch step.Type {
|
||||
case StepTypeAPI:
|
||||
executeAPIStep(ctx, step, sr)
|
||||
case StepTypeCommand:
|
||||
executeCommandStep(ctx, step, sr)
|
||||
case StepTypeSkill:
|
||||
executeSkillStep(ctx, step, sr, dryRun)
|
||||
default:
|
||||
sr.OK = false
|
||||
sr.Error = fmt.Sprintf("unknown step type: %q", step.Type)
|
||||
}
|
||||
return sr
|
||||
}
|
||||
|
||||
// executeAPIStep makes an HTTP call through the API client.
|
||||
func executeAPIStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
|
||||
path := resolvePath(step.Target, ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPIWithQuery(step.Method, path, step.Query)
|
||||
if err != nil {
|
||||
sr.OK = false
|
||||
sr.Error = err.Error()
|
||||
} else {
|
||||
sr.OK = env.OK
|
||||
sr.Data = env.Data
|
||||
}
|
||||
}
|
||||
|
||||
// executeCommandStep runs a gitlink-cli subcommand as a subprocess.
|
||||
func executeCommandStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
|
||||
parts := parseCommandTarget(step.Target)
|
||||
if len(parts) == 0 {
|
||||
sr.OK = false
|
||||
sr.Error = fmt.Sprintf("empty command target: %q", step.Target)
|
||||
return
|
||||
}
|
||||
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
bin = "gitlink-cli"
|
||||
}
|
||||
|
||||
args := append(parts, "--format", "json")
|
||||
if ctx.Owner != "" {
|
||||
args = append(args, "--owner", ctx.Owner)
|
||||
}
|
||||
if ctx.Repo != "" {
|
||||
args = append(args, "--repo", ctx.Repo)
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Stderr = nil
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
sr.OK = false
|
||||
sr.Error = fmt.Sprintf("command failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
if err := json.Unmarshal(out, &data); err != nil {
|
||||
sr.OK = true
|
||||
sr.Data = strings.TrimSpace(string(out))
|
||||
} else {
|
||||
sr.OK = true
|
||||
sr.Data = data
|
||||
}
|
||||
}
|
||||
|
||||
// executeSkillStep runs a skill step. Depending on aiMode, it uses the AI API or
|
||||
// falls back to a deterministic rule engine. Both paths produce the same AIResponse
|
||||
// format, and actions from either source go through the same security whitelist.
|
||||
func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult, dryRun bool) {
|
||||
upstream := collectUpstream(ctx, step)
|
||||
|
||||
if dryRun {
|
||||
sr.OK = true
|
||||
sr.Data = map[string]interface{}{
|
||||
"_skill": step.Target,
|
||||
"_dry_run": true,
|
||||
"_depends_on": step.DependsOn,
|
||||
"_upstream": upstream,
|
||||
"_hint": "预览模式:展示将要传给 AI/规则引擎 的上游数据,不实际执行。",
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
aiMode := resolveAIMode(ctx)
|
||||
client := NewAIClient()
|
||||
var aiResp *AIResponse
|
||||
var usedAI bool
|
||||
|
||||
switch aiMode {
|
||||
case AIModeNoAI:
|
||||
resp, err := runRuleEngine(step, upstream)
|
||||
if err != nil {
|
||||
sr.OK = true
|
||||
sr.Data = map[string]interface{}{
|
||||
"_skill": step.Target,
|
||||
"_needs_ai": true,
|
||||
"_upstream": upstream,
|
||||
"_error": fmt.Sprintf("rule engine failed: %v", err),
|
||||
}
|
||||
return
|
||||
}
|
||||
aiResp = resp
|
||||
|
||||
case AIModeAI:
|
||||
if !client.HasKey() {
|
||||
sr.OK = false
|
||||
sr.Error = "AI 模式需要配置 API Key(设置 ANTHROPIC_API_KEY 环境变量或 config set anthropic_api_key)"
|
||||
return
|
||||
}
|
||||
resp, err := callAI(client, step, upstream)
|
||||
if err != nil {
|
||||
sr.OK = false
|
||||
sr.Error = fmt.Sprintf("AI 调用失败: %v", err)
|
||||
return
|
||||
}
|
||||
aiResp = resp
|
||||
usedAI = true
|
||||
|
||||
default: // "auto"
|
||||
if client.HasKey() {
|
||||
resp, err := callAI(client, step, upstream)
|
||||
if err == nil {
|
||||
aiResp = resp
|
||||
usedAI = true
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[workflow] AI 调用失败,降级到规则引擎: %v\n", err)
|
||||
}
|
||||
}
|
||||
if aiResp == nil {
|
||||
resp, err := runRuleEngine(step, upstream)
|
||||
if err != nil {
|
||||
sr.OK = true
|
||||
sr.Data = map[string]interface{}{
|
||||
"_skill": step.Target,
|
||||
"_needs_ai": true,
|
||||
"_upstream": upstream,
|
||||
"_error": fmt.Sprintf("AI 和规则引擎均失败: %v", err),
|
||||
}
|
||||
return
|
||||
}
|
||||
aiResp = resp
|
||||
}
|
||||
}
|
||||
|
||||
executed := executeActions(ctx, aiResp.Actions)
|
||||
|
||||
sr.OK = true
|
||||
sr.Data = map[string]interface{}{
|
||||
"ok": true,
|
||||
"analysis": aiResp.Analysis,
|
||||
"executed": executed,
|
||||
"_ai_used": usedAI,
|
||||
"_skill": step.Target,
|
||||
}
|
||||
}
|
||||
|
||||
// executeActions runs allowed actions from an AIResponse. Returns count of
|
||||
// successfully executed actions. Actions from both AI and rule engines pass
|
||||
// through the same security whitelist.
|
||||
func executeActions(ctx *common.RuntimeContext, actions []AIAction) int {
|
||||
executed := 0
|
||||
for _, action := range actions {
|
||||
if !isActionAllowed(action) {
|
||||
fmt.Fprintf(os.Stderr, "[workflow] blocked action: %s %s\n", action.Type, action.Command)
|
||||
continue
|
||||
}
|
||||
if action.Type == "api" {
|
||||
path := resolvePath(action.Path, ctx.Owner, ctx.Repo)
|
||||
_, err := ctx.CallAPI(action.Method, path, action.Body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[workflow] api action failed: %v\n", err)
|
||||
continue
|
||||
}
|
||||
executed++
|
||||
} else if action.Type == "cli" {
|
||||
args := []string{action.Module, action.Command}
|
||||
for k, v := range action.Args {
|
||||
args = append(args, "--"+k, v)
|
||||
}
|
||||
args = append(args, "--owner", ctx.Owner, "--repo", ctx.Repo)
|
||||
bin, _ := os.Executable()
|
||||
if bin == "" {
|
||||
bin = "gitlink-cli"
|
||||
}
|
||||
err := exec.Command(bin, args...).Run()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[workflow] cli action failed: %v\n", err)
|
||||
continue
|
||||
}
|
||||
executed++
|
||||
}
|
||||
}
|
||||
return executed
|
||||
}
|
||||
|
||||
// resolveAIMode determines the effective AI mode from the context.
|
||||
func resolveAIMode(ctx *common.RuntimeContext) AIMode {
|
||||
switch ctx.AIMode {
|
||||
case "ai":
|
||||
return AIModeAI
|
||||
case "no-ai":
|
||||
return AIModeNoAI
|
||||
default:
|
||||
return AIModeAuto
|
||||
}
|
||||
}
|
||||
|
||||
// callAI invokes the Anthropic API for a skill step.
|
||||
func callAI(client *AIClient, step StepDef, upstream map[string]interface{}) (*AIResponse, error) {
|
||||
skillMD := readSkillDoc(step.Target)
|
||||
upstreamJSON, _ := json.MarshalIndent(upstream, "", " ")
|
||||
return client.Analyze(&AIRequest{
|
||||
SystemPrompt: skillMD,
|
||||
UserData: string(upstreamJSON),
|
||||
})
|
||||
}
|
||||
|
||||
// runRuleEngine looks up and invokes the rule engine for a skill target.
|
||||
func runRuleEngine(step StepDef, upstream map[string]interface{}) (*AIResponse, error) {
|
||||
engine, ok := RuleEngines[step.Target]
|
||||
if !ok {
|
||||
return nil, ErrNoRuleEngine(step.Target)
|
||||
}
|
||||
return engine(upstream, step.Name)
|
||||
}
|
||||
|
||||
// collectUpstream gathers data from steps declared in DependsOn.
|
||||
func collectUpstream(ctx *common.RuntimeContext, step StepDef) map[string]interface{} {
|
||||
upstream := make(map[string]interface{})
|
||||
for _, dep := range step.DependsOn {
|
||||
if v, ok := ctx.Args[dep]; ok {
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
|
||||
upstream[dep] = parsed
|
||||
} else {
|
||||
upstream[dep] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no DependsOn, collect all available upstream data.
|
||||
if len(step.DependsOn) == 0 {
|
||||
for k, v := range ctx.Args {
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
|
||||
upstream[k] = parsed
|
||||
} else {
|
||||
upstream[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return upstream
|
||||
}
|
||||
|
||||
// readSkillDoc reads the full SKILL.md for a given skill name.
|
||||
func readSkillDoc(target string) string {
|
||||
paths := []string{}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
paths = append(paths, filepath.Join(filepath.Dir(exe), "skills", target, "SKILL.md"))
|
||||
}
|
||||
paths = append(paths,
|
||||
filepath.Join("skills", target, "SKILL.md"),
|
||||
filepath.Join("/etc/gitlink-cli/skills", target, "SKILL.md"),
|
||||
)
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
paths = append(paths, filepath.Join(home, ".config", "gitlink-cli", "skills", target, "SKILL.md"))
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
data, err := os.ReadFile(p)
|
||||
if err == nil {
|
||||
return string(data)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("# %s\n\nSkill documentation not found.", target)
|
||||
}
|
||||
|
||||
// Security whitelist for AI-generated actions.
|
||||
|
||||
var allowedAPIMethods = map[string]bool{
|
||||
"GET": true, "POST": true, "PATCH": true,
|
||||
}
|
||||
|
||||
var allowedCLIModules = map[string]bool{
|
||||
"issue": true, "pr": true, "release": true,
|
||||
"wiki": true, "member": true, "label": true,
|
||||
"milestone": true, "branch": true, "comment": true,
|
||||
}
|
||||
|
||||
var blockedCLICommands = map[string]bool{
|
||||
"+delete": true, "+remove": true, "+batch-delete": true,
|
||||
"+fork": true, "+batch-fork": true,
|
||||
}
|
||||
|
||||
func isActionAllowed(action AIAction) bool {
|
||||
if action.Type == "api" {
|
||||
if !allowedAPIMethods[action.Method] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if action.Type == "cli" {
|
||||
if !allowedCLIModules[action.Module] {
|
||||
return false
|
||||
}
|
||||
if blockedCLICommands[action.Command] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parseCommandTarget splits a CLI command string into tokens,
|
||||
// respecting quoted arguments.
|
||||
func parseCommandTarget(target string) []string {
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
inQuote := false
|
||||
quoteChar := byte(0)
|
||||
|
||||
for i := 0; i < len(target); i++ {
|
||||
c := target[i]
|
||||
switch {
|
||||
case c == '"' || c == '\'':
|
||||
if inQuote && c == quoteChar {
|
||||
inQuote = false
|
||||
quoteChar = 0
|
||||
} else if !inQuote {
|
||||
inQuote = true
|
||||
quoteChar = c
|
||||
} else {
|
||||
current.WriteByte(c)
|
||||
}
|
||||
case c == ' ' && !inQuote:
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
default:
|
||||
current.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Watch polls the first step (or watchStep) every interval and triggers the
|
||||
// full workflow (with AI) only when data changes. Blocks until Ctrl+C.
|
||||
func Watch(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration, watchStep string) error {
|
||||
if watchStep == "" && len(wf.Steps) > 0 {
|
||||
watchStep = wf.Steps[0].Name
|
||||
}
|
||||
|
||||
fmt.Printf("👀 Watching %s/%s for %q changes every %v\n", ctx.Owner, ctx.Repo, watchStep, interval)
|
||||
fmt.Printf(" Trigger: %s on %s\n", wf.Trigger.Type, wf.Trigger.On)
|
||||
fmt.Println(" Press Ctrl+C to stop")
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
|
||||
state, _ := LoadState(wf.Name)
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sig:
|
||||
fmt.Println("\n👋 watch stopped")
|
||||
return nil
|
||||
case t := <-tick.C:
|
||||
// Phase 1: cheap dry-run to check for changes
|
||||
dryResult, err := Run(ctx, wf, true)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
|
||||
continue
|
||||
}
|
||||
|
||||
changed := state.Diff(dryResult.Steps)
|
||||
if len(changed) == 0 && state.TotalRuns > 0 {
|
||||
fmt.Printf("[%s] ✓ no changes\n", t.Format("15:04:05"))
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] 🔔 change detected: %v\n", t.Format("15:04:05"), changed)
|
||||
|
||||
// Phase 2: full run with AI
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] ❌ AI run error: %v\n", t.Format("15:04:05"), err)
|
||||
continue
|
||||
}
|
||||
|
||||
state.Diff(result.Steps)
|
||||
state.TotalRuns++
|
||||
state.Save()
|
||||
|
||||
for _, sr := range result.Steps {
|
||||
if sr.OK {
|
||||
fmt.Printf(" ✓ %s\n", sr.Step)
|
||||
} else {
|
||||
fmt.Printf(" ✗ %s: %s\n", sr.Step, sr.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule runs the full workflow on a repeating interval. Blocks until Ctrl+C.
|
||||
// Schedule always runs with AI (cron-style workflows like weekly reports always need fresh output).
|
||||
func Schedule(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration) error {
|
||||
fmt.Printf("⏰ Scheduled %q every %v on %s/%s\n", wf.Name, interval, ctx.Owner, ctx.Repo)
|
||||
fmt.Println(" Press Ctrl+C to stop")
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
|
||||
// Run immediately on start (dry-run to establish baseline)
|
||||
state, _ := LoadState(wf.Name)
|
||||
dryResult, _ := Run(ctx, wf, true)
|
||||
state.Diff(dryResult.Steps)
|
||||
state.TotalRuns++
|
||||
state.Save()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sig:
|
||||
fmt.Println("\n👋 schedule stopped")
|
||||
return nil
|
||||
case t := <-tick.C:
|
||||
// Phase 1: dry-run to check for changes
|
||||
dryResult, err := Run(ctx, wf, true)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
|
||||
continue
|
||||
}
|
||||
|
||||
changed := state.Diff(dryResult.Steps)
|
||||
state.TotalRuns++
|
||||
state.Save()
|
||||
|
||||
if len(changed) == 0 {
|
||||
fmt.Printf("[%s] ✓ no changes, skipped AI run\n", t.Format("15:04:05"))
|
||||
continue
|
||||
}
|
||||
|
||||
// Phase 2: full run with AI
|
||||
fmt.Printf("[%s] ⏳ changes detected, running %q with AI...\n", t.Format("15:04:05"), wf.Name)
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
ok, total := 0, len(result.Steps)
|
||||
for _, sr := range result.Steps {
|
||||
if sr.OK {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
fmt.Printf("✅ %d/%d steps OK\n", ok, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// AIMode controls whether AI is used for skill steps.
|
||||
type AIMode string
|
||||
|
||||
const (
|
||||
AIModeAuto AIMode = "auto" // Use AI if API key available, else rules
|
||||
AIModeAI AIMode = "ai" // Force AI (error if no key)
|
||||
AIModeNoAI AIMode = "no-ai" // Force rule engine only
|
||||
)
|
||||
|
||||
// RuleEngineFunc is the signature for a deterministic rule engine.
|
||||
// It receives upstream data (same JSON the AI would get) and the step name,
|
||||
// and returns the same AIResponse format the AI would produce.
|
||||
type RuleEngineFunc func(upstream map[string]interface{}, stepName string) (*AIResponse, error)
|
||||
|
||||
// RuleEngines is a registry of skill-target → rule-engine mappings.
|
||||
// Populated by the rules/ package init().
|
||||
var RuleEngines = map[string]RuleEngineFunc{}
|
||||
|
||||
// RegisterRuleEngine registers a rule engine function for a given skill target.
|
||||
// Called by the rules package during init().
|
||||
func RegisterRuleEngine(target string, fn RuleEngineFunc) {
|
||||
RuleEngines[target] = fn
|
||||
}
|
||||
|
||||
// ErrNoRuleEngine is returned when no rule engine is registered for a target.
|
||||
func ErrNoRuleEngine(target string) error {
|
||||
return fmt.Errorf("no rule engine registered for skill target %q", target)
|
||||
}
|
||||
|
||||
// StepType classifies what mechanism executes a step.
|
||||
type StepType string
|
||||
|
||||
const (
|
||||
StepTypeSkill StepType = "skill"
|
||||
StepTypeCommand StepType = "command"
|
||||
StepTypeAPI StepType = "api"
|
||||
)
|
||||
|
||||
// StepDef defines a single step in a workflow.
|
||||
//
|
||||
// skill: Target = "gitlink-triage" → AI Agent reads the Skill doc
|
||||
// command: Target = "issue +list --state open" → CLI subprocess
|
||||
// api: Target = "{v1}/issues" → HTTP call, Method = GET/POST/...
|
||||
type StepDef struct {
|
||||
Type StepType `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Purpose string `json:"purpose"`
|
||||
Target string `json:"target"`
|
||||
DependsOn []string `json:"depends_on,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Query url.Values `json:"-"`
|
||||
}
|
||||
|
||||
// TriggerDef configures when a workflow runs.
|
||||
type TriggerDef struct {
|
||||
Type string `json:"type"` // "manual" | "poll" | "cron"
|
||||
On string `json:"on"` // event description or cron expression
|
||||
Interval string `json:"interval,omitempty"` // poll: "5m" cron: "0 9 * * 1"
|
||||
}
|
||||
|
||||
// WorkflowDef is a named, ordered sequence of steps with a trigger.
|
||||
type WorkflowDef struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Trigger TriggerDef `json:"trigger"`
|
||||
Steps []StepDef `json:"steps"`
|
||||
}
|
||||
|
||||
// AIAction is a write instruction returned by an AI skill step.
|
||||
type AIAction struct {
|
||||
Type string `json:"type"` // "api" | "cli"
|
||||
Method string `json:"method,omitempty"` // api: GET/PATCH/POST
|
||||
Path string `json:"path,omitempty"` // api: /v1/{owner}/{repo}/issues/7
|
||||
Body map[string]interface{} `json:"body,omitempty"` // api: request body
|
||||
Module string `json:"module,omitempty"` // cli: "issue"
|
||||
Command string `json:"command,omitempty"` // cli: "+comment"
|
||||
Args map[string]string `json:"args,omitempty"` // cli: {"number":"10"}
|
||||
}
|
||||
|
||||
// WorkflowState tracks persistent run state and change detection snapshots.
|
||||
type WorkflowState struct {
|
||||
Workflow string `json:"workflow"`
|
||||
LastRun string `json:"last_run"`
|
||||
TotalRuns int `json:"total_runs"`
|
||||
Snapshots map[string]string `json:"snapshots"` // stepName → md5(json)
|
||||
}
|
||||
|
|
@ -1,394 +0,0 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- Registry ---
|
||||
|
||||
var registry = map[string]*WorkflowDef{}
|
||||
|
||||
func register(wf *WorkflowDef) {
|
||||
registry[wf.Name] = wf
|
||||
}
|
||||
|
||||
// All returns all registered workflows sorted by name.
|
||||
func All() []*WorkflowDef {
|
||||
names := make([]string, 0, len(registry))
|
||||
for n := range registry {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
result := make([]*WorkflowDef, len(names))
|
||||
for i, n := range names {
|
||||
result[i] = registry[n]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Get returns a workflow by name, or nil.
|
||||
func Get(name string) *WorkflowDef {
|
||||
return registry[name]
|
||||
}
|
||||
|
||||
// --- CLI Commands ---
|
||||
|
||||
// Shortcuts returns all workflow CLI commands.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List available workflows",
|
||||
Flags: []common.Flag{
|
||||
{Name: "category", Short: "c", Usage: "Filter by category", Default: ""},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
cat := ctx.Arg("category")
|
||||
workflows := All()
|
||||
filtered := make([]*WorkflowDef, 0)
|
||||
for _, w := range workflows {
|
||||
if cat == "" || strings.EqualFold(w.Category, cat) {
|
||||
filtered = append(filtered, w)
|
||||
}
|
||||
}
|
||||
type listItem struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
StepCount int `json:"step_count"`
|
||||
}
|
||||
items := make([]listItem, len(filtered))
|
||||
for i, w := range filtered {
|
||||
items[i] = listItem{
|
||||
Name: w.Name,
|
||||
Category: w.Category,
|
||||
Description: w.Description,
|
||||
StepCount: len(w.Steps),
|
||||
}
|
||||
}
|
||||
return ctx.OutputData(items)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "info",
|
||||
Description: "Show workflow detail (steps and trigger)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
return ctx.OutputData(wf)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "run",
|
||||
Description: "Execute a workflow manually",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview mode (no AI calls)", Bool: true},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
{Name: "daemon-loop", Usage: "Internal: run in loop mode", Bool: true},
|
||||
{Name: "interval", Usage: "Internal: loop interval", Default: "5m"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
|
||||
aiMode, err := resolveAIModeFromArgs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Daemon loop mode (internal — forked by +start)
|
||||
if ctx.Arg("daemon-loop") == "true" {
|
||||
intervalStr := ctx.Arg("interval")
|
||||
interval, err := time.ParseDuration(intervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
return DaemonLoop(ctx, wf, interval)
|
||||
}
|
||||
|
||||
return runWorkflowCommand(ctx, wf, ctx.Arg("dry-run") == "true", aiMode)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "init",
|
||||
Description: "Run project one-click initialization workflow",
|
||||
Flags: []common.Flag{
|
||||
{Name: "dry-run", Usage: "Preview initialization without AI calls", Bool: true},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
wf := Get("project-init")
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", "project-init")
|
||||
}
|
||||
|
||||
aiMode, err := resolveAIModeFromArgs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runWorkflowCommand(ctx, wf, ctx.Arg("dry-run") == "true", aiMode)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "watch",
|
||||
Description: "Poll for changes and trigger workflow on delta",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 30s, 5m, 1h)", Default: "5m"},
|
||||
{Name: "step", Short: "s", Usage: "Step name to watch for changes (default: first step)", Default: ""},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
intervalStr := ctx.Arg("interval")
|
||||
interval, err := time.ParseDuration(intervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
|
||||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||
if modeErr != nil {
|
||||
return modeErr
|
||||
}
|
||||
ctx.AIMode = aiMode
|
||||
|
||||
return Watch(ctx, wf, interval, ctx.Arg("step"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "schedule",
|
||||
Description: "Run a workflow on a repeating schedule",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "interval", Short: "i", Usage: "Run interval (e.g. 1h, 24h)", Default: "24h"},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
intervalStr := ctx.Arg("interval")
|
||||
interval, err := time.ParseDuration(intervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
|
||||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||
if modeErr != nil {
|
||||
return modeErr
|
||||
}
|
||||
ctx.AIMode = aiMode
|
||||
|
||||
return Schedule(ctx, wf, interval)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "start",
|
||||
Description: "Start workflow as background daemon",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 5m, 1h)", Default: "5m"},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
intervalStr := ctx.Arg("interval")
|
||||
interval, err := time.ParseDuration(intervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
|
||||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||
if modeErr != nil {
|
||||
return modeErr
|
||||
}
|
||||
|
||||
return StartDaemon(ctx, wf, interval, aiMode)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "stop",
|
||||
Description: "Stop workflow daemon",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return StopDaemon(name)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "status",
|
||||
Description: "Show daemon status",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return StatusDaemon(name)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "logs",
|
||||
Description: "View daemon log output",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "follow", Short: "f", Usage: "Follow log output (like tail -f)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tailDaemonLog(name, ctx.Arg("follow") == "true")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "install-systemd",
|
||||
Description: "Generate systemd service unit for a workflow daemon",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "interval", Short: "i", Usage: "Poll interval", Default: "5m"},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
|
||||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||
if modeErr != nil {
|
||||
return modeErr
|
||||
}
|
||||
|
||||
return installSystemdUnit(ctx, wf, ctx.Arg("interval"), aiMode)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveAIModeFromArgs(ctx *common.RuntimeContext) (string, error) {
|
||||
ai := ctx.Arg("ai") == "true"
|
||||
noAI := ctx.Arg("no-ai") == "true"
|
||||
if ai && noAI {
|
||||
return "", fmt.Errorf("--ai 和 --no-ai 互斥,只能指定其中一个")
|
||||
}
|
||||
if ai {
|
||||
return "ai", nil
|
||||
}
|
||||
if noAI {
|
||||
return "no-ai", nil
|
||||
}
|
||||
return "auto", nil
|
||||
}
|
||||
|
||||
func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) error {
|
||||
result, err := RunWithMode(ctx, wf, dryRun, aiMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
needsAI := 0
|
||||
ruleEngine := 0
|
||||
for _, sr := range result.Steps {
|
||||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||
if v, _ := m["_needs_ai"]; v == true {
|
||||
needsAI++
|
||||
}
|
||||
if v, _ := m["_ai_used"]; v == true {
|
||||
ruleEngine++ // AI was used
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if needsAI > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\n⚠ %d 个 skill 步骤需要 AI 处理:\n", needsAI)
|
||||
for _, sr := range result.Steps {
|
||||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||
if v, _ := m["_needs_ai"]; v == true {
|
||||
fmt.Fprintf(os.Stderr, " - %s (%s)\n", sr.Step, m["_skill"])
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n你可以:\n")
|
||||
fmt.Fprintf(os.Stderr, " 1. 配置 API Key 启用全自动: gitlink-cli config set anthropic_api_key <key>\n")
|
||||
fmt.Fprintf(os.Stderr, " 2. 将以上完整 JSON 输出交给 AI Agent 继续处理\n")
|
||||
} else if ruleEngine > 0 {
|
||||
fmt.Fprintf(os.Stderr, "🤖 AI 已处理 %d 个 skill 步骤\n", ruleEngine)
|
||||
} else {
|
||||
noAI := 0
|
||||
for _, sr := range result.Steps {
|
||||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||
if v, _ := m["_ai_used"]; v == false {
|
||||
if _, hasSkill := m["_skill"]; hasSkill {
|
||||
noAI++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if noAI > 0 {
|
||||
fmt.Fprintf(os.Stderr, "⚙️ 规则引擎已处理 %d 个 skill 步骤 (未使用 AI)\n", noAI)
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.Output(output.SuccessEnvelope(result, nil))
|
||||
}
|
||||
|
|
@ -1,565 +0,0 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
if len(registry) != 5 {
|
||||
t.Fatalf("expected 5 workflows, got %d", len(registry))
|
||||
}
|
||||
|
||||
for _, name := range []string{"community-ops", "code-quality", "project-init", "multi-repo", "contributor-growth"} {
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
t.Fatalf("workflow %q not found", name)
|
||||
}
|
||||
if len(wf.Steps) == 0 {
|
||||
t.Fatalf("workflow %q has no steps", name)
|
||||
}
|
||||
if wf.Trigger.Type == "" {
|
||||
t.Fatalf("workflow %q has no trigger.type", name)
|
||||
}
|
||||
}
|
||||
|
||||
all := All()
|
||||
if len(all) != 5 {
|
||||
t.Fatalf("All() returned %d workflows, expected 5", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNonexistent(t *testing.T) {
|
||||
if Get("nonexistent") != nil {
|
||||
t.Fatal("expected nil for nonexistent workflow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutsCount(t *testing.T) {
|
||||
sc := Shortcuts()
|
||||
if len(sc) != 11 {
|
||||
t.Fatalf("expected 11 shortcuts (list, info, run, init, watch, schedule, start, stop, status, logs, install-systemd), got %d", len(sc))
|
||||
}
|
||||
names := map[string]bool{
|
||||
"list": false, "info": false, "run": false, "init": false, "watch": false,
|
||||
"schedule": false, "start": false, "stop": false, "status": false,
|
||||
"logs": false, "install-systemd": false,
|
||||
}
|
||||
for _, s := range sc {
|
||||
if _, ok := names[s.Name]; !ok {
|
||||
t.Fatalf("unexpected shortcut: %s", s.Name)
|
||||
}
|
||||
names[s.Name] = true
|
||||
}
|
||||
for n, found := range names {
|
||||
if !found {
|
||||
t.Fatalf("missing shortcut: %s", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectInitShortcut(t *testing.T) {
|
||||
var initShortcut *common.Shortcut
|
||||
for _, s := range Shortcuts() {
|
||||
if s.Name == "init" {
|
||||
initShortcut = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if initShortcut == nil {
|
||||
t.Fatal("missing init shortcut")
|
||||
}
|
||||
if initShortcut.Description == "" {
|
||||
t.Fatal("init shortcut should have a description")
|
||||
}
|
||||
if len(initShortcut.Flags) != 3 {
|
||||
t.Fatalf("init shortcut should have 3 flags (dry-run, ai, no-ai), got %d: %+v", len(initShortcut.Flags), initShortcut.Flags)
|
||||
}
|
||||
hasDryRun := false
|
||||
for _, f := range initShortcut.Flags {
|
||||
if f.Name == "dry-run" && f.Bool {
|
||||
hasDryRun = true
|
||||
}
|
||||
}
|
||||
if !hasDryRun {
|
||||
t.Fatal("init shortcut should expose bool --dry-run flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
template, owner, repo, expected string
|
||||
}{
|
||||
{"{v1}/issues", "chroe", "gitlink-cli", "/v1/chroe/gitlink-cli/issues"},
|
||||
{"{base}/pulls", "chroe", "gitlink-cli", "/chroe/gitlink-cli/pulls"},
|
||||
{"{base}", "org", "proj", "/org/proj"},
|
||||
{"{v1}/issues?state=open", "x", "y", "/v1/x/y/issues?state=open"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := resolvePath(tc.template, tc.owner, tc.repo)
|
||||
if got != tc.expected {
|
||||
t.Fatalf("resolvePath(%q, %s, %s) = %q, want %q", tc.template, tc.owner, tc.repo, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggers(t *testing.T) {
|
||||
expected := map[string]struct {
|
||||
on string
|
||||
typ string
|
||||
}{
|
||||
"community-ops": {"issue.created", "poll"},
|
||||
"code-quality": {"pr.opened", "poll"},
|
||||
"project-init": {"manual", "manual"},
|
||||
"multi-repo": {"0 9 * * 1", "cron"},
|
||||
"contributor-growth": {"0 9 * * 1", "cron"},
|
||||
}
|
||||
for name, want := range expected {
|
||||
wf := Get(name)
|
||||
if wf.Trigger.On != want.on {
|
||||
t.Fatalf("%s: trigger.on = %q, want %q", name, wf.Trigger.On, want.on)
|
||||
}
|
||||
if wf.Trigger.Type != want.typ {
|
||||
t.Fatalf("%s: trigger.type = %q, want %q", name, wf.Trigger.Type, want.typ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepTypes(t *testing.T) {
|
||||
wf := Get("community-ops")
|
||||
if wf == nil {
|
||||
t.Fatal("community-ops not found")
|
||||
}
|
||||
|
||||
typeCounts := map[StepType]int{}
|
||||
for _, s := range wf.Steps {
|
||||
typeCounts[s.Type]++
|
||||
}
|
||||
if typeCounts[StepTypeCommand] < 1 {
|
||||
t.Fatal("community-ops should have at least one command step")
|
||||
}
|
||||
if typeCounts[StepTypeSkill] < 1 {
|
||||
t.Fatal("community-ops should have at least one skill step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillStepDependsOn(t *testing.T) {
|
||||
wf := Get("community-ops")
|
||||
if wf == nil {
|
||||
t.Fatal("community-ops not found")
|
||||
}
|
||||
|
||||
var triage *StepDef
|
||||
for i := range wf.Steps {
|
||||
if wf.Steps[i].Name == "triage" {
|
||||
triage = &wf.Steps[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if triage == nil {
|
||||
t.Fatal("triage step not found")
|
||||
}
|
||||
if len(triage.DependsOn) != 3 {
|
||||
t.Fatalf("triage step should have 3 dependencies, got %d: %v", len(triage.DependsOn), triage.DependsOn)
|
||||
}
|
||||
expectedDeps := map[string]bool{"open-issues": false, "labels": false, "members": false}
|
||||
for _, dep := range triage.DependsOn {
|
||||
if _, ok := expectedDeps[dep]; !ok {
|
||||
t.Fatalf("unexpected dependency: %s", dep)
|
||||
}
|
||||
expectedDeps[dep] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{"issue +list --state open", []string{"issue", "+list", "--state", "open"}},
|
||||
{"repo +info", []string{"repo", "+info"}},
|
||||
{"pr +list --state merged --limit 50", []string{"pr", "+list", "--state", "merged", "--limit", "50"}},
|
||||
{"issue +list --state open --limit 50", []string{"issue", "+list", "--state", "open", "--limit", "50"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := parseCommandTarget(tc.input)
|
||||
if len(got) != len(tc.expected) {
|
||||
t.Fatalf("parseCommandTarget(%q): len=%d, want len=%d (got=%v)", tc.input, len(got), len(tc.expected), got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.expected[i] {
|
||||
t.Fatalf("parseCommandTarget(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Security whitelist tests ---
|
||||
|
||||
func TestActionAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
action AIAction
|
||||
allowed bool
|
||||
}{
|
||||
{"api GET", AIAction{Type: "api", Method: "GET"}, true},
|
||||
{"api POST", AIAction{Type: "api", Method: "POST"}, true},
|
||||
{"api PATCH", AIAction{Type: "api", Method: "PATCH"}, true},
|
||||
{"api DELETE blocked", AIAction{Type: "api", Method: "DELETE"}, false},
|
||||
{"cli issue comment", AIAction{Type: "cli", Module: "issue", Command: "+comment"}, true},
|
||||
{"cli delete blocked", AIAction{Type: "cli", Module: "repo", Command: "+delete"}, false},
|
||||
{"cli fork blocked", AIAction{Type: "cli", Module: "repo", Command: "+fork"}, false},
|
||||
{"cli repo module blocked", AIAction{Type: "cli", Module: "org", Command: "+list"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isActionAllowed(tc.action); got != tc.allowed {
|
||||
t.Errorf("isActionAllowed(%+v) = %v, want %v", tc.action, got, tc.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- State tests ---
|
||||
|
||||
func TestStateSaveLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
s := &WorkflowState{
|
||||
Workflow: "test-wf",
|
||||
TotalRuns: 5,
|
||||
Snapshots: map[string]string{"step1": "abc123"},
|
||||
}
|
||||
if err := s.Save(); err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadState("test-wf")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState failed: %v", err)
|
||||
}
|
||||
if loaded.TotalRuns != 5 {
|
||||
t.Fatalf("TotalRuns = %d, want 5", loaded.TotalRuns)
|
||||
}
|
||||
if loaded.Snapshots["step1"] != "abc123" {
|
||||
t.Fatalf("Snapshots[step1] = %q, want abc123", loaded.Snapshots["step1"])
|
||||
}
|
||||
|
||||
os.Remove(filepath.Join(dir, "workflow-test-wf-state.json"))
|
||||
}
|
||||
|
||||
func TestStateDiff(t *testing.T) {
|
||||
s := &WorkflowState{
|
||||
Workflow: "test-diff",
|
||||
Snapshots: map[string]string{"step1": "oldhash"},
|
||||
}
|
||||
|
||||
results := []StepResult{
|
||||
{Step: "step1", OK: true, Data: "changed data"},
|
||||
{Step: "step2", OK: true, Data: "new step"},
|
||||
{Step: "step3", OK: false, Data: "ignored"},
|
||||
}
|
||||
|
||||
changed := s.Diff(results)
|
||||
if len(changed) != 1 {
|
||||
t.Fatalf("Diff: expected 1 changed step, got %d", len(changed))
|
||||
}
|
||||
if changed[0] != "step1" {
|
||||
t.Fatalf("Diff: expected 'step1' to change, got %q", changed[0])
|
||||
}
|
||||
if _, ok := s.Snapshots["step2"]; !ok {
|
||||
t.Fatal("step2 should be added to snapshots")
|
||||
}
|
||||
if _, ok := s.Snapshots["step3"]; ok {
|
||||
t.Fatal("step3 (failed) should NOT be added to snapshots")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStateNotExist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
s, err := LoadState("nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState should not error for missing file: %v", err)
|
||||
}
|
||||
if s.Workflow != "nonexistent" {
|
||||
t.Fatalf("Workflow = %q, want nonexistent", s.Workflow)
|
||||
}
|
||||
if s.Snapshots == nil {
|
||||
t.Fatal("Snapshots should be initialized as empty map")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Engine integration tests ---
|
||||
|
||||
func TestRunWithAPISteps(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
|
||||
{"id": 1, "subject": "bug"},
|
||||
{"id": 2, "subject": "feature"},
|
||||
}, nil))
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/labels.json":
|
||||
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
|
||||
{"id": 10, "name": "bug"},
|
||||
{"id": 11, "name": "enhancement"},
|
||||
}, nil))
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-api",
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeAPI, Name: "fetch-issues", Purpose: "get issues", Method: "GET", Target: "{v1}/issues"},
|
||||
{Type: StepTypeAPI, Name: "fetch-labels", Purpose: "get labels", Method: "GET", Target: "{v1}/labels"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() failed: %v", err)
|
||||
}
|
||||
if result.Owner != "owner" || result.Repo != "repo" {
|
||||
t.Fatalf("expected owner/repo = owner/repo, got %s/%s", result.Owner, result.Repo)
|
||||
}
|
||||
if len(result.Steps) != 2 {
|
||||
t.Fatalf("expected 2 step results, got %d", len(result.Steps))
|
||||
}
|
||||
for _, sr := range result.Steps {
|
||||
if !sr.OK {
|
||||
t.Fatalf("step %q: expected ok=true, got error=%q", sr.Step, sr.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillStepReceivesUpstream(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/owner/repo/issues.json" {
|
||||
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
|
||||
"issues": []map[string]interface{}{{"id": 1}},
|
||||
}, nil))
|
||||
} else if r.URL.Path == "/v1/owner/repo/labels.json" {
|
||||
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
|
||||
"labels": []map[string]interface{}{{"name": "bug"}},
|
||||
}, nil))
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-skill-upstream",
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeAPI, Name: "get-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
|
||||
{Type: StepTypeAPI, Name: "get-labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
|
||||
{Type: StepTypeSkill, Name: "ai-triage", Purpose: "triage", Target: "gitlink-triage"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() failed: %v", err)
|
||||
}
|
||||
|
||||
skillData, ok := result.Steps[2].Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step data is not a map")
|
||||
}
|
||||
upstream, ok := skillData["_upstream"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step missing _upstream map")
|
||||
}
|
||||
if _, hasIssues := upstream["get-issues"]; !hasIssues {
|
||||
t.Fatal("_upstream missing get-issues key")
|
||||
}
|
||||
if _, hasLabels := upstream["get-labels"]; !hasLabels {
|
||||
t.Fatal("_upstream missing get-labels key")
|
||||
}
|
||||
if skillData["_skill"] != "gitlink-triage" {
|
||||
t.Fatalf("_skill = %q, want %q", skillData["_skill"], "gitlink-triage")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillStepWithDependsOn verifies that when DependsOn is set,
|
||||
// only those specific upstream steps are collected.
|
||||
func TestSkillStepWithDependsOn(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-depends-on",
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeAPI, Name: "open-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
|
||||
{Type: StepTypeAPI, Name: "labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
|
||||
{Type: StepTypeAPI, Name: "members", Purpose: "members", Method: "GET", Target: "{v1}/members"},
|
||||
{Type: StepTypeSkill, Name: "triage", Purpose: "triage", Target: "gitlink-triage",
|
||||
DependsOn: []string{"open-issues", "labels"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() failed: %v", err)
|
||||
}
|
||||
|
||||
skillData, ok := result.Steps[3].Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step data is not a map")
|
||||
}
|
||||
upstream, ok := skillData["_upstream"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step missing _upstream map")
|
||||
}
|
||||
if _, hasIssues := upstream["open-issues"]; !hasIssues {
|
||||
t.Fatal("_upstream missing open-issues key")
|
||||
}
|
||||
if _, hasLabels := upstream["labels"]; !hasLabels {
|
||||
t.Fatal("_upstream missing labels key")
|
||||
}
|
||||
if _, hasMembers := upstream["members"]; hasMembers {
|
||||
t.Fatal("_upstream should NOT contain members (not in DependsOn)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStepFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"ok": false, "error": "internal server error",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-fail",
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeAPI, Name: "bad-step", Purpose: "will fail", Method: "GET", Target: "{v1}/bad"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() returned error: %v (steps should fail gracefully)", err)
|
||||
}
|
||||
if result.Steps[0].OK {
|
||||
t.Fatal("expected step to fail, but it passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnknownStepType(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("no request expected")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-unknown",
|
||||
Steps: []StepDef{
|
||||
{Type: StepType("invalid"), Name: "bad", Purpose: "unknown", Target: "x"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wf, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() returned error: %v", err)
|
||||
}
|
||||
if result.Steps[0].OK {
|
||||
t.Fatal("unknown step type should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeQualityHasReviewStep(t *testing.T) {
|
||||
wf := Get("code-quality")
|
||||
if wf == nil {
|
||||
t.Fatal("code-quality not found")
|
||||
}
|
||||
if len(wf.Steps) < 7 {
|
||||
t.Fatalf("code-quality should have at least 7 steps (including review), got %d", len(wf.Steps))
|
||||
}
|
||||
found := false
|
||||
for _, s := range wf.Steps {
|
||||
if s.Target == "gitlink-review" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("code-quality missing gitlink-review skill step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillStepDryRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := newTestContext(t, server)
|
||||
wf := &WorkflowDef{
|
||||
Name: "test-dry-run",
|
||||
Steps: []StepDef{
|
||||
{Type: StepTypeAPI, Name: "get-data", Purpose: "data", Method: "GET", Target: "{v1}/issues"},
|
||||
{Type: StepTypeSkill, Name: "ai-step", Purpose: "AI analysis", Target: "gitlink-triage",
|
||||
DependsOn: []string{"get-data"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(ctx, wf, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() dry-run failed: %v", err)
|
||||
}
|
||||
|
||||
skillData, ok := result.Steps[1].Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("skill step data is not a map")
|
||||
}
|
||||
if v, _ := skillData["_dry_run"]; v != true {
|
||||
t.Fatal("dry-run skill step should have _dry_run=true")
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func newTestContext(t *testing.T, server *httptest.Server) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
return &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
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("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# Stage 1: Build
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build gitlink-cli binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o gitlink-cli .
|
||||
|
||||
# Build showcase server binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o showcase-server ./showcase/
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache ca-certificates git
|
||||
|
||||
RUN mkdir -p /root/.config/gitlink-cli
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/gitlink-cli .
|
||||
COPY --from=builder /build/showcase-server .
|
||||
|
||||
EXPOSE 9090
|
||||
|
||||
CMD ["./showcase-server"]
|
||||
1014
showcase/index.html
1014
showcase/index.html
File diff suppressed because it is too large
Load Diff
149
showcase/main.go
149
showcase/main.go
|
|
@ -1,149 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var indexHTML []byte
|
||||
|
||||
type RunResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Command string `json:"command"`
|
||||
Output interface{} `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "9090"
|
||||
}
|
||||
cliBin := findCLIBinary()
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(indexHTML)
|
||||
})
|
||||
|
||||
http.HandleFunc("/api/run", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
module := r.URL.Query().Get("module")
|
||||
command := r.URL.Query().Get("command")
|
||||
owner := r.URL.Query().Get("owner")
|
||||
repo := r.URL.Query().Get("repo")
|
||||
format := r.URL.Query().Get("format")
|
||||
extraArgs := r.URL.Query().Get("args")
|
||||
|
||||
if module == "" || command == "" {
|
||||
json.NewEncoder(w).Encode(RunResult{Error: "missing module or command"})
|
||||
return
|
||||
}
|
||||
if owner == "" {
|
||||
owner = "chroe"
|
||||
}
|
||||
if repo == "" {
|
||||
if module == "wiki" {
|
||||
repo = "gitlink_help_center"
|
||||
} else {
|
||||
repo = "gitlink-cli"
|
||||
}
|
||||
}
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
args := []string{module, "+" + command, "--owner", owner, "--repo", repo, "--format", format}
|
||||
if extraArgs != "" {
|
||||
args = append(args, parseShellArgs(extraArgs)...)
|
||||
}
|
||||
|
||||
cmdStr := "gitlink-cli " + strings.Join(args, " ")
|
||||
log.Printf("Running: %s", cmdStr)
|
||||
|
||||
cmd := exec.Command(cliBin, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
result := RunResult{
|
||||
Command: cmdStr,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Error = strings.TrimSpace(string(output))
|
||||
result.Output = nil
|
||||
} else {
|
||||
result.OK = true
|
||||
var parsed interface{}
|
||||
if json.Unmarshal(output, &parsed) == nil {
|
||||
result.Output = parsed
|
||||
} else {
|
||||
result.Output = strings.TrimSpace(string(output))
|
||||
}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
|
||||
fmt.Printf("Showcase Dashboard running at http://localhost:%s\n", port)
|
||||
log.Fatal(http.ListenAndServe(":"+port, nil))
|
||||
}
|
||||
|
||||
// parseShellArgs splits a shell-style argument string, respecting quoted values.
|
||||
// e.g. `--content "Hello Wiki!" --message "create page"` -> ["--content", "Hello Wiki!", "--message", "create page"]
|
||||
func parseShellArgs(s string) []string {
|
||||
var args []string
|
||||
var current strings.Builder
|
||||
inQuote := false
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if ch == '"' {
|
||||
inQuote = !inQuote
|
||||
continue
|
||||
}
|
||||
if ch == ' ' && !inQuote {
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
continue
|
||||
}
|
||||
current.WriteByte(ch)
|
||||
}
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func findCLIBinary() string {
|
||||
exe, _ := os.Executable()
|
||||
exeDir := filepath.Dir(exe)
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(exeDir, "gitlink-cli.exe"),
|
||||
filepath.Join(exeDir, "gitlink-cli"),
|
||||
filepath.Join(exeDir, "..", "gitlink-cli.exe"),
|
||||
filepath.Join(exeDir, "..", "gitlink-cli"),
|
||||
"./gitlink-cli.exe",
|
||||
"./gitlink-cli",
|
||||
"../gitlink-cli.exe",
|
||||
"../gitlink-cli",
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
abs, _ := filepath.Abs(c)
|
||||
return abs
|
||||
}
|
||||
}
|
||||
return "gitlink-cli"
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
version: 2
|
||||
name: 构建部署Showcase
|
||||
description: "代码提交自动触发:在服务器上拉取代码、构建Docker镜像并部署"
|
||||
global:
|
||||
concurrent: 1
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: push
|
||||
ruleset-operator: AND
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
- ref: ssh_cmd_0
|
||||
name: SSH部署到服务器
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_pass: ((deploy_server.server_password))
|
||||
ssh_ip: '"118.31.4.168"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_cmd: >-
|
||||
"mkdir -p /opt/gitlink-cli && cd /opt/gitlink-cli && (git clone https://gitlink.org.cn/chroe/gitlink-cli.git . || git pull origin master) && docker build -f showcase/Dockerfile -t gitlink-cli-showcase . && docker stop gitlink-cli-showcase || true && docker rm gitlink-cli-showcase || true && docker run -d -p 9090:9090 --name gitlink-cli-showcase --restart unless-stopped gitlink-cli-showcase"
|
||||
needs:
|
||||
- start
|
||||
- ref: end
|
||||
name: 结束
|
||||
task: end
|
||||
needs:
|
||||
- ssh_cmd_0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue