Compare commits

...

87 Commits

Author SHA1 Message Date
dtwdtw eff7336527 docs: clarify Windows agent CLI invocation 2026-06-08 19:33:16 +08:00
Tiger 7947d4dfd6 Update muel's avatar from letter avatar to user photo
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 02:39:39 +08:00
Tiger 68aa9ae6e6 Add co63oc and lindiwen23 to README contributors
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 02:35:03 +08:00
wbtiger 27cc58328d Merge pull request 'feat(repo): add insight shortcuts' (#59) from wangyue111/gitlink-cli:feat/repo-insight-shortcuts into master 2026-06-08 02:32:26 +08:00
Tiger ee0d882e1c Merge PR #59: feat(repo): add insight shortcuts (languages, contributors, contributor-stats, code-stats, community)
Resolved conflicts in README.md, README.zh-CN.md, repo.go, repo_test.go,
skills/README.md, skills/gitlink-repo/SKILL.md. Updated register_test.go
to include new license group.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 02:32:19 +08:00
wbtiger ca578a74be Merge pull request 'feat(repo): add settings and topic shortcuts' (#71) from wangyue111/gitlink-cli:feat/repo-settings-shortcuts into master 2026-06-08 02:26:54 +08:00
wbtiger 55805aa13d Merge pull request 'fix(health): use effective list filters' (#120) from wangyue111/gitlink-cli:fix/health-list-filters into master 2026-06-08 02:24:55 +08:00
wbtiger dd4ed35448 Merge pull request '修正 Issue 和 PR 列表筛选' (#117) from Mengz/gitlink-cli:mengz/list-filter-fixes into master 2026-06-08 02:24:41 +08:00
wbtiger 723e698db4 Merge pull request '新增 Raw API 批处理执行器' (#124) from Mengz/gitlink-cli:mengz/api-batch-runner into master 2026-06-08 02:24:14 +08:00
wbtiger 03a9d7a942 Merge pull request '修复认证凭据 fallback 配置目录不一致' (#133) from Mengz/gitlink-cli:mengz/auth-config-dir-fallback into master 2026-06-08 02:23:06 +08:00
wbtiger 34f14b079f Merge pull request 'feat: 新增 6 个 Agent Skill + 收窄 gitlink-search 触发范围' (#102) from jiangtx/gitlink-cli:pr/skills-upstream into master 2026-06-08 02:22:44 +08:00
wbtiger f3e5d4aba0 Merge pull request 'feat(skills): add gitlink-gatekeeper — Policy-as-Code PR merge gate' (#90) from recorder/gitlink-cli:feat/gatekeeper-skill into master 2026-06-08 02:22:00 +08:00
wbtiger 64dad994ae Merge pull request 'feat(shortcut): add shortcuts/license' (#121) from co63oc/gitlink-cli:fix2 into master 2026-06-08 02:21:21 +08:00
wbtiger 7e863398aa Merge pull request '新增 CLI 自诊断命令' (#132) from Mengz/gitlink-cli:mengz/doctor-command into master 2026-06-08 02:19:54 +08:00
wbtiger 9c085fb246 Merge pull request 'fix(i18n): 对齐 locale key schema 并补充 gosec 注释' (#104) from muel/gitlink-cli:fix/i18n-schema-gosec into master 2026-06-08 02:19:19 +08:00
wbtiger a9ee74efb0 Merge pull request 'docs: fix skills badge repository link' (#66) from Mengz/gitlink-cli:codex/fix-skills-badge-link into master 2026-06-08 02:18:02 +08:00
Mengz d6321e7af5 fix: 统一认证凭据 fallback 配置目录 2026-06-06 18:42:50 +08:00
Mengz dec10afaeb feat: 新增 CLI 自诊断命令 2026-06-06 18:11:45 +08:00
Mengz 35e50a1828 feat: 新增 Raw API 批处理执行器 2026-06-06 11:55:20 +08:00
co63oc dedba6e49f feat(shortcut): add shortcuts/license 2026-06-05 16:46:37 +08:00
wangyue789 a1d3a85ab8 fix(health): use effective list filters 2026-06-05 14:56:02 +08:00
Mengz 0b48ec90d8 fix: 修正 Issue 和 PR 列表筛选 2026-06-05 11:43:30 +08:00
何开元 3255884c6e feat(skills): add gitlink-gatekeeper — Policy-as-Code PR merge gate
新增 gitlink-gatekeeper Skill:版本化 gatekeeper.yaml 策略 → 5 维 0-100 评分卡
→ 三态裁决(PASS/REQUEST_CHANGES/COMMENT)→ 建议性 common 评论回写。默认 dry-run、
绝不自动合并。复用 pr +view/+files/+diff、pr +comment、ci +builds、label(已并入 master)。

- SKILL.md(六步工作流)+ REFERENCE.md(gatekeeper.yaml 全字段 + 评分算法 + CLI 映射)
  + TROUBLESHOOTING.md(12 问)
- examples/:3 套策略预设 + 评分卡样例 + 3 个可逐位复算的裁决记录(90/38/68)
- skills/README.md 登记一行

按 #90 review 重做:基于最新 master 的单个干净 commit,仅含 skill 文档,
不含 gitlink-cli 二进制(.gitignore 已排除)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:05:29 -07:00
wangyue789 9bfa59c7e8 feat(repo): add settings and topic shortcuts 2026-06-04 07:19:08 +08:00
wangyue789 cedd8b6263 feat(repo): add insight and interaction shortcuts 2026-06-04 07:17:13 +08:00
wbtiger b45241dcda Merge pull request 'docs: add missing contributors dtwdtw, puygob236, recorder' (#109) from muel/gitlink-cli:docs/add-missing-contributors into master 2026-06-04 00:29:31 +08:00
wbtiger 633bed3360 docs: add missing contributor puygob236
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 00:29:52 +08:00
wbtiger c263a2fa34 Merge pull request 'gitlink-health 开源项目健康度 Skill 贡献' (#17) from yingjie/gitlink-cli:master into master 2026-06-03 23:49:27 +08:00
wbtiger 6636175bd8 Merge pull request 'refactor: rename pr +close to pr +refuse' (#105) from muel/gitlink-cli:rename/pr-close-to-refuse into master 2026-06-03 23:20:11 +08:00
wbtiger 83fe18f8c0 refactor: rename pr +close to pr +refuse
The `pr +close` shortcut called the `refuse_merge` API, which is
misleading — "close" suggests a benign operation, but the actual
behavior is to refuse/reject the pull request permanently.

Rename the shortcut to `pr +refuse` so the name matches the API
semantics, and add a warning in the docs that it must never be used
as a substitute for `pr +merge`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 23:20:32 +08:00
wbtiger 0e13e8bdd4 Merge pull request 'feat(issue): support metadata fields' (#57) from wangyue111/gitlink-cli:feat/issue-metadata-fields into master 2026-06-03 22:37:14 +08:00
wbtiger 559e1a74f3 chore: ensure trailing newline
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 22:37:40 +08:00
Yingjie Shang 17e8c113f7 fix(health): address code review issues 3-6,8 2026-06-03 21:12:44 +08:00
Yingjie Shang 41b89976aa perf(health): parallel fetch with errgroup and global rate limiter
- Replace sequential state loops with 5 parallel goroutines (errgroup)
- Global rate limiter (1000 reqs/10min, burst 5) replaces per-page sleep
- sharedState with sync.Mutex for cross-goroutine dedup and counting
- Fix getOrCreateUser/getOrCreateTag race conditions (INSERT OR IGNORE)
- Remove sleep() and API aggregates verbose output
2026-06-03 20:59:50 +08:00
Yingjie Shang 0a578bbab0 feat(health): remove per-issue detail API, add tag tables and persistence
- Drop fetchIssueDetail — issue list response already contains all needed fields
- Add tags/issue_tags/pull_tags tables with indexes
- Persist PR and Issue tags during savePull/saveIssue
- Fix issue aggregate field names (total_count/opened_count/closed_count)
- Document tag tables and queries in queries.md
2026-06-03 20:59:50 +08:00
Yingjie Shang 0da45def2b feat(shortcut): rewrite gitlink-health as a Go shortcut
Migrate health analysis from Python scripts to a native Go shortcut
for better performance and single-binary distribution. Remove the
Python collector (fetcher.py) and move schema.sql into shortcuts/health/.
2026-06-03 20:59:50 +08:00
whzy f4820c03b4 fix(i18n): align schema and gosec annotation 2026-06-03 13:43:56 +08:00
林迪文 d478de59a3 feat: 新增 6 个 Agent Skill + 收窄 gitlink-search 触发范围
新增 Skill:
- gitlink-onboarding: 新人入门引导,帮助新贡献者发现适合入门的 Issue
- gitlink-issue-triage: Issue 智能分拣,按类型/紧急度/复杂度分类并生成分拣报告
- gitlink-research-tracker: 技术调研报告生成,多维度评估+成熟度评分+趋势洞察
- gitlink-contributor-insight: 贡献者活跃度分析,支持命令可用性降级适配
- gitlink-ci-health: CI 健康巡检,通过 repo +info 检测 DevOps 状态
- gitlink-notification-digest: 通知摘要,支持 source 字段分类和批量已读

修改:
- gitlink-search: 收窄描述避免与 research-tracker 触发冲突

每个 Skill 均包含使用示例。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:36:13 +08:00
wbtiger c7a3224c79 Merge pull request 'feat(skills): 新增许可证合规检查的skill : gitlink-license-compliance' (#94) from yangsai/gitlink-cli:feat/gitlink-license-compliance into master 2026-06-02 23:07:48 +08:00
wbtiger 9843fc7ceb Merge PR #57: feat(issue): support metadata fields
Resolved conflicts with i18n infrastructure from PR #95 and metadata
lookup shortcuts from PR #43.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 22:46:34 +08:00
wbtiger 8e558cceca Merge pull request 'feat(issue): add metadata lookup shortcuts' (#43) from wangyue111/gitlink-cli:feat/issue-metadata-shortcuts into master 2026-06-02 22:30:26 +08:00
wbtiger 49240b3c02 Merge pull request 'fix: pr +review now posts a journal comment alongside the formal review' (#98) from fix/pr-review-journal-sync into master 2026-06-02 22:08:07 +08:00
wbtiger edf2fc30b8 fix: pr +review now posts a journal comment alongside the formal review
The GitLink API has two separate systems for PR feedback:
- POST /pulls/:id/reviews creates a formal review (approve/reject/comment)
  that only appears in the "Reviews" panel
- POST /issues/:issueID/journals creates a comment visible in the main
  PR conversation tab

Previously, `pr +review` only called the reviews API, so the review
content was invisible to the contributor in the main PR conversation.
Now it also posts a journal summary so the review is visible where
contributors expect to see it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 22:03:04 +08:00
wbtiger dec8c10a1b Merge pull request 'feat(i18n): 完善 CLI 国际化帮助、运行时错误与校验机制' (#95) from muel/gitlink-cli:feat/i18n-cli-foundation-clean into master 2026-06-02 22:02:27 +08:00
whzy 39306994bc fix(i18n): align schema and check tool lint 2026-06-02 14:59:59 +08:00
wangyue789 34be160d96 feat(issue): add metadata lookup shortcuts 2026-06-02 12:45:30 +08:00
wangyue789 89d5321713 feat(issue): support metadata fields and id alias 2026-06-02 12:42:34 +08:00
whzy 37dfd2e2e2 feat(i18n): add CLI localization foundation 2026-06-01 17:33:10 +08:00
yangsai01 ac7499936d feat(skills): add skill gitlink-license-compliance 2026-06-01 17:14:21 +08:00
wbtiger e3bb4d2711 docs: update contributors section with usernames and new contributors
- Add GitLink username labels under each contributor avatar
- Add dtwdtw (PR #85)
- Add recorder (PR #89)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:24:35 +08:00
wbtiger e393484dc9 fix: update TestRegisterAll expected groups for label and pipeline
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:18:36 +08:00
wbtiger fb847bf13f Merge remote-tracking branch 'wangyue111/feat/pipeline-openapi-shortcuts' into _test_68_int 2026-06-01 01:17:23 +08:00
wbtiger 902886cabd Merge pull request 'fix: preserve raw file API paths' (#85) from dtwdtw/gitlink-cli:fix/issue-142586-raw-file-api into master 2026-06-01 01:04:25 +08:00
wbtiger 18975162a1 fix(issue): treat view id as issue number
Support --id as alias for --number in issue +view command.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:52:55 +08:00
wbtiger a91d553130 Merge pull request 'feat(label): add issue label shortcuts' (#89) from recorder/gitlink-cli:feat/label-shortcuts into master 2026-05-31 23:41:27 +08:00
wbtiger 687633d22f Merge pull request 'chore: fix CI workflow, golangci-lint config, and minor lint/format issues' (#92) from muel/gitlink-cli:chore/test-coverage-and-lint into master 2026-05-31 22:45:14 +08:00
wbtiger 1bf16d31db chore: fix CI workflow, golangci-lint config, and minor lint/format issues
- Add checkout and setup-go steps to Gitea CI workflow, use make targets
- Exclude errcheck for test files in golangci-lint config
- Fix staticcheck QF1002 (tagged switch) in repo_test.go
- Fix gofmt trailing newline in triage_rules_test.go

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:45:17 +08:00
wbtiger 5a266867b5 Merge pull request 'chore: add test coverage to 90.3%, lint fixes, and Makefile targets' (#91) from muel/gitlink-cli:chore/test-coverage-and-lint into master 2026-05-31 15:41:34 +08:00
wbtiger 0170b5b063 test: add comprehensive test coverage across all packages (88.5% → 90.3%)
Add 18 new test files covering formatter, envelope, config, auth,
client, context, register, and all shortcut group packages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:41:54 +08:00
wbtiger c969631495 Merge pull request 'chore: add golangci-lint config and fix lint issues' (#88) from muel/gitlink-cli:chore/add-lint-and-fix-issues into master 2026-05-31 00:38:33 +08:00
wbtiger f7263cc466 chore: add golangci-lint config and fix lint issues
- Add .golangci.yml with 8 essential linters (errcheck, govet,
  ineffassign, staticcheck, unused, errorlint, gosec, misspell)
- Fix real bugs: errors.As, %w wrapping, nil map check, dead code,
  unnecessary conversion, unused parameters
- Add `make lint` target, include it in `make check` pipeline
- Update pre-commit hook to run lint step

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 00:37:45 +08:00
何开元 cca3d019fe feat(label): add issue label shortcuts
Add a `label` shortcut group wrapping the GitLink issue_tags (项目标记) API
with +list / +create / +update / +delete, plus the gitlink-label Skill,
bilingual README usage, and a changelog entry.

- +list supports keyword filter, only-name, and sort options
- +create defaults color to #1E90FF and validates hex client-side
- +update fetches current values and merges, preserving unspecified fields
- unit tests cover HTTP method, path, query, payload, color validation, id coercion
- ignore the local `gitlink-cli` build artifact (.gitignore previously only
  ignored the Windows .exe)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 06:38:39 -07:00
wbtiger 4631b81e5c style: fix gofmt alignment in root.go and register.go 2026-05-29 23:59:40 +08:00
wbtiger 6ded4851cb ci: add local checks (make check, pre-commit hook) and Gitea Actions workflow skeleton 2026-05-29 23:58:51 +08:00
wbtiger a4c93802a7 ci: add PR checks workflow (build, test, vet, fmt) 2026-05-29 23:57:44 +08:00
fsafasff c28b72d46f fix: preserve raw file API paths 2026-05-29 11:41:10 +08:00
wangyue789 41a2666c5b feat(pipeline): add OpenAPI shortcuts 2026-05-27 22:20:18 +08:00
papertager 7d8c33a938 docs: fix skills badge repository link 2026-05-27 21:57:20 +08:00
wbtiger 2f8b987c2c Merge pull request 'feat(issue): add user lookup shortcuts' (#44) from wangyue111/gitlink-cli:feat/issue-user-shortcuts into master 2026-05-27 01:14:31 +08:00
wbtiger c89e398b46 Merge pull request 'feat(cli): add productivity shortcuts and API body inputs' (#45) from wangyue111/gitlink-cli:feat/cli-productivity-shortcuts into master 2026-05-27 01:08:49 +08:00
wangyue789 00c753b421 feat(pr): add reopen shortcut 2026-05-27 01:08:54 +08:00
wangyue789 2421c1f0c8 feat(api): support body input files 2026-05-27 01:08:54 +08:00
wbtiger 6ce72f07b2 Merge pull request 'feat(member): add repository member shortcuts' (#46) from puygob236/gitlink-cli:feat/member-shortcuts into master 2026-05-27 01:06:12 +08:00
wbtiger 419388f11e merge: resolve conflicts with upstream/master 2026-05-27 01:06:27 +08:00
wbtiger 5369ce65bb Merge pull request 'fix(pr): include closed time in view output' (#49) from dtwdtw/gitlink-cli:fix/issue-14-pr-closed-at into master 2026-05-27 00:52:20 +08:00
wbtiger 27117c0f16 Merge pull request 'feat(repo): add README shortcut' (#52) from dtwdtw/gitlink-cli:fix/issue-17-api-prefix into master 2026-05-27 00:52:04 +08:00
wbtiger 898b7f59d1 feat: add milestone, compare, pr reopen shortcuts and api enhancements (#45)
- milestone shortcuts: list, create, view, update, delete, close, reopen
- compare shortcuts: view, files (compare branches/tags/commits)
- pr +reopen: reopen closed pull requests
- api: add --body-file and --body-stdin for reading JSON from file/stdin
- comprehensive tests for all new shortcuts

Co-authored-by: wangyue111 <wangyue111>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:44:39 +08:00
wbtiger 36249f3087 feat: add PR closed time and repo README shortcuts (#49, #52)
PR #49 (dtwdtw): enrich PR view with closed_at from issue journals
- Fetches close timestamp for closed/rejected PRs
- Skips extra API call for open PRs
- 2 new tests

PR #52 (dtwdtw): add repo +readme shortcut
- Fetches README content with optional --ref and --path params
- normalizeAPIPath prevents duplicate /api prefix in client
- 4 new tests (3 client + 1 repo)

Co-authored-by: dtwdtw <dtwdtw>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:35:31 +08:00
wbtiger 7bcba5aed8 feat(member): add repository member shortcuts (#46)
Add member command group with 8 shortcuts:
- +list, +add, +batch-add, +remove, +role
- +invite-link, +invite-info, +accept-invite

Batch add supports --dry-run, CSV file input, and partial failure
reporting. 12 tests included.

Co-authored-by: Jiachen Li (puygob236) <noreply@gitlink.org.cn>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:24:15 +08:00
wbtiger f888dd39ed Merge pull request 'feat(workflows): add community ops automation workflow example' (#32) from puygob236/gitlink-cli:community-ops-automation-pr into master 2026-05-26 23:58:11 +08:00
fsafasff 65cd7f6199 feat(repo): add readme shortcut 2026-05-26 17:54:33 +08:00
fsafasff 7f0f332f77 fix(pr): include closed time in view output 2026-05-26 17:11:33 +08:00
Jiachen Li 46799d8e56 fix(member): report batch-add partial failures 2026-05-25 21:16:04 +08:00
Jiachen Li 1feb6a85fc feat(member): add repository member shortcuts 2026-05-25 13:42:41 +08:00
wangyue789 23a478bd69 feat(issue): add authors shortcut 2026-05-25 08:50:47 +08:00
wangyue789 9638d279d3 feat(issue): add assigners shortcut 2026-05-25 08:49:41 +08:00
ljc0426 9ebed898ac feat(workflows): add community ops automation example 2026-05-24 14:34:47 +08:00
193 changed files with 24715 additions and 648 deletions

30
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,30 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
check:
name: Build, Lint, Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Build
run: go build ./...
- name: Lint
run: make lint
- name: Test
run: make test
- name: Check formatting
run: make fmt

30
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,30 @@
name: Test
on:
pull_request:
push:
branches:
- main
- master
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Validate i18n messages
run: go run ./internal/i18n/cmd/check
- name: Scan i18n key references
run: go run ./internal/i18n/cmd/check --scan-code
- name: Run Go tests
run: go test ./...

1
.gitignore vendored
View File

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

59
.golangci.yml Normal file
View File

@ -0,0 +1,59 @@
version: "2"
linters:
default: none
enable:
# Core: catch real bugs
- errcheck # unchecked errors
- govet # suspicious constructs
- ineffassign # wasted assignments
- staticcheck # comprehensive bug detection
- unused # dead code
# Error handling
- errorlint # errors.As / %w best practices
# Security
- gosec # security issues
# Typos
- misspell # spelling mistakes in identifiers
settings:
gosec:
excludes:
- G104 # errcheck already handles unchecked errors
- G304 # file inclusion by variable is expected for CLI tools
exclusions:
paths:
- vendor/
- npm/
- skills/
- docs/
rules:
# Idiomatic Go: defer Close() error is intentionally ignored
- linters: [errcheck]
text: "Error return value of .*(resp\\.Body\\.Close|file\\.Close).*is not checked"
# Output formatting: fmt.Fprint* errors are low-value
- linters: [errcheck]
text: "Error return value of `fmt\\.Fprintf?"
# Test helpers: FlagSet.Set is setup code
- linters: [errcheck]
text: "Error return value of .*FlagSet.*\\.Set"
# Best-effort output rendering
- linters: [errcheck]
path: render\.go$
# errcheck: test helpers intentionally ignore return values
- linters: [errcheck]
path: _test\.go$
# errorlint: type assertions are fine in tests
- linters: [errorlint]
path: _test\.go$
# gosec: tests are not attack surface
- linters: [gosec]
path: _test\.go$
# apiInt: intentional uint64->int truncation for API response parsing
- linters: [gosec]
text: "G115: integer overflow conversion uint64 -> int"

View File

@ -3,7 +3,7 @@ BINARY := gitlink-cli
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)' LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)'
.PHONY: build install clean test .PHONY: build install clean test check vet fmt cover lint
build: build:
go build -ldflags "$(LDFLAGS)" -o $(BINARY) . go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
@ -15,4 +15,30 @@ clean:
rm -f $(BINARY) rm -f $(BINARY)
test: test:
go test ./... go test -race ./...
vet:
go vet ./...
fmt:
@unformatted=$$(gofmt -s -l .); \
if [ -n "$$unformatted" ]; then \
echo "Files not formatted:"; \
echo "$$unformatted"; \
exit 1; \
fi
cover:
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
lint:
golangci-lint run ./...
check: fmt vet lint test
@echo "All checks passed."
hooks:
cp scripts/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
@echo "Pre-commit hook installed."

244
README.md
View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![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) [![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, CI/CD, and AI-powered workflows, with 40+ commands and 13 AI Agent [Skills](./skills/). The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
**[中文文档](./README.zh-CN.md)** **[中文文档](./README.zh-CN.md)**
@ -13,22 +13,69 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
## Contributors ## Contributors
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center;"> <div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;">
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a> <div align="center">
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a> <a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a> <br><sub><a href="https://www.gitlink.org.cn/wangyue111">wangyue111</a></sub>
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a> </div>
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a> <div align="center">
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Z/144_206_212/120.png" width="40" height="40" alt="muel" style="border-radius: 50%;"></a> <a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a> <br><sub><a href="https://www.gitlink.org.cn/wbtiger">wbtiger</a></sub>
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a> </div>
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a> <div align="center">
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/Mengz">Mengz</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/yangsai">yangsai</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/mengcheng">mengcheng</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/images/avatars/User/149182?t=1779603476" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/muel">muel</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/Leo77">Leo77</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/yingjie">yingjie</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/topshare">topshare</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/dtwdtw" title="dtwdtw"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/D/53_166_51/120.png" width="40" height="40" alt="dtwdtw" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/dtwdtw">dtwdtw</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/recorder" title="recorder"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/R/141_201_87/120.png" width="40" height="40" alt="recorder" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/recorder">recorder</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/puygob236" title="Jiachen Li"><img src="https://www.gitlink.org.cn/images/avatars/User/149183?t=1778815174" width="40" height="40" alt="puygob236" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/puygob236">puygob236</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/co63oc" title="co63oc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/C/205_201_141/120.png" width="40" height="40" alt="co63oc" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/co63oc">co63oc</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/lindiwen23" title="lindiwen23"><img src="https://www.gitlink.org.cn/images/avatars/User/141609?t=1748270628" width="40" height="40" alt="lindiwen23" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/lindiwen23">lindiwen23</a></sub>
</div>
</div> </div>
## Why gitlink-cli? ## Why gitlink-cli?
- **Agent-Native Design** — 13 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup - **Agent-Native Design**Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Webhook, Branch, Release, CI, Org, Search, User — all core domains covered - **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output - **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded - **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install` - **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
@ -40,13 +87,16 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| Category | Capabilities | | Category | Capabilities |
|----------|-------------| |----------|-------------|
| 📦 Repo | List, create, fork, delete repositories, view repo info | | 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions |
| 🐛 Issue | Create, update, close, batch close, comment on issues | | 🐛 Issue | Create, update, close, batch close, comment on issues |
| 🔖 Label | Create, list, update, delete issue labels |
| 🔀 PR | Create, merge, review pull requests, view changed files | | 🔀 PR | Create, merge, review pull requests, view changed files |
| 👥 Member | List, add, remove repository members, change roles, create and accept invite links |
| 🌿 Branch | Create, delete, list, protect, unprotect branches | | 🌿 Branch | Create, delete, list, protect, unprotect branches |
| 🏷️ Release | Create, view, delete releases | | 🏷️ Release | Create, view, delete releases |
| 🏢 Org | Manage organizations, members, teams | | 🏢 Org | Manage organizations, members, teams |
| 🔧 CI | View builds, logs, CI/CD operations | | 🔧 CI | View builds, logs, CI/CD operations |
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
| 🔔 Webhook | Manage repo webhooks and test deliveries | | 🔔 Webhook | Manage repo webhooks and test deliveries |
| 🔍 Search | Search repositories, users | | 🔍 Search | Search repositories, users |
| 👤 User | View user profiles and info | | 👤 User | View user profiles and info |
@ -70,7 +120,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
**From npm (recommended):** **From npm (recommended):**
```bash ```bash
# One command: installs CLI binary + all 13 AI Agent Skills # One command: installs CLI binary + AI Agent Skills
npm install -g @gitlink-ai/cli npm install -g @gitlink-ai/cli
``` ```
@ -86,7 +136,7 @@ cd gitlink-cli
make install make install
``` ```
> **Windows users:** Run `npm install -g @gitlink-ai/cli` in PowerShell or CMD. For building from source, use `go install .` instead of `make install`. > **Windows users:** Run `npm install -g @gitlink-ai/cli` in PowerShell or CMD. For building from source, use `go install .` instead of `make install`. If your Agent or shell uses Git Bash, see the FAQ below for the `node .../bin/cli.js` fallback invocation.
#### Configure & Use #### Configure & Use
@ -151,6 +201,32 @@ gitlink-cli repo +list
# View repository info # View repository info
gitlink-cli repo +info --owner Gitlink --repo forgeplus gitlink-cli repo +info --owner Gitlink --repo forgeplus
# Read repository README
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
# Show language breakdown
gitlink-cli repo +languages --owner Gitlink --repo forgeplus
# List contributors
gitlink-cli repo +contributors --owner Gitlink --repo forgeplus
# Show contributor code-line stats for a branch, tag, or commit
gitlink-cli repo +contributor-stats --owner Gitlink --repo forgeplus --ref master --pass-year 1
# Show repository code stats
gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master
# List watchers and stargazers in a time range
gitlink-cli repo +watchers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
gitlink-cli repo +stargazers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
# Preview and apply repository interaction actions
gitlink-cli repo +follow --owner Gitlink --repo forgeplus --dry-run
gitlink-cli repo +follow --owner Gitlink --repo forgeplus
gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123
gitlink-cli repo +like --owner Gitlink --repo forgeplus
gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123
# Create a repository # Create a repository
gitlink-cli repo +create -n my-project -d "Project description" gitlink-cli repo +create -n my-project -d "Project description"
@ -175,6 +251,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68 gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
``` ```
### Member Management
```bash
# List repository members
gitlink-cli member +list --owner Gitlink --repo forgeplus
# Add a member
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
# Preview batch add without changing data
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
# Batch add members from a CSV file
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
# Change a member role
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
# Create an invite link
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
```
### Issue Management ### Issue Management
```bash ```bash
@ -184,9 +282,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
# Create an issue # Create an issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..." gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..."
# Create an issue with metadata
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
# View an issue # View an issue
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
# Update issue metadata
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
# Close an issue # Close an issue
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
@ -198,6 +302,45 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
# Add a comment # Add a comment
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
# List issue assigners
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
# List issue authors
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
# List issue priorities
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus
# List issue tags
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
# List issue statuses
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
```
`issue +view`, `issue +update`, `issue +close`, and `issue +comment` prefer
`--number` / `-n` for the issue number shown in the web URL. `--id` / `-i`
is accepted as a compatibility alias for the same web issue number, not the
global database ID.
### Label Management
```bash
# List issue labels
gitlink-cli label +list --owner Gitlink --repo forgeplus
# Filter labels by keyword
gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug
# Create a label (color defaults to #1E90FF)
gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "Something is broken" -c "#FF0000"
# Update a label (unspecified fields are preserved)
gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00"
# Delete a label
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
``` ```
### Pull Requests ### Pull Requests
@ -218,6 +361,9 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
# Merge a PR # Merge a PR
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42 gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
# Reopen a closed PR
gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
# View changed files # View changed files
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42 gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
@ -280,6 +426,28 @@ gitlink-cli ci +log --owner Gitlink --repo forgeplus -i <build_id>
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id> gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
``` ```
### Pipeline Operations
```bash
# List platform pipelines
gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20
# List repository pipeline runs
gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml
# Start a pipeline workflow, previewing the request first
gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run
# Inspect pipeline details and logs
gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7
gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43
gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99
# Toggle or delete pipeline workflows, previewing destructive writes first
gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
```
### Search ### Search
```bash ```bash
@ -400,6 +568,12 @@ gitlink-cli api GET /users/me
# POST request # POST request
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}' gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
# POST request with body from a file
gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json
# POST request with body from stdin
Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin
# With query parameters # With query parameters
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
``` ```
@ -437,24 +611,27 @@ git push gitlink
## AI Agent Skills ## AI Agent Skills
The `skills/` directory contains 12 Agent Skill files for AI-automated GitLink operations. The `skills/` directory contains Agent Skill files for AI-automated GitLink operations.
See [skills/README.md](skills/README.md) for details. See [skills/README.md](skills/README.md) for details.
| Skill | Description | | Skill | Description |
|-------|-------------| |-------|-------------|
| `gitlink-shared` | Authentication, global parameters, safety rules, API notes | | `gitlink-shared` | Authentication, global parameters, safety rules, API notes |
| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) | | `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) |
| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) | | `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) | | `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
| `gitlink-member` | Repository member and invite link management |
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) | | `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
| `gitlink-release` | Release management (create, view, delete, etc.) | | `gitlink-release` | Release management (create, view, delete, etc.) |
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) | | `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
| `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) |
| `gitlink-search` | Search (repositories, users, etc.) | | `gitlink-search` | Search (repositories, users, etc.) |
| `gitlink-org` | Organization management (members, teams, etc.) | | `gitlink-org` | Organization management (members, teams, etc.) |
| `gitlink-user` | User management (profile info, etc.) | | `gitlink-user` | User management (profile info, etc.) |
| `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) | | `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) |
| `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) | | `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) |
| `gitlink-health` | Project health analysis (PR/Issue metrics aggregation, health reports) |
## Project Structure ## Project Structure
@ -477,10 +654,12 @@ gitlink-cli/
│ ├── repo/ # Repository shortcuts │ ├── repo/ # Repository shortcuts
│ ├── issue/ # Issue shortcuts │ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts │ ├── pr/ # PR shortcuts
│ ├── member/ # Repository member shortcuts
│ ├── branch/ # Branch shortcuts │ ├── branch/ # Branch shortcuts
│ ├── release/ # Release shortcuts │ ├── release/ # Release shortcuts
│ ├── org/ # Organization shortcuts │ ├── org/ # Organization shortcuts
│ ├── ci/ # CI shortcuts │ ├── ci/ # CI shortcuts
│ ├── pipeline/ # Pipeline shortcuts
│ ├── search/ # Search shortcuts │ ├── search/ # Search shortcuts
│ ├── user/ # User shortcuts │ ├── user/ # User shortcuts
│ └── register.go # Registration entry point │ └── register.go # Registration entry point
@ -563,6 +742,35 @@ If the error persists, check whether the release page contains the asset for you
for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64. for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64.
You can also download the binary manually from the release page or build from source with `go install .`. You can also download the binary manually from the release page or build from source with `go install .`.
### Q: What if `gitlink-cli` works in PowerShell/CMD but fails in Git Bash or a Windows Agent shell?
On Windows, `npm install -g @gitlink-ai/cli` creates npm shims. PowerShell and CMD can execute those shims directly, but some Git Bash based Agents cannot.
Use the direct Node entry instead:
```bash
GITLINK_CLI="$(npm root -g)/@gitlink-ai/cli/bin/cli.js"
node "$GITLINK_CLI" user +info --login <login> --format json
```
If `npm root -g` is unavailable, locate the global package first and run the entry file manually:
```bash
npm list -g @gitlink-ai/cli --depth=0
node "/c/Program Files/nodejs/node_modules/@gitlink-ai/cli/bin/cli.js" user +info --login <login> --format json
```
For Skills and Agent prompts, prefer documenting both forms:
```bash
# PowerShell / CMD / macOS / Linux
gitlink-cli user +info --login <login> --format json
# Windows Git Bash or shim-incompatible Agent shells
GITLINK_CLI="$(npm root -g)/@gitlink-ai/cli/bin/cli.js"
node "$GITLINK_CLI" user +info --login <login> --format json
```
### Q: Where are credentials stored on Windows? ### Q: Where are credentials stored on Windows?
gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`). gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`).

View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![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) [![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+ 命令和 13 个 AI Agent [Skills](./skills/)。 [GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
**[English](./README.md)** **[English](./README.md)**
@ -13,22 +13,69 @@
## 贡献者 ## 贡献者
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center;"> <div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;">
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a> <div align="center">
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a> <a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a> <br><sub><a href="https://www.gitlink.org.cn/wangyue111">wangyue111</a></sub>
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a> </div>
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a> <div align="center">
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Z/144_206_212/120.png" width="40" height="40" alt="muel" style="border-radius: 50%;"></a> <a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a> <br><sub><a href="https://www.gitlink.org.cn/wbtiger">wbtiger</a></sub>
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a> </div>
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a> <div align="center">
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/Mengz">Mengz</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/yangsai">yangsai</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/mengcheng">mengcheng</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/images/avatars/User/149182?t=1779603476" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/muel">muel</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/Leo77">Leo77</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/yingjie">yingjie</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/topshare">topshare</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/dtwdtw" title="dtwdtw"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/D/53_166_51/120.png" width="40" height="40" alt="dtwdtw" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/dtwdtw">dtwdtw</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/recorder" title="recorder"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/R/141_201_87/120.png" width="40" height="40" alt="recorder" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/recorder">recorder</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/puygob236" title="Jiachen Li"><img src="https://www.gitlink.org.cn/images/avatars/User/149183?t=1778815174" width="40" height="40" alt="puygob236" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/puygob236">puygob236</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/co63oc" title="co63oc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/C/205_201_141/120.png" width="40" height="40" alt="co63oc" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/co63oc">co63oc</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/lindiwen23" title="lindiwen23"><img src="https://www.gitlink.org.cn/images/avatars/User/141609?t=1748270628" width="40" height="40" alt="lindiwen23" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/lindiwen23">lindiwen23</a></sub>
</div>
</div> </div>
## 为什么选择 gitlink-cli ## 为什么选择 gitlink-cli
- **Agent-Native 设计** — 开箱即用 13 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink - **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、Webhook、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖 - **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出 - **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装 - **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
- **开源零门槛** — 木兰宽松许可证第2版MulanPSL-2.0`npm install` 即用 - **开源零门槛** — 木兰宽松许可证第2版MulanPSL-2.0`npm install` 即用
@ -40,13 +87,16 @@
| 分类 | 能力 | | 分类 | 能力 |
|------|------| |------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息 | | 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息、洞察数据和互动状态 |
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | | 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 | | 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
| 🌿 分支 | 创建、删除、保护分支 | | 🌿 分支 | 创建、删除、保护分支 |
| 🏷️ 发布 | 创建、查看、删除 Release | | 🏷️ 发布 | 创建、查看、删除 Release |
| 🏢 组织 | 管理组织、成员、团队 | | 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 | | 🔧 CI | 查看构建、日志、CI/CD 操作 |
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
| 🔍 搜索 | 搜索仓库、用户 | | 🔍 搜索 | 搜索仓库、用户 |
| 👤 用户 | 查看用户资料和信息 | | 👤 用户 | 查看用户资料和信息 |
| 📋 项目管理 | Sprint 管理、看板、周报 | | 📋 项目管理 | Sprint 管理、看板、周报 |
@ -94,7 +144,7 @@ make install
npx skills add ./skills -y -g npx skills add ./skills -y -g
``` ```
> **Windows 用户注意:** 请在 PowerShell 或 CMD 中运行 `npm install -g @gitlink-ai/cli`。从源码构建请使用 `go install .` 代替 `make install` > **Windows 用户注意:** 请在 PowerShell 或 CMD 中运行 `npm install -g @gitlink-ai/cli`。从源码构建请使用 `go install .` 代替 `make install`如果你的 Agent 或 Shell 使用 Git Bash请参考下方 FAQ 中的 `node .../bin/cli.js` 兜底调用方式。
#### 配置与使用 #### 配置与使用
@ -162,6 +212,32 @@ gitlink-cli repo +list
# 查看仓库信息 # 查看仓库信息
gitlink-cli repo +info --owner Gitlink --repo forgeplus gitlink-cli repo +info --owner Gitlink --repo forgeplus
# 读取仓库 README
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
# 查看语言占比
gitlink-cli repo +languages --owner Gitlink --repo forgeplus
# 列出贡献者
gitlink-cli repo +contributors --owner Gitlink --repo forgeplus
# 查看分支、标签或提交的贡献者代码行统计
gitlink-cli repo +contributor-stats --owner Gitlink --repo forgeplus --ref master --pass-year 1
# 查看仓库代码统计
gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master
# 按时间范围查看关注者和点赞者
gitlink-cli repo +watchers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
gitlink-cli repo +stargazers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
# 预览并执行仓库互动操作
gitlink-cli repo +follow --owner Gitlink --repo forgeplus --dry-run
gitlink-cli repo +follow --owner Gitlink --repo forgeplus
gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123
gitlink-cli repo +like --owner Gitlink --repo forgeplus
gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123
# 创建仓库 # 创建仓库
gitlink-cli repo +create -n my-project -d "项目描述" gitlink-cli repo +create -n my-project -d "项目描述"
@ -186,6 +262,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68 gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
``` ```
### 成员管理
```bash
# 列出仓库成员
gitlink-cli member +list --owner Gitlink --repo forgeplus
# 添加成员
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
# 预览批量添加成员,不修改数据
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
# 从 CSV 文件批量添加成员
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
# 调整成员权限
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
# 生成邀请链接
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
```
### Issue 管理 ### Issue 管理
```bash ```bash
@ -195,9 +293,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
# 创建 Issue # 创建 Issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..." gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."
# 创建带元数据的 Issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
# 查看 Issue # 查看 Issue
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
# 更新 Issue 元数据
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
# 关闭 Issue # 关闭 Issue
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
@ -209,6 +313,44 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
# 添加评论 # 添加评论
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
# 列出 Issue 负责人
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
# 列出 Issue 发布人
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
# 列出 Issue 优先级
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus
# 列出 Issue 标签
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
# 列出 Issue 状态
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
```
`issue +view`、`issue +update`、`issue +close` 和 `issue +comment` 推荐使用
`--number` / `-n` 传网页 URL 中的 Issue 编号。`--id` / `-i` 是同一网页 Issue
编号的兼容别名,不是数据库内部 ID。
### 标签管理
```bash
# 列出 Issue 标签
gitlink-cli label +list --owner Gitlink --repo forgeplus
# 按关键词筛选标签
gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug
# 创建标签(颜色默认 #1E90FF
gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "功能缺陷" -c "#FF0000"
# 更新标签(未指定的字段会被保留)
gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00"
# 删除标签
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
``` ```
### Pull Request ### Pull Request
@ -229,6 +371,9 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
# 合并 PR # 合并 PR
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42 gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
# 重开已关闭的 PR
gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
# 查看 PR 变更文件 # 查看 PR 变更文件
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42 gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
@ -259,6 +404,28 @@ gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id> gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
``` ```
### 流水线管理
```bash
# 列出平台流水线
gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20
# 列出仓库流水线运行记录
gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml
# 运行流水线工作流,先用 dry-run 预览请求
gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run
# 查看流水线详情、日志和运行结果
gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7
gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43
gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99
# 启停或删除流水线工作流,写入/删除前先预览
gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
```
### 搜索 ### 搜索
```bash ```bash
@ -280,6 +447,12 @@ gitlink-cli api GET /users/me
# POST 请求 # POST 请求
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}' gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
# 从文件读取 JSON body
gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json
# 从 stdin 读取 JSON body
Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin
# 带查询参数 # 带查询参数
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
``` ```
@ -317,19 +490,21 @@ git push gitlink
## AI Agent Skills ## AI Agent Skills
`skills/` 目录包含 13 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。 `skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
详见 [skills/README.md](skills/README.md) 详见 [skills/README.md](skills/README.md)
| Skill | 说明 | | Skill | 说明 |
|-------|------| |-------|------|
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 | | `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
| `gitlink-repo` | 仓库操作创建、查看、删除、Fork 等) | | `gitlink-repo` | 仓库操作创建、查看、删除、Fork、洞察数据等) |
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) | | `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
| `gitlink-pr` | Pull Request 操作创建、合并、Review 等) | | `gitlink-pr` | Pull Request 操作创建、合并、Review 等) |
| `gitlink-member` | 仓库成员与邀请链接管理 |
| `gitlink-release` | 发布管理(创建、查看、删除等) | | `gitlink-release` | 发布管理(创建、查看、删除等) |
| `gitlink-org` | 组织管理(成员、团队等) | | `gitlink-org` | 组织管理(成员、团队等) |
| `gitlink-ci` | CI/CD 操作(构建、日志等) | | `gitlink-ci` | CI/CD 操作(构建、日志等) |
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
| `gitlink-search` | 搜索功能(仓库、用户等) | | `gitlink-search` | 搜索功能(仓库、用户等) |
| `gitlink-user` | 用户管理(个人信息等) | | `gitlink-user` | 用户管理(个人信息等) |
| `gitlink-pm` | 项目管理Sprint、看板、周报等 | | `gitlink-pm` | 项目管理Sprint、看板、周报等 |
@ -356,10 +531,12 @@ gitlink-cli/
│ ├── repo/ # 仓库 shortcuts │ ├── repo/ # 仓库 shortcuts
│ ├── issue/ # Issue shortcuts │ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts │ ├── pr/ # PR shortcuts
│ ├── member/ # 仓库成员 shortcuts
│ ├── branch/ # 分支 shortcuts │ ├── branch/ # 分支 shortcuts
│ ├── release/ # Release shortcuts │ ├── release/ # Release shortcuts
│ ├── org/ # 组织 shortcuts │ ├── org/ # 组织 shortcuts
│ ├── ci/ # CI shortcuts │ ├── ci/ # CI shortcuts
│ ├── pipeline/ # Pipeline shortcuts
│ ├── search/ # 搜索 shortcuts │ ├── search/ # 搜索 shortcuts
│ ├── user/ # 用户 shortcuts │ ├── user/ # 用户 shortcuts
│ └── register.go # 注册入口 │ └── register.go # 注册入口
@ -440,6 +617,35 @@ npm install -g @gitlink-ai/cli
如果仍然失败,请检查 Release 页面是否包含当前平台的资产,例如 Windows x64 对应 `gitlink-cli_<version>_windows_amd64.zip`。也可以从 Release 页面手动下载二进制,或使用 `go install .` 从源码构建。 如果仍然失败,请检查 Release 页面是否包含当前平台的资产,例如 Windows x64 对应 `gitlink-cli_<version>_windows_amd64.zip`。也可以从 Release 页面手动下载二进制,或使用 `go install .` 从源码构建。
### Q: 为什么 `gitlink-cli` 在 PowerShell/CMD 可用,但在 Git Bash 或 Windows Agent Shell 里失败?
在 Windows 上,`npm install -g @gitlink-ai/cli` 会生成 npm shim。PowerShell 和 CMD 可以直接执行这些 shim但部分基于 Git Bash 的 Agent Shell 不能正确处理。
这时请直接调用 Node 入口文件:
```bash
GITLINK_CLI="$(npm root -g)/@gitlink-ai/cli/bin/cli.js"
node "$GITLINK_CLI" user +info --login <login> --format json
```
如果 `npm root -g` 不可用,先定位全局安装目录,再手动执行入口文件:
```bash
npm list -g @gitlink-ai/cli --depth=0
node "/c/Program Files/nodejs/node_modules/@gitlink-ai/cli/bin/cli.js" user +info --login <login> --format json
```
在 Skill 或 Agent 提示词中,建议同时写明这两种调用方式:
```bash
# PowerShell / CMD / macOS / Linux
gitlink-cli user +info --login <login> --format json
# Windows Git Bash 或不兼容 npm shim 的 Agent Shell
GITLINK_CLI="$(npm root -g)/@gitlink-ai/cli/bin/cli.js"
node "$GITLINK_CLI" user +info --login <login> --format json
```
### Q: Windows 上凭证存储在哪里? ### Q: Windows 上凭证存储在哪里?
gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。 gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。

View File

@ -2,37 +2,70 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"net/url" "net/url"
"os"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/internal/output"
) )
func NewAPICmd() *cobra.Command { func NewAPICmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
apiCmd := &cobra.Command{ apiCmd := &cobra.Command{
Use: "api <METHOD> <PATH>", Use: "api (<METHOD> <PATH> | --batch-file <FILE>)",
Short: "Make raw API requests to GitLink", Short: tr.T("cmd.api.short"),
Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`, Long: tr.T("cmd.api.long"),
Example: ` gitlink-cli api GET /users/me Example: ` gitlink-cli api GET /users/me
gitlink-cli api GET /projects --query 'page=1&limit=10' gitlink-cli api GET /projects --query 'page=1&limit=10'
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`, gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
Args: cobra.ExactArgs(2), gitlink-cli api POST /:owner/:repo/issues --body-file issue.json
gitlink-cli api --batch-file plan.json --dry-run
gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=gitlink-cli`,
Args: validateAPIArgs,
RunE: runAPI, RunE: runAPI,
} }
apiCmd.Flags().String("body", "", "Request body (JSON string)") apiCmd.Flags().String("body", "", tr.T("flag.api.body"))
apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)") apiCmd.Flags().String("body-file", "", tr.T("flag.api.body_file"))
apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)") apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin"))
apiCmd.Flags().String("query", "", tr.T("flag.api.query"))
apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header"))
apiCmd.Flags().String("batch-file", "", tr.T("flag.api.batch_file"))
apiCmd.Flags().Bool("dry-run", false, tr.T("flag.api.batch_dry_run"))
apiCmd.Flags().Bool("continue-on-error", false, tr.T("flag.api.batch_continue_on_error"))
apiCmd.Flags().StringArray("var", nil, tr.T("flag.api.batch_var"))
return apiCmd return apiCmd
} }
func validateAPIArgs(c *cobra.Command, args []string) error {
batchFile, _ := c.Flags().GetString("batch-file")
if batchFile != "" {
if len(args) != 0 {
return fmt.Errorf("api batch mode does not accept METHOD or PATH arguments")
}
return nil
}
return cobra.ExactArgs(2)(c, args)
}
func runAPI(c *cobra.Command, args []string) error { func runAPI(c *cobra.Command, args []string) error {
batchFile, _ := c.Flags().GetString("batch-file")
if batchFile != "" {
return runAPIBatch(c, batchFile)
}
method := strings.ToUpper(args[0]) method := strings.ToUpper(args[0])
path := args[1] path := args[1]
@ -46,12 +79,9 @@ func runAPI(c *cobra.Command, args []string) error {
} }
cli.Debug = cmdutil.Debug cli.Debug = cmdutil.Debug
var body interface{} body, err := readJSONBody(c)
bodyStr, _ := c.Flags().GetString("body") if err != nil {
if bodyStr != "" { return err
if err := json.Unmarshal([]byte(bodyStr), &body); err != nil {
return fmt.Errorf("invalid JSON body: %w", err)
}
} }
var query url.Values var query url.Values
@ -66,7 +96,8 @@ func runAPI(c *cobra.Command, args []string) error {
env, err := cli.Do(method, path, body, query) env, err := cli.Do(method, path, body, query)
if err != nil { if err != nil {
if apiErr, ok := err.(*client.APIError); ok { var apiErr *client.APIError
if errors.As(err, &apiErr) {
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "") errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "")
return output.Print(errEnv, resolveFormat()) return output.Print(errEnv, resolveFormat())
} }
@ -76,6 +107,49 @@ func runAPI(c *cobra.Command, args []string) error {
return output.Print(env, resolveFormat()) return output.Print(env, resolveFormat())
} }
func readJSONBody(c *cobra.Command) (interface{}, error) {
bodyStr, _ := c.Flags().GetString("body")
bodyFile, _ := c.Flags().GetString("body-file")
bodyStdin, _ := c.Flags().GetBool("body-stdin")
sources := 0
if bodyStr != "" {
sources++
}
if bodyFile != "" {
sources++
}
if bodyStdin {
sources++
}
if sources == 0 {
return nil, nil
}
if sources > 1 {
return nil, fmt.Errorf("use only one of --body, --body-file, or --body-stdin")
}
var data []byte
var err error
switch {
case bodyStr != "":
data = []byte(bodyStr)
case bodyFile != "":
data, err = os.ReadFile(bodyFile)
case bodyStdin:
data, err = io.ReadAll(c.InOrStdin())
}
if err != nil {
return nil, fmt.Errorf("read JSON body: %w", err)
}
var body interface{}
if err := json.Unmarshal(data, &body); err != nil {
return nil, fmt.Errorf("invalid JSON body: %w", err)
}
return body, nil
}
func resolveFormat() string { func resolveFormat() string {
f := cmdutil.Format f := cmdutil.Format
if f == "" { if f == "" {

404
cmd/api/api_test.go Normal file
View File

@ -0,0 +1,404 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
)
func TestResolveFormat(t *testing.T) {
tests := []struct {
name string
flagFormat string
want string
}{
{"empty defaults to json", "", "json"},
{"explicit json", "json", "json"},
{"explicit yaml", "yaml", "yaml"},
{"explicit table", "table", "table"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmdutil.Format = tt.flagFormat
if got := resolveFormat(); got != tt.want {
t.Fatalf("resolveFormat = %q, want %q", got, tt.want)
}
})
}
}
func TestNewAPICmd(t *testing.T) {
cmd := NewAPICmd()
if cmd.Use != "api (<METHOD> <PATH> | --batch-file <FILE>)" {
t.Fatalf("Use = %q", cmd.Use)
}
if cmd.Short == "" {
t.Fatal("Short is empty")
}
// Verify flags exist
flags := []string{"body", "query", "header", "batch-file", "dry-run", "continue-on-error", "var"}
for _, f := range flags {
if cmd.Flags().Lookup(f) == nil {
t.Fatalf("flag %q not found", f)
}
}
}
func setupAPITest(t *testing.T, handler http.HandlerFunc) string {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
os.MkdirAll(dir, 0700)
os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("base_url: "+server.URL+"\ndefault_format: table\n"), 0600)
return dir
}
func TestRunAPIGet(t *testing.T) {
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/me.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"login": "testuser", "id": 42})
})
cmdutil.Format = "json"
cmd := NewAPICmd()
cmd.SetArgs([]string{"GET", "/users/me"})
if err := cmd.Execute(); err != nil {
t.Fatalf("runAPI GET error: %v", err)
}
}
func TestRunAPIPostWithBody(t *testing.T) {
var gotBody map[string]interface{}
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1, "title": "new issue"})
})
cmdutil.Format = "json"
cmd := NewAPICmd()
cmd.SetArgs([]string{"POST", "/repos/owner/repo/issues"})
cmd.Flags().Set("body", `{"title":"new issue","body":"test"}`)
if err := cmd.Execute(); err != nil {
t.Fatalf("runAPI POST error: %v", err)
}
if gotBody["title"] != "new issue" {
t.Fatalf("body title = %q, want 'new issue'", gotBody["title"])
}
}
func TestRunAPIBadJSONBody(t *testing.T) {
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not reach server")
})
cmdutil.Format = "json"
cmd := NewAPICmd()
cmd.SetArgs([]string{"POST", "/repos/owner/repo/issues"})
cmd.Flags().Set("body", `{bad json}`)
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for bad JSON body")
}
}
func TestRunAPIBadQuery(t *testing.T) {
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not reach server")
})
cmdutil.Format = "json"
cmd := NewAPICmd()
cmd.SetArgs([]string{"GET", "/repos/owner/repo/issues"})
cmd.Flags().Set("query", "key=%zz")
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for bad query string")
}
}
func TestRunAPIHTTPError(t *testing.T) {
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
})
cmdutil.Format = "json"
cmd := NewAPICmd()
cmd.SetArgs([]string{"GET", "/nonexistent"})
// HTTP errors are caught and printed as error envelopes; runAPI does not return the error
if err := cmd.Execute(); err != nil {
t.Fatalf("runAPI HTTP error: %v (expected success with error envelope)", err)
}
}
func TestRunAPIStatusError(t *testing.T) {
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{"status": float64(401), "message": "Unauthorized"})
})
cmdutil.Format = "json"
cmd := NewAPICmd()
cmd.SetArgs([]string{"GET", "/users/me"})
// Should print error envelope, not return a Go error (status check in Do() handles this)
// Actually, HTTP 401 triggers APIError return from Do(), so this should error
if err := cmd.Execute(); err != nil {
// Expected — HTTP error
t.Logf("got expected error: %v", err)
}
}
func TestRunAPIDebug(t *testing.T) {
var gotDebugHeader bool
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
gotDebugHeader = true
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
})
cmdutil.Format = "json"
cmdutil.Debug = true
defer func() { cmdutil.Debug = false }()
cmd := NewAPICmd()
cmd.SetArgs([]string{"GET", "/users/me"})
if err := cmd.Execute(); err != nil {
t.Fatalf("runAPI debug error: %v", err)
}
if !gotDebugHeader {
t.Fatal("server not reached")
}
}
func TestRunAPINoPrefix(t *testing.T) {
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/me.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"login": "testuser"})
})
cmdutil.Format = "json"
cmd := NewAPICmd()
cmd.SetArgs([]string{"GET", "users/me"})
if err := cmd.Execute(); err != nil {
t.Fatalf("runAPI no-prefix error: %v", err)
}
}
func TestRenderBatchRequestsTemplateVars(t *testing.T) {
requests, err := renderBatchRequests([]batchRequest{
{
Name: "comment-{{number}}",
Method: "post",
Path: "v1/{{owner}}/{{repo}}/issues/{{number}}/journals",
Query: map[string]interface{}{
"label": []interface{}{"{{label}}", "triage"},
"page": float64(1),
},
Body: map[string]interface{}{
"notes": "handled by {{actor}}",
"meta": map[string]interface{}{"repo": "{{repo}}"},
},
},
}, map[string]string{
"owner": "Gitlink",
"repo": "gitlink-cli",
"number": "42",
"label": "bug",
"actor": "bot",
})
if err != nil {
t.Fatalf("renderBatchRequests error: %v", err)
}
if len(requests) != 1 {
t.Fatalf("len = %d, want 1", len(requests))
}
req := requests[0]
if req.Name != "comment-42" {
t.Fatalf("Name = %q", req.Name)
}
if req.Method != "POST" {
t.Fatalf("Method = %q", req.Method)
}
if req.Path != "/v1/Gitlink/gitlink-cli/issues/42/journals" {
t.Fatalf("Path = %q", req.Path)
}
if got := req.Query["label"]; len(got) != 2 || got[0] != "bug" || got[1] != "triage" {
t.Fatalf("label query = %#v", got)
}
body := req.Body.(map[string]interface{})
if body["notes"] != "handled by bot" {
t.Fatalf("notes = %#v", body["notes"])
}
}
func TestRenderBatchRequestsMissingVar(t *testing.T) {
_, err := renderBatchRequests([]batchRequest{{Method: "GET", Path: "/{{missing}}"}}, nil)
if err == nil {
t.Fatal("expected missing variable error")
}
}
func TestRunAPIBatchDryRunDoesNotReachServer(t *testing.T) {
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("dry-run should not reach server")
})
cmdutil.Format = "json"
plan := writeBatchPlan(t, map[string]interface{}{
"vars": map[string]string{"owner": "Gitlink"},
"requests": []map[string]interface{}{
{"name": "me", "method": "GET", "path": "/users/me"},
{"name": "repo", "method": "GET", "path": "/{{owner}}/gitlink-cli"},
},
})
cmd := NewAPICmd()
cmd.SetArgs([]string{"--batch-file", plan, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run batch error: %v", err)
}
}
func TestRunAPIBatchExecutesRequestsWithOverrides(t *testing.T) {
var seen []string
var gotBody map[string]interface{}
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Method+" "+r.URL.String())
switch r.URL.Path {
case "/v1/Mengz/gitlink-cli/issues.json":
if r.URL.Query().Get("state") != "open" {
t.Fatalf("state query = %q", r.URL.Query().Get("state"))
}
json.NewEncoder(w).Encode(map[string]interface{}{"issues": []interface{}{}})
case "/v1/Mengz/gitlink-cli/issues/7/journals.json":
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode body: %v", err)
}
json.NewEncoder(w).Encode(map[string]interface{}{"id": 99})
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
})
cmdutil.Format = "json"
plan := writeBatchPlan(t, map[string]interface{}{
"vars": map[string]string{"owner": "Gitlink", "repo": "gitlink-cli", "issue": "7"},
"requests": []map[string]interface{}{
{
"name": "list",
"method": "GET",
"path": "/v1/{{owner}}/{{repo}}/issues",
"query": map[string]interface{}{"state": "open"},
},
{
"name": "comment",
"method": "POST",
"path": "/v1/{{owner}}/{{repo}}/issues/{{issue}}/journals",
"body": map[string]interface{}{"notes": "hello {{repo}}"},
},
},
})
cmd := NewAPICmd()
cmd.SetArgs([]string{"--batch-file", plan, "--var", "owner=Mengz"})
if err := cmd.Execute(); err != nil {
t.Fatalf("batch execute error: %v", err)
}
if len(seen) != 2 {
t.Fatalf("requests = %d, want 2 (%v)", len(seen), seen)
}
if gotBody["notes"] != "hello gitlink-cli" {
t.Fatalf("body notes = %#v", gotBody["notes"])
}
}
func TestRunAPIBatchStopsOnErrorByDefault(t *testing.T) {
var seen []string
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.URL.Path)
if r.URL.Path == "/fail.json" {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
})
cmdutil.Format = "json"
plan := writeBatchPlan(t, map[string]interface{}{
"requests": []map[string]interface{}{
{"method": "GET", "path": "/ok"},
{"method": "GET", "path": "/fail"},
{"method": "GET", "path": "/never"},
},
})
cmd := NewAPICmd()
cmd.SetArgs([]string{"--batch-file", plan})
if err := cmd.Execute(); err == nil {
t.Fatal("expected batch error")
}
if len(seen) != 2 {
t.Fatalf("requests = %d, want 2 (%v)", len(seen), seen)
}
}
func TestRunAPIBatchContinueOnError(t *testing.T) {
var seen []string
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.URL.Path)
if r.URL.Path == "/fail.json" {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
})
cmdutil.Format = "json"
plan := writeBatchPlan(t, map[string]interface{}{
"requests": []map[string]interface{}{
{"method": "GET", "path": "/ok"},
{"method": "GET", "path": "/fail"},
{"method": "GET", "path": "/after"},
},
})
cmd := NewAPICmd()
cmd.SetArgs([]string{"--batch-file", plan, "--continue-on-error"})
if err := cmd.Execute(); err != nil {
t.Fatalf("batch should continue: %v", err)
}
if len(seen) != 3 {
t.Fatalf("requests = %d, want 3 (%v)", len(seen), seen)
}
}
func writeBatchPlan(t *testing.T, payload interface{}) string {
t.Helper()
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal plan: %v", err)
}
path := filepath.Join(t.TempDir(), "plan.json")
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatalf("write plan: %v", err)
}
return path
}

351
cmd/api/batch.go Normal file
View File

@ -0,0 +1,351 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"regexp"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
type batchPlan struct {
Vars map[string]string `json:"vars"`
Requests []batchRequest `json:"requests"`
}
type batchRequest struct {
Name string `json:"name"`
Method string `json:"method"`
Path string `json:"path"`
Query map[string]interface{} `json:"query"`
Body interface{} `json:"body"`
}
type renderedBatchRequest struct {
Index int `json:"index" yaml:"index"`
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Method string `json:"method" yaml:"method"`
Path string `json:"path" yaml:"path"`
Query url.Values `json:"query,omitempty" yaml:"query,omitempty"`
Body interface{} `json:"body,omitempty" yaml:"body,omitempty"`
}
type batchResult struct {
Index int `json:"index" yaml:"index"`
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Method string `json:"method" yaml:"method"`
Path string `json:"path" yaml:"path"`
OK bool `json:"ok" yaml:"ok"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
Data interface{} `json:"data,omitempty" yaml:"data,omitempty"`
}
type batchSummary struct {
DryRun bool `json:"dry_run" yaml:"dry_run"`
ContinueOnError bool `json:"continue_on_error" yaml:"continue_on_error"`
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Variables map[string]string `json:"variables,omitempty" yaml:"variables,omitempty"`
Requests []renderedBatchRequest `json:"requests,omitempty" yaml:"requests,omitempty"`
Results []batchResult `json:"results,omitempty" yaml:"results,omitempty"`
}
var templatePattern = regexp.MustCompile(`\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}`)
func runAPIBatch(c *cobra.Command, batchFile string) error {
if hasSingleRequestInput(c) {
return fmt.Errorf("use batch flags separately from --body, --body-file, --body-stdin, --query, or --header")
}
dryRun, _ := c.Flags().GetBool("dry-run")
continueOnError, _ := c.Flags().GetBool("continue-on-error")
overrides, err := parseBatchVars(c)
if err != nil {
return err
}
plan, err := readBatchPlan(batchFile)
if err != nil {
return err
}
vars := mergeBatchVars(plan.Vars, overrides)
requests, err := renderBatchRequests(plan.Requests, vars)
if err != nil {
return err
}
if dryRun {
return output.Print(output.SuccessEnvelope(batchSummary{
DryRun: true,
ContinueOnError: continueOnError,
Total: len(requests),
Variables: sortedVars(vars),
Requests: requests,
}, nil), resolveFormat())
}
cli, err := client.New()
if err != nil {
return err
}
cli.Debug = cmdutil.Debug
summary := batchSummary{
DryRun: false,
ContinueOnError: continueOnError,
Total: len(requests),
Variables: sortedVars(vars),
Results: make([]batchResult, 0, len(requests)),
}
for _, req := range requests {
result := batchResult{
Index: req.Index,
Name: req.Name,
Method: req.Method,
Path: req.Path,
}
env, callErr := cli.Do(req.Method, req.Path, req.Body, req.Query)
if callErr != nil {
summary.Failed++
result.OK = false
result.Error = apiBatchErrorMessage(callErr)
summary.Results = append(summary.Results, result)
if !continueOnError {
_ = output.Print(output.SuccessEnvelope(summary, nil), resolveFormat())
return callErr
}
continue
}
summary.Succeeded++
result.OK = true
if env != nil {
result.Data = env.Data
}
summary.Results = append(summary.Results, result)
}
return output.Print(output.SuccessEnvelope(summary, nil), resolveFormat())
}
func hasSingleRequestInput(c *cobra.Command) bool {
body, _ := c.Flags().GetString("body")
bodyFile, _ := c.Flags().GetString("body-file")
bodyStdin, _ := c.Flags().GetBool("body-stdin")
query, _ := c.Flags().GetString("query")
headers, _ := c.Flags().GetStringSlice("header")
return body != "" || bodyFile != "" || bodyStdin || query != "" || len(headers) > 0
}
func parseBatchVars(c *cobra.Command) (map[string]string, error) {
raw, _ := c.Flags().GetStringArray("var")
vars := make(map[string]string, len(raw))
for _, item := range raw {
key, value, ok := strings.Cut(item, "=")
key = strings.TrimSpace(key)
if !ok || key == "" {
return nil, fmt.Errorf("invalid --var %q, want key=value", item)
}
vars[key] = value
}
return vars, nil
}
func readBatchPlan(path string) (*batchPlan, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read batch file: %w", err)
}
var plan batchPlan
if err := json.Unmarshal(data, &plan); err != nil {
return nil, fmt.Errorf("invalid batch file JSON: %w", err)
}
if len(plan.Requests) == 0 {
return nil, fmt.Errorf("batch file must contain at least one request")
}
return &plan, nil
}
func mergeBatchVars(planVars, overrides map[string]string) map[string]string {
vars := make(map[string]string, len(planVars)+len(overrides))
for key, value := range planVars {
vars[key] = value
}
for key, value := range overrides {
vars[key] = value
}
return vars
}
func renderBatchRequests(requests []batchRequest, vars map[string]string) ([]renderedBatchRequest, error) {
rendered := make([]renderedBatchRequest, 0, len(requests))
for i, req := range requests {
method := strings.ToUpper(strings.TrimSpace(req.Method))
if method == "" {
return nil, fmt.Errorf("request %d method is required", i+1)
}
path, err := renderTemplate(req.Path, vars)
if err != nil {
return nil, fmt.Errorf("request %d path: %w", i+1, err)
}
path = strings.TrimSpace(path)
if path == "" {
return nil, fmt.Errorf("request %d path is required", i+1)
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
query, err := renderBatchQuery(req.Query, vars)
if err != nil {
return nil, fmt.Errorf("request %d query: %w", i+1, err)
}
body, err := renderBatchValue(req.Body, vars)
if err != nil {
return nil, fmt.Errorf("request %d body: %w", i+1, err)
}
name, err := renderTemplate(req.Name, vars)
if err != nil {
return nil, fmt.Errorf("request %d name: %w", i+1, err)
}
rendered = append(rendered, renderedBatchRequest{
Index: i + 1,
Name: name,
Method: method,
Path: path,
Query: query,
Body: body,
})
}
return rendered, nil
}
func renderBatchQuery(raw map[string]interface{}, vars map[string]string) (url.Values, error) {
if len(raw) == 0 {
return nil, nil
}
query := url.Values{}
keys := make([]string, 0, len(raw))
for key := range raw {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
renderedKey, err := renderTemplate(key, vars)
if err != nil {
return nil, err
}
values, err := renderQueryValues(raw[key], vars)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
for _, value := range values {
query.Add(renderedKey, value)
}
}
return query, nil
}
func renderQueryValues(raw interface{}, vars map[string]string) ([]string, error) {
switch value := raw.(type) {
case nil:
return []string{""}, nil
case string:
rendered, err := renderTemplate(value, vars)
return []string{rendered}, err
case []interface{}:
values := make([]string, 0, len(value))
for _, item := range value {
itemValues, err := renderQueryValues(item, vars)
if err != nil {
return nil, err
}
values = append(values, itemValues...)
}
return values, nil
default:
return []string{fmt.Sprint(value)}, nil
}
}
func renderBatchValue(raw interface{}, vars map[string]string) (interface{}, error) {
switch value := raw.(type) {
case nil:
return nil, nil
case string:
return renderTemplate(value, vars)
case []interface{}:
items := make([]interface{}, 0, len(value))
for _, item := range value {
rendered, err := renderBatchValue(item, vars)
if err != nil {
return nil, err
}
items = append(items, rendered)
}
return items, nil
case map[string]interface{}:
obj := make(map[string]interface{}, len(value))
for key, item := range value {
renderedKey, err := renderTemplate(key, vars)
if err != nil {
return nil, err
}
rendered, err := renderBatchValue(item, vars)
if err != nil {
return nil, err
}
obj[renderedKey] = rendered
}
return obj, nil
default:
return raw, nil
}
}
func renderTemplate(value string, vars map[string]string) (string, error) {
var missing []string
rendered := templatePattern.ReplaceAllStringFunc(value, func(match string) string {
parts := templatePattern.FindStringSubmatch(match)
if len(parts) != 2 {
return match
}
replacement, ok := vars[parts[1]]
if !ok {
missing = append(missing, parts[1])
return match
}
return replacement
})
if len(missing) > 0 {
sort.Strings(missing)
return "", fmt.Errorf("missing template variable(s): %s", strings.Join(missing, ", "))
}
return rendered, nil
}
func sortedVars(vars map[string]string) map[string]string {
if len(vars) == 0 {
return nil
}
copyVars := make(map[string]string, len(vars))
for key, value := range vars {
copyVars[key] = value
}
return copyVars
}
func apiBatchErrorMessage(err error) string {
var apiErr *client.APIError
if errors.As(err, &apiErr) {
return apiErr.Message
}
return err.Error()
}

View File

@ -2,141 +2,182 @@ package auth
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"io"
"os" "os"
"strings" "strings"
"syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term" "golang.org/x/term"
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth" internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
) )
const envTokenVar = "GITLINK_TOKEN" const envTokenVar = "GITLINK_TOKEN"
func NewAuthCmd() *cobra.Command { var (
storeToken = internalAuth.StoreToken
loadToken = internalAuth.LoadToken
)
func NewAuthCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "auth", Use: "auth",
Short: "Authentication commands", Short: tr.T("cmd.auth.short"),
} }
cmd.AddCommand(newLoginCmd()) cmd.AddCommand(newLoginCmd(tr))
cmd.AddCommand(newLogoutCmd()) cmd.AddCommand(newLogoutCmd(tr))
cmd.AddCommand(newStatusCmd()) cmd.AddCommand(newStatusCmd(tr))
return cmd return cmd
} }
func newLoginCmd() *cobra.Command { func newLoginCmd(tr *i18n.Translator) *cobra.Command {
var tokenMode bool var tokenMode bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "login", Use: "login",
Short: "Login to GitLink", Short: tr.T("cmd.auth.login.short"),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if tokenMode { if tokenMode {
return loginWithToken() return loginWithToken(cmd.InOrStdin(), cmd.OutOrStdout(), tr)
} }
return loginWithPassword() return loginWithPassword(cmd.InOrStdin(), cmd.OutOrStdout(), tr)
}, },
} }
cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token") cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token"))
return cmd return cmd
} }
func loginWithPassword() error { func loginWithPassword(in io.Reader, out io.Writer, tr *i18n.Translator) error {
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(in)
if _, err := fmt.Fprint(out, tr.T("prompt.auth.username")); err != nil {
fmt.Print("Username/Email/Phone: ") return err
}
username, _ := reader.ReadString('\n') username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username) username = strings.TrimSpace(username)
fmt.Print("Password: ") if _, err := fmt.Fprint(out, tr.T("prompt.auth.password")); err != nil {
passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) return err
}
passwordBytes, err := readPassword(in, reader)
if err != nil { if err != nil {
return fmt.Errorf("failed to read password: %w", err) return fmt.Errorf("failed to read password: %w", err)
} }
fmt.Println() if _, err := fmt.Fprintln(out); err != nil {
return err
}
password := string(passwordBytes) password := string(passwordBytes)
result, err := internalAuth.Login(username, password) result, err := internalAuth.Login(username, password)
if err != nil { if err != nil {
return fmt.Errorf("login failed: %w", err) return errors.New(tr.Tf("error.auth.login_failed", i18n.Args{"message": err.Error()}))
} }
fmt.Printf("✓ Logged in as %s\n", result.Login) _, err = fmt.Fprintln(out, tr.Tf("success.auth.logged_in_as", i18n.Args{"login": result.Login}))
return nil return err
} }
func loginWithToken() error { func readPassword(in io.Reader, reader *bufio.Reader) ([]byte, error) {
reader := bufio.NewReader(os.Stdin) if file, ok := in.(*os.File); ok {
fmt.Print("Paste your token: ") fd := int(file.Fd())
if term.IsTerminal(fd) {
return term.ReadPassword(fd)
}
}
password, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return nil, err
}
return []byte(strings.TrimRight(password, "\r\n")), nil
}
func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error {
reader := bufio.NewReader(in)
if _, err := fmt.Fprint(out, tr.T("prompt.auth.token")); err != nil {
return err
}
token, _ := reader.ReadString('\n') token, _ := reader.ReadString('\n')
token = strings.TrimSpace(token) token = strings.TrimSpace(token)
if token == "" { if token == "" {
return fmt.Errorf("token cannot be empty") return errors.New(tr.T("error.auth.token_empty"))
} }
if err := internalAuth.StoreToken(token); err != nil { if err := storeToken(token); err != nil {
return fmt.Errorf("failed to store token: %w", err) return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()}))
} }
fmt.Println("✓ Token saved") _, err := fmt.Fprintln(out, tr.T("success.auth.token_saved"))
return nil return err
} }
func newLogoutCmd() *cobra.Command { func newLogoutCmd(tr *i18n.Translator) *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "logout", Use: "logout",
Short: "Logout from GitLink", Short: tr.T("cmd.auth.logout.short"),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if err := internalAuth.DeleteToken(); err != nil { if err := internalAuth.DeleteToken(); err != nil {
return fmt.Errorf("failed to delete token: %w", err) return errors.New(tr.Tf("error.auth.delete_token_failed", i18n.Args{"message": err.Error()}))
} }
fmt.Println("✓ Logged out") _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out"))
return nil return err
}, },
} }
} }
func newStatusCmd() *cobra.Command { func newStatusCmd(tr *i18n.Translator) *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "status", Use: "status",
Short: "Show authentication status", Short: tr.T("cmd.auth.status.short"),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
out := cmd.OutOrStdout()
// Check env var token first // Check env var token first
if envToken := os.Getenv(envTokenVar); envToken != "" { if envToken := os.Getenv(envTokenVar); envToken != "" {
fmt.Printf("✓ Logged in via %s environment variable\n", envTokenVar) if _, err := fmt.Fprintln(out, tr.Tf("success.auth.logged_in_via_env", i18n.Args{"env": envTokenVar})); err != nil {
return err
}
} }
token, err := internalAuth.LoadToken() token, err := loadToken()
if err != nil || token == "" { if err != nil || token == "" {
if os.Getenv(envTokenVar) == "" { if os.Getenv(envTokenVar) == "" {
fmt.Println("✗ Not logged in") if _, err := fmt.Fprintln(out, tr.T("warning.auth.not_logged_in")); err != nil {
fmt.Println(" Run: gitlink-cli auth login") return err
fmt.Printf(" Or set %s environment variable\n", envTokenVar) }
if _, err := fmt.Fprintln(out, tr.T("output.auth.login_hint")); err != nil {
return err
}
if _, err := fmt.Fprintln(out, tr.Tf("output.auth.env_hint", i18n.Args{"env": envTokenVar})); err != nil {
return err
}
} }
return nil return nil
} }
user, err := internalAuth.GetCurrentUser() user, err := internalAuth.GetCurrentUser()
if err != nil { if err != nil {
fmt.Printf("✓ Token stored (but cannot verify: %v)\n", err) _, err := fmt.Fprintln(out, tr.Tf("warning.auth.token_unverified", i18n.Args{"message": err.Error()}))
return nil return err
} }
login, _ := user["login"].(string) login, _ := user["login"].(string)
name, _ := user["name"].(string) name, _ := user["name"].(string)
if login != "" { if login != "" {
fmt.Printf("✓ Logged in as %s", login) text := tr.Tf("success.auth.logged_in_as", i18n.Args{"login": login})
if name != "" { if name != "" {
fmt.Printf(" (%s)", name) text = fmt.Sprintf("%s (%s)", text, name)
} }
fmt.Println() _, err := fmt.Fprintln(out, text)
} else { return err
fmt.Println("✓ Token stored (user info unavailable)")
} }
return nil _, err = fmt.Fprintln(out, tr.T("warning.auth.user_unavailable"))
return err
}, },
} }
} }

243
cmd/auth/auth_test.go Normal file
View File

@ -0,0 +1,243 @@
package auth
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/spf13/cobra"
"github.com/zalando/go-keyring"
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
)
func tempConfigDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
return dir
}
func TestEnvTokenVar(t *testing.T) {
if envTokenVar != "GITLINK_TOKEN" {
t.Fatalf("envTokenVar = %q, want GITLINK_TOKEN", envTokenVar)
}
}
func TestNewAuthCmd(t *testing.T) {
cmd := NewAuthCmd()
if cmd.Use != "auth" {
t.Fatalf("Use = %q, want auth", cmd.Use)
}
if cmd.Short == "" {
t.Fatal("Short is empty")
}
expectedSubs := map[string]bool{
"login": false, "logout": false, "status": false,
}
for _, sub := range cmd.Commands() {
if _, ok := expectedSubs[sub.Use]; !ok {
t.Fatalf("unexpected subcommand: %q", sub.Use)
}
if expectedSubs[sub.Use] {
t.Fatalf("duplicate subcommand: %q", sub.Use)
}
expectedSubs[sub.Use] = true
if sub.Short == "" {
t.Fatalf("subcommand %q has empty Short", sub.Use)
}
}
for name, found := range expectedSubs {
if !found {
t.Fatalf("missing subcommand: %q", name)
}
}
}
func TestLoginTokenFlag(t *testing.T) {
cmd := NewAuthCmd()
loginCmd := findSub(cmd, "login")
if loginCmd == nil {
t.Fatal("login subcommand not found")
}
if f := loginCmd.Flags().Lookup("token"); f == nil {
t.Fatal("login command missing --token flag")
}
}
func TestStatusCmdNotLoggedIn(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
_ = internalAuth.DeleteToken()
cmd := findSub(NewAuthCmd(), "status")
if cmd == nil {
t.Fatal("status subcommand not found")
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("status error: %v", err)
}
}
func TestStatusCmdEnvToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "env-token-123")
_ = internalAuth.DeleteToken()
cmd := findSub(NewAuthCmd(), "status")
cmd.RunE(cmd, nil)
}
func TestStatusCmdStoredToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
os.MkdirAll(dir, 0700)
os.WriteFile(filepath.Join(dir, "credentials"), []byte("cookie:test=abc"), 0600)
cmd := findSub(NewAuthCmd(), "status")
cmd.RunE(cmd, nil)
}
func TestStatusCmdEnvAndStoredToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "env-token")
os.MkdirAll(dir, 0700)
os.WriteFile(filepath.Join(dir, "credentials"), []byte("stored-token"), 0600)
cmd := findSub(NewAuthCmd(), "status")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("status error: %v", err)
}
}
func TestStatusCmdStoredTokenButLoadFails(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
// Do not create credentials; LoadToken should return empty.
cmd := findSub(NewAuthCmd(), "status")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("status error: %v", err)
}
}
func TestLogoutCmdNoStoredToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
// Do not create credentials; logout should be idempotent.
cmd := findSub(NewAuthCmd(), "logout")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("logout should succeed without stored token: %v", err)
}
}
func TestLogoutCmd(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
// Store a token first so DeleteToken has something to delete
os.MkdirAll(dir, 0700)
os.WriteFile(filepath.Join(dir, "credentials"), []byte("some-token"), 0600)
cmd := findSub(NewAuthCmd(), "logout")
if cmd == nil {
t.Fatal("logout subcommand not found")
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("logout error: %v", err)
}
}
func TestLoginWithToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
// Mock stdin
oldStdin := os.Stdin
r, w, _ := os.Pipe()
os.Stdin = r
defer func() { os.Stdin = oldStdin }()
go func() {
w.Write([]byte("test-token-123\n"))
w.Close()
}()
cmd := findSub(NewAuthCmd(), "login")
if cmd == nil {
t.Fatal("login subcommand not found")
}
cmd.Flags().Set("token", "true")
err := cmd.RunE(cmd, nil)
if err != nil {
t.Fatalf("login --token error: %v", err)
}
// Verify token was saved to file
data, err := os.ReadFile(filepath.Join(dir, "credentials"))
if err != nil {
t.Fatalf("read credentials: %v", err)
}
if string(data) != "test-token-123" {
t.Fatalf("token = %q, want test-token-123", string(data))
}
}
func TestLoginWithTokenEmpty(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
oldStdin := os.Stdin
r, w, _ := os.Pipe()
os.Stdin = r
defer func() { os.Stdin = oldStdin }()
go func() {
w.Write([]byte("\n"))
w.Close()
}()
cmd := findSub(NewAuthCmd(), "login")
cmd.Flags().Set("token", "true")
err := cmd.RunE(cmd, nil)
if err == nil {
t.Fatal("expected error for empty token")
}
}
func TestLoginWithPasswordNoTerminal(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
// term.ReadPassword will fail because test has no terminal
cmd := findSub(NewAuthCmd(), "login")
// Don't set --token, so it goes to loginWithPassword
err := cmd.RunE(cmd, nil)
if err == nil {
t.Fatal("expected error when terminal unavailable (ReadPassword fails)")
}
}
func findSub(cmd *cobra.Command, name string) *cobra.Command {
for _, sub := range cmd.Commands() {
if sub.Use == name {
return sub
}
}
return nil
}

68
cmd/cmd_test.go Normal file
View File

@ -0,0 +1,68 @@
package cmd
import (
"bytes"
"strings"
"testing"
)
func TestNewRootCmdDefaults(t *testing.T) {
root, err := NewRootCmd(RootOptions{Version: "test"}, nil)
if err != nil {
t.Fatal(err)
}
if root.Use != "gitlink-cli" {
t.Fatalf("Use = %q", root.Use)
}
if !root.SilenceUsage {
t.Fatal("expected SilenceUsage=true")
}
}
func TestRootHelp(t *testing.T) {
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("help command error: %v", err)
}
}
func TestVersionCmd(t *testing.T) {
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"version"}}, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("version command error: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "gitlink-cli test" {
t.Fatalf("version output = %q", got)
}
}
func TestRootCmdHasSubcommands(t *testing.T) {
root, err := NewRootCmd(RootOptions{Version: "test"}, nil)
if err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, sub := range root.Commands() {
names[sub.Use] = true
}
for _, want := range []string{"auth", "config", "doctor", "version"} {
if !names[want] {
t.Fatalf("missing subcommand: %s", want)
}
}
if len(root.Commands()) < 4 {
t.Fatalf("expected at least 4 subcommands, got %d", len(root.Commands()))
}
}

View File

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

View File

@ -1,59 +1,80 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
) )
func NewConfigCmd() *cobra.Command { func NewConfigCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "config", Use: "config",
Short: "Manage gitlink-cli configuration", Short: tr.T("cmd.config.short"),
} }
cmd.AddCommand(newInitCmd()) cmd.AddCommand(newInitCmd(tr))
cmd.AddCommand(newSetCmd()) cmd.AddCommand(newSetCmd(tr))
cmd.AddCommand(newGetCmd()) cmd.AddCommand(newGetCmd(tr))
cmd.AddCommand(newListCmd()) cmd.AddCommand(newListCmd(tr))
return cmd return cmd
} }
func newInitCmd() *cobra.Command { func newInitCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
return &cobra.Command{ return &cobra.Command{
Use: "init", Use: "init",
Short: "Initialize configuration file", Short: tr.T("cmd.config.init.short"),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg := internalConfig.DefaultConfig() cfg := internalConfig.DefaultConfig()
if err := internalConfig.Save(cfg); err != nil { if err := internalConfig.Save(cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err) return errors.New(tr.Tf("error.config.save_failed", i18n.Args{"message": err.Error()}))
} }
fmt.Printf("✓ Config initialized at %s\n", internalConfig.ConfigPath()) _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.initialized", i18n.Args{"path": internalConfig.ConfigPath()}))
return nil return err
}, },
} }
} }
func newSetCmd() *cobra.Command { func newSetCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
return &cobra.Command{ return &cobra.Command{
Use: "set <key> <value>", Use: "set <key> <value>",
Short: "Set a configuration value", Short: tr.T("cmd.config.set.short"),
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if err := internalConfig.Set(args[0], args[1]); err != nil { if err := internalConfig.Set(args[0], args[1]); err != nil {
return err return err
} }
fmt.Printf("✓ %s = %s\n", args[0], args[1]) _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.set", i18n.Args{
return nil "key": args[0],
"value": args[1],
}))
return err
}, },
} }
} }
func newGetCmd() *cobra.Command { func newGetCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
return &cobra.Command{ return &cobra.Command{
Use: "get <key>", Use: "get <key>",
Short: "Get a configuration value", Short: tr.T("cmd.config.get.short"),
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
val, err := internalConfig.Get(args[0]) val, err := internalConfig.Get(args[0])
@ -61,30 +82,49 @@ func newGetCmd() *cobra.Command {
return err return err
} }
if val == "" { if val == "" {
fmt.Printf("%s: (not set)\n", args[0]) _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], tr.T("output.config.not_set"))
} else { return err
fmt.Printf("%s: %s\n", args[0], val)
} }
return nil _, err = fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], val)
return err
}, },
} }
} }
func newListCmd() *cobra.Command { func newListCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
return &cobra.Command{ return &cobra.Command{
Use: "list", Use: "list",
Short: "List all configuration values", Short: tr.T("cmd.config.list.short"),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := internalConfig.Load() cfg, err := internalConfig.Load()
if err != nil { if err != nil {
return err return err
} }
fmt.Printf("base_url: %s\n", cfg.BaseURL) out := cmd.OutOrStdout()
fmt.Printf("default_format: %s\n", cfg.Format) if _, err := fmt.Fprintf(out, "base_url: %s\n", cfg.BaseURL); err != nil {
fmt.Printf("editor: %s\n", cfg.Editor) return err
fmt.Printf("pager: %s\n", cfg.Pager) }
fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath()) if _, err := fmt.Fprintf(out, "default_format: %s\n", cfg.Format); err != nil {
return nil return err
}
if _, err := fmt.Fprintf(out, "editor: %s\n", cfg.Editor); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "pager: %s\n", cfg.Pager); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "lang: %s\n", cfg.Lang); err != nil {
return err
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
_, err = fmt.Fprintln(out, tr.Tf("output.config.file", i18n.Args{"path": internalConfig.ConfigPath()}))
return err
}, },
} }
} }

217
cmd/config/config_test.go Normal file
View File

@ -0,0 +1,217 @@
package config
import (
"os"
"testing"
"github.com/spf13/cobra"
)
func TestNewConfigCmd(t *testing.T) {
cmd := NewConfigCmd()
if cmd.Use != "config" {
t.Fatalf("Use = %q, want config", cmd.Use)
}
if cmd.Short == "" {
t.Fatal("Short is empty")
}
expectedSubs := map[string]bool{
"init": false, "set <key> <value>": false, "get <key>": false, "list": false,
}
for _, sub := range cmd.Commands() {
if _, ok := expectedSubs[sub.Use]; !ok {
t.Fatalf("unexpected subcommand: %q", sub.Use)
}
if expectedSubs[sub.Use] {
t.Fatalf("duplicate subcommand: %q", sub.Use)
}
expectedSubs[sub.Use] = true
if sub.Short == "" {
t.Fatalf("subcommand %q has empty Short", sub.Use)
}
}
for name, found := range expectedSubs {
if !found {
t.Fatalf("missing subcommand: %q", name)
}
}
}
func TestSetCmdArgs(t *testing.T) {
cmd := findSub(NewConfigCmd(), "set <key> <value>")
if cmd == nil {
t.Fatal("set subcommand not found")
}
if cmd.Args == nil {
t.Fatal("set should require exact args")
}
}
func TestGetCmdArgs(t *testing.T) {
cmd := findSub(NewConfigCmd(), "get <key>")
if cmd == nil {
t.Fatal("get subcommand not found")
}
if cmd.Args == nil {
t.Fatal("get should require exact args")
}
}
func TestConfigInitRun(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
cmd := findSub(NewConfigCmd(), "init")
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("init error: %v", err)
}
}
func TestConfigSetAndGet(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
// Init first
initCmd := findSub(NewConfigCmd(), "init")
initCmd.SetArgs([]string{})
if err := initCmd.Execute(); err != nil {
t.Fatalf("init error: %v", err)
}
// Set a value
setCmd := findSub(NewConfigCmd(), "set <key> <value>")
setCmd.SetArgs([]string{"base_url", "https://example.com"})
if err := setCmd.Execute(); err != nil {
t.Fatalf("set error: %v", err)
}
// Get it back
getCmd := findSub(NewConfigCmd(), "get <key>")
getCmd.SetArgs([]string{"base_url"})
if err := getCmd.Execute(); err != nil {
t.Fatalf("get error: %v", err)
}
}
func TestConfigGetNotSet(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
os.WriteFile(dir+"/config.yaml", []byte("base_url: https://example.com\n"), 0644)
getCmd := findSub(NewConfigCmd(), "get <key>")
getCmd.SetArgs([]string{"editor"})
if err := getCmd.Execute(); err != nil {
t.Fatalf("get not-set error: %v", err)
}
}
func TestConfigList(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
initCmd := findSub(NewConfigCmd(), "init")
initCmd.SetArgs([]string{})
if err := initCmd.Execute(); err != nil {
t.Fatalf("init error: %v", err)
}
listCmd := findSub(NewConfigCmd(), "list")
listCmd.SetArgs([]string{})
if err := listCmd.Execute(); err != nil {
t.Fatalf("list error: %v", err)
}
}
func TestConfigInitRunE(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
cmd := newInitCmd()
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("init RunE error: %v", err)
}
}
func TestConfigSetRunE(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
// Init first so config file exists
initCmd := newInitCmd()
if err := initCmd.RunE(initCmd, nil); err != nil {
t.Fatalf("init error: %v", err)
}
cmd := newSetCmd()
if err := cmd.RunE(cmd, []string{"base_url", "https://example.com"}); err != nil {
t.Fatalf("set RunE error: %v", err)
}
}
func TestConfigSetRunENoConfig(t *testing.T) {
// Set without init should still work — Load returns defaults, Save creates dir
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
cmd := newSetCmd()
if err := cmd.RunE(cmd, []string{"base_url", "https://example.com"}); err != nil {
t.Fatalf("set RunE error: %v", err)
}
}
func TestConfigGetRunE(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
initCmd := newInitCmd()
initCmd.RunE(initCmd, nil)
cmd := newGetCmd()
if err := cmd.RunE(cmd, []string{"base_url"}); err != nil {
t.Fatalf("get RunE error: %v", err)
}
}
func TestConfigGetRunENotSet(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
os.WriteFile(dir+"/config.yaml", []byte("base_url: https://example.com\n"), 0644)
cmd := newGetCmd()
if err := cmd.RunE(cmd, []string{"editor"}); err != nil {
t.Fatalf("get RunE not-set error: %v", err)
}
}
func TestConfigListRunE(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
initCmd := newInitCmd()
initCmd.RunE(initCmd, nil)
cmd := newListCmd()
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("list RunE error: %v", err)
}
}
func TestConfigListRunENoConfig(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
// Don't init — Load returns defaults for missing file, so this should work
cmd := newListCmd()
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("list RunE error: %v", err)
}
}
func findSub(cmd *cobra.Command, name string) *cobra.Command {
for _, sub := range cmd.Commands() {
if sub.Use == name {
return sub
}
}
return nil
}

319
cmd/doctor/doctor.go Normal file
View File

@ -0,0 +1,319 @@
package doctor
import (
"fmt"
"net/url"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
repoContext "github.com/gitlink-org/gitlink-cli/internal/context"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
const (
statusOK = "ok"
statusWarning = "warning"
statusError = "error"
statusSkipped = "skipped"
)
var (
loadConfig = internalConfig.Load
loadToken = internalAuth.LoadToken
getCurrentUser = internalAuth.GetCurrentUser
resolveOwnerRepo = repoContext.ResolveOwnerRepo
statFile = os.Stat
lookupEnv = os.LookupEnv
)
type Report struct {
OK bool `json:"ok"`
Summary Summary `json:"summary"`
Checks []Check `json:"checks"`
Actions []string `json:"actions,omitempty"`
}
type Summary struct {
OK int `json:"ok"`
Warning int `json:"warning"`
Error int `json:"error"`
Skipped int `json:"skipped"`
Total int `json:"total"`
}
type Check struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Suggestion string `json:"suggestion,omitempty"`
Details map[string]interface{} `json:"details,omitempty"`
}
func NewDoctorCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
var skipNetwork bool
cmd := &cobra.Command{
Use: "doctor",
Short: tr.T("cmd.doctor.short"),
Long: tr.T("cmd.doctor.long"),
RunE: func(cmd *cobra.Command, args []string) error {
report := Run(skipNetwork, tr)
return output.PrintTo(cmd.OutOrStdout(), output.SuccessEnvelope(report, nil), resolveFormat())
},
}
cmd.Flags().BoolVar(&skipNetwork, "skip-network", false, tr.T("flag.doctor.skip_network"))
return cmd
}
func Run(skipNetwork bool, tr *i18n.Translator) Report {
if tr == nil {
tr = i18n.Default()
}
checks := make([]Check, 0, 5)
cfg, cfgErr := loadConfig()
checks = append(checks, checkConfigFile(tr, cfgErr))
checks = append(checks, checkConfigValues(tr, cfg, cfgErr))
checks = append(checks, checkAuthToken(tr))
checks = append(checks, checkRepoContext(tr))
checks = append(checks, checkAuthenticatedUser(tr, skipNetwork, cfgErr))
report := Report{OK: true, Checks: checks}
seenActions := map[string]bool{}
for _, check := range checks {
report.Summary.Total++
switch check.Status {
case statusOK:
report.Summary.OK++
case statusWarning:
report.Summary.Warning++
case statusError:
report.OK = false
report.Summary.Error++
case statusSkipped:
report.Summary.Skipped++
}
if check.Suggestion != "" && !seenActions[check.Suggestion] {
report.Actions = append(report.Actions, check.Suggestion)
seenActions[check.Suggestion] = true
}
}
return report
}
func checkConfigFile(tr *i18n.Translator, cfgErr error) Check {
path := internalConfig.ConfigPath()
info, err := statFile(path)
if err != nil {
if os.IsNotExist(err) {
return Check{
Name: "config_file",
Status: statusWarning,
Message: tr.T("output.doctor.config_file.missing"),
Suggestion: "gitlink-cli config init",
Details: map[string]interface{}{"path": path},
}
}
return Check{
Name: "config_file",
Status: statusError,
Message: tr.Tf("output.doctor.config_file.unreadable", i18n.Args{"message": err.Error()}),
Suggestion: tr.T("output.doctor.suggestion.check_config_permissions"),
Details: map[string]interface{}{"path": path},
}
}
if cfgErr != nil {
return Check{
Name: "config_file",
Status: statusError,
Message: tr.Tf("output.doctor.config_file.invalid", i18n.Args{"message": cfgErr.Error()}),
Suggestion: tr.T("output.doctor.suggestion.fix_config_yaml"),
Details: map[string]interface{}{"path": path},
}
}
return Check{
Name: "config_file",
Status: statusOK,
Message: tr.T("output.doctor.config_file.ok"),
Details: map[string]interface{}{
"path": path,
"size": info.Size(),
},
}
}
func checkConfigValues(tr *i18n.Translator, cfg *internalConfig.Config, cfgErr error) Check {
if cfgErr != nil || cfg == nil {
return Check{
Name: "config_values",
Status: statusSkipped,
Message: tr.T("output.doctor.config_values.skipped"),
Suggestion: tr.T("output.doctor.suggestion.fix_config_yaml"),
}
}
details := map[string]interface{}{
"base_url": cfg.BaseURL,
"default_format": cfg.Format,
}
if err := validateBaseURL(cfg.BaseURL); err != nil {
return Check{
Name: "config_values",
Status: statusError,
Message: tr.Tf("output.doctor.config_values.bad_base_url", i18n.Args{"message": err.Error()}),
Suggestion: "gitlink-cli config set base_url https://www.gitlink.org.cn/api",
Details: details,
}
}
if !validFormat(cfg.Format) {
return Check{
Name: "config_values",
Status: statusWarning,
Message: tr.Tf("output.doctor.config_values.bad_format", i18n.Args{"format": cfg.Format}),
Suggestion: "gitlink-cli config set default_format table",
Details: details,
}
}
return Check{
Name: "config_values",
Status: statusOK,
Message: tr.T("output.doctor.config_values.ok"),
Details: details,
}
}
func checkAuthToken(tr *i18n.Translator) Check {
if token, ok := lookupEnv("GITLINK_TOKEN"); ok && strings.TrimSpace(token) != "" {
return Check{
Name: "auth_token",
Status: statusOK,
Message: tr.T("output.doctor.auth_token.env"),
Details: map[string]interface{}{"source": "env"},
}
}
token, err := loadToken()
if err != nil || strings.TrimSpace(token) == "" {
return Check{
Name: "auth_token",
Status: statusWarning,
Message: tr.T("output.doctor.auth_token.missing"),
Suggestion: "gitlink-cli auth login",
}
}
source := "token"
if strings.HasPrefix(token, "cookie:") {
source = "cookie"
}
return Check{
Name: "auth_token",
Status: statusOK,
Message: tr.T("output.doctor.auth_token.stored"),
Details: map[string]interface{}{"source": source},
}
}
func checkRepoContext(tr *i18n.Translator) Check {
owner, repo, err := resolveOwnerRepo(cmdutil.Owner, cmdutil.Repo)
if err != nil {
return Check{
Name: "repo_context",
Status: statusWarning,
Message: tr.Tf("output.doctor.repo_context.missing", i18n.Args{"message": err.Error()}),
Suggestion: tr.T("output.doctor.suggestion.pass_owner_repo"),
}
}
return Check{
Name: "repo_context",
Status: statusOK,
Message: tr.Tf("output.doctor.repo_context.ok", i18n.Args{"owner": owner, "repo": repo}),
Details: map[string]interface{}{
"owner": owner,
"repo": repo,
},
}
}
func checkAuthenticatedUser(tr *i18n.Translator, skipNetwork bool, cfgErr error) Check {
if skipNetwork {
return Check{
Name: "api_auth",
Status: statusSkipped,
Message: tr.T("output.doctor.api_auth.skipped"),
}
}
if cfgErr != nil {
return Check{
Name: "api_auth",
Status: statusSkipped,
Message: tr.T("output.doctor.api_auth.config_skipped"),
Suggestion: tr.T("output.doctor.suggestion.fix_config_yaml"),
}
}
user, err := getCurrentUser()
if err != nil {
return Check{
Name: "api_auth",
Status: statusError,
Message: tr.Tf("output.doctor.api_auth.failed", i18n.Args{"message": err.Error()}),
Suggestion: "gitlink-cli auth login",
}
}
login, _ := user["login"].(string)
if login == "" {
return Check{
Name: "api_auth",
Status: statusWarning,
Message: tr.T("output.doctor.api_auth.no_login"),
Suggestion: tr.T("output.doctor.suggestion.check_token"),
}
}
return Check{
Name: "api_auth",
Status: statusOK,
Message: tr.Tf("output.doctor.api_auth.ok", i18n.Args{"login": login}),
Details: map[string]interface{}{
"login": login,
},
}
}
func validateBaseURL(value string) error {
u, err := url.Parse(value)
if err != nil {
return err
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("scheme must be http or https")
}
if u.Host == "" {
return fmt.Errorf("host is required")
}
return nil
}
func validFormat(value string) bool {
switch value {
case "json", "table", "yaml":
return true
default:
return false
}
}
func resolveFormat() string {
if cmdutil.Format != "" {
return cmdutil.Format
}
return "json"
}

200
cmd/doctor/doctor_test.go Normal file
View File

@ -0,0 +1,200 @@
package doctor
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
)
func TestDoctorSkipNetworkReportsLocalChecks(t *testing.T) {
withDoctorTestState(t)
writeConfig(t, "base_url: https://www.gitlink.org.cn/api\ndefault_format: json\n")
t.Setenv("GITLINK_TOKEN", "secret-token")
resolveOwnerRepo = func(owner, repo string) (string, string, error) {
return "Gitlink", "gitlink-cli", nil
}
report := Run(true, i18n.Default())
if !report.OK {
t.Fatalf("expected report OK, got %+v", report)
}
assertCheck(t, report, "config_file", statusOK)
assertCheck(t, report, "config_values", statusOK)
assertCheck(t, report, "auth_token", statusOK)
assertCheck(t, report, "repo_context", statusOK)
assertCheck(t, report, "api_auth", statusSkipped)
if report.Summary.Total != 5 {
t.Fatalf("summary total = %d, want 5", report.Summary.Total)
}
}
func TestDoctorInvalidConfigDoesNotPanic(t *testing.T) {
withDoctorTestState(t)
writeConfig(t, "base_url: [broken\n")
resolveOwnerRepo = func(owner, repo string) (string, string, error) {
return "Gitlink", "gitlink-cli", nil
}
report := Run(true, i18n.Default())
if report.OK {
t.Fatalf("expected report not OK, got %+v", report)
}
assertCheck(t, report, "config_file", statusError)
assertCheck(t, report, "config_values", statusSkipped)
}
func TestDoctorInvalidBaseURL(t *testing.T) {
withDoctorTestState(t)
writeConfig(t, "base_url: gitlink.local/api\ndefault_format: table\n")
resolveOwnerRepo = func(owner, repo string) (string, string, error) {
return "Gitlink", "gitlink-cli", nil
}
report := Run(true, i18n.Default())
if report.OK {
t.Fatalf("expected invalid base_url to mark report not OK")
}
check := assertCheck(t, report, "config_values", statusError)
if check.Suggestion == "" {
t.Fatalf("expected config_values suggestion")
}
}
func TestDoctorMissingRepoContextIsWarning(t *testing.T) {
withDoctorTestState(t)
writeConfig(t, "base_url: https://www.gitlink.org.cn/api\ndefault_format: table\n")
resolveOwnerRepo = func(owner, repo string) (string, string, error) {
return "", "", errors.New("no origin remote")
}
report := Run(true, i18n.Default())
assertCheck(t, report, "auth_token", statusWarning)
check := assertCheck(t, report, "repo_context", statusWarning)
if check.Suggestion == "" {
t.Fatalf("expected repo_context suggestion")
}
if !report.OK {
t.Fatalf("warnings should not make report fail: %+v", report)
}
}
func TestDoctorNetworkCheckCanSucceed(t *testing.T) {
withDoctorTestState(t)
writeConfig(t, "base_url: https://www.gitlink.org.cn/api\ndefault_format: table\n")
resolveOwnerRepo = func(owner, repo string) (string, string, error) {
return "Gitlink", "gitlink-cli", nil
}
getCurrentUser = func() (map[string]interface{}, error) {
return map[string]interface{}{"login": "Mengz"}, nil
}
report := Run(false, i18n.Default())
assertCheck(t, report, "api_auth", statusOK)
if !report.OK {
t.Fatalf("expected report OK, got %+v", report)
}
}
func TestDoctorCommandPrintsJSONEnvelope(t *testing.T) {
withDoctorTestState(t)
writeConfig(t, "base_url: https://www.gitlink.org.cn/api\ndefault_format: json\n")
resolveOwnerRepo = func(owner, repo string) (string, string, error) {
return "Gitlink", "gitlink-cli", nil
}
cmd := NewDoctorCmd(i18n.Default())
cmd.SetArgs([]string{"--skip-network"})
var out bytes.Buffer
cmd.SetOut(&out)
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var env struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
}
if !env.OK || len(env.Data) == 0 {
t.Fatalf("unexpected envelope: %+v", env)
}
}
func withDoctorTestState(t *testing.T) {
t.Helper()
oldLoadConfig := loadConfig
oldLoadToken := loadToken
oldGetCurrentUser := getCurrentUser
oldResolveOwnerRepo := resolveOwnerRepo
oldStatFile := statFile
oldLookupEnv := lookupEnv
oldFormat := cmdutil.Format
oldOwner := cmdutil.Owner
oldRepo := cmdutil.Repo
t.Setenv("GITLINK_CONFIG_DIR", t.TempDir())
t.Setenv("GITLINK_TOKEN", "")
cmdutil.Format = "json"
cmdutil.Owner = ""
cmdutil.Repo = ""
loadConfig = oldLoadConfig
loadToken = func() (string, error) { return "", os.ErrNotExist }
getCurrentUser = func() (map[string]interface{}, error) {
return nil, errors.New("unexpected network call")
}
resolveOwnerRepo = oldResolveOwnerRepo
statFile = oldStatFile
lookupEnv = func(key string) (string, bool) {
if key == "GITLINK_TOKEN" {
value := os.Getenv(key)
return value, value != ""
}
return os.LookupEnv(key)
}
t.Cleanup(func() {
loadConfig = oldLoadConfig
loadToken = oldLoadToken
getCurrentUser = oldGetCurrentUser
resolveOwnerRepo = oldResolveOwnerRepo
statFile = oldStatFile
lookupEnv = oldLookupEnv
cmdutil.Format = oldFormat
cmdutil.Owner = oldOwner
cmdutil.Repo = oldRepo
})
}
func writeConfig(t *testing.T, content string) {
t.Helper()
dir := os.Getenv("GITLINK_CONFIG_DIR")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(content), 0600); err != nil {
t.Fatal(err)
}
}
func assertCheck(t *testing.T, report Report, name, status string) Check {
t.Helper()
for _, check := range report.Checks {
if check.Name == name {
if check.Status != status {
t.Fatalf("%s status = %s, want %s; check=%+v", name, check.Status, status, check)
}
return check
}
}
t.Fatalf("missing check %q in %+v", name, report.Checks)
return Check{}
}

View File

@ -1,54 +1,129 @@
package cmd package cmd
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api" apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config" configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
doctorCmd "github.com/gitlink-org/gitlink-cli/cmd/doctor"
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts" "github.com/gitlink-org/gitlink-cli/shortcuts"
) )
var Version = "dev" var Version = "dev"
var rootCmd = &cobra.Command{ type RootOptions struct {
Use: "gitlink-cli", Version string
Short: "GitLink CLI — command-line tool for gitlink.org.cn", Args []string
Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`, Env map[string]string
SilenceUsage: true, ConfigLang string
SilenceErrors: true,
} }
func init() { func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)") if tr == nil {
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)") var err error
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)") tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang)
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output") if err != nil {
return nil, err
}
}
rootCmd.AddCommand(authCmd.NewAuthCmd()) version := opts.Version
rootCmd.AddCommand(apiCmd.NewAPICmd()) if version == "" {
rootCmd.AddCommand(configCmd.NewConfigCmd()) version = Version
rootCmd.AddCommand(versionCmd) }
shortcuts.RegisterAll(rootCmd) rootCmd := &cobra.Command{
Use: "gitlink-cli",
Short: tr.T("cmd.root.short"),
Long: tr.T("cmd.root.long"),
SilenceUsage: true,
SilenceErrors: true,
}
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", tr.T("flag.owner"))
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", tr.T("flag.repo"))
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", tr.T("flag.format"))
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, tr.T("flag.debug"))
rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang"))
rootCmd.AddCommand(authCmd.NewAuthCmd(tr))
rootCmd.AddCommand(apiCmd.NewAPICmd(tr))
rootCmd.AddCommand(configCmd.NewConfigCmd(tr))
rootCmd.AddCommand(doctorCmd.NewDoctorCmd(tr))
rootCmd.AddCommand(newVersionCmd(version, tr))
shortcuts.RegisterAll(rootCmd, tr)
if opts.Args != nil {
rootCmd.SetArgs(opts.Args)
}
return rootCmd, nil
} }
var versionCmd = &cobra.Command{ func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command {
Use: "version", return &cobra.Command{
Short: "Print version information", Use: "version",
Run: func(cmd *cobra.Command, args []string) { Short: tr.T("cmd.version.short"),
fmt.Printf("gitlink-cli %s\n", Version) RunE: func(cmd *cobra.Command, args []string) error {
}, _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version}))
return err
},
}
} }
func Execute() error { func Execute() error {
args := os.Args[1:]
rootCmd, err := NewRootCmd(RootOptions{
Version: Version,
Args: args,
}, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return err
}
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
return err return err
} }
return nil return nil
} }
func newTranslator(args []string, env map[string]string, configLang string) (*i18n.Translator, error) {
available, err := i18n.AvailableLocales()
if err != nil {
return nil, err
}
if env == nil {
env = i18n.EnvMap()
}
if configLang == "" {
configLang = loadConfigLangBestEffort()
}
resolved := i18n.ResolveLocaleDetailed(i18n.ResolveOptions{
ExplicitLang: i18n.PreScanLang(args),
Env: env,
ConfigLang: configLang,
}, available)
if !resolved.Supported && (resolved.Source == "flag" || resolved.Source == "env") {
tr := i18n.Default()
return nil, errors.New(tr.Tf("error.unsupported_language", i18n.Args{"lang": resolved.Requested}))
}
return i18n.New(i18n.Options{Locale: resolved.Locale})
}
func loadConfigLangBestEffort() string {
cfg, err := internalConfig.Load()
if err != nil {
return ""
}
return cfg.Lang
}

310
cmd/root_test.go Normal file
View File

@ -0,0 +1,310 @@
package cmd
import (
"bytes"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
)
func TestRootHelpUsesSelectedLocale(t *testing.T) {
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
if err != nil {
t.Fatal(err)
}
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, tr)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
help := out.String()
if !strings.Contains(help, "用于管理 GitLink 上的仓库") {
t.Fatalf("expected Chinese root long help, got:\n%s", help)
}
if !strings.Contains(help, "仓库操作") {
t.Fatalf("expected Chinese shortcut group help, got:\n%s", help)
}
if !strings.Contains(help, "认证命令") || !strings.Contains(help, "管理 gitlink-cli 配置") {
t.Fatalf("expected Chinese core command help, got:\n%s", help)
}
if !strings.Contains(help, "--lang") || !strings.Contains(help, "显示语言") {
t.Fatalf("expected localized lang flag help, got:\n%s", help)
}
}
func TestRootHelpUsesExplicitLang(t *testing.T) {
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--lang", "zh-CN", "--help"}, Env: map[string]string{}}, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
help := out.String()
for _, want := range []string{"用于管理 GitLink", "显示语言", "仓库"} {
if !strings.Contains(help, want) {
t.Fatalf("expected %q in help, got:\n%s", want, help)
}
}
}
func TestRootHelpUsesEnvLang(t *testing.T) {
root, err := NewRootCmd(RootOptions{
Version: "test",
Args: []string{"repo", "--help"},
Env: map[string]string{"GITLINK_LANG": "zh-CN"},
}, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
help := out.String()
for _, want := range []string{"仓库操作", "仓库所有者", "仓库名称"} {
if !strings.Contains(help, want) {
t.Fatalf("expected %q in help, got:\n%s", want, help)
}
}
}
func TestExplicitLangOverridesConfigLang(t *testing.T) {
root, err := NewRootCmd(RootOptions{
Version: "test",
Args: []string{"--lang", "en-US", "--help"},
Env: map[string]string{},
ConfigLang: "zh-CN",
}, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
help := out.String()
if !strings.Contains(help, "Repository operations") {
t.Fatalf("expected English help, got:\n%s", help)
}
if strings.Contains(help, "仓库操作") {
t.Fatalf("expected explicit en-US to override config zh-CN, got:\n%s", help)
}
}
func TestUnsupportedExplicitLangReturnsError(t *testing.T) {
_, err := NewRootCmd(RootOptions{
Version: "test",
Args: []string{"--lang", "fr-FR", "--help"},
Env: map[string]string{},
}, nil)
if err == nil {
t.Fatal("expected unsupported language error")
}
if !strings.Contains(err.Error(), "unsupported language") {
t.Fatalf("expected unsupported language error, got %q", err.Error())
}
}
func TestRequireArgUsesLocalizedError(t *testing.T) {
root, err := NewRootCmd(RootOptions{
Version: "test",
Args: []string{"--lang", "zh-CN", "repo", "+create"},
Env: map[string]string{},
}, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
err = root.Execute()
if err == nil {
t.Fatal("expected missing required flag error")
}
if !strings.Contains(err.Error(), "缺少必需参数") {
t.Fatalf("expected localized missing flag error, got %q", err.Error())
}
}
func TestCoreCommandHelpUsesSelectedLocale(t *testing.T) {
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
if err != nil {
t.Fatal(err)
}
cases := []struct {
args []string
want []string
}{
{
args: []string{"api", "--help"},
want: []string{"向 GitLink API 发送任意 HTTP 请求", "--body", "请求体JSON 字符串)"},
},
{
args: []string{"auth", "login", "--help"},
want: []string{"登录 GitLink", "--token", "通过粘贴已有 Token 登录"},
},
{
args: []string{"config", "--help"},
want: []string{"管理 gitlink-cli 配置", "初始化配置文件", "列出所有配置项"},
},
}
for _, tc := range cases {
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("%v: %v", tc.args, err)
}
help := out.String()
for _, want := range tc.want {
if !strings.Contains(help, want) {
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
}
}
}
}
func TestShortcutHelpUsesSelectedLocale(t *testing.T) {
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
if err != nil {
t.Fatal(err)
}
cases := []struct {
args []string
want []string
}{
{
args: []string{"repo", "+create", "--help"},
want: []string{"创建新仓库", "--name", "仓库名称", "--private", "设为私有仓库"},
},
{
args: []string{"pr", "+review", "--help"},
want: []string{"创建拉取请求评审", "--content", "评审内容", "--dry-run"},
},
}
for _, tc := range cases {
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("%v: %v", tc.args, err)
}
help := out.String()
for _, want := range tc.want {
if !strings.Contains(help, want) {
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
}
}
}
}
func TestRemainingShortcutHelpUsesSelectedLocale(t *testing.T) {
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
if err != nil {
t.Fatal(err)
}
cases := []struct {
args []string
want []string
}{
{
args: []string{"branch", "+create", "--help"},
want: []string{"创建分支", "--from", "源分支或 Commit"},
},
{
args: []string{"release", "+create", "--help"},
want: []string{"创建发布", "--prerelease", "标记为预发布"},
},
{
args: []string{"webhook", "+create", "--help"},
want: []string{"创建仓库 Webhook", "--events", "逗号分隔的事件"},
},
{
args: []string{"ci", "+logs", "--help"},
want: []string{"查看构建日志", "--build", "构建编号"},
},
}
for _, tc := range cases {
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("%v: %v", tc.args, err)
}
help := out.String()
for _, want := range tc.want {
if !strings.Contains(help, want) {
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
}
}
}
}
func TestVersionUsesInjectedVersion(t *testing.T) {
tr, err := i18n.New(i18n.Options{Locale: "en-US"})
if err != nil {
t.Fatal(err)
}
root, err := NewRootCmd(RootOptions{Version: "1.2.3", Args: []string{"version"}}, tr)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if got := strings.TrimSpace(out.String()); got != "gitlink-cli 1.2.3" {
t.Fatalf("version output = %q", got)
}
}

View File

@ -0,0 +1,7 @@
# 新增 Raw API 批处理执行器
`gitlink-cli api` 现在支持通过 `--batch-file` 读取 JSON 批处理计划,把多个尚未封装为 shortcut 的 GitLink API 请求组织成一次可审计的自动化执行。计划文件支持 `vars` 模板变量,`--var key=value` 可以在执行时覆盖变量便于同一批处理流程复用到不同仓库、Issue 或分支。
批处理模式提供 `--dry-run` 预览渲染后的 method、path、query 和 body不会访问远端实际执行时会输出每一步的成功/失败、响应数据和汇总计数。默认遇到失败立即停止,传入 `--continue-on-error` 后会继续执行后续请求,适合批量巡检、批量评论、批量元数据修复等场景。
本次变更包含计划文件解析、模板渲染、query/body 递归替换、失败控制、结构化汇总输出、中英文帮助文案、README 示例、Skill reference 和单元测试。测试覆盖 dry-run 不发请求、变量覆盖、模板缺失报错、失败默认中断以及失败继续执行等关键行为。

View File

@ -0,0 +1,7 @@
# 认证凭据 fallback 配置目录一致性修复
`gitlink-cli` 的主配置文件已经支持通过 `GITLINK_CONFIG_DIR` 指定配置目录,但认证模块在系统 Keychain 不可用时仍然把 fallback 凭据写到用户 home 下的 `~/.config/gitlink-cli/credentials`。这会让 CI、Windows 测试、Agent 沙箱和多账号隔离场景出现配置目录与凭据目录不一致的问题,也会导致测试中设置临时 HOME 后仍读写真实用户目录。
本次修复让文件凭据路径统一复用 `internal/config.ConfigDir()`:设置 `GITLINK_CONFIG_DIR`fallback 凭据保存到 `$GITLINK_CONFIG_DIR/credentials`;未设置时仍保持原有默认路径。`auth logout` 在 fallback 文件不存在时也改为幂等成功,避免用户已经没有本地凭据时退出登录反而报错。
测试同步改为使用 `GITLINK_CONFIG_DIR` 隔离凭据目录,覆盖默认配置目录、文件创建、保存/读取/删除、Keychain 不可用 fallback、无凭据登出等场景。该修复提升了跨平台稳定性也让本地全量测试不再因为 Windows `HOME`/`USERPROFILE` 解析差异污染真实用户凭据目录。

View File

@ -0,0 +1,7 @@
# CLI 自诊断命令
新增 `gitlink-cli doctor`用于在用户遇到“无法认证、仓库识别失败、配置异常、API 请求失败”等问题时快速定位原因。命令会一次性检查配置文件是否存在且可解析、`base_url` 和 `default_format` 是否合理、本地 Token 或 `GITLINK_TOKEN` 是否可用、当前目录能否解析出 GitLink 仓库上下文,以及认证 API 是否能正常返回当前用户。
输出沿用项目已有的 `ok/data/error/meta` 结构,诊断结果包含每个检查项的状态、说明、细节和可执行修复建议,便于人类阅读,也便于 Agent 或 CI 解析。默认会验证认证 API 连通性,`--skip-network` 可在离线环境或 CI 中只做本地检查。
本次变更同时补充了中英文帮助文案、README 使用示例和单元测试。测试覆盖了正常本地检查、损坏配置文件、非法 `base_url`、仓库上下文缺失、认证 API mock 成功,以及命令 JSON envelope 输出,确保诊断命令在常见失败场景下返回结构化结果而不是直接崩溃。

View File

@ -0,0 +1,24 @@
# Issue ID Alias
## Summary
`issue +view`, `issue +close`, `issue +update`, and `issue +comment` now accept
`--id` / `-i` as a compatibility alias for `--number` / `-n`.
The alias uses the same project-level issue number shown in the web URL, for
example `issues/123`. It is not the global database ID.
`--number` remains the preferred flag and takes precedence when both flags are
provided.
## Examples
```bash
gitlink-cli issue +view --owner Gitlink --repo forgeplus --id 123
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 --body "Fixed"
```
## Submitter
Wang Yue

View File

@ -0,0 +1,36 @@
# Issue Metadata Fields
## Summary
`issue +create` and `issue +update` now support common GitLink Issue metadata fields.
When updating or closing an Issue, the shortcut also carries the current metadata
back to the API so unrelated fields are not reset by partial updates.
## Added flags
| Flag | API field |
|------|-----------|
| `--priority-id` | `priority_id` |
| `--tag-ids` | `issue_tag_ids` |
| `--assigner-ids` | `assigner_ids` |
| `--branch` | `branch_name` |
| `--start-date` | `start_date` |
| `--due-date` | `due_date` |
`issue +create --label` is also mapped as a single tag ID for backward compatibility.
## Examples
```bash
gitlink-cli issue +create --owner Gitlink --repo forgeplus \
--title "Bug: login failed" \
--priority-id 3 \
--tag-ids 4,5 \
--assigner-ids 7
gitlink-cli issue +update --owner Gitlink --repo forgeplus \
--number 123 \
--priority-id 4 \
--branch bugfix/login \
--due-date 2026-06-15
```

View File

@ -0,0 +1,18 @@
# Label shortcut
新增 `label` Shortcut 组,补齐 GitLink Issue 标签(项目标记 / `issue_tags`OpenAPI 的常用操作封装:
- `label +list`
- `label +create`
- `label +update`
- `label +delete`
实现要点:
- 列表支持 `--keyword` 关键词过滤、`--only-name` 精简返回、`--sort-by` / `--sort-direction` 排序,映射到 API 的 `order_by` / `order_direction`
- `+create``--color` 缺省为 `#1E90FF`;颜色统一做十六进制(`#RGB` / `#RRGGBB`)客户端校验,非法颜色在调用 API 前即报错。
- `+update` 先从列表接口取标签当前值并与传入字段合并,避免漏传字段被清空(更新接口要求 `name`/`description`/`color` 同时提交);无任何变更字段时直接报错。
- 路径使用 `/api/v1/{owner}/{repo}/issue_tags`,与 webhook/milestone 等组保持一致的 `/v1/` 前缀约定。
- 补充单元测试覆盖各命令的 HTTP 方法、路径、查询参数、payload以及颜色校验和 id 归一化逻辑。
背景在此之前Issue 标签只能通过 Raw API`issue_tags`)手工管理;`gitlink-code-review`、`gitlink-insight` 等 Skill 在做 Issue 分拣 / 打标签时都需要拼接原始请求。`label` 组将其提升为一等命令,并配套 `skills/gitlink-label/` Skill 文档,方便人类与 AI Agent 直接复用。

View File

@ -0,0 +1,12 @@
# Issue/PR 列表筛选增强
本次变更修正并增强 `issue +list``pr +list` 的筛选能力。
此前 `issue +list --state open` 会向服务端发送 `state=open`,但 GitLink v1 Issue 列表接口实际使用 `category=opened/closed/all`,因此列表可能仍返回关闭 Issue。`pr +list --state open` 也没有映射到 PR 列表接口实际使用的 `status=0/1/2` 参数Skill 文档中甚至需要提醒用户该参数可能只影响统计。现在两个命令都会保留原有 `--state` 用户体验,同时转换为服务端真实生效的参数。
新增筛选项:
- `issue +list` 支持 `--keyword`、`--participant`、`--author-id`、`--assignee-id`、`--milestone-id`、`--status-id`、`--tag-ids`、`--sort-by`、`--sort-direction`。
- `pr +list` 支持 `--keyword`、`--priority-id`、`--tag-id`、`--milestone-id`、`--reviewer-id`、`--assignee-id`、`--sort-by`、`--sort-direction`。
单元测试覆盖了状态映射、筛选参数透传和 `all` 状态兼容README、中文 README、Issue Skill 与 PR Skill 已同步更新。

View File

@ -0,0 +1,14 @@
# Member Shortcut
新增 `member` Shortcut 组,支持仓库成员管理和项目邀请链接操作:
- `member +list`
- `member +add`
- `member +batch-add`
- `member +remove`
- `member +role`
- `member +invite-link`
- `member +invite-info`
- `member +accept-invite`
同时补充了单元测试、README 示例和 `gitlink-member` Skill 说明。

View File

@ -0,0 +1,19 @@
# Milestone shortcut
新增 `milestone` Shortcut 组,补齐 GitLink 里程碑 OpenAPI 的常用操作封装:
- `milestone +list`
- `milestone +create`
- `milestone +view`
- `milestone +update`
- `milestone +delete`
- `milestone +close`
- `milestone +reopen`
实现要点:
- 支持列表筛选、分页、排序,以及详情页关联 Issue 过滤参数。
- 写入时将 CLI 参数 `--due-date` 映射为 API 字段 `effective_date`
- `+update` 在没有任何变更字段时直接报错,避免发送空更新。
- `+close``+reopen` 使用 GitLink 的 milestone 状态更新接口。
- 补充单元测试覆盖各命令的 HTTP 方法、路径、查询参数和 payload。

View File

@ -0,0 +1,39 @@
# Pipeline OpenAPI Shortcuts
Submitter: Wang Yue
This change adds a dedicated `pipeline` shortcut group for GitLink Pipeline OpenAPI coverage.
## Commands
- `pipeline +list`
- `pipeline +runs`
- `pipeline +run`
- `pipeline +view`
- `pipeline +delete`
- `pipeline +save-yaml`
- `pipeline +enable`
- `pipeline +disable`
- `pipeline +logs`
- `pipeline +results`
## API Mapping
| Shortcut | Method | API path |
|----------|--------|----------|
| `pipeline +list` | GET | `/api/pm/pipelines.json` |
| `pipeline +runs` | GET | `/api/v1/{owner}/{repo}/actions/runs.json` |
| `pipeline +run` | POST | `/api/v1/{owner}/{repo}/actions/runs.json` |
| `pipeline +view` | GET | `/api/v1/{owner}/{repo}/pipelines/{id}.json` |
| `pipeline +delete` | DELETE | `/api/v1/{owner}/{repo}/pipelines/{id}.json` |
| `pipeline +save-yaml` | POST | `/api/v1/{owner}/{repo}/pipelines/save_yaml` |
| `pipeline +enable` | POST | `/api/v1/{owner}/{repo}/actions/enable.json` |
| `pipeline +disable` | POST | `/api/v1/{owner}/{repo}/actions/disable.json` |
| `pipeline +logs` | POST | `/api/v1/{owner}/{repo}/actions/runs/{run_id}/jobs/0` |
| `pipeline +results` | GET | `/api/v1/{owner}/{repo}/pipelines/run_results.json` |
## Verification
- Unit tests cover request methods, paths, query parameters, request bodies, dry-run behavior, and invalid ID validation.
- Help documentation is available through `gitlink-cli pipeline --help` and command-specific help.
- Write and delete commands support `--dry-run` to preview requests before changing pipeline state.

View File

@ -0,0 +1,51 @@
# Repo Insight Shortcuts
## Summary
Adds read-only repository insight shortcuts so maintainers and agents can inspect project health without falling back to Raw API calls.
## Commands
| Command | Purpose |
|---------|---------|
| `gitlink-cli repo +languages` | Show repository language statistics |
| `gitlink-cli repo +contributors` | List repository contributors |
| `gitlink-cli repo +contributor-stats` | List contributor statistics with additions and deletions |
| `gitlink-cli repo +code-stats` | Show repository code statistics |
| `gitlink-cli repo +watchers` | List repository watchers |
| `gitlink-cli repo +stargazers` | List repository stargazers |
| `gitlink-cli repo +follow` | Follow a repository |
| `gitlink-cli repo +unfollow` | Unfollow a repository |
| `gitlink-cli repo +like` | Like a repository |
| `gitlink-cli repo +unlike` | Unlike a repository |
## Validation
- `repo +contributor-stats --pass-year` must be a positive integer.
- `repo +watchers` and `repo +stargazers` accept optional `--start-at` and `--end-at` Unix timestamps.
- Time range timestamps must be non-negative, and `--start-at` cannot be greater than `--end-at`.
- `repo +follow`, `repo +unfollow`, `repo +like`, and `repo +unlike` accept optional `--project-id`; if omitted, the project ID is resolved from `--owner/--repo`.
- Repository interaction actions support `--dry-run` so callers can preview the resolved project ID and endpoint before changing remote state.
## Tests
Unit tests cover endpoint paths, query parameter mapping, optional ref and time-range filters, project ID auto-resolution, dry-run previews, and invalid argument handling before any API request is sent.
## 中文说明
### 变更内容
- 新增 `repo +languages`、`repo +contributors`、`repo +contributor-stats`、`repo +code-stats`、`repo +watchers`、`repo +stargazers` 等仓库洞察命令。
- 新增 `repo +follow`、`repo +unfollow`、`repo +like`、`repo +unlike` 仓库互动命令,并支持 `--project-id``--dry-run`
- `repo +contributor-stats``repo +code-stats` 使用 v1 API支持 `--ref``--pass-year` 参数。
- `repo +watchers``repo +stargazers` 支持 `--start-at` / `--end-at` 时间范围,并在请求前校验时间戳。
- 更新 README、README.zh-CN、`gitlink-repo` Skill 和变更说明,减少仓库分析场景对 Raw API 的依赖。
- 提交者:王越
### 验证
- `GOPROXY=https://goproxy.cn,direct go test ./...`
- `go run . repo --help`
- `go run . repo +contributor-stats --help`
- `go run . repo +watchers --help`
- `git diff --check`

View File

@ -0,0 +1,40 @@
# Repository Settings Shortcuts
Submitter: Wang Yue
This change expands repository shortcut coverage for repository metadata, settings, project topics, navigation units, and transfer OpenAPI endpoints.
## Commands
- `repo +detail`
- `repo +simple`
- `repo +settings`
- `repo +units`
- `repo +units-update`
- `repo +topics`
- `repo +topic-add`
- `repo +topic-delete`
- `repo +transfer-orgs`
- `repo +transfer`
- `repo +transfer-cancel`
## API Mapping
| Shortcut | Method | API path |
|----------|--------|----------|
| `repo +detail` | GET | `/api/{owner}/{repo}/detail.json` |
| `repo +simple` | GET | `/api/{owner}/{repo}/simple.json` |
| `repo +settings` | GET | `/api/{owner}/{repo}/edit.json` |
| `repo +units` | GET | `/api/{owner}/{repo}/project_units.json` |
| `repo +units-update` | POST | `/api/{owner}/{repo}/project_units.json` |
| `repo +topics` | GET | `/api/v1/project_topics.json` |
| `repo +topic-add` | POST | `/api/v1/project_topics.json` |
| `repo +topic-delete` | DELETE | `/api/v1/project_topics/{id}.json` |
| `repo +transfer-orgs` | GET | `/api/{owner}/{repo}/applied_transfer_projects/organizations.json` |
| `repo +transfer` | POST | `/api/{owner}/{repo}/applied_transfer_projects.json` |
| `repo +transfer-cancel` | POST | `/api/{owner}/{repo}/applied_transfer_projects/cancel.json` |
## Verification
- Unit tests cover request methods, paths, query parameters, JSON payloads, dry-run behavior, CSV de-duplication, and invalid project ID validation.
- Write and state-changing commands support `--dry-run`.

View File

@ -0,0 +1,11 @@
# Windows Agent Shell 调用说明
在 Windows 上通过 `npm install -g @gitlink-ai/cli` 安装 CLI 后PowerShell 和 CMD 可以直接执行 npm 生成的 shim但部分基于 Git Bash 的 Agent Shell 不能正确处理 `.cmd` 包装器,导致 `gitlink-cli` 命令执行失败。
本次更新不修改 Go 主程序,而是补充稳定的调用约定:
- PowerShell / CMD / macOS / Linux 继续直接使用 `gitlink-cli ...`
- Windows Git Bash 或 shim 不兼容的 Agent Shell 改为通过 Node 直接执行 `@gitlink-ai/cli/bin/cli.js`
- 在 `README`、`README.zh-CN` 和 `skills/gitlink-shared/SKILL.md` 中都增加了可复制的 fallback 命令
这样做可以让 Skill、Agent 提示词和人工排障文档对齐,避免 Windows 上 “PowerShell 可用、Git Bash 失败” 的调用差异继续反复出现。

77
docs/i18n.md Normal file
View File

@ -0,0 +1,77 @@
# GitLink CLI i18n Guide
## Goals
GitLink CLI localizes human-facing command-line text while keeping machine-readable output stable. The i18n layer is infrastructure, not a place to store every string in the project.
## Translate
- Cobra command `Short`, `Long`, and human-facing examples.
- Flag usage text.
- User-facing errors.
- Interactive prompts.
- Success messages.
- Warnings.
- Confirmation messages.
- Table column labels when the output is meant for humans.
## Do Not Translate
- JSON field names.
- Raw API response bodies.
- Debug logs and developer diagnostics.
- Machine-readable status enum values.
- HTTP methods, paths, query keys, and payload field names.
- Long-form README documentation.
- Test assertion descriptions.
## Key Names
Use stable, descriptive keys:
- `cmd.*` for command help.
- `flag.*` for flag usage.
- `error.*` for user-facing errors.
- `prompt.*` for interactive input prompts.
- `success.*` for successful user-facing operations.
- `warning.*` for warnings.
- `confirm.*` for confirmation prompts.
- `table.*` for human table headers.
Do not invent numbered keys such as `msg001`. Prefer names that describe ownership and intent, for example `error.missing_required_flag`.
## Adding Text
1. Add the key to `internal/i18n/locales/en-US.json`.
2. Add the same key to every other locale, including `zh-CN.json`.
3. Keep placeholders identical across locales, for example `{name}`.
4. Use `tr.T("key")` or `tr.Tf("key", i18n.Args{...})`.
5. Run:
```powershell
go run ./internal/i18n/cmd/check
go test ./...
```
Use `go run ./internal/i18n/cmd/check --fix` to format locale JSON.
Use `go run ./internal/i18n/cmd/check --scan-code` before opening a PR. The scanner is intentionally lightweight:
- Name command-construction translators `tr` when calling `tr.T(...)` or `tr.Tf(...)`.
- Use `ctx.Tr.T(...)` or `ctx.Tr.Tf(...)` in runtime shortcut code.
- Avoid calling translator methods through other variable names such as `translator.T(...)`; the current scan may not detect them.
- Do not add new `i18n.Default().T(...)` or `i18n.Default().Tf(...)` usages.
## Runtime Access
Command construction receives `*i18n.Translator` from `NewRootCmd`. Shortcut execution receives the same translator through `RuntimeContext.Tr`.
New command code should receive a translator explicitly. `i18n.Default()` exists only as a legacy migration fallback and should not be used for new command paths.
## Review Checklist
- Locale JSON is sorted and formatted with two spaces.
- Every locale has the same keys as `en-US`.
- Template placeholders match across locales.
- New command/runtime text uses i18n only when it is human-facing.
- JSON output, API raw responses, debug logs, and machine-readable values are unchanged.

View File

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

View File

@ -0,0 +1,17 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright 2026 GitLink Workflow Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,53 @@
# GitLink 构建端到端自动化工作流
面向 GitLink 竞赛子赛题三的端到端自动化工作流项目。
本项目面向开源社区运营场景,使用 `gitlink-cli` 串联仓库信息、Issue、PR 和 Release 数据采集自动生成社区周报、Release Notes 草稿和结构化摘要,并支持将摘要发布到指定 GitLink Issue。该流程覆盖“数据采集 -> 指标分析 -> 文档生成 -> 结果发布”的完整闭环。
## 交付物
- `scripts/gitlink_workflow.py`:主工作流入口
- `scripts/run_demo.ps1`:一键复现脚本
- `docs/architecture.md`:架构图与流程说明
- `docs/quickstart.md`:最短复现路径
- `docs/runbook.md`:运行手册
- `docs/verification.md`:真实仓库验证记录
- `docs/submission-checklist.md`:参赛提交核对清单
- `docs/upload-to-gitlink.md`:仓库目录结构说明
- `examples/sample_config.json`:参赛仓库配置
- `examples/demo_active_config.json`:公开仓库验证配置
- `examples/demo_outputs/`:真实运行示例产物
- `tests/test_gitlink_workflow.py`:单测
- `LICENSE`Apache 2.0
## 运行方式
推荐直接运行一键脚本:
```powershell
.\scripts\run_demo.ps1
```
切换到参赛仓库配置:
```powershell
.\scripts\run_demo.ps1 -Config examples\sample_config.json
```
## 输出
- `outputs/*_report.md`
- `outputs/*_release_notes.md`
- `outputs/*_summary.json`
## 已验证仓库
- `puygob236/gitlink-cli`完成仓库信息、Issue、PR、Release 采集,并完成 Issue 摘要回写验证
- `Gitlink/gitlink-cli`完成仓库信息、Issue、PR、Release 采集并生成包含有效统计数据的周报、Release Notes 和结构化摘要
## 项目定位
- 满足子赛题三“端到端自动化工作流”的要求
- 串联 4 个数据采集命令和 1 个结果发布命令
- 支持在真实 GitLink 项目上复现
- 提供运行脚本、验证记录、示例产物和单元测试

View File

@ -0,0 +1,26 @@
# 架构说明
本项目采用“采集 -> 归一化 -> 分析 -> 生成 -> 发布”的五段式流程。
![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

After

Width:  |  Height:  |  Size: 400 KiB

View File

@ -0,0 +1,32 @@
# 示例输出摘要
## 验证目标
`Gitlink/gitlink-cli`
## 运行命令
```powershell
.\scripts\run_demo.ps1
```
## 关键结果
- Issues: 15
- PR: 20
- Release: 11
- 输出文件:
- `outputs/Gitlink_gitlink-cli_20260520_140525_report.md`
- `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md`
- `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json`
## 仓库内示例产物
- `examples/demo_outputs/Gitlink_gitlink-cli_report.md`
- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md`
- `examples/demo_outputs/puygob236_gitlink-cli_report.md`
- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md`
## 额外验证
`puygob236/gitlink-cli` 已完成仓库信息、Issue、PR 和 Release 采集验证,并完成摘要回写到 Issue 的发布验证。

View File

@ -0,0 +1,34 @@
# 快速开始
## 一键运行
直接运行一键脚本:
```powershell
.\scripts\run_demo.ps1
```
脚本会自动通过 `npm exec` 找到 `@gitlink-ai/cli`,把 `gitlink-cli` 放到临时 PATH 里,再执行:
- 仓库信息采集
- Issue 列表采集
- PR 列表采集
- Release 列表采集
- 周报生成
- Release Notes 草稿生成
## 配置切换
- `examples/demo_active_config.json`:公开仓库验证配置,默认指向 `Gitlink/gitlink-cli`
- `examples/sample_config.json`:参赛仓库验证配置,默认指向 `puygob236/gitlink-cli`
## 输出
- `outputs/*_report.md`
- `outputs/*_release_notes.md`
- `outputs/*_summary.json`
## 已验证事实
- `puygob236/gitlink-cli` 已完成采集、报告生成和 Issue 摘要回写验证
- `Gitlink/gitlink-cli` 可生成带统计内容的周报和 Release Notes

View File

@ -0,0 +1,54 @@
# 运行手册
## 前置条件
- 已安装 `gitlink-cli`
- 已完成 `gitlink-cli auth login`
- 目标仓库有可读权限
官方快速开始里要求的验证命令是:
```powershell
gitlink-cli user +me
```
## 运行方式
### 1. 只生成报告
```powershell
python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json
```
### 2. 生成报告并发布摘要
```powershell
python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json --publish-issue-id 123
```
### 3. 一键复现
```powershell
.\scripts\run_demo.ps1
```
## 输出文件
- `outputs/*_report.md`:完整周报
- `outputs/*_release_notes.md`Release Notes 草稿
- `outputs/*_summary.json`:结构化摘要
## 验证清单
- `repo +info` 能返回仓库信息
- `issue +list` 能返回 Issue 列表
- `pr +list` 能返回 PR 列表
- `release +list` 能返回 Release 列表
- 报告文件能落盘
- Release Notes 草稿能落盘
- 发布模式能把摘要写回指定 Issue
## 真实项目配置
- `examples/demo_active_config.json` 指向 `Gitlink/gitlink-cli`,用于验证活跃公开仓库的数据分析能力。
- `examples/sample_config.json` 指向 `puygob236/gitlink-cli`,用于验证参赛仓库的采集和 Issue 回写能力。

View File

@ -0,0 +1,28 @@
# 提交核对清单
## 官方交付要求映射
| 要求 | 本项目对应内容 |
| --- | --- |
| 工作流串联不少于 3 个 CLI 命令或 Skill 调用 | `scripts/gitlink_workflow.py` 串联 `repo +info`、`issue +list`、`pr +list`、`release +list`,并支持 `issue +comment` 发布摘要 |
| 提供可复现执行脚本或 Agent 对话记录 | `scripts/run_demo.ps1` |
| 在至少一个真实 GitLink 项目上运行并展示效果 | `docs/verification.md`、`docs/demo-output.md`、`examples/demo_outputs/` |
| 提供工作流说明文档 | `README.md`、`docs/quickstart.md`、`docs/runbook.md` |
| 提供架构图 | `docs/architecture.md` 引用 `docs/assets/architecture-workflow-v2.svg` |
| 代码开源并托管到 GitLink | `https://gitlink.org.cn/puygob236/gitlink-cli``examples/workflows/community-ops-automation/` |
| 提供完整中文 README | `README.md` |
| 开源协议 | `LICENSE`Apache 2.0 |
## 验证状态
- `python -m py_compile .\scripts\gitlink_workflow.py .\tests\test_gitlink_workflow.py`:通过
- `python -m unittest discover -s tests`:通过
- `.\scripts\run_demo.ps1`:已在 `Gitlink/gitlink-cli` 上跑通
- `.\scripts\run_demo.ps1 -Config examples\sample_config.json`:已在 `puygob236/gitlink-cli` 上跑通
- `.\scripts\run_demo.ps1 -Config examples\sample_config.json -PublishIssueId 2`:已完成 Issue 摘要回写验证
## 交付内容
- `README.md`、`docs/`、`scripts/`、`examples/`、`tests/`、`LICENSE` 均位于 `examples/workflows/community-ops-automation/`
- `outputs/` 为运行时生成目录,评审可通过复现脚本重新生成。
- `examples/demo_outputs/` 提供固定示例产物,便于快速查看报告格式和输出内容。

View File

@ -0,0 +1,30 @@
# GitLink 仓库目录结构
本作品以 `gitlink-cli` 工作流示例的形式托管在 GitLink 仓库中目录与主项目源码保持隔离避免改变主仓库既有命令、Skill 和设计文档结构。
## 作品路径
```text
examples/workflows/community-ops-automation/
```
## 目录内容
- `README.md`:项目说明与复现入口
- `LICENSE`Apache 2.0 开源协议
- `.gitignore`:运行时产物忽略规则
- `docs/`:架构、运行、验证和交付说明
- `examples/`:配置文件和示例输出
- `scripts/`:工作流执行脚本
- `tests/`:单元测试
## 仓库内验证
进入作品目录后运行:
```powershell
python -m unittest discover -s tests
.\scripts\run_demo.ps1
```
生成的 `outputs/` 是运行时目录;固定示例产物位于 `examples/demo_outputs/`

View File

@ -0,0 +1,67 @@
# 验证记录
## 环境
- Windows PowerShell
- Python 3
- `@gitlink-ai/cli` 0.1.13
## 已验证的真实仓库
### `puygob236/gitlink-cli`
- `repo +info` 可访问
- `issue +list` 可访问
- `pr +list` 可访问
- `release +list` 可访问
- 已完成 Issue 摘要回写验证
### `Gitlink/gitlink-cli`
- `repo +info` 可访问
- `issue +list` 可访问
- `pr +list` 可访问
- `release +list` 可访问
- 当前可提取到的统计结果:
- Issues: 15
- PR: 20
- Release: 11
## 本地输出
已生成的文件:
- `outputs/Gitlink_gitlink-cli_20260515_040153_report.md`
- `outputs/Gitlink_gitlink-cli_20260515_040153_summary.json`
- `outputs/Gitlink_gitlink-cli_20260515_121523_report.md`
- `outputs/Gitlink_gitlink-cli_20260515_121523_release_notes.md`
- `outputs/Gitlink_gitlink-cli_20260515_121523_summary.json`
- `outputs/puygob236_gitlink-cli_20260515_121544_report.md`
- `outputs/puygob236_gitlink-cli_20260515_121544_release_notes.md`
- `outputs/puygob236_gitlink-cli_20260515_121544_summary.json`
- `outputs/puygob236_gitlink-cli_20260515_121845_report.md`
- `outputs/puygob236_gitlink-cli_20260515_121845_release_notes.md`
- `outputs/puygob236_gitlink-cli_20260515_121845_summary.json`
- `outputs/Gitlink_gitlink-cli_20260520_140525_report.md`
- `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md`
- `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json`
- `outputs/puygob236_gitlink-cli_20260520_143224_report.md`
- `outputs/puygob236_gitlink-cli_20260520_143224_release_notes.md`
- `outputs/puygob236_gitlink-cli_20260520_143224_summary.json`
其中 `20260520_140525` 对应公开仓库数据分析验证,`20260520_143224` 对应参赛仓库采集与 Issue 回写验证。
## 示例产物
`outputs/` 是运行时目录,仓库交付中同时提供了轻量示例:
- `examples/demo_outputs/Gitlink_gitlink-cli_report.md`
- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md`
- `examples/demo_outputs/puygob236_gitlink-cli_report.md`
- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md`
## 复现方式
```powershell
.\scripts\run_demo.ps1
```

View File

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

View File

@ -0,0 +1,18 @@
# 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

@ -0,0 +1,32 @@
# gitlink-cli 自动化周报
- 统计窗口:近 7 天
- 生成时间2026-05-20 14:05:25 UTC
## 核心指标
| 指标 | 数值 |
| --- | ---: |
| Issues 总数 | 15 |
| 打开 Issues | 5 |
| 超窗 Issue | 1 |
| PR 总数 | 20 |
| 打开 PR | 5 |
| 已合并 PR | 8 |
| Release 数 | 11 |
## 热点标签
- 无
## 最近合并 PR
### fix
- fix(npm): improve missing binary diagnostics (2026-05-19)
### feature
- feat(pr): add pr +comment shortcut (2026-05-14)
## 风险提示
### 超窗 Issue
- 2 gitlink-cli 使用讨论与反馈收集 (open) 2026-04-18
### 建议动作
- 存在 1 个超过 7 天未更新的开放 Issue建议优先清理。

View File

@ -0,0 +1,10 @@
# 示例输出说明
本目录保存一次真实 GitLink 项目的演示输出,便于评审在不重新运行脚本时快速查看效果。
- `Gitlink_gitlink-cli_report.md`:活跃官方仓库周报示例
- `Gitlink_gitlink-cli_release_notes.md`:活跃官方仓库 Release Notes 草稿示例
- `puygob236_gitlink-cli_report.md`:参赛 fork 连通性周报示例
- `puygob236_gitlink-cli_release_notes.md`:参赛 fork Release Notes 草稿示例
完整结构化摘要会在运行脚本后生成到 `outputs/*_summary.json`

View File

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

View File

@ -0,0 +1,26 @@
# gitlink-cli 自动化周报
- 统计窗口:近 7 天
- 生成时间2026-05-20 14:32:24 UTC
## 核心指标
| 指标 | 数值 |
| --- | ---: |
| Issues 总数 | 2 |
| 打开 Issues | 2 |
| 超窗 Issue | 0 |
| PR 总数 | 0 |
| 打开 PR | 0 |
| 已合并 PR | 0 |
| Release 数 | 0 |
## 热点标签
- 无
## 最近合并 PR
- 无
## 风险提示
### 建议动作
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。

View File

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

View File

@ -0,0 +1,814 @@
from __future__ import annotations
import argparse
import json
import os
import subprocess
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable
class WorkflowError(RuntimeError):
pass
CLI_PAGE_SIZE = 100
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="GitLink 社区运营自动化工作流:周报 + Release Notes + 风险提示"
)
parser.add_argument(
"--config",
type=Path,
default=Path("examples/sample_config.json"),
help="配置文件路径",
)
parser.add_argument("--owner", help="覆盖配置中的仓库所有者")
parser.add_argument("--repo", help="覆盖配置中的仓库名称")
parser.add_argument(
"--window-days",
type=int,
help="统计窗口,默认从配置文件读取或使用 7 天",
)
parser.add_argument(
"--output-dir",
type=Path,
help="输出目录,默认从配置文件读取或使用 outputs",
)
parser.add_argument(
"--publish-issue-id",
type=int,
help="发布摘要到指定 Issue 评论,未提供则只生成本地报告",
)
parser.add_argument(
"--now",
help="固定当前时间,便于测试,格式为 ISO8601",
)
parser.add_argument(
"--skip-releases",
action="store_true",
help="跳过 release 列表采集",
)
parser.add_argument(
"--cli-bin",
help="gitlink-cli 可执行文件路径;可配合 GITLINK_CLI_BIN 使用",
)
return parser.parse_args(argv)
def load_json_file(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
def sanitize_repo_name(value: str) -> str:
return value.replace("/", "_").replace("\\", "_")
def parse_datetime(value: Any) -> datetime | None:
if value in (None, "", []):
return None
if isinstance(value, datetime):
dt = value
else:
text = str(value).strip()
if not text:
return None
text = text.replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(text)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def parse_iso_now(value: str | None) -> datetime:
if not value:
return datetime.now(timezone.utc)
dt = parse_datetime(value)
if dt is None:
raise WorkflowError(f"无法解析 --now 的值: {value}")
return dt
def first_value(item: dict[str, Any], keys: Iterable[str], default: Any = None) -> Any:
for key in keys:
if key in item:
value = item[key]
if value not in (None, "", []):
return value
return default
def normalize_labels(value: Any) -> list[str]:
labels: list[str] = []
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
name = first_value(item, ("name", "title", "label_name"))
if name:
labels.append(str(name))
elif item not in (None, ""):
labels.append(str(item))
elif isinstance(value, str) and value:
labels.append(value)
return labels
def extract_first_list(payload: Any, keys: Iterable[str]) -> list[Any]:
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
for key in keys:
value = payload.get(key)
if isinstance(value, list):
return value
for value in payload.values():
found = extract_first_list(value, keys)
if found:
return found
return []
def extract_first_dict(payload: Any, keys: Iterable[str]) -> dict[str, Any]:
if isinstance(payload, dict):
for key in keys:
value = payload.get(key)
if isinstance(value, dict):
return value
for value in payload.values():
found = extract_first_dict(value, keys)
if found:
return found
if isinstance(payload, list):
for item in payload:
found = extract_first_dict(item, keys)
if found:
return found
return {}
def run_gitlink_cli(command: list[str], owner: str, repo: str, cwd: Path | None = None) -> Any:
if shutil_which("gitlink-cli") is None:
raise WorkflowError("未找到 gitlink-cli请先安装并确保它在 PATH 中")
cli_path = shutil_which("gitlink-cli") or "gitlink-cli"
if cli_path.lower().endswith((".cmd", ".bat")):
cmd = [
"cmd",
"/c",
cli_path,
*command,
"--owner",
owner,
"--repo",
repo,
"--format",
"json",
]
else:
cmd = [
cli_path,
*command,
"--owner",
owner,
"--repo",
repo,
"--format",
"json",
]
proc = subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
encoding="utf-8",
)
if proc.returncode != 0:
stderr = proc.stderr.strip() or proc.stdout.strip() or "未知错误"
raise WorkflowError(f"{' '.join(cmd)} 失败: {stderr}")
return parse_json_output(proc.stdout)
def parse_json_output(text: str) -> Any:
stripped = text.strip()
if not stripped:
raise WorkflowError("CLI 返回空结果")
try:
return json.loads(stripped)
except json.JSONDecodeError:
first_json = min(
[idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1],
default=-1,
)
if first_json > 0:
return json.loads(stripped[first_json:])
raise WorkflowError(f"无法解析 CLI JSON 输出: {stripped[:120]}")
def normalize_repo_info(payload: Any) -> dict[str, Any]:
repo = extract_first_dict(payload, ("project", "repo", "repository", "data"))
if not repo and isinstance(payload, dict):
repo = payload
return {
"name": first_value(repo, ("name", "repo_name", "project_name", "identifier"), ""),
"description": first_value(repo, ("description", "desc", "summary"), ""),
"default_branch": first_value(repo, ("default_branch", "defaultBranch"), ""),
"language": first_value(repo, ("language",), ""),
"raw": repo,
}
def normalize_issue_state(item: dict[str, Any], query_state: str | None = None) -> str:
raw_status = first_value(item, ("status_id", "status", "state_id"), None)
raw_name = str(
first_value(item, ("issue_status", "status_name", "state", "status_name_cn"), "")
).strip().lower()
if raw_status is not None:
try:
raw_status = int(raw_status)
except (TypeError, ValueError):
raw_status = str(raw_status).strip().lower()
if raw_status in {5, "5", "closed", "close"} or "" in raw_name or "closed" in raw_name:
return "closed"
if raw_status in {1, "1", 2, "2", 3, "3", "open", "opened"} or "" in raw_name or "" in raw_name:
return "open"
if query_state:
return query_state
return "open"
def normalize_issue(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]:
return {
"id": str(first_value(item, ("project_issues_index", "iid", "issue_id", "id", "number"), "")),
"title": str(first_value(item, ("subject", "title", "name"), "(untitled)")),
"state": normalize_issue_state(item, query_state=query_state),
"created_at": parse_datetime(
first_value(item, ("created_at", "createdAt", "created_time", "created", "format_time"))
),
"updated_at": parse_datetime(
first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "format_time"))
),
"labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])),
"raw": item,
}
def normalize_issues(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]:
items = extract_first_list(payload, ("issues", "issue_list", "items", "list"))
normalized: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
continue
normalized.append(normalize_issue(item, query_state=query_state))
return normalized
def normalize_pr_state(item: dict[str, Any], query_state: str | None = None) -> str:
raw_status = first_value(item, ("pull_request_status", "pull_request_staus", "status_id", "state_id"), None)
if raw_status is not None:
try:
raw_status = int(raw_status)
except (TypeError, ValueError):
raw_status = str(raw_status).strip().lower()
if raw_status in {1, "1", "merged"}:
return "merged"
if raw_status in {2, "2", "closed", "close"}:
return "closed"
if raw_status in {0, "0", "open", "opened"}:
return "open"
if query_state:
return query_state
return "open"
def normalize_pr(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]:
state = normalize_pr_state(item, query_state=query_state)
merged_at = parse_datetime(first_value(item, ("merged_at", "mergedAt", "merged_time")))
merged_flag = state == "merged" or merged_at is not None
return {
"id": str(
first_value(item, ("pull_request_number", "iid", "pr_id", "merge_request_iid", "id", "number"), "")
),
"title": str(first_value(item, ("title", "subject", "name"), "(untitled)")),
"state": state,
"created_at": parse_datetime(
first_value(item, ("created_at", "createdAt", "created_time", "created", "pr_full_time"))
),
"updated_at": parse_datetime(
first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "pr_full_time"))
),
"merged_at": merged_at
or (parse_datetime(first_value(item, ("pr_full_time",))) if state == "merged" else None),
"merged": merged_flag,
"labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])),
"raw": item,
}
def normalize_prs(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]:
items = extract_first_list(payload, ("pull_requests", "merge_requests", "prs", "items", "list"))
normalized: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
continue
normalized.append(normalize_pr(item, query_state=query_state))
return normalized
def normalize_releases(payload: Any) -> list[dict[str, Any]]:
items = extract_first_list(payload, ("releases", "items", "list"))
normalized: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
continue
normalized.append(
{
"id": str(first_value(item, ("version_id", "id", "release_id", "iid"), "")),
"title": str(first_value(item, ("name", "title", "tag_name"), "(untitled)")),
"created_at": parse_datetime(
first_value(item, ("created_at", "createdAt", "released_at", "releasedAt"))
),
"raw": item,
}
)
return normalized
def is_open(state: str) -> bool:
return state == "open"
def is_closed(state: str) -> bool:
return state in {"closed", "close", "done", "resolved"}
def classify_title(title: str) -> str:
lowered = title.strip().lower()
prefix = lowered.split(":", 1)[0]
prefix = prefix.split("(", 1)[0].strip()
mapping = {
"feat": "feature",
"feature": "feature",
"fix": "fix",
"bugfix": "fix",
"docs": "docs",
"doc": "docs",
"refactor": "refactor",
"test": "test",
"chore": "chore",
"ci": "ci",
}
return mapping.get(prefix, "other")
def within_window(dt: datetime | None, cutoff: datetime) -> bool:
return dt is not None and dt >= cutoff
def dedupe_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[str] = set()
result: list[dict[str, Any]] = []
for item in records:
key = str(item.get("id", "")).strip()
if not key or key in seen:
continue
seen.add(key)
result.append(item)
return result
def fetch_paginated_payload(
command: list[str],
owner: str,
repo: str,
item_keys: tuple[str, ...],
page_size: int = CLI_PAGE_SIZE,
) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
page = 1
max_pages = 50
while True:
if page > max_pages:
break
payload = run_gitlink_cli(
[*command, "--page", str(page), "--limit", str(page_size)],
owner,
repo,
)
page_items = extract_first_list(payload, item_keys)
page_items = [item for item in page_items if isinstance(item, dict)]
if not page_items:
break
items.extend(page_items)
if len(page_items) < page_size:
break
page += 1
return items
def fetch_issues(owner: str, repo: str) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for state in ("open", "closed"):
payloads = fetch_paginated_payload(
["issue", "+list", "--state", state],
owner,
repo,
("issues", "issue_list", "items", "list"),
)
records.extend(normalize_issues({"issues": payloads}, query_state=state))
return dedupe_records(records)
def fetch_prs(owner: str, repo: str) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for state in ("open", "merged", "closed"):
payloads = fetch_paginated_payload(
["pr", "+list", "--state", state],
owner,
repo,
("pull_requests", "merge_requests", "prs", "items", "list"),
)
records.extend(normalize_prs({"pull_requests": payloads}, query_state=state))
return dedupe_records(records)
def fetch_releases(owner: str, repo: str) -> list[dict[str, Any]]:
payloads = fetch_paginated_payload(
["release", "+list"],
owner,
repo,
("releases", "items", "list"),
)
return dedupe_records(normalize_releases({"releases": payloads}))
def summarize_workflow(
repo_info: dict[str, Any],
issues: list[dict[str, Any]],
prs: list[dict[str, Any]],
releases: list[dict[str, Any]],
now: datetime,
window_days: int,
) -> dict[str, Any]:
cutoff = now - timedelta(days=window_days)
open_issues = [item for item in issues if is_open(item["state"])]
closed_issues = [item for item in issues if is_closed(item["state"])]
stale_issues = [
item
for item in open_issues
if item["updated_at"] is None or item["updated_at"] < cutoff
]
merged_prs = [item for item in prs if item["merged"] or item["state"] == "merged"]
open_prs = [item for item in prs if is_open(item["state"]) or (not item["merged"] and not is_closed(item["state"]))]
stale_prs = [
item
for item in open_prs
if item["updated_at"] is None or item["updated_at"] < cutoff
]
recent_merged_prs = [
item
for item in merged_prs
if within_window(item["merged_at"] or item["updated_at"] or item["created_at"], cutoff)
]
issue_label_counter: Counter[str] = Counter()
for item in issues:
issue_label_counter.update(item["labels"])
pr_buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
for item in recent_merged_prs:
pr_buckets[classify_title(item["title"])].append(item)
actions: list[str] = []
if stale_issues:
actions.append(
f"存在 {len(stale_issues)} 个超过 {window_days} 天未更新的开放 Issue建议优先清理。"
)
if stale_prs:
actions.append(
f"存在 {len(stale_prs)} 个超过 {window_days} 天未更新的开放 PR建议安排 review 或重新拆解。"
)
if not releases:
actions.append("当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。")
return {
"repo": repo_info,
"window_days": window_days,
"now": now,
"cutoff": cutoff,
"counts": {
"issues_total": len(issues),
"issues_open": len(open_issues),
"issues_closed": len(closed_issues),
"issues_stale": len(stale_issues),
"prs_total": len(prs),
"prs_open": len(open_prs),
"prs_merged": len(merged_prs),
"prs_stale": len(stale_prs),
"releases_total": len(releases),
},
"labels": issue_label_counter.most_common(8),
"stale_issues": stale_issues,
"stale_prs": stale_prs,
"recent_merged_prs": recent_merged_prs,
"pr_buckets": {key: value for key, value in pr_buckets.items()},
"actions": actions,
}
def render_list_block(items: list[dict[str, Any]], title_key: str = "title") -> str:
if not items:
return "- 无"
lines = []
for item in items[:10]:
parts = [f"- {item.get('id', '')} {item.get(title_key, '')}".strip()]
state = item.get("state")
if state:
parts.append(f"({state})")
dt = item.get("updated_at") or item.get("merged_at") or item.get("created_at")
if isinstance(dt, datetime):
parts.append(dt.strftime("%Y-%m-%d"))
lines.append(" ".join(parts))
return "\n".join(lines)
def render_markdown_report(summary: dict[str, Any]) -> str:
repo = summary["repo"]
counts = summary["counts"]
lines: list[str] = []
title = repo["name"] or "GitLink 仓库"
lines.append(f"# {title} 自动化周报")
if repo.get("description"):
lines.append("")
lines.append(repo["description"])
lines.append("")
lines.append(f"- 统计窗口:近 {summary['window_days']}")
lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
lines.append("")
lines.append("## 核心指标")
lines.append("")
lines.append("| 指标 | 数值 |")
lines.append("| --- | ---: |")
lines.append(f"| Issues 总数 | {counts['issues_total']} |")
lines.append(f"| 打开 Issues | {counts['issues_open']} |")
lines.append(f"| 超窗 Issue | {counts['issues_stale']} |")
lines.append(f"| PR 总数 | {counts['prs_total']} |")
lines.append(f"| 打开 PR | {counts['prs_open']} |")
lines.append(f"| 已合并 PR | {counts['prs_merged']} |")
lines.append(f"| Release 数 | {counts['releases_total']} |")
lines.append("")
lines.append("## 热点标签")
if summary["labels"]:
for label, count in summary["labels"]:
lines.append(f"- {label}: {count}")
else:
lines.append("- 无")
lines.append("")
lines.append("## 最近合并 PR")
recent_groups = summary["pr_buckets"]
if recent_groups:
for bucket, items in recent_groups.items():
lines.append(f"### {bucket}")
for item in items[:8]:
merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at")
suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else ""
lines.append(f"- {item['title']}{suffix}")
else:
lines.append("- 无")
lines.append("")
lines.append("## 风险提示")
if summary["stale_issues"]:
lines.append("### 超窗 Issue")
lines.append(render_list_block(summary["stale_issues"]))
lines.append("")
if summary["stale_prs"]:
lines.append("### 超窗 PR")
lines.append(render_list_block(summary["stale_prs"]))
lines.append("")
if summary["actions"]:
lines.append("### 建议动作")
for action in summary["actions"]:
lines.append(f"- {action}")
else:
lines.append("- 当前未发现明显风险。")
return "\n".join(lines).rstrip() + "\n"
def render_release_notes(summary: dict[str, Any]) -> str:
repo = summary["repo"]
lines: list[str] = []
title = repo["name"] or "GitLink 仓库"
lines.append(f"# {title} Release Notes 草稿")
lines.append("")
lines.append(f"- 统计窗口:近 {summary['window_days']}")
lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
lines.append("")
lines.append("## 变更概览")
lines.append(f"- 已合并 PR{summary['counts']['prs_merged']}")
lines.append(f"- 最近窗口内合并 PR{len(summary['recent_merged_prs'])}")
lines.append("")
lines.append("## 变更分类")
groups = summary["pr_buckets"]
if groups:
for bucket in ("feature", "fix", "docs", "refactor", "test", "chore", "ci", "other"):
items = groups.get(bucket, [])
if not items:
continue
lines.append(f"### {bucket}")
for item in items[:10]:
merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at")
suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else ""
lines.append(f"- {item['title']}{suffix}")
lines.append("")
else:
lines.append("- 无")
lines.append("")
lines.append("## 发布说明")
if summary["actions"]:
for action in summary["actions"]:
lines.append(f"- {action}")
else:
lines.append("- 当前未发现明显风险。")
return "\n".join(lines).rstrip() + "\n"
def render_publish_comment(
summary: dict[str, Any],
report_path: Path,
release_notes_path: Path | None = None,
) -> str:
repo = summary["repo"]
counts = summary["counts"]
lines = [
f"## {repo['name'] or 'GitLink 仓库'} 自动化周报摘要",
"",
f"- 时间窗:近 {summary['window_days']}",
f"- Issues{counts['issues_open']} 个打开,{counts['issues_stale']} 个超窗",
f"- PR{counts['prs_open']} 个打开,{counts['prs_merged']} 个已合并",
f"- Release{counts['releases_total']}",
"",
f"完整报告已生成:`{report_path.as_posix()}`",
]
if release_notes_path is not None:
lines.append(f"Release Notes 草稿:`{release_notes_path.as_posix()}`")
if summary["actions"]:
lines.append("")
lines.append("### 建议动作")
for action in summary["actions"][:3]:
lines.append(f"- {action}")
return "\n".join(lines).rstrip()
def build_issue_comment_command(issue_number: int, comment: str) -> list[str]:
return ["issue", "+comment", "--number", str(issue_number), "--body", comment]
def safe_fetch(
label: str,
func,
warnings: list[str],
default: Any,
) -> Any:
try:
return func()
except Exception as exc: # noqa: BLE001
warnings.append(f"{label} 失败:{exc}")
return default
def shutil_which(name: str) -> str | None:
from shutil import which
return which(name)
def build_artifacts(
owner: str,
repo: str,
window_days: int,
output_dir: Path,
now: datetime,
publish_issue_id: int | None,
skip_releases: bool,
) -> tuple[dict[str, Any], Path, Path, Path, list[str]]:
warnings: list[str] = []
repo_info = safe_fetch(
"repo +info",
lambda: normalize_repo_info(run_gitlink_cli(["repo", "+info"], owner, repo)),
warnings,
{"name": repo, "description": "", "default_branch": "", "language": "", "raw": {}},
)
issues = safe_fetch("issue +list", lambda: fetch_issues(owner, repo), warnings, [])
prs = safe_fetch("pr +list", lambda: fetch_prs(owner, repo), warnings, [])
releases = [] if skip_releases else safe_fetch(
"release +list",
lambda: fetch_releases(owner, repo),
warnings,
[],
)
summary = summarize_workflow(repo_info, issues, prs, releases, now, window_days)
summary["warnings"] = warnings
summary["owner"] = owner
summary["repo_name"] = repo
summary["publish_issue_id"] = publish_issue_id
output_dir.mkdir(parents=True, exist_ok=True)
stamp = now.strftime("%Y%m%d_%H%M%S")
repo_slug = sanitize_repo_name(repo)
base_name = f"{owner}_{repo_slug}_{stamp}"
report_path = output_dir / f"{base_name}_report.md"
summary_path = output_dir / f"{base_name}_summary.json"
release_notes_path = output_dir / f"{base_name}_release_notes.md"
report_text = render_markdown_report(summary)
release_notes_text = render_release_notes(summary)
report_path.write_text(report_text, encoding="utf-8")
release_notes_path.write_text(release_notes_text, encoding="utf-8")
summary_path.write_text(
json.dumps(
{
**summary,
"now": summary["now"].isoformat(),
"cutoff": summary["cutoff"].isoformat(),
"artifacts": {
"report": report_path.as_posix(),
"summary": summary_path.as_posix(),
"release_notes": release_notes_path.as_posix(),
},
},
ensure_ascii=False,
indent=2,
default=str,
),
encoding="utf-8",
)
if publish_issue_id is not None:
comment = render_publish_comment(summary, report_path, release_notes_path)
try:
run_gitlink_cli(
build_issue_comment_command(publish_issue_id, comment),
owner,
repo,
)
except Exception as exc: # noqa: BLE001
warnings.append(f"issue +comment 失败:{exc}")
return summary, report_path, summary_path, release_notes_path, warnings
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
config = load_json_file(args.config)
owner = args.owner or config.get("owner")
repo = args.repo or config.get("repo")
if not owner or not repo:
raise WorkflowError("请在配置文件或命令行中提供 owner 和 repo")
window_days = args.window_days or int(config.get("window_days", 7))
output_dir = args.output_dir or Path(config.get("output_dir", "outputs"))
now = parse_iso_now(args.now)
summary, report_path, summary_path, release_notes_path, warnings = build_artifacts(
owner=owner,
repo=repo,
window_days=window_days,
output_dir=output_dir,
now=now,
publish_issue_id=args.publish_issue_id,
skip_releases=args.skip_releases,
)
print(f"已生成报告: {report_path}")
print(f"已生成摘要: {summary_path}")
print(f"已生成 Release Notes: {release_notes_path}")
if warnings:
print("警告:")
for warning in warnings:
print(f"- {warning}")
print(
"指标概览: "
f"Issues={summary['counts']['issues_total']}, "
f"PR={summary['counts']['prs_total']}, "
f"Release={summary['counts']['releases_total']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,45 @@
param(
[string]$Config = "examples/demo_active_config.json",
[string]$Owner = "",
[string]$Repo = "",
[int]$WindowDays = 7,
[string]$OutputDir = "outputs",
[int]$PublishIssueId = 0,
[switch]$SkipReleases
)
$ErrorActionPreference = "Stop"
$cliCandidates = npm.cmd exec --yes --package=@gitlink-ai/cli -- cmd /c where gitlink-cli 2>$null
$cliPath = $cliCandidates | Where-Object { $_ -match 'gitlink-cli\.cmd$' } | Select-Object -First 1
if (-not $cliPath) {
$cliPath = $cliCandidates | Select-Object -First 1
}
if (-not $cliPath) {
throw "未能通过 npm exec 找到 gitlink-cli"
}
$cliDir = Split-Path -Parent $cliPath
$env:PATH = "$cliDir;$env:PATH"
$args = @(
"scripts\gitlink_workflow.py",
"--config", $Config,
"--window-days", "$WindowDays",
"--output-dir", $OutputDir
)
if ($Owner) {
$args += @("--owner", $Owner)
}
if ($Repo) {
$args += @("--repo", $Repo)
}
if ($PublishIssueId -gt 0) {
$args += @("--publish-issue-id", "$PublishIssueId")
}
if ($SkipReleases.IsPresent) {
$args += "--skip-releases"
}
python @args

View File

@ -0,0 +1,133 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from scripts.gitlink_workflow import (
build_issue_comment_command,
normalize_issues,
normalize_prs,
normalize_releases,
render_markdown_report,
render_release_notes,
summarize_workflow,
)
class WorkflowTests(unittest.TestCase):
def setUp(self) -> None:
self.now = datetime(2026, 5, 15, 12, 0, tzinfo=timezone.utc)
self.repo_info = {
"name": "forgeplus",
"description": "demo repo",
"default_branch": "master",
}
def test_normalize_issue_payload(self) -> None:
payload = {
"data": {
"issues": [
{
"project_issues_index": 1,
"subject": "feat: add report",
"status_id": 1,
"status_name": "新增",
"updated_at": "2026-05-10T10:00:00Z",
"labels": [{"name": "enhancement"}],
}
]
}
}
issues = normalize_issues(payload)
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["title"], "feat: add report")
self.assertEqual(issues[0]["labels"], ["enhancement"])
self.assertEqual(issues[0]["state"], "open")
def test_normalize_pr_payload(self) -> None:
payload = {
"data": {
"merge_requests": [
{
"pull_request_number": 10,
"title": "fix: bug",
"pull_request_status": 1,
"merged_at": "2026-05-14T10:00:00Z",
}
]
}
}
prs = normalize_prs(payload)
self.assertEqual(len(prs), 1)
self.assertTrue(prs[0]["merged"])
self.assertEqual(prs[0]["state"], "merged")
def test_normalize_release_payload(self) -> None:
payload = {"data": {"releases": [{"id": 5, "name": "v1.0.0"}]}}
releases = normalize_releases(payload)
self.assertEqual(len(releases), 1)
self.assertEqual(releases[0]["title"], "v1.0.0")
def test_summary_and_report(self) -> None:
issues = [
{
"id": "1",
"title": "feat: add report",
"state": "open",
"created_at": datetime(2026, 5, 5, 12, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 5, 10, 12, 0, tzinfo=timezone.utc),
"labels": ["enhancement"],
},
{
"id": "2",
"title": "fix: stale issue",
"state": "open",
"created_at": datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc),
"labels": ["bug"],
},
]
prs = [
{
"id": "10",
"title": "feat: workflow",
"state": "merged",
"created_at": datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc),
"merged_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc),
"merged": True,
"labels": [],
},
{
"id": "11",
"title": "chore: cleanup",
"state": "open",
"created_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 5, 2, 12, 0, tzinfo=timezone.utc),
"merged_at": None,
"merged": False,
"labels": [],
},
]
releases = [{"id": "1", "title": "v1.0.0", "created_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc)}]
summary = summarize_workflow(self.repo_info, issues, prs, releases, self.now, 7)
report = render_markdown_report(summary)
self.assertIn("# forgeplus 自动化周报", report)
self.assertIn("Issues 总数", report)
self.assertIn("超窗 Issue", report)
self.assertIn("feature", report)
release_notes = render_release_notes(summary)
self.assertIn("Release Notes", release_notes)
self.assertIn("变更分类", release_notes)
self.assertEqual(summary["counts"]["issues_stale"], 1)
self.assertEqual(summary["counts"]["prs_merged"], 1)
self.assertIn("feature", summary["pr_buckets"])
def test_issue_comment_command_uses_number_flag(self) -> None:
command = build_issue_comment_command(2, "demo")
self.assertEqual(command, ["issue", "+comment", "--number", "2", "--body", "demo"])
self.assertNotIn("-i", command)
if __name__ == "__main__":
unittest.main()

11
go.mod
View File

@ -5,16 +5,27 @@ go 1.26.1
require ( require (
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8 github.com/zalando/go-keyring v0.2.8
golang.org/x/sync v0.20.0
golang.org/x/term v0.41.0 golang.org/x/term v0.41.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.1
) )
require ( require (
github.com/danieljoos/wincred v1.2.3 // indirect github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )

51
go.sum
View File

@ -4,17 +4,31 @@ github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMF
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@ -30,12 +44,49 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@ -102,7 +102,7 @@ func Login(username, password string) (*LoginResult, error) {
if _, verifyErr := GetCurrentUser(); verifyErr != nil { if _, verifyErr := GetCurrentUser(); verifyErr != nil {
// Clean up the bad token // Clean up the bad token
_ = DeleteToken() _ = DeleteToken()
return nil, fmt.Errorf("login failed: credentials not accepted by API (%v)", verifyErr) return nil, fmt.Errorf("login failed: credentials not accepted by API (%w)", verifyErr)
} }
return &result, nil return &result, nil

191
internal/auth/login_test.go Normal file
View File

@ -0,0 +1,191 @@
package auth
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/zalando/go-keyring"
)
func setupConfigDir(t *testing.T, baseURL string) string {
t.Helper()
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
// Write a minimal config
cfgDir := filepath.Join(dir)
os.MkdirAll(cfgDir, 0700)
os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte("base_url: "+baseURL+"\ndefault_format: table\n"), 0600)
return dir
}
func TestGetCurrentUserSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/me.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"login": "testuser",
"name": "Test User",
"id": 42,
})
}))
defer server.Close()
setupConfigDir(t, server.URL)
// Need to prevent any cookie/token auth from interfering
os.Unsetenv("GITLINK_TOKEN")
user, err := GetCurrentUser()
if err != nil {
t.Fatalf("GetCurrentUser error: %v", err)
}
if user["login"] != "testuser" {
t.Fatalf("login = %q, want testuser", user["login"])
}
if user["name"] != "Test User" {
t.Fatalf("name = %q", user["name"])
}
}
func TestGetCurrentUserHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("unauthorized"))
}))
defer server.Close()
setupConfigDir(t, server.URL)
os.Unsetenv("GITLINK_TOKEN")
_, err := GetCurrentUser()
if err == nil {
t.Fatal("expected error for 401")
}
}
func TestGetCurrentUserStatusError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": float64(-1),
"message": "Token invalid",
})
}))
defer server.Close()
setupConfigDir(t, server.URL)
os.Unsetenv("GITLINK_TOKEN")
_, err := GetCurrentUser()
if err == nil {
t.Fatal("expected error for status=-1")
}
}
func TestGetCurrentUserMissingLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 42,
})
}))
defer server.Close()
setupConfigDir(t, server.URL)
os.Unsetenv("GITLINK_TOKEN")
_, err := GetCurrentUser()
if err == nil {
t.Fatal("expected error when login field is missing")
}
}
func TestLoginSuccess(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("GITLINK_TOKEN", "")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/accounts/login.json":
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Set-Cookie", "autologin_trustie=sess123; Path=/")
json.NewEncoder(w).Encode(map[string]interface{}{
"username": "testuser",
"login": "testuser",
"user_id": 42,
"token": "tok123",
})
case r.Method == "GET" && r.URL.Path == "/users/me.json":
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"login": "testuser",
"name": "Test User",
"id": float64(42),
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
setupConfigDir(t, server.URL)
result, err := Login("testuser", "password")
if err != nil {
t.Fatalf("Login error: %v", err)
}
if result.Username != "testuser" {
t.Fatalf("Username = %q, want testuser", result.Username)
}
}
func TestLoginStatusError(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": float64(-1),
"message": "Invalid credentials",
})
}))
defer server.Close()
setupConfigDir(t, server.URL)
_, err := Login("testuser", "wrongpass")
if err == nil {
t.Fatal("expected error for invalid credentials")
}
}
func TestLoginNoCookies(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := t.TempDir()
t.Setenv("HOME", dir)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"username": "testuser",
"login": "testuser",
"user_id": 42,
})
}))
defer server.Close()
setupConfigDir(t, server.URL)
_, err := Login("testuser", "password")
if err == nil {
t.Fatal("expected error when no auth cookies")
}
}

View File

@ -4,6 +4,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/zalando/go-keyring" "github.com/zalando/go-keyring"
) )
@ -41,8 +42,7 @@ func DeleteToken() error {
// File-based fallback // File-based fallback
func credentialPath() string { func credentialPath() string {
home, _ := os.UserHomeDir() return filepath.Join(config.ConfigDir(), "credentials")
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
} }
func storeTokenFile(token string) error { func storeTokenFile(token string) error {
@ -62,5 +62,9 @@ func loadTokenFile() (string, error) {
} }
func deleteTokenFile() error { func deleteTokenFile() error {
return os.Remove(credentialPath()) err := os.Remove(credentialPath())
if os.IsNotExist(err) {
return nil
}
return err
} }

View File

@ -0,0 +1,168 @@
package auth
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/zalando/go-keyring"
)
func tempHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
return dir
}
func tempConfigDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
return dir
}
func TestStoreLoadDeleteTokenFile(t *testing.T) {
tempConfigDir(t)
// First, delete any existing token
_ = deleteTokenFile()
// Initially, loading should fail
_, err := loadTokenFile()
if err == nil {
t.Fatal("expected error loading non-existent token file")
}
// Store a token
if err := storeTokenFile("test-token-123"); err != nil {
t.Fatalf("storeTokenFile error: %v", err)
}
// Load it back
token, err := loadTokenFile()
if err != nil {
t.Fatalf("loadTokenFile error: %v", err)
}
if token != "test-token-123" {
t.Fatalf("token = %q, want test-token-123", token)
}
// Delete it
if err := deleteTokenFile(); err != nil {
t.Fatalf("deleteTokenFile error: %v", err)
}
// Now loading should fail again
_, err = loadTokenFile()
if err == nil {
t.Fatal("expected error after delete")
}
}
func TestCredentialPath(t *testing.T) {
dir := tempConfigDir(t)
got := credentialPath()
expected := filepath.Join(dir, "credentials")
if got != expected {
t.Fatalf("credentialPath = %q, want %q", got, expected)
}
}
func TestCredentialPathDefaultsToConfigDir(t *testing.T) {
home := tempHome(t)
t.Setenv("GITLINK_CONFIG_DIR", "")
got := credentialPath()
expected := filepath.Join(home, ".config", "gitlink-cli", "credentials")
if got != expected {
t.Fatalf("credentialPath = %q, want %q", got, expected)
}
}
func TestStoreTokenFileCreatesDir(t *testing.T) {
dir := tempConfigDir(t)
_ = deleteTokenFile()
// Config dir shouldn't exist yet
credDir := dir
os.RemoveAll(credDir)
if err := storeTokenFile("new-token"); err != nil {
t.Fatalf("storeTokenFile error: %v", err)
}
// Verify file exists and has content
data, err := os.ReadFile(filepath.Join(credDir, "credentials"))
if err != nil {
t.Fatalf("read error: %v", err)
}
if string(data) != "new-token" {
t.Fatalf("file content = %q, want new-token", string(data))
}
}
func TestDeleteTokenFileNonExistent(t *testing.T) {
tempConfigDir(t)
_ = deleteTokenFile()
// Logout-style cleanup should be idempotent when fallback credentials do not exist.
if err := deleteTokenFile(); err != nil {
t.Fatalf("deleteTokenFile error: %v", err)
}
}
func TestStoreLoadTokenFileEmpty(t *testing.T) {
tempConfigDir(t)
_ = deleteTokenFile()
if err := storeTokenFile(""); err != nil {
t.Fatalf("storeTokenFile empty: %v", err)
}
token, err := loadTokenFile()
if err != nil {
t.Fatalf("loadTokenFile error: %v", err)
}
if token != "" {
t.Fatalf("token = %q, want empty", token)
}
}
func TestStoreTokenFallback(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := tempConfigDir(t)
_ = deleteTokenFile()
if err := StoreToken("keychain-fallback-token"); err != nil {
t.Fatalf("StoreToken error: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "credentials"))
if err != nil {
t.Fatalf("read error: %v", err)
}
if string(data) != "keychain-fallback-token" {
t.Fatalf("file content = %q, want keychain-fallback-token", string(data))
}
}
func TestDeleteTokenFallback(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := tempConfigDir(t)
_ = deleteTokenFile()
p := filepath.Join(dir, "credentials")
os.MkdirAll(filepath.Dir(p), 0700)
os.WriteFile(p, []byte("delete-me"), 0600)
if err := DeleteToken(); err != nil {
t.Fatalf("DeleteToken error: %v", err)
}
_, err := os.Stat(p)
if !os.IsNotExist(err) {
t.Fatal("file should be deleted")
}
}

View File

@ -0,0 +1,239 @@
package auth
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestTransportCookieAuth(t *testing.T) {
// Mock an HTTP server that checks for the Cookie header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie := r.Header.Get("Cookie")
if cookie == "" {
t.Error("expected Cookie header")
}
// Verify the request has the right Accept header
if r.Header.Get("Accept") != "application/json" {
t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept"))
}
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
transport := &Transport{Base: http.DefaultTransport}
client := &http.Client{Transport: transport}
// Set a cookie-based token
os.Setenv("GITLINK_TOKEN", "cookie:autologin_trustie=test123")
defer os.Unsetenv("GITLINK_TOKEN")
req, _ := http.NewRequest("GET", server.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestTransportTokenAuth(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("access_token") == "" {
t.Error("expected access_token query parameter")
}
if r.Header.Get("Accept") != "application/json" {
t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept"))
}
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
transport := &Transport{Base: http.DefaultTransport}
client := &http.Client{Transport: transport}
os.Setenv("GITLINK_TOKEN", "private-token-abc")
defer os.Unsetenv("GITLINK_TOKEN")
req, _ := http.NewRequest("GET", server.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestTransportCookieAppend(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie := r.Header.Get("Cookie")
if cookie == "" {
t.Error("expected Cookie header")
}
// Should contain both original and injected cookies
if cookie != "existing=val; autologin_trustie=injected" {
t.Errorf("Cookie = %q, want 'existing=val; autologin_trustie=injected'", cookie)
}
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
transport := &Transport{Base: http.DefaultTransport}
client := &http.Client{Transport: transport}
os.Setenv("GITLINK_TOKEN", "cookie:autologin_trustie=injected")
defer os.Unsetenv("GITLINK_TOKEN")
req, _ := http.NewRequest("GET", server.URL, nil)
req.Header.Set("Cookie", "existing=val")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
}
func TestTransportNoToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") != "application/json" {
t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept"))
}
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
transport := &Transport{Base: http.DefaultTransport}
client := &http.Client{Transport: transport}
// Force empty env and redirect HOME to avoid keychain fallback
os.Unsetenv("GITLINK_TOKEN")
oldHome := os.Getenv("HOME")
tempHome := t.TempDir()
os.Setenv("HOME", tempHome)
defer os.Setenv("HOME", oldHome)
req, _ := http.NewRequest("GET", server.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
}
func TestTransportDefaultContentType(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("Content-Type = %q, want application/json", r.Header.Get("Content-Type"))
}
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
transport := &Transport{Base: http.DefaultTransport}
client := &http.Client{Transport: transport}
// Transport only sets Content-Type when Body is non-nil
body := strings.NewReader(`{"key":"val"}`)
req, _ := http.NewRequest("POST", server.URL, body)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
}
func TestTransportExplicitContentType(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Should preserve explicit Content-Type
if r.Header.Get("Content-Type") != "text/plain" {
t.Errorf("Content-Type = %q, want text/plain", r.Header.Get("Content-Type"))
}
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
transport := &Transport{Base: http.DefaultTransport}
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("POST", server.URL, nil)
req.Header.Set("Content-Type", "text/plain")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
}
func TestNewHTTPClient(t *testing.T) {
client := NewHTTPClient()
if client == nil {
t.Fatal("expected non-nil client")
}
if client.Transport == nil {
t.Fatal("expected Transport to be set")
}
if _, ok := client.Transport.(*Transport); !ok {
t.Fatalf("expected *Transport, got %T", client.Transport)
}
}
func TestTransportEnvVarPriority(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("access_token") != "env-token" {
t.Errorf("access_token = %q, want env-token", r.URL.Query().Get("access_token"))
}
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
transport := &Transport{Base: http.DefaultTransport}
client := &http.Client{Transport: transport}
os.Setenv("GITLINK_TOKEN", "env-token")
defer os.Unsetenv("GITLINK_TOKEN")
req, _ := http.NewRequest("GET", server.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
}
func TestTransportNilBase(t *testing.T) {
// When Base is nil, it should use http.DefaultTransport
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
transport := &Transport{Base: nil}
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", server.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
}

View File

@ -42,19 +42,21 @@ func New() (*Client, error) {
} }
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
path = normalizeAPIPath(c.BaseURL, path)
// Append .json suffix if not already present (GitLink API convention) // Append .json suffix if not already present (GitLink API convention)
// Handle paths that may already contain query strings (e.g., /path?key=val) // Handle paths that may already contain query strings (e.g., /path?key=val)
if idx := strings.Index(path, "?"); idx != -1 { if idx := strings.Index(path, "?"); idx != -1 {
basePath := path[:idx] basePath := path[:idx]
queryStr := path[idx:] queryStr := path[idx:]
if !strings.HasSuffix(basePath, ".json") { if shouldAppendJSONSuffix(basePath) {
path = basePath + ".json" + queryStr path = basePath + ".json" + queryStr
} }
} else if !strings.HasSuffix(path, ".json") { } else if shouldAppendJSONSuffix(path) {
path += ".json" path += ".json"
} }
fullURL := c.BaseURL + path fullURL := c.BaseURL + path
if query != nil && len(query) > 0 { if len(query) > 0 {
sep := "?" sep := "?"
if strings.Contains(fullURL, "?") { if strings.Contains(fullURL, "?") {
sep = "&" sep = "&"
@ -158,6 +160,31 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
return output.SuccessEnvelope(raw, meta), nil return output.SuccessEnvelope(raw, meta), nil
} }
func shouldAppendJSONSuffix(path string) bool {
if strings.HasSuffix(path, ".json") {
return false
}
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range parts {
if part == "raw" && i >= 2 && i+2 < len(parts) {
return false
}
}
return true
}
func normalizeAPIPath(baseURL, path string) string {
if strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api") {
switch {
case path == "/api":
return ""
case strings.HasPrefix(path, "/api/"):
return strings.TrimPrefix(path, "/api")
}
}
return path
}
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) { func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
return c.Do("GET", path, nil, query) return c.Do("GET", path, nil, query)
} }

View File

@ -0,0 +1,527 @@
package client
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
)
func TestAPIError(t *testing.T) {
err := &APIError{StatusCode: 404, Code: "not_found", Message: "PR not found"}
if err.Error() != "[not_found] PR not found" {
t.Fatalf("Error() = %q, want %q", err.Error(), "[not_found] PR not found")
}
}
func TestSuggestFix(t *testing.T) {
tests := []struct {
code int
want string
}{
{401, "请先运行 gitlink-cli auth login 登录"},
{403, "权限不足,请确认账户权限或联系项目管理员"},
{404, "资源不存在,请检查 owner/repo/id 是否正确"},
{422, "参数校验失败,请检查请求参数"},
{500, ""},
{0, ""},
}
for _, tt := range tests {
t.Run(http.StatusText(tt.code), func(t *testing.T) {
if got := suggestFix(tt.code); got != tt.want {
t.Fatalf("suggestFix(%d) = %q, want %q", tt.code, got, tt.want)
}
})
}
}
func TestClientDoSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true,"data":{"key":"value"}}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Do("GET", "/api/test", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !env.OK {
t.Fatal("expected OK=true")
}
}
func TestClientDoJSONSuffix(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/test.json" {
t.Fatalf("expected path /api/test.json, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Do("GET", "/api/test", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestClientDoJSONSuffixPreserved(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/test.json" {
t.Fatalf("expected path /api/test.json, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Do("GET", "/api/test.json", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestClientDoQueryParams(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("state") != "open" {
t.Fatalf("expected state=open, got %s", r.URL.Query().Get("state"))
}
if r.URL.Query().Get("page") != "1" {
t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
q := url.Values{}
q.Set("state", "open")
q.Set("page", "1")
_, err := c.Do("GET", "/api/test", nil, q)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestClientDoHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Do("GET", "/api/test", nil, nil)
if err == nil {
t.Fatal("expected error for 404")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 404 {
t.Fatalf("StatusCode = %d, want 404", apiErr.StatusCode)
}
}
func TestClientDoNonJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("plain text response"))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Do("GET", "/api/test", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !env.OK {
t.Fatal("expected OK=true for non-JSON response")
}
}
func TestClientDoStatusError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":403,"message":"Forbidden"}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Do("GET", "/api/test", nil, nil)
if err == nil {
t.Fatal("expected error for status=403")
}
if env == nil {
t.Fatal("expected envelope for status error")
}
if env.OK {
t.Fatal("expected OK=false for status error")
}
}
func TestClientDoStatusZero(t *testing.T) {
// status=0, 200, 1 are treated as success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":0,"data":"ok"}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Do("GET", "/api/test", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !env.OK {
t.Fatal("expected OK=true for status=0")
}
}
func TestClientDoPaginationMeta(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"total_count":100,"page":1,"limit":20,"data":[]}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Do("GET", "/api/test", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if env.Meta == nil {
t.Fatal("expected Meta to be populated")
}
if env.Meta.TotalCount != 100 {
t.Fatalf("TotalCount = %d, want 100", env.Meta.TotalCount)
}
if env.Meta.Page != 1 {
t.Fatalf("Page = %d, want 1", env.Meta.Page)
}
if env.Meta.Limit != 20 {
t.Fatalf("Limit = %d, want 20", env.Meta.Limit)
}
}
func TestClientDoPathWithQuery(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The path query param should be preserved
if r.URL.Query().Get("filepath") != "test.go" {
t.Fatalf("expected filepath=test.go, got %s", r.URL.Query().Get("filepath"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Do("GET", "/api/sub_entries?filepath=test.go", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestClientDoPathWithQueryAndExtraParams(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("ref") != "master" {
t.Fatalf("expected ref=master, got %s", r.URL.Query().Get("ref"))
}
if r.URL.Query().Get("filepath") != "test.go" {
t.Fatalf("expected filepath=test.go, got %s", r.URL.Query().Get("filepath"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
q := url.Values{}
q.Set("ref", "master")
_, err := c.Do("GET", "/api/sub_entries?filepath=test.go", nil, q)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestClientDoWithBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":123}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Do("POST", "/api/create", map[string]string{"title": "test"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !env.OK {
t.Fatal("expected OK=true")
}
}
func TestClientGet(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Get("/api/test", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestClientPost(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Post("/api/test", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestClientPut(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
t.Fatalf("expected PUT, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Put("/api/test", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestClientDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Fatalf("expected DELETE, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Delete("/api/test", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestClientDoInvalidURL(t *testing.T) {
c := &Client{HTTP: &http.Client{}, BaseURL: "://invalid"}
_, err := c.Do("GET", "/api/test", nil, nil)
if err == nil {
t.Fatal("expected error for invalid URL")
}
}
func TestClientDebug(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL, Debug: true}
_, err := c.Do("GET", "/api/test", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestClientDoStatusInt(t *testing.T) {
// Some APIs return status as int, not float64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":404,"message":"Not Found"}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Do("GET", "/api/test", nil, nil)
if err == nil {
t.Fatal("expected error for status=404 (int)")
}
}
func TestClientNew(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
cfgPath := filepath.Join(dir, "config.yaml")
os.WriteFile(cfgPath, []byte("base_url: https://gitlink.example.com/api/v1\n"), 0644)
cli, err := New()
if err != nil {
t.Fatalf("New error: %v", err)
}
if cli.BaseURL != "https://gitlink.example.com/api/v1" {
t.Fatalf("BaseURL = %q", cli.BaseURL)
}
if cli.HTTP == nil {
t.Fatal("HTTP client is nil")
}
}
func TestPaginateAllSinglePage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data":[{"id":1},{"id":2}]}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
items, err := c.PaginateAll("/test", nil)
if err != nil {
t.Fatalf("PaginateAll error: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
}
func TestPaginateAllMultiPage(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
page := r.URL.Query().Get("page")
if page == "1" {
w.Write([]byte(`{"data":[{"id":1},{"id":2}]}`))
} else {
w.Write([]byte(`{"data":[{"id":3}]}`))
}
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
params := url.Values{}
params.Set("limit", "2")
items, err := c.PaginateAll("/test", params)
if err != nil {
t.Fatalf("PaginateAll error: %v", err)
}
if len(items) != 3 {
t.Fatalf("expected 3 items, got %d", len(items))
}
if callCount != 2 {
t.Fatalf("expected 2 API calls, got %d", callCount)
}
}
func TestPaginateAllWrappedData(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data":[{"id":1},{"id":2}],"total_count":2}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
items, err := c.PaginateAll("/test", nil)
if err != nil {
t.Fatalf("PaginateAll error: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
}
func TestPaginateAllSingleObject(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"name":"single-object"}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
items, err := c.PaginateAll("/test", nil)
if err != nil {
t.Fatalf("PaginateAll error: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
var data map[string]interface{}
json.Unmarshal(items[0], &data)
if data["name"] != "single-object" {
t.Fatalf("unexpected data: %v", data)
}
}
func TestPaginateAllHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.PaginateAll("/test", nil)
if err == nil {
t.Fatal("expected error for 500 response")
}
}
func TestPaginateAllNotOK(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":500,"message":"error"}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.PaginateAll("/test", nil)
if err == nil {
t.Fatal("expected error when envelope ok=false")
}
}
func TestShouldAppendJSONSuffixSkipsRawFilePath(t *testing.T) {
if shouldAppendJSONSuffix("/Gitlink/forgeplus/raw/master/README.md") {
t.Fatal("raw file path should not get .json suffix")
}
}
func TestShouldAppendJSONSuffixKeepsRawRepositoryName(t *testing.T) {
if !shouldAppendJSONSuffix("/users/raw/projects") {
t.Fatal("regular API path should get .json suffix")
}
}
func TestShouldAppendJSONSuffixSkipsExistingJSONPath(t *testing.T) {
if shouldAppendJSONSuffix("/projects.json") {
t.Fatal("existing .json path should not get another suffix")
}
}

View File

@ -17,6 +17,7 @@ type Config struct {
Format string `yaml:"default_format"` Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"` Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"` Pager string `yaml:"pager,omitempty"`
Lang string `yaml:"lang,omitempty"`
} }
func DefaultConfig() *Config { func DefaultConfig() *Config {
@ -85,6 +86,8 @@ func Get(key string) (string, error) {
return cfg.Editor, nil return cfg.Editor, nil
case "pager": case "pager":
return cfg.Pager, nil return cfg.Pager, nil
case "lang":
return cfg.Lang, nil
default: default:
return "", nil return "", nil
} }
@ -104,6 +107,8 @@ func Set(key, value string) error {
cfg.Editor = value cfg.Editor = value
case "pager": case "pager":
cfg.Pager = value cfg.Pager = value
case "lang":
cfg.Lang = value
} }
return Save(cfg) return Save(cfg)
} }

View File

@ -0,0 +1,225 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func tempConfigDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
return dir
}
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
if cfg.BaseURL != DefaultBaseURL {
t.Fatalf("BaseURL = %q, want %q", cfg.BaseURL, DefaultBaseURL)
}
if cfg.Format != DefaultFormat {
t.Fatalf("Format = %q, want %q", cfg.Format, DefaultFormat)
}
}
func TestConfigDirEnv(t *testing.T) {
dir := tempConfigDir(t)
if got := ConfigDir(); got != dir {
t.Fatalf("ConfigDir = %q, want %q", got, dir)
}
}
func TestConfigDirDefault(t *testing.T) {
// Without GITLINK_CONFIG_DIR set, should use $HOME/.config/gitlink-cli
t.Setenv("GITLINK_CONFIG_DIR", "")
got := ConfigDir()
home, _ := os.UserHomeDir()
if !strings.Contains(got, ".config") && !strings.Contains(got, "gitlink-cli") {
t.Fatalf("ConfigDir = %q, expected path under home", got)
}
if home != "" && !strings.HasPrefix(got, home) {
t.Fatalf("ConfigDir = %q, expected to start with home %q", got, home)
}
}
func TestConfigPath(t *testing.T) {
dir := tempConfigDir(t)
got := ConfigPath()
want := filepath.Join(dir, "config.yaml")
if got != want {
t.Fatalf("ConfigPath = %q, want %q", got, want)
}
}
func TestLoadAndSave(t *testing.T) {
tempConfigDir(t)
cfg := DefaultConfig()
cfg.BaseURL = "https://custom.example.com/api"
cfg.Format = "json"
cfg.Editor = "vim"
cfg.Pager = "less"
if err := Save(cfg); err != nil {
t.Fatalf("Save error: %v", err)
}
loaded, err := Load()
if err != nil {
t.Fatalf("Load error: %v", err)
}
if loaded.BaseURL != "https://custom.example.com/api" {
t.Fatalf("BaseURL = %q", loaded.BaseURL)
}
if loaded.Format != "json" {
t.Fatalf("Format = %q", loaded.Format)
}
if loaded.Editor != "vim" {
t.Fatalf("Editor = %q", loaded.Editor)
}
if loaded.Pager != "less" {
t.Fatalf("Pager = %q", loaded.Pager)
}
}
func TestLoadDefaultsWhenFileMissing(t *testing.T) {
tempConfigDir(t)
// No config file exists
cfg, err := Load()
if err != nil {
t.Fatalf("Load error: %v", err)
}
if cfg.BaseURL != DefaultBaseURL {
t.Fatalf("BaseURL = %q, want default", cfg.BaseURL)
}
if cfg.Format != DefaultFormat {
t.Fatalf("Format = %q, want default", cfg.Format)
}
}
func TestLoadEmptyValuesFallbackToDefaults(t *testing.T) {
dir := tempConfigDir(t)
// Write config with empty values
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("base_url: \"\"\ndefault_format: \"\"\n"), 0600); err != nil {
t.Fatalf("write error: %v", err)
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load error: %v", err)
}
if cfg.BaseURL != DefaultBaseURL {
t.Fatalf("BaseURL = %q, want default", cfg.BaseURL)
}
if cfg.Format != DefaultFormat {
t.Fatalf("Format = %q, want default", cfg.Format)
}
}
func TestGet(t *testing.T) {
tempConfigDir(t)
cfg := DefaultConfig()
cfg.BaseURL = "https://get.example.com/api"
if err := Save(cfg); err != nil {
t.Fatalf("Save error: %v", err)
}
tests := []struct {
key string
want string
}{
{"base_url", "https://get.example.com/api"},
{"default_format", "table"},
{"editor", ""},
{"pager", ""},
{"unknown_key", ""},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
got, err := Get(tt.key)
if err != nil {
t.Fatalf("Get error: %v", err)
}
if got != tt.want {
t.Fatalf("Get(%q) = %q, want %q", tt.key, got, tt.want)
}
})
}
}
func TestSet(t *testing.T) {
tempConfigDir(t)
// First save defaults
if err := Save(DefaultConfig()); err != nil {
t.Fatalf("Save error: %v", err)
}
if err := Set("base_url", "https://set.example.com/api"); err != nil {
t.Fatalf("Set base_url error: %v", err)
}
if err := Set("editor", "nano"); err != nil {
t.Fatalf("Set editor error: %v", err)
}
// Verify Get reads updated values
baseURL, _ := Get("base_url")
if baseURL != "https://set.example.com/api" {
t.Fatalf("Get base_url = %q", baseURL)
}
editor, _ := Get("editor")
if editor != "nano" {
t.Fatalf("Get editor = %q", editor)
}
// default_format should still be default
format, _ := Get("default_format")
if format != DefaultFormat {
t.Fatalf("Get default_format = %q", format)
}
}
func TestSetUnknownKey(t *testing.T) {
tempConfigDir(t)
if err := Save(DefaultConfig()); err != nil {
t.Fatalf("Save error: %v", err)
}
// Setting unknown key should not error, just silently ignored
if err := Set("nonexistent", "value"); err != nil {
t.Fatalf("Set nonexistent error: %v", err)
}
}
func TestSaveCreatesDir(t *testing.T) {
// Use a subdirectory that doesn't exist yet
dir := filepath.Join(t.TempDir(), "new", "subdir")
t.Setenv("GITLINK_CONFIG_DIR", dir)
cfg := DefaultConfig()
cfg.BaseURL = "https://test.example.com/api"
if err := Save(cfg); err != nil {
t.Fatalf("Save error: %v", err)
}
// Verify it was actually saved
loaded, err := Load()
if err != nil {
t.Fatalf("Load error: %v", err)
}
if loaded.BaseURL != "https://test.example.com/api" {
t.Fatalf("BaseURL = %q", loaded.BaseURL)
}
}
func TestLoadInvalidYAML(t *testing.T) {
dir := tempConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("::: invalid yaml :::"), 0600); err != nil {
t.Fatalf("write error: %v", err)
}
_, err := Load()
if err == nil {
t.Fatal("expected error for invalid YAML")
}
}

View File

@ -0,0 +1,132 @@
package context
import (
"testing"
)
func TestParseRemoteURLHTTPS(t *testing.T) {
owner, repo, err := parseRemoteURL("https://www.gitlink.org.cn/Gitlink/gitlink-cli.git")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "Gitlink" {
t.Fatalf("owner = %q, want Gitlink", owner)
}
if repo != "gitlink-cli" {
t.Fatalf("repo = %q, want gitlink-cli", repo)
}
}
func TestParseRemoteURLHTTPSNoGit(t *testing.T) {
owner, repo, err := parseRemoteURL("https://www.gitlink.org.cn/owner/repo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "owner" || repo != "repo" {
t.Fatalf("got %s/%s, want owner/repo", owner, repo)
}
}
func TestParseRemoteURLSSH(t *testing.T) {
owner, repo, err := parseRemoteURL("git@www.gitlink.org.cn:Gitlink/gitlink-cli.git")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "Gitlink" {
t.Fatalf("owner = %q, want Gitlink", owner)
}
if repo != "gitlink-cli" {
t.Fatalf("repo = %q, want gitlink-cli", repo)
}
}
func TestParseRemoteURLSSHNoSuffix(t *testing.T) {
owner, repo, err := parseRemoteURL("git@gitlink.org.cn:owner/repo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "owner" || repo != "repo" {
t.Fatalf("got %s/%s, want owner/repo", owner, repo)
}
}
func TestParseRemoteURLInvalidSSH(t *testing.T) {
_, _, err := parseRemoteURL("git@gitlink.org.cn")
if err == nil {
t.Fatal("expected error for invalid SSH URL")
}
}
func TestParseRemoteURLInvalidHTTPS(t *testing.T) {
_, _, err := parseRemoteURL("://invalid-url")
if err == nil {
t.Fatal("expected error for invalid HTTPS URL")
}
}
func TestParsePathSegments(t *testing.T) {
tests := []struct {
name string
path string
wantOwner string
wantRepo string
wantErr bool
}{
{"basic", "owner/repo", "owner", "repo", false},
{"with git", "owner/repo.git", "owner", "repo", false},
{"leading slash", "/owner/repo", "owner", "repo", false},
{"both", "/owner/repo.git", "owner", "repo", false},
{"with subpath", "owner/repo/sub", "owner", "repo", false},
{"single segment", "onlyowner", "", "", true},
{"empty", "", "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
owner, repo, err := parsePathSegments(tt.path)
if tt.wantErr && err == nil {
t.Fatal("expected error")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != tt.wantOwner || repo != tt.wantRepo {
t.Fatalf("got %s/%s, want %s/%s", owner, repo, tt.wantOwner, tt.wantRepo)
}
})
}
}
func TestResolveOwnerRepoExplicit(t *testing.T) {
owner, repo, err := ResolveOwnerRepo("explicitOwner", "explicitRepo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "explicitOwner" || repo != "explicitRepo" {
t.Fatalf("got %s/%s, want explicitOwner/explicitRepo", owner, repo)
}
}
func TestResolveOwnerRepoPartialFlagsInGitRepo(t *testing.T) {
// When in a git repo, partial flags use git remote for the missing part.
owner, repo, err := ResolveOwnerRepo("", "partialRepo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner == "" {
t.Fatal("expected owner to be resolved from git remote")
}
if repo != "partialRepo" {
t.Fatalf("repo = %q, want partialRepo", repo)
}
owner, repo, err = ResolveOwnerRepo("partialOwner", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "partialOwner" {
t.Fatalf("owner = %q, want partialOwner", owner)
}
if repo == "" {
t.Fatal("expected repo to be resolved from git remote")
}
}

4
internal/i18n/args.go Normal file
View File

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

View File

@ -0,0 +1,164 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
)
func main() {
fix := flag.Bool("fix", false, "format locale JSON files")
scanCode := flag.Bool("scan-code", false, "scan Go source for referenced i18n keys")
flag.Parse()
problems, err := i18n.Validate(i18n.NewEmbedLoader(), "en-US")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if len(problems) > 0 {
for _, problem := range problems {
fmt.Fprintln(os.Stderr, problem.String())
}
os.Exit(1)
}
if err := checkLocaleFormat(*fix); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if *scanCode {
if err := checkCodeReferences(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
fmt.Println("i18n messages are valid")
}
func checkLocaleFormat(fix bool) error {
// #nosec G703 -- pattern is repo-local and not user-controlled.
files, err := filepath.Glob(filepath.Join("internal", "i18n", "locales", "*.json"))
if err != nil {
return err
}
for _, path := range files {
data, err := os.ReadFile(path)
if err != nil {
return err
}
formatted, err := formatJSON(data)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
if string(data) == string(formatted) {
continue
}
if fix {
if err := os.WriteFile(path, formatted, 0600); err != nil {
return err
}
continue
}
return fmt.Errorf("%s: locale JSON is not formatted; run go run ./internal/i18n/cmd/check --fix", path)
}
return nil
}
func formatJSON(data []byte) ([]byte, error) {
var messages map[string]string
if err := json.Unmarshal(data, &messages); err != nil {
return nil, err
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(messages); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func checkCodeReferences() error {
loader := i18n.NewEmbedLoader()
base, err := loader.Load("en-US")
if err != nil {
return err
}
used, defaultUses, err := scanCodeKeys([]string{"cmd", "shortcuts", "internal"})
if err != nil {
return err
}
var missing []string
for key := range used {
if _, ok := base[key]; !ok {
missing = append(missing, key)
}
}
sort.Strings(missing)
if len(missing) > 0 {
return fmt.Errorf("missing i18n key references: %s", strings.Join(missing, ", "))
}
for _, item := range defaultUses {
fmt.Fprintf(os.Stderr, "warning: avoid new i18n.Default() usage at %s\n", item)
}
return nil
}
func scanCodeKeys(roots []string) (map[string]struct{}, []string, error) {
keyPattern := regexp.MustCompile(`(?:tr|ctx\.Tr|i18n\.Default\(\))\.T(?:f)?\("([^"]+)"`)
defaultPattern := regexp.MustCompile(`i18n\.Default\(\)\.T(?:f)?\("([^"]+)"`)
used := map[string]struct{}{}
var defaultUses []string
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
if strings.Contains(filepath.ToSlash(path), "internal/i18n/locales") {
return filepath.SkipDir
}
return nil
}
if entry.Type()&os.ModeSymlink != 0 {
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
if !entry.Type().IsRegular() {
return nil
}
data, err := os.ReadFile(path) // #nosec G122 -- dev-only scan over repo roots; symlinks are skipped above.
if err != nil {
return err
}
text := string(data)
for _, match := range keyPattern.FindAllStringSubmatch(text, -1) {
used[match[1]] = struct{}{}
}
for _, match := range defaultPattern.FindAllStringSubmatchIndex(text, -1) {
line := 1 + strings.Count(text[:match[0]], "\n")
defaultUses = append(defaultUses, fmt.Sprintf("%s:%d", filepath.ToSlash(path), line))
}
return nil
})
if err != nil {
return nil, nil, err
}
}
sort.Strings(defaultUses)
return used, defaultUses, nil
}

5
internal/i18n/doc.go Normal file
View File

@ -0,0 +1,5 @@
// Package i18n provides localized user-facing messages for the CLI.
//
// It intentionally does not localize machine-readable output such as JSON
// field names, API response bodies, or debug diagnostics.
package i18n

66
internal/i18n/loader.go Normal file
View File

@ -0,0 +1,66 @@
package i18n
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"path/filepath"
"sort"
"strings"
)
//go:embed locales/*.json
var embeddedLocales embed.FS
// Loader loads locale messages from a backing store.
type Loader interface {
Load(locale string) (map[string]string, error)
AvailableLocales() ([]string, error)
}
type embedLoader struct {
fs fs.FS
}
// NewEmbedLoader returns the default loader backed by embedded locale files.
func NewEmbedLoader() Loader {
return embedLoader{fs: embeddedLocales}
}
func (l embedLoader) Load(locale string) (map[string]string, error) {
locale = NormalizeLocale(locale)
path := filepath.ToSlash(filepath.Join("locales", locale+".json"))
data, err := fs.ReadFile(l.fs, path)
if err != nil {
return nil, fmt.Errorf("load locale %s: %w", locale, err)
}
var messages map[string]string
if err := json.Unmarshal(data, &messages); err != nil {
return nil, fmt.Errorf("parse locale %s: %w", locale, err)
}
return messages, nil
}
func (l embedLoader) AvailableLocales() ([]string, error) {
entries, err := fs.ReadDir(l.fs, "locales")
if err != nil {
return nil, err
}
locales := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
locales = append(locales, strings.TrimSuffix(entry.Name(), ".json"))
}
sort.Strings(locales)
return locales, nil
}
// AvailableLocales returns locales available from the embedded loader.
func AvailableLocales() ([]string, error) {
return NewEmbedLoader().AvailableLocales()
}

100
internal/i18n/locale.go Normal file
View File

@ -0,0 +1,100 @@
package i18n
import (
"strings"
)
// NormalizeLocale converts common locale spellings to a stable BCP-47-like form.
func NormalizeLocale(locale string) string {
locale = strings.TrimSpace(locale)
if locale == "" {
return ""
}
if idx := strings.IndexByte(locale, '.'); idx >= 0 {
locale = locale[:idx]
}
locale = strings.ReplaceAll(locale, "_", "-")
parts := strings.Split(locale, "-")
normalized := make([]string, 0, len(parts))
for i, part := range parts {
if part == "" {
continue
}
switch {
case i == 0:
normalized = append(normalized, strings.ToLower(part))
case len(part) == 2:
normalized = append(normalized, strings.ToUpper(part))
case len(part) == 4:
normalized = append(normalized, strings.ToUpper(part[:1])+strings.ToLower(part[1:]))
default:
normalized = append(normalized, part)
}
}
return strings.Join(normalized, "-")
}
// MatchLocale resolves requested to one of available using exact, safe alias,
// then fallback matching.
func MatchLocale(requested string, available []string, fallback string) string {
return matchLocale(requested, available, fallback).Locale
}
type localeMatch struct {
Locale string
Requested string
Fallbacked bool
Supported bool
}
func matchLocale(requested string, available []string, fallback string) localeMatch {
fallback = NormalizeLocale(fallback)
if fallback == "" {
fallback = defaultFallbackLocale
}
if len(available) == 0 {
return localeMatch{Locale: fallback, Requested: NormalizeLocale(requested), Fallbacked: true}
}
byLocale := make(map[string]string, len(available))
for _, locale := range available {
normalized := NormalizeLocale(locale)
byLocale[normalized] = normalized
}
candidate := NormalizeLocale(requested)
if candidate != "" {
if matched, ok := byLocale[candidate]; ok {
return localeMatch{Locale: matched, Requested: candidate, Supported: true}
}
if alias := localeAlias(candidate); alias != "" {
if matched, ok := byLocale[alias]; ok {
return localeMatch{Locale: matched, Requested: candidate, Supported: true}
}
}
}
if matched, ok := byLocale[fallback]; ok {
return localeMatch{Locale: matched, Requested: candidate, Fallbacked: candidate != "", Supported: false}
}
return localeMatch{Locale: NormalizeLocale(available[0]), Requested: candidate, Fallbacked: candidate != "", Supported: false}
}
func primaryLanguage(locale string) string {
if idx := strings.IndexByte(locale, '-'); idx >= 0 {
return locale[:idx]
}
return locale
}
func localeAlias(locale string) string {
switch {
case locale == "zh" || locale == "zh-CN" || locale == "zh-Hans" || locale == "zh-Hans-CN":
return "zh-CN"
case primaryLanguage(locale) == "en":
return "en-US"
default:
return ""
}
}

View File

@ -0,0 +1,35 @@
package i18n
import "testing"
func TestNormalizeLocale(t *testing.T) {
cases := map[string]string{
"zh_CN": "zh-CN",
"zh_CN.UTF-8": "zh-CN",
"zh-Hans-CN": "zh-Hans-CN",
"EN_us": "en-US",
" en-US ": "en-US",
"zh-hans-cn.utf": "zh-Hans-CN",
}
for input, want := range cases {
if got := NormalizeLocale(input); got != want {
t.Fatalf("NormalizeLocale(%q) = %q, want %q", input, got, want)
}
}
}
func TestMatchLocale(t *testing.T) {
available := []string{"en-US", "zh-CN"}
cases := map[string]string{
"zh_CN": "zh-CN",
"zh-Hans-CN": "zh-CN",
"zh": "zh-CN",
"en": "en-US",
"fr-FR": "en-US",
}
for input, want := range cases {
if got := MatchLocale(input, available, "en-US"); got != want {
t.Fatalf("MatchLocale(%q) = %q, want %q", input, got, want)
}
}
}

View File

@ -0,0 +1,229 @@
{
"cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.",
"cmd.api.short": "Make raw API requests to GitLink",
"cmd.auth.login.short": "Login to GitLink",
"cmd.auth.logout.short": "Logout from GitLink",
"cmd.auth.short": "Authentication commands",
"cmd.auth.status.short": "Show authentication status",
"cmd.branch.create.short": "Create a branch",
"cmd.branch.delete.short": "Delete a branch",
"cmd.branch.list.short": "List branches",
"cmd.branch.protect.short": "Set branch protection",
"cmd.branch.short": "Branch operations",
"cmd.branch.unprotect.short": "Remove branch protection",
"cmd.ci.builds.short": "List CI builds",
"cmd.ci.logs.short": "View build logs",
"cmd.ci.restart.short": "Restart a build",
"cmd.ci.short": "CI/CD operations",
"cmd.ci.stop.short": "Stop a build",
"cmd.config.get.short": "Get a configuration value",
"cmd.config.init.short": "Initialize configuration file",
"cmd.config.list.short": "List all configuration values",
"cmd.config.set.short": "Set a configuration value",
"cmd.config.short": "Manage gitlink-cli configuration",
"cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.",
"cmd.doctor.short": "Diagnose gitlink-cli environment problems",
"cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes",
"cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.",
"cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes",
"cmd.issue.batch_label.short": "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.",
"cmd.issue.batch_list.long": "List issue batch maintenance candidates without changing remote data.\n\nExamples:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json",
"cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data",
"cmd.issue.close.short": "Close an issue",
"cmd.issue.comment.short": "Add a comment to an issue",
"cmd.issue.create.short": "Create a new issue",
"cmd.issue.list.short": "List issues",
"cmd.issue.short": "Issue operations",
"cmd.issue.update.short": "Update an issue",
"cmd.issue.view.short": "View issue details",
"cmd.org.create.short": "Create an organization",
"cmd.org.info.short": "Show organization details",
"cmd.org.list.short": "List organizations",
"cmd.org.members.short": "List organization members",
"cmd.org.short": "Organization operations",
"cmd.pr.close.short": "Close a pull request",
"cmd.pr.comment.short": "Add a comment to a pull request",
"cmd.pr.create.short": "Create a pull request",
"cmd.pr.diff.short": "Show diff for a pull request",
"cmd.pr.files.short": "List changed files in a pull request",
"cmd.pr.list.short": "List pull requests",
"cmd.pr.merge.short": "Merge a pull request",
"cmd.pr.review.short": "Create a pull request review",
"cmd.pr.reviews.short": "List pull request reviews",
"cmd.pr.short": "Pull request operations",
"cmd.pr.version_diff.short": "Show diff for a pull request patchset version",
"cmd.pr.versions.short": "List pull request patchset versions",
"cmd.pr.view.short": "View pull request details",
"cmd.release.create.short": "Create a release",
"cmd.release.delete.short": "Delete a release",
"cmd.release.list.short": "List releases",
"cmd.release.short": "Release operations",
"cmd.release.view.short": "View release details",
"cmd.repo.create.short": "Create a new repository",
"cmd.repo.delete.short": "Delete a repository",
"cmd.repo.fork.short": "Fork a repository",
"cmd.repo.info.short": "Show repository details",
"cmd.repo.list.short": "List repositories for a user or organization",
"cmd.repo.short": "Repository operations",
"cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.",
"cmd.root.short": "GitLink CLI - command-line tool for GitLink",
"cmd.search.repos.short": "Search repositories",
"cmd.search.short": "Search operations",
"cmd.search.users.short": "Search users",
"cmd.user.info.short": "Show user profile",
"cmd.user.me.short": "Show current authenticated user",
"cmd.user.short": "User operations",
"cmd.version.short": "Print version information",
"cmd.webhook.create.short": "Create a repository webhook",
"cmd.webhook.delete.short": "Delete a repository webhook",
"cmd.webhook.list.short": "List repository webhooks",
"cmd.webhook.short": "Webhook operations",
"cmd.webhook.tasks.short": "List webhook delivery tasks",
"cmd.webhook.test.short": "Trigger a test delivery for a webhook",
"cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available",
"cmd.webhook.view.short": "View webhook details",
"error.auth.delete_token_failed": "failed to delete token: {message}",
"error.auth.login_failed": "login failed: {message}",
"error.auth.store_token_failed": "failed to store token: {message}",
"error.auth.token_empty": "token cannot be empty",
"error.config.save_failed": "failed to save config: {message}",
"error.missing_required_flag": "required flag --{name} is missing",
"error.unsupported_language": "unsupported language: {lang}",
"flag.api.body": "Request body (JSON string)",
"flag.api.body_file": "Read request body JSON from a file",
"flag.api.body_stdin": "Read request body JSON from stdin",
"flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure",
"flag.api.batch_dry_run": "Preview batch requests without sending remote requests",
"flag.api.batch_file": "Read an API batch plan from a JSON file",
"flag.api.batch_var": "Override a batch template variable (key=value, repeatable)",
"flag.api.header": "Additional headers (key:value)",
"flag.api.query": "Query parameters (key=val&key2=val2)",
"flag.auth.token": "Login by pasting an existing token",
"flag.branch.from": "Source branch or commit",
"flag.branch.name": "Branch name",
"flag.ci.build": "Build number",
"flag.ci.stage": "Stage number",
"flag.ci.step": "Step number",
"flag.comment.body": "Comment body",
"flag.debug": "Enable debug output",
"flag.description": "Description",
"flag.doctor.skip_network": "Skip authenticated API connectivity checks",
"flag.dry_run": "Preview the request without creating it",
"flag.format": "Output format: json, table, yaml (default: table)",
"flag.issue.add_label": "Label to add to each matching issue",
"flag.issue.assignee": "Assignee login",
"flag.issue.batch.reason": "Optional reason shown in the batch result",
"flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.",
"flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7",
"flag.issue.batch_close.state": "Filter by issue state before closing",
"flag.issue.batch_label.state": "Filter by issue state",
"flag.issue.batch_list.limit": "Maximum issues to return, capped at 100",
"flag.issue.batch_process.limit": "Maximum issues to process, capped at 100",
"flag.issue.assignee_id": "Assignee user ID",
"flag.issue.author_id": "Author user ID",
"flag.issue.body": "Issue description",
"flag.issue.label": "Label ID",
"flag.issue.label_filter": "Filter by existing label",
"flag.issue.milestone": "Milestone ID",
"flag.issue.new_body": "New description",
"flag.issue.new_state": "New state: open, closed, or numeric status_id",
"flag.issue.new_title": "New title",
"flag.issue.number": "Issue number (as shown in the web URL)",
"flag.issue.older_than_days": "Only include issues inactive for at least this many days",
"flag.issue.participant": "Participant filter: all, aboutme, authoredme, assignedme, atme",
"flag.issue.state": "Filter by state: open, closed, all",
"flag.issue.status_id": "Issue status ID",
"flag.issue.tag_ids": "Comma-separated issue tag IDs",
"flag.issue.title": "Issue title",
"flag.lang": "Display language",
"flag.limit": "Items per page",
"flag.org.id": "Organization ID",
"flag.org.id_or_login": "Organization ID or login",
"flag.org.name": "Organization name",
"flag.owner": "Repository owner (auto-detected from git remote)",
"flag.page": "Page number",
"flag.pr.assignee_id": "Assignee user ID",
"flag.pr.base": "Target branch",
"flag.pr.body": "PR description",
"flag.pr.file": "Filter diff by file path",
"flag.pr.head": "Source branch",
"flag.pr.id": "PR number",
"flag.pr.milestone_id": "Milestone ID",
"flag.pr.merge_method": "Merge method: merge, rebase, squash",
"flag.pr.priority_id": "Priority ID",
"flag.pr.review_commit": "Commit SHA to attach the review to",
"flag.pr.review_content": "Review content",
"flag.pr.reviewer_id": "Reviewer user ID",
"flag.pr.review_status": "Review status: common, approved, rejected",
"flag.pr.review_status_filter": "Filter review status: common, approved, rejected",
"flag.pr.state": "Filter: open, merged, closed",
"flag.pr.tag_id": "Issue tag ID",
"flag.pr.title": "PR title",
"flag.pr.version_id": "Patchset version ID",
"flag.release.body": "Release notes",
"flag.release.id": "Release ID",
"flag.release.id_or_tag": "Release ID or tag",
"flag.release.name": "Release name",
"flag.release.prerelease": "Mark as prerelease (true/false)",
"flag.release.tag": "Tag name",
"flag.release.target": "Target branch",
"flag.repo": "Repository name (auto-detected from git remote)",
"flag.repo.category": "Filter: manage/mirror/sync/fork/all (default: manage)",
"flag.repo.description": "Repository description",
"flag.repo.name": "Repository name",
"flag.repo.private": "Make repository private (true/false)",
"flag.search.keyword": "Search keyword",
"flag.sort_by": "Sort field",
"flag.sort_direction": "Sort direction: asc, desc",
"flag.user": "User login (default: current user)",
"flag.user.login": "User login name",
"flag.webhook.active": "Whether the webhook is active: true or false",
"flag.webhook.branch_filter": "Branch glob filter for push/create/delete events",
"flag.webhook.content_type": "Payload content type: json or form",
"flag.webhook.events": "Comma-separated events, for example: push,issues_only",
"flag.webhook.http_method": "HTTP method: POST or GET",
"flag.webhook.id": "Webhook ID",
"flag.webhook.secret": "Webhook secret",
"flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.",
"flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot",
"flag.webhook.url": "Webhook target URL",
"output.auth.env_hint": " Or set {env} environment variable",
"output.auth.login_hint": " Run: gitlink-cli auth login",
"output.config.file": "Config file: {path}",
"output.config.not_set": "(not set)",
"output.version": "gitlink-cli {version}",
"output.doctor.api_auth.config_skipped": "API authentication check skipped because the configuration file is invalid.",
"output.doctor.api_auth.failed": "Authenticated API request failed: {message}",
"output.doctor.api_auth.no_login": "Authenticated API response did not include a login field.",
"output.doctor.api_auth.ok": "Authenticated API request succeeded as {login}.",
"output.doctor.api_auth.skipped": "Authenticated API connectivity check skipped.",
"output.doctor.auth_token.env": "GITLINK_TOKEN environment variable is set.",
"output.doctor.auth_token.missing": "No stored token or GITLINK_TOKEN environment variable was found.",
"output.doctor.auth_token.stored": "Stored credentials were found.",
"output.doctor.config_file.invalid": "Configuration file exists but cannot be parsed: {message}",
"output.doctor.config_file.missing": "Configuration file was not found; built-in defaults will be used.",
"output.doctor.config_file.ok": "Configuration file is readable.",
"output.doctor.config_file.unreadable": "Configuration file cannot be read: {message}",
"output.doctor.config_values.bad_base_url": "base_url is invalid: {message}",
"output.doctor.config_values.bad_format": "default_format is {format}, expected json, table or yaml.",
"output.doctor.config_values.ok": "Configuration values are valid.",
"output.doctor.config_values.skipped": "Configuration value checks skipped because the configuration file is invalid.",
"output.doctor.repo_context.missing": "Repository context could not be resolved: {message}",
"output.doctor.repo_context.ok": "Repository context resolved to {owner}/{repo}.",
"output.doctor.suggestion.check_config_permissions": "Check file permissions for the gitlink-cli config directory.",
"output.doctor.suggestion.check_token": "Check whether the stored token is valid, or run gitlink-cli auth login again.",
"output.doctor.suggestion.fix_config_yaml": "Fix the YAML syntax in the gitlink-cli config file.",
"output.doctor.suggestion.pass_owner_repo": "Run the command with --owner and --repo when not inside a GitLink repository.",
"prompt.auth.password": "Password: ",
"prompt.auth.token": "Paste your access token: ",
"prompt.auth.username": "Username/Email/Phone: ",
"success.auth.logged_in_as": "✓ Logged in as {login}",
"success.auth.logged_in_via_env": "✓ Logged in via {env} environment variable",
"success.auth.logged_out": "✓ Logged out",
"success.auth.token_saved": "✓ Token saved",
"success.config.initialized": "✓ Config initialized at {path}",
"success.config.set": "✓ {key} = {value}",
"warning.auth.not_logged_in": "✗ Not logged in",
"warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})",
"warning.auth.user_unavailable": "✓ Token stored (user info unavailable)"
}

View File

@ -0,0 +1,229 @@
{
"cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。",
"cmd.api.short": "向 GitLink 发起原始 API 请求",
"cmd.auth.login.short": "登录 GitLink",
"cmd.auth.logout.short": "退出 GitLink 登录",
"cmd.auth.short": "认证命令",
"cmd.auth.status.short": "显示认证状态",
"cmd.branch.create.short": "创建分支",
"cmd.branch.delete.short": "删除分支",
"cmd.branch.list.short": "列出分支",
"cmd.branch.protect.short": "设置分支保护",
"cmd.branch.short": "分支操作",
"cmd.branch.unprotect.short": "移除分支保护",
"cmd.ci.builds.short": "列出 CI 构建",
"cmd.ci.logs.short": "查看构建日志",
"cmd.ci.restart.short": "重启构建",
"cmd.ci.short": "CI/CD 操作",
"cmd.ci.stop.short": "停止构建",
"cmd.config.get.short": "获取配置项",
"cmd.config.init.short": "初始化配置文件",
"cmd.config.list.short": "列出所有配置项",
"cmd.config.set.short": "设置配置项",
"cmd.config.short": "管理 gitlink-cli 配置",
"cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。",
"cmd.doctor.short": "诊断 gitlink-cli 环境问题",
"cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes",
"cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run传入 --yes 后执行。",
"cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes",
"cmd.issue.batch_label.short": "给筛选后的议题批量添加标签。默认 dry-run传入 --yes 后执行。",
"cmd.issue.batch_list.long": "列出议题批量维护候选项,不修改远端数据。\n\n示例\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json",
"cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据",
"cmd.issue.close.short": "关闭议题",
"cmd.issue.comment.short": "给议题添加评论",
"cmd.issue.create.short": "创建新议题",
"cmd.issue.list.short": "列出议题",
"cmd.issue.short": "议题操作",
"cmd.issue.update.short": "更新议题",
"cmd.issue.view.short": "查看议题详情",
"cmd.org.create.short": "创建组织",
"cmd.org.info.short": "显示组织详情",
"cmd.org.list.short": "列出组织",
"cmd.org.members.short": "列出组织成员",
"cmd.org.short": "组织操作",
"cmd.pr.close.short": "关闭拉取请求",
"cmd.pr.comment.short": "给拉取请求添加评论",
"cmd.pr.create.short": "创建拉取请求",
"cmd.pr.diff.short": "显示拉取请求 diff",
"cmd.pr.files.short": "列出拉取请求中的变更文件",
"cmd.pr.list.short": "列出拉取请求",
"cmd.pr.merge.short": "合并拉取请求",
"cmd.pr.review.short": "创建拉取请求评审",
"cmd.pr.reviews.short": "列出拉取请求评审",
"cmd.pr.short": "拉取请求操作",
"cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff",
"cmd.pr.versions.short": "列出拉取请求补丁集版本",
"cmd.pr.view.short": "查看拉取请求详情",
"cmd.release.create.short": "创建发布",
"cmd.release.delete.short": "删除发布",
"cmd.release.list.short": "列出发布",
"cmd.release.short": "发布操作",
"cmd.release.view.short": "查看发布详情",
"cmd.repo.create.short": "创建新仓库",
"cmd.repo.delete.short": "删除仓库",
"cmd.repo.fork.short": "Fork 仓库",
"cmd.repo.info.short": "显示仓库详情",
"cmd.repo.list.short": "列出用户或组织的仓库",
"cmd.repo.short": "仓库操作",
"cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。",
"cmd.root.short": "GitLink CLI - GitLink 命令行工具",
"cmd.search.repos.short": "搜索仓库",
"cmd.search.short": "搜索操作",
"cmd.search.users.short": "搜索用户",
"cmd.user.info.short": "显示用户资料",
"cmd.user.me.short": "显示当前认证用户",
"cmd.user.short": "用户操作",
"cmd.version.short": "打印版本信息",
"cmd.webhook.create.short": "创建仓库 Webhook",
"cmd.webhook.delete.short": "删除仓库 Webhook",
"cmd.webhook.list.short": "列出仓库 Webhook",
"cmd.webhook.short": "Webhook 操作",
"cmd.webhook.tasks.short": "列出 Webhook 投递任务",
"cmd.webhook.test.short": "触发 Webhook 测试投递",
"cmd.webhook.update.short": "更新仓库 Webhook并在可用时保留未指定字段",
"cmd.webhook.view.short": "查看 Webhook 详情",
"error.auth.delete_token_failed": "删除 Token 失败:{message}",
"error.auth.login_failed": "登录失败:{message}",
"error.auth.store_token_failed": "保存 Token 失败:{message}",
"error.auth.token_empty": "Token 不能为空",
"error.config.save_failed": "保存配置失败:{message}",
"error.missing_required_flag": "缺少必需参数 --{name}",
"error.unsupported_language": "不支持的语言:{lang}",
"flag.api.body": "请求体JSON 字符串)",
"flag.api.body_file": "从文件读取 JSON 请求体",
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
"flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求",
"flag.api.batch_dry_run": "预览批处理请求,不发送远端请求",
"flag.api.batch_file": "从 JSON 文件读取 API 批处理计划",
"flag.api.batch_var": "覆盖批处理模板变量key=value可重复",
"flag.api.header": "附加请求头key:value",
"flag.api.query": "查询参数key=val&key2=val2",
"flag.auth.token": "通过粘贴已有 Token 登录",
"flag.branch.from": "源分支或 Commit",
"flag.branch.name": "分支名称",
"flag.ci.build": "构建编号",
"flag.ci.stage": "阶段编号",
"flag.ci.step": "步骤编号",
"flag.comment.body": "评论内容",
"flag.debug": "启用调试输出",
"flag.description": "描述",
"flag.doctor.skip_network": "跳过需要访问 GitLink 的认证连通性检查",
"flag.dry_run": "预览请求,不实际创建",
"flag.format": "输出格式json、table、yaml默认table",
"flag.issue.add_label": "要添加到每个匹配议题的标签",
"flag.issue.assignee": "负责人登录名",
"flag.issue.batch.reason": "批量结果中显示的可选原因",
"flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。",
"flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7",
"flag.issue.batch_close.state": "关闭前按议题状态筛选",
"flag.issue.batch_label.state": "按议题状态筛选",
"flag.issue.batch_list.limit": "最多返回的议题数,上限 100",
"flag.issue.batch_process.limit": "最多处理的议题数,上限 100",
"flag.issue.assignee_id": "负责人用户 ID",
"flag.issue.author_id": "作者用户 ID",
"flag.issue.body": "议题描述",
"flag.issue.label": "标签 ID",
"flag.issue.label_filter": "按已有标签筛选",
"flag.issue.milestone": "里程碑 ID",
"flag.issue.new_body": "新描述",
"flag.issue.new_state": "新状态open、closed 或数字 status_id",
"flag.issue.new_title": "新标题",
"flag.issue.number": "议题编号(网页 URL 中显示的编号)",
"flag.issue.older_than_days": "只包含至少这么多天未活动的议题",
"flag.issue.participant": "参与范围all、aboutme、authoredme、assignedme、atme",
"flag.issue.state": "按状态筛选open、closed、all",
"flag.issue.status_id": "议题状态 ID",
"flag.issue.tag_ids": "逗号分隔的议题标签 ID",
"flag.issue.title": "议题标题",
"flag.lang": "显示语言",
"flag.limit": "每页条目数",
"flag.org.id": "组织 ID",
"flag.org.id_or_login": "组织 ID 或登录名",
"flag.org.name": "组织名称",
"flag.owner": "仓库所有者(自动从 git remote 检测)",
"flag.page": "页码",
"flag.pr.assignee_id": "指派人用户 ID",
"flag.pr.base": "目标分支",
"flag.pr.body": "PR 描述",
"flag.pr.file": "按文件路径筛选 diff",
"flag.pr.head": "源分支",
"flag.pr.id": "PR 编号",
"flag.pr.milestone_id": "里程碑 ID",
"flag.pr.merge_method": "合并方式merge、rebase、squash",
"flag.pr.priority_id": "优先级 ID",
"flag.pr.review_commit": "关联评审的 Commit SHA",
"flag.pr.review_content": "评审内容",
"flag.pr.reviewer_id": "评审人用户 ID",
"flag.pr.review_status": "评审状态common、approved、rejected",
"flag.pr.review_status_filter": "按评审状态筛选common、approved、rejected",
"flag.pr.state": "筛选open、merged、closed",
"flag.pr.tag_id": "议题标签 ID",
"flag.pr.title": "PR 标题",
"flag.pr.version_id": "补丁集版本 ID",
"flag.release.body": "发布说明",
"flag.release.id": "发布 ID",
"flag.release.id_or_tag": "发布 ID 或标签",
"flag.release.name": "发布名称",
"flag.release.prerelease": "标记为预发布true/false",
"flag.release.tag": "标签名称",
"flag.release.target": "目标分支",
"flag.repo": "仓库名称(自动从 git remote 检测)",
"flag.repo.category": "筛选manage/mirror/sync/fork/all默认manage",
"flag.repo.description": "仓库描述",
"flag.repo.name": "仓库名称",
"flag.repo.private": "设为私有仓库true/false",
"flag.search.keyword": "搜索关键词",
"flag.sort_by": "排序字段",
"flag.sort_direction": "排序方向asc、desc",
"flag.user": "用户登录名(默认:当前用户)",
"flag.user.login": "用户登录名",
"flag.webhook.active": "Webhook 是否启用true 或 false",
"flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选",
"flag.webhook.content_type": "Payload 内容类型json 或 form",
"flag.webhook.events": "逗号分隔的事件例如push,issues_only",
"flag.webhook.http_method": "HTTP 方法POST 或 GET",
"flag.webhook.id": "Webhook ID",
"flag.webhook.secret": "Webhook 密钥",
"flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。",
"flag.webhook.type": "Webhook 类型gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot",
"flag.webhook.url": "Webhook 目标 URL",
"output.auth.env_hint": " 或设置 {env} 环境变量",
"output.auth.login_hint": " 运行gitlink-cli auth login",
"output.config.file": "配置文件:{path}",
"output.config.not_set": "(未设置)",
"output.version": "gitlink-cli {version}",
"output.doctor.api_auth.config_skipped": "配置文件无效,已跳过 API 认证检查。",
"output.doctor.api_auth.failed": "认证 API 请求失败:{message}",
"output.doctor.api_auth.no_login": "认证 API 响应中缺少 login 字段。",
"output.doctor.api_auth.ok": "认证 API 请求成功,当前用户为 {login}。",
"output.doctor.api_auth.skipped": "已跳过认证 API 连通性检查。",
"output.doctor.auth_token.env": "已设置 GITLINK_TOKEN 环境变量。",
"output.doctor.auth_token.missing": "未找到已保存的 Token也未设置 GITLINK_TOKEN 环境变量。",
"output.doctor.auth_token.stored": "已找到本地保存的认证凭据。",
"output.doctor.config_file.invalid": "配置文件存在,但无法解析:{message}",
"output.doctor.config_file.missing": "未找到配置文件,将使用内置默认值。",
"output.doctor.config_file.ok": "配置文件可读取。",
"output.doctor.config_file.unreadable": "配置文件无法读取:{message}",
"output.doctor.config_values.bad_base_url": "base_url 无效:{message}",
"output.doctor.config_values.bad_format": "default_format 当前为 {format},应为 json、table 或 yaml。",
"output.doctor.config_values.ok": "配置项有效。",
"output.doctor.config_values.skipped": "配置文件无效,已跳过配置项检查。",
"output.doctor.repo_context.missing": "无法解析仓库上下文:{message}",
"output.doctor.repo_context.ok": "仓库上下文已解析为 {owner}/{repo}。",
"output.doctor.suggestion.check_config_permissions": "检查 gitlink-cli 配置目录的文件权限。",
"output.doctor.suggestion.check_token": "检查已保存的 Token 是否有效,或重新运行 gitlink-cli auth login。",
"output.doctor.suggestion.fix_config_yaml": "修复 gitlink-cli 配置文件中的 YAML 语法。",
"output.doctor.suggestion.pass_owner_repo": "不在 GitLink 仓库目录内时,请通过 --owner 和 --repo 指定仓库。",
"prompt.auth.password": "密码:",
"prompt.auth.token": "粘贴你的访问 Token",
"prompt.auth.username": "用户名/邮箱/手机号:",
"success.auth.logged_in_as": "✓ 已登录为 {login}",
"success.auth.logged_in_via_env": "✓ 已通过 {env} 环境变量登录",
"success.auth.logged_out": "✓ 已退出登录",
"success.auth.token_saved": "✓ Token 已保存",
"success.config.initialized": "✓ 配置已初始化:{path}",
"success.config.set": "✓ 已设置 {key} = {value}",
"warning.auth.not_logged_in": "✗ 未登录",
"warning.auth.token_unverified": "✓ Token 已保存(但无法验证:{message}",
"warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)"
}

10
internal/i18n/options.go Normal file
View File

@ -0,0 +1,10 @@
package i18n
const defaultFallbackLocale = "en-US"
// Options controls Translator construction.
type Options struct {
Locale string
FallbackLocale string
Loader Loader
}

95
internal/i18n/resolver.go Normal file
View File

@ -0,0 +1,95 @@
package i18n
import (
"os"
"strings"
)
// ResolveOptions contains locale inputs ordered by caller intent.
type ResolveOptions struct {
ExplicitLang string
Env map[string]string
ConfigLang string
}
type ResolvedLocale struct {
Locale string
Source string
Requested string
Fallbacked bool
Supported bool
}
// ResolveLocale resolves a locale using CLI flag, env, config, system env, fallback.
func ResolveLocale(opts ResolveOptions, available []string) string {
return ResolveLocaleDetailed(opts, available).Locale
}
func ResolveLocaleDetailed(opts ResolveOptions, available []string) ResolvedLocale {
candidates := []struct {
source string
value string
}{
{source: "flag", value: opts.ExplicitLang},
{source: "env", value: envValue(opts.Env, "GITLINK_LANG")},
{source: "config", value: opts.ConfigLang},
{source: "lc_all", value: envValue(opts.Env, "LC_ALL")},
{source: "lang", value: envValue(opts.Env, "LANG")},
}
for _, candidate := range candidates {
if strings.TrimSpace(candidate.value) == "" {
continue
}
match := matchLocale(candidate.value, available, defaultFallbackLocale)
return ResolvedLocale{
Locale: match.Locale,
Source: candidate.source,
Requested: match.Requested,
Fallbacked: match.Fallbacked,
Supported: match.Supported,
}
}
match := matchLocale(defaultFallbackLocale, available, defaultFallbackLocale)
return ResolvedLocale{
Locale: match.Locale,
Source: "default",
Requested: match.Requested,
Fallbacked: match.Fallbacked,
Supported: match.Supported,
}
}
// PreScanLang reads --lang before Cobra constructs localized help text.
func PreScanLang(args []string) string {
for i, arg := range args {
if arg == "--lang" {
if i+1 < len(args) {
return args[i+1]
}
return ""
}
if strings.HasPrefix(arg, "--lang=") {
return strings.TrimPrefix(arg, "--lang=")
}
}
return ""
}
// EnvMap returns process environment as a string map.
func EnvMap() map[string]string {
env := make(map[string]string)
for _, item := range os.Environ() {
key, value, ok := strings.Cut(item, "=")
if ok {
env[key] = value
}
}
return env
}
func envValue(env map[string]string, key string) string {
if env == nil {
return os.Getenv(key)
}
return env[key]
}

View File

@ -0,0 +1,31 @@
package i18n
import "testing"
func TestPreScanLang(t *testing.T) {
cases := []struct {
args []string
want string
}{
{[]string{"--lang", "zh-CN", "repo"}, "zh-CN"},
{[]string{"repo", "--lang=zh-CN"}, "zh-CN"},
{[]string{"repo"}, ""},
}
for _, tc := range cases {
if got := PreScanLang(tc.args); got != tc.want {
t.Fatalf("PreScanLang(%v) = %q, want %q", tc.args, got, tc.want)
}
}
}
func TestResolveLocalePriority(t *testing.T) {
available := []string{"en-US", "zh-CN"}
got := ResolveLocale(ResolveOptions{
ExplicitLang: "en-US",
Env: map[string]string{"GITLINK_LANG": "zh-CN"},
ConfigLang: "zh-CN",
}, available)
if got != "en-US" {
t.Fatalf("ResolveLocale() = %q, want en-US", got)
}
}

View File

@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "GitLink CLI locale messages",
"type": "object",
"additionalProperties": {
"type": "string",
"minLength": 1
},
"propertyNames": {
"pattern": "^(cmd|flag|error|prompt|success|warning|confirm|table|output)\\.[a-z0-9_.-]+$"
}
}

41
internal/i18n/template.go Normal file
View File

@ -0,0 +1,41 @@
package i18n
import (
"fmt"
"regexp"
"sort"
)
var placeholderPattern = regexp.MustCompile(`\{([A-Za-z_][A-Za-z0-9_]*)\}`)
func renderTemplate(message string, args Args) string {
if len(args) == 0 {
return message
}
return placeholderPattern.ReplaceAllStringFunc(message, func(match string) string {
name := match[1 : len(match)-1]
value, ok := args[name]
if !ok || value == nil {
return match
}
if stringer, ok := value.(fmt.Stringer); ok {
return stringer.String()
}
return fmt.Sprint(value)
})
}
func extractTemplateArgs(message string) []string {
matches := placeholderPattern.FindAllStringSubmatch(message, -1)
seen := make(map[string]struct{}, len(matches))
for _, match := range matches {
seen[match[1]] = struct{}{}
}
args := make([]string, 0, len(seen))
for arg := range seen {
args = append(args, arg)
}
sort.Strings(args)
return args
}

View File

@ -0,0 +1,24 @@
package i18n
import "testing"
func TestRenderTemplateKeepsMissingArgs(t *testing.T) {
got := renderTemplate("Delete {owner}/{repo}", Args{"owner": "alice"})
want := "Delete alice/{repo}"
if got != want {
t.Fatalf("renderTemplate() = %q, want %q", got, want)
}
}
func TestExtractTemplateArgs(t *testing.T) {
got := extractTemplateArgs("Delete {owner}/{repo}/{owner}")
want := []string{"owner", "repo"}
if len(got) != len(want) {
t.Fatalf("args length = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("arg[%d] = %q, want %q", i, got[i], want[i])
}
}
}

View File

@ -0,0 +1,89 @@
package i18n
import "fmt"
// Translator resolves localized messages with fallback behavior suitable for CLI use.
type Translator struct {
locale string
fallbackLocale string
messages map[string]string
fallback map[string]string
}
// Default returns an English translator for legacy migration only.
// New command code should receive *Translator explicitly.
func Default() *Translator {
tr, err := New(Options{Locale: defaultFallbackLocale})
if err != nil {
return &Translator{
locale: defaultFallbackLocale,
fallbackLocale: defaultFallbackLocale,
messages: map[string]string{},
fallback: map[string]string{},
}
}
return tr
}
// New constructs a Translator. Missing messages fall back to FallbackLocale.
func New(opts Options) (*Translator, error) {
loader := opts.Loader
if loader == nil {
loader = NewEmbedLoader()
}
available, err := loader.AvailableLocales()
if err != nil {
return nil, err
}
fallbackLocale := opts.FallbackLocale
if fallbackLocale == "" {
fallbackLocale = defaultFallbackLocale
}
fallbackLocale = MatchLocale(fallbackLocale, available, defaultFallbackLocale)
locale := MatchLocale(opts.Locale, available, fallbackLocale)
fallback, err := loader.Load(fallbackLocale)
if err != nil {
return nil, fmt.Errorf("load fallback locale: %w", err)
}
messages := fallback
if locale != fallbackLocale {
messages, err = loader.Load(locale)
if err != nil {
return nil, fmt.Errorf("load locale: %w", err)
}
}
return &Translator{
locale: locale,
fallbackLocale: fallbackLocale,
messages: messages,
fallback: fallback,
}, nil
}
func (t *Translator) Locale() string {
return t.locale
}
// T returns a localized message, falling back to en-US and then the key itself.
func (t *Translator) T(key string) string {
if t == nil {
return key
}
if value, ok := t.messages[key]; ok {
return value
}
if value, ok := t.fallback[key]; ok {
return value
}
return key
}
// Tf returns a localized message with {name} placeholders rendered from args.
func (t *Translator) Tf(key string, args Args) string {
return renderTemplate(t.T(key), args)
}

View File

@ -0,0 +1,79 @@
package i18n
import (
"errors"
"testing"
)
type mapLoader struct {
messages map[string]map[string]string
}
func (l mapLoader) Load(locale string) (map[string]string, error) {
messages, ok := l.messages[locale]
if !ok {
return nil, errors.New("missing locale")
}
return messages, nil
}
func (l mapLoader) AvailableLocales() ([]string, error) {
locales := make([]string, 0, len(l.messages))
for locale := range l.messages {
locales = append(locales, locale)
}
return locales, nil
}
func TestTranslatorReturnsLocalizedMessage(t *testing.T) {
tr, err := New(Options{
Locale: "zh-CN",
Loader: mapLoader{messages: map[string]map[string]string{
"en-US": {"cmd.root.short": "GitLink CLI"},
"zh-CN": {"cmd.root.short": "GitLink 命令行"},
}},
})
if err != nil {
t.Fatal(err)
}
if got := tr.T("cmd.root.short"); got != "GitLink 命令行" {
t.Fatalf("expected localized message, got %q", got)
}
}
func TestTranslatorFallsBackToBaseThenKey(t *testing.T) {
tr, err := New(Options{
Locale: "zh-CN",
Loader: mapLoader{messages: map[string]map[string]string{
"en-US": {"cmd.root.short": "GitLink CLI"},
"zh-CN": {},
}},
})
if err != nil {
t.Fatal(err)
}
if got := tr.T("cmd.root.short"); got != "GitLink CLI" {
t.Fatalf("expected fallback message, got %q", got)
}
if got := tr.T("cmd.missing.short"); got != "cmd.missing.short" {
t.Fatalf("expected key fallback, got %q", got)
}
}
func TestTranslatorRendersArgs(t *testing.T) {
tr, err := New(Options{
Locale: "en-US",
Loader: mapLoader{messages: map[string]map[string]string{
"en-US": {"output.version": "gitlink-cli {version}"},
}},
})
if err != nil {
t.Fatal(err)
}
if got := tr.Tf("output.version", Args{"version": "1.2.3"}); got != "gitlink-cli 1.2.3" {
t.Fatalf("expected rendered message, got %q", got)
}
}

104
internal/i18n/validate.go Normal file
View File

@ -0,0 +1,104 @@
package i18n
import (
"fmt"
"reflect"
"regexp"
"sort"
"strings"
)
var keyPattern = regexp.MustCompile(`^(cmd|flag|error|prompt|success|warning|confirm|table|output)\.[a-z0-9_.-]+$`)
// Problem describes a locale validation issue.
type Problem struct {
Locale string
Key string
Message string
}
func (p Problem) String() string {
if p.Key == "" {
return fmt.Sprintf("%s: %s", p.Locale, p.Message)
}
return fmt.Sprintf("%s:%s: %s", p.Locale, p.Key, p.Message)
}
// Validate checks all locales against baseLocale.
func Validate(loader Loader, baseLocale string) ([]Problem, error) {
if loader == nil {
loader = NewEmbedLoader()
}
baseLocale = NormalizeLocale(baseLocale)
if baseLocale == "" {
baseLocale = defaultFallbackLocale
}
locales, err := loader.AvailableLocales()
if err != nil {
return nil, err
}
sort.Strings(locales)
allMessages := make(map[string]map[string]string, len(locales))
for _, locale := range locales {
normalized := NormalizeLocale(locale)
if normalized != locale {
return []Problem{{Locale: locale, Message: "locale filename is not normalized"}}, nil
}
messages, err := loader.Load(locale)
if err != nil {
return nil, err
}
allMessages[locale] = messages
}
base, ok := allMessages[baseLocale]
if !ok {
return []Problem{{Locale: baseLocale, Message: "base locale is missing"}}, nil
}
var problems []Problem
for key, value := range base {
problems = append(problems, validateMessage(baseLocale, key, value)...)
}
for _, locale := range locales {
messages := allMessages[locale]
for key, value := range messages {
problems = append(problems, validateMessage(locale, key, value)...)
if _, ok := base[key]; !ok {
problems = append(problems, Problem{Locale: locale, Key: key, Message: "key is not present in base locale"})
}
}
for key, baseValue := range base {
value, ok := messages[key]
if !ok {
problems = append(problems, Problem{Locale: locale, Key: key, Message: "missing key"})
continue
}
baseArgs := extractTemplateArgs(baseValue)
args := extractTemplateArgs(value)
if !reflect.DeepEqual(baseArgs, args) {
problems = append(problems, Problem{
Locale: locale,
Key: key,
Message: fmt.Sprintf("template args mismatch: expected {%s}, got {%s}", strings.Join(baseArgs, ","), strings.Join(args, ",")),
})
}
}
}
return problems, nil
}
func validateMessage(locale, key, value string) []Problem {
var problems []Problem
if !keyPattern.MatchString(key) {
problems = append(problems, Problem{Locale: locale, Key: key, Message: "key does not match naming rules"})
}
if strings.TrimSpace(value) == "" {
problems = append(problems, Problem{Locale: locale, Key: key, Message: "message is empty"})
}
return problems
}

View File

@ -0,0 +1,31 @@
package i18n
import "testing"
func TestValidateEmbeddedLocales(t *testing.T) {
problems, err := Validate(NewEmbedLoader(), "en-US")
if err != nil {
t.Fatal(err)
}
if len(problems) > 0 {
t.Fatalf("expected no problems, got %v", problems)
}
}
func TestValidateFindsMissingKeyAndArgMismatch(t *testing.T) {
problems, err := Validate(mapLoader{messages: map[string]map[string]string{
"en-US": {
"cmd.root.short": "Hello {name}",
"flag.owner": "Owner",
},
"zh-CN": {
"cmd.root.short": "你好",
},
}}, "en-US")
if err != nil {
t.Fatal(err)
}
if len(problems) != 2 {
t.Fatalf("expected 2 problems, got %d: %v", len(problems), problems)
}
}

View File

@ -0,0 +1,78 @@
package output
import (
"encoding/json"
"testing"
)
func TestSuccessEnvelope(t *testing.T) {
env := SuccessEnvelope(map[string]interface{}{"key": "value"}, nil)
if !env.OK {
t.Fatal("expected OK=true")
}
if env.Data == nil {
t.Fatal("expected non-nil Data")
}
if env.Error != nil {
t.Fatal("expected nil Error")
}
}
func TestSuccessEnvelopeWithMeta(t *testing.T) {
meta := &Meta{Page: 1, Limit: 20, TotalCount: 100}
env := SuccessEnvelope("data", meta)
if env.Meta != meta {
t.Fatal("expected Meta to be set")
}
}
func TestErrorEnvelope(t *testing.T) {
env := ErrorEnvelope(404, "Not Found", "Check the URL")
if env.OK {
t.Fatal("expected OK=false")
}
if env.Data != nil {
t.Fatal("expected nil Data")
}
if env.Error == nil {
t.Fatal("expected non-nil Error")
}
if env.Error.Code != 404 {
t.Fatalf("Code = %v, want 404", env.Error.Code)
}
if env.Error.Message != "Not Found" {
t.Fatalf("Message = %q, want 'Not Found'", env.Error.Message)
}
if env.Error.Suggestion != "Check the URL" {
t.Fatalf("Suggestion = %q, want 'Check the URL'", env.Error.Suggestion)
}
}
func TestEnvelopeJSON(t *testing.T) {
env := SuccessEnvelope("hello", nil)
data, err := env.JSON()
if err != nil {
t.Fatalf("JSON() error: %v", err)
}
var decoded Envelope
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal JSON output: %v", err)
}
if !decoded.OK {
t.Fatal("expected OK=true in JSON")
}
}
func TestErrorInfoFields(t *testing.T) {
info := ErrorInfo{Code: 500, Message: "Internal Error", Suggestion: "Retry later"}
if info.Message != "Internal Error" {
t.Fatalf("Message = %q", info.Message)
}
}
func TestMetaFields(t *testing.T) {
meta := Meta{Page: 2, Limit: 50, TotalCount: 200, Identity: "user1"}
if meta.Page != 2 || meta.Limit != 50 || meta.TotalCount != 200 || meta.Identity != "user1" {
t.Fatal("Meta fields don't match")
}
}

View File

@ -0,0 +1,246 @@
package output
import (
"bytes"
"strings"
"testing"
)
func TestPrint(t *testing.T) {
env := SuccessEnvelope(map[string]interface{}{"status": "ok"}, nil)
if err := Print(env, "json"); err != nil {
t.Fatalf("Print error: %v", err)
}
}
func TestPrintDefaultFormat(t *testing.T) {
env := SuccessEnvelope(map[string]interface{}{"status": "ok"}, nil)
if err := Print(env, ""); err != nil {
t.Fatalf("Print default format error: %v", err)
}
}
func TestPrintToJSON(t *testing.T) {
var buf bytes.Buffer
env := SuccessEnvelope(map[string]interface{}{"status": "ok"}, nil)
if err := PrintTo(&buf, env, "json"); err != nil {
t.Fatalf("PrintTo json: %v", err)
}
if !strings.Contains(buf.String(), `"ok"`) {
t.Fatalf("expected ok in JSON output, got: %s", buf.String())
}
if !strings.Contains(buf.String(), `"status"`) {
t.Fatalf("expected status in JSON output, got: %s", buf.String())
}
}
func TestPrintToYAML(t *testing.T) {
var buf bytes.Buffer
env := SuccessEnvelope(map[string]interface{}{"status": "ok"}, nil)
if err := PrintTo(&buf, env, "yaml"); err != nil {
t.Fatalf("PrintTo yaml: %v", err)
}
if !strings.Contains(buf.String(), "ok") {
t.Fatalf("expected ok in YAML output, got: %s", buf.String())
}
}
func TestPrintToTableSlice(t *testing.T) {
var buf bytes.Buffer
env := SuccessEnvelope([]interface{}{
map[string]interface{}{"id": float64(1), "name": "test"},
map[string]interface{}{"id": float64(2), "name": "test2"},
}, nil)
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo table slice: %v", err)
}
out := buf.String()
if !strings.Contains(out, "id") || !strings.Contains(out, "name") {
t.Fatalf("expected table headers, got: %s", out)
}
if !strings.Contains(out, "1") || !strings.Contains(out, "test") {
t.Fatalf("expected table data, got: %s", out)
}
}
func TestPrintToTableEmptySlice(t *testing.T) {
var buf bytes.Buffer
env := SuccessEnvelope([]interface{}{}, nil)
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo table empty: %v", err)
}
if !strings.Contains(buf.String(), "No results") {
t.Fatalf("expected 'No results', got: %s", buf.String())
}
}
func TestPrintToTableSliceNonMap(t *testing.T) {
var buf bytes.Buffer
env := SuccessEnvelope([]interface{}{"string1", "string2"}, nil)
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo table non-map slice: %v", err)
}
// Should fallback to JSON
if !strings.Contains(buf.String(), "[") {
t.Fatalf("expected JSON array fallback, got: %s", buf.String())
}
}
func TestPrintToTableMap(t *testing.T) {
var buf bytes.Buffer
env := SuccessEnvelope(map[string]interface{}{
"key1": "val1",
"key2": "val2",
}, nil)
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo table map: %v", err)
}
out := buf.String()
if !strings.Contains(out, "KEY") || !strings.Contains(out, "VALUE") {
t.Fatalf("expected KEY/VALUE headers, got: %s", out)
}
}
func TestPrintToTableMapComplex(t *testing.T) {
var buf bytes.Buffer
env := SuccessEnvelope(map[string]interface{}{
"nested": map[string]interface{}{"a": "b"},
}, nil)
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo table complex map: %v", err)
}
// Should fallback to JSON because of nested map
if !strings.Contains(buf.String(), "{") {
t.Fatalf("expected JSON fallback for complex map, got: %s", buf.String())
}
}
func TestPrintToTableError(t *testing.T) {
var buf bytes.Buffer
env := ErrorEnvelope(500, "server error", "try again")
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo table error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "Error: server error") {
t.Fatalf("expected error message, got: %s", out)
}
if !strings.Contains(out, "try again") {
t.Fatalf("expected suggestion, got: %s", out)
}
}
func TestPrintToTableErrorNoSuggestion(t *testing.T) {
var buf bytes.Buffer
env := ErrorEnvelope(500, "server error", "")
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo table error no suggestion: %v", err)
}
out := buf.String()
if strings.Contains(out, "Suggestion:") {
t.Fatalf("should not have suggestion line, got: %s", out)
}
}
func TestPrintToTableNilData(t *testing.T) {
var buf bytes.Buffer
env := SuccessEnvelope(nil, nil)
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo table nil data: %v", err)
}
if !strings.Contains(buf.String(), "No data") {
t.Fatalf("expected 'No data', got: %s", buf.String())
}
}
func TestPrintToDefaultFormat(t *testing.T) {
var buf bytes.Buffer
env := SuccessEnvelope("test", nil)
if err := PrintTo(&buf, env, ""); err != nil {
t.Fatalf("PrintTo default format: %v", err)
}
// Default should be JSON
if !strings.Contains(buf.String(), `"ok"`) {
t.Fatalf("expected JSON output for default format, got: %s", buf.String())
}
}
func TestPrintToUnknownFormat(t *testing.T) {
var buf bytes.Buffer
env := SuccessEnvelope("test", nil)
if err := PrintTo(&buf, env, "xml"); err != nil {
t.Fatalf("PrintTo unknown format: %v", err)
}
// Unknown format should fallback to JSON
if !strings.Contains(buf.String(), `"ok"`) {
t.Fatalf("expected JSON fallback for unknown format, got: %s", buf.String())
}
}
func TestHasComplexValues(t *testing.T) {
tests := []struct {
name string
m map[string]interface{}
want bool
}{
{"flat", map[string]interface{}{"a": "1", "b": "2"}, false},
{"nested map", map[string]interface{}{"a": map[string]interface{}{"x": "y"}}, true},
{"nested slice", map[string]interface{}{"a": []interface{}{1, 2}}, true},
{"empty", map[string]interface{}{}, false},
{"nil", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasComplexValues(tt.m); got != tt.want {
t.Fatalf("hasComplexValues = %v, want %v", got, tt.want)
}
})
}
}
func TestCollectKeys(t *testing.T) {
m := map[string]interface{}{
"title": "test",
"id": float64(1),
"status": "open",
"custom_key": "val",
}
keys := collectKeys(m)
// Priority keys should come first
if len(keys) != 4 {
t.Fatalf("expected 4 keys, got %d", len(keys))
}
if keys[0] != "id" {
t.Fatalf("first key should be 'id', got %q", keys[0])
}
if keys[1] != "title" {
t.Fatalf("second key should be 'title', got %q", keys[1])
}
if keys[2] != "status" {
t.Fatalf("third key should be 'status', got %q", keys[2])
}
}
func TestFormatValue(t *testing.T) {
tests := []struct {
name string
v interface{}
want string
}{
{"nil", nil, ""},
{"string", "hello", "hello"},
{"int", 42, "42"},
{"float", 3.14, "3.14"},
{"bool", true, "true"},
{"slice", []interface{}{1, 2, 3}, "[1,2,3]"},
{"map", map[string]interface{}{"a": "b"}, `{"a":"b"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatValue(tt.v)
if got != tt.want {
t.Fatalf("formatValue = %q, want %q", got, tt.want)
}
})
}
}

15
main_test.go Normal file
View File

@ -0,0 +1,15 @@
package main
import (
"os"
"testing"
)
func TestMainHelp(t *testing.T) {
origArgs := os.Args
os.Args = []string{"gitlink-cli", "--help"}
defer func() { os.Args = origArgs }()
// Should not call os.Exit because --help returns nil
main()
}

29
scripts/pre-commit Executable file
View File

@ -0,0 +1,29 @@
#!/bin/sh
# Pre-commit hook: run fmt + vet + lint + test before every commit.
# Install: make hooks
set -e
echo "=== gofmt ==="
unformatted=$(gofmt -s -l .)
if [ -n "$unformatted" ]; then
echo "ERROR: these files are not formatted:"
echo "$unformatted"
echo "Run: gofmt -s -w ."
exit 1
fi
echo " OK"
echo "=== go vet ==="
go vet ./...
echo " OK"
echo "=== golangci-lint ==="
golangci-lint run ./...
echo " OK"
echo "=== go test ==="
go test -race ./...
echo " OK"
echo "=== pre-commit passed ==="

View File

@ -4,17 +4,19 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/common"
) )
func Shortcuts() []*common.Shortcut { func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{ return []*common.Shortcut{
{ {
Name: "list", Name: "list",
Description: "List branches", Description: tr.T("cmd.branch.list.short"),
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"}, {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
@ -32,10 +34,10 @@ func Shortcuts() []*common.Shortcut {
}, },
{ {
Name: "create", Name: "create",
Description: "Create a branch", Description: tr.T("cmd.branch.create.short"),
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true}, {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
{Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"}, {Name: "from", Short: "f", Usage: tr.T("flag.branch.from"), Default: "master"},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
@ -59,9 +61,9 @@ func Shortcuts() []*common.Shortcut {
}, },
{ {
Name: "delete", Name: "delete",
Description: "Delete a branch", Description: tr.T("cmd.branch.delete.short"),
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true}, {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
@ -80,9 +82,9 @@ func Shortcuts() []*common.Shortcut {
}, },
{ {
Name: "protect", Name: "protect",
Description: "Set branch protection", Description: tr.T("cmd.branch.protect.short"),
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true}, {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
@ -101,9 +103,9 @@ func Shortcuts() []*common.Shortcut {
}, },
{ {
Name: "unprotect", Name: "unprotect",
Description: "Remove branch protection", Description: tr.T("cmd.branch.unprotect.short"),
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true}, {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
@ -119,3 +121,10 @@ func Shortcuts() []*common.Shortcut {
}, },
} }
} }
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}

View File

@ -0,0 +1,218 @@
package branch
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
shortcuts := Shortcuts()
for _, s := range shortcuts {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// --- list ---
func TestBranchList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/owner/repo/branches.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, []interface{}{
map[string]interface{}{"name": "master"},
map[string]interface{}{"name": "develop"},
})
}))
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"})
if err != nil {
t.Fatalf("list failed: %v", err)
}
}
// --- create ---
func TestBranchCreate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/v1/owner/repo/branches.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"name": "feature-x"})
}))
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{"name": "feature-x", "from": "master"})
if err != nil {
t.Fatalf("create failed: %v", err)
}
}
func TestBranchCreateDefaultFrom(t *testing.T) {
// When 'from' is not set, it defaults to "master"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/owner/repo/branches.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"name": "feature-y"})
}))
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{"name": "feature-y"})
if err != nil {
t.Fatalf("create failed: %v", err)
}
}
// --- delete ---
func TestBranchDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/owner/repo/branches/delete.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"message": "deleted"})
}))
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
}
// --- protect ---
func TestBranchProtect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo/protected_branches.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"message": "protected"})
}))
defer server.Close()
err := runShortcut(t, server, "protect", map[string]string{"name": "master"})
if err != nil {
t.Fatalf("protect failed: %v", err)
}
}
// --- unprotect ---
func TestBranchUnprotect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Fatalf("expected DELETE, got %s", r.Method)
}
if r.URL.Path != "/owner/repo/protected_branches/master.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"message": "unprotected"})
}))
defer server.Close()
err := runShortcut(t, server, "unprotect", map[string]string{"name": "master"})
if err != nil {
t.Fatalf("unprotect failed: %v", err)
}
}
// --- HTTP error paths ---
func TestBranchListHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestBranchCreateHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{"name": "feature-x"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestBranchDeleteHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestBranchProtectHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "protect", map[string]string{"name": "master"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestBranchUnprotectHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "unprotect", map[string]string{"name": "master"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}

View File

@ -4,17 +4,19 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/common"
) )
func Shortcuts() []*common.Shortcut { func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{ return []*common.Shortcut{
{ {
Name: "builds", Name: "builds",
Description: "List CI builds", Description: tr.T("cmd.ci.builds.short"),
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"}, {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
@ -32,11 +34,11 @@ func Shortcuts() []*common.Shortcut {
}, },
{ {
Name: "logs", Name: "logs",
Description: "View build logs", Description: tr.T("cmd.ci.logs.short"),
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true}, {Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true},
{Name: "stage", Short: "s", Usage: "Stage number", Default: "1"}, {Name: "stage", Short: "s", Usage: tr.T("flag.ci.stage"), Default: "1"},
{Name: "step", Usage: "Step number", Default: "1"}, {Name: "step", Usage: tr.T("flag.ci.step"), Default: "1"},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
@ -60,9 +62,9 @@ func Shortcuts() []*common.Shortcut {
}, },
{ {
Name: "restart", Name: "restart",
Description: "Restart a build", Description: tr.T("cmd.ci.restart.short"),
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true}, {Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
@ -78,9 +80,9 @@ func Shortcuts() []*common.Shortcut {
}, },
{ {
Name: "stop", Name: "stop",
Description: "Stop a build", Description: tr.T("cmd.ci.stop.short"),
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true}, {Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
@ -96,3 +98,10 @@ func Shortcuts() []*common.Shortcut {
}, },
} }
} }
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}

182
shortcuts/ci/ci_test.go Normal file
View File

@ -0,0 +1,182 @@
package ci
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// --- builds ---
func TestCIBuilds(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo/builds.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, []interface{}{
map[string]interface{}{"number": float64(1), "status": "success"},
})
}))
defer server.Close()
err := runShortcut(t, server, "builds", map[string]string{"page": "1", "limit": "20"})
if err != nil {
t.Fatalf("builds failed: %v", err)
}
}
// --- logs ---
func TestCILogs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo/builds/5/logs/1/1.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"log": "Build output..."})
}))
defer server.Close()
err := runShortcut(t, server, "logs", map[string]string{"build": "5", "stage": "1", "step": "1"})
if err != nil {
t.Fatalf("logs failed: %v", err)
}
}
func TestCILogsDefaults(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo/builds/3/logs/1/1.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"log": "output"})
}))
defer server.Close()
err := runShortcut(t, server, "logs", map[string]string{"build": "3"})
if err != nil {
t.Fatalf("logs with defaults failed: %v", err)
}
}
// --- restart ---
func TestCIRestart(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo/builds/7/restart.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"message": "restarted"})
}))
defer server.Close()
err := runShortcut(t, server, "restart", map[string]string{"build": "7"})
if err != nil {
t.Fatalf("restart failed: %v", err)
}
}
// --- stop ---
func TestCIStop(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Fatalf("expected DELETE, got %s", r.Method)
}
if r.URL.Path != "/owner/repo/builds/7/stop.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"message": "stopped"})
}))
defer server.Close()
err := runShortcut(t, server, "stop", map[string]string{"build": "7"})
if err != nil {
t.Fatalf("stop failed: %v", err)
}
}
// --- HTTP error paths ---
func TestCIBuildsHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "builds", map[string]string{"page": "1", "limit": "20"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestCILogsHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "logs", map[string]string{"build": "5"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestCIRestartHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "restart", map[string]string{"build": "7"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestCIStopHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "stop", map[string]string{"build": "7"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}

View File

@ -3,14 +3,20 @@ package common
import ( import (
"strconv" "strconv"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand. // MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand.
func MountShortcut(parent *cobra.Command, s *Shortcut) { func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Translator) {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "+" + s.Name, Use: "+" + s.Name,
Short: s.Description, Short: s.Description,
Long: s.Long,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
// Collect flag values // Collect flag values
flagValues := make(map[string]string) flagValues := make(map[string]string)
@ -26,10 +32,16 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
} }
} }
ctx, err := NewRuntimeContext(flagValues) ctx, err := NewRuntimeContext(flagValues, tr)
if err != nil { if err != nil {
return err return err
} }
for _, f := range s.Flags {
if f.Required && flagValues[f.Name] == "" {
_, err := ctx.RequireArg(f.Name)
return err
}
}
return s.Run(ctx) return s.Run(ctx)
}, },
@ -48,17 +60,18 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
} else { } else {
cmd.Flags().String(f.Name, f.Default, f.Usage) cmd.Flags().String(f.Name, f.Default, f.Usage)
} }
if f.Required {
cmd.MarkFlagRequired(f.Name)
}
} }
parent.AddCommand(cmd) parent.AddCommand(cmd)
} }
// MountShortcuts mounts multiple shortcuts under a parent command. // MountShortcuts mounts multiple shortcuts under a parent command.
func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut) { func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut, translators ...*i18n.Translator) {
var tr *i18n.Translator
if len(translators) > 0 {
tr = translators[0]
}
for _, s := range shortcuts { for _, s := range shortcuts {
MountShortcut(parent, s) MountShortcut(parent, s, tr)
} }
} }

View File

@ -29,3 +29,51 @@ func TestMountShortcutSupportsBoolFlags(t *testing.T) {
t.Fatalf("dry-run flag = %q, want true", got) t.Fatalf("dry-run flag = %q, want true", got)
} }
} }
func TestMountShortcuts(t *testing.T) {
var called []string
root := &cobra.Command{Use: "root"}
shortcuts := []*Shortcut{
{
Name: "first",
Flags: []Flag{
{Name: "name", Default: "default1"},
},
Run: func(ctx *RuntimeContext) error {
called = append(called, "first")
return nil
},
},
{
Name: "second",
Flags: []Flag{
{Name: "name", Default: "default2"},
},
Run: func(ctx *RuntimeContext) error {
called = append(called, "second")
return nil
},
},
}
MountShortcuts(root, shortcuts)
if len(root.Commands()) != 2 {
t.Fatalf("expected 2 subcommands, got %d", len(root.Commands()))
}
root.SetArgs([]string{"+first"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute +first: %v", err)
}
if len(called) != 1 || called[0] != "first" {
t.Fatalf("called = %v, want [first]", called)
}
root.SetArgs([]string{"+second"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute +second: %v", err)
}
if len(called) != 2 || called[1] != "second" {
t.Fatalf("called = %v, want [first second]", called)
}
}

View File

@ -2,12 +2,14 @@ package common
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/url" "net/url"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/context" "github.com/gitlink-org/gitlink-cli/internal/context"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/internal/output"
) )
@ -15,6 +17,7 @@ import (
type Shortcut struct { type Shortcut struct {
Name string Name string
Description string Description string
Long string
Flags []Flag Flags []Flag
Run func(ctx *RuntimeContext) error Run func(ctx *RuntimeContext) error
} }
@ -36,10 +39,15 @@ type RuntimeContext struct {
Repo string Repo string
Format string Format string
Args map[string]string Args map[string]string
Tr *i18n.Translator
} }
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo. // NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { func NewRuntimeContext(args map[string]string, translators ...*i18n.Translator) (*RuntimeContext, error) {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
cli, err := client.New() cli, err := client.New()
if err != nil { if err != nil {
return nil, err return nil, err
@ -57,6 +65,7 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
Repo: cmdutil.Repo, Repo: cmdutil.Repo,
Format: format, Format: format,
Args: args, Args: args,
Tr: tr,
}, nil }, nil
} }
@ -113,7 +122,7 @@ func (ctx *RuntimeContext) Arg(name string) string {
func (ctx *RuntimeContext) RequireArg(name string) (string, error) { func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
v := ctx.Arg(name) v := ctx.Arg(name)
if v == "" { if v == "" {
return "", fmt.Errorf("required flag --%s is missing", name) return "", errors.New(ctx.Tr.Tf("error.missing_required_flag", i18n.Args{"name": name}))
} }
return v, nil return v, nil
} }

View File

@ -0,0 +1,195 @@
package common
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
func TestRuntimeContextRepoPath(t *testing.T) {
ctx := &RuntimeContext{Owner: "owner", Repo: "repo"}
if got := ctx.RepoPath(); got != "/owner/repo" {
t.Fatalf("RepoPath = %q, want /owner/repo", got)
}
}
func TestRuntimeContextArg(t *testing.T) {
ctx := &RuntimeContext{
Args: map[string]string{"key1": "val1"},
}
tests := []struct {
name string
want string
}{
{"key1", "val1"},
{"key2", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ctx.Arg(tt.name); got != tt.want {
t.Fatalf("Arg(%q) = %q, want %q", tt.name, got, tt.want)
}
})
}
}
func TestRuntimeContextRequireArg(t *testing.T) {
ctx := &RuntimeContext{
Args: map[string]string{"required": "present"},
}
v, err := ctx.RequireArg("required")
if err != nil {
t.Fatalf("RequireArg error: %v", err)
}
if v != "present" {
t.Fatalf("RequireArg = %q, want present", v)
}
_, err = ctx.RequireArg("missing")
if err == nil {
t.Fatal("expected error for missing required arg")
}
}
func TestRuntimeContextCallAPI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data":"test"}`))
}))
defer server.Close()
ctx := &RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
}
env, err := ctx.CallAPI("GET", "/test", nil)
if err != nil {
t.Fatalf("CallAPI error: %v", err)
}
if !env.OK {
t.Fatal("expected OK=true")
}
}
func TestRuntimeContextCallAPIWithQuery(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("state") != "open" {
t.Fatalf("expected state=open, got %s", r.URL.Query().Get("state"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data":"test"}`))
}))
defer server.Close()
ctx := &RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
}
q := url.Values{}
q.Set("state", "open")
_, err := ctx.CallAPIWithQuery("GET", "/test", q)
if err != nil {
t.Fatalf("CallAPIWithQuery error: %v", err)
}
}
func TestRuntimeContextOutput(t *testing.T) {
ctx := &RuntimeContext{
Client: &client.Client{HTTP: http.DefaultClient, BaseURL: "http://localhost"},
Format: "json",
}
// Output should succeed (data goes to stdout)
env := &output.Envelope{OK: true, Data: map[string]interface{}{"key": "val"}}
err := ctx.Output(env)
if err != nil {
t.Fatalf("Output error: %v", err)
}
}
func TestRuntimeContextOutputData(t *testing.T) {
ctx := &RuntimeContext{
Client: &client.Client{HTTP: http.DefaultClient, BaseURL: "http://localhost"},
Format: "json",
}
err := ctx.OutputData(map[string]interface{}{"key": "val"})
if err != nil {
t.Fatalf("OutputData error: %v", err)
}
}
func TestRuntimeContextResolveOwnerRepo(t *testing.T) {
// When Owner and Repo are already set, ResolveOwnerRepo should succeed
ctx := &RuntimeContext{Owner: "explicitOwner", Repo: "explicitRepo"}
if err := ctx.ResolveOwnerRepo(); err != nil {
t.Fatalf("ResolveOwnerRepo error: %v", err)
}
if ctx.Owner != "explicitOwner" || ctx.Repo != "explicitRepo" {
t.Fatalf("ResolveOwnerRepo changed values")
}
}
func TestShortcutStruct(t *testing.T) {
s := Shortcut{
Name: "test",
Description: "test shortcut",
Flags: []Flag{{Name: "verbose", Usage: "verbose output"}},
Run: func(ctx *RuntimeContext) error { return nil },
}
if s.Name != "test" {
t.Fatalf("Name = %q", s.Name)
}
if len(s.Flags) != 1 {
t.Fatalf("expected 1 flag, got %d", len(s.Flags))
}
if s.Run == nil {
t.Fatal("expected non-nil Run")
}
}
func TestFlagStruct(t *testing.T) {
f := Flag{
Name: "output",
Short: "o",
Usage: "Output format",
Required: true,
Default: "json",
Bool: false,
}
if f.Name != "output" {
t.Fatalf("Name = %q", f.Name)
}
if f.Short != "o" {
t.Fatalf("Short = %q", f.Short)
}
if !f.Required {
t.Fatal("expected Required=true")
}
}
func TestRuntimeContextPaginateAll(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data":[{"id":1},{"id":2}]}`))
}))
defer server.Close()
ctx := &RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
}
items, err := ctx.PaginateAll("/items", nil)
if err != nil {
t.Fatalf("PaginateAll error: %v", err)
}
if len(items) != 2 {
t.Fatalf("PaginateAll returned %d items, want 2", len(items))
}
}

View File

@ -0,0 +1,83 @@
package compare
import (
"encoding/base64"
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "view",
Description: "Compare two branches, tags, or commits",
Flags: []common.Flag{
{Name: "head", Usage: "Source branch, tag, or commit", Required: true},
{Name: "base", Usage: "Target branch, tag, or commit", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
head, err := ctx.RequireArg("head")
if err != nil {
return err
}
base, err := ctx.RequireArg("base")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", comparePath(ctx, head, base), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "files",
Description: "List changed files between two refs",
Flags: []common.Flag{
{Name: "head", Usage: "Source branch, tag, or commit", Required: true},
{Name: "base", Usage: "Target branch, tag, or commit", Required: true},
{Name: "file", Short: "f", Usage: "Filter by file path"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
head, err := ctx.RequireArg("head")
if err != nil {
return err
}
base, err := ctx.RequireArg("base")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if file := ctx.Arg("file"); file != "" {
q.Set("filepath", file)
}
env, err := ctx.CallAPIWithQuery("GET", "/v1"+comparePath(ctx, head, base)+"/files", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func comparePath(ctx *common.RuntimeContext, head, base string) string {
return fmt.Sprintf("%s/compare/%s...%s", ctx.RepoPath(), encodeRef(head), encodeRef(base))
}
func encodeRef(ref string) string {
return base64.RawURLEncoding.EncodeToString([]byte(ref))
}

View File

@ -0,0 +1,107 @@
package compare
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestCompareViewEncodesRefs(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
if r.Method != "GET" || r.URL.Path != "/owner/repo/compare/ZmVhdHVyZS9hcGk...bWFzdGVy.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{"commits_count": 1})
}))
defer server.Close()
err := runCompareShortcut(t, server, "view", map[string]string{
"head": "feature/api",
"base": "master",
})
if err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
if calledPath == "" {
t.Fatal("server was not called")
}
}
func TestCompareFilesUsesV1EndpointWithFilters(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/compare/YnVnZml4...cmVsZWFzZS92MQ/files.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("filepath"); got != "cmd/api/api.go" {
t.Fatalf("filepath query = %q, want cmd/api/api.go", got)
}
if got := r.URL.Query().Get("page"); got != "2" {
t.Fatalf("page query = %q, want 2", got)
}
if got := r.URL.Query().Get("limit"); got != "50" {
t.Fatalf("limit query = %q, want 50", got)
}
writeJSON(t, w, map[string]interface{}{"files": []interface{}{}})
}))
defer server.Close()
err := runCompareShortcut(t, server, "files", map[string]string{
"head": "bugfix",
"base": "release/v1",
"file": "cmd/api/api.go",
"page": "2",
"limit": "50",
})
if err != nil {
t.Fatalf("files shortcut failed: %v", err)
}
}
func TestEncodeRefUsesRawURLBase64(t *testing.T) {
got := encodeRef("feature/api")
want := "ZmVhdHVyZS9hcGk"
if got != want {
t.Fatalf("encodeRef() = %q, want %q", got, want)
}
}
func runCompareShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findCompareShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findCompareShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}

127
shortcuts/health/api.go Normal file
View File

@ -0,0 +1,127 @@
package health
import (
"fmt"
"net/url"
"os"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// v1RepoPath constructs the v1 API path for a repository.
func v1RepoPath(owner, repo string) string {
return fmt.Sprintf("/v1/%s/%s", owner, repo)
}
func normalizePRListStatus(state string) string {
switch state {
case "open", "opened":
return "0"
case "merged":
return "1"
case "closed":
return "2"
default:
return ""
}
}
func normalizeIssueListCategory(state string) string {
switch state {
case "open", "opened":
return "opened"
case "closed":
return "closed"
default:
return "all"
}
}
func fetchPRListPage(ctx *common.RuntimeContext, state string, page, limit int) ([]interface{}, error) {
q := url.Values{}
q.Set("page", fmt.Sprintf("%d", page))
q.Set("limit", fmt.Sprintf("%d", limit))
if status := normalizePRListStatus(state); status != "" {
q.Set("status", status)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx.Owner, ctx.Repo)+"/pulls", q)
if err != nil {
fmt.Fprintf(os.Stderr, " CLI error: pr list state=%s page=%d: %v\n", state, page, err)
return nil, err
}
if !env.OK {
err := fmt.Errorf("API error: pr list state=%s page=%d", state, page)
fmt.Fprintf(os.Stderr, " %v\n", err)
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response type for pr list")
}
pulls, _ := data["pulls"].([]interface{})
return pulls, nil
}
// fetchPRDetail retrieves full PR detail to extract merged_at timestamp.
// The list API doesn't return merged_at; only the detail endpoint does.
func fetchPRDetail(ctx *common.RuntimeContext, prNumber int) (string, error) {
path := fmt.Sprintf("%s/pulls/%d", ctx.RepoPath(), prNumber)
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return "", fmt.Errorf("PR detail fetch #%d: %w", prNumber, err)
}
if !env.OK {
return "", fmt.Errorf("PR detail API error #%d", prNumber)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", nil
}
// Try multiple possible locations for merged_at in the detail response
for _, candidate := range []string{
"merged_at",
"mergedAt",
} {
// data.pull_request.merged_at
if pr, _ := data["pull_request"].(map[string]interface{}); pr != nil {
if v, _ := pr[candidate].(string); v != "" {
return v, nil
}
}
// data.issue.merged_at
if issue, _ := data["issue"].(map[string]interface{}); issue != nil {
if v, _ := issue[candidate].(string); v != "" {
return v, nil
}
}
// data.merged_at (flat)
if v, _ := data[candidate].(string); v != "" {
return v, nil
}
}
return "", nil
}
func fetchIssueListPage(ctx *common.RuntimeContext, owner, repo, state string, page, limit int) ([]interface{}, error) {
q := url.Values{}
q.Set("page", fmt.Sprintf("%d", page))
q.Set("limit", fmt.Sprintf("%d", limit))
q.Set("category", normalizeIssueListCategory(state))
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(owner, repo)+"/issues", q)
if err != nil {
fmt.Fprintf(os.Stderr, " CLI error: issue list state=%s page=%d: %v\n", state, page, err)
return nil, err
}
if !env.OK {
err := fmt.Errorf("API error: issue list state=%s page=%d", state, page)
fmt.Fprintf(os.Stderr, " %v\n", err)
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response type for issue list")
}
issues, _ := data["issues"].([]interface{})
return issues, nil
}

Some files were not shown because too many files have changed in this diff Show More