Compare commits
201 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
9df6e2830b | |
|
|
c19b52e5b7 | |
|
|
217a3a5861 | |
|
|
9dd3777e17 | |
|
|
07f7a86304 | |
|
|
4e889737db | |
|
|
de535f650f | |
|
|
fc15715336 | |
|
|
1aebf115f6 | |
|
|
9c296fafcf | |
|
|
07edea2153 | |
|
|
8c5fee06f6 | |
|
|
b3e30f907d | |
|
|
6f722c1334 | |
|
|
6c78185b90 | |
|
|
6009a7ce8b | |
|
|
363c9e478c | |
|
|
dc77493159 | |
|
|
31058942b5 | |
|
|
20f7f1fa43 | |
|
|
3f4809bffb | |
|
|
4c85a09921 | |
|
|
776871c2b1 | |
|
|
321ef64fe5 | |
|
|
348f2ab696 | |
|
|
be2df69734 | |
|
|
51a1fb6a46 | |
|
|
932d877b3c | |
|
|
a35dc781b9 | |
|
|
897b2ff59e | |
|
|
9de2f209c8 | |
|
|
c60033ae48 | |
|
|
b2192cc272 | |
|
|
dcf75299eb | |
|
|
516651cb34 | |
|
|
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: "定时运行:多仓库 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 . && cp gitlink-cli /usr/local/bin/ && gitlink-cli version"'
|
||||
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) 反馈。
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
# 变更说明汇总
|
||||
|
||||
> 本文件汇总 GitLink CLI 智能化能力提升项目所有变更,按子任务分类。
|
||||
> 最后更新:2026-07-07
|
||||
|
||||
---
|
||||
|
||||
## 子任务一:CLI 能力扩展
|
||||
|
||||
### 新增 Shortcut 命令组
|
||||
|
||||
在原有 10 个命令组(branch/ci/common/issue/org/pr/release/repo/search/user)基础上,新增 13 个命令组:
|
||||
|
||||
| 命令组 | 子命令数 | 代码行数 | 单元测试 | 作者 | 说明 |
|
||||
|--------|---------|---------|---------|------|------|
|
||||
| webhook | 20 | 533 | ✅ | Mengz | Webhook 管理(增删改查+测试) |
|
||||
| wiki | 13 | 555 | ✅ | 赵昌 | 仓库 Wiki 页面管理 |
|
||||
| snippet | 9 | 271 | ✅ | 赵昌 | 代码片段管理 |
|
||||
| file | 18 | 490 | ❌ | 刘焱 | 文件管理(上传/下载/批量推送) |
|
||||
| collaborator | 13 | 69 | ✅ | 刘焱 | 协作者管理 |
|
||||
| milestone | 14 | 224 | ❌ | 刘焱 | 里程碑管理 |
|
||||
| commit | 13 | 172 | ❌ | 刘焱 | 提交记录查询 |
|
||||
| template | 10 | 150 | ❌ | 刘焱 | 模板管理(导入/导出) |
|
||||
| label | 11 | 218 | ❌ | 刘焱 | 标签管理 |
|
||||
| dataset | 9 | 123 | ❌ | 刘焱 | 数据集管理 |
|
||||
| tag | 7 | 138 | ✅ | 刘焱 | 标签(Release Tag)管理 |
|
||||
| sshkey | 8 | 96 | ❌ | 刘焱 | SSH 公钥管理 |
|
||||
| attachment | 4 | 53 | ❌ | 刘焱 | 附件管理 |
|
||||
| util | 3 | 46 | ❌ | 刘焱 | 通用工具函数 |
|
||||
|
||||
**合计**:新增 149 个子命令,约 3000 行 Go 代码,6 个命令组配有单元测试。
|
||||
|
||||
### 已有命令组扩展
|
||||
|
||||
对原有命令组也进行了功能增强:
|
||||
- `ci` — 新增 save-yaml、run-pipeline、delete-pipeline 等 DevOps 流水线管理命令
|
||||
- `pr` — 新增 batch-merge、batch-close、version-diff、versions 等批量操作命令
|
||||
- `issue` — 新增 batch-update 批量更新命令
|
||||
- `repo` — 新增 code-stats、contributors-stat 等统计分析命令
|
||||
|
||||
详细变更说明:[webhook-shortcut.md](./webhook-shortcut.md)
|
||||
|
||||
---
|
||||
|
||||
## 子任务二:AI Agent Skills
|
||||
|
||||
### 新增 Skills
|
||||
|
||||
在原有 Skills 基础上,团队新增 16 个自定义 Skill:
|
||||
|
||||
| Skill | 作者 | 说明 |
|
||||
|-------|------|------|
|
||||
| gitlink-community-ops | 赵昌 | 社区运营自动化(Issue 分拣→周报→Release Notes) |
|
||||
| gitlink-quality-gate | 赵昌 | 代码质量门禁(PR 审查→CI 检查→门禁决策) |
|
||||
| gitlink-project-health | 赵昌 | 项目健康度分析 |
|
||||
| gitlink-issue-triage | 赵昌 | Issue 自动分拣 |
|
||||
| gitlink-newcomer-guide | 赵昌 | 新人引导 |
|
||||
| gitlink-pr-deep-review | 赵昌 | PR 深度审查 |
|
||||
| gitlink-research-hotspot | 赵昌 | 科研热点追踪与知识图谱 |
|
||||
| gitlink-research-insight | 赵昌 | 科研项目洞悉 |
|
||||
| gitlink-snippet | 赵昌 | 代码片段 Skill |
|
||||
| gitlink-wiki | 赵昌 | Wiki 管理 Skill |
|
||||
| gitlink-contributor-growth | 刘焱 | 贡献者成长体系 |
|
||||
| gitlink-multi-repo-coordination | 刘焱 | 多仓库协同 |
|
||||
| gitlink-project-bootstrap | 刘焱 | 项目一键初始化 |
|
||||
| gitlink-research-compliance | 曾蔚然 | 科研合规与复现性 |
|
||||
| gitlink-research-matching | 曾蔚然 | 科研协作智能匹配 |
|
||||
| gitlink-research-progress | 曾蔚然 | 科研进度跟踪与预警 |
|
||||
|
||||
每个 Skill 包含:`SKILL.md`(命令参考)+ `examples/`(使用示例)+ `scripts/`(可复现脚本)。
|
||||
|
||||
---
|
||||
|
||||
## 子任务三:端到端自动化工作流
|
||||
|
||||
5 个端到端场景,统一采用"三阶段脚本 + JS 分析引擎"架构:
|
||||
|
||||
| 场景 | 串联脚本 | 分析引擎 | 作者 |
|
||||
|------|---------|---------|------|
|
||||
| 社区运营自动化 | community-ops-full.sh | health-analyze.js | 赵昌 |
|
||||
| 代码质量门禁 | quality-gate-full.sh | scoring-engine.js | 赵昌 |
|
||||
| 项目一键初始化 | project-bootstrap-full.sh | skeleton-gen.js | 刘焱 |
|
||||
| 多仓库协同 | multi-repo-full.sh | dashboard-gen.js | 刘焱 |
|
||||
| 贡献者成长体系 | contributor-growth-full.sh | growth-analyze.js | 刘焱 |
|
||||
|
||||
---
|
||||
|
||||
## 子任务四:科研辅助模块
|
||||
|
||||
5 大科研辅助模块,覆盖科研全生命周期:
|
||||
|
||||
| 模块 | Skill | 功能 |
|
||||
|------|-------|------|
|
||||
| 科研项目洞悉 | gitlink-research-insight | 8 维数据采集 + 4 维评分 |
|
||||
| 热点追踪与知识图谱 | gitlink-research-hotspot | 多关键词搜索 + 实体关系图谱 |
|
||||
| 科研合规与复现性 | gitlink-research-compliance | 三维复现性证据链 |
|
||||
| 科研协作智能匹配 | gitlink-research-matching | 角色匹配 + 缺口识别 |
|
||||
| 科研进度跟踪与预警 | gitlink-research-progress | 6 维动态风险信号 |
|
||||
|
||||
---
|
||||
|
||||
## DevOps 流水线
|
||||
|
||||
1 条已部署的建木流水线,push 代码时自动触发集成和部署:
|
||||
|
||||
| 流水线 | 触发方式 | 功能 | 文件 |
|
||||
|--------|---------|------|------|
|
||||
| 构建流水线 | push | 拉取代码 → 编译 → 部署到 /usr/local/bin → 验证 | `.devops/构建流水线.yml` |
|
||||
|
||||
另有 3 条流水线 YAML 配置已提交到仓库但未部署(项目初始化、多仓库协同、贡献者成长)。
|
||||
|
||||
---
|
||||
|
||||
## 操作中心
|
||||
|
||||
新增 `/gitlink` 斜杠命令作为统一入口,16 项操作上下选择执行。配置文件:`.claude/commands/gitlink.md`
|
||||
|
|
@ -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.
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,257 @@
|
|||
# 端到端工作流说明文档
|
||||
|
||||
> 子任务三 · 5 个端到端自动化场景
|
||||
> 最后更新:2026-07-07
|
||||
|
||||
## 一、总体架构
|
||||
|
||||
### 1.1 设计理念
|
||||
|
||||
5 个端到端场景统一采用"三阶段脚本 + JS 分析引擎 + DevOps 流水线"架构:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ DevOps 流水线层 │
|
||||
│ (1 条已部署 · push 触发) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 端到端编排层 │
|
||||
│ 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` — 分析引擎
|
||||
|
||||
## 三、场景二:代码质量门禁
|
||||
|
||||
### 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-local-ci-check.sh)
|
||||
│ ├── 本地 go build 编译验证
|
||||
│ ├── 测试覆盖率
|
||||
│ └── 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`(80 行)
|
||||
- `skills/gitlink-quality-gate/scripts/03-local-ci-check.sh` — 本地 CI 检查
|
||||
|
||||
## 四、场景三:项目一键初始化
|
||||
|
||||
### 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`(61 行)
|
||||
- `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`(70 行)
|
||||
- `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`(56 行)
|
||||
- `skills/gitlink-contributor-growth/scripts/growth-analyze.js` — 分析引擎
|
||||
- `.devops/贡献者成长体系流水线.yml`(定时触发)
|
||||
|
||||
## 七、DevOps 流水线
|
||||
|
||||
### 7.1 已部署流水线
|
||||
|
||||
| 流水线 | 触发方式 | 功能 | 文件 |
|
||||
|--------|----------|------|------|
|
||||
| 构建流水线 | push | 拉取代码 → 编译 → 部署到 /usr/local/bin → 验证 | `.devops/构建流水线.yml` |
|
||||
|
||||
该流水线已在 GitLink DevOps 平台部署并成功运行,push 代码时自动触发集成和部署。
|
||||
|
||||
### 7.2 流水线 YAML 配置(未部署)
|
||||
|
||||
以下 YAML 配置文件已提交到仓库,但尚未在建木平台部署:
|
||||
|
||||
| 流水线 | 触发方式 | 对应场景 | 文件 |
|
||||
|--------|----------|----------|------|
|
||||
| 项目初始化 | 手动 | 场景三 | `.devops/项目初始化流水线.yml` |
|
||||
| 多仓库协同 | 手动 | 场景四 | `.devops/多仓库协同流水线.yml` |
|
||||
| 贡献者成长 | 定时 | 场景五 | `.devops/贡献者成长体系流水线.yml` |
|
||||
|
||||
社区运营(场景一)和代码质量门禁(场景二)通过手动执行脚本运行。所有流水线配置 DRY_RUN 默认安全模式,SSH 密钥用 `((work_together.ssh_key))` 密文引用。
|
||||
|
||||
— 文档完 —
|
||||
Binary file not shown.
Binary file not shown.
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,16 +54,41 @@ func Shortcuts() []*common.Shortcut {
|
|||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
|
||||
if 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", fmt.Sprintf("/v1%s/actions/runs", ctx.RepoPath()), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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,222 @@ 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"},
|
||||
{Name: "name", Short: "n", Usage: "Pipeline name (optional)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
if n := ctx.Arg("name"); n != "" {
|
||||
q.Set("name", n)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue