forked from Gitlink/gitlink-cli
Compare commits
87 Commits
master
...
docs/windo
| Author | SHA1 | Date |
|---|---|---|
|
|
eff7336527 | |
|
|
7947d4dfd6 | |
|
|
68aa9ae6e6 | |
|
|
27cc58328d | |
|
|
ee0d882e1c | |
|
|
ca578a74be | |
|
|
55805aa13d | |
|
|
dd4ed35448 | |
|
|
723e698db4 | |
|
|
03a9d7a942 | |
|
|
34f14b079f | |
|
|
f3e5d4aba0 | |
|
|
64dad994ae | |
|
|
7e863398aa | |
|
|
9c085fb246 | |
|
|
a9ee74efb0 | |
|
|
d6321e7af5 | |
|
|
dec10afaeb | |
|
|
35e50a1828 | |
|
|
dedba6e49f | |
|
|
a1d3a85ab8 | |
|
|
0b48ec90d8 | |
|
|
3255884c6e | |
|
|
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 | |
|
|
46799d8e56 | |
|
|
1feb6a85fc | |
|
|
23a478bd69 | |
|
|
9638d279d3 | |
|
|
9ebed898ac |
|
|
@ -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 ./...
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
|
|
||||||
gitlink-cli.exe
|
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")
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)'
|
LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)'
|
||||||
|
|
||||||
.PHONY: build install clean test
|
.PHONY: build install clean test check vet fmt cover lint
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||||
|
|
@ -15,4 +15,30 @@ clean:
|
||||||
rm -f $(BINARY)
|
rm -f $(BINARY)
|
||||||
|
|
||||||
test:
|
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."
|
||||||
|
|
|
||||||
244
README.md
244
README.md
|
|
@ -5,7 +5,7 @@
|
||||||
[](https://golang.org)
|
[](https://golang.org)
|
||||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
[](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, webhooks, CI/CD, and AI-powered workflows, with 40+ commands and 13 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.zh-CN.md)**
|
**[中文文档](./README.zh-CN.md)**
|
||||||
|
|
||||||
|
|
@ -13,22 +13,69 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center;">
|
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;">
|
||||||
<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>
|
<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>
|
<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>
|
||||||
<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/wangyue111">wangyue111</a></sub>
|
||||||
<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>
|
</div>
|
||||||
<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>
|
<div align="center">
|
||||||
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Z/144_206_212/120.png" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
|
<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>
|
||||||
<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/wbtiger">wbtiger</a></sub>
|
||||||
<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>
|
</div>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
## Why gitlink-cli?
|
## Why gitlink-cli?
|
||||||
|
|
||||||
- **Agent-Native Design** — 13 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
|
- **Agent-Native Design** — 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, Webhook, Branch, Release, CI, Org, Search, User — all core domains covered
|
- **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
|
- **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
|
- **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`
|
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
|
||||||
|
|
@ -40,13 +87,16 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
||||||
|
|
||||||
| Category | Capabilities |
|
| Category | Capabilities |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| 📦 Repo | List, create, fork, delete repositories, view repo info |
|
| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions |
|
||||||
| 🐛 Issue | Create, update, close, batch close, comment on issues |
|
| 🐛 Issue | Create, update, close, batch close, comment on issues |
|
||||||
|
| 🔖 Label | Create, list, update, delete issue labels |
|
||||||
| 🔀 PR | Create, merge, review pull requests, view changed files |
|
| 🔀 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 |
|
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
|
||||||
| 🏷️ Release | Create, view, delete releases |
|
| 🏷️ Release | Create, view, delete releases |
|
||||||
| 🏢 Org | Manage organizations, members, teams |
|
| 🏢 Org | Manage organizations, members, teams |
|
||||||
| 🔧 CI | View builds, logs, CI/CD operations |
|
| 🔧 CI | View builds, logs, CI/CD operations |
|
||||||
|
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
|
||||||
| 🔔 Webhook | Manage repo webhooks and test deliveries |
|
| 🔔 Webhook | Manage repo webhooks and test deliveries |
|
||||||
| 🔍 Search | Search repositories, users |
|
| 🔍 Search | Search repositories, users |
|
||||||
| 👤 User | View user profiles and info |
|
| 👤 User | View user profiles and info |
|
||||||
|
|
@ -70,7 +120,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
||||||
**From npm (recommended):**
|
**From npm (recommended):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# One command: installs CLI binary + all 13 AI Agent Skills
|
# One command: installs CLI binary + AI Agent Skills
|
||||||
npm install -g @gitlink-ai/cli
|
npm install -g @gitlink-ai/cli
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -86,7 +136,7 @@ cd gitlink-cli
|
||||||
make install
|
make install
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Windows users:** Run `npm install -g @gitlink-ai/cli` in PowerShell or CMD. For building from source, use `go install .` instead of `make install`.
|
> **Windows users:** Run `npm install -g @gitlink-ai/cli` in PowerShell or CMD. For building from source, use `go install .` instead of `make install`. If your Agent or shell uses Git Bash, see the FAQ below for the `node .../bin/cli.js` fallback invocation.
|
||||||
|
|
||||||
#### Configure & Use
|
#### Configure & Use
|
||||||
|
|
||||||
|
|
@ -151,6 +201,32 @@ gitlink-cli repo +list
|
||||||
# View repository info
|
# View repository info
|
||||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
||||||
|
|
||||||
|
# Read repository README
|
||||||
|
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
|
||||||
|
|
||||||
|
# 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
|
# Create a repository
|
||||||
gitlink-cli repo +create -n my-project -d "Project description"
|
gitlink-cli repo +create -n my-project -d "Project description"
|
||||||
|
|
||||||
|
|
@ -175,6 +251,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
||||||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
### Issue Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -184,9 +282,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
|
||||||
# Create an issue
|
# Create an issue
|
||||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..."
|
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
|
# View an issue
|
||||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
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
|
# Close an issue
|
||||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||||
|
|
||||||
|
|
@ -198,6 +302,45 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
|
||||||
|
|
||||||
# Add a comment
|
# Add a comment
|
||||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
|
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
|
### Pull Requests
|
||||||
|
|
@ -218,6 +361,9 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
||||||
# Merge a PR
|
# Merge a PR
|
||||||
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
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
|
# View changed files
|
||||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
||||||
|
|
||||||
|
|
@ -280,6 +426,28 @@ gitlink-cli ci +log --owner Gitlink --repo forgeplus -i <build_id>
|
||||||
gitlink-cli ci +restart --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
|
||||||
|
```
|
||||||
|
|
||||||
### Search
|
### Search
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -400,6 +568,12 @@ gitlink-cli api GET /users/me
|
||||||
# POST request
|
# POST request
|
||||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
|
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
|
# With query parameters
|
||||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
||||||
```
|
```
|
||||||
|
|
@ -437,24 +611,27 @@ git push gitlink
|
||||||
|
|
||||||
## AI Agent Skills
|
## 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 |
|
| Skill | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `gitlink-shared` | Authentication, global parameters, safety rules, API notes |
|
| `gitlink-shared` | Authentication, global parameters, safety rules, API notes |
|
||||||
| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) |
|
| `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) |
|
||||||
| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
|
| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
|
||||||
| `gitlink-pr` | Pull request operations (create, merge, review, 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-branch` | Branch management (create, delete, list, protect, unprotect) |
|
||||||
| `gitlink-release` | Release management (create, view, delete, etc.) |
|
| `gitlink-release` | Release management (create, view, delete, etc.) |
|
||||||
| `gitlink-ci` | CI/CD operations (builds, logs, 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-search` | Search (repositories, users, etc.) |
|
||||||
| `gitlink-org` | Organization management (members, teams, etc.) |
|
| `gitlink-org` | Organization management (members, teams, etc.) |
|
||||||
| `gitlink-user` | User management (profile info, etc.) |
|
| `gitlink-user` | User management (profile info, etc.) |
|
||||||
| `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) |
|
| `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) |
|
||||||
| `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, 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
|
## Project Structure
|
||||||
|
|
||||||
|
|
@ -477,10 +654,12 @@ gitlink-cli/
|
||||||
│ ├── repo/ # Repository shortcuts
|
│ ├── repo/ # Repository shortcuts
|
||||||
│ ├── issue/ # Issue shortcuts
|
│ ├── issue/ # Issue shortcuts
|
||||||
│ ├── pr/ # PR shortcuts
|
│ ├── pr/ # PR shortcuts
|
||||||
|
│ ├── member/ # Repository member shortcuts
|
||||||
│ ├── branch/ # Branch shortcuts
|
│ ├── branch/ # Branch shortcuts
|
||||||
│ ├── release/ # Release shortcuts
|
│ ├── release/ # Release shortcuts
|
||||||
│ ├── org/ # Organization shortcuts
|
│ ├── org/ # Organization shortcuts
|
||||||
│ ├── ci/ # CI shortcuts
|
│ ├── ci/ # CI shortcuts
|
||||||
|
│ ├── pipeline/ # Pipeline shortcuts
|
||||||
│ ├── search/ # Search shortcuts
|
│ ├── search/ # Search shortcuts
|
||||||
│ ├── user/ # User shortcuts
|
│ ├── user/ # User shortcuts
|
||||||
│ └── register.go # Registration entry point
|
│ └── register.go # Registration entry point
|
||||||
|
|
@ -563,6 +742,35 @@ If the error persists, check whether the release page contains the asset for you
|
||||||
for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64.
|
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 .`.
|
You can also download the binary manually from the release page or build from source with `go install .`.
|
||||||
|
|
||||||
|
### Q: What if `gitlink-cli` works in PowerShell/CMD but fails in Git Bash or a Windows Agent shell?
|
||||||
|
|
||||||
|
On Windows, `npm install -g @gitlink-ai/cli` creates npm shims. PowerShell and CMD can execute those shims directly, but some Git Bash based Agents cannot.
|
||||||
|
|
||||||
|
Use the direct Node entry instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GITLINK_CLI="$(npm root -g)/@gitlink-ai/cli/bin/cli.js"
|
||||||
|
node "$GITLINK_CLI" user +info --login <login> --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
If `npm root -g` is unavailable, locate the global package first and run the entry file manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm list -g @gitlink-ai/cli --depth=0
|
||||||
|
node "/c/Program Files/nodejs/node_modules/@gitlink-ai/cli/bin/cli.js" user +info --login <login> --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
For Skills and Agent prompts, prefer documenting both forms:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PowerShell / CMD / macOS / Linux
|
||||||
|
gitlink-cli user +info --login <login> --format json
|
||||||
|
|
||||||
|
# Windows Git Bash or shim-incompatible Agent shells
|
||||||
|
GITLINK_CLI="$(npm root -g)/@gitlink-ai/cli/bin/cli.js"
|
||||||
|
node "$GITLINK_CLI" user +info --login <login> --format json
|
||||||
|
```
|
||||||
|
|
||||||
### Q: Where are credentials stored on Windows?
|
### Q: Where are credentials stored on Windows?
|
||||||
|
|
||||||
gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`).
|
gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`).
|
||||||
|
|
|
||||||
240
README.zh-CN.md
240
README.zh-CN.md
|
|
@ -5,7 +5,7 @@
|
||||||
[](https://golang.org)
|
[](https://golang.org)
|
||||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
||||||
|
|
||||||
[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 13 个 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/)。
|
||||||
|
|
||||||
**[English](./README.md)**
|
**[English](./README.md)**
|
||||||
|
|
||||||
|
|
@ -13,22 +13,69 @@
|
||||||
|
|
||||||
## 贡献者
|
## 贡献者
|
||||||
|
|
||||||
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center;">
|
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;">
|
||||||
<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>
|
<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>
|
<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>
|
||||||
<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/wangyue111">wangyue111</a></sub>
|
||||||
<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>
|
</div>
|
||||||
<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>
|
<div align="center">
|
||||||
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Z/144_206_212/120.png" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
|
<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>
|
||||||
<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/wbtiger">wbtiger</a></sub>
|
||||||
<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>
|
</div>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
## 为什么选择 gitlink-cli?
|
## 为什么选择 gitlink-cli?
|
||||||
|
|
||||||
- **Agent-Native 设计** — 开箱即用 13 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||||
- **广泛覆盖** — 仓库、Issue、PR、Webhook、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
|
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
|
||||||
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
|
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
|
||||||
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
|
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
|
||||||
- **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用
|
- **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用
|
||||||
|
|
@ -40,13 +87,16 @@
|
||||||
|
|
||||||
| 分类 | 能力 |
|
| 分类 | 能力 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 |
|
||||||
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
|
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
|
||||||
|
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
|
||||||
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
||||||
|
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
|
||||||
| 🌿 分支 | 创建、删除、保护分支 |
|
| 🌿 分支 | 创建、删除、保护分支 |
|
||||||
| 🏷️ 发布 | 创建、查看、删除 Release |
|
| 🏷️ 发布 | 创建、查看、删除 Release |
|
||||||
| 🏢 组织 | 管理组织、成员、团队 |
|
| 🏢 组织 | 管理组织、成员、团队 |
|
||||||
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
|
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
|
||||||
|
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
|
||||||
| 🔍 搜索 | 搜索仓库、用户 |
|
| 🔍 搜索 | 搜索仓库、用户 |
|
||||||
| 👤 用户 | 查看用户资料和信息 |
|
| 👤 用户 | 查看用户资料和信息 |
|
||||||
| 📋 项目管理 | Sprint 管理、看板、周报 |
|
| 📋 项目管理 | Sprint 管理、看板、周报 |
|
||||||
|
|
@ -94,7 +144,7 @@ make install
|
||||||
npx skills add ./skills -y -g
|
npx skills add ./skills -y -g
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Windows 用户注意:** 请在 PowerShell 或 CMD 中运行 `npm install -g @gitlink-ai/cli`。从源码构建请使用 `go install .` 代替 `make install`。
|
> **Windows 用户注意:** 请在 PowerShell 或 CMD 中运行 `npm install -g @gitlink-ai/cli`。从源码构建请使用 `go install .` 代替 `make install`。如果你的 Agent 或 Shell 使用 Git Bash,请参考下方 FAQ 中的 `node .../bin/cli.js` 兜底调用方式。
|
||||||
|
|
||||||
#### 配置与使用
|
#### 配置与使用
|
||||||
|
|
||||||
|
|
@ -162,6 +212,32 @@ gitlink-cli repo +list
|
||||||
# 查看仓库信息
|
# 查看仓库信息
|
||||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
||||||
|
|
||||||
|
# 读取仓库 README
|
||||||
|
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
|
||||||
|
|
||||||
|
# 查看语言占比
|
||||||
|
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 "项目描述"
|
gitlink-cli repo +create -n my-project -d "项目描述"
|
||||||
|
|
||||||
|
|
@ -186,6 +262,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
||||||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 成员管理
|
||||||
|
|
||||||
|
```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 管理
|
### Issue 管理
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -195,9 +293,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
|
||||||
# 创建 Issue
|
# 创建 Issue
|
||||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."
|
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
|
# 查看 Issue
|
||||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
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
|
# 关闭 Issue
|
||||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||||
|
|
||||||
|
|
@ -209,6 +313,44 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
|
||||||
|
|
||||||
# 添加评论
|
# 添加评论
|
||||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
|
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
|
### Pull Request
|
||||||
|
|
@ -229,6 +371,9 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
||||||
# 合并 PR
|
# 合并 PR
|
||||||
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
||||||
|
|
||||||
|
# 重开已关闭的 PR
|
||||||
|
gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
|
||||||
|
|
||||||
# 查看 PR 变更文件
|
# 查看 PR 变更文件
|
||||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
||||||
|
|
||||||
|
|
@ -259,6 +404,28 @@ gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.
|
||||||
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
|
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 流水线管理
|
||||||
|
|
||||||
|
```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
|
```bash
|
||||||
|
|
@ -280,6 +447,12 @@ gitlink-cli api GET /users/me
|
||||||
# POST 请求
|
# POST 请求
|
||||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
|
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'
|
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
||||||
```
|
```
|
||||||
|
|
@ -317,19 +490,21 @@ git push gitlink
|
||||||
|
|
||||||
## AI Agent Skills
|
## AI Agent Skills
|
||||||
|
|
||||||
`skills/` 目录包含 13 个 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 | 说明 |
|
| Skill | 说明 |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
|
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
|
||||||
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork 等) |
|
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork、洞察数据等) |
|
||||||
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
|
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
|
||||||
| `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) |
|
| `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) |
|
||||||
|
| `gitlink-member` | 仓库成员与邀请链接管理 |
|
||||||
| `gitlink-release` | 发布管理(创建、查看、删除等) |
|
| `gitlink-release` | 发布管理(创建、查看、删除等) |
|
||||||
| `gitlink-org` | 组织管理(成员、团队等) |
|
| `gitlink-org` | 组织管理(成员、团队等) |
|
||||||
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
|
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
|
||||||
|
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
|
||||||
| `gitlink-search` | 搜索功能(仓库、用户等) |
|
| `gitlink-search` | 搜索功能(仓库、用户等) |
|
||||||
| `gitlink-user` | 用户管理(个人信息等) |
|
| `gitlink-user` | 用户管理(个人信息等) |
|
||||||
| `gitlink-pm` | 项目管理(Sprint、看板、周报等) |
|
| `gitlink-pm` | 项目管理(Sprint、看板、周报等) |
|
||||||
|
|
@ -356,10 +531,12 @@ gitlink-cli/
|
||||||
│ ├── repo/ # 仓库 shortcuts
|
│ ├── repo/ # 仓库 shortcuts
|
||||||
│ ├── issue/ # Issue shortcuts
|
│ ├── issue/ # Issue shortcuts
|
||||||
│ ├── pr/ # PR shortcuts
|
│ ├── pr/ # PR shortcuts
|
||||||
|
│ ├── member/ # 仓库成员 shortcuts
|
||||||
│ ├── branch/ # 分支 shortcuts
|
│ ├── branch/ # 分支 shortcuts
|
||||||
│ ├── release/ # Release shortcuts
|
│ ├── release/ # Release shortcuts
|
||||||
│ ├── org/ # 组织 shortcuts
|
│ ├── org/ # 组织 shortcuts
|
||||||
│ ├── ci/ # CI shortcuts
|
│ ├── ci/ # CI shortcuts
|
||||||
|
│ ├── pipeline/ # Pipeline shortcuts
|
||||||
│ ├── search/ # 搜索 shortcuts
|
│ ├── search/ # 搜索 shortcuts
|
||||||
│ ├── user/ # 用户 shortcuts
|
│ ├── user/ # 用户 shortcuts
|
||||||
│ └── register.go # 注册入口
|
│ └── register.go # 注册入口
|
||||||
|
|
@ -440,6 +617,35 @@ npm install -g @gitlink-ai/cli
|
||||||
|
|
||||||
如果仍然失败,请检查 Release 页面是否包含当前平台的资产,例如 Windows x64 对应 `gitlink-cli_<version>_windows_amd64.zip`。也可以从 Release 页面手动下载二进制,或使用 `go install .` 从源码构建。
|
如果仍然失败,请检查 Release 页面是否包含当前平台的资产,例如 Windows x64 对应 `gitlink-cli_<version>_windows_amd64.zip`。也可以从 Release 页面手动下载二进制,或使用 `go install .` 从源码构建。
|
||||||
|
|
||||||
|
### Q: 为什么 `gitlink-cli` 在 PowerShell/CMD 可用,但在 Git Bash 或 Windows Agent Shell 里失败?
|
||||||
|
|
||||||
|
在 Windows 上,`npm install -g @gitlink-ai/cli` 会生成 npm shim。PowerShell 和 CMD 可以直接执行这些 shim,但部分基于 Git Bash 的 Agent Shell 不能正确处理。
|
||||||
|
|
||||||
|
这时请直接调用 Node 入口文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GITLINK_CLI="$(npm root -g)/@gitlink-ai/cli/bin/cli.js"
|
||||||
|
node "$GITLINK_CLI" user +info --login <login> --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `npm root -g` 不可用,先定位全局安装目录,再手动执行入口文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm list -g @gitlink-ai/cli --depth=0
|
||||||
|
node "/c/Program Files/nodejs/node_modules/@gitlink-ai/cli/bin/cli.js" user +info --login <login> --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
在 Skill 或 Agent 提示词中,建议同时写明这两种调用方式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PowerShell / CMD / macOS / Linux
|
||||||
|
gitlink-cli user +info --login <login> --format json
|
||||||
|
|
||||||
|
# Windows Git Bash 或不兼容 npm shim 的 Agent Shell
|
||||||
|
GITLINK_CLI="$(npm root -g)/@gitlink-ai/cli/bin/cli.js"
|
||||||
|
node "$GITLINK_CLI" user +info --login <login> --format json
|
||||||
|
```
|
||||||
|
|
||||||
### Q: Windows 上凭证存储在哪里?
|
### Q: Windows 上凭证存储在哪里?
|
||||||
|
|
||||||
gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。
|
gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。
|
||||||
|
|
|
||||||
106
cmd/api/api.go
106
cmd/api/api.go
|
|
@ -2,37 +2,70 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
"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{
|
apiCmd := &cobra.Command{
|
||||||
Use: "api <METHOD> <PATH>",
|
Use: "api (<METHOD> <PATH> | --batch-file <FILE>)",
|
||||||
Short: "Make raw API requests to GitLink",
|
Short: tr.T("cmd.api.short"),
|
||||||
Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`,
|
Long: tr.T("cmd.api.long"),
|
||||||
Example: ` gitlink-cli api GET /users/me
|
Example: ` gitlink-cli api GET /users/me
|
||||||
gitlink-cli api GET /projects --query 'page=1&limit=10'
|
gitlink-cli api GET /projects --query 'page=1&limit=10'
|
||||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`,
|
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
|
||||||
Args: cobra.ExactArgs(2),
|
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,
|
RunE: runAPI,
|
||||||
}
|
}
|
||||||
|
|
||||||
apiCmd.Flags().String("body", "", "Request body (JSON string)")
|
apiCmd.Flags().String("body", "", tr.T("flag.api.body"))
|
||||||
apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)")
|
apiCmd.Flags().String("body-file", "", tr.T("flag.api.body_file"))
|
||||||
apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)")
|
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
|
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 {
|
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])
|
method := strings.ToUpper(args[0])
|
||||||
path := args[1]
|
path := args[1]
|
||||||
|
|
||||||
|
|
@ -46,12 +79,9 @@ func runAPI(c *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
cli.Debug = cmdutil.Debug
|
cli.Debug = cmdutil.Debug
|
||||||
|
|
||||||
var body interface{}
|
body, err := readJSONBody(c)
|
||||||
bodyStr, _ := c.Flags().GetString("body")
|
if err != nil {
|
||||||
if bodyStr != "" {
|
return err
|
||||||
if err := json.Unmarshal([]byte(bodyStr), &body); err != nil {
|
|
||||||
return fmt.Errorf("invalid JSON body: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var query url.Values
|
var query url.Values
|
||||||
|
|
@ -66,7 +96,8 @@ func runAPI(c *cobra.Command, args []string) error {
|
||||||
|
|
||||||
env, err := cli.Do(method, path, body, query)
|
env, err := cli.Do(method, path, body, query)
|
||||||
if err != nil {
|
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, "")
|
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "")
|
||||||
return output.Print(errEnv, resolveFormat())
|
return output.Print(errEnv, resolveFormat())
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +107,49 @@ func runAPI(c *cobra.Command, args []string) error {
|
||||||
return output.Print(env, resolveFormat())
|
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 {
|
func resolveFormat() string {
|
||||||
f := cmdutil.Format
|
f := cmdutil.Format
|
||||||
if f == "" {
|
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 (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
|
||||||
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
|
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
const envTokenVar = "GITLINK_TOKEN"
|
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{
|
cmd := &cobra.Command{
|
||||||
Use: "auth",
|
Use: "auth",
|
||||||
Short: "Authentication commands",
|
Short: tr.T("cmd.auth.short"),
|
||||||
}
|
}
|
||||||
cmd.AddCommand(newLoginCmd())
|
cmd.AddCommand(newLoginCmd(tr))
|
||||||
cmd.AddCommand(newLogoutCmd())
|
cmd.AddCommand(newLogoutCmd(tr))
|
||||||
cmd.AddCommand(newStatusCmd())
|
cmd.AddCommand(newStatusCmd(tr))
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newLoginCmd() *cobra.Command {
|
func newLoginCmd(tr *i18n.Translator) *cobra.Command {
|
||||||
var tokenMode bool
|
var tokenMode bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "login",
|
Use: "login",
|
||||||
Short: "Login to GitLink",
|
Short: tr.T("cmd.auth.login.short"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if tokenMode {
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginWithPassword() error {
|
func loginWithPassword(in io.Reader, out io.Writer, tr *i18n.Translator) error {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(in)
|
||||||
|
if _, err := fmt.Fprint(out, tr.T("prompt.auth.username")); err != nil {
|
||||||
fmt.Print("Username/Email/Phone: ")
|
return err
|
||||||
|
}
|
||||||
username, _ := reader.ReadString('\n')
|
username, _ := reader.ReadString('\n')
|
||||||
username = strings.TrimSpace(username)
|
username = strings.TrimSpace(username)
|
||||||
|
|
||||||
fmt.Print("Password: ")
|
if _, err := fmt.Fprint(out, tr.T("prompt.auth.password")); err != nil {
|
||||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
return err
|
||||||
|
}
|
||||||
|
passwordBytes, err := readPassword(in, reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read password: %w", err)
|
return fmt.Errorf("failed to read password: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Println()
|
if _, err := fmt.Fprintln(out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
password := string(passwordBytes)
|
password := string(passwordBytes)
|
||||||
|
|
||||||
result, err := internalAuth.Login(username, password)
|
result, err := internalAuth.Login(username, password)
|
||||||
if err != nil {
|
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)
|
_, err = fmt.Fprintln(out, tr.Tf("success.auth.logged_in_as", i18n.Args{"login": result.Login}))
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginWithToken() error {
|
func readPassword(in io.Reader, reader *bufio.Reader) ([]byte, error) {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
if file, ok := in.(*os.File); ok {
|
||||||
fmt.Print("Paste your token: ")
|
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, _ := reader.ReadString('\n')
|
||||||
token = strings.TrimSpace(token)
|
token = strings.TrimSpace(token)
|
||||||
|
|
||||||
if 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 {
|
if err := storeToken(token); err != nil {
|
||||||
return fmt.Errorf("failed to store token: %w", err)
|
return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("✓ Token saved")
|
_, err := fmt.Fprintln(out, tr.T("success.auth.token_saved"))
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func newLogoutCmd() *cobra.Command {
|
func newLogoutCmd(tr *i18n.Translator) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "logout",
|
Use: "logout",
|
||||||
Short: "Logout from GitLink",
|
Short: tr.T("cmd.auth.logout.short"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := internalAuth.DeleteToken(); err != nil {
|
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")
|
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out"))
|
||||||
return nil
|
return err
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStatusCmd() *cobra.Command {
|
func newStatusCmd(tr *i18n.Translator) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "status",
|
Use: "status",
|
||||||
Short: "Show authentication status",
|
Short: tr.T("cmd.auth.status.short"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
out := cmd.OutOrStdout()
|
||||||
// Check env var token first
|
// Check env var token first
|
||||||
if envToken := os.Getenv(envTokenVar); envToken != "" {
|
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 err != nil || token == "" {
|
||||||
if os.Getenv(envTokenVar) == "" {
|
if os.Getenv(envTokenVar) == "" {
|
||||||
fmt.Println("✗ Not logged in")
|
if _, err := fmt.Fprintln(out, tr.T("warning.auth.not_logged_in")); err != nil {
|
||||||
fmt.Println(" Run: gitlink-cli auth login")
|
return err
|
||||||
fmt.Printf(" Or set %s environment variable\n", envTokenVar)
|
}
|
||||||
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := internalAuth.GetCurrentUser()
|
user, err := internalAuth.GetCurrentUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("✓ Token stored (but cannot verify: %v)\n", err)
|
_, err := fmt.Fprintln(out, tr.Tf("warning.auth.token_unverified", i18n.Args{"message": err.Error()}))
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
login, _ := user["login"].(string)
|
login, _ := user["login"].(string)
|
||||||
name, _ := user["name"].(string)
|
name, _ := user["name"].(string)
|
||||||
if login != "" {
|
if login != "" {
|
||||||
fmt.Printf("✓ Logged in as %s", login)
|
text := tr.Tf("success.auth.logged_in_as", i18n.Args{"login": login})
|
||||||
if name != "" {
|
if name != "" {
|
||||||
fmt.Printf(" (%s)", name)
|
text = fmt.Sprintf("%s (%s)", text, name)
|
||||||
}
|
}
|
||||||
fmt.Println()
|
_, err := fmt.Fprintln(out, text)
|
||||||
} else {
|
return err
|
||||||
fmt.Println("✓ Token stored (user info unavailable)")
|
|
||||||
}
|
}
|
||||||
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
|
Repo string
|
||||||
Format string
|
Format string
|
||||||
Debug bool
|
Debug bool
|
||||||
|
Lang string
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,80 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
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{
|
cmd := &cobra.Command{
|
||||||
Use: "config",
|
Use: "config",
|
||||||
Short: "Manage gitlink-cli configuration",
|
Short: tr.T("cmd.config.short"),
|
||||||
}
|
}
|
||||||
cmd.AddCommand(newInitCmd())
|
cmd.AddCommand(newInitCmd(tr))
|
||||||
cmd.AddCommand(newSetCmd())
|
cmd.AddCommand(newSetCmd(tr))
|
||||||
cmd.AddCommand(newGetCmd())
|
cmd.AddCommand(newGetCmd(tr))
|
||||||
cmd.AddCommand(newListCmd())
|
cmd.AddCommand(newListCmd(tr))
|
||||||
return cmd
|
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{
|
return &cobra.Command{
|
||||||
Use: "init",
|
Use: "init",
|
||||||
Short: "Initialize configuration file",
|
Short: tr.T("cmd.config.init.short"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg := internalConfig.DefaultConfig()
|
cfg := internalConfig.DefaultConfig()
|
||||||
if err := internalConfig.Save(cfg); err != nil {
|
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())
|
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.initialized", i18n.Args{"path": internalConfig.ConfigPath()}))
|
||||||
return nil
|
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{
|
return &cobra.Command{
|
||||||
Use: "set <key> <value>",
|
Use: "set <key> <value>",
|
||||||
Short: "Set a configuration value",
|
Short: tr.T("cmd.config.set.short"),
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := internalConfig.Set(args[0], args[1]); err != nil {
|
if err := internalConfig.Set(args[0], args[1]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("✓ %s = %s\n", args[0], args[1])
|
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.set", i18n.Args{
|
||||||
return nil
|
"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{
|
return &cobra.Command{
|
||||||
Use: "get <key>",
|
Use: "get <key>",
|
||||||
Short: "Get a configuration value",
|
Short: tr.T("cmd.config.get.short"),
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
val, err := internalConfig.Get(args[0])
|
val, err := internalConfig.Get(args[0])
|
||||||
|
|
@ -61,30 +82,49 @@ func newGetCmd() *cobra.Command {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if val == "" {
|
if val == "" {
|
||||||
fmt.Printf("%s: (not set)\n", args[0])
|
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], tr.T("output.config.not_set"))
|
||||||
} else {
|
return err
|
||||||
fmt.Printf("%s: %s\n", args[0], val)
|
|
||||||
}
|
}
|
||||||
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{
|
return &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List all configuration values",
|
Short: tr.T("cmd.config.list.short"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, err := internalConfig.Load()
|
cfg, err := internalConfig.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("base_url: %s\n", cfg.BaseURL)
|
out := cmd.OutOrStdout()
|
||||||
fmt.Printf("default_format: %s\n", cfg.Format)
|
if _, err := fmt.Fprintf(out, "base_url: %s\n", cfg.BaseURL); err != nil {
|
||||||
fmt.Printf("editor: %s\n", cfg.Editor)
|
return err
|
||||||
fmt.Printf("pager: %s\n", cfg.Pager)
|
}
|
||||||
fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath())
|
if _, err := fmt.Fprintf(out, "default_format: %s\n", cfg.Format); err != nil {
|
||||||
return 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
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"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"
|
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"
|
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"
|
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
type RootOptions struct {
|
||||||
Use: "gitlink-cli",
|
Version string
|
||||||
Short: "GitLink CLI — command-line tool for gitlink.org.cn",
|
Args []string
|
||||||
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.`,
|
Env map[string]string
|
||||||
SilenceUsage: true,
|
ConfigLang string
|
||||||
SilenceErrors: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
|
||||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)")
|
if tr == nil {
|
||||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)")
|
var err error
|
||||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)")
|
tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang)
|
||||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rootCmd.AddCommand(authCmd.NewAuthCmd())
|
version := opts.Version
|
||||||
rootCmd.AddCommand(apiCmd.NewAPICmd())
|
if version == "" {
|
||||||
rootCmd.AddCommand(configCmd.NewConfigCmd())
|
version = Version
|
||||||
rootCmd.AddCommand(versionCmd)
|
}
|
||||||
|
|
||||||
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{
|
func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command {
|
||||||
Use: "version",
|
return &cobra.Command{
|
||||||
Short: "Print version information",
|
Use: "version",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Short: tr.T("cmd.version.short"),
|
||||||
fmt.Printf("gitlink-cli %s\n", Version)
|
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 {
|
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 {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
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,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,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,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,11 @@
|
||||||
|
# Windows Agent Shell 调用说明
|
||||||
|
|
||||||
|
在 Windows 上通过 `npm install -g @gitlink-ai/cli` 安装 CLI 后,PowerShell 和 CMD 可以直接执行 npm 生成的 shim,但部分基于 Git Bash 的 Agent Shell 不能正确处理 `.cmd` 包装器,导致 `gitlink-cli` 命令执行失败。
|
||||||
|
|
||||||
|
本次更新不修改 Go 主程序,而是补充稳定的调用约定:
|
||||||
|
|
||||||
|
- PowerShell / CMD / macOS / Linux 继续直接使用 `gitlink-cli ...`
|
||||||
|
- Windows Git Bash 或 shim 不兼容的 Agent Shell 改为通过 Node 直接执行 `@gitlink-ai/cli/bin/cli.js`
|
||||||
|
- 在 `README`、`README.zh-CN` 和 `skills/gitlink-shared/SKILL.md` 中都增加了可复制的 fallback 命令
|
||||||
|
|
||||||
|
这样做可以让 Skill、Agent 提示词和人工排障文档对齐,避免 Windows 上 “PowerShell 可用、Git Bash 失败” 的调用差异继续反复出现。
|
||||||
|
|
@ -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,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()
|
||||||
11
go.mod
11
go.mod
|
|
@ -5,16 +5,27 @@ go 1.26.1
|
||||||
require (
|
require (
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/zalando/go-keyring v0.2.8
|
github.com/zalando/go-keyring v0.2.8
|
||||||
|
golang.org/x/sync v0.20.0
|
||||||
golang.org/x/term v0.41.0
|
golang.org/x/term v0.41.0
|
||||||
|
golang.org/x/time v0.15.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.50.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||||
|
modernc.org/libc v1.72.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
51
go.sum
51
go.sum
|
|
@ -4,17 +4,31 @@ github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMF
|
||||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
|
@ -30,12 +44,49 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
|
||||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||||
|
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ func Login(username, password string) (*LoginResult, error) {
|
||||||
if _, verifyErr := GetCurrentUser(); verifyErr != nil {
|
if _, verifyErr := GetCurrentUser(); verifyErr != nil {
|
||||||
// Clean up the bad token
|
// Clean up the bad token
|
||||||
_ = DeleteToken()
|
_ = DeleteToken()
|
||||||
return nil, fmt.Errorf("login failed: credentials not accepted by API (%v)", verifyErr)
|
return nil, fmt.Errorf("login failed: credentials not accepted by API (%w)", verifyErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &result, nil
|
return &result, nil
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/zalando/go-keyring"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupConfigDir(t *testing.T, baseURL string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||||
|
// Write a minimal config
|
||||||
|
cfgDir := filepath.Join(dir)
|
||||||
|
os.MkdirAll(cfgDir, 0700)
|
||||||
|
os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte("base_url: "+baseURL+"\ndefault_format: table\n"), 0600)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCurrentUserSuccess(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(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",
|
||||||
|
"name": "Test User",
|
||||||
|
"id": 42,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
setupConfigDir(t, server.URL)
|
||||||
|
// Need to prevent any cookie/token auth from interfering
|
||||||
|
os.Unsetenv("GITLINK_TOKEN")
|
||||||
|
|
||||||
|
user, err := GetCurrentUser()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCurrentUser error: %v", err)
|
||||||
|
}
|
||||||
|
if user["login"] != "testuser" {
|
||||||
|
t.Fatalf("login = %q, want testuser", user["login"])
|
||||||
|
}
|
||||||
|
if user["name"] != "Test User" {
|
||||||
|
t.Fatalf("name = %q", user["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCurrentUserHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte("unauthorized"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
setupConfigDir(t, server.URL)
|
||||||
|
os.Unsetenv("GITLINK_TOKEN")
|
||||||
|
|
||||||
|
_, err := GetCurrentUser()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 401")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCurrentUserStatusError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": float64(-1),
|
||||||
|
"message": "Token invalid",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
setupConfigDir(t, server.URL)
|
||||||
|
os.Unsetenv("GITLINK_TOKEN")
|
||||||
|
|
||||||
|
_, err := GetCurrentUser()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for status=-1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCurrentUserMissingLogin(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"id": 42,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
setupConfigDir(t, server.URL)
|
||||||
|
os.Unsetenv("GITLINK_TOKEN")
|
||||||
|
|
||||||
|
_, err := GetCurrentUser()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when login field is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSuccess(t *testing.T) {
|
||||||
|
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("HOME", dir)
|
||||||
|
t.Setenv("GITLINK_TOKEN", "")
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "POST" && r.URL.Path == "/accounts/login.json":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Set-Cookie", "autologin_trustie=sess123; Path=/")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"username": "testuser",
|
||||||
|
"login": "testuser",
|
||||||
|
"user_id": 42,
|
||||||
|
"token": "tok123",
|
||||||
|
})
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/users/me.json":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"login": "testuser",
|
||||||
|
"name": "Test User",
|
||||||
|
"id": float64(42),
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
setupConfigDir(t, server.URL)
|
||||||
|
|
||||||
|
result, err := Login("testuser", "password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Username != "testuser" {
|
||||||
|
t.Fatalf("Username = %q, want testuser", result.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginStatusError(t *testing.T) {
|
||||||
|
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": float64(-1),
|
||||||
|
"message": "Invalid credentials",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
setupConfigDir(t, server.URL)
|
||||||
|
|
||||||
|
_, err := Login("testuser", "wrongpass")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginNoCookies(t *testing.T) {
|
||||||
|
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("HOME", dir)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"username": "testuser",
|
||||||
|
"login": "testuser",
|
||||||
|
"user_id": 42,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
setupConfigDir(t, server.URL)
|
||||||
|
|
||||||
|
_, err := Login("testuser", "password")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no auth cookies")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||||
"github.com/zalando/go-keyring"
|
"github.com/zalando/go-keyring"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,8 +42,7 @@ func DeleteToken() error {
|
||||||
// File-based fallback
|
// File-based fallback
|
||||||
|
|
||||||
func credentialPath() string {
|
func credentialPath() string {
|
||||||
home, _ := os.UserHomeDir()
|
return filepath.Join(config.ConfigDir(), "credentials")
|
||||||
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func storeTokenFile(token string) error {
|
func storeTokenFile(token string) error {
|
||||||
|
|
@ -62,5 +62,9 @@ func loadTokenFile() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteTokenFile() error {
|
func deleteTokenFile() error {
|
||||||
return os.Remove(credentialPath())
|
err := os.Remove(credentialPath())
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/zalando/go-keyring"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tempHome(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("HOME", dir)
|
||||||
|
t.Setenv("USERPROFILE", dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func tempConfigDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadDeleteTokenFile(t *testing.T) {
|
||||||
|
tempConfigDir(t)
|
||||||
|
|
||||||
|
// First, delete any existing token
|
||||||
|
_ = deleteTokenFile()
|
||||||
|
|
||||||
|
// Initially, loading should fail
|
||||||
|
_, err := loadTokenFile()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error loading non-existent token file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store a token
|
||||||
|
if err := storeTokenFile("test-token-123"); err != nil {
|
||||||
|
t.Fatalf("storeTokenFile error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load it back
|
||||||
|
token, err := loadTokenFile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadTokenFile error: %v", err)
|
||||||
|
}
|
||||||
|
if token != "test-token-123" {
|
||||||
|
t.Fatalf("token = %q, want test-token-123", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete it
|
||||||
|
if err := deleteTokenFile(); err != nil {
|
||||||
|
t.Fatalf("deleteTokenFile error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now loading should fail again
|
||||||
|
_, err = loadTokenFile()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error after delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialPath(t *testing.T) {
|
||||||
|
dir := tempConfigDir(t)
|
||||||
|
got := credentialPath()
|
||||||
|
expected := filepath.Join(dir, "credentials")
|
||||||
|
if got != expected {
|
||||||
|
t.Fatalf("credentialPath = %q, want %q", got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialPathDefaultsToConfigDir(t *testing.T) {
|
||||||
|
home := tempHome(t)
|
||||||
|
t.Setenv("GITLINK_CONFIG_DIR", "")
|
||||||
|
|
||||||
|
got := credentialPath()
|
||||||
|
expected := filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
||||||
|
if got != expected {
|
||||||
|
t.Fatalf("credentialPath = %q, want %q", got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreTokenFileCreatesDir(t *testing.T) {
|
||||||
|
dir := tempConfigDir(t)
|
||||||
|
_ = deleteTokenFile()
|
||||||
|
|
||||||
|
// Config dir shouldn't exist yet
|
||||||
|
credDir := dir
|
||||||
|
os.RemoveAll(credDir)
|
||||||
|
|
||||||
|
if err := storeTokenFile("new-token"); err != nil {
|
||||||
|
t.Fatalf("storeTokenFile error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file exists and has content
|
||||||
|
data, err := os.ReadFile(filepath.Join(credDir, "credentials"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read error: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "new-token" {
|
||||||
|
t.Fatalf("file content = %q, want new-token", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteTokenFileNonExistent(t *testing.T) {
|
||||||
|
tempConfigDir(t)
|
||||||
|
_ = deleteTokenFile()
|
||||||
|
// Logout-style cleanup should be idempotent when fallback credentials do not exist.
|
||||||
|
if err := deleteTokenFile(); err != nil {
|
||||||
|
t.Fatalf("deleteTokenFile error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadTokenFileEmpty(t *testing.T) {
|
||||||
|
tempConfigDir(t)
|
||||||
|
_ = deleteTokenFile()
|
||||||
|
|
||||||
|
if err := storeTokenFile(""); err != nil {
|
||||||
|
t.Fatalf("storeTokenFile empty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := loadTokenFile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadTokenFile error: %v", err)
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
t.Fatalf("token = %q, want empty", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreTokenFallback(t *testing.T) {
|
||||||
|
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||||
|
dir := tempConfigDir(t)
|
||||||
|
_ = deleteTokenFile()
|
||||||
|
|
||||||
|
if err := StoreToken("keychain-fallback-token"); err != nil {
|
||||||
|
t.Fatalf("StoreToken error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "credentials"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read error: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "keychain-fallback-token" {
|
||||||
|
t.Fatalf("file content = %q, want keychain-fallback-token", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteTokenFallback(t *testing.T) {
|
||||||
|
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||||
|
dir := tempConfigDir(t)
|
||||||
|
_ = deleteTokenFile()
|
||||||
|
|
||||||
|
p := filepath.Join(dir, "credentials")
|
||||||
|
os.MkdirAll(filepath.Dir(p), 0700)
|
||||||
|
os.WriteFile(p, []byte("delete-me"), 0600)
|
||||||
|
|
||||||
|
if err := DeleteToken(); err != nil {
|
||||||
|
t.Fatalf("DeleteToken error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := os.Stat(p)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Fatal("file should be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTransportCookieAuth(t *testing.T) {
|
||||||
|
// Mock an HTTP server that checks for the Cookie header
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie := r.Header.Get("Cookie")
|
||||||
|
if cookie == "" {
|
||||||
|
t.Error("expected Cookie header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the request has the right Accept header
|
||||||
|
if r.Header.Get("Accept") != "application/json" {
|
||||||
|
t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept"))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
transport := &Transport{Base: http.DefaultTransport}
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
|
||||||
|
// Set a cookie-based token
|
||||||
|
os.Setenv("GITLINK_TOKEN", "cookie:autologin_trustie=test123")
|
||||||
|
defer os.Unsetenv("GITLINK_TOKEN")
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransportTokenAuth(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("access_token") == "" {
|
||||||
|
t.Error("expected access_token query parameter")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Header.Get("Accept") != "application/json" {
|
||||||
|
t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept"))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
transport := &Transport{Base: http.DefaultTransport}
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
|
||||||
|
os.Setenv("GITLINK_TOKEN", "private-token-abc")
|
||||||
|
defer os.Unsetenv("GITLINK_TOKEN")
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransportCookieAppend(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie := r.Header.Get("Cookie")
|
||||||
|
if cookie == "" {
|
||||||
|
t.Error("expected Cookie header")
|
||||||
|
}
|
||||||
|
// Should contain both original and injected cookies
|
||||||
|
if cookie != "existing=val; autologin_trustie=injected" {
|
||||||
|
t.Errorf("Cookie = %q, want 'existing=val; autologin_trustie=injected'", cookie)
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
transport := &Transport{Base: http.DefaultTransport}
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
|
||||||
|
os.Setenv("GITLINK_TOKEN", "cookie:autologin_trustie=injected")
|
||||||
|
defer os.Unsetenv("GITLINK_TOKEN")
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||||
|
req.Header.Set("Cookie", "existing=val")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransportNoToken(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Accept") != "application/json" {
|
||||||
|
t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept"))
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
transport := &Transport{Base: http.DefaultTransport}
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
|
||||||
|
// Force empty env and redirect HOME to avoid keychain fallback
|
||||||
|
os.Unsetenv("GITLINK_TOKEN")
|
||||||
|
oldHome := os.Getenv("HOME")
|
||||||
|
tempHome := t.TempDir()
|
||||||
|
os.Setenv("HOME", tempHome)
|
||||||
|
defer os.Setenv("HOME", oldHome)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransportDefaultContentType(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Content-Type") != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", r.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
transport := &Transport{Base: http.DefaultTransport}
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
|
||||||
|
// Transport only sets Content-Type when Body is non-nil
|
||||||
|
body := strings.NewReader(`{"key":"val"}`)
|
||||||
|
req, _ := http.NewRequest("POST", server.URL, body)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransportExplicitContentType(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Should preserve explicit Content-Type
|
||||||
|
if r.Header.Get("Content-Type") != "text/plain" {
|
||||||
|
t.Errorf("Content-Type = %q, want text/plain", r.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
transport := &Transport{Base: http.DefaultTransport}
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", server.URL, nil)
|
||||||
|
req.Header.Set("Content-Type", "text/plain")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewHTTPClient(t *testing.T) {
|
||||||
|
client := NewHTTPClient()
|
||||||
|
if client == nil {
|
||||||
|
t.Fatal("expected non-nil client")
|
||||||
|
}
|
||||||
|
if client.Transport == nil {
|
||||||
|
t.Fatal("expected Transport to be set")
|
||||||
|
}
|
||||||
|
if _, ok := client.Transport.(*Transport); !ok {
|
||||||
|
t.Fatalf("expected *Transport, got %T", client.Transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransportEnvVarPriority(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("access_token") != "env-token" {
|
||||||
|
t.Errorf("access_token = %q, want env-token", r.URL.Query().Get("access_token"))
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
transport := &Transport{Base: http.DefaultTransport}
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
|
||||||
|
os.Setenv("GITLINK_TOKEN", "env-token")
|
||||||
|
defer os.Unsetenv("GITLINK_TOKEN")
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransportNilBase(t *testing.T) {
|
||||||
|
// When Base is nil, it should use http.DefaultTransport
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
transport := &Transport{Base: nil}
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
@ -42,19 +42,21 @@ func New() (*Client, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
|
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
|
||||||
|
path = normalizeAPIPath(c.BaseURL, path)
|
||||||
|
|
||||||
// Append .json suffix if not already present (GitLink API convention)
|
// Append .json suffix if not already present (GitLink API convention)
|
||||||
// Handle paths that may already contain query strings (e.g., /path?key=val)
|
// Handle paths that may already contain query strings (e.g., /path?key=val)
|
||||||
if idx := strings.Index(path, "?"); idx != -1 {
|
if idx := strings.Index(path, "?"); idx != -1 {
|
||||||
basePath := path[:idx]
|
basePath := path[:idx]
|
||||||
queryStr := path[idx:]
|
queryStr := path[idx:]
|
||||||
if !strings.HasSuffix(basePath, ".json") {
|
if shouldAppendJSONSuffix(basePath) {
|
||||||
path = basePath + ".json" + queryStr
|
path = basePath + ".json" + queryStr
|
||||||
}
|
}
|
||||||
} else if !strings.HasSuffix(path, ".json") {
|
} else if shouldAppendJSONSuffix(path) {
|
||||||
path += ".json"
|
path += ".json"
|
||||||
}
|
}
|
||||||
fullURL := c.BaseURL + path
|
fullURL := c.BaseURL + path
|
||||||
if query != nil && len(query) > 0 {
|
if len(query) > 0 {
|
||||||
sep := "?"
|
sep := "?"
|
||||||
if strings.Contains(fullURL, "?") {
|
if strings.Contains(fullURL, "?") {
|
||||||
sep = "&"
|
sep = "&"
|
||||||
|
|
@ -158,6 +160,31 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
||||||
return output.SuccessEnvelope(raw, meta), nil
|
return output.SuccessEnvelope(raw, meta), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldAppendJSONSuffix(path string) bool {
|
||||||
|
if strings.HasSuffix(path, ".json") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "raw" && i >= 2 && i+2 < len(parts) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAPIPath(baseURL, path string) string {
|
||||||
|
if strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api") {
|
||||||
|
switch {
|
||||||
|
case path == "/api":
|
||||||
|
return ""
|
||||||
|
case strings.HasPrefix(path, "/api/"):
|
||||||
|
return strings.TrimPrefix(path, "/api")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
|
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
|
||||||
return c.Do("GET", path, nil, query)
|
return c.Do("GET", path, nil, query)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,527 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIError(t *testing.T) {
|
||||||
|
err := &APIError{StatusCode: 404, Code: "not_found", Message: "PR not found"}
|
||||||
|
if err.Error() != "[not_found] PR not found" {
|
||||||
|
t.Fatalf("Error() = %q, want %q", err.Error(), "[not_found] PR not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuggestFix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
code int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{401, "请先运行 gitlink-cli auth login 登录"},
|
||||||
|
{403, "权限不足,请确认账户权限或联系项目管理员"},
|
||||||
|
{404, "资源不存在,请检查 owner/repo/id 是否正确"},
|
||||||
|
{422, "参数校验失败,请检查请求参数"},
|
||||||
|
{500, ""},
|
||||||
|
{0, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(http.StatusText(tt.code), func(t *testing.T) {
|
||||||
|
if got := suggestFix(tt.code); got != tt.want {
|
||||||
|
t.Fatalf("suggestFix(%d) = %q, want %q", tt.code, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoSuccess(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"ok":true,"data":{"key":"value"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
env, err := c.Do("GET", "/api/test", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !env.OK {
|
||||||
|
t.Fatal("expected OK=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoJSONSuffix(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/test.json" {
|
||||||
|
t.Fatalf("expected path /api/test.json, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
_, err := c.Do("GET", "/api/test", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoJSONSuffixPreserved(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/test.json" {
|
||||||
|
t.Fatalf("expected path /api/test.json, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
_, err := c.Do("GET", "/api/test.json", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoQueryParams(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("state") != "open" {
|
||||||
|
t.Fatalf("expected state=open, got %s", r.URL.Query().Get("state"))
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("page") != "1" {
|
||||||
|
t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("state", "open")
|
||||||
|
q.Set("page", "1")
|
||||||
|
_, err := c.Do("GET", "/api/test", nil, q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte("not found"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
_, err := c.Do("GET", "/api/test", nil, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 404")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 404 {
|
||||||
|
t.Fatalf("StatusCode = %d, want 404", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoNonJSON(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("plain text response"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
env, err := c.Do("GET", "/api/test", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !env.OK {
|
||||||
|
t.Fatal("expected OK=true for non-JSON response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoStatusError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"status":403,"message":"Forbidden"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
env, err := c.Do("GET", "/api/test", nil, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for status=403")
|
||||||
|
}
|
||||||
|
if env == nil {
|
||||||
|
t.Fatal("expected envelope for status error")
|
||||||
|
}
|
||||||
|
if env.OK {
|
||||||
|
t.Fatal("expected OK=false for status error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoStatusZero(t *testing.T) {
|
||||||
|
// status=0, 200, 1 are treated as success
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"status":0,"data":"ok"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
env, err := c.Do("GET", "/api/test", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !env.OK {
|
||||||
|
t.Fatal("expected OK=true for status=0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoPaginationMeta(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"total_count":100,"page":1,"limit":20,"data":[]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
env, err := c.Do("GET", "/api/test", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if env.Meta == nil {
|
||||||
|
t.Fatal("expected Meta to be populated")
|
||||||
|
}
|
||||||
|
if env.Meta.TotalCount != 100 {
|
||||||
|
t.Fatalf("TotalCount = %d, want 100", env.Meta.TotalCount)
|
||||||
|
}
|
||||||
|
if env.Meta.Page != 1 {
|
||||||
|
t.Fatalf("Page = %d, want 1", env.Meta.Page)
|
||||||
|
}
|
||||||
|
if env.Meta.Limit != 20 {
|
||||||
|
t.Fatalf("Limit = %d, want 20", env.Meta.Limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoPathWithQuery(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// The path query param should be preserved
|
||||||
|
if r.URL.Query().Get("filepath") != "test.go" {
|
||||||
|
t.Fatalf("expected filepath=test.go, got %s", r.URL.Query().Get("filepath"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
_, err := c.Do("GET", "/api/sub_entries?filepath=test.go", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoPathWithQueryAndExtraParams(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("ref") != "master" {
|
||||||
|
t.Fatalf("expected ref=master, got %s", r.URL.Query().Get("ref"))
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("filepath") != "test.go" {
|
||||||
|
t.Fatalf("expected filepath=test.go, got %s", r.URL.Query().Get("filepath"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("ref", "master")
|
||||||
|
_, err := c.Do("GET", "/api/sub_entries?filepath=test.go", nil, q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoWithBody(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
t.Fatalf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"id":123}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
env, err := c.Do("POST", "/api/create", map[string]string{"title": "test"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !env.OK {
|
||||||
|
t.Fatal("expected OK=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientGet(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
t.Fatalf("expected GET, got %s", r.Method)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
_, err := c.Get("/api/test", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientPost(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
t.Fatalf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
_, err := c.Post("/api/test", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientPut(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "PUT" {
|
||||||
|
t.Fatalf("expected PUT, got %s", r.Method)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
_, err := c.Put("/api/test", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDelete(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "DELETE" {
|
||||||
|
t.Fatalf("expected DELETE, got %s", r.Method)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
_, err := c.Delete("/api/test", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoInvalidURL(t *testing.T) {
|
||||||
|
c := &Client{HTTP: &http.Client{}, BaseURL: "://invalid"}
|
||||||
|
_, err := c.Do("GET", "/api/test", nil, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDebug(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL, Debug: true}
|
||||||
|
_, err := c.Do("GET", "/api/test", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDoStatusInt(t *testing.T) {
|
||||||
|
// Some APIs return status as int, not float64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"status":404,"message":"Not Found"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
_, err := c.Do("GET", "/api/test", nil, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for status=404 (int)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientNew(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||||
|
cfgPath := filepath.Join(dir, "config.yaml")
|
||||||
|
os.WriteFile(cfgPath, []byte("base_url: https://gitlink.example.com/api/v1\n"), 0644)
|
||||||
|
|
||||||
|
cli, err := New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New error: %v", err)
|
||||||
|
}
|
||||||
|
if cli.BaseURL != "https://gitlink.example.com/api/v1" {
|
||||||
|
t.Fatalf("BaseURL = %q", cli.BaseURL)
|
||||||
|
}
|
||||||
|
if cli.HTTP == nil {
|
||||||
|
t.Fatal("HTTP client is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginateAllSinglePage(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"data":[{"id":1},{"id":2}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
items, err := c.PaginateAll("/test", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PaginateAll error: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("expected 2 items, got %d", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginateAllMultiPage(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
page := r.URL.Query().Get("page")
|
||||||
|
if page == "1" {
|
||||||
|
w.Write([]byte(`{"data":[{"id":1},{"id":2}]}`))
|
||||||
|
} else {
|
||||||
|
w.Write([]byte(`{"data":[{"id":3}]}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("limit", "2")
|
||||||
|
items, err := c.PaginateAll("/test", params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PaginateAll error: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 3 {
|
||||||
|
t.Fatalf("expected 3 items, got %d", len(items))
|
||||||
|
}
|
||||||
|
if callCount != 2 {
|
||||||
|
t.Fatalf("expected 2 API calls, got %d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginateAllWrappedData(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"data":[{"id":1},{"id":2}],"total_count":2}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
items, err := c.PaginateAll("/test", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PaginateAll error: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("expected 2 items, got %d", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginateAllSingleObject(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"name":"single-object"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
items, err := c.PaginateAll("/test", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PaginateAll error: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("expected 1 item, got %d", len(items))
|
||||||
|
}
|
||||||
|
var data map[string]interface{}
|
||||||
|
json.Unmarshal(items[0], &data)
|
||||||
|
if data["name"] != "single-object" {
|
||||||
|
t.Fatalf("unexpected data: %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginateAllHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
_, err := c.PaginateAll("/test", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 500 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginateAllNotOK(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"status":500,"message":"error"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
_, err := c.PaginateAll("/test", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when envelope ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldAppendJSONSuffixSkipsRawFilePath(t *testing.T) {
|
||||||
|
if shouldAppendJSONSuffix("/Gitlink/forgeplus/raw/master/README.md") {
|
||||||
|
t.Fatal("raw file path should not get .json suffix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldAppendJSONSuffixKeepsRawRepositoryName(t *testing.T) {
|
||||||
|
if !shouldAppendJSONSuffix("/users/raw/projects") {
|
||||||
|
t.Fatal("regular API path should get .json suffix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldAppendJSONSuffixSkipsExistingJSONPath(t *testing.T) {
|
||||||
|
if shouldAppendJSONSuffix("/projects.json") {
|
||||||
|
t.Fatal("existing .json path should not get another suffix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ type Config struct {
|
||||||
Format string `yaml:"default_format"`
|
Format string `yaml:"default_format"`
|
||||||
Editor string `yaml:"editor,omitempty"`
|
Editor string `yaml:"editor,omitempty"`
|
||||||
Pager string `yaml:"pager,omitempty"`
|
Pager string `yaml:"pager,omitempty"`
|
||||||
|
Lang string `yaml:"lang,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() *Config {
|
func DefaultConfig() *Config {
|
||||||
|
|
@ -85,6 +86,8 @@ func Get(key string) (string, error) {
|
||||||
return cfg.Editor, nil
|
return cfg.Editor, nil
|
||||||
case "pager":
|
case "pager":
|
||||||
return cfg.Pager, nil
|
return cfg.Pager, nil
|
||||||
|
case "lang":
|
||||||
|
return cfg.Lang, nil
|
||||||
default:
|
default:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +107,8 @@ func Set(key, value string) error {
|
||||||
cfg.Editor = value
|
cfg.Editor = value
|
||||||
case "pager":
|
case "pager":
|
||||||
cfg.Pager = value
|
cfg.Pager = value
|
||||||
|
case "lang":
|
||||||
|
cfg.Lang = value
|
||||||
}
|
}
|
||||||
return Save(cfg)
|
return Save(cfg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tempConfigDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if cfg.BaseURL != DefaultBaseURL {
|
||||||
|
t.Fatalf("BaseURL = %q, want %q", cfg.BaseURL, DefaultBaseURL)
|
||||||
|
}
|
||||||
|
if cfg.Format != DefaultFormat {
|
||||||
|
t.Fatalf("Format = %q, want %q", cfg.Format, DefaultFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigDirEnv(t *testing.T) {
|
||||||
|
dir := tempConfigDir(t)
|
||||||
|
if got := ConfigDir(); got != dir {
|
||||||
|
t.Fatalf("ConfigDir = %q, want %q", got, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigDirDefault(t *testing.T) {
|
||||||
|
// Without GITLINK_CONFIG_DIR set, should use $HOME/.config/gitlink-cli
|
||||||
|
t.Setenv("GITLINK_CONFIG_DIR", "")
|
||||||
|
got := ConfigDir()
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if !strings.Contains(got, ".config") && !strings.Contains(got, "gitlink-cli") {
|
||||||
|
t.Fatalf("ConfigDir = %q, expected path under home", got)
|
||||||
|
}
|
||||||
|
if home != "" && !strings.HasPrefix(got, home) {
|
||||||
|
t.Fatalf("ConfigDir = %q, expected to start with home %q", got, home)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigPath(t *testing.T) {
|
||||||
|
dir := tempConfigDir(t)
|
||||||
|
got := ConfigPath()
|
||||||
|
want := filepath.Join(dir, "config.yaml")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ConfigPath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAndSave(t *testing.T) {
|
||||||
|
tempConfigDir(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.BaseURL = "https://custom.example.com/api"
|
||||||
|
cfg.Format = "json"
|
||||||
|
cfg.Editor = "vim"
|
||||||
|
cfg.Pager = "less"
|
||||||
|
|
||||||
|
if err := Save(cfg); err != nil {
|
||||||
|
t.Fatalf("Save error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.BaseURL != "https://custom.example.com/api" {
|
||||||
|
t.Fatalf("BaseURL = %q", loaded.BaseURL)
|
||||||
|
}
|
||||||
|
if loaded.Format != "json" {
|
||||||
|
t.Fatalf("Format = %q", loaded.Format)
|
||||||
|
}
|
||||||
|
if loaded.Editor != "vim" {
|
||||||
|
t.Fatalf("Editor = %q", loaded.Editor)
|
||||||
|
}
|
||||||
|
if loaded.Pager != "less" {
|
||||||
|
t.Fatalf("Pager = %q", loaded.Pager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaultsWhenFileMissing(t *testing.T) {
|
||||||
|
tempConfigDir(t)
|
||||||
|
// No config file exists
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.BaseURL != DefaultBaseURL {
|
||||||
|
t.Fatalf("BaseURL = %q, want default", cfg.BaseURL)
|
||||||
|
}
|
||||||
|
if cfg.Format != DefaultFormat {
|
||||||
|
t.Fatalf("Format = %q, want default", cfg.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEmptyValuesFallbackToDefaults(t *testing.T) {
|
||||||
|
dir := tempConfigDir(t)
|
||||||
|
// Write config with empty values
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("base_url: \"\"\ndefault_format: \"\"\n"), 0600); err != nil {
|
||||||
|
t.Fatalf("write error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.BaseURL != DefaultBaseURL {
|
||||||
|
t.Fatalf("BaseURL = %q, want default", cfg.BaseURL)
|
||||||
|
}
|
||||||
|
if cfg.Format != DefaultFormat {
|
||||||
|
t.Fatalf("Format = %q, want default", cfg.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet(t *testing.T) {
|
||||||
|
tempConfigDir(t)
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.BaseURL = "https://get.example.com/api"
|
||||||
|
if err := Save(cfg); err != nil {
|
||||||
|
t.Fatalf("Save error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
key string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"base_url", "https://get.example.com/api"},
|
||||||
|
{"default_format", "table"},
|
||||||
|
{"editor", ""},
|
||||||
|
{"pager", ""},
|
||||||
|
{"unknown_key", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key, func(t *testing.T) {
|
||||||
|
got, err := Get(tt.key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("Get(%q) = %q, want %q", tt.key, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSet(t *testing.T) {
|
||||||
|
tempConfigDir(t)
|
||||||
|
// First save defaults
|
||||||
|
if err := Save(DefaultConfig()); err != nil {
|
||||||
|
t.Fatalf("Save error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Set("base_url", "https://set.example.com/api"); err != nil {
|
||||||
|
t.Fatalf("Set base_url error: %v", err)
|
||||||
|
}
|
||||||
|
if err := Set("editor", "nano"); err != nil {
|
||||||
|
t.Fatalf("Set editor error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Get reads updated values
|
||||||
|
baseURL, _ := Get("base_url")
|
||||||
|
if baseURL != "https://set.example.com/api" {
|
||||||
|
t.Fatalf("Get base_url = %q", baseURL)
|
||||||
|
}
|
||||||
|
editor, _ := Get("editor")
|
||||||
|
if editor != "nano" {
|
||||||
|
t.Fatalf("Get editor = %q", editor)
|
||||||
|
}
|
||||||
|
// default_format should still be default
|
||||||
|
format, _ := Get("default_format")
|
||||||
|
if format != DefaultFormat {
|
||||||
|
t.Fatalf("Get default_format = %q", format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetUnknownKey(t *testing.T) {
|
||||||
|
tempConfigDir(t)
|
||||||
|
if err := Save(DefaultConfig()); err != nil {
|
||||||
|
t.Fatalf("Save error: %v", err)
|
||||||
|
}
|
||||||
|
// Setting unknown key should not error, just silently ignored
|
||||||
|
if err := Set("nonexistent", "value"); err != nil {
|
||||||
|
t.Fatalf("Set nonexistent error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveCreatesDir(t *testing.T) {
|
||||||
|
// Use a subdirectory that doesn't exist yet
|
||||||
|
dir := filepath.Join(t.TempDir(), "new", "subdir")
|
||||||
|
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.BaseURL = "https://test.example.com/api"
|
||||||
|
if err := Save(cfg); err != nil {
|
||||||
|
t.Fatalf("Save error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it was actually saved
|
||||||
|
loaded, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load error: %v", err)
|
||||||
|
}
|
||||||
|
if loaded.BaseURL != "https://test.example.com/api" {
|
||||||
|
t.Fatalf("BaseURL = %q", loaded.BaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadInvalidYAML(t *testing.T) {
|
||||||
|
dir := tempConfigDir(t)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("::: invalid yaml :::"), 0600); err != nil {
|
||||||
|
t.Fatalf("write error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := Load()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid YAML")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRemoteURLHTTPS(t *testing.T) {
|
||||||
|
owner, repo, err := parseRemoteURL("https://www.gitlink.org.cn/Gitlink/gitlink-cli.git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if owner != "Gitlink" {
|
||||||
|
t.Fatalf("owner = %q, want Gitlink", owner)
|
||||||
|
}
|
||||||
|
if repo != "gitlink-cli" {
|
||||||
|
t.Fatalf("repo = %q, want gitlink-cli", repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRemoteURLHTTPSNoGit(t *testing.T) {
|
||||||
|
owner, repo, err := parseRemoteURL("https://www.gitlink.org.cn/owner/repo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if owner != "owner" || repo != "repo" {
|
||||||
|
t.Fatalf("got %s/%s, want owner/repo", owner, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRemoteURLSSH(t *testing.T) {
|
||||||
|
owner, repo, err := parseRemoteURL("git@www.gitlink.org.cn:Gitlink/gitlink-cli.git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if owner != "Gitlink" {
|
||||||
|
t.Fatalf("owner = %q, want Gitlink", owner)
|
||||||
|
}
|
||||||
|
if repo != "gitlink-cli" {
|
||||||
|
t.Fatalf("repo = %q, want gitlink-cli", repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRemoteURLSSHNoSuffix(t *testing.T) {
|
||||||
|
owner, repo, err := parseRemoteURL("git@gitlink.org.cn:owner/repo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if owner != "owner" || repo != "repo" {
|
||||||
|
t.Fatalf("got %s/%s, want owner/repo", owner, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRemoteURLInvalidSSH(t *testing.T) {
|
||||||
|
_, _, err := parseRemoteURL("git@gitlink.org.cn")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid SSH URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRemoteURLInvalidHTTPS(t *testing.T) {
|
||||||
|
_, _, err := parseRemoteURL("://invalid-url")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid HTTPS URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePathSegments(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
wantOwner string
|
||||||
|
wantRepo string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"basic", "owner/repo", "owner", "repo", false},
|
||||||
|
{"with git", "owner/repo.git", "owner", "repo", false},
|
||||||
|
{"leading slash", "/owner/repo", "owner", "repo", false},
|
||||||
|
{"both", "/owner/repo.git", "owner", "repo", false},
|
||||||
|
{"with subpath", "owner/repo/sub", "owner", "repo", false},
|
||||||
|
{"single segment", "onlyowner", "", "", true},
|
||||||
|
{"empty", "", "", "", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
owner, repo, err := parsePathSegments(tt.path)
|
||||||
|
if tt.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !tt.wantErr && err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if owner != tt.wantOwner || repo != tt.wantRepo {
|
||||||
|
t.Fatalf("got %s/%s, want %s/%s", owner, repo, tt.wantOwner, tt.wantRepo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveOwnerRepoExplicit(t *testing.T) {
|
||||||
|
owner, repo, err := ResolveOwnerRepo("explicitOwner", "explicitRepo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if owner != "explicitOwner" || repo != "explicitRepo" {
|
||||||
|
t.Fatalf("got %s/%s, want explicitOwner/explicitRepo", owner, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveOwnerRepoPartialFlagsInGitRepo(t *testing.T) {
|
||||||
|
// When in a git repo, partial flags use git remote for the missing part.
|
||||||
|
owner, repo, err := ResolveOwnerRepo("", "partialRepo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if owner == "" {
|
||||||
|
t.Fatal("expected owner to be resolved from git remote")
|
||||||
|
}
|
||||||
|
if repo != "partialRepo" {
|
||||||
|
t.Fatalf("repo = %q, want partialRepo", repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, repo, err = ResolveOwnerRepo("partialOwner", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if owner != "partialOwner" {
|
||||||
|
t.Fatalf("owner = %q, want partialOwner", owner)
|
||||||
|
}
|
||||||
|
if repo == "" {
|
||||||
|
t.Fatal("expected repo to be resolved from git remote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
// Args contains named values used by parameterized messages.
|
||||||
|
type Args map[string]any
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fix := flag.Bool("fix", false, "format locale JSON files")
|
||||||
|
scanCode := flag.Bool("scan-code", false, "scan Go source for referenced i18n keys")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
problems, err := i18n.Validate(i18n.NewEmbedLoader(), "en-US")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(problems) > 0 {
|
||||||
|
for _, problem := range problems {
|
||||||
|
fmt.Fprintln(os.Stderr, problem.String())
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := checkLocaleFormat(*fix); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if *scanCode {
|
||||||
|
if err := checkCodeReferences(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println("i18n messages are valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkLocaleFormat(fix bool) error {
|
||||||
|
// #nosec G703 -- pattern is repo-local and not user-controlled.
|
||||||
|
files, err := filepath.Glob(filepath.Join("internal", "i18n", "locales", "*.json"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, path := range files {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
formatted, err := formatJSON(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
if string(data) == string(formatted) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fix {
|
||||||
|
if err := os.WriteFile(path, formatted, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: locale JSON is not formatted; run go run ./internal/i18n/cmd/check --fix", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatJSON(data []byte) ([]byte, error) {
|
||||||
|
var messages map[string]string
|
||||||
|
if err := json.Unmarshal(data, &messages); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(messages); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkCodeReferences() error {
|
||||||
|
loader := i18n.NewEmbedLoader()
|
||||||
|
base, err := loader.Load("en-US")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
used, defaultUses, err := scanCodeKeys([]string{"cmd", "shortcuts", "internal"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
for key := range used {
|
||||||
|
if _, ok := base[key]; !ok {
|
||||||
|
missing = append(missing, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(missing)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return fmt.Errorf("missing i18n key references: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
for _, item := range defaultUses {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: avoid new i18n.Default() usage at %s\n", item)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCodeKeys(roots []string) (map[string]struct{}, []string, error) {
|
||||||
|
keyPattern := regexp.MustCompile(`(?:tr|ctx\.Tr|i18n\.Default\(\))\.T(?:f)?\("([^"]+)"`)
|
||||||
|
defaultPattern := regexp.MustCompile(`i18n\.Default\(\)\.T(?:f)?\("([^"]+)"`)
|
||||||
|
used := map[string]struct{}{}
|
||||||
|
var defaultUses []string
|
||||||
|
for _, root := range roots {
|
||||||
|
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
if strings.Contains(filepath.ToSlash(path), "internal/i18n/locales") {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if entry.Type()&os.ModeSymlink != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if filepath.Ext(path) != ".go" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, "_test.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !entry.Type().IsRegular() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path) // #nosec G122 -- dev-only scan over repo roots; symlinks are skipped above.
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, match := range keyPattern.FindAllStringSubmatch(text, -1) {
|
||||||
|
used[match[1]] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, match := range defaultPattern.FindAllStringSubmatchIndex(text, -1) {
|
||||||
|
line := 1 + strings.Count(text[:match[0]], "\n")
|
||||||
|
defaultUses = append(defaultUses, fmt.Sprintf("%s:%d", filepath.ToSlash(path), line))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(defaultUses)
|
||||||
|
return used, defaultUses, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Package i18n provides localized user-facing messages for the CLI.
|
||||||
|
//
|
||||||
|
// It intentionally does not localize machine-readable output such as JSON
|
||||||
|
// field names, API response bodies, or debug diagnostics.
|
||||||
|
package i18n
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed locales/*.json
|
||||||
|
var embeddedLocales embed.FS
|
||||||
|
|
||||||
|
// Loader loads locale messages from a backing store.
|
||||||
|
type Loader interface {
|
||||||
|
Load(locale string) (map[string]string, error)
|
||||||
|
AvailableLocales() ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type embedLoader struct {
|
||||||
|
fs fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEmbedLoader returns the default loader backed by embedded locale files.
|
||||||
|
func NewEmbedLoader() Loader {
|
||||||
|
return embedLoader{fs: embeddedLocales}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l embedLoader) Load(locale string) (map[string]string, error) {
|
||||||
|
locale = NormalizeLocale(locale)
|
||||||
|
path := filepath.ToSlash(filepath.Join("locales", locale+".json"))
|
||||||
|
data, err := fs.ReadFile(l.fs, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load locale %s: %w", locale, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages map[string]string
|
||||||
|
if err := json.Unmarshal(data, &messages); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse locale %s: %w", locale, err)
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l embedLoader) AvailableLocales() ([]string, error) {
|
||||||
|
entries, err := fs.ReadDir(l.fs, "locales")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
locales := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
locales = append(locales, strings.TrimSuffix(entry.Name(), ".json"))
|
||||||
|
}
|
||||||
|
sort.Strings(locales)
|
||||||
|
return locales, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvailableLocales returns locales available from the embedded loader.
|
||||||
|
func AvailableLocales() ([]string, error) {
|
||||||
|
return NewEmbedLoader().AvailableLocales()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeLocale converts common locale spellings to a stable BCP-47-like form.
|
||||||
|
func NormalizeLocale(locale string) string {
|
||||||
|
locale = strings.TrimSpace(locale)
|
||||||
|
if locale == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if idx := strings.IndexByte(locale, '.'); idx >= 0 {
|
||||||
|
locale = locale[:idx]
|
||||||
|
}
|
||||||
|
locale = strings.ReplaceAll(locale, "_", "-")
|
||||||
|
|
||||||
|
parts := strings.Split(locale, "-")
|
||||||
|
normalized := make([]string, 0, len(parts))
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case i == 0:
|
||||||
|
normalized = append(normalized, strings.ToLower(part))
|
||||||
|
case len(part) == 2:
|
||||||
|
normalized = append(normalized, strings.ToUpper(part))
|
||||||
|
case len(part) == 4:
|
||||||
|
normalized = append(normalized, strings.ToUpper(part[:1])+strings.ToLower(part[1:]))
|
||||||
|
default:
|
||||||
|
normalized = append(normalized, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(normalized, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchLocale resolves requested to one of available using exact, safe alias,
|
||||||
|
// then fallback matching.
|
||||||
|
func MatchLocale(requested string, available []string, fallback string) string {
|
||||||
|
return matchLocale(requested, available, fallback).Locale
|
||||||
|
}
|
||||||
|
|
||||||
|
type localeMatch struct {
|
||||||
|
Locale string
|
||||||
|
Requested string
|
||||||
|
Fallbacked bool
|
||||||
|
Supported bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchLocale(requested string, available []string, fallback string) localeMatch {
|
||||||
|
fallback = NormalizeLocale(fallback)
|
||||||
|
if fallback == "" {
|
||||||
|
fallback = defaultFallbackLocale
|
||||||
|
}
|
||||||
|
if len(available) == 0 {
|
||||||
|
return localeMatch{Locale: fallback, Requested: NormalizeLocale(requested), Fallbacked: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
byLocale := make(map[string]string, len(available))
|
||||||
|
for _, locale := range available {
|
||||||
|
normalized := NormalizeLocale(locale)
|
||||||
|
byLocale[normalized] = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := NormalizeLocale(requested)
|
||||||
|
if candidate != "" {
|
||||||
|
if matched, ok := byLocale[candidate]; ok {
|
||||||
|
return localeMatch{Locale: matched, Requested: candidate, Supported: true}
|
||||||
|
}
|
||||||
|
if alias := localeAlias(candidate); alias != "" {
|
||||||
|
if matched, ok := byLocale[alias]; ok {
|
||||||
|
return localeMatch{Locale: matched, Requested: candidate, Supported: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matched, ok := byLocale[fallback]; ok {
|
||||||
|
return localeMatch{Locale: matched, Requested: candidate, Fallbacked: candidate != "", Supported: false}
|
||||||
|
}
|
||||||
|
return localeMatch{Locale: NormalizeLocale(available[0]), Requested: candidate, Fallbacked: candidate != "", Supported: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryLanguage(locale string) string {
|
||||||
|
if idx := strings.IndexByte(locale, '-'); idx >= 0 {
|
||||||
|
return locale[:idx]
|
||||||
|
}
|
||||||
|
return locale
|
||||||
|
}
|
||||||
|
|
||||||
|
func localeAlias(locale string) string {
|
||||||
|
switch {
|
||||||
|
case locale == "zh" || locale == "zh-CN" || locale == "zh-Hans" || locale == "zh-Hans-CN":
|
||||||
|
return "zh-CN"
|
||||||
|
case primaryLanguage(locale) == "en":
|
||||||
|
return "en-US"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeLocale(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"zh_CN": "zh-CN",
|
||||||
|
"zh_CN.UTF-8": "zh-CN",
|
||||||
|
"zh-Hans-CN": "zh-Hans-CN",
|
||||||
|
"EN_us": "en-US",
|
||||||
|
" en-US ": "en-US",
|
||||||
|
"zh-hans-cn.utf": "zh-Hans-CN",
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
if got := NormalizeLocale(input); got != want {
|
||||||
|
t.Fatalf("NormalizeLocale(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchLocale(t *testing.T) {
|
||||||
|
available := []string{"en-US", "zh-CN"}
|
||||||
|
cases := map[string]string{
|
||||||
|
"zh_CN": "zh-CN",
|
||||||
|
"zh-Hans-CN": "zh-CN",
|
||||||
|
"zh": "zh-CN",
|
||||||
|
"en": "en-US",
|
||||||
|
"fr-FR": "en-US",
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
if got := MatchLocale(input, available, "en-US"); got != want {
|
||||||
|
t.Fatalf("MatchLocale(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
{
|
||||||
|
"cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.",
|
||||||
|
"cmd.api.short": "Make raw API requests to GitLink",
|
||||||
|
"cmd.auth.login.short": "Login to GitLink",
|
||||||
|
"cmd.auth.logout.short": "Logout from GitLink",
|
||||||
|
"cmd.auth.short": "Authentication commands",
|
||||||
|
"cmd.auth.status.short": "Show authentication status",
|
||||||
|
"cmd.branch.create.short": "Create a branch",
|
||||||
|
"cmd.branch.delete.short": "Delete a branch",
|
||||||
|
"cmd.branch.list.short": "List branches",
|
||||||
|
"cmd.branch.protect.short": "Set branch protection",
|
||||||
|
"cmd.branch.short": "Branch operations",
|
||||||
|
"cmd.branch.unprotect.short": "Remove branch protection",
|
||||||
|
"cmd.ci.builds.short": "List CI builds",
|
||||||
|
"cmd.ci.logs.short": "View build logs",
|
||||||
|
"cmd.ci.restart.short": "Restart a build",
|
||||||
|
"cmd.ci.short": "CI/CD operations",
|
||||||
|
"cmd.ci.stop.short": "Stop a build",
|
||||||
|
"cmd.config.get.short": "Get a configuration value",
|
||||||
|
"cmd.config.init.short": "Initialize configuration file",
|
||||||
|
"cmd.config.list.short": "List all configuration values",
|
||||||
|
"cmd.config.set.short": "Set a configuration value",
|
||||||
|
"cmd.config.short": "Manage gitlink-cli configuration",
|
||||||
|
"cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.",
|
||||||
|
"cmd.doctor.short": "Diagnose gitlink-cli environment problems",
|
||||||
|
"cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes",
|
||||||
|
"cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.",
|
||||||
|
"cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes",
|
||||||
|
"cmd.issue.batch_label.short": "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.",
|
||||||
|
"cmd.issue.batch_list.long": "List issue batch maintenance candidates without changing remote data.\n\nExamples:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json",
|
||||||
|
"cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data",
|
||||||
|
"cmd.issue.close.short": "Close an issue",
|
||||||
|
"cmd.issue.comment.short": "Add a comment to an issue",
|
||||||
|
"cmd.issue.create.short": "Create a new issue",
|
||||||
|
"cmd.issue.list.short": "List issues",
|
||||||
|
"cmd.issue.short": "Issue operations",
|
||||||
|
"cmd.issue.update.short": "Update an issue",
|
||||||
|
"cmd.issue.view.short": "View issue details",
|
||||||
|
"cmd.org.create.short": "Create an organization",
|
||||||
|
"cmd.org.info.short": "Show organization details",
|
||||||
|
"cmd.org.list.short": "List organizations",
|
||||||
|
"cmd.org.members.short": "List organization members",
|
||||||
|
"cmd.org.short": "Organization operations",
|
||||||
|
"cmd.pr.close.short": "Close a pull request",
|
||||||
|
"cmd.pr.comment.short": "Add a comment to a pull request",
|
||||||
|
"cmd.pr.create.short": "Create a pull request",
|
||||||
|
"cmd.pr.diff.short": "Show diff for a pull request",
|
||||||
|
"cmd.pr.files.short": "List changed files in a pull request",
|
||||||
|
"cmd.pr.list.short": "List pull requests",
|
||||||
|
"cmd.pr.merge.short": "Merge a pull request",
|
||||||
|
"cmd.pr.review.short": "Create a pull request review",
|
||||||
|
"cmd.pr.reviews.short": "List pull request reviews",
|
||||||
|
"cmd.pr.short": "Pull request operations",
|
||||||
|
"cmd.pr.version_diff.short": "Show diff for a pull request patchset version",
|
||||||
|
"cmd.pr.versions.short": "List pull request patchset versions",
|
||||||
|
"cmd.pr.view.short": "View pull request details",
|
||||||
|
"cmd.release.create.short": "Create a release",
|
||||||
|
"cmd.release.delete.short": "Delete a release",
|
||||||
|
"cmd.release.list.short": "List releases",
|
||||||
|
"cmd.release.short": "Release operations",
|
||||||
|
"cmd.release.view.short": "View release details",
|
||||||
|
"cmd.repo.create.short": "Create a new repository",
|
||||||
|
"cmd.repo.delete.short": "Delete a repository",
|
||||||
|
"cmd.repo.fork.short": "Fork a repository",
|
||||||
|
"cmd.repo.info.short": "Show repository details",
|
||||||
|
"cmd.repo.list.short": "List repositories for a user or organization",
|
||||||
|
"cmd.repo.short": "Repository operations",
|
||||||
|
"cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.",
|
||||||
|
"cmd.root.short": "GitLink CLI - command-line tool for GitLink",
|
||||||
|
"cmd.search.repos.short": "Search repositories",
|
||||||
|
"cmd.search.short": "Search operations",
|
||||||
|
"cmd.search.users.short": "Search users",
|
||||||
|
"cmd.user.info.short": "Show user profile",
|
||||||
|
"cmd.user.me.short": "Show current authenticated user",
|
||||||
|
"cmd.user.short": "User operations",
|
||||||
|
"cmd.version.short": "Print version information",
|
||||||
|
"cmd.webhook.create.short": "Create a repository webhook",
|
||||||
|
"cmd.webhook.delete.short": "Delete a repository webhook",
|
||||||
|
"cmd.webhook.list.short": "List repository webhooks",
|
||||||
|
"cmd.webhook.short": "Webhook operations",
|
||||||
|
"cmd.webhook.tasks.short": "List webhook delivery tasks",
|
||||||
|
"cmd.webhook.test.short": "Trigger a test delivery for a webhook",
|
||||||
|
"cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available",
|
||||||
|
"cmd.webhook.view.short": "View webhook details",
|
||||||
|
"error.auth.delete_token_failed": "failed to delete token: {message}",
|
||||||
|
"error.auth.login_failed": "login failed: {message}",
|
||||||
|
"error.auth.store_token_failed": "failed to store token: {message}",
|
||||||
|
"error.auth.token_empty": "token cannot be empty",
|
||||||
|
"error.config.save_failed": "failed to save config: {message}",
|
||||||
|
"error.missing_required_flag": "required flag --{name} is missing",
|
||||||
|
"error.unsupported_language": "unsupported language: {lang}",
|
||||||
|
"flag.api.body": "Request body (JSON string)",
|
||||||
|
"flag.api.body_file": "Read request body JSON from a file",
|
||||||
|
"flag.api.body_stdin": "Read request body JSON from stdin",
|
||||||
|
"flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure",
|
||||||
|
"flag.api.batch_dry_run": "Preview batch requests without sending remote requests",
|
||||||
|
"flag.api.batch_file": "Read an API batch plan from a JSON file",
|
||||||
|
"flag.api.batch_var": "Override a batch template variable (key=value, repeatable)",
|
||||||
|
"flag.api.header": "Additional headers (key:value)",
|
||||||
|
"flag.api.query": "Query parameters (key=val&key2=val2)",
|
||||||
|
"flag.auth.token": "Login by pasting an existing token",
|
||||||
|
"flag.branch.from": "Source branch or commit",
|
||||||
|
"flag.branch.name": "Branch name",
|
||||||
|
"flag.ci.build": "Build number",
|
||||||
|
"flag.ci.stage": "Stage number",
|
||||||
|
"flag.ci.step": "Step number",
|
||||||
|
"flag.comment.body": "Comment body",
|
||||||
|
"flag.debug": "Enable debug output",
|
||||||
|
"flag.description": "Description",
|
||||||
|
"flag.doctor.skip_network": "Skip authenticated API connectivity checks",
|
||||||
|
"flag.dry_run": "Preview the request without creating it",
|
||||||
|
"flag.format": "Output format: json, table, yaml (default: table)",
|
||||||
|
"flag.issue.add_label": "Label to add to each matching issue",
|
||||||
|
"flag.issue.assignee": "Assignee login",
|
||||||
|
"flag.issue.batch.reason": "Optional reason shown in the batch result",
|
||||||
|
"flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.",
|
||||||
|
"flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7",
|
||||||
|
"flag.issue.batch_close.state": "Filter by issue state before closing",
|
||||||
|
"flag.issue.batch_label.state": "Filter by issue state",
|
||||||
|
"flag.issue.batch_list.limit": "Maximum issues to return, capped at 100",
|
||||||
|
"flag.issue.batch_process.limit": "Maximum issues to process, capped at 100",
|
||||||
|
"flag.issue.assignee_id": "Assignee user ID",
|
||||||
|
"flag.issue.author_id": "Author user ID",
|
||||||
|
"flag.issue.body": "Issue description",
|
||||||
|
"flag.issue.label": "Label ID",
|
||||||
|
"flag.issue.label_filter": "Filter by existing label",
|
||||||
|
"flag.issue.milestone": "Milestone ID",
|
||||||
|
"flag.issue.new_body": "New description",
|
||||||
|
"flag.issue.new_state": "New state: open, closed, or numeric status_id",
|
||||||
|
"flag.issue.new_title": "New title",
|
||||||
|
"flag.issue.number": "Issue number (as shown in the web URL)",
|
||||||
|
"flag.issue.older_than_days": "Only include issues inactive for at least this many days",
|
||||||
|
"flag.issue.participant": "Participant filter: all, aboutme, authoredme, assignedme, atme",
|
||||||
|
"flag.issue.state": "Filter by state: open, closed, all",
|
||||||
|
"flag.issue.status_id": "Issue status ID",
|
||||||
|
"flag.issue.tag_ids": "Comma-separated issue tag IDs",
|
||||||
|
"flag.issue.title": "Issue title",
|
||||||
|
"flag.lang": "Display language",
|
||||||
|
"flag.limit": "Items per page",
|
||||||
|
"flag.org.id": "Organization ID",
|
||||||
|
"flag.org.id_or_login": "Organization ID or login",
|
||||||
|
"flag.org.name": "Organization name",
|
||||||
|
"flag.owner": "Repository owner (auto-detected from git remote)",
|
||||||
|
"flag.page": "Page number",
|
||||||
|
"flag.pr.assignee_id": "Assignee user ID",
|
||||||
|
"flag.pr.base": "Target branch",
|
||||||
|
"flag.pr.body": "PR description",
|
||||||
|
"flag.pr.file": "Filter diff by file path",
|
||||||
|
"flag.pr.head": "Source branch",
|
||||||
|
"flag.pr.id": "PR number",
|
||||||
|
"flag.pr.milestone_id": "Milestone ID",
|
||||||
|
"flag.pr.merge_method": "Merge method: merge, rebase, squash",
|
||||||
|
"flag.pr.priority_id": "Priority ID",
|
||||||
|
"flag.pr.review_commit": "Commit SHA to attach the review to",
|
||||||
|
"flag.pr.review_content": "Review content",
|
||||||
|
"flag.pr.reviewer_id": "Reviewer user ID",
|
||||||
|
"flag.pr.review_status": "Review status: common, approved, rejected",
|
||||||
|
"flag.pr.review_status_filter": "Filter review status: common, approved, rejected",
|
||||||
|
"flag.pr.state": "Filter: open, merged, closed",
|
||||||
|
"flag.pr.tag_id": "Issue tag ID",
|
||||||
|
"flag.pr.title": "PR title",
|
||||||
|
"flag.pr.version_id": "Patchset version ID",
|
||||||
|
"flag.release.body": "Release notes",
|
||||||
|
"flag.release.id": "Release ID",
|
||||||
|
"flag.release.id_or_tag": "Release ID or tag",
|
||||||
|
"flag.release.name": "Release name",
|
||||||
|
"flag.release.prerelease": "Mark as prerelease (true/false)",
|
||||||
|
"flag.release.tag": "Tag name",
|
||||||
|
"flag.release.target": "Target branch",
|
||||||
|
"flag.repo": "Repository name (auto-detected from git remote)",
|
||||||
|
"flag.repo.category": "Filter: manage/mirror/sync/fork/all (default: manage)",
|
||||||
|
"flag.repo.description": "Repository description",
|
||||||
|
"flag.repo.name": "Repository name",
|
||||||
|
"flag.repo.private": "Make repository private (true/false)",
|
||||||
|
"flag.search.keyword": "Search keyword",
|
||||||
|
"flag.sort_by": "Sort field",
|
||||||
|
"flag.sort_direction": "Sort direction: asc, desc",
|
||||||
|
"flag.user": "User login (default: current user)",
|
||||||
|
"flag.user.login": "User login name",
|
||||||
|
"flag.webhook.active": "Whether the webhook is active: true or false",
|
||||||
|
"flag.webhook.branch_filter": "Branch glob filter for push/create/delete events",
|
||||||
|
"flag.webhook.content_type": "Payload content type: json or form",
|
||||||
|
"flag.webhook.events": "Comma-separated events, for example: push,issues_only",
|
||||||
|
"flag.webhook.http_method": "HTTP method: POST or GET",
|
||||||
|
"flag.webhook.id": "Webhook ID",
|
||||||
|
"flag.webhook.secret": "Webhook secret",
|
||||||
|
"flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.",
|
||||||
|
"flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot",
|
||||||
|
"flag.webhook.url": "Webhook target URL",
|
||||||
|
"output.auth.env_hint": " Or set {env} environment variable",
|
||||||
|
"output.auth.login_hint": " Run: gitlink-cli auth login",
|
||||||
|
"output.config.file": "Config file: {path}",
|
||||||
|
"output.config.not_set": "(not set)",
|
||||||
|
"output.version": "gitlink-cli {version}",
|
||||||
|
"output.doctor.api_auth.config_skipped": "API authentication check skipped because the configuration file is invalid.",
|
||||||
|
"output.doctor.api_auth.failed": "Authenticated API request failed: {message}",
|
||||||
|
"output.doctor.api_auth.no_login": "Authenticated API response did not include a login field.",
|
||||||
|
"output.doctor.api_auth.ok": "Authenticated API request succeeded as {login}.",
|
||||||
|
"output.doctor.api_auth.skipped": "Authenticated API connectivity check skipped.",
|
||||||
|
"output.doctor.auth_token.env": "GITLINK_TOKEN environment variable is set.",
|
||||||
|
"output.doctor.auth_token.missing": "No stored token or GITLINK_TOKEN environment variable was found.",
|
||||||
|
"output.doctor.auth_token.stored": "Stored credentials were found.",
|
||||||
|
"output.doctor.config_file.invalid": "Configuration file exists but cannot be parsed: {message}",
|
||||||
|
"output.doctor.config_file.missing": "Configuration file was not found; built-in defaults will be used.",
|
||||||
|
"output.doctor.config_file.ok": "Configuration file is readable.",
|
||||||
|
"output.doctor.config_file.unreadable": "Configuration file cannot be read: {message}",
|
||||||
|
"output.doctor.config_values.bad_base_url": "base_url is invalid: {message}",
|
||||||
|
"output.doctor.config_values.bad_format": "default_format is {format}, expected json, table or yaml.",
|
||||||
|
"output.doctor.config_values.ok": "Configuration values are valid.",
|
||||||
|
"output.doctor.config_values.skipped": "Configuration value checks skipped because the configuration file is invalid.",
|
||||||
|
"output.doctor.repo_context.missing": "Repository context could not be resolved: {message}",
|
||||||
|
"output.doctor.repo_context.ok": "Repository context resolved to {owner}/{repo}.",
|
||||||
|
"output.doctor.suggestion.check_config_permissions": "Check file permissions for the gitlink-cli config directory.",
|
||||||
|
"output.doctor.suggestion.check_token": "Check whether the stored token is valid, or run gitlink-cli auth login again.",
|
||||||
|
"output.doctor.suggestion.fix_config_yaml": "Fix the YAML syntax in the gitlink-cli config file.",
|
||||||
|
"output.doctor.suggestion.pass_owner_repo": "Run the command with --owner and --repo when not inside a GitLink repository.",
|
||||||
|
"prompt.auth.password": "Password: ",
|
||||||
|
"prompt.auth.token": "Paste your access token: ",
|
||||||
|
"prompt.auth.username": "Username/Email/Phone: ",
|
||||||
|
"success.auth.logged_in_as": "✓ Logged in as {login}",
|
||||||
|
"success.auth.logged_in_via_env": "✓ Logged in via {env} environment variable",
|
||||||
|
"success.auth.logged_out": "✓ Logged out",
|
||||||
|
"success.auth.token_saved": "✓ Token saved",
|
||||||
|
"success.config.initialized": "✓ Config initialized at {path}",
|
||||||
|
"success.config.set": "✓ {key} = {value}",
|
||||||
|
"warning.auth.not_logged_in": "✗ Not logged in",
|
||||||
|
"warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})",
|
||||||
|
"warning.auth.user_unavailable": "✓ Token stored (user info unavailable)"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
{
|
||||||
|
"cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。",
|
||||||
|
"cmd.api.short": "向 GitLink 发起原始 API 请求",
|
||||||
|
"cmd.auth.login.short": "登录 GitLink",
|
||||||
|
"cmd.auth.logout.short": "退出 GitLink 登录",
|
||||||
|
"cmd.auth.short": "认证命令",
|
||||||
|
"cmd.auth.status.short": "显示认证状态",
|
||||||
|
"cmd.branch.create.short": "创建分支",
|
||||||
|
"cmd.branch.delete.short": "删除分支",
|
||||||
|
"cmd.branch.list.short": "列出分支",
|
||||||
|
"cmd.branch.protect.short": "设置分支保护",
|
||||||
|
"cmd.branch.short": "分支操作",
|
||||||
|
"cmd.branch.unprotect.short": "移除分支保护",
|
||||||
|
"cmd.ci.builds.short": "列出 CI 构建",
|
||||||
|
"cmd.ci.logs.short": "查看构建日志",
|
||||||
|
"cmd.ci.restart.short": "重启构建",
|
||||||
|
"cmd.ci.short": "CI/CD 操作",
|
||||||
|
"cmd.ci.stop.short": "停止构建",
|
||||||
|
"cmd.config.get.short": "获取配置项",
|
||||||
|
"cmd.config.init.short": "初始化配置文件",
|
||||||
|
"cmd.config.list.short": "列出所有配置项",
|
||||||
|
"cmd.config.set.short": "设置配置项",
|
||||||
|
"cmd.config.short": "管理 gitlink-cli 配置",
|
||||||
|
"cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。",
|
||||||
|
"cmd.doctor.short": "诊断 gitlink-cli 环境问题",
|
||||||
|
"cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes",
|
||||||
|
"cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。",
|
||||||
|
"cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes",
|
||||||
|
"cmd.issue.batch_label.short": "给筛选后的议题批量添加标签。默认 dry-run;传入 --yes 后执行。",
|
||||||
|
"cmd.issue.batch_list.long": "列出议题批量维护候选项,不修改远端数据。\n\n示例:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json",
|
||||||
|
"cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据",
|
||||||
|
"cmd.issue.close.short": "关闭议题",
|
||||||
|
"cmd.issue.comment.short": "给议题添加评论",
|
||||||
|
"cmd.issue.create.short": "创建新议题",
|
||||||
|
"cmd.issue.list.short": "列出议题",
|
||||||
|
"cmd.issue.short": "议题操作",
|
||||||
|
"cmd.issue.update.short": "更新议题",
|
||||||
|
"cmd.issue.view.short": "查看议题详情",
|
||||||
|
"cmd.org.create.short": "创建组织",
|
||||||
|
"cmd.org.info.short": "显示组织详情",
|
||||||
|
"cmd.org.list.short": "列出组织",
|
||||||
|
"cmd.org.members.short": "列出组织成员",
|
||||||
|
"cmd.org.short": "组织操作",
|
||||||
|
"cmd.pr.close.short": "关闭拉取请求",
|
||||||
|
"cmd.pr.comment.short": "给拉取请求添加评论",
|
||||||
|
"cmd.pr.create.short": "创建拉取请求",
|
||||||
|
"cmd.pr.diff.short": "显示拉取请求 diff",
|
||||||
|
"cmd.pr.files.short": "列出拉取请求中的变更文件",
|
||||||
|
"cmd.pr.list.short": "列出拉取请求",
|
||||||
|
"cmd.pr.merge.short": "合并拉取请求",
|
||||||
|
"cmd.pr.review.short": "创建拉取请求评审",
|
||||||
|
"cmd.pr.reviews.short": "列出拉取请求评审",
|
||||||
|
"cmd.pr.short": "拉取请求操作",
|
||||||
|
"cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff",
|
||||||
|
"cmd.pr.versions.short": "列出拉取请求补丁集版本",
|
||||||
|
"cmd.pr.view.short": "查看拉取请求详情",
|
||||||
|
"cmd.release.create.short": "创建发布",
|
||||||
|
"cmd.release.delete.short": "删除发布",
|
||||||
|
"cmd.release.list.short": "列出发布",
|
||||||
|
"cmd.release.short": "发布操作",
|
||||||
|
"cmd.release.view.short": "查看发布详情",
|
||||||
|
"cmd.repo.create.short": "创建新仓库",
|
||||||
|
"cmd.repo.delete.short": "删除仓库",
|
||||||
|
"cmd.repo.fork.short": "Fork 仓库",
|
||||||
|
"cmd.repo.info.short": "显示仓库详情",
|
||||||
|
"cmd.repo.list.short": "列出用户或组织的仓库",
|
||||||
|
"cmd.repo.short": "仓库操作",
|
||||||
|
"cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。",
|
||||||
|
"cmd.root.short": "GitLink CLI - GitLink 命令行工具",
|
||||||
|
"cmd.search.repos.short": "搜索仓库",
|
||||||
|
"cmd.search.short": "搜索操作",
|
||||||
|
"cmd.search.users.short": "搜索用户",
|
||||||
|
"cmd.user.info.short": "显示用户资料",
|
||||||
|
"cmd.user.me.short": "显示当前认证用户",
|
||||||
|
"cmd.user.short": "用户操作",
|
||||||
|
"cmd.version.short": "打印版本信息",
|
||||||
|
"cmd.webhook.create.short": "创建仓库 Webhook",
|
||||||
|
"cmd.webhook.delete.short": "删除仓库 Webhook",
|
||||||
|
"cmd.webhook.list.short": "列出仓库 Webhook",
|
||||||
|
"cmd.webhook.short": "Webhook 操作",
|
||||||
|
"cmd.webhook.tasks.short": "列出 Webhook 投递任务",
|
||||||
|
"cmd.webhook.test.short": "触发 Webhook 测试投递",
|
||||||
|
"cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段",
|
||||||
|
"cmd.webhook.view.short": "查看 Webhook 详情",
|
||||||
|
"error.auth.delete_token_failed": "删除 Token 失败:{message}",
|
||||||
|
"error.auth.login_failed": "登录失败:{message}",
|
||||||
|
"error.auth.store_token_failed": "保存 Token 失败:{message}",
|
||||||
|
"error.auth.token_empty": "Token 不能为空",
|
||||||
|
"error.config.save_failed": "保存配置失败:{message}",
|
||||||
|
"error.missing_required_flag": "缺少必需参数 --{name}",
|
||||||
|
"error.unsupported_language": "不支持的语言:{lang}",
|
||||||
|
"flag.api.body": "请求体(JSON 字符串)",
|
||||||
|
"flag.api.body_file": "从文件读取 JSON 请求体",
|
||||||
|
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
|
||||||
|
"flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求",
|
||||||
|
"flag.api.batch_dry_run": "预览批处理请求,不发送远端请求",
|
||||||
|
"flag.api.batch_file": "从 JSON 文件读取 API 批处理计划",
|
||||||
|
"flag.api.batch_var": "覆盖批处理模板变量(key=value,可重复)",
|
||||||
|
"flag.api.header": "附加请求头(key:value)",
|
||||||
|
"flag.api.query": "查询参数(key=val&key2=val2)",
|
||||||
|
"flag.auth.token": "通过粘贴已有 Token 登录",
|
||||||
|
"flag.branch.from": "源分支或 Commit",
|
||||||
|
"flag.branch.name": "分支名称",
|
||||||
|
"flag.ci.build": "构建编号",
|
||||||
|
"flag.ci.stage": "阶段编号",
|
||||||
|
"flag.ci.step": "步骤编号",
|
||||||
|
"flag.comment.body": "评论内容",
|
||||||
|
"flag.debug": "启用调试输出",
|
||||||
|
"flag.description": "描述",
|
||||||
|
"flag.doctor.skip_network": "跳过需要访问 GitLink 的认证连通性检查",
|
||||||
|
"flag.dry_run": "预览请求,不实际创建",
|
||||||
|
"flag.format": "输出格式:json、table、yaml(默认:table)",
|
||||||
|
"flag.issue.add_label": "要添加到每个匹配议题的标签",
|
||||||
|
"flag.issue.assignee": "负责人登录名",
|
||||||
|
"flag.issue.batch.reason": "批量结果中显示的可选原因",
|
||||||
|
"flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。",
|
||||||
|
"flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7",
|
||||||
|
"flag.issue.batch_close.state": "关闭前按议题状态筛选",
|
||||||
|
"flag.issue.batch_label.state": "按议题状态筛选",
|
||||||
|
"flag.issue.batch_list.limit": "最多返回的议题数,上限 100",
|
||||||
|
"flag.issue.batch_process.limit": "最多处理的议题数,上限 100",
|
||||||
|
"flag.issue.assignee_id": "负责人用户 ID",
|
||||||
|
"flag.issue.author_id": "作者用户 ID",
|
||||||
|
"flag.issue.body": "议题描述",
|
||||||
|
"flag.issue.label": "标签 ID",
|
||||||
|
"flag.issue.label_filter": "按已有标签筛选",
|
||||||
|
"flag.issue.milestone": "里程碑 ID",
|
||||||
|
"flag.issue.new_body": "新描述",
|
||||||
|
"flag.issue.new_state": "新状态:open、closed 或数字 status_id",
|
||||||
|
"flag.issue.new_title": "新标题",
|
||||||
|
"flag.issue.number": "议题编号(网页 URL 中显示的编号)",
|
||||||
|
"flag.issue.older_than_days": "只包含至少这么多天未活动的议题",
|
||||||
|
"flag.issue.participant": "参与范围:all、aboutme、authoredme、assignedme、atme",
|
||||||
|
"flag.issue.state": "按状态筛选:open、closed、all",
|
||||||
|
"flag.issue.status_id": "议题状态 ID",
|
||||||
|
"flag.issue.tag_ids": "逗号分隔的议题标签 ID",
|
||||||
|
"flag.issue.title": "议题标题",
|
||||||
|
"flag.lang": "显示语言",
|
||||||
|
"flag.limit": "每页条目数",
|
||||||
|
"flag.org.id": "组织 ID",
|
||||||
|
"flag.org.id_or_login": "组织 ID 或登录名",
|
||||||
|
"flag.org.name": "组织名称",
|
||||||
|
"flag.owner": "仓库所有者(自动从 git remote 检测)",
|
||||||
|
"flag.page": "页码",
|
||||||
|
"flag.pr.assignee_id": "指派人用户 ID",
|
||||||
|
"flag.pr.base": "目标分支",
|
||||||
|
"flag.pr.body": "PR 描述",
|
||||||
|
"flag.pr.file": "按文件路径筛选 diff",
|
||||||
|
"flag.pr.head": "源分支",
|
||||||
|
"flag.pr.id": "PR 编号",
|
||||||
|
"flag.pr.milestone_id": "里程碑 ID",
|
||||||
|
"flag.pr.merge_method": "合并方式:merge、rebase、squash",
|
||||||
|
"flag.pr.priority_id": "优先级 ID",
|
||||||
|
"flag.pr.review_commit": "关联评审的 Commit SHA",
|
||||||
|
"flag.pr.review_content": "评审内容",
|
||||||
|
"flag.pr.reviewer_id": "评审人用户 ID",
|
||||||
|
"flag.pr.review_status": "评审状态:common、approved、rejected",
|
||||||
|
"flag.pr.review_status_filter": "按评审状态筛选:common、approved、rejected",
|
||||||
|
"flag.pr.state": "筛选:open、merged、closed",
|
||||||
|
"flag.pr.tag_id": "议题标签 ID",
|
||||||
|
"flag.pr.title": "PR 标题",
|
||||||
|
"flag.pr.version_id": "补丁集版本 ID",
|
||||||
|
"flag.release.body": "发布说明",
|
||||||
|
"flag.release.id": "发布 ID",
|
||||||
|
"flag.release.id_or_tag": "发布 ID 或标签",
|
||||||
|
"flag.release.name": "发布名称",
|
||||||
|
"flag.release.prerelease": "标记为预发布(true/false)",
|
||||||
|
"flag.release.tag": "标签名称",
|
||||||
|
"flag.release.target": "目标分支",
|
||||||
|
"flag.repo": "仓库名称(自动从 git remote 检测)",
|
||||||
|
"flag.repo.category": "筛选:manage/mirror/sync/fork/all(默认:manage)",
|
||||||
|
"flag.repo.description": "仓库描述",
|
||||||
|
"flag.repo.name": "仓库名称",
|
||||||
|
"flag.repo.private": "设为私有仓库(true/false)",
|
||||||
|
"flag.search.keyword": "搜索关键词",
|
||||||
|
"flag.sort_by": "排序字段",
|
||||||
|
"flag.sort_direction": "排序方向:asc、desc",
|
||||||
|
"flag.user": "用户登录名(默认:当前用户)",
|
||||||
|
"flag.user.login": "用户登录名",
|
||||||
|
"flag.webhook.active": "Webhook 是否启用:true 或 false",
|
||||||
|
"flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选",
|
||||||
|
"flag.webhook.content_type": "Payload 内容类型:json 或 form",
|
||||||
|
"flag.webhook.events": "逗号分隔的事件,例如:push,issues_only",
|
||||||
|
"flag.webhook.http_method": "HTTP 方法:POST 或 GET",
|
||||||
|
"flag.webhook.id": "Webhook ID",
|
||||||
|
"flag.webhook.secret": "Webhook 密钥",
|
||||||
|
"flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。",
|
||||||
|
"flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot",
|
||||||
|
"flag.webhook.url": "Webhook 目标 URL",
|
||||||
|
"output.auth.env_hint": " 或设置 {env} 环境变量",
|
||||||
|
"output.auth.login_hint": " 运行:gitlink-cli auth login",
|
||||||
|
"output.config.file": "配置文件:{path}",
|
||||||
|
"output.config.not_set": "(未设置)",
|
||||||
|
"output.version": "gitlink-cli {version}",
|
||||||
|
"output.doctor.api_auth.config_skipped": "配置文件无效,已跳过 API 认证检查。",
|
||||||
|
"output.doctor.api_auth.failed": "认证 API 请求失败:{message}",
|
||||||
|
"output.doctor.api_auth.no_login": "认证 API 响应中缺少 login 字段。",
|
||||||
|
"output.doctor.api_auth.ok": "认证 API 请求成功,当前用户为 {login}。",
|
||||||
|
"output.doctor.api_auth.skipped": "已跳过认证 API 连通性检查。",
|
||||||
|
"output.doctor.auth_token.env": "已设置 GITLINK_TOKEN 环境变量。",
|
||||||
|
"output.doctor.auth_token.missing": "未找到已保存的 Token,也未设置 GITLINK_TOKEN 环境变量。",
|
||||||
|
"output.doctor.auth_token.stored": "已找到本地保存的认证凭据。",
|
||||||
|
"output.doctor.config_file.invalid": "配置文件存在,但无法解析:{message}",
|
||||||
|
"output.doctor.config_file.missing": "未找到配置文件,将使用内置默认值。",
|
||||||
|
"output.doctor.config_file.ok": "配置文件可读取。",
|
||||||
|
"output.doctor.config_file.unreadable": "配置文件无法读取:{message}",
|
||||||
|
"output.doctor.config_values.bad_base_url": "base_url 无效:{message}",
|
||||||
|
"output.doctor.config_values.bad_format": "default_format 当前为 {format},应为 json、table 或 yaml。",
|
||||||
|
"output.doctor.config_values.ok": "配置项有效。",
|
||||||
|
"output.doctor.config_values.skipped": "配置文件无效,已跳过配置项检查。",
|
||||||
|
"output.doctor.repo_context.missing": "无法解析仓库上下文:{message}",
|
||||||
|
"output.doctor.repo_context.ok": "仓库上下文已解析为 {owner}/{repo}。",
|
||||||
|
"output.doctor.suggestion.check_config_permissions": "检查 gitlink-cli 配置目录的文件权限。",
|
||||||
|
"output.doctor.suggestion.check_token": "检查已保存的 Token 是否有效,或重新运行 gitlink-cli auth login。",
|
||||||
|
"output.doctor.suggestion.fix_config_yaml": "修复 gitlink-cli 配置文件中的 YAML 语法。",
|
||||||
|
"output.doctor.suggestion.pass_owner_repo": "不在 GitLink 仓库目录内时,请通过 --owner 和 --repo 指定仓库。",
|
||||||
|
"prompt.auth.password": "密码:",
|
||||||
|
"prompt.auth.token": "粘贴你的访问 Token:",
|
||||||
|
"prompt.auth.username": "用户名/邮箱/手机号:",
|
||||||
|
"success.auth.logged_in_as": "✓ 已登录为 {login}",
|
||||||
|
"success.auth.logged_in_via_env": "✓ 已通过 {env} 环境变量登录",
|
||||||
|
"success.auth.logged_out": "✓ 已退出登录",
|
||||||
|
"success.auth.token_saved": "✓ Token 已保存",
|
||||||
|
"success.config.initialized": "✓ 配置已初始化:{path}",
|
||||||
|
"success.config.set": "✓ 已设置 {key} = {value}",
|
||||||
|
"warning.auth.not_logged_in": "✗ 未登录",
|
||||||
|
"warning.auth.token_unverified": "✓ Token 已保存(但无法验证:{message})",
|
||||||
|
"warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
const defaultFallbackLocale = "en-US"
|
||||||
|
|
||||||
|
// Options controls Translator construction.
|
||||||
|
type Options struct {
|
||||||
|
Locale string
|
||||||
|
FallbackLocale string
|
||||||
|
Loader Loader
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolveOptions contains locale inputs ordered by caller intent.
|
||||||
|
type ResolveOptions struct {
|
||||||
|
ExplicitLang string
|
||||||
|
Env map[string]string
|
||||||
|
ConfigLang string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedLocale struct {
|
||||||
|
Locale string
|
||||||
|
Source string
|
||||||
|
Requested string
|
||||||
|
Fallbacked bool
|
||||||
|
Supported bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveLocale resolves a locale using CLI flag, env, config, system env, fallback.
|
||||||
|
func ResolveLocale(opts ResolveOptions, available []string) string {
|
||||||
|
return ResolveLocaleDetailed(opts, available).Locale
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveLocaleDetailed(opts ResolveOptions, available []string) ResolvedLocale {
|
||||||
|
candidates := []struct {
|
||||||
|
source string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{source: "flag", value: opts.ExplicitLang},
|
||||||
|
{source: "env", value: envValue(opts.Env, "GITLINK_LANG")},
|
||||||
|
{source: "config", value: opts.ConfigLang},
|
||||||
|
{source: "lc_all", value: envValue(opts.Env, "LC_ALL")},
|
||||||
|
{source: "lang", value: envValue(opts.Env, "LANG")},
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if strings.TrimSpace(candidate.value) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
match := matchLocale(candidate.value, available, defaultFallbackLocale)
|
||||||
|
return ResolvedLocale{
|
||||||
|
Locale: match.Locale,
|
||||||
|
Source: candidate.source,
|
||||||
|
Requested: match.Requested,
|
||||||
|
Fallbacked: match.Fallbacked,
|
||||||
|
Supported: match.Supported,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match := matchLocale(defaultFallbackLocale, available, defaultFallbackLocale)
|
||||||
|
return ResolvedLocale{
|
||||||
|
Locale: match.Locale,
|
||||||
|
Source: "default",
|
||||||
|
Requested: match.Requested,
|
||||||
|
Fallbacked: match.Fallbacked,
|
||||||
|
Supported: match.Supported,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreScanLang reads --lang before Cobra constructs localized help text.
|
||||||
|
func PreScanLang(args []string) string {
|
||||||
|
for i, arg := range args {
|
||||||
|
if arg == "--lang" {
|
||||||
|
if i+1 < len(args) {
|
||||||
|
return args[i+1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(arg, "--lang=") {
|
||||||
|
return strings.TrimPrefix(arg, "--lang=")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvMap returns process environment as a string map.
|
||||||
|
func EnvMap() map[string]string {
|
||||||
|
env := make(map[string]string)
|
||||||
|
for _, item := range os.Environ() {
|
||||||
|
key, value, ok := strings.Cut(item, "=")
|
||||||
|
if ok {
|
||||||
|
env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func envValue(env map[string]string, key string) string {
|
||||||
|
if env == nil {
|
||||||
|
return os.Getenv(key)
|
||||||
|
}
|
||||||
|
return env[key]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPreScanLang(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{[]string{"--lang", "zh-CN", "repo"}, "zh-CN"},
|
||||||
|
{[]string{"repo", "--lang=zh-CN"}, "zh-CN"},
|
||||||
|
{[]string{"repo"}, ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := PreScanLang(tc.args); got != tc.want {
|
||||||
|
t.Fatalf("PreScanLang(%v) = %q, want %q", tc.args, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLocalePriority(t *testing.T) {
|
||||||
|
available := []string{"en-US", "zh-CN"}
|
||||||
|
got := ResolveLocale(ResolveOptions{
|
||||||
|
ExplicitLang: "en-US",
|
||||||
|
Env: map[string]string{"GITLINK_LANG": "zh-CN"},
|
||||||
|
ConfigLang: "zh-CN",
|
||||||
|
}, available)
|
||||||
|
if got != "en-US" {
|
||||||
|
t.Fatalf("ResolveLocale() = %q, want en-US", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "GitLink CLI locale messages",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"propertyNames": {
|
||||||
|
"pattern": "^(cmd|flag|error|prompt|success|warning|confirm|table|output)\\.[a-z0-9_.-]+$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
var placeholderPattern = regexp.MustCompile(`\{([A-Za-z_][A-Za-z0-9_]*)\}`)
|
||||||
|
|
||||||
|
func renderTemplate(message string, args Args) string {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return placeholderPattern.ReplaceAllStringFunc(message, func(match string) string {
|
||||||
|
name := match[1 : len(match)-1]
|
||||||
|
value, ok := args[name]
|
||||||
|
if !ok || value == nil {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
if stringer, ok := value.(fmt.Stringer); ok {
|
||||||
|
return stringer.String()
|
||||||
|
}
|
||||||
|
return fmt.Sprint(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTemplateArgs(message string) []string {
|
||||||
|
matches := placeholderPattern.FindAllStringSubmatch(message, -1)
|
||||||
|
seen := make(map[string]struct{}, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
seen[match[1]] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
args := make([]string, 0, len(seen))
|
||||||
|
for arg := range seen {
|
||||||
|
args = append(args, arg)
|
||||||
|
}
|
||||||
|
sort.Strings(args)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRenderTemplateKeepsMissingArgs(t *testing.T) {
|
||||||
|
got := renderTemplate("Delete {owner}/{repo}", Args{"owner": "alice"})
|
||||||
|
want := "Delete alice/{repo}"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("renderTemplate() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTemplateArgs(t *testing.T) {
|
||||||
|
got := extractTemplateArgs("Delete {owner}/{repo}/{owner}")
|
||||||
|
want := []string{"owner", "repo"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("args length = %d, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("arg[%d] = %q, want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Translator resolves localized messages with fallback behavior suitable for CLI use.
|
||||||
|
type Translator struct {
|
||||||
|
locale string
|
||||||
|
fallbackLocale string
|
||||||
|
messages map[string]string
|
||||||
|
fallback map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default returns an English translator for legacy migration only.
|
||||||
|
// New command code should receive *Translator explicitly.
|
||||||
|
func Default() *Translator {
|
||||||
|
tr, err := New(Options{Locale: defaultFallbackLocale})
|
||||||
|
if err != nil {
|
||||||
|
return &Translator{
|
||||||
|
locale: defaultFallbackLocale,
|
||||||
|
fallbackLocale: defaultFallbackLocale,
|
||||||
|
messages: map[string]string{},
|
||||||
|
fallback: map[string]string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Translator. Missing messages fall back to FallbackLocale.
|
||||||
|
func New(opts Options) (*Translator, error) {
|
||||||
|
loader := opts.Loader
|
||||||
|
if loader == nil {
|
||||||
|
loader = NewEmbedLoader()
|
||||||
|
}
|
||||||
|
|
||||||
|
available, err := loader.AvailableLocales()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbackLocale := opts.FallbackLocale
|
||||||
|
if fallbackLocale == "" {
|
||||||
|
fallbackLocale = defaultFallbackLocale
|
||||||
|
}
|
||||||
|
fallbackLocale = MatchLocale(fallbackLocale, available, defaultFallbackLocale)
|
||||||
|
locale := MatchLocale(opts.Locale, available, fallbackLocale)
|
||||||
|
|
||||||
|
fallback, err := loader.Load(fallbackLocale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load fallback locale: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := fallback
|
||||||
|
if locale != fallbackLocale {
|
||||||
|
messages, err = loader.Load(locale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load locale: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Translator{
|
||||||
|
locale: locale,
|
||||||
|
fallbackLocale: fallbackLocale,
|
||||||
|
messages: messages,
|
||||||
|
fallback: fallback,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Translator) Locale() string {
|
||||||
|
return t.locale
|
||||||
|
}
|
||||||
|
|
||||||
|
// T returns a localized message, falling back to en-US and then the key itself.
|
||||||
|
func (t *Translator) T(key string) string {
|
||||||
|
if t == nil {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
if value, ok := t.messages[key]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if value, ok := t.fallback[key]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tf returns a localized message with {name} placeholders rendered from args.
|
||||||
|
func (t *Translator) Tf(key string, args Args) string {
|
||||||
|
return renderTemplate(t.T(key), args)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mapLoader struct {
|
||||||
|
messages map[string]map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l mapLoader) Load(locale string) (map[string]string, error) {
|
||||||
|
messages, ok := l.messages[locale]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("missing locale")
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l mapLoader) AvailableLocales() ([]string, error) {
|
||||||
|
locales := make([]string, 0, len(l.messages))
|
||||||
|
for locale := range l.messages {
|
||||||
|
locales = append(locales, locale)
|
||||||
|
}
|
||||||
|
return locales, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranslatorReturnsLocalizedMessage(t *testing.T) {
|
||||||
|
tr, err := New(Options{
|
||||||
|
Locale: "zh-CN",
|
||||||
|
Loader: mapLoader{messages: map[string]map[string]string{
|
||||||
|
"en-US": {"cmd.root.short": "GitLink CLI"},
|
||||||
|
"zh-CN": {"cmd.root.short": "GitLink 命令行"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := tr.T("cmd.root.short"); got != "GitLink 命令行" {
|
||||||
|
t.Fatalf("expected localized message, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranslatorFallsBackToBaseThenKey(t *testing.T) {
|
||||||
|
tr, err := New(Options{
|
||||||
|
Locale: "zh-CN",
|
||||||
|
Loader: mapLoader{messages: map[string]map[string]string{
|
||||||
|
"en-US": {"cmd.root.short": "GitLink CLI"},
|
||||||
|
"zh-CN": {},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := tr.T("cmd.root.short"); got != "GitLink CLI" {
|
||||||
|
t.Fatalf("expected fallback message, got %q", got)
|
||||||
|
}
|
||||||
|
if got := tr.T("cmd.missing.short"); got != "cmd.missing.short" {
|
||||||
|
t.Fatalf("expected key fallback, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranslatorRendersArgs(t *testing.T) {
|
||||||
|
tr, err := New(Options{
|
||||||
|
Locale: "en-US",
|
||||||
|
Loader: mapLoader{messages: map[string]map[string]string{
|
||||||
|
"en-US": {"output.version": "gitlink-cli {version}"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := tr.Tf("output.version", Args{"version": "1.2.3"}); got != "gitlink-cli 1.2.3" {
|
||||||
|
t.Fatalf("expected rendered message, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var keyPattern = regexp.MustCompile(`^(cmd|flag|error|prompt|success|warning|confirm|table|output)\.[a-z0-9_.-]+$`)
|
||||||
|
|
||||||
|
// Problem describes a locale validation issue.
|
||||||
|
type Problem struct {
|
||||||
|
Locale string
|
||||||
|
Key string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Problem) String() string {
|
||||||
|
if p.Key == "" {
|
||||||
|
return fmt.Sprintf("%s: %s", p.Locale, p.Message)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%s: %s", p.Locale, p.Key, p.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks all locales against baseLocale.
|
||||||
|
func Validate(loader Loader, baseLocale string) ([]Problem, error) {
|
||||||
|
if loader == nil {
|
||||||
|
loader = NewEmbedLoader()
|
||||||
|
}
|
||||||
|
baseLocale = NormalizeLocale(baseLocale)
|
||||||
|
if baseLocale == "" {
|
||||||
|
baseLocale = defaultFallbackLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
locales, err := loader.AvailableLocales()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(locales)
|
||||||
|
|
||||||
|
allMessages := make(map[string]map[string]string, len(locales))
|
||||||
|
for _, locale := range locales {
|
||||||
|
normalized := NormalizeLocale(locale)
|
||||||
|
if normalized != locale {
|
||||||
|
return []Problem{{Locale: locale, Message: "locale filename is not normalized"}}, nil
|
||||||
|
}
|
||||||
|
messages, err := loader.Load(locale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allMessages[locale] = messages
|
||||||
|
}
|
||||||
|
|
||||||
|
base, ok := allMessages[baseLocale]
|
||||||
|
if !ok {
|
||||||
|
return []Problem{{Locale: baseLocale, Message: "base locale is missing"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var problems []Problem
|
||||||
|
for key, value := range base {
|
||||||
|
problems = append(problems, validateMessage(baseLocale, key, value)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, locale := range locales {
|
||||||
|
messages := allMessages[locale]
|
||||||
|
for key, value := range messages {
|
||||||
|
problems = append(problems, validateMessage(locale, key, value)...)
|
||||||
|
if _, ok := base[key]; !ok {
|
||||||
|
problems = append(problems, Problem{Locale: locale, Key: key, Message: "key is not present in base locale"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, baseValue := range base {
|
||||||
|
value, ok := messages[key]
|
||||||
|
if !ok {
|
||||||
|
problems = append(problems, Problem{Locale: locale, Key: key, Message: "missing key"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
baseArgs := extractTemplateArgs(baseValue)
|
||||||
|
args := extractTemplateArgs(value)
|
||||||
|
if !reflect.DeepEqual(baseArgs, args) {
|
||||||
|
problems = append(problems, Problem{
|
||||||
|
Locale: locale,
|
||||||
|
Key: key,
|
||||||
|
Message: fmt.Sprintf("template args mismatch: expected {%s}, got {%s}", strings.Join(baseArgs, ","), strings.Join(args, ",")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return problems, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMessage(locale, key, value string) []Problem {
|
||||||
|
var problems []Problem
|
||||||
|
if !keyPattern.MatchString(key) {
|
||||||
|
problems = append(problems, Problem{Locale: locale, Key: key, Message: "key does not match naming rules"})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
problems = append(problems, Problem{Locale: locale, Key: key, Message: "message is empty"})
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateEmbeddedLocales(t *testing.T) {
|
||||||
|
problems, err := Validate(NewEmbedLoader(), "en-US")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(problems) > 0 {
|
||||||
|
t.Fatalf("expected no problems, got %v", problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFindsMissingKeyAndArgMismatch(t *testing.T) {
|
||||||
|
problems, err := Validate(mapLoader{messages: map[string]map[string]string{
|
||||||
|
"en-US": {
|
||||||
|
"cmd.root.short": "Hello {name}",
|
||||||
|
"flag.owner": "Owner",
|
||||||
|
},
|
||||||
|
"zh-CN": {
|
||||||
|
"cmd.root.short": "你好",
|
||||||
|
},
|
||||||
|
}}, "en-US")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(problems) != 2 {
|
||||||
|
t.Fatalf("expected 2 problems, got %d: %v", len(problems), problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSuccessEnvelope(t *testing.T) {
|
||||||
|
env := SuccessEnvelope(map[string]interface{}{"key": "value"}, nil)
|
||||||
|
if !env.OK {
|
||||||
|
t.Fatal("expected OK=true")
|
||||||
|
}
|
||||||
|
if env.Data == nil {
|
||||||
|
t.Fatal("expected non-nil Data")
|
||||||
|
}
|
||||||
|
if env.Error != nil {
|
||||||
|
t.Fatal("expected nil Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuccessEnvelopeWithMeta(t *testing.T) {
|
||||||
|
meta := &Meta{Page: 1, Limit: 20, TotalCount: 100}
|
||||||
|
env := SuccessEnvelope("data", meta)
|
||||||
|
if env.Meta != meta {
|
||||||
|
t.Fatal("expected Meta to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorEnvelope(t *testing.T) {
|
||||||
|
env := ErrorEnvelope(404, "Not Found", "Check the URL")
|
||||||
|
if env.OK {
|
||||||
|
t.Fatal("expected OK=false")
|
||||||
|
}
|
||||||
|
if env.Data != nil {
|
||||||
|
t.Fatal("expected nil Data")
|
||||||
|
}
|
||||||
|
if env.Error == nil {
|
||||||
|
t.Fatal("expected non-nil Error")
|
||||||
|
}
|
||||||
|
if env.Error.Code != 404 {
|
||||||
|
t.Fatalf("Code = %v, want 404", env.Error.Code)
|
||||||
|
}
|
||||||
|
if env.Error.Message != "Not Found" {
|
||||||
|
t.Fatalf("Message = %q, want 'Not Found'", env.Error.Message)
|
||||||
|
}
|
||||||
|
if env.Error.Suggestion != "Check the URL" {
|
||||||
|
t.Fatalf("Suggestion = %q, want 'Check the URL'", env.Error.Suggestion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopeJSON(t *testing.T) {
|
||||||
|
env := SuccessEnvelope("hello", nil)
|
||||||
|
data, err := env.JSON()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("JSON() error: %v", err)
|
||||||
|
}
|
||||||
|
var decoded Envelope
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal JSON output: %v", err)
|
||||||
|
}
|
||||||
|
if !decoded.OK {
|
||||||
|
t.Fatal("expected OK=true in JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorInfoFields(t *testing.T) {
|
||||||
|
info := ErrorInfo{Code: 500, Message: "Internal Error", Suggestion: "Retry later"}
|
||||||
|
if info.Message != "Internal Error" {
|
||||||
|
t.Fatalf("Message = %q", info.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetaFields(t *testing.T) {
|
||||||
|
meta := Meta{Page: 2, Limit: 50, TotalCount: 200, Identity: "user1"}
|
||||||
|
if meta.Page != 2 || meta.Limit != 50 || meta.TotalCount != 200 || meta.Identity != "user1" {
|
||||||
|
t.Fatal("Meta fields don't match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrint(t *testing.T) {
|
||||||
|
env := SuccessEnvelope(map[string]interface{}{"status": "ok"}, nil)
|
||||||
|
if err := Print(env, "json"); err != nil {
|
||||||
|
t.Fatalf("Print error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintDefaultFormat(t *testing.T) {
|
||||||
|
env := SuccessEnvelope(map[string]interface{}{"status": "ok"}, nil)
|
||||||
|
if err := Print(env, ""); err != nil {
|
||||||
|
t.Fatalf("Print default format error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintToJSON(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := SuccessEnvelope(map[string]interface{}{"status": "ok"}, nil)
|
||||||
|
if err := PrintTo(&buf, env, "json"); err != nil {
|
||||||
|
t.Fatalf("PrintTo json: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), `"ok"`) {
|
||||||
|
t.Fatalf("expected ok in JSON output, got: %s", buf.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), `"status"`) {
|
||||||
|
t.Fatalf("expected status in JSON output, got: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintToYAML(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := SuccessEnvelope(map[string]interface{}{"status": "ok"}, nil)
|
||||||
|
if err := PrintTo(&buf, env, "yaml"); err != nil {
|
||||||
|
t.Fatalf("PrintTo yaml: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "ok") {
|
||||||
|
t.Fatalf("expected ok in YAML output, got: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintToTableSlice(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := SuccessEnvelope([]interface{}{
|
||||||
|
map[string]interface{}{"id": float64(1), "name": "test"},
|
||||||
|
map[string]interface{}{"id": float64(2), "name": "test2"},
|
||||||
|
}, nil)
|
||||||
|
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||||
|
t.Fatalf("PrintTo table slice: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "id") || !strings.Contains(out, "name") {
|
||||||
|
t.Fatalf("expected table headers, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "1") || !strings.Contains(out, "test") {
|
||||||
|
t.Fatalf("expected table data, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintToTableEmptySlice(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := SuccessEnvelope([]interface{}{}, nil)
|
||||||
|
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||||
|
t.Fatalf("PrintTo table empty: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "No results") {
|
||||||
|
t.Fatalf("expected 'No results', got: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintToTableSliceNonMap(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := SuccessEnvelope([]interface{}{"string1", "string2"}, nil)
|
||||||
|
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||||
|
t.Fatalf("PrintTo table non-map slice: %v", err)
|
||||||
|
}
|
||||||
|
// Should fallback to JSON
|
||||||
|
if !strings.Contains(buf.String(), "[") {
|
||||||
|
t.Fatalf("expected JSON array fallback, got: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintToTableMap(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := SuccessEnvelope(map[string]interface{}{
|
||||||
|
"key1": "val1",
|
||||||
|
"key2": "val2",
|
||||||
|
}, nil)
|
||||||
|
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||||
|
t.Fatalf("PrintTo table map: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "KEY") || !strings.Contains(out, "VALUE") {
|
||||||
|
t.Fatalf("expected KEY/VALUE headers, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintToTableMapComplex(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := SuccessEnvelope(map[string]interface{}{
|
||||||
|
"nested": map[string]interface{}{"a": "b"},
|
||||||
|
}, nil)
|
||||||
|
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||||
|
t.Fatalf("PrintTo table complex map: %v", err)
|
||||||
|
}
|
||||||
|
// Should fallback to JSON because of nested map
|
||||||
|
if !strings.Contains(buf.String(), "{") {
|
||||||
|
t.Fatalf("expected JSON fallback for complex map, got: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintToTableError(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := ErrorEnvelope(500, "server error", "try again")
|
||||||
|
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||||
|
t.Fatalf("PrintTo table error: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "Error: server error") {
|
||||||
|
t.Fatalf("expected error message, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "try again") {
|
||||||
|
t.Fatalf("expected suggestion, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintToTableErrorNoSuggestion(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := ErrorEnvelope(500, "server error", "")
|
||||||
|
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||||
|
t.Fatalf("PrintTo table error no suggestion: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if strings.Contains(out, "Suggestion:") {
|
||||||
|
t.Fatalf("should not have suggestion line, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintToTableNilData(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := SuccessEnvelope(nil, nil)
|
||||||
|
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||||
|
t.Fatalf("PrintTo table nil data: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "No data") {
|
||||||
|
t.Fatalf("expected 'No data', got: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintToDefaultFormat(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := SuccessEnvelope("test", nil)
|
||||||
|
if err := PrintTo(&buf, env, ""); err != nil {
|
||||||
|
t.Fatalf("PrintTo default format: %v", err)
|
||||||
|
}
|
||||||
|
// Default should be JSON
|
||||||
|
if !strings.Contains(buf.String(), `"ok"`) {
|
||||||
|
t.Fatalf("expected JSON output for default format, got: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintToUnknownFormat(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
env := SuccessEnvelope("test", nil)
|
||||||
|
if err := PrintTo(&buf, env, "xml"); err != nil {
|
||||||
|
t.Fatalf("PrintTo unknown format: %v", err)
|
||||||
|
}
|
||||||
|
// Unknown format should fallback to JSON
|
||||||
|
if !strings.Contains(buf.String(), `"ok"`) {
|
||||||
|
t.Fatalf("expected JSON fallback for unknown format, got: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasComplexValues(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
m map[string]interface{}
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"flat", map[string]interface{}{"a": "1", "b": "2"}, false},
|
||||||
|
{"nested map", map[string]interface{}{"a": map[string]interface{}{"x": "y"}}, true},
|
||||||
|
{"nested slice", map[string]interface{}{"a": []interface{}{1, 2}}, true},
|
||||||
|
{"empty", map[string]interface{}{}, false},
|
||||||
|
{"nil", nil, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := hasComplexValues(tt.m); got != tt.want {
|
||||||
|
t.Fatalf("hasComplexValues = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectKeys(t *testing.T) {
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"title": "test",
|
||||||
|
"id": float64(1),
|
||||||
|
"status": "open",
|
||||||
|
"custom_key": "val",
|
||||||
|
}
|
||||||
|
keys := collectKeys(m)
|
||||||
|
// Priority keys should come first
|
||||||
|
if len(keys) != 4 {
|
||||||
|
t.Fatalf("expected 4 keys, got %d", len(keys))
|
||||||
|
}
|
||||||
|
if keys[0] != "id" {
|
||||||
|
t.Fatalf("first key should be 'id', got %q", keys[0])
|
||||||
|
}
|
||||||
|
if keys[1] != "title" {
|
||||||
|
t.Fatalf("second key should be 'title', got %q", keys[1])
|
||||||
|
}
|
||||||
|
if keys[2] != "status" {
|
||||||
|
t.Fatalf("third key should be 'status', got %q", keys[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatValue(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
v interface{}
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"nil", nil, ""},
|
||||||
|
{"string", "hello", "hello"},
|
||||||
|
{"int", 42, "42"},
|
||||||
|
{"float", 3.14, "3.14"},
|
||||||
|
{"bool", true, "true"},
|
||||||
|
{"slice", []interface{}{1, 2, 3}, "[1,2,3]"},
|
||||||
|
{"map", map[string]interface{}{"a": "b"}, `{"a":"b"}`},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := formatValue(tt.v)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("formatValue = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMainHelp(t *testing.T) {
|
||||||
|
origArgs := os.Args
|
||||||
|
os.Args = []string{"gitlink-cli", "--help"}
|
||||||
|
defer func() { os.Args = origArgs }()
|
||||||
|
|
||||||
|
// Should not call os.Exit because --help returns nil
|
||||||
|
main()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Pre-commit hook: run fmt + vet + lint + test before every commit.
|
||||||
|
# Install: make hooks
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== gofmt ==="
|
||||||
|
unformatted=$(gofmt -s -l .)
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "ERROR: these files are not formatted:"
|
||||||
|
echo "$unformatted"
|
||||||
|
echo "Run: gofmt -s -w ."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " OK"
|
||||||
|
|
||||||
|
echo "=== go vet ==="
|
||||||
|
go vet ./...
|
||||||
|
echo " OK"
|
||||||
|
|
||||||
|
echo "=== golangci-lint ==="
|
||||||
|
golangci-lint run ./...
|
||||||
|
echo " OK"
|
||||||
|
|
||||||
|
echo "=== go test ==="
|
||||||
|
go test -race ./...
|
||||||
|
echo " OK"
|
||||||
|
|
||||||
|
echo "=== pre-commit passed ==="
|
||||||
|
|
@ -4,17 +4,19 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Shortcuts() []*common.Shortcut {
|
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
||||||
|
tr := shortcutTranslator(translators...)
|
||||||
return []*common.Shortcut{
|
return []*common.Shortcut{
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List branches",
|
Description: tr.T("cmd.branch.list.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -32,10 +34,10 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a branch",
|
Description: tr.T("cmd.branch.create.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
|
||||||
{Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"},
|
{Name: "from", Short: "f", Usage: tr.T("flag.branch.from"), Default: "master"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -59,9 +61,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "delete",
|
Name: "delete",
|
||||||
Description: "Delete a branch",
|
Description: tr.T("cmd.branch.delete.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -80,9 +82,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "protect",
|
Name: "protect",
|
||||||
Description: "Set branch protection",
|
Description: tr.T("cmd.branch.protect.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -101,9 +103,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "unprotect",
|
Name: "unprotect",
|
||||||
Description: "Remove branch protection",
|
Description: tr.T("cmd.branch.unprotect.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -119,3 +121,10 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
|
||||||
|
if len(translators) > 0 && translators[0] != nil {
|
||||||
|
return translators[0]
|
||||||
|
}
|
||||||
|
return i18n.Default()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
package branch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
shortcuts := Shortcuts()
|
||||||
|
for _, s := range shortcuts {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- list ---
|
||||||
|
|
||||||
|
func TestBranchList(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/owner/repo/branches.json" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(w, []interface{}{
|
||||||
|
map[string]interface{}{"name": "master"},
|
||||||
|
map[string]interface{}{"name": "develop"},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- create ---
|
||||||
|
|
||||||
|
func TestBranchCreate(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
t.Fatalf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v1/owner/repo/branches.json" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"name": "feature-x"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "create", map[string]string{"name": "feature-x", "from": "master"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchCreateDefaultFrom(t *testing.T) {
|
||||||
|
// When 'from' is not set, it defaults to "master"
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/owner/repo/branches.json" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"name": "feature-y"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "create", map[string]string{"name": "feature-y"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- delete ---
|
||||||
|
|
||||||
|
func TestBranchDelete(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/owner/repo/branches/delete.json" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"message": "deleted"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- protect ---
|
||||||
|
|
||||||
|
func TestBranchProtect(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/owner/repo/protected_branches.json" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"message": "protected"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "protect", map[string]string{"name": "master"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("protect failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- unprotect ---
|
||||||
|
|
||||||
|
func TestBranchUnprotect(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "DELETE" {
|
||||||
|
t.Fatalf("expected DELETE, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/owner/repo/protected_branches/master.json" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"message": "unprotected"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "unprotect", map[string]string{"name": "master"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unprotect failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP error paths ---
|
||||||
|
|
||||||
|
func TestBranchListHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("server error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchCreateHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("server error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "create", map[string]string{"name": "feature-x"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchDeleteHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("server error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchProtectHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("server error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "protect", map[string]string{"name": "master"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchUnprotectHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("server error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "unprotect", map[string]string{"name": "master"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,17 +4,19 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Shortcuts() []*common.Shortcut {
|
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
||||||
|
tr := shortcutTranslator(translators...)
|
||||||
return []*common.Shortcut{
|
return []*common.Shortcut{
|
||||||
{
|
{
|
||||||
Name: "builds",
|
Name: "builds",
|
||||||
Description: "List CI builds",
|
Description: tr.T("cmd.ci.builds.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -32,11 +34,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "logs",
|
Name: "logs",
|
||||||
Description: "View build logs",
|
Description: tr.T("cmd.ci.logs.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
{Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true},
|
||||||
{Name: "stage", Short: "s", Usage: "Stage number", Default: "1"},
|
{Name: "stage", Short: "s", Usage: tr.T("flag.ci.stage"), Default: "1"},
|
||||||
{Name: "step", Usage: "Step number", Default: "1"},
|
{Name: "step", Usage: tr.T("flag.ci.step"), Default: "1"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -60,9 +62,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "restart",
|
Name: "restart",
|
||||||
Description: "Restart a build",
|
Description: tr.T("cmd.ci.restart.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
{Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -78,9 +80,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "stop",
|
Name: "stop",
|
||||||
Description: "Stop a build",
|
Description: tr.T("cmd.ci.stop.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
{Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -96,3 +98,10 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
|
||||||
|
if len(translators) > 0 && translators[0] != nil {
|
||||||
|
return translators[0]
|
||||||
|
}
|
||||||
|
return i18n.Default()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
package ci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range Shortcuts() {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- builds ---
|
||||||
|
|
||||||
|
func TestCIBuilds(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/owner/repo/builds.json" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(w, []interface{}{
|
||||||
|
map[string]interface{}{"number": float64(1), "status": "success"},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "builds", map[string]string{"page": "1", "limit": "20"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("builds failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- logs ---
|
||||||
|
|
||||||
|
func TestCILogs(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/owner/repo/builds/5/logs/1/1.json" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"log": "Build output..."})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "logs", map[string]string{"build": "5", "stage": "1", "step": "1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("logs failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCILogsDefaults(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/owner/repo/builds/3/logs/1/1.json" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"log": "output"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "logs", map[string]string{"build": "3"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("logs with defaults failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- restart ---
|
||||||
|
|
||||||
|
func TestCIRestart(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/owner/repo/builds/7/restart.json" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"message": "restarted"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "restart", map[string]string{"build": "7"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("restart failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- stop ---
|
||||||
|
|
||||||
|
func TestCIStop(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "DELETE" {
|
||||||
|
t.Fatalf("expected DELETE, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/owner/repo/builds/7/stop.json" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"message": "stopped"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "stop", map[string]string{"build": "7"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stop failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP error paths ---
|
||||||
|
|
||||||
|
func TestCIBuildsHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("server error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "builds", map[string]string{"page": "1", "limit": "20"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCILogsHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("server error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "logs", map[string]string{"build": "5"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCIRestartHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("server error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "restart", map[string]string{"build": "7"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCIStopHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("server error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "stop", map[string]string{"build": "7"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,14 +3,20 @@ package common
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand.
|
// MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand.
|
||||||
func MountShortcut(parent *cobra.Command, s *Shortcut) {
|
func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Translator) {
|
||||||
|
tr := i18n.Default()
|
||||||
|
if len(translators) > 0 && translators[0] != nil {
|
||||||
|
tr = translators[0]
|
||||||
|
}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "+" + s.Name,
|
Use: "+" + s.Name,
|
||||||
Short: s.Description,
|
Short: s.Description,
|
||||||
|
Long: s.Long,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
// Collect flag values
|
// Collect flag values
|
||||||
flagValues := make(map[string]string)
|
flagValues := make(map[string]string)
|
||||||
|
|
@ -26,10 +32,16 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, err := NewRuntimeContext(flagValues)
|
ctx, err := NewRuntimeContext(flagValues, tr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for _, f := range s.Flags {
|
||||||
|
if f.Required && flagValues[f.Name] == "" {
|
||||||
|
_, err := ctx.RequireArg(f.Name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return s.Run(ctx)
|
return s.Run(ctx)
|
||||||
},
|
},
|
||||||
|
|
@ -48,17 +60,18 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
|
||||||
} else {
|
} else {
|
||||||
cmd.Flags().String(f.Name, f.Default, f.Usage)
|
cmd.Flags().String(f.Name, f.Default, f.Usage)
|
||||||
}
|
}
|
||||||
if f.Required {
|
|
||||||
cmd.MarkFlagRequired(f.Name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parent.AddCommand(cmd)
|
parent.AddCommand(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MountShortcuts mounts multiple shortcuts under a parent command.
|
// MountShortcuts mounts multiple shortcuts under a parent command.
|
||||||
func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut) {
|
func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut, translators ...*i18n.Translator) {
|
||||||
|
var tr *i18n.Translator
|
||||||
|
if len(translators) > 0 {
|
||||||
|
tr = translators[0]
|
||||||
|
}
|
||||||
for _, s := range shortcuts {
|
for _, s := range shortcuts {
|
||||||
MountShortcut(parent, s)
|
MountShortcut(parent, s, tr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,51 @@ func TestMountShortcutSupportsBoolFlags(t *testing.T) {
|
||||||
t.Fatalf("dry-run flag = %q, want true", got)
|
t.Fatalf("dry-run flag = %q, want true", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMountShortcuts(t *testing.T) {
|
||||||
|
var called []string
|
||||||
|
root := &cobra.Command{Use: "root"}
|
||||||
|
shortcuts := []*Shortcut{
|
||||||
|
{
|
||||||
|
Name: "first",
|
||||||
|
Flags: []Flag{
|
||||||
|
{Name: "name", Default: "default1"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *RuntimeContext) error {
|
||||||
|
called = append(called, "first")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "second",
|
||||||
|
Flags: []Flag{
|
||||||
|
{Name: "name", Default: "default2"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *RuntimeContext) error {
|
||||||
|
called = append(called, "second")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
MountShortcuts(root, shortcuts)
|
||||||
|
|
||||||
|
if len(root.Commands()) != 2 {
|
||||||
|
t.Fatalf("expected 2 subcommands, got %d", len(root.Commands()))
|
||||||
|
}
|
||||||
|
|
||||||
|
root.SetArgs([]string{"+first"})
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatalf("Execute +first: %v", err)
|
||||||
|
}
|
||||||
|
if len(called) != 1 || called[0] != "first" {
|
||||||
|
t.Fatalf("called = %v, want [first]", called)
|
||||||
|
}
|
||||||
|
|
||||||
|
root.SetArgs([]string{"+second"})
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatalf("Execute +second: %v", err)
|
||||||
|
}
|
||||||
|
if len(called) != 2 || called[1] != "second" {
|
||||||
|
t.Fatalf("called = %v, want [first second]", called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/context"
|
"github.com/gitlink-org/gitlink-cli/internal/context"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -15,6 +17,7 @@ import (
|
||||||
type Shortcut struct {
|
type Shortcut struct {
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
|
Long string
|
||||||
Flags []Flag
|
Flags []Flag
|
||||||
Run func(ctx *RuntimeContext) error
|
Run func(ctx *RuntimeContext) error
|
||||||
}
|
}
|
||||||
|
|
@ -36,10 +39,15 @@ type RuntimeContext struct {
|
||||||
Repo string
|
Repo string
|
||||||
Format string
|
Format string
|
||||||
Args map[string]string
|
Args map[string]string
|
||||||
|
Tr *i18n.Translator
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
|
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
|
||||||
func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
|
func NewRuntimeContext(args map[string]string, translators ...*i18n.Translator) (*RuntimeContext, error) {
|
||||||
|
tr := i18n.Default()
|
||||||
|
if len(translators) > 0 && translators[0] != nil {
|
||||||
|
tr = translators[0]
|
||||||
|
}
|
||||||
cli, err := client.New()
|
cli, err := client.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -57,6 +65,7 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
|
||||||
Repo: cmdutil.Repo,
|
Repo: cmdutil.Repo,
|
||||||
Format: format,
|
Format: format,
|
||||||
Args: args,
|
Args: args,
|
||||||
|
Tr: tr,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,7 +122,7 @@ func (ctx *RuntimeContext) Arg(name string) string {
|
||||||
func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
|
func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
|
||||||
v := ctx.Arg(name)
|
v := ctx.Arg(name)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
return "", fmt.Errorf("required flag --%s is missing", name)
|
return "", errors.New(ctx.Tr.Tf("error.missing_required_flag", i18n.Args{"name": name}))
|
||||||
}
|
}
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuntimeContextRepoPath(t *testing.T) {
|
||||||
|
ctx := &RuntimeContext{Owner: "owner", Repo: "repo"}
|
||||||
|
if got := ctx.RepoPath(); got != "/owner/repo" {
|
||||||
|
t.Fatalf("RepoPath = %q, want /owner/repo", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeContextArg(t *testing.T) {
|
||||||
|
ctx := &RuntimeContext{
|
||||||
|
Args: map[string]string{"key1": "val1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"key1", "val1"},
|
||||||
|
{"key2", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := ctx.Arg(tt.name); got != tt.want {
|
||||||
|
t.Fatalf("Arg(%q) = %q, want %q", tt.name, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeContextRequireArg(t *testing.T) {
|
||||||
|
ctx := &RuntimeContext{
|
||||||
|
Args: map[string]string{"required": "present"},
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := ctx.RequireArg("required")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RequireArg error: %v", err)
|
||||||
|
}
|
||||||
|
if v != "present" {
|
||||||
|
t.Fatalf("RequireArg = %q, want present", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ctx.RequireArg("missing")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing required arg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeContextCallAPI(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
t.Fatalf("expected GET, got %s", r.Method)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"data":"test"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx := &RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := ctx.CallAPI("GET", "/test", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CallAPI error: %v", err)
|
||||||
|
}
|
||||||
|
if !env.OK {
|
||||||
|
t.Fatal("expected OK=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeContextCallAPIWithQuery(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("state") != "open" {
|
||||||
|
t.Fatalf("expected state=open, got %s", r.URL.Query().Get("state"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"data":"test"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx := &RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
}
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("state", "open")
|
||||||
|
_, err := ctx.CallAPIWithQuery("GET", "/test", q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CallAPIWithQuery error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeContextOutput(t *testing.T) {
|
||||||
|
ctx := &RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: http.DefaultClient, BaseURL: "http://localhost"},
|
||||||
|
Format: "json",
|
||||||
|
}
|
||||||
|
// Output should succeed (data goes to stdout)
|
||||||
|
env := &output.Envelope{OK: true, Data: map[string]interface{}{"key": "val"}}
|
||||||
|
err := ctx.Output(env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Output error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeContextOutputData(t *testing.T) {
|
||||||
|
ctx := &RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: http.DefaultClient, BaseURL: "http://localhost"},
|
||||||
|
Format: "json",
|
||||||
|
}
|
||||||
|
err := ctx.OutputData(map[string]interface{}{"key": "val"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OutputData error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeContextResolveOwnerRepo(t *testing.T) {
|
||||||
|
// When Owner and Repo are already set, ResolveOwnerRepo should succeed
|
||||||
|
ctx := &RuntimeContext{Owner: "explicitOwner", Repo: "explicitRepo"}
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
t.Fatalf("ResolveOwnerRepo error: %v", err)
|
||||||
|
}
|
||||||
|
if ctx.Owner != "explicitOwner" || ctx.Repo != "explicitRepo" {
|
||||||
|
t.Fatalf("ResolveOwnerRepo changed values")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortcutStruct(t *testing.T) {
|
||||||
|
s := Shortcut{
|
||||||
|
Name: "test",
|
||||||
|
Description: "test shortcut",
|
||||||
|
Flags: []Flag{{Name: "verbose", Usage: "verbose output"}},
|
||||||
|
Run: func(ctx *RuntimeContext) error { return nil },
|
||||||
|
}
|
||||||
|
if s.Name != "test" {
|
||||||
|
t.Fatalf("Name = %q", s.Name)
|
||||||
|
}
|
||||||
|
if len(s.Flags) != 1 {
|
||||||
|
t.Fatalf("expected 1 flag, got %d", len(s.Flags))
|
||||||
|
}
|
||||||
|
if s.Run == nil {
|
||||||
|
t.Fatal("expected non-nil Run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlagStruct(t *testing.T) {
|
||||||
|
f := Flag{
|
||||||
|
Name: "output",
|
||||||
|
Short: "o",
|
||||||
|
Usage: "Output format",
|
||||||
|
Required: true,
|
||||||
|
Default: "json",
|
||||||
|
Bool: false,
|
||||||
|
}
|
||||||
|
if f.Name != "output" {
|
||||||
|
t.Fatalf("Name = %q", f.Name)
|
||||||
|
}
|
||||||
|
if f.Short != "o" {
|
||||||
|
t.Fatalf("Short = %q", f.Short)
|
||||||
|
}
|
||||||
|
if !f.Required {
|
||||||
|
t.Fatal("expected Required=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeContextPaginateAll(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"data":[{"id":1},{"id":2}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx := &RuntimeContext{
|
||||||
|
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||||
|
}
|
||||||
|
items, err := ctx.PaginateAll("/items", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PaginateAll error: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("PaginateAll returned %d items, want 2", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
package compare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
{
|
||||||
|
Name: "view",
|
||||||
|
Description: "Compare two branches, tags, or commits",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "head", Usage: "Source branch, tag, or commit", Required: true},
|
||||||
|
{Name: "base", Usage: "Target branch, tag, or commit", Required: true},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
head, err := ctx.RequireArg("head")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
base, err := ctx.RequireArg("base")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPI("GET", comparePath(ctx, head, base), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "files",
|
||||||
|
Description: "List changed files between two refs",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "head", Usage: "Source branch, tag, or commit", Required: true},
|
||||||
|
{Name: "base", Usage: "Target branch, tag, or commit", Required: true},
|
||||||
|
{Name: "file", Short: "f", Usage: "Filter by file path"},
|
||||||
|
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||||
|
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
head, err := ctx.RequireArg("head")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
base, err := ctx.RequireArg("base")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("page", ctx.Arg("page"))
|
||||||
|
q.Set("limit", ctx.Arg("limit"))
|
||||||
|
if file := ctx.Arg("file"); file != "" {
|
||||||
|
q.Set("filepath", file)
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", "/v1"+comparePath(ctx, head, base)+"/files", q)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparePath(ctx *common.RuntimeContext, head, base string) string {
|
||||||
|
return fmt.Sprintf("%s/compare/%s...%s", ctx.RepoPath(), encodeRef(head), encodeRef(base))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeRef(ref string) string {
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(ref))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
package compare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompareViewEncodesRefs(t *testing.T) {
|
||||||
|
var calledPath string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calledPath = r.URL.Path
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/owner/repo/compare/ZmVhdHVyZS9hcGk...bWFzdGVy.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"commits_count": 1})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runCompareShortcut(t, server, "view", map[string]string{
|
||||||
|
"head": "feature/api",
|
||||||
|
"base": "master",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("view shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
if calledPath == "" {
|
||||||
|
t.Fatal("server was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareFilesUsesV1EndpointWithFilters(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/compare/YnVnZml4...cmVsZWFzZS92MQ/files.json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("filepath"); got != "cmd/api/api.go" {
|
||||||
|
t.Fatalf("filepath query = %q, want cmd/api/api.go", got)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("page"); got != "2" {
|
||||||
|
t.Fatalf("page query = %q, want 2", got)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("limit"); got != "50" {
|
||||||
|
t.Fatalf("limit query = %q, want 50", got)
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"files": []interface{}{}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runCompareShortcut(t, server, "files", map[string]string{
|
||||||
|
"head": "bugfix",
|
||||||
|
"base": "release/v1",
|
||||||
|
"file": "cmd/api/api.go",
|
||||||
|
"page": "2",
|
||||||
|
"limit": "50",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("files shortcut failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeRefUsesRawURLBase64(t *testing.T) {
|
||||||
|
got := encodeRef("feature/api")
|
||||||
|
want := "ZmVhdHVyZS9hcGk"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("encodeRef() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCompareShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
|
t.Helper()
|
||||||
|
shortcut := findCompareShortcut(t, name)
|
||||||
|
ctx := &common.RuntimeContext{
|
||||||
|
Client: &client.Client{
|
||||||
|
HTTP: server.Client(),
|
||||||
|
BaseURL: server.URL,
|
||||||
|
},
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Format: "json",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
return shortcut.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCompareShortcut(t *testing.T, name string) *common.Shortcut {
|
||||||
|
t.Helper()
|
||||||
|
for _, shortcut := range Shortcuts() {
|
||||||
|
if shortcut.Name == name {
|
||||||
|
return shortcut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("shortcut %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||||
|
t.Fatalf("failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
package health
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// v1RepoPath constructs the v1 API path for a repository.
|
||||||
|
func v1RepoPath(owner, repo string) string {
|
||||||
|
return fmt.Sprintf("/v1/%s/%s", owner, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePRListStatus(state string) string {
|
||||||
|
switch state {
|
||||||
|
case "open", "opened":
|
||||||
|
return "0"
|
||||||
|
case "merged":
|
||||||
|
return "1"
|
||||||
|
case "closed":
|
||||||
|
return "2"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIssueListCategory(state string) string {
|
||||||
|
switch state {
|
||||||
|
case "open", "opened":
|
||||||
|
return "opened"
|
||||||
|
case "closed":
|
||||||
|
return "closed"
|
||||||
|
default:
|
||||||
|
return "all"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPRListPage(ctx *common.RuntimeContext, state string, page, limit int) ([]interface{}, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("page", fmt.Sprintf("%d", page))
|
||||||
|
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||||
|
if status := normalizePRListStatus(state); status != "" {
|
||||||
|
q.Set("status", status)
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx.Owner, ctx.Repo)+"/pulls", q)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, " CLI error: pr list state=%s page=%d: %v\n", state, page, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !env.OK {
|
||||||
|
err := fmt.Errorf("API error: pr list state=%s page=%d", state, page)
|
||||||
|
fmt.Fprintf(os.Stderr, " %v\n", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, ok := env.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected response type for pr list")
|
||||||
|
}
|
||||||
|
pulls, _ := data["pulls"].([]interface{})
|
||||||
|
return pulls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchPRDetail retrieves full PR detail to extract merged_at timestamp.
|
||||||
|
// The list API doesn't return merged_at; only the detail endpoint does.
|
||||||
|
func fetchPRDetail(ctx *common.RuntimeContext, prNumber int) (string, error) {
|
||||||
|
path := fmt.Sprintf("%s/pulls/%d", ctx.RepoPath(), prNumber)
|
||||||
|
env, err := ctx.CallAPI("GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("PR detail fetch #%d: %w", prNumber, err)
|
||||||
|
}
|
||||||
|
if !env.OK {
|
||||||
|
return "", fmt.Errorf("PR detail API error #%d", prNumber)
|
||||||
|
}
|
||||||
|
data, ok := env.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try multiple possible locations for merged_at in the detail response
|
||||||
|
for _, candidate := range []string{
|
||||||
|
"merged_at",
|
||||||
|
"mergedAt",
|
||||||
|
} {
|
||||||
|
// data.pull_request.merged_at
|
||||||
|
if pr, _ := data["pull_request"].(map[string]interface{}); pr != nil {
|
||||||
|
if v, _ := pr[candidate].(string); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// data.issue.merged_at
|
||||||
|
if issue, _ := data["issue"].(map[string]interface{}); issue != nil {
|
||||||
|
if v, _ := issue[candidate].(string); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// data.merged_at (flat)
|
||||||
|
if v, _ := data[candidate].(string); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchIssueListPage(ctx *common.RuntimeContext, owner, repo, state string, page, limit int) ([]interface{}, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("page", fmt.Sprintf("%d", page))
|
||||||
|
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||||
|
q.Set("category", normalizeIssueListCategory(state))
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(owner, repo)+"/issues", q)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, " CLI error: issue list state=%s page=%d: %v\n", state, page, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !env.OK {
|
||||||
|
err := fmt.Errorf("API error: issue list state=%s page=%d", state, page)
|
||||||
|
fmt.Fprintf(os.Stderr, " %v\n", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, ok := env.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected response type for issue list")
|
||||||
|
}
|
||||||
|
issues, _ := data["issues"].([]interface{})
|
||||||
|
return issues, nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue