forked from Gitlink/gitlink-cli
Compare commits
168 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
71ca2bb683 | |
|
|
593b5e8f0d | |
|
|
dbf0f45ce3 | |
|
|
2c9a8b6192 | |
|
|
982f2cb336 | |
|
|
0703d3eba9 | |
|
|
ef7a2c6ac1 | |
|
|
6eebfeb40d | |
|
|
6454645f50 | |
|
|
d3485fce45 | |
|
|
67b46eece4 | |
|
|
1806382459 | |
|
|
46154f7c5e | |
|
|
a61a8c4ef4 | |
|
|
789db5e5ae | |
|
|
ecc5578cf4 | |
|
|
8c29d98a57 | |
|
|
1c84cac060 | |
|
|
706a1396ad | |
|
|
29ca94db76 | |
|
|
7f731eb04f | |
|
|
da4c8c0d10 | |
|
|
2729cc69ba | |
|
|
7b880cb8ad | |
|
|
fb2d60f67a | |
|
|
968ea0d7b4 | |
|
|
85a67a875f | |
|
|
17c555620b | |
|
|
9be51d34fc | |
|
|
52b7093846 | |
|
|
d0ca6b24ec | |
|
|
462ae7b012 | |
|
|
220aa70e51 | |
|
|
21966bf1ea | |
|
|
caad409185 | |
|
|
60e8ab7768 | |
|
|
21ebb2f89c | |
|
|
f1dd01bebc | |
|
|
0da1d8d7bf | |
|
|
a580562922 | |
|
|
6e45fbb3e1 | |
|
|
2d963ebf41 | |
|
|
7947d4dfd6 | |
|
|
68aa9ae6e6 | |
|
|
27cc58328d | |
|
|
ee0d882e1c | |
|
|
ca578a74be | |
|
|
55805aa13d | |
|
|
dd4ed35448 | |
|
|
723e698db4 | |
|
|
03a9d7a942 | |
|
|
34f14b079f | |
|
|
f3e5d4aba0 | |
|
|
64dad994ae | |
|
|
7e863398aa | |
|
|
9c085fb246 | |
|
|
a9ee74efb0 | |
|
|
d6321e7af5 | |
|
|
dec10afaeb | |
|
|
35e50a1828 | |
|
|
dedba6e49f | |
|
|
a1d3a85ab8 | |
|
|
0b48ec90d8 | |
|
|
b884a7478c | |
|
|
6353987267 | |
|
|
3255884c6e | |
|
|
82ac20ae74 | |
|
|
5711aa3654 | |
|
|
a2fd36b4a6 | |
|
|
b319c44b02 | |
|
|
33ad8e62a0 | |
|
|
9bfa59c7e8 | |
|
|
cedd8b6263 | |
|
|
b45241dcda | |
|
|
633bed3360 | |
|
|
c263a2fa34 | |
|
|
6636175bd8 | |
|
|
83fe18f8c0 | |
|
|
0e13e8bdd4 | |
|
|
559e1a74f3 | |
|
|
17e8c113f7 | |
|
|
41b89976aa | |
|
|
0a578bbab0 | |
|
|
0da45def2b | |
|
|
f4820c03b4 | |
|
|
d478de59a3 | |
|
|
c7a3224c79 | |
|
|
9843fc7ceb | |
|
|
8e558cceca | |
|
|
49240b3c02 | |
|
|
edf2fc30b8 | |
|
|
dec8c10a1b | |
|
|
39306994bc | |
|
|
34be160d96 | |
|
|
89d5321713 | |
|
|
37dfd2e2e2 | |
|
|
ac7499936d | |
|
|
e3bb4d2711 | |
|
|
e393484dc9 | |
|
|
fb847bf13f | |
|
|
902886cabd | |
|
|
18975162a1 | |
|
|
a91d553130 | |
|
|
687633d22f | |
|
|
1bf16d31db | |
|
|
5a266867b5 | |
|
|
0170b5b063 | |
|
|
c969631495 | |
|
|
f7263cc466 | |
|
|
cca3d019fe | |
|
|
4631b81e5c | |
|
|
6ded4851cb | |
|
|
a4c93802a7 | |
|
|
c28b72d46f | |
|
|
41a2666c5b | |
|
|
7d8c33a938 | |
|
|
2f8b987c2c | |
|
|
c89e398b46 | |
|
|
00c753b421 | |
|
|
2421c1f0c8 | |
|
|
6ce72f07b2 | |
|
|
419388f11e | |
|
|
5369ce65bb | |
|
|
27117c0f16 | |
|
|
898b7f59d1 | |
|
|
36249f3087 | |
|
|
7bcba5aed8 | |
|
|
f888dd39ed | |
|
|
65cd7f6199 | |
|
|
7f0f332f77 | |
|
|
42bc83295a | |
|
|
6b355e19e2 | |
|
|
b3f139801b | |
|
|
29dee2697e | |
|
|
be8327822a | |
|
|
b0327204d0 | |
|
|
018c08e178 | |
|
|
2dc230430b | |
|
|
a904f48099 | |
|
|
7730347253 | |
|
|
5673a109a4 | |
|
|
46799d8e56 | |
|
|
1feb6a85fc | |
|
|
23a478bd69 | |
|
|
9638d279d3 | |
|
|
9ebed898ac | |
|
|
a46e06b78a | |
|
|
64c0ff3cb5 | |
|
|
cbe9186c5c | |
|
|
2fb99ee0cc | |
|
|
4bfbfc6e8f | |
|
|
49cd590227 | |
|
|
2b64b8efa7 | |
|
|
9b6268cc47 | |
|
|
255410181f | |
|
|
d0a817c5a8 | |
|
|
ba78cb75b2 | |
|
|
fbea42ecce | |
|
|
67059ec1b2 | |
|
|
c57bd280c5 | |
|
|
e85dd367d8 | |
|
|
a302f2a530 | |
|
|
923e6af127 | |
|
|
7bd29fdf15 | |
|
|
478e7c8f88 | |
|
|
df1d6bb29e | |
|
|
2f84f3c62e | |
|
|
83a642a72d |
|
|
@ -0,0 +1,30 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Build, Lint, Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Lint
|
||||
run: make lint
|
||||
|
||||
- name: Test
|
||||
run: make test
|
||||
|
||||
- name: Check formatting
|
||||
run: make fmt
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Validate i18n messages
|
||||
run: go run ./internal/i18n/cmd/check
|
||||
|
||||
- name: Scan i18n key references
|
||||
run: go run ./internal/i18n/cmd/check --scan-code
|
||||
|
||||
- name: Run Go tests
|
||||
run: go test ./...
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
gitlink-cli.exe
|
||||
/gitlink-cli
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
version: "2"
|
||||
|
||||
linters:
|
||||
default: none
|
||||
|
||||
enable:
|
||||
# Core: catch real bugs
|
||||
- errcheck # unchecked errors
|
||||
- govet # suspicious constructs
|
||||
- ineffassign # wasted assignments
|
||||
- staticcheck # comprehensive bug detection
|
||||
- unused # dead code
|
||||
|
||||
# Error handling
|
||||
- errorlint # errors.As / %w best practices
|
||||
|
||||
# Security
|
||||
- gosec # security issues
|
||||
|
||||
# Typos
|
||||
- misspell # spelling mistakes in identifiers
|
||||
|
||||
settings:
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # errcheck already handles unchecked errors
|
||||
- G304 # file inclusion by variable is expected for CLI tools
|
||||
|
||||
exclusions:
|
||||
paths:
|
||||
- vendor/
|
||||
- npm/
|
||||
- skills/
|
||||
- docs/
|
||||
rules:
|
||||
# Idiomatic Go: defer Close() error is intentionally ignored
|
||||
- linters: [errcheck]
|
||||
text: "Error return value of .*(resp\\.Body\\.Close|file\\.Close).*is not checked"
|
||||
# Output formatting: fmt.Fprint* errors are low-value
|
||||
- linters: [errcheck]
|
||||
text: "Error return value of `fmt\\.Fprintf?"
|
||||
# Test helpers: FlagSet.Set is setup code
|
||||
- linters: [errcheck]
|
||||
text: "Error return value of .*FlagSet.*\\.Set"
|
||||
# Best-effort output rendering
|
||||
- linters: [errcheck]
|
||||
path: render\.go$
|
||||
# errcheck: test helpers intentionally ignore return values
|
||||
- linters: [errcheck]
|
||||
path: _test\.go$
|
||||
# errorlint: type assertions are fine in tests
|
||||
- linters: [errorlint]
|
||||
path: _test\.go$
|
||||
# gosec: tests are not attack surface
|
||||
- linters: [gosec]
|
||||
path: _test\.go$
|
||||
# apiInt: intentional uint64->int truncation for API response parsing
|
||||
- linters: [gosec]
|
||||
text: "G115: integer overflow conversion uint64 -> int"
|
||||
30
Makefile
30
Makefile
|
|
@ -3,7 +3,7 @@ BINARY := gitlink-cli
|
|||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)'
|
||||
|
||||
.PHONY: build install clean test
|
||||
.PHONY: build install clean test check vet fmt cover lint
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||
|
|
@ -15,4 +15,30 @@ clean:
|
|||
rm -f $(BINARY)
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
@unformatted=$$(gofmt -s -l .); \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "Files not formatted:"; \
|
||||
echo "$$unformatted"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
cover:
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
check: fmt vet lint test
|
||||
@echo "All checks passed."
|
||||
|
||||
hooks:
|
||||
cp scripts/pre-commit .git/hooks/pre-commit
|
||||
chmod +x .git/hooks/pre-commit
|
||||
@echo "Pre-commit hook installed."
|
||||
|
|
|
|||
480
README.md
480
README.md
|
|
@ -5,16 +5,93 @@
|
|||
[](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/).
|
||||
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, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/README.md).
|
||||
|
||||
**[中文文档](./README.zh-CN.md)**
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#ai-agent-skills) · [Auth](#configure--use) · [Commands](#usage-examples) · [Contributing](#related-projects)
|
||||
|
||||
## Contributors
|
||||
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;">
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/wangyue111">wangyue111</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/wbtiger">wbtiger</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/Mengz">Mengz</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/yangsai">yangsai</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/mengcheng">mengcheng</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/images/avatars/User/149182?t=1779603476" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/muel">muel</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/Leo77">Leo77</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/yingjie">yingjie</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/topshare">topshare</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/dtwdtw" title="dtwdtw"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/D/53_166_51/120.png" width="40" height="40" alt="dtwdtw" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/dtwdtw">dtwdtw</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/recorder" title="recorder"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/R/141_201_87/120.png" width="40" height="40" alt="recorder" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/recorder">recorder</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/puygob236" title="Jiachen Li"><img src="https://www.gitlink.org.cn/images/avatars/User/149183?t=1778815174" width="40" height="40" alt="puygob236" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/puygob236">puygob236</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/co63oc" title="co63oc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/C/205_201_141/120.png" width="40" height="40" alt="co63oc" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/co63oc">co63oc</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/lindiwen23" title="lindiwen23"><img src="https://www.gitlink.org.cn/images/avatars/User/141609?t=1748270628" width="40" height="40" alt="lindiwen23" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/lindiwen23">lindiwen23</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/ohanabi" title="ohanabi"><img src="https://www.gitlink.org.cn/images/avatars/User/148166?t=1778230283" width="40" height="40" alt="ohanabi" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/ohanabi">ohanabi</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/jiangtx" title="jiangtx"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/J/67_157_94/120.png" width="40" height="40" alt="jiangtx" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/jiangtx">jiangtx</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/luwanzhou" title="luwanzhou"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/165_135_246/120.png" width="40" height="40" alt="luwanzhou" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/luwanzhou">luwanzhou</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/whale_hihihi" title="whale_hihihi"><img src="https://www.gitlink.org.cn/images/avatars/User/137722?t=1778575729" width="40" height="40" alt="whale_hihihi" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/whale_hihihi">whale_hihihi</a></sub>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Why gitlink-cli?
|
||||
|
||||
- **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
|
||||
- **Wide Coverage** — Repository, Issue, PR, Branch, Release, CI, Org, Search, User — all core domains covered
|
||||
- **Agent-Native Design** — Structured [Skills](./skills/README.md) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
|
||||
- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
|
||||
- **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`
|
||||
|
|
@ -26,15 +103,22 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
|
||||
| Category | Capabilities |
|
||||
|----------|-------------|
|
||||
| 📦 Repo | List, create, fork, delete repositories, view repo info |
|
||||
| 🐛 Issue | Create, update, close, batch close, comment on issues |
|
||||
| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions |
|
||||
| 🐛 Issue | Create, update, close, batch close/update/delete, comment on issues |
|
||||
| 🔖 Label | Create, list, update, delete issue labels |
|
||||
| 🔀 PR | Create, merge, review pull requests, view changed files |
|
||||
| 👥 Member | List, add, remove repository members, change roles, create and accept invite links |
|
||||
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
|
||||
| 🏷️ Release | Create, view, delete releases |
|
||||
| 🏷️ Release | Create, edit, update, view, delete releases |
|
||||
| 🏢 Org | Manage organizations, members, teams |
|
||||
| 🔧 CI | View builds, logs, CI/CD operations |
|
||||
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
|
||||
| 🔔 Webhook | Manage repo webhooks and test deliveries |
|
||||
| 📖 Wiki | List, view, create, update, and delete wiki pages |
|
||||
| 🔍 Search | Search repositories, users |
|
||||
| 📊 Dataset | Query research datasets by project |
|
||||
| 👤 User | View user profiles and info |
|
||||
| 📊 Profile | User ability, role, major, activity, and contribution statistics |
|
||||
| 📋 PM | Sprint management, kanban boards, weekly reports |
|
||||
| 🤖 Workflow | AI-powered issue triage, PR review, release notes |
|
||||
|
||||
|
|
@ -55,7 +139,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
**From npm (recommended):**
|
||||
|
||||
```bash
|
||||
# One command: installs CLI binary + all 12 AI Agent Skills
|
||||
# One command: installs CLI binary + AI Agent Skills
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
|
|
@ -136,6 +220,36 @@ gitlink-cli repo +list
|
|||
# View repository info
|
||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
||||
|
||||
# Read repository README
|
||||
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# List repository files at root or a directory
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
|
||||
|
||||
# Show language breakdown
|
||||
gitlink-cli repo +languages --owner Gitlink --repo forgeplus
|
||||
|
||||
# List contributors
|
||||
gitlink-cli repo +contributors --owner Gitlink --repo forgeplus
|
||||
|
||||
# Show contributor code-line stats for a branch, tag, or commit
|
||||
gitlink-cli repo +contributor-stats --owner Gitlink --repo forgeplus --ref master --pass-year 1
|
||||
|
||||
# Show repository code stats
|
||||
gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# List watchers and stargazers in a time range
|
||||
gitlink-cli repo +watchers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
|
||||
gitlink-cli repo +stargazers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
|
||||
|
||||
# Preview and apply repository interaction actions
|
||||
gitlink-cli repo +follow --owner Gitlink --repo forgeplus --dry-run
|
||||
gitlink-cli repo +follow --owner Gitlink --repo forgeplus
|
||||
gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123
|
||||
gitlink-cli repo +like --owner Gitlink --repo forgeplus
|
||||
gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123
|
||||
|
||||
# Create a repository
|
||||
gitlink-cli repo +create -n my-project -d "Project description"
|
||||
|
||||
|
|
@ -143,6 +257,67 @@ gitlink-cli repo +create -n my-project -d "Project description"
|
|||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Webhook Management
|
||||
|
||||
```bash
|
||||
# List webhooks
|
||||
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Create a webhook
|
||||
gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
|
||||
--url https://example.com/hook --events push,create
|
||||
|
||||
# Test a webhook
|
||||
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
||||
|
||||
# View webhook delivery tasks
|
||||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### Wiki Management
|
||||
|
||||
```bash
|
||||
# List wiki pages (table of contents)
|
||||
gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
|
||||
|
||||
# View a wiki page by page name
|
||||
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
|
||||
|
||||
# Create a wiki page
|
||||
gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
|
||||
-n getting-started -t "Getting Started" -c "# Getting Started Guide"
|
||||
|
||||
# Update a wiki page title and/or content
|
||||
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title"
|
||||
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# Updated content"
|
||||
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title" -c "New content"
|
||||
|
||||
# Delete a wiki page
|
||||
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
|
||||
```
|
||||
|
||||
### Member Management
|
||||
|
||||
```bash
|
||||
# List repository members
|
||||
gitlink-cli member +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Add a member
|
||||
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
|
||||
|
||||
# Preview batch add without changing data
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
|
||||
|
||||
# Batch add members from a CSV file
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
|
||||
|
||||
# Change a member role
|
||||
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
|
||||
|
||||
# Create an invite link
|
||||
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
|
||||
```
|
||||
|
||||
### Issue Management
|
||||
|
||||
```bash
|
||||
|
|
@ -152,9 +327,15 @@ 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..."
|
||||
|
||||
# Create an issue with metadata
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
|
||||
|
||||
# View an issue
|
||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
||||
|
||||
# Update issue metadata
|
||||
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
|
||||
|
||||
# Close an issue
|
||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||
|
||||
|
|
@ -164,8 +345,55 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
|
|||
# Batch close issues from a CSV file
|
||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
|
||||
|
||||
# Preview batch metadata update by API issue IDs
|
||||
# Note: --ids uses API issue IDs, not web URL issue numbers.
|
||||
gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run
|
||||
|
||||
# Destructive batch delete requires both dry-run first and --yes for real execution
|
||||
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run
|
||||
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes
|
||||
|
||||
# Add a comment
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
|
||||
|
||||
# List issue assigners
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
|
||||
|
||||
# List issue authors
|
||||
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
|
||||
|
||||
# List issue priorities
|
||||
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus
|
||||
|
||||
# List issue tags
|
||||
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
|
||||
|
||||
# List issue statuses
|
||||
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
`issue +view`, `issue +update`, `issue +close`, and `issue +comment` prefer
|
||||
`--number` / `-n` for the issue number shown in the web URL. `--id` / `-i`
|
||||
is accepted as a compatibility alias for the same web issue number, not the
|
||||
global database ID.
|
||||
|
||||
### Label Management
|
||||
|
||||
```bash
|
||||
# List issue labels
|
||||
gitlink-cli label +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Filter labels by keyword
|
||||
gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug
|
||||
|
||||
# Create a label (color defaults to #1E90FF)
|
||||
gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "Something is broken" -c "#FF0000"
|
||||
|
||||
# Update a label (unspecified fields are preserved)
|
||||
gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00"
|
||||
|
||||
# Delete a label
|
||||
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
|
||||
```
|
||||
|
||||
### Pull Requests
|
||||
|
|
@ -186,8 +414,24 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
|||
# Merge a PR
|
||||
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# Reopen a closed PR
|
||||
gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# View changed files
|
||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# List PR patchset versions
|
||||
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# View a patchset version diff
|
||||
gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040
|
||||
|
||||
# List PR reviews
|
||||
gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# Create a PR review (with dry-run preview)
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
|
||||
```
|
||||
|
||||
### Branch Management
|
||||
|
|
@ -215,11 +459,18 @@ gitlink-cli branch +unprotect --name main
|
|||
# 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..."
|
||||
# Create a release with release notes and optional assets
|
||||
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 Stable" -b "Changelog..." --attachment-ids 12,34
|
||||
|
||||
# View a release
|
||||
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
|
||||
|
||||
# Get edit data and update while preserving unspecified fields
|
||||
gitlink-cli release +edit --owner Gitlink --repo forgeplus -i <version_id>
|
||||
gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b "Updated changelog" --dry-run
|
||||
|
||||
# Preview release deletion before executing it
|
||||
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --dry-run
|
||||
```
|
||||
|
||||
### CI/CD Operations
|
||||
|
|
@ -235,6 +486,38 @@ gitlink-cli ci +log --owner Gitlink --repo forgeplus -i <build_id>
|
|||
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
|
||||
```
|
||||
|
||||
### Pipeline Operations
|
||||
|
||||
```bash
|
||||
# List platform pipelines
|
||||
gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20
|
||||
|
||||
# List repository pipeline runs
|
||||
gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml
|
||||
|
||||
# Start a pipeline workflow, previewing the request first
|
||||
gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run
|
||||
|
||||
# Inspect pipeline details and logs
|
||||
gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7
|
||||
gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43
|
||||
gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99
|
||||
|
||||
# Toggle or delete pipeline workflows, previewing destructive writes first
|
||||
gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run
|
||||
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
|
||||
```
|
||||
|
||||
### Ignore File Templates
|
||||
|
||||
```bash
|
||||
# List all available .gitignore templates
|
||||
gitlink-cli ignore +list
|
||||
|
||||
# Filter templates by name
|
||||
gitlink-cli ignore +list --name Go
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
```bash
|
||||
|
|
@ -245,6 +528,154 @@ gitlink-cli search +repos -k "machine learning"
|
|||
gitlink-cli search +users -k "zhangsan"
|
||||
```
|
||||
|
||||
### User Profile
|
||||
|
||||
`profile` surfaces GitLink's native user statistics (ability, role, major, activity,
|
||||
contribution). When `--user` is omitted it defaults to the authenticated user.
|
||||
|
||||
```bash
|
||||
# Development ability scores + language breakdown
|
||||
gitlink-cli profile +ability --user zhangsan
|
||||
|
||||
# Role positioning / major (discipline) categories
|
||||
gitlink-cli profile +role --user zhangsan
|
||||
gitlink-cli profile +major --user zhangsan
|
||||
|
||||
# Ability within a time window (Unix timestamps)
|
||||
gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600
|
||||
|
||||
# Recent activity (issues / PRs / commits per day) for the current user
|
||||
gitlink-cli profile +activity
|
||||
|
||||
# Contribution heatmap for a given year
|
||||
gitlink-cli profile +contribution --user zhangsan --year 2025
|
||||
```
|
||||
|
||||
### Workflow Agent Commands
|
||||
|
||||
`workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports:
|
||||
|
||||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +repo-report`
|
||||
|
||||
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
|
||||
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Triage with local parameters
|
||||
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
|
||||
|
||||
# Triage with JSON output
|
||||
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
|
||||
|
||||
# Triage with Chinese markdown output
|
||||
gitlink-cli workflow +triage \
|
||||
--title "安装失败,无法登录" \
|
||||
--body "运行命令时报错" \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
|
||||
# Triage from a local JSON file
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
|
||||
|
||||
# Triage by read-only GitLink fetch
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
|
||||
|
||||
# Health for a healthy repository
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--agent-readiness-known \
|
||||
--agent-readiness-score 9 \
|
||||
--format table
|
||||
|
||||
# Health for a risky repository
|
||||
gitlink-cli workflow +health \
|
||||
--repository demo/repo \
|
||||
--open-issues 60 \
|
||||
--stale-issues 25 \
|
||||
--open-prs 12 \
|
||||
--stale-prs 6 \
|
||||
--recent-activity-known \
|
||||
--recent-activity-days 120 \
|
||||
--release-known=false \
|
||||
--format json
|
||||
|
||||
# Health with Chinese markdown output
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
|
||||
# Health by read-only GitLink fetch
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
|
||||
|
||||
# PR review summary by read-only GitLink fetch
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
|
||||
|
||||
# PR review summary from a local JSON file
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
|
||||
|
||||
# Repository workflow report by read-only GitLink fetch
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
|
||||
# Repository workflow report from a local JSON file
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
||||
```
|
||||
|
||||
Output formats:
|
||||
|
||||
- `json` for scripts and AI Agents
|
||||
- `table` for terminal review
|
||||
- `markdown` for Issue comments, PR comments, release notes, and competition write-ups
|
||||
|
||||
Safety:
|
||||
|
||||
- Current workflow commands use local analysis by default and can also read GitLink data in read-only fetch mode.
|
||||
- They do not modify remote GitLink data.
|
||||
- They do not depend on LLM APIs.
|
||||
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
|
||||
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
|
||||
|
||||
### Dataset
|
||||
|
||||
`dataset` manages and queries GitLink research datasets (title, description,
|
||||
paper content, license, owning project).
|
||||
|
||||
```bash
|
||||
# List datasets for one or more projects (by numeric project ID)
|
||||
gitlink-cli dataset +list --ids 5988
|
||||
|
||||
# View a repository's dataset and attachments
|
||||
gitlink-cli dataset +view --owner Gitlink --repo forgeplus
|
||||
|
||||
# Create / update a repository's dataset (preview first with --dry-run)
|
||||
gitlink-cli dataset +create --owner me --repo proj -t "My dataset" -d "..." --license-id 359 --dry-run
|
||||
gitlink-cli dataset +update --owner me --repo proj -t "My dataset" -d "updated"
|
||||
|
||||
# Delete a dataset attachment (destructive: preview, then confirm with --yes)
|
||||
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --dry-run
|
||||
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --yes
|
||||
```
|
||||
|
||||
> Note: `dataset +list` (platform dataset query) is verified on production
|
||||
> gitlink.org.cn. The per-repo `+view`/`+create`/`+update` routes follow the
|
||||
> published OpenAPI contract but are not yet deployed on production (they return
|
||||
> 404 there); they will work once the platform enables them.
|
||||
|
||||
### Raw API
|
||||
|
||||
For endpoints not covered by shortcuts, use the Raw API directly:
|
||||
|
|
@ -256,6 +687,12 @@ gitlink-cli api GET /users/me
|
|||
# POST request
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
|
||||
|
||||
# POST request with body from a file
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json
|
||||
|
||||
# POST request with body from stdin
|
||||
Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin
|
||||
|
||||
# With query parameters
|
||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
||||
```
|
||||
|
|
@ -266,7 +703,7 @@ gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
|||
|-----------|-------------|---------|
|
||||
| `--owner` | Repository owner | `--owner Gitlink` |
|
||||
| `--repo` | Repository name | `--repo forgeplus` |
|
||||
| `--format` | Output format (json/table/yaml) | `--format json` |
|
||||
| `--format` | Output format (json/table/yaml; workflow also supports markdown) | `--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`.
|
||||
|
|
@ -293,24 +730,27 @@ git push gitlink
|
|||
|
||||
## AI Agent Skills
|
||||
|
||||
The `skills/` directory contains 12 Agent Skill files for AI-automated GitLink operations.
|
||||
The `skills/` directory contains Agent Skill files for AI-automated GitLink operations.
|
||||
|
||||
See [skills/README.md](skills/README.md) for details.
|
||||
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-repo` | Repository operations (create, view, delete, fork, insights, etc.) |
|
||||
| `gitlink-issue` | Issue operations (create, update, close, batch update/delete, comment, etc.) |
|
||||
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
|
||||
| `gitlink-member` | Repository member and invite link management |
|
||||
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
|
||||
| `gitlink-release` | Release management (create, view, delete, etc.) |
|
||||
| `gitlink-release` | Release management (create, edit, update, view, delete, etc.) |
|
||||
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
|
||||
| `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, 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.) |
|
||||
| `gitlink-health` | Project health analysis (PR/Issue metrics aggregation, health reports) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
|
@ -333,10 +773,12 @@ gitlink-cli/
|
|||
│ ├── repo/ # Repository shortcuts
|
||||
│ ├── issue/ # Issue shortcuts
|
||||
│ ├── pr/ # PR shortcuts
|
||||
│ ├── member/ # Repository member shortcuts
|
||||
│ ├── branch/ # Branch shortcuts
|
||||
│ ├── release/ # Release shortcuts
|
||||
│ ├── org/ # Organization shortcuts
|
||||
│ ├── ci/ # CI shortcuts
|
||||
│ ├── pipeline/ # Pipeline shortcuts
|
||||
│ ├── search/ # Search shortcuts
|
||||
│ ├── user/ # User shortcuts
|
||||
│ └── register.go # Registration entry point
|
||||
|
|
@ -360,7 +802,7 @@ gitlink-cli/
|
|||
|
||||
## Documentation
|
||||
|
||||
- [Skills Guide](skills/README.md) — AI Agent Skills detailed documentation
|
||||
- [Skills Guide](./skills/README.md) — AI Agent Skills detailed documentation
|
||||
- [Design Document](doc/design.md) — Architecture design and development plan
|
||||
|
||||
## FAQ
|
||||
|
|
@ -415,7 +857,9 @@ Reinstall first:
|
|||
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 .`.
|
||||
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?
|
||||
|
||||
|
|
@ -423,7 +867,7 @@ gitlink-cli uses Windows Credential Manager for secure token storage. If Credent
|
|||
|
||||
### Q: Where can I find the full API reference?
|
||||
|
||||
See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md).
|
||||
See [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md).
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
365
README.zh-CN.md
365
README.zh-CN.md
|
|
@ -5,16 +5,93 @@
|
|||
[](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/)。
|
||||
[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/README.md)。
|
||||
|
||||
**[English](./README.md)**
|
||||
|
||||
[安装](#安装与快速上手) · [AI Agent Skills](#ai-agent-skills) · [认证](#配置与使用) · [命令](#使用示例) · [贡献](#相关项目)
|
||||
|
||||
## 贡献者
|
||||
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;">
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/wangyue111">wangyue111</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/wbtiger">wbtiger</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/Mengz">Mengz</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/yangsai">yangsai</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/mengcheng">mengcheng</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/images/avatars/User/149182?t=1779603476" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/muel">muel</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/Leo77">Leo77</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/yingjie">yingjie</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/topshare">topshare</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/dtwdtw" title="dtwdtw"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/D/53_166_51/120.png" width="40" height="40" alt="dtwdtw" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/dtwdtw">dtwdtw</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/recorder" title="recorder"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/R/141_201_87/120.png" width="40" height="40" alt="recorder" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/recorder">recorder</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/puygob236" title="Jiachen Li"><img src="https://www.gitlink.org.cn/images/avatars/User/149183?t=1778815174" width="40" height="40" alt="puygob236" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/puygob236">puygob236</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/co63oc" title="co63oc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/C/205_201_141/120.png" width="40" height="40" alt="co63oc" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/co63oc">co63oc</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/lindiwen23" title="lindiwen23"><img src="https://www.gitlink.org.cn/images/avatars/User/141609?t=1748270628" width="40" height="40" alt="lindiwen23" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/lindiwen23">lindiwen23</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/ohanabi" title="ohanabi"><img src="https://www.gitlink.org.cn/images/avatars/User/148166?t=1778230283" width="40" height="40" alt="ohanabi" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/ohanabi">ohanabi</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/jiangtx" title="jiangtx"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/J/67_157_94/120.png" width="40" height="40" alt="jiangtx" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/jiangtx">jiangtx</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/luwanzhou" title="luwanzhou"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/165_135_246/120.png" width="40" height="40" alt="luwanzhou" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/luwanzhou">luwanzhou</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/whale_hihihi" title="whale_hihihi"><img src="https://www.gitlink.org.cn/images/avatars/User/137722?t=1778575729" width="40" height="40" alt="whale_hihihi" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/whale_hihihi">whale_hihihi</a></sub>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 为什么选择 gitlink-cli?
|
||||
|
||||
- **Agent-Native 设计** — 开箱即用 11 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||
- **广泛覆盖** — 仓库、Issue、PR、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
|
||||
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/README.md),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
|
||||
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
|
||||
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
|
||||
- **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用
|
||||
|
|
@ -26,15 +103,21 @@
|
|||
|
||||
| 分类 | 能力 |
|
||||
|------|------|
|
||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
||||
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
|
||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 |
|
||||
| 🐛 Issue | 创建、更新、关闭、批量关闭/更新/删除、评论 Issue |
|
||||
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
|
||||
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
||||
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
|
||||
| 🌿 分支 | 创建、删除、保护分支 |
|
||||
| 🏷️ 发布 | 创建、查看、删除 Release |
|
||||
| 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release |
|
||||
| 🏢 组织 | 管理组织、成员、团队 |
|
||||
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
|
||||
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
|
||||
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
|
||||
| 🔍 搜索 | 搜索仓库、用户 |
|
||||
| 📊 数据集 | 按项目查询科研数据集 |
|
||||
| 👤 用户 | 查看用户资料和信息 |
|
||||
| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 |
|
||||
| 📋 项目管理 | Sprint 管理、看板、周报 |
|
||||
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
|
||||
|
||||
|
|
@ -148,6 +231,36 @@ gitlink-cli repo +list
|
|||
# 查看仓库信息
|
||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
||||
|
||||
# 读取仓库 README
|
||||
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# 列出仓库根目录或指定目录文件
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
|
||||
|
||||
# 查看语言占比
|
||||
gitlink-cli repo +languages --owner Gitlink --repo forgeplus
|
||||
|
||||
# 列出贡献者
|
||||
gitlink-cli repo +contributors --owner Gitlink --repo forgeplus
|
||||
|
||||
# 查看分支、标签或提交的贡献者代码行统计
|
||||
gitlink-cli repo +contributor-stats --owner Gitlink --repo forgeplus --ref master --pass-year 1
|
||||
|
||||
# 查看仓库代码统计
|
||||
gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# 按时间范围查看关注者和点赞者
|
||||
gitlink-cli repo +watchers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
|
||||
gitlink-cli repo +stargazers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
|
||||
|
||||
# 预览并执行仓库互动操作
|
||||
gitlink-cli repo +follow --owner Gitlink --repo forgeplus --dry-run
|
||||
gitlink-cli repo +follow --owner Gitlink --repo forgeplus
|
||||
gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123
|
||||
gitlink-cli repo +like --owner Gitlink --repo forgeplus
|
||||
gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123
|
||||
|
||||
# 创建仓库
|
||||
gitlink-cli repo +create -n my-project -d "项目描述"
|
||||
|
||||
|
|
@ -155,6 +268,67 @@ gitlink-cli repo +create -n my-project -d "项目描述"
|
|||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Webhook 管理
|
||||
|
||||
```bash
|
||||
# 列出 webhook
|
||||
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建 webhook
|
||||
gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
|
||||
--url https://example.com/hook --events push,create
|
||||
|
||||
# 测试 webhook
|
||||
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
||||
|
||||
# 查看 webhook 投递任务
|
||||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### Wiki 管理
|
||||
|
||||
```bash
|
||||
# 列出 Wiki 页面(目录结构)
|
||||
gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
|
||||
|
||||
# 查看 Wiki 页面
|
||||
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
|
||||
|
||||
# 创建 Wiki 页面
|
||||
gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
|
||||
-n getting-started -t "快速开始" -c "# 快速开始指南"
|
||||
|
||||
# 更新 Wiki 页面标题和/或内容
|
||||
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题"
|
||||
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# 更新后的内容"
|
||||
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题" -c "新内容"
|
||||
|
||||
# 删除 Wiki 页面
|
||||
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
|
||||
```
|
||||
|
||||
### 成员管理
|
||||
|
||||
```bash
|
||||
# 列出仓库成员
|
||||
gitlink-cli member +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 添加成员
|
||||
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
|
||||
|
||||
# 预览批量添加成员,不修改数据
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
|
||||
|
||||
# 从 CSV 文件批量添加成员
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
|
||||
|
||||
# 调整成员权限
|
||||
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
|
||||
|
||||
# 生成邀请链接
|
||||
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
|
||||
```
|
||||
|
||||
### Issue 管理
|
||||
|
||||
```bash
|
||||
|
|
@ -164,9 +338,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
|
|||
# 创建 Issue
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."
|
||||
|
||||
# 创建带元数据的 Issue
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
|
||||
|
||||
# 查看 Issue
|
||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
||||
|
||||
# 更新 Issue 元数据
|
||||
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
|
||||
|
||||
# 关闭 Issue
|
||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||
|
||||
|
|
@ -176,8 +356,54 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
|
|||
# 从 CSV 文件批量关闭 Issue
|
||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
|
||||
|
||||
# 按 API issue id 预览批量更新元数据
|
||||
# 注意:--ids 是 API issue id,不是网页 URL 中的 Issue 编号。
|
||||
gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run
|
||||
|
||||
# 危险批量删除必须先 dry-run,真实执行还要显式 --yes
|
||||
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run
|
||||
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes
|
||||
|
||||
# 添加评论
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
|
||||
|
||||
# 列出 Issue 负责人
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
|
||||
|
||||
# 列出 Issue 发布人
|
||||
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
|
||||
|
||||
# 列出 Issue 优先级
|
||||
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus
|
||||
|
||||
# 列出 Issue 标签
|
||||
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
|
||||
|
||||
# 列出 Issue 状态
|
||||
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
`issue +view`、`issue +update`、`issue +close` 和 `issue +comment` 推荐使用
|
||||
`--number` / `-n` 传网页 URL 中的 Issue 编号。`--id` / `-i` 是同一网页 Issue
|
||||
编号的兼容别名,不是数据库内部 ID。
|
||||
|
||||
### 标签管理
|
||||
|
||||
```bash
|
||||
# 列出 Issue 标签
|
||||
gitlink-cli label +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 按关键词筛选标签
|
||||
gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug
|
||||
|
||||
# 创建标签(颜色默认 #1E90FF)
|
||||
gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "功能缺陷" -c "#FF0000"
|
||||
|
||||
# 更新标签(未指定的字段会被保留)
|
||||
gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00"
|
||||
|
||||
# 删除标签
|
||||
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
|
||||
```
|
||||
|
||||
### Pull Request
|
||||
|
|
@ -198,8 +424,24 @@ 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 +reopen --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看 PR 变更文件
|
||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看 PR patchset/version 列表
|
||||
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看指定 patchset/version diff
|
||||
gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040
|
||||
|
||||
# 查看 PR 审查记录
|
||||
gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 创建 PR 审查(支持 dry-run 预览)
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
|
||||
```
|
||||
|
||||
### 发布管理
|
||||
|
|
@ -208,11 +450,50 @@ gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
|||
# 列出 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,可附带附件 ID
|
||||
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..." --attachment-ids 12,34
|
||||
|
||||
# 查看 Release
|
||||
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
|
||||
|
||||
# 获取编辑数据并保留未传字段更新
|
||||
gitlink-cli release +edit --owner Gitlink --repo forgeplus -i <version_id>
|
||||
gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b "更新后的内容" --dry-run
|
||||
|
||||
# 删除前先预览请求
|
||||
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --dry-run
|
||||
```
|
||||
|
||||
### 流水线管理
|
||||
|
||||
```bash
|
||||
# 列出平台流水线
|
||||
gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20
|
||||
|
||||
# 列出仓库流水线运行记录
|
||||
gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml
|
||||
|
||||
# 运行流水线工作流,先用 dry-run 预览请求
|
||||
gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run
|
||||
|
||||
# 查看流水线详情、日志和运行结果
|
||||
gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7
|
||||
gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43
|
||||
gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99
|
||||
|
||||
# 启停或删除流水线工作流,写入/删除前先预览
|
||||
gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run
|
||||
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
|
||||
```
|
||||
|
||||
### 忽略文件模板
|
||||
|
||||
```bash
|
||||
# 列出所有可用的 .gitignore 模板
|
||||
gitlink-cli ignore +list
|
||||
|
||||
# 按名称筛选模板
|
||||
gitlink-cli ignore +list --name Go
|
||||
```
|
||||
|
||||
### 搜索
|
||||
|
|
@ -225,6 +506,50 @@ gitlink-cli search +repos -k "machine learning"
|
|||
gitlink-cli search +users -k "zhangsan"
|
||||
```
|
||||
|
||||
### 用户画像
|
||||
|
||||
`profile` 暴露 GitLink 原生的用户画像统计(开发能力、角色定位、专业定位、近期活动、贡献热力图)。
|
||||
省略 `--user` 时默认使用当前认证用户。
|
||||
|
||||
```bash
|
||||
# 开发能力评分 + 语言分布
|
||||
gitlink-cli profile +ability --user zhangsan
|
||||
|
||||
# 角色定位 / 专业(学科)定位
|
||||
gitlink-cli profile +role --user zhangsan
|
||||
gitlink-cli profile +major --user zhangsan
|
||||
|
||||
# 指定时间范围的开发能力(Unix 时间戳)
|
||||
gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600
|
||||
|
||||
# 当前用户的近期活动(每日 疑修 / 合并请求 / 提交)
|
||||
gitlink-cli profile +activity
|
||||
|
||||
# 指定年份的贡献热力图
|
||||
gitlink-cli profile +contribution --user zhangsan --year 2025
|
||||
```
|
||||
|
||||
### 数据集
|
||||
|
||||
`dataset` 管理并查询 GitLink 科研数据集(标题、描述、论文内容、许可证、所属项目)。
|
||||
|
||||
```bash
|
||||
# 按数字项目 ID 列出一个或多个项目的数据集
|
||||
gitlink-cli dataset +list --ids 5988
|
||||
|
||||
# 查看仓库的数据集及其附件
|
||||
gitlink-cli dataset +view --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建 / 更新仓库数据集(先用 --dry-run 预览)
|
||||
gitlink-cli dataset +create --owner me --repo proj -t "我的数据集" -d "..." --license-id 359 --dry-run
|
||||
gitlink-cli dataset +update --owner me --repo proj -t "我的数据集" -d "更新"
|
||||
|
||||
# 删除数据集附件(破坏性:先预览,再用 --yes 确认)
|
||||
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --dry-run
|
||||
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --yes
|
||||
```
|
||||
|
||||
> 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404),待平台上线后即可生效。
|
||||
### Raw API
|
||||
|
||||
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:
|
||||
|
|
@ -236,6 +561,12 @@ gitlink-cli api GET /users/me
|
|||
# POST 请求
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
|
||||
|
||||
# 从文件读取 JSON body
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json
|
||||
|
||||
# 从 stdin 读取 JSON body
|
||||
Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin
|
||||
|
||||
# 带查询参数
|
||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
||||
```
|
||||
|
|
@ -273,19 +604,21 @@ git push gitlink
|
|||
|
||||
## AI Agent Skills
|
||||
|
||||
`skills/` 目录包含 11 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
|
||||
`skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
|
||||
|
||||
详见 [skills/README.md](skills/README.md)
|
||||
详见 [skills/README.md](./skills/README.md)
|
||||
|
||||
| Skill | 说明 |
|
||||
|-------|------|
|
||||
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
|
||||
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork 等) |
|
||||
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
|
||||
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork、洞察数据等) |
|
||||
| `gitlink-issue` | Issue 操作(创建、更新、关闭、批量更新/删除、评论等) |
|
||||
| `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) |
|
||||
| `gitlink-release` | 发布管理(创建、查看、删除等) |
|
||||
| `gitlink-member` | 仓库成员与邀请链接管理 |
|
||||
| `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) |
|
||||
| `gitlink-org` | 组织管理(成员、团队等) |
|
||||
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
|
||||
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
|
||||
| `gitlink-search` | 搜索功能(仓库、用户等) |
|
||||
| `gitlink-user` | 用户管理(个人信息等) |
|
||||
| `gitlink-pm` | 项目管理(Sprint、看板、周报等) |
|
||||
|
|
@ -312,10 +645,12 @@ gitlink-cli/
|
|||
│ ├── repo/ # 仓库 shortcuts
|
||||
│ ├── issue/ # Issue shortcuts
|
||||
│ ├── pr/ # PR shortcuts
|
||||
│ ├── member/ # 仓库成员 shortcuts
|
||||
│ ├── branch/ # 分支 shortcuts
|
||||
│ ├── release/ # Release shortcuts
|
||||
│ ├── org/ # 组织 shortcuts
|
||||
│ ├── ci/ # CI shortcuts
|
||||
│ ├── pipeline/ # Pipeline shortcuts
|
||||
│ ├── search/ # 搜索 shortcuts
|
||||
│ ├── user/ # 用户 shortcuts
|
||||
│ └── register.go # 注册入口
|
||||
|
|
@ -339,7 +674,7 @@ gitlink-cli/
|
|||
|
||||
## 文档
|
||||
|
||||
- [Skills 使用指南](skills/README.md) — AI Agent Skills 详细说明
|
||||
- [Skills 使用指南](./skills/README.md) — AI Agent Skills 详细说明
|
||||
- [设计文档](doc/design.md) — 架构设计和开发计划
|
||||
|
||||
## 常见问题
|
||||
|
|
@ -402,7 +737,7 @@ gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Creden
|
|||
|
||||
### Q: 如何查看完整的 API 参考?
|
||||
|
||||
查看 [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md)
|
||||
查看 [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
|
|
|
|||
106
cmd/api/api.go
106
cmd/api/api.go
|
|
@ -2,37 +2,70 @@ package api
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
func NewAPICmd() *cobra.Command {
|
||||
func NewAPICmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
apiCmd := &cobra.Command{
|
||||
Use: "api <METHOD> <PATH>",
|
||||
Short: "Make raw API requests to GitLink",
|
||||
Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`,
|
||||
Use: "api (<METHOD> <PATH> | --batch-file <FILE>)",
|
||||
Short: tr.T("cmd.api.short"),
|
||||
Long: tr.T("cmd.api.long"),
|
||||
Example: ` gitlink-cli api GET /users/me
|
||||
gitlink-cli api GET /projects --query 'page=1&limit=10'
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
|
||||
gitlink-cli api POST /:owner/:repo/issues --body-file issue.json
|
||||
gitlink-cli api --batch-file plan.json --dry-run
|
||||
gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=gitlink-cli`,
|
||||
Args: validateAPIArgs,
|
||||
RunE: runAPI,
|
||||
}
|
||||
|
||||
apiCmd.Flags().String("body", "", "Request body (JSON string)")
|
||||
apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)")
|
||||
apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)")
|
||||
apiCmd.Flags().String("body", "", tr.T("flag.api.body"))
|
||||
apiCmd.Flags().String("body-file", "", tr.T("flag.api.body_file"))
|
||||
apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin"))
|
||||
apiCmd.Flags().String("query", "", tr.T("flag.api.query"))
|
||||
apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header"))
|
||||
apiCmd.Flags().String("batch-file", "", tr.T("flag.api.batch_file"))
|
||||
apiCmd.Flags().Bool("dry-run", false, tr.T("flag.api.batch_dry_run"))
|
||||
apiCmd.Flags().Bool("continue-on-error", false, tr.T("flag.api.batch_continue_on_error"))
|
||||
apiCmd.Flags().StringArray("var", nil, tr.T("flag.api.batch_var"))
|
||||
|
||||
return apiCmd
|
||||
}
|
||||
|
||||
func validateAPIArgs(c *cobra.Command, args []string) error {
|
||||
batchFile, _ := c.Flags().GetString("batch-file")
|
||||
if batchFile != "" {
|
||||
if len(args) != 0 {
|
||||
return fmt.Errorf("api batch mode does not accept METHOD or PATH arguments")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return cobra.ExactArgs(2)(c, args)
|
||||
}
|
||||
|
||||
func runAPI(c *cobra.Command, args []string) error {
|
||||
batchFile, _ := c.Flags().GetString("batch-file")
|
||||
if batchFile != "" {
|
||||
return runAPIBatch(c, batchFile)
|
||||
}
|
||||
|
||||
method := strings.ToUpper(args[0])
|
||||
path := args[1]
|
||||
|
||||
|
|
@ -46,12 +79,9 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
}
|
||||
cli.Debug = cmdutil.Debug
|
||||
|
||||
var body interface{}
|
||||
bodyStr, _ := c.Flags().GetString("body")
|
||||
if bodyStr != "" {
|
||||
if err := json.Unmarshal([]byte(bodyStr), &body); err != nil {
|
||||
return fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
body, err := readJSONBody(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var query url.Values
|
||||
|
|
@ -66,7 +96,8 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
|
||||
env, err := cli.Do(method, path, body, query)
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*client.APIError); ok {
|
||||
var apiErr *client.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "")
|
||||
return output.Print(errEnv, resolveFormat())
|
||||
}
|
||||
|
|
@ -76,6 +107,49 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
return output.Print(env, resolveFormat())
|
||||
}
|
||||
|
||||
func readJSONBody(c *cobra.Command) (interface{}, error) {
|
||||
bodyStr, _ := c.Flags().GetString("body")
|
||||
bodyFile, _ := c.Flags().GetString("body-file")
|
||||
bodyStdin, _ := c.Flags().GetBool("body-stdin")
|
||||
|
||||
sources := 0
|
||||
if bodyStr != "" {
|
||||
sources++
|
||||
}
|
||||
if bodyFile != "" {
|
||||
sources++
|
||||
}
|
||||
if bodyStdin {
|
||||
sources++
|
||||
}
|
||||
if sources == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if sources > 1 {
|
||||
return nil, fmt.Errorf("use only one of --body, --body-file, or --body-stdin")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
var err error
|
||||
switch {
|
||||
case bodyStr != "":
|
||||
data = []byte(bodyStr)
|
||||
case bodyFile != "":
|
||||
data, err = os.ReadFile(bodyFile)
|
||||
case bodyStdin:
|
||||
data, err = io.ReadAll(c.InOrStdin())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read JSON body: %w", err)
|
||||
}
|
||||
|
||||
var body interface{}
|
||||
if err := json.Unmarshal(data, &body); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func resolveFormat() string {
|
||||
f := cmdutil.Format
|
||||
if f == "" {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,404 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
)
|
||||
|
||||
func TestResolveFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flagFormat string
|
||||
want string
|
||||
}{
|
||||
{"empty defaults to json", "", "json"},
|
||||
{"explicit json", "json", "json"},
|
||||
{"explicit yaml", "yaml", "yaml"},
|
||||
{"explicit table", "table", "table"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmdutil.Format = tt.flagFormat
|
||||
if got := resolveFormat(); got != tt.want {
|
||||
t.Fatalf("resolveFormat = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAPICmd(t *testing.T) {
|
||||
cmd := NewAPICmd()
|
||||
if cmd.Use != "api (<METHOD> <PATH> | --batch-file <FILE>)" {
|
||||
t.Fatalf("Use = %q", cmd.Use)
|
||||
}
|
||||
if cmd.Short == "" {
|
||||
t.Fatal("Short is empty")
|
||||
}
|
||||
|
||||
// Verify flags exist
|
||||
flags := []string{"body", "query", "header", "batch-file", "dry-run", "continue-on-error", "var"}
|
||||
for _, f := range flags {
|
||||
if cmd.Flags().Lookup(f) == nil {
|
||||
t.Fatalf("flag %q not found", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupAPITest(t *testing.T, handler http.HandlerFunc) string {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
os.MkdirAll(dir, 0700)
|
||||
os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("base_url: "+server.URL+"\ndefault_format: table\n"), 0600)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestRunAPIGet(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/users/me.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"login": "testuser", "id": 42})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/users/me"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("runAPI GET error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIPostWithBody(t *testing.T) {
|
||||
var gotBody map[string]interface{}
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1, "title": "new issue"})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"POST", "/repos/owner/repo/issues"})
|
||||
cmd.Flags().Set("body", `{"title":"new issue","body":"test"}`)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("runAPI POST error: %v", err)
|
||||
}
|
||||
if gotBody["title"] != "new issue" {
|
||||
t.Fatalf("body title = %q, want 'new issue'", gotBody["title"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIBadJSONBody(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not reach server")
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"POST", "/repos/owner/repo/issues"})
|
||||
cmd.Flags().Set("body", `{bad json}`)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad JSON body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIBadQuery(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not reach server")
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/repos/owner/repo/issues"})
|
||||
cmd.Flags().Set("query", "key=%zz")
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad query string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIHTTPError(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("not found"))
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/nonexistent"})
|
||||
// HTTP errors are caught and printed as error envelopes; runAPI does not return the error
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("runAPI HTTP error: %v (expected success with error envelope)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIStatusError(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"status": float64(401), "message": "Unauthorized"})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/users/me"})
|
||||
// Should print error envelope, not return a Go error (status check in Do() handles this)
|
||||
// Actually, HTTP 401 triggers APIError return from Do(), so this should error
|
||||
if err := cmd.Execute(); err != nil {
|
||||
// Expected — HTTP error
|
||||
t.Logf("got expected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIDebug(t *testing.T) {
|
||||
var gotDebugHeader bool
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotDebugHeader = true
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
cmdutil.Debug = true
|
||||
defer func() { cmdutil.Debug = false }()
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/users/me"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("runAPI debug error: %v", err)
|
||||
}
|
||||
if !gotDebugHeader {
|
||||
t.Fatal("server not reached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPINoPrefix(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/users/me.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"login": "testuser"})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "users/me"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("runAPI no-prefix error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBatchRequestsTemplateVars(t *testing.T) {
|
||||
requests, err := renderBatchRequests([]batchRequest{
|
||||
{
|
||||
Name: "comment-{{number}}",
|
||||
Method: "post",
|
||||
Path: "v1/{{owner}}/{{repo}}/issues/{{number}}/journals",
|
||||
Query: map[string]interface{}{
|
||||
"label": []interface{}{"{{label}}", "triage"},
|
||||
"page": float64(1),
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"notes": "handled by {{actor}}",
|
||||
"meta": map[string]interface{}{"repo": "{{repo}}"},
|
||||
},
|
||||
},
|
||||
}, map[string]string{
|
||||
"owner": "Gitlink",
|
||||
"repo": "gitlink-cli",
|
||||
"number": "42",
|
||||
"label": "bug",
|
||||
"actor": "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("renderBatchRequests error: %v", err)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(requests))
|
||||
}
|
||||
req := requests[0]
|
||||
if req.Name != "comment-42" {
|
||||
t.Fatalf("Name = %q", req.Name)
|
||||
}
|
||||
if req.Method != "POST" {
|
||||
t.Fatalf("Method = %q", req.Method)
|
||||
}
|
||||
if req.Path != "/v1/Gitlink/gitlink-cli/issues/42/journals" {
|
||||
t.Fatalf("Path = %q", req.Path)
|
||||
}
|
||||
if got := req.Query["label"]; len(got) != 2 || got[0] != "bug" || got[1] != "triage" {
|
||||
t.Fatalf("label query = %#v", got)
|
||||
}
|
||||
body := req.Body.(map[string]interface{})
|
||||
if body["notes"] != "handled by bot" {
|
||||
t.Fatalf("notes = %#v", body["notes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBatchRequestsMissingVar(t *testing.T) {
|
||||
_, err := renderBatchRequests([]batchRequest{{Method: "GET", Path: "/{{missing}}"}}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing variable error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIBatchDryRunDoesNotReachServer(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("dry-run should not reach server")
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
plan := writeBatchPlan(t, map[string]interface{}{
|
||||
"vars": map[string]string{"owner": "Gitlink"},
|
||||
"requests": []map[string]interface{}{
|
||||
{"name": "me", "method": "GET", "path": "/users/me"},
|
||||
{"name": "repo", "method": "GET", "path": "/{{owner}}/gitlink-cli"},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"--batch-file", plan, "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("dry-run batch error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIBatchExecutesRequestsWithOverrides(t *testing.T) {
|
||||
var seen []string
|
||||
var gotBody map[string]interface{}
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = append(seen, r.Method+" "+r.URL.String())
|
||||
switch r.URL.Path {
|
||||
case "/v1/Mengz/gitlink-cli/issues.json":
|
||||
if r.URL.Query().Get("state") != "open" {
|
||||
t.Fatalf("state query = %q", r.URL.Query().Get("state"))
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"issues": []interface{}{}})
|
||||
case "/v1/Mengz/gitlink-cli/issues/7/journals.json":
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": 99})
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
plan := writeBatchPlan(t, map[string]interface{}{
|
||||
"vars": map[string]string{"owner": "Gitlink", "repo": "gitlink-cli", "issue": "7"},
|
||||
"requests": []map[string]interface{}{
|
||||
{
|
||||
"name": "list",
|
||||
"method": "GET",
|
||||
"path": "/v1/{{owner}}/{{repo}}/issues",
|
||||
"query": map[string]interface{}{"state": "open"},
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"method": "POST",
|
||||
"path": "/v1/{{owner}}/{{repo}}/issues/{{issue}}/journals",
|
||||
"body": map[string]interface{}{"notes": "hello {{repo}}"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"--batch-file", plan, "--var", "owner=Mengz"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("batch execute error: %v", err)
|
||||
}
|
||||
if len(seen) != 2 {
|
||||
t.Fatalf("requests = %d, want 2 (%v)", len(seen), seen)
|
||||
}
|
||||
if gotBody["notes"] != "hello gitlink-cli" {
|
||||
t.Fatalf("body notes = %#v", gotBody["notes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIBatchStopsOnErrorByDefault(t *testing.T) {
|
||||
var seen []string
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = append(seen, r.URL.Path)
|
||||
if r.URL.Path == "/fail.json" {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
plan := writeBatchPlan(t, map[string]interface{}{
|
||||
"requests": []map[string]interface{}{
|
||||
{"method": "GET", "path": "/ok"},
|
||||
{"method": "GET", "path": "/fail"},
|
||||
{"method": "GET", "path": "/never"},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"--batch-file", plan})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected batch error")
|
||||
}
|
||||
if len(seen) != 2 {
|
||||
t.Fatalf("requests = %d, want 2 (%v)", len(seen), seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIBatchContinueOnError(t *testing.T) {
|
||||
var seen []string
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = append(seen, r.URL.Path)
|
||||
if r.URL.Path == "/fail.json" {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
plan := writeBatchPlan(t, map[string]interface{}{
|
||||
"requests": []map[string]interface{}{
|
||||
{"method": "GET", "path": "/ok"},
|
||||
{"method": "GET", "path": "/fail"},
|
||||
{"method": "GET", "path": "/after"},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"--batch-file", plan, "--continue-on-error"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("batch should continue: %v", err)
|
||||
}
|
||||
if len(seen) != 3 {
|
||||
t.Fatalf("requests = %d, want 3 (%v)", len(seen), seen)
|
||||
}
|
||||
}
|
||||
|
||||
func writeBatchPlan(t *testing.T, payload interface{}) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal plan: %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "plan.json")
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatalf("write plan: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
|
@ -0,0 +1,351 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
type batchPlan struct {
|
||||
Vars map[string]string `json:"vars"`
|
||||
Requests []batchRequest `json:"requests"`
|
||||
}
|
||||
|
||||
type batchRequest struct {
|
||||
Name string `json:"name"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Query map[string]interface{} `json:"query"`
|
||||
Body interface{} `json:"body"`
|
||||
}
|
||||
|
||||
type renderedBatchRequest struct {
|
||||
Index int `json:"index" yaml:"index"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
Method string `json:"method" yaml:"method"`
|
||||
Path string `json:"path" yaml:"path"`
|
||||
Query url.Values `json:"query,omitempty" yaml:"query,omitempty"`
|
||||
Body interface{} `json:"body,omitempty" yaml:"body,omitempty"`
|
||||
}
|
||||
|
||||
type batchResult struct {
|
||||
Index int `json:"index" yaml:"index"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
Method string `json:"method" yaml:"method"`
|
||||
Path string `json:"path" yaml:"path"`
|
||||
OK bool `json:"ok" yaml:"ok"`
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
Data interface{} `json:"data,omitempty" yaml:"data,omitempty"`
|
||||
}
|
||||
|
||||
type batchSummary struct {
|
||||
DryRun bool `json:"dry_run" yaml:"dry_run"`
|
||||
ContinueOnError bool `json:"continue_on_error" yaml:"continue_on_error"`
|
||||
Total int `json:"total" yaml:"total"`
|
||||
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
||||
Failed int `json:"failed" yaml:"failed"`
|
||||
Variables map[string]string `json:"variables,omitempty" yaml:"variables,omitempty"`
|
||||
Requests []renderedBatchRequest `json:"requests,omitempty" yaml:"requests,omitempty"`
|
||||
Results []batchResult `json:"results,omitempty" yaml:"results,omitempty"`
|
||||
}
|
||||
|
||||
var templatePattern = regexp.MustCompile(`\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}`)
|
||||
|
||||
func runAPIBatch(c *cobra.Command, batchFile string) error {
|
||||
if hasSingleRequestInput(c) {
|
||||
return fmt.Errorf("use batch flags separately from --body, --body-file, --body-stdin, --query, or --header")
|
||||
}
|
||||
|
||||
dryRun, _ := c.Flags().GetBool("dry-run")
|
||||
continueOnError, _ := c.Flags().GetBool("continue-on-error")
|
||||
overrides, err := parseBatchVars(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plan, err := readBatchPlan(batchFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vars := mergeBatchVars(plan.Vars, overrides)
|
||||
requests, err := renderBatchRequests(plan.Requests, vars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dryRun {
|
||||
return output.Print(output.SuccessEnvelope(batchSummary{
|
||||
DryRun: true,
|
||||
ContinueOnError: continueOnError,
|
||||
Total: len(requests),
|
||||
Variables: sortedVars(vars),
|
||||
Requests: requests,
|
||||
}, nil), resolveFormat())
|
||||
}
|
||||
|
||||
cli, err := client.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cli.Debug = cmdutil.Debug
|
||||
|
||||
summary := batchSummary{
|
||||
DryRun: false,
|
||||
ContinueOnError: continueOnError,
|
||||
Total: len(requests),
|
||||
Variables: sortedVars(vars),
|
||||
Results: make([]batchResult, 0, len(requests)),
|
||||
}
|
||||
for _, req := range requests {
|
||||
result := batchResult{
|
||||
Index: req.Index,
|
||||
Name: req.Name,
|
||||
Method: req.Method,
|
||||
Path: req.Path,
|
||||
}
|
||||
env, callErr := cli.Do(req.Method, req.Path, req.Body, req.Query)
|
||||
if callErr != nil {
|
||||
summary.Failed++
|
||||
result.OK = false
|
||||
result.Error = apiBatchErrorMessage(callErr)
|
||||
summary.Results = append(summary.Results, result)
|
||||
if !continueOnError {
|
||||
_ = output.Print(output.SuccessEnvelope(summary, nil), resolveFormat())
|
||||
return callErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.Succeeded++
|
||||
result.OK = true
|
||||
if env != nil {
|
||||
result.Data = env.Data
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
return output.Print(output.SuccessEnvelope(summary, nil), resolveFormat())
|
||||
}
|
||||
|
||||
func hasSingleRequestInput(c *cobra.Command) bool {
|
||||
body, _ := c.Flags().GetString("body")
|
||||
bodyFile, _ := c.Flags().GetString("body-file")
|
||||
bodyStdin, _ := c.Flags().GetBool("body-stdin")
|
||||
query, _ := c.Flags().GetString("query")
|
||||
headers, _ := c.Flags().GetStringSlice("header")
|
||||
return body != "" || bodyFile != "" || bodyStdin || query != "" || len(headers) > 0
|
||||
}
|
||||
|
||||
func parseBatchVars(c *cobra.Command) (map[string]string, error) {
|
||||
raw, _ := c.Flags().GetStringArray("var")
|
||||
vars := make(map[string]string, len(raw))
|
||||
for _, item := range raw {
|
||||
key, value, ok := strings.Cut(item, "=")
|
||||
key = strings.TrimSpace(key)
|
||||
if !ok || key == "" {
|
||||
return nil, fmt.Errorf("invalid --var %q, want key=value", item)
|
||||
}
|
||||
vars[key] = value
|
||||
}
|
||||
return vars, nil
|
||||
}
|
||||
|
||||
func readBatchPlan(path string) (*batchPlan, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read batch file: %w", err)
|
||||
}
|
||||
var plan batchPlan
|
||||
if err := json.Unmarshal(data, &plan); err != nil {
|
||||
return nil, fmt.Errorf("invalid batch file JSON: %w", err)
|
||||
}
|
||||
if len(plan.Requests) == 0 {
|
||||
return nil, fmt.Errorf("batch file must contain at least one request")
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
func mergeBatchVars(planVars, overrides map[string]string) map[string]string {
|
||||
vars := make(map[string]string, len(planVars)+len(overrides))
|
||||
for key, value := range planVars {
|
||||
vars[key] = value
|
||||
}
|
||||
for key, value := range overrides {
|
||||
vars[key] = value
|
||||
}
|
||||
return vars
|
||||
}
|
||||
|
||||
func renderBatchRequests(requests []batchRequest, vars map[string]string) ([]renderedBatchRequest, error) {
|
||||
rendered := make([]renderedBatchRequest, 0, len(requests))
|
||||
for i, req := range requests {
|
||||
method := strings.ToUpper(strings.TrimSpace(req.Method))
|
||||
if method == "" {
|
||||
return nil, fmt.Errorf("request %d method is required", i+1)
|
||||
}
|
||||
path, err := renderTemplate(req.Path, vars)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request %d path: %w", i+1, err)
|
||||
}
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("request %d path is required", i+1)
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
query, err := renderBatchQuery(req.Query, vars)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request %d query: %w", i+1, err)
|
||||
}
|
||||
body, err := renderBatchValue(req.Body, vars)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request %d body: %w", i+1, err)
|
||||
}
|
||||
name, err := renderTemplate(req.Name, vars)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request %d name: %w", i+1, err)
|
||||
}
|
||||
rendered = append(rendered, renderedBatchRequest{
|
||||
Index: i + 1,
|
||||
Name: name,
|
||||
Method: method,
|
||||
Path: path,
|
||||
Query: query,
|
||||
Body: body,
|
||||
})
|
||||
}
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
func renderBatchQuery(raw map[string]interface{}, vars map[string]string) (url.Values, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query := url.Values{}
|
||||
keys := make([]string, 0, len(raw))
|
||||
for key := range raw {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
renderedKey, err := renderTemplate(key, vars)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := renderQueryValues(raw[key], vars)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
for _, value := range values {
|
||||
query.Add(renderedKey, value)
|
||||
}
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func renderQueryValues(raw interface{}, vars map[string]string) ([]string, error) {
|
||||
switch value := raw.(type) {
|
||||
case nil:
|
||||
return []string{""}, nil
|
||||
case string:
|
||||
rendered, err := renderTemplate(value, vars)
|
||||
return []string{rendered}, err
|
||||
case []interface{}:
|
||||
values := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
itemValues, err := renderQueryValues(item, vars)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, itemValues...)
|
||||
}
|
||||
return values, nil
|
||||
default:
|
||||
return []string{fmt.Sprint(value)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func renderBatchValue(raw interface{}, vars map[string]string) (interface{}, error) {
|
||||
switch value := raw.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case string:
|
||||
return renderTemplate(value, vars)
|
||||
case []interface{}:
|
||||
items := make([]interface{}, 0, len(value))
|
||||
for _, item := range value {
|
||||
rendered, err := renderBatchValue(item, vars)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, rendered)
|
||||
}
|
||||
return items, nil
|
||||
case map[string]interface{}:
|
||||
obj := make(map[string]interface{}, len(value))
|
||||
for key, item := range value {
|
||||
renderedKey, err := renderTemplate(key, vars)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rendered, err := renderBatchValue(item, vars)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj[renderedKey] = rendered
|
||||
}
|
||||
return obj, nil
|
||||
default:
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
|
||||
func renderTemplate(value string, vars map[string]string) (string, error) {
|
||||
var missing []string
|
||||
rendered := templatePattern.ReplaceAllStringFunc(value, func(match string) string {
|
||||
parts := templatePattern.FindStringSubmatch(match)
|
||||
if len(parts) != 2 {
|
||||
return match
|
||||
}
|
||||
replacement, ok := vars[parts[1]]
|
||||
if !ok {
|
||||
missing = append(missing, parts[1])
|
||||
return match
|
||||
}
|
||||
return replacement
|
||||
})
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
return "", fmt.Errorf("missing template variable(s): %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
func sortedVars(vars map[string]string) map[string]string {
|
||||
if len(vars) == 0 {
|
||||
return nil
|
||||
}
|
||||
copyVars := make(map[string]string, len(vars))
|
||||
for key, value := range vars {
|
||||
copyVars[key] = value
|
||||
}
|
||||
return copyVars
|
||||
}
|
||||
|
||||
func apiBatchErrorMessage(err error) string {
|
||||
var apiErr *client.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.Message
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
139
cmd/auth/auth.go
139
cmd/auth/auth.go
|
|
@ -2,141 +2,182 @@ package auth
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
)
|
||||
|
||||
const envTokenVar = "GITLINK_TOKEN"
|
||||
|
||||
func NewAuthCmd() *cobra.Command {
|
||||
var (
|
||||
storeToken = internalAuth.StoreToken
|
||||
loadToken = internalAuth.LoadToken
|
||||
)
|
||||
|
||||
func NewAuthCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Authentication commands",
|
||||
Short: tr.T("cmd.auth.short"),
|
||||
}
|
||||
cmd.AddCommand(newLoginCmd())
|
||||
cmd.AddCommand(newLogoutCmd())
|
||||
cmd.AddCommand(newStatusCmd())
|
||||
cmd.AddCommand(newLoginCmd(tr))
|
||||
cmd.AddCommand(newLogoutCmd(tr))
|
||||
cmd.AddCommand(newStatusCmd(tr))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLoginCmd() *cobra.Command {
|
||||
func newLoginCmd(tr *i18n.Translator) *cobra.Command {
|
||||
var tokenMode bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Login to GitLink",
|
||||
Short: tr.T("cmd.auth.login.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if tokenMode {
|
||||
return loginWithToken()
|
||||
return loginWithToken(cmd.InOrStdin(), cmd.OutOrStdout(), tr)
|
||||
}
|
||||
return loginWithPassword()
|
||||
return loginWithPassword(cmd.InOrStdin(), cmd.OutOrStdout(), tr)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token")
|
||||
cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func loginWithPassword() error {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Print("Username/Email/Phone: ")
|
||||
func loginWithPassword(in io.Reader, out io.Writer, tr *i18n.Translator) error {
|
||||
reader := bufio.NewReader(in)
|
||||
if _, err := fmt.Fprint(out, tr.T("prompt.auth.username")); err != nil {
|
||||
return err
|
||||
}
|
||||
username, _ := reader.ReadString('\n')
|
||||
username = strings.TrimSpace(username)
|
||||
|
||||
fmt.Print("Password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if _, err := fmt.Fprint(out, tr.T("prompt.auth.password")); err != nil {
|
||||
return err
|
||||
}
|
||||
passwordBytes, err := readPassword(in, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
if _, err := fmt.Fprintln(out); err != nil {
|
||||
return err
|
||||
}
|
||||
password := string(passwordBytes)
|
||||
|
||||
result, err := internalAuth.Login(username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
return errors.New(tr.Tf("error.auth.login_failed", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Logged in as %s\n", result.Login)
|
||||
return nil
|
||||
_, err = fmt.Fprintln(out, tr.Tf("success.auth.logged_in_as", i18n.Args{"login": result.Login}))
|
||||
return err
|
||||
}
|
||||
|
||||
func loginWithToken() error {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("Paste your token: ")
|
||||
func readPassword(in io.Reader, reader *bufio.Reader) ([]byte, error) {
|
||||
if file, ok := in.(*os.File); ok {
|
||||
fd := int(file.Fd())
|
||||
if term.IsTerminal(fd) {
|
||||
return term.ReadPassword(fd)
|
||||
}
|
||||
}
|
||||
password, err := reader.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(strings.TrimRight(password, "\r\n")), nil
|
||||
}
|
||||
|
||||
func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error {
|
||||
reader := bufio.NewReader(in)
|
||||
if _, err := fmt.Fprint(out, tr.T("prompt.auth.token")); err != nil {
|
||||
return err
|
||||
}
|
||||
token, _ := reader.ReadString('\n')
|
||||
token = strings.TrimSpace(token)
|
||||
|
||||
if token == "" {
|
||||
return fmt.Errorf("token cannot be empty")
|
||||
return errors.New(tr.T("error.auth.token_empty"))
|
||||
}
|
||||
|
||||
if err := internalAuth.StoreToken(token); err != nil {
|
||||
return fmt.Errorf("failed to store token: %w", err)
|
||||
if err := storeToken(token); err != nil {
|
||||
return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
|
||||
fmt.Println("✓ Token saved")
|
||||
return nil
|
||||
_, err := fmt.Fprintln(out, tr.T("success.auth.token_saved"))
|
||||
return err
|
||||
}
|
||||
|
||||
func newLogoutCmd() *cobra.Command {
|
||||
func newLogoutCmd(tr *i18n.Translator) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Logout from GitLink",
|
||||
Short: tr.T("cmd.auth.logout.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := internalAuth.DeleteToken(); err != nil {
|
||||
return fmt.Errorf("failed to delete token: %w", err)
|
||||
return errors.New(tr.Tf("error.auth.delete_token_failed", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
fmt.Println("✓ Logged out")
|
||||
return nil
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out"))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newStatusCmd() *cobra.Command {
|
||||
func newStatusCmd(tr *i18n.Translator) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show authentication status",
|
||||
Short: tr.T("cmd.auth.status.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
out := cmd.OutOrStdout()
|
||||
// Check env var token first
|
||||
if envToken := os.Getenv(envTokenVar); envToken != "" {
|
||||
fmt.Printf("✓ Logged in via %s environment variable\n", envTokenVar)
|
||||
if _, err := fmt.Fprintln(out, tr.Tf("success.auth.logged_in_via_env", i18n.Args{"env": envTokenVar})); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
token, err := internalAuth.LoadToken()
|
||||
token, err := loadToken()
|
||||
if err != nil || token == "" {
|
||||
if os.Getenv(envTokenVar) == "" {
|
||||
fmt.Println("✗ Not logged in")
|
||||
fmt.Println(" Run: gitlink-cli auth login")
|
||||
fmt.Printf(" Or set %s environment variable\n", envTokenVar)
|
||||
if _, err := fmt.Fprintln(out, tr.T("warning.auth.not_logged_in")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out, tr.T("output.auth.login_hint")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out, tr.Tf("output.auth.env_hint", i18n.Args{"env": envTokenVar})); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := internalAuth.GetCurrentUser()
|
||||
if err != nil {
|
||||
fmt.Printf("✓ Token stored (but cannot verify: %v)\n", err)
|
||||
return nil
|
||||
_, err := fmt.Fprintln(out, tr.Tf("warning.auth.token_unverified", i18n.Args{"message": err.Error()}))
|
||||
return err
|
||||
}
|
||||
|
||||
login, _ := user["login"].(string)
|
||||
name, _ := user["name"].(string)
|
||||
if login != "" {
|
||||
fmt.Printf("✓ Logged in as %s", login)
|
||||
text := tr.Tf("success.auth.logged_in_as", i18n.Args{"login": login})
|
||||
if name != "" {
|
||||
fmt.Printf(" (%s)", name)
|
||||
text = fmt.Sprintf("%s (%s)", text, name)
|
||||
}
|
||||
fmt.Println()
|
||||
} else {
|
||||
fmt.Println("✓ Token stored (user info unavailable)")
|
||||
_, err := fmt.Fprintln(out, text)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
_, err = fmt.Fprintln(out, tr.T("warning.auth.user_unavailable"))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/zalando/go-keyring"
|
||||
|
||||
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||
)
|
||||
|
||||
func tempConfigDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestEnvTokenVar(t *testing.T) {
|
||||
if envTokenVar != "GITLINK_TOKEN" {
|
||||
t.Fatalf("envTokenVar = %q, want GITLINK_TOKEN", envTokenVar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthCmd(t *testing.T) {
|
||||
cmd := NewAuthCmd()
|
||||
if cmd.Use != "auth" {
|
||||
t.Fatalf("Use = %q, want auth", cmd.Use)
|
||||
}
|
||||
if cmd.Short == "" {
|
||||
t.Fatal("Short is empty")
|
||||
}
|
||||
|
||||
expectedSubs := map[string]bool{
|
||||
"login": false, "logout": false, "status": false,
|
||||
}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if _, ok := expectedSubs[sub.Use]; !ok {
|
||||
t.Fatalf("unexpected subcommand: %q", sub.Use)
|
||||
}
|
||||
if expectedSubs[sub.Use] {
|
||||
t.Fatalf("duplicate subcommand: %q", sub.Use)
|
||||
}
|
||||
expectedSubs[sub.Use] = true
|
||||
if sub.Short == "" {
|
||||
t.Fatalf("subcommand %q has empty Short", sub.Use)
|
||||
}
|
||||
}
|
||||
for name, found := range expectedSubs {
|
||||
if !found {
|
||||
t.Fatalf("missing subcommand: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginTokenFlag(t *testing.T) {
|
||||
cmd := NewAuthCmd()
|
||||
loginCmd := findSub(cmd, "login")
|
||||
if loginCmd == nil {
|
||||
t.Fatal("login subcommand not found")
|
||||
}
|
||||
if f := loginCmd.Flags().Lookup("token"); f == nil {
|
||||
t.Fatal("login command missing --token flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCmdNotLoggedIn(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
_ = internalAuth.DeleteToken()
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
if cmd == nil {
|
||||
t.Fatal("status subcommand not found")
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("status error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCmdEnvToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "env-token-123")
|
||||
_ = internalAuth.DeleteToken()
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
cmd.RunE(cmd, nil)
|
||||
}
|
||||
|
||||
func TestStatusCmdStoredToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
dir := tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
os.MkdirAll(dir, 0700)
|
||||
os.WriteFile(filepath.Join(dir, "credentials"), []byte("cookie:test=abc"), 0600)
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
cmd.RunE(cmd, nil)
|
||||
}
|
||||
|
||||
func TestStatusCmdEnvAndStoredToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
dir := tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "env-token")
|
||||
|
||||
os.MkdirAll(dir, 0700)
|
||||
os.WriteFile(filepath.Join(dir, "credentials"), []byte("stored-token"), 0600)
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("status error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCmdStoredTokenButLoadFails(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
// Do not create credentials; LoadToken should return empty.
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("status error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutCmdNoStoredToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
// Do not create credentials; logout should be idempotent.
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "logout")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("logout should succeed without stored token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutCmd(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
dir := tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
// Store a token first so DeleteToken has something to delete
|
||||
os.MkdirAll(dir, 0700)
|
||||
os.WriteFile(filepath.Join(dir, "credentials"), []byte("some-token"), 0600)
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "logout")
|
||||
if cmd == nil {
|
||||
t.Fatal("logout subcommand not found")
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("logout error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWithToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
dir := tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
// Mock stdin
|
||||
oldStdin := os.Stdin
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdin = r
|
||||
defer func() { os.Stdin = oldStdin }()
|
||||
|
||||
go func() {
|
||||
w.Write([]byte("test-token-123\n"))
|
||||
w.Close()
|
||||
}()
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "login")
|
||||
if cmd == nil {
|
||||
t.Fatal("login subcommand not found")
|
||||
}
|
||||
cmd.Flags().Set("token", "true")
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("login --token error: %v", err)
|
||||
}
|
||||
|
||||
// Verify token was saved to file
|
||||
data, err := os.ReadFile(filepath.Join(dir, "credentials"))
|
||||
if err != nil {
|
||||
t.Fatalf("read credentials: %v", err)
|
||||
}
|
||||
if string(data) != "test-token-123" {
|
||||
t.Fatalf("token = %q, want test-token-123", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWithTokenEmpty(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
oldStdin := os.Stdin
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdin = r
|
||||
defer func() { os.Stdin = oldStdin }()
|
||||
|
||||
go func() {
|
||||
w.Write([]byte("\n"))
|
||||
w.Close()
|
||||
}()
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "login")
|
||||
cmd.Flags().Set("token", "true")
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWithPasswordNoTerminal(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
// term.ReadPassword will fail because test has no terminal
|
||||
cmd := findSub(NewAuthCmd(), "login")
|
||||
// Don't set --token, so it goes to loginWithPassword
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when terminal unavailable (ReadPassword fails)")
|
||||
}
|
||||
}
|
||||
|
||||
func findSub(cmd *cobra.Command, name string) *cobra.Command {
|
||||
for _, sub := range cmd.Commands() {
|
||||
if sub.Use == name {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewRootCmdDefaults(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root.Use != "gitlink-cli" {
|
||||
t.Fatalf("Use = %q", root.Use)
|
||||
}
|
||||
if !root.SilenceUsage {
|
||||
t.Fatal("expected SilenceUsage=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelp(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("help command error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCmd(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"version"}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("version command error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "gitlink-cli test" {
|
||||
t.Fatalf("version output = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootCmdHasSubcommands(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, sub := range root.Commands() {
|
||||
names[sub.Use] = true
|
||||
}
|
||||
for _, want := range []string{"auth", "config", "doctor", "version"} {
|
||||
if !names[want] {
|
||||
t.Fatalf("missing subcommand: %s", want)
|
||||
}
|
||||
}
|
||||
if len(root.Commands()) < 4 {
|
||||
t.Fatalf("expected at least 4 subcommands, got %d", len(root.Commands()))
|
||||
}
|
||||
}
|
||||
|
|
@ -6,4 +6,5 @@ var (
|
|||
Repo string
|
||||
Format string
|
||||
Debug bool
|
||||
Lang string
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,59 +1,80 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
)
|
||||
|
||||
func NewConfigCmd() *cobra.Command {
|
||||
func NewConfigCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage gitlink-cli configuration",
|
||||
Short: tr.T("cmd.config.short"),
|
||||
}
|
||||
cmd.AddCommand(newInitCmd())
|
||||
cmd.AddCommand(newSetCmd())
|
||||
cmd.AddCommand(newGetCmd())
|
||||
cmd.AddCommand(newListCmd())
|
||||
cmd.AddCommand(newInitCmd(tr))
|
||||
cmd.AddCommand(newSetCmd(tr))
|
||||
cmd.AddCommand(newGetCmd(tr))
|
||||
cmd.AddCommand(newListCmd(tr))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInitCmd() *cobra.Command {
|
||||
func newInitCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
return &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize configuration file",
|
||||
Short: tr.T("cmd.config.init.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg := internalConfig.DefaultConfig()
|
||||
if err := internalConfig.Save(cfg); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
return errors.New(tr.Tf("error.config.save_failed", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
fmt.Printf("✓ Config initialized at %s\n", internalConfig.ConfigPath())
|
||||
return nil
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.initialized", i18n.Args{"path": internalConfig.ConfigPath()}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newSetCmd() *cobra.Command {
|
||||
func newSetCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
return &cobra.Command{
|
||||
Use: "set <key> <value>",
|
||||
Short: "Set a configuration value",
|
||||
Short: tr.T("cmd.config.set.short"),
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := internalConfig.Set(args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ %s = %s\n", args[0], args[1])
|
||||
return nil
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.set", i18n.Args{
|
||||
"key": args[0],
|
||||
"value": args[1],
|
||||
}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newGetCmd() *cobra.Command {
|
||||
func newGetCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
return &cobra.Command{
|
||||
Use: "get <key>",
|
||||
Short: "Get a configuration value",
|
||||
Short: tr.T("cmd.config.get.short"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
val, err := internalConfig.Get(args[0])
|
||||
|
|
@ -61,30 +82,49 @@ func newGetCmd() *cobra.Command {
|
|||
return err
|
||||
}
|
||||
if val == "" {
|
||||
fmt.Printf("%s: (not set)\n", args[0])
|
||||
} else {
|
||||
fmt.Printf("%s: %s\n", args[0], val)
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], tr.T("output.config.not_set"))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], val)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newListCmd() *cobra.Command {
|
||||
func newListCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all configuration values",
|
||||
Short: tr.T("cmd.config.list.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("base_url: %s\n", cfg.BaseURL)
|
||||
fmt.Printf("default_format: %s\n", cfg.Format)
|
||||
fmt.Printf("editor: %s\n", cfg.Editor)
|
||||
fmt.Printf("pager: %s\n", cfg.Pager)
|
||||
fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath())
|
||||
return nil
|
||||
out := cmd.OutOrStdout()
|
||||
if _, err := fmt.Fprintf(out, "base_url: %s\n", cfg.BaseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "default_format: %s\n", cfg.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "editor: %s\n", cfg.Editor); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "pager: %s\n", cfg.Pager); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "lang: %s\n", cfg.Lang); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(out, tr.Tf("output.config.file", i18n.Args{"path": internalConfig.ConfigPath()}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestNewConfigCmd(t *testing.T) {
|
||||
cmd := NewConfigCmd()
|
||||
if cmd.Use != "config" {
|
||||
t.Fatalf("Use = %q, want config", cmd.Use)
|
||||
}
|
||||
if cmd.Short == "" {
|
||||
t.Fatal("Short is empty")
|
||||
}
|
||||
|
||||
expectedSubs := map[string]bool{
|
||||
"init": false, "set <key> <value>": false, "get <key>": false, "list": false,
|
||||
}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if _, ok := expectedSubs[sub.Use]; !ok {
|
||||
t.Fatalf("unexpected subcommand: %q", sub.Use)
|
||||
}
|
||||
if expectedSubs[sub.Use] {
|
||||
t.Fatalf("duplicate subcommand: %q", sub.Use)
|
||||
}
|
||||
expectedSubs[sub.Use] = true
|
||||
if sub.Short == "" {
|
||||
t.Fatalf("subcommand %q has empty Short", sub.Use)
|
||||
}
|
||||
}
|
||||
for name, found := range expectedSubs {
|
||||
if !found {
|
||||
t.Fatalf("missing subcommand: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCmdArgs(t *testing.T) {
|
||||
cmd := findSub(NewConfigCmd(), "set <key> <value>")
|
||||
if cmd == nil {
|
||||
t.Fatal("set subcommand not found")
|
||||
}
|
||||
if cmd.Args == nil {
|
||||
t.Fatal("set should require exact args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCmdArgs(t *testing.T) {
|
||||
cmd := findSub(NewConfigCmd(), "get <key>")
|
||||
if cmd == nil {
|
||||
t.Fatal("get subcommand not found")
|
||||
}
|
||||
if cmd.Args == nil {
|
||||
t.Fatal("get should require exact args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInitRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
cmd := findSub(NewConfigCmd(), "init")
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("init error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetAndGet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
// Init first
|
||||
initCmd := findSub(NewConfigCmd(), "init")
|
||||
initCmd.SetArgs([]string{})
|
||||
if err := initCmd.Execute(); err != nil {
|
||||
t.Fatalf("init error: %v", err)
|
||||
}
|
||||
|
||||
// Set a value
|
||||
setCmd := findSub(NewConfigCmd(), "set <key> <value>")
|
||||
setCmd.SetArgs([]string{"base_url", "https://example.com"})
|
||||
if err := setCmd.Execute(); err != nil {
|
||||
t.Fatalf("set error: %v", err)
|
||||
}
|
||||
|
||||
// Get it back
|
||||
getCmd := findSub(NewConfigCmd(), "get <key>")
|
||||
getCmd.SetArgs([]string{"base_url"})
|
||||
if err := getCmd.Execute(); err != nil {
|
||||
t.Fatalf("get error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetNotSet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
os.WriteFile(dir+"/config.yaml", []byte("base_url: https://example.com\n"), 0644)
|
||||
|
||||
getCmd := findSub(NewConfigCmd(), "get <key>")
|
||||
getCmd.SetArgs([]string{"editor"})
|
||||
if err := getCmd.Execute(); err != nil {
|
||||
t.Fatalf("get not-set error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
initCmd := findSub(NewConfigCmd(), "init")
|
||||
initCmd.SetArgs([]string{})
|
||||
if err := initCmd.Execute(); err != nil {
|
||||
t.Fatalf("init error: %v", err)
|
||||
}
|
||||
|
||||
listCmd := findSub(NewConfigCmd(), "list")
|
||||
listCmd.SetArgs([]string{})
|
||||
if err := listCmd.Execute(); err != nil {
|
||||
t.Fatalf("list error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInitRunE(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
cmd := newInitCmd()
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("init RunE error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetRunE(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
// Init first so config file exists
|
||||
initCmd := newInitCmd()
|
||||
if err := initCmd.RunE(initCmd, nil); err != nil {
|
||||
t.Fatalf("init error: %v", err)
|
||||
}
|
||||
|
||||
cmd := newSetCmd()
|
||||
if err := cmd.RunE(cmd, []string{"base_url", "https://example.com"}); err != nil {
|
||||
t.Fatalf("set RunE error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetRunENoConfig(t *testing.T) {
|
||||
// Set without init should still work — Load returns defaults, Save creates dir
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
cmd := newSetCmd()
|
||||
if err := cmd.RunE(cmd, []string{"base_url", "https://example.com"}); err != nil {
|
||||
t.Fatalf("set RunE error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetRunE(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
initCmd := newInitCmd()
|
||||
initCmd.RunE(initCmd, nil)
|
||||
|
||||
cmd := newGetCmd()
|
||||
if err := cmd.RunE(cmd, []string{"base_url"}); err != nil {
|
||||
t.Fatalf("get RunE error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetRunENotSet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
os.WriteFile(dir+"/config.yaml", []byte("base_url: https://example.com\n"), 0644)
|
||||
|
||||
cmd := newGetCmd()
|
||||
if err := cmd.RunE(cmd, []string{"editor"}); err != nil {
|
||||
t.Fatalf("get RunE not-set error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListRunE(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
initCmd := newInitCmd()
|
||||
initCmd.RunE(initCmd, nil)
|
||||
|
||||
cmd := newListCmd()
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("list RunE error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListRunENoConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
// Don't init — Load returns defaults for missing file, so this should work
|
||||
cmd := newListCmd()
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("list RunE error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func findSub(cmd *cobra.Command, name string) *cobra.Command {
|
||||
for _, sub := range cmd.Commands() {
|
||||
if sub.Use == name {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
package doctor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
repoContext "github.com/gitlink-org/gitlink-cli/internal/context"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
const (
|
||||
statusOK = "ok"
|
||||
statusWarning = "warning"
|
||||
statusError = "error"
|
||||
statusSkipped = "skipped"
|
||||
)
|
||||
|
||||
var (
|
||||
loadConfig = internalConfig.Load
|
||||
loadToken = internalAuth.LoadToken
|
||||
getCurrentUser = internalAuth.GetCurrentUser
|
||||
resolveOwnerRepo = repoContext.ResolveOwnerRepo
|
||||
statFile = os.Stat
|
||||
lookupEnv = os.LookupEnv
|
||||
)
|
||||
|
||||
type Report struct {
|
||||
OK bool `json:"ok"`
|
||||
Summary Summary `json:"summary"`
|
||||
Checks []Check `json:"checks"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
OK int `json:"ok"`
|
||||
Warning int `json:"warning"`
|
||||
Error int `json:"error"`
|
||||
Skipped int `json:"skipped"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type Check struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Suggestion string `json:"suggestion,omitempty"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func NewDoctorCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
|
||||
var skipNetwork bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: tr.T("cmd.doctor.short"),
|
||||
Long: tr.T("cmd.doctor.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
report := Run(skipNetwork, tr)
|
||||
return output.PrintTo(cmd.OutOrStdout(), output.SuccessEnvelope(report, nil), resolveFormat())
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&skipNetwork, "skip-network", false, tr.T("flag.doctor.skip_network"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func Run(skipNetwork bool, tr *i18n.Translator) Report {
|
||||
if tr == nil {
|
||||
tr = i18n.Default()
|
||||
}
|
||||
|
||||
checks := make([]Check, 0, 5)
|
||||
cfg, cfgErr := loadConfig()
|
||||
checks = append(checks, checkConfigFile(tr, cfgErr))
|
||||
checks = append(checks, checkConfigValues(tr, cfg, cfgErr))
|
||||
checks = append(checks, checkAuthToken(tr))
|
||||
checks = append(checks, checkRepoContext(tr))
|
||||
checks = append(checks, checkAuthenticatedUser(tr, skipNetwork, cfgErr))
|
||||
|
||||
report := Report{OK: true, Checks: checks}
|
||||
seenActions := map[string]bool{}
|
||||
for _, check := range checks {
|
||||
report.Summary.Total++
|
||||
switch check.Status {
|
||||
case statusOK:
|
||||
report.Summary.OK++
|
||||
case statusWarning:
|
||||
report.Summary.Warning++
|
||||
case statusError:
|
||||
report.OK = false
|
||||
report.Summary.Error++
|
||||
case statusSkipped:
|
||||
report.Summary.Skipped++
|
||||
}
|
||||
if check.Suggestion != "" && !seenActions[check.Suggestion] {
|
||||
report.Actions = append(report.Actions, check.Suggestion)
|
||||
seenActions[check.Suggestion] = true
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func checkConfigFile(tr *i18n.Translator, cfgErr error) Check {
|
||||
path := internalConfig.ConfigPath()
|
||||
info, err := statFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return Check{
|
||||
Name: "config_file",
|
||||
Status: statusWarning,
|
||||
Message: tr.T("output.doctor.config_file.missing"),
|
||||
Suggestion: "gitlink-cli config init",
|
||||
Details: map[string]interface{}{"path": path},
|
||||
}
|
||||
}
|
||||
return Check{
|
||||
Name: "config_file",
|
||||
Status: statusError,
|
||||
Message: tr.Tf("output.doctor.config_file.unreadable", i18n.Args{"message": err.Error()}),
|
||||
Suggestion: tr.T("output.doctor.suggestion.check_config_permissions"),
|
||||
Details: map[string]interface{}{"path": path},
|
||||
}
|
||||
}
|
||||
if cfgErr != nil {
|
||||
return Check{
|
||||
Name: "config_file",
|
||||
Status: statusError,
|
||||
Message: tr.Tf("output.doctor.config_file.invalid", i18n.Args{"message": cfgErr.Error()}),
|
||||
Suggestion: tr.T("output.doctor.suggestion.fix_config_yaml"),
|
||||
Details: map[string]interface{}{"path": path},
|
||||
}
|
||||
}
|
||||
return Check{
|
||||
Name: "config_file",
|
||||
Status: statusOK,
|
||||
Message: tr.T("output.doctor.config_file.ok"),
|
||||
Details: map[string]interface{}{
|
||||
"path": path,
|
||||
"size": info.Size(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func checkConfigValues(tr *i18n.Translator, cfg *internalConfig.Config, cfgErr error) Check {
|
||||
if cfgErr != nil || cfg == nil {
|
||||
return Check{
|
||||
Name: "config_values",
|
||||
Status: statusSkipped,
|
||||
Message: tr.T("output.doctor.config_values.skipped"),
|
||||
Suggestion: tr.T("output.doctor.suggestion.fix_config_yaml"),
|
||||
}
|
||||
}
|
||||
|
||||
details := map[string]interface{}{
|
||||
"base_url": cfg.BaseURL,
|
||||
"default_format": cfg.Format,
|
||||
}
|
||||
if err := validateBaseURL(cfg.BaseURL); err != nil {
|
||||
return Check{
|
||||
Name: "config_values",
|
||||
Status: statusError,
|
||||
Message: tr.Tf("output.doctor.config_values.bad_base_url", i18n.Args{"message": err.Error()}),
|
||||
Suggestion: "gitlink-cli config set base_url https://www.gitlink.org.cn/api",
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
if !validFormat(cfg.Format) {
|
||||
return Check{
|
||||
Name: "config_values",
|
||||
Status: statusWarning,
|
||||
Message: tr.Tf("output.doctor.config_values.bad_format", i18n.Args{"format": cfg.Format}),
|
||||
Suggestion: "gitlink-cli config set default_format table",
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
return Check{
|
||||
Name: "config_values",
|
||||
Status: statusOK,
|
||||
Message: tr.T("output.doctor.config_values.ok"),
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
func checkAuthToken(tr *i18n.Translator) Check {
|
||||
if token, ok := lookupEnv("GITLINK_TOKEN"); ok && strings.TrimSpace(token) != "" {
|
||||
return Check{
|
||||
Name: "auth_token",
|
||||
Status: statusOK,
|
||||
Message: tr.T("output.doctor.auth_token.env"),
|
||||
Details: map[string]interface{}{"source": "env"},
|
||||
}
|
||||
}
|
||||
token, err := loadToken()
|
||||
if err != nil || strings.TrimSpace(token) == "" {
|
||||
return Check{
|
||||
Name: "auth_token",
|
||||
Status: statusWarning,
|
||||
Message: tr.T("output.doctor.auth_token.missing"),
|
||||
Suggestion: "gitlink-cli auth login",
|
||||
}
|
||||
}
|
||||
source := "token"
|
||||
if strings.HasPrefix(token, "cookie:") {
|
||||
source = "cookie"
|
||||
}
|
||||
return Check{
|
||||
Name: "auth_token",
|
||||
Status: statusOK,
|
||||
Message: tr.T("output.doctor.auth_token.stored"),
|
||||
Details: map[string]interface{}{"source": source},
|
||||
}
|
||||
}
|
||||
|
||||
func checkRepoContext(tr *i18n.Translator) Check {
|
||||
owner, repo, err := resolveOwnerRepo(cmdutil.Owner, cmdutil.Repo)
|
||||
if err != nil {
|
||||
return Check{
|
||||
Name: "repo_context",
|
||||
Status: statusWarning,
|
||||
Message: tr.Tf("output.doctor.repo_context.missing", i18n.Args{"message": err.Error()}),
|
||||
Suggestion: tr.T("output.doctor.suggestion.pass_owner_repo"),
|
||||
}
|
||||
}
|
||||
return Check{
|
||||
Name: "repo_context",
|
||||
Status: statusOK,
|
||||
Message: tr.Tf("output.doctor.repo_context.ok", i18n.Args{"owner": owner, "repo": repo}),
|
||||
Details: map[string]interface{}{
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func checkAuthenticatedUser(tr *i18n.Translator, skipNetwork bool, cfgErr error) Check {
|
||||
if skipNetwork {
|
||||
return Check{
|
||||
Name: "api_auth",
|
||||
Status: statusSkipped,
|
||||
Message: tr.T("output.doctor.api_auth.skipped"),
|
||||
}
|
||||
}
|
||||
if cfgErr != nil {
|
||||
return Check{
|
||||
Name: "api_auth",
|
||||
Status: statusSkipped,
|
||||
Message: tr.T("output.doctor.api_auth.config_skipped"),
|
||||
Suggestion: tr.T("output.doctor.suggestion.fix_config_yaml"),
|
||||
}
|
||||
}
|
||||
|
||||
user, err := getCurrentUser()
|
||||
if err != nil {
|
||||
return Check{
|
||||
Name: "api_auth",
|
||||
Status: statusError,
|
||||
Message: tr.Tf("output.doctor.api_auth.failed", i18n.Args{"message": err.Error()}),
|
||||
Suggestion: "gitlink-cli auth login",
|
||||
}
|
||||
}
|
||||
login, _ := user["login"].(string)
|
||||
if login == "" {
|
||||
return Check{
|
||||
Name: "api_auth",
|
||||
Status: statusWarning,
|
||||
Message: tr.T("output.doctor.api_auth.no_login"),
|
||||
Suggestion: tr.T("output.doctor.suggestion.check_token"),
|
||||
}
|
||||
}
|
||||
return Check{
|
||||
Name: "api_auth",
|
||||
Status: statusOK,
|
||||
Message: tr.Tf("output.doctor.api_auth.ok", i18n.Args{"login": login}),
|
||||
Details: map[string]interface{}{
|
||||
"login": login,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validateBaseURL(value string) error {
|
||||
u, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("scheme must be http or https")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("host is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validFormat(value string) bool {
|
||||
switch value {
|
||||
case "json", "table", "yaml":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveFormat() string {
|
||||
if cmdutil.Format != "" {
|
||||
return cmdutil.Format
|
||||
}
|
||||
return "json"
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
package doctor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
)
|
||||
|
||||
func TestDoctorSkipNetworkReportsLocalChecks(t *testing.T) {
|
||||
withDoctorTestState(t)
|
||||
writeConfig(t, "base_url: https://www.gitlink.org.cn/api\ndefault_format: json\n")
|
||||
t.Setenv("GITLINK_TOKEN", "secret-token")
|
||||
resolveOwnerRepo = func(owner, repo string) (string, string, error) {
|
||||
return "Gitlink", "gitlink-cli", nil
|
||||
}
|
||||
|
||||
report := Run(true, i18n.Default())
|
||||
if !report.OK {
|
||||
t.Fatalf("expected report OK, got %+v", report)
|
||||
}
|
||||
assertCheck(t, report, "config_file", statusOK)
|
||||
assertCheck(t, report, "config_values", statusOK)
|
||||
assertCheck(t, report, "auth_token", statusOK)
|
||||
assertCheck(t, report, "repo_context", statusOK)
|
||||
assertCheck(t, report, "api_auth", statusSkipped)
|
||||
if report.Summary.Total != 5 {
|
||||
t.Fatalf("summary total = %d, want 5", report.Summary.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorInvalidConfigDoesNotPanic(t *testing.T) {
|
||||
withDoctorTestState(t)
|
||||
writeConfig(t, "base_url: [broken\n")
|
||||
resolveOwnerRepo = func(owner, repo string) (string, string, error) {
|
||||
return "Gitlink", "gitlink-cli", nil
|
||||
}
|
||||
|
||||
report := Run(true, i18n.Default())
|
||||
if report.OK {
|
||||
t.Fatalf("expected report not OK, got %+v", report)
|
||||
}
|
||||
assertCheck(t, report, "config_file", statusError)
|
||||
assertCheck(t, report, "config_values", statusSkipped)
|
||||
}
|
||||
|
||||
func TestDoctorInvalidBaseURL(t *testing.T) {
|
||||
withDoctorTestState(t)
|
||||
writeConfig(t, "base_url: gitlink.local/api\ndefault_format: table\n")
|
||||
resolveOwnerRepo = func(owner, repo string) (string, string, error) {
|
||||
return "Gitlink", "gitlink-cli", nil
|
||||
}
|
||||
|
||||
report := Run(true, i18n.Default())
|
||||
if report.OK {
|
||||
t.Fatalf("expected invalid base_url to mark report not OK")
|
||||
}
|
||||
check := assertCheck(t, report, "config_values", statusError)
|
||||
if check.Suggestion == "" {
|
||||
t.Fatalf("expected config_values suggestion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorMissingRepoContextIsWarning(t *testing.T) {
|
||||
withDoctorTestState(t)
|
||||
writeConfig(t, "base_url: https://www.gitlink.org.cn/api\ndefault_format: table\n")
|
||||
resolveOwnerRepo = func(owner, repo string) (string, string, error) {
|
||||
return "", "", errors.New("no origin remote")
|
||||
}
|
||||
|
||||
report := Run(true, i18n.Default())
|
||||
assertCheck(t, report, "auth_token", statusWarning)
|
||||
check := assertCheck(t, report, "repo_context", statusWarning)
|
||||
if check.Suggestion == "" {
|
||||
t.Fatalf("expected repo_context suggestion")
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("warnings should not make report fail: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorNetworkCheckCanSucceed(t *testing.T) {
|
||||
withDoctorTestState(t)
|
||||
writeConfig(t, "base_url: https://www.gitlink.org.cn/api\ndefault_format: table\n")
|
||||
resolveOwnerRepo = func(owner, repo string) (string, string, error) {
|
||||
return "Gitlink", "gitlink-cli", nil
|
||||
}
|
||||
getCurrentUser = func() (map[string]interface{}, error) {
|
||||
return map[string]interface{}{"login": "Mengz"}, nil
|
||||
}
|
||||
|
||||
report := Run(false, i18n.Default())
|
||||
assertCheck(t, report, "api_auth", statusOK)
|
||||
if !report.OK {
|
||||
t.Fatalf("expected report OK, got %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCommandPrintsJSONEnvelope(t *testing.T) {
|
||||
withDoctorTestState(t)
|
||||
writeConfig(t, "base_url: https://www.gitlink.org.cn/api\ndefault_format: json\n")
|
||||
resolveOwnerRepo = func(owner, repo string) (string, string, error) {
|
||||
return "Gitlink", "gitlink-cli", nil
|
||||
}
|
||||
|
||||
cmd := NewDoctorCmd(i18n.Default())
|
||||
cmd.SetArgs([]string{"--skip-network"})
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
|
||||
}
|
||||
if !env.OK || len(env.Data) == 0 {
|
||||
t.Fatalf("unexpected envelope: %+v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func withDoctorTestState(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
oldLoadConfig := loadConfig
|
||||
oldLoadToken := loadToken
|
||||
oldGetCurrentUser := getCurrentUser
|
||||
oldResolveOwnerRepo := resolveOwnerRepo
|
||||
oldStatFile := statFile
|
||||
oldLookupEnv := lookupEnv
|
||||
oldFormat := cmdutil.Format
|
||||
oldOwner := cmdutil.Owner
|
||||
oldRepo := cmdutil.Repo
|
||||
|
||||
t.Setenv("GITLINK_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
cmdutil.Format = "json"
|
||||
cmdutil.Owner = ""
|
||||
cmdutil.Repo = ""
|
||||
loadConfig = oldLoadConfig
|
||||
loadToken = func() (string, error) { return "", os.ErrNotExist }
|
||||
getCurrentUser = func() (map[string]interface{}, error) {
|
||||
return nil, errors.New("unexpected network call")
|
||||
}
|
||||
resolveOwnerRepo = oldResolveOwnerRepo
|
||||
statFile = oldStatFile
|
||||
lookupEnv = func(key string) (string, bool) {
|
||||
if key == "GITLINK_TOKEN" {
|
||||
value := os.Getenv(key)
|
||||
return value, value != ""
|
||||
}
|
||||
return os.LookupEnv(key)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
loadConfig = oldLoadConfig
|
||||
loadToken = oldLoadToken
|
||||
getCurrentUser = oldGetCurrentUser
|
||||
resolveOwnerRepo = oldResolveOwnerRepo
|
||||
statFile = oldStatFile
|
||||
lookupEnv = oldLookupEnv
|
||||
cmdutil.Format = oldFormat
|
||||
cmdutil.Owner = oldOwner
|
||||
cmdutil.Repo = oldRepo
|
||||
})
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, content string) {
|
||||
t.Helper()
|
||||
dir := os.Getenv("GITLINK_CONFIG_DIR")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, report Report, name, status string) Check {
|
||||
t.Helper()
|
||||
for _, check := range report.Checks {
|
||||
if check.Name == name {
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %s, want %s; check=%+v", name, check.Status, status, check)
|
||||
}
|
||||
return check
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing check %q in %+v", name, report.Checks)
|
||||
return Check{}
|
||||
}
|
||||
123
cmd/root.go
123
cmd/root.go
|
|
@ -1,54 +1,129 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
||||
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
|
||||
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
|
||||
doctorCmd "github.com/gitlink-org/gitlink-cli/cmd/doctor"
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "gitlink-cli",
|
||||
Short: "GitLink CLI — command-line tool for gitlink.org.cn",
|
||||
Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
type RootOptions struct {
|
||||
Version string
|
||||
Args []string
|
||||
Env map[string]string
|
||||
ConfigLang string
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)")
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)")
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)")
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
|
||||
func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
|
||||
if tr == nil {
|
||||
var err error
|
||||
tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(authCmd.NewAuthCmd())
|
||||
rootCmd.AddCommand(apiCmd.NewAPICmd())
|
||||
rootCmd.AddCommand(configCmd.NewConfigCmd())
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
version := opts.Version
|
||||
if version == "" {
|
||||
version = Version
|
||||
}
|
||||
|
||||
shortcuts.RegisterAll(rootCmd)
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "gitlink-cli",
|
||||
Short: tr.T("cmd.root.short"),
|
||||
Long: tr.T("cmd.root.long"),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", tr.T("flag.owner"))
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", tr.T("flag.repo"))
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", tr.T("flag.format"))
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, tr.T("flag.debug"))
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang"))
|
||||
|
||||
rootCmd.AddCommand(authCmd.NewAuthCmd(tr))
|
||||
rootCmd.AddCommand(apiCmd.NewAPICmd(tr))
|
||||
rootCmd.AddCommand(configCmd.NewConfigCmd(tr))
|
||||
rootCmd.AddCommand(doctorCmd.NewDoctorCmd(tr))
|
||||
rootCmd.AddCommand(newVersionCmd(version, tr))
|
||||
|
||||
shortcuts.RegisterAll(rootCmd, tr)
|
||||
|
||||
if opts.Args != nil {
|
||||
rootCmd.SetArgs(opts.Args)
|
||||
}
|
||||
return rootCmd, nil
|
||||
}
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("gitlink-cli %s\n", Version)
|
||||
},
|
||||
func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: tr.T("cmd.version.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
args := os.Args[1:]
|
||||
rootCmd, err := NewRootCmd(RootOptions{
|
||||
Version: Version,
|
||||
Args: args,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTranslator(args []string, env map[string]string, configLang string) (*i18n.Translator, error) {
|
||||
available, err := i18n.AvailableLocales()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env == nil {
|
||||
env = i18n.EnvMap()
|
||||
}
|
||||
if configLang == "" {
|
||||
configLang = loadConfigLangBestEffort()
|
||||
}
|
||||
resolved := i18n.ResolveLocaleDetailed(i18n.ResolveOptions{
|
||||
ExplicitLang: i18n.PreScanLang(args),
|
||||
Env: env,
|
||||
ConfigLang: configLang,
|
||||
}, available)
|
||||
if !resolved.Supported && (resolved.Source == "flag" || resolved.Source == "env") {
|
||||
tr := i18n.Default()
|
||||
return nil, errors.New(tr.Tf("error.unsupported_language", i18n.Args{"lang": resolved.Requested}))
|
||||
}
|
||||
return i18n.New(i18n.Options{Locale: resolved.Locale})
|
||||
}
|
||||
|
||||
func loadConfigLangBestEffort() string {
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.Lang
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
)
|
||||
|
||||
func TestRootHelpUsesSelectedLocale(t *testing.T) {
|
||||
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
if !strings.Contains(help, "用于管理 GitLink 上的仓库") {
|
||||
t.Fatalf("expected Chinese root long help, got:\n%s", help)
|
||||
}
|
||||
if !strings.Contains(help, "仓库操作") {
|
||||
t.Fatalf("expected Chinese shortcut group help, got:\n%s", help)
|
||||
}
|
||||
if !strings.Contains(help, "认证命令") || !strings.Contains(help, "管理 gitlink-cli 配置") {
|
||||
t.Fatalf("expected Chinese core command help, got:\n%s", help)
|
||||
}
|
||||
if !strings.Contains(help, "--lang") || !strings.Contains(help, "显示语言") {
|
||||
t.Fatalf("expected localized lang flag help, got:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpUsesExplicitLang(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--lang", "zh-CN", "--help"}, Env: map[string]string{}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
for _, want := range []string{"用于管理 GitLink", "显示语言", "仓库"} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("expected %q in help, got:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpUsesEnvLang(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{
|
||||
Version: "test",
|
||||
Args: []string{"repo", "--help"},
|
||||
Env: map[string]string{"GITLINK_LANG": "zh-CN"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
for _, want := range []string{"仓库操作", "仓库所有者", "仓库名称"} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("expected %q in help, got:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitLangOverridesConfigLang(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{
|
||||
Version: "test",
|
||||
Args: []string{"--lang", "en-US", "--help"},
|
||||
Env: map[string]string{},
|
||||
ConfigLang: "zh-CN",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
if !strings.Contains(help, "Repository operations") {
|
||||
t.Fatalf("expected English help, got:\n%s", help)
|
||||
}
|
||||
if strings.Contains(help, "仓库操作") {
|
||||
t.Fatalf("expected explicit en-US to override config zh-CN, got:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedExplicitLangReturnsError(t *testing.T) {
|
||||
_, err := NewRootCmd(RootOptions{
|
||||
Version: "test",
|
||||
Args: []string{"--lang", "fr-FR", "--help"},
|
||||
Env: map[string]string{},
|
||||
}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported language error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported language") {
|
||||
t.Fatalf("expected unsupported language error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireArgUsesLocalizedError(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{
|
||||
Version: "test",
|
||||
Args: []string{"--lang", "zh-CN", "repo", "+create"},
|
||||
Env: map[string]string{},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
err = root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected missing required flag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "缺少必需参数") {
|
||||
t.Fatalf("expected localized missing flag error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreCommandHelpUsesSelectedLocale(t *testing.T) {
|
||||
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
args: []string{"api", "--help"},
|
||||
want: []string{"向 GitLink API 发送任意 HTTP 请求", "--body", "请求体(JSON 字符串)"},
|
||||
},
|
||||
{
|
||||
args: []string{"auth", "login", "--help"},
|
||||
want: []string{"登录 GitLink", "--token", "通过粘贴已有 Token 登录"},
|
||||
},
|
||||
{
|
||||
args: []string{"config", "--help"},
|
||||
want: []string{"管理 gitlink-cli 配置", "初始化配置文件", "列出所有配置项"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("%v: %v", tc.args, err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutHelpUsesSelectedLocale(t *testing.T) {
|
||||
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
args: []string{"repo", "+create", "--help"},
|
||||
want: []string{"创建新仓库", "--name", "仓库名称", "--private", "设为私有仓库"},
|
||||
},
|
||||
{
|
||||
args: []string{"pr", "+review", "--help"},
|
||||
want: []string{"创建拉取请求评审", "--content", "评审内容", "--dry-run"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("%v: %v", tc.args, err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemainingShortcutHelpUsesSelectedLocale(t *testing.T) {
|
||||
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
args: []string{"branch", "+create", "--help"},
|
||||
want: []string{"创建分支", "--from", "源分支或 Commit"},
|
||||
},
|
||||
{
|
||||
args: []string{"release", "+create", "--help"},
|
||||
want: []string{"创建发布", "--prerelease", "标记为预发布"},
|
||||
},
|
||||
{
|
||||
args: []string{"webhook", "+create", "--help"},
|
||||
want: []string{"创建仓库 Webhook", "--events", "逗号分隔的事件"},
|
||||
},
|
||||
{
|
||||
args: []string{"ci", "+logs", "--help"},
|
||||
want: []string{"查看构建日志", "--build", "构建编号"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("%v: %v", tc.args, err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionUsesInjectedVersion(t *testing.T) {
|
||||
tr, err := i18n.New(i18n.Options{Locale: "en-US"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := NewRootCmd(RootOptions{Version: "1.2.3", Args: []string{"version"}}, tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := strings.TrimSpace(out.String()); got != "gitlink-cli 1.2.3" {
|
||||
t.Fatalf("version output = %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# 新增 Raw API 批处理执行器
|
||||
|
||||
`gitlink-cli api` 现在支持通过 `--batch-file` 读取 JSON 批处理计划,把多个尚未封装为 shortcut 的 GitLink API 请求组织成一次可审计的自动化执行。计划文件支持 `vars` 模板变量,`--var key=value` 可以在执行时覆盖变量,便于同一批处理流程复用到不同仓库、Issue 或分支。
|
||||
|
||||
批处理模式提供 `--dry-run` 预览渲染后的 method、path、query 和 body,不会访问远端;实际执行时会输出每一步的成功/失败、响应数据和汇总计数。默认遇到失败立即停止,传入 `--continue-on-error` 后会继续执行后续请求,适合批量巡检、批量评论、批量元数据修复等场景。
|
||||
|
||||
本次变更包含计划文件解析、模板渲染、query/body 递归替换、失败控制、结构化汇总输出、中英文帮助文案、README 示例、Skill reference 和单元测试。测试覆盖 dry-run 不发请求、变量覆盖、模板缺失报错、失败默认中断以及失败继续执行等关键行为。
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# 认证凭据 fallback 配置目录一致性修复
|
||||
|
||||
`gitlink-cli` 的主配置文件已经支持通过 `GITLINK_CONFIG_DIR` 指定配置目录,但认证模块在系统 Keychain 不可用时仍然把 fallback 凭据写到用户 home 下的 `~/.config/gitlink-cli/credentials`。这会让 CI、Windows 测试、Agent 沙箱和多账号隔离场景出现配置目录与凭据目录不一致的问题,也会导致测试中设置临时 HOME 后仍读写真实用户目录。
|
||||
|
||||
本次修复让文件凭据路径统一复用 `internal/config.ConfigDir()`:设置 `GITLINK_CONFIG_DIR` 时,fallback 凭据保存到 `$GITLINK_CONFIG_DIR/credentials`;未设置时仍保持原有默认路径。`auth logout` 在 fallback 文件不存在时也改为幂等成功,避免用户已经没有本地凭据时退出登录反而报错。
|
||||
|
||||
测试同步改为使用 `GITLINK_CONFIG_DIR` 隔离凭据目录,覆盖默认配置目录、文件创建、保存/读取/删除、Keychain 不可用 fallback、无凭据登出等场景。该修复提升了跨平台稳定性,也让本地全量测试不再因为 Windows `HOME`/`USERPROFILE` 解析差异污染真实用户凭据目录。
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# Dataset Shortcuts
|
||||
|
||||
## Summary
|
||||
|
||||
Adds a new `dataset` shortcut group for managing and querying GitLink research
|
||||
datasets, which previously had no shortcut coverage. Datasets carry
|
||||
research-oriented metadata (title, description, `paper_content`, license, owning
|
||||
project) that is valuable for research/scientometric scenarios.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose | Endpoint |
|
||||
|---------|---------|----------|
|
||||
| `gitlink-cli dataset +view` | View a repository's dataset and attachments | `GET /v1/{owner}/{repo}/dataset` |
|
||||
| `gitlink-cli dataset +list --ids <ids>` | List datasets for one or more projects | `GET /v1/project_datasets` |
|
||||
| `gitlink-cli dataset +create` | Create a repository's dataset | `POST /v1/{owner}/{repo}/dataset` |
|
||||
| `gitlink-cli dataset +update` | Update a repository's dataset | `PUT /v1/{owner}/{repo}/dataset` |
|
||||
| `gitlink-cli dataset +delete-attachment --uuid <uuid>` | Delete a dataset attachment | `DELETE /attachments/{uuid}` |
|
||||
|
||||
## Behaviour
|
||||
|
||||
- `+view` paginates attachments via `--page`/`--limit`.
|
||||
- `+list --ids 1,2,3` queries datasets by comma-separated numeric project IDs;
|
||||
IDs are validated client-side before the request.
|
||||
- `+create`/`+update` send `title`, `description`, optional `license-id`
|
||||
(validated as a positive integer) and `paper-content`. Both support
|
||||
`--dry-run` to preview the request body without writing.
|
||||
- `+delete-attachment` is destructive: it requires `--dry-run` preview or an
|
||||
explicit `--yes` confirmation before issuing the DELETE.
|
||||
|
||||
## Production status (verified)
|
||||
|
||||
Verified against production `gitlink.org.cn`:
|
||||
|
||||
- `GET /v1/project_datasets` (`+list`) — **available and verified** (e.g.
|
||||
`--ids 5988` returns the forgeplus dataset).
|
||||
- The per-repository routes `/v1/{owner}/{repo}/dataset`
|
||||
(`+view`/`+create`/`+update`) currently return `404` on production www
|
||||
(confirmed even for a repository's own owner; not reachable on the gateway
|
||||
host either). They follow the documented contract and are expected to work
|
||||
once the platform deploys these routes. `+delete-attachment` targets the
|
||||
generic attachments endpoint.
|
||||
|
||||
The commands and request shapes match the published OpenAPI spec, so they are
|
||||
ready the moment the routes go live; unit tests exercise every command against a
|
||||
mock server.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests cover the view path with pagination, `--ids` normalization and
|
||||
validation, create/update request bodies and `license-id` validation, dry-run
|
||||
previews, and the destructive-delete confirmation guard (`--yes`).
|
||||
|
||||
## 中文说明
|
||||
|
||||
### 变更内容
|
||||
|
||||
- 新增 `dataset` 命令组:`+view`、`+list`、`+create`、`+update`、`+delete-attachment`。
|
||||
- `+view` 支持 `--page`/`--limit` 对附件分页;`+list --ids` 按项目 ID 查询。
|
||||
- `+create`/`+update` 发送 `title`/`description`/可选 `license-id`/`paper-content`,均支持 `--dry-run` 预览。
|
||||
- `+delete-attachment` 为破坏性操作,需 `--dry-run` 预览或显式 `--yes` 确认。
|
||||
|
||||
### 生产状态(已验证)
|
||||
|
||||
- `GET /v1/project_datasets`(`+list`)在生产**可用并已验证**(如 `--ids 5988` 返回 forgeplus 数据集)。
|
||||
- `/v1/{owner}/{repo}/dataset` 的 `+view`/`+create`/`+update` 当前在生产 www 返回 `404`(即使对仓库 owner 也如此,gateway 也未托管)。实现严格遵循已发布的 OpenAPI 契约,待平台部署后即可生效;单测以 mock 覆盖全部命令。
|
||||
|
||||
### 相对文档契约的增强
|
||||
|
||||
双语 i18n 帮助文案、写操作 `--dry-run` 预览、破坏性删除 `--yes` 二次确认、`license-id` 正整数校验。
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# CLI 自诊断命令
|
||||
|
||||
新增 `gitlink-cli doctor`,用于在用户遇到“无法认证、仓库识别失败、配置异常、API 请求失败”等问题时快速定位原因。命令会一次性检查配置文件是否存在且可解析、`base_url` 和 `default_format` 是否合理、本地 Token 或 `GITLINK_TOKEN` 是否可用、当前目录能否解析出 GitLink 仓库上下文,以及认证 API 是否能正常返回当前用户。
|
||||
|
||||
输出沿用项目已有的 `ok/data/error/meta` 结构,诊断结果包含每个检查项的状态、说明、细节和可执行修复建议,便于人类阅读,也便于 Agent 或 CI 解析。默认会验证认证 API 连通性,`--skip-network` 可在离线环境或 CI 中只做本地检查。
|
||||
|
||||
本次变更同时补充了中英文帮助文案、README 使用示例和单元测试。测试覆盖了正常本地检查、损坏配置文件、非法 `base_url`、仓库上下文缺失、认证 API mock 成功,以及命令 JSON envelope 输出,确保诊断命令在常见失败场景下返回结构化结果而不是直接崩溃。
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Ignore shortcut
|
||||
|
||||
新增 `ignore` Shortcut 组,补齐 GitLink 忽略文件模板(`.gitignore`)查询:
|
||||
|
||||
- `ignore +list`
|
||||
|
||||
同时补充了单元测试、README 示例。
|
||||
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Issue batch maintenance shortcuts
|
||||
|
||||
## Summary
|
||||
|
||||
Add OpenAPI-backed Issue batch maintenance shortcuts:
|
||||
|
||||
- `issue +batch-update` — batch update Issue status, priority, milestone, tags, and assigners by API issue IDs.
|
||||
- `issue +batch-delete` — batch delete Issues by API issue IDs with explicit confirmation.
|
||||
|
||||
This complements the existing `issue +batch-close` command. `batch-close` uses web URL issue numbers, while the OpenAPI batch update/delete endpoints use API issue IDs.
|
||||
|
||||
## OpenAPI coverage
|
||||
|
||||
| Command | Method | Endpoint |
|
||||
|---|---|---|
|
||||
| `issue +batch-update` | PATCH | `/api/v1/{owner}/{repo}/issues/batch_update.json` |
|
||||
| `issue +batch-delete` | DELETE | `/api/v1/{owner}/{repo}/issues/batch_destroy.json` |
|
||||
|
||||
## ID semantics
|
||||
|
||||
- `issue +batch-close --numbers` uses web URL Issue numbers (`project_issues_index`).
|
||||
- `issue +batch-update --ids` and `issue +batch-delete --ids` use API Issue IDs returned by Issue APIs.
|
||||
|
||||
The docs and help text explicitly call this out to avoid mixing the two ID types.
|
||||
|
||||
## Safety and usability
|
||||
|
||||
- Both commands support `--dry-run`.
|
||||
- `issue +batch-update` requires at least one update field.
|
||||
- `issue +batch-delete` is destructive and requires `--yes` for real execution.
|
||||
- ID lists are validated as positive integers and de-duplicated.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +batch-update \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--ids 101,102 \
|
||||
--status-id 3 \
|
||||
--priority-id 2 \
|
||||
--tag-ids 7,8 \
|
||||
--assigner-ids 11,12 \
|
||||
--dry-run
|
||||
|
||||
gitlink-cli issue +batch-delete \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--ids 101,102 \
|
||||
--dry-run
|
||||
|
||||
gitlink-cli issue +batch-delete \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--ids 101,102 \
|
||||
--yes
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
GOPROXY=https://goproxy.cn,direct go test ./...
|
||||
go vet ./...
|
||||
go run . issue +batch-update --help
|
||||
go run . issue +batch-delete --help
|
||||
go run . issue +batch-update --owner wangyue111 --repo gitlink-cli --ids 101,102 --status-id 3 --dry-run --format json
|
||||
go run . issue +batch-delete --owner wangyue111 --repo gitlink-cli --ids 101,102 --dry-run --format json
|
||||
```
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Issue ID Alias
|
||||
|
||||
## Summary
|
||||
|
||||
`issue +view`, `issue +close`, `issue +update`, and `issue +comment` now accept
|
||||
`--id` / `-i` as a compatibility alias for `--number` / `-n`.
|
||||
|
||||
The alias uses the same project-level issue number shown in the web URL, for
|
||||
example `issues/123`. It is not the global database ID.
|
||||
|
||||
`--number` remains the preferred flag and takes precedence when both flags are
|
||||
provided.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus --id 123
|
||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 --body "Fixed"
|
||||
```
|
||||
|
||||
## Submitter
|
||||
|
||||
Wang Yue
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Issue Metadata Fields
|
||||
|
||||
## Summary
|
||||
|
||||
`issue +create` and `issue +update` now support common GitLink Issue metadata fields.
|
||||
When updating or closing an Issue, the shortcut also carries the current metadata
|
||||
back to the API so unrelated fields are not reset by partial updates.
|
||||
|
||||
## Added flags
|
||||
|
||||
| Flag | API field |
|
||||
|------|-----------|
|
||||
| `--priority-id` | `priority_id` |
|
||||
| `--tag-ids` | `issue_tag_ids` |
|
||||
| `--assigner-ids` | `assigner_ids` |
|
||||
| `--branch` | `branch_name` |
|
||||
| `--start-date` | `start_date` |
|
||||
| `--due-date` | `due_date` |
|
||||
|
||||
`issue +create --label` is also mapped as a single tag ID for backward compatibility.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus \
|
||||
--title "Bug: login failed" \
|
||||
--priority-id 3 \
|
||||
--tag-ids 4,5 \
|
||||
--assigner-ids 7
|
||||
|
||||
gitlink-cli issue +update --owner Gitlink --repo forgeplus \
|
||||
--number 123 \
|
||||
--priority-id 4 \
|
||||
--branch bugfix/login \
|
||||
--due-date 2026-06-15
|
||||
```
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Label shortcut
|
||||
|
||||
新增 `label` Shortcut 组,补齐 GitLink Issue 标签(项目标记 / `issue_tags`)OpenAPI 的常用操作封装:
|
||||
|
||||
- `label +list`
|
||||
- `label +create`
|
||||
- `label +update`
|
||||
- `label +delete`
|
||||
|
||||
实现要点:
|
||||
|
||||
- 列表支持 `--keyword` 关键词过滤、`--only-name` 精简返回、`--sort-by` / `--sort-direction` 排序,映射到 API 的 `order_by` / `order_direction`。
|
||||
- `+create` 的 `--color` 缺省为 `#1E90FF`;颜色统一做十六进制(`#RGB` / `#RRGGBB`)客户端校验,非法颜色在调用 API 前即报错。
|
||||
- `+update` 先从列表接口取标签当前值并与传入字段合并,避免漏传字段被清空(更新接口要求 `name`/`description`/`color` 同时提交);无任何变更字段时直接报错。
|
||||
- 路径使用 `/api/v1/{owner}/{repo}/issue_tags`,与 webhook/milestone 等组保持一致的 `/v1/` 前缀约定。
|
||||
- 补充单元测试覆盖各命令的 HTTP 方法、路径、查询参数、payload,以及颜色校验和 id 归一化逻辑。
|
||||
|
||||
背景:在此之前,Issue 标签只能通过 Raw API(`issue_tags`)手工管理;`gitlink-code-review`、`gitlink-insight` 等 Skill 在做 Issue 分拣 / 打标签时都需要拼接原始请求。`label` 组将其提升为一等命令,并配套 `skills/gitlink-label/` Skill 文档,方便人类与 AI Agent 直接复用。
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Issue/PR 列表筛选增强
|
||||
|
||||
本次变更修正并增强 `issue +list` 与 `pr +list` 的筛选能力。
|
||||
|
||||
此前 `issue +list --state open` 会向服务端发送 `state=open`,但 GitLink v1 Issue 列表接口实际使用 `category=opened/closed/all`,因此列表可能仍返回关闭 Issue。`pr +list --state open` 也没有映射到 PR 列表接口实际使用的 `status=0/1/2` 参数,Skill 文档中甚至需要提醒用户该参数可能只影响统计。现在两个命令都会保留原有 `--state` 用户体验,同时转换为服务端真实生效的参数。
|
||||
|
||||
新增筛选项:
|
||||
|
||||
- `issue +list` 支持 `--keyword`、`--participant`、`--author-id`、`--assignee-id`、`--milestone-id`、`--status-id`、`--tag-ids`、`--sort-by`、`--sort-direction`。
|
||||
- `pr +list` 支持 `--keyword`、`--priority-id`、`--tag-id`、`--milestone-id`、`--reviewer-id`、`--assignee-id`、`--sort-by`、`--sort-direction`。
|
||||
|
||||
单元测试覆盖了状态映射、筛选参数透传和 `all` 状态兼容;README、中文 README、Issue Skill 与 PR Skill 已同步更新。
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Member Shortcut
|
||||
|
||||
新增 `member` Shortcut 组,支持仓库成员管理和项目邀请链接操作:
|
||||
|
||||
- `member +list`
|
||||
- `member +add`
|
||||
- `member +batch-add`
|
||||
- `member +remove`
|
||||
- `member +role`
|
||||
- `member +invite-link`
|
||||
- `member +invite-info`
|
||||
- `member +accept-invite`
|
||||
|
||||
同时补充了单元测试、README 示例和 `gitlink-member` Skill 说明。
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# Milestone shortcut
|
||||
|
||||
新增 `milestone` Shortcut 组,补齐 GitLink 里程碑 OpenAPI 的常用操作封装:
|
||||
|
||||
- `milestone +list`
|
||||
- `milestone +create`
|
||||
- `milestone +view`
|
||||
- `milestone +update`
|
||||
- `milestone +delete`
|
||||
- `milestone +close`
|
||||
- `milestone +reopen`
|
||||
|
||||
实现要点:
|
||||
|
||||
- 支持列表筛选、分页、排序,以及详情页关联 Issue 过滤参数。
|
||||
- 写入时将 CLI 参数 `--due-date` 映射为 API 字段 `effective_date`。
|
||||
- `+update` 在没有任何变更字段时直接报错,避免发送空更新。
|
||||
- `+close` 和 `+reopen` 使用 GitLink 的 milestone 状态更新接口。
|
||||
- 补充单元测试覆盖各命令的 HTTP 方法、路径、查询参数和 payload。
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Pipeline OpenAPI Shortcuts
|
||||
|
||||
Submitter: Wang Yue
|
||||
|
||||
This change adds a dedicated `pipeline` shortcut group for GitLink Pipeline OpenAPI coverage.
|
||||
|
||||
## Commands
|
||||
|
||||
- `pipeline +list`
|
||||
- `pipeline +runs`
|
||||
- `pipeline +run`
|
||||
- `pipeline +view`
|
||||
- `pipeline +delete`
|
||||
- `pipeline +save-yaml`
|
||||
- `pipeline +enable`
|
||||
- `pipeline +disable`
|
||||
- `pipeline +logs`
|
||||
- `pipeline +results`
|
||||
|
||||
## API Mapping
|
||||
|
||||
| Shortcut | Method | API path |
|
||||
|----------|--------|----------|
|
||||
| `pipeline +list` | GET | `/api/pm/pipelines.json` |
|
||||
| `pipeline +runs` | GET | `/api/v1/{owner}/{repo}/actions/runs.json` |
|
||||
| `pipeline +run` | POST | `/api/v1/{owner}/{repo}/actions/runs.json` |
|
||||
| `pipeline +view` | GET | `/api/v1/{owner}/{repo}/pipelines/{id}.json` |
|
||||
| `pipeline +delete` | DELETE | `/api/v1/{owner}/{repo}/pipelines/{id}.json` |
|
||||
| `pipeline +save-yaml` | POST | `/api/v1/{owner}/{repo}/pipelines/save_yaml` |
|
||||
| `pipeline +enable` | POST | `/api/v1/{owner}/{repo}/actions/enable.json` |
|
||||
| `pipeline +disable` | POST | `/api/v1/{owner}/{repo}/actions/disable.json` |
|
||||
| `pipeline +logs` | POST | `/api/v1/{owner}/{repo}/actions/runs/{run_id}/jobs/0` |
|
||||
| `pipeline +results` | GET | `/api/v1/{owner}/{repo}/pipelines/run_results.json` |
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit tests cover request methods, paths, query parameters, request bodies, dry-run behavior, and invalid ID validation.
|
||||
- Help documentation is available through `gitlink-cli pipeline --help` and command-specific help.
|
||||
- Write and delete commands support `--dry-run` to preview requests before changing pipeline state.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# Profile (User Statistics) Shortcuts
|
||||
|
||||
## Summary
|
||||
|
||||
Adds a new read-only `profile` shortcut group that wraps GitLink's user statistics
|
||||
APIs (development ability, role positioning, major/discipline, recent activity, and
|
||||
contribution heatmap). These endpoints previously had no shortcut coverage, forcing
|
||||
agents to fall back to Raw API calls — the `gitlink-contributor-insight` skill even
|
||||
documents `user +stats`/`user +heatmap` as unavailable. The `profile` group surfaces
|
||||
the platform's native portrait data directly, powering "research subject portrait"
|
||||
scenarios.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose | Endpoint |
|
||||
|---------|---------|----------|
|
||||
| `gitlink-cli profile +ability` | Development ability scores + language breakdown | `GET /users/{user}/statistics/develop` |
|
||||
| `gitlink-cli profile +role` | Role positioning | `GET /users/{user}/statistics/role` |
|
||||
| `gitlink-cli profile +major` | Major/discipline categories | `GET /users/{user}/statistics/major` |
|
||||
| `gitlink-cli profile +activity` | Recent activity (issues/PRs/commits per day) | `GET /users/{user}/statistics/activity` |
|
||||
| `gitlink-cli profile +contribution` | Contribution heatmap | `GET /users/{user}/headmaps` |
|
||||
|
||||
## Behaviour
|
||||
|
||||
- `--user`/`-u` selects the target user. When omitted, the user is resolved from the
|
||||
authenticated account via `/users/me`, so `gitlink-cli profile +ability` works with
|
||||
no arguments.
|
||||
- `+ability`, `+role`, and `+major` accept optional `--start-time` / `--end-time`
|
||||
(Unix timestamps) that map to the `start_time` / `end_time` query parameters.
|
||||
- `+contribution` accepts an optional `--year` query parameter.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests cover endpoint paths for every subcommand, the `start_time`/`end_time` and
|
||||
`year` query parameter mapping, current-user fallback via `/users/me`, the missing-login
|
||||
error path, and HTTP error handling.
|
||||
|
||||
## 中文说明
|
||||
|
||||
### 变更内容
|
||||
|
||||
- 新增 `profile` 命令组,封装 GitLink 用户画像统计接口:
|
||||
- `profile +ability` 开发能力评分(影响力/贡献度/活跃度/项目经验/语言能力)及语言分布
|
||||
- `profile +role` 角色定位
|
||||
- `profile +major` 专业/学科定位(如深度学习、量子计算)
|
||||
- `profile +activity` 近期活动统计(每日疑修/合并请求/提交数量)
|
||||
- `profile +contribution` 贡献热力图
|
||||
- `--user`/`-u` 指定目标用户;缺省时通过 `/users/me` 解析为当前认证用户。
|
||||
- `+ability`/`+role`/`+major` 支持 `--start-time`/`--end-time`(Unix 时间戳)。
|
||||
- `+contribution` 支持 `--year`。
|
||||
|
||||
### 价值
|
||||
|
||||
这些接口此前无任何 shortcut 封装,`gitlink-contributor-insight` Skill 甚至将
|
||||
`user +stats`/`user +heatmap` 标注为"不可用"并改用 PR 时间戳手工推算。`profile`
|
||||
命令组直接暴露平台原生画像数据,为"科研主体画像"等场景提供数据底座。
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Release Update Shortcuts
|
||||
|
||||
Submitter: Wang Yue
|
||||
|
||||
This change completes the release shortcut coverage for the release edit/update OpenAPI endpoints and improves release write safety.
|
||||
|
||||
## Commands
|
||||
|
||||
- Add `release +edit` for `/api/{owner}/{repo}/releases/{id}/edit.json`.
|
||||
- Add `release +update` for `PUT /api/{owner}/{repo}/releases/{id}.json`.
|
||||
- Extend `release +create` with `--draft` and `--attachment-ids`.
|
||||
- Extend `release +delete` with `--dry-run`.
|
||||
|
||||
## Behavior
|
||||
|
||||
- `release +update` fetches current edit data first, then preserves unspecified fields such as `name`, `tag_name`, `body`, `target_commitish`, `draft`, `prerelease`, and existing attachment IDs.
|
||||
- `release +update` validates boolean flags before reading remote data.
|
||||
- `release +update` and `release +delete` support `--dry-run` to preview write/delete requests.
|
||||
- `release +create` validates boolean flags and de-duplicates comma-separated attachment IDs.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit tests cover create payloads, edit endpoint routing, update field preservation, attachment overrides, dry-run behavior, and invalid argument validation.
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Repo Insight Shortcuts
|
||||
|
||||
## Summary
|
||||
|
||||
Adds read-only repository insight shortcuts so maintainers and agents can inspect project health without falling back to Raw API calls.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `gitlink-cli repo +languages` | Show repository language statistics |
|
||||
| `gitlink-cli repo +contributors` | List repository contributors |
|
||||
| `gitlink-cli repo +contributor-stats` | List contributor statistics with additions and deletions |
|
||||
| `gitlink-cli repo +code-stats` | Show repository code statistics |
|
||||
| `gitlink-cli repo +watchers` | List repository watchers |
|
||||
| `gitlink-cli repo +stargazers` | List repository stargazers |
|
||||
| `gitlink-cli repo +follow` | Follow a repository |
|
||||
| `gitlink-cli repo +unfollow` | Unfollow a repository |
|
||||
| `gitlink-cli repo +like` | Like a repository |
|
||||
| `gitlink-cli repo +unlike` | Unlike a repository |
|
||||
|
||||
## Validation
|
||||
|
||||
- `repo +contributor-stats --pass-year` must be a positive integer.
|
||||
- `repo +watchers` and `repo +stargazers` accept optional `--start-at` and `--end-at` Unix timestamps.
|
||||
- Time range timestamps must be non-negative, and `--start-at` cannot be greater than `--end-at`.
|
||||
- `repo +follow`, `repo +unfollow`, `repo +like`, and `repo +unlike` accept optional `--project-id`; if omitted, the project ID is resolved from `--owner/--repo`.
|
||||
- Repository interaction actions support `--dry-run` so callers can preview the resolved project ID and endpoint before changing remote state.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests cover endpoint paths, query parameter mapping, optional ref and time-range filters, project ID auto-resolution, dry-run previews, and invalid argument handling before any API request is sent.
|
||||
|
||||
## 中文说明
|
||||
|
||||
### 变更内容
|
||||
|
||||
- 新增 `repo +languages`、`repo +contributors`、`repo +contributor-stats`、`repo +code-stats`、`repo +watchers`、`repo +stargazers` 等仓库洞察命令。
|
||||
- 新增 `repo +follow`、`repo +unfollow`、`repo +like`、`repo +unlike` 仓库互动命令,并支持 `--project-id` 和 `--dry-run`。
|
||||
- `repo +contributor-stats` 和 `repo +code-stats` 使用 v1 API,支持 `--ref` 和 `--pass-year` 参数。
|
||||
- `repo +watchers` 和 `repo +stargazers` 支持 `--start-at` / `--end-at` 时间范围,并在请求前校验时间戳。
|
||||
- 更新 README、README.zh-CN、`gitlink-repo` Skill 和变更说明,减少仓库分析场景对 Raw API 的依赖。
|
||||
- 提交者:王越
|
||||
|
||||
### 验证
|
||||
|
||||
- `GOPROXY=https://goproxy.cn,direct go test ./...`
|
||||
- `go run . repo --help`
|
||||
- `go run . repo +contributor-stats --help`
|
||||
- `go run . repo +watchers --help`
|
||||
- `git diff --check`
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# Repository Settings Shortcuts
|
||||
|
||||
Submitter: Wang Yue
|
||||
|
||||
This change expands repository shortcut coverage for repository metadata, settings, project topics, navigation units, and transfer OpenAPI endpoints.
|
||||
|
||||
## Commands
|
||||
|
||||
- `repo +detail`
|
||||
- `repo +simple`
|
||||
- `repo +settings`
|
||||
- `repo +units`
|
||||
- `repo +units-update`
|
||||
- `repo +topics`
|
||||
- `repo +topic-add`
|
||||
- `repo +topic-delete`
|
||||
- `repo +transfer-orgs`
|
||||
- `repo +transfer`
|
||||
- `repo +transfer-cancel`
|
||||
|
||||
## API Mapping
|
||||
|
||||
| Shortcut | Method | API path |
|
||||
|----------|--------|----------|
|
||||
| `repo +detail` | GET | `/api/{owner}/{repo}/detail.json` |
|
||||
| `repo +simple` | GET | `/api/{owner}/{repo}/simple.json` |
|
||||
| `repo +settings` | GET | `/api/{owner}/{repo}/edit.json` |
|
||||
| `repo +units` | GET | `/api/{owner}/{repo}/project_units.json` |
|
||||
| `repo +units-update` | POST | `/api/{owner}/{repo}/project_units.json` |
|
||||
| `repo +topics` | GET | `/api/v1/project_topics.json` |
|
||||
| `repo +topic-add` | POST | `/api/v1/project_topics.json` |
|
||||
| `repo +topic-delete` | DELETE | `/api/v1/project_topics/{id}.json` |
|
||||
| `repo +transfer-orgs` | GET | `/api/{owner}/{repo}/applied_transfer_projects/organizations.json` |
|
||||
| `repo +transfer` | POST | `/api/{owner}/{repo}/applied_transfer_projects.json` |
|
||||
| `repo +transfer-cancel` | POST | `/api/{owner}/{repo}/applied_transfer_projects/cancel.json` |
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit tests cover request methods, paths, query parameters, JSON payloads, dry-run behavior, CSV de-duplication, and invalid project ID validation.
|
||||
- Write and state-changing commands support `--dry-run`.
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# repo +tree 仓库文件树查询命令
|
||||
|
||||
## 背景
|
||||
|
||||
`gitlink-cli repo` 已经提供仓库详情、README、语言统计和贡献者查询能力,但缺少直接查看仓库目录结构的 Shortcut。用户或 AI Agent 如果要判断仓库中是否存在 README、LICENSE、依赖清单、测试目录、文档目录等文件,过去需要手动调用 Raw API `/sub_entries`。
|
||||
|
||||
本次变更把仓库文件树查询封装为 `repo +tree`,降低普通用户和自动化工作流的使用门槛。
|
||||
|
||||
## 变更内容
|
||||
|
||||
- 新增 `gitlink-cli repo +tree` Shortcut。
|
||||
- 调用 `GET /{owner}/{repo}/sub_entries` 获取仓库根目录或指定目录下的文件和子目录。
|
||||
- 支持 `--path, -p` 指定目录路径;不传时查询仓库根目录。
|
||||
- 支持 `--ref, -r` 指定分支、标签或提交引用;默认值为 `master`。
|
||||
- 复用现有仓库上下文解析、API 调用和统一输出格式。
|
||||
- 补充中英文 i18n 文案,避免新增命令帮助信息硬编码。
|
||||
|
||||
## 命令示例
|
||||
|
||||
```bash
|
||||
# 查看仓库根目录
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# 查看指定目录
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
|
||||
|
||||
# Agent 场景建议使用 JSON 输出
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --format json
|
||||
```
|
||||
|
||||
## 参数说明
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--path, -p` | 否 | 要查看的目录路径,不传时查询仓库根目录 |
|
||||
| `--ref, -r` | 否 | 分支、标签或提交引用,默认 `master` |
|
||||
| `--owner` | 否 | 全局参数,仓库所有者,可从 git remote 自动解析 |
|
||||
| `--repo` | 否 | 全局参数,仓库名称,可从 git remote 自动解析 |
|
||||
| `--format` | 否 | 全局参数,输出格式:`json`、`table` 或 `yaml` |
|
||||
|
||||
## 测试覆盖
|
||||
|
||||
单元测试覆盖以下内容:
|
||||
|
||||
- 根目录查询默认使用 `master`。
|
||||
- 根目录查询不发送空 `filepath` 参数。
|
||||
- 指定 `--path` 和 `--ref` 时正确映射到 `filepath` 与 `ref` 查询参数。
|
||||
- `repo +tree` 的命令说明和 `--path/-p`、`--ref/-r` 参数注册完整。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
## 交付要求核对
|
||||
|
||||
- 功能代码:`shortcuts/repo/repo.go`
|
||||
- 单元测试:`shortcuts/repo/repo_test.go`
|
||||
- 命令帮助文档:`README.md`、`README.zh-CN.md`、`skills/gitlink-repo/SKILL.md`、`skills/gitlink-repo/references/gitlink-repo-tree.md`
|
||||
- 变更说明文档:`doc/changes/repo-tree-shortcut.md`
|
||||
|
||||
## 兼容性
|
||||
|
||||
该变更只新增 Shortcut、单元测试和文档,不修改已有命令参数或输出结构。根目录查询时不再发送空 `filepath` 查询参数,语义更清晰,对现有功能无破坏性影响。
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Webhook Shortcut
|
||||
|
||||
新增 `webhook` Shortcut 组,支持:
|
||||
|
||||
- `webhook +list`
|
||||
- `webhook +create`
|
||||
- `webhook +view`
|
||||
- `webhook +update`
|
||||
- `webhook +delete`
|
||||
- `webhook +test`
|
||||
|
||||
同时补充了对应单元测试、帮助文档和示例说明。
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Wiki Shortcut
|
||||
|
||||
新增 `wiki` Shortcut 组,支持 Wiki 页面管理:
|
||||
|
||||
- `wiki +list` - 列出 Wiki 页面(目录结构)
|
||||
- `wiki +view` - 按页面名称查看 Wiki 页面详情
|
||||
- `wiki +create` - 创建新的 Wiki 页面
|
||||
- `wiki +update` - 更新 Wiki 页面标题和/或内容
|
||||
- `wiki +delete` - 删除 Wiki 页面
|
||||
|
||||
## 实现要点
|
||||
|
||||
- **API 端点**:基于 `/api/wiki/open/{action}` 扁平路径结构,覆盖 5 个 Wiki 管理接口:
|
||||
- `GET /api/wiki/open/wikiPages` — 目录列表
|
||||
- `GET /api/wiki/open/getWiki` — 查看页面
|
||||
- `POST /api/wiki/open/createWiki` — 创建页面
|
||||
- `PUT /api/wiki/open/updateWiki` — 更新页面
|
||||
- `DELETE /api/wiki/open/deleteWiki` — 删除页面
|
||||
- **标识方式**:Wiki 页面通过 `pageName`(slug)标识,所有操作需要 `projectId`(GitLink 项目数字 ID)
|
||||
- **内容编码**:创建和更新时,内容自动进行 base64 编码后以 `content_base64` 字段发送
|
||||
- **更新保护**:`+update` 要求必须提供 `--title` 和 `--page-name`;`--content` 为可选
|
||||
- **Shortcut 模式**:使用 `common.Shortcut` + `RuntimeContext` 框架,与其他模块保持一致
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ gitlink-cli/
|
|||
│ ├── common/
|
||||
│ │ ├── types.go # Shortcut / Flag / RuntimeContext 定义
|
||||
│ │ └── runner.go # CallAPI / PaginateAll / ResolveOwnerRepo
|
||||
│ ├── repo/ # repo +create / +clone / +fork / +list / +info
|
||||
│ ├── repo/ # repo +list / +info / +readme / +tree / +languages / +create ...
|
||||
│ ├── issue/ # issue +list / +create / +view / +close / +comment
|
||||
│ ├── pr/ # pr +list / +create / +view / +merge / +review
|
||||
│ ├── release/ # release +list / +create / +download
|
||||
|
|
@ -78,7 +78,7 @@ gitlink-cli/
|
|||
|
||||
| 领域 | Shortcuts | 数量 |
|
||||
|------|-----------|------|
|
||||
| repo | `+create` `+clone` `+fork` `+list` `+info` `+delete` `+settings` | 7 |
|
||||
| repo | `+list` `+info` `+readme` `+tree` `+languages` `+contributors` `+contributor-stats` `+code-stats` `+watchers` `+stargazers` `+follow` `+unfollow` `+like` `+unlike` `+create` `+fork` `+delete` | 17 |
|
||||
| issue | `+list` `+create` `+view` `+update` `+close` `+comment` `+assign` `+label` | 8 |
|
||||
| pr | `+list` `+create` `+view` `+merge` `+close` `+review` `+files` `+diff` | 8 |
|
||||
| release | `+list` `+create` `+view` `+delete` `+download` | 5 |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
# GitLink CLI i18n Guide
|
||||
|
||||
## Goals
|
||||
|
||||
GitLink CLI localizes human-facing command-line text while keeping machine-readable output stable. The i18n layer is infrastructure, not a place to store every string in the project.
|
||||
|
||||
## Translate
|
||||
|
||||
- Cobra command `Short`, `Long`, and human-facing examples.
|
||||
- Flag usage text.
|
||||
- User-facing errors.
|
||||
- Interactive prompts.
|
||||
- Success messages.
|
||||
- Warnings.
|
||||
- Confirmation messages.
|
||||
- Table column labels when the output is meant for humans.
|
||||
|
||||
## Do Not Translate
|
||||
|
||||
- JSON field names.
|
||||
- Raw API response bodies.
|
||||
- Debug logs and developer diagnostics.
|
||||
- Machine-readable status enum values.
|
||||
- HTTP methods, paths, query keys, and payload field names.
|
||||
- Long-form README documentation.
|
||||
- Test assertion descriptions.
|
||||
|
||||
## Key Names
|
||||
|
||||
Use stable, descriptive keys:
|
||||
|
||||
- `cmd.*` for command help.
|
||||
- `flag.*` for flag usage.
|
||||
- `error.*` for user-facing errors.
|
||||
- `prompt.*` for interactive input prompts.
|
||||
- `success.*` for successful user-facing operations.
|
||||
- `warning.*` for warnings.
|
||||
- `confirm.*` for confirmation prompts.
|
||||
- `table.*` for human table headers.
|
||||
|
||||
Do not invent numbered keys such as `msg001`. Prefer names that describe ownership and intent, for example `error.missing_required_flag`.
|
||||
|
||||
## Adding Text
|
||||
|
||||
1. Add the key to `internal/i18n/locales/en-US.json`.
|
||||
2. Add the same key to every other locale, including `zh-CN.json`.
|
||||
3. Keep placeholders identical across locales, for example `{name}`.
|
||||
4. Use `tr.T("key")` or `tr.Tf("key", i18n.Args{...})`.
|
||||
5. Run:
|
||||
|
||||
```powershell
|
||||
go run ./internal/i18n/cmd/check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Use `go run ./internal/i18n/cmd/check --fix` to format locale JSON.
|
||||
|
||||
Use `go run ./internal/i18n/cmd/check --scan-code` before opening a PR. The scanner is intentionally lightweight:
|
||||
|
||||
- Name command-construction translators `tr` when calling `tr.T(...)` or `tr.Tf(...)`.
|
||||
- Use `ctx.Tr.T(...)` or `ctx.Tr.Tf(...)` in runtime shortcut code.
|
||||
- Avoid calling translator methods through other variable names such as `translator.T(...)`; the current scan may not detect them.
|
||||
- Do not add new `i18n.Default().T(...)` or `i18n.Default().Tf(...)` usages.
|
||||
|
||||
## Runtime Access
|
||||
|
||||
Command construction receives `*i18n.Translator` from `NewRootCmd`. Shortcut execution receives the same translator through `RuntimeContext.Tr`.
|
||||
|
||||
New command code should receive a translator explicitly. `i18n.Default()` exists only as a legacy migration fallback and should not be used for new command paths.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Locale JSON is sorted and formatted with two spaces.
|
||||
- Every locale has the same keys as `en-US`.
|
||||
- Template placeholders match across locales.
|
||||
- New command/runtime text uses i18n only when it is human-facing.
|
||||
- JSON output, API raw responses, debug logs, and machine-readable values are unchanged.
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
# feat(workflow): add agent workflow commands for repository maintenance
|
||||
|
||||
## Summary
|
||||
|
||||
This PR adds four read-only workflow commands for repository maintenance:
|
||||
|
||||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +repo-report`
|
||||
|
||||
The commands provide rule-based, explainable analysis with stable `json`, concise `table`,
|
||||
and copy-friendly `markdown` output.
|
||||
|
||||
## Motivation
|
||||
|
||||
Open-source maintainers often spend time on repetitive information organization before
|
||||
making actual decisions:
|
||||
|
||||
- Issue triage cost
|
||||
- PR review cost
|
||||
- repository health visibility
|
||||
- Agent needs stable structured output
|
||||
|
||||
This PR adds workflow-level analysis on top of the existing GitLink CLI shortcut architecture
|
||||
without introducing LLM dependencies or remote write behavior.
|
||||
|
||||
## Changes
|
||||
|
||||
### `workflow +triage`
|
||||
|
||||
- Classifies issues by type
|
||||
- Scores priority and confidence
|
||||
- Detects missing bug-report information
|
||||
- Produces risk flags, recommended actions, suggested comments, and reasoning
|
||||
|
||||
### `workflow +health`
|
||||
|
||||
- Scores repository health
|
||||
- Covers issue/PR backlog, activity, release, CI, docs, license, contributing, and Agent readiness signals
|
||||
- Tolerates unknown metrics without failing the command
|
||||
|
||||
### `workflow +pr-summary`
|
||||
|
||||
- Summarizes PR metadata, changed files, and commits
|
||||
- Produces change type, risk level, review focus, test suggestions, merge checklist, and reasoning
|
||||
- Supports local JSON input and remote read-only PR fetch
|
||||
|
||||
### `workflow +repo-report`
|
||||
|
||||
- Aggregates health, issue triage, and PR summary signals
|
||||
- Produces a repository workflow report with score, risk level, recommendations, and reasoning
|
||||
- Supports partial read-only remote aggregation when optional sections are unavailable
|
||||
|
||||
## Safety
|
||||
|
||||
- Remote mode is read-only
|
||||
- No LLM dependency
|
||||
- No labels/comments/close operations
|
||||
- No PR approve/reject/merge operations
|
||||
- No `internal/output` change
|
||||
- No new third-party dependency
|
||||
- Test fixtures do not contain secrets or tokens
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
|
||||
go test ./shortcuts/workflow
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Coverage includes:
|
||||
|
||||
- triage rules
|
||||
- health scoring
|
||||
- PR summary rules
|
||||
- repo report aggregation
|
||||
- fetch normalization
|
||||
- partial failure handling
|
||||
- `json` / `table` / `markdown` rendering
|
||||
- local `--from` fixtures
|
||||
- command wiring tests
|
||||
|
||||
## Documentation
|
||||
|
||||
- `README.md`
|
||||
- `docs/workflow-agent-design.md`
|
||||
- `docs/workflow-agent-test-report.md`
|
||||
- `skills/gitlink-workflow/SKILL.md`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- `workflow +release-notes` is not implemented.
|
||||
- `workflow +stale` is not implemented.
|
||||
- Real GitLink API shapes may require follow-up normalization.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
|
||||
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
|
||||
```
|
||||
|
|
@ -0,0 +1,502 @@
|
|||
# GitLink CLI Workflow Agent Design
|
||||
|
||||
## Background
|
||||
|
||||
`gitlink-cli` already provides low-level and shortcut operations for GitLink repositories,
|
||||
issues, pull requests, releases, CI, organizations, search, and users.
|
||||
The repository also includes `skills/gitlink-workflow/SKILL.md`, which describes
|
||||
AI workflow patterns such as Issue triage, PR review, and Release Notes generation.
|
||||
|
||||
The current Go command tree did not include a `workflow` command group before this work.
|
||||
The competition PR turns the documented workflow concept into concrete,
|
||||
deterministic CLI commands that can be used by human maintainers and AI Agents
|
||||
without calling an external LLM.
|
||||
|
||||
## Goals
|
||||
|
||||
First PR:
|
||||
- Add `gitlink-cli workflow +triage`.
|
||||
- Add `gitlink-cli workflow +health`.
|
||||
- Keep write behavior dry-run by default.
|
||||
- Produce stable JSON for Agents.
|
||||
- Produce concise table output for terminal users.
|
||||
- Produce markdown output for reports, PR comments, Issue comments, and competition materials.
|
||||
- Support `--lang en` and `--lang zh-CN` with a lightweight message helper.
|
||||
|
||||
Additional workflow commands:
|
||||
- `workflow +pr-summary`: done
|
||||
- `workflow +repo-report`: done
|
||||
- `workflow +release-notes`: planned
|
||||
- `workflow +stale`: planned
|
||||
|
||||
Current implementation status:
|
||||
- Rule engine: done
|
||||
- Local command layer: done
|
||||
- API fetch layer: done
|
||||
- Boundary tests: expanded for empty responses, field normalization,
|
||||
unknown tolerance, and read-only error handling
|
||||
- PR summary command: done with local JSON input, read-only fetch, rules, renderers, and tests
|
||||
- Repo report command: done with local JSON input, partial read-only fetch aggregation,
|
||||
scoring, renderers, and tests
|
||||
|
||||
## Current Repository Findings
|
||||
|
||||
Command registration:
|
||||
- `cmd/root.go` registers global flags and calls `shortcuts.RegisterAll(rootCmd)`.
|
||||
- `shortcuts/register.go` maps command groups to shortcut slices.
|
||||
- Each group exposes `Shortcuts() []*common.Shortcut`.
|
||||
- `common.MountShortcut` maps a `Shortcut` into a Cobra command named `+<name>`.
|
||||
|
||||
Runtime and API calls:
|
||||
- `common.NewRuntimeContext` creates `client.Client`, carries owner, repo, format, and command args.
|
||||
- `ctx.ResolveOwnerRepo()` resolves `--owner` / `--repo` or Git remote context.
|
||||
- `ctx.CallAPI` and `ctx.CallAPIWithQuery` call `internal/client`.
|
||||
- `client.Do` appends `.json`, injects auth via transport, parses GitLink error-in-body responses, and returns `output.Envelope`.
|
||||
|
||||
Output:
|
||||
- `internal/output` currently supports `json`, `yaml`, and generic `table`.
|
||||
- Workflow requires `markdown`; the minimal-risk approach is a workflow-local renderer that prints stable workflow DTOs.
|
||||
- A later cleanup can promote markdown support into `internal/output` if multiple command groups need it.
|
||||
- Current workflow commands also expose workflow-local `json`, `table`, and `markdown` rendering without changing the global formatter.
|
||||
|
||||
Testing:
|
||||
- Existing tests use pure unit tests plus `httptest.Server`.
|
||||
- Shortcut tests instantiate `common.RuntimeContext` manually with a mocked `client.Client`.
|
||||
- This pattern should be reused for workflow API tests.
|
||||
|
||||
## Command Design
|
||||
|
||||
### `workflow +triage`
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --dry-run --format json
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --format table
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --lang zh-CN --format markdown
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--state`: default `open`
|
||||
- `--limit`: default `30`
|
||||
- `--page`: default `1`
|
||||
- `--dry-run`: default `true`
|
||||
- `--from`: optional local JSON input
|
||||
- `--title`, `--body`, `--number`, `--author`, `--url`, `--labels`: optional local single-issue input
|
||||
- `--lang`: default `en`, allowed `en`, `zh-CN`
|
||||
|
||||
Stable JSON item fields:
|
||||
- `issue_id`
|
||||
- `number`
|
||||
- `title`
|
||||
- `url`
|
||||
- `author`
|
||||
- `state`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `detected_type`
|
||||
- `priority`
|
||||
- `confidence`
|
||||
- `suggested_labels`
|
||||
- `missing_information`
|
||||
- `risk_flags`
|
||||
- `recommended_action`
|
||||
- `suggested_comment`
|
||||
- `reasoning`
|
||||
|
||||
Rule categories:
|
||||
- `bug`
|
||||
- `feature`
|
||||
- `question`
|
||||
- `docs`
|
||||
- `ci`
|
||||
- `security`
|
||||
- `performance`
|
||||
- `refactor`
|
||||
- `unknown`
|
||||
|
||||
Priority:
|
||||
- `P0`: security incident, secret/token leak, auth bypass, repository unusable
|
||||
- `P1`: core command unusable, install/login failure, CI/release blocker
|
||||
- `P2`: normal bug, important feature, missing docs blocking usage
|
||||
- `P3`: ordinary question, typo, minor improvement
|
||||
|
||||
Missing information for bug-like issues:
|
||||
- reproduction steps
|
||||
- expected behavior
|
||||
- actual behavior
|
||||
- version
|
||||
- OS / platform
|
||||
- command output
|
||||
- logs
|
||||
|
||||
### `workflow +health`
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --format json
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --format table
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --lang zh-CN --format markdown
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--stale-days`: default `30`
|
||||
- `--from`: optional local JSON input
|
||||
- local metric flags such as `--repository`, `--open-issues`, `--open-prs`, `--has-readme`, `--has-license`, and `--agent-readiness-score`
|
||||
- `--lang`: default `en`
|
||||
|
||||
Stable JSON fields:
|
||||
- `repository`
|
||||
- `open_issues`
|
||||
- `open_prs`
|
||||
- `stale_issues`
|
||||
- `stale_prs`
|
||||
- `recent_activity`
|
||||
- `release_status`
|
||||
- `ci_status`
|
||||
- `documentation_status`
|
||||
- `license_status`
|
||||
- `contribution_status`
|
||||
- `agent_readiness_score`
|
||||
- `health_score`
|
||||
- `risk_level`
|
||||
- `recommendations`
|
||||
- `scoring_notes`
|
||||
|
||||
Scoring:
|
||||
- Issue backlog and response: 20
|
||||
- PR backlog and merge state: 20
|
||||
- Recent activity: 15
|
||||
- Release status: 15
|
||||
- Documentation completeness: 10
|
||||
- License and contribution readiness: 10
|
||||
- Agent readiness: 10
|
||||
|
||||
Unknown metric policy:
|
||||
- Keep field present.
|
||||
- Set status or score detail to `unknown`.
|
||||
- Add one entry to `scoring_notes`.
|
||||
- Either omit the metric from denominator or apply a conservative partial score; the first PR should prefer denominator adjustment to avoid fake precision.
|
||||
|
||||
Risk levels:
|
||||
- `low`: 80-100
|
||||
- `medium`: 60-79
|
||||
- `high`: 40-59
|
||||
- `critical`: 0-39
|
||||
|
||||
## Architecture
|
||||
|
||||
Proposed files:
|
||||
|
||||
```text
|
||||
shortcuts/workflow/
|
||||
workflow.go # Shortcuts() and command wiring
|
||||
types.go # Stable DTOs
|
||||
triage_rules.go # pure classifier, scoring, missing info detection
|
||||
triage_fetch.go # GitLink issue fetching and response normalization
|
||||
triage_render.go # json/table/markdown workflow rendering if needed
|
||||
health_score.go # pure health scoring
|
||||
health_fetch.go # repo, issue, PR, release, CI/doc/license probes
|
||||
health_render.go # markdown/table rendering
|
||||
messages.go # en and zh-CN strings
|
||||
*_test.go
|
||||
```
|
||||
|
||||
Registration:
|
||||
- Add `workflow` import in `shortcuts/register.go`.
|
||||
- Add `"workflow": workflow.Shortcuts()` to `groups`.
|
||||
- Add description `"AI agent workflow analysis"`.
|
||||
|
||||
No new dependency is needed for this PR.
|
||||
|
||||
## Data Normalization
|
||||
|
||||
GitLink responses vary by endpoint. Workflow code should not depend on a single raw shape. Add small extraction helpers:
|
||||
|
||||
- `stringField(map, keys...)`
|
||||
- `numberField(map, keys...)`
|
||||
- `timeField(map, keys...)`
|
||||
- `sliceField(map, keys...)`
|
||||
- `extractItems(env, candidateKeys...)`
|
||||
|
||||
Candidate issue list keys:
|
||||
- `issues`
|
||||
- `data`
|
||||
- direct array after future client improvements
|
||||
|
||||
Candidate issue fields:
|
||||
- ID: `id`, `issue_id`
|
||||
- Number: `project_issues_index`, `number`, `index`, `id`
|
||||
- Title: `subject`, `title`
|
||||
- Body: `description`, `body`
|
||||
- Author: `author.login`, `user.login`, `login`
|
||||
- URL: `html_url`, `url`, `issue_url`
|
||||
|
||||
Health activity fields currently tolerated:
|
||||
- `updated_at`
|
||||
- `updatedAt`
|
||||
- `last_updated_at`
|
||||
- `lastUpdatedAt`
|
||||
- `last_activity_at`
|
||||
- `lastActivityAt`
|
||||
- `merged_at`
|
||||
- `mergedAt`
|
||||
- `closed_at`
|
||||
- `closedAt`
|
||||
|
||||
## Safety Strategy
|
||||
|
||||
- `+triage` only reads by default.
|
||||
- `--dry-run` defaults true.
|
||||
- A future explicit write flag for posting comments must require `--dry-run=false` in a later PR.
|
||||
- Generated comments are output as data, not posted remotely in the first PR.
|
||||
- Health checks never mutate remote state.
|
||||
- If an API probe fails, health continues with `unknown`.
|
||||
- The implemented prototype is local-first and has no LLM dependency.
|
||||
- Remote fetch mode remains read-only and does not post comments, labels, merges, or close actions.
|
||||
- API failures should fall back to `unknown` metrics or a clear fetch error instead of fabricating healthy data.
|
||||
|
||||
## Core Pseudocode
|
||||
|
||||
### Triage
|
||||
|
||||
```go
|
||||
issues := fetchIssues(owner, repo, state, limit, page)
|
||||
results := []TriageResult{}
|
||||
for _, issue := range issues {
|
||||
text := normalize(issue.Title + "\n" + issue.Body)
|
||||
scores := scoreKeywords(text, keywordRules)
|
||||
detectedType := maxScoreType(scores)
|
||||
priority := scorePriority(text, detectedType)
|
||||
missing := detectMissingInfo(issue, detectedType)
|
||||
confidence := confidenceFromScores(scores, missing)
|
||||
result := TriageResult{
|
||||
IssueID: issue.ID,
|
||||
Number: issue.Number,
|
||||
DetectedType: detectedType,
|
||||
Priority: priority,
|
||||
SuggestedLabels: labelsFor(detectedType, priority, riskFlags),
|
||||
MissingInformation: missing,
|
||||
RiskFlags: detectRiskFlags(text),
|
||||
RecommendedAction: actionFor(detectedType, priority, missing, lang),
|
||||
SuggestedComment: commentFor(missing, lang),
|
||||
Reasoning: explainTopMatches(scores, priorityRules),
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
render(results, format, lang)
|
||||
```
|
||||
|
||||
### Health
|
||||
|
||||
```go
|
||||
signals := collectHealthSignals(owner, repo)
|
||||
score := NewWeightedScore(100)
|
||||
score.Add("issues", 20, scoreIssueBacklog(signals.OpenIssues, signals.StaleIssues))
|
||||
score.Add("prs", 20, scorePRBacklog(signals.OpenPRs, signals.StalePRs))
|
||||
score.Add("activity", 15, scoreRecentActivity(signals.RecentActivity))
|
||||
score.Add("release", 15, scoreReleaseStatus(signals.ReleaseStatus))
|
||||
score.Add("docs", 10, scoreDocStatus(signals.DocumentationStatus))
|
||||
score.Add("license", 10, scoreLicenseContribution(signals.LicenseStatus, signals.ContributionStatus))
|
||||
score.Add("agent", 10, scoreAgentReadiness(signals))
|
||||
result := HealthResult{
|
||||
HealthScore: score.Percent(),
|
||||
RiskLevel: riskLevel(score.Percent()),
|
||||
Recommendations: recommendations(signals, score),
|
||||
ScoringNotes: score.Notes(),
|
||||
}
|
||||
render(result, format, lang)
|
||||
```
|
||||
|
||||
## Output Protocol
|
||||
|
||||
JSON:
|
||||
- Use stable struct tags.
|
||||
- Include empty arrays as `[]` where useful for Agent consumption.
|
||||
- Avoid prose outside JSON.
|
||||
|
||||
Table:
|
||||
- Triage columns: `NUMBER`, `TYPE`, `PRIORITY`, `CONFIDENCE`, `MISSING`, `ACTION`
|
||||
- Health rows: `METRIC`, `STATUS`, `SCORE`, `NOTE`
|
||||
|
||||
Markdown:
|
||||
- Triage: one summary table with type, priority, confidence, action, and missing information.
|
||||
- Health: repository score, metric table, recommendations, and scoring notes.
|
||||
- `zh-CN` changes rule messages and recommendation text, not JSON field names.
|
||||
|
||||
## Test Plan
|
||||
|
||||
Unit tests:
|
||||
- Issue type classification.
|
||||
- Priority scoring.
|
||||
- Missing information detection.
|
||||
- Risk flag detection.
|
||||
- Suggested comment generation.
|
||||
- Health weighted score and risk level.
|
||||
- Unknown metric denominator adjustment.
|
||||
- Markdown headings and required sections.
|
||||
|
||||
Mock API tests:
|
||||
- `workflow +triage` fetches issues and normalizes raw response.
|
||||
- `workflow +health` tolerates failing CI/release/doc probes.
|
||||
|
||||
Command tests:
|
||||
- `--dry-run` defaults to true.
|
||||
- `--lang zh-CN` accepted.
|
||||
- invalid `--lang` falls back to `en`.
|
||||
- `--format markdown` routes to markdown renderer.
|
||||
|
||||
## Later Extensions
|
||||
|
||||
### `workflow +pr-summary`
|
||||
|
||||
Inputs:
|
||||
- `--number`
|
||||
- `--from`
|
||||
- `--lang`
|
||||
- `--format`
|
||||
- optional `--include-files`
|
||||
- optional `--include-commits`
|
||||
- optional `--max-files`
|
||||
- optional `--max-commits`
|
||||
|
||||
Default format:
|
||||
- `table` for human review when `--format` is omitted
|
||||
|
||||
Data:
|
||||
- PR details
|
||||
- changed files
|
||||
- commits
|
||||
|
||||
Output:
|
||||
- `change_type`
|
||||
- `risk_level`
|
||||
- `review_focus`
|
||||
- `test_suggestions`
|
||||
- `merge_checklist`
|
||||
- `reasoning`
|
||||
|
||||
Implementation status:
|
||||
- read-only local JSON mode: done
|
||||
- read-only GitLink fetch mode: done
|
||||
- rules and renderers: done
|
||||
- tests: rules, fetch boundary, render, and command wiring
|
||||
|
||||
Safety:
|
||||
- no comments
|
||||
- no approve/reject
|
||||
- no merge
|
||||
- no remote write operation
|
||||
|
||||
### `workflow +repo-report`
|
||||
|
||||
Inputs:
|
||||
- `--owner`
|
||||
- `--repo`
|
||||
- `--from`
|
||||
- `--lang`
|
||||
- `--format`
|
||||
- optional `--issue-limit`
|
||||
- optional `--pr-limit`
|
||||
- optional `--stale-days`
|
||||
- optional `--include-issues`
|
||||
- optional `--include-prs`
|
||||
- optional `--include-health`
|
||||
|
||||
Default format:
|
||||
- `markdown` for maintainer and competition reports when `--format` is omitted
|
||||
|
||||
Data:
|
||||
- repository health input and score
|
||||
- issue triage results aggregated by type, priority, risk, and missing information
|
||||
- PR summary results aggregated by type, risk, and review focus
|
||||
|
||||
Output:
|
||||
- `report_score`
|
||||
- `risk_level`
|
||||
- `health`
|
||||
- `issue_summary`
|
||||
- `pr_summary`
|
||||
- `recommendations`
|
||||
- `reasoning`
|
||||
|
||||
Partial report strategy:
|
||||
- health, issue, and PR sections are fetched independently
|
||||
- if at least one enabled section succeeds, the command returns a partial report
|
||||
- failed sections are recorded in scoring notes or reasoning
|
||||
- PR remote aggregation currently uses PR list metadata only;
|
||||
detailed changed files and commits remain available through `workflow +pr-summary --number`
|
||||
|
||||
Safety:
|
||||
- read-only aggregation only
|
||||
- no comments, labels, closes, approve/reject, or merge operations
|
||||
- no LLM dependency
|
||||
|
||||
### `workflow +release-notes`
|
||||
|
||||
Inputs:
|
||||
- `--from`
|
||||
- `--to`
|
||||
- optional `--tag`
|
||||
- optional `--lang`
|
||||
|
||||
Data:
|
||||
- PR titles
|
||||
- commit messages
|
||||
|
||||
Markdown categories:
|
||||
- Features
|
||||
- Bug Fixes
|
||||
- Documentation
|
||||
- Tests
|
||||
- Refactoring
|
||||
- Chores
|
||||
- Breaking Changes
|
||||
|
||||
### `workflow +stale`
|
||||
|
||||
Inputs:
|
||||
- `--stale-days`
|
||||
- `--state`
|
||||
- `--dry-run`
|
||||
|
||||
Behavior:
|
||||
- Identify stale issues and PRs.
|
||||
- Generate suggested comments or labels.
|
||||
- Do not mutate remote state by default.
|
||||
|
||||
## API Fetch Layer
|
||||
|
||||
The current fetch layer uses:
|
||||
|
||||
- `triage_fetch.go`
|
||||
- `health_fetch.go`
|
||||
- `pr_fetch.go`
|
||||
- `repo_report_fetch.go`
|
||||
|
||||
Design goals already applied:
|
||||
|
||||
- tolerate unknown or partial API fields
|
||||
- map GitLink response shapes into stable workflow DTOs
|
||||
- continue operating when optional signals fail
|
||||
- keep remote-write actions disabled until explicitly enabled later
|
||||
|
||||
Planned fetch-layer extension:
|
||||
|
||||
- `triage_fetch.go` and `health_fetch.go` remain the normalization boundary for remote mode.
|
||||
- `pr_fetch.go` now reuses the same stable DTO and message patterns for read-only PR metadata, changed files, and commits.
|
||||
- `repo_report_fetch.go` composes the existing fetch helpers and records partial failures instead of failing the whole report.
|
||||
- Future `release-notes` should reuse the same normalization and renderer patterns.
|
||||
- Unknown or missing fields should stay explicit in JSON output so Agents can decide how to proceed.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Pure DTOs and rule engine.
|
||||
2. Pure health scoring.
|
||||
3. Workflow renderers.
|
||||
4. Command registration.
|
||||
5. API fetch and normalization.
|
||||
6. Tests.
|
||||
7. README updates.
|
||||
8. Competition docs and test report.
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
# Workflow Agent Test Report
|
||||
|
||||
## Scope
|
||||
|
||||
This phase covers:
|
||||
|
||||
- Issue triage rules
|
||||
- health scoring rules
|
||||
- PR summary rules
|
||||
- repository report aggregation rules
|
||||
- local command execution
|
||||
- API fetch boundary tests
|
||||
- remote read-only manual verification
|
||||
- `json` / `table` / `markdown` rendering
|
||||
- language handling
|
||||
- mock tests do not depend on the real remote API
|
||||
|
||||
## Environment
|
||||
|
||||
- OS: Windows
|
||||
- Go version: `go1.26.1 windows/amd64`
|
||||
- Go path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\go.exe`
|
||||
- gofmt path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\gofmt.exe`
|
||||
|
||||
## Test Commands
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
|
||||
go test ./shortcuts/workflow
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
- `go test ./shortcuts/workflow` passed.
|
||||
- `go test ./...` passed.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
- triage rules tests
|
||||
- health score tests
|
||||
- messages tests
|
||||
- render tests
|
||||
- command tests
|
||||
- fetch boundary tests
|
||||
- PR summary rules and fetch tests
|
||||
- repo report aggregation, render, command, and partial fetch tests
|
||||
|
||||
## API Fetch Boundary Tests
|
||||
|
||||
- empty issue responses return a clear error instead of panicking
|
||||
- missing issue titles still allow body-only issues to be normalized
|
||||
- label normalization supports string arrays, object arrays, and title/name variants
|
||||
- author normalization supports string, `user`, and `creator` shapes
|
||||
- GitLink error-in-body responses return readable errors
|
||||
- health activity timestamps accept `updated_at`, `updatedAt`, `last_activity_at`, `merged_at`, and `closed_at`
|
||||
- release responses accept `releases`, `data`, and direct array shapes
|
||||
- CI unavailability is recorded as `unknown` without failing the whole health run
|
||||
- stale-days values `0` and negative values fall back to the default `30`
|
||||
- PR summary fetch normalizes PR metadata, changed files, commits, authors, branches, and list limits
|
||||
- PR summary tolerates partial files or commits fetch failures while keeping base PR metadata
|
||||
- PR summary base PR error-in-body responses return readable errors
|
||||
- repo report fetch composes health, issue, and PR sections
|
||||
- repo report returns a partial report when at least one enabled section succeeds
|
||||
- repo report returns an error when all enabled fetched sections fail
|
||||
- repo report issue and PR limits are covered
|
||||
|
||||
## Manual Command Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
|
||||
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
|
||||
gitlink-cli workflow +triage \
|
||||
--title "安装失败,无法登录" \
|
||||
--body "运行命令时报错" \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--agent-readiness-known \
|
||||
--agent-readiness-score 9 \
|
||||
--format table
|
||||
gitlink-cli workflow +health \
|
||||
--repository demo/repo \
|
||||
--open-issues 60 \
|
||||
--stale-issues 25 \
|
||||
--open-prs 12 \
|
||||
--stale-prs 6 \
|
||||
--recent-activity-known \
|
||||
--recent-activity-days 120 \
|
||||
--release-known=false \
|
||||
--format json
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
||||
```
|
||||
|
||||
## Remote Manual Verification
|
||||
|
||||
- Command: `gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table`
|
||||
- Result: succeeded, returned five issues in table form.
|
||||
- Command: `gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --lang zh-CN --format markdown`
|
||||
- Result: succeeded, returned a markdown health report with score `58` and risk level `high`.
|
||||
- Remote writes: `No`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Current workflow commands support local analysis and read-only GitLink fetch mode.
|
||||
- `workflow +triage` still supports local parameters or a local JSON file via `--from`.
|
||||
- `workflow +health` still supports local parameters or a local JSON file via `--from`.
|
||||
- `workflow +pr-summary` supports local JSON input and read-only GitLink fetch mode.
|
||||
- `workflow +repo-report` supports local JSON input and partial read-only GitLink fetch aggregation.
|
||||
- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata only;
|
||||
detailed file and commit analysis remains available through `workflow +pr-summary --number`.
|
||||
- `json/table/markdown` are rendered inside the workflow package, not by the global formatter.
|
||||
- Fetch-layer tests use `httptest` and do not depend on the real remote API.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The rule-based Agent Workflow prototype, including the read-only fetch layer, is implemented, tested, and locally runnable.
|
||||
|
||||
## Final Verification
|
||||
|
||||
Final verification should be run before opening the official GitLink PR:
|
||||
|
||||
```bash
|
||||
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
|
||||
go test ./shortcuts/workflow
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
- `go test ./shortcuts/workflow` passes.
|
||||
- `go test ./...` passes.
|
||||
- No remote write operation is performed by workflow commands.
|
||||
|
||||
## Competition Demo Commands
|
||||
|
||||
Prefer local fixtures for stable demos:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
|
||||
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
|
||||
```
|
||||
|
||||
Read-only remote smoke commands:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
```
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
outputs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright 2026 GitLink Workflow Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# GitLink 构建端到端自动化工作流
|
||||
|
||||
面向 GitLink 竞赛子赛题三的端到端自动化工作流项目。
|
||||
|
||||
本项目面向开源社区运营场景,使用 `gitlink-cli` 串联仓库信息、Issue、PR 和 Release 数据采集,自动生成社区周报、Release Notes 草稿和结构化摘要,并支持将摘要发布到指定 GitLink Issue。该流程覆盖“数据采集 -> 指标分析 -> 文档生成 -> 结果发布”的完整闭环。
|
||||
|
||||
## 交付物
|
||||
|
||||
- `scripts/gitlink_workflow.py`:主工作流入口
|
||||
- `scripts/run_demo.ps1`:一键复现脚本
|
||||
- `docs/architecture.md`:架构图与流程说明
|
||||
- `docs/quickstart.md`:最短复现路径
|
||||
- `docs/runbook.md`:运行手册
|
||||
- `docs/verification.md`:真实仓库验证记录
|
||||
- `docs/submission-checklist.md`:参赛提交核对清单
|
||||
- `docs/upload-to-gitlink.md`:仓库目录结构说明
|
||||
- `examples/sample_config.json`:参赛仓库配置
|
||||
- `examples/demo_active_config.json`:公开仓库验证配置
|
||||
- `examples/demo_outputs/`:真实运行示例产物
|
||||
- `tests/test_gitlink_workflow.py`:单测
|
||||
- `LICENSE`:Apache 2.0
|
||||
|
||||
## 运行方式
|
||||
|
||||
推荐直接运行一键脚本:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
切换到参赛仓库配置:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Config examples\sample_config.json
|
||||
```
|
||||
|
||||
## 输出
|
||||
|
||||
- `outputs/*_report.md`
|
||||
- `outputs/*_release_notes.md`
|
||||
- `outputs/*_summary.json`
|
||||
|
||||
## 已验证仓库
|
||||
|
||||
- `puygob236/gitlink-cli`:完成仓库信息、Issue、PR、Release 采集,并完成 Issue 摘要回写验证
|
||||
- `Gitlink/gitlink-cli`:完成仓库信息、Issue、PR、Release 采集,并生成包含有效统计数据的周报、Release Notes 和结构化摘要
|
||||
|
||||
## 项目定位
|
||||
|
||||
- 满足子赛题三“端到端自动化工作流”的要求
|
||||
- 串联 4 个数据采集命令和 1 个结果发布命令
|
||||
- 支持在真实 GitLink 项目上复现
|
||||
- 提供运行脚本、验证记录、示例产物和单元测试
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# 架构说明
|
||||
|
||||
本项目采用“采集 -> 归一化 -> 分析 -> 生成 -> 发布”的五段式流程。
|
||||
|
||||

|
||||
|
||||
## 设计目标
|
||||
|
||||
- 低门槛:只依赖 `gitlink-cli` 和 Python 标准库
|
||||
- 可复现:同一配置可重复跑出同类报告
|
||||
- 可维护:采集、归一化、分析、生成和发布步骤保持清晰边界
|
||||
- 可验证:报告文件、结构化摘要和 Issue 评论均可作为运行结果核验依据
|
||||
|
||||
## 为什么选这个链路
|
||||
|
||||
子赛题三要求使用现有命令或 Skill 组合形成完整解决方案。本方案覆盖:
|
||||
|
||||
1. 仓库信息采集
|
||||
2. Issue 列表采集
|
||||
3. PR 列表采集
|
||||
4. Release 列表采集
|
||||
5. 报告生成
|
||||
6. Issue 摘要发布
|
||||
|
||||
该链路满足不少于 3 个 CLI 调用的要求,并形成从数据获取到结果发布的端到端闭环。
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 400 KiB |
|
|
@ -0,0 +1,32 @@
|
|||
# 示例输出摘要
|
||||
|
||||
## 验证目标
|
||||
|
||||
`Gitlink/gitlink-cli`
|
||||
|
||||
## 运行命令
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
## 关键结果
|
||||
|
||||
- Issues: 15
|
||||
- PR: 20
|
||||
- Release: 11
|
||||
- 输出文件:
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_report.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json`
|
||||
|
||||
## 仓库内示例产物
|
||||
|
||||
- `examples/demo_outputs/Gitlink_gitlink-cli_report.md`
|
||||
- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md`
|
||||
- `examples/demo_outputs/puygob236_gitlink-cli_report.md`
|
||||
- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md`
|
||||
|
||||
## 额外验证
|
||||
|
||||
`puygob236/gitlink-cli` 已完成仓库信息、Issue、PR 和 Release 采集验证,并完成摘要回写到 Issue 的发布验证。
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# 快速开始
|
||||
|
||||
## 一键运行
|
||||
|
||||
直接运行一键脚本:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
脚本会自动通过 `npm exec` 找到 `@gitlink-ai/cli`,把 `gitlink-cli` 放到临时 PATH 里,再执行:
|
||||
|
||||
- 仓库信息采集
|
||||
- Issue 列表采集
|
||||
- PR 列表采集
|
||||
- Release 列表采集
|
||||
- 周报生成
|
||||
- Release Notes 草稿生成
|
||||
|
||||
## 配置切换
|
||||
|
||||
- `examples/demo_active_config.json`:公开仓库验证配置,默认指向 `Gitlink/gitlink-cli`
|
||||
- `examples/sample_config.json`:参赛仓库验证配置,默认指向 `puygob236/gitlink-cli`
|
||||
|
||||
## 输出
|
||||
|
||||
- `outputs/*_report.md`
|
||||
- `outputs/*_release_notes.md`
|
||||
- `outputs/*_summary.json`
|
||||
|
||||
## 已验证事实
|
||||
|
||||
- `puygob236/gitlink-cli` 已完成采集、报告生成和 Issue 摘要回写验证
|
||||
- `Gitlink/gitlink-cli` 可生成带统计内容的周报和 Release Notes
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# 运行手册
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 已安装 `gitlink-cli`
|
||||
- 已完成 `gitlink-cli auth login`
|
||||
- 目标仓库有可读权限
|
||||
|
||||
官方快速开始里要求的验证命令是:
|
||||
|
||||
```powershell
|
||||
gitlink-cli user +me
|
||||
```
|
||||
|
||||
## 运行方式
|
||||
|
||||
### 1. 只生成报告
|
||||
|
||||
```powershell
|
||||
python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json
|
||||
```
|
||||
|
||||
### 2. 生成报告并发布摘要
|
||||
|
||||
```powershell
|
||||
python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json --publish-issue-id 123
|
||||
```
|
||||
|
||||
### 3. 一键复现
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
## 输出文件
|
||||
|
||||
- `outputs/*_report.md`:完整周报
|
||||
- `outputs/*_release_notes.md`:Release Notes 草稿
|
||||
- `outputs/*_summary.json`:结构化摘要
|
||||
|
||||
## 验证清单
|
||||
|
||||
- `repo +info` 能返回仓库信息
|
||||
- `issue +list` 能返回 Issue 列表
|
||||
- `pr +list` 能返回 PR 列表
|
||||
- `release +list` 能返回 Release 列表
|
||||
- 报告文件能落盘
|
||||
- Release Notes 草稿能落盘
|
||||
- 发布模式能把摘要写回指定 Issue
|
||||
|
||||
## 真实项目配置
|
||||
|
||||
- `examples/demo_active_config.json` 指向 `Gitlink/gitlink-cli`,用于验证活跃公开仓库的数据分析能力。
|
||||
- `examples/sample_config.json` 指向 `puygob236/gitlink-cli`,用于验证参赛仓库的采集和 Issue 回写能力。
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# 提交核对清单
|
||||
|
||||
## 官方交付要求映射
|
||||
|
||||
| 要求 | 本项目对应内容 |
|
||||
| --- | --- |
|
||||
| 工作流串联不少于 3 个 CLI 命令或 Skill 调用 | `scripts/gitlink_workflow.py` 串联 `repo +info`、`issue +list`、`pr +list`、`release +list`,并支持 `issue +comment` 发布摘要 |
|
||||
| 提供可复现执行脚本或 Agent 对话记录 | `scripts/run_demo.ps1` |
|
||||
| 在至少一个真实 GitLink 项目上运行并展示效果 | `docs/verification.md`、`docs/demo-output.md`、`examples/demo_outputs/` |
|
||||
| 提供工作流说明文档 | `README.md`、`docs/quickstart.md`、`docs/runbook.md` |
|
||||
| 提供架构图 | `docs/architecture.md` 引用 `docs/assets/architecture-workflow-v2.svg` |
|
||||
| 代码开源并托管到 GitLink | `https://gitlink.org.cn/puygob236/gitlink-cli` 的 `examples/workflows/community-ops-automation/` |
|
||||
| 提供完整中文 README | `README.md` |
|
||||
| 开源协议 | `LICENSE`,Apache 2.0 |
|
||||
|
||||
## 验证状态
|
||||
|
||||
- `python -m py_compile .\scripts\gitlink_workflow.py .\tests\test_gitlink_workflow.py`:通过
|
||||
- `python -m unittest discover -s tests`:通过
|
||||
- `.\scripts\run_demo.ps1`:已在 `Gitlink/gitlink-cli` 上跑通
|
||||
- `.\scripts\run_demo.ps1 -Config examples\sample_config.json`:已在 `puygob236/gitlink-cli` 上跑通
|
||||
- `.\scripts\run_demo.ps1 -Config examples\sample_config.json -PublishIssueId 2`:已完成 Issue 摘要回写验证
|
||||
|
||||
## 交付内容
|
||||
|
||||
- `README.md`、`docs/`、`scripts/`、`examples/`、`tests/`、`LICENSE` 均位于 `examples/workflows/community-ops-automation/`。
|
||||
- `outputs/` 为运行时生成目录,评审可通过复现脚本重新生成。
|
||||
- `examples/demo_outputs/` 提供固定示例产物,便于快速查看报告格式和输出内容。
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# GitLink 仓库目录结构
|
||||
|
||||
本作品以 `gitlink-cli` 工作流示例的形式托管在 GitLink 仓库中,目录与主项目源码保持隔离,避免改变主仓库既有命令、Skill 和设计文档结构。
|
||||
|
||||
## 作品路径
|
||||
|
||||
```text
|
||||
examples/workflows/community-ops-automation/
|
||||
```
|
||||
|
||||
## 目录内容
|
||||
|
||||
- `README.md`:项目说明与复现入口
|
||||
- `LICENSE`:Apache 2.0 开源协议
|
||||
- `.gitignore`:运行时产物忽略规则
|
||||
- `docs/`:架构、运行、验证和交付说明
|
||||
- `examples/`:配置文件和示例输出
|
||||
- `scripts/`:工作流执行脚本
|
||||
- `tests/`:单元测试
|
||||
|
||||
## 仓库内验证
|
||||
|
||||
进入作品目录后运行:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s tests
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
生成的 `outputs/` 是运行时目录;固定示例产物位于 `examples/demo_outputs/`。
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# 验证记录
|
||||
|
||||
## 环境
|
||||
|
||||
- Windows PowerShell
|
||||
- Python 3
|
||||
- `@gitlink-ai/cli` 0.1.13
|
||||
|
||||
## 已验证的真实仓库
|
||||
|
||||
### `puygob236/gitlink-cli`
|
||||
|
||||
- `repo +info` 可访问
|
||||
- `issue +list` 可访问
|
||||
- `pr +list` 可访问
|
||||
- `release +list` 可访问
|
||||
- 已完成 Issue 摘要回写验证
|
||||
|
||||
### `Gitlink/gitlink-cli`
|
||||
|
||||
- `repo +info` 可访问
|
||||
- `issue +list` 可访问
|
||||
- `pr +list` 可访问
|
||||
- `release +list` 可访问
|
||||
- 当前可提取到的统计结果:
|
||||
- Issues: 15
|
||||
- PR: 20
|
||||
- Release: 11
|
||||
|
||||
## 本地输出
|
||||
|
||||
已生成的文件:
|
||||
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_040153_report.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_040153_summary.json`
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_121523_report.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_121523_release_notes.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_121523_summary.json`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121544_report.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121544_release_notes.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121544_summary.json`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121845_report.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121845_release_notes.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121845_summary.json`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_report.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json`
|
||||
- `outputs/puygob236_gitlink-cli_20260520_143224_report.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260520_143224_release_notes.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260520_143224_summary.json`
|
||||
|
||||
其中 `20260520_140525` 对应公开仓库数据分析验证,`20260520_143224` 对应参赛仓库采集与 Issue 回写验证。
|
||||
|
||||
## 示例产物
|
||||
|
||||
`outputs/` 是运行时目录,仓库交付中同时提供了轻量示例:
|
||||
|
||||
- `examples/demo_outputs/Gitlink_gitlink-cli_report.md`
|
||||
- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md`
|
||||
- `examples/demo_outputs/puygob236_gitlink-cli_report.md`
|
||||
- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md`
|
||||
|
||||
## 复现方式
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"owner": "Gitlink",
|
||||
"repo": "gitlink-cli",
|
||||
"window_days": 7,
|
||||
"output_dir": "outputs"
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# gitlink-cli Release Notes 草稿
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:05:25 UTC
|
||||
|
||||
## 变更概览
|
||||
- 已合并 PR:8 个
|
||||
- 最近窗口内合并 PR:2 个
|
||||
|
||||
## 变更分类
|
||||
### feature
|
||||
- feat(pr): add pr +comment shortcut (2026-05-14)
|
||||
|
||||
### fix
|
||||
- fix(npm): improve missing binary diagnostics (2026-05-19)
|
||||
|
||||
## 发布说明
|
||||
- 存在 1 个超过 7 天未更新的开放 Issue,建议优先清理。
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# gitlink-cli 自动化周报
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:05:25 UTC
|
||||
|
||||
## 核心指标
|
||||
|
||||
| 指标 | 数值 |
|
||||
| --- | ---: |
|
||||
| Issues 总数 | 15 |
|
||||
| 打开 Issues | 5 |
|
||||
| 超窗 Issue | 1 |
|
||||
| PR 总数 | 20 |
|
||||
| 打开 PR | 5 |
|
||||
| 已合并 PR | 8 |
|
||||
| Release 数 | 11 |
|
||||
|
||||
## 热点标签
|
||||
- 无
|
||||
|
||||
## 最近合并 PR
|
||||
### fix
|
||||
- fix(npm): improve missing binary diagnostics (2026-05-19)
|
||||
### feature
|
||||
- feat(pr): add pr +comment shortcut (2026-05-14)
|
||||
|
||||
## 风险提示
|
||||
### 超窗 Issue
|
||||
- 2 gitlink-cli 使用讨论与反馈收集 (open) 2026-04-18
|
||||
|
||||
### 建议动作
|
||||
- 存在 1 个超过 7 天未更新的开放 Issue,建议优先清理。
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# 示例输出说明
|
||||
|
||||
本目录保存一次真实 GitLink 项目的演示输出,便于评审在不重新运行脚本时快速查看效果。
|
||||
|
||||
- `Gitlink_gitlink-cli_report.md`:活跃官方仓库周报示例
|
||||
- `Gitlink_gitlink-cli_release_notes.md`:活跃官方仓库 Release Notes 草稿示例
|
||||
- `puygob236_gitlink-cli_report.md`:参赛 fork 连通性周报示例
|
||||
- `puygob236_gitlink-cli_release_notes.md`:参赛 fork Release Notes 草稿示例
|
||||
|
||||
完整结构化摘要会在运行脚本后生成到 `outputs/*_summary.json`。
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# gitlink-cli Release Notes 草稿
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:32:24 UTC
|
||||
|
||||
## 变更概览
|
||||
- 已合并 PR:0 个
|
||||
- 最近窗口内合并 PR:0 个
|
||||
|
||||
## 变更分类
|
||||
- 无
|
||||
|
||||
## 发布说明
|
||||
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# gitlink-cli 自动化周报
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:32:24 UTC
|
||||
|
||||
## 核心指标
|
||||
|
||||
| 指标 | 数值 |
|
||||
| --- | ---: |
|
||||
| Issues 总数 | 2 |
|
||||
| 打开 Issues | 2 |
|
||||
| 超窗 Issue | 0 |
|
||||
| PR 总数 | 0 |
|
||||
| 打开 PR | 0 |
|
||||
| 已合并 PR | 0 |
|
||||
| Release 数 | 0 |
|
||||
|
||||
## 热点标签
|
||||
- 无
|
||||
|
||||
## 最近合并 PR
|
||||
- 无
|
||||
|
||||
## 风险提示
|
||||
### 建议动作
|
||||
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"owner": "puygob236",
|
||||
"repo": "gitlink-cli",
|
||||
"window_days": 7,
|
||||
"output_dir": "outputs"
|
||||
}
|
||||
|
|
@ -0,0 +1,814 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
class WorkflowError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
CLI_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="GitLink 社区运营自动化工作流:周报 + Release Notes + 风险提示"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=Path,
|
||||
default=Path("examples/sample_config.json"),
|
||||
help="配置文件路径",
|
||||
)
|
||||
parser.add_argument("--owner", help="覆盖配置中的仓库所有者")
|
||||
parser.add_argument("--repo", help="覆盖配置中的仓库名称")
|
||||
parser.add_argument(
|
||||
"--window-days",
|
||||
type=int,
|
||||
help="统计窗口,默认从配置文件读取或使用 7 天",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
type=Path,
|
||||
help="输出目录,默认从配置文件读取或使用 outputs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--publish-issue-id",
|
||||
type=int,
|
||||
help="发布摘要到指定 Issue 评论,未提供则只生成本地报告",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--now",
|
||||
help="固定当前时间,便于测试,格式为 ISO8601",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-releases",
|
||||
action="store_true",
|
||||
help="跳过 release 列表采集",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cli-bin",
|
||||
help="gitlink-cli 可执行文件路径;可配合 GITLINK_CLI_BIN 使用",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def load_json_file(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def sanitize_repo_name(value: str) -> str:
|
||||
return value.replace("/", "_").replace("\\", "_")
|
||||
|
||||
|
||||
def parse_datetime(value: Any) -> datetime | None:
|
||||
if value in (None, "", []):
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
dt = value
|
||||
else:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
text = text.replace("Z", "+00:00")
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def parse_iso_now(value: str | None) -> datetime:
|
||||
if not value:
|
||||
return datetime.now(timezone.utc)
|
||||
dt = parse_datetime(value)
|
||||
if dt is None:
|
||||
raise WorkflowError(f"无法解析 --now 的值: {value}")
|
||||
return dt
|
||||
|
||||
|
||||
def first_value(item: dict[str, Any], keys: Iterable[str], default: Any = None) -> Any:
|
||||
for key in keys:
|
||||
if key in item:
|
||||
value = item[key]
|
||||
if value not in (None, "", []):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def normalize_labels(value: Any) -> list[str]:
|
||||
labels: list[str] = []
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
name = first_value(item, ("name", "title", "label_name"))
|
||||
if name:
|
||||
labels.append(str(name))
|
||||
elif item not in (None, ""):
|
||||
labels.append(str(item))
|
||||
elif isinstance(value, str) and value:
|
||||
labels.append(value)
|
||||
return labels
|
||||
|
||||
|
||||
def extract_first_list(payload: Any, keys: Iterable[str]) -> list[Any]:
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if isinstance(payload, dict):
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
for value in payload.values():
|
||||
found = extract_first_list(value, keys)
|
||||
if found:
|
||||
return found
|
||||
return []
|
||||
|
||||
|
||||
def extract_first_dict(payload: Any, keys: Iterable[str]) -> dict[str, Any]:
|
||||
if isinstance(payload, dict):
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
for value in payload.values():
|
||||
found = extract_first_dict(value, keys)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(payload, list):
|
||||
for item in payload:
|
||||
found = extract_first_dict(item, keys)
|
||||
if found:
|
||||
return found
|
||||
return {}
|
||||
|
||||
|
||||
def run_gitlink_cli(command: list[str], owner: str, repo: str, cwd: Path | None = None) -> Any:
|
||||
if shutil_which("gitlink-cli") is None:
|
||||
raise WorkflowError("未找到 gitlink-cli,请先安装并确保它在 PATH 中")
|
||||
|
||||
cli_path = shutil_which("gitlink-cli") or "gitlink-cli"
|
||||
if cli_path.lower().endswith((".cmd", ".bat")):
|
||||
cmd = [
|
||||
"cmd",
|
||||
"/c",
|
||||
cli_path,
|
||||
*command,
|
||||
"--owner",
|
||||
owner,
|
||||
"--repo",
|
||||
repo,
|
||||
"--format",
|
||||
"json",
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
cli_path,
|
||||
*command,
|
||||
"--owner",
|
||||
owner,
|
||||
"--repo",
|
||||
repo,
|
||||
"--format",
|
||||
"json",
|
||||
]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
stderr = proc.stderr.strip() or proc.stdout.strip() or "未知错误"
|
||||
raise WorkflowError(f"{' '.join(cmd)} 失败: {stderr}")
|
||||
return parse_json_output(proc.stdout)
|
||||
|
||||
|
||||
def parse_json_output(text: str) -> Any:
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
raise WorkflowError("CLI 返回空结果")
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
first_json = min(
|
||||
[idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1],
|
||||
default=-1,
|
||||
)
|
||||
if first_json > 0:
|
||||
return json.loads(stripped[first_json:])
|
||||
raise WorkflowError(f"无法解析 CLI JSON 输出: {stripped[:120]}")
|
||||
|
||||
|
||||
def normalize_repo_info(payload: Any) -> dict[str, Any]:
|
||||
repo = extract_first_dict(payload, ("project", "repo", "repository", "data"))
|
||||
if not repo and isinstance(payload, dict):
|
||||
repo = payload
|
||||
return {
|
||||
"name": first_value(repo, ("name", "repo_name", "project_name", "identifier"), ""),
|
||||
"description": first_value(repo, ("description", "desc", "summary"), ""),
|
||||
"default_branch": first_value(repo, ("default_branch", "defaultBranch"), ""),
|
||||
"language": first_value(repo, ("language",), ""),
|
||||
"raw": repo,
|
||||
}
|
||||
|
||||
|
||||
def normalize_issue_state(item: dict[str, Any], query_state: str | None = None) -> str:
|
||||
raw_status = first_value(item, ("status_id", "status", "state_id"), None)
|
||||
raw_name = str(
|
||||
first_value(item, ("issue_status", "status_name", "state", "status_name_cn"), "")
|
||||
).strip().lower()
|
||||
if raw_status is not None:
|
||||
try:
|
||||
raw_status = int(raw_status)
|
||||
except (TypeError, ValueError):
|
||||
raw_status = str(raw_status).strip().lower()
|
||||
if raw_status in {5, "5", "closed", "close"} or "关" in raw_name or "closed" in raw_name:
|
||||
return "closed"
|
||||
if raw_status in {1, "1", 2, "2", 3, "3", "open", "opened"} or "开" in raw_name or "新" in raw_name:
|
||||
return "open"
|
||||
if query_state:
|
||||
return query_state
|
||||
return "open"
|
||||
|
||||
|
||||
def normalize_issue(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(first_value(item, ("project_issues_index", "iid", "issue_id", "id", "number"), "")),
|
||||
"title": str(first_value(item, ("subject", "title", "name"), "(untitled)")),
|
||||
"state": normalize_issue_state(item, query_state=query_state),
|
||||
"created_at": parse_datetime(
|
||||
first_value(item, ("created_at", "createdAt", "created_time", "created", "format_time"))
|
||||
),
|
||||
"updated_at": parse_datetime(
|
||||
first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "format_time"))
|
||||
),
|
||||
"labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])),
|
||||
"raw": item,
|
||||
}
|
||||
|
||||
|
||||
def normalize_issues(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]:
|
||||
items = extract_first_list(payload, ("issues", "issue_list", "items", "list"))
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized.append(normalize_issue(item, query_state=query_state))
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_pr_state(item: dict[str, Any], query_state: str | None = None) -> str:
|
||||
raw_status = first_value(item, ("pull_request_status", "pull_request_staus", "status_id", "state_id"), None)
|
||||
if raw_status is not None:
|
||||
try:
|
||||
raw_status = int(raw_status)
|
||||
except (TypeError, ValueError):
|
||||
raw_status = str(raw_status).strip().lower()
|
||||
if raw_status in {1, "1", "merged"}:
|
||||
return "merged"
|
||||
if raw_status in {2, "2", "closed", "close"}:
|
||||
return "closed"
|
||||
if raw_status in {0, "0", "open", "opened"}:
|
||||
return "open"
|
||||
if query_state:
|
||||
return query_state
|
||||
return "open"
|
||||
|
||||
|
||||
def normalize_pr(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]:
|
||||
state = normalize_pr_state(item, query_state=query_state)
|
||||
merged_at = parse_datetime(first_value(item, ("merged_at", "mergedAt", "merged_time")))
|
||||
merged_flag = state == "merged" or merged_at is not None
|
||||
return {
|
||||
"id": str(
|
||||
first_value(item, ("pull_request_number", "iid", "pr_id", "merge_request_iid", "id", "number"), "")
|
||||
),
|
||||
"title": str(first_value(item, ("title", "subject", "name"), "(untitled)")),
|
||||
"state": state,
|
||||
"created_at": parse_datetime(
|
||||
first_value(item, ("created_at", "createdAt", "created_time", "created", "pr_full_time"))
|
||||
),
|
||||
"updated_at": parse_datetime(
|
||||
first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "pr_full_time"))
|
||||
),
|
||||
"merged_at": merged_at
|
||||
or (parse_datetime(first_value(item, ("pr_full_time",))) if state == "merged" else None),
|
||||
"merged": merged_flag,
|
||||
"labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])),
|
||||
"raw": item,
|
||||
}
|
||||
|
||||
|
||||
def normalize_prs(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]:
|
||||
items = extract_first_list(payload, ("pull_requests", "merge_requests", "prs", "items", "list"))
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized.append(normalize_pr(item, query_state=query_state))
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_releases(payload: Any) -> list[dict[str, Any]]:
|
||||
items = extract_first_list(payload, ("releases", "items", "list"))
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"id": str(first_value(item, ("version_id", "id", "release_id", "iid"), "")),
|
||||
"title": str(first_value(item, ("name", "title", "tag_name"), "(untitled)")),
|
||||
"created_at": parse_datetime(
|
||||
first_value(item, ("created_at", "createdAt", "released_at", "releasedAt"))
|
||||
),
|
||||
"raw": item,
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def is_open(state: str) -> bool:
|
||||
return state == "open"
|
||||
|
||||
|
||||
def is_closed(state: str) -> bool:
|
||||
return state in {"closed", "close", "done", "resolved"}
|
||||
|
||||
|
||||
def classify_title(title: str) -> str:
|
||||
lowered = title.strip().lower()
|
||||
prefix = lowered.split(":", 1)[0]
|
||||
prefix = prefix.split("(", 1)[0].strip()
|
||||
mapping = {
|
||||
"feat": "feature",
|
||||
"feature": "feature",
|
||||
"fix": "fix",
|
||||
"bugfix": "fix",
|
||||
"docs": "docs",
|
||||
"doc": "docs",
|
||||
"refactor": "refactor",
|
||||
"test": "test",
|
||||
"chore": "chore",
|
||||
"ci": "ci",
|
||||
}
|
||||
return mapping.get(prefix, "other")
|
||||
|
||||
|
||||
def within_window(dt: datetime | None, cutoff: datetime) -> bool:
|
||||
return dt is not None and dt >= cutoff
|
||||
|
||||
|
||||
def dedupe_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
seen: set[str] = set()
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in records:
|
||||
key = str(item.get("id", "")).strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def fetch_paginated_payload(
|
||||
command: list[str],
|
||||
owner: str,
|
||||
repo: str,
|
||||
item_keys: tuple[str, ...],
|
||||
page_size: int = CLI_PAGE_SIZE,
|
||||
) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
max_pages = 50
|
||||
while True:
|
||||
if page > max_pages:
|
||||
break
|
||||
payload = run_gitlink_cli(
|
||||
[*command, "--page", str(page), "--limit", str(page_size)],
|
||||
owner,
|
||||
repo,
|
||||
)
|
||||
page_items = extract_first_list(payload, item_keys)
|
||||
page_items = [item for item in page_items if isinstance(item, dict)]
|
||||
if not page_items:
|
||||
break
|
||||
items.extend(page_items)
|
||||
if len(page_items) < page_size:
|
||||
break
|
||||
page += 1
|
||||
return items
|
||||
|
||||
|
||||
def fetch_issues(owner: str, repo: str) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for state in ("open", "closed"):
|
||||
payloads = fetch_paginated_payload(
|
||||
["issue", "+list", "--state", state],
|
||||
owner,
|
||||
repo,
|
||||
("issues", "issue_list", "items", "list"),
|
||||
)
|
||||
records.extend(normalize_issues({"issues": payloads}, query_state=state))
|
||||
return dedupe_records(records)
|
||||
|
||||
|
||||
def fetch_prs(owner: str, repo: str) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for state in ("open", "merged", "closed"):
|
||||
payloads = fetch_paginated_payload(
|
||||
["pr", "+list", "--state", state],
|
||||
owner,
|
||||
repo,
|
||||
("pull_requests", "merge_requests", "prs", "items", "list"),
|
||||
)
|
||||
records.extend(normalize_prs({"pull_requests": payloads}, query_state=state))
|
||||
return dedupe_records(records)
|
||||
|
||||
|
||||
def fetch_releases(owner: str, repo: str) -> list[dict[str, Any]]:
|
||||
payloads = fetch_paginated_payload(
|
||||
["release", "+list"],
|
||||
owner,
|
||||
repo,
|
||||
("releases", "items", "list"),
|
||||
)
|
||||
return dedupe_records(normalize_releases({"releases": payloads}))
|
||||
|
||||
|
||||
def summarize_workflow(
|
||||
repo_info: dict[str, Any],
|
||||
issues: list[dict[str, Any]],
|
||||
prs: list[dict[str, Any]],
|
||||
releases: list[dict[str, Any]],
|
||||
now: datetime,
|
||||
window_days: int,
|
||||
) -> dict[str, Any]:
|
||||
cutoff = now - timedelta(days=window_days)
|
||||
|
||||
open_issues = [item for item in issues if is_open(item["state"])]
|
||||
closed_issues = [item for item in issues if is_closed(item["state"])]
|
||||
stale_issues = [
|
||||
item
|
||||
for item in open_issues
|
||||
if item["updated_at"] is None or item["updated_at"] < cutoff
|
||||
]
|
||||
|
||||
merged_prs = [item for item in prs if item["merged"] or item["state"] == "merged"]
|
||||
open_prs = [item for item in prs if is_open(item["state"]) or (not item["merged"] and not is_closed(item["state"]))]
|
||||
stale_prs = [
|
||||
item
|
||||
for item in open_prs
|
||||
if item["updated_at"] is None or item["updated_at"] < cutoff
|
||||
]
|
||||
recent_merged_prs = [
|
||||
item
|
||||
for item in merged_prs
|
||||
if within_window(item["merged_at"] or item["updated_at"] or item["created_at"], cutoff)
|
||||
]
|
||||
|
||||
issue_label_counter: Counter[str] = Counter()
|
||||
for item in issues:
|
||||
issue_label_counter.update(item["labels"])
|
||||
|
||||
pr_buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for item in recent_merged_prs:
|
||||
pr_buckets[classify_title(item["title"])].append(item)
|
||||
|
||||
actions: list[str] = []
|
||||
if stale_issues:
|
||||
actions.append(
|
||||
f"存在 {len(stale_issues)} 个超过 {window_days} 天未更新的开放 Issue,建议优先清理。"
|
||||
)
|
||||
if stale_prs:
|
||||
actions.append(
|
||||
f"存在 {len(stale_prs)} 个超过 {window_days} 天未更新的开放 PR,建议安排 review 或重新拆解。"
|
||||
)
|
||||
if not releases:
|
||||
actions.append("当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。")
|
||||
|
||||
return {
|
||||
"repo": repo_info,
|
||||
"window_days": window_days,
|
||||
"now": now,
|
||||
"cutoff": cutoff,
|
||||
"counts": {
|
||||
"issues_total": len(issues),
|
||||
"issues_open": len(open_issues),
|
||||
"issues_closed": len(closed_issues),
|
||||
"issues_stale": len(stale_issues),
|
||||
"prs_total": len(prs),
|
||||
"prs_open": len(open_prs),
|
||||
"prs_merged": len(merged_prs),
|
||||
"prs_stale": len(stale_prs),
|
||||
"releases_total": len(releases),
|
||||
},
|
||||
"labels": issue_label_counter.most_common(8),
|
||||
"stale_issues": stale_issues,
|
||||
"stale_prs": stale_prs,
|
||||
"recent_merged_prs": recent_merged_prs,
|
||||
"pr_buckets": {key: value for key, value in pr_buckets.items()},
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
def render_list_block(items: list[dict[str, Any]], title_key: str = "title") -> str:
|
||||
if not items:
|
||||
return "- 无"
|
||||
lines = []
|
||||
for item in items[:10]:
|
||||
parts = [f"- {item.get('id', '')} {item.get(title_key, '')}".strip()]
|
||||
state = item.get("state")
|
||||
if state:
|
||||
parts.append(f"({state})")
|
||||
dt = item.get("updated_at") or item.get("merged_at") or item.get("created_at")
|
||||
if isinstance(dt, datetime):
|
||||
parts.append(dt.strftime("%Y-%m-%d"))
|
||||
lines.append(" ".join(parts))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_markdown_report(summary: dict[str, Any]) -> str:
|
||||
repo = summary["repo"]
|
||||
counts = summary["counts"]
|
||||
lines: list[str] = []
|
||||
title = repo["name"] or "GitLink 仓库"
|
||||
lines.append(f"# {title} 自动化周报")
|
||||
if repo.get("description"):
|
||||
lines.append("")
|
||||
lines.append(repo["description"])
|
||||
lines.append("")
|
||||
lines.append(f"- 统计窗口:近 {summary['window_days']} 天")
|
||||
lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
lines.append("")
|
||||
lines.append("## 核心指标")
|
||||
lines.append("")
|
||||
lines.append("| 指标 | 数值 |")
|
||||
lines.append("| --- | ---: |")
|
||||
lines.append(f"| Issues 总数 | {counts['issues_total']} |")
|
||||
lines.append(f"| 打开 Issues | {counts['issues_open']} |")
|
||||
lines.append(f"| 超窗 Issue | {counts['issues_stale']} |")
|
||||
lines.append(f"| PR 总数 | {counts['prs_total']} |")
|
||||
lines.append(f"| 打开 PR | {counts['prs_open']} |")
|
||||
lines.append(f"| 已合并 PR | {counts['prs_merged']} |")
|
||||
lines.append(f"| Release 数 | {counts['releases_total']} |")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 热点标签")
|
||||
if summary["labels"]:
|
||||
for label, count in summary["labels"]:
|
||||
lines.append(f"- {label}: {count}")
|
||||
else:
|
||||
lines.append("- 无")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 最近合并 PR")
|
||||
recent_groups = summary["pr_buckets"]
|
||||
if recent_groups:
|
||||
for bucket, items in recent_groups.items():
|
||||
lines.append(f"### {bucket}")
|
||||
for item in items[:8]:
|
||||
merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at")
|
||||
suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else ""
|
||||
lines.append(f"- {item['title']}{suffix}")
|
||||
else:
|
||||
lines.append("- 无")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 风险提示")
|
||||
if summary["stale_issues"]:
|
||||
lines.append("### 超窗 Issue")
|
||||
lines.append(render_list_block(summary["stale_issues"]))
|
||||
lines.append("")
|
||||
if summary["stale_prs"]:
|
||||
lines.append("### 超窗 PR")
|
||||
lines.append(render_list_block(summary["stale_prs"]))
|
||||
lines.append("")
|
||||
if summary["actions"]:
|
||||
lines.append("### 建议动作")
|
||||
for action in summary["actions"]:
|
||||
lines.append(f"- {action}")
|
||||
else:
|
||||
lines.append("- 当前未发现明显风险。")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def render_release_notes(summary: dict[str, Any]) -> str:
|
||||
repo = summary["repo"]
|
||||
lines: list[str] = []
|
||||
title = repo["name"] or "GitLink 仓库"
|
||||
lines.append(f"# {title} Release Notes 草稿")
|
||||
lines.append("")
|
||||
lines.append(f"- 统计窗口:近 {summary['window_days']} 天")
|
||||
lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
lines.append("")
|
||||
lines.append("## 变更概览")
|
||||
lines.append(f"- 已合并 PR:{summary['counts']['prs_merged']} 个")
|
||||
lines.append(f"- 最近窗口内合并 PR:{len(summary['recent_merged_prs'])} 个")
|
||||
lines.append("")
|
||||
lines.append("## 变更分类")
|
||||
groups = summary["pr_buckets"]
|
||||
if groups:
|
||||
for bucket in ("feature", "fix", "docs", "refactor", "test", "chore", "ci", "other"):
|
||||
items = groups.get(bucket, [])
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f"### {bucket}")
|
||||
for item in items[:10]:
|
||||
merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at")
|
||||
suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else ""
|
||||
lines.append(f"- {item['title']}{suffix}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("- 无")
|
||||
lines.append("")
|
||||
lines.append("## 发布说明")
|
||||
if summary["actions"]:
|
||||
for action in summary["actions"]:
|
||||
lines.append(f"- {action}")
|
||||
else:
|
||||
lines.append("- 当前未发现明显风险。")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def render_publish_comment(
|
||||
summary: dict[str, Any],
|
||||
report_path: Path,
|
||||
release_notes_path: Path | None = None,
|
||||
) -> str:
|
||||
repo = summary["repo"]
|
||||
counts = summary["counts"]
|
||||
lines = [
|
||||
f"## {repo['name'] or 'GitLink 仓库'} 自动化周报摘要",
|
||||
"",
|
||||
f"- 时间窗:近 {summary['window_days']} 天",
|
||||
f"- Issues:{counts['issues_open']} 个打开,{counts['issues_stale']} 个超窗",
|
||||
f"- PR:{counts['prs_open']} 个打开,{counts['prs_merged']} 个已合并",
|
||||
f"- Release:{counts['releases_total']} 条",
|
||||
"",
|
||||
f"完整报告已生成:`{report_path.as_posix()}`",
|
||||
]
|
||||
if release_notes_path is not None:
|
||||
lines.append(f"Release Notes 草稿:`{release_notes_path.as_posix()}`")
|
||||
if summary["actions"]:
|
||||
lines.append("")
|
||||
lines.append("### 建议动作")
|
||||
for action in summary["actions"][:3]:
|
||||
lines.append(f"- {action}")
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
def build_issue_comment_command(issue_number: int, comment: str) -> list[str]:
|
||||
return ["issue", "+comment", "--number", str(issue_number), "--body", comment]
|
||||
|
||||
|
||||
def safe_fetch(
|
||||
label: str,
|
||||
func,
|
||||
warnings: list[str],
|
||||
default: Any,
|
||||
) -> Any:
|
||||
try:
|
||||
return func()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warnings.append(f"{label} 失败:{exc}")
|
||||
return default
|
||||
|
||||
|
||||
def shutil_which(name: str) -> str | None:
|
||||
from shutil import which
|
||||
|
||||
return which(name)
|
||||
|
||||
|
||||
def build_artifacts(
|
||||
owner: str,
|
||||
repo: str,
|
||||
window_days: int,
|
||||
output_dir: Path,
|
||||
now: datetime,
|
||||
publish_issue_id: int | None,
|
||||
skip_releases: bool,
|
||||
) -> tuple[dict[str, Any], Path, Path, Path, list[str]]:
|
||||
warnings: list[str] = []
|
||||
repo_info = safe_fetch(
|
||||
"repo +info",
|
||||
lambda: normalize_repo_info(run_gitlink_cli(["repo", "+info"], owner, repo)),
|
||||
warnings,
|
||||
{"name": repo, "description": "", "default_branch": "", "language": "", "raw": {}},
|
||||
)
|
||||
issues = safe_fetch("issue +list", lambda: fetch_issues(owner, repo), warnings, [])
|
||||
prs = safe_fetch("pr +list", lambda: fetch_prs(owner, repo), warnings, [])
|
||||
releases = [] if skip_releases else safe_fetch(
|
||||
"release +list",
|
||||
lambda: fetch_releases(owner, repo),
|
||||
warnings,
|
||||
[],
|
||||
)
|
||||
|
||||
summary = summarize_workflow(repo_info, issues, prs, releases, now, window_days)
|
||||
summary["warnings"] = warnings
|
||||
summary["owner"] = owner
|
||||
summary["repo_name"] = repo
|
||||
summary["publish_issue_id"] = publish_issue_id
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = now.strftime("%Y%m%d_%H%M%S")
|
||||
repo_slug = sanitize_repo_name(repo)
|
||||
base_name = f"{owner}_{repo_slug}_{stamp}"
|
||||
report_path = output_dir / f"{base_name}_report.md"
|
||||
summary_path = output_dir / f"{base_name}_summary.json"
|
||||
release_notes_path = output_dir / f"{base_name}_release_notes.md"
|
||||
|
||||
report_text = render_markdown_report(summary)
|
||||
release_notes_text = render_release_notes(summary)
|
||||
report_path.write_text(report_text, encoding="utf-8")
|
||||
release_notes_path.write_text(release_notes_text, encoding="utf-8")
|
||||
summary_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
**summary,
|
||||
"now": summary["now"].isoformat(),
|
||||
"cutoff": summary["cutoff"].isoformat(),
|
||||
"artifacts": {
|
||||
"report": report_path.as_posix(),
|
||||
"summary": summary_path.as_posix(),
|
||||
"release_notes": release_notes_path.as_posix(),
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
if publish_issue_id is not None:
|
||||
comment = render_publish_comment(summary, report_path, release_notes_path)
|
||||
try:
|
||||
run_gitlink_cli(
|
||||
build_issue_comment_command(publish_issue_id, comment),
|
||||
owner,
|
||||
repo,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warnings.append(f"issue +comment 失败:{exc}")
|
||||
|
||||
return summary, report_path, summary_path, release_notes_path, warnings
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
config = load_json_file(args.config)
|
||||
|
||||
owner = args.owner or config.get("owner")
|
||||
repo = args.repo or config.get("repo")
|
||||
if not owner or not repo:
|
||||
raise WorkflowError("请在配置文件或命令行中提供 owner 和 repo")
|
||||
|
||||
window_days = args.window_days or int(config.get("window_days", 7))
|
||||
output_dir = args.output_dir or Path(config.get("output_dir", "outputs"))
|
||||
now = parse_iso_now(args.now)
|
||||
|
||||
summary, report_path, summary_path, release_notes_path, warnings = build_artifacts(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
window_days=window_days,
|
||||
output_dir=output_dir,
|
||||
now=now,
|
||||
publish_issue_id=args.publish_issue_id,
|
||||
skip_releases=args.skip_releases,
|
||||
)
|
||||
|
||||
print(f"已生成报告: {report_path}")
|
||||
print(f"已生成摘要: {summary_path}")
|
||||
print(f"已生成 Release Notes: {release_notes_path}")
|
||||
if warnings:
|
||||
print("警告:")
|
||||
for warning in warnings:
|
||||
print(f"- {warning}")
|
||||
print(
|
||||
"指标概览: "
|
||||
f"Issues={summary['counts']['issues_total']}, "
|
||||
f"PR={summary['counts']['prs_total']}, "
|
||||
f"Release={summary['counts']['releases_total']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
param(
|
||||
[string]$Config = "examples/demo_active_config.json",
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[int]$WindowDays = 7,
|
||||
[string]$OutputDir = "outputs",
|
||||
[int]$PublishIssueId = 0,
|
||||
[switch]$SkipReleases
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$cliCandidates = npm.cmd exec --yes --package=@gitlink-ai/cli -- cmd /c where gitlink-cli 2>$null
|
||||
$cliPath = $cliCandidates | Where-Object { $_ -match 'gitlink-cli\.cmd$' } | Select-Object -First 1
|
||||
if (-not $cliPath) {
|
||||
$cliPath = $cliCandidates | Select-Object -First 1
|
||||
}
|
||||
if (-not $cliPath) {
|
||||
throw "未能通过 npm exec 找到 gitlink-cli"
|
||||
}
|
||||
|
||||
$cliDir = Split-Path -Parent $cliPath
|
||||
$env:PATH = "$cliDir;$env:PATH"
|
||||
|
||||
$args = @(
|
||||
"scripts\gitlink_workflow.py",
|
||||
"--config", $Config,
|
||||
"--window-days", "$WindowDays",
|
||||
"--output-dir", $OutputDir
|
||||
)
|
||||
|
||||
if ($Owner) {
|
||||
$args += @("--owner", $Owner)
|
||||
}
|
||||
if ($Repo) {
|
||||
$args += @("--repo", $Repo)
|
||||
}
|
||||
if ($PublishIssueId -gt 0) {
|
||||
$args += @("--publish-issue-id", "$PublishIssueId")
|
||||
}
|
||||
if ($SkipReleases.IsPresent) {
|
||||
$args += "--skip-releases"
|
||||
}
|
||||
|
||||
python @args
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from scripts.gitlink_workflow import (
|
||||
build_issue_comment_command,
|
||||
normalize_issues,
|
||||
normalize_prs,
|
||||
normalize_releases,
|
||||
render_markdown_report,
|
||||
render_release_notes,
|
||||
summarize_workflow,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.now = datetime(2026, 5, 15, 12, 0, tzinfo=timezone.utc)
|
||||
self.repo_info = {
|
||||
"name": "forgeplus",
|
||||
"description": "demo repo",
|
||||
"default_branch": "master",
|
||||
}
|
||||
|
||||
def test_normalize_issue_payload(self) -> None:
|
||||
payload = {
|
||||
"data": {
|
||||
"issues": [
|
||||
{
|
||||
"project_issues_index": 1,
|
||||
"subject": "feat: add report",
|
||||
"status_id": 1,
|
||||
"status_name": "新增",
|
||||
"updated_at": "2026-05-10T10:00:00Z",
|
||||
"labels": [{"name": "enhancement"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
issues = normalize_issues(payload)
|
||||
self.assertEqual(len(issues), 1)
|
||||
self.assertEqual(issues[0]["title"], "feat: add report")
|
||||
self.assertEqual(issues[0]["labels"], ["enhancement"])
|
||||
self.assertEqual(issues[0]["state"], "open")
|
||||
|
||||
def test_normalize_pr_payload(self) -> None:
|
||||
payload = {
|
||||
"data": {
|
||||
"merge_requests": [
|
||||
{
|
||||
"pull_request_number": 10,
|
||||
"title": "fix: bug",
|
||||
"pull_request_status": 1,
|
||||
"merged_at": "2026-05-14T10:00:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
prs = normalize_prs(payload)
|
||||
self.assertEqual(len(prs), 1)
|
||||
self.assertTrue(prs[0]["merged"])
|
||||
self.assertEqual(prs[0]["state"], "merged")
|
||||
|
||||
def test_normalize_release_payload(self) -> None:
|
||||
payload = {"data": {"releases": [{"id": 5, "name": "v1.0.0"}]}}
|
||||
releases = normalize_releases(payload)
|
||||
self.assertEqual(len(releases), 1)
|
||||
self.assertEqual(releases[0]["title"], "v1.0.0")
|
||||
|
||||
def test_summary_and_report(self) -> None:
|
||||
issues = [
|
||||
{
|
||||
"id": "1",
|
||||
"title": "feat: add report",
|
||||
"state": "open",
|
||||
"created_at": datetime(2026, 5, 5, 12, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 5, 10, 12, 0, tzinfo=timezone.utc),
|
||||
"labels": ["enhancement"],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"title": "fix: stale issue",
|
||||
"state": "open",
|
||||
"created_at": datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc),
|
||||
"labels": ["bug"],
|
||||
},
|
||||
]
|
||||
prs = [
|
||||
{
|
||||
"id": "10",
|
||||
"title": "feat: workflow",
|
||||
"state": "merged",
|
||||
"created_at": datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc),
|
||||
"merged_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc),
|
||||
"merged": True,
|
||||
"labels": [],
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"title": "chore: cleanup",
|
||||
"state": "open",
|
||||
"created_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 5, 2, 12, 0, tzinfo=timezone.utc),
|
||||
"merged_at": None,
|
||||
"merged": False,
|
||||
"labels": [],
|
||||
},
|
||||
]
|
||||
releases = [{"id": "1", "title": "v1.0.0", "created_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc)}]
|
||||
summary = summarize_workflow(self.repo_info, issues, prs, releases, self.now, 7)
|
||||
report = render_markdown_report(summary)
|
||||
self.assertIn("# forgeplus 自动化周报", report)
|
||||
self.assertIn("Issues 总数", report)
|
||||
self.assertIn("超窗 Issue", report)
|
||||
self.assertIn("feature", report)
|
||||
release_notes = render_release_notes(summary)
|
||||
self.assertIn("Release Notes", release_notes)
|
||||
self.assertIn("变更分类", release_notes)
|
||||
self.assertEqual(summary["counts"]["issues_stale"], 1)
|
||||
self.assertEqual(summary["counts"]["prs_merged"], 1)
|
||||
self.assertIn("feature", summary["pr_buckets"])
|
||||
|
||||
def test_issue_comment_command_uses_number_flag(self) -> None:
|
||||
command = build_issue_comment_command(2, "demo")
|
||||
self.assertEqual(command, ["issue", "+comment", "--number", "2", "--body", "demo"])
|
||||
self.assertNotIn("-i", command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
outputs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# PR 质量门禁工作流(pr-quality-gatekeeper)
|
||||
|
||||
把已收录的 [`gitlink-gatekeeper` Skill](../../../skills/gitlink-gatekeeper/SKILL.md)(Policy-as-Code 合并门禁)包成**可直接运行的端到端工作流**:
|
||||
|
||||
> **采集 → 路由 → 裁决 → 回写/善后**:读取一个真实 PR 的元信息/变更文件/commits/CI,按变更路径建议 reviewer,依 `gatekeeper.yaml` 策略算出**确定性 0–100 评分卡**与**三态裁决**(PASS / REQUEST_CHANGES / COMMENT),并(仅在 `--apply` 时)把评分卡评论、裁决标签、tracking issue 真实回写到 GitLink。
|
||||
|
||||
与仓库内已有能力的关系:`label` 命令(裁决标签)→ `gitlink-gatekeeper` Skill(裁决知识)→ **本工作流(可复现闭环)**,三层共用同一套策略文件,互为支撑而非重复。
|
||||
|
||||
## 交付物
|
||||
|
||||
- `scripts/gatekeeper_workflow.py`:单 PR 门禁闭环(纯标准库,Python ≥3.9,零第三方依赖)
|
||||
- `scripts/gatekeeper_sweep.py`:**仓库级批量体检**——对全部 open PR 逐个 dry-run,产出治理报告
|
||||
- `owner-rules.example.yaml`:变更路径 → reviewer 的路由表样例
|
||||
- `config.example.yaml`:工作流配置样例(命令行参数可覆盖)
|
||||
- `findings.example.json`:AI/人工审查发现注入样例(**来自对真实 PR diff 的真实审查**,行号可复核)
|
||||
- `docs/architecture.md` · `docs/quickstart.md` · `docs/runbook.md` · `docs/verification.md`
|
||||
- `ci-example/`:Gitea Actions 接入示例(PR 触发自动门禁,退出码 2 = REQUEST_CHANGES)
|
||||
- `examples/demo-outputs/`:真实平台运行产物(PASS 90 评分卡 / 注入发现后的 55 分评分卡 / 113 个 open PR 的全仓体检报告)
|
||||
- `tests/test_scoring.py`:确定性回归护栏(同输入 → 同分 → 同裁决)
|
||||
|
||||
## 快速运行(默认 dry-run,不写远端)
|
||||
|
||||
```bash
|
||||
npm install -g @gitlink-ai/cli # ≥0.2.0,自带 label 命令与 gitlink-gatekeeper Skill
|
||||
gitlink-cli auth login
|
||||
|
||||
python3 scripts/gatekeeper_workflow.py \
|
||||
--owner <owner> --repo <repo> --pr <PR号> \
|
||||
--policy ../../../skills/gitlink-gatekeeper/examples/gatekeeper.yaml \
|
||||
--owner-rules owner-rules.example.yaml \
|
||||
--output-dir outputs
|
||||
```
|
||||
|
||||
- 注入审查发现得到含扣分的评分卡:加 `--findings findings.example.json`
|
||||
- 真实回写(评论 + 标签 + tracking issue):加 `--apply`(请先在自有仓库演练)
|
||||
- 全仓批量体检(只读,零写入):
|
||||
|
||||
```bash
|
||||
python3 scripts/gatekeeper_sweep.py \
|
||||
--owner <owner> --repo <repo> \
|
||||
--policy ../../../skills/gitlink-gatekeeper/examples/gatekeeper.yaml \
|
||||
--owner-rules owner-rules.example.yaml \
|
||||
--output-dir sweep-out --date-label $(date +%F)
|
||||
```
|
||||
|
||||
更多见 [`docs/quickstart.md`](docs/quickstart.md) 与 [`docs/runbook.md`](docs/runbook.md)。
|
||||
|
||||
## 已在真实平台验证
|
||||
|
||||
全部证据见 [`docs/verification.md`](docs/verification.md),要点:
|
||||
|
||||
| 验证 | 对象 | 结果 |
|
||||
|------|------|------|
|
||||
| dry-run | 本仓库真实 PR(pull_request_id 15222) | ✅ PASS 90/100,8 个变更文件路由正确 |
|
||||
| 注入真实审查发现 | 同一 PR + `findings.example.json` | ❌ REQUEST_CHANGES 55/100(裁决翻转,确定性可复算) |
|
||||
| `--apply` 真实回写 | 自有 fork 的演练 PR | 评分卡评论 + tracking issue + 裁决标签全部由 API 回执确认 |
|
||||
| **全仓批量体检** | 本仓库**全部 113 个 open PR** | 113/113 成功:PASS 105 / COMMENT 6 / REQUEST_CHANGES 2,均分 88.5;96% 未关联 issue |
|
||||
| 单测 | `tests/test_scoring.py` | 全绿(锁定四个权威裁决案例的分值与裁决) |
|
||||
|
||||
## 设计要点
|
||||
|
||||
- **确定性评分**:AI 只负责产出「发现列表」(可选注入),扣分与裁决由纯函数完成——同策略 + 同 PR → 同裁决,可逐位手算复现、可审计。
|
||||
- **安全默认**:默认 dry-run 什么都不写;即便策略开了 `auto_merge`,也必须 `verdict == PASS` 且显式 `--apply` 才会合并;强语义的 approve/reject 始终留给人,自动裁决只以建议性 `common` 评论 + 标签呈现。
|
||||
- **原生适配 GitLink**:PR 标题/描述取自 `pr +view` 的 `issue.subject/description`;标签挂载走「`label +list` 查 id → Raw API `POST /:owner/:repo/issues/<issue_id>`」;尊重 `common/approved/rejected` 三态 review。
|
||||
- **零依赖、零常驻**:纯标准库脚本 + `gitlink-cli`,无需部署 webhook 服务或数据库,CI 一条 step 即可接入(见 `ci-example/`);确定性意味着**大规模治理零 AI 成本**。
|
||||
|
||||
## 许可证
|
||||
|
||||
随仓库 [MulanPSL-2.0](../../../LICENSE)。
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# CI 集成示例 —— 门禁接 CI
|
||||
|
||||
本目录演示如何把 **gitlink-gatekeeper** 的 PR 看门人门禁接到 CI 上,让裁决直接挡住不达标的 PR。
|
||||
|
||||
> 这是**示例**,不是开箱即用的生产配置;`gitlink-cli` 的安装方式、PR 编号字段名需按你的 runner 实际情况调整。
|
||||
|
||||
## 文件
|
||||
|
||||
- [`gatekeeper.gitea.yml`](gatekeeper.gitea.yml):Gitea Actions 工作流(GitLink 基于 Gitea,语法与 GitHub Actions 兼容)。
|
||||
|
||||
## 用法
|
||||
|
||||
1. 把 `gatekeeper.gitea.yml` 复制到目标仓库的 `.gitea/workflows/` 目录。
|
||||
2. 在仓库 **Settings → Actions → Secrets** 新增 `GITLINK_TOKEN`,值为有权读取该仓库 PR 的访问令牌(供 `gitlink-cli` 认证)。**Token 切勿写进仓库或日志。**
|
||||
3. 提一个 PR 触发工作流即可。
|
||||
|
||||
## 工作原理
|
||||
|
||||
- 触发:PR 的 `opened` / `synchronize` / `reopened` 事件。
|
||||
- 步骤:检出 → 准备 Python 3.9(脚本纯标准库,无需装依赖)→ 装 `gitlink-cli` → 跑 `scripts/gatekeeper_workflow.py` 采集本次 PR 上下文并评分裁决。
|
||||
- **退出码即门禁**:
|
||||
- `0` = PASS / COMMENT → job 通过,放行。
|
||||
- `2` = REQUEST_CHANGES → 工作流把它转成 job 失败,挡住该 PR。
|
||||
- `1` = 可预期错误(缺参数 / 未装 `gitlink-cli` 等)→ 同样失败。
|
||||
- 产物:评分卡与 `summary.json` 落在 `outputs/`,工作流用 `upload-artifact` 上传,便于在 CI 页面查看裁决依据。
|
||||
|
||||
调门禁松紧只需改 `--policy` 指向的 `gatekeeper.yaml`(策略字段说明见 [`gitlink-gatekeeper` Skill REFERENCE](../../../../skills/gitlink-gatekeeper/REFERENCE.md))。
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# gitlink-gatekeeper —— Gitea Actions CI 示例(GitLink 平台用)
|
||||
#
|
||||
# ⚠️ 这是一个「门禁接 CI」的演示示例,不是开箱即用的生产配置。
|
||||
# - GitLink 基于 Gitea,其 Actions 语法与 GitHub Actions 兼容,工作流放在
|
||||
# 仓库的 .gitea/workflows/ 目录下。把本文件复制过去并按需调整即可启用。
|
||||
# - 需要在仓库 Settings → Actions → Secrets 配置一个 GITLINK_TOKEN secret
|
||||
# (供 gitlink-cli 认证、采集目标 PR 的上下文)。Token 切勿写进仓库。
|
||||
# - runner 需能访问 GitLink API;Python 3.9+ 与 gitlink-cli 的安装方式按实际
|
||||
# runner 镜像调整(下面 install 步骤仅为占位示意)。
|
||||
#
|
||||
# 触发:对 PR 的 open / 同步事件运行门禁,脚本返回码 2(REQUEST_CHANGES)
|
||||
# 会让本 job 失败,从而在 CI 上挡住该 PR(PASS/COMMENT 返回 0 即通过)。
|
||||
|
||||
name: gatekeeper
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
gatekeeper:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 准备 Python(纯标准库,无需装依赖)
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.9"
|
||||
|
||||
# 安装 gitlink-cli(示意:按 runner 实际情况替换为正确的安装/分发方式)
|
||||
- name: 安装 gitlink-cli
|
||||
run: |
|
||||
# 例如从发布物下载或用包管理器安装,确保 PATH 里有 gitlink-cli
|
||||
gitlink-cli --version
|
||||
|
||||
# 跑门禁:采集本次 PR 上下文 → 评分 → 裁决。
|
||||
# REQUEST_CHANGES 时脚本退出码为 2;下面用 if/exit 把它转成 job 失败。
|
||||
- name: 运行 PR 看门人门禁
|
||||
env:
|
||||
# gitlink-cli 通过该环境变量认证(对应仓库配置的 secret)
|
||||
GITLINK_TOKEN: ${{ secrets.GITLINK_TOKEN }}
|
||||
# Gitea 注入的 PR 编号;不同 runner 字段名可能不同,按实际调整
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
python3 examples/workflows/pr-quality-gatekeeper/scripts/gatekeeper_workflow.py \
|
||||
--owner "${{ github.repository_owner }}" \
|
||||
--repo "${{ github.event.repository.name }}" \
|
||||
--pr "${PR_NUMBER}" \
|
||||
--policy skills/gitlink-gatekeeper/examples/gatekeeper.yaml \
|
||||
--owner-rules workflow/owner-rules.yaml \
|
||||
--cli-bin gitlink-cli
|
||||
code=$?
|
||||
# 退出码:0 = PASS/COMMENT(放行);2 = REQUEST_CHANGES(挡住,让 job 失败);
|
||||
# 1 = 可预期错误(缺参数 / 未装 gitlink-cli 等),同样视为失败。
|
||||
if [ "$code" -eq 0 ]; then
|
||||
echo "门禁通过(PASS/COMMENT)"
|
||||
exit 0
|
||||
elif [ "$code" -eq 2 ]; then
|
||||
echo "::error::门禁裁决 REQUEST_CHANGES,阻止合并"
|
||||
exit 1
|
||||
else
|
||||
echo "::error::门禁执行出错(退出码 $code)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 上传评分卡 / summary 产物,便于在 CI 页面查看裁决依据
|
||||
- name: 上传门禁产物
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gatekeeper-scorecard
|
||||
path: outputs/
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# config.example.yaml — PR 看门人闭环的工作流配置(gatekeeper_workflow.py --config 用)
|
||||
#
|
||||
# 命令行参数(--owner/--repo/--pr/--policy/--owner-rules/--findings)会覆盖这里的同名字段。
|
||||
# 相对路径以本配置文件所在目录为基准解析。
|
||||
|
||||
owner: Gitlink # 目标仓库 owner(GitLink 主分支为 master)
|
||||
repo: gitlink-cli # 目标仓库名
|
||||
pr: 1 # 目标 PR 编号(用 --pr 覆盖以复用同一配置跑不同 PR)
|
||||
|
||||
# 策略文件(Policy-as-Code)。缺省 / 文件不存在时回退脚本内置默认策略(SSOT 第 2 节)。
|
||||
policy: ../skills/gitlink-gatekeeper/examples/gatekeeper.yaml
|
||||
|
||||
# 文件路径 → reviewer 路由表(工作流步骤 1)
|
||||
owner_rules: owner-rules.yaml
|
||||
|
||||
# 可选:AI 审查发现 JSON(注入 review_findings 维度)。
|
||||
# 缺省为空 → 评分仍确定性可复现(同策略 + 同 PR 上下文 → 同裁决)。
|
||||
# 这里默认指向随附的示例(1 major + 2 minor),开箱即可跑通;用 --findings 覆盖为你自己的产物。
|
||||
findings: findings.sample.json
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
# 架构与数据流 — PR 看门人闭环
|
||||
|
||||
本工作流采用「**采集 → 路由 → 裁决 → 回写/善后**」四段式流水线,把 `gitlink-gatekeeper` 的 Policy-as-Code 门禁包成一条可复现闭环。所有数值/字段/算法以已收录的 [`gitlink-gatekeeper` Skill REFERENCE](../../../../skills/gitlink-gatekeeper/REFERENCE.md) 为准。
|
||||
|
||||
## 设计目标
|
||||
|
||||
- **可复现**:同策略 + 同 PR 上下文 → 同评分卡 + 同裁决(确定性算法,SSOT 第 3–5 节)。
|
||||
- **可审计**:评分卡逐维列分 + 备注,硬门禁逐条列出命中原因,裁决可追溯到具体规则与 `gatekeeper.yaml` 版本。
|
||||
- **安全默认**:默认 dry-run,写操作需显式 `--apply`;绝不默认自动合并(SSOT 第 8 节)。
|
||||
- **低门槛**:仅依赖 `gitlink-cli` 与 Python 标准库(含内置 YAML 子集解析器,无第三方包)。
|
||||
- **边界清晰**:采集、路由、裁决、回写四段各自独立,便于单测与替换(如换一套评分维度只动裁决段)。
|
||||
|
||||
## ASCII 流程图
|
||||
|
||||
> 下面是数据流占位图:左侧为 `gitlink-cli` 采集,中间为本脚本的确定性处理,右侧为回写/善后的写操作(仅 `--apply` 时执行)。
|
||||
|
||||
```
|
||||
┌──────────────────────────── gatekeeper_workflow.py ────────────────────────────┐
|
||||
│ │
|
||||
gitlink-cli (读) │ step 1: 路由 step 2: 裁决 step 3: 回写 + 善后 │ gitlink-cli (写, 仅 --apply)
|
||||
───────────────────── │ ─────────────────── ────────────────── ───────────────────────── │ ─────────────────────────────
|
||||
│ │
|
||||
pr +view ──┐ │ ┌─ review_findings(40) ─┐ │
|
||||
pr +files ──┼──▶ 采集 │ changed_files │ test_coverage (20) │ verdict │ pr +comment ─▶ 评分卡评论
|
||||
ci +builds │ 归一化 │ │ │ pr_hygiene (15) │──▶ ── PASS ───────┐ │
|
||||
api .../ │ │ ▼ │ commit_quality(15) │ ── COMMENT ───┐ │ │ label +create ─▶ 裁决标签
|
||||
commits ──┘ │ owner-rules.yaml │ ci_status (10) │ ── REQ_CHG ─┐ │ │ │ (+ 挂 issue_tag_ids
|
||||
▲ │ (glob → reviewer) └───────────┬───────────┘ │ │ │ │ via Raw API POST
|
||||
│ │ │ ▼ │ │ │ │ /:owner/:repo/issues/:id)
|
||||
gatekeeper.yaml ─────┼─────┼──────────────▶ hard_gates 判定 ─┴─▶ total 0..100 ─▶ 阈值 │ │ │ │
|
||||
(Policy-as-Code) │ ▼ (SSOT 第4节) (SSOT 第3节) (第5节) │ │ │ │ issue +create ─▶ tracking issue
|
||||
│ │ suggested_reviewers ──────────────────────────────────────────┘ │ │ │ (仅 REQUEST_CHANGES)
|
||||
findings.json ───────┼──▶ review_findings 注入 │ │ │
|
||||
(可选, AI 审查) │ │ │ │ pr +merge ─▶ 合并 (受限:
|
||||
│ ┌── outputs/*_scorecard.md ◀────────────┘ │ │ PASS + auto_merge + --apply)
|
||||
│ 本地产物落盘 (总是) ───────┤ │ │
|
||||
│ └── outputs/*_summary.json ◀──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
dry-run(默认):右侧写操作仅打印「将要执行的命令」,不实际调用 → 安全。
|
||||
--apply :右侧写操作真正执行;其中合并需同时满足 PASS + 策略 auto_merge=true + --apply。
|
||||
```
|
||||
|
||||
## 四段职责
|
||||
|
||||
### ① 采集(collect_pr_context)
|
||||
调只读 `gitlink-cli` 命令拿到 PR 元信息、变更文件、CI 状态、commits(端点未开放时降级,不阻断)。输出统一归一化为内部结构,兼容 GitLink Envelope 的多种字段名。
|
||||
|
||||
### ② 路由(route_reviewers)
|
||||
读 `owner-rules.yaml`,对每个变更文件按 glob 顺序匹配(首个命中生效,顺序即优先级),产出 `reviewer → 文件清单`;未命中文件归 `default_reviewers`。结果写进评分卡的「Suggested reviewers」分区。**只产出建议,不调用任何写操作**——是否真正分配由维护者决定。
|
||||
|
||||
### ③ 裁决(score_dimensions / evaluate_hard_gates / decide_verdict)
|
||||
- 五维加权评分(权重和=100,SSOT 第 3 节),可选注入 AI findings 影响 `review_findings`。
|
||||
- 硬门禁逐项判定(SSOT 第 4 节),任一命中即 `hard_gate_failed`。
|
||||
- 裁决判定树(SSOT 第 5 节):硬门禁失败 → REQUEST_CHANGES;否则按总分与 `pass`/`request_changes` 阈值落三态。
|
||||
- 渲染评分卡(SSOT 第 6 节模板)。
|
||||
|
||||
### ④ 回写 + 善后(build_*_command + execute_write)
|
||||
按裁决构造写操作计划:评分卡评论、裁决标签、(REQUEST_CHANGES 时)tracking issue、(受限)合并。dry-run 只打印计划;`--apply` 才逐条执行并记录结果到 `summary.json`。
|
||||
|
||||
## 为什么选这条链路
|
||||
|
||||
子赛题三要求用现有命令 / Skill 组合形成完整解决方案,且串联不少于 3 步。本链路:
|
||||
1. 串联了 **4 个只读采集命令** + **最多 4 个写命令**,远超 3 步下限。
|
||||
2. 形成从「数据获取」到「治理动作落地」的端到端闭环,并能接入 CI(REQUEST_CHANGES 返回码 2)。
|
||||
3. 复用本作品自研的 `label` 命令组(子赛题一)与 gatekeeper 策略(子赛题二),三个子赛题在同一作品内闭环,相互增强。
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# 最短复现路径(3 步)
|
||||
|
||||
## 1. 安装与认证
|
||||
|
||||
```bash
|
||||
npm install -g @gitlink-ai/cli # ≥0.2.0(label 命令与 gitlink-gatekeeper Skill 已内置)
|
||||
gitlink-cli auth login # 或 export GITLINK_TOKEN=<私人令牌>
|
||||
gitlink-cli auth status # 确认已登录
|
||||
```
|
||||
|
||||
## 2. 对任意真实 PR 出评分卡(dry-run,零写入)
|
||||
|
||||
在本目录(`examples/workflows/pr-quality-gatekeeper/`)下:
|
||||
|
||||
```bash
|
||||
python3 scripts/gatekeeper_workflow.py \
|
||||
--owner Gitlink --repo gitlink-cli --pr <PR号> \
|
||||
--policy ../../../skills/gitlink-gatekeeper/examples/gatekeeper.yaml \
|
||||
--owner-rules owner-rules.example.yaml \
|
||||
--output-dir outputs
|
||||
```
|
||||
|
||||
产物:`outputs/<owner>_<repo>_pr<id>_scorecard.md`(评分卡)+ `_summary.json`(结构化摘要)。
|
||||
退出码:`0` = PASS/COMMENT,`2` = REQUEST_CHANGES(可直接当 CI 门禁用),`1` = 运行错误。
|
||||
|
||||
不带 `--policy` 也能跑(脚本内置同值默认策略);想看含扣分的评分卡,加 `--findings findings.example.json`。
|
||||
|
||||
## 3. 可选进阶
|
||||
|
||||
- **真实回写**(评论 + 裁决标签 + tracking issue):加 `--apply`。请先在自有 fork 演练;自动裁决只用建议性 `common` 评论,绝不替人 approve/reject,绝不自动合并。
|
||||
- **全仓体检**(只读批扫全部 open PR,出治理报告):
|
||||
|
||||
```bash
|
||||
python3 scripts/gatekeeper_sweep.py \
|
||||
--owner Gitlink --repo gitlink-cli \
|
||||
--policy ../../../skills/gitlink-gatekeeper/examples/gatekeeper.yaml \
|
||||
--owner-rules owner-rules.example.yaml \
|
||||
--output-dir sweep-out --date-label $(date +%F)
|
||||
```
|
||||
|
||||
- **CI 接入**:见 [`../ci-example/`](../ci-example/)(Gitea Actions,PR 触发自动门禁)。
|
||||
- **改门禁松紧**:复制一份 `gatekeeper.yaml` 改 `weights/hard_gates/thresholds`,字段说明见 [Skill REFERENCE](../../../../skills/gitlink-gatekeeper/REFERENCE.md)。
|
||||
|
||||
## 验证自己改动没破坏确定性
|
||||
|
||||
```bash
|
||||
python3 tests/test_scoring.py # 同输入 → 同分 → 同裁决 的回归护栏
|
||||
```
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
# 运行手册 — PR 看门人闭环
|
||||
|
||||
本手册覆盖 `scripts/gatekeeper_workflow.py` 的前置条件、运行步骤、参数、预期输出与回滚。数值/字段以已收录的 [`gitlink-gatekeeper` Skill REFERENCE](../../../../skills/gitlink-gatekeeper/REFERENCE.md) 为准。
|
||||
|
||||
## 1. 前置条件
|
||||
|
||||
- 已安装 `gitlink-cli` 且在 `PATH` 中(或用 `--cli-bin` 指定路径)。
|
||||
- 已完成登录:`gitlink-cli auth login`(Token 有效期 7 天,过期重新登录;详见 [gitlink-shared](../../../../skills/gitlink-shared/SKILL.md))。
|
||||
- 对目标仓库有读权限;要 `--apply` 回写评论/标签/建 issue 时需写权限。
|
||||
- Python 3.8+(脚本纯标准库,无需 `pip install`)。
|
||||
|
||||
验证登录态:
|
||||
|
||||
```bash
|
||||
gitlink-cli auth status
|
||||
gitlink-cli pr +view -i <pr_id> --owner <owner> --repo <repo> --format json # 确认目标 PR 可读
|
||||
```
|
||||
|
||||
## 2. 配置
|
||||
|
||||
编辑 [`../config.example.yaml`](../config.example.yaml)(或复制一份),填好 `owner`/`repo`/`pr` 与策略、路由表路径。命令行参数会覆盖配置同名字段,相对路径以配置文件所在目录为基准。
|
||||
|
||||
按需调整 [`../owner-rules.example.yaml`](../owner-rules.example.yaml):把占位 reviewer 用户名替换成本仓库维护者,按「具体规则在前」排列 glob。
|
||||
|
||||
按需选择策略预设(均在 [`../../../../skills/gitlink-gatekeeper/examples/`](../../../../skills/gitlink-gatekeeper/examples/)):
|
||||
- `gatekeeper.yaml`:均衡基线(= SSOT 内置默认)。
|
||||
- `gatekeeper.strict.yaml`:严格预设。
|
||||
- `gatekeeper.lenient.yaml`:宽松预设。
|
||||
- 不指定 `--policy` 且配置无 `policy` 字段时,回退脚本内置默认策略(与 `gatekeeper.yaml` 等价)。
|
||||
|
||||
## 3. 运行步骤
|
||||
|
||||
### 步骤 A:dry-run 预览(安全默认,必做)
|
||||
|
||||
```bash
|
||||
python3 scripts/gatekeeper_workflow.py --config config.example.yaml --pr <pr_id>
|
||||
```
|
||||
|
||||
此模式**不写任何东西**,只采集 + 评分 + 打印将要执行的写命令 + 落盘本地产物。先看评分卡与计划是否符合预期。
|
||||
|
||||
### 步骤 B:注入 AI 审查发现(可选)
|
||||
|
||||
`review_findings` 维度默认按 0 发现计分。若已有 AI 代码审查(如 `gitlink-code-review` Skill)产出,整理成 JSON 注入:
|
||||
|
||||
```json
|
||||
{ "findings": [
|
||||
{ "severity": "blocker", "message": "硬编码密钥", "file": "internal/auth/refresh.go", "line": 12 },
|
||||
{ "severity": "minor", "message": "缺超时上下文", "file": "internal/auth/handler.go", "line": 40 }
|
||||
] }
|
||||
```
|
||||
|
||||
```bash
|
||||
python3 scripts/gatekeeper_workflow.py --config config.example.yaml --pr <pr_id> --findings findings.json
|
||||
```
|
||||
|
||||
`severity` 取值:`blocker` / `major` / `minor` / `nit`(其余忽略)。
|
||||
|
||||
### 步骤 C:apply 执行写操作
|
||||
|
||||
确认 dry-run 计划无误后,加 `--apply`:
|
||||
|
||||
```bash
|
||||
python3 scripts/gatekeeper_workflow.py --config config.example.yaml --pr <pr_id> --apply
|
||||
```
|
||||
|
||||
将依次执行(按裁决):回写评分卡评论 → 确保裁决标签存在 →(仅 REQUEST_CHANGES)创建 tracking issue。
|
||||
**合并不会自动发生**:仅当策略 `behavior.auto_merge: true` 且裁决为 `PASS` 且本次带 `--apply` 时,才追加 `pr +merge`。默认 `auto_merge: false`。
|
||||
|
||||
## 4. 参数速查
|
||||
|
||||
| 参数 | 说明 | 默认 |
|
||||
|------|------|------|
|
||||
| `--config` | 工作流配置 YAML(owner/repo/pr/policy/owner_rules/findings) | 无 |
|
||||
| `--owner` / `--repo` / `--pr` | 覆盖配置中的目标 | 取自 config |
|
||||
| `--policy` | `gatekeeper.yaml` 路径 | 内置默认策略 |
|
||||
| `--owner-rules` | `owner-rules.yaml` 路径 | 取自 config |
|
||||
| `--findings` | AI 审查发现 JSON | 空(0 发现) |
|
||||
| `--cli-bin` | `gitlink-cli` 可执行路径 | `gitlink-cli` |
|
||||
| `--skip-ci` | 跳过 CI 采集(`ci_status` 记 `unknown`) | 否 |
|
||||
| `--output-dir` | 本地产物目录 | `outputs` |
|
||||
| `--apply` | **执行写操作**;不传则仅预览 | 否(dry-run) |
|
||||
|
||||
## 5. 预期输出
|
||||
|
||||
- 终端:三段进度(路由 / 裁决 / 回写)+ 评分概览 + 计划或执行结果 + 最终裁决。
|
||||
- 文件:
|
||||
- `outputs/<owner>_<repo>_pr<id>_scorecard.md` — 评分卡(SSOT 第 6 节模板)。
|
||||
- `outputs/<owner>_<repo>_pr<id>_summary.json` — 结构化摘要(路由、各维得分、硬门禁、裁决、`planned_writes`、`executed`、产物路径)。
|
||||
- 退出码:`PASS`/`COMMENT` → `0`;`REQUEST_CHANGES` → `2`(可作 CI 门禁);可预期错误(缺配置 / 未登录 / CLI 缺失)→ `1`。
|
||||
|
||||
样例评分卡见 [`../../../../skills/gitlink-gatekeeper/examples/scorecard-sample.md`](../../../../skills/gitlink-gatekeeper/examples/scorecard-sample.md)。
|
||||
|
||||
## 6. 回滚
|
||||
|
||||
dry-run 不产生任何远端副作用,无需回滚(本地产物可直接删 `outputs/`)。
|
||||
|
||||
`--apply` 后如需撤销:
|
||||
|
||||
| 已做的写操作 | 回滚方式 |
|
||||
|--------------|----------|
|
||||
| 回写的评分卡评论 | 评论走 issue journals,在 PR 页面手动删除该评论即可;脚本不提供删除命令(避免误删他人评论) |
|
||||
| 创建的裁决标签定义 | `gitlink-cli label +delete -i <label_id> --owner <o> --repo <r>`(先 `label +list` 查 id) |
|
||||
| 创建的 tracking issue | `gitlink-cli issue +close -n <number> --owner <o> --repo <r>`(关闭而非删除,保留审计痕迹) |
|
||||
| 已合并的 PR | **不可自动回滚**。这也是默认 `auto_merge: false` 的原因;合并前务必人工确认。如确需撤销,按仓库常规流程 revert commit |
|
||||
|
||||
> 安全提示:任何 `--apply` 写操作前,脚本会在 dry-run 计划里完整复述将执行的命令。生产仓库建议先 dry-run,再 `--apply`。
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# 真实平台验证记录
|
||||
|
||||
> 全部针对 **GitLink 线上真实平台** 运行(`gitlink-cli` + Token 认证),非 mock。
|
||||
> 他人仓库一律 dry-run(只读零写入);写操作只在自有 fork 演练。
|
||||
> 运行环境:macOS(Apple Silicon)· Python 3.9 · `@gitlink-ai/cli` 0.2.0(npm 官方发布版,零本地构建)。
|
||||
|
||||
## A. dry-run:真实 PR → PASS 90/100
|
||||
|
||||
对本仓库真实 PR(`pull_request_id 15222`,feat/org-team-projects,8 个变更文件):
|
||||
|
||||
- 路由正确分流:README/docs/skill → doc-maintainer;`org.go` → go-reviewer;`org_test.go` → qa-reviewer
|
||||
- 评分(确定性,可手算复现):review 40/40 · test 20/20(1 src/1 test)· hygiene 10/15(desc✓/issue✗/size✓)· commit 15/15 · ci 5/10(unknown)= **90 → PASS**
|
||||
- CI 取不到构建记录 → `unknown`:按策略**不触发**硬门禁(仅显式 `failing` 触发),只在 CI 维记半分
|
||||
- 产物:[`../examples/demo-outputs/scorecard-pass-90.md`](../examples/demo-outputs/scorecard-pass-90.md)
|
||||
|
||||
## B. 注入真实审查发现 → REQUEST_CHANGES 55/100
|
||||
|
||||
同一 PR,注入 [`../findings.example.json`](../findings.example.json) 重跑:review_findings 40/40 → 5/40(1 major + 2 minor),总分 90 → 55,**裁决翻转为 REQUEST_CHANGES**。
|
||||
|
||||
**发现是真的,不是编的**——三条均来自对该 PR 真实 diff(head `bcc27bf`)的代码审查,标注 `shortcuts/org/org.go` 真实行号,任何人拉取该分支可逐条复核。其中 major:新增的 `parseBool` 只认字面 `"true"`,`--dry-run=1` 会被静默当 false,而该 flag 守护的是「批量移除团队全部项目」这一破坏性操作。
|
||||
|
||||
产物:[`../examples/demo-outputs/scorecard-findings-55.md`](../examples/demo-outputs/scorecard-findings-55.md)
|
||||
|
||||
## C. `--apply` 真实回写(自有 fork 演练)
|
||||
|
||||
在自有 fork 的演练 PR(故意「改源码不带测试」)上执行 `--apply`:
|
||||
|
||||
- 触发硬门禁 `require_tests_for_src_changes` → REQUEST_CHANGES 40/100
|
||||
- GitLink API 回执确认三件写操作全部落地:
|
||||
1. 评分卡评论回写到 PR(comment id `472741`)
|
||||
2. 自动创建 tracking issue(id `143217`),汇总硬门禁 + 必修项 + 建议 reviewer,与 PR 双向回链
|
||||
3. 裁决标签挂载到 PR 背后 issue(`label +list` 查 id → Raw API `POST /:owner/:repo/issues/<issue_id>`)——依赖本仓库的 `label` 命令(0.2.0 起官方发布版自带)
|
||||
|
||||
## D. 全仓批量体检:113 个 open PR
|
||||
|
||||
`gatekeeper_sweep.py` 对本仓库**全部 113 个 open PR** 逐个 dry-run(只读、零写入、零 AI 成本),113/113 成功:
|
||||
|
||||
- 裁决分布:**PASS 105 · COMMENT 6 · REQUEST_CHANGES 2**;分数 min 70 / 中位 90 / 均值 88.5 / max 95
|
||||
- 治理洞察:**96% 的 open PR 未关联 issue**;2 个 PR 触发 `require_tests_for_src_changes`(改源码不带测试)
|
||||
- 完整报告(含全量明细表):[`../examples/demo-outputs/sweep-report-2026-06-10.md`](../examples/demo-outputs/sweep-report-2026-06-10.md)
|
||||
- 诚实口径:批扫不注入审查发现(review_findings 维未评、按满分计),CI 统一 `--skip-ci`(unknown 半分)——总分代表「除人工/AI 审查外的工程卫生分」,偏乐观
|
||||
|
||||
## E. 单元测试(确定性回归护栏)
|
||||
|
||||
```bash
|
||||
$ python3 tests/test_scoring.py
|
||||
OK
|
||||
```
|
||||
|
||||
锁定四个权威裁决案例(PASS / REQUEST_CHANGES / COMMENT / 硬门禁直拒)的**总分与裁决**与 Skill 文档逐位一致;任何改动若破坏「同输入 → 同分 → 同裁决」,测试立即变红。
|
||||
|
||||
## 真实运行当场暴露过的问题(透明记录)
|
||||
|
||||
- GitLink 的 PR 标题/描述在 `pr +view` 返回的 `issue.subject/description`,而非 `pull_request` 子对象——离线 mock 测不到,真实平台运行才暴露并修复。
|
||||
- npm 0.1.18 时代 `--apply` 的打标签步骤会报 `unknown command "label"`(彼时 `label` 命令尚未发布);0.2.0 起官方发布版自带,整条闭环零本地构建跑通。
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
## 🛡️ Gatekeeper Report — PR #15222 feat(org): add team project binding shortcuts
|
||||
|
||||
**Verdict: ❌ REQUEST_CHANGES** · Score: 55/100 · policy: gatekeeper.yaml@v1
|
||||
|
||||
| Dimension | Weight | Score | Notes |
|
||||
|-----------|:------:|:-----:|-------|
|
||||
| Review findings | 40 | 5/40 | 0 blocker / 1 major / 2 minor / 0 nit |
|
||||
| Test coverage | 20 | 20/20 | 1 src / 1 test files |
|
||||
| PR hygiene | 15 | 10/15 | desc ✓ / linked issue ✗ / size ✓ |
|
||||
| Commit quality | 15 | 15/15 | 0/0 conventional |
|
||||
| CI status | 10 | 5/10 | unknown |
|
||||
|
||||
### 👥 Suggested reviewers (3)
|
||||
- @doc-maintainer — 6 file(s): README.md, README.zh-CN.md, doc/changes/org-team-projects.md …
|
||||
- @go-reviewer — 2 file(s): shortcuts/org/org.go, shortcuts/org/org_test.go
|
||||
- @qa-reviewer — 1 file(s): shortcuts/org/org_test.go
|
||||
|
||||
### 🔴 Must fix (1)
|
||||
- [major] parseBool 只把字面 "true" 当真:用户传 --dry-run=1 / t / TRUE 以外写法会被静默解析为 false。该 flag 守护的是 team-projects-remove-all(批量移除团队全部项目)这类破坏性操作——预览意图被静默降级为真实执行。建议改用 strconv.ParseBool(与 shortcuts/common/runner.go:52 解析 flag 默认值的行为一致),无法识别的值应报错而非吞掉 — shortcuts/org/org.go:156
|
||||
|
||||
### 🟡 Should fix (2)
|
||||
- [minor] team-projects-remove-all 一条命令清空团队全部项目绑定,除 --dry-run 外无确认机制;建议在 Description/help 标注危险性,或要求显式 --yes 二次确认 — shortcuts/org/org.go:103
|
||||
- [minor] dry-run 预览 payload 中 action(add_all_team_projects/remove_all_team_projects)与实际请求 path 段(create_all/destroy_all,见 :134)是两套词汇,排查问题时易误导;建议统一或在 payload 同时给出两者映射 — shortcuts/org/org.go:142
|
||||
|
||||
### Next steps
|
||||
1. 评分低于阈值,按上方 Must/Should fix 修复后重新触发 gatekeeper
|
||||
---
|
||||
*Generated by gitlink-gatekeeper · policy-as-code PR gate · re-run after changes*
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
## 🛡️ Gatekeeper Report — PR #15222 feat(org): add team project binding shortcuts
|
||||
|
||||
**Verdict: ✅ PASS** · Score: 90/100 · policy: gatekeeper.yaml@v1
|
||||
|
||||
| Dimension | Weight | Score | Notes |
|
||||
|-----------|:------:|:-----:|-------|
|
||||
| Review findings | 40 | 40/40 | 0 blocker / 0 major / 0 minor / 0 nit |
|
||||
| Test coverage | 20 | 20/20 | 1 src / 1 test files |
|
||||
| PR hygiene | 15 | 10/15 | desc ✓ / linked issue ✗ / size ✓ |
|
||||
| Commit quality | 15 | 15/15 | 0/0 conventional |
|
||||
| CI status | 10 | 5/10 | unknown |
|
||||
|
||||
### 👥 Suggested reviewers (3)
|
||||
- @doc-maintainer — 6 file(s): README.md, README.zh-CN.md, doc/changes/org-team-projects.md …
|
||||
- @go-reviewer — 2 file(s): shortcuts/org/org.go, shortcuts/org/org_test.go
|
||||
- @qa-reviewer — 1 file(s): shortcuts/org/org_test.go
|
||||
|
||||
### Next steps
|
||||
1. 满足合并门禁;如策略开启 auto_merge 且操作者带 --apply,可执行合并
|
||||
---
|
||||
*Generated by gitlink-gatekeeper · policy-as-code PR gate · re-run after changes*
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
# gatekeeper 仓库体检报告 —— Gitlink/gitlink-cli(2026-06-10)
|
||||
|
||||
> 对 **113 个 open PR** 全量 dry-run(**只读,零写入**)· 策略 `gatekeeper.yaml` · 成功 113 / 失败 0
|
||||
>
|
||||
> **诚实口径**:批扫未注入 AI 审查发现,review_findings 维按 0 发现计满分(**该维度未评**);CI 维按 `--skip-ci` 统一记 unknown(半分)。其余维度为真实采集。因此**总分代表「除人工/AI 审查外的工程卫生分」,偏乐观**;裁决分布同理。
|
||||
|
||||
## 总览
|
||||
|
||||
- 裁决分布:COMMENT **6** · PASS **105** · REQUEST_CHANGES **2**
|
||||
- 分数:min 70 / 中位 90 / 均值 88.5 / max 95
|
||||
- **0%** 的 PR 测试覆盖维 0 分(改动不带任何测试)
|
||||
- **96%** 的 PR 未关联 issue
|
||||
- **2%** 的 PR 触发 REQUEST_CHANGES(硬门禁或低分)
|
||||
|
||||
硬门禁命中:`require_tests_for_src_changes` × 2
|
||||
|
||||
## 全量明细(按分数降序)
|
||||
|
||||
| PR | 标题 | 作者 | 总分 | 裁决 | 硬门禁失败 | 卫生(描述/关联/体量) |
|
||||
|----|------|------|-----:|------|-----------|---------------------|
|
||||
| [#145](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/145) | fix(issue): preserve metadata during batch close | dtwdtw | 95 | PASS | — | ✓/✓/✓ |
|
||||
| [#218](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/218) | feat(skills): 新增 科研Fork影响力分析 的skill : gitlink-re | yangsai | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#177](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/177) | feat(wiki): add wiki management shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#217](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/217) | feat(commands): add command catalog export | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#216](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/216) | feat(api): support saved variables in batch plan | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#214](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/214) | feat(pr): add conversation comment shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#213](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/213) | feat(repo): add mirror sync shortcut | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#212](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/212) | feat(feedback): add feedback shortcut | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#211](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/211) | feat(repo): add profile view shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#210](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/210) | feat(skills): 新增维护者交接与分支治理 Skills | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#208](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/208) | feat(user): add pinned project shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#207](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/207) | feat(user): add statistics shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#206](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/206) | feat(commit): add commit inspection shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#204](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/204) | feat(org): 增强组织团队与成员管理快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#203](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/203) | feat(user): 增加用户画像分析快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#202](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/202) | feat(ignore): add ignore template shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#201](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/201) | feat(account): add account auth shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#200](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/200) | feat(pr): add review journal shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#199](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/199) | feat(code): add read-only code browsing shortcut | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#198](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/198) | feat(message): 增加消息中心快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#197](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/197) | feat(message-settings): 增加消息通知设置快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#194](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/194) | fix(pr): 补齐 pr +view 的合并与关闭时间字段 | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#193](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/193) | feat(shortcut): add shortcuts/wiki | co63oc | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#192](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/192) | feat(repo): add navigation unit shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#191](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/191) | feat(user): add profile shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#187](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/187) | feat(org): add team project bulk shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#186](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/186) | feat(ref): add branch and tag shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#185](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/185) | Add workflow pull request review queue | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#184](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/184) | Add workflow release notes generator | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#183](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/183) | feat(project): add lifecycle flow shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#182](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/182) | feat(issue): add journal maintenance shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#181](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/181) | feat(topic): add project topic shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#180](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/180) | feat(template): add project template shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#179](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/179) | feat(dataset): add research dataset shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#178](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/178) | feat(contents): add repository content shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#176](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/176) | feat(user): add dashboard shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#175](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/175) | feat(notification): add message and setting shor | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#174](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/174) | feat(public-key): add SSH key shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#173](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/173) | feat(account): add cancellation shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#172](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/172) | feat(account): add security shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#171](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/171) | feat(oauth): add token shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#170](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/170) | Add repository file search and batch commit shor | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#167](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/167) | feat(account): add email verification shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#164](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/164) | Add pull request review comment management short | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#163](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/163) | Add complete issue comment management shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#160](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/160) | Add GitLink feedback submission shortcut | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#158](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/158) | Add code trace analysis shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#153](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/153) | feat(shortcut): add shortcuts/ignore | co63oc | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#151](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/151) | feat(transfer): add transfer request shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#135](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/135) | feat(dev): add developer resource shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#118](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/118) | feat(access): add project access shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#114](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/114) | feat(mirror): add mirror repository shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#113](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/113) | feat(todo): add request approval shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#107](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/107) | feat(star): add starred project shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#83](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/83) | feat(org): add team project binding shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#82](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/82) | feat(meta): add attachment and metadata shortcut | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#78](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/78) | feat(branch): complete OpenAPI shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#76](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/76) | feat(notification): add OpenAPI shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#72](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/72) | feat(template): add project template shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#70](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/70) | feat(user): add account and stats shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#65](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/65) | feat(wiki): add OpenAPI shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#64](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/64) | feat(dataset): add OpenAPI shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#63](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/63) | feat(code): add repository code OpenAPI shortcut | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#152](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/152) | chore(doc): fix README.md | co63oc | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#137](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/137) | feat(skills): 增强 7 个 Agent Skill + 新增 2 个 Skill( | whale | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#149](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/149) | feat(skills): 新增 学者/团队科研画像生成 的skill : gitlink-sc | yangsai | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#148](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/148) | feat(skills): 新增 科研热点追踪与知识图谱构建 的skill : gitlink- | yangsai | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#144](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/144) | feat(skills): 新增 3 个 Agent Skill — wiki-builder, | whale | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#134](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/134) | 新增 shell 自动补全命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#99](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/99) | 新增 5 个仓库检查快捷命令 (languages/contributors/files/tag | jiangtx | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#86](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/86) | fix: preserve issue metadata on update | dtwdtw | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#73](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/73) | feat(user): add SSH key shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#67](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/67) | feat(repo): add repository units shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#60](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/60) | feat: add notification shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#58](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/58) | feat: add repository reaction shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#126](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/126) | feat(skills): 新增 gitlink-scaffold 社区健康文件体检 Skill | Ct201314 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#56](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/56) | feat: add git tag shortcut group | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#125](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/125) | feat(skills): 新增 gitlink-newcomer 新人引导 Skill | Ct201314 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#127](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/127) | feat(skills): 新增 gitlink-deps 依赖追踪 Skill | Ct201314 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#128](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/128) | feat(skills): 新增 gitlink-contributor 贡献者致谢与成长 Sk | Ct201314 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#129](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/129) | feat(skills): 新增 gitlink-kb 知识库问答 Skill | Ct201314 | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#115](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/115) | feat: add catalog template shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#116](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/116) | 新增仓库洞察快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#119](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/119) | 新增仓库转移快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#122](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/122) | 完善仓库 README 快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#50](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/50) | feat: add wiki shortcut group | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#54](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/54) | gitlink-growth 开源贡献者成长系统 Skill 贡献 | yingjie | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#23](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/23) | feat: support fork metadata in pr create | Mengz | 90 | PASS | — | ✓/✗/✓ |
|
||||
| [#196](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/196) | feat(release): 增加发布资产管理快捷命令 | Mengz | 88 | PASS | — | ✓/✗/✓ |
|
||||
| [#215](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/215) | fix(client): improve API robustness | wangyue111 | 87 | PASS | — | ✓/✗/✓ |
|
||||
| [#147](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/147) | feat(shortcuts): 新增 wiki/commit/file/star/watch | chroe | 86 | PASS | — | ✓/✗/✓ |
|
||||
| [#209](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/209) | feat(milestone): 增加里程碑进度分析快捷命令 | Mengz | 85 | PASS | — | ✓/✗/✓ |
|
||||
| [#205](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/205) | fix(issue): 修复详情缺失并保护更新元数据 | Mengz | 85 | PASS | — | ✓/✗/✓ |
|
||||
| [#195](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/195) | feat(compare): 新增 compare 汇总与提交筛选能力 | Mengz | 85 | PASS | — | ✓/✗/✓ |
|
||||
| [#190](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/190) | Add workflow release readiness gate | Mengz | 85 | PASS | — | ✓/✗/✓ |
|
||||
| [#189](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/189) | Add workflow duplicate issue detection | Mengz | 85 | PASS | — | ✓/✗/✓ |
|
||||
| [#188](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/188) | Add workflow dependency risk audit | Mengz | 85 | PASS | — | ✓/✗/✓ |
|
||||
| [#165](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/165) | feat(issue): add batch maintenance shortcuts | wangyue111 | 85 | PASS | — | ✓/✗/✓ |
|
||||
| [#159](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/159) | Add member application workflow shortcuts | Mengz | 85 | PASS | — | ✓/✗/✓ |
|
||||
| [#77](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/77) | feat(journal): add issue and PR comment shortcut | wangyue111 | 85 | PASS | — | ✓/✗/✓ |
|
||||
| [#150](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/150) | 新增 Issue 批量导出命令 | Mengz | 85 | PASS | — | ✓/✗/✓ |
|
||||
| [#142](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/142) | 新增 PR 本地检出命令 | Mengz | 85 | PASS | — | ✓/✗/✓ |
|
||||
| [#100](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/100) | 查看指定时间范围的开发统计 | jiangtx | 85 | PASS | — | ✗/✗/✓ |
|
||||
| [#101](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/101) | 查看用户项目动态 | jiangtx | 85 | PASS | — | ✗/✗/✓ |
|
||||
| [#21](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/21) | feat: add attachment shortcut group | Mengz | 85 | PASS | — | ✓/✗/✓ |
|
||||
| [#139](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/139) | feat(wiki): 新增 Wiki 页面与目录管理 Shortcuts | whale | 82 | COMMENT | — | ✓/✗/✓ |
|
||||
| [#130](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/130) | feat(workflows): 新增 gitlink-flow 社区运营自动化端到端工作流 | Ct201314 | 82 | COMMENT | — | ✓/✗/✓ |
|
||||
| [#97](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/97) | 基础设施修复 | jiangtx | 81 | COMMENT | — | ✗/✗/✓ |
|
||||
| [#123](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/123) | 新增 Release 资产下载命令 | Mengz | 80 | COMMENT | — | ✗/✗/✓ |
|
||||
| [#103](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/103) | feat: 新建 pm 模块,添加 6 条项目管理命令 | wyxttn | 78 | COMMENT | — | — |
|
||||
| [#131](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/131) | 子赛题三 - Java-Gatekeeper 端到端自动化质量门禁工作流 | xxxx12 | 75 | REQUEST_CHANGES | require_tests_for_src_changes | ✓/✓/✓ |
|
||||
| [#30](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/30) | 增加wiki管理的shortcut | camelliamc | 74 | COMMENT | — | — |
|
||||
| [#146](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/146) | feat: 新增 Showcase Dashboard 交互式展示页 | chroe | 70 | REQUEST_CHANGES | require_tests_for_src_changes | ✓/✗/✓ |
|
||||
|
||||
## 这份报告说明了什么
|
||||
|
||||
- 同一份 `gatekeeper.yaml` 策略可以**无人值守地体检一个真实活跃仓库的全部积压**——确定性评分意味着大规模治理零 AI 成本,AI 只在需要语义判断(review_findings)时按需介入。
|
||||
- 任何人重跑本报告(`python3 scripts/gatekeeper_sweep.py`)会对同一组 PR 得到同样的分数与裁决。
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"_comment": "对 Gitlink/gitlink-cli PR #15222(feat/org-team-projects,head bcc27bf)真实 diff 的人工+AI 代码审查发现。每条均可在该 PR 的 shortcuts/org/org.go 对应行号复核——非样例数据。",
|
||||
"findings": [
|
||||
{
|
||||
"severity": "major",
|
||||
"file": "shortcuts/org/org.go",
|
||||
"line": 156,
|
||||
"message": "parseBool 只把字面 \"true\" 当真:用户传 --dry-run=1 / t / TRUE 以外写法会被静默解析为 false。该 flag 守护的是 team-projects-remove-all(批量移除团队全部项目)这类破坏性操作——预览意图被静默降级为真实执行。建议改用 strconv.ParseBool(与 shortcuts/common/runner.go:52 解析 flag 默认值的行为一致),无法识别的值应报错而非吞掉"
|
||||
},
|
||||
{
|
||||
"severity": "minor",
|
||||
"file": "shortcuts/org/org.go",
|
||||
"line": 103,
|
||||
"message": "team-projects-remove-all 一条命令清空团队全部项目绑定,除 --dry-run 外无确认机制;建议在 Description/help 标注危险性,或要求显式 --yes 二次确认"
|
||||
},
|
||||
{
|
||||
"severity": "minor",
|
||||
"file": "shortcuts/org/org.go",
|
||||
"line": 142,
|
||||
"message": "dry-run 预览 payload 中 action(add_all_team_projects/remove_all_team_projects)与实际请求 path 段(create_all/destroy_all,见 :134)是两套词汇,排查问题时易误导;建议统一或在 payload 同时给出两者映射"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# owner-rules.yaml — 文件路径 → reviewer 路由表(gitlink-gatekeeper 工作流步骤 1)
|
||||
#
|
||||
# 作用:PR 看门人闭环的第一步「路由」。脚本拉取 PR 变更文件后,按下面的
|
||||
# glob 规则把每个文件映射到建议 reviewer,写进评分卡的「Suggested
|
||||
# reviewers」分区(REQUEST_CHANGES 时也写进 tracking issue)。
|
||||
#
|
||||
# 语义(见 scripts/gatekeeper_workflow.py route_reviewers):
|
||||
# - rules 按顺序匹配,**首个命中的规则生效**(顺序即优先级,把更具体的放前面)。
|
||||
# - glob 用 Python fnmatch 语法(* 不跨目录段时也会匹配 /,与 fnmatch 行为一致)。
|
||||
# - 一个文件命中后不再继续匹配后续规则;多个 reviewer 写在同一规则的 reviewers 列表里。
|
||||
# - 未命中任何规则的文件归到 default_reviewers(兜底)。
|
||||
#
|
||||
# 排序示例(首个命中即生效、顺序即优先级,请按需排序):
|
||||
# 下面把目录 glob `skills/**` 放在语言 glob `**/*.go` 之前,于是 `skills/foo.go`
|
||||
# 会先命中 `skills/**` → 路由给 skill-owner,而不会落到 go-reviewer。若你希望
|
||||
# skills 下的 Go 文件仍由 go-reviewer 审,就把语言规则提到目录规则之前
|
||||
# (或在目录规则里收窄 glob,如 `skills/**/*.md`)。
|
||||
#
|
||||
# 注意:本表只产出「建议」,不调用任何写操作;真正分配 reviewer 由维护者在
|
||||
# PR 页面决定。gatekeeper 不替人点提交(SSOT 第 8 节安全规则)。
|
||||
#
|
||||
# reviewers 填 GitLink 用户名(login)。下方为占位示例,真实使用时替换为本仓库的维护者。
|
||||
|
||||
rules:
|
||||
# —— 文档:只改文档走文档维护者,避免占用代码 reviewer ——
|
||||
- glob: "docs/**"
|
||||
reviewers: ["doc-maintainer"]
|
||||
- glob: "**/*.md"
|
||||
reviewers: ["doc-maintainer"]
|
||||
|
||||
# —— 工作流 / 脚本本体 ——
|
||||
- glob: "workflow/**"
|
||||
reviewers: ["workflow-owner"]
|
||||
- glob: "skills/**"
|
||||
reviewers: ["skill-owner"]
|
||||
|
||||
# —— 按语言路由到对应方向的 reviewer ——
|
||||
- glob: "**/*_test.go"
|
||||
reviewers: ["go-reviewer", "qa-reviewer"]
|
||||
- glob: "**/*.go"
|
||||
reviewers: ["go-reviewer"]
|
||||
- glob: "test_*.py"
|
||||
reviewers: ["py-reviewer", "qa-reviewer"]
|
||||
- glob: "**/*.py"
|
||||
reviewers: ["py-reviewer"]
|
||||
- glob: "**/*.ts"
|
||||
reviewers: ["fe-reviewer"]
|
||||
- glob: "**/*.js"
|
||||
reviewers: ["fe-reviewer"]
|
||||
|
||||
# —— 高敏感区:CI / 依赖 / 安全配置,强制资深 reviewer ——
|
||||
- glob: ".gitea/**"
|
||||
reviewers: ["ci-owner", "security-reviewer"]
|
||||
- glob: "**/Dockerfile"
|
||||
reviewers: ["ci-owner"]
|
||||
- glob: "go.mod"
|
||||
reviewers: ["security-reviewer"]
|
||||
- glob: "go.sum"
|
||||
reviewers: ["security-reviewer"]
|
||||
|
||||
# 未命中上面任何规则的文件,兜底分配给这些人
|
||||
default_reviewers: ["maintainer"]
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
# SPDX-License-Identifier: MulanPSL-2.0
|
||||
"""gatekeeper_sweep —— 对一个仓库的全部 open PR 批量跑门禁(只读 dry-run),出治理报告。
|
||||
|
||||
把单 PR 的「策略 → 评分卡 → 裁决」升级为仓库级体检:
|
||||
1. 翻页拉取 PR 列表,筛出 open;
|
||||
2. 逐个调用 gatekeeper_workflow.py(强制 dry-run,绝不 --apply,对远端零写入);
|
||||
3. 汇总每个 PR 的 summary.json → 聚合统计 + 全量明细表 → sweep-report.md / sweep-summary.json。
|
||||
|
||||
诚实口径:批扫不注入 AI 审查发现(--findings),review_findings 维按 0 发现计满分,
|
||||
报告中明确标注「该维度未评」;其余 4 维(测试/卫生/commit/CI)为真实采集结果。
|
||||
纯标准库,无第三方依赖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
API_BASE = "https://www.gitlink.org.cn/api"
|
||||
HYGIENE_RE = re.compile(r"desc (✓|✗) / linked issue (✓|✗) / size (✓|✗)")
|
||||
|
||||
|
||||
def fetch_open_prs(owner: str, repo: str, limit_pages: int = 20) -> list[dict[str, Any]]:
|
||||
"""翻页拉取 PR 列表并筛出 open(列表接口的 status 参数不可靠,按字段过滤)。"""
|
||||
items: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
while page <= limit_pages:
|
||||
url = f"{API_BASE}/{owner}/{repo}/pulls.json?page={page}&limit=50"
|
||||
with urllib.request.urlopen(url, timeout=30) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
batch = data.get("issues") or []
|
||||
if not batch:
|
||||
break
|
||||
items.extend(batch)
|
||||
if len(items) >= int(data.get("search_count") or 0):
|
||||
break
|
||||
page += 1
|
||||
return [it for it in items if it.get("pull_request_staus") == "open"]
|
||||
|
||||
|
||||
def run_one(
|
||||
workflow_script: Path,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
policy: Path,
|
||||
owner_rules: Path,
|
||||
cli_bin: str,
|
||||
out_dir: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""对单个 PR 跑一次 dry-run 门禁,返回解析后的行记录(失败不抛,记 error)。"""
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(workflow_script),
|
||||
"--owner", owner,
|
||||
"--repo", repo,
|
||||
"--pr", str(number),
|
||||
"--policy", str(policy),
|
||||
"--owner-rules", str(owner_rules),
|
||||
"--cli-bin", cli_bin,
|
||||
"--skip-ci",
|
||||
"--output-dir", str(out_dir),
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||
slug = f"{owner}_{repo}_pr{number}".replace("/", "_")
|
||||
summary_path = out_dir / f"{slug}_summary.json"
|
||||
if proc.returncode == 1 or not summary_path.exists():
|
||||
return {"number": number, "error": (proc.stderr or proc.stdout)[-200:].strip()}
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
hygiene = ""
|
||||
scorecard_path = out_dir / f"{slug}_scorecard.md"
|
||||
if scorecard_path.exists():
|
||||
m = HYGIENE_RE.search(scorecard_path.read_text(encoding="utf-8"))
|
||||
if m:
|
||||
hygiene = "/".join(m.groups()) # 例如 "✓/✗/✓":描述/关联issue/体量
|
||||
return {
|
||||
"number": number,
|
||||
"verdict": summary.get("verdict"),
|
||||
"total": summary.get("total"),
|
||||
"scores": summary.get("scores", {}),
|
||||
"hard_gate_failures": [f.get("gate") if isinstance(f, dict) else f
|
||||
for f in summary.get("hard_gate_failures", [])],
|
||||
"hygiene": hygiene,
|
||||
"suggested_reviewers": summary.get("routing", {}).get("suggested_reviewers", []),
|
||||
}
|
||||
|
||||
|
||||
def aggregate(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
ok = [r for r in rows if "error" not in r]
|
||||
totals = sorted(r["total"] for r in ok)
|
||||
verdicts: dict[str, int] = {}
|
||||
gate_hits: dict[str, int] = {}
|
||||
for r in ok:
|
||||
verdicts[r["verdict"]] = verdicts.get(r["verdict"], 0) + 1
|
||||
for g in r["hard_gate_failures"]:
|
||||
gate_hits[str(g)] = gate_hits.get(str(g), 0) + 1
|
||||
def pct(n: int) -> str:
|
||||
return f"{100 * n / len(ok):.0f}%" if ok else "0%"
|
||||
no_linked = sum(1 for r in ok if r["hygiene"] and r["hygiene"].split("/")[1] == "✗")
|
||||
zero_cov = sum(1 for r in ok if r["scores"].get("test_coverage") == 0)
|
||||
return {
|
||||
"scanned": len(rows),
|
||||
"succeeded": len(ok),
|
||||
"failed": len(rows) - len(ok),
|
||||
"verdicts": verdicts,
|
||||
"score_min": totals[0] if totals else None,
|
||||
"score_median": totals[len(totals) // 2] if totals else None,
|
||||
"score_avg": round(sum(totals) / len(totals), 1) if totals else None,
|
||||
"score_max": totals[-1] if totals else None,
|
||||
"hard_gate_hits": gate_hits,
|
||||
"pct_zero_test_coverage": pct(zero_cov),
|
||||
"pct_no_linked_issue": pct(no_linked),
|
||||
"pct_request_changes": pct(verdicts.get("REQUEST_CHANGES", 0)),
|
||||
}
|
||||
|
||||
|
||||
def render_report(
|
||||
owner: str, repo: str, policy_label: str, date_label: str,
|
||||
rows: list[dict[str, Any]], agg: dict[str, Any],
|
||||
pr_meta: dict[int, dict[str, Any]],
|
||||
) -> str:
|
||||
ok = [r for r in rows if "error" not in r]
|
||||
lines = [
|
||||
f"# gatekeeper 仓库体检报告 —— {owner}/{repo}({date_label})",
|
||||
"",
|
||||
f"> 对 **{agg['scanned']} 个 open PR** 全量 dry-run(**只读,零写入**)· 策略 `{policy_label}` · "
|
||||
f"成功 {agg['succeeded']} / 失败 {agg['failed']}",
|
||||
">",
|
||||
"> **诚实口径**:批扫未注入 AI 审查发现,review_findings 维按 0 发现计满分(**该维度未评**);"
|
||||
"CI 维按 `--skip-ci` 统一记 unknown(半分)。其余维度为真实采集。"
|
||||
"因此**总分代表「除人工/AI 审查外的工程卫生分」,偏乐观**;裁决分布同理。",
|
||||
"",
|
||||
"## 总览",
|
||||
"",
|
||||
f"- 裁决分布:{' · '.join(f'{k} **{v}**' for k, v in sorted(agg['verdicts'].items()))}",
|
||||
f"- 分数:min {agg['score_min']} / 中位 {agg['score_median']} / 均值 {agg['score_avg']} / max {agg['score_max']}",
|
||||
f"- **{agg['pct_zero_test_coverage']}** 的 PR 测试覆盖维 0 分(改动不带任何测试)",
|
||||
f"- **{agg['pct_no_linked_issue']}** 的 PR 未关联 issue",
|
||||
f"- **{agg['pct_request_changes']}** 的 PR 触发 REQUEST_CHANGES(硬门禁或低分)",
|
||||
"",
|
||||
"硬门禁命中:" + (";".join(f"`{g}` × {n}" for g, n in sorted(agg["hard_gate_hits"].items(), key=lambda x: -x[1])) or "无"),
|
||||
"",
|
||||
"## 全量明细(按分数降序)",
|
||||
"",
|
||||
"| PR | 标题 | 作者 | 总分 | 裁决 | 硬门禁失败 | 卫生(描述/关联/体量) |",
|
||||
"|----|------|------|-----:|------|-----------|---------------------|",
|
||||
]
|
||||
for r in sorted(ok, key=lambda x: -x["total"]):
|
||||
meta = pr_meta.get(r["number"], {})
|
||||
title = str(meta.get("name", ""))[:48].replace("|", "\\|")
|
||||
gates = ", ".join(str(g) for g in r["hard_gate_failures"]) or "—"
|
||||
lines.append(
|
||||
f"| [#{r['number']}](https://www.gitlink.org.cn/{owner}/{repo}/pulls/{r['number']}) "
|
||||
f"| {title} | {meta.get('author_name', '?')} | {r['total']} | {r['verdict']} | {gates} | {r['hygiene'] or '—'} |"
|
||||
)
|
||||
errs = [r for r in rows if "error" in r]
|
||||
if errs:
|
||||
lines += ["", "## 跑失败的 PR", ""]
|
||||
lines += [f"- #{r['number']}:`{r['error']}`" for r in errs]
|
||||
lines += [
|
||||
"",
|
||||
"## 这份报告说明了什么",
|
||||
"",
|
||||
"- 同一份 `gatekeeper.yaml` 策略可以**无人值守地体检一个真实活跃仓库的全部积压**——"
|
||||
"确定性评分意味着大规模治理零 AI 成本,AI 只在需要语义判断(review_findings)时按需介入。",
|
||||
"- 任何人重跑本报告(`python3 scripts/gatekeeper_sweep.py`)会对同一组 PR 得到同样的分数与裁决。",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="对全部 open PR 批量 dry-run 出治理报告")
|
||||
parser.add_argument("--owner", default="Gitlink")
|
||||
parser.add_argument("--repo", default="gitlink-cli")
|
||||
parser.add_argument("--policy", type=Path, required=True)
|
||||
parser.add_argument("--owner-rules", dest="owner_rules", type=Path, required=True)
|
||||
parser.add_argument("--cli-bin", default="gitlink-cli")
|
||||
parser.add_argument("--output-dir", type=Path, default=Path("sweep-outputs"))
|
||||
parser.add_argument("--date-label", default="sweep", help="报告日期标签(可复现:不取系统时间)")
|
||||
parser.add_argument("--max", type=int, default=0, help="只跑前 N 个(0=全量),用于试跑")
|
||||
parser.add_argument("--sleep", type=float, default=0.2, help="相邻 PR 间隔秒数(对平台礼貌)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
workflow_script = Path(__file__).with_name("gatekeeper_workflow.py")
|
||||
runs_dir = args.output_dir / "runs"
|
||||
runs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
prs = fetch_open_prs(args.owner, args.repo)
|
||||
if args.max:
|
||||
prs = prs[: args.max]
|
||||
pr_meta = {int(p["pull_request_number"]): p for p in prs}
|
||||
print(f"open PR 共 {len(prs)} 个,开始批扫(dry-run,零写入)…", flush=True)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for i, p in enumerate(prs, 1):
|
||||
number = int(p["pull_request_number"])
|
||||
row = run_one(workflow_script, args.owner, args.repo, number,
|
||||
args.policy, args.owner_rules, args.cli_bin, runs_dir)
|
||||
rows.append(row)
|
||||
tag = row.get("verdict", "ERROR")
|
||||
print(f"[{i}/{len(prs)}] PR #{number} → {tag} {row.get('total', '')}", flush=True)
|
||||
time.sleep(args.sleep)
|
||||
|
||||
agg = aggregate(rows)
|
||||
policy_label = args.policy.name
|
||||
report = render_report(args.owner, args.repo, policy_label, args.date_label, rows, agg, pr_meta)
|
||||
(args.output_dir / "sweep-report.md").write_text(report, encoding="utf-8")
|
||||
(args.output_dir / "sweep-summary.json").write_text(
|
||||
json.dumps({"aggregate": agg, "rows": rows}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"\n报告:{args.output_dir / 'sweep-report.md'}")
|
||||
print(f"汇总:{args.output_dir / 'sweep-summary.json'}")
|
||||
print(f"裁决分布:{agg['verdicts']} · 均分 {agg['score_avg']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,248 @@
|
|||
#!/usr/bin/env python3
|
||||
"""可复现评分单测 —— 把「同输入 → 同分 → 同裁决」从口号变成可验证事实。
|
||||
|
||||
纯标准库 unittest(Python 3.9 兼容)。直接 import `scripts/gatekeeper_workflow.py`
|
||||
的确定性算法(score_dimensions / evaluate_hard_gates / decide_verdict),对四个
|
||||
权威裁决案例(skills/gitlink-gatekeeper/examples/decision-*.md 与
|
||||
scorecard-sample.md)构造等价的 ScoreInput,断言**总分**与**三态裁决**与文档逐位一致。
|
||||
|
||||
任意一处算法改动若改变了这四个案例的分值,本测试立即变红——即为「确定性」的回归护栏。
|
||||
|
||||
运行:
|
||||
python3 workflow/tests/test_scoring.py
|
||||
或:
|
||||
python3 -m unittest workflow.tests.test_scoring # 在仓库根目录
|
||||
|
||||
数值来源(默认策略 gatekeeper.yaml,与脚本内置 DEFAULT_POLICY 一致):
|
||||
权重 40/20/15/15/10;severity_penalty blocker=100/major=25/minor=5/nit=1;
|
||||
thresholds pass=85 / request_changes=60;max_changed_files=80。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 以绝对路径加载被测脚本(它在 scripts/ 下、非包,按文件直接载入最稳)
|
||||
#
|
||||
# 注意:必须先把模块塞进 sys.modules 再 exec —— 被测脚本用了
|
||||
# `from __future__ import annotations`,Python 3.9 的 @dataclass 在解析字符串
|
||||
# 注解时会回查 sys.modules[cls.__module__],未注册会取到 None 而报
|
||||
# AttributeError('NoneType' object has no attribute '__dict__')。
|
||||
# --------------------------------------------------------------------------- #
|
||||
_SCRIPT = (
|
||||
Path(__file__).resolve().parent.parent / "scripts" / "gatekeeper_workflow.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("gatekeeper_workflow", _SCRIPT)
|
||||
assert _spec and _spec.loader, f"无法定位被测脚本:{_SCRIPT}"
|
||||
gw = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["gatekeeper_workflow"] = gw
|
||||
_spec.loader.exec_module(gw) # type: ignore[union-attr]
|
||||
|
||||
ScoreInput = gw.ScoreInput
|
||||
Finding = gw.Finding
|
||||
score_dimensions = gw.score_dimensions
|
||||
evaluate_hard_gates = gw.evaluate_hard_gates
|
||||
decide_verdict = gw.decide_verdict
|
||||
# 默认策略(深拷贝一份,避免任何用例意外改到共享 dict)
|
||||
import json as _json # noqa: E402
|
||||
|
||||
DEFAULT_POLICY = _json.loads(_json.dumps(gw.DEFAULT_POLICY))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 构造辅助:把「严重度计数 / 文件数 / commit 计数」翻译成 ScoreInput 字段
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _findings(blocker: int = 0, major: int = 0, minor: int = 0, nit: int = 0):
|
||||
"""按严重度计数生成 Finding 列表(message/file/line 对评分无影响,仅 severity 计 penalty)。"""
|
||||
out = []
|
||||
for sev, n in (("blocker", blocker), ("major", major), ("minor", minor), ("nit", nit)):
|
||||
for i in range(n):
|
||||
out.append(Finding(severity=sev, message=f"{sev} #{i}", file="f.go", line=i + 1))
|
||||
return out
|
||||
|
||||
|
||||
def _commits(conforming: int, total: int):
|
||||
"""生成 total 条 commit message,其中 conforming 条符合 Conventional Commits。"""
|
||||
assert conforming <= total
|
||||
msgs = [f"feat(mod{i}): conforming change {i}" for i in range(conforming)]
|
||||
msgs += [f"wip update {i}" for i in range(total - conforming)] # 'wip ...' 不匹配规约
|
||||
return msgs
|
||||
|
||||
|
||||
def _files(n: int):
|
||||
"""生成 n 个占位变更文件路径(仅用于 size 维度计 len,src/test 计数由字段直接给定)。"""
|
||||
return [f"path/file_{i}.go" for i in range(n)]
|
||||
|
||||
|
||||
def _build(
|
||||
*,
|
||||
pr_id: str,
|
||||
title: str,
|
||||
desc_len: int,
|
||||
linked_issue: bool,
|
||||
n_files: int,
|
||||
src: int,
|
||||
tests: int,
|
||||
commits: tuple, # (conforming, total)
|
||||
ci: str,
|
||||
findings_counts: dict,
|
||||
) -> ScoreInput:
|
||||
description = "x" * desc_len if desc_len else ""
|
||||
return ScoreInput(
|
||||
pr_id=pr_id,
|
||||
title=title,
|
||||
description=description,
|
||||
changed_files=_files(n_files),
|
||||
changed_src=src,
|
||||
changed_tests=tests,
|
||||
commits=_commits(*commits),
|
||||
ci_status=ci,
|
||||
linked_issue=linked_issue,
|
||||
findings=_findings(**findings_counts),
|
||||
)
|
||||
|
||||
|
||||
def _run(inp: ScoreInput):
|
||||
"""跑完整确定性链路,返回 (total, verdict)。"""
|
||||
dims = score_dimensions(inp, DEFAULT_POLICY)
|
||||
failures = evaluate_hard_gates(inp, DEFAULT_POLICY)
|
||||
verdict = decide_verdict(dims["total"], bool(failures), DEFAULT_POLICY)
|
||||
return dims, failures, verdict
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 四个权威案例
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestAuthoritativeCases(unittest.TestCase):
|
||||
"""对照 examples/ 下四个裁决记录,断言总分与裁决。"""
|
||||
|
||||
def test_decision_pass(self):
|
||||
# decision-pass.md:3 src / 2 test、desc 142(含#198)、4/4 commit、CI passing、
|
||||
# 0/0/1/2 findings → 33+17+15+15+10 = 90 → PASS
|
||||
inp = _build(
|
||||
pr_id="214",
|
||||
title="feat(search): validate pagination params",
|
||||
desc_len=142,
|
||||
linked_issue=True,
|
||||
n_files=5,
|
||||
src=3,
|
||||
tests=2,
|
||||
commits=(4, 4),
|
||||
ci="passing",
|
||||
findings_counts={"minor": 1, "nit": 2},
|
||||
)
|
||||
dims, failures, verdict = _run(inp)
|
||||
self.assertEqual(dims["review_findings"]["score"], 33)
|
||||
self.assertEqual(dims["test_coverage"]["score"], 17)
|
||||
self.assertEqual(dims["pr_hygiene"]["score"], 15)
|
||||
self.assertEqual(dims["commit_quality"]["score"], 15)
|
||||
self.assertEqual(dims["ci_status"]["score"], 10)
|
||||
self.assertEqual(failures, [])
|
||||
self.assertEqual(dims["total"], 90)
|
||||
self.assertEqual(verdict, "PASS")
|
||||
|
||||
def test_decision_request_changes(self):
|
||||
# decision-request-changes.md:4 src / 0 test(触发硬门禁
|
||||
# require_tests_for_src_changes)、desc 88 无关联、2/3 commit、CI passing、
|
||||
# 0/1/1/2 findings → 8+0+10+10+10 = 38 → REQUEST_CHANGES
|
||||
inp = _build(
|
||||
pr_id="305",
|
||||
title="refactor(billing): rework settlement pipeline",
|
||||
desc_len=88,
|
||||
linked_issue=False,
|
||||
n_files=4,
|
||||
src=4,
|
||||
tests=0,
|
||||
commits=(2, 3),
|
||||
ci="passing",
|
||||
findings_counts={"major": 1, "minor": 1, "nit": 2},
|
||||
)
|
||||
dims, failures, verdict = _run(inp)
|
||||
self.assertEqual(dims["review_findings"]["score"], 8)
|
||||
self.assertEqual(dims["test_coverage"]["score"], 0)
|
||||
self.assertEqual(dims["pr_hygiene"]["score"], 10)
|
||||
self.assertEqual(dims["commit_quality"]["score"], 10)
|
||||
self.assertEqual(dims["ci_status"]["score"], 10)
|
||||
gate_names = {f["gate"] for f in failures}
|
||||
self.assertIn("require_tests_for_src_changes", gate_names)
|
||||
self.assertEqual(dims["total"], 38)
|
||||
self.assertEqual(verdict, "REQUEST_CHANGES")
|
||||
|
||||
def test_decision_comment(self):
|
||||
# decision-comment.md:2 src / 1 test、desc 52 无关联、2/3 commit、CI passing、
|
||||
# 0/0/3/2 findings → 23+15+10+10+10 = 68 ∈ [60,85) 且无硬门禁 → COMMENT
|
||||
inp = _build(
|
||||
pr_id="277",
|
||||
title="feat(config): merge defaults on load",
|
||||
desc_len=52,
|
||||
linked_issue=False,
|
||||
n_files=2,
|
||||
src=2,
|
||||
tests=1,
|
||||
commits=(2, 3),
|
||||
ci="passing",
|
||||
findings_counts={"minor": 3, "nit": 2},
|
||||
)
|
||||
dims, failures, verdict = _run(inp)
|
||||
self.assertEqual(dims["review_findings"]["score"], 23)
|
||||
self.assertEqual(dims["test_coverage"]["score"], 15)
|
||||
self.assertEqual(dims["pr_hygiene"]["score"], 10)
|
||||
self.assertEqual(dims["commit_quality"]["score"], 10)
|
||||
self.assertEqual(dims["ci_status"]["score"], 10)
|
||||
self.assertEqual(failures, [])
|
||||
self.assertEqual(dims["total"], 68)
|
||||
self.assertEqual(verdict, "COMMENT")
|
||||
|
||||
def test_scorecard_sample(self):
|
||||
# scorecard-sample.md:4 src / 0 test(触发硬门禁)、desc 64 无关联、3/4 commit、
|
||||
# CI passing、0/1/2/1 findings → 4+0+10+11+10 = 35 → REQUEST_CHANGES
|
||||
inp = _build(
|
||||
pr_id="128",
|
||||
title="feat(auth): add refresh-token rotation",
|
||||
desc_len=64,
|
||||
linked_issue=False,
|
||||
n_files=6,
|
||||
src=4,
|
||||
tests=0,
|
||||
commits=(3, 4),
|
||||
ci="passing",
|
||||
findings_counts={"major": 1, "minor": 2, "nit": 1},
|
||||
)
|
||||
dims, failures, verdict = _run(inp)
|
||||
self.assertEqual(dims["review_findings"]["score"], 4)
|
||||
self.assertEqual(dims["test_coverage"]["score"], 0)
|
||||
self.assertEqual(dims["pr_hygiene"]["score"], 10)
|
||||
self.assertEqual(dims["commit_quality"]["score"], 11)
|
||||
self.assertEqual(dims["ci_status"]["score"], 10)
|
||||
gate_names = {f["gate"] for f in failures}
|
||||
self.assertIn("require_tests_for_src_changes", gate_names)
|
||||
self.assertEqual(dims["total"], 35)
|
||||
self.assertEqual(verdict, "REQUEST_CHANGES")
|
||||
|
||||
|
||||
class TestVerdictBoundaries(unittest.TestCase):
|
||||
"""裁决判定树(decide_verdict)边界:与 thresholds pass=85 / request_changes=60 一致。"""
|
||||
|
||||
def test_pass_threshold_inclusive(self):
|
||||
self.assertEqual(decide_verdict(85, False, DEFAULT_POLICY), "PASS")
|
||||
|
||||
def test_comment_band(self):
|
||||
self.assertEqual(decide_verdict(60, False, DEFAULT_POLICY), "COMMENT")
|
||||
self.assertEqual(decide_verdict(84, False, DEFAULT_POLICY), "COMMENT")
|
||||
|
||||
def test_request_changes_below_band(self):
|
||||
self.assertEqual(decide_verdict(59, False, DEFAULT_POLICY), "REQUEST_CHANGES")
|
||||
|
||||
def test_hard_gate_short_circuits_high_score(self):
|
||||
# 即便满分,硬门禁失败也直接 REQUEST_CHANGES
|
||||
self.assertEqual(decide_verdict(100, True, DEFAULT_POLICY), "REQUEST_CHANGES")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
|
@ -0,0 +1 @@
|
|||
outputs/
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
# GitLink 项目一键初始化与协作启动工作流
|
||||
|
||||
面向 GitLink 竞赛子赛题三的端到端自动化工作流示例。
|
||||
|
||||
本项目聚焦开源项目从 0 到可协作状态的启动过程,使用 `gitlink-cli` 串联仓库检查、分支规划、初始 Issue 创建和结果回写等能力,自动生成 README、LICENSE、CI 配置、协作文档、初始化报告和结构化清单。该流程覆盖“项目配置 -> 初始化文件生成 -> GitLink 命令编排 -> 任务落地 -> 报告归档”的完整闭环。
|
||||
|
||||
## 交付物
|
||||
|
||||
- `scripts/bootstrap_project.go`:主工作流入口
|
||||
- `scripts/run_demo.ps1`:一键复现脚本
|
||||
- `examples/sample_project.json`:示例项目配置
|
||||
- `examples/verification_comment_config.json`:真实回写验证配置
|
||||
- `examples/demo_outputs/`:固定示例输出
|
||||
- `docs/workflow-spec.md`:工作流说明文档
|
||||
- `docs/architecture.md`:架构与流程说明
|
||||
- `docs/assets/bootstrap-architecture.svg`:架构图
|
||||
- `docs/quickstart.md`:最短复现路径
|
||||
- `docs/runbook.md`:运行手册
|
||||
- `docs/verification.md`:验证记录
|
||||
- `docs/submission-checklist.md`:赛题要求映射
|
||||
- `scripts/bootstrap_project_test.go`:Go 单元测试
|
||||
|
||||
## 实现语言
|
||||
|
||||
本工作流主实现采用 Go,主要考虑如下:
|
||||
|
||||
- 与 `gitlink-cli` 主仓库技术栈一致,便于维护者阅读、测试和后续集成。
|
||||
- 可直接复用 Go 标准库完成 JSON 配置解析、文件生成、命令编排和单元测试,不引入额外运行时依赖。
|
||||
- Windows、Linux 和 macOS 均可通过 `go run` 复现,便于评审在不同环境中执行。
|
||||
- 对命令执行结果、退出码和结构化日志的处理更接近 `gitlink-cli` 自身工程风格。
|
||||
|
||||
## 运行方式
|
||||
|
||||
进入本目录后执行 dry-run:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
执行后会生成:
|
||||
|
||||
- `outputs/*_bootstrap_report.md`
|
||||
- `outputs/*_summary.md`
|
||||
- `outputs/*_manifest.json`
|
||||
- `outputs/*_files.json`
|
||||
- `outputs/command_log_*.json`
|
||||
|
||||
输出文件名包含目标仓库和生成时间,格式如下:
|
||||
|
||||
- `{owner}_{repo}_{YYYYMMDD_HHMMSS}_bootstrap_report.md`
|
||||
- `{owner}_{repo}_{YYYYMMDD_HHMMSS}_summary.md`
|
||||
- `{owner}_{repo}_{YYYYMMDD_HHMMSS}_manifest.json`
|
||||
- `{owner}_{repo}_{YYYYMMDD_HHMMSS}_files.json`
|
||||
- `command_log_{YYYYMMDD_HHMMSS}.json`
|
||||
|
||||
例如 `puygob236_gitlink-bootstrap-demo_20260524_080000_bootstrap_report.md`。实际运行时会按当前时间生成新文件名,`examples/demo_outputs/` 中的固定时间戳文件仅作为示例产物。
|
||||
|
||||
如需执行真实 GitLink 写操作,在完成 GitLink 认证并核对目标仓库后使用:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Apply
|
||||
```
|
||||
|
||||
如需连同仓库创建一起执行:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Apply -CreateRepo
|
||||
```
|
||||
|
||||
如需把初始化摘要发布到指定 Issue:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1
|
||||
```
|
||||
|
||||
## 工作流串联的 gitlink-cli 调用
|
||||
|
||||
默认配置会规划 7 个 `gitlink-cli` 调用:
|
||||
|
||||
1. `repo +info`
|
||||
2. `branch +list`
|
||||
3. `branch +create`
|
||||
4. `branch +create`
|
||||
5. `issue +create`
|
||||
6. `issue +create`
|
||||
7. `issue +create`
|
||||
|
||||
当指定 `-PublishIssueNumber` 时,会额外追加 `issue +comment`,用于把初始化摘要回写到 GitLink Issue。
|
||||
当指定 `-CreateRepo` 时,会在检查仓库前追加 `repo +create`。
|
||||
|
||||
## 文档索引
|
||||
|
||||
- 工作流说明:`docs/workflow-spec.md`
|
||||
- 架构说明与架构图:`docs/architecture.md`
|
||||
- 复现指南:`docs/quickstart.md`
|
||||
- 运行手册:`docs/runbook.md`
|
||||
- 验证记录:`docs/verification.md`
|
||||
- 提交核对清单:`docs/submission-checklist.md`
|
||||
|
||||
## 场景价值
|
||||
|
||||
- 降低新开源项目启动成本,避免 README、License、CI、初始任务缺失。
|
||||
- 将项目初始化过程结构化,便于团队复用和审计。
|
||||
- 将 `gitlink-cli` 的仓库、分支、Issue 和评论能力串联为可复现方案。
|
||||
- 支持 dry-run 和 apply 两种模式,兼顾演示稳定性和真实落地。
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# 架构说明
|
||||
|
||||
本工作流采用“配置输入 -> 资产生成 -> CLI 编排 -> GitLink 落地 -> 结果归档”的五段式架构。正式架构图见 `docs/assets/bootstrap-architecture.svg`。
|
||||
|
||||

|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["项目配置<br/>sample_project.json"] --> B["资产生成<br/>README / LICENSE / CI / 协作文档"]
|
||||
B --> C["CLI 编排<br/>repo / branch / issue / comment"]
|
||||
C --> D["GitLink 项目空间<br/>仓库 / 分支 / Issue"]
|
||||
D --> E["结果归档<br/>报告 / 摘要 / manifest / 命令日志"]
|
||||
C --> E
|
||||
```
|
||||
|
||||
## 模块职责
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| 配置输入 | 描述项目名称、目标仓库、初始化分支和初始 Issue |
|
||||
| 资产生成 | 生成 README、LICENSE、CI 配置、贡献指南和路线图 |
|
||||
| CLI 编排 | 规划或执行 `gitlink-cli` 命令,串联仓库、分支、Issue 和评论能力 |
|
||||
| GitLink 落地 | 在真实 GitLink 仓库中创建分支、Issue,并可回写摘要 |
|
||||
| 结果归档 | 输出 Markdown 报告、摘要、JSON manifest 和命令日志 |
|
||||
|
||||
## 端到端链路
|
||||
|
||||
1. 读取 `examples/sample_project.json`。
|
||||
2. 生成初始化文件包。
|
||||
3. 规划 `repo +info` 和 `branch +list` 检查目标状态。
|
||||
4. 规划或执行 `branch +create` 创建协作分支。
|
||||
5. 规划或执行 `issue +create` 创建初始任务。
|
||||
6. 可选执行 `issue +comment` 发布初始化摘要。
|
||||
7. 生成报告与命令日志,支撑复现和审计。
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1672" height="941" viewBox="0 0 1672 941" role="img" aria-labelledby="title desc">
|
||||
<title id="title">GitLink Project Bootstrap Automation</title>
|
||||
<desc id="desc">A vector architecture diagram for a configuration-driven GitLink project bootstrap workflow.</desc>
|
||||
<defs>
|
||||
<filter id="cardShadow" x="-16%" y="-16%" width="132%" height="132%">
|
||||
<feDropShadow dx="0" dy="12" stdDeviation="11" flood-color="#0a1b35" flood-opacity="0.11"/>
|
||||
</filter>
|
||||
<style>
|
||||
text { font-family: Arial, "Microsoft YaHei", sans-serif; fill: #0b1736; }
|
||||
.title { font-size: 72px; font-weight: 800; letter-spacing: 0; }
|
||||
.subtitle { font-size: 27px; fill: #46556b; }
|
||||
.card-title { font-size: 34px; font-weight: 800; }
|
||||
.cli-title { font-size: 30px; font-weight: 800; }
|
||||
.title-navy { fill: #071449; }
|
||||
.title-teal { fill: #075e70; }
|
||||
.title-blue { fill: #14579f; }
|
||||
.title-slate { fill: #334155; }
|
||||
.item { font-size: 23px; fill: #101a2f; }
|
||||
.item-condensed { font-size: 22px; fill: #101a2f; }
|
||||
.small { font-size: 20px; fill: #123e21; }
|
||||
.mono { font-family: Consolas, "Courier New", monospace; font-size: 19px; fill: #101a2f; }
|
||||
.hairline { stroke: #a8b5c4; stroke-width: 1.6; }
|
||||
.icon-line { fill: none; stroke-width: 3.5; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.bullet-navy { fill: #071449; }
|
||||
.bullet-teal { fill: #075e70; }
|
||||
.bullet-blue { fill: #14579f; }
|
||||
.bullet-slate { fill: #334155; }
|
||||
.valid { fill: #32833f; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect width="1672" height="941" fill="#fbfcfe"/>
|
||||
<text x="836" y="93" text-anchor="middle" class="title">GitLink Project Bootstrap Automation</text>
|
||||
<text x="836" y="151" text-anchor="middle" class="subtitle">Configuration-driven project initialization, CLI orchestration, GitLink execution, and reproducible evidence</text>
|
||||
|
||||
<!-- Input -->
|
||||
<g transform="translate(48 208)">
|
||||
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#071449" stroke-width="2.2" filter="url(#cardShadow)"/>
|
||||
<g transform="translate(72 36)" class="icon-line" stroke="#071449">
|
||||
<path d="M10 0h55l28 28v102H10z"/>
|
||||
<path d="M65 0v28h28"/>
|
||||
<text x="50" y="78" text-anchor="middle" font-family="Consolas, 'Courier New', monospace" font-size="34" font-weight="700" fill="#071449" stroke="none">{ }</text>
|
||||
<path d="M32 92h38"/>
|
||||
<path d="M32 111h29"/>
|
||||
</g>
|
||||
<text x="128" y="196" text-anchor="middle" class="card-title title-navy">Input</text>
|
||||
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
|
||||
<circle cx="24" cy="259" r="4.5" class="bullet-navy"/><text x="43" y="267" class="item">Project metadata</text>
|
||||
<circle cx="24" cy="305" r="4.5" class="bullet-navy"/><text x="43" y="313" class="item">Repository target</text>
|
||||
<circle cx="24" cy="351" r="4.5" class="bullet-navy"/><text x="43" y="359" class="item">Branches</text>
|
||||
<circle cx="24" cy="397" r="4.5" class="bullet-navy"/><text x="43" y="405" class="item">Initial issues</text>
|
||||
<circle cx="24" cy="443" r="4.5" class="bullet-navy"/><text x="43" y="451" class="mono">sample_project.json</text>
|
||||
</g>
|
||||
|
||||
<!-- Assets -->
|
||||
<g transform="translate(377 208)">
|
||||
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#075e70" stroke-width="2.2" filter="url(#cardShadow)"/>
|
||||
<g transform="translate(64 35)" class="icon-line" stroke="#075e70">
|
||||
<path d="M2 45h17v84H2z"/>
|
||||
<path d="M22 25h58l23 23v84H22z"/>
|
||||
<path d="M80 25v23h23"/>
|
||||
<path d="M43 6h59l23 23v84h-22"/>
|
||||
<path d="M102 6v23h23"/>
|
||||
<path d="M43 58h44"/>
|
||||
<path d="M43 80h48"/>
|
||||
<path d="M43 102h40"/>
|
||||
</g>
|
||||
<text x="128" y="196" text-anchor="middle" class="card-title title-teal">Assets</text>
|
||||
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
|
||||
<circle cx="24" cy="259" r="4.5" class="bullet-teal"/><text x="43" y="267" class="item">README</text>
|
||||
<circle cx="24" cy="305" r="4.5" class="bullet-teal"/><text x="43" y="313" class="item">LICENSE</text>
|
||||
<circle cx="24" cy="351" r="4.5" class="bullet-teal"/><text x="43" y="359" class="item">Go CI</text>
|
||||
<circle cx="24" cy="397" r="4.5" class="bullet-teal"/><text x="43" y="405" class="item">CONTRIBUTING</text>
|
||||
<circle cx="24" cy="443" r="4.5" class="bullet-teal"/><text x="43" y="451" class="item">ROADMAP</text>
|
||||
</g>
|
||||
|
||||
<!-- CLI -->
|
||||
<g transform="translate(705 208)">
|
||||
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#14579f" stroke-width="2.2" filter="url(#cardShadow)"/>
|
||||
<g transform="translate(79 41)" class="icon-line" stroke="#14579f">
|
||||
<rect x="0" y="0" width="98" height="94" rx="6"/>
|
||||
<path d="M0 29h98"/>
|
||||
<circle cx="17" cy="14" r="3" fill="#14579f" stroke="none"/>
|
||||
<circle cx="33" cy="14" r="3" fill="#14579f" stroke="none"/>
|
||||
<circle cx="49" cy="14" r="3" fill="#14579f" stroke="none"/>
|
||||
<path d="M31 56l18 17-18 18"/>
|
||||
<path d="M61 86h22"/>
|
||||
</g>
|
||||
<text x="128" y="196" text-anchor="middle" class="cli-title title-blue" textLength="218" lengthAdjust="spacingAndGlyphs">CLI Orchestration</text>
|
||||
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
|
||||
<circle cx="24" cy="259" r="4.5" class="bullet-blue"/><text x="52" y="267" class="mono">repo +info</text>
|
||||
<circle cx="24" cy="305" r="4.5" class="bullet-blue"/><text x="52" y="313" class="mono">branch +list</text>
|
||||
<circle cx="24" cy="351" r="4.5" class="bullet-blue"/><text x="52" y="359" class="mono">branch +create</text>
|
||||
<circle cx="24" cy="397" r="4.5" class="bullet-blue"/><text x="52" y="405" class="mono">issue +create</text>
|
||||
</g>
|
||||
|
||||
<!-- GitLink -->
|
||||
<g transform="translate(1032 208)">
|
||||
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#334155" stroke-width="2.2" filter="url(#cardShadow)"/>
|
||||
<g transform="translate(51 40)" class="icon-line" stroke="#334155">
|
||||
<path d="M44 106h68c25 0 42-17 42-41 0-23-16-40-40-42C109 9 93 0 75 0 54 0 37 12 29 31 11 36 0 50 0 68c0 22 18 38 43 38"/>
|
||||
<circle cx="76" cy="36" r="8"/>
|
||||
<circle cx="51" cy="70" r="8"/>
|
||||
<circle cx="102" cy="70" r="8"/>
|
||||
<path d="M72 44L56 63"/>
|
||||
<path d="M80 44l17 20"/>
|
||||
<path d="M76 44v21"/>
|
||||
</g>
|
||||
<text x="128" y="196" text-anchor="middle" class="card-title title-slate">GitLink</text>
|
||||
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
|
||||
<circle cx="24" cy="259" r="4.5" class="bullet-slate"/><text x="43" y="267" class="item">Repository state</text>
|
||||
<circle cx="24" cy="305" r="4.5" class="bullet-slate"/><text x="43" y="313" class="item-condensed" textLength="188" lengthAdjust="spacingAndGlyphs">Collaboration branches</text>
|
||||
<circle cx="24" cy="351" r="4.5" class="bullet-slate"/><text x="43" y="359" class="item">Bootstrap issues</text>
|
||||
<circle cx="24" cy="397" r="4.5" class="bullet-slate"/><text x="43" y="405" class="item">Issue comment</text>
|
||||
<circle cx="24" cy="443" r="4.5" class="bullet-slate"/><text x="43" y="445" class="item">Apply mode writes</text><text x="43" y="475" class="item">remotely</text>
|
||||
</g>
|
||||
|
||||
<!-- Evidence -->
|
||||
<g transform="translate(1364 208)">
|
||||
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#14579f" stroke-width="2.2" filter="url(#cardShadow)"/>
|
||||
<g transform="translate(72 36)" class="icon-line" stroke="#14579f">
|
||||
<path d="M10 0h55l28 28v102H10z"/>
|
||||
<path d="M65 0v28h28"/>
|
||||
<path d="M31 56h43"/>
|
||||
<path d="M31 79h43"/>
|
||||
<path d="M31 102h34"/>
|
||||
</g>
|
||||
<text x="128" y="196" text-anchor="middle" class="card-title title-blue">Evidence</text>
|
||||
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
|
||||
<circle cx="24" cy="259" r="4.5" class="bullet-blue"/><text x="43" y="267" class="item">Markdown report</text>
|
||||
<circle cx="24" cy="305" r="4.5" class="bullet-blue"/><text x="43" y="313" class="item">Issue summary</text>
|
||||
<circle cx="24" cy="351" r="4.5" class="bullet-blue"/><text x="43" y="359" class="mono">manifest.json</text>
|
||||
<circle cx="24" cy="397" r="4.5" class="bullet-blue"/><text x="43" y="405" class="mono">files.json</text>
|
||||
<circle cx="24" cy="443" r="4.5" class="bullet-blue"/><text x="43" y="451" class="item">command log</text>
|
||||
</g>
|
||||
|
||||
<polygon points="316,404 343,404 343,386 371,411 343,436 343,418 316,418" fill="#06133a"/>
|
||||
<polygon points="644,404 671,404 671,386 699,411 671,436 671,418 644,418" fill="#06133a"/>
|
||||
<polygon points="972,404 999,404 999,386 1027,411 999,436 999,418 972,418" fill="#06133a"/>
|
||||
<polygon points="1302,404 1329,404 1329,386 1357,411 1329,436 1329,418 1302,418" fill="#06133a"/>
|
||||
|
||||
<g transform="translate(55 777)">
|
||||
<rect width="1562" height="122" rx="14" fill="#f8fbf8" stroke="#a8b8aa" stroke-width="1.8"/>
|
||||
<g transform="translate(32 22)">
|
||||
<rect width="330" height="78" rx="12" fill="#ffffff" stroke="#4f9a57" stroke-width="2"/>
|
||||
<circle cx="49" cy="39" r="23" class="valid"/>
|
||||
<path d="M38 38l8 9 18-20" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="96" y="48" class="small" fill="#0f4d22">Go implementation</text>
|
||||
</g>
|
||||
<g transform="translate(400 22)">
|
||||
<rect width="330" height="78" rx="12" fill="#ffffff" stroke="#4f9a57" stroke-width="2"/>
|
||||
<circle cx="49" cy="39" r="23" class="valid"/>
|
||||
<path d="M38 38l8 9 18-20" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="96" y="48" class="small" fill="#0f4d22">7 default CLI calls</text>
|
||||
</g>
|
||||
<g transform="translate(768 22)">
|
||||
<rect width="330" height="78" rx="12" fill="#ffffff" stroke="#4f9a57" stroke-width="2"/>
|
||||
<circle cx="49" cy="39" r="23" class="valid"/>
|
||||
<path d="M38 38l8 9 18-20" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="96" y="48" class="small" fill="#0f4d22">Reproducible dry-run</text>
|
||||
</g>
|
||||
<g transform="translate(1136 22)">
|
||||
<rect width="370" height="78" rx="12" fill="#ffffff" stroke="#4f9a57" stroke-width="2"/>
|
||||
<circle cx="49" cy="39" r="23" class="valid"/>
|
||||
<path d="M38 38l8 9 18-20" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="96" y="48" class="small" fill="#0f4d22">Validated on real GitLink repo</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
|
@ -0,0 +1,56 @@
|
|||
# 快速开始
|
||||
|
||||
## 1. 进入目录
|
||||
|
||||
```powershell
|
||||
cd examples\workflows\project-bootstrap-automation
|
||||
```
|
||||
|
||||
## 2. 运行 dry-run
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
该命令不会写入 GitLink,只生成初始化材料和命令计划。
|
||||
|
||||
## 3. 查看输出
|
||||
|
||||
```powershell
|
||||
Get-ChildItem outputs
|
||||
```
|
||||
|
||||
重点查看:
|
||||
|
||||
- `*_bootstrap_report.md`
|
||||
- `*_summary.md`
|
||||
- `*_manifest.json`
|
||||
- `command_log_*.json`
|
||||
|
||||
## 4. 执行单元测试
|
||||
|
||||
```powershell
|
||||
go test ./scripts
|
||||
```
|
||||
|
||||
## 5. 执行真实写入
|
||||
|
||||
确认目标仓库和认证状态后执行:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Apply
|
||||
```
|
||||
|
||||
执行真实写入前,应先通过 `gitlink-cli auth login` 或当前环境已配置的认证方式完成 GitLink 登录。
|
||||
|
||||
如需创建目标仓库:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Apply -CreateRepo
|
||||
```
|
||||
|
||||
如需把摘要发布到指定 Issue:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1
|
||||
```
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# 运行手册
|
||||
|
||||
## 模式说明
|
||||
|
||||
| 模式 | 命令 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| dry-run | `.\scripts\run_demo.ps1` | 只生成材料和命令计划,不写入 GitLink |
|
||||
| apply | `.\scripts\run_demo.ps1 -Apply` | 执行真实 `gitlink-cli` 命令 |
|
||||
| apply + create repo | `.\scripts\run_demo.ps1 -Apply -CreateRepo` | 先创建仓库,再执行初始化命令 |
|
||||
| apply + comment | `.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1` | 执行真实命令,并将摘要评论到指定 Issue |
|
||||
|
||||
## 配置文件
|
||||
|
||||
默认配置位于:
|
||||
|
||||
```text
|
||||
examples/sample_project.json
|
||||
```
|
||||
|
||||
主要字段:
|
||||
|
||||
- `project`:项目名称、描述、语言、许可证
|
||||
- `repository`:目标 GitLink 仓库 owner/name
|
||||
- `branches`:需要创建的协作分支
|
||||
- `issues`:初始化 Issue 列表
|
||||
- `publish.issue_number`:可选的摘要发布 Issue 编号
|
||||
|
||||
## 输出文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --- | --- |
|
||||
| `*_bootstrap_report.md` | 初始化报告 |
|
||||
| `*_summary.md` | 可发布到 Issue 的摘要 |
|
||||
| `*_manifest.json` | 结构化初始化清单 |
|
||||
| `*_files.json` | 生成文件内容包 |
|
||||
| `command_log_*.json` | gitlink-cli 命令计划或执行结果 |
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 默认 dry-run,不进行远端写操作。
|
||||
- 只有显式传入 `-Apply` 才执行真实 GitLink 命令。
|
||||
- `-PublishIssueNumber` 只在明确指定 Issue 编号时追加评论命令。
|
||||
- 所有命令会写入 `command_log_*.json`,便于复盘和审计。
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# 提交核对清单
|
||||
|
||||
## 官方交付要求映射
|
||||
|
||||
| 要求 | 本项目对应内容 |
|
||||
| --- | --- |
|
||||
| 工作流串联不少于 3 个 CLI 命令或 Skill 调用 | `scripts/bootstrap_project.go` 规划或执行 `repo +info`、`branch +list`、`branch +create`、`issue +create`、`issue +comment` |
|
||||
| 提供可复现执行脚本或 Agent 对话记录 | `scripts/run_demo.ps1` |
|
||||
| 在至少一个真实 GitLink 项目上运行并展示效果 | 已在 `puygob236/gitlink-bootstrap-demo` 完成仓库读取、分支读取、Issue 创建和 Issue 摘要回写验证 |
|
||||
| 提供工作流说明文档 | `README.md`、`docs/workflow-spec.md`、`docs/quickstart.md`、`docs/runbook.md` |
|
||||
| 提供架构图 | `docs/architecture.md`、`docs/assets/bootstrap-architecture.svg` |
|
||||
| 提供演示材料 | 演示视频作为比赛平台附件提交;仓库内保留 `scripts/run_demo.ps1`、`docs/verification.md` 和 `examples/demo_outputs/` 作为可复现证据 |
|
||||
| 代码开源并托管到 GitLink | 放置于 `examples/workflows/project-bootstrap-automation/` |
|
||||
| 提供完整中文 README | `README.md` |
|
||||
|
||||
## 验证状态
|
||||
|
||||
- `go test ./scripts`:通过
|
||||
- `.\scripts\run_demo.ps1`:通过
|
||||
- dry-run 生成 7 个 gitlink-cli 调用计划,满足赛题要求
|
||||
- `.\scripts\run_demo.ps1 -Config examples\verification_comment_config.json -Apply -PublishIssueNumber 4`:通过,3 个真实 gitlink-cli 调用状态均为 `ok`
|
||||
|
||||
## 交付内容
|
||||
|
||||
- `README.md`、`docs/`、`scripts/`、`examples/` 均位于本目录。
|
||||
- `outputs/` 为运行时生成目录,评审可通过复现脚本重新生成。
|
||||
- `examples/demo_outputs/` 用于保存固定示例产物。
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# 验证记录
|
||||
|
||||
## 本地验证
|
||||
|
||||
执行目录:
|
||||
|
||||
```text
|
||||
examples/workflows/project-bootstrap-automation
|
||||
```
|
||||
|
||||
单元测试:
|
||||
|
||||
```powershell
|
||||
go test ./scripts
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
ok github.com/gitlink-org/gitlink-cli/examples/workflows/project-bootstrap-automation/scripts
|
||||
```
|
||||
|
||||
dry-run 复现:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
已生成初始化报告: outputs\puygob236_gitlink-bootstrap-demo_20260524_072107_bootstrap_report.md
|
||||
已生成初始化摘要: outputs\puygob236_gitlink-bootstrap-demo_20260524_072107_summary.md
|
||||
已生成文件清单: outputs\puygob236_gitlink-bootstrap-demo_20260524_072107_manifest.json
|
||||
已生成命令日志: outputs\command_log_20260524_072107.json
|
||||
模式: dry-run
|
||||
计划/执行 gitlink-cli 调用: 7 个
|
||||
```
|
||||
|
||||
## 真实仓库验证计划
|
||||
|
||||
目标仓库:
|
||||
|
||||
```text
|
||||
puygob236/gitlink-bootstrap-demo
|
||||
```
|
||||
|
||||
验证步骤:
|
||||
|
||||
1. 确认 GitLink 认证可用。
|
||||
2. 创建或确认目标仓库存在。
|
||||
3. 执行 `.\scripts\run_demo.ps1 -Apply`。
|
||||
4. 检查分支、Issue 和输出报告。
|
||||
5. 如需展示回写能力,执行 `.\scripts\run_demo.ps1 -Apply -PublishIssueNumber <number>`。
|
||||
|
||||
## 真实仓库验证结果
|
||||
|
||||
目标仓库:
|
||||
|
||||
```text
|
||||
https://gitlink.org.cn/puygob236/gitlink-bootstrap-demo
|
||||
```
|
||||
|
||||
已完成验证:
|
||||
|
||||
- `repo +info`:成功读取 `puygob236/gitlink-bootstrap-demo` 仓库信息。
|
||||
- `branch +list`:成功读取 `master`、`develop`、`release/v0.1` 分支。
|
||||
- `issue +create`:成功创建初始化 Issue,生成项目任务清单。
|
||||
- `issue +comment`:成功将初始化摘要回写到 Issue。
|
||||
|
||||
回写验证命令:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Config examples\verification_comment_config.json -Apply -PublishIssueNumber 4
|
||||
```
|
||||
|
||||
回写验证结果:
|
||||
|
||||
```text
|
||||
模式: apply
|
||||
计划/执行 gitlink-cli 调用: 3 个
|
||||
```
|
||||
|
||||
命令日志中 3 条调用状态均为 `ok`,无 stderr。
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
# 工作流说明
|
||||
|
||||
## 场景定位
|
||||
|
||||
本工作流面向 GitLink 子赛题三“构建端到端自动化工作流”,选择“项目一键初始化”作为应用场景。目标是在新开源项目创建初期,将项目配置、初始化文件、协作分支、初始 Issue 和执行报告统一串联,形成可复现、可审计的启动流程。
|
||||
|
||||
该场景覆盖开源项目常见的启动缺口:
|
||||
|
||||
- README、License、CI 配置和协作文档不完整。
|
||||
- 初始任务缺少统一模板,Issue 粒度和验收标准不一致。
|
||||
- 分支、Issue、报告产物分散,难以复盘初始化过程。
|
||||
- 真实写入和演示复现之间缺少安全边界。
|
||||
|
||||
## 端到端流程
|
||||
|
||||
工作流由 `scripts/bootstrap_project.go` 实现,默认读取 `examples/sample_project.json`,并按以下顺序执行:
|
||||
|
||||
1. 解析项目配置,读取项目名称、仓库 owner/name、许可证、初始化分支和初始 Issue。
|
||||
2. 生成初始化文件包,包括 README、LICENSE、CI 配置、贡献指南和路线图。
|
||||
3. 规划或执行 `repo +info`,检查目标 GitLink 仓库状态。
|
||||
4. 规划或执行 `branch +list`,读取分支状态。
|
||||
5. 规划或执行 `branch +create`,创建协作分支。
|
||||
6. 规划或执行 `issue +create`,创建初始化任务。
|
||||
7. 可选执行 `issue +comment`,将初始化摘要回写到指定 Issue。
|
||||
8. 生成 Markdown 报告、摘要、manifest、文件包和命令日志。
|
||||
|
||||
## 串联的 GitLink CLI 能力
|
||||
|
||||
默认 dry-run 配置会生成 7 个 `gitlink-cli` 调用计划:
|
||||
|
||||
| 顺序 | CLI 能力 | 用途 |
|
||||
| ---: | --- | --- |
|
||||
| 1 | `repo +info` | 检查目标仓库信息 |
|
||||
| 2 | `branch +list` | 读取当前分支列表 |
|
||||
| 3 | `branch +create` | 创建 `develop` 协作分支 |
|
||||
| 4 | `branch +create` | 创建 `release/v0.1` 发布分支 |
|
||||
| 5 | `issue +create` | 创建 README 与快速开始任务 |
|
||||
| 6 | `issue +create` | 创建 CI 检查任务 |
|
||||
| 7 | `issue +create` | 创建 v0.1 里程碑任务 |
|
||||
|
||||
当传入 `-PublishIssueNumber` 时,会追加 `issue +comment`,用于把初始化摘要发布到指定 GitLink Issue。
|
||||
|
||||
## 运行模式
|
||||
|
||||
| 模式 | 命令 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| dry-run | `.\scripts\run_demo.ps1` | 生成材料和命令计划,不写入 GitLink |
|
||||
| apply | `.\scripts\run_demo.ps1 -Apply` | 执行真实 GitLink CLI 命令 |
|
||||
| apply + create repo | `.\scripts\run_demo.ps1 -Apply -CreateRepo` | 先创建仓库,再执行初始化流程 |
|
||||
| apply + comment | `.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1` | 执行真实命令并回写摘要 |
|
||||
|
||||
## 输出产物
|
||||
|
||||
运行后会生成以下文件:
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --- | --- |
|
||||
| `*_bootstrap_report.md` | 初始化报告,展示目标项目、生成文件、分支计划和 Issue 计划 |
|
||||
| `*_summary.md` | 可发布到 Issue 的初始化摘要 |
|
||||
| `*_manifest.json` | 结构化初始化清单 |
|
||||
| `*_files.json` | 生成文件内容包 |
|
||||
| `command_log_*.json` | gitlink-cli 命令计划或执行结果 |
|
||||
|
||||
固定示例输出保存在 `examples/demo_outputs/`,用于评审快速查看产物格式。`outputs/` 是运行时目录,可通过脚本重新生成。
|
||||
|
||||
## 工程边界
|
||||
|
||||
- 主实现使用 Go,便于与 `gitlink-cli` 主仓库技术栈保持一致。
|
||||
- 默认 dry-run,避免演示阶段误写远端仓库。
|
||||
- 真实写入必须显式传入 `-Apply`。
|
||||
- 命令日志记录每个 CLI 调用的状态,便于复盘和排查。
|
||||
- 测试覆盖文件生成、CLI 编排、Issue 内容生成、幂等跳过判断和输出 manifest。
|
||||
|
||||
## 赛题价值
|
||||
|
||||
该工作流不是单个命令封装,而是面向真实开源项目启动流程的组合式方案。它把 `gitlink-cli` 的仓库、分支、Issue 和评论能力整合为一个可复现闭环,符合子赛题三对“串联多个 CLI 命令或 Skill 调用”“真实项目运行展示”“工作流说明文档和架构图”的要求。
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# 示例输出
|
||||
|
||||
本目录保存 `scripts/bootstrap_project.go` 在 dry-run 模式下生成的固定示例产物,便于快速查看工作流输出格式。
|
||||
|
||||
生成命令:
|
||||
|
||||
```powershell
|
||||
go run scripts\bootstrap_project.go --config examples\sample_project.json --output-dir examples\demo_outputs --now 2026-05-24T08:00:00Z
|
||||
```
|
||||
|
||||
产物说明:
|
||||
|
||||
- `*_bootstrap_report.md`:项目初始化报告
|
||||
- `*_summary.md`:可发布到 Issue 的初始化摘要
|
||||
- `*_manifest.json`:结构化初始化清单
|
||||
- `*_files.json`:生成文件内容包
|
||||
- `command_log_*.json`:gitlink-cli 命令计划
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
{
|
||||
"mode": "dry-run",
|
||||
"commands": [
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"repo",
|
||||
"+info",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"branch",
|
||||
"+list",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"branch",
|
||||
"+create",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--name",
|
||||
"develop",
|
||||
"--from",
|
||||
"master",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"branch",
|
||||
"+create",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--name",
|
||||
"release/v0.1",
|
||||
"--from",
|
||||
"master",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"issue",
|
||||
"+create",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--title",
|
||||
"完善项目 README 与快速开始文档",
|
||||
"--body",
|
||||
"仓库: `puygob236/gitlink-bootstrap-demo`\n\n类型: documentation\n优先级: normal\n\n## 任务清单\n\n- [ ] 补充项目背景和目标用户\n- [ ] 补充安装与运行步骤\n- [ ] 补充最小示例\n\n## 验收标准\n\nREADME 能支撑新贡献者在 10 分钟内完成本地启动。\n",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"issue",
|
||||
"+create",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--title",
|
||||
"建立基础 CI 检查",
|
||||
"--body",
|
||||
"仓库: `puygob236/gitlink-bootstrap-demo`\n\n类型: ci\n优先级: high\n\n## 任务清单\n\n- [ ] 添加测试命令\n- [ ] 添加 lint 或格式检查\n- [ ] 在 PR 中展示检查结果\n\n## 验收标准\n\n每次 push 和 PR 均能触发基础检查。\n",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"issue",
|
||||
"+create",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--title",
|
||||
"规划 v0.1 版本里程碑",
|
||||
"--body",
|
||||
"仓库: `puygob236/gitlink-bootstrap-demo`\n\n类型: release\n优先级: normal\n\n## 任务清单\n\n- [ ] 整理 v0.1 范围\n- [ ] 确定验收标准\n- [ ] 准备 Release Notes 模板\n\n## 验收标准\n\n形成可执行的 v0.1 版本任务列表。\n",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# GitLink 项目初始化工作流报告
|
||||
|
||||
## 目标项目
|
||||
|
||||
- 仓库: `puygob236/gitlink-bootstrap-demo`
|
||||
- 项目名称: Open Research Toolkit
|
||||
- 描述: A reproducible GitLink project initialized by an end-to-end automation workflow.
|
||||
- 生成时间: 2026-05-24T08:00:00Z
|
||||
|
||||
## 初始化文件
|
||||
|
||||
| 文件 | 字节数 |
|
||||
| --- | ---: |
|
||||
| `README.md` | 545 |
|
||||
| `LICENSE` | 179 |
|
||||
| `.github/workflows/ci.yml` | 232 |
|
||||
| `docs/CONTRIBUTING.md` | 83 |
|
||||
| `docs/ROADMAP.md` | 92 |
|
||||
|
||||
## 分支计划
|
||||
|
||||
| 分支 | 来源 | 保护 |
|
||||
| --- | --- | --- |
|
||||
| `develop` | `master` | false |
|
||||
| `release/v0.1` | `master` | false |
|
||||
|
||||
## 初始 Issue 计划
|
||||
|
||||
| 序号 | 标题 | 优先级 |
|
||||
| ---: | --- | --- |
|
||||
| 1 | 完善项目 README 与快速开始文档 | normal |
|
||||
| 2 | 建立基础 CI 检查 | high |
|
||||
| 3 | 规划 v0.1 版本里程碑 | normal |
|
||||
|
||||
## 工作流闭环
|
||||
|
||||
1. 读取项目配置。
|
||||
2. 生成 README、LICENSE、CI 和协作文档。
|
||||
3. 调用 gitlink-cli 检查仓库和分支状态。
|
||||
4. 调用 gitlink-cli 创建初始化 Issue。
|
||||
5. 输出报告、摘要和结构化 manifest,必要时回写到 GitLink Issue。
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
".github/workflows/ci.yml": "name: Go CI\n\non:\n push:\n pull_request:\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-go@v5\n with:\n go-version: \"1.23\"\n - run: go test ./...\n",
|
||||
"LICENSE": "# License\n\nThis project is initialized with the `MulanPSL-2.0` license.\n\nThe final repository should keep the complete license text that matches the selected open-source license.\n",
|
||||
"README.md": "# Open Research Toolkit\n\nA reproducible GitLink project initialized by an end-to-end automation workflow.\n\n## 项目信息\n\n- GitLink 仓库: `puygob236/gitlink-bootstrap-demo`\n- 技术方向: Go\n- 初始化来源: GitLink 项目一键初始化工作流\n\n## 快速开始\n\n```bash\ngit clone https://gitlink.org.cn/puygob236/gitlink-bootstrap-demo.git\ncd gitlink-bootstrap-demo\n```\n\n## 协作约定\n\n- 使用 Issue 跟踪需求、缺陷和文档任务。\n- 使用 Pull Request 合并代码变更。\n- 重要里程碑通过 Release Notes 记录。\n",
|
||||
"docs/CONTRIBUTING.md": "# 贡献指南\n\n请通过 Issue 讨论需求,通过 Pull Request 提交变更。\n",
|
||||
"docs/ROADMAP.md": "# Roadmap\n\n- [ ] 完成项目初始化\n- [ ] 建立基础测试\n- [ ] 发布第一个版本\n"
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"repository": "puygob236/gitlink-bootstrap-demo",
|
||||
"project": {
|
||||
"name": "Open Research Toolkit",
|
||||
"description": "A reproducible GitLink project initialized by an end-to-end automation workflow.",
|
||||
"language": "Go",
|
||||
"license": "MulanPSL-2.0"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"bytes": 545
|
||||
},
|
||||
{
|
||||
"path": "LICENSE",
|
||||
"bytes": 179
|
||||
},
|
||||
{
|
||||
"path": ".github/workflows/ci.yml",
|
||||
"bytes": 232
|
||||
},
|
||||
{
|
||||
"path": "docs/CONTRIBUTING.md",
|
||||
"bytes": 83
|
||||
},
|
||||
{
|
||||
"path": "docs/ROADMAP.md",
|
||||
"bytes": 92
|
||||
}
|
||||
],
|
||||
"branches": [
|
||||
{
|
||||
"name": "develop",
|
||||
"from": "master",
|
||||
"create": true,
|
||||
"protect": false
|
||||
},
|
||||
{
|
||||
"name": "release/v0.1",
|
||||
"from": "master",
|
||||
"create": true,
|
||||
"protect": false
|
||||
}
|
||||
],
|
||||
"issues": [
|
||||
{
|
||||
"title": "完善项目 README 与快速开始文档",
|
||||
"type": "documentation",
|
||||
"priority": "normal",
|
||||
"tasks": [
|
||||
"补充项目背景和目标用户",
|
||||
"补充安装与运行步骤",
|
||||
"补充最小示例"
|
||||
],
|
||||
"acceptance": "README 能支撑新贡献者在 10 分钟内完成本地启动。"
|
||||
},
|
||||
{
|
||||
"title": "建立基础 CI 检查",
|
||||
"type": "ci",
|
||||
"priority": "high",
|
||||
"tasks": [
|
||||
"添加测试命令",
|
||||
"添加 lint 或格式检查",
|
||||
"在 PR 中展示检查结果"
|
||||
],
|
||||
"acceptance": "每次 push 和 PR 均能触发基础检查。"
|
||||
},
|
||||
{
|
||||
"title": "规划 v0.1 版本里程碑",
|
||||
"type": "release",
|
||||
"priority": "normal",
|
||||
"tasks": [
|
||||
"整理 v0.1 范围",
|
||||
"确定验收标准",
|
||||
"准备 Release Notes 模板"
|
||||
],
|
||||
"acceptance": "形成可执行的 v0.1 版本任务列表。"
|
||||
}
|
||||
],
|
||||
"generated_at": "2026-05-24T08:00:00Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# GitLink 项目初始化摘要
|
||||
|
||||
- 目标仓库: `puygob236/gitlink-bootstrap-demo`
|
||||
- 项目名称: Open Research Toolkit
|
||||
- 生成时间: 2026-05-24T08:00:00Z
|
||||
- 初始化文件: 5 个
|
||||
- 初始 Issue: 3 个
|
||||
- 分支动作: 2 个
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"project": {
|
||||
"name": "Open Research Toolkit",
|
||||
"description": "A reproducible GitLink project initialized by an end-to-end automation workflow.",
|
||||
"language": "Go",
|
||||
"license": "MulanPSL-2.0"
|
||||
},
|
||||
"repository": {
|
||||
"owner": "puygob236",
|
||||
"name": "gitlink-bootstrap-demo"
|
||||
},
|
||||
"branches": [
|
||||
{
|
||||
"name": "develop",
|
||||
"from": "master",
|
||||
"create": true,
|
||||
"protect": false
|
||||
},
|
||||
{
|
||||
"name": "release/v0.1",
|
||||
"from": "master",
|
||||
"create": true,
|
||||
"protect": false
|
||||
}
|
||||
],
|
||||
"issues": [
|
||||
{
|
||||
"title": "完善项目 README 与快速开始文档",
|
||||
"type": "documentation",
|
||||
"priority": "normal",
|
||||
"tasks": [
|
||||
"补充项目背景和目标用户",
|
||||
"补充安装与运行步骤",
|
||||
"补充最小示例"
|
||||
],
|
||||
"acceptance": "README 能支撑新贡献者在 10 分钟内完成本地启动。"
|
||||
},
|
||||
{
|
||||
"title": "建立基础 CI 检查",
|
||||
"type": "ci",
|
||||
"priority": "high",
|
||||
"tasks": [
|
||||
"添加测试命令",
|
||||
"添加 lint 或格式检查",
|
||||
"在 PR 中展示检查结果"
|
||||
],
|
||||
"acceptance": "每次 push 和 PR 均能触发基础检查。"
|
||||
},
|
||||
{
|
||||
"title": "规划 v0.1 版本里程碑",
|
||||
"type": "release",
|
||||
"priority": "normal",
|
||||
"tasks": [
|
||||
"整理 v0.1 范围",
|
||||
"确定验收标准",
|
||||
"准备 Release Notes 模板"
|
||||
],
|
||||
"acceptance": "形成可执行的 v0.1 版本任务列表。"
|
||||
}
|
||||
],
|
||||
"publish": {
|
||||
"issue_number": 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"project": {
|
||||
"name": "Open Research Toolkit",
|
||||
"description": "A reproducible GitLink project initialized by an end-to-end automation workflow.",
|
||||
"language": "Go",
|
||||
"license": "MulanPSL-2.0"
|
||||
},
|
||||
"repository": {
|
||||
"owner": "puygob236",
|
||||
"name": "gitlink-bootstrap-demo"
|
||||
},
|
||||
"branches": [],
|
||||
"issues": [],
|
||||
"publish": {
|
||||
"issue_number": 4
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue