Compare commits

..

1 Commits

Author SHA1 Message Date
yangsai01 d070037b9d feat(skills): add skills gitlink-release-auto 2026-05-21 15:44:32 +08:00
263 changed files with 447 additions and 37441 deletions

View File

@ -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

View File

@ -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 ./...

3
.gitignore vendored
View File

@ -1,3 +0,0 @@
gitlink-cli.exe
/gitlink-cli

View File

@ -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"

View File

@ -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
View File

@ -5,85 +5,16 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](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?

View File

@ -5,85 +5,16 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](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 # 注册入口

View File

@ -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 == "" {

View File

@ -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
}

View File

@ -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()
}

View File

@ -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
},
}
}

View File

@ -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
}

View File

@ -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()))
}
}

View File

@ -6,5 +6,4 @@ var (
Repo string
Format string
Debug bool
Lang string
)

View File

@ -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
},
}
}

View File

@ -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
}

View File

@ -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"
}

View File

@ -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{}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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 不发请求、变量覆盖、模板缺失报错、失败默认中断以及失败继续执行等关键行为。

View File

@ -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` 解析差异污染真实用户凭据目录。

View File

@ -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 输出,确保诊断命令在常见失败场景下返回结构化结果而不是直接崩溃。

View File

@ -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

View File

@ -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
```

View File

@ -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 直接复用。

View File

@ -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 已同步更新。

View File

@ -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 说明。

View File

@ -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。

View File

@ -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.

View File

@ -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.

View File

@ -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`

View File

@ -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`.

View File

@ -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` 查询参数,语义更清晰,对现有功能无破坏性影响。

View File

@ -1,12 +0,0 @@
# Webhook Shortcut
新增 `webhook` Shortcut 组,支持:
- `webhook +list`
- `webhook +create`
- `webhook +view`
- `webhook +update`
- `webhook +delete`
- `webhook +test`
同时补充了对应单元测试、帮助文档和示例说明。

View File

@ -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 |

View File

@ -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.

View File

@ -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
```

View File

@ -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.

View File

@ -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
```

View File

@ -1,8 +0,0 @@
outputs/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
*.log
*.tmp
*.swp

View File

@ -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.

View File

@ -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 项目上复现
- 提供运行脚本、验证记录、示例产物和单元测试

View File

@ -1,26 +0,0 @@
# 架构说明
本项目采用“采集 -> 归一化 -> 分析 -> 生成 -> 发布”的五段式流程。
![GitLink 社区运营端到端自动化工作流架构](assets/architecture-workflow-v2.svg)
## 设计目标
- 低门槛:只依赖 `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

View File

@ -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 的发布验证。

View File

@ -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

View File

@ -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 回写能力。

View File

@ -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/` 提供固定示例产物,便于快速查看报告格式和输出内容。

View File

@ -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/`

View File

@ -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
```

View File

@ -1,6 +0,0 @@
{
"owner": "Gitlink",
"repo": "gitlink-cli",
"window_days": 7,
"output_dir": "outputs"
}

View File

@ -1,18 +0,0 @@
# gitlink-cli Release Notes 草稿
- 统计窗口:近 7 天
- 生成时间2026-05-20 14:05:25 UTC
## 变更概览
- 已合并 PR8 个
- 最近窗口内合并 PR2 个
## 变更分类
### feature
- feat(pr): add pr +comment shortcut (2026-05-14)
### fix
- fix(npm): improve missing binary diagnostics (2026-05-19)
## 发布说明
- 存在 1 个超过 7 天未更新的开放 Issue建议优先清理。

View File

@ -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建议优先清理。

View File

@ -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`

View File

@ -1,14 +0,0 @@
# gitlink-cli Release Notes 草稿
- 统计窗口:近 7 天
- 生成时间2026-05-20 14:32:24 UTC
## 变更概览
- 已合并 PR0 个
- 最近窗口内合并 PR0 个
## 变更分类
- 无
## 发布说明
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。

View File

@ -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 权限。

View File

@ -1,6 +0,0 @@
{
"owner": "puygob236",
"repo": "gitlink-cli",
"window_days": 7,
"output_dir": "outputs"
}

View File

@ -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())

View File

@ -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

View File

@ -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()

View File

@ -1 +0,0 @@
outputs/

View File

@ -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 两种模式,兼顾演示稳定性和真实落地。

View File

@ -1,34 +0,0 @@
# 架构说明
本工作流采用“配置输入 -> 资产生成 -> CLI 编排 -> GitLink 落地 -> 结果归档”的五段式架构。正式架构图见 `docs/assets/bootstrap-architecture.svg`
![GitLink 项目一键初始化工作流架构](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. 生成报告与命令日志,支撑复现和审计。

View File

@ -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

View File

@ -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
```

View File

@ -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`,便于复盘和审计。

View File

@ -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/` 用于保存固定示例产物。

View File

@ -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。

View File

@ -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 调用”“真实项目运行展示”“工作流说明文档和架构图”的要求。

View File

@ -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 命令计划

View File

@ -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": ""
}
]
}

View File

@ -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。

View File

@ -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"
}

View File

@ -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"
}

View File

@ -1,8 +0,0 @@
# GitLink 项目初始化摘要
- 目标仓库: `puygob236/gitlink-bootstrap-demo`
- 项目名称: Open Research Toolkit
- 生成时间: 2026-05-24T08:00:00Z
- 初始化文件: 5 个
- 初始 Issue: 3 个
- 分支动作: 2 个

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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

View File

@ -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")
}
}

View File

@ -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())
}

View File

@ -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")
}
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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")
}
}

View File

@ -1,4 +0,0 @@
package i18n
// Args contains named values used by parameterized messages.
type Args map[string]any

View File

@ -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
}

View File

@ -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

View File

@ -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()
}

View File

@ -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 ""
}
}

View File

@ -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)
}
}
}

View File

@ -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