forked from Gitlink/gitlink-cli
Compare commits
1 Commits
master
...
feat/relea
| Author | SHA1 | Date |
|---|---|---|
|
|
d070037b9d |
|
|
@ -1,30 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
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,3 +0,0 @@
|
|||
|
||||
gitlink-cli.exe
|
||||
/gitlink-cli
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
version: "2"
|
||||
|
||||
linters:
|
||||
default: none
|
||||
|
||||
enable:
|
||||
# Core: catch real bugs
|
||||
- errcheck # unchecked errors
|
||||
- govet # suspicious constructs
|
||||
- ineffassign # wasted assignments
|
||||
- staticcheck # comprehensive bug detection
|
||||
- unused # dead code
|
||||
|
||||
# Error handling
|
||||
- errorlint # errors.As / %w best practices
|
||||
|
||||
# Security
|
||||
- gosec # security issues
|
||||
|
||||
# Typos
|
||||
- misspell # spelling mistakes in identifiers
|
||||
|
||||
settings:
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # errcheck already handles unchecked errors
|
||||
- G304 # file inclusion by variable is expected for CLI tools
|
||||
|
||||
exclusions:
|
||||
paths:
|
||||
- vendor/
|
||||
- npm/
|
||||
- skills/
|
||||
- docs/
|
||||
rules:
|
||||
# Idiomatic Go: defer Close() error is intentionally ignored
|
||||
- linters: [errcheck]
|
||||
text: "Error return value of .*(resp\\.Body\\.Close|file\\.Close).*is not checked"
|
||||
# Output formatting: fmt.Fprint* errors are low-value
|
||||
- linters: [errcheck]
|
||||
text: "Error return value of `fmt\\.Fprintf?"
|
||||
# Test helpers: FlagSet.Set is setup code
|
||||
- linters: [errcheck]
|
||||
text: "Error return value of .*FlagSet.*\\.Set"
|
||||
# Best-effort output rendering
|
||||
- linters: [errcheck]
|
||||
path: render\.go$
|
||||
# errcheck: test helpers intentionally ignore return values
|
||||
- linters: [errcheck]
|
||||
path: _test\.go$
|
||||
# errorlint: type assertions are fine in tests
|
||||
- linters: [errorlint]
|
||||
path: _test\.go$
|
||||
# gosec: tests are not attack surface
|
||||
- linters: [gosec]
|
||||
path: _test\.go$
|
||||
# apiInt: intentional uint64->int truncation for API response parsing
|
||||
- linters: [gosec]
|
||||
text: "G115: integer overflow conversion uint64 -> int"
|
||||
30
Makefile
30
Makefile
|
|
@ -3,7 +3,7 @@ BINARY := gitlink-cli
|
|||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)'
|
||||
|
||||
.PHONY: build install clean test check vet fmt cover lint
|
||||
.PHONY: build install clean test
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||
|
|
@ -15,30 +15,4 @@ clean:
|
|||
rm -f $(BINARY)
|
||||
|
||||
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."
|
||||
go test ./...
|
||||
|
|
|
|||
370
README.md
370
README.md
|
|
@ -5,85 +5,16 @@
|
|||
[](https://golang.org)
|
||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
||||
|
||||
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and 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, CI/CD, and AI-powered workflows, with 40+ commands and 12 AI Agent [Skills](./skills/).
|
||||
|
||||
**[中文文档](./README.zh-CN.md)**
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#ai-agent-skills) · [Auth](#configure--use) · [Commands](#usage-examples) · [Contributing](#related-projects)
|
||||
|
||||
## Contributors
|
||||
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;">
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/wangyue111">wangyue111</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/wbtiger">wbtiger</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/Mengz">Mengz</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/yangsai">yangsai</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/mengcheng">mengcheng</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/images/avatars/User/149182?t=1779603476" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/muel">muel</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/Leo77">Leo77</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/yingjie">yingjie</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/topshare">topshare</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/dtwdtw" title="dtwdtw"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/D/53_166_51/120.png" width="40" height="40" alt="dtwdtw" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/dtwdtw">dtwdtw</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/recorder" title="recorder"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/R/141_201_87/120.png" width="40" height="40" alt="recorder" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/recorder">recorder</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/puygob236" title="Jiachen Li"><img src="https://www.gitlink.org.cn/images/avatars/User/149183?t=1778815174" width="40" height="40" alt="puygob236" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/puygob236">puygob236</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/co63oc" title="co63oc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/C/205_201_141/120.png" width="40" height="40" alt="co63oc" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/co63oc">co63oc</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/lindiwen23" title="lindiwen23"><img src="https://www.gitlink.org.cn/images/avatars/User/141609?t=1748270628" width="40" height="40" alt="lindiwen23" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/lindiwen23">lindiwen23</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/ohanabi" title="ohanabi"><img src="https://www.gitlink.org.cn/images/avatars/User/148166?t=1778230283" width="40" height="40" alt="ohanabi" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/ohanabi">ohanabi</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/jiangtx" title="jiangtx"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/J/67_157_94/120.png" width="40" height="40" alt="jiangtx" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/jiangtx">jiangtx</a></sub>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Why gitlink-cli?
|
||||
|
||||
- **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, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
|
||||
- **Agent-Native Design** — 12 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
|
||||
- **Wide Coverage** — Repository, Issue, PR, Branch, Release, CI, Org, Search, User — all core domains covered
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
|
||||
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
|
||||
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
|
||||
|
|
@ -95,17 +26,13 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
|
||||
| Category | Capabilities |
|
||||
|----------|-------------|
|
||||
| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions |
|
||||
| 📦 Repo | List, create, fork, delete repositories, view repo info |
|
||||
| 🐛 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 |
|
||||
| 👥 Member | List, add, remove repository members, change roles, create and accept invite links |
|
||||
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
|
||||
| 🏷️ Release | Create, edit, update, view, delete releases |
|
||||
| 🏷️ Release | Create, view, delete releases |
|
||||
| 🏢 Org | Manage organizations, members, teams |
|
||||
| 🔧 CI | View builds, logs, CI/CD operations |
|
||||
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
|
||||
| 🔔 Webhook | Manage repo webhooks and test deliveries |
|
||||
| 🔍 Search | Search repositories, users |
|
||||
| 👤 User | View user profiles and info |
|
||||
| 📋 PM | Sprint management, kanban boards, weekly reports |
|
||||
|
|
@ -128,7 +55,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
**From npm (recommended):**
|
||||
|
||||
```bash
|
||||
# One command: installs CLI binary + AI Agent Skills
|
||||
# One command: installs CLI binary + all 12 AI Agent Skills
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
|
|
@ -209,36 +136,6 @@ gitlink-cli repo +list
|
|||
# View repository info
|
||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
||||
|
||||
# Read repository README
|
||||
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# List repository files at root or a directory
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
|
||||
|
||||
# Show language breakdown
|
||||
gitlink-cli repo +languages --owner Gitlink --repo forgeplus
|
||||
|
||||
# List contributors
|
||||
gitlink-cli repo +contributors --owner Gitlink --repo forgeplus
|
||||
|
||||
# Show contributor code-line stats for a branch, tag, or commit
|
||||
gitlink-cli repo +contributor-stats --owner Gitlink --repo forgeplus --ref master --pass-year 1
|
||||
|
||||
# Show repository code stats
|
||||
gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# List watchers and stargazers in a time range
|
||||
gitlink-cli repo +watchers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
|
||||
gitlink-cli repo +stargazers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
|
||||
|
||||
# Preview and apply repository interaction actions
|
||||
gitlink-cli repo +follow --owner Gitlink --repo forgeplus --dry-run
|
||||
gitlink-cli repo +follow --owner Gitlink --repo forgeplus
|
||||
gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123
|
||||
gitlink-cli repo +like --owner Gitlink --repo forgeplus
|
||||
gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123
|
||||
|
||||
# Create a repository
|
||||
gitlink-cli repo +create -n my-project -d "Project description"
|
||||
|
||||
|
|
@ -246,45 +143,6 @@ gitlink-cli repo +create -n my-project -d "Project description"
|
|||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Webhook Management
|
||||
|
||||
```bash
|
||||
# List webhooks
|
||||
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Create a webhook
|
||||
gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
|
||||
--url https://example.com/hook --events push,create
|
||||
|
||||
# Test a webhook
|
||||
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
||||
|
||||
# View webhook delivery tasks
|
||||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### Member Management
|
||||
|
||||
```bash
|
||||
# List repository members
|
||||
gitlink-cli member +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Add a member
|
||||
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
|
||||
|
||||
# Preview batch add without changing data
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
|
||||
|
||||
# Batch add members from a CSV file
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
|
||||
|
||||
# Change a member role
|
||||
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
|
||||
|
||||
# Create an invite link
|
||||
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
|
||||
```
|
||||
|
||||
### Issue Management
|
||||
|
||||
```bash
|
||||
|
|
@ -294,15 +152,9 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
|
|||
# Create an issue
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..."
|
||||
|
||||
# Create an issue with metadata
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
|
||||
|
||||
# View an issue
|
||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
||||
|
||||
# Update issue metadata
|
||||
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
|
||||
|
||||
# Close an issue
|
||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||
|
||||
|
|
@ -314,45 +166,6 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
|
|||
|
||||
# Add a comment
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
|
||||
|
||||
# List issue assigners
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
|
||||
|
||||
# List issue authors
|
||||
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
|
||||
|
||||
# List issue priorities
|
||||
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus
|
||||
|
||||
# List issue tags
|
||||
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
|
||||
|
||||
# List issue statuses
|
||||
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
`issue +view`, `issue +update`, `issue +close`, and `issue +comment` prefer
|
||||
`--number` / `-n` for the issue number shown in the web URL. `--id` / `-i`
|
||||
is accepted as a compatibility alias for the same web issue number, not the
|
||||
global database ID.
|
||||
|
||||
### Label Management
|
||||
|
||||
```bash
|
||||
# List issue labels
|
||||
gitlink-cli label +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Filter labels by keyword
|
||||
gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug
|
||||
|
||||
# Create a label (color defaults to #1E90FF)
|
||||
gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "Something is broken" -c "#FF0000"
|
||||
|
||||
# Update a label (unspecified fields are preserved)
|
||||
gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00"
|
||||
|
||||
# Delete a label
|
||||
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
|
||||
```
|
||||
|
||||
### Pull Requests
|
||||
|
|
@ -373,24 +186,8 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
|||
# Merge a PR
|
||||
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# Reopen a closed PR
|
||||
gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# View changed files
|
||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# List PR patchset versions
|
||||
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# View a patchset version diff
|
||||
gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040
|
||||
|
||||
# List PR reviews
|
||||
gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# Create a PR review (with dry-run preview)
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
|
||||
```
|
||||
|
||||
### Branch Management
|
||||
|
|
@ -418,18 +215,11 @@ gitlink-cli branch +unprotect --name main
|
|||
# List releases
|
||||
gitlink-cli release +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Create a release with release notes and optional assets
|
||||
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 Stable" -b "Changelog..." --attachment-ids 12,34
|
||||
# Create a release
|
||||
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 Stable" -b "Changelog..."
|
||||
|
||||
# View a release
|
||||
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
|
||||
|
||||
# Get edit data and update while preserving unspecified fields
|
||||
gitlink-cli release +edit --owner Gitlink --repo forgeplus -i <version_id>
|
||||
gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b "Updated changelog" --dry-run
|
||||
|
||||
# Preview release deletion before executing it
|
||||
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --dry-run
|
||||
```
|
||||
|
||||
### CI/CD Operations
|
||||
|
|
@ -445,28 +235,6 @@ gitlink-cli ci +log --owner Gitlink --repo forgeplus -i <build_id>
|
|||
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
|
||||
```
|
||||
|
||||
### Pipeline Operations
|
||||
|
||||
```bash
|
||||
# List platform pipelines
|
||||
gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20
|
||||
|
||||
# List repository pipeline runs
|
||||
gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml
|
||||
|
||||
# Start a pipeline workflow, previewing the request first
|
||||
gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run
|
||||
|
||||
# Inspect pipeline details and logs
|
||||
gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7
|
||||
gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43
|
||||
gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99
|
||||
|
||||
# Toggle or delete pipeline workflows, previewing destructive writes first
|
||||
gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run
|
||||
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
```bash
|
||||
|
|
@ -477,105 +245,6 @@ gitlink-cli search +repos -k "machine learning"
|
|||
gitlink-cli search +users -k "zhangsan"
|
||||
```
|
||||
|
||||
### Workflow Agent Commands
|
||||
|
||||
`workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports:
|
||||
|
||||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +repo-report`
|
||||
|
||||
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
|
||||
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Triage with local parameters
|
||||
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
|
||||
|
||||
# Triage with JSON output
|
||||
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
|
||||
|
||||
# Triage with Chinese markdown output
|
||||
gitlink-cli workflow +triage \
|
||||
--title "安装失败,无法登录" \
|
||||
--body "运行命令时报错" \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
|
||||
# Triage from a local JSON file
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
|
||||
|
||||
# Triage by read-only GitLink fetch
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
|
||||
|
||||
# Health for a healthy repository
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--agent-readiness-known \
|
||||
--agent-readiness-score 9 \
|
||||
--format table
|
||||
|
||||
# Health for a risky repository
|
||||
gitlink-cli workflow +health \
|
||||
--repository demo/repo \
|
||||
--open-issues 60 \
|
||||
--stale-issues 25 \
|
||||
--open-prs 12 \
|
||||
--stale-prs 6 \
|
||||
--recent-activity-known \
|
||||
--recent-activity-days 120 \
|
||||
--release-known=false \
|
||||
--format json
|
||||
|
||||
# Health with Chinese markdown output
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
|
||||
# Health by read-only GitLink fetch
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
|
||||
|
||||
# PR review summary by read-only GitLink fetch
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
|
||||
|
||||
# PR review summary from a local JSON file
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
|
||||
|
||||
# Repository workflow report by read-only GitLink fetch
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
|
||||
# Repository workflow report from a local JSON file
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
||||
```
|
||||
|
||||
Output formats:
|
||||
|
||||
- `json` for scripts and AI Agents
|
||||
- `table` for terminal review
|
||||
- `markdown` for Issue comments, PR comments, release notes, and competition write-ups
|
||||
|
||||
Safety:
|
||||
|
||||
- Current workflow commands use local analysis by default and can also read GitLink data in read-only fetch mode.
|
||||
- They do not modify remote GitLink data.
|
||||
- They do not depend on LLM APIs.
|
||||
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
|
||||
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
|
||||
|
||||
### Raw API
|
||||
|
||||
For endpoints not covered by shortcuts, use the Raw API directly:
|
||||
|
|
@ -587,12 +256,6 @@ gitlink-cli api GET /users/me
|
|||
# POST request
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
|
||||
|
||||
# POST request with body from a file
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json
|
||||
|
||||
# POST request with body from stdin
|
||||
Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin
|
||||
|
||||
# With query parameters
|
||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
||||
```
|
||||
|
|
@ -603,7 +266,7 @@ gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
|||
|-----------|-------------|---------|
|
||||
| `--owner` | Repository owner | `--owner Gitlink` |
|
||||
| `--repo` | Repository name | `--repo forgeplus` |
|
||||
| `--format` | Output format (json/table/yaml; workflow also supports markdown) | `--format json` |
|
||||
| `--format` | Output format (json/table/yaml) | `--format json` |
|
||||
| `--debug` | Enable debug output | `--debug` |
|
||||
|
||||
**Automatic context resolution:** When running inside a git repository, `--owner` and `--repo` are automatically resolved from `git remote origin`.
|
||||
|
|
@ -630,27 +293,24 @@ git push gitlink
|
|||
|
||||
## AI Agent Skills
|
||||
|
||||
The `skills/` directory contains Agent Skill files for AI-automated GitLink operations.
|
||||
The `skills/` directory contains 12 Agent Skill files for AI-automated GitLink operations.
|
||||
|
||||
See [skills/README.md](skills/README.md) for details.
|
||||
|
||||
| Skill | Description |
|
||||
|-------|-------------|
|
||||
| `gitlink-shared` | Authentication, global parameters, safety rules, API notes |
|
||||
| `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) |
|
||||
| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) |
|
||||
| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
|
||||
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
|
||||
| `gitlink-member` | Repository member and invite link management |
|
||||
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
|
||||
| `gitlink-release` | Release management (create, edit, update, view, delete, etc.) |
|
||||
| `gitlink-release` | Release management (create, view, delete, etc.) |
|
||||
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
|
||||
| `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) |
|
||||
| `gitlink-search` | Search (repositories, users, etc.) |
|
||||
| `gitlink-org` | Organization management (members, teams, etc.) |
|
||||
| `gitlink-user` | User management (profile info, etc.) |
|
||||
| `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) |
|
||||
| `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) |
|
||||
| `gitlink-health` | Project health analysis (PR/Issue metrics aggregation, health reports) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
|
@ -673,12 +333,10 @@ gitlink-cli/
|
|||
│ ├── repo/ # Repository shortcuts
|
||||
│ ├── issue/ # Issue shortcuts
|
||||
│ ├── pr/ # PR shortcuts
|
||||
│ ├── member/ # Repository member shortcuts
|
||||
│ ├── branch/ # Branch shortcuts
|
||||
│ ├── release/ # Release shortcuts
|
||||
│ ├── org/ # Organization shortcuts
|
||||
│ ├── ci/ # CI shortcuts
|
||||
│ ├── pipeline/ # Pipeline shortcuts
|
||||
│ ├── search/ # Search shortcuts
|
||||
│ ├── user/ # User shortcuts
|
||||
│ └── register.go # Registration entry point
|
||||
|
|
@ -757,9 +415,7 @@ Reinstall first:
|
|||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
If the error persists, check whether the release page contains the asset for your platform,
|
||||
for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64.
|
||||
You can also download the binary manually from the release page or build from source with `go install .`.
|
||||
If the error persists, check whether the release page contains the asset for your platform, for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64. You can also download the binary manually from the release page or build from source with `go install .`.
|
||||
|
||||
### Q: Where are credentials stored on Windows?
|
||||
|
||||
|
|
|
|||
260
README.zh-CN.md
260
README.zh-CN.md
|
|
@ -5,85 +5,16 @@
|
|||
[](https://golang.org)
|
||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
||||
|
||||
[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
|
||||
[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 11 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
**[English](./README.md)**
|
||||
|
||||
[安装](#安装与快速上手) · [AI Agent Skills](#ai-agent-skills) · [认证](#配置与使用) · [命令](#使用示例) · [贡献](#相关项目)
|
||||
|
||||
## 贡献者
|
||||
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;">
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/wangyue111">wangyue111</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/wbtiger">wbtiger</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/Mengz">Mengz</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/yangsai">yangsai</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/mengcheng">mengcheng</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/images/avatars/User/149182?t=1779603476" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/muel">muel</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/Leo77">Leo77</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/yingjie">yingjie</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/topshare">topshare</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/dtwdtw" title="dtwdtw"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/D/53_166_51/120.png" width="40" height="40" alt="dtwdtw" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/dtwdtw">dtwdtw</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/recorder" title="recorder"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/R/141_201_87/120.png" width="40" height="40" alt="recorder" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/recorder">recorder</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/puygob236" title="Jiachen Li"><img src="https://www.gitlink.org.cn/images/avatars/User/149183?t=1778815174" width="40" height="40" alt="puygob236" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/puygob236">puygob236</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/co63oc" title="co63oc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/C/205_201_141/120.png" width="40" height="40" alt="co63oc" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/co63oc">co63oc</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/lindiwen23" title="lindiwen23"><img src="https://www.gitlink.org.cn/images/avatars/User/141609?t=1748270628" width="40" height="40" alt="lindiwen23" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/lindiwen23">lindiwen23</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/ohanabi" title="ohanabi"><img src="https://www.gitlink.org.cn/images/avatars/User/148166?t=1778230283" width="40" height="40" alt="ohanabi" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/ohanabi">ohanabi</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/jiangtx" title="jiangtx"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/J/67_157_94/120.png" width="40" height="40" alt="jiangtx" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/jiangtx">jiangtx</a></sub>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 为什么选择 gitlink-cli?
|
||||
|
||||
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
|
||||
- **Agent-Native 设计** — 开箱即用 11 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||
- **广泛覆盖** — 仓库、Issue、PR、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
|
||||
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
|
||||
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
|
||||
- **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用
|
||||
|
|
@ -95,16 +26,13 @@
|
|||
|
||||
| 分类 | 能力 |
|
||||
|------|------|
|
||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 |
|
||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
||||
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
|
||||
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
|
||||
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
||||
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
|
||||
| 🌿 分支 | 创建、删除、保护分支 |
|
||||
| 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release |
|
||||
| 🏷️ 发布 | 创建、查看、删除 Release |
|
||||
| 🏢 组织 | 管理组织、成员、团队 |
|
||||
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
|
||||
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
|
||||
| 🔍 搜索 | 搜索仓库、用户 |
|
||||
| 👤 用户 | 查看用户资料和信息 |
|
||||
| 📋 项目管理 | Sprint 管理、看板、周报 |
|
||||
|
|
@ -220,36 +148,6 @@ gitlink-cli repo +list
|
|||
# 查看仓库信息
|
||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
||||
|
||||
# 读取仓库 README
|
||||
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# 列出仓库根目录或指定目录文件
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
|
||||
|
||||
# 查看语言占比
|
||||
gitlink-cli repo +languages --owner Gitlink --repo forgeplus
|
||||
|
||||
# 列出贡献者
|
||||
gitlink-cli repo +contributors --owner Gitlink --repo forgeplus
|
||||
|
||||
# 查看分支、标签或提交的贡献者代码行统计
|
||||
gitlink-cli repo +contributor-stats --owner Gitlink --repo forgeplus --ref master --pass-year 1
|
||||
|
||||
# 查看仓库代码统计
|
||||
gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# 按时间范围查看关注者和点赞者
|
||||
gitlink-cli repo +watchers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
|
||||
gitlink-cli repo +stargazers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
|
||||
|
||||
# 预览并执行仓库互动操作
|
||||
gitlink-cli repo +follow --owner Gitlink --repo forgeplus --dry-run
|
||||
gitlink-cli repo +follow --owner Gitlink --repo forgeplus
|
||||
gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123
|
||||
gitlink-cli repo +like --owner Gitlink --repo forgeplus
|
||||
gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123
|
||||
|
||||
# 创建仓库
|
||||
gitlink-cli repo +create -n my-project -d "项目描述"
|
||||
|
||||
|
|
@ -257,45 +155,6 @@ gitlink-cli repo +create -n my-project -d "项目描述"
|
|||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Webhook 管理
|
||||
|
||||
```bash
|
||||
# 列出 webhook
|
||||
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建 webhook
|
||||
gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
|
||||
--url https://example.com/hook --events push,create
|
||||
|
||||
# 测试 webhook
|
||||
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
||||
|
||||
# 查看 webhook 投递任务
|
||||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### 成员管理
|
||||
|
||||
```bash
|
||||
# 列出仓库成员
|
||||
gitlink-cli member +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 添加成员
|
||||
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
|
||||
|
||||
# 预览批量添加成员,不修改数据
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
|
||||
|
||||
# 从 CSV 文件批量添加成员
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
|
||||
|
||||
# 调整成员权限
|
||||
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
|
||||
|
||||
# 生成邀请链接
|
||||
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
|
||||
```
|
||||
|
||||
### Issue 管理
|
||||
|
||||
```bash
|
||||
|
|
@ -305,15 +164,9 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
|
|||
# 创建 Issue
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."
|
||||
|
||||
# 创建带元数据的 Issue
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
|
||||
|
||||
# 查看 Issue
|
||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
||||
|
||||
# 更新 Issue 元数据
|
||||
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
|
||||
|
||||
# 关闭 Issue
|
||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||
|
||||
|
|
@ -325,44 +178,6 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
|
|||
|
||||
# 添加评论
|
||||
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
|
||||
|
|
@ -383,24 +198,8 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
|||
# 合并 PR
|
||||
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 重开已关闭的 PR
|
||||
gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看 PR 变更文件
|
||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看 PR patchset/version 列表
|
||||
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看指定 patchset/version diff
|
||||
gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040
|
||||
|
||||
# 查看 PR 审查记录
|
||||
gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 创建 PR 审查(支持 dry-run 预览)
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
|
||||
```
|
||||
|
||||
### 发布管理
|
||||
|
|
@ -409,40 +208,11 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved
|
|||
# 列出 Release
|
||||
gitlink-cli release +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建 Release,可附带附件 ID
|
||||
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..." --attachment-ids 12,34
|
||||
# 创建 Release
|
||||
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..."
|
||||
|
||||
# 查看 Release
|
||||
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
|
||||
|
||||
# 获取编辑数据并保留未传字段更新
|
||||
gitlink-cli release +edit --owner Gitlink --repo forgeplus -i <version_id>
|
||||
gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b "更新后的内容" --dry-run
|
||||
|
||||
# 删除前先预览请求
|
||||
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --dry-run
|
||||
```
|
||||
|
||||
### 流水线管理
|
||||
|
||||
```bash
|
||||
# 列出平台流水线
|
||||
gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20
|
||||
|
||||
# 列出仓库流水线运行记录
|
||||
gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml
|
||||
|
||||
# 运行流水线工作流,先用 dry-run 预览请求
|
||||
gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run
|
||||
|
||||
# 查看流水线详情、日志和运行结果
|
||||
gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7
|
||||
gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43
|
||||
gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99
|
||||
|
||||
# 启停或删除流水线工作流,写入/删除前先预览
|
||||
gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run
|
||||
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
|
||||
```
|
||||
|
||||
### 搜索
|
||||
|
|
@ -466,12 +236,6 @@ gitlink-cli api GET /users/me
|
|||
# POST 请求
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
|
||||
|
||||
# 从文件读取 JSON body
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json
|
||||
|
||||
# 从 stdin 读取 JSON body
|
||||
Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin
|
||||
|
||||
# 带查询参数
|
||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
||||
```
|
||||
|
|
@ -509,21 +273,19 @@ git push gitlink
|
|||
|
||||
## AI Agent Skills
|
||||
|
||||
`skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
|
||||
`skills/` 目录包含 11 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
|
||||
|
||||
详见 [skills/README.md](skills/README.md)
|
||||
|
||||
| Skill | 说明 |
|
||||
|-------|------|
|
||||
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
|
||||
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork、洞察数据等) |
|
||||
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork 等) |
|
||||
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
|
||||
| `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) |
|
||||
| `gitlink-member` | 仓库成员与邀请链接管理 |
|
||||
| `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) |
|
||||
| `gitlink-release` | 发布管理(创建、查看、删除等) |
|
||||
| `gitlink-org` | 组织管理(成员、团队等) |
|
||||
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
|
||||
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
|
||||
| `gitlink-search` | 搜索功能(仓库、用户等) |
|
||||
| `gitlink-user` | 用户管理(个人信息等) |
|
||||
| `gitlink-pm` | 项目管理(Sprint、看板、周报等) |
|
||||
|
|
@ -550,12 +312,10 @@ gitlink-cli/
|
|||
│ ├── repo/ # 仓库 shortcuts
|
||||
│ ├── issue/ # Issue shortcuts
|
||||
│ ├── pr/ # PR shortcuts
|
||||
│ ├── member/ # 仓库成员 shortcuts
|
||||
│ ├── branch/ # 分支 shortcuts
|
||||
│ ├── release/ # Release shortcuts
|
||||
│ ├── org/ # 组织 shortcuts
|
||||
│ ├── ci/ # CI shortcuts
|
||||
│ ├── pipeline/ # Pipeline shortcuts
|
||||
│ ├── search/ # 搜索 shortcuts
|
||||
│ ├── user/ # 用户 shortcuts
|
||||
│ └── register.go # 注册入口
|
||||
|
|
|
|||
106
cmd/api/api.go
106
cmd/api/api.go
|
|
@ -2,70 +2,37 @@ package api
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
func NewAPICmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
func NewAPICmd() *cobra.Command {
|
||||
apiCmd := &cobra.Command{
|
||||
Use: "api (<METHOD> <PATH> | --batch-file <FILE>)",
|
||||
Short: tr.T("cmd.api.short"),
|
||||
Long: tr.T("cmd.api.long"),
|
||||
Use: "api <METHOD> <PATH>",
|
||||
Short: "Make raw API requests to GitLink",
|
||||
Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`,
|
||||
Example: ` gitlink-cli api GET /users/me
|
||||
gitlink-cli api GET /projects --query 'page=1&limit=10'
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
|
||||
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,
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runAPI,
|
||||
}
|
||||
|
||||
apiCmd.Flags().String("body", "", tr.T("flag.api.body"))
|
||||
apiCmd.Flags().String("body-file", "", tr.T("flag.api.body_file"))
|
||||
apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin"))
|
||||
apiCmd.Flags().String("query", "", tr.T("flag.api.query"))
|
||||
apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header"))
|
||||
apiCmd.Flags().String("batch-file", "", tr.T("flag.api.batch_file"))
|
||||
apiCmd.Flags().Bool("dry-run", false, tr.T("flag.api.batch_dry_run"))
|
||||
apiCmd.Flags().Bool("continue-on-error", false, tr.T("flag.api.batch_continue_on_error"))
|
||||
apiCmd.Flags().StringArray("var", nil, tr.T("flag.api.batch_var"))
|
||||
apiCmd.Flags().String("body", "", "Request body (JSON string)")
|
||||
apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)")
|
||||
apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)")
|
||||
|
||||
return apiCmd
|
||||
}
|
||||
|
||||
func validateAPIArgs(c *cobra.Command, args []string) error {
|
||||
batchFile, _ := c.Flags().GetString("batch-file")
|
||||
if batchFile != "" {
|
||||
if len(args) != 0 {
|
||||
return fmt.Errorf("api batch mode does not accept METHOD or PATH arguments")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return cobra.ExactArgs(2)(c, args)
|
||||
}
|
||||
|
||||
func runAPI(c *cobra.Command, args []string) error {
|
||||
batchFile, _ := c.Flags().GetString("batch-file")
|
||||
if batchFile != "" {
|
||||
return runAPIBatch(c, batchFile)
|
||||
}
|
||||
|
||||
method := strings.ToUpper(args[0])
|
||||
path := args[1]
|
||||
|
||||
|
|
@ -79,9 +46,12 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
}
|
||||
cli.Debug = cmdutil.Debug
|
||||
|
||||
body, err := readJSONBody(c)
|
||||
if err != nil {
|
||||
return err
|
||||
var body interface{}
|
||||
bodyStr, _ := c.Flags().GetString("body")
|
||||
if bodyStr != "" {
|
||||
if err := json.Unmarshal([]byte(bodyStr), &body); err != nil {
|
||||
return fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var query url.Values
|
||||
|
|
@ -96,8 +66,7 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
|
||||
env, err := cli.Do(method, path, body, query)
|
||||
if err != nil {
|
||||
var apiErr *client.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if apiErr, ok := err.(*client.APIError); ok {
|
||||
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "")
|
||||
return output.Print(errEnv, resolveFormat())
|
||||
}
|
||||
|
|
@ -107,49 +76,6 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
return output.Print(env, resolveFormat())
|
||||
}
|
||||
|
||||
func readJSONBody(c *cobra.Command) (interface{}, error) {
|
||||
bodyStr, _ := c.Flags().GetString("body")
|
||||
bodyFile, _ := c.Flags().GetString("body-file")
|
||||
bodyStdin, _ := c.Flags().GetBool("body-stdin")
|
||||
|
||||
sources := 0
|
||||
if bodyStr != "" {
|
||||
sources++
|
||||
}
|
||||
if bodyFile != "" {
|
||||
sources++
|
||||
}
|
||||
if bodyStdin {
|
||||
sources++
|
||||
}
|
||||
if sources == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if sources > 1 {
|
||||
return nil, fmt.Errorf("use only one of --body, --body-file, or --body-stdin")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
var err error
|
||||
switch {
|
||||
case bodyStr != "":
|
||||
data = []byte(bodyStr)
|
||||
case bodyFile != "":
|
||||
data, err = os.ReadFile(bodyFile)
|
||||
case bodyStdin:
|
||||
data, err = io.ReadAll(c.InOrStdin())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read JSON body: %w", err)
|
||||
}
|
||||
|
||||
var body interface{}
|
||||
if err := json.Unmarshal(data, &body); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func resolveFormat() string {
|
||||
f := cmdutil.Format
|
||||
if f == "" {
|
||||
|
|
|
|||
|
|
@ -1,404 +0,0 @@
|
|||
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
|
||||
}
|
||||
351
cmd/api/batch.go
351
cmd/api/batch.go
|
|
@ -1,351 +0,0 @@
|
|||
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,182 +2,141 @@ package auth
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
)
|
||||
|
||||
const envTokenVar = "GITLINK_TOKEN"
|
||||
|
||||
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]
|
||||
}
|
||||
func NewAuthCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: tr.T("cmd.auth.short"),
|
||||
Short: "Authentication commands",
|
||||
}
|
||||
cmd.AddCommand(newLoginCmd(tr))
|
||||
cmd.AddCommand(newLogoutCmd(tr))
|
||||
cmd.AddCommand(newStatusCmd(tr))
|
||||
cmd.AddCommand(newLoginCmd())
|
||||
cmd.AddCommand(newLogoutCmd())
|
||||
cmd.AddCommand(newStatusCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLoginCmd(tr *i18n.Translator) *cobra.Command {
|
||||
func newLoginCmd() *cobra.Command {
|
||||
var tokenMode bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: tr.T("cmd.auth.login.short"),
|
||||
Short: "Login to GitLink",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if tokenMode {
|
||||
return loginWithToken(cmd.InOrStdin(), cmd.OutOrStdout(), tr)
|
||||
return loginWithToken()
|
||||
}
|
||||
return loginWithPassword(cmd.InOrStdin(), cmd.OutOrStdout(), tr)
|
||||
return loginWithPassword()
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token"))
|
||||
cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func loginWithPassword(in io.Reader, out io.Writer, tr *i18n.Translator) error {
|
||||
reader := bufio.NewReader(in)
|
||||
if _, err := fmt.Fprint(out, tr.T("prompt.auth.username")); err != nil {
|
||||
return err
|
||||
}
|
||||
func loginWithPassword() error {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Print("Username/Email/Phone: ")
|
||||
username, _ := reader.ReadString('\n')
|
||||
username = strings.TrimSpace(username)
|
||||
|
||||
if _, err := fmt.Fprint(out, tr.T("prompt.auth.password")); err != nil {
|
||||
return err
|
||||
}
|
||||
passwordBytes, err := readPassword(in, reader)
|
||||
fmt.Print("Password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
if _, err := fmt.Fprintln(out); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println()
|
||||
password := string(passwordBytes)
|
||||
|
||||
result, err := internalAuth.Login(username, password)
|
||||
if err != nil {
|
||||
return errors.New(tr.Tf("error.auth.login_failed", i18n.Args{"message": err.Error()}))
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(out, tr.Tf("success.auth.logged_in_as", i18n.Args{"login": result.Login}))
|
||||
return err
|
||||
fmt.Printf("✓ Logged in as %s\n", result.Login)
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPassword(in io.Reader, reader *bufio.Reader) ([]byte, error) {
|
||||
if file, ok := in.(*os.File); ok {
|
||||
fd := int(file.Fd())
|
||||
if term.IsTerminal(fd) {
|
||||
return term.ReadPassword(fd)
|
||||
}
|
||||
}
|
||||
password, err := reader.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(strings.TrimRight(password, "\r\n")), nil
|
||||
}
|
||||
|
||||
func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error {
|
||||
reader := bufio.NewReader(in)
|
||||
if _, err := fmt.Fprint(out, tr.T("prompt.auth.token")); err != nil {
|
||||
return err
|
||||
}
|
||||
func loginWithToken() error {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("Paste your token: ")
|
||||
token, _ := reader.ReadString('\n')
|
||||
token = strings.TrimSpace(token)
|
||||
|
||||
if token == "" {
|
||||
return errors.New(tr.T("error.auth.token_empty"))
|
||||
return fmt.Errorf("token cannot be empty")
|
||||
}
|
||||
|
||||
if err := storeToken(token); err != nil {
|
||||
return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()}))
|
||||
if err := internalAuth.StoreToken(token); err != nil {
|
||||
return fmt.Errorf("failed to store token: %w", err)
|
||||
}
|
||||
|
||||
_, err := fmt.Fprintln(out, tr.T("success.auth.token_saved"))
|
||||
return err
|
||||
fmt.Println("✓ Token saved")
|
||||
return nil
|
||||
}
|
||||
|
||||
func newLogoutCmd(tr *i18n.Translator) *cobra.Command {
|
||||
func newLogoutCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: tr.T("cmd.auth.logout.short"),
|
||||
Short: "Logout from GitLink",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := internalAuth.DeleteToken(); err != nil {
|
||||
return errors.New(tr.Tf("error.auth.delete_token_failed", i18n.Args{"message": err.Error()}))
|
||||
return fmt.Errorf("failed to delete token: %w", err)
|
||||
}
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out"))
|
||||
return err
|
||||
fmt.Println("✓ Logged out")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newStatusCmd(tr *i18n.Translator) *cobra.Command {
|
||||
func newStatusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: tr.T("cmd.auth.status.short"),
|
||||
Short: "Show authentication status",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
out := cmd.OutOrStdout()
|
||||
// Check env var token first
|
||||
if envToken := os.Getenv(envTokenVar); envToken != "" {
|
||||
if _, err := fmt.Fprintln(out, tr.Tf("success.auth.logged_in_via_env", i18n.Args{"env": envTokenVar})); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ Logged in via %s environment variable\n", envTokenVar)
|
||||
}
|
||||
|
||||
token, err := loadToken()
|
||||
token, err := internalAuth.LoadToken()
|
||||
if err != nil || token == "" {
|
||||
if os.Getenv(envTokenVar) == "" {
|
||||
if _, err := fmt.Fprintln(out, tr.T("warning.auth.not_logged_in")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out, tr.T("output.auth.login_hint")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out, tr.Tf("output.auth.env_hint", i18n.Args{"env": envTokenVar})); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("✗ Not logged in")
|
||||
fmt.Println(" Run: gitlink-cli auth login")
|
||||
fmt.Printf(" Or set %s environment variable\n", envTokenVar)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := internalAuth.GetCurrentUser()
|
||||
if err != nil {
|
||||
_, err := fmt.Fprintln(out, tr.Tf("warning.auth.token_unverified", i18n.Args{"message": err.Error()}))
|
||||
return err
|
||||
fmt.Printf("✓ Token stored (but cannot verify: %v)\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
login, _ := user["login"].(string)
|
||||
name, _ := user["name"].(string)
|
||||
if login != "" {
|
||||
text := tr.Tf("success.auth.logged_in_as", i18n.Args{"login": login})
|
||||
fmt.Printf("✓ Logged in as %s", login)
|
||||
if name != "" {
|
||||
text = fmt.Sprintf("%s (%s)", text, name)
|
||||
fmt.Printf(" (%s)", name)
|
||||
}
|
||||
_, err := fmt.Fprintln(out, text)
|
||||
return err
|
||||
fmt.Println()
|
||||
} else {
|
||||
fmt.Println("✓ Token stored (user info unavailable)")
|
||||
}
|
||||
_, err = fmt.Fprintln(out, tr.T("warning.auth.user_unavailable"))
|
||||
return err
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,243 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
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,5 +6,4 @@ var (
|
|||
Repo string
|
||||
Format string
|
||||
Debug bool
|
||||
Lang string
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,80 +1,59 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
)
|
||||
|
||||
func NewConfigCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
func NewConfigCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: tr.T("cmd.config.short"),
|
||||
Short: "Manage gitlink-cli configuration",
|
||||
}
|
||||
cmd.AddCommand(newInitCmd(tr))
|
||||
cmd.AddCommand(newSetCmd(tr))
|
||||
cmd.AddCommand(newGetCmd(tr))
|
||||
cmd.AddCommand(newListCmd(tr))
|
||||
cmd.AddCommand(newInitCmd())
|
||||
cmd.AddCommand(newSetCmd())
|
||||
cmd.AddCommand(newGetCmd())
|
||||
cmd.AddCommand(newListCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInitCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
func newInitCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "init",
|
||||
Short: tr.T("cmd.config.init.short"),
|
||||
Short: "Initialize configuration file",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg := internalConfig.DefaultConfig()
|
||||
if err := internalConfig.Save(cfg); err != nil {
|
||||
return errors.New(tr.Tf("error.config.save_failed", i18n.Args{"message": err.Error()}))
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.initialized", i18n.Args{"path": internalConfig.ConfigPath()}))
|
||||
return err
|
||||
fmt.Printf("✓ Config initialized at %s\n", internalConfig.ConfigPath())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newSetCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
func newSetCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "set <key> <value>",
|
||||
Short: tr.T("cmd.config.set.short"),
|
||||
Short: "Set a configuration value",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := internalConfig.Set(args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.set", i18n.Args{
|
||||
"key": args[0],
|
||||
"value": args[1],
|
||||
}))
|
||||
return err
|
||||
fmt.Printf("✓ %s = %s\n", args[0], args[1])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newGetCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
func newGetCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get <key>",
|
||||
Short: tr.T("cmd.config.get.short"),
|
||||
Short: "Get a configuration value",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
val, err := internalConfig.Get(args[0])
|
||||
|
|
@ -82,49 +61,30 @@ func newGetCmd(translators ...*i18n.Translator) *cobra.Command {
|
|||
return err
|
||||
}
|
||||
if val == "" {
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], tr.T("output.config.not_set"))
|
||||
return err
|
||||
fmt.Printf("%s: (not set)\n", args[0])
|
||||
} else {
|
||||
fmt.Printf("%s: %s\n", args[0], val)
|
||||
}
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], val)
|
||||
return err
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newListCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
func newListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: tr.T("cmd.config.list.short"),
|
||||
Short: "List all configuration values",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
if _, err := fmt.Fprintf(out, "base_url: %s\n", cfg.BaseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "default_format: %s\n", cfg.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "editor: %s\n", cfg.Editor); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "pager: %s\n", cfg.Pager); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "lang: %s\n", cfg.Lang); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(out, tr.Tf("output.config.file", i18n.Args{"path": internalConfig.ConfigPath()}))
|
||||
return err
|
||||
fmt.Printf("base_url: %s\n", cfg.BaseURL)
|
||||
fmt.Printf("default_format: %s\n", cfg.Format)
|
||||
fmt.Printf("editor: %s\n", cfg.Editor)
|
||||
fmt.Printf("pager: %s\n", cfg.Pager)
|
||||
fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,217 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
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"
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
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,129 +1,54 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
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"
|
||||
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
||||
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
|
||||
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
|
||||
doctorCmd "github.com/gitlink-org/gitlink-cli/cmd/doctor"
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
type RootOptions struct {
|
||||
Version string
|
||||
Args []string
|
||||
Env map[string]string
|
||||
ConfigLang string
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "gitlink-cli",
|
||||
Short: "GitLink CLI — command-line tool for gitlink.org.cn",
|
||||
Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
|
||||
if tr == nil {
|
||||
var err error
|
||||
tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)")
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)")
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)")
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
|
||||
|
||||
version := opts.Version
|
||||
if version == "" {
|
||||
version = Version
|
||||
}
|
||||
rootCmd.AddCommand(authCmd.NewAuthCmd())
|
||||
rootCmd.AddCommand(apiCmd.NewAPICmd())
|
||||
rootCmd.AddCommand(configCmd.NewConfigCmd())
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
|
||||
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
|
||||
shortcuts.RegisterAll(rootCmd)
|
||||
}
|
||||
|
||||
func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: tr.T("cmd.version.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("gitlink-cli %s\n", Version)
|
||||
},
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
args := os.Args[1:]
|
||||
rootCmd, err := NewRootCmd(RootOptions{
|
||||
Version: Version,
|
||||
Args: args,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTranslator(args []string, env map[string]string, configLang string) (*i18n.Translator, error) {
|
||||
available, err := i18n.AvailableLocales()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env == nil {
|
||||
env = i18n.EnvMap()
|
||||
}
|
||||
if configLang == "" {
|
||||
configLang = loadConfigLangBestEffort()
|
||||
}
|
||||
resolved := i18n.ResolveLocaleDetailed(i18n.ResolveOptions{
|
||||
ExplicitLang: i18n.PreScanLang(args),
|
||||
Env: env,
|
||||
ConfigLang: configLang,
|
||||
}, available)
|
||||
if !resolved.Supported && (resolved.Source == "flag" || resolved.Source == "env") {
|
||||
tr := i18n.Default()
|
||||
return nil, errors.New(tr.Tf("error.unsupported_language", i18n.Args{"lang": resolved.Requested}))
|
||||
}
|
||||
return i18n.New(i18n.Options{Locale: resolved.Locale})
|
||||
}
|
||||
|
||||
func loadConfigLangBestEffort() string {
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.Lang
|
||||
}
|
||||
|
|
|
|||
310
cmd/root_test.go
310
cmd/root_test.go
|
|
@ -1,310 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# 新增 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 不发请求、变量覆盖、模板缺失报错、失败默认中断以及失败继续执行等关键行为。
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# 认证凭据 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` 解析差异污染真实用户凭据目录。
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# 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 输出,确保诊断命令在常见失败场景下返回结构化结果而不是直接崩溃。
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# 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
|
||||
```
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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 直接复用。
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# 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 已同步更新。
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# 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 说明。
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# 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。
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# Release Update Shortcuts
|
||||
|
||||
Submitter: Wang Yue
|
||||
|
||||
This change completes the release shortcut coverage for the release edit/update OpenAPI endpoints and improves release write safety.
|
||||
|
||||
## Commands
|
||||
|
||||
- Add `release +edit` for `/api/{owner}/{repo}/releases/{id}/edit.json`.
|
||||
- Add `release +update` for `PUT /api/{owner}/{repo}/releases/{id}.json`.
|
||||
- Extend `release +create` with `--draft` and `--attachment-ids`.
|
||||
- Extend `release +delete` with `--dry-run`.
|
||||
|
||||
## Behavior
|
||||
|
||||
- `release +update` fetches current edit data first, then preserves unspecified fields such as `name`, `tag_name`, `body`, `target_commitish`, `draft`, `prerelease`, and existing attachment IDs.
|
||||
- `release +update` validates boolean flags before reading remote data.
|
||||
- `release +update` and `release +delete` support `--dry-run` to preview write/delete requests.
|
||||
- `release +create` validates boolean flags and de-duplicates comma-separated attachment IDs.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit tests cover create payloads, edit endpoint routing, update field preservation, attachment overrides, dry-run behavior, and invalid argument validation.
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# 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`
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
# 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`.
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
# repo +tree 仓库文件树查询命令
|
||||
|
||||
## 背景
|
||||
|
||||
`gitlink-cli repo` 已经提供仓库详情、README、语言统计和贡献者查询能力,但缺少直接查看仓库目录结构的 Shortcut。用户或 AI Agent 如果要判断仓库中是否存在 README、LICENSE、依赖清单、测试目录、文档目录等文件,过去需要手动调用 Raw API `/sub_entries`。
|
||||
|
||||
本次变更把仓库文件树查询封装为 `repo +tree`,降低普通用户和自动化工作流的使用门槛。
|
||||
|
||||
## 变更内容
|
||||
|
||||
- 新增 `gitlink-cli repo +tree` Shortcut。
|
||||
- 调用 `GET /{owner}/{repo}/sub_entries` 获取仓库根目录或指定目录下的文件和子目录。
|
||||
- 支持 `--path, -p` 指定目录路径;不传时查询仓库根目录。
|
||||
- 支持 `--ref, -r` 指定分支、标签或提交引用;默认值为 `master`。
|
||||
- 复用现有仓库上下文解析、API 调用和统一输出格式。
|
||||
- 补充中英文 i18n 文案,避免新增命令帮助信息硬编码。
|
||||
|
||||
## 命令示例
|
||||
|
||||
```bash
|
||||
# 查看仓库根目录
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# 查看指定目录
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
|
||||
|
||||
# Agent 场景建议使用 JSON 输出
|
||||
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --format json
|
||||
```
|
||||
|
||||
## 参数说明
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--path, -p` | 否 | 要查看的目录路径,不传时查询仓库根目录 |
|
||||
| `--ref, -r` | 否 | 分支、标签或提交引用,默认 `master` |
|
||||
| `--owner` | 否 | 全局参数,仓库所有者,可从 git remote 自动解析 |
|
||||
| `--repo` | 否 | 全局参数,仓库名称,可从 git remote 自动解析 |
|
||||
| `--format` | 否 | 全局参数,输出格式:`json`、`table` 或 `yaml` |
|
||||
|
||||
## 测试覆盖
|
||||
|
||||
单元测试覆盖以下内容:
|
||||
|
||||
- 根目录查询默认使用 `master`。
|
||||
- 根目录查询不发送空 `filepath` 参数。
|
||||
- 指定 `--path` 和 `--ref` 时正确映射到 `filepath` 与 `ref` 查询参数。
|
||||
- `repo +tree` 的命令说明和 `--path/-p`、`--ref/-r` 参数注册完整。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
## 交付要求核对
|
||||
|
||||
- 功能代码:`shortcuts/repo/repo.go`
|
||||
- 单元测试:`shortcuts/repo/repo_test.go`
|
||||
- 命令帮助文档:`README.md`、`README.zh-CN.md`、`skills/gitlink-repo/SKILL.md`、`skills/gitlink-repo/references/gitlink-repo-tree.md`
|
||||
- 变更说明文档:`doc/changes/repo-tree-shortcut.md`
|
||||
|
||||
## 兼容性
|
||||
|
||||
该变更只新增 Shortcut、单元测试和文档,不修改已有命令参数或输出结构。根目录查询时不再发送空 `filepath` 查询参数,语义更清晰,对现有功能无破坏性影响。
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# Webhook Shortcut
|
||||
|
||||
新增 `webhook` Shortcut 组,支持:
|
||||
|
||||
- `webhook +list`
|
||||
- `webhook +create`
|
||||
- `webhook +view`
|
||||
- `webhook +update`
|
||||
- `webhook +delete`
|
||||
- `webhook +test`
|
||||
|
||||
同时补充了对应单元测试、帮助文档和示例说明。
|
||||
|
|
@ -40,7 +40,7 @@ gitlink-cli/
|
|||
│ ├── common/
|
||||
│ │ ├── types.go # Shortcut / Flag / RuntimeContext 定义
|
||||
│ │ └── runner.go # CallAPI / PaginateAll / ResolveOwnerRepo
|
||||
│ ├── repo/ # repo +list / +info / +readme / +tree / +languages / +create ...
|
||||
│ ├── repo/ # repo +create / +clone / +fork / +list / +info
|
||||
│ ├── issue/ # issue +list / +create / +view / +close / +comment
|
||||
│ ├── pr/ # pr +list / +create / +view / +merge / +review
|
||||
│ ├── release/ # release +list / +create / +download
|
||||
|
|
@ -78,7 +78,7 @@ gitlink-cli/
|
|||
|
||||
| 领域 | Shortcuts | 数量 |
|
||||
|------|-----------|------|
|
||||
| repo | `+list` `+info` `+readme` `+tree` `+languages` `+contributors` `+contributor-stats` `+code-stats` `+watchers` `+stargazers` `+follow` `+unfollow` `+like` `+unlike` `+create` `+fork` `+delete` | 17 |
|
||||
| repo | `+create` `+clone` `+fork` `+list` `+info` `+delete` `+settings` | 7 |
|
||||
| issue | `+list` `+create` `+view` `+update` `+close` `+comment` `+assign` `+label` | 8 |
|
||||
| pr | `+list` `+create` `+view` `+merge` `+close` `+review` `+files` `+diff` | 8 |
|
||||
| release | `+list` `+create` `+view` `+delete` `+download` | 5 |
|
||||
|
|
|
|||
77
docs/i18n.md
77
docs/i18n.md
|
|
@ -1,77 +0,0 @@
|
|||
# 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.
|
||||
105
docs/pr-draft.md
105
docs/pr-draft.md
|
|
@ -1,105 +0,0 @@
|
|||
# feat(workflow): add agent workflow commands for repository maintenance
|
||||
|
||||
## Summary
|
||||
|
||||
This PR adds four read-only workflow commands for repository maintenance:
|
||||
|
||||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +repo-report`
|
||||
|
||||
The commands provide rule-based, explainable analysis with stable `json`, concise `table`,
|
||||
and copy-friendly `markdown` output.
|
||||
|
||||
## Motivation
|
||||
|
||||
Open-source maintainers often spend time on repetitive information organization before
|
||||
making actual decisions:
|
||||
|
||||
- Issue triage cost
|
||||
- PR review cost
|
||||
- repository health visibility
|
||||
- Agent needs stable structured output
|
||||
|
||||
This PR adds workflow-level analysis on top of the existing GitLink CLI shortcut architecture
|
||||
without introducing LLM dependencies or remote write behavior.
|
||||
|
||||
## Changes
|
||||
|
||||
### `workflow +triage`
|
||||
|
||||
- Classifies issues by type
|
||||
- Scores priority and confidence
|
||||
- Detects missing bug-report information
|
||||
- Produces risk flags, recommended actions, suggested comments, and reasoning
|
||||
|
||||
### `workflow +health`
|
||||
|
||||
- Scores repository health
|
||||
- Covers issue/PR backlog, activity, release, CI, docs, license, contributing, and Agent readiness signals
|
||||
- Tolerates unknown metrics without failing the command
|
||||
|
||||
### `workflow +pr-summary`
|
||||
|
||||
- Summarizes PR metadata, changed files, and commits
|
||||
- Produces change type, risk level, review focus, test suggestions, merge checklist, and reasoning
|
||||
- Supports local JSON input and remote read-only PR fetch
|
||||
|
||||
### `workflow +repo-report`
|
||||
|
||||
- Aggregates health, issue triage, and PR summary signals
|
||||
- Produces a repository workflow report with score, risk level, recommendations, and reasoning
|
||||
- Supports partial read-only remote aggregation when optional sections are unavailable
|
||||
|
||||
## Safety
|
||||
|
||||
- Remote mode is read-only
|
||||
- No LLM dependency
|
||||
- No labels/comments/close operations
|
||||
- No PR approve/reject/merge operations
|
||||
- No `internal/output` change
|
||||
- No new third-party dependency
|
||||
- Test fixtures do not contain secrets or tokens
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
|
||||
go test ./shortcuts/workflow
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Coverage includes:
|
||||
|
||||
- triage rules
|
||||
- health scoring
|
||||
- PR summary rules
|
||||
- repo report aggregation
|
||||
- fetch normalization
|
||||
- partial failure handling
|
||||
- `json` / `table` / `markdown` rendering
|
||||
- local `--from` fixtures
|
||||
- command wiring tests
|
||||
|
||||
## Documentation
|
||||
|
||||
- `README.md`
|
||||
- `docs/workflow-agent-design.md`
|
||||
- `docs/workflow-agent-test-report.md`
|
||||
- `skills/gitlink-workflow/SKILL.md`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- `workflow +release-notes` is not implemented.
|
||||
- `workflow +stale` is not implemented.
|
||||
- Real GitLink API shapes may require follow-up normalization.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
|
||||
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
|
||||
```
|
||||
|
|
@ -1,502 +0,0 @@
|
|||
# GitLink CLI Workflow Agent Design
|
||||
|
||||
## Background
|
||||
|
||||
`gitlink-cli` already provides low-level and shortcut operations for GitLink repositories,
|
||||
issues, pull requests, releases, CI, organizations, search, and users.
|
||||
The repository also includes `skills/gitlink-workflow/SKILL.md`, which describes
|
||||
AI workflow patterns such as Issue triage, PR review, and Release Notes generation.
|
||||
|
||||
The current Go command tree did not include a `workflow` command group before this work.
|
||||
The competition PR turns the documented workflow concept into concrete,
|
||||
deterministic CLI commands that can be used by human maintainers and AI Agents
|
||||
without calling an external LLM.
|
||||
|
||||
## Goals
|
||||
|
||||
First PR:
|
||||
- Add `gitlink-cli workflow +triage`.
|
||||
- Add `gitlink-cli workflow +health`.
|
||||
- Keep write behavior dry-run by default.
|
||||
- Produce stable JSON for Agents.
|
||||
- Produce concise table output for terminal users.
|
||||
- Produce markdown output for reports, PR comments, Issue comments, and competition materials.
|
||||
- Support `--lang en` and `--lang zh-CN` with a lightweight message helper.
|
||||
|
||||
Additional workflow commands:
|
||||
- `workflow +pr-summary`: done
|
||||
- `workflow +repo-report`: done
|
||||
- `workflow +release-notes`: planned
|
||||
- `workflow +stale`: planned
|
||||
|
||||
Current implementation status:
|
||||
- Rule engine: done
|
||||
- Local command layer: done
|
||||
- API fetch layer: done
|
||||
- Boundary tests: expanded for empty responses, field normalization,
|
||||
unknown tolerance, and read-only error handling
|
||||
- PR summary command: done with local JSON input, read-only fetch, rules, renderers, and tests
|
||||
- Repo report command: done with local JSON input, partial read-only fetch aggregation,
|
||||
scoring, renderers, and tests
|
||||
|
||||
## Current Repository Findings
|
||||
|
||||
Command registration:
|
||||
- `cmd/root.go` registers global flags and calls `shortcuts.RegisterAll(rootCmd)`.
|
||||
- `shortcuts/register.go` maps command groups to shortcut slices.
|
||||
- Each group exposes `Shortcuts() []*common.Shortcut`.
|
||||
- `common.MountShortcut` maps a `Shortcut` into a Cobra command named `+<name>`.
|
||||
|
||||
Runtime and API calls:
|
||||
- `common.NewRuntimeContext` creates `client.Client`, carries owner, repo, format, and command args.
|
||||
- `ctx.ResolveOwnerRepo()` resolves `--owner` / `--repo` or Git remote context.
|
||||
- `ctx.CallAPI` and `ctx.CallAPIWithQuery` call `internal/client`.
|
||||
- `client.Do` appends `.json`, injects auth via transport, parses GitLink error-in-body responses, and returns `output.Envelope`.
|
||||
|
||||
Output:
|
||||
- `internal/output` currently supports `json`, `yaml`, and generic `table`.
|
||||
- Workflow requires `markdown`; the minimal-risk approach is a workflow-local renderer that prints stable workflow DTOs.
|
||||
- A later cleanup can promote markdown support into `internal/output` if multiple command groups need it.
|
||||
- Current workflow commands also expose workflow-local `json`, `table`, and `markdown` rendering without changing the global formatter.
|
||||
|
||||
Testing:
|
||||
- Existing tests use pure unit tests plus `httptest.Server`.
|
||||
- Shortcut tests instantiate `common.RuntimeContext` manually with a mocked `client.Client`.
|
||||
- This pattern should be reused for workflow API tests.
|
||||
|
||||
## Command Design
|
||||
|
||||
### `workflow +triage`
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --dry-run --format json
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --format table
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --lang zh-CN --format markdown
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--state`: default `open`
|
||||
- `--limit`: default `30`
|
||||
- `--page`: default `1`
|
||||
- `--dry-run`: default `true`
|
||||
- `--from`: optional local JSON input
|
||||
- `--title`, `--body`, `--number`, `--author`, `--url`, `--labels`: optional local single-issue input
|
||||
- `--lang`: default `en`, allowed `en`, `zh-CN`
|
||||
|
||||
Stable JSON item fields:
|
||||
- `issue_id`
|
||||
- `number`
|
||||
- `title`
|
||||
- `url`
|
||||
- `author`
|
||||
- `state`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `detected_type`
|
||||
- `priority`
|
||||
- `confidence`
|
||||
- `suggested_labels`
|
||||
- `missing_information`
|
||||
- `risk_flags`
|
||||
- `recommended_action`
|
||||
- `suggested_comment`
|
||||
- `reasoning`
|
||||
|
||||
Rule categories:
|
||||
- `bug`
|
||||
- `feature`
|
||||
- `question`
|
||||
- `docs`
|
||||
- `ci`
|
||||
- `security`
|
||||
- `performance`
|
||||
- `refactor`
|
||||
- `unknown`
|
||||
|
||||
Priority:
|
||||
- `P0`: security incident, secret/token leak, auth bypass, repository unusable
|
||||
- `P1`: core command unusable, install/login failure, CI/release blocker
|
||||
- `P2`: normal bug, important feature, missing docs blocking usage
|
||||
- `P3`: ordinary question, typo, minor improvement
|
||||
|
||||
Missing information for bug-like issues:
|
||||
- reproduction steps
|
||||
- expected behavior
|
||||
- actual behavior
|
||||
- version
|
||||
- OS / platform
|
||||
- command output
|
||||
- logs
|
||||
|
||||
### `workflow +health`
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --format json
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --format table
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --lang zh-CN --format markdown
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--stale-days`: default `30`
|
||||
- `--from`: optional local JSON input
|
||||
- local metric flags such as `--repository`, `--open-issues`, `--open-prs`, `--has-readme`, `--has-license`, and `--agent-readiness-score`
|
||||
- `--lang`: default `en`
|
||||
|
||||
Stable JSON fields:
|
||||
- `repository`
|
||||
- `open_issues`
|
||||
- `open_prs`
|
||||
- `stale_issues`
|
||||
- `stale_prs`
|
||||
- `recent_activity`
|
||||
- `release_status`
|
||||
- `ci_status`
|
||||
- `documentation_status`
|
||||
- `license_status`
|
||||
- `contribution_status`
|
||||
- `agent_readiness_score`
|
||||
- `health_score`
|
||||
- `risk_level`
|
||||
- `recommendations`
|
||||
- `scoring_notes`
|
||||
|
||||
Scoring:
|
||||
- Issue backlog and response: 20
|
||||
- PR backlog and merge state: 20
|
||||
- Recent activity: 15
|
||||
- Release status: 15
|
||||
- Documentation completeness: 10
|
||||
- License and contribution readiness: 10
|
||||
- Agent readiness: 10
|
||||
|
||||
Unknown metric policy:
|
||||
- Keep field present.
|
||||
- Set status or score detail to `unknown`.
|
||||
- Add one entry to `scoring_notes`.
|
||||
- Either omit the metric from denominator or apply a conservative partial score; the first PR should prefer denominator adjustment to avoid fake precision.
|
||||
|
||||
Risk levels:
|
||||
- `low`: 80-100
|
||||
- `medium`: 60-79
|
||||
- `high`: 40-59
|
||||
- `critical`: 0-39
|
||||
|
||||
## Architecture
|
||||
|
||||
Proposed files:
|
||||
|
||||
```text
|
||||
shortcuts/workflow/
|
||||
workflow.go # Shortcuts() and command wiring
|
||||
types.go # Stable DTOs
|
||||
triage_rules.go # pure classifier, scoring, missing info detection
|
||||
triage_fetch.go # GitLink issue fetching and response normalization
|
||||
triage_render.go # json/table/markdown workflow rendering if needed
|
||||
health_score.go # pure health scoring
|
||||
health_fetch.go # repo, issue, PR, release, CI/doc/license probes
|
||||
health_render.go # markdown/table rendering
|
||||
messages.go # en and zh-CN strings
|
||||
*_test.go
|
||||
```
|
||||
|
||||
Registration:
|
||||
- Add `workflow` import in `shortcuts/register.go`.
|
||||
- Add `"workflow": workflow.Shortcuts()` to `groups`.
|
||||
- Add description `"AI agent workflow analysis"`.
|
||||
|
||||
No new dependency is needed for this PR.
|
||||
|
||||
## Data Normalization
|
||||
|
||||
GitLink responses vary by endpoint. Workflow code should not depend on a single raw shape. Add small extraction helpers:
|
||||
|
||||
- `stringField(map, keys...)`
|
||||
- `numberField(map, keys...)`
|
||||
- `timeField(map, keys...)`
|
||||
- `sliceField(map, keys...)`
|
||||
- `extractItems(env, candidateKeys...)`
|
||||
|
||||
Candidate issue list keys:
|
||||
- `issues`
|
||||
- `data`
|
||||
- direct array after future client improvements
|
||||
|
||||
Candidate issue fields:
|
||||
- ID: `id`, `issue_id`
|
||||
- Number: `project_issues_index`, `number`, `index`, `id`
|
||||
- Title: `subject`, `title`
|
||||
- Body: `description`, `body`
|
||||
- Author: `author.login`, `user.login`, `login`
|
||||
- URL: `html_url`, `url`, `issue_url`
|
||||
|
||||
Health activity fields currently tolerated:
|
||||
- `updated_at`
|
||||
- `updatedAt`
|
||||
- `last_updated_at`
|
||||
- `lastUpdatedAt`
|
||||
- `last_activity_at`
|
||||
- `lastActivityAt`
|
||||
- `merged_at`
|
||||
- `mergedAt`
|
||||
- `closed_at`
|
||||
- `closedAt`
|
||||
|
||||
## Safety Strategy
|
||||
|
||||
- `+triage` only reads by default.
|
||||
- `--dry-run` defaults true.
|
||||
- A future explicit write flag for posting comments must require `--dry-run=false` in a later PR.
|
||||
- Generated comments are output as data, not posted remotely in the first PR.
|
||||
- Health checks never mutate remote state.
|
||||
- If an API probe fails, health continues with `unknown`.
|
||||
- The implemented prototype is local-first and has no LLM dependency.
|
||||
- Remote fetch mode remains read-only and does not post comments, labels, merges, or close actions.
|
||||
- API failures should fall back to `unknown` metrics or a clear fetch error instead of fabricating healthy data.
|
||||
|
||||
## Core Pseudocode
|
||||
|
||||
### Triage
|
||||
|
||||
```go
|
||||
issues := fetchIssues(owner, repo, state, limit, page)
|
||||
results := []TriageResult{}
|
||||
for _, issue := range issues {
|
||||
text := normalize(issue.Title + "\n" + issue.Body)
|
||||
scores := scoreKeywords(text, keywordRules)
|
||||
detectedType := maxScoreType(scores)
|
||||
priority := scorePriority(text, detectedType)
|
||||
missing := detectMissingInfo(issue, detectedType)
|
||||
confidence := confidenceFromScores(scores, missing)
|
||||
result := TriageResult{
|
||||
IssueID: issue.ID,
|
||||
Number: issue.Number,
|
||||
DetectedType: detectedType,
|
||||
Priority: priority,
|
||||
SuggestedLabels: labelsFor(detectedType, priority, riskFlags),
|
||||
MissingInformation: missing,
|
||||
RiskFlags: detectRiskFlags(text),
|
||||
RecommendedAction: actionFor(detectedType, priority, missing, lang),
|
||||
SuggestedComment: commentFor(missing, lang),
|
||||
Reasoning: explainTopMatches(scores, priorityRules),
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
render(results, format, lang)
|
||||
```
|
||||
|
||||
### Health
|
||||
|
||||
```go
|
||||
signals := collectHealthSignals(owner, repo)
|
||||
score := NewWeightedScore(100)
|
||||
score.Add("issues", 20, scoreIssueBacklog(signals.OpenIssues, signals.StaleIssues))
|
||||
score.Add("prs", 20, scorePRBacklog(signals.OpenPRs, signals.StalePRs))
|
||||
score.Add("activity", 15, scoreRecentActivity(signals.RecentActivity))
|
||||
score.Add("release", 15, scoreReleaseStatus(signals.ReleaseStatus))
|
||||
score.Add("docs", 10, scoreDocStatus(signals.DocumentationStatus))
|
||||
score.Add("license", 10, scoreLicenseContribution(signals.LicenseStatus, signals.ContributionStatus))
|
||||
score.Add("agent", 10, scoreAgentReadiness(signals))
|
||||
result := HealthResult{
|
||||
HealthScore: score.Percent(),
|
||||
RiskLevel: riskLevel(score.Percent()),
|
||||
Recommendations: recommendations(signals, score),
|
||||
ScoringNotes: score.Notes(),
|
||||
}
|
||||
render(result, format, lang)
|
||||
```
|
||||
|
||||
## Output Protocol
|
||||
|
||||
JSON:
|
||||
- Use stable struct tags.
|
||||
- Include empty arrays as `[]` where useful for Agent consumption.
|
||||
- Avoid prose outside JSON.
|
||||
|
||||
Table:
|
||||
- Triage columns: `NUMBER`, `TYPE`, `PRIORITY`, `CONFIDENCE`, `MISSING`, `ACTION`
|
||||
- Health rows: `METRIC`, `STATUS`, `SCORE`, `NOTE`
|
||||
|
||||
Markdown:
|
||||
- Triage: one summary table with type, priority, confidence, action, and missing information.
|
||||
- Health: repository score, metric table, recommendations, and scoring notes.
|
||||
- `zh-CN` changes rule messages and recommendation text, not JSON field names.
|
||||
|
||||
## Test Plan
|
||||
|
||||
Unit tests:
|
||||
- Issue type classification.
|
||||
- Priority scoring.
|
||||
- Missing information detection.
|
||||
- Risk flag detection.
|
||||
- Suggested comment generation.
|
||||
- Health weighted score and risk level.
|
||||
- Unknown metric denominator adjustment.
|
||||
- Markdown headings and required sections.
|
||||
|
||||
Mock API tests:
|
||||
- `workflow +triage` fetches issues and normalizes raw response.
|
||||
- `workflow +health` tolerates failing CI/release/doc probes.
|
||||
|
||||
Command tests:
|
||||
- `--dry-run` defaults to true.
|
||||
- `--lang zh-CN` accepted.
|
||||
- invalid `--lang` falls back to `en`.
|
||||
- `--format markdown` routes to markdown renderer.
|
||||
|
||||
## Later Extensions
|
||||
|
||||
### `workflow +pr-summary`
|
||||
|
||||
Inputs:
|
||||
- `--number`
|
||||
- `--from`
|
||||
- `--lang`
|
||||
- `--format`
|
||||
- optional `--include-files`
|
||||
- optional `--include-commits`
|
||||
- optional `--max-files`
|
||||
- optional `--max-commits`
|
||||
|
||||
Default format:
|
||||
- `table` for human review when `--format` is omitted
|
||||
|
||||
Data:
|
||||
- PR details
|
||||
- changed files
|
||||
- commits
|
||||
|
||||
Output:
|
||||
- `change_type`
|
||||
- `risk_level`
|
||||
- `review_focus`
|
||||
- `test_suggestions`
|
||||
- `merge_checklist`
|
||||
- `reasoning`
|
||||
|
||||
Implementation status:
|
||||
- read-only local JSON mode: done
|
||||
- read-only GitLink fetch mode: done
|
||||
- rules and renderers: done
|
||||
- tests: rules, fetch boundary, render, and command wiring
|
||||
|
||||
Safety:
|
||||
- no comments
|
||||
- no approve/reject
|
||||
- no merge
|
||||
- no remote write operation
|
||||
|
||||
### `workflow +repo-report`
|
||||
|
||||
Inputs:
|
||||
- `--owner`
|
||||
- `--repo`
|
||||
- `--from`
|
||||
- `--lang`
|
||||
- `--format`
|
||||
- optional `--issue-limit`
|
||||
- optional `--pr-limit`
|
||||
- optional `--stale-days`
|
||||
- optional `--include-issues`
|
||||
- optional `--include-prs`
|
||||
- optional `--include-health`
|
||||
|
||||
Default format:
|
||||
- `markdown` for maintainer and competition reports when `--format` is omitted
|
||||
|
||||
Data:
|
||||
- repository health input and score
|
||||
- issue triage results aggregated by type, priority, risk, and missing information
|
||||
- PR summary results aggregated by type, risk, and review focus
|
||||
|
||||
Output:
|
||||
- `report_score`
|
||||
- `risk_level`
|
||||
- `health`
|
||||
- `issue_summary`
|
||||
- `pr_summary`
|
||||
- `recommendations`
|
||||
- `reasoning`
|
||||
|
||||
Partial report strategy:
|
||||
- health, issue, and PR sections are fetched independently
|
||||
- if at least one enabled section succeeds, the command returns a partial report
|
||||
- failed sections are recorded in scoring notes or reasoning
|
||||
- PR remote aggregation currently uses PR list metadata only;
|
||||
detailed changed files and commits remain available through `workflow +pr-summary --number`
|
||||
|
||||
Safety:
|
||||
- read-only aggregation only
|
||||
- no comments, labels, closes, approve/reject, or merge operations
|
||||
- no LLM dependency
|
||||
|
||||
### `workflow +release-notes`
|
||||
|
||||
Inputs:
|
||||
- `--from`
|
||||
- `--to`
|
||||
- optional `--tag`
|
||||
- optional `--lang`
|
||||
|
||||
Data:
|
||||
- PR titles
|
||||
- commit messages
|
||||
|
||||
Markdown categories:
|
||||
- Features
|
||||
- Bug Fixes
|
||||
- Documentation
|
||||
- Tests
|
||||
- Refactoring
|
||||
- Chores
|
||||
- Breaking Changes
|
||||
|
||||
### `workflow +stale`
|
||||
|
||||
Inputs:
|
||||
- `--stale-days`
|
||||
- `--state`
|
||||
- `--dry-run`
|
||||
|
||||
Behavior:
|
||||
- Identify stale issues and PRs.
|
||||
- Generate suggested comments or labels.
|
||||
- Do not mutate remote state by default.
|
||||
|
||||
## API Fetch Layer
|
||||
|
||||
The current fetch layer uses:
|
||||
|
||||
- `triage_fetch.go`
|
||||
- `health_fetch.go`
|
||||
- `pr_fetch.go`
|
||||
- `repo_report_fetch.go`
|
||||
|
||||
Design goals already applied:
|
||||
|
||||
- tolerate unknown or partial API fields
|
||||
- map GitLink response shapes into stable workflow DTOs
|
||||
- continue operating when optional signals fail
|
||||
- keep remote-write actions disabled until explicitly enabled later
|
||||
|
||||
Planned fetch-layer extension:
|
||||
|
||||
- `triage_fetch.go` and `health_fetch.go` remain the normalization boundary for remote mode.
|
||||
- `pr_fetch.go` now reuses the same stable DTO and message patterns for read-only PR metadata, changed files, and commits.
|
||||
- `repo_report_fetch.go` composes the existing fetch helpers and records partial failures instead of failing the whole report.
|
||||
- Future `release-notes` should reuse the same normalization and renderer patterns.
|
||||
- Unknown or missing fields should stay explicit in JSON output so Agents can decide how to proceed.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Pure DTOs and rule engine.
|
||||
2. Pure health scoring.
|
||||
3. Workflow renderers.
|
||||
4. Command registration.
|
||||
5. API fetch and normalization.
|
||||
6. Tests.
|
||||
7. README updates.
|
||||
8. Competition docs and test report.
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
# Workflow Agent Test Report
|
||||
|
||||
## Scope
|
||||
|
||||
This phase covers:
|
||||
|
||||
- Issue triage rules
|
||||
- health scoring rules
|
||||
- PR summary rules
|
||||
- repository report aggregation rules
|
||||
- local command execution
|
||||
- API fetch boundary tests
|
||||
- remote read-only manual verification
|
||||
- `json` / `table` / `markdown` rendering
|
||||
- language handling
|
||||
- mock tests do not depend on the real remote API
|
||||
|
||||
## Environment
|
||||
|
||||
- OS: Windows
|
||||
- Go version: `go1.26.1 windows/amd64`
|
||||
- Go path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\go.exe`
|
||||
- gofmt path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\gofmt.exe`
|
||||
|
||||
## Test Commands
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
|
||||
go test ./shortcuts/workflow
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
- `go test ./shortcuts/workflow` passed.
|
||||
- `go test ./...` passed.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
- triage rules tests
|
||||
- health score tests
|
||||
- messages tests
|
||||
- render tests
|
||||
- command tests
|
||||
- fetch boundary tests
|
||||
- PR summary rules and fetch tests
|
||||
- repo report aggregation, render, command, and partial fetch tests
|
||||
|
||||
## API Fetch Boundary Tests
|
||||
|
||||
- empty issue responses return a clear error instead of panicking
|
||||
- missing issue titles still allow body-only issues to be normalized
|
||||
- label normalization supports string arrays, object arrays, and title/name variants
|
||||
- author normalization supports string, `user`, and `creator` shapes
|
||||
- GitLink error-in-body responses return readable errors
|
||||
- health activity timestamps accept `updated_at`, `updatedAt`, `last_activity_at`, `merged_at`, and `closed_at`
|
||||
- release responses accept `releases`, `data`, and direct array shapes
|
||||
- CI unavailability is recorded as `unknown` without failing the whole health run
|
||||
- stale-days values `0` and negative values fall back to the default `30`
|
||||
- PR summary fetch normalizes PR metadata, changed files, commits, authors, branches, and list limits
|
||||
- PR summary tolerates partial files or commits fetch failures while keeping base PR metadata
|
||||
- PR summary base PR error-in-body responses return readable errors
|
||||
- repo report fetch composes health, issue, and PR sections
|
||||
- repo report returns a partial report when at least one enabled section succeeds
|
||||
- repo report returns an error when all enabled fetched sections fail
|
||||
- repo report issue and PR limits are covered
|
||||
|
||||
## Manual Command Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
|
||||
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
|
||||
gitlink-cli workflow +triage \
|
||||
--title "安装失败,无法登录" \
|
||||
--body "运行命令时报错" \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--agent-readiness-known \
|
||||
--agent-readiness-score 9 \
|
||||
--format table
|
||||
gitlink-cli workflow +health \
|
||||
--repository demo/repo \
|
||||
--open-issues 60 \
|
||||
--stale-issues 25 \
|
||||
--open-prs 12 \
|
||||
--stale-prs 6 \
|
||||
--recent-activity-known \
|
||||
--recent-activity-days 120 \
|
||||
--release-known=false \
|
||||
--format json
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
||||
```
|
||||
|
||||
## Remote Manual Verification
|
||||
|
||||
- Command: `gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table`
|
||||
- Result: succeeded, returned five issues in table form.
|
||||
- Command: `gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --lang zh-CN --format markdown`
|
||||
- Result: succeeded, returned a markdown health report with score `58` and risk level `high`.
|
||||
- Remote writes: `No`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Current workflow commands support local analysis and read-only GitLink fetch mode.
|
||||
- `workflow +triage` still supports local parameters or a local JSON file via `--from`.
|
||||
- `workflow +health` still supports local parameters or a local JSON file via `--from`.
|
||||
- `workflow +pr-summary` supports local JSON input and read-only GitLink fetch mode.
|
||||
- `workflow +repo-report` supports local JSON input and partial read-only GitLink fetch aggregation.
|
||||
- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata only;
|
||||
detailed file and commit analysis remains available through `workflow +pr-summary --number`.
|
||||
- `json/table/markdown` are rendered inside the workflow package, not by the global formatter.
|
||||
- Fetch-layer tests use `httptest` and do not depend on the real remote API.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The rule-based Agent Workflow prototype, including the read-only fetch layer, is implemented, tested, and locally runnable.
|
||||
|
||||
## Final Verification
|
||||
|
||||
Final verification should be run before opening the official GitLink PR:
|
||||
|
||||
```bash
|
||||
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
|
||||
go test ./shortcuts/workflow
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
- `go test ./shortcuts/workflow` passes.
|
||||
- `go test ./...` passes.
|
||||
- No remote write operation is performed by workflow commands.
|
||||
|
||||
## Competition Demo Commands
|
||||
|
||||
Prefer local fixtures for stable demos:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
|
||||
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
|
||||
```
|
||||
|
||||
Read-only remote smoke commands:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
```
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
outputs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
# 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 项目上复现
|
||||
- 提供运行脚本、验证记录、示例产物和单元测试
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# 架构说明
|
||||
|
||||
本项目采用“采集 -> 归一化 -> 分析 -> 生成 -> 发布”的五段式流程。
|
||||
|
||||

|
||||
|
||||
## 设计目标
|
||||
|
||||
- 低门槛:只依赖 `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
|
Before Width: | Height: | Size: 400 KiB |
|
|
@ -1,32 +0,0 @@
|
|||
# 示例输出摘要
|
||||
|
||||
## 验证目标
|
||||
|
||||
`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 的发布验证。
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# 快速开始
|
||||
|
||||
## 一键运行
|
||||
|
||||
直接运行一键脚本:
|
||||
|
||||
```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
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
# 运行手册
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 已安装 `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 回写能力。
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
# 提交核对清单
|
||||
|
||||
## 官方交付要求映射
|
||||
|
||||
| 要求 | 本项目对应内容 |
|
||||
| --- | --- |
|
||||
| 工作流串联不少于 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/` 提供固定示例产物,便于快速查看报告格式和输出内容。
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# 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/`。
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
# 验证记录
|
||||
|
||||
## 环境
|
||||
|
||||
- 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
|
||||
```
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"owner": "Gitlink",
|
||||
"repo": "gitlink-cli",
|
||||
"window_days": 7,
|
||||
"output_dir": "outputs"
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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,建议优先清理。
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# 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,建议优先清理。
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# 示例输出说明
|
||||
|
||||
本目录保存一次真实 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`。
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# gitlink-cli Release Notes 草稿
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:32:24 UTC
|
||||
|
||||
## 变更概览
|
||||
- 已合并 PR:0 个
|
||||
- 最近窗口内合并 PR:0 个
|
||||
|
||||
## 变更分类
|
||||
- 无
|
||||
|
||||
## 发布说明
|
||||
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# 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 权限。
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"owner": "puygob236",
|
||||
"repo": "gitlink-cli",
|
||||
"window_days": 7,
|
||||
"output_dir": "outputs"
|
||||
}
|
||||
|
|
@ -1,814 +0,0 @@
|
|||
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())
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
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()
|
||||
|
|
@ -1 +0,0 @@
|
|||
outputs/
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
# GitLink 项目一键初始化与协作启动工作流
|
||||
|
||||
面向 GitLink 竞赛子赛题三的端到端自动化工作流示例。
|
||||
|
||||
本项目聚焦开源项目从 0 到可协作状态的启动过程,使用 `gitlink-cli` 串联仓库检查、分支规划、初始 Issue 创建和结果回写等能力,自动生成 README、LICENSE、CI 配置、协作文档、初始化报告和结构化清单。该流程覆盖“项目配置 -> 初始化文件生成 -> GitLink 命令编排 -> 任务落地 -> 报告归档”的完整闭环。
|
||||
|
||||
## 交付物
|
||||
|
||||
- `scripts/bootstrap_project.go`:主工作流入口
|
||||
- `scripts/run_demo.ps1`:一键复现脚本
|
||||
- `examples/sample_project.json`:示例项目配置
|
||||
- `examples/verification_comment_config.json`:真实回写验证配置
|
||||
- `examples/demo_outputs/`:固定示例输出
|
||||
- `docs/workflow-spec.md`:工作流说明文档
|
||||
- `docs/architecture.md`:架构与流程说明
|
||||
- `docs/assets/bootstrap-architecture.svg`:架构图
|
||||
- `docs/quickstart.md`:最短复现路径
|
||||
- `docs/runbook.md`:运行手册
|
||||
- `docs/verification.md`:验证记录
|
||||
- `docs/submission-checklist.md`:赛题要求映射
|
||||
- `scripts/bootstrap_project_test.go`:Go 单元测试
|
||||
|
||||
## 实现语言
|
||||
|
||||
本工作流主实现采用 Go,主要考虑如下:
|
||||
|
||||
- 与 `gitlink-cli` 主仓库技术栈一致,便于维护者阅读、测试和后续集成。
|
||||
- 可直接复用 Go 标准库完成 JSON 配置解析、文件生成、命令编排和单元测试,不引入额外运行时依赖。
|
||||
- Windows、Linux 和 macOS 均可通过 `go run` 复现,便于评审在不同环境中执行。
|
||||
- 对命令执行结果、退出码和结构化日志的处理更接近 `gitlink-cli` 自身工程风格。
|
||||
|
||||
## 运行方式
|
||||
|
||||
进入本目录后执行 dry-run:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
执行后会生成:
|
||||
|
||||
- `outputs/*_bootstrap_report.md`
|
||||
- `outputs/*_summary.md`
|
||||
- `outputs/*_manifest.json`
|
||||
- `outputs/*_files.json`
|
||||
- `outputs/command_log_*.json`
|
||||
|
||||
输出文件名包含目标仓库和生成时间,格式如下:
|
||||
|
||||
- `{owner}_{repo}_{YYYYMMDD_HHMMSS}_bootstrap_report.md`
|
||||
- `{owner}_{repo}_{YYYYMMDD_HHMMSS}_summary.md`
|
||||
- `{owner}_{repo}_{YYYYMMDD_HHMMSS}_manifest.json`
|
||||
- `{owner}_{repo}_{YYYYMMDD_HHMMSS}_files.json`
|
||||
- `command_log_{YYYYMMDD_HHMMSS}.json`
|
||||
|
||||
例如 `puygob236_gitlink-bootstrap-demo_20260524_080000_bootstrap_report.md`。实际运行时会按当前时间生成新文件名,`examples/demo_outputs/` 中的固定时间戳文件仅作为示例产物。
|
||||
|
||||
如需执行真实 GitLink 写操作,在完成 GitLink 认证并核对目标仓库后使用:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Apply
|
||||
```
|
||||
|
||||
如需连同仓库创建一起执行:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Apply -CreateRepo
|
||||
```
|
||||
|
||||
如需把初始化摘要发布到指定 Issue:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1
|
||||
```
|
||||
|
||||
## 工作流串联的 gitlink-cli 调用
|
||||
|
||||
默认配置会规划 7 个 `gitlink-cli` 调用:
|
||||
|
||||
1. `repo +info`
|
||||
2. `branch +list`
|
||||
3. `branch +create`
|
||||
4. `branch +create`
|
||||
5. `issue +create`
|
||||
6. `issue +create`
|
||||
7. `issue +create`
|
||||
|
||||
当指定 `-PublishIssueNumber` 时,会额外追加 `issue +comment`,用于把初始化摘要回写到 GitLink Issue。
|
||||
当指定 `-CreateRepo` 时,会在检查仓库前追加 `repo +create`。
|
||||
|
||||
## 文档索引
|
||||
|
||||
- 工作流说明:`docs/workflow-spec.md`
|
||||
- 架构说明与架构图:`docs/architecture.md`
|
||||
- 复现指南:`docs/quickstart.md`
|
||||
- 运行手册:`docs/runbook.md`
|
||||
- 验证记录:`docs/verification.md`
|
||||
- 提交核对清单:`docs/submission-checklist.md`
|
||||
|
||||
## 场景价值
|
||||
|
||||
- 降低新开源项目启动成本,避免 README、License、CI、初始任务缺失。
|
||||
- 将项目初始化过程结构化,便于团队复用和审计。
|
||||
- 将 `gitlink-cli` 的仓库、分支、Issue 和评论能力串联为可复现方案。
|
||||
- 支持 dry-run 和 apply 两种模式,兼顾演示稳定性和真实落地。
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# 架构说明
|
||||
|
||||
本工作流采用“配置输入 -> 资产生成 -> CLI 编排 -> GitLink 落地 -> 结果归档”的五段式架构。正式架构图见 `docs/assets/bootstrap-architecture.svg`。
|
||||
|
||||

|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["项目配置<br/>sample_project.json"] --> B["资产生成<br/>README / LICENSE / CI / 协作文档"]
|
||||
B --> C["CLI 编排<br/>repo / branch / issue / comment"]
|
||||
C --> D["GitLink 项目空间<br/>仓库 / 分支 / Issue"]
|
||||
D --> E["结果归档<br/>报告 / 摘要 / manifest / 命令日志"]
|
||||
C --> E
|
||||
```
|
||||
|
||||
## 模块职责
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| 配置输入 | 描述项目名称、目标仓库、初始化分支和初始 Issue |
|
||||
| 资产生成 | 生成 README、LICENSE、CI 配置、贡献指南和路线图 |
|
||||
| CLI 编排 | 规划或执行 `gitlink-cli` 命令,串联仓库、分支、Issue 和评论能力 |
|
||||
| GitLink 落地 | 在真实 GitLink 仓库中创建分支、Issue,并可回写摘要 |
|
||||
| 结果归档 | 输出 Markdown 报告、摘要、JSON manifest 和命令日志 |
|
||||
|
||||
## 端到端链路
|
||||
|
||||
1. 读取 `examples/sample_project.json`。
|
||||
2. 生成初始化文件包。
|
||||
3. 规划 `repo +info` 和 `branch +list` 检查目标状态。
|
||||
4. 规划或执行 `branch +create` 创建协作分支。
|
||||
5. 规划或执行 `issue +create` 创建初始任务。
|
||||
6. 可选执行 `issue +comment` 发布初始化摘要。
|
||||
7. 生成报告与命令日志,支撑复现和审计。
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1672" height="941" viewBox="0 0 1672 941" role="img" aria-labelledby="title desc">
|
||||
<title id="title">GitLink Project Bootstrap Automation</title>
|
||||
<desc id="desc">A vector architecture diagram for a configuration-driven GitLink project bootstrap workflow.</desc>
|
||||
<defs>
|
||||
<filter id="cardShadow" x="-16%" y="-16%" width="132%" height="132%">
|
||||
<feDropShadow dx="0" dy="12" stdDeviation="11" flood-color="#0a1b35" flood-opacity="0.11"/>
|
||||
</filter>
|
||||
<style>
|
||||
text { font-family: Arial, "Microsoft YaHei", sans-serif; fill: #0b1736; }
|
||||
.title { font-size: 72px; font-weight: 800; letter-spacing: 0; }
|
||||
.subtitle { font-size: 27px; fill: #46556b; }
|
||||
.card-title { font-size: 34px; font-weight: 800; }
|
||||
.cli-title { font-size: 30px; font-weight: 800; }
|
||||
.title-navy { fill: #071449; }
|
||||
.title-teal { fill: #075e70; }
|
||||
.title-blue { fill: #14579f; }
|
||||
.title-slate { fill: #334155; }
|
||||
.item { font-size: 23px; fill: #101a2f; }
|
||||
.item-condensed { font-size: 22px; fill: #101a2f; }
|
||||
.small { font-size: 20px; fill: #123e21; }
|
||||
.mono { font-family: Consolas, "Courier New", monospace; font-size: 19px; fill: #101a2f; }
|
||||
.hairline { stroke: #a8b5c4; stroke-width: 1.6; }
|
||||
.icon-line { fill: none; stroke-width: 3.5; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.bullet-navy { fill: #071449; }
|
||||
.bullet-teal { fill: #075e70; }
|
||||
.bullet-blue { fill: #14579f; }
|
||||
.bullet-slate { fill: #334155; }
|
||||
.valid { fill: #32833f; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect width="1672" height="941" fill="#fbfcfe"/>
|
||||
<text x="836" y="93" text-anchor="middle" class="title">GitLink Project Bootstrap Automation</text>
|
||||
<text x="836" y="151" text-anchor="middle" class="subtitle">Configuration-driven project initialization, CLI orchestration, GitLink execution, and reproducible evidence</text>
|
||||
|
||||
<!-- Input -->
|
||||
<g transform="translate(48 208)">
|
||||
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#071449" stroke-width="2.2" filter="url(#cardShadow)"/>
|
||||
<g transform="translate(72 36)" class="icon-line" stroke="#071449">
|
||||
<path d="M10 0h55l28 28v102H10z"/>
|
||||
<path d="M65 0v28h28"/>
|
||||
<text x="50" y="78" text-anchor="middle" font-family="Consolas, 'Courier New', monospace" font-size="34" font-weight="700" fill="#071449" stroke="none">{ }</text>
|
||||
<path d="M32 92h38"/>
|
||||
<path d="M32 111h29"/>
|
||||
</g>
|
||||
<text x="128" y="196" text-anchor="middle" class="card-title title-navy">Input</text>
|
||||
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
|
||||
<circle cx="24" cy="259" r="4.5" class="bullet-navy"/><text x="43" y="267" class="item">Project metadata</text>
|
||||
<circle cx="24" cy="305" r="4.5" class="bullet-navy"/><text x="43" y="313" class="item">Repository target</text>
|
||||
<circle cx="24" cy="351" r="4.5" class="bullet-navy"/><text x="43" y="359" class="item">Branches</text>
|
||||
<circle cx="24" cy="397" r="4.5" class="bullet-navy"/><text x="43" y="405" class="item">Initial issues</text>
|
||||
<circle cx="24" cy="443" r="4.5" class="bullet-navy"/><text x="43" y="451" class="mono">sample_project.json</text>
|
||||
</g>
|
||||
|
||||
<!-- Assets -->
|
||||
<g transform="translate(377 208)">
|
||||
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#075e70" stroke-width="2.2" filter="url(#cardShadow)"/>
|
||||
<g transform="translate(64 35)" class="icon-line" stroke="#075e70">
|
||||
<path d="M2 45h17v84H2z"/>
|
||||
<path d="M22 25h58l23 23v84H22z"/>
|
||||
<path d="M80 25v23h23"/>
|
||||
<path d="M43 6h59l23 23v84h-22"/>
|
||||
<path d="M102 6v23h23"/>
|
||||
<path d="M43 58h44"/>
|
||||
<path d="M43 80h48"/>
|
||||
<path d="M43 102h40"/>
|
||||
</g>
|
||||
<text x="128" y="196" text-anchor="middle" class="card-title title-teal">Assets</text>
|
||||
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
|
||||
<circle cx="24" cy="259" r="4.5" class="bullet-teal"/><text x="43" y="267" class="item">README</text>
|
||||
<circle cx="24" cy="305" r="4.5" class="bullet-teal"/><text x="43" y="313" class="item">LICENSE</text>
|
||||
<circle cx="24" cy="351" r="4.5" class="bullet-teal"/><text x="43" y="359" class="item">Go CI</text>
|
||||
<circle cx="24" cy="397" r="4.5" class="bullet-teal"/><text x="43" y="405" class="item">CONTRIBUTING</text>
|
||||
<circle cx="24" cy="443" r="4.5" class="bullet-teal"/><text x="43" y="451" class="item">ROADMAP</text>
|
||||
</g>
|
||||
|
||||
<!-- CLI -->
|
||||
<g transform="translate(705 208)">
|
||||
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#14579f" stroke-width="2.2" filter="url(#cardShadow)"/>
|
||||
<g transform="translate(79 41)" class="icon-line" stroke="#14579f">
|
||||
<rect x="0" y="0" width="98" height="94" rx="6"/>
|
||||
<path d="M0 29h98"/>
|
||||
<circle cx="17" cy="14" r="3" fill="#14579f" stroke="none"/>
|
||||
<circle cx="33" cy="14" r="3" fill="#14579f" stroke="none"/>
|
||||
<circle cx="49" cy="14" r="3" fill="#14579f" stroke="none"/>
|
||||
<path d="M31 56l18 17-18 18"/>
|
||||
<path d="M61 86h22"/>
|
||||
</g>
|
||||
<text x="128" y="196" text-anchor="middle" class="cli-title title-blue" textLength="218" lengthAdjust="spacingAndGlyphs">CLI Orchestration</text>
|
||||
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
|
||||
<circle cx="24" cy="259" r="4.5" class="bullet-blue"/><text x="52" y="267" class="mono">repo +info</text>
|
||||
<circle cx="24" cy="305" r="4.5" class="bullet-blue"/><text x="52" y="313" class="mono">branch +list</text>
|
||||
<circle cx="24" cy="351" r="4.5" class="bullet-blue"/><text x="52" y="359" class="mono">branch +create</text>
|
||||
<circle cx="24" cy="397" r="4.5" class="bullet-blue"/><text x="52" y="405" class="mono">issue +create</text>
|
||||
</g>
|
||||
|
||||
<!-- GitLink -->
|
||||
<g transform="translate(1032 208)">
|
||||
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#334155" stroke-width="2.2" filter="url(#cardShadow)"/>
|
||||
<g transform="translate(51 40)" class="icon-line" stroke="#334155">
|
||||
<path d="M44 106h68c25 0 42-17 42-41 0-23-16-40-40-42C109 9 93 0 75 0 54 0 37 12 29 31 11 36 0 50 0 68c0 22 18 38 43 38"/>
|
||||
<circle cx="76" cy="36" r="8"/>
|
||||
<circle cx="51" cy="70" r="8"/>
|
||||
<circle cx="102" cy="70" r="8"/>
|
||||
<path d="M72 44L56 63"/>
|
||||
<path d="M80 44l17 20"/>
|
||||
<path d="M76 44v21"/>
|
||||
</g>
|
||||
<text x="128" y="196" text-anchor="middle" class="card-title title-slate">GitLink</text>
|
||||
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
|
||||
<circle cx="24" cy="259" r="4.5" class="bullet-slate"/><text x="43" y="267" class="item">Repository state</text>
|
||||
<circle cx="24" cy="305" r="4.5" class="bullet-slate"/><text x="43" y="313" class="item-condensed" textLength="188" lengthAdjust="spacingAndGlyphs">Collaboration branches</text>
|
||||
<circle cx="24" cy="351" r="4.5" class="bullet-slate"/><text x="43" y="359" class="item">Bootstrap issues</text>
|
||||
<circle cx="24" cy="397" r="4.5" class="bullet-slate"/><text x="43" y="405" class="item">Issue comment</text>
|
||||
<circle cx="24" cy="443" r="4.5" class="bullet-slate"/><text x="43" y="445" class="item">Apply mode writes</text><text x="43" y="475" class="item">remotely</text>
|
||||
</g>
|
||||
|
||||
<!-- Evidence -->
|
||||
<g transform="translate(1364 208)">
|
||||
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#14579f" stroke-width="2.2" filter="url(#cardShadow)"/>
|
||||
<g transform="translate(72 36)" class="icon-line" stroke="#14579f">
|
||||
<path d="M10 0h55l28 28v102H10z"/>
|
||||
<path d="M65 0v28h28"/>
|
||||
<path d="M31 56h43"/>
|
||||
<path d="M31 79h43"/>
|
||||
<path d="M31 102h34"/>
|
||||
</g>
|
||||
<text x="128" y="196" text-anchor="middle" class="card-title title-blue">Evidence</text>
|
||||
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
|
||||
<circle cx="24" cy="259" r="4.5" class="bullet-blue"/><text x="43" y="267" class="item">Markdown report</text>
|
||||
<circle cx="24" cy="305" r="4.5" class="bullet-blue"/><text x="43" y="313" class="item">Issue summary</text>
|
||||
<circle cx="24" cy="351" r="4.5" class="bullet-blue"/><text x="43" y="359" class="mono">manifest.json</text>
|
||||
<circle cx="24" cy="397" r="4.5" class="bullet-blue"/><text x="43" y="405" class="mono">files.json</text>
|
||||
<circle cx="24" cy="443" r="4.5" class="bullet-blue"/><text x="43" y="451" class="item">command log</text>
|
||||
</g>
|
||||
|
||||
<polygon points="316,404 343,404 343,386 371,411 343,436 343,418 316,418" fill="#06133a"/>
|
||||
<polygon points="644,404 671,404 671,386 699,411 671,436 671,418 644,418" fill="#06133a"/>
|
||||
<polygon points="972,404 999,404 999,386 1027,411 999,436 999,418 972,418" fill="#06133a"/>
|
||||
<polygon points="1302,404 1329,404 1329,386 1357,411 1329,436 1329,418 1302,418" fill="#06133a"/>
|
||||
|
||||
<g transform="translate(55 777)">
|
||||
<rect width="1562" height="122" rx="14" fill="#f8fbf8" stroke="#a8b8aa" stroke-width="1.8"/>
|
||||
<g transform="translate(32 22)">
|
||||
<rect width="330" height="78" rx="12" fill="#ffffff" stroke="#4f9a57" stroke-width="2"/>
|
||||
<circle cx="49" cy="39" r="23" class="valid"/>
|
||||
<path d="M38 38l8 9 18-20" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="96" y="48" class="small" fill="#0f4d22">Go implementation</text>
|
||||
</g>
|
||||
<g transform="translate(400 22)">
|
||||
<rect width="330" height="78" rx="12" fill="#ffffff" stroke="#4f9a57" stroke-width="2"/>
|
||||
<circle cx="49" cy="39" r="23" class="valid"/>
|
||||
<path d="M38 38l8 9 18-20" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="96" y="48" class="small" fill="#0f4d22">7 default CLI calls</text>
|
||||
</g>
|
||||
<g transform="translate(768 22)">
|
||||
<rect width="330" height="78" rx="12" fill="#ffffff" stroke="#4f9a57" stroke-width="2"/>
|
||||
<circle cx="49" cy="39" r="23" class="valid"/>
|
||||
<path d="M38 38l8 9 18-20" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="96" y="48" class="small" fill="#0f4d22">Reproducible dry-run</text>
|
||||
</g>
|
||||
<g transform="translate(1136 22)">
|
||||
<rect width="370" height="78" rx="12" fill="#ffffff" stroke="#4f9a57" stroke-width="2"/>
|
||||
<circle cx="49" cy="39" r="23" class="valid"/>
|
||||
<path d="M38 38l8 9 18-20" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="96" y="48" class="small" fill="#0f4d22">Validated on real GitLink repo</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 10 KiB |
|
|
@ -1,56 +0,0 @@
|
|||
# 快速开始
|
||||
|
||||
## 1. 进入目录
|
||||
|
||||
```powershell
|
||||
cd examples\workflows\project-bootstrap-automation
|
||||
```
|
||||
|
||||
## 2. 运行 dry-run
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
该命令不会写入 GitLink,只生成初始化材料和命令计划。
|
||||
|
||||
## 3. 查看输出
|
||||
|
||||
```powershell
|
||||
Get-ChildItem outputs
|
||||
```
|
||||
|
||||
重点查看:
|
||||
|
||||
- `*_bootstrap_report.md`
|
||||
- `*_summary.md`
|
||||
- `*_manifest.json`
|
||||
- `command_log_*.json`
|
||||
|
||||
## 4. 执行单元测试
|
||||
|
||||
```powershell
|
||||
go test ./scripts
|
||||
```
|
||||
|
||||
## 5. 执行真实写入
|
||||
|
||||
确认目标仓库和认证状态后执行:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Apply
|
||||
```
|
||||
|
||||
执行真实写入前,应先通过 `gitlink-cli auth login` 或当前环境已配置的认证方式完成 GitLink 登录。
|
||||
|
||||
如需创建目标仓库:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Apply -CreateRepo
|
||||
```
|
||||
|
||||
如需把摘要发布到指定 Issue:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1
|
||||
```
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# 运行手册
|
||||
|
||||
## 模式说明
|
||||
|
||||
| 模式 | 命令 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| dry-run | `.\scripts\run_demo.ps1` | 只生成材料和命令计划,不写入 GitLink |
|
||||
| apply | `.\scripts\run_demo.ps1 -Apply` | 执行真实 `gitlink-cli` 命令 |
|
||||
| apply + create repo | `.\scripts\run_demo.ps1 -Apply -CreateRepo` | 先创建仓库,再执行初始化命令 |
|
||||
| apply + comment | `.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1` | 执行真实命令,并将摘要评论到指定 Issue |
|
||||
|
||||
## 配置文件
|
||||
|
||||
默认配置位于:
|
||||
|
||||
```text
|
||||
examples/sample_project.json
|
||||
```
|
||||
|
||||
主要字段:
|
||||
|
||||
- `project`:项目名称、描述、语言、许可证
|
||||
- `repository`:目标 GitLink 仓库 owner/name
|
||||
- `branches`:需要创建的协作分支
|
||||
- `issues`:初始化 Issue 列表
|
||||
- `publish.issue_number`:可选的摘要发布 Issue 编号
|
||||
|
||||
## 输出文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --- | --- |
|
||||
| `*_bootstrap_report.md` | 初始化报告 |
|
||||
| `*_summary.md` | 可发布到 Issue 的摘要 |
|
||||
| `*_manifest.json` | 结构化初始化清单 |
|
||||
| `*_files.json` | 生成文件内容包 |
|
||||
| `command_log_*.json` | gitlink-cli 命令计划或执行结果 |
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 默认 dry-run,不进行远端写操作。
|
||||
- 只有显式传入 `-Apply` 才执行真实 GitLink 命令。
|
||||
- `-PublishIssueNumber` 只在明确指定 Issue 编号时追加评论命令。
|
||||
- 所有命令会写入 `command_log_*.json`,便于复盘和审计。
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
# 提交核对清单
|
||||
|
||||
## 官方交付要求映射
|
||||
|
||||
| 要求 | 本项目对应内容 |
|
||||
| --- | --- |
|
||||
| 工作流串联不少于 3 个 CLI 命令或 Skill 调用 | `scripts/bootstrap_project.go` 规划或执行 `repo +info`、`branch +list`、`branch +create`、`issue +create`、`issue +comment` |
|
||||
| 提供可复现执行脚本或 Agent 对话记录 | `scripts/run_demo.ps1` |
|
||||
| 在至少一个真实 GitLink 项目上运行并展示效果 | 已在 `puygob236/gitlink-bootstrap-demo` 完成仓库读取、分支读取、Issue 创建和 Issue 摘要回写验证 |
|
||||
| 提供工作流说明文档 | `README.md`、`docs/workflow-spec.md`、`docs/quickstart.md`、`docs/runbook.md` |
|
||||
| 提供架构图 | `docs/architecture.md`、`docs/assets/bootstrap-architecture.svg` |
|
||||
| 提供演示材料 | 演示视频作为比赛平台附件提交;仓库内保留 `scripts/run_demo.ps1`、`docs/verification.md` 和 `examples/demo_outputs/` 作为可复现证据 |
|
||||
| 代码开源并托管到 GitLink | 放置于 `examples/workflows/project-bootstrap-automation/` |
|
||||
| 提供完整中文 README | `README.md` |
|
||||
|
||||
## 验证状态
|
||||
|
||||
- `go test ./scripts`:通过
|
||||
- `.\scripts\run_demo.ps1`:通过
|
||||
- dry-run 生成 7 个 gitlink-cli 调用计划,满足赛题要求
|
||||
- `.\scripts\run_demo.ps1 -Config examples\verification_comment_config.json -Apply -PublishIssueNumber 4`:通过,3 个真实 gitlink-cli 调用状态均为 `ok`
|
||||
|
||||
## 交付内容
|
||||
|
||||
- `README.md`、`docs/`、`scripts/`、`examples/` 均位于本目录。
|
||||
- `outputs/` 为运行时生成目录,评审可通过复现脚本重新生成。
|
||||
- `examples/demo_outputs/` 用于保存固定示例产物。
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
# 验证记录
|
||||
|
||||
## 本地验证
|
||||
|
||||
执行目录:
|
||||
|
||||
```text
|
||||
examples/workflows/project-bootstrap-automation
|
||||
```
|
||||
|
||||
单元测试:
|
||||
|
||||
```powershell
|
||||
go test ./scripts
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
ok github.com/gitlink-org/gitlink-cli/examples/workflows/project-bootstrap-automation/scripts
|
||||
```
|
||||
|
||||
dry-run 复现:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
已生成初始化报告: outputs\puygob236_gitlink-bootstrap-demo_20260524_072107_bootstrap_report.md
|
||||
已生成初始化摘要: outputs\puygob236_gitlink-bootstrap-demo_20260524_072107_summary.md
|
||||
已生成文件清单: outputs\puygob236_gitlink-bootstrap-demo_20260524_072107_manifest.json
|
||||
已生成命令日志: outputs\command_log_20260524_072107.json
|
||||
模式: dry-run
|
||||
计划/执行 gitlink-cli 调用: 7 个
|
||||
```
|
||||
|
||||
## 真实仓库验证计划
|
||||
|
||||
目标仓库:
|
||||
|
||||
```text
|
||||
puygob236/gitlink-bootstrap-demo
|
||||
```
|
||||
|
||||
验证步骤:
|
||||
|
||||
1. 确认 GitLink 认证可用。
|
||||
2. 创建或确认目标仓库存在。
|
||||
3. 执行 `.\scripts\run_demo.ps1 -Apply`。
|
||||
4. 检查分支、Issue 和输出报告。
|
||||
5. 如需展示回写能力,执行 `.\scripts\run_demo.ps1 -Apply -PublishIssueNumber <number>`。
|
||||
|
||||
## 真实仓库验证结果
|
||||
|
||||
目标仓库:
|
||||
|
||||
```text
|
||||
https://gitlink.org.cn/puygob236/gitlink-bootstrap-demo
|
||||
```
|
||||
|
||||
已完成验证:
|
||||
|
||||
- `repo +info`:成功读取 `puygob236/gitlink-bootstrap-demo` 仓库信息。
|
||||
- `branch +list`:成功读取 `master`、`develop`、`release/v0.1` 分支。
|
||||
- `issue +create`:成功创建初始化 Issue,生成项目任务清单。
|
||||
- `issue +comment`:成功将初始化摘要回写到 Issue。
|
||||
|
||||
回写验证命令:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Config examples\verification_comment_config.json -Apply -PublishIssueNumber 4
|
||||
```
|
||||
|
||||
回写验证结果:
|
||||
|
||||
```text
|
||||
模式: apply
|
||||
计划/执行 gitlink-cli 调用: 3 个
|
||||
```
|
||||
|
||||
命令日志中 3 条调用状态均为 `ok`,无 stderr。
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
# 工作流说明
|
||||
|
||||
## 场景定位
|
||||
|
||||
本工作流面向 GitLink 子赛题三“构建端到端自动化工作流”,选择“项目一键初始化”作为应用场景。目标是在新开源项目创建初期,将项目配置、初始化文件、协作分支、初始 Issue 和执行报告统一串联,形成可复现、可审计的启动流程。
|
||||
|
||||
该场景覆盖开源项目常见的启动缺口:
|
||||
|
||||
- README、License、CI 配置和协作文档不完整。
|
||||
- 初始任务缺少统一模板,Issue 粒度和验收标准不一致。
|
||||
- 分支、Issue、报告产物分散,难以复盘初始化过程。
|
||||
- 真实写入和演示复现之间缺少安全边界。
|
||||
|
||||
## 端到端流程
|
||||
|
||||
工作流由 `scripts/bootstrap_project.go` 实现,默认读取 `examples/sample_project.json`,并按以下顺序执行:
|
||||
|
||||
1. 解析项目配置,读取项目名称、仓库 owner/name、许可证、初始化分支和初始 Issue。
|
||||
2. 生成初始化文件包,包括 README、LICENSE、CI 配置、贡献指南和路线图。
|
||||
3. 规划或执行 `repo +info`,检查目标 GitLink 仓库状态。
|
||||
4. 规划或执行 `branch +list`,读取分支状态。
|
||||
5. 规划或执行 `branch +create`,创建协作分支。
|
||||
6. 规划或执行 `issue +create`,创建初始化任务。
|
||||
7. 可选执行 `issue +comment`,将初始化摘要回写到指定 Issue。
|
||||
8. 生成 Markdown 报告、摘要、manifest、文件包和命令日志。
|
||||
|
||||
## 串联的 GitLink CLI 能力
|
||||
|
||||
默认 dry-run 配置会生成 7 个 `gitlink-cli` 调用计划:
|
||||
|
||||
| 顺序 | CLI 能力 | 用途 |
|
||||
| ---: | --- | --- |
|
||||
| 1 | `repo +info` | 检查目标仓库信息 |
|
||||
| 2 | `branch +list` | 读取当前分支列表 |
|
||||
| 3 | `branch +create` | 创建 `develop` 协作分支 |
|
||||
| 4 | `branch +create` | 创建 `release/v0.1` 发布分支 |
|
||||
| 5 | `issue +create` | 创建 README 与快速开始任务 |
|
||||
| 6 | `issue +create` | 创建 CI 检查任务 |
|
||||
| 7 | `issue +create` | 创建 v0.1 里程碑任务 |
|
||||
|
||||
当传入 `-PublishIssueNumber` 时,会追加 `issue +comment`,用于把初始化摘要发布到指定 GitLink Issue。
|
||||
|
||||
## 运行模式
|
||||
|
||||
| 模式 | 命令 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| dry-run | `.\scripts\run_demo.ps1` | 生成材料和命令计划,不写入 GitLink |
|
||||
| apply | `.\scripts\run_demo.ps1 -Apply` | 执行真实 GitLink CLI 命令 |
|
||||
| apply + create repo | `.\scripts\run_demo.ps1 -Apply -CreateRepo` | 先创建仓库,再执行初始化流程 |
|
||||
| apply + comment | `.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1` | 执行真实命令并回写摘要 |
|
||||
|
||||
## 输出产物
|
||||
|
||||
运行后会生成以下文件:
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --- | --- |
|
||||
| `*_bootstrap_report.md` | 初始化报告,展示目标项目、生成文件、分支计划和 Issue 计划 |
|
||||
| `*_summary.md` | 可发布到 Issue 的初始化摘要 |
|
||||
| `*_manifest.json` | 结构化初始化清单 |
|
||||
| `*_files.json` | 生成文件内容包 |
|
||||
| `command_log_*.json` | gitlink-cli 命令计划或执行结果 |
|
||||
|
||||
固定示例输出保存在 `examples/demo_outputs/`,用于评审快速查看产物格式。`outputs/` 是运行时目录,可通过脚本重新生成。
|
||||
|
||||
## 工程边界
|
||||
|
||||
- 主实现使用 Go,便于与 `gitlink-cli` 主仓库技术栈保持一致。
|
||||
- 默认 dry-run,避免演示阶段误写远端仓库。
|
||||
- 真实写入必须显式传入 `-Apply`。
|
||||
- 命令日志记录每个 CLI 调用的状态,便于复盘和排查。
|
||||
- 测试覆盖文件生成、CLI 编排、Issue 内容生成、幂等跳过判断和输出 manifest。
|
||||
|
||||
## 赛题价值
|
||||
|
||||
该工作流不是单个命令封装,而是面向真实开源项目启动流程的组合式方案。它把 `gitlink-cli` 的仓库、分支、Issue 和评论能力整合为一个可复现闭环,符合子赛题三对“串联多个 CLI 命令或 Skill 调用”“真实项目运行展示”“工作流说明文档和架构图”的要求。
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# 示例输出
|
||||
|
||||
本目录保存 `scripts/bootstrap_project.go` 在 dry-run 模式下生成的固定示例产物,便于快速查看工作流输出格式。
|
||||
|
||||
生成命令:
|
||||
|
||||
```powershell
|
||||
go run scripts\bootstrap_project.go --config examples\sample_project.json --output-dir examples\demo_outputs --now 2026-05-24T08:00:00Z
|
||||
```
|
||||
|
||||
产物说明:
|
||||
|
||||
- `*_bootstrap_report.md`:项目初始化报告
|
||||
- `*_summary.md`:可发布到 Issue 的初始化摘要
|
||||
- `*_manifest.json`:结构化初始化清单
|
||||
- `*_files.json`:生成文件内容包
|
||||
- `command_log_*.json`:gitlink-cli 命令计划
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
{
|
||||
"mode": "dry-run",
|
||||
"commands": [
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"repo",
|
||||
"+info",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"branch",
|
||||
"+list",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"branch",
|
||||
"+create",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--name",
|
||||
"develop",
|
||||
"--from",
|
||||
"master",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"branch",
|
||||
"+create",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--name",
|
||||
"release/v0.1",
|
||||
"--from",
|
||||
"master",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"issue",
|
||||
"+create",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--title",
|
||||
"完善项目 README 与快速开始文档",
|
||||
"--body",
|
||||
"仓库: `puygob236/gitlink-bootstrap-demo`\n\n类型: documentation\n优先级: normal\n\n## 任务清单\n\n- [ ] 补充项目背景和目标用户\n- [ ] 补充安装与运行步骤\n- [ ] 补充最小示例\n\n## 验收标准\n\nREADME 能支撑新贡献者在 10 分钟内完成本地启动。\n",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"issue",
|
||||
"+create",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--title",
|
||||
"建立基础 CI 检查",
|
||||
"--body",
|
||||
"仓库: `puygob236/gitlink-bootstrap-demo`\n\n类型: ci\n优先级: high\n\n## 任务清单\n\n- [ ] 添加测试命令\n- [ ] 添加 lint 或格式检查\n- [ ] 在 PR 中展示检查结果\n\n## 验收标准\n\n每次 push 和 PR 均能触发基础检查。\n",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"gitlink-cli",
|
||||
"issue",
|
||||
"+create",
|
||||
"--owner",
|
||||
"puygob236",
|
||||
"--repo",
|
||||
"gitlink-bootstrap-demo",
|
||||
"--title",
|
||||
"规划 v0.1 版本里程碑",
|
||||
"--body",
|
||||
"仓库: `puygob236/gitlink-bootstrap-demo`\n\n类型: release\n优先级: normal\n\n## 任务清单\n\n- [ ] 整理 v0.1 范围\n- [ ] 确定验收标准\n- [ ] 准备 Release Notes 模板\n\n## 验收标准\n\n形成可执行的 v0.1 版本任务列表。\n",
|
||||
"--format",
|
||||
"json"
|
||||
],
|
||||
"status": "planned",
|
||||
"returncode": null,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# GitLink 项目初始化工作流报告
|
||||
|
||||
## 目标项目
|
||||
|
||||
- 仓库: `puygob236/gitlink-bootstrap-demo`
|
||||
- 项目名称: Open Research Toolkit
|
||||
- 描述: A reproducible GitLink project initialized by an end-to-end automation workflow.
|
||||
- 生成时间: 2026-05-24T08:00:00Z
|
||||
|
||||
## 初始化文件
|
||||
|
||||
| 文件 | 字节数 |
|
||||
| --- | ---: |
|
||||
| `README.md` | 545 |
|
||||
| `LICENSE` | 179 |
|
||||
| `.github/workflows/ci.yml` | 232 |
|
||||
| `docs/CONTRIBUTING.md` | 83 |
|
||||
| `docs/ROADMAP.md` | 92 |
|
||||
|
||||
## 分支计划
|
||||
|
||||
| 分支 | 来源 | 保护 |
|
||||
| --- | --- | --- |
|
||||
| `develop` | `master` | false |
|
||||
| `release/v0.1` | `master` | false |
|
||||
|
||||
## 初始 Issue 计划
|
||||
|
||||
| 序号 | 标题 | 优先级 |
|
||||
| ---: | --- | --- |
|
||||
| 1 | 完善项目 README 与快速开始文档 | normal |
|
||||
| 2 | 建立基础 CI 检查 | high |
|
||||
| 3 | 规划 v0.1 版本里程碑 | normal |
|
||||
|
||||
## 工作流闭环
|
||||
|
||||
1. 读取项目配置。
|
||||
2. 生成 README、LICENSE、CI 和协作文档。
|
||||
3. 调用 gitlink-cli 检查仓库和分支状态。
|
||||
4. 调用 gitlink-cli 创建初始化 Issue。
|
||||
5. 输出报告、摘要和结构化 manifest,必要时回写到 GitLink Issue。
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
".github/workflows/ci.yml": "name: Go CI\n\non:\n push:\n pull_request:\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-go@v5\n with:\n go-version: \"1.23\"\n - run: go test ./...\n",
|
||||
"LICENSE": "# License\n\nThis project is initialized with the `MulanPSL-2.0` license.\n\nThe final repository should keep the complete license text that matches the selected open-source license.\n",
|
||||
"README.md": "# Open Research Toolkit\n\nA reproducible GitLink project initialized by an end-to-end automation workflow.\n\n## 项目信息\n\n- GitLink 仓库: `puygob236/gitlink-bootstrap-demo`\n- 技术方向: Go\n- 初始化来源: GitLink 项目一键初始化工作流\n\n## 快速开始\n\n```bash\ngit clone https://gitlink.org.cn/puygob236/gitlink-bootstrap-demo.git\ncd gitlink-bootstrap-demo\n```\n\n## 协作约定\n\n- 使用 Issue 跟踪需求、缺陷和文档任务。\n- 使用 Pull Request 合并代码变更。\n- 重要里程碑通过 Release Notes 记录。\n",
|
||||
"docs/CONTRIBUTING.md": "# 贡献指南\n\n请通过 Issue 讨论需求,通过 Pull Request 提交变更。\n",
|
||||
"docs/ROADMAP.md": "# Roadmap\n\n- [ ] 完成项目初始化\n- [ ] 建立基础测试\n- [ ] 发布第一个版本\n"
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
{
|
||||
"repository": "puygob236/gitlink-bootstrap-demo",
|
||||
"project": {
|
||||
"name": "Open Research Toolkit",
|
||||
"description": "A reproducible GitLink project initialized by an end-to-end automation workflow.",
|
||||
"language": "Go",
|
||||
"license": "MulanPSL-2.0"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"bytes": 545
|
||||
},
|
||||
{
|
||||
"path": "LICENSE",
|
||||
"bytes": 179
|
||||
},
|
||||
{
|
||||
"path": ".github/workflows/ci.yml",
|
||||
"bytes": 232
|
||||
},
|
||||
{
|
||||
"path": "docs/CONTRIBUTING.md",
|
||||
"bytes": 83
|
||||
},
|
||||
{
|
||||
"path": "docs/ROADMAP.md",
|
||||
"bytes": 92
|
||||
}
|
||||
],
|
||||
"branches": [
|
||||
{
|
||||
"name": "develop",
|
||||
"from": "master",
|
||||
"create": true,
|
||||
"protect": false
|
||||
},
|
||||
{
|
||||
"name": "release/v0.1",
|
||||
"from": "master",
|
||||
"create": true,
|
||||
"protect": false
|
||||
}
|
||||
],
|
||||
"issues": [
|
||||
{
|
||||
"title": "完善项目 README 与快速开始文档",
|
||||
"type": "documentation",
|
||||
"priority": "normal",
|
||||
"tasks": [
|
||||
"补充项目背景和目标用户",
|
||||
"补充安装与运行步骤",
|
||||
"补充最小示例"
|
||||
],
|
||||
"acceptance": "README 能支撑新贡献者在 10 分钟内完成本地启动。"
|
||||
},
|
||||
{
|
||||
"title": "建立基础 CI 检查",
|
||||
"type": "ci",
|
||||
"priority": "high",
|
||||
"tasks": [
|
||||
"添加测试命令",
|
||||
"添加 lint 或格式检查",
|
||||
"在 PR 中展示检查结果"
|
||||
],
|
||||
"acceptance": "每次 push 和 PR 均能触发基础检查。"
|
||||
},
|
||||
{
|
||||
"title": "规划 v0.1 版本里程碑",
|
||||
"type": "release",
|
||||
"priority": "normal",
|
||||
"tasks": [
|
||||
"整理 v0.1 范围",
|
||||
"确定验收标准",
|
||||
"准备 Release Notes 模板"
|
||||
],
|
||||
"acceptance": "形成可执行的 v0.1 版本任务列表。"
|
||||
}
|
||||
],
|
||||
"generated_at": "2026-05-24T08:00:00Z"
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
# GitLink 项目初始化摘要
|
||||
|
||||
- 目标仓库: `puygob236/gitlink-bootstrap-demo`
|
||||
- 项目名称: Open Research Toolkit
|
||||
- 生成时间: 2026-05-24T08:00:00Z
|
||||
- 初始化文件: 5 个
|
||||
- 初始 Issue: 3 个
|
||||
- 分支动作: 2 个
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
{
|
||||
"project": {
|
||||
"name": "Open Research Toolkit",
|
||||
"description": "A reproducible GitLink project initialized by an end-to-end automation workflow.",
|
||||
"language": "Go",
|
||||
"license": "MulanPSL-2.0"
|
||||
},
|
||||
"repository": {
|
||||
"owner": "puygob236",
|
||||
"name": "gitlink-bootstrap-demo"
|
||||
},
|
||||
"branches": [
|
||||
{
|
||||
"name": "develop",
|
||||
"from": "master",
|
||||
"create": true,
|
||||
"protect": false
|
||||
},
|
||||
{
|
||||
"name": "release/v0.1",
|
||||
"from": "master",
|
||||
"create": true,
|
||||
"protect": false
|
||||
}
|
||||
],
|
||||
"issues": [
|
||||
{
|
||||
"title": "完善项目 README 与快速开始文档",
|
||||
"type": "documentation",
|
||||
"priority": "normal",
|
||||
"tasks": [
|
||||
"补充项目背景和目标用户",
|
||||
"补充安装与运行步骤",
|
||||
"补充最小示例"
|
||||
],
|
||||
"acceptance": "README 能支撑新贡献者在 10 分钟内完成本地启动。"
|
||||
},
|
||||
{
|
||||
"title": "建立基础 CI 检查",
|
||||
"type": "ci",
|
||||
"priority": "high",
|
||||
"tasks": [
|
||||
"添加测试命令",
|
||||
"添加 lint 或格式检查",
|
||||
"在 PR 中展示检查结果"
|
||||
],
|
||||
"acceptance": "每次 push 和 PR 均能触发基础检查。"
|
||||
},
|
||||
{
|
||||
"title": "规划 v0.1 版本里程碑",
|
||||
"type": "release",
|
||||
"priority": "normal",
|
||||
"tasks": [
|
||||
"整理 v0.1 范围",
|
||||
"确定验收标准",
|
||||
"准备 Release Notes 模板"
|
||||
],
|
||||
"acceptance": "形成可执行的 v0.1 版本任务列表。"
|
||||
}
|
||||
],
|
||||
"publish": {
|
||||
"issue_number": 0
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"project": {
|
||||
"name": "Open Research Toolkit",
|
||||
"description": "A reproducible GitLink project initialized by an end-to-end automation workflow.",
|
||||
"language": "Go",
|
||||
"license": "MulanPSL-2.0"
|
||||
},
|
||||
"repository": {
|
||||
"owner": "puygob236",
|
||||
"name": "gitlink-bootstrap-demo"
|
||||
},
|
||||
"branches": [],
|
||||
"issues": [],
|
||||
"publish": {
|
||||
"issue_number": 4
|
||||
}
|
||||
}
|
||||
|
|
@ -1,478 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProjectConfig struct {
|
||||
Project ProjectInfo `json:"project"`
|
||||
Repository RepositoryInfo `json:"repository"`
|
||||
Branches []BranchPlan `json:"branches"`
|
||||
Issues []IssuePlan `json:"issues"`
|
||||
Publish PublishConfig `json:"publish"`
|
||||
}
|
||||
|
||||
type ProjectInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Language string `json:"language"`
|
||||
License string `json:"license"`
|
||||
}
|
||||
|
||||
type RepositoryInfo struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type BranchPlan struct {
|
||||
Name string `json:"name"`
|
||||
From string `json:"from"`
|
||||
Create *bool `json:"create"`
|
||||
Protect bool `json:"protect"`
|
||||
}
|
||||
|
||||
type IssuePlan struct {
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Priority string `json:"priority"`
|
||||
Tasks []string `json:"tasks"`
|
||||
Acceptance string `json:"acceptance"`
|
||||
}
|
||||
|
||||
type PublishConfig struct {
|
||||
IssueNumber int `json:"issue_number"`
|
||||
}
|
||||
|
||||
type FileManifestItem struct {
|
||||
Path string `json:"path"`
|
||||
Bytes int `json:"bytes"`
|
||||
}
|
||||
|
||||
type OutputManifest struct {
|
||||
Repository string `json:"repository"`
|
||||
Project ProjectInfo `json:"project"`
|
||||
Files []FileManifestItem `json:"files"`
|
||||
Branches []BranchPlan `json:"branches"`
|
||||
Issues []IssuePlan `json:"issues"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
type CommandResult struct {
|
||||
Command []string `json:"command"`
|
||||
Status string `json:"status"`
|
||||
ReturnCode *int `json:"returncode"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
}
|
||||
|
||||
type CommandLog struct {
|
||||
Mode string `json:"mode"`
|
||||
Commands []CommandResult `json:"commands"`
|
||||
}
|
||||
|
||||
type OutputPaths struct {
|
||||
Report string
|
||||
Summary string
|
||||
Manifest string
|
||||
Files string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
ConfigPath string
|
||||
OutputDir string
|
||||
CLIBin string
|
||||
Apply bool
|
||||
CreateRepo bool
|
||||
PublishIssueNumber int
|
||||
Now string
|
||||
}
|
||||
|
||||
func parseFlags(args []string) Options {
|
||||
var opts Options
|
||||
fs := flag.NewFlagSet("bootstrap-project", flag.ExitOnError)
|
||||
fs.StringVar(&opts.ConfigPath, "config", filepath.FromSlash("examples/sample_project.json"), "配置文件路径")
|
||||
fs.StringVar(&opts.OutputDir, "output-dir", "outputs", "输出目录")
|
||||
fs.StringVar(&opts.CLIBin, "cli-bin", firstNonEmpty(os.Getenv("GITLINK_CLI_BIN"), "gitlink-cli"), "gitlink-cli 可执行文件路径")
|
||||
fs.BoolVar(&opts.Apply, "apply", false, "执行真实 GitLink 写操作")
|
||||
fs.BoolVar(&opts.CreateRepo, "create-repo", false, "仓库不存在时创建仓库")
|
||||
fs.IntVar(&opts.PublishIssueNumber, "publish-issue-number", 0, "把初始化摘要评论到指定 Issue")
|
||||
fs.StringVar(&opts.Now, "now", "", "固定当前时间,ISO8601 格式")
|
||||
_ = fs.Parse(args)
|
||||
return opts
|
||||
}
|
||||
|
||||
func loadConfig(path string) (ProjectConfig, error) {
|
||||
var config ProjectConfig
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return config, fmt.Errorf("配置文件不存在: %s", path)
|
||||
}
|
||||
data = bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf})
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return config, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func parseNow(value string) (time.Time, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return time.Now().UTC(), nil
|
||||
}
|
||||
text := strings.ReplaceAll(strings.TrimSpace(value), "Z", "+00:00")
|
||||
dt, err := time.Parse(time.RFC3339, text)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return dt.UTC(), nil
|
||||
}
|
||||
|
||||
func isoTime(value time.Time) string {
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func safeName(value string) string {
|
||||
replacer := strings.NewReplacer("/", "_", "\\", "_", " ", "_")
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
func renderReadme(config ProjectConfig) string {
|
||||
language := firstNonEmpty(config.Project.Language, "未指定")
|
||||
return fmt.Sprintf("# %s\n\n%s\n\n## 项目信息\n\n- GitLink 仓库: `%s/%s`\n- 技术方向: %s\n- 初始化来源: GitLink 项目一键初始化工作流\n\n## 快速开始\n\n```bash\ngit clone https://gitlink.org.cn/%s/%s.git\ncd %s\n```\n\n## 协作约定\n\n- 使用 Issue 跟踪需求、缺陷和文档任务。\n- 使用 Pull Request 合并代码变更。\n- 重要里程碑通过 Release Notes 记录。\n",
|
||||
config.Project.Name,
|
||||
config.Project.Description,
|
||||
config.Repository.Owner,
|
||||
config.Repository.Name,
|
||||
language,
|
||||
config.Repository.Owner,
|
||||
config.Repository.Name,
|
||||
config.Repository.Name,
|
||||
)
|
||||
}
|
||||
|
||||
func renderLicense(config ProjectConfig) string {
|
||||
licenseName := firstNonEmpty(config.Project.License, "MulanPSL-2.0")
|
||||
return fmt.Sprintf("# License\n\nThis project is initialized with the `%s` license.\n\nThe final repository should keep the complete license text that matches the selected open-source license.\n", licenseName)
|
||||
}
|
||||
|
||||
func renderCI(config ProjectConfig) string {
|
||||
if strings.Contains(strings.ToLower(config.Project.Language), "go") {
|
||||
return `name: Go CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
- run: go test ./...
|
||||
`
|
||||
}
|
||||
return `name: Basic CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo "Add project-specific checks here."
|
||||
`
|
||||
}
|
||||
|
||||
func plannedFiles(config ProjectConfig) map[string]string {
|
||||
return map[string]string{
|
||||
"README.md": renderReadme(config),
|
||||
"LICENSE": renderLicense(config),
|
||||
".github/workflows/ci.yml": renderCI(config),
|
||||
"docs/CONTRIBUTING.md": "# 贡献指南\n\n请通过 Issue 讨论需求,通过 Pull Request 提交变更。\n",
|
||||
"docs/ROADMAP.md": "# Roadmap\n\n- [ ] 完成项目初始化\n- [ ] 建立基础测试\n- [ ] 发布第一个版本\n",
|
||||
}
|
||||
}
|
||||
|
||||
func issueBody(item IssuePlan, config ProjectConfig) string {
|
||||
repo := fmt.Sprintf("%s/%s", config.Repository.Owner, config.Repository.Name)
|
||||
tasks := "- [ ] 待补充"
|
||||
if len(item.Tasks) > 0 {
|
||||
lines := make([]string, 0, len(item.Tasks))
|
||||
for _, task := range item.Tasks {
|
||||
lines = append(lines, "- [ ] "+task)
|
||||
}
|
||||
tasks = strings.Join(lines, "\n")
|
||||
}
|
||||
return fmt.Sprintf("仓库: `%s`\n\n类型: %s\n优先级: %s\n\n## 任务清单\n\n%s\n\n## 验收标准\n\n%s\n",
|
||||
repo,
|
||||
firstNonEmpty(item.Type, "task"),
|
||||
firstNonEmpty(item.Priority, "normal"),
|
||||
tasks,
|
||||
firstNonEmpty(item.Acceptance, "完成后在本 Issue 中说明验证结果。"),
|
||||
)
|
||||
}
|
||||
|
||||
func shouldCreateBranch(branch BranchPlan) bool {
|
||||
return branch.Create == nil || *branch.Create
|
||||
}
|
||||
|
||||
func branchFrom(branch BranchPlan) string {
|
||||
return firstNonEmpty(branch.From, "master")
|
||||
}
|
||||
|
||||
func buildCLIPlan(config ProjectConfig, summary string, createRepo bool) [][]string {
|
||||
owner := config.Repository.Owner
|
||||
repo := config.Repository.Name
|
||||
commands := [][]string{}
|
||||
if createRepo {
|
||||
commands = append(commands, []string{"repo", "+create", "--name", repo, "--description", config.Project.Description, "--format", "json"})
|
||||
}
|
||||
commands = append(commands,
|
||||
[]string{"repo", "+info", "--owner", owner, "--repo", repo, "--format", "json"},
|
||||
[]string{"branch", "+list", "--owner", owner, "--repo", repo, "--format", "json"},
|
||||
)
|
||||
for _, branch := range config.Branches {
|
||||
if shouldCreateBranch(branch) {
|
||||
commands = append(commands, []string{"branch", "+create", "--owner", owner, "--repo", repo, "--name", branch.Name, "--from", branchFrom(branch), "--format", "json"})
|
||||
}
|
||||
if branch.Protect {
|
||||
commands = append(commands, []string{"branch", "+protect", "--owner", owner, "--repo", repo, "--name", branch.Name, "--format", "json"})
|
||||
}
|
||||
}
|
||||
for _, issue := range config.Issues {
|
||||
commands = append(commands, []string{"issue", "+create", "--owner", owner, "--repo", repo, "--title", issue.Title, "--body", issueBody(issue, config), "--format", "json"})
|
||||
}
|
||||
if summary != "" && config.Publish.IssueNumber > 0 {
|
||||
commands = append(commands, []string{"issue", "+comment", "--owner", owner, "--repo", repo, "--number", strconv.Itoa(config.Publish.IssueNumber), "--body", summary, "--format", "json"})
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
func runCommand(cliBin string, args []string, apply bool) CommandResult {
|
||||
command := append([]string{cliBin}, args...)
|
||||
if !apply {
|
||||
return CommandResult{Command: command, Status: "planned"}
|
||||
}
|
||||
cmd := exec.Command(cliBin, args...)
|
||||
if strings.HasSuffix(strings.ToLower(cliBin), ".cmd") || strings.HasSuffix(strings.ToLower(cliBin), ".bat") {
|
||||
cmd = exec.Command("cmd", append([]string{"/c", cliBin}, args...)...)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
returnCode := 0
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
returnCode = exitErr.ExitCode()
|
||||
} else {
|
||||
returnCode = 1
|
||||
}
|
||||
if isIdempotentSkip(stderr.String()) {
|
||||
status = "skipped"
|
||||
}
|
||||
}
|
||||
return CommandResult{
|
||||
Command: command,
|
||||
Status: status,
|
||||
ReturnCode: &returnCode,
|
||||
Stdout: strings.TrimSpace(stdout.String()),
|
||||
Stderr: strings.TrimSpace(stderr.String()),
|
||||
}
|
||||
}
|
||||
|
||||
func isIdempotentSkip(stderr string) bool {
|
||||
knownMessages := []string{
|
||||
"新分支已存在",
|
||||
"branch already exists",
|
||||
"repository already exists",
|
||||
"仓库已存在",
|
||||
}
|
||||
for _, message := range knownMessages {
|
||||
if strings.Contains(stderr, message) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeOutputs(config ProjectConfig, outputDir string, now time.Time) (OutputPaths, error) {
|
||||
owner := config.Repository.Owner
|
||||
repo := config.Repository.Name
|
||||
prefix := fmt.Sprintf("%s_%s_%s", safeName(owner), safeName(repo), now.UTC().Format("20060102_150405"))
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
return OutputPaths{}, err
|
||||
}
|
||||
files := plannedFiles(config)
|
||||
fileManifest := make([]FileManifestItem, 0, len(files))
|
||||
for _, path := range []string{"README.md", "LICENSE", ".github/workflows/ci.yml", "docs/CONTRIBUTING.md", "docs/ROADMAP.md"} {
|
||||
if content, ok := files[path]; ok {
|
||||
fileManifest = append(fileManifest, FileManifestItem{Path: path, Bytes: len([]byte(content))})
|
||||
}
|
||||
}
|
||||
summary := fmt.Sprintf("# GitLink 项目初始化摘要\n\n- 目标仓库: `%s/%s`\n- 项目名称: %s\n- 生成时间: %s\n- 初始化文件: %d 个\n- 初始 Issue: %d 个\n- 分支动作: %d 个\n",
|
||||
owner,
|
||||
repo,
|
||||
config.Project.Name,
|
||||
isoTime(now),
|
||||
len(files),
|
||||
len(config.Issues),
|
||||
len(config.Branches),
|
||||
)
|
||||
report := renderReport(config, fileManifest, now)
|
||||
manifest := OutputManifest{
|
||||
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
||||
Project: config.Project,
|
||||
Files: fileManifest,
|
||||
Branches: config.Branches,
|
||||
Issues: config.Issues,
|
||||
GeneratedAt: isoTime(now),
|
||||
}
|
||||
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return OutputPaths{}, err
|
||||
}
|
||||
filesJSON, err := json.MarshalIndent(files, "", " ")
|
||||
if err != nil {
|
||||
return OutputPaths{}, err
|
||||
}
|
||||
paths := OutputPaths{
|
||||
Report: filepath.Join(outputDir, prefix+"_bootstrap_report.md"),
|
||||
Summary: filepath.Join(outputDir, prefix+"_summary.md"),
|
||||
Manifest: filepath.Join(outputDir, prefix+"_manifest.json"),
|
||||
Files: filepath.Join(outputDir, prefix+"_files.json"),
|
||||
}
|
||||
writes := map[string][]byte{
|
||||
paths.Report: []byte(report),
|
||||
paths.Summary: []byte(summary),
|
||||
paths.Manifest: manifestJSON,
|
||||
paths.Files: filesJSON,
|
||||
}
|
||||
for path, data := range writes {
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return OutputPaths{}, err
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func renderReport(config ProjectConfig, fileManifest []FileManifestItem, now time.Time) string {
|
||||
fileRows := []string{}
|
||||
for _, item := range fileManifest {
|
||||
fileRows = append(fileRows, fmt.Sprintf("| `%s` | %d |", item.Path, item.Bytes))
|
||||
}
|
||||
branchRows := []string{}
|
||||
for _, item := range config.Branches {
|
||||
branchRows = append(branchRows, fmt.Sprintf("| `%s` | `%s` | %t |", item.Name, branchFrom(item), item.Protect))
|
||||
}
|
||||
if len(branchRows) == 0 {
|
||||
branchRows = append(branchRows, "| 无 | 无 | false |")
|
||||
}
|
||||
issueRows := []string{}
|
||||
for idx, item := range config.Issues {
|
||||
issueRows = append(issueRows, fmt.Sprintf("| %d | %s | %s |", idx+1, item.Title, firstNonEmpty(item.Priority, "normal")))
|
||||
}
|
||||
if len(issueRows) == 0 {
|
||||
issueRows = append(issueRows, "| 0 | 无 | normal |")
|
||||
}
|
||||
return fmt.Sprintf("# GitLink 项目初始化工作流报告\n\n## 目标项目\n\n- 仓库: `%s/%s`\n- 项目名称: %s\n- 描述: %s\n- 生成时间: %s\n\n## 初始化文件\n\n| 文件 | 字节数 |\n| --- | ---: |\n%s\n\n## 分支计划\n\n| 分支 | 来源 | 保护 |\n| --- | --- | --- |\n%s\n\n## 初始 Issue 计划\n\n| 序号 | 标题 | 优先级 |\n| ---: | --- | --- |\n%s\n\n## 工作流闭环\n\n1. 读取项目配置。\n2. 生成 README、LICENSE、CI 和协作文档。\n3. 调用 gitlink-cli 检查仓库和分支状态。\n4. 调用 gitlink-cli 创建初始化 Issue。\n5. 输出报告、摘要和结构化 manifest,必要时回写到 GitLink Issue。\n",
|
||||
config.Repository.Owner,
|
||||
config.Repository.Name,
|
||||
config.Project.Name,
|
||||
config.Project.Description,
|
||||
isoTime(now),
|
||||
strings.Join(fileRows, "\n"),
|
||||
strings.Join(branchRows, "\n"),
|
||||
strings.Join(issueRows, "\n"),
|
||||
)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
opts := parseFlags(os.Args[1:])
|
||||
config, err := loadConfig(opts.ConfigPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
now, err := parseNow(opts.Now)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "无法解析 --now 的值: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if opts.PublishIssueNumber > 0 {
|
||||
config.Publish.IssueNumber = opts.PublishIssueNumber
|
||||
}
|
||||
outputPaths, err := writeOutputs(config, opts.OutputDir, now)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "写入输出失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
summaryBytes, err := os.ReadFile(outputPaths.Summary)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "读取摘要失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
plan := buildCLIPlan(config, string(summaryBytes), opts.CreateRepo)
|
||||
results := make([]CommandResult, 0, len(plan))
|
||||
for _, command := range plan {
|
||||
results = append(results, runCommand(opts.CLIBin, command, opts.Apply))
|
||||
}
|
||||
mode := "dry-run"
|
||||
if opts.Apply {
|
||||
mode = "apply"
|
||||
}
|
||||
commandLog := CommandLog{Mode: mode, Commands: results}
|
||||
commandLogJSON, err := json.MarshalIndent(commandLog, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "生成命令日志失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
commandLogPath := filepath.Join(opts.OutputDir, fmt.Sprintf("command_log_%s.json", now.UTC().Format("20060102_150405")))
|
||||
if err := os.WriteFile(commandLogPath, commandLogJSON, 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "写入命令日志失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("已生成初始化报告: %s\n", outputPaths.Report)
|
||||
fmt.Printf("已生成初始化摘要: %s\n", outputPaths.Summary)
|
||||
fmt.Printf("已生成文件清单: %s\n", outputPaths.Manifest)
|
||||
fmt.Printf("已生成命令日志: %s\n", commandLogPath)
|
||||
fmt.Printf("模式: %s\n", mode)
|
||||
fmt.Printf("计划/执行 gitlink-cli 调用: %d 个\n", len(results))
|
||||
failed := 0
|
||||
for _, result := range results {
|
||||
if result.Status == "failed" {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
fmt.Printf("失败命令: %d 个\n", failed)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sampleConfig() ProjectConfig {
|
||||
create := true
|
||||
return ProjectConfig{
|
||||
Project: ProjectInfo{
|
||||
Name: "Demo Project",
|
||||
Description: "Demo description",
|
||||
Language: "Go",
|
||||
License: "MulanPSL-2.0",
|
||||
},
|
||||
Repository: RepositoryInfo{Owner: "alice", Name: "demo"},
|
||||
Branches: []BranchPlan{{Name: "develop", From: "master", Create: &create}},
|
||||
Issues: []IssuePlan{
|
||||
{
|
||||
Title: "Write README",
|
||||
Type: "documentation",
|
||||
Priority: "normal",
|
||||
Tasks: []string{"Add quickstart", "Add license"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlannedFilesIncludeRequiredProjectArtifacts(t *testing.T) {
|
||||
files := plannedFiles(sampleConfig())
|
||||
for _, path := range []string{"README.md", "LICENSE", ".github/workflows/ci.yml", "docs/CONTRIBUTING.md"} {
|
||||
if _, ok := files[path]; !ok {
|
||||
t.Fatalf("expected planned file %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCLIPlanChainsMoreThanThreeGitlinkCommands(t *testing.T) {
|
||||
plan := buildCLIPlan(sampleConfig(), "", false)
|
||||
if len(plan) < 4 {
|
||||
t.Fatalf("expected at least 4 commands, got %d", len(plan))
|
||||
}
|
||||
if strings.Join(plan[0][:2], " ") != "repo +info" {
|
||||
t.Fatalf("unexpected first command: %#v", plan[0])
|
||||
}
|
||||
if !containsCommand(plan, "branch +list") {
|
||||
t.Fatalf("branch +list command missing: %#v", plan)
|
||||
}
|
||||
if !containsCommand(plan, "issue +create") {
|
||||
t.Fatalf("issue +create command missing: %#v", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCLIPlanCanCreateRepositoryFirst(t *testing.T) {
|
||||
plan := buildCLIPlan(sampleConfig(), "", true)
|
||||
if strings.Join(plan[0][:2], " ") != "repo +create" {
|
||||
t.Fatalf("unexpected first command: %#v", plan[0])
|
||||
}
|
||||
if strings.Join(plan[1][:2], " ") != "repo +info" {
|
||||
t.Fatalf("unexpected second command: %#v", plan[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueBodyContainsChecklistAndRepository(t *testing.T) {
|
||||
config := sampleConfig()
|
||||
body := issueBody(config.Issues[0], config)
|
||||
if !strings.Contains(body, "`alice/demo`") {
|
||||
t.Fatalf("repository missing from body: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "- [ ] Add quickstart") {
|
||||
t.Fatalf("checklist missing from body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteOutputsCreatesReportManifestAndSummary(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
paths, err := writeOutputs(sampleConfig(), tmp, time.Date(2026, 5, 24, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("writeOutputs returned error: %v", err)
|
||||
}
|
||||
for _, path := range []string{paths.Report, paths.Summary, paths.Manifest, paths.Files} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected output %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(paths.Manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("read manifest: %v", err)
|
||||
}
|
||||
var manifest OutputManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatalf("unmarshal manifest: %v", err)
|
||||
}
|
||||
if manifest.Repository != "alice/demo" {
|
||||
t.Fatalf("unexpected repository: %s", manifest.Repository)
|
||||
}
|
||||
if len(manifest.Files) < 4 {
|
||||
t.Fatalf("expected at least 4 files, got %d", len(manifest.Files))
|
||||
}
|
||||
if filepath.Base(paths.Report) == "" {
|
||||
t.Fatal("report path should include filename")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchAlreadyExistsIsIdempotentSkip(t *testing.T) {
|
||||
if !isIdempotentSkip("[-1] 新分支已存在!") {
|
||||
t.Fatal("expected existing branch error to be skipped")
|
||||
}
|
||||
if isIdempotentSkip("[401] 请登录后再操作") {
|
||||
t.Fatal("auth error should not be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func containsCommand(plan [][]string, command string) bool {
|
||||
for _, item := range plan {
|
||||
if len(item) >= 2 && strings.Join(item[:2], " ") == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
param(
|
||||
[string]$Config = "examples/sample_project.json",
|
||||
[string]$OutputDir = "outputs",
|
||||
[switch]$Apply,
|
||||
[switch]$CreateRepo,
|
||||
[int]$PublishIssueNumber = 0
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$args = @(
|
||||
"run",
|
||||
"scripts\bootstrap_project.go",
|
||||
"--config", $Config,
|
||||
"--output-dir", $OutputDir
|
||||
)
|
||||
|
||||
if ($Apply.IsPresent) {
|
||||
$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"
|
||||
}
|
||||
$args += @("--cli-bin", $cliPath, "--apply")
|
||||
}
|
||||
|
||||
if ($CreateRepo.IsPresent) {
|
||||
$args += "--create-repo"
|
||||
}
|
||||
|
||||
if ($PublishIssueNumber -gt 0) {
|
||||
$args += @("--publish-issue-number", "$PublishIssueNumber")
|
||||
}
|
||||
|
||||
go @args
|
||||
11
go.mod
11
go.mod
|
|
@ -5,27 +5,16 @@ go 1.26.1
|
|||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
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/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
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/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // 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
|
||||
golang.org/x/sys v0.42.0 // 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,31 +4,17 @@ 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/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/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/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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
|
|
@ -44,49 +30,12 @@ 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/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
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/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/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 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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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 {
|
||||
// Clean up the bad token
|
||||
_ = DeleteToken()
|
||||
return nil, fmt.Errorf("login failed: credentials not accepted by API (%w)", verifyErr)
|
||||
return nil, fmt.Errorf("login failed: credentials not accepted by API (%v)", verifyErr)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
|
|
|
|||
|
|
@ -1,191 +0,0 @@
|
|||
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,7 +4,6 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
|
|
@ -42,7 +41,8 @@ func DeleteToken() error {
|
|||
// File-based fallback
|
||||
|
||||
func credentialPath() string {
|
||||
return filepath.Join(config.ConfigDir(), "credentials")
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
||||
}
|
||||
|
||||
func storeTokenFile(token string) error {
|
||||
|
|
@ -62,9 +62,5 @@ func loadTokenFile() (string, error) {
|
|||
}
|
||||
|
||||
func deleteTokenFile() error {
|
||||
err := os.Remove(credentialPath())
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
return os.Remove(credentialPath())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,168 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
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,21 +42,19 @@ func New() (*Client, 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)
|
||||
// Handle paths that may already contain query strings (e.g., /path?key=val)
|
||||
if idx := strings.Index(path, "?"); idx != -1 {
|
||||
basePath := path[:idx]
|
||||
queryStr := path[idx:]
|
||||
if shouldAppendJSONSuffix(basePath) {
|
||||
if !strings.HasSuffix(basePath, ".json") {
|
||||
path = basePath + ".json" + queryStr
|
||||
}
|
||||
} else if shouldAppendJSONSuffix(path) {
|
||||
} else if !strings.HasSuffix(path, ".json") {
|
||||
path += ".json"
|
||||
}
|
||||
fullURL := c.BaseURL + path
|
||||
if len(query) > 0 {
|
||||
if query != nil && len(query) > 0 {
|
||||
sep := "?"
|
||||
if strings.Contains(fullURL, "?") {
|
||||
sep = "&"
|
||||
|
|
@ -160,31 +158,6 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
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) {
|
||||
return c.Do("GET", path, nil, query)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,527 +0,0 @@
|
|||
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,7 +17,6 @@ type Config struct {
|
|||
Format string `yaml:"default_format"`
|
||||
Editor string `yaml:"editor,omitempty"`
|
||||
Pager string `yaml:"pager,omitempty"`
|
||||
Lang string `yaml:"lang,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
|
|
@ -86,8 +85,6 @@ func Get(key string) (string, error) {
|
|||
return cfg.Editor, nil
|
||||
case "pager":
|
||||
return cfg.Pager, nil
|
||||
case "lang":
|
||||
return cfg.Lang, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
|
|
@ -107,8 +104,6 @@ func Set(key, value string) error {
|
|||
cfg.Editor = value
|
||||
case "pager":
|
||||
cfg.Pager = value
|
||||
case "lang":
|
||||
cfg.Lang = value
|
||||
}
|
||||
return Save(cfg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,225 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
package i18n
|
||||
|
||||
// Args contains named values used by parameterized messages.
|
||||
type Args map[string]any
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
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 ""
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
{
|
||||
"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.repo.tree.short": "List repository files and directories",
|
||||
"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.repo.tree.path": "Directory path to list (default: repository root)",
|
||||
"flag.repo.tree.ref": "Branch, tag, or commit ref",
|
||||
"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)"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue