forked from Gitlink/gitlink-cli
Compare commits
No commits in common. "master" and "test3" have entirely different histories.
|
|
@ -1,119 +0,0 @@
|
|||
---
|
||||
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)。
|
||||
- 用户随时可输入「返回」回到主菜单,或「退出」结束。
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
version: 2
|
||||
name: 构建流水线
|
||||
description: "gitlink-cli CI 流水线:拉取代码、构建、部署、验证"
|
||||
description: "gitlink-cli CI 流水线:拉取代码、构建、验证"
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
|
|
@ -20,14 +20,14 @@ workflow:
|
|||
name: 开始
|
||||
task: start
|
||||
- ref: ssh_cmd_0
|
||||
name: 拉取代码并构建部署
|
||||
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"'
|
||||
ssh_cmd: '"export GOPROXY=https://goproxy.cn,direct && cd /root/gitlink-cli || git clone https://gitlink.org.cn/z2_cc/gitlink-cli.git /root/gitlink-cli; cd /root/gitlink-cli && git pull origin master && go build -o gitlink-cli . && ./gitlink-cli version"'
|
||||
needs:
|
||||
- start
|
||||
- ref: end
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
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
|
||||
|
|
@ -18,21 +18,3 @@ 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
|
||||
|
|
|
|||
264
CLAUDE.md
264
CLAUDE.md
|
|
@ -1,264 +0,0 @@
|
|||
# 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` — 共享基础(认证、安全规则)
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,398 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,317 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,433 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
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,7 +10,6 @@ import (
|
|||
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
||||
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
|
||||
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
|
||||
interactiveCmd "github.com/gitlink-org/gitlink-cli/cmd/interactive"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
)
|
||||
|
||||
|
|
@ -43,8 +42,6 @@ func init() {
|
|||
rootCmd.AddCommand(versionCmd)
|
||||
|
||||
shortcuts.RegisterAll(rootCmd)
|
||||
|
||||
rootCmd.AddCommand(interactiveCmd.NewInteractiveCmd())
|
||||
}
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
|
|
|
|||
|
|
@ -1,308 +0,0 @@
|
|||
# Release Notes — gitlink-cli
|
||||
|
||||
> 官方 [GitLink](https://www.gitlink.org.cn) 命令行工具,面向人类用户和 AI Agent。
|
||||
> 支持 macOS、Linux 和 Windows,覆盖仓库管理、Issue 跟踪、Pull Request、Webhook、CI/CD 及 AI 驱动的工作流。
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0 (2026-06-17) — 交互式 REPL 与输出大重构
|
||||
|
||||
### 里程碑亮点
|
||||
|
||||
- ✨ **交互式 REPL 模式**:新增基于 Bubble Tea TUI 框架的命令面板、参数表单与滚动视口
|
||||
- 📊 **输出系统重构**:统一表格渲染,终结 JSON 回退;支持嵌套对象层级展开、值截断、动态列合并
|
||||
- 🌏 **全命令中文覆盖**:全部 25+ 命令组的子命令描述、参数注释、帮助文本完成中文化
|
||||
- 🐛 **P1-P4 缺陷修复**:修复 16 项实测发现的问题,涉及 collaborator、branch、file、tag、interactive 等多个模块
|
||||
- 🧩 **3 个新 AI Agent Skill**:issue-triage、project-health、newcomer-guide
|
||||
- 🧪 **大规模测试**:新增交互模式冒烟测试、output 表格渲染单元测试、hook-runner 测试等
|
||||
|
||||
### 新增功能
|
||||
|
||||
#### 交互式 REPL (Bubble Tea TUI)
|
||||
|
||||
- **命令面板** (`/` 快捷键唤起):实时搜索 80+ 命令,支持模糊匹配
|
||||
- **参数表单**:选中命令后自动弹出表单填写 flags,支持必填/可选参数
|
||||
- **滚动视口**:长输出支持上下滚动浏览,不再截断
|
||||
- **默认 table 输出**:REPL 中统一使用表格格式,告别 JSON 裸输出
|
||||
- **Ctrl+C/Esc**:正确的退出与返回行为
|
||||
- **Smoke Test**:所有注册命令执行不 panic 的自动化验证
|
||||
|
||||
#### 输出系统重构
|
||||
|
||||
- **`printSliceTable` 增强**:表头取自所有行的字段并集,不再仅依赖第一行
|
||||
- **嵌套 Map 详情视图**:单对象详情(如 `tag +view`、`repo +info`)渲染为可读层级表格,`└` 缩进展开子字段,不再回退 JSON
|
||||
- **原生 Map Slice 表格化**:`[]map[string]string` 等本地构造数据也渲染为表格,不再显示 `[N items]`
|
||||
- **值截断与列限制**:超长值(URL、SHA、commit message)自动截断,核心列优先固定
|
||||
- **已废弃**:`hasOnlyNestedMaps` JSON 回退逻辑移除
|
||||
|
||||
#### Skill 与 Workflow
|
||||
|
||||
- 新增 3 个 AI Agent Skill:
|
||||
- `gitlink-issue-triage` — Issue 分类与优先级评估
|
||||
- `gitlink-project-health` — 项目健康度洞察
|
||||
- `gitlink-newcomer-guide` — 新贡献者引导
|
||||
- `gitlink-webhook` Skill:新增 `+failed`、`+task-view` 命令
|
||||
- 新增 `gitlink-wiki`、`gitlink-snippet` Skill
|
||||
- 新增 Workflow 自动化示例:issue-triage、project-health、newcomer-guide
|
||||
|
||||
#### 命令增强
|
||||
|
||||
- **Webhook**:`+failed` 查看失败投递、`+task-view` 支持 UUID/数字 ID 双查询、`--limit` 分页支持
|
||||
- **Shortcuts**:导出 `GetAllShortcuts` / `GetDescriptions` 供交互模式消费
|
||||
|
||||
### Bug 修复
|
||||
|
||||
| 模块 | 问题 | 修复 |
|
||||
|------|------|------|
|
||||
| **collaborator** | `change-role`/`batch-role` 请求体格式错误导致"未找到用户" | 改为 `{user_id: 整数, role: string}` 格式;自动解析用户名到数字 ID |
|
||||
| **branch** | protect/unprotect 接口路径误加 `/v1` 导致 404 | 走无 `/v1` 老接口 |
|
||||
| **branch** | `+list` 字段过多导致表格不稳定 | 投影到核心列:name/protected/last_commit/commit_time |
|
||||
| **file** | `+get` 端点调用了错误 API | 改为 `/entries` 提取单文件元信息 |
|
||||
| **file** | `+tree --recursive` 参数被 API 拒绝 | 移除无效 flag |
|
||||
| **file** | `+create`/`+update`/`+delete` 缺少必需参数 | 补 `--new-branch`、`--sha` 改为必填 |
|
||||
| **file** | `+replace` 端点已废弃 | 移除该命令 |
|
||||
| **tag** | `+view` 调用了不存在端点导致"标签不存在" | 改用列表过滤,新增 `findTagByName` |
|
||||
| **interactive** | 带可选参数的命令跳过表单直接执行 | 改为"有任何 flag 都弹表单" |
|
||||
| **interactive** | nil Client 导致 panic | `NewRuntimeContext` 初始化 |
|
||||
| **interactive** | `/` 键响应延迟 | 立即响应而非等待后续输入 |
|
||||
| **output** | 原生 `[]map[string]string` 不识别为表格 | reflect 统一处理 |
|
||||
| **output** | 单对象嵌套 Map 回退 JSON | 层级表格展开 |
|
||||
|
||||
### 文档与国际化
|
||||
|
||||
- **全部 25+ 命令组描述中文化**:repo / pr / issue / branch / tag / file / wiki / webhook / snippet / user / org / ci / search / label / milestone / release / commit / collaborator / attachment / dataset / template / util / sshkey / shortcut / interactive
|
||||
- **Shortcuts 模块详细中文注释**:参数映射(CLI→API)、批处理模式、设计决策
|
||||
- **P1-P4 缺陷中文标记**:issue +create --label 遗漏、hardcoded 'master' 等已知问题文档化
|
||||
- **交互模式设计文档**:实现计划 + 命令面板设计规格
|
||||
|
||||
### 基础设施
|
||||
|
||||
- **依赖**:引入 `bubbletea`、`bubbles`、`huh` TUI 框架
|
||||
- **CI**:DevOps 流水线(构建+验证)
|
||||
- **测试**:interactive smoke test、output 表格渲染回归测试、tag `+view` 单元测试、hook-runner 单元测试
|
||||
|
||||
### 贡献者
|
||||
|
||||
自 v0.1.18 以来的贡献者(按字母顺序):
|
||||
|
||||
2403_89190320, Leo77, Mengz, Tiger, s2_cc, wangyue789, wqer, yangsai01, 刘焱, 赵昌
|
||||
|
||||
---
|
||||
|
||||
## v0.1.18 (2026-05-19) — Webhook 集成与 Skills 扩展
|
||||
|
||||
> 这也是 v0.2.0 之前带交互式 REPL 内测功能的过渡版本。
|
||||
|
||||
### 核心变更
|
||||
|
||||
- **交互模式 MVP**:首次合入 Bubble Tea REPL 基础框架(命令执行器 + flag 解析 + TUI 入口),但随后回退(`Revert "Add TUI foundation"`),留待 v0.2.0 正式发布
|
||||
- **npm 安装**:修复 Windows 二进制缺失问题,改进安装后诊断
|
||||
|
||||
---
|
||||
|
||||
## v0.1.17 (2026-05-17) — Skills 生态与包管理器支持
|
||||
|
||||
### 新功能
|
||||
|
||||
- **3 个新 Skill**:`gitlink-code-review`(代码审查)、`gitlink-insight`(仓库洞察)、`gitlink-compliance`(合规检查)
|
||||
- **2 个自动化 Skill**:`gitlink-release-auto`(自动发版)、`gitlink-commit-quality`(提交信息规范检查)
|
||||
- **包管理器支持**:Homebrew(macOS/Linux)、Scoop(Windows)、Chocolatey(Windows)
|
||||
- **CI**:GitHub Release 工作流与 npm 自动发布
|
||||
- **npm 安装增强**:校验和验证、错误消息改进、版本检查
|
||||
|
||||
### 命令增强
|
||||
|
||||
- **PR**:新增 `+review` 审查快捷、`+patchset` 补丁集版本切换
|
||||
- **PR**:`+comment` 评论 CRUD
|
||||
- **Issue**:`+batch-close` 批量关闭(dry-run 支持)
|
||||
- **PR/Issue**:统一使用 project-level 编号替代数据库 ID
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- **Issue**: `+list` 输出使用项目级 Issue 编号
|
||||
- **PR**: 统一使用 `pull_request_number` 替代 `pull_request_id`
|
||||
- **Issue**: `update` 时保留描述字段
|
||||
- **npm**: Windows 二进制缺失导致静默退出
|
||||
|
||||
### 文档
|
||||
|
||||
- Skills REFERENCE.md 参考文档
|
||||
- README 更新至 v0.1.17(12 Skills、npm 一键安装)
|
||||
- Fork-based PR 工作流指南
|
||||
- Skill 工具边界规则(防止在 GitLink 上误用 gh/hub)
|
||||
|
||||
---
|
||||
|
||||
## v0.1.14 (2026-05-17) — 全 API 覆盖里程碑
|
||||
|
||||
### 新功能
|
||||
|
||||
- **40+ 新命令 shortcut**:
|
||||
- **Repo**:22 个新 shortcut(settings/stats/social/transfer/invite/migrate/topics)
|
||||
- **User**:22 个新 shortcut(messages/stats/transfers/settings/feedback)
|
||||
- **PR**:comments CRUD、commits、reopen、update
|
||||
- **Issue**:comments CRUD、delete、batch-update、batch-destroy
|
||||
- **Branch**:all/default/restore;**Release**:edit/update
|
||||
- **协作**:org team-projects;**File**:batch/entries/replace
|
||||
- **CI 流水线**、**Wiki 导入/导出**、**Repo 加入/退出/迁移/话题**
|
||||
- **Util**、**Dataset**、**Template**、**Attachment** 快捷组
|
||||
- **代码片段管理**:`snippet +list/+view/+create/+delete`
|
||||
- **看板**:`board +view/+columns/+move`
|
||||
- **OAuth2 认证**、**颜色检测与 CJK 宽度支持**
|
||||
- **Wiki 命令改用 Git 操作**:`+list/+view/+create/+update/+delete` 重构为 Git-based
|
||||
|
||||
### 跨平台
|
||||
|
||||
- **Windows (x64/arm64)** 完整支持
|
||||
- 终端颜色自动检测与中文字符宽度适配
|
||||
|
||||
### Skills
|
||||
|
||||
- **6 个新 Skills**:`gitlink-repo`、`gitlink-issue`、`gitlink-pr`、`gitlink-ci`、`gitlink-search`、`gitlink-workflow`
|
||||
- **Skill 架构升级**:Anthropic Agent Skills 标准
|
||||
- **pm / org / user / release / branch** 等 Skill 持续完善
|
||||
|
||||
### 基础设施
|
||||
|
||||
- npm 包 `@gitlink-ai/cli` 分发上线
|
||||
- `GITLINK_TOKEN` 环境变量支持非交互式认证
|
||||
- `gitlink-cli-install-skills` 跨平台安装命令
|
||||
|
||||
### 项目健康
|
||||
|
||||
- 新增 CONTRIBUTORS 章节展示项目贡献者
|
||||
- 许可证切换至 MulanPSL-2.0(木兰宽松许可证)
|
||||
- README 中英双语
|
||||
|
||||
---
|
||||
|
||||
## v0.1.8 ~ v0.1.13 (2026-04-07 ~ 2026-04-15) — 功能夯实
|
||||
|
||||
### v0.1.13 (2026-04-15)
|
||||
|
||||
- **npm 安装方案**:修复 GitHub Release asset 匹配回退与相对 URL 拼接问题
|
||||
- 改进 release 创建时默认 `target_commitish=master`
|
||||
- Issue 创建默认 `priority_id=2`
|
||||
|
||||
### v0.1.12 (2026-04-15)
|
||||
|
||||
- Webhook 快捷命令组正式合入
|
||||
- `hook-runner` 集成为 `webhook` 子命令
|
||||
- 新增 `--limit` 标志与 UUID 显示改进
|
||||
|
||||
### v0.1.11 (2026-04-08)
|
||||
|
||||
- **PR 审查**:`+review` 审查提交
|
||||
- **PR 补丁集**:`+patchset-versions` 版本切换
|
||||
- 3 个新 Skills:`gitlink-code-review`、`gitlink-insight`、`gitlink-compliance`
|
||||
|
||||
### v0.1.10 (2026-04-08)
|
||||
|
||||
- 2 个自动化 Skill:`gitlink-release-auto`、`gitlink-commit-quality`
|
||||
- Webhook 端点测试修复
|
||||
|
||||
### v0.1.9 (2026-04-07)
|
||||
|
||||
- **Webhook shortcut 组**正式版
|
||||
- Skills 文档完善
|
||||
|
||||
### v0.1.8 (2026-04-07)
|
||||
|
||||
- **Branch Skill**:`gitlink-branch` 保护分支管理
|
||||
- PR `+comment` 快捷
|
||||
- Issue `+batch-close` 批量关闭
|
||||
- Issue v1 API 迁移,使用 `project_issues_index` 替代数据库 ID
|
||||
- 修复 Issue 描述 update 丢失问题
|
||||
|
||||
---
|
||||
|
||||
## v0.1.0-skill-test (2026-04-04) — Skill 概念验证
|
||||
|
||||
- Skill PR 全流程测试
|
||||
- 首个 Skill 原型验证
|
||||
- PR 创建、合入工作流测试
|
||||
|
||||
---
|
||||
|
||||
## v0.0.1-test-del / initial (2026-04-03) — 项目初始化
|
||||
|
||||
- `github.com/gitlink-org/gitlink-cli` 项目启动
|
||||
- 基础 CLI 框架(Cobra)
|
||||
- 初始命令:auth、repo、issue、pr
|
||||
- npm 分发基础 (`@gitlink-ai/cli`)
|
||||
- Cookie-based 登录与 token 认证
|
||||
|
||||
---
|
||||
|
||||
## 统计一览
|
||||
|
||||
| 版本 | 日期 | 新增文件 | 代码变更 | 贡献者 |
|
||||
|------|------|----------|----------|--------|
|
||||
| init → v0.1.8 | 2026-04 | ~30 | ~8k | 5 |
|
||||
| v0.1.8 → v0.1.14 | 2026-04~05 | ~60 | ~15k | 8 |
|
||||
| v0.1.14 → v0.1.18 | 2026-05 | ~22 | +514/-1015 | 4 |
|
||||
| v0.1.18 → v0.2.0 | 2026-05~06 | 114 | +24531/-324 | 10 |
|
||||
|
||||
## Skills 一览
|
||||
|
||||
| Skill | 用途 | 引入版本 |
|
||||
|-------|------|----------|
|
||||
| `gitlink-repo` | 仓库管理 | v0.1.14 |
|
||||
| `gitlink-issue` | Issue 跟踪 | v0.1.14 |
|
||||
| `gitlink-pr` | Pull Request 管理 | v0.1.14 |
|
||||
| `gitlink-ci` | CI/CD 流水线 | v0.1.14 |
|
||||
| `gitlink-search` | 全局搜索 | v0.1.14 |
|
||||
| `gitlink-workflow` | 工作流自动化 | v0.1.14 |
|
||||
| `gitlink-branch` | 保护分支管理 | v0.1.8 |
|
||||
| `gitlink-pm` | 项目管理 | v0.1.14 |
|
||||
| `gitlink-org` | 组织管理 | v0.1.14 |
|
||||
| `gitlink-user` | 用户管理 | v0.1.14 |
|
||||
| `gitlink-release` | 发布管理 | v0.1.14 |
|
||||
| `gitlink-code-review` | 代码审查 | v0.1.11 |
|
||||
| `gitlink-insight` | 仓库洞察 | v0.1.11 |
|
||||
| `gitlink-compliance` | 合规检查 | v0.1.11 |
|
||||
| `gitlink-release-auto` | 自动发版 | v0.1.10 |
|
||||
| `gitlink-commit-quality` | 提交信息规范 | v0.1.10 |
|
||||
| `gitlink-webhook` | Webhook 管理 | v0.1.9 |
|
||||
| `gitlink-wiki` | Wiki 管理 | v0.2.0 |
|
||||
| `gitlink-snippet` | 代码片段管理 | v0.2.0 |
|
||||
| `gitlink-issue-triage` | Issue 分类与优先级 | v0.2.0 |
|
||||
| `gitlink-project-health` | 项目健康度 | v0.2.0 |
|
||||
| `gitlink-newcomer-guide` | 新贡献者引导 | v0.2.0 |
|
||||
| `gitlink-shared` | 共享工具库 | v0.2.0 |
|
||||
| `gitlink-workflow` | 工作流定义 | v0.2.0 |
|
||||
|
||||
---
|
||||
|
||||
## 升级指南
|
||||
|
||||
### v0.1.x → v0.2.0
|
||||
|
||||
- **交互模式**:运行 `gitlink-cli interactive` 或 `gitlink-cli tui` 进入 REPL
|
||||
- **输出格式**:v0.2.0 默认输出格式为 table,如需 JSON 请使用 `--output json` 或 `--json`
|
||||
- **File 命令**:`+replace` 已移除,请改用 `+create`/`+update`
|
||||
- **Tag 命令**:`+view` 行为变更(从单端点改为列表过滤),功能一致
|
||||
|
||||
### 首次安装
|
||||
|
||||
```bash
|
||||
# npm (推荐)
|
||||
npm install -g @gitlink-ai/cli
|
||||
|
||||
# macOS (Homebrew)
|
||||
brew tap gitlink-ai/tap
|
||||
brew install gitlink-cli
|
||||
|
||||
# Windows (Scoop)
|
||||
scoop bucket add gitlink https://github.com/gitlink-ai/scoop-bucket
|
||||
scoop install gitlink-cli
|
||||
|
||||
# Windows (Chocolatey)
|
||||
choco install gitlink-cli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> 完整的提交历史请参阅 [CHANGELOG](./changes/) 目录。
|
||||
>
|
||||
> 如发现任何问题,欢迎在 [GitLink Issues](https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues) 反馈。
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
# 变更说明汇总
|
||||
|
||||
> 本文件汇总 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`
|
||||
609
doc/demo.html
609
doc/demo.html
|
|
@ -1,609 +0,0 @@
|
|||
<!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>
|
||||
101
doc/demo.sh
101
doc/demo.sh
|
|
@ -1,101 +0,0 @@
|
|||
#!/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 ""
|
||||
|
|
@ -1,289 +0,0 @@
|
|||
# 子任务三 & 子任务四 — 工作交接文档
|
||||
|
||||
> 最后更新: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(子任务四)*
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
# 工作日志 — 子任务三 & 子任务四
|
||||
|
||||
> 项目: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.
92
doc/演示脚本.md
92
doc/演示脚本.md
|
|
@ -1,92 +0,0 @@
|
|||
# 项目汇报演示脚本
|
||||
|
||||
> 配合 `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 报告文件
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
# 科研辅助场景使用文档
|
||||
|
||||
> 子任务四 · 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 处理跨领域共性问题,科研层处理科研特有问题。
|
||||
|
||||
— 文档完 —
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
# 端到端工作流说明文档
|
||||
|
||||
> 子任务三 · 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
|
|
@ -1,355 +0,0 @@
|
|||
# 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)
|
||||
- 语法高亮
|
||||
24
go.mod
24
go.mod
|
|
@ -3,9 +3,6 @@ module github.com/gitlink-org/gitlink-cli
|
|||
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
|
||||
|
|
@ -13,32 +10,11 @@ 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,39 +1,9 @@
|
|||
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=
|
||||
|
|
@ -42,32 +12,12 @@ 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=
|
||||
|
|
@ -77,21 +27,13 @@ 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=
|
||||
|
|
|
|||
|
|
@ -130,7 +130,6 @@ 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),
|
||||
|
|
@ -184,23 +183,19 @@ 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 "Run 'gitlink auth login' to authenticate" // 未认证
|
||||
return "Run 'gitlink auth login' to authenticate"
|
||||
case 403:
|
||||
return "Permission denied. Check your account permissions or contact the project admin" // 权限不足
|
||||
return "Permission denied. Check your account permissions or contact the project admin"
|
||||
case 404:
|
||||
return "Resource not found. Verify --owner, --repo, and the resource ID" // 资源不存在
|
||||
return "Resource not found. Verify --owner, --repo, and the resource ID"
|
||||
case 422:
|
||||
return "Validation failed. Check the request parameters" // 参数校验失败
|
||||
return "Validation failed. Check the request parameters"
|
||||
case 500:
|
||||
return "Server error. Try again later or contact the platform admin" // 服务端错误
|
||||
return "Server error. Try again later or contact the platform admin"
|
||||
default:
|
||||
return "" // 其他状态码不给出具体建议
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"unicode"
|
||||
|
|
@ -73,13 +72,10 @@ func printTable(w io.Writer, envelope *Envelope) error {
|
|||
case []interface{}:
|
||||
return printSliceTable(w, data)
|
||||
case map[string]interface{}:
|
||||
// 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)
|
||||
// For maps with nested structures, prefer JSON
|
||||
if hasComplexValues(data) {
|
||||
return printJSON(w, envelope)
|
||||
}
|
||||
// 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
|
||||
|
|
@ -87,91 +83,32 @@ func printTable(w io.Writer, envelope *Envelope) error {
|
|||
}
|
||||
}
|
||||
|
||||
// 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{}
|
||||
func hasComplexValues(m map[string]interface{}) bool {
|
||||
for _, v := range m {
|
||||
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
|
||||
switch v.(type) {
|
||||
case map[string]interface{}, []interface{}:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return best
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// If the first item isn't a map, fall back to JSON (unchanged behavior).
|
||||
if _, ok := items[0].(map[string]interface{}); !ok {
|
||||
// Collect headers from first item
|
||||
first, ok := items[0].(map[string]interface{})
|
||||
if !ok {
|
||||
data, _ := json.MarshalIndent(items, "", " ")
|
||||
fmt.Fprintln(w, string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
headers := collectKeys(first)
|
||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
|
||||
// Print headers
|
||||
fmt.Fprintln(tw, strings.Join(headers, "\t"))
|
||||
|
|
@ -193,75 +130,31 @@ func printSliceTable(w io.Writer, items []interface{}) error {
|
|||
}
|
||||
fmt.Fprintln(tw, strings.Join(vals, "\t"))
|
||||
}
|
||||
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
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// 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 := range collectKeys(m) {
|
||||
writeMapRow(tw, k, m[k], 0)
|
||||
for k, v := range m {
|
||||
fmt.Fprintf(tw, "%s\t%s\n", k, formatValue(v))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// 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))
|
||||
func collectKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
// Prefer common keys first
|
||||
priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"}
|
||||
seen := map[string]bool{}
|
||||
for _, p := range preferredHeaders {
|
||||
if keySet[p] {
|
||||
keys = append(keys, p)
|
||||
seen[p] = true
|
||||
for _, k := range priority {
|
||||
if _, ok := m[k]; ok {
|
||||
keys = append(keys, k)
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
for k := range keySet {
|
||||
for k := range m {
|
||||
if !seen[k] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
|
@ -269,132 +162,22 @@ func orderKeySet(keySet map[string]bool) []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:
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
return formatNestedMap(m)
|
||||
}
|
||||
// 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.
|
||||
case reflect.Map, reflect.Slice:
|
||||
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)
|
||||
s := string(data)
|
||||
if len(s) > 60 {
|
||||
return s[:57] + "..."
|
||||
}
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
default:
|
||||
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
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,368 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"docx": "^9.7.1",
|
||||
"pptxgenjs": "^4.0.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -8,10 +8,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +upload:上传文件附件
|
||||
{
|
||||
Name: "upload",
|
||||
Description: "上传文件附件",
|
||||
Description: "Upload a file attachment",
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Short: "f", Usage: "Path to the file to upload", Required: true},
|
||||
},
|
||||
|
|
@ -30,10 +29,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:根据 UUID 删除文件附件
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "根据 UUID 删除文件附件",
|
||||
Description: "Delete a file attachment by UUID",
|
||||
Flags: []common.Flag{
|
||||
{Name: "uuid", Short: "u", Usage: "Attachment UUID to delete", Required: true},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,294 @@
|
|||
package board
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// v1RepoPath returns the v1 API path prefix.
|
||||
func v1RepoPath(ctx *common.RuntimeContext) string {
|
||||
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||
}
|
||||
|
||||
// Shortcuts returns project board management shortcuts.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "view",
|
||||
Description: "Show project board (issues grouped by status)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
|
||||
{Name: "milestone", Short: "m", Usage: "Milestone ID to filter by"},
|
||||
},
|
||||
Run: runView,
|
||||
},
|
||||
{
|
||||
Name: "columns",
|
||||
Description: "List available board columns (issue statuses)",
|
||||
Run: runColumns,
|
||||
},
|
||||
{
|
||||
Name: "move",
|
||||
Description: "Move an issue to a different status column",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
|
||||
{Name: "state", Short: "s", Usage: "Target state: open, in_progress, resolved, closed, or numeric status_id", Required: true},
|
||||
},
|
||||
Run: runMove,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Default known status mapping (fallback if API call fails)
|
||||
var knownStatuses = map[int]string{
|
||||
1: "New",
|
||||
2: "In Progress",
|
||||
3: "Resolved",
|
||||
4: "Reopened",
|
||||
5: "Closed",
|
||||
6: "Rejected",
|
||||
}
|
||||
|
||||
// statusNameToID maps common names to status IDs
|
||||
// Supports both Chinese and English names
|
||||
var statusNameToID = map[string]int{
|
||||
"open": 1,
|
||||
"new": 1,
|
||||
"新增": 1,
|
||||
"in_progress": 2,
|
||||
"inprogress": 2,
|
||||
"wip": 2,
|
||||
"正在解决": 2,
|
||||
"resolved": 3,
|
||||
"已解决": 3,
|
||||
"reopened": 4,
|
||||
"closed": 5,
|
||||
"close": 5,
|
||||
"关闭": 5,
|
||||
"rejected": 6,
|
||||
"拒绝": 6,
|
||||
"feedback": 6,
|
||||
}
|
||||
|
||||
// --- Commands ---
|
||||
|
||||
func runView(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("limit", "100")
|
||||
if s := ctx.Arg("state"); s != "" {
|
||||
q.Set("state", s)
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
q.Set("fixed_version_id", m)
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse issues and group by status
|
||||
raw, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
issuesRaw, ok := raw["issues"].([]interface{})
|
||||
if !ok {
|
||||
// No issues, return empty board
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"columns": []map[string]interface{}{},
|
||||
"total": 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Group issues by status_id
|
||||
columns := make(map[int][]map[string]interface{})
|
||||
statusOrder := make([]int, 0)
|
||||
|
||||
for _, rawIssue := range issuesRaw {
|
||||
issue, ok := rawIssue.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract number
|
||||
number := 0
|
||||
if n, ok := issue["project_issues_index"].(float64); ok {
|
||||
number = int(n)
|
||||
} else if n, ok := issue["number"]; ok {
|
||||
if v, ok := n.(float64); ok {
|
||||
number = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract status_id
|
||||
statusID := 0
|
||||
if s, ok := issue["status_id"]; ok {
|
||||
if v, ok := s.(float64); ok {
|
||||
statusID = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract subject
|
||||
subject, _ := issue["subject"].(string)
|
||||
|
||||
// Extract assignee
|
||||
assignee := ""
|
||||
if assigned, ok := issue["assigned_to"]; ok {
|
||||
if a, ok := assigned.(map[string]interface{}); ok {
|
||||
if login, ok := a["login"].(string); ok {
|
||||
assignee = login
|
||||
} else if name, ok := a["name"].(string); ok {
|
||||
assignee = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item := map[string]interface{}{
|
||||
"number": number,
|
||||
"subject": subject,
|
||||
"assignee": assignee,
|
||||
}
|
||||
|
||||
if _, exists := columns[statusID]; !exists {
|
||||
columns[statusID] = make([]map[string]interface{}, 0)
|
||||
statusOrder = append(statusOrder, statusID)
|
||||
}
|
||||
columns[statusID] = append(columns[statusID], item)
|
||||
}
|
||||
|
||||
// Sort status IDs
|
||||
sort.Ints(statusOrder)
|
||||
|
||||
// Build output
|
||||
boardColumns := make([]map[string]interface{}, 0)
|
||||
for _, sid := range statusOrder {
|
||||
name, ok := knownStatuses[sid]
|
||||
if !ok {
|
||||
name = fmt.Sprintf("Status %d", sid)
|
||||
}
|
||||
boardColumns = append(boardColumns, map[string]interface{}{
|
||||
"id": sid,
|
||||
"name": name,
|
||||
"issues": columns[sid],
|
||||
"count": len(columns[sid]),
|
||||
})
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, col := range boardColumns {
|
||||
total += col["count"].(int)
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"columns": boardColumns,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
func runColumns(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Try fetching from API first
|
||||
env, err := ctx.CallAPI("GET", v1RepoPath(ctx)+"/issue_statues", nil)
|
||||
if err == nil {
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
// Fallback to known statuses
|
||||
type statusInfo struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
statuses := make([]statusInfo, 0)
|
||||
for id, name := range knownStatuses {
|
||||
statuses = append(statuses, statusInfo{ID: id, Name: name})
|
||||
}
|
||||
sort.Slice(statuses, func(i, j int) bool {
|
||||
return statuses[i].ID < statuses[j].ID
|
||||
})
|
||||
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"statuses": statuses,
|
||||
})
|
||||
}
|
||||
|
||||
func runMove(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state := ctx.Arg("state")
|
||||
if state == "" {
|
||||
return fmt.Errorf("required flag --state is missing")
|
||||
}
|
||||
|
||||
// Resolve status_id from state string or numeric
|
||||
var statusID int
|
||||
if id, err := strconv.Atoi(state); err == nil {
|
||||
statusID = id
|
||||
} else if id, ok := statusNameToID[strings.ToLower(strings.TrimSpace(state))]; ok {
|
||||
statusID = id
|
||||
} else {
|
||||
return fmt.Errorf("invalid --state %q: use open, in_progress, resolved, closed, or a numeric status_id", state)
|
||||
}
|
||||
|
||||
// Fetch current issue to preserve subject and description
|
||||
current, err := fetchIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch issue: %w", err)
|
||||
}
|
||||
|
||||
// Update the issue status
|
||||
payload := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
"status_id": statusID,
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
type existingIssue struct {
|
||||
Subject string
|
||||
Description string
|
||||
}
|
||||
|
||||
func fetchIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
|
||||
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, ok := getEnv.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to parse issue data")
|
||||
}
|
||||
subject, _ := data["subject"].(string)
|
||||
description, _ := data["description"].(string)
|
||||
return &existingIssue{
|
||||
Subject: subject,
|
||||
Description: description,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
package board
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
func runBoardShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findBoardShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "test-owner",
|
||||
Repo: "test-repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = map[string]string{}
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findBoardShortcut(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 newBoardTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRequest(t *testing.T, r *http.Request, method, path string) {
|
||||
t.Helper()
|
||||
if r.Method != method || r.URL.Path != path {
|
||||
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
|
||||
}
|
||||
}
|
||||
|
||||
func assertQueryParam(t *testing.T, r *http.Request, key, expected string) {
|
||||
t.Helper()
|
||||
actual := r.URL.Query().Get(key)
|
||||
if actual != expected {
|
||||
t.Fatalf("query param %q = %q, want %q", key, actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestBoardView(t *testing.T) {
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issues.json")
|
||||
assertQueryParam(t, r, "limit", "100")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 3,
|
||||
"issues": []interface{}{
|
||||
map[string]interface{}{
|
||||
"project_issues_index": float64(1),
|
||||
"subject": "Fix login bug",
|
||||
"status_id": float64(1),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"project_issues_index": float64(2),
|
||||
"subject": "Add dark mode",
|
||||
"status_id": float64(2),
|
||||
"assigned_to": map[string]interface{}{
|
||||
"login": "dev1",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"project_issues_index": float64(3),
|
||||
"subject": "Update docs",
|
||||
"status_id": float64(5),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "view", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("board view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardViewWithStateFilter(t *testing.T) {
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issues.json")
|
||||
assertQueryParam(t, r, "state", "closed")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 1,
|
||||
"issues": []interface{}{
|
||||
map[string]interface{}{
|
||||
"project_issues_index": float64(3),
|
||||
"subject": "Closed issue",
|
||||
"status_id": float64(5),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "view", map[string]string{
|
||||
"state": "closed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("board view with state filter failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardViewWithMilestoneFilter(t *testing.T) {
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issues.json")
|
||||
assertQueryParam(t, r, "fixed_version_id", "42")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 0,
|
||||
"issues": []interface{}{},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "view", map[string]string{
|
||||
"milestone": "42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("board view with milestone filter failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardViewEmpty(t *testing.T) {
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 0,
|
||||
"issues": []interface{}{},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "view", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("board view for empty repo failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardColumns(t *testing.T) {
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issue_statues.json")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"statues": []interface{}{
|
||||
map[string]interface{}{"id": float64(1), "name": "新增"},
|
||||
map[string]interface{}{"id": float64(2), "name": "正在解决"},
|
||||
map[string]interface{}{"id": float64(3), "name": "已解决"},
|
||||
map[string]interface{}{"id": float64(5), "name": "关闭"},
|
||||
},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "columns", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("board columns failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardColumnsFallback(t *testing.T) {
|
||||
// Server returns 404, which should trigger the fallback to known statuses
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "not found"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "columns", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("board columns fallback failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardMove(t *testing.T) {
|
||||
callCount := 0
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
// First call: fetch current issue
|
||||
assertRequest(t, r, "GET", "/v1/test-owner/test-repo/issues/5.json")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"subject": "Test issue",
|
||||
"description": "Test description",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Second call: update issue status
|
||||
assertRequest(t, r, "PATCH", "/v1/test-owner/test-repo/issues/5.json")
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode payload: %v", err)
|
||||
}
|
||||
if payload["status_id"] != float64(2) {
|
||||
t.Fatalf("status_id = %v, want 2", payload["status_id"])
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"id": 5, "status_id": 2})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||
"number": "5",
|
||||
"state": "in_progress",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("board move failed: %v", err)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardMoveWithChineseState(t *testing.T) {
|
||||
callCount := 0
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"subject": "Test",
|
||||
"description": "Test",
|
||||
})
|
||||
return
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["status_id"] != float64(5) {
|
||||
t.Fatalf("status_id = %v, want 5", payload["status_id"])
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||
"number": "1",
|
||||
"state": "关闭",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("board move with Chinese state failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardMoveRejectsMissingNumber(t *testing.T) {
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||
"state": "closed",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --number is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardMoveRejectsMissingState(t *testing.T) {
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||
"number": "1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --state is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardMoveRejectsInvalidState(t *testing.T) {
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("should not call API, got: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||
"number": "1",
|
||||
"state": "invalid_state_name",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardMoveWithNumericState(t *testing.T) {
|
||||
callCount := 0
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"subject": "Test",
|
||||
"description": "Test",
|
||||
})
|
||||
return
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["status_id"] != float64(3) {
|
||||
t.Fatalf("status_id = %v, want 3", payload["status_id"])
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||
"number": "1",
|
||||
"state": "3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("board move with numeric state failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardViewHandlesAPIError(t *testing.T) {
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "not found"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "view", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when API returns 404")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardMoveHandlesAPIFetchError(t *testing.T) {
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
writeJSON(t, w, map[string]interface{}{"message": "issue not found"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "move", map[string]string{
|
||||
"number": "999",
|
||||
"state": "closed",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when issue does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardViewGroupsIssuesCorrectly(t *testing.T) {
|
||||
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"issues": []interface{}{
|
||||
map[string]interface{}{
|
||||
"project_issues_index": float64(1),
|
||||
"subject": "Bug A",
|
||||
"status_id": float64(1),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"project_issues_index": float64(2),
|
||||
"subject": "Bug B",
|
||||
"status_id": float64(1),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"project_issues_index": float64(3),
|
||||
"subject": "Task A",
|
||||
"status_id": float64(2),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"project_issues_index": float64(4),
|
||||
"subject": "Done A",
|
||||
"status_id": float64(5),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runBoardShortcut(t, server, "view", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("board view grouping failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,8 @@ import (
|
|||
|
||||
func newBatchDeleteShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-delete:批量删除多个分支
|
||||
Name: "batch-delete",
|
||||
Description: "批量删除多个分支",
|
||||
Description: "Delete multiple branches",
|
||||
Long: `Delete multiple branches from the current repository in a single operation.
|
||||
|
||||
Provide branch names via --names (comma-separated) or --from (plain-text file
|
||||
|
|
@ -40,9 +39,8 @@ changes.`,
|
|||
|
||||
func newBatchProtectShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-protect:批量为多个分支开启保护
|
||||
Name: "batch-protect",
|
||||
Description: "批量为多个分支开启保护",
|
||||
Description: "Set branch protection for multiple branches",
|
||||
Long: `Enable branch protection for multiple branches in a single operation.
|
||||
|
||||
Provide branch names via --names (comma-separated) or --from (plain-text file
|
||||
|
|
@ -71,8 +69,6 @@ changes.`,
|
|||
|
||||
// --- batch-delete ---
|
||||
|
||||
// [B 类批量] 逐个删除分支——POST /branches/delete(不可逆)
|
||||
// 使用 CollectStrings 而非 CollectNumbers,分支名是字符串非数字
|
||||
func runBatchDelete(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -116,8 +112,6 @@ func runBatchDelete(ctx *common.RuntimeContext) error {
|
|||
|
||||
// --- batch-protect ---
|
||||
|
||||
// [B 类批量] 逐个设置分支保护——POST /protected_branches
|
||||
// 保护后的分支限制 force-push 和删除操作
|
||||
func runBatchProtect(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -137,7 +131,7 @@ func runBatchProtect(ctx *common.RuntimeContext) error {
|
|||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
_, err := ctx.CallAPI("POST", protectedBranchesPath(ctx.RepoPath()), payload)
|
||||
_, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
|
||||
return err
|
||||
})
|
||||
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
|
||||
|
|
@ -150,12 +144,3 @@ func runBatchProtect(ctx *common.RuntimeContext) error {
|
|||
}
|
||||
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,14 +9,11 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +batch-delete:批量删除多个分支
|
||||
newBatchDeleteShortcut(),
|
||||
// +batch-protect:批量为多个分支开启保护
|
||||
newBatchProtectShortcut(),
|
||||
// +list:列出仓库中的分支
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出分支",
|
||||
Description: "List branches",
|
||||
Long: `List all branches in the current repository.
|
||||
|
||||
Shows branch name, protection status, and other details.
|
||||
|
|
@ -41,19 +38,12 @@ Use --page and --limit for pagination.`,
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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),
|
||||
})
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:从指定来源创建新分支
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建分支",
|
||||
Description: "Create a branch",
|
||||
Long: `Create a new branch in the current repository.
|
||||
|
||||
The new branch is created from the specified source branch or commit.
|
||||
|
|
@ -73,8 +63,6 @@ If --from is omitted, the repository default branch is used.`,
|
|||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
from := ctx.Arg("from")
|
||||
// [P1 Bug#2] 原代码: from = "master"(硬编码)
|
||||
// 修复: 使用 GetDefaultBranch() 动态查询仓库默认分支
|
||||
if from == "" {
|
||||
from = common.GetDefaultBranch(ctx)
|
||||
}
|
||||
|
|
@ -89,10 +77,9 @@ If --from is omitted, the repository default branch is used.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除一个分支
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除分支",
|
||||
Description: "Delete a branch",
|
||||
Long: `Delete a branch from the current repository.
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before deletion.`,
|
||||
|
|
@ -121,10 +108,9 @@ This action is irreversible. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +protect:为分支开启保护
|
||||
{
|
||||
Name: "protect",
|
||||
Description: "开启分支保护",
|
||||
Description: "Set branch protection",
|
||||
Long: `Enable branch protection for the specified branch.
|
||||
|
||||
Protected branches restrict force-pushes and deletion.`,
|
||||
|
|
@ -141,17 +127,16 @@ Protected branches restrict force-pushes and deletion.`,
|
|||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", protectedBranchesPath(ctx.RepoPath()), payload)
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +unprotect:移除分支保护
|
||||
{
|
||||
Name: "unprotect",
|
||||
Description: "移除分支保护",
|
||||
Description: "Remove branch protection",
|
||||
Long: `Remove branch protection from the specified branch.
|
||||
|
||||
Once unprotected, the branch can be force-pushed or deleted.`,
|
||||
|
|
@ -165,17 +150,16 @@ Once unprotected, the branch can be force-pushed or deleted.`,
|
|||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
env, err := ctx.CallAPI("DELETE", protectedBranchesPath(ctx.RepoPath())+"/"+url.PathEscape(name), nil)
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +all:不分页列出全部分支
|
||||
{
|
||||
Name: "all",
|
||||
Description: "列出全部分支(不分页)",
|
||||
Description: "List all branches without pagination",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -187,10 +171,9 @@ Once unprotected, the branch can be force-pushed or deleted.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +default:修改仓库的默认分支
|
||||
{
|
||||
Name: "default",
|
||||
Description: "修改默认分支",
|
||||
Description: "Update the default branch",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name to set as default", Required: true},
|
||||
},
|
||||
|
|
@ -209,10 +192,9 @@ Once unprotected, the branch can be force-pushed or deleted.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +restore:恢复一个已删除的分支
|
||||
{
|
||||
Name: "restore",
|
||||
Description: "恢复已删除的分支",
|
||||
Description: "Restore a deleted branch",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name to restore", Required: true},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
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,10 +9,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +builds:列出 CI 构建记录
|
||||
{
|
||||
Name: "builds",
|
||||
Description: "列出 CI 构建记录",
|
||||
Description: "List CI builds",
|
||||
Long: `List CI/CD builds for the current repository.
|
||||
|
||||
Shows build number, status, branch, event type, and duration.
|
||||
|
|
@ -69,17 +68,16 @@ and --page and --limit for pagination.`,
|
|||
if u := ctx.Arg("until"); u != "" {
|
||||
q.Set("until", u)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/actions/runs", ctx.RepoPath()), q)
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +logs:查看构建日志
|
||||
{
|
||||
Name: "logs",
|
||||
Description: "查看构建日志",
|
||||
Description: "View build logs",
|
||||
Long: `View logs for a specific CI/CD build step.
|
||||
|
||||
Requires the build number. Use --stage and --step to navigate
|
||||
|
|
@ -114,10 +112,9 @@ to a particular stage and step within the build.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +restart:重新触发构建
|
||||
{
|
||||
Name: "restart",
|
||||
Description: "重新触发构建",
|
||||
Description: "Restart a build",
|
||||
Long: `Restart a previously completed or failed CI/CD build.
|
||||
|
||||
The build will be re-triggered with the same commit and configuration.`,
|
||||
|
|
@ -138,10 +135,9 @@ The build will be re-triggered with the same commit and configuration.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +stop:停止构建
|
||||
{
|
||||
Name: "stop",
|
||||
Description: "停止构建",
|
||||
Description: "Stop a build",
|
||||
Long: `Stop a currently running CI/CD build.
|
||||
|
||||
Prompts for confirmation before stopping the build.`,
|
||||
|
|
@ -170,10 +166,9 @@ Prompts for confirmation before stopping the build.`,
|
|||
|
||||
// --- Pipeline / Actions group ---
|
||||
|
||||
// +pipelines:列出 CI 流水线模板
|
||||
{
|
||||
Name: "pipelines",
|
||||
Description: "列出 CI 流水线模板",
|
||||
Description: "List CI pipeline templates",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/pm/pipelines", nil)
|
||||
if err != nil {
|
||||
|
|
@ -182,10 +177,9 @@ Prompts for confirmation before stopping the build.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +run-pipeline:运行 CI 流水线
|
||||
{
|
||||
Name: "run-pipeline",
|
||||
Description: "运行 CI 流水线",
|
||||
Description: "Run a CI pipeline",
|
||||
Flags: []common.Flag{
|
||||
{Name: "pipeline", Short: "p", Usage: "Pipeline name or ID", Required: true},
|
||||
{Name: "branch", Short: "b", Usage: "Branch to run on"},
|
||||
|
|
@ -211,10 +205,9 @@ Prompts for confirmation before stopping the build.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +runs:列出 CI 运行历史
|
||||
{
|
||||
Name: "runs",
|
||||
Description: "列出 CI 运行历史",
|
||||
Description: "List CI run history",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
|
|
@ -233,10 +226,9 @@ Prompts for confirmation before stopping the build.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +save-yaml:保存流水线 YAML 配置
|
||||
{
|
||||
Name: "save-yaml",
|
||||
Description: "保存流水线 YAML 配置",
|
||||
Description: "Save a pipeline YAML configuration",
|
||||
Flags: []common.Flag{
|
||||
{Name: "yaml", Short: "y", Usage: "YAML content", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Pipeline name"},
|
||||
|
|
@ -262,10 +254,9 @@ Prompts for confirmation before stopping the build.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +pipeline-detail:查看流水线详情
|
||||
{
|
||||
Name: "pipeline-detail",
|
||||
Description: "查看流水线详情",
|
||||
Description: "Show pipeline details",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Pipeline ID", Required: true},
|
||||
},
|
||||
|
|
@ -284,10 +275,9 @@ Prompts for confirmation before stopping the build.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-pipeline:删除流水线
|
||||
{
|
||||
Name: "delete-pipeline",
|
||||
Description: "删除流水线",
|
||||
Description: "Delete a pipeline",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Pipeline ID", Required: true},
|
||||
},
|
||||
|
|
@ -306,10 +296,9 @@ Prompts for confirmation before stopping the build.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +disable:为仓库禁用 CI
|
||||
{
|
||||
Name: "disable",
|
||||
Description: "为仓库禁用 CI",
|
||||
Description: "Disable CI for the repository",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -321,10 +310,9 @@ Prompts for confirmation before stopping the build.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +enable:为仓库启用 CI
|
||||
{
|
||||
Name: "enable",
|
||||
Description: "为仓库启用 CI",
|
||||
Description: "Enable CI for the repository",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -336,10 +324,9 @@ Prompts for confirmation before stopping the build.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +run-log:获取 CI 运行日志
|
||||
{
|
||||
Name: "run-log",
|
||||
Description: "获取 CI 运行日志",
|
||||
Description: "Get CI run log",
|
||||
Flags: []common.Flag{
|
||||
{Name: "run", Short: "r", Usage: "Run ID", Required: true},
|
||||
},
|
||||
|
|
@ -358,14 +345,12 @@ Prompts for confirmation before stopping the build.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +run-results:获取 CI 运行结果报告
|
||||
{
|
||||
Name: "run-results",
|
||||
Description: "获取 CI 运行结果报告",
|
||||
Description: "Get CI run results report",
|
||||
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 {
|
||||
|
|
@ -374,9 +359,6 @@ Prompts for confirmation before stopping the build.`,
|
|||
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
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// userRole 保存用户名和对应角色,支持 CSV 每行指定不同 role
|
||||
// 这是 collaborator 批量命令特有的需求:不同用户可能分配不同的权限
|
||||
// 而 --users + --role 方式所有用户同一个 role,CSV 方式每行可不同
|
||||
// userRole holds a username and the role to assign.
|
||||
type userRole struct {
|
||||
User string
|
||||
Role string
|
||||
|
|
@ -16,9 +14,8 @@ type userRole struct {
|
|||
|
||||
func newBatchAddShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-add:批量添加多名协作者
|
||||
Name: "batch-add",
|
||||
Description: "批量添加多名协作者",
|
||||
Description: "Add multiple collaborators to the repository",
|
||||
Long: `Add multiple collaborators to the repository in a single operation.
|
||||
|
||||
Provide collaborators via --users (comma-separated usernames) or --from (CSV
|
||||
|
|
@ -52,9 +49,8 @@ Available roles: admin, write, read.`,
|
|||
|
||||
func newBatchRemoveShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-remove:批量移除多名协作者
|
||||
Name: "batch-remove",
|
||||
Description: "批量移除多名协作者",
|
||||
Description: "Remove multiple collaborators from the repository",
|
||||
Long: `Remove multiple collaborators from the repository in a single operation.
|
||||
|
||||
Provide collaborators via --users (comma-separated usernames) or --from (CSV
|
||||
|
|
@ -84,9 +80,8 @@ any changes.`,
|
|||
|
||||
func newBatchRoleShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-role:批量修改多名协作者的角色
|
||||
Name: "batch-role",
|
||||
Description: "批量修改多名协作者的角色",
|
||||
Description: "Change role for multiple collaborators",
|
||||
Long: `Change the role of multiple collaborators in a single operation.
|
||||
|
||||
Provide collaborators via --users (comma-separated usernames) combined with
|
||||
|
|
@ -116,8 +111,6 @@ Available roles: admin, write, read.`,
|
|||
|
||||
// --- batch-add ---
|
||||
|
||||
// [B 类批量] 逐个添加协作者——POST /collaborators
|
||||
// 支持 --users + --role 统一角色 或 --from CSV 每行不同角色
|
||||
func runBatchAdd(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -161,7 +154,6 @@ func runBatchAdd(ctx *common.RuntimeContext) error {
|
|||
|
||||
// --- batch-remove ---
|
||||
|
||||
// [B 类批量] 逐个移除协作者——DELETE /collaborators/remove(不可逆)
|
||||
func runBatchRemove(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -207,7 +199,6 @@ func runBatchRemove(ctx *common.RuntimeContext) error {
|
|||
|
||||
// --- batch-role ---
|
||||
|
||||
// [B 类批量] 逐个修改协作者角色——PUT /collaborators/change_role
|
||||
func runBatchRole(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -236,23 +227,13 @@ func runBatchRole(ctx *common.RuntimeContext) error {
|
|||
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),
|
||||
"user": map[string]string{
|
||||
"user_id": user,
|
||||
"role_name": role,
|
||||
},
|
||||
}
|
||||
_, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/collaborators/change_role", ctx.Owner, ctx.Repo), payload)
|
||||
return err
|
||||
|
|
@ -270,9 +251,8 @@ func runBatchRole(ctx *common.RuntimeContext) error {
|
|||
|
||||
// --- helpers ---
|
||||
|
||||
// collectUserRoles 收集用户-角色对(支持 --users + --role 和 --from CSV 两种输入)
|
||||
// 与 CollectStrings 不同,此函数需要同时处理 {用户, 角色} 成对数据
|
||||
// --from CSV 要求有 user_id 和 role 两列,每行可指定不同角色
|
||||
// collectUserRoles builds a deduplicated slice of userRole pairs from
|
||||
// --users (combined with defaultRole) and/or --from CSV.
|
||||
func collectUserRoles(usersValue, fromPath, defaultRole string) ([]userRole, error) {
|
||||
var pairs []userRole
|
||||
|
||||
|
|
@ -309,7 +289,7 @@ func collectUserRoles(usersValue, fromPath, defaultRole string) ([]userRole, err
|
|||
return deduped, nil
|
||||
}
|
||||
|
||||
// collectUsers 收集用户名(仅用于 batch-remove,不需要角色信息)
|
||||
// collectUsers builds a deduplicated slice of usernames from --users and/or --from CSV.
|
||||
func collectUsers(usersValue, fromPath string) ([]string, error) {
|
||||
users := common.ParseStringList(usersValue)
|
||||
|
||||
|
|
@ -324,7 +304,7 @@ func collectUsers(usersValue, fromPath string) ([]string, error) {
|
|||
return common.DedupeStrings(users), nil
|
||||
}
|
||||
|
||||
// roleForUser 在 userRole 列表中查找指定用户的角色(线性查找)
|
||||
// roleForUser looks up the role assigned to a user in the pairs slice.
|
||||
func roleForUser(pairs []userRole, user string) string {
|
||||
for _, p := range pairs {
|
||||
if p.User == user {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return append([]*common.Shortcut{
|
||||
// +list:列出仓库的协作者
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库协作者",
|
||||
Description: "List repository collaborators",
|
||||
Long: `List repository collaborators.
|
||||
|
||||
Shows all users who have access to the repository along with their
|
||||
|
|
@ -29,10 +28,9 @@ username, role, and permission level.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +add:向仓库添加一名协作者
|
||||
{
|
||||
Name: "add",
|
||||
Description: "添加仓库协作者",
|
||||
Description: "Add a collaborator to the repository",
|
||||
Long: `Add a collaborator to the repository.
|
||||
|
||||
Invites a user to collaborate on the repository with the specified role.
|
||||
|
|
@ -68,10 +66,9 @@ Available roles: admin, write (default), read.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +remove:从仓库移除一名协作者
|
||||
{
|
||||
Name: "remove",
|
||||
Description: "移除仓库协作者",
|
||||
Description: "Remove a collaborator from the repository",
|
||||
Long: `Remove a collaborator from the repository.
|
||||
|
||||
Revokes the user's access to the repository.`,
|
||||
|
|
@ -100,10 +97,9 @@ Revokes the user's access to the repository.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +change-role:修改协作者的角色
|
||||
{
|
||||
Name: "change-role",
|
||||
Description: "修改协作者角色",
|
||||
Description: "Change a collaborator's role",
|
||||
Long: `Change a collaborator's role.
|
||||
|
||||
Updates the permission level of an existing collaborator.
|
||||
|
|
@ -129,15 +125,11 @@ Available roles: admin, write, read.`,
|
|||
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),
|
||||
"user": map[string]string{
|
||||
"user_id": user,
|
||||
"role_name": role,
|
||||
},
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/collaborators/change_role", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
|
|
@ -147,11 +139,8 @@ Available roles: admin, write, read.`,
|
|||
},
|
||||
},
|
||||
},
|
||||
// +batch-add:批量添加多名协作者
|
||||
newBatchAddShortcut(),
|
||||
// +batch-remove:批量移除多名协作者
|
||||
newBatchRemoveShortcut(),
|
||||
// +batch-role:批量修改多名协作者的角色
|
||||
newBatchRoleShortcut(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -8,10 +8,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库提交记录
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库提交记录",
|
||||
Description: "List repository commits",
|
||||
Long: `List repository commits.
|
||||
|
||||
Shows commit SHA, author, message, and date.
|
||||
|
|
@ -45,10 +44,9 @@ Filter by path, date range, or branch with respective flags.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看提交详情(含变更文件)
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看提交详情(含变更文件)",
|
||||
Description: "View commit details including changed files",
|
||||
Long: `View commit details including changed files.
|
||||
|
||||
Shows the full commit information and the list of files changed
|
||||
|
|
@ -73,10 +71,9 @@ in the specified commit.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +diff:查看提交的 diff 内容
|
||||
{
|
||||
Name: "diff",
|
||||
Description: "查看提交的 diff 内容",
|
||||
Description: "View the diff of a commit",
|
||||
Long: `View the diff of a commit.
|
||||
|
||||
Returns the patch/diff content for the specified commit,
|
||||
|
|
@ -101,10 +98,9 @@ showing exact line-level additions and deletions.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +blame:获取文件的逐行提交追溯信息
|
||||
{
|
||||
Name: "blame",
|
||||
Description: "获取文件的逐行提交追溯信息",
|
||||
Description: "Get blame information for a file",
|
||||
Long: `Get blame information for a file.
|
||||
|
||||
Shows which commit and author last modified each line of a file.
|
||||
|
|
@ -132,10 +128,9 @@ Use --page and --limit for pagination.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +compare:比较两个引用(分支、标签或 SHA)
|
||||
{
|
||||
Name: "compare",
|
||||
Description: "比较两个引用(分支、标签或 SHA)",
|
||||
Description: "Compare two refs (branches, tags, or SHAs)",
|
||||
Long: `Compare two refs (branches, tags, or SHAs).
|
||||
|
||||
Returns the commits and file changes between a base and head ref.
|
||||
|
|
|
|||
|
|
@ -8,67 +8,29 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// --- Result structures 批量操作输出结构 ---
|
||||
// --- Result structures ---
|
||||
|
||||
// BatchResult 记录批量操作中单个项目的处理结果
|
||||
// BatchResult records the outcome of a single item in a batch operation.
|
||||
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"` // 失败原因
|
||||
Item string `json:"item" yaml:"item"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Status string `json:"status" yaml:"status"` // "planned", "ok", "failed"
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
}
|
||||
|
||||
// BatchSummary 是所有批量命令的统一输出格式
|
||||
// 无论操作哪种资源,输出结构都一致,方便脚本处理
|
||||
// BatchSummary is the unified output of every batch command.
|
||||
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"` // 每个项目的详细结果
|
||||
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 []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"]
|
||||
// CollectNumbers collects integer identifiers from --numbers and --from CSV.
|
||||
func CollectNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||
numbers, err := ParseNumberList(numbersValue)
|
||||
if err != nil {
|
||||
|
|
@ -84,9 +46,8 @@ func CollectNumbers(numbersValue, csvPath string) ([]string, error) {
|
|||
return DedupeStrings(append(numbers, csvNumbers...)), nil
|
||||
}
|
||||
|
||||
// CollectStrings 收集字符串标识符(分支名、用户名等),支持 --names 和 --from 文件合并去重
|
||||
// 与 CollectNumbers 不同,此类标识符不需要是整数
|
||||
// 文件格式:每行一个名称(纯文本,不是 CSV)
|
||||
// CollectStrings collects string identifiers (e.g. branch names) from --names and --from file.
|
||||
// The file is read one entry per line (plain text, not CSV).
|
||||
func CollectStrings(namesValue, filePath string) ([]string, error) {
|
||||
names := ParseStringList(namesValue)
|
||||
if filePath == "" {
|
||||
|
|
@ -99,11 +60,9 @@ func CollectStrings(namesValue, filePath string) ([]string, error) {
|
|||
return DedupeStrings(append(names, fileNames...)), nil
|
||||
}
|
||||
|
||||
// --- Parsing helpers 解析辅助函数 ---
|
||||
// --- Parsing helpers ---
|
||||
|
||||
// ParseNumberList 解析逗号分隔数字列表 → 校验每个值都是整数 + 去重
|
||||
// 输入 "7,8,9" → ["7","8","9"];空字符串 → nil(合法空值)
|
||||
// 输入 "7,abc" → 报错 "invalid number \"abc\": must be an integer"
|
||||
// ParseNumberList parses a comma-separated list of integers.
|
||||
func ParseNumberList(value string) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
|
|
@ -111,9 +70,7 @@ func ParseNumberList(value string) ([]string, error) {
|
|||
return NormalizeIntStrings(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
// ParseStringList 解析逗号分隔字符串列表,自动去除空格和空值
|
||||
// 输入 "branch1, branch2, " → ["branch1","branch2"]
|
||||
// 注意:此函数不做整数校验,适用于分支名/用户名等非数字标识符
|
||||
// ParseStringList parses a comma-separated list of strings (trimmed).
|
||||
func ParseStringList(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
|
|
@ -131,10 +88,8 @@ func ParseStringList(value string) []string {
|
|||
|
||||
// --- CSV / file reading ---
|
||||
|
||||
// ReadColumnFromCSV 从 CSV 文件中读取单列数据(支持表头自动检测)
|
||||
// columnNames: 可接受的列名列表,按优先级排序
|
||||
// 列名检测逻辑:依次匹配 number / issue_number / project_issues_index
|
||||
// 都不匹配则默认使用第一列;有表头从第2行读,无表头从第1行读
|
||||
// ReadColumnFromCSV reads a single column from a CSV file.
|
||||
// columnNames lists acceptable header names; if none match, the first column is used.
|
||||
func ReadColumnFromCSV(path string, columnNames []string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
|
|
@ -183,9 +138,8 @@ func ReadColumnFromCSV(path string, columnNames []string) ([]string, error) {
|
|||
return values, nil
|
||||
}
|
||||
|
||||
// ReadRowsFromCSV 从 CSV 文件读取多列数据,返回 [{col:val, ...}, ...]
|
||||
// 适用于需要同时读取多列的批量操作,如 collaborator 需要 user_id + role 两列
|
||||
// requiredCols: 需要的列名列表,函数会校验这些列是否都存在,不存在则报错
|
||||
// ReadRowsFromCSV reads multiple columns from a CSV file.
|
||||
// Returns a slice of maps keyed by the required column names.
|
||||
func ReadRowsFromCSV(path string, requiredCols []string) ([]map[string]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
|
|
@ -203,28 +157,27 @@ func ReadRowsFromCSV(path string, requiredCols []string) ([]map[string]string, e
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
// 步骤1:从表头行构建列名→列索引的映射表
|
||||
// Map column name → column index from the header row
|
||||
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 // 记录该列在第几列
|
||||
colIndex[req] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤2:校验所有必需的列是否都在表头中找到,缺列直接报错
|
||||
// Validate all required columns found
|
||||
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:] { // 跳过表头行
|
||||
for _, record := range records[1:] {
|
||||
row := make(map[string]string, len(requiredCols))
|
||||
for _, req := range requiredCols {
|
||||
idx := colIndex[req]
|
||||
|
|
@ -237,8 +190,7 @@ func ReadRowsFromCSV(path string, requiredCols []string) ([]map[string]string, e
|
|||
return rows, nil
|
||||
}
|
||||
|
||||
// ReadLinesFromFile 从纯文本文件逐行读取(非CSV),自动忽略空行
|
||||
// 用于支持 --from 参数传入分支名、用户名列表(每行一个)
|
||||
// ReadLinesFromFile reads non-empty lines from a plain-text file.
|
||||
func ReadLinesFromFile(path string) ([]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
|
@ -257,23 +209,20 @@ func ReadLinesFromFile(path string) ([]string, error) {
|
|||
|
||||
// --- Validation & deduplication ---
|
||||
|
||||
// NormalizeIntStrings 校验每个值都是合法整数 + 去重,批处理的输入"安检"
|
||||
// 输入 ["7","8","8","abc"] → 报错("abc" 不是整数)
|
||||
// 输入 ["7","8","8","9"] → ["7","8","9"](重复的 "8" 被去掉)
|
||||
// NormalizeIntStrings validates that every value is an integer and deduplicates.
|
||||
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 // 忽略空字符串
|
||||
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 // 重复值跳过
|
||||
continue
|
||||
}
|
||||
seen[v] = true
|
||||
result = append(result, v)
|
||||
|
|
@ -281,7 +230,7 @@ func NormalizeIntStrings(values []string) ([]string, error) {
|
|||
return result, nil
|
||||
}
|
||||
|
||||
// DedupeStrings 字符串去重(保持原始顺序,保留第一个出现的)
|
||||
// DedupeStrings deduplicates strings while preserving order.
|
||||
func DedupeStrings(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
|
|
@ -295,9 +244,37 @@ func DedupeStrings(values []string) []string {
|
|||
return result
|
||||
}
|
||||
|
||||
// --- Batch processing loop ---
|
||||
|
||||
// ProcessBatch runs fn on each item. On dry-run it marks items as "planned".
|
||||
// Single-item failure does not stop the loop; the caller gets a full summary.
|
||||
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)),
|
||||
}
|
||||
for _, item := range items {
|
||||
r := BatchResult{Item: item, Action: action}
|
||||
if dryRun {
|
||||
r.Status = "planned"
|
||||
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.Results = append(summary.Results, r)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
|
||||
// ParseBool 解析布尔字符串,用于处理 --prerelease 等布尔参数
|
||||
// ParseBool parses a boolean string.
|
||||
func ParseBool(value string) bool {
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
|
|
|
|||
|
|
@ -9,24 +9,19 @@ import (
|
|||
"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 个批量命令
|
||||
// ConfirmAction prompts the user for confirmation before a destructive action.
|
||||
// Returns nil if confirmed, error if cancelled.
|
||||
// Skipped automatically when --yes flag is set.
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -120,28 +120,24 @@ func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
|
|||
return v, nil
|
||||
}
|
||||
|
||||
// [P1 Bug#2 修复] 原代码在 branch +create 和 release +create 中硬编码 "master" 作为默认分支
|
||||
// 当仓库默认分支是 "main" 时会出错。此函数动态查询仓库的默认分支,失败时回退到 "master"
|
||||
// 设计模式:动态查询 + 静态兜底(graceful degradation)
|
||||
// GetDefaultBranch queries the repo API and returns the default branch name.
|
||||
// Falls back to "master" if the API call fails or the field is not present.
|
||||
func GetDefaultBranch(ctx *RuntimeContext) string {
|
||||
if ctx.Owner == "" || ctx.Repo == "" {
|
||||
return "master" // owner/repo 未设置,直接回退
|
||||
return "master"
|
||||
}
|
||||
// 调用 GET /{owner}/{repo} API 获取仓库元信息
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return "master" // API 调用失败(网络错误等),回退
|
||||
return "master"
|
||||
}
|
||||
// 将返回数据断言为 map[string]interface{}(JSON 对象)
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return "master" // 返回格式异常,回退
|
||||
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 db
|
||||
}
|
||||
}
|
||||
return "master" // 字段不存在或为空,回退
|
||||
return "master"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +create:在仓库中创建数据集
|
||||
{
|
||||
Name: "create",
|
||||
Description: "在仓库中创建数据集",
|
||||
Description: "Create a dataset in a repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Dataset title", Required: true},
|
||||
{Name: "description", Short: "d", Usage: "Dataset description"},
|
||||
|
|
@ -37,10 +36,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新仓库中的数据集
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新仓库中的数据集",
|
||||
Description: "Update a dataset in a repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "New dataset title"},
|
||||
{Name: "description", Short: "d", Usage: "New dataset description"},
|
||||
|
|
@ -68,10 +66,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看仓库的数据集详情
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看仓库的数据集详情",
|
||||
Description: "View dataset details for a repository",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -83,10 +80,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +list:列出项目数据集
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出项目数据集",
|
||||
Description: "List project datasets",
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
|
|
|
|||
|
|
@ -4,19 +4,15 @@ 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: "获取仓库文件内容",
|
||||
Description: "Get file contents from repository",
|
||||
Long: `Get file contents from the repository.
|
||||
|
||||
Retrieves the content and metadata of a file at the specified path.
|
||||
|
|
@ -38,32 +34,21 @@ Optionally specify a branch, tag, or commit SHA with --ref.`,
|
|||
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,
|
||||
apiPath := fmt.Sprintf("/%s/%s/files", ctx.Owner, ctx.Repo)
|
||||
query := buildQuery(map[string]string{
|
||||
"filepath": filePath,
|
||||
"ref": ctx.Arg("ref"),
|
||||
}))
|
||||
})
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
|
||||
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)
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +readme:获取仓库 README
|
||||
{
|
||||
Name: "readme",
|
||||
Description: "获取仓库 README",
|
||||
Description: "Get the repository README",
|
||||
Long: `Get the repository README.
|
||||
|
||||
Fetches the README file of the current repository.`,
|
||||
|
|
@ -80,10 +65,9 @@ Fetches the README file of the current repository.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:在仓库中新建文件
|
||||
{
|
||||
Name: "create",
|
||||
Description: "在仓库中新建文件",
|
||||
Description: "Create a new file in the repository",
|
||||
Long: `Create a new file in the repository.
|
||||
|
||||
Creates a file at the given path with the provided content.
|
||||
|
|
@ -99,7 +83,6 @@ A commit message is required.`,
|
|||
{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 {
|
||||
|
|
@ -117,24 +100,14 @@ A commit message is required.`,
|
|||
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 != "" {
|
||||
if branch := ctx.Arg("branch"); 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
|
||||
|
|
@ -142,10 +115,9 @@ A commit message is required.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新仓库中的已有文件
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新仓库中的已有文件",
|
||||
Description: "Update an existing file in the repository",
|
||||
Long: `Update an existing file in the repository.
|
||||
|
||||
Replaces the content of a file at the given path.
|
||||
|
|
@ -160,9 +132,8 @@ Provide --sha to detect conflicting mid-air edits.`,
|
|||
{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)"},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch"},
|
||||
{Name: "sha", Usage: "Original file SHA (for conflict detection)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -180,27 +151,16 @@ Provide --sha to detect conflicting mid-air edits.`,
|
|||
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 != "" {
|
||||
if branch := ctx.Arg("branch"); branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
if newBranch != "" {
|
||||
payload["new_branch"] = newBranch
|
||||
if sha := ctx.Arg("sha"); sha != "" {
|
||||
payload["sha"] = sha
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/update_file", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
|
|
@ -209,10 +169,9 @@ Provide --sha to detect conflicting mid-air edits.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除仓库中的文件
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除仓库中的文件",
|
||||
Description: "Delete a file from the repository",
|
||||
Long: `Delete a file from the repository.
|
||||
|
||||
Removes the file at the given path from the repository.
|
||||
|
|
@ -226,9 +185,8 @@ Provide --sha to detect conflicting mid-air edits.`,
|
|||
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)"},
|
||||
{Name: "branch", Short: "b", Usage: "Target branch"},
|
||||
{Name: "sha", Usage: "File SHA (for conflict detection)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -242,26 +200,15 @@ Provide --sha to detect conflicting mid-air edits.`,
|
|||
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,
|
||||
"filepath": filePath,
|
||||
"message": message,
|
||||
}
|
||||
if branch != "" {
|
||||
if branch := ctx.Arg("branch"); branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
if newBranch != "" {
|
||||
payload["new_branch"] = newBranch
|
||||
if sha := ctx.Arg("sha"); sha != "" {
|
||||
payload["sha"] = sha
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/delete_file", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
|
|
@ -270,25 +217,21 @@ Provide --sha to detect conflicting mid-air edits.`,
|
|||
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).
|
||||
Description: "Get a git tree for a given SHA",
|
||||
Long: `Get a git tree for a given SHA.
|
||||
|
||||
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
|
||||
Returns the directory listing (tree object) at the specified SHA.
|
||||
Use --recursive to fetch the entire tree recursively.`,
|
||||
Example: ` # Get a git tree
|
||||
gitlink file +tree --sha abc123def456
|
||||
|
||||
# List entries at a specific commit
|
||||
gitlink file +tree --sha 7947d4dfd67bbf64265dd89465dfe9ce6a92bf83`,
|
||||
# Get a recursive tree
|
||||
gitlink file +tree --sha abc123def456 --recursive`,
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Branch name, tag, or commit SHA", Required: true},
|
||||
{Name: "sha", Short: "s", Usage: "Tree SHA", Required: true},
|
||||
{Name: "recursive", Usage: "Fetch recursively", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -299,17 +242,19 @@ descend into a subdirectory use 'file +entries --path <dir>'.`,
|
|||
return err
|
||||
}
|
||||
apiPath := fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha)
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, nil)
|
||||
query := buildQuery(map[string]string{
|
||||
"recursive": ctx.Arg("recursive"),
|
||||
})
|
||||
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +blob:根据 SHA 获取 git blob 内容
|
||||
{
|
||||
Name: "blob",
|
||||
Description: "根据 SHA 获取 git blob 内容",
|
||||
Description: "Get a git blob by SHA",
|
||||
Long: `Get a git blob by SHA.
|
||||
|
||||
Returns the raw blob content for the specified SHA.`,
|
||||
|
|
@ -333,10 +278,9 @@ Returns the raw blob content for the specified SHA.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +batch:在单次提交中批量创建或更新多个文件
|
||||
{
|
||||
Name: "batch",
|
||||
Description: "在单次提交中批量创建或更新多个文件",
|
||||
Description: "Create or update multiple files in a single commit",
|
||||
Flags: []common.Flag{
|
||||
{Name: "files", Short: "f", Usage: "JSON array of file objects", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||
|
|
@ -372,10 +316,9 @@ Returns the raw blob content for the specified SHA.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +entries:获取仓库目录列表
|
||||
{
|
||||
Name: "entries",
|
||||
Description: "获取仓库目录列表",
|
||||
Description: "Get repository entries (directory listing)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "Directory path"},
|
||||
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
|
||||
|
|
@ -396,10 +339,9 @@ Returns the raw blob content for the specified SHA.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +sub-entries:获取仓库子目录列表
|
||||
{
|
||||
Name: "sub-entries",
|
||||
Description: "获取仓库子目录列表",
|
||||
Description: "Get repository sub-entries",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "Directory path"},
|
||||
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
|
||||
|
|
@ -420,9 +362,46 @@ Returns the raw blob content for the specified SHA.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// 注:原 +replace 命令已移除。其端点 /replace_file 返回"接口方法异常"
|
||||
//(端点废弃),且功能与 +create(新文件)/ +update(已有文件)完全重复。
|
||||
// 如需替换文件内容,请用 file +update。
|
||||
{
|
||||
Name: "replace",
|
||||
Description: "Replace a file in the repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
|
||||
{Name: "content", Short: "c", Usage: "File content (will be base64-encoded)", 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
|
||||
}
|
||||
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
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"filepath": filePath,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
"message": message,
|
||||
}
|
||||
if branch := ctx.Arg("branch"); branch != "" {
|
||||
payload["branch"] = branch
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/replace_file", ctx.Owner, ctx.Repo), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -439,52 +418,3 @@ func buildQuery(params map[string]string) map[string][]string {
|
|||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -13,10 +13,9 @@ const closedIssueStatusID = 5
|
|||
// 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 时直接用项目编号
|
||||
// resolveIssueDBIDs fetches all issues and maps user-facing project_issues_index
|
||||
// values to their internal database IDs. Returns a slice of interface{} values
|
||||
// suitable for JSON serialization in batch API calls.
|
||||
func resolveIssueDBIDs(ctx *common.RuntimeContext, numbers []string) ([]interface{}, error) {
|
||||
q := url.Values{}
|
||||
q.Set("limit", "200")
|
||||
|
|
@ -35,27 +34,24 @@ func resolveIssueDBIDs(ctx *common.RuntimeContext, numbers []string) ([]interfac
|
|||
return nil, fmt.Errorf("unexpected issues list format")
|
||||
}
|
||||
|
||||
// 构建 project_issues_index → 数据库ID 的映射表
|
||||
// 例如: {"3": 143372, "4": 143373, "5": 143374}
|
||||
// Build mapping: project_issues_index (as string) → database id
|
||||
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)
|
||||
idx, ok := issue["project_issues_index"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, ok := issue["id"] // 数据库主键 ID(如 143372)
|
||||
id, ok := issue["id"]
|
||||
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]
|
||||
|
|
@ -73,9 +69,8 @@ func resolveIssueDBIDs(ctx *common.RuntimeContext, numbers []string) ([]interfac
|
|||
|
||||
func newBatchCloseShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-close:按疑修编号或 CSV 文件批量关闭多个疑修
|
||||
Name: "batch-close",
|
||||
Description: "按疑修编号或 CSV 文件批量关闭疑修",
|
||||
Description: "Close multiple issues by issue numbers or a CSV file",
|
||||
Long: `Close multiple issues in a single operation.
|
||||
|
||||
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
|
|
@ -99,8 +94,6 @@ header column; otherwise the first column is used.`,
|
|||
}
|
||||
}
|
||||
|
||||
// [B 类批量] 逐个关闭 issue——PATCH /issues/{n},每个请求独立
|
||||
// 不需要 ID 映射,单 issue API 接受项目编号而非数据库主键
|
||||
func runBatchClose(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -130,18 +123,16 @@ 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, // 5 = closed
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
"status_id": closedIssueStatusID,
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
|
@ -154,9 +145,8 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
|
|||
|
||||
func newBatchUpdateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-update:使用原生批量接口一次性更新多个疑修(支持 --dry-run 预览)
|
||||
Name: "batch-update",
|
||||
Description: "通过原生批量接口批量更新多个疑修",
|
||||
Description: "Update multiple issues at once using the native batch API",
|
||||
Long: `Update multiple issues in a single API call.
|
||||
|
||||
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
|
|
@ -187,8 +177,6 @@ must be specified. Use --dry-run to preview changes without applying them.`,
|
|||
}
|
||||
}
|
||||
|
||||
// [A 类批量] 原生批量 API——一次 PATCH /issues/batch_update 请求操作多个 issue
|
||||
// 需要先通过 resolveIssueDBIDs 将项目编号转为数据库 ID
|
||||
func runBatchUpdate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -266,9 +254,8 @@ func runBatchUpdate(ctx *common.RuntimeContext) error {
|
|||
|
||||
func newBatchDestroyShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-destroy:使用原生批量接口一次性永久删除多个疑修(支持 --dry-run 与确认提示)
|
||||
Name: "batch-destroy",
|
||||
Description: "通过原生批量接口批量删除多个疑修",
|
||||
Description: "Delete multiple issues at once using the native batch API",
|
||||
Long: `Permanently delete multiple issues in a single API call.
|
||||
|
||||
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
|
|
@ -291,8 +278,6 @@ You will be prompted for confirmation unless --yes is set.`,
|
|||
}
|
||||
}
|
||||
|
||||
// [A 类批量] 原生批量 API——一次 DELETE /issues/batch_destroy 删除多个 issue(不可逆)
|
||||
// 执行前调用 ConfirmAction 弹确认框
|
||||
func runBatchDestroy(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -25,10 +25,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
newBatchCloseShortcut(),
|
||||
newBatchUpdateShortcut(),
|
||||
newBatchDestroyShortcut(),
|
||||
// +list:列出当前仓库的疑修(支持按状态/里程碑/标签筛选)
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出疑修",
|
||||
Description: "List issues",
|
||||
Long: `List issues in the current repository.
|
||||
|
||||
Shows issue number, title, status, and other details.
|
||||
|
|
@ -75,25 +74,23 @@ Use --state to filter by status, --milestone, --assignee, --label,
|
|||
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"
|
||||
q.Set("fixed_version_id", m)
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
q.Set("assigned_to_id", a) // CLI "assignee" → API "assigned_to_id"
|
||||
q.Set("assigned_to_id", a)
|
||||
}
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
q.Set("issue_tag_id", l) // CLI "label" → API "issue_tag_id"
|
||||
q.Set("issue_tag_id", l)
|
||||
}
|
||||
if k := ctx.Arg("keyword"); k != "" {
|
||||
q.Set("keyword", k) // 关键词搜索(名称一致,无需映射)
|
||||
q.Set("keyword", k)
|
||||
}
|
||||
if s := ctx.Arg("sort"); s != "" {
|
||||
q.Set("sort", s) // 排序字段(如 created_on, updated_on)
|
||||
q.Set("sort", s)
|
||||
}
|
||||
if d := ctx.Arg("sort-direction"); d != "" {
|
||||
q.Set("sort_direction", d) // 排序方向 asc/desc(实测 API 用 sort+sort_direction,不是 sort_by)
|
||||
q.Set("sort_direction", d)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||
if err != nil {
|
||||
|
|
@ -103,10 +100,9 @@ Use --state to filter by status, --milestone, --assignee, --label,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:在当前仓库创建新疑修(需 --title,可附带描述/指派人/里程碑/标签)
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建新疑修",
|
||||
Description: "Create a new issue",
|
||||
Long: `Create a new issue in the current repository.
|
||||
|
||||
Requires a title (--title). You may optionally set a description,
|
||||
|
|
@ -144,16 +140,13 @@ assignee, milestone, and label at creation time.`,
|
|||
body["description"] = desc
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
body["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
|
||||
body["assigned_to_id"] = a
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
body["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
|
||||
body["fixed_version_id"] = m
|
||||
}
|
||||
// [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"
|
||||
body["issue_tag_id"] = l
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||||
if err != nil {
|
||||
|
|
@ -162,10 +155,9 @@ assignee, milestone, and label at creation time.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看指定疑修的详情(标题/描述/状态/指派人等)
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看疑修详情",
|
||||
Description: "View issue details",
|
||||
Long: `View detailed information about a specific issue.
|
||||
|
||||
Displays the issue title, description, status, assignee,
|
||||
|
|
@ -193,10 +185,9 @@ and other metadata. Use the issue number as shown in the web URL.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +close:关闭疑修(保留标题和描述,可选附上说明性评论)
|
||||
{
|
||||
Name: "close",
|
||||
Description: "关闭疑修",
|
||||
Description: "Close an issue",
|
||||
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).
|
||||
|
|
@ -236,8 +227,7 @@ Use --comment to leave an explanation when closing.`,
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// [P2 参数补全] 关闭(PATCH)和评论(POST journals)是两个独立 API,代码串行调用
|
||||
// 关闭 issue 本身是一个 PATCH 请求,评论是另一个 POST 请求到 journals 端点
|
||||
// Optionally post a closing comment
|
||||
if comment := ctx.Arg("comment"); comment != "" {
|
||||
_, _ = ctx.CallAPI("POST",
|
||||
fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number),
|
||||
|
|
@ -246,10 +236,9 @@ Use --comment to leave an explanation when closing.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新疑修的字段(标题/描述/状态/里程碑/指派人/标签/优先级等)
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新疑修",
|
||||
Description: "Update an issue",
|
||||
Long: `Update the title, description, status, or other fields of an existing issue.
|
||||
|
||||
At least one of --title, --body, --state, --milestone, --assignee,
|
||||
|
|
@ -307,8 +296,6 @@ The --state flag accepts "open", "closed", or a numeric status_id.`,
|
|||
return err
|
||||
}
|
||||
|
||||
// [P2 参数补全] 保留原值,只修改用户指定的字段
|
||||
// GitLink PATCH API 要求 subject 和 description 不能为空,所以必须先查出原值
|
||||
body := map[string]interface{}{
|
||||
"subject": current.Subject,
|
||||
"description": current.Description,
|
||||
|
|
@ -326,18 +313,17 @@ The --state flag accepts "open", "closed", or a numeric status_id.`,
|
|||
}
|
||||
body["status_id"] = statusID
|
||||
}
|
||||
// [P2 参数补全] 以下 4 个参数原 CLI 未暴露
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
body["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
|
||||
body["fixed_version_id"] = m
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
body["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
|
||||
body["assigned_to_id"] = a
|
||||
}
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
body["issue_tag_id"] = l // CLI "label" → API "issue_tag_id"
|
||||
body["issue_tag_id"] = l
|
||||
}
|
||||
if p := ctx.Arg("priority"); p != "" {
|
||||
body["priority_id"] = p // CLI "priority" → API "priority_id"
|
||||
body["priority_id"] = p
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||
if err != nil {
|
||||
|
|
@ -346,10 +332,9 @@ The --state flag accepts "open", "closed", or a numeric status_id.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +comment:在指定疑修下添加一条评论(--number 和 --body 必填)
|
||||
{
|
||||
Name: "comment",
|
||||
Description: "在疑修下添加评论",
|
||||
Description: "Add a comment to an issue",
|
||||
Long: `Add a comment (journal entry) to an existing issue.
|
||||
|
||||
Both --number and --body are required.`,
|
||||
|
|
@ -384,10 +369,9 @@ Both --number and --body are required.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +comments:列出指定疑修下的评论与动态记录
|
||||
{
|
||||
Name: "comments",
|
||||
Description: "列出疑修的评论与动态",
|
||||
Description: "List comments and activity on an issue",
|
||||
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"},
|
||||
|
|
@ -411,10 +395,9 @@ Both --number and --body are required.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update-comment:修改疑修下的某条评论内容
|
||||
{
|
||||
Name: "update-comment",
|
||||
Description: "修改疑修下的评论",
|
||||
Description: "Update a comment on an issue",
|
||||
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},
|
||||
|
|
@ -446,10 +429,9 @@ Both --number and --body are required.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-comment:删除疑修下的某条评论
|
||||
{
|
||||
Name: "delete-comment",
|
||||
Description: "删除疑修下的评论",
|
||||
Description: "Delete a comment on an issue",
|
||||
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},
|
||||
|
|
@ -473,10 +455,9 @@ Both --number and --body are required.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +reply-comment:列出某条评论的子回复
|
||||
{
|
||||
Name: "reply-comment",
|
||||
Description: "列出评论的子回复",
|
||||
Description: "List child replies of a comment",
|
||||
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},
|
||||
|
|
@ -500,10 +481,9 @@ Both --number and --body are required.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除指定疑修
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除疑修",
|
||||
Description: "Delete an issue",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
},
|
||||
|
|
@ -522,10 +502,9 @@ Both --number and --body are required.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +batch-update:通过原生批量接口批量更新多个疑修(按数据库 ID)
|
||||
{
|
||||
Name: "batch-update",
|
||||
Description: "批量更新多个疑修",
|
||||
Description: "Batch update multiple issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
|
||||
{Name: "state", Short: "s", Usage: "New state: open, closed"},
|
||||
|
|
@ -559,10 +538,9 @@ Both --number and --body are required.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +batch-destroy:通过原生批量接口批量删除多个疑修(按数据库 ID)
|
||||
{
|
||||
Name: "batch-destroy",
|
||||
Description: "批量删除多个疑修",
|
||||
Description: "Batch delete multiple issues",
|
||||
Flags: []common.Flag{
|
||||
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的疑修标签
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的疑修标签",
|
||||
Description: "List repository issue labels (tags)",
|
||||
Long: `List repository issue labels (tags).
|
||||
|
||||
Returns all labels defined in the repository.`,
|
||||
|
|
@ -28,10 +27,9 @@ Returns all labels defined in the repository.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建疑修标签
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建疑修标签",
|
||||
Description: "Create an issue label",
|
||||
Long: `Create an issue label.
|
||||
|
||||
Creates a new label with the given name.
|
||||
|
|
@ -66,10 +64,9 @@ Optionally specify a color in hex format (without #).`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新疑修标签
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新疑修标签",
|
||||
Description: "Update an issue label",
|
||||
Long: `Update an issue label.
|
||||
|
||||
Updates the name and/or color of an existing label.
|
||||
|
|
@ -106,10 +103,9 @@ At least one of --name or --color should be provided.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除疑修标签
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除疑修标签",
|
||||
Description: "Delete an issue label",
|
||||
Long: `Delete an issue label.
|
||||
|
||||
Permanently removes the label from the repository.`,
|
||||
|
|
@ -133,10 +129,9 @@ Permanently removes the label from the repository.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +priorities:列出可用的疑修优先级
|
||||
{
|
||||
Name: "priorities",
|
||||
Description: "列出可用的疑修优先级",
|
||||
Description: "List available issue priorities",
|
||||
Long: `List available issue priorities.
|
||||
|
||||
Returns the priority levels that can be assigned to issues.`,
|
||||
|
|
@ -153,10 +148,9 @@ Returns the priority levels that can be assigned to issues.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +statuses:列出可用的疑修状态
|
||||
{
|
||||
Name: "statuses",
|
||||
Description: "列出可用的疑修状态",
|
||||
Description: "List available issue statuses",
|
||||
Long: `List available issue statuses.
|
||||
|
||||
Returns the status values that issues can have
|
||||
|
|
@ -174,10 +168,9 @@ Returns the status values that issues can have
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +authors:列出可用的疑修作者
|
||||
{
|
||||
Name: "authors",
|
||||
Description: "列出可用的疑修作者",
|
||||
Description: "List available issue authors",
|
||||
Long: `List available issue authors.
|
||||
|
||||
Returns the users who have authored issues in the repository.`,
|
||||
|
|
@ -194,10 +187,9 @@ Returns the users who have authored issues in the repository.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +assigners:列出可用的疑修复办人
|
||||
{
|
||||
Name: "assigners",
|
||||
Description: "列出可用的疑修复办人",
|
||||
Description: "List available issue assigners",
|
||||
Long: `List available issue assigners.
|
||||
|
||||
Returns the users who can be assigned to issues in the repository.`,
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有里程碑
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的所有里程碑",
|
||||
Description: "List repository milestones",
|
||||
Long: `List repository milestones.
|
||||
|
||||
Displays all milestones for the repository with optional filtering by state.
|
||||
|
|
@ -41,10 +40,9 @@ Supports pagination with --page and --limit flags.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建里程碑
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建里程碑",
|
||||
Description: "Create a milestone",
|
||||
Long: `Create a milestone.
|
||||
|
||||
Creates a new milestone with the given title. Optionally add a description
|
||||
|
|
@ -83,10 +81,9 @@ and due date.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看里程碑的详细信息
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看里程碑的详细信息",
|
||||
Description: "View milestone details",
|
||||
Long: `View milestone details.
|
||||
|
||||
Shows detailed information about a specific milestone including its
|
||||
|
|
@ -111,10 +108,9 @@ title, description, status, due date, and completion progress.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新里程碑
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新里程碑",
|
||||
Description: "Update a milestone",
|
||||
Long: `Update a milestone.
|
||||
|
||||
Modifies an existing milestone's title, description, due date, or status.
|
||||
|
|
@ -162,10 +158,9 @@ Only the fields you specify will be changed.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除里程碑
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除里程碑",
|
||||
Description: "Delete a milestone",
|
||||
Long: `Delete a milestone.
|
||||
|
||||
Permanently removes a milestone from the repository. This action cannot be undone.`,
|
||||
|
|
@ -189,10 +184,9 @@ Permanently removes a milestone from the repository. This action cannot be undon
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +close:关闭里程碑
|
||||
{
|
||||
Name: "close",
|
||||
Description: "关闭里程碑",
|
||||
Description: "Close a milestone",
|
||||
Long: `Close a milestone.
|
||||
|
||||
Marks a milestone as closed. Closed milestones are still visible but
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出组织
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出组织",
|
||||
Description: "List organizations",
|
||||
Long: `List all organizations visible to the authenticated user.
|
||||
|
||||
Returns organization ID, name, and description for each entry.
|
||||
|
|
@ -37,10 +36,9 @@ Use --page and --limit for pagination.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +info:查看组织详情
|
||||
{
|
||||
Name: "info",
|
||||
Description: "查看组织详情",
|
||||
Description: "Show organization details",
|
||||
Long: `Show detailed information about an organization.
|
||||
|
||||
Returns the full organization profile including name, description,
|
||||
|
|
@ -62,10 +60,9 @@ member count, and repository list.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +members:列出组织成员
|
||||
{
|
||||
Name: "members",
|
||||
Description: "列出组织成员",
|
||||
Description: "List organization members",
|
||||
Long: `List all members of an organization.
|
||||
|
||||
Shows each member's login name, role, and join date.
|
||||
|
|
@ -92,10 +89,9 @@ Use --page and --limit for pagination.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建组织
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建组织",
|
||||
Description: "Create an organization",
|
||||
Long: `Create a new organization on GitLink.
|
||||
|
||||
Requires a name for the organization. An optional description
|
||||
|
|
@ -124,10 +120,9 @@ can be provided with --description.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +add-team-projects:为团队添加全部项目
|
||||
{
|
||||
Name: "add-team-projects",
|
||||
Description: "为团队添加全部项目",
|
||||
Description: "Add all projects to a team",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization login", Required: true},
|
||||
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
|
||||
|
|
@ -142,10 +137,9 @@ can be provided with --description.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +remove-team-projects:移除团队的全部项目
|
||||
{
|
||||
Name: "remove-team-projects",
|
||||
Description: "移除团队的全部项目",
|
||||
Description: "Remove all projects from a team",
|
||||
Flags: []common.Flag{
|
||||
{Name: "org", Short: "o", Usage: "Organization login", Required: true},
|
||||
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@ import (
|
|||
|
||||
func newBatchCloseShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-close:按编号或 CSV 文件批量关闭多个合并请求(支持 --dry-run 预览)
|
||||
Name: "batch-close",
|
||||
Description: "批量关闭多个合并请求",
|
||||
Description: "Close multiple pull requests",
|
||||
Long: `Close multiple pull requests in a single operation.
|
||||
|
||||
Provide PR numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
|
|
@ -36,7 +35,6 @@ header column; otherwise the first column is used.`,
|
|||
}
|
||||
}
|
||||
|
||||
// [B 类批量] 逐个关闭 PR——POST /pulls/{n}/refuse_merge,每个请求独立
|
||||
func runBatchClose(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -69,9 +67,8 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
|||
|
||||
func newBatchMergeShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
// +batch-merge:按编号或 CSV 文件批量合并多个合并请求(不可逆,默认需确认)
|
||||
Name: "batch-merge",
|
||||
Description: "批量合并多个合并请求",
|
||||
Description: "Merge multiple pull requests",
|
||||
Long: `Merge multiple pull requests in a single operation.
|
||||
|
||||
Provide PR numbers via --numbers (comma-separated) or --from (CSV file).
|
||||
|
|
@ -108,8 +105,6 @@ header column; otherwise the first column is used.`,
|
|||
}
|
||||
}
|
||||
|
||||
// [B 类批量] 逐个合并 PR——POST /pulls/{n}/pr_merge
|
||||
// 不可逆操作:执行前调用 ConfirmAction 确认(除非 --yes)
|
||||
func runBatchMerge(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -117,9 +112,8 @@ func runBatchMerge(ctx *common.RuntimeContext) error {
|
|||
|
||||
method := ctx.Arg("method")
|
||||
if method == "" {
|
||||
method = "merge" // 默认使用 merge 方式
|
||||
method = "merge"
|
||||
}
|
||||
// P2 补全的 validateMergeMethod:只允许 merge/rebase/squash 三种
|
||||
if err := validateMergeMethod(method); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -135,7 +129,6 @@ func runBatchMerge(ctx *common.RuntimeContext) error {
|
|||
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)
|
||||
|
|
@ -146,10 +139,9 @@ func runBatchMerge(ctx *common.RuntimeContext) error {
|
|||
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
|
||||
"do": method,
|
||||
}
|
||||
_, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), number), payload)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -13,10 +13,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
newBatchMergeShortcut(),
|
||||
// +list:列出当前仓库的合并请求(支持按状态/里程碑/标签/评审人筛选)
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出合并请求",
|
||||
Description: "List pull requests",
|
||||
Long: `List pull requests in the current repository.
|
||||
|
||||
Shows PR number, title, status, author, and other details.
|
||||
|
|
@ -65,27 +64,26 @@ additional filtering, and --sort to control ordering.`,
|
|||
if s := ctx.Arg("state"); s != "" {
|
||||
q.Set("state", s)
|
||||
}
|
||||
// [P2 参数补全] 以下 7 个查询参数原 CLI 未暴露
|
||||
if k := ctx.Arg("keyword"); k != "" {
|
||||
q.Set("keyword", k) // 关键词搜索
|
||||
q.Set("keyword", k)
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
q.Set("version_id", m) // CLI "milestone" → API "version_id"(PR 的里程碑字段名和 issue 不同)
|
||||
q.Set("version_id", m)
|
||||
}
|
||||
if t := ctx.Arg("tag"); t != "" {
|
||||
q.Set("issue_tag_id", t) // CLI "tag" → API "issue_tag_id"
|
||||
q.Set("issue_tag_id", t)
|
||||
}
|
||||
if r := ctx.Arg("reviewer"); r != "" {
|
||||
q.Set("reviewer_id", r) // CLI "reviewer" → API "reviewer_id"
|
||||
q.Set("reviewer_id", r)
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
q.Set("assign_user_id", a) // CLI "assignee" → API "assign_user_id"
|
||||
q.Set("assign_user_id", a)
|
||||
}
|
||||
if s := ctx.Arg("sort"); s != "" {
|
||||
q.Set("sort", s) // 排序字段
|
||||
q.Set("sort", s)
|
||||
}
|
||||
if d := ctx.Arg("sort-direction"); d != "" {
|
||||
q.Set("sort_direction", d) // 排序方向
|
||||
q.Set("sort_direction", d)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
|
||||
if err != nil {
|
||||
|
|
@ -94,10 +92,9 @@ additional filtering, and --sort to control ordering.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建新合并请求(需 --title 和 --head 源分支,目标分支默认仓库默认分支)
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建合并请求",
|
||||
Description: "Create a pull request",
|
||||
Long: `Create a new pull request in the current repository.
|
||||
|
||||
Requires a title and source branch (--head). The target branch
|
||||
|
|
@ -132,8 +129,6 @@ additional fields at creation time.`,
|
|||
title, _ := ctx.RequireArg("title")
|
||||
head, _ := ctx.RequireArg("head")
|
||||
base := ctx.Arg("base")
|
||||
// [P1 Bug#2] 原代码: base = "master"(硬编码)
|
||||
// 修复: 使用 GetDefaultBranch() 动态查询仓库默认分支
|
||||
if base == "" {
|
||||
base = common.GetDefaultBranch(ctx)
|
||||
}
|
||||
|
|
@ -145,21 +140,18 @@ additional fields at creation time.`,
|
|||
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"
|
||||
payload["priority_id"] = p
|
||||
}
|
||||
if a := ctx.Arg("assignee"); a != "" {
|
||||
payload["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
|
||||
payload["assigned_to_id"] = a
|
||||
}
|
||||
if m := ctx.Arg("milestone"); m != "" {
|
||||
payload["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
|
||||
payload["fixed_version_id"] = m
|
||||
}
|
||||
// [P2 参数补全] PR 的标签需要数组格式 issue_tag_ids(和 issue 的单值 issue_tag_id 不同)
|
||||
// 支持逗号分隔多标签:--label "1,2,3" → [1, 2, 3]
|
||||
if l := ctx.Arg("label"); l != "" {
|
||||
// Support comma-separated label IDs
|
||||
if strings.Contains(l, ",") {
|
||||
// 多标签:拆分、去空格、转为 []interface{} 数组
|
||||
parts := strings.Split(l, ",")
|
||||
ids := make([]interface{}, len(parts))
|
||||
for i, p := range parts {
|
||||
|
|
@ -167,7 +159,6 @@ additional fields at creation time.`,
|
|||
}
|
||||
payload["issue_tag_ids"] = ids
|
||||
} else {
|
||||
// 单标签也用数组格式
|
||||
payload["issue_tag_ids"] = []interface{}{l}
|
||||
}
|
||||
}
|
||||
|
|
@ -178,10 +169,9 @@ additional fields at creation time.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看指定合并请求的详情(标题/描述/状态/作者/分支等)
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看合并请求详情",
|
||||
Description: "View pull request details",
|
||||
Long: `View detailed information about a specific pull request.
|
||||
|
||||
Displays PR title, description, status, author, branches,
|
||||
|
|
@ -203,10 +193,9 @@ and other metadata.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +merge:合并合并请求(支持 merge/rebase/squash 三种方式,可自定义提交信息)
|
||||
{
|
||||
Name: "merge",
|
||||
Description: "合并合并请求",
|
||||
Description: "Merge a pull request",
|
||||
Long: `Merge a pull request using the specified merge method.
|
||||
|
||||
Supported merge methods: merge (default), rebase, squash.
|
||||
|
|
@ -256,10 +245,9 @@ Use --title and --body to customize the merge commit message.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +close:关闭合并请求(不合并,关闭后需重新开启才能合并)
|
||||
{
|
||||
Name: "close",
|
||||
Description: "关闭合并请求",
|
||||
Description: "Close a pull request",
|
||||
Long: `Close a pull request without merging.
|
||||
|
||||
The PR will be marked as closed and cannot be merged afterwards
|
||||
|
|
@ -281,10 +269,9 @@ unless it is reopened.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +files:列出合并请求中的变更文件(路径/变更类型/增删行数)
|
||||
{
|
||||
Name: "files",
|
||||
Description: "列出合并请求的变更文件",
|
||||
Description: "List changed files in a pull request",
|
||||
Long: `List all files changed in a pull request.
|
||||
|
||||
Shows the file path, change type (added, modified, deleted),
|
||||
|
|
@ -306,10 +293,9 @@ and the number of additions and deletions for each file.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +versions:列出合并请求的补丁集版本历史
|
||||
{
|
||||
Name: "versions",
|
||||
Description: "列出合并请求的补丁集版本",
|
||||
Description: "List pull request patchset versions",
|
||||
Long: `List all patchset versions of a pull request.
|
||||
|
||||
Each time a PR is updated with new commits, a new patchset version
|
||||
|
|
@ -334,13 +320,9 @@ is created. This command shows the version history.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// [P1 Bug#3 修复] 原代码中 +diff 和 +files 调用了同一个 API 端点,功能完全重复
|
||||
// 修复: 将 +diff 改名为 +version-diff,指向 PR 补丁集版本的差异 API
|
||||
// +version-diff:查看合并请求某补丁集版本的差异(可用 --file 过滤指定文件)
|
||||
// +files 仍然用于查看 PR 文件列表,两者功能区分开
|
||||
{
|
||||
Name: "version-diff",
|
||||
Description: "查看合并请求补丁集版本的差异",
|
||||
Description: "Show diff for a pull request patchset version",
|
||||
Long: `Show the diff for a specific patchset version of a pull request.
|
||||
|
||||
Use --file to filter the diff to a specific file path.`,
|
||||
|
|
@ -383,10 +365,9 @@ Use --file to filter the diff to a specific file path.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +reviews:列出合并请求的评审记录(可按评审状态筛选)
|
||||
{
|
||||
Name: "reviews",
|
||||
Description: "列出合并请求的评审",
|
||||
Description: "List pull request reviews",
|
||||
Long: `List all reviews submitted for a pull request.
|
||||
|
||||
Use --status to filter reviews by their status:
|
||||
|
|
@ -422,10 +403,9 @@ common, approved, or rejected.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +review:对合并请求提交评审(common/approved/rejected,支持 --dry-run 预览)
|
||||
{
|
||||
Name: "review",
|
||||
Description: "创建合并请求评审",
|
||||
Description: "Create a pull request review",
|
||||
Long: `Submit a review on a pull request.
|
||||
|
||||
Supported review statuses: common (comment), approved, rejected.
|
||||
|
|
@ -488,10 +468,9 @@ Use --dry-run to preview the review without submitting it.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +comments:列出合并请求下的评审评论
|
||||
{
|
||||
Name: "comments",
|
||||
Description: "列出合并请求的评审评论",
|
||||
Description: "List review comments on a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -510,10 +489,9 @@ Use --dry-run to preview the review without submitting it.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create-comment:在合并请求下创建一条评审评论(可附行号与文件路径)
|
||||
{
|
||||
Name: "create-comment",
|
||||
Description: "创建合并请求的评审评论",
|
||||
Description: "Create a review comment on a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||
|
|
@ -548,10 +526,9 @@ Use --dry-run to preview the review without submitting it.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update-comment:修改合并请求下的某条评审评论
|
||||
{
|
||||
Name: "update-comment",
|
||||
Description: "修改合并请求的评审评论",
|
||||
Description: "Update a review comment on a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
|
||||
|
|
@ -583,10 +560,9 @@ Use --dry-run to preview the review without submitting it.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-comment:删除合并请求下的某条评审评论
|
||||
{
|
||||
Name: "delete-comment",
|
||||
Description: "删除合并请求的评审评论",
|
||||
Description: "Delete a review comment on a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
|
||||
|
|
@ -610,10 +586,9 @@ Use --dry-run to preview the review without submitting it.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +commits:列出合并请求中包含的提交记录
|
||||
{
|
||||
Name: "commits",
|
||||
Description: "列出合并请求的提交",
|
||||
Description: "List commits in a pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -632,10 +607,9 @@ Use --dry-run to preview the review without submitting it.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +reopen:重新打开已关闭的合并请求
|
||||
{
|
||||
Name: "reopen",
|
||||
Description: "重新打开合并请求",
|
||||
Description: "Reopen a closed pull request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
},
|
||||
|
|
@ -654,10 +628,9 @@ Use --dry-run to preview the review without submitting it.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新合并请求的标题和/或描述
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新合并请求的标题或描述",
|
||||
Description: "Update pull request title and/or description",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "title", Short: "t", Usage: "New title"},
|
||||
|
|
@ -690,10 +663,9 @@ Use --dry-run to preview the review without submitting it.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +comment:在合并请求下添加评论(作为关联疑修的日志条目发布)
|
||||
{
|
||||
Name: "comment",
|
||||
Description: "在合并请求下添加评论",
|
||||
Description: "Add a comment to a pull request",
|
||||
Long: `Add a comment to a pull request.
|
||||
|
||||
The comment is posted as a journal entry on the underlying issue
|
||||
|
|
@ -746,8 +718,6 @@ func validatePRReviewStatus(status string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// [P2 参数补全] 校验合并方式参数,只允许 merge/rebase/squash 三种合法值
|
||||
// 这是纯前端校验,在发送 API 请求之前拦截非法输入
|
||||
func validateMergeMethod(method string) error {
|
||||
switch method {
|
||||
case "merge", "rebase", "squash":
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/attachment"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/board"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/collaborator"
|
||||
|
|
@ -43,6 +44,7 @@ func RegisterAll(root *cobra.Command) {
|
|||
"ci": ci.Shortcuts(),
|
||||
"webhook": webhook.Shortcuts(),
|
||||
"wiki": wiki.Shortcuts(),
|
||||
"board": board.Shortcuts(),
|
||||
"snippet": snippet.Shortcuts(),
|
||||
"collaborator": collaborator.Shortcuts(),
|
||||
"tag": tag.Shortcuts(),
|
||||
|
|
@ -69,6 +71,7 @@ func RegisterAll(root *cobra.Command) {
|
|||
"ci": "CI/CD operations",
|
||||
"webhook": "Webhook operations",
|
||||
"wiki": "Wiki operations",
|
||||
"board": "Project board (kanban) operations",
|
||||
"snippet": "Code snippet management",
|
||||
"collaborator": "Collaborator management",
|
||||
"tag": "Git tag operations",
|
||||
|
|
@ -92,62 +95,3 @@ 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,10 +10,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有发行版
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的所有发行版",
|
||||
Description: "List releases",
|
||||
Long: `List all releases in the current repository.
|
||||
|
||||
Shows release tag, name, prerelease status, and creation date.
|
||||
|
|
@ -48,22 +47,20 @@ Use --latest to show only the most recent release.`,
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// [P2 参数补全] API 不支持"只看预发布版本"的过滤参数,改为客户端过滤
|
||||
// 拿到全量数据后遍历,只保留 prerelease=true 的条目
|
||||
// --prerelease-only: client-side filter
|
||||
if ctx.Arg("prerelease-only") == "true" {
|
||||
filterReleasesByPrerelease(env)
|
||||
}
|
||||
// [P2 参数补全] --latest: 拿到列表后只保留第一条(最新)
|
||||
// --latest: keep only the first (most recent) release
|
||||
if ctx.Arg("latest") == "true" {
|
||||
keepOnlyLatestRelease(env)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建发行版
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建发行版",
|
||||
Description: "Create a release",
|
||||
Long: `Create a new release for the current repository.
|
||||
|
||||
A release is associated with a Git tag. If the tag does not exist,
|
||||
|
|
@ -95,12 +92,10 @@ and --prerelease to mark it as a pre-release version.`,
|
|||
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) // 动态查询默认分支
|
||||
payload["target_commitish"] = common.GetDefaultBranch(ctx)
|
||||
}
|
||||
if ctx.Arg("prerelease") == "true" {
|
||||
payload["prerelease"] = true
|
||||
|
|
@ -112,10 +107,9 @@ and --prerelease to mark it as a pre-release version.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看发行版的详细信息
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看发行版的详细信息",
|
||||
Description: "View release details",
|
||||
Long: `Show detailed information about a specific release.
|
||||
|
||||
Provide the release ID or tag name to view its details.`,
|
||||
|
|
@ -139,10 +133,9 @@ Provide the release ID or tag name to view its details.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除发行版
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除发行版",
|
||||
Description: "Delete a release",
|
||||
Long: `Delete a release from the current repository.
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before deletion.`,
|
||||
|
|
@ -180,10 +173,9 @@ This action is irreversible. A confirmation prompt is shown before deletion.`,
|
|||
}, nil))
|
||||
},
|
||||
},
|
||||
// +edit:获取发行版编辑页详情
|
||||
{
|
||||
Name: "edit",
|
||||
Description: "获取发行版编辑页详情",
|
||||
Description: "Get release edit page details",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
},
|
||||
|
|
@ -199,10 +191,9 @@ This action is irreversible. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新发行版
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新发行版",
|
||||
Description: "Update a release",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New release name"},
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出当前用户或指定用户的仓库(支持按类别筛选:manage/mirror/sync/fork/all)
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出用户或组织的仓库",
|
||||
Description: "List repositories for a user or organization",
|
||||
Long: `List repositories visible to the current user.
|
||||
|
||||
By default shows repositories you manage. Use --user to list
|
||||
|
|
@ -54,10 +53,9 @@ another user's repositories. Use --category to filter by type
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +info:显示当前仓库的详情(名称/描述/可见性/默认分支等)
|
||||
{
|
||||
Name: "info",
|
||||
Description: "显示仓库详情",
|
||||
Description: "Show repository details",
|
||||
Long: `Display detailed information about the current repository.
|
||||
|
||||
Shows name, description, visibility, default branch, and other metadata.`,
|
||||
|
|
@ -74,10 +72,9 @@ Shows name, description, visibility, default branch, and other metadata.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:在当前账户下创建新仓库(需 --name,可选 --description 与 --private)
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建新仓库",
|
||||
Description: "Create a new repository",
|
||||
Long: `Create a new repository under your account.
|
||||
|
||||
The repository name is required. Optionally provide a description
|
||||
|
|
@ -126,10 +123,9 @@ and set visibility to private.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +fork:将当前仓库 fork 到自己的命名空间下
|
||||
{
|
||||
Name: "fork",
|
||||
Description: "Fork 仓库",
|
||||
Description: "Fork a repository",
|
||||
Long: `Fork the current repository into your account.
|
||||
|
||||
This creates a copy of the repository under your namespace.`,
|
||||
|
|
@ -146,10 +142,9 @@ This creates a copy of the repository under your namespace.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:永久删除当前仓库(不可逆,删除前需确认)
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除仓库",
|
||||
Description: "Delete a repository",
|
||||
Long: `Delete the current repository permanently.
|
||||
|
||||
This action is irreversible and removes all data including issues,
|
||||
|
|
@ -175,10 +170,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
|
||||
// --- Settings/Info group ---
|
||||
|
||||
// +update:更新当前仓库的设置(名称/描述/可见性/网站)
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新仓库设置",
|
||||
Description: "Update repository settings",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "New repository name"},
|
||||
{Name: "description", Short: "d", Usage: "Repository description"},
|
||||
|
|
@ -209,10 +203,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +about:显示仓库的关于页面
|
||||
{
|
||||
Name: "about",
|
||||
Description: "显示仓库关于页面",
|
||||
Description: "Show repository about page",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -224,10 +217,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +menu:列出仓库的导航菜单
|
||||
{
|
||||
Name: "menu",
|
||||
Description: "列出仓库导航菜单",
|
||||
Description: "List repository navigation menu",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -239,10 +231,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +units-get:获取仓库的导航单元设置
|
||||
{
|
||||
Name: "units-get",
|
||||
Description: "获取仓库导航单元设置",
|
||||
Description: "Get repository navigation settings",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -254,10 +245,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +units-set:更新仓库的导航单元设置(--units 传 JSON 数组)
|
||||
{
|
||||
Name: "units-set",
|
||||
Description: "更新仓库导航单元设置",
|
||||
Description: "Update repository navigation settings",
|
||||
Flags: []common.Flag{
|
||||
{Name: "units", Short: "u", Usage: "JSON array of unit settings", Required: true},
|
||||
},
|
||||
|
|
@ -283,10 +273,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +edit-detail:获取仓库编辑页面的详情
|
||||
{
|
||||
Name: "edit-detail",
|
||||
Description: "获取仓库编辑详情",
|
||||
Description: "Get repository edit details",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -298,10 +287,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +simple:获取仓库的简化详情信息
|
||||
{
|
||||
Name: "simple",
|
||||
Description: "获取仓库简化详情",
|
||||
Description: "Get repository simplified details",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -316,10 +304,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
|
||||
// --- Stats group ---
|
||||
|
||||
// +code-stats:获取仓库的代码统计信息
|
||||
{
|
||||
Name: "code-stats",
|
||||
Description: "获取仓库代码统计",
|
||||
Description: "Get repository code statistics",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -331,10 +318,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +languages:获取仓库的编程语言占比
|
||||
{
|
||||
Name: "languages",
|
||||
Description: "获取仓库语言占比",
|
||||
Description: "Get repository language breakdown",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -346,10 +332,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +contributors:列出仓库的贡献者
|
||||
{
|
||||
Name: "contributors",
|
||||
Description: "列出仓库贡献者",
|
||||
Description: "List repository contributors",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -361,10 +346,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +contributors-stat:获取贡献者的代码统计信息
|
||||
{
|
||||
Name: "contributors-stat",
|
||||
Description: "获取贡献者代码统计",
|
||||
Description: "Get contributor code statistics",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -379,10 +363,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
|
||||
// --- Social/Interaction group ---
|
||||
|
||||
// +recommend:获取推荐项目列表
|
||||
{
|
||||
Name: "recommend",
|
||||
Description: "获取推荐项目",
|
||||
Description: "Get recommended projects",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/projects/recommend", nil)
|
||||
if err != nil {
|
||||
|
|
@ -391,10 +374,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +star:点赞(加星)指定项目
|
||||
{
|
||||
Name: "star",
|
||||
Description: "点赞项目",
|
||||
Description: "Star (like) a project",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
},
|
||||
|
|
@ -410,10 +392,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +unstar:取消点赞(取消加星)指定项目
|
||||
{
|
||||
Name: "unstar",
|
||||
Description: "取消点赞项目",
|
||||
Description: "Unstar (unlike) a project",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
},
|
||||
|
|
@ -429,10 +410,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +watch:关注指定项目
|
||||
{
|
||||
Name: "watch",
|
||||
Description: "关注项目",
|
||||
Description: "Watch (follow) a project",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
},
|
||||
|
|
@ -455,10 +435,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +unwatch:取消关注指定项目
|
||||
{
|
||||
Name: "unwatch",
|
||||
Description: "取消关注项目",
|
||||
Description: "Unwatch (unfollow) a project",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
},
|
||||
|
|
@ -474,10 +453,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +stargazers:列出对当前仓库加星的用户
|
||||
{
|
||||
Name: "stargazers",
|
||||
Description: "列出仓库的加星用户",
|
||||
Description: "List users who starred the repository",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -489,10 +467,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +watchers:列出关注当前仓库的用户
|
||||
{
|
||||
Name: "watchers",
|
||||
Description: "列出仓库的关注者",
|
||||
Description: "List users watching the repository",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -507,10 +484,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
|
||||
// --- Transfer group ---
|
||||
|
||||
// +transfer:将仓库转移给其他所有者或组织
|
||||
{
|
||||
Name: "transfer",
|
||||
Description: "转移仓库所有权",
|
||||
Description: "Transfer repository to another owner",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Target owner/organization", Required: true},
|
||||
},
|
||||
|
|
@ -532,10 +508,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +cancel-transfer:取消待处理的仓库转移申请
|
||||
{
|
||||
Name: "cancel-transfer",
|
||||
Description: "取消待处理的仓库转移",
|
||||
Description: "Cancel a pending repository transfer",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -547,10 +522,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +transfer-orgs:列出可用于转移目标组织
|
||||
{
|
||||
Name: "transfer-orgs",
|
||||
Description: "列出可转移的组织",
|
||||
Description: "List organizations available for transfer",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -562,10 +536,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +invite-link:获取当前仓库的邀请链接
|
||||
{
|
||||
Name: "invite-link",
|
||||
Description: "获取仓库邀请链接",
|
||||
Description: "Get current repository invite link",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -580,10 +553,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
|
||||
// --- L3 Operations: Join/Quit/Mirror/Topics ---
|
||||
|
||||
// +join:申请加入指定项目(可指定期望角色)
|
||||
{
|
||||
Name: "join",
|
||||
Description: "申请加入项目",
|
||||
Description: "Apply to join a project",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
|
||||
{Name: "role", Short: "r", Usage: "Desired role"},
|
||||
|
|
@ -606,10 +578,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +quit:退出当前项目
|
||||
{
|
||||
Name: "quit",
|
||||
Description: "退出项目",
|
||||
Description: "Quit a project",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
|
|
@ -621,10 +592,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +migrate:创建镜像项目(从上游 URL 镜像克隆)
|
||||
{
|
||||
Name: "migrate",
|
||||
Description: "创建镜像项目",
|
||||
Description: "Create a mirrored project",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
|
||||
{Name: "clone-url", Short: "u", Usage: "Upstream clone URL", Required: true},
|
||||
|
|
@ -653,10 +623,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +sync-mirror:同步镜像仓库(从上游拉取更新)
|
||||
{
|
||||
Name: "sync-mirror",
|
||||
Description: "同步镜像仓库",
|
||||
Description: "Sync a mirrored repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Repository ID", Required: true},
|
||||
},
|
||||
|
|
@ -672,10 +641,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +topics:列出项目话题
|
||||
{
|
||||
Name: "topics",
|
||||
Description: "列出项目话题",
|
||||
Description: "List project topics",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/v1/project_topics", nil)
|
||||
if err != nil {
|
||||
|
|
@ -684,10 +652,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create-topic:创建项目话题(需 --name)
|
||||
{
|
||||
Name: "create-topic",
|
||||
Description: "创建项目话题",
|
||||
Description: "Create a project topic",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Topic name", Required: true},
|
||||
},
|
||||
|
|
@ -706,10 +673,9 @@ pull requests, and wiki. A confirmation prompt is shown before deletion.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-topic:删除指定项目话题
|
||||
{
|
||||
Name: "delete-topic",
|
||||
Description: "删除项目话题",
|
||||
Description: "Delete a project topic",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Topic ID", Required: true},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +repos:搜索仓库
|
||||
{
|
||||
Name: "repos",
|
||||
Description: "搜索仓库",
|
||||
Description: "Search repositories",
|
||||
Long: `Search for repositories on GitLink by keyword.
|
||||
|
||||
Returns matching repository names, owners, and descriptions.
|
||||
|
|
@ -42,8 +41,6 @@ to control ordering, and --page and --limit for pagination.`,
|
|||
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)
|
||||
}
|
||||
|
|
@ -60,10 +57,9 @@ to control ordering, and --page and --limit for pagination.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +users:搜索用户
|
||||
{
|
||||
Name: "users",
|
||||
Description: "搜索用户",
|
||||
Description: "Search users",
|
||||
Long: `Search for users on GitLink by keyword.
|
||||
|
||||
Returns matching user login names, display names, and profile summaries.
|
||||
|
|
@ -90,7 +86,6 @@ for pagination.`,
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,25 +16,22 @@ const snippetsDir = "snippets"
|
|||
// Shortcuts returns code snippet management shortcuts.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库中的所有代码片段
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库中的所有代码片段",
|
||||
Description: "List code snippets in the repository",
|
||||
Run: runList,
|
||||
},
|
||||
// +view:查看代码片段内容
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看代码片段内容",
|
||||
Description: "View a code snippet",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Snippet name", Required: true},
|
||||
},
|
||||
Run: runView,
|
||||
},
|
||||
// +create:新建代码片段
|
||||
{
|
||||
Name: "create",
|
||||
Description: "新建代码片段",
|
||||
Description: "Create a new code snippet",
|
||||
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},
|
||||
|
|
@ -44,10 +41,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: runCreate,
|
||||
},
|
||||
// +delete:删除代码片段
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除代码片段",
|
||||
Description: "Delete a code snippet",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Snippet name", Required: true},
|
||||
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出当前账号的 SSH 公钥
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出当前账号的 SSH 公钥",
|
||||
Description: "List your SSH public keys",
|
||||
Long: `List your SSH public keys.
|
||||
|
||||
Shows all SSH public keys associated with your account, including
|
||||
|
|
@ -34,10 +33,9 @@ Supports pagination with --page and --limit flags.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:添加新的 SSH 公钥
|
||||
{
|
||||
Name: "create",
|
||||
Description: "添加新的 SSH 公钥",
|
||||
Description: "Add a new SSH public key",
|
||||
Long: `Add a new SSH public key.
|
||||
|
||||
Registers a new SSH public key to your account. The key content must
|
||||
|
|
@ -68,10 +66,9 @@ be a valid public key in ssh-rsa or ssh-ed25519 format.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除指定的 SSH 公钥
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除指定的 SSH 公钥",
|
||||
Description: "Delete an SSH public key",
|
||||
Long: `Delete an SSH public key.
|
||||
|
||||
Removes an SSH public key from your account by its ID. This action cannot be undone.`,
|
||||
|
|
|
|||
|
|
@ -3,16 +3,14 @@ package tag
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有标签
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的所有标签",
|
||||
Description: "List repository tags",
|
||||
Long: `List all tags in the current repository.
|
||||
|
||||
Shows tag name, commit SHA, and creation date.
|
||||
|
|
@ -42,15 +40,12 @@ Use --search to filter tags by name pattern.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看标签的详细信息
|
||||
// 注意:GitLink API 不提供"获取单个标签"端点,这里通过列出全部标签后按名称过滤实现。
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看标签的详细信息",
|
||||
Description: "View tag details",
|
||||
Long: `Show detailed information about a specific tag.
|
||||
|
||||
GitLink's API has no single-tag endpoint, so this lists all tags and
|
||||
returns the one whose name matches.`,
|
||||
Displays the tag name, associated commit, message, and tagger details.`,
|
||||
Example: ` # View details of a tag
|
||||
gitlink tag +view --name v1.0.0`,
|
||||
Flags: []common.Flag{
|
||||
|
|
@ -64,21 +59,16 @@ returns the one whose name matches.`,
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo), nil)
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/tags/%s", ctx.Owner, ctx.Repo, name), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag := findTagByName(env, name)
|
||||
if tag == nil {
|
||||
return fmt.Errorf("标签 %q 不存在", name)
|
||||
}
|
||||
return ctx.OutputData(tag)
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除标签
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除标签",
|
||||
Description: "Delete a tag",
|
||||
Long: `Delete a tag from the current repository.
|
||||
|
||||
This action is irreversible. A confirmation prompt is shown before deletion.`,
|
||||
|
|
@ -109,30 +99,3 @@ This action is irreversible. A confirmation prompt is shown before deletion.`,
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
// findTagByName 在标签列表 envelope 中按名称查找单个标签。
|
||||
// GitLink API 没有"获取单个标签"端点,tag +view 需要先列出全部再过滤。
|
||||
// 找不到时返回 nil。
|
||||
func findTagByName(env *output.Envelope, name string) map[string]interface{} {
|
||||
if env == nil || env.Data == nil {
|
||||
return nil
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
tags, ok := data["tags"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, t := range tags {
|
||||
tag, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if n, _ := tag["name"].(string); n == name {
|
||||
return tag
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
// TestFindTagByName_Found 验证能从标签列表中按名称找到标签。
|
||||
func TestFindTagByName_Found(t *testing.T) {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{
|
||||
"tags": []interface{}{
|
||||
map[string]interface{}{"name": "v1.0.0", "id": "abc"},
|
||||
map[string]interface{}{"name": "v2.0.0", "id": "def"},
|
||||
},
|
||||
"total_count": 2,
|
||||
},
|
||||
}
|
||||
tag := findTagByName(env, "v1.0.0")
|
||||
if tag == nil {
|
||||
t.Fatal("expected to find v1.0.0")
|
||||
}
|
||||
if tag["id"] != "abc" {
|
||||
t.Fatalf("expected id abc, got %v", tag["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindTagByName_NotFound 验证找不到时返回 nil。
|
||||
func TestFindTagByName_NotFound(t *testing.T) {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{
|
||||
"tags": []interface{}{
|
||||
map[string]interface{}{"name": "v1.0.0"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if tag := findTagByName(env, "v9.9.9"); tag != nil {
|
||||
t.Fatalf("expected nil for nonexistent tag, got %v", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindTagByName_NilEnvelope 验证边界情况。
|
||||
func TestFindTagByName_NilEnvelope(t *testing.T) {
|
||||
if tag := findTagByName(nil, "v1"); tag != nil {
|
||||
t.Fatal("expected nil for nil envelope")
|
||||
}
|
||||
env := &output.Envelope{OK: true}
|
||||
if tag := findTagByName(env, "v1"); tag != nil {
|
||||
t.Fatal("expected nil for nil data")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindTagByName_NoTagsKey 验证数据结构不含 tags 时返回 nil。
|
||||
func TestFindTagByName_NoTagsKey(t *testing.T) {
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{"other": "value"},
|
||||
}
|
||||
if tag := findTagByName(env, "v1"); tag != nil {
|
||||
t.Fatalf("expected nil when no tags key, got %v", tag)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,10 +8,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出某拥有者的项目模板
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出某拥有者的项目模板",
|
||||
Description: "List project templates for an owner",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
},
|
||||
|
|
@ -27,10 +26,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建项目模板
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建项目模板",
|
||||
Description: "Create a project template",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Template name", Required: true},
|
||||
|
|
@ -58,10 +56,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看项目模板
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看项目模板",
|
||||
Description: "View a project template",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
|
||||
|
|
@ -89,10 +86,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新项目模板
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新项目模板",
|
||||
Description: "Update a project template",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
|
||||
|
|
@ -122,10 +118,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:删除项目模板
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除项目模板",
|
||||
Description: "Delete a project template",
|
||||
Flags: []common.Flag{
|
||||
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
|
||||
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
|
||||
|
|
|
|||
|
|
@ -10,10 +10,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +me:显示当前登录用户信息
|
||||
{
|
||||
Name: "me",
|
||||
Description: "显示当前登录用户",
|
||||
Description: "Show current authenticated user",
|
||||
Long: `Show profile information for the currently authenticated user.
|
||||
|
||||
Displays login name, email, and account details based on the
|
||||
|
|
@ -28,10 +27,9 @@ configured API token.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +info:显示指定用户的资料
|
||||
{
|
||||
Name: "info",
|
||||
Description: "显示用户资料",
|
||||
Description: "Show user profile",
|
||||
Long: `Show public profile information for a GitLink user.
|
||||
|
||||
Looks up the user by their login name and displays their
|
||||
|
|
@ -54,10 +52,9 @@ username, bio, and other public profile fields.`,
|
|||
},
|
||||
},
|
||||
// --- Message management ---
|
||||
// +messages:列出用户的私信
|
||||
{
|
||||
Name: "messages",
|
||||
Description: "列出用户私信",
|
||||
Description: "List user messages",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "page", Usage: "Page number", Default: "1"},
|
||||
|
|
@ -86,10 +83,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete-message:删除一条私信
|
||||
{
|
||||
Name: "delete-message",
|
||||
Description: "删除一条私信",
|
||||
Description: "Delete a message",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Message ID", Required: true},
|
||||
|
|
@ -112,10 +108,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create-message:发送一条私信
|
||||
{
|
||||
Name: "create-message",
|
||||
Description: "发送一条私信",
|
||||
Description: "Create a message",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "content", Usage: "Message content", Required: true},
|
||||
|
|
@ -138,10 +133,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +read-message:将私信标记为已读
|
||||
{
|
||||
Name: "read-message",
|
||||
Description: "将私信标记为已读",
|
||||
Description: "Mark a message as read",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Message ID", Required: true},
|
||||
|
|
@ -164,10 +158,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +msg-settings:获取消息通知设置
|
||||
{
|
||||
Name: "msg-settings",
|
||||
Description: "获取消息通知设置",
|
||||
Description: "Get message notification settings",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
|
|
@ -183,10 +176,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update-msg-settings:更新消息通知设置
|
||||
{
|
||||
Name: "update-msg-settings",
|
||||
Description: "更新消息通知设置",
|
||||
Description: "Update message notification settings",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "settings", Usage: "Settings JSON array", Required: true},
|
||||
|
|
@ -214,10 +206,9 @@ username, bio, and other public profile fields.`,
|
|||
},
|
||||
},
|
||||
// --- Pinned projects ---
|
||||
// +pinned-projects:列出置顶项目
|
||||
{
|
||||
Name: "pinned-projects",
|
||||
Description: "列出置顶项目",
|
||||
Description: "List pinned projects",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
|
|
@ -233,10 +224,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +pin-project:置顶一个项目
|
||||
{
|
||||
Name: "pin-project",
|
||||
Description: "置顶一个项目",
|
||||
Description: "Pin a project",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Project ID", Required: true},
|
||||
|
|
@ -259,10 +249,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +reorder-pinned:调整置顶项目的顺序
|
||||
{
|
||||
Name: "reorder-pinned",
|
||||
Description: "调整置顶项目的顺序",
|
||||
Description: "Reorder a pinned project",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Pinned project ID", Required: true},
|
||||
|
|
@ -291,10 +280,9 @@ username, bio, and other public profile fields.`,
|
|||
},
|
||||
},
|
||||
// --- Statistics ---
|
||||
// +activity:获取用户活动统计
|
||||
{
|
||||
Name: "activity",
|
||||
Description: "获取用户活动统计",
|
||||
Description: "Get user activity statistics",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
|
|
@ -310,10 +298,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +headmap:获取用户活跃热力图数据
|
||||
{
|
||||
Name: "headmap",
|
||||
Description: "获取用户活跃热力图数据",
|
||||
Description: "Get user heatmap data",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
|
|
@ -329,10 +316,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +develop-stats:获取用户开发统计
|
||||
{
|
||||
Name: "develop-stats",
|
||||
Description: "获取用户开发统计",
|
||||
Description: "Get user development statistics",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
|
|
@ -348,10 +334,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +role-stats:获取用户角色统计
|
||||
{
|
||||
Name: "role-stats",
|
||||
Description: "获取用户角色统计",
|
||||
Description: "Get user role statistics",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
|
|
@ -367,10 +352,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +major-stats:获取用户专业统计
|
||||
{
|
||||
Name: "major-stats",
|
||||
Description: "获取用户专业统计",
|
||||
Description: "Get user major statistics",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
|
|
@ -387,10 +371,9 @@ username, bio, and other public profile fields.`,
|
|||
},
|
||||
},
|
||||
// --- Transfer / Apply ---
|
||||
// +applied-transfers:列出待处理的仓库转让申请
|
||||
{
|
||||
Name: "applied-transfers",
|
||||
Description: "列出待处理的仓库转让申请",
|
||||
Description: "List applied transfer projects",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
|
|
@ -406,10 +389,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +accept-transfer:接受仓库转让申请
|
||||
{
|
||||
Name: "accept-transfer",
|
||||
Description: "接受仓库转让申请",
|
||||
Description: "Accept a project transfer",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Transfer ID", Required: true},
|
||||
|
|
@ -430,10 +412,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +refuse-transfer:拒绝仓库转让申请
|
||||
{
|
||||
Name: "refuse-transfer",
|
||||
Description: "拒绝仓库转让申请",
|
||||
Description: "Refuse a project transfer",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Transfer ID", Required: true},
|
||||
|
|
@ -454,10 +435,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +applied-projects:列出待处理的仓库加入申请
|
||||
{
|
||||
Name: "applied-projects",
|
||||
Description: "列出待处理的仓库加入申请",
|
||||
Description: "List applied projects (join requests)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
|
|
@ -473,10 +453,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +accept-join:接受仓库加入申请
|
||||
{
|
||||
Name: "accept-join",
|
||||
Description: "接受仓库加入申请",
|
||||
Description: "Accept a project join request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Application ID", Required: true},
|
||||
|
|
@ -497,10 +476,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +refuse-join:拒绝仓库加入申请
|
||||
{
|
||||
Name: "refuse-join",
|
||||
Description: "拒绝仓库加入申请",
|
||||
Description: "Refuse a project join request",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "id", Usage: "Application ID", Required: true},
|
||||
|
|
@ -522,10 +500,9 @@ username, bio, and other public profile fields.`,
|
|||
},
|
||||
},
|
||||
// --- Feedback / Update ---
|
||||
// +feedback:提交反馈意见
|
||||
{
|
||||
Name: "feedback",
|
||||
Description: "提交反馈意见",
|
||||
Description: "Submit feedback",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "content", Usage: "Feedback content", Required: true},
|
||||
|
|
@ -548,10 +525,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update:更新用户资料
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新用户资料",
|
||||
Description: "Update user profile",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "nickname", Usage: "New nickname"},
|
||||
|
|
@ -577,10 +553,9 @@ username, bio, and other public profile fields.`,
|
|||
},
|
||||
},
|
||||
// --- Account settings ---
|
||||
// +change-password:修改账户密码
|
||||
{
|
||||
Name: "change-password",
|
||||
Description: "修改账户密码",
|
||||
Description: "Change password",
|
||||
Flags: []common.Flag{
|
||||
{Name: "old-password", Usage: "Current password", Required: true},
|
||||
{Name: "new-password", Usage: "New password", Required: true},
|
||||
|
|
@ -604,10 +579,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update-email:更新邮箱地址
|
||||
{
|
||||
Name: "update-email",
|
||||
Description: "更新邮箱地址",
|
||||
Description: "Update email address",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "email", Usage: "New email address", Required: true},
|
||||
|
|
@ -635,10 +609,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +update-phone:更新手机号码
|
||||
{
|
||||
Name: "update-phone",
|
||||
Description: "更新手机号码",
|
||||
Description: "Update phone number",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "phone", Usage: "New phone number", Required: true},
|
||||
|
|
@ -666,10 +639,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +send-verify:发送邮箱验证码
|
||||
{
|
||||
Name: "send-verify",
|
||||
Description: "发送邮箱验证码",
|
||||
Description: "Send email verification code",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "email", Usage: "Email address"},
|
||||
|
|
@ -690,10 +662,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +check-verify:校验邮箱验证码
|
||||
{
|
||||
Name: "check-verify",
|
||||
Description: "校验邮箱验证码",
|
||||
Description: "Check email verification code",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "code", Usage: "Verification code", Required: true},
|
||||
|
|
@ -721,10 +692,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +check-password:校验密码
|
||||
{
|
||||
Name: "check-password",
|
||||
Description: "校验密码",
|
||||
Description: "Verify password",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "password", Usage: "Password to verify", Required: true},
|
||||
|
|
@ -747,10 +717,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +check-email:校验邮箱
|
||||
{
|
||||
Name: "check-email",
|
||||
Description: "校验邮箱",
|
||||
Description: "Verify email",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
{Name: "email", Usage: "Email to verify", Required: true},
|
||||
|
|
@ -773,10 +742,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +can-delete:检查账户是否可被注销
|
||||
{
|
||||
Name: "can-delete",
|
||||
Description: "检查账户是否可被注销",
|
||||
Description: "Check if user account can be deleted",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
|
|
@ -792,10 +760,9 @@ username, bio, and other public profile fields.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +delete:注销(停用)用户账户
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "注销(停用)用户账户",
|
||||
Description: "Delete (deactivate) user account",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +licenses:列出可用的开源许可证模板
|
||||
{
|
||||
Name: "licenses",
|
||||
Description: "列出可用的开源许可证模板",
|
||||
Description: "List available open source license templates",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/licenses", nil)
|
||||
if err != nil {
|
||||
|
|
@ -18,10 +17,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +ignores:列出可用的 .gitignore 模板
|
||||
{
|
||||
Name: "ignores",
|
||||
Description: "列出可用的 .gitignore 模板",
|
||||
Description: "List available .gitignore templates",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/ignores", nil)
|
||||
if err != nil {
|
||||
|
|
@ -30,10 +28,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +platform-msg-settings:查看平台消息通知设置
|
||||
{
|
||||
Name: "platform-msg-settings",
|
||||
Description: "查看平台消息通知设置",
|
||||
Description: "Show platform-wide message notification settings",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
env, err := ctx.CallAPI("GET", "/template_message_settings", nil)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package webhook
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -25,10 +24,9 @@ var allowedWebhookEvents = map[string]bool{
|
|||
// Shortcuts returns webhook management shortcuts.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有 Web 钩子
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的所有 Web 钩子",
|
||||
Description: "List repository webhooks",
|
||||
Long: `List repository webhooks.
|
||||
|
||||
Returns all webhooks configured for the current repository.`,
|
||||
|
|
@ -45,10 +43,9 @@ Returns all webhooks configured for the current repository.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +view:查看 Web 钩子的详细信息
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看 Web 钩子的详细信息",
|
||||
Description: "View webhook details",
|
||||
Long: `View webhook details.
|
||||
|
||||
Shows the full configuration of a specific webhook including
|
||||
|
|
@ -73,10 +70,9 @@ URL, events, type, and active status.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +create:创建仓库 Web 钩子
|
||||
{
|
||||
Name: "create",
|
||||
Description: "创建仓库 Web 钩子",
|
||||
Description: "Create a repository webhook",
|
||||
Long: `Create a repository webhook.
|
||||
|
||||
Creates a new webhook that sends HTTP requests to the specified URL
|
||||
|
|
@ -99,10 +95,9 @@ dingtalk, telegram, msteams, feishu, matrix, jianmu, softbot.`,
|
|||
},
|
||||
Run: runCreate,
|
||||
},
|
||||
// +update:更新仓库 Web 钩子,未指定的字段尽可能保留原值
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新仓库 Web 钩子,未指定的字段尽可能保留原值",
|
||||
Description: "Update a repository webhook while preserving unspecified fields when available",
|
||||
Long: `Update a repository webhook while preserving unspecified fields when available.
|
||||
|
||||
Fetches the current webhook configuration, merges changes from the provided
|
||||
|
|
@ -126,10 +121,9 @@ current configuration.`,
|
|||
},
|
||||
Run: runUpdate,
|
||||
},
|
||||
// +delete:删除仓库 Web 钩子
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除仓库 Web 钩子",
|
||||
Description: "Delete a repository webhook",
|
||||
Long: `Delete a repository webhook.
|
||||
|
||||
Permanently removes the webhook from the repository.`,
|
||||
|
|
@ -153,10 +147,9 @@ Permanently removes the webhook from the repository.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +test:触发 Web 钩子的测试投递
|
||||
{
|
||||
Name: "test",
|
||||
Description: "触发 Web 钩子的测试投递",
|
||||
Description: "Trigger a test delivery for a webhook",
|
||||
Long: `Trigger a test delivery for a webhook.
|
||||
|
||||
Sends a test payload to the webhook URL to verify the configuration.`,
|
||||
|
|
@ -180,10 +173,9 @@ Sends a test payload to the webhook URL to verify the configuration.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +tasks:列出 Web 钩子的投递任务
|
||||
{
|
||||
Name: "tasks",
|
||||
Description: "列出 Web 钩子的投递任务",
|
||||
Description: "List webhook delivery tasks",
|
||||
Long: `List webhook delivery tasks.
|
||||
|
||||
Returns the history of webhook deliveries including status,
|
||||
|
|
@ -208,115 +200,6 @@ response code, and timing information.`,
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
// +failed:列出投递失败的 Web 钩子任务
|
||||
{
|
||||
Name: "failed",
|
||||
Description: "列出投递失败的 Web 钩子任务",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
{Name: "limit", Short: "l", Usage: "Number of recent failed tasks", Default: "10"},
|
||||
},
|
||||
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/hooktasks", webhookItemPath(ctx, id)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return ctx.Output(env)
|
||||
}
|
||||
tasks, ok := raw["hooktasks"].([]interface{})
|
||||
if !ok {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"hooktasks": []interface{}{},
|
||||
"total_count": 0,
|
||||
})
|
||||
}
|
||||
limit := 0
|
||||
if limitStr := ctx.Arg("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
failedTasks := make([]interface{}, 0)
|
||||
for _, rawTask := range tasks {
|
||||
task, ok := rawTask.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if succeed, ok := task["is_succeed"].(bool); ok && !succeed {
|
||||
failedTasks = append(failedTasks, map[string]interface{}{
|
||||
"uuid": task["uuid"],
|
||||
"event_type": task["event_type"],
|
||||
"delivered_time": task["delivered_time"],
|
||||
"status": "failed",
|
||||
})
|
||||
if limit > 0 && len(failedTasks) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"hooktasks": failedTasks,
|
||||
"total_count": len(failedTasks),
|
||||
})
|
||||
},
|
||||
},
|
||||
// +task-view:查看 Web 钩子投递任务的详细信息
|
||||
{
|
||||
Name: "task-view",
|
||||
Description: "查看 Web 钩子投递任务的详细信息",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||
{Name: "task-id", Usage: "Task ID (numeric)", 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
|
||||
}
|
||||
taskID, err := ctx.RequireArg("task-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to parse hook tasks")
|
||||
}
|
||||
tasks, ok := raw["hooktasks"].([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("no hook tasks found")
|
||||
}
|
||||
for _, rawTask := range tasks {
|
||||
task, ok := rawTask.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Support both numeric ID and UUID
|
||||
if tid, ok := task["id"].(float64); ok && fmt.Sprintf("%.0f", tid) == taskID {
|
||||
return ctx.OutputData(task)
|
||||
}
|
||||
if uuid, ok := task["uuid"].(string); ok && uuid == taskID {
|
||||
return ctx.OutputData(task)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("task %s not found", taskID)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -149,25 +149,22 @@ func buildPageContent(title, content string) string {
|
|||
// Shortcuts returns wiki management shortcuts backed by Git operations.
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
// +list:列出仓库的所有 Wiki 文档页面
|
||||
{
|
||||
Name: "list",
|
||||
Description: "列出仓库的所有 Wiki 文档页面",
|
||||
Description: "List wiki pages in a repository",
|
||||
Run: runList,
|
||||
},
|
||||
// +view:查看 Wiki 文档页面内容
|
||||
{
|
||||
Name: "view",
|
||||
Description: "查看 Wiki 文档页面内容",
|
||||
Description: "View wiki page content",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
},
|
||||
Run: runView,
|
||||
},
|
||||
// +create:新建 Wiki 文档页面
|
||||
{
|
||||
Name: "create",
|
||||
Description: "新建 Wiki 文档页面",
|
||||
Description: "Create a wiki page",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
{Name: "title", Short: "t", Usage: "Page title (used as H1 heading)"},
|
||||
|
|
@ -177,10 +174,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: runCreate,
|
||||
},
|
||||
// +update:更新 Wiki 文档页面
|
||||
{
|
||||
Name: "update",
|
||||
Description: "更新 Wiki 文档页面",
|
||||
Description: "Update a wiki page",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
{Name: "title", Short: "t", Usage: "Page title (used as H1 heading)"},
|
||||
|
|
@ -190,28 +186,25 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: runUpdate,
|
||||
},
|
||||
// +delete:删除 Wiki 文档页面
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "删除 Wiki 文档页面",
|
||||
Description: "Delete a wiki page",
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
},
|
||||
Run: runDelete,
|
||||
},
|
||||
// +export:导出 Wiki 文档
|
||||
{
|
||||
Name: "export",
|
||||
Description: "导出 Wiki 文档",
|
||||
Description: "Export wiki pages",
|
||||
Flags: []common.Flag{
|
||||
{Name: "project-id", Usage: "Project ID (numeric)"},
|
||||
},
|
||||
Run: runExport,
|
||||
},
|
||||
// +import:从文件导入 Wiki 文档
|
||||
{
|
||||
Name: "import",
|
||||
Description: "从文件导入 Wiki 文档",
|
||||
Description: "Import wiki pages from file",
|
||||
Flags: []common.Flag{
|
||||
{Name: "project-id", Usage: "Project ID", Required: true},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -108,29 +108,6 @@ skills/
|
|||
│ └── ci-workflow.md # CI 工作流
|
||||
├── gitlink-pm/ # 项目管理
|
||||
│ └── SKILL.md # PM 操作指南
|
||||
├── gitlink-duplicate-detector/ # 重复 Issue 检测
|
||||
│ ├── SKILL.md # 重复检测与关联收敛指南
|
||||
│ └── examples/
|
||||
│ └── duplicate-detection-workflow.md # 端到端工作流与验证记录
|
||||
├── gitlink-pr-deep-review/ # PR 深度审查
|
||||
│ ├── SKILL.md # 编排 code-review + 设计/一致性推理
|
||||
│ └── examples/
|
||||
│ └── pr-deep-review-workflow.md # 端到端工作流与验证记录
|
||||
├── gitlink-project-bootstrap/ # 项目一键初始化(子任务五)
|
||||
│ ├── SKILL.md # 创建仓库 → README/LICENSE/CI → 初始 Issue/里程碑
|
||||
│ ├── examples/
|
||||
│ │ └── bootstrap-workflow.md # 端到端工作流与验证记录
|
||||
│ └── scripts/ # 三阶段 + 全流程串联 + 骨架生成引擎
|
||||
├── gitlink-multi-repo-coordination/ # 多仓库协同(子任务五)
|
||||
│ ├── SKILL.md # 跨仓库 Issue 追踪 / PR 看板 / Release 协调
|
||||
│ ├── examples/
|
||||
│ │ └── multi-repo-workflow.md # 端到端工作流与验证记录
|
||||
│ └── scripts/ # 三阶段 + 全流程串联 + 看板聚合引擎
|
||||
├── gitlink-contributor-growth/ # 贡献者成长体系(子任务五)
|
||||
│ ├── SKILL.md # 贡献追踪 → 排行/段位 → 自动颁发徽章
|
||||
│ ├── examples/
|
||||
│ │ └── contributor-growth-workflow.md # 端到端工作流与验证记录
|
||||
│ └── scripts/ # 三阶段 + 全流程串联 + 成长分析引擎
|
||||
└── gitlink-workflow/ # AI 自动化工作流
|
||||
└── SKILL.md # 工作流模板(Issue 分类、PR Review、Release Notes)
|
||||
```
|
||||
|
|
@ -161,18 +138,6 @@ skills/
|
|||
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
|
||||
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
|
||||
|
||||
### 端到端自动化流水线 Skills(子任务三 / 五)
|
||||
|
||||
多阶段串联、可直接复现的完整解决方案,每个 Skill 都配有 `scripts/*-full.sh` 全流程脚本与 `.devops/*流水线.yml` GitLink DevOps 流水线,默认 `DRY_RUN=true`。
|
||||
|
||||
| Skill | 场景 | 全流程脚本 |
|
||||
|-------|------|-----------|
|
||||
| **gitlink-community-ops** | 社区运营:Issue 分拣 → 周报 → Release Notes | `community-ops-full.sh` |
|
||||
| **gitlink-quality-gate** | 代码质量看门人:PR 审查 → CI → 门禁决策 → 合并 | `quality-gate-full.sh` |
|
||||
| **gitlink-project-bootstrap** | 项目一键初始化:创建仓库 → README/LICENSE/CI → 初始 Issue/里程碑 | `project-bootstrap-full.sh` |
|
||||
| **gitlink-multi-repo-coordination** | 多仓库协同:跨仓库 Issue 追踪 / PR 看板 / Release 协调(`REPOS` 环境变量) | `multi-repo-full.sh` |
|
||||
| **gitlink-contributor-growth** | 贡献者成长:活动追踪 → 排行/段位 → 自动颁发徽章 | `contributor-growth-full.sh` |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 使用场景
|
||||
|
|
@ -336,11 +301,6 @@ AI 代理可以:
|
|||
- ✅ 自动分类 Issue
|
||||
- ✅ 自动生成 Release Notes
|
||||
- ✅ 自动执行代码审查
|
||||
- ✅ 自动检测并收敛重复 Issue([gitlink-duplicate-detector](gitlink-duplicate-detector/SKILL.md))
|
||||
- ✅ PR 深度审查:实现质量 + 设计/需求一致性 + 跨模块影响([gitlink-pr-deep-review](gitlink-pr-deep-review/SKILL.md))
|
||||
- ✅ 项目一键初始化:输入描述即可创建仓库 + README/LICENSE/CI + 初始 Issue/里程碑([gitlink-project-bootstrap](gitlink-project-bootstrap/SKILL.md))
|
||||
- ✅ 多仓库协同:跨仓库统一 Issue 追踪 / PR 状态看板 / Release 协调发布([gitlink-multi-repo-coordination](gitlink-multi-repo-coordination/SKILL.md))
|
||||
- ✅ 贡献者成长体系:追踪活动 → 生成排行/段位 → 自动颁发徽章([gitlink-contributor-growth](gitlink-contributor-growth/SKILL.md))
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -50,34 +50,23 @@ gitlink-cli pr +files --id <pull_request_id> --format json
|
|||
| `files[].isDeleted` | boolean | 是否删除文件 |
|
||||
| `files[].isRenamed` | boolean | 是否重命名 |
|
||||
|
||||
### 获取 PR Diff(`pr +version-diff`,替代已移除的 `pr +diff`)
|
||||
|
||||
`pr +diff` 已移除(与 `file` 冗余)。先取补丁集版本,再看版本差异:
|
||||
### 获取 PR Diff
|
||||
|
||||
```bash
|
||||
# 1) 取 patchset version_id
|
||||
gitlink-cli pr +versions --id <pull_request_id> --format json
|
||||
# 2) 看该版本的逐行 diff
|
||||
gitlink-cli pr +version-diff --id <pull_request_id> --version-id <vid> [--file <path>] --format json
|
||||
gitlink-cli pr +diff --id <pull_request_id> --format json
|
||||
```
|
||||
|
||||
**`pr +versions` 返回字段:**
|
||||
**返回字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.versions[].id` | int | **patchset 版本 ID(version-diff 用这个)** |
|
||||
|
||||
**`pr +version-diff` 返回字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.file_nums` | int | 文件总数 |
|
||||
| `data.files[].name` | string | 文件名 |
|
||||
| `data.files[].addition` / `deletion` | int | 该文件增/删行数 |
|
||||
| `data.files[].sections[].lines[].content` | string | 行内容(含 `@@ ...` hunk 头) |
|
||||
| `data.files[].sections[].lines[].type` | int | **2=新增, 3=删除, 4=hunk 头(`@@`);据此筛出变更行** |
|
||||
|
||||
> ⚠️ `--file <path>` 过滤不稳定,建议整份取后客户端按 `files[].name` 筛。需读文件全文用 `file +get --ref <head分支> --path <文件>`。
|
||||
| `files_count` | int | 文件总数 |
|
||||
| `total_addition` | int | 总新增行数 |
|
||||
| `total_deletion` | int | 总删除行数 |
|
||||
| `files[].sections[].lines[].leftIdx` | int | 原文件行号 |
|
||||
| `files[].sections[].lines[].rightIdx` | int | 新文件行号 |
|
||||
| `files[].sections[].lines[].type` | int | 1=未变, 2=新增, 3=删除, 4=统计信息 |
|
||||
| `files[].sections[].lines[].content` | string | 行内容 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -111,53 +100,6 @@ gitlink-cli pr +list --state <open|merged|closed> --format json
|
|||
|
||||
---
|
||||
|
||||
## 评审与评论命令(代码审查写入)
|
||||
|
||||
### `pr +review` — 提交评审(首选)
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +review --id <pr_id> --status <common|approved|rejected> --content "<报告>" [--commit <sha>] [--dry-run] --format json
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `-i, --id` | PR 编号 |
|
||||
| `-s, --status` | **评审状态:`common`(普通评论)/ `approved`(批准)/ `rejected`(拒绝,即 Request changes)**,默认 `common` |
|
||||
| `-c, --content` | 评审内容(Markdown,放整体审查报告) |
|
||||
| `-m, --commit` | 可选,挂到具体 commit SHA |
|
||||
| `--dry-run` | **预览不提交(审查必用)** |
|
||||
|
||||
> 状态映射:报告含 Critical → `rejected`;仅 Warning/Suggestion → `common`;无问题 → `approved`。**不要用 GitHub 风格的 `event:"COMMENT"`**,GitLink 用 `--status`。
|
||||
|
||||
### `pr +create-comment` — 行内评审评论
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +create-comment --id <pr_id> --path <文件路径> --line <行号> --body "<意见>" --format json
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `-i, --id` | PR 编号 |
|
||||
| `-p, --path` | 文件路径 |
|
||||
| `-l, --line` | **文件中的行号**(非 diff 补丁位置;注意 import/头注释偏移) |
|
||||
| `-b, --body` | 评论内容 |
|
||||
|
||||
> 行号不确定时**不要发**——并入 `pr +review --content` 总报告,或改用 `pr +comment`。
|
||||
|
||||
### `pr +comment` — 普通评论(不绑行)
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +comment --id <pr_id> --body "<评论>" --format json
|
||||
```
|
||||
|
||||
> 以 journal 形式挂在 PR 下,不绑定文件行。是行内评论失败时的安全 fallback。
|
||||
|
||||
### `pr +reviews` — 列出已有评审(复审时用)
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +reviews --id <pr_id> [--status <common|approved|rejected>] --format json
|
||||
```
|
||||
|
||||
## Issue 相关 API
|
||||
|
||||
### 获取 Issue 列表
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: gitlink-code-review
|
||||
version: 1.2.0
|
||||
description: "当用户需要对 GitLink 上的 Pull Request 做代码审查时触发:获取 PR 变更/Diff、按严重程度输出结构化 Review 意见、并(经确认后)把评审评论提交到 PR。适用于收到 PR Review 请求、需要批量审查 PR、为 PR 自动生成审查报告等场景。"
|
||||
version: 1.0.0
|
||||
description: "智能代码审查:获取 PR 变更、分析代码质量、自动生成 Review 评论与摘要报告。当用户需要审查 Pull Request、检查代码质量或生成审查报告时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
|
|
@ -11,201 +11,311 @@ metadata:
|
|||
# gitlink-code-review(智能代码审查)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有写入/删除操作前,务必先确认用户意图;提交 Review 会通知 PR 相关人,必须先出报告→用户确认→dry-run→再提交。**
|
||||
**CRITICAL — 所有写入/删除操作前,务必先确认用户意图。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **用 Shortcut,不用 Raw API**:提交评审用 `pr +review`,行内评论用 `pr +create-comment`,普通评论用 `pr +comment`。不要用 GitHub 风格的 Raw API(`event:"COMMENT"` / `position` 等字段 GitLink 不支持)。
|
||||
2. **建议优先,确认后提交**:先把结构化审查报告交给用户确认,**禁止未经确认直接提交 Review/评论**。尤其禁止用 `--yes` 绕过确认,除非用户明确说「直接提交/不用确认」。
|
||||
3. **严重程度分级驱动结论**:有 Critical → 评审状态用 `rejected`(Request changes);仅 Suggestion/Positive → `common`;无问题 → `approved`。
|
||||
4. **安全红线零容忍**:硬编码密钥、SQL/命令注入、XSS、路径遍历、不安全反序列化必须标 Critical。
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## 工作流概览
|
||||
|
||||
| 阶段 | 操作 | 命令 |
|
||||
|------|------|------|
|
||||
| ① 获取上下文 | PR 详情 / 变更文件 / Diff | `pr +view` / `pr +files` / `pr +diff` |
|
||||
| ② 逐文件分析 | 按语言检查项审查 | (AI 分析) |
|
||||
| ③ 出审查报告 | 按严重程度分级,**等用户确认** | (无写入) |
|
||||
| ④ dry-run 预览 | 用户确认后,预览评审 | `pr +review --dry-run` |
|
||||
| ⑤ 提交评审 | 用户再次确认后提交 | `pr +review`(去 `--dry-run`) |
|
||||
| ⑥ 行内评论(可选) | 针对具体行追加 | `pr +create-comment --path --line` |
|
||||
本 Skill 提供一套完整的 AI 驱动代码审查工作流,覆盖从获取 PR 变更到生成审查报告的全过程。不需要额外的 CLI Shortcuts——现有 `gitlink-cli` 命令 + AI Agent 的分析能力即可完成。
|
||||
|
||||
| 阶段 | 操作 | AI Agent 角色 |
|
||||
|------|------|--------------|
|
||||
| ① 获取上下文 | 拉取 PR 详情、变更文件、Diff | 执行 CLI 命令采集数据 |
|
||||
| ② 分析代码 | 检查每个文件的变更 | 逐文件审查,标记问题 |
|
||||
| ③ 结构化反馈 | 按严重程度分级输出审查意见 | 生成分级 Review 评论 |
|
||||
| ④ 提交评论 | 发表 Review 到 PR | 通过 API 提交 |
|
||||
| ⑤ 生成报告 | 输出审查摘要 | 生成 Markdown 摘要 |
|
||||
|
||||
---
|
||||
|
||||
## 详细工作流
|
||||
|
||||
### Step 1:获取 PR 上下文
|
||||
### 工作流 1:PR 代码审查
|
||||
|
||||
**场景**:收到 PR Review 请求后,进行完整代码审查。
|
||||
|
||||
#### Step 1:获取 PR 上下文
|
||||
|
||||
```bash
|
||||
# PR 详情
|
||||
# 获取 PR 详情
|
||||
gitlink-cli pr +view --id <pr_id> --format json
|
||||
|
||||
# 变更文件列表(含增删行数、是否新建)
|
||||
# 获取变更文件列表
|
||||
gitlink-cli pr +files --id <pr_id> --format json
|
||||
|
||||
# Diff 内容(先取补丁集版本,再看版本差异;pr +diff 已移除)
|
||||
gitlink-cli pr +versions --id <pr_id> --format json # 拿 version_id
|
||||
gitlink-cli pr +version-diff --id <pr_id> --version-id <vid> --format json # 逐行 diff
|
||||
# 获取 Diff 内容(含变更行号和代码上下文)
|
||||
gitlink-cli pr +diff --id <pr_id> --format json
|
||||
```
|
||||
|
||||
> `--id` 是 PR 编号(web URL 中的编号)。Diff 在 `files[].sections[].lines[]`,`type`:2=新增、3=删除、4=hunk 头(`@@ ...`);按文件分组逐文件分析,大型 PR 分段处理。
|
||||
>
|
||||
> ⚠️ **实测(2026-06-21)**:`pr +diff` 已被移除(与 `file` 冗余),由 `pr +version-diff` 替代。`--file <path>` 过滤不稳定,建议整份取 diff 后客户端按文件名筛。需要读某文件全文时用 `file +get --ref <head分支> --path <文件>`。
|
||||
#### Step 2:逐文件分析
|
||||
|
||||
### Step 2:逐文件分析
|
||||
对每个变更文件,根据文件类型执行针对性检查:
|
||||
|
||||
对每个变更文件,按语言执行针对性检查:
|
||||
**Python 文件检查项:**
|
||||
- 语法与导入:未使用的 import、循环导入、wildcard import
|
||||
- 代码规范:PEP 8 风格偏离、过长行(>88 chars)、命名规范
|
||||
- 安全:硬编码密钥、SQL 注入风险、`eval()`/`exec()` 使用
|
||||
- 性能:不必要的循环、缺少缓存、N+1 查询
|
||||
- 错误处理:裸 `except`、吞异常、缺少 finally
|
||||
|
||||
**Python**:未用/循环 import;PEP8 偏离与过长行;硬编码密钥、SQL 注入、`eval()`/`exec()`;裸 `except`、吞异常;N+1 查询。
|
||||
**JS/TS**:`innerHTML` 直赋、`eval()`;`any` 滥用;未处理 Promise;废弃 API。
|
||||
**Go**:未检查的 error return、panic 滥用;goroutine 泄漏、缺 sync;未关闭 file/conn;导出标识符缺注释。
|
||||
**通用**:硬编码配置/密钥/URL;边界条件缺失;圈复杂度过高;魔法数字;DRY 违反;注释过时;测试覆盖不足。
|
||||
**JavaScript/TypeScript 文件检查项:**
|
||||
- 安全:`innerHTML` 直接赋值、`eval()` 使用
|
||||
- 类型安全:`any` 滥用、缺失类型定义
|
||||
- 性能:不必要的 re-render、大对象深拷贝
|
||||
- 异步:未处理的 Promise、缺少 error boundary
|
||||
- 依赖:已废弃 API 使用
|
||||
|
||||
### Step 3:出审查报告(建议,不写入)
|
||||
**Go 文件检查项:**
|
||||
- 错误处理:未检查的 error return、panic 滥用
|
||||
- 并发:goroutine 泄漏、缺少 sync 保护
|
||||
- 资源管理:未关闭的 file/conn、defer 使用
|
||||
- 命名:导出标识符缺少注释、变量 shadowing
|
||||
|
||||
按严重程度分级输出,**此阶段不执行任何写入**:
|
||||
**通用检查项:**
|
||||
- 硬编码的配置值、密钥、URL
|
||||
- 缺少或错误的边界条件检查
|
||||
- 过于复杂的函数(圈复杂度高)
|
||||
- 魔法数字(未命名的常量)
|
||||
- 重复代码(DRY 违反)
|
||||
- 缺少或过时的注释
|
||||
- 测试覆盖不足
|
||||
|
||||
#### Step 3:生成结构化审查结果
|
||||
|
||||
按以下 Severity 分级输出:
|
||||
|
||||
```markdown
|
||||
## PR #<id> 代码审查报告
|
||||
|
||||
### 🔴 Critical(必须修改)
|
||||
- <问题> — <文件>:<行号>
|
||||
- <问题描述> — <文件>:<行号>
|
||||
> <修改建议>
|
||||
|
||||
### 🟡 Warning(建议修改) / 🔵 Suggestion(可选优化) / ✅ Positive(值得肯定)
|
||||
- ...
|
||||
### 🟡 Warning(建议修改)
|
||||
- <问题描述> — <文件>:<行号>
|
||||
> <修改建议>
|
||||
|
||||
### 总体结论
|
||||
<评审状态建议:rejected / common / approved;是否阻塞合并>
|
||||
### 🔵 Suggestion(可选优化)
|
||||
- <问题描述> — <文件>:<行号>
|
||||
> <修改建议>
|
||||
|
||||
### ✅ Positive(值得肯定)
|
||||
- <做得好的地方>
|
||||
```
|
||||
|
||||
**等待用户确认**:用户没说「确认/提交」前停在 Step 3。**禁止用 `--yes` 自行推进。**
|
||||
|
||||
### Step 4:dry-run 预览(用户确认报告后)
|
||||
|
||||
根据报告的严重程度选择评审状态,dry-run 预览(不实际提交):
|
||||
|
||||
| 报告含 Critical | 评审状态 | 含义 |
|
||||
|-----------------|----------|------|
|
||||
| 是 | `rejected` | Request changes,阻塞合并 |
|
||||
| 否,仅 Warning/Suggestion | `common` | 普通评审评论 |
|
||||
| 无问题 | `approved` | 批准合并 |
|
||||
#### Step 4:提交 Review 评论
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +review --id <pr_id> \
|
||||
--status <common|approved|rejected> \
|
||||
--content "<把审查报告 Markdown 作为 content>" \
|
||||
--dry-run --format json
|
||||
# 方式 1:提交整体 Review
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{
|
||||
"body": "## 审查结果\n\n### 🔴 Critical\n...\n\n### 🟡 Warning\n...\n\n总体评价:...",
|
||||
"event": "COMMENT"
|
||||
}'
|
||||
|
||||
# 方式 2:在特定行添加内联评论(逐条提交)
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{
|
||||
"body": "这里存在安全风险:用户输入未经转义直接拼接到 SQL 查询中,存在注入风险。建议使用参数化查询。",
|
||||
"event": "COMMENT",
|
||||
"commit_id": "<commit_sha>",
|
||||
"path": "src/query.py",
|
||||
"position": 42
|
||||
}'
|
||||
```
|
||||
|
||||
> `--content` 放整体审查报告(Markdown)。`--commit <sha>` 可选,把评审挂到具体 commit。核对 dry-run 输出无误。
|
||||
> **注意:** `event` 参数支持 `COMMENT`(普通评论)和 `APPROVE`(批准)。对于需要修改的问题,使用 `COMMENT`。
|
||||
|
||||
### Step 5:提交评审(用户确认 dry-run 后)
|
||||
#### Step 5:生成审查摘要
|
||||
|
||||
去掉 `--dry-run` 正式提交:
|
||||
审查完成后,输出 Markdown 摘要供用户查阅:
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +review --id <pr_id> \
|
||||
--status <common|approved|rejected> \
|
||||
--content "<审查报告>" \
|
||||
--format json
|
||||
```
|
||||
```markdown
|
||||
## 📋 审查摘要 — PR #<id> <title>
|
||||
|
||||
### Step 6:行内评论(可选,针对具体行)
|
||||
| 指标 | 数据 |
|
||||
|------|------|
|
||||
| 审查文件数 | <n> |
|
||||
| 变更行数 | +<add> / -<del> |
|
||||
| Critical 问题 | <n> |
|
||||
| Warning | <n> |
|
||||
| Suggestion | <n> |
|
||||
|
||||
需要把某条意见精准挂在某一行时,用 `pr +create-comment`:
|
||||
### 主要发现
|
||||
1. **[Critical]** <最严重的问题>
|
||||
2. **[Warning]** <次要问题>
|
||||
3. **[Suggestion]** <优化建议>
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +create-comment --id <pr_id> \
|
||||
--path <文件路径> --line <行号> \
|
||||
--body "<针对该行的意见>" --format json
|
||||
```
|
||||
### 总体评价
|
||||
<整体评估:代码质量、审查通过建议>
|
||||
|
||||
> ⚠️ `--line` 是**文件中的行号**。Diff 输出给的可能是补丁内位置,需换算为文件真实行号(注意文件头部 import/license 偏移)。**若行号不确定,不要盲目发**——把该意见并入 Step 5 的 `--content` 总报告,或改用 `pr +comment`(普通评论,不挂行)。
|
||||
>
|
||||
> ⚠️ **实测(2026-06-21)**:`pr +create-comment --line` 会返回 `ok:true`,但返回的 `line_code` 为 `null`,**评论未真正锚定到该行**(仅挂在文件级)。因此优先用 `pr +review --content` 提交总报告;行内评论不可靠时改用 `pr +comment`。
|
||||
|
||||
```bash
|
||||
# 普通评论(挂在 PR 下,不绑定行;行号不确定时的安全 fallback)
|
||||
gitlink-cli pr +comment --id <pr_id> --body "<评论>" --format json
|
||||
---
|
||||
*由 gitlink-code-review Skill 自动生成*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 评审状态与严重程度的映射
|
||||
### 工作流 2:仓库代码健康度扫描
|
||||
|
||||
| 报告内容 | `--status` | 合并建议 |
|
||||
|---------|-----------|---------|
|
||||
| 含任何 Critical | `rejected` | 阻塞,修复后复审 |
|
||||
| 仅 Warning/Suggestion | `common` | 可合并,建议跟进 |
|
||||
| 全部 Positive / 无问题 | `approved` | 可合并 |
|
||||
|
||||
## 安全红线(必须标 Critical)
|
||||
|
||||
- 硬编码的密钥 / Token / 密码 / 数据库连接串
|
||||
- SQL / NoSQL 注入(用户输入直接拼接进查询)
|
||||
- 命令注入(shell 命令拼接用户输入)
|
||||
- 路径遍历(用户输入直接用于文件路径)
|
||||
- XSS(未转义的用户输入直接渲染,如 `innerHTML`)
|
||||
- 不安全的反序列化
|
||||
|
||||
## 代码审查最佳实践
|
||||
|
||||
1. **先大局后细节**:先理解 PR 目的与整体变更范围,再逐文件审查。
|
||||
2. **关注行为而非风格**:风格问题交给 linter/formatter。
|
||||
3. **提供可操作的建议**:不只指出问题,给出具体修改方案。
|
||||
4. **肯定好的代码**:清晰命名、完善测试、良好设计给予正面反馈。
|
||||
5. **控制评论量**:最严重的 3–5 个问题比 20 个小问题更有价值。
|
||||
|
||||
## 命令速查
|
||||
**场景**:对仓库整体代码质量进行评估,不依赖 PR。
|
||||
|
||||
```bash
|
||||
# 获取上下文
|
||||
gitlink-cli pr +view --id <pr_id> --format json
|
||||
gitlink-cli pr +files --id <pr_id> --format json
|
||||
gitlink-cli pr +versions --id <pr_id> --format json # 拿 version_id
|
||||
gitlink-cli pr +version-diff --id <pr_id> --version-id <vid> --format json # 逐行 diff(替代已移除的 pr +diff)
|
||||
# 1. 获取仓库信息
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 提交评审(首选)
|
||||
gitlink-cli pr +review --id <pr_id> --status <common|approved|rejected> --content "<报告>" [--commit <sha>] [--dry-run]
|
||||
# 2. 获取仓库文件列表(遍历关键目录)
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master'
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=tests&ref=master'
|
||||
|
||||
# 行内评论(绑定文件+行;行号不确定时勿用)
|
||||
gitlink-cli pr +create-comment --id <pr_id> --path <path> --line <n> --body "<意见>"
|
||||
# 3. 获取关键文件内容
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/README.md
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/.gitignore
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/.eslintrc.js # 或类似配置
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/package.json # 或 go.mod, Cargo.toml
|
||||
|
||||
# 普通评论(不绑行;安全 fallback)
|
||||
gitlink-cli pr +comment --id <pr_id> --body "<评论>"
|
||||
|
||||
# 查看已有评审(复审时用)
|
||||
gitlink-cli pr +reviews --id <pr_id> --format json
|
||||
# 4. 获取语言统计和贡献者
|
||||
gitlink-cli api GET /:owner/:repo/languages
|
||||
gitlink-cli api GET /:owner/:repo/contributors
|
||||
```
|
||||
|
||||
**健康度检查清单:**
|
||||
|
||||
| 检查项 | 标准 | 评分依据 |
|
||||
|--------|------|----------|
|
||||
| 文档完整性 | 有 README、CONTRIBUTING、CHANGELOG | 文件是否存在、内容质量 |
|
||||
| 许可证 | 有 LICENSE 文件 | 是否存在、是否合规 |
|
||||
| CI 配置 | 有 CI 配置(.github/workflows, Jenkinsfile 等) | 文件是否存在 |
|
||||
| 代码规范 | 有 linter 配置 | eslint/prettier/ruff/pylint 等 |
|
||||
| 测试覆盖 | 有 test 目录或测试文件 | 测试文件比例 |
|
||||
| 依赖管理 | 依赖文件完整且无已知漏洞 | package-lock/go.sum/poetry.lock |
|
||||
| Issue 健康度 | Issue 有分类标签、响应及时 | 通过 Issue 列表分析 |
|
||||
|
||||
**输出格式:**
|
||||
|
||||
```markdown
|
||||
## 🏥 仓库健康度报告 — <owner>/<repo>
|
||||
|
||||
### 总体评分:<⭐x/5>
|
||||
|
||||
| 维度 | 状态 | 评分 | 建议 |
|
||||
|------|:----:|:----:|------|
|
||||
| 📖 文档 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
| 📜 许可证 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
| 🔧 CI/CD | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
| 🎨 代码规范 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
| 🧪 测试覆盖 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
| 📦 依赖安全 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
| 🐛 Issue 管理 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
|
||||
|
||||
### 关键发现
|
||||
1. <最需要改进的问题>
|
||||
2. <次要问题>
|
||||
3. <做得好的方面>
|
||||
|
||||
### 改进路线图
|
||||
- **紧急(本周):** ...
|
||||
- **短期(本月):** ...
|
||||
- **长期(本季度):** ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 工作流 3:批量 Issue Triage + 自动分配
|
||||
|
||||
**场景**:对新 Issue 进行自动分类、标签分配和责任人推荐。
|
||||
|
||||
```bash
|
||||
# 1. 获取未标记的 Issue
|
||||
gitlink-cli issue +list --state open --format json
|
||||
|
||||
# 2. 逐个分析 Issue 内容
|
||||
gitlink-cli issue +view --id <issue_id> --format json
|
||||
|
||||
# 3. 根据内容智能分类
|
||||
# 分析标题和描述后,通过 Raw API 打标签
|
||||
gitlink-cli api POST /:owner/:repo/issues/:id --body '{
|
||||
"issue_tag_ids": [<tag_id>],
|
||||
"done_ratio": 0,
|
||||
"subject": "<原始标题>",
|
||||
"description": "<原始描述>"
|
||||
}'
|
||||
```
|
||||
|
||||
**分类规则参考:**
|
||||
|
||||
| Issue 关键词 | 推荐标签 | 优先级 |
|
||||
|-------------|----------|:------:|
|
||||
| bug, 错误, 失败, crash, 崩溃 | bug | 🔴 High |
|
||||
| feature, 新增, 建议, 希望 | enhancement | 🔵 Low |
|
||||
| 安全, 漏洞, 权限, 泄露 | security | 🔴 High |
|
||||
| 性能, 慢, 卡顿, 优化 | performance | 🟡 Medium |
|
||||
| 文档, README, 注释 | documentation | 🔵 Low |
|
||||
| question, 如何, 怎么, 请问 | question | 🟡 Medium |
|
||||
| 测试, test, 覆盖率 | testing | 🔵 Low |
|
||||
|
||||
---
|
||||
|
||||
## Raw API 参考
|
||||
|
||||
评审相关操作**优先用上述 Shortcut**。Shortcut 未覆盖时才用 Raw API,且字段需符合 GitLink(**不是** GitHub 的 `event:"COMMENT"`/`position`):
|
||||
代码审查相关的 GitLink API 端点:
|
||||
|
||||
```bash
|
||||
# 列出已有评审
|
||||
gitlink-cli api GET /:owner/:repo/pulls/:id/reviews --format json
|
||||
# 获取 PR 详情
|
||||
gitlink-cli api GET /:owner/:repo/pulls/:id --format json
|
||||
|
||||
# 获取 PR 变更文件列表
|
||||
gitlink-cli api GET /:owner/:repo/pulls/:id/files --format json
|
||||
|
||||
# 获取 PR Diff
|
||||
gitlink-cli api GET /:owner/:repo/pulls/:id/diff --format json
|
||||
|
||||
# 提交 PR Review
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"...","event":"COMMENT"}'
|
||||
|
||||
# 获取仓库文件列表
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<branch>'
|
||||
|
||||
# 获取仓库语言统计
|
||||
gitlink-cli api GET /:owner/:repo/languages --format json
|
||||
|
||||
# 获取贡献者列表
|
||||
gitlink-cli api GET /:owner/:repo/contributors --format json
|
||||
|
||||
# 获取仓库动态
|
||||
gitlink-cli api GET /:owner/:repo/activity --format json
|
||||
```
|
||||
|
||||
> 字段说明详见 [`REFERENCE.md`](REFERENCE.md)。
|
||||
## 代码审查最佳实践
|
||||
|
||||
## 红线与常见错误
|
||||
### 审查原则
|
||||
|
||||
- ❌ **用 GitHub 风格 Raw API 提交评审**(`event:"COMMENT"`、`position`、`commit_id`+`path`)——GitLink 用 `pr +review --status` 与 `pr +create-comment --path --line`。
|
||||
- ❌ **未经用户确认就提交 Review/评论**——必须 报告→确认→dry-run→提交。
|
||||
- ❌ **用 `--yes` 绕过确认门**——除非用户明确要求「直接提交」。
|
||||
- ❌ **行号不确定却发 `+create-comment --line`**——会挂错行或被拒;改并入总报告或用 `+comment`。
|
||||
- ❌ **有 Critical 却用 `--status common/approved`**——Critical 必须配 `rejected`。
|
||||
- ❌ **用 `gh` 操作 GitLink**——只用 `gitlink-cli`。
|
||||
- 所有命令加 `--format json` 便于解析;输出为 `{ok, data, meta}` envelope。
|
||||
1. **先大局后细节**:先理解 PR 的目的和整体变更范围,再逐文件审查
|
||||
2. **关注行为,而非风格**:自动化工具(linter/formatter)能处理的风格问题优先交给工具
|
||||
3. **提供可操作的建议**:不只是指出问题,要给出具体的修改方案
|
||||
4. **肯定好的代码**:发现好的设计、清晰的命名、完善的测试时给予正面反馈
|
||||
5. **控制评论量**:避免信息过载——最严重的 3-5 个问题比 20 个小问题更有价值
|
||||
|
||||
### 安全红线
|
||||
|
||||
以下问题必须标记为 **Critical**,不得忽略:
|
||||
|
||||
- 硬编码的密钥 / Token / 密码
|
||||
- SQL / NoSQL 注入漏洞
|
||||
- 命令注入(shell 命令拼接)
|
||||
- 路径遍历(用户输入直接用于文件路径)
|
||||
- 不安全的反序列化
|
||||
- XSS(未转义的用户输入直接渲染)
|
||||
|
||||
### 输出规范
|
||||
|
||||
- 始终使用 `--format json` 获取结构化数据
|
||||
- 审查报告输出为 **Markdown 格式**,便于直接粘贴到 PR 评论
|
||||
- 涉及文件/行号时使用精准引用,方便定位
|
||||
- 批量操作前使用 `--dry-run` 预检
|
||||
|
||||
## 注意事项
|
||||
|
||||
- Review/评论提交后会通知 PR 所有相关人,内容请专业、可操作。
|
||||
- 大型 PR 的 diff 可能非常大,按文件分段处理,避免一次性塞满上下文。
|
||||
- `pr +diff` / `+files` 接口有频率限制,避免短时间内重复请求。
|
||||
- 对 draft PR,提示用户先标记为 Ready for Review 再审查。
|
||||
- 审查范围限于 PR 本身(代码审查);仓库整体健康度、Issue 分拣等场景见各自专用 Skill,不在本 Skill 范围。
|
||||
- PR Review 提交后会通知所有关注该 PR 的参与者,评论内容请保持专业
|
||||
- `pr +diff` 输出可能很大(大型 PR),Agent 应分段处理
|
||||
- API 的 PR files 和 diff 接口有频率限制,避免短时间内重复请求
|
||||
- 对于 draft PR(草稿),应提示用户先将其标记为 Ready for Review
|
||||
|
|
|
|||
|
|
@ -113,50 +113,17 @@ gitlink-cli pr +diff --id 42 --format json
|
|||
- 有类型注解,代码可读性好
|
||||
```
|
||||
|
||||
### Step 5:dry-run 预览(用户确认报告后)
|
||||
|
||||
报告含 2 个 Critical,评审状态取 `rejected`(Request changes)。先 dry-run 预览,**不实际提交**:
|
||||
### Step 5:提交 Review
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +review --id 42 \
|
||||
--status rejected \
|
||||
--content "## PR #42 代码审查报告
|
||||
|
||||
### 🔴 Critical
|
||||
1. **JWT Secret 硬编码** — src/config.py:15。建议 os.getenv(\"JWT_SECRET\")。
|
||||
2. **SQL 注入风险** — src/auth/login.py:42。建议参数化查询。
|
||||
|
||||
### 🟡 Warning
|
||||
1. **密码明文存储** — 建议使用 bcrypt 哈希处理。
|
||||
|
||||
### 总体评价
|
||||
代码整体结构清晰,测试覆盖良好。建议修复 Critical 问题后合并。" \
|
||||
--dry-run --format json
|
||||
# 提交整体 Review 评论
|
||||
gitlink-cli api POST /Gitlink/forgeplus/pulls/42/reviews --body '{
|
||||
"body": "## PR #42 代码审查报告\n\n### 🔴 Critical\n\n1. **JWT Secret 硬编码** — `src/config.py:15`\n JWT_SECRET 硬编码在源码中。建议使用 `os.getenv(\"JWT_SECRET\")`。\n\n2. **SQL 注入风险** — `src/auth/login.py:42`\n 直接拼接用户输入到 SQL 查询。建议使用参数化查询。\n\n### 🟡 Warning\n\n1. **密码明文存储** — 建议使用 bcrypt 哈希处理。\n\n### 总体评价\n\n代码整体结构清晰,测试覆盖良好。建议修复 Critical 问题后合并。",
|
||||
"event": "COMMENT"
|
||||
}'
|
||||
```
|
||||
|
||||
→ 核对预览无误,请用户二次确认。
|
||||
|
||||
### Step 6:提交评审(用户确认 dry-run 后)
|
||||
|
||||
去掉 `--dry-run` 正式提交:
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +review --id 42 --status rejected --content "<同上审查报告>" --format json
|
||||
```
|
||||
|
||||
### Step 7:行内评论(可选,针对具体行)
|
||||
|
||||
把 Critical 意见精准挂到对应行(行号取文件真实行号,注意 import/头注释偏移;不确定则并入上面的总报告):
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +create-comment --id 42 --path src/config.py --line 15 \
|
||||
--body "Critical: JWT_SECRET 硬编码,存在泄露风险。改用 os.getenv(\"JWT_SECRET\")。" --format json
|
||||
|
||||
gitlink-cli pr +create-comment --id 42 --path src/auth/login.py --line 42 \
|
||||
--body "Critical: SQL 注入风险,用户输入直接拼接。改用参数化查询。" --format json
|
||||
```
|
||||
|
||||
### Step 8:输出审查摘要
|
||||
### Step 6:输出审查摘要
|
||||
|
||||
```markdown
|
||||
## 📋 审查摘要 — PR #42 feat: add user authentication module
|
||||
|
|
@ -192,12 +159,6 @@ gitlink-cli pr +files --id <id> --format json
|
|||
# 获取 Diff
|
||||
gitlink-cli pr +diff --id <id> --format json
|
||||
|
||||
# 提交评审(首选;Critical→rejected / 仅建议→common / 无问题→approved)
|
||||
gitlink-cli pr +review --id <id> --status <common|approved|rejected> --content "<审查报告>" [--dry-run]
|
||||
|
||||
# 行内评论(绑定文件+行;行号不确定时勿用,改用 +comment 或并入 --content)
|
||||
gitlink-cli pr +create-comment --id <id> --path <path> --line <n> --body "<意见>"
|
||||
|
||||
# 普通评论(不绑行)
|
||||
gitlink-cli pr +comment --id <id> --body "<评论>"
|
||||
# 提交 Review
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"...","event":"COMMENT"}'
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,202 +0,0 @@
|
|||
---
|
||||
name: gitlink-community-ops
|
||||
version: 1.0.0
|
||||
description: "社区运营自动化:Issue 自动分拣 → 周报生成 → Release Notes 发布。串联 issue-triage、project-health、release-auto 三阶段,实现社区运营全自动化。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli", "jq"]
|
||||
cliHelp: "gitlink-cli issue --help / gitlink-cli pr --help / gitlink-cli release --help"
|
||||
---
|
||||
|
||||
# gitlink-community-ops(社区运营自动化)
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证、全局参数和 API 注意事项。
|
||||
>
|
||||
> **CRITICAL — 所有 GitLink 操作必须使用 `gitlink-cli`,禁止使用 `gh`(GitHub CLI)操作 GitLink 资源。**
|
||||
|
||||
## 说明
|
||||
|
||||
本 Skill 串联 **三个已有 Skill** 形成一个完整的社区运营自动化流水线:
|
||||
|
||||
1. **Issue 自动分拣** — 新 Issue 自动分类、打标签、分配责任人、添加引导评论
|
||||
2. **社区周报生成** — 采集 Issue/PR/Commit/Milestone 数据,生成带评分的健康度报告
|
||||
3. **自动发布 Release Notes** — 分析提交历史,生成结构化 Release Notes 并创建发布
|
||||
|
||||
**与子任务二的区别:** 子任务二交付的是单一步骤的 Skill(如只做 Issue 分拣),而本工作流将多个 Skill 串联成完整的端到端解决方案。
|
||||
|
||||
---
|
||||
|
||||
## 三阶段工作流总览
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[新 Issue / 周期触发] --> B[Phase 1: Issue 分拣]
|
||||
B --> C[Phase 2: 周报生成]
|
||||
C --> D[Phase 3: Release Notes]
|
||||
|
||||
subgraph B[Phase 1: Issue Triage]
|
||||
B1[采集未分类 Issue] --> B2[关键词权重分析]
|
||||
B2 --> B3[自动打标签]
|
||||
B3 --> B4[添加引导评论]
|
||||
end
|
||||
|
||||
subgraph C[Phase 2: 周报]
|
||||
C1[采集 Issue/PR/Commit/Milestone] --> C2[计算健康度评分]
|
||||
C2 --> C3[生成趋势报告]
|
||||
end
|
||||
|
||||
subgraph D[Phase 3: Release]
|
||||
D1[获取最新 Tag] --> D2[分析提交历史]
|
||||
D2 --> D3[语义化版本推荐]
|
||||
D3 --> D4[生成 Release Notes]
|
||||
D4 --> D5[创建 Release]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1:Issue 自动分拣
|
||||
|
||||
**参考 Skill:** [`../gitlink-issue-triage/SKILL.md`](../gitlink-issue-triage/SKILL.md)
|
||||
|
||||
### 步骤
|
||||
|
||||
```bash
|
||||
# 1. 获取所有打开的 Issue
|
||||
gitlink-cli issue +list --owner <owner> --repo <repo> --state open --format json
|
||||
|
||||
# 2. 逐个查看 Issue 详情(提取标题和描述用于分析)
|
||||
gitlink-cli issue +view --owner <owner> --repo <repo> --number <id> --format json
|
||||
|
||||
# 3. 关键词权重分析(AI 执行)
|
||||
# 参见 gitlink-issue-triage 的"关键词权重分类系统"
|
||||
|
||||
# 4. 获取可用标签
|
||||
gitlink-cli label +list --owner <owner> --repo <repo>
|
||||
|
||||
# 5. 打标签(dry-run 优先)
|
||||
gitlink-cli issue +update --owner <owner> --repo <repo> --number <id> --labels <label_id>
|
||||
|
||||
# 6. 添加引导评论
|
||||
gitlink-cli issue +comment --owner <owner> --repo <repo> --number <id> --body "<comment>"
|
||||
```
|
||||
|
||||
### 关键词权重分类矩阵
|
||||
|
||||
| 类别 | 关键词 | 权重分 | 建议标签 |
|
||||
|------|--------|--------|---------|
|
||||
| Bug | bug(10)、错误(8)、失败(7)、异常(6)、crash(10)、报错(5) | ≥15 | bug |
|
||||
| 功能需求 | 建议(8)、希望(7)、需要(5)、feature(8)、支持(4)、新增(5) | ≥12 | enhancement |
|
||||
| 文档 | 文档(10)、README(8)、拼写(5)、缺少(4)、帮助(3) | ≥10 | documentation |
|
||||
| 安全 | 漏洞(10)、安全(8)、权限(7)、泄露(8)、注入(6) | ≥12 | security |
|
||||
| 性能 | 慢(8)、卡顿(7)、性能(6)、优化(5)、performance(8) | ≥10 | performance |
|
||||
| 问题咨询 | 请问(5)、怎么(5)、如何(4)、help(3)、? (2) | ≥6 | question |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2:社区周报生成
|
||||
|
||||
**参考 Skill:** [`../gitlink-project-health/SKILL.md`](../gitlink-project-health/SKILL.md)
|
||||
|
||||
### 数据采集
|
||||
|
||||
```bash
|
||||
# Issue 数据
|
||||
gitlink-cli issue +list --owner <owner> --repo <repo> --state all --format json
|
||||
gitlink-cli issue +list --owner <owner> --repo <repo> --state open
|
||||
gitlink-cli issue +list --owner <owner> --repo <repo> --state closed --limit 50
|
||||
|
||||
# PR 数据
|
||||
gitlink-cli pr +list --owner <owner> --repo <repo> --state all --format json
|
||||
gitlink-cli pr +list --owner <owner> --repo <repo> --state merged --limit 50
|
||||
|
||||
# 贡献者数据
|
||||
gitlink-cli commit +list --owner <owner> --repo <repo> --limit 50
|
||||
|
||||
# 里程碑
|
||||
gitlink-cli milestone +list --owner <owner> --repo <repo>
|
||||
```
|
||||
|
||||
### 评分维度
|
||||
|
||||
| 维度 | 计算方法 | 权重 |
|
||||
|------|----------|------|
|
||||
| Issue 健康度 | 关闭率 + 平均响应时间 | 30% |
|
||||
| PR 健康度 | 合并率 + 平均合并时间 | 30% |
|
||||
| 贡献者活跃度 | 贡献者数 + 提交频率 | 25% |
|
||||
| 里程碑进度 | 完成度 + 逾期风险 | 15% |
|
||||
|
||||
### 综合评分
|
||||
|
||||
```
|
||||
综合得分 = Issue得分 × 30% + PR得分 × 30% + 活跃度得分 × 25% + 里程碑得分 × 15%
|
||||
|
||||
9-10 → 🟢 优秀 7-8 → 🟢 良好
|
||||
5-6 → 🟡 一般 3-4 → 🔴 堪忧
|
||||
0-2 → 🔴 危险
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3:自动 Release Notes
|
||||
|
||||
**参考 Skill:** [`../gitlink-release-auto/SKILL.md`](../gitlink-release-auto/SKILL.md)
|
||||
|
||||
### 步骤
|
||||
|
||||
```bash
|
||||
# 1. 获取最新 Release 确定基线版本
|
||||
gitlink-cli release +list --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 2. 获取从上一版本到当前的提交历史
|
||||
git log <last_tag>..HEAD --format="%H|%s|%an|%ad" --date=short --stat
|
||||
|
||||
# 3. AI 分析提交分类(按 Conventional Commits 规范)
|
||||
# feat: → ✨ 新功能 fix: → 🐛 Bug 修复
|
||||
# docs: → 📝 文档 refactor: → ♻️ 重构
|
||||
# perf: → ⚡ 性能优化 test: → ✅ 测试
|
||||
# chore: → 🔧 工程 revert: → ⏪ 回退
|
||||
|
||||
# 4. 语义化版本推荐
|
||||
# BREAKING CHANGE → 主版本 +1
|
||||
# feat → 次版本 +1
|
||||
# fix/docs/refactor → 修订号 +1
|
||||
|
||||
# 5. 创建 Release(dry-run 模式优先)
|
||||
gitlink-cli release +create --owner <owner> --repo <repo> \
|
||||
--tag <new_version> --name "<new_version>" \
|
||||
--body "<generated_release_notes>" --target master
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 可复现脚本
|
||||
|
||||
本 Skill 提供可直接执行的 Bash 脚本,位于 `scripts/` 目录:
|
||||
|
||||
| 脚本 | 功能 | 使用方式 |
|
||||
|------|------|----------|
|
||||
| `01-issue-triage.sh` | Phase 1 Issue 分拣 | `bash 01-issue-triage.sh <owner> <repo>` |
|
||||
| `02-weekly-health-report.sh` | Phase 2 周报生成 | `bash 02-weekly-health-report.sh <owner> <repo>` |
|
||||
| `03-auto-release-notes.sh` | Phase 3 Release Notes | `bash 03-auto-release-notes.sh <owner> <repo>` |
|
||||
| `community-ops-full.sh` | 全流程串联 | `bash community-ops-full.sh <owner> <repo>` |
|
||||
|
||||
**所有脚本默认 dry-run 模式**(不会实际修改数据),确认无误后通过环境变量切换:
|
||||
|
||||
```bash
|
||||
# Dry-run(默认,安全)
|
||||
DRY_RUN=true bash scripts/community-ops-full.sh z2_cc gitlink-cli
|
||||
|
||||
# 实际执行
|
||||
DRY_RUN=false bash scripts/community-ops-full.sh z2_cc gitlink-cli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **dry-run 优先**:所有写操作(`issue +update`、`issue +comment`、`release +create`)默认不应实际执行
|
||||
2. **Token 有效**:运行前确认 `gitlink-cli auth status` 返回正常
|
||||
3. **关键词准确率**:AI 的分类结果建议人工复核,特别是边缘情况
|
||||
4. **版本号冲突**:如果最新 tag 不是 release 版本,需人工确认基线
|
||||
5. **输出文件**:所有 JSON 数据保存到 `scripts/_output/` 目录供审计
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
# 社区运营自动化工作流 — 端到端示例
|
||||
|
||||
**场景**:项目维护者希望自动处理社区的日常运营工作:新 Issue 自动分类、每周生成社区报告、版本发布时自动生成 Release Notes。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- `gitlink-cli` 已安装并登录(`gitlink-cli auth status`)
|
||||
- 目标仓库已确认(示例使用 `z2_cc/gitlink-cli`)
|
||||
- `jq` 已安装(用于 JSON 解析)
|
||||
|
||||
## 工作流步骤
|
||||
|
||||
### Step 1:Issue 自动分拣
|
||||
|
||||
```bash
|
||||
# 1.1 获取所有打开的 Issue
|
||||
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open --format json
|
||||
```
|
||||
|
||||
**输出示例:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": [
|
||||
{"number": 42, "subject": "登录页面报错:500 Internal Server Error", "state": 1},
|
||||
{"number": 43, "subject": "建议增加导出功能", "state": 1},
|
||||
{"number": 44, "subject": "README 中缺少安装说明", "state": 1}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 1.2 分析 Issue 内容并进行关键词权重评分(AI 驱动)
|
||||
# Issue #42: "报错"(5) + "500"(0) = 5分 → Bug → 标签: bug
|
||||
# Issue #43: "建议"(8) + "增加"(5) = 13分 → 功能需求 → 标签: enhancement
|
||||
# Issue #44: "文档"(10) + "缺少"(4) = 14分 → 文档 → 标签: documentation
|
||||
|
||||
# 1.3 获取可用标签列表
|
||||
gitlink-cli label +list --owner z2_cc --repo gitlink-cli
|
||||
```
|
||||
|
||||
```bash
|
||||
# 1.4 为 Issue #42 添加标签和分类评论(dry-run 模式)
|
||||
DRY_RUN=true
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "[DRY-RUN] 将为 Issue #42 添加标签: bug"
|
||||
echo "[DRY-RUN] 将为 Issue #42 添加评论:自动分类结果"
|
||||
fi
|
||||
```
|
||||
|
||||
**Expected:** 所有未分类的 Issue 被分析并建议标签。dry-run 模式下不实际修改。
|
||||
|
||||
### Step 2:社区周报生成
|
||||
|
||||
```bash
|
||||
# 2.1 采集周报数据
|
||||
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state all --format json
|
||||
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state closed --limit 50 --format json
|
||||
gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state all --format json
|
||||
gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state merged --limit 50 --format json
|
||||
gitlink-cli commit +list --owner z2_cc --repo gitlink-cli --limit 50
|
||||
gitlink-cli milestone +list --owner z2_cc --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
**输出示例(Issue 数据):**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"total_count": 16,
|
||||
"issues": [
|
||||
{"number": 1, "subject": "...", "state": 2, "created_at": "2026-05-20", "closed_at": "2026-05-21"},
|
||||
{"number": 2, "subject": "...", "state": 1, "created_at": "2026-06-01"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**AI 分析逻辑:**
|
||||
```
|
||||
总 Issue = 16(打开 2 + 关闭 14)
|
||||
关闭率 = 14/16 = 87.5% → 🟢 优秀
|
||||
平均响应时间 = 计算 closed_at - created_at 的平均天数
|
||||
近 30 天净变化 = 新增 - 关闭
|
||||
```
|
||||
|
||||
### Step 3:自动 Release Notes
|
||||
|
||||
```bash
|
||||
# 3.1 获取最新 Release 确定基线版本
|
||||
gitlink-cli release +list --owner z2_cc --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
**输出示例:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": [
|
||||
{"tag_name": "v0.2.0", "name": "v0.2.0", "created_at": "2026-06-17"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 3.2 获取提交历史
|
||||
git log v0.2.0..HEAD --format="%H|%s|%an|%ad" --date=short
|
||||
```
|
||||
|
||||
**输出示例:**
|
||||
```
|
||||
abc1234|feat: 新增 Webhook 投递监控|z2_cc|2026-06-20
|
||||
abc1235|fix: 修复 Wiki 侧边栏残留|z2_cc|2026-06-22
|
||||
abc1236|docs: 更新 CLI 帮助文档|z2_cc|2026-06-24
|
||||
```
|
||||
|
||||
**AI 分析结果:**
|
||||
```
|
||||
共 3 次提交:feat 1、fix 1、docs 1
|
||||
涉及 5 个文件,+120/-30 行
|
||||
推荐版本:v0.3.0(有 feat,无 breaking)
|
||||
```
|
||||
|
||||
```bash
|
||||
# 3.3 创建 Release(dry-run 模式)
|
||||
echo "[DRY-RUN] 将创建 Release: v0.3.0"
|
||||
echo "[DRY-RUN] Release Notes 已生成到 _output/release-notes-v0.3.0.md"
|
||||
```
|
||||
|
||||
## 完整命令速览
|
||||
|
||||
```bash
|
||||
# Phase 1: Issue Triage
|
||||
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open --format json
|
||||
gitlink-cli label +list --owner z2_cc --repo gitlink-cli
|
||||
|
||||
# Phase 2: Weekly Report
|
||||
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state all --format json
|
||||
gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state all --format json
|
||||
gitlink-cli commit +list --owner z2_cc --repo gitlink-cli --limit 50
|
||||
|
||||
# Phase 3: Release Notes
|
||||
gitlink-cli release +list --owner z2_cc --repo gitlink-cli --format json
|
||||
git log <last_tag>..HEAD --format="%H|%s|%an|%ad" --date=short
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有写操作默认 dry-run,确认后通过 `DRY_RUN=false` 实际执行
|
||||
- 确保在仓库目录下运行(或显式指定 `--owner` 和 `--repo`)
|
||||
- 周报的数据范围建议明确(如"最近 7 天")
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
#!/bin/bash
|
||||
# ================================================================
|
||||
# Phase 1: Issue 自动分拣
|
||||
# 功能:采集未分类 Issue → 关键词权重分析 → 打标签 → 加评论
|
||||
# 使用:bash 01-issue-triage.sh <owner> <repo>
|
||||
# 环境变量:DRY_RUN=true|false (默认 true)
|
||||
# ================================================================
|
||||
set -e
|
||||
|
||||
# 使用本地编译的 gitlink-cli(支持完整命令)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LOCAL_CLI="$(cd "$SCRIPT_DIR/../../.." && pwd)/gitlink-cli.exe"
|
||||
if [ -f "$LOCAL_CLI" ]; then
|
||||
CLI="$LOCAL_CLI"
|
||||
else
|
||||
CLI="gitlink-cli"
|
||||
fi
|
||||
|
||||
OWNER=${1:?"Usage: $0 <owner> <repo>"}
|
||||
REPO=${2}
|
||||
DRY_RUN=${DRY_RUN:-true}
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUTPUT_DIR="$SCRIPT_DIR/_output"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
echo "[========================================]"
|
||||
echo "[ Phase 1: Issue 自动分拣 ]"
|
||||
echo "[ Target: $OWNER/$REPO ]"
|
||||
echo "[ DRY_RUN: $DRY_RUN ]"
|
||||
echo "[========================================]"
|
||||
|
||||
# Step 1: 获取所有打开的 Issue
|
||||
echo "[INFO] 获取所有打开的 Issue..."
|
||||
ISSUES=$($CLI issue +list --owner "$OWNER" --repo "$REPO" --state open --format json 2>&1) || {
|
||||
echo "[ERROR] 获取 Issue 列表失败: $ISSUES"
|
||||
exit 1
|
||||
}
|
||||
echo "$ISSUES" > "$OUTPUT_DIR/issues-open.json"
|
||||
|
||||
TOTAL=$(echo "$ISSUES" | node -e "
|
||||
let s='';
|
||||
process.stdin.on('data',d=>s+=d).on('end',()=>{
|
||||
const d=JSON.parse(s).data||[];
|
||||
const items=Array.isArray(d)?d:(d.issues||[]);
|
||||
process.stdout.write(String(items.length));
|
||||
});
|
||||
" 2>/dev/null || echo "0")
|
||||
|
||||
echo "[INFO] 发现 $TOTAL 个待处理的 Issue"
|
||||
if [ "$TOTAL" -eq 0 ]; then
|
||||
echo "[INFO] 没有待处理的 Issue,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Step 2: 获取可用标签
|
||||
echo "[INFO] 获取可用标签列表..."
|
||||
LABELS=$($CLI label +list --owner "$OWNER" --repo "$REPO" --format json 2>&1) || {
|
||||
echo "[WARN] 获取标签列表失败"
|
||||
LABELS='{"data":[]}'
|
||||
}
|
||||
echo "$LABELS" > "$OUTPUT_DIR/labels.json"
|
||||
|
||||
# Step 3: 逐 Issue 分析
|
||||
echo "[INFO] 开始分析每个 Issue..."
|
||||
for i in $(seq 0 $((TOTAL - 1))); do
|
||||
ISSUE_DATA=$(echo "$ISSUES" | node -e "
|
||||
let s='';
|
||||
process.stdin.on('data',d=>s+=d).on('end',()=>{
|
||||
const d=JSON.parse(s).data||[];
|
||||
const items=Array.isArray(d)?d:(d.issues||[]);
|
||||
process.stdout.write(JSON.stringify(items[$i]));
|
||||
});
|
||||
")
|
||||
|
||||
NUMBER=$(echo "$ISSUE_DATA" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s);process.stdout.write(String(d.number||d.id||''))})")
|
||||
SUBJECT=$(echo "$ISSUE_DATA" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s);process.stdout.write(String(d.subject||d.title||''))})")
|
||||
|
||||
if [ -z "$NUMBER" ]; then
|
||||
echo "[WARN] Issue #$i: 无法解析编号,跳过"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[INFO] --- Issue #$NUMBER: $SUBJECT ---"
|
||||
|
||||
# 获取 Issue 详情
|
||||
DETAIL=$($CLI issue +view --owner "$OWNER" --repo "$REPO" --number "$NUMBER" --format json 2>&1) || {
|
||||
echo "[WARN] 获取 Issue #$NUMBER 详情失败,使用标题分析"
|
||||
DETAIL="{\"data\":{\"subject\":\"$SUBJECT\",\"description\":\"\"}}"
|
||||
}
|
||||
echo "$DETAIL" > "$OUTPUT_DIR/issue-$NUMBER-detail.json"
|
||||
|
||||
# ---- Step 4: 关键词权重分析(自动分类) ----
|
||||
CATEGORY="needs-triage"
|
||||
LABEL_ID=""
|
||||
LABEL_NAME=""
|
||||
COMMENT_TEXT=""
|
||||
|
||||
# 简单关键词匹配
|
||||
case "$SUBJECT" in
|
||||
*[Bb]ug*|*错误*|*失败*|*异常*|*crash*|*报错*)
|
||||
CATEGORY="bug"; LABEL_ID="325573"; LABEL_NAME="缺陷"
|
||||
COMMENT_TEXT="AI 自动分类结果:🐛 Bug(缺陷)"
|
||||
;;
|
||||
*建议*|*希望*|*feature*|*新增*|*支持*|*Shortcut*|*命令*|*兼容*|*封装*|*API*|*子任务*|*Webhook*|*Wiki*|*Snippet*)
|
||||
CATEGORY="enhancement"; LABEL_ID="325574"; LABEL_NAME="功能"
|
||||
COMMENT_TEXT="AI 自动分类结果:✨ 功能需求"
|
||||
;;
|
||||
*文档*|*README*|*拼写*|*帮助*|*doc*|*说明*)
|
||||
CATEGORY="documentation"; LABEL_ID="325580"; LABEL_NAME="文档"
|
||||
COMMENT_TEXT="AI 自动分类结果:📝 文档"
|
||||
;;
|
||||
*demo*|*测试*|*test*)
|
||||
CATEGORY="test"; LABEL_ID="325581"; LABEL_NAME="测试"
|
||||
COMMENT_TEXT="AI 自动分类结果:🧪 测试"
|
||||
;;
|
||||
esac
|
||||
echo "[INFO] 分类结果: $CATEGORY (标签: $LABEL_NAME)"
|
||||
|
||||
# ---- Step 5: 打标签 + 加评论 ----
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "[DRY-RUN] 将为 Issue #$NUMBER 添加标签: $LABEL_NAME ($LABEL_ID)"
|
||||
echo "[DRY-RUN] 将添加引导评论: $COMMENT_TEXT"
|
||||
else
|
||||
echo "[INFO] 为 Issue #$NUMBER 打标签和添加评论..."
|
||||
if [ -n "$LABEL_ID" ]; then
|
||||
$CLI issue +batch-update --owner "$OWNER" --repo "$REPO" --numbers "$NUMBER" --label "$LABEL_ID" 2>&1 || echo "[WARN] 打标签失败"
|
||||
fi
|
||||
if [ -n "$COMMENT_TEXT" ]; then
|
||||
$CLI issue +comment --owner "$OWNER" --repo "$REPO" --number "$NUMBER" --body "$COMMENT_TEXT" 2>&1 || echo "[WARN] 添加评论失败"
|
||||
fi
|
||||
echo "[INFO] Issue #$NUMBER 处理完成"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "[========================================]"
|
||||
echo "[ Phase 1 完成 ]"
|
||||
echo "[ Issue 数据保存至: $OUTPUT_DIR/ ]"
|
||||
echo "[========================================]"
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
#!/bin/bash
|
||||
# ================================================================
|
||||
# Phase 2: 社区周报生成
|
||||
# 功能:采集 Issue/PR/Commit/Milestone 数据 → 计算健康度评分 → 生成报告
|
||||
# 使用:bash 02-weekly-health-report.sh <owner> <repo>
|
||||
# ================================================================
|
||||
set -e
|
||||
|
||||
# 自动检测并使用本地编译的 gitlink-cli
|
||||
source "$(cd "$(dirname "$0")" && pwd)/../../gitlink-shared/scripts/detect-cli.sh"
|
||||
|
||||
OWNER=${1:?"Usage: $0 <owner> <repo>"}
|
||||
REPO=${2}
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUTPUT_DIR="$SCRIPT_DIR/_output"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
echo "[========================================]"
|
||||
echo "[ Phase 2: 社区周报生成 ]"
|
||||
echo "[ Target: $OWNER/$REPO ]"
|
||||
echo "[========================================]"
|
||||
|
||||
# ---- Step 1: 采集 Issue 数据 ----
|
||||
echo "[INFO] 采集 Issue 数据..."
|
||||
gitlink-cli issue +list --owner "$OWNER" --repo "$REPO" --state all --format json > "$OUTPUT_DIR/issues-all.json" 2>&1 || echo "[WARN] 采集 Issue all 失败"
|
||||
gitlink-cli issue +list --owner "$OWNER" --repo "$REPO" --state open --format json > "$OUTPUT_DIR/issues-open.json" 2>&1 || echo "[WARN] 采集 Issue open 失败"
|
||||
gitlink-cli issue +list --owner "$OWNER" --repo "$REPO" --state closed --limit 50 --format json > "$OUTPUT_DIR/issues-closed.json" 2>&1 || echo "[WARN] 采集 Issue closed 失败"
|
||||
|
||||
# ---- Step 2: 采集 PR 数据 ----
|
||||
echo "[INFO] 采集 PR 数据..."
|
||||
gitlink-cli pr +list --owner "$OWNER" --repo "$REPO" --state all --format json > "$OUTPUT_DIR/prs-all.json" 2>&1 || echo "[WARN] 采集 PR all 失败"
|
||||
gitlink-cli pr +list --owner "$OWNER" --repo "$REPO" --state open --format json > "$OUTPUT_DIR/prs-open.json" 2>&1 || echo "[WARN] 采集 PR open 失败"
|
||||
gitlink-cli pr +list --owner "$OWNER" --repo "$REPO" --state merged --limit 50 --format json > "$OUTPUT_DIR/prs-merged.json" 2>&1 || echo "[WARN] 采集 PR merged 失败"
|
||||
|
||||
# ---- Step 3: 采集贡献者数据 ----
|
||||
echo "[INFO] 采集贡献者数据..."
|
||||
gitlink-cli commit +list --owner "$OWNER" --repo "$REPO" --limit 50 --format json > "$OUTPUT_DIR/commits-50.json" 2>&1 || gitlink-cli repo +contributors --owner "$OWNER" --repo "$REPO" --format json > "$OUTPUT_DIR/contributors.json" 2>&1 || echo "[WARN] 采集贡献者数据失败"
|
||||
gitlink-cli milestone +list --owner "$OWNER" --repo "$REPO" --format json > "$OUTPUT_DIR/milestones.json" 2>&1 || echo "[WARN] 采集 milestone 失败"
|
||||
gitlink-cli repo +info --owner "$OWNER" --repo "$REPO" --format json > "$OUTPUT_DIR/repo-info.json" 2>&1 || echo "[WARN] 采集 repo info 失败"
|
||||
|
||||
# ---- Step 4: 数据分析 ----
|
||||
echo "[INFO] 运行数据分析引擎..."
|
||||
node "$SCRIPT_DIR/health-analyze.js" "$OUTPUT_DIR"
|
||||
|
||||
echo ""
|
||||
echo "[========================================]"
|
||||
echo "[ Phase 2 完成 ]"
|
||||
echo "[ 数据保存至: $OUTPUT_DIR/ ]"
|
||||
echo "[========================================]"
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
#!/bin/bash
|
||||
# ================================================================
|
||||
# Phase 3: 自动 Release Notes
|
||||
# 功能:获取最新 tag → 分析 commit → 推荐版本号 → 生成 Release Notes
|
||||
# 使用:bash 03-auto-release-notes.sh <owner> <repo>
|
||||
# 环境变量:DRY_RUN=true|false (默认 true)
|
||||
# ================================================================
|
||||
set -e
|
||||
|
||||
OWNER=${1:?"Usage: $0 <owner> <repo>"}
|
||||
REPO=${2}
|
||||
DRY_RUN=${DRY_RUN:-true}
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUTPUT_DIR="$SCRIPT_DIR/_output"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
echo "[========================================]"
|
||||
echo "[ Phase 3: 自动 Release Notes ]"
|
||||
echo "[ Target: $OWNER/$REPO ]"
|
||||
echo "[ DRY_RUN: $DRY_RUN ]"
|
||||
echo "[========================================]"
|
||||
|
||||
# ---- Step 1: 获取最新 Release ----
|
||||
echo "[INFO] 获取最新 Release..."
|
||||
RELEASE_DATA=$(gitlink-cli release +list --owner "$OWNER" --repo "$REPO" --format json 2>&1) || {
|
||||
echo "[WARN] 获取 Release 列表失败: $RELEASE_DATA"
|
||||
echo "[INFO] 使用默认基线 v0.0.0"
|
||||
LAST_TAG="v0.0.0"
|
||||
}
|
||||
echo "$RELEASE_DATA" > "$OUTPUT_DIR/releases.json"
|
||||
|
||||
# 提取最新 tag
|
||||
LAST_TAG=$(echo "$RELEASE_DATA" | node -e "
|
||||
let s='';
|
||||
process.stdin.on('data',d=>s+=d).on('end',()=>{
|
||||
try {
|
||||
const d=JSON.parse(s).data||[];
|
||||
const items=Array.isArray(d)?d:(d.releases||d);
|
||||
if(items.length>0) process.stdout.write(String(items[0].tag_name||'v0.0.0'));
|
||||
else process.stdout.write('v0.0.0');
|
||||
} catch(e) { process.stdout.write('v0.0.0'); }
|
||||
});
|
||||
" 2>/dev/null || echo "v0.0.0")
|
||||
|
||||
echo "[INFO] 最新 tag: $LAST_TAG"
|
||||
|
||||
# ---- Step 2: 获取提交历史 ----
|
||||
echo "[INFO] 获取提交历史 (基线: $LAST_TAG)..."
|
||||
# 尝试从 git 获取(需要在 repo 目录下)
|
||||
if git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
echo "[INFO] 在 git 仓库中,尝试获取 tags..."
|
||||
git fetch --tags 2>&1 | tail -3 || true
|
||||
|
||||
# 检查 tag 是否存在
|
||||
if git rev-parse -q --verify "$LAST_TAG" > /dev/null 2>&1; then
|
||||
echo "[INFO] 找到本地 tag: $LAST_TAG"
|
||||
git log "$LAST_TAG"..HEAD --format="%H|%s|%an|%ad" --date=short > "$OUTPUT_DIR/commits-since-$LAST_TAG.log" 2>&1
|
||||
git log "$LAST_TAG"..HEAD --stat --oneline > "$OUTPUT_DIR/stats-since-$LAST_TAG.log" 2>&1
|
||||
echo "[INFO] 提交历史已保存"
|
||||
else
|
||||
echo "[WARN] 本地未找到 tag '$LAST_TAG',尝试从远程获取..."
|
||||
# 尝试从 remote 获取指定 tag
|
||||
git fetch origin tag "$LAST_TAG" 2>&1 | tail -3 || true
|
||||
if git rev-parse -q --verify "$LAST_TAG" > /dev/null 2>&1; then
|
||||
git log "$LAST_TAG"..HEAD --format="%H|%s|%an|%ad" --date=short > "$OUTPUT_DIR/commits-since-$LAST_TAG.log" 2>&1
|
||||
git log "$LAST_TAG"..HEAD --stat --oneline > "$OUTPUT_DIR/stats-since-$LAST_TAG.log" 2>&1
|
||||
else
|
||||
echo "[WARN] 远程也无此 tag,获取全部提交历史"
|
||||
git log --all --format="%H|%s|%an|%ad" --date=short -100 > "$OUTPUT_DIR/commits-since-$LAST_TAG.log" 2>&1
|
||||
git log --all --oneline --stat -100 > "$OUTPUT_DIR/stats-since-$LAST_TAG.log" 2>&1
|
||||
echo "[INFO] 已获取最近 100 条提交(未按 tag 过滤)"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "[WARN] 不在 git 仓库中,通过 CLI 获取提交历史..."
|
||||
source "$SCRIPT_DIR/../../gitlink-shared/scripts/detect-cli.sh" 2>/dev/null || true
|
||||
gitlink-cli commit +list --owner "$OWNER" --repo "$REPO" --limit 100 --format json > "$OUTPUT_DIR/commits-api.json" 2>&1 || {
|
||||
echo "[ERROR] 通过 API 获取提交历史也失败"
|
||||
echo "[INFO] Release Notes 生成跳过"
|
||||
touch "$OUTPUT_DIR/commits-api.json"
|
||||
}
|
||||
fi
|
||||
|
||||
# ---- Step 3: 运行 Release 分析引擎 ----
|
||||
echo "[INFO] 运行 Release Notes 分析引擎..."
|
||||
node "$SCRIPT_DIR/release-analyze.js" "$OUTPUT_DIR" "$LAST_TAG"
|
||||
|
||||
echo ""
|
||||
echo "[========================================]"
|
||||
echo "[ Phase 3 完成 ]"
|
||||
echo "[========================================]"
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>release-notes-v0.1.0</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 60px;
|
||||
}
|
||||
.header-card {
|
||||
background: linear-gradient(135deg, #1e293b, #334155);
|
||||
color: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px 40px;
|
||||
margin: 24px 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header-card h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.header-card .meta {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px 28px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #f1f5f9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
.card h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
.card p {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card p:last-child { margin-bottom: 0; }
|
||||
.card code {
|
||||
background: #f1f5f9;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
color: #0f172a;
|
||||
}
|
||||
.card strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
.score-section {
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.score-ring {
|
||||
display: inline-block;
|
||||
}
|
||||
.score-label {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
.cell-ok { color: #16a34a; font-weight: 500; }
|
||||
.cell-fail { color: #dc2626; }
|
||||
.cell-green { color: #16a34a; }
|
||||
.cell-yellow { color: #ca8a04; }
|
||||
.cell-red { color: #dc2626; }
|
||||
.cell-warn { color: #ea580c; }
|
||||
ol {
|
||||
padding-left: 20px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
ol li {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
padding: 4px 0;
|
||||
list-style-position: outside;
|
||||
}
|
||||
li::marker {
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.code-block {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 16px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
line-height: 1.5;
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.page { padding: 0 12px 40px; }
|
||||
.header-card { padding: 20px; flex-direction: column; text-align: center; }
|
||||
.header-card h1 { font-size: 18px; }
|
||||
.card { padding: 16px; }
|
||||
}
|
||||
@media print {
|
||||
body { background: white; }
|
||||
.header-card { break-inside: avoid; }
|
||||
.card { break-inside: avoid; box-shadow: none; border: 1px solid #e2e8f0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="header-card">
|
||||
<div>
|
||||
<h1>release-notes-v0.1.0</h1>
|
||||
<div class="meta">生成时间:2026/6/30 10:49:09</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="card report-content">
|
||||
<h2>v0.1.0 (2026-06-30)</h2>
|
||||
|
||||
<h3>📊 版本概览</h3>
|
||||
<ol><li>基于上一版本:v0.0.0</li>
|
||||
<li>包含 <strong>100</strong> 次提交</li>
|
||||
<li>涉及 <strong>252</strong> 个文件</li>
|
||||
<li>新增 <strong>22410</strong> 行 / 删除 <strong>5510</strong> 行</li>
|
||||
<li>贡献者:<strong>7</strong> 人</li>
|
||||
<p></ol></p>
|
||||
<h3>✨ 新功能(22)</h3>
|
||||
|
||||
<ol><li>feat: PPT 添加 HTML 报告超链接跳转(@赵昌)</li>
|
||||
<li>feat: 整合 Demo 终端输出到 PPT(@赵昌)</li>
|
||||
<li>feat: PPT 添加备注稿和演示跳转按钮(@赵昌)</li>
|
||||
<li>feat: 综合演示网页 + 增强 PPT(17页)(@赵昌)</li>
|
||||
<li>feat: 汇报 PPT 和演示脚本(@赵昌)</li>
|
||||
<li>feat: 子任务三 & 子任务四 — 端到端工作流与科研辅助模块(@赵昌)</li>
|
||||
<li>feat(skills): 完善 issue-triage 与 code-review Skill,补充 gitlink-shared 已知坑(@s2_cc)</li>
|
||||
<li>feat(skills): 新增 gitlink-pr-deep-review PR 深度审查 Skill(@z2_cc)</li>
|
||||
<li>feat(skills): 新增 gitlink-duplicate-detector 重复 Issue 检测 Skill(@kaka)</li>
|
||||
<li>feat(compliance-skill): 新增敏感信息扫描工作流与报告整合(@刘焱)</li>
|
||||
<li>feat(output): 单对象详情渲染为可读的层级表格,不再回退 JSON(@刘焱)</li>
|
||||
<li>feat(interactive): 默认使用 table 格式输出,提升可读性(@刘焱)</li>
|
||||
<li>feat(interactive): add scrollable viewport for long command output(@刘焱)</li>
|
||||
<li>feat(output): improve table readability — truncate values, limit columns, summarize nested maps(@刘焱)</li>
|
||||
<li>feat(output): render nested list data as table instead of falling back to JSON(@刘焱)</li>
|
||||
<li>feat(interactive): add REPL with command palette, form, and cobra entry(@刘焱)</li>
|
||||
<li>feat(interactive): add command executor with stdout capture and flag parsing(@刘焱)</li>
|
||||
<li>feat(shortcuts): export GetAllShortcuts and GetDescriptions for interactive mode(@刘焱)</li>
|
||||
<li>feat: add three new skills - issue triage, project health, newcomer guide(@赵昌)</li>
|
||||
<li>feat: add wiki and snippet skills, update webhook skill with new commands(@赵昌)</li>
|
||||
<li>feat: task-view supports both numeric ID and UUID lookup(@赵昌)</li>
|
||||
<li>feat: add webhook +failed and +task-view commands(@赵昌)</li>
|
||||
<p></ol></p>
|
||||
<p><strong>占比:</strong> 22%</p>
|
||||
|
||||
<h3>🐛 Bug 修复(15)</h3>
|
||||
|
||||
<ol><li>fix: 修正成员信息并去除学校水印(@赵昌)</li>
|
||||
<li>fix: 优化技术债务(@赵昌)</li>
|
||||
<li>fix(newcomer-skill): 修复 label 命令 Bug,加入状态过滤与幂等去重(@刘焱)</li>
|
||||
<li>fix(compliance-skill): 修复 DB 连接串正则与跨文件一致性(C1/I1/I2/I3/I4/M2)(@刘焱)</li>
|
||||
<li>fix(collaborator): 修复 change-role/batch-role 请求体(@刘焱)</li>
|
||||
<li>fix(branch): 修复保护分支接口路径与列表输出(@刘焱)</li>
|
||||
<li>fix(output): 表头取所有行字段并集并固定核心列顺序(@刘焱)</li>
|
||||
<li>fix(interactive): 命令面板选中带可选参数的命令也弹出表单(@刘焱)</li>
|
||||
<li>fix(file): 移除 +tree 无效的 --recursive flag(@刘焱)</li>
|
||||
<li>fix(file): 修复文件管理命令的端点和参数问题(@刘焱)</li>
|
||||
<li>fix(output): 本地构造的 map slice 也渲染为表格,不再显示 [N items](@刘焱)</li>
|
||||
<li>fix(tag): tag +view 改用列表过滤,GitLink 无单标签 API 端点(@刘焱)</li>
|
||||
<li>fix(interactive): initialize RuntimeContext via NewRuntimeContext to prevent nil-Client panic(@刘焱)</li>
|
||||
<li>fix(interactive): trigger command palette immediately on '/' keypress(@刘焱)</li>
|
||||
<li>fix(interactive): correct Esc behavior and direct-exec for optional flags(@刘焱)</li>
|
||||
<p></ol></p>
|
||||
<p><strong>占比:</strong> 15%</p>
|
||||
|
||||
<h3>📝 文档(51)</h3>
|
||||
|
||||
<ol><li>docs: 三份报告统一转为 Word 格式(@赵昌)</li>
|
||||
<li>docs: 更新报告覆盖全部成员贡献(@赵昌)</li>
|
||||
<li>docs: 更新工作日志(文档清理 & 技术债务优化)(@赵昌)</li>
|
||||
<li>docs: 更新工作日志技术债务状态(@赵昌)</li>
|
||||
<li>docs: 统一新需求构思报告和变更影响分析及测试报告(@赵昌)</li>
|
||||
<li>docs: 添加子任务三/四工作日志(@赵昌)</li>
|
||||
<li>docs: 添加子任务三/四交接文档(@赵昌)</li>
|
||||
<li>docs: enrich demo page with detailed AI analysis and scoring(@赵昌)</li>
|
||||
<li>docs: update demo page with AI analysis outputs and scoring(@赵昌)</li>
|
||||
<li>docs(demo): 演示页新增许可证合规检查与新人引导交互流程(@刘焱)</li>
|
||||
<li>docs: add interactive demo page for skills showcase(@赵昌)</li>
|
||||
<li>docs: add release notes workflow example(@赵昌)</li>
|
||||
<li>docs(plans): 项目级 Skill 集成实现计划(@刘焱)</li>
|
||||
<li>docs(specs): 项目级 Skill 集成设计(Claude Code 兼容)(@刘焱)</li>
|
||||
<li>docs(skills): 真实验证回填(@刘焱)</li>
|
||||
<li>docs(skills-readme): 概览表补 compliance 与 newcomer-guide(@刘焱)</li>
|
||||
<li>docs(newcomer-skill): 示例加入去重检查与批量场景(@刘焱)</li>
|
||||
<li>docs(newcomer-skill): 新增 REFERENCE(命令/字段/标签变体/幂等规则)(@刘焱)</li>
|
||||
<li>docs(compliance-skill): 新增端到端合规检查示例(@刘焱)</li>
|
||||
<li>docs(compliance-skill): REFERENCE 增补敏感信息扫描章节(@刘焱)</li>
|
||||
<li>docs(plans): GitLink Skills 丰富与完善实现计划(@刘焱)</li>
|
||||
<li>docs(specs): GitLink Skills 丰富与完善设计规格(@刘焱)</li>
|
||||
<li>docs: add comprehensive Chinese comments and mark bug fixes (P1-P4)(@s2_cc)</li>
|
||||
<li>docs(repo): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(pr): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(issue): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(attachment): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(dataset): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(template): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(ci): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(search): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(org): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(util): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(sshkey): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(label): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(commit): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(file): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(milestone): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(release): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(collaborator): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(tag): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(branch): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(wiki): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(user): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(snippet): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(webhook): 子命令描述和注释中文化(@刘焱)</li>
|
||||
<li>docs(shortcuts): 给每个命令组添加中文注释和中文描述(@刘焱)</li>
|
||||
<li>docs: add interactive REPL implementation plan(@刘焱)</li>
|
||||
<li>docs: add interactive REPL command palette design spec(@刘焱)</li>
|
||||
<li>docs: add workflow examples for issue-triage, project-health, newcomer-guide(@赵昌)</li>
|
||||
<li>docs: update report - remove hook-runner, update webhook section(@赵昌)</li>
|
||||
<p></ol></p>
|
||||
<p><strong>占比:</strong> 51%</p>
|
||||
|
||||
<h3>✅ 测试(1)</h3>
|
||||
|
||||
<ol><li>test(interactive): smoke-test all registered commands execute without panic(@刘焱)</li>
|
||||
<p></ol></p>
|
||||
<p><strong>占比:</strong> 1%</p>
|
||||
|
||||
<h3>🔧 工程配置(3)</h3>
|
||||
|
||||
<ol><li>chore: 删除多余的 demo 文件(@赵昌)</li>
|
||||
<li>chore: 清理多余的场景文档目录(@赵昌)</li>
|
||||
<li>chore: add bubbletea, bubbles, huh dependencies(@刘焱)</li>
|
||||
<p></ol></p>
|
||||
<p><strong>占比:</strong> 3%</p>
|
||||
|
||||
<h3>📦 其他变更(8)</h3>
|
||||
|
||||
<ol><li>refine: 优化演示页面布局和结果展示(@赵昌)</li>
|
||||
<li>Merge remote-tracking branch 'origin/master'(@s2_cc)</li>
|
||||
<li>Merge branch 'z2cc'(@s2_cc)</li>
|
||||
<li>enhance: enrich skill outputs with data analysis and scoring logic(@赵昌)</li>
|
||||
<li>remove probe(@刘焱)</li>
|
||||
<li>probe(@liuyan688)</li>
|
||||
<li>添加Python示例(@wqer)</li>
|
||||
<li>merge: integrate hook-runner into webhook commands(@赵昌)</li>
|
||||
<p></ol></p>
|
||||
<p><strong>占比:</strong> 8%</p>
|
||||
|
||||
<h3>📈 统计汇总</h3>
|
||||
<div class="table-wrap"><table><thead><tr><th>类别</th><th>数量</th><th>占比</th></tr></thead><tbody><tr><td>✨ 新功能</td><td>22</td><td>22%</td></tr><tr><td>🐛 Bug 修复</td><td>15</td><td>15%</td></tr><tr><td>📝 文档</td><td>51</td><td>51%</td></tr><tr><td>♻️ 代码重构</td><td>0</td><td>0%</td></tr><tr><td>⚡ 性能优化</td><td>0</td><td>0%</td></tr><tr><td class="cell-ok">✅ 测试</td><td>1</td><td>1%</td></tr><tr><td>🔧 工程配置</td><td>3</td><td>3%</td></tr><tr><td>⏪ 回退</td><td>0</td><td>0%</td></tr><tr><td>📦 其他变更</td><td>8</td><td>8%</td></tr></tbody></table></div>
|
||||
<p>---</p>
|
||||
<p>*自动生成时间:2026-06-30 02:49:09*</p>
|
||||
|
||||
</div>
|
||||
<div class="footer">
|
||||
Powered by gitlink-cli · 数据来源于 GitLink 平台
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>社区健康度周报</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 60px;
|
||||
}
|
||||
.header-card {
|
||||
background: linear-gradient(135deg, #1e293b, #334155);
|
||||
color: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px 40px;
|
||||
margin: 24px 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header-card h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.header-card .meta {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px 28px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #f1f5f9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
.card h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
.card p {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card p:last-child { margin-bottom: 0; }
|
||||
.card code {
|
||||
background: #f1f5f9;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
color: #0f172a;
|
||||
}
|
||||
.card strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
.score-section {
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.score-ring {
|
||||
display: inline-block;
|
||||
}
|
||||
.score-label {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
.cell-ok { color: #16a34a; font-weight: 500; }
|
||||
.cell-fail { color: #dc2626; }
|
||||
.cell-green { color: #16a34a; }
|
||||
.cell-yellow { color: #ca8a04; }
|
||||
.cell-red { color: #dc2626; }
|
||||
.cell-warn { color: #ea580c; }
|
||||
ol {
|
||||
padding-left: 20px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
ol li {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
padding: 4px 0;
|
||||
list-style-position: outside;
|
||||
}
|
||||
li::marker {
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.code-block {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 16px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
line-height: 1.5;
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.page { padding: 0 12px 40px; }
|
||||
.header-card { padding: 20px; flex-direction: column; text-align: center; }
|
||||
.header-card h1 { font-size: 18px; }
|
||||
.card { padding: 16px; }
|
||||
}
|
||||
@media print {
|
||||
body { background: white; }
|
||||
.header-card { break-inside: avoid; }
|
||||
.card { break-inside: avoid; box-shadow: none; border: 1px solid #e2e8f0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="header-card">
|
||||
<div>
|
||||
<h1>社区健康度周报</h1>
|
||||
<div class="meta">生成时间:2026/6/30 10:49:09</div>
|
||||
</div>
|
||||
|
||||
<div class="score-section">
|
||||
<div class="score-ring">
|
||||
<svg viewBox="0 0 120 120" width="140" height="140">
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#e2e8f0" stroke-width="8"/>
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#ef4444" stroke-width="8"
|
||||
stroke-dasharray="326.7256359733385" stroke-dashoffset="212.37166338267005"
|
||||
transform="rotate(-90, 60, 60)" stroke-linecap="round"
|
||||
style="transition: stroke-dashoffset 1.5s ease-in-out;"/>
|
||||
<text x="60" y="50" text-anchor="middle" font-size="32" font-weight="700" fill="#1e293b">3.5</text>
|
||||
<text x="60" y="72" text-anchor="middle" font-size="13" fill="#64748b">/ 10</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="score-label">🔴 堪忧</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card report-content">
|
||||
<h1>📊 社区健康度周报</h1>
|
||||
|
||||
<p><strong>报告日期:</strong> 2026-06-30</p>
|
||||
<p><strong>项目:</strong> z2_cc/gitlink-cli</p>
|
||||
<p><strong>综合评分:</strong> 3.5/10 🔴 堪忧</p>
|
||||
|
||||
<h2>一、本周概览</h2>
|
||||
<div class="table-wrap"><table><thead><tr><th>指标</th><th>数据</th><th>状态</th></tr></thead><tbody><tr><td>Issue 总数</td><td>21</td><td>—</td></tr><tr><td>打开中</td><td>17</td><td class="cell-red">🔴</td></tr><tr><td>已关闭</td><td>4</td><td class="cell-red">🔴</td></tr><tr><td>关闭率</td><td>19%</td><td class="cell-red">🔴 偏低</td></tr><tr><td>PR 总数</td><td>2</td><td>—</td></tr><tr><td>打开中</td><td>0</td><td>—</td></tr><tr><td>已合并</td><td>0</td><td class="cell-red">🔴</td></tr><tr><td>合并率</td><td>0%</td><td class="cell-red">🔴 偏低</td></tr><tr><td>贡献者数</td><td>1 人</td><td class="cell-red">🔴</td></tr><tr><td>平均响应时间</td><td>21.5 天</td><td class="cell-yellow">🟡 正常</td></tr></tbody></table></div>
|
||||
<h2>二、Issue 分析(评分:3/10)</h2>
|
||||
<ol><li>总 Issue 数:21(打开 17 / 关闭 4)</li>
|
||||
<li>关闭率:19% ⚠️ 偏低</li>
|
||||
<li>平均响应时间:21.5 天</li>
|
||||
<li><strong>分析:</strong> Issue 关闭率偏低,建议定期进行 Issue 清理和回复。</li>
|
||||
<p></ol></p>
|
||||
<h2>三、PR 分析(评分:3/10)</h2>
|
||||
<ol><li>总 PR 数:2(打开 0 / 合并 0)</li>
|
||||
<li>合并率:0% ⚠️ 偏低</li>
|
||||
<li><strong>分析:</strong> PR 合并率偏低,建议加快代码审查和合并速度。</li>
|
||||
<p></ol></p>
|
||||
<h2>四、贡献者活跃度(评分:3/10)</h2>
|
||||
<ol><li>近 50 次提交贡献者:1 人</li>
|
||||
<li><strong>分析:</strong> 单人维护 🔴</li>
|
||||
<p></ol></p>
|
||||
<h2>五、改进建议</h2>
|
||||
<ol><li data-index="1">Issue 关闭率偏低,建议定期清理积压 Issue</li>
|
||||
<li data-index="2">PR 合并率偏低,建议加快 PR 处理速度</li>
|
||||
<li data-index="3">贡献者较少,建议标记 good-first-issue 吸引新贡献者</li>
|
||||
<p></ol></p>
|
||||
<p>---</p>
|
||||
<p>*数据采集时间:2026-06-30 02:49:08*</p>
|
||||
<p>*AI 生成,建议人工复核*</p>
|
||||
|
||||
</div>
|
||||
<div class="footer">
|
||||
Powered by gitlink-cli · 数据来源于 GitLink 平台
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
#!/bin/bash
|
||||
# ================================================================
|
||||
# 社区运营自动化 — 全流程串联脚本
|
||||
# 功能:依次执行 Issue 分拣 → 周报生成 → Release Notes
|
||||
# 使用:bash community-ops-full.sh <owner> <repo>
|
||||
# 环境变量:DRY_RUN=true|false (默认 true)
|
||||
# ================================================================
|
||||
set -e
|
||||
|
||||
OWNER=${1:?"Usage: $0 <owner> <repo>"}
|
||||
REPO=${2}
|
||||
DRY_RUN=${DRY_RUN:-true}
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUTPUT_DIR="$SCRIPT_DIR/_output"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
echo "============================================"
|
||||
echo " 社区运营自动化"
|
||||
echo " Target: $OWNER/$REPO"
|
||||
echo " DRY_RUN: $DRY_RUN"
|
||||
echo " 开始时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# Phase 1
|
||||
echo "▸ Phase 1/3: Issue 自动分拣"
|
||||
bash "$SCRIPT_DIR/01-issue-triage.sh" "$OWNER" "$REPO"
|
||||
echo ""
|
||||
|
||||
# Phase 2
|
||||
echo "▸ Phase 2/3: 社区周报生成"
|
||||
bash "$SCRIPT_DIR/02-weekly-health-report.sh" "$OWNER" "$REPO"
|
||||
echo ""
|
||||
|
||||
# Phase 3
|
||||
echo "▸ Phase 3/3: 自动 Release Notes"
|
||||
bash "$SCRIPT_DIR/03-auto-release-notes.sh" "$OWNER" "$REPO"
|
||||
echo ""
|
||||
|
||||
# 生成 HTML 可视化报告
|
||||
echo "▸ 生成 HTML 可视化报告..."
|
||||
SHARED_SCRIPT="$SCRIPT_DIR/../../gitlink-shared/scripts/md-to-html.js"
|
||||
if [ -f "$OUTPUT_DIR/weekly-health-report.md" ]; then
|
||||
node "$SHARED_SCRIPT" "$OUTPUT_DIR/weekly-health-report.md" 2>/dev/null || true
|
||||
fi
|
||||
for rn in "$OUTPUT_DIR"/release-notes-*.md; do
|
||||
[ -f "$rn" ] && node "$SHARED_SCRIPT" "$rn" 2>/dev/null || true
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "============================================"
|
||||
echo " 全流程完成"
|
||||
echo " 结束时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo " 📄 Markdown: $OUTPUT_DIR/"
|
||||
echo " 🌐 HTML: 已生成"
|
||||
echo "============================================"
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const dir = process.argv[2] || '_output';
|
||||
|
||||
function readJSON(name) {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); }
|
||||
catch(e) { return {data: {}}; }
|
||||
}
|
||||
|
||||
// ==== 读取数据 ====
|
||||
const repoInfo = readJSON('repo-info.json').data || {};
|
||||
const issuesAll = readJSON('issues-all.json').data || {};
|
||||
const issuesOpen = readJSON('issues-open.json').data || {};
|
||||
const issuesClosed = readJSON('issues-closed.json').data || {};
|
||||
const prsAll = readJSON('prs-all.json').data || {};
|
||||
const prsMerged = readJSON('prs-merged.json').data || {};
|
||||
const milestones = readJSON('milestones.json').data || {};
|
||||
|
||||
// ==== Issue 分析 ====
|
||||
const issueList = issuesAll.issues || [];
|
||||
const openCount = issuesOpen.issues ? issuesOpen.issues.length : (issuesAll.opened_count || 0);
|
||||
const closedCount = issuesAll.closed_count || 0;
|
||||
const totalIssues = openCount + closedCount || issueList.length || 0;
|
||||
const closeRate = totalIssues > 0 ? Math.round(closedCount / totalIssues * 100) : 0;
|
||||
|
||||
// 响应时间:计算 closed list 中 created_at 到现在的平均天数
|
||||
let totalResponseDays = 0;
|
||||
let responseCount = 0;
|
||||
(issuesClosed.issues || []).forEach(i => {
|
||||
if (i.created_at) {
|
||||
const created = new Date(i.created_at.replace(' ', 'T'));
|
||||
if (!isNaN(created.getTime())) {
|
||||
totalResponseDays += (Date.now() - created.getTime()) / (24 * 60 * 60 * 1000);
|
||||
responseCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
const avgResponseDays = responseCount > 0 ? Math.round(totalResponseDays / responseCount * 10) / 10 : 0;
|
||||
|
||||
const closeRateScore = closeRate > 80 ? 10 : closeRate > 50 ? 7 : closeRate > 30 ? 5 : 3;
|
||||
const responseScore = avgResponseDays < 2 ? 10 : avgResponseDays < 7 ? 7 : 3;
|
||||
|
||||
// ==== PR 分析 ====
|
||||
const prList = prsAll.pulls || [];
|
||||
let prOpen = 0, prMergedCount = 0;
|
||||
prList.forEach(p => {
|
||||
const s = (p.status || '').toLowerCase();
|
||||
if (s === 'open' || s === 'opened') prOpen++;
|
||||
else if (s === 'merged') prMergedCount++;
|
||||
});
|
||||
const totalPRs = repoInfo.pull_requests_count || prList.length || 0;
|
||||
const mergeRate = totalPRs > 0 ? Math.round(prMergedCount / totalPRs * 100) : 0;
|
||||
const mergeRateScore = mergeRate > 70 ? 10 : mergeRate > 40 ? 7 : 3;
|
||||
|
||||
// ==== 贡献者活跃度 ====
|
||||
const contribCount = repoInfo.contributor_users_count || 0;
|
||||
const contribScore = contribCount >= 5 ? 10 : contribCount >= 2 ? 7 : 3;
|
||||
|
||||
// ==== 综合评分 ====
|
||||
const healthScore = Math.round((closeRateScore * 0.30 + mergeRateScore * 0.30 + contribScore * 0.25 + 6 * 0.15) * 10) / 10;
|
||||
const healthLevel = healthScore >= 9 ? '🟢 优秀' : healthScore >= 7 ? '🟢 良好' : healthScore >= 5 ? '🟡 一般' : '🔴 堪忧';
|
||||
|
||||
// ==== 改进建议 ====
|
||||
const suggestions = [];
|
||||
if (closeRate < 50) suggestions.push('Issue 关闭率偏低,建议定期清理积压 Issue');
|
||||
if (mergeRate < 50 && totalPRs > 0) suggestions.push('PR 合并率偏低,建议加快 PR 处理速度');
|
||||
if (contribCount < 3) suggestions.push('贡献者较少,建议标记 good-first-issue 吸引新贡献者');
|
||||
if (suggestions.length === 0) suggestions.push('项目状态良好,继续保持');
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().slice(0, 10);
|
||||
const timeStr = now.toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
// ==== 生成周报 ====
|
||||
const report = `# 📊 社区健康度周报
|
||||
|
||||
**报告日期:** ${dateStr}
|
||||
**项目:** z2_cc/gitlink-cli
|
||||
**综合评分:** ${healthScore}/10 ${healthLevel}
|
||||
|
||||
## 一、本周概览
|
||||
| 指标 | 数据 | 状态 |
|
||||
|------|------|------|
|
||||
| Issue 总数 | ${totalIssues} | — |
|
||||
| 打开中 | ${openCount} | ${openCount > 10 ? '🔴' : openCount > 5 ? '🟡' : '🟢'} |
|
||||
| 已关闭 | ${closedCount} | ${closeRate > 50 ? '🟢' : '🔴'} |
|
||||
| 关闭率 | ${closeRate}% | ${closeRateScore >= 7 ? '🟢 良好' : closeRateScore >= 5 ? '🟡 一般' : '🔴 偏低'} |
|
||||
| PR 总数 | ${totalPRs} | — |
|
||||
| 打开中 | ${prOpen} | — |
|
||||
| 已合并 | ${prMergedCount} | ${mergeRate > 50 ? '🟢' : '🔴'} |
|
||||
| 合并率 | ${mergeRate}% | ${mergeRateScore >= 7 ? '🟢 良好' : '🔴 偏低'} |
|
||||
| 贡献者数 | ${contribCount} 人 | ${contribScore >= 7 ? '🟢' : '🔴'} |
|
||||
| 平均响应时间 | ${avgResponseDays} 天 | ${responseScore >= 7 ? '🟢 及时' : '🟡 正常'} |
|
||||
|
||||
## 二、Issue 分析(评分:${closeRateScore}/10)
|
||||
- 总 Issue 数:${totalIssues}(打开 ${openCount} / 关闭 ${closedCount})
|
||||
- 关闭率:${closeRate}%${closeRate > 50 ? ' ✅ 良好' : ' ⚠️ 偏低'}
|
||||
- 平均响应时间:${avgResponseDays} 天
|
||||
- **分析:** ${closeRate > 50 ? '社区 Issue 处理效率良好。' : 'Issue 关闭率偏低,建议定期进行 Issue 清理和回复。'}
|
||||
|
||||
## 三、PR 分析(评分:${mergeRateScore}/10)
|
||||
- 总 PR 数:${totalPRs}(打开 ${prOpen} / 合并 ${prMergedCount})
|
||||
- 合并率:${mergeRate}%${mergeRate > 50 ? ' ✅ 良好' : ' ⚠️ 偏低'}
|
||||
- **分析:** ${mergeRate > 50 ? 'PR 合并效率良好,团队协作顺畅。' : 'PR 合并率偏低,建议加快代码审查和合并速度。'}
|
||||
|
||||
## 四、贡献者活跃度(评分:${contribScore}/10)
|
||||
- 近 50 次提交贡献者:${contribCount} 人
|
||||
- **分析:** ${contribCount >= 5 ? '团队协作活跃 🟢' : contribCount >= 2 ? '少量贡献者 🟡' : '单人维护 🔴'}
|
||||
|
||||
## 五、改进建议
|
||||
${suggestions.map((s, i) => `${i + 1}. ${s}`).join('\n')}
|
||||
|
||||
---
|
||||
*数据采集时间:${timeStr}*
|
||||
*AI 生成,建议人工复核*
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'weekly-health-report.md'), report, 'utf8');
|
||||
console.log('周报已生成');
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue