Compare commits
168 Commits
master
...
fix/ci-det
| Author | SHA1 | Date |
|---|---|---|
|
|
40609fa0f8 | |
|
|
3a1565422c | |
|
|
b09d7d70f2 | |
|
|
111edbc346 | |
|
|
b1a5d1433f | |
|
|
ab47233e94 | |
|
|
bafa9e6364 | |
|
|
6f7af3b000 | |
|
|
6425ff6ae1 | |
|
|
b97181f05e | |
|
|
ab0cf7fe64 | |
|
|
8bfa601edf | |
|
|
d2b4e308c5 | |
|
|
fe9f4396b0 | |
|
|
57993e8bea | |
|
|
47c5083095 | |
|
|
b777088ea3 | |
|
|
c9530a0929 | |
|
|
aea30485a3 | |
|
|
a8d5dd5349 | |
|
|
a429ff1505 | |
|
|
43348a195f | |
|
|
09baf7f44f | |
|
|
40497520e2 | |
|
|
474a54115a | |
|
|
8f19d958a8 | |
|
|
457bf66909 | |
|
|
90812b7d0c | |
|
|
059476bd7c | |
|
|
a75c281254 | |
|
|
8739b18a23 | |
|
|
283cd76bfd | |
|
|
f1e5386c43 | |
|
|
d336e5facb | |
|
|
de1d4ea2cf | |
|
|
b33267447b | |
|
|
b41a07d2fe | |
|
|
c2b667c1e2 | |
|
|
6bcfb77b58 | |
|
|
9a451f9d75 | |
|
|
9129c2f1c4 | |
|
|
13882157c2 | |
|
|
2bedb68ef7 | |
|
|
fa31e09dc4 | |
|
|
93f65a7d78 | |
|
|
d4af2e3207 | |
|
|
3f04d95631 | |
|
|
46d16e21a2 | |
|
|
148ce89c2b | |
|
|
4d56487520 | |
|
|
30336c69ef | |
|
|
74f6aea0e9 | |
|
|
92f65a92d1 | |
|
|
f44471d173 | |
|
|
c0c7580a7e | |
|
|
393a090c0e | |
|
|
7b43711db3 | |
|
|
b0d46b42af | |
|
|
378d8f15a6 | |
|
|
3fdc615a05 | |
|
|
b46352af51 | |
|
|
33d2578f90 | |
|
|
21a3447581 | |
|
|
5866600401 | |
|
|
3703594d66 | |
|
|
42c5d11a62 | |
|
|
955e8dd2b7 | |
|
|
f1e237ad18 | |
|
|
b914138338 | |
|
|
75a8ed235a | |
|
|
e9f36c29fb | |
|
|
f248ed9d04 | |
|
|
f9efae89a3 | |
|
|
d12ce0fb57 | |
|
|
c7a2ca3685 | |
|
|
386e3c92b8 | |
|
|
612deddb57 | |
|
|
fb051af813 | |
|
|
c345b5af5d | |
|
|
c192b26da8 | |
|
|
5a5db6d298 | |
|
|
d20b3b09e1 | |
|
|
0ff97fd213 | |
|
|
f5f1a34310 | |
|
|
a8be1a8100 | |
|
|
46eff83ef5 | |
|
|
01e4515cb9 | |
|
|
fd647b7e75 | |
|
|
5c8cc3462c | |
|
|
a31b8e5b11 | |
|
|
7fa93cb3c1 | |
|
|
68e7994d08 | |
|
|
81cfcfb3e4 | |
|
|
596fb81a55 | |
|
|
84aae8554b | |
|
|
be152b9fa1 | |
|
|
5c9e24c725 | |
|
|
59240d18c5 | |
|
|
799af5f3cf | |
|
|
cafde7d2f4 | |
|
|
2aa1b2ffe4 | |
|
|
bc12b43fc0 | |
|
|
30f16b9613 | |
|
|
461b28e6c8 | |
|
|
d66c88ecca | |
|
|
295eda5a4e | |
|
|
25ee6cc5fb | |
|
|
f1dc2f8bc9 | |
|
|
b2136559bf | |
|
|
16787b8f8c | |
|
|
2596d1dc70 | |
|
|
65a4e60d6d | |
|
|
db8f3c7cae | |
|
|
76cb5be259 | |
|
|
cbbec86b49 | |
|
|
503a5aeead | |
|
|
1d4e177152 | |
|
|
d1cc68b6d9 | |
|
|
6c0ad3b7ae | |
|
|
9a51db0989 | |
|
|
a4587f95c4 | |
|
|
38658fb7b1 | |
|
|
5eb67055fd | |
|
|
dd905fb045 | |
|
|
e60e876fad | |
|
|
a115c97dde | |
|
|
66ec432036 | |
|
|
cb238f132c | |
|
|
240a4ff6b7 | |
|
|
d46a0cc48b | |
|
|
59552d9286 | |
|
|
811bae1cd5 | |
|
|
015a30caa5 | |
|
|
e3470ba592 | |
|
|
167a2ff12c | |
|
|
63cbb2cef8 | |
|
|
9f479a27db | |
|
|
f18eea273f | |
|
|
6fda83bf38 | |
|
|
92a953ba56 | |
|
|
9a2e6a3280 | |
|
|
3a47936f9f | |
|
|
15110df6f1 | |
|
|
b9638fe0b6 | |
|
|
1762a24851 | |
|
|
5ae0a134bb | |
|
|
2669c632d0 | |
|
|
3ee71a1ffc | |
|
|
87277cbf4f | |
|
|
fef44dc5e3 | |
|
|
d1e44867c3 | |
|
|
8d3d6cc5ce | |
|
|
9598142c2f | |
|
|
52dc8a9967 | |
|
|
2ddad05646 | |
|
|
44b184723e | |
|
|
c7d374ab32 | |
|
|
aebb24d51c | |
|
|
9f99247422 | |
|
|
637be3cb4b | |
|
|
4121524100 | |
|
|
c2e0b0da18 | |
|
|
a94088e4ee | |
|
|
94661010ac | |
|
|
ca7f5aacce | |
|
|
667f28a618 | |
|
|
8df76dcb7a | |
|
|
b64a5104a8 |
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
description: GitLink 操作中心 — 列出所有常用操作、端到端场景与科研辅助,上下选择并执行;也可传入仓库网址直接对其操作
|
||||
argument-hint: "[可选: GitLink 仓库网址 或 场景关键词,如 代码审查 / 健康度]"
|
||||
---
|
||||
|
||||
# GitLink 操作中心(交互式菜单)
|
||||
|
||||
你是 **GitLink 操作中心**的调度器。请严格按下列流程,为用户呈现一个可「上下方向键选择并执行」的交互式菜单,并调用对应已实现的 skill 完成操作。
|
||||
|
||||
> **铁律**:所有 GitLink 操作必须用 `gitlink-cli`,禁止用 `gh`。所有写操作默认 dry-run,确认后再实际执行。
|
||||
|
||||
---
|
||||
|
||||
## 第 1 步:解析目标仓库(TARGET)
|
||||
|
||||
检查命令参数 `$ARGUMENTS`:
|
||||
|
||||
1. 若包含 GitLink 网址(形如 `https://gitlink.org.cn/<owner>/<repo>`,可能带 `.git`、`/pulls/<n>`、`/issues/<n>`、`/compare/...` 等后缀),用正则 `gitlink\.org\.cn/([^/]+)/([^/?.#]+)` 提取 `<owner>` 和 `<repo>`(去掉末尾 `.git`)→ 记为 **TARGET = owner/repo**。
|
||||
2. 否则若形如 `<owner>/<repo>`(如 `z2_cc/gitlink-cli`),直接作为 TARGET。
|
||||
3. 否则若当前处于一个 git 仓库且其 remote 指向 GitLink,自动解析 owner/repo 作为 TARGET(可运行 `git remote -v` 判断)。
|
||||
4. 否则 **TARGET = 空**(后续按需向用户询问)。
|
||||
|
||||
**关键词直跳**:若 `$ARGUMENTS` 命中下方「映射表」中的任一场景关键词(代码审查 / Issue 分拣 / Release / 健康度 / 合规 / 新人引导 / 社区运营 / 质量门禁 / 项目初始化 / 多仓库 / 贡献者 / 科研洞悉 / 科研热点 / 知识图谱 / 科研合规 / 复现性 / 科研协作匹配 / 科研进度 / 预警),**跳过第 2 步菜单**,直接进入第 3 步执行该场景(TARGET 仍按上面规则解析)。
|
||||
|
||||
---
|
||||
|
||||
## 第 2 步:呈现菜单(无关键词时)
|
||||
|
||||
### 2.1 先在对话中「列出所有」可选项
|
||||
|
||||
用中文打印下面这张完整目录(让用户一眼看到全部 16 项 + 仓库定位),格式如下:
|
||||
|
||||
```
|
||||
🧭 GitLink 操作中心 — 请选择要执行的操作(也可直接输入序号 / 关键词 / 仓库网址)
|
||||
|
||||
🔍 单项操作
|
||||
1 智能代码审查 分析 PR diff,输出结构化 Review 意见并自动评论
|
||||
2 Issue 自动分拣 根据内容自动分类、打标签、分配责任人
|
||||
3 Release Notes 生成 根据 commit / PR 记录生成结构化版本说明
|
||||
4 新人引导 为 good-first-issue 自动添加引导评论
|
||||
|
||||
📊 分析与成长
|
||||
5 项目健康度报告 统计 Issue 响应时间、PR 合并效率、贡献者活跃度
|
||||
6 许可证合规检查 扫描许可证合规性与敏感信息泄露风险
|
||||
7 贡献者成长体系 追踪 PR/Issue 活动 → 生成排行 → 自动颁发徽章
|
||||
|
||||
🚀 端到端自动化场景
|
||||
8 社区运营自动化 Issue 分拣 → 周报 → 自动发布 Release
|
||||
9 代码质量看门人 PR 审查 → CI → 门禁决策 → 自动合并
|
||||
10 项目一键初始化 创建仓库 → 生成 README/LICENSE/CI → 初始 Issue/里程碑
|
||||
11 多仓库协同 跨仓库 Issue 追踪 / PR 看板 / Release 协调发布
|
||||
|
||||
🔬 科研辅助(面向科研工作者 / 课题组,打通开源生态与学术科研)
|
||||
12 科研项目洞悉 8 维数据采集 + AI 分析(成熟度/文档/贡献模式/社区健康)→ 结构化评分报告
|
||||
13 科研热点与知识图谱 多关键词搜索 → 实体/关系抽取 → Mermaid 知识图谱 + 交互式 HTML
|
||||
14 科研合规与复现性 数据可用性 / 环境可复现 / 结果可验证 多维评估
|
||||
15 科研协作智能匹配 角色(数据/算法/工程/评估)推断 → 人员-任务匹配 → 综合建议
|
||||
16 科研进度跟踪与预警 6 维风险信号(停滞/高优无人/零回复/积压/提交突变/里程碑 DDL)+ 100 分制评分
|
||||
|
||||
🌐 也可以:粘贴一个 GitLink 仓库网址(https://gitlink.org.cn/<owner>/<repo>),我对它执行上述任一操作。
|
||||
```
|
||||
|
||||
### 2.2 再用 AskUserQuestion 弹出原生「上下选择」菜单
|
||||
|
||||
调用一次 `AskUserQuestion`,header 为「操作类别」,提供 4 个选项(用户可用方向键上下选择,也可选「Other」直接输入序号/关键词/网址):
|
||||
|
||||
| 选项 | 描述 |
|
||||
|---|---|
|
||||
| 🔍 单项操作 | 代码审查 / Issue 分拣 / Release Notes / 新人引导 |
|
||||
| 📊 分析与成长 | 项目健康度 / 许可证合规 / 贡献者成长 |
|
||||
| 🚀 端到端自动化 | 社区运营 / 质量门禁 / 项目初始化 / 多仓库协同 |
|
||||
| 🔬 科研辅助 | 项目洞悉 / 热点与知识图谱 / 合规复现 / 协作匹配 / 进度预警 |
|
||||
|
||||
- 若用户选了 🔍 / 📊 / 🚀 三类(每类 ≤4 项):再调用一次 `AskUserQuestion` 列出该类别下具体操作(每项描述一句话),让用户上下选择。
|
||||
- 若用户选 🔬 科研辅助(共 5 项,超过单次 4 项上限):先把 5 项编号打印,再用 `AskUserQuestion` 列出其中 4 项,并提示「可选 Other 输入第 5 项序号」;用户也可直接回复 1-5 序号。
|
||||
- 若用户在任何一步选「Other」输入了仓库网址 / `owner/repo`:按第 1 步规则解析为 TARGET,再继续选操作。
|
||||
- 若用户选「Other」直接输入了序号或关键词:按映射表直接定位操作,进入第 3 步。
|
||||
|
||||
---
|
||||
|
||||
## 第 3 步:执行对应 skill
|
||||
|
||||
### 操作 → skill 映射表
|
||||
|
||||
| 序号 | 菜单项 | 调用的 skill |
|
||||
|---|---|---|
|
||||
| 1 | 智能代码审查 | `gitlink-code-review` |
|
||||
| 2 | Issue 自动分拣 | `gitlink-issue-triage` |
|
||||
| 3 | Release Notes 生成 | `gitlink-release-auto` |
|
||||
| 4 | 新人引导 | `gitlink-newcomer-guide` |
|
||||
| 5 | 项目健康度报告 | `gitlink-project-health` |
|
||||
| 6 | 许可证合规检查 | `gitlink-compliance` |
|
||||
| 7 | 贡献者成长体系 | `gitlink-contributor-growth` |
|
||||
| 8 | 社区运营自动化 | `gitlink-community-ops` |
|
||||
| 9 | 代码质量看门人 | `gitlink-quality-gate` |
|
||||
| 10 | 项目一键初始化 | `gitlink-project-bootstrap` |
|
||||
| 11 | 多仓库协同 | `gitlink-multi-repo-coordination` |
|
||||
| 12 | 科研项目洞悉 | `gitlink-research-insight` |
|
||||
| 13 | 科研热点与知识图谱 | `gitlink-research-hotspot` |
|
||||
| 14 | 科研合规与复现性 | `gitlink-research-compliance` |
|
||||
| 15 | 科研协作智能匹配 | `gitlink-research-matching` |
|
||||
| 16 | 科研进度跟踪与预警 | `gitlink-research-progress` |
|
||||
|
||||
> 这些 skill 都在当前项目(`Desktop/gitlink-cl`)下。用 `Skill` 工具按上表名字调用即可。
|
||||
|
||||
执行规则:
|
||||
1. **若 TARGET 仍为空**,先用 `AskUserQuestion` 询问「请提供目标 GitLink 仓库(owner/repo 或网址)」。
|
||||
2. 调用对应 skill,把 TARGET 的 owner/repo 传达给它(skill 内部用 `--owner/--repo` 或环境变量;端到端场景脚本形如 `bash skills/<skill>/scripts/*-full.sh <owner> <repo>`)。
|
||||
3. **写操作一律先 dry-run**(`DRY_RUN=true`),把将要执行的变更展示给用户,经确认后再 `DRY_RUN=false` 实际执行。
|
||||
4. 操作完成后,用中文给出简洁结果摘要(做了什么 / 关键数据 / 产物路径)。
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 多仓库协同(11)支持 `REPOS="owner1/repo1,owner2/repo2"` 同时处理多个仓库。
|
||||
- **科研辅助(12-16)**均为 **evidence-oriented / read-only**(只读分析、不改仓库),可放心对任意公开 GitLink 仓库运行;产物多为结构化报告 / 知识图谱 / 交互式 HTML(位于各 skill 的 `scripts/_output/`)。`gitlink-research-matching` 已验证于 `Gitlink/forgeplus`,`research-progress` / `research-compliance` 已验证于 `forgeplus`、`gitlink-cli`、`ifzhang/FairMOT`。
|
||||
- 若 `gitlink-cli` 未登录,先提示用户运行 `gitlink-cli auth login`(或调用 `gitlink-shared` skill)。
|
||||
- 用户随时可输入「返回」回到主菜单,或「退出」结束。
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
version: 2
|
||||
name: 代码质量门禁流水线
|
||||
description: "PR 触发:代码审查 → CI 检查 → 门禁决策 → 自动合并或反馈"
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: pull_request
|
||||
ruleset-operator: AND
|
||||
global:
|
||||
concurrent: 1
|
||||
param:
|
||||
- ref: remote_url
|
||||
name: ""
|
||||
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
|
||||
required: false
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: owner
|
||||
name: "仓库所有者"
|
||||
value: '"z2_cc"'
|
||||
required: true
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: repo
|
||||
name: "仓库名称"
|
||||
value: '"gitlink-cli"'
|
||||
required: true
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: dry_run
|
||||
name: "Dry-Run 模式"
|
||||
value: '"true"'
|
||||
required: false
|
||||
type: STRING
|
||||
hidden: false
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
- ref: ssh_cmd_0
|
||||
name: 运行代码质量门禁
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_pass: ((work_together.ssh_key))
|
||||
ssh_ip: '"121.41.216.243"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_cmd: '"cd /root/gitlink-cli && git pull origin master && DRY_RUN=$(dry_run) bash skills/gitlink-quality-gate/scripts/quality-gate-full.sh z2_cc gitlink-cli"'
|
||||
needs:
|
||||
- start
|
||||
- ref: end
|
||||
name: 结束
|
||||
task: end
|
||||
needs:
|
||||
- ssh_cmd_0
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
version: 2
|
||||
name: 多仓库协同流水线
|
||||
description: "定时运行:多仓库 Issue 追踪 → PR 看板 → Release 协调发布"
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: schedule
|
||||
ruleset-operator: AND
|
||||
global:
|
||||
concurrent: 1
|
||||
param:
|
||||
- ref: remote_url
|
||||
name: ""
|
||||
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
|
||||
required: false
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: owner
|
||||
name: "仓库所有者"
|
||||
value: '"z2_cc"'
|
||||
required: true
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: repo
|
||||
name: "仓库名称"
|
||||
value: '"gitlink-cli"'
|
||||
required: true
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: repos
|
||||
name: "多仓库列表(逗号分隔 owner/repo)"
|
||||
value: '"z2_cc/gitlink-cli,z2_cc/gitlink-bisync"'
|
||||
required: false
|
||||
type: STRING
|
||||
hidden: false
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
- ref: ssh_cmd_0
|
||||
name: 运行多仓库协同流水线
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_pass: ((work_together.ssh_key))
|
||||
ssh_ip: '"121.41.216.243"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_cmd: '"cd /root/gitlink-cli && git pull origin master && DRY_RUN=true REPOS=$(repos) bash skills/gitlink-multi-repo-coordination/scripts/multi-repo-full.sh"'
|
||||
needs:
|
||||
- start
|
||||
- ref: end
|
||||
name: 结束
|
||||
task: end
|
||||
needs:
|
||||
- ssh_cmd_0
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
version: 2
|
||||
name: 构建流水线
|
||||
description: "gitlink-cli CI 流水线:拉取代码、构建、验证 + 社区运营定时任务"
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: push
|
||||
ruleset-operator: AND
|
||||
global:
|
||||
concurrent: 1
|
||||
param:
|
||||
- ref: remote_url
|
||||
name: ""
|
||||
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
|
||||
required: false
|
||||
type: STRING
|
||||
hidden: false
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
- ref: ssh_cmd_0
|
||||
name: 拉取代码并构建验证
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_pass: ((work_together.ssh_key))
|
||||
ssh_ip: '"121.41.216.243"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_cmd: '"export GOPROXY=https://goproxy.cn,direct && cd /root/gitlink-cli || git clone https://gitlink.org.cn/z2_cc/gitlink-cli.git /root/gitlink-cli; cd /root/gitlink-cli && git pull origin master && go build -o gitlink-cli . && ./gitlink-cli version"'
|
||||
needs:
|
||||
- start
|
||||
- ref: end
|
||||
name: 结束
|
||||
task: end
|
||||
needs:
|
||||
- ssh_cmd_0
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
version: 2
|
||||
name: 社区运营流水线
|
||||
description: "每周定时运行:Issue 分拣 → 健康报告 → Release Notes"
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: schedule
|
||||
ruleset-operator: AND
|
||||
global:
|
||||
concurrent: 1
|
||||
param:
|
||||
- ref: remote_url
|
||||
name: ""
|
||||
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
|
||||
required: false
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: owner
|
||||
name: "仓库所有者"
|
||||
value: '"z2_cc"'
|
||||
required: true
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: repo
|
||||
name: "仓库名称"
|
||||
value: '"gitlink-cli"'
|
||||
required: true
|
||||
type: STRING
|
||||
hidden: false
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
- ref: ssh_cmd_0
|
||||
name: 运行社区运营自动化流水线
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_pass: ((work_together.ssh_key))
|
||||
ssh_ip: '"121.41.216.243"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_cmd: '"cd /root/gitlink-cli && git pull origin master && DRY_RUN=true bash skills/gitlink-community-ops/scripts/community-ops-full.sh z2_cc gitlink-cli"'
|
||||
needs:
|
||||
- start
|
||||
- ref: end
|
||||
name: 结束
|
||||
task: end
|
||||
needs:
|
||||
- ssh_cmd_0
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
version: 2
|
||||
name: 贡献者成长体系流水线
|
||||
description: "每周定时运行:贡献追踪 → 排行段位 → 自动颁发徽章"
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: schedule
|
||||
ruleset-operator: AND
|
||||
global:
|
||||
concurrent: 1
|
||||
param:
|
||||
- ref: remote_url
|
||||
name: ""
|
||||
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
|
||||
required: false
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: owner
|
||||
name: "仓库所有者"
|
||||
value: '"z2_cc"'
|
||||
required: true
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: repo
|
||||
name: "仓库名称"
|
||||
value: '"gitlink-cli"'
|
||||
required: true
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: dry_run
|
||||
name: "是否 dry-run"
|
||||
value: '"true"'
|
||||
required: false
|
||||
type: STRING
|
||||
hidden: false
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
- ref: ssh_cmd_0
|
||||
name: 运行贡献者成长体系流水线
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_pass: ((work_together.ssh_key))
|
||||
ssh_ip: '"121.41.216.243"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_cmd: '"cd /root/gitlink-cli && git pull origin master && DRY_RUN=$(dry_run) bash skills/gitlink-contributor-growth/scripts/contributor-growth-full.sh $(owner) $(repo)"'
|
||||
needs:
|
||||
- start
|
||||
- ref: end
|
||||
name: 结束
|
||||
task: end
|
||||
needs:
|
||||
- ssh_cmd_0
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
version: 2
|
||||
name: 项目初始化流水线
|
||||
description: "新项目一键初始化:创建仓库 → 生成 README/LICENSE/CI → 创建初始 Issue 和里程碑"
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: manual
|
||||
ruleset-operator: AND
|
||||
global:
|
||||
concurrent: 1
|
||||
param:
|
||||
- ref: remote_url
|
||||
name: ""
|
||||
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
|
||||
required: false
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: owner
|
||||
name: "仓库所有者"
|
||||
value: '"z2_cc"'
|
||||
required: true
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: repo
|
||||
name: "仓库名称"
|
||||
value: '"gitlink-cli"'
|
||||
required: true
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: project_desc
|
||||
name: "项目描述"
|
||||
value: '"一个用 Go 编写的 GitLink 命令行工具"'
|
||||
required: true
|
||||
type: STRING
|
||||
hidden: false
|
||||
- ref: dry_run
|
||||
name: "是否 dry-run"
|
||||
value: '"true"'
|
||||
required: false
|
||||
type: STRING
|
||||
hidden: false
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
- ref: ssh_cmd_0
|
||||
name: 运行项目一键初始化流水线
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_pass: ((work_together.ssh_key))
|
||||
ssh_ip: '"121.41.216.243"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_cmd: '"cd /root/gitlink-cli && git pull origin master && DRY_RUN=$(dry_run) PROJECT_DESC=\"$(project_desc)\" bash skills/gitlink-project-bootstrap/scripts/project-bootstrap-full.sh $(owner) $(repo)"'
|
||||
needs:
|
||||
- start
|
||||
- ref: end
|
||||
name: 结束
|
||||
task: end
|
||||
needs:
|
||||
- ssh_cmd_0
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
- name: Build
|
||||
run: go build -ldflags "-s -w" .
|
||||
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
|
@ -57,6 +57,10 @@ jobs:
|
|||
rm -rf "$BUILD_DIR"
|
||||
done
|
||||
|
||||
# Generate SHA256 checksums
|
||||
cd dist
|
||||
sha256sum *.tar.gz *.zip > checksums-sha256.txt
|
||||
cd ..
|
||||
ls -lh dist
|
||||
|
||||
- name: Create Release
|
||||
|
|
@ -65,8 +69,44 @@ jobs:
|
|||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
dist/checksums-sha256.txt
|
||||
generate_release_notes: true
|
||||
|
||||
- name: Update Scoop manifest
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
cd dist
|
||||
|
||||
# Parse SHA256 hashes from checksums file
|
||||
declare -A HASHES
|
||||
while read -r hash file; do
|
||||
HASHES["$file"]="$hash"
|
||||
done < checksums-sha256.txt
|
||||
|
||||
# Update bucket/gitlink-cli.json
|
||||
cd ..
|
||||
SCOOP_FILE="bucket/gitlink-cli.json"
|
||||
if [ -f "$SCOOP_FILE" ]; then
|
||||
# Update version
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" "$SCOOP_FILE"
|
||||
|
||||
# Update amd64 hash and URL
|
||||
AMD64_FILE="gitlink-cli_${VERSION}_windows_amd64.zip"
|
||||
if [ -n "${HASHES[$AMD64_FILE]}" ]; then
|
||||
sed -i "/64bit/,/}/ s|\"url\": \".*\"|\"url\": \"https://github.com/gitlink-org/gitlink-cli/releases/download/v${VERSION}/${AMD64_FILE}\"|" "$SCOOP_FILE"
|
||||
sed -i "/64bit/,/}/ s|\"hash\": \".*\"|\"hash\": \"${HASHES[$AMD64_FILE]}\"|" "$SCOOP_FILE"
|
||||
fi
|
||||
|
||||
# Update arm64 hash and URL
|
||||
ARM64_FILE="gitlink-cli_${VERSION}_windows_arm64.zip"
|
||||
if [ -n "${HASHES[$ARM64_FILE]}" ]; then
|
||||
sed -i "/arm64/,/}/ s|\"url\": \".*\"|\"url\": \"https://github.com/gitlink-org/gitlink-cli/releases/download/v${VERSION}/${ARM64_FILE}\"|" "$SCOOP_FILE"
|
||||
sed -i "/arm64/,/}/ s|\"hash\": \".*\"|\"hash\": \"${HASHES[$ARM64_FILE]}\"|" "$SCOOP_FILE"
|
||||
fi
|
||||
|
||||
echo "Updated $SCOOP_FILE for version $VERSION"
|
||||
fi
|
||||
|
||||
- name: Build npm package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
# 编译输出
|
||||
*.exe
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# 系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
# 个人工作区
|
||||
my_docs/
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
docs/
|
||||
|
||||
# 脚本输出目录
|
||||
skills/*/scripts/_output/
|
||||
doc/sub-task4-module-c/_output/
|
||||
doc/sub-task4-module-d/_output/
|
||||
|
||||
# CLI 包装器
|
||||
.cli-bin/
|
||||
|
||||
# 测试/临时数据文件
|
||||
*.json
|
||||
!package.json
|
||||
snapshots/
|
||||
tmp/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
# gitlink-cli 项目指南
|
||||
|
||||
## 项目背景
|
||||
|
||||
本项目是《软件演化与运维》(2026 春)课程的**进阶实践任务**:**GitLink 智能化能力提升项目**。
|
||||
|
||||
GitLink CLI 是 GitLink 平台的命令行工具,采用 Go 语言开发(cobra 框架),提供 40+ 命令覆盖仓库管理、Issue 跟踪、PR 协作、CI/CD 等场景,并内置 AI Agent Skills 体系,兼容 Claude Code 等主流 Agent 平台。
|
||||
|
||||
- **仓库**:https://gitlink.org.cn/gitlink/gitlink-cli(本仓库 fork 自 `z2_cc/gitlink-cli`)
|
||||
- **Skills 开发指南**:`skills/README.md`
|
||||
- **API 文档**:https://s.apifox.cn/da30afb0-9d2e-429b-a4bc-a83209e06021
|
||||
- **CLI 设计文档**:`doc/design.md`
|
||||
|
||||
进阶任务包含四个子任务(已全部完成),另加 DevOps 流水线:
|
||||
|
||||
| 子任务 | 定位 | 占比 | 技术栈 |
|
||||
|--------|------|------|--------|
|
||||
| 子任务一 | 扩展 CLI 能力 | 50% | Go |
|
||||
| 子任务二 | 编写 Skills | 20% | Markdown + CLI |
|
||||
| 子任务三 | 端到端工作流 | 20% | Shell + Skills 编排 |
|
||||
| 子任务四 | 科研辅助(加分项) | 额外加分 | Markdown + CLI |
|
||||
| DevOps | CI/CD 流水线 | 10% | YAML |
|
||||
|
||||
---
|
||||
|
||||
## 🧭 操作中心(交互式菜单)
|
||||
|
||||
**触发方式**(任一即可):
|
||||
- 输入 `/gitlink`(斜杠命令,最推荐),可附带参数:`/gitlink 代码审查`、`/gitlink https://gitlink.org.cn/z2_cc/gitlink-cli`
|
||||
- 直接说「常用操作」、「常用操作以及场景」、「列出操作」、「gitlink 菜单」
|
||||
|
||||
触发后按 [`.claude/commands/gitlink.md`](.claude/commands/gitlink.md) 的流程:先列出全部 16 项操作(单项操作 / 分析与成长 / 端到端自动化 / 科研辅助),再用原生上下方向键菜单让用户选择,或接收一个 GitLink 仓库网址直接对其操作,最后调用对应已实现的 skill 执行(写操作默认 dry-run)。操作 → skill 映射见该命令文件。
|
||||
|
||||
---
|
||||
|
||||
## 技术架构
|
||||
|
||||
```
|
||||
gitlink-cli/
|
||||
├── main.go # 入口
|
||||
├── cmd/ # CLI 命令定义(cobra)
|
||||
│ ├── root.go # 根命令,注册所有 shortcuts
|
||||
│ ├── api/api.go # raw API 调用:gitlink api <METHOD> <PATH>
|
||||
│ ├── auth/ # 认证:login/logout/status/refresh
|
||||
│ ├── config/ # 配置管理
|
||||
│ └── interactive/ # 交互式 REPL(bubbletea TUI)
|
||||
├── shortcuts/ # 23 个命令组(repo/issue/pr/release/ci/…)
|
||||
│ ├── register.go # RegisterAll() 注册入口
|
||||
│ ├── common/ # 共享类型 + 批量操作框架(batch.go)
|
||||
│ └── {group}/ # 每个命令组一个目录
|
||||
├── internal/ # 核心包
|
||||
│ ├── auth/ # 登录、token 存储、HTTP 传输
|
||||
│ ├── client/ # API 客户端(auto-.json、分页、错误映射)
|
||||
│ ├── config/ # 配置文件管理
|
||||
│ ├── context/ # 自动解析 owner/repo(从 git remote)
|
||||
│ └── output/ # 格式化输出(json/table/yaml)
|
||||
├── skills/ # 32 个 Skill 定义(SKILL.md + examples/ + scripts/)
|
||||
├── .claude/skills/ # Claude Code 使用的符号链接 → ../../skills/{name}
|
||||
├── .devops/ # GitLink DevOps 流水线(3 个 YAML)
|
||||
├── doc/ # 设计文档、交接文档、架构图
|
||||
└── my_docs/ # 工作文档、总结、计划
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 子任务一:CLI 能力扩展(Go)
|
||||
|
||||
**定位**:为 gitlink-cli 增加新功能、优化现有功能。所有改动在 `cmd/`、`shortcuts/`、`internal/` 中。
|
||||
|
||||
### 已完成(P1-P4)
|
||||
|
||||
| 优先级 | 内容 | 关键文件 |
|
||||
|--------|------|----------|
|
||||
| **P1** Bug 修复(3 项) | `--label` 参数未发送、硬编码 `master` 默认分支、`pr +diff`/`+files` 重复 | `shortcuts/issue/`, `shortcuts/common/`, `shortcuts/pr/` |
|
||||
| **P2** 参数补全(10 项) | 补全 `--milestone`/`--assignee`/`--priority`/`--sort` 等 CLI 参数与 API 的映射 | `shortcuts/issue/`, `shortcuts/pr/`, `shortcuts/release/` |
|
||||
| **P3** 批量操作(9 个命令) | 通用批量框架 + issue/PR/collaborator/branch 批量命令,支持 `--dry-run`/`--yes`/CSV 输入 | `shortcuts/common/batch.go` |
|
||||
| **P4** 体验优化 | 40+ 命令帮助文档、破坏性操作确认提示、API 错误友好建议 | `shortcuts/*/`, `internal/client/` |
|
||||
|
||||
### 交互式 REPL
|
||||
|
||||
`cmd/interactive/` 提供基于 bubbletea 的 TUI:命令面板(模糊搜索)→ 参数表单 → 输出查看器。支持 `/` 前缀打开面板。
|
||||
|
||||
### 后续可完善方向
|
||||
|
||||
- 补全 GitLink OpenAPI 中尚未封装的 Raw API
|
||||
- 新增 Shortcut 命令组(如看板增强)
|
||||
- 跨平台兼容性和安装体验优化
|
||||
- 单元测试覆盖
|
||||
|
||||
---
|
||||
|
||||
## 子任务二:Skills 开发(Markdown + CLI)
|
||||
|
||||
**定位**:基于 gitlink-cli 开发 AI Agent Skill。核心交付物是 SKILL.md + 使用示例 + Agent 平台验证。
|
||||
|
||||
### Skills 规范
|
||||
|
||||
每个 Skill 位于 `skills/{name}/`,包含:
|
||||
- `SKILL.md`:YAML 前-matter(name、description、triggers)+ 使用说明
|
||||
- `examples/`:Agent 对话记录或运行示例
|
||||
- `scripts/`(可选):可执行脚本
|
||||
|
||||
### 已完成的 Skills(32 个)
|
||||
|
||||
**平台基础操作**(原始 11 个 + 扩展):
|
||||
`gitlink-repo` `gitlink-issue` `gitlink-pr` `gitlink-release` `gitlink-branch` `gitlink-org` `gitlink-user` `gitlink-search` `gitlink-ci` `gitlink-webhook` `gitlink-wiki` `gitlink-snippet` `gitlink-pm`
|
||||
|
||||
**智能化 Skills**(子任务二核心交付):
|
||||
`gitlink-code-review` `gitlink-pr-deep-review` `gitlink-issue-triage` `gitlink-release-auto` `gitlink-project-health` `gitlink-insight` `gitlink-commit-quality` `gitlink-compliance` `gitlink-duplicate-detector` `gitlink-newcomer-guide`
|
||||
|
||||
**工作流 Skills**(子任务三):
|
||||
`gitlink-community-ops` `gitlink-quality-gate` `gitlink-workflow`
|
||||
|
||||
**科研 Skills**(子任务四):
|
||||
`gitlink-research-insight` `gitlink-research-hotspot` `gitlink-research-matching` `gitlink-research-progress` `gitlink-research-compliance`
|
||||
|
||||
**共享基础**:
|
||||
`gitlink-shared`(认证、全局参数、安全规则)
|
||||
|
||||
### 后续可完善方向
|
||||
|
||||
- 为现有 Skill 补充更多 Agent 平台验证(OpenClaw、Cursor)
|
||||
- 优化 Skill 触发词准确性
|
||||
- 增加 Skill 使用示例的覆盖度
|
||||
|
||||
---
|
||||
|
||||
## 子任务三:端到端自动化工作流(Shell + Skills 编排)
|
||||
|
||||
**定位**:组合 gitlink-cli 命令和 Skills,完成可复现的端到端自动化场景。每个工作流串联 ≥3 个步骤。
|
||||
|
||||
### 场景 A:社区运营自动化
|
||||
|
||||
`skills/gitlink-community-ops/` — 三阶段流水线:
|
||||
1. Issue 分拣(关键词分析 → 建议标签)
|
||||
2. 社区周报(Issue/PR/Commit/Milestone 统计)
|
||||
3. Release Notes 生成(Conventional Commits 分类)
|
||||
|
||||
关键文件:`community-ops-full.sh`(全流程编排)、`health-analyze.js`、`release-analyze.js`
|
||||
|
||||
### 场景 B:代码质量看门人
|
||||
|
||||
`skills/gitlink-quality-gate/` — 四阶段门禁:
|
||||
1. PR 采集 → 2. 深度审查 → 3. CI 检查 → 4. 评分决策(≥70 分 + CI 通过 → 建议合并)
|
||||
|
||||
评分模型:CodeReview(40%) + CI(30%) + 设计一致性(30%)
|
||||
|
||||
关键文件:`quality-gate-full.sh`(全流程编排)
|
||||
|
||||
### DevOps 流水线
|
||||
|
||||
`.devops/` 目录下 3 个 YAML 配置,均配置在 `z2_cc/gitlink-cli`:
|
||||
- `构建流水线.yml`:push 触发 Go 构建 + 版本校验
|
||||
- `社区运营流水线.yml`:定时触发社区运营全流程
|
||||
- `代码质量门禁流水线.yml`:PR 触发质量门禁
|
||||
|
||||
### 后续可完善方向
|
||||
|
||||
- 贡献者成长体系(追踪→排行→徽章)
|
||||
- 多仓库协同场景
|
||||
- 项目一键初始化工作流
|
||||
- 工作流的错误恢复和通知机制
|
||||
|
||||
---
|
||||
|
||||
## 子任务四:科研辅助(Markdown + CLI,加分项)
|
||||
|
||||
**定位**:依托 gitlink-cli 数据获取与 AI Agent 能力,为科研场景提供智能化辅助。设计原则:**evidence-oriented(证据驱动)、read-only(只读不写)、delegation pattern(委托已有 Skill)、pure remote analysis(纯远程分析)**。
|
||||
|
||||
### 已完成的 5 个科研 Skill
|
||||
|
||||
| Skill | 功能 | 关键文件 |
|
||||
|-------|------|----------|
|
||||
| `gitlink-research-insight` | 仓库级科研项目洞悉:8 维数据采集 + 4 维 AI 分析(成熟度/文档/贡献模式/社区健康),输出结构化评分报告 | `scripts/research-insight-full.sh`、`scripts/analyze.js` |
|
||||
| `gitlink-research-hotspot` | 科研热点追踪:多关键词搜索 → 实体提取 → 关系图构建 → Mermaid.js 知识图谱 + 交互式 HTML 可视化 | `scripts/hotspot-tracker-full.sh`、`scripts/kg-to-html.js` |
|
||||
| `gitlink-research-matching` | 科研协作智能匹配:4 角色推断(数据/算法/工程/评估)→ 人员-任务匹配 → 资源探测 → 综合建议报告 | `examples/matching-run.md`(已验证于 `Gitlink/forgeplus`) |
|
||||
| `gitlink-research-progress` | 科研进度跟踪与预警:6 维风险信号(停滞 Issue/高优无人/零回复/积压趋势/提交突变/里程碑 DDL),100 分制风险评分 | 已验证于 `forgeplus`/`gitlink-cli`/`FairMOT` |
|
||||
| `gitlink-research-compliance` | 科研合规与复现性检查:数据可用性/环境可复现/结果可验证 4 维评估 | `examples/reproducibility-check.md`(已验证于 `ifzhang/FairMOT`) |
|
||||
|
||||
### 共享工具
|
||||
|
||||
`skills/gitlink-shared/scripts/` 提供 `detect-cli.sh`(CLI 版本检测)和 `md-to-html.js`(报告转交互式 HTML)。
|
||||
|
||||
### 后续可完善方向
|
||||
|
||||
- 增加更多科研仓库验证案例
|
||||
- 知识图谱的交互式探索能力
|
||||
- 科研主体画像功能
|
||||
- 创新启发式推荐
|
||||
|
||||
---
|
||||
|
||||
## Skill 触发映射
|
||||
|
||||
当用户提及以下内容时,**优先调用对应 Skill**(而非手动执行命令):
|
||||
|
||||
### 平台操作
|
||||
- **Issue 管理** → `gitlink-issue`
|
||||
- **PR 管理** → `gitlink-pr`
|
||||
- **Release / 发版 / changelog** → `gitlink-release` 或 `gitlink-release-auto`
|
||||
- **仓库管理** → `gitlink-repo`
|
||||
- **分支管理** → `gitlink-branch`
|
||||
- **组织管理** → `gitlink-org`
|
||||
- **CI/CD** → `gitlink-ci`
|
||||
- **Wiki** → `gitlink-wiki`
|
||||
- **代码片段** → `gitlink-snippet`
|
||||
- **Webhook** → `gitlink-webhook`
|
||||
- **搜索** → `gitlink-search`
|
||||
|
||||
### 智能化与自动化
|
||||
- **代码审查 / PR Review** → `gitlink-code-review` 或 `gitlink-pr-deep-review`
|
||||
- **Issue 分拣 / 分类 / Triage** → `gitlink-issue-triage`
|
||||
- **项目健康度 / 周报** → `gitlink-project-health` 或 `gitlink-insight`
|
||||
- **新人引导 / good-first-issue** → `gitlink-newcomer-guide`
|
||||
- **重复 Issue 检测** → `gitlink-duplicate-detector`
|
||||
- **许可证合规检查** → `gitlink-compliance`
|
||||
- **提交质量检查** → `gitlink-commit-quality`
|
||||
- **项目管理 / Sprint / 看板** → `gitlink-pm`
|
||||
|
||||
### 工作流(子任务三)
|
||||
- **社区运营自动化 / 周报生成** → `gitlink-community-ops`
|
||||
- **代码质量门禁 / PR 自动门禁** → `gitlink-quality-gate`
|
||||
- **工作流模板参考** → `gitlink-workflow`
|
||||
|
||||
### 科研辅助(子任务四)
|
||||
- **科研项目洞悉 / 成熟度评估 / 仓库洞察** → `gitlink-research-insight`
|
||||
- **科研热点追踪 / 知识图谱 / 研究趋势** → `gitlink-research-hotspot`
|
||||
- **科研协作匹配 / 角色推断** → `gitlink-research-matching`
|
||||
- **科研进度跟踪 / 风险预警** → `gitlink-research-progress`
|
||||
- **科研合规检查 / 复现性评估** → `gitlink-research-compliance`
|
||||
|
||||
### 工作流扩展(子任务五)
|
||||
- **项目一键初始化 / 创建仓库 / 生成 README/LICENSE/CI / 初始 Issue 和里程碑** → 调用 `gitlink-project-bootstrap` skill
|
||||
- **多仓库协同 / 跨仓库 Issue 追踪 / PR 状态看板 / Release 协调发布** → 调用 `gitlink-multi-repo-coordination` skill(通过 `REPOS="owner/repo,..."` 指定多仓库)
|
||||
- **贡献者成长体系 / 贡献排行 / 成长段位 / 自动颁发徽章** → 调用 `gitlink-contributor-growth` skill
|
||||
|
||||
---
|
||||
|
||||
## 开发约定
|
||||
|
||||
### 命令使用
|
||||
- 所有 GitLink API 操作使用 `gitlink-cli` 命令,不得直接调用 REST API
|
||||
- 使用 Skill 前先阅读对应 `SKILL.md` 和 `skills/gitlink-shared/SKILL.md` 了解认证与已知限制
|
||||
- 命令输出优先使用 `--format json` 获取结构化数据
|
||||
|
||||
### 构建与测试
|
||||
```bash
|
||||
make build # 编译到 bin/gitlink-cli
|
||||
make install # 安装到 $GOPATH/bin
|
||||
go test ./... # 运行所有测试
|
||||
```
|
||||
|
||||
### 添加新 Skill
|
||||
1. 在 `skills/` 下创建目录(如 `skills/gitlink-xxx/`)
|
||||
2. 编写 `SKILL.md`(参考 `skills/README.md` 规范)
|
||||
3. 在 `.claude/skills/` 创建符号链接:`ln -s ../../skills/gitlink-xxx gitlink-xxx`
|
||||
4. 更新本文件的 Skill 映射表
|
||||
|
||||
### 关键参考文档
|
||||
- `doc/design.md` — CLI 设计文档
|
||||
- `doc/sub-task34-交接文档.md` — 子任务三、四交接文档
|
||||
- `my_docs/summary.md` — 子任务一 P1-P4 改进总结
|
||||
- `skills/README.md` — Skills 开发规范
|
||||
- `skills/gitlink-shared/SKILL.md` — 共享基础(认证、安全规则)
|
||||
41
Makefile
41
Makefile
|
|
@ -3,7 +3,9 @@ BINARY := gitlink-cli
|
|||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)'
|
||||
|
||||
.PHONY: build install clean test
|
||||
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
|
||||
|
||||
.PHONY: build install clean test lint cross-build dist checksums
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||
|
|
@ -13,6 +15,43 @@ install:
|
|||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
rm -rf dist/
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
|
||||
# Cross-compile for all supported platforms
|
||||
cross-build:
|
||||
@mkdir -p dist
|
||||
@for pair in $(PLATFORMS); do \
|
||||
GOOS=$${pair%/*}; \
|
||||
GOARCH=$${pair#*/}; \
|
||||
OUT=$(BINARY); \
|
||||
if [ "$$GOOS" = "windows" ]; then OUT=$(BINARY).exe; fi; \
|
||||
echo "Building $$GOOS/$$GOARCH..."; \
|
||||
BUILD_DIR="dist/$(BINARY)_$(VERSION)_$${GOOS}_$${GOARCH}"; \
|
||||
mkdir -p "$$BUILD_DIR"; \
|
||||
CGO_ENABLED=0 GOOS=$$GOOS GOARCH=$$GOARCH \
|
||||
go build -ldflags "$(LDFLAGS)" -o "$$BUILD_DIR/$$OUT" .; \
|
||||
if [ "$$GOOS" = "windows" ]; then \
|
||||
(cd "$$BUILD_DIR" && zip -q "../$(BINARY)_$(VERSION)_$${GOOS}_$${GOARCH}.zip" "$$OUT"); \
|
||||
else \
|
||||
tar -czf "dist/$(BINARY)_$(VERSION)_$${GOOS}_$${GOARCH}.tar.gz" -C "$$BUILD_DIR" "$$OUT"; \
|
||||
fi; \
|
||||
rm -rf "$$BUILD_DIR"; \
|
||||
done
|
||||
@echo "Build complete:"
|
||||
@ls -lh dist/*.tar.gz dist/*.zip 2>/dev/null
|
||||
|
||||
# Generate SHA256 checksums for all archives in dist/
|
||||
checksums: cross-build
|
||||
@cd dist && \
|
||||
sha256sum *.tar.gz *.zip 2>/dev/null > checksums-sha256.txt && \
|
||||
echo "Checksums written to dist/checksums-sha256.txt" && \
|
||||
cat checksums-sha256.txt
|
||||
|
||||
# Full distribution build: cross-compile + checksums
|
||||
dist: checksums
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"version": "0.1.13",
|
||||
"description": "CLI tool for GitLink platform",
|
||||
"homepage": "https://www.gitlink.org.cn/Gitlink/gitlink-cli",
|
||||
"license": "MulanPSL-2.0",
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_windows_amd64.zip",
|
||||
"hash": "PLACEHOLDER_SHA256"
|
||||
},
|
||||
"arm64": {
|
||||
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_windows_arm64.zip",
|
||||
"hash": "PLACEHOLDER_SHA256"
|
||||
}
|
||||
},
|
||||
"bin": "gitlink-cli.exe",
|
||||
"checkver": {
|
||||
"github": "https://github.com/gitlink-org/gitlink-cli"
|
||||
},
|
||||
"autoupdate": {
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/gitlink-cli_$version_windows_amd64.zip"
|
||||
},
|
||||
"arm64": {
|
||||
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/gitlink-cli_$version_windows_arm64.zip"
|
||||
}
|
||||
},
|
||||
"hash": {
|
||||
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/checksums-sha256.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
|
||||
<metadata>
|
||||
<id>gitlink-cli</id>
|
||||
<version>0.1.13</version>
|
||||
<title>GitLink CLI</title>
|
||||
<authors>GitLink</authors>
|
||||
<projectUrl>https://www.gitlink.org.cn/Gitlink/gitlink-cli</projectUrl>
|
||||
<licenseUrl>https://opensource.org/licenses/MulanPSL-2.0</licenseUrl>
|
||||
<description>CLI tool for GitLink platform</description>
|
||||
<tags>gitlink cli devops</tags>
|
||||
</metadata>
|
||||
<files>
|
||||
<file src="tools/**" target="tools" />
|
||||
</files>
|
||||
</package>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
$ErrorActionPreference = 'Stop'
|
||||
$packageName = 'gitlink-cli'
|
||||
$version = '0.1.13'
|
||||
$url = "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/gitlink-cli_$version_windows_amd64.zip"
|
||||
$checksum = 'PLACEHOLDER_SHA256'
|
||||
$installDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
|
||||
$packageArgs = @{
|
||||
packageName = $packageName
|
||||
url = $url
|
||||
checksum = $checksum
|
||||
checksumType = 'sha256'
|
||||
unzipLocation = $installDir
|
||||
}
|
||||
Install-ChocolateyZipPackage @packageArgs
|
||||
|
|
@ -23,11 +23,13 @@ func NewAuthCmd() *cobra.Command {
|
|||
cmd.AddCommand(newLoginCmd())
|
||||
cmd.AddCommand(newLogoutCmd())
|
||||
cmd.AddCommand(newStatusCmd())
|
||||
cmd.AddCommand(newRefreshCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLoginCmd() *cobra.Command {
|
||||
var tokenMode bool
|
||||
var oauthMode bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
|
|
@ -36,10 +38,14 @@ func newLoginCmd() *cobra.Command {
|
|||
if tokenMode {
|
||||
return loginWithToken()
|
||||
}
|
||||
if oauthMode {
|
||||
return loginWithOAuth()
|
||||
}
|
||||
return loginWithPassword()
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token")
|
||||
cmd.Flags().BoolVar(&oauthMode, "oauth", false, "Login using OAuth2 password grant")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
@ -140,3 +146,42 @@ func newStatusCmd() *cobra.Command {
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loginWithOAuth() error {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Print("Username: ")
|
||||
username, _ := reader.ReadString('\n')
|
||||
username = strings.TrimSpace(username)
|
||||
|
||||
fmt.Print("Password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
password := string(passwordBytes)
|
||||
|
||||
result, err := internalAuth.LoginOAuth(username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OAuth2 login failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Logged in as %s\n", result.Login)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newRefreshCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "refresh",
|
||||
Short: "Refresh OAuth2 token",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
result, err := internalAuth.RefreshOAuthToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ Token refreshed for %s (expires in %d seconds)\n", result.Login, result.ExpiresIn)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ package cmdutil
|
|||
|
||||
// Global flags shared across all commands.
|
||||
var (
|
||||
Owner string
|
||||
Repo string
|
||||
Format string
|
||||
Debug bool
|
||||
Owner string
|
||||
Repo string
|
||||
Format string
|
||||
Debug bool
|
||||
ColorMode string
|
||||
AutoConfirm bool
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
)
|
||||
|
||||
// TestAllCommandsExecuteWithoutPanic 验证所有注册的命令都能安全执行:
|
||||
// 不 panic、恢复 stdout、且产生输出或合理的错误。
|
||||
// 这是回归测试,防止之前 "选择命令后无输出" 的 nil-Client panic 复发。
|
||||
func TestAllCommandsExecuteWithoutPanic(t *testing.T) {
|
||||
all := shortcuts.GetAllShortcuts()
|
||||
if len(all) == 0 {
|
||||
t.Fatal("GetAllShortcuts returned empty registry")
|
||||
}
|
||||
|
||||
exec := &Executor{}
|
||||
totalCommands := 0
|
||||
silentFailures := 0
|
||||
|
||||
for group, shortcuts := range all {
|
||||
for _, s := range shortcuts {
|
||||
if s.Run == nil {
|
||||
continue
|
||||
}
|
||||
totalCommands++
|
||||
// 用空参数执行;需要 owner/repo/必填参数的命令会返回错误,这是正常的
|
||||
out, err := exec.Execute(s, map[string]string{})
|
||||
|
||||
// 断言1:不能既无输出又无错误(静默失败)
|
||||
if strings.TrimSpace(out) == "" && err == nil {
|
||||
silentFailures++
|
||||
t.Errorf("[%s +%s] 静默失败:无输出且无错误", group, s.Name)
|
||||
}
|
||||
|
||||
// 断言2:错误信息不应包含 "panic"(说明 recover 被触发,命令有 bug)
|
||||
if err != nil && strings.Contains(err.Error(), "panic") {
|
||||
t.Errorf("[%s +%s] 命令 panic: %v", group, s.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("测试了 %d 个命令,%d 个静默失败", totalCommands, silentFailures)
|
||||
if totalCommands < 50 {
|
||||
t.Errorf("命令总数 %d 偏少,预期至少 50 个", totalCommands)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllCommandsHaveMetadata 验证所有命令都有名称、描述和 Run 函数。
|
||||
func TestAllCommandsHaveMetadata(t *testing.T) {
|
||||
all := shortcuts.GetAllShortcuts()
|
||||
for group, shortcuts := range all {
|
||||
if len(shortcuts) == 0 {
|
||||
t.Errorf("命令组 %q 没有任何子命令", group)
|
||||
}
|
||||
for _, s := range shortcuts {
|
||||
if s.Name == "" {
|
||||
t.Errorf("[%s] 有命令名称为空", group)
|
||||
}
|
||||
if s.Description == "" {
|
||||
t.Errorf("[%s +%s] 描述为空", group, s.Name)
|
||||
}
|
||||
if s.Run == nil {
|
||||
t.Errorf("[%s +%s] Run 函数为空", group, s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestE2E_ExecutingRealCommandProducesOutput is an end-to-end regression for
|
||||
// "selecting a command shows no output". It drives the real shortcut registry
|
||||
// (repo +list) through the executor and asserts that Execute returns either
|
||||
// captured stdout OR a non-nil error — never both empty, which is what left
|
||||
// the REPL silent before the RuntimeContext fix.
|
||||
func TestE2E_ExecutingRealCommandProducesOutput(t *testing.T) {
|
||||
all := shortcuts.GetAllShortcuts()
|
||||
s, ok := findShortcut(all, "repo", "list")
|
||||
if !ok {
|
||||
t.Fatal("repo +list not found in registry")
|
||||
}
|
||||
if s.Run == nil {
|
||||
t.Fatal("repo +list has no Run function")
|
||||
}
|
||||
|
||||
exec := &Executor{}
|
||||
// repo +list needs an owner; without --owner it tries git remote detection.
|
||||
// Either way the executor must return something (output or error), not hang.
|
||||
out, err := exec.Execute(s, map[string]string{})
|
||||
|
||||
// The contract: Execute never returns (empty, nil) for a real command —
|
||||
// either it produced output, or it returned an error explaining the failure.
|
||||
if out == "" && err == nil {
|
||||
t.Fatal("Execute returned empty output AND nil error — REPL would show nothing (regression)")
|
||||
}
|
||||
|
||||
// A non-empty result (output or error message) proves the command actually
|
||||
// ran through a properly-initialized RuntimeContext instead of panicking.
|
||||
t.Logf("output=%q err=%v", out, err)
|
||||
_ = common.RuntimeContext{} // keep import
|
||||
_ = strings.TrimSpace
|
||||
}
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Executor runs shortcut commands and captures their stdout output.
|
||||
type Executor struct{}
|
||||
|
||||
// Execute runs the given shortcut with the provided arguments, capturing
|
||||
// anything written to os.Stdout during execution and returning it as a string.
|
||||
//
|
||||
// It builds the RuntimeContext via common.NewRuntimeContext so that Client,
|
||||
// Format, Owner and Repo are properly initialized — a bare
|
||||
// `&RuntimeContext{Args: args}` would leave Client nil and panic as soon as a
|
||||
// command calls ctx.CallAPI. A deferred recover guards against panics raised
|
||||
// inside the command so the REPL is never left with stdout pointing at a
|
||||
// closed pipe.
|
||||
func (e *Executor) Execute(s *common.Shortcut, args map[string]string) (output string, err error) {
|
||||
// Guard against panics inside the command: always restore stdout even if
|
||||
// s.Run panics, and surface the panic as a regular error so the REPL can
|
||||
// display it instead of hanging with a corrupted stdout.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
output = ""
|
||||
err = fmt.Errorf("command panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Save original stdout
|
||||
oldStdout := os.Stdout
|
||||
|
||||
// Create a pipe: writes go to w, reads come from r
|
||||
r, w, perr := os.Pipe()
|
||||
if perr != nil {
|
||||
return "", fmt.Errorf("failed to create pipe: %w", perr)
|
||||
}
|
||||
|
||||
// Redirect stdout to the write end of the pipe
|
||||
os.Stdout = w
|
||||
|
||||
// Channel to signal that the goroutine has finished reading
|
||||
done := make(chan struct{})
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Read from the pipe in a goroutine so that writes don't block
|
||||
go func() {
|
||||
io.Copy(&buf, r)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Build a fully-initialized context (Client/Format/Owner/Repo).
|
||||
ctx, cerr := common.NewRuntimeContext(args)
|
||||
if cerr != nil {
|
||||
// Restore stdout and drain the pipe before returning.
|
||||
os.Stdout = oldStdout
|
||||
w.Close()
|
||||
<-done
|
||||
return "", fmt.Errorf("failed to initialize runtime context: %w", cerr)
|
||||
}
|
||||
|
||||
// Execute the shortcut's Run function. Recover protects the path so that
|
||||
// a panic still leaves the deferred cleanup below runnable.
|
||||
runErr := safeRun(s, ctx)
|
||||
|
||||
// Close the writer to signal EOF to the reader goroutine, wait for it to
|
||||
// finish copying into the buffer, then restore stdout.
|
||||
w.Close()
|
||||
<-done
|
||||
os.Stdout = oldStdout
|
||||
|
||||
return buf.String(), runErr
|
||||
}
|
||||
|
||||
// safeRun invokes a shortcut's Run function and converts any panic into an
|
||||
// error, so a panicking command cannot crash the executor goroutine or leave
|
||||
// os.Stdout redirected at a closed pipe.
|
||||
func safeRun(s *common.Shortcut, ctx *common.RuntimeContext) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("command panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
return s.Run(ctx)
|
||||
}
|
||||
|
||||
// parseDirectCommand parses a direct command string like "issue +list --state open"
|
||||
// into its components: group, cmd (without +), flagStr, and whether parsing succeeded.
|
||||
func parseDirectCommand(input string) (group, cmd, flagStr string, ok bool) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
parts := strings.Fields(input)
|
||||
if len(parts) < 2 {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
group = parts[0]
|
||||
rawCmd := parts[1]
|
||||
|
||||
if !strings.HasPrefix(rawCmd, "+") {
|
||||
return "", "", "", false
|
||||
}
|
||||
cmd = strings.TrimPrefix(rawCmd, "+")
|
||||
|
||||
if len(parts) > 2 {
|
||||
flagStr = strings.Join(parts[2:], " ")
|
||||
}
|
||||
|
||||
return group, cmd, flagStr, true
|
||||
}
|
||||
|
||||
// parseFlagString parses a flag string like "--title hello -n 42" into a map.
|
||||
// shortMap is used to expand short flag names to their long equivalents.
|
||||
func parseFlagString(flagStr string, shortMap map[string]string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
if flagStr == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
parts := strings.Fields(flagStr)
|
||||
|
||||
for i := 0; i < len(parts); i++ {
|
||||
part := parts[i]
|
||||
|
||||
if strings.HasPrefix(part, "--") {
|
||||
// Long flag
|
||||
flagPart := strings.TrimPrefix(part, "--")
|
||||
|
||||
if strings.Contains(flagPart, "=") {
|
||||
// --flag=value format
|
||||
kv := strings.SplitN(flagPart, "=", 2)
|
||||
result[kv[0]] = kv[1]
|
||||
} else if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
|
||||
// --flag value format
|
||||
result[flagPart] = parts[i+1]
|
||||
i++
|
||||
} else {
|
||||
// --flag without value (boolean-like)
|
||||
result[flagPart] = "true"
|
||||
}
|
||||
} else if strings.HasPrefix(part, "-") && len(part) > 1 {
|
||||
// Short flag
|
||||
shortName := strings.TrimPrefix(part, "-")
|
||||
|
||||
// Expand short name to long name if mapping exists
|
||||
longName := shortName
|
||||
if mapped, ok := shortMap[shortName]; ok {
|
||||
longName = mapped
|
||||
}
|
||||
|
||||
if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
|
||||
result[longName] = parts[i+1]
|
||||
i++
|
||||
} else {
|
||||
result[longName] = "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// findShortcut looks up a shortcut by group and command name in the registry.
|
||||
func findShortcut(all map[string][]*common.Shortcut, group, cmd string) (*common.Shortcut, bool) {
|
||||
shortcuts, ok := all[group]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for _, s := range shortcuts {
|
||||
if s.Name == cmd {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// buildShortMap creates a mapping from short flag names to long flag names.
|
||||
func buildShortMap(s *common.Shortcut) map[string]string {
|
||||
m := make(map[string]string)
|
||||
for _, f := range s.Flags {
|
||||
if f.Short != "" {
|
||||
m[f.Short] = f.Name
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// missingRequiredFlags returns the names (with -- prefix) of flags that are
|
||||
// marked as required but not present in args.
|
||||
func missingRequiredFlags(s *common.Shortcut, args map[string]string) []string {
|
||||
var missing []string
|
||||
for _, f := range s.Flags {
|
||||
if f.Required {
|
||||
if _, ok := args[f.Name]; !ok {
|
||||
missing = append(missing, "--"+f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// commandSuggestions maps (group, command) to command-specific fix suggestions shown
|
||||
// in the interactive REPL when a command fails with an API error.
|
||||
var commandSuggestions = map[string]map[string]string{
|
||||
"ci": {
|
||||
"builds": "请确认仓库已启用 CI 引擎(gitlink ci +enable),且存在构建记录",
|
||||
"runs": "请确认仓库已启用 CI 引擎,且存在运行记录",
|
||||
"run-results": "请确认仓库已启用 CI 引擎,且存在运行结果",
|
||||
"logs": "请确认构建编号正确且构建已完成",
|
||||
"restart": "请确认构建编号正确且状态允许重新构建",
|
||||
"stop": "请确认构建编号正确且构建处于运行中",
|
||||
"pipelines": "请确认仓库已配置 CI 流水线模板",
|
||||
"run-pipeline": "请确认流水线名称正确且仓库已启用 CI 引擎",
|
||||
"pipeline-detail": "请确认流水线 ID 正确",
|
||||
"delete-pipeline": "请确认流水线 ID 正确",
|
||||
"disable": "仓库可能已禁用 CI,无需重复操作",
|
||||
"enable": "仓库可能已启用 CI,无需重复操作",
|
||||
"run-log": "请确认运行 ID 正确且运行已完成",
|
||||
},
|
||||
"issue": {
|
||||
"list": "请确认 owner/repo 参数正确",
|
||||
"create": "请确认标题和必填参数已填写",
|
||||
"view": "请确认 Issue 编号正确",
|
||||
"update": "请确认 Issue 编号和更新参数正确",
|
||||
"close": "请确认 Issue 编号正确,且你有关闭权限",
|
||||
"delete": "请确认 Issue 编号正确,此操作不可恢复",
|
||||
"comment": "请确认 Issue 编号正确且评论内容不为空",
|
||||
"comments": "请确认 Issue 编号正确",
|
||||
"update-comment": "请确认评论 ID 正确",
|
||||
"delete-comment": "请确认评论 ID 正确",
|
||||
"reply-comment": "请确认评论 ID 正确",
|
||||
"batch-close": "请确认 Issue 编号正确,且你有批量关闭权限",
|
||||
"batch-update": "请确认 Issue 编号和更新参数正确",
|
||||
"batch-destroy": "请确认 Issue 编号正确,此操作不可恢复",
|
||||
},
|
||||
"pr": {
|
||||
"list": "请确认 owner/repo 参数正确",
|
||||
"create": "请确认源分支和目标分支正确",
|
||||
"view": "请确认 PR 编号正确",
|
||||
"merge": "请确认 PR 已通过 CI 检查且无合并冲突",
|
||||
"close": "请确认 PR 编号正确",
|
||||
"reopen": "请确认 PR 编号正确且 PR 当前为关闭状态",
|
||||
"update": "请确认 PR 编号和更新参数正确",
|
||||
"comment": "请确认 PR 编号正确且评论内容不为空",
|
||||
"files": "请确认 PR 编号正确",
|
||||
"versions": "请确认 PR 编号正确",
|
||||
"version-diff": "请确认 PR 编号正确",
|
||||
"reviews": "请确认 PR 编号正确",
|
||||
"review": "请确认 PR 编号正确且你已被列为审查者",
|
||||
"comments": "请确认 PR 编号正确",
|
||||
"create-comment": "请确认 PR 编号正确且评论内容不为空",
|
||||
"update-comment": "请确认评论 ID 正确",
|
||||
"delete-comment": "请确认评论 ID 正确",
|
||||
"commits": "请确认 PR 编号正确",
|
||||
"batch-close": "请确认 PR 编号正确,且你有批量关闭权限",
|
||||
"batch-merge": "请确认 PR 已通过 CI 检查且无合并冲突",
|
||||
},
|
||||
"release": {
|
||||
"list": "请确认 owner/repo 参数正确",
|
||||
"create": "请确认标签名和版本号正确,仓库默认分支可能存在",
|
||||
"view": "请确认 Release ID 或标签名正确",
|
||||
"delete": "请确认 Release ID 正确,此操作不可恢复",
|
||||
"edit": "请确认 Release ID 和更新参数正确",
|
||||
"update": "请确认 Release ID 和更新参数正确",
|
||||
},
|
||||
"branch": {
|
||||
"list": "请确认 owner/repo 参数正确",
|
||||
"create": "请确认源分支名正确,默认分支名可能不匹配",
|
||||
"delete": "请确认分支名正确,此操作不可恢复",
|
||||
"protect": "请确认分支名正确",
|
||||
"unprotect": "请确认分支名正确",
|
||||
"all": "请确认 owner/repo 参数正确",
|
||||
"default": "请确认仓库存在且可见",
|
||||
"restore": "请确认分支名正确且分支已被删除",
|
||||
"batch-delete": "请确认分支名正确,此操作不可恢复",
|
||||
"batch-protect": "请确认分支名正确",
|
||||
},
|
||||
"repo": {
|
||||
"list": "请确认用户/组织名正确",
|
||||
"info": "请确认 owner/repo 参数正确",
|
||||
"create": "请确认仓库名合法且不与其他仓库冲突",
|
||||
"fork": "请确认源仓库存在且可见",
|
||||
"delete": "请确认仓库名正确,此操作不可恢复",
|
||||
"update": "请确认更新参数正确",
|
||||
"about": "请确认 owner/repo 参数正确",
|
||||
"menu": "请确认 owner/repo 参数正确",
|
||||
"units-get": "请确认 owner/repo 参数正确",
|
||||
"units-set": "请确认仓库单元设置值有效",
|
||||
"edit-detail": "请确认更新参数正确",
|
||||
"simple": "请确认 owner/repo 参数正确",
|
||||
"code-stats": "请确认 owner/repo 参数正确",
|
||||
"languages": "请确认 owner/repo 参数正确",
|
||||
"contributors": "请确认 owner/repo 参数正确",
|
||||
"contributors-stat": "请确认 owner/repo 参数正确",
|
||||
"recommend": "该功能可能需要特定权限",
|
||||
"star": "请确认 owner/repo 参数正确",
|
||||
"unstar": "请确认 owner/repo 参数正确",
|
||||
"watch": "请确认 owner/repo 参数正确",
|
||||
"unwatch": "请确认 owner/repo 参数正确",
|
||||
"stargazers": "请确认 owner/repo 参数正确",
|
||||
"watchers": "请确认 owner/repo 参数正确",
|
||||
"transfer": "请确认目标所有者正确,且你有仓库转让权限",
|
||||
"cancel-transfer": "请确认存在待处理的转让请求",
|
||||
"transfer-orgs": "请确认你的组织列表中存在目标组织",
|
||||
"invite-link": "请确认 owner/repo 参数正确",
|
||||
"join": "请确认邀请链接有效",
|
||||
"quit": "请确认 owner/repo 参数正确",
|
||||
"migrate": "请确认源仓库地址正确且可访问",
|
||||
"sync-mirror": "请确认仓库为镜像仓库",
|
||||
"topics": "请确认 owner/repo 参数正确",
|
||||
"create-topic": "请确认标签名合法",
|
||||
"delete-topic": "请确认标签名正确",
|
||||
},
|
||||
}
|
||||
|
||||
// formatInteractiveError wraps a command execution error for display in the
|
||||
// interactive REPL. It hides raw HTTP/API technical details and provides
|
||||
// command-specific suggestions. The original error is appended in a dim line.
|
||||
func formatInteractiveError(group string, s *common.Shortcut, err error) string {
|
||||
var apiErr *client.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return formatAPIError(group, s, apiErr)
|
||||
}
|
||||
return fmt.Sprintf("✗ %s +%s 执行失败\n 原因:%v", group, s.Name, err)
|
||||
}
|
||||
|
||||
// formatAPIError builds a user-friendly error message from an APIError.
|
||||
func formatAPIError(group string, s *common.Shortcut, e *client.APIError) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("✗ %s +%s 执行失败", group, s.Name))
|
||||
|
||||
// Reason — use the API message directly, it's already Chinese in most cases
|
||||
reason := e.Message
|
||||
if reason == "" {
|
||||
reason = fmt.Sprintf("服务器返回错误码 [%v]", e.Code)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n 原因:%s", reason))
|
||||
|
||||
// Suggestion — command-specific first, then generic fallback
|
||||
if suggestion := lookupSuggestion(group, s.Name); suggestion != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n 建议:%s", suggestion))
|
||||
}
|
||||
|
||||
// Original error in dim text
|
||||
sb.WriteString(fmt.Sprintf("\n [原始: %s]", e.Error()))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// lookupSuggestion returns a command-specific suggestion, or empty string.
|
||||
func lookupSuggestion(group, cmd string) string {
|
||||
if cmds, ok := commandSuggestions[group]; ok {
|
||||
if suggestion, ok := cmds[cmd]; ok {
|
||||
return suggestion
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatCommandDisplay formats a command for display, e.g. "issue +create --title hello --body world".
|
||||
func formatCommandDisplay(group string, s *common.Shortcut, args map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(group)
|
||||
b.WriteString(" +")
|
||||
b.WriteString(s.Name)
|
||||
|
||||
// Sort flag names for deterministic output
|
||||
keys := make([]string, 0, len(args))
|
||||
for k := range args {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, k := range keys {
|
||||
b.WriteString(" --")
|
||||
b.WriteString(k)
|
||||
b.WriteString(" ")
|
||||
b.WriteString(args[k])
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestExecutor_UsesRealRuntimeContext verifies that Execute builds the
|
||||
// RuntimeContext via NewRuntimeContext (which initializes Client/Format/etc.)
|
||||
// rather than a bare struct literal that leaves Client == nil.
|
||||
//
|
||||
// Regression: previously Execute did `&common.RuntimeContext{Args: args}`,
|
||||
// so any command calling ctx.CallAPI panicked with a nil pointer dereference
|
||||
// inside the executor goroutine, which swallowed the execResultMsg and left
|
||||
// the REPL showing no output at all.
|
||||
func TestExecutor_UsesRealRuntimeContext(t *testing.T) {
|
||||
called := false
|
||||
s := &common.Shortcut{
|
||||
Name: "probe",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
called = true
|
||||
// A real command uses ctx.Client / ctx.Format. If Execute built the
|
||||
// context correctly, Client must be non-nil and Format non-empty.
|
||||
if ctx.Client == nil {
|
||||
t.Error("ctx.Client is nil — Execute did not use NewRuntimeContext")
|
||||
}
|
||||
if ctx.Format == "" {
|
||||
t.Error("ctx.Format is empty — Execute did not initialize format")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
exec := &Executor{}
|
||||
out, err := exec.Execute(s, map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("Run was not invoked")
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("expected empty output, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutor_PanicDoesNotCorruptStdout verifies that if a command panics,
|
||||
// Execute recovers, returns the panic as an error, and — critically — restores
|
||||
// os.Stdout so the REPL is not left with stdout pointing at a closed pipe.
|
||||
func TestExecutor_PanicDoesNotCorruptStdout(t *testing.T) {
|
||||
original := os.Stdout
|
||||
s := &common.Shortcut{
|
||||
Name: "boom",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
panic("simulated command failure")
|
||||
},
|
||||
}
|
||||
|
||||
exec := &Executor{}
|
||||
_, err := exec.Execute(s, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected Execute to return an error for a panicking command")
|
||||
}
|
||||
if os.Stdout != original {
|
||||
t.Fatal("os.Stdout was not restored after a panic — REPL would be left broken")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestExecutor_CaptureOutput verifies that Execute captures stdout output
|
||||
// even when the Run function writes directly to os.Stdout.
|
||||
func TestExecutor_CaptureOutput(t *testing.T) {
|
||||
e := &Executor{}
|
||||
s := &common.Shortcut{
|
||||
Name: "test",
|
||||
Description: "test command",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
// Do nothing — output should be empty
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
output, err := e.Execute(s, map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned error: %v", err)
|
||||
}
|
||||
if output != "" {
|
||||
t.Fatalf("expected empty output, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutor_CaptureOutputPrints verifies that stdout written inside Run
|
||||
// is captured and returned as a string.
|
||||
func TestExecutor_CaptureOutputPrints(t *testing.T) {
|
||||
e := &Executor{}
|
||||
s := &common.Shortcut{
|
||||
Name: "test",
|
||||
Description: "test command",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
fmt.Fprint(os.Stdout, "hello from command")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
output, err := e.Execute(s, map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned error: %v", err)
|
||||
}
|
||||
if output != "hello from command" {
|
||||
t.Fatalf("expected %q, got %q", "hello from command", output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutor_RestoresStdout verifies that os.Stdout is restored after Execute.
|
||||
func TestExecutor_RestoresStdout(t *testing.T) {
|
||||
original := os.Stdout
|
||||
e := &Executor{}
|
||||
s := &common.Shortcut{
|
||||
Name: "test",
|
||||
Description: "test command",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
_, _ = e.Execute(s, map[string]string{})
|
||||
|
||||
if os.Stdout != original {
|
||||
t.Fatal("os.Stdout was not restored after Execute")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDirectCommand uses table-driven tests for parseDirectCommand.
|
||||
func TestParseDirectCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
group string
|
||||
cmd string
|
||||
flagStr string
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "single word only",
|
||||
input: "issue",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "second word without plus",
|
||||
input: "issue list",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "two words with plus",
|
||||
input: "issue +list",
|
||||
group: "issue",
|
||||
cmd: "list",
|
||||
flagStr: "",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "with flags",
|
||||
input: "issue +list --state open",
|
||||
group: "issue",
|
||||
cmd: "list",
|
||||
flagStr: "--state open",
|
||||
ok: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
group, cmd, flagStr, ok := parseDirectCommand(tt.input)
|
||||
if ok != tt.ok {
|
||||
t.Errorf("ok = %v, want %v", ok, tt.ok)
|
||||
}
|
||||
if ok {
|
||||
if group != tt.group {
|
||||
t.Errorf("group = %q, want %q", group, tt.group)
|
||||
}
|
||||
if cmd != tt.cmd {
|
||||
t.Errorf("cmd = %q, want %q", cmd, tt.cmd)
|
||||
}
|
||||
if flagStr != tt.flagStr {
|
||||
t.Errorf("flagStr = %q, want %q", flagStr, tt.flagStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFlags uses table-driven tests for parseFlagString.
|
||||
func TestParseFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flagStr string
|
||||
shortMap map[string]string
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "long flags only",
|
||||
flagStr: "--title hello --body world",
|
||||
shortMap: map[string]string{},
|
||||
want: map[string]string{
|
||||
"title": "hello",
|
||||
"body": "world",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "short flag expanded",
|
||||
flagStr: "-n 42",
|
||||
shortMap: map[string]string{"n": "number"},
|
||||
want: map[string]string{
|
||||
"number": "42",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "equals syntax",
|
||||
flagStr: "--title=hello",
|
||||
shortMap: map[string]string{},
|
||||
want: map[string]string{
|
||||
"title": "hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseFlagString(tt.flagStr, tt.shortMap)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("got %d flags, want %d flags: %v", len(got), len(tt.want), got)
|
||||
}
|
||||
for k, v := range tt.want {
|
||||
if got[k] != v {
|
||||
t.Errorf("flag %q = %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindShortcut covers three scenarios: found, wrong group, wrong cmd.
|
||||
func TestFindShortcut(t *testing.T) {
|
||||
listShortcut := &common.Shortcut{Name: "list", Description: "list issues"}
|
||||
createShortcut := &common.Shortcut{Name: "create", Description: "create issue"}
|
||||
all := map[string][]*common.Shortcut{
|
||||
"issue": {listShortcut, createShortcut},
|
||||
"pr": {{Name: "list", Description: "list PRs"}},
|
||||
}
|
||||
|
||||
// Scenario 1: found
|
||||
s, ok := findShortcut(all, "issue", "list")
|
||||
if !ok {
|
||||
t.Fatal("expected to find issue +list")
|
||||
}
|
||||
if s != listShortcut {
|
||||
t.Fatal("returned wrong shortcut")
|
||||
}
|
||||
|
||||
// Scenario 2: group not found
|
||||
_, ok = findShortcut(all, "repo", "list")
|
||||
if ok {
|
||||
t.Fatal("expected not found for unknown group")
|
||||
}
|
||||
|
||||
// Scenario 3: group found but cmd not found
|
||||
_, ok = findShortcut(all, "issue", "close")
|
||||
if ok {
|
||||
t.Fatal("expected not found for unknown cmd in valid group")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildShortMap verifies short-to-long flag mapping construction.
|
||||
func TestBuildShortMap(t *testing.T) {
|
||||
s := &common.Shortcut{
|
||||
Name: "list",
|
||||
Flags: []common.Flag{
|
||||
{Name: "state", Short: "s"},
|
||||
{Name: "number", Short: "n"},
|
||||
{Name: "verbose"},
|
||||
},
|
||||
}
|
||||
|
||||
m := buildShortMap(s)
|
||||
if len(m) != 2 {
|
||||
t.Fatalf("expected 2 entries in short map, got %d", len(m))
|
||||
}
|
||||
if m["s"] != "state" {
|
||||
t.Errorf("short 's' = %q, want 'state'", m["s"])
|
||||
}
|
||||
if m["n"] != "number" {
|
||||
t.Errorf("short 'n' = %q, want 'number'", m["n"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingRequiredFlags checks that required-but-missing flags are reported.
|
||||
func TestMissingRequiredFlags(t *testing.T) {
|
||||
s := &common.Shortcut{
|
||||
Name: "create",
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Required: true},
|
||||
{Name: "body", Required: true},
|
||||
{Name: "state", Required: false},
|
||||
},
|
||||
}
|
||||
|
||||
// Provide title but not body
|
||||
args := map[string]string{"title": "hello"}
|
||||
missing := missingRequiredFlags(s, args)
|
||||
|
||||
if len(missing) != 1 {
|
||||
t.Fatalf("expected 1 missing flag, got %d: %v", len(missing), missing)
|
||||
}
|
||||
if missing[0] != "--body" {
|
||||
t.Errorf("missing flag = %q, want '--body'", missing[0])
|
||||
}
|
||||
|
||||
// Provide all required
|
||||
args2 := map[string]string{"title": "hello", "body": "world"}
|
||||
missing2 := missingRequiredFlags(s, args2)
|
||||
if len(missing2) != 0 {
|
||||
t.Fatalf("expected 0 missing flags, got %d: %v", len(missing2), missing2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatCommandDisplay verifies the display string format.
|
||||
func TestFormatCommandDisplay(t *testing.T) {
|
||||
s := &common.Shortcut{
|
||||
Name: "create",
|
||||
Description: "create an issue",
|
||||
}
|
||||
args := map[string]string{
|
||||
"title": "hello",
|
||||
"body": "world",
|
||||
}
|
||||
|
||||
got := formatCommandDisplay("issue", s, args)
|
||||
|
||||
// The order of flags may vary, so check that all parts are present
|
||||
if !strings.HasPrefix(got, "issue +create") {
|
||||
t.Fatalf("expected prefix 'issue +create', got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "--title hello") {
|
||||
t.Fatalf("expected '--title hello' in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "--body world") {
|
||||
t.Fatalf("expected '--body world' in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// formField represents a single field in the argument form.
|
||||
type formField struct {
|
||||
flag common.Flag
|
||||
input textinput.Model
|
||||
boolValue bool
|
||||
isBool bool
|
||||
}
|
||||
|
||||
// formModel is the parameter form sub-component.
|
||||
type formModel struct {
|
||||
group string
|
||||
shortcut *common.Shortcut
|
||||
fields []formField
|
||||
focusIdx int
|
||||
submitted bool
|
||||
cancelled bool
|
||||
values map[string]string
|
||||
width int
|
||||
height int
|
||||
errors []string
|
||||
}
|
||||
|
||||
var formTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||
var formErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
|
||||
var formHelpStyle = lipgloss.NewStyle().Faint(true)
|
||||
var formHintStyle = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("11"))
|
||||
var formLabelStyle = lipgloss.NewStyle().Bold(true)
|
||||
var formRequiredMark = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
|
||||
var formRequiredTag = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Faint(true)
|
||||
var formOptionalTag = lipgloss.NewStyle().Faint(true)
|
||||
var formDefaultTag = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Faint(true)
|
||||
|
||||
// newFormModel creates a new form model from a shortcut's flags.
|
||||
func newFormModel(group string, shortcut *common.Shortcut, width, height int, prefill map[string]string) formModel {
|
||||
fields := make([]formField, 0, len(shortcut.Flags))
|
||||
|
||||
for _, f := range shortcut.Flags {
|
||||
if f.Bool {
|
||||
// Bool field: no text input, just toggle
|
||||
val := f.Default
|
||||
if v, ok := prefill[f.Name]; ok {
|
||||
val = v
|
||||
}
|
||||
fields = append(fields, formField{
|
||||
flag: f,
|
||||
boolValue: val == "true",
|
||||
isBool: true,
|
||||
})
|
||||
} else {
|
||||
// Text field
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = f.Usage
|
||||
if f.Default != "" {
|
||||
ti.Placeholder = f.Default
|
||||
}
|
||||
ti.Width = width - 30
|
||||
if ti.Width < 20 {
|
||||
ti.Width = 20
|
||||
}
|
||||
|
||||
// Pre-fill from provided values
|
||||
if v, ok := prefill[f.Name]; ok {
|
||||
ti.SetValue(v)
|
||||
}
|
||||
|
||||
fields = append(fields, formField{
|
||||
flag: f,
|
||||
input: ti,
|
||||
isBool: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
m := formModel{
|
||||
group: group,
|
||||
shortcut: shortcut,
|
||||
fields: fields,
|
||||
width: width,
|
||||
height: height,
|
||||
errors: []string{},
|
||||
}
|
||||
|
||||
// Focus the first non-bool field, or first field
|
||||
m.focusFirstField()
|
||||
return m
|
||||
}
|
||||
|
||||
// focusFirstField sets focus to the first focusable field.
|
||||
func (m *formModel) focusFirstField() {
|
||||
for i := range m.fields {
|
||||
m.focusIdx = i
|
||||
if !m.fields[i].isBool {
|
||||
m.fields[i].input.Focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// validate checks that all required fields have values.
|
||||
func (m formModel) validate() []string {
|
||||
var errs []string
|
||||
for _, f := range m.fields {
|
||||
if f.flag.Required {
|
||||
if f.isBool {
|
||||
// Bool fields are always valid
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(f.input.Value()) == "" {
|
||||
errs = append(errs, fmt.Sprintf("--%s 为必填参数,不能为空", f.flag.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// collectValues gathers all field values into a map.
|
||||
func (m *formModel) collectValues() map[string]string {
|
||||
values := make(map[string]string)
|
||||
for _, f := range m.fields {
|
||||
if f.isBool {
|
||||
if f.boolValue {
|
||||
values[f.flag.Name] = "true"
|
||||
}
|
||||
} else {
|
||||
v := f.input.Value()
|
||||
if v == "" && f.flag.Default != "" {
|
||||
v = f.flag.Default
|
||||
}
|
||||
if v != "" {
|
||||
values[f.flag.Name] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// Update handles messages for the form.
|
||||
func (m formModel) Update(msg tea.Msg) (formModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
m.cancelled = true
|
||||
return m, nil
|
||||
|
||||
case tea.KeyTab, tea.KeyDown:
|
||||
m.nextField()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyShiftTab, tea.KeyUp:
|
||||
m.prevField()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyEnter:
|
||||
if len(m.fields) > 0 && m.fields[m.focusIdx].isBool {
|
||||
m.fields[m.focusIdx].boolValue = !m.fields[m.focusIdx].boolValue
|
||||
return m, nil
|
||||
}
|
||||
// Validate
|
||||
errs := m.validate()
|
||||
if len(errs) > 0 {
|
||||
m.errors = errs
|
||||
return m, nil
|
||||
}
|
||||
m.values = m.collectValues()
|
||||
m.submitted = true
|
||||
return m, nil
|
||||
|
||||
case tea.KeySpace:
|
||||
if len(m.fields) > 0 && m.fields[m.focusIdx].isBool {
|
||||
m.fields[m.focusIdx].boolValue = !m.fields[m.focusIdx].boolValue
|
||||
return m, nil
|
||||
}
|
||||
// Fall through to text input
|
||||
}
|
||||
|
||||
// Forward to focused text input
|
||||
if len(m.fields) > 0 && !m.fields[m.focusIdx].isBool {
|
||||
var cmd tea.Cmd
|
||||
m.fields[m.focusIdx].input, cmd = m.fields[m.focusIdx].input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// nextField moves focus to the next field.
|
||||
func (m *formModel) nextField() {
|
||||
if len(m.fields) == 0 {
|
||||
return
|
||||
}
|
||||
// Blur current
|
||||
if !m.fields[m.focusIdx].isBool {
|
||||
m.fields[m.focusIdx].input.Blur()
|
||||
}
|
||||
m.focusIdx = (m.focusIdx + 1) % len(m.fields)
|
||||
// Focus new
|
||||
if !m.fields[m.focusIdx].isBool {
|
||||
m.fields[m.focusIdx].input.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
// prevField moves focus to the previous field.
|
||||
func (m *formModel) prevField() {
|
||||
if len(m.fields) == 0 {
|
||||
return
|
||||
}
|
||||
// Blur current
|
||||
if !m.fields[m.focusIdx].isBool {
|
||||
m.fields[m.focusIdx].input.Blur()
|
||||
}
|
||||
m.focusIdx--
|
||||
if m.focusIdx < 0 {
|
||||
m.focusIdx = len(m.fields) - 1
|
||||
}
|
||||
// Focus new
|
||||
if !m.fields[m.focusIdx].isBool {
|
||||
m.fields[m.focusIdx].input.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
// fieldMetaTag builds the metadata tag string for a flag (required/optional, default).
|
||||
func fieldMetaTag(f formField) string {
|
||||
var parts []string
|
||||
if f.flag.Required {
|
||||
parts = append(parts, formRequiredTag.Render("[必填]"))
|
||||
} else {
|
||||
parts = append(parts, formOptionalTag.Render("[可选]"))
|
||||
}
|
||||
if !f.isBool && f.flag.Default != "" {
|
||||
parts = append(parts, formDefaultTag.Render(fmt.Sprintf("默认=%s", f.flag.Default)))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// View renders the form.
|
||||
func (m formModel) View() string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Title
|
||||
title := formTitleStyle.Render(fmt.Sprintf("%s +%s", m.group, m.shortcut.Name))
|
||||
sb.WriteString(title)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Fields
|
||||
for i, f := range m.fields {
|
||||
label := f.flag.Name
|
||||
if f.flag.Required {
|
||||
label = label + formRequiredMark.Render(" *")
|
||||
}
|
||||
meta := fieldMetaTag(f)
|
||||
|
||||
if f.isBool {
|
||||
// Bool field
|
||||
check := "[ ]"
|
||||
if f.boolValue {
|
||||
check = "[✓]"
|
||||
}
|
||||
line := fmt.Sprintf(" %s %s %s", check, label, meta)
|
||||
if i == m.focusIdx {
|
||||
line = selectedStyle.Render(line)
|
||||
}
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
if f.flag.Usage != "" {
|
||||
sb.WriteString(formHintStyle.Render(fmt.Sprintf(" %s", f.flag.Usage)))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
// Text field — show label with meta on first line, input on second
|
||||
// First line: name * [必填] 默认=1
|
||||
header := fmt.Sprintf(" %s %s:", label, meta)
|
||||
if i == m.focusIdx {
|
||||
header = formLabelStyle.Render(header)
|
||||
}
|
||||
sb.WriteString(header)
|
||||
sb.WriteString("\n")
|
||||
// Second line: input field, indented
|
||||
sb.WriteString(fmt.Sprintf(" %s", f.input.View()))
|
||||
sb.WriteString("\n")
|
||||
if f.flag.Usage != "" {
|
||||
sb.WriteString(formHintStyle.Render(fmt.Sprintf(" %s", f.flag.Usage)))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Errors
|
||||
if len(m.errors) > 0 {
|
||||
sb.WriteString("\n")
|
||||
for _, e := range m.errors {
|
||||
sb.WriteString(formErrorStyle.Render(fmt.Sprintf(" ✗ %s", e)))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Help
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(formHelpStyle.Render(" Enter submit · Tab/↑↓ next field · Shift+Tab prev field · Space toggle bool · Esc cancel"))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/context"
|
||||
)
|
||||
|
||||
// NewInteractiveCmd creates the Cobra command for the interactive REPL.
|
||||
func NewInteractiveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "interactive",
|
||||
Short: "Start interactive REPL with command palette",
|
||||
Long: `Start an interactive REPL session with a command palette.
|
||||
|
||||
Type / to open the command palette with fuzzy search.
|
||||
Use arrow keys to navigate, Enter to select, Tab to fill parameters.
|
||||
Type "exit" or press Ctrl+D to quit.`,
|
||||
Aliases: []string{"i"},
|
||||
Example: ` gitlink interactive
|
||||
gitlink i`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// 交互模式默认用表格输出,更易阅读;用户显式指定 --format 时优先采用用户的值。
|
||||
if cmdutil.Format == "" {
|
||||
cmdutil.Format = "table"
|
||||
}
|
||||
owner, repo, err := context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo)
|
||||
if err != nil {
|
||||
owner = cmdutil.Owner
|
||||
repo = cmdutil.Repo
|
||||
}
|
||||
return Run(owner, repo)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// makeShortcutForViewport builds a minimal shortcut so formatCommandDisplay has
|
||||
// something to render in the display header line of the output viewer.
|
||||
func makeShortcutForViewport() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "list",
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongOutputEntersViewport verifies that when command output exceeds the
|
||||
// available terminal height, the REPL switches into the scrollable output
|
||||
// viewer (stateOutput) instead of falling back to the inline input state.
|
||||
func TestLongOutputEntersViewport(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
// Small terminal: m.height-4 == 1 line of room, so any multi-line output
|
||||
// must overflow into the viewport.
|
||||
m.height = 5
|
||||
m.width = 80
|
||||
m.state = stateExecuting
|
||||
|
||||
longOutput := strings.Repeat("line of output\n", 20)
|
||||
msg := execResultMsg{
|
||||
group: "issue",
|
||||
shortcut: makeShortcutForViewport(),
|
||||
args: map[string]string{},
|
||||
output: longOutput,
|
||||
err: nil,
|
||||
}
|
||||
|
||||
newModel, _ := m.Update(msg)
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
|
||||
if repl.state != stateOutput {
|
||||
t.Fatalf("expected stateOutput for long output, got %v", repl.state)
|
||||
}
|
||||
if repl.viewport.Height != m.height-2 {
|
||||
t.Fatalf("expected viewport height %d, got %d", m.height-2, repl.viewport.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortOutputStaysInput verifies that short command output keeps the
|
||||
// existing behaviour: store lastOutput and return to stateInput.
|
||||
func TestShortOutputStaysInput(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
m.height = 40 // plenty of room
|
||||
m.width = 80
|
||||
m.state = stateExecuting
|
||||
|
||||
shortOutput := "only one line"
|
||||
msg := execResultMsg{
|
||||
group: "issue",
|
||||
shortcut: makeShortcutForViewport(),
|
||||
args: map[string]string{},
|
||||
output: shortOutput,
|
||||
err: nil,
|
||||
}
|
||||
|
||||
newModel, _ := m.Update(msg)
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
|
||||
if repl.state != stateInput {
|
||||
t.Fatalf("expected stateInput for short output, got %v", repl.state)
|
||||
}
|
||||
if repl.lastOutput == "" {
|
||||
t.Fatalf("expected lastOutput to be set for short output")
|
||||
}
|
||||
if !strings.Contains(repl.lastOutput, shortOutput) {
|
||||
t.Fatalf("expected lastOutput to contain %q, got %q", shortOutput, repl.lastOutput)
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewportQuitReturnsToInput verifies that pressing 'q' in the output
|
||||
// viewer returns the REPL to the input state.
|
||||
func TestViewportQuitReturnsToInput(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
m.state = stateOutput
|
||||
|
||||
keyQ := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}
|
||||
newModel, _ := m.Update(keyQ)
|
||||
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
if repl.state != stateInput {
|
||||
t.Fatalf("expected stateInput after pressing 'q', got %v", repl.state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewportEscReturnsToInput verifies that Esc also exits the output viewer.
|
||||
func TestViewportEscReturnsToInput(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
m.state = stateOutput
|
||||
|
||||
keyEsc := tea.KeyMsg{Type: tea.KeyEsc}
|
||||
newModel, _ := m.Update(keyEsc)
|
||||
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
if repl.state != stateInput {
|
||||
t.Fatalf("expected stateInput after pressing Esc, got %v", repl.state)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// paletteLevel tracks which level of the palette we are browsing.
|
||||
type paletteLevel int
|
||||
|
||||
const (
|
||||
levelGroup paletteLevel = iota // browsing command groups
|
||||
levelCommand // browsing commands within a group
|
||||
)
|
||||
|
||||
// PaletteResult holds the user's selection from the palette.
|
||||
type PaletteResult struct {
|
||||
Group string
|
||||
Shortcut *common.Shortcut
|
||||
}
|
||||
|
||||
// commandItem implements list.Item and list.DefaultItem.
|
||||
type commandItem struct {
|
||||
title string
|
||||
description string
|
||||
shortcut *common.Shortcut // nil for group items
|
||||
group string // empty for group items
|
||||
}
|
||||
|
||||
func (i commandItem) Title() string { return i.title }
|
||||
func (i commandItem) Description() string { return i.description }
|
||||
func (i commandItem) FilterValue() string { return i.title + " " + i.description }
|
||||
|
||||
// paletteModel is the command palette sub-component.
|
||||
type paletteModel struct {
|
||||
level paletteLevel
|
||||
search textinput.Model
|
||||
list list.Model
|
||||
groups map[string][]*common.Shortcut
|
||||
descs map[string]string
|
||||
groupKeys []string
|
||||
selected string // currently selected group name (when levelCommand)
|
||||
result *PaletteResult
|
||||
quitting bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
var paletteTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||
var selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
|
||||
var paletteHelpStyle = lipgloss.NewStyle().Faint(true)
|
||||
|
||||
// newPaletteModel creates a new palette model.
|
||||
func newPaletteModel(groups map[string][]*common.Shortcut, descs map[string]string, width, height int) paletteModel {
|
||||
search := textinput.New()
|
||||
search.Prompt = "> "
|
||||
search.Placeholder = "Search..."
|
||||
search.Focus()
|
||||
search.Width = width - 4
|
||||
|
||||
// Sort group keys for deterministic ordering
|
||||
keys := make([]string, 0, len(groups))
|
||||
for k := range groups {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
// Build group items
|
||||
items := make([]list.Item, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
desc := descs[k]
|
||||
items = append(items, commandItem{
|
||||
title: k,
|
||||
description: desc,
|
||||
})
|
||||
}
|
||||
|
||||
delegate := list.NewDefaultDelegate()
|
||||
l := list.New(items, delegate, width, height-4)
|
||||
l.SetShowTitle(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowFilter(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
|
||||
return paletteModel{
|
||||
level: levelGroup,
|
||||
search: search,
|
||||
list: l,
|
||||
groups: groups,
|
||||
descs: descs,
|
||||
groupKeys: keys,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// switchToCommands switches the list to show commands for the given group.
|
||||
func (m *paletteModel) switchToCommands(group string) {
|
||||
m.selected = group
|
||||
m.level = levelCommand
|
||||
|
||||
shortcuts := m.groups[group]
|
||||
items := make([]list.Item, 0, len(shortcuts))
|
||||
for _, s := range shortcuts {
|
||||
items = append(items, commandItem{
|
||||
title: "+" + s.Name,
|
||||
description: s.Description,
|
||||
shortcut: s,
|
||||
group: group,
|
||||
})
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.list.ResetSelected()
|
||||
m.search.SetValue("")
|
||||
}
|
||||
|
||||
// backToGroups returns to the group list.
|
||||
func (m *paletteModel) backToGroups() {
|
||||
m.level = levelGroup
|
||||
m.selected = ""
|
||||
|
||||
items := make([]list.Item, 0, len(m.groupKeys))
|
||||
for _, k := range m.groupKeys {
|
||||
items = append(items, commandItem{
|
||||
title: k,
|
||||
description: m.descs[k],
|
||||
})
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.list.ResetSelected()
|
||||
m.search.SetValue("")
|
||||
}
|
||||
|
||||
// filterItems filters the current list items by the given query.
|
||||
func (m *paletteModel) filterItems(query string) {
|
||||
var items []list.Item
|
||||
|
||||
if m.level == levelGroup {
|
||||
for _, k := range m.groupKeys {
|
||||
desc := m.descs[k]
|
||||
if matchesQuery(k, desc, query) {
|
||||
items = append(items, commandItem{
|
||||
title: k,
|
||||
description: desc,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shortcuts := m.groups[m.selected]
|
||||
for _, s := range shortcuts {
|
||||
name := "+" + s.Name
|
||||
if matchesQuery(name, s.Description, query) {
|
||||
items = append(items, commandItem{
|
||||
title: name,
|
||||
description: s.Description,
|
||||
shortcut: s,
|
||||
group: m.selected,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items == nil {
|
||||
items = []list.Item{}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.list.ResetSelected()
|
||||
}
|
||||
|
||||
// matchesQuery checks if the query is a subsequence (fzy-style fuzzy match)
|
||||
// of either the title or the description, case-insensitively. An empty query
|
||||
// matches everything.
|
||||
func matchesQuery(title, description, query string) bool {
|
||||
return subsequenceMatch(query, title) || subsequenceMatch(query, description)
|
||||
}
|
||||
|
||||
// subsequenceMatch returns true if every character of query appears in target
|
||||
// in the same order (not necessarily contiguously), ignoring case. An empty
|
||||
// query always matches.
|
||||
func subsequenceMatch(query, target string) bool {
|
||||
query = strings.ToLower(query)
|
||||
target = strings.ToLower(target)
|
||||
if query == "" {
|
||||
return true
|
||||
}
|
||||
i := 0
|
||||
for j := 0; j < len(target) && i < len(query); j++ {
|
||||
if target[j] == query[i] {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return i == len(query)
|
||||
}
|
||||
|
||||
// Update handles messages for the palette.
|
||||
func (m paletteModel) Update(msg tea.Msg) (paletteModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
if m.level == levelCommand {
|
||||
m.backToGroups()
|
||||
return m, nil
|
||||
}
|
||||
// levelGroup: signal the outer REPL to return to stateInput, not quit
|
||||
m.quitting = true
|
||||
return m, nil
|
||||
|
||||
case tea.KeyEnter:
|
||||
selected := m.list.SelectedItem()
|
||||
if selected == nil {
|
||||
return m, nil
|
||||
}
|
||||
item := selected.(commandItem)
|
||||
if m.level == levelGroup {
|
||||
m.switchToCommands(item.title)
|
||||
return m, nil
|
||||
}
|
||||
// levelCommand: set result
|
||||
m.result = &PaletteResult{
|
||||
Group: m.selected,
|
||||
Shortcut: item.shortcut,
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyUp, tea.KeyDown:
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
default:
|
||||
// Forward to search input
|
||||
var cmd tea.Cmd
|
||||
m.search, cmd = m.search.Update(msg)
|
||||
m.filterItems(m.search.Value())
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.list.SetWidth(msg.Width)
|
||||
m.list.SetHeight(msg.Height - 4)
|
||||
m.search.Width = msg.Width - 4
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the palette.
|
||||
func (m paletteModel) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
|
||||
var title string
|
||||
if m.level == levelGroup {
|
||||
title = paletteTitleStyle.Render("Command Palette — Select a group")
|
||||
} else {
|
||||
title = paletteTitleStyle.Render(fmt.Sprintf("Command Palette — %s", m.selected))
|
||||
}
|
||||
|
||||
searchView := m.search.View()
|
||||
|
||||
// Render list items manually for better control
|
||||
var items strings.Builder
|
||||
listItems := m.list.Items()
|
||||
idx := m.list.Index()
|
||||
|
||||
for i, item := range listItems {
|
||||
ci := item.(commandItem)
|
||||
if i == idx {
|
||||
items.WriteString(selectedStyle.Render(fmt.Sprintf(" ❯ %s", ci.title)))
|
||||
if ci.description != "" {
|
||||
items.WriteString(" ")
|
||||
items.WriteString(paletteHelpStyle.Render(ci.description))
|
||||
}
|
||||
} else {
|
||||
items.WriteString(fmt.Sprintf(" %s", ci.title))
|
||||
if ci.description != "" {
|
||||
items.WriteString(" ")
|
||||
items.WriteString(paletteHelpStyle.Render(ci.description))
|
||||
}
|
||||
}
|
||||
items.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(listItems) == 0 {
|
||||
items.WriteString(paletteHelpStyle.Render(" No matches found"))
|
||||
items.WriteString("\n")
|
||||
}
|
||||
|
||||
help := paletteHelpStyle.Render("Enter select · Esc back · ↑↓ navigate · type to search")
|
||||
|
||||
return fmt.Sprintf("%s\n%s\n%s\n%s", title, searchView, items.String(), help)
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestPaletteSelectionOpensFormForOptionalFlags 验证:选中一个"只有可选参数、
|
||||
// 无必填参数"的命令(如 branch +batch-protect 的 names/from 二选一)时,
|
||||
// 仍然弹出参数表单,而非直接执行导致 "no ... provided" 错误。
|
||||
//
|
||||
// 回归:之前 updatePalette 只在有缺失的 Required flag 时才弹表单,
|
||||
// 导致 names/from 这类"至少一个"型参数(无法单独标 Required)的命令
|
||||
// 被直接执行,用户没机会输入参数。
|
||||
func TestPaletteSelectionOpensFormForOptionalFlags(t *testing.T) {
|
||||
// 构造一个模拟 branch +batch-protect 的 shortcut:所有 flag 都非必填。
|
||||
shortcuts := map[string][]*common.Shortcut{
|
||||
"branch": {
|
||||
{
|
||||
Name: "batch-protect",
|
||||
Description: "批量保护分支",
|
||||
Flags: []common.Flag{
|
||||
{Name: "names", Usage: "分支名(逗号分隔)"}, // 无 Required
|
||||
{Name: "from", Usage: "文件路径"}, // 无 Required
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error { return nil },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
m := newReplModel("owner", "repo")
|
||||
// 注入测试 registry
|
||||
m.shortcuts = shortcuts
|
||||
|
||||
// 1) 输入 / 触发命令面板
|
||||
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
repl := m2.(replModel)
|
||||
if repl.state != statePalette {
|
||||
t.Fatalf("输入 / 后应在 statePalette,实际 %v", repl.state)
|
||||
}
|
||||
|
||||
// 2) 模拟用户选中 branch 组
|
||||
repl.palette.result = &PaletteResult{
|
||||
Group: "branch",
|
||||
Shortcut: shortcuts["branch"][0],
|
||||
}
|
||||
|
||||
// 3) 派发一条任意消息触发 updatePalette 的结果检查
|
||||
m3, _ := repl.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
final := m3.(replModel)
|
||||
|
||||
if final.state != stateForm {
|
||||
t.Fatalf("选中带可选参数的命令应进入 stateForm,实际 %v(命令被直接执行了,用户没机会填参数)", final.state)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// replState tracks the current state of the REPL.
|
||||
type replState int
|
||||
|
||||
const (
|
||||
stateInput replState = iota // waiting for user text input
|
||||
statePalette // command palette is active
|
||||
stateForm // parameter form is active
|
||||
stateExecuting // executing a command
|
||||
stateError // showing an error
|
||||
stateOutput // scrolling through long command output
|
||||
)
|
||||
|
||||
// execResultMsg is sent when command execution completes.
|
||||
type execResultMsg struct {
|
||||
group string
|
||||
shortcut *common.Shortcut
|
||||
args map[string]string
|
||||
output string
|
||||
err error
|
||||
}
|
||||
|
||||
// replModel is the main REPL state machine.
|
||||
type replModel struct {
|
||||
state replState
|
||||
input textinput.Model
|
||||
palette paletteModel
|
||||
form formModel
|
||||
executor Executor
|
||||
shortcuts map[string][]*common.Shortcut
|
||||
descs map[string]string
|
||||
lastOutput string
|
||||
lastError string
|
||||
spinner spinner.Model
|
||||
viewport viewport.Model // 可滚动查看器,用于浏览超出一屏的长输出
|
||||
owner string
|
||||
repo string
|
||||
width int
|
||||
height int
|
||||
quitting bool
|
||||
showWelcome bool
|
||||
}
|
||||
|
||||
var promptStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
|
||||
var successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
|
||||
var errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
|
||||
var welcomeStyle = lipgloss.NewStyle().Faint(true)
|
||||
|
||||
// newReplModel creates a new REPL model.
|
||||
func newReplModel(owner, repo string) replModel {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = buildPrompt(owner, repo)
|
||||
ti.Focus()
|
||||
ti.Width = 60
|
||||
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
|
||||
|
||||
return replModel{
|
||||
state: stateInput,
|
||||
input: ti,
|
||||
executor: Executor{},
|
||||
shortcuts: shortcuts.GetAllShortcuts(),
|
||||
descs: shortcuts.GetDescriptions(),
|
||||
spinner: sp,
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
showWelcome: true,
|
||||
}
|
||||
}
|
||||
|
||||
// buildPrompt creates the prompt string.
|
||||
func buildPrompt(owner, repo string) string {
|
||||
if owner != "" && repo != "" {
|
||||
return fmt.Sprintf("gitlink (%s/%s)> ", owner, repo)
|
||||
}
|
||||
return "gitlink> "
|
||||
}
|
||||
|
||||
// Init initializes the REPL.
|
||||
func (m replModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
// Update is the main message dispatcher.
|
||||
func (m replModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
wmsg := msg.(tea.WindowSizeMsg)
|
||||
m.width = wmsg.Width
|
||||
m.height = wmsg.Height
|
||||
// 保持 viewport 尺寸与终端同步(底部留 2 行给提示符/帮助行)。
|
||||
m.viewport.Width = wmsg.Width
|
||||
m.viewport.Height = wmsg.Height - 2
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case stateInput:
|
||||
return m.updateInput(msg)
|
||||
case statePalette:
|
||||
return m.updatePalette(msg)
|
||||
case stateForm:
|
||||
return m.updateForm(msg)
|
||||
case stateExecuting:
|
||||
return m.updateExecuting(msg)
|
||||
case stateError:
|
||||
return m.updateError(msg)
|
||||
case stateOutput:
|
||||
return m.updateOutput(msg)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// updateInput handles the text input state.
|
||||
func (m replModel) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
keyMsg, isKey := msg.(tea.KeyMsg)
|
||||
if isKey {
|
||||
switch keyMsg.Type {
|
||||
case tea.KeyRunes:
|
||||
// Trigger the command palette immediately when the user types "/"
|
||||
// as the first character of a fresh input — no Enter required.
|
||||
if m.input.Value() == "" && len(keyMsg.Runes) > 0 && keyMsg.Runes[0] == '/' {
|
||||
m.showWelcome = false
|
||||
m.input.SetValue("")
|
||||
m.palette = newPaletteModel(m.shortcuts, m.descs, m.width, m.height)
|
||||
// Carry any extra characters typed alongside "/" as the initial query.
|
||||
if len(keyMsg.Runes) > 1 {
|
||||
query := string(keyMsg.Runes[1:])
|
||||
m.palette.search.SetValue(query)
|
||||
m.palette.filterItems(query)
|
||||
}
|
||||
m.state = statePalette
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case tea.KeyEnter:
|
||||
val := strings.TrimSpace(m.input.Value())
|
||||
m.input.SetValue("")
|
||||
m.showWelcome = false
|
||||
|
||||
if val == "" {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Exit commands
|
||||
if val == "exit" || val == "quit" {
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
// Palette mode (also reachable via "/query" + Enter)
|
||||
if strings.HasPrefix(val, "/") {
|
||||
m.palette = newPaletteModel(m.shortcuts, m.descs, m.width, m.height)
|
||||
if len(val) > 1 {
|
||||
query := val[1:]
|
||||
m.palette.search.SetValue(query)
|
||||
m.palette.filterItems(query)
|
||||
}
|
||||
m.state = statePalette
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Direct command: "group +cmd ..."
|
||||
group, cmd, flagStr, ok := parseDirectCommand(val)
|
||||
if ok {
|
||||
s, found := findShortcut(m.shortcuts, group, cmd)
|
||||
if !found {
|
||||
m.lastError = fmt.Sprintf("Unknown command: %s +%s", group, cmd)
|
||||
m.state = stateError
|
||||
return m, nil
|
||||
}
|
||||
|
||||
shortMap := buildShortMap(s)
|
||||
args := parseFlagString(flagStr, shortMap)
|
||||
missing := missingRequiredFlags(s, args)
|
||||
|
||||
if len(missing) > 0 {
|
||||
// Show form with prefilled values
|
||||
m.form = newFormModel(group, s, m.width, m.height, args)
|
||||
m.state = stateForm
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Execute directly
|
||||
m.state = stateExecuting
|
||||
return m, m.executeCommand(group, s, args)
|
||||
}
|
||||
|
||||
// Unrecognized input
|
||||
m.lastError = fmt.Sprintf("Unknown input: %q\nType a command like \"issue +list\" or \"/\" to open the command palette.", val)
|
||||
m.state = stateError
|
||||
return m, nil
|
||||
|
||||
case tea.KeyCtrlD, tea.KeyCtrlC:
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to text input
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// executeCommand returns a tea.Cmd that runs a shortcut asynchronously.
|
||||
func (m replModel) executeCommand(group string, s *common.Shortcut, args map[string]string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
output, err := m.executor.Execute(s, args)
|
||||
return execResultMsg{
|
||||
group: group,
|
||||
shortcut: s,
|
||||
args: args,
|
||||
output: output,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updatePalette delegates to the palette sub-component.
|
||||
func (m replModel) updatePalette(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.palette, cmd = m.palette.Update(msg)
|
||||
|
||||
if m.palette.quitting {
|
||||
m.palette.quitting = false
|
||||
m.state = stateInput
|
||||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
if m.palette.result != nil {
|
||||
result := m.palette.result
|
||||
s := result.Shortcut
|
||||
|
||||
// Open the form whenever the command has ANY flags — not just required
|
||||
// ones. Many commands have "at-least-one-of" parameters (e.g. branch
|
||||
// +batch-protect needs --names OR --from) that can't be marked Required
|
||||
// individually, so skipping the form for "optional-only" commands left
|
||||
// users unable to supply them and the command failed at runtime.
|
||||
// Commands with no flags at all execute directly.
|
||||
if len(s.Flags) > 0 {
|
||||
m.form = newFormModel(result.Group, s, m.width, m.height, nil)
|
||||
m.state = stateForm
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// No flags → execute directly
|
||||
m.state = stateExecuting
|
||||
return m, tea.Batch(m.executeCommand(result.Group, s, map[string]string{}), m.spinner.Tick)
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// updateForm delegates to the form sub-component.
|
||||
func (m replModel) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.form, cmd = m.form.Update(msg)
|
||||
|
||||
if m.form.cancelled {
|
||||
m.form.cancelled = false
|
||||
m.state = stateInput
|
||||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
if m.form.submitted {
|
||||
args := m.form.values
|
||||
group := m.form.group
|
||||
s := m.form.shortcut
|
||||
m.form.submitted = false
|
||||
m.state = stateExecuting
|
||||
return m, m.executeCommand(group, s, args)
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// updateExecuting waits for the execResultMsg.
|
||||
func (m replModel) updateExecuting(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case execResultMsg:
|
||||
if msg.err != nil {
|
||||
m.lastError = formatInteractiveError(msg.group, msg.shortcut, msg.err)
|
||||
m.lastOutput = ""
|
||||
m.state = stateError
|
||||
return m, nil
|
||||
}
|
||||
display := formatCommandDisplay(msg.group, msg.shortcut, msg.args)
|
||||
content := fmt.Sprintf(" ✓ 执行: %s\n\n%s", display, msg.output)
|
||||
// 输出超过可用高度(留 4 行给提示符与边距)时进入可滚动查看器,
|
||||
// 否则保持原行为:直接显示在输入状态。
|
||||
lineCount := strings.Count(content, "\n") + 1
|
||||
if m.height > 0 && lineCount > m.height-4 {
|
||||
m.viewport = viewport.New(m.width, m.height-2)
|
||||
m.viewport.SetContent(content)
|
||||
m.viewport.GotoTop()
|
||||
m.lastOutput = ""
|
||||
m.lastError = ""
|
||||
m.state = stateOutput
|
||||
return m, nil
|
||||
}
|
||||
m.lastOutput = content
|
||||
m.lastError = ""
|
||||
m.state = stateInput
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// updateError waits for Enter to return to input state.
|
||||
func (m replModel) updateError(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
keyMsg, isKey := msg.(tea.KeyMsg)
|
||||
if isKey {
|
||||
switch keyMsg.Type {
|
||||
case tea.KeyEnter, tea.KeyEsc:
|
||||
m.lastError = ""
|
||||
m.state = stateInput
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
case tea.KeyCtrlD, tea.KeyCtrlC:
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// updateOutput 处理输出查看器状态:用方向键/PageUp/PageDown 浏览长输出,
|
||||
// 按 q 或 Esc 返回输入状态。
|
||||
func (m replModel) updateOutput(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc, tea.KeyCtrlC:
|
||||
m.state = stateInput
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
case tea.KeyRunes:
|
||||
// q / Q 返回输入
|
||||
if len(msg.Runes) > 0 && (msg.Runes[0] == 'q' || msg.Runes[0] == 'Q') {
|
||||
m.state = stateInput
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
}
|
||||
}
|
||||
// 其余按键(方向键、PageUp/Down、Home/End)转发给 viewport,
|
||||
// viewport 内置响应这些按键,无需手动处理。
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// View renders the REPL based on current state.
|
||||
func (m replModel) View() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case stateInput:
|
||||
var sb strings.Builder
|
||||
if m.showWelcome {
|
||||
sb.WriteString(welcomeStyle.Render("Welcome to gitlink-cli interactive mode."))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(welcomeStyle.Render("Type / to open the command palette, \"exit\" or Ctrl+D to quit."))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
if m.lastOutput != "" {
|
||||
sb.WriteString(successStyle.Render(m.lastOutput))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(m.input.View())
|
||||
return sb.String()
|
||||
|
||||
case statePalette:
|
||||
return m.palette.View()
|
||||
|
||||
case stateForm:
|
||||
return m.form.View()
|
||||
|
||||
case stateExecuting:
|
||||
return fmt.Sprintf("\n %s Executing command...\n", m.spinner.View())
|
||||
|
||||
case stateError:
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(errorStyle.Render(m.lastError))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(formHelpStyle.Render(" Press Enter to continue"))
|
||||
sb.WriteString("\n")
|
||||
return sb.String()
|
||||
|
||||
case stateOutput:
|
||||
return m.viewport.View() + "\n" +
|
||||
formHelpStyle.Render(" ↑↓/PgUp/PgDn 滚动 q/Esc 返回")
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Run starts the interactive REPL.
|
||||
func Run(owner, repo string) error {
|
||||
m := newReplModel(owner, repo)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package interactive
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// TestSlashTriggersPaletteImmediately verifies that typing "/" as the first
|
||||
// character (KeyRunes) switches the REPL into the palette state without
|
||||
// requiring Enter. This is the regression test for the bug where the
|
||||
// command palette never appeared.
|
||||
func TestSlashTriggersPaletteImmediately(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
if m.state != stateInput {
|
||||
t.Fatalf("expected initial state stateInput, got %v", m.state)
|
||||
}
|
||||
|
||||
// Simulate the user pressing the "/" key.
|
||||
// bubbletea delivers printable chars as KeyRunes with the runes populated.
|
||||
keySlash := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
|
||||
newModel, _ := m.Update(keySlash)
|
||||
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
if repl.state != statePalette {
|
||||
t.Fatalf("expected state statePalette after typing '/', got %v", repl.state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashWithQueryCarriesIntoSearch verifies "/iss" pre-fills the palette search.
|
||||
func TestSlashWithQueryCarriesIntoSearch(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
|
||||
keySlashQuery := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/', 'i', 's', 's'}}
|
||||
newModel, _ := m.Update(keySlashQuery)
|
||||
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
if repl.state != statePalette {
|
||||
t.Fatalf("expected state statePalette, got %v", repl.state)
|
||||
}
|
||||
if got := repl.palette.search.Value(); got != "iss" {
|
||||
t.Fatalf("expected palette search 'iss', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonSlashInputStaysInInputState verifies ordinary text doesn't open the palette.
|
||||
func TestNonSlashInputStaysInInputState(t *testing.T) {
|
||||
m := newReplModel("owner", "repo")
|
||||
|
||||
keyA := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}
|
||||
newModel, _ := m.Update(keyA)
|
||||
|
||||
repl, ok := newModel.(replModel)
|
||||
if !ok {
|
||||
t.Fatalf("expected replModel, got %T", newModel)
|
||||
}
|
||||
if repl.state != stateInput {
|
||||
t.Fatalf("typing 'a' should stay in stateInput, got %v", repl.state)
|
||||
}
|
||||
}
|
||||
10
cmd/root.go
10
cmd/root.go
|
|
@ -10,17 +10,23 @@ 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"
|
||||
interactiveCmd "github.com/gitlink-org/gitlink-cli/cmd/interactive"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
var colorMode string
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "gitlink-cli",
|
||||
Short: "GitLink CLI — command-line tool for gitlink.org.cn",
|
||||
Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
cmdutil.ColorMode = colorMode
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
|
@ -28,6 +34,8 @@ func init() {
|
|||
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)")
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)")
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
|
||||
rootCmd.PersistentFlags().StringVar(&colorMode, "color", "auto", "Color output: auto, always, never")
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.AutoConfirm, "yes", false, "Skip confirmation prompts")
|
||||
|
||||
rootCmd.AddCommand(authCmd.NewAuthCmd())
|
||||
rootCmd.AddCommand(apiCmd.NewAPICmd())
|
||||
|
|
@ -35,6 +43,8 @@ func init() {
|
|||
rootCmd.AddCommand(versionCmd)
|
||||
|
||||
shortcuts.RegisterAll(rootCmd)
|
||||
|
||||
rootCmd.AddCommand(interactiveCmd.NewInteractiveCmd())
|
||||
}
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,308 @@
|
|||
# Release Notes — gitlink-cli
|
||||
|
||||
> 官方 [GitLink](https://www.gitlink.org.cn) 命令行工具,面向人类用户和 AI Agent。
|
||||
> 支持 macOS、Linux 和 Windows,覆盖仓库管理、Issue 跟踪、Pull Request、Webhook、CI/CD 及 AI 驱动的工作流。
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0 (2026-06-17) — 交互式 REPL 与输出大重构
|
||||
|
||||
### 里程碑亮点
|
||||
|
||||
- ✨ **交互式 REPL 模式**:新增基于 Bubble Tea TUI 框架的命令面板、参数表单与滚动视口
|
||||
- 📊 **输出系统重构**:统一表格渲染,终结 JSON 回退;支持嵌套对象层级展开、值截断、动态列合并
|
||||
- 🌏 **全命令中文覆盖**:全部 25+ 命令组的子命令描述、参数注释、帮助文本完成中文化
|
||||
- 🐛 **P1-P4 缺陷修复**:修复 16 项实测发现的问题,涉及 collaborator、branch、file、tag、interactive 等多个模块
|
||||
- 🧩 **3 个新 AI Agent Skill**:issue-triage、project-health、newcomer-guide
|
||||
- 🧪 **大规模测试**:新增交互模式冒烟测试、output 表格渲染单元测试、hook-runner 测试等
|
||||
|
||||
### 新增功能
|
||||
|
||||
#### 交互式 REPL (Bubble Tea TUI)
|
||||
|
||||
- **命令面板** (`/` 快捷键唤起):实时搜索 80+ 命令,支持模糊匹配
|
||||
- **参数表单**:选中命令后自动弹出表单填写 flags,支持必填/可选参数
|
||||
- **滚动视口**:长输出支持上下滚动浏览,不再截断
|
||||
- **默认 table 输出**:REPL 中统一使用表格格式,告别 JSON 裸输出
|
||||
- **Ctrl+C/Esc**:正确的退出与返回行为
|
||||
- **Smoke Test**:所有注册命令执行不 panic 的自动化验证
|
||||
|
||||
#### 输出系统重构
|
||||
|
||||
- **`printSliceTable` 增强**:表头取自所有行的字段并集,不再仅依赖第一行
|
||||
- **嵌套 Map 详情视图**:单对象详情(如 `tag +view`、`repo +info`)渲染为可读层级表格,`└` 缩进展开子字段,不再回退 JSON
|
||||
- **原生 Map Slice 表格化**:`[]map[string]string` 等本地构造数据也渲染为表格,不再显示 `[N items]`
|
||||
- **值截断与列限制**:超长值(URL、SHA、commit message)自动截断,核心列优先固定
|
||||
- **已废弃**:`hasOnlyNestedMaps` JSON 回退逻辑移除
|
||||
|
||||
#### Skill 与 Workflow
|
||||
|
||||
- 新增 3 个 AI Agent Skill:
|
||||
- `gitlink-issue-triage` — Issue 分类与优先级评估
|
||||
- `gitlink-project-health` — 项目健康度洞察
|
||||
- `gitlink-newcomer-guide` — 新贡献者引导
|
||||
- `gitlink-webhook` Skill:新增 `+failed`、`+task-view` 命令
|
||||
- 新增 `gitlink-wiki`、`gitlink-snippet` Skill
|
||||
- 新增 Workflow 自动化示例:issue-triage、project-health、newcomer-guide
|
||||
|
||||
#### 命令增强
|
||||
|
||||
- **Webhook**:`+failed` 查看失败投递、`+task-view` 支持 UUID/数字 ID 双查询、`--limit` 分页支持
|
||||
- **Shortcuts**:导出 `GetAllShortcuts` / `GetDescriptions` 供交互模式消费
|
||||
|
||||
### Bug 修复
|
||||
|
||||
| 模块 | 问题 | 修复 |
|
||||
|------|------|------|
|
||||
| **collaborator** | `change-role`/`batch-role` 请求体格式错误导致"未找到用户" | 改为 `{user_id: 整数, role: string}` 格式;自动解析用户名到数字 ID |
|
||||
| **branch** | protect/unprotect 接口路径误加 `/v1` 导致 404 | 走无 `/v1` 老接口 |
|
||||
| **branch** | `+list` 字段过多导致表格不稳定 | 投影到核心列:name/protected/last_commit/commit_time |
|
||||
| **file** | `+get` 端点调用了错误 API | 改为 `/entries` 提取单文件元信息 |
|
||||
| **file** | `+tree --recursive` 参数被 API 拒绝 | 移除无效 flag |
|
||||
| **file** | `+create`/`+update`/`+delete` 缺少必需参数 | 补 `--new-branch`、`--sha` 改为必填 |
|
||||
| **file** | `+replace` 端点已废弃 | 移除该命令 |
|
||||
| **tag** | `+view` 调用了不存在端点导致"标签不存在" | 改用列表过滤,新增 `findTagByName` |
|
||||
| **interactive** | 带可选参数的命令跳过表单直接执行 | 改为"有任何 flag 都弹表单" |
|
||||
| **interactive** | nil Client 导致 panic | `NewRuntimeContext` 初始化 |
|
||||
| **interactive** | `/` 键响应延迟 | 立即响应而非等待后续输入 |
|
||||
| **output** | 原生 `[]map[string]string` 不识别为表格 | reflect 统一处理 |
|
||||
| **output** | 单对象嵌套 Map 回退 JSON | 层级表格展开 |
|
||||
|
||||
### 文档与国际化
|
||||
|
||||
- **全部 25+ 命令组描述中文化**:repo / pr / issue / branch / tag / file / wiki / webhook / snippet / user / org / ci / search / label / milestone / release / commit / collaborator / attachment / dataset / template / util / sshkey / shortcut / interactive
|
||||
- **Shortcuts 模块详细中文注释**:参数映射(CLI→API)、批处理模式、设计决策
|
||||
- **P1-P4 缺陷中文标记**:issue +create --label 遗漏、hardcoded 'master' 等已知问题文档化
|
||||
- **交互模式设计文档**:实现计划 + 命令面板设计规格
|
||||
|
||||
### 基础设施
|
||||
|
||||
- **依赖**:引入 `bubbletea`、`bubbles`、`huh` TUI 框架
|
||||
- **CI**:DevOps 流水线(构建+验证)
|
||||
- **测试**:interactive smoke test、output 表格渲染回归测试、tag `+view` 单元测试、hook-runner 单元测试
|
||||
|
||||
### 贡献者
|
||||
|
||||
自 v0.1.18 以来的贡献者(按字母顺序):
|
||||
|
||||
2403_89190320, Leo77, Mengz, Tiger, s2_cc, wangyue789, wqer, yangsai01, 刘焱, 赵昌
|
||||
|
||||
---
|
||||
|
||||
## v0.1.18 (2026-05-19) — Webhook 集成与 Skills 扩展
|
||||
|
||||
> 这也是 v0.2.0 之前带交互式 REPL 内测功能的过渡版本。
|
||||
|
||||
### 核心变更
|
||||
|
||||
- **交互模式 MVP**:首次合入 Bubble Tea REPL 基础框架(命令执行器 + flag 解析 + TUI 入口),但随后回退(`Revert "Add TUI foundation"`),留待 v0.2.0 正式发布
|
||||
- **npm 安装**:修复 Windows 二进制缺失问题,改进安装后诊断
|
||||
|
||||
---
|
||||
|
||||
## v0.1.17 (2026-05-17) — Skills 生态与包管理器支持
|
||||
|
||||
### 新功能
|
||||
|
||||
- **3 个新 Skill**:`gitlink-code-review`(代码审查)、`gitlink-insight`(仓库洞察)、`gitlink-compliance`(合规检查)
|
||||
- **2 个自动化 Skill**:`gitlink-release-auto`(自动发版)、`gitlink-commit-quality`(提交信息规范检查)
|
||||
- **包管理器支持**:Homebrew(macOS/Linux)、Scoop(Windows)、Chocolatey(Windows)
|
||||
- **CI**:GitHub Release 工作流与 npm 自动发布
|
||||
- **npm 安装增强**:校验和验证、错误消息改进、版本检查
|
||||
|
||||
### 命令增强
|
||||
|
||||
- **PR**:新增 `+review` 审查快捷、`+patchset` 补丁集版本切换
|
||||
- **PR**:`+comment` 评论 CRUD
|
||||
- **Issue**:`+batch-close` 批量关闭(dry-run 支持)
|
||||
- **PR/Issue**:统一使用 project-level 编号替代数据库 ID
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- **Issue**: `+list` 输出使用项目级 Issue 编号
|
||||
- **PR**: 统一使用 `pull_request_number` 替代 `pull_request_id`
|
||||
- **Issue**: `update` 时保留描述字段
|
||||
- **npm**: Windows 二进制缺失导致静默退出
|
||||
|
||||
### 文档
|
||||
|
||||
- Skills REFERENCE.md 参考文档
|
||||
- README 更新至 v0.1.17(12 Skills、npm 一键安装)
|
||||
- Fork-based PR 工作流指南
|
||||
- Skill 工具边界规则(防止在 GitLink 上误用 gh/hub)
|
||||
|
||||
---
|
||||
|
||||
## v0.1.14 (2026-05-17) — 全 API 覆盖里程碑
|
||||
|
||||
### 新功能
|
||||
|
||||
- **40+ 新命令 shortcut**:
|
||||
- **Repo**:22 个新 shortcut(settings/stats/social/transfer/invite/migrate/topics)
|
||||
- **User**:22 个新 shortcut(messages/stats/transfers/settings/feedback)
|
||||
- **PR**:comments CRUD、commits、reopen、update
|
||||
- **Issue**:comments CRUD、delete、batch-update、batch-destroy
|
||||
- **Branch**:all/default/restore;**Release**:edit/update
|
||||
- **协作**:org team-projects;**File**:batch/entries/replace
|
||||
- **CI 流水线**、**Wiki 导入/导出**、**Repo 加入/退出/迁移/话题**
|
||||
- **Util**、**Dataset**、**Template**、**Attachment** 快捷组
|
||||
- **代码片段管理**:`snippet +list/+view/+create/+delete`
|
||||
- **看板**:`board +view/+columns/+move`
|
||||
- **OAuth2 认证**、**颜色检测与 CJK 宽度支持**
|
||||
- **Wiki 命令改用 Git 操作**:`+list/+view/+create/+update/+delete` 重构为 Git-based
|
||||
|
||||
### 跨平台
|
||||
|
||||
- **Windows (x64/arm64)** 完整支持
|
||||
- 终端颜色自动检测与中文字符宽度适配
|
||||
|
||||
### Skills
|
||||
|
||||
- **6 个新 Skills**:`gitlink-repo`、`gitlink-issue`、`gitlink-pr`、`gitlink-ci`、`gitlink-search`、`gitlink-workflow`
|
||||
- **Skill 架构升级**:Anthropic Agent Skills 标准
|
||||
- **pm / org / user / release / branch** 等 Skill 持续完善
|
||||
|
||||
### 基础设施
|
||||
|
||||
- npm 包 `@gitlink-ai/cli` 分发上线
|
||||
- `GITLINK_TOKEN` 环境变量支持非交互式认证
|
||||
- `gitlink-cli-install-skills` 跨平台安装命令
|
||||
|
||||
### 项目健康
|
||||
|
||||
- 新增 CONTRIBUTORS 章节展示项目贡献者
|
||||
- 许可证切换至 MulanPSL-2.0(木兰宽松许可证)
|
||||
- README 中英双语
|
||||
|
||||
---
|
||||
|
||||
## v0.1.8 ~ v0.1.13 (2026-04-07 ~ 2026-04-15) — 功能夯实
|
||||
|
||||
### v0.1.13 (2026-04-15)
|
||||
|
||||
- **npm 安装方案**:修复 GitHub Release asset 匹配回退与相对 URL 拼接问题
|
||||
- 改进 release 创建时默认 `target_commitish=master`
|
||||
- Issue 创建默认 `priority_id=2`
|
||||
|
||||
### v0.1.12 (2026-04-15)
|
||||
|
||||
- Webhook 快捷命令组正式合入
|
||||
- `hook-runner` 集成为 `webhook` 子命令
|
||||
- 新增 `--limit` 标志与 UUID 显示改进
|
||||
|
||||
### v0.1.11 (2026-04-08)
|
||||
|
||||
- **PR 审查**:`+review` 审查提交
|
||||
- **PR 补丁集**:`+patchset-versions` 版本切换
|
||||
- 3 个新 Skills:`gitlink-code-review`、`gitlink-insight`、`gitlink-compliance`
|
||||
|
||||
### v0.1.10 (2026-04-08)
|
||||
|
||||
- 2 个自动化 Skill:`gitlink-release-auto`、`gitlink-commit-quality`
|
||||
- Webhook 端点测试修复
|
||||
|
||||
### v0.1.9 (2026-04-07)
|
||||
|
||||
- **Webhook shortcut 组**正式版
|
||||
- Skills 文档完善
|
||||
|
||||
### v0.1.8 (2026-04-07)
|
||||
|
||||
- **Branch Skill**:`gitlink-branch` 保护分支管理
|
||||
- PR `+comment` 快捷
|
||||
- Issue `+batch-close` 批量关闭
|
||||
- Issue v1 API 迁移,使用 `project_issues_index` 替代数据库 ID
|
||||
- 修复 Issue 描述 update 丢失问题
|
||||
|
||||
---
|
||||
|
||||
## v0.1.0-skill-test (2026-04-04) — Skill 概念验证
|
||||
|
||||
- Skill PR 全流程测试
|
||||
- 首个 Skill 原型验证
|
||||
- PR 创建、合入工作流测试
|
||||
|
||||
---
|
||||
|
||||
## v0.0.1-test-del / initial (2026-04-03) — 项目初始化
|
||||
|
||||
- `github.com/gitlink-org/gitlink-cli` 项目启动
|
||||
- 基础 CLI 框架(Cobra)
|
||||
- 初始命令:auth、repo、issue、pr
|
||||
- npm 分发基础 (`@gitlink-ai/cli`)
|
||||
- Cookie-based 登录与 token 认证
|
||||
|
||||
---
|
||||
|
||||
## 统计一览
|
||||
|
||||
| 版本 | 日期 | 新增文件 | 代码变更 | 贡献者 |
|
||||
|------|------|----------|----------|--------|
|
||||
| init → v0.1.8 | 2026-04 | ~30 | ~8k | 5 |
|
||||
| v0.1.8 → v0.1.14 | 2026-04~05 | ~60 | ~15k | 8 |
|
||||
| v0.1.14 → v0.1.18 | 2026-05 | ~22 | +514/-1015 | 4 |
|
||||
| v0.1.18 → v0.2.0 | 2026-05~06 | 114 | +24531/-324 | 10 |
|
||||
|
||||
## Skills 一览
|
||||
|
||||
| Skill | 用途 | 引入版本 |
|
||||
|-------|------|----------|
|
||||
| `gitlink-repo` | 仓库管理 | v0.1.14 |
|
||||
| `gitlink-issue` | Issue 跟踪 | v0.1.14 |
|
||||
| `gitlink-pr` | Pull Request 管理 | v0.1.14 |
|
||||
| `gitlink-ci` | CI/CD 流水线 | v0.1.14 |
|
||||
| `gitlink-search` | 全局搜索 | v0.1.14 |
|
||||
| `gitlink-workflow` | 工作流自动化 | v0.1.14 |
|
||||
| `gitlink-branch` | 保护分支管理 | v0.1.8 |
|
||||
| `gitlink-pm` | 项目管理 | v0.1.14 |
|
||||
| `gitlink-org` | 组织管理 | v0.1.14 |
|
||||
| `gitlink-user` | 用户管理 | v0.1.14 |
|
||||
| `gitlink-release` | 发布管理 | v0.1.14 |
|
||||
| `gitlink-code-review` | 代码审查 | v0.1.11 |
|
||||
| `gitlink-insight` | 仓库洞察 | v0.1.11 |
|
||||
| `gitlink-compliance` | 合规检查 | v0.1.11 |
|
||||
| `gitlink-release-auto` | 自动发版 | v0.1.10 |
|
||||
| `gitlink-commit-quality` | 提交信息规范 | v0.1.10 |
|
||||
| `gitlink-webhook` | Webhook 管理 | v0.1.9 |
|
||||
| `gitlink-wiki` | Wiki 管理 | v0.2.0 |
|
||||
| `gitlink-snippet` | 代码片段管理 | v0.2.0 |
|
||||
| `gitlink-issue-triage` | Issue 分类与优先级 | v0.2.0 |
|
||||
| `gitlink-project-health` | 项目健康度 | v0.2.0 |
|
||||
| `gitlink-newcomer-guide` | 新贡献者引导 | v0.2.0 |
|
||||
| `gitlink-shared` | 共享工具库 | v0.2.0 |
|
||||
| `gitlink-workflow` | 工作流定义 | v0.2.0 |
|
||||
|
||||
---
|
||||
|
||||
## 升级指南
|
||||
|
||||
### v0.1.x → v0.2.0
|
||||
|
||||
- **交互模式**:运行 `gitlink-cli interactive` 或 `gitlink-cli tui` 进入 REPL
|
||||
- **输出格式**:v0.2.0 默认输出格式为 table,如需 JSON 请使用 `--output json` 或 `--json`
|
||||
- **File 命令**:`+replace` 已移除,请改用 `+create`/`+update`
|
||||
- **Tag 命令**:`+view` 行为变更(从单端点改为列表过滤),功能一致
|
||||
|
||||
### 首次安装
|
||||
|
||||
```bash
|
||||
# npm (推荐)
|
||||
npm install -g @gitlink-ai/cli
|
||||
|
||||
# macOS (Homebrew)
|
||||
brew tap gitlink-ai/tap
|
||||
brew install gitlink-cli
|
||||
|
||||
# Windows (Scoop)
|
||||
scoop bucket add gitlink https://github.com/gitlink-ai/scoop-bucket
|
||||
scoop install gitlink-cli
|
||||
|
||||
# Windows (Chocolatey)
|
||||
choco install gitlink-cli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> 完整的提交历史请参阅 [CHANGELOG](./changes/) 目录。
|
||||
>
|
||||
> 如发现任何问题,欢迎在 [GitLink Issues](https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues) 反馈。
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Webhook Shortcut
|
||||
|
||||
新增 `webhook` Shortcut 组,支持:
|
||||
新增 `webhook` Shortcut 组,支持
|
||||
|
||||
- `webhook +list`
|
||||
- `webhook +create`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,609 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GitLink CLI 现场演示</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{--bg:#0f172a;--bg2:#1e293b;--card:#1e3a5f;--accent:#38bdf8;--accent2:#818cf8;--gold:#fbbf24;--text:#e2e8f0;--dim:#94a3b8;--green:#22c55e;--red:#ef4444;--border:#334155}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Microsoft YaHei',sans-serif;display:flex;height:100vh;overflow:hidden}
|
||||
/* Sidebar */
|
||||
.sidebar{width:280px;background:var(--bg2);border-right:1px solid var(--border);flex-shrink:0;overflow-y:auto}
|
||||
.sidebar-header{padding:20px 24px;border-bottom:1px solid var(--border)}
|
||||
.sidebar-header h1{font-size:18px;color:var(--accent);margin-bottom:4px}
|
||||
.sidebar-header p{font-size:12px;color:var(--dim)}
|
||||
.nav-section{padding:12px 0}
|
||||
.nav-section-title{padding:4px 24px;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:1px}
|
||||
.nav-item{padding:10px 24px;cursor:pointer;font-size:14px;color:var(--text);border-left:3px solid transparent;transition:all .2s;display:flex;align-items:center;gap:10px}
|
||||
.nav-item:hover{background:var(--card);color:var(--accent)}
|
||||
.nav-item.active{background:var(--card);color:var(--accent);border-left-color:var(--accent)}
|
||||
.nav-item .icon{font-size:18px;width:24px;text-align:center}
|
||||
.nav-item .time{margin-left:auto;font-size:11px;color:var(--dim);background:var(--bg);padding:2px 6px;border-radius:4px}
|
||||
/* Main */
|
||||
.main{flex:1;overflow-y:auto;padding:0}
|
||||
.demo-page{display:none;padding:32px 40px;min-height:100%}
|
||||
.demo-page.active{display:block}
|
||||
.page-header{margin-bottom:24px}
|
||||
.page-header h2{font-size:26px;color:var(--text);margin-bottom:6px}
|
||||
.page-header p{font-size:14px;color:var(--dim)}
|
||||
/* Terminal */
|
||||
.terminal{background:#1e1e2e;border-radius:10px;overflow:hidden;margin:16px 0;box-shadow:0 4px 24px rgba(0,0,0,.3)}
|
||||
.term-bar{background:#313244;padding:8px 14px;display:flex;align-items:center;gap:8px}
|
||||
.term-dot{width:12px;height:12px;border-radius:50%}
|
||||
.term-dot.r{background:#f38ba8}.term-dot.y{background:#f9e2af}.term-dot.g{background:#a6e3a1}
|
||||
.term-title{flex:1;text-align:center;font-size:12px;color:#6c7086;font-family:monospace}
|
||||
.term-body{padding:16px 20px;font-family:'Cascadia Code','Fira Code',monospace;font-size:13px;line-height:1.7;color:#cdd6f4;max-height:500px;overflow-y:auto}
|
||||
.term-prompt{color:#89b4fa}.term-cmd{color:#cdd6f4}.term-flag{color:#f9e2af}.term-str{color:#a6e3a1}.term-out{color:#a6adc8}.term-ok{color:#a6e3a1}.term-info{color:#89b4fa}.term-warn{color:#f9e2af}.term-err{color:#f38ba8}.term-dim{color:#6c7086}.term-section{color:#cba6f7;font-weight:bold}
|
||||
/* Cards */
|
||||
.card-grid{display:grid;gap:16px;margin:16px 0}
|
||||
.card{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:20px}
|
||||
.card h3{font-size:16px;color:var(--accent);margin-bottom:10px}
|
||||
.card p{font-size:13px;color:var(--text);line-height:1.6}
|
||||
.metric-row{display:flex;gap:16px;flex-wrap:wrap;margin:12px 0}
|
||||
.metric{background:var(--card);border-radius:8px;padding:12px 20px;text-align:center;min-width:120px}
|
||||
.metric .val{font-size:28px;font-weight:bold;color:var(--gold)}
|
||||
.metric .lbl{font-size:12px;color:var(--dim);margin-top:4px}
|
||||
/* Output link */
|
||||
.output-link{display:inline-flex;align-items:center;gap:8px;background:var(--card);border:1px solid var(--accent);border-radius:8px;padding:10px 20px;color:var(--accent);text-decoration:none;font-size:14px;transition:all .2s;cursor:pointer}
|
||||
.output-link:hover{background:var(--accent);color:var(--bg)}
|
||||
.output-link .arrow{font-size:16px}
|
||||
/* Phase */
|
||||
.phase{display:flex;gap:16px;margin:12px 0;align-items:flex-start}
|
||||
.phase-num{width:36px;height:36px;border-radius:50%;background:var(--accent);color:var(--bg);display:flex;align-items:center;justify-content:center;font-weight:bold;font-size:16px;flex-shrink:0}
|
||||
.phase-content{flex:1}
|
||||
.phase-content h4{font-size:15px;color:var(--text);margin-bottom:4px}
|
||||
.phase-content p{font-size:13px;color:var(--dim);line-height:1.5}
|
||||
/* Table */
|
||||
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13px}
|
||||
th{background:var(--card);color:var(--accent);padding:10px 14px;text-align:left;border-bottom:2px solid var(--border)}
|
||||
td{padding:8px 14px;border-bottom:1px solid var(--border);color:var(--text)}
|
||||
tr:hover td{background:var(--bg2)}
|
||||
/* Score bar */
|
||||
.score-bar{display:flex;align-items:center;gap:12px;margin:8px 0}
|
||||
.score-label{width:120px;font-size:13px;color:var(--text)}
|
||||
.score-track{flex:1;height:24px;background:var(--bg);border-radius:4px;overflow:hidden}
|
||||
.score-fill{height:100%;border-radius:4px;display:flex;align-items:center;justify-content:flex-end;padding-right:8px;font-size:11px;color:#fff;font-weight:bold}
|
||||
.score-val{width:60px;font-size:14px;font-weight:bold;color:var(--gold);text-align:right}
|
||||
/* Hint */
|
||||
.hint{background:rgba(56,189,248,.1);border:1px solid var(--accent);border-radius:8px;padding:12px 20px;margin:16px 0;font-size:13px;color:var(--text)}
|
||||
.hint b{color:var(--accent)}
|
||||
/* Badges */
|
||||
.badge{display:inline-block;padding:2px 10px;border-radius:4px;font-size:11px;font-weight:bold}
|
||||
.badge-new{background:rgba(34,197,94,.2);color:var(--green);border:1px solid var(--green)}
|
||||
.badge-orig{background:rgba(148,163,184,.1);color:var(--dim);border:1px solid var(--border)}
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar{width:8px;height:8px}
|
||||
::-webkit-scrollbar-track{background:var(--bg)}
|
||||
::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
|
||||
::-webkit-scrollbar-thumb:hover{background:var(--dim)}
|
||||
/* Home */
|
||||
.home-hero{text-align:center;padding:40px 0}
|
||||
.home-hero h2{font-size:32px;color:var(--text);margin-bottom:12px}
|
||||
.home-hero p{font-size:16px;color:var(--dim);margin-bottom:30px}
|
||||
.home-metrics{display:flex;gap:20px;justify-content:center;flex-wrap:wrap;margin:30px 0}
|
||||
.home-metric{background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:20px 30px;text-align:center;min-width:140px}
|
||||
.home-metric .val{font-size:36px;font-weight:bold}
|
||||
.home-metric .lbl{font-size:13px;color:var(--dim);margin-top:6px}
|
||||
@keyframes blink{0%,50%{opacity:1}51%,100%{opacity:0}}
|
||||
@keyframes fadein{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
||||
.repl-line{animation:fadein .3s ease}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>GitLink CLI</h1>
|
||||
<p>现场演示 · 4 场景 · ~3 分钟</p>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">概览</div>
|
||||
<div class="nav-item active" onclick="show('home',this)"><span class="icon">🏠</span> 首页</div>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">子任务一 · CLI</div>
|
||||
<div class="nav-item" onclick="show('cli',this)"><span class="icon">⌨️</span> CLI 命令 <span class="time">30s</span></div>
|
||||
<div class="nav-item" onclick="show('repl',this)"><span class="icon">🖥️</span> REPL 交互 <span class="time">45s</span></div>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">子任务三 · 工作流</div>
|
||||
<div class="nav-item" onclick="show('community',this)"><span class="icon">📊</span> 社区运营 <span class="time">60s</span></div>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">子任务四 · 科研</div>
|
||||
<div class="nav-item" onclick="show('insight',this)"><span class="icon">🔬</span> 项目洞悉 <span class="time">45s</span></div>
|
||||
<div class="nav-item" onclick="show('hotspot',this)"><span class="icon">🕸️</span> 知识图谱 <span class="time">45s</span></div>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">评分体系</div>
|
||||
<div class="nav-item" onclick="show('scoring',this)"><span class="icon">📐</span> 权重设计 <span class="time">30s</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- ===== HOME ===== -->
|
||||
<div class="demo-page active" id="home">
|
||||
<div class="home-hero">
|
||||
<h2>GitLink 智能化能力提升项目</h2>
|
||||
<p>gitlink-cli 命令行工具 · AI Agent Skills · 工作流自动化 · 科研辅助</p>
|
||||
<div class="home-metrics">
|
||||
<div class="home-metric"><div class="val" style="color:#38bdf8">236</div><div class="lbl">Git 提交</div></div>
|
||||
<div class="home-metric"><div class="val" style="color:#818cf8">29</div><div class="lbl">AI Skills</div></div>
|
||||
<div class="home-metric"><div class="val" style="color:#22c55e">14</div><div class="lbl">新命令模块</div></div>
|
||||
<div class="home-metric"><div class="val" style="color:#fbbf24">16.8k</div><div class="lbl">Go 代码行</div></div>
|
||||
<div class="home-metric"><div class="val" style="color:#a78bfa">20</div><div class="lbl">测试文件</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint">
|
||||
<b>💡 演示说明:</b>点击左侧导航查看各场景。每个场景包含<b>命令展示</b>和<b>输出结果跳转</b>。所有数据来自 GitLink API 实时采集。
|
||||
</div>
|
||||
<div class="card-grid" style="grid-template-columns:1fr 1fr">
|
||||
<div class="card">
|
||||
<h3>📁 代码仓库</h3>
|
||||
<p>fork 自 GitLink 官方 gitlink-cli,在初始 commit 基础上完成全部工作</p>
|
||||
<a class="output-link" href="https://www.gitlink.org.cn/z2_cc/gitlink-cli" target="_blank" style="margin-top:12px">前往仓库 →</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>👥 团队</h3>
|
||||
<p>赵昌 (wqer) · 子任务三/四<br>刘焱 (liuyan688) · CLI 底层 + REPL<br>曾蔚然 (z2_cc) · DevOps + 审查</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== CLI ===== -->
|
||||
<div class="demo-page" id="cli">
|
||||
<div class="page-header">
|
||||
<h2>① CLI 基础命令演示</h2>
|
||||
<p>新增 14 个 Shortcut 模块 · 统一输出格式 · 真实 API 数据</p>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="term-bar"><span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span><span class="term-title">z@z-pc: ~/gitlink-cli</span></div>
|
||||
<div class="term-body">
|
||||
<div><span class="term-prompt">z@z-pc</span><span class="term-dim">:</span><span class="term-prompt">~/gitlink-cli</span><span class="term-dim">$</span> <span class="term-cmd">gitlink-cli</span> <span class="term-cmd">repo</span> <span class="term-flag">+info</span> <span class="term-flag">--owner</span> <span class="term-str">z2_cc</span> <span class="term-flag">--repo</span> <span class="term-str">gitlink-cli</span></div>
|
||||
<div class="term-out"> name gitlink-cli</div>
|
||||
<div class="term-out"> full_name z2_cc/gitlink-cli</div>
|
||||
<div class="term-out"> default_branch master</div>
|
||||
<div class="term-out"> issues_count 17</div>
|
||||
<div class="term-out"> forked_count 2</div>
|
||||
<div class="term-out"> clone_url https://gitlink.org.cn/z2_cc/gitlink-cli.git</div>
|
||||
<div> </div>
|
||||
<div><span class="term-prompt">z@z-pc</span><span class="term-dim">:</span><span class="term-prompt">~/gitlink-cli</span><span class="term-dim">$</span> <span class="term-cmd">gitlink-cli</span> <span class="term-cmd">collaborator</span> <span class="term-flag">+list</span> <span class="term-flag">--owner</span> <span class="term-str">z2_cc</span> <span class="term-flag">--repo</span> <span class="term-str">gitlink-cli</span></div>
|
||||
<div class="term-out"> login role is_owner</div>
|
||||
<div class="term-out"> ────────────────────────────────────</div>
|
||||
<div class="term-out"> z2_cc Manager true</div>
|
||||
<div class="term-out"> wqer Manager false</div>
|
||||
<div class="term-out"> liuyan688 Manager false</div>
|
||||
<div> </div>
|
||||
<div><span class="term-prompt">z@z-pc</span><span class="term-dim">:</span><span class="term-prompt">~/gitlink-cli</span><span class="term-dim">$</span> <span class="term-cmd">gitlink-cli</span> <span class="term-cmd">webhook</span> <span class="term-flag">+failed</span> <span class="term-flag">--owner</span> <span class="term-str">z2_cc</span> <span class="term-flag">--repo</span> <span class="term-str">gitlink-cli</span></div>
|
||||
<div class="term-out"> 最近 7 天投递失败记录:0 条 <span class="term-ok">✓</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-grid" style="grid-template-columns:1fr 1fr 1fr">
|
||||
<div class="card"><h3>📌 新增 14 模块</h3><p>collaborator · tag · milestone · file · commit · label · sshkey · webhook · wiki · snippet · dataset · template · attachment · util</p></div>
|
||||
<div class="card"><h3>⭐ 统一输出</h3><p>所有命令支持 <code>--format json/table/yaml</code>,JSON Envelope: {ok, data, meta}</p></div>
|
||||
<div class="card"><h3>✅ 测试覆盖</h3><p>20 个测试文件,含单元测试 + E2E 测试</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== REPL ===== -->
|
||||
<div class="demo-page" id="repl">
|
||||
<div class="page-header">
|
||||
<h2>② REPL 交互模式 — IDE 级体验</h2>
|
||||
<p>Bubble Tea TUI · 2027 行 Go · 5 状态状态机 · 7 个 E2E 测试 · 点击下方命令体验</p>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
|
||||
<!-- 左:交互终端 -->
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<div style="background:#313244;padding:8px 14px;font-size:12px;color:#6c7086;display:flex;align-items:center;gap:8px">
|
||||
<span style="width:12px;height:12px;border-radius:50%;background:#f38ba8"></span>
|
||||
<span style="width:12px;height:12px;border-radius:50%;background:#f9e2af"></span>
|
||||
<span style="width:12px;height:12px;border-radius:50%;background:#a6e3a1"></span>
|
||||
<span style="flex:1;text-align:center">gitlink-cli REPL — 交互终端</span>
|
||||
</div>
|
||||
<div id="repl-terminal" style="background:#1e1e2e;padding:16px;font-family:'Cascadia Code','Fira Code',monospace;font-size:13px;line-height:1.6;color:#cdd6f4;min-height:340px;max-height:340px;overflow-y:auto">
|
||||
<div style="color:#a6e3a1">Welcome to gitlink-cli interactive mode.</div>
|
||||
<div style="color:#6c7086">Type / to open command palette, "exit" or Ctrl+D to quit.</div>
|
||||
<div style="height:8px"></div>
|
||||
<div id="repl-output"></div>
|
||||
<div><span style="color:#89b4fa">z@z-pc</span><span style="color:#6c7086">:</span><span style="color:#89b4fa">~/gitlink-cli</span><span style="color:#6c7086">$ </span><span id="repl-cursor" style="background:#cdd6f4;width:8px;height:16px;display:inline-block;animation:blink 1s infinite"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右:操作指引 -->
|
||||
<div class="card">
|
||||
<h3>🖱️ 点击体验三个阶段</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-top:12px">
|
||||
<button onclick="replDemo('palette')" style="background:#1e3a5f;border:1px solid #38bdf8;color:#38bdf8;padding:10px 16px;border-radius:8px;cursor:pointer;font-size:13px;text-align:left;font-family:inherit">
|
||||
<b>① 命令面板</b> — 输入 / 搜索命令<br>
|
||||
<span style="font-size:11px;color:#94a3b8">点击模拟:输入 / → 搜索 issue → 选命令</span>
|
||||
</button>
|
||||
<button onclick="replDemo('form')" style="background:#1e293b;border:1px solid #22c55e;color:#22c55e;padding:10px 16px;border-radius:8px;cursor:pointer;font-size:13px;text-align:left;font-family:inherit">
|
||||
<b>② 表单输入</b> — Tab 切换参数<br>
|
||||
<span style="font-size:11px;color:#94a3b8">点击模拟:填 title → Tab → 填 assignee → 提交</span>
|
||||
</button>
|
||||
<button onclick="replDemo('viewport')" style="background:#312e81;border:1px solid #fbbf24;color:#fbbf24;padding:10px 16px;border-radius:8px;cursor:pointer;font-size:13px;text-align:left;font-family:inherit">
|
||||
<b>③ 滚动视口</b> — 长输出浏览<br>
|
||||
<span style="font-size:11px;color:#94a3b8">点击模拟:执行 issue +list → 滚动浏览</span>
|
||||
</button>
|
||||
<button onclick="replDemo('reset')" style="background:#1e293b;border:1px solid #64748b;color:#94a3b8;padding:8px 16px;border-radius:8px;cursor:pointer;font-size:12px;text-align:left;font-family:inherit">
|
||||
↺ 重置终端
|
||||
</button>
|
||||
</div>
|
||||
<div style="margin-top:14px;padding:10px;background:rgba(56,189,248,.08);border-radius:6px;font-size:11px;color:#94a3b8;line-height:1.5">
|
||||
<b style="color:#38bdf8">键盘操作:</b><br>
|
||||
<code style="color:#a6e3a1">/</code> 唤起面板 · <code style="color:#a6e3a1">↑↓</code> 导航 · <code style="color:#a6e3a1">Enter</code> 选中<br>
|
||||
<code style="color:#a6e3a1">Tab</code> 切字段 · <code style="color:#a6e3a1">Space</code> 切布尔 · <code style="color:#a6e3a1">Esc</code> 取消<br>
|
||||
<code style="color:#a6e3a1">PgUp/PgDn</code> 翻页 · <code style="color:#a6e3a1">q</code> 返回
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
<b>⭐ 核心亮点:</b>用户无需记忆命令语法——输入 / 搜索命令,Tab 填写参数,提交即执行。体验接近 VS Code 命令面板。
|
||||
<br><b>技术实现:</b>基于 Charmbracelet Bubble Tea(Elm 架构),repl.go(433行) + palette.go(305行) + form.go(296行) + executor.go(237行),7 个 E2E 测试。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== COMMUNITY ===== -->
|
||||
<div class="demo-page" id="community">
|
||||
<div class="page-header">
|
||||
<h2>③ 社区运营自动化</h2>
|
||||
<p>community-ops-full.sh · 三阶段流水线一键执行 · 真实运行结果</p>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="term-bar"><span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span><span class="term-title">community-ops-full.sh</span></div>
|
||||
<div class="term-body">
|
||||
<div><span class="term-prompt">z@z-pc</span><span class="term-dim">:</span><span class="term-prompt">~/gitlink-cli</span><span class="term-dim">$</span> <span class="term-flag">DRY_RUN=true</span> <span class="term-cmd">bash</span> <span class="term-str">skills/gitlink-community-ops/scripts/community-ops-full.sh</span> <span class="term-str">z2_cc</span> <span class="term-str">gitlink-cli</span></div>
|
||||
<div> </div>
|
||||
<div class="term-section">============================================</div>
|
||||
<div class="term-section"> 社区运营自动化</div>
|
||||
<div class="term-section">============================================</div>
|
||||
<div> </div>
|
||||
<div><span class="term-info">▸ Phase 1/3: Issue 自动分拣</span></div>
|
||||
<div class="term-out">[INFO] 获取所有打开的 Issue...</div>
|
||||
<div class="term-out">[INFO] 发现 21 个 Issue(17 打开 / 4 关闭)</div>
|
||||
<div class="term-out">[INFO] 自动分类:bug 8 · enhancement 6 · documentation 4 · other 3</div>
|
||||
<div class="term-ok">✓ 分拣完成</div>
|
||||
<div> </div>
|
||||
<div><span class="term-info">▸ Phase 2/3: 社区周报生成</span></div>
|
||||
<div class="term-out">[INFO] 采集 PR/提交/贡献者数据...</div>
|
||||
<div class="term-out">[INFO] 综合评分: 3.5/10 🔴 堪忧</div>
|
||||
<div class="term-out">[INFO] 关闭率: 19% | 合并率: 0% | 贡献者: 1人</div>
|
||||
<div class="term-ok">✓ HTML 报告已生成: _output/weekly-health-report.html</div>
|
||||
<div> </div>
|
||||
<div><span class="term-info">▸ Phase 3/3: 自动 Release Notes</span></div>
|
||||
<div class="term-out">[INFO] 获取最近 100 条提交...</div>
|
||||
<div class="term-out">[INFO] 新功能 22 项 | Bug 修复 15 项 | 文档 8 项</div>
|
||||
<div class="term-out">[INFO] 贡献者: 7 人 | 涉及文件: 252 个</div>
|
||||
<div class="term-ok">✓ HTML 报告已生成: _output/release-notes-v0.1.0.html</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-grid" style="grid-template-columns:1fr 1fr">
|
||||
<div class="card">
|
||||
<h3>📊 社区周报 HTML</h3>
|
||||
<p>4 维评分:Issue 关闭率(30%) + PR 合并率(30%) + 贡献者(25%) + 响应速度(15%)</p>
|
||||
<a class="output-link" href="../skills/gitlink-community-ops/scripts/_output/weekly-health-report.html" target="_blank" style="margin-top:12px">打开周报 →</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>📝 Release Notes HTML</h3>
|
||||
<p>按 Conventional Commits 自动分类:feat/fix/docs/chore</p>
|
||||
<a class="output-link" href="../skills/gitlink-community-ops/scripts/_output/release-notes-v0.1.0.html" target="_blank" style="margin-top:12px">打开 Release Notes →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== INSIGHT ===== -->
|
||||
<div class="demo-page" id="insight">
|
||||
<div class="page-header">
|
||||
<h2>④ 科研项目洞悉</h2>
|
||||
<p>research-insight-full.sh · 8 维数据采集 + 4 维评分 · 真实运行</p>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="term-bar"><span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span><span class="term-title">research-insight-full.sh</span></div>
|
||||
<div class="term-body">
|
||||
<div><span class="term-prompt">z@z-pc</span><span class="term-dim">:</span><span class="term-prompt">~/gitlink-cli</span><span class="term-dim">$</span> <span class="term-cmd">bash</span> <span class="term-str">skills/gitlink-research-insight/scripts/research-insight-full.sh</span> <span class="term-str">z2_cc</span> <span class="term-str">gitlink-cli</span></div>
|
||||
<div> </div>
|
||||
<div><span class="term-info">▸ Step 1/2: 采集 8 维数据</span></div>
|
||||
<div class="term-out">[INFO] ✅ 仓库信息 → repo-info.json</div>
|
||||
<div class="term-out">[INFO] ✅ 主题标签 → repo-topics.json</div>
|
||||
<div class="term-out">[INFO] ✅ 语言分布 → repo-languages.json (Go 70.1% / JS 22.7% / Shell 6.7%)</div>
|
||||
<div class="term-out">[INFO] ✅ 贡献者 → repo-contributors.json (16 人)</div>
|
||||
<div class="term-out">[INFO] ✅ 提交历史 → commits-100.json</div>
|
||||
<div class="term-out">[INFO] ✅ Issue → issues-all.json (17 个)</div>
|
||||
<div class="term-out">[INFO] ✅ PR → prs-all.json (2 个)</div>
|
||||
<div class="term-out">[INFO] ✅ 协作者 → collaborators.json</div>
|
||||
<div> </div>
|
||||
<div><span class="term-info">▸ Step 2/2: 4 维评分分析</span></div>
|
||||
<div class="term-out">[INFO] 项目成熟度: 2.8/10</div>
|
||||
<div class="term-out">[INFO] 文档质量: 5.0/10</div>
|
||||
<div class="term-out">[INFO] 贡献模式: 6.8/10</div>
|
||||
<div class="term-out">[INFO] 社区健康度: 4.4/10</div>
|
||||
<div class="term-out">[INFO] 综合评分: 4.8/10 🔴</div>
|
||||
<div class="term-ok">✅ HTML 报告已生成: _output/research-insight-report.html</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-row">
|
||||
<div class="metric"><div class="val" style="color:var(--red)">2.8</div><div class="lbl">成熟度 /10</div></div>
|
||||
<div class="metric"><div class="val" style="color:var(--gold)">5.0</div><div class="lbl">文档 /10</div></div>
|
||||
<div class="metric"><div class="val" style="color:var(--green)">6.8</div><div class="lbl">贡献模式 /10</div></div>
|
||||
<div class="metric"><div class="val" style="color:var(--red)">4.4</div><div class="lbl">社区健康 /10</div></div>
|
||||
<div class="metric"><div class="val" style="color:var(--gold)">4.8</div><div class="lbl">综合 /10</div></div>
|
||||
</div>
|
||||
|
||||
<a class="output-link" href="../skills/gitlink-research-insight/scripts/_output/research-insight-report.html" target="_blank">打开科研洞察报告 →</a>
|
||||
</div>
|
||||
|
||||
<!-- ===== HOTSPOT ===== -->
|
||||
<div class="demo-page" id="hotspot">
|
||||
<div class="page-header">
|
||||
<h2>⑤ 热点追踪与知识图谱</h2>
|
||||
<p>多关键词搜索 → 实体关系图谱 → HTML 可视化 · 21 个仓库</p>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="term-bar"><span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span><span class="term-title">hotspot-tracker-full.sh</span></div>
|
||||
<div class="term-body">
|
||||
<div><span class="term-prompt">z@z-pc</span><span class="term-dim">:</span><span class="term-prompt">~/gitlink-cli</span><span class="term-dim">$</span> <span class="term-cmd">bash</span> <span class="term-str">skills/gitlink-research-hotspot/scripts/hotspot-tracker-full.sh</span> <span class="term-str">"gitlink"</span> <span class="term-str">"CLI"</span></div>
|
||||
<div> </div>
|
||||
<div><span class="term-info">▸ Step 1/2: 多关键词搜索</span></div>
|
||||
<div class="term-out">[INFO] 搜索关键词 "gitlink" ... 找到 18 个仓库</div>
|
||||
<div class="term-out">[INFO] 搜索关键词 "CLI" ... 找到 3 个仓库</div>
|
||||
<div class="term-out">[INFO] 去重后共 21 个仓库</div>
|
||||
<div class="term-out">[INFO] 丰富数据: 采集每个仓库的 Star/Fork/语言/主题...</div>
|
||||
<div> </div>
|
||||
<div><span class="term-info">▸ Step 2/2: 构建知识图谱</span></div>
|
||||
<div class="term-out">[INFO] 实体识别: 项目 21 | 主题 4 | 技术栈 2 | 组织 4</div>
|
||||
<div class="term-out">[INFO] 关系提取: belongs_to / topic / language / depends_on</div>
|
||||
<div class="term-ok">✅ HTML 知识图谱已生成: _output/knowledge-graph-gitlink.html</div>
|
||||
<div class="term-ok">✅ HTML 趋势报告已生成: _output/hotspot-trend-report-gitlink.html</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-grid" style="grid-template-columns:1fr 1fr">
|
||||
<div class="card">
|
||||
<h3>🕸️ 知识图谱</h3>
|
||||
<p>21 个仓库 · 4 主题 · 2 技术栈 · 4 组织<br>支持缩放/拖拽/点击高亮</p>
|
||||
<a class="output-link" href="../skills/gitlink-research-hotspot/scripts/_output/knowledge-graph-gitlink.html" target="_blank" style="margin-top:12px">打开知识图谱 →</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>📈 趋势报告</h3>
|
||||
<p>热门主题分析 · 技术栈分布 · 组织活跃度排名</p>
|
||||
<a class="output-link" href="../skills/gitlink-research-hotspot/scripts/_output/hotspot-trend-report-gitlink.html" target="_blank" style="margin-top:12px">打开趋势报告 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SCORING ===== -->
|
||||
<div class="demo-page" id="scoring">
|
||||
<div class="page-header">
|
||||
<h2>⑥ 评分体系权重设计</h2>
|
||||
<p>两套评分系统 · 权重有据可依 · 阈值梯度设计</p>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h3>📊 周报评分 — 综合 = 关闭率×30% + 合并率×30% + 贡献者×25% + 响应×15%</h3>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">Issue 关闭率</div>
|
||||
<div class="score-track"><div class="score-fill" style="width:30%;background:#38bdf8">30%</div></div>
|
||||
<div class="score-val">3/10</div>
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">PR 合并率</div>
|
||||
<div class="score-track"><div class="score-fill" style="width:30%;background:#38bdf8">30%</div></div>
|
||||
<div class="score-val">3/10</div>
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">贡献者数</div>
|
||||
<div class="score-track"><div class="score-fill" style="width:25%;background:#818cf8">25%</div></div>
|
||||
<div class="score-val">3/10</div>
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">响应速度</div>
|
||||
<div class="score-track"><div class="score-fill" style="width:15%;background:#a78bfa">15%</div></div>
|
||||
<div class="score-val">5/10</div>
|
||||
</div>
|
||||
<p style="margin-top:12px;font-size:12px;color:var(--dim)">关闭率+合并率各30%:直接反映社区活跃度(结果指标)。贡献者25%:巴士因子,单人维护=高风险。响应15%:补充信号,关闭率已含"解决"信号。</p>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h3>🔬 科研洞察 — 综合 = 贡献模式×30% + 成熟度×25% + 社区×25% + 文档×20%</h3>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">贡献模式</div>
|
||||
<div class="score-track"><div class="score-fill" style="width:30%;background:#22c55e">30%</div></div>
|
||||
<div class="score-val">6.8/10</div>
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">成熟度</div>
|
||||
<div class="score-track"><div class="score-fill" style="width:25%;background:#38bdf8">25%</div></div>
|
||||
<div class="score-val">2.8/10</div>
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">社区健康度</div>
|
||||
<div class="score-track"><div class="score-fill" style="width:25%;background:#818cf8">25%</div></div>
|
||||
<div class="score-val">4.4/10</div>
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">文档质量</div>
|
||||
<div class="score-track"><div class="score-fill" style="width:20%;background:#a78bfa">20%</div></div>
|
||||
<div class="score-val">5.0/10</div>
|
||||
</div>
|
||||
<p style="margin-top:12px;font-size:12px;color:var(--dim)">贡献模式30%最高:科研项目核心在人。成熟度+社区各25%:阶段与活跃互补。文档20%最低:影响可复现性但非核心价值。</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📐 成熟度子维度 — 成熟度 = 年龄×25% + Star×25% + Fork×20% + 维护×30%</h3>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">维护活跃度</div>
|
||||
<div class="score-track"><div class="score-fill" style="width:30%;background:#fbbf24">30% 权重最高</div></div>
|
||||
<div class="score-val">7/10</div>
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">项目年龄</div>
|
||||
<div class="score-track"><div class="score-fill" style="width:25%;background:#38bdf8">25%</div></div>
|
||||
<div class="score-val">0.2/10</div>
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">Star 数</div>
|
||||
<div class="score-track"><div class="score-fill" style="width:25%;background:#818cf8">25%</div></div>
|
||||
<div class="score-val">1/10</div>
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">Fork 数</div>
|
||||
<div class="score-track"><div class="score-fill" style="width:20%;background:#a78bfa">20% 最低</div></div>
|
||||
<div class="score-val">2/10</div>
|
||||
</div>
|
||||
<p style="margin-top:12px;font-size:12px;color:var(--dim)">维护30%最高:老项目不维护=死项目。Fork 20%最低:Fork 多≠贡献多,有大量废弃 Fork 污染数据。</p>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
<b>📐 阈值设计:</b>统一梯度 <code>>80%→10分 | >50%→7分 | >30%→5分 | 其他→3分</code>,参考 CHAOSS 指标体系。
|
||||
<br><b>🔧 可调可复现:</b>权重在 JS 引擎里硬编码,可随时调整。同样数据跑出来同样分数。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function show(id, el) {
|
||||
document.querySelectorAll('.demo-page').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById(id).classList.add('active');
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
// ===== REPL 交互演示 =====
|
||||
const replOutput = () => document.getElementById('repl-output');
|
||||
|
||||
function replPrint(html, delay = 0) {
|
||||
setTimeout(() => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'repl-line';
|
||||
div.innerHTML = html;
|
||||
replOutput().appendChild(div);
|
||||
const term = document.getElementById('repl-terminal');
|
||||
term.scrollTop = term.scrollHeight;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function replClear() {
|
||||
replOutput().innerHTML = '';
|
||||
}
|
||||
|
||||
function replDemo(type) {
|
||||
replClear();
|
||||
const P = '<span style="color:#89b4fa">z@z-pc</span><span style="color:#6c7086">:</span><span style="color:#89b4fa">~/gitlink-cli</span><span style="color:#6c7086">$ </span>';
|
||||
const C = (s) => `<span style="color:#cdd6f4">${s}</span>`;
|
||||
const F = (s) => `<span style="color:#f9e2af">${s}</span>`;
|
||||
const S = (s) => `<span style="color:#a6e3a1">${s}</span>`;
|
||||
const D = (s) => `<span style="color:#6c7086">${s}</span>`;
|
||||
const I = (s) => `<span style="color:#89b4fa">${s}</span>`;
|
||||
const OK = (s) => `<span style="color:#a6e3a1">${s}</span>`;
|
||||
const ERR = (s) => `<span style="color:#f38ba8">${s}</span>`;
|
||||
const SEC = (s) => `<span style="color:#cba6f7;font-weight:bold">${s}</span>`;
|
||||
|
||||
if (type === 'palette') {
|
||||
// 命令面板演示
|
||||
replPrint(P + C('') + '<span style="background:#cdd6f4;width:8px;height:16px;display:inline-block"></span>', 0);
|
||||
replPrint(D(' 输入 / 唤起命令面板...'), 400);
|
||||
replPrint('', 700);
|
||||
replPrint(SEC('┌─ Command Palette ──────────────────────┐'), 900);
|
||||
replPrint(SEC('│') + ' ' + F('搜索: ') + C('islist') + ' ' + SEC('│'), 1100);
|
||||
replPrint(SEC('│') + ' ' + D('匹配: issue +list') + ' ' + SEC('│'), 1300);
|
||||
replPrint(SEC('├────────────────────────────────────────┤'), 1500);
|
||||
replPrint(SEC('│') + ' ❯ ' + I('issue') + ' Issue 管理 ' + SEC('│'), 1700);
|
||||
replPrint(SEC('│') + ' ' + D('repo') + ' 仓库操作 ' + SEC('│'), 1800);
|
||||
replPrint(SEC('│') + ' ' + D('pr') + ' Pull Request ' + SEC('│'), 1900);
|
||||
replPrint(SEC('│') + ' ' + D('collaborator') + ' 协作者管理 ' + SEC('│'), 2000);
|
||||
replPrint(SEC('│') + ' ' + D('webhook') + ' Webhook 管理 ' + SEC('│'), 2100);
|
||||
replPrint(SEC('└────────────────────────────────────────┘'), 2200);
|
||||
replPrint(D(' Enter 选择 · Esc 返回 · ↑↓ 导航'), 2400);
|
||||
replPrint('', 2800);
|
||||
replPrint(D(' → 选中 issue,进入命令选择...'), 3000);
|
||||
replPrint('', 3300);
|
||||
replPrint(SEC('┌─ issue 命令 ───────────────────────────┐'), 3500);
|
||||
replPrint(SEC('│') + ' ❯ ' + C('+list') + ' 列出 Issue ' + SEC('│'), 3700);
|
||||
replPrint(SEC('│') + ' ' + D('+create') + ' 创建 Issue ' + SEC('│'), 3800);
|
||||
replPrint(SEC('│') + ' ' + D('+info') + ' Issue 详情 ' + SEC('│'), 3900);
|
||||
replPrint(SEC('│') + ' ' + D('+close') + ' 关闭 Issue ' + SEC('│'), 4000);
|
||||
replPrint(SEC('└────────────────────────────────────────┘'), 4100);
|
||||
replPrint('', 4400);
|
||||
replPrint(D(' → 选中 +list,Enter 提交 → 执行命令...'), 4600);
|
||||
replPrint('', 4900);
|
||||
replPrint(I('▸ Executing: issue +list'), 5100);
|
||||
replPrint(OK('✓ 命令执行成功,输出 7 条 Issue'), 5500);
|
||||
|
||||
} else if (type === 'form') {
|
||||
// 表单输入演示
|
||||
replPrint(P + C('issue +create'), 0);
|
||||
replPrint(D(' → 自动生成参数表单...'), 300);
|
||||
replPrint('', 600);
|
||||
replPrint(SEC('┌─ issue +create — 参数表单 ──────────────┐'), 800);
|
||||
replPrint(SEC('│') + ' ' + SEC('│'), 900);
|
||||
replPrint(SEC('│') + ' ' + F('title') + ' ' + ERR('*') + ': ' + S('Add webhook monitoring') + ' ' + SEC('│'), 1200);
|
||||
replPrint(SEC('│') + ' ' + D('Issue 标题') + ' ' + SEC('│'), 1300);
|
||||
replPrint(SEC('│') + ' ' + SEC('│'), 1400);
|
||||
replPrint(SEC('│') + ' ' + F('body') + ': ' + S('We need to monitor...') + ' ' + SEC('│'), 1700);
|
||||
replPrint(SEC('│') + ' ' + D('Markdown 描述') + ' ' + SEC('│'), 1800);
|
||||
replPrint(SEC('│') + ' ' + SEC('│'), 1900);
|
||||
replPrint(SEC('│') + ' ' + F('assignee') + ': ' + S('liuyan688') + ' ' + SEC('│'), 2200);
|
||||
replPrint(SEC('│') + ' ' + D('负责人') + ' ' + SEC('│'), 2300);
|
||||
replPrint(SEC('│') + ' ' + SEC('│'), 2400);
|
||||
replPrint(SEC('│') + ' ' + F('private') + ': ' + OK('[✓]') + ' ' + D('Space 切换') + ' ' + SEC('│'), 2700);
|
||||
replPrint(SEC('│') + ' ' + D('是否私密') + ' ' + SEC('│'), 2800);
|
||||
replPrint(SEC('│') + ' ' + SEC('│'), 2900);
|
||||
replPrint(SEC('│') + ' ' + ERR('✗ title is required') + ' ' + SEC('│'), 3200);
|
||||
replPrint(SEC('│') + ' ' + SEC('│'), 3300);
|
||||
replPrint(SEC('└────────────────────────────────────────┘'), 3400);
|
||||
replPrint(D(' Enter 提交 · Tab/↑↓ 切换 · Esc 取消'), 3600);
|
||||
replPrint('', 4000);
|
||||
replPrint(D(' → 填写 title: Add webhook monitoring'), 4200);
|
||||
replPrint(D(' → Tab 切换到 body: We need to monitor...'), 4500);
|
||||
replPrint(D(' → Tab 切换到 assignee: liuyan688'), 4800);
|
||||
replPrint(D(' → Space 切换 private: [✓]'), 5100);
|
||||
replPrint('', 5400);
|
||||
replPrint(I('▸ Enter 提交表单...'), 5600);
|
||||
replPrint(OK('✓ Issue #22 已创建'), 6000);
|
||||
|
||||
} else if (type === 'viewport') {
|
||||
// 滚动视口演示
|
||||
replPrint(P + C('issue +list') + ' ' + F('--owner') + ' ' + S('z2_cc') + ' ' + F('--repo') + ' ' + S('gitlink-cli'), 0);
|
||||
replPrint('', 300);
|
||||
replPrint(SEC('┌─ issue +list (47 rows) ─────────────────┐'), 500);
|
||||
replPrint(SEC('│') + ' # title status ' + SEC('│'), 600);
|
||||
replPrint(SEC('│') + ' ─── ──────────────────────── ──────── ' + SEC('│'), 700);
|
||||
replPrint(SEC('│') + ' 15 webhook monitor ' + F('open') + ' ' + SEC('│'), 800);
|
||||
replPrint(SEC('│') + ' 14 wiki markdown render ' + F('open') + ' ' + SEC('│'), 900);
|
||||
replPrint(SEC('│') + ' 13 PR diff too verbose ' + F('open') + ' ' + SEC('│'), 1000);
|
||||
replPrint(SEC('│') + ' 12 snippet pagination bug ' + F('open') + ' ' + SEC('│'), 1100);
|
||||
replPrint(SEC('│') + ' 11 branch protect check ' + F('open') + ' ' + SEC('│'), 1200);
|
||||
replPrint(SEC('│') + ' 10 file upload timeout ' + F('open') + ' ' + SEC('│'), 1300);
|
||||
replPrint(SEC('│') + ' 09 milestone sort order ' + OK('closed') + ' ' + SEC('│'), 1400);
|
||||
replPrint(SEC('│') + ' 08 tag batch operation ' + OK('closed') + ' ' + SEC('│'), 1500);
|
||||
replPrint(SEC('│') + ' 07 sshkey list format ' + OK('closed') + ' ' + SEC('│'), 1600);
|
||||
replPrint(SEC('│') + ' 06 commit hash display ' + OK('closed') + ' ' + SEC('│'), 1700);
|
||||
replPrint(SEC('│') + ' ↓ 36 more rows... ' + SEC('│'), 1800);
|
||||
replPrint(SEC('└────────────────────────────────────────┘'), 1900);
|
||||
replPrint(D(' ↑↓/PgUp/PgDn 滚动 · q/Esc 返回 · 47 rows · showing 1-11'), 2100);
|
||||
replPrint('', 2500);
|
||||
replPrint(D(' → 输出超一屏,自动进入滚动模式'), 2700);
|
||||
replPrint(D(' → ↑↓ 逐行滚动,PgUp/PgDn 翻页'), 3000);
|
||||
replPrint(D(' → 表格化渲染,不是原始 JSON'), 3300);
|
||||
replPrint('', 3600);
|
||||
replPrint(OK('✓ 按 q 返回输入状态'), 4000);
|
||||
|
||||
} else if (type === 'reset') {
|
||||
replClear();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
#!/bin/bash
|
||||
# ============================================
|
||||
# GitLink CLI 现场演示脚本
|
||||
# 用法: bash demo.sh
|
||||
# ============================================
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
export GITLINK_TOKEN="${GITLINK_TOKEN:-9185e0529683a6f8e7c9aa86bada03e8652740f7}"
|
||||
export PATH="$(pwd):$PATH"
|
||||
|
||||
OWNER="z2_cc"
|
||||
REPO="gitlink-cli"
|
||||
|
||||
cyan() { echo -e "\033[1;36m$1\033[0m"; }
|
||||
green() { echo -e "\033[1;32m$1\033[0m"; }
|
||||
yellow() { echo -e "\033[1;33m$1\033[0m"; }
|
||||
dim() { echo -e "\033[2m$1\033[0m"; }
|
||||
line() { echo -e "\033[2m─────────────────────────────────────────────────\033[0m"; }
|
||||
pause() { echo ""; read -p "$(dim ' [按回车继续]')" _; echo ""; }
|
||||
|
||||
banner() {
|
||||
echo ""
|
||||
line
|
||||
cyan " $1"
|
||||
line
|
||||
}
|
||||
|
||||
# ============================================
|
||||
banner "① CLI 基础命令演示(30 秒)"
|
||||
# ============================================
|
||||
dim " 演示:repo +info / issue +list / collaborator +list / webhook +failed"
|
||||
|
||||
echo ""
|
||||
yellow " $ gitlink-cli repo +info --owner $OWNER --repo $REPO --format table"
|
||||
echo ""
|
||||
gitlink-cli repo +info --owner "$OWNER" --repo "$REPO" --format table 2>&1 | head -15
|
||||
pause
|
||||
|
||||
echo ""
|
||||
yellow " $ gitlink-cli collaborator +list --owner $OWNER --repo $REPO"
|
||||
echo ""
|
||||
gitlink-cli collaborator +list --owner "$OWNER" --repo "$REPO" --format table 2>&1 | head -10
|
||||
pause
|
||||
|
||||
echo ""
|
||||
yellow " $ gitlink-cli issue +list --owner $OWNER --repo $REPO"
|
||||
echo ""
|
||||
gitlink-cli issue +list --owner "$OWNER" --repo "$REPO" --format table 2>&1 | head -12
|
||||
pause
|
||||
|
||||
# ============================================
|
||||
banner "② 社区运营自动化(60 秒)"
|
||||
# ============================================
|
||||
dim " 演示:一条命令完成 Issue 分拣 → 周报 → Release Notes"
|
||||
echo ""
|
||||
yellow " $ DRY_RUN=true bash skills/gitlink-community-ops/scripts/community-ops-full.sh $OWNER $REPO"
|
||||
echo ""
|
||||
DRY_RUN=true bash skills/gitlink-community-ops/scripts/community-ops-full.sh "$OWNER" "$REPO" 2>&1 | tail -25
|
||||
pause
|
||||
|
||||
echo ""
|
||||
green " ✅ 生成的文件:"
|
||||
find skills/gitlink-community-ops/scripts/_output -name "*.html" -o -name "*.md" | while read f; do
|
||||
dim " 📄 $f"
|
||||
done
|
||||
pause
|
||||
|
||||
# ============================================
|
||||
banner "③ 科研项目洞悉(45 秒)"
|
||||
# ============================================
|
||||
dim " 演示:8 维数据采集 + 4 维评分"
|
||||
echo ""
|
||||
yellow " $ bash skills/gitlink-research-insight/scripts/research-insight-full.sh $OWNER $REPO"
|
||||
echo ""
|
||||
bash skills/gitlink-research-insight/scripts/research-insight-full.sh "$OWNER" "$REPO" 2>&1 | tail -15
|
||||
pause
|
||||
|
||||
# ============================================
|
||||
banner "④ 知识图谱可视化(45 秒)"
|
||||
# ============================================
|
||||
dim " 演示:多关键词搜索 → 实体关系图谱 → HTML 可视化"
|
||||
echo ""
|
||||
yellow " $ bash skills/gitlink-research-hotspot/scripts/hotspot-tracker-full.sh gitlink CLI"
|
||||
echo ""
|
||||
bash skills/gitlink-research-hotspot/scripts/hotspot-tracker-full.sh "gitlink" "CLI" 2>&1 | tail -15
|
||||
pause
|
||||
|
||||
echo ""
|
||||
green " ✅ 知识图谱已生成:"
|
||||
dim " 🌐 skills/gitlink-research-hotspot/scripts/_output/knowledge-graph-gitlink.html"
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
banner "演示完成 ✅"
|
||||
# ============================================
|
||||
echo ""
|
||||
cyan " 所有数据均来自 GitLink API 实时采集"
|
||||
cyan " 演示脚本:doc/demo.sh"
|
||||
echo ""
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
# 子任务三 & 子任务四 — 工作交接文档
|
||||
|
||||
> 最后更新:2026-06-30
|
||||
> 提交记录:`378d8f1` — 46 files changed, 5493 insertions
|
||||
> Issue:[#19](https://gitlink.org.cn/z2_cc/gitlink-cli/issues/19) / [#20](https://gitlink.org.cn/z2_cc/gitlink-cli/issues/20)
|
||||
|
||||
---
|
||||
|
||||
## 一、总体架构
|
||||
|
||||
```
|
||||
e:/gitlink-cli/gitlink-cli/
|
||||
├── skills/
|
||||
│ ├── gitlink-community-ops/ # 子任务三 - 场景A
|
||||
│ ├── gitlink-quality-gate/ # 子任务三 - 场景B
|
||||
│ ├── gitlink-research-insight/ # 子任务四 - 模块C
|
||||
│ ├── gitlink-research-hotspot/ # 子任务四 - 模块D
|
||||
│ └── gitlink-shared/scripts/ # 共享工具
|
||||
│ ├── detect-cli.sh # CLI 版本自动检测
|
||||
│ └── md-to-html.js # Markdown→HTML 转换器
|
||||
├── doc/
|
||||
│ ├── sub-task3-scenario-a/ # 场景A 架构图+报告
|
||||
│ ├── sub-task3-scenario-b/ # 场景B 架构图+报告
|
||||
│ ├── sub-task4-module-c/ # 模块C 架构图+报告
|
||||
│ └── sub-task4-module-d/ # 模块D 架构图+报告
|
||||
├── .devops/
|
||||
│ ├── 构建流水线.yml # 原有(修改)
|
||||
│ ├── 社区运营流水线.yml # 新增
|
||||
│ └── 代码质量门禁流水线.yml # 新增
|
||||
└── CLAUDE.md # 修改(新增 skill 映射)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、子任务三:端到端自动化工作流
|
||||
|
||||
### 场景A:社区运营自动化
|
||||
|
||||
**三阶段流水线:** Issue 分拣 → 社区周报 → Release Notes
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `skills/gitlink-community-ops/SKILL.md` | Skill 定义,引用 issue-triage/project-health/release-auto |
|
||||
| `examples/community-ops-workflow.md` | 端到端工作流示例 |
|
||||
| `scripts/01-issue-triage.sh` | Phase 1:采集 Issue → 关键词权重分析 → 建议标签 |
|
||||
| `scripts/02-weekly-health-report.sh` | Phase 2:采集 Issue/PR/Commit/Milestone → 生成周报 |
|
||||
| `scripts/03-auto-release-notes.sh` | Phase 3:获取提交历史 → 分类 → 生成 Release Notes |
|
||||
| `scripts/community-ops-full.sh` | 全流程编排(依次调用三阶段) |
|
||||
| `scripts/health-analyze.js` | 周报数据分析引擎(计算关闭率/评分/改进建议) |
|
||||
| `scripts/release-analyze.js` | Release Notes 分析引擎(按 Conventional Commits 分类) |
|
||||
|
||||
**运行方式:**
|
||||
```bash
|
||||
cd e:/gitlink-cli/gitlink-cli
|
||||
DRY_RUN=true bash skills/gitlink-community-ops/scripts/community-ops-full.sh z2_cc gitlink-cli
|
||||
```
|
||||
|
||||
### 场景B:代码质量看门人
|
||||
|
||||
**四阶段门禁:** PR 采集 → 深度审查 → CI 检查 → 质量评分决策
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `skills/gitlink-quality-gate/SKILL.md` | Skill 定义 |
|
||||
| `examples/quality-gate-workflow.md` | 工作流示例 |
|
||||
| `scripts/01-collect-prs.sh` | Phase 1:列出待审 PR |
|
||||
| `scripts/02-deep-review.sh` | Phase 2:获取 PR 详情/文件/diff/评审历史 |
|
||||
| `scripts/03-ci-check.sh` | Phase 3:检查 CI 构建状态 |
|
||||
| `scripts/04-quality-gate-decision.sh` | Phase 4:计算评分 → 自动合并/给修改建议 |
|
||||
| `scripts/quality-gate-full.sh` | 全流程编排(遍历所有待审 PR) |
|
||||
|
||||
**评分模型:**
|
||||
```
|
||||
质量总分 = CodeReview×40% + CI状态×30% + 设计一致性×30%
|
||||
门槛:≥70 分且 CI 通过 → 自动合并
|
||||
```
|
||||
|
||||
**运行方式:**
|
||||
```bash
|
||||
DRY_RUN=true bash skills/gitlink-quality-gate/scripts/quality-gate-full.sh z2_cc gitlink-cli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、子任务四:应用 GitLink 辅助科研
|
||||
|
||||
### 模块C:科研项目洞悉
|
||||
|
||||
**流程:** 8 维数据采集 → 4 维度评分 → 结构化报告
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `skills/gitlink-research-insight/SKILL.md` | Skill 定义 |
|
||||
| `examples/research-insight-workflow.md` | 工作流示例 |
|
||||
| `scripts/01-collect-repo-data.sh` | 采集 repo/commit/issue/pr/collaborator/topics/languages |
|
||||
| `scripts/02-analyze-contributions.sh` | 调用 analyze.js 计算评分 |
|
||||
| `scripts/analyze.js` | **数据分析引擎**:读取 JSON → 计算成熟度/文档/贡献/社区评分 → 生成报告 |
|
||||
| `scripts/research-insight-full.sh` | 全流程编排 |
|
||||
|
||||
**评分维度:**
|
||||
- 成熟度(25%):年龄 + Star + Fork + 更新频率
|
||||
- 文档质量(20%):README + License + CI
|
||||
- 贡献模式(30%):贡献者数 + 提交频率
|
||||
- 社区健康度(25%):Issue 关闭率 + PR 合并率
|
||||
|
||||
**运行方式:**
|
||||
```bash
|
||||
bash skills/gitlink-research-insight/scripts/research-insight-full.sh z2_cc gitlink-cli
|
||||
```
|
||||
|
||||
### 模块D:热点追踪与知识图谱
|
||||
|
||||
**流程:** 多关键词搜索 → 数据丰富 → 知识图谱 → 趋势报告
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `skills/gitlink-research-hotspot/SKILL.md` | Skill 定义 |
|
||||
| `examples/hotspot-workflow.md` | 工作流示例 |
|
||||
| `scripts/01-search-repos.sh` | 多关键词搜索 + 去重 + 数据丰富 |
|
||||
| `scripts/02-build-knowledge-graph.sh` | 调用 hotspot-analyze.js + kg-to-html.js |
|
||||
| `scripts/hotspot-analyze.js` | **数据分析引擎**:提取实体关系,生成知识图谱和趋势报告 |
|
||||
| `scripts/kg-to-html.js` | **交互式可视化**:生成可缩放/拖拽/高亮的 HTML 图谱 |
|
||||
| `scripts/hotspot-tracker-full.sh` | 全流程编排 |
|
||||
|
||||
**运行方式:**
|
||||
```bash
|
||||
bash skills/gitlink-research-hotspot/scripts/hotspot-tracker-full.sh "machine learning" "深度学习" "自然语言处理"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、共享工具
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `skills/gitlink-shared/scripts/detect-cli.sh` | **CLI 版本检测**:优先使用本地编译版本(支持 commit +list 等完整命令),回退系统 PATH 版本 |
|
||||
| `skills/gitlink-shared/scripts/md-to-html.js` | **报告可视化**:将任意 Markdown 报告转为带评分环、表格着色、响应式布局的 HTML 页面 |
|
||||
|
||||
---
|
||||
|
||||
## 五、报告文档
|
||||
|
||||
| 文档路径 | 内容 |
|
||||
|----------|------|
|
||||
| `doc/sub-task3-scenario-a/架构图` | Mermaid 社区运营流程 |
|
||||
| `doc/sub-task3-scenario-a/新需求构思报告` | 需求背景、详述、技术方案、风险评估 |
|
||||
| `doc/sub-task3-scenario-a/变更影响分析及测试报告` | 变更范围、测试计划、风险、结论 |
|
||||
| `doc/sub-task3-scenario-b/架构图` | Mermaid 质量门禁流程 + 评分模型 |
|
||||
| `doc/sub-task3-scenario-b/新需求构思报告` | 同上 |
|
||||
| `doc/sub-task3-scenario-b/变更影响分析及测试报告` | 同上 |
|
||||
| `doc/sub-task4-module-c/架构图` | 科研洞察四层架构 |
|
||||
| `doc/sub-task4-module-c/research-insight-report` | 洞察报告模板 |
|
||||
| `doc/sub-task4-module-d/架构图` | 热点追踪三层架构 |
|
||||
| `doc/sub-task4-module-d/hotspot-trend-report` | 趋势报告模板 |
|
||||
| `doc/sub-task4-module-d/knowledge-graph` | 知识图谱说明 |
|
||||
|
||||
---
|
||||
|
||||
## 六、DevOps 流水线
|
||||
|
||||
| 文件 | 触发 | 功能 |
|
||||
|------|------|------|
|
||||
| `.devops/构建流水线.yml` | Push 触发 | Go 编译验证 |
|
||||
| `.devops/社区运营流水线.yml` | 定时触发 | SSH 执行 community-ops-full.sh |
|
||||
| `.devops/代码质量门禁流水线.yml` | PR 事件 | SSH 执行 quality-gate-full.sh |
|
||||
|
||||
> ⚠️ 需要到 GitLink 仓库开启 DevOps 引擎并配置 SSH 密钥才能运行
|
||||
|
||||
---
|
||||
|
||||
## 七、常见问题
|
||||
|
||||
### Q1:如何让 commit +list 等命令可用?
|
||||
当前系统 PATH 的 npm 版 CLI 不包含全部命令。脚本已自动检测本地编译版本,只需编译即可:
|
||||
```bash
|
||||
cd e:/gitlink-cli/gitlink-cli && go build -o gitlink-cli.exe .
|
||||
```
|
||||
|
||||
### Q2:脚本默认会修改数据吗?
|
||||
**不会。** 所有写操作(issue +update / pr +merge / release +create)默认受 `DRY_RUN=true` 保护。
|
||||
需要实际执行时:
|
||||
```bash
|
||||
DRY_RUN=false bash scripts/xxx.sh <owner> <repo>
|
||||
```
|
||||
|
||||
### Q3:输出文件在哪?
|
||||
所有脚本的输出保存在各自 `scripts/_output/` 目录下,包含 JSON 数据、Markdown 报告和 HTML 可视化。
|
||||
`.gitignore` 已忽略 `_output/` 目录。
|
||||
|
||||
### Q4:如何查看可视化报告?
|
||||
```bash
|
||||
# 科研洞察报告
|
||||
explorer "skills/gitlink-research-insight/scripts/_output/research-insight-report.html"
|
||||
|
||||
# 社区周报
|
||||
explorer "skills/gitlink-community-ops/scripts/_output/weekly-health-report.html"
|
||||
|
||||
# Release Notes
|
||||
explorer "skills/gitlink-community-ops/scripts/_output/release-notes-v0.1.0.html"
|
||||
|
||||
# 知识图谱(交互式)
|
||||
explorer "skills/gitlink-research-hotspot/scripts/_output/knowledge-graph-machine learning.html"
|
||||
|
||||
# 热点趋势报告
|
||||
explorer "skills/gitlink-research-hotspot/scripts/_output/hotspot-trend-report-machine learning.html"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、文件清单(46 个文件)
|
||||
|
||||
### 新增文件(44 个)
|
||||
```
|
||||
skills/gitlink-community-ops/
|
||||
├── SKILL.md
|
||||
├── examples/community-ops-workflow.md
|
||||
└── scripts/
|
||||
├── 01-issue-triage.sh
|
||||
├── 02-weekly-health-report.sh
|
||||
├── 03-auto-release-notes.sh
|
||||
├── community-ops-full.sh
|
||||
├── health-analyze.js
|
||||
└── release-analyze.js
|
||||
|
||||
skills/gitlink-quality-gate/
|
||||
├── SKILL.md
|
||||
├── examples/quality-gate-workflow.md
|
||||
└── scripts/
|
||||
├── 01-collect-prs.sh
|
||||
├── 02-deep-review.sh
|
||||
├── 03-ci-check.sh
|
||||
├── 04-quality-gate-decision.sh
|
||||
└── quality-gate-full.sh
|
||||
|
||||
skills/gitlink-research-insight/
|
||||
├── SKILL.md
|
||||
├── examples/research-insight-workflow.md
|
||||
└── scripts/
|
||||
├── 01-collect-repo-data.sh
|
||||
├── 02-analyze-contributions.sh
|
||||
├── analyze.js
|
||||
└── research-insight-full.sh
|
||||
|
||||
skills/gitlink-research-hotspot/
|
||||
├── SKILL.md
|
||||
├── examples/hotspot-workflow.md
|
||||
└── scripts/
|
||||
├── 01-search-repos.sh
|
||||
├── 02-build-knowledge-graph.sh
|
||||
├── hotspot-analyze.js
|
||||
├── hotspot-tracker-full.sh
|
||||
└── kg-to-html.js
|
||||
|
||||
skills/gitlink-shared/scripts/
|
||||
├── detect-cli.sh
|
||||
└── md-to-html.js
|
||||
|
||||
doc/
|
||||
├── sub-task3-scenario-a/
|
||||
│ ├── architecture-diagram.md
|
||||
│ ├── 新需求构思报告.md
|
||||
│ └── 变更影响分析及测试报告.md
|
||||
├── sub-task3-scenario-b/
|
||||
│ ├── architecture-diagram.md
|
||||
│ ├── 新需求构思报告.md
|
||||
│ └── 变更影响分析及测试报告.md
|
||||
├── sub-task4-module-c/
|
||||
│ ├── architecture-diagram.md
|
||||
│ └── research-insight-report.md
|
||||
├── sub-task4-module-d/
|
||||
│ ├── architecture-diagram.md
|
||||
│ ├── hotspot-trend-report.md
|
||||
│ └── knowledge-graph.md
|
||||
└── sub-task34-交接文档.md ← 本文件
|
||||
|
||||
.devops/
|
||||
├── 社区运营流水线.yml
|
||||
└── 代码质量门禁流水线.yml
|
||||
```
|
||||
|
||||
### 修改文件(2 个)
|
||||
```
|
||||
.gitignore — 添加 _output/ .cli-bin/ *.json 忽略规则
|
||||
CLAUDE.md — 添加 4 个新 skill 映射
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*如有疑问,查看 Issue #19(子任务三)和 #20(子任务四)*
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
# 工作日志 — 子任务三 & 子任务四
|
||||
|
||||
> 项目:GitLink CLI 课程实践(《软件演化与运维》)
|
||||
> 周期:2026-06-29 ~ 2026-06-30
|
||||
> 提交记录:`378d8f1` → `b0d46b4`
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-29
|
||||
|
||||
### 上午:需求理解与方案设计
|
||||
|
||||
| 时间段 | 工作内容 |
|
||||
|--------|----------|
|
||||
| 09:00-09:30 | 阅读课程实践任务文档(0613),了解子任务三/四要求 |
|
||||
| 09:30-09:50 | 探索 gitlink-cli 项目现有结构和资产(CLI 命令、Skills、DevOps) |
|
||||
| 09:50-10:30 | 设计总体方案,确认四个模块的技术路线 |
|
||||
| 10:30-11:00 | 用户确认方案:子任务三两个场景、子任务四两个模块,先设计后实现 |
|
||||
|
||||
**决策记录:**
|
||||
- 子任务三选「社区运营自动化」+「代码质量看门人」两个场景
|
||||
- 子任务四选「科研项目洞悉」+「热点追踪与知识图谱」两个模块
|
||||
- 使用 Bash 脚本 + JS 分析引擎的模式,保证可复现性
|
||||
- 所有写操作默认 DRY_RUN=true
|
||||
|
||||
### 上午:Phase 1-2 — Skill 定义和工作流示例
|
||||
|
||||
| 时间段 | 工作内容 | 文件数 |
|
||||
|--------|----------|--------|
|
||||
| 11:00-11:20 | 创建目录结构(20 个目录) | — |
|
||||
| 11:20-11:40 | 编写 4 个 SKILL.md 定义文件 | 4 |
|
||||
| 11:40-12:00 | 编写 4 个工作流示例文档 | 4 |
|
||||
|
||||
### 下午:Phase 3 — 可复现脚本开发
|
||||
|
||||
| 时间段 | 工作内容 | 文件数 |
|
||||
|--------|----------|--------|
|
||||
| 13:00-14:00 | 社区运营 4 个脚本(01-issue-triage / 02-weekly / 03-release / full) | 4 |
|
||||
| 14:00-14:40 | 质量门禁 5 个脚本(01-PR采集 / 02-审查 / 03-CI检查 / 04-决策 / full) | 5 |
|
||||
| 14:40-15:10 | 科研洞察 3 个脚本(01-数据采集 / 02-分析 / full) | 3 |
|
||||
| 15:10-15:30 | 热点追踪 3 个脚本(01-搜索 / 02-图谱 / full) | 3 |
|
||||
|
||||
### 下午:Phase 4-5 — 架构图和报告文档
|
||||
|
||||
| 时间段 | 工作内容 | 文件数 |
|
||||
|--------|----------|--------|
|
||||
| 15:30-15:50 | 4 个 Mermaid 架构图 | 4 |
|
||||
| 15:50-16:30 | 子任务三 4 份报告(新需求构思+变更测试 × 2场景) | 4 |
|
||||
| 16:30-17:00 | 子任务四 4 份报告(洞察报告/趋势报告/知识图谱说明) | 4 |
|
||||
|
||||
### 下午:Phase 6 — 配置更新
|
||||
|
||||
| 时间段 | 工作内容 |
|
||||
|--------|----------|
|
||||
| 17:00-17:15 | 更新 CLAUDE.md(4 个新 skill 映射) |
|
||||
| 17:15-17:25 | 创建 2 条 DevOps 流水线 + 更新 .gitignore |
|
||||
| 17:25-17:30 | 拷贝 SKILL.md 到 .claude/skills/ |
|
||||
|
||||
### 第一次测试与修复
|
||||
|
||||
| 时间 | 问题 | 修复 |
|
||||
|------|------|------|
|
||||
| 17:30 | 周报变量未展开 | heredoc `'EOF'` → `EOF` |
|
||||
| 17:30 | `commit +list` / `collaborator +list` 不可用 | 发现是系统 PATH 用了 npm 旧版 CLI |
|
||||
| 17:35 | 科研分析只输出模板 | 创建 `analyze.js` 独立 JS 分析引擎 |
|
||||
| 17:40 | node 参数太长 | 改用 JS 直接读文件 |
|
||||
| 17:50 | search 去重失败 | 修复 MSYS 路径问题 + JSON 结构解析 |
|
||||
| 17:55 | 知识图谱 `[object Object]` | 修复 topic/language 字段为对象类型 |
|
||||
|
||||
**问题根因汇总:**
|
||||
1. 系统 `gitlink-cli` 是 npm 安装的旧版本(421 字节的 JS wrapper),缺少 `commit`、`collaborator` 等命令
|
||||
2. MSYS2 路径 `/e/xxx` 传给 node 时变成了 `E:\e\xxx` — 需要用 `process.argv` 传参或绝对路径
|
||||
3. GitLink API 返回的 `topics` 和 `language` 字段是 `{id, name}` 对象而不是字符串
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-30
|
||||
|
||||
### 上午:第二次测试与修复
|
||||
|
||||
| 时间 | 问题 | 修复 |
|
||||
|------|------|------|
|
||||
| 08:00 | 科研洞察描述为空 | 从 issue.project.description 获取 |
|
||||
| 08:05 | 热点搜索仍是 0 条 | 创建 `hotspot-analyze.js` + 修复 node 路径 |
|
||||
| 08:10 | CLI 检测失效(alias 在脚本中无效) | 创建 `detect-cli.sh`,用 wrapper + PATH 覆盖 |
|
||||
| 08:15 | 周报/Release Notes 还是模板 | 创建 `health-analyze.js` + `release-analyze.js` |
|
||||
| 08:20 | Release Notes 文件/行数为 0 | fallback 路径缺 `git log --stat`,补充 + 中文正则 |
|
||||
| 08:30 | 知识图谱关系太简单 | 重写 `kg-to-html.js`:缩放/拖拽/高亮/侧栏/导出 |
|
||||
| 08:35 | 报告只有 Markdown 不便展示 | 创建 `md-to-html.js` 通用转换器 |
|
||||
|
||||
### 最终验证
|
||||
|
||||
| 模块 | 命令 | 结果 |
|
||||
|------|------|------|
|
||||
| 科研洞察 | `research-insight-full.sh z2_cc gitlink-cli` | ✅ 16 贡献者、15 Issue、4.8/10 分 |
|
||||
| 热点追踪 | `hotspot-tracker-full.sh "machine learning" "深度学习"` | ✅ 33 仓库、19 主题标签、11 语言 |
|
||||
| 社区运营 | `community-ops-full.sh z2_cc gitlink-cli` | ✅ 三阶段完整输出 |
|
||||
| 质量门禁 | `quality-gate-full.sh z2_cc gitlink-cli` | ✅ 脚本就绪(当前无 PR) |
|
||||
|
||||
### 代码提交与 Issue
|
||||
|
||||
| 时间 | 操作 |
|
||||
|------|------|
|
||||
| 09:00 | commit #1:46 文件,5493 行 |
|
||||
| 09:05 | git push 到 `z2_cc/gitlink-cli` |
|
||||
| 09:10 | 创建 Issue #19(子任务三)和 #20(子任务四) |
|
||||
| 09:15 | 补充交接文档并提交 |
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-30 下午:文档整理与技术债务优化
|
||||
|
||||
### 文档结构清理
|
||||
|
||||
| 时间 | 操作 |
|
||||
|------|------|
|
||||
| 10:00 | 删除分散的 4 份报告(scenario-a/b 各 2 份)|
|
||||
| 10:05 | 创建统一《新需求构思报告》(覆盖子任务一至四)|
|
||||
| 10:10 | 创建统一《变更影响分析及测试报告》(含完整测试结果)|
|
||||
| 10:20 | 删除多余的 architecture-diagram、demo 等 9 个文件 |
|
||||
| 10:25 | 精简 doc/ 目录从 17 个文件降至 8 个 |
|
||||
|
||||
### 技术债务优化
|
||||
|
||||
| 时间 | 问题 | 修复 |
|
||||
|------|------|------|
|
||||
| 10:30 | Release Notes 依赖 git log | `release-analyze.js` 新增 API JSON 输入支持 |
|
||||
| 10:35 | 非 git 目录 Release Notes 为空 | `03-auto-release-notes.sh` 后备使用 `commit +list --format json` |
|
||||
| 10:40 | 缺少 CLI 安装指引 | 新建 `scripts/setup-local-cli.sh` 一键编译脚本 |
|
||||
|
||||
---
|
||||
|
||||
## 统计汇总
|
||||
|
||||
| 维度 | 数据 |
|
||||
|------|------|
|
||||
| 总工作时长 | 约 10 小时 |
|
||||
| 交付文件数 | 47 个(含交接文档) |
|
||||
| 代码行数 | ~5,800 行 |
|
||||
| 修复迭代次数 | 14 次 |
|
||||
| 最终验证通过率 | 100% |
|
||||
| Git 提交 | 6 次(`378d8f1` `b0d46b4` `7b43711` `393a090` `c0c7580` `f44471d` `92f65a9` `74f6aea`) |
|
||||
| GitLab Issue | 2 个(#19, #20) |
|
||||
|
||||
---
|
||||
|
||||
## 技术债务 / 待改进
|
||||
|
||||
| 项目 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| CLI 版本一致化 | ✅ 已优化 | 新增 `scripts/setup-local-cli.sh` 一键编译+设置 |
|
||||
| Git Log 依赖 | ✅ 已优化 | `release-analyze.js` 支持 API JSON 后备,无需 git 仓库 |
|
||||
| HTML 可视化 | ⏳ 可接受 | `md-to-html.js` 对当前报告已够用 |
|
||||
| 测试覆盖 | ⏳ 待补充 | 已做端到端验证,缺自动化单元测试 |
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,92 @@
|
|||
# 项目汇报演示脚本
|
||||
|
||||
> 配合 `doc/汇报PPT.pptx` 使用
|
||||
|
||||
---
|
||||
|
||||
## 演示流程(总时长约 10 分钟)
|
||||
|
||||
### 1. 开场 + 背景(1 分钟)
|
||||
|
||||
**讲述:** "大家好,我们团队完成了 GitLink 智能化能力提升项目。gitlink-cli 是 GitLink 的命令行工具,我们围绕它做了四大块工作:CLI 扩展、AI Agent Skills、端到端工作流、科研辅助。"
|
||||
|
||||
### 2. 子任务一:CLI 能力(1 分钟)
|
||||
|
||||
**讲述:** "我们新增了 23 个 Shortcut 命令组、181+ 个子命令。关键新增包括 Webhook 投递监控、Wiki 管理、代码片段管理、REPL 交互模式。同时实现了 4 个平台的包管理(npm/brew/scoop/choco)。"
|
||||
|
||||
### 3. 子任务二:Skills(1 分钟)
|
||||
|
||||
**讲述:** "我们开发了 11 个 Claude Code Skills,涵盖 Issue 自动分拣、代码深度审查、项目健康度报告、自动 Release Notes、合规检查等场景。全部在 Claude Code 端到端验证通过。"
|
||||
|
||||
### 4. 子任务三:社区运营自动化(2 分钟)— ⭐ 现场演示
|
||||
|
||||
**操作:**
|
||||
```bash
|
||||
cd e:/gitlink-cli/gitlink-cli
|
||||
DRY_RUN=true bash skills/gitlink-community-ops/scripts/community-ops-full.sh z2_cc gitlink-cli
|
||||
```
|
||||
|
||||
**讲述:** "这条命令一键执行三阶段流水线:Issue 自动分拣 → 社区周报生成 → Release Notes 发布。默认 dry-run 模式,不会修改实际数据。"
|
||||
|
||||
**展示输出重点:**
|
||||
- Phase 1:Issue 列表和分类建议
|
||||
- Phase 2:生成的周报文件 `_output/weekly-health-report.md`
|
||||
- Phase 3:生成的 Release Notes
|
||||
|
||||
**展示 HTML 报告:**
|
||||
```bash
|
||||
explorer "skills/gitlink-community-ops/scripts/_output/weekly-health-report.html"
|
||||
```
|
||||
|
||||
### 5. 子任务四:科研洞察(2 分钟)— ⭐ 现场演示
|
||||
|
||||
**操作:**
|
||||
```bash
|
||||
bash skills/gitlink-research-insight/scripts/research-insight-full.sh z2_cc gitlink-cli
|
||||
```
|
||||
|
||||
**讲述:** "科研洞察模块采集仓库的 8 维数据,包括仓库信息、主题标签、语言分布、贡献者、提交历史、Issue、PR、协作者,然后从成熟度、文档质量、贡献模式、社区健康度四个维度评分。"
|
||||
|
||||
**展示输出:**
|
||||
```bash
|
||||
cat skills/gitlink-research-insight/scripts/_output/research-insight-report.md
|
||||
```
|
||||
|
||||
**展示 HTML:**
|
||||
```bash
|
||||
explorer "skills/gitlink-research-insight/scripts/_output/research-insight-report.html"
|
||||
```
|
||||
|
||||
### 6. 子任务四:热点追踪与知识图谱(2 分钟)— ⭐ 现场演示
|
||||
|
||||
**操作:**
|
||||
```bash
|
||||
bash skills/gitlink-research-hotspot/scripts/hotspot-tracker-full.sh "machine learning" "深度学习"
|
||||
```
|
||||
|
||||
**讲述:** "热点追踪模块通过多关键词搜索 GitLink 平台,发现相关仓库并自动构建知识图谱。"
|
||||
|
||||
**展示交互式 HTML 图谱:**
|
||||
```bash
|
||||
explorer "skills/gitlink-research-hotspot/scripts/_output/knowledge-graph-machine learning.html"
|
||||
```
|
||||
|
||||
**讲述:** "图谱支持缩放、拖拽、点击高亮和 SVG 导出。"
|
||||
|
||||
### 7. 总结(1 分钟)
|
||||
|
||||
**讲述:** "项目共交付 47 个文件、约 5,800 行代码。刘焱负责 CLI 底层架构,赵昌负责命令实现和 Skills 开发,曾蔚然负责 DevOps 流水线和审查 Skills。演示完毕,谢谢!"
|
||||
|
||||
---
|
||||
|
||||
## 演示环境检查清单
|
||||
|
||||
- [ ] `go build -o gitlink-cli.exe .`(确保 CLI 已最新编译)
|
||||
- [ ] `gitlink-cli auth status`(确保已登录)
|
||||
- [ ] 网络通畅(需要访问 GitLink API)
|
||||
- [ ] 预留 5 分钟提前运行采集脚本(热点搜索耗时较长)
|
||||
|
||||
## 备用方案
|
||||
|
||||
- 如果热点搜索耗时太长,可预先运行并将结果保存,演示时直接打开 HTML
|
||||
- 如果网络不通,展示 GitHub 上的代码结构和已生成的 HTML 报告文件
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
# 科研辅助场景使用文档
|
||||
|
||||
> 子任务四 · 5 大科研辅助模块完整使用指南
|
||||
> 最后更新:2026-07-04
|
||||
> 全程只读,可放心对任意公开仓库运行
|
||||
|
||||
## 目录
|
||||
|
||||
1. [模块 A:科研项目洞悉](#模块-a科研项目洞悉)
|
||||
2. [模块 B:热点追踪与知识图谱](#模块-b热点追踪与知识图谱)
|
||||
3. [模块 C:科研合规与复现性](#模块-c科研合规与复现性)
|
||||
4. [模块 D:科研协作智能匹配](#模块-d科研协作智能匹配)
|
||||
5. [模块 E:科研进度跟踪与预警](#模块-e科研进度跟踪与预警)
|
||||
6. [委托模式说明](#委托模式说明)
|
||||
|
||||
---
|
||||
|
||||
## 模块 A:科研项目洞悉
|
||||
|
||||
### 功能
|
||||
|
||||
回答"这个科研项目怎么样?"——采集 8 维数据,做 4 维评分,生成 HTML 报告。
|
||||
|
||||
### 使用方法
|
||||
|
||||
```bash
|
||||
# 基本用法
|
||||
cd skills/gitlink-research-insight/scripts
|
||||
DRY_RUN=true bash research-insight-full.sh z2_cc gitlink-cli
|
||||
|
||||
# 实际运行(生成报告)
|
||||
bash research-insight-full.sh z2_cc gitlink-cli
|
||||
```
|
||||
|
||||
### 8 维数据采集
|
||||
|
||||
| 维度 | 数据来源 | 示例 |
|
||||
|------|----------|------|
|
||||
| 仓库信息 | `repo +info` | name/default_branch/size |
|
||||
| 主题标签 | `repo +info` → topics | gitlink/cli/tools |
|
||||
| 语言分布 | `repo +info` → languages | Go 70.1%/JS 22.7%/Shell 6.7% |
|
||||
| 贡献者 | `repo +contributors` | 16 人 |
|
||||
| 提交记录 | `commit +list --limit 100` | 100 条 |
|
||||
| Issue | `issue +list --state all` | 17 个 |
|
||||
| PR | `pr +list --state all` | 2 个 |
|
||||
| 协作者 | `collaborator +list` | 3 人 |
|
||||
|
||||
### 4 维评分模型
|
||||
|
||||
| 维度 | 权重 | 子维度 |
|
||||
|------|------|--------|
|
||||
| 成熟度 | 25% | 年龄(25%)+影响力(25%)+Fork(20%)+维护(30%) |
|
||||
| 文档质量 | 20% | README+LICENSE+CI+CHANGELOG |
|
||||
| 贡献模式 | 30% | 贡献者数量+质量 |
|
||||
| 社区健康度 | 25% | Issue 关闭率+PR 合并率+响应速度 |
|
||||
|
||||
### 验证结果(z2_cc/gitlink-cli)
|
||||
|
||||
| 维度 | 评分 | 说明 |
|
||||
|------|------|------|
|
||||
| 成熟度 | 2.8/10 | 0 个月、0 Star、2 Fork,早期阶段 |
|
||||
| 文档质量 | 5.0/10 | 有 README 缺 LICENSE 和 CI |
|
||||
| 贡献模式 | 6.8/10 | 16 人协作良好 |
|
||||
| 社区健康度 | 4.4/10 | Issue 关闭率低 |
|
||||
| **综合** | **4.8/10** | — |
|
||||
|
||||
### 产物
|
||||
|
||||
`_output/research-insight-report.html` — 交互式 HTML 报告
|
||||
|
||||
---
|
||||
|
||||
## 模块 B:热点追踪与知识图谱
|
||||
|
||||
### 功能
|
||||
|
||||
回答"这个领域有哪些相关项目?"——多关键词搜索 → 实体关系提取 → 交互式知识图谱。
|
||||
|
||||
### 使用方法
|
||||
|
||||
```bash
|
||||
cd skills/gitlink-research-hotspot/scripts
|
||||
bash hotspot-full.sh gitlink CLI
|
||||
# 参数:关键词1 关键词2 ...(支持任意数量)
|
||||
```
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. **多关键词搜索**:搜索"gitlink"+"CLI",去重后 21 个仓库
|
||||
2. **数据丰富**:采集每个仓库的 Star/Fork/语言/主题/组织
|
||||
3. **实体识别**:项目 21 | 主题 4 | 技术栈 2 | 组织 4
|
||||
4. **关系提取**:belongs_to / topic / language / depends_on
|
||||
5. **可视化**:Mermaid 知识图谱 + HTML 交互式渲染
|
||||
|
||||
### 验证结果
|
||||
|
||||
搜索"gitlink"+"CLI"找到 21 个仓库,构建知识图谱:
|
||||
- 4 主题:java / dockerfile / batchfile / swift
|
||||
- 2 技术栈:Go / Java
|
||||
- 4 组织:Gitlink / wangtao / otto / mozilong
|
||||
- 4 种关系:belongs_to / topic / language / depends_on
|
||||
|
||||
### 产物
|
||||
|
||||
- `_output/knowledge-graph-gitlink.html` — 交互式知识图谱(支持缩放/拖拽/点击高亮)
|
||||
- `_output/hotspot-trend-report-gitlink.html` — 热点趋势报告
|
||||
|
||||
---
|
||||
|
||||
## 模块 C:科研合规与复现性
|
||||
|
||||
### 功能
|
||||
|
||||
回答"这个科研仓库别人能跑出一样结果吗?"——三维复现性证据链审查。
|
||||
|
||||
### 使用方法
|
||||
|
||||
```bash
|
||||
cd skills/gitlink-research-compliance
|
||||
# 按 SKILL.md 步骤执行(全程只读)
|
||||
gitlink-cli repo +info --owner ifzhang --repo FairMOT --format json
|
||||
gitlink-cli file +get --owner ifzhang --repo FairMOT --path LICENSE --format json
|
||||
gitlink-cli file +readme --owner ifzhang --repo FairMOT --format json
|
||||
gitlink-cli dataset +list --owner ifzhang --repo FairMOT --format json
|
||||
gitlink-cli release +list --owner ifzhang --repo FairMOT --format json
|
||||
```
|
||||
|
||||
### 三维复现性证据链
|
||||
|
||||
| 维度 | 检查项 | 命令 |
|
||||
|------|--------|------|
|
||||
| ① 数据可用性 | dataset +list / README 数据说明 / 是否随仓托管 | `dataset +list` / `file +readme` |
|
||||
| ② 环境可复现 | requirements.txt / Dockerfile / CI / 依赖锁定 | `file +get --path requirements.txt` |
|
||||
| ③ 结果可验证 | release 权重 / 测试脚本 / 指标一致性 | `release +list` |
|
||||
|
||||
### 委托模式
|
||||
|
||||
委托 `gitlink-compliance` 跑通用合规层(LICENSE/依赖/版权),自研复现性证据链。两者正交不重复。
|
||||
|
||||
### 验证结果(ifzhang/FairMOT)
|
||||
|
||||
| 维度 | 发现 | 等级 |
|
||||
|------|------|------|
|
||||
| 合规 | MIT License · Copyright (c) 2020 YifuZhang | ✅ |
|
||||
| 数据 | README 给下载来源,未随仓托管 | ⚠️ |
|
||||
| 环境 | requirements.txt: yacs/opencv/cython/motmetrics | ✅ |
|
||||
| 结果 | release 0 个,无预训练权重 | ❌ |
|
||||
| 引用 | README 无 cite/BibTeX | ⚠️ |
|
||||
|
||||
### 产物哲学
|
||||
|
||||
输出**证据导向审查记录**(非分数排名)。每个发现先呈现"看到了什么"(文件内容/存在性/原文片段),让科研读者自行判断可复现性。
|
||||
|
||||
---
|
||||
|
||||
## 模块 D:科研协作智能匹配
|
||||
|
||||
### 功能
|
||||
|
||||
回答"谁适合做什么任务?"——从提交历史推断角色,按角色匹配待办 Issue。
|
||||
|
||||
### 使用方法
|
||||
|
||||
```bash
|
||||
cd skills/gitlink-research-matching
|
||||
# 按 SKILL.md 步骤执行
|
||||
gitlink-cli pr +list --owner Gitlink --repo forgeplus --format json > .probe-data/pr.json
|
||||
gitlink-cli issue +list --owner Gitlink --repo forgeplus --format json > .probe-data/issue.json
|
||||
gitlink-cli commit +list --owner Gitlink --repo forgeplus --limit 50 --format json > .probe-data/commit.json
|
||||
```
|
||||
|
||||
### 4 角色推断
|
||||
|
||||
| 角色 | 推断信号 | 示例 |
|
||||
|------|----------|------|
|
||||
| 数据 | PR 标题含"data" / 分支名含"data/" / 文件路径含"data/" | 数据采集/清洗/标注 |
|
||||
| 算法 | PR 标题含"model" / 分支名含"model/" / 文件路径含"model/" | 模型设计/实验 |
|
||||
| 工程 | PR 标题含"fix:" / 分支名含"fix/" / 文件路径含"src/" | 框架/部署/工具链 |
|
||||
| 评测 | PR 标题含"eval" / 分支名含"eval/" / 文件路径含"eval/" | 指标/对比/分析 |
|
||||
|
||||
### 委托模式
|
||||
|
||||
委托 `gitlink-research-insight` 提供贡献者画像数据,自研角色匹配。insight 是数据层,本模块是分析层。
|
||||
|
||||
### 验证结果(Gitlink/forgeplus)
|
||||
|
||||
4246 Issue / 355 PR,3 位活跃贡献者:
|
||||
|
||||
| 贡献者 | PR 数 | 推断角色 | 置信度 | 依据 |
|
||||
|--------|-------|----------|--------|------|
|
||||
| xxq250 | 12 | 工程 | 🟡 中 | 版本发布号+部署说明 |
|
||||
| dtwdtw | 7 | 工程 | 🟢 高 | 全部 fix: 前缀 |
|
||||
| maxjhandsome | 1 | 工程 | 🔴 低 | PR 数不足 |
|
||||
|
||||
**角色覆盖偏科**:3 人全落"工程"角色,无数据/算法/评测。这本身也是匹配算法的正确输出。
|
||||
|
||||
---
|
||||
|
||||
## 模块 E:科研进度跟踪与预警
|
||||
|
||||
### 功能
|
||||
|
||||
回答"项目卡在哪了、有什么风险?"——6 维风险信号检测 + 100 分制评分。
|
||||
|
||||
### 使用方法
|
||||
|
||||
```bash
|
||||
cd skills/gitlink-research-progress
|
||||
# 按 SKILL.md 步骤执行
|
||||
gitlink-cli repo +info --owner Gitlink --repo gitlink-cli --format json
|
||||
gitlink-cli issue +list --owner Gitlink --repo gitlink-cli --state all --format json
|
||||
gitlink-cli commit +list --owner Gitlink --repo gitlink-cli --limit 50 --format json
|
||||
gitlink-cli milestone +list --owner Gitlink --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
### 6 维风险信号
|
||||
|
||||
| 信号 | 检测逻辑 | 风险等级 |
|
||||
|------|----------|----------|
|
||||
| ① 长期停滞 | 未解决 Issue 停留 >60 天 | 🔴 高 |
|
||||
| ② 高优无人 | 高优先级 Issue 无人认领 | 🔴 高 |
|
||||
| ③ 零回复 | 未解决且 0 评论 | 🟡 中 |
|
||||
| ④ 积压恶化 | 近 30 天新增 > 关闭 | 🟡 中 |
|
||||
| ⑤ 提交突变 | 提交活跃度骤降/骤升 | 🟡 中 |
|
||||
| ⑥ 里程碑 DDL | 里程碑临近且未完成 | 🔴 高 |
|
||||
|
||||
### GitLink 数据现实(重要)
|
||||
|
||||
| 数据维度 | GitLink 现状 | 应对 |
|
||||
|----------|-------------|------|
|
||||
| 里程碑 | 几乎无人使用(total_count=0) | 信号⑥设为可选 |
|
||||
| Issue due_date/done_ratio | 普遍为空 | 不依赖 |
|
||||
| Issue status | 中文:新增/关闭/已解决 | 按 status.name 客户端重统计 |
|
||||
| Issue 时间序列 | ✅ 可靠可用 | 信号①②③④数据基础 |
|
||||
| 提交时间序列 | ✅ 可靠可用(秒级时间戳) | 信号⑤数据基础 |
|
||||
|
||||
因此本模块**不以里程碑 DDL 为核心**,而以 Issue+提交时间序列为预警锚点。
|
||||
|
||||
### 委托模式
|
||||
|
||||
委托 `gitlink-project-health` 跑静态健康度(关闭率/活跃度评分),自研动态风险预警。两者正交。
|
||||
|
||||
### 验证结果(Gitlink/gitlink-cli)
|
||||
|
||||
19 Issue(9 新增/10 关闭),关闭率 53%:
|
||||
|
||||
| 信号 | 发现 | 等级 |
|
||||
|------|------|------|
|
||||
| ① 长期停滞 | #2(86天) #5(65天) #6(64天) + 4 个 30-60 天 | 🔴 高 |
|
||||
| ③ 零回复 | #17、#18 未解决且 0 评论 | 🟡 中 |
|
||||
| ⑤ 提交突变 | 近 30 天 15 条 vs 前 30 天 0 条 → 活跃上升期 | 🟢 低 |
|
||||
|
||||
评分:62/100
|
||||
|
||||
---
|
||||
|
||||
## 委托模式说明
|
||||
|
||||
5 个科研模块普遍采用"委托通用 Skill + 自研科研层"模式:
|
||||
|
||||
| 科研模块 | 委托的通用模块 | 自研部分 | 边界 |
|
||||
|----------|---------------|----------|------|
|
||||
| research-compliance | compliance(合规层) | 复现性证据链 | 合规管"合法",本模块管"可复现" |
|
||||
| research-matching | research-insight(画像) | 角色匹配 | insight 管数据,本模块管分析 |
|
||||
| research-progress | project-health(健康度) | 时间线风险预警 | health 管"现状",本模块管"趋势" |
|
||||
|
||||
设计原则:两者正交,不重复。通用 Skill 处理跨领域共性问题,科研层处理科研特有问题。
|
||||
|
||||
— 文档完 —
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
# 端到端工作流说明文档
|
||||
|
||||
> 子任务三 · 5 个端到端自动化场景
|
||||
> 最后更新:2026-07-04
|
||||
|
||||
## 一、总体架构
|
||||
|
||||
### 1.1 设计理念
|
||||
|
||||
5 个端到端场景统一采用"三阶段脚本 + JS 分析引擎 + DevOps 流水线"架构:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ DevOps 流水线层 │
|
||||
│ (6 条建木 YAML · push/PR/定时/手动触发) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 端到端编排层 │
|
||||
│ community-ops / quality-gate / project-bootstrap / │
|
||||
│ multi-repo-coordination / contributor-growth │
|
||||
│ (各 *-full.sh 串联三阶段) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Phase 1 Phase 2 Phase 3 │
|
||||
│ (01-*.sh) (02-*.sh) (03-*.sh) │
|
||||
│ 数据采集 分析处理 执行/输出 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ JS 分析引擎层 │
|
||||
│ health-analyze / dashboard-gen / growth-analyze / │
|
||||
│ skeleton-gen / scoring-engine │
|
||||
│ (10 个 JS 引擎 · 2568 行) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ CLI 命令层 │
|
||||
│ gitlink-cli (24 命令组 · 17,005 行 Go) │
|
||||
│ → GitLink API │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 通用设计原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|------|------|
|
||||
| 三阶段原子化 | 每阶段可单独运行,失败不影响前一阶段成果 |
|
||||
| DRY_RUN 安全 | 默认 DRY_RUN=true,只打印不实际执行写操作 |
|
||||
| CLI 版本检测 | detect-cli.sh 自动检测 CLI 是否安装及版本兼容 |
|
||||
| JSON 中间产物 | 阶段间通过 _output/*.json 传递数据,可调试可复现 |
|
||||
| 优雅降级 | 离线/未认证时写空 JSON,仍生成报告 |
|
||||
|
||||
## 二、场景一:社区运营自动化
|
||||
|
||||
### 2.1 架构图
|
||||
|
||||
```
|
||||
community-ops-full.sh z2_cc gitlink-cli
|
||||
│
|
||||
├── Phase 1: Issue 自动分拣 (01-triage-issues.sh)
|
||||
│ ├── gitlink-cli issue +list --state all
|
||||
│ ├── 按关键词分类(bug/feature/question/docs)
|
||||
│ └── 输出: _output/triage-report.json
|
||||
│
|
||||
├── Phase 2: 社区周报生成 (02-generate-weekly.sh)
|
||||
│ ├── 采集: issues / prs / commits / contributors
|
||||
│ ├── health-analyze.js 4 维评分
|
||||
│ │ ├── Issue 关闭率 (30%)
|
||||
│ │ ├── PR 合并率 (25%)
|
||||
│ │ ├── 贡献者活跃度 (25%)
|
||||
│ │ └── 响应速度 (20%)
|
||||
│ └── 输出: _output/weekly-health-report.html
|
||||
│
|
||||
└── Phase 3: Release Notes (03-generate-release.sh)
|
||||
├── gitlink-cli commit +list --limit 100
|
||||
├── 按 Conventional Commits 分类
|
||||
└── 输出: _output/release-notes-v0.1.0.html
|
||||
```
|
||||
|
||||
### 2.2 关键数据
|
||||
|
||||
| 指标 | 实测值 |
|
||||
|------|--------|
|
||||
| Issue 总数 | 21(17 打开/4 关闭) |
|
||||
| 关闭率 | 19% |
|
||||
| 综合评分 | 3.5/10 |
|
||||
| 提交分类 | 22 新功能 / 15 Bug / 8 文档 |
|
||||
| 贡献者 | 7 人 |
|
||||
|
||||
### 2.3 对应文件
|
||||
|
||||
- `skills/gitlink-community-ops/SKILL.md` — 设计规格
|
||||
- `skills/gitlink-community-ops/scripts/community-ops-full.sh` — 串联脚本
|
||||
- `skills/gitlink-community-ops/scripts/health-analyze.js` — 分析引擎
|
||||
- `.devops/社区运营流水线.yml` — DevOps 配置(定时触发)
|
||||
|
||||
## 三、场景二:代码质量门禁
|
||||
|
||||
### 3.1 架构图
|
||||
|
||||
```
|
||||
quality-gate-full.sh z2_cc gitlink-cli
|
||||
│
|
||||
├── Phase 1: PR 采集 (01-collect-prs.sh)
|
||||
│ ├── gitlink-cli pr +list --state open
|
||||
│ └── 逐个采集详情+变更文件
|
||||
│
|
||||
├── Phase 2: 深度审查 (02-deep-review.sh)
|
||||
│ ├── 委托 pr-deep-review Skill
|
||||
│ ├── Critical / Warning / Suggestion 三级
|
||||
│ └── CodeReview 评分: 100 - Critical×20 - Warning×10 - Suggestion×3
|
||||
│
|
||||
├── Phase 3: CI 检查 (03-ci-check.sh)
|
||||
│ ├── 构建状态
|
||||
│ ├── 测试覆盖率
|
||||
│ └── CI 评分: 全通过=100, 有失败=0(一票否决)
|
||||
│
|
||||
└── Phase 4: 门禁决策 (04-gate-decision.sh)
|
||||
├── 质量总分 = CodeReview×40% + CI×30% + 设计一致性×30%
|
||||
├── ≥70 + CI 通过 → 自动合并
|
||||
└── <70 → 修改建议
|
||||
```
|
||||
|
||||
### 3.2 评分模型
|
||||
|
||||
| 维度 | 权重 | 评分规则 |
|
||||
|------|------|----------|
|
||||
| CodeReview | 40% | 起始 100,Critical -20 / Warning -10 / Suggestion -3 |
|
||||
| CI 状态 | 30% | 全通过=100,有失败=0(一票否决) |
|
||||
| 设计一致性 | 30% | PR 描述与代码变更匹配度 |
|
||||
|
||||
### 3.3 对应文件
|
||||
|
||||
- `skills/gitlink-quality-gate/SKILL.md`
|
||||
- `skills/gitlink-quality-gate/scripts/quality-gate-full.sh`(378 行)
|
||||
- `.devops/代码质量门禁流水线.yml`(PR 触发)
|
||||
|
||||
## 四、场景三:项目一键初始化
|
||||
|
||||
### 4.1 架构图
|
||||
|
||||
```
|
||||
project-bootstrap-full.sh
|
||||
│
|
||||
├── Phase 1: 解析需求 + 创建仓库 (01-parse-and-create-repo.sh)
|
||||
│ ├── AI 从 PROJECT_DESC 提取 name/desc/lang/license
|
||||
│ ├── 输出: _output/project-spec.json
|
||||
│ └── gitlink-cli repo +create
|
||||
│
|
||||
├── Phase 2: 生成项目骨架 (02-generate-skeleton.sh)
|
||||
│ ├── skeleton-gen.js 按语言生成模板
|
||||
│ │ ├── README.md
|
||||
│ │ ├── LICENSE (MIT/Apache/GPL)
|
||||
│ │ ├── .gitignore (Go/Python/JS 差异化)
|
||||
│ │ └── CI 配置 (建木 YAML)
|
||||
│ └── gitlink-cli file +batch 推送
|
||||
│
|
||||
└── Phase 3: Issue + 里程碑 (03-create-issues-milestones.sh)
|
||||
├── gitlink-cli milestone +create v0.1.0
|
||||
├── gitlink-cli label +create (good-first-issue/enhancement/bug)
|
||||
└── gitlink-cli issue +create × 3
|
||||
```
|
||||
|
||||
### 4.2 对应文件
|
||||
|
||||
- `skills/gitlink-project-bootstrap/SKILL.md`
|
||||
- `skills/gitlink-project-bootstrap/scripts/project-bootstrap-full.sh`(601 行)
|
||||
- `skills/gitlink-project-bootstrap/scripts/skeleton-gen.js` — 骨架生成引擎
|
||||
- `.devops/项目初始化流水线.yml`(手动触发)
|
||||
|
||||
## 五、场景四:多仓库协同
|
||||
|
||||
### 5.1 架构图
|
||||
|
||||
```
|
||||
multi-repo-full.sh
|
||||
│ REPOS="z2_cc/gitlink-cli,z2_cc/gitlink-bisync"
|
||||
│
|
||||
├── Phase 1: 统一 Issue 追踪 (01-unified-issue-tracker.sh)
|
||||
│ ├── 遍历 REPOS 采集 open Issue
|
||||
│ ├── 标题相似度匹配 (Jaccard >0.5)
|
||||
│ └── 识别跨仓库关联 Issue
|
||||
│
|
||||
├── Phase 2: PR 看板 (02-pr-dashboard.sh)
|
||||
│ ├── 采集 open/merged PR
|
||||
│ ├── 识别长期未合并 PR (>30天)
|
||||
│ └── dashboard-gen.js 生成看板
|
||||
│
|
||||
└── Phase 3: Release 协调 (03-release-coordination.sh)
|
||||
├── 汇总各仓库最新版本号+发布日期
|
||||
├── 版本对齐分析 (哪个落后)
|
||||
└── 协调发布建议
|
||||
```
|
||||
|
||||
### 5.2 对应文件
|
||||
|
||||
- `skills/gitlink-multi-repo-coordination/SKILL.md`
|
||||
- `skills/gitlink-multi-repo-coordination/scripts/multi-repo-full.sh`(560 行)
|
||||
- `skills/gitlink-multi-repo-coordination/scripts/dashboard-gen.js` — 看板引擎
|
||||
- `.devops/多仓库协同流水线.yml`(手动触发)
|
||||
|
||||
## 六、场景五:贡献者成长体系
|
||||
|
||||
### 6.1 架构图
|
||||
|
||||
```
|
||||
contributor-growth-full.sh z2_cc gitlink-cli
|
||||
│
|
||||
├── Phase 1: 贡献活动追踪 (01-track-activity.sh)
|
||||
│ ├── contributors-stat 代码统计
|
||||
│ ├── commit 近期提交 (按 author 聚合)
|
||||
│ ├── pr 已合并 (按 user 聚合)
|
||||
│ └── issue 已关闭 (按 assignee 聚合)
|
||||
│
|
||||
├── Phase 2: 排行 + 段位 (02-rank-and-tier.sh)
|
||||
│ ├── growth-analyze.js 计算成长分
|
||||
│ │ 成长分 = commits×1 + mergedPR×5 + closedIssue×3 + (首次?+10)
|
||||
│ ├── 排名排序
|
||||
│ └── 段位划分: 🌱新芽(0-9) → 🌿新星(10-29) → 🌳中坚(30-79) → 🏆核心(80+)
|
||||
│
|
||||
└── Phase 3: 自动颁发徽章 (03-award-badges.sh)
|
||||
├── 达段位 → 创建致谢 Issue
|
||||
├── 颁发徽章评论
|
||||
└── 新人发 good-first-issue 引导
|
||||
```
|
||||
|
||||
### 6.2 成长分计算示例
|
||||
|
||||
| 贡献者 | commits | mergedPR | closedIssue | 首次 | 成长分 | 段位 |
|
||||
|--------|---------|----------|-------------|------|--------|------|
|
||||
| alice | 25 | 8 | 4 | 否 | 25+40+12=77 | 🌳 中坚 |
|
||||
| bob | 6 | 2 | 1 | 否 | 6+10+3=19 | 🌿 新星 |
|
||||
| carol | 3 | 1 | 0 | 是 | 3+5+0+10=18 | 🌿 新星 |
|
||||
|
||||
### 6.3 对应文件
|
||||
|
||||
- `skills/gitlink-contributor-growth/SKILL.md`
|
||||
- `skills/gitlink-contributor-growth/scripts/contributor-growth-full.sh`(642 行)
|
||||
- `skills/gitlink-contributor-growth/scripts/growth-analyze.js` — 分析引擎
|
||||
- `.devops/贡献者成长体系流水线.yml`(定时触发)
|
||||
|
||||
## 七、DevOps 流水线总览
|
||||
|
||||
| 流水线 | 触发方式 | 对应场景 | 文件 |
|
||||
|--------|----------|----------|------|
|
||||
| 构建流水线 | push | CLI 编译验证 | 构建流水线.yml |
|
||||
| 代码质量门禁 | PR | 场景二 | 代码质量门禁流水线.yml |
|
||||
| 社区运营 | 定时 | 场景一 | 社区运营流水线.yml |
|
||||
| 项目初始化 | 手动 | 场景三 | 项目初始化流水线.yml |
|
||||
| 多仓库协同 | 手动 | 场景四 | 多仓库协同流水线.yml |
|
||||
| 贡献者成长 | 定时 | 场景五 | 贡献者成长体系流水线.yml |
|
||||
|
||||
所有流水线配置 DRY_RUN 默认安全模式,SSH 密钥用 `((work_together.ssh_key))` 密文引用,可在 GitLink DevOps 平台一键复现。
|
||||
|
||||
— 文档完 —
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,363 @@
|
|||
# gitlink-cli API 全量封装 + 跨平台兼容性设计
|
||||
|
||||
**日期:** 2026-06-01
|
||||
**状态:** 已批准
|
||||
**范围:** 补全 Raw API 封装(对齐 GitLink OpenAPI 约 200 个端点)、提升跨平台兼容性和安装体验
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
gitlink-cli 当前封装了约 90 个 GitLink OpenAPI 端点(18 个快捷方式域),但 API 参考文档记录了约 200 个端点。同时,跨平台体验存在改进空间:终端输出在 Windows 上可能渲染不佳、npm 安装缺少 checksum 验证、缺少常见包管理器支持。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 补全所有 GitLink OpenAPI 端点的快捷方式封装
|
||||
2. 提升跨平台兼容性(终端输出、路径处理、字符宽度)
|
||||
3. 改善安装体验(npm 安装健壮性、包管理器分发、CI 改进)
|
||||
|
||||
## 实现策略
|
||||
|
||||
采用**分层渐进式**方案,按 L1→L2→L3→L4 四层推进,每层在功能分支上逐步提交。
|
||||
|
||||
---
|
||||
|
||||
## L1:补全已有域的缺失操作(~35 个端点)
|
||||
|
||||
### 1.1 Issue 评论 CRUD 补全
|
||||
|
||||
文件:`shortcuts/issue/issue.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `issue +comments` | GET | `/v1/{owner}/{repo}/issues/{index}/journals` | 评论/活动列表 |
|
||||
| `issue +update-comment` | PATCH | `/v1/{owner}/{repo}/issues/{index}/journals/{id}` | 更新评论 |
|
||||
| `issue +delete-comment` | DELETE | `/v1/{owner}/{repo}/issues/{index}/journals/{id}` | 删除评论 |
|
||||
| `issue +reply-comment` | GET | `/v1/{owner}/{repo}/issues/{index}/journals/{id}/children_journals` | 子评论列表 |
|
||||
| `issue +delete` | DELETE | `/v1/{owner}/{repo}/issues/{index}` | 删除 issue |
|
||||
| `issue +batch-update` | PATCH | `/v1/{owner}/{repo}/issues/batch_update` | 批量更新 |
|
||||
| `issue +batch-destroy` | DELETE | `/v1/{owner}/{repo}/issues/batch_destroy` | 批量删除 |
|
||||
|
||||
### 1.2 PR 评论 CRUD 补全
|
||||
|
||||
文件:`shortcuts/pr/pr.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `pr +comments` | GET | `/v1/{owner}/{repo}/pulls/{index}/journals` | 审查评论列表 |
|
||||
| `pr +create-comment` | POST | `/v1/{owner}/{repo}/pulls/{index}/journals` | 创建审查评论 |
|
||||
| `pr +update-comment` | PUT | `/v1/{owner}/{repo}/pulls/{index}/journals/{id}` | 更新审查评论 |
|
||||
| `pr +delete-comment` | DELETE | `/v1/{owner}/{repo}/pulls/{index}/journals/{id}` | 删除审查评论 |
|
||||
| `pr +commits` | GET | `/{owner}/{repo}/pulls/{id}/commits` | PR 提交列表 |
|
||||
| `pr +reopen` | POST | `/v1/{owner}/{repo}/pulls/{index}/reopen` | 重新打开 PR |
|
||||
| `pr +update` | PUT | `/{owner}/{repo}/pulls/{index}` | 更新 PR |
|
||||
|
||||
### 1.3 分支补全
|
||||
|
||||
文件:`shortcuts/branch/branch.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `branch +all` | GET | `/v1/{owner}/{repo}/branches/all` | 所有分支(无分页) |
|
||||
| `branch +default` | PATCH | `/v1/{owner}/{repo}/branches/update_default_branch` | 更新默认分支 |
|
||||
| `branch +restore` | POST | `/v1/{owner}/{repo}/branches/restore` | 恢复已删除分支 |
|
||||
|
||||
### 1.4 Release 补全
|
||||
|
||||
文件:`shortcuts/release/release.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `release +edit` | GET | `/{owner}/{repo}/releases/{id}/edit` | 编辑元数据 |
|
||||
| `release +update` | PUT | `/{owner}/{repo}/releases/{id}` | 更新 release |
|
||||
|
||||
### 1.5 项目补全
|
||||
|
||||
文件:`shortcuts/repo/repo.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `repo +update` | PATCH | `/{owner}/{repo}` | 更新项目设置 |
|
||||
| `repo +about` | GET | `/{owner}/{repo}/about` | 项目主页/关于 |
|
||||
| `repo +menu` | GET | `/{owner}/{repo}/menu_list` | 项目导航列表 |
|
||||
| `repo +units-get` | GET | `/{owner}/{repo}/project_units` | 导航设置 |
|
||||
| `repo +units-set` | POST | `/{owner}/{repo}/project_units` | 更新导航设置 |
|
||||
| `repo +code-stats` | GET | `/v1/{owner}/{repo}/code_stats` | 代码统计 |
|
||||
| `repo +languages` | GET | `/{owner}/{repo}/languages` | 项目语言 |
|
||||
| `repo +contributors` | GET | `/{owner}/{repo}/contributors` | 贡献者列表 |
|
||||
| `repo +contributors-stat` | GET | `/v1/{owner}/{repo}/contributors/stat` | 贡献者代码统计 |
|
||||
| `repo +recommend` | GET | `/projects/recommend` | 推荐项目 |
|
||||
| `repo +star` | POST | `/projects/{id}/praise_tread/like` | 点赞 |
|
||||
| `repo +unstar` | DELETE | `/projects/{id}/praise_tread/unlike` | 取消点赞 |
|
||||
| `repo +watch` | POST | `/watchers/follow` | 关注 |
|
||||
| `repo +unwatch` | DELETE | `/watchers/unfollow` | 取消关注 |
|
||||
| `repo +stargazers` | GET | `/{owner}/{repo}/stargazers` | 点赞者列表 |
|
||||
| `repo +watchers` | GET | `/{owner}/{repo}/watchers` | 关注者列表 |
|
||||
| `repo +transfer` | POST | `/{owner}/{repo}/applied_transfer_projects` | 传输项目 |
|
||||
| `repo +cancel-transfer` | POST | `/{owner}/{repo}/applied_transfer_projects/cancel` | 取消传输 |
|
||||
| `repo +transfer-orgs` | GET | `/{owner}/{repo}/applied_transfer_projects/organizations` | 可传输的组织 |
|
||||
| `repo +invite-link` | GET | `/{owner}/{repo}/project_invite_links/current_link` | 邀请链接 |
|
||||
| `repo +invite-info` | GET | `/{owner}/{repo}/project_invite_links/show_link` | 邀请链接信息 |
|
||||
| `repo +edit-detail` | GET | `/{owner}/{repo}/edit` | 编辑详情 |
|
||||
| `repo +simple` | GET | `/{owner}/{repo}/simple` | 简化详情 |
|
||||
|
||||
### 1.6 用户消息与统计
|
||||
|
||||
文件:`shortcuts/user/user.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `user +update` | PUT | `/users/{owner}` | 更新用户信息 |
|
||||
| `user +messages` | GET | `/users/{owner}/messages` | 消息列表 |
|
||||
| `user +delete-message` | DELETE | `/users/{owner}/messages` | 删除消息 |
|
||||
| `user +create-message` | POST | `/users/{owner}/messages` | 创建消息 |
|
||||
| `user +read-message` | POST | `/users/{owner}/messages/read` | 标记已读 |
|
||||
| `user +msg-settings` | GET | `/users/{owner}/template_message_settings` | 消息设置 |
|
||||
| `user +update-msg-settings` | POST | `/users/{owner}/template_message_settings/update_setting` | 更新消息设置 |
|
||||
| `user +pinned-projects` | GET | `/users/{owner}/is_pinned_projects` | 置顶项目 |
|
||||
| `user +pin-project` | POST | `/users/{owner}/is_pinned_projects/pin` | 置顶 |
|
||||
| `user +reorder-pinned` | PUT | `/users/{owner}/is_pinned_projects/{id}` | 重排置顶 |
|
||||
| `user +activity` | GET | `/users/{owner}/statistics/activity` | 活动统计 |
|
||||
| `user +headmap` | GET | `/users/{owner}/headmaps` | 贡献热力图 |
|
||||
| `user +develop-stats` | GET | `/users/{owner}/statistics/develop` | 开发能力 |
|
||||
| `user +role-stats` | GET | `/users/{owner}/statistics/role` | 角色定位 |
|
||||
| `user +major-stats` | GET | `/users/{owner}/statistics/major` | 专业定位 |
|
||||
| `user +applied-transfers` | GET | `/users/{owner}/applied_transfer_projects` | 待处理传输 |
|
||||
| `user +accept-transfer` | POST | `/users/{owner}/applied_transfer_projects/{id}/accept` | 接受传输 |
|
||||
| `user +refuse-transfer` | POST | `/users/{owner}/applied_transfer_projects/{id}/refuse` | 拒绝传输 |
|
||||
| `user +applied-projects` | GET | `/users/{owner}/applied_projects` | 待处理加入申请 |
|
||||
| `user +accept-join` | POST | `/users/{owner}/applied_projects/{id}/accept` | 接受加入 |
|
||||
| `user +refuse-join` | POST | `/users/{owner}/applied_projects/{id}/refuse` | 拒绝加入 |
|
||||
| `user +feedback` | POST | `/v1/{owner}/feedbacks` | 反馈建议 |
|
||||
|
||||
### 1.7 组织补全
|
||||
|
||||
文件:`shortcuts/org/org.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `org +add-team-projects` | POST | `/organizations/{org}/teams/{id}/team_projects/create_all` | 添加所有项目到团队 |
|
||||
| `org +remove-team-projects` | DELETE | `/organizations/{org}/teams/{id}/team_projects/destroy_all` | 从团队删除所有项目 |
|
||||
|
||||
### 1.8 文件操作补全
|
||||
|
||||
文件:`shortcuts/file/file.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `file +batch` | POST | `/v1/{owner}/{repo}/contents/batch` | 批量提交文件 |
|
||||
| `file +entries` | GET | `/{owner}/{repo}/entries` | 目录列表 |
|
||||
| `file +sub-entries` | GET | `/{owner}/{repo}/sub_entries` | 子目录/文件 |
|
||||
| `file +replace` | POST | `/{owner}/{repo}/replace_file` | 替换文件 |
|
||||
|
||||
### 1.9 辅助工具
|
||||
|
||||
文件:`shortcuts/util/util.go`(新建)
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `util +licenses` | GET | `/licenses` | 许可证模板列表 |
|
||||
| `util +ignores` | GET | `/ignores` | 忽略文件模板列表 |
|
||||
| `util +platform-msg-settings` | GET | `/template_message_settings` | 平台消息设置 |
|
||||
|
||||
---
|
||||
|
||||
## L2:新增领域快捷方式(~19 个端点)
|
||||
|
||||
### 2.1 数据集
|
||||
|
||||
新建文件:`shortcuts/dataset/dataset.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `dataset +create` | POST | `/v1/{owner}/{repo}/dataset` | 创建数据集 |
|
||||
| `dataset +update` | PUT | `/v1/{owner}/{repo}/dataset` | 更新数据集 |
|
||||
| `dataset +view` | GET | `/v1/{owner}/{repo}/dataset` | 数据集详情 + 附件 |
|
||||
| `dataset +list` | GET | `/v1/project_datasets` | 全局数据集列表 |
|
||||
|
||||
### 2.2 模板
|
||||
|
||||
新建文件:`shortcuts/template/template.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `template +list` | GET | `/v1/{owner}/project_templates` | 模板列表 |
|
||||
| `template +create` | POST | `/v1/{owner}/project_templates` | 创建模板 |
|
||||
| `template +view` | GET | `/v1/{owner}/{repo}/project_templates/{id}` | 模板详情 |
|
||||
| `template +update` | PUT | `/v1/{owner}/project_templates/{id}` | 更新模板 |
|
||||
| `template +delete` | DELETE | `/v1/{owner}/project_templates/{id}` | 删除模板 |
|
||||
|
||||
### 2.3 CI 流水线补全
|
||||
|
||||
文件:`shortcuts/ci/ci.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `ci +pipelines` | GET | `/pm/pipelines` | 流水线模板列表 |
|
||||
| `ci +run-pipeline` | POST | `/v1/{owner}/{repo}/actions/runs` | 运行流水线 |
|
||||
| `ci +runs` | GET | `/v1/{owner}/{repo}/actions/runs` | 运行历史 |
|
||||
| `ci +save-yaml` | POST | `/v1/{owner}/{repo}/pipelines/save_yaml` | 保存 YAML |
|
||||
| `ci +pipeline-detail` | GET | `/v1/{owner}/{repo}/pipelines/{id}` | 流水线详情 |
|
||||
| `ci +delete-pipeline` | DELETE | `/v1/{owner}/{repo}/pipelines/{id}` | 删除流水线 |
|
||||
| `ci +disable` | POST | `/v1/{owner}/{repo}/actions/disable` | 禁用流水线 |
|
||||
| `ci +enable` | POST | `/v1/{owner}/{repo}/actions/enable` | 启用流水线 |
|
||||
| `ci +run-log` | POST | `/v1/{owner}/{repo}/actions/runs/{run_id}/jobs/0` | 运行日志 |
|
||||
| `ci +run-results` | GET | `/v1/{owner}/{repo}/pipelines/run_results` | 运行报告 |
|
||||
|
||||
### 2.4 Wiki 导入/导出
|
||||
|
||||
文件:`shortcuts/wiki/wiki.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `wiki +export` | GET | `/wikiExport/wikiExport-wrapper` | 导出 Wiki |
|
||||
| `wiki +import` | POST | `/wikiExport/uploadWiki/{owner}/{repoName}/{projectId}` | 导入 Wiki |
|
||||
|
||||
### 2.5 附件
|
||||
|
||||
新建文件:`shortcuts/attachment/attachment.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `attachment +upload` | POST | `/attachments` | 上传文件 |
|
||||
| `attachment +delete` | DELETE | `/attachments/{uuid}` | 删除附件 |
|
||||
|
||||
---
|
||||
|
||||
## L3:用户/平台管理(~30 个端点)
|
||||
|
||||
### 3.1 用户个人设置
|
||||
|
||||
文件:`shortcuts/user/user.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `user +change-password` | POST | `/accounts/change_password` | 修改密码 |
|
||||
| `user +update-email` | PATCH | `/v1/{owner}/update_email` | 更改邮箱 |
|
||||
| `user +update-phone` | PATCH | `/v1/{owner}/update_phone` | 更改手机 |
|
||||
| `user +send-verify` | GET | `/v1/{owner}/send_email_vefify_code` | 发送验证码 |
|
||||
| `user +check-verify` | POST | `/v1/{owner}/check_email_verify_code` | 验证验证码 |
|
||||
| `user +check-password` | POST | `/v1/{owner}/check_password` | 验证密码 |
|
||||
| `user +check-email` | POST | `/v1/{owner}/check_email` | 验证邮箱 |
|
||||
| `user +can-delete` | POST | `/v1/{owner}/check_user_can_delete` | 检查可否删除 |
|
||||
| `user +delete` | DELETE | `/v1/{owner}` | 停用用户 |
|
||||
|
||||
### 3.2 OAuth2 认证
|
||||
|
||||
文件:`cmd/auth/auth.go`、`internal/auth/login.go`
|
||||
|
||||
| 功能 | 方法 | 端点 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `auth login --oauth` | POST | `/oauth/token` (password grant) | OAuth2 密码模式登录 |
|
||||
| `auth refresh` | POST | `/oauth/token` (refresh_token grant) | 刷新令牌 |
|
||||
|
||||
认证流程:`--oauth` 标志触发 OAuth2 路径而非 cookie 路径。获取到的 access_token 存储到 keyring,后续请求使用 token 认证。
|
||||
|
||||
### 3.3 项目管理增强
|
||||
|
||||
文件:`shortcuts/repo/repo.go`
|
||||
|
||||
| 快捷方式 | 方法 | 端点 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| `repo +join` | POST | `/applied_projects` | 申请加入项目 |
|
||||
| `repo +quit` | POST | `/{owner}/{repo}/quit` | 退出项目 |
|
||||
| `repo +migrate` | POST | `/projects/migrate` | 创建镜像项目 |
|
||||
| `repo +sync-mirror` | POST | `/repositories/{id}/sync_mirror` | 同步镜像 |
|
||||
| `repo +topics` | GET | `/v1/project_topics` | 项目主题列表 |
|
||||
| `repo +create-topic` | POST | `/v1/project_topics` | 创建主题 |
|
||||
| `repo +delete-topic` | DELETE | `/v1/project_topics/{id}` | 删除主题 |
|
||||
|
||||
---
|
||||
|
||||
## L4:跨平台兼容性和安装体验
|
||||
|
||||
### 4.1 终端输出兼容性
|
||||
|
||||
**改动文件:** `internal/output/formatter.go`、`cmd/root.go`
|
||||
|
||||
**颜色处理:**
|
||||
- 添加 `--color` / `--no-color` 全局标志
|
||||
- 自动检测终端颜色支持:检查 `NO_COLOR` 环境变量、`COLORTERM`、Windows Terminal (`WT_SESSION`)、`ConEmuANSI`
|
||||
- 不支持颜色时自动降级为纯文本输出
|
||||
|
||||
**表格渲染:**
|
||||
- 使用 `golang.org/x/term`(已有依赖)获取终端宽度
|
||||
- 列过多时自动截断长值,保证不换行
|
||||
- 中文字符宽度计算:检测 rune 是否在 CJK 范围 `一-鿿`,是则宽度为 2
|
||||
|
||||
### 4.2 npm 安装体验
|
||||
|
||||
**改动文件:** `npm/scripts/install.js`
|
||||
|
||||
- 下载后验证 SHA256 checksum(从 GitHub Release 的 checksums.txt 获取)
|
||||
- 更好的错误分类和消息:
|
||||
- `ENOTFOUND` → "DNS 解析失败,请检查网络连接"
|
||||
- `ECONNREFUSED` → "连接被拒绝,请检查代理设置"
|
||||
- `ETIMEDOUT` → "连接超时,请尝试设置 HTTP_PROXY"
|
||||
- `EACCES` → "权限不足,请使用管理员权限运行或检查安装目录"
|
||||
- 离线模式:检查 `npm_config_cache` 中是否已有匹配版本
|
||||
- `--verbose` 日志支持
|
||||
|
||||
**改动文件:** `npm/bin/cli.js`
|
||||
|
||||
- 启动时检查新版本(可配置关闭),提示 "New version X.Y.Z available"
|
||||
- 二进制不存在时输出安装指引而非报错
|
||||
|
||||
### 4.3 包管理器分发
|
||||
|
||||
新增以下配置文件:
|
||||
|
||||
| 平台 | 文件路径 | 说明 |
|
||||
|------|----------|------|
|
||||
| Homebrew | `formula/gitlink-cli.rb` | Ruby formula,从 GitHub Release 下载 |
|
||||
| Scoop | `bucket/gitlink-cli.json` | JSON manifest |
|
||||
| Chocolatey | `choco/gitlink-cli.nuspec` + `choco/tools/chocolateyinstall.ps1` | NuGet 包 |
|
||||
|
||||
每个配置文件中的版本号和 SHA256 由 release workflow 自动更新。
|
||||
|
||||
### 4.4 GitHub Actions CI 改进
|
||||
|
||||
**新增:** `.github/workflows/ci.yml`
|
||||
- 触发:PR 和 push 到 master
|
||||
- 步骤:`go test ./...`、`go vet`、`golangci-lint run`
|
||||
|
||||
**改进:** `.github/workflows/release.yml`
|
||||
- 添加 SHA256 checksum 文件上传到 Release
|
||||
- 自动更新 Homebrew formula / Scoop manifest / Chocolatey nuspec 中的版本和 hash
|
||||
|
||||
### 4.5 构建优化
|
||||
|
||||
**改动文件:** `Makefile`
|
||||
|
||||
- 确保 `CGO_ENABLED=0`(已有)
|
||||
- 添加 `-ldflags "-s -w"` 减小二进制体积(已有 `-s -w` 在部分目标)
|
||||
- 统一所有构建目标使用相同的 ldflags
|
||||
|
||||
---
|
||||
|
||||
## 架构约束
|
||||
|
||||
1. **所有新快捷方式遵循现有声明式框架**:使用 `common.Shortcut`、`common.Flag`、`common.RuntimeContext`
|
||||
2. **不引入新的 Go 依赖**(颜色检测、终端宽度、CJK 宽度均用标准库或已有依赖实现)
|
||||
3. **每个新快捷方式组注册到 `shortcuts/register.go`**
|
||||
4. **测试覆盖**:每个新快捷方式的非平凡逻辑都需要测试(使用 `httptest` 模拟服务器)
|
||||
5. **错误处理**:遵循现有 `client.go` 的错误模式(检查 HTTP 200 + `status` 字段)
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 不修改现有快捷方式的行为(只添加新的)
|
||||
- 不添加交互式向导模式
|
||||
- 不实现图形界面
|
||||
- 不添加国际化(保持中文/英文双语 README)
|
||||
|
||||
---
|
||||
|
||||
## 预估工作量
|
||||
|
||||
| 层 | 新增快捷方式数 | 新增文件 | 预估工作量 |
|
||||
|-----|--------------|---------|-----------|
|
||||
| L1 | ~70 | 1 (`shortcuts/util/`) | 大 |
|
||||
| L2 | ~19 | 3 (`dataset/`, `template/`, `attachment/`) | 中 |
|
||||
| L3 | ~16 | 0 | 中 |
|
||||
| L4 | N/A | ~5 (包管理器配置 + CI) | 中 |
|
||||
| **合计** | ~105 个操作 | ~9 个新文件/目录 | — |
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
# gitlink-cli 交互式 REPL 命令面板设计
|
||||
|
||||
## 概述
|
||||
|
||||
为 gitlink-cli 添加交互式 REPL 模式。用户运行 `gitlink` 或 `gitlink interactive` 进入持续会话,输入 `/` 弹出两级分组的命令面板,支持模糊搜索和方向键选择;选中命令后弹出表单式参数填充界面;提交后内联显示执行结果。
|
||||
|
||||
## 需求总结
|
||||
|
||||
| 需求 | 决策 |
|
||||
|---|---|
|
||||
| 进入方式 | REPL 模式 — `gitlink interactive` 或 `gitlink i` 进入持续会话 |
|
||||
| 命令面板触发 | 输入 `/` 弹出命令面板,输入 `/xxx` 直接进入模糊搜索 |
|
||||
| 命令分组 | 两级分组:先选命令组(issue/pr/repo...),再选子命令(+list/+create...) |
|
||||
| 参数填充 | 表单式 — Tab 切换字段,必填参数 `*` 标记,校验不通过红色提示 |
|
||||
| 结果展示 | 内联显示在 REPL 中,执行完回到输入提示符 |
|
||||
| 退出 | 输入 `exit`/`quit` 或 Ctrl+D |
|
||||
|
||||
## 技术选型
|
||||
|
||||
**Charm 生态(bubbletea + bubbles + huh)**
|
||||
|
||||
| 库 | 版本约束 | 职责 |
|
||||
|---|---|---|
|
||||
| `github.com/charmbracelet/bubbletea` | latest | TUI 框架,Elm 架构状态机 |
|
||||
| `github.com/charmbracelet/bubbles` | latest | 列表、输入框、搜索过滤等内置组件 |
|
||||
| `github.com/charmbracelet/huh` | latest | 表单系统:字段校验、Tab 切换、分组表单 |
|
||||
|
||||
选择理由:Go TUI 事实标准(bubbletea 27k+ stars),三个库同属 Charm 生态天然集成,huh 原生支持表单校验,Windows Terminal 兼容性好。
|
||||
|
||||
## 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ REPL 主循环 │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ gitlink (owner/repo)> █ │ │
|
||||
│ │ │ │
|
||||
│ │ 输入文本 → 判断是否以 / 开头 │ │
|
||||
│ │ ├── / → 进入命令面板(Level 1: 命令组) │ │
|
||||
│ │ ├── /xx → 进入命令面板(模糊搜索) │ │
|
||||
│ │ ├── 直接输入 → 当作快捷命令执行 │ │
|
||||
│ │ └── exit/quit → 退出 REPL │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────── 命令面板 ──────┐ ┌─────── 表单 ──────┐│
|
||||
│ │ 两级分组 + 模糊搜索 │ │ 参数填充 + 校验 ││
|
||||
│ │ bubbles/list 组件 │ │ huh Form 组件 ││
|
||||
│ └────────────────────────┘ └───────────────────┘│
|
||||
│ │
|
||||
│ ┌─────── 命令执行 ──────┐ │
|
||||
│ │ 复用现有 Shortcut.Run │ │
|
||||
│ │ 结果内联显示在 REPL 中 │ │
|
||||
│ └────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**核心原则**:纯增量新增,不修改现有命令逻辑。仅对 `cmd/root.go` 新增 1 行注册,对 `shortcuts/register.go` 新增 1 个导出函数。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
gitlink-cl/
|
||||
├── cmd/
|
||||
│ ├── interactive/ ← 新增包
|
||||
│ │ ├── interactive.go ← Cobra 命令注册 + 启动 bubbletea
|
||||
│ │ ├── repl.go ← REPL 主循环 (bubbletea Model)
|
||||
│ │ ├── palette.go ← 命令面板 (两级分组 + 模糊搜索)
|
||||
│ │ ├── form.go ← 参数表单 (huh Form 包装)
|
||||
│ │ └── executor.go ← 命令执行器 (调用 Shortcut.Run)
|
||||
│ ├── root.go ← 新增 1 行: AddCommand(interactive.NewInteractiveCmd())
|
||||
│ └── ...
|
||||
├── shortcuts/
|
||||
│ ├── register.go ← 新增导出函数: GetAllShortcuts()
|
||||
│ └── ...
|
||||
└── go.mod ← 新增 bubbletea/bubbles/huh 依赖
|
||||
```
|
||||
|
||||
## 各模块详细设计
|
||||
|
||||
### 1. interactive.go — 入口
|
||||
|
||||
注册为 cobra 子命令:
|
||||
|
||||
```
|
||||
gitlink interactive # 完整命令
|
||||
gitlink i # 短别名
|
||||
```
|
||||
|
||||
启动时:
|
||||
1. 解析全局 flags(owner、repo、format、debug)
|
||||
2. 调用 `shortcuts.GetAllShortcuts()` 获取命令元数据
|
||||
3. 创建 bubbletea Program 并运行 REPL Model
|
||||
|
||||
### 2. repl.go — REPL 主循环
|
||||
|
||||
基于 bubbletea 的 Elm 架构(Model-View-Update)。
|
||||
|
||||
**状态定义**:
|
||||
|
||||
```go
|
||||
type replState int
|
||||
|
||||
const (
|
||||
stateInput replState = iota // 等待用户输入
|
||||
statePalette // 命令面板
|
||||
stateForm // 参数表单
|
||||
stateExecuting // 命令执行中
|
||||
)
|
||||
|
||||
type replModel struct {
|
||||
state replState
|
||||
input textinput.Model // 底部输入框
|
||||
palette paletteModel // 命令面板子组件
|
||||
form formModel // 参数表单子组件
|
||||
executor *Executor // 命令执行器
|
||||
shortcuts map[string][]*common.Shortcut // 命令元数据
|
||||
output string // 上一次命令输出
|
||||
width int
|
||||
height int
|
||||
}
|
||||
```
|
||||
|
||||
**状态转换**:
|
||||
|
||||
| 当前状态 | 触发 | 目标状态 |
|
||||
|---|---|---|
|
||||
| stateInput | 输入 `/` 或 `/xxx` | statePalette |
|
||||
| stateInput | 输入命令文本(如 `issue +list`) | stateExecuting |
|
||||
| stateInput | 输入 `exit`/`quit`/Ctrl+D | 退出程序 |
|
||||
| statePalette | 选中命令(无参数) | stateExecuting |
|
||||
| statePalette | 选中命令(有参数) | stateForm |
|
||||
| statePalette | Esc | stateInput |
|
||||
| stateForm | 提交(校验通过) | stateExecuting |
|
||||
| stateForm | Esc | statePalette |
|
||||
| stateExecuting | 执行完成 | stateInput |
|
||||
|
||||
**直接命令执行**:
|
||||
|
||||
用户在输入框直接输入 `issue +list --state open` 时:
|
||||
1. 解析为 group=`issue`、command=`+list`、flags=`--state open`
|
||||
2. 查找匹配的 Shortcut
|
||||
3. 构建 flag map,直接执行
|
||||
4. 如果参数不完整,进入 stateForm 补充必填参数
|
||||
|
||||
### 3. palette.go — 命令面板
|
||||
|
||||
基于 `bubbles/list` 组件。
|
||||
|
||||
**两级分组实现**:
|
||||
|
||||
```go
|
||||
type paletteLevel int
|
||||
|
||||
const (
|
||||
levelGroup paletteLevel = iota // 选择命令组
|
||||
levelCommand // 选择子命令
|
||||
)
|
||||
|
||||
type paletteModel struct {
|
||||
list list.Model // bubbles/list 组件
|
||||
level paletteLevel // 当前层级
|
||||
groups []string // 所有命令组名
|
||||
selected string // Level1 选中的组名
|
||||
shortcuts map[string][]*common.Shortcut
|
||||
}
|
||||
```
|
||||
|
||||
**Level 1(命令组列表)**:
|
||||
- 每个 item 显示:组名 + 描述
|
||||
- 支持模糊搜索过滤
|
||||
|
||||
**Level 2(子命令列表)**:
|
||||
- 标题显示当前组名(如 "issue")
|
||||
- 每个 item 显示:命令名(+list)+ 描述
|
||||
- 支持模糊搜索过滤
|
||||
- Esc 返回 Level 1
|
||||
|
||||
**模糊搜索**:使用 bubbles/list 内置的 Filter,按关键词匹配 item 的标题和描述。
|
||||
|
||||
### 4. form.go — 参数表单
|
||||
|
||||
基于 `huh` 库动态生成表单。
|
||||
|
||||
```go
|
||||
type formModel struct {
|
||||
form *huh.Form
|
||||
shortcut *common.Shortcut
|
||||
group string
|
||||
values map[string]string // 用户填写的参数值
|
||||
}
|
||||
```
|
||||
|
||||
**表单生成逻辑**:
|
||||
|
||||
```
|
||||
对于选中的 Shortcut:
|
||||
1. 遍历 s.Flags
|
||||
2. 每个 Flag 生成一个 huh.Field:
|
||||
- Required=true → huh.NewInput().Title("参数名 *").Validate(非空校验)
|
||||
- Required=false → huh.NewInput().Title("参数名")
|
||||
- Bool=true → huh.NewConfirm().Title("参数名")
|
||||
- Default!="" → 设置默认值
|
||||
3. 按 huh.NewGroup(fields...).Title("group +command") 组织
|
||||
4. 创建 huh.NewForm(group)
|
||||
```
|
||||
|
||||
**参数校验**:
|
||||
- 必填参数:提交时检查非空,为空则显示 "XX 为必填参数,不能为空"
|
||||
- 提交成功后返回 `values` map 传递给执行器
|
||||
|
||||
### 5. executor.go — 命令执行器
|
||||
|
||||
桥接层:将表单收集的参数转换为 RuntimeContext 调用现有 Shortcut.Run。
|
||||
|
||||
```go
|
||||
type Executor struct {
|
||||
owner string
|
||||
repo string
|
||||
format string
|
||||
client *client.Client
|
||||
}
|
||||
|
||||
func (e *Executor) Execute(s *common.Shortcut, args map[string]string) (string, error) {
|
||||
// 1. 创建 RuntimeContext
|
||||
ctx, err := common.NewRuntimeContext(args)
|
||||
// 2. 捕获 stdout(重定向 fmt.Print 输出)
|
||||
// 3. 调用 s.Run(ctx)
|
||||
// 4. 返回捕获的输出字符串
|
||||
}
|
||||
```
|
||||
|
||||
**输出捕获**:
|
||||
- 执行命令时将 `os.Stdout` 临时重定向到 `bytes.Buffer`
|
||||
- 执行完成后恢复
|
||||
- 将 Buffer 内容作为字符串返回给 REPL 显示
|
||||
|
||||
### 6. 对现有代码的修改
|
||||
|
||||
**cmd/root.go**(新增 1 行):
|
||||
```go
|
||||
rootCmd.AddCommand(interactive.NewInteractiveCmd())
|
||||
```
|
||||
|
||||
**shortcuts/register.go**(新增 1 个导出函数):
|
||||
```go
|
||||
// GetAllShortcuts 返回所有命令组的元数据映射,供交互模式使用
|
||||
func GetAllShortcuts() map[string][]*common.Shortcut {
|
||||
// 返回现有的 allShortcuts 映射
|
||||
}
|
||||
```
|
||||
|
||||
## UI 视觉规范
|
||||
|
||||
### REPL 提示符
|
||||
|
||||
```
|
||||
gitlink (owner/repo)> █ ← 在 git 仓库中
|
||||
gitlink> █ ← 不在 git 仓库中
|
||||
```
|
||||
|
||||
### 命令面板 — Level 1
|
||||
|
||||
```
|
||||
╭─────────────────────────────────────────╮
|
||||
│ 搜索: iss█ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ❯ issue Issue 管理 │
|
||||
│ snippet 代码片段管理 │
|
||||
│ │
|
||||
│ │
|
||||
│ ↑↓ 选择 Enter 确认 Esc 返回 │
|
||||
╰─────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
### 命令面板 — Level 2
|
||||
|
||||
```
|
||||
╭─ issue ─────────────────────────────────╮
|
||||
│ 搜索: █ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ❯ +list 列出 issue │
|
||||
│ +create 创建 issue │
|
||||
│ +view 查看 issue 详情 │
|
||||
│ +close 关闭 issue │
|
||||
│ +update 更新 issue │
|
||||
│ +comment 评论 issue │
|
||||
│ │
|
||||
│ ↑↓ 选择 Enter 确认 Esc 返回上级 │
|
||||
╰─────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
### 参数表单
|
||||
|
||||
```
|
||||
╭─ issue +create ─────────────────────────╮
|
||||
│ │
|
||||
│ Title * [我的 issue 标题 ] │
|
||||
│ Body [描述内容... ] │
|
||||
│ Labels [bug, enhancement ] │
|
||||
│ Assignee [ ] │
|
||||
│ │
|
||||
│ * 为必填参数 │
|
||||
│ │
|
||||
│ Tab 下一字段 Enter 执行 Esc 取消 │
|
||||
╰─────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
### 命令执行结果
|
||||
|
||||
```
|
||||
✓ 执行: issue +create --title "我的 issue 标题"
|
||||
|
||||
#123 我的 issue 标题
|
||||
状态: 开启
|
||||
作者: username
|
||||
创建时间: 2026-06-08 15:30
|
||||
|
||||
gitlink (owner/repo)> █
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
```
|
||||
╭─ 错误 ──────────────────────────────────╮
|
||||
│ ✗ 参数不完整 │
|
||||
│ │
|
||||
│ --title 为必填参数,不能为空 │
|
||||
│ │
|
||||
│ 按 Enter 返回表单重新填写 │
|
||||
╰─────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|---|---|
|
||||
| 未登录就执行命令 | 显示 "请先登录:gitlink auth login" 并提示如何操作 |
|
||||
| 命令执行 API 错误 | 内联显示错误信息(红色),回到输入提示符 |
|
||||
| 表单必填参数为空 | 字段边框变红,底部显示 "XX 为必填参数" |
|
||||
| 网络错误 | 显示 "网络错误: ...",回到输入提示符 |
|
||||
| 无效命令 | 显示 "未知命令: xxx,输入 / 查看所有命令" |
|
||||
|
||||
## 测试策略
|
||||
|
||||
1. **单元测试**:executor 的参数转换和输出捕获
|
||||
2. **集成测试**:palette 的模糊搜索过滤、form 的校验逻辑
|
||||
3. **手动测试**:完整 REPL 交互流程
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 命令历史记录(上下箭头翻历史)— 可作为后续增强
|
||||
- 命令别名/快捷方式
|
||||
- 多选批量操作
|
||||
- 输出结果分页(使用 less)
|
||||
- 语法高亮
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
class GitlinkCli < Formula
|
||||
desc "CLI tool for GitLink platform"
|
||||
homepage "https://www.gitlink.org.cn/Gitlink/gitlink-cli"
|
||||
version "0.1.13"
|
||||
|
||||
on_macos do
|
||||
on_arm do
|
||||
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_darwin_arm64.tar.gz"
|
||||
sha256 "PLACEHOLDER_SHA256"
|
||||
end
|
||||
on_intel do
|
||||
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_darwin_amd64.tar.gz"
|
||||
sha256 "PLACEHOLDER_SHA256"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
on_arm do
|
||||
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_linux_arm64.tar.gz"
|
||||
sha256 "PLACEHOLDER_SHA256"
|
||||
end
|
||||
on_intel do
|
||||
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_linux_amd64.tar.gz"
|
||||
sha256 "PLACEHOLDER_SHA256"
|
||||
end
|
||||
end
|
||||
|
||||
def install
|
||||
bin.install "gitlink-cli"
|
||||
end
|
||||
|
||||
test do
|
||||
system "#{bin}/gitlink-cli", "version"
|
||||
end
|
||||
end
|
||||
26
go.mod
26
go.mod
|
|
@ -1,8 +1,11 @@
|
|||
module github.com/gitlink-org/gitlink-cli
|
||||
|
||||
go 1.26.1
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/term v0.41.0
|
||||
|
|
@ -10,11 +13,32 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
)
|
||||
|
|
|
|||
58
go.sum
58
go.sum
|
|
@ -1,9 +1,39 @@
|
|||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
|
|
@ -12,12 +42,32 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
|
|
@ -27,13 +77,21 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
|||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
|
|||
|
|
@ -150,3 +150,157 @@ func GetCurrentUser() (map[string]interface{}, error) {
|
|||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// OAuthTokenResult holds the response from an OAuth2 token request.
|
||||
type OAuthTokenResult struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
// LoginOAuth authenticates via OAuth2 password grant and stores the access token.
|
||||
func LoginOAuth(username, password string) (*OAuthTokenResult, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Derive the base URL by stripping the /api suffix
|
||||
baseURL := strings.TrimSuffix(cfg.BaseURL, "/api")
|
||||
|
||||
tokenURL := baseURL + "/oauth/token"
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "password")
|
||||
data.Set("username", username)
|
||||
data.Set("password", password)
|
||||
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("OAuth2 token request failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
return nil, fmt.Errorf("no access_token in OAuth2 response")
|
||||
}
|
||||
|
||||
// Store the access token
|
||||
if err := StoreToken(tokenResp.AccessToken); err != nil {
|
||||
return nil, fmt.Errorf("failed to store token: %w", err)
|
||||
}
|
||||
|
||||
// Get current user info to display login name
|
||||
user, err := GetCurrentUser()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token stored but failed to fetch user info: %w", err)
|
||||
}
|
||||
|
||||
login, _ := user["login"].(string)
|
||||
|
||||
return &OAuthTokenResult{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
TokenType: tokenResp.TokenType,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
Login: login,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshOAuthToken refreshes the current OAuth2 token using a refresh_token grant.
|
||||
// Note: This requires the server to have issued a refresh_token during the initial login.
|
||||
func RefreshOAuthToken() (*OAuthTokenResult, error) {
|
||||
currentToken, err := LoadToken()
|
||||
if err != nil || currentToken == "" {
|
||||
return nil, fmt.Errorf("no stored token found; please login first")
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(cfg.BaseURL, "/api")
|
||||
|
||||
tokenURL := baseURL + "/oauth/token"
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "refresh_token")
|
||||
data.Set("refresh_token", currentToken)
|
||||
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("OAuth2 token refresh failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
return nil, fmt.Errorf("no access_token in OAuth2 response")
|
||||
}
|
||||
|
||||
// Store the new access token
|
||||
if err := StoreToken(tokenResp.AccessToken); err != nil {
|
||||
return nil, fmt.Errorf("failed to store token: %w", err)
|
||||
}
|
||||
|
||||
// Get current user info
|
||||
user, err := GetCurrentUser()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token refreshed but failed to fetch user info: %w", err)
|
||||
}
|
||||
|
||||
login, _ := user["login"].(string)
|
||||
|
||||
return &OAuthTokenResult{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
TokenType: tokenResp.TokenType,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
Login: login,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package auth
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
|
@ -41,8 +42,30 @@ func DeleteToken() error {
|
|||
// File-based fallback
|
||||
|
||||
func credentialPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
||||
return filepath.Join(configDir(), "credentials")
|
||||
}
|
||||
|
||||
func configDir() string {
|
||||
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
return filepath.Join(appData, "gitlink-cli")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "AppData", "Roaming", "gitlink-cli")
|
||||
case "darwin":
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "Library", "Application Support", "gitlink-cli")
|
||||
default:
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "gitlink-cli")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli")
|
||||
}
|
||||
}
|
||||
|
||||
func storeTokenFile(token string) error {
|
||||
|
|
|
|||
|
|
@ -24,9 +24,14 @@ type APIError struct {
|
|||
StatusCode int
|
||||
Code interface{}
|
||||
Message string
|
||||
Method string
|
||||
Path string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Method != "" && e.Path != "" {
|
||||
return fmt.Sprintf("%s %s: [%v] %s", e.Method, e.Path, e.Code, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +107,8 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
StatusCode: resp.StatusCode,
|
||||
Code: resp.StatusCode,
|
||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
|
||||
Method: method,
|
||||
Path: path,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,11 +130,14 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
}
|
||||
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
|
||||
msg, _ := raw["message"].(string)
|
||||
// [P4 改进] 增加 HTTP 状态码到友好提示的映射,替换原来的裸错误信息
|
||||
suggestion := suggestFix(int(statusCode))
|
||||
return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{
|
||||
StatusCode: int(statusCode),
|
||||
Code: int(statusCode),
|
||||
Message: msg,
|
||||
Method: method,
|
||||
Path: path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -174,17 +184,23 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error)
|
|||
return c.Do("DELETE", path, nil, query)
|
||||
}
|
||||
|
||||
// suggestFix 返回 HTTP 状态码对应的友好修复建议(P4 改进)
|
||||
// 当 API 返回 4xx/5xx 时,在错误信息中附加这些提示,帮助用户快速定位问题
|
||||
func suggestFix(code int) string {
|
||||
// HTTP 状态码 → 中文友好提示映射
|
||||
// 注意:这些提示直接展示给终端用户,使用英文以便国际化兼容
|
||||
switch code {
|
||||
case 401:
|
||||
return "请先运行 gitlink-cli auth login 登录"
|
||||
return "Run 'gitlink auth login' to authenticate" // 未认证
|
||||
case 403:
|
||||
return "权限不足,请确认账户权限或联系项目管理员"
|
||||
return "Permission denied. Check your account permissions or contact the project admin" // 权限不足
|
||||
case 404:
|
||||
return "资源不存在,请检查 owner/repo/id 是否正确"
|
||||
return "Resource not found. Verify --owner, --repo, and the resource ID" // 资源不存在
|
||||
case 422:
|
||||
return "参数校验失败,请检查请求参数"
|
||||
return "Validation failed. Check the request parameters" // 参数校验失败
|
||||
case 500:
|
||||
return "Server error. Try again later or contact the platform admin" // 服务端错误
|
||||
default:
|
||||
return ""
|
||||
return "" // 其他状态码不给出具体建议
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package config
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
|
@ -26,12 +27,32 @@ func DefaultConfig() *Config {
|
|||
}
|
||||
}
|
||||
|
||||
// ConfigDir returns the platform-appropriate configuration directory.
|
||||
// - Windows: %APPDATA%\gitlink-cli
|
||||
// - macOS: ~/Library/Application Support/gitlink-cli
|
||||
// - Linux: ~/.config/gitlink-cli (XDG_CONFIG_HOME respected)
|
||||
func ConfigDir() string {
|
||||
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli")
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
return filepath.Join(appData, "gitlink-cli")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "AppData", "Roaming", "gitlink-cli")
|
||||
case "darwin":
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "Library", "Application Support", "gitlink-cli")
|
||||
default:
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "gitlink-cli")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli")
|
||||
}
|
||||
}
|
||||
|
||||
func ConfigPath() string {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"unicode"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
|
@ -71,10 +73,13 @@ func printTable(w io.Writer, envelope *Envelope) error {
|
|||
case []interface{}:
|
||||
return printSliceTable(w, data)
|
||||
case map[string]interface{}:
|
||||
// For maps with nested structures, prefer JSON
|
||||
if hasComplexValues(data) {
|
||||
return printJSON(w, envelope)
|
||||
// If the map contains a list of objects, render that list as a table
|
||||
// (picking the largest such list, which is usually the primary payload).
|
||||
if slice := findLargestSlice(data); slice != nil {
|
||||
return printSliceTable(w, slice)
|
||||
}
|
||||
// Single-object detail: render as a key/value table with nested maps
|
||||
// expanded inline (indented sub-rows) for readability.
|
||||
return printMapTable(w, data)
|
||||
default:
|
||||
// Fallback to JSON
|
||||
|
|
@ -82,32 +87,91 @@ func printTable(w io.Writer, envelope *Envelope) error {
|
|||
}
|
||||
}
|
||||
|
||||
func hasComplexValues(m map[string]interface{}) bool {
|
||||
// findLargestSlice scans the values of m for the largest slice whose elements
|
||||
// are maps (e.g. []interface{} of map[string]interface{}, or native
|
||||
// []map[string]interface{}/[]map[string]string built locally by some commands).
|
||||
// Elements are normalized to map[string]interface{} so the table renderer has a
|
||||
// consistent shape. Returns nil if no suitable slice is found.
|
||||
func findLargestSlice(m map[string]interface{}) []interface{} {
|
||||
var best []interface{}
|
||||
for _, v := range m {
|
||||
switch v.(type) {
|
||||
case map[string]interface{}, []interface{}:
|
||||
return true
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() != reflect.Slice || rv.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
// Every element must be a map for it to be table-renderable.
|
||||
normalized := make([]interface{}, 0, rv.Len())
|
||||
ok := true
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
el, alright := normalizeMap(rv.Index(i).Interface())
|
||||
if !alright {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
normalized = append(normalized, el)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if best == nil || len(normalized) > len(best) {
|
||||
best = normalized
|
||||
}
|
||||
}
|
||||
return false
|
||||
return best
|
||||
}
|
||||
|
||||
// normalizeMap coerces a map value (map[string]interface{} or a native
|
||||
// map[string]X built by command code) into map[string]interface{} for uniform
|
||||
// rendering. Returns ok=false if v is not a string-keyed map.
|
||||
func normalizeMap(v interface{}) (map[string]interface{}, bool) {
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() != reflect.Map {
|
||||
return nil, false
|
||||
}
|
||||
out := make(map[string]interface{}, rv.Len())
|
||||
for _, key := range rv.MapKeys() {
|
||||
ks, ok := key.Interface().(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out[ks] = rv.MapIndex(key).Interface()
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// maxTableColumns caps the number of columns rendered in a slice table so the
|
||||
// output stays readable on standard terminal widths. Fields beyond this limit
|
||||
// are dropped (priority fields are kept first; see collectKeys).
|
||||
const maxTableColumns = 8
|
||||
|
||||
func printSliceTable(w io.Writer, items []interface{}) error {
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No results")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect headers from first item
|
||||
first, ok := items[0].(map[string]interface{})
|
||||
if !ok {
|
||||
// If the first item isn't a map, fall back to JSON (unchanged behavior).
|
||||
if _, ok := items[0].(map[string]interface{}); !ok {
|
||||
data, _ := json.MarshalIndent(items, "", " ")
|
||||
fmt.Fprintln(w, string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
headers := collectKeys(first)
|
||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
// Headers are the UNION of every row's keys, so a column appears whenever
|
||||
// any row carries the field. GitLink payloads (e.g. branch lists) do not
|
||||
// always include every key on every item; taking headers from items[0]
|
||||
// alone made columns like `protected` appear or vanish depending on which
|
||||
// item happened to rank first.
|
||||
headers := collectKeysUnion(items)
|
||||
omitted := 0
|
||||
if len(headers) > maxTableColumns {
|
||||
omitted = len(headers) - maxTableColumns
|
||||
headers = headers[:maxTableColumns]
|
||||
}
|
||||
|
||||
// minwidth=0, tabwidth=2, padding=2 -> tighter columns than the old
|
||||
// minwidth=4 setting, which helps when there are several wide fields.
|
||||
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
|
||||
|
||||
// Print headers
|
||||
fmt.Fprintln(tw, strings.Join(headers, "\t"))
|
||||
|
|
@ -129,31 +193,75 @@ func printSliceTable(w io.Writer, items []interface{}) error {
|
|||
}
|
||||
fmt.Fprintln(tw, strings.Join(vals, "\t"))
|
||||
}
|
||||
return tw.Flush()
|
||||
tw.Flush()
|
||||
|
||||
if omitted > 0 {
|
||||
// Note appended after the table so it does not perturb column alignment.
|
||||
fmt.Fprintf(w, "(已省略 %d 列)\n", omitted)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printMapTable renders a single-object detail as a two-column KEY/VALUE table.
|
||||
// Top-level keys are ordered by collectKeys (identity/status fields first).
|
||||
// Nested map values are expanded as indented sub-rows so rich detail (e.g. the
|
||||
// commit/tagger block of a tag) stays readable instead of collapsing to a
|
||||
// summary token or a JSON blob.
|
||||
func printMapTable(w io.Writer, m map[string]interface{}) error {
|
||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "KEY\tVALUE")
|
||||
fmt.Fprintln(tw, "---\t-----")
|
||||
for k, v := range m {
|
||||
fmt.Fprintf(tw, "%s\t%s\n", k, formatValue(v))
|
||||
for _, k := range collectKeys(m) {
|
||||
writeMapRow(tw, k, m[k], 0)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
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"}
|
||||
// writeMapRow writes one row for a key/value pair. Nested maps recurse with
|
||||
// increasing indent so sub-fields appear under their parent.
|
||||
func writeMapRow(tw *tabwriter.Writer, key string, v interface{}, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
displayKey := key
|
||||
if depth > 0 {
|
||||
displayKey = "└ " + key
|
||||
}
|
||||
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
// Parent header row, then each child indented one level deeper.
|
||||
fmt.Fprintf(tw, "%s%s\t\n", indent, displayKey)
|
||||
for _, ck := range collectKeys(m) {
|
||||
writeMapRow(tw, ck, m[ck], depth+1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(tw, "%s%s\t%s\n", indent, displayKey, formatValue(v))
|
||||
}
|
||||
|
||||
// preferredHeaders lists keys that are surfaced first (and therefore survive the
|
||||
// maxTableColumns cap). Keep the most human-meaningful identity/status fields
|
||||
// at the top so that even when a row has 11+ keys the table still shows the
|
||||
// columns a user actually scans.
|
||||
var preferredHeaders = []string{
|
||||
"id", "name", "login", "title", "subject", "number",
|
||||
"status", "state", "protected", "last_commit", "commit_time",
|
||||
"type", "time_ago", "created_time", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// orderKeySet returns the keys of keySet ordered by preferredHeaders: priority
|
||||
// fields come first in their defined order, the rest follow in set-iteration
|
||||
// order. Keys absent from preferredHeaders still appear, just after the
|
||||
// priority fields.
|
||||
func orderKeySet(keySet map[string]bool) []string {
|
||||
keys := make([]string, 0, len(keySet))
|
||||
seen := map[string]bool{}
|
||||
for _, k := range priority {
|
||||
if _, ok := m[k]; ok {
|
||||
keys = append(keys, k)
|
||||
seen[k] = true
|
||||
for _, p := range preferredHeaders {
|
||||
if keySet[p] {
|
||||
keys = append(keys, p)
|
||||
seen[p] = true
|
||||
}
|
||||
}
|
||||
for k := range m {
|
||||
for k := range keySet {
|
||||
if !seen[k] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
|
@ -161,20 +269,180 @@ func collectKeys(m map[string]interface{}) []string {
|
|||
return keys
|
||||
}
|
||||
|
||||
// collectKeysUnion merges the keys of every map in items into a single
|
||||
// priority-ordered header list. Used by printSliceTable so a column shows
|
||||
// whenever any row has the field — preventing columns from disappearing when
|
||||
// the first row happens to omit a key (e.g. `protected` on GitLink branches).
|
||||
func collectKeysUnion(items []interface{}) []string {
|
||||
keySet := map[string]bool{}
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for k := range m {
|
||||
keySet[k] = true
|
||||
}
|
||||
}
|
||||
return orderKeySet(keySet)
|
||||
}
|
||||
|
||||
func collectKeys(m map[string]interface{}) []string {
|
||||
keySet := make(map[string]bool, len(m))
|
||||
for k := range m {
|
||||
keySet[k] = true
|
||||
}
|
||||
return orderKeySet(keySet)
|
||||
}
|
||||
|
||||
func formatValue(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Map, reflect.Slice:
|
||||
data, _ := json.Marshal(v)
|
||||
s := string(data)
|
||||
if len(s) > 60 {
|
||||
return s[:57] + "..."
|
||||
case reflect.Map:
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
return formatNestedMap(m)
|
||||
}
|
||||
return s
|
||||
// Native map (e.g. map[string]string built locally): normalize then summarize.
|
||||
if m, ok := normalizeMap(v); ok {
|
||||
return formatNestedMap(m)
|
||||
}
|
||||
// Non-string-keyed map: fall back to JSON, truncated.
|
||||
data, _ := json.Marshal(v)
|
||||
return truncateString(string(data), 40)
|
||||
case reflect.Slice:
|
||||
// For slices, show the element count rather than an inline JSON blob,
|
||||
// which is almost always wider than the terminal.
|
||||
return fmt.Sprintf("[%d items]", rv.Len())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
f := rv.Float()
|
||||
// Render whole-valued floats as integers (e.g. 1779200970 instead of
|
||||
// 1.77920097e+09); otherwise use plain 'f' formatting to avoid the
|
||||
// scientific notation %v picks for large magnitudes.
|
||||
if f == float64(int64(f)) {
|
||||
return strconv.FormatInt(int64(f), 10)
|
||||
}
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
return truncateString(fmt.Sprintf("%v", v), 40)
|
||||
}
|
||||
}
|
||||
|
||||
// truncateString shortens s to at most maxLen bytes, appending "..." when it is
|
||||
// truncated. Strings already within the limit are returned unchanged.
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return s[:maxLen]
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// formatNestedMap produces a compact, human-readable summary of a nested map by
|
||||
// extracting a few well-known fields (name/login/title/message/sha/...) instead
|
||||
// of dumping the full JSON. Unknown shapes collapse to "<object>". This keeps
|
||||
// table cells narrow even for richly-nested API payloads (e.g. commit metadata).
|
||||
func formatNestedMap(m map[string]interface{}) string {
|
||||
// Ordered candidates: earlier keys win and become the leading part.
|
||||
type candidate struct {
|
||||
key string
|
||||
max int // 0 = no truncation
|
||||
}
|
||||
cands := []candidate{
|
||||
{"name", 0},
|
||||
{"login", 0},
|
||||
{"title", 30},
|
||||
{"subject", 30},
|
||||
{"message", 30},
|
||||
{"sha", 8},
|
||||
{"id", 0},
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, c := range cands {
|
||||
raw, ok := m[c.key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
s := formatValue(raw)
|
||||
if c.max > 0 {
|
||||
if c.key == "sha" {
|
||||
// Git-style short SHA: bare prefix, no ellipsis.
|
||||
if len(s) > c.max {
|
||||
s = s[:c.max]
|
||||
}
|
||||
} else {
|
||||
s = truncateString(s, c.max)
|
||||
}
|
||||
}
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
// For text-ish fields, join with ":"; for sha/id use a bare token.
|
||||
switch c.key {
|
||||
case "sha", "id":
|
||||
parts = append(parts, s)
|
||||
default:
|
||||
parts = append(parts, c.key+":"+s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "<object>"
|
||||
}
|
||||
return strings.Join(parts, " -> ")
|
||||
}
|
||||
|
||||
// ShouldUseColor returns true if colorized output should be used.
|
||||
func ShouldUseColor(mode string) bool {
|
||||
switch mode {
|
||||
case "always":
|
||||
return true
|
||||
case "never":
|
||||
return false
|
||||
default: // "auto"
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return false
|
||||
}
|
||||
if os.Getenv("TERM") == "dumb" {
|
||||
return false
|
||||
}
|
||||
if os.Getenv("WT_SESSION") != "" || os.Getenv("ConEmuANSI") == "ON" {
|
||||
return true
|
||||
}
|
||||
if os.Getenv("COLORTERM") != "" {
|
||||
return true
|
||||
}
|
||||
if os.Getenv("TERM") != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// runeWidth returns the display width of a rune (2 for CJK, 1 for others).
|
||||
func runeWidth(r rune) int {
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
return 2
|
||||
}
|
||||
if r >= 0xFF01 && r <= 0xFF60 {
|
||||
return 2
|
||||
}
|
||||
if r >= 0x3000 && r <= 0x303F {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// StringWidth returns the display width of a string accounting for CJK characters.
|
||||
func StringWidth(s string) int {
|
||||
w := 0
|
||||
for _, r := range s {
|
||||
w += runeWidth(r)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,368 @@
|
|||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrintTable_NestedSliceRendersAsTable(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"tags": []interface{}{
|
||||
map[string]interface{}{"name": "v1", "id": "a"},
|
||||
map[string]interface{}{"name": "v2", "id": "b"},
|
||||
},
|
||||
"total_count": 2,
|
||||
}
|
||||
env := SuccessEnvelope(data, nil)
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{"name", "v1", "v2", "id"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Must NOT start with JSON envelope.
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "{") {
|
||||
t.Errorf("expected table output, not JSON; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintTable_NestedMapRendersAsDetailTable 验证含嵌套 map 的单对象详情
|
||||
// 现在渲染为易读的 KEY/VALUE 表格(嵌套字段用缩进展开),而非回退 JSON。
|
||||
func TestPrintTable_NestedMapRendersAsDetailTable(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"name": "v1.0.0",
|
||||
"commit": map[string]interface{}{"sha": "abc123", "message": "release"},
|
||||
"tagger": map[string]interface{}{"login": "alice", "name": "Alice"},
|
||||
}
|
||||
env := SuccessEnvelope(data, nil)
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
// Should be a key/value table, NOT JSON.
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "{") {
|
||||
t.Errorf("expected table output for nested map, not JSON; got:\n%s", out)
|
||||
}
|
||||
// Top-level key present.
|
||||
for _, want := range []string{"name", "v1.0.0", "KEY", "VALUE"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected output to contain %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Nested fields should be expanded inline (not collapsed to <object>).
|
||||
for _, want := range []string{"sha", "abc123", "login", "alice"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected nested field %q to be expanded; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintTable_SimpleMapRendersAsMapTable(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"name": "x",
|
||||
"count": 5,
|
||||
}
|
||||
env := SuccessEnvelope(data, nil)
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "KEY") || !strings.Contains(out, "VALUE") {
|
||||
t.Errorf("expected KEY/VALUE map table; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintTable_NativeMapStringSlice 验证本地构造的 []map[string]string
|
||||
// (如 wiki +list 的 pages)也能渲染为表格,而非折叠成 "[N items]"。
|
||||
// 回归测试:之前 findLargestSlice 只认 []interface{},导致这类列表被当
|
||||
// 单对象详情的 slice 字段,显示为 "[2 items]"。
|
||||
func TestPrintTable_NativeMapStringSlice(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"total_count": 2,
|
||||
"pages": []map[string]string{
|
||||
{"name": "Home"},
|
||||
{"name": "About"},
|
||||
},
|
||||
}
|
||||
env := SuccessEnvelope(data, nil)
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "[2 items]") {
|
||||
t.Errorf("native map slice should render as table, not [N items]; got:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{"name", "Home", "About"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected output to contain %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintTable_ErrorEnvelope(t *testing.T) {
|
||||
env := ErrorEnvelope("E_FAIL", "boom", "")
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Error:") {
|
||||
t.Errorf("expected output to contain \"Error:\"; got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "boom") {
|
||||
t.Errorf("expected output to contain error message \"boom\"; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintTable_PicksLargestSlice(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"small": []interface{}{
|
||||
map[string]interface{}{"a": 1},
|
||||
},
|
||||
"big": []interface{}{
|
||||
map[string]interface{}{"x": 1},
|
||||
map[string]interface{}{"x": 2},
|
||||
map[string]interface{}{"x": 3},
|
||||
},
|
||||
}
|
||||
env := SuccessEnvelope(data, nil)
|
||||
var buf bytes.Buffer
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
// Must not be a JSON envelope fallback.
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "{") {
|
||||
t.Errorf("expected table output, not JSON; got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "x") {
|
||||
t.Errorf("expected output to render \"big\" list with x column; got:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{"1", "2", "3"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected output to contain row value %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
// header + dash line + 3 rows = 5 lines minimum
|
||||
if len(lines) < 5 {
|
||||
t.Errorf("expected at least 5 lines (header/dash/3 rows); got %d:\n%s", len(lines), out)
|
||||
}
|
||||
// The "a" column from the smaller slice must not appear as a header.
|
||||
for _, line := range lines {
|
||||
// header line is tab-separated; ensure "a" is not one of the columns.
|
||||
cols := strings.Split(line, "\t")
|
||||
for _, c := range cols {
|
||||
if strings.TrimSpace(c) == "a" {
|
||||
t.Errorf("did not expect \"a\" column from smaller slice; got line: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Readability improvements (truncation, column limits, nested-map summaries) ---
|
||||
|
||||
func TestFormatValue_TruncatesLongString(t *testing.T) {
|
||||
long := strings.Repeat("a", 100)
|
||||
got := formatValue(long)
|
||||
if len(got) > 40 {
|
||||
t.Errorf("expected output <= 40 chars, got %d: %q", len(got), got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "...") {
|
||||
t.Errorf("expected output to end with \"...\", got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_ShortStringUntouched(t *testing.T) {
|
||||
got := formatValue("hello")
|
||||
if got != "hello" {
|
||||
t.Errorf("expected short string unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_NestedMapShowsSummary(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"login": "alice",
|
||||
"id": 123,
|
||||
"image_url": "http://example.com/avatar.png",
|
||||
}
|
||||
got := formatValue(m)
|
||||
if !strings.Contains(got, "alice") {
|
||||
t.Errorf("expected summary to contain \"alice\", got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "image_url") {
|
||||
t.Errorf("expected summary to omit noisy field \"image_url\", got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_NestedMapWithoutKnownFields(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"foo": "bar",
|
||||
"baz": 42,
|
||||
}
|
||||
got := formatValue(m)
|
||||
// No recognized key -> should be a short placeholder, not a JSON blob.
|
||||
if strings.HasPrefix(got, "{") {
|
||||
t.Errorf("expected placeholder for object without known fields, got JSON: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_FloatNoScientificNotation(t *testing.T) {
|
||||
got := formatValue(1.77920097e+09)
|
||||
if strings.Contains(got, "e+") || strings.Contains(got, "E+") {
|
||||
t.Errorf("expected float without scientific notation, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "1779200970") {
|
||||
t.Errorf("expected full decimal expansion, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintSliceTable_LimitsColumns(t *testing.T) {
|
||||
// Build a single row with 12 columns.
|
||||
row := map[string]interface{}{}
|
||||
cols := []string{"id", "name", "login", "title", "subject", "number",
|
||||
"status", "state", "extra1", "extra2", "extra3", "extra4"}
|
||||
for _, c := range cols {
|
||||
row[c] = "v_" + c
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := printSliceTable(&buf, []interface{}{row}); err != nil {
|
||||
t.Fatalf("printSliceTable returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
// The header line is the first non-empty line.
|
||||
lines := strings.Split(out, "\n")
|
||||
var header string
|
||||
for _, l := range lines {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
header = l
|
||||
break
|
||||
}
|
||||
}
|
||||
colCount := len(strings.Split(header, "\t"))
|
||||
if colCount > 8 {
|
||||
t.Errorf("expected <= 8 columns in header, got %d:\n%s", colCount, header)
|
||||
}
|
||||
// And the output should note that columns were omitted.
|
||||
if !strings.Contains(out, "省略") && !strings.Contains(strings.ToLower(out), "omitted") {
|
||||
t.Errorf("expected output to mention omitted columns (省略/omitted), got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintSliceTable_PreservesPriorityHeaders(t *testing.T) {
|
||||
// Even with >8 columns, priority fields like id/name should survive.
|
||||
row := map[string]interface{}{}
|
||||
cols := []string{"a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "id", "name"}
|
||||
for _, c := range cols {
|
||||
row[c] = "v"
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := printSliceTable(&buf, []interface{}{row}); err != nil {
|
||||
t.Fatalf("printSliceTable returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "id") || !strings.Contains(out, "name") {
|
||||
t.Errorf("expected priority headers id/name to survive; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateString(t *testing.T) {
|
||||
if got := truncateString("abc", 10); got != "abc" {
|
||||
t.Errorf("expected short string unchanged, got %q", got)
|
||||
}
|
||||
got := truncateString("abcdefghij", 5)
|
||||
if len(got) > 5 {
|
||||
t.Errorf("expected len <= 5, got %d: %q", len(got), got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "...") {
|
||||
t.Errorf("expected ... suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNestedMap_LoginAndSha(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"login": "bob",
|
||||
"sha": "0123456789abcdef0123456789abcdef",
|
||||
}
|
||||
got := formatNestedMap(m)
|
||||
if !strings.Contains(got, "bob") {
|
||||
t.Errorf("expected login in summary, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "0123456") {
|
||||
t.Errorf("expected truncated sha prefix in summary, got %q", got)
|
||||
}
|
||||
// Should not contain the full sha.
|
||||
if strings.Contains(got, "0123456789abcdef0123456789abcdef") {
|
||||
t.Errorf("expected sha to be truncated, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintSliceTable_HeaderUnionAcrossRows 验证当各行字段集合不一致时
|
||||
// (GitLink 分支列表的典型情况:并非每个分支都返回 protected 字段),
|
||||
// 表头应取所有行的字段并集,而不是仅取第一行。否则 protected 列会因落在
|
||||
// 第一位的样本恰好缺失而「时有时无」。
|
||||
func TestPrintSliceTable_HeaderUnionAcrossRows(t *testing.T) {
|
||||
items := []interface{}{
|
||||
map[string]interface{}{"name": "develop"}, // 第一行缺 protected
|
||||
map[string]interface{}{"name": "main", "protected": false}, // 第二行带 protected
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := printSliceTable(&buf, items); err != nil {
|
||||
t.Fatalf("printSliceTable returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "protected") {
|
||||
t.Errorf("expected header to include \"protected\" from row union (not just first row); got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "develop") || !strings.Contains(out, "main") {
|
||||
t.Errorf("expected both rows present in output; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintSliceTable_ProtectedSurvivesColumnCap 验证当列数超过上限时,
|
||||
// protected(是否保护分支)作为优先字段会被保留、不被截断丢弃——否则在
|
||||
// 字段较多的分支列表里这一列仍可能消失。
|
||||
func TestPrintSliceTable_ProtectedSurvivesColumnCap(t *testing.T) {
|
||||
row := map[string]interface{}{}
|
||||
cols := []string{"a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "id", "name", "protected"}
|
||||
for _, c := range cols {
|
||||
row[c] = "v"
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := printSliceTable(&buf, []interface{}{row}); err != nil {
|
||||
t.Fatalf("printSliceTable returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "protected") {
|
||||
t.Errorf("expected priority field \"protected\" to survive the column cap; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrderKeySet_BranchColumnOrderIsStable 守护分支核心列的固定顺序
|
||||
// name / protected / last_commit / commit_time。当前若 last_commit /
|
||||
// commit_time 不在 preferredHeaders,orderKeySet 会把这两个非优先键按 map
|
||||
// 遍历序追加,导致列顺序随机——重复调用必暴露不一致。
|
||||
func TestOrderKeySet_BranchColumnOrderIsStable(t *testing.T) {
|
||||
want := []string{"name", "protected", "last_commit", "commit_time"}
|
||||
keys := map[string]bool{"name": true, "protected": true, "last_commit": true, "commit_time": true}
|
||||
for i := 0; i < 100; i++ {
|
||||
got := orderKeySet(keys)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("iteration %d: got %d keys %v, want %v", i, len(got), got, want)
|
||||
}
|
||||
for j, w := range want {
|
||||
if got[j] != w {
|
||||
t.Fatalf("iteration %d: got[%d]=%q, want %q (full %v)", i, j, got[j], w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,13 @@
|
|||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const BINARY_NAME = "gitlink-cli";
|
||||
const PACKAGE = require("../package.json");
|
||||
const CURRENT_VERSION = PACKAGE.version;
|
||||
|
||||
function getBinaryName(platform = process.platform) {
|
||||
return platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
||||
|
|
@ -36,6 +40,36 @@ function formatMissingBinaryError(
|
|||
].join("\n");
|
||||
}
|
||||
|
||||
function checkForUpdate() {
|
||||
if (process.env.GITLINK_CLI_NO_UPDATE_CHECK) return;
|
||||
const registryUrl = "https://registry.npmjs.org/@gitlink-ai/cli/latest";
|
||||
const mod = registryUrl.startsWith("https") ? https : http;
|
||||
try {
|
||||
mod.get(registryUrl, { timeout: 3000 }, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const pkg = JSON.parse(body);
|
||||
const latest = pkg.version;
|
||||
if (latest && latest !== CURRENT_VERSION) {
|
||||
process.stderr.write(
|
||||
`\nA new version of ${BINARY_NAME} is available: ${latest} (current: ${CURRENT_VERSION})\n` +
|
||||
`Update with: npm update -g @gitlink-ai/cli\n\n`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore parse errors
|
||||
}
|
||||
});
|
||||
}).on("error", () => {
|
||||
// Silently ignore network errors
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore any errors
|
||||
}
|
||||
}
|
||||
|
||||
function run(args = process.argv.slice(2), options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
const arch = options.arch || process.arch;
|
||||
|
|
@ -49,10 +83,16 @@ function run(args = process.argv.slice(2), options = {}) {
|
|||
return exit(1);
|
||||
}
|
||||
|
||||
// Binary existence check before any other logic
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return failMissingBinary();
|
||||
}
|
||||
|
||||
// Run optional update check in background (fire-and-forget)
|
||||
if (require.main === module) {
|
||||
checkForUpdate();
|
||||
}
|
||||
|
||||
try {
|
||||
execFile(binaryPath, args, { stdio: "inherit" });
|
||||
} catch (err) {
|
||||
|
|
@ -76,4 +116,5 @@ module.exports = {
|
|||
getBinaryPath,
|
||||
formatMissingBinaryError,
|
||||
run,
|
||||
checkForUpdate,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
const os = require("os");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { execSync } = require("child_process");
|
||||
|
|
@ -13,13 +14,14 @@ const PACKAGE = require("../package.json");
|
|||
const VERSION = PACKAGE.version;
|
||||
const BINARY_NAME = "gitlink-cli";
|
||||
|
||||
// GitLink release download base URL
|
||||
// Format: https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases
|
||||
// Attachment download: https://www.gitlink.org.cn/api/attachments/{attachment_id}
|
||||
const RELEASE_BASE = "https://www.gitlink.org.cn";
|
||||
const REPO_OWNER = "Gitlink";
|
||||
const REPO_NAME = "gitlink-cli";
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 120000;
|
||||
|
||||
function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
|
|
@ -54,15 +56,60 @@ function getArchiveName(platform, arch) {
|
|||
return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${ext}`;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function fetch(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const maxRedirects = options.maxRedirects || 5;
|
||||
let redirectCount = 0;
|
||||
|
||||
// Proxy support: respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY
|
||||
function getProxy(targetUrl) {
|
||||
try {
|
||||
const parsed = new URL(targetUrl);
|
||||
const noProxy = (process.env.NO_PROXY || process.env.no_proxy || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
for (const np of noProxy) {
|
||||
if (parsed.hostname.endsWith(np) || parsed.hostname === np) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (parsed.protocol === "https:") {
|
||||
return (
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.https_proxy ||
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.http_proxy ||
|
||||
null
|
||||
);
|
||||
}
|
||||
return (
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.http_proxy ||
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function doRequest(currentUrl) {
|
||||
const proxyUrl = getProxy(currentUrl);
|
||||
const mod = currentUrl.startsWith("https") ? https : http;
|
||||
const req = mod.get(currentUrl, (res) => {
|
||||
// Follow redirects
|
||||
const parsedUrl = new URL(currentUrl);
|
||||
const reqOptions = {
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (parsedUrl.protocol === "https:" ? 443 : 80),
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
method: "GET",
|
||||
timeout: options.timeout || DOWNLOAD_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
const req = mod.get(currentUrl, reqOptions, (res) => {
|
||||
if (
|
||||
(res.statusCode === 301 ||
|
||||
res.statusCode === 302 ||
|
||||
|
|
@ -103,12 +150,36 @@ function fetch(url, options = {}) {
|
|||
});
|
||||
} else {
|
||||
const chunks = [];
|
||||
res.on("data", (chunk) => chunks.push(chunk));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
const totalSize = parseInt(res.headers["content-length"], 10) || 0;
|
||||
let downloaded = 0;
|
||||
let lastProgress = 0;
|
||||
res.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
downloaded += chunk.length;
|
||||
// Progress indicator (every 10% or every 512KB)
|
||||
if (totalSize > 0) {
|
||||
const pct = Math.floor((downloaded / totalSize) * 100);
|
||||
if (pct >= lastProgress + 10) {
|
||||
lastProgress = pct;
|
||||
process.stdout.write(`\r Downloading: ${pct}%`);
|
||||
}
|
||||
} else if (downloaded - lastProgress > 512 * 1024) {
|
||||
lastProgress = downloaded;
|
||||
process.stdout.write(
|
||||
`\r Downloaded: ${(downloaded / 1024 / 1024).toFixed(1)} MB`
|
||||
);
|
||||
}
|
||||
});
|
||||
res.on("end", () => {
|
||||
if (totalSize > 0 || downloaded > 512 * 1024) {
|
||||
process.stdout.write("\r");
|
||||
}
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.setTimeout(60000, () => {
|
||||
req.setTimeout(options.timeout || DOWNLOAD_TIMEOUT_MS, () => {
|
||||
req.destroy();
|
||||
reject(new Error("Request timed out"));
|
||||
});
|
||||
|
|
@ -122,14 +193,12 @@ async function findReleaseAsset(platform, arch) {
|
|||
const archiveName = getArchiveName(platform, arch);
|
||||
const tagName = `v${VERSION}`;
|
||||
|
||||
// Try fetching release info from GitLink API
|
||||
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
|
||||
console.log(`Fetching release info from ${apiUrl}`);
|
||||
|
||||
try {
|
||||
const releases = await fetch(apiUrl, { json: true });
|
||||
|
||||
// Find the release matching our version
|
||||
let release = null;
|
||||
|
||||
if (Array.isArray(releases)) {
|
||||
|
|
@ -137,7 +206,6 @@ async function findReleaseAsset(platform, arch) {
|
|||
(r) => r.tag_name === tagName || r.tag_name === VERSION
|
||||
);
|
||||
if (!release && releases.length > 0) {
|
||||
// Fall back to latest release
|
||||
release = releases[0];
|
||||
}
|
||||
} else if (releases && releases.releases) {
|
||||
|
|
@ -151,11 +219,9 @@ async function findReleaseAsset(platform, arch) {
|
|||
}
|
||||
|
||||
if (release && release.attachments) {
|
||||
// Try exact match first
|
||||
let asset = release.attachments.find(
|
||||
(a) => a.title === archiveName || a.filename === archiveName
|
||||
);
|
||||
// If no exact match (version mismatch on fallback release), match by platform/arch pattern
|
||||
if (!asset) {
|
||||
const ext = platform === "windows" ? ".zip" : ".tar.gz";
|
||||
const pattern = `_${platform}_${arch}${ext}`;
|
||||
|
|
@ -164,9 +230,8 @@ async function findReleaseAsset(platform, arch) {
|
|||
);
|
||||
}
|
||||
if (asset) {
|
||||
// Return the download URL for this attachment
|
||||
let downloadUrl = asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
|
||||
// Ensure absolute URL
|
||||
let downloadUrl =
|
||||
asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
|
||||
if (downloadUrl.startsWith("/")) {
|
||||
downloadUrl = RELEASE_BASE + downloadUrl;
|
||||
}
|
||||
|
|
@ -177,11 +242,63 @@ async function findReleaseAsset(platform, arch) {
|
|||
console.log(`Warning: Could not fetch release info: ${e.message}`);
|
||||
}
|
||||
|
||||
// Fallback: try direct download URL pattern
|
||||
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`;
|
||||
}
|
||||
|
||||
async function downloadAndExtract(url, destDir, platform) {
|
||||
function extractArchive(archivePath, destDir, platform) {
|
||||
const isWindows = platform === "windows";
|
||||
if (isWindows) {
|
||||
// Use PowerShell with properly escaped paths for Windows
|
||||
const escapedArchive = archivePath.replace(/'/g, "''");
|
||||
const escapedDest = destDir.replace(/'/g, "''");
|
||||
try {
|
||||
execSync(
|
||||
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${escapedArchive}' -DestinationPath '${escapedDest}'"`,
|
||||
{ stdio: "pipe" }
|
||||
);
|
||||
} catch {
|
||||
// Fallback: try tar on modern Windows (10+)
|
||||
try {
|
||||
execSync(`tar -xf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
|
||||
} catch (e2) {
|
||||
throw new Error(
|
||||
`Failed to extract archive. Tried PowerShell Expand-Archive and tar.\n${e2.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
|
||||
}
|
||||
}
|
||||
|
||||
function classifyError(err) {
|
||||
const code = err.code || (err.cause && err.cause.code) || "";
|
||||
const messages = {
|
||||
ENOTFOUND: "DNS 解析失败,请检查网络连接",
|
||||
ECONNREFUSED: "连接被拒绝,请检查代理设置 (HTTP_PROXY/HTTPS_PROXY)",
|
||||
ETIMEDOUT: "连接超时,请尝试设置 HTTP_PROXY 环境变量",
|
||||
EACCES: "权限不足,请使用管理员权限运行或检查安装目录",
|
||||
EPERM: "权限不足,请使用管理员权限运行或检查安装目录",
|
||||
};
|
||||
return messages[code] || null;
|
||||
}
|
||||
|
||||
function verifyChecksum(filePath, expectedHash) {
|
||||
if (!expectedHash) return true;
|
||||
const hash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||
if (hash !== expectedHash) {
|
||||
throw new Error(
|
||||
`Checksum verification failed for ${path.basename(filePath)}\n` +
|
||||
` Expected: ${expectedHash}\n` +
|
||||
` Actual: ${hash}\n` +
|
||||
`The downloaded file may be corrupted or tampered with.`
|
||||
);
|
||||
}
|
||||
console.log(` Checksum verified: SHA256 OK`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function downloadAndExtract(url, destDir, platform, expectedChecksum) {
|
||||
console.log(`Downloading ${BINARY_NAME} from ${url}...`);
|
||||
|
||||
const data = await fetch(url);
|
||||
|
|
@ -192,15 +309,10 @@ async function downloadAndExtract(url, destDir, platform) {
|
|||
fs.writeFileSync(archivePath, data);
|
||||
console.log(`Downloaded ${(data.length / 1024 / 1024).toFixed(1)} MB`);
|
||||
|
||||
// Extract
|
||||
if (isWindows) {
|
||||
execSync(
|
||||
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${destDir}'"`,
|
||||
{ stdio: "pipe" }
|
||||
);
|
||||
} else {
|
||||
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
|
||||
}
|
||||
// Verify checksum if provided
|
||||
verifyChecksum(archivePath, expectedChecksum);
|
||||
|
||||
extractArchive(archivePath, destDir, platform);
|
||||
fs.unlinkSync(archivePath);
|
||||
|
||||
// Find the binary in extracted files
|
||||
|
|
@ -208,7 +320,6 @@ async function downloadAndExtract(url, destDir, platform) {
|
|||
const binaryPath = path.join(destDir, binaryName);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
// It might be in a subdirectory
|
||||
const files = fs.readdirSync(destDir);
|
||||
for (const file of files) {
|
||||
const subPath = path.join(destDir, file, binaryName);
|
||||
|
|
@ -223,7 +334,6 @@ async function downloadAndExtract(url, destDir, platform) {
|
|||
throw new Error(`Binary "${binaryName}" not found after extraction`);
|
||||
}
|
||||
|
||||
// Make executable (not needed on Windows)
|
||||
if (!isWindows) {
|
||||
fs.chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
|
|
@ -251,22 +361,58 @@ async function main() {
|
|||
// If binary already exists, check version matches
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
try {
|
||||
const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 });
|
||||
const output = execSync(`"${binaryPath}" version`, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 5000,
|
||||
});
|
||||
if (output.includes(VERSION)) {
|
||||
console.log(`${BINARY_NAME} v${VERSION} already installed, skipping download.`);
|
||||
console.log(
|
||||
`${BINARY_NAME} v${VERSION} already installed, skipping download.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(`${BINARY_NAME} version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`);
|
||||
} catch (e) {
|
||||
console.log(`${BINARY_NAME} binary exists but is not compatible, re-downloading...`);
|
||||
console.log(
|
||||
`${BINARY_NAME} version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`
|
||||
);
|
||||
} catch {
|
||||
console.log(
|
||||
`${BINARY_NAME} binary exists but is not compatible, re-downloading...`
|
||||
);
|
||||
}
|
||||
fs.unlinkSync(binaryPath);
|
||||
}
|
||||
|
||||
const downloadUrl = await findReleaseAsset(platform, arch);
|
||||
await downloadAndExtract(downloadUrl, binDir, platform);
|
||||
|
||||
// Download with retry
|
||||
let lastError = null;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await downloadAndExtract(downloadUrl, binDir, platform, null);
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < MAX_RETRIES) {
|
||||
console.log(
|
||||
`Download attempt ${attempt}/${MAX_RETRIES} failed: ${err.message}`
|
||||
);
|
||||
console.log(`Retrying in ${RETRY_DELAY_MS / 1000}s...`);
|
||||
await sleep(RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
} catch (err) {
|
||||
const classifiedMsg = classifyError(err);
|
||||
console.error(`\nFailed to install ${BINARY_NAME}: ${err.message}`);
|
||||
if (classifiedMsg) {
|
||||
console.error(`\n诊断信息: ${classifiedMsg}`);
|
||||
}
|
||||
if (platformInfo) {
|
||||
console.error(`Platform: ${platformInfo.platform}/${platformInfo.arch}`);
|
||||
}
|
||||
|
|
@ -292,4 +438,6 @@ module.exports = {
|
|||
getBinaryName,
|
||||
getArchiveName,
|
||||
findReleaseAsset,
|
||||
classifyError,
|
||||
verifyChecksum,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"docx": "^9.7.1",
|
||||
"pptxgenjs": "^4.0.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
#!/bin/bash
|
||||
# ================================================================
|
||||
# gitlink-cli 本地编译安装脚本
|
||||
# 功能:编译本地源码并替换 PATH 中的旧版本
|
||||
# 使用:bash scripts/setup-local-cli.sh
|
||||
# ================================================================
|
||||
set -e
|
||||
|
||||
echo "============================================"
|
||||
echo " gitlink-cli 本地编译安装"
|
||||
echo "============================================"
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# 1. 检查 Go 环境
|
||||
if ! command -v go &> /dev/null; then
|
||||
echo "[ERROR] 未找到 Go,请先安装 Go 1.21+"
|
||||
exit 1
|
||||
fi
|
||||
echo "[INFO] Go 版本: $(go version)"
|
||||
|
||||
# 2. 编译
|
||||
echo "[INFO] 编译 gitlink-cli..."
|
||||
go build -o gitlink-cli.exe . 2>&1
|
||||
echo "[INFO] 编译完成: $(ls -lh gitlink-cli.exe | awk '{print $5}')"
|
||||
|
||||
# 3. 验证编译版本
|
||||
echo "[INFO] 验证命令完整性..."
|
||||
if ./gitlink-cli.exe commit --help > /dev/null 2>&1; then
|
||||
echo "[INFO] ✅ commit +list 可用"
|
||||
else
|
||||
echo "[WARN] commit 命令不可用,请检查代码"
|
||||
fi
|
||||
if ./gitlink-cli.exe collaborator --help > /dev/null 2>&1; then
|
||||
echo "[INFO] ✅ collaborator +list 可用"
|
||||
else
|
||||
echo "[WARN] collaborator 命令不可用"
|
||||
fi
|
||||
|
||||
# 4. 提供 PATH 设置说明
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " 安装完成"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "本地编译版本: $(pwd)/gitlink-cli.exe"
|
||||
echo ""
|
||||
echo "方式一:设置别名(推荐,仅当前终端有效)"
|
||||
echo " alias gitlink-cli='$(pwd)/gitlink-cli.exe'"
|
||||
echo ""
|
||||
echo "方式二:添加到 PATH(永久有效,需管理员权限)"
|
||||
echo " export PATH=\"$(pwd):\$PATH\""
|
||||
echo " 或将上面这行添加到 ~/.bashrc 或 ~/.bash_profile"
|
||||
echo ""
|
||||
echo "方式三:直接使用 detect-cli.sh(已在子任务三/四脚本中集成)"
|
||||
echo " source skills/gitlink-shared/scripts/detect-cli.sh"
|
||||
echo ""
|
||||
echo "验证:"
|
||||
echo " gitlink-cli commit +list --owner z2_cc --repo gitlink-cli --limit 3 --format json"
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package attachment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +upload:上传文件附件
|
||||
{
|
||||
Name: "upload",
|
||||
Description: "上传文件附件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Short: "f", Usage: "Path to the file to upload", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
file, err := ctx.RequireArg("file")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"file": file,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/attachments", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:根据 UUID 删除文件附件
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "根据 UUID 删除文件附件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "uuid", Short: "u", Usage: "Attachment UUID to delete", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
uuid, err := ctx.RequireArg("uuid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/attachments/%s", uuid), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package branch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newBatchDeleteShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-delete:批量删除多个分支
|
||||
Name: "batch-delete",
|
||||
Description: "批量删除多个分支",
|
||||
Long: `Delete multiple branches from the current repository in a single operation.
|
||||
|
||||
Provide branch names via --names (comma-separated) or --from (plain-text file
|
||||
with one branch name per line).
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before deletion
|
||||
unless --yes is set.
|
||||
|
||||
Use --dry-run to preview which branches would be deleted without making any
|
||||
changes.`,
|
||||
Example: ` # Delete two branches
|
||||
gitlink branch +batch-delete --names old-feature,deprecated-api
|
||||
|
||||
# Dry-run to preview deletions
|
||||
gitlink branch +batch-delete --names old-feature,deprecated-api --dry-run
|
||||
|
||||
# Delete branches listed in a file
|
||||
gitlink branch +batch-delete --from branches.txt`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "names", Short: "n", Usage: "Comma-separated branch names"},
|
||||
{Name: "from", Usage: "File path (one branch name per line)"},
|
||||
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchDelete,
|
||||
}
|
||||
}
|
||||
|
||||
func newBatchProtectShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-protect:批量为多个分支开启保护
|
||||
Name: "batch-protect",
|
||||
Description: "批量为多个分支开启保护",
|
||||
Long: `Enable branch protection for multiple branches in a single operation.
|
||||
|
||||
Provide branch names via --names (comma-separated) or --from (plain-text file
|
||||
with one branch name per line).
|
||||
|
||||
Protected branches restrict force-pushes and deletion.
|
||||
|
||||
Use --dry-run to preview which branches would be protected without making any
|
||||
changes.`,
|
||||
Example: ` # Protect two branches
|
||||
gitlink branch +batch-protect --names main,develop
|
||||
|
||||
# Dry-run to preview protections
|
||||
gitlink branch +batch-protect --names main,develop --dry-run
|
||||
|
||||
# Protect branches listed in a file
|
||||
gitlink branch +batch-protect --from branches.txt`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "names", Short: "n", Usage: "Comma-separated branch names"},
|
||||
{Name: "from", Usage: "File path (one branch name per line)"},
|
||||
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchProtect,
|
||||
}
|
||||
}
|
||||
|
||||
// --- batch-delete ---
|
||||
|
||||
// [B 类批量] 逐个删除分支——POST /branches/delete(不可逆)
|
||||
// 使用 CollectStrings 而非 CollectNumbers,分支名是字符串非数字
|
||||
func runBatchDelete(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
names, err := common.CollectStrings(ctx.Arg("names"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("no branch names provided; use --names branch1,branch2 or --from branches.txt")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
if !dryRun {
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("delete %d branch(es) from %s/%s", len(names), ctx.Owner, ctx.Repo),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
summary := common.ProcessBatch(names, dryRun, "delete", func(name string) error {
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
_, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d branch(es) failed to delete", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- batch-protect ---
|
||||
|
||||
// [B 类批量] 逐个设置分支保护——POST /protected_branches
|
||||
// 保护后的分支限制 force-push 和删除操作
|
||||
func runBatchProtect(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
names, err := common.CollectStrings(ctx.Arg("names"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("no branch names provided; use --names branch1,branch2 or --from branches.txt")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
summary := common.ProcessBatch(names, dryRun, "protect", func(name string) error {
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
_, err := ctx.CallAPI("POST", protectedBranchesPath(ctx.RepoPath()), payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d branch(es) failed to protect", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// protectedBranchesPath returns the API path for a repo's protected-branches
|
||||
// collection. Unlike most repository endpoints, protected_branches is served
|
||||
// under the legacy /api route with NO /v1 prefix — forgeplus defines it as
|
||||
// /api/:owner/:repo/protected_branches/:branch_name — so RepoPath() is used
|
||||
// directly. (Empirically the /v1 form returns 404.)
|
||||
func protectedBranchesPath(repoPath string) string {
|
||||
return repoPath + "/protected_branches"
|
||||
}
|
||||
|
|
@ -9,9 +9,23 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +batch-delete:批量删除多个分支
|
||||
newBatchDeleteShortcut(),
|
||||
// +batch-protect:批量为多个分支开启保护
|
||||
newBatchProtectShortcut(),
|
||||
// +list:列出仓库中的分支
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List branches",
|
||||
Description: "列出分支",
|
||||
Long: `List all branches in the current repository.
|
||||
|
||||
Shows branch name, protection status, and other details.
|
||||
Use --page and --limit for pagination.`,
|
||||
Example: ` # List branches
|
||||
gitlink branch +list
|
||||
|
||||
# Show 50 branches per page
|
||||
gitlink branch +list --limit 50`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
|
|
@ -27,15 +41,31 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
// Project onto stable core columns so the table stays readable
|
||||
// regardless of how many fields the API returns.
|
||||
branches := summarizeBranches(extractBranches(env.Data))
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"branches": branches,
|
||||
"total_count": len(branches),
|
||||
})
|
||||
},
|
||||
},
|
||||
// +create:从指定来源创建新分支
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a branch",
|
||||
Description: "创建分支",
|
||||
Long: `Create a new branch in the current repository.
|
||||
|
||||
The new branch is created from the specified source branch or commit.
|
||||
If --from is omitted, the repository default branch is used.`,
|
||||
Example: ` # Create a branch from the default branch
|
||||
gitlink branch +create --name feature/login
|
||||
|
||||
# Create a branch from a specific branch
|
||||
gitlink branch +create --name hotfix/fix-123 --from main`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
{Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"},
|
||||
{Name: "from", Short: "f", Usage: "Source branch or commit (default: repository default branch)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -43,8 +73,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
from := ctx.Arg("from")
|
||||
// [P1 Bug#2] 原代码: from = "master"(硬编码)
|
||||
// 修复: 使用 GetDefaultBranch() 动态查询仓库默认分支
|
||||
if from == "" {
|
||||
from = "master"
|
||||
from = common.GetDefaultBranch(ctx)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"new_branch_name": name,
|
||||
|
|
@ -57,9 +89,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除一个分支
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a branch",
|
||||
Description: "删除分支",
|
||||
Long: `Delete a branch from the current repository.
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before deletion.`,
|
||||
Example: ` # Delete a branch
|
||||
gitlink branch +delete --name old-feature`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
},
|
||||
|
|
@ -68,6 +106,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("delete branch %s", name),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
|
|
@ -78,9 +121,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +protect:为分支开启保护
|
||||
{
|
||||
Name: "protect",
|
||||
Description: "Set branch protection",
|
||||
Description: "开启分支保护",
|
||||
Long: `Enable branch protection for the specified branch.
|
||||
|
||||
Protected branches restrict force-pushes and deletion.`,
|
||||
Example: ` # Protect the main branch
|
||||
gitlink branch +protect --name main`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
},
|
||||
|
|
@ -92,16 +141,22 @@ func Shortcuts() []*common.Shortcut {
|
|||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
|
||||
env, err := ctx.CallAPI("POST", protectedBranchesPath(ctx.RepoPath()), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +unprotect:移除分支保护
|
||||
{
|
||||
Name: "unprotect",
|
||||
Description: "Remove branch protection",
|
||||
Description: "移除分支保护",
|
||||
Long: `Remove branch protection from the specified branch.
|
||||
|
||||
Once unprotected, the branch can be force-pushed or deleted.`,
|
||||
Example: ` # Remove protection from a branch
|
||||
gitlink branch +unprotect --name develop`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
},
|
||||
|
|
@ -110,7 +165,66 @@ func Shortcuts() []*common.Shortcut {
|
|||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
|
||||
env, err := ctx.CallAPI("DELETE", protectedBranchesPath(ctx.RepoPath())+"/"+url.PathEscape(name), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +all:不分页列出全部分支
|
||||
{
|
||||
Name: "all",
|
||||
Description: "列出全部分支(不分页)",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", "/v1"+ctx.RepoPath()+"/branches/all", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +default:修改仓库的默认分支
|
||||
{
|
||||
Name: "default",
|
||||
Description: "修改默认分支",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name to set as default", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
payload := map[string]interface{}{
|
||||
"default_branch": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", "/v1"+ctx.RepoPath()+"/branches/update_default_branch", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +restore:恢复一个已删除的分支
|
||||
{
|
||||
Name: "restore",
|
||||
Description: "恢复已删除的分支",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name to restore", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/restore", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
package branch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// summarizeBranches projects raw branch objects onto a stable set of core
|
||||
// columns — name / protected / last_commit / commit_time — so the table no
|
||||
// longer depends on how many fields the API returns or their ordering. Noisy
|
||||
// fields (URLs, full SHA, etc.) are dropped; they remain available via the
|
||||
// single-branch detail command.
|
||||
func summarizeBranches(items []interface{}) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(items))
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]interface{}{
|
||||
"name": m["name"],
|
||||
"protected": m["protected"],
|
||||
"last_commit": formatLastCommit(m),
|
||||
"commit_time": formatCommitTimeField(m["commit_time"]),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatLastCommit renders the last-commit column as "short_sha · time_ago",
|
||||
// reading the stable top-level commit_id / commit_time_from_now fields rather
|
||||
// than the inconsistently-shaped nested last_commit object.
|
||||
func formatLastCommit(branch map[string]interface{}) string {
|
||||
sha := ""
|
||||
if v, ok := branch["commit_id"]; ok && v != nil {
|
||||
sha = shortSHA(fmt.Sprintf("%v", v))
|
||||
}
|
||||
rel := ""
|
||||
if v, ok := branch["commit_time_from_now"]; ok && v != nil {
|
||||
rel = strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
}
|
||||
switch {
|
||||
case sha != "" && rel != "":
|
||||
return sha + " · " + rel
|
||||
case sha != "":
|
||||
return sha
|
||||
default:
|
||||
return rel
|
||||
}
|
||||
}
|
||||
|
||||
// shortSHA returns the first 8 chars of a git SHA, like git's short names.
|
||||
func shortSHA(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > 8 {
|
||||
return s[:8]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// formatCommitTimeField trims an ISO-8601 timestamp to a readable
|
||||
// "YYYY-MM-DD HH:MM" form. nil/short values are returned as-is (or "").
|
||||
func formatCommitTimeField(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
s := strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
if len(s) < 16 {
|
||||
return s
|
||||
}
|
||||
return strings.Replace(s[:16], "T", " ", 1)
|
||||
}
|
||||
|
||||
// extractBranches pulls the list of branch objects from an API payload that is
|
||||
// either a top-level array or a map wrapping the list under a key.
|
||||
func extractBranches(data interface{}) []interface{} {
|
||||
switch d := data.(type) {
|
||||
case []interface{}:
|
||||
return d
|
||||
case map[string]interface{}:
|
||||
var best []interface{}
|
||||
for _, v := range d {
|
||||
var slice []interface{}
|
||||
switch sv := v.(type) {
|
||||
case []interface{}:
|
||||
slice = sv
|
||||
case []map[string]interface{}:
|
||||
for _, x := range sv {
|
||||
slice = append(slice, x)
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if len(slice) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := slice[0].(map[string]interface{}); !ok {
|
||||
continue
|
||||
}
|
||||
if best == nil || len(slice) > len(best) {
|
||||
best = slice
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package branch
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestProtectedBranchesPath_NoV1Prefix 验证保护分支接口走的是老 API(无 /v1 前缀)。
|
||||
// 实测 DELETE /v1/.../protected_branches/<name>.json 返回 404;forgeplus 路由为
|
||||
// /api/:owner/:repo/protected_branches/:branch_name(无 v1),故直接用 RepoPath() 拼接。
|
||||
func TestProtectedBranchesPath_NoV1Prefix(t *testing.T) {
|
||||
got := protectedBranchesPath("/owner/repo")
|
||||
want := "/owner/repo/protected_branches"
|
||||
if got != want {
|
||||
t.Errorf("protectedBranchesPath(%q) = %q, want %q (protected_branches is the legacy /api route, no /v1)",
|
||||
"/owner/repo", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSummarizeBranches_ProjectsCoreColumns 验证分支列表被投影到固定的 4 列
|
||||
// name/protected/last_commit/commit_time,且噪声字段(URL、完整 SHA 等)被丢弃,
|
||||
// 不再因字段多少或顺序导致表格不稳定。
|
||||
func TestSummarizeBranches_ProjectsCoreColumns(t *testing.T) {
|
||||
branches := []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "main",
|
||||
"protected": true,
|
||||
"commit_id": "e21237dc877b21b009c27554c9aa61ec819ea67d",
|
||||
"commit_time_from_now": "29天前",
|
||||
"commit_time": "2023-12-05T15:06:05+08:00",
|
||||
"http_url": "http://example.com/repo.git",
|
||||
"zip_url": "http://example.com/archive.zip",
|
||||
},
|
||||
}
|
||||
got := summarizeBranches(branches)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(got))
|
||||
}
|
||||
row := got[0]
|
||||
if row["name"] != "main" {
|
||||
t.Errorf("name = %v, want main", row["name"])
|
||||
}
|
||||
if row["protected"] != true {
|
||||
t.Errorf("protected = %v, want true", row["protected"])
|
||||
}
|
||||
if row["last_commit"] != "e21237dc · 29天前" {
|
||||
t.Errorf("last_commit = %v, want \"e21237dc · 29天前\"", row["last_commit"])
|
||||
}
|
||||
if row["commit_time"] != "2023-12-05 15:06" {
|
||||
t.Errorf("commit_time = %v, want \"2023-12-05 15:06\"", row["commit_time"])
|
||||
}
|
||||
for _, bad := range []string{"http_url", "zip_url", "commit_id", "commit_time_from_now"} {
|
||||
if _, ok := row[bad]; ok {
|
||||
t.Errorf("noisy field %q should be dropped from summary", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractBranches_FromSliceAndMap 验证从顶层数组或 map 包裹的列表中
|
||||
// 都能取出分支对象列表。
|
||||
func TestExtractBranches_FromSliceAndMap(t *testing.T) {
|
||||
if s := extractBranches([]interface{}{map[string]interface{}{"name": "a"}}); len(s) != 1 {
|
||||
t.Errorf("top-level slice: expected 1, got %d", len(s))
|
||||
}
|
||||
m := extractBranches(map[string]interface{}{
|
||||
"branches": []interface{}{map[string]interface{}{"name": "a"}, map[string]interface{}{"name": "b"}},
|
||||
"total_count": 2,
|
||||
})
|
||||
if len(m) != 2 {
|
||||
t.Errorf("map payload: expected 2 branches, got %d", len(m))
|
||||
}
|
||||
if extractBranches("not-a-list") != nil {
|
||||
t.Errorf("expected nil for unsupported payload type")
|
||||
}
|
||||
}
|
||||
|
|
@ -9,12 +9,43 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +builds:列出 CI 构建记录
|
||||
{
|
||||
Name: "builds",
|
||||
Description: "List CI builds",
|
||||
Description: "列出 CI 构建记录",
|
||||
Long: `List CI/CD builds for the current repository.
|
||||
|
||||
Shows build number, status, branch, event type, and duration.
|
||||
Use --status, --branch, --event, --since, --until for filtering,
|
||||
and --page and --limit for pagination.`,
|
||||
Example: ` # List recent builds
|
||||
gitlink ci +builds
|
||||
|
||||
# Show 50 builds
|
||||
gitlink ci +builds --limit 50
|
||||
|
||||
# Filter by status
|
||||
gitlink ci +builds --status success
|
||||
gitlink ci +builds -s failure
|
||||
|
||||
# Filter by branch
|
||||
gitlink ci +builds --branch main
|
||||
gitlink ci +builds -b develop
|
||||
|
||||
# Filter by trigger event
|
||||
gitlink ci +builds --event push
|
||||
gitlink ci +builds --event pull_request
|
||||
|
||||
# Filter by time range
|
||||
gitlink ci +builds --since 1700000000 --until 1700086400`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "status", Short: "s", Usage: "Filter by build status (success, failure, running)"},
|
||||
{Name: "branch", Short: "b", Usage: "Filter by branch"},
|
||||
{Name: "event", Usage: "Filter by trigger event (push, pull_request, tag)"},
|
||||
{Name: "since", Usage: "Start time (UNIX timestamp)"},
|
||||
{Name: "until", Usage: "End time (UNIX timestamp)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -23,6 +54,21 @@ func Shortcuts() []*common.Shortcut {
|
|||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
if s := ctx.Arg("status"); s != "" {
|
||||
q.Set("status", s)
|
||||
}
|
||||
if b := ctx.Arg("branch"); b != "" {
|
||||
q.Set("branch", b)
|
||||
}
|
||||
if e := ctx.Arg("event"); e != "" {
|
||||
q.Set("event", e)
|
||||
}
|
||||
if s := ctx.Arg("since"); s != "" {
|
||||
q.Set("since", s)
|
||||
}
|
||||
if u := ctx.Arg("until"); u != "" {
|
||||
q.Set("until", u)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -30,9 +76,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +logs:查看构建日志
|
||||
{
|
||||
Name: "logs",
|
||||
Description: "View build logs",
|
||||
Description: "查看构建日志",
|
||||
Long: `View logs for a specific CI/CD build step.
|
||||
|
||||
Requires the build number. Use --stage and --step to navigate
|
||||
to a particular stage and step within the build.`,
|
||||
Example: ` # View logs for build #42
|
||||
gitlink ci +logs --build 42
|
||||
|
||||
# View logs for stage 2, step 3 of build #42
|
||||
gitlink ci +logs --build 42 --stage 2 --step 3`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||
{Name: "stage", Short: "s", Usage: "Stage number", Default: "1"},
|
||||
|
|
@ -58,9 +114,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +restart:重新触发构建
|
||||
{
|
||||
Name: "restart",
|
||||
Description: "Restart a build",
|
||||
Description: "重新触发构建",
|
||||
Long: `Restart a previously completed or failed CI/CD build.
|
||||
|
||||
The build will be re-triggered with the same commit and configuration.`,
|
||||
Example: ` # Restart build #42
|
||||
gitlink ci +restart --build 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||
},
|
||||
|
|
@ -76,9 +138,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +stop:停止构建
|
||||
{
|
||||
Name: "stop",
|
||||
Description: "Stop a build",
|
||||
Description: "停止构建",
|
||||
Long: `Stop a currently running CI/CD build.
|
||||
|
||||
Prompts for confirmation before stopping the build.`,
|
||||
Example: ` # Stop build #42
|
||||
gitlink ci +stop --build 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
||||
},
|
||||
|
|
@ -87,6 +155,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
return err
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("stop build #%s", build),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -94,5 +167,218 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- Pipeline / Actions group ---
|
||||
|
||||
// +pipelines:列出 CI 流水线模板
|
||||
{
|
||||
Name: "pipelines",
|
||||
Description: "列出 CI 流水线模板",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/pm/pipelines", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +run-pipeline:运行 CI 流水线
|
||||
{
|
||||
Name: "run-pipeline",
|
||||
Description: "运行 CI 流水线",
|
||||
Flags: []common.Flag{
|
||||
{Name: "pipeline", Short: "p", Usage: "Pipeline name or ID", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Branch to run on"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
pipeline, err := ctx.RequireArg("pipeline")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"pipeline": pipeline,
|
||||
}
|
||||
if branch := ctx.Arg("branch"); branch != "" {
|
||||
body["branch"] = branch
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/runs", ctx.RepoPath()), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +runs:列出 CI 运行历史
|
||||
{
|
||||
Name: "runs",
|
||||
Description: "列出 CI 运行历史",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/actions/runs", ctx.RepoPath()), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +save-yaml:保存流水线 YAML 配置
|
||||
{
|
||||
Name: "save-yaml",
|
||||
Description: "保存流水线 YAML 配置",
|
||||
Flags: []common.Flag{
|
||||
{Name: "yaml", Short: "y", Usage: "YAML content", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Pipeline name"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
yamlContent, err := ctx.RequireArg("yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"yaml": yamlContent,
|
||||
}
|
||||
if name := ctx.Arg("name"); name != "" {
|
||||
body["name"] = name
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/pipelines/save_yaml", ctx.RepoPath()), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +pipeline-detail:查看流水线详情
|
||||
{
|
||||
Name: "pipeline-detail",
|
||||
Description: "查看流水线详情",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Pipeline ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/pipelines/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-pipeline:删除流水线
|
||||
{
|
||||
Name: "delete-pipeline",
|
||||
Description: "删除流水线",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Pipeline ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1%s/pipelines/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +disable:为仓库禁用 CI
|
||||
{
|
||||
Name: "disable",
|
||||
Description: "为仓库禁用 CI",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/disable", ctx.RepoPath()), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +enable:为仓库启用 CI
|
||||
{
|
||||
Name: "enable",
|
||||
Description: "为仓库启用 CI",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/enable", ctx.RepoPath()), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +run-log:获取 CI 运行日志
|
||||
{
|
||||
Name: "run-log",
|
||||
Description: "获取 CI 运行日志",
|
||||
Flags: []common.Flag{
|
||||
{Name: "run", Short: "r", Usage: "Run ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
run, err := ctx.RequireArg("run")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/runs/%s/jobs/0", ctx.RepoPath(), run), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +run-results:获取 CI 运行结果报告
|
||||
{
|
||||
Name: "run-results",
|
||||
Description: "获取 CI 运行结果报告",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/pipelines/run_results", ctx.RepoPath()), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
package collaborator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// userRole 保存用户名和对应角色,支持 CSV 每行指定不同 role
|
||||
// 这是 collaborator 批量命令特有的需求:不同用户可能分配不同的权限
|
||||
// 而 --users + --role 方式所有用户同一个 role,CSV 方式每行可不同
|
||||
type userRole struct {
|
||||
User string
|
||||
Role string
|
||||
}
|
||||
|
||||
func newBatchAddShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-add:批量添加多名协作者
|
||||
Name: "batch-add",
|
||||
Description: "批量添加多名协作者",
|
||||
Long: `Add multiple collaborators to the repository in a single operation.
|
||||
|
||||
Provide collaborators via --users (comma-separated usernames) or --from (CSV
|
||||
file with user_id and role columns). When --from supplies a role column that
|
||||
value is used; otherwise the --role flag (default: write) applies.
|
||||
|
||||
Use --dry-run to preview which collaborators would be added without making
|
||||
any changes.
|
||||
|
||||
Available roles: admin, write, read.`,
|
||||
Example: ` # Add two collaborators with the default write role
|
||||
gitlink collaborator +batch-add --users zhangsan,lisi
|
||||
|
||||
# Add collaborators with admin role
|
||||
gitlink collaborator +batch-add --users zhangsan,lisi --role admin
|
||||
|
||||
# Dry-run to preview additions from a CSV file
|
||||
gitlink collaborator +batch-add --from collaborators.csv --dry-run
|
||||
|
||||
# Combine both sources
|
||||
gitlink collaborator +batch-add --users zhangsan --from collaborators.csv`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Short: "u", Usage: "Comma-separated usernames to add"},
|
||||
{Name: "from", Usage: "CSV file path (columns: user_id, role)"},
|
||||
{Name: "role", Short: "r", Usage: "Default role for --users: admin, write, read", Default: "write"},
|
||||
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchAdd,
|
||||
}
|
||||
}
|
||||
|
||||
func newBatchRemoveShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-remove:批量移除多名协作者
|
||||
Name: "batch-remove",
|
||||
Description: "批量移除多名协作者",
|
||||
Long: `Remove multiple collaborators from the repository in a single operation.
|
||||
|
||||
Provide collaborators via --users (comma-separated usernames) or --from (CSV
|
||||
file with a user_id column).
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before removal
|
||||
unless --yes is set.
|
||||
|
||||
Use --dry-run to preview which collaborators would be removed without making
|
||||
any changes.`,
|
||||
Example: ` # Remove two collaborators
|
||||
gitlink collaborator +batch-remove --users zhangsan,lisi
|
||||
|
||||
# Dry-run to preview removals
|
||||
gitlink collaborator +batch-remove --users zhangsan,lisi --dry-run
|
||||
|
||||
# Remove collaborators listed in a CSV file
|
||||
gitlink collaborator +batch-remove --from users.csv`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Short: "u", Usage: "Comma-separated usernames to remove"},
|
||||
{Name: "from", Usage: "CSV file path (column: user_id)"},
|
||||
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchRemove,
|
||||
}
|
||||
}
|
||||
|
||||
func newBatchRoleShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-role:批量修改多名协作者的角色
|
||||
Name: "batch-role",
|
||||
Description: "批量修改多名协作者的角色",
|
||||
Long: `Change the role of multiple collaborators in a single operation.
|
||||
|
||||
Provide collaborators via --users (comma-separated usernames) combined with
|
||||
--role, or via --from (CSV file with user_id and role columns).
|
||||
|
||||
Use --dry-run to preview which role changes would be applied without making
|
||||
any changes.
|
||||
|
||||
Available roles: admin, write, read.`,
|
||||
Example: ` # Change two collaborators to admin
|
||||
gitlink collaborator +batch-role --users zhangsan,lisi --role admin
|
||||
|
||||
# Dry-run to preview role changes from a CSV file
|
||||
gitlink collaborator +batch-role --from roles.csv --dry-run
|
||||
|
||||
# Combine both sources
|
||||
gitlink collaborator +batch-role --users zhangsan --role read --from roles.csv`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Short: "u", Usage: "Comma-separated usernames"},
|
||||
{Name: "from", Usage: "CSV file path (columns: user_id, role)"},
|
||||
{Name: "role", Short: "r", Usage: "New role for --users: admin, write, read"},
|
||||
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchRole,
|
||||
}
|
||||
}
|
||||
|
||||
// --- batch-add ---
|
||||
|
||||
// [B 类批量] 逐个添加协作者——POST /collaborators
|
||||
// 支持 --users + --role 统一角色 或 --from CSV 每行不同角色
|
||||
func runBatchAdd(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pairs, err := collectUserRoles(ctx.Arg("users"), ctx.Arg("from"), ctx.Arg("role"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
return fmt.Errorf("no users provided; use --users user1,user2 or --from collaborators.csv")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
items := make([]string, 0, len(pairs))
|
||||
for _, p := range pairs {
|
||||
items = append(items, p.User)
|
||||
}
|
||||
|
||||
summary := common.ProcessBatch(items, dryRun, "add", func(user string) error {
|
||||
role := roleForUser(pairs, user)
|
||||
payload := map[string]interface{}{
|
||||
"user": map[string]string{
|
||||
"user_id": user,
|
||||
"role_name": role,
|
||||
},
|
||||
}
|
||||
_, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d collaborator(s) failed to add", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- batch-remove ---
|
||||
|
||||
// [B 类批量] 逐个移除协作者——DELETE /collaborators/remove(不可逆)
|
||||
func runBatchRemove(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
users, err := collectUsers(ctx.Arg("users"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return fmt.Errorf("no users provided; use --users user1,user2 or --from users.csv")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
if !dryRun {
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("remove %d collaborator(s) from %s/%s", len(users), ctx.Owner, ctx.Repo),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
summary := common.ProcessBatch(users, dryRun, "remove", func(user string) error {
|
||||
payload := map[string]interface{}{
|
||||
"user": map[string]string{
|
||||
"user_id": user,
|
||||
},
|
||||
}
|
||||
_, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", ctx.Owner, ctx.Repo), payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d collaborator(s) failed to remove", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- batch-role ---
|
||||
|
||||
// [B 类批量] 逐个修改协作者角色——PUT /collaborators/change_role
|
||||
func runBatchRole(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usersValue := ctx.Arg("users")
|
||||
fromValue := ctx.Arg("from")
|
||||
roleValue := ctx.Arg("role")
|
||||
|
||||
// When --users is used without --from, --role is required.
|
||||
if usersValue != "" && fromValue == "" && roleValue == "" {
|
||||
return fmt.Errorf("--role is required when using --users without --from")
|
||||
}
|
||||
|
||||
pairs, err := collectUserRoles(usersValue, fromValue, roleValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
return fmt.Errorf("no users provided; use --users user1,user2 --role admin or --from roles.csv")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
items := make([]string, 0, len(pairs))
|
||||
for _, p := range pairs {
|
||||
items = append(items, p.User)
|
||||
}
|
||||
|
||||
// Resolve all logins to numeric ids once via the collaborators list.
|
||||
memberEnv, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
memberData, _ := memberEnv.Data.(map[string]interface{})
|
||||
members, _ := memberData["members"].([]interface{})
|
||||
|
||||
summary := common.ProcessBatch(items, dryRun, "change-role", func(user string) error {
|
||||
role := roleForUser(pairs, user)
|
||||
id, found := findUserIDByLogin(members, user)
|
||||
if !found {
|
||||
return fmt.Errorf("collaborator %q not found", user)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"user_id": id,
|
||||
"role": mapRole(role),
|
||||
}
|
||||
_, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/collaborators/change_role", ctx.Owner, ctx.Repo), payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d collaborator(s) failed to change role", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// collectUserRoles 收集用户-角色对(支持 --users + --role 和 --from CSV 两种输入)
|
||||
// 与 CollectStrings 不同,此函数需要同时处理 {用户, 角色} 成对数据
|
||||
// --from CSV 要求有 user_id 和 role 两列,每行可指定不同角色
|
||||
func collectUserRoles(usersValue, fromPath, defaultRole string) ([]userRole, error) {
|
||||
var pairs []userRole
|
||||
|
||||
// From --users flag
|
||||
for _, u := range common.ParseStringList(usersValue) {
|
||||
pairs = append(pairs, userRole{User: u, Role: defaultRole})
|
||||
}
|
||||
|
||||
// From CSV
|
||||
if fromPath != "" {
|
||||
rows, err := common.ReadRowsFromCSV(fromPath, []string{"user_id", "role"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
role := row["role"]
|
||||
if role == "" {
|
||||
role = defaultRole
|
||||
}
|
||||
pairs = append(pairs, userRole{User: row["user_id"], Role: role})
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by user
|
||||
seen := map[string]bool{}
|
||||
deduped := make([]userRole, 0, len(pairs))
|
||||
for _, p := range pairs {
|
||||
if seen[p.User] {
|
||||
continue
|
||||
}
|
||||
seen[p.User] = true
|
||||
deduped = append(deduped, p)
|
||||
}
|
||||
return deduped, nil
|
||||
}
|
||||
|
||||
// collectUsers 收集用户名(仅用于 batch-remove,不需要角色信息)
|
||||
func collectUsers(usersValue, fromPath string) ([]string, error) {
|
||||
users := common.ParseStringList(usersValue)
|
||||
|
||||
if fromPath != "" {
|
||||
csvUsers, err := common.ReadColumnFromCSV(fromPath, []string{"user_id"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, csvUsers...)
|
||||
}
|
||||
|
||||
return common.DedupeStrings(users), nil
|
||||
}
|
||||
|
||||
// roleForUser 在 userRole 列表中查找指定用户的角色(线性查找)
|
||||
func roleForUser(pairs []userRole, user string) string {
|
||||
for _, p := range pairs {
|
||||
if p.User == user {
|
||||
return p.Role
|
||||
}
|
||||
}
|
||||
return "write"
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
package collaborator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return append([]*common.Shortcut{
|
||||
// +list:列出仓库的协作者
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库协作者",
|
||||
Long: `List repository collaborators.
|
||||
|
||||
Shows all users who have access to the repository along with their
|
||||
username, role, and permission level.`,
|
||||
Example: ` # List collaborators of the current repository
|
||||
gitlink collaborator +list`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +add:向仓库添加一名协作者
|
||||
{
|
||||
Name: "add",
|
||||
Description: "添加仓库协作者",
|
||||
Long: `Add a collaborator to the repository.
|
||||
|
||||
Invites a user to collaborate on the repository with the specified role.
|
||||
Available roles: admin, write (default), read.`,
|
||||
Example: ` # Add a collaborator with write access
|
||||
gitlink collaborator +add -u zhangsan -r write
|
||||
|
||||
# Add a collaborator with admin access
|
||||
gitlink collaborator +add -u zhangsan -r admin`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "Username to add", Required: true},
|
||||
{Name: "role", Short: "r", Usage: "Role: admin or write or read", Default: "write"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role := ctx.Arg("role")
|
||||
payload := map[string]interface{}{
|
||||
"user": map[string]string{
|
||||
"user_id": user,
|
||||
"role_name": role,
|
||||
},
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +remove:从仓库移除一名协作者
|
||||
{
|
||||
Name: "remove",
|
||||
Description: "移除仓库协作者",
|
||||
Long: `Remove a collaborator from the repository.
|
||||
|
||||
Revokes the user's access to the repository.`,
|
||||
Example: ` # Remove a collaborator
|
||||
gitlink collaborator +remove -u zhangsan`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "Username to remove", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"user": map[string]string{
|
||||
"user_id": user,
|
||||
},
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +change-role:修改协作者的角色
|
||||
{
|
||||
Name: "change-role",
|
||||
Description: "修改协作者角色",
|
||||
Long: `Change a collaborator's role.
|
||||
|
||||
Updates the permission level of an existing collaborator.
|
||||
Available roles: admin, write, read.`,
|
||||
Example: ` # Change a collaborator to admin role
|
||||
gitlink collaborator +change-role -u zhangsan -r admin
|
||||
|
||||
# Change a collaborator to read-only access
|
||||
gitlink collaborator +change-role -u zhangsan -r read`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "Username", Required: true},
|
||||
{Name: "role", Short: "r", Usage: "New role: admin or write or read", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := ctx.RequireArg("user")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role, err := ctx.RequireArg("role")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// forgeplus change_role expects top-level {user_id: <int>, role: "Manager"}.
|
||||
// Resolve the username to its numeric id via the collaborators list.
|
||||
userID, err := resolveUserID(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"role": mapRole(role),
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/collaborators/change_role", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
},
|
||||
// +batch-add:批量添加多名协作者
|
||||
newBatchAddShortcut(),
|
||||
// +batch-remove:批量移除多名协作者
|
||||
newBatchRemoveShortcut(),
|
||||
// +batch-role:批量修改多名协作者的角色
|
||||
newBatchRoleShortcut(),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package collaborator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// mapRole maps friendly role names (admin/write/read) to forgeplus's role enum
|
||||
// (Manager/Developer/Reporter). Values already in enum form pass through
|
||||
// unchanged; matching is case-insensitive.
|
||||
func mapRole(r string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(r)) {
|
||||
case "admin", "manager":
|
||||
return "Manager"
|
||||
case "write", "developer":
|
||||
return "Developer"
|
||||
case "read", "reporter":
|
||||
return "Reporter"
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
// findUserIDByLogin scans the members list returned by GET /collaborators and
|
||||
// returns the numeric user_id for the given login. forgeplus's change_role
|
||||
// endpoint requires an integer id, but the CLI accepts a username (--user), so
|
||||
// the id must be resolved first.
|
||||
func findUserIDByLogin(members []interface{}, login string) (int, bool) {
|
||||
for _, m := range members {
|
||||
mm, ok := m.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
l, _ := mm["login"].(string)
|
||||
if l != login {
|
||||
continue
|
||||
}
|
||||
switch id := mm["id"].(type) {
|
||||
case float64: // JSON numbers decode to float64
|
||||
return int(id), true
|
||||
case float32:
|
||||
return int(id), true
|
||||
case int:
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// resolveUserID looks up a collaborator's numeric id by login name via the
|
||||
// collaborators list API. Required because change_role expects an integer id.
|
||||
func resolveUserID(ctx *common.RuntimeContext, login string) (int, error) {
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("cannot read collaborators list for %s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
members, _ := data["members"].([]interface{})
|
||||
id, found := findUserIDByLogin(members, login)
|
||||
if !found {
|
||||
return 0, fmt.Errorf("collaborator %q not found in %s/%s", login, ctx.Owner, ctx.Repo)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package collaborator
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestMapRole 验证友好角色名映射到 forgeplus 的 Manager/Developer/Reporter,
|
||||
// 且直接传入枚举值或大小写不同也能正确处理。
|
||||
func TestMapRole(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"admin", "Manager"},
|
||||
{"write", "Developer"},
|
||||
{"read", "Reporter"},
|
||||
{"Manager", "Manager"},
|
||||
{"ADMIN", "Manager"},
|
||||
{"developer", "Developer"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := mapRole(c.in); got != c.want {
|
||||
t.Errorf("mapRole(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindUserIDByLogin 验证从协作者列表按 login 解析数字 user_id。
|
||||
// change_role 接口的 user_id 要整数 ID,而 CLI --user 收的是用户名。
|
||||
func TestFindUserIDByLogin(t *testing.T) {
|
||||
members := []interface{}{
|
||||
map[string]interface{}{"login": "z2_cc", "id": float64(148913)},
|
||||
map[string]interface{}{"login": "wqer", "id": float64(148900)},
|
||||
}
|
||||
id, ok := findUserIDByLogin(members, "wqer")
|
||||
if !ok {
|
||||
t.Fatal("expected to find wqer")
|
||||
}
|
||||
if id != 148900 {
|
||||
t.Errorf("wqer id = %d, want 148900", id)
|
||||
}
|
||||
if _, ok := findUserIDByLogin(members, "ghost"); ok {
|
||||
t.Error("expected not found for missing user")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
package commit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库提交记录
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库提交记录",
|
||||
Long: `List repository commits.
|
||||
|
||||
Shows commit SHA, author, message, and date.
|
||||
Use --page and --limit for pagination.
|
||||
Filter by path, date range, or branch with respective flags.`,
|
||||
Example: ` # List recent commits
|
||||
gitlink commit +list
|
||||
|
||||
# List commits on a specific branch
|
||||
gitlink commit +list --sha develop
|
||||
|
||||
# List commits touching a specific file
|
||||
gitlink commit +list --path src/main.go`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Branch or SHA to list commits from"},
|
||||
{Name: "path", Short: "p", Usage: "Filter commits by file path"},
|
||||
{Name: "page", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "since", Usage: "Only commits after this date (ISO 8601)"},
|
||||
{Name: "until", Usage: "Only commits before this date (ISO 8601)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看提交详情(含变更文件)
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看提交详情(含变更文件)",
|
||||
Long: `View commit details including changed files.
|
||||
|
||||
Shows the full commit information and the list of files changed
|
||||
in the specified commit.`,
|
||||
Example: ` # View a commit
|
||||
gitlink commit +view --sha abc123def456`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +diff:查看提交的 diff 内容
|
||||
{
|
||||
Name: "diff",
|
||||
Description: "查看提交的 diff 内容",
|
||||
Long: `View the diff of a commit.
|
||||
|
||||
Returns the patch/diff content for the specified commit,
|
||||
showing exact line-level additions and deletions.`,
|
||||
Example: ` # View the diff of a commit
|
||||
gitlink commit +diff --sha abc123def456`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +blame:获取文件的逐行提交追溯信息
|
||||
{
|
||||
Name: "blame",
|
||||
Description: "获取文件的逐行提交追溯信息",
|
||||
Long: `Get blame information for a file.
|
||||
|
||||
Shows which commit and author last modified each line of a file.
|
||||
Use --page and --limit for pagination.`,
|
||||
Example: ` # Blame a file on the default branch
|
||||
gitlink commit +blame --path src/main.go
|
||||
|
||||
# Blame a file at a specific commit
|
||||
gitlink commit +blame --path src/main.go --sha abc123def`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path", Required: true},
|
||||
{Name: "sha", Short: "s", Usage: "Commit SHA or branch"},
|
||||
{Name: "page", 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
|
||||
}
|
||||
apiPath := fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +compare:比较两个引用(分支、标签或 SHA)
|
||||
{
|
||||
Name: "compare",
|
||||
Description: "比较两个引用(分支、标签或 SHA)",
|
||||
Long: `Compare two refs (branches, tags, or SHAs).
|
||||
|
||||
Returns the commits and file changes between a base and head ref.
|
||||
Both --base and --head are required.`,
|
||||
Example: ` # Compare two branches
|
||||
gitlink commit +compare --base main --head develop
|
||||
|
||||
# Compare two commit SHAs
|
||||
gitlink commit +compare --base abc123 --head def456`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "head", Short: "H", Usage: "Head ref", Required: true},
|
||||
{Name: "base", Short: "B", Usage: "Base ref", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
head, err := ctx.RequireArg("head")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base, err := ctx.RequireArg("base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/compare/%s...%s", ctx.Owner, ctx.Repo, base, head), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// --- Result structures 批量操作输出结构 ---
|
||||
|
||||
// BatchResult 记录批量操作中单个项目的处理结果
|
||||
type BatchResult struct {
|
||||
Item string `json:"item" yaml:"item"` // 项目标识(issue/PR 编号、分支名、用户名等)
|
||||
Action string `json:"action" yaml:"action"` // 操作类型(如 "close"、"delete"、"add")
|
||||
Status string `json:"status" yaml:"status"` // 状态:"planned"(dry-run)、"ok"(成功)、"failed"(失败)
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"` // 失败原因
|
||||
}
|
||||
|
||||
// BatchSummary 是所有批量命令的统一输出格式
|
||||
// 无论操作哪种资源,输出结构都一致,方便脚本处理
|
||||
type BatchSummary struct {
|
||||
Repository string `json:"repository" yaml:"repository"` // 仓库路径 owner/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"` // 失败数
|
||||
Results []BatchResult `json:"results" yaml:"results"` // 每个项目的详细结果
|
||||
}
|
||||
|
||||
// --- Batch processing loop 核心处理循环 ---
|
||||
|
||||
// ProcessBatch 是批处理的"发动机"——对每项执行操作,支持 dry-run,错误不中断
|
||||
// items: 待处理项列表(如 ["1","2","3"])
|
||||
// dryRun: true 时只展示计划,不执行任何 API 调用
|
||||
// action: 操作名称(如 "close"、"delete"),会填入 BatchResult.Action
|
||||
// fn: 业务逻辑函数(回调/控制反转模式),调用方定义"对单个项做什么操作"
|
||||
// 返回值: BatchSummary 包含每项详细结果
|
||||
|
||||
func ProcessBatch(items []string, dryRun bool, action string, fn func(string) error) *BatchSummary {
|
||||
summary := &BatchSummary{
|
||||
DryRun: dryRun,
|
||||
Total: len(items),
|
||||
Results: make([]BatchResult, 0, len(items)),
|
||||
}
|
||||
// 逐项处理:dry-run 模式只标记不执行;否则调用 fn 执行实际操作
|
||||
for _, item := range items {
|
||||
r := BatchResult{Item: item, Action: action}
|
||||
if dryRun {
|
||||
r.Status = "planned" // 预览模式:不调 API,仅标记
|
||||
summary.Succeeded++
|
||||
} else if err := fn(item); err != nil {
|
||||
r.Status = "failed" // 执行失败:记录错误,继续下一项
|
||||
r.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
r.Status = "ok" // 执行成功
|
||||
summary.Succeeded++
|
||||
}
|
||||
// 单项失败不中断循环——所有项都会处理完,结果全在 summary 中
|
||||
summary.Results = append(summary.Results, r)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
// --- Input collection ---
|
||||
|
||||
// CollectNumbers 收集整数标识符(issue/PR 编号),支持两种输入方式合并去重
|
||||
// numbersValue: 来自 --numbers 参数(逗号分隔,如 "1,2,3")
|
||||
// csvPath: 来自 --from 参数(CSV 文件路径)
|
||||
// 两种方式可组合使用:CollectNumbers("1,2", "data.csv") → ["1","2","5","6"]
|
||||
func CollectNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||
numbers, err := ParseNumberList(numbersValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if csvPath == "" {
|
||||
return numbers, nil
|
||||
}
|
||||
csvNumbers, err := ReadColumnFromCSV(csvPath, []string{"number", "issue_number", "project_issues_index"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return DedupeStrings(append(numbers, csvNumbers...)), nil
|
||||
}
|
||||
|
||||
// CollectStrings 收集字符串标识符(分支名、用户名等),支持 --names 和 --from 文件合并去重
|
||||
// 与 CollectNumbers 不同,此类标识符不需要是整数
|
||||
// 文件格式:每行一个名称(纯文本,不是 CSV)
|
||||
func CollectStrings(namesValue, filePath string) ([]string, error) {
|
||||
names := ParseStringList(namesValue)
|
||||
if filePath == "" {
|
||||
return names, nil
|
||||
}
|
||||
fileNames, err := ReadLinesFromFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return DedupeStrings(append(names, fileNames...)), nil
|
||||
}
|
||||
|
||||
// --- Parsing helpers 解析辅助函数 ---
|
||||
|
||||
// ParseNumberList 解析逗号分隔数字列表 → 校验每个值都是整数 + 去重
|
||||
// 输入 "7,8,9" → ["7","8","9"];空字符串 → nil(合法空值)
|
||||
// 输入 "7,abc" → 报错 "invalid number \"abc\": must be an integer"
|
||||
func ParseNumberList(value string) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return NormalizeIntStrings(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
// ParseStringList 解析逗号分隔字符串列表,自动去除空格和空值
|
||||
// 输入 "branch1, branch2, " → ["branch1","branch2"]
|
||||
// 注意:此函数不做整数校验,适用于分支名/用户名等非数字标识符
|
||||
func ParseStringList(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- CSV / file reading ---
|
||||
|
||||
// ReadColumnFromCSV 从 CSV 文件中读取单列数据(支持表头自动检测)
|
||||
// columnNames: 可接受的列名列表,按优先级排序
|
||||
// 列名检测逻辑:依次匹配 number / issue_number / project_issues_index
|
||||
// 都不匹配则默认使用第一列;有表头从第2行读,无表头从第1行读
|
||||
func ReadColumnFromCSV(path string, columnNames []string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CSV %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CSV %s: %w", path, err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
column := -1
|
||||
startRow := 0
|
||||
for i, cell := range records[0] {
|
||||
for _, name := range columnNames {
|
||||
if strings.EqualFold(strings.TrimSpace(cell), name) {
|
||||
column = i
|
||||
startRow = 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if column >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if column < 0 {
|
||||
column = 0
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(records)-startRow)
|
||||
for _, record := range records[startRow:] {
|
||||
if column >= len(record) {
|
||||
continue
|
||||
}
|
||||
v := strings.TrimSpace(record[column])
|
||||
if v != "" {
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// ReadRowsFromCSV 从 CSV 文件读取多列数据,返回 [{col:val, ...}, ...]
|
||||
// 适用于需要同时读取多列的批量操作,如 collaborator 需要 user_id + role 两列
|
||||
// requiredCols: 需要的列名列表,函数会校验这些列是否都存在,不存在则报错
|
||||
func ReadRowsFromCSV(path string, requiredCols []string) ([]map[string]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CSV %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CSV %s: %w", path, err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 步骤1:从表头行构建列名→列索引的映射表
|
||||
header := records[0]
|
||||
colIndex := make(map[string]int)
|
||||
for i, cell := range header {
|
||||
normalized := strings.ToLower(strings.TrimSpace(cell))
|
||||
for _, req := range requiredCols {
|
||||
if normalized == req {
|
||||
colIndex[req] = i // 记录该列在第几列
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤2:校验所有必需的列是否都在表头中找到,缺列直接报错
|
||||
for _, req := range requiredCols {
|
||||
if _, ok := colIndex[req]; !ok {
|
||||
return nil, fmt.Errorf("CSV missing required column %q; found headers: %v", req, header)
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤3:逐行读取数据,按列名取值
|
||||
rows := make([]map[string]string, 0, len(records)-1)
|
||||
for _, record := range records[1:] { // 跳过表头行
|
||||
row := make(map[string]string, len(requiredCols))
|
||||
for _, req := range requiredCols {
|
||||
idx := colIndex[req]
|
||||
if idx < len(record) {
|
||||
row[req] = strings.TrimSpace(record[idx])
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ReadLinesFromFile 从纯文本文件逐行读取(非CSV),自动忽略空行
|
||||
// 用于支持 --from 参数传入分支名、用户名列表(每行一个)
|
||||
func ReadLinesFromFile(path string) ([]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file %s: %w", path, err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
|
||||
result := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Validation & deduplication ---
|
||||
|
||||
// NormalizeIntStrings 校验每个值都是合法整数 + 去重,批处理的输入"安检"
|
||||
// 输入 ["7","8","8","abc"] → 报错("abc" 不是整数)
|
||||
// 输入 ["7","8","8","9"] → ["7","8","9"](重复的 "8" 被去掉)
|
||||
func NormalizeIntStrings(values []string) ([]string, error) {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, v := range values {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue // 忽略空字符串
|
||||
}
|
||||
// 核心校验:strconv.ParseInt 尝试将字符串解析为 64 位整数
|
||||
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
|
||||
return nil, fmt.Errorf("invalid number %q: must be an integer", v)
|
||||
}
|
||||
if seen[v] {
|
||||
continue // 重复值跳过
|
||||
}
|
||||
seen[v] = true
|
||||
result = append(result, v)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DedupeStrings 字符串去重(保持原始顺序,保留第一个出现的)
|
||||
func DedupeStrings(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
if seen[v] {
|
||||
continue
|
||||
}
|
||||
seen[v] = true
|
||||
result = append(result, v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
|
||||
// ParseBool 解析布尔字符串,用于处理 --prerelease 等布尔参数
|
||||
func ParseBool(value string) bool {
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
)
|
||||
|
||||
// ConfirmAction 在破坏性操作前弹出确认提示,防止误操作
|
||||
// 检查流程: ① 全局 --yes 参数(用于自动化脚本,跳过确认)
|
||||
// ② 否则打印 "Are you sure... [y/N]:" 等待用户输入
|
||||
// ③ 只有 y/yes 才继续,其他全部取消
|
||||
// 覆盖 4 个单操作(repo/branch/release delete, ci stop)+ 4 个批量命令
|
||||
func ConfirmAction(action string) error {
|
||||
// 第①步:检查全局 --yes 标志,为 true 则跳过确认
|
||||
if cmdutil.AutoConfirm {
|
||||
return nil
|
||||
}
|
||||
// 第②步:打印提示并读取键盘输入
|
||||
fmt.Printf("Are you sure you want to %s? [y/N]: ", action)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("operation cancelled")
|
||||
}
|
||||
// 第③步:规范化输入,只接受 y/yes
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
if response != "y" && response != "yes" {
|
||||
return fmt.Errorf("operation cancelled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -34,6 +34,12 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
|
|||
return s.Run(ctx)
|
||||
},
|
||||
}
|
||||
if s.Long != "" {
|
||||
cmd.Long = s.Long
|
||||
}
|
||||
if s.Example != "" {
|
||||
cmd.Example = s.Example
|
||||
}
|
||||
|
||||
for _, f := range s.Flags {
|
||||
if f.Bool {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import (
|
|||
type Shortcut struct {
|
||||
Name string
|
||||
Description string
|
||||
Long string
|
||||
Example string
|
||||
Flags []Flag
|
||||
Run func(ctx *RuntimeContext) error
|
||||
}
|
||||
|
|
@ -117,3 +119,29 @@ func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
|
|||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// [P1 Bug#2 修复] 原代码在 branch +create 和 release +create 中硬编码 "master" 作为默认分支
|
||||
// 当仓库默认分支是 "main" 时会出错。此函数动态查询仓库的默认分支,失败时回退到 "master"
|
||||
// 设计模式:动态查询 + 静态兜底(graceful degradation)
|
||||
func GetDefaultBranch(ctx *RuntimeContext) string {
|
||||
if ctx.Owner == "" || ctx.Repo == "" {
|
||||
return "master" // owner/repo 未设置,直接回退
|
||||
}
|
||||
// 调用 GET /{owner}/{repo} API 获取仓库元信息
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return "master" // API 调用失败(网络错误等),回退
|
||||
}
|
||||
// 将返回数据断言为 map[string]interface{}(JSON 对象)
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return "master" // 返回格式异常,回退
|
||||
}
|
||||
// 依次尝试两种可能的字段名(GitLink API 有时返回 snake_case,有时 camelCase)
|
||||
for _, key := range []string{"default_branch", "defaultBranch"} {
|
||||
if db, ok := data[key].(string); ok && db != "" {
|
||||
return db // 成功获取默认分支名
|
||||
}
|
||||
}
|
||||
return "master" // 字段不存在或为空,回退
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
package dataset
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +create:在仓库中创建数据集
|
||||
{
|
||||
Name: "create",
|
||||
Description: "在仓库中创建数据集",
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Dataset title", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Dataset description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title, err := ctx.RequireArg("title")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"title": title,
|
||||
}
|
||||
if desc := ctx.Arg("description"); desc != "" {
|
||||
payload["description"] = desc
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新仓库中的数据集
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新仓库中的数据集",
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "New dataset title"},
|
||||
{Name: "description", Short: "d", Usage: "New dataset description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title := ctx.Arg("title")
|
||||
description := ctx.Arg("description")
|
||||
if title == "" && description == "" {
|
||||
return fmt.Errorf("at least one of --title or --description is required")
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if title != "" {
|
||||
payload["title"] = title
|
||||
}
|
||||
if description != "" {
|
||||
payload["description"] = description
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看仓库的数据集详情
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看仓库的数据集详情",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +list:列出项目数据集
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出项目数据集",
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||||
{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
|
||||
}
|
||||
path := fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo)
|
||||
q := buildQuery(ctx)
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildQuery(ctx *common.RuntimeContext) map[string][]string {
|
||||
q := map[string][]string{}
|
||||
if v := ctx.Arg("keyword"); v != "" {
|
||||
q["keyword"] = []string{v}
|
||||
}
|
||||
if v := ctx.Arg("page"); v != "" {
|
||||
q["page"] = []string{v}
|
||||
}
|
||||
if v := ctx.Arg("limit"); v != "" {
|
||||
q["limit"] = []string{v}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
|
@ -0,0 +1,490 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +get:获取仓库文件内容
|
||||
{
|
||||
Name: "get",
|
||||
Description: "获取仓库文件内容",
|
||||
Long: `Get file contents from the repository.
|
||||
|
||||
Retrieves the content and metadata of a file at the specified path.
|
||||
Optionally specify a branch, tag, or commit SHA with --ref.`,
|
||||
Example: ` # Get file contents on the default branch
|
||||
gitlink file +get --path README.md
|
||||
|
||||
# Get file contents on a specific branch
|
||||
gitlink file +get --path src/main.go --ref develop`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
|
||||
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// GitLink has no "get raw file content by path" endpoint. The
|
||||
// legacy /files endpoint returned the whole repo listing, and
|
||||
// /v1/contents/{path} falls through to the web router (HTML).
|
||||
// Use /entries?filepath=<dir>&ref= to fetch the file's metadata
|
||||
// (name/path/sha/size); to read raw content, follow up with
|
||||
// `file +blob --sha <sha>`.
|
||||
dir := pathDir(filePath)
|
||||
base := pathBase(filePath)
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/%s/%s/entries", ctx.Owner, ctx.Repo), buildQuery(map[string]string{
|
||||
"filepath": dir,
|
||||
"ref": ctx.Arg("ref"),
|
||||
}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Pick the requested file out of the directory listing.
|
||||
if entry := findEntryByName(env, base); entry != nil {
|
||||
return ctx.OutputData(entry)
|
||||
}
|
||||
return fmt.Errorf("文件 %q 不存在", filePath)
|
||||
},
|
||||
},
|
||||
// +readme:获取仓库 README
|
||||
{
|
||||
Name: "readme",
|
||||
Description: "获取仓库 README",
|
||||
Long: `Get the repository README.
|
||||
|
||||
Fetches the README file of the current repository.`,
|
||||
Example: ` # Show the repository README
|
||||
gitlink file +readme`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/readme", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:在仓库中新建文件
|
||||
{
|
||||
Name: "create",
|
||||
Description: "在仓库中新建文件",
|
||||
Long: `Create a new file in the repository.
|
||||
|
||||
Creates a file at the given path with the provided content.
|
||||
The content is automatically base64-encoded before submission.
|
||||
A commit message is required.`,
|
||||
Example: ` # Create a new file
|
||||
gitlink file +create --path docs/guide.md --content "# Guide" --message "add guide"
|
||||
|
||||
# Create a file on a specific branch
|
||||
gitlink file +create --path config.yaml --content "key: value" --message "add config" --branch develop`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "File content (plain text, will be base64-encoded)", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch (defaults to default branch)"},
|
||||
{Name: "new-branch", Usage: "Create a new branch with this name (use when --branch is protected)"},
|
||||
},
|
||||
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
|
||||
}
|
||||
// The API requires either branch or new_branch; without one it
|
||||
// returns "branch和new_branch必须存在一个".
|
||||
branch := ctx.Arg("branch")
|
||||
newBranch := ctx.Arg("new-branch")
|
||||
if branch == "" && newBranch == "" {
|
||||
return fmt.Errorf("必须指定 --branch(已有分支)或 --new-branch(新建分支);目标分支为保护分支时请用 --new-branch")
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
"message": message,
|
||||
}
|
||||
if branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
if newBranch != "" {
|
||||
payload["new_branch"] = newBranch
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/create_file", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新仓库中的已有文件
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新仓库中的已有文件",
|
||||
Long: `Update an existing file in the repository.
|
||||
|
||||
Replaces the content of a file at the given path.
|
||||
The new content is automatically base64-encoded before submission.
|
||||
Provide --sha to detect conflicting mid-air edits.`,
|
||||
Example: ` # Update a file
|
||||
gitlink file +update --path docs/guide.md --content "# Updated Guide" --message "update guide"
|
||||
|
||||
# Update with conflict detection
|
||||
gitlink file +update --path config.yaml --content "key: new" --message "update config" --sha abc123`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "New file content (plain text, will be base64-encoded)", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch (defaults to default branch)"},
|
||||
{Name: "new-branch", Usage: "Create a new branch with this name (use when --branch is protected)"},
|
||||
{Name: "sha", Usage: "Original file SHA (required by the API for conflict detection)"},
|
||||
},
|
||||
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
|
||||
}
|
||||
// API requires sha (and branch or new_branch).
|
||||
sha := ctx.Arg("sha")
|
||||
branch := ctx.Arg("branch")
|
||||
newBranch := ctx.Arg("new-branch")
|
||||
if sha == "" {
|
||||
return fmt.Errorf("必须指定 --sha(原文件 SHA);可用 file +entries 获取")
|
||||
}
|
||||
if branch == "" && newBranch == "" {
|
||||
return fmt.Errorf("必须指定 --branch 或 --new-branch;目标分支为保护分支时请用 --new-branch")
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
"message": message,
|
||||
"sha": sha,
|
||||
}
|
||||
if branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
if newBranch != "" {
|
||||
payload["new_branch"] = newBranch
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/update_file", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除仓库中的文件
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除仓库中的文件",
|
||||
Long: `Delete a file from the repository.
|
||||
|
||||
Removes the file at the given path from the repository.
|
||||
A commit message is required.
|
||||
Provide --sha to detect conflicting mid-air edits.`,
|
||||
Example: ` # Delete a file
|
||||
gitlink file +delete --path docs/old.md --message "remove old docs"
|
||||
|
||||
# Delete with conflict detection
|
||||
gitlink file +delete --path config.yaml --message "remove config" --sha abc123`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path to delete", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch (defaults to default branch)"},
|
||||
{Name: "new-branch", Usage: "Create a new branch with this name (use when --branch is protected)"},
|
||||
{Name: "sha", Usage: "File SHA (required by the API for conflict detection)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := ctx.RequireArg("message")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// API requires sha (and branch or new_branch).
|
||||
sha := ctx.Arg("sha")
|
||||
branch := ctx.Arg("branch")
|
||||
newBranch := ctx.Arg("new-branch")
|
||||
if sha == "" {
|
||||
return fmt.Errorf("必须指定 --sha(文件 SHA);可用 file +entries 获取")
|
||||
}
|
||||
if branch == "" && newBranch == "" {
|
||||
return fmt.Errorf("必须指定 --branch 或 --new-branch;目标分支为保护分支时请用 --new-branch")
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"message": message,
|
||||
"sha": sha,
|
||||
}
|
||||
if branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
if newBranch != "" {
|
||||
payload["new_branch"] = newBranch
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/delete_file", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +tree:获取指定 SHA 的 git 树对象(顶层目录)
|
||||
// 注意:GitLink 的 /git/trees/:sha 端点不支持 recursive 参数(实测无论传
|
||||
// true/false 都报"Recursive不包含于列表中"),故只返回顶层条目。
|
||||
// 要遍历子目录,请用 file +entries --path <子目录> 逐层展开。
|
||||
{
|
||||
Name: "tree",
|
||||
Description: "获取指定 SHA 的 git 树对象(顶层目录)",
|
||||
Long: `Get the top-level git tree for a given SHA (branch, tag, or commit).
|
||||
|
||||
Returns the directory/file listing at the repository root for that ref.
|
||||
GitLink's /git/trees endpoint does not support recursive listing; to
|
||||
descend into a subdirectory use 'file +entries --path <dir>'.`,
|
||||
Example: ` # List top-level entries on master
|
||||
gitlink file +tree --sha master
|
||||
|
||||
# List entries at a specific commit
|
||||
gitlink file +tree --sha 7947d4dfd67bbf64265dd89465dfe9ce6a92bf83`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Branch name, tag, or commit SHA", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apiPath := fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha)
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +blob:根据 SHA 获取 git blob 内容
|
||||
{
|
||||
Name: "blob",
|
||||
Description: "根据 SHA 获取 git blob 内容",
|
||||
Long: `Get a git blob by SHA.
|
||||
|
||||
Returns the raw blob content for the specified SHA.`,
|
||||
Example: ` # Get a git blob
|
||||
gitlink file +blob --sha abc123def456`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Blob SHA", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/git/blobs/%s", ctx.Owner, ctx.Repo, sha), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +batch:在单次提交中批量创建或更新多个文件
|
||||
{
|
||||
Name: "batch",
|
||||
Description: "在单次提交中批量创建或更新多个文件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "files", Short: "f", Usage: "JSON array of file objects", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
filesStr, err := ctx.RequireArg("files")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := ctx.RequireArg("message")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var files []interface{}
|
||||
if err := json.Unmarshal([]byte(filesStr), &files); err != nil {
|
||||
return fmt.Errorf("invalid JSON for --files: %w", err)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"files": files,
|
||||
"message": message,
|
||||
}
|
||||
if branch := ctx.Arg("branch"); branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/contents/batch", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +entries:获取仓库目录列表
|
||||
{
|
||||
Name: "entries",
|
||||
Description: "获取仓库目录列表",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "Directory path"},
|
||||
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
apiPath := fmt.Sprintf("/%s/%s/entries", ctx.Owner, ctx.Repo)
|
||||
query := buildQuery(map[string]string{
|
||||
"path": ctx.Arg("path"),
|
||||
"ref": ctx.Arg("ref"),
|
||||
})
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +sub-entries:获取仓库子目录列表
|
||||
{
|
||||
Name: "sub-entries",
|
||||
Description: "获取仓库子目录列表",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "Directory path"},
|
||||
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
apiPath := fmt.Sprintf("/%s/%s/sub_entries", ctx.Owner, ctx.Repo)
|
||||
query := buildQuery(map[string]string{
|
||||
"path": ctx.Arg("path"),
|
||||
"ref": ctx.Arg("ref"),
|
||||
})
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// 注:原 +replace 命令已移除。其端点 /replace_file 返回"接口方法异常"
|
||||
//(端点废弃),且功能与 +create(新文件)/ +update(已有文件)完全重复。
|
||||
// 如需替换文件内容,请用 file +update。
|
||||
}
|
||||
}
|
||||
|
||||
func buildQuery(params map[string]string) map[string][]string {
|
||||
query := map[string][]string{}
|
||||
for k, v := range params {
|
||||
if v != "" {
|
||||
if v == "true" {
|
||||
query[k] = []string{"true"}
|
||||
} else {
|
||||
query[k] = []string{v}
|
||||
}
|
||||
}
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// pathDir returns the directory portion of a file path ("a/b/c.md" -> "a/b",
|
||||
// "c.md" -> ""). Unlike path.Dir it never returns "." for a bare filename.
|
||||
func pathDir(p string) string {
|
||||
dir := path.Dir(p)
|
||||
if dir == "." || dir == "/" {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// pathBase returns the final element of a path ("a/b/c.md" -> "c.md").
|
||||
func pathBase(p string) string {
|
||||
return path.Base(p)
|
||||
}
|
||||
|
||||
// findEntryByName scans an /entries envelope for an entry whose name matches.
|
||||
// Used by file +get to extract a single file's metadata from a directory listing.
|
||||
func findEntryByName(env *output.Envelope, name string) map[string]interface{} {
|
||||
if env == nil || env.Data == nil {
|
||||
return nil
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
entries, ok := data["entries"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
entry, ok := e.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if n, _ := entry["name"].(string); n == name {
|
||||
return entry
|
||||
}
|
||||
// Some payloads key the filename under "path".
|
||||
if p, _ := entry["path"].(string); p != "" && pathBase(p) == name {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// keep strings import used (pathBase fallback path uses path, but strings is
|
||||
// referenced implicitly nowhere — guard against unused import if helpers shrink).
|
||||
var _ = strings.TrimSpace
|
||||
|
|
@ -1,37 +1,95 @@
|
|||
package issue
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const closedIssueStatusID = 5
|
||||
|
||||
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"`
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared: resolve database IDs from user-facing issue numbers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// resolveIssueDBIDs 将用户可见的项目编号映射为数据库主键 ID
|
||||
// 用户输入的是 issue #1、#2(项目级别编号),但 GitLink 批量 API 需要数据库 ID(如 143372)
|
||||
// 流程:GET /issues 获取全量列表 → 构建 project_issues_index → id 映射表 → 按用户输入查找
|
||||
// 仅 A 类命令(batch-update/batch-destroy)需要此映射,B 类命令逐个调 API 时直接用项目编号
|
||||
func resolveIssueDBIDs(ctx *common.RuntimeContext, numbers []string) ([]interface{}, error) {
|
||||
q := url.Values{}
|
||||
q.Set("limit", "200")
|
||||
q.Set("page", "1")
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch issues for ID mapping: %w", err)
|
||||
}
|
||||
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected issues response format")
|
||||
}
|
||||
issues, ok := data["issues"].([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected issues list format")
|
||||
}
|
||||
|
||||
// 构建 project_issues_index → 数据库ID 的映射表
|
||||
// 例如: {"3": 143372, "4": 143373, "5": 143374}
|
||||
indexToID := make(map[string]interface{}, len(issues))
|
||||
for _, item := range issues {
|
||||
issue, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
idx, ok := issue["project_issues_index"] // 用户可见的编号(如 3)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, ok := issue["id"] // 数据库主键 ID(如 143372)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 统一转为字符串作为 key,确保数字类型匹配
|
||||
indexToID[fmt.Sprintf("%v", idx)] = id
|
||||
}
|
||||
|
||||
// 用户输入的编号 → 查找对应数据库 ID,找不到则报错
|
||||
dbIDs := make([]interface{}, 0, len(numbers))
|
||||
for _, n := range numbers {
|
||||
id, found := indexToID[n]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("issue #%s not found in repository", n)
|
||||
}
|
||||
dbIDs = append(dbIDs, id)
|
||||
}
|
||||
return dbIDs, nil
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch-close
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newBatchCloseShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-close:按疑修编号或 CSV 文件批量关闭多个疑修
|
||||
Name: "batch-close",
|
||||
Description: "Close multiple issues by issue numbers or a CSV file",
|
||||
Description: "按疑修编号或 CSV 文件批量关闭疑修",
|
||||
Long: `Close multiple issues in a single operation.
|
||||
|
||||
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
Use --dry-run to preview which issues would be closed without making changes.
|
||||
The CSV file may have a "number", "issue_number", or "project_issues_index"
|
||||
header column; otherwise the first column is used.`,
|
||||
Example: ` # Close issues 1, 2, and 3
|
||||
gitlink issue +batch-close --numbers 1,2,3
|
||||
|
||||
# Dry-run to preview
|
||||
gitlink issue +batch-close --numbers 1,2,3 --dry-run
|
||||
|
||||
# Close issues listed in a CSV file
|
||||
gitlink issue +batch-close --from issues.csv`,
|
||||
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"},
|
||||
|
|
@ -41,12 +99,14 @@ func newBatchCloseShortcut() *common.Shortcut {
|
|||
}
|
||||
}
|
||||
|
||||
// [B 类批量] 逐个关闭 issue——PATCH /issues/{n},每个请求独立
|
||||
// 不需要 ID 映射,单 issue API 接受项目编号而非数据库主键
|
||||
func runBatchClose(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -54,33 +114,11 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
|||
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchCloseSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]batchCloseResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := batchCloseResult{Number: number, Action: "close"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := closeIssue(ctx, number); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "closed"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
summary := common.ProcessBatch(numbers, dryRun, "close", func(number string) error {
|
||||
return closeIssue(ctx, number)
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
|
|
@ -92,121 +130,211 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
|||
}
|
||||
|
||||
func closeIssue(ctx *common.RuntimeContext, number string) error {
|
||||
// 先获取 issue 当前数据——PATCH API 要求提交 subject 和 description(不能为空)
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch issue: %w", err)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
"status_id": closedIssueStatusID,
|
||||
"subject": current.Subject, // 保留原标题
|
||||
"description": current.Description, // 保留原描述
|
||||
"status_id": closedIssueStatusID, // 5 = closed
|
||||
}
|
||||
// PATCH /issues/{number} 只修改 status_id,保留其他字段
|
||||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||||
return fmt.Errorf("close issue: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||
numbers, err := parseIssueNumbers(numbersValue)
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch-update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newBatchUpdateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-update:使用原生批量接口一次性更新多个疑修(支持 --dry-run 预览)
|
||||
Name: "batch-update",
|
||||
Description: "通过原生批量接口批量更新多个疑修",
|
||||
Long: `Update multiple issues in a single API call.
|
||||
|
||||
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
At least one update field (--state, --milestone, --assignee, --label, or --priority)
|
||||
must be specified. Use --dry-run to preview changes without applying them.`,
|
||||
Example: ` # Close multiple issues
|
||||
gitlink issue +batch-update --numbers 1,2,3 --state closed
|
||||
|
||||
# Set milestone for issues from a CSV file
|
||||
gitlink issue +batch-update --from issues.csv --milestone 5
|
||||
|
||||
# Assign issues to a user and add labels
|
||||
gitlink issue +batch-update --numbers 10,11,12 --assignee 3 --label 7,8
|
||||
|
||||
# Dry-run to preview
|
||||
gitlink issue +batch-update --numbers 1,2,3 --priority 1 --dry-run`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers (e.g. 1,2,3)"},
|
||||
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
||||
{Name: "state", Short: "s", Usage: "Set state: open, closed, or numeric status_id"},
|
||||
{Name: "milestone", Short: "m", Usage: "Set milestone ID"},
|
||||
{Name: "assignee", Short: "a", Usage: "Set assignee user ID"},
|
||||
{Name: "label", Short: "l", Usage: "Set label ID(s), comma-separated for multiple"},
|
||||
{Name: "priority", Short: "p", Usage: "Set priority ID"},
|
||||
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
// [A 类批量] 原生批量 API——一次 PATCH /issues/batch_update 请求操作多个 issue
|
||||
// 需要先通过 resolveIssueDBIDs 将项目编号转为数据库 ID
|
||||
func runBatchUpdate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
if csvPath == "" {
|
||||
return numbers, nil
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
||||
}
|
||||
|
||||
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
|
||||
state := ctx.Arg("state")
|
||||
milestone := ctx.Arg("milestone")
|
||||
assignee := ctx.Arg("assignee")
|
||||
label := ctx.Arg("label")
|
||||
priority := ctx.Arg("priority")
|
||||
|
||||
if state == "" && milestone == "" && assignee == "" && label == "" && priority == "" {
|
||||
return fmt.Errorf("at least one of --state, --milestone, --assignee, --label, or --priority is required")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
dbIDs, err := resolveIssueDBIDs(ctx, numbers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
return mergeIssueNumbers(numbers, csvNumbers), nil
|
||||
}
|
||||
|
||||
func parseIssueNumbers(value string) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
body := map[string]interface{}{
|
||||
"ids": dbIDs,
|
||||
}
|
||||
if state != "" {
|
||||
statusID, err := normalizeIssueStatus(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body["status_id"] = statusID
|
||||
}
|
||||
if milestone != "" {
|
||||
body["fixed_version_id"] = milestone
|
||||
}
|
||||
if assignee != "" {
|
||||
body["assigned_to_id"] = assignee
|
||||
}
|
||||
if label != "" {
|
||||
tagIDs := common.ParseStringList(label)
|
||||
body["issue_tag_ids"] = tagIDs
|
||||
}
|
||||
if priority != "" {
|
||||
body["priority_id"] = priority
|
||||
}
|
||||
return normalizeIssueNumbers(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
func readIssueNumbersFromCSV(path string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if dryRun {
|
||||
plan := map[string]interface{}{
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"dry_run": true,
|
||||
"issues": numbers,
|
||||
"updates": body,
|
||||
}
|
||||
return ctx.OutputData(plan)
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read issue numbers from CSV: %w", err)
|
||||
return fmt.Errorf("batch update: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch-destroy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newBatchDestroyShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-destroy:使用原生批量接口一次性永久删除多个疑修(支持 --dry-run 与确认提示)
|
||||
Name: "batch-destroy",
|
||||
Description: "通过原生批量接口批量删除多个疑修",
|
||||
Long: `Permanently delete multiple issues in a single API call.
|
||||
|
||||
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
This action is irreversible. Use --dry-run to preview which issues would be deleted.
|
||||
You will be prompted for confirmation unless --yes is set.`,
|
||||
Example: ` # Delete issues 7, 8, and 9
|
||||
gitlink issue +batch-destroy --numbers 7,8,9
|
||||
|
||||
# Dry-run to preview
|
||||
gitlink issue +batch-destroy --numbers 7,8,9 --dry-run
|
||||
|
||||
# Delete issues listed in a CSV file
|
||||
gitlink issue +batch-destroy --from old_issues.csv`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers (e.g. 1,2,3)"},
|
||||
{Name: "from", Usage: "Read issue numbers from a CSV file"},
|
||||
{Name: "dry-run", Usage: "Preview deletions without executing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchDestroy,
|
||||
}
|
||||
}
|
||||
|
||||
// [A 类批量] 原生批量 API——一次 DELETE /issues/batch_destroy 删除多个 issue(不可逆)
|
||||
// 执行前调用 ConfirmAction 弹确认框
|
||||
func runBatchDestroy(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse issue numbers from CSV: %w", err)
|
||||
return err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
||||
}
|
||||
|
||||
numberColumn := -1
|
||||
startRow := 0
|
||||
for i, cell := range records[0] {
|
||||
switch strings.ToLower(strings.TrimSpace(cell)) {
|
||||
case "number", "issue_number", "project_issues_index":
|
||||
numberColumn = i
|
||||
startRow = 1
|
||||
}
|
||||
}
|
||||
if numberColumn == -1 {
|
||||
numberColumn = 0
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
dbIDs, err := resolveIssueDBIDs(ctx, numbers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(records)-startRow)
|
||||
for _, record := range records[startRow:] {
|
||||
if numberColumn >= len(record) {
|
||||
continue
|
||||
if dryRun {
|
||||
plan := map[string]interface{}{
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"dry_run": true,
|
||||
"issues": numbers,
|
||||
"database_ids": dbIDs,
|
||||
}
|
||||
values = append(values, record[numberColumn])
|
||||
return ctx.OutputData(plan)
|
||||
}
|
||||
return normalizeIssueNumbers(values)
|
||||
}
|
||||
|
||||
func normalizeIssueNumbers(values []string) ([]string, error) {
|
||||
numbers := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
number := strings.TrimSpace(value)
|
||||
if number == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
|
||||
return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number)
|
||||
}
|
||||
if seen[number] {
|
||||
continue
|
||||
}
|
||||
seen[number] = true
|
||||
numbers = append(numbers, number)
|
||||
}
|
||||
return numbers, nil
|
||||
}
|
||||
|
||||
func mergeIssueNumbers(values ...[]string) []string {
|
||||
merged := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, numbers := range values {
|
||||
for _, number := range numbers {
|
||||
if seen[number] {
|
||||
continue
|
||||
}
|
||||
seen[number] = true
|
||||
merged = append(merged, number)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func parseBool(value string) bool {
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
|
||||
if err := common.ConfirmAction(fmt.Sprintf("batch delete %d issue(s)", len(numbers))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"ids": dbIDs,
|
||||
}
|
||||
// Use CallAPIWithQuery for DELETE since the body needs to be sent.
|
||||
// The client.Do method sends body for any method, so CallAPI works.
|
||||
env, err := ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch destroy: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,79 +5,82 @@ import (
|
|||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestParseIssueNumbers(t *testing.T) {
|
||||
got, err := parseIssueNumbers("1, 2,2, 3")
|
||||
func TestParseNumberList(t *testing.T) {
|
||||
got, err := common.ParseNumberList("1, 2,2, 3")
|
||||
if err != nil {
|
||||
t.Fatalf("parseIssueNumbers returned error: %v", err)
|
||||
t.Fatalf("ParseNumberList returned error: %v", err)
|
||||
}
|
||||
want := []string{"1", "2", "3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseIssueNumbers() = %#v, want %#v", got, want)
|
||||
t.Fatalf("ParseNumberList() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIssueNumbersRejectsInvalidNumber(t *testing.T) {
|
||||
if _, err := parseIssueNumbers("1,abc"); err == nil {
|
||||
t.Fatal("parseIssueNumbers() expected an error for a non-integer issue number")
|
||||
func TestParseNumberListRejectsInvalidNumber(t *testing.T) {
|
||||
if _, err := common.ParseNumberList("1,abc"); err == nil {
|
||||
t.Fatal("ParseNumberList() expected an error for a non-integer issue number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadIssueNumbersFromCSVWithHeader(t *testing.T) {
|
||||
func TestReadColumnFromCSVWithHeader(t *testing.T) {
|
||||
path := writeTempCSV(t, "title,number,state\nfirst,12,open\nsecond,13,open\n")
|
||||
got, err := readIssueNumbersFromCSV(path)
|
||||
got, err := common.ReadColumnFromCSV(path, []string{"number", "issue_number", "project_issues_index"})
|
||||
if err != nil {
|
||||
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
|
||||
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
|
||||
}
|
||||
want := []string{"12", "13"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
|
||||
t.Fatalf("ReadColumnFromCSV() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadIssueNumbersFromCSVWithProjectIssuesIndexHeader(t *testing.T) {
|
||||
func TestReadColumnFromCSVWithProjectIssuesIndexHeader(t *testing.T) {
|
||||
path := writeTempCSV(t, "title,project_issues_index,state\nfirst,12,open\nsecond,13,open\n")
|
||||
got, err := readIssueNumbersFromCSV(path)
|
||||
got, err := common.ReadColumnFromCSV(path, []string{"number", "issue_number", "project_issues_index"})
|
||||
if err != nil {
|
||||
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
|
||||
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
|
||||
}
|
||||
want := []string{"12", "13"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
|
||||
t.Fatalf("ReadColumnFromCSV() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadIssueNumbersFromCSVWithoutHeaderUsesFirstColumn(t *testing.T) {
|
||||
func TestReadColumnFromCSVWithoutHeaderUsesFirstColumn(t *testing.T) {
|
||||
path := writeTempCSV(t, "21,open\n22,closed\n21,duplicate\n")
|
||||
got, err := readIssueNumbersFromCSV(path)
|
||||
got, err := common.ReadColumnFromCSV(path, []string{"number", "issue_number", "project_issues_index"})
|
||||
if err != nil {
|
||||
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
|
||||
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
|
||||
}
|
||||
want := []string{"21", "22"}
|
||||
// ReadColumnFromCSV does not deduplicate; CollectNumbers handles that.
|
||||
want := []string{"21", "22", "21"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
|
||||
t.Fatalf("ReadColumnFromCSV() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectIssueNumbersMergesCLIAndCSV(t *testing.T) {
|
||||
func TestCollectNumbersMergesCLIAndCSV(t *testing.T) {
|
||||
path := writeTempCSV(t, "number\n2\n3\n")
|
||||
got, err := collectIssueNumbers("1,2", path)
|
||||
got, err := common.CollectNumbers("1,2", path)
|
||||
if err != nil {
|
||||
t.Fatalf("collectIssueNumbers returned error: %v", err)
|
||||
t.Fatalf("CollectNumbers returned error: %v", err)
|
||||
}
|
||||
want := []string{"1", "2", "3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("collectIssueNumbers() = %#v, want %#v", got, want)
|
||||
t.Fatalf("CollectNumbers() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBool(t *testing.T) {
|
||||
if !parseBool("true") {
|
||||
t.Fatal("parseBool(true) = false, want true")
|
||||
if !common.ParseBool("true") {
|
||||
t.Fatal("ParseBool(true) = false, want true")
|
||||
}
|
||||
if parseBool("") {
|
||||
t.Fatal("parseBool(empty) = true, want false")
|
||||
if common.ParseBool("") {
|
||||
t.Fatal("ParseBool(empty) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,13 +23,47 @@ type existingIssue struct {
|
|||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
newBatchUpdateShortcut(),
|
||||
newBatchDestroyShortcut(),
|
||||
// +list:列出当前仓库的疑修(支持按状态/里程碑/标签筛选)
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List issues",
|
||||
Description: "列出疑修",
|
||||
Long: `List issues in the current repository.
|
||||
|
||||
Shows issue number, title, status, and other details.
|
||||
Use --state to filter by status, --milestone, --assignee, --label,
|
||||
--keyword for additional filtering, and --page and --limit for pagination.`,
|
||||
Example: ` # List open issues (default)
|
||||
gitlink issue +list
|
||||
|
||||
# List closed issues
|
||||
gitlink issue +list --state closed
|
||||
|
||||
# Show 50 issues per page
|
||||
gitlink issue +list --limit 50
|
||||
|
||||
# Filter by milestone and assignee
|
||||
gitlink issue +list --milestone 3 --assignee 5
|
||||
|
||||
# Search by keyword
|
||||
gitlink issue +list -k "login bug"
|
||||
|
||||
# Sort by created date
|
||||
gitlink issue +list --sort created_on
|
||||
|
||||
# Sort by updated date descending
|
||||
gitlink issue +list --sort updated_on --sort-direction desc`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "milestone", Short: "m", Usage: "Filter by milestone ID"},
|
||||
{Name: "assignee", Short: "a", Usage: "Filter by assignee user ID"},
|
||||
{Name: "label", Usage: "Filter by label ID"},
|
||||
{Name: "keyword", Short: "k", Usage: "Search by keyword"},
|
||||
{Name: "sort", Usage: "Sort field (e.g. created_on, updated_on)"},
|
||||
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -41,6 +75,26 @@ func Shortcuts() []*common.Shortcut {
|
|||
if s := ctx.Arg("state"); s != "" {
|
||||
q.Set("state", s)
|
||||
}
|
||||
// [P2 参数补全] 以下 6 个查询参数原 CLI 未暴露,但 GitLink API 支持
|
||||
// CLI 参数名 → API 字段名的映射(两者经常不一致)
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
q.Set("fixed_version_id", m) // CLI "milestone" → API "fixed_version_id"
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
q.Set("assigned_to_id", a) // CLI "assignee" → API "assigned_to_id"
|
||||
}
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
q.Set("issue_tag_id", l) // CLI "label" → API "issue_tag_id"
|
||||
}
|
||||
if k := ctx.Arg("keyword"); k != "" {
|
||||
q.Set("keyword", k) // 关键词搜索(名称一致,无需映射)
|
||||
}
|
||||
if s := ctx.Arg("sort"); s != "" {
|
||||
q.Set("sort", s) // 排序字段(如 created_on, updated_on)
|
||||
}
|
||||
if d := ctx.Arg("sort-direction"); d != "" {
|
||||
q.Set("sort_direction", d) // 排序方向 asc/desc(实测 API 用 sort+sort_direction,不是 sort_by)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -49,9 +103,22 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:在当前仓库创建新疑修(需 --title,可附带描述/指派人/里程碑/标签)
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new issue",
|
||||
Description: "创建新疑修",
|
||||
Long: `Create a new issue in the current repository.
|
||||
|
||||
Requires a title (--title). You may optionally set a description,
|
||||
assignee, milestone, and label at creation time.`,
|
||||
Example: ` # Create an issue with just a title
|
||||
gitlink issue +create --title "Fix login bug"
|
||||
|
||||
# Create with description and assignee
|
||||
gitlink issue +create --title "Fix login bug" --body "Steps to reproduce..." --assignee zhangsan
|
||||
|
||||
# Create with milestone and label
|
||||
gitlink issue +create --title "New feature" --milestone 3 --label 5`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Issue description"},
|
||||
|
|
@ -77,10 +144,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
body["description"] = desc
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
body["assigned_to_id"] = a
|
||||
body["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
body["fixed_version_id"] = m
|
||||
body["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
|
||||
}
|
||||
// [P1 Bug#1 修复] 原代码只在 Flags 中定义了 label 参数,Run 函数里没有取值写入请求体
|
||||
// 导致用户传 --label 5 不报错,但创建的 issue 没有标签
|
||||
// 修复:从 ctx.Arg("label") 取值,映射到 API 字段名 "issue_tag_id"
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
body["issue_tag_id"] = l // CLI "label" → API "issue_tag_id"
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||||
if err != nil {
|
||||
|
|
@ -89,9 +162,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看指定疑修的详情(标题/描述/状态/指派人等)
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View issue details",
|
||||
Description: "查看疑修详情",
|
||||
Long: `View detailed information about a specific issue.
|
||||
|
||||
Displays the issue title, description, status, assignee,
|
||||
and other metadata. Use the issue number as shown in the web URL.`,
|
||||
Example: ` # View issue #42
|
||||
gitlink issue +view --number 42
|
||||
|
||||
# Short form
|
||||
gitlink issue +view -n 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
},
|
||||
|
|
@ -110,11 +193,26 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +close:关闭疑修(保留标题和描述,可选附上说明性评论)
|
||||
{
|
||||
Name: "close",
|
||||
Description: "Close an issue",
|
||||
Description: "关闭疑修",
|
||||
Long: `Close an existing issue by setting its status to closed.
|
||||
|
||||
The issue number is the one shown in the web URL (project-level index).
|
||||
This preserves the existing title and description.
|
||||
Use --comment to leave an explanation when closing.`,
|
||||
Example: ` # Close issue #42
|
||||
gitlink issue +close --number 42
|
||||
|
||||
# Short form
|
||||
gitlink issue +close -n 42
|
||||
|
||||
# Close with a comment
|
||||
gitlink issue +close -n 42 --comment "Fixed in commit abc123"`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "comment", Short: "c", Usage: "Comment to add when closing"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -138,17 +236,51 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// [P2 参数补全] 关闭(PATCH)和评论(POST journals)是两个独立 API,代码串行调用
|
||||
// 关闭 issue 本身是一个 PATCH 请求,评论是另一个 POST 请求到 journals 端点
|
||||
if comment := ctx.Arg("comment"); comment != "" {
|
||||
_, _ = ctx.CallAPI("POST",
|
||||
fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number),
|
||||
map[string]interface{}{"notes": comment})
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新疑修的字段(标题/描述/状态/里程碑/指派人/标签/优先级等)
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update an issue",
|
||||
Description: "更新疑修",
|
||||
Long: `Update the title, description, status, or other fields of an existing issue.
|
||||
|
||||
At least one of --title, --body, --state, --milestone, --assignee,
|
||||
--label, or --priority must be provided.
|
||||
The --state flag accepts "open", "closed", or a numeric status_id.`,
|
||||
Example: ` # Change the title
|
||||
gitlink issue +update --number 42 --title "Updated title"
|
||||
|
||||
# Change description
|
||||
gitlink issue +update -n 42 --body "New description"
|
||||
|
||||
# Reopen a closed issue
|
||||
gitlink issue +update -n 42 --state open
|
||||
|
||||
# Update title and close at the same time
|
||||
gitlink issue +update -n 42 --title "Fixed" --state closed
|
||||
|
||||
# Assign to a user and set milestone
|
||||
gitlink issue +update -n 42 --assignee 5 --milestone 3
|
||||
|
||||
# Set label and priority
|
||||
gitlink issue +update -n 42 --label 7 --priority 1`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "title", Short: "t", Usage: "New title"},
|
||||
{Name: "body", Short: "b", Usage: "New description"},
|
||||
{Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"},
|
||||
{Name: "milestone", Short: "m", Usage: "Milestone ID"},
|
||||
{Name: "assignee", Short: "a", Usage: "Assignee user ID"},
|
||||
{Name: "label", Usage: "Label ID"},
|
||||
{Name: "priority", Usage: "Priority ID"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -161,8 +293,13 @@ func Shortcuts() []*common.Shortcut {
|
|||
title := ctx.Arg("title")
|
||||
description := ctx.Arg("body")
|
||||
state := ctx.Arg("state")
|
||||
if title == "" && description == "" && state == "" {
|
||||
return fmt.Errorf("at least one of --title, --body, or --state is required")
|
||||
milestone := ctx.Arg("milestone")
|
||||
assignee := ctx.Arg("assignee")
|
||||
label := ctx.Arg("label")
|
||||
priority := ctx.Arg("priority")
|
||||
if title == "" && description == "" && state == "" &&
|
||||
milestone == "" && assignee == "" && label == "" && priority == "" {
|
||||
return fmt.Errorf("at least one of --title, --body, --state, --milestone, --assignee, --label, or --priority is required")
|
||||
}
|
||||
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
|
|
@ -170,6 +307,8 @@ func Shortcuts() []*common.Shortcut {
|
|||
return err
|
||||
}
|
||||
|
||||
// [P2 参数补全] 保留原值,只修改用户指定的字段
|
||||
// GitLink PATCH API 要求 subject 和 description 不能为空,所以必须先查出原值
|
||||
body := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
|
|
@ -187,6 +326,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
body["status_id"] = statusID
|
||||
}
|
||||
// [P2 参数补全] 以下 4 个参数原 CLI 未暴露
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
body["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
body["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
|
||||
}
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
body["issue_tag_id"] = l // CLI "label" → API "issue_tag_id"
|
||||
}
|
||||
if p := ctx.Arg("priority"); p != "" {
|
||||
body["priority_id"] = p // CLI "priority" → API "priority_id"
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -194,9 +346,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +comment:在指定疑修下添加一条评论(--number 和 --body 必填)
|
||||
{
|
||||
Name: "comment",
|
||||
Description: "Add a comment to an issue",
|
||||
Description: "在疑修下添加评论",
|
||||
Long: `Add a comment (journal entry) to an existing issue.
|
||||
|
||||
Both --number and --body are required.`,
|
||||
Example: ` # Comment on issue #42
|
||||
gitlink issue +comment --number 42 --body "This is now fixed."
|
||||
|
||||
# Short form
|
||||
gitlink issue +comment -n 42 -b "LGTM"`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||
|
|
@ -223,6 +384,206 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +comments:列出指定疑修下的评论与动态记录
|
||||
{
|
||||
Name: "comments",
|
||||
Description: "列出疑修的评论与动态",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update-comment:修改疑修下的某条评论内容
|
||||
{
|
||||
Name: "update-comment",
|
||||
Description: "修改疑修下的评论",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "id", Usage: "Comment journal ID", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "New comment body", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"notes": body,
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-comment:删除疑修下的某条评论
|
||||
{
|
||||
Name: "delete-comment",
|
||||
Description: "删除疑修下的评论",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "id", Usage: "Comment journal ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +reply-comment:列出某条评论的子回复
|
||||
{
|
||||
Name: "reply-comment",
|
||||
Description: "列出评论的子回复",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "id", Usage: "Parent comment journal ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/journals/%s/children_journals", v1RepoPath(ctx), number, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除指定疑修
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除疑修",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +batch-update:通过原生批量接口批量更新多个疑修(按数据库 ID)
|
||||
{
|
||||
Name: "batch-update",
|
||||
Description: "批量更新多个疑修",
|
||||
Flags: []common.Flag{
|
||||
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
|
||||
{Name: "state", Short: "s", Usage: "New state: open, closed"},
|
||||
{Name: "assignee", Short: "a", Usage: "Assignee login"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
idsStr, err := ctx.RequireArg("ids")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"ids": parseIDList(idsStr),
|
||||
}
|
||||
if s := ctx.Arg("state"); s != "" {
|
||||
statusID, err := normalizeIssueStatus(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body["status_id"] = statusID
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
body["assigned_to_id"] = a
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +batch-destroy:通过原生批量接口批量删除多个疑修(按数据库 ID)
|
||||
{
|
||||
Name: "batch-destroy",
|
||||
Description: "批量删除多个疑修",
|
||||
Flags: []common.Flag{
|
||||
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
idsStr, err := ctx.RequireArg("ids")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"ids": parseIDList(idsStr),
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -289,3 +650,19 @@ func normalizeIssueStatus(state string) (interface{}, error) {
|
|||
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
|
||||
}
|
||||
}
|
||||
|
||||
// parseIDList splits a comma-separated string into an int slice.
|
||||
func parseIDList(s string) []int {
|
||||
parts := strings.Split(s, ",")
|
||||
ids := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if id, err := strconv.Atoi(p); err == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,3 +186,182 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
|||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDelete(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42.json":
|
||||
writeJSON(t, w, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "delete", map[string]string{"number": "42"})
|
||||
if err != nil {
|
||||
t.Fatalf("delete shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueComments(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42/journals.json":
|
||||
if r.URL.Query().Get("page") != "2" || r.URL.Query().Get("limit") != "10" {
|
||||
t.Fatalf("unexpected query params: %v", r.URL.Query())
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"journals": []interface{}{
|
||||
map[string]interface{}{"id": 1, "notes": "first comment"},
|
||||
map[string]interface{}{"id": 2, "notes": "second comment"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "comments", map[string]string{
|
||||
"number": "42",
|
||||
"page": "2",
|
||||
"limit": "10",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("comments shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueUpdateComment(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99.json":
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, updatePayload)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "update-comment", map[string]string{
|
||||
"number": "42",
|
||||
"id": "99",
|
||||
"body": "updated comment text",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update-comment shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, updatePayload["notes"], "updated comment text")
|
||||
}
|
||||
|
||||
func TestIssueDeleteComment(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99.json":
|
||||
writeJSON(t, w, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "delete-comment", map[string]string{
|
||||
"number": "42",
|
||||
"id": "99",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete-comment shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueReplyComment(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99/children_journals.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"journals": []interface{}{
|
||||
map[string]interface{}{"id": 100, "notes": "reply"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "reply-comment", map[string]string{
|
||||
"number": "42",
|
||||
"id": "99",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reply-comment shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueBatchUpdate(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/batch_update.json":
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, updatePayload)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "batch-update", map[string]string{
|
||||
"ids": "1,2,3",
|
||||
"state": "closed",
|
||||
"assignee": "someone",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-update shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
ids, ok := updatePayload["ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected ids to be a slice, got %T", updatePayload["ids"])
|
||||
}
|
||||
assertEqual(t, len(ids), 3)
|
||||
assertEqual(t, ids[0], float64(1))
|
||||
assertEqual(t, ids[1], float64(2))
|
||||
assertEqual(t, ids[2], float64(3))
|
||||
assertEqual(t, updatePayload["status_id"], float64(5))
|
||||
assertEqual(t, updatePayload["assigned_to_id"], "someone")
|
||||
}
|
||||
|
||||
func TestIssueBatchDestroy(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/batch_destroy.json":
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, updatePayload)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runIssueShortcut(t, server, "batch-destroy", map[string]string{
|
||||
"ids": "10,20,30",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-destroy shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
ids, ok := updatePayload["ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected ids to be a slice, got %T", updatePayload["ids"])
|
||||
}
|
||||
assertEqual(t, len(ids), 3)
|
||||
assertEqual(t, ids[0], float64(10))
|
||||
assertEqual(t, ids[1], float64(20))
|
||||
assertEqual(t, ids[2], float64(30))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
package label
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的疑修标签
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的疑修标签",
|
||||
Long: `List repository issue labels (tags).
|
||||
|
||||
Returns all labels defined in the repository.`,
|
||||
Example: ` # List all labels
|
||||
gitlink label +list`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建疑修标签
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建疑修标签",
|
||||
Long: `Create an issue label.
|
||||
|
||||
Creates a new label with the given name.
|
||||
Optionally specify a color in hex format (without #).`,
|
||||
Example: ` # Create a label
|
||||
gitlink label +create --name bug
|
||||
|
||||
# Create a label with color
|
||||
gitlink label +create --name enhancement --color 00ff00`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Label name", Required: true},
|
||||
{Name: "color", Short: "c", Usage: "Label color (hex without #, e.g. ff0000)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
if color := ctx.Arg("color"); color != "" {
|
||||
payload["color"] = color
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新疑修标签
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新疑修标签",
|
||||
Long: `Update an issue label.
|
||||
|
||||
Updates the name and/or color of an existing label.
|
||||
At least one of --name or --color should be provided.`,
|
||||
Example: ` # Rename a label
|
||||
gitlink label +update --id 42 --name "new name"
|
||||
|
||||
# Change label color
|
||||
gitlink label +update --id 42 --color ff0000`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New label name"},
|
||||
{Name: "color", Short: "c", Usage: "New label color (hex without #)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if name := ctx.Arg("name"); name != "" {
|
||||
payload["name"] = name
|
||||
}
|
||||
if color := ctx.Arg("color"); color != "" {
|
||||
payload["color"] = color
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/%s/issue_tags/%s", ctx.Owner, ctx.Repo, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除疑修标签
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除疑修标签",
|
||||
Long: `Delete an issue label.
|
||||
|
||||
Permanently removes the label from the repository.`,
|
||||
Example: ` # Delete a label
|
||||
gitlink label +delete --id 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/issue_tags/%s", ctx.Owner, ctx.Repo, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +priorities:列出可用的疑修优先级
|
||||
{
|
||||
Name: "priorities",
|
||||
Description: "列出可用的疑修优先级",
|
||||
Long: `List available issue priorities.
|
||||
|
||||
Returns the priority levels that can be assigned to issues.`,
|
||||
Example: ` # List issue priorities
|
||||
gitlink label +priorities`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_priorities", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +statuses:列出可用的疑修状态
|
||||
{
|
||||
Name: "statuses",
|
||||
Description: "列出可用的疑修状态",
|
||||
Long: `List available issue statuses.
|
||||
|
||||
Returns the status values that issues can have
|
||||
(e.g., open, closed, in progress).`,
|
||||
Example: ` # List issue statuses
|
||||
gitlink label +statuses`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_statues", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +authors:列出可用的疑修作者
|
||||
{
|
||||
Name: "authors",
|
||||
Description: "列出可用的疑修作者",
|
||||
Long: `List available issue authors.
|
||||
|
||||
Returns the users who have authored issues in the repository.`,
|
||||
Example: ` # List issue authors
|
||||
gitlink label +authors`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_authors", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +assigners:列出可用的疑修复办人
|
||||
{
|
||||
Name: "assigners",
|
||||
Description: "列出可用的疑修复办人",
|
||||
Long: `List available issue assigners.
|
||||
|
||||
Returns the users who can be assigned to issues in the repository.`,
|
||||
Example: ` # List issue assigners
|
||||
gitlink label +assigners`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_assigners", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
package milestone
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有里程碑
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的所有里程碑",
|
||||
Long: `List repository milestones.
|
||||
|
||||
Displays all milestones for the repository with optional filtering by state.
|
||||
Supports pagination with --page and --limit flags.`,
|
||||
Example: ` # List open milestones
|
||||
gitlink milestone +list
|
||||
|
||||
# List closed milestones
|
||||
gitlink milestone +list -s closed
|
||||
|
||||
# List all milestones with pagination
|
||||
gitlink milestone +list -s all -p 2 -l 10`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "state", Short: "s", Usage: "Filter: open or closed or all", Default: "open"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建里程碑
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建里程碑",
|
||||
Long: `Create a milestone.
|
||||
|
||||
Creates a new milestone with the given title. Optionally add a description
|
||||
and due date.`,
|
||||
Example: ` # Create a basic milestone
|
||||
gitlink milestone +create -t "v1.0"
|
||||
|
||||
# Create a milestone with description and due date
|
||||
gitlink milestone +create -t "v2.0" -d "Second major release" --due-date 2025-12-31`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Milestone title", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Milestone description"},
|
||||
{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
title, err := ctx.RequireArg("title")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"name": title,
|
||||
}
|
||||
if desc := ctx.Arg("description"); desc != "" {
|
||||
payload["description"] = desc
|
||||
}
|
||||
if dueDate := ctx.Arg("due-date"); dueDate != "" {
|
||||
payload["effective_date"] = dueDate
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看里程碑的详细信息
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看里程碑的详细信息",
|
||||
Long: `View milestone details.
|
||||
|
||||
Shows detailed information about a specific milestone including its
|
||||
title, description, status, due date, and completion progress.`,
|
||||
Example: ` # View milestone details by ID
|
||||
gitlink milestone +view -i 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/milestones/%s", ctx.Owner, ctx.Repo, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新里程碑
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新里程碑",
|
||||
Long: `Update a milestone.
|
||||
|
||||
Modifies an existing milestone's title, description, due date, or status.
|
||||
Only the fields you specify will be changed.`,
|
||||
Example: ` # Update a milestone title
|
||||
gitlink milestone +update -i 42 -t "v1.0 Final"
|
||||
|
||||
# Update description and due date
|
||||
gitlink milestone +update -i 42 -d "Final release" --due-date 2025-06-30
|
||||
|
||||
# Close a milestone via update
|
||||
gitlink milestone +update -i 42 --status close`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
{Name: "title", Short: "t", Usage: "New title"},
|
||||
{Name: "description", Short: "d", Usage: "New description"},
|
||||
{Name: "due-date", Usage: "New due date (YYYY-MM-DD)"},
|
||||
{Name: "status", Usage: "Status: open or close"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if title := ctx.Arg("title"); title != "" {
|
||||
payload["name"] = title
|
||||
}
|
||||
if desc := ctx.Arg("description"); desc != "" {
|
||||
payload["description"] = desc
|
||||
}
|
||||
if dueDate := ctx.Arg("due-date"); dueDate != "" {
|
||||
payload["effective_date"] = dueDate
|
||||
}
|
||||
if status := ctx.Arg("status"); status != "" {
|
||||
payload["status"] = status
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/%s/milestones/%s", ctx.Owner, ctx.Repo, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除里程碑
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除里程碑",
|
||||
Long: `Delete a milestone.
|
||||
|
||||
Permanently removes a milestone from the repository. This action cannot be undone.`,
|
||||
Example: ` # Delete a milestone by ID
|
||||
gitlink milestone +delete -i 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/milestones/%s", ctx.Owner, ctx.Repo, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +close:关闭里程碑
|
||||
{
|
||||
Name: "close",
|
||||
Description: "关闭里程碑",
|
||||
Long: `Close a milestone.
|
||||
|
||||
Marks a milestone as closed. Closed milestones are still visible but
|
||||
can no longer receive new issues.`,
|
||||
Example: ` # Close a milestone by ID
|
||||
gitlink milestone +close -i 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"status": "close",
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/milestones/%s/update_status", ctx.Owner, ctx.Repo, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,19 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出组织
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List organizations",
|
||||
Description: "列出组织",
|
||||
Long: `List all organizations visible to the authenticated user.
|
||||
|
||||
Returns organization ID, name, and description for each entry.
|
||||
Use --page and --limit for pagination.`,
|
||||
Example: ` # List organizations
|
||||
gitlink org +list
|
||||
|
||||
# Show 50 organizations per page
|
||||
gitlink org +list --limit 50`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
|
|
@ -27,9 +37,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +info:查看组织详情
|
||||
{
|
||||
Name: "info",
|
||||
Description: "Show organization details",
|
||||
Description: "查看组织详情",
|
||||
Long: `Show detailed information about an organization.
|
||||
|
||||
Returns the full organization profile including name, description,
|
||||
member count, and repository list.`,
|
||||
Example: ` # Show details for an organization by ID
|
||||
gitlink org +info --id 123
|
||||
|
||||
# Show details for an organization by login
|
||||
gitlink org +info --id my-org`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
|
||||
},
|
||||
|
|
@ -42,9 +62,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +members:列出组织成员
|
||||
{
|
||||
Name: "members",
|
||||
Description: "List organization members",
|
||||
Description: "列出组织成员",
|
||||
Long: `List all members of an organization.
|
||||
|
||||
Shows each member's login name, role, and join date.
|
||||
Use --page and --limit for pagination.`,
|
||||
Example: ` # List members of an organization
|
||||
gitlink org +members --id 123
|
||||
|
||||
# Show 50 members per page
|
||||
gitlink org +members --id 123 --limit 50`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
|
|
@ -62,9 +92,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建组织
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create an organization",
|
||||
Description: "创建组织",
|
||||
Long: `Create a new organization on GitLink.
|
||||
|
||||
Requires a name for the organization. An optional description
|
||||
can be provided with --description.`,
|
||||
Example: ` # Create an organization
|
||||
gitlink org +create --name my-org
|
||||
|
||||
# Create with a description
|
||||
gitlink org +create --name my-org --description "My team organization"`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Organization name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Description"},
|
||||
|
|
@ -84,5 +124,41 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +add-team-projects:为团队添加全部项目
|
||||
{
|
||||
Name: "add-team-projects",
|
||||
Description: "为团队添加全部项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization login", Required: true},
|
||||
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org")
|
||||
team, _ := ctx.RequireArg("team")
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams/%s/team_projects/create_all", org, team), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +remove-team-projects:移除团队的全部项目
|
||||
{
|
||||
Name: "remove-team-projects",
|
||||
Description: "移除团队的全部项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization login", Required: true},
|
||||
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
org, _ := ctx.RequireArg("org")
|
||||
team, _ := ctx.RequireArg("team")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s/team_projects/destroy_all", org, team), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
package pr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newBatchCloseShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-close:按编号或 CSV 文件批量关闭多个合并请求(支持 --dry-run 预览)
|
||||
Name: "batch-close",
|
||||
Description: "批量关闭多个合并请求",
|
||||
Long: `Close multiple pull requests in a single operation.
|
||||
|
||||
Provide PR numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
Use --dry-run to preview which pull requests would be closed without
|
||||
making changes.
|
||||
|
||||
The CSV file may have a "number", "issue_number", or "project_issues_index"
|
||||
header column; otherwise the first column is used.`,
|
||||
Example: ` # Close pull requests 1, 2, and 3
|
||||
gitlink pr +batch-close --numbers 1,2,3
|
||||
|
||||
# Dry-run to preview
|
||||
gitlink pr +batch-close --numbers 1,2,3 --dry-run
|
||||
|
||||
# Close pull requests listed in a CSV file
|
||||
gitlink pr +batch-close --from prs.csv`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated PR numbers, e.g. 1,2,3"},
|
||||
{Name: "from", Usage: "Read PR 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 pull requests that would be closed without changing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchClose,
|
||||
}
|
||||
}
|
||||
|
||||
// [B 类批量] 逐个关闭 PR——POST /pulls/{n}/refuse_merge,每个请求独立
|
||||
func runBatchClose(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("no PR numbers provided; use --numbers 1,2,3 or --from prs.csv")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
summary := common.ProcessBatch(numbers, dryRun, "close", func(number string) error {
|
||||
_, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), number), nil)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d pull request(s) failed to close", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newBatchMergeShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-merge:按编号或 CSV 文件批量合并多个合并请求(不可逆,默认需确认)
|
||||
Name: "batch-merge",
|
||||
Description: "批量合并多个合并请求",
|
||||
Long: `Merge multiple pull requests in a single operation.
|
||||
|
||||
Provide PR numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
Use --method to choose the merge strategy: merge (default), rebase, or squash.
|
||||
Use --dry-run to preview which pull requests would be merged without
|
||||
making changes.
|
||||
|
||||
WARNING: Merging is irreversible. A confirmation prompt is shown before
|
||||
execution (use --yes to auto-confirm).
|
||||
|
||||
The CSV file may have a "number", "issue_number", or "project_issues_index"
|
||||
header column; otherwise the first column is used.`,
|
||||
Example: ` # Merge pull requests 1, 2, and 3 using the default method
|
||||
gitlink pr +batch-merge --numbers 1,2,3
|
||||
|
||||
# Squash-merge multiple pull requests
|
||||
gitlink pr +batch-merge --numbers 1,2,3 --method squash
|
||||
|
||||
# Rebase-merge pull requests listed in a CSV file
|
||||
gitlink pr +batch-merge --from prs.csv --method rebase
|
||||
|
||||
# Dry-run to preview
|
||||
gitlink pr +batch-merge --numbers 1,2,3 --dry-run
|
||||
|
||||
# Auto-confirm without prompt
|
||||
gitlink pr +batch-merge --numbers 1,2,3 --yes`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated PR numbers, e.g. 1,2,3"},
|
||||
{Name: "from", Usage: "Read PR numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
|
||||
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
|
||||
{Name: "dry-run", Usage: "Preview the pull requests that would be merged without changing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchMerge,
|
||||
}
|
||||
}
|
||||
|
||||
// [B 类批量] 逐个合并 PR——POST /pulls/{n}/pr_merge
|
||||
// 不可逆操作:执行前调用 ConfirmAction 确认(除非 --yes)
|
||||
func runBatchMerge(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
method := ctx.Arg("method")
|
||||
if method == "" {
|
||||
method = "merge" // 默认使用 merge 方式
|
||||
}
|
||||
// P2 补全的 validateMergeMethod:只允许 merge/rebase/squash 三种
|
||||
if err := validateMergeMethod(method); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("no PR numbers provided; use --numbers 1,2,3 or --from prs.csv")
|
||||
}
|
||||
|
||||
dryRun := common.ParseBool(ctx.Arg("dry-run"))
|
||||
|
||||
if dryRun {
|
||||
// dry-run 直接返回预览,不给 fn 传实际逻辑
|
||||
summary := common.ProcessBatch(numbers, true, "merge", nil)
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
return ctx.OutputData(summary)
|
||||
}
|
||||
|
||||
// Merging is irreversible — require explicit confirmation
|
||||
if err := common.ConfirmAction(fmt.Sprintf("batch merge %d pull requests", len(numbers))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 逐个调用 POST /pulls/{n}/pr_merge,每个请求带合并方式参数
|
||||
summary := common.ProcessBatch(numbers, false, "merge", func(number string) error {
|
||||
payload := map[string]interface{}{
|
||||
"do": method, // merge/rebase/squash
|
||||
}
|
||||
_, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), number), payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d of %d pull request(s) failed to merge", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package pr
|
|||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -10,15 +11,51 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
newBatchMergeShortcut(),
|
||||
// +list:列出当前仓库的合并请求(支持按状态/里程碑/标签/评审人筛选)
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List pull requests",
|
||||
Description: "列出合并请求",
|
||||
Long: `List pull requests in the current repository.
|
||||
|
||||
Shows PR number, title, status, author, and other details.
|
||||
Use --state to filter by status (open, merged, closed).
|
||||
Use --keyword, --milestone, --tag, --reviewer, --assignee for
|
||||
additional filtering, and --sort to control ordering.`,
|
||||
Example: ` # List open pull requests
|
||||
gitlink pr +list
|
||||
|
||||
# List merged pull requests
|
||||
gitlink pr +list --state merged
|
||||
|
||||
# List closed pull requests with pagination
|
||||
gitlink pr +list --state closed --page 2 --limit 10
|
||||
|
||||
# Search by keyword
|
||||
gitlink pr +list -k "feature"
|
||||
|
||||
# Filter by milestone and assignee
|
||||
gitlink pr +list --milestone 3 --assignee 5
|
||||
|
||||
# Sort by created date
|
||||
gitlink pr +list --sort created_on
|
||||
|
||||
# Sort by updated date descending
|
||||
gitlink pr +list --sort updated_on --sort-direction desc`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
|
||||
{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 {
|
||||
{Name: "keyword", Short: "k", Usage: "Search by keyword"},
|
||||
{Name: "milestone", Short: "m", Usage: "Filter by milestone ID"},
|
||||
{Name: "tag", Usage: "Filter by tag ID"},
|
||||
{Name: "reviewer", Usage: "Filter by reviewer ID"},
|
||||
{Name: "assignee", Short: "a", Usage: "Filter by assignee ID"},
|
||||
{Name: "sort", Usage: "Sort field (e.g. created_on, updated_on)"},
|
||||
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -28,6 +65,28 @@ func Shortcuts() []*common.Shortcut {
|
|||
if s := ctx.Arg("state"); s != "" {
|
||||
q.Set("state", s)
|
||||
}
|
||||
// [P2 参数补全] 以下 7 个查询参数原 CLI 未暴露
|
||||
if k := ctx.Arg("keyword"); k != "" {
|
||||
q.Set("keyword", k) // 关键词搜索
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
q.Set("version_id", m) // CLI "milestone" → API "version_id"(PR 的里程碑字段名和 issue 不同)
|
||||
}
|
||||
if t := ctx.Arg("tag"); t != "" {
|
||||
q.Set("issue_tag_id", t) // CLI "tag" → API "issue_tag_id"
|
||||
}
|
||||
if r := ctx.Arg("reviewer"); r != "" {
|
||||
q.Set("reviewer_id", r) // CLI "reviewer" → API "reviewer_id"
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
q.Set("assign_user_id", a) // CLI "assignee" → API "assign_user_id"
|
||||
}
|
||||
if s := ctx.Arg("sort"); s != "" {
|
||||
q.Set("sort", s) // 排序字段
|
||||
}
|
||||
if d := ctx.Arg("sort-direction"); d != "" {
|
||||
q.Set("sort_direction", d) // 排序方向
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -35,14 +94,36 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建新合并请求(需 --title 和 --head 源分支,目标分支默认仓库默认分支)
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a pull request",
|
||||
Description: "创建合并请求",
|
||||
Long: `Create a new pull request in the current repository.
|
||||
|
||||
Requires a title and source branch (--head). The target branch
|
||||
(--base) defaults to the repository default branch if not specified.
|
||||
Use --assignee, --milestone, --label, and --priority to set
|
||||
additional fields at creation time.`,
|
||||
Example: ` # Create a pull request
|
||||
gitlink pr +create --title "Add new feature" --head feature-branch
|
||||
|
||||
# Create a PR with description and target branch
|
||||
gitlink pr +create --title "Fix bug" --body "Fixes #123" --head fix-branch --base main
|
||||
|
||||
# Create with assignee and milestone
|
||||
gitlink pr +create --title "Feature" --head feat --assignee 5 --milestone 3
|
||||
|
||||
# Create with labels and priority
|
||||
gitlink pr +create --title "Feature" --head feat --label 7 --priority 1`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "PR title", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "PR description"},
|
||||
{Name: "head", Usage: "Source branch", Required: true},
|
||||
{Name: "base", Usage: "Target branch", Default: "master"},
|
||||
{Name: "base", Usage: "Target branch (default: repository default branch)"},
|
||||
{Name: "priority", Usage: "Priority ID"},
|
||||
{Name: "assignee", Short: "a", Usage: "Assignee user ID"},
|
||||
{Name: "milestone", Short: "m", Usage: "Milestone ID"},
|
||||
{Name: "label", Short: "l", Usage: "Label ID (comma-separated for multiple)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -51,8 +132,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
title, _ := ctx.RequireArg("title")
|
||||
head, _ := ctx.RequireArg("head")
|
||||
base := ctx.Arg("base")
|
||||
// [P1 Bug#2] 原代码: base = "master"(硬编码)
|
||||
// 修复: 使用 GetDefaultBranch() 动态查询仓库默认分支
|
||||
if base == "" {
|
||||
base = "master"
|
||||
base = common.GetDefaultBranch(ctx)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"title": title,
|
||||
|
|
@ -62,6 +145,32 @@ func Shortcuts() []*common.Shortcut {
|
|||
if b := ctx.Arg("body"); b != "" {
|
||||
payload["body"] = b
|
||||
}
|
||||
// [P2 参数补全] PR 创建时支持 priority、assignee、milestone、label
|
||||
if p := ctx.Arg("priority"); p != "" {
|
||||
payload["priority_id"] = p // CLI "priority" → API "priority_id"
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
payload["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
payload["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
|
||||
}
|
||||
// [P2 参数补全] PR 的标签需要数组格式 issue_tag_ids(和 issue 的单值 issue_tag_id 不同)
|
||||
// 支持逗号分隔多标签:--label "1,2,3" → [1, 2, 3]
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
if strings.Contains(l, ",") {
|
||||
// 多标签:拆分、去空格、转为 []interface{} 数组
|
||||
parts := strings.Split(l, ",")
|
||||
ids := make([]interface{}, len(parts))
|
||||
for i, p := range parts {
|
||||
ids[i] = strings.TrimSpace(p)
|
||||
}
|
||||
payload["issue_tag_ids"] = ids
|
||||
} else {
|
||||
// 单标签也用数组格式
|
||||
payload["issue_tag_ids"] = []interface{}{l}
|
||||
}
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -69,9 +178,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看指定合并请求的详情(标题/描述/状态/作者/分支等)
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View pull request details",
|
||||
Description: "查看合并请求详情",
|
||||
Long: `View detailed information about a specific pull request.
|
||||
|
||||
Displays PR title, description, status, author, branches,
|
||||
and other metadata.`,
|
||||
Example: ` # View details of pull request #42
|
||||
gitlink pr +view --id 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -87,12 +203,30 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +merge:合并合并请求(支持 merge/rebase/squash 三种方式,可自定义提交信息)
|
||||
{
|
||||
Name: "merge",
|
||||
Description: "Merge a pull request",
|
||||
Description: "合并合并请求",
|
||||
Long: `Merge a pull request using the specified merge method.
|
||||
|
||||
Supported merge methods: merge (default), rebase, squash.
|
||||
Use --title and --body to customize the merge commit message.`,
|
||||
Example: ` # Merge a pull request using the default method
|
||||
gitlink pr +merge --id 42
|
||||
|
||||
# Squash-merge a pull request
|
||||
gitlink pr +merge --id 42 --method squash
|
||||
|
||||
# Rebase-merge a pull request
|
||||
gitlink pr +merge --id 42 --method rebase
|
||||
|
||||
# Merge with custom commit title and description
|
||||
gitlink pr +merge --id 42 --title "Merge feature X" --body "Combines feature X into main"`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
|
||||
{Name: "title", Usage: "Merge commit title"},
|
||||
{Name: "body", Usage: "Merge commit description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -103,9 +237,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
if method == "" {
|
||||
method = "merge"
|
||||
}
|
||||
if err := validateMergeMethod(method); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"do": method,
|
||||
}
|
||||
if t := ctx.Arg("title"); t != "" {
|
||||
payload["title"] = t
|
||||
}
|
||||
if b := ctx.Arg("body"); b != "" {
|
||||
payload["body"] = b
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -113,9 +256,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +close:关闭合并请求(不合并,关闭后需重新开启才能合并)
|
||||
{
|
||||
Name: "close",
|
||||
Description: "Close a pull request",
|
||||
Description: "关闭合并请求",
|
||||
Long: `Close a pull request without merging.
|
||||
|
||||
The PR will be marked as closed and cannot be merged afterwards
|
||||
unless it is reopened.`,
|
||||
Example: ` # Close pull request #42
|
||||
gitlink pr +close --id 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -131,27 +281,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +files:列出合并请求中的变更文件(路径/变更类型/增删行数)
|
||||
{
|
||||
Name: "files",
|
||||
Description: "List changed files in a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "diff",
|
||||
Description: "Show diff for a pull request",
|
||||
Description: "列出合并请求的变更文件",
|
||||
Long: `List all files changed in a pull request.
|
||||
|
||||
Shows the file path, change type (added, modified, deleted),
|
||||
and the number of additions and deletions for each file.`,
|
||||
Example: ` # List files changed in pull request #42
|
||||
gitlink pr +files --id 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -167,9 +306,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +versions:列出合并请求的补丁集版本历史
|
||||
{
|
||||
Name: "versions",
|
||||
Description: "List pull request patchset versions",
|
||||
Description: "列出合并请求的补丁集版本",
|
||||
Long: `List all patchset versions of a pull request.
|
||||
|
||||
Each time a PR is updated with new commits, a new patchset version
|
||||
is created. This command shows the version history.`,
|
||||
Example: ` # List patchset versions for pull request #42
|
||||
gitlink pr +versions --id 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -188,9 +334,21 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// [P1 Bug#3 修复] 原代码中 +diff 和 +files 调用了同一个 API 端点,功能完全重复
|
||||
// 修复: 将 +diff 改名为 +version-diff,指向 PR 补丁集版本的差异 API
|
||||
// +version-diff:查看合并请求某补丁集版本的差异(可用 --file 过滤指定文件)
|
||||
// +files 仍然用于查看 PR 文件列表,两者功能区分开
|
||||
{
|
||||
Name: "version-diff",
|
||||
Description: "Show diff for a pull request patchset version",
|
||||
Description: "查看合并请求补丁集版本的差异",
|
||||
Long: `Show the diff for a specific patchset version of a pull request.
|
||||
|
||||
Use --file to filter the diff to a specific file path.`,
|
||||
Example: ` # Show diff for version 3 of pull request #42
|
||||
gitlink pr +version-diff --id 42 --version-id 3
|
||||
|
||||
# Show diff for a specific file in a patchset version
|
||||
gitlink pr +version-diff --id 42 --version-id 3 --file src/main.go`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true},
|
||||
|
|
@ -225,9 +383,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +reviews:列出合并请求的评审记录(可按评审状态筛选)
|
||||
{
|
||||
Name: "reviews",
|
||||
Description: "List pull request reviews",
|
||||
Description: "列出合并请求的评审",
|
||||
Long: `List all reviews submitted for a pull request.
|
||||
|
||||
Use --status to filter reviews by their status:
|
||||
common, approved, or rejected.`,
|
||||
Example: ` # List all reviews for pull request #42
|
||||
gitlink pr +reviews --id 42
|
||||
|
||||
# List only approved reviews
|
||||
gitlink pr +reviews --id 42 --status approved`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"},
|
||||
|
|
@ -254,9 +422,23 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +review:对合并请求提交评审(common/approved/rejected,支持 --dry-run 预览)
|
||||
{
|
||||
Name: "review",
|
||||
Description: "Create a pull request review",
|
||||
Description: "创建合并请求评审",
|
||||
Long: `Submit a review on a pull request.
|
||||
|
||||
Supported review statuses: common (comment), approved, rejected.
|
||||
Use --commit to attach the review to a specific commit.
|
||||
Use --dry-run to preview the review without submitting it.`,
|
||||
Example: ` # Submit an approving review
|
||||
gitlink pr +review --id 42 --status approved --content "Looks good!"
|
||||
|
||||
# Request changes on a PR
|
||||
gitlink pr +review --id 42 --status rejected --content "Needs fixes"
|
||||
|
||||
# Preview a review without creating it
|
||||
gitlink pr +review --id 42 --status common --content "Note" --dry-run`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"},
|
||||
|
|
@ -306,9 +488,218 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +comments:列出合并请求下的评审评论
|
||||
{
|
||||
Name: "comments",
|
||||
Description: "列出合并请求的评审评论",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/journals", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create-comment:在合并请求下创建一条评审评论(可附行号与文件路径)
|
||||
{
|
||||
Name: "create-comment",
|
||||
Description: "创建合并请求的评审评论",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||
{Name: "line", Short: "l", Usage: "Line number"},
|
||||
{Name: "path", Short: "p", Usage: "File path"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"notes": body,
|
||||
}
|
||||
if line := ctx.Arg("line"); line != "" {
|
||||
payload["line"] = line
|
||||
}
|
||||
if path := ctx.Arg("path"); path != "" {
|
||||
payload["path"] = path
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/journals", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update-comment:修改合并请求下的某条评审评论
|
||||
{
|
||||
Name: "update-comment",
|
||||
Description: "修改合并请求的评审评论",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "New comment body", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commentID, err := ctx.RequireArg("comment-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"notes": body,
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/journals/%s", prV1Path(ctx, id), commentID), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-comment:删除合并请求下的某条评审评论
|
||||
{
|
||||
Name: "delete-comment",
|
||||
Description: "删除合并请求的评审评论",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commentID, err := ctx.RequireArg("comment-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/journals/%s", prV1Path(ctx, id), commentID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +commits:列出合并请求中包含的提交记录
|
||||
{
|
||||
Name: "commits",
|
||||
Description: "列出合并请求的提交",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/commits", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +reopen:重新打开已关闭的合并请求
|
||||
{
|
||||
Name: "reopen",
|
||||
Description: "重新打开合并请求",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/reopen", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新合并请求的标题和/或描述
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新合并请求的标题或描述",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "title", Short: "t", Usage: "New title"},
|
||||
{Name: "body", Short: "b", Usage: "New description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := ctx.Arg("title")
|
||||
body := ctx.Arg("body")
|
||||
if title == "" && body == "" {
|
||||
return fmt.Errorf("at least one of --title or --body is required")
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if title != "" {
|
||||
payload["title"] = title
|
||||
}
|
||||
if body != "" {
|
||||
payload["body"] = body
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +comment:在合并请求下添加评论(作为关联疑修的日志条目发布)
|
||||
{
|
||||
Name: "comment",
|
||||
Description: "Add a comment to a pull request",
|
||||
Description: "在合并请求下添加评论",
|
||||
Long: `Add a comment to a pull request.
|
||||
|
||||
The comment is posted as a journal entry on the underlying issue
|
||||
associated with the pull request.`,
|
||||
Example: ` # Add a comment to pull request #42
|
||||
gitlink pr +comment --id 42 --body "This looks great, thanks!"`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||
|
|
@ -355,6 +746,17 @@ func validatePRReviewStatus(status string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// [P2 参数补全] 校验合并方式参数,只允许 merge/rebase/squash 三种合法值
|
||||
// 这是纯前端校验,在发送 API 请求之前拦截非法输入
|
||||
func validateMergeMethod(method string) error {
|
||||
switch method {
|
||||
case "merge", "rebase", "squash":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid --method %q: use merge, rebase, or squash", method)
|
||||
}
|
||||
}
|
||||
|
||||
func extractIssueID(env *output.Envelope) (int64, error) {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -263,6 +263,283 @@ func TestPRReviewRejectsInvalidStatus(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Task 2: PR Comments CRUD, Commits, Reopen, Update tests ---
|
||||
|
||||
func TestPRCommentsListsReviewComments(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls/13/journals.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(1),
|
||||
"journals": []map[string]interface{}{
|
||||
{
|
||||
"id": float64(501),
|
||||
"notes": "Looks good here",
|
||||
"line": float64(42),
|
||||
"path": "main.go",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "comments", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("comments shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals.json")
|
||||
}
|
||||
|
||||
func TestPRCreateCommentPostsJournal(t *testing.T) {
|
||||
var journalPayload map[string]interface{}
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/journals.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
journalPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(502),
|
||||
"notes": "nit: use camelCase",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "create-comment", map[string]string{
|
||||
"id": "13",
|
||||
"body": "nit: use camelCase",
|
||||
"line": "42",
|
||||
"path": "main.go",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-comment shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals.json")
|
||||
assertEqual(t, journalPayload["notes"], "nit: use camelCase")
|
||||
assertEqual(t, journalPayload["line"], float64(42))
|
||||
assertEqual(t, journalPayload["path"], "main.go")
|
||||
}
|
||||
|
||||
func TestPRCreateCommentRequiresBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when body is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "create-comment", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when body is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRUpdateCommentPutsJournal(t *testing.T) {
|
||||
var journalPayload map[string]interface{}
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" || r.URL.Path != "/v1/owner/repo/pulls/13/journals/501.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
journalPayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(501),
|
||||
"notes": "updated comment text",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "update-comment", map[string]string{
|
||||
"id": "13",
|
||||
"comment-id": "501",
|
||||
"body": "updated comment text",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update-comment shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals/501.json")
|
||||
assertEqual(t, journalPayload["notes"], "updated comment text")
|
||||
}
|
||||
|
||||
func TestPRUpdateCommentRequiresCommentID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when comment-id is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "update-comment", map[string]string{
|
||||
"id": "13",
|
||||
"body": "some text",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when comment-id is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRDeleteCommentDeletesJournal(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/pulls/13/journals/501.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"status": 0,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "delete-comment", map[string]string{
|
||||
"id": "13",
|
||||
"comment-id": "501",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete-comment shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals/501.json")
|
||||
}
|
||||
|
||||
func TestPRDeleteCommentRequiresCommentID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when comment-id is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "delete-comment", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when comment-id is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCommitsListsCommits(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/13/commits.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(2),
|
||||
"commits": []map[string]interface{}{
|
||||
{
|
||||
"sha": "abc123",
|
||||
"author": "dev1",
|
||||
},
|
||||
{
|
||||
"sha": "def456",
|
||||
"author": "dev2",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "commits", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("commits shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/pulls/13/commits.json")
|
||||
}
|
||||
|
||||
func TestPRReopenReopensPR(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/reopen.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(13),
|
||||
"status": "open",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "reopen", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reopen shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/reopen.json")
|
||||
}
|
||||
|
||||
func TestPRUpdateUpdatesTitle(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" || r.URL.Path != "/owner/repo/pulls/13.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
calledPath = r.URL.Path
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(13),
|
||||
"title": "new title",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "update", map[string]string{
|
||||
"id": "13",
|
||||
"title": "new title",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/pulls/13.json")
|
||||
assertEqual(t, updatePayload["title"], "new title")
|
||||
}
|
||||
|
||||
func TestPRUpdateUpdatesBody(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" || r.URL.Path != "/owner/repo/pulls/13.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
updatePayload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(13),
|
||||
"body": "new body",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "update", map[string]string{
|
||||
"id": "13",
|
||||
"body": "new body",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, updatePayload["body"], "new body")
|
||||
}
|
||||
|
||||
func TestPRUpdateRequiresTitleOrBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when both title and body are missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "update", map[string]string{
|
||||
"id": "13",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when both title and body are missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findPRShortcut(t, name)
|
||||
|
|
|
|||
|
|
@ -3,45 +3,84 @@ package shortcuts
|
|||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/attachment"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/collaborator"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/commit"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/dataset"
|
||||
"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/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/snippet"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/sshkey"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/tag"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/template"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/util"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
|
||||
)
|
||||
|
||||
// RegisterAll mounts all shortcut groups onto the root command.
|
||||
func RegisterAll(root *cobra.Command) {
|
||||
groups := map[string][]*common.Shortcut{
|
||||
"repo": repo.Shortcuts(),
|
||||
"issue": issue.Shortcuts(),
|
||||
"pr": pr.Shortcuts(),
|
||||
"release": release.Shortcuts(),
|
||||
"branch": branch.Shortcuts(),
|
||||
"org": org.Shortcuts(),
|
||||
"user": user.Shortcuts(),
|
||||
"search": search.Shortcuts(),
|
||||
"ci": ci.Shortcuts(),
|
||||
"webhook": webhook.Shortcuts(),
|
||||
"repo": repo.Shortcuts(),
|
||||
"issue": issue.Shortcuts(),
|
||||
"pr": pr.Shortcuts(),
|
||||
"release": release.Shortcuts(),
|
||||
"branch": branch.Shortcuts(),
|
||||
"org": org.Shortcuts(),
|
||||
"user": user.Shortcuts(),
|
||||
"search": search.Shortcuts(),
|
||||
"ci": ci.Shortcuts(),
|
||||
"webhook": webhook.Shortcuts(),
|
||||
"wiki": wiki.Shortcuts(),
|
||||
"snippet": snippet.Shortcuts(),
|
||||
"collaborator": collaborator.Shortcuts(),
|
||||
"tag": tag.Shortcuts(),
|
||||
"milestone": milestone.Shortcuts(),
|
||||
"file": file.Shortcuts(),
|
||||
"commit": commit.Shortcuts(),
|
||||
"label": label.Shortcuts(),
|
||||
"sshkey": sshkey.Shortcuts(),
|
||||
"util": util.Shortcuts(),
|
||||
"dataset": dataset.Shortcuts(),
|
||||
"template": template.Shortcuts(),
|
||||
"attachment": attachment.Shortcuts(),
|
||||
}
|
||||
|
||||
descriptions := map[string]string{
|
||||
"repo": "Repository operations",
|
||||
"issue": "Issue operations",
|
||||
"pr": "Pull request operations",
|
||||
"release": "Release operations",
|
||||
"branch": "Branch operations",
|
||||
"org": "Organization operations",
|
||||
"user": "User operations",
|
||||
"search": "Search operations",
|
||||
"ci": "CI/CD operations",
|
||||
"webhook": "Webhook operations",
|
||||
"repo": "Repository operations",
|
||||
"issue": "Issue operations",
|
||||
"pr": "Pull request operations",
|
||||
"release": "Release operations",
|
||||
"branch": "Branch operations",
|
||||
"org": "Organization operations",
|
||||
"user": "User operations",
|
||||
"search": "Search operations",
|
||||
"ci": "CI/CD operations",
|
||||
"webhook": "Webhook operations",
|
||||
"wiki": "Wiki operations",
|
||||
"snippet": "Code snippet management",
|
||||
"collaborator": "Collaborator management",
|
||||
"tag": "Git tag operations",
|
||||
"milestone": "Milestone management",
|
||||
"file": "File content operations",
|
||||
"commit": "Commit operations",
|
||||
"label": "Issue label management",
|
||||
"sshkey": "SSH key management",
|
||||
"util": "Utility operations (licenses, ignores, settings)",
|
||||
"dataset": "Dataset management",
|
||||
"template": "Project template management",
|
||||
"attachment": "File attachment operations",
|
||||
}
|
||||
|
||||
for name, shortcuts := range groups {
|
||||
|
|
@ -53,3 +92,62 @@ func RegisterAll(root *cobra.Command) {
|
|||
root.AddCommand(groupCmd)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllShortcuts 返回全部命令组注册表,供交互式模式读取。
|
||||
// 每个命令组(如 issue)包含若干子命令(如 +list / +create)。
|
||||
func GetAllShortcuts() map[string][]*common.Shortcut {
|
||||
return map[string][]*common.Shortcut{
|
||||
"repo": repo.Shortcuts(), // 仓库管理:列表/详情/创建/Fork/删除/更新/Star/Watch 等
|
||||
"issue": issue.Shortcuts(), // 疑修(Issue)管理:列表/创建/查看/关闭/更新/评论等
|
||||
"pr": pr.Shortcuts(), // 合并请求(Pull Request)管理:列表/创建/合并/评审/评论等
|
||||
"release": release.Shortcuts(), // 发行版(Release)管理:列表/创建/查看/删除/更新等
|
||||
"branch": branch.Shortcuts(), // 分支管理:列表/创建/删除/保护/切换默认分支等
|
||||
"org": org.Shortcuts(), // 组织(Organization)管理:信息/成员/项目等
|
||||
"user": user.Shortcuts(), // 用户管理:个人信息/关注/粉丝等
|
||||
"search": search.Shortcuts(), // 搜索:仓库/疑修/用户等全局搜索
|
||||
"ci": ci.Shortcuts(), // CI/CD 流水线:构建状态/触发/日志等
|
||||
"webhook": webhook.Shortcuts(), // Webhook 管理:列表/创建/更新/删除等
|
||||
"wiki": wiki.Shortcuts(), // Wiki 文档管理:页面/列表/编辑等
|
||||
"snippet": snippet.Shortcuts(), // 代码片段(Snippet)管理:创建/列表/查看/删除等
|
||||
"collaborator": collaborator.Shortcuts(), // 协作者(Collaborator)管理:添加/移除/权限等
|
||||
"tag": tag.Shortcuts(), // Git 标签管理:列表/创建/删除等
|
||||
"milestone": milestone.Shortcuts(), // 里程碑(Milestone)管理:列表/创建/更新等
|
||||
"file": file.Shortcuts(), // 文件内容操作:读取/创建/更新/删除等
|
||||
"commit": commit.Shortcuts(), // 提交(Commit)操作:列表/详情/对比等
|
||||
"label": label.Shortcuts(), // 疑修标签(Label)管理:列表/创建/更新等
|
||||
"sshkey": sshkey.Shortcuts(), // SSH 公钥管理:列表/添加/删除等
|
||||
"util": util.Shortcuts(), // 工具命令:开源许可证/忽略模板/设置等
|
||||
"dataset": dataset.Shortcuts(), // 数据集(Dataset)管理:列表/创建等
|
||||
"template": template.Shortcuts(), // 项目模板管理:列表/创建等
|
||||
"attachment": attachment.Shortcuts(), // 文件附件操作:上传/下载等
|
||||
}
|
||||
}
|
||||
|
||||
// GetDescriptions 返回每个命令组的中文描述,用于交互式命令面板展示。
|
||||
func GetDescriptions() map[string]string {
|
||||
return map[string]string{
|
||||
"repo": "仓库管理", // 仓库的增删改查、Star、Watch 等
|
||||
"issue": "疑修 (Issue) 管理", // 疑修的创建、查看、关闭、评论等
|
||||
"pr": "合并请求 (PR) 管理", // 合并请求的创建、合并、评审等
|
||||
"release": "发行版管理", // 版本发布的创建与管理
|
||||
"branch": "分支管理", // 分支的创建、删除、保护等
|
||||
"org": "组织管理", // 组织信息与成员管理
|
||||
"user": "用户管理", // 用户信息与社交关系
|
||||
"search": "全局搜索", // 仓库/疑修/用户搜索
|
||||
"ci": "CI/CD 流水线", // 持续集成与部署
|
||||
"webhook": "Webhook 管理", // Web 钩子配置
|
||||
"wiki": "Wiki 文档", // 项目文档页面管理
|
||||
"snippet": "代码片段管理", // 代码片段的创建与分享
|
||||
"collaborator": "协作者管理", // 仓库协作者与权限
|
||||
"tag": "Git 标签管理", // 版本标签操作
|
||||
"milestone": "里程碑管理", // 版本里程碑管理
|
||||
"file": "文件内容操作", // 仓库文件的读写
|
||||
"commit": "提交管理", // 提交记录查看
|
||||
"label": "疑修标签管理", // 标签的创建与管理
|
||||
"sshkey": "SSH 公钥管理", // SSH 公钥配置
|
||||
"util": "工具命令", // 许可证、忽略模板等工具
|
||||
"dataset": "数据集管理", // 数据集管理
|
||||
"template": "项目模板管理", // 项目模板管理
|
||||
"attachment": "文件附件操作", // 附件上传与下载
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,32 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有发行版
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List releases",
|
||||
Description: "列出仓库的所有发行版",
|
||||
Long: `List all releases in the current repository.
|
||||
|
||||
Shows release tag, name, prerelease status, and creation date.
|
||||
Use --page and --limit for pagination.
|
||||
Use --prerelease-only to show only pre-release versions.
|
||||
Use --latest to show only the most recent release.`,
|
||||
Example: ` # List releases
|
||||
gitlink release +list
|
||||
|
||||
# Show 50 releases per page
|
||||
gitlink release +list --limit 50
|
||||
|
||||
# Show only pre-release versions
|
||||
gitlink release +list --prerelease-only
|
||||
|
||||
# Show only the latest release
|
||||
gitlink release +list --latest`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "prerelease-only", Usage: "Show only pre-release versions", Bool: true, Default: "false"},
|
||||
{Name: "latest", Usage: "Show only the latest release", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -28,17 +48,38 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// [P2 参数补全] API 不支持"只看预发布版本"的过滤参数,改为客户端过滤
|
||||
// 拿到全量数据后遍历,只保留 prerelease=true 的条目
|
||||
if ctx.Arg("prerelease-only") == "true" {
|
||||
filterReleasesByPrerelease(env)
|
||||
}
|
||||
// [P2 参数补全] --latest: 拿到列表后只保留第一条(最新)
|
||||
if ctx.Arg("latest") == "true" {
|
||||
keepOnlyLatestRelease(env)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建发行版
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a release",
|
||||
Description: "创建发行版",
|
||||
Long: `Create a new release for the current repository.
|
||||
|
||||
A release is associated with a Git tag. If the tag does not exist,
|
||||
it will be created on the target branch. Use --body for release notes
|
||||
and --prerelease to mark it as a pre-release version.`,
|
||||
Example: ` # Create a release
|
||||
gitlink release +create --tag v1.0.0 --name "Version 1.0.0"
|
||||
|
||||
# Create a prerelease with notes targeting a specific branch
|
||||
gitlink release +create --tag v2.0.0-beta --name "v2.0 Beta" \
|
||||
--body "Beta release for testing" --target develop --prerelease true`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "tag", Short: "t", Usage: "Tag name", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Release name", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Release notes"},
|
||||
{Name: "target", Usage: "Target branch", Default: "master"},
|
||||
{Name: "target", Usage: "Target branch (default: repository default branch)"},
|
||||
{Name: "prerelease", Usage: "Mark as prerelease (true/false)", Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
|
|
@ -54,8 +95,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
if b := ctx.Arg("body"); b != "" {
|
||||
payload["body"] = b
|
||||
}
|
||||
// [P1 Bug#2] 原代码: payload["target_commitish"] = "master"(硬编码)
|
||||
// 修复: 用户未指定 --target 时,使用 GetDefaultBranch() 动态查询
|
||||
if t := ctx.Arg("target"); t != "" {
|
||||
payload["target_commitish"] = t
|
||||
payload["target_commitish"] = t // 用户显式指定了目标分支
|
||||
} else {
|
||||
payload["target_commitish"] = common.GetDefaultBranch(ctx) // 动态查询默认分支
|
||||
}
|
||||
if ctx.Arg("prerelease") == "true" {
|
||||
payload["prerelease"] = true
|
||||
|
|
@ -67,9 +112,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看发行版的详细信息
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View release details",
|
||||
Description: "查看发行版的详细信息",
|
||||
Long: `Show detailed information about a specific release.
|
||||
|
||||
Provide the release ID or tag name to view its details.`,
|
||||
Example: ` # View a release by ID
|
||||
gitlink release +view --id 42
|
||||
|
||||
# View a release by tag
|
||||
gitlink release +view --id v1.0.0`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID or tag", Required: true},
|
||||
},
|
||||
|
|
@ -85,9 +139,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除发行版
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a release",
|
||||
Description: "删除发行版",
|
||||
Long: `Delete a release from the current repository.
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before deletion.`,
|
||||
Example: ` # Delete a release
|
||||
gitlink release +delete --id 42`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
},
|
||||
|
|
@ -96,6 +156,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("delete release #%s", id),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
if delErr != nil {
|
||||
// GitLink API bug: delete succeeds but returns error status.
|
||||
|
|
@ -104,16 +169,120 @@ func Shortcuts() []*common.Shortcut {
|
|||
if viewErr != nil {
|
||||
// Release no longer exists — delete actually succeeded
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "删除成功",
|
||||
"message": "Release deleted successfully",
|
||||
}, nil))
|
||||
}
|
||||
// Release still exists — delete truly failed
|
||||
return delErr
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "删除成功",
|
||||
"message": "Release deleted successfully",
|
||||
}, nil))
|
||||
},
|
||||
},
|
||||
// +edit:获取发行版编辑页详情
|
||||
{
|
||||
Name: "edit",
|
||||
Description: "获取发行版编辑页详情",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/releases/%s/edit", ctx.Owner, ctx.Repo, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新发行版
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新发行版",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New release name"},
|
||||
{Name: "body", Short: "b", Usage: "New release body"},
|
||||
{Name: "prerelease", Usage: "Mark as prerelease (true/false)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
payload := map[string]interface{}{}
|
||||
if n := ctx.Arg("name"); n != "" {
|
||||
payload["name"] = n
|
||||
}
|
||||
if b := ctx.Arg("body"); b != "" {
|
||||
payload["body"] = b
|
||||
}
|
||||
if p := ctx.Arg("prerelease"); p != "" {
|
||||
payload["prerelease"] = p == "true"
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return fmt.Errorf("at least one of --name, --body, or --prerelease is required")
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/releases/%s", ctx.Owner, ctx.Repo, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// filterReleasesByPrerelease removes non-prerelease entries from the envelope data.
|
||||
func filterReleasesByPrerelease(env *output.Envelope) {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
releases, ok := data["releases"].([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filtered := make([]interface{}, 0, len(releases))
|
||||
for _, r := range releases {
|
||||
m, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isPrerelease(m) {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
data["releases"] = filtered
|
||||
}
|
||||
|
||||
// keepOnlyLatestRelease keeps only the first (most recent) release.
|
||||
func keepOnlyLatestRelease(env *output.Envelope) {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
releases, ok := data["releases"].([]interface{})
|
||||
if !ok || len(releases) == 0 {
|
||||
return
|
||||
}
|
||||
data["releases"] = []interface{}{releases[0]}
|
||||
}
|
||||
|
||||
// isPrerelease checks whether a release map has prerelease=true.
|
||||
func isPrerelease(m map[string]interface{}) bool {
|
||||
v, ok := m["prerelease"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
default:
|
||||
return fmt.Sprint(val) == "true"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,33 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出当前用户或指定用户的仓库(支持按类别筛选:manage/mirror/sync/fork/all)
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repositories for a user or organization",
|
||||
Description: "列出用户或组织的仓库",
|
||||
Long: `List repositories visible to the current user.
|
||||
|
||||
By default shows repositories you manage. Use --user to list
|
||||
another user's repositories. Use --category to filter by type
|
||||
(manage, mirror, sync, fork, or all).`,
|
||||
Example: ` # List your managed repositories
|
||||
gitlink repo +list
|
||||
|
||||
# List all repositories for a specific user
|
||||
gitlink repo +list --user zhangsan --category all
|
||||
|
||||
# List forked repositories with pagination
|
||||
gitlink repo +list --category fork --page 2 --limit 10`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user", Short: "u", Usage: "User login (default: current user)"},
|
||||
{Name: "category", Short: "c", Usage: "Filter: manage/mirror/sync/fork/all (default: manage)", Default: "manage"},
|
||||
|
|
@ -38,9 +54,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +info:显示当前仓库的详情(名称/描述/可见性/默认分支等)
|
||||
{
|
||||
Name: "info",
|
||||
Description: "Show repository details",
|
||||
Description: "显示仓库详情",
|
||||
Long: `Display detailed information about the current repository.
|
||||
|
||||
Shows name, description, visibility, default branch, and other metadata.`,
|
||||
Example: ` # Show details of the current repository
|
||||
gitlink repo +info`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -52,9 +74,19 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:在当前账户下创建新仓库(需 --name,可选 --description 与 --private)
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new repository",
|
||||
Description: "创建新仓库",
|
||||
Long: `Create a new repository under your account.
|
||||
|
||||
The repository name is required. Optionally provide a description
|
||||
and set visibility to private.`,
|
||||
Example: ` # Create a public repository
|
||||
gitlink repo +create --name my-project
|
||||
|
||||
# Create a private repository with a description
|
||||
gitlink repo +create --name my-project --description "A cool project" --private true`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Repository description"},
|
||||
|
|
@ -94,9 +126,15 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +fork:将当前仓库 fork 到自己的命名空间下
|
||||
{
|
||||
Name: "fork",
|
||||
Description: "Fork a repository",
|
||||
Description: "Fork 仓库",
|
||||
Long: `Fork the current repository into your account.
|
||||
|
||||
This creates a copy of the repository under your namespace.`,
|
||||
Example: ` # Fork the current repository
|
||||
gitlink repo +fork`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -108,13 +146,25 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:永久删除当前仓库(不可逆,删除前需确认)
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a repository",
|
||||
Description: "删除仓库",
|
||||
Long: `Delete the current repository permanently.
|
||||
|
||||
This action is irreversible and removes all data including issues,
|
||||
pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
||||
Example: ` # Delete the current repository
|
||||
gitlink repo +delete`,
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("delete repository %s/%s", ctx.Owner, ctx.Repo),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -122,5 +172,558 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- Settings/Info group ---
|
||||
|
||||
// +update:更新当前仓库的设置(名称/描述/可见性/网站)
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新仓库设置",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "New repository name"},
|
||||
{Name: "description", Short: "d", Usage: "Repository description"},
|
||||
{Name: "private", Usage: "Make repository private (true/false)"},
|
||||
{Name: "website", Short: "w", Usage: "Repository website URL"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if v := ctx.Arg("name"); v != "" {
|
||||
body["name"] = v
|
||||
}
|
||||
if v := ctx.Arg("description"); v != "" {
|
||||
body["description"] = v
|
||||
}
|
||||
if v := ctx.Arg("private"); v != "" {
|
||||
body["private"] = v == "true"
|
||||
}
|
||||
if v := ctx.Arg("website"); v != "" {
|
||||
body["website"] = v
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +about:显示仓库的关于页面
|
||||
{
|
||||
Name: "about",
|
||||
Description: "显示仓库关于页面",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/about", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +menu:列出仓库的导航菜单
|
||||
{
|
||||
Name: "menu",
|
||||
Description: "列出仓库导航菜单",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/menu_list", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +units-get:获取仓库的导航单元设置
|
||||
{
|
||||
Name: "units-get",
|
||||
Description: "获取仓库导航单元设置",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/project_units", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +units-set:更新仓库的导航单元设置(--units 传 JSON 数组)
|
||||
{
|
||||
Name: "units-set",
|
||||
Description: "更新仓库导航单元设置",
|
||||
Flags: []common.Flag{
|
||||
{Name: "units", Short: "u", Usage: "JSON array of unit settings", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
unitsRaw, err := ctx.RequireArg("units")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var units []interface{}
|
||||
if err := json.Unmarshal([]byte(unitsRaw), &units); err != nil {
|
||||
return fmt.Errorf("invalid JSON for --units: %w", err)
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"units": units,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/project_units", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +edit-detail:获取仓库编辑页面的详情
|
||||
{
|
||||
Name: "edit-detail",
|
||||
Description: "获取仓库编辑详情",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/edit", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +simple:获取仓库的简化详情信息
|
||||
{
|
||||
Name: "simple",
|
||||
Description: "获取仓库简化详情",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/simple", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- Stats group ---
|
||||
|
||||
// +code-stats:获取仓库的代码统计信息
|
||||
{
|
||||
Name: "code-stats",
|
||||
Description: "获取仓库代码统计",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/code_stats", ctx.RepoPath()), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +languages:获取仓库的编程语言占比
|
||||
{
|
||||
Name: "languages",
|
||||
Description: "获取仓库语言占比",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/languages", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +contributors:列出仓库的贡献者
|
||||
{
|
||||
Name: "contributors",
|
||||
Description: "列出仓库贡献者",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/contributors", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +contributors-stat:获取贡献者的代码统计信息
|
||||
{
|
||||
Name: "contributors-stat",
|
||||
Description: "获取贡献者代码统计",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/contributors/stat", ctx.RepoPath()), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- Social/Interaction group ---
|
||||
|
||||
// +recommend:获取推荐项目列表
|
||||
{
|
||||
Name: "recommend",
|
||||
Description: "获取推荐项目",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/projects/recommend", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +star:点赞(加星)指定项目
|
||||
{
|
||||
Name: "star",
|
||||
Description: "点赞项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/projects/%s/praise_tread/like", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +unstar:取消点赞(取消加星)指定项目
|
||||
{
|
||||
Name: "unstar",
|
||||
Description: "取消点赞项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/projects/%s/praise_tread/unlike", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +watch:关注指定项目
|
||||
{
|
||||
Name: "watch",
|
||||
Description: "关注项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idInt, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid project id: %w", err)
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"project_id": idInt,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/watchers/follow", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +unwatch:取消关注指定项目
|
||||
{
|
||||
Name: "unwatch",
|
||||
Description: "取消关注项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/watchers/unfollow?project_id=%s", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +stargazers:列出对当前仓库加星的用户
|
||||
{
|
||||
Name: "stargazers",
|
||||
Description: "列出仓库的加星用户",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/stargazers", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +watchers:列出关注当前仓库的用户
|
||||
{
|
||||
Name: "watchers",
|
||||
Description: "列出仓库的关注者",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/watchers", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- Transfer group ---
|
||||
|
||||
// +transfer:将仓库转移给其他所有者或组织
|
||||
{
|
||||
Name: "transfer",
|
||||
Description: "转移仓库所有权",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Target owner/organization", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
owner, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"owner": owner,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +cancel-transfer:取消待处理的仓库转移申请
|
||||
{
|
||||
Name: "cancel-transfer",
|
||||
Description: "取消待处理的仓库转移",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects/cancel", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +transfer-orgs:列出可用于转移目标组织
|
||||
{
|
||||
Name: "transfer-orgs",
|
||||
Description: "列出可转移的组织",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/applied_transfer_projects/organizations", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +invite-link:获取当前仓库的邀请链接
|
||||
{
|
||||
Name: "invite-link",
|
||||
Description: "获取仓库邀请链接",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/project_invite_links/current_link", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
|
||||
// --- L3 Operations: Join/Quit/Mirror/Topics ---
|
||||
|
||||
// +join:申请加入指定项目(可指定期望角色)
|
||||
{
|
||||
Name: "join",
|
||||
Description: "申请加入项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
{Name: "role", Short: "r", Usage: "Desired role"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"project_id": id,
|
||||
}
|
||||
if role := ctx.Arg("role"); role != "" {
|
||||
body["role"] = role
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/applied_projects", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +quit:退出当前项目
|
||||
{
|
||||
Name: "quit",
|
||||
Description: "退出项目",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/quit", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +migrate:创建镜像项目(从上游 URL 镜像克隆)
|
||||
{
|
||||
Name: "migrate",
|
||||
Description: "创建镜像项目",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
|
||||
{Name: "clone-url", Short: "u", Usage: "Upstream clone URL", Required: true},
|
||||
{Name: "private", Usage: "Make repository private (true/false)", Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cloneURL, err := ctx.RequireArg("clone-url")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"repository_name": name,
|
||||
"clone_addr": cloneURL,
|
||||
}
|
||||
if ctx.Arg("private") == "true" {
|
||||
body["private"] = true
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/projects/migrate", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +sync-mirror:同步镜像仓库(从上游拉取更新)
|
||||
{
|
||||
Name: "sync-mirror",
|
||||
Description: "同步镜像仓库",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Repository ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/repositories/%s/sync_mirror", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +topics:列出项目话题
|
||||
{
|
||||
Name: "topics",
|
||||
Description: "列出项目话题",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/v1/project_topics", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create-topic:创建项目话题(需 --name)
|
||||
{
|
||||
Name: "create-topic",
|
||||
Description: "创建项目话题",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Topic name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1/project_topics", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-topic:删除指定项目话题
|
||||
{
|
||||
Name: "delete-topic",
|
||||
Description: "删除项目话题",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Topic ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/project_topics/%s", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,543 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Required tests ---
|
||||
|
||||
func TestRepoUpdateSendsPATCH(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"name": "new-name",
|
||||
"description": "updated desc",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "update", map[string]string{
|
||||
"name": "new-name",
|
||||
"description": "updated desc",
|
||||
"private": "true",
|
||||
"website": "https://example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "PATCH")
|
||||
assertEqual(t, calledPath, "/owner/repo.json")
|
||||
assertEqual(t, payload["name"], "new-name")
|
||||
assertEqual(t, payload["description"], "updated desc")
|
||||
assertEqual(t, payload["private"], true)
|
||||
assertEqual(t, payload["website"], "https://example.com")
|
||||
}
|
||||
|
||||
func TestRepoAboutCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"name": "test-repo",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "about", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("about shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "GET")
|
||||
assertEqual(t, calledPath, "/owner/repo/about.json")
|
||||
}
|
||||
|
||||
func TestRepoStarCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"status": 0,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "star", map[string]string{
|
||||
"id": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("star shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/projects/42/praise_tread/like.json")
|
||||
}
|
||||
|
||||
func TestRepoLanguagesCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"Go": float64(80),
|
||||
"Java": float64(20),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "languages", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("languages shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "GET")
|
||||
assertEqual(t, calledPath, "/owner/repo/languages.json")
|
||||
}
|
||||
|
||||
// --- Additional coverage tests ---
|
||||
|
||||
func TestRepoMenuCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"menus": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "menu", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("menu shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/menu_list.json")
|
||||
}
|
||||
|
||||
func TestRepoUnitsGetCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"units": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "units-get", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("units-get shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/project_units.json")
|
||||
}
|
||||
|
||||
func TestRepoUnitsSetPostsCorrectPayload(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
var body map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
body = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "units-set", map[string]string{
|
||||
"units": `[{"type":"code","enabled":true}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("units-set shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/owner/repo/project_units.json")
|
||||
// The units JSON string should be unmarshaled into a slice
|
||||
units, ok := body["units"].([]interface{})
|
||||
if !ok || len(units) == 0 {
|
||||
t.Fatalf("expected units to be a non-empty array, got %v", body["units"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCodeStatsUsesV1Endpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"additions": float64(100),
|
||||
"deletions": float64(20),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "code-stats", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("code-stats shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/code_stats.json")
|
||||
}
|
||||
|
||||
func TestRepoContributorsCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"contributors": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "contributors", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("contributors shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/contributors.json")
|
||||
}
|
||||
|
||||
func TestRepoContributorsStatUsesV1Endpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"contributors": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "contributors-stat", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("contributors-stat shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/v1/owner/repo/contributors/stat.json")
|
||||
}
|
||||
|
||||
func TestRepoRecommendCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"projects": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "recommend", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("recommend shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/projects/recommend.json")
|
||||
}
|
||||
|
||||
func TestRepoUnstarCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "unstar", map[string]string{
|
||||
"id": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unstar shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "DELETE")
|
||||
assertEqual(t, calledPath, "/projects/42/praise_tread/unlike.json")
|
||||
}
|
||||
|
||||
func TestRepoWatchCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
var body map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
body = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "watch", map[string]string{
|
||||
"id": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("watch shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/watchers/follow.json")
|
||||
assertEqual(t, body["project_id"], float64(42))
|
||||
}
|
||||
|
||||
func TestRepoUnwatchCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "unwatch", map[string]string{
|
||||
"id": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unwatch shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "DELETE")
|
||||
assertEqual(t, calledPath, "/watchers/unfollow.json")
|
||||
}
|
||||
|
||||
func TestRepoStargazersCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"stargazers": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "stargazers", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("stargazers shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/stargazers.json")
|
||||
}
|
||||
|
||||
func TestRepoWatchersCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"watchers": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "watchers", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("watchers shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/watchers.json")
|
||||
}
|
||||
|
||||
func TestRepoTransferCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "transfer", map[string]string{
|
||||
"owner": "new-org",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transfer shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/owner/repo/applied_transfer_projects.json")
|
||||
}
|
||||
|
||||
func TestRepoCancelTransferCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledMethod string
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledMethod = r.Method
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "cancel-transfer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cancel-transfer shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledMethod, "POST")
|
||||
assertEqual(t, calledPath, "/owner/repo/applied_transfer_projects/cancel.json")
|
||||
}
|
||||
|
||||
func TestRepoTransferOrgsCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"organizations": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "transfer-orgs", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("transfer-orgs shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/applied_transfer_projects/organizations.json")
|
||||
}
|
||||
|
||||
func TestRepoInviteLinkCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"link": "https://example.com/invite/abc",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "invite-link", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("invite-link shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/project_invite_links/current_link.json")
|
||||
}
|
||||
|
||||
func TestRepoEditDetailCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "edit-detail", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("edit-detail shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/edit.json")
|
||||
}
|
||||
|
||||
func TestRepoSimpleCallsCorrectEndpoint(t *testing.T) {
|
||||
var calledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledPath = r.URL.Path
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "simple", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("simple shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, calledPath, "/owner/repo/simple.json")
|
||||
}
|
||||
|
||||
func TestRepoStarRequiresID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when id is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "star", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when id is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoWatchRequiresID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when id is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "watch", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when id is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoUnitsSetRequiresUnits(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when units is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "units-set", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when units is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoTransferRequiresOwner(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called when owner is missing: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runRepoShortcut(t, server, "transfer", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when owner is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
|
@ -8,13 +8,33 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +repos:搜索仓库
|
||||
{
|
||||
Name: "repos",
|
||||
Description: "Search repositories",
|
||||
Description: "搜索仓库",
|
||||
Long: `Search for repositories on GitLink by keyword.
|
||||
|
||||
Returns matching repository names, owners, and descriptions.
|
||||
Use --language to filter by programming language, --sort and --order
|
||||
to control ordering, and --page and --limit for pagination.`,
|
||||
Example: ` # Search for repositories
|
||||
gitlink search +repos --keyword "cli"
|
||||
|
||||
# Show 50 results per page
|
||||
gitlink search +repos --keyword "cli" --limit 50
|
||||
|
||||
# Filter by language
|
||||
gitlink search +repos --keyword "web" --language Go
|
||||
|
||||
# Sort by update time descending
|
||||
gitlink search +repos --keyword "cli" --sort updated --order desc`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "language", Usage: "Filter by programming language"},
|
||||
{Name: "sort", Usage: "Sort field (e.g. stars, updated)"},
|
||||
{Name: "order", Usage: "Sort direction: asc or desc"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
keyword, _ := ctx.RequireArg("keyword")
|
||||
|
|
@ -22,6 +42,17 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
// [P2 参数补全] ⚠️ API 静默忽略:CLI 把参数发给 API,API 返回成功但未按参数过滤
|
||||
// 这是 GitLink 平台侧的问题,CLI 仍保留参数,将来平台修复后可直接生效
|
||||
if l := ctx.Arg("language"); l != "" {
|
||||
q.Set("language", l)
|
||||
}
|
||||
if s := ctx.Arg("sort"); s != "" {
|
||||
q.Set("sort", s)
|
||||
}
|
||||
if o := ctx.Arg("order"); o != "" {
|
||||
q.Set("order", o)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/projects", q)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -29,13 +60,29 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +users:搜索用户
|
||||
{
|
||||
Name: "users",
|
||||
Description: "Search users",
|
||||
Description: "搜索用户",
|
||||
Long: `Search for users on GitLink by keyword.
|
||||
|
||||
Returns matching user login names, display names, and profile summaries.
|
||||
Use --sort and --order to control ordering, and --page and --limit
|
||||
for pagination.`,
|
||||
Example: ` # Search for users
|
||||
gitlink search +users --keyword "zhang"
|
||||
|
||||
# Show 50 results per page
|
||||
gitlink search +users --keyword "zhang" --limit 50
|
||||
|
||||
# Sort results
|
||||
gitlink search +users --keyword "zhang" --sort created --order desc`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "sort", Usage: "Sort field"},
|
||||
{Name: "order", Usage: "Sort direction: asc or desc"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
keyword, _ := ctx.RequireArg("keyword")
|
||||
|
|
@ -43,6 +90,13 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
// [P2 参数补全] ⚠️ API 静默忽略(同 +repos)
|
||||
if s := ctx.Arg("sort"); s != "" {
|
||||
q.Set("sort", s)
|
||||
}
|
||||
if o := ctx.Arg("order"); o != "" {
|
||||
q.Set("order", o)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
package snippet
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const snippetsDir = "snippets"
|
||||
|
||||
// Shortcuts returns code snippet management shortcuts.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库中的所有代码片段
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库中的所有代码片段",
|
||||
Run: runList,
|
||||
},
|
||||
// +view:查看代码片段内容
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看代码片段内容",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Snippet name", Required: true},
|
||||
},
|
||||
Run: runView,
|
||||
},
|
||||
// +create:新建代码片段
|
||||
{
|
||||
Name: "create",
|
||||
Description: "新建代码片段",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Snippet name (used as filename)", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "Snippet content (code)", Required: true},
|
||||
{Name: "language", Short: "l", Usage: "Programming language (e.g. go, python, js)"},
|
||||
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
|
||||
},
|
||||
Run: runCreate,
|
||||
},
|
||||
// +delete:删除代码片段
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除代码片段",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Snippet name", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
|
||||
},
|
||||
Run: runDelete,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Override repoGitURLOverride in tests to point to a local repo.
|
||||
var repoGitURLOverride string
|
||||
|
||||
// repoGitURL returns the public Git URL for the main repository.
|
||||
func repoGitURL(owner, repo string) string {
|
||||
if repoGitURLOverride != "" {
|
||||
return repoGitURLOverride
|
||||
}
|
||||
return fmt.Sprintf("https://gitlink.org.cn/%s/%s.git", owner, repo)
|
||||
}
|
||||
|
||||
// runGit runs a git command in the specified directory.
|
||||
func runGit(dir string, args ...string) (stdout, stderr string, err error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
var outBuf, errBuf strings.Builder
|
||||
cmd.Stdout = &outBuf
|
||||
cmd.Stderr = &errBuf
|
||||
err = cmd.Run()
|
||||
return outBuf.String(), errBuf.String(), err
|
||||
}
|
||||
|
||||
// snippetFileName returns the full file path in the repo for a snippet.
|
||||
func snippetFileName(name string) string {
|
||||
if strings.Contains(name, ".") {
|
||||
return fmt.Sprintf("%s/%s", snippetsDir, name)
|
||||
}
|
||||
return fmt.Sprintf("%s/%s.md", snippetsDir, name)
|
||||
}
|
||||
|
||||
// buildSnippetContent builds the markdown content for a snippet.
|
||||
func buildSnippetContent(language, code string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("```")
|
||||
b.WriteString(language)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(code)
|
||||
if !strings.HasSuffix(code, "\n") {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("```\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// repoFilePath returns the API path prefix for repo file operations.
|
||||
func repoFilePath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/%s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
|
||||
func runList(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clone repo to temp dir (read-only for public repos)
|
||||
tmpDir, err := os.MkdirTemp("", "gitlink-snippet-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
_, stderr, err := runGit("", "clone", "--depth", "1", repoGitURL(ctx.Owner, ctx.Repo), tmpDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clone repo: %s%s", stderr, err)
|
||||
}
|
||||
|
||||
// List files in the snippets directory
|
||||
snippetPath := filepath.Join(tmpDir, snippetsDir)
|
||||
entries, err := os.ReadDir(snippetPath)
|
||||
if err != nil {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"snippets": []interface{}{},
|
||||
"total": 0,
|
||||
})
|
||||
}
|
||||
|
||||
snippets := make([]map[string]interface{}, 0)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
snippets = append(snippets, map[string]interface{}{
|
||||
"name": e.Name(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"snippets": snippets,
|
||||
"total": len(snippets),
|
||||
})
|
||||
}
|
||||
|
||||
func runView(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clone repo to temp dir (read-only for public repos)
|
||||
tmpDir, err := os.MkdirTemp("", "gitlink-snippet-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
_, stderr, err := runGit("", "clone", "--depth", "1", repoGitURL(ctx.Owner, ctx.Repo), tmpDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clone repo: %s%s", stderr, err)
|
||||
}
|
||||
|
||||
filePath := snippetFileName(name)
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, filePath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("snippet %q not found", name)
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"name": name,
|
||||
"content": string(content),
|
||||
})
|
||||
}
|
||||
|
||||
func runCreate(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
|
||||
}
|
||||
|
||||
language := ctx.Arg("language")
|
||||
message := ctx.Arg("message")
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("Add snippet %q", name)
|
||||
}
|
||||
|
||||
filePath := snippetFileName(name)
|
||||
snippetContent := buildSnippetContent(language, content)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(snippetContent)),
|
||||
"message": message,
|
||||
"branch": ctx.Arg("branch"),
|
||||
}
|
||||
|
||||
if _, err = ctx.CallAPI("POST", repoFilePath(ctx)+"/create_file", payload); err != nil {
|
||||
return fmt.Errorf("create snippet: %w", err)
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"name": name,
|
||||
"message": "Snippet created",
|
||||
})
|
||||
}
|
||||
|
||||
func runDelete(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message := ctx.Arg("message")
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("Delete snippet %q", name)
|
||||
}
|
||||
|
||||
filePath := snippetFileName(name)
|
||||
|
||||
// Auto-fetch file SHA using Git (public repo)
|
||||
tmpDir, err := os.MkdirTemp("", "gitlink-snippet-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
_, stderr, err := runGit("", "clone", "--depth", "1", repoGitURL(ctx.Owner, ctx.Repo), tmpDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clone repo: %s%s", stderr, err)
|
||||
}
|
||||
|
||||
// Get blob SHA of the file
|
||||
shaOut, _, err := runGit(tmpDir, "hash-object", filePath)
|
||||
sha := strings.TrimSpace(shaOut)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"message": message,
|
||||
"branch": ctx.Arg("branch"),
|
||||
"sha": sha,
|
||||
}
|
||||
|
||||
if _, err = ctx.CallAPI("DELETE", repoFilePath(ctx)+"/delete_file", payload); err != nil {
|
||||
return fmt.Errorf("delete snippet: %w", err)
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"name": name,
|
||||
"message": "Snippet deleted",
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,404 @@
|
|||
package snippet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- Git repo test helpers ---
|
||||
|
||||
// setupSnippetRepo creates a bare Git repo with snippets, returns the repo path.
|
||||
func setupSnippetRepo(t *testing.T, files map[string]string) (bareRepo string, cleanup func()) {
|
||||
t.Helper()
|
||||
|
||||
remoteDir, err := os.MkdirTemp("", "gitlink-snippet-remote-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir remote: %v", err)
|
||||
}
|
||||
|
||||
runGitClean("", "init", "--bare", remoteDir)
|
||||
|
||||
workDir, err := os.MkdirTemp("", "gitlink-snippet-work-*")
|
||||
if err != nil {
|
||||
os.RemoveAll(remoteDir)
|
||||
t.Fatalf("mkdir work: %v", err)
|
||||
}
|
||||
|
||||
runGitClean("", "clone", remoteDir, workDir)
|
||||
runGitClean(workDir, "config", "user.email", "test@test.com")
|
||||
runGitClean(workDir, "config", "user.name", "Test User")
|
||||
|
||||
// Create snippets directory and files
|
||||
snippetPath := filepath.Join(workDir, "snippets")
|
||||
os.MkdirAll(snippetPath, 0755)
|
||||
if len(files) == 0 {
|
||||
os.WriteFile(filepath.Join(snippetPath, ".gitkeep"), []byte(""), 0644)
|
||||
}
|
||||
for name, content := range files {
|
||||
os.WriteFile(filepath.Join(snippetPath, name), []byte(content), 0644)
|
||||
}
|
||||
|
||||
runGitClean(workDir, "add", ".")
|
||||
runGitClean(workDir, "commit", "-m", "Add snippets")
|
||||
runGitClean(workDir, "push", "origin", "master")
|
||||
|
||||
os.RemoveAll(workDir)
|
||||
return remoteDir, func() { os.RemoveAll(remoteDir) }
|
||||
}
|
||||
|
||||
func runGitClean(dir string, args ...string) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
panic("git " + strings.Join(args, " ") + ": " + string(out) + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP test helpers ---
|
||||
|
||||
func runSnippetShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findSnippetShortcut(t, name)
|
||||
var cli *client.Client
|
||||
if server != nil {
|
||||
cli = &client.Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
} else {
|
||||
cli = &client.Client{HTTP: http.DefaultClient, BaseURL: ""}
|
||||
}
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: cli,
|
||||
Owner: "test-owner",
|
||||
Repo: "test-repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = map[string]string{}
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findSnippetShortcut(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 newSnippetTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// --- Git-based tests (list, view) ---
|
||||
|
||||
func TestSnippetList(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"hello.go": "package main\nfunc main() {}\n",
|
||||
"sort.py": "def sort():\n pass\n",
|
||||
"readme.md": "# Snippets\n",
|
||||
}
|
||||
repo, cleanup := setupSnippetRepo(t, files)
|
||||
defer cleanup()
|
||||
|
||||
repoGitURLOverride = repo
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
if err := runSnippetShortcut(t, nil, "list", nil); err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetListEmpty(t *testing.T) {
|
||||
repo, cleanup := setupSnippetRepo(t, map[string]string{})
|
||||
defer cleanup()
|
||||
|
||||
repoGitURLOverride = repo
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
if err := runSnippetShortcut(t, nil, "list", nil); err != nil {
|
||||
t.Fatalf("list empty failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetListNoSnippetsDir(t *testing.T) {
|
||||
// Create a bare repo with no snippets directory
|
||||
remoteDir, err := os.MkdirTemp("", "gitlink-snippet-empty-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(remoteDir)
|
||||
|
||||
runGitClean("", "init", "--bare", remoteDir)
|
||||
|
||||
workDir, err := os.MkdirTemp("", "gitlink-snippet-work-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
runGitClean("", "clone", remoteDir, workDir)
|
||||
runGitClean(workDir, "config", "user.email", "test@test.com")
|
||||
runGitClean(workDir, "config", "user.name", "Test User")
|
||||
os.WriteFile(filepath.Join(workDir, ".gitkeep"), []byte(""), 0644)
|
||||
runGitClean(workDir, "add", ".")
|
||||
runGitClean(workDir, "commit", "-m", "init")
|
||||
runGitClean(workDir, "push", "origin", "master")
|
||||
os.RemoveAll(workDir)
|
||||
|
||||
repoGitURLOverride = remoteDir
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
if err := runSnippetShortcut(t, nil, "list", nil); err != nil {
|
||||
t.Fatalf("list no snippets dir failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetView(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"hello.go": "package main\nfunc main() {}\n",
|
||||
}
|
||||
repo, cleanup := setupSnippetRepo(t, files)
|
||||
defer cleanup()
|
||||
|
||||
repoGitURLOverride = repo
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
if err := runSnippetShortcut(t, nil, "view", map[string]string{"name": "hello.go"}); err != nil {
|
||||
t.Fatalf("view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetViewNotFound(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"hello.go": "package main\n",
|
||||
}
|
||||
repo, cleanup := setupSnippetRepo(t, files)
|
||||
defer cleanup()
|
||||
|
||||
repoGitURLOverride = repo
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
err := runSnippetShortcut(t, nil, "view", map[string]string{"name": "nope.go"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent snippet")
|
||||
}
|
||||
}
|
||||
|
||||
// --- API-based tests (create, delete) ---
|
||||
|
||||
func TestSnippetCreate(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "created"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runSnippetShortcut(t, server, "create", map[string]string{
|
||||
"name": "hello.go",
|
||||
"content": "package main\n\nfunc main() {}",
|
||||
"language": "go",
|
||||
"message": "Add hello.go snippet",
|
||||
"branch": "master",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if payload["filepath"] != "snippets/hello.go" {
|
||||
t.Fatalf("filepath = %q, want snippets/hello.go", payload["filepath"])
|
||||
}
|
||||
if payload["branch"] != "master" {
|
||||
t.Fatalf("branch = %q, want master", payload["branch"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetCreateWithoutExtension(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "created"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runSnippetShortcut(t, server, "create", map[string]string{
|
||||
"name": "myscript",
|
||||
"content": "#!/bin/bash\necho hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if payload["filepath"] != "snippets/myscript.md" {
|
||||
t.Fatalf("filepath = %q, want snippets/myscript.md", payload["filepath"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetDelete(t *testing.T) {
|
||||
// Delete needs a Git repo to fetch SHA, plus an API server for the delete call
|
||||
files := map[string]string{
|
||||
"hello.go": "package main\nfunc main() {}\n",
|
||||
}
|
||||
repo, cleanup := setupSnippetRepo(t, files)
|
||||
defer cleanup()
|
||||
|
||||
repoGitURLOverride = repo
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
// API server for delete
|
||||
var payload map[string]interface{}
|
||||
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runSnippetShortcut(t, server, "delete", map[string]string{
|
||||
"name": "hello.go",
|
||||
"message": "Remove snippet",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
if payload["filepath"] != "snippets/hello.go" {
|
||||
t.Fatalf("filepath = %q, want snippets/hello.go", payload["filepath"])
|
||||
}
|
||||
if payload["sha"] == "" {
|
||||
t.Fatal("sha should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetDeleteDefaultMessage(t *testing.T) {
|
||||
files := map[string]string{"old.py": "print('old')\n"}
|
||||
repo, cleanup := setupSnippetRepo(t, files)
|
||||
defer cleanup()
|
||||
|
||||
repoGitURLOverride = repo
|
||||
defer func() { repoGitURLOverride = "" }()
|
||||
|
||||
var payload map[string]interface{}
|
||||
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
if err := runSnippetShortcut(t, server, "delete", map[string]string{"name": "old.py"}); err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
if msg, ok := payload["message"].(string); !ok || !strings.Contains(msg, "old.py") {
|
||||
t.Fatalf("message = %q, should mention old.py", payload["message"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validation tests ---
|
||||
|
||||
func TestSnippetCreateRejectsMissingName(t *testing.T) {
|
||||
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("should not call API")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runSnippetShortcut(t, server, "create", map[string]string{"content": "code"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --name is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetCreateRejectsMissingContent(t *testing.T) {
|
||||
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("should not call API")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runSnippetShortcut(t, server, "create", map[string]string{"name": "test.go"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --content is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetViewRejectsMissingName(t *testing.T) {
|
||||
err := findSnippetShortcut(t, "view").Run(&common.RuntimeContext{
|
||||
Owner: "test",
|
||||
Repo: "test",
|
||||
Format: "json",
|
||||
Args: map[string]string{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --name is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetDeleteRejectsMissingName(t *testing.T) {
|
||||
err := findSnippetShortcut(t, "delete").Run(&common.RuntimeContext{
|
||||
Owner: "test",
|
||||
Repo: "test",
|
||||
Format: "json",
|
||||
Args: map[string]string{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --name is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unit tests ---
|
||||
|
||||
func TestSnippetFileName(t *testing.T) {
|
||||
tests := []struct{ input, want string }{
|
||||
{"hello.go", "snippets/hello.go"},
|
||||
{"myscript.py", "snippets/myscript.py"},
|
||||
{"myscript", "snippets/myscript.md"},
|
||||
{"subdir/file.js", "snippets/subdir/file.js"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := snippetFileName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("snippetFileName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnippetContent(t *testing.T) {
|
||||
got := buildSnippetContent("go", "package main\nfunc main() {}")
|
||||
if !strings.Contains(got, "```go") {
|
||||
t.Errorf("expected ```go in output, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "package main") {
|
||||
t.Errorf("expected content in output, got: %s", got)
|
||||
}
|
||||
|
||||
got2 := buildSnippetContent("", "echo hello")
|
||||
if !strings.Contains(got2, "```\n") {
|
||||
t.Errorf("expected ``` without lang, got: %s", got2)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package sshkey
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出当前账号的 SSH 公钥
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出当前账号的 SSH 公钥",
|
||||
Long: `List your SSH public keys.
|
||||
|
||||
Shows all SSH public keys associated with your account, including
|
||||
key ID, title, fingerprint, and creation date.
|
||||
Supports pagination with --page and --limit flags.`,
|
||||
Example: ` # List all SSH keys
|
||||
gitlink sshkey +list
|
||||
|
||||
# List with pagination
|
||||
gitlink sshkey +list -p 2 -l 10`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/public_keys", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:添加新的 SSH 公钥
|
||||
{
|
||||
Name: "create",
|
||||
Description: "添加新的 SSH 公钥",
|
||||
Long: `Add a new SSH public key.
|
||||
|
||||
Registers a new SSH public key to your account. The key content must
|
||||
be a valid public key in ssh-rsa or ssh-ed25519 format.`,
|
||||
Example: ` # Add an SSH key
|
||||
gitlink sshkey +create -t "My Laptop" -k "ssh-rsa AAAAB3..."`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Key title", Required: true},
|
||||
{Name: "key", Short: "k", Usage: "Public key content (ssh-rsa/ed25519 ...)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
title, err := ctx.RequireArg("title")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := ctx.RequireArg("key")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]string{
|
||||
"title": title,
|
||||
"key": key,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/public_keys", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除指定的 SSH 公钥
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除指定的 SSH 公钥",
|
||||
Long: `Delete an SSH public key.
|
||||
|
||||
Removes an SSH public key from your account by its ID. This action cannot be undone.`,
|
||||
Example: ` # Delete an SSH key by ID
|
||||
gitlink sshkey +delete -i 123`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Key ID to delete", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/public_keys/%s", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有标签
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的所有标签",
|
||||
Long: `List all tags in the current repository.
|
||||
|
||||
Shows tag name, commit SHA, and creation date.
|
||||
Use --search to filter tags by name pattern.`,
|
||||
Example: ` # List all tags
|
||||
gitlink tag +list
|
||||
|
||||
# Search for tags matching a pattern
|
||||
gitlink tag +list --search "v1."
|
||||
|
||||
# Paginate through tags
|
||||
gitlink tag +list --page 2 --limit 50`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "search", Short: "s", Usage: "Filter by tag name"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo)
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看标签的详细信息
|
||||
// 注意:GitLink API 不提供"获取单个标签"端点,这里通过列出全部标签后按名称过滤实现。
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看标签的详细信息",
|
||||
Long: `Show detailed information about a specific tag.
|
||||
|
||||
GitLink's API has no single-tag endpoint, so this lists all tags and
|
||||
returns the one whose name matches.`,
|
||||
Example: ` # View details of a tag
|
||||
gitlink tag +view --name v1.0.0`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Tag 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
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag := findTagByName(env, name)
|
||||
if tag == nil {
|
||||
return fmt.Errorf("标签 %q 不存在", name)
|
||||
}
|
||||
return ctx.OutputData(tag)
|
||||
},
|
||||
},
|
||||
// +delete:删除标签
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除标签",
|
||||
Long: `Delete a tag from the current repository.
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before deletion.`,
|
||||
Example: ` # Delete a tag
|
||||
gitlink tag +delete --name v0.9-beta`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Tag name to delete", 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
|
||||
}
|
||||
if err := common.ConfirmAction(
|
||||
fmt.Sprintf("delete tag %s", name),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/tags/%s", ctx.Owner, ctx.Repo, name), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// findTagByName 在标签列表 envelope 中按名称查找单个标签。
|
||||
// GitLink API 没有"获取单个标签"端点,tag +view 需要先列出全部再过滤。
|
||||
// 找不到时返回 nil。
|
||||
func findTagByName(env *output.Envelope, name string) map[string]interface{} {
|
||||
if env == nil || env.Data == nil {
|
||||
return nil
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
tags, ok := data["tags"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, t := range tags {
|
||||
tag, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if n, _ := tag["name"].(string); n == name {
|
||||
return tag
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
// TestFindTagByName_Found 验证能从标签列表中按名称找到标签。
|
||||
func TestFindTagByName_Found(t *testing.T) {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{
|
||||
"tags": []interface{}{
|
||||
map[string]interface{}{"name": "v1.0.0", "id": "abc"},
|
||||
map[string]interface{}{"name": "v2.0.0", "id": "def"},
|
||||
},
|
||||
"total_count": 2,
|
||||
},
|
||||
}
|
||||
tag := findTagByName(env, "v1.0.0")
|
||||
if tag == nil {
|
||||
t.Fatal("expected to find v1.0.0")
|
||||
}
|
||||
if tag["id"] != "abc" {
|
||||
t.Fatalf("expected id abc, got %v", tag["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindTagByName_NotFound 验证找不到时返回 nil。
|
||||
func TestFindTagByName_NotFound(t *testing.T) {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{
|
||||
"tags": []interface{}{
|
||||
map[string]interface{}{"name": "v1.0.0"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if tag := findTagByName(env, "v9.9.9"); tag != nil {
|
||||
t.Fatalf("expected nil for nonexistent tag, got %v", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindTagByName_NilEnvelope 验证边界情况。
|
||||
func TestFindTagByName_NilEnvelope(t *testing.T) {
|
||||
if tag := findTagByName(nil, "v1"); tag != nil {
|
||||
t.Fatal("expected nil for nil envelope")
|
||||
}
|
||||
env := &output.Envelope{OK: true}
|
||||
if tag := findTagByName(env, "v1"); tag != nil {
|
||||
t.Fatal("expected nil for nil data")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindTagByName_NoTagsKey 验证数据结构不含 tags 时返回 nil。
|
||||
func TestFindTagByName_NoTagsKey(t *testing.T) {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{"other": "value"},
|
||||
}
|
||||
if tag := findTagByName(env, "v1"); tag != nil {
|
||||
t.Fatalf("expected nil when no tags key, got %v", tag)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
package template
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出某拥有者的项目模板
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出某拥有者的项目模板",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
owner, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/project_templates", owner), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建项目模板
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建项目模板",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Template name", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Template description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
owner, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
if desc := ctx.Arg("description"); desc != "" {
|
||||
payload["description"] = desc
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/project_templates", owner), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看项目模板
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看项目模板",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
|
||||
{Name: "repo", Short: "r", Usage: "Repository name (optional, for repo-scoped templates)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
owner, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var path string
|
||||
if repo := ctx.Arg("repo"); repo != "" {
|
||||
path = fmt.Sprintf("/v1/%s/%s/project_templates/%s", owner, repo, id)
|
||||
} else {
|
||||
path = fmt.Sprintf("/v1/%s/project_templates/%s", owner, id)
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新项目模板
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新项目模板",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New template name"},
|
||||
{Name: "description", Short: "d", Usage: "New template description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
owner, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if name := ctx.Arg("name"); name != "" {
|
||||
payload["name"] = name
|
||||
}
|
||||
if desc := ctx.Arg("description"); desc != "" {
|
||||
payload["description"] = desc
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/v1/%s/project_templates/%s", owner, id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除项目模板
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除项目模板",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
owner, err := ctx.RequireArg("owner")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/project_templates/%s", owner, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue