Compare commits
92 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
a2c1746688 | |
|
|
08aa94d4ef | |
|
|
7352b8c765 | |
|
|
6be9766f05 | |
|
|
95f8019e33 | |
|
|
86ec288180 | |
|
|
e8af93c52b | |
|
|
03f310a6cd | |
|
|
7c1bc9cc4a | |
|
|
a995868a8d | |
|
|
08e3ccc063 | |
|
|
7af9177798 | |
|
|
c4cd105068 | |
|
|
e6f9703d24 | |
|
|
1f5c3b2d8c | |
|
|
9c180328bd | |
|
|
ee3e4bda86 | |
|
|
9553a30efc | |
|
|
7faba628d3 | |
|
|
b5f8b299ad | |
|
|
67a38b48e5 | |
|
|
fdb6e115d9 | |
|
|
8c2f843d38 | |
|
|
4eba22ed19 | |
|
|
32785401c0 | |
|
|
716a4335a9 | |
|
|
78c7ad2771 | |
|
|
4b5d402e89 | |
|
|
c3fc44cbca | |
|
|
e6ddc44178 | |
|
|
c12875ac26 | |
|
|
a95b07a507 | |
|
|
1af90f192b | |
|
|
a3a3643369 | |
|
|
808f6e8d7e | |
|
|
758f90a22e | |
|
|
22e8300768 | |
|
|
373e79ae5f | |
|
|
b7e310363f | |
|
|
cd2cb11aad | |
|
|
5bec1e5bf1 | |
|
|
60cb293913 | |
|
|
32783c2086 | |
|
|
fb5ebc4d2c | |
|
|
90e51dbadf | |
|
|
3052e0bfa1 | |
|
|
603ab89d31 | |
|
|
8391c3f3eb | |
|
|
045c3b3c5f | |
|
|
9368c89b4d | |
|
|
21859ae6c1 | |
|
|
d1965eb098 | |
|
|
ce7bb8ad19 | |
|
|
831bd74be6 | |
|
|
f6467c5e84 | |
|
|
452c87dca7 | |
|
|
7f0652fd79 | |
|
|
73b5a557bc | |
|
|
cb8dfd5537 | |
|
|
49c4b2931f | |
|
|
623e065efa | |
|
|
b1bd9759ce | |
|
|
c229513584 | |
|
|
1004bddc8d | |
|
|
ad30d3b233 | |
|
|
3558a1626c | |
|
|
72f1a7d6f5 | |
|
|
9ca2ab9530 | |
|
|
ac78457f9e | |
|
|
cf9f56feb3 | |
|
|
c1155c2262 | |
|
|
4e4e3fb7c9 | |
|
|
f627c3d813 | |
|
|
bfd8cb0ba5 | |
|
|
1a5dbe9e8e | |
|
|
37264ea7ec | |
|
|
fca5e9b821 | |
|
|
7cd995dffa | |
|
|
d072d54995 | |
|
|
ad215e292d | |
|
|
d6c1a92fe3 | |
|
|
999e721bd8 | |
|
|
a82717bbf3 | |
|
|
d63ea089ba | |
|
|
74f75a55ca | |
|
|
e29a02a3d0 | |
|
|
2f943d9a53 | |
|
|
6042a7ea93 | |
|
|
60b6ea6f6c | |
|
|
1276744ee2 | |
|
|
f024db9878 | |
|
|
65f9f49e00 |
|
|
@ -0,0 +1,31 @@
|
||||||
|
version: 2
|
||||||
|
name: 构建部署Showcase
|
||||||
|
description: "代码提交自动触发:在服务器上拉取代码、构建Docker镜像并部署"
|
||||||
|
global:
|
||||||
|
concurrent: 1
|
||||||
|
trigger:
|
||||||
|
webhook: gitlink@1.0.0
|
||||||
|
event:
|
||||||
|
- ref: push
|
||||||
|
ruleset-operator: AND
|
||||||
|
workflow:
|
||||||
|
- ref: start
|
||||||
|
name: 开始
|
||||||
|
task: start
|
||||||
|
- ref: ssh_cmd_0
|
||||||
|
name: SSH部署到服务器
|
||||||
|
task: ssh_cmd@1.1.1
|
||||||
|
input:
|
||||||
|
ssh_pass: ((deploy_server.server_password))
|
||||||
|
ssh_ip: '"118.31.4.168"'
|
||||||
|
ssh_port: '"22"'
|
||||||
|
ssh_user: '"root"'
|
||||||
|
ssh_cmd: >-
|
||||||
|
"git config --global --add safe.directory /opt/gitlink-cli && cd /opt/gitlink-cli && git fetch origin && git reset --hard origin/master && DOCKER_BUILDKIT=1 docker build -f showcase/Dockerfile -t gitlink-cli-showcase . && docker stop gitlink-cli-showcase || true && docker rm gitlink-cli-showcase || true && docker run -d -p 9090:9090 --name gitlink-cli-showcase --restart unless-stopped -e GITLINK_TOKEN='cookie:autologin_trustie=56c6d2b4378588465c97d48679eed7bd2495ff1a' gitlink-cli-showcase"
|
||||||
|
needs:
|
||||||
|
- start
|
||||||
|
- ref: end
|
||||||
|
name: 结束
|
||||||
|
task: end
|
||||||
|
needs:
|
||||||
|
- ssh_cmd_0
|
||||||
|
|
@ -16,7 +16,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.22'
|
go-version: '1.26'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -36,7 +36,9 @@ jobs:
|
||||||
"linux amd64" \
|
"linux amd64" \
|
||||||
"linux arm64" \
|
"linux arm64" \
|
||||||
"windows amd64" \
|
"windows amd64" \
|
||||||
"windows arm64"; do
|
"windows arm64" \
|
||||||
|
"freebsd amd64" \
|
||||||
|
"freebsd arm64"; do
|
||||||
GOOS=$(echo "$pair" | cut -d' ' -f1)
|
GOOS=$(echo "$pair" | cut -d' ' -f1)
|
||||||
GOARCH=$(echo "$pair" | cut -d' ' -f2)
|
GOARCH=$(echo "$pair" | cut -d' ' -f2)
|
||||||
OUT="gitlink-cli"
|
OUT="gitlink-cli"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Binaries
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
bin/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Showcase build outputs(由 Dockerfile / deploy 脚本编译,不入库)
|
||||||
|
showcase/showcase-linux
|
||||||
|
showcase/showcase-server-linux
|
||||||
|
showcase/showcase-server
|
||||||
|
showcase/showcase
|
||||||
|
|
||||||
|
# Test binary
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of go coverage
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Go workspace
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
509
README.md
509
README.md
|
|
@ -1,430 +1,183 @@
|
||||||
# gitlink-cli
|
# GitLink CLI · 智能化能力提升
|
||||||
|
|
||||||
[](https://www.gitlink.org.cn/Gitlink/gitlink-cli)
|
> 让 AI 智能体和开发者都能在终端高效操控 GitLink 平台,把仓库管理、Issue/PR 协作、CI/CD 变成可自动化、可复现的流程。
|
||||||
[](https://license.coscl.org.cn/MulanPSL2)
|
|
||||||
[](https://golang.org)
|
|
||||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
|
||||||
|
|
||||||
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows, with 40+ commands and 12 AI Agent [Skills](./skills/).
|
**第八届 CCF 开源创新大赛 · track1 GitLink-CLI 贡献赛**参赛作品。
|
||||||
|
|
||||||
**[中文文档](./README.zh-CN.md)**
|
---
|
||||||
|
|
||||||
[Install](#installation--quick-start) · [AI Agent Skills](#ai-agent-skills) · [Auth](#configure--use) · [Commands](#usage-examples) · [Contributing](#related-projects)
|
## 项目简介
|
||||||
|
|
||||||
## Why gitlink-cli?
|
随着 Claude Code、Cursor 等 AI 编程智能体兴起,开发者正从"手动操作平台"转向"Agent 驱动开发"。但 GitLink 平台的能力长期锁在网页 GUI 里——AI 看不到、调不动,开发者也得在终端和浏览器间反复切换。
|
||||||
|
|
||||||
- **Agent-Native Design** — 12 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
|
本项目围绕 [gitlink-cli](https://gitlink.org.cn/gitlink/gitlink-cli) 做了四件事:
|
||||||
- **Wide Coverage** — Repository, Issue, PR, Branch, Release, CI, Org, Search, User — all core domains covered
|
|
||||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
|
|
||||||
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
|
|
||||||
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
|
|
||||||
- **Up and Running in 3 Minutes** — Interactive login or `GITLINK_TOKEN` env var, from install to first API call in just 3 steps
|
|
||||||
- **Secure & Controllable** — OS-native keychain credential storage, `GITLINK_TOKEN` env var for CI/CD & non-interactive environments, auto git remote context resolution
|
|
||||||
- **Three-Layer Architecture** — Shortcuts (human & AI friendly) → Raw API (full coverage) → Config (configuration management)
|
|
||||||
|
|
||||||
## Features
|
1. **把平台能力命令化**——新增 19 个命令模块、110 条命令,让每类操作都能在终端一行命令完成
|
||||||
|
2. **给 AI 写使用说明书**——23 个 Skill,让 Claude Code 等智能体直接会操控 GitLink
|
||||||
|
3. **串成端到端工作流**——自研工作流引擎,一行命令把多个 Skill 串成自动化流水线
|
||||||
|
4. **延伸到科研场景**——科研项目洞悉、FAIR 合规检查、贡献者画像
|
||||||
|
|
||||||
| Category | Capabilities |
|
| 指标 | 数值 |
|
||||||
|----------|-------------|
|
|------|------|
|
||||||
| 📦 Repo | List, create, fork, delete repositories, view repo info |
|
| 命令模块 | 19 个(110 条命令) |
|
||||||
| 🐛 Issue | Create, update, close, batch close, comment on issues |
|
| 批量操作 | 11 条(全部支持 `--dry-run` 预演) |
|
||||||
| 🔀 PR | Create, merge, review pull requests, view changed files |
|
| AI Agent Skill | 23 个 |
|
||||||
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
|
| 预置工作流 | 5 个 |
|
||||||
| 🏷️ Release | Create, view, delete releases |
|
| 单元测试 | 187 个 |
|
||||||
| 🏢 Org | Manage organizations, members, teams |
|
| 交叉编译平台 | 8 个(Win/Linux/macOS/FreeBSD × amd64/arm64) |
|
||||||
| 🔧 CI | View builds, logs, CI/CD operations |
|
|
||||||
| 🔍 Search | Search repositories, users |
|
|
||||||
| 👤 User | View user profiles and info |
|
|
||||||
| 📋 PM | Sprint management, kanban boards, weekly reports |
|
|
||||||
| 🤖 Workflow | AI-powered issue triage, PR review, release notes |
|
|
||||||
|
|
||||||
## Installation & Quick Start
|
---
|
||||||
|
|
||||||
### Requirements
|
## 四大子赛题成果
|
||||||
|
|
||||||
- Node.js 14+ (`npm`/`npx`) — for npm installation
|
### 子赛题一 · 增加 CLI 能力
|
||||||
- Supported platforms: macOS, Linux, Windows (x64/arm64)
|
新增 19 个 Shortcut 模块(仓库/Issue/PR/Release/CI/Webhook/标签/里程碑/Wiki/工作流…),11 条批量操作,修复跨平台兼容性(Windows/Linux/macOS/FreeBSD)。已向主仓库提交 PR。
|
||||||
- Go 1.26+ — only required for building from source
|
- 代码:[`shortcuts/`](shortcuts/)、[`internal/`](internal/)、[`cmd/`](cmd/)
|
||||||
|
- 提交材料:[`提交材料/子赛题一-CLI能力/`](提交材料/子赛题一-CLI能力/)
|
||||||
|
|
||||||
### Quick Start (Human Users)
|
### 子赛题二 · 编写 Skills
|
||||||
|
23 个 Skill(命令操作类 + 智能分析型),覆盖健康度报告、Release Notes 生成、Issue 智能分拣、智能代码审查、许可证合规、科研辅助等。一键 `setup-skills.sh` 装进 Claude Code 即可用 `/gitlink-xxx` 调用。
|
||||||
|
- 代码:[`skills/`](skills/)
|
||||||
|
- 演示录屏:[`提交材料/子赛题二-Skills/demos/`](提交材料/子赛题二-Skills/demos/)
|
||||||
|
|
||||||
> **Note for AI assistants:** If you are an AI Agent helping the user with installation, jump directly to [Quick Start (AI Agent)](#quick-start-ai-agent), which contains all the steps you need to complete.
|
### 子赛题三 · 端到端工作流
|
||||||
|
自研工作流引擎(`shortcuts/workflow/`),AI/规则双模式,5 个预置工作流(代码质量看门人/社区运营自动化/贡献者成长/多仓库协同/项目一键初始化),支持手动/轮询/定时触发,systemd 7×24 自动运行。
|
||||||
|
- 代码:[`shortcuts/workflow/`](shortcuts/workflow/)
|
||||||
|
- 提交材料:[`提交材料/子赛题三-工作流/`](提交材料/子赛题三-工作流/)
|
||||||
|
|
||||||
#### Install
|
### 子赛题四 · 辅助科研
|
||||||
|
gitlink-spark(科研软件 X 光)、gitlink-research-fair(FAIR 合规检查)、gitlink-contributor-ranking(贡献者画像),面向科研项目洞悉、合规校验、协作匹配。
|
||||||
|
- 代码:[`skills/gitlink-spark`](skills/gitlink-spark)、[`skills/gitlink-research-fair`](skills/gitlink-research-fair)
|
||||||
|
- 提交材料:[`提交材料/子赛题四-科研/`](提交材料/子赛题四-科研/)
|
||||||
|
|
||||||
**From npm (recommended):**
|
> 📁 所有变更说明、演示录屏/截图集中在 [`提交材料/`](提交材料/) 目录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# One command: installs CLI binary + all 12 AI Agent Skills
|
# 方式一:go install(需 Go 1.26+)
|
||||||
|
go install github.com/gitlink-org/gitlink-cli@latest
|
||||||
|
|
||||||
|
# 方式二:npm
|
||||||
npm install -g @gitlink-ai/cli
|
npm install -g @gitlink-ai/cli
|
||||||
|
|
||||||
|
# 方式三:下载预编译包(8 平台)
|
||||||
|
# 见 Release 页面
|
||||||
```
|
```
|
||||||
|
|
||||||
The binary is auto-downloaded for your platform during `postinstall`. No extra steps needed.
|
### 认证
|
||||||
|
|
||||||
**From source:**
|
|
||||||
|
|
||||||
Requires Go 1.26+.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git
|
gitlink-cli auth login # 浏览器登录,cookie 自动保存
|
||||||
cd gitlink-cli
|
gitlink-cli auth status # 查看登录状态
|
||||||
make install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Windows users:** Run `npm install -g @gitlink-ai/cli` in PowerShell or CMD. For building from source, use `go install .` instead of `make install`.
|
### 使用示例
|
||||||
|
|
||||||
#### Configure & Use
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Configure (one-time, interactive guided setup)
|
# 列出仓库的 Issue
|
||||||
gitlink-cli config init
|
gitlink-cli issue +list --owner gitlink --repo gitlink-cli
|
||||||
|
|
||||||
# 2. Log in (choose one)
|
# 批量关闭(先预演)
|
||||||
gitlink-cli auth login # Username/password (recommended)
|
gitlink-cli issue +batch-close --numbers 1,2,3 --dry-run
|
||||||
gitlink-cli auth login --token # Or paste a private token
|
|
||||||
export GITLINK_TOKEN="your-token" # Or set env var (for CI/CD, non-interactive environments)
|
|
||||||
|
|
||||||
# 3. Start using
|
# 在 Claude Code 里一句话调用 Skill
|
||||||
gitlink-cli repo +list
|
/gitlink-triage 帮我分拣所有未分类的 Issue
|
||||||
```
|
```
|
||||||
|
|
||||||
### Quick Start (AI Agent)
|
### 一键启用 Skills(Claude Code)
|
||||||
|
|
||||||
> The following steps are for AI Agents. Some steps require the user to complete actions in a browser.
|
|
||||||
|
|
||||||
**Step 1 — Install**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# One command: CLI binary + all Skills auto-installed
|
bash scripts/setup-skills.sh # 把 skills/ 链接到 ~/.claude/skills/
|
||||||
npm install -g @gitlink-ai/cli
|
# 之后在 Claude Code 里就能 /gitlink-xxx 调用
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 2 — Configure**
|
### 在线演示
|
||||||
|
|
||||||
```bash
|
Showcase Dashboard:<http://118.31.4.168:9090> —— 浏览器里点卡片就能跑命令,无需安装。
|
||||||
gitlink-cli config init
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构说明
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ 入口层 cmd/ main.go → root.go (cobra root) │
|
||||||
|
│ 4 个基础命令: auth / api / config / version │
|
||||||
|
│ 全局 flag: --owner/--repo/--format/--debug │
|
||||||
|
└───────────────┬──────────────────────────────────────────┘
|
||||||
|
│ shortcuts.RegisterAll(rootCmd)
|
||||||
|
┌───────────────▼──────────────────────────────────────────┐
|
||||||
|
│ 业务层 shortcuts/ 19 组 × 100+ 命令 + 工作流引擎 │
|
||||||
|
│ register.go 命令注册中枢 │
|
||||||
|
│ common/ RuntimeContext 业务统一上下文 │
|
||||||
|
│ workflow/ 工作流引擎(AI/规则双模式 + 5 预置流) │
|
||||||
|
└───────────────┬──────────────────────────────────────────┘
|
||||||
|
│ RuntimeContext.CallAPI / Output
|
||||||
|
┌───────────────▼──────────────────────────────────────────┐
|
||||||
|
│ 基础层 internal/ Go 内部包,不对外暴露 │
|
||||||
|
│ client/ HTTP 客户端 + .json 后缀 + 错误检测 │
|
||||||
|
│ auth/ Transport 自动注入 Cookie/access_token │
|
||||||
|
│ config/ YAML 配置 + 跨平台路径 │
|
||||||
|
│ output/ Envelope(ok/data/meta) + json/yaml/table │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
独立的 AI 层 skills/ 23 个 SKILL.md,面向 AI Agent 的自然语言说明书
|
||||||
|
(非编译期依赖,指导 Agent 调用编译好的二进制)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 3 — Login**
|
**核心设计**:业务命令只依赖 `RuntimeContext`,绝不直接接触 cobra/HTTP 细节——新增模块只挂一个 `shortcuts/` 子包,不动入口与基础层。这是 19 模块 100+ 命令仍保持可测试性的根本。
|
||||||
|
|
||||||
For interactive environments:
|
---
|
||||||
```bash
|
|
||||||
gitlink-cli auth login
|
|
||||||
```
|
|
||||||
|
|
||||||
For non-interactive environments (CI/CD, Trae sandbox, MCP, etc.):
|
## 目录结构
|
||||||
```bash
|
|
||||||
export GITLINK_TOKEN="your-private-token"
|
|
||||||
```
|
|
||||||
|
|
||||||
> To get a private token, go to GitLink web → Settings → Private Tokens.
|
|
||||||
|
|
||||||
**Step 4 — Verify**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gitlink-cli user +me
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Repository Operations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List repositories
|
|
||||||
gitlink-cli repo +list
|
|
||||||
|
|
||||||
# View repository info
|
|
||||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
|
||||||
|
|
||||||
# Create a repository
|
|
||||||
gitlink-cli repo +create -n my-project -d "Project description"
|
|
||||||
|
|
||||||
# Fork a repository
|
|
||||||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List issues
|
|
||||||
gitlink-cli issue +list --owner Gitlink --repo forgeplus
|
|
||||||
|
|
||||||
# Create an issue
|
|
||||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..."
|
|
||||||
|
|
||||||
# View an issue
|
|
||||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
|
||||||
|
|
||||||
# Close an issue
|
|
||||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
|
||||||
|
|
||||||
# Preview batch close without changing data
|
|
||||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run
|
|
||||||
|
|
||||||
# Batch close issues from a CSV file
|
|
||||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
|
|
||||||
|
|
||||||
# Add a comment
|
|
||||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pull Requests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List PRs
|
|
||||||
gitlink-cli pr +list --owner Gitlink --repo forgeplus
|
|
||||||
|
|
||||||
# Create a PR (same-repo branch)
|
|
||||||
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: Search feature" --head feature/search --base master
|
|
||||||
|
|
||||||
# Create a PR (from a fork)
|
|
||||||
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: New feature" --head your_username/forgeplus:feature/my-feature --base master
|
|
||||||
|
|
||||||
# View a PR
|
|
||||||
gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
|
||||||
|
|
||||||
# Merge a PR
|
|
||||||
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
|
||||||
|
|
||||||
# View changed files
|
|
||||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
|
||||||
```
|
|
||||||
|
|
||||||
### Branch Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List branches
|
|
||||||
gitlink-cli branch +list --owner Gitlink --repo forgeplus
|
|
||||||
|
|
||||||
# Create a branch
|
|
||||||
gitlink-cli branch +create --name feature/new-feature
|
|
||||||
|
|
||||||
# Delete a branch
|
|
||||||
gitlink-cli branch +delete --name feature/old-feature
|
|
||||||
|
|
||||||
# Protect a branch
|
|
||||||
gitlink-cli branch +protect --name main
|
|
||||||
|
|
||||||
# Remove branch protection
|
|
||||||
gitlink-cli branch +unprotect --name main
|
|
||||||
```
|
|
||||||
|
|
||||||
### Release Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List releases
|
|
||||||
gitlink-cli release +list --owner Gitlink --repo forgeplus
|
|
||||||
|
|
||||||
# Create a release
|
|
||||||
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 Stable" -b "Changelog..."
|
|
||||||
|
|
||||||
# View a release
|
|
||||||
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
|
|
||||||
```
|
|
||||||
|
|
||||||
### CI/CD Operations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List builds
|
|
||||||
gitlink-cli ci +list --owner Gitlink --repo forgeplus
|
|
||||||
|
|
||||||
# View build log
|
|
||||||
gitlink-cli ci +log --owner Gitlink --repo forgeplus -i <build_id>
|
|
||||||
|
|
||||||
# Restart a build
|
|
||||||
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Search
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Search repositories
|
|
||||||
gitlink-cli search +repos -k "machine learning"
|
|
||||||
|
|
||||||
# Search users
|
|
||||||
gitlink-cli search +users -k "zhangsan"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Raw API
|
|
||||||
|
|
||||||
For endpoints not covered by shortcuts, use the Raw API directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# GET request
|
|
||||||
gitlink-cli api GET /users/me
|
|
||||||
|
|
||||||
# POST request
|
|
||||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
|
|
||||||
|
|
||||||
# With query parameters
|
|
||||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Global Parameters
|
|
||||||
|
|
||||||
| Parameter | Description | Example |
|
|
||||||
|-----------|-------------|---------|
|
|
||||||
| `--owner` | Repository owner | `--owner Gitlink` |
|
|
||||||
| `--repo` | Repository name | `--repo forgeplus` |
|
|
||||||
| `--format` | Output format (json/table/yaml) | `--format json` |
|
|
||||||
| `--debug` | Enable debug output | `--debug` |
|
|
||||||
|
|
||||||
**Automatic context resolution:** When running inside a git repository, `--owner` and `--repo` are automatically resolved from `git remote origin`.
|
|
||||||
|
|
||||||
## Branch Conventions
|
|
||||||
|
|
||||||
gitlink-cli supports bidirectional code sync between GitHub and GitLink:
|
|
||||||
|
|
||||||
| Platform | Default Branch |
|
|
||||||
|----------|---------------|
|
|
||||||
| GitHub | `main` |
|
|
||||||
| GitLink | `master` |
|
|
||||||
|
|
||||||
**Push to GitLink from local:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Method 1: Use git command directly
|
|
||||||
git push gitlink main:master
|
|
||||||
|
|
||||||
# Method 2: Configure git remote
|
|
||||||
git config remote.gitlink.push refs/heads/main:refs/heads/master
|
|
||||||
git push gitlink
|
|
||||||
```
|
|
||||||
|
|
||||||
## AI Agent Skills
|
|
||||||
|
|
||||||
The `skills/` directory contains 12 Agent Skill files for AI-automated GitLink operations.
|
|
||||||
|
|
||||||
See [skills/README.md](skills/README.md) for details.
|
|
||||||
|
|
||||||
| Skill | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| `gitlink-shared` | Authentication, global parameters, safety rules, API notes |
|
|
||||||
| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) |
|
|
||||||
| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
|
|
||||||
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
|
|
||||||
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
|
|
||||||
| `gitlink-release` | Release management (create, view, delete, etc.) |
|
|
||||||
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
|
|
||||||
| `gitlink-search` | Search (repositories, users, etc.) |
|
|
||||||
| `gitlink-org` | Organization management (members, teams, etc.) |
|
|
||||||
| `gitlink-user` | User management (profile info, etc.) |
|
|
||||||
| `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) |
|
|
||||||
| `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) |
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
gitlink-cli/
|
gitlink-cli/
|
||||||
├── cmd/ # Cobra command definitions
|
├── README.md # 本文件
|
||||||
│ ├── root.go # Root command + global flags
|
├── cmd/ # 入口层(auth/api/config/version)
|
||||||
│ ├── auth/ # Authentication commands
|
├── shortcuts/ # 业务层(19 命令模块 + workflow 工作流引擎)
|
||||||
│ ├── api/ # Raw API commands
|
├── internal/ # 基础层(client/auth/config/output/context/errors)
|
||||||
│ ├── config/ # Configuration commands
|
├── skills/ # 23 个 AI Agent Skill
|
||||||
│ └── cmdutil/ # Global utilities
|
├── showcase/ # 在线演示 Dashboard(Go 单二进制)
|
||||||
├── internal/ # Internal packages
|
├── scripts/ # 脚本(setup-skills.sh 等)
|
||||||
│ ├── auth/ # Login, token storage, transport
|
├── docs/ # 设计文档、API 参考
|
||||||
│ ├── client/ # HTTP client + pagination
|
└── 提交材料/ # ★ 比赛提交材料(变更说明 + 演示录屏/截图)
|
||||||
│ ├── config/ # Config file management
|
├── 子赛题一-CLI能力/
|
||||||
│ ├── context/ # Git remote resolution
|
├── 子赛题二-Skills/
|
||||||
│ └── output/ # Envelope + formatter
|
├── 子赛题三-工作流/
|
||||||
├── shortcuts/ # Shortcut implementations
|
└── 子赛题四-科研/
|
||||||
│ ├── common/ # Framework (types, runner)
|
|
||||||
│ ├── repo/ # Repository shortcuts
|
|
||||||
│ ├── issue/ # Issue shortcuts
|
|
||||||
│ ├── pr/ # PR shortcuts
|
|
||||||
│ ├── branch/ # Branch shortcuts
|
|
||||||
│ ├── release/ # Release shortcuts
|
|
||||||
│ ├── org/ # Organization shortcuts
|
|
||||||
│ ├── ci/ # CI shortcuts
|
|
||||||
│ ├── search/ # Search shortcuts
|
|
||||||
│ ├── user/ # User shortcuts
|
|
||||||
│ └── register.go # Registration entry point
|
|
||||||
├── skills/ # AI Agent Skills
|
|
||||||
│ ├── README.md # Skills guide
|
|
||||||
│ ├── gitlink-shared/ # Shared rules
|
|
||||||
│ ├── gitlink-repo/ # Repository skill
|
|
||||||
│ ├── gitlink-issue/ # Issue skill
|
|
||||||
│ ├── gitlink-pr/ # PR skill
|
|
||||||
│ ├── gitlink-pm/ # Project management skill
|
|
||||||
│ └── ...
|
|
||||||
├── doc/ # Design documents
|
|
||||||
│ ├── Design.md
|
|
||||||
│ ├── CODE_SYNC_STRATEGY_FINAL.md
|
|
||||||
│ └── ...
|
|
||||||
├── main.go
|
|
||||||
├── Makefile
|
|
||||||
├── go.mod
|
|
||||||
└── README.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
---
|
||||||
|
|
||||||
- [Skills Guide](skills/README.md) — AI Agent Skills detailed documentation
|
## 技术栈
|
||||||
- [Design Document](doc/design.md) — Architecture design and development plan
|
|
||||||
|
|
||||||
## FAQ
|
- **语言**:Go 1.26(子赛题一);Markdown/Shell(子赛题二三四)
|
||||||
|
- **CLI 框架**:spf13/cobra
|
||||||
|
- **密钥存储**:zalando/go-keyring(OS keychain + 文件 fallback)
|
||||||
|
- **配置**:gopkg.in/yaml.v3
|
||||||
|
- **CI/CD**:GitLink DevOps(建木引擎)自动部署 + GitHub Actions 8 平台发版
|
||||||
|
|
||||||
### Q: How do I use gitlink-cli in scripts?
|
---
|
||||||
|
|
||||||
Use the `GITLINK_TOKEN` environment variable + `--format json` for structured output:
|
## 团队
|
||||||
|
|
||||||
```bash
|
| 成员 | 负责 |
|
||||||
export GITLINK_TOKEN="your-private-token"
|
|------|------|
|
||||||
gitlink-cli repo +list --format json | jq '.data.projects[] | .name'
|
| chroe | CLI 核心模块、Showcase、CI/CD、health/changelog/triage Skill、工作流引擎 |
|
||||||
```
|
| yetja | 批量操作、单元测试、license/repo/org Skill、工作流规则引擎 |
|
||||||
|
| caoweiqiong | file/member/watch/star 模块、review Skill、FreeBSD 支持 |
|
||||||
|
|
||||||
### Q: How does automatic owner/repo resolution work?
|
---
|
||||||
|
|
||||||
When running inside a git repository, the CLI automatically resolves `--owner` and `--repo` from `git remote origin`:
|
## 相关链接
|
||||||
|
|
||||||
```bash
|
- 上游仓库:<https://gitlink.org.cn/gitlink/gitlink-cli>
|
||||||
cd ~/my-gitlink-project
|
- 本项目(Fork):<https://gitlink.org.cn/chroe/gitlink-cli>
|
||||||
gitlink-cli issue +list # Automatically uses the current repository
|
- 在线 Showcase:<http://118.31.4.168:9090>
|
||||||
```
|
- Skills 开发指南:[`skills/README.md`](skills/README.md)
|
||||||
|
|
||||||
### Q: What if my token expires?
|
|
||||||
|
|
||||||
Re-authenticate:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Username/password login
|
|
||||||
gitlink-cli auth login
|
|
||||||
|
|
||||||
# Or use a private token (generate at GitLink web → Settings → Private Tokens)
|
|
||||||
gitlink-cli auth login --token
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: How do I use gitlink-cli in CI/CD or non-interactive environments (e.g. Trae sandbox)?
|
|
||||||
|
|
||||||
Set the `GITLINK_TOKEN` environment variable — no `auth login` needed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export GITLINK_TOKEN="your-private-token"
|
|
||||||
gitlink-cli repo +list # Ready to use
|
|
||||||
gitlink-cli auth status # Shows "✓ Logged in via GITLINK_TOKEN environment variable"
|
|
||||||
```
|
|
||||||
|
|
||||||
Priority: `GITLINK_TOKEN` env var > keyring/file stored token. When the env var is not set, the original interactive login flow works as before.
|
|
||||||
|
|
||||||
### Q: What if npm installs successfully but `gitlink-cli` reports a missing binary?
|
|
||||||
|
|
||||||
Reinstall first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g @gitlink-ai/cli
|
|
||||||
```
|
|
||||||
|
|
||||||
If the error persists, check whether the release page contains the asset for your platform, for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64. You can also download the binary manually from the release page or build from source with `go install .`.
|
|
||||||
|
|
||||||
### Q: Where are credentials stored on Windows?
|
|
||||||
|
|
||||||
gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`).
|
|
||||||
|
|
||||||
### Q: Where can I find the full API reference?
|
|
||||||
|
|
||||||
See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md).
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MulanPSL-2.0](https://license.coscl.org.cn/MulanPSL2)
|
|
||||||
|
|
|
||||||
409
README.zh-CN.md
409
README.zh-CN.md
|
|
@ -1,409 +0,0 @@
|
||||||
# gitlink-cli
|
|
||||||
|
|
||||||
[](https://www.gitlink.org.cn/Gitlink/gitlink-cli)
|
|
||||||
[](https://license.coscl.org.cn/MulanPSL2)
|
|
||||||
[](https://golang.org)
|
|
||||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
|
||||||
|
|
||||||
[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 11 个 AI Agent [Skills](./skills/)。
|
|
||||||
|
|
||||||
**[English](./README.md)**
|
|
||||||
|
|
||||||
[安装](#安装与快速上手) · [AI Agent Skills](#ai-agent-skills) · [认证](#配置与使用) · [命令](#使用示例) · [贡献](#相关项目)
|
|
||||||
|
|
||||||
## 为什么选择 gitlink-cli?
|
|
||||||
|
|
||||||
- **Agent-Native 设计** — 开箱即用 11 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
|
||||||
- **广泛覆盖** — 仓库、Issue、PR、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
|
|
||||||
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
|
|
||||||
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
|
|
||||||
- **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用
|
|
||||||
- **3 分钟上手** — 交互式登录或 `GITLINK_TOKEN` 环境变量,从安装到首次 API 调用仅需 3 步
|
|
||||||
- **安全可控** — OS 原生 keychain 凭证存储,`GITLINK_TOKEN` 环境变量支持 CI/CD 和非交互环境,自动 git remote 上下文解析
|
|
||||||
- **三层架构** — Shortcuts(人+AI友好)→ Raw API(全覆盖)→ Config(配置管理)
|
|
||||||
|
|
||||||
## 功能一览
|
|
||||||
|
|
||||||
| 分类 | 能力 |
|
|
||||||
|------|------|
|
|
||||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
|
||||||
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
|
|
||||||
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
|
||||||
| 🌿 分支 | 创建、删除、保护分支 |
|
|
||||||
| 🏷️ 发布 | 创建、查看、删除 Release |
|
|
||||||
| 🏢 组织 | 管理组织、成员、团队 |
|
|
||||||
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
|
|
||||||
| 🔍 搜索 | 搜索仓库、用户 |
|
|
||||||
| 👤 用户 | 查看用户资料和信息 |
|
|
||||||
| 📋 项目管理 | Sprint 管理、看板、周报 |
|
|
||||||
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
|
|
||||||
|
|
||||||
## 安装与快速上手
|
|
||||||
|
|
||||||
### 前置条件
|
|
||||||
|
|
||||||
- Node.js 14+(`npm`/`npx`)— 用于 npm 安装
|
|
||||||
- 支持平台:macOS、Linux、Windows(x64/arm64)
|
|
||||||
- Go 1.26+ — 仅从源码构建时需要
|
|
||||||
|
|
||||||
### 快速上手(人类用户)
|
|
||||||
|
|
||||||
> **AI 助手请注意:** 如果你是帮助用户安装的 AI Agent,请直接跳到 [快速上手(AI Agent)](#快速上手ai-agent),其中包含你需要完成的所有步骤。
|
|
||||||
|
|
||||||
#### 安装
|
|
||||||
|
|
||||||
选择以下**任一**方式:
|
|
||||||
|
|
||||||
**方式 1 — 从 npm 安装(推荐):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装 CLI
|
|
||||||
npm install -g @gitlink-ai/cli
|
|
||||||
|
|
||||||
# 安装 CLI Skill(必须,全平台通用)
|
|
||||||
gitlink-cli-install-skills
|
|
||||||
|
|
||||||
# 也可使用 npx 安装 Skill
|
|
||||||
npx skills add ccfos/gitlink-cli/skills -y -g
|
|
||||||
```
|
|
||||||
|
|
||||||
**方式 2 — 从源码构建:**
|
|
||||||
|
|
||||||
需要 Go 1.26+。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git
|
|
||||||
cd gitlink-cli
|
|
||||||
make install
|
|
||||||
|
|
||||||
# 安装 CLI Skill(必须)
|
|
||||||
npx skills add ./skills -y -g
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Windows 用户注意:** 请在 PowerShell 或 CMD 中运行 `npm install -g @gitlink-ai/cli`。从源码构建请使用 `go install .` 代替 `make install`。
|
|
||||||
|
|
||||||
#### 配置与使用
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 配置(首次使用,交互式引导)
|
|
||||||
gitlink-cli config init
|
|
||||||
|
|
||||||
# 2. 登录(任选其一)
|
|
||||||
gitlink-cli auth login # 用户名密码(推荐)
|
|
||||||
gitlink-cli auth login --token # 或粘贴私人令牌
|
|
||||||
export GITLINK_TOKEN="your-token" # 或设置环境变量(适用于 CI/CD、非交互环境)
|
|
||||||
|
|
||||||
# 3. 开始使用
|
|
||||||
gitlink-cli repo +list
|
|
||||||
```
|
|
||||||
|
|
||||||
### 快速上手(AI Agent)
|
|
||||||
|
|
||||||
> 以下步骤面向 AI Agent。部分步骤需要用户在浏览器中完成操作。
|
|
||||||
|
|
||||||
**第 1 步 — 安装**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装 CLI
|
|
||||||
npm install -g @gitlink-ai/cli
|
|
||||||
|
|
||||||
# 安装 CLI Skill(必须,全平台通用)
|
|
||||||
gitlink-cli-install-skills
|
|
||||||
```
|
|
||||||
|
|
||||||
**第 2 步 — 配置**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gitlink-cli config init
|
|
||||||
```
|
|
||||||
|
|
||||||
**第 3 步 — 登录**
|
|
||||||
|
|
||||||
交互环境:
|
|
||||||
```bash
|
|
||||||
gitlink-cli auth login
|
|
||||||
```
|
|
||||||
|
|
||||||
非交互环境(CI/CD、Trae 沙箱、MCP 等):
|
|
||||||
```bash
|
|
||||||
export GITLINK_TOKEN="your-private-token"
|
|
||||||
```
|
|
||||||
|
|
||||||
> 获取私人令牌:GitLink 网页端 → 个人设置 → 私人令牌。
|
|
||||||
|
|
||||||
**第 4 步 — 验证**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gitlink-cli user +me
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用示例
|
|
||||||
|
|
||||||
### 仓库操作
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 列出仓库
|
|
||||||
gitlink-cli repo +list
|
|
||||||
|
|
||||||
# 查看仓库信息
|
|
||||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
|
||||||
|
|
||||||
# 创建仓库
|
|
||||||
gitlink-cli repo +create -n my-project -d "项目描述"
|
|
||||||
|
|
||||||
# Fork 仓库
|
|
||||||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue 管理
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 列出 Issue
|
|
||||||
gitlink-cli issue +list --owner Gitlink --repo forgeplus
|
|
||||||
|
|
||||||
# 创建 Issue
|
|
||||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."
|
|
||||||
|
|
||||||
# 查看 Issue
|
|
||||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
|
||||||
|
|
||||||
# 关闭 Issue
|
|
||||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
|
||||||
|
|
||||||
# 预览批量关闭,不修改数据
|
|
||||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run
|
|
||||||
|
|
||||||
# 从 CSV 文件批量关闭 Issue
|
|
||||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
|
|
||||||
|
|
||||||
# 添加评论
|
|
||||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pull Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 列出 PR
|
|
||||||
gitlink-cli pr +list --owner Gitlink --repo forgeplus
|
|
||||||
|
|
||||||
# 创建 PR(同仓库分支)
|
|
||||||
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 搜索功能" --head feature/search --base master
|
|
||||||
|
|
||||||
# 创建 PR(从 Fork 仓库)
|
|
||||||
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 新功能" --head your_username/forgeplus:feature/my-feature --base master
|
|
||||||
|
|
||||||
# 查看 PR
|
|
||||||
gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
|
||||||
|
|
||||||
# 合并 PR
|
|
||||||
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
|
||||||
|
|
||||||
# 查看 PR 变更文件
|
|
||||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
|
||||||
```
|
|
||||||
|
|
||||||
### 发布管理
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 列出 Release
|
|
||||||
gitlink-cli release +list --owner Gitlink --repo forgeplus
|
|
||||||
|
|
||||||
# 创建 Release
|
|
||||||
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..."
|
|
||||||
|
|
||||||
# 查看 Release
|
|
||||||
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 搜索
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 搜索仓库
|
|
||||||
gitlink-cli search +repos -k "machine learning"
|
|
||||||
|
|
||||||
# 搜索用户
|
|
||||||
gitlink-cli search +users -k "zhangsan"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Raw API
|
|
||||||
|
|
||||||
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# GET 请求
|
|
||||||
gitlink-cli api GET /users/me
|
|
||||||
|
|
||||||
# POST 请求
|
|
||||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
|
|
||||||
|
|
||||||
# 带查询参数
|
|
||||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 全局参数
|
|
||||||
|
|
||||||
| 参数 | 说明 | 示例 |
|
|
||||||
|------|------|------|
|
|
||||||
| `--owner` | 仓库所有者 | `--owner Gitlink` |
|
|
||||||
| `--repo` | 仓库名称 | `--repo forgeplus` |
|
|
||||||
| `--format` | 输出格式(json/table/yaml) | `--format json` |
|
|
||||||
| `--debug` | 启用调试输出 | `--debug` |
|
|
||||||
|
|
||||||
**自动上下文解析**:在 git 仓库目录下,`--owner` 和 `--repo` 会自动从 `git remote origin` 解析。
|
|
||||||
|
|
||||||
## 分支约定
|
|
||||||
|
|
||||||
gitlink-cli 支持 GitHub 和 GitLink 的代码双向同步:
|
|
||||||
|
|
||||||
| 平台 | 主分支 |
|
|
||||||
|------|--------|
|
|
||||||
| GitHub | `main` |
|
|
||||||
| GitLink | `master` |
|
|
||||||
|
|
||||||
**本地 push 到 GitLink**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式 1:使用 git 命令
|
|
||||||
git push gitlink main:master
|
|
||||||
|
|
||||||
# 方式 2:配置 git remote
|
|
||||||
git config remote.gitlink.push refs/heads/main:refs/heads/master
|
|
||||||
git push gitlink
|
|
||||||
```
|
|
||||||
|
|
||||||
## AI Agent Skills
|
|
||||||
|
|
||||||
`skills/` 目录包含 11 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
|
|
||||||
|
|
||||||
详见 [skills/README.md](skills/README.md)
|
|
||||||
|
|
||||||
| Skill | 说明 |
|
|
||||||
|-------|------|
|
|
||||||
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
|
|
||||||
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork 等) |
|
|
||||||
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
|
|
||||||
| `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) |
|
|
||||||
| `gitlink-release` | 发布管理(创建、查看、删除等) |
|
|
||||||
| `gitlink-org` | 组织管理(成员、团队等) |
|
|
||||||
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
|
|
||||||
| `gitlink-search` | 搜索功能(仓库、用户等) |
|
|
||||||
| `gitlink-user` | 用户管理(个人信息等) |
|
|
||||||
| `gitlink-pm` | 项目管理(Sprint、看板、周报等) |
|
|
||||||
| `gitlink-workflow` | AI 自动化工作流(Issue 分类、PR Review、Release Notes 等) |
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
gitlink-cli/
|
|
||||||
├── cmd/ # Cobra 命令定义
|
|
||||||
│ ├── root.go # 根命令 + 全局 flags
|
|
||||||
│ ├── auth/ # 认证命令
|
|
||||||
│ ├── api/ # Raw API 命令
|
|
||||||
│ ├── config/ # 配置命令
|
|
||||||
│ └── cmdutil/ # 全局工具
|
|
||||||
├── internal/ # 内部包
|
|
||||||
│ ├── auth/ # 登录、Token 存储、Transport
|
|
||||||
│ ├── client/ # HTTP 客户端 + 分页
|
|
||||||
│ ├── config/ # 配置文件管理
|
|
||||||
│ ├── context/ # git remote 解析
|
|
||||||
│ └── output/ # Envelope + Formatter
|
|
||||||
├── shortcuts/ # Shortcut 实现
|
|
||||||
│ ├── common/ # 框架(types, runner)
|
|
||||||
│ ├── repo/ # 仓库 shortcuts
|
|
||||||
│ ├── issue/ # Issue shortcuts
|
|
||||||
│ ├── pr/ # PR shortcuts
|
|
||||||
│ ├── branch/ # 分支 shortcuts
|
|
||||||
│ ├── release/ # Release shortcuts
|
|
||||||
│ ├── org/ # 组织 shortcuts
|
|
||||||
│ ├── ci/ # CI shortcuts
|
|
||||||
│ ├── search/ # 搜索 shortcuts
|
|
||||||
│ ├── user/ # 用户 shortcuts
|
|
||||||
│ └── register.go # 注册入口
|
|
||||||
├── skills/ # AI Agent Skills
|
|
||||||
│ ├── README.md # Skills 使用指南
|
|
||||||
│ ├── gitlink-shared/ # 共享规则
|
|
||||||
│ ├── gitlink-repo/ # 仓库 Skill
|
|
||||||
│ ├── gitlink-issue/ # Issue Skill
|
|
||||||
│ ├── gitlink-pr/ # PR Skill
|
|
||||||
│ ├── gitlink-pm/ # 项目管理 Skill
|
|
||||||
│ └── ...
|
|
||||||
├── doc/ # 设计文档
|
|
||||||
│ ├── Design.md
|
|
||||||
│ ├── CODE_SYNC_STRATEGY_FINAL.md
|
|
||||||
│ └── ...
|
|
||||||
├── main.go
|
|
||||||
├── Makefile
|
|
||||||
├── go.mod
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## 文档
|
|
||||||
|
|
||||||
- [Skills 使用指南](skills/README.md) — AI Agent Skills 详细说明
|
|
||||||
- [设计文档](doc/design.md) — 架构设计和开发计划
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### Q: 如何在脚本中使用 gitlink-cli?
|
|
||||||
|
|
||||||
使用 `GITLINK_TOKEN` 环境变量 + `--format json` 获取结构化输出:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export GITLINK_TOKEN="your-private-token"
|
|
||||||
gitlink-cli repo +list --format json | jq '.data.projects[] | .name'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: 如何自动解析 owner/repo?
|
|
||||||
|
|
||||||
在 git 仓库目录下运行命令,CLI 会自动从 `git remote origin` 解析:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/my-gitlink-project
|
|
||||||
gitlink-cli issue +list # 自动使用当前仓库
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: Token 过期了怎么办?
|
|
||||||
|
|
||||||
重新登录:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 用户名密码登录
|
|
||||||
gitlink-cli auth login
|
|
||||||
|
|
||||||
# 或使用私人令牌(在 GitLink 网页端 个人设置 → 私人令牌 中生成)
|
|
||||||
gitlink-cli auth login --token
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: 如何在 CI/CD 或非交互环境(Trae 沙箱等)中使用?
|
|
||||||
|
|
||||||
设置 `GITLINK_TOKEN` 环境变量即可,无需 `auth login`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export GITLINK_TOKEN="your-private-token"
|
|
||||||
gitlink-cli repo +list # 直接可用
|
|
||||||
gitlink-cli auth status # 显示 "✓ Logged in via GITLINK_TOKEN environment variable"
|
|
||||||
```
|
|
||||||
|
|
||||||
Token 优先级:`GITLINK_TOKEN` 环境变量 > keyring/文件存储的 token。不设置环境变量时完全兼容原有交互式登录。
|
|
||||||
|
|
||||||
### Q: npm 安装成功但 `gitlink-cli` 提示缺少二进制怎么办?
|
|
||||||
|
|
||||||
先尝试重新安装:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g @gitlink-ai/cli
|
|
||||||
```
|
|
||||||
|
|
||||||
如果仍然失败,请检查 Release 页面是否包含当前平台的资产,例如 Windows x64 对应 `gitlink-cli_<version>_windows_amd64.zip`。也可以从 Release 页面手动下载二进制,或使用 `go install .` 从源码构建。
|
|
||||||
|
|
||||||
### Q: Windows 上凭证存储在哪里?
|
|
||||||
|
|
||||||
gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。
|
|
||||||
|
|
||||||
### Q: 如何查看完整的 API 参考?
|
|
||||||
|
|
||||||
查看 [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md)
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
[MulanPSL-2.0](https://license.coscl.org.cn/MulanPSL2)
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
@ -13,6 +14,12 @@ import (
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// msysPathRe matches Windows drive paths like "D:/something" that MSYS2
|
||||||
|
// auto-converts from Unix paths like "/something". When the api command
|
||||||
|
// receives "/v1/owner/repo", Git Bash on Windows may convert it to
|
||||||
|
// "C:/Program Files/Git/v1/owner/repo". We detect and revert this.
|
||||||
|
var msysPathRe = regexp.MustCompile(`^[A-Za-z]:/`)
|
||||||
|
|
||||||
func NewAPICmd() *cobra.Command {
|
func NewAPICmd() *cobra.Command {
|
||||||
apiCmd := &cobra.Command{
|
apiCmd := &cobra.Command{
|
||||||
Use: "api <METHOD> <PATH>",
|
Use: "api <METHOD> <PATH>",
|
||||||
|
|
@ -36,6 +43,20 @@ func runAPI(c *cobra.Command, args []string) error {
|
||||||
method := strings.ToUpper(args[0])
|
method := strings.ToUpper(args[0])
|
||||||
path := args[1]
|
path := args[1]
|
||||||
|
|
||||||
|
// Fix MSYS2/Git Bash path auto-conversion on Windows.
|
||||||
|
// "/v1/owner/repo" gets converted to "C:/Program Files/Git/v1/owner/repo".
|
||||||
|
// Detect Windows drive letter prefix and strip it, then restore leading "/".
|
||||||
|
if msysPathRe.MatchString(path) {
|
||||||
|
// Find where the original API path starts (after the git install dir)
|
||||||
|
// by looking for common API prefixes like /v1/, /api/, /users/, etc.
|
||||||
|
for _, prefix := range []string{"/v1/", "/v2/", "/api/", "/users/", "/projects/"} {
|
||||||
|
if idx := strings.Index(path, prefix); idx >= 0 {
|
||||||
|
path = path[idx:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(path, "/") {
|
if !strings.HasPrefix(path, "/") {
|
||||||
path = "/" + path
|
path = "/" + path
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
||||||
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
|
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
|
||||||
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
|
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
|
||||||
|
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -47,7 +48,10 @@ var versionCmd = &cobra.Command{
|
||||||
|
|
||||||
func Execute() error {
|
func Execute() error {
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
|
||||||
|
if s := clierrors.FindSuggestion(err); s != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "建议: %s\n", s)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,767 @@
|
||||||
|
# gitlink-review(智能代码审查)Skill 实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 新增一个纯 Markdown skill `gitlink-review`,引导 AI agent 对指定 GitLink PR 做多视角 + 对抗式自检的结构化代码审查,并把报告卡作为评论发布。
|
||||||
|
|
||||||
|
**Architecture:** 交付物是 `skills/gitlink-review/` 下的 SKILL.md + REFERENCE.md + examples/,无 Go 代码。智能来自 agent 按管道执行(取 diff → 降噪 → 6 视角 → 对抗自检 → 综合 → 预览 → 发布)。发布主路径用已验证可用的 `pr +comment`;行内评论为"reviews API 可用时增强"。用"植入缺陷测试 PR"做端到端真实验证。
|
||||||
|
|
||||||
|
**Tech Stack:** Markdown + gitlink-cli(`pr +view` / `pr +files` / `pr +diff` / `pr +comment` / `api POST /pulls/:id/reviews`)。
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前置约束(所有任务适用)
|
||||||
|
|
||||||
|
- **`master` 受保护**:所有改动在特性分支 `feat/gitlink-review-skill` 上提交;测试用 fixture 在独立分支 `test/review-fixture` 上(仅供测试,不合并)。
|
||||||
|
- **平台**:Windows + Git Bash。路径用正斜杠。
|
||||||
|
- **认证**:`gitlink-cli` 已登录(`user +me` 可用)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1:建立特性分支并纳入 spec/plan
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Track: `docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md`
|
||||||
|
- Track: `docs/superpowers/plans/2026-06-18-gitlink-review-skill.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:从最新 master 建分支**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/CWQ98/Desktop/演化与运维/gitlink-cli"
|
||||||
|
git fetch origin
|
||||||
|
git checkout master
|
||||||
|
git pull --ff-only origin master
|
||||||
|
git checkout -b feat/gitlink-review-skill
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:位于 `feat/gitlink-review-skill`,与 master 同步。
|
||||||
|
|
||||||
|
- [ ] **Step 2:确认 CLI 可用**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli version
|
||||||
|
gitlink-cli user +me --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:打印版本;`user +me` 返回 `login: caoweiqiong`。
|
||||||
|
|
||||||
|
- [ ] **Step 3:把 spec 和 plan 纳入版本控制**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md \
|
||||||
|
docs/superpowers/plans/2026-06-18-gitlink-review-skill.md
|
||||||
|
git commit -m "docs(review): 添加 gitlink-review 设计 spec 与实现计划"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2:创建"植入缺陷"测试 PR(端到端验证载体)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create(在 `test/review-fixture` 分支上,不合并):`review_fixture.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1:从 master 建测试分支**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout master
|
||||||
|
git checkout -b test/review-fixture
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:写 fixture 文件(含 4 个植入点)**
|
||||||
|
|
||||||
|
创建 `review_fixture.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
// 本文件为 gitlink-review skill 的测试夹具,故意植入缺陷。DO NOT MERGE。
|
||||||
|
package reviewtest
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
var _ = os.Getenv // 仅占位引入,避免 unused 报错(build ignore 下不影响)
|
||||||
|
|
||||||
|
// User 占位类型
|
||||||
|
type User struct{ Name string }
|
||||||
|
|
||||||
|
// 1. 正确性:空指针未判
|
||||||
|
func CurrentUser(token string) *User {
|
||||||
|
if token == "" {
|
||||||
|
return nil // token 无效返回 nil
|
||||||
|
}
|
||||||
|
return &User{Name: "cwq"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Greet(token string) string {
|
||||||
|
u := CurrentUser(token)
|
||||||
|
return "hello " + u.Name // ← BUG: u 可能为 nil,解引用会 panic
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 安全:硬编码 Token(明显伪造字符串,避免触发真实密钥扫描)
|
||||||
|
var AdminToken = "glpat-FAKEFAKEFAKE0000000000"
|
||||||
|
|
||||||
|
// 3. 测试:新函数 Welcome,本 PR 无对应 _test.go
|
||||||
|
func Welcome(name string) string {
|
||||||
|
if name == "" {
|
||||||
|
return "guest"
|
||||||
|
}
|
||||||
|
return "hi " + name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 伪阳性:看似除零,但调用方 DoMath 已保证除数非 0
|
||||||
|
func Divide(a, b int) int {
|
||||||
|
return a / b
|
||||||
|
}
|
||||||
|
|
||||||
|
func DoMath(x int) int {
|
||||||
|
if x == 0 {
|
||||||
|
return 0 // 上游保证 x != 0
|
||||||
|
}
|
||||||
|
return Divide(100, x) // 因此 Divide 的除零在真实调用路径上是伪阳性
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:提交并推送**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add review_fixture.go
|
||||||
|
git commit -m "test(review): 植入缺陷夹具(正确性/安全/测试/伪阳性)"
|
||||||
|
git push origin test/review-fixture
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4:创建 PR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli pr +create \
|
||||||
|
--owner chroe --repo gitlink-cli \
|
||||||
|
--head test/review-fixture --base master \
|
||||||
|
--title "test: gitlink-review 植入缺陷夹具(请勿合并)" \
|
||||||
|
--body "gitlink-review skill 端到端验证用 PR。含 4 个植入点:空指针、硬编码 Token、缺测试、伪阳性。验证完成后将关闭不合并。" \
|
||||||
|
--format json
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:`pull_request_number` 返回一个数(记为 `<PRN>`,如 3)。记录到 `review_fixture_notes.txt`(本地临时):
|
||||||
|
|
||||||
|
```
|
||||||
|
TEST_PR_NUMBER=<PRN>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5:验证 diff 可取**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli pr +files --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||||
|
gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id <PRN> --format json | head -c 800
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:files 含 `review_fixture.go`;diff 含上述代码片段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3:实测 reviews API(决定行内评论能力)
|
||||||
|
|
||||||
|
**Files:** 无(结论写入 Task 5 的 REFERENCE.md)
|
||||||
|
|
||||||
|
- [ ] **Step 1:在测试 PR 上探测 POST reviews**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli api POST /chroe/gitlink-cli/pulls/<PRN>/reviews \
|
||||||
|
--body '{"body":"gitlink-review probe: 行内评论能力探测","event":"COMMENT"}' 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:判断并记录结论**
|
||||||
|
|
||||||
|
- 若返回 `{"ok":true,...}`(或含 review id)→ **行内评论可用**,记录实际请求体格式。
|
||||||
|
- 若返回 404 / URL 被注入 git exec-path → **不可用(与 triage 记录的 `api POST` bug 一致)**。
|
||||||
|
|
||||||
|
把结论写入本地 `review_api_probe.txt`:
|
||||||
|
|
||||||
|
```
|
||||||
|
REVIEWS_API=<available|unavailable>
|
||||||
|
NOTES=<观察到的响应或错误>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:若可用,再试带行号的行内评论**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli api POST /chroe/gitlink-cli/pulls/<PRN>/reviews \
|
||||||
|
--body '{"event":"COMMENT","body":"行内探测","line":22,"path":"review_fixture.go","side":"RIGHT"}' 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
记录是否支持 `line/path/side`(行内定位)。结果并入 `review_api_probe.txt`。
|
||||||
|
|
||||||
|
> 此 task 无需 commit(结论是数据,将在 Task 5 固化进 REFERENCE.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4:写 SKILL.md(主管道与方法论)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/gitlink-review/SKILL.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:切回特性分支并建目录**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout feat/gitlink-review-skill
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:写 SKILL.md**
|
||||||
|
|
||||||
|
创建 `skills/gitlink-review/SKILL.md`,**完整内容**如下:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
---
|
||||||
|
name: gitlink-review
|
||||||
|
version: 1.0.0
|
||||||
|
description: "智能代码审查:分析 PR diff,多视角评审 + 对抗式自检,输出结构化 Review 意见并作为评论发布。当用户需要审查 GitLink PR、做代码 review、自动生成审查意见时触发。"
|
||||||
|
metadata:
|
||||||
|
requires:
|
||||||
|
bins: ["gitlink-cli"]
|
||||||
|
cliHelp: "gitlink-cli pr --help"
|
||||||
|
---
|
||||||
|
|
||||||
|
# gitlink-review(智能代码审查)
|
||||||
|
|
||||||
|
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||||
|
**CRITICAL — 所有写入操作(发布评论)前默认先预览、确认后再执行;`--auto` 跳过确认但仍受置信度门控。**
|
||||||
|
**CRITICAL — 绝不自动 approve / merge PR。审查只是评论,不做合并决策。**
|
||||||
|
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。**
|
||||||
|
|
||||||
|
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md);详细检查清单与字段映射见 [`REFERENCE.md`](REFERENCE.md)。
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本 Skill 引导 AI 对指定 GitLink PR 做结构化、多视角、低误报的代码审查,并把"报告卡"作为评论发布。核心特点:
|
||||||
|
|
||||||
|
1. **多视角评审团**:6 个视角并行扫 diff
|
||||||
|
2. **对抗式自检**:每条候选发现先自我反驳,成立的才留下(直击 AI 审查误报老大难)
|
||||||
|
3. **降噪**:自动跳过生成代码 / vendor / lock / 纯重命名
|
||||||
|
4. **可执行**:每条发现带 `file:line` + 修复建议 + 理由
|
||||||
|
|
||||||
|
## 命令接口(skill 约定参数,非 gitlink-cli 新增 flag)
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--owner/--repo` | 自动从 cwd 解析 | 目标仓库 |
|
||||||
|
| `--id` | 必填 | PR 编号(`pull_request_number`,网页 `/pulls/N`) |
|
||||||
|
| `--lenses` | 全 6 视角 | 子集,如 `correctness,security` |
|
||||||
|
| `--auto` | 关 | 跳过预览确认直接发布(仍受置信度门控) |
|
||||||
|
| `--inline` | 关 | 尝试附行内评论(reviews API 可用时) |
|
||||||
|
| `--max-findings` | 12 | 报告卡上限 |
|
||||||
|
| `--refresh` | 关 | 即使存在旧审查哨兵也重审并更新 |
|
||||||
|
|
||||||
|
## 管道(8 步)
|
||||||
|
|
||||||
|
### ① 取上下文
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli pr +view --owner <owner> --repo <repo> --id <N> --format json # 元数据
|
||||||
|
gitlink-cli pr +files --owner <owner> --repo <repo> --id <N> --format json # 文件 +/-
|
||||||
|
gitlink-cli pr +diff --owner <owner> --repo <repo> --id <N> --format json # 核心:diff 内容
|
||||||
|
gitlink-cli repo +info --owner <owner> --repo <repo> --format json # 语言校准
|
||||||
|
```
|
||||||
|
|
||||||
|
### ② 降噪过滤
|
||||||
|
|
||||||
|
按下述"降噪规则"跳过噪音文件;在报告卡"范围"行注明跳过数量,不静默吞掉。
|
||||||
|
|
||||||
|
### ③ 多视角分析
|
||||||
|
|
||||||
|
对每个启用的视角扫 diff,产出候选发现,每条含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "lens": "correctness", "severity": "high", "likelihood": "likely",
|
||||||
|
"file": "auth/login.go", "line": 42, "what": "空指针未判",
|
||||||
|
"why": "...", "fix": "...", "confidence": "high" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ④ 对抗式自检(质量门)
|
||||||
|
|
||||||
|
逐条自问(任一成立即丢弃或降级):
|
||||||
|
|
||||||
|
1. 语境里是否已有保护(外层判空、上游校验、框架机制)?
|
||||||
|
2. 是真缺陷还是风格偏好?偏好 → 降级 🔵 nit 或丢弃。
|
||||||
|
3. 引用的 API/签名/语言行为是否真实存在?(不确定则不报)
|
||||||
|
4. 是否与另一视角重复?
|
||||||
|
|
||||||
|
**置信度门控**:`confidence=low` 且 `severity≠high` → 丢弃。
|
||||||
|
|
||||||
|
### ⑤ 综合
|
||||||
|
|
||||||
|
跨视角去重 → 按 `严重度×likelihood` 排序 → 封顶 `--max-findings`。
|
||||||
|
|
||||||
|
### ⑥ 预览(默认)
|
||||||
|
|
||||||
|
展示报告卡给用户;确认后发布。`--auto` 跳过。
|
||||||
|
|
||||||
|
### ⑦ 发布
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli pr +comment --owner <owner> --repo <repo> --id <N> \
|
||||||
|
--body "<报告卡全文,含哨兵>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--inline` 且 reviews API 可用 → 对 🔴/🟡 发现附行内评论(见 REFERENCE.md 的 API 结论)。
|
||||||
|
|
||||||
|
### ⑧ 幂等
|
||||||
|
|
||||||
|
发布前检查该 PR 是否已有哨兵 `<!-- gitlink-review v1 ... -->`;有则默认提议"更新"(`--refresh` 才覆盖)。
|
||||||
|
|
||||||
|
## 评审团 6 视角
|
||||||
|
|
||||||
|
| 视角 | 盯什么 |
|
||||||
|
|------|--------|
|
||||||
|
| 🔴 正确性 (correctness) | 逻辑错、边界、空值、并发竞态、资源泄漏、错误处理、类型转换 |
|
||||||
|
| 🔒 安全 (security) | 注入(SQL/命令/XSS)、鉴权越权、密钥/Token 泄露、路径穿越、不安全反序列化、弱加密 |
|
||||||
|
| ⚡ 性能 (performance) | N+1 查询、无谓拷贝、O(n²)/嵌套循环、热路径分配、缺索引 |
|
||||||
|
| 🧪 测试 (tests) | 新增/改动代码有无测试、边界用例、断言有效性 |
|
||||||
|
| 🧹 可维护性 (maintainability) | 命名、重复、圈复杂度、抽象边界、与既有约定一致 |
|
||||||
|
| 🚨 生产风险 (prod-risk) | "合并后凌晨3点哪会炸"——可观测性/日志、回滚、破坏性变更、配置依赖、降级 |
|
||||||
|
|
||||||
|
> 各视角的展开检查清单见 [`REFERENCE.md`](REFERENCE.md)。
|
||||||
|
|
||||||
|
## 报告卡格式(作为评论发布)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
🤖 **gitlink-review 报告**
|
||||||
|
|
||||||
|
**结论**:<总体:✅LGTM / ⚠️建议修改 / 🛑有阻塞>(A 🔴阻塞 / B 🟡建议 / C 🔵nit)
|
||||||
|
**范围**:<X> 文件,+<A>/-<D>(跳过 <Y> 噪音文件)|视角:<L>|自检丢弃:<R>
|
||||||
|
|
||||||
|
| 严重度 | 视角 | 位置 | 问题 |
|
||||||
|
|:---:|---|---|---|
|
||||||
|
| 🔴 | correctness | path/file.go:42 | <一句话> |
|
||||||
|
|
||||||
|
### 🔴 阻塞(合并前需处理)
|
||||||
|
1. **[correctness] path/file.go:42** — <what>
|
||||||
|
- **为什么**:<why>
|
||||||
|
- **建议**:<fix>
|
||||||
|
|
||||||
|
### 🟡 建议
|
||||||
|
…
|
||||||
|
|
||||||
|
### 🔵 nit
|
||||||
|
…
|
||||||
|
|
||||||
|
### ✅ 未发现问题的方面
|
||||||
|
- <视角>:<结论>
|
||||||
|
|
||||||
|
---
|
||||||
|
<!-- gitlink-review v1 | pr:<N> | lenses:<L> | refuted:<R> | sha:<head-sha> -->
|
||||||
|
*由 gitlink-review skill 生成。*
|
||||||
|
```
|
||||||
|
|
||||||
|
## 降噪规则(自动跳过)
|
||||||
|
|
||||||
|
生成代码(`*.gen.go`/`*.pb.go`/`*_generated.*`/`*.min.js`/dist//build//target/)、第三方(vendor//third_party//node_modules/)、锁文件(go.sum/package-lock.json/yarn.lock/pnpm-lock.yaml/Cargo.lock)、纯重命名/移动、二进制/图片/字体。
|
||||||
|
|
||||||
|
## 安全护栏
|
||||||
|
|
||||||
|
- 默认预览确认;`--auto` 跳过但仍受置信度门控
|
||||||
|
- **绝不自动 approve / merge**
|
||||||
|
- 行内评论仅 `--inline` 且 reviews API 可用时发
|
||||||
|
- `--max-findings` 封顶防刷屏
|
||||||
|
- 评论失败 → 把报告卡原文交用户手动粘贴
|
||||||
|
|
||||||
|
## 错误处理与降级
|
||||||
|
|
||||||
|
| 情况 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| 无 diff | 报"无可审查变更",不发评论 |
|
||||||
|
| PR 不存在/无权限 | 清晰错误,不发评论 |
|
||||||
|
| reviews API 404 | 跳过行内,总结评论照发 |
|
||||||
|
| diff 过大 | 抽样 + 标注"部分审查(仅 X/Y 文件)" |
|
||||||
|
| 评论发布失败 | 输出报告卡原文供手动粘贴 |
|
||||||
|
| 全部 low 置信 | 报"未发现高置信问题",列待人工确认项 |
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
- **先预览再发布**(默认),降低对外噪音
|
||||||
|
- **置信度优先**:宁可少报,不要误报
|
||||||
|
- **可执行**:每条发现必须带"建议修复"
|
||||||
|
- **不越权**:只评论,不合并
|
||||||
|
````
|
||||||
|
|
||||||
|
- [ ] **Step 3:提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-review/SKILL.md
|
||||||
|
git commit -m "feat(review): 新增 gitlink-review SKILL.md(管道+6视角+对抗自检+报告卡)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5:写 REFERENCE.md(清单 + 评级 + API 结论)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/gitlink-review/REFERENCE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:读 Task 3 的探测结论**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat review_api_probe.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
把 `<REVIEWS_API>` 与 `<REVIEWS_NOTES>`(下文占位)替换为真实结论。
|
||||||
|
|
||||||
|
- [ ] **Step 2:写 REFERENCE.md**
|
||||||
|
|
||||||
|
创建 `skills/gitlink-review/REFERENCE.md`,完整内容(**把 `<REVIEWS_API>` 等占位替换为 Task 3 实测结果**):
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
# gitlink-review 参考手册
|
||||||
|
|
||||||
|
> 各视角检查清单、严重度评级、降噪规则、reviews API 实测结论、字段映射。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、各视角检查清单
|
||||||
|
|
||||||
|
### 🔴 正确性 (correctness)
|
||||||
|
- 空值/nil 解引用未判
|
||||||
|
- 边界:off-by-one、数组越界、空集合
|
||||||
|
- 错误未处理或被吞(`err != nil` 缺失、`_ = err`)
|
||||||
|
- 并发:数据竞态、缺锁、死锁
|
||||||
|
- 资源泄漏:文件/连接/goroutine 未关闭或回收
|
||||||
|
- 类型转换/断言未检查
|
||||||
|
- 逻辑分支遗漏、return 路径不全
|
||||||
|
|
||||||
|
### 🔒 安全 (security)
|
||||||
|
- 注入:SQL 拼接、命令、XSS、模板未转义
|
||||||
|
- 鉴权/越权:缺权限校验、IDOR
|
||||||
|
- 密钥/Token 硬编码或写入日志
|
||||||
|
- 路径穿越(`../`、用户输入拼路径)
|
||||||
|
- 不安全反序列化、弱加密/弱随机
|
||||||
|
- 危险默认值(debug 开关、CORS *)
|
||||||
|
|
||||||
|
### ⚡ 性能 (performance)
|
||||||
|
- N+1 查询、循环内 IO/查询
|
||||||
|
- 无谓拷贝大对象、字符串反复拼接
|
||||||
|
- O(n²)/深层嵌套循环
|
||||||
|
- 热路径分配、缺缓存
|
||||||
|
- 缺索引/全表扫描
|
||||||
|
|
||||||
|
### 🧪 测试 (tests)
|
||||||
|
- 新增/改动函数有无对应测试
|
||||||
|
- 边界与异常用例覆盖
|
||||||
|
- 断言是否有效(非 `assert true`)
|
||||||
|
- mock 是否合理、是否过度
|
||||||
|
|
||||||
|
### 🧹 可维护性 (maintainability)
|
||||||
|
- 命名是否达意
|
||||||
|
- 重复代码(DRY)
|
||||||
|
- 圈复杂度过高、函数过长
|
||||||
|
- 抽象边界模糊、职责混杂
|
||||||
|
- 与既有代码风格/约定不一致
|
||||||
|
|
||||||
|
### 🚨 生产风险 (prod-risk)
|
||||||
|
- 破坏性变更(API/DB schema/配置格式)
|
||||||
|
- 可观测性:关键路径有无日志/指标
|
||||||
|
- 回滚能力:是否可安全回退
|
||||||
|
- 配置/环境依赖、启动顺序
|
||||||
|
- 降级与限流缺失
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、严重度 × likelihood 评级
|
||||||
|
|
||||||
|
| severity | 含义 | 处理 |
|
||||||
|
|----------|------|------|
|
||||||
|
| 🔴 high | 阻塞:会导致 bug/安全问题/线上故障 | 合并前需处理 |
|
||||||
|
| 🟡 medium | 建议:应修复但不强制阻塞 | 建议处理 |
|
||||||
|
| 🔵 low | nit:风格/可读性 | 可选 |
|
||||||
|
|
||||||
|
| likelihood | 含义 |
|
||||||
|
|------------|------|
|
||||||
|
| likely | 真实路径上会发生 |
|
||||||
|
| possible | 特定条件下发生 |
|
||||||
|
| unlikely | 罕见但可能 |
|
||||||
|
|
||||||
|
排序权重:`high×likely` > `high×possible` > `medium×likely` > …。
|
||||||
|
|
||||||
|
置信度 `confidence`:high/medium/low。**门控**:`confidence=low && severity≠high → 丢弃`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、降噪规则
|
||||||
|
|
||||||
|
跳过:`*.gen.go`、`*.pb.go`、`*_generated.*`、`*.min.js`、`dist/`、`build/`、`target/`、`vendor/`、`third_party/`、`node_modules/`、`go.sum`、`package-lock.json`、`yarn.lock`、`pnpm-lock.yaml`、`Cargo.lock`、纯重命名/移动、二进制/图片/字体。跳过时在报告卡"范围"行计数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、reviews API 实测结论(决定行内评论)
|
||||||
|
|
||||||
|
**实测命令**(于测试 PR `<PRN>`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli api POST /<owner>/<repo>/pulls/<N>/reviews \
|
||||||
|
--body '{"body":"...","event":"COMMENT"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**结论**:<REVIEWS_API>(可用 / 不可用)
|
||||||
|
|
||||||
|
<REVIEWS_NOTES>(实测观察到的响应或错误)
|
||||||
|
|
||||||
|
**设计影响**:
|
||||||
|
- 若 **不可用**(如返回 404 / git exec-path 注入 URL)→ 行内评论关闭,仅用 `pr +comment` 发总结评论。`--inline` 被忽略并提示原因。
|
||||||
|
- 若 **可用** → `--inline` 时对 🔴/🟡 发现调用上述 API 发行内评论;请求体按实测支持的格式(是否含 `line/path/side`)构造。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、字段映射
|
||||||
|
|
||||||
|
### pr +view 关键字段
|
||||||
|
|
||||||
|
| 字段 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `issue.subject` / `issue.description` | 理解 PR 意图,校准审查重点 |
|
||||||
|
| `pull_request.base` / `pull_request.head` | 目标/源分支 |
|
||||||
|
| `author.login` | 作者(自审盲区提示) |
|
||||||
|
| `files_count` / `commits_count` | 范围概览 |
|
||||||
|
|
||||||
|
### pr +diff 关键字段
|
||||||
|
|
||||||
|
| 字段 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `files[].name` | 文件路径 |
|
||||||
|
| `files[].addition` / `deletion` | 增删行数 |
|
||||||
|
| `files[].sha` | 文件 sha(哨兵可选) |
|
||||||
|
| diff 正文 | 分析输入 |
|
||||||
|
|
||||||
|
**行号映射规则**:报告卡里的 `file:line` 用**文件中的实际行号**(非 diff hunks 的 `+n` 相对行号)。从 diff hunk 头(`@@ -a,b +c,d @@`)推算:实际行号 = `c + (hunk 内相对行)`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、幂等哨兵
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- gitlink-review v1 | pr:<N> | lenses:<L> | refuted:<R> | sha:<head-sha> -->
|
||||||
|
```
|
||||||
|
|
||||||
|
- 发布前用 `pr +view`(或取评论)检查是否已含该哨兵
|
||||||
|
- `sha` = 审查所基于的 head commit;PR 有新提交时提示"审查已过期,建议重审"
|
||||||
|
- 默认不覆盖;`--refresh` 才重发
|
||||||
|
````
|
||||||
|
|
||||||
|
- [ ] **Step 3:提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-review/REFERENCE.md
|
||||||
|
git commit -m "docs(review): 新增 REFERENCE.md(视角清单+评级+API实测结论+字段映射)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6:端到端验证——对测试 PR 跑审查
|
||||||
|
|
||||||
|
**Files:** 无(验证 + 捕获输出)
|
||||||
|
|
||||||
|
- [ ] **Step 1:按 SKILL.md 对 `<PRN>` 执行审查**
|
||||||
|
|
||||||
|
人工/agent 依 SKILL.md 管道执行:取 view/files/diff → 降噪 → 6 视角 → 对抗自检 → 综合 → 生成报告卡。把生成的报告卡全文存到本地 `review_output_<PRN>.md`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli pr +view --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||||
|
gitlink-cli pr +files --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||||
|
gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:验证 4 个植入点**
|
||||||
|
|
||||||
|
断言(必须全部满足,否则回改 SKILL.md/REFERENCE.md 后重跑):
|
||||||
|
|
||||||
|
| # | 植入点 | 期望 | 校验 |
|
||||||
|
|---|--------|------|------|
|
||||||
|
| 1 | `Greet` 空指针 | 🔴 correctness 命中,含 `review_fixture.go:<行>` 与 `u.Name` | 报告卡中能找到 |
|
||||||
|
| 2 | `AdminToken` 硬编码 | 🔒 security 命中 | 报告卡中能找到 |
|
||||||
|
| 3 | `Welcome` 缺测试 | 🧪 tests 命中(无 _test.go) | 报告卡中能找到 |
|
||||||
|
| 4 | `Divide` 除零(DoMath 已保护) | **被对抗自检丢弃**,`refuted` 计数 ≥1,**不应**出现在发现列表 | 报告卡中无此项 |
|
||||||
|
|
||||||
|
- [ ] **Step 3:发布到测试 PR 并验证哨兵**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli pr +comment --owner chroe --repo gitlink-cli --id <PRN> \
|
||||||
|
--body "$(cat review_output_<PRN>.md)" --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:`ok: true`。再 `pr +view` 确认 `comments_count` 增加。
|
||||||
|
|
||||||
|
- [ ] **Step 4:若验证不通过,回改并重跑**
|
||||||
|
|
||||||
|
任何断言失败 → 修正 SKILL.md 的检查清单/自检规则 → 重新执行 Step 1-3,直到 4 项全过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7:写 examples/review-workflow.md(真实走查)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/gitlink-review/examples/review-workflow.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:基于 Task 6 的真实输出写示例**
|
||||||
|
|
||||||
|
创建 `skills/gitlink-review/examples/review-workflow.md`:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
# 示例:对植入缺陷 PR 的智能审查(真实数据)
|
||||||
|
|
||||||
|
> 基于 `chroe/gitlink-cli` 测试 PR #<PRN>(`test/review-fixture`)于 2026-06-18 实际执行。
|
||||||
|
> 该 PR 故意植入 4 个问题用于验证 gitlink-review skill。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1:取上下文
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli pr +view --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||||
|
gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
**范围**:1 文件 `review_fixture.go`,+38/-0,无噪音文件需跳过。
|
||||||
|
|
||||||
|
## Step 2:6 视角扫描 + 对抗自检(关键过程)
|
||||||
|
|
||||||
|
| 候选发现 | 视角 | 自检结果 |
|
||||||
|
|----------|------|----------|
|
||||||
|
| `Greet` 中 `u.Name` 解引用 nil | correctness | ✅ 成立(无外层判空)→ 保留 🔴 |
|
||||||
|
| `AdminToken` 硬编码 | security | ✅ 成立 → 保留 🔴 |
|
||||||
|
| `Welcome` 无对应测试 | tests | ✅ 成立(本 PR 无 _test.go)→ 保留 🟡 |
|
||||||
|
| `Divide(a,b)` 除零 | correctness | ❌ **被反驳**:`DoMath` 已保证 `x≠0`,真实调用路径除数非 0 → 丢弃 |
|
||||||
|
|
||||||
|
**自检丢弃:1 条(refuted=1)**。
|
||||||
|
|
||||||
|
## Step 3:发布报告卡(真实输出)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli pr +comment --owner chroe --repo gitlink-cli --id <PRN> \
|
||||||
|
--body "<报告卡全文>"
|
||||||
|
```
|
||||||
|
|
||||||
|
<此处粘贴 Task 6 生成的真实报告卡全文>
|
||||||
|
|
||||||
|
**发布结果**:`ok: true`,PR 评论数 +1。
|
||||||
|
|
||||||
|
## Step 4:幂等验证
|
||||||
|
|
||||||
|
再次运行(不带 `--refresh`)→ 检测到哨兵 `<!-- gitlink-review v1 | pr:<PRN> | ... -->` → 提示"已存在审查,使用 --refresh 更新",未重复发布。
|
||||||
|
|
||||||
|
## 关键结论
|
||||||
|
|
||||||
|
- 3 个真实缺陷被对应视角命中,1 个伪阳性被对抗自检丢弃(refuted=1)
|
||||||
|
- 行内评论:<根据 Task 3 结论写"可用已附行内" 或 "API 不可用,仅总结评论">
|
||||||
|
````
|
||||||
|
|
||||||
|
- [ ] **Step 2:提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-review/examples/review-workflow.md
|
||||||
|
git commit -m "docs(review): 新增真实走查示例 review-workflow.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8:更新 README 与 workflow 链接
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/README.md`
|
||||||
|
- Modify: `skills/gitlink-workflow/SKILL.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:README 智能 skill 表加一行**
|
||||||
|
|
||||||
|
在 `skills/README.md` 的"智能 Skills"表追加:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| **gitlink-review** | 智能代码审查 | 分析 PR diff,多视角评审 + 对抗式自检,结构化 Review 意见自动评论 |
|
||||||
|
```
|
||||||
|
|
||||||
|
并在文档导航/按需处补一行指向 `gitlink-review/SKILL.md`。
|
||||||
|
|
||||||
|
- [ ] **Step 2:workflow 的浅层 Code Review 链回本 skill**
|
||||||
|
|
||||||
|
在 `skills/gitlink-workflow/SKILL.md` 的"AI 在 PR 流程中的角色 → Code Review"处补一句:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
> 深度代码审查请使用 [`../gitlink-review/SKILL.md`](../gitlink-review/SKILL.md)(多视角 + 对抗式自检 + 自动评论)。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/README.md skills/gitlink-workflow/SKILL.md
|
||||||
|
git commit -m "docs(review): README 智能表登记 gitlink-review,workflow 链回深度审查"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9:清理测试 PR + 推送特性分支 + 建 PR
|
||||||
|
|
||||||
|
**Files:** 无
|
||||||
|
|
||||||
|
- [ ] **Step 1:关闭测试 PR(不合并)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli pr +close --owner chroe --repo gitlink-cli --id <PRN>
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:`ok: true`,PR 状态变 closed(未合并,植入缺陷不进 master)。
|
||||||
|
|
||||||
|
- [ ] **Step 2:删本地/远端测试分支**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git branch -D test/review-fixture
|
||||||
|
git push origin --delete test/review-fixture
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:清理本地临时文件**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -f review_fixture_notes.txt review_api_probe.txt review_output_*.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4:推送特性分支**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout feat/gitlink-review-skill
|
||||||
|
git push origin feat/gitlink-review-skill
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5:创建合并到 master 的 PR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli pr +create \
|
||||||
|
--owner chroe --repo gitlink-cli \
|
||||||
|
--head feat/gitlink-review-skill --base master \
|
||||||
|
--title "feat: 新增 gitlink-review 智能代码审查 Skill" \
|
||||||
|
--body "## 目的
|
||||||
|
子任务二核心场景:智能代码审查(分析 PR diff,多视角+对抗自检,结构化 Review 自动评论)。
|
||||||
|
|
||||||
|
## 交付
|
||||||
|
- skills/gitlink-review/:SKILL.md + REFERENCE.md + examples/review-workflow.md
|
||||||
|
- skills/README.md、skills/gitlink-workflow/SKILL.md:登记与链接
|
||||||
|
- docs/superpowers/:设计 spec + 实现计划
|
||||||
|
|
||||||
|
## 验证(真实数据)
|
||||||
|
植入缺陷测试 PR 已验证:3 个真实缺陷被对应视角命中,1 个伪阳性被对抗自检丢弃(refuted=1),哨兵幂等,pr +comment 发布成功。reviews API:<填 Task 3 结论>。
|
||||||
|
|
||||||
|
## 设计要点
|
||||||
|
- 多视角评审团(正确性/安全/性能/测试/可维护/生产风险)
|
||||||
|
- 对抗式自检质量门(直击误报)
|
||||||
|
- 默认预览确认,绝不自动合并" \
|
||||||
|
--format json
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6:记录 PR 号并通知用户**
|
||||||
|
|
||||||
|
把返回的 `pull_request_number` 报告给用户,等待其合并(master 受保护)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收(全部满足才算完成)
|
||||||
|
|
||||||
|
- [ ] `skills/gitlink-review/` 三件套齐全,结构与 triage/health 一致
|
||||||
|
- [ ] Task 6 四个植入点断言全过(3 命中 + 1 丢弃)
|
||||||
|
- [ ] REFERENCE.md 的 reviews API 结论为实测结果(非猜测)
|
||||||
|
- [ ] 报告卡含哨兵,幂等生效
|
||||||
|
- [ ] README 与 workflow 链接已更新
|
||||||
|
- [ ] 测试 PR 已关闭未合并,测试分支已删
|
||||||
|
- [ ] 特性分支已推送,PR 已创建
|
||||||
|
|
@ -0,0 +1,638 @@
|
||||||
|
# gitlink-research-fair Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a `gitlink-research-fair` Claude Code Skill that turns any GitLink research repo into a FAIR 体检报告卡 + auto-remediation PR + reproducibility certificate.
|
||||||
|
|
||||||
|
**Architecture:** Methodology-type Skill (pure Markdown + gitlink-cli calls, no Go). Report-card-centric: M1 FAIR Checker (5-axis rubric, reuses gitlink-license for license/security) + M2 KG + M3 OpenAlex provenance + M4 Remediation. Validated end-to-end on `songhui18/ICCV2021论文复现`.
|
||||||
|
|
||||||
|
**Tech Stack:** Markdown (SKILL.md/REFERENCE.md/examples), gitlink-cli (Go CLI, source build), Mermaid (KG viz), OpenAlex REST (optional enrichment).
|
||||||
|
|
||||||
|
**Source of truth:** `docs/superpowers/specs/2026-07-01-gitlink-research-fair-design.md` (read it first).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment & Gotchas (engineer must know)
|
||||||
|
|
||||||
|
- **Branch:** already on `feat/gitlink-research-fair` (master is protected — never push master). Commit only your own files; leave pre-existing `D README_TASKB.md` / `D gitlink-cli.exe` / `?? dist/` untouched.
|
||||||
|
- **Binary:** use the go/bin source build. Verify: `gitlink-cli version` → `gitlink-cli dev`, and `gitlink-cli search --help` shows `+repos`. If commands like `file`/`member` are missing, run `go build -o gitlink-cli.exe .` in the repo root and use `./gitlink-cli.exe`.
|
||||||
|
- **Encoding (Windows):** gitlink-cli JSON contains Chinese. When parsing with Python, always set `PYTHONUTF8=1 PYTHONIOENCODING=utf-8`. Never inline Chinese inside `python -c "..."` on this shell (GBK-mangles it) — write a `.py` file instead.
|
||||||
|
- **Known GitLink CLI bug:** `file +tree --recursive true` returns empty on some repos. Workaround: `file +tree` top-level → `file +get --path <subdir>` recursively, or use `file +list`.
|
||||||
|
- **All examples use `--owner songhui18 --repo ICCV2021论文复现`** unless noted. 处方 (open PR) needs write access → **fork first** (`gitlink-cli repo +fork`); if fork/push fails, fall back to `--no-fork` (write materials to local files).
|
||||||
|
- **Verification commands** use `grep` (available in Git Bash). Expected outputs are illustrative; the check is "contains the key string".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `skills/gitlink-research-fair/SKILL.md` | Entry doc: frontmatter, CRITICAL headers, command interface, 8-step pipeline, 5-axis rubric, report card template, remediation guardrails, certificate, KG/OpenAlex, error table |
|
||||||
|
| `skills/gitlink-research-fair/REFERENCE.md` | Deep reference: FAIR4RS per-principle mapping, scoring algorithm, KG schema (triples), OpenAlex fields, SWH-ID notes, **academic citation library**, honesty caveats (incl. 2 banned claims), live-probe findings |
|
||||||
|
| `skills/gitlink-research-fair/examples/research-fair-workflow.md` | Real walkthrough on songhui18: report card + remediation PR + certificate, with contrast run on a licensed repo |
|
||||||
|
| `skills/gitlink-research-fair/examples/img/` | Screenshots (GitLink web: opened PR) — optional but recommended for deliverable |
|
||||||
|
| `skills/README.md` | Add gitlink-research-fair row (科研辅助类) |
|
||||||
|
| `skills/gitlink-workflow/SKILL.md` | Optional cross-link |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Scaffold SKILL.md (frontmatter + CRITICAL headers)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/gitlink-research-fair/SKILL.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create SKILL.md with frontmatter and CRITICAL headers**
|
||||||
|
|
||||||
|
Content (exact):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: gitlink-research-fair
|
||||||
|
version: 1.0.0
|
||||||
|
description: "科研软件 FAIR 体检:分析 GitLink 科研仓库的可发现/可访问/可互操作/可复用/可复现性,输出体检报告卡,自动开 PR 修复缺口并签发 SWH/commit 锚定的可复现证书。当用户需要评估科研仓库的 FAIR 性与可复现性、生成科研软件体检报告时触发。"
|
||||||
|
metadata:
|
||||||
|
requires:
|
||||||
|
bins: ["gitlink-cli"]
|
||||||
|
cliHelp: "gitlink-cli repo --help"
|
||||||
|
---
|
||||||
|
|
||||||
|
# gitlink-research-fair(科研软件 FAIR 体检)
|
||||||
|
|
||||||
|
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||||
|
**CRITICAL — 所有写操作(开修复 PR)默认先预览、确认后再执行;`--auto` 跳过预览但仍受护栏约束。**
|
||||||
|
**CRITICAL — 绝不自动 merge;绝不 force-push;处方只对 fork 开 PR,绝不碰原仓库。**
|
||||||
|
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。**
|
||||||
|
|
||||||
|
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md);详细检查清单、评分算法、KG schema 与学术引用见 [`REFERENCE.md`](REFERENCE.md)。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify frontmatter + CRITICAL lines**
|
||||||
|
|
||||||
|
Run: `head -20 skills/gitlink-research-fair/SKILL.md | grep -c -e "name: gitlink-research-fair" -e "description:" -e "CRITICAL"`
|
||||||
|
Expected: `6` (1 `name:` + 1 `description:` + 4 `CRITICAL` lines). If `<6`, fix.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/SKILL.md
|
||||||
|
git commit -m "feat(research-fair): scaffold SKILL.md(frontmatter + CRITICAL 头)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: SKILL.md — 命令接口 + 8 步管道
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/gitlink-research-fair/SKILL.md` (append after the 前置条件 line)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append 概述 + 命令接口 + 8 步管道 sections**
|
||||||
|
|
||||||
|
Append (exact content):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本 Skill 引导 AI 对任意 GitLink 科研仓库做结构化、有学术标准依据、低误报的"FAIR + 可复现性"诊断,产出**体检报告卡**,并对缺口**自动开 PR 修复**、用 **SWH/commit 锚定**签发**可复现证书**。许可证/安全子项**复用 `gitlink-license`**。四幕剧本(v1):诊断 → 处方 → 证书。
|
||||||
|
|
||||||
|
## 命令接口(skill 约定参数,非 CLI flag)
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--owner/--repo` | 自动从 cwd 解析 | 目标科研仓库 |
|
||||||
|
| `--lenses` | 全 5 轴 | 子集,如 `F,A,Repro` |
|
||||||
|
| `--auto` | 关 | 跳过预览直接开 PR(仍受护栏) |
|
||||||
|
| `--no-fork` | 关 | 不开 PR,处方物料落本地 |
|
||||||
|
| `--enrich` | 开 | 启用 OpenAlex 溯源(限流自动降级) |
|
||||||
|
| `--refresh` | 关 | 即使有旧报告哨兵也重评 |
|
||||||
|
|
||||||
|
## 管道(8 步)
|
||||||
|
|
||||||
|
### ① 取上下文
|
||||||
|
`repo +info`(元数据/license/topics/has_dataset)· `file +list`(关键文件清单)
|
||||||
|
|
||||||
|
### ② 深采
|
||||||
|
`file +get` 读 LICENSE/CITATION.cff/codemeta.json/README/requirements/Dockerfile · `release +list`(版本)· `commit +list`(SHA)· `issue/pr +list`(协作)· `member +list`(作者)
|
||||||
|
|
||||||
|
### ③ FAIR 评分(M1)
|
||||||
|
逐项评 5 轴(许可证/安全子项委托 `gitlink-license` 流程)→ `✓/✗/⚠/⊥` + 证据 + 置信度
|
||||||
|
|
||||||
|
### ④ KG 构建(M2)
|
||||||
|
四实体(Repo/Contributor/File/Commit)+Paper/Dataset/License 节点 → Mermaid 小图 + triples JSON
|
||||||
|
|
||||||
|
### ⑤ 溯源富集(M3)
|
||||||
|
从 README/CITATION 抽论文 → OpenAlex REST 反查作者/机构(best-effort)
|
||||||
|
|
||||||
|
### ⑥ 渲染报告卡(hero)
|
||||||
|
等级 + 雷达图 + 5 轴逐项表 + 处方摘要 + 证书栏 + KG 小图 + 溯源栏
|
||||||
|
|
||||||
|
### ⑦ 处方(M4)
|
||||||
|
对 `✗/⚠` 项生成 CITATION.cff/codemeta.json/LICENSE/复现章节 → 默认预览
|
||||||
|
|
||||||
|
### ⑧ 发布
|
||||||
|
确认后对 fork 开 PR(PR body 带报告卡摘要)+ 出 SWH/commit 证书;内嵌哨兵
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the pipeline + interface landed**
|
||||||
|
|
||||||
|
Run: `grep -c -e "管道(8 步)" -e "命令接口" -e "### ⑧ 发布" skills/gitlink-research-fair/SKILL.md`
|
||||||
|
Expected: `3`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/SKILL.md
|
||||||
|
git commit -m "feat(research-fair): SKILL 命令接口 + 8 步管道"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: SKILL.md — 5 轴 rubric 表
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/gitlink-research-fair/SKILL.md` (append after 管道)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append the 5-axis rubric**
|
||||||
|
|
||||||
|
Append (exact):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 5 轴评分 Rubric
|
||||||
|
|
||||||
|
> 对标 **FAIR4RS(Nature Sci Data 2022)+ howfairis 5 维**。诚实声明:这是**适配版**评分(社区尚无认证级自动校验器),不是官方认证。**FAIR ≠ 可复现**,故 Repro 单独成轴。逐条映射与证据见 [`REFERENCE.md`](REFERENCE.md)。
|
||||||
|
|
||||||
|
每项判定 `✓满足 / ✗缺失 / ⚠部分 / ⊥GitLink 无该字段(跳过并标注)`,附证据来源 + 置信度。
|
||||||
|
|
||||||
|
| 轴 | FAIR4RS 映射 | 检查项(证据) |
|
||||||
|
|----|--------------|----------------|
|
||||||
|
| **F 可发现** | F1/F2 | F1 清晰描述(repo desc ≥20 字) · F2 话题/关键词(topics) · F3 标准元数据(codemeta.json/.zenodo.json) |
|
||||||
|
| **A 可访问** | F1.2/R1.1 | A1 公开(is_public) · A2 有许可证(license_id/LICENSE)[gitlink-license] · A3 有版本发布(release tag) · A4 持久标识(commit SHA/SWH-ID) |
|
||||||
|
| **I 可互操作** | R1.1/I | I1 依赖清单(requirements/go.mod/package.json/environment.yml) · I2 许可证机器可读(SPDX/license_id) · I3 元数据标准格式(CITATION.cff/codemeta) |
|
||||||
|
| **R 可复用** | R1.2/R2 | R1 溯源/引用(CITATION.cff/README 引用) · R2 README 质量(安装+使用) · R3 源码声明[gitlink-license] · R4 无敏感泄露[gitlink-license] |
|
||||||
|
| **Repro 可复现** ⭐差异轴 | (独立于 FAIR) | Rep1 环境锁(锁版本/Dockerfile) · Rep2 数据集说明(has_dataset/README 引用) · Rep3 复现步骤(README 章节) · Rep4 版本固定 · Rep5 入口可执行(main/Makefile/CLI) |
|
||||||
|
|
||||||
|
**评分**:轴分 = 满足项 / (总项 − ⊥项)(⚠ 计 0.5);总评 = 5 轴均分(某轴全 ⊥ 则记 N/A 并排除);等级 **A🟢≥80 / B🟡60-79 / C🔴<60**。`confidence=low` 标"待人工确认",不进总评。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify all 5 axes + scoring present**
|
||||||
|
|
||||||
|
Run: `grep -c -e "F 可发现" -e "A 可访问" -e "I 可互操作" -e "R 可复用" -e "Repro 可复现" -e "A🟢≥80" skills/gitlink-research-fair/SKILL.md`
|
||||||
|
Expected: `6`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/SKILL.md
|
||||||
|
git commit -m "feat(research-fair): SKILL 5 轴 FAIR rubric 表"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: SKILL.md — 报告卡模板 + 处方护栏 + 证书 + KG/OpenAlex + 错误降级
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/gitlink-research-fair/SKILL.md` (append after rubric)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append report card template**
|
||||||
|
|
||||||
|
Append (exact):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 报告卡格式(hero 产出)
|
||||||
|
|
||||||
|
\`\`\`markdown
|
||||||
|
🏥 **gitlink-research-fair 科研软件体检报告**
|
||||||
|
|
||||||
|
**总体**:<等级> <分>/100(F · A · I · R · Repro)|版本锚定:commit <sha>(<release 或 无>)
|
||||||
|
**范围**:检查 19 项(✓N · ✗N · ⚠N · ⊥N,跳过:<原因>)
|
||||||
|
|
||||||
|
### 五轴雷达
|
||||||
|
(ASCII 或 Mermaid 雷达:F/A/I/R/Repro)
|
||||||
|
|
||||||
|
### 逐项
|
||||||
|
| 轴 | 项 | 状态 | 证据 |
|
||||||
|
|---|---|:---:|---|
|
||||||
|
| <轴> | <id 项名> | ✓/✗/⚠/⊥ | <gitlink-cli 证据> |
|
||||||
|
|
||||||
|
### 🔧 处方(可自动修复 N 项)
|
||||||
|
- [<轴><id>] <生成物>(从 <来源> 推断)
|
||||||
|
|
||||||
|
### 📜 可复现证书
|
||||||
|
锚定版本:git+commit <sha> | FAIR: <等级> | Repro: <状态>
|
||||||
|
(完整 SWH-ID 需 swh-identify;本次用 commit-SHA 锚定)
|
||||||
|
|
||||||
|
### 🔗 溯源(OpenAlex)
|
||||||
|
<作者/机构/载体 或 "未找到关联论文,已跳过">
|
||||||
|
|
||||||
|
### 🧬 科研关系图(KG)
|
||||||
|
\`\`\`mermaid
|
||||||
|
<Repo—contributes→Contributor · Repo—licensed?—? · Repo—has→Dataset?>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
---
|
||||||
|
<!-- gitlink-research-fair v1 | repo:<owner>/<repo> | grade:<G> | sha:<head-sha> -->
|
||||||
|
*由 gitlink-research-fair skill 生成。*
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
哨兵 `<!-- gitlink-research-fair v1 | repo | grade | sha -->` 用于幂等与"基于哪个 commit"标识。重跑检测旧哨兵 → 默认提议更新(`--refresh` 才覆盖);`sha` 不匹配 → 提示"报告已过期,建议重评"。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Append 处方 + 护栏 + 证书 + KG/OpenAlex + 错误降级**
|
||||||
|
|
||||||
|
Append (exact):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 处方(M4,惊艳闭环)
|
||||||
|
|
||||||
|
| 缺口 | 生成物 |
|
||||||
|
|------|--------|
|
||||||
|
| F3 缺 codemeta.json | 从 repo +info + README 生成 codemeta.json 草稿 |
|
||||||
|
| R1 缺 CITATION.cff | 从 README/作者生成 CITATION.cff 草稿 |
|
||||||
|
| A2 缺 license | 给 MIT / 木兰 PSL v2 模板(任选) |
|
||||||
|
| Rep1 缺依赖锁 | 从 import 扫描建议 requirements.txt |
|
||||||
|
| Rep3 缺复现章节 | 从入口/README 生成"复现"章节草稿 |
|
||||||
|
|
||||||
|
**安全护栏**:默认全量预览 → 确认 → 对 **fork** 开一个 PR(PR body 带报告卡摘要);`--auto` 跳过预览但**永不 force-push、永不碰原仓库、永不自动 merge**;`--no-fork` 物料落本地。所有写操作需认证。
|
||||||
|
|
||||||
|
## 证书(SWH/commit 锚定)
|
||||||
|
|
||||||
|
用 HEAD commit SHA + release tag 锚定版本。若环境有 Python `swh.model` → 算完整 SWH-ID;否则 `git+<commit-SHA>` 锚定,报告卡标注"完整 SWH-ID 需 swh-identify"。证书含:仓库、锚定版本、FAIR 等级、Repro 状态、可引用条目(对标 ACM Artifact Badge)。嵌报告卡证书栏。
|
||||||
|
|
||||||
|
## KG + OpenAlex(支撑栏)
|
||||||
|
|
||||||
|
- **KG**:四实体(Repo/Contributor/File/Commit)+Paper/Dataset/License;关系 contributes-to/authored/depends-on/cites/licensed-under/version-at。输出 Mermaid 小图 + triples JSON(schema 见 REFERENCE)。
|
||||||
|
- **OpenAlex**:README/CITATION 抽论文 → REST `/works` 反查作者/机构/载体。降级:限流或抽不到 → 跳过,标注"未找到关联论文"。
|
||||||
|
|
||||||
|
## 错误处理与降级
|
||||||
|
|
||||||
|
| 情况 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| `file +tree --recursive` 返回空 | 手动递归子目录 |
|
||||||
|
| 无 LICENSE | A2 ✗,处方建议模板 |
|
||||||
|
| OpenAlex 限流/无果 | 跳过溯源栏标注 |
|
||||||
|
| `swh.model` 不可用 | commit-SHA 锚定 + 标注 |
|
||||||
|
| 仓库过大 | 抽样文件 + "部分审查"标注 |
|
||||||
|
| fork 失败/无写权限 | 处方物料落本地(`--no-fork`) |
|
||||||
|
| 二进制是 npm 旧版 | 强制 `./gitlink-cli` 或 `go build` 重建 |
|
||||||
|
| 仓库私有/无权限 | 清晰错误,不发报告 |
|
||||||
|
| 报告/PR 发布失败 | 报告卡原文交用户手动粘贴 |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify sentinel + key sections**
|
||||||
|
|
||||||
|
Run: `grep -c -e "gitlink-research-fair v1 | repo" -e "## 处方" -e "## 证书" -e "swh.model" skills/gitlink-research-fair/SKILL.md`
|
||||||
|
Expected: `4`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/SKILL.md
|
||||||
|
git commit -m "feat(research-fair): SKILL 报告卡模板+处方护栏+证书+KG/OpenAlex+错误降级"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: REFERENCE.md — FAIR4RS 映射 + 评分算法 + KG schema + 引用库 + 诚实边界
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/gitlink-research-fair/REFERENCE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create REFERENCE.md with all deep-reference sections**
|
||||||
|
|
||||||
|
Content (exact):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# gitlink-research-fair 参考文档
|
||||||
|
|
||||||
|
> 本文件为 SKILL.md 的深度参考。评分细则、KG schema、OpenAlex/SWH 技术细节、学术引用库与诚实边界均在此。
|
||||||
|
|
||||||
|
## 一、FAIR4RS 逐条映射
|
||||||
|
|
||||||
|
| 原则 | 含义 | 本 Skill 检查项 | GitLink 证据 | 兜底(无字段时) |
|
||||||
|
|------|------|----------------|--------------|------------------|
|
||||||
|
| F1 | 全局唯一持久标识 | A4 持久标识 | repo identifier/URL + commit SHA | commit-SHA 锚定 |
|
||||||
|
| F1.2 | 版本级唯一标识 | A3 有版本发布 | `release +list` | ⊥(无 release 则 ✗) |
|
||||||
|
| F2 | 富元数据 | F1 描述/F2 topics/F3 codemeta | `repo +info` desc/topics;`file +get` codemeta.json | README 抽取(标注推断) |
|
||||||
|
| R1.1 | 清晰可访问许可证 | A2/I2 | `repo +info` license_id;LICENSE 文件 | gitlink-license 识别 |
|
||||||
|
| R1.2 | 详细溯源 | R1 | CITATION.cff/README 引用 | LLM 从 README 推断(标注) |
|
||||||
|
| R2 | 对其他软件的限定引用 | R3 | 源码 SPDX/依赖清单 | gitlink-license 维度4 |
|
||||||
|
|
||||||
|
## 二、评分算法
|
||||||
|
|
||||||
|
- 每项状态:`✓`(1) / `⚠`(0.5) / `✗`(0) / `⊥`(跳过,不计入分母)
|
||||||
|
- 轴分 = Σ状态值 / (轴内项数 − ⊥项数) × 100
|
||||||
|
- 总评 = 各轴分均值(全 ⊥ 轴记 N/A 排除)
|
||||||
|
- 等级:A🟢≥80 / B🟡60-79 / C🔴<60
|
||||||
|
- `confidence=low`:标注"待人工确认",不计入分子分母
|
||||||
|
|
||||||
|
## 三、可复现性 checklist(独立于 FAIR)
|
||||||
|
|
||||||
|
Rep1 环境锁(requirements 锁版本/Dockerfile/environment.yml)· Rep2 数据集说明(has_dataset/README 引用/下载脚本)· Rep3 复现步骤(README "运行/复现/Quick Start" 章节)· Rep4 版本固定(release tag 或 commit 锚定)· Rep5 入口可执行(main.py/train.py/Makefile/CLI)
|
||||||
|
|
||||||
|
## 四、KG schema(triples)
|
||||||
|
|
||||||
|
实体:Repo, Contributor, File, Commit, Paper, Dataset, License
|
||||||
|
关系(主谓宾三元组):
|
||||||
|
- (Contributor)—contributes-to→(Repo)
|
||||||
|
- (Contributor)—authored→(Paper)
|
||||||
|
- (Repo)—depends-on→(File/依赖)
|
||||||
|
- (Paper)—cites→(Paper)
|
||||||
|
- (Repo)—licensed-under→(License)
|
||||||
|
- (Repo)—version-at→(Commit)
|
||||||
|
- (Repo)—has→(Dataset)
|
||||||
|
|
||||||
|
输出:`triples.json`(数组 of {s,p,o})+ Mermaid `graph LR` 小图。
|
||||||
|
|
||||||
|
## 五、OpenAlex 字段
|
||||||
|
|
||||||
|
- 端点:`https://api.openalex.org/works?search=<title>` (2025-02 起 freemium,建议带 `mailto` 参数走 polite pool)
|
||||||
|
- 取字段:`authorships[].author.display_name`、`authorships[].institutions[].display_name`、`host_venue.display_name`(或 `primary_location.source.display_name`)、`concepts[].display_name`、`doi`
|
||||||
|
- 降级:HTTP 429/无结果 → 报告卡溯源栏标"未找到关联论文"
|
||||||
|
|
||||||
|
## 六、SWH-ID 说明
|
||||||
|
|
||||||
|
- 完整 SWH-ID = `swh:1:dir:<hash>` 或 `swh:1:rev:<hash>`,基于 Merkle DAG,需 `swh.model`(Python)计算或 `swh-identify`
|
||||||
|
- 环境无 `swh.model` 时:用 `git+<commit-SHA>` 作版本锚定,报告卡标注"完整 SWH-ID 需 swh-identify"
|
||||||
|
- commit SHA 来自 `commit +list`(HEAD)
|
||||||
|
|
||||||
|
## 七、学术引用库("信服"骨架,每条对抗式核验过)
|
||||||
|
|
||||||
|
| 支撑点 | 文献 |
|
||||||
|
|--------|------|
|
||||||
|
| 科研软件 FAIR 原则 F1/F1.2/F2/R1.1/R1.2/R2 可机器校验 | Barker et al. 2022, *Nature Scientific Data*, https://www.nature.com/articles/s41597-022-01710-x |
|
||||||
|
| 可复现性危机:2024 ICLR/ICML/NeurIPS 仅 19.5% 提供官方代码 | PaperCoder, arXiv:2504.17192 (Table 9) |
|
||||||
|
| 结构化对比表/报告卡范式 | ORKG, Jaradeh et al. K-CAP 2019 |
|
||||||
|
| 仓库→RDF 知识图谱 schema(13 实体/47 关系/794 万三元组) | LPWC, ISWC 2023 |
|
||||||
|
| SWH-ID 版本锚定(Merkle DAG, git 兼容) | Di Cosmo et al. ICMS 2020, PMC7340894 |
|
||||||
|
| 作者/机构溯源用 OpenAlex REST | Priem et al. 2022, arXiv:2205.01833 |
|
||||||
|
| 仓库级 KG 问答(四实体+SZZ+Cypher, CoT 50%→90%) | Repo-KG, arXiv:2412.03815 |
|
||||||
|
| 科研软件可复现徽章体系 | ACM Artifact Review Badging, https://www.acm.org/publications/policies/artifact-review-badging |
|
||||||
|
| 直接竞品 howfairis(5 维,仅 GitHub) | https://github.com/fair-software/howfairis |
|
||||||
|
| 战略时机:PWC 不稳定 | TIB 博客 2025-10 "Papers With Code went offline"(单一二手源,pitch 前复核) |
|
||||||
|
|
||||||
|
## 八、诚实边界(不可过度宣称)
|
||||||
|
|
||||||
|
1. FAIR4RS 自称 **aspirational**;本 Skill 是**自建 checker**(社区尚无认证级校验器),不说"套用现成标准工具"。
|
||||||
|
2. **FAIRness ≠ 可复现性**(FAIR 必要非充分)—— Repro 单独成轴。
|
||||||
|
3. OpenAlex 自 2025-02 起 freemium(~$1/day、需 key、100 req/s)—— 单仓演示够,批量控量。
|
||||||
|
4. **禁止使用两条已证伪论点**:①"MSR 六分类法"、②"FAIR 分高→被引更多"因果。
|
||||||
|
5. 文献多跑在 GitHub;"在 GitLink 上复刻"是合理外推,须真机跑通闭环。
|
||||||
|
|
||||||
|
## 九、实测结论(live probe,Task 6 回填)
|
||||||
|
|
||||||
|
> 此节由 Task 6 在 `songhui18/ICCV2021论文复现` 真机验证后回填:license 状态、README/requirements/releases/commit SHA、各轴实测分、fork→PR 链路是否可用、OpenAlex 是否命中。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify citations + banned claims + sections**
|
||||||
|
|
||||||
|
Run: `grep -c -e "s41597-022-01710-x" -e "2504.17192" -e "禁止使用两条已证伪" -e "## 九、实测结论" skills/gitlink-research-fair/REFERENCE.md`
|
||||||
|
Expected: `4`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/REFERENCE.md
|
||||||
|
git commit -m "feat(research-fair): REFERENCE(FAIR4RS映射+评分+KG schema+引用库+诚实边界)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Live probe songhui18 — 回填 REFERENCE 实测结论
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/gitlink-research-fair/REFERENCE.md` (§九 实测结论)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Probe repo metadata + files**
|
||||||
|
|
||||||
|
Run (capture outputs):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli repo +info --owner songhui18 --repo ICCV2021论文复现 --format json > /tmp/rf_info.json
|
||||||
|
gitlink-cli file +list --owner songhui18 --repo ICCV2021论文复现 --format json > /tmp/rf_files.json
|
||||||
|
gitlink-cli release +list --owner songhui18 --repo ICCV2021论文复现 --format json > /tmp/rf_rel.json
|
||||||
|
gitlink-cli commit +list --owner songhui18 --repo ICCV2021论文复现 --page 1 --format json > /tmp/rf_commits.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: each file non-empty, valid JSON with `"ok": true`. If any errors, debug auth/binary first (see Environment & Gotchas).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extract ground truth (license/desc/topics/releases/README/requirements/commit SHA)**
|
||||||
|
|
||||||
|
Write `_probe.py` (UTF-8) and run with `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python _probe.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import json, glob
|
||||||
|
def load(p):
|
||||||
|
try:
|
||||||
|
raw=open(p,encoding='utf-8').read(); i=raw.find('{')
|
||||||
|
return json.loads(raw[i:]) if i>=0 else {}
|
||||||
|
except Exception as e:
|
||||||
|
return {'_err':str(e)}
|
||||||
|
info=load('/tmp/rf_info.json'); files=load('/tmp/rf_files.json')
|
||||||
|
rel=load('/tmp/rf_rel.json'); commits=load('/tmp/rf_commits.json')
|
||||||
|
d=info.get('data',info) if isinstance(info,dict) else {}
|
||||||
|
print('license_id:', d.get('license_id'))
|
||||||
|
print('desc_len:', len(d.get('description') or ''))
|
||||||
|
print('topics:', d.get('topics'))
|
||||||
|
print('has_dataset:', d.get('has_dataset'))
|
||||||
|
print('is_public:', d.get('is_public'))
|
||||||
|
# releases
|
||||||
|
rd=rel.get('data',rel) if isinstance(rel,dict) else {}
|
||||||
|
releases=rd.get('releases') or rd.get('project_releases') or []
|
||||||
|
print('releases:', len(releases) if isinstance(releases,list) else releases)
|
||||||
|
# files — look for key names
|
||||||
|
fd=files.get('data',files) if isinstance(files,dict) else {}
|
||||||
|
entries=fd.get('entries') or fd.get('files') or fd.get('trees') or []
|
||||||
|
names=[ (e.get('name') or e.get('path') or '') for e in entries ] if isinstance(entries,list) else []
|
||||||
|
key=['LICENSE','LICENSE.md','COPYING','README.md','README','requirements.txt','environment.yml','Dockerfile','Makefile','CITATION.cff','codemeta.json','.zenodo.json','go.mod','package.json']
|
||||||
|
print('present:', [k for k in key if any(k==n or n.endswith('/'+k) for n in names)])
|
||||||
|
print('absent :', [k for k in key if not any(k==n or n.endswith('/'+k) for n in names)])
|
||||||
|
# HEAD commit sha
|
||||||
|
cd=commits.get('data',commits) if isinstance(commits,dict) else {}
|
||||||
|
cl=cd.get('commits') or []
|
||||||
|
print('head_sha:', (cl[0].get('sha') if cl and isinstance(cl,list) else None))
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: prints real values (adapt field paths if GitLink nests differently — inspect raw JSON). Record: license state (expect absent), README/requirements present?, releases (expect 0), HEAD sha, has_dataset.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Backfill REFERENCE §九 with real findings**
|
||||||
|
|
||||||
|
Replace the `> 此节由 Task 6...回填` line with real data: license state (expected: absent), README present?, requirements present?, releases count (expected: 0), HEAD commit SHA, has_dataset, and each axis's real ✓/✗ (preview the grade — expect B/C due to no license).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/REFERENCE.md
|
||||||
|
git commit -m "docs(research-fair): REFERENCE 回填 songhui18 实测结论"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Live run — 报告卡 + 处方 PR(fork)+ 证书
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- (produces real artifacts; outputs captured into Task 8's examples doc)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Produce the report card following SKILL.md**
|
||||||
|
|
||||||
|
Using Task 6 ground truth, walk the 8-step pipeline on `songhui18/ICCV2021论文复现`: score all 5 axes, build KG (Mermaid), try OpenAlex enrichment (search README for paper title/DOI → query api.openalex.org; if none, mark skipped), render the full report card with real values + sentinel + real commit SHA.
|
||||||
|
|
||||||
|
Expected: a complete Markdown report card with a real grade (B/C), real axis scores, real evidence per item.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fork + open remediation PR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitlink-cli repo +fork --owner songhui18 --repo ICCV2021论文复现 --format json
|
||||||
|
# clone the fork locally, add generated CITATION.cff + LICENSE + 复现章节, commit, push
|
||||||
|
# then:
|
||||||
|
gitlink-cli pr +create --owner <你的login> --repo ICCV2021论文复现 \
|
||||||
|
--head <你的login>:feat/fair-remediation --base master \
|
||||||
|
--title "Improve research FAIRness & reproducibility (gitlink-research-fair)" \
|
||||||
|
--body "<报告卡摘要>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PR created with a numeric id. **Fallback**: if fork/push fails, use `--no-fork` — write CITATION.cff/LICENSE/复现章节 to a local `_remediation/` dir and note "fork 链路不可用,已降级本地物料" in the examples doc.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify certificate + capture screenshot**
|
||||||
|
|
||||||
|
Confirm the certificate line in the report card has the real HEAD commit SHA (from Task 6) and grade. If `swh.model` importable in python, compute full SWH-ID; else keep `git+<sha>`. Capture a screenshot of the opened PR on GitLink web → save to `skills/gitlink-research-fair/examples/img/pr-songhui18.png` (recommended).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit (artifacts/screenshots only)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/examples/img/ # if screenshot captured
|
||||||
|
git commit -m "feat(research-fair): songhui18 实跑报告卡+处方PR+证书(截图)"
|
||||||
|
```
|
||||||
|
|
||||||
|
(No commit if --no-fork fallback and no screenshot — proceed to Task 8.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: examples/research-fair-workflow.md — 真实走查
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/gitlink-research-fair/examples/research-fair-workflow.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the real walkthrough using Task 6+7 outputs**
|
||||||
|
|
||||||
|
Content skeleton (fill with REAL data from Tasks 6-7):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 示例:科研软件 FAIR 体检(真实数据)
|
||||||
|
|
||||||
|
> 基于 `songhui18/ICCV2021论文复现`(论文复现合集,19⭐)于 2026-07-XX 实跑。
|
||||||
|
|
||||||
|
## Step 1:取上下文(真实输出摘要)
|
||||||
|
- license_id: <实测> | 描述: <长度>字 | topics: <...> | has_dataset: <...>
|
||||||
|
- 关键文件:LICENSE=<有/无> · README=<...> · requirements=<...> · CITATION.cff=<...> · releases=<N>
|
||||||
|
|
||||||
|
## Step 2:5 轴评分(真实)
|
||||||
|
| 轴 | 分 | 关键依据 |
|
||||||
|
|---|---|---|
|
||||||
|
| F | <实> | ... |
|
||||||
|
| A | <实> | 无 license → A2 ✗ |
|
||||||
|
| I | <实> | ... |
|
||||||
|
| R | <实> | ... |
|
||||||
|
| Repro | <实> | ... |
|
||||||
|
**总体:<等级> <分>/100**
|
||||||
|
|
||||||
|
## Step 3:报告卡(真实全文)
|
||||||
|
<贴 Task 7 报告卡全文,含哨兵>
|
||||||
|
|
||||||
|
## Step 4:处方 PR
|
||||||
|
<PR 链接 / 或 --no-fork 降级说明>,含 CITATION.cff + LICENSE + 复现章节
|
||||||
|
|
||||||
|
## Step 5:证书
|
||||||
|
锚定 commit <SHA>,FAIR <等级>,Repro <状态>
|
||||||
|
|
||||||
|
## 对照:有 license 仓库
|
||||||
|
跑 `<对照仓库>`(有 license)→ 预期 A 级,证明评分区分度。
|
||||||
|
|
||||||
|
## 关键结论
|
||||||
|
- "论文复现"仓库被"可复现性"工具体检:主题共振
|
||||||
|
- 无 license = 天然缺口,处方有活干
|
||||||
|
- 与 howfairis 差异:GitLink 原生 + 自动修复 + 证书
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify real data (no placeholders)**
|
||||||
|
|
||||||
|
Run: `grep -E "TBD|TODO|<等级>|<分>|<实>|<实测>|<SHA>" skills/gitlink-research-fair/examples/research-fair-workflow.md`
|
||||||
|
Expected: no matches (all `<...>` placeholders filled with real data). If any remain, fill them from Tasks 6-7.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/examples/research-fair-workflow.md
|
||||||
|
git commit -m "feat(research-fair): examples songhui18 真实走查"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: 对照仓库跑(证明评分区分度)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/gitlink-research-fair/examples/research-fair-workflow.md` (对照 section)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Pick a licensed repo and run the pipeline**
|
||||||
|
|
||||||
|
Use `leejt/图神经网络快速开发测试工具` (has license_id=Y) — or `Edgedev/Edge深度学习框架`. Run `repo +info` + `file +list` to confirm it has a LICENSE, then score the 5 axes.
|
||||||
|
|
||||||
|
Expected: A2 = ✓ (has license) → A axis notably higher than songhui18; overall grade A (≥80) or clearly higher than songhui18's B/C.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fill the 对照 section with real contrast numbers**
|
||||||
|
|
||||||
|
Update the "对照:有 license 仓库" section with: repo name, its grade (expect A), and a one-line contrast ("songhui18 B/C(无 license)vs 对照 A(有 license)→ 评分有区分度").
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/examples/research-fair-workflow.md
|
||||||
|
git commit -m "docs(research-fair): 对照仓库证明评分区分度"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: 注册 + README + workflow 链接 + 最终验收
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/README.md`
|
||||||
|
- Modify: `skills/gitlink-workflow/SKILL.md` (optional cross-link)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register the skill (so Claude Code's Skill tool discovers it)**
|
||||||
|
|
||||||
|
Run: `bash scripts/setup-skills.sh` (creates the junction into `~/.claude/skills/`). Then verify discovery: `ls ~/.claude/skills/ | grep gitlink-research-fair`.
|
||||||
|
Expected: `gitlink-research-fair` listed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add README row**
|
||||||
|
|
||||||
|
In `skills/README.md`, add to the 智能 Skills table (near gitlink-review):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| **gitlink-research-fair** | 科研软件 FAIR 体检 | 5 轴 FAIR/可复现评分、报告卡、自动修复 PR、SWH/commit 可复现证书 |
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify: `grep -c "gitlink-research-fair" skills/README.md` → `≥1`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: (Optional) cross-link from gitlink-workflow**
|
||||||
|
|
||||||
|
In `skills/gitlink-workflow/SKILL.md`, under a research/repo-health context, add a line linking to `../gitlink-research-fair/SKILL.md`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final acceptance check (spec §16)**
|
||||||
|
|
||||||
|
Verify each (run/read):
|
||||||
|
- [ ] `skills/gitlink-research-fair/` 三件套齐全(SKILL.md + REFERENCE.md + examples/)
|
||||||
|
- [ ] 5 轴 rubric 每项有证据来源 + 状态判定 + 兜底
|
||||||
|
- [ ] songhui18 真跑通:报告卡 + 处方 PR(或 --no-fork 降级)+ 证书,有截图
|
||||||
|
- [ ] 对照仓库评分有区分度
|
||||||
|
- [ ] 边界(私有/空/无 README)降级正确(至少手动核对 SKILL 错误表覆盖)
|
||||||
|
- [ ] REFERENCE 含引用库 + 2 条禁用论点
|
||||||
|
- [ ] README 已登记
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit + final state**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/README.md skills/gitlink-workflow/SKILL.md
|
||||||
|
git commit -m "docs(research-fair): README 登记 + workflow 链接 + 注册"
|
||||||
|
git log --oneline -12
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: clean commit history on `feat/gitlink-research-fair`, all artifacts present. Then open a PR to master (per protected-branch workflow).
|
||||||
|
|
@ -0,0 +1,846 @@
|
||||||
|
# gitlink-spark Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a `gitlink-spark` Skill + runnable `spark.py` that mines "literature↔code semantic gaps" across arXiv × GitLink × GitHub and outputs an opportunity report with optional fork+issue kickoff.
|
||||||
|
|
||||||
|
**Architecture:** `scripts/spark.py` is a standalone data-fusion script (fetches real data → JSON). `SKILL.md` orchestrates an LLM that reads the JSON, semantically matches two gap types (theory-no-impl / demand-no-solution) with evidence triples, renders an opportunity report, and optionally forks+issues to kickoff. Pure functions are unit-tested; the live GNN run is integration validation.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11 stdlib only (urllib, subprocess, xml.etree, json, argparse) — no pip deps. gitlink-cli. arXiv Atom API + GitHub Search REST.
|
||||||
|
|
||||||
|
**Source of truth:** `docs/superpowers/specs/2026-07-01-gitlink-spark-design.md` (read it first).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment & Gotchas (engineer must know)
|
||||||
|
|
||||||
|
- **Branch:** on `feat/gitlink-research-fair` (spark is 子任务四第二部分, shares PR #5). Commit only your own files; leave pre-existing `D README_TASKB.md` / `D gitlink-cli.exe` / `?? dist/` / `?? _edge_prescription/` untouched.
|
||||||
|
- **Encoding:** all python that touches Chinese gitlink-cli output MUST run with `PYTHONUTF8=1 PYTHONIOENCODING=utf-8`. Never inline Chinese in `python -c` — write a `.py` file.
|
||||||
|
- **arXiv must be HTTPS** (`https://export.arxiv.org`); plain HTTP is sandbox-blocked (returns 0 bytes).
|
||||||
|
- **GitHub unauthenticated = 10 req/min** → `fetch_github_count` sleeps ~7s between calls. Set `GITHUB_TOKEN` env to raise to 5000/h. Cache by query key.
|
||||||
|
- **`gitlink-cli search +issues` returns HTML (broken)** — never use it. Use per-repo `gitlink-cli issue +list --owner X --repo Y --state open` (returns JSON).
|
||||||
|
- **`--repo` uses identifier** (ASCII slug), not Chinese display name.
|
||||||
|
- **No `pip install`** — spark.py uses stdlib only. Tests run via `python test_spark.py` (assert-based, no pytest).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `skills/gitlink-spark/scripts/spark.py` | Standalone data fusion: arXiv + gitlink-cli + GitHub → JSON on stdout. Pure parsers + network fetchers + main() |
|
||||||
|
| `skills/gitlink-spark/scripts/test_spark.py` | Assert-based unit tests for pure parsers (parse_arxiv_atom, parse_github_search, extract_method_keywords) |
|
||||||
|
| `skills/gitlink-spark/SKILL.md` | 4-stage orchestration, gap taxonomy, GitHub threshold rule, report template, kickoff guardrails, error table |
|
||||||
|
| `skills/gitlink-spark/REFERENCE.md` | Gap taxonomy detail, GitHub tiers, LLM prompt template, data-source findings, honesty caveats |
|
||||||
|
| `skills/gitlink-spark/examples/spark-图神经网络.md` | Real GNN run: 2-3 gap cards + kickoff screenshot |
|
||||||
|
| `skills/README.md` | Add gitlink-spark row |
|
||||||
|
| `skills/gitlink-workflow/SKILL.md` | Optional cross-link |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Scaffold SKILL.md (frontmatter + CRITICAL + pipeline + command interface)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/gitlink-spark/SKILL.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create SKILL.md with frontmatter, CRITICAL headers, 概述, 命令接口, 4-stage pipeline**
|
||||||
|
|
||||||
|
Content (exact):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: gitlink-spark
|
||||||
|
version: 1.0.0
|
||||||
|
description: "文献-代码语义缺口挖掘机:给一个研究领域,跨 arXiv × GitLink × GitHub 三源挖'有理论无实现/有需求无解答'语义缺口,输出空白学术机会报告,可一键 fork+issue 起跑。当用户需要找研究点、发现论文-代码空白、科研选题启发时触发。"
|
||||||
|
metadata:
|
||||||
|
requires:
|
||||||
|
bins: ["gitlink-cli"]
|
||||||
|
cliHelp: "python skills/gitlink-spark/scripts/spark.py --help"
|
||||||
|
---
|
||||||
|
|
||||||
|
# gitlink-spark(文献-代码语义缺口挖掘机)
|
||||||
|
|
||||||
|
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||||
|
**CRITICAL — 缺口由 LLM 揨断,但每条必须带实证三件套(论文 id / GitLink 查询+命中数 / GitHub total_count);无实证的缺口必须丢弃。**
|
||||||
|
**CRITICAL — 起跑(fork+issue)默认预览确认;绝不自动 merge、绝不 force-push、绝不碰原仓库。**
|
||||||
|
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`。**
|
||||||
|
|
||||||
|
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md);缺口分类法、GitHub 阈值、LLM prompt 模板见 [`REFERENCE.md`](REFERENCE.md)。
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
给一个研究领域,跨 **arXiv(学术)× GitLink(中文生态)× GitHub(全球)** 三源挖两类语义缺口,输出**空白学术机会报告**。`scripts/spark.py` 抓真实数据(JSON),LLM 做语义匹配并附实证三件套。与 `gitlink-research-fair`(评估已有)组成"科研辅助双联装"——本 skill 负责**发现空白**。
|
||||||
|
|
||||||
|
## 命令接口
|
||||||
|
|
||||||
|
数据融合脚本(可独立运行):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python skills/gitlink-spark/scripts/spark.py --field "图神经网络" [--max-papers 10] [--gap-type both|theory|demand] [--github-token $GITHUB_TOKEN]
|
||||||
|
# → stdout: 融合 JSON {papers, gitlink_repos, gitlink_issues, github_counts}
|
||||||
|
```
|
||||||
|
|
||||||
|
skill 约定参数(非 CLI flag):
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--field` | 必填 | 研究领域 |
|
||||||
|
| `--gap-type` | `both` | `theory` / `demand` / `both` |
|
||||||
|
| `--max-papers` | 10 | arXiv 抓取上限(控 GitHub 调用) |
|
||||||
|
| `--auto` | 关 | 跳过预览直接起跑(仍受护栏) |
|
||||||
|
| `--no-fork` | 关 | 只出报告,不起跑 |
|
||||||
|
|
||||||
|
## 管道(4 阶段)
|
||||||
|
|
||||||
|
### ① 学术采
|
||||||
|
`spark.py` 调 arXiv HTTPS API 抓领域近 90 天论文(标题/摘要/arxiv id/方法关键词)
|
||||||
|
|
||||||
|
### ② GitLink 采
|
||||||
|
`spark.py` 调 `gitlink-cli search +repos` 抓领域仓库;对每个仓库 `issue +list --state open` 抓 open issue(**不用 search +issues**,它返回 HTML)
|
||||||
|
|
||||||
|
### ③ 全球对照
|
||||||
|
`spark.py` 调 GitHub Search API 对每个论文方法查 `total_count` + Top3 仓库(限流+缓存)
|
||||||
|
|
||||||
|
### ④ 缺口匹配(LLM)+ 报告 + 起跑
|
||||||
|
读 spark.py 的 JSON → 语义匹配两类缺口(每张带实证三件套)→ 渲染机会报告 → 可选 fork+issue 起跑
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify**
|
||||||
|
|
||||||
|
Run: `head -30 skills/gitlink-spark/SKILL.md | grep -c -e "name: gitlink-spark" -e "description:" -e "CRITICAL" -e "管道(4 阶段)"`
|
||||||
|
Expected: `6`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-spark/SKILL.md
|
||||||
|
git commit -m "feat(spark): scaffold SKILL.md(frontmatter+CRITICAL+4阶段管道)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: SKILL.md — gap taxonomy + evidence triples + GitHub thresholds + report + guardrails
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/gitlink-spark/SKILL.md` (append after 管道)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append gap taxonomy, thresholds, report template, kickoff, error table**
|
||||||
|
|
||||||
|
Append (exact):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 两类缺口 + 实证三件套(信服核心)
|
||||||
|
|
||||||
|
每张缺口卡**必须**带齐三件套,否则丢弃(防 LLM 编造):
|
||||||
|
|
||||||
|
### 类型 A:有理论无实现(paper → code gap)
|
||||||
|
- **三件套**:① 论文 arxiv id + 标题 + 发表日期 ② GitLink 搜索查询串 + 命中数(0/极少) ③ GitHub total_count + Top 仓库
|
||||||
|
- LLM 判定:论文提出方法 M;GitLink 实现 0/极少;GitHub 按下方阈值分级
|
||||||
|
|
||||||
|
### 类型 B:有需求无解答(open issue → applied gap)
|
||||||
|
- **三件套**:① issue URL + 主题 + 讨论人数/状态 ② GitLink 无现成实现解此痛点 ③ GitHub 是否有成熟开源解
|
||||||
|
- 降噪:LLM 只挑"研究性痛点"(性能/可扩展/新场景),排除"安装报错"等使用问题
|
||||||
|
|
||||||
|
## GitHub 全球对照阈值(诚实核心,硬需求)
|
||||||
|
|
||||||
|
防止"GitLink 0 ≠ 全球空白"误导。对每个"理论无实现"候选按 GitHub total_count 分级:
|
||||||
|
|
||||||
|
| GitHub total_count | 分级 | 报告行为 |
|
||||||
|
|--------------------|------|----------|
|
||||||
|
| `< 10` | 全球稀缺(真空白) | 报为高价值缺口 |
|
||||||
|
| `10–50` | 新兴(部分空白) | 报为中等缺口("GitLink 空白,全球新兴") |
|
||||||
|
| `≥ 50` | 全球已成熟 | **不报为空白**,列入"✅ 已诚实排除" |
|
||||||
|
|
||||||
|
宁可少报,不误报机会。
|
||||||
|
|
||||||
|
## 机会报告格式(hero)
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
⚡ **gitlink-spark 机会报告:<field>**
|
||||||
|
|
||||||
|
学术采:arXiv 近 90 天 N 篇 | GitLink 仓库 M 个 | GitHub 全球基线已对照
|
||||||
|
生成时间:YYYY-MM-DD
|
||||||
|
|
||||||
|
### 🧩 缺口 1 · 有理论无实现 [全球稀缺·高价值]
|
||||||
|
**论文**:[arxiv:<id>] "<title>" (<date>)
|
||||||
|
**方法关键词**:<...>
|
||||||
|
**GitLink**:search "<query>" → **0 命中**(查询串留底)
|
||||||
|
**GitHub 全球**:total_count = **N**(Top: <repo> <stars>⭐)→ 稀缺
|
||||||
|
**机会建议**:<LLM 一句话>
|
||||||
|
**起跑**:[按钮] fork 基准 <repo> → 创建 issue 粘论文伪代码
|
||||||
|
|
||||||
|
### 🧩 缺口 2 · 有需求无解答 [应用机会]
|
||||||
|
**Issue**:<repo>#<n> "<subject>"(N 人讨论, open)
|
||||||
|
**痛点**:<LLM 归纳>
|
||||||
|
**GitLink / GitHub**:均无成熟解
|
||||||
|
**机会建议**:<LLM 一句话>
|
||||||
|
|
||||||
|
### ✅ 已诚实排除(非空白)
|
||||||
|
- 论文 Y:GitLink 虽 0,但 GitHub 已 N 个 → 全球已成熟,不报
|
||||||
|
|
||||||
|
---
|
||||||
|
<!-- gitlink-spark v1 | field:<field> | gaps:<N> | date:<YYYY-MM-DD> -->
|
||||||
|
*由 gitlink-spark skill 生成。*
|
||||||
|
````
|
||||||
|
|
||||||
|
## 起跑动作 + 护栏
|
||||||
|
|
||||||
|
选定一张"理论无实现"缺口卡 → 确认 →
|
||||||
|
1. `gitlink-cli repo +fork` 最近基准(GitHub Top 仓库或 GitLink 最近实现)
|
||||||
|
2. LLM 从 arXiv 论文抓 Algorithm/Pseudocode 节
|
||||||
|
3. `gitlink-cli issue +create` 在 fork 建复现 todo issue(body 粘伪代码 + 报告卡摘要)
|
||||||
|
|
||||||
|
**护栏**:默认预览;`--auto` 跳过但**永不 force-push、永不碰原仓库、永不自动 merge**;`--no-fork` 只出报告。
|
||||||
|
|
||||||
|
## 错误处理与降级
|
||||||
|
|
||||||
|
| 情况 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| arXiv 空/超时 | HTTPS 重试;仍空降级用既有论文 |
|
||||||
|
| `search +issues` 返回 HTML | 不用,改逐仓库 `issue +list` |
|
||||||
|
| GitHub 未认证限流(10/min) | spark.py sleep ~7s;建议设 `GITHUB_TOKEN` |
|
||||||
|
| GitHub 查询失败 | 该论文标"对照失败",不进缺口判定 |
|
||||||
|
| OpenAlex 503 | 跳过引用富集 |
|
||||||
|
| LLM 缺口无三件套 | 置信度门控丢弃 |
|
||||||
|
| fork/issue 起跑失败 | 输出 fork 目标 + 伪代码文本供手动起跑 |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify**
|
||||||
|
|
||||||
|
Run: `grep -c -e "三件套" -e "GitHub total_count" -e "已诚实排除" -e "起跑动作" skills/gitlink-spark/SKILL.md`
|
||||||
|
Expected: `4`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-spark/SKILL.md
|
||||||
|
git commit -m "feat(spark): SKILL 缺口分类法+GitHub阈值+报告模板+起跑护栏+降级"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: spark.py — parse_arxiv_atom + test (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/gitlink-spark/scripts/spark.py`
|
||||||
|
- Create: `skills/gitlink-spark/scripts/test_spark.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`test_spark.py` (exact):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Assert-based unit tests for spark.py pure parsers. Run: python test_spark.py"""
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from spark import parse_arxiv_atom, parse_github_search, extract_method_keywords
|
||||||
|
|
||||||
|
SAMPLE_ARXIV = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<id>http://arxiv.org/abs/2403.12345v1</id>
|
||||||
|
<title>Graph Attention Networks with Sparse Transformers</title>
|
||||||
|
<summary>We propose a new graph attention mechanism using sparse attention.</summary>
|
||||||
|
<published>2024-03-15T00:00:00Z</published>
|
||||||
|
</entry>
|
||||||
|
<entry>
|
||||||
|
<id>http://arxiv.org/abs/2404.99999v2</id>
|
||||||
|
<title>Federated Learning on Heterogeneous Graphs</title>
|
||||||
|
<summary>A federated approach for heterogeneous graph neural networks.</summary>
|
||||||
|
<published>2024-04-20T00:00:00Z</published>
|
||||||
|
</entry>
|
||||||
|
</feed>"""
|
||||||
|
|
||||||
|
def test_parse_arxiv_atom():
|
||||||
|
papers = parse_arxiv_atom(SAMPLE_ARXIV)
|
||||||
|
assert len(papers) == 2, f"expected 2 papers, got {len(papers)}"
|
||||||
|
assert papers[0]["arxiv_id"] == "2403.12345v1", papers[0]["arxiv_id"]
|
||||||
|
assert "Graph Attention" in papers[0]["title"]
|
||||||
|
assert papers[0]["published"] == "2024-03-15"
|
||||||
|
assert "sparse" in papers[0]["abstract"].lower()
|
||||||
|
print("test_parse_arxiv_atom OK")
|
||||||
|
|
||||||
|
def test_parse_github_search():
|
||||||
|
import json as _j
|
||||||
|
sample = _j.dumps({"total_count": 1543, "items": [{"full_name": "a/b", "stargazers_count": 3534}]})
|
||||||
|
res = parse_github_search(sample)
|
||||||
|
assert res["total_count"] == 1543
|
||||||
|
assert res["top"][0]["full_name"] == "a/b"
|
||||||
|
assert res["top"][0]["stars"] == 3534
|
||||||
|
print("test_parse_github_search OK")
|
||||||
|
|
||||||
|
def test_extract_method_keywords():
|
||||||
|
kws = extract_method_keywords("Graph Attention Networks", "We propose a sparse attention mechanism for graphs.", max_k=5)
|
||||||
|
assert "graph" in kws and "attention" in kws
|
||||||
|
assert "propose" not in kws # 'propose' is in the stop set, filtered out
|
||||||
|
print("test_extract_method_keywords OK")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_parse_arxiv_atom()
|
||||||
|
test_parse_github_search()
|
||||||
|
test_extract_method_keywords()
|
||||||
|
print("ALL TESTS PASSED")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd skills/gitlink-spark/scripts && python test_spark.py`
|
||||||
|
Expected: FAIL with `ModuleNotFoundError: No module named 'spark'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal spark.py with parse_arxiv_atom (+ stubs for the other two so import works)**
|
||||||
|
|
||||||
|
`spark.py` (exact):
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""gitlink-spark data fusion: arXiv x GitLink x GitHub -> JSON on stdout. Stdlib only."""
|
||||||
|
import argparse, json, os, sys, time, subprocess, urllib.request, urllib.parse, re
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
ARXIV_ENDPOINT = "https://export.arxiv.org/api/query"
|
||||||
|
GITHUB_ENDPOINT = "https://api.github.com/search/repositories"
|
||||||
|
|
||||||
|
_NS = {"a": "http://www.w3.org/2005/Atom"}
|
||||||
|
|
||||||
|
def parse_arxiv_atom(xml_text):
|
||||||
|
"""Parse arXiv Atom feed -> list of {arxiv_id, title, abstract, published}."""
|
||||||
|
root = ET.fromstring(xml_text)
|
||||||
|
papers = []
|
||||||
|
for e in root.findall("a:entry", _NS):
|
||||||
|
aid = (e.find("a:id", _NS).text or "").strip().split("/")[-1]
|
||||||
|
title = re.sub(r"\s+", " ", (e.find("a:title", _NS).text or "").strip())
|
||||||
|
summary = re.sub(r"\s+", " ", (e.find("a:summary", _NS).text or "").strip())
|
||||||
|
pub = (e.find("a:published", _NS).text or "")[:10]
|
||||||
|
papers.append({"arxiv_id": aid, "title": title, "abstract": summary, "published": pub})
|
||||||
|
return papers
|
||||||
|
|
||||||
|
def parse_github_search(json_text):
|
||||||
|
return {"total_count": 0, "top": []} # stub — implemented in Task 4
|
||||||
|
|
||||||
|
def extract_method_keywords(title, abstract, max_k=5):
|
||||||
|
return [] # stub — implemented in Task 4
|
||||||
|
|
||||||
|
def main():
|
||||||
|
pass # implemented in Task 7
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test — arxiv test passes, others fail on stubs**
|
||||||
|
|
||||||
|
Run: `cd skills/gitlink-spark/scripts && python test_spark.py`
|
||||||
|
Expected: `test_parse_arxiv_atom OK`, then FAIL on `test_parse_github_search` (top[0] index error on empty). This confirms arxiv parser works; stubs next.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-spark/scripts/spark.py skills/gitlink-spark/scripts/test_spark.py
|
||||||
|
git commit -m "feat(spark): spark.py parse_arxiv_atom + test(TDD)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: spark.py — parse_github_search + extract_method_keywords (real impl)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/gitlink-spark/scripts/spark.py` (replace the two stubs)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the two stub functions with real implementations**
|
||||||
|
|
||||||
|
Replace `def parse_github_search(json_text): ...` and `def extract_method_keywords(...): ...` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def parse_github_search(json_text):
|
||||||
|
"""Parse GitHub search JSON -> {total_count, top:[{full_name, stars}]}."""
|
||||||
|
d = json.loads(json_text)
|
||||||
|
return {
|
||||||
|
"total_count": d.get("total_count", 0),
|
||||||
|
"top": [{"full_name": r.get("full_name"), "stars": r.get("stargazers_count")}
|
||||||
|
for r in (d.get("items") or [])[:3]],
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract_method_keywords(title, abstract, max_k=5):
|
||||||
|
"""Crude keyword extraction for GitHub/arXiv query."""
|
||||||
|
text = (title + " " + abstract).lower()
|
||||||
|
stop = {"the", "a", "an", "of", "for", "and", "to", "in", "on", "with", "via",
|
||||||
|
"based", "using", "by", "from", "as", "is", "are", "we", "our", "this",
|
||||||
|
"that", "propose", "proposed", "paper", "method", "approach", "novel", "new"}
|
||||||
|
tokens = re.findall(r"[a-z][a-z0-9-]+", text)
|
||||||
|
seen = set(); out = []
|
||||||
|
for t in tokens:
|
||||||
|
if t in stop or len(t) < 3 or t in seen:
|
||||||
|
continue
|
||||||
|
seen.add(t); out.append(t)
|
||||||
|
if len(out) >= max_k:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run all tests — expect ALL PASS**
|
||||||
|
|
||||||
|
Run: `cd skills/gitlink-spark/scripts && python test_spark.py`
|
||||||
|
Expected: `ALL TESTS PASSED` (all 3 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-spark/scripts/spark.py
|
||||||
|
git commit -m "feat(spark): parse_github_search + extract_method_keywords 实现(3 测试全过)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: spark.py — fetch_arxiv (network)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/gitlink-spark/scripts/spark.py` (add fetch_arxiv before main)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add fetch_arxiv**
|
||||||
|
|
||||||
|
Insert before `def main()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def fetch_arxiv(field, max_papers=10):
|
||||||
|
"""Search arXiv (HTTPS) for recent papers in field. Returns list of paper dicts."""
|
||||||
|
q = urllib.parse.quote(f'abs:"{field}"')
|
||||||
|
url = (f"{ARXIV_ENDPOINT}?search_query={q}&max_results={max_papers}"
|
||||||
|
f"&sortBy=submittedDate&sortOrder=descending")
|
||||||
|
with urllib.request.urlopen(url, timeout=30) as r:
|
||||||
|
papers = parse_arxiv_atom(r.read().decode("utf-8", "replace"))
|
||||||
|
for p in papers:
|
||||||
|
p["method_keywords"] = extract_method_keywords(p["title"], p["abstract"])
|
||||||
|
return papers
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Smoke test fetch_arxiv (live network)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd skills/gitlink-spark/scripts && PYTHONUTF8=1 python -c "
|
||||||
|
from spark import fetch_arxiv
|
||||||
|
ps = fetch_arxiv('graph neural network', max_papers=2)
|
||||||
|
assert len(ps) >= 1, 'no papers'
|
||||||
|
p = ps[0]
|
||||||
|
assert p['arxiv_id'] and p['title'] and p['published']
|
||||||
|
assert isinstance(p['method_keywords'], list)
|
||||||
|
print('OK', p['arxiv_id'], '|', p['title'][:50])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: `OK 2504.xxxxx | <recent GNN paper title>` (a real recent arxiv id). If 0 bytes, confirm HTTPS (not HTTP).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-spark/scripts/spark.py
|
||||||
|
git commit -m "feat(spark): fetch_arxiv(arXiv HTTPS 网络层)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: spark.py — fetch_gitlink_repos + fetch_gitlink_issues
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/gitlink-spark/scripts/spark.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add _gitlink helper + the two fetchers**
|
||||||
|
|
||||||
|
Insert before `def main()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _gitlink(*args):
|
||||||
|
"""Run gitlink-cli with json output; return parsed dict (UTF-8 safe)."""
|
||||||
|
r = subprocess.run(["gitlink-cli"] + list(args) + ["--format", "json"],
|
||||||
|
capture_output=True, text=True, encoding="utf-8",
|
||||||
|
errors="replace", timeout=60)
|
||||||
|
raw = r.stdout
|
||||||
|
i = raw.find("{")
|
||||||
|
return json.loads(raw[i:]) if i >= 0 else {}
|
||||||
|
|
||||||
|
def fetch_gitlink_repos(field):
|
||||||
|
"""gitlink-cli search +repos -> list of {owner, repo(identifier), name, desc, topics}."""
|
||||||
|
d = _gitlink("search", "+repos", "-k", field)
|
||||||
|
projs = d.get("data", {}).get("projects", []) or []
|
||||||
|
out = []
|
||||||
|
for p in projs:
|
||||||
|
out.append({
|
||||||
|
"owner": (p.get("author") or {}).get("login"),
|
||||||
|
"repo": p.get("identifier"),
|
||||||
|
"name": p.get("name"),
|
||||||
|
"desc": p.get("description"),
|
||||||
|
"topics": [t.get("name") if isinstance(t, dict) else t for t in (p.get("topics") or [])],
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
def fetch_gitlink_issues(repos, max_per_repo=10):
|
||||||
|
"""Per-repo issue +list (open) -> list of {repo, number, subject, status, participants}.
|
||||||
|
Works around search +issues returning HTML."""
|
||||||
|
out = []
|
||||||
|
for r in repos:
|
||||||
|
if not (r.get("owner") and r.get("repo")):
|
||||||
|
continue
|
||||||
|
d = _gitlink("issue", "+list", "--owner", r["owner"], "--repo", r["repo"], "--state", "open")
|
||||||
|
data = d.get("data", {}) or {}
|
||||||
|
issues = data.get("issues") or []
|
||||||
|
for it in issues[:max_per_repo]:
|
||||||
|
st = (it.get("status") or {})
|
||||||
|
if st.get("name") == "关闭":
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"repo": f'{r["owner"]}/{r["repo"]}',
|
||||||
|
"number": it.get("project_issues_index") or it.get("number"),
|
||||||
|
"subject": it.get("subject"),
|
||||||
|
"status": st.get("name"),
|
||||||
|
"participants": it.get("participants_count") or 0,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Smoke test (live)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd skills/gitlink-spark/scripts && PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -c "
|
||||||
|
from spark import fetch_gitlink_repos, fetch_gitlink_issues
|
||||||
|
repos = fetch_gitlink_repos('图神经网络')
|
||||||
|
print('repos:', len(repos))
|
||||||
|
if repos: print(' sample:', repos[0]['owner'], '/', repos[0]['repo'])
|
||||||
|
iss = fetch_gitlink_issues(repos[:2])
|
||||||
|
print('issues(from first 2 repos):', len(iss))
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: `repos: N` (N≥1, includes GraphGallery-class), `issues: M`. If `repos: 0`, the field keyword missed — retry with `'graph neural'`. If issues JSON parse fails, inspect gitlink-cli `issue +list` structure and adapt field names.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-spark/scripts/spark.py
|
||||||
|
git commit -m "feat(spark): fetch_gitlink_repos + fetch_gitlink_issues(逐仓库,绕开 search+issues HTML)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: spark.py — fetch_github_count + main() + end-to-end smoke
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/gitlink-spark/scripts/spark.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add fetch_github_count (cache+throttle) and implement main()**
|
||||||
|
|
||||||
|
Replace `def main(): pass` and add fetch_github_count before it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_GH_CACHE = {}
|
||||||
|
|
||||||
|
def fetch_github_count(query, token=None, throttle=True):
|
||||||
|
"""GitHub search total_count + top3 for a query. Caches + throttles (10/min unauth)."""
|
||||||
|
if query in _GH_CACHE:
|
||||||
|
return _GH_CACHE[query]
|
||||||
|
url = f"{GITHUB_ENDPOINT}?q={urllib.parse.quote(query)}&per_page=3&sort=stars"
|
||||||
|
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json",
|
||||||
|
"User-Agent": "gitlink-spark/1.0"})
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"Bearer {token}")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=25) as r:
|
||||||
|
res = parse_github_search(r.read().decode("utf-8", "replace"))
|
||||||
|
except Exception as e:
|
||||||
|
res = {"total_count": None, "top": [], "error": str(e)[:80]}
|
||||||
|
if throttle and not token:
|
||||||
|
time.sleep(7) # unauthenticated = 10 req/min
|
||||||
|
_GH_CACHE[query] = res
|
||||||
|
return res
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="gitlink-spark data fusion")
|
||||||
|
ap.add_argument("--field", required=True)
|
||||||
|
ap.add_argument("--max-papers", type=int, default=10)
|
||||||
|
ap.add_argument("--gap-type", default="both", choices=["both", "theory", "demand"])
|
||||||
|
ap.add_argument("--github-token", default=os.environ.get("GITHUB_TOKEN"))
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
papers = fetch_arxiv(args.field, args.max_papers)
|
||||||
|
grepos = fetch_gitlink_repos(args.field)
|
||||||
|
gissues = fetch_gitlink_issues(grepos) if args.gap_type in ("both", "demand") else []
|
||||||
|
gh_counts = {}
|
||||||
|
if args.gap_type in ("both", "theory"):
|
||||||
|
for p in papers:
|
||||||
|
q = p["title"][:60] # primary query = paper title (truncated)
|
||||||
|
gh_counts[q] = fetch_github_count(q, args.github_token)
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"field": args.field,
|
||||||
|
"papers": papers,
|
||||||
|
"gitlink_repos": grepos,
|
||||||
|
"gitlink_issues": gissues,
|
||||||
|
"github_counts": gh_counts,
|
||||||
|
}
|
||||||
|
json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: End-to-end smoke (tiny, live)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd skills/gitlink-spark/scripts && PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python spark.py --field "graph neural network" --max-papers 3 --gap-type theory > _smoke.json 2>&1
|
||||||
|
PYTHONUTF8=1 python -c "
|
||||||
|
import json
|
||||||
|
d=json.load(open('_smoke.json',encoding='utf-8'))
|
||||||
|
print('field:', d['field'])
|
||||||
|
print('papers:', len(d['papers']), '| gitlink_repos:', len(d['gitlink_repos']))
|
||||||
|
print('github_counts keys:', len(d['github_counts']))
|
||||||
|
g=d['github_counts']
|
||||||
|
for k,v in list(g.items())[:1]: print(' sample gh:', k[:30], '-> total', v.get('total_count'))
|
||||||
|
"
|
||||||
|
rm -f _smoke.json
|
||||||
|
```
|
||||||
|
Expected: papers=3, gitlink_repos≥1, github_counts has 3 entries with real total_count ints. Takes ~25s (3 GitHub calls × 7s throttle). If GitHub 403 rate-limit, set `GITHUB_TOKEN` env or wait 60s.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-run unit tests (regression)**
|
||||||
|
|
||||||
|
Run: `cd skills/gitlink-spark/scripts && python test_spark.py`
|
||||||
|
Expected: `ALL TESTS PASSED`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-spark/scripts/spark.py
|
||||||
|
git commit -m "feat(spark): fetch_github_count + main() 编排 → JSON(端到端 smoke 通过)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: REFERENCE.md — gap taxonomy detail + GitHub tiers + LLM prompt + data sources
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/gitlink-spark/REFERENCE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create REFERENCE.md**
|
||||||
|
|
||||||
|
Content (exact):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# gitlink-spark 参考文档
|
||||||
|
|
||||||
|
> SKILL.md 的深度参考:缺口分类细则、GitHub 阈值、LLM prompt 模板、数据源实测、诚实边界。
|
||||||
|
|
||||||
|
## 一、缺口分类法细则
|
||||||
|
|
||||||
|
### 类型 A 有理论无实现
|
||||||
|
- 输入:arXiv 论文方法 M(title + method_keywords)
|
||||||
|
- GitLink 侧:`search +repos -k <M>` 命中数(0 或极少,如 ≤2)
|
||||||
|
- GitHub 侧:`total_count`(按 §二阈值分级)
|
||||||
|
- 判定为"缺口"条件:GitLink ≤2 **且** GitHub < 50(全球稀缺或新兴)
|
||||||
|
|
||||||
|
### 类型 B 有需求无解答
|
||||||
|
- 输入:领域仓库的 open issue(`issue +list`,排除关闭)
|
||||||
|
- LLM 筛"研究性痛点":含性能/可扩展性/新场景/新数据集,排除安装报错/使用咨询
|
||||||
|
- 判定:GitLink 无现成实现解此痛点 **且** GitHub 无成熟开源方案
|
||||||
|
|
||||||
|
## 二、GitHub 全球对照阈值
|
||||||
|
|
||||||
|
| total_count | 分级 | 报告 |
|
||||||
|
|---|---|---|
|
||||||
|
| < 10 | 全球稀缺 | 高价值缺口 |
|
||||||
|
| 10–50 | 新兴 | 中等缺口 |
|
||||||
|
| ≥ 50 | 已成熟 | **不报为空白**,列入"已诚实排除" |
|
||||||
|
|
||||||
|
spark.py 缓存 GitHub 结果(按 query key),避免重复调用。
|
||||||
|
|
||||||
|
## 三、LLM 缺口匹配 prompt 模板
|
||||||
|
|
||||||
|
```
|
||||||
|
你是科研机会发现助手。下面是 spark.py 抓取的真实数据(JSON)。
|
||||||
|
请跨"arXiv 论文 × GitLink 仓库/issues × GitHub 全球计数"找出语义缺口,输出机会报告。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 只输出可溯源到下列数据的缺口;每张缺口卡带"实证三件套"。
|
||||||
|
2. 类型A(理论无实现):论文 M 的 GitLink 命中≤2 且 GitHub total_count<50 才报;
|
||||||
|
GitHub ≥50 的论文列入"已诚实排除",不报为空白。
|
||||||
|
3. 类型B(需求无解答):只挑研究性痛点 issue,排除使用/安装类。
|
||||||
|
4. 每张卡给一句"机会建议"(主观),但证据必须客观可查。
|
||||||
|
5. 宁可少报,不误报。
|
||||||
|
|
||||||
|
数据:
|
||||||
|
{spark.py 的 JSON}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 四、数据源实测结论(2026-07-01)
|
||||||
|
|
||||||
|
| 源 | 状态 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| arXiv API | ✅ 必须 HTTPS | HTTP 被沙箱阻断返回 0 字节 |
|
||||||
|
| gitlink-cli search +repos | ✅ | 用 identifier/关键词 |
|
||||||
|
| gitlink-cli issue +list | ✅ | 逐仓库,绕开 search+issues |
|
||||||
|
| gitlink-cli search +issues | ❌ 返回 HTML | 不可用,勿用 |
|
||||||
|
| GitHub Search API | ✅ | 未认证 10/min;GITHUB_TOKEN 提额 |
|
||||||
|
| OpenAlex | ⚠ 间歇 503 | best-effort 富集,降级跳过 |
|
||||||
|
|
||||||
|
## 五、诚实边界
|
||||||
|
|
||||||
|
1. **GitLink 覆盖薄**:缺口卡明确标 "GitLink 0 / GitHub N";GitHub ≥50 不报为空白。
|
||||||
|
2. LLM 缺口必须可溯源实证三件套,否则丢弃。
|
||||||
|
3. arXiv 仅覆盖 CS/物理等,报告标注学科范围。
|
||||||
|
4. GitHub 未认证 10/min:spark.py sleep 7s + 缓存;建议 demo 设 GITHUB_TOKEN。
|
||||||
|
5. "机会建议"为主观启发,标注"需研究者自行判断"。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify**
|
||||||
|
|
||||||
|
Run: `grep -c -e "类型 A 有理论无实现" -e "GitHub ≥50 不报为空白" -e "search +issues.*HTML" -e "LLM 缺口匹配 prompt" skills/gitlink-spark/REFERENCE.md`
|
||||||
|
Expected: `4`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-spark/REFERENCE.md
|
||||||
|
git commit -m "feat(spark): REFERENCE(缺口分类法+GitHub阈值+LLM prompt+数据源实测)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Live GNN run — produce 2-3 gap cards + ≥1 kickoff
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- (no committed code; produces real data feeding Task 10's examples doc)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run spark.py on GNN (full)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd skills/gitlink-spark/scripts
|
||||||
|
PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python spark.py --field "graph neural network" --max-papers 8 --gap-type both > _gnn.json 2>&1
|
||||||
|
PYTHONUTF8=1 python -c "
|
||||||
|
import json
|
||||||
|
d=json.load(open('_gnn.json',encoding='utf-8'))
|
||||||
|
print('papers:',len(d['papers']),'| repos:',len(d['gitlink_repos']),'| issues:',len(d['gitlink_issues']))
|
||||||
|
# 列出 GitHub 稀缺(<10) 的论文 = 理论缺口候选
|
||||||
|
for p in d['papers']:
|
||||||
|
q=p['title'][:60]; gh=d['github_counts'].get(q,{})
|
||||||
|
tc=gh.get('total_count')
|
||||||
|
if tc is not None and tc < 10:
|
||||||
|
print(' THEORY gap cand:', p['arxiv_id'], '|', p['title'][:45], '| GitHub', tc)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: papers=8, repos≥1, and ≥1 THEORY gap candidate (GitHub <10). Note the candidate arxiv_ids + GitHub counts. Keep `_gnn.json` for the examples doc.
|
||||||
|
|
||||||
|
- [ ] **Step 2: LLM-match gap cards from _gnn.json**
|
||||||
|
|
||||||
|
Following REFERENCE §三 prompt, produce the opportunity report from `_gnn.json`:
|
||||||
|
- ≥1 "理论无实现" gap card (from Step 1 candidates) — with evidence triple
|
||||||
|
- ≥1 "需求无解答" gap card (from gitlink_issues, if any research-pain issue; if issues empty/none research-y, note honestly and lean on theory gaps + lower the demand bar OR widen field keyword)
|
||||||
|
- ≥1 "已诚实排除" entry (a paper with GitHub ≥50)
|
||||||
|
Save the report text (will go into Task 10 examples). If no demand-side issue exists, be honest: report 2 theory gaps + 1 排除, note demand-side sparse for GNN on GitLink.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Kickoff (fork + issue) on ONE theory gap**
|
||||||
|
|
||||||
|
Pick the best theory gap (GitHub <10). Fork its GitHub top repo's nearest GitLink equivalent OR the GitHub top repo isn't forkable via gitlink-cli (cross-platform) — instead: if a GitLink baseline exists, `gitlink-cli repo +fork` it; else create a todo issue on an existing GitLink GNN repo (e.g. GraphGallery) describing the reproduction plan with the paper's pseudocode.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 若有 GitLink 基准仓库,fork 它;否则在 leejt/GraphGallery 开个复现 todo issue
|
||||||
|
gitlink-cli issue +create --owner leejt --repo GraphGallery \
|
||||||
|
--title "Reproduction todo: <paper title> (gitlink-spark 机会)" \
|
||||||
|
--body "<论文 arxiv 链接 + Algorithm 伪代码摘要 + 机会报告卡>"
|
||||||
|
```
|
||||||
|
Capture the issue URL/number. If write fails (no permission on leejt/GraphGallery), fall back: create the issue on your own fork (fork first) OR output the todo text for manual creation. Note the actual outcome.
|
||||||
|
|
||||||
|
- [ ] **Step 4: No commit (data-gathering)** — proceed to Task 10.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: examples/spark-图神经网络.md — real walkthrough
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/gitlink-spark/examples/spark-图神经网络.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the real walkthrough using Task 9 outputs**
|
||||||
|
|
||||||
|
Content skeleton (fill with REAL data from `_gnn.json` + Task 9 report/kickoff — no placeholders):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 示例:gitlink-spark GNN 缺口挖掘(真实数据)
|
||||||
|
|
||||||
|
> 基于 `spark.py --field "graph neural network" --max-papers 8` 于 2026-07-XX 实跑。
|
||||||
|
|
||||||
|
## 数据采集(真实)
|
||||||
|
- arXiv 论文:N 篇(近 90 天)
|
||||||
|
- GitLink 仓库:M 个(含 <列举>)
|
||||||
|
- GitLink open issues:K 条
|
||||||
|
- GitHub 全球对照:8 个查询
|
||||||
|
|
||||||
|
## 机会报告(真实全文)
|
||||||
|
<贴 Task 9 §2 的报告全文,含 ≥1 理论缺口 + ≥1 排除,每张带实证三件套>
|
||||||
|
|
||||||
|
## 起跑(真实)
|
||||||
|
<fork/issue 链接或降级说明>
|
||||||
|
|
||||||
|
## 关键结论
|
||||||
|
- 三源融合真实可跑
|
||||||
|
- GitHub 阈值生效(≥50 不报为空白)
|
||||||
|
- 每条缺口可溯源到 spark.py JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no placeholders**
|
||||||
|
|
||||||
|
Run: `grep -E "TBD|TODO|<论文|<列举|<贴 Task" skills/gitlink-spark/examples/spark-图神经网络.md`
|
||||||
|
Expected: no matches. If any, fill from Task 9 outputs.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Clean temp + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -f skills/gitlink-spark/scripts/_gnn.json
|
||||||
|
git add skills/gitlink-spark/examples/spark-图神经网络.md
|
||||||
|
git commit -m "feat(spark): examples GNN 真实走查(2-3缺口卡+起跑)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: README + workflow link + register + acceptance
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/README.md`
|
||||||
|
- Modify: `skills/gitlink-workflow/SKILL.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register the skill**
|
||||||
|
|
||||||
|
Run: `bash scripts/setup-skills.sh` then `ls ~/.claude/skills/ | grep gitlink-spark`
|
||||||
|
Expected: `gitlink-spark` listed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add README row**
|
||||||
|
|
||||||
|
In `skills/README.md` 智能 Skills table, after the gitlink-research-fair row add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| **gitlink-spark** | 文献-代码语义缺口挖掘机 | arXiv×GitLink×GitHub 三源挖"理论无实现/需求无解答"缺口,出机会报告,一键 fork+issue 起跑 |
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify: `grep -c gitlink-spark skills/README.md` → `≥1`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: workflow cross-link**
|
||||||
|
|
||||||
|
In `skills/gitlink-workflow/SKILL.md` 专项 Skill list, add:
|
||||||
|
```markdown
|
||||||
|
> - 科研机会发现(缺口挖掘) → [`../gitlink-spark/SKILL.md`](../gitlink-spark/SKILL.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Acceptance (spec §15)**
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
- [ ] 四件套齐全(SKILL.md + spark.py + REFERENCE.md + examples/)
|
||||||
|
- [ ] `python spark.py --field 图神经网络` 输出合法 JSON
|
||||||
|
- [ ] GNN 跑出 ≥1 理论 + ≥1 demand(或诚实标注 demand 稀疏)缺口卡,三件套可查
|
||||||
|
- [ ] GitHub 阈值生效(≥1 "已诚实排除")
|
||||||
|
- [ ] ≥1 理论缺口走完起跑
|
||||||
|
- [ ] 每条缺口可溯源 spark.py JSON
|
||||||
|
- [ ] REFERENCE 含分类法+数据源实测+诚实边界
|
||||||
|
- [ ] README 登记
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit + push (updates PR #5)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/README.md skills/gitlink-workflow/SKILL.md
|
||||||
|
git commit -m "docs(spark): README 登记 + workflow 链接 + 注册"
|
||||||
|
git push myfork feat/gitlink-research-fair
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,759 @@
|
||||||
|
# gitlink-research-fair v2 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Upgrade `gitlink-research-fair` from "5-axis FAIR card" to **科研软件 X 光 (Research Software X-Ray)** — a runnable `fair.py` extracts a real research profile (paper/datasets/repro/citation), LLM renders a varied per-repo report (verdict line + real Mermaid knowledge graph + repo-specific findings) with optional prescription.
|
||||||
|
|
||||||
|
**Architecture:** `scripts/fair.py` = stdlib-only deterministic extractor (README regex + file scan → research-profile JSON). `SKILL.md` orchestrates the LLM: 4-dimension verdicts (each citing fair.py evidence) → real knowledge graph → hybrid report → optional fork+PR prescription. Pure extractors are unit-tested; Feature_Critic + Edge live runs are integration validation.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11 stdlib only (argparse/json/subprocess/re). gitlink-cli (file +get/+list, repo +info, commit +list). Mermaid for the graph.
|
||||||
|
|
||||||
|
**Source of truth:** `docs/superpowers/specs/2026-07-07-gitlink-research-fair-v2-design.md` (read it first).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment & Gotchas (engineer must know)
|
||||||
|
|
||||||
|
- **Branch:** `feat/gitlink-research-fair` (v2 升级与 v1/spark 同 PR #5). Commit only your own files; leave pre-existing `D README_TASKB.md` / `D gitlink-cli.exe` / `?? dist/` / `?? _edge_prescription/` / `?? report-cards/` untouched.
|
||||||
|
- **Encoding:** python touching gitlink-cli output MUST run `PYTHONUTF8=1 PYTHONIOENCODING=utf-8`. Never inline Chinese in `python -c` — write a `.py` file.
|
||||||
|
- **`file +list` returns `data` as a stringified JSON** → `json.loads` it (known structure, see Task 3).
|
||||||
|
- **`file +get` content lives at `data.entries.content`** (plain text, not base64).
|
||||||
|
- **`--repo` uses identifier** (ASCII slug, e.g. `Feature_Critic`), not Chinese display name.
|
||||||
|
- **stdlib only** — no pip. Tests run via `python test_fair.py` (assert-based, no pytest).
|
||||||
|
- **v1 to remove:** the old `examples/research-fair-workflow.md` (songhui18 v1 report card) is replaced by v2 examples (Task 8 `git rm`s it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `skills/gitlink-research-fair/scripts/fair.py` | Stdlib extractor: README regex + file scan → research-profile JSON. Pure extractors + gitlink-cli fetchers + main() |
|
||||||
|
| `skills/gitlink-research-fair/scripts/test_fair.py` | Assert-based unit tests for pure extractors (extract_paper, extract_datasets, assess_repro, assess_citation, extract_methods_frameworks) |
|
||||||
|
| `skills/gitlink-research-fair/SKILL.md` | REWRITE: X-ray pipeline, 4-dim verdict table, hybrid report template, real KG section, prescription guardrails, degradation table |
|
||||||
|
| `skills/gitlink-research-fair/REFERENCE.md` | REWRITE: 4-dim verdict rules, real KG schema, extraction rules, data-source findings, FAIR4RS anchor |
|
||||||
|
| `skills/gitlink-research-fair/examples/feature-critic-xray.md` | Real Feature_Critic X-ray (live-demo script) |
|
||||||
|
| `skills/gitlink-research-fair/examples/edge-xray.md` | Real Edge X-ray (engine-class contrast: license-conflict finding) |
|
||||||
|
| `skills/README.md` + `skills/gitlink-workflow/SKILL.md` | Update fair description → "科研软件 X 光" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: fair.py — extract_paper + extract_datasets + tests (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/gitlink-research-fair/scripts/fair.py`
|
||||||
|
- Create: `skills/gitlink-research-fair/scripts/test_fair.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`test_fair.py` (exact):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Assert-based unit tests for fair.py pure extractors. Run: python test_fair.py"""
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from fair import extract_paper, extract_datasets, assess_repro, assess_citation, extract_methods_frameworks
|
||||||
|
|
||||||
|
SAMPLE_README = """# Feature_Critic
|
||||||
|
Demo code for 'Feature-Critic Networks for Heterogeneous Domain Generalisation'.
|
||||||
|
This paper is located at https://arxiv.org/abs/1901.11448 and will appear in ICML 2019.
|
||||||
|
Evaluated on PACS and Visual Decathlon.
|
||||||
|
|
||||||
|
@inproceedings{li2019feature,
|
||||||
|
title={Feature-Critic Networks},
|
||||||
|
booktitle={ICML}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
SAMPLE_FILES = ["README.md", "main_Feature_Critic.py", "main_baseline.py", "model_PACS.py",
|
||||||
|
"alexnet.py", "resnet.py", "vggnet.py", "data_gen_PACS.py", "get_model_dataset.sh", "utils.py"]
|
||||||
|
|
||||||
|
def test_extract_paper():
|
||||||
|
p = extract_paper(SAMPLE_README)
|
||||||
|
assert p["arxiv_id"] == "1901.11448", p["arxiv_id"]
|
||||||
|
assert p["arxiv_url"] == "https://arxiv.org/abs/1901.11448"
|
||||||
|
assert p["venue"] == "ICML"
|
||||||
|
assert p["in_readme"] is True
|
||||||
|
print("test_extract_paper OK")
|
||||||
|
|
||||||
|
def test_extract_paper_none():
|
||||||
|
p = extract_paper("# Hello\nA normal project with no paper.")
|
||||||
|
assert p["in_readme"] in (False, True) # title-only may set in_readme; arxiv must be None
|
||||||
|
assert p["arxiv_id"] is None
|
||||||
|
print("test_extract_paper_none OK")
|
||||||
|
|
||||||
|
def test_extract_datasets():
|
||||||
|
ds = extract_datasets(SAMPLE_README, SAMPLE_FILES)
|
||||||
|
names = [d["name"] for d in ds]
|
||||||
|
assert "PACS" in names and "Visual Decathlon" in names
|
||||||
|
pacs = [d for d in ds if d["name"] == "PACS"][0]
|
||||||
|
assert pacs["download_script"] and "data_gen_PACS.py" in pacs["download_script"]
|
||||||
|
print("test_extract_datasets OK")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_extract_paper(); test_extract_paper_none(); test_extract_datasets()
|
||||||
|
print("PART 1 OK (run all after Task 2)")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd skills/gitlink-research-fair/scripts && python test_fair.py`
|
||||||
|
Expected: FAIL with `ModuleNotFoundError: No module named 'fair'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write fair.py with extract_paper + extract_datasets (+ stubs for Task 2 functions so import works)**
|
||||||
|
|
||||||
|
`fair.py` (exact):
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""gitlink-research-fair v2: research software X-ray. Extract a research profile from a GitLink repo. Stdlib only."""
|
||||||
|
import argparse, json, os, sys, subprocess, re
|
||||||
|
|
||||||
|
_ARXIV_PATS = [
|
||||||
|
r'https?://arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})',
|
||||||
|
r'arXiv:(\d{4}\.\d{4,5})',
|
||||||
|
r'\b(\d{4}\.\d{4,5})\b',
|
||||||
|
]
|
||||||
|
_DOI_PAT = r'10\.\d{4,9}/\S+'
|
||||||
|
_VENUES = ["ICML", "NeurIPS", "NIPS", "ICLR", "CVPR", "ICCV", "ECCV", "ACL", "EMNLP",
|
||||||
|
"NAACL", "KDD", "WWW", "AAAI", "IJCAI", "SIGGRAPH", "Nature", "Science"]
|
||||||
|
_KNOWN_DATASETS = ["Visual Decathlon", "PACS", "ImageNet", "CIFAR-10", "CIFAR-100", "CIFAR",
|
||||||
|
"Cora", "Citeseer", "Pubmed", "MNIST", "COCO", "QM9", "ZINC", "OGB", "ogbn",
|
||||||
|
"Wikipedia", "PPI", "Reddit", "Amazon", "Yelp", "MUTAG"]
|
||||||
|
|
||||||
|
def extract_paper(readme):
|
||||||
|
"""Extract paper provenance (arxiv/doi/title/venue) from README text."""
|
||||||
|
if not readme:
|
||||||
|
return {"in_readme": False, "arxiv_id": None, "arxiv_url": None, "doi": None,
|
||||||
|
"title": None, "authors": [], "venue": None}
|
||||||
|
arxiv_id = arxiv_url = None
|
||||||
|
for pat in _ARXIV_PATS:
|
||||||
|
m = re.search(pat, readme)
|
||||||
|
if m:
|
||||||
|
arxiv_id = m.group(1); arxiv_url = f"https://arxiv.org/abs/{m.group(1)}"; break
|
||||||
|
doi = None
|
||||||
|
m = re.search(_DOI_PAT, readme)
|
||||||
|
if m:
|
||||||
|
doi = m.group(0).rstrip(").,;]")
|
||||||
|
venue = None
|
||||||
|
for v in _VENUES:
|
||||||
|
if re.search(rf"\b{re.escape(v)}\b", readme):
|
||||||
|
venue = v; break
|
||||||
|
title = None
|
||||||
|
m2 = (re.search(r"[Cc]ode (?:for|of)\s+'([^']+)'", readme)
|
||||||
|
or re.search(r'[Cc]ode (?:for|of)\s+"([^"]+)"', readme)
|
||||||
|
or re.search(r"^\s*#\s+(.+)$", readme, re.M))
|
||||||
|
if m2:
|
||||||
|
title = m2.group(1).strip()
|
||||||
|
return {"in_readme": bool(arxiv_id or doi or title), "arxiv_id": arxiv_id,
|
||||||
|
"arxiv_url": arxiv_url, "doi": doi, "title": title, "authors": [], "venue": venue}
|
||||||
|
|
||||||
|
def extract_datasets(readme, files):
|
||||||
|
"""Identify referenced datasets (known-name match + data scripts)."""
|
||||||
|
text = readme or ""
|
||||||
|
found = []
|
||||||
|
for ds in _KNOWN_DATASETS:
|
||||||
|
if re.search(rf"\b{re.escape(ds)}\b", text, re.I):
|
||||||
|
found.append(ds)
|
||||||
|
scripts = [f for f in files if any(k in (f or "").lower()
|
||||||
|
for k in ["data_gen", "get_data", "download", "prepare_data", "data_load"])]
|
||||||
|
return [{"name": ds, "evidence": "mentioned in README",
|
||||||
|
"download_script": scripts[:2] or None, "license": None} for ds in found]
|
||||||
|
|
||||||
|
def assess_repro(files, readme): # implemented in Task 2
|
||||||
|
return {"deps_files": [], "deps_pinned": False, "entry_points": [], "expected_results": False, "env_spec": False}
|
||||||
|
|
||||||
|
def assess_citation(files, readme): # implemented in Task 2
|
||||||
|
return {"cff": False, "codemeta": False, "zenodo": False, "readme_bibtex": None}
|
||||||
|
|
||||||
|
def extract_methods_frameworks(files, readme): # implemented in Task 2
|
||||||
|
return {"methods": [], "frameworks": []}
|
||||||
|
|
||||||
|
def main(): # implemented in Task 3
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify Task-1 tests pass**
|
||||||
|
|
||||||
|
Run: `cd skills/gitlink-research-fair/scripts && python test_fair.py`
|
||||||
|
Expected: `test_extract_paper OK` / `test_extract_paper_none OK` / `test_extract_datasets OK` / `PART 1 OK`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/scripts/fair.py skills/gitlink-research-fair/scripts/test_fair.py
|
||||||
|
git commit -m "feat(fair-v2): fair.py extract_paper + extract_datasets + test(TDD)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: fair.py — assess_repro + assess_citation + extract_methods_frameworks (real impl + tests)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/gitlink-research-fair/scripts/fair.py` (replace the 3 stubs)
|
||||||
|
- Modify: `skills/gitlink-research-fair/scripts/test_fair.py` (add tests)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add tests for the 3 functions**
|
||||||
|
|
||||||
|
Append to `test_fair.py` (before the `if __name__` block):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_assess_repro():
|
||||||
|
files = ["README.md", "main_Feature_Critic.py", "requirements.txt", "model_PACS.py"]
|
||||||
|
r = assess_repro(files, SAMPLE_README)
|
||||||
|
assert "requirements.txt" in r["deps_files"]
|
||||||
|
assert r["deps_pinned"] is True
|
||||||
|
assert "main_Feature_Critic.py" in r["entry_points"]
|
||||||
|
assert r["expected_results"] is False # SAMPLE_README has no accuracy/results table
|
||||||
|
print("test_assess_repro OK")
|
||||||
|
|
||||||
|
def test_assess_citation():
|
||||||
|
c = assess_citation(SAMPLE_FILES, SAMPLE_README)
|
||||||
|
assert c["cff"] is False and c["codemeta"] is False
|
||||||
|
assert c["readme_bibtex"] and "@inproceedings" in c["readme_bibtex"]
|
||||||
|
print("test_assess_citation OK")
|
||||||
|
|
||||||
|
def test_extract_methods_frameworks():
|
||||||
|
mf = extract_methods_frameworks(SAMPLE_FILES, SAMPLE_README)
|
||||||
|
assert "domain generalisation" in mf["methods"], mf["methods"] # SAMPLE_README 提到 Domain Generalisation
|
||||||
|
assert isinstance(mf["frameworks"], list)
|
||||||
|
print("test_extract_methods_frameworks OK")
|
||||||
|
```
|
||||||
|
|
||||||
|
And replace the `if __name__ == "__main__":` block with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_extract_paper(); test_extract_paper_none(); test_extract_datasets()
|
||||||
|
test_assess_repro(); test_assess_citation(); test_extract_methods_frameworks()
|
||||||
|
print("ALL TESTS PASSED")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify the 3 new ones fail**
|
||||||
|
|
||||||
|
Run: `cd skills/gitlink-research-fair/scripts && python test_fair.py`
|
||||||
|
Expected: `test_assess_repro OK` may print then FAIL on assert (stubs return empty deps_files → `"requirements.txt" in []` is False → assert fails). Confirms stubs need replacing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the 3 stubs with real implementations**
|
||||||
|
|
||||||
|
Replace `def assess_repro(...) ...` / `def assess_citation(...) ...` / `def extract_methods_frameworks(...) ...` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def assess_repro(files, readme):
|
||||||
|
"""Static reproducibility readiness: deps + entry + env + expected results."""
|
||||||
|
name_set = {(f or "") for f in files}
|
||||||
|
deps_candidates = ["requirements.txt", "environment.yml", "go.mod", "package.json",
|
||||||
|
"Dockerfile", "setup.py", "pyproject.toml"]
|
||||||
|
deps_files = [f for f in deps_candidates if f in name_set]
|
||||||
|
entry_points = sorted([f for f in name_set if re.match(r"(main|train|run|demo)_?\w*\.py$", f, re.I)])
|
||||||
|
env_spec = any(f in ("Dockerfile", "environment.yml") for f in deps_files)
|
||||||
|
expected = bool(re.search(r"(accuracy|f1\b|bleu|rouge|results?\s*(table|in section)|table\s*\d)",
|
||||||
|
readme or "", re.I))
|
||||||
|
return {"deps_files": deps_files, "deps_pinned": bool(deps_files),
|
||||||
|
"entry_points": entry_points[:5], "expected_results": expected, "env_spec": env_spec}
|
||||||
|
|
||||||
|
def assess_citation(files, readme):
|
||||||
|
"""Citation readiness: CITATION.cff / codemeta / zenodo + README bibtex."""
|
||||||
|
name_set = {(f or "") for f in files}
|
||||||
|
m = re.search(r"@(inproceedings|article|misc|book)\{[^}]+\}", readme or "", re.S | re.I)
|
||||||
|
return {"cff": "CITATION.cff" in name_set,
|
||||||
|
"codemeta": "codemeta.json" in name_set,
|
||||||
|
"zenodo": ".zenodo.json" in name_set,
|
||||||
|
"readme_bibtex": (m.group(0)[:200] if m else None)}
|
||||||
|
|
||||||
|
def extract_methods_frameworks(files, readme):
|
||||||
|
"""Infer methods + frameworks from filenames + README."""
|
||||||
|
text = " ".join(files) + " " + (readme or "")
|
||||||
|
frameworks = []
|
||||||
|
if re.search(r"\b(torch|pytorch|nn\.module)\b", text, re.I): frameworks.append("PyTorch")
|
||||||
|
if re.search(r"\b(tensorflow|tf\.|keras)\b", text, re.I): frameworks.append("TensorFlow")
|
||||||
|
if re.search(r"\b(jax|flax|haiku)\b", text, re.I): frameworks.append("JAX")
|
||||||
|
if re.search(r"\b(sklearn|scikit-learn)\b", text, re.I): frameworks.append("scikit-learn")
|
||||||
|
methods = []
|
||||||
|
for kw in ["attention", "transformer", "contrastive", "meta-learning", "federated",
|
||||||
|
"graph", "convolution", "resnet", "gan", "diffusion", "reinforcement",
|
||||||
|
"domain generalisation", "domain generalization"]:
|
||||||
|
if re.search(rf"\b{kw}", text, re.I):
|
||||||
|
methods.append(kw)
|
||||||
|
return {"methods": methods[:6],
|
||||||
|
"frameworks": frameworks or ["unknown (infer from filenames; verify imports)"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run all tests — expect ALL PASS**
|
||||||
|
|
||||||
|
Run: `cd skills/gitlink-research-fair/scripts && python test_fair.py`
|
||||||
|
Expected: `ALL TESTS PASSED` (6 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/scripts/fair.py skills/gitlink-research-fair/scripts/test_fair.py
|
||||||
|
git commit -m "feat(fair-v2): assess_repro + assess_citation + extract_methods_frameworks(6 测试全过)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: fair.py — gitlink-cli fetchers + main + end-to-end smoke
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/gitlink-research-fair/scripts/fair.py` (add network layer + replace main stub)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add gitlink-cli fetchers + implement main()**
|
||||||
|
|
||||||
|
Insert before `def main():` and replace the `def main(): pass` stub:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _gitlink(*args):
|
||||||
|
"""Run gitlink-cli with json output; return parsed dict (UTF-8 safe)."""
|
||||||
|
r = subprocess.run(["gitlink-cli"] + list(args) + ["--format", "json"],
|
||||||
|
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60)
|
||||||
|
raw = r.stdout
|
||||||
|
i = raw.find("{")
|
||||||
|
return json.loads(raw[i:]) if i >= 0 else {}
|
||||||
|
|
||||||
|
def fetch_readme(owner, repo):
|
||||||
|
d = _gitlink("file", "+get", "--owner", owner, "--repo", repo, "--path", "README.md")
|
||||||
|
ent = (d.get("data", {}) or {}).get("entries", {}) or {}
|
||||||
|
return ent.get("content", "") if isinstance(ent, dict) else ""
|
||||||
|
|
||||||
|
def fetch_file_list(owner, repo):
|
||||||
|
d = _gitlink("file", "+list", "--owner", owner, "--repo", repo)
|
||||||
|
fd = d.get("data", "[]")
|
||||||
|
if isinstance(fd, str):
|
||||||
|
fd = json.loads(fd)
|
||||||
|
return [f.get("name") for f in fd if isinstance(f, dict)] if isinstance(fd, list) else []
|
||||||
|
|
||||||
|
def fetch_repo_meta(owner, repo):
|
||||||
|
info = _gitlink("repo", "+info", "--owner", owner, "--repo", repo)
|
||||||
|
comm = _gitlink("commit", "+list", "--owner", owner, "--repo", repo, "--page", "1")
|
||||||
|
cd = comm.get("data", {})
|
||||||
|
cl = cd.get("commits") if isinstance(cd, dict) else None
|
||||||
|
head = (cl[0].get("sha") if cl and isinstance(cl, list) and cl else None)
|
||||||
|
d = info.get("data", {}) or {}
|
||||||
|
return {"identifier": d.get("identifier"), "license_id": d.get("license_id"),
|
||||||
|
"has_dataset": d.get("has_dataset"), "head_sha": head}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="gitlink-research-fair v2: research software X-ray")
|
||||||
|
ap.add_argument("--owner", required=True)
|
||||||
|
ap.add_argument("--repo", required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
readme = fetch_readme(args.owner, args.repo)
|
||||||
|
files = fetch_file_list(args.owner, args.repo)
|
||||||
|
meta = fetch_repo_meta(args.owner, args.repo)
|
||||||
|
mf = extract_methods_frameworks(files, readme)
|
||||||
|
profile = {
|
||||||
|
"repo": f"{args.owner}/{args.repo}",
|
||||||
|
"head_sha": meta.get("head_sha"),
|
||||||
|
"paper": extract_paper(readme),
|
||||||
|
"datasets": extract_datasets(readme, files),
|
||||||
|
"repro": assess_repro(files, readme),
|
||||||
|
"citation": assess_citation(files, readme),
|
||||||
|
"methods": mf["methods"],
|
||||||
|
"frameworks": mf["frameworks"],
|
||||||
|
"license": {"file": any("LICENSE" in (f or "") for f in files),
|
||||||
|
"license_id": meta.get("license_id")},
|
||||||
|
"files_count": len(files),
|
||||||
|
}
|
||||||
|
json.dump(profile, sys.stdout, ensure_ascii=False, indent=2)
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: End-to-end smoke (live, Feature_Critic)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd skills/gitlink-research-fair/scripts
|
||||||
|
PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python fair.py --owner liyiying10 --repo Feature_Critic > _xray.json 2>&1
|
||||||
|
PYTHONUTF8=1 python -c "
|
||||||
|
import json
|
||||||
|
d=json.load(open('_xray.json',encoding='utf-8'))
|
||||||
|
print('repo:', d['repo'], '| head:', (d.get('head_sha') or '')[:7])
|
||||||
|
print('paper arxiv:', d['paper']['arxiv_id'], '| venue:', d['paper']['venue'], '| in_readme:', d['paper']['in_readme'])
|
||||||
|
print('datasets:', [x['name'] for x in d['datasets']])
|
||||||
|
print('repro deps:', d['repro']['deps_files'], '| entry:', d['repro']['entry_points'], '| env:', d['repro']['env_spec'])
|
||||||
|
print('citation cff:', d['citation']['cff'], '| bibtex?', bool(d['citation']['readme_bibtex']))
|
||||||
|
print('files:', d['files_count'])
|
||||||
|
"
|
||||||
|
rm -f _xray.json
|
||||||
|
```
|
||||||
|
Expected: arxiv `1901.11448` + venue `ICML` + datasets incl `PACS`/`Visual Decathlon` + entry `main_Feature_Critic.py` + cff False + bibtex True + files 15. If arxiv None, inspect README (the repo may have changed; adapt regex). If `file +list` parse fails, confirm data is stringified-JSON and json.loads handles it.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-run unit tests (regression)**
|
||||||
|
|
||||||
|
Run: `cd skills/gitlink-research-fair/scripts && python test_fair.py`
|
||||||
|
Expected: `ALL TESTS PASSED`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/scripts/fair.py
|
||||||
|
git commit -m "feat(fair-v2): gitlink-cli fetchers + main() 编排 → 科研画像 JSON(Feature_Critic smoke 通过)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Rewrite SKILL.md (X-ray pipeline + 4-dim + hybrid report + real KG + prescription + degradation)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify (overwrite): `skills/gitlink-research-fair/SKILL.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Overwrite SKILL.md with the v2 content**
|
||||||
|
|
||||||
|
New `SKILL.md` (exact — this replaces the v1 5-axis content entirely):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: gitlink-research-fair
|
||||||
|
version: 2.0.0
|
||||||
|
description: "科研软件 X 光:用 fair.py 真抽取 GitLink 科研仓库的论文/数据/复现/引用画像,LLM 四维裁决,输出含真科研图谱与特有关键发现的洞察报告,可选处方 PR。当用户需要深挖科研仓库的科研产物、评估可复现/可引用性时触发。"
|
||||||
|
metadata:
|
||||||
|
requires:
|
||||||
|
bins: ["gitlink-cli"]
|
||||||
|
cliHelp: "python skills/gitlink-research-fair/scripts/fair.py --help"
|
||||||
|
---
|
||||||
|
|
||||||
|
# gitlink-research-fair v2(科研软件 X 光)
|
||||||
|
|
||||||
|
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||||
|
**CRITICAL — 裁决由 LLM 做出,但每条必须引用 `fair.py` 抽到的实证(arxiv id / 文件名 / deps 状态);无实证的判断丢弃。**
|
||||||
|
**CRITICAL — 处方(开 PR)默认预览确认;绝不自动 merge、绝不 force-push、绝不碰原仓库。**
|
||||||
|
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`。**
|
||||||
|
|
||||||
|
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md);四维裁决细则、真 KG schema、抽取规则见 [`REFERENCE.md`](REFERENCE.md)。
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
**科研软件 X 光**:`scripts/fair.py` 真抽取仓库内容(README/文件/依赖)→ 科研画像 JSON;LLM 对 4 个科研专属维度裁决(论文溯源/数据链/复现就绪/引用就绪),渲染**每 repo 特有的洞察报告**(裁决总览 + 真科研图谱 + 关键发现),可选处方 PR。与 `gitlink-health`(项目过程健康)正交,与 `gitlink-spark`(跨仓挖缺口)互补——本 skill **单仓深挖科研产物**。
|
||||||
|
|
||||||
|
## 命令接口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python skills/gitlink-research-fair/scripts/fair.py --owner <owner> --repo <identifier>
|
||||||
|
# → stdout: 科研画像 JSON {paper, datasets, repro, citation, methods, frameworks, license, head_sha, files_count}
|
||||||
|
```
|
||||||
|
|
||||||
|
skill 约定参数(非 CLI flag):
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--owner/--repo` | 自动从 cwd 解析 | 目标科研仓库(用 identifier) |
|
||||||
|
| `--auto` | 关 | 跳过预览直接开处方 PR(仍受护栏) |
|
||||||
|
| `--no-fork` | 关 | 只出 X 光报告,不开 PR |
|
||||||
|
| `--refresh` | 关 | 即使有旧哨兵也重评 |
|
||||||
|
|
||||||
|
## 管道
|
||||||
|
|
||||||
|
### ① 抽取(fair.py,确定性)
|
||||||
|
`fetch_readme`(file +get)+ `fetch_file_list`(file +list)+ `fetch_repo_meta`(repo +info + commit +list 取 HEAD sha)→ `extract_paper` / `extract_datasets` / `assess_repro` / `assess_citation` / `extract_methods_frameworks` → 科研画像 JSON
|
||||||
|
|
||||||
|
### ② 四维裁决(LLM,读 JSON)
|
||||||
|
论文溯源 / 数据链 / 复现就绪 / 引用就绪。每维 `✅/⚠️/❌` + **引用画像字段的具体证据**。规则见 [`REFERENCE.md`](REFERENCE.md)。
|
||||||
|
|
||||||
|
### ③ 真科研图谱(从画像生成 Mermaid)
|
||||||
|
节点 `Paper↔Method↔Code↔Dataset↔Framework↔Citation`,带状态色(✅绿/⚠️黄/❌红)。schema 见 REFERENCE。
|
||||||
|
|
||||||
|
### ④ 渲染 X 光报告(hero)并落盘
|
||||||
|
裁决一行 + 真图谱 + **本 repo 特有关键发现** + 处方摘要 + 双裁决证书。**始终保存** `report-cards/<owner>-<repo>-xray.md`(绝不只在终端)。
|
||||||
|
|
||||||
|
### ⑤ 处方(可选)
|
||||||
|
对 ❌/⚠️ 项生成 CITATION.cff(从 README 抽的引用)/ requirements.txt(从 import 扫)/ Dockerfile → fork → PR(默认预览)。
|
||||||
|
|
||||||
|
## 四维裁决(速览,细则见 REFERENCE)
|
||||||
|
|
||||||
|
| 维度 | ✅ | ⚠️ | ❌ |
|
||||||
|
|------|---|----|----|
|
||||||
|
| 论文溯源 | arxiv/DOI + 元数据全 | 仅 README 文字,无稳定链接 | 无论文线索 |
|
||||||
|
| 数据链 | 命名 + 下载脚本 + license | 命名但无脚本/无 license | 未提及数据集 |
|
||||||
|
| 复现就绪 | 依赖锁+入口+环境+期望结果齐全 | 有入口但缺依赖锁/环境/期望结果 | 无入口/无依赖 |
|
||||||
|
| 引用就绪 | CITATION.cff/codemeta + DOI + 版本 | 仅 README 引用文本 | 无引用信息 |
|
||||||
|
|
||||||
|
## 报告格式(混合主视觉,hero)
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
🔬 **科研软件 X 光 — <owner>/<repo>**
|
||||||
|
|
||||||
|
═══════════════════════════════════════
|
||||||
|
论文溯源 <V> | 数据链 <V> | 复现就绪 <V> | 引用就绪 <V>
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
### 🧬 真科研图谱
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
P[<paper venue+arxiv>]:::ok -->|proposes| M[<method>]
|
||||||
|
M -->|implements| C[<entry file>]:::ok
|
||||||
|
C -->|uses| D[<datasets>]:::warn
|
||||||
|
C -->|depends| F[<framework>]:::warn
|
||||||
|
P -->|cited-via| Ci[<CITATION? or 无>]:::bad
|
||||||
|
classDef ok fill:#cfe,stroke:#3a3; classDef warn fill:#ffe,stroke:#cc3; classDef bad fill:#fee,stroke:#c33;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔍 关键发现(本 repo 特有)
|
||||||
|
- <LLM 从画像抽出的 ≥3 条具体发现,每条引用 fair.py 字段>
|
||||||
|
|
||||||
|
### 🔧 处方(可选)
|
||||||
|
- <对 ❌/⚠️ 项的修复建议>
|
||||||
|
|
||||||
|
### 📜 双裁决证书
|
||||||
|
复现就绪 <V> | 引用就绪 <V> | 锚定 commit `<sha>`
|
||||||
|
|
||||||
|
---
|
||||||
|
<!-- gitlink-research-fair v2 | repo:<owner>/<repo> | paper:<✅/⚠️/❌> | repro:<V> | cite:<V> | sha:<head> -->
|
||||||
|
*由 gitlink-research-fair v2(科研软件 X 光)生成。*
|
||||||
|
````
|
||||||
|
|
||||||
|
## 处方闭环 + 护栏
|
||||||
|
|
||||||
|
对 ❌/⚠️ 项生成修复:`CITATION.cff`(从 README 抽的引用文本构造)+ `requirements.txt`(从代码 import 扫)+ `Dockerfile`(模板)→ `repo +fork` → `pr +create`(PR body 带报告摘要)。**护栏**:默认预览;`--auto` 跳过但**永不 force-push、永不碰原仓库、永不自动 merge**;`--no-fork` 报告已落盘(④),不开 PR。
|
||||||
|
|
||||||
|
## 错误处理与降级
|
||||||
|
|
||||||
|
| 情况 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| README 读失败 | 降级用 file list + 元数据,标注"README 不可读,结论受限" |
|
||||||
|
| `file +list` data 为字符串 | json.loads 解套 |
|
||||||
|
| arxiv/DOI 抽不到 | 论文溯源判 ⚠️/❌,据实 |
|
||||||
|
| 框架无法推断 | frameworks 标"未知",不编造 |
|
||||||
|
| `--repo` 用中文显示名 404 | 提示用 identifier |
|
||||||
|
| fork/PR 失败 | 处方物料落本地,告知路径 |
|
||||||
|
| 报告/PR 发布失败 | 报告已在 ④ 落盘,告知路径 |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the rewrite removed v1 content**
|
||||||
|
|
||||||
|
Run: `grep -c -e "5 轴评分 Rubric" -e "Repro 可复现" -e "五轴雷达" skills/gitlink-research-fair/SKILL.md`
|
||||||
|
Expected: `0` (v1 5-axis content gone). Then `grep -c -e "科研软件 X 光" -e "四维裁决" -e "真科研图谱" skills/gitlink-research-fair/SKILL.md` → expect `≥3`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/SKILL.md
|
||||||
|
git commit -m "feat(fair-v2): 重写 SKILL.md(X光管道+四维裁决+混合报告+真图谱+处方),移除v1五轴"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Rewrite REFERENCE.md (4-dim rules + KG schema + extraction rules + FAIR4RS anchor)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify (overwrite): `skills/gitlink-research-fair/REFERENCE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Overwrite REFERENCE.md with v2 content**
|
||||||
|
|
||||||
|
New `REFERENCE.md` (exact — replaces v1):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# gitlink-research-fair v2 参考文档(科研软件 X 光)
|
||||||
|
|
||||||
|
> SKILL.md 的深度参考:四维裁决细则、真 KG schema、fair.py 抽取规则、数据源实测、学术锚。
|
||||||
|
|
||||||
|
## 一、四维裁决细则
|
||||||
|
|
||||||
|
每维 `✅/⚠️/❌`,**必须引用 fair.py 画像字段作证据**。
|
||||||
|
|
||||||
|
### 论文溯源(F2/R1.2)
|
||||||
|
- ✅ `paper.arxiv_id` 或 `paper.doi` 非空 + `paper.title`/`venue` 抽到
|
||||||
|
- ⚠️ 仅 `paper.title` 抽到("Code for..." 句式),无 arxiv/DOI
|
||||||
|
- ❌ `paper.in_readme` = False
|
||||||
|
|
||||||
|
### 数据链(FAIR 数据维度)
|
||||||
|
- ✅ `datasets` 非空 + 至少一个有 `download_script` + 数据 license 可考
|
||||||
|
- ⚠️ `datasets` 非空(命名)但无 download_script 或无 license
|
||||||
|
- ❌ `datasets` 为空
|
||||||
|
|
||||||
|
### 复现就绪(独立于 FAIR)
|
||||||
|
- ✅ `repro.deps_pinned`=True + `entry_points` 非空 + `env_spec`=True + `expected_results`=True(四件齐全)
|
||||||
|
- ⚠️ 有 `entry_points` 但缺依赖锁/环境/期望结果中任一
|
||||||
|
- ❌ 无 `entry_points` 或无 `deps_files`
|
||||||
|
|
||||||
|
### 引用就绪(R1.1/R2)
|
||||||
|
- ✅ `citation.cff` 或 `citation.codemeta` 为 True + 有版本/DOI
|
||||||
|
- ⚠️ 仅 `citation.readme_bibtex` 非空(README 有引用文本,无机器可读文件)
|
||||||
|
- ❌ 三者皆 False
|
||||||
|
|
||||||
|
## 二、真科研图谱 schema(Mermaid)
|
||||||
|
|
||||||
|
节点:`Paper(venue+arxiv)` / `Method` / `Code(entry file)` / `Dataset` / `Framework` / `Citation`
|
||||||
|
边:`Paper —proposes→ Method`、`Method —implements→ Code`、`Code —uses→ Dataset`、`Code —depends→ Framework`、`Paper —cited-via→ Citation`
|
||||||
|
状态色(按对应维度裁决):✅ `classDef ok fill:#cfe` / ⚠️ `classDef warn fill:#ffe` / ❌ `classDef bad fill:#fee`
|
||||||
|
|
||||||
|
## 三、fair.py 抽取规则
|
||||||
|
|
||||||
|
- **arxiv**:三路正则(arxiv URL / `arXiv:id` / 裸 `\d{4}.\d{4,5}`),取首个命中
|
||||||
|
- **venue**:白名单(ICML/NeurIPS/ICLR/CVPR/ACL/...)正则
|
||||||
|
- **datasets**:已知名白名单(PACS/Visual Decathlon/Cora/ImageNet/...)+ 数据脚本(data_gen/get_data/download)
|
||||||
|
- **repro**:依赖文件名匹配(requirements/go.mod/environment.yml/Dockerfile/setup.py)+ 入口(main/train/run*.py)+ 期望结果(accuracy/f1/results table 正则)
|
||||||
|
- **citation**:CITATION.cff/codemeta.json/.zenodo.json 文件存在 + README `@inproceedings/@article` bibtex
|
||||||
|
- **frameworks**:torch/tensorflow/jax/sklearn 关键词(文件名+README),无则标"未知,verify imports"
|
||||||
|
|
||||||
|
## 四、数据源实测(2026-07)
|
||||||
|
|
||||||
|
| 源 | 状态 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| `gitlink-cli file +get` | ✅ | content 在 `data.entries.content`(纯文本) |
|
||||||
|
| `gitlink-cli file +list` | ✅ | `data` 是字符串化 JSON,需 json.loads |
|
||||||
|
| `gitlink-cli repo +info` | ✅ | license_id/identifier/has_dataset |
|
||||||
|
| `gitlink-cli commit +list` | ✅ | HEAD sha |
|
||||||
|
| OpenAlex | ❌ 已砍 | v1 弱环节(间歇 503),v2 不依赖 |
|
||||||
|
|
||||||
|
## 五、学术锚(FAIR4RS)
|
||||||
|
|
||||||
|
四维裁决对标 **FAIR4RS**(Barker et al. 2022, Nature Sci Data):论文溯源→F2/R1.2、数据链→FAIR-Data、复现就绪→(独立轴,FAIR 必要非充分)、引用就绪→R1.1/R2。诚实声明:这是适配版评分(社区尚无认证级自动校验器),非官方认证。
|
||||||
|
|
||||||
|
## 六、诚实边界
|
||||||
|
|
||||||
|
1. fair.py 抽取覆盖度受 README 写法影响;非标准 README 可能漏(同时匹配多句式兜底)。
|
||||||
|
2. 框架/方法为推断,标"推断"/"未知",不肯定。
|
||||||
|
3. 复现就绪是**静态**判断(依赖/入口/环境/期望结果四件套),不实际跑代码。
|
||||||
|
4. v1 的 OpenAlex 溯源已砍(避免 503 弱环节)。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify**
|
||||||
|
|
||||||
|
Run: `grep -c -e "四维裁决细则" -e "真科研图谱 schema" -e "fair.py 抽取规则" -e "FAIR4RS" skills/gitlink-research-fair/REFERENCE.md`
|
||||||
|
Expected: `4`. And `grep -c -e "5 轴" -e "OpenAlex 字段" skills/gitlink-research-fair/REFERENCE.md` → `0`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/gitlink-research-fair/REFERENCE.md
|
||||||
|
git commit -m "feat(fair-v2): 重写 REFERENCE(四维裁决细则+真KG schema+抽取规则+FAIR4RS锚)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Live run — Feature_Critic X-ray (live-demo) + 落盘
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- (produces `report-cards/liyiying10-Feature_Critic-xray.md`; data feeds Task 8 example)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run fair.py on Feature_Critic**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd skills/gitlink-research-fair/scripts
|
||||||
|
PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python fair.py --owner liyiying10 --repo Feature_Critic > _fc.json 2>&1
|
||||||
|
PYTHONUTF8=1 python -c "
|
||||||
|
import json
|
||||||
|
d=json.load(open('_fc.json',encoding='utf-8'))
|
||||||
|
assert d['paper']['arxiv_id']=='1901.11448', 'arxiv 抽取失败'
|
||||||
|
assert d['paper']['venue']=='ICML'
|
||||||
|
assert any(x['name']=='PACS' for x in d['datasets'])
|
||||||
|
assert 'main_Feature_Critic.py' in d['repro']['entry_points']
|
||||||
|
assert d['citation']['cff'] is False
|
||||||
|
print('Feature_Critic 画像 OK | head:', (d.get('head_sha') or '')[:7])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: assertions pass. Keep `_fc.json` for the report.
|
||||||
|
|
||||||
|
- [ ] **Step 2: LLM-render the X-ray report (controller)**
|
||||||
|
|
||||||
|
Read `_fc.json`, apply REFERENCE §一 verdict rules, render the full hybrid report (verdict line + Mermaid graph + ≥3 repo-specific findings + prescription + dual-verdict certificate) per SKILL.md §报告格式. Expected verdicts: 论文溯源 ✅ / 数据链 ⚠️ / 复现就绪 ⚠️ / 引用就绪 ❌.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Save report to report-cards/**
|
||||||
|
|
||||||
|
Save the rendered report to `report-cards/liyiying10-Feature_Critic-xray.md` (with sentinel). This is the live-demo artifact.
|
||||||
|
|
||||||
|
- [ ] **Step 4: No commit (data-gathering)** — proceed to Task 8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Live run — Edge X-ray (contrast) + 落盘
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- (produces `report-cards/Edgedev-Edge-Computing-Engine-xray.md`; data feeds Task 8 example)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run fair.py on Edge**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd skills/gitlink-research-fair/scripts
|
||||||
|
PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python fair.py --owner Edgedev --repo Edge-Computing-Engine > _edge.json 2>&1
|
||||||
|
PYTHONUTF8=1 python -c "
|
||||||
|
import json
|
||||||
|
d=json.load(open('_edge.json',encoding='utf-8'))
|
||||||
|
print('Edge | paper:', d['paper'].get('arxiv_id'), '| methods:', d['methods'][:3], '| frameworks:', d['frameworks'][:2])
|
||||||
|
print('license file:', d['license']['file'], '| files:', d['files_count'])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: paper arxiv likely None (Edge has no arxiv), methods incl autodiff/CNN-ish, license file True. Keep `_edge.json`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: LLM-render Edge X-ray + save**
|
||||||
|
|
||||||
|
Render the Edge X-ray (controller). The **license-conflict finding** (README "禁止闭源商用" vs Apache LICENSE — note: fair.py detects `license.file=True`; the conflict is read from README content during rendering) must appear as a key finding. Verdicts will differ from Feature_Critic (e.g., 论文溯源 ❌, no paper). Save to `report-cards/Edgedev-Edge-Computing-Engine-xray.md`. The two reports must look visibly different (proves not 千篇一律).
|
||||||
|
|
||||||
|
- [ ] **Step 3: No commit** — proceed to Task 8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: examples (2 docs) + remove v1 example
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/gitlink-research-fair/examples/feature-critic-xray.md`
|
||||||
|
- Create: `skills/gitlink-research-fair/examples/edge-xray.md`
|
||||||
|
- Remove: `skills/gitlink-research-fair/examples/research-fair-workflow.md` (v1 songhui18)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write feature-critic-xray.md (using Task 6 outputs)**
|
||||||
|
|
||||||
|
Real walkthrough: the fair.py command + the画像摘要 + the full X-ray report (from `report-cards/liyiying10-Feature_Critic-xray.md`) + a "答辩演示脚本" section (what to say when demoing live). No placeholders — all real values (arxiv 1901.11448, PACS/Visual Decathlon, etc.).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write edge-xray.md (using Task 7 outputs)**
|
||||||
|
|
||||||
|
Real walkthrough: fair.py command + 画像 + X-ray report (license-conflict finding). Emphasize the contrast with Feature_Critic (different profile → different report).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove v1 example + clean temp + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:\Users\CWQ98\Desktop\演化与运维\gitlink-cli"
|
||||||
|
git rm skills/gitlink-research-fair/examples/research-fair-workflow.md
|
||||||
|
rm -f skills/gitlink-research-fair/scripts/_fc.json skills/gitlink-research-fair/scripts/_edge.json
|
||||||
|
rm -rf skills/gitlink-research-fair/scripts/__pycache__
|
||||||
|
git add skills/gitlink-research-fair/examples/feature-critic-xray.md skills/gitlink-research-fair/examples/edge-xray.md
|
||||||
|
git commit -m "feat(fair-v2): examples Feature_Critic+Edge 真实X光走查;移除 v1 songhui18 示例"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: README + workflow update + register + acceptance + push
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/README.md`
|
||||||
|
- Modify: `skills/gitlink-workflow/SKILL.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update README fair row**
|
||||||
|
|
||||||
|
In `skills/README.md`, change the gitlink-research-fair row to:
|
||||||
|
```markdown
|
||||||
|
| **gitlink-research-fair** | 科研软件 X 光 | fair.py 抽论文/数据/复现/引用画像,四维裁决 + 真科研图谱 + 特有关键发现报告,可选处方 PR |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update workflow link text**
|
||||||
|
|
||||||
|
In `skills/gitlink-workflow/SKILL.md`, change "科研软件 FAIR 体检" → "科研软件 X 光(fair v2)".
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-register + acceptance (spec §16)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/setup-skills.sh # re-link (no-op if already linked)
|
||||||
|
ls ~/.claude/skills/ | grep gitlink-research-fair
|
||||||
|
```
|
||||||
|
Verify spec §16:
|
||||||
|
- [ ] SKILL.md(重写)+ fair.py + test_fair.py + REFERENCE.md(重写)+ 2 examples 齐全
|
||||||
|
- [ ] `python fair.py --owner liyiying10 --repo Feature_Critic` 出合法画像 JSON(arxiv 1901.11448)
|
||||||
|
- [ ] test_fair.py 6 测试全过
|
||||||
|
- [ ] Feature_Critic X 光:论文✅/数据⚠️/复现⚠️/引用❌ + 真图谱 ≥6 节点 + 关键发现 ≥3
|
||||||
|
- [ ] Edge X 光:license 冲突作为发现;与 Feature_Critic 报告明显不同
|
||||||
|
- [ ] 报告落盘 report-cards/
|
||||||
|
- [ ] v1 五轴雷达/假 KG 已从 SKILL.md 移除(grep 0)
|
||||||
|
- [ ] README/workflow 描述更新
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit + push (updates PR #5)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/README.md skills/gitlink-workflow/SKILL.md
|
||||||
|
git commit -m "docs(fair-v2): README/workflow 更新为「科研软件 X 光」+ 注册"
|
||||||
|
git push myfork feat/gitlink-research-fair
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
# 智能代码审查 Skill(gitlink-review)设计
|
||||||
|
|
||||||
|
- **日期**:2026-06-18
|
||||||
|
- **状态**:已批准,待编写实现计划
|
||||||
|
- **作者**:CWQ + Claude
|
||||||
|
- **定位**:子任务二核心场景之一——智能代码审查;纯 Markdown skill(SKILL.md + REFERENCE.md + examples/),无需 Go 代码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
gitlink-cli 现有 16 个 skill 中**没有专门的代码审查 skill**:`gitlink-workflow` 的"PR 全流程"仅在 Step 5 浅层提及(`pr +files` + "给出审查意见")。README 列的智能 skill 只有 health / changelog / triage 三件套。
|
||||||
|
|
||||||
|
本 skill 填补该缺口:引导 AI agent 对指定 GitLink PR 执行**结构化、多视角、低误报**的代码审查,并把结构化审查意见作为评论发布到 PR。
|
||||||
|
|
||||||
|
**实用性目标**:信噪比高、误报少、每条发现可执行(带 `file:line` + 修复建议 + 理由)、能稳定落地到 GitLink(`pr +comment` 已验证可用)。
|
||||||
|
|
||||||
|
**创意性目标**:多视角"评审团" + 对抗式自检(自带质量门,直击 AI code review 的误报老大难)+ 生产风险(oncall)视角。
|
||||||
|
|
||||||
|
## 2. 非目标(YAGNI)
|
||||||
|
|
||||||
|
- 不做 CI / webhook 自动触发——按需由 agent 调用
|
||||||
|
- **绝不自动 approve / merge**
|
||||||
|
- 不做跨仓库批量(单 PR 为主;批量留作可选入口,不在首版实现)
|
||||||
|
- 不修改 gitlink-cli 的 Go 代码——纯 skill 交付物
|
||||||
|
|
||||||
|
## 3. 关键决策(用户确认)
|
||||||
|
|
||||||
|
| 决策点 | 选择 |
|
||||||
|
|--------|------|
|
||||||
|
| 评论形式 | 总结评论为主(`pr +comment`,可靠)+ 行内评论增强(reviews API 可用时附加,否则降级) |
|
||||||
|
| 发表策略 | 默认预览、确认后发;`--auto` 跳过确认(仍受置信度门控) |
|
||||||
|
| 审查引擎 | 多视角评审团(6 视角全量)+ 对抗式自检 |
|
||||||
|
|
||||||
|
## 4. 文件清单与改动范围
|
||||||
|
|
||||||
|
| 文件 | 动作 | 内容 |
|
||||||
|
|------|------|------|
|
||||||
|
| `skills/gitlink-review/SKILL.md` | 新增 | 主管道、6 视角、对抗自检、降噪、输出格式、安全护栏、命令接口 |
|
||||||
|
| `skills/gitlink-review/REFERENCE.md` | 新增 | 各视角检查清单、严重度评级表、字段映射、reviews API 实测结论与降级、噪音文件规则 |
|
||||||
|
| `skills/gitlink-review/examples/review-workflow.md` | 新增 | 真实 PR 走查(含植入缺陷验证、真实输出) |
|
||||||
|
| `skills/README.md` | 修改 | 智能技能表新增 gitlink-review |
|
||||||
|
| `skills/gitlink-workflow/SKILL.md` | 修改 | 浅层 Code Review 步骤链回本 skill |
|
||||||
|
|
||||||
|
## 5. 命令接口
|
||||||
|
|
||||||
|
skill 由 agent 调用,参数(约定,非 CLI 子命令):
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--owner/--repo` | 自动从 cwd 解析 | 目标仓库 |
|
||||||
|
| `--id` | 必填 | PR 编号(`pull_request_number`,网页 URL `/pulls/N`) |
|
||||||
|
| `--lenses` | 全 6 视角 | 指定子集,如 `correctness,security` |
|
||||||
|
| `--auto` | 关 | 跳过预览确认,直接发布(仍受置信度门控) |
|
||||||
|
| `--inline` | 关 | 尝试附行内评论(reviews API 可用时) |
|
||||||
|
| `--max-findings` | 12 | 报告卡上限,防刷屏 |
|
||||||
|
| `--refresh` | 关 | 即使检测到旧审查哨兵也重审并更新 |
|
||||||
|
|
||||||
|
## 6. 数据流(8 步管道)
|
||||||
|
|
||||||
|
```
|
||||||
|
① 取上下文 pr +view / pr +files / pr +diff(核心输入)/ repo +info(语言校准、约定)
|
||||||
|
② 降噪过滤 跳过生成代码 / vendor / lock / 纯重命名 / 二进制 / minified
|
||||||
|
③ 多视角分析 6 视角并行扫 diff → 候选发现
|
||||||
|
④ 对抗式自检 逐条尝试反驳 → 丢弃不成立/低置信
|
||||||
|
⑤ 综合 跨视角去重、按 严重度×likelihood 排序、封顶 → 报告卡
|
||||||
|
⑥ 预览 默认展示给用户,确认后发(--auto 跳过)
|
||||||
|
⑦ 发布 pr +comment 发总结评论(可靠);--inline 且 reviews API 可用 → 附行内评论,否则降级
|
||||||
|
⑧ 幂等 评论内嵌哨兵 <!-- gitlink-review v1 -->,重跑检测旧审查 → 提议更新而非刷屏
|
||||||
|
```
|
||||||
|
|
||||||
|
每条候选发现的数据结构(内部):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lens": "correctness",
|
||||||
|
"severity": "high", // high(🔴) | medium(🟡) | low(🔵)
|
||||||
|
"likelihood": "likely", // likely | possible | unlikely
|
||||||
|
"file": "auth/login.go",
|
||||||
|
"line": 42,
|
||||||
|
"what": "空指针未判",
|
||||||
|
"why": "GetUser 在 token 无效时返回 nil,此处直接解引用",
|
||||||
|
"fix": "if u := GetUser(t); u != nil { ... }",
|
||||||
|
"confidence": "high" // high | medium | low
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 评审团 6 视角
|
||||||
|
|
||||||
|
| 视角 | 盯什么 |
|
||||||
|
|------|--------|
|
||||||
|
| 🔴 正确性 (correctness) | 逻辑错、边界、空值、并发竞态、资源泄漏、错误处理、类型转换 |
|
||||||
|
| 🔒 安全 (security) | 注入(SQL/命令/XSS)、鉴权越权、密钥/Token 泄露、路径穿越、不安全反序列化、弱加密 |
|
||||||
|
| ⚡ 性能 (performance) | N+1 查询、无谓拷贝、O(n²)/嵌套循环、热路径分配、缺索引、大对象常驻 |
|
||||||
|
| 🧪 测试 (tests) | 新增/改动代码有无对应测试、边界用例、断言是否有效、mock 是否合理 |
|
||||||
|
| 🧹 可维护性 (maintainability) | 命名、重复代码、圈复杂度、抽象边界、与既有约定一致性 |
|
||||||
|
| 🚨 生产风险 (prod-risk) | "合并后凌晨3点哪会炸"——可观测性/日志、回滚能力、破坏性变更(API/DB/schema)、配置依赖、降级路径 |
|
||||||
|
|
||||||
|
各视角的展开检查清单写入 `REFERENCE.md`。
|
||||||
|
|
||||||
|
## 8. 对抗式自检(质量门,创意核心)
|
||||||
|
|
||||||
|
对每条候选发现,agent 自我问以下问题,任一成立即丢弃或降级:
|
||||||
|
|
||||||
|
1. **语境已处理**:完整上下文里是否已有保护(外层判空、上游校验、框架机制)?
|
||||||
|
2. **风格冒充 bug**:这是真缺陷还是个人风格偏好?若是偏好,降级为 🔵 nit 或丢弃。
|
||||||
|
3. **幻觉检查**:我引用的 API/函数签名/语言行为是否真实存在?(不确定则不报,或标注"待确认")
|
||||||
|
4. **重复/已被覆盖**:是否与另一视角的发现其实是同一问题?
|
||||||
|
|
||||||
|
**置信度门控**:`confidence=low` 且 `severity≠high` → 丢弃。这保证只发高信号发现。
|
||||||
|
|
||||||
|
## 9. 降噪规则(自动跳过,不审查)
|
||||||
|
|
||||||
|
- 生成代码:`*.gen.go`、`*.pb.go`、`*_generated.*`、`*.min.js`、dist/、build/、target/
|
||||||
|
- 第三方:`vendor/`、`third_party/`、`node_modules/`
|
||||||
|
- 锁文件:`go.sum`、`package-lock.json`、`yarn.lock`、`pnpm-lock.yaml`、`Cargo.lock`
|
||||||
|
- 纯重命名/移动(无内容变更)
|
||||||
|
- 二进制文件、图片、字体
|
||||||
|
|
||||||
|
跳过时在报告卡"范围"行注明跳过数量,不静默吞掉。
|
||||||
|
|
||||||
|
## 10. 输出格式(报告卡,作为 PR 评论发布)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
🤖 **gitlink-review 报告**
|
||||||
|
|
||||||
|
**结论**:⚠️ 建议修改(2 🔴阻塞 / 4 🟡建议 / 3 🔵nit)
|
||||||
|
**范围**:5 文件,+120/-30(跳过 2 噪音文件)|视角:6|自检丢弃:3
|
||||||
|
|
||||||
|
| 严重度 | 视角 | 位置 | 问题 |
|
||||||
|
|:---:|---|---|---|
|
||||||
|
| 🔴 | 正确性 | auth/login.go:42 | 空指针未判 |
|
||||||
|
| 🔴 | 安全 | config.go:8 | 硬编码 Token |
|
||||||
|
| 🟡 | 性能 | list.go:88 | 循环内重复查询 |
|
||||||
|
|
||||||
|
### 🔴 阻塞(合并前需处理)
|
||||||
|
1. **[正确性] auth/login.go:42** — `GetUser(token)` 在 token 无效时返回 nil,此处直接解引用会 panic。
|
||||||
|
- **建议**:`if u := GetUser(t); u != nil { ... }`
|
||||||
|
2. **[安全] config.go:8** — 硬编码 Token,存在泄露风险。
|
||||||
|
- **建议**:改从环境变量读取 `os.Getenv("GITLINK_TOKEN")`
|
||||||
|
|
||||||
|
### 🟡 建议
|
||||||
|
…
|
||||||
|
|
||||||
|
### 🔵 nit
|
||||||
|
…
|
||||||
|
|
||||||
|
### ✅ 未发现问题的方面
|
||||||
|
- 安全:未发现注入点
|
||||||
|
- 生产风险:无破坏性 API 变更
|
||||||
|
|
||||||
|
---
|
||||||
|
<!-- gitlink-review v1 | pr:5 | lenses:6 | refuted:3 | sha:<head-sha> -->
|
||||||
|
*由 gitlink-review skill 生成;本评论为预览确认后发布。*
|
||||||
|
```
|
||||||
|
|
||||||
|
哨兵 `<!-- gitlink-review v1 | pr:N | lenses | refuted | sha -->` 用于幂等与"针对哪个 commit"标识。
|
||||||
|
|
||||||
|
## 11. 安全护栏
|
||||||
|
|
||||||
|
- 默认预览确认;`--auto` 跳过确认但**仍受置信度门控**
|
||||||
|
- **绝不自动 approve / merge**(即使 `--auto`)
|
||||||
|
- 行内评论仅在显式 `--inline` 且 reviews API 实测可用时发,否则不发
|
||||||
|
- `--max-findings` 封顶,防刷屏
|
||||||
|
- 所有写操作需认证(遵循 `gitlink-shared`)
|
||||||
|
- 跳过自己作者本人的 PR 时可提示(避免自审盲区),但不强制阻断
|
||||||
|
|
||||||
|
## 12. 错误处理与降级
|
||||||
|
|
||||||
|
| 情况 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| `pr +diff` 无差异 | 报告"无可审查变更",不发评论 |
|
||||||
|
| PR 不存在/无权限 | 清晰错误信息,不发评论 |
|
||||||
|
| reviews API 404(已知 `api POST` bug) | 记录、跳过行内、总结评论照发 |
|
||||||
|
| diff 过大(> 阈值) | 抽样审查 + 标注"部分审查(仅 X/Y 文件)" |
|
||||||
|
| `pr +comment` 发布失败 | 把报告卡原文输出给用户手动粘贴 |
|
||||||
|
| 置信度全部 low | 报告"未发现高置信问题",列出待人工确认项 |
|
||||||
|
|
||||||
|
## 13. 幂等与去重
|
||||||
|
|
||||||
|
- 评论内嵌哨兵;重跑时先 `pr +view`/取评论检测哨兵
|
||||||
|
- 已存在旧审查:默认提议"更新"(`--refresh` 才覆盖),避免重复发
|
||||||
|
- `sha` 字段记录审查所基于的 head commit;PR 有新提交时提示"审查已过期,建议重审"
|
||||||
|
|
||||||
|
## 14. 验证计划(真实数据,沿用其他 skill 惯例)
|
||||||
|
|
||||||
|
建一个**植入已知缺陷的小测试 PR**,包含:
|
||||||
|
|
||||||
|
1. 一处空指针/越界(验证 🔴 正确性视角命中)
|
||||||
|
2. 一处硬编码密钥(验证 🔒 安全视角命中)
|
||||||
|
3. 一处缺测试的新函数(验证 🧪 测试视角命中)
|
||||||
|
4. 一处"看起来像 bug 但语境已处理"的伪阳性(验证对抗自检丢弃)
|
||||||
|
|
||||||
|
验证项:
|
||||||
|
- [ ] 每个植入缺陷被对应视角命中
|
||||||
|
- [ ] 伪阳性被对抗自检丢弃(refuted 计数 +1)
|
||||||
|
- [ ] 哨兵正确内嵌,重跑不刷屏
|
||||||
|
- [ ] 总结评论通过 `pr +comment` 成功发布
|
||||||
|
- [ ] reviews API 实测:记录可用/不可用结论写入 REFERENCE.md
|
||||||
|
- [ ] `--auto` 与预览两种模式均验证
|
||||||
|
|
||||||
|
结果(真实输出)写入 `examples/review-workflow.md`。
|
||||||
|
|
||||||
|
## 15. 风险与未决
|
||||||
|
|
||||||
|
- **reviews API 可用性**:`gitlink-triage` 记录 `api POST` 有 URL 注入 bug 导致 404。本 skill 的行内评论依赖 `POST /:owner/:repo/pulls/:id/reviews`,实现时**必须实测**;不可用则设计已内建降级(仅总结评论)。结论写入 REFERENCE.md。
|
||||||
|
- **token 消耗**:6 视角全量较重;通过 `--lenses` 子集和 `--max-findings` 控制。
|
||||||
|
- **行号偏移**:diff 行号 vs 文件行号的映射需在 REFERENCE.md 给出规则,保证 `file:line` 准确。
|
||||||
|
|
||||||
|
## 16. 验收标准
|
||||||
|
|
||||||
|
1. `skills/gitlink-review/` 三件套齐全,结构与 triage/health 等智能 skill 一致
|
||||||
|
2. 测试 PR 的 4 个植入点验证全部通过(3 命中 + 1 丢弃)
|
||||||
|
3. 总结评论在真实 PR 成功发布,含哨兵
|
||||||
|
4. reviews API 可用性有明确实测结论
|
||||||
|
5. README 与 workflow 链接更新
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
# 科研软件 FAIR 体检 Skill(gitlink-research-fair)设计
|
||||||
|
|
||||||
|
- **日期**:2026-07-01
|
||||||
|
- **状态**:已批准,待编写实现计划
|
||||||
|
- **作者**:CWQ + Claude
|
||||||
|
- **定位**:子任务四(应用 GitLink 辅助科研,加分项)——把 GitLink 科研仓库转化为"FAIR 体检报告卡 + 自动修复 + 可复现证书";纯 Markdown skill(SKILL.md + REFERENCE.md + examples/),无需 Go 代码
|
||||||
|
- **架构中心**:报告卡中心(FAIR 体检报告为 hero 产出,KG/处方/证书/OpenAlex 为其栏目)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
子任务四要求把 gitlink-cli 与学术科研结合,做一个"科研分析层"skill。gitlink-cli 现有 17 个 skill 都是**数据采集/运维型**(issue/pr/search/user/pm/review/health/license/changelog/ci/repo/branch/release/triage/workflow/org/shared),缺少把仓库数据**升维成科研洞察**的能力。
|
||||||
|
|
||||||
|
本 skill 填补该缺口:对任意 GitLink 科研仓库执行**结构化、有学术标准依据、低误报**的"FAIR + 可复现性"诊断,产出一张**体检报告卡**,并对发现的缺口**自动开 PR 修复**、用 **SWH/commit 锚定版本**签发**可复现证书**。
|
||||||
|
|
||||||
|
**实用性目标**:评分有据(对标 FAIR4RS + howfairis)、每项检查可追溯证据(哪个 gitlink-cli 字段/文件)、缺口可执行修复(生成 CITATION.cff/codemeta.json/LICENSE/复现章节)、能在真实 GitLink 科研仓库跑通。
|
||||||
|
|
||||||
|
**创意性目标**:四幕剧本(**诊断 → 处方 → 证书**,v1 不含排行)+ 中国代码托管平台**原生**的科研软件诊断(现有 howfairis/Codefair 只跑 GitHub)+ 知识图谱 + OpenAlex 溯源。
|
||||||
|
|
||||||
|
## 2. 非目标(YAGNI)
|
||||||
|
|
||||||
|
- **不做多仓库 FAIR 排行榜**(第四幕"排行"留 v2)
|
||||||
|
- 不做 CI/webhook 自动触发——按需由 agent 调用
|
||||||
|
- **绝不自动 merge** 修复 PR;处方默认预览确认,`--auto` 仅跳过预览
|
||||||
|
- **绝不 force-push、绝不碰原仓库**——处方只对 fork 开 PR
|
||||||
|
- 不修改 gitlink-cli 的 Go 代码——纯 skill 交付物
|
||||||
|
- 不重复 `gitlink-license` 已有能力——许可证/安全子项**委托复用**它
|
||||||
|
|
||||||
|
## 3. 关键决策(用户确认)
|
||||||
|
|
||||||
|
| 决策点 | 选择 | 理由 |
|
||||||
|
|--------|------|------|
|
||||||
|
| v1 范围 | 诊断 + 处方 + 证书(不做排行) | 保留最惊艳的"自动修复闭环",避开多仓库验证成本 |
|
||||||
|
| 验证仓库 | `songhui18/ICCV2021论文复现`(处方走 fork) | "论文复现"主题与"可复现性"内核共振;19⭐真实活跃;无 license=现成缺口 |
|
||||||
|
| 架构中心 | 报告卡中心(A 方案) | 最强"信服"载体(FAIR4RS 逐项可核对);避免评委认知过载 |
|
||||||
|
| 许可证/安全支柱 | 复用 `gitlink-license` | 已有 4 维评分+模式库,不重复造 |
|
||||||
|
| 发表策略 | 默认预览、确认后开 PR;`--auto` 跳过预览 | 沿用既有"先预览再执行"偏好 |
|
||||||
|
|
||||||
|
## 4. 学术依据与差异化("信服"骨架,每条对抗式核验过)
|
||||||
|
|
||||||
|
| 支撑点 | 文献(已核验) | 在 skill 中的用途 |
|
||||||
|
|--------|----------------|------------------|
|
||||||
|
| 科研软件 FAIR 原则有国际标准,F1/F1.2/F2/R1.1/R1.2/R2 可逐条机器校验 | Barker et al. 2022, *Nature Sci Data* [s41597-022-01710-x](3-0 ✓×3) | 评分骨架 |
|
||||||
|
| 可复现性危机铁证:2024 ICLR/ICML/NeurIPS 论文仅 19.5% 提供官方代码 | PaperCoder [arXiv:2504.17192] Table 9(2-0 ✓) | 开场"为什么重要"硬数据 |
|
||||||
|
| 结构化对比表/报告卡是有学术先例的成熟范式 | ORKG (K-CAP 2019)(3-0 ✓) | 报告卡范式出处 |
|
||||||
|
| 仓库数据→RDF 知识图谱的实体/关系 schema 可借鉴(13 实体/47 关系/794 万三元组) | LPWC, ISWC 2023(3-0 ✓) | KG 本体设计 |
|
||||||
|
| 版本锚定用 SWH-ID(Merkle DAG + git 兼容哈希,本地可生成) | Di Cosmo et al. ICMS 2020 [PMC7340894](3-0 ✓) | 证书版本锚定 |
|
||||||
|
| 作者/机构溯源直接调 OpenAlex REST 反查(无需自建索引) | Priem et al. 2022 [arXiv:2205.01833](3-0 ✓) | 溯源富集 |
|
||||||
|
| 仓库级 KG 问答在真实仓库跑通(四实体+SZZ+Cypher,CoT 50%→90%) | Repo-KG [arXiv:2412.03815](3-0 ✓) | KG+推理可行性基线 |
|
||||||
|
| 科研软件"可用+可复现"有现成徽章体系可对标 | ACM Artifact Badging [acm.org](5 核验) | 证书/徽章锚 |
|
||||||
|
| 直接竞品 howfairis(5 维:repository/license/registry/citation/checklist)只跑 GitHub | fair-software/howfairis | 差异化:GitLink 原生 |
|
||||||
|
|
||||||
|
**差异化叙事(必须咬死)**:*第一个原生跑在中国代码托管平台上、面向任意科研仓库的实时 FAIR+可复现性诊断 → 自动修复 → 证书闭环,纯 Agent Skill 形态。* 与 Software Heritage(重存档)、Papers With Code(重列表)、OpenAlex(重文献)、ASCL(重天体软件)正交。
|
||||||
|
|
||||||
|
**战略时机**:据 TIB 2025-10 博客 *Papers With Code went offline*,PWC 出现下线/不稳定,社区在找替代——中国平台原生科研软件诊断工具是真空(⚠️ 单一二手源,pitch 前自行复核 PWC 状态)。
|
||||||
|
|
||||||
|
**诚实边界(不可过度宣称)**:
|
||||||
|
1. FAIR4RS 原文自称 **aspirational**,F1.2/R1.1/R1.2/R2 可逐条校验,但"全自动合规验证器"社区仍在建(SOMEF/FAIR EVA)——本 skill 是**自建 checker**,这本身是贡献,不说"套用现成标准工具"。
|
||||||
|
2. **FAIRness ≠ 可复现性**(FAIR 是必要非充分)——报告卡里 Repro **单独成轴**,不混为一谈。
|
||||||
|
3. OpenAlex 自 2025-02 起 freemium(免费档 ~$1/day、需 API key、100 req/s)——批量控量,单仓演示够。
|
||||||
|
4. **两条已被证伪的论点禁止使用**:①"MSR 六分类法"、②"FAIR 分高→被引更多"的因果(研究已剔除)。
|
||||||
|
5. 文献多跑在 GitHub——"在 GitLink 上复刻"是合理外推,必须真机跑通闭环。
|
||||||
|
|
||||||
|
## 5. 文件清单与改动范围
|
||||||
|
|
||||||
|
| 文件 | 动作 | 内容 |
|
||||||
|
|------|------|------|
|
||||||
|
| `skills/gitlink-research-fair/SKILL.md` | 新增 | 主管道、5 轴 rubric、报告卡模板、处方护栏、证书、KG/OpenAlex、错误降级、命令接口 |
|
||||||
|
| `skills/gitlink-research-fair/REFERENCE.md` | 新增 | FAIR4RS 逐条映射表、评分算法、KG schema(triples)、OpenAlex 字段、SWH-ID 说明、**学术引用库**、诚实边界 |
|
||||||
|
| `skills/gitlink-research-fair/examples/research-fair-workflow.md` | 新增 | `songhui18/ICCV2021论文复现` 真实走查(报告卡+处方 PR+证书+截图) |
|
||||||
|
| `skills/README.md` | 修改 | 新增 gitlink-research-fair(科研辅助类) |
|
||||||
|
| `skills/gitlink-workflow/SKILL.md` | 修改(可选) | 在合适处链回本 skill |
|
||||||
|
|
||||||
|
## 6. 命令接口
|
||||||
|
|
||||||
|
skill 由 agent 调用,参数(约定,非 CLI 子命令):
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--owner/--repo` | 自动从 cwd 解析 | 目标科研仓库 |
|
||||||
|
| `--lenses` | 全 5 轴 | 子集,如 `F,A,Repro` |
|
||||||
|
| `--auto` | 关 | 跳过预览直接开 PR(仍受护栏:不 force-push/不碰原仓/不 merge) |
|
||||||
|
| `--no-fork` | 关 | 不开 PR,处方物料落本地文件供手动处理 |
|
||||||
|
| `--enrich` | 开 | 启用 OpenAlex 溯源(限流则自动降级) |
|
||||||
|
| `--refresh` | 关 | 即使存在旧报告哨兵也重评 |
|
||||||
|
|
||||||
|
## 7. 数据流(8 步管道)
|
||||||
|
|
||||||
|
```
|
||||||
|
① 取上下文 repo +info(元数据/license/topics/has_dataset)· file +list(关键文件清单)
|
||||||
|
② 深采 file +get 读 LICENSE/CITATION.cff/codemeta.json/README/requirements/Dockerfile ·
|
||||||
|
release +list(版本)· commit +list(SHA)· issue/pr +list(协作)· member +list(作者)
|
||||||
|
③ FAIR 评分 M1 逐项评 5 轴(许可证/安全子项委托 gitlink-license 流程)→ ✓/✗/⚠/⊥ + 证据 + 置信度
|
||||||
|
④ KG 构建 M2 四实体(Repo/Contributor/File/Commit)+Paper/Dataset/License 节点 → Mermaid 小图 + triples
|
||||||
|
⑤ 溯源富集 M3 从 README/CITATION 抽论文 → OpenAlex REST 反查作者/机构(best-effort)
|
||||||
|
⑥ 渲染报告卡 hero:等级+雷达图+5 轴逐项表+处方摘要+证书栏+KG 小图+溯源栏
|
||||||
|
⑦ 处方 M4 对 ✗/⚠ 项生成 CITATION.cff/codemeta.json/LICENSE/复现章节 → 默认预览
|
||||||
|
⑧ 发布 确认后对 fork 开 PR(PR body 带报告卡摘要)+ 出 SWH/commit 锚定证书;内嵌哨兵
|
||||||
|
```
|
||||||
|
|
||||||
|
每项检查的内部数据结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"axis": "A", "id": "A2", "title": "有明确许可证",
|
||||||
|
"status": "✗", // ✓满足 | ✗缺失 | ⚠部分 | ⊥无字段(跳过)
|
||||||
|
"evidence": "repo +info.license_id 为空;根目录无 LICENSE 文件",
|
||||||
|
"confidence": "high", // high | medium | low
|
||||||
|
"fix": "建议添加 MIT 或木兰 PSL v2 LICENSE(处方可自动生成)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 评分 Rubric(核心)
|
||||||
|
|
||||||
|
> 对标 **FAIR4RS(Nature Sci Data 2022)+ howfairis 5 维**。诚实声明:这是**适配版**评分(社区尚无认证级自动校验器),不是官方认证。**FAIR ≠ 可复现**,故 Repro 单独成轴。
|
||||||
|
|
||||||
|
每项判定 `✓满足 / ✗缺失 / ⚠部分 / ⊥GitLink 无该字段(跳过并标注)`,附**证据来源** + 置信度。
|
||||||
|
|
||||||
|
| 轴 | FAIR4RS 映射 | 检查项(证据) |
|
||||||
|
|----|--------------|----------------|
|
||||||
|
| **F 可发现** | F1/F2 | F1 清晰描述(repo desc 非空且≥20 字) · F2 话题/关键词(topics) · F3 标准元数据(codemeta.json/.zenodo.json) |
|
||||||
|
| **A 可访问** | F1.2/R1.1 | A1 公开(is_public) · A2 有许可证(license_id/LICENSE)[gitlink-license] · A3 有版本发布(release tag) · A4 持久标识(commit SHA/SWH-ID) |
|
||||||
|
| **I 可互操作** | R1.1/I | I1 依赖清单(requirements/go.mod/package.json/environment.yml) · I2 许可证机器可读(SPDX/license_id) · I3 元数据标准格式(CITATION.cff/codemeta) |
|
||||||
|
| **R 可复用** | R1.2/R2 | R1 溯源/引用(CITATION.cff/README 引用) · R2 README 质量(安装+使用) · R3 源码声明[gitlink-license] · R4 无敏感泄露[gitlink-license] |
|
||||||
|
| **Repro 可复现** ⭐差异轴 | (独立于 FAIR) | Rep1 环境锁(锁版本/Dockerfile) · Rep2 数据集说明(has_dataset/README 引用) · Rep3 复现步骤(README 章节) · Rep4 版本固定 · Rep5 入口可执行(main/Makefile/CLI) |
|
||||||
|
|
||||||
|
**评分算法**:轴分 = 满足项 / (总项 − ⊥项)(⚠ 计 0.5);总评 = 5 轴均分 → 等级 **A🟢≥80 / B🟡60-79 / C🔴<60**。若某轴全部为 ⊥(GitLink 无任何可用字段),该轴记 N/A 并从总评均分中排除。`⊥` 项在报告卡"范围"行注明"GitLink 无 X 字段,已跳过 N 项",不静默吞掉。`confidence=low` 的项标注"待人工确认",不进总评分计算。
|
||||||
|
|
||||||
|
## 9. 报告卡格式(hero 产出)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
🏥 **gitlink-research-fair 科研软件体检报告**
|
||||||
|
|
||||||
|
**总体**:B 🟡 64/100(F 70 · A 50 · I 40 · R 80 · Repro 80)|版本锚定:commit a1b2c3d(无 release)
|
||||||
|
**范围**:检查 19 项(✓11 · ✗4 · ⚠2 · ⊥2,跳过:GitLink 无 codemeta 字段)
|
||||||
|
|
||||||
|
### 五轴雷达
|
||||||
|
Repro 80
|
||||||
|
/ \
|
||||||
|
R 80 F 70
|
||||||
|
\ /
|
||||||
|
I 40 —— A 50
|
||||||
|
|
||||||
|
### 逐项(节选)
|
||||||
|
| 轴 | 项 | 状态 | 证据 |
|
||||||
|
|---|---|:---:|---|
|
||||||
|
| A | A2 有许可证 | ✗ | license_id 空,根目录无 LICENSE |
|
||||||
|
| Repro | Rep3 复现步骤 | ⚠ | README 有"使用"但无完整复现流程 |
|
||||||
|
| F | F1 清晰描述 | ✓ | repo desc 详述 CV 顶会论文复现合集 |
|
||||||
|
|
||||||
|
### 🔧 处方(可自动修复 3 项)
|
||||||
|
- [R1] 生成 CITATION.cff(从 README/作者推断)
|
||||||
|
- [A2] 添加 LICENSE(MIT / 木兰 PSL v2 任选)
|
||||||
|
- [Rep3] 补"复现"章节(从入口推断)
|
||||||
|
|
||||||
|
### 📜 可复现证书
|
||||||
|
锚定版本:git+commit a1b2c3d | FAIR: B 🟡 | Repro: ⚠ 部分可复现
|
||||||
|
(完整 SWH-ID 需 swh-identify;本次用 commit-SHA 锚定)
|
||||||
|
|
||||||
|
### 🔗 溯源(OpenAlex)
|
||||||
|
未找到关联论文(README 未含可识别 DOI/标题)— 已跳过
|
||||||
|
|
||||||
|
### 🧬 科研关系图(KG)
|
||||||
|
[Mermaid: Repo—contributes→Contributor×3 · Repo—licensed?—? · Repo—has→Dataset? ]
|
||||||
|
|
||||||
|
---
|
||||||
|
<!-- gitlink-research-fair v1 | repo:songhui18/ICCV2021论文复现 | grade:B | sha:a1b2c3d -->
|
||||||
|
*由 gitlink-research-fair skill 生成。*
|
||||||
|
```
|
||||||
|
|
||||||
|
哨兵 `<!-- gitlink-research-fair v1 | repo | grade | sha -->` 用于幂等与"基于哪个 commit"标识。
|
||||||
|
|
||||||
|
## 10. 处方模块(M4,惊艳闭环)与安全护栏
|
||||||
|
|
||||||
|
对可自动修复的 ✗/⚠ 项生成物料:
|
||||||
|
|
||||||
|
| 缺口 | 生成物 |
|
||||||
|
|------|--------|
|
||||||
|
| F3 缺 codemeta.json | 从 repo +info + README 生成 codemeta.json 草稿 |
|
||||||
|
| R1 缺 CITATION.cff | 从 README/作者生成 CITATION.cff 草稿 |
|
||||||
|
| A2 缺 license | 给 MIT / 木兰 PSL v2 模板(任选) |
|
||||||
|
| Rep1 缺依赖锁 | 从 import 扫描建议 requirements.txt |
|
||||||
|
| Rep3 缺复现章节 | 从入口/README 生成"复现"章节草稿 |
|
||||||
|
|
||||||
|
**安全护栏**(沿用既有偏好):
|
||||||
|
- 默认全量预览 → 用户确认 → 对 **fork**(`caoweiqiong/ICCV2021论文复现`)开一个 PR,PR body 带报告卡摘要
|
||||||
|
- `--auto` 跳过预览,但**永不 force-push、永不碰原仓库、永不自动 merge**
|
||||||
|
- `--no-fork`:不开 PR,物料落本地文件供手动处理
|
||||||
|
- 所有写操作需认证(遵循 `gitlink-shared`)
|
||||||
|
|
||||||
|
## 11. 证书(SWH/commit 锚定)
|
||||||
|
|
||||||
|
- 版本锚定:HEAD commit SHA + release tag(若有)
|
||||||
|
- 若环境有 Python `swh.model` → 算完整 SWH-ID;否则用 `git+<commit-SHA>` 锚定,报告卡标注"完整 SWH-ID 需 swh-identify"
|
||||||
|
- 证书内容:仓库、锚定版本、FAIR 等级、Repro 状态、生成时间、可引用条目(对标 ACM Artifact Badge 的 Available/Functional 风格)
|
||||||
|
- 嵌入报告卡"证书栏";亦可单独输出可复制引用文本
|
||||||
|
|
||||||
|
## 12. KG + OpenAlex(支撑栏,非主角)
|
||||||
|
|
||||||
|
- **KG**:四实体(Repo / Contributor / File / Commit)+ Paper / Dataset / License 节点;关系 contributes-to / authored / depends-on / cites / licensed-under / version-at。输出 Mermaid 小图(报告卡内嵌)+ triples JSON(REFERENCE 给 schema)。借鉴 LPWC 实体/关系划分与 Repo-KG 四实体。
|
||||||
|
- **OpenAlex**:从 README/CITATION 抽论文标题/作者/DOI → REST `/works` 反查作者机构/发表载体/概念。降级:freemium 限流或抽不到 → 跳过,标注"未找到关联论文",不影响主报告卡。
|
||||||
|
|
||||||
|
## 13. 错误处理与降级
|
||||||
|
|
||||||
|
| 情况 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| `file +tree --recursive` 返回空 | 手动递归子目录(gitlink-license 已知坑) |
|
||||||
|
| 无 LICENSE | A2 ✗,处方建议模板 |
|
||||||
|
| OpenAlex 限流/无果 | 跳过溯源栏标注 |
|
||||||
|
| `swh.model` 不可用 | commit-SHA 锚定 + 标注 |
|
||||||
|
| 仓库过大 | 抽样文件 + "部分审查"标注 |
|
||||||
|
| fork 失败/无写权限 | 处方物料落本地文件供手动 PR |
|
||||||
|
| 二进制是 npm 旧版 | 强制 `./gitlink-cli` 或提示 `go build` 重建 |
|
||||||
|
| 仓库私有/无权限 | 清晰错误,不发报告 |
|
||||||
|
| 报告/PR 发布失败 | 报告卡原文交用户手动粘贴 |
|
||||||
|
|
||||||
|
## 14. 验证计划(真实数据)
|
||||||
|
|
||||||
|
主目标:**`songhui18/ICCV2021论文复现`**(论文复现合集,19⭐/17fork,Python/CUDA/Jupyter,无 license)。
|
||||||
|
|
||||||
|
验证项:
|
||||||
|
- [ ] 报告卡正确给出 B/C 级(因无 license,A2 ✗),5 轴评分有区分度
|
||||||
|
- [ ] 处方:fork 后成功开 1 个 PR(CITATION.cff + LICENSE + 复现章节),PR body 含报告卡摘要
|
||||||
|
- [ ] 证书:commit-SHA 锚定正确(若有 swh.model 则给完整 SWH-ID)
|
||||||
|
- [ ] **对照**:跑一个有 license 的仓库(`leejt/图神经网络快速开发测试工具` 或 `Edgedev/Edge深度学习框架`)→ 预期 A 级,证明评分有区分度
|
||||||
|
- [ ] **边界**:私有仓 / 空仓 / 无 README → 降级正确,不崩溃
|
||||||
|
- [ ] OpenAlex 溯源:能抽到论文则富集,抽不到则标注降级
|
||||||
|
- [ ] KG:Mermaid 图 + triples JSON 正确生成
|
||||||
|
|
||||||
|
结果(真实输出 + 截图)写入 `examples/research-fair-workflow.md`。
|
||||||
|
|
||||||
|
## 15. 风险与未决
|
||||||
|
|
||||||
|
- **GitLink 元数据粒度**:FAIR4RS 某些条目(如 R2 限定引用、标准 codemeta)GitLink 无专用字段 → 用 ⊥ 跳过 + 文件探测 + LLM 推断兜底,明确标注"推断 vs 已验证"。实现时需在真实仓库上确认哪些项可机器读取。
|
||||||
|
- **OpenAlex freemium 限额**:2025-02 起;单仓演示够,批量需控量。
|
||||||
|
- **SWH-ID 完整性**:纯 Markdown+CLI 环境无 `swh.model` 时只能 commit-SHA 锚定;实现时探测依赖,降级已内建。
|
||||||
|
- **GitLink GraphQL/REST 粒度**:Repo-KG 的 Cypher QA 需图数据库,本 skill **不引入图数据库**(纯 Mermaid+triples),KG 仅作可视化/溯源,不做复杂问答(YAGNI)。
|
||||||
|
- **处方 PR 真实性**:对 fork 开 PR 需 `gitlink-cli repo +fork` + 本地改文件 + push + `pr +create`;需实测 fork→PR 链路是否顺畅,不通则降级 `--no-fork`。
|
||||||
|
|
||||||
|
## 16. 验收标准
|
||||||
|
|
||||||
|
1. `skills/gitlink-research-fair/` 三件套齐全,结构与 health/changelog/triage/review 等智能 skill 一致
|
||||||
|
2. 5 轴 rubric 每项有明确证据来源 + 状态判定 + 兜底规则
|
||||||
|
3. `songhui18/ICCV2021论文复现` 真实跑通:报告卡 + 处方 PR(对 fork)+ 证书,有截图
|
||||||
|
4. 对照仓库评分有区分度(有 license → A,无 → B/C)
|
||||||
|
5. 边界情况(私有/空/无 README)降级正确
|
||||||
|
6. REFERENCE.md 含完整学术引用库 + 诚实边界(含 2 条禁用论点)
|
||||||
|
7. README 登记 gitlink-research-fair
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
# 文献-代码语义缺口挖掘机 Skill(gitlink-spark)设计
|
||||||
|
|
||||||
|
- **日期**:2026-07-01
|
||||||
|
- **状态**:已批准,待编写实现计划
|
||||||
|
- **作者**:CWQ + Claude
|
||||||
|
- **定位**:子任务四(应用 GitLink 辅助科研)的**第二部分**——与 `gitlink-research-fair`(评估已有)组成"科研辅助双联装";本 skill 负责"**发现空白**"。
|
||||||
|
- **形态**:skill + **可运行 python 脚本**(非纯 Markdown);不写 Go。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
`gitlink-research-fair` 解决"已有科研软件合不合格"。研究者还有个更核心的痛点:**找新的研究点**。他们读大量论文找灵感,却很难发现"**学术界的最新理论**"与"**开源社区的实际落地**"之间有哪些未被填补的空白——而这往往是低成本发论文/出成果的黄金地带。
|
||||||
|
|
||||||
|
本 skill 填补该缺口:给一个研究领域,跨 **arXiv(学术)× GitLink(中文生态)× GitHub(全球)** 三源,挖出两类语义缺口,输出**空白学术机会报告**,并可一键 **fork+issue 起跑**。
|
||||||
|
|
||||||
|
**实用性目标**(demo 级,非生产级全场景通用):在 GNN 领域跑出 **2-3 个漂亮真实例子**供答辩展示;每条缺口带可查实证。
|
||||||
|
|
||||||
|
**创意性目标**:从"paper→code gap"角度启发创新,区别于 Papers With Code(只链接、不挖缺口)/ OpenAlex(只文献)/ GitHub Archive(只存档)。
|
||||||
|
|
||||||
|
## 2. 非目标(YAGNI)
|
||||||
|
|
||||||
|
- **不做生产级全领域通用**——demo 级,GNN 跑通 2-3 例即可,不追求任意领域鲁棒
|
||||||
|
- 不做实时订阅/webhook——按需 agent 调用
|
||||||
|
- 不自动撰写论文/实现代码——只"提议机会 + 起跑 scaffold"
|
||||||
|
- **绝不自动 merge**;起跑(fork+issue)默认预览,`--auto` 跳过预览
|
||||||
|
- 不修改 gitlink-cli 的 Go 代码——skill + python 脚本
|
||||||
|
- 不重复 `gitlink-research-fair`——fair 评估单仓 FAIR,spark 跨仓跨源挖缺口
|
||||||
|
|
||||||
|
## 3. 关键决策(用户确认)
|
||||||
|
|
||||||
|
| 决策点 | 选择 | 理由 |
|
||||||
|
|--------|------|------|
|
||||||
|
| v1 缺口类型 | **两种都做**(有理论无实现 + 有需求无解答) | 完整愿景;demo 级 2-3 例即可 |
|
||||||
|
| 验证领域 | **图神经网络 GNN** | 三源最均衡:arXiv 海量、GitLink 少量(缺口真实)、GitHub 海量(对照鲜明) |
|
||||||
|
| GitHub 全球对照 | **硬需求**,做 | 避免"GitLink 0 ≠ 全球空白"误导;含阈值过滤 |
|
||||||
|
| 缺口匹配引擎 | **A. LLM 语义匹配为主** | 脚本抓真实数据,LLM 提缺口假设+附实证三件套;demo 级最轻最灵活 |
|
||||||
|
| 起跑动作 | fork 最近基准 + issue 粘伪代码 todo | 对称 fair 处方 PR;强 demo 闭环 |
|
||||||
|
| 交付方式 | 与 fair 同 PR #5(子任务四双联装) | 统一叙事 |
|
||||||
|
|
||||||
|
## 4. 数据源与可行性(实测 2026-07-01)
|
||||||
|
|
||||||
|
| 数据源 | 状态 | 用途 | 备注 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| arXiv API(HTTPS) | ✅ HTTP 200,返回论文条目 | ① 学术采:领域近 N 天论文 | 必须 HTTPS(HTTP 被沙箱阻断) |
|
||||||
|
| `gitlink-cli search +repos` | ✅ | ② GitLink 仓库 | 用 identifier/关键词 |
|
||||||
|
| `gitlink-cli issue +list`(逐仓库) | ✅ | ② GitLink open issue | 绕开 `search +issues`(返回 HTML 的坑) |
|
||||||
|
| GitHub Search API | ✅ HTTP 200(total_count + items) | ③ 全球对照 | 未认证 10 req/min;用 `GITHUB_TOKEN` 提至 5000/h |
|
||||||
|
| OpenAlex | ⚠ 间歇 503 | 引用计数(可选富集) | best-effort,降级跳过 |
|
||||||
|
|
||||||
|
## 5. 文件清单
|
||||||
|
|
||||||
|
| 文件 | 动作 | 内容 |
|
||||||
|
|------|------|------|
|
||||||
|
| `skills/gitlink-spark/SKILL.md` | 新增 | 4 阶段管道编排、缺口分类法、报告模板、GitHub 阈值规则、起跑护栏、错误降级、命令接口 |
|
||||||
|
| `skills/gitlink-spark/scripts/spark.py` | 新增 | **可独立运行的数据融合脚本**:arXiv + gitlink-cli + GitHub API → 输出 JSON 给 LLM;含缓存与限流 |
|
||||||
|
| `skills/gitlink-spark/REFERENCE.md` | 新增 | 缺口分类法细则、GitHub 阈值与分级、LLM prompt 模板、数据源实测结论、诚实边界 |
|
||||||
|
| `skills/gitlink-spark/examples/spark-图神经网络.md` | 新增 | GNN 真实跑出的 2-3 缺口卡(含起跑截图) |
|
||||||
|
| `skills/README.md` | 修改 | 智能技能表加 gitlink-spark |
|
||||||
|
| `skills/gitlink-workflow/SKILL.md` | 修改(可选) | 专项 skill 链表加 gitlink-spark |
|
||||||
|
|
||||||
|
## 6. 命令接口
|
||||||
|
|
||||||
|
### spark.py(独立可运行)
|
||||||
|
```bash
|
||||||
|
python skills/gitlink-spark/scripts/spark.py \
|
||||||
|
--field "图神经网络" \
|
||||||
|
[--max-papers 10] \
|
||||||
|
[--gap-type both|theory|demand] \
|
||||||
|
[--github-token $GITHUB_TOKEN] # 可选,未设则按 10/min 限流
|
||||||
|
# → stdout 输出融合 JSON:{papers, gitlink_repos, gitlink_issues, github_counts}
|
||||||
|
```
|
||||||
|
|
||||||
|
### skill 约定参数(SKILL.md,非 CLI flag)
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--field` | 必填 | 研究领域(如 `图神经网络`、`federated learning`) |
|
||||||
|
| `--gap-type` | `both` | `theory` / `demand` / `both` |
|
||||||
|
| `--max-papers` | 10 | arXiv 抓取论文上限(控制 GitHub 调用) |
|
||||||
|
| `--auto` | 关 | 跳过预览直接起跑(仍受护栏) |
|
||||||
|
| `--no-fork` | 关 | 只出报告,不起跑 |
|
||||||
|
|
||||||
|
## 7. 数据流(4 阶段管道)
|
||||||
|
|
||||||
|
```
|
||||||
|
① 学术采 spark.py: arXiv HTTPS 抓领域近 90 天论文(标题/摘要/arxiv id/方法关键词)
|
||||||
|
② GitLink spark.py: gitlink-cli search +repos 抓领域仓库;
|
||||||
|
对每个仓库 issue +list 抓 open issue(绕开 search+issues HTML)
|
||||||
|
③ 全球对照 spark.py: GitHub Search API 对每个论文方法查 total_count + Top3 仓库
|
||||||
|
④ 缺口匹配(LLM,SKILL.md 编排)
|
||||||
|
读 spark.py 输出的 JSON → 语义匹配两类缺口 → 每张带实证三件套 → 渲染机会报告
|
||||||
|
⑤ 起跑(可选) 选定缺口 → repo +fork 基准 → issue +create 粘论文伪代码 todo
|
||||||
|
```
|
||||||
|
|
||||||
|
**职责切分**:`spark.py` 只抓**真实数据**(确定性、可复现);**缺口发现**交给 LLM(语义判断),但必须附实证,受置信度门控。
|
||||||
|
|
||||||
|
spark.py 输出 JSON schema:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "图神经网络",
|
||||||
|
"papers": [{"arxiv_id":"2403.xxxxx","title":"...","abstract":"...","published":"2024-03-15","method_keywords":[...]}],
|
||||||
|
"gitlink_repos": [{"owner":"leejt","repo":"GraphGallery","desc":"...","topics":[...]}],
|
||||||
|
"gitlink_issues": [{"repo":"leejt/GraphGallery","number":12,"subject":"...","status":"open","participants":3}],
|
||||||
|
"github_counts": [{"method":"graph attention XXX","total_count":2,"top":[{"full_name":"...","stars":3534}]}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 两类缺口分类法 + 实证三件套
|
||||||
|
|
||||||
|
每张缺口卡**必须**带齐三件套,否则被置信度门控丢弃(防 LLM 编造):
|
||||||
|
|
||||||
|
### 类型 A:有理论无实现(paper → code gap)
|
||||||
|
- **三件套**:① 论文 arxiv id + 标题 + 发表日期 ② GitLink 搜索查询串 + 命中数(0 或极少) ③ GitHub total_count + Top 仓库(参考基准)
|
||||||
|
- **判定**:论文提出方法 M;GitLink 实现 0/极少;GitHub 按下面阈值分级。
|
||||||
|
|
||||||
|
### 类型 B:有需求无解答(open issue → applied research gap)
|
||||||
|
- **三件套**:① issue URL + 主题 + 讨论人数/状态 ② GitLink 无现成实现解此痛点 ③ GitHub 是否有成熟开源解(对照)
|
||||||
|
- **判定**:领域仓库中存在"研究性痛点"open issue(排除"安装报错"类使用问题);学术/GitHub 无现成解。
|
||||||
|
- **降噪**:LLM 筛"研究性痛点"(含性能/可扩展性/新场景),排除使用/部署类。
|
||||||
|
|
||||||
|
## 9. GitHub 全球对照与阈值(诚实核心,硬需求落地)
|
||||||
|
|
||||||
|
防止"GitLink 0 ≠ 全球空白"误导。对每个"理论无实现"候选,按 GitHub total_count 分级:
|
||||||
|
|
||||||
|
| GitHub total_count | 分级 | 报告行为 |
|
||||||
|
|--------------------|------|----------|
|
||||||
|
| `< 10` | **全球稀缺(真空白)** | 报为高价值缺口:"GitLink 生态空白 × 全球稀缺 → 复现并开源到 GitLink,易成本平台标杆" |
|
||||||
|
| `10–50` | **新兴(部分空白)** | 报为中等缺口:"GitLink 空白,全球新兴(N 个),可做中文生态首个完整实现" |
|
||||||
|
| `≥ 50` | **全球已成熟** | **不报为空白**,列入"✅ 已诚实排除"区:"GitLink 虽 0,但 GitHub 已 N 个(含官方)→ 全球已成熟,非空白" |
|
||||||
|
|
||||||
|
> 这个分级是本 skill 的诚实命门:宁可少报,不误报机会。GitHub 计数缓存(spark.py 按 method key 缓存去重)。
|
||||||
|
|
||||||
|
## 10. 机会报告格式(hero)
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
⚡ **gitlink-spark 机会报告:<field>**
|
||||||
|
|
||||||
|
学术采:arXiv 近 90 天 N 篇 | GitLink 仓库 M 个 | GitHub 全球基线已对照
|
||||||
|
生成时间:YYYY-MM-DD
|
||||||
|
|
||||||
|
### 🧩 缺口 1 · 有理论无实现 [全球稀缺·高价值]
|
||||||
|
**论文**:[arxiv:<id>] "<title>" (<venue/date>)
|
||||||
|
**方法关键词**:<...>
|
||||||
|
**GitLink**:search "<query>" → **0 命中**(查询串留底可复现)
|
||||||
|
**GitHub 全球**:total_count = **2**(Top: <repo> <stars>⭐)→ 全球稀缺
|
||||||
|
**机会建议**:<LLM 一句话:为何值得复现 + 开源到 GitLink>
|
||||||
|
**起跑**:[按钮] fork 基准 <repo> → 创建 issue 粘论文 Algorithm 1 伪代码
|
||||||
|
|
||||||
|
### 🧩 缺口 2 · 有需求无解答 [应用机会]
|
||||||
|
**Issue**:<repo>#<n> "<subject>"(N 人讨论, open, <date>)
|
||||||
|
**痛点**:<LLM 一句话研究性痛点归纳>
|
||||||
|
**GitLink / GitHub**:均无成熟开源解
|
||||||
|
**机会建议**:<LLM 一句话:可写应用级论文 + GitLink 落地>
|
||||||
|
|
||||||
|
### ✅ 已诚实排除(非空白)
|
||||||
|
- 论文 Y:GitLink 虽 0,但 GitHub 已 47 个实现(含官方)→ 全球已成熟,不报
|
||||||
|
|
||||||
|
---
|
||||||
|
<!-- gitlink-spark v1 | field:<field> | gaps:<N> | date:<YYYY-MM-DD> -->
|
||||||
|
*由 gitlink-spark skill 生成。*
|
||||||
|
````
|
||||||
|
|
||||||
|
## 11. 起跑动作(M5)+ 安全护栏
|
||||||
|
|
||||||
|
选定一张"理论无实现"缺口卡 → 用户确认 →
|
||||||
|
1. `gitlink-cli repo +fork` 最近基准(GitHub Top 仓库 或 GitLink 最近实现)
|
||||||
|
2. LLM 从 arXiv 论文抓取 Algorithm/Pseudocode 节
|
||||||
|
3. `gitlink-cli issue +create` 在 fork 上建一个复现 todo issue,body 粘入论文伪代码 + 报告卡摘要
|
||||||
|
|
||||||
|
**护栏**(沿用既有偏好):默认预览确认;`--auto` 跳过预览但**永不 force-push、永不碰原仓库、永不自动 merge**;`--no-fork` 只出报告。
|
||||||
|
|
||||||
|
## 12. 错误处理与降级
|
||||||
|
|
||||||
|
| 情况 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| arXiv 返回空/超时 | 改 HTTPS 重试;仍空则报告"学术采失败",降级用既有论文 |
|
||||||
|
| `search +issues` 返回 HTML(已知坑) | 不用它;用 `issue +list` 逐仓库枚举 |
|
||||||
|
| GitHub 未认证限流(10/min) | spark.py sleep ~7s 限速;建议设 `GITHUB_TOKEN` |
|
||||||
|
| GitHub total_count 查询失败 | 该论文标注"GitHub 对照失败",不进缺口判定 |
|
||||||
|
| OpenAlex 503 | 跳过引用富集,不影响主报告 |
|
||||||
|
| LLM 缺口无实证三件套 | 置信度门控丢弃 |
|
||||||
|
| fork/issue 起跑失败 | 输出 fork 目标 + 伪代码文本供手动起跑 |
|
||||||
|
| 二进制是 npm 旧版 | 强制 `./gitlink-cli` 或 `go build` |
|
||||||
|
|
||||||
|
## 13. 验证计划(demo 2-3 例,GNN 领域)
|
||||||
|
|
||||||
|
实跑 `spark.py --field "图神经网络"` + LLM 匹配,产出:
|
||||||
|
- [ ] **≥1 张"理论无实现"缺口卡**(GitHub total_count < 10,全球稀缺),三件套可查
|
||||||
|
- [ ] **≥1 张"需求无解答"缺口卡**(真实 open issue + 研究性痛点),三件套可查
|
||||||
|
- [ ] **≥1 张"理论无实现"缺口卡走完 fork+issue 起跑**(截图/链接;demand 卡为写论文方向、无 fork 起跑)
|
||||||
|
- [ ] "✅ 已诚实排除"区至少 1 条(GitHub ≥50 的非空白),证明阈值生效
|
||||||
|
- [ ] 全报告无 LLM 编造(每条可溯源到 spark.py JSON)
|
||||||
|
|
||||||
|
结果写入 `examples/spark-图神经网络.md`。
|
||||||
|
|
||||||
|
## 14. 风险与未决
|
||||||
|
|
||||||
|
- **arXiv 方法抽取**:从论文摘要自动抽"方法关键词"供 GitHub 查询,LLM 抽取有噪声 → spark.py 同时用论文标题关键词 + LLM 抽取双路查询 GitHub,取 total_count。
|
||||||
|
- **GitLink GNN 仓库数量**:可能很少(之前扫到 GraphGallery 等);需求侧缺口(issue)依赖仓库数,若太少则 demand 缺口样本不足 → 必要时放宽领域关键词(如含 `图`/`GNN`/`graph neural`)。
|
||||||
|
- **GitHub 限流**:demo 一次 10 篇论文 × 1 查询 = 10 次,刚好未认证上限;建议跑 demo 时设 `GITHUB_TOKEN`。
|
||||||
|
- **缺口"机会建议"主观**:靠实证三件套兜底;建议标注"机会仅为启发,需研究者自行判断"。
|
||||||
|
|
||||||
|
## 15. 验收标准
|
||||||
|
|
||||||
|
1. `skills/gitlink-spark/` 四件套齐全(SKILL.md + scripts/spark.py + REFERENCE.md + examples/)
|
||||||
|
2. `spark.py` 可独立运行:`python spark.py --field 图神经网络` 输出合法融合 JSON
|
||||||
|
3. GNN 跑出 2-3 张真实缺口卡(≥1 theory + ≥1 demand),每张三件套可查
|
||||||
|
4. GitHub 阈值生效:"已诚实排除"区至少 1 条
|
||||||
|
5. ≥1 张缺口卡走完 fork+issue 起跑,有截图/链接
|
||||||
|
6. 全报告每条缺口可溯源到 spark.py JSON(无 LLM 编造)
|
||||||
|
7. REFERENCE.md 含缺口分类法 + 数据源实测结论 + 诚实边界
|
||||||
|
8. README 登记 gitlink-spark
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
# gitlink-research-fair v2 设计(科研软件 X 光 / Research Software X-Ray)
|
||||||
|
|
||||||
|
- **日期**:2026-07-07
|
||||||
|
- **状态**:已批准,待编写实现计划
|
||||||
|
- **作者**:CWQ + Claude
|
||||||
|
- **定位**:对 v1(5 轴 FAIR 体检卡)的**重大升级**,回应"与 health 重合 / 报告千篇一律 / 假科研图谱 / 纯 Markdown 无脚本"四条批评。同一 skill 演进(保留 fair 名 + FAIR4RS 学术锚 + PR #5),重构内部。
|
||||||
|
- **形态**:skill + **可运行 `scripts/fair.py`**(stdlib only),内容感知的科研分析工作流。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
v1 的 fair 是"5 轴 FAIR 打分 + 报告卡",存在四个真问题:
|
||||||
|
1. 与 `gitlink-health` 形态重合(都是"聚合 gitlink-cli → AI → 打分报告"),且不够科研专属。
|
||||||
|
2. 报告千篇一律(每仓库都是同一组 ✓/✗);五轴 ASCII 雷达呈现烂;"科研关系图"是假的(只画作者/license/dataset?)。
|
||||||
|
3. 纯 Markdown,无脚本,分析靠 LLM"看一眼",不可复现。
|
||||||
|
4. 缺真实科研洞察。
|
||||||
|
|
||||||
|
v2 升级为**科研软件 X 光**:`fair.py` **真抽取** repo 内容(README/文件/依赖)→ 科研画像 JSON → LLM 对 4 个科研专属维度做裁决(每条带实证)→ **每 repo 特有的洞察报告**(裁决总览 + 真科研图谱 + 关键发现)+ 可选处方闭环。
|
||||||
|
|
||||||
|
**目标**:内容感知(每份报告说本 repo 特有的东西)、科研专属(论文/数据/复现/引用,非通用软件质量)、可执行(fair.py 脚本)、demo 级(1-2 示例 + 1 答辩演示,非全场景覆盖)。
|
||||||
|
|
||||||
|
## 2. 非目标(YAGNI)
|
||||||
|
|
||||||
|
- 不做"实际运行代码验证复现"(静态复现就绪度检查即可,跑代码超 demo 范围)
|
||||||
|
- 不做全场景/全领域覆盖——Feature_Critic + Edge 两个场景跑通即可
|
||||||
|
- 不重复 spark(spark 跨仓跨源挖缺口;fair v2 单仓深挖科研产物)
|
||||||
|
- 不自动 merge;处方默认预览
|
||||||
|
- 不写 Go——skill + python 脚本
|
||||||
|
- **砍掉 v1 的**:5 轴雷达、假 KG、OpenAlex 溯源(弱环节)、与 health 重合的通用质量味
|
||||||
|
|
||||||
|
## 3. 关键决策(用户确认)
|
||||||
|
|
||||||
|
| 决策点 | 选择 | 理由 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 主角定位 | **科研软件 X 光**(深挖科研产物 + 4 维裁决) | 替代 5 轴打分,科研专属,每 repo 特有 |
|
||||||
|
| 报告主视觉 | **混合**(裁决一行 + 真 Mermaid 图谱 + 关键发现) | 扫读 + 视觉兼顾,回应雷达烂 + 假图谱 + 千篇一律三连批 |
|
||||||
|
| 改造方式 | **演进式**(保留 fair 名/FAIR4RS 锚/PR #5) | 不浪费 v1 资产,重构内部 |
|
||||||
|
| 演示仓库 | **liyiying10/Feature_Critic**(ICML2019 paper-code) | README 明含 arxiv 链接,X 光论文溯源能真抽,4 维有区分度 |
|
||||||
|
| 第二示例 | **Edgedev/Edge-Computing-Engine**(引擎类) | 与 Feature_Critic 不同 profile(license 冲突发现),证明不千篇一律 |
|
||||||
|
| 脚本 | **fair.py**(stdlib only,fair.py 抽数据 + LLM 裁决) | 内容感知、可复现,取代 LLM"看一眼" |
|
||||||
|
|
||||||
|
## 4. 与 v1 的差异 + 取舍
|
||||||
|
|
||||||
|
| 维度 | v1 | v2 |
|
||||||
|
|------|----|----|
|
||||||
|
| 评分对象 | 5 轴 FAIR(F/A/I/R/Repro)通用软件质量 | 4 维科研专属(论文溯源/数据链/复现就绪/引用就绪) |
|
||||||
|
| 数据来源 | LLM 看 gitlink-cli 元数据 | fair.py 真抽取 README/文件/依赖 |
|
||||||
|
| 图谱 | 假 KG(作者/license/dataset?) | 真 Mermaid:paper↔method↔code↔dataset↔framework↔citation |
|
||||||
|
| 报告 | 千篇一律 ✓/✗ + ASCII 雷达 | 裁决一行 + 真图谱 + **本 repo 特有关键发现** |
|
||||||
|
| 脚本 | 无(纯 Markdown) | fair.py(可独立运行) |
|
||||||
|
| 处方 | 有(保留) | 保留升级(CITATION.cff 从 README 抽 / requirements 从 import 扫) |
|
||||||
|
| OpenAlex 溯源 | 有(弱、易 503) | **砍**(去掉更自洽) |
|
||||||
|
| 落盘 | 有(v1 已补) | 保留(report-cards/<owner>-<repo>-xray.md) |
|
||||||
|
|
||||||
|
## 5. 文件清单
|
||||||
|
|
||||||
|
| 文件 | 动作 | 内容 |
|
||||||
|
|------|------|------|
|
||||||
|
| `skills/gitlink-research-fair/SKILL.md` | 重写 | X 光管道(抽数→裁决→图谱→报告→处方)、4 维裁决表、报告模板、真图谱说明、处方护栏、降级 |
|
||||||
|
| `skills/gitlink-research-fair/scripts/fair.py` | 新增 | 真抽取(README/文件/依赖)→ 科研画像 JSON;stdlib only |
|
||||||
|
| `skills/gitlink-research-fair/scripts/test_fair.py` | 新增 | 抽取函数单测(arxiv 正则 / 依赖检测 / 数据集识别 / 引用检测) |
|
||||||
|
| `skills/gitlink-research-fair/REFERENCE.md` | 重写 | 4 维裁决细则 + 真 KG schema + 抽取规则 + 数据源实测 + 学术锚(FAIR4RS) |
|
||||||
|
| `skills/gitlink-research-fair/examples/feature-critic-xray.md` | 新增 | Feature_Critic 真实 X 光走查(答辩演示脚本) |
|
||||||
|
| `skills/gitlink-research-fair/examples/edge-xray.md` | 新增 | Edge 引擎类对照(license 冲突发现) |
|
||||||
|
| `skills/README.md` / `skills/gitlink-workflow/SKILL.md` | 微调 | fair 描述改为"科研软件 X 光" |
|
||||||
|
|
||||||
|
> v1 的旧 examples(songhui18 报告卡)替换为 v2 X 光示例。
|
||||||
|
|
||||||
|
## 6. 命令接口
|
||||||
|
|
||||||
|
### fair.py(可独立运行)
|
||||||
|
```bash
|
||||||
|
python skills/gitlink-research-fair/scripts/fair.py --owner liyiying10 --repo Feature_Critic
|
||||||
|
# → stdout: 科研画像 JSON {paper, datasets, repro, citation, methods, frameworks, license, files, head_sha}
|
||||||
|
```
|
||||||
|
|
||||||
|
### skill 约定参数(SKILL.md)
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--owner/--repo` | 自动从 cwd 解析 | 目标科研仓库(用 identifier) |
|
||||||
|
| `--auto` | 关 | 跳过预览直接开处方 PR(仍受护栏) |
|
||||||
|
| `--no-fork` | 关 | 只出 X 光报告,不开 PR |
|
||||||
|
| `--refresh` | 关 | 即使有旧哨兵也重评 |
|
||||||
|
|
||||||
|
## 7. 数据流(管道)
|
||||||
|
|
||||||
|
```
|
||||||
|
① 抽取(fair.py,确定性)
|
||||||
|
fetch readme (file +get) + file list (file +list) + repo +info + commit +list(HEAD sha)
|
||||||
|
→ extract_paper / extract_datasets / assess_repro / assess_citation / extract_methods_frameworks
|
||||||
|
→ 科研画像 JSON
|
||||||
|
② 裁决(LLM,读 JSON)4 维 × (✅/⚠️/❌ + 具体证据)
|
||||||
|
③ 真科研图谱(从画像生成 Mermaid)
|
||||||
|
④ 渲染 X 光报告(hero:裁决一行 + 真图谱 + 关键发现 + 处方摘要 + 双裁决证书)→ 落盘
|
||||||
|
⑤ 处方(可选):fork → 建 CITATION.cff/requirements/Dockerfile → PR(默认预览)
|
||||||
|
```
|
||||||
|
|
||||||
|
**职责切分**:fair.py 只做**确定性抽取**(可复现);**裁决与"关键发现"叙述**交给 LLM(语义),但每条必须引用 fair.py 抽到的实证。
|
||||||
|
|
||||||
|
## 8. fair.py 抽取目标 + 科研画像 JSON schema
|
||||||
|
|
||||||
|
抽取函数(纯函数单测 + gitlink-cli 网络层):
|
||||||
|
- `extract_paper(readme)` → 正则抽 arxiv id/URL、DOI、OpenReview/aclanthology、标题/作者/会议、"Code for the paper" 句式
|
||||||
|
- `extract_datasets(readme, files)` → 已知数据集名(PACS/Cora/ImageNet/Visual Decathlon/MNIST/CIFAR…)+ 数据脚本(data_gen/get_data/download.sh)+ 数据 license/DOI
|
||||||
|
- `assess_repro(files, readme)` → 依赖文件(requirements/go.mod/environment.yml/Dockerfile)+ 锁版本?;入口(main/train/run/*.py);期望结果(results.md/accuracy/表);环境说明
|
||||||
|
- `assess_citation(files, readme)` → CITATION.cff / codemeta.json / .zenodo.json 存在?+ README BibTeX/引用文本
|
||||||
|
- `extract_methods_frameworks(files, readme)` → 方法词 + 框架(torch/tensorflow/jax,从 import/文件名推断)
|
||||||
|
|
||||||
|
科研画像 JSON:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"repo": "liyiying10/Feature_Critic", "head_sha": "<sha>",
|
||||||
|
"paper": {"arxiv_id":"1901.11448","arxiv_url":"https://arxiv.org/abs/1901.11448","doi":null,
|
||||||
|
"title":"Feature-Critic Networks for Heterogeneous Domain Generalisation",
|
||||||
|
"authors":["Yiying Li","Yongxin Yang","Wei Zhou","Timothy M. Hospedales"],"venue":"ICML 2019","in_readme":true},
|
||||||
|
"datasets": [{"name":"PACS","evidence":"data_gen_PACS.py + README","download_script":"get_model_dataset.sh","license":null},
|
||||||
|
{"name":"Visual Decathlon","evidence":"data_gen_VD.py","download_script":null,"license":null}],
|
||||||
|
"repro": {"deps_files":[],"deps_pinned":false,"entry_points":["main_Feature_Critic.py","main_baseline.py"],
|
||||||
|
"expected_results":false,"env_spec":null},
|
||||||
|
"citation": {"cff":false,"codemeta":false,"zenodo":false,
|
||||||
|
"readme_bibtex":"Li, Yang, Zhou, Hospedales. Feature-Critic Networks... ICML 2019"},
|
||||||
|
"methods": ["feature-critic","meta-learning","domain generalisation"],
|
||||||
|
"frameworks": ["torch (inferred: alexnet/resnet/vggnet)"],
|
||||||
|
"license": {"file":false,"type":null,"conflict":null},
|
||||||
|
"files_count": 15
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 4 维裁决规则(LLM 读画像,每维 ✅/⚠️/❌ + 证据)
|
||||||
|
|
||||||
|
| 维度 | ✅ | ⚠️ | ❌ |
|
||||||
|
|------|---|----|----|
|
||||||
|
| 论文溯源 | 有 arxiv/DOI + 元数据全 | 仅 README 文字提及,无稳定链接 | 无任何论文线索 |
|
||||||
|
| 数据链 | 数据集命名 + 下载脚本 + license | 命名但无脚本/无 license | 未提及数据集 |
|
||||||
|
| 复现就绪 | 依赖锁 + 入口 + 环境 + 期望结果四件齐全 | 有入口但缺依赖锁/环境/期望结果 | 无入口/无依赖 |
|
||||||
|
| 引用就绪 | CITATION.cff/codemeta + DOI + 版本 | 仅 README 引用文本 | 无任何引用信息 |
|
||||||
|
|
||||||
|
每维裁决**必须引用画像里的具体字段**(如"arxiv 1901.11448 已抽""requirements.txt 缺失"),无实证的判断丢弃。
|
||||||
|
|
||||||
|
## 10. 真科研图谱 schema(Mermaid,从画像生成)
|
||||||
|
|
||||||
|
节点(带状态色):`Paper(arxiv+venue)` / `Method` / `Code(file)` / `Dataset` / `Framework` / `Citation`
|
||||||
|
边:`Paper —proposes→ Method`、`Method —implements→ Code`、`Code —uses→ Dataset`、`Code —depends→ Framework`、`Paper —cited-via→ Citation`
|
||||||
|
状态色:✅绿 / ⚠️黄 / ❌红(缺失项)。
|
||||||
|
|
||||||
|
Feature_Critic 实例化:`ICML2019(1901.11448)✅ —proposes→ Feature-Critic方法 —implements→ main_Feature_Critic.py✅ —uses→ PACS⚠️/Visual Decathlon⚠️ —depends→ PyTorch(推断)⚠️;Paper —cited-via→ 无CITATION❌`。真有科研含义,非 v1 假图。
|
||||||
|
|
||||||
|
## 11. 报告格式(混合主视觉,hero)
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
🔬 **科研软件 X 光 — liyiying10/Feature_Critic**
|
||||||
|
|
||||||
|
═══════════════════════════════════════
|
||||||
|
论文溯源 ✅ | 数据链 ⚠️ | 复现就绪 ⚠️ | 引用就绪 ❌
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
### 🧬 真科研图谱
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
P[ICML2019 arxiv:1901.11448]:::ok -->|proposes| M[Feature-Critic 方法]
|
||||||
|
M -->|implements| C[main_Feature_Critic.py]:::ok
|
||||||
|
C -->|uses| D1[PACS]:::warn
|
||||||
|
C -->|uses| D2[Visual Decathlon]:::warn
|
||||||
|
C -->|depends| F[PyTorch 推断]:::warn
|
||||||
|
P -->|cited-via| Ci[无 CITATION.cff]:::bad
|
||||||
|
classDef ok fill:#cfe,stroke:#3a3; classDef warn fill:#ffe,stroke:#cc3; classDef bad fill:#fee,stroke:#c33;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔍 关键发现(本 repo 特有)
|
||||||
|
- 论文 ICML2019 arxiv:1901.11448 已溯源 ✓,但**无 CITATION.cff** → 机器不可引用
|
||||||
|
- 数据集 PACS/Visual Decathlon 命名 + `get_model_dataset.sh`,但**无数据 license**
|
||||||
|
- 入口 `main_Feature_Critic.py` 在,但**无 requirements.txt** → 依赖未锁,复现风险
|
||||||
|
- 框架:alexnet/resnet/vggnet(疑似 PyTorch,**依赖未声明**)
|
||||||
|
|
||||||
|
### 🔧 处方(可选)
|
||||||
|
补 `CITATION.cff`(从 README 抽的引用)+ `requirements.txt`(从 import 扫)+ `Dockerfile`
|
||||||
|
|
||||||
|
### 📜 双裁决证书
|
||||||
|
复现就绪 ⚠️ 部分 | 引用就绪 ❌ | 锚定 commit `<sha>`
|
||||||
|
|
||||||
|
---
|
||||||
|
<!-- gitlink-research-fair v2 | repo:<owner>/<repo> | paper:? | repro:? | cite:? | sha:<head> -->
|
||||||
|
*由 gitlink-research-fair v2(科研软件 X 光)生成。*
|
||||||
|
````
|
||||||
|
|
||||||
|
报告**始终落盘** `report-cards/<owner>-<repo>-xray.md`(沿用 v1 落盘原则)。
|
||||||
|
|
||||||
|
## 12. 处方闭环 + 护栏(保留 v1 升级)
|
||||||
|
|
||||||
|
对 ❌/⚠️ 项生成修复:`CITATION.cff`(从 README 抽的引用文本构造)+ `requirements.txt`(从代码 import 扫出依赖)+ `Dockerfile`(模板)。→ fork → PR(默认预览,`--auto` 跳过;永不 force-push/碰原仓库/自动 merge;`--no-fork` 报告已落盘)。
|
||||||
|
|
||||||
|
## 13. 错误处理与降级
|
||||||
|
|
||||||
|
| 情况 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| README 读取失败 | 降级用 file list + 元数据,标注"README 不可读,结论受限" |
|
||||||
|
| `file +list` data 为字符串 | json.loads 解套(已知结构) |
|
||||||
|
| arxiv/DOI 抽不到 | 论文溯源维度判 ⚠️ 或 ❌,据实 |
|
||||||
|
| 框架无法推断 | frameworks 标 "未知",不编造 |
|
||||||
|
| `--repo` 用了中文显示名 404 | 提示用 identifier(从 search 取) |
|
||||||
|
| fork/PR 失败 | 处方物料落本地,告知路径 |
|
||||||
|
| 报告/PR 发布失败 | 报告已在 ④ 落盘,告知路径 |
|
||||||
|
|
||||||
|
## 14. 验证计划(1-2 示例 + 答辩演示)
|
||||||
|
|
||||||
|
- **答辩现场演示 + 示例1**:`liyiying10/Feature_Critic`
|
||||||
|
- [ ] fair.py 抽出 arxiv 1901.11448 + ICML2019 + PACS/VD 数据集 + 入口 + 无 CITATION/requirements
|
||||||
|
- [ ] 4 维裁决:论文 ✅ / 数据 ⚠️ / 复现 ⚠️ / 引用 ❌
|
||||||
|
- [ ] 真图谱画出 6 类节点 + 状态色
|
||||||
|
- [ ] 关键发现 ≥3 条本 repo 特有
|
||||||
|
- **示例2**:`Edgedev/Edge-Computing-Engine`
|
||||||
|
- [ ] X 光发现 license 冲突(Apache vs README"禁商用")作为关键发现
|
||||||
|
- [ ] 与 Feature_Critic 报告内容明显不同(证明不千篇一律)
|
||||||
|
- 两示例均落盘 report-cards/ + 写入 examples/。
|
||||||
|
|
||||||
|
## 15. 风险与未决
|
||||||
|
|
||||||
|
- **README 抽取覆盖度**:正则抽 arxiv/DOI 依赖 README 写法;对非标准 README 可能漏。fair.py 同时匹配多种句式(arxiv URL / arXiv:id / 裸 id)兜底。
|
||||||
|
- **框架推断**:从文件名/import 推断 PyTorch/TF 有噪声 → 标"推断",不肯定。
|
||||||
|
- **数据集识别**:已知数据集名白名单有限;未知名标"unnamed dataset (mentioned)"。
|
||||||
|
- **GitLink file +list data 字符串化**:fair.py 需 json.loads 解套(已知)。
|
||||||
|
- **演示仓库稳定性**:Feature_Critic 是 1⭐ 小仓但 ICML2019 真实学术代码,内容稳定;演示前复跑一次确认 README/文件未变。
|
||||||
|
|
||||||
|
## 16. 验收标准
|
||||||
|
|
||||||
|
1. `skills/gitlink-research-fair/` 含 SKILL.md(重写)+ scripts/fair.py + test_fair.py + REFERENCE.md(重写)+ 2 个 examples
|
||||||
|
2. `python fair.py --owner liyiying10 --repo Feature_Critic` 输出合法科研画像 JSON(含真实 arxiv 1901.11448)
|
||||||
|
3. test_fair.py 抽取函数单测全过
|
||||||
|
4. Feature_Critic X 光:4 维裁决正确(论文✅/数据⚠️/复现⚠️/引用❌)+ 真图谱 ≥6 节点 + 关键发现 ≥3 条
|
||||||
|
5. Edge X 光:license 冲突作为关键发现,报告与 Feature_Critic 明显不同
|
||||||
|
6. 报告落盘 report-cards/
|
||||||
|
7. v1 的 5 轴雷达/假 KG 已从 SKILL.md 移除
|
||||||
|
8. README/workflow 描述更新为"科研软件 X 光"
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||||
"github.com/zalando/go-keyring"
|
"github.com/zalando/go-keyring"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,8 +42,7 @@ func DeleteToken() error {
|
||||||
// File-based fallback
|
// File-based fallback
|
||||||
|
|
||||||
func credentialPath() string {
|
func credentialPath() string {
|
||||||
home, _ := os.UserHomeDir()
|
return filepath.Join(config.ConfigDir(), "credentials")
|
||||||
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func storeTokenFile(token string) error {
|
func storeTokenFile(token string) error {
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,17 @@ type APIError struct {
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Code interface{}
|
Code interface{}
|
||||||
Message string
|
Message string
|
||||||
|
Suggestion string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *APIError) Error() string {
|
func (e *APIError) Error() string {
|
||||||
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Suggest() string {
|
||||||
|
return e.Suggestion
|
||||||
|
}
|
||||||
|
|
||||||
func New() (*Client, error) {
|
func New() (*Client, error) {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -77,6 +82,10 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
if c.Debug {
|
if c.Debug {
|
||||||
fmt.Printf("→ %s %s\n", method, fullURL)
|
fmt.Printf("→ %s %s\n", method, fullURL)
|
||||||
}
|
}
|
||||||
|
|
@ -98,10 +107,12 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
||||||
|
|
||||||
// Check HTTP-level errors
|
// Check HTTP-level errors
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
|
suggestion := suggestFix(resp.StatusCode)
|
||||||
return nil, &APIError{
|
return nil, &APIError{
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
Code: resp.StatusCode,
|
Code: resp.StatusCode,
|
||||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
|
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
|
||||||
|
Suggestion: suggestion,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,6 +139,7 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
||||||
StatusCode: int(statusCode),
|
StatusCode: int(statusCode),
|
||||||
Code: int(statusCode),
|
Code: int(statusCode),
|
||||||
Message: msg,
|
Message: msg,
|
||||||
|
Suggestion: suggestion,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -174,16 +186,100 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error)
|
||||||
return c.Do("DELETE", path, nil, query)
|
return c.Do("DELETE", path, nil, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DoRaw sends a request without appending the .json suffix.
|
||||||
|
func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
|
||||||
|
fullURL := c.BaseURL + path
|
||||||
|
if query != nil && len(query) > 0 {
|
||||||
|
fullURL += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, fullURL, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Debug {
|
||||||
|
fmt.Printf("→ %s %s\n", method, fullURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respData, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Debug {
|
||||||
|
fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, &APIError{
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Code: resp.StatusCode,
|
||||||
|
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(respData, &raw); err != nil {
|
||||||
|
return output.SuccessEnvelope(string(respData), nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check error-in-body pattern
|
||||||
|
if status, ok := raw["status"]; ok {
|
||||||
|
var statusCode float64
|
||||||
|
switch v := status.(type) {
|
||||||
|
case float64:
|
||||||
|
statusCode = v
|
||||||
|
case int:
|
||||||
|
statusCode = float64(v)
|
||||||
|
}
|
||||||
|
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
|
||||||
|
msg, _ := raw["message"].(string)
|
||||||
|
return output.ErrorEnvelope(int(statusCode), msg, ""), &APIError{
|
||||||
|
StatusCode: int(statusCode),
|
||||||
|
Code: int(statusCode),
|
||||||
|
Message: msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.SuccessEnvelope(raw, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
func suggestFix(code int) string {
|
func suggestFix(code int) string {
|
||||||
switch code {
|
switch code {
|
||||||
|
case 400:
|
||||||
|
return "请求参数格式错误,请检查参数值是否正确"
|
||||||
case 401:
|
case 401:
|
||||||
return "请先运行 gitlink-cli auth login 登录"
|
return "请先运行 gitlink-cli auth login 登录"
|
||||||
case 403:
|
case 403:
|
||||||
return "权限不足,请确认账户权限或联系项目管理员"
|
return "权限不足,请确认账户权限或联系项目管理员"
|
||||||
case 404:
|
case 404:
|
||||||
return "资源不存在,请检查 owner/repo/id 是否正确"
|
return "资源不存在,请检查 owner/repo/id 是否正确"
|
||||||
|
case 409:
|
||||||
|
return "资源冲突,可能存在同名资源"
|
||||||
case 422:
|
case 422:
|
||||||
return "参数校验失败,请检查请求参数"
|
return "参数校验失败,请检查请求参数"
|
||||||
|
case 500, 502, 503:
|
||||||
|
return "服务器内部错误,请稍后重试或联系平台管理员"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package config
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
@ -17,6 +18,7 @@ type Config struct {
|
||||||
Format string `yaml:"default_format"`
|
Format string `yaml:"default_format"`
|
||||||
Editor string `yaml:"editor,omitempty"`
|
Editor string `yaml:"editor,omitempty"`
|
||||||
Pager string `yaml:"pager,omitempty"`
|
Pager string `yaml:"pager,omitempty"`
|
||||||
|
DeepSeekAPIKey string `yaml:"deepseek_api_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() *Config {
|
func DefaultConfig() *Config {
|
||||||
|
|
@ -30,6 +32,11 @@ func ConfigDir() string {
|
||||||
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
|
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if appData := os.Getenv("AppData"); appData != "" {
|
||||||
|
return filepath.Join(appData, "gitlink-cli")
|
||||||
|
}
|
||||||
|
}
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
return filepath.Join(home, ".config", "gitlink-cli")
|
return filepath.Join(home, ".config", "gitlink-cli")
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +92,8 @@ func Get(key string) (string, error) {
|
||||||
return cfg.Editor, nil
|
return cfg.Editor, nil
|
||||||
case "pager":
|
case "pager":
|
||||||
return cfg.Pager, nil
|
return cfg.Pager, nil
|
||||||
|
case "deepseek_api_key":
|
||||||
|
return cfg.DeepSeekAPIKey, nil
|
||||||
default:
|
default:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +113,8 @@ func Set(key, value string) error {
|
||||||
cfg.Editor = value
|
cfg.Editor = value
|
||||||
case "pager":
|
case "pager":
|
||||||
cfg.Pager = value
|
cfg.Pager = value
|
||||||
|
case "deepseek_api_key":
|
||||||
|
cfg.DeepSeekAPIKey = value
|
||||||
}
|
}
|
||||||
return Save(cfg)
|
return Save(cfg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) {
|
||||||
owner, repo, err := fromGitRemote()
|
owner, repo, err := fromGitRemote()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if flagOwner == "" || flagRepo == "" {
|
if flagOwner == "" || flagRepo == "" {
|
||||||
return "", "", fmt.Errorf("cannot detect owner/repo from git remote: %w\nUse --owner and --repo flags to specify explicitly", err)
|
return "", "", fmt.Errorf("无法从 git remote 自动检测 owner/repo: %w\n请使用 --owner 和 --repo 参数手动指定", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,7 +34,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) {
|
||||||
func fromGitRemote() (string, string, error) {
|
func fromGitRemote() (string, string, error) {
|
||||||
out, err := exec.Command("git", "remote", "get-url", "origin").Output()
|
out, err := exec.Command("git", "remote", "get-url", "origin").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("not a git repository or no remote 'origin'")
|
return "", "", fmt.Errorf("当前目录不是 git 仓库或未配置 remote 'origin'")
|
||||||
}
|
}
|
||||||
remote := strings.TrimSpace(string(out))
|
remote := strings.TrimSpace(string(out))
|
||||||
return parseRemoteURL(remote)
|
return parseRemoteURL(remote)
|
||||||
|
|
@ -45,7 +45,7 @@ func parseRemoteURL(remote string) (string, string, error) {
|
||||||
if strings.HasPrefix(remote, "git@") {
|
if strings.HasPrefix(remote, "git@") {
|
||||||
parts := strings.SplitN(remote, ":", 2)
|
parts := strings.SplitN(remote, ":", 2)
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return "", "", fmt.Errorf("cannot parse SSH remote: %s", remote)
|
return "", "", fmt.Errorf("无法解析 SSH 远程地址: %s", remote)
|
||||||
}
|
}
|
||||||
return parsePathSegments(parts[1])
|
return parsePathSegments(parts[1])
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ func parseRemoteURL(remote string) (string, string, error) {
|
||||||
// HTTPS format: https://www.gitlink.org.cn/owner/repo.git
|
// HTTPS format: https://www.gitlink.org.cn/owner/repo.git
|
||||||
u, err := url.Parse(remote)
|
u, err := url.Parse(remote)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("cannot parse remote URL: %s", remote)
|
return "", "", fmt.Errorf("无法解析远程地址 URL: %s", remote)
|
||||||
}
|
}
|
||||||
return parsePathSegments(u.Path)
|
return parsePathSegments(u.Path)
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +63,7 @@ func parsePathSegments(path string) (string, string, error) {
|
||||||
path = strings.TrimSuffix(path, ".git")
|
path = strings.TrimSuffix(path, ".git")
|
||||||
parts := strings.SplitN(path, "/", 3)
|
parts := strings.SplitN(path, "/", 3)
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return "", "", fmt.Errorf("cannot extract owner/repo from path: %s", path)
|
return "", "", fmt.Errorf("无法从路径提取 owner/repo: %s", path)
|
||||||
}
|
}
|
||||||
return parts[0], parts[1], nil
|
return parts[0], parts[1], nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Suggester is implemented by errors that carry a user-facing suggestion.
|
||||||
|
type Suggester interface {
|
||||||
|
Suggest() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error is a CLI error with an optional suggestion for the user.
|
||||||
|
type Error struct {
|
||||||
|
Message string
|
||||||
|
Suggestion string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
if e.Err != nil {
|
||||||
|
return fmt.Sprintf("%s: %v", e.Message, e.Err)
|
||||||
|
}
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Suggest() string {
|
||||||
|
return e.Suggestion
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Error with an optional suggestion.
|
||||||
|
func New(message, suggestion string) *Error {
|
||||||
|
return &Error{Message: message, Suggestion: suggestion}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapf wraps an error with a formatted message and optional suggestion.
|
||||||
|
func Wrapf(err error, suggestion, format string, args ...interface{}) *Error {
|
||||||
|
return &Error{
|
||||||
|
Message: fmt.Sprintf(format, args...),
|
||||||
|
Suggestion: suggestion,
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap wraps an error with a message and optional suggestion.
|
||||||
|
func Wrap(err error, message, suggestion string) *Error {
|
||||||
|
return &Error{Message: message, Suggestion: suggestion, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSuggestion walks the error chain and returns the first suggestion found.
|
||||||
|
func FindSuggestion(err error) string {
|
||||||
|
var s Suggester
|
||||||
|
if errors.As(err, &s) {
|
||||||
|
return s.Suggest()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
@ -66,22 +66,68 @@ func printTable(w io.Writer, envelope *Envelope) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to render as table if data is a slice of maps
|
// Convert struct types to generic map/slice via JSON round-trip
|
||||||
switch data := envelope.Data.(type) {
|
data := envelope.Data
|
||||||
|
if _, ok := data.(map[string]interface{}); !ok {
|
||||||
|
if _, ok := data.([]interface{}); !ok {
|
||||||
|
generic, err := toGeneric(data)
|
||||||
|
if err != nil {
|
||||||
|
return printJSON(w, envelope)
|
||||||
|
}
|
||||||
|
data = generic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch data := data.(type) {
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
return printSliceTable(w, data)
|
return printSliceTable(w, data)
|
||||||
case map[string]interface{}:
|
case map[string]interface{}:
|
||||||
// For maps with nested structures, prefer JSON
|
if slice := findSliceInMap(data); slice != nil {
|
||||||
|
return printSliceTable(w, slice)
|
||||||
|
}
|
||||||
if hasComplexValues(data) {
|
if hasComplexValues(data) {
|
||||||
return printJSON(w, envelope)
|
return printJSON(w, envelope)
|
||||||
}
|
}
|
||||||
return printMapTable(w, data)
|
return printMapTable(w, data)
|
||||||
default:
|
default:
|
||||||
// Fallback to JSON
|
|
||||||
return printJSON(w, envelope)
|
return printJSON(w, envelope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toGeneric(v interface{}) (interface{}, error) {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result interface{}
|
||||||
|
if err := json.Unmarshal(b, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSliceInMap(m map[string]interface{}) []interface{} {
|
||||||
|
for _, key := range []string{
|
||||||
|
"issues", "pull_requests", "milestones", "webhooks", "issue_tags",
|
||||||
|
"commits", "files", "members", "collaborators", "users", "branches",
|
||||||
|
"releases", "entries", "tags", "watchers", "results",
|
||||||
|
} {
|
||||||
|
if v, ok := m[key]; ok {
|
||||||
|
if slice, ok := v.([]interface{}); ok && len(slice) > 0 {
|
||||||
|
return slice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range m {
|
||||||
|
if slice, ok := v.([]interface{}); ok && len(slice) > 0 {
|
||||||
|
if _, isMap := slice[0].(map[string]interface{}); isMap {
|
||||||
|
return slice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func hasComplexValues(m map[string]interface{}) bool {
|
func hasComplexValues(m map[string]interface{}) bool {
|
||||||
for _, v := range m {
|
for _, v := range m {
|
||||||
switch v.(type) {
|
switch v.(type) {
|
||||||
|
|
@ -98,7 +144,6 @@ func printSliceTable(w io.Writer, items []interface{}) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect headers from first item
|
|
||||||
first, ok := items[0].(map[string]interface{})
|
first, ok := items[0].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
data, _ := json.MarshalIndent(items, "", " ")
|
data, _ := json.MarshalIndent(items, "", " ")
|
||||||
|
|
@ -109,7 +154,6 @@ func printSliceTable(w io.Writer, items []interface{}) error {
|
||||||
headers := collectKeys(first)
|
headers := collectKeys(first)
|
||||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||||
|
|
||||||
// Print headers
|
|
||||||
fmt.Fprintln(tw, strings.Join(headers, "\t"))
|
fmt.Fprintln(tw, strings.Join(headers, "\t"))
|
||||||
dashes := make([]string, len(headers))
|
dashes := make([]string, len(headers))
|
||||||
for i, h := range headers {
|
for i, h := range headers {
|
||||||
|
|
@ -117,7 +161,6 @@ func printSliceTable(w io.Writer, items []interface{}) error {
|
||||||
}
|
}
|
||||||
fmt.Fprintln(tw, strings.Join(dashes, "\t"))
|
fmt.Fprintln(tw, strings.Join(dashes, "\t"))
|
||||||
|
|
||||||
// Print rows
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
m, ok := item.(map[string]interface{})
|
m, ok := item.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -144,7 +187,6 @@ func printMapTable(w io.Writer, m map[string]interface{}) error {
|
||||||
|
|
||||||
func collectKeys(m map[string]interface{}) []string {
|
func collectKeys(m map[string]interface{}) []string {
|
||||||
keys := make([]string, 0, len(m))
|
keys := make([]string, 0, len(m))
|
||||||
// Prefer common keys first
|
|
||||||
priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"}
|
priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, k := range priority {
|
for _, k := range priority {
|
||||||
|
|
@ -170,10 +212,21 @@ func formatValue(v interface{}) string {
|
||||||
case reflect.Map, reflect.Slice:
|
case reflect.Map, reflect.Slice:
|
||||||
data, _ := json.Marshal(v)
|
data, _ := json.Marshal(v)
|
||||||
s := string(data)
|
s := string(data)
|
||||||
if len(s) > 60 {
|
if len(s) > 50 {
|
||||||
return s[:57] + "..."
|
return s[:47] + "..."
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
|
case reflect.Bool:
|
||||||
|
if v.(bool) {
|
||||||
|
return "yes"
|
||||||
|
}
|
||||||
|
return "no"
|
||||||
|
case reflect.Float64:
|
||||||
|
f := v.(float64)
|
||||||
|
if f == float64(int64(f)) {
|
||||||
|
return fmt.Sprintf("%d", int64(f))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("%v", v)
|
return fmt.Sprintf("%v", v)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
main.go
1
main.go
|
|
@ -11,3 +11,4 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// test: code-quality watch trigger test 1782976608
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
|
||||||
darwin: "darwin",
|
darwin: "darwin",
|
||||||
linux: "linux",
|
linux: "linux",
|
||||||
win32: "windows",
|
win32: "windows",
|
||||||
|
freebsd: "freebsd",
|
||||||
};
|
};
|
||||||
|
|
||||||
const archMap = {
|
const archMap = {
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
PR Test 2026年 4月 7日 星期二 11时45分56秒 CST
|
|
||||||
|
|
@ -27,6 +27,8 @@ PLATFORMS=(
|
||||||
"linux/arm64"
|
"linux/arm64"
|
||||||
"windows/amd64"
|
"windows/amd64"
|
||||||
"windows/arm64"
|
"windows/arm64"
|
||||||
|
"freebsd/amd64"
|
||||||
|
"freebsd/arm64"
|
||||||
)
|
)
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
|
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const os = require("os");
|
|
||||||
const path = require("path");
|
|
||||||
const fs = require("fs");
|
|
||||||
const https = require("https");
|
|
||||||
const http = require("http");
|
|
||||||
const { execSync } = require("child_process");
|
|
||||||
|
|
||||||
const PACKAGE = require("../package.json");
|
|
||||||
const VERSION = PACKAGE.version;
|
|
||||||
const BINARY_NAME = "gitlink-cli";
|
|
||||||
|
|
||||||
const RELEASE_BASE = "https://www.gitlink.org.cn";
|
|
||||||
const REPO_OWNER = "Gitlink";
|
|
||||||
const REPO_NAME = "gitlink-cli";
|
|
||||||
|
|
||||||
function getPlatformInfo() {
|
|
||||||
const platform = os.platform();
|
|
||||||
const arch = os.arch();
|
|
||||||
const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" };
|
|
||||||
const archMap = { x64: "amd64", arm64: "arm64" };
|
|
||||||
const goPlatform = platformMap[platform];
|
|
||||||
const goArch = archMap[arch];
|
|
||||||
if (!goPlatform || !goArch) {
|
|
||||||
throw new Error(`Unsupported platform: ${platform}-${arch}`);
|
|
||||||
}
|
|
||||||
return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetch(url, options = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const maxRedirects = options.maxRedirects || 5;
|
|
||||||
let redirectCount = 0;
|
|
||||||
function doRequest(currentUrl) {
|
|
||||||
const mod = currentUrl.startsWith("https") ? https : http;
|
|
||||||
const req = mod.get(currentUrl, (res) => {
|
|
||||||
if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
|
|
||||||
redirectCount++;
|
|
||||||
if (redirectCount > maxRedirects) { reject(new Error("Too many redirects")); return; }
|
|
||||||
let redirectUrl = res.headers.location;
|
|
||||||
if (redirectUrl.startsWith("/")) {
|
|
||||||
const parsed = new URL(currentUrl);
|
|
||||||
redirectUrl = `${parsed.protocol}//${parsed.host}${redirectUrl}`;
|
|
||||||
}
|
|
||||||
doRequest(redirectUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (res.statusCode !== 200) {
|
|
||||||
reject(new Error(`HTTP ${res.statusCode}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (options.json) {
|
|
||||||
let body = "";
|
|
||||||
res.on("data", (chunk) => (body += chunk));
|
|
||||||
res.on("end", () => {
|
|
||||||
try { resolve(JSON.parse(body)); } catch (e) { reject(new Error("JSON parse failed")); }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (chunk) => chunks.push(chunk));
|
|
||||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.setTimeout(30000, () => { req.destroy(); reject(new Error("Timeout")); });
|
|
||||||
}
|
|
||||||
doRequest(url);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findReleaseAsset(platform, arch) {
|
|
||||||
const archiveName = `gitlink-cli_${VERSION}_${platform}_${arch}.tar.gz`;
|
|
||||||
const tagName = `v${VERSION}`;
|
|
||||||
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const releases = await fetch(apiUrl, { json: true });
|
|
||||||
const rlist = Array.isArray(releases) ? releases : (releases && releases.releases ? releases.releases : []);
|
|
||||||
let release = rlist.find(r => r.tag_name === tagName || r.tag_name === VERSION);
|
|
||||||
if (!release && rlist.length > 0) release = rlist[0];
|
|
||||||
|
|
||||||
if (release && release.attachments) {
|
|
||||||
let asset = release.attachments.find(a => a.title === archiveName || a.filename === archiveName);
|
|
||||||
if (!asset) {
|
|
||||||
const pattern = `_${platform}_${arch}.tar.gz`;
|
|
||||||
asset = release.attachments.find(a => (a.title || a.filename || "").endsWith(pattern));
|
|
||||||
}
|
|
||||||
if (asset) {
|
|
||||||
let url = asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
|
|
||||||
if (url.startsWith("/")) url = RELEASE_BASE + url;
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadAndExtract(url, destDir, platform) {
|
|
||||||
const data = await fetch(url);
|
|
||||||
const archivePath = path.join(destDir, "download.tar.gz");
|
|
||||||
fs.writeFileSync(archivePath, data);
|
|
||||||
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
|
|
||||||
fs.unlinkSync(archivePath);
|
|
||||||
|
|
||||||
const binaryPath = path.join(destDir, BINARY_NAME);
|
|
||||||
if (!fs.existsSync(binaryPath)) {
|
|
||||||
const files = fs.readdirSync(destDir);
|
|
||||||
for (const file of files) {
|
|
||||||
const subPath = path.join(destDir, file, BINARY_NAME);
|
|
||||||
if (fs.existsSync(subPath)) { fs.renameSync(subPath, binaryPath); break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(binaryPath)) throw new Error("Binary not found after extraction");
|
|
||||||
fs.chmodSync(binaryPath, 0o755);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const { platform, arch } = getPlatformInfo();
|
|
||||||
const binDir = path.join(__dirname, "..", "bin");
|
|
||||||
if (!fs.existsSync(binDir)) { fs.mkdirSync(binDir, { recursive: true }); }
|
|
||||||
|
|
||||||
const binaryPath = path.join(binDir, BINARY_NAME);
|
|
||||||
if (fs.existsSync(binaryPath)) {
|
|
||||||
try {
|
|
||||||
const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 });
|
|
||||||
if (output.includes(VERSION)) {
|
|
||||||
console.log(`${BINARY_NAME} v${VERSION} already installed.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
fs.unlinkSync(binaryPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const downloadUrl = await findReleaseAsset(platform, arch);
|
|
||||||
await downloadAndExtract(downloadUrl, binDir, platform);
|
|
||||||
console.log(`${BINARY_NAME} v${VERSION} installed.`);
|
|
||||||
} catch (err) {
|
|
||||||
// Don't fail npm install — binary can be installed later
|
|
||||||
console.warn(`⚠ ${BINARY_NAME} binary download failed: ${err.message}`);
|
|
||||||
console.warn(` Skills are installed. You can install the binary manually later:`);
|
|
||||||
console.warn(` npm run postinstall`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Link skills/gitlink-* into ~/.claude/skills/ so Claude Code can load and
|
||||||
|
# invoke them through the Skill tool (e.g. `Skill gitlink-issue`).
|
||||||
|
#
|
||||||
|
# Cross-platform:
|
||||||
|
# - Windows (Git Bash / MSYS): directory junction (mklink /J, no admin needed)
|
||||||
|
# - macOS / Linux: symlink
|
||||||
|
#
|
||||||
|
# Links point at the in-repo skills/ directory, so updating the repo keeps the
|
||||||
|
# Skill content in sync. The script is idempotent and safe to re-run.
|
||||||
|
#
|
||||||
|
# CRITICAL (Windows): an existing link is removed with `rmdir` (no /s), never
|
||||||
|
# `rm -rf` — `rm -rf` would follow the junction and DELETE THE SOURCE FILES.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
SRC="$PROJECT_DIR/skills"
|
||||||
|
DST="$HOME/.claude/skills"
|
||||||
|
|
||||||
|
# --- collect gitlink-* skills (top-level skills/ + workflow-bundled skills) ---
|
||||||
|
shopt -s nullglob
|
||||||
|
SKILLS=("$SRC"/gitlink-*/ "$PROJECT_DIR/shortcuts/workflow/skills"/gitlink-*/)
|
||||||
|
shopt -u nullglob
|
||||||
|
if [ ${#SKILLS[@]} -eq 0 ]; then
|
||||||
|
echo "No gitlink-* skills found under $SRC" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DST"
|
||||||
|
|
||||||
|
# --- detect platform ---
|
||||||
|
case "$(uname -s)" in
|
||||||
|
MINGW*|MSYS*|CYGWIN*) PLATFORM=windows ;;
|
||||||
|
*) PLATFORM=unix ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
OK=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
for skill in "${SKILLS[@]}"; do
|
||||||
|
skill="${skill%/}" # strip trailing slash for clean paths
|
||||||
|
name="$(basename "$skill")"
|
||||||
|
link="$DST/$name"
|
||||||
|
|
||||||
|
if [ "$PLATFORM" = "windows" ]; then
|
||||||
|
win_link="$(cygpath -w "$link")"
|
||||||
|
win_src="$(cygpath -w "$skill")"
|
||||||
|
|
||||||
|
if [ -e "$link" ] || [ -L "$link" ]; then
|
||||||
|
# rmdir (no /s) removes only the junction/symlink, never the target.
|
||||||
|
if ! cmd //c rmdir "$win_link" >/dev/null 2>&1; then
|
||||||
|
echo "SKIP $name (existing path is not a link — left untouched)"
|
||||||
|
FAIL=$((FAIL+1)); continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if powershell -NoProfile -Command \
|
||||||
|
"New-Item -ItemType Junction -Path '$win_link' -Target '$win_src' -ErrorAction Stop" \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
echo "OK $name"; OK=$((OK+1))
|
||||||
|
else
|
||||||
|
echo "FAIL $name"; FAIL=$((FAIL+1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# ln -sfn replaces an existing symlink safely (does not follow it).
|
||||||
|
if ln -sfn "$skill" "$link"; then
|
||||||
|
echo "OK $name"; OK=$((OK+1))
|
||||||
|
else
|
||||||
|
echo "FAIL $name"; FAIL=$((FAIL+1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done: $OK linked, $FAIL failed."
|
||||||
|
echo "Target: $DST"
|
||||||
|
echo "Skills are now invocable via the Claude Code Skill tool."
|
||||||
|
|
@ -18,14 +18,14 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("page", ctx.Arg("page"))
|
q.Set("page", ctx.Arg("page"))
|
||||||
q.Set("limit", ctx.Arg("limit"))
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q)
|
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("获取分支列表失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -39,9 +39,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
name, _ := ctx.RequireArg("name")
|
|
||||||
from := ctx.Arg("from")
|
from := ctx.Arg("from")
|
||||||
if from == "" {
|
if from == "" {
|
||||||
from = "master"
|
from = "master"
|
||||||
|
|
@ -52,7 +55,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload)
|
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("创建分支失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -65,15 +68,18 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
name, _ := ctx.RequireArg("name")
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"branch_name": name,
|
"branch_name": name,
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
|
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("删除分支失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -86,15 +92,18 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
name, _ := ctx.RequireArg("name")
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"branch_name": name,
|
"branch_name": name,
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
|
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("设置分支保护失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -107,13 +116,16 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
name, _ := ctx.RequireArg("name")
|
name, err := ctx.RequireArg("name")
|
||||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("取消分支保护失败: %w", err)
|
||||||
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
package branch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBranchList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/branches.json" {
|
||||||
|
writeJSON(t, w, []map[string]interface{}{
|
||||||
|
{"name": "master", "protected": true},
|
||||||
|
{"name": "develop", "protected": false},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBranchShortcut(t, server, "list", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("branch list failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchCreate(t *testing.T) {
|
||||||
|
var body map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/branches.json" {
|
||||||
|
body = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{"name": "feature-x"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBranchShortcut(t, server, "create", map[string]string{
|
||||||
|
"name": "feature-x",
|
||||||
|
"from": "master",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("branch create failed: %v", err)
|
||||||
|
}
|
||||||
|
if body["new_branch_name"] != "feature-x" {
|
||||||
|
t.Fatalf("expected new_branch_name=feature-x, got %v", body["new_branch_name"])
|
||||||
|
}
|
||||||
|
if body["old_branch_name"] != "master" {
|
||||||
|
t.Fatalf("expected old_branch_name=master, got %v", body["old_branch_name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchDelete(t *testing.T) {
|
||||||
|
var body map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/branches/delete.json" {
|
||||||
|
body = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBranchShortcut(t, server, "delete", map[string]string{"name": "feature-x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("branch delete failed: %v", err)
|
||||||
|
}
|
||||||
|
if body["branch_name"] != "feature-x" {
|
||||||
|
t.Fatalf("expected branch_name=feature-x, got %v", body["branch_name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchProtect(t *testing.T) {
|
||||||
|
var body map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/owner/repo/protected_branches.json" {
|
||||||
|
body = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "protected"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runBranchShortcut(t, server, "protect", map[string]string{"name": "master"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("branch protect failed: %v", err)
|
||||||
|
}
|
||||||
|
if body["branch_name"] != "master" {
|
||||||
|
t.Fatalf("expected branch_name=master, got %v", body["branch_name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBranchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findBranchShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findBranchShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range Shortcuts() {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||||
|
t.Fatalf("failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,14 +18,14 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("page", ctx.Arg("page"))
|
q.Set("page", ctx.Arg("page"))
|
||||||
q.Set("limit", ctx.Arg("limit"))
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
|
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("获取构建列表失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -40,9 +40,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
build, err := ctx.RequireArg("build")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
build, _ := ctx.RequireArg("build")
|
|
||||||
stage := ctx.Arg("stage")
|
stage := ctx.Arg("stage")
|
||||||
step := ctx.Arg("step")
|
step := ctx.Arg("step")
|
||||||
if stage == "" {
|
if stage == "" {
|
||||||
|
|
@ -53,7 +56,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil)
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("获取构建日志失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -66,13 +69,16 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
build, _ := ctx.RequireArg("build")
|
build, err := ctx.RequireArg("build")
|
||||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("重启构建失败: %w", err)
|
||||||
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -84,13 +90,16 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
build, _ := ctx.RequireArg("build")
|
build, err := ctx.RequireArg("build")
|
||||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("停止构建失败: %w", err)
|
||||||
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
package commit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Description: "List commits in a repository",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA"},
|
||||||
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("page", ctx.Arg("page"))
|
||||||
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
|
if sha := ctx.Arg("sha"); sha != "" {
|
||||||
|
q.Set("sha", sha)
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo), q)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取提交列表失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "view",
|
||||||
|
Description: "View files changed in a commit",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
|
||||||
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
sha, err := ctx.RequireArg("sha")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("page", ctx.Arg("page"))
|
||||||
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), q)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查看提交详情失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "diff",
|
||||||
|
Description: "Show diff for a commit",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
sha, err := ctx.RequireArg("sha")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取提交 Diff 失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "blame",
|
||||||
|
Description: "Show blame for a file",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "path", Short: "p", Usage: "File path", Required: true},
|
||||||
|
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
filePath, err := ctx.RequireArg("path")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("filepath", filePath)
|
||||||
|
q.Set("sha", ctx.Arg("sha"))
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo), q)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取 Blame 信息失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
package commit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommitList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/commits.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"total_count": 1,
|
||||||
|
"commits": []map[string]interface{}{
|
||||||
|
{"sha": "abc123", "commit_message": "initial commit"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runCommitShortcut(t, server, "list", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitListWithSHA(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/commits.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("sha") != "develop" {
|
||||||
|
t.Fatalf("expected sha=develop, got %s", r.URL.Query().Get("sha"))
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"total_count": 0,
|
||||||
|
"commits": []map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runCommitShortcut(t, server, "list", map[string]string{"sha": "develop"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list with sha failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitView(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/commits/abc123/files.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"file_nums": 1,
|
||||||
|
"files": []map[string]interface{}{
|
||||||
|
{"filename": "main.go", "additions": 10, "deletions": 2},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runCommitShortcut(t, server, "view", map[string]string{"sha": "abc123"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("view shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitDiff(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/commits/abc123/diff.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"file_nums": 1,
|
||||||
|
"total_addition": 10,
|
||||||
|
"total_deletion": 2,
|
||||||
|
"files": []map[string]interface{}{{"name": "main.go"}},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runCommitShortcut(t, server, "diff", map[string]string{"sha": "abc123"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("diff shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitBlame(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/blame.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("filepath") != "main.go" {
|
||||||
|
t.Fatalf("expected filepath=main.go, got %s", r.URL.Query().Get("filepath"))
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"file_name": "main.go",
|
||||||
|
"num_lines": 20,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runCommitShortcut(t, server, "blame", map[string]string{"path": "main.go", "sha": "master"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blame shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === helpers ===
|
||||||
|
|
||||||
|
func runCommitShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findCommitShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{
|
||||||
|
HTTP: server.Client(),
|
||||||
|
BaseURL: server.URL,
|
||||||
|
},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCommitShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range Shortcuts() {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||||
|
t.Fatalf("failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEqual(t *testing.T, got, want interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
|
||||||
|
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,7 @@ type RuntimeContext struct {
|
||||||
Repo string
|
Repo string
|
||||||
Format string
|
Format string
|
||||||
Args map[string]string
|
Args map[string]string
|
||||||
|
AIMode string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
|
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Description: "List repository files",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
|
||||||
|
{Name: "search", Short: "s", Usage: "Search keyword"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
if ref := ctx.Arg("ref"); ref != "" {
|
||||||
|
q.Set("ref", ref)
|
||||||
|
}
|
||||||
|
if search := ctx.Arg("search"); search != "" {
|
||||||
|
q.Set("search", search)
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tree",
|
||||||
|
Description: "List file tree for a branch or commit",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"},
|
||||||
|
{Name: "recursive", Usage: "Recursively list all files", Bool: true, Default: "false"},
|
||||||
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sha := ctx.Arg("sha")
|
||||||
|
if sha == "" {
|
||||||
|
sha = "master"
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("page", ctx.Arg("page"))
|
||||||
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
|
if ctx.Arg("recursive") == "true" {
|
||||||
|
q.Set("recursive", "true")
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET",
|
||||||
|
fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha), q)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "get",
|
||||||
|
Description: "Get file or directory contents",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "path", Short: "p", Usage: "File or directory path", Required: true},
|
||||||
|
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filePath, err := ctx.RequireArg("path")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("filepath", filePath)
|
||||||
|
q.Set("ref", ctx.Arg("ref"))
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "create",
|
||||||
|
Description: "Create a new file in the repository",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "path", Short: "p", Usage: "File path", Required: true},
|
||||||
|
{Name: "content", Short: "c", Usage: "File content (plain text, auto Base64 encoded)", Required: true},
|
||||||
|
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||||
|
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filePath, err := ctx.RequireArg("path")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content, err := ctx.RequireArg("content")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
message, err := ctx.RequireArg("message")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
branch := ctx.Arg("branch")
|
||||||
|
if branch == "" {
|
||||||
|
branch = "master"
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"filepath": filePath,
|
||||||
|
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||||
|
"message": message,
|
||||||
|
"branch": branch,
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/create_file", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete",
|
||||||
|
Description: "Delete a file from the repository",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "path", Short: "p", Usage: "File path", Required: true},
|
||||||
|
{Name: "sha", Short: "s", Usage: "File blob SHA (from file +list)", Required: true},
|
||||||
|
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
|
||||||
|
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filePath, err := ctx.RequireArg("path")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sha, err := ctx.RequireArg("sha")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
message, err := ctx.RequireArg("message")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
branch := ctx.Arg("branch")
|
||||||
|
if branch == "" {
|
||||||
|
branch = "master"
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"filepath": filePath,
|
||||||
|
"sha": sha,
|
||||||
|
"message": message,
|
||||||
|
"branch": branch,
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/delete_file", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/owner/repo/files.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(t, w, []map[string]interface{}{
|
||||||
|
{"name": "README.md", "path": "README.md", "type": "file"},
|
||||||
|
{"name": "src", "path": "src", "type": "dir"},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runFileShortcut(t, server, "list", map[string]string{}); err != nil {
|
||||||
|
t.Fatalf("list failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileListWithRef(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("ref") != "dev" {
|
||||||
|
t.Fatalf("expected ref=dev, got %s", r.URL.Query().Get("ref"))
|
||||||
|
}
|
||||||
|
writeJSON(t, w, []map[string]interface{}{})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runFileShortcut(t, server, "list", map[string]string{"ref": "dev"}); err != nil {
|
||||||
|
t.Fatalf("list with ref failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileTree(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/git/trees/master.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"total_count": 1,
|
||||||
|
"entries": []map[string]interface{}{{"name": "main.go", "type": "file"}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runFileShortcut(t, server, "tree", map[string]string{}); err != nil {
|
||||||
|
t.Fatalf("tree failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileTreeRecursive(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("recursive") != "true" {
|
||||||
|
t.Fatalf("expected recursive=true")
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"entries": []map[string]interface{}{}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runFileShortcut(t, server, "tree", map[string]string{"recursive": "true"}); err != nil {
|
||||||
|
t.Fatalf("tree recursive failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileGet(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/owner/repo/sub_entries.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("filepath") != "README.md" {
|
||||||
|
t.Fatalf("expected filepath=README.md, got %s", r.URL.Query().Get("filepath"))
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"name": "README.md", "type": "file"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runFileShortcut(t, server, "get", map[string]string{"path": "README.md"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileGetRequiresPath(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made without --path")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runFileShortcut(t, server, "get", map[string]string{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing --path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileCreate(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/owner/repo/create_file.json" {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["filepath"] != "test.txt" {
|
||||||
|
t.Fatalf("expected filepath=test.txt, got %v", payload["filepath"])
|
||||||
|
}
|
||||||
|
if payload["message"] != "add test" {
|
||||||
|
t.Fatalf("expected message=add test, got %v", payload["message"])
|
||||||
|
}
|
||||||
|
if payload["branch"] != "master" {
|
||||||
|
t.Fatalf("expected branch=master, got %v", payload["branch"])
|
||||||
|
}
|
||||||
|
if _, ok := payload["content"].(string); !ok || payload["content"] == "" {
|
||||||
|
t.Fatal("content should be a non-empty Base64 string")
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"name": "test.txt", "sha": "abc123"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runFileShortcut(t, server, "create", map[string]string{
|
||||||
|
"path": "test.txt", "content": "hello world", "message": "add test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileDelete(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "DELETE" && r.URL.Path == "/owner/repo/delete_file.json" {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["filepath"] != "old.txt" {
|
||||||
|
t.Fatalf("expected filepath=old.txt, got %v", payload["filepath"])
|
||||||
|
}
|
||||||
|
if payload["sha"] != "def456" {
|
||||||
|
t.Fatalf("expected sha=def456, got %v", payload["sha"])
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runFileShortcut(t, server, "delete", map[string]string{
|
||||||
|
"path": "old.txt", "sha": "def456", "message": "remove old",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileDeleteRequiresPath(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made without --path")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runFileShortcut(t, server, "delete", map[string]string{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing --path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === helpers ===
|
||||||
|
|
||||||
|
func runFileShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findFileShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
Owner: "owner", Repo: "repo", Format: "json", Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findFileShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range Shortcuts() {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
@ -6,26 +6,29 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
const closedIssueStatusID = 5
|
const closedIssueStatusID = 5
|
||||||
|
const openIssueStatusID = 1
|
||||||
|
|
||||||
type batchCloseResult struct {
|
type batchResult struct {
|
||||||
Number string `json:"number" yaml:"number"`
|
Number string `json:"number" yaml:"number"`
|
||||||
Action string `json:"action" yaml:"action"`
|
Action string `json:"action" yaml:"action"`
|
||||||
Status string `json:"status" yaml:"status"`
|
Status string `json:"status" yaml:"status"`
|
||||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type batchCloseSummary struct {
|
type batchSummary struct {
|
||||||
Repository string `json:"repository" yaml:"repository"`
|
Repository string `json:"repository" yaml:"repository"`
|
||||||
DryRun bool `json:"dry_run" yaml:"dry_run"`
|
DryRun bool `json:"dry_run" yaml:"dry_run"`
|
||||||
Total int `json:"total" yaml:"total"`
|
Total int `json:"total" yaml:"total"`
|
||||||
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
||||||
Failed int `json:"failed" yaml:"failed"`
|
Failed int `json:"failed" yaml:"failed"`
|
||||||
Results []batchCloseResult `json:"results" yaml:"results"`
|
Duration string `json:"duration" yaml:"duration"`
|
||||||
|
Results []batchResult `json:"results" yaml:"results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBatchCloseShortcut() *common.Shortcut {
|
func newBatchCloseShortcut() *common.Shortcut {
|
||||||
|
|
@ -43,27 +46,29 @@ func newBatchCloseShortcut() *common.Shortcut {
|
||||||
|
|
||||||
func runBatchClose(ctx *common.RuntimeContext) error {
|
func runBatchClose(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(numbers) == 0 {
|
if len(numbers) == 0 {
|
||||||
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||||
}
|
}
|
||||||
|
|
||||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||||
summary := batchCloseSummary{
|
summary := batchSummary{
|
||||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||||
DryRun: dryRun,
|
DryRun: dryRun,
|
||||||
Total: len(numbers),
|
Total: len(numbers),
|
||||||
Results: make([]batchCloseResult, 0, len(numbers)),
|
Results: make([]batchResult, 0, len(numbers)),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, number := range numbers {
|
for _, number := range numbers {
|
||||||
result := batchCloseResult{Number: number, Action: "close"}
|
result := batchResult{Number: number, Action: "close"}
|
||||||
if dryRun {
|
if dryRun {
|
||||||
result.Status = "planned"
|
result.Status = "planned"
|
||||||
summary.Succeeded++
|
summary.Succeeded++
|
||||||
|
|
@ -82,11 +87,13 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
||||||
summary.Results = append(summary.Results, result)
|
summary.Results = append(summary.Results, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
summary.Duration = time.Since(start).String()
|
||||||
|
|
||||||
if err := ctx.OutputData(summary); err != nil {
|
if err := ctx.OutputData(summary); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if summary.Failed > 0 {
|
if summary.Failed > 0 {
|
||||||
return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total)
|
return fmt.Errorf("%d / %d 个 Issue 关闭失败", summary.Failed, summary.Total)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +101,7 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
||||||
func closeIssue(ctx *common.RuntimeContext, number string) error {
|
func closeIssue(ctx *common.RuntimeContext, number string) error {
|
||||||
current, err := fetchExistingIssue(ctx, number)
|
current, err := fetchExistingIssue(ctx, number)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("fetch issue: %w", err)
|
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
|
|
@ -103,11 +110,466 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
|
||||||
"status_id": closedIssueStatusID,
|
"status_id": closedIssueStatusID,
|
||||||
}
|
}
|
||||||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||||||
return fmt.Errorf("close issue: %w", err)
|
return fmt.Errorf("关闭 Issue: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newBatchReopenShortcut() *common.Shortcut {
|
||||||
|
return &common.Shortcut{
|
||||||
|
Name: "batch-reopen",
|
||||||
|
Description: "Reopen multiple closed issues by issue numbers or a CSV file",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
|
||||||
|
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
|
||||||
|
{Name: "dry-run", Usage: "Preview the issues that would be reopened without changing them", Bool: true, Default: "false"},
|
||||||
|
},
|
||||||
|
Run: runBatchReopen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBatchReopen(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(numbers) == 0 {
|
||||||
|
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||||
|
summary := batchSummary{
|
||||||
|
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||||
|
DryRun: dryRun,
|
||||||
|
Total: len(numbers),
|
||||||
|
Results: make([]batchResult, 0, len(numbers)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, number := range numbers {
|
||||||
|
result := batchResult{Number: number, Action: "reopen"}
|
||||||
|
if dryRun {
|
||||||
|
result.Status = "planned"
|
||||||
|
summary.Succeeded++
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := reopenIssue(ctx, number); err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Error = err.Error()
|
||||||
|
summary.Failed++
|
||||||
|
} else {
|
||||||
|
result.Status = "reopened"
|
||||||
|
summary.Succeeded++
|
||||||
|
}
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Duration = time.Since(start).String()
|
||||||
|
|
||||||
|
if err := ctx.OutputData(summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if summary.Failed > 0 {
|
||||||
|
return fmt.Errorf("%d / %d 个 Issue 重新打开失败", summary.Failed, summary.Total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reopenIssue(ctx *common.RuntimeContext, number string) error {
|
||||||
|
current, err := fetchExistingIssue(ctx, number)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"subject": current.Subject,
|
||||||
|
"description": current.Description,
|
||||||
|
"status_id": openIssueStatusID,
|
||||||
|
}
|
||||||
|
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||||||
|
return fmt.Errorf("重新打开 Issue: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchAssignShortcut() *common.Shortcut {
|
||||||
|
return &common.Shortcut{
|
||||||
|
Name: "batch-assign",
|
||||||
|
Description: "Assign multiple issues to a user by issue numbers or a CSV file",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
|
||||||
|
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
|
||||||
|
{Name: "user", Short: "u", Usage: "Assignee user ID (numeric)", Required: true},
|
||||||
|
{Name: "dry-run", Usage: "Preview the issues that would be assigned without changing them", Bool: true, Default: "false"},
|
||||||
|
},
|
||||||
|
Run: runBatchAssign,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBatchAssign(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := ctx.RequireArg("user")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(numbers) == 0 {
|
||||||
|
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||||
|
summary := batchSummary{
|
||||||
|
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||||
|
DryRun: dryRun,
|
||||||
|
Total: len(numbers),
|
||||||
|
Results: make([]batchResult, 0, len(numbers)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, number := range numbers {
|
||||||
|
result := batchResult{Number: number, Action: "assign"}
|
||||||
|
if dryRun {
|
||||||
|
result.Status = "planned"
|
||||||
|
summary.Succeeded++
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := assignIssue(ctx, number, user); err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Error = err.Error()
|
||||||
|
summary.Failed++
|
||||||
|
} else {
|
||||||
|
result.Status = "assigned"
|
||||||
|
summary.Succeeded++
|
||||||
|
}
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.OutputData(summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if summary.Failed > 0 {
|
||||||
|
return fmt.Errorf("%d / %d 个 Issue 分配失败", summary.Failed, summary.Total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignIssue(ctx *common.RuntimeContext, number, user string) error {
|
||||||
|
current, err := fetchExistingIssue(ctx, number)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := strconv.Atoi(user)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user 参数必须是数字 ID,而不是用户名")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"subject": current.Subject,
|
||||||
|
"description": current.Description,
|
||||||
|
"assigner_ids": []int64{int64(userID)},
|
||||||
|
}
|
||||||
|
if current.StatusID != nil {
|
||||||
|
body["status_id"] = current.StatusID
|
||||||
|
}
|
||||||
|
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||||||
|
return fmt.Errorf("分配 Issue: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchLabelShortcut() *common.Shortcut {
|
||||||
|
return &common.Shortcut{
|
||||||
|
Name: "batch-label",
|
||||||
|
Description: "Add or remove labels on multiple issues by issue numbers or a CSV file",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
|
||||||
|
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
|
||||||
|
{Name: "add", Short: "a", Usage: "Comma-separated label IDs to add"},
|
||||||
|
{Name: "remove", Short: "r", Usage: "Comma-separated label IDs to remove"},
|
||||||
|
{Name: "dry-run", Usage: "Preview the issues that would be labeled without changing them", Bool: true, Default: "false"},
|
||||||
|
},
|
||||||
|
Run: runBatchLabel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBatchLabel(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addIDs := ctx.Arg("add")
|
||||||
|
removeIDs := ctx.Arg("remove")
|
||||||
|
if addIDs == "" && removeIDs == "" {
|
||||||
|
return fmt.Errorf("至少需要指定 --add 或 --remove 中的一个")
|
||||||
|
}
|
||||||
|
|
||||||
|
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(numbers) == 0 {
|
||||||
|
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||||
|
summary := batchSummary{
|
||||||
|
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||||
|
DryRun: dryRun,
|
||||||
|
Total: len(numbers),
|
||||||
|
Results: make([]batchResult, 0, len(numbers)),
|
||||||
|
}
|
||||||
|
|
||||||
|
addLabels := parseCommaSeparated(addIDs)
|
||||||
|
removeLabels := parseCommaSeparated(removeIDs)
|
||||||
|
|
||||||
|
for _, number := range numbers {
|
||||||
|
result := batchResult{Number: number, Action: "label"}
|
||||||
|
if dryRun {
|
||||||
|
result.Status = "planned"
|
||||||
|
summary.Succeeded++
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := labelIssue(ctx, number, addLabels, removeLabels); err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Error = err.Error()
|
||||||
|
summary.Failed++
|
||||||
|
} else {
|
||||||
|
result.Status = "labeled"
|
||||||
|
summary.Succeeded++
|
||||||
|
}
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.OutputData(summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if summary.Failed > 0 {
|
||||||
|
return fmt.Errorf("%d / %d 个 Issue 标签操作失败", summary.Failed, summary.Total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func labelIssue(ctx *common.RuntimeContext, number string, addLabels, removeLabels []string) error {
|
||||||
|
var errs []string
|
||||||
|
for _, labelID := range addLabels {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"tag_id": labelID,
|
||||||
|
}
|
||||||
|
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/tags", v1RepoPath(ctx), number), body); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("添加标签 %s: %v", labelID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, labelID := range removeLabels {
|
||||||
|
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("移除标签 %s: %v", labelID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("%s", strings.Join(errs, "\n"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchMilestoneShortcut() *common.Shortcut {
|
||||||
|
return &common.Shortcut{
|
||||||
|
Name: "batch-milestone",
|
||||||
|
Description: "Set milestone on multiple issues by issue numbers or a CSV file",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
|
||||||
|
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
|
||||||
|
{Name: "milestone", Short: "m", Usage: "Milestone ID", Required: true},
|
||||||
|
{Name: "dry-run", Usage: "Preview the issues that would be updated without changing them", Bool: true, Default: "false"},
|
||||||
|
},
|
||||||
|
Run: runBatchMilestone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBatchMilestone(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
milestoneID, err := ctx.RequireArg("milestone")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(numbers) == 0 {
|
||||||
|
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||||
|
summary := batchSummary{
|
||||||
|
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||||
|
DryRun: dryRun,
|
||||||
|
Total: len(numbers),
|
||||||
|
Results: make([]batchResult, 0, len(numbers)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, number := range numbers {
|
||||||
|
result := batchResult{Number: number, Action: "milestone"}
|
||||||
|
if dryRun {
|
||||||
|
result.Status = "planned"
|
||||||
|
summary.Succeeded++
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setMilestone(ctx, number, milestoneID); err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Error = err.Error()
|
||||||
|
summary.Failed++
|
||||||
|
} else {
|
||||||
|
result.Status = "milestoned"
|
||||||
|
summary.Succeeded++
|
||||||
|
}
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.OutputData(summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if summary.Failed > 0 {
|
||||||
|
return fmt.Errorf("%d / %d 个 Issue 设置里程碑失败", summary.Failed, summary.Total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMilestone(ctx *common.RuntimeContext, number, milestoneID string) error {
|
||||||
|
current, err := fetchExistingIssue(ctx, number)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"subject": current.Subject,
|
||||||
|
"description": current.Description,
|
||||||
|
"fixed_version_id": milestoneID,
|
||||||
|
}
|
||||||
|
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||||||
|
return fmt.Errorf("设置里程碑: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchCommentShortcut() *common.Shortcut {
|
||||||
|
return &common.Shortcut{
|
||||||
|
Name: "batch-comment",
|
||||||
|
Description: "Add a comment to multiple issues by issue numbers or a CSV file",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
|
||||||
|
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
|
||||||
|
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||||
|
{Name: "dry-run", Usage: "Preview the issues that would be commented on without changing them", Bool: true, Default: "false"},
|
||||||
|
},
|
||||||
|
Run: runBatchComment,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBatchComment(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ctx.RequireArg("body")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(numbers) == 0 {
|
||||||
|
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||||
|
summary := batchSummary{
|
||||||
|
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||||
|
DryRun: dryRun,
|
||||||
|
Total: len(numbers),
|
||||||
|
Results: make([]batchResult, 0, len(numbers)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, number := range numbers {
|
||||||
|
result := batchResult{Number: number, Action: "comment"}
|
||||||
|
if dryRun {
|
||||||
|
result.Status = "planned"
|
||||||
|
summary.Succeeded++
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := commentIssue(ctx, number, body); err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Error = err.Error()
|
||||||
|
summary.Failed++
|
||||||
|
} else {
|
||||||
|
result.Status = "commented"
|
||||||
|
summary.Succeeded++
|
||||||
|
}
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Duration = time.Since(start).String()
|
||||||
|
|
||||||
|
if err := ctx.OutputData(summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if summary.Failed > 0 {
|
||||||
|
return fmt.Errorf("%d / %d 个 Issue 评论失败", summary.Failed, summary.Total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commentIssue(ctx *common.RuntimeContext, number, body string) error {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"notes": body,
|
||||||
|
}
|
||||||
|
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload); err != nil {
|
||||||
|
return fmt.Errorf("添加评论: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCommaSeparated(value string) []string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var result []string
|
||||||
|
for _, item := range strings.Split(value, ",") {
|
||||||
|
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
||||||
|
result = append(result, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||||
numbers, err := parseIssueNumbers(numbersValue)
|
numbers, err := parseIssueNumbers(numbersValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -134,7 +596,7 @@ func parseIssueNumbers(value string) ([]string, error) {
|
||||||
func readIssueNumbersFromCSV(path string) ([]string, error) {
|
func readIssueNumbersFromCSV(path string) ([]string, error) {
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read issue numbers from CSV: %w", err)
|
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
|
|
@ -142,7 +604,7 @@ func readIssueNumbersFromCSV(path string) ([]string, error) {
|
||||||
reader.TrimLeadingSpace = true
|
reader.TrimLeadingSpace = true
|
||||||
records, err := reader.ReadAll()
|
records, err := reader.ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse issue numbers from CSV: %w", err)
|
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
|
||||||
}
|
}
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
@ -180,7 +642,7 @@ func normalizeIssueNumbers(values []string) ([]string, error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
|
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
|
||||||
return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number)
|
return nil, fmt.Errorf("无效的 Issue 编号 %q: Issue 编号必须是整数", number)
|
||||||
}
|
}
|
||||||
if seen[number] {
|
if seen[number] {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -17,11 +18,17 @@ func v1RepoPath(ctx *common.RuntimeContext) string {
|
||||||
type existingIssue struct {
|
type existingIssue struct {
|
||||||
Subject string
|
Subject string
|
||||||
Description string
|
Description string
|
||||||
|
StatusID interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Shortcuts() []*common.Shortcut {
|
func Shortcuts() []*common.Shortcut {
|
||||||
return []*common.Shortcut{
|
return []*common.Shortcut{
|
||||||
newBatchCloseShortcut(),
|
newBatchCloseShortcut(),
|
||||||
|
newBatchReopenShortcut(),
|
||||||
|
newBatchAssignShortcut(),
|
||||||
|
newBatchLabelShortcut(),
|
||||||
|
newBatchMilestoneShortcut(),
|
||||||
|
newBatchCommentShortcut(),
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List issues",
|
Description: "List issues",
|
||||||
|
|
@ -32,7 +39,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("page", ctx.Arg("page"))
|
q.Set("page", ctx.Arg("page"))
|
||||||
|
|
@ -42,7 +49,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("获取 Issue 列表失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -59,7 +66,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
title, err := ctx.RequireArg("title")
|
title, err := ctx.RequireArg("title")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -82,7 +89,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("创建 Issue 失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -95,7 +102,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
number, err := ctx.RequireArg("number")
|
number, err := ctx.RequireArg("number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -103,7 +110,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("查看 Issue 详情失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -116,7 +123,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
number, err := ctx.RequireArg("number")
|
number, err := ctx.RequireArg("number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -124,7 +131,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
current, err := fetchExistingIssue(ctx, number)
|
current, err := fetchExistingIssue(ctx, number)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("关闭 Issue 失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
|
|
@ -133,9 +140,40 @@ func Shortcuts() []*common.Shortcut {
|
||||||
"status_id": 5, // 5 = closed
|
"status_id": 5, // 5 = closed
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("关闭 Issue 失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "reopen",
|
||||||
|
Description: "Reopen a closed issue",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
number, err := ctx.RequireArg("number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
current, err := fetchExistingIssue(ctx, number)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("重新打开 Issue 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"subject": current.Subject,
|
||||||
|
"description": current.Description,
|
||||||
|
"status_id": 1, // 1 = open
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("重新打开 Issue 失败: %w", err)
|
||||||
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -150,7 +188,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
number, err := ctx.RequireArg("number")
|
number, err := ctx.RequireArg("number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -160,12 +198,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
description := ctx.Arg("body")
|
description := ctx.Arg("body")
|
||||||
state := ctx.Arg("state")
|
state := ctx.Arg("state")
|
||||||
if title == "" && description == "" && state == "" {
|
if title == "" && description == "" && state == "" {
|
||||||
return fmt.Errorf("at least one of --title, --body, or --state is required")
|
return fmt.Errorf("至少需要指定 --title、--body 或 --state 中的一个")
|
||||||
}
|
}
|
||||||
|
|
||||||
current, err := fetchExistingIssue(ctx, number)
|
current, err := fetchExistingIssue(ctx, number)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("更新 Issue 失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
|
|
@ -187,11 +225,109 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("更新 Issue 失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "assign",
|
||||||
|
Description: "Assign an issue to a user",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||||
|
{Name: "user", Short: "u", Usage: "Assignee user ID (numeric)", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
number, err := ctx.RequireArg("number")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user, err := ctx.RequireArg("user")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
current, err := fetchExistingIssue(ctx, number)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("分配 Issue 失败: %w", err)
|
||||||
|
}
|
||||||
|
userID, err := strconv.Atoi(user)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user 参数必须是数字 ID,而不是用户名")
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"subject": current.Subject,
|
||||||
|
"description": current.Description,
|
||||||
|
"assigner_ids": []int64{int64(userID)},
|
||||||
|
}
|
||||||
|
if current.StatusID != nil {
|
||||||
|
body["status_id"] = current.StatusID
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("分配 Issue 失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "label",
|
||||||
|
Description: "Add or remove labels on an issue",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||||
|
{Name: "add", Short: "a", Usage: "Comma-separated label IDs to add"},
|
||||||
|
{Name: "remove", Short: "r", Usage: "Comma-separated label IDs to remove"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
number, err := ctx.RequireArg("number")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
addIDs := ctx.Arg("add")
|
||||||
|
removeIDs := ctx.Arg("remove")
|
||||||
|
if addIDs == "" && removeIDs == "" {
|
||||||
|
return fmt.Errorf("至少需要指定 --add 或 --remove 中的一个")
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []string
|
||||||
|
if addIDs != "" {
|
||||||
|
for _, labelID := range strings.Split(addIDs, ",") {
|
||||||
|
labelID = strings.TrimSpace(labelID)
|
||||||
|
if labelID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"tag_id": labelID,
|
||||||
|
}
|
||||||
|
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/tags", v1RepoPath(ctx), number), body); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("添加标签 %s: %v", labelID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removeIDs != "" {
|
||||||
|
for _, labelID := range strings.Split(removeIDs, ",") {
|
||||||
|
labelID = strings.TrimSpace(labelID)
|
||||||
|
if labelID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("移除标签 %s: %v", labelID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("标签操作失败:\n%s", strings.Join(errs, "\n"))
|
||||||
|
}
|
||||||
|
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||||
|
"message": "标签操作成功",
|
||||||
|
}, nil))
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "comment",
|
Name: "comment",
|
||||||
Description: "Add a comment to an issue",
|
Description: "Add a comment to an issue",
|
||||||
|
|
@ -201,7 +337,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
number, err := ctx.RequireArg("number")
|
number, err := ctx.RequireArg("number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -216,7 +352,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload)
|
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("添加评论失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -227,20 +363,28 @@ func Shortcuts() []*common.Shortcut {
|
||||||
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
|
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
|
||||||
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("获取 Issue 详情: %w", err)
|
||||||
}
|
}
|
||||||
issueData, ok := getEnv.Data.(map[string]interface{})
|
issueData, ok := getEnv.Data.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("failed to parse issue data")
|
return nil, fmt.Errorf("解析 Issue 数据失败")
|
||||||
}
|
}
|
||||||
subject, _ := issueData["subject"].(string)
|
subject, _ := issueData["subject"].(string)
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
return nil, fmt.Errorf("failed to parse issue subject")
|
return nil, fmt.Errorf("解析 Issue 标题失败")
|
||||||
}
|
}
|
||||||
description, _ := issueData["description"].(string)
|
description, _ := issueData["description"].(string)
|
||||||
|
statusID := issueData["status_id"]
|
||||||
|
if statusID == nil {
|
||||||
|
// Try nested status object
|
||||||
|
if status, ok := issueData["status"].(map[string]interface{}); ok {
|
||||||
|
statusID = status["id"]
|
||||||
|
}
|
||||||
|
}
|
||||||
return &existingIssue{
|
return &existingIssue{
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Description: description,
|
Description: description,
|
||||||
|
StatusID: statusID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,6 +398,6 @@ func normalizeIssueStatus(state string) (interface{}, error) {
|
||||||
if id, err := strconv.Atoi(state); err == nil {
|
if id, err := strconv.Atoi(state); err == nil {
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
|
return nil, fmt.Errorf("无效的 --state 值 %q: 请使用 open、closed 或数字 status_id", state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,3 +186,361 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
||||||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIssueList(t *testing.T) {
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"issues": []map[string]interface{}{
|
||||||
|
{"id": float64(1), "subject": "bug", "status_id": float64(1)},
|
||||||
|
{"id": float64(2), "subject": "feature", "status_id": float64(5)},
|
||||||
|
},
|
||||||
|
"total_count": float64(2),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "list", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue list failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCreate(t *testing.T) {
|
||||||
|
var createPayload map[string]interface{}
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues.json" {
|
||||||
|
createPayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(99), "subject": "new bug",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "create", map[string]string{
|
||||||
|
"title": "new bug",
|
||||||
|
"body": "steps to reproduce",
|
||||||
|
"assignee": "42",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, createPayload["subject"], "new bug")
|
||||||
|
assertEqual(t, createPayload["description"], "steps to reproduce")
|
||||||
|
assertEqual(t, createPayload["assigned_to_id"], "42")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueView(t *testing.T) {
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1), "subject": "test issue", "status_id": float64(1),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "view", map[string]string{"number": "1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue view failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueComment(t *testing.T) {
|
||||||
|
var commentPayload map[string]interface{}
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/1/journals.json" {
|
||||||
|
commentPayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1), "notes": "looks good",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "comment", map[string]string{
|
||||||
|
"number": "1",
|
||||||
|
"body": "looks good",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue comment failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, commentPayload["notes"], "looks good")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueAssignSendsCorrectUser(t *testing.T) {
|
||||||
|
var assignPayload map[string]interface{}
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"subject": "Existing title",
|
||||||
|
"description": "Existing description",
|
||||||
|
})
|
||||||
|
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
|
||||||
|
assignPayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "指派成功"})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "assign", map[string]string{
|
||||||
|
"number": "42",
|
||||||
|
"user": "42",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assign shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitLink 责任人字段是 assigner_ids(数组),不是 assigned_to_id
|
||||||
|
got, ok := assignPayload["assigner_ids"].([]interface{})
|
||||||
|
if !ok || len(got) != 1 {
|
||||||
|
t.Fatalf("expected assigner_ids [42], got %#v", assignPayload["assigner_ids"])
|
||||||
|
}
|
||||||
|
assertEqual(t, got[0], float64(42))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueAssignRequiresNumberAndUser(t *testing.T) {
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"message": "ok",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "assign", map[string]string{"number": ""})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("assign shortcut should error when required flags are missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueLabelAddSendsCorrectTagIDs(t *testing.T) {
|
||||||
|
var capturedPaths []string
|
||||||
|
var capturedPayloads []map[string]interface{}
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/42/tags.json":
|
||||||
|
capturedPaths = append(capturedPaths, r.URL.Path)
|
||||||
|
capturedPayloads = append(capturedPayloads, decodeJSON(t, r))
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"message": "标签添加成功",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "label", map[string]string{
|
||||||
|
"number": "42",
|
||||||
|
"add": "5,8",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("label shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(capturedPaths) != 2 {
|
||||||
|
t.Fatalf("expected 2 tag add calls, got %d", len(capturedPaths))
|
||||||
|
}
|
||||||
|
assertEqual(t, capturedPayloads[0]["tag_id"], "5")
|
||||||
|
assertEqual(t, capturedPayloads[1]["tag_id"], "8")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueLabelRemoveSendsDeleteRequests(t *testing.T) {
|
||||||
|
var capturedPaths []string
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42/tags/3.json":
|
||||||
|
capturedPaths = append(capturedPaths, r.URL.Path)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"message": "标签删除成功",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "label", map[string]string{
|
||||||
|
"number": "42",
|
||||||
|
"remove": "3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("label shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(capturedPaths) != 1 {
|
||||||
|
t.Fatalf("expected 1 tag delete call, got %d", len(capturedPaths))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchAssign(t *testing.T) {
|
||||||
|
var assignPayload map[string]interface{}
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && (r.URL.Path == "/v1/owner/repo/issues/1.json" || r.URL.Path == "/v1/owner/repo/issues/2.json"):
|
||||||
|
writeJSON(t, w, map[string]interface{}{"subject": "title", "description": "desc"})
|
||||||
|
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/1.json":
|
||||||
|
assignPayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "指派成功"})
|
||||||
|
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/2.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "指派成功"})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "batch-assign", map[string]string{
|
||||||
|
"numbers": "1,2",
|
||||||
|
"user": "42",
|
||||||
|
"dry-run": "false",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch-assign failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := assignPayload["assigner_ids"].([]interface{})
|
||||||
|
if !ok || len(got) != 1 {
|
||||||
|
t.Fatalf("expected assigner_ids [42], got %#v", assignPayload["assigner_ids"])
|
||||||
|
}
|
||||||
|
assertEqual(t, got[0], float64(42))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchAssignDryRun(t *testing.T) {
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made in dry-run mode")
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "batch-assign", map[string]string{
|
||||||
|
"numbers": "1,2,3",
|
||||||
|
"user": "zhangsan",
|
||||||
|
"dry-run": "true",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch-assign dry-run failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchLabelAddAndRemove(t *testing.T) {
|
||||||
|
var capturedPaths []string
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/1/tags.json":
|
||||||
|
capturedPaths = append(capturedPaths, r.URL.Path)
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "标签添加成功"})
|
||||||
|
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/1/tags/3.json":
|
||||||
|
capturedPaths = append(capturedPaths, r.URL.Path)
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "标签删除成功"})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "batch-label", map[string]string{
|
||||||
|
"numbers": "1",
|
||||||
|
"add": "5",
|
||||||
|
"remove": "3",
|
||||||
|
"dry-run": "false",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch-label failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(capturedPaths) != 2 {
|
||||||
|
t.Fatalf("expected 2 API calls, got %d", len(capturedPaths))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchLabelDryRun(t *testing.T) {
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made in dry-run mode")
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "batch-label", map[string]string{
|
||||||
|
"numbers": "1,2",
|
||||||
|
"add": "5,8",
|
||||||
|
"dry-run": "true",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch-label dry-run failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchMilestone(t *testing.T) {
|
||||||
|
var patchPayload map[string]interface{}
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"subject": "Test issue", "description": "desc",
|
||||||
|
})
|
||||||
|
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/1.json":
|
||||||
|
patchPayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "success"})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "batch-milestone", map[string]string{
|
||||||
|
"numbers": "1",
|
||||||
|
"milestone": "5",
|
||||||
|
"dry-run": "false",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch-milestone failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, patchPayload["fixed_version_id"], "5")
|
||||||
|
assertEqual(t, patchPayload["subject"], "Test issue")
|
||||||
|
assertEqual(t, patchPayload["description"], "desc")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchMilestoneDryRun(t *testing.T) {
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made in dry-run mode")
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "batch-milestone", map[string]string{
|
||||||
|
"numbers": "1,2,3",
|
||||||
|
"milestone": "5",
|
||||||
|
"dry-run": "true",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch-milestone dry-run failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueLabelRequiresAddOrRemove(t *testing.T) {
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatalf("should not make any API calls")
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "label", map[string]string{
|
||||||
|
"number": "42",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when neither --add nor --remove is provided")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
package label
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func v1RepoPath(ctx *common.RuntimeContext) string {
|
||||||
|
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Description: "List issue labels",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||||||
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("page", ctx.Arg("page"))
|
||||||
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
|
if k := ctx.Arg("keyword"); k != "" {
|
||||||
|
q.Set("keyword", k)
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_tags", q)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取标签列表失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "create",
|
||||||
|
Description: "Create an issue label",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Label name", Required: true},
|
||||||
|
{Name: "color", Short: "c", Usage: "Label color (hex, e.g. #FF0000)", Default: "#F17013"},
|
||||||
|
{Name: "description", Short: "d", Usage: "Label description"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"color": ctx.Arg("color"),
|
||||||
|
}
|
||||||
|
if desc := ctx.Arg("description"); desc != "" {
|
||||||
|
body["description"] = desc
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issue_tags", body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建标签失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "update",
|
||||||
|
Description: "Update an issue label",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
|
||||||
|
{Name: "name", Short: "n", Usage: "New name"},
|
||||||
|
{Name: "color", Short: "c", Usage: "New color (hex, e.g. #FF0000)"},
|
||||||
|
{Name: "description", Short: "d", Usage: "New description"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := ctx.Arg("name")
|
||||||
|
color := ctx.Arg("color")
|
||||||
|
desc := ctx.Arg("description")
|
||||||
|
if name == "" && color == "" && desc == "" {
|
||||||
|
return fmt.Errorf("至少需要指定 --name、--color 或 --description 中的一个")
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
if name != "" {
|
||||||
|
body["name"] = name
|
||||||
|
}
|
||||||
|
if color != "" {
|
||||||
|
body["color"] = color
|
||||||
|
}
|
||||||
|
if desc != "" {
|
||||||
|
body["description"] = desc
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issue_tags/%s", v1RepoPath(ctx), id), body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("更新标签失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete",
|
||||||
|
Description: "Delete an issue label",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issue_tags/%s", v1RepoPath(ctx), id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("删除标签失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
package label
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLabelList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_tags.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"total_count": 2,
|
||||||
|
"issue_tags": []map[string]interface{}{
|
||||||
|
{"id": 1, "name": "bug", "color": "#FF0000"},
|
||||||
|
{"id": 2, "name": "feature", "color": "#00FF00"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runLabelShortcut(t, server, "list", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLabelCreate(t *testing.T) {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issue_tags.json" {
|
||||||
|
payload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"status": 0,
|
||||||
|
"message": "success",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runLabelShortcut(t, server, "create", map[string]string{
|
||||||
|
"name": "enhancement",
|
||||||
|
"color": "#0000FF",
|
||||||
|
"description": "New feature",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, payload["name"], "enhancement")
|
||||||
|
assertEqual(t, payload["color"], "#0000FF")
|
||||||
|
assertEqual(t, payload["description"], "New feature")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLabelCreateWithDefaultColor(t *testing.T) {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issue_tags.json" {
|
||||||
|
payload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"status": 0,
|
||||||
|
"message": "success",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runLabelShortcut(t, server, "create", map[string]string{
|
||||||
|
"name": "bug",
|
||||||
|
"color": "#F17013",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, payload["color"], "#F17013")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLabelUpdate(t *testing.T) {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issue_tags/3.json" {
|
||||||
|
payload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"status": 0,
|
||||||
|
"message": "success",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runLabelShortcut(t, server, "update", map[string]string{
|
||||||
|
"id": "3",
|
||||||
|
"name": "critical",
|
||||||
|
"color": "#FF0000",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, payload["name"], "critical")
|
||||||
|
assertEqual(t, payload["color"], "#FF0000")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLabelUpdateRequiresAtLeastOneField(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made when no fields are provided")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runLabelShortcut(t, server, "update", map[string]string{
|
||||||
|
"id": "3",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no update fields provided, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLabelDelete(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issue_tags/3.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"status": 0,
|
||||||
|
"message": "success",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runLabelShortcut(t, server, "delete", map[string]string{
|
||||||
|
"id": "3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLabelShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findLabelShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{
|
||||||
|
HTTP: server.Client(),
|
||||||
|
BaseURL: server.URL,
|
||||||
|
},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findLabelShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, shortcut := range Shortcuts() {
|
||||||
|
if shortcut.Name == name {
|
||||||
|
return shortcut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||||
|
t.Fatalf("failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
|
||||||
|
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type batchAddResult struct {
|
||||||
|
User string `json:"user" yaml:"user"`
|
||||||
|
Action string `json:"action" yaml:"action"`
|
||||||
|
Status string `json:"status" yaml:"status"`
|
||||||
|
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchAddSummary struct {
|
||||||
|
Owner string `json:"owner" yaml:"owner"`
|
||||||
|
Repo string `json:"repo" yaml:"repo"`
|
||||||
|
DryRun bool `json:"dry_run" yaml:"dry_run"`
|
||||||
|
Total int `json:"total" yaml:"total"`
|
||||||
|
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
||||||
|
Failed int `json:"failed" yaml:"failed"`
|
||||||
|
Duration string `json:"duration" yaml:"duration"`
|
||||||
|
Results []batchAddResult `json:"results" yaml:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchAddShortcut() *common.Shortcut {
|
||||||
|
return &common.Shortcut{
|
||||||
|
Name: "batch-add",
|
||||||
|
Description: "批量添加成员到项目,支持逗号分隔列表或 CSV 文件",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "users", Short: "u", Usage: "逗号分隔的用户数字 ID,例如: 42,99,105"},
|
||||||
|
{Name: "from", Usage: "从 CSV 文件读取用户 ID。支持 user_id/id/user 列名或无表头首列"},
|
||||||
|
{Name: "dry-run", Usage: "仅预览将要添加的成员,不实际执行", Bool: true, Default: "false"},
|
||||||
|
},
|
||||||
|
Run: runBatchAdd,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBatchAdd(ctx *common.RuntimeContext) error {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDs, err := collectUserIDs(ctx.Arg("users"), ctx.Arg("from"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return fmt.Errorf("未提供用户 ID,请使用 --users 42,99 或 --from users.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
dryRun := parseMemberBool(ctx.Arg("dry-run"))
|
||||||
|
|
||||||
|
summary := batchAddSummary{
|
||||||
|
Owner: ctx.Owner,
|
||||||
|
Repo: ctx.Repo,
|
||||||
|
DryRun: dryRun,
|
||||||
|
Total: len(userIDs),
|
||||||
|
Results: make([]batchAddResult, 0, len(userIDs)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
result := batchAddResult{User: uid, Action: "add"}
|
||||||
|
if dryRun {
|
||||||
|
result.Status = "planned"
|
||||||
|
summary.Succeeded++
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ := strconv.ParseInt(uid, 10, 64)
|
||||||
|
body := map[string]interface{}{"user_id": id}
|
||||||
|
if _, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body); err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Error = err.Error()
|
||||||
|
summary.Failed++
|
||||||
|
} else {
|
||||||
|
result.Status = "added"
|
||||||
|
summary.Succeeded++
|
||||||
|
}
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Duration = time.Since(start).String()
|
||||||
|
|
||||||
|
if err := ctx.OutputData(summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if summary.Failed > 0 {
|
||||||
|
return fmt.Errorf("%d / %d 个成员添加失败", summary.Failed, summary.Total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectUserIDs(usersValue, csvPath string) ([]string, error) {
|
||||||
|
ids, err := parseUserIDList(usersValue)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if csvPath == "" {
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
csvIDs, err := readUserIDsFromCSV(csvPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mergeUserIDLists(ids, csvIDs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUserIDList(value string) ([]string, error) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return normalizeUserIDs(strings.Split(value, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUserIDsFromCSV(path string) ([]string, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
reader := csv.NewReader(file)
|
||||||
|
reader.TrimLeadingSpace = true
|
||||||
|
records, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
idCol := -1
|
||||||
|
startRow := 0
|
||||||
|
for i, cell := range records[0] {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(cell)) {
|
||||||
|
case "user_id", "id", "user", "uid":
|
||||||
|
idCol = i
|
||||||
|
startRow = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idCol == -1 {
|
||||||
|
idCol = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]string, 0, len(records)-startRow)
|
||||||
|
for _, record := range records[startRow:] {
|
||||||
|
if idCol >= len(record) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values = append(values, record[idCol])
|
||||||
|
}
|
||||||
|
return normalizeUserIDs(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUserIDs(values []string) ([]string, error) {
|
||||||
|
ids := make([]string, 0, len(values))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, value := range values {
|
||||||
|
id := strings.TrimSpace(value)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
|
||||||
|
return nil, fmt.Errorf("无效的用户 ID %q: 必须是整数", id)
|
||||||
|
}
|
||||||
|
if seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeUserIDLists(values ...[]string) []string {
|
||||||
|
merged := []string{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, ids := range values {
|
||||||
|
for _, id := range ids {
|
||||||
|
if seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
merged = append(merged, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMemberBool(value string) bool {
|
||||||
|
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||||
|
return err == nil && parsed
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
batchAddShortcut(),
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Description: "List project members",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
if k := ctx.Arg("keyword"); k != "" {
|
||||||
|
q.Set("keyword", k)
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET",
|
||||||
|
fmt.Sprintf("/v1/%s/%s/collaborators", ctx.Owner, ctx.Repo), q)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "add",
|
||||||
|
Description: "Add a member to the project",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "user-id", Short: "u", Usage: "Numeric user ID", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userIDStr, _ := ctx.RequireArg("user-id")
|
||||||
|
userID, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid user-id %q: must be an integer", userIDStr)
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{"user_id": userID}
|
||||||
|
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "remove",
|
||||||
|
Description: "Remove a member from the project",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "user-id", Short: "u", Usage: "Numeric user ID", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userIDStr, _ := ctx.RequireArg("user-id")
|
||||||
|
userID, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid user-id %q: must be an integer", userIDStr)
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{"user_id": userID}
|
||||||
|
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "update",
|
||||||
|
Description: "Change a member's role",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "user-id", Short: "u", Usage: "Numeric user ID", Required: true},
|
||||||
|
{Name: "role", Short: "r", Usage: "Role: Manager, Developer, Reporter", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userIDStr, _ := ctx.RequireArg("user-id")
|
||||||
|
role, _ := ctx.RequireArg("role")
|
||||||
|
userID, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid user-id %q: must be an integer", userIDStr)
|
||||||
|
}
|
||||||
|
switch role {
|
||||||
|
case "Manager", "Developer", "Reporter":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid role %q: must be Manager, Developer, or Reporter", role)
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"role": role,
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("PUT", ctx.RepoPath()+"/collaborators/change_role", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMemberList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/collaborators.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"total_count": 2,
|
||||||
|
"collaborators": []map[string]interface{}{
|
||||||
|
{"id": 1, "login": "alice", "role_name": "Manager"},
|
||||||
|
{"id": 2, "login": "bob", "role_name": "Developer"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runMemberShortcut(t, server, "list", map[string]string{}); err != nil {
|
||||||
|
t.Fatalf("list failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemberAdd(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/owner/repo/collaborators.json" {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["user_id"].(float64) != 42 {
|
||||||
|
t.Fatalf("expected user_id=42, got %v", payload["user_id"])
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "42"}); err != nil {
|
||||||
|
t.Fatalf("add failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemberAddRejectsInvalidID(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made with invalid ID")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "abc"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid user-id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemberRemove(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "DELETE" && r.URL.Path == "/owner/repo/collaborators/remove.json" {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["user_id"].(float64) != 42 {
|
||||||
|
t.Fatalf("expected user_id=42")
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runMemberShortcut(t, server, "remove", map[string]string{"user-id": "42"}); err != nil {
|
||||||
|
t.Fatalf("remove failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemberUpdate(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "PUT" && r.URL.Path == "/owner/repo/collaborators/change_role.json" {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["user_id"].(float64) != 42 {
|
||||||
|
t.Fatalf("expected user_id=42")
|
||||||
|
}
|
||||||
|
if payload["role"] != "Developer" {
|
||||||
|
t.Fatalf("expected role=Developer, got %v", payload["role"])
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runMemberShortcut(t, server, "update", map[string]string{
|
||||||
|
"user-id": "42", "role": "Developer",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemberUpdateRejectsInvalidRole(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made with invalid role")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runMemberShortcut(t, server, "update", map[string]string{
|
||||||
|
"user-id": "42", "role": "Admin",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === helpers ===
|
||||||
|
|
||||||
|
func runMemberShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findMemberShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
Owner: "owner", Repo: "repo", Format: "json", Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findMemberShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range Shortcuts() {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
package milestone
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func v1RepoPath(ctx *common.RuntimeContext) string {
|
||||||
|
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Description: "List milestones",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "state", Short: "s", Usage: "Filter by state: opening, closed, all", Default: "opening"},
|
||||||
|
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||||||
|
{Name: "sort", Usage: "Sort by: created_on, updated_on, effective_date, issues_count, percent", Default: "created_on"},
|
||||||
|
{Name: "direction", Short: "d", Usage: "Sort direction: asc, desc", Default: "desc"},
|
||||||
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("page", ctx.Arg("page"))
|
||||||
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
|
if s := ctx.Arg("state"); s != "" && s != "all" {
|
||||||
|
q.Set("category", s)
|
||||||
|
}
|
||||||
|
if k := ctx.Arg("keyword"); k != "" {
|
||||||
|
q.Set("keyword", k)
|
||||||
|
}
|
||||||
|
if s := ctx.Arg("sort"); s != "" {
|
||||||
|
q.Set("sort_by", s)
|
||||||
|
}
|
||||||
|
if d := ctx.Arg("direction"); d != "" {
|
||||||
|
q.Set("sort_direction", d)
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/milestones", q)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取里程碑列表失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "view",
|
||||||
|
Description: "View milestone details",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查看里程碑详情失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "create",
|
||||||
|
Description: "Create a milestone",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Milestone name", Required: true},
|
||||||
|
{Name: "description", Short: "d", Usage: "Milestone description"},
|
||||||
|
{Name: "due", Usage: "Due date (YYYY-MM-DD)"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
if desc := ctx.Arg("description"); desc != "" {
|
||||||
|
body["description"] = desc
|
||||||
|
}
|
||||||
|
if due := ctx.Arg("due"); due != "" {
|
||||||
|
body["effective_date"] = due
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/milestones", body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建里程碑失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "update",
|
||||||
|
Description: "Update a milestone",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||||
|
{Name: "name", Short: "n", Usage: "New name"},
|
||||||
|
{Name: "description", Short: "d", Usage: "New description"},
|
||||||
|
{Name: "due", Usage: "New due date (YYYY-MM-DD)"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := ctx.Arg("name")
|
||||||
|
desc := ctx.Arg("description")
|
||||||
|
due := ctx.Arg("due")
|
||||||
|
if name == "" && desc == "" && due == "" {
|
||||||
|
return fmt.Errorf("至少需要指定 --name、--description 或 --due 中的一个")
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
if name != "" {
|
||||||
|
body["name"] = name
|
||||||
|
}
|
||||||
|
if desc != "" {
|
||||||
|
body["description"] = desc
|
||||||
|
}
|
||||||
|
if due != "" {
|
||||||
|
body["effective_date"] = due
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("更新里程碑失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "close",
|
||||||
|
Description: "Close a milestone",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"status": "closed",
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/milestones/%s/update_status", ctx.Owner, ctx.Repo, id), body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("关闭里程碑失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete",
|
||||||
|
Description: "Delete a milestone",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("删除里程碑失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
package milestone
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMilestoneList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/milestones.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("category") != "opening" {
|
||||||
|
t.Fatalf("expected category=opening, got %s", r.URL.Query().Get("category"))
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"total_count": 1,
|
||||||
|
"milestones": []map[string]interface{}{
|
||||||
|
{"id": 1, "name": "v1.0", "status": "open"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runMilestoneShortcut(t, server, "list", map[string]string{
|
||||||
|
"state": "opening",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMilestoneCreate(t *testing.T) {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/milestones.json" {
|
||||||
|
payload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"status": 0,
|
||||||
|
"message": "success",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runMilestoneShortcut(t, server, "create", map[string]string{
|
||||||
|
"name": "v2.0",
|
||||||
|
"description": "Next release",
|
||||||
|
"due": "2026-06-01",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, payload["name"], "v2.0")
|
||||||
|
assertEqual(t, payload["description"], "Next release")
|
||||||
|
assertEqual(t, payload["effective_date"], "2026-06-01")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMilestoneUpdate(t *testing.T) {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/milestones/5.json" {
|
||||||
|
payload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"status": 0,
|
||||||
|
"message": "success",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runMilestoneShortcut(t, server, "update", map[string]string{
|
||||||
|
"id": "5",
|
||||||
|
"name": "v3.0",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, payload["name"], "v3.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMilestoneClose(t *testing.T) {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/owner/repo/milestones/5/update_status.json" {
|
||||||
|
payload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"status": 0,
|
||||||
|
"message": "操作成功",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runMilestoneShortcut(t, server, "close", map[string]string{
|
||||||
|
"id": "5",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, payload["status"], "closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMilestoneDelete(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/milestones/5.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"status": 0,
|
||||||
|
"message": "success",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runMilestoneShortcut(t, server, "delete", map[string]string{
|
||||||
|
"id": "5",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMilestoneView(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/milestones/1.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1), "name": "v1.0", "status": "open",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runMilestoneShortcut(t, server, "view", map[string]string{"id": "1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("milestone view failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMilestoneUpdateRequiresAtLeastOneField(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made when no fields are provided")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runMilestoneShortcut(t, server, "update", map[string]string{
|
||||||
|
"id": "5",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no update fields provided, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMilestoneShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findMilestoneShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{
|
||||||
|
HTTP: server.Client(),
|
||||||
|
BaseURL: server.URL,
|
||||||
|
},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findMilestoneShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, shortcut := range Shortcuts() {
|
||||||
|
if shortcut.Name == name {
|
||||||
|
return shortcut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||||
|
t.Fatalf("failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
|
||||||
|
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type batchInviteResult struct {
|
||||||
|
User string `json:"user" yaml:"user"`
|
||||||
|
Action string `json:"action" yaml:"action"`
|
||||||
|
Status string `json:"status" yaml:"status"`
|
||||||
|
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchInviteSummary struct {
|
||||||
|
Org string `json:"org" yaml:"org"`
|
||||||
|
DryRun bool `json:"dry_run" yaml:"dry_run"`
|
||||||
|
Total int `json:"total" yaml:"total"`
|
||||||
|
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
||||||
|
Failed int `json:"failed" yaml:"failed"`
|
||||||
|
Duration string `json:"duration" yaml:"duration"`
|
||||||
|
Results []batchInviteResult `json:"results" yaml:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchInviteShortcut() *common.Shortcut {
|
||||||
|
return &common.Shortcut{
|
||||||
|
Name: "batch-invite",
|
||||||
|
Description: "批量邀请成员加入组织,支持逗号分隔列表或 CSV 文件",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "组织 ID 或 login", Required: true},
|
||||||
|
{Name: "users", Short: "u", Usage: "逗号分隔的用户名或 ID,例如: alice,bob,charlie"},
|
||||||
|
{Name: "from", Usage: "从 CSV 文件读取用户名。支持 user/login/user_id 列名或无表头首列"},
|
||||||
|
{Name: "role", Short: "r", Usage: "成员角色: member 或 admin", Default: "member"},
|
||||||
|
{Name: "dry-run", Usage: "仅预览将要邀请的成员,不实际执行", Bool: true, Default: "false"},
|
||||||
|
},
|
||||||
|
Run: runBatchInvite,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBatchInvite(ctx *common.RuntimeContext) error {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
orgID, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
role := ctx.Arg("role")
|
||||||
|
if role == "" {
|
||||||
|
role = "member"
|
||||||
|
}
|
||||||
|
if role != "member" && role != "admin" {
|
||||||
|
return fmt.Errorf("无效的角色 %q: 必须为 member 或 admin", role)
|
||||||
|
}
|
||||||
|
|
||||||
|
userInputs, err := collectUsers(ctx.Arg("users"), ctx.Arg("from"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(userInputs) == 0 {
|
||||||
|
return fmt.Errorf("未提供用户名,请使用 --users alice,bob 或 --from users.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将用户名解析为数字 ID(如果传入的已经是数字则直接使用)
|
||||||
|
resolvedUsers, resolveErrors := resolveUserIDs(ctx, userInputs)
|
||||||
|
|
||||||
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||||
|
|
||||||
|
summary := batchInviteSummary{
|
||||||
|
Org: orgID,
|
||||||
|
DryRun: dryRun,
|
||||||
|
Total: len(userInputs),
|
||||||
|
Results: make([]batchInviteResult, 0, len(userInputs)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先记录解析失败的
|
||||||
|
for input, errMsg := range resolveErrors {
|
||||||
|
summary.Results = append(summary.Results, batchInviteResult{
|
||||||
|
User: input,
|
||||||
|
Action: "invite",
|
||||||
|
Status: "failed",
|
||||||
|
Error: errMsg,
|
||||||
|
})
|
||||||
|
summary.Failed++
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ru := range resolvedUsers {
|
||||||
|
displayName := ru.Input
|
||||||
|
if ru.Input != ru.UserID {
|
||||||
|
displayName = fmt.Sprintf("%s (ID:%s)", ru.Input, ru.UserID)
|
||||||
|
}
|
||||||
|
result := batchInviteResult{User: displayName, Action: "invite"}
|
||||||
|
if dryRun {
|
||||||
|
result.Status = "planned"
|
||||||
|
summary.Succeeded++
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDInt, _ := strconv.ParseInt(ru.UserID, 10, 64)
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"user_id": userIDInt,
|
||||||
|
"role": role,
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/organizations/%s/organization_users", orgID)
|
||||||
|
if _, err := ctx.CallAPI("POST", path, body); err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Error = err.Error()
|
||||||
|
summary.Failed++
|
||||||
|
} else {
|
||||||
|
result.Status = "invited"
|
||||||
|
summary.Succeeded++
|
||||||
|
}
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Duration = time.Since(start).String()
|
||||||
|
|
||||||
|
if err := ctx.OutputData(summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if summary.Failed > 0 {
|
||||||
|
return fmt.Errorf("%d / %d 个成员邀请失败", summary.Failed, summary.Total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectUsers(usersValue, csvPath string) ([]string, error) {
|
||||||
|
users, err := parseUserList(usersValue)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if csvPath == "" {
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
csvUsers, err := readUsersFromCSV(csvPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mergeUserLists(users, csvUsers), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUserList(value string) ([]string, error) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return normalizeUserIDs(strings.Split(value, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUsersFromCSV(path string) ([]string, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
reader := csv.NewReader(file)
|
||||||
|
reader.TrimLeadingSpace = true
|
||||||
|
records, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userCol := -1
|
||||||
|
startRow := 0
|
||||||
|
for i, cell := range records[0] {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(cell)) {
|
||||||
|
case "user", "login", "user_id", "username":
|
||||||
|
userCol = i
|
||||||
|
startRow = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if userCol == -1 {
|
||||||
|
userCol = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]string, 0, len(records)-startRow)
|
||||||
|
for _, record := range records[startRow:] {
|
||||||
|
if userCol >= len(record) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values = append(values, record[userCol])
|
||||||
|
}
|
||||||
|
return normalizeUserIDs(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUserIDs(values []string) ([]string, error) {
|
||||||
|
users := make([]string, 0, len(values))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, value := range values {
|
||||||
|
user := strings.TrimSpace(value)
|
||||||
|
if user == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[user] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[user] = true
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBool(value string) bool {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(value), "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeUserLists(values ...[]string) []string {
|
||||||
|
merged := []string{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, users := range values {
|
||||||
|
for _, u := range users {
|
||||||
|
if seen[u] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[u] = true
|
||||||
|
merged = append(merged, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvedUser holds the mapping from user input to numeric ID.
|
||||||
|
type resolvedUser struct {
|
||||||
|
Input string // original input (username or numeric string)
|
||||||
|
UserID string // resolved numeric user ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveUserIDs converts usernames to numeric user IDs via the search API.
|
||||||
|
// If an input is already numeric, it is used directly.
|
||||||
|
func resolveUserIDs(ctx *common.RuntimeContext, inputs []string) ([]resolvedUser, map[string]string) {
|
||||||
|
results := make([]resolvedUser, 0, len(inputs))
|
||||||
|
errors := make(map[string]string)
|
||||||
|
|
||||||
|
for _, input := range inputs {
|
||||||
|
// 如果已经是纯数字,直接使用
|
||||||
|
if _, err := strconv.Atoi(input); err == nil {
|
||||||
|
results = append(results, resolvedUser{Input: input, UserID: input})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过搜索 API 查找用户名对应的数字 ID
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("search", input)
|
||||||
|
q.Set("limit", "5")
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
|
||||||
|
if err != nil {
|
||||||
|
errors[input] = fmt.Sprintf("查找用户失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// env.Data 是 {"total_count":N, "users":[...]} 的 map 结构
|
||||||
|
users := extractUsers(env.Data)
|
||||||
|
if len(users) == 0 {
|
||||||
|
errors[input] = fmt.Sprintf("未找到用户 %q", input)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 精确匹配用户名
|
||||||
|
matched := users[0]
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Login == input {
|
||||||
|
matched = u
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, resolvedUser{
|
||||||
|
Input: input,
|
||||||
|
UserID: strconv.Itoa(matched.UserID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchUser holds a parsed user from search results.
|
||||||
|
type searchUser struct {
|
||||||
|
Login string
|
||||||
|
UserID int
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractUsers extracts the user list from the search API response data.
|
||||||
|
// data is expected to be map[string]interface{} with a "users" key containing a slice.
|
||||||
|
func extractUsers(data interface{}) []searchUser {
|
||||||
|
if data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, ok := data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rawUsers, ok := m["users"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
usersSlice, ok := rawUsers.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var result []searchUser
|
||||||
|
for _, item := range usersSlice {
|
||||||
|
um, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
login, _ := um["login"].(string)
|
||||||
|
userID := 0
|
||||||
|
switch v := um["user_id"].(type) {
|
||||||
|
case float64:
|
||||||
|
userID = int(v)
|
||||||
|
case int:
|
||||||
|
userID = v
|
||||||
|
case string:
|
||||||
|
userID, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
if login != "" && userID > 0 {
|
||||||
|
result = append(result, searchUser{Login: login, UserID: userID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
|
|
||||||
func Shortcuts() []*common.Shortcut {
|
func Shortcuts() []*common.Shortcut {
|
||||||
return []*common.Shortcut{
|
return []*common.Shortcut{
|
||||||
|
newBatchInviteShortcut(),
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List organizations",
|
Description: "List organizations",
|
||||||
|
|
@ -22,7 +23,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
q.Set("limit", ctx.Arg("limit"))
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
env, err := ctx.CallAPIWithQuery("GET", "/organizations", q)
|
env, err := ctx.CallAPIWithQuery("GET", "/organizations", q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("获取组织列表失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -34,11 +35,14 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
|
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
id, _ := ctx.RequireArg("id")
|
id, err := ctx.RequireArg("id")
|
||||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查看组织详情失败: %w", err)
|
||||||
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -51,13 +55,16 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
id, _ := ctx.RequireArg("id")
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("page", ctx.Arg("page"))
|
q.Set("page", ctx.Arg("page"))
|
||||||
q.Set("limit", ctx.Arg("limit"))
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q)
|
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("获取组织成员列表失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -70,7 +77,10 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{Name: "description", Short: "d", Usage: "Description"},
|
{Name: "description", Short: "d", Usage: "Description"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
name, _ := ctx.RequireArg("name")
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"name": name,
|
"name": name,
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +89,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("POST", "/organizations", payload)
|
env, err := ctx.CallAPI("POST", "/organizations", payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("创建组织失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOrgList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/organizations.json" {
|
||||||
|
writeJSON(t, w, []map[string]interface{}{
|
||||||
|
{"id": float64(1), "name": "org1"},
|
||||||
|
{"id": float64(2), "name": "org2"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runOrgShortcut(t, server, "list", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("org list failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrgInfo(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/organizations/1.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1), "name": "myorg", "description": "A test org",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runOrgShortcut(t, server, "info", map[string]string{"id": "1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("org info failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrgMembers(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/organizations/1/organization_users.json" {
|
||||||
|
writeJSON(t, w, []map[string]interface{}{
|
||||||
|
{"id": float64(1), "login": "user1", "role": "admin"},
|
||||||
|
{"id": float64(2), "login": "user2", "role": "member"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runOrgShortcut(t, server, "members", map[string]string{"id": "1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("org members failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrgCreate(t *testing.T) {
|
||||||
|
var createPayload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/organizations.json" {
|
||||||
|
createPayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(99), "name": "new-org",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runOrgShortcut(t, server, "create", map[string]string{
|
||||||
|
"name": "new-org",
|
||||||
|
"description": "My new organization",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("org create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if createPayload["name"] != "new-org" {
|
||||||
|
t.Fatalf("expected name=new-org, got %v", createPayload["name"])
|
||||||
|
}
|
||||||
|
if createPayload["description"] != "My new organization" {
|
||||||
|
t.Fatalf("expected description='My new organization', got %v", createPayload["description"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOrgShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findOrgShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findOrgShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range Shortcuts() {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||||
|
t.Fatalf("failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("page", ctx.Arg("page"))
|
q.Set("page", ctx.Arg("page"))
|
||||||
|
|
@ -30,7 +30,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
|
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("获取 PR 列表失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -46,10 +46,16 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
title, err := ctx.RequireArg("title")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
head, err := ctx.RequireArg("head")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
title, _ := ctx.RequireArg("title")
|
|
||||||
head, _ := ctx.RequireArg("head")
|
|
||||||
base := ctx.Arg("base")
|
base := ctx.Arg("base")
|
||||||
if base == "" {
|
if base == "" {
|
||||||
base = "master"
|
base = "master"
|
||||||
|
|
@ -64,7 +70,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
|
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("创建 PR 失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -77,13 +83,16 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
id, _ := ctx.RequireArg("id")
|
id, err := ctx.RequireArg("id")
|
||||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查看 PR 详情失败: %w", err)
|
||||||
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -96,9 +105,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
id, _ := ctx.RequireArg("id")
|
|
||||||
method := ctx.Arg("method")
|
method := ctx.Arg("method")
|
||||||
if method == "" {
|
if method == "" {
|
||||||
method = "merge"
|
method = "merge"
|
||||||
|
|
@ -108,7 +120,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
|
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("合并 PR 失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -121,13 +133,16 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
id, _ := ctx.RequireArg("id")
|
id, err := ctx.RequireArg("id")
|
||||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("关闭 PR 失败: %w", err)
|
||||||
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -139,13 +154,16 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
id, _ := ctx.RequireArg("id")
|
id, err := ctx.RequireArg("id")
|
||||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取 PR 变更文件失败: %w", err)
|
||||||
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -157,13 +175,53 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
id, _ := ctx.RequireArg("id")
|
id, err := ctx.RequireArg("id")
|
||||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取 PR Diff 失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "review",
|
||||||
|
Description: "Submit a review on a pull request",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||||
|
{Name: "body", Short: "b", Usage: "Review comment body", Required: true},
|
||||||
|
{Name: "action", Short: "a", Usage: "Review action: approve, comment, request-changes", Default: "comment"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := ctx.RequireArg("body")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
action := ctx.Arg("action")
|
||||||
|
if action == "" {
|
||||||
|
action = "comment"
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewAction := mapAction(action)
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"body": body,
|
||||||
|
"action": reviewAction,
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("提交 PR Review 失败: %w", err)
|
||||||
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -176,14 +234,20 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := ctx.RequireArg("body")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
id, _ := ctx.RequireArg("id")
|
|
||||||
body, _ := ctx.RequireArg("body")
|
|
||||||
|
|
||||||
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("fetch PR: %w", err)
|
return fmt.Errorf("获取 PR 详情: %w", err)
|
||||||
}
|
}
|
||||||
issueID, err := extractIssueID(prEnv)
|
issueID, err := extractIssueID(prEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -195,7 +259,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload)
|
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("添加 PR 评论失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -203,18 +267,29 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapAction(action string) string {
|
||||||
|
switch action {
|
||||||
|
case "approve":
|
||||||
|
return "approved"
|
||||||
|
case "request-changes":
|
||||||
|
return "changes_requested"
|
||||||
|
default:
|
||||||
|
return "commented"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func extractIssueID(env *output.Envelope) (int64, error) {
|
func extractIssueID(env *output.Envelope) (int64, error) {
|
||||||
data, ok := env.Data.(map[string]interface{})
|
data, ok := env.Data.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, fmt.Errorf("unexpected PR response format")
|
return 0, fmt.Errorf("PR 响应格式异常")
|
||||||
}
|
}
|
||||||
issue, ok := data["issue"].(map[string]interface{})
|
issue, ok := data["issue"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, fmt.Errorf("PR response missing issue field")
|
return 0, fmt.Errorf("PR 响应缺少 issue 字段")
|
||||||
}
|
}
|
||||||
idFloat, ok := issue["id"].(float64)
|
idFloat, ok := issue["id"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, fmt.Errorf("PR response missing issue.id field")
|
return 0, fmt.Errorf("PR 响应缺少 issue.id 字段")
|
||||||
}
|
}
|
||||||
return int64(idFloat), nil
|
return int64(idFloat), nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,3 +141,238 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
||||||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPRList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/owner/repo/pulls.json" {
|
||||||
|
writeJSON(t, w, []map[string]interface{}{
|
||||||
|
{"id": float64(1), "title": "fix bug", "state": "open"},
|
||||||
|
{"id": float64(2), "title": "add feature", "state": "merged"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runPRShortcut(t, server, "list", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pr list failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRCreate(t *testing.T) {
|
||||||
|
var createPayload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/owner/repo/pulls.json" {
|
||||||
|
createPayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1), "title": "new feature",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runPRShortcut(t, server, "create", map[string]string{
|
||||||
|
"title": "new feature",
|
||||||
|
"head": "feature-branch",
|
||||||
|
"base": "master",
|
||||||
|
"body": "This adds a new feature",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pr create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, createPayload["title"], "new feature")
|
||||||
|
assertEqual(t, createPayload["head"], "feature-branch")
|
||||||
|
assertEqual(t, createPayload["base"], "master")
|
||||||
|
assertEqual(t, createPayload["body"], "This adds a new feature")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRView(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/1.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1), "title": "fix bug", "state": "open",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runPRShortcut(t, server, "view", map[string]string{"id": "1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pr view failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRMerge(t *testing.T) {
|
||||||
|
var mergePayload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/1/pr_merge.json" {
|
||||||
|
mergePayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "merged"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runPRShortcut(t, server, "merge", map[string]string{
|
||||||
|
"id": "1",
|
||||||
|
"method": "squash",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pr merge failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, mergePayload["do"], "squash")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRClose(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/1/refuse_merge.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "closed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runPRShortcut(t, server, "close", map[string]string{"id": "1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pr close failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRFiles(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/1/files.json" {
|
||||||
|
writeJSON(t, w, []map[string]interface{}{
|
||||||
|
{"filename": "src/main.go", "status": "modified"},
|
||||||
|
{"filename": "src/test.go", "status": "added"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runPRShortcut(t, server, "files", map[string]string{"id": "1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pr files failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRDiff(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/1/files.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"files": []interface{}{
|
||||||
|
map[string]interface{}{"filename": "main.go", "patch": "@@ -1,3 +1,4 @@"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runPRShortcut(t, server, "diff", map[string]string{"id": "1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pr diff failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRReviewSubmitsApproveAction(t *testing.T) {
|
||||||
|
var reviewPath string
|
||||||
|
var reviewPayload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/13/reviews.json":
|
||||||
|
reviewPath = r.URL.Path
|
||||||
|
reviewPayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1),
|
||||||
|
"state": "approved",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runPRShortcut(t, server, "review", map[string]string{
|
||||||
|
"id": "13",
|
||||||
|
"body": "LGTM!",
|
||||||
|
"action": "approve",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("review shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reviewPath == "" {
|
||||||
|
t.Fatal("review endpoint was not called")
|
||||||
|
}
|
||||||
|
assertEqual(t, reviewPayload["body"], "LGTM!")
|
||||||
|
assertEqual(t, reviewPayload["action"], "approved")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRReviewDefaultsToCommentAction(t *testing.T) {
|
||||||
|
var reviewPayload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/13/reviews.json":
|
||||||
|
reviewPayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(2),
|
||||||
|
"state": "commented",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runPRShortcut(t, server, "review", map[string]string{
|
||||||
|
"id": "13",
|
||||||
|
"body": "Looks good",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("review shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, reviewPayload["action"], "commented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRReviewSubmitsRequestChangesAction(t *testing.T) {
|
||||||
|
var reviewPayload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/13/reviews.json":
|
||||||
|
reviewPayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(3),
|
||||||
|
"state": "changes_requested",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runPRShortcut(t, server, "review", map[string]string{
|
||||||
|
"id": "13",
|
||||||
|
"body": "Please fix the null check",
|
||||||
|
"action": "request-changes",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("review shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, reviewPayload["action"], "changes_requested")
|
||||||
|
assertEqual(t, reviewPayload["body"], "Please fix the null check")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,26 @@ import (
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/commit"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/file"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/star"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/watch"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/cli"
|
||||||
|
_ "github.com/gitlink-org/gitlink-cli/shortcuts/workflow/defs"
|
||||||
|
_ "github.com/gitlink-org/gitlink-cli/shortcuts/workflow/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterAll mounts all shortcut groups onto the root command.
|
// RegisterAll mounts all shortcut groups onto the root command.
|
||||||
|
|
@ -27,6 +39,16 @@ func RegisterAll(root *cobra.Command) {
|
||||||
"user": user.Shortcuts(),
|
"user": user.Shortcuts(),
|
||||||
"search": search.Shortcuts(),
|
"search": search.Shortcuts(),
|
||||||
"ci": ci.Shortcuts(),
|
"ci": ci.Shortcuts(),
|
||||||
|
"commit": commit.Shortcuts(),
|
||||||
|
"milestone": milestone.Shortcuts(),
|
||||||
|
"webhook": webhook.Shortcuts(),
|
||||||
|
"label": label.Shortcuts(),
|
||||||
|
"file": file.Shortcuts(),
|
||||||
|
"member": member.Shortcuts(),
|
||||||
|
"watch": watch.Shortcuts(),
|
||||||
|
"star": star.Shortcuts(),
|
||||||
|
"wiki": wiki.Shortcuts(),
|
||||||
|
"workflow": cli.Shortcuts(),
|
||||||
}
|
}
|
||||||
|
|
||||||
descriptions := map[string]string{
|
descriptions := map[string]string{
|
||||||
|
|
@ -39,6 +61,16 @@ func RegisterAll(root *cobra.Command) {
|
||||||
"user": "User operations",
|
"user": "User operations",
|
||||||
"search": "Search operations",
|
"search": "Search operations",
|
||||||
"ci": "CI/CD operations",
|
"ci": "CI/CD operations",
|
||||||
|
"commit": "Commit operations",
|
||||||
|
"milestone": "Milestone operations",
|
||||||
|
"webhook": "Webhook operations",
|
||||||
|
"label": "Issue label operations",
|
||||||
|
"file": "Repository file operations",
|
||||||
|
"member": "Project member management",
|
||||||
|
"watch": "Watch repository operations",
|
||||||
|
"star": "Star repository operations",
|
||||||
|
"wiki": "Wiki page operations",
|
||||||
|
"workflow": "Automated workflow operations",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, shortcuts := range groups {
|
for name, shortcuts := range groups {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ package release
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
|
@ -19,14 +22,14 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("page", ctx.Arg("page"))
|
q.Set("page", ctx.Arg("page"))
|
||||||
q.Set("limit", ctx.Arg("limit"))
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q)
|
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("获取 Release 列表失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -43,10 +46,16 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
tag, err := ctx.RequireArg("tag")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tag, _ := ctx.RequireArg("tag")
|
|
||||||
name, _ := ctx.RequireArg("name")
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"tag_name": tag,
|
"tag_name": tag,
|
||||||
"name": name,
|
"name": name,
|
||||||
|
|
@ -62,7 +71,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload)
|
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("创建 Release 失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -75,16 +84,122 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
id, _ := ctx.RequireArg("id")
|
id, err := ctx.RequireArg("id")
|
||||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查看 Release 详情失败: %w", err)
|
||||||
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "download",
|
||||||
|
Description: "Download release assets",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "Release ID or tag", Required: true},
|
||||||
|
{Name: "asset", Short: "a", Usage: "Asset file name (omit to list all assets)"},
|
||||||
|
{Name: "dir", Short: "d", Usage: "Download directory", Default: "."},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
assetName := ctx.Arg("asset")
|
||||||
|
dlDir := ctx.Arg("dir")
|
||||||
|
if dlDir == "" {
|
||||||
|
dlDir = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取 Release 信息失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseData, ok := releaseEnv.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("Release 响应格式异常")
|
||||||
|
}
|
||||||
|
|
||||||
|
assets, ok := releaseData["assets"].([]interface{})
|
||||||
|
if !ok || len(assets) == 0 {
|
||||||
|
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||||
|
"message": "该 Release 没有附件资源",
|
||||||
|
}, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
if assetName == "" {
|
||||||
|
var assetList []map[string]interface{}
|
||||||
|
for _, a := range assets {
|
||||||
|
asset, _ := a.(map[string]interface{})
|
||||||
|
assetList = append(assetList, map[string]interface{}{
|
||||||
|
"name": asset["name"],
|
||||||
|
"size": asset["size"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ctx.OutputData(map[string]interface{}{
|
||||||
|
"assets": assetList,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadURL string
|
||||||
|
for _, a := range assets {
|
||||||
|
asset, _ := a.(map[string]interface{})
|
||||||
|
name, _ := asset["name"].(string)
|
||||||
|
if name == assetName {
|
||||||
|
downloadURL, _ = asset["download_url"].(string)
|
||||||
|
if downloadURL == "" {
|
||||||
|
downloadURL, _ = asset["url"].(string)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if downloadURL == "" {
|
||||||
|
return fmt.Errorf("Release 中未找到附件 %q", assetName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dlDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("创建目录 %s 失败: %w", dlDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := ctx.Client.HTTP.Get(downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("下载失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath := filepath.Join(dlDir, assetName)
|
||||||
|
file, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建文件 %s 失败: %w", destPath, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
written, err := io.Copy(file, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("写入文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||||
|
"message": "下载完成",
|
||||||
|
"path": destPath,
|
||||||
|
"size": written,
|
||||||
|
}, nil))
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "delete",
|
Name: "delete",
|
||||||
Description: "Delete a release",
|
Description: "Delete a release",
|
||||||
|
|
@ -93,22 +208,21 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
id, _ := ctx.RequireArg("id")
|
|
||||||
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||||
if delErr != nil {
|
if delErr != nil {
|
||||||
// GitLink API bug: delete succeeds but returns error status.
|
|
||||||
// Verify by checking if the release still exists.
|
|
||||||
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||||
if viewErr != nil {
|
if viewErr != nil {
|
||||||
// Release no longer exists — delete actually succeeded
|
|
||||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||||
"message": "删除成功",
|
"message": "删除成功",
|
||||||
}, nil))
|
}, nil))
|
||||||
}
|
}
|
||||||
// Release still exists — delete truly failed
|
return fmt.Errorf("删除 Release 失败: %w", delErr)
|
||||||
return delErr
|
|
||||||
}
|
}
|
||||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||||
"message": "删除成功",
|
"message": "删除成功",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
package release
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReleaseDownloadListsAssetsWhenNoAssetFlag(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1),
|
||||||
|
"tag": "v1.0",
|
||||||
|
"assets": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"name": "app-linux-amd64",
|
||||||
|
"size": float64(1048576),
|
||||||
|
"download_url": "https://example.com/dl/app-linux-amd64",
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"name": "app-darwin-amd64",
|
||||||
|
"size": float64(2097152),
|
||||||
|
"download_url": "https://example.com/dl/app-darwin-amd64",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runReleaseShortcut(t, server, "download", map[string]string{
|
||||||
|
"id": "v1.0",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("download shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseDownloadNoAssetsReturnsMessage(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1),
|
||||||
|
"tag": "v1.0",
|
||||||
|
"assets": []interface{}{},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runReleaseShortcut(t, server, "download", map[string]string{
|
||||||
|
"id": "v1.0",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("download shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseDownloadFetchesAssetURL(t *testing.T) {
|
||||||
|
assetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Write([]byte("fake-binary-content"))
|
||||||
|
}))
|
||||||
|
defer assetServer.Close()
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1),
|
||||||
|
"tag": "v1.0",
|
||||||
|
"assets": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"name": "app-linux-amd64",
|
||||||
|
"size": float64(1048576),
|
||||||
|
"download_url": assetServer.URL + "/dl/app-linux-amd64",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
err := runReleaseShortcut(t, server, "download", map[string]string{
|
||||||
|
"id": "v1.0",
|
||||||
|
"asset": "app-linux-amd64",
|
||||||
|
"dir": tmpDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("download shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath := filepath.Join(tmpDir, "app-linux-amd64")
|
||||||
|
data, err := os.ReadFile(destPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read downloaded file: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "fake-binary-content" {
|
||||||
|
t.Fatalf("got %q, want %q", string(data), "fake-binary-content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseDownloadAssetNotFound(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1),
|
||||||
|
"tag": "v1.0",
|
||||||
|
"assets": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"name": "app-linux-amd64",
|
||||||
|
"size": float64(1048576),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runReleaseShortcut(t, server, "download", map[string]string{
|
||||||
|
"id": "v1.0",
|
||||||
|
"asset": "nonexistent.tar.gz",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-existent asset, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json" {
|
||||||
|
writeJSON(t, w, []map[string]interface{}{
|
||||||
|
{"id": float64(1), "tag_name": "v1.0", "name": "First release"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runReleaseShortcut(t, server, "list", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("release list failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseCreate(t *testing.T) {
|
||||||
|
var body map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/owner/repo/releases.json" {
|
||||||
|
body = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1), "tag_name": "v2.0",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runReleaseShortcut(t, server, "create", map[string]string{
|
||||||
|
"tag": "v2.0",
|
||||||
|
"name": "Version 2.0",
|
||||||
|
"body": "Release notes here",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("release create failed: %v", err)
|
||||||
|
}
|
||||||
|
assertEqual(t, body["tag_name"], "v2.0")
|
||||||
|
assertEqual(t, body["name"], "Version 2.0")
|
||||||
|
assertEqual(t, body["body"], "Release notes here")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseView(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1), "tag_name": "v1.0", "name": "First Release",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runReleaseShortcut(t, server, "view", map[string]string{"id": "v1.0"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("release view failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseDelete(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "DELETE" && r.URL.Path == "/owner/repo/releases/1.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("release delete failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runReleaseShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findReleaseShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{
|
||||||
|
HTTP: server.Client(),
|
||||||
|
BaseURL: server.URL,
|
||||||
|
},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findReleaseShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, shortcut := range Shortcuts() {
|
||||||
|
if shortcut.Name == name {
|
||||||
|
return shortcut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||||
|
t.Fatalf("failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
|
||||||
|
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type batchRepoResult struct {
|
||||||
|
Repo string `json:"repo" yaml:"repo"`
|
||||||
|
Action string `json:"action" yaml:"action"`
|
||||||
|
Status string `json:"status" yaml:"status"`
|
||||||
|
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchRepoSummary struct {
|
||||||
|
Total int `json:"total" yaml:"total"`
|
||||||
|
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
||||||
|
Failed int `json:"failed" yaml:"failed"`
|
||||||
|
Duration string `json:"duration" yaml:"duration"`
|
||||||
|
Results []batchRepoResult `json:"results" yaml:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchCreateShortcut() *common.Shortcut {
|
||||||
|
return &common.Shortcut{
|
||||||
|
Name: "batch-create",
|
||||||
|
Description: "批量创建仓库,支持逗号分隔列表或 CSV 文件",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库名称,例如: repo1,repo2,repo3"},
|
||||||
|
{Name: "from", Usage: "从 CSV 文件读取仓库名称。支持 name/repo/repository 列名或无表头首列"},
|
||||||
|
{Name: "description", Short: "d", Usage: "仓库描述(所有仓库共用一个描述)"},
|
||||||
|
{Name: "private", Usage: "设为私有仓库 (true/false)", Default: "false"},
|
||||||
|
{Name: "dry-run", Usage: "仅预览将要创建的仓库,不实际执行", Bool: true, Default: "false"},
|
||||||
|
},
|
||||||
|
Run: runBatchCreate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBatchCreate(ctx *common.RuntimeContext) error {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(repos) == 0 {
|
||||||
|
return fmt.Errorf("未提供仓库名称,请使用 --repos repo1,repo2 或 --from repos.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅取仓库名,不需要 owner/repo 格式
|
||||||
|
names := make([]string, len(repos))
|
||||||
|
for i, r := range repos {
|
||||||
|
parts := strings.SplitN(r, "/", 2)
|
||||||
|
names[i] = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||||
|
summary := batchRepoSummary{
|
||||||
|
Total: len(names),
|
||||||
|
Results: make([]batchRepoResult, 0, len(names)),
|
||||||
|
}
|
||||||
|
|
||||||
|
var userLogin string
|
||||||
|
var userID int
|
||||||
|
if !dryRun {
|
||||||
|
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取当前用户信息失败: %w", err)
|
||||||
|
}
|
||||||
|
userData, _ := userEnv.Data.(map[string]interface{})
|
||||||
|
login, _ := userData["login"].(string)
|
||||||
|
if login == "" {
|
||||||
|
return fmt.Errorf("无法获取当前用户名")
|
||||||
|
}
|
||||||
|
userLogin = login
|
||||||
|
uid, _ := userData["user_id"].(float64)
|
||||||
|
userID = int(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private := ctx.Arg("private") == "true"
|
||||||
|
desc := ctx.Arg("description")
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
result := batchRepoResult{Repo: name, Action: "create"}
|
||||||
|
if dryRun {
|
||||||
|
result.Status = "planned"
|
||||||
|
summary.Succeeded++
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"repository_name": name,
|
||||||
|
"user_id": userID,
|
||||||
|
}
|
||||||
|
if desc != "" {
|
||||||
|
body["description"] = desc
|
||||||
|
}
|
||||||
|
if private {
|
||||||
|
body["private"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
path := fmt.Sprintf("/%s/%s", userLogin, name)
|
||||||
|
if _, err := ctx.CallAPI("POST", path, body); err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Error = err.Error()
|
||||||
|
summary.Failed++
|
||||||
|
} else {
|
||||||
|
result.Status = "created"
|
||||||
|
summary.Succeeded++
|
||||||
|
}
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Duration = time.Since(start).String()
|
||||||
|
|
||||||
|
if err := ctx.OutputData(summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if summary.Failed > 0 {
|
||||||
|
return fmt.Errorf("%d / %d 个仓库创建失败", summary.Failed, summary.Total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchForkShortcut() *common.Shortcut {
|
||||||
|
return &common.Shortcut{
|
||||||
|
Name: "batch-fork",
|
||||||
|
Description: "批量 Fork 仓库,支持逗号分隔列表或 CSV 文件",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo,例如: alice/proj1,bob/proj2"},
|
||||||
|
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo 单列或 owner、repo 双列格式"},
|
||||||
|
{Name: "dry-run", Usage: "仅预览将要 Fork 的仓库,不实际执行", Bool: true, Default: "false"},
|
||||||
|
},
|
||||||
|
Run: runBatchFork,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBatchFork(ctx *common.RuntimeContext) error {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(repos) == 0 {
|
||||||
|
return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||||
|
summary := batchRepoSummary{
|
||||||
|
Total: len(repos),
|
||||||
|
Results: make([]batchRepoResult, 0, len(repos)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, repoID := range repos {
|
||||||
|
parts := strings.SplitN(repoID, "/", 2)
|
||||||
|
result := batchRepoResult{Repo: repoID, Action: "fork"}
|
||||||
|
if dryRun {
|
||||||
|
result.Status = "planned"
|
||||||
|
summary.Succeeded++
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := fmt.Sprintf("/%s/%s/forks", parts[0], parts[1])
|
||||||
|
if _, err := ctx.CallAPI("POST", path, nil); err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Error = err.Error()
|
||||||
|
summary.Failed++
|
||||||
|
} else {
|
||||||
|
result.Status = "forked"
|
||||||
|
summary.Succeeded++
|
||||||
|
}
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Duration = time.Since(start).String()
|
||||||
|
|
||||||
|
if err := ctx.OutputData(summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if summary.Failed > 0 {
|
||||||
|
return fmt.Errorf("%d / %d 个仓库 Fork 失败", summary.Failed, summary.Total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchDeleteShortcut() *common.Shortcut {
|
||||||
|
return &common.Shortcut{
|
||||||
|
Name: "batch-delete",
|
||||||
|
Description: "批量删除仓库,支持逗号分隔列表或 CSV 文件",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo,例如: alice/proj1,bob/proj2"},
|
||||||
|
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo 单列或 owner、repo 双列格式"},
|
||||||
|
{Name: "dry-run", Usage: "仅预览将要删除的仓库,不实际执行", Bool: true, Default: "false"},
|
||||||
|
},
|
||||||
|
Run: runBatchDelete,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBatchDelete(ctx *common.RuntimeContext) error {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(repos) == 0 {
|
||||||
|
return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||||
|
summary := batchRepoSummary{
|
||||||
|
Total: len(repos),
|
||||||
|
Results: make([]batchRepoResult, 0, len(repos)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, repoID := range repos {
|
||||||
|
parts := strings.SplitN(repoID, "/", 2)
|
||||||
|
result := batchRepoResult{Repo: repoID, Action: "delete"}
|
||||||
|
if dryRun {
|
||||||
|
result.Status = "planned"
|
||||||
|
summary.Succeeded++
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := fmt.Sprintf("/%s/%s", parts[0], parts[1])
|
||||||
|
if _, err := ctx.CallAPI("DELETE", path, nil); err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Error = err.Error()
|
||||||
|
summary.Failed++
|
||||||
|
} else {
|
||||||
|
result.Status = "deleted"
|
||||||
|
summary.Succeeded++
|
||||||
|
}
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Duration = time.Since(start).String()
|
||||||
|
|
||||||
|
if err := ctx.OutputData(summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if summary.Failed > 0 {
|
||||||
|
return fmt.Errorf("%d / %d 个仓库删除失败", summary.Failed, summary.Total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CSV / list helpers ---
|
||||||
|
|
||||||
|
func collectRepos(reposValue, csvPath string) ([]string, error) {
|
||||||
|
repos, err := parseRepoList(reposValue)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if csvPath == "" {
|
||||||
|
return repos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
csvRepos, err := readReposFromCSV(csvPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mergeRepoStrings(repos, csvRepos), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRepoList(value string) ([]string, error) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return normalizeRepoIDs(strings.Split(value, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readReposFromCSV(path string) ([]string, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
reader := csv.NewReader(file)
|
||||||
|
reader.TrimLeadingSpace = true
|
||||||
|
records, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
singleCol, ownerCol, repoCol := -1, -1, -1
|
||||||
|
startRow := 0
|
||||||
|
for i, cell := range records[0] {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(cell)) {
|
||||||
|
case "owner/repo", "full_name":
|
||||||
|
singleCol = i
|
||||||
|
startRow = 1
|
||||||
|
case "owner":
|
||||||
|
ownerCol = i
|
||||||
|
startRow = 1
|
||||||
|
case "repo", "repository", "name":
|
||||||
|
if repoCol == -1 {
|
||||||
|
repoCol = i
|
||||||
|
}
|
||||||
|
startRow = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ownerCol == -1 || repoCol == -1 {
|
||||||
|
// Not dual-column: use single-column mode
|
||||||
|
if singleCol == -1 {
|
||||||
|
singleCol = 0
|
||||||
|
}
|
||||||
|
ownerCol = -1
|
||||||
|
repoCol = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]string, 0, len(records)-startRow)
|
||||||
|
for _, record := range records[startRow:] {
|
||||||
|
var repoID string
|
||||||
|
if singleCol >= 0 && singleCol < len(record) {
|
||||||
|
repoID = record[singleCol]
|
||||||
|
} else if ownerCol >= 0 && repoCol >= 0 && ownerCol < len(record) && repoCol < len(record) {
|
||||||
|
repoID = record[ownerCol] + "/" + record[repoCol]
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values = append(values, repoID)
|
||||||
|
}
|
||||||
|
return normalizeRepoIDs(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRepoIDs(values []string) ([]string, error) {
|
||||||
|
repos := make([]string, 0, len(values))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, value := range values {
|
||||||
|
repoID := strings.TrimSpace(value)
|
||||||
|
if repoID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[repoID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[repoID] = true
|
||||||
|
repos = append(repos, repoID)
|
||||||
|
}
|
||||||
|
return repos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeRepoStrings(values ...[]string) []string {
|
||||||
|
merged := []string{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, repos := range values {
|
||||||
|
for _, r := range repos {
|
||||||
|
if seen[r] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[r] = true
|
||||||
|
merged = append(merged, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBool(value string) bool {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(value), "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,18 @@ package repo
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Shortcuts() []*common.Shortcut {
|
func Shortcuts() []*common.Shortcut {
|
||||||
return []*common.Shortcut{
|
return []*common.Shortcut{
|
||||||
|
newBatchCreateShortcut(),
|
||||||
|
newBatchForkShortcut(),
|
||||||
|
newBatchDeleteShortcut(),
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List repositories for a user or organization",
|
Description: "List repositories for a user or organization",
|
||||||
|
|
@ -33,21 +39,57 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("获取仓库列表失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "clone",
|
||||||
|
Description: "Clone a repository from GitLink",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "url", Short: "u", Usage: "Repository URL or owner/repo format", Required: true},
|
||||||
|
{Name: "dir", Short: "d", Usage: "Target directory (default: repo name)"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
rawURL, err := ctx.RequireArg("url")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
targetDir := ctx.Arg("dir")
|
||||||
|
|
||||||
|
cloneURL, repoName, err := parseCloneTarget(rawURL, ctx.Client.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetDir == "" {
|
||||||
|
targetDir = repoName
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("正在克隆 %s 到 %s...\n", cloneURL, targetDir)
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "clone", cloneURL, targetDir)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("克隆失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "info",
|
Name: "info",
|
||||||
Description: "Show repository details",
|
Description: "Show repository details",
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("获取仓库详情失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -65,15 +107,14 @@ func Shortcuts() []*common.Shortcut {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Get current user login for the create path
|
|
||||||
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
|
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get current user: %w", err)
|
return fmt.Errorf("获取当前用户信息失败: %w", err)
|
||||||
}
|
}
|
||||||
userData, _ := userEnv.Data.(map[string]interface{})
|
userData, _ := userEnv.Data.(map[string]interface{})
|
||||||
login, _ := userData["login"].(string)
|
login, _ := userData["login"].(string)
|
||||||
if login == "" {
|
if login == "" {
|
||||||
return fmt.Errorf("cannot determine current user login")
|
return fmt.Errorf("无法获取当前用户名")
|
||||||
}
|
}
|
||||||
userID, _ := userData["user_id"].(float64)
|
userID, _ := userData["user_id"].(float64)
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
|
|
@ -89,7 +130,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body)
|
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("创建仓库失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -99,11 +140,57 @@ func Shortcuts() []*common.Shortcut {
|
||||||
Description: "Fork a repository",
|
Description: "Fork a repository",
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", nil)
|
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("Fork 仓库失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "settings",
|
||||||
|
Description: "View or update repository settings",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "visibility", Usage: "Set visibility: public, private"},
|
||||||
|
{Name: "default-branch", Usage: "Set default branch"},
|
||||||
|
{Name: "description", Short: "d", Usage: "Update repository description"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
visibility := ctx.Arg("visibility")
|
||||||
|
defaultBranch := ctx.Arg("default-branch")
|
||||||
|
description := ctx.Arg("description")
|
||||||
|
|
||||||
|
if visibility == "" && defaultBranch == "" && description == "" {
|
||||||
|
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取仓库设置失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
if visibility != "" {
|
||||||
|
if visibility != "public" && visibility != "private" {
|
||||||
|
return fmt.Errorf("无效的 visibility 值 %q: 必须为 public 或 private", visibility)
|
||||||
|
}
|
||||||
|
body["visibility"] = visibility
|
||||||
|
}
|
||||||
|
if defaultBranch != "" {
|
||||||
|
body["default_branch"] = defaultBranch
|
||||||
|
}
|
||||||
|
if description != "" {
|
||||||
|
body["description"] = description
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("更新仓库设置失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -113,14 +200,41 @@ func Shortcuts() []*common.Shortcut {
|
||||||
Description: "Delete a repository",
|
Description: "Delete a repository",
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
|
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("删除仓库失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseCloneTarget 把用户输入的 --url 解析成可克隆的 cloneURL 和默认目录名 repoName。
|
||||||
|
// 支持两种输入:
|
||||||
|
// - 完整 URL:https://www.gitlink.org.cn/owner/repo[.git]
|
||||||
|
// - 简写格式:owner/repo(会基于 BaseURL 拼成完整 URL)
|
||||||
|
//
|
||||||
|
// 抽成纯函数便于跨平台单元测试(不依赖 git / PATH)。
|
||||||
|
func parseCloneTarget(rawURL, baseURL string) (cloneURL, repoName string, err error) {
|
||||||
|
if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") {
|
||||||
|
cloneURL = rawURL
|
||||||
|
if !strings.HasSuffix(cloneURL, ".git") {
|
||||||
|
cloneURL += ".git"
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimSuffix(rawURL, ".git"), "/")
|
||||||
|
repoName = parts[len(parts)-1]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(rawURL, "/", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
err = fmt.Errorf("无效的仓库格式 %q: 请使用 owner/repo 格式", rawURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webBase := strings.TrimSuffix(strings.TrimSuffix(baseURL, "/"), "/api")
|
||||||
|
cloneURL = fmt.Sprintf("%s/%s/%s.git", webBase, parts[0], parts[1])
|
||||||
|
repoName = parts[1]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,397 @@
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepoSettingsViewShowsCurrentSettings(t *testing.T) {
|
||||||
|
var capturedMethod string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
capturedMethod = r.Method
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"name": "repo",
|
||||||
|
"visibility": "public",
|
||||||
|
"default_branch": "master",
|
||||||
|
"description": "A test repo",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runRepoShortcut(t, server, "settings", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if capturedMethod != "GET" {
|
||||||
|
t.Fatalf("expected GET for viewing settings, got %s", capturedMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoSettingsUpdateVisibility(t *testing.T) {
|
||||||
|
var updateMethod string
|
||||||
|
var updatePayload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
|
||||||
|
updateMethod = r.Method
|
||||||
|
updatePayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"name": "repo",
|
||||||
|
"visibility": "private",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runRepoShortcut(t, server, "settings", map[string]string{
|
||||||
|
"visibility": "private",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if updateMethod != "PATCH" {
|
||||||
|
t.Fatalf("expected PATCH for updating settings, got %s", updateMethod)
|
||||||
|
}
|
||||||
|
assertEqual(t, updatePayload["visibility"], "private")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoSettingsUpdateDescription(t *testing.T) {
|
||||||
|
var updatePayload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
|
||||||
|
updatePayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"description": "Updated description",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runRepoShortcut(t, server, "settings", map[string]string{
|
||||||
|
"description": "Updated description",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, updatePayload["description"], "Updated description")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoSettingsUpdateDefaultBranch(t *testing.T) {
|
||||||
|
var updatePayload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
|
||||||
|
updatePayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"default_branch": "main",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runRepoShortcut(t, server, "settings", map[string]string{
|
||||||
|
"default-branch": "main",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, updatePayload["default_branch"], "main")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoSettingsRejectsInvalidVisibility(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatalf("should not make API call with invalid visibility")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runRepoShortcut(t, server, "settings", map[string]string{
|
||||||
|
"visibility": "invalid",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid visibility, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoSettingsUpdateMultipleFields(t *testing.T) {
|
||||||
|
var updatePayload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
|
||||||
|
updatePayload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"visibility": "private",
|
||||||
|
"default_branch": "main",
|
||||||
|
"description": "new desc",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runRepoShortcut(t, server, "settings", map[string]string{
|
||||||
|
"visibility": "private",
|
||||||
|
"default-branch": "main",
|
||||||
|
"description": "new desc",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, updatePayload["visibility"], "private")
|
||||||
|
assertEqual(t, updatePayload["default_branch"], "main")
|
||||||
|
assertEqual(t, updatePayload["description"], "new desc")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRepoShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findRepoShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{
|
||||||
|
HTTP: server.Client(),
|
||||||
|
BaseURL: server.URL,
|
||||||
|
},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRepoShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, shortcut := range Shortcuts() {
|
||||||
|
if shortcut.Name == name {
|
||||||
|
return shortcut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||||
|
t.Fatalf("failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
|
||||||
|
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clone 测试直接验证 parseCloneTarget 的解析逻辑(纯函数,不依赖 git/PATH,全平台可跑)。
|
||||||
|
const testCloneBaseURL = "https://www.gitlink.org.cn/api"
|
||||||
|
|
||||||
|
func TestRepoCloneParsesOwnerRepoFormat(t *testing.T) {
|
||||||
|
cloneURL, repoName, err := parseCloneTarget("myorg/myrepo", testCloneBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCloneTarget failed: %v", err)
|
||||||
|
}
|
||||||
|
if cloneURL != "https://www.gitlink.org.cn/myorg/myrepo.git" {
|
||||||
|
t.Errorf("cloneURL = %q, want .../myorg/myrepo.git", cloneURL)
|
||||||
|
}
|
||||||
|
if repoName != "myrepo" {
|
||||||
|
t.Errorf("repoName = %q, want myrepo", repoName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoCloneParsesFullURL(t *testing.T) {
|
||||||
|
cloneURL, repoName, err := parseCloneTarget("https://www.gitlink.org.cn/myorg/myrepo", testCloneBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCloneTarget failed: %v", err)
|
||||||
|
}
|
||||||
|
// 完整 URL 缺 .git 时应自动补上
|
||||||
|
if cloneURL != "https://www.gitlink.org.cn/myorg/myrepo.git" {
|
||||||
|
t.Errorf("cloneURL = %q, want .../myorg/myrepo.git", cloneURL)
|
||||||
|
}
|
||||||
|
if repoName != "myrepo" {
|
||||||
|
t.Errorf("repoName = %q, want myrepo", repoName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoCloneInvalidFormat(t *testing.T) {
|
||||||
|
_, _, err := parseCloneTarget("invalidformat", testCloneBaseURL)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid format, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "owner/repo") {
|
||||||
|
t.Fatalf("expected error mentioning owner/repo, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoCloneURLWithGitSuffix(t *testing.T) {
|
||||||
|
cloneURL, repoName, err := parseCloneTarget("https://www.gitlink.org.cn/myorg/myrepo.git", testCloneBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCloneTarget failed: %v", err)
|
||||||
|
}
|
||||||
|
// 已带 .git 时不应重复追加
|
||||||
|
if cloneURL != "https://www.gitlink.org.cn/myorg/myrepo.git" {
|
||||||
|
t.Errorf("cloneURL = %q, want unchanged .../myorg/myrepo.git", cloneURL)
|
||||||
|
}
|
||||||
|
if repoName != "myrepo" {
|
||||||
|
t.Errorf("repoName = %q, want myrepo", repoName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoListWithoutUser(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/projects.json" {
|
||||||
|
writeJSON(t, w, []map[string]interface{}{
|
||||||
|
{"id": float64(1), "name": "test-repo"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runRepoShortcut(t, server, "list", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repo list failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoListWithUser(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/users/testuser/projects.json" {
|
||||||
|
writeJSON(t, w, []map[string]interface{}{
|
||||||
|
{"id": float64(1), "name": "test-repo"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runRepoShortcut(t, server, "list", map[string]string{"user": "testuser"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repo list failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoInfo(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/owner/repo.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1), "name": "test-repo", "visibility": "public",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runRepoShortcut(t, server, "info", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repo info failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoCreate(t *testing.T) {
|
||||||
|
var createPath string
|
||||||
|
var createBody map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/users/me.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"login": "testuser",
|
||||||
|
"user_id": float64(42),
|
||||||
|
})
|
||||||
|
case r.Method == "POST" && r.URL.Path == "/testuser/myrepo.json":
|
||||||
|
createPath = r.URL.Path
|
||||||
|
createBody = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(99), "name": "myrepo",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runRepoShortcut(t, server, "create", map[string]string{
|
||||||
|
"name": "myrepo",
|
||||||
|
"description": "test desc",
|
||||||
|
"private": "true",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repo create failed: %v", err)
|
||||||
|
}
|
||||||
|
if createPath == "" {
|
||||||
|
t.Fatal("create endpoint was not called")
|
||||||
|
}
|
||||||
|
assertEqual(t, createBody["name"], "myrepo")
|
||||||
|
assertEqual(t, createBody["description"], "test desc")
|
||||||
|
assertEqual(t, createBody["private"], true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoFork(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/owner/repo/forks.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "forked"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runRepoShortcut(t, server, "fork", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repo fork failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoDelete(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "DELETE" && r.URL.Path == "/owner/repo.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runRepoShortcut(t, server, "delete", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repo delete failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package search
|
package search
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
|
@ -17,14 +18,17 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
keyword, _ := ctx.RequireArg("keyword")
|
keyword, err := ctx.RequireArg("keyword")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("search", keyword)
|
q.Set("search", keyword)
|
||||||
q.Set("page", ctx.Arg("page"))
|
q.Set("page", ctx.Arg("page"))
|
||||||
q.Set("limit", ctx.Arg("limit"))
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
env, err := ctx.CallAPIWithQuery("GET", "/projects", q)
|
env, err := ctx.CallAPIWithQuery("GET", "/projects", q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("搜索仓库失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -38,15 +42,50 @@ func Shortcuts() []*common.Shortcut {
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
keyword, _ := ctx.RequireArg("keyword")
|
keyword, err := ctx.RequireArg("keyword")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("search", keyword)
|
q.Set("search", keyword)
|
||||||
q.Set("page", ctx.Arg("page"))
|
q.Set("page", ctx.Arg("page"))
|
||||||
q.Set("limit", ctx.Arg("limit"))
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
|
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("搜索用户失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "issues",
|
||||||
|
Description: "Search issues across repositories",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
|
||||||
|
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "all"},
|
||||||
|
{Name: "owner", Short: "o", Usage: "Limit to owner's repositories"},
|
||||||
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
keyword, err := ctx.RequireArg("keyword")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("search", keyword)
|
||||||
|
q.Set("page", ctx.Arg("page"))
|
||||||
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
|
if s := ctx.Arg("state"); s != "" && s != "all" {
|
||||||
|
q.Set("state", s)
|
||||||
|
}
|
||||||
|
if o := ctx.Arg("owner"); o != "" {
|
||||||
|
q.Set("owner", o)
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", "/issues", q)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("搜索 Issue 失败: %w", err)
|
||||||
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearchIssuesSendsKeywordAndState(t *testing.T) {
|
||||||
|
var capturedPath string
|
||||||
|
var capturedQuery url.Values
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
capturedPath = r.URL.Path
|
||||||
|
capturedQuery = r.URL.Query()
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"data": []map[string]interface{}{
|
||||||
|
{"id": float64(1), "subject": "bug found"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runSearchShortcut(t, server, "issues", map[string]string{
|
||||||
|
"keyword": "bug",
|
||||||
|
"state": "open",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issues shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if capturedPath != "/issues.json" {
|
||||||
|
t.Fatalf("expected path /issues.json, got %s", capturedPath)
|
||||||
|
}
|
||||||
|
if capturedQuery.Get("search") != "bug" {
|
||||||
|
t.Fatalf("expected search=bug, got %s", capturedQuery.Get("search"))
|
||||||
|
}
|
||||||
|
if capturedQuery.Get("state") != "open" {
|
||||||
|
t.Fatalf("expected state=open, got %s", capturedQuery.Get("state"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchIssuesOmitsStateWhenAll(t *testing.T) {
|
||||||
|
var capturedQuery url.Values
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
capturedQuery = r.URL.Query()
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"data": []map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runSearchShortcut(t, server, "issues", map[string]string{
|
||||||
|
"keyword": "bug",
|
||||||
|
"state": "all",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issues shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if capturedQuery.Get("state") != "" {
|
||||||
|
t.Fatalf("expected no state filter, got %s", capturedQuery.Get("state"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchIssuesSendsOwnerWhenProvided(t *testing.T) {
|
||||||
|
var capturedQuery url.Values
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
capturedQuery = r.URL.Query()
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"data": []map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runSearchShortcut(t, server, "issues", map[string]string{
|
||||||
|
"keyword": "bug",
|
||||||
|
"owner": "myorg",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issues shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if capturedQuery.Get("owner") != "myorg" {
|
||||||
|
t.Fatalf("expected owner=myorg, got %s", capturedQuery.Get("owner"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchIssuesDefaultsPageAndLimit(t *testing.T) {
|
||||||
|
var capturedQuery url.Values
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
capturedQuery = r.URL.Query()
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"data": []map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runSearchShortcut(t, server, "issues", map[string]string{
|
||||||
|
"keyword": "bug",
|
||||||
|
"page": "1",
|
||||||
|
"limit": "20",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issues shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if capturedQuery.Get("page") != "1" {
|
||||||
|
t.Fatalf("expected page=1, got %s", capturedQuery.Get("page"))
|
||||||
|
}
|
||||||
|
if capturedQuery.Get("limit") != "20" {
|
||||||
|
t.Fatalf("expected limit=20, got %s", capturedQuery.Get("limit"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchRepos(t *testing.T) {
|
||||||
|
var capturedQuery url.Values
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
capturedQuery = r.URL.Query()
|
||||||
|
writeJSON(t, w, []map[string]interface{}{
|
||||||
|
{"id": float64(1), "name": "search-result"},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runSearchShortcut(t, server, "repos", map[string]string{
|
||||||
|
"keyword": "machine-learning",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search repos failed: %v", err)
|
||||||
|
}
|
||||||
|
if capturedQuery.Get("search") != "machine-learning" {
|
||||||
|
t.Fatalf("expected search=machine-learning, got %s", capturedQuery.Get("search"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchUsers(t *testing.T) {
|
||||||
|
var capturedPath string
|
||||||
|
var capturedQuery url.Values
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
capturedPath = r.URL.Path
|
||||||
|
capturedQuery = r.URL.Query()
|
||||||
|
writeJSON(t, w, []map[string]interface{}{
|
||||||
|
{"id": float64(1), "login": "zhangsan"},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runSearchShortcut(t, server, "users", map[string]string{
|
||||||
|
"keyword": "zhangsan",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search users failed: %v", err)
|
||||||
|
}
|
||||||
|
if capturedPath != "/users/list.json" {
|
||||||
|
t.Fatalf("expected path /users/list.json, got %s", capturedPath)
|
||||||
|
}
|
||||||
|
if capturedQuery.Get("search") != "zhangsan" {
|
||||||
|
t.Fatalf("expected search=zhangsan, got %s", capturedQuery.Get("search"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSearchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findSearchShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{
|
||||||
|
HTTP: server.Client(),
|
||||||
|
BaseURL: server.URL,
|
||||||
|
},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSearchShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, shortcut := range Shortcuts() {
|
||||||
|
if shortcut.Name == name {
|
||||||
|
return shortcut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||||
|
t.Fatalf("failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
package star
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
{
|
||||||
|
Name: "star",
|
||||||
|
Description: "Star (like) a repository",
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
projectID, err := resolveProjectID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("POST",
|
||||||
|
fmt.Sprintf("/projects/%d/praise_tread/like", projectID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "unstar",
|
||||||
|
Description: "Unstar (unlike) a repository",
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
projectID, err := resolveProjectID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("DELETE",
|
||||||
|
fmt.Sprintf("/projects/%d/praise_tread/unlike", projectID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "stars",
|
||||||
|
Description: "List stargazers of a repository",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "owner", Short: "o", Usage: "Repository owner", Required: true},
|
||||||
|
{Name: "repo", Short: "r", Usage: "Repository name", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
owner, _ := ctx.RequireArg("owner")
|
||||||
|
repo, _ := ctx.RequireArg("repo")
|
||||||
|
env, err := ctx.CallAPI("GET",
|
||||||
|
fmt.Sprintf("/%s/%s/stargazers", owner, repo), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveProjectID(ctx *common.RuntimeContext) (int64, error) {
|
||||||
|
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get project info: %w", err)
|
||||||
|
}
|
||||||
|
data, ok := env.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("unexpected project info response")
|
||||||
|
}
|
||||||
|
for _, key := range []string{"id", "project_id", "repo_id"} {
|
||||||
|
if id, ok := data[key].(float64); ok {
|
||||||
|
return int64(id), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("cannot find project id in response")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
package star
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStar(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
|
||||||
|
case r.Method == "POST" && r.URL.Path == "/projects/100/praise_tread/like.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runStarShortcut(t, server, "star", map[string]string{}); err != nil {
|
||||||
|
t.Fatalf("star failed: %v", err)
|
||||||
|
}
|
||||||
|
if callCount != 2 {
|
||||||
|
t.Fatalf("expected 2 API calls, got %d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnstar(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
|
||||||
|
case r.Method == "DELETE" && r.URL.Path == "/projects/100/praise_tread/unlike.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runStarShortcut(t, server, "unstar", map[string]string{}); err != nil {
|
||||||
|
t.Fatalf("unstar failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStars(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/owner/repo/stargazers.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"count": 1,
|
||||||
|
"users": []map[string]interface{}{{"login": "alice"}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runStarShortcut(t, server, "stars", map[string]string{
|
||||||
|
"owner": "owner", "repo": "repo",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stars failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStarShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findStarShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
Owner: "owner", Repo: "repo", Format: "json", Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findStarShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range Shortcuts() {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
env, err := ctx.CallAPI("GET", "/users/me", nil)
|
env, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("获取当前用户信息失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
@ -32,7 +32,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("查看用户详情失败: %w", err)
|
||||||
}
|
}
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserMe(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/users/me.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1),
|
||||||
|
"login": "testuser",
|
||||||
|
"name": "Test User",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runUserShortcut(t, server, "me", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("user me failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserInfo(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/users/zhangsan.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(42),
|
||||||
|
"login": "zhangsan",
|
||||||
|
"name": "Zhang San",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runUserShortcut(t, server, "info", map[string]string{"login": "zhangsan"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("user info failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUserShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findUserShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findUserShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range Shortcuts() {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||||
|
t.Fatalf("failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package watch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
{
|
||||||
|
Name: "watch",
|
||||||
|
Description: "Watch a repository",
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
projectID, err := resolveProjectID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("target_type", "project")
|
||||||
|
q.Set("id", fmt.Sprintf("%d", projectID))
|
||||||
|
env, err := ctx.CallAPIWithQuery("POST", "/watchers/follow", q)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "unwatch",
|
||||||
|
Description: "Unwatch a repository",
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
projectID, err := resolveProjectID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("target_type", "project")
|
||||||
|
q.Set("id", fmt.Sprintf("%d", projectID))
|
||||||
|
env, err := ctx.CallAPIWithQuery("DELETE", "/watchers/unfollow", q)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "watchers",
|
||||||
|
Description: "List watchers of a repository",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "owner", Short: "o", Usage: "Repository owner", Required: true},
|
||||||
|
{Name: "repo", Short: "r", Usage: "Repository name", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
owner, _ := ctx.RequireArg("owner")
|
||||||
|
repo, _ := ctx.RequireArg("repo")
|
||||||
|
ctx.Owner = owner
|
||||||
|
ctx.Repo = repo
|
||||||
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/watchers", owner, repo), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveProjectID(ctx *common.RuntimeContext) (int64, error) {
|
||||||
|
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get project info: %w", err)
|
||||||
|
}
|
||||||
|
data, ok := env.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("unexpected project info response")
|
||||||
|
}
|
||||||
|
for _, key := range []string{"id", "project_id", "repo_id"} {
|
||||||
|
if id, ok := data[key].(float64); ok {
|
||||||
|
return int64(id), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("cannot find project id in response")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
package watch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWatch(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{"id": float64(100), "name": "repo"})
|
||||||
|
case r.Method == "POST" && r.URL.Path == "/watchers/follow.json":
|
||||||
|
if r.URL.Query().Get("target_type") != "project" {
|
||||||
|
t.Fatal("expected target_type=project")
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("id") != "100" {
|
||||||
|
t.Fatalf("expected id=100, got %s", r.URL.Query().Get("id"))
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"watched": true})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runWatchShortcut(t, server, "watch", map[string]string{}); err != nil {
|
||||||
|
t.Fatalf("watch failed: %v", err)
|
||||||
|
}
|
||||||
|
if callCount != 2 {
|
||||||
|
t.Fatalf("expected 2 calls (GET project + POST watch), got %d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnwatch(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
|
||||||
|
case r.Method == "DELETE" && r.URL.Path == "/watchers/unfollow.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{"watched": false})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runWatchShortcut(t, server, "unwatch", map[string]string{}); err != nil {
|
||||||
|
t.Fatalf("unwatch failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchers(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/owner/repo/watchers.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"count": 1,
|
||||||
|
"users": []map[string]interface{}{{"login": "alice", "is_watch": true}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWatchShortcut(t, server, "watchers", map[string]string{
|
||||||
|
"owner": "owner", "repo": "repo",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("watchers failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === helpers ===
|
||||||
|
|
||||||
|
func runWatchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findWatchShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
Owner: "owner", Repo: "repo", Format: "json", Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findWatchShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range Shortcuts() {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func v1RepoPath(ctx *common.RuntimeContext) string {
|
||||||
|
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Description: "List webhooks",
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("GET", v1RepoPath(ctx)+"/webhooks", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "create",
|
||||||
|
Description: "Create a webhook",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "url", Short: "u", Usage: "Webhook URL", Required: true},
|
||||||
|
{Name: "events", Short: "e", Usage: "Comma-separated events (push,create,delete,issues_only,pull_request_only)", Default: "push"},
|
||||||
|
{Name: "content-type", Usage: "Content type: json, form", Default: "json"},
|
||||||
|
{Name: "secret", Short: "s", Usage: "Webhook secret"},
|
||||||
|
{Name: "branch-filter", Short: "b", Usage: "Branch filter glob pattern", Default: "*"},
|
||||||
|
{Name: "active", Usage: "Enable webhook (true/false)", Default: "true"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
webhookURL, err := ctx.RequireArg("url")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
eventsStr := ctx.Arg("events")
|
||||||
|
events := []string{}
|
||||||
|
for _, e := range strings.Split(eventsStr, ",") {
|
||||||
|
e = strings.TrimSpace(e)
|
||||||
|
if e != "" {
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"url": webhookURL,
|
||||||
|
"type": "gitea",
|
||||||
|
"active": ctx.Arg("active") == "true",
|
||||||
|
"content_type": ctx.Arg("content-type"),
|
||||||
|
"http_method": "POST",
|
||||||
|
"branch_filter": ctx.Arg("branch-filter"),
|
||||||
|
"events": events,
|
||||||
|
}
|
||||||
|
if secret := ctx.Arg("secret"); secret != "" {
|
||||||
|
body["secret"] = secret
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/webhooks", body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建 Webhook 失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "view",
|
||||||
|
Description: "View webhook details",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查看 Webhook 详情失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "update",
|
||||||
|
Description: "Update a webhook",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||||
|
{Name: "url", Short: "u", Usage: "New webhook URL"},
|
||||||
|
{Name: "events", Short: "e", Usage: "Comma-separated events"},
|
||||||
|
{Name: "content-type", Usage: "Content type: json, form"},
|
||||||
|
{Name: "secret", Short: "s", Usage: "New webhook secret"},
|
||||||
|
{Name: "branch-filter", Short: "b", Usage: "Branch filter glob pattern"},
|
||||||
|
{Name: "active", Usage: "Enable webhook (true/false)"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
if u := ctx.Arg("url"); u != "" {
|
||||||
|
body["url"] = u
|
||||||
|
}
|
||||||
|
if eventsStr := ctx.Arg("events"); eventsStr != "" {
|
||||||
|
events := []string{}
|
||||||
|
for _, e := range strings.Split(eventsStr, ",") {
|
||||||
|
e = strings.TrimSpace(e)
|
||||||
|
if e != "" {
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body["events"] = events
|
||||||
|
}
|
||||||
|
if ct := ctx.Arg("content-type"); ct != "" {
|
||||||
|
body["content_type"] = ct
|
||||||
|
}
|
||||||
|
if secret := ctx.Arg("secret"); secret != "" {
|
||||||
|
body["secret"] = secret
|
||||||
|
}
|
||||||
|
if bf := ctx.Arg("branch-filter"); bf != "" {
|
||||||
|
body["branch_filter"] = bf
|
||||||
|
}
|
||||||
|
if active := ctx.Arg("active"); active != "" {
|
||||||
|
body["active"] = active == "true"
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
return fmt.Errorf("至少需要指定一个要更新的字段")
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("更新 Webhook 失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete",
|
||||||
|
Description: "Delete a webhook",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("删除 Webhook 失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "test",
|
||||||
|
Description: "Test a webhook delivery",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||||
|
}
|
||||||
|
id, err := ctx.RequireArg("id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", v1RepoPath(ctx), id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("测试 Webhook 失败: %w", err)
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWebhookList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/webhooks.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"total_count": 1,
|
||||||
|
"webhooks": []map[string]interface{}{
|
||||||
|
{"id": 1, "url": "https://example.com/hook", "is_active": true},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWebhookShortcut(t, server, "list", map[string]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookCreate(t *testing.T) {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/webhooks.json" {
|
||||||
|
payload = decodeJSON(t, r)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": 42,
|
||||||
|
"url": "https://example.com/hook",
|
||||||
|
"active": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWebhookShortcut(t, server, "create", map[string]string{
|
||||||
|
"url": "https://example.com/hook",
|
||||||
|
"events": "push,create",
|
||||||
|
"secret": "mysecret",
|
||||||
|
"active": "true",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, payload["url"], "https://example.com/hook")
|
||||||
|
assertEqual(t, payload["active"], true)
|
||||||
|
assertEqual(t, payload["type"], "gitea")
|
||||||
|
assertEqual(t, payload["secret"], "mysecret")
|
||||||
|
|
||||||
|
events, ok := payload["events"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("events should be a slice, got %T", payload["events"])
|
||||||
|
}
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("expected 2 events, got %d", len(events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookDelete(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/webhooks/42.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"status": 0,
|
||||||
|
"message": "success",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWebhookShortcut(t, server, "delete", map[string]string{
|
||||||
|
"id": "42",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookTest(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/webhooks/42/tests.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"status": 0,
|
||||||
|
"message": "success",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWebhookShortcut(t, server, "test", map[string]string{
|
||||||
|
"id": "42",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("test shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookView(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks/1.json" {
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": float64(1), "url": "https://example.com/hook", "is_active": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWebhookShortcut(t, server, "view", map[string]string{"id": "1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("webhook view failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookUpdateRequiresAtLeastOneField(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made when no fields are provided")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWebhookShortcut(t, server, "update", map[string]string{
|
||||||
|
"id": "42",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no update fields provided, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWebhookShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findWebhookShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{
|
||||||
|
HTTP: server.Client(),
|
||||||
|
BaseURL: server.URL,
|
||||||
|
},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findWebhookShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, shortcut := range Shortcuts() {
|
||||||
|
if shortcut.Name == name {
|
||||||
|
return shortcut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||||
|
t.Fatalf("failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
|
||||||
|
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
package wiki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
const wikiBaseURL = "https://gateway.gitlink.org.cn/api"
|
||||||
|
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Description: "List wiki pages",
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
projectID, err := fetchProjectID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("owner", ctx.Owner)
|
||||||
|
q.Set("repo", ctx.Repo)
|
||||||
|
q.Set("projectId", fmt.Sprintf("%d", projectID))
|
||||||
|
return callWikiAPI(ctx, "GET", "/wiki/open/wikiPages", nil, q)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "view",
|
||||||
|
Description: "View a wiki page",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
projectID, err := fetchProjectID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("owner", ctx.Owner)
|
||||||
|
q.Set("repo", ctx.Repo)
|
||||||
|
q.Set("projectId", fmt.Sprintf("%d", projectID))
|
||||||
|
q.Set("pageName", name)
|
||||||
|
return callWikiAPI(ctx, "GET", "/wiki/open/getWiki", nil, q)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "create",
|
||||||
|
Description: "Create a wiki page",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||||
|
{Name: "content", Short: "c", Usage: "Page content (will be base64 encoded)", Required: true},
|
||||||
|
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content, err := ctx.RequireArg("content")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
projectID, err := fetchProjectID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"owner": ctx.Owner,
|
||||||
|
"repo": ctx.Repo,
|
||||||
|
"projectId": projectID,
|
||||||
|
"pageName": name,
|
||||||
|
"title": name,
|
||||||
|
"message": ctx.Arg("message"),
|
||||||
|
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||||
|
}
|
||||||
|
return callWikiAPI(ctx, "POST", "/wiki/open/createWiki", body, nil)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "update",
|
||||||
|
Description: "Update a wiki page",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||||
|
{Name: "content", Short: "c", Usage: "New page content (will be base64 encoded)", Required: true},
|
||||||
|
{Name: "message", Short: "m", Usage: "Commit message"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content, err := ctx.RequireArg("content")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
projectID, err := fetchProjectID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"owner": ctx.Owner,
|
||||||
|
"repo": ctx.Repo,
|
||||||
|
"projectId": projectID,
|
||||||
|
"pageName": name,
|
||||||
|
"title": name,
|
||||||
|
"message": ctx.Arg("message"),
|
||||||
|
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||||
|
}
|
||||||
|
return callWikiAPI(ctx, "PUT", "/wiki/open/updateWiki", body, nil)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete",
|
||||||
|
Description: "Delete a wiki page and remove it from sidebar",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
projectID, err := fetchProjectID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Delete the wiki page
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"owner": ctx.Owner,
|
||||||
|
"repo": ctx.Repo,
|
||||||
|
"projectId": projectID,
|
||||||
|
"pageName": name,
|
||||||
|
}
|
||||||
|
if err := callWikiAPISilent(ctx, "DELETE", "/wiki/open/deleteWiki", body, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Wait for GitLink async sidebar rebuild, then clean up
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
cleanSidebar(ctx, projectID, name)
|
||||||
|
|
||||||
|
fmt.Printf("Wiki page %q deleted successfully.\n", name)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// callWikiAPI sends a request to the wiki gateway.
|
||||||
|
// It switches the client BaseURL to the wiki gateway for the duration of the call,
|
||||||
|
// but skips the switch during tests (local httptest server).
|
||||||
|
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error {
|
||||||
|
origBase := ctx.Client.BaseURL
|
||||||
|
if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") {
|
||||||
|
ctx.Client.BaseURL = wikiBaseURL
|
||||||
|
}
|
||||||
|
defer func() { ctx.Client.BaseURL = origBase }()
|
||||||
|
|
||||||
|
env, err := ctx.Client.DoRaw(method, path, body, query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchProjectID resolves the numeric project ID from the repo info API.
|
||||||
|
func fetchProjectID(ctx *common.RuntimeContext) (int64, error) {
|
||||||
|
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get project info: %w", err)
|
||||||
|
}
|
||||||
|
data, ok := env.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("unexpected project info response")
|
||||||
|
}
|
||||||
|
for _, key := range []string{"project_id", "repo_id", "id"} {
|
||||||
|
if id, ok := data[key].(float64); ok {
|
||||||
|
return int64(id), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("project id not found in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// callWikiAPISilent is like callWikiAPI but does not print output.
|
||||||
|
func callWikiAPISilent(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error {
|
||||||
|
origBase := ctx.Client.BaseURL
|
||||||
|
if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") {
|
||||||
|
ctx.Client.BaseURL = wikiBaseURL
|
||||||
|
}
|
||||||
|
defer func() { ctx.Client.BaseURL = origBase }()
|
||||||
|
|
||||||
|
_, err := ctx.Client.DoRaw(method, path, body, query)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarPageName = "_Sidebar" // GitLink uses capital S for the sidebar page
|
||||||
|
|
||||||
|
// cleanSidebar fetches the wiki sidebar, removes the deleted page link, and updates it.
|
||||||
|
func cleanSidebar(ctx *common.RuntimeContext, projectID int64, pageName string) {
|
||||||
|
// Fetch sidebar
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("owner", ctx.Owner)
|
||||||
|
q.Set("repo", ctx.Repo)
|
||||||
|
q.Set("projectId", fmt.Sprintf("%d", projectID))
|
||||||
|
q.Set("pageName", sidebarPageName)
|
||||||
|
|
||||||
|
origBase := ctx.Client.BaseURL
|
||||||
|
if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") {
|
||||||
|
ctx.Client.BaseURL = wikiBaseURL
|
||||||
|
}
|
||||||
|
defer func() { ctx.Client.BaseURL = origBase }()
|
||||||
|
|
||||||
|
env, err := ctx.Client.DoRaw("GET", "/wiki/open/getWiki", nil, q)
|
||||||
|
if err != nil {
|
||||||
|
return // sidebar might not exist, silently skip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract content_base64 from response.
|
||||||
|
// DoRaw auto-parses JSON, so env.Data is a map with "data" as either
|
||||||
|
// a nested dict (already parsed) or a JSON string (needs parsing).
|
||||||
|
outer, ok := env.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var inner map[string]interface{}
|
||||||
|
switch v := outer["data"].(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
inner = v
|
||||||
|
case string:
|
||||||
|
if err := json.Unmarshal([]byte(v), &inner); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contentB64, ok := inner["content_base64"].(string)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentBytes, err := base64.StdEncoding.DecodeString(contentB64)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sidebar := string(contentBytes)
|
||||||
|
|
||||||
|
// Remove the line containing [[pageName]]
|
||||||
|
target := "[[" + pageName + "]]"
|
||||||
|
lines := strings.Split(sidebar, "\n")
|
||||||
|
var newLines []string
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed != target {
|
||||||
|
newLines = append(newLines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newSidebar := strings.Join(newLines, "\n")
|
||||||
|
|
||||||
|
// No change needed
|
||||||
|
if newSidebar == sidebar {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sidebar
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"owner": ctx.Owner,
|
||||||
|
"repo": ctx.Repo,
|
||||||
|
"projectId": projectID,
|
||||||
|
"pageName": sidebarPageName,
|
||||||
|
"title": sidebarPageName,
|
||||||
|
"message": "Remove deleted page " + pageName + " from sidebar",
|
||||||
|
"content_base64": base64.StdEncoding.EncodeToString([]byte(newSidebar)),
|
||||||
|
}
|
||||||
|
ctx.Client.DoRaw("PUT", "/wiki/open/updateWiki", body, nil)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,325 @@
|
||||||
|
package wiki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- list ---
|
||||||
|
|
||||||
|
func TestWikiList(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages":
|
||||||
|
if r.URL.Query().Get("projectId") != "42" {
|
||||||
|
t.Fatalf("expected projectId=42, got %s", r.URL.Query().Get("projectId"))
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"title": "Home", "sub_url": "Home"},
|
||||||
|
map[string]interface{}{"title": "Guide", "sub_url": "Guide"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runWikiShortcut(t, server, "list", map[string]string{}); err != nil {
|
||||||
|
t.Fatalf("list failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWikiListWithProjectID(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{"project_id": float64(99)})
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages":
|
||||||
|
if r.URL.Query().Get("projectId") != "99" {
|
||||||
|
t.Fatalf("expected projectId=99, got %s", r.URL.Query().Get("projectId"))
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"data": []interface{}{}})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := runWikiShortcut(t, server, "list", map[string]string{}); err != nil {
|
||||||
|
t.Fatalf("list with project_id failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- view ---
|
||||||
|
|
||||||
|
func TestWikiView(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki":
|
||||||
|
if r.URL.Query().Get("pageName") != "Home" {
|
||||||
|
t.Fatalf("expected pageName=Home, got %s", r.URL.Query().Get("pageName"))
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"title": "Home",
|
||||||
|
"content_base64": base64.StdEncoding.EncodeToString([]byte("Welcome to wiki")),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWikiShortcut(t, server, "view", map[string]string{"name": "Home"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("view failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWikiViewRequiresName(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made without --name")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWikiShortcut(t, server, "view", map[string]string{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing --name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- create ---
|
||||||
|
|
||||||
|
func TestWikiCreate(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
|
||||||
|
case r.Method == "POST" && r.URL.Path == "/wiki/open/createWiki":
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["pageName"] != "NewPage" {
|
||||||
|
t.Fatalf("expected pageName=NewPage, got %v", payload["pageName"])
|
||||||
|
}
|
||||||
|
if payload["title"] != "NewPage" {
|
||||||
|
t.Fatalf("expected title=NewPage, got %v", payload["title"])
|
||||||
|
}
|
||||||
|
if payload["owner"] != "owner" {
|
||||||
|
t.Fatalf("expected owner=owner, got %v", payload["owner"])
|
||||||
|
}
|
||||||
|
if payload["repo"] != "repo" {
|
||||||
|
t.Fatalf("expected repo=repo, got %v", payload["repo"])
|
||||||
|
}
|
||||||
|
if payload["projectId"].(float64) != 42 {
|
||||||
|
t.Fatalf("expected projectId=42, got %v", payload["projectId"])
|
||||||
|
}
|
||||||
|
expectedContent := base64.StdEncoding.EncodeToString([]byte("Hello Wiki!"))
|
||||||
|
if payload["content_base64"] != expectedContent {
|
||||||
|
t.Fatalf("content_base64 mismatch: got %v", payload["content_base64"])
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"code": 201,
|
||||||
|
"data": map[string]interface{}{"title": "NewPage"},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWikiShortcut(t, server, "create", map[string]string{
|
||||||
|
"name": "NewPage", "content": "Hello Wiki!", "message": "create page",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWikiCreateRequiresName(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made without --name")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWikiShortcut(t, server, "create", map[string]string{"content": "test"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing --name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWikiCreateRequiresContent(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made without --content")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWikiShortcut(t, server, "create", map[string]string{"name": "Test"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing --content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- update ---
|
||||||
|
|
||||||
|
func TestWikiUpdate(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
|
||||||
|
case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki":
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["pageName"] != "Home" {
|
||||||
|
t.Fatalf("expected pageName=Home, got %v", payload["pageName"])
|
||||||
|
}
|
||||||
|
if payload["title"] != "Home" {
|
||||||
|
t.Fatalf("expected title=Home, got %v", payload["title"])
|
||||||
|
}
|
||||||
|
if payload["message"] != "update page" {
|
||||||
|
t.Fatalf("expected message=update page, got %v", payload["message"])
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"code": 200})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWikiShortcut(t, server, "update", map[string]string{
|
||||||
|
"name": "Home", "content": "Updated content", "message": "update page",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWikiUpdateRequiresName(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made without --name")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWikiShortcut(t, server, "update", map[string]string{"content": "test"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing --name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- delete ---
|
||||||
|
|
||||||
|
func TestWikiDelete(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
sidebarContent := "[[Home]]\n[[OldPage]]\n[[Guide]]"
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
|
||||||
|
case r.Method == "DELETE" && r.URL.Path == "/wiki/open/deleteWiki":
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["pageName"] != "OldPage" {
|
||||||
|
t.Fatalf("expected pageName=OldPage, got %v", payload["pageName"])
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"code": 200})
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki":
|
||||||
|
if r.URL.Query().Get("pageName") != "_Sidebar" {
|
||||||
|
t.Fatalf("expected pageName=_Sidebar, got %s", r.URL.Query().Get("pageName"))
|
||||||
|
}
|
||||||
|
// Return sidebar with the page still in it
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
|
"data": fmt.Sprintf(`{"content_base64":"%s"}`, base64.StdEncoding.EncodeToString([]byte(sidebarContent))),
|
||||||
|
})
|
||||||
|
case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki":
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["pageName"] != "_Sidebar" {
|
||||||
|
t.Fatalf("expected pageName=_Sidebar, got %v", payload["pageName"])
|
||||||
|
}
|
||||||
|
// Verify OldPage is removed from sidebar
|
||||||
|
updated, _ := base64.StdEncoding.DecodeString(payload["content_base64"].(string))
|
||||||
|
if strings.Contains(string(updated), "[[OldPage]]") {
|
||||||
|
t.Fatal("sidebar should not contain [[OldPage]] after delete")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(updated), "[[Home]]") {
|
||||||
|
t.Fatal("sidebar should still contain [[Home]]")
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"code": 200})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWikiShortcut(t, server, "delete", map[string]string{"name": "OldPage"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWikiDeleteRequiresName(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("no request should be made without --name")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runWikiShortcut(t, server, "delete", map[string]string{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing --name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func runWikiShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findWikiShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
Owner: "owner", Repo: "repo", Format: "json", Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findWikiShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range Shortcuts() {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||||
|
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deepseekBaseURL = "https://api.deepseek.com/v1/chat/completions"
|
||||||
|
const defaultModel = "deepseek-chat"
|
||||||
|
|
||||||
|
// AIClient wraps the DeepSeek API for skill step execution.
|
||||||
|
type AIClient struct {
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonOutputInstruction = `
|
||||||
|
|
||||||
|
IMPORTANT: You MUST respond with a single JSON object in exactly this format:
|
||||||
|
{"analysis": "<your full markdown report as a single string>", "actions": [...list of actions...]}
|
||||||
|
|
||||||
|
The "analysis" field is a STRING containing your entire analysis report in markdown.
|
||||||
|
The "actions" field is an array of action objects: {"type":"cli", "module":"pr", "command":"+comment", "args":{"id":"4", "body":"..."}} or {"type":"api", "method":"POST", "path":"/v1/...", "body":{...}}.
|
||||||
|
Do NOT include any text outside the JSON object. Do NOT use markdown code fences around the JSON.`
|
||||||
|
|
||||||
|
// NewAIClient resolves the API key (env → config) and returns a client, or nil if unavailable.
|
||||||
|
func NewAIClient() *AIClient {
|
||||||
|
key := os.Getenv("DEEPSEEK_API_KEY")
|
||||||
|
if key == "" {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err == nil {
|
||||||
|
key = cfg.DeepSeekAPIKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &AIClient{
|
||||||
|
apiKey: key,
|
||||||
|
model: defaultModel,
|
||||||
|
http: &http.Client{Timeout: 120 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze sends the skill prompt + upstream data to the DeepSeek API and parses the response.
|
||||||
|
func (c *AIClient) Analyze(req *wf.AIRequest) (*wf.AIResponse, error) {
|
||||||
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("AI client not configured: set DEEPSEEK_API_KEY or configure deepseek_api_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"model": c.model,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"temperature": 0.1,
|
||||||
|
"messages": []map[string]string{
|
||||||
|
{"role": "system", "content": req.SystemPrompt + jsonOutputInstruction},
|
||||||
|
{"role": "user", "content": req.UserData},
|
||||||
|
},
|
||||||
|
"response_format": map[string]string{"type": "json_object"},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequest("POST", deepseekBaseURL, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
|
|
||||||
|
resp, err := c.http.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("API call: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("DeepSeek API returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty response from DeepSeek API")
|
||||||
|
}
|
||||||
|
|
||||||
|
text := result.Choices[0].Message.Content
|
||||||
|
aiResp, err := parseAIResponse(text)
|
||||||
|
if err != nil {
|
||||||
|
if extracted := extractJSONFromMarkdown(text); extracted != "" {
|
||||||
|
aiResp2, err2 := parseAIResponse(extracted)
|
||||||
|
if err2 != nil {
|
||||||
|
return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text)
|
||||||
|
}
|
||||||
|
aiResp = aiResp2
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return aiResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAIResponse unmarshals the AI's JSON output, coercing numeric arg values to strings.
|
||||||
|
func parseAIResponse(text string) (*wf.AIResponse, error) {
|
||||||
|
raw := struct {
|
||||||
|
Analysis json.RawMessage `json:"analysis"`
|
||||||
|
Actions []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Body map[string]interface{} `json:"body,omitempty"`
|
||||||
|
Module string `json:"module,omitempty"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
Args map[string]interface{} `json:"args,omitempty"`
|
||||||
|
} `json:"actions"`
|
||||||
|
}{}
|
||||||
|
if err := json.Unmarshal([]byte(text), &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &wf.AIResponse{}
|
||||||
|
if err := json.Unmarshal(raw.Analysis, &resp.Analysis); err != nil {
|
||||||
|
resp.Analysis = string(raw.Analysis)
|
||||||
|
}
|
||||||
|
for _, a := range raw.Actions {
|
||||||
|
args := make(map[string]string, len(a.Args))
|
||||||
|
for k, v := range a.Args {
|
||||||
|
args[k] = fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
resp.Actions = append(resp.Actions, wf.AIAction{
|
||||||
|
Type: a.Type,
|
||||||
|
Method: a.Method,
|
||||||
|
Path: a.Path,
|
||||||
|
Body: a.Body,
|
||||||
|
Module: a.Module,
|
||||||
|
Command: a.Command,
|
||||||
|
Args: args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasKey reports whether the AI client is configured.
|
||||||
|
func (c *AIClient) HasKey() bool {
|
||||||
|
return c != nil && c.apiKey != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractJSONFromMarkdown tries to pull a JSON object out of a markdown code fence.
|
||||||
|
func extractJSONFromMarkdown(text string) string {
|
||||||
|
start := strings.Index(text, "```json")
|
||||||
|
if start == -1 {
|
||||||
|
start = strings.Index(text, "```")
|
||||||
|
}
|
||||||
|
if start == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
nl := strings.Index(text[start:], "\n")
|
||||||
|
if nl == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
content := text[start+nl+1:]
|
||||||
|
end := strings.Index(content, "```")
|
||||||
|
if end == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(content[:end])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,397 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/daemon"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shortcuts returns all workflow CLI commands.
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Description: "List available workflows",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "category", Short: "c", Usage: "Filter by category", Default: ""},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
cat := ctx.Arg("category")
|
||||||
|
workflows := wf.All()
|
||||||
|
filtered := make([]*wf.WorkflowDef, 0)
|
||||||
|
for _, w := range workflows {
|
||||||
|
if cat == "" || strings.EqualFold(w.Category, cat) {
|
||||||
|
filtered = append(filtered, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type listItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
StepCount int `json:"step_count"`
|
||||||
|
}
|
||||||
|
items := make([]listItem, len(filtered))
|
||||||
|
for i, w := range filtered {
|
||||||
|
items[i] = listItem{
|
||||||
|
Name: w.Name,
|
||||||
|
Category: w.Category,
|
||||||
|
Description: w.Description,
|
||||||
|
StepCount: len(w.Steps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx.OutputData(items)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "info",
|
||||||
|
Description: "Show workflow detail (steps and trigger)",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := wf.Get(name)
|
||||||
|
if w == nil {
|
||||||
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||||
|
}
|
||||||
|
return ctx.OutputData(w)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "run",
|
||||||
|
Description: "Execute a workflow manually",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||||
|
{Name: "dry-run", Usage: "Preview mode (no AI calls)", Bool: true},
|
||||||
|
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||||
|
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||||
|
{Name: "desc", Short: "d", Usage: "Project description (for project-init workflow)", Default: ""},
|
||||||
|
{Name: "repos", Usage: "Comma-separated repositories for multi-repo workflow, e.g. org/backend,org/frontend", Default: ""},
|
||||||
|
{Name: "from", Usage: "CSV file for multi-repo workflow with owner,repo columns", Default: ""},
|
||||||
|
{Name: "release", Usage: "Target release/tag for multi-repo release coordination", Default: ""},
|
||||||
|
{Name: "wiki-repo", Usage: "Wiki target repo for publishing multi-repo report, e.g. org/dashboard", Default: ""},
|
||||||
|
{Name: "daemon-loop", Usage: "Internal: run in loop mode", Bool: true},
|
||||||
|
{Name: "interval", Usage: "Internal: loop interval", Default: "5m"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := wf.Get(name)
|
||||||
|
if w == nil {
|
||||||
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if desc := ctx.Arg("desc"); desc != "" {
|
||||||
|
ctx.Args["_desc"] = desc
|
||||||
|
}
|
||||||
|
|
||||||
|
if wr := ctx.Arg("wiki-repo"); wr != "" {
|
||||||
|
owner, repo := splitRepoRef(wr)
|
||||||
|
if owner != "" && repo != "" {
|
||||||
|
ctx.Args["_wiki_owner"] = owner
|
||||||
|
ctx.Args["_wiki_repo"] = repo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aiMode, err := resolveAIModeFromArgs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Arg("daemon-loop") == "true" {
|
||||||
|
intervalStr := ctx.Arg("interval")
|
||||||
|
interval, err := time.ParseDuration(intervalStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||||
|
}
|
||||||
|
return daemon.DaemonLoop(ctx, w, interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
return runWorkflowCommand(ctx, w, ctx.Arg("dry-run") == "true", aiMode)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "watch",
|
||||||
|
Description: "Poll for changes and trigger workflow on delta",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||||
|
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 30s, 5m, 1h)", Default: "5m"},
|
||||||
|
{Name: "step", Short: "s", Usage: "Step name to watch for changes (default: first step)", Default: ""},
|
||||||
|
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||||
|
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := wf.Get(name)
|
||||||
|
if w == nil {
|
||||||
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||||
|
}
|
||||||
|
if w.Name == "multi-repo" {
|
||||||
|
return fmt.Errorf("workflow +watch 不支持 multi-repo;多仓库协同请求量较大,请使用 workflow +run 手动检查,或 workflow +schedule --interval 6h/24h 做低频巡检")
|
||||||
|
}
|
||||||
|
intervalStr := ctx.Arg("interval")
|
||||||
|
interval, err := time.ParseDuration(intervalStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||||
|
if modeErr != nil {
|
||||||
|
return modeErr
|
||||||
|
}
|
||||||
|
ctx.AIMode = aiMode
|
||||||
|
|
||||||
|
return daemon.Watch(ctx, w, interval, ctx.Arg("step"))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "schedule",
|
||||||
|
Description: "Run a workflow on a repeating schedule",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||||
|
{Name: "interval", Short: "i", Usage: "Run interval (e.g. 1h, 24h)", Default: "24h"},
|
||||||
|
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||||
|
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||||
|
{Name: "repos", Usage: "Comma-separated repositories for multi-repo workflow", Default: ""},
|
||||||
|
{Name: "from", Usage: "CSV file for multi-repo workflow with owner,repo columns", Default: ""},
|
||||||
|
{Name: "release", Usage: "Target release/tag for multi-repo release coordination", Default: ""},
|
||||||
|
{Name: "wiki-repo", Usage: "Wiki target repo for publishing multi-repo report, e.g. org/dashboard", Default: ""},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := wf.Get(name)
|
||||||
|
if w == nil {
|
||||||
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||||
|
}
|
||||||
|
intervalStr := ctx.Arg("interval")
|
||||||
|
interval, err := time.ParseDuration(intervalStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wr := ctx.Arg("wiki-repo"); wr != "" {
|
||||||
|
owner, repo := splitRepoRef(wr)
|
||||||
|
if owner != "" && repo != "" {
|
||||||
|
ctx.Args["_wiki_owner"] = owner
|
||||||
|
ctx.Args["_wiki_repo"] = repo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||||
|
if modeErr != nil {
|
||||||
|
return modeErr
|
||||||
|
}
|
||||||
|
ctx.AIMode = aiMode
|
||||||
|
|
||||||
|
return daemon.Schedule(ctx, w, interval)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "start",
|
||||||
|
Description: "Start workflow as background daemon",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||||
|
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 5m, 1h)", Default: "5m"},
|
||||||
|
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||||
|
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||||
|
{Name: "repos", Usage: "Comma-separated repositories for multi-repo workflow", Default: ""},
|
||||||
|
{Name: "from", Usage: "CSV file for multi-repo workflow with owner,repo columns", Default: ""},
|
||||||
|
{Name: "release", Usage: "Target release/tag for multi-repo release coordination", Default: ""},
|
||||||
|
{Name: "wiki-repo", Usage: "Wiki target repo for publishing multi-repo report, e.g. org/dashboard", Default: ""},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := wf.Get(name)
|
||||||
|
if w == nil {
|
||||||
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||||
|
}
|
||||||
|
intervalStr := ctx.Arg("interval")
|
||||||
|
interval, err := time.ParseDuration(intervalStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wr := ctx.Arg("wiki-repo"); wr != "" {
|
||||||
|
owner, repo := splitRepoRef(wr)
|
||||||
|
if owner != "" && repo != "" {
|
||||||
|
ctx.Args["_wiki_owner"] = owner
|
||||||
|
ctx.Args["_wiki_repo"] = repo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||||
|
if modeErr != nil {
|
||||||
|
return modeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return daemon.StartDaemon(ctx, w, interval, aiMode)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "stop",
|
||||||
|
Description: "Stop workflow daemon",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return daemon.StopDaemon(name)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "status",
|
||||||
|
Description: "Show daemon status",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return daemon.StatusDaemon(name)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "logs",
|
||||||
|
Description: "View daemon log output",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||||
|
{Name: "follow", Short: "f", Usage: "Follow log output (like tail -f)", Bool: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return daemon.TailDaemonLog(name, ctx.Arg("follow") == "true")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "install-systemd",
|
||||||
|
Description: "Generate systemd service unit for a workflow daemon",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||||
|
{Name: "interval", Short: "i", Usage: "Poll interval", Default: "5m"},
|
||||||
|
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||||
|
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
name, err := ctx.RequireArg("name")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := wf.Get(name)
|
||||||
|
if w == nil {
|
||||||
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||||
|
if modeErr != nil {
|
||||||
|
return modeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return daemon.InstallSystemdUnit(ctx, w, ctx.Arg("interval"), aiMode)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAIModeFromArgs(ctx *common.RuntimeContext) (string, error) {
|
||||||
|
ai := ctx.Arg("ai") == "true"
|
||||||
|
noAI := ctx.Arg("no-ai") == "true"
|
||||||
|
if ai && noAI {
|
||||||
|
return "", fmt.Errorf("--ai 和 --no-ai 互斥,只能指定其中一个")
|
||||||
|
}
|
||||||
|
if ai {
|
||||||
|
return "ai", nil
|
||||||
|
}
|
||||||
|
if noAI {
|
||||||
|
return "no-ai", nil
|
||||||
|
}
|
||||||
|
return "auto", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWorkflowCommand(ctx *common.RuntimeContext, w *wf.WorkflowDef, dryRun bool, aiMode string) error {
|
||||||
|
result, err := engine.RunWithMode(ctx, w, dryRun, aiMode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
needsAI := 0
|
||||||
|
ruleEngine := 0
|
||||||
|
for _, sr := range result.Steps {
|
||||||
|
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||||
|
if v, _ := m["_needs_ai"]; v == true {
|
||||||
|
needsAI++
|
||||||
|
}
|
||||||
|
if v, _ := m["_ai_used"]; v == true {
|
||||||
|
ruleEngine++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsAI > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n⚠ %d 个 skill 步骤需要 AI 处理:\n", needsAI)
|
||||||
|
for _, sr := range result.Steps {
|
||||||
|
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||||
|
if v, _ := m["_needs_ai"]; v == true {
|
||||||
|
fmt.Fprintf(os.Stderr, " - %s (%s)\n", sr.Step, m["_skill"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "\n你可以:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " 1. 配置 API Key 启用全自动: gitlink-cli config set deepseek_api_key <key>\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " 2. 将以上完整 JSON 输出交给 AI Agent 继续处理\n")
|
||||||
|
} else if ruleEngine > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "🤖 AI 已处理 %d 个 skill 步骤\n", ruleEngine)
|
||||||
|
} else {
|
||||||
|
noAI := 0
|
||||||
|
for _, sr := range result.Steps {
|
||||||
|
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||||
|
if v, _ := m["_ai_used"]; v == false {
|
||||||
|
if _, hasSkill := m["_skill"]; hasSkill {
|
||||||
|
noAI++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if noAI > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "⚙️ 规则引擎已处理 %d 个 skill 步骤 (未使用 AI)\n", noAI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.Output(output.SuccessEnvelope(result, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitRepoRef(raw string) (owner, repo string) {
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(raw), "/", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestShortcutsCount(t *testing.T) {
|
||||||
|
sc := Shortcuts()
|
||||||
|
if len(sc) != 10 {
|
||||||
|
t.Fatalf("expected 10 shortcuts (list, info, run, watch, schedule, start, stop, status, logs, install-systemd), got %d", len(sc))
|
||||||
|
}
|
||||||
|
names := map[string]bool{
|
||||||
|
"list": false, "info": false, "run": false, "watch": false,
|
||||||
|
"schedule": false, "start": false, "stop": false, "status": false,
|
||||||
|
"logs": false, "install-systemd": false,
|
||||||
|
}
|
||||||
|
for _, s := range sc {
|
||||||
|
if _, ok := names[s.Name]; !ok {
|
||||||
|
t.Fatalf("unexpected shortcut: %s", s.Name)
|
||||||
|
}
|
||||||
|
names[s.Name] = true
|
||||||
|
}
|
||||||
|
for n, found := range names {
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("missing shortcut: %s", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
package workflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseCommandTarget splits a CLI command string into tokens, respecting quoted arguments.
|
||||||
|
func ParseCommandTarget(target string) []string {
|
||||||
|
var parts []string
|
||||||
|
var current strings.Builder
|
||||||
|
inQuote := false
|
||||||
|
quoteChar := byte(0)
|
||||||
|
|
||||||
|
for i := 0; i < len(target); i++ {
|
||||||
|
c := target[i]
|
||||||
|
switch {
|
||||||
|
case c == '"' || c == '\'':
|
||||||
|
if inQuote && c == quoteChar {
|
||||||
|
inQuote = false
|
||||||
|
quoteChar = 0
|
||||||
|
} else if !inQuote {
|
||||||
|
inQuote = true
|
||||||
|
quoteChar = c
|
||||||
|
} else {
|
||||||
|
current.WriteByte(c)
|
||||||
|
}
|
||||||
|
case c == ' ' && !inQuote:
|
||||||
|
if current.Len() > 0 {
|
||||||
|
parts = append(parts, current.String())
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
current.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current.Len() > 0 {
|
||||||
|
parts = append(parts, current.String())
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveCLIBinary finds the gitlink-cli binary for subprocess calls.
|
||||||
|
func ResolveCLIBinary() string {
|
||||||
|
if exe, err := os.Executable(); err == nil && exe != "" {
|
||||||
|
return exe
|
||||||
|
}
|
||||||
|
for _, p := range []string{"./gitlink-cli", "./gitlink-cli.exe", "../gitlink-cli", "../gitlink-cli.exe"} {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
if abs, err := filepath.Abs(p); err == nil {
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "gitlink-cli"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePath replaces template placeholders in a path string.
|
||||||
|
func ResolvePath(template, owner, repo string) string {
|
||||||
|
base := fmt.Sprintf("/%s/%s", owner, repo)
|
||||||
|
v1 := fmt.Sprintf("/v1/%s/%s", owner, repo)
|
||||||
|
s := strings.Replace(template, "{v1}", v1, 1)
|
||||||
|
s = strings.Replace(s, "{base}", base, 1)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,397 @@
|
||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartDaemon launches a workflow as a background daemon process.
|
||||||
|
func StartDaemon(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration, aiMode string) error {
|
||||||
|
bin, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot find executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := BuildDaemonArgs(ctx, wfDef, interval, aiMode)
|
||||||
|
|
||||||
|
cmd := exec.Command(bin, args...)
|
||||||
|
applyDaemonAttrs(cmd)
|
||||||
|
|
||||||
|
logPath := daemonLogPath(wfDef.Name)
|
||||||
|
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create log file: %w", err)
|
||||||
|
}
|
||||||
|
cmd.Stdout = logFile
|
||||||
|
cmd.Stderr = logFile
|
||||||
|
cmd.Stdin = nil
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
logFile.Close()
|
||||||
|
return fmt.Errorf("start daemon: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pid := cmd.Process.Pid
|
||||||
|
if err := savePID(wfDef.Name, pid); err != nil {
|
||||||
|
return fmt.Errorf("save pid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Daemon started for %q (PID: %d)\n", wfDef.Name, pid)
|
||||||
|
fmt.Printf("Log: %s\n", logPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildDaemonArgs constructs the CLI arguments for the daemon subprocess.
|
||||||
|
func BuildDaemonArgs(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration, aiMode string) []string {
|
||||||
|
args := []string{
|
||||||
|
"workflow", "+run", "--name", wfDef.Name,
|
||||||
|
"--format", "json", "--daemon-loop",
|
||||||
|
"--interval", interval.String(),
|
||||||
|
}
|
||||||
|
if !engine.IsExplicitMultiRepoRun(ctx, wfDef) {
|
||||||
|
args = append(args, "--owner", ctx.Owner, "--repo", ctx.Repo)
|
||||||
|
}
|
||||||
|
if aiMode == "ai" {
|
||||||
|
args = append(args, "--ai")
|
||||||
|
} else if aiMode == "no-ai" {
|
||||||
|
args = append(args, "--no-ai")
|
||||||
|
}
|
||||||
|
if repos := ctx.Arg("repos"); repos != "" {
|
||||||
|
args = append(args, "--repos", repos)
|
||||||
|
}
|
||||||
|
if from := ctx.Arg("from"); from != "" {
|
||||||
|
args = append(args, "--from", from)
|
||||||
|
}
|
||||||
|
if release := ctx.Arg("release"); release != "" {
|
||||||
|
args = append(args, "--release", release)
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopDaemon stops a running workflow daemon by name.
|
||||||
|
func StopDaemon(name string) error {
|
||||||
|
pid, err := readPID(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
proc, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
cleanPID(name)
|
||||||
|
return fmt.Errorf("daemon %q not running (PID %d not found)", name, pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := proc.Signal(os.Interrupt); err != nil {
|
||||||
|
cleanPID(name)
|
||||||
|
return fmt.Errorf("failed to stop daemon %q: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanPID(name)
|
||||||
|
fmt.Printf("Daemon %q stopped (PID %d)\n", name, pid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusDaemon prints the current daemon status for a workflow.
|
||||||
|
func StatusDaemon(name string) error {
|
||||||
|
st, err := state.LoadState(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pid, pidErr := readPID(name)
|
||||||
|
running := pidErr == nil && processRunning(pid)
|
||||||
|
|
||||||
|
fmt.Printf("工作流: %s\n", name)
|
||||||
|
if running {
|
||||||
|
fmt.Printf("状态: 运行中 (PID: %d)\n", pid)
|
||||||
|
} else {
|
||||||
|
fmt.Println("状态: 已停止")
|
||||||
|
}
|
||||||
|
if st.LastRun != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, st.LastRun)
|
||||||
|
if err == nil {
|
||||||
|
fmt.Printf("上次运行: %s\n", t.Format("2006-01-02 15:04"))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("上次运行: %s\n", st.LastRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("累计运行: %d 次\n", st.TotalRuns)
|
||||||
|
fmt.Printf("快照步骤: %d 个\n", len(st.Snapshots))
|
||||||
|
fmt.Printf("日志文件: %s\n", daemonLogPath(name))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DaemonLoop runs the workflow repeatedly in a loop.
|
||||||
|
func DaemonLoop(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration) error {
|
||||||
|
tick := time.NewTicker(interval)
|
||||||
|
defer tick.Stop()
|
||||||
|
|
||||||
|
doDaemonCycle(ctx, wfDef)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-tick.C:
|
||||||
|
doDaemonCycle(ctx, wfDef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func doDaemonCycle(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) {
|
||||||
|
st, _ := state.LoadState(wfDef.Name)
|
||||||
|
|
||||||
|
dryResult, err := engine.Run(ctx, wfDef, true)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] error: %v\n", time.Now().Format(time.RFC3339), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := st.Diff(dryResult.Steps)
|
||||||
|
if len(changed) == 0 {
|
||||||
|
if st.TotalRuns > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] 没有检测到变更\n", time.Now().Format(time.RFC3339))
|
||||||
|
st.TotalRuns++
|
||||||
|
st.Save()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] 🔔 检测到变更: %v\n", time.Now().Format(time.RFC3339), changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fullResult, err := engine.Run(ctx, wfDef, false)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] run error: %v\n", time.Now().Format(time.RFC3339), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
st, _ = state.LoadState(wfDef.Name)
|
||||||
|
st.TotalRuns++
|
||||||
|
st.Diff(fullResult.Steps)
|
||||||
|
st.UpdateSnapshots(fullResult.Steps)
|
||||||
|
st.Save()
|
||||||
|
|
||||||
|
ok, total := 0, len(fullResult.Steps)
|
||||||
|
for _, sr := range fullResult.Steps {
|
||||||
|
if sr.OK {
|
||||||
|
ok++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] ✅ %d/%d steps OK\n", time.Now().Format(time.RFC3339), ok, total)
|
||||||
|
|
||||||
|
for _, sr := range fullResult.Steps {
|
||||||
|
if !sr.OK {
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] ❌ %s 失败: %s\n", time.Now().Format(time.RFC3339), sr.Step, sr.Error)
|
||||||
|
}
|
||||||
|
if sr.Type == wf.StepTypeSkill && sr.Data != nil {
|
||||||
|
logSkillFindings(sr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logSkillFindings(sr wf.StepResult) {
|
||||||
|
m, ok := sr.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
skill, _ := m["_skill"].(string)
|
||||||
|
analysis := m["analysis"]
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] ── %s 分析结果 ──\n", time.Now().Format(time.RFC3339), skill)
|
||||||
|
|
||||||
|
if s, ok := analysis.(string); ok && s != "" {
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] %s\n", time.Now().Format(time.RFC3339), line)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
am, _ := analysis.(map[string]interface{})
|
||||||
|
if am == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary, ok := am["summary"].(string); ok && summary != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] 📋 %s\n", time.Now().Format(time.RFC3339), summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if findings, ok := am["findings"]; ok && findings != nil {
|
||||||
|
raw, _ := json.Marshal(findings)
|
||||||
|
var arr []interface{}
|
||||||
|
if json.Unmarshal(raw, &arr) == nil {
|
||||||
|
for _, f := range arr {
|
||||||
|
if fm, ok := f.(map[string]interface{}); ok {
|
||||||
|
sev := fm["severity"]
|
||||||
|
what := fm["what"]
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] [%v] %v\n", time.Now().Format(time.RFC3339), sev, what)
|
||||||
|
if prNum, ok := fm["pr_number"]; ok && prNum != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] PR: #%v\n", time.Now().Format(time.RFC3339), prNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg, ok := am["message"].(string); ok && msg != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] ℹ️ %s\n", time.Now().Format(time.RFC3339), msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reviewData, ok := am["reviewed_prs"]; ok {
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] 已审查 PR 数: %v\n", time.Now().Format(time.RFC3339), reviewData)
|
||||||
|
}
|
||||||
|
if totalFindings, ok := am["total_findings"]; ok {
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] 发现问题数: %v\n", time.Now().Format(time.RFC3339), totalFindings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DaemonLogPath returns the log file path for a workflow daemon.
|
||||||
|
func DaemonLogPath(name string) string {
|
||||||
|
return daemonLogPath(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func daemonLogPath(name string) string {
|
||||||
|
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.log", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TailDaemonLog reads and optionally follows a daemon log file.
|
||||||
|
func TailDaemonLog(name string, follow bool) error {
|
||||||
|
path := daemonLogPath(name)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取日志文件 %s: %w (daemon 可能尚未启动)", path, err)
|
||||||
|
}
|
||||||
|
fmt.Print(string(data))
|
||||||
|
|
||||||
|
if !follow {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, os.Interrupt)
|
||||||
|
ticker := time.NewTicker(1 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
offset := int64(len(data))
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-sig:
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fi.Size() > offset {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.Seek(offset, 0)
|
||||||
|
buf := make([]byte, fi.Size()-offset)
|
||||||
|
n, _ := f.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
fmt.Print(string(buf[:n]))
|
||||||
|
}
|
||||||
|
offset = fi.Size()
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallSystemdUnit generates a systemd service unit file for a workflow daemon.
|
||||||
|
func InstallSystemdUnit(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval, aiMode string) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return fmt.Errorf("resolve repo: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bin, _ := os.Executable()
|
||||||
|
extraArgs := ""
|
||||||
|
if aiMode == "ai" {
|
||||||
|
extraArgs = " --ai"
|
||||||
|
} else if aiMode == "no-ai" {
|
||||||
|
extraArgs = " --no-ai"
|
||||||
|
}
|
||||||
|
|
||||||
|
unit := fmt.Sprintf(`[Unit]
|
||||||
|
Description=GitLink CLI Workflow: %s (%s/%s)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=%s workflow +run --name %s --owner %s --repo %s --format json --daemon-loop --interval %s%s
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=30
|
||||||
|
StandardOutput=append:%s
|
||||||
|
StandardError=append:%s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`,
|
||||||
|
wfDef.Name, ctx.Owner, ctx.Repo,
|
||||||
|
bin, wfDef.Name, ctx.Owner, ctx.Repo, interval, extraArgs,
|
||||||
|
daemonLogPath(wfDef.Name), daemonLogPath(wfDef.Name),
|
||||||
|
)
|
||||||
|
|
||||||
|
unitPath := filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.service", wfDef.Name))
|
||||||
|
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
|
||||||
|
return fmt.Errorf("写入 unit 文件: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Systemd unit 已写入: %s\n\n", unitPath)
|
||||||
|
fmt.Println("安装步骤:")
|
||||||
|
fmt.Printf(" sudo cp %s /etc/systemd/system/\n", unitPath)
|
||||||
|
fmt.Println(" sudo systemctl daemon-reload")
|
||||||
|
fmt.Printf(" sudo systemctl enable workflow-%s\n", wfDef.Name)
|
||||||
|
fmt.Printf(" sudo systemctl start workflow-%s\n", wfDef.Name)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("查看日志: journalctl -u workflow-%s -f\n", wfDef.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePID(name string, pid int) error {
|
||||||
|
dir := config.ConfigDir()
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(pidPath(name), []byte(strconv.Itoa(pid)), 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPID(name string) (int, error) {
|
||||||
|
data, err := os.ReadFile(pidPath(name))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return 0, fmt.Errorf("daemon %q is not running (no PID file)", name)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return strconv.Atoi(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanPID(name string) {
|
||||||
|
os.Remove(pidPath(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func processRunning(pid int) bool {
|
||||||
|
proc, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return proc.Signal(syscall.Signal(0)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pidPath(name string) string {
|
||||||
|
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.pid", name))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildDaemonArgsSkipsOwnerRepoForExplicitMultiRepo(t *testing.T) {
|
||||||
|
wfDef := &wf.WorkflowDef{
|
||||||
|
Name: "multi-repo",
|
||||||
|
}
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Owner: "ignored",
|
||||||
|
Repo: "ignored",
|
||||||
|
Args: map[string]string{
|
||||||
|
"repos": "org/backend,org/frontend",
|
||||||
|
"release": "v1.4.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
args := BuildDaemonArgs(ctx, wfDef, 24*time.Hour, "no-ai")
|
||||||
|
if stringSliceContains(args, "--owner") || stringSliceContains(args, "--repo") {
|
||||||
|
t.Fatalf("explicit multi-repo daemon args should not include owner/repo: %v", args)
|
||||||
|
}
|
||||||
|
if !stringSliceContains(args, "--repos") || !stringSliceContains(args, "org/backend,org/frontend") {
|
||||||
|
t.Fatalf("daemon args missing repos: %v", args)
|
||||||
|
}
|
||||||
|
if !stringSliceContains(args, "--release") || !stringSliceContains(args, "v1.4.0") {
|
||||||
|
t.Fatalf("daemon args missing release: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringSliceContains(items []string, want string) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if item == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyDaemonAttrs(cmd *exec.Cmd) {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyDaemonAttrs(cmd *exec.Cmd) {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Schedule runs the full workflow on a repeating interval. Blocks until Ctrl+C.
|
||||||
|
func Schedule(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration) error {
|
||||||
|
fmt.Printf("⏰ Scheduled %q every %v on %s/%s\n", wfDef.Name, interval, ctx.Owner, ctx.Repo)
|
||||||
|
fmt.Println(" Press Ctrl+C to stop")
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, os.Interrupt)
|
||||||
|
tick := time.NewTicker(interval)
|
||||||
|
defer tick.Stop()
|
||||||
|
|
||||||
|
st, _ := state.LoadState(wfDef.Name)
|
||||||
|
dryResult, _ := engine.Run(ctx, wfDef, true)
|
||||||
|
st.UpdateSnapshots(dryResult.Steps)
|
||||||
|
st.TotalRuns++
|
||||||
|
st.Save()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-sig:
|
||||||
|
fmt.Println("\n👋 schedule stopped")
|
||||||
|
return nil
|
||||||
|
case t := <-tick.C:
|
||||||
|
dryResult, err := engine.Run(ctx, wfDef, true)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := st.Diff(dryResult.Steps)
|
||||||
|
st.UpdateSnapshots(dryResult.Steps)
|
||||||
|
st.TotalRuns++
|
||||||
|
st.Save()
|
||||||
|
|
||||||
|
if len(changed) == 0 {
|
||||||
|
fmt.Printf("[%s] ✓ no changes, skipped AI run\n", t.Format("15:04:05"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[%s] ⏳ changes detected, running %q with AI...\n", t.Format("15:04:05"), wfDef.Name)
|
||||||
|
result, err := engine.Run(ctx, wfDef, false)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("❌ error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, total := 0, len(result.Steps)
|
||||||
|
for _, sr := range result.Steps {
|
||||||
|
if sr.OK {
|
||||||
|
ok++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("✅ %d/%d steps OK\n", ok, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Watch polls the first step (or watchStep) every interval and triggers the
|
||||||
|
// full workflow (with AI) only when data changes. Blocks until Ctrl+C.
|
||||||
|
func Watch(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration, watchStep string) error {
|
||||||
|
if watchStep == "" && len(wfDef.Steps) > 0 {
|
||||||
|
watchStep = wfDef.Steps[0].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("👀 Watching %s/%s for %q changes every %v\n", ctx.Owner, ctx.Repo, watchStep, interval)
|
||||||
|
fmt.Printf(" Trigger: %s on %s\n", wfDef.Trigger.Type, wfDef.Trigger.On)
|
||||||
|
fmt.Println(" Press Ctrl+C to stop")
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, os.Interrupt)
|
||||||
|
|
||||||
|
st, _ := state.LoadState(wfDef.Name)
|
||||||
|
tick := time.NewTicker(interval)
|
||||||
|
defer tick.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-sig:
|
||||||
|
fmt.Println("\n👋 watch stopped")
|
||||||
|
return nil
|
||||||
|
case t := <-tick.C:
|
||||||
|
dryResult, err := engine.Run(ctx, wfDef, true)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := st.Diff(dryResult.Steps)
|
||||||
|
if len(changed) == 0 && st.TotalRuns > 0 {
|
||||||
|
fmt.Printf("[%s] ✓ no changes\n", t.Format("15:04:05"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[%s] 🔔 change detected: %v\n", t.Format("15:04:05"), changed)
|
||||||
|
|
||||||
|
result, err := engine.Run(ctx, wfDef, false)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[%s] ❌ AI run error: %v\n", t.Format("15:04:05"), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
st.UpdateSnapshots(result.Steps)
|
||||||
|
st.TotalRuns++
|
||||||
|
st.Save()
|
||||||
|
|
||||||
|
for _, sr := range result.Steps {
|
||||||
|
if sr.OK {
|
||||||
|
fmt.Printf(" ✓ %s\n", sr.Step)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✗ %s: %s\n", sr.Step, sr.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package defs
|
||||||
|
|
||||||
|
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
|
||||||
|
// RegisterCodeQuality registers the code-quality workflow definition.
|
||||||
|
func RegisterCodeQuality() {
|
||||||
|
wf.Register(&wf.WorkflowDef{
|
||||||
|
Name: "code-quality",
|
||||||
|
Category: "质量",
|
||||||
|
Description: "代码质量看门人:PR 提交 → Review → CI 检查 → 结果汇总",
|
||||||
|
Trigger: wf.TriggerDef{
|
||||||
|
Type: "poll",
|
||||||
|
On: "pr.opened",
|
||||||
|
Interval: "5m",
|
||||||
|
},
|
||||||
|
Steps: []wf.StepDef{
|
||||||
|
{Type: wf.StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "ci-builds", Purpose: "获取 CI 构建状态", Target: "ci +builds --limit 10"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "commits", Purpose: "获取最近提交供 CI 关联分析", Target: "commit +list --limit 30"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "branches", Purpose: "获取分支列表检查保护状态", Target: "branch +list"},
|
||||||
|
{Type: wf.StepTypeSkill, Name: "review", Purpose: "AI 审查 PR 代码质量", Target: "gitlink-review", DependsOn: []string{"open-prs"}},
|
||||||
|
{Type: wf.StepTypeSkill, Name: "ci-diagnosis", Purpose: "AI 诊断 CI 构建失败并给出建议", Target: "gitlink-ci", DependsOn: []string{"ci-builds", "commits"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package defs
|
||||||
|
|
||||||
|
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
|
||||||
|
// RegisterCommunityOps registers the community-ops workflow definition.
|
||||||
|
func RegisterCommunityOps() {
|
||||||
|
wf.Register(&wf.WorkflowDef{
|
||||||
|
Name: "community-ops",
|
||||||
|
Category: "运营",
|
||||||
|
Description: "社区运营自动化:Issue 智能分拣 → 生成周报 → 生成 Release Notes",
|
||||||
|
Trigger: wf.TriggerDef{
|
||||||
|
Type: "poll",
|
||||||
|
On: "issue.created",
|
||||||
|
Interval: "5m",
|
||||||
|
},
|
||||||
|
Steps: []wf.StepDef{
|
||||||
|
{Type: wf.StepTypeCommand, Name: "open-issues", Purpose: "获取所有开放 Issue 供 AI 分类", Target: "issue +list --state open --limit 50"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "labels", Purpose: "获取标签库供 AI 匹配", Target: "label +list"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "members", Purpose: "获取成员列表供 AI 分配责任人", Target: "member +list"},
|
||||||
|
{Type: wf.StepTypeSkill, Name: "triage", Purpose: "AI 分析前三步数据,输出分拣表格并执行打标签/分配", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}, RunWhen: wf.RunAlways},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "获取项目基础信息", Target: "repo +info"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "merged-prs", Purpose: "获取已合并 PR 计算合并效率", Target: "pr +list --state merged --limit 50"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "commits", Purpose: "获取提交历史分析活跃度", Target: "commit +list --limit 50"},
|
||||||
|
{Type: wf.StepTypeSkill, Name: "health-report", Purpose: "AI 根据指标生成周报", Target: "gitlink-health", DependsOn: []string{"repo-info", "merged-prs", "commits", "open-issues"}, RunWhen: wf.RunWeekly},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "releases", Purpose: "获取版本发布记录", Target: "release +list"},
|
||||||
|
{Type: wf.StepTypeSkill, Name: "changelog", Purpose: "AI 分类 commit 生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}, RunWhen: wf.RunOnChange},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package defs
|
||||||
|
|
||||||
|
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
|
||||||
|
// RegisterContributorGrowth registers the contributor-growth workflow definition.
|
||||||
|
func RegisterContributorGrowth() {
|
||||||
|
wf.Register(&wf.WorkflowDef{
|
||||||
|
Name: "contributor-growth",
|
||||||
|
Category: "成长",
|
||||||
|
Description: "贡献者成长体系:追踪贡献者活动 → 生成排行 → 识别活跃与流失",
|
||||||
|
Trigger: wf.TriggerDef{
|
||||||
|
Type: "cron",
|
||||||
|
On: "0 9 * * 1",
|
||||||
|
Interval: "24h",
|
||||||
|
},
|
||||||
|
Steps: []wf.StepDef{
|
||||||
|
{Type: wf.StepTypeCommand, Name: "commits", Purpose: "提交历史统计代码贡献", Target: "commit +list --limit 100"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "open-issues", Purpose: "开放 Issue 统计 Issue 贡献", Target: "issue +list --state open --limit 50"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "closed-issues", Purpose: "已关闭 Issue 统计解决贡献", Target: "issue +list --state closed --limit 50"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "merged-prs", Purpose: "已合并 PR 统计代码贡献", Target: "pr +list --state merged --limit 50"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "members", Purpose: "项目成员列表统计参与度", Target: "member +list"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "项目基础数据(Fork/Star/Watch)", Target: "repo +info"},
|
||||||
|
{Type: wf.StepTypeSkill, Name: "contributor-ranking", Purpose: "AI 分析贡献者排行并识别活跃与流失", Target: "gitlink-contributor-ranking", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package defs
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterCommunityOps()
|
||||||
|
RegisterCodeQuality()
|
||||||
|
RegisterProjectInit()
|
||||||
|
RegisterMultiRepo()
|
||||||
|
RegisterContributorGrowth()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package defs
|
||||||
|
|
||||||
|
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
|
||||||
|
// RegisterMultiRepo registers the multi-repo workflow definition.
|
||||||
|
func RegisterMultiRepo() {
|
||||||
|
wf.Register(&wf.WorkflowDef{
|
||||||
|
Name: "multi-repo",
|
||||||
|
Category: "协同",
|
||||||
|
Description: "多仓库协同:跨仓库 Issue/PR 状态看板、Release 协调发布",
|
||||||
|
Trigger: wf.TriggerDef{
|
||||||
|
Type: "cron",
|
||||||
|
On: "0 9 * * 1",
|
||||||
|
Interval: "24h",
|
||||||
|
},
|
||||||
|
Steps: []wf.StepDef{
|
||||||
|
{
|
||||||
|
Type: wf.StepTypeCommand,
|
||||||
|
Name: "multi-repo-snapshot",
|
||||||
|
Purpose: "采集多个仓库的 Issue/PR/Release/Milestone 状态",
|
||||||
|
Target: "workflow-internal:multi-repo-snapshot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: wf.StepTypeSkill,
|
||||||
|
Name: "multi-repo-coordination",
|
||||||
|
Purpose: "生成统一 Issue 追踪、PR 看板、Release 协调报告",
|
||||||
|
Target: "gitlink-multi-repo",
|
||||||
|
DependsOn: []string{"multi-repo-snapshot"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package defs
|
||||||
|
|
||||||
|
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
|
||||||
|
// RegisterProjectInit registers the project-init workflow definition.
|
||||||
|
func RegisterProjectInit() {
|
||||||
|
wf.Register(&wf.WorkflowDef{
|
||||||
|
Name: "project-init",
|
||||||
|
Category: "初始化",
|
||||||
|
Description: "项目一键初始化:创建仓库 → 脚手架文件 → 标签/里程碑/Issue → 许可证审计 → 健康报告",
|
||||||
|
Trigger: wf.TriggerDef{
|
||||||
|
Type: "manual",
|
||||||
|
On: "manual",
|
||||||
|
},
|
||||||
|
Steps: []wf.StepDef{
|
||||||
|
{Type: wf.StepTypeSkill, Name: "init-scaffold", Purpose: "根据描述创建仓库并初始化脚手架(README/LICENSE/.gitignore/标签/里程碑/Issue)", Target: "gitlink-init-scaffold"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "确认仓库已创建并获取基础信息", Target: "repo +info"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "existing-files", Purpose: "验证 README/LICENSE 文件", Target: "file +list"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "labels", Purpose: "验证标签库", Target: "label +list"},
|
||||||
|
{Type: wf.StepTypeSkill, Name: "license-check", Purpose: "检查许可证合规并扫描敏感信息泄露", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "milestones", Purpose: "验证里程碑", Target: "milestone +list"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "existing-issues", Purpose: "验证初始 Issue", Target: "issue +list --state all --limit 10"},
|
||||||
|
{Type: wf.StepTypeCommand, Name: "branches", Purpose: "检查分支结构", Target: "branch +list"},
|
||||||
|
{Type: wf.StepTypeSkill, Name: "repo-audit", Purpose: "综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "existing-files", "labels", "milestones", "branches"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
func executeActions(ctx *common.RuntimeContext, actions []wf.AIAction) (int, []string) {
|
||||||
|
executed := 0
|
||||||
|
var errors []string
|
||||||
|
seen := make(map[string]bool, len(actions))
|
||||||
|
for _, action := range actions {
|
||||||
|
key := actionKey(action)
|
||||||
|
if seen[key] {
|
||||||
|
fmt.Fprintf(os.Stderr, "[workflow] skipped duplicate action: %s %s %s\n", action.Type, action.Module, action.Command)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
if !isActionAllowed(action) {
|
||||||
|
msg := fmt.Sprintf("blocked action: %s %s +%s", action.Type, action.Module, action.Command)
|
||||||
|
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
|
||||||
|
errors = append(errors, msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if action.Type == "api" {
|
||||||
|
path := wf.ResolvePath(action.Path, ctx.Owner, ctx.Repo)
|
||||||
|
_, err := ctx.CallAPI(action.Method, path, action.Body)
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("api %s %s: %v", action.Method, path, err)
|
||||||
|
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
|
||||||
|
errors = append(errors, msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
executed++
|
||||||
|
} else if action.Type == "cli" {
|
||||||
|
args := []string{action.Module, action.Command}
|
||||||
|
for k, v := range action.Args {
|
||||||
|
args = append(args, "--"+k, v)
|
||||||
|
}
|
||||||
|
if ctx.Owner != "" {
|
||||||
|
args = append(args, "--owner", ctx.Owner)
|
||||||
|
}
|
||||||
|
if ctx.Repo != "" {
|
||||||
|
args = append(args, "--repo", ctx.Repo)
|
||||||
|
}
|
||||||
|
bin := wf.ResolveCLIBinary()
|
||||||
|
cmd := exec.Command(bin, args...)
|
||||||
|
var cliStderr bytes.Buffer
|
||||||
|
cmd.Stderr = &cliStderr
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
stderrStr := strings.TrimSpace(cliStderr.String())
|
||||||
|
if action.Module == "repo" && action.Command == "+create" && strings.Contains(stderrStr, "已被使用") {
|
||||||
|
fmt.Fprintf(os.Stderr, "[workflow] repo %s already exists, reusing\n", action.Args["name"])
|
||||||
|
executed++
|
||||||
|
if name := action.Args["name"]; name != "" && ctx.Repo == "" {
|
||||||
|
ctx.Repo = name
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("cli %s: %v", strings.Join(args, " "), err)
|
||||||
|
if cliStderr.Len() > 0 {
|
||||||
|
msg += " — " + stderrStr
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
|
||||||
|
errors = append(errors, msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
executed++
|
||||||
|
if action.Module == "repo" && action.Command == "+create" {
|
||||||
|
if name := action.Args["name"]; name != "" && ctx.Repo == "" {
|
||||||
|
ctx.Repo = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return executed, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionKey(action wf.AIAction) string {
|
||||||
|
raw, err := json.Marshal(action)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("%s:%s:%s:%v:%s:%s", action.Type, action.Module, action.Command, action.Args, action.Method, action.Path)
|
||||||
|
}
|
||||||
|
sum := md5.Sum(raw)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedAPIMethods = map[string]bool{
|
||||||
|
"GET": true, "POST": true, "PATCH": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedCLIModules = map[string]bool{
|
||||||
|
"issue": true, "pr": true, "release": true,
|
||||||
|
"wiki": true, "member": true, "label": true,
|
||||||
|
"milestone": true, "branch": true, "comment": true,
|
||||||
|
"repo": true, "file": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockedCLICommands = map[string]bool{
|
||||||
|
"+delete": true, "+remove": true, "+batch-delete": true,
|
||||||
|
"+fork": true, "+batch-fork": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func isActionAllowed(action wf.AIAction) bool {
|
||||||
|
if action.Type == "api" {
|
||||||
|
if !allowedAPIMethods[action.Method] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action.Type == "cli" {
|
||||||
|
if !allowedCLIModules[action.Module] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if blockedCLICommands[action.Command] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestActionAllowed(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
action wf.AIAction
|
||||||
|
allowed bool
|
||||||
|
}{
|
||||||
|
{"api GET", wf.AIAction{Type: "api", Method: "GET"}, true},
|
||||||
|
{"api POST", wf.AIAction{Type: "api", Method: "POST"}, true},
|
||||||
|
{"api PATCH", wf.AIAction{Type: "api", Method: "PATCH"}, true},
|
||||||
|
{"api DELETE blocked", wf.AIAction{Type: "api", Method: "DELETE"}, false},
|
||||||
|
{"cli issue comment", wf.AIAction{Type: "cli", Module: "issue", Command: "+comment"}, true},
|
||||||
|
{"cli delete blocked", wf.AIAction{Type: "cli", Module: "repo", Command: "+delete"}, false},
|
||||||
|
{"cli fork blocked", wf.AIAction{Type: "cli", Module: "repo", Command: "+fork"}, false},
|
||||||
|
{"cli repo module blocked", wf.AIAction{Type: "cli", Module: "org", Command: "+list"}, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := isActionAllowed(tc.action); got != tc.allowed {
|
||||||
|
t.Errorf("isActionAllowed(%+v) = %v, want %v", tc.action, got, tc.allowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunWithAPISteps(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||||
|
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
|
||||||
|
{"id": 1, "subject": "bug"},
|
||||||
|
{"id": 2, "subject": "feature"},
|
||||||
|
}, nil))
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/labels.json":
|
||||||
|
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
|
||||||
|
{"id": 10, "name": "bug"},
|
||||||
|
{"id": 11, "name": "enhancement"},
|
||||||
|
}, nil))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx := newTestContext(t, server)
|
||||||
|
wfDef := &wf.WorkflowDef{
|
||||||
|
Name: "test-api",
|
||||||
|
Steps: []wf.StepDef{
|
||||||
|
{Type: wf.StepTypeAPI, Name: "fetch-issues", Purpose: "get issues", Method: "GET", Target: "{v1}/issues"},
|
||||||
|
{Type: wf.StepTypeAPI, Name: "fetch-labels", Purpose: "get labels", Method: "GET", Target: "{v1}/labels"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Run(ctx, wfDef, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.Owner != "owner" || result.Repo != "repo" {
|
||||||
|
t.Fatalf("expected owner/repo = owner/repo, got %s/%s", result.Owner, result.Repo)
|
||||||
|
}
|
||||||
|
if len(result.Steps) != 2 {
|
||||||
|
t.Fatalf("expected 2 step results, got %d", len(result.Steps))
|
||||||
|
}
|
||||||
|
for _, sr := range result.Steps {
|
||||||
|
if !sr.OK {
|
||||||
|
t.Fatalf("step %q: expected ok=true, got error=%q", sr.Step, sr.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillStepReceivesUpstream(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/owner/repo/issues.json" {
|
||||||
|
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
|
||||||
|
"issues": []map[string]interface{}{{"id": 1}},
|
||||||
|
}, nil))
|
||||||
|
} else if r.URL.Path == "/v1/owner/repo/labels.json" {
|
||||||
|
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
|
||||||
|
"labels": []map[string]interface{}{{"name": "bug"}},
|
||||||
|
}, nil))
|
||||||
|
} else {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx := newTestContext(t, server)
|
||||||
|
wfDef := &wf.WorkflowDef{
|
||||||
|
Name: "test-skill-upstream",
|
||||||
|
Steps: []wf.StepDef{
|
||||||
|
{Type: wf.StepTypeAPI, Name: "get-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
|
||||||
|
{Type: wf.StepTypeAPI, Name: "get-labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
|
||||||
|
{Type: wf.StepTypeSkill, Name: "ai-triage", Purpose: "triage", Target: "gitlink-triage"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Run(ctx, wfDef, true) // dry-run to test upstream without AI
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillData, ok := result.Steps[2].Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("skill step data is not a map")
|
||||||
|
}
|
||||||
|
if v, _ := skillData["_dry_run"]; v != true {
|
||||||
|
t.Fatal("skill step should have _dry_run=true")
|
||||||
|
}
|
||||||
|
upstream, ok := skillData["_upstream"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("skill step missing _upstream map")
|
||||||
|
}
|
||||||
|
if _, hasIssues := upstream["get-issues"]; !hasIssues {
|
||||||
|
t.Fatal("_upstream missing get-issues key")
|
||||||
|
}
|
||||||
|
if _, hasLabels := upstream["get-labels"]; !hasLabels {
|
||||||
|
t.Fatal("_upstream missing get-labels key")
|
||||||
|
}
|
||||||
|
if skillData["_skill"] != "gitlink-triage" {
|
||||||
|
t.Fatalf("_skill = %q, want %q", skillData["_skill"], "gitlink-triage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillStepWithDependsOn(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx := newTestContext(t, server)
|
||||||
|
wfDef := &wf.WorkflowDef{
|
||||||
|
Name: "test-depends-on",
|
||||||
|
Steps: []wf.StepDef{
|
||||||
|
{Type: wf.StepTypeAPI, Name: "open-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
|
||||||
|
{Type: wf.StepTypeAPI, Name: "labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
|
||||||
|
{Type: wf.StepTypeAPI, Name: "members", Purpose: "members", Method: "GET", Target: "{v1}/members"},
|
||||||
|
{Type: wf.StepTypeSkill, Name: "triage", Purpose: "triage", Target: "gitlink-triage",
|
||||||
|
DependsOn: []string{"open-issues", "labels"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Run(ctx, wfDef, true) // dry-run to test DependsOn filter without AI
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillData, ok := result.Steps[3].Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("skill step data is not a map")
|
||||||
|
}
|
||||||
|
upstream, ok := skillData["_upstream"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("skill step missing _upstream map")
|
||||||
|
}
|
||||||
|
if _, hasIssues := upstream["open-issues"]; !hasIssues {
|
||||||
|
t.Fatal("_upstream missing open-issues key")
|
||||||
|
}
|
||||||
|
if _, hasLabels := upstream["labels"]; !hasLabels {
|
||||||
|
t.Fatal("_upstream missing labels key")
|
||||||
|
}
|
||||||
|
if _, hasMembers := upstream["members"]; hasMembers {
|
||||||
|
t.Fatal("_upstream should NOT contain members (not in DependsOn)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunStepFailure(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(500)
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"ok": false, "error": "internal server error",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx := newTestContext(t, server)
|
||||||
|
wfDef := &wf.WorkflowDef{
|
||||||
|
Name: "test-fail",
|
||||||
|
Steps: []wf.StepDef{
|
||||||
|
{Type: wf.StepTypeAPI, Name: "bad-step", Purpose: "will fail", Method: "GET", Target: "{v1}/bad"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Run(ctx, wfDef, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() returned error: %v (steps should fail gracefully)", err)
|
||||||
|
}
|
||||||
|
if result.Steps[0].OK {
|
||||||
|
t.Fatal("expected step to fail, but it passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUnknownStepType(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatalf("no request expected")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx := newTestContext(t, server)
|
||||||
|
wfDef := &wf.WorkflowDef{
|
||||||
|
Name: "test-unknown",
|
||||||
|
Steps: []wf.StepDef{
|
||||||
|
{Type: wf.StepType("invalid"), Name: "bad", Purpose: "unknown", Target: "x"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Run(ctx, wfDef, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Steps[0].OK {
|
||||||
|
t.Fatal("unknown step type should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillStepDryRun(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx := newTestContext(t, server)
|
||||||
|
wfDef := &wf.WorkflowDef{
|
||||||
|
Name: "test-dry-run",
|
||||||
|
Steps: []wf.StepDef{
|
||||||
|
{Type: wf.StepTypeAPI, Name: "get-data", Purpose: "data", Method: "GET", Target: "{v1}/issues"},
|
||||||
|
{Type: wf.StepTypeSkill, Name: "ai-step", Purpose: "AI analysis", Target: "gitlink-triage",
|
||||||
|
DependsOn: []string{"get-data"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Run(ctx, wfDef, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() dry-run failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillData, ok := result.Steps[1].Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("skill step data is not a map")
|
||||||
|
}
|
||||||
|
if v, _ := skillData["_dry_run"]; v != true {
|
||||||
|
t.Fatal("dry-run skill step should have _dry_run=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestContext(t *testing.T, server *httptest.Server) *common.RuntimeContext {
|
||||||
|
t.Helper()
|
||||||
|
return &common.RuntimeContext{
|
||||||
|
Client: &client.Client{
|
||||||
|
HTTP: server.Client(),
|
||||||
|
BaseURL: server.URL,
|
||||||
|
},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: map[string]string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||||
|
t.Fatalf("failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run executes every step in a workflow sequentially.
|
||||||
|
func Run(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, dryRun bool) (*wf.WorkflowResult, error) {
|
||||||
|
return RunWithMode(ctx, wfDef, dryRun, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithMode executes a workflow with explicit AI mode control.
|
||||||
|
func RunWithMode(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, dryRun bool, aiMode string) (*wf.WorkflowResult, error) {
|
||||||
|
if aiMode != "" {
|
||||||
|
ctx.AIMode = aiMode
|
||||||
|
}
|
||||||
|
return run(ctx, wfDef, dryRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, dryRun bool) (*wf.WorkflowResult, error) {
|
||||||
|
if !shouldSkipOwnerRepoResolve(ctx, wfDef) {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve repo: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Args == nil {
|
||||||
|
ctx.Args = make(map[string]string)
|
||||||
|
}
|
||||||
|
if _, ok := ctx.Args["dry_run"]; !ok && dryRun {
|
||||||
|
ctx.Args["dry_run"] = "true"
|
||||||
|
}
|
||||||
|
ctx.Args["__wf_name"] = wfDef.Name
|
||||||
|
|
||||||
|
if ctx.Owner != "" {
|
||||||
|
ctx.Args["_owner"] = ctx.Owner
|
||||||
|
}
|
||||||
|
if ctx.Repo != "" {
|
||||||
|
ctx.Args["_repo"] = ctx.Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
var workflowState *state.WorkflowState
|
||||||
|
if !dryRun {
|
||||||
|
workflowState, _ = state.LoadState(wfDef.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]wf.StepResult, 0, len(wfDef.Steps))
|
||||||
|
for _, step := range wfDef.Steps {
|
||||||
|
var upstreamHash string
|
||||||
|
if !dryRun && workflowState != nil {
|
||||||
|
upstream := collectUpstream(ctx, step)
|
||||||
|
upstreamHash = state.HashData(upstream)
|
||||||
|
}
|
||||||
|
|
||||||
|
isDaemon := ctx.Arg("daemon-loop") == "true"
|
||||||
|
if isDaemon && !dryRun && workflowState != nil && step.RunWhen != "" && step.RunWhen != wf.RunAlways {
|
||||||
|
shouldRun, skipReason := checkPhaseCondition(step, workflowState, upstreamHash)
|
||||||
|
if !shouldRun {
|
||||||
|
fmt.Fprintf(os.Stderr, "[%s] 跳过 %s: %s\n", wfDef.Name, step.Name, skipReason)
|
||||||
|
results = append(results, wf.StepResult{
|
||||||
|
Step: step.Name,
|
||||||
|
Purpose: step.Purpose,
|
||||||
|
Type: step.Type,
|
||||||
|
OK: true,
|
||||||
|
Skipped: true,
|
||||||
|
SkipReason: skipReason,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sr := ExecuteStep(ctx, step, dryRun)
|
||||||
|
results = append(results, *sr)
|
||||||
|
|
||||||
|
if sr.OK && !sr.Skipped && step.Name == "init-scaffold" && ctx.Repo == "" {
|
||||||
|
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||||
|
if a, ok := m["analysis"].(map[string]interface{}); ok {
|
||||||
|
if r, ok := a["repo"].(string); ok && r != "" {
|
||||||
|
parts := strings.SplitN(r, "/", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
ctx.Repo = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dryRun && workflowState != nil && sr.OK && !sr.Skipped {
|
||||||
|
if workflowState.PhaseLastRun == nil {
|
||||||
|
workflowState.PhaseLastRun = make(map[string]string)
|
||||||
|
}
|
||||||
|
workflowState.PhaseLastRun[step.Name] = time.Now().Format(time.RFC3339)
|
||||||
|
if workflowState.PhaseUpstream == nil {
|
||||||
|
workflowState.PhaseUpstream = make(map[string]string)
|
||||||
|
}
|
||||||
|
workflowState.PhaseUpstream[step.Name] = upstreamHash
|
||||||
|
}
|
||||||
|
|
||||||
|
if sr.Data != nil {
|
||||||
|
raw, err := json.Marshal(sr.Data)
|
||||||
|
if err == nil {
|
||||||
|
ctx.Args[step.Name] = string(raw)
|
||||||
|
} else {
|
||||||
|
ctx.Args[step.Name] = fmt.Sprint(sr.Data)
|
||||||
|
}
|
||||||
|
} else if !sr.OK && sr.Error != "" {
|
||||||
|
ctx.Args[step.Name] = fmt.Sprintf(`{"_error": true, "_message": %q}`, sr.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dryRun && workflowState != nil {
|
||||||
|
workflowState.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &wf.WorkflowResult{
|
||||||
|
Workflow: wfDef.Name,
|
||||||
|
Owner: ctx.Owner,
|
||||||
|
Repo: ctx.Repo,
|
||||||
|
Steps: results,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkipOwnerRepoResolve(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) bool {
|
||||||
|
if wfDef != nil && wfDef.Name == "project-init" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isExplicitMultiRepoRun(ctx, wfDef)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExplicitMultiRepoRun reports whether this is a multi-repo run with explicit
|
||||||
|
// --repos or --from flags (as opposed to resolving from the current directory).
|
||||||
|
func IsExplicitMultiRepoRun(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) bool {
|
||||||
|
return isExplicitMultiRepoRun(ctx, wfDef)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExplicitMultiRepoRun(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) bool {
|
||||||
|
if wfDef == nil || wfDef.Name != "multi-repo" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ctx.Arg("repos") != "" || ctx.Arg("from") != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPhaseCondition(step wf.StepDef, workflowState *state.WorkflowState, upstreamHash string) (bool, string) {
|
||||||
|
switch step.RunWhen {
|
||||||
|
case wf.RunWeekly:
|
||||||
|
last := workflowState.PhaseLastRun[step.Name]
|
||||||
|
if last == "" {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, last)
|
||||||
|
if err != nil {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
if time.Since(t) >= 7*24*time.Hour {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
next := t.Add(7 * 24 * time.Hour)
|
||||||
|
return false, fmt.Sprintf("下次运行: %s", next.Format("01-02 15:04"))
|
||||||
|
case wf.RunOnChange:
|
||||||
|
if prev, ok := workflowState.PhaseUpstream[step.Name]; ok && prev == upstreamHash {
|
||||||
|
return false, "数据无变化"
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
default:
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/ai"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
func executeSkillStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult, dryRun bool) {
|
||||||
|
upstream := collectUpstream(ctx, step)
|
||||||
|
|
||||||
|
if step.Target == "gitlink-review" {
|
||||||
|
state.EnrichWithPRDiffs(upstream, ctx.Owner, ctx.Repo)
|
||||||
|
state.FilterReviewedPRs(upstream, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
sr.OK = true
|
||||||
|
sr.Data = map[string]interface{}{
|
||||||
|
"_skill": step.Target,
|
||||||
|
"_dry_run": true,
|
||||||
|
"_depends_on": step.DependsOn,
|
||||||
|
"_upstream": upstream,
|
||||||
|
"_hint": "预览模式:展示将要传给 AI/规则引擎 的上游数据,不实际执行。",
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
aiMode := resolveAIMode(ctx)
|
||||||
|
client := ai.NewAIClient()
|
||||||
|
var aiResp *wf.AIResponse
|
||||||
|
var usedAI bool
|
||||||
|
var aiAnalysis interface{}
|
||||||
|
|
||||||
|
switch aiMode {
|
||||||
|
case wf.AIModeNoAI:
|
||||||
|
resp, err := runRuleEngine(step, upstream)
|
||||||
|
if err != nil {
|
||||||
|
sr.OK = true
|
||||||
|
sr.Data = map[string]interface{}{
|
||||||
|
"_skill": step.Target,
|
||||||
|
"_needs_ai": true,
|
||||||
|
"_upstream": upstream,
|
||||||
|
"_error": fmt.Sprintf("rule engine failed: %v", err),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aiResp = resp
|
||||||
|
|
||||||
|
case wf.AIModeAI:
|
||||||
|
if !client.HasKey() {
|
||||||
|
sr.OK = false
|
||||||
|
sr.Error = "AI 模式需要配置 API Key(设置 DEEPSEEK_API_KEY 环境变量或 config set deepseek_api_key)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := callAI(client, step, upstream)
|
||||||
|
if err != nil {
|
||||||
|
sr.OK = false
|
||||||
|
sr.Error = fmt.Sprintf("AI 调用失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aiResp = resp
|
||||||
|
usedAI = true
|
||||||
|
|
||||||
|
default:
|
||||||
|
if client.HasKey() {
|
||||||
|
resp, err := callAI(client, step, upstream)
|
||||||
|
if err == nil {
|
||||||
|
aiResp = resp
|
||||||
|
usedAI = true
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "[workflow] AI 调用失败,降级到规则引擎: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if aiResp == nil {
|
||||||
|
resp, err := runRuleEngine(step, upstream)
|
||||||
|
if err != nil {
|
||||||
|
sr.OK = true
|
||||||
|
sr.Data = map[string]interface{}{
|
||||||
|
"_skill": step.Target,
|
||||||
|
"_needs_ai": true,
|
||||||
|
"_upstream": upstream,
|
||||||
|
"_error": fmt.Sprintf("AI 和规则引擎均失败: %v", err),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aiResp = resp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if usedAI && step.Target == "gitlink-triage" {
|
||||||
|
if ruleResp, err := runRuleEngine(step, upstream); err == nil {
|
||||||
|
aiAnalysis = aiResp.Analysis
|
||||||
|
aiResp.Actions = ruleResp.Actions
|
||||||
|
aiResp.Analysis = ruleResp.Analysis
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "[workflow] triage rule action fallback failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if usedAI && step.Target == "gitlink-init-scaffold" {
|
||||||
|
if suggested := extractRepoNameFromAIAnalysis(aiResp.Analysis); suggested != "" {
|
||||||
|
upstream["_repo"] = suggested
|
||||||
|
}
|
||||||
|
if ruleResp, err := runRuleEngine(step, upstream); err == nil {
|
||||||
|
aiAnalysis = aiResp.Analysis
|
||||||
|
aiResp.Actions = ruleResp.Actions
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "[workflow] init-scaffold rule fallback failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if usedAI && step.Name == "health-report" {
|
||||||
|
if ruleResp, err := runRuleEngine(step, upstream); err == nil && len(ruleResp.Actions) > 0 {
|
||||||
|
content := ""
|
||||||
|
if s, ok := aiResp.Analysis.(string); ok && s != "" {
|
||||||
|
content = s
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
|
||||||
|
content = "# 项目健康度报告\n\n" + string(b) + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range ruleResp.Actions {
|
||||||
|
if a.Module == "wiki" && a.Command == "+create" {
|
||||||
|
if content != "" {
|
||||||
|
a.Args["content"] = content
|
||||||
|
}
|
||||||
|
aiResp.Actions = append(aiResp.Actions, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if usedAI && step.Name == "contributor-ranking" {
|
||||||
|
content := ""
|
||||||
|
if s, ok := aiResp.Analysis.(string); ok {
|
||||||
|
content = s
|
||||||
|
} else if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
|
||||||
|
content = "# 贡献者排行榜\n\n```json\n" + string(b) + "\n```\n"
|
||||||
|
}
|
||||||
|
if content != "" {
|
||||||
|
pageName := "贡献者排行榜 " + time.Now().Format("2006-01-02")
|
||||||
|
aiResp.Actions = append(aiResp.Actions,
|
||||||
|
wf.AIAction{
|
||||||
|
Type: "cli", Module: "wiki", Command: "+create",
|
||||||
|
Args: map[string]string{
|
||||||
|
"name": pageName,
|
||||||
|
"content": content,
|
||||||
|
"message": "自动生成贡献者排行榜",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wf.AIAction{
|
||||||
|
Type: "cli", Module: "wiki", Command: "+update",
|
||||||
|
Args: map[string]string{
|
||||||
|
"name": pageName,
|
||||||
|
"content": content,
|
||||||
|
"message": "自动更新贡献者排行榜",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if usedAI && step.Name == "multi-repo-coordination" {
|
||||||
|
if ruleResp, err := runRuleEngine(step, upstream); err == nil && len(ruleResp.Actions) > 0 {
|
||||||
|
content := ""
|
||||||
|
if s, ok := aiResp.Analysis.(string); ok && s != "" {
|
||||||
|
content = s
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
|
||||||
|
content = "# 多仓库协同报告\n\n```json\n" + string(b) + "\n```\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range ruleResp.Actions {
|
||||||
|
if a.Module == "wiki" && a.Command == "+create" {
|
||||||
|
if content != "" {
|
||||||
|
a.Args["content"] = content
|
||||||
|
}
|
||||||
|
aiResp.Actions = append(aiResp.Actions, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
executed, actionErrors := executeActions(ctx, aiResp.Actions)
|
||||||
|
|
||||||
|
if step.Target == "gitlink-review" && !dryRun {
|
||||||
|
state.SaveReviewedPRFingerprints(upstream, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
sr.OK = executed > 0 || len(aiResp.Actions) == 0
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"ok": sr.OK,
|
||||||
|
"analysis": aiResp.Analysis,
|
||||||
|
"executed": executed,
|
||||||
|
"_ai_used": usedAI,
|
||||||
|
"_skill": step.Target,
|
||||||
|
}
|
||||||
|
if len(actionErrors) > 0 {
|
||||||
|
data["errors"] = actionErrors
|
||||||
|
}
|
||||||
|
if aiAnalysis != nil {
|
||||||
|
data["ai_analysis"] = aiAnalysis
|
||||||
|
}
|
||||||
|
sr.Data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAIMode(ctx *common.RuntimeContext) wf.AIMode {
|
||||||
|
switch ctx.AIMode {
|
||||||
|
case "ai":
|
||||||
|
return wf.AIModeAI
|
||||||
|
case "no-ai":
|
||||||
|
return wf.AIModeNoAI
|
||||||
|
default:
|
||||||
|
return wf.AIModeAuto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func callAI(client *ai.AIClient, step wf.StepDef, upstream map[string]interface{}) (*wf.AIResponse, error) {
|
||||||
|
skillMD := readSkillDoc(step.Target)
|
||||||
|
upstreamJSON, _ := json.MarshalIndent(upstream, "", " ")
|
||||||
|
return client.Analyze(&wf.AIRequest{
|
||||||
|
SystemPrompt: skillMD,
|
||||||
|
UserData: string(upstreamJSON),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractRepoNameFromAIAnalysis(analysis interface{}) string {
|
||||||
|
m, ok := analysis.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, key := range []string{"repo_name", "repo", "_repo", "name", "suggested_name"} {
|
||||||
|
if v, ok := m[key].(string); ok && v != "" {
|
||||||
|
v = strings.ToLower(strings.TrimSpace(v))
|
||||||
|
v = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(v, "-")
|
||||||
|
v = regexp.MustCompile(`-+`).ReplaceAllString(v, "-")
|
||||||
|
v = strings.Trim(v, "-")
|
||||||
|
if len(v) >= 2 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRuleEngine(step wf.StepDef, upstream map[string]interface{}) (*wf.AIResponse, error) {
|
||||||
|
engine, ok := wf.RuleEngines[step.Target]
|
||||||
|
if !ok {
|
||||||
|
return nil, wf.ErrNoRuleEngine(step.Target)
|
||||||
|
}
|
||||||
|
return engine(upstream, step.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectUpstream(ctx *common.RuntimeContext, step wf.StepDef) map[string]interface{} {
|
||||||
|
upstream := make(map[string]interface{})
|
||||||
|
|
||||||
|
for k, v := range ctx.Args {
|
||||||
|
if strings.HasPrefix(k, "_") {
|
||||||
|
upstream[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dep := range step.DependsOn {
|
||||||
|
if v, ok := ctx.Args[dep]; ok {
|
||||||
|
var parsed interface{}
|
||||||
|
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
|
||||||
|
upstream[dep] = parsed
|
||||||
|
} else {
|
||||||
|
upstream[dep] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(step.DependsOn) == 0 {
|
||||||
|
for k, v := range ctx.Args {
|
||||||
|
var parsed interface{}
|
||||||
|
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
|
||||||
|
upstream[k] = parsed
|
||||||
|
} else {
|
||||||
|
upstream[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return upstream
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSkillDoc(target string) string {
|
||||||
|
paths := []string{}
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
exeDir := filepath.Dir(exe)
|
||||||
|
paths = append(paths, filepath.Join(exeDir, "skills", target, "SKILL.md"))
|
||||||
|
paths = append(paths, filepath.Join(exeDir, "shortcuts", "workflow", "skills", target, "SKILL.md"))
|
||||||
|
}
|
||||||
|
paths = append(paths,
|
||||||
|
filepath.Join("skills", target, "SKILL.md"),
|
||||||
|
filepath.Join("shortcuts", "workflow", "skills", target, "SKILL.md"),
|
||||||
|
filepath.Join("/etc/gitlink-cli/skills", target, "SKILL.md"),
|
||||||
|
)
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err == nil {
|
||||||
|
paths = append(paths, filepath.Join(home, ".config", "gitlink-cli", "skills", target, "SKILL.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err == nil {
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("# %s\n\nSkill documentation not found.", target)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecuteStep dispatches a step to the right executor based on its Type.
|
||||||
|
func ExecuteStep(ctx *common.RuntimeContext, step wf.StepDef, dryRun bool) *wf.StepResult {
|
||||||
|
sr := &wf.StepResult{
|
||||||
|
Step: step.Name,
|
||||||
|
Purpose: step.Purpose,
|
||||||
|
Type: step.Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch step.Type {
|
||||||
|
case wf.StepTypeAPI:
|
||||||
|
executeAPIStep(ctx, step, sr)
|
||||||
|
case wf.StepTypeCommand:
|
||||||
|
executeCommandStep(ctx, step, sr)
|
||||||
|
case wf.StepTypeSkill:
|
||||||
|
executeSkillStep(ctx, step, sr, dryRun)
|
||||||
|
default:
|
||||||
|
sr.OK = false
|
||||||
|
sr.Error = fmt.Sprintf("unknown step type: %q", step.Type)
|
||||||
|
}
|
||||||
|
return sr
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeAPIStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult) {
|
||||||
|
path := wf.ResolvePath(step.Target, ctx.Owner, ctx.Repo)
|
||||||
|
env, err := ctx.CallAPIWithQuery(step.Method, path, step.Query)
|
||||||
|
if err != nil {
|
||||||
|
sr.OK = false
|
||||||
|
sr.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
sr.OK = env.OK
|
||||||
|
sr.Data = env.Data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeCommandStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult) {
|
||||||
|
if strings.HasPrefix(step.Target, "workflow-internal:") {
|
||||||
|
executeInternalCommandStep(ctx, step, sr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := wf.ParseCommandTarget(step.Target)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
sr.OK = false
|
||||||
|
sr.Error = fmt.Sprintf("empty command target: %q", step.Target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bin := wf.ResolveCLIBinary()
|
||||||
|
|
||||||
|
args := append(parts, "--format", "json")
|
||||||
|
if ctx.Owner != "" {
|
||||||
|
args = append(args, "--owner", ctx.Owner)
|
||||||
|
}
|
||||||
|
if ctx.Repo != "" {
|
||||||
|
args = append(args, "--repo", ctx.Repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(bin, args...)
|
||||||
|
cmd.Stderr = nil
|
||||||
|
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
sr.OK = false
|
||||||
|
sr.Error = fmt.Sprintf("command failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var data interface{}
|
||||||
|
if err := json.Unmarshal(out, &data); err != nil {
|
||||||
|
sr.OK = true
|
||||||
|
sr.Data = strings.TrimSpace(string(out))
|
||||||
|
} else {
|
||||||
|
sr.OK = true
|
||||||
|
sr.Data = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeInternalCommandStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult) {
|
||||||
|
switch strings.TrimPrefix(step.Target, "workflow-internal:") {
|
||||||
|
case "multi-repo-snapshot":
|
||||||
|
snap, err := snapshot.BuildMultiRepoSnapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
sr.OK = false
|
||||||
|
sr.Error = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sr.OK = true
|
||||||
|
sr.Data = snap
|
||||||
|
default:
|
||||||
|
sr.OK = false
|
||||||
|
sr.Error = fmt.Sprintf("unknown internal workflow command: %q", step.Target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package workflow
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
var registry = map[string]*WorkflowDef{}
|
||||||
|
|
||||||
|
// Register adds a workflow definition to the global registry.
|
||||||
|
func Register(wf *WorkflowDef) {
|
||||||
|
registry[wf.Name] = wf
|
||||||
|
}
|
||||||
|
|
||||||
|
// All returns all registered workflows sorted by name.
|
||||||
|
func All() []*WorkflowDef {
|
||||||
|
names := make([]string, 0, len(registry))
|
||||||
|
for n := range registry {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
result := make([]*WorkflowDef, len(names))
|
||||||
|
for i, n := range names {
|
||||||
|
result[i] = registry[n]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a workflow by name, or nil.
|
||||||
|
func Get(name string) *WorkflowDef {
|
||||||
|
return registry[name]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoMergeRule merges PRs when code quality criteria are met.
|
||||||
|
// Criteria: no high-severity security findings + CI is healthy.
|
||||||
|
func AutoMergeRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||||
|
prs := extractPRs(upstream, "open-prs")
|
||||||
|
if len(prs) == 0 {
|
||||||
|
return &workflow.AIResponse{
|
||||||
|
Analysis: map[string]interface{}{
|
||||||
|
"merged": 0,
|
||||||
|
"reason": "没有开放的 PR",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check CI health from ci-diagnosis step result.
|
||||||
|
ciHealthy := true
|
||||||
|
if ciData, ok := upstream["ci-diagnosis"].(map[string]interface{}); ok {
|
||||||
|
if ciAnalysis, ok := ciData["analysis"].(map[string]interface{}); ok {
|
||||||
|
ciHealthy = IsCIHealthy(ciAnalysis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for high-severity security findings from review step.
|
||||||
|
hasHighSeverity := false
|
||||||
|
if reviewData, ok := upstream["review"].(map[string]interface{}); ok {
|
||||||
|
if reviewAnalysis, ok := reviewData["analysis"].(map[string]interface{}); ok {
|
||||||
|
hasHighSeverity = hasHighSeverityFindings(reviewAnalysis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasHighSeverity {
|
||||||
|
return &workflow.AIResponse{
|
||||||
|
Analysis: map[string]interface{}{
|
||||||
|
"merged": 0,
|
||||||
|
"reason": "Review 发现高危安全问题,阻止自动合并",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ciHealthy {
|
||||||
|
return &workflow.AIResponse{
|
||||||
|
Analysis: map[string]interface{}{
|
||||||
|
"merged": 0,
|
||||||
|
"reason": "CI 构建未通过,阻止自动合并",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// All checks passed — merge each open PR.
|
||||||
|
var actions []workflow.AIAction
|
||||||
|
openCount := 0
|
||||||
|
for _, pr := range prs {
|
||||||
|
// Only merge open PRs.
|
||||||
|
status := str(pr, "pull_request_status", "pull_request_staus", "status", "state")
|
||||||
|
if status != "" && status != "open" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
openCount++
|
||||||
|
prNum := interfaceToString(pr["pull_request_number"])
|
||||||
|
if prNum == "" {
|
||||||
|
prNum = interfaceToString(pr["id"])
|
||||||
|
}
|
||||||
|
if prNum == "" {
|
||||||
|
prNum = interfaceToString(pr["number"])
|
||||||
|
}
|
||||||
|
if prNum == "" {
|
||||||
|
prNum = interfaceToString(pr["pull_request_id"])
|
||||||
|
}
|
||||||
|
if prNum == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
actions = append(actions, workflow.AIAction{
|
||||||
|
Type: "cli",
|
||||||
|
Module: "pr",
|
||||||
|
Command: "+merge",
|
||||||
|
Args: map[string]string{
|
||||||
|
"id": prNum,
|
||||||
|
"method": "squash",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(actions) == 0 {
|
||||||
|
reason := "无法解析 PR 编号"
|
||||||
|
if openCount == 0 {
|
||||||
|
reason = "没有开放的 PR"
|
||||||
|
}
|
||||||
|
return &workflow.AIResponse{
|
||||||
|
Analysis: map[string]interface{}{
|
||||||
|
"merged": 0,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &workflow.AIResponse{
|
||||||
|
Analysis: map[string]interface{}{
|
||||||
|
"merged": len(actions),
|
||||||
|
"reason": fmt.Sprintf("质量达标,已合并 %d 个 PR", len(actions)),
|
||||||
|
},
|
||||||
|
Actions: actions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasHighSeverityFindings checks if the review analysis contains any
|
||||||
|
// high-severity security findings that should block auto-merge.
|
||||||
|
func hasHighSeverityFindings(analysis map[string]interface{}) bool {
|
||||||
|
findings, ok := analysis["findings"]
|
||||||
|
if !ok || findings == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle []finding (direct from rule engine).
|
||||||
|
if fList, ok := findings.([]finding); ok {
|
||||||
|
for _, f := range fList {
|
||||||
|
if f.Severity == "high" && f.Lens == "security" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle []interface{} (after JSON round-trip).
|
||||||
|
if fList, ok := findings.([]interface{}); ok {
|
||||||
|
for _, item := range fList {
|
||||||
|
if fm, ok := item.(map[string]interface{}); ok {
|
||||||
|
sev := ""
|
||||||
|
lens := ""
|
||||||
|
if s, ok := fm["severity"].(string); ok {
|
||||||
|
sev = s
|
||||||
|
}
|
||||||
|
if l, ok := fm["lens"].(string); ok {
|
||||||
|
lens = l
|
||||||
|
}
|
||||||
|
if sev == "high" && lens == "security" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// commitGroup holds category → commits mapping for changelog generation.
|
||||||
|
type commitGroup struct {
|
||||||
|
Category string
|
||||||
|
Emoji string
|
||||||
|
Commits []map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var changelogRules = []struct {
|
||||||
|
emoji string
|
||||||
|
name string
|
||||||
|
patterns []*regexp.Regexp
|
||||||
|
}{
|
||||||
|
{"✨", "新功能", []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`^(feat|feature|add|新增|支持)(\(.+\))?!?[::]`),
|
||||||
|
}},
|
||||||
|
{"🐛", "Bug 修复", []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`^(fix|bugfix|hotfix|修复|解决)(\(.+\))?!?[::]`),
|
||||||
|
}},
|
||||||
|
{"🔧", "改进优化", []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`^(refactor|perf|improve|enhance|style|fmt|optimize|优化|增强|完善|调整|格式化)(\(.+\))?!?[::]`),
|
||||||
|
}},
|
||||||
|
{"📚", "文档", []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`^(docs|doc|文档|README|注释)(\(.+\))?!?[::]`),
|
||||||
|
}},
|
||||||
|
{"🧪", "测试", []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`^(test|tests|测试)(\(.+\))?!?[::]`),
|
||||||
|
}},
|
||||||
|
{"🏗️", "构建/CI", []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`^(build|ci|chore|构建|部署|Docker)(\(.+\))?!?[::]`),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var breakingRe = regexp.MustCompile(`!:`)
|
||||||
|
var breakingBodyRe = regexp.MustCompile(`BREAKING[ -]CHANGE`)
|
||||||
|
|
||||||
|
// ChangelogRule classifies commits and generates release notes.
|
||||||
|
func ChangelogRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||||
|
commits := extractCommits(upstream)
|
||||||
|
releases := extractList(upstream, "releases")
|
||||||
|
mergedPRs := extractPRs(upstream, "merged-prs")
|
||||||
|
|
||||||
|
groups := []commitGroup{}
|
||||||
|
breaking := []map[string]interface{}{}
|
||||||
|
uncategorized := []map[string]interface{}{}
|
||||||
|
|
||||||
|
for _, c := range commits {
|
||||||
|
msg := str(c, "title", "message", "commit", "subject")
|
||||||
|
body := str(c, "body", "description")
|
||||||
|
if msg == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check breaking change.
|
||||||
|
if breakingRe.MatchString(msg) || breakingBodyRe.MatchString(body) {
|
||||||
|
breaking = append(breaking, c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
categorized := false
|
||||||
|
for i, rule := range changelogRules {
|
||||||
|
for _, re := range rule.patterns {
|
||||||
|
if re.MatchString(msg) {
|
||||||
|
// Extend existing group or create new.
|
||||||
|
found := false
|
||||||
|
for j, g := range groups {
|
||||||
|
if g.Category == rule.name {
|
||||||
|
groups[j].Commits = append(groups[j].Commits, c)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
groups = append(groups, commitGroup{
|
||||||
|
Category: rule.name,
|
||||||
|
Emoji: rule.emoji,
|
||||||
|
Commits: []map[string]interface{}{c},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = i // suppress unused
|
||||||
|
categorized = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if categorized {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !categorized {
|
||||||
|
uncategorized = append(uncategorized, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort groups: features first, then bug fixes, then rest.
|
||||||
|
sort.SliceStable(groups, func(i, j int) bool {
|
||||||
|
return orderOf(groups[i].Category) < orderOf(groups[j].Category)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add breaking changes group at top if present.
|
||||||
|
if len(breaking) > 0 {
|
||||||
|
groups = append([]commitGroup{{
|
||||||
|
Category: "破坏性变更",
|
||||||
|
Emoji: "⚠️",
|
||||||
|
Commits: breaking,
|
||||||
|
}}, groups...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add uncategorized at end.
|
||||||
|
if len(uncategorized) > 0 {
|
||||||
|
groups = append(groups, commitGroup{
|
||||||
|
Category: "其他",
|
||||||
|
Emoji: "🔀",
|
||||||
|
Commits: uncategorized,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build analysis.
|
||||||
|
sections := []map[string]interface{}{}
|
||||||
|
for _, g := range groups {
|
||||||
|
items := []string{}
|
||||||
|
for _, c := range g.Commits {
|
||||||
|
msg := str(c, "title", "message", "commit", "subject")
|
||||||
|
sha := str(c, "sha", "id", "commit_id")
|
||||||
|
if sha != "" && len(sha) > 7 {
|
||||||
|
sha = sha[:7]
|
||||||
|
}
|
||||||
|
items = append(items, fmt.Sprintf("%s %s", sha, msg))
|
||||||
|
}
|
||||||
|
sections = append(sections, map[string]interface{}{
|
||||||
|
"category": g.Emoji + " " + g.Category,
|
||||||
|
"count": len(g.Commits),
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
analysis := map[string]interface{}{
|
||||||
|
"total_commits": len(commits),
|
||||||
|
"sections": sections,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build release creation action if there are categorized commits.
|
||||||
|
var actions []workflow.AIAction
|
||||||
|
if len(commits) > 0 {
|
||||||
|
// Determine next version tag.
|
||||||
|
latestTag := "v0.0.0"
|
||||||
|
for _, rel := range releases {
|
||||||
|
if t := str(rel, "tag_name", "tag", "name"); t != "" {
|
||||||
|
if compareTags(t, latestTag) > 0 {
|
||||||
|
latestTag = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also check merged PRs for version hints.
|
||||||
|
for _, pr := range mergedPRs {
|
||||||
|
labels := str(pr, "labels")
|
||||||
|
if strings.Contains(labels, "release") || strings.Contains(labels, "version") {
|
||||||
|
// PR merged with release label — bump version.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTag := bumpTag(latestTag)
|
||||||
|
if len(breaking) > 0 {
|
||||||
|
nextTag = bumpMajor(latestTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := buildChangelogBody(sections, nextTag)
|
||||||
|
|
||||||
|
if nextTag != latestTag {
|
||||||
|
actions = append(actions, workflow.AIAction{
|
||||||
|
Type: "cli",
|
||||||
|
Module: "release",
|
||||||
|
Command: "+create",
|
||||||
|
Args: map[string]string{
|
||||||
|
"tag": nextTag,
|
||||||
|
"name": nextTag,
|
||||||
|
"body": body,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderOf(cat string) int {
|
||||||
|
order := map[string]int{
|
||||||
|
"破坏性变更": 0,
|
||||||
|
"新功能": 1,
|
||||||
|
"Bug 修复": 2,
|
||||||
|
"改进优化": 3,
|
||||||
|
"文档": 4,
|
||||||
|
"测试": 5,
|
||||||
|
"构建/CI": 6,
|
||||||
|
"其他": 7,
|
||||||
|
}
|
||||||
|
if o, ok := order[cat]; ok {
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
return 99
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareTags(a, b string) int {
|
||||||
|
an := normalizeTag(a)
|
||||||
|
bn := normalizeTag(b)
|
||||||
|
if an > bn {
|
||||||
|
return 1
|
||||||
|
} else if an < bn {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTag(t string) string {
|
||||||
|
t = strings.TrimPrefix(t, "v")
|
||||||
|
parts := strings.Split(t, ".")
|
||||||
|
for len(parts) < 3 {
|
||||||
|
parts = append(parts, "0")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func bumpTag(tag string) string {
|
||||||
|
parts := strings.Split(normalizeTag(tag), ".")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return "v0.1.0"
|
||||||
|
}
|
||||||
|
minor := atoi(parts[1])
|
||||||
|
return fmt.Sprintf("v%s.%d.0", parts[0], minor+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bumpMajor(tag string) string {
|
||||||
|
parts := strings.Split(normalizeTag(tag), ".")
|
||||||
|
if len(parts) < 1 {
|
||||||
|
return "v1.0.0"
|
||||||
|
}
|
||||||
|
major := atoi(parts[0])
|
||||||
|
return fmt.Sprintf("v%d.0.0", major+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoi(s string) int {
|
||||||
|
var n int
|
||||||
|
fmt.Sscanf(s, "%d", &n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildChangelogBody(sections []map[string]interface{}, tag string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "# %s\n\n", tag)
|
||||||
|
for _, sec := range sections {
|
||||||
|
fmt.Fprintf(&b, "## %s (%d)\n\n", sec["category"], sec["count"])
|
||||||
|
if items, ok := sec["items"].([]string); ok {
|
||||||
|
for _, item := range items {
|
||||||
|
fmt.Fprintf(&b, "- %s\n", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChangelogConventionalCommits(t *testing.T) {
|
||||||
|
upstream := map[string]interface{}{
|
||||||
|
"commits": map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"sha": "abc12345", "title": "feat: add user login"},
|
||||||
|
map[string]interface{}{"sha": "def12345", "title": "fix: resolve null pointer"},
|
||||||
|
map[string]interface{}{"sha": "ghi12345", "title": "docs: update README"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"releases": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := ChangelogRule(upstream, "changelog")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChangelogRule failed: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Analysis == nil {
|
||||||
|
t.Fatal("expected non-nil Analysis")
|
||||||
|
}
|
||||||
|
|
||||||
|
analysis := resp.Analysis.(map[string]interface{})
|
||||||
|
sections := analysis["sections"].([]map[string]interface{})
|
||||||
|
if len(sections) < 3 {
|
||||||
|
t.Fatalf("expected at least 3 sections (features, bugs, docs), got %d", len(sections))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangelogBreakingChange(t *testing.T) {
|
||||||
|
upstream := map[string]interface{}{
|
||||||
|
"commits": map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"sha": "abc12345", "title": "feat!: drop support for v1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"releases": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := ChangelogRule(upstream, "changelog")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChangelogRule failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
analysis := resp.Analysis.(map[string]interface{})
|
||||||
|
sections := analysis["sections"].([]map[string]interface{})
|
||||||
|
if len(sections) == 0 {
|
||||||
|
t.Fatal("expected breaking changes section")
|
||||||
|
}
|
||||||
|
first := sections[0]
|
||||||
|
if cat := first["category"]; cat == nil {
|
||||||
|
t.Fatal("first section missing category")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangelogChineseKeywords(t *testing.T) {
|
||||||
|
upstream := map[string]interface{}{
|
||||||
|
"commits": map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"sha": "aaa11111", "title": "新增:用户管理模块"},
|
||||||
|
map[string]interface{}{"sha": "bbb11111", "title": "修复:登录页面报错"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"releases": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := ChangelogRule(upstream, "changelog")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChangelogRule failed: %v", err)
|
||||||
|
}
|
||||||
|
analysis := resp.Analysis.(map[string]interface{})
|
||||||
|
// Should have at least 2 sections.
|
||||||
|
sections := analysis["sections"].([]map[string]interface{})
|
||||||
|
if len(sections) < 2 {
|
||||||
|
t.Fatalf("expected at least 2 sections, got %d", len(sections))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangelogNoCommits(t *testing.T) {
|
||||||
|
resp, err := ChangelogRule(map[string]interface{}{}, "changelog")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChangelogRule failed: %v", err)
|
||||||
|
}
|
||||||
|
analysis := resp.Analysis.(map[string]interface{})
|
||||||
|
if v := analysis["total_commits"]; v.(int) != 0 {
|
||||||
|
t.Fatalf("expected 0 total_commits, got %v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangelogOutputFormat(t *testing.T) {
|
||||||
|
resp, err := ChangelogRule(map[string]interface{}{}, "changelog")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChangelogRule failed: %v", err)
|
||||||
|
}
|
||||||
|
var _ *workflow.AIResponse = resp
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CIDiagnosisRule matches CI build error logs against known patterns.
|
||||||
|
func CIDiagnosisRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||||
|
builds := extractList(upstream, "ci-builds")
|
||||||
|
commits := extractCommits(upstream)
|
||||||
|
|
||||||
|
type diagnosis struct {
|
||||||
|
BuildNumber interface{} `json:"build_number"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Pattern string `json:"matched_pattern"`
|
||||||
|
Diagnosis string `json:"diagnosis"`
|
||||||
|
Suggestion string `json:"suggestion"`
|
||||||
|
RelatedSHA string `json:"related_commit"`
|
||||||
|
}
|
||||||
|
var diagnoses []diagnosis
|
||||||
|
var actions []workflow.AIAction
|
||||||
|
|
||||||
|
for _, build := range builds {
|
||||||
|
status := str(build, "status", "state", "result")
|
||||||
|
if status != "failed" && status != "failure" && status != "error" && status != "3" {
|
||||||
|
// Check nested: some APIs use "build" wrapper.
|
||||||
|
if inner, ok := build["build"].(map[string]interface{}); ok {
|
||||||
|
build = inner
|
||||||
|
status = str(build, "status", "state", "result")
|
||||||
|
if status != "failed" && status != "failure" && status != "error" && status != "3" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log := str(build, "log", "logs", "output", "build_log")
|
||||||
|
if log == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
d := diagnoseLog(log)
|
||||||
|
buildNum := build["build_number"]
|
||||||
|
if buildNum == nil {
|
||||||
|
buildNum = build["id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find related commit.
|
||||||
|
relatedSHA := ""
|
||||||
|
for _, c := range commits {
|
||||||
|
cSha := str(c, "sha", "id", "commit_id")
|
||||||
|
if cSha != "" && containsAny(str(c, "title", "message", "commit"), d.Pattern) {
|
||||||
|
relatedSHA = cSha
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnoses = append(diagnoses, diagnosis{
|
||||||
|
BuildNumber: buildNum,
|
||||||
|
Status: status,
|
||||||
|
Pattern: d.Pattern,
|
||||||
|
Diagnosis: d.Diagnosis,
|
||||||
|
Suggestion: d.Suggestion,
|
||||||
|
RelatedSHA: relatedSHA,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-retry for transient failures.
|
||||||
|
if d.Transient {
|
||||||
|
actions = append(actions, workflow.AIAction{
|
||||||
|
Type: "api",
|
||||||
|
Method: "POST",
|
||||||
|
Path: fmt.Sprintf("{v1}/builds/%v/retry", buildNum),
|
||||||
|
Body: map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(diagnoses) == 0 {
|
||||||
|
return &workflow.AIResponse{
|
||||||
|
Analysis: map[string]interface{}{"diagnoses": nil, "message": "no failed builds found"},
|
||||||
|
Actions: nil,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
analysis := map[string]interface{}{
|
||||||
|
"diagnoses": diagnoses,
|
||||||
|
"total_failures": len(diagnoses),
|
||||||
|
}
|
||||||
|
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type logPattern struct {
|
||||||
|
Re *regexp.Regexp
|
||||||
|
Pattern string
|
||||||
|
Diagnosis string
|
||||||
|
Suggestion string
|
||||||
|
Transient bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var ciPatterns = []logPattern{
|
||||||
|
{regexp.MustCompile(`cannot find package|package .* is not in`), "cannot find package",
|
||||||
|
"依赖缺失",
|
||||||
|
"检查 go.mod/package.json 确认依赖已声明",
|
||||||
|
false},
|
||||||
|
{regexp.MustCompile(`syntax error|unexpected token|unexpected EOF`), "syntax error",
|
||||||
|
"语法错误",
|
||||||
|
"检查最近提交中的语法问题",
|
||||||
|
false},
|
||||||
|
{regexp.MustCompile(`permission denied|access denied|forbidden|401|403`), "permission denied",
|
||||||
|
"权限不足",
|
||||||
|
"检查密钥配置和访问权限",
|
||||||
|
false},
|
||||||
|
{regexp.MustCompile(`connection refused|connection reset|no route to host|dial tcp`), "connection refused",
|
||||||
|
"服务不可达",
|
||||||
|
"检查外部服务状态和网络连接",
|
||||||
|
true},
|
||||||
|
{regexp.MustCompile(`out of memory|OOM|killed|signal: killed`), "out of memory",
|
||||||
|
"资源不足(内存溢出)",
|
||||||
|
"优化内存使用或增加构建资源",
|
||||||
|
false},
|
||||||
|
{regexp.MustCompile(`No such file|file not found|not found`), "No such file",
|
||||||
|
"文件缺失",
|
||||||
|
"检查 .devops/ 路径和依赖文件配置",
|
||||||
|
false},
|
||||||
|
{regexp.MustCompile(`docker:.*not found|docker.*command not found`), "docker not found",
|
||||||
|
"Docker 环境缺失",
|
||||||
|
"构建环境未配置 Docker,检查 CI 配置",
|
||||||
|
false},
|
||||||
|
{regexp.MustCompile(`FAIL|exit status [1-9]|Test.*failed`), "exit status 1",
|
||||||
|
"测试失败",
|
||||||
|
"查看测试输出定位失败用例",
|
||||||
|
false},
|
||||||
|
{regexp.MustCompile(`timeout|timed out|deadline exceeded`), "timeout",
|
||||||
|
"构建超时",
|
||||||
|
"优化构建脚本或增加超时时间",
|
||||||
|
true},
|
||||||
|
{regexp.MustCompile(`undefined:|undefined symbol|cannot use|type mismatch`), "undefined:",
|
||||||
|
"编译错误(未定义符号)",
|
||||||
|
"检查导入和类型定义",
|
||||||
|
false},
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnoseLog(log string) logPattern {
|
||||||
|
for _, p := range ciPatterns {
|
||||||
|
if p.Re.MatchString(log) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return logPattern{
|
||||||
|
Pattern: "unknown",
|
||||||
|
Diagnosis: "未知错误",
|
||||||
|
Suggestion: "请人工查看 CI 日志进行诊断",
|
||||||
|
Transient: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAny(s string, patterns ...string) bool {
|
||||||
|
for _, p := range patterns {
|
||||||
|
if p != "" && len(s) > 0 && len(p) > 0 {
|
||||||
|
// Simple substring check.
|
||||||
|
if len(s) >= len(p) {
|
||||||
|
for i := 0; i <= len(s)-len(p); i++ {
|
||||||
|
if s[i:i+len(p)] == p {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCIHealthy checks whether CI diagnosis results indicate no failures.
|
||||||
|
func IsCIHealthy(analysis map[string]interface{}) bool {
|
||||||
|
if msg, ok := analysis["message"].(string); ok && msg == "no failed builds found" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if tf, ok := analysis["total_failures"]; ok {
|
||||||
|
switch v := tf.(type) {
|
||||||
|
case float64:
|
||||||
|
return v == 0
|
||||||
|
case int:
|
||||||
|
return v == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCIDiagnosisPatterns(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
log string
|
||||||
|
pattern string
|
||||||
|
transient bool
|
||||||
|
}{
|
||||||
|
{"cannot find package github.com/foo/bar", "cannot find package", false},
|
||||||
|
{"syntax error: unexpected token at line 42", "syntax error", false},
|
||||||
|
{"permission denied: unable to access /tmp/build", "permission denied", false},
|
||||||
|
{"connection refused: dial tcp 10.0.0.1:8080", "connection refused", true},
|
||||||
|
{"out of memory: process killed", "out of memory", false},
|
||||||
|
{"No such file or directory: .devops/build.yml", "No such file", false},
|
||||||
|
{"FAIL: TestLogin (0.23s)", "exit status 1", false},
|
||||||
|
{"timeout: deadline exceeded after 300s", "timeout", true},
|
||||||
|
{"undefined: UserService in main.go:15", "undefined:", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
d := diagnoseLog(tc.log)
|
||||||
|
if d.Pattern != tc.pattern {
|
||||||
|
t.Errorf("log=%q: expected pattern %q, got %q", tc.log, tc.pattern, d.Pattern)
|
||||||
|
}
|
||||||
|
if d.Transient != tc.transient {
|
||||||
|
t.Errorf("log=%q: expected transient=%v, got %v", tc.log, tc.transient, d.Transient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCIDiagnosisNoFailures(t *testing.T) {
|
||||||
|
upstream := map[string]interface{}{
|
||||||
|
"ci-builds": map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"id": "1", "status": "success", "log": "build passed"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := CIDiagnosisRule(upstream, "ci-diagnosis")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CIDiagnosisRule failed: %v", err)
|
||||||
|
}
|
||||||
|
analysis := resp.Analysis.(map[string]interface{})
|
||||||
|
if msg := analysis["message"]; msg != "no failed builds found" {
|
||||||
|
t.Fatalf("expected 'no failed builds found', got %v", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCIDiagnosisWithFailures(t *testing.T) {
|
||||||
|
upstream := map[string]interface{}{
|
||||||
|
"ci-builds": map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"id": "1", "status": "failed", "log": "connection refused"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"commits": map[string]interface{}{
|
||||||
|
"data": []interface{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := CIDiagnosisRule(upstream, "ci-diagnosis")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CIDiagnosisRule failed: %v", err)
|
||||||
|
}
|
||||||
|
analysis := resp.Analysis.(map[string]interface{})
|
||||||
|
if v := analysis["total_failures"]; v.(int) != 1 {
|
||||||
|
t.Fatalf("expected 1 failure, got %v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,410 @@
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contributorEntry holds per-contributor aggregate data.
|
||||||
|
type contributorEntry struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Commits int `json:"commits"`
|
||||||
|
Issues int `json:"issues"`
|
||||||
|
PRs int `json:"prs"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Trend float64 `json:"trend"`
|
||||||
|
LastActivity string `json:"last_activity"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContributorRankingRule produces a contributor ranking report.
|
||||||
|
func ContributorRankingRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||||
|
commits := extractCommits(upstream)
|
||||||
|
issues := append(extractIssues(upstream, "open-issues"), extractIssues(upstream, "closed-issues")...)
|
||||||
|
prs := extractPRs(upstream, "merged-prs")
|
||||||
|
members := extractMembers(upstream)
|
||||||
|
|
||||||
|
// Aggregate per login.
|
||||||
|
stats := map[string]*contributorEntry{}
|
||||||
|
for _, c := range commits {
|
||||||
|
login := authorLogin(c)
|
||||||
|
if login == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e := ensureEntry(stats, login, members)
|
||||||
|
e.Commits++
|
||||||
|
e.Total++
|
||||||
|
if ts := commitTimestamp(c); ts != "" && ts > e.LastActivity {
|
||||||
|
e.LastActivity = ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, i := range issues {
|
||||||
|
login := authorLogin(i)
|
||||||
|
if login == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e := ensureEntry(stats, login, members)
|
||||||
|
e.Issues++
|
||||||
|
e.Total++
|
||||||
|
if ts := issueTimestamp(i); ts != "" && ts > e.LastActivity {
|
||||||
|
e.LastActivity = ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range prs {
|
||||||
|
login := authorLogin(p)
|
||||||
|
if login == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e := ensureEntry(stats, login, members)
|
||||||
|
e.PRs++
|
||||||
|
e.Total++
|
||||||
|
if ts := prTimestamp(p); ts != "" && ts > e.LastActivity {
|
||||||
|
e.LastActivity = ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate trends using 30-day windows.
|
||||||
|
now := time.Now()
|
||||||
|
cutoff30 := now.Add(-30 * 24 * time.Hour)
|
||||||
|
cutoff60 := now.Add(-60 * 24 * time.Hour)
|
||||||
|
|
||||||
|
recent := countInWindow(commits, cutoff30, now)
|
||||||
|
prev := countInWindow(commits, cutoff60, cutoff30)
|
||||||
|
for login := range stats {
|
||||||
|
rc := recent[login]
|
||||||
|
pc := prev[login]
|
||||||
|
if pc > 0 {
|
||||||
|
stats[login].Trend = float64(rc-pc) / float64(pc) * 100
|
||||||
|
} else if rc > 0 {
|
||||||
|
stats[login].Trend = 100
|
||||||
|
}
|
||||||
|
// Tagging.
|
||||||
|
if stats[login].Trend > 50 {
|
||||||
|
stats[login].Tags = append(stats[login].Tags, "new-star")
|
||||||
|
}
|
||||||
|
if stats[login].LastActivity != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, stats[login].LastActivity)
|
||||||
|
if err == nil && now.Sub(t) > 30*24*time.Hour {
|
||||||
|
stats[login].Tags = append(stats[login].Tags, "churn-risk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by total desc.
|
||||||
|
entries := make([]contributorEntry, 0, len(stats))
|
||||||
|
for _, e := range stats {
|
||||||
|
entries = append(entries, *e)
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Total > entries[j].Total })
|
||||||
|
|
||||||
|
// Build analysis.
|
||||||
|
rankings := make([]map[string]interface{}, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
rankings[i] = map[string]interface{}{
|
||||||
|
"rank": i + 1,
|
||||||
|
"login": e.Login,
|
||||||
|
"name": e.Name,
|
||||||
|
"commits": e.Commits,
|
||||||
|
"issues": e.Issues,
|
||||||
|
"prs": e.PRs,
|
||||||
|
"total": e.Total,
|
||||||
|
"trend": e.Trend,
|
||||||
|
"last_activity": e.LastActivity,
|
||||||
|
"tags": e.Tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
analysis := map[string]interface{}{
|
||||||
|
"title": "贡献者排行榜",
|
||||||
|
"rankings": rankings,
|
||||||
|
"churn_risk": filterByTag(rankings, "churn-risk"),
|
||||||
|
"new_stars": filterByTag(rankings, "new-star"),
|
||||||
|
}
|
||||||
|
// Build wiki page content.
|
||||||
|
wikiContent := buildContributorWiki(analysis, rankings)
|
||||||
|
pageName := "贡献者排行榜 " + time.Now().Format("2006-01-02")
|
||||||
|
actions := []workflow.AIAction{
|
||||||
|
{
|
||||||
|
Type: "cli", Module: "wiki", Command: "+create",
|
||||||
|
Args: map[string]string{
|
||||||
|
"name": pageName,
|
||||||
|
"content": wikiContent,
|
||||||
|
"message": "自动生成贡献者排行榜",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "cli", Module: "wiki", Command: "+update",
|
||||||
|
Args: map[string]string{
|
||||||
|
"name": pageName,
|
||||||
|
"content": wikiContent,
|
||||||
|
"message": "自动更新贡献者排行榜",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureEntry(stats map[string]*contributorEntry, login string, members map[string]string) *contributorEntry {
|
||||||
|
if e, ok := stats[login]; ok {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
e := &contributorEntry{Login: login, Name: members[login]}
|
||||||
|
stats[login] = e
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterByTag(rankings []map[string]interface{}, tag string) []map[string]interface{} {
|
||||||
|
var out []map[string]interface{}
|
||||||
|
for _, r := range rankings {
|
||||||
|
if tags, ok := r["tags"].([]string); ok {
|
||||||
|
for _, t := range tags {
|
||||||
|
if t == tag {
|
||||||
|
out = append(out, r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func countInWindow(commits []map[string]interface{}, start, end time.Time) map[string]int {
|
||||||
|
m := map[string]int{}
|
||||||
|
for _, c := range commits {
|
||||||
|
ts := commitTimestamp(c)
|
||||||
|
if ts == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, ts)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.After(start) && t.Before(end) {
|
||||||
|
m[authorLogin(c)]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func extractCommits(upstream map[string]interface{}) []map[string]interface{} {
|
||||||
|
return extractList(upstream, "commits")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractIssues(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||||||
|
return extractList(upstream, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPRs(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||||||
|
return extractList(upstream, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractMembers(upstream map[string]interface{}) map[string]string {
|
||||||
|
raw, ok := upstream["members"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Unwrap envelope: {"ok": true, "data": {"collaborators": [...]}}
|
||||||
|
if m, ok := raw.(map[string]interface{}); ok {
|
||||||
|
if data, ok := m["data"]; ok {
|
||||||
|
raw = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unwrap inner key: {"members": [...]} or {"collaborators": [...]}
|
||||||
|
if m, ok := raw.(map[string]interface{}); ok {
|
||||||
|
for _, key := range []string{"members", "collaborators"} {
|
||||||
|
if list, ok := m[key]; ok {
|
||||||
|
raw = list
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
members := map[string]string{}
|
||||||
|
list, ok := raw.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return members
|
||||||
|
}
|
||||||
|
for _, item := range list {
|
||||||
|
m, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
login := str(m, "login", "username", "name")
|
||||||
|
id := fmt.Sprint(m["id"])
|
||||||
|
if login != "" && id != "" && id != "0" && id != "<nil>" {
|
||||||
|
members[login] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return members
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractList(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||||||
|
raw, ok := upstream[key]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// upstream values may be stored as an envelope: {"ok": true, "data": [...]}
|
||||||
|
if m, ok := raw.(map[string]interface{}); ok {
|
||||||
|
if data, ok := m["data"]; ok {
|
||||||
|
raw = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Some CLI commands return data as a JSON-encoded string; try to decode it.
|
||||||
|
if s, ok := raw.(string); ok {
|
||||||
|
var parsed interface{}
|
||||||
|
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
|
||||||
|
raw = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GitLink API wraps lists inside a map: {"issues": [...], "milestones": [...], ...}
|
||||||
|
if m, ok := raw.(map[string]interface{}); ok {
|
||||||
|
for _, listKey := range []string{"commits", "issues", "pull_requests", "issue_tags", "tags", "releases", "members", "items", "milestones", "branches"} {
|
||||||
|
if v, ok := m[listKey]; ok {
|
||||||
|
if arr, ok := v.([]interface{}); ok {
|
||||||
|
raw = arr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list, _ := raw.([]interface{})
|
||||||
|
var out []map[string]interface{}
|
||||||
|
for _, item := range list {
|
||||||
|
if m, ok := item.(map[string]interface{}); ok {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorLogin(m map[string]interface{}) string {
|
||||||
|
// Try top-level keys first.
|
||||||
|
if s := str(m, "login", "username"); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
// Try nested author/committer/user.
|
||||||
|
for _, key := range []string{"author", "committer", "user"} {
|
||||||
|
if a, ok := m[key].(map[string]interface{}); ok {
|
||||||
|
if s := str(a, "login", "username", "name"); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
} else if s, ok := m[key].(string); ok && s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitTimestamp(m map[string]interface{}) string {
|
||||||
|
// Try Unix timestamp (commit_time).
|
||||||
|
for _, key := range []string{"commit_time", "committed_date", "authored_date"} {
|
||||||
|
switch v := m[key].(type) {
|
||||||
|
case float64:
|
||||||
|
if v > 0 {
|
||||||
|
return time.Unix(int64(v), 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try string timestamps.
|
||||||
|
for _, key := range []string{"created_at", "updated_at"} {
|
||||||
|
if s := str(m, key); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try nested author.
|
||||||
|
if a, ok := m["author"].(map[string]interface{}); ok {
|
||||||
|
return str(a, "date", "created_at")
|
||||||
|
}
|
||||||
|
if a, ok := m["committer"].(map[string]interface{}); ok {
|
||||||
|
return str(a, "date", "created_at")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueTimestamp(m map[string]interface{}) string {
|
||||||
|
return str(m, "created_at", "updated_at", "closed_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
func prTimestamp(m map[string]interface{}) string {
|
||||||
|
return str(m, "created_at", "merged_at", "updated_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
// str returns the first non-empty string value for the given keys.
|
||||||
|
func str(m map[string]interface{}, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
v, _ := m[k].(string)
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildContributorWiki generates a markdown wiki page from ranking data.
|
||||||
|
func buildContributorWiki(analysis map[string]interface{}, rankings []map[string]interface{}) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("# 贡献者排行榜\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("> 自动生成于 %s\n\n", time.Now().Format("2006-01-02 15:04")))
|
||||||
|
|
||||||
|
sb.WriteString("## 总览\n\n")
|
||||||
|
sb.WriteString("| 排名 | 贡献者 | 提交 | Issue | PR | 总计 | 趋势 | 标签 |\n")
|
||||||
|
sb.WriteString("|------|--------|------|-------|-----|------|------|------|\n")
|
||||||
|
for _, r := range rankings {
|
||||||
|
name := fmt.Sprint(r["name"])
|
||||||
|
if name == "" || name == "<nil>" {
|
||||||
|
name = fmt.Sprint(r["login"])
|
||||||
|
}
|
||||||
|
tags := ""
|
||||||
|
if t, ok := r["tags"].([]string); ok && len(t) > 0 {
|
||||||
|
tags = strings.Join(t, ", ")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("| %v | %s | %v | %v | %v | %v | %.0f%% | %s |\n",
|
||||||
|
r["rank"], name, r["commits"], r["issues"], r["prs"], r["total"], r["trend"], tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新星
|
||||||
|
if newStars, ok := analysis["new_stars"].([]map[string]interface{}); ok && len(newStars) > 0 {
|
||||||
|
sb.WriteString("\n## 新星\n\n")
|
||||||
|
for _, s := range newStars {
|
||||||
|
name := fmt.Sprint(s["name"])
|
||||||
|
if name == "" || name == "<nil>" {
|
||||||
|
name = fmt.Sprint(s["login"])
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("- **%s** — 趋势 +%.0f%%\n", name, s["trend"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流失风险
|
||||||
|
if churn, ok := analysis["churn_risk"].([]map[string]interface{}); ok && len(churn) > 0 {
|
||||||
|
sb.WriteString("\n## 流失风险\n\n")
|
||||||
|
for _, c := range churn {
|
||||||
|
name := fmt.Sprint(c["name"])
|
||||||
|
if name == "" || name == "<nil>" {
|
||||||
|
name = fmt.Sprint(c["login"])
|
||||||
|
}
|
||||||
|
last := fmt.Sprint(c["last_activity"])
|
||||||
|
if t, err := time.Parse(time.RFC3339, last); err == nil {
|
||||||
|
last = t.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("- **%s** — 最后活动 %s\n", name, last))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n> 由 contributor-growth 工作流自动生成\n")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContributorRanking(t *testing.T) {
|
||||||
|
upstream := map[string]interface{}{
|
||||||
|
"commits": map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"created_at": "2025-06-01T00:00:00Z", "author": "dev1"},
|
||||||
|
map[string]interface{}{"created_at": "2025-06-05T00:00:00Z", "author": "dev1"},
|
||||||
|
map[string]interface{}{"created_at": "2025-06-10T00:00:00Z", "author": "dev2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
"closed-issues": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
"members": map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||||
|
map[string]interface{}{"login": "dev2", "name": "Dev Two"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||||
|
}
|
||||||
|
analysis := resp.Analysis.(map[string]interface{})
|
||||||
|
rankings := analysis["rankings"].([]map[string]interface{})
|
||||||
|
if len(rankings) != 2 {
|
||||||
|
t.Fatalf("expected 2 rankings, got %d", len(rankings))
|
||||||
|
}
|
||||||
|
|
||||||
|
// dev1 should be ranked #1 (2 commits vs 1).
|
||||||
|
first := rankings[0]
|
||||||
|
if first["login"] != "dev1" {
|
||||||
|
t.Errorf("expected dev1 as #1, got %v", first["login"])
|
||||||
|
}
|
||||||
|
if first["rank"] != 1 {
|
||||||
|
t.Errorf("expected rank 1, got %v", first["rank"])
|
||||||
|
}
|
||||||
|
if first["commits"] != 2 {
|
||||||
|
t.Errorf("expected 2 commits, got %v", first["commits"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChurnRiskDetection(t *testing.T) {
|
||||||
|
// 35 days ago — should trigger churn risk.
|
||||||
|
oldDate := "2025-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
upstream := map[string]interface{}{
|
||||||
|
"commits": map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"created_at": oldDate, "author": "dev1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
"closed-issues": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
"members": map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||||
|
}
|
||||||
|
analysis := resp.Analysis.(map[string]interface{})
|
||||||
|
rankings := analysis["rankings"].([]map[string]interface{})
|
||||||
|
if len(rankings) > 0 {
|
||||||
|
tags := rankings[0]["tags"].([]string)
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag == "churn-risk" {
|
||||||
|
return // success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("expected churn-risk tag for old activity, got tags: %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContributorOutputFormat(t *testing.T) {
|
||||||
|
resp, err := ContributorRankingRule(map[string]interface{}{}, "contributor-ranking")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Analysis == nil {
|
||||||
|
t.Fatal("expected non-nil Analysis")
|
||||||
|
}
|
||||||
|
if len(resp.Actions) < 1 {
|
||||||
|
t.Fatal("expected at least 1 wiki action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewStarDetection(t *testing.T) {
|
||||||
|
// Use very recent dates so the commits appear in the last 30 days.
|
||||||
|
now := time.Now()
|
||||||
|
d1 := now.Add(-2 * 24 * time.Hour).Format(time.RFC3339)
|
||||||
|
d2 := now.Add(-3 * 24 * time.Hour).Format(time.RFC3339)
|
||||||
|
d3 := now.Add(-4 * 24 * time.Hour).Format(time.RFC3339)
|
||||||
|
|
||||||
|
upstream := map[string]interface{}{
|
||||||
|
"commits": map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"created_at": d1, "author": "dev1"},
|
||||||
|
map[string]interface{}{"created_at": d2, "author": "dev1"},
|
||||||
|
map[string]interface{}{"created_at": d3, "author": "dev1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
"closed-issues": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||||
|
"members": map[string]interface{}{
|
||||||
|
"data": []interface{}{
|
||||||
|
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||||
|
}
|
||||||
|
analysis := resp.Analysis.(map[string]interface{})
|
||||||
|
// Should have new-star or at least ranking.
|
||||||
|
rankings := analysis["rankings"].([]map[string]interface{})
|
||||||
|
if len(rankings) == 0 {
|
||||||
|
t.Fatal("expected at least 1 ranking entry")
|
||||||
|
}
|
||||||
|
tags, _ := rankings[0]["tags"].([]string)
|
||||||
|
t.Logf("tags for dev1: %v", tags)
|
||||||
|
// With only recent commits (no previous period), trend should be 100%, triggering new-star.
|
||||||
|
found := false
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag == "new-star" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected new-star tag, got: %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
|
||||||
|
// HealthDispatchRule routes "gitlink-health" skill calls to the correct engine
|
||||||
|
// based on step name and upstream data shape.
|
||||||
|
func HealthDispatchRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||||
|
// contributor-ranking: from contributor_growth workflow.
|
||||||
|
if stepName == "contributor-ranking" {
|
||||||
|
return ContributorRankingRule(upstream, stepName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// repo-health: from multi_repo workflow.
|
||||||
|
if stepName == "repo-health" {
|
||||||
|
return HealthReportRule(upstream, stepName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// health-report: from community_ops workflow.
|
||||||
|
if stepName == "health-report" {
|
||||||
|
return HealthReportRule(upstream, stepName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: inspect upstream shape to decide.
|
||||||
|
// If upstream has "members" and "merged-prs" but no "repo-info", it's contributor ranking.
|
||||||
|
_, hasRepoInfo := upstream["repo-info"]
|
||||||
|
_, hasMembers := upstream["members"]
|
||||||
|
if hasMembers && !hasRepoInfo {
|
||||||
|
return ContributorRankingRule(upstream, stepName)
|
||||||
|
}
|
||||||
|
return HealthReportRule(upstream, stepName)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,413 @@
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HealthReportRule computes a 4-dimension weighted health score.
|
||||||
|
func HealthReportRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||||
|
repoInfo := extractFirst(upstream, "repo-info")
|
||||||
|
mergedPRs := extractPRs(upstream, "merged-prs")
|
||||||
|
commits := extractCommits(upstream)
|
||||||
|
openIssues := extractIssues(upstream, "open-issues")
|
||||||
|
|
||||||
|
// Compute raw metrics.
|
||||||
|
totalIssues := len(openIssues) // approximation
|
||||||
|
totalPRs := len(mergedPRs)
|
||||||
|
now := time.Now()
|
||||||
|
recentCommits := countRecent(commits, now, 30)
|
||||||
|
releaseCount := 0
|
||||||
|
if repoInfo != nil {
|
||||||
|
if v, ok := repoInfo["release_count"].(float64); ok {
|
||||||
|
releaseCount = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dimension 1: Issue Health (30%)
|
||||||
|
issueScore := scoreIssueHealth(totalIssues, openIssues, now)
|
||||||
|
|
||||||
|
// Dimension 2: PR Health (30%)
|
||||||
|
prScore := scorePRHealth(totalPRs, mergedPRs, now)
|
||||||
|
|
||||||
|
// Dimension 3: Contributor Health (20%)
|
||||||
|
contributorScore := scoreContributorHealth(commits, now)
|
||||||
|
|
||||||
|
// Dimension 4: Activity (20%)
|
||||||
|
activityScore := scoreActivity(recentCommits, releaseCount, repoInfo)
|
||||||
|
|
||||||
|
// Composite score.
|
||||||
|
composite := issueScore*0.30 + prScore*0.30 + contributorScore*0.20 + activityScore*0.20
|
||||||
|
|
||||||
|
analysis := map[string]interface{}{
|
||||||
|
"title": "项目健康度报告",
|
||||||
|
"composite": math.Round(composite*10) / 10,
|
||||||
|
"grade": grade(composite),
|
||||||
|
"dimensions": map[string]interface{}{
|
||||||
|
"issue_health": map[string]interface{}{
|
||||||
|
"score": math.Round(issueScore*10) / 10,
|
||||||
|
"weight": 0.30,
|
||||||
|
"grade": grade(issueScore),
|
||||||
|
},
|
||||||
|
"pr_health": map[string]interface{}{
|
||||||
|
"score": math.Round(prScore*10) / 10,
|
||||||
|
"weight": 0.30,
|
||||||
|
"grade": grade(prScore),
|
||||||
|
},
|
||||||
|
"contributor_health": map[string]interface{}{
|
||||||
|
"score": math.Round(contributorScore*10) / 10,
|
||||||
|
"weight": 0.20,
|
||||||
|
"grade": grade(contributorScore),
|
||||||
|
},
|
||||||
|
"activity": map[string]interface{}{
|
||||||
|
"score": math.Round(activityScore*10) / 10,
|
||||||
|
"weight": 0.20,
|
||||||
|
"grade": grade(activityScore),
|
||||||
|
"recent_commits": recentCommits,
|
||||||
|
"releases": releaseCount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Build wiki page action to publish the report.
|
||||||
|
body := buildHealthReportMarkdown(analysis, upstream)
|
||||||
|
pageName := fmt.Sprintf("健康度报告-%s", now.Format("2006-01-02"))
|
||||||
|
actions := []workflow.AIAction{
|
||||||
|
{
|
||||||
|
Type: "cli",
|
||||||
|
Module: "wiki",
|
||||||
|
Command: "+create",
|
||||||
|
Args: map[string]string{
|
||||||
|
"name": pageName,
|
||||||
|
"content": body,
|
||||||
|
"message": "自动生成项目健康度报告",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "cli",
|
||||||
|
Module: "wiki",
|
||||||
|
Command: "+update",
|
||||||
|
Args: map[string]string{
|
||||||
|
"name": pageName,
|
||||||
|
"content": body,
|
||||||
|
"message": "自动更新项目健康度报告",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHealthReportMarkdown renders the analysis and upstream data as a markdown report.
|
||||||
|
func buildHealthReportMarkdown(analysis map[string]interface{}, upstream map[string]interface{}) string {
|
||||||
|
var b strings.Builder
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
b.WriteString("# 项目健康度报告\n\n")
|
||||||
|
fmt.Fprintf(&b, "> 报告生成时间:%s\n\n", now.Format("2006-01-02 15:04"))
|
||||||
|
|
||||||
|
composite, _ := analysis["composite"].(float64)
|
||||||
|
grade, _ := analysis["grade"].(string)
|
||||||
|
fmt.Fprintf(&b, "## 总体评分:%.1f 分(%s)\n\n", composite, grade)
|
||||||
|
|
||||||
|
dims, _ := analysis["dimensions"].(map[string]interface{})
|
||||||
|
|
||||||
|
b.WriteString("| 维度 | 得分 | 权重 | 等级 |\n")
|
||||||
|
b.WriteString("|------|------|------|------|\n")
|
||||||
|
|
||||||
|
dimDefs := []struct{ key, label string }{
|
||||||
|
{"issue_health", "Issue 健康度"},
|
||||||
|
{"pr_health", "PR 健康度"},
|
||||||
|
{"contributor_health", "贡献者活跃度"},
|
||||||
|
{"activity", "项目活跃度"},
|
||||||
|
}
|
||||||
|
for _, d := range dimDefs {
|
||||||
|
if dim, ok := dims[d.key].(map[string]interface{}); ok {
|
||||||
|
score, _ := dim["score"].(float64)
|
||||||
|
weight, _ := dim["weight"].(float64)
|
||||||
|
g, _ := dim["grade"].(string)
|
||||||
|
fmt.Fprintf(&b, "| %s | %.1f | %.0f%% | %s |\n", d.label, score, weight*100, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "| **综合评分** | **%.1f** | — | **%s** |\n\n", composite, grade)
|
||||||
|
b.WriteString("等级标准:优秀(≥80) | 良好(60-79) | 需改进(<60)\n\n")
|
||||||
|
|
||||||
|
// Detail sections
|
||||||
|
b.WriteString("## 各项指标详情\n\n")
|
||||||
|
|
||||||
|
// Issue health
|
||||||
|
b.WriteString("### Issue 健康度\n\n")
|
||||||
|
openIssues := extractIssues(upstream, "open-issues")
|
||||||
|
fmt.Fprintf(&b, "- 开放 Issue 数:%d\n", len(openIssues))
|
||||||
|
staleCount := 0
|
||||||
|
for _, iss := range openIssues {
|
||||||
|
ts := issueTimestamp(iss)
|
||||||
|
if ts == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, ts)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(t) > 30*24*time.Hour {
|
||||||
|
staleCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "- 超 30 天未关闭 Issue 数:%d\n\n", staleCount)
|
||||||
|
|
||||||
|
// PR health
|
||||||
|
b.WriteString("### PR 健康度\n\n")
|
||||||
|
mergedPRs := extractPRs(upstream, "merged-prs")
|
||||||
|
fmt.Fprintf(&b, "- 已合并 PR 数:%d\n", len(mergedPRs))
|
||||||
|
var totalHours float64
|
||||||
|
prCount := 0
|
||||||
|
for _, pr := range mergedPRs {
|
||||||
|
created := prTimestamp(pr)
|
||||||
|
merged := str(pr, "merged_at")
|
||||||
|
if created == "" || merged == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ct, err1 := time.Parse(time.RFC3339, created)
|
||||||
|
mt, err2 := time.Parse(time.RFC3339, merged)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalHours += mt.Sub(ct).Hours()
|
||||||
|
prCount++
|
||||||
|
}
|
||||||
|
if prCount > 0 {
|
||||||
|
fmt.Fprintf(&b, "- 平均合并耗时:%.1f 天\n\n", totalHours/float64(prCount)/24)
|
||||||
|
} else {
|
||||||
|
b.WriteString("- 平均合并耗时:N/A\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contributor health
|
||||||
|
b.WriteString("### 贡献者活跃度\n\n")
|
||||||
|
commits := extractCommits(upstream)
|
||||||
|
authors := map[string]bool{}
|
||||||
|
for _, c := range commits {
|
||||||
|
ts := commitTimestamp(c)
|
||||||
|
if ts == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, ts)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(t) <= 30*24*time.Hour {
|
||||||
|
authors[authorLogin(c)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "- 近 30 天活跃贡献者:%d 人\n\n", len(authors))
|
||||||
|
|
||||||
|
// Activity
|
||||||
|
b.WriteString("### 项目活跃度\n\n")
|
||||||
|
recentCommits := countRecent(commits, now, 30)
|
||||||
|
releaseCount := 0
|
||||||
|
if repoInfo := extractFirst(upstream, "repo-info"); repoInfo != nil {
|
||||||
|
if v, ok := repoInfo["release_count"].(float64); ok {
|
||||||
|
releaseCount = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "- 近 30 天提交数:%d\n", recentCommits)
|
||||||
|
fmt.Fprintf(&b, "- 发行版本数:%d\n\n", releaseCount)
|
||||||
|
|
||||||
|
// Improvement suggestions
|
||||||
|
b.WriteString("## 改进建议\n\n")
|
||||||
|
suggestions := []string{}
|
||||||
|
if composite < 60 {
|
||||||
|
suggestions = append(suggestions, "- 项目整体健康度较低,建议重点关注以下改进方向")
|
||||||
|
}
|
||||||
|
if dim, ok := dims["issue_health"].(map[string]interface{}); ok {
|
||||||
|
if score, _ := dim["score"].(float64); score < 60 {
|
||||||
|
suggestions = append(suggestions, "- **Issue 管理**:及时关闭已解决的 Issue,减少超 30 天未响应的 Issue 堆积")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dim, ok := dims["pr_health"].(map[string]interface{}); ok {
|
||||||
|
if score, _ := dim["score"].(float64); score < 60 {
|
||||||
|
suggestions = append(suggestions, "- **PR 审查**:加快 PR Review 速度,目标将平均合并时间控制在 3 天以内")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dim, ok := dims["contributor_health"].(map[string]interface{}); ok {
|
||||||
|
if score, _ := dim["score"].(float64); score < 60 {
|
||||||
|
suggestions = append(suggestions, "- **社区建设**:吸引更多贡献者参与项目,可以标记 good-first-issue 降低新贡献者参与门槛")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dim, ok := dims["activity"].(map[string]interface{}); ok {
|
||||||
|
if score, _ := dim["score"].(float64); score < 60 {
|
||||||
|
suggestions = append(suggestions, "- **项目活跃度**:保持定期提交和版本发布节奏,增加项目可见度")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(suggestions) == 0 {
|
||||||
|
suggestions = append(suggestions, "- 项目整体健康度良好,继续保持当前节奏")
|
||||||
|
}
|
||||||
|
for _, s := range suggestions {
|
||||||
|
b.WriteString(s)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n> 由 community-ops 工作流自动生成\n")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreIssueHealth(total int, openIssues []map[string]interface{}, now time.Time) float64 {
|
||||||
|
if total == 0 {
|
||||||
|
return 80 // neutral
|
||||||
|
}
|
||||||
|
// Stale issues: open for >30 days.
|
||||||
|
stale := 0
|
||||||
|
for _, iss := range openIssues {
|
||||||
|
ts := issueTimestamp(iss)
|
||||||
|
if ts == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, ts)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(t) > 30*24*time.Hour {
|
||||||
|
stale++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ratio := float64(stale) / float64(max(total, 1))
|
||||||
|
score := (1 - ratio) * 100
|
||||||
|
return clamp(score)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scorePRHealth(total int, mergedPRs []map[string]interface{}, now time.Time) float64 {
|
||||||
|
if total == 0 {
|
||||||
|
return 80 // neutral
|
||||||
|
}
|
||||||
|
// Avg merge time from creation.
|
||||||
|
var totalHours float64
|
||||||
|
count := 0
|
||||||
|
for _, pr := range mergedPRs {
|
||||||
|
created := prTimestamp(pr)
|
||||||
|
merged := str(pr, "merged_at")
|
||||||
|
if created == "" || merged == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ct, err1 := time.Parse(time.RFC3339, created)
|
||||||
|
mt, err2 := time.Parse(time.RFC3339, merged)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalHours += mt.Sub(ct).Hours()
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return 80
|
||||||
|
}
|
||||||
|
avgDays := totalHours / float64(count) / 24
|
||||||
|
// <3 days = excellent (100), 3-7 = good (80), >7 = needs improvement (50).
|
||||||
|
if avgDays < 3 {
|
||||||
|
return 100
|
||||||
|
} else if avgDays < 7 {
|
||||||
|
return 80
|
||||||
|
}
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreContributorHealth(commits []map[string]interface{}, now time.Time) float64 {
|
||||||
|
// Unique authors in last 30 days.
|
||||||
|
authors := map[string]bool{}
|
||||||
|
for _, c := range commits {
|
||||||
|
ts := commitTimestamp(c)
|
||||||
|
if ts == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, ts)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(t) <= 30*24*time.Hour {
|
||||||
|
authors[authorLogin(c)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := len(authors)
|
||||||
|
// >3 active = 100, 1-3 = 60, 0 = 30.
|
||||||
|
if n > 3 {
|
||||||
|
return 100
|
||||||
|
} else if n >= 1 {
|
||||||
|
return 60
|
||||||
|
}
|
||||||
|
return 30
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreActivity(recentCommits int, releaseCount int, repoInfo map[string]interface{}) float64 {
|
||||||
|
score := 0.0
|
||||||
|
if recentCommits >= 10 {
|
||||||
|
score += 50
|
||||||
|
} else if recentCommits > 0 {
|
||||||
|
score += float64(recentCommits) / 10 * 50
|
||||||
|
}
|
||||||
|
if releaseCount >= 3 {
|
||||||
|
score += 50
|
||||||
|
} else if releaseCount > 0 {
|
||||||
|
score += float64(releaseCount) / 3 * 50
|
||||||
|
}
|
||||||
|
if score == 0 {
|
||||||
|
score = 30 // bare minimum if repo exists
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func countRecent(commits []map[string]interface{}, now time.Time, days int) int {
|
||||||
|
n := 0
|
||||||
|
for _, c := range commits {
|
||||||
|
ts := commitTimestamp(c)
|
||||||
|
if ts == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, ts)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(t) <= time.Duration(days)*24*time.Hour {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFirst returns the first map from upstream by key (some upstream data is wrapped).
|
||||||
|
func extractFirst(upstream map[string]interface{}, key string) map[string]interface{} {
|
||||||
|
list := extractList(upstream, key)
|
||||||
|
if len(list) > 0 {
|
||||||
|
return list[0]
|
||||||
|
}
|
||||||
|
// Try direct map — key may contain the envelope {ok, data, ...}.
|
||||||
|
if m, ok := upstream[key].(map[string]interface{}); ok {
|
||||||
|
// Unwrap envelope if present.
|
||||||
|
if data, ok := m["data"]; ok {
|
||||||
|
if inner, ok := data.(map[string]interface{}); ok {
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func grade(score float64) string {
|
||||||
|
if score >= 80 {
|
||||||
|
return "优秀"
|
||||||
|
} else if score >= 60 {
|
||||||
|
return "良好"
|
||||||
|
}
|
||||||
|
return "需改进"
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(v float64) float64 {
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue