Compare commits

...

168 Commits

Author SHA1 Message Date
wbtiger 71ca2bb683 Merge pull request 'chore(skills): update skills/README.md' (#260) from co63oc/gitlink-cli:fix5 into master 2026-06-22 00:28:41 +08:00
wbtiger 593b5e8f0d Merge pull request 'feat(shortcut): add shortcuts/wiki' (#193) from co63oc/gitlink-cli:fix4 into master 2026-06-22 00:03:52 +08:00
co63oc dbf0f45ce3 chore(skills): update skills/README.md 2026-06-16 14:27:50 +08:00
co63oc 2c9a8b6192 feat(shortcut): add shortcuts/wiki 2026-06-16 10:24:15 +08:00
wbtiger 982f2cb336 Merge pull request 'feat(issue): add batch maintenance shortcuts' (#165) from wangyue111/gitlink-cli:feat/issue-batch-maintenance into master 2026-06-16 00:15:17 +08:00
wangyue789 0703d3eba9 feat(issue): add batch maintenance shortcuts 2026-06-15 08:21:10 +08:00
wbtiger ef7a2c6ac1 Merge pull request 'docs: add luwanzhou and whale_hihihi to contributors' (#247) from chore/add-contributors-luwanzhou-whale into master 2026-06-14 19:41:06 +08:00
Tiger 6eebfeb40d docs: add luwanzhou and whale_hihihi to contributors 2026-06-14 19:41:54 +08:00
wbtiger 6454645f50 Merge pull request 'feat(skills): add gitlink-issueops — Issue 驱动的 Agent 自动化示例 Skill(响应 #6)' (#220) from recorder/gitlink-cli:feat/issueops-skill into master 2026-06-14 19:35:41 +08:00
wbtiger d3485fce45 Merge pull request 'feat(examples): add pr-quality-gatekeeper end-to-end workflow(子题三:门禁闭环 + 全仓批扫)' (#219) from recorder/gitlink-cli:feat/example-workflow-pr-gatekeeper into master 2026-06-14 19:32:55 +08:00
wbtiger 67b46eece4 Merge pull request 'feat(skills): 新增 issue标签管理的skill、过期issue自动管理的skill' (#233) from yangsai/gitlink-cli:feat/gitlink-stale-issue-manager into master 2026-06-14 19:23:37 +08:00
wbtiger 1806382459 Merge pull request 'feat(skills): 新增 科研Fork影响力分析 的skill : gitlink-research-fork-impact' (#218) from yangsai/gitlink-cli:feat/gitlink-research-fork-impact into master 2026-06-14 19:21:20 +08:00
wbtiger 46154f7c5e Merge pull request 'feat(skills): 新增 学者/团队科研画像生成 的skill : gitlink-scholar-profile' (#149) from yangsai/gitlink-cli:feat/gitlink-scholar-profile into master 2026-06-14 19:20:42 +08:00
wbtiger a61a8c4ef4 Merge pull request 'feat(skills): 新增 3 个 Agent Skill — wiki-builder, pipeline-guardian, webhook-sentinel' (#144) from whale_hihihi/gitlink-cli:skills/new-skills into master 2026-06-14 19:18:43 +08:00
wbtiger 789db5e5ae Merge pull request 'feat: 新增 dataset 数据集管理快捷命令(list/view/create/update/delete-attachment)' (#243) from luwanzhou/gitlink-cli:feat/dataset-shortcuts into master 2026-06-14 18:51:49 +08:00
Tiger ecc5578cf4 Merge upstream/master: resolve conflicts in README.zh-CN.md and register_test.go
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 18:50:50 +08:00
wbtiger 8c29d98a57 Merge pull request 'feat(profile): 新增用户画像统计快捷命令' (#241) from luwanzhou/gitlink-cli:feat/profile-shortcuts into master 2026-06-14 18:32:04 +08:00
wbtiger 1c84cac060 Merge pull request 'feat(shortcut): add shortcuts/ignore' (#153) from co63oc/gitlink-cli:fix3 into master 2026-06-14 18:29:41 +08:00
wbtiger 706a1396ad Merge pull request 'chore(doc): fix README.md' (#152) from co63oc/gitlink-cli:fix1 into master 2026-06-14 18:28:17 +08:00
luwanzhou 29ca94db76 feat(dataset): add full dataset CRUD (view/create/update/delete-attachment)
Extend the dataset group beyond the verified project query to the full
documented contract:

- dataset +view              -> GET    /v1/{owner}/{repo}/dataset (+page/limit)
- dataset +list --ids        -> GET    /v1/project_datasets   (prod-verified)
- dataset +create            -> POST   /v1/{owner}/{repo}/dataset
- dataset +update            -> PUT    /v1/{owner}/{repo}/dataset
- dataset +delete-attachment -> DELETE /attachments/{uuid}

Quality over a bare contract port: bilingual (en-US/zh-CN) i18n help,
--dry-run preview on create/update, --yes confirmation guard on the
destructive attachment delete, and license-id validation.

Only /v1/project_datasets is currently reachable on production www; the
per-repo routes follow the published OpenAPI spec and return 404 there
until the platform deploys them (documented in the change note). Unit
tests exercise every command against a mock server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:01:57 +08:00
luwanzhou 7f731eb04f feat(dataset): add dataset query shortcuts
Add a `dataset` shortcut group for querying GitLink research datasets,
which previously had no shortcut coverage:

- dataset +list --ids <ids>  -> GET /v1/project_datasets
- dataset +view              -> GET /v1/project_datasets (project ID
                                 resolved from --owner/--repo)

Only the platform-wide query endpoint is available on production
gitlink.org.cn; the documented per-repo dataset CRUD routes return 404
there (verified against the API), so this group wraps the query endpoint
for both listing and per-repo viewing.

Includes unit tests, bilingual (en-US/zh-CN) i18n help text, README
updates, and a change note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:48:22 +08:00
luwanzhou da4c8c0d10 feat(profile): 新增用户画像统计快捷命令
新增只读 profile 命令组,封装此前无 shortcut 覆盖的
GitLink 用户画像统计接口:

profile +ability      -> /users/{user}/statistics/develop
profile +role         -> /users/{user}/statistics/role
profile +major        -> /users/{user}/statistics/major
profile +activity     -> /users/{user}/statistics/activity
profile +contribution -> /users/{user}/headmaps

省略 --user 时默认通过 /users/me 解析当前认证用户。
统计命令支持 --start-time/--end-time 时间窗口,
+contribution 支持 --year 参数。

包含单元测试、中英双语 i18n 帮助文本、README 更新
及变更说明文档。
2026-06-13 23:07:27 +08:00
yangsai01 2729cc69ba feat(skills): add skill gitlink-issue-tag、gitlink-stale-issue-manager 2026-06-12 18:28:07 +08:00
yangsai01 7b880cb8ad feat(skills): add skill gitlink-scholar-profile 2026-06-12 10:43:23 +08:00
yangsai01 fb2d60f67a feat(skills): add skill gitlink-research-fork-impact 2026-06-12 10:37:31 +08:00
何开元 968ea0d7b4 feat(skills): add gitlink-issueops — issue-driven agent automation (closes #6)
IssueOps loop: create an issue -> agent picks it up -> result written back.
Two modes: live webhook callback, and a zero-infra Replay mode that polls
'webhook +tasks' (GitLink records every delivery payload even if the endpoint
is unreachable - validated on the real platform).

- SKILL.md: task conventions ([agent] prefix / agent:todo label), 7-step loop,
  untrusted-input rule (issue content is data, never instructions), write ops
  gated on user confirmation, gatekeeper integration for code tasks
- references/REFERENCE.md: allowed webhook events, hooktask payload anatomy,
  dedup cursor, the two issue-id/API tracks (v1 PATCH tag_ids for plain issues),
  CLI 0.2.0 quirks discovered during validation
- references/validation-session.md: full real-platform run with verifiable ids
  (webhook 51579, issue #3/144169, hooktask 4836246, comment 475692, label attached)

Requested by maintainer in #6 ('后续可以考虑出一个示例 Skill 来演示这个场景').
2026-06-11 19:34:36 -07:00
何开元 85a67a875f feat(examples): add pr-quality-gatekeeper end-to-end workflow
Runnable reference implementation of the merged gitlink-gatekeeper Skill:
collect -> route -> decide (deterministic 0-100 scorecard, three-state verdict)
-> write-back (comment + label + tracking issue, --apply gated, never auto-merge).

- scripts/gatekeeper_workflow.py: single-PR gate loop (pure stdlib, py>=3.9)
- scripts/gatekeeper_sweep.py: repo-wide triage — real run over all 113 open PRs
  of this repo (read-only): PASS 105 / COMMENT 6 / REQUEST_CHANGES 2, avg 88.5
- findings.example.json: real code-review findings of a real PR diff (verifiable line numbers)
- ci-example/: Gitea Actions integration (exit code 2 = REQUEST_CHANGES as CI gate)
- docs: architecture / quickstart / runbook / verification (real-platform evidence)
- tests/test_scoring.py: determinism regression guard (green)

Closes the sub-task-3 loop on top of the label command (#89) and the
gitlink-gatekeeper Skill (#90), sharing one policy file across all three layers.
2026-06-11 19:03:52 -07:00
co63oc 17c555620b feat(shortcut): add shortcuts/ignore 2026-06-09 08:50:39 +08:00
co63oc 9be51d34fc chore(doc): fix README.md 2026-06-09 07:56:38 +08:00
Tiger 52b7093846 chore: bump version to 0.2.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:36:12 +08:00
Tiger d0ca6b24ec Add ohanabi and jiangtx to README contributors
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:21:01 +08:00
wbtiger 462ae7b012 Merge pull request 'feat(repo): 新增 repo +tree 文件树查询命令' (#143) from ohanabi/gitlink-cli:feat/repo-tree-shortcut-final into master 2026-06-09 01:18:09 +08:00
wbtiger 220aa70e51 Merge pull request 'feat(workflows): add project bootstrap automation example' (#112) from puygob236/gitlink-cli:project-bootstrap-automation-fork into master 2026-06-09 01:10:10 +08:00
wbtiger 21966bf1ea Merge pull request 'feat(release): add edit and update shortcuts' (#69) from wangyue111/gitlink-cli:feat/release-update-shortcuts into master 2026-06-09 01:01:55 +08:00
Jiachen Li caad409185 docs(workflows): refine bootstrap architecture svg 2026-06-08 16:29:11 +08:00
Jiachen Li 60e8ab7768 docs(workflows): address project bootstrap review feedback 2026-06-08 16:00:24 +08:00
NeeNe 21ebb2f89c chore: 打磨仓库文件树命令交付质量 2026-06-08 12:13:28 +08:00
whale f1dd01bebc feat(skills): 新增 3 个 Agent Skill — wiki-builder, pipeline-guardian, webhook-sentinel
新增 Skill:
- gitlink-wiki-builder: Wiki 文档自动化,填补 9 个 wiki CLI 命令的 Skill 空白
- gitlink-pipeline-guardian: 流水线健康监控、故障分析和性能优化
- gitlink-webhook-sentinel: Webhook 投递监控、故障诊断和安全审计

每个 Skill 包含 SKILL.md + examples/,已在 Claude Code 中验证通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:01:48 +08:00
NeeNe 0da1d8d7bf docs: 补充仓库文件树命令变更说明 2026-06-08 11:55:09 +08:00
NeeNe a580562922 chore: 打磨仓库文件树命令文档和国际化 2026-06-08 11:51:54 +08:00
NeeNe 6e45fbb3e1 feat: 新增仓库文件树快捷命令 2026-06-08 11:36:41 +08:00
wangyue789 2d963ebf41 feat(release): add edit and update shortcuts 2026-06-08 08:30:21 +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
puygob236 b884a7478c Delete examples/workflows/project-bootstrap-automation/docs/assets/bootstrap-architecture.svg 2026-06-04 15:18:34 +08:00
Jiachen Li 6353987267 docs(workflows): use rendered project bootstrap architecture figure 2026-06-04 15:18:50 +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
Jiachen Li 82ac20ae74 docs(workflows): fix project bootstrap architecture layout 2026-06-04 14:57:42 +08:00
puygob236 5711aa3654 Update architecture.md 2026-06-04 14:45:46 +08:00
Jiachen Li a2fd36b4a6 docs(workflows): add project bootstrap submission materials 2026-06-04 14:44:22 +08:00
Jiachen Li b319c44b02 docs(workflows): align project bootstrap validation command 2026-06-04 14:30:04 +08:00
Jiachen Li 33ad8e62a0 feat(workflows): add project bootstrap automation example 2026-06-04 14:30:04 +08: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
wbtiger 42bc83295a Merge pull request '新增 workflow Shortcut 命令,支持 Issue 分诊、PR 摘要与仓库报告' (#29) from muel/gitlink-cli:workflow-agent-suite into master 2026-05-26 16:28:27 +08:00
wbtiger 6b355e19e2 fix(workflow): improve input mode detection and error hint
- hasLocalTriageInput now also detects triage-specific flags (--body,
  --number, --author, --url, --labels) to enter local input mode.
- hasLocalHealthInput now checks all health-related flags to prevent
  invalid values like --open-issues=abc from silently falling through
  to remote fetch instead of being validated.
- Remote fetch failure in +triage now includes a hint suggesting
  --title or --from for local analysis.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:28:28 +08:00
whzy b3f139801b docs: trim materials for official PR 2026-05-26 16:28:28 +08:00
whzy 29dee2697e chore: ignore local gitlink-cli binary 2026-05-26 16:28:28 +08:00
whzy be8327822a docs: remove continuation file from PR draft 2026-05-26 16:28:28 +08:00
whzy b0327204d0 docs: update final submission checklist placeholders 2026-05-26 16:28:28 +08:00
whzy 018c08e178 docs: align workflow agent competition materials 2026-05-26 16:28:28 +08:00
whzy 2dc230430b docs: add competition submission materials 2026-05-26 16:28:28 +08:00
whzy a904f48099 Add workflow repo-report command 2026-05-26 16:28:28 +08:00
whzy 7730347253 Add workflow pr-summary command 2026-05-26 16:28:00 +08:00
whzy 5673a109a4 Add workflow agent triage and health commands 2026-05-26 16:28:00 +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
wbtiger a46e06b78a Merge pull request 'fix: normalize issue +list output to use project-level issue numbers' (#31) from fix/issue-list-normalize-ids into master 2026-05-24 01:04:31 +08:00
Tiger 64c0ff3cb5 Normalize issue +list output to use project-level issue numbers
The API returns both "id" (global database PK) and "project_issues_index"
(per-project sequential number). The +list JSON output was a raw API
pass-through, making the database ID the most prominent identifier.

This change normalizes each issue in the list:
- Add "number" field from project_issues_index (matches the web URL)
- Rename "id" to "database_id" to prevent confusion

The "number" field now matches the --number flag used by +view, +close,
+update, and +comment commands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 01:00:31 +08:00
Tiger cbe9186c5c Style contributors avatars in horizontal row with gap
Use flex layout with 20px gap (half avatar width) and rounded avatars.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:34:26 +08:00
Tiger 2fb99ee0cc Add contributors section to READMEs
List all 9 contributors by PR count (desc), showing avatars and
GitLink IDs, placed before "Why gitlink-cli" section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:31:47 +08:00
Tiger 4bfbfc6e8f Replace PR #20 webhook with PR #22 improved implementation
- Use v1 API paths for all webhook endpoints (fixes view/test returning HTML/404)
- Add +tasks command for listing webhook delivery tasks
- Add webhook event validation (10-event whitelist)
- Add webhook type validation (10-type whitelist)
- Add content-type and http-method validation
- Fix update to preserve unspecified fields via fetch-and-merge
- Add event deduplication in parseWebhookEvents
- Add 9 comprehensive unit tests (up from 3)
- Use --http-method flag (maintains backward compatibility with PR #20)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 22:45:34 +08:00
Tiger 49cd590227 Merge PR #26: feat(pr): add review shortcuts
Resolved conflicts between PR #24 (+versions, +version-diff) and PR #26
(+reviews, +review). Unified prReviewsPath() to use existing prV1Path() helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 22:12:01 +08:00
wbtiger 2b64b8efa7 Merge pull request 'feat(pr): add patchset version shortcuts' (#24) from wangyue111/gitlink-cli:feat/pr-patchset-versions into master 2026-05-23 21:45:41 +08:00
wbtiger 9b6268cc47 Merge pull request 'feat: add webhook shortcut group' (#20) from Mengz/gitlink-cli:codex/webhook-shortcut into master 2026-05-23 21:44:22 +08:00
wbtiger 255410181f Merge pull request 'feat(skills): add gitlink-code-review, gitlink-insight, gitlink-compliance Skills' (#28) from Leo77/gitlink-cli:master into master 2026-05-23 21:27:42 +08:00
wbtiger d0a817c5a8 Merge pull request 'feat(skills): 新增检查提交信息是否规范的的skill : gitlink-commit-quality' (#27) from yangsai/gitlink-cli:feat/commit-quality into master 2026-05-23 21:26:21 +08:00
wbtiger ba78cb75b2 Merge pull request 'feat(skills): 新增自动发版的skill : gitlink-release-auto' (#25) from yangsai/gitlink-cli:feat/gitlink-release-auto into master 2026-05-23 21:24:33 +08:00
wangyue789 fbea42ecce feat(pr): add review shortcuts 2026-05-21 17:20:15 +08:00
yangsai01 67059ec1b2 feat(skills): add skills gitlink-release-auto 2026-05-21 17:00:55 +08:00
yangsai01 c57bd280c5 feat(skills): add skills gitlink-commit-quality 2026-05-21 15:43:35 +08:00
wangyue789 e85dd367d8 feat(pr): add patchset version shortcuts 2026-05-21 11:58:47 +08:00
2403_89190320 a302f2a530 docs(skills): add REFERENCE.md for all 3 Skills 2026-05-21 02:08:39 +08:00
2403_89190320 923e6af127 feat(skills): add gitlink-code-review, gitlink-insight, gitlink-compliance Skills 2026-05-21 01:58:41 +08:00
Leo77 7bd29fdf15 Delete gitlink-code-review 2026-05-21 01:49:29 +08:00
Leo77 478e7c8f88 Add gitlink-code-review 2026-05-21 01:40:30 +08:00
Mengz df1d6bb29e test: fix webhook endpoint expectations 2026-05-20 09:30:51 +08:00
Mengz 2f84f3c62e docs: finalize webhook shortcut docs 2026-05-19 15:54:31 +08:00
Mengz 83a642a72d feat: add webhook shortcut group 2026-05-19 10:55:23 +08:00
308 changed files with 47134 additions and 520 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 ./...

3
.gitignore vendored Normal file
View File

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

480
README.md
View File

@ -5,16 +5,93 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows, with 40+ commands and 12 AI Agent [Skills](./skills/).
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/README.md).
**[中文文档](./README.zh-CN.md)**
[Install](#installation--quick-start) · [AI Agent Skills](#ai-agent-skills) · [Auth](#configure--use) · [Commands](#usage-examples) · [Contributing](#related-projects)
## Contributors
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;">
<div align="center">
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/wangyue111">wangyue111</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/wbtiger">wbtiger</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/Mengz">Mengz</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/yangsai">yangsai</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/mengcheng">mengcheng</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/images/avatars/User/149182?t=1779603476" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/muel">muel</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/Leo77">Leo77</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/yingjie">yingjie</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/topshare">topshare</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/dtwdtw" title="dtwdtw"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/D/53_166_51/120.png" width="40" height="40" alt="dtwdtw" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/dtwdtw">dtwdtw</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/recorder" title="recorder"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/R/141_201_87/120.png" width="40" height="40" alt="recorder" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/recorder">recorder</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/puygob236" title="Jiachen Li"><img src="https://www.gitlink.org.cn/images/avatars/User/149183?t=1778815174" width="40" height="40" alt="puygob236" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/puygob236">puygob236</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/co63oc" title="co63oc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/C/205_201_141/120.png" width="40" height="40" alt="co63oc" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/co63oc">co63oc</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/lindiwen23" title="lindiwen23"><img src="https://www.gitlink.org.cn/images/avatars/User/141609?t=1748270628" width="40" height="40" alt="lindiwen23" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/lindiwen23">lindiwen23</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/ohanabi" title="ohanabi"><img src="https://www.gitlink.org.cn/images/avatars/User/148166?t=1778230283" width="40" height="40" alt="ohanabi" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/ohanabi">ohanabi</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/jiangtx" title="jiangtx"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/J/67_157_94/120.png" width="40" height="40" alt="jiangtx" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/jiangtx">jiangtx</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/luwanzhou" title="luwanzhou"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/165_135_246/120.png" width="40" height="40" alt="luwanzhou" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/luwanzhou">luwanzhou</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/whale_hihihi" title="whale_hihihi"><img src="https://www.gitlink.org.cn/images/avatars/User/137722?t=1778575729" width="40" height="40" alt="whale_hihihi" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/whale_hihihi">whale_hihihi</a></sub>
</div>
</div>
## Why gitlink-cli?
- **Agent-Native Design** — 12 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Branch, Release, CI, Org, Search, User — all core domains covered
- **Agent-Native Design**Structured [Skills](./skills/README.md) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
@ -26,15 +103,22 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| Category | Capabilities |
|----------|-------------|
| 📦 Repo | List, create, fork, delete repositories, view repo info |
| 🐛 Issue | Create, update, close, batch close, comment on issues |
| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions |
| 🐛 Issue | Create, update, close, batch close/update/delete, comment on issues |
| 🔖 Label | Create, list, update, delete issue labels |
| 🔀 PR | Create, merge, review pull requests, view changed files |
| 👥 Member | List, add, remove repository members, change roles, create and accept invite links |
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
| 🏷️ Release | Create, view, delete releases |
| 🏷️ Release | Create, edit, update, view, delete releases |
| 🏢 Org | Manage organizations, members, teams |
| 🔧 CI | View builds, logs, CI/CD operations |
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
| 🔔 Webhook | Manage repo webhooks and test deliveries |
| 📖 Wiki | List, view, create, update, and delete wiki pages |
| 🔍 Search | Search repositories, users |
| 📊 Dataset | Query research datasets by project |
| 👤 User | View user profiles and info |
| 📊 Profile | User ability, role, major, activity, and contribution statistics |
| 📋 PM | Sprint management, kanban boards, weekly reports |
| 🤖 Workflow | AI-powered issue triage, PR review, release notes |
@ -55,7 +139,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
**From npm (recommended):**
```bash
# One command: installs CLI binary + all 12 AI Agent Skills
# One command: installs CLI binary + AI Agent Skills
npm install -g @gitlink-ai/cli
```
@ -136,6 +220,36 @@ gitlink-cli repo +list
# View repository info
gitlink-cli repo +info --owner Gitlink --repo forgeplus
# Read repository README
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
# List repository files at root or a directory
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
# Show language breakdown
gitlink-cli repo +languages --owner Gitlink --repo forgeplus
# List contributors
gitlink-cli repo +contributors --owner Gitlink --repo forgeplus
# Show contributor code-line stats for a branch, tag, or commit
gitlink-cli repo +contributor-stats --owner Gitlink --repo forgeplus --ref master --pass-year 1
# Show repository code stats
gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master
# List watchers and stargazers in a time range
gitlink-cli repo +watchers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
gitlink-cli repo +stargazers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
# Preview and apply repository interaction actions
gitlink-cli repo +follow --owner Gitlink --repo forgeplus --dry-run
gitlink-cli repo +follow --owner Gitlink --repo forgeplus
gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123
gitlink-cli repo +like --owner Gitlink --repo forgeplus
gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123
# Create a repository
gitlink-cli repo +create -n my-project -d "Project description"
@ -143,6 +257,67 @@ gitlink-cli repo +create -n my-project -d "Project description"
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
```
### Webhook Management
```bash
# List webhooks
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
# Create a webhook
gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
--url https://example.com/hook --events push,create
# Test a webhook
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
# View webhook delivery tasks
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### Wiki Management
```bash
# List wiki pages (table of contents)
gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
# View a wiki page by page name
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
# Create a wiki page
gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
-n getting-started -t "Getting Started" -c "# Getting Started Guide"
# Update a wiki page title and/or content
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# Updated content"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title" -c "New content"
# Delete a wiki page
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
```
### Member Management
```bash
# List repository members
gitlink-cli member +list --owner Gitlink --repo forgeplus
# Add a member
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
# Preview batch add without changing data
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
# Batch add members from a CSV file
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
# Change a member role
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
# Create an invite link
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
```
### Issue Management
```bash
@ -152,9 +327,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
# Create an issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..."
# Create an issue with metadata
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
# View an issue
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
# Update issue metadata
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
# Close an issue
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
@ -164,8 +345,55 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
# Batch close issues from a CSV file
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
# Preview batch metadata update by API issue IDs
# Note: --ids uses API issue IDs, not web URL issue numbers.
gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run
# Destructive batch delete requires both dry-run first and --yes for real execution
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes
# Add a comment
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
# List issue assigners
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
# List issue authors
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
# List issue priorities
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus
# List issue tags
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
# List issue statuses
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
```
`issue +view`, `issue +update`, `issue +close`, and `issue +comment` prefer
`--number` / `-n` for the issue number shown in the web URL. `--id` / `-i`
is accepted as a compatibility alias for the same web issue number, not the
global database ID.
### Label Management
```bash
# List issue labels
gitlink-cli label +list --owner Gitlink --repo forgeplus
# Filter labels by keyword
gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug
# Create a label (color defaults to #1E90FF)
gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "Something is broken" -c "#FF0000"
# Update a label (unspecified fields are preserved)
gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00"
# Delete a label
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
```
### Pull Requests
@ -186,8 +414,24 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
# Merge a PR
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
# Reopen a closed PR
gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
# View changed files
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
# List PR patchset versions
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
# View a patchset version diff
gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040
# List PR reviews
gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
# Create a PR review (with dry-run preview)
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
```
### Branch Management
@ -215,11 +459,18 @@ gitlink-cli branch +unprotect --name main
# List releases
gitlink-cli release +list --owner Gitlink --repo forgeplus
# Create a release
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 Stable" -b "Changelog..."
# Create a release with release notes and optional assets
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 Stable" -b "Changelog..." --attachment-ids 12,34
# View a release
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
# Get edit data and update while preserving unspecified fields
gitlink-cli release +edit --owner Gitlink --repo forgeplus -i <version_id>
gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b "Updated changelog" --dry-run
# Preview release deletion before executing it
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --dry-run
```
### CI/CD Operations
@ -235,6 +486,38 @@ gitlink-cli ci +log --owner Gitlink --repo forgeplus -i <build_id>
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
```
### Pipeline Operations
```bash
# List platform pipelines
gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20
# List repository pipeline runs
gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml
# Start a pipeline workflow, previewing the request first
gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run
# Inspect pipeline details and logs
gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7
gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43
gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99
# Toggle or delete pipeline workflows, previewing destructive writes first
gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
```
### Ignore File Templates
```bash
# List all available .gitignore templates
gitlink-cli ignore +list
# Filter templates by name
gitlink-cli ignore +list --name Go
```
### Search
```bash
@ -245,6 +528,154 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### User Profile
`profile` surfaces GitLink's native user statistics (ability, role, major, activity,
contribution). When `--user` is omitted it defaults to the authenticated user.
```bash
# Development ability scores + language breakdown
gitlink-cli profile +ability --user zhangsan
# Role positioning / major (discipline) categories
gitlink-cli profile +role --user zhangsan
gitlink-cli profile +major --user zhangsan
# Ability within a time window (Unix timestamps)
gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600
# Recent activity (issues / PRs / commits per day) for the current user
gitlink-cli profile +activity
# Contribution heatmap for a given year
gitlink-cli profile +contribution --user zhangsan --year 2025
```
### Workflow Agent Commands
`workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports:
- `workflow +triage`
- `workflow +health`
- `workflow +pr-summary`
- `workflow +repo-report`
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
Examples:
```bash
# Triage with local parameters
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
# Triage with JSON output
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
# Triage with Chinese markdown output
gitlink-cli workflow +triage \
--title "安装失败,无法登录" \
--body "运行命令时报错" \
--lang zh-CN \
--format markdown
# Triage from a local JSON file
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
# Triage by read-only GitLink fetch
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
# Health for a healthy repository
gitlink-cli workflow +health \
--repository Gitlink/gitlink-cli \
--open-issues 3 \
--open-prs 1 \
--has-readme \
--has-license \
--has-contributing \
--agent-readiness-known \
--agent-readiness-score 9 \
--format table
# Health for a risky repository
gitlink-cli workflow +health \
--repository demo/repo \
--open-issues 60 \
--stale-issues 25 \
--open-prs 12 \
--stale-prs 6 \
--recent-activity-known \
--recent-activity-days 120 \
--release-known=false \
--format json
# Health with Chinese markdown output
gitlink-cli workflow +health \
--repository Gitlink/gitlink-cli \
--open-issues 3 \
--open-prs 1 \
--has-readme \
--has-license \
--has-contributing \
--lang zh-CN \
--format markdown
# Health by read-only GitLink fetch
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
# PR review summary by read-only GitLink fetch
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
# PR review summary from a local JSON file
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
# Repository workflow report by read-only GitLink fetch
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
# Repository workflow report from a local JSON file
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
```
Output formats:
- `json` for scripts and AI Agents
- `table` for terminal review
- `markdown` for Issue comments, PR comments, release notes, and competition write-ups
Safety:
- Current workflow commands use local analysis by default and can also read GitLink data in read-only fetch mode.
- They do not modify remote GitLink data.
- They do not depend on LLM APIs.
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
### Dataset
`dataset` manages and queries GitLink research datasets (title, description,
paper content, license, owning project).
```bash
# List datasets for one or more projects (by numeric project ID)
gitlink-cli dataset +list --ids 5988
# View a repository's dataset and attachments
gitlink-cli dataset +view --owner Gitlink --repo forgeplus
# Create / update a repository's dataset (preview first with --dry-run)
gitlink-cli dataset +create --owner me --repo proj -t "My dataset" -d "..." --license-id 359 --dry-run
gitlink-cli dataset +update --owner me --repo proj -t "My dataset" -d "updated"
# Delete a dataset attachment (destructive: preview, then confirm with --yes)
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --dry-run
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --yes
```
> Note: `dataset +list` (platform dataset query) is verified on production
> gitlink.org.cn. The per-repo `+view`/`+create`/`+update` routes follow the
> published OpenAPI contract but are not yet deployed on production (they return
> 404 there); they will work once the platform enables them.
### Raw API
For endpoints not covered by shortcuts, use the Raw API directly:
@ -256,6 +687,12 @@ gitlink-cli api GET /users/me
# POST request
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
# POST request with body from a file
gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json
# POST request with body from stdin
Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin
# With query parameters
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
```
@ -266,7 +703,7 @@ gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|-----------|-------------|---------|
| `--owner` | Repository owner | `--owner Gitlink` |
| `--repo` | Repository name | `--repo forgeplus` |
| `--format` | Output format (json/table/yaml) | `--format json` |
| `--format` | Output format (json/table/yaml; workflow also supports markdown) | `--format json` |
| `--debug` | Enable debug output | `--debug` |
**Automatic context resolution:** When running inside a git repository, `--owner` and `--repo` are automatically resolved from `git remote origin`.
@ -293,24 +730,27 @@ git push gitlink
## AI Agent Skills
The `skills/` directory contains 12 Agent Skill files for AI-automated GitLink operations.
The `skills/` directory contains Agent Skill files for AI-automated GitLink operations.
See [skills/README.md](skills/README.md) for details.
See [skills/README.md](./skills/README.md) for details.
| Skill | Description |
|-------|-------------|
| `gitlink-shared` | Authentication, global parameters, safety rules, API notes |
| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) |
| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
| `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) |
| `gitlink-issue` | Issue operations (create, update, close, batch update/delete, comment, etc.) |
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
| `gitlink-member` | Repository member and invite link management |
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
| `gitlink-release` | Release management (create, view, delete, etc.) |
| `gitlink-release` | Release management (create, edit, update, view, delete, etc.) |
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
| `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) |
| `gitlink-search` | Search (repositories, users, etc.) |
| `gitlink-org` | Organization management (members, teams, etc.) |
| `gitlink-user` | User management (profile info, etc.) |
| `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) |
| `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) |
| `gitlink-health` | Project health analysis (PR/Issue metrics aggregation, health reports) |
## Project Structure
@ -333,10 +773,12 @@ gitlink-cli/
│ ├── repo/ # Repository shortcuts
│ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts
│ ├── member/ # Repository member shortcuts
│ ├── branch/ # Branch shortcuts
│ ├── release/ # Release shortcuts
│ ├── org/ # Organization shortcuts
│ ├── ci/ # CI shortcuts
│ ├── pipeline/ # Pipeline shortcuts
│ ├── search/ # Search shortcuts
│ ├── user/ # User shortcuts
│ └── register.go # Registration entry point
@ -360,7 +802,7 @@ gitlink-cli/
## Documentation
- [Skills Guide](skills/README.md) — AI Agent Skills detailed documentation
- [Skills Guide](./skills/README.md) — AI Agent Skills detailed documentation
- [Design Document](doc/design.md) — Architecture design and development plan
## FAQ
@ -415,7 +857,9 @@ Reinstall first:
npm install -g @gitlink-ai/cli
```
If the error persists, check whether the release page contains the asset for your platform, for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64. You can also download the binary manually from the release page or build from source with `go install .`.
If the error persists, check whether the release page contains the asset for your platform,
for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64.
You can also download the binary manually from the release page or build from source with `go install .`.
### Q: Where are credentials stored on Windows?
@ -423,7 +867,7 @@ gitlink-cli uses Windows Credential Manager for secure token storage. If Credent
### Q: Where can I find the full API reference?
See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md).
See [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md).
## License

View File

@ -5,16 +5,93 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 11 个 AI Agent [Skills](./skills/)。
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/README.md)。
**[English](./README.md)**
[安装](#安装与快速上手) · [AI Agent Skills](#ai-agent-skills) · [认证](#配置与使用) · [命令](#使用示例) · [贡献](#相关项目)
## 贡献者
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;">
<div align="center">
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/wangyue111">wangyue111</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/wbtiger">wbtiger</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/Mengz">Mengz</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/yangsai">yangsai</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/mengcheng">mengcheng</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/images/avatars/User/149182?t=1779603476" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/muel">muel</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/Leo77">Leo77</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/yingjie">yingjie</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/topshare">topshare</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/dtwdtw" title="dtwdtw"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/D/53_166_51/120.png" width="40" height="40" alt="dtwdtw" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/dtwdtw">dtwdtw</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/recorder" title="recorder"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/R/141_201_87/120.png" width="40" height="40" alt="recorder" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/recorder">recorder</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/puygob236" title="Jiachen Li"><img src="https://www.gitlink.org.cn/images/avatars/User/149183?t=1778815174" width="40" height="40" alt="puygob236" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/puygob236">puygob236</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/co63oc" title="co63oc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/C/205_201_141/120.png" width="40" height="40" alt="co63oc" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/co63oc">co63oc</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/lindiwen23" title="lindiwen23"><img src="https://www.gitlink.org.cn/images/avatars/User/141609?t=1748270628" width="40" height="40" alt="lindiwen23" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/lindiwen23">lindiwen23</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/ohanabi" title="ohanabi"><img src="https://www.gitlink.org.cn/images/avatars/User/148166?t=1778230283" width="40" height="40" alt="ohanabi" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/ohanabi">ohanabi</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/jiangtx" title="jiangtx"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/J/67_157_94/120.png" width="40" height="40" alt="jiangtx" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/jiangtx">jiangtx</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/luwanzhou" title="luwanzhou"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/165_135_246/120.png" width="40" height="40" alt="luwanzhou" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/luwanzhou">luwanzhou</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/whale_hihihi" title="whale_hihihi"><img src="https://www.gitlink.org.cn/images/avatars/User/137722?t=1778575729" width="40" height="40" alt="whale_hihihi" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/whale_hihihi">whale_hihihi</a></sub>
</div>
</div>
## 为什么选择 gitlink-cli
- **Agent-Native 设计** — 开箱即用 11 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/README.md),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
- **开源零门槛** — 木兰宽松许可证第2版MulanPSL-2.0`npm install` 即用
@ -26,15 +103,21 @@
| 分类 | 能力 |
|------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息 |
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息、洞察数据和互动状态 |
| 🐛 Issue | 创建、更新、关闭、批量关闭/更新/删除、评论 Issue |
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
| 🌿 分支 | 创建、删除、保护分支 |
| 🏷️ 发布 | 创建、查看、删除 Release |
| 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release |
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🔍 搜索 | 搜索仓库、用户 |
| 📊 数据集 | 按项目查询科研数据集 |
| 👤 用户 | 查看用户资料和信息 |
| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
@ -148,6 +231,36 @@ gitlink-cli repo +list
# 查看仓库信息
gitlink-cli repo +info --owner Gitlink --repo forgeplus
# 读取仓库 README
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
# 列出仓库根目录或指定目录文件
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
# 查看语言占比
gitlink-cli repo +languages --owner Gitlink --repo forgeplus
# 列出贡献者
gitlink-cli repo +contributors --owner Gitlink --repo forgeplus
# 查看分支、标签或提交的贡献者代码行统计
gitlink-cli repo +contributor-stats --owner Gitlink --repo forgeplus --ref master --pass-year 1
# 查看仓库代码统计
gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master
# 按时间范围查看关注者和点赞者
gitlink-cli repo +watchers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
gitlink-cli repo +stargazers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000
# 预览并执行仓库互动操作
gitlink-cli repo +follow --owner Gitlink --repo forgeplus --dry-run
gitlink-cli repo +follow --owner Gitlink --repo forgeplus
gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123
gitlink-cli repo +like --owner Gitlink --repo forgeplus
gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123
# 创建仓库
gitlink-cli repo +create -n my-project -d "项目描述"
@ -155,6 +268,67 @@ gitlink-cli repo +create -n my-project -d "项目描述"
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
```
### Webhook 管理
```bash
# 列出 webhook
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
# 创建 webhook
gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
--url https://example.com/hook --events push,create
# 测试 webhook
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
# 查看 webhook 投递任务
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### Wiki 管理
```bash
# 列出 Wiki 页面(目录结构)
gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
# 查看 Wiki 页面
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
# 创建 Wiki 页面
gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
-n getting-started -t "快速开始" -c "# 快速开始指南"
# 更新 Wiki 页面标题和/或内容
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# 更新后的内容"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题" -c "新内容"
# 删除 Wiki 页面
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
```
### 成员管理
```bash
# 列出仓库成员
gitlink-cli member +list --owner Gitlink --repo forgeplus
# 添加成员
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
# 预览批量添加成员,不修改数据
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
# 从 CSV 文件批量添加成员
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
# 调整成员权限
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
# 生成邀请链接
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
```
### Issue 管理
```bash
@ -164,9 +338,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
# 创建 Issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."
# 创建带元数据的 Issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
# 查看 Issue
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
# 更新 Issue 元数据
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
# 关闭 Issue
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
@ -176,8 +356,54 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
# 从 CSV 文件批量关闭 Issue
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
# 按 API issue id 预览批量更新元数据
# 注意:--ids 是 API issue id不是网页 URL 中的 Issue 编号。
gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run
# 危险批量删除必须先 dry-run真实执行还要显式 --yes
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes
# 添加评论
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
# 列出 Issue 负责人
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
# 列出 Issue 发布人
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
# 列出 Issue 优先级
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus
# 列出 Issue 标签
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
# 列出 Issue 状态
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
```
`issue +view`、`issue +update`、`issue +close` 和 `issue +comment` 推荐使用
`--number` / `-n` 传网页 URL 中的 Issue 编号。`--id` / `-i` 是同一网页 Issue
编号的兼容别名,不是数据库内部 ID。
### 标签管理
```bash
# 列出 Issue 标签
gitlink-cli label +list --owner Gitlink --repo forgeplus
# 按关键词筛选标签
gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug
# 创建标签(颜色默认 #1E90FF
gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "功能缺陷" -c "#FF0000"
# 更新标签(未指定的字段会被保留)
gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00"
# 删除标签
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
```
### Pull Request
@ -198,8 +424,24 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
# 合并 PR
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
# 重开已关闭的 PR
gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
# 查看 PR 变更文件
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
# 查看 PR patchset/version 列表
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
# 查看指定 patchset/version diff
gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040
# 查看 PR 审查记录
gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
# 创建 PR 审查(支持 dry-run 预览)
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
```
### 发布管理
@ -208,11 +450,50 @@ gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
# 列出 Release
gitlink-cli release +list --owner Gitlink --repo forgeplus
# 创建 Release
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..."
# 创建 Release,可附带附件 ID
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..." --attachment-ids 12,34
# 查看 Release
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
# 获取编辑数据并保留未传字段更新
gitlink-cli release +edit --owner Gitlink --repo forgeplus -i <version_id>
gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b "更新后的内容" --dry-run
# 删除前先预览请求
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --dry-run
```
### 流水线管理
```bash
# 列出平台流水线
gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20
# 列出仓库流水线运行记录
gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml
# 运行流水线工作流,先用 dry-run 预览请求
gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run
# 查看流水线详情、日志和运行结果
gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7
gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43
gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99
# 启停或删除流水线工作流,写入/删除前先预览
gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
```
### 忽略文件模板
```bash
# 列出所有可用的 .gitignore 模板
gitlink-cli ignore +list
# 按名称筛选模板
gitlink-cli ignore +list --name Go
```
### 搜索
@ -225,6 +506,50 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### 用户画像
`profile` 暴露 GitLink 原生的用户画像统计(开发能力、角色定位、专业定位、近期活动、贡献热力图)。
省略 `--user` 时默认使用当前认证用户。
```bash
# 开发能力评分 + 语言分布
gitlink-cli profile +ability --user zhangsan
# 角色定位 / 专业(学科)定位
gitlink-cli profile +role --user zhangsan
gitlink-cli profile +major --user zhangsan
# 指定时间范围的开发能力Unix 时间戳)
gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600
# 当前用户的近期活动(每日 疑修 / 合并请求 / 提交)
gitlink-cli profile +activity
# 指定年份的贡献热力图
gitlink-cli profile +contribution --user zhangsan --year 2025
```
### 数据集
`dataset` 管理并查询 GitLink 科研数据集(标题、描述、论文内容、许可证、所属项目)。
```bash
# 按数字项目 ID 列出一个或多个项目的数据集
gitlink-cli dataset +list --ids 5988
# 查看仓库的数据集及其附件
gitlink-cli dataset +view --owner Gitlink --repo forgeplus
# 创建 / 更新仓库数据集(先用 --dry-run 预览)
gitlink-cli dataset +create --owner me --repo proj -t "我的数据集" -d "..." --license-id 359 --dry-run
gitlink-cli dataset +update --owner me --repo proj -t "我的数据集" -d "更新"
# 删除数据集附件(破坏性:先预览,再用 --yes 确认)
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --dry-run
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --yes
```
> 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404待平台上线后即可生效。
### Raw API
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:
@ -236,6 +561,12 @@ gitlink-cli api GET /users/me
# POST 请求
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
# 从文件读取 JSON body
gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json
# 从 stdin 读取 JSON body
Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin
# 带查询参数
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
```
@ -273,19 +604,21 @@ git push gitlink
## AI Agent Skills
`skills/` 目录包含 11 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
`skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
详见 [skills/README.md](skills/README.md)
详见 [skills/README.md](./skills/README.md)
| Skill | 说明 |
|-------|------|
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
| `gitlink-repo` | 仓库操作创建、查看、删除、Fork 等) |
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
| `gitlink-repo` | 仓库操作创建、查看、删除、Fork、洞察数据等) |
| `gitlink-issue` | Issue 操作(创建、更新、关闭、批量更新/删除、评论等) |
| `gitlink-pr` | Pull Request 操作创建、合并、Review 等) |
| `gitlink-release` | 发布管理(创建、查看、删除等) |
| `gitlink-member` | 仓库成员与邀请链接管理 |
| `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) |
| `gitlink-org` | 组织管理(成员、团队等) |
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
| `gitlink-search` | 搜索功能(仓库、用户等) |
| `gitlink-user` | 用户管理(个人信息等) |
| `gitlink-pm` | 项目管理Sprint、看板、周报等 |
@ -312,10 +645,12 @@ gitlink-cli/
│ ├── repo/ # 仓库 shortcuts
│ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts
│ ├── member/ # 仓库成员 shortcuts
│ ├── branch/ # 分支 shortcuts
│ ├── release/ # Release shortcuts
│ ├── org/ # 组织 shortcuts
│ ├── ci/ # CI shortcuts
│ ├── pipeline/ # Pipeline shortcuts
│ ├── search/ # 搜索 shortcuts
│ ├── user/ # 用户 shortcuts
│ └── register.go # 注册入口
@ -339,7 +674,7 @@ gitlink-cli/
## 文档
- [Skills 使用指南](skills/README.md) — AI Agent Skills 详细说明
- [Skills 使用指南](./skills/README.md) — AI Agent Skills 详细说明
- [设计文档](doc/design.md) — 架构设计和开发计划
## 常见问题
@ -402,7 +737,7 @@ gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Creden
### Q: 如何查看完整的 API 参考?
查看 [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md)
查看 [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md)
## 许可证

View File

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

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

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
Format string
Debug bool
Lang string
)

View File

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

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

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,70 @@
# Dataset Shortcuts
## Summary
Adds a new `dataset` shortcut group for managing and querying GitLink research
datasets, which previously had no shortcut coverage. Datasets carry
research-oriented metadata (title, description, `paper_content`, license, owning
project) that is valuable for research/scientometric scenarios.
## Commands
| Command | Purpose | Endpoint |
|---------|---------|----------|
| `gitlink-cli dataset +view` | View a repository's dataset and attachments | `GET /v1/{owner}/{repo}/dataset` |
| `gitlink-cli dataset +list --ids <ids>` | List datasets for one or more projects | `GET /v1/project_datasets` |
| `gitlink-cli dataset +create` | Create a repository's dataset | `POST /v1/{owner}/{repo}/dataset` |
| `gitlink-cli dataset +update` | Update a repository's dataset | `PUT /v1/{owner}/{repo}/dataset` |
| `gitlink-cli dataset +delete-attachment --uuid <uuid>` | Delete a dataset attachment | `DELETE /attachments/{uuid}` |
## Behaviour
- `+view` paginates attachments via `--page`/`--limit`.
- `+list --ids 1,2,3` queries datasets by comma-separated numeric project IDs;
IDs are validated client-side before the request.
- `+create`/`+update` send `title`, `description`, optional `license-id`
(validated as a positive integer) and `paper-content`. Both support
`--dry-run` to preview the request body without writing.
- `+delete-attachment` is destructive: it requires `--dry-run` preview or an
explicit `--yes` confirmation before issuing the DELETE.
## Production status (verified)
Verified against production `gitlink.org.cn`:
- `GET /v1/project_datasets` (`+list`) — **available and verified** (e.g.
`--ids 5988` returns the forgeplus dataset).
- The per-repository routes `/v1/{owner}/{repo}/dataset`
(`+view`/`+create`/`+update`) currently return `404` on production www
(confirmed even for a repository's own owner; not reachable on the gateway
host either). They follow the documented contract and are expected to work
once the platform deploys these routes. `+delete-attachment` targets the
generic attachments endpoint.
The commands and request shapes match the published OpenAPI spec, so they are
ready the moment the routes go live; unit tests exercise every command against a
mock server.
## Tests
Unit tests cover the view path with pagination, `--ids` normalization and
validation, create/update request bodies and `license-id` validation, dry-run
previews, and the destructive-delete confirmation guard (`--yes`).
## 中文说明
### 变更内容
- 新增 `dataset` 命令组:`+view`、`+list`、`+create`、`+update`、`+delete-attachment`。
- `+view` 支持 `--page`/`--limit` 对附件分页;`+list --ids` 按项目 ID 查询。
- `+create`/`+update` 发送 `title`/`description`/可选 `license-id`/`paper-content`,均支持 `--dry-run` 预览。
- `+delete-attachment` 为破坏性操作,需 `--dry-run` 预览或显式 `--yes` 确认。
### 生产状态(已验证)
- `GET /v1/project_datasets``+list`)在生产**可用并已验证**(如 `--ids 5988` 返回 forgeplus 数据集)。
- `/v1/{owner}/{repo}/dataset``+view`/`+create`/`+update` 当前在生产 www 返回 `404`(即使对仓库 owner 也如此gateway 也未托管)。实现严格遵循已发布的 OpenAPI 契约,待平台部署后即可生效;单测以 mock 覆盖全部命令。
### 相对文档契约的增强
双语 i18n 帮助文案、写操作 `--dry-run` 预览、破坏性删除 `--yes` 二次确认、`license-id` 正整数校验。

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,8 @@
# Ignore shortcut
新增 `ignore` Shortcut 组,补齐 GitLink 忽略文件模板(`.gitignore`)查询:
- `ignore +list`
同时补充了单元测试、README 示例。

View File

@ -0,0 +1,68 @@
# Issue batch maintenance shortcuts
## Summary
Add OpenAPI-backed Issue batch maintenance shortcuts:
- `issue +batch-update` — batch update Issue status, priority, milestone, tags, and assigners by API issue IDs.
- `issue +batch-delete` — batch delete Issues by API issue IDs with explicit confirmation.
This complements the existing `issue +batch-close` command. `batch-close` uses web URL issue numbers, while the OpenAPI batch update/delete endpoints use API issue IDs.
## OpenAPI coverage
| Command | Method | Endpoint |
|---|---|---|
| `issue +batch-update` | PATCH | `/api/v1/{owner}/{repo}/issues/batch_update.json` |
| `issue +batch-delete` | DELETE | `/api/v1/{owner}/{repo}/issues/batch_destroy.json` |
## ID semantics
- `issue +batch-close --numbers` uses web URL Issue numbers (`project_issues_index`).
- `issue +batch-update --ids` and `issue +batch-delete --ids` use API Issue IDs returned by Issue APIs.
The docs and help text explicitly call this out to avoid mixing the two ID types.
## Safety and usability
- Both commands support `--dry-run`.
- `issue +batch-update` requires at least one update field.
- `issue +batch-delete` is destructive and requires `--yes` for real execution.
- ID lists are validated as positive integers and de-duplicated.
## Examples
```bash
gitlink-cli issue +batch-update \
--owner Gitlink \
--repo forgeplus \
--ids 101,102 \
--status-id 3 \
--priority-id 2 \
--tag-ids 7,8 \
--assigner-ids 11,12 \
--dry-run
gitlink-cli issue +batch-delete \
--owner Gitlink \
--repo forgeplus \
--ids 101,102 \
--dry-run
gitlink-cli issue +batch-delete \
--owner Gitlink \
--repo forgeplus \
--ids 101,102 \
--yes
```
## Tests
```bash
GOPROXY=https://goproxy.cn,direct go test ./...
go vet ./...
go run . issue +batch-update --help
go run . issue +batch-delete --help
go run . issue +batch-update --owner wangyue111 --repo gitlink-cli --ids 101,102 --status-id 3 --dry-run --format json
go run . issue +batch-delete --owner wangyue111 --repo gitlink-cli --ids 101,102 --dry-run --format json
```

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,56 @@
# Profile (User Statistics) Shortcuts
## Summary
Adds a new read-only `profile` shortcut group that wraps GitLink's user statistics
APIs (development ability, role positioning, major/discipline, recent activity, and
contribution heatmap). These endpoints previously had no shortcut coverage, forcing
agents to fall back to Raw API calls — the `gitlink-contributor-insight` skill even
documents `user +stats`/`user +heatmap` as unavailable. The `profile` group surfaces
the platform's native portrait data directly, powering "research subject portrait"
scenarios.
## Commands
| Command | Purpose | Endpoint |
|---------|---------|----------|
| `gitlink-cli profile +ability` | Development ability scores + language breakdown | `GET /users/{user}/statistics/develop` |
| `gitlink-cli profile +role` | Role positioning | `GET /users/{user}/statistics/role` |
| `gitlink-cli profile +major` | Major/discipline categories | `GET /users/{user}/statistics/major` |
| `gitlink-cli profile +activity` | Recent activity (issues/PRs/commits per day) | `GET /users/{user}/statistics/activity` |
| `gitlink-cli profile +contribution` | Contribution heatmap | `GET /users/{user}/headmaps` |
## Behaviour
- `--user`/`-u` selects the target user. When omitted, the user is resolved from the
authenticated account via `/users/me`, so `gitlink-cli profile +ability` works with
no arguments.
- `+ability`, `+role`, and `+major` accept optional `--start-time` / `--end-time`
(Unix timestamps) that map to the `start_time` / `end_time` query parameters.
- `+contribution` accepts an optional `--year` query parameter.
## Tests
Unit tests cover endpoint paths for every subcommand, the `start_time`/`end_time` and
`year` query parameter mapping, current-user fallback via `/users/me`, the missing-login
error path, and HTTP error handling.
## 中文说明
### 变更内容
- 新增 `profile` 命令组,封装 GitLink 用户画像统计接口:
- `profile +ability` 开发能力评分(影响力/贡献度/活跃度/项目经验/语言能力)及语言分布
- `profile +role` 角色定位
- `profile +major` 专业/学科定位(如深度学习、量子计算)
- `profile +activity` 近期活动统计(每日疑修/合并请求/提交数量)
- `profile +contribution` 贡献热力图
- `--user`/`-u` 指定目标用户;缺省时通过 `/users/me` 解析为当前认证用户。
- `+ability`/`+role`/`+major` 支持 `--start-time`/`--end-time`Unix 时间戳)。
- `+contribution` 支持 `--year`
### 价值
这些接口此前无任何 shortcut 封装,`gitlink-contributor-insight` Skill 甚至将
`user +stats`/`user +heatmap` 标注为"不可用"并改用 PR 时间戳手工推算。`profile`
命令组直接暴露平台原生画像数据,为"科研主体画像"等场景提供数据底座。

View File

@ -0,0 +1,23 @@
# Release Update Shortcuts
Submitter: Wang Yue
This change completes the release shortcut coverage for the release edit/update OpenAPI endpoints and improves release write safety.
## Commands
- Add `release +edit` for `/api/{owner}/{repo}/releases/{id}/edit.json`.
- Add `release +update` for `PUT /api/{owner}/{repo}/releases/{id}.json`.
- Extend `release +create` with `--draft` and `--attachment-ids`.
- Extend `release +delete` with `--dry-run`.
## Behavior
- `release +update` fetches current edit data first, then preserves unspecified fields such as `name`, `tag_name`, `body`, `target_commitish`, `draft`, `prerelease`, and existing attachment IDs.
- `release +update` validates boolean flags before reading remote data.
- `release +update` and `release +delete` support `--dry-run` to preview write/delete requests.
- `release +create` validates boolean flags and de-duplicates comma-separated attachment IDs.
## Verification
- Unit tests cover create payloads, edit endpoint routing, update field preservation, attachment overrides, dry-run behavior, and invalid argument validation.

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,65 @@
# repo +tree 仓库文件树查询命令
## 背景
`gitlink-cli repo` 已经提供仓库详情、README、语言统计和贡献者查询能力但缺少直接查看仓库目录结构的 Shortcut。用户或 AI Agent 如果要判断仓库中是否存在 README、LICENSE、依赖清单、测试目录、文档目录等文件过去需要手动调用 Raw API `/sub_entries`
本次变更把仓库文件树查询封装为 `repo +tree`,降低普通用户和自动化工作流的使用门槛。
## 变更内容
- 新增 `gitlink-cli repo +tree` Shortcut。
- 调用 `GET /{owner}/{repo}/sub_entries` 获取仓库根目录或指定目录下的文件和子目录。
- 支持 `--path, -p` 指定目录路径;不传时查询仓库根目录。
- 支持 `--ref, -r` 指定分支、标签或提交引用;默认值为 `master`
- 复用现有仓库上下文解析、API 调用和统一输出格式。
- 补充中英文 i18n 文案,避免新增命令帮助信息硬编码。
## 命令示例
```bash
# 查看仓库根目录
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
# 查看指定目录
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
# Agent 场景建议使用 JSON 输出
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --format json
```
## 参数说明
| 参数 | 必填 | 说明 |
|------|------|------|
| `--path, -p` | 否 | 要查看的目录路径,不传时查询仓库根目录 |
| `--ref, -r` | 否 | 分支、标签或提交引用,默认 `master` |
| `--owner` | 否 | 全局参数,仓库所有者,可从 git remote 自动解析 |
| `--repo` | 否 | 全局参数,仓库名称,可从 git remote 自动解析 |
| `--format` | 否 | 全局参数,输出格式:`json`、`table` 或 `yaml` |
## 测试覆盖
单元测试覆盖以下内容:
- 根目录查询默认使用 `master`
- 根目录查询不发送空 `filepath` 参数。
- 指定 `--path``--ref` 时正确映射到 `filepath``ref` 查询参数。
- `repo +tree` 的命令说明和 `--path/-p`、`--ref/-r` 参数注册完整。
验证命令:
```bash
make test
```
## 交付要求核对
- 功能代码:`shortcuts/repo/repo.go`
- 单元测试:`shortcuts/repo/repo_test.go`
- 命令帮助文档:`README.md`、`README.zh-CN.md`、`skills/gitlink-repo/SKILL.md`、`skills/gitlink-repo/references/gitlink-repo-tree.md`
- 变更说明文档:`doc/changes/repo-tree-shortcut.md`
## 兼容性
该变更只新增 Shortcut、单元测试和文档不修改已有命令参数或输出结构。根目录查询时不再发送空 `filepath` 查询参数,语义更清晰,对现有功能无破坏性影响。

View File

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

View File

@ -0,0 +1,23 @@
# Wiki Shortcut
新增 `wiki` Shortcut 组,支持 Wiki 页面管理:
- `wiki +list` - 列出 Wiki 页面(目录结构)
- `wiki +view` - 按页面名称查看 Wiki 页面详情
- `wiki +create` - 创建新的 Wiki 页面
- `wiki +update` - 更新 Wiki 页面标题和/或内容
- `wiki +delete` - 删除 Wiki 页面
## 实现要点
- **API 端点**:基于 `/api/wiki/open/{action}` 扁平路径结构,覆盖 5 个 Wiki 管理接口:
- `GET /api/wiki/open/wikiPages` — 目录列表
- `GET /api/wiki/open/getWiki` — 查看页面
- `POST /api/wiki/open/createWiki` — 创建页面
- `PUT /api/wiki/open/updateWiki` — 更新页面
- `DELETE /api/wiki/open/deleteWiki` — 删除页面
- **标识方式**Wiki 页面通过 `pageName`slug标识所有操作需要 `projectId`GitLink 项目数字 ID
- **内容编码**:创建和更新时,内容自动进行 base64 编码后以 `content_base64` 字段发送
- **更新保护**`+update` 要求必须提供 `--title``--page-name``--content` 为可选
- **Shortcut 模式**:使用 `common.Shortcut` + `RuntimeContext` 框架,与其他模块保持一致

View File

@ -40,7 +40,7 @@ gitlink-cli/
│ ├── common/
│ │ ├── types.go # Shortcut / Flag / RuntimeContext 定义
│ │ └── runner.go # CallAPI / PaginateAll / ResolveOwnerRepo
│ ├── repo/ # repo +create / +clone / +fork / +list / +info
│ ├── repo/ # repo +list / +info / +readme / +tree / +languages / +create ...
│ ├── issue/ # issue +list / +create / +view / +close / +comment
│ ├── pr/ # pr +list / +create / +view / +merge / +review
│ ├── release/ # release +list / +create / +download
@ -78,7 +78,7 @@ gitlink-cli/
| 领域 | Shortcuts | 数量 |
|------|-----------|------|
| repo | `+create` `+clone` `+fork` `+list` `+info` `+delete` `+settings` | 7 |
| repo | `+list` `+info` `+readme` `+tree` `+languages` `+contributors` `+contributor-stats` `+code-stats` `+watchers` `+stargazers` `+follow` `+unfollow` `+like` `+unlike` `+create` `+fork` `+delete` | 17 |
| issue | `+list` `+create` `+view` `+update` `+close` `+comment` `+assign` `+label` | 8 |
| pr | `+list` `+create` `+view` `+merge` `+close` `+review` `+files` `+diff` | 8 |
| release | `+list` `+create` `+view` `+delete` `+download` | 5 |

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.

105
docs/pr-draft.md Normal file
View File

@ -0,0 +1,105 @@
# feat(workflow): add agent workflow commands for repository maintenance
## Summary
This PR adds four read-only workflow commands for repository maintenance:
- `workflow +triage`
- `workflow +health`
- `workflow +pr-summary`
- `workflow +repo-report`
The commands provide rule-based, explainable analysis with stable `json`, concise `table`,
and copy-friendly `markdown` output.
## Motivation
Open-source maintainers often spend time on repetitive information organization before
making actual decisions:
- Issue triage cost
- PR review cost
- repository health visibility
- Agent needs stable structured output
This PR adds workflow-level analysis on top of the existing GitLink CLI shortcut architecture
without introducing LLM dependencies or remote write behavior.
## Changes
### `workflow +triage`
- Classifies issues by type
- Scores priority and confidence
- Detects missing bug-report information
- Produces risk flags, recommended actions, suggested comments, and reasoning
### `workflow +health`
- Scores repository health
- Covers issue/PR backlog, activity, release, CI, docs, license, contributing, and Agent readiness signals
- Tolerates unknown metrics without failing the command
### `workflow +pr-summary`
- Summarizes PR metadata, changed files, and commits
- Produces change type, risk level, review focus, test suggestions, merge checklist, and reasoning
- Supports local JSON input and remote read-only PR fetch
### `workflow +repo-report`
- Aggregates health, issue triage, and PR summary signals
- Produces a repository workflow report with score, risk level, recommendations, and reasoning
- Supports partial read-only remote aggregation when optional sections are unavailable
## Safety
- Remote mode is read-only
- No LLM dependency
- No labels/comments/close operations
- No PR approve/reject/merge operations
- No `internal/output` change
- No new third-party dependency
- Test fixtures do not contain secrets or tokens
## Tests
```bash
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
go test ./shortcuts/workflow
go test ./...
```
Coverage includes:
- triage rules
- health scoring
- PR summary rules
- repo report aggregation
- fetch normalization
- partial failure handling
- `json` / `table` / `markdown` rendering
- local `--from` fixtures
- command wiring tests
## Documentation
- `README.md`
- `docs/workflow-agent-design.md`
- `docs/workflow-agent-test-report.md`
- `skills/gitlink-workflow/SKILL.md`
## Known Limitations
- `workflow +release-notes` is not implemented.
- `workflow +stale` is not implemented.
- Real GitLink API shapes may require follow-up normalization.
## Examples
```bash
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
```

View File

@ -0,0 +1,502 @@
# GitLink CLI Workflow Agent Design
## Background
`gitlink-cli` already provides low-level and shortcut operations for GitLink repositories,
issues, pull requests, releases, CI, organizations, search, and users.
The repository also includes `skills/gitlink-workflow/SKILL.md`, which describes
AI workflow patterns such as Issue triage, PR review, and Release Notes generation.
The current Go command tree did not include a `workflow` command group before this work.
The competition PR turns the documented workflow concept into concrete,
deterministic CLI commands that can be used by human maintainers and AI Agents
without calling an external LLM.
## Goals
First PR:
- Add `gitlink-cli workflow +triage`.
- Add `gitlink-cli workflow +health`.
- Keep write behavior dry-run by default.
- Produce stable JSON for Agents.
- Produce concise table output for terminal users.
- Produce markdown output for reports, PR comments, Issue comments, and competition materials.
- Support `--lang en` and `--lang zh-CN` with a lightweight message helper.
Additional workflow commands:
- `workflow +pr-summary`: done
- `workflow +repo-report`: done
- `workflow +release-notes`: planned
- `workflow +stale`: planned
Current implementation status:
- Rule engine: done
- Local command layer: done
- API fetch layer: done
- Boundary tests: expanded for empty responses, field normalization,
unknown tolerance, and read-only error handling
- PR summary command: done with local JSON input, read-only fetch, rules, renderers, and tests
- Repo report command: done with local JSON input, partial read-only fetch aggregation,
scoring, renderers, and tests
## Current Repository Findings
Command registration:
- `cmd/root.go` registers global flags and calls `shortcuts.RegisterAll(rootCmd)`.
- `shortcuts/register.go` maps command groups to shortcut slices.
- Each group exposes `Shortcuts() []*common.Shortcut`.
- `common.MountShortcut` maps a `Shortcut` into a Cobra command named `+<name>`.
Runtime and API calls:
- `common.NewRuntimeContext` creates `client.Client`, carries owner, repo, format, and command args.
- `ctx.ResolveOwnerRepo()` resolves `--owner` / `--repo` or Git remote context.
- `ctx.CallAPI` and `ctx.CallAPIWithQuery` call `internal/client`.
- `client.Do` appends `.json`, injects auth via transport, parses GitLink error-in-body responses, and returns `output.Envelope`.
Output:
- `internal/output` currently supports `json`, `yaml`, and generic `table`.
- Workflow requires `markdown`; the minimal-risk approach is a workflow-local renderer that prints stable workflow DTOs.
- A later cleanup can promote markdown support into `internal/output` if multiple command groups need it.
- Current workflow commands also expose workflow-local `json`, `table`, and `markdown` rendering without changing the global formatter.
Testing:
- Existing tests use pure unit tests plus `httptest.Server`.
- Shortcut tests instantiate `common.RuntimeContext` manually with a mocked `client.Client`.
- This pattern should be reused for workflow API tests.
## Command Design
### `workflow +triage`
Examples:
```bash
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --dry-run --format json
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --format table
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --lang zh-CN --format markdown
```
Flags:
- `--state`: default `open`
- `--limit`: default `30`
- `--page`: default `1`
- `--dry-run`: default `true`
- `--from`: optional local JSON input
- `--title`, `--body`, `--number`, `--author`, `--url`, `--labels`: optional local single-issue input
- `--lang`: default `en`, allowed `en`, `zh-CN`
Stable JSON item fields:
- `issue_id`
- `number`
- `title`
- `url`
- `author`
- `state`
- `created_at`
- `updated_at`
- `detected_type`
- `priority`
- `confidence`
- `suggested_labels`
- `missing_information`
- `risk_flags`
- `recommended_action`
- `suggested_comment`
- `reasoning`
Rule categories:
- `bug`
- `feature`
- `question`
- `docs`
- `ci`
- `security`
- `performance`
- `refactor`
- `unknown`
Priority:
- `P0`: security incident, secret/token leak, auth bypass, repository unusable
- `P1`: core command unusable, install/login failure, CI/release blocker
- `P2`: normal bug, important feature, missing docs blocking usage
- `P3`: ordinary question, typo, minor improvement
Missing information for bug-like issues:
- reproduction steps
- expected behavior
- actual behavior
- version
- OS / platform
- command output
- logs
### `workflow +health`
Examples:
```bash
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --format json
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --format table
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --lang zh-CN --format markdown
```
Flags:
- `--stale-days`: default `30`
- `--from`: optional local JSON input
- local metric flags such as `--repository`, `--open-issues`, `--open-prs`, `--has-readme`, `--has-license`, and `--agent-readiness-score`
- `--lang`: default `en`
Stable JSON fields:
- `repository`
- `open_issues`
- `open_prs`
- `stale_issues`
- `stale_prs`
- `recent_activity`
- `release_status`
- `ci_status`
- `documentation_status`
- `license_status`
- `contribution_status`
- `agent_readiness_score`
- `health_score`
- `risk_level`
- `recommendations`
- `scoring_notes`
Scoring:
- Issue backlog and response: 20
- PR backlog and merge state: 20
- Recent activity: 15
- Release status: 15
- Documentation completeness: 10
- License and contribution readiness: 10
- Agent readiness: 10
Unknown metric policy:
- Keep field present.
- Set status or score detail to `unknown`.
- Add one entry to `scoring_notes`.
- Either omit the metric from denominator or apply a conservative partial score; the first PR should prefer denominator adjustment to avoid fake precision.
Risk levels:
- `low`: 80-100
- `medium`: 60-79
- `high`: 40-59
- `critical`: 0-39
## Architecture
Proposed files:
```text
shortcuts/workflow/
workflow.go # Shortcuts() and command wiring
types.go # Stable DTOs
triage_rules.go # pure classifier, scoring, missing info detection
triage_fetch.go # GitLink issue fetching and response normalization
triage_render.go # json/table/markdown workflow rendering if needed
health_score.go # pure health scoring
health_fetch.go # repo, issue, PR, release, CI/doc/license probes
health_render.go # markdown/table rendering
messages.go # en and zh-CN strings
*_test.go
```
Registration:
- Add `workflow` import in `shortcuts/register.go`.
- Add `"workflow": workflow.Shortcuts()` to `groups`.
- Add description `"AI agent workflow analysis"`.
No new dependency is needed for this PR.
## Data Normalization
GitLink responses vary by endpoint. Workflow code should not depend on a single raw shape. Add small extraction helpers:
- `stringField(map, keys...)`
- `numberField(map, keys...)`
- `timeField(map, keys...)`
- `sliceField(map, keys...)`
- `extractItems(env, candidateKeys...)`
Candidate issue list keys:
- `issues`
- `data`
- direct array after future client improvements
Candidate issue fields:
- ID: `id`, `issue_id`
- Number: `project_issues_index`, `number`, `index`, `id`
- Title: `subject`, `title`
- Body: `description`, `body`
- Author: `author.login`, `user.login`, `login`
- URL: `html_url`, `url`, `issue_url`
Health activity fields currently tolerated:
- `updated_at`
- `updatedAt`
- `last_updated_at`
- `lastUpdatedAt`
- `last_activity_at`
- `lastActivityAt`
- `merged_at`
- `mergedAt`
- `closed_at`
- `closedAt`
## Safety Strategy
- `+triage` only reads by default.
- `--dry-run` defaults true.
- A future explicit write flag for posting comments must require `--dry-run=false` in a later PR.
- Generated comments are output as data, not posted remotely in the first PR.
- Health checks never mutate remote state.
- If an API probe fails, health continues with `unknown`.
- The implemented prototype is local-first and has no LLM dependency.
- Remote fetch mode remains read-only and does not post comments, labels, merges, or close actions.
- API failures should fall back to `unknown` metrics or a clear fetch error instead of fabricating healthy data.
## Core Pseudocode
### Triage
```go
issues := fetchIssues(owner, repo, state, limit, page)
results := []TriageResult{}
for _, issue := range issues {
text := normalize(issue.Title + "\n" + issue.Body)
scores := scoreKeywords(text, keywordRules)
detectedType := maxScoreType(scores)
priority := scorePriority(text, detectedType)
missing := detectMissingInfo(issue, detectedType)
confidence := confidenceFromScores(scores, missing)
result := TriageResult{
IssueID: issue.ID,
Number: issue.Number,
DetectedType: detectedType,
Priority: priority,
SuggestedLabels: labelsFor(detectedType, priority, riskFlags),
MissingInformation: missing,
RiskFlags: detectRiskFlags(text),
RecommendedAction: actionFor(detectedType, priority, missing, lang),
SuggestedComment: commentFor(missing, lang),
Reasoning: explainTopMatches(scores, priorityRules),
}
results = append(results, result)
}
render(results, format, lang)
```
### Health
```go
signals := collectHealthSignals(owner, repo)
score := NewWeightedScore(100)
score.Add("issues", 20, scoreIssueBacklog(signals.OpenIssues, signals.StaleIssues))
score.Add("prs", 20, scorePRBacklog(signals.OpenPRs, signals.StalePRs))
score.Add("activity", 15, scoreRecentActivity(signals.RecentActivity))
score.Add("release", 15, scoreReleaseStatus(signals.ReleaseStatus))
score.Add("docs", 10, scoreDocStatus(signals.DocumentationStatus))
score.Add("license", 10, scoreLicenseContribution(signals.LicenseStatus, signals.ContributionStatus))
score.Add("agent", 10, scoreAgentReadiness(signals))
result := HealthResult{
HealthScore: score.Percent(),
RiskLevel: riskLevel(score.Percent()),
Recommendations: recommendations(signals, score),
ScoringNotes: score.Notes(),
}
render(result, format, lang)
```
## Output Protocol
JSON:
- Use stable struct tags.
- Include empty arrays as `[]` where useful for Agent consumption.
- Avoid prose outside JSON.
Table:
- Triage columns: `NUMBER`, `TYPE`, `PRIORITY`, `CONFIDENCE`, `MISSING`, `ACTION`
- Health rows: `METRIC`, `STATUS`, `SCORE`, `NOTE`
Markdown:
- Triage: one summary table with type, priority, confidence, action, and missing information.
- Health: repository score, metric table, recommendations, and scoring notes.
- `zh-CN` changes rule messages and recommendation text, not JSON field names.
## Test Plan
Unit tests:
- Issue type classification.
- Priority scoring.
- Missing information detection.
- Risk flag detection.
- Suggested comment generation.
- Health weighted score and risk level.
- Unknown metric denominator adjustment.
- Markdown headings and required sections.
Mock API tests:
- `workflow +triage` fetches issues and normalizes raw response.
- `workflow +health` tolerates failing CI/release/doc probes.
Command tests:
- `--dry-run` defaults to true.
- `--lang zh-CN` accepted.
- invalid `--lang` falls back to `en`.
- `--format markdown` routes to markdown renderer.
## Later Extensions
### `workflow +pr-summary`
Inputs:
- `--number`
- `--from`
- `--lang`
- `--format`
- optional `--include-files`
- optional `--include-commits`
- optional `--max-files`
- optional `--max-commits`
Default format:
- `table` for human review when `--format` is omitted
Data:
- PR details
- changed files
- commits
Output:
- `change_type`
- `risk_level`
- `review_focus`
- `test_suggestions`
- `merge_checklist`
- `reasoning`
Implementation status:
- read-only local JSON mode: done
- read-only GitLink fetch mode: done
- rules and renderers: done
- tests: rules, fetch boundary, render, and command wiring
Safety:
- no comments
- no approve/reject
- no merge
- no remote write operation
### `workflow +repo-report`
Inputs:
- `--owner`
- `--repo`
- `--from`
- `--lang`
- `--format`
- optional `--issue-limit`
- optional `--pr-limit`
- optional `--stale-days`
- optional `--include-issues`
- optional `--include-prs`
- optional `--include-health`
Default format:
- `markdown` for maintainer and competition reports when `--format` is omitted
Data:
- repository health input and score
- issue triage results aggregated by type, priority, risk, and missing information
- PR summary results aggregated by type, risk, and review focus
Output:
- `report_score`
- `risk_level`
- `health`
- `issue_summary`
- `pr_summary`
- `recommendations`
- `reasoning`
Partial report strategy:
- health, issue, and PR sections are fetched independently
- if at least one enabled section succeeds, the command returns a partial report
- failed sections are recorded in scoring notes or reasoning
- PR remote aggregation currently uses PR list metadata only;
detailed changed files and commits remain available through `workflow +pr-summary --number`
Safety:
- read-only aggregation only
- no comments, labels, closes, approve/reject, or merge operations
- no LLM dependency
### `workflow +release-notes`
Inputs:
- `--from`
- `--to`
- optional `--tag`
- optional `--lang`
Data:
- PR titles
- commit messages
Markdown categories:
- Features
- Bug Fixes
- Documentation
- Tests
- Refactoring
- Chores
- Breaking Changes
### `workflow +stale`
Inputs:
- `--stale-days`
- `--state`
- `--dry-run`
Behavior:
- Identify stale issues and PRs.
- Generate suggested comments or labels.
- Do not mutate remote state by default.
## API Fetch Layer
The current fetch layer uses:
- `triage_fetch.go`
- `health_fetch.go`
- `pr_fetch.go`
- `repo_report_fetch.go`
Design goals already applied:
- tolerate unknown or partial API fields
- map GitLink response shapes into stable workflow DTOs
- continue operating when optional signals fail
- keep remote-write actions disabled until explicitly enabled later
Planned fetch-layer extension:
- `triage_fetch.go` and `health_fetch.go` remain the normalization boundary for remote mode.
- `pr_fetch.go` now reuses the same stable DTO and message patterns for read-only PR metadata, changed files, and commits.
- `repo_report_fetch.go` composes the existing fetch helpers and records partial failures instead of failing the whole report.
- Future `release-notes` should reuse the same normalization and renderer patterns.
- Unknown or missing fields should stay explicit in JSON output so Agents can decide how to proceed.
## Implementation Order
1. Pure DTOs and rule engine.
2. Pure health scoring.
3. Workflow renderers.
4. Command registration.
5. API fetch and normalization.
6. Tests.
7. README updates.
8. Competition docs and test report.

View File

@ -0,0 +1,174 @@
# Workflow Agent Test Report
## Scope
This phase covers:
- Issue triage rules
- health scoring rules
- PR summary rules
- repository report aggregation rules
- local command execution
- API fetch boundary tests
- remote read-only manual verification
- `json` / `table` / `markdown` rendering
- language handling
- mock tests do not depend on the real remote API
## Environment
- OS: Windows
- Go version: `go1.26.1 windows/amd64`
- Go path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\go.exe`
- gofmt path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\gofmt.exe`
## Test Commands
Executed:
```bash
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
go test ./shortcuts/workflow
go test ./...
```
Results:
- `go test ./shortcuts/workflow` passed.
- `go test ./...` passed.
## Unit Tests
- triage rules tests
- health score tests
- messages tests
- render tests
- command tests
- fetch boundary tests
- PR summary rules and fetch tests
- repo report aggregation, render, command, and partial fetch tests
## API Fetch Boundary Tests
- empty issue responses return a clear error instead of panicking
- missing issue titles still allow body-only issues to be normalized
- label normalization supports string arrays, object arrays, and title/name variants
- author normalization supports string, `user`, and `creator` shapes
- GitLink error-in-body responses return readable errors
- health activity timestamps accept `updated_at`, `updatedAt`, `last_activity_at`, `merged_at`, and `closed_at`
- release responses accept `releases`, `data`, and direct array shapes
- CI unavailability is recorded as `unknown` without failing the whole health run
- stale-days values `0` and negative values fall back to the default `30`
- PR summary fetch normalizes PR metadata, changed files, commits, authors, branches, and list limits
- PR summary tolerates partial files or commits fetch failures while keeping base PR metadata
- PR summary base PR error-in-body responses return readable errors
- repo report fetch composes health, issue, and PR sections
- repo report returns a partial report when at least one enabled section succeeds
- repo report returns an error when all enabled fetched sections fail
- repo report issue and PR limits are covered
## Manual Command Examples
```bash
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
gitlink-cli workflow +triage \
--title "安装失败,无法登录" \
--body "运行命令时报错" \
--lang zh-CN \
--format markdown
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
gitlink-cli workflow +health \
--repository Gitlink/gitlink-cli \
--open-issues 3 \
--open-prs 1 \
--has-readme \
--has-license \
--has-contributing \
--agent-readiness-known \
--agent-readiness-score 9 \
--format table
gitlink-cli workflow +health \
--repository demo/repo \
--open-issues 60 \
--stale-issues 25 \
--open-prs 12 \
--stale-prs 6 \
--recent-activity-known \
--recent-activity-days 120 \
--release-known=false \
--format json
gitlink-cli workflow +health \
--repository Gitlink/gitlink-cli \
--open-issues 3 \
--open-prs 1 \
--has-readme \
--has-license \
--has-contributing \
--lang zh-CN \
--format markdown
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
```
## Remote Manual Verification
- Command: `gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table`
- Result: succeeded, returned five issues in table form.
- Command: `gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --lang zh-CN --format markdown`
- Result: succeeded, returned a markdown health report with score `58` and risk level `high`.
- Remote writes: `No`
## Known Limitations
- Current workflow commands support local analysis and read-only GitLink fetch mode.
- `workflow +triage` still supports local parameters or a local JSON file via `--from`.
- `workflow +health` still supports local parameters or a local JSON file via `--from`.
- `workflow +pr-summary` supports local JSON input and read-only GitLink fetch mode.
- `workflow +repo-report` supports local JSON input and partial read-only GitLink fetch aggregation.
- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata only;
detailed file and commit analysis remains available through `workflow +pr-summary --number`.
- `json/table/markdown` are rendered inside the workflow package, not by the global formatter.
- Fetch-layer tests use `httptest` and do not depend on the real remote API.
## Conclusion
The rule-based Agent Workflow prototype, including the read-only fetch layer, is implemented, tested, and locally runnable.
## Final Verification
Final verification should be run before opening the official GitLink PR:
```bash
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
go test ./shortcuts/workflow
go test ./...
```
Expected result:
- `go test ./shortcuts/workflow` passes.
- `go test ./...` passes.
- No remote write operation is performed by workflow commands.
## Competition Demo Commands
Prefer local fixtures for stable demos:
```bash
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
```
Read-only remote smoke commands:
```bash
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
```

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

View File

@ -0,0 +1,3 @@
outputs/
__pycache__/
*.pyc

View File

@ -0,0 +1,69 @@
# PR 质量门禁工作流pr-quality-gatekeeper
把已收录的 [`gitlink-gatekeeper` Skill](../../../skills/gitlink-gatekeeper/SKILL.md)Policy-as-Code 合并门禁)包成**可直接运行的端到端工作流**
> **采集 → 路由 → 裁决 → 回写/善后**:读取一个真实 PR 的元信息/变更文件/commits/CI按变更路径建议 reviewer`gatekeeper.yaml` 策略算出**确定性 0100 评分卡**与**三态裁决**PASS / REQUEST_CHANGES / COMMENT仅在 `--apply`把评分卡评论、裁决标签、tracking issue 真实回写到 GitLink。
与仓库内已有能力的关系:`label` 命令(裁决标签)→ `gitlink-gatekeeper` Skill裁决知识**本工作流(可复现闭环)**,三层共用同一套策略文件,互为支撑而非重复。
## 交付物
- `scripts/gatekeeper_workflow.py`:单 PR 门禁闭环纯标准库Python ≥3.9,零第三方依赖)
- `scripts/gatekeeper_sweep.py`**仓库级批量体检**——对全部 open PR 逐个 dry-run产出治理报告
- `owner-rules.example.yaml`:变更路径 → reviewer 的路由表样例
- `config.example.yaml`:工作流配置样例(命令行参数可覆盖)
- `findings.example.json`AI/人工审查发现注入样例(**来自对真实 PR diff 的真实审查**,行号可复核)
- `docs/architecture.md` · `docs/quickstart.md` · `docs/runbook.md` · `docs/verification.md`
- `ci-example/`Gitea Actions 接入示例PR 触发自动门禁,退出码 2 = REQUEST_CHANGES
- `examples/demo-outputs/`真实平台运行产物PASS 90 评分卡 / 注入发现后的 55 分评分卡 / 113 个 open PR 的全仓体检报告)
- `tests/test_scoring.py`:确定性回归护栏(同输入 → 同分 → 同裁决)
## 快速运行(默认 dry-run不写远端
```bash
npm install -g @gitlink-ai/cli # ≥0.2.0,自带 label 命令与 gitlink-gatekeeper Skill
gitlink-cli auth login
python3 scripts/gatekeeper_workflow.py \
--owner <owner> --repo <repo> --pr <PR号> \
--policy ../../../skills/gitlink-gatekeeper/examples/gatekeeper.yaml \
--owner-rules owner-rules.example.yaml \
--output-dir outputs
```
- 注入审查发现得到含扣分的评分卡:加 `--findings findings.example.json`
- 真实回写(评论 + 标签 + tracking issue`--apply`(请先在自有仓库演练)
- 全仓批量体检(只读,零写入):
```bash
python3 scripts/gatekeeper_sweep.py \
--owner <owner> --repo <repo> \
--policy ../../../skills/gitlink-gatekeeper/examples/gatekeeper.yaml \
--owner-rules owner-rules.example.yaml \
--output-dir sweep-out --date-label $(date +%F)
```
更多见 [`docs/quickstart.md`](docs/quickstart.md) 与 [`docs/runbook.md`](docs/runbook.md)。
## 已在真实平台验证
全部证据见 [`docs/verification.md`](docs/verification.md),要点:
| 验证 | 对象 | 结果 |
|------|------|------|
| dry-run | 本仓库真实 PRpull_request_id 15222 | ✅ PASS 90/1008 个变更文件路由正确 |
| 注入真实审查发现 | 同一 PR + `findings.example.json` | ❌ REQUEST_CHANGES 55/100裁决翻转确定性可复算 |
| `--apply` 真实回写 | 自有 fork 的演练 PR | 评分卡评论 + tracking issue + 裁决标签全部由 API 回执确认 |
| **全仓批量体检** | 本仓库**全部 113 个 open PR** | 113/113 成功PASS 105 / COMMENT 6 / REQUEST_CHANGES 2均分 88.596% 未关联 issue |
| 单测 | `tests/test_scoring.py` | 全绿(锁定四个权威裁决案例的分值与裁决) |
## 设计要点
- **确定性评分**AI 只负责产出「发现列表」(可选注入),扣分与裁决由纯函数完成——同策略 + 同 PR → 同裁决,可逐位手算复现、可审计。
- **安全默认**:默认 dry-run 什么都不写;即便策略开了 `auto_merge`,也必须 `verdict == PASS` 且显式 `--apply` 才会合并;强语义的 approve/reject 始终留给人,自动裁决只以建议性 `common` 评论 + 标签呈现。
- **原生适配 GitLink**PR 标题/描述取自 `pr +view``issue.subject/description`;标签挂载走「`label +list` 查 id → Raw API `POST /:owner/:repo/issues/<issue_id>`」;尊重 `common/approved/rejected` 三态 review。
- **零依赖、零常驻**:纯标准库脚本 + `gitlink-cli`,无需部署 webhook 服务或数据库CI 一条 step 即可接入(见 `ci-example/`);确定性意味着**大规模治理零 AI 成本**。
## 许可证
随仓库 [MulanPSL-2.0](../../../LICENSE)。

View File

@ -0,0 +1,27 @@
# CI 集成示例 —— 门禁接 CI
本目录演示如何把 **gitlink-gatekeeper** 的 PR 看门人门禁接到 CI 上,让裁决直接挡住不达标的 PR。
> 这是**示例**,不是开箱即用的生产配置;`gitlink-cli` 的安装方式、PR 编号字段名需按你的 runner 实际情况调整。
## 文件
- [`gatekeeper.gitea.yml`](gatekeeper.gitea.yml)Gitea Actions 工作流GitLink 基于 Gitea语法与 GitHub Actions 兼容)。
## 用法
1. 把 `gatekeeper.gitea.yml` 复制到目标仓库的 `.gitea/workflows/` 目录。
2. 在仓库 **Settings → Actions → Secrets** 新增 `GITLINK_TOKEN`,值为有权读取该仓库 PR 的访问令牌(供 `gitlink-cli` 认证)。**Token 切勿写进仓库或日志。**
3. 提一个 PR 触发工作流即可。
## 工作原理
- 触发PR 的 `opened` / `synchronize` / `reopened` 事件。
- 步骤:检出 → 准备 Python 3.9(脚本纯标准库,无需装依赖)→ 装 `gitlink-cli` → 跑 `scripts/gatekeeper_workflow.py` 采集本次 PR 上下文并评分裁决。
- **退出码即门禁**
- `0` = PASS / COMMENT → job 通过,放行。
- `2` = REQUEST_CHANGES → 工作流把它转成 job 失败,挡住该 PR。
- `1` = 可预期错误(缺参数 / 未装 `gitlink-cli` 等)→ 同样失败。
- 产物:评分卡与 `summary.json` 落在 `outputs/`,工作流用 `upload-artifact` 上传,便于在 CI 页面查看裁决依据。
调门禁松紧只需改 `--policy` 指向的 `gatekeeper.yaml`(策略字段说明见 [`gitlink-gatekeeper` Skill REFERENCE](../../../../skills/gitlink-gatekeeper/REFERENCE.md))。

View File

@ -0,0 +1,75 @@
# gitlink-gatekeeper —— Gitea Actions CI 示例GitLink 平台用)
#
# ⚠️ 这是一个「门禁接 CI」的演示示例不是开箱即用的生产配置。
# - GitLink 基于 Gitea其 Actions 语法与 GitHub Actions 兼容,工作流放在
# 仓库的 .gitea/workflows/ 目录下。把本文件复制过去并按需调整即可启用。
# - 需要在仓库 Settings → Actions → Secrets 配置一个 GITLINK_TOKEN secret
# (供 gitlink-cli 认证、采集目标 PR 的上下文。Token 切勿写进仓库。
# - runner 需能访问 GitLink APIPython 3.9+ 与 gitlink-cli 的安装方式按实际
# runner 镜像调整(下面 install 步骤仅为占位示意)。
#
# 触发:对 PR 的 open / 同步事件运行门禁,脚本返回码 2REQUEST_CHANGES
# 会让本 job 失败,从而在 CI 上挡住该 PRPASS/COMMENT 返回 0 即通过)。
name: gatekeeper
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
gatekeeper:
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 准备 Python纯标准库无需装依赖
uses: actions/setup-python@v5
with:
python-version: "3.9"
# 安装 gitlink-cli示意按 runner 实际情况替换为正确的安装/分发方式)
- name: 安装 gitlink-cli
run: |
# 例如从发布物下载或用包管理器安装,确保 PATH 里有 gitlink-cli
gitlink-cli --version
# 跑门禁:采集本次 PR 上下文 → 评分 → 裁决。
# REQUEST_CHANGES 时脚本退出码为 2下面用 if/exit 把它转成 job 失败。
- name: 运行 PR 看门人门禁
env:
# gitlink-cli 通过该环境变量认证(对应仓库配置的 secret
GITLINK_TOKEN: ${{ secrets.GITLINK_TOKEN }}
# Gitea 注入的 PR 编号;不同 runner 字段名可能不同,按实际调整
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -o pipefail
python3 examples/workflows/pr-quality-gatekeeper/scripts/gatekeeper_workflow.py \
--owner "${{ github.repository_owner }}" \
--repo "${{ github.event.repository.name }}" \
--pr "${PR_NUMBER}" \
--policy skills/gitlink-gatekeeper/examples/gatekeeper.yaml \
--owner-rules workflow/owner-rules.yaml \
--cli-bin gitlink-cli
code=$?
# 退出码0 = PASS/COMMENT放行2 = REQUEST_CHANGES挡住让 job 失败);
# 1 = 可预期错误(缺参数 / 未装 gitlink-cli 等),同样视为失败。
if [ "$code" -eq 0 ]; then
echo "门禁通过PASS/COMMENT"
exit 0
elif [ "$code" -eq 2 ]; then
echo "::error::门禁裁决 REQUEST_CHANGES阻止合并"
exit 1
else
echo "::error::门禁执行出错(退出码 $code"
exit 1
fi
# 上传评分卡 / summary 产物,便于在 CI 页面查看裁决依据
- name: 上传门禁产物
if: always()
uses: actions/upload-artifact@v4
with:
name: gatekeeper-scorecard
path: outputs/

View File

@ -0,0 +1,19 @@
# config.example.yaml — PR 看门人闭环的工作流配置gatekeeper_workflow.py --config 用)
#
# 命令行参数(--owner/--repo/--pr/--policy/--owner-rules/--findings会覆盖这里的同名字段。
# 相对路径以本配置文件所在目录为基准解析。
owner: Gitlink # 目标仓库 ownerGitLink 主分支为 master
repo: gitlink-cli # 目标仓库名
pr: 1 # 目标 PR 编号(用 --pr 覆盖以复用同一配置跑不同 PR
# 策略文件Policy-as-Code。缺省 / 文件不存在时回退脚本内置默认策略SSOT 第 2 节)。
policy: ../skills/gitlink-gatekeeper/examples/gatekeeper.yaml
# 文件路径 → reviewer 路由表(工作流步骤 1
owner_rules: owner-rules.yaml
# 可选AI 审查发现 JSON注入 review_findings 维度)。
# 缺省为空 → 评分仍确定性可复现(同策略 + 同 PR 上下文 → 同裁决)。
# 这里默认指向随附的示例1 major + 2 minor开箱即可跑通用 --findings 覆盖为你自己的产物。
findings: findings.sample.json

View File

@ -0,0 +1,66 @@
# 架构与数据流 — PR 看门人闭环
本工作流采用「**采集 → 路由 → 裁决 → 回写/善后**」四段式流水线,把 `gitlink-gatekeeper` 的 Policy-as-Code 门禁包成一条可复现闭环。所有数值/字段/算法以已收录的 [`gitlink-gatekeeper` Skill REFERENCE](../../../../skills/gitlink-gatekeeper/REFERENCE.md) 为准。
## 设计目标
- **可复现**:同策略 + 同 PR 上下文 → 同评分卡 + 同裁决确定性算法SSOT 第 35 节)。
- **可审计**:评分卡逐维列分 + 备注,硬门禁逐条列出命中原因,裁决可追溯到具体规则与 `gatekeeper.yaml` 版本。
- **安全默认**:默认 dry-run写操作需显式 `--apply`绝不默认自动合并SSOT 第 8 节)。
- **低门槛**:仅依赖 `gitlink-cli` 与 Python 标准库(含内置 YAML 子集解析器,无第三方包)。
- **边界清晰**:采集、路由、裁决、回写四段各自独立,便于单测与替换(如换一套评分维度只动裁决段)。
## ASCII 流程图
> 下面是数据流占位图:左侧为 `gitlink-cli` 采集,中间为本脚本的确定性处理,右侧为回写/善后的写操作(仅 `--apply` 时执行)。
```
┌──────────────────────────── gatekeeper_workflow.py ────────────────────────────┐
│ │
gitlink-cli (读) │ step 1: 路由 step 2: 裁决 step 3: 回写 + 善后 │ gitlink-cli (写, 仅 --apply)
───────────────────── │ ─────────────────── ────────────────── ───────────────────────── │ ─────────────────────────────
│ │
pr +view ──┐ │ ┌─ review_findings(40) ─┐ │
pr +files ──┼──▶ 采集 │ changed_files │ test_coverage (20) │ verdict │ pr +comment ─▶ 评分卡评论
ci +builds │ 归一化 │ │ │ pr_hygiene (15) │──▶ ── PASS ───────┐ │
api .../ │ │ ▼ │ commit_quality(15) │ ── COMMENT ───┐ │ │ label +create ─▶ 裁决标签
commits ──┘ │ owner-rules.yaml │ ci_status (10) │ ── REQ_CHG ─┐ │ │ │ (+ 挂 issue_tag_ids
▲ │ (glob → reviewer) └───────────┬───────────┘ │ │ │ │ via Raw API POST
│ │ │ ▼ │ │ │ │ /:owner/:repo/issues/:id)
gatekeeper.yaml ─────┼─────┼──────────────▶ hard_gates 判定 ─┴─▶ total 0..100 ─▶ 阈值 │ │ │ │
(Policy-as-Code) │ ▼ (SSOT 第4节) (SSOT 第3节) (第5节) │ │ │ │ issue +create ─▶ tracking issue
│ │ suggested_reviewers ──────────────────────────────────────────┘ │ │ │ (仅 REQUEST_CHANGES)
findings.json ───────┼──▶ review_findings 注入 │ │ │
(可选, AI 审查) │ │ │ │ pr +merge ─▶ 合并 (受限:
│ ┌── outputs/*_scorecard.md ◀────────────┘ │ │ PASS + auto_merge + --apply)
│ 本地产物落盘 (总是) ───────┤ │ │
│ └── outputs/*_summary.json ◀──────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
dry-run默认右侧写操作仅打印「将要执行的命令」不实际调用 → 安全。
--apply :右侧写操作真正执行;其中合并需同时满足 PASS + 策略 auto_merge=true + --apply。
```
## 四段职责
### ① 采集collect_pr_context
调只读 `gitlink-cli` 命令拿到 PR 元信息、变更文件、CI 状态、commits端点未开放时降级不阻断。输出统一归一化为内部结构兼容 GitLink Envelope 的多种字段名。
### ② 路由route_reviewers
`owner-rules.yaml`,对每个变更文件按 glob 顺序匹配(首个命中生效,顺序即优先级),产出 `reviewer → 文件清单`;未命中文件归 `default_reviewers`。结果写进评分卡的「Suggested reviewers」分区。**只产出建议,不调用任何写操作**——是否真正分配由维护者决定。
### ③ 裁决score_dimensions / evaluate_hard_gates / decide_verdict
- 五维加权评分(权重和=100SSOT 第 3 节),可选注入 AI findings 影响 `review_findings`
- 硬门禁逐项判定SSOT 第 4 节),任一命中即 `hard_gate_failed`
- 裁决判定树SSOT 第 5 节):硬门禁失败 → REQUEST_CHANGES否则按总分与 `pass`/`request_changes` 阈值落三态。
- 渲染评分卡SSOT 第 6 节模板)。
### ④ 回写 + 善后build_*_command + execute_write
按裁决构造写操作计划评分卡评论、裁决标签、REQUEST_CHANGES 时tracking issue、受限合并。dry-run 只打印计划;`--apply` 才逐条执行并记录结果到 `summary.json`
## 为什么选这条链路
子赛题三要求用现有命令 / Skill 组合形成完整解决方案,且串联不少于 3 步。本链路:
1. 串联了 **4 个只读采集命令** + **最多 4 个写命令**,远超 3 步下限。
2. 形成从「数据获取」到「治理动作落地」的端到端闭环,并能接入 CIREQUEST_CHANGES 返回码 2
3. 复用本作品自研的 `label` 命令组(子赛题一)与 gatekeeper 策略(子赛题二),三个子赛题在同一作品内闭环,相互增强。

View File

@ -0,0 +1,48 @@
# 最短复现路径3 步)
## 1. 安装与认证
```bash
npm install -g @gitlink-ai/cli # ≥0.2.0label 命令与 gitlink-gatekeeper Skill 已内置)
gitlink-cli auth login # 或 export GITLINK_TOKEN=<私人令牌>
gitlink-cli auth status # 确认已登录
```
## 2. 对任意真实 PR 出评分卡dry-run零写入
在本目录(`examples/workflows/pr-quality-gatekeeper/`)下:
```bash
python3 scripts/gatekeeper_workflow.py \
--owner Gitlink --repo gitlink-cli --pr <PR号> \
--policy ../../../skills/gitlink-gatekeeper/examples/gatekeeper.yaml \
--owner-rules owner-rules.example.yaml \
--output-dir outputs
```
产物:`outputs/<owner>_<repo>_pr<id>_scorecard.md`(评分卡)+ `_summary.json`(结构化摘要)。
退出码:`0` = PASS/COMMENT`2` = REQUEST_CHANGES可直接当 CI 门禁用),`1` = 运行错误。
不带 `--policy` 也能跑(脚本内置同值默认策略);想看含扣分的评分卡,加 `--findings findings.example.json`
## 3. 可选进阶
- **真实回写**(评论 + 裁决标签 + tracking issue`--apply`。请先在自有 fork 演练;自动裁决只用建议性 `common` 评论,绝不替人 approve/reject绝不自动合并。
- **全仓体检**(只读批扫全部 open PR出治理报告
```bash
python3 scripts/gatekeeper_sweep.py \
--owner Gitlink --repo gitlink-cli \
--policy ../../../skills/gitlink-gatekeeper/examples/gatekeeper.yaml \
--owner-rules owner-rules.example.yaml \
--output-dir sweep-out --date-label $(date +%F)
```
- **CI 接入**:见 [`../ci-example/`](../ci-example/)Gitea ActionsPR 触发自动门禁)。
- **改门禁松紧**:复制一份 `gatekeeper.yaml``weights/hard_gates/thresholds`,字段说明见 [Skill REFERENCE](../../../../skills/gitlink-gatekeeper/REFERENCE.md)。
## 验证自己改动没破坏确定性
```bash
python3 tests/test_scoring.py # 同输入 → 同分 → 同裁决 的回归护栏
```

View File

@ -0,0 +1,106 @@
# 运行手册 — PR 看门人闭环
本手册覆盖 `scripts/gatekeeper_workflow.py` 的前置条件、运行步骤、参数、预期输出与回滚。数值/字段以已收录的 [`gitlink-gatekeeper` Skill REFERENCE](../../../../skills/gitlink-gatekeeper/REFERENCE.md) 为准。
## 1. 前置条件
- 已安装 `gitlink-cli` 且在 `PATH` 中(或用 `--cli-bin` 指定路径)。
- 已完成登录:`gitlink-cli auth login`Token 有效期 7 天,过期重新登录;详见 [gitlink-shared](../../../../skills/gitlink-shared/SKILL.md))。
- 对目标仓库有读权限;要 `--apply` 回写评论/标签/建 issue 时需写权限。
- Python 3.8+(脚本纯标准库,无需 `pip install`)。
验证登录态:
```bash
gitlink-cli auth status
gitlink-cli pr +view -i <pr_id> --owner <owner> --repo <repo> --format json # 确认目标 PR 可读
```
## 2. 配置
编辑 [`../config.example.yaml`](../config.example.yaml)(或复制一份),填好 `owner`/`repo`/`pr` 与策略、路由表路径。命令行参数会覆盖配置同名字段,相对路径以配置文件所在目录为基准。
按需调整 [`../owner-rules.example.yaml`](../owner-rules.example.yaml):把占位 reviewer 用户名替换成本仓库维护者,按「具体规则在前」排列 glob。
按需选择策略预设(均在 [`../../../../skills/gitlink-gatekeeper/examples/`](../../../../skills/gitlink-gatekeeper/examples/)
- `gatekeeper.yaml`:均衡基线(= SSOT 内置默认)。
- `gatekeeper.strict.yaml`:严格预设。
- `gatekeeper.lenient.yaml`:宽松预设。
- 不指定 `--policy` 且配置无 `policy` 字段时,回退脚本内置默认策略(与 `gatekeeper.yaml` 等价)。
## 3. 运行步骤
### 步骤 Adry-run 预览(安全默认,必做)
```bash
python3 scripts/gatekeeper_workflow.py --config config.example.yaml --pr <pr_id>
```
此模式**不写任何东西**,只采集 + 评分 + 打印将要执行的写命令 + 落盘本地产物。先看评分卡与计划是否符合预期。
### 步骤 B注入 AI 审查发现(可选)
`review_findings` 维度默认按 0 发现计分。若已有 AI 代码审查(如 `gitlink-code-review` Skill产出整理成 JSON 注入:
```json
{ "findings": [
{ "severity": "blocker", "message": "硬编码密钥", "file": "internal/auth/refresh.go", "line": 12 },
{ "severity": "minor", "message": "缺超时上下文", "file": "internal/auth/handler.go", "line": 40 }
] }
```
```bash
python3 scripts/gatekeeper_workflow.py --config config.example.yaml --pr <pr_id> --findings findings.json
```
`severity` 取值:`blocker` / `major` / `minor` / `nit`(其余忽略)。
### 步骤 Capply 执行写操作
确认 dry-run 计划无误后,加 `--apply`
```bash
python3 scripts/gatekeeper_workflow.py --config config.example.yaml --pr <pr_id> --apply
```
将依次执行(按裁决):回写评分卡评论 → 确保裁决标签存在 →(仅 REQUEST_CHANGES创建 tracking issue。
**合并不会自动发生**:仅当策略 `behavior.auto_merge: true` 且裁决为 `PASS` 且本次带 `--apply` 时,才追加 `pr +merge`。默认 `auto_merge: false`
## 4. 参数速查
| 参数 | 说明 | 默认 |
|------|------|------|
| `--config` | 工作流配置 YAMLowner/repo/pr/policy/owner_rules/findings | 无 |
| `--owner` / `--repo` / `--pr` | 覆盖配置中的目标 | 取自 config |
| `--policy` | `gatekeeper.yaml` 路径 | 内置默认策略 |
| `--owner-rules` | `owner-rules.yaml` 路径 | 取自 config |
| `--findings` | AI 审查发现 JSON | 空0 发现) |
| `--cli-bin` | `gitlink-cli` 可执行路径 | `gitlink-cli` |
| `--skip-ci` | 跳过 CI 采集(`ci_status` 记 `unknown` | 否 |
| `--output-dir` | 本地产物目录 | `outputs` |
| `--apply` | **执行写操作**;不传则仅预览 | 否dry-run |
## 5. 预期输出
- 终端:三段进度(路由 / 裁决 / 回写)+ 评分概览 + 计划或执行结果 + 最终裁决。
- 文件:
- `outputs/<owner>_<repo>_pr<id>_scorecard.md` — 评分卡SSOT 第 6 节模板)。
- `outputs/<owner>_<repo>_pr<id>_summary.json` — 结构化摘要(路由、各维得分、硬门禁、裁决、`planned_writes`、`executed`、产物路径)。
- 退出码:`PASS`/`COMMENT` → `0``REQUEST_CHANGES` → `2`(可作 CI 门禁);可预期错误(缺配置 / 未登录 / CLI 缺失)→ `1`
样例评分卡见 [`../../../../skills/gitlink-gatekeeper/examples/scorecard-sample.md`](../../../../skills/gitlink-gatekeeper/examples/scorecard-sample.md)。
## 6. 回滚
dry-run 不产生任何远端副作用,无需回滚(本地产物可直接删 `outputs/`)。
`--apply` 后如需撤销:
| 已做的写操作 | 回滚方式 |
|--------------|----------|
| 回写的评分卡评论 | 评论走 issue journals在 PR 页面手动删除该评论即可;脚本不提供删除命令(避免误删他人评论) |
| 创建的裁决标签定义 | `gitlink-cli label +delete -i <label_id> --owner <o> --repo <r>`(先 `label +list` 查 id |
| 创建的 tracking issue | `gitlink-cli issue +close -n <number> --owner <o> --repo <r>`(关闭而非删除,保留审计痕迹) |
| 已合并的 PR | **不可自动回滚**。这也是默认 `auto_merge: false` 的原因;合并前务必人工确认。如确需撤销,按仓库常规流程 revert commit |
> 安全提示:任何 `--apply` 写操作前,脚本会在 dry-run 计划里完整复述将执行的命令。生产仓库建议先 dry-run`--apply`

View File

@ -0,0 +1,55 @@
# 真实平台验证记录
> 全部针对 **GitLink 线上真实平台** 运行(`gitlink-cli` + Token 认证),非 mock。
> 他人仓库一律 dry-run只读零写入写操作只在自有 fork 演练。
> 运行环境macOSApple Silicon· Python 3.9 · `@gitlink-ai/cli` 0.2.0npm 官方发布版,零本地构建)。
## A. dry-run真实 PR → PASS 90/100
对本仓库真实 PR`pull_request_id 15222`feat/org-team-projects8 个变更文件):
- 路由正确分流README/docs/skill → doc-maintainer`org.go` → go-reviewer`org_test.go` → qa-reviewer
- 评分确定性可手算复现review 40/40 · test 20/201 src/1 test· hygiene 10/15desc✓/issue✗/size✓· commit 15/15 · ci 5/10unknown= **90 → PASS**
- CI 取不到构建记录 → `unknown`:按策略**不触发**硬门禁(仅显式 `failing` 触发),只在 CI 维记半分
- 产物:[`../examples/demo-outputs/scorecard-pass-90.md`](../examples/demo-outputs/scorecard-pass-90.md)
## B. 注入真实审查发现 → REQUEST_CHANGES 55/100
同一 PR注入 [`../findings.example.json`](../findings.example.json) 重跑review_findings 40/40 → 5/401 major + 2 minor总分 90 → 55**裁决翻转为 REQUEST_CHANGES**。
**发现是真的,不是编的**——三条均来自对该 PR 真实 diffhead `bcc27bf`)的代码审查,标注 `shortcuts/org/org.go` 真实行号,任何人拉取该分支可逐条复核。其中 major新增的 `parseBool` 只认字面 `"true"``--dry-run=1` 会被静默当 false而该 flag 守护的是「批量移除团队全部项目」这一破坏性操作。
产物:[`../examples/demo-outputs/scorecard-findings-55.md`](../examples/demo-outputs/scorecard-findings-55.md)
## C. `--apply` 真实回写(自有 fork 演练)
在自有 fork 的演练 PR故意「改源码不带测试」上执行 `--apply`
- 触发硬门禁 `require_tests_for_src_changes` → REQUEST_CHANGES 40/100
- GitLink API 回执确认三件写操作全部落地:
1. 评分卡评论回写到 PRcomment id `472741`
2. 自动创建 tracking issueid `143217`),汇总硬门禁 + 必修项 + 建议 reviewer与 PR 双向回链
3. 裁决标签挂载到 PR 背后 issue`label +list` 查 id → Raw API `POST /:owner/:repo/issues/<issue_id>`)——依赖本仓库的 `label` 命令0.2.0 起官方发布版自带)
## D. 全仓批量体检113 个 open PR
`gatekeeper_sweep.py` 对本仓库**全部 113 个 open PR** 逐个 dry-run只读、零写入、零 AI 成本113/113 成功:
- 裁决分布:**PASS 105 · COMMENT 6 · REQUEST_CHANGES 2**;分数 min 70 / 中位 90 / 均值 88.5 / max 95
- 治理洞察:**96% 的 open PR 未关联 issue**2 个 PR 触发 `require_tests_for_src_changes`(改源码不带测试)
- 完整报告(含全量明细表):[`../examples/demo-outputs/sweep-report-2026-06-10.md`](../examples/demo-outputs/sweep-report-2026-06-10.md)
- 诚实口径批扫不注入审查发现review_findings 维未评、按满分计CI 统一 `--skip-ci`unknown 半分)——总分代表「除人工/AI 审查外的工程卫生分」,偏乐观
## E. 单元测试(确定性回归护栏)
```bash
$ python3 tests/test_scoring.py
OK
```
锁定四个权威裁决案例PASS / REQUEST_CHANGES / COMMENT / 硬门禁直拒)的**总分与裁决**与 Skill 文档逐位一致;任何改动若破坏「同输入 → 同分 → 同裁决」,测试立即变红。
## 真实运行当场暴露过的问题(透明记录)
- GitLink 的 PR 标题/描述在 `pr +view` 返回的 `issue.subject/description`,而非 `pull_request` 子对象——离线 mock 测不到,真实平台运行才暴露并修复。
- npm 0.1.18 时代 `--apply` 的打标签步骤会报 `unknown command "label"`(彼时 `label` 命令尚未发布0.2.0 起官方发布版自带,整条闭环零本地构建跑通。

View File

@ -0,0 +1,28 @@
## 🛡️ Gatekeeper Report — PR #15222 feat(org): add team project binding shortcuts
**Verdict: ❌ REQUEST_CHANGES** · Score: 55/100 · policy: gatekeeper.yaml@v1
| Dimension | Weight | Score | Notes |
|-----------|:------:|:-----:|-------|
| Review findings | 40 | 5/40 | 0 blocker / 1 major / 2 minor / 0 nit |
| Test coverage | 20 | 20/20 | 1 src / 1 test files |
| PR hygiene | 15 | 10/15 | desc ✓ / linked issue ✗ / size ✓ |
| Commit quality | 15 | 15/15 | 0/0 conventional |
| CI status | 10 | 5/10 | unknown |
### 👥 Suggested reviewers (3)
- @doc-maintainer — 6 file(s): README.md, README.zh-CN.md, doc/changes/org-team-projects.md …
- @go-reviewer — 2 file(s): shortcuts/org/org.go, shortcuts/org/org_test.go
- @qa-reviewer — 1 file(s): shortcuts/org/org_test.go
### 🔴 Must fix (1)
- [major] parseBool 只把字面 "true" 当真:用户传 --dry-run=1 / t / TRUE 以外写法会被静默解析为 false。该 flag 守护的是 team-projects-remove-all批量移除团队全部项目这类破坏性操作——预览意图被静默降级为真实执行。建议改用 strconv.ParseBool与 shortcuts/common/runner.go:52 解析 flag 默认值的行为一致),无法识别的值应报错而非吞掉 — shortcuts/org/org.go:156
### 🟡 Should fix (2)
- [minor] team-projects-remove-all 一条命令清空团队全部项目绑定,除 --dry-run 外无确认机制;建议在 Description/help 标注危险性,或要求显式 --yes 二次确认 — shortcuts/org/org.go:103
- [minor] dry-run 预览 payload 中 actionadd_all_team_projects/remove_all_team_projects与实际请求 path 段create_all/destroy_all见 :134是两套词汇排查问题时易误导建议统一或在 payload 同时给出两者映射 — shortcuts/org/org.go:142
### Next steps
1. 评分低于阈值,按上方 Must/Should fix 修复后重新触发 gatekeeper
---
*Generated by gitlink-gatekeeper · policy-as-code PR gate · re-run after changes*

View File

@ -0,0 +1,21 @@
## 🛡️ Gatekeeper Report — PR #15222 feat(org): add team project binding shortcuts
**Verdict: ✅ PASS** · Score: 90/100 · policy: gatekeeper.yaml@v1
| Dimension | Weight | Score | Notes |
|-----------|:------:|:-----:|-------|
| Review findings | 40 | 40/40 | 0 blocker / 0 major / 0 minor / 0 nit |
| Test coverage | 20 | 20/20 | 1 src / 1 test files |
| PR hygiene | 15 | 10/15 | desc ✓ / linked issue ✗ / size ✓ |
| Commit quality | 15 | 15/15 | 0/0 conventional |
| CI status | 10 | 5/10 | unknown |
### 👥 Suggested reviewers (3)
- @doc-maintainer — 6 file(s): README.md, README.zh-CN.md, doc/changes/org-team-projects.md …
- @go-reviewer — 2 file(s): shortcuts/org/org.go, shortcuts/org/org_test.go
- @qa-reviewer — 1 file(s): shortcuts/org/org_test.go
### Next steps
1. 满足合并门禁;如策略开启 auto_merge 且操作者带 --apply可执行合并
---
*Generated by gitlink-gatekeeper · policy-as-code PR gate · re-run after changes*

View File

@ -0,0 +1,138 @@
# gatekeeper 仓库体检报告 —— Gitlink/gitlink-cli2026-06-10
> 对 **113 个 open PR** 全量 dry-run**只读,零写入**)· 策略 `gatekeeper.yaml` · 成功 113 / 失败 0
>
> **诚实口径**:批扫未注入 AI 审查发现review_findings 维按 0 发现计满分(**该维度未评**CI 维按 `--skip-ci` 统一记 unknown半分。其余维度为真实采集。因此**总分代表「除人工/AI 审查外的工程卫生分」,偏乐观**;裁决分布同理。
## 总览
- 裁决分布COMMENT **6** · PASS **105** · REQUEST_CHANGES **2**
- 分数min 70 / 中位 90 / 均值 88.5 / max 95
- **0%** 的 PR 测试覆盖维 0 分(改动不带任何测试)
- **96%** 的 PR 未关联 issue
- **2%** 的 PR 触发 REQUEST_CHANGES硬门禁或低分
硬门禁命中:`require_tests_for_src_changes` × 2
## 全量明细(按分数降序)
| PR | 标题 | 作者 | 总分 | 裁决 | 硬门禁失败 | 卫生(描述/关联/体量) |
|----|------|------|-----:|------|-----------|---------------------|
| [#145](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/145) | fix(issue): preserve metadata during batch close | dtwdtw | 95 | PASS | — | ✓/✓/✓ |
| [#218](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/218) | feat(skills): 新增 科研Fork影响力分析 的skill : gitlink-re | yangsai | 90 | PASS | — | ✓/✗/✓ |
| [#177](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/177) | feat(wiki): add wiki management shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#217](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/217) | feat(commands): add command catalog export | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#216](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/216) | feat(api): support saved variables in batch plan | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#214](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/214) | feat(pr): add conversation comment shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#213](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/213) | feat(repo): add mirror sync shortcut | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#212](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/212) | feat(feedback): add feedback shortcut | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#211](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/211) | feat(repo): add profile view shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#210](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/210) | feat(skills): 新增维护者交接与分支治理 Skills | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#208](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/208) | feat(user): add pinned project shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#207](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/207) | feat(user): add statistics shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#206](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/206) | feat(commit): add commit inspection shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#204](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/204) | feat(org): 增强组织团队与成员管理快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#203](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/203) | feat(user): 增加用户画像分析快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#202](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/202) | feat(ignore): add ignore template shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#201](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/201) | feat(account): add account auth shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#200](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/200) | feat(pr): add review journal shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#199](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/199) | feat(code): add read-only code browsing shortcut | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#198](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/198) | feat(message): 增加消息中心快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#197](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/197) | feat(message-settings): 增加消息通知设置快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#194](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/194) | fix(pr): 补齐 pr +view 的合并与关闭时间字段 | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#193](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/193) | feat(shortcut): add shortcuts/wiki | co63oc | 90 | PASS | — | ✓/✗/✓ |
| [#192](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/192) | feat(repo): add navigation unit shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#191](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/191) | feat(user): add profile shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#187](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/187) | feat(org): add team project bulk shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#186](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/186) | feat(ref): add branch and tag shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#185](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/185) | Add workflow pull request review queue | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#184](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/184) | Add workflow release notes generator | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#183](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/183) | feat(project): add lifecycle flow shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#182](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/182) | feat(issue): add journal maintenance shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#181](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/181) | feat(topic): add project topic shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#180](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/180) | feat(template): add project template shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#179](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/179) | feat(dataset): add research dataset shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#178](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/178) | feat(contents): add repository content shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#176](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/176) | feat(user): add dashboard shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#175](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/175) | feat(notification): add message and setting shor | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#174](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/174) | feat(public-key): add SSH key shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#173](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/173) | feat(account): add cancellation shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#172](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/172) | feat(account): add security shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#171](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/171) | feat(oauth): add token shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#170](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/170) | Add repository file search and batch commit shor | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#167](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/167) | feat(account): add email verification shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#164](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/164) | Add pull request review comment management short | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#163](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/163) | Add complete issue comment management shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#160](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/160) | Add GitLink feedback submission shortcut | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#158](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/158) | Add code trace analysis shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#153](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/153) | feat(shortcut): add shortcuts/ignore | co63oc | 90 | PASS | — | ✓/✗/✓ |
| [#151](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/151) | feat(transfer): add transfer request shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#135](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/135) | feat(dev): add developer resource shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#118](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/118) | feat(access): add project access shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#114](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/114) | feat(mirror): add mirror repository shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#113](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/113) | feat(todo): add request approval shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#107](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/107) | feat(star): add starred project shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#83](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/83) | feat(org): add team project binding shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#82](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/82) | feat(meta): add attachment and metadata shortcut | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#78](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/78) | feat(branch): complete OpenAPI shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#76](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/76) | feat(notification): add OpenAPI shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#72](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/72) | feat(template): add project template shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#70](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/70) | feat(user): add account and stats shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#65](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/65) | feat(wiki): add OpenAPI shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#64](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/64) | feat(dataset): add OpenAPI shortcuts | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#63](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/63) | feat(code): add repository code OpenAPI shortcut | wangyue111 | 90 | PASS | — | ✓/✗/✓ |
| [#152](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/152) | chore(doc): fix README.md | co63oc | 90 | PASS | — | ✓/✗/✓ |
| [#137](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/137) | feat(skills): 增强 7 个 Agent Skill + 新增 2 个 Skill | whale | 90 | PASS | — | ✓/✗/✓ |
| [#149](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/149) | feat(skills): 新增 学者/团队科研画像生成 的skill : gitlink-sc | yangsai | 90 | PASS | — | ✓/✗/✓ |
| [#148](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/148) | feat(skills): 新增 科研热点追踪与知识图谱构建 的skill : gitlink- | yangsai | 90 | PASS | — | ✓/✗/✓ |
| [#144](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/144) | feat(skills): 新增 3 个 Agent Skill — wiki-builder, | whale | 90 | PASS | — | ✓/✗/✓ |
| [#134](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/134) | 新增 shell 自动补全命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#99](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/99) | 新增 5 个仓库检查快捷命令 (languages/contributors/files/tag | jiangtx | 90 | PASS | — | ✓/✗/✓ |
| [#86](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/86) | fix: preserve issue metadata on update | dtwdtw | 90 | PASS | — | ✓/✗/✓ |
| [#73](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/73) | feat(user): add SSH key shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#67](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/67) | feat(repo): add repository units shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#60](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/60) | feat: add notification shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#58](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/58) | feat: add repository reaction shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#126](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/126) | feat(skills): 新增 gitlink-scaffold 社区健康文件体检 Skill | Ct201314 | 90 | PASS | — | ✓/✗/✓ |
| [#56](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/56) | feat: add git tag shortcut group | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#125](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/125) | feat(skills): 新增 gitlink-newcomer 新人引导 Skill | Ct201314 | 90 | PASS | — | ✓/✗/✓ |
| [#127](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/127) | feat(skills): 新增 gitlink-deps 依赖追踪 Skill | Ct201314 | 90 | PASS | — | ✓/✗/✓ |
| [#128](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/128) | feat(skills): 新增 gitlink-contributor 贡献者致谢与成长 Sk | Ct201314 | 90 | PASS | — | ✓/✗/✓ |
| [#129](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/129) | feat(skills): 新增 gitlink-kb 知识库问答 Skill | Ct201314 | 90 | PASS | — | ✓/✗/✓ |
| [#115](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/115) | feat: add catalog template shortcuts | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#116](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/116) | 新增仓库洞察快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#119](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/119) | 新增仓库转移快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#122](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/122) | 完善仓库 README 快捷命令 | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#50](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/50) | feat: add wiki shortcut group | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#54](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/54) | gitlink-growth 开源贡献者成长系统 Skill 贡献 | yingjie | 90 | PASS | — | ✓/✗/✓ |
| [#23](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/23) | feat: support fork metadata in pr create | Mengz | 90 | PASS | — | ✓/✗/✓ |
| [#196](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/196) | feat(release): 增加发布资产管理快捷命令 | Mengz | 88 | PASS | — | ✓/✗/✓ |
| [#215](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/215) | fix(client): improve API robustness | wangyue111 | 87 | PASS | — | ✓/✗/✓ |
| [#147](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/147) | feat(shortcuts): 新增 wiki/commit/file/star/watch | chroe | 86 | PASS | — | ✓/✗/✓ |
| [#209](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/209) | feat(milestone): 增加里程碑进度分析快捷命令 | Mengz | 85 | PASS | — | ✓/✗/✓ |
| [#205](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/205) | fix(issue): 修复详情缺失并保护更新元数据 | Mengz | 85 | PASS | — | ✓/✗/✓ |
| [#195](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/195) | feat(compare): 新增 compare 汇总与提交筛选能力 | Mengz | 85 | PASS | — | ✓/✗/✓ |
| [#190](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/190) | Add workflow release readiness gate | Mengz | 85 | PASS | — | ✓/✗/✓ |
| [#189](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/189) | Add workflow duplicate issue detection | Mengz | 85 | PASS | — | ✓/✗/✓ |
| [#188](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/188) | Add workflow dependency risk audit | Mengz | 85 | PASS | — | ✓/✗/✓ |
| [#165](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/165) | feat(issue): add batch maintenance shortcuts | wangyue111 | 85 | PASS | — | ✓/✗/✓ |
| [#159](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/159) | Add member application workflow shortcuts | Mengz | 85 | PASS | — | ✓/✗/✓ |
| [#77](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/77) | feat(journal): add issue and PR comment shortcut | wangyue111 | 85 | PASS | — | ✓/✗/✓ |
| [#150](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/150) | 新增 Issue 批量导出命令 | Mengz | 85 | PASS | — | ✓/✗/✓ |
| [#142](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/142) | 新增 PR 本地检出命令 | Mengz | 85 | PASS | — | ✓/✗/✓ |
| [#100](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/100) | 查看指定时间范围的开发统计 | jiangtx | 85 | PASS | — | ✗/✗/✓ |
| [#101](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/101) | 查看用户项目动态 | jiangtx | 85 | PASS | — | ✗/✗/✓ |
| [#21](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/21) | feat: add attachment shortcut group | Mengz | 85 | PASS | — | ✓/✗/✓ |
| [#139](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/139) | feat(wiki): 新增 Wiki 页面与目录管理 Shortcuts | whale | 82 | COMMENT | — | ✓/✗/✓ |
| [#130](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/130) | feat(workflows): 新增 gitlink-flow 社区运营自动化端到端工作流 | Ct201314 | 82 | COMMENT | — | ✓/✗/✓ |
| [#97](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/97) | 基础设施修复 | jiangtx | 81 | COMMENT | — | ✗/✗/✓ |
| [#123](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/123) | 新增 Release 资产下载命令 | Mengz | 80 | COMMENT | — | ✗/✗/✓ |
| [#103](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/103) | feat: 新建 pm 模块,添加 6 条项目管理命令 | wyxttn | 78 | COMMENT | — | — |
| [#131](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/131) | 子赛题三 - Java-Gatekeeper 端到端自动化质量门禁工作流 | xxxx12 | 75 | REQUEST_CHANGES | require_tests_for_src_changes | ✓/✓/✓ |
| [#30](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/30) | 增加wiki管理的shortcut | camelliamc | 74 | COMMENT | — | — |
| [#146](https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/146) | feat: 新增 Showcase Dashboard 交互式展示页 | chroe | 70 | REQUEST_CHANGES | require_tests_for_src_changes | ✓/✗/✓ |
## 这份报告说明了什么
- 同一份 `gatekeeper.yaml` 策略可以**无人值守地体检一个真实活跃仓库的全部积压**——确定性评分意味着大规模治理零 AI 成本AI 只在需要语义判断review_findings时按需介入。
- 任何人重跑本报告(`python3 scripts/gatekeeper_sweep.py`)会对同一组 PR 得到同样的分数与裁决。

View File

@ -0,0 +1,23 @@
{
"_comment": "对 Gitlink/gitlink-cli PR #15222feat/org-team-projectshead bcc27bf真实 diff 的人工+AI 代码审查发现。每条均可在该 PR 的 shortcuts/org/org.go 对应行号复核——非样例数据。",
"findings": [
{
"severity": "major",
"file": "shortcuts/org/org.go",
"line": 156,
"message": "parseBool 只把字面 \"true\" 当真:用户传 --dry-run=1 / t / TRUE 以外写法会被静默解析为 false。该 flag 守护的是 team-projects-remove-all批量移除团队全部项目这类破坏性操作——预览意图被静默降级为真实执行。建议改用 strconv.ParseBool与 shortcuts/common/runner.go:52 解析 flag 默认值的行为一致),无法识别的值应报错而非吞掉"
},
{
"severity": "minor",
"file": "shortcuts/org/org.go",
"line": 103,
"message": "team-projects-remove-all 一条命令清空团队全部项目绑定,除 --dry-run 外无确认机制;建议在 Description/help 标注危险性,或要求显式 --yes 二次确认"
},
{
"severity": "minor",
"file": "shortcuts/org/org.go",
"line": 142,
"message": "dry-run 预览 payload 中 actionadd_all_team_projects/remove_all_team_projects与实际请求 path 段create_all/destroy_all见 :134是两套词汇排查问题时易误导建议统一或在 payload 同时给出两者映射"
}
]
}

View File

@ -0,0 +1,62 @@
# owner-rules.yaml — 文件路径 → reviewer 路由表gitlink-gatekeeper 工作流步骤 1
#
# 作用PR 看门人闭环的第一步「路由」。脚本拉取 PR 变更文件后,按下面的
# glob 规则把每个文件映射到建议 reviewer写进评分卡的「Suggested
# reviewers」分区REQUEST_CHANGES 时也写进 tracking issue
#
# 语义(见 scripts/gatekeeper_workflow.py route_reviewers
# - rules 按顺序匹配,**首个命中的规则生效**(顺序即优先级,把更具体的放前面)。
# - glob 用 Python fnmatch 语法(* 不跨目录段时也会匹配 /,与 fnmatch 行为一致)。
# - 一个文件命中后不再继续匹配后续规则;多个 reviewer 写在同一规则的 reviewers 列表里。
# - 未命中任何规则的文件归到 default_reviewers兜底
#
# 排序示例(首个命中即生效、顺序即优先级,请按需排序):
# 下面把目录 glob `skills/**` 放在语言 glob `**/*.go` 之前,于是 `skills/foo.go`
# 会先命中 `skills/**` → 路由给 skill-owner而不会落到 go-reviewer。若你希望
# skills 下的 Go 文件仍由 go-reviewer 审,就把语言规则提到目录规则之前
# (或在目录规则里收窄 glob如 `skills/**/*.md`)。
#
# 注意:本表只产出「建议」,不调用任何写操作;真正分配 reviewer 由维护者在
# PR 页面决定。gatekeeper 不替人点提交SSOT 第 8 节安全规则)。
#
# reviewers 填 GitLink 用户名login。下方为占位示例真实使用时替换为本仓库的维护者。
rules:
# —— 文档:只改文档走文档维护者,避免占用代码 reviewer ——
- glob: "docs/**"
reviewers: ["doc-maintainer"]
- glob: "**/*.md"
reviewers: ["doc-maintainer"]
# —— 工作流 / 脚本本体 ——
- glob: "workflow/**"
reviewers: ["workflow-owner"]
- glob: "skills/**"
reviewers: ["skill-owner"]
# —— 按语言路由到对应方向的 reviewer ——
- glob: "**/*_test.go"
reviewers: ["go-reviewer", "qa-reviewer"]
- glob: "**/*.go"
reviewers: ["go-reviewer"]
- glob: "test_*.py"
reviewers: ["py-reviewer", "qa-reviewer"]
- glob: "**/*.py"
reviewers: ["py-reviewer"]
- glob: "**/*.ts"
reviewers: ["fe-reviewer"]
- glob: "**/*.js"
reviewers: ["fe-reviewer"]
# —— 高敏感区CI / 依赖 / 安全配置,强制资深 reviewer ——
- glob: ".gitea/**"
reviewers: ["ci-owner", "security-reviewer"]
- glob: "**/Dockerfile"
reviewers: ["ci-owner"]
- glob: "go.mod"
reviewers: ["security-reviewer"]
- glob: "go.sum"
reviewers: ["security-reviewer"]
# 未命中上面任何规则的文件,兜底分配给这些人
default_reviewers: ["maintainer"]

View File

@ -0,0 +1,227 @@
# SPDX-License-Identifier: MulanPSL-2.0
"""gatekeeper_sweep —— 对一个仓库的全部 open PR 批量跑门禁(只读 dry-run出治理报告。
把单 PR 策略 评分卡 裁决升级为仓库级体检
1. 翻页拉取 PR 列表筛出 open
2. 逐个调用 gatekeeper_workflow.py强制 dry-run绝不 --apply对远端零写入
3. 汇总每个 PR summary.json 聚合统计 + 全量明细表 sweep-report.md / sweep-summary.json
诚实口径批扫不注入 AI 审查发现--findingsreview_findings 维按 0 发现计满分
报告中明确标注该维度未评其余 4 测试/卫生/commit/CI为真实采集结果
纯标准库无第三方依赖
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from typing import Any
API_BASE = "https://www.gitlink.org.cn/api"
HYGIENE_RE = re.compile(r"desc (✓|✗) / linked issue (✓|✗) / size (✓|✗)")
def fetch_open_prs(owner: str, repo: str, limit_pages: int = 20) -> list[dict[str, Any]]:
"""翻页拉取 PR 列表并筛出 open列表接口的 status 参数不可靠,按字段过滤)。"""
items: list[dict[str, Any]] = []
page = 1
while page <= limit_pages:
url = f"{API_BASE}/{owner}/{repo}/pulls.json?page={page}&limit=50"
with urllib.request.urlopen(url, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
batch = data.get("issues") or []
if not batch:
break
items.extend(batch)
if len(items) >= int(data.get("search_count") or 0):
break
page += 1
return [it for it in items if it.get("pull_request_staus") == "open"]
def run_one(
workflow_script: Path,
owner: str,
repo: str,
number: int,
policy: Path,
owner_rules: Path,
cli_bin: str,
out_dir: Path,
) -> dict[str, Any]:
"""对单个 PR 跑一次 dry-run 门禁,返回解析后的行记录(失败不抛,记 error"""
cmd = [
sys.executable,
str(workflow_script),
"--owner", owner,
"--repo", repo,
"--pr", str(number),
"--policy", str(policy),
"--owner-rules", str(owner_rules),
"--cli-bin", cli_bin,
"--skip-ci",
"--output-dir", str(out_dir),
]
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
slug = f"{owner}_{repo}_pr{number}".replace("/", "_")
summary_path = out_dir / f"{slug}_summary.json"
if proc.returncode == 1 or not summary_path.exists():
return {"number": number, "error": (proc.stderr or proc.stdout)[-200:].strip()}
summary = json.loads(summary_path.read_text(encoding="utf-8"))
hygiene = ""
scorecard_path = out_dir / f"{slug}_scorecard.md"
if scorecard_path.exists():
m = HYGIENE_RE.search(scorecard_path.read_text(encoding="utf-8"))
if m:
hygiene = "/".join(m.groups()) # 例如 "✓/✗/✓":描述/关联issue/体量
return {
"number": number,
"verdict": summary.get("verdict"),
"total": summary.get("total"),
"scores": summary.get("scores", {}),
"hard_gate_failures": [f.get("gate") if isinstance(f, dict) else f
for f in summary.get("hard_gate_failures", [])],
"hygiene": hygiene,
"suggested_reviewers": summary.get("routing", {}).get("suggested_reviewers", []),
}
def aggregate(rows: list[dict[str, Any]]) -> dict[str, Any]:
ok = [r for r in rows if "error" not in r]
totals = sorted(r["total"] for r in ok)
verdicts: dict[str, int] = {}
gate_hits: dict[str, int] = {}
for r in ok:
verdicts[r["verdict"]] = verdicts.get(r["verdict"], 0) + 1
for g in r["hard_gate_failures"]:
gate_hits[str(g)] = gate_hits.get(str(g), 0) + 1
def pct(n: int) -> str:
return f"{100 * n / len(ok):.0f}%" if ok else "0%"
no_linked = sum(1 for r in ok if r["hygiene"] and r["hygiene"].split("/")[1] == "")
zero_cov = sum(1 for r in ok if r["scores"].get("test_coverage") == 0)
return {
"scanned": len(rows),
"succeeded": len(ok),
"failed": len(rows) - len(ok),
"verdicts": verdicts,
"score_min": totals[0] if totals else None,
"score_median": totals[len(totals) // 2] if totals else None,
"score_avg": round(sum(totals) / len(totals), 1) if totals else None,
"score_max": totals[-1] if totals else None,
"hard_gate_hits": gate_hits,
"pct_zero_test_coverage": pct(zero_cov),
"pct_no_linked_issue": pct(no_linked),
"pct_request_changes": pct(verdicts.get("REQUEST_CHANGES", 0)),
}
def render_report(
owner: str, repo: str, policy_label: str, date_label: str,
rows: list[dict[str, Any]], agg: dict[str, Any],
pr_meta: dict[int, dict[str, Any]],
) -> str:
ok = [r for r in rows if "error" not in r]
lines = [
f"# gatekeeper 仓库体检报告 —— {owner}/{repo}{date_label}",
"",
f"> 对 **{agg['scanned']} 个 open PR** 全量 dry-run**只读,零写入**)· 策略 `{policy_label}` · "
f"成功 {agg['succeeded']} / 失败 {agg['failed']}",
">",
"> **诚实口径**:批扫未注入 AI 审查发现review_findings 维按 0 发现计满分(**该维度未评**"
"CI 维按 `--skip-ci` 统一记 unknown半分。其余维度为真实采集。"
"因此**总分代表「除人工/AI 审查外的工程卫生分」,偏乐观**;裁决分布同理。",
"",
"## 总览",
"",
f"- 裁决分布:{' · '.join(f'{k} **{v}**' for k, v in sorted(agg['verdicts'].items()))}",
f"- 分数min {agg['score_min']} / 中位 {agg['score_median']} / 均值 {agg['score_avg']} / max {agg['score_max']}",
f"- **{agg['pct_zero_test_coverage']}** 的 PR 测试覆盖维 0 分(改动不带任何测试)",
f"- **{agg['pct_no_linked_issue']}** 的 PR 未关联 issue",
f"- **{agg['pct_request_changes']}** 的 PR 触发 REQUEST_CHANGES硬门禁或低分",
"",
"硬门禁命中:" + ("".join(f"`{g}` × {n}" for g, n in sorted(agg["hard_gate_hits"].items(), key=lambda x: -x[1])) or ""),
"",
"## 全量明细(按分数降序)",
"",
"| PR | 标题 | 作者 | 总分 | 裁决 | 硬门禁失败 | 卫生(描述/关联/体量) |",
"|----|------|------|-----:|------|-----------|---------------------|",
]
for r in sorted(ok, key=lambda x: -x["total"]):
meta = pr_meta.get(r["number"], {})
title = str(meta.get("name", ""))[:48].replace("|", "\\|")
gates = ", ".join(str(g) for g in r["hard_gate_failures"]) or ""
lines.append(
f"| [#{r['number']}](https://www.gitlink.org.cn/{owner}/{repo}/pulls/{r['number']}) "
f"| {title} | {meta.get('author_name', '?')} | {r['total']} | {r['verdict']} | {gates} | {r['hygiene'] or ''} |"
)
errs = [r for r in rows if "error" in r]
if errs:
lines += ["", "## 跑失败的 PR", ""]
lines += [f"- #{r['number']}`{r['error']}`" for r in errs]
lines += [
"",
"## 这份报告说明了什么",
"",
"- 同一份 `gatekeeper.yaml` 策略可以**无人值守地体检一个真实活跃仓库的全部积压**——"
"确定性评分意味着大规模治理零 AI 成本AI 只在需要语义判断review_findings时按需介入。",
"- 任何人重跑本报告(`python3 scripts/gatekeeper_sweep.py`)会对同一组 PR 得到同样的分数与裁决。",
"",
]
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="对全部 open PR 批量 dry-run 出治理报告")
parser.add_argument("--owner", default="Gitlink")
parser.add_argument("--repo", default="gitlink-cli")
parser.add_argument("--policy", type=Path, required=True)
parser.add_argument("--owner-rules", dest="owner_rules", type=Path, required=True)
parser.add_argument("--cli-bin", default="gitlink-cli")
parser.add_argument("--output-dir", type=Path, default=Path("sweep-outputs"))
parser.add_argument("--date-label", default="sweep", help="报告日期标签(可复现:不取系统时间)")
parser.add_argument("--max", type=int, default=0, help="只跑前 N 个0=全量),用于试跑")
parser.add_argument("--sleep", type=float, default=0.2, help="相邻 PR 间隔秒数(对平台礼貌)")
args = parser.parse_args(argv)
workflow_script = Path(__file__).with_name("gatekeeper_workflow.py")
runs_dir = args.output_dir / "runs"
runs_dir.mkdir(parents=True, exist_ok=True)
prs = fetch_open_prs(args.owner, args.repo)
if args.max:
prs = prs[: args.max]
pr_meta = {int(p["pull_request_number"]): p for p in prs}
print(f"open PR 共 {len(prs)}开始批扫dry-run零写入", flush=True)
rows: list[dict[str, Any]] = []
for i, p in enumerate(prs, 1):
number = int(p["pull_request_number"])
row = run_one(workflow_script, args.owner, args.repo, number,
args.policy, args.owner_rules, args.cli_bin, runs_dir)
rows.append(row)
tag = row.get("verdict", "ERROR")
print(f"[{i}/{len(prs)}] PR #{number}{tag} {row.get('total', '')}", flush=True)
time.sleep(args.sleep)
agg = aggregate(rows)
policy_label = args.policy.name
report = render_report(args.owner, args.repo, policy_label, args.date_label, rows, agg, pr_meta)
(args.output_dir / "sweep-report.md").write_text(report, encoding="utf-8")
(args.output_dir / "sweep-summary.json").write_text(
json.dumps({"aggregate": agg, "rows": rows}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"\n报告:{args.output_dir / 'sweep-report.md'}")
print(f"汇总:{args.output_dir / 'sweep-summary.json'}")
print(f"裁决分布:{agg['verdicts']} · 均分 {agg['score_avg']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""可复现评分单测 —— 把「同输入 → 同分 → 同裁决」从口号变成可验证事实。
纯标准库 unittestPython 3.9 兼容直接 import `scripts/gatekeeper_workflow.py`
的确定性算法score_dimensions / evaluate_hard_gates / decide_verdict对四个
权威裁决案例skills/gitlink-gatekeeper/examples/decision-*.md
scorecard-sample.md构造等价的 ScoreInput断言**总分****三态裁决**与文档逐位一致
任意一处算法改动若改变了这四个案例的分值本测试立即变红即为确定性的回归护栏
运行
python3 workflow/tests/test_scoring.py
python3 -m unittest workflow.tests.test_scoring # 在仓库根目录
数值来源默认策略 gatekeeper.yaml与脚本内置 DEFAULT_POLICY 一致
权重 40/20/15/15/10severity_penalty blocker=100/major=25/minor=5/nit=1
thresholds pass=85 / request_changes=60max_changed_files=80
"""
from __future__ import annotations
import importlib.util
import sys
import unittest
from pathlib import Path
# --------------------------------------------------------------------------- #
# 以绝对路径加载被测脚本(它在 scripts/ 下、非包,按文件直接载入最稳)
#
# 注意:必须先把模块塞进 sys.modules 再 exec —— 被测脚本用了
# `from __future__ import annotations`Python 3.9 的 @dataclass 在解析字符串
# 注解时会回查 sys.modules[cls.__module__],未注册会取到 None 而报
# AttributeError'NoneType' object has no attribute '__dict__')。
# --------------------------------------------------------------------------- #
_SCRIPT = (
Path(__file__).resolve().parent.parent / "scripts" / "gatekeeper_workflow.py"
)
_spec = importlib.util.spec_from_file_location("gatekeeper_workflow", _SCRIPT)
assert _spec and _spec.loader, f"无法定位被测脚本:{_SCRIPT}"
gw = importlib.util.module_from_spec(_spec)
sys.modules["gatekeeper_workflow"] = gw
_spec.loader.exec_module(gw) # type: ignore[union-attr]
ScoreInput = gw.ScoreInput
Finding = gw.Finding
score_dimensions = gw.score_dimensions
evaluate_hard_gates = gw.evaluate_hard_gates
decide_verdict = gw.decide_verdict
# 默认策略(深拷贝一份,避免任何用例意外改到共享 dict
import json as _json # noqa: E402
DEFAULT_POLICY = _json.loads(_json.dumps(gw.DEFAULT_POLICY))
# --------------------------------------------------------------------------- #
# 构造辅助:把「严重度计数 / 文件数 / commit 计数」翻译成 ScoreInput 字段
# --------------------------------------------------------------------------- #
def _findings(blocker: int = 0, major: int = 0, minor: int = 0, nit: int = 0):
"""按严重度计数生成 Finding 列表message/file/line 对评分无影响,仅 severity 计 penalty"""
out = []
for sev, n in (("blocker", blocker), ("major", major), ("minor", minor), ("nit", nit)):
for i in range(n):
out.append(Finding(severity=sev, message=f"{sev} #{i}", file="f.go", line=i + 1))
return out
def _commits(conforming: int, total: int):
"""生成 total 条 commit message其中 conforming 条符合 Conventional Commits。"""
assert conforming <= total
msgs = [f"feat(mod{i}): conforming change {i}" for i in range(conforming)]
msgs += [f"wip update {i}" for i in range(total - conforming)] # 'wip ...' 不匹配规约
return msgs
def _files(n: int):
"""生成 n 个占位变更文件路径(仅用于 size 维度计 lensrc/test 计数由字段直接给定)。"""
return [f"path/file_{i}.go" for i in range(n)]
def _build(
*,
pr_id: str,
title: str,
desc_len: int,
linked_issue: bool,
n_files: int,
src: int,
tests: int,
commits: tuple, # (conforming, total)
ci: str,
findings_counts: dict,
) -> ScoreInput:
description = "x" * desc_len if desc_len else ""
return ScoreInput(
pr_id=pr_id,
title=title,
description=description,
changed_files=_files(n_files),
changed_src=src,
changed_tests=tests,
commits=_commits(*commits),
ci_status=ci,
linked_issue=linked_issue,
findings=_findings(**findings_counts),
)
def _run(inp: ScoreInput):
"""跑完整确定性链路,返回 (total, verdict)。"""
dims = score_dimensions(inp, DEFAULT_POLICY)
failures = evaluate_hard_gates(inp, DEFAULT_POLICY)
verdict = decide_verdict(dims["total"], bool(failures), DEFAULT_POLICY)
return dims, failures, verdict
# --------------------------------------------------------------------------- #
# 四个权威案例
# --------------------------------------------------------------------------- #
class TestAuthoritativeCases(unittest.TestCase):
"""对照 examples/ 下四个裁决记录,断言总分与裁决。"""
def test_decision_pass(self):
# decision-pass.md3 src / 2 test、desc 142(含#198)、4/4 commit、CI passing、
# 0/0/1/2 findings → 33+17+15+15+10 = 90 → PASS
inp = _build(
pr_id="214",
title="feat(search): validate pagination params",
desc_len=142,
linked_issue=True,
n_files=5,
src=3,
tests=2,
commits=(4, 4),
ci="passing",
findings_counts={"minor": 1, "nit": 2},
)
dims, failures, verdict = _run(inp)
self.assertEqual(dims["review_findings"]["score"], 33)
self.assertEqual(dims["test_coverage"]["score"], 17)
self.assertEqual(dims["pr_hygiene"]["score"], 15)
self.assertEqual(dims["commit_quality"]["score"], 15)
self.assertEqual(dims["ci_status"]["score"], 10)
self.assertEqual(failures, [])
self.assertEqual(dims["total"], 90)
self.assertEqual(verdict, "PASS")
def test_decision_request_changes(self):
# decision-request-changes.md4 src / 0 test触发硬门禁
# require_tests_for_src_changes、desc 88 无关联、2/3 commit、CI passing、
# 0/1/1/2 findings → 8+0+10+10+10 = 38 → REQUEST_CHANGES
inp = _build(
pr_id="305",
title="refactor(billing): rework settlement pipeline",
desc_len=88,
linked_issue=False,
n_files=4,
src=4,
tests=0,
commits=(2, 3),
ci="passing",
findings_counts={"major": 1, "minor": 1, "nit": 2},
)
dims, failures, verdict = _run(inp)
self.assertEqual(dims["review_findings"]["score"], 8)
self.assertEqual(dims["test_coverage"]["score"], 0)
self.assertEqual(dims["pr_hygiene"]["score"], 10)
self.assertEqual(dims["commit_quality"]["score"], 10)
self.assertEqual(dims["ci_status"]["score"], 10)
gate_names = {f["gate"] for f in failures}
self.assertIn("require_tests_for_src_changes", gate_names)
self.assertEqual(dims["total"], 38)
self.assertEqual(verdict, "REQUEST_CHANGES")
def test_decision_comment(self):
# decision-comment.md2 src / 1 test、desc 52 无关联、2/3 commit、CI passing、
# 0/0/3/2 findings → 23+15+10+10+10 = 68 ∈ [60,85) 且无硬门禁 → COMMENT
inp = _build(
pr_id="277",
title="feat(config): merge defaults on load",
desc_len=52,
linked_issue=False,
n_files=2,
src=2,
tests=1,
commits=(2, 3),
ci="passing",
findings_counts={"minor": 3, "nit": 2},
)
dims, failures, verdict = _run(inp)
self.assertEqual(dims["review_findings"]["score"], 23)
self.assertEqual(dims["test_coverage"]["score"], 15)
self.assertEqual(dims["pr_hygiene"]["score"], 10)
self.assertEqual(dims["commit_quality"]["score"], 10)
self.assertEqual(dims["ci_status"]["score"], 10)
self.assertEqual(failures, [])
self.assertEqual(dims["total"], 68)
self.assertEqual(verdict, "COMMENT")
def test_scorecard_sample(self):
# scorecard-sample.md4 src / 0 test触发硬门禁、desc 64 无关联、3/4 commit、
# CI passing、0/1/2/1 findings → 4+0+10+11+10 = 35 → REQUEST_CHANGES
inp = _build(
pr_id="128",
title="feat(auth): add refresh-token rotation",
desc_len=64,
linked_issue=False,
n_files=6,
src=4,
tests=0,
commits=(3, 4),
ci="passing",
findings_counts={"major": 1, "minor": 2, "nit": 1},
)
dims, failures, verdict = _run(inp)
self.assertEqual(dims["review_findings"]["score"], 4)
self.assertEqual(dims["test_coverage"]["score"], 0)
self.assertEqual(dims["pr_hygiene"]["score"], 10)
self.assertEqual(dims["commit_quality"]["score"], 11)
self.assertEqual(dims["ci_status"]["score"], 10)
gate_names = {f["gate"] for f in failures}
self.assertIn("require_tests_for_src_changes", gate_names)
self.assertEqual(dims["total"], 35)
self.assertEqual(verdict, "REQUEST_CHANGES")
class TestVerdictBoundaries(unittest.TestCase):
"""裁决判定树decide_verdict边界与 thresholds pass=85 / request_changes=60 一致。"""
def test_pass_threshold_inclusive(self):
self.assertEqual(decide_verdict(85, False, DEFAULT_POLICY), "PASS")
def test_comment_band(self):
self.assertEqual(decide_verdict(60, False, DEFAULT_POLICY), "COMMENT")
self.assertEqual(decide_verdict(84, False, DEFAULT_POLICY), "COMMENT")
def test_request_changes_below_band(self):
self.assertEqual(decide_verdict(59, False, DEFAULT_POLICY), "REQUEST_CHANGES")
def test_hard_gate_short_circuits_high_score(self):
# 即便满分,硬门禁失败也直接 REQUEST_CHANGES
self.assertEqual(decide_verdict(100, True, DEFAULT_POLICY), "REQUEST_CHANGES")
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1 @@
outputs/

View File

@ -0,0 +1,105 @@
# GitLink 项目一键初始化与协作启动工作流
面向 GitLink 竞赛子赛题三的端到端自动化工作流示例。
本项目聚焦开源项目从 0 到可协作状态的启动过程,使用 `gitlink-cli` 串联仓库检查、分支规划、初始 Issue 创建和结果回写等能力,自动生成 README、LICENSE、CI 配置、协作文档、初始化报告和结构化清单。该流程覆盖“项目配置 -> 初始化文件生成 -> GitLink 命令编排 -> 任务落地 -> 报告归档”的完整闭环。
## 交付物
- `scripts/bootstrap_project.go`:主工作流入口
- `scripts/run_demo.ps1`:一键复现脚本
- `examples/sample_project.json`:示例项目配置
- `examples/verification_comment_config.json`:真实回写验证配置
- `examples/demo_outputs/`:固定示例输出
- `docs/workflow-spec.md`:工作流说明文档
- `docs/architecture.md`:架构与流程说明
- `docs/assets/bootstrap-architecture.svg`:架构图
- `docs/quickstart.md`:最短复现路径
- `docs/runbook.md`:运行手册
- `docs/verification.md`:验证记录
- `docs/submission-checklist.md`:赛题要求映射
- `scripts/bootstrap_project_test.go`Go 单元测试
## 实现语言
本工作流主实现采用 Go主要考虑如下
- 与 `gitlink-cli` 主仓库技术栈一致,便于维护者阅读、测试和后续集成。
- 可直接复用 Go 标准库完成 JSON 配置解析、文件生成、命令编排和单元测试,不引入额外运行时依赖。
- Windows、Linux 和 macOS 均可通过 `go run` 复现,便于评审在不同环境中执行。
- 对命令执行结果、退出码和结构化日志的处理更接近 `gitlink-cli` 自身工程风格。
## 运行方式
进入本目录后执行 dry-run
```powershell
.\scripts\run_demo.ps1
```
执行后会生成:
- `outputs/*_bootstrap_report.md`
- `outputs/*_summary.md`
- `outputs/*_manifest.json`
- `outputs/*_files.json`
- `outputs/command_log_*.json`
输出文件名包含目标仓库和生成时间,格式如下:
- `{owner}_{repo}_{YYYYMMDD_HHMMSS}_bootstrap_report.md`
- `{owner}_{repo}_{YYYYMMDD_HHMMSS}_summary.md`
- `{owner}_{repo}_{YYYYMMDD_HHMMSS}_manifest.json`
- `{owner}_{repo}_{YYYYMMDD_HHMMSS}_files.json`
- `command_log_{YYYYMMDD_HHMMSS}.json`
例如 `puygob236_gitlink-bootstrap-demo_20260524_080000_bootstrap_report.md`。实际运行时会按当前时间生成新文件名,`examples/demo_outputs/` 中的固定时间戳文件仅作为示例产物。
如需执行真实 GitLink 写操作,在完成 GitLink 认证并核对目标仓库后使用:
```powershell
.\scripts\run_demo.ps1 -Apply
```
如需连同仓库创建一起执行:
```powershell
.\scripts\run_demo.ps1 -Apply -CreateRepo
```
如需把初始化摘要发布到指定 Issue
```powershell
.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1
```
## 工作流串联的 gitlink-cli 调用
默认配置会规划 7 个 `gitlink-cli` 调用:
1. `repo +info`
2. `branch +list`
3. `branch +create`
4. `branch +create`
5. `issue +create`
6. `issue +create`
7. `issue +create`
当指定 `-PublishIssueNumber` 时,会额外追加 `issue +comment`,用于把初始化摘要回写到 GitLink Issue。
当指定 `-CreateRepo` 时,会在检查仓库前追加 `repo +create`
## 文档索引
- 工作流说明:`docs/workflow-spec.md`
- 架构说明与架构图:`docs/architecture.md`
- 复现指南:`docs/quickstart.md`
- 运行手册:`docs/runbook.md`
- 验证记录:`docs/verification.md`
- 提交核对清单:`docs/submission-checklist.md`
## 场景价值
- 降低新开源项目启动成本,避免 README、License、CI、初始任务缺失。
- 将项目初始化过程结构化,便于团队复用和审计。
- 将 `gitlink-cli` 的仓库、分支、Issue 和评论能力串联为可复现方案。
- 支持 dry-run 和 apply 两种模式,兼顾演示稳定性和真实落地。

View File

@ -0,0 +1,34 @@
# 架构说明
本工作流采用“配置输入 -> 资产生成 -> CLI 编排 -> GitLink 落地 -> 结果归档”的五段式架构。正式架构图见 `docs/assets/bootstrap-architecture.svg`
![GitLink 项目一键初始化工作流架构](assets/bootstrap-architecture.svg)
```mermaid
flowchart LR
A["项目配置<br/>sample_project.json"] --> B["资产生成<br/>README / LICENSE / CI / 协作文档"]
B --> C["CLI 编排<br/>repo / branch / issue / comment"]
C --> D["GitLink 项目空间<br/>仓库 / 分支 / Issue"]
D --> E["结果归档<br/>报告 / 摘要 / manifest / 命令日志"]
C --> E
```
## 模块职责
| 模块 | 职责 |
| --- | --- |
| 配置输入 | 描述项目名称、目标仓库、初始化分支和初始 Issue |
| 资产生成 | 生成 README、LICENSE、CI 配置、贡献指南和路线图 |
| CLI 编排 | 规划或执行 `gitlink-cli` 命令串联仓库、分支、Issue 和评论能力 |
| GitLink 落地 | 在真实 GitLink 仓库中创建分支、Issue并可回写摘要 |
| 结果归档 | 输出 Markdown 报告、摘要、JSON manifest 和命令日志 |
## 端到端链路
1. 读取 `examples/sample_project.json`
2. 生成初始化文件包。
3. 规划 `repo +info``branch +list` 检查目标状态。
4. 规划或执行 `branch +create` 创建协作分支。
5. 规划或执行 `issue +create` 创建初始任务。
6. 可选执行 `issue +comment` 发布初始化摘要。
7. 生成报告与命令日志,支撑复现和审计。

View File

@ -0,0 +1,169 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1672" height="941" viewBox="0 0 1672 941" role="img" aria-labelledby="title desc">
<title id="title">GitLink Project Bootstrap Automation</title>
<desc id="desc">A vector architecture diagram for a configuration-driven GitLink project bootstrap workflow.</desc>
<defs>
<filter id="cardShadow" x="-16%" y="-16%" width="132%" height="132%">
<feDropShadow dx="0" dy="12" stdDeviation="11" flood-color="#0a1b35" flood-opacity="0.11"/>
</filter>
<style>
text { font-family: Arial, "Microsoft YaHei", sans-serif; fill: #0b1736; }
.title { font-size: 72px; font-weight: 800; letter-spacing: 0; }
.subtitle { font-size: 27px; fill: #46556b; }
.card-title { font-size: 34px; font-weight: 800; }
.cli-title { font-size: 30px; font-weight: 800; }
.title-navy { fill: #071449; }
.title-teal { fill: #075e70; }
.title-blue { fill: #14579f; }
.title-slate { fill: #334155; }
.item { font-size: 23px; fill: #101a2f; }
.item-condensed { font-size: 22px; fill: #101a2f; }
.small { font-size: 20px; fill: #123e21; }
.mono { font-family: Consolas, "Courier New", monospace; font-size: 19px; fill: #101a2f; }
.hairline { stroke: #a8b5c4; stroke-width: 1.6; }
.icon-line { fill: none; stroke-width: 3.5; stroke-linecap: round; stroke-linejoin: round; }
.bullet-navy { fill: #071449; }
.bullet-teal { fill: #075e70; }
.bullet-blue { fill: #14579f; }
.bullet-slate { fill: #334155; }
.valid { fill: #32833f; }
</style>
</defs>
<rect width="1672" height="941" fill="#fbfcfe"/>
<text x="836" y="93" text-anchor="middle" class="title">GitLink Project Bootstrap Automation</text>
<text x="836" y="151" text-anchor="middle" class="subtitle">Configuration-driven project initialization, CLI orchestration, GitLink execution, and reproducible evidence</text>
<!-- Input -->
<g transform="translate(48 208)">
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#071449" stroke-width="2.2" filter="url(#cardShadow)"/>
<g transform="translate(72 36)" class="icon-line" stroke="#071449">
<path d="M10 0h55l28 28v102H10z"/>
<path d="M65 0v28h28"/>
<text x="50" y="78" text-anchor="middle" font-family="Consolas, 'Courier New', monospace" font-size="34" font-weight="700" fill="#071449" stroke="none">{ }</text>
<path d="M32 92h38"/>
<path d="M32 111h29"/>
</g>
<text x="128" y="196" text-anchor="middle" class="card-title title-navy">Input</text>
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
<circle cx="24" cy="259" r="4.5" class="bullet-navy"/><text x="43" y="267" class="item">Project metadata</text>
<circle cx="24" cy="305" r="4.5" class="bullet-navy"/><text x="43" y="313" class="item">Repository target</text>
<circle cx="24" cy="351" r="4.5" class="bullet-navy"/><text x="43" y="359" class="item">Branches</text>
<circle cx="24" cy="397" r="4.5" class="bullet-navy"/><text x="43" y="405" class="item">Initial issues</text>
<circle cx="24" cy="443" r="4.5" class="bullet-navy"/><text x="43" y="451" class="mono">sample_project.json</text>
</g>
<!-- Assets -->
<g transform="translate(377 208)">
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#075e70" stroke-width="2.2" filter="url(#cardShadow)"/>
<g transform="translate(64 35)" class="icon-line" stroke="#075e70">
<path d="M2 45h17v84H2z"/>
<path d="M22 25h58l23 23v84H22z"/>
<path d="M80 25v23h23"/>
<path d="M43 6h59l23 23v84h-22"/>
<path d="M102 6v23h23"/>
<path d="M43 58h44"/>
<path d="M43 80h48"/>
<path d="M43 102h40"/>
</g>
<text x="128" y="196" text-anchor="middle" class="card-title title-teal">Assets</text>
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
<circle cx="24" cy="259" r="4.5" class="bullet-teal"/><text x="43" y="267" class="item">README</text>
<circle cx="24" cy="305" r="4.5" class="bullet-teal"/><text x="43" y="313" class="item">LICENSE</text>
<circle cx="24" cy="351" r="4.5" class="bullet-teal"/><text x="43" y="359" class="item">Go CI</text>
<circle cx="24" cy="397" r="4.5" class="bullet-teal"/><text x="43" y="405" class="item">CONTRIBUTING</text>
<circle cx="24" cy="443" r="4.5" class="bullet-teal"/><text x="43" y="451" class="item">ROADMAP</text>
</g>
<!-- CLI -->
<g transform="translate(705 208)">
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#14579f" stroke-width="2.2" filter="url(#cardShadow)"/>
<g transform="translate(79 41)" class="icon-line" stroke="#14579f">
<rect x="0" y="0" width="98" height="94" rx="6"/>
<path d="M0 29h98"/>
<circle cx="17" cy="14" r="3" fill="#14579f" stroke="none"/>
<circle cx="33" cy="14" r="3" fill="#14579f" stroke="none"/>
<circle cx="49" cy="14" r="3" fill="#14579f" stroke="none"/>
<path d="M31 56l18 17-18 18"/>
<path d="M61 86h22"/>
</g>
<text x="128" y="196" text-anchor="middle" class="cli-title title-blue" textLength="218" lengthAdjust="spacingAndGlyphs">CLI Orchestration</text>
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
<circle cx="24" cy="259" r="4.5" class="bullet-blue"/><text x="52" y="267" class="mono">repo +info</text>
<circle cx="24" cy="305" r="4.5" class="bullet-blue"/><text x="52" y="313" class="mono">branch +list</text>
<circle cx="24" cy="351" r="4.5" class="bullet-blue"/><text x="52" y="359" class="mono">branch +create</text>
<circle cx="24" cy="397" r="4.5" class="bullet-blue"/><text x="52" y="405" class="mono">issue +create</text>
</g>
<!-- GitLink -->
<g transform="translate(1032 208)">
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#334155" stroke-width="2.2" filter="url(#cardShadow)"/>
<g transform="translate(51 40)" class="icon-line" stroke="#334155">
<path d="M44 106h68c25 0 42-17 42-41 0-23-16-40-40-42C109 9 93 0 75 0 54 0 37 12 29 31 11 36 0 50 0 68c0 22 18 38 43 38"/>
<circle cx="76" cy="36" r="8"/>
<circle cx="51" cy="70" r="8"/>
<circle cx="102" cy="70" r="8"/>
<path d="M72 44L56 63"/>
<path d="M80 44l17 20"/>
<path d="M76 44v21"/>
</g>
<text x="128" y="196" text-anchor="middle" class="card-title title-slate">GitLink</text>
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
<circle cx="24" cy="259" r="4.5" class="bullet-slate"/><text x="43" y="267" class="item">Repository state</text>
<circle cx="24" cy="305" r="4.5" class="bullet-slate"/><text x="43" y="313" class="item-condensed" textLength="188" lengthAdjust="spacingAndGlyphs">Collaboration branches</text>
<circle cx="24" cy="351" r="4.5" class="bullet-slate"/><text x="43" y="359" class="item">Bootstrap issues</text>
<circle cx="24" cy="397" r="4.5" class="bullet-slate"/><text x="43" y="405" class="item">Issue comment</text>
<circle cx="24" cy="443" r="4.5" class="bullet-slate"/><text x="43" y="445" class="item">Apply mode writes</text><text x="43" y="475" class="item">remotely</text>
</g>
<!-- Evidence -->
<g transform="translate(1364 208)">
<rect width="255" height="505" rx="18" fill="#ffffff" stroke="#14579f" stroke-width="2.2" filter="url(#cardShadow)"/>
<g transform="translate(72 36)" class="icon-line" stroke="#14579f">
<path d="M10 0h55l28 28v102H10z"/>
<path d="M65 0v28h28"/>
<path d="M31 56h43"/>
<path d="M31 79h43"/>
<path d="M31 102h34"/>
</g>
<text x="128" y="196" text-anchor="middle" class="card-title title-blue">Evidence</text>
<line x1="23" y1="221" x2="232" y2="221" class="hairline"/>
<circle cx="24" cy="259" r="4.5" class="bullet-blue"/><text x="43" y="267" class="item">Markdown report</text>
<circle cx="24" cy="305" r="4.5" class="bullet-blue"/><text x="43" y="313" class="item">Issue summary</text>
<circle cx="24" cy="351" r="4.5" class="bullet-blue"/><text x="43" y="359" class="mono">manifest.json</text>
<circle cx="24" cy="397" r="4.5" class="bullet-blue"/><text x="43" y="405" class="mono">files.json</text>
<circle cx="24" cy="443" r="4.5" class="bullet-blue"/><text x="43" y="451" class="item">command log</text>
</g>
<polygon points="316,404 343,404 343,386 371,411 343,436 343,418 316,418" fill="#06133a"/>
<polygon points="644,404 671,404 671,386 699,411 671,436 671,418 644,418" fill="#06133a"/>
<polygon points="972,404 999,404 999,386 1027,411 999,436 999,418 972,418" fill="#06133a"/>
<polygon points="1302,404 1329,404 1329,386 1357,411 1329,436 1329,418 1302,418" fill="#06133a"/>
<g transform="translate(55 777)">
<rect width="1562" height="122" rx="14" fill="#f8fbf8" stroke="#a8b8aa" stroke-width="1.8"/>
<g transform="translate(32 22)">
<rect width="330" height="78" rx="12" fill="#ffffff" stroke="#4f9a57" stroke-width="2"/>
<circle cx="49" cy="39" r="23" class="valid"/>
<path d="M38 38l8 9 18-20" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<text x="96" y="48" class="small" fill="#0f4d22">Go implementation</text>
</g>
<g transform="translate(400 22)">
<rect width="330" height="78" rx="12" fill="#ffffff" stroke="#4f9a57" stroke-width="2"/>
<circle cx="49" cy="39" r="23" class="valid"/>
<path d="M38 38l8 9 18-20" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<text x="96" y="48" class="small" fill="#0f4d22">7 default CLI calls</text>
</g>
<g transform="translate(768 22)">
<rect width="330" height="78" rx="12" fill="#ffffff" stroke="#4f9a57" stroke-width="2"/>
<circle cx="49" cy="39" r="23" class="valid"/>
<path d="M38 38l8 9 18-20" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<text x="96" y="48" class="small" fill="#0f4d22">Reproducible dry-run</text>
</g>
<g transform="translate(1136 22)">
<rect width="370" height="78" rx="12" fill="#ffffff" stroke="#4f9a57" stroke-width="2"/>
<circle cx="49" cy="39" r="23" class="valid"/>
<path d="M38 38l8 9 18-20" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<text x="96" y="48" class="small" fill="#0f4d22">Validated on real GitLink repo</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,56 @@
# 快速开始
## 1. 进入目录
```powershell
cd examples\workflows\project-bootstrap-automation
```
## 2. 运行 dry-run
```powershell
.\scripts\run_demo.ps1
```
该命令不会写入 GitLink只生成初始化材料和命令计划。
## 3. 查看输出
```powershell
Get-ChildItem outputs
```
重点查看:
- `*_bootstrap_report.md`
- `*_summary.md`
- `*_manifest.json`
- `command_log_*.json`
## 4. 执行单元测试
```powershell
go test ./scripts
```
## 5. 执行真实写入
确认目标仓库和认证状态后执行:
```powershell
.\scripts\run_demo.ps1 -Apply
```
执行真实写入前,应先通过 `gitlink-cli auth login` 或当前环境已配置的认证方式完成 GitLink 登录。
如需创建目标仓库:
```powershell
.\scripts\run_demo.ps1 -Apply -CreateRepo
```
如需把摘要发布到指定 Issue
```powershell
.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1
```

View File

@ -0,0 +1,43 @@
# 运行手册
## 模式说明
| 模式 | 命令 | 说明 |
| --- | --- | --- |
| dry-run | `.\scripts\run_demo.ps1` | 只生成材料和命令计划,不写入 GitLink |
| apply | `.\scripts\run_demo.ps1 -Apply` | 执行真实 `gitlink-cli` 命令 |
| apply + create repo | `.\scripts\run_demo.ps1 -Apply -CreateRepo` | 先创建仓库,再执行初始化命令 |
| apply + comment | `.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1` | 执行真实命令,并将摘要评论到指定 Issue |
## 配置文件
默认配置位于:
```text
examples/sample_project.json
```
主要字段:
- `project`:项目名称、描述、语言、许可证
- `repository`:目标 GitLink 仓库 owner/name
- `branches`:需要创建的协作分支
- `issues`:初始化 Issue 列表
- `publish.issue_number`:可选的摘要发布 Issue 编号
## 输出文件
| 文件 | 说明 |
| --- | --- |
| `*_bootstrap_report.md` | 初始化报告 |
| `*_summary.md` | 可发布到 Issue 的摘要 |
| `*_manifest.json` | 结构化初始化清单 |
| `*_files.json` | 生成文件内容包 |
| `command_log_*.json` | gitlink-cli 命令计划或执行结果 |
## 安全边界
- 默认 dry-run不进行远端写操作。
- 只有显式传入 `-Apply` 才执行真实 GitLink 命令。
- `-PublishIssueNumber` 只在明确指定 Issue 编号时追加评论命令。
- 所有命令会写入 `command_log_*.json`,便于复盘和审计。

View File

@ -0,0 +1,27 @@
# 提交核对清单
## 官方交付要求映射
| 要求 | 本项目对应内容 |
| --- | --- |
| 工作流串联不少于 3 个 CLI 命令或 Skill 调用 | `scripts/bootstrap_project.go` 规划或执行 `repo +info`、`branch +list`、`branch +create`、`issue +create`、`issue +comment` |
| 提供可复现执行脚本或 Agent 对话记录 | `scripts/run_demo.ps1` |
| 在至少一个真实 GitLink 项目上运行并展示效果 | 已在 `puygob236/gitlink-bootstrap-demo` 完成仓库读取、分支读取、Issue 创建和 Issue 摘要回写验证 |
| 提供工作流说明文档 | `README.md`、`docs/workflow-spec.md`、`docs/quickstart.md`、`docs/runbook.md` |
| 提供架构图 | `docs/architecture.md`、`docs/assets/bootstrap-architecture.svg` |
| 提供演示材料 | 演示视频作为比赛平台附件提交;仓库内保留 `scripts/run_demo.ps1`、`docs/verification.md` 和 `examples/demo_outputs/` 作为可复现证据 |
| 代码开源并托管到 GitLink | 放置于 `examples/workflows/project-bootstrap-automation/` |
| 提供完整中文 README | `README.md` |
## 验证状态
- `go test ./scripts`:通过
- `.\scripts\run_demo.ps1`:通过
- dry-run 生成 7 个 gitlink-cli 调用计划,满足赛题要求
- `.\scripts\run_demo.ps1 -Config examples\verification_comment_config.json -Apply -PublishIssueNumber 4`通过3 个真实 gitlink-cli 调用状态均为 `ok`
## 交付内容
- `README.md`、`docs/`、`scripts/`、`examples/` 均位于本目录。
- `outputs/` 为运行时生成目录,评审可通过复现脚本重新生成。
- `examples/demo_outputs/` 用于保存固定示例产物。

View File

@ -0,0 +1,84 @@
# 验证记录
## 本地验证
执行目录:
```text
examples/workflows/project-bootstrap-automation
```
单元测试:
```powershell
go test ./scripts
```
结果:
```text
ok github.com/gitlink-org/gitlink-cli/examples/workflows/project-bootstrap-automation/scripts
```
dry-run 复现:
```powershell
.\scripts\run_demo.ps1
```
结果:
```text
已生成初始化报告: outputs\puygob236_gitlink-bootstrap-demo_20260524_072107_bootstrap_report.md
已生成初始化摘要: outputs\puygob236_gitlink-bootstrap-demo_20260524_072107_summary.md
已生成文件清单: outputs\puygob236_gitlink-bootstrap-demo_20260524_072107_manifest.json
已生成命令日志: outputs\command_log_20260524_072107.json
模式: dry-run
计划/执行 gitlink-cli 调用: 7 个
```
## 真实仓库验证计划
目标仓库:
```text
puygob236/gitlink-bootstrap-demo
```
验证步骤:
1. 确认 GitLink 认证可用。
2. 创建或确认目标仓库存在。
3. 执行 `.\scripts\run_demo.ps1 -Apply`
4. 检查分支、Issue 和输出报告。
5. 如需展示回写能力,执行 `.\scripts\run_demo.ps1 -Apply -PublishIssueNumber <number>`
## 真实仓库验证结果
目标仓库:
```text
https://gitlink.org.cn/puygob236/gitlink-bootstrap-demo
```
已完成验证:
- `repo +info`:成功读取 `puygob236/gitlink-bootstrap-demo` 仓库信息。
- `branch +list`:成功读取 `master`、`develop`、`release/v0.1` 分支。
- `issue +create`:成功创建初始化 Issue生成项目任务清单。
- `issue +comment`:成功将初始化摘要回写到 Issue。
回写验证命令:
```powershell
.\scripts\run_demo.ps1 -Config examples\verification_comment_config.json -Apply -PublishIssueNumber 4
```
回写验证结果:
```text
模式: apply
计划/执行 gitlink-cli 调用: 3 个
```
命令日志中 3 条调用状态均为 `ok`,无 stderr。

View File

@ -0,0 +1,76 @@
# 工作流说明
## 场景定位
本工作流面向 GitLink 子赛题三“构建端到端自动化工作流”,选择“项目一键初始化”作为应用场景。目标是在新开源项目创建初期,将项目配置、初始化文件、协作分支、初始 Issue 和执行报告统一串联,形成可复现、可审计的启动流程。
该场景覆盖开源项目常见的启动缺口:
- README、License、CI 配置和协作文档不完整。
- 初始任务缺少统一模板Issue 粒度和验收标准不一致。
- 分支、Issue、报告产物分散难以复盘初始化过程。
- 真实写入和演示复现之间缺少安全边界。
## 端到端流程
工作流由 `scripts/bootstrap_project.go` 实现,默认读取 `examples/sample_project.json`,并按以下顺序执行:
1. 解析项目配置,读取项目名称、仓库 owner/name、许可证、初始化分支和初始 Issue。
2. 生成初始化文件包,包括 README、LICENSE、CI 配置、贡献指南和路线图。
3. 规划或执行 `repo +info`,检查目标 GitLink 仓库状态。
4. 规划或执行 `branch +list`,读取分支状态。
5. 规划或执行 `branch +create`,创建协作分支。
6. 规划或执行 `issue +create`,创建初始化任务。
7. 可选执行 `issue +comment`,将初始化摘要回写到指定 Issue。
8. 生成 Markdown 报告、摘要、manifest、文件包和命令日志。
## 串联的 GitLink CLI 能力
默认 dry-run 配置会生成 7 个 `gitlink-cli` 调用计划:
| 顺序 | CLI 能力 | 用途 |
| ---: | --- | --- |
| 1 | `repo +info` | 检查目标仓库信息 |
| 2 | `branch +list` | 读取当前分支列表 |
| 3 | `branch +create` | 创建 `develop` 协作分支 |
| 4 | `branch +create` | 创建 `release/v0.1` 发布分支 |
| 5 | `issue +create` | 创建 README 与快速开始任务 |
| 6 | `issue +create` | 创建 CI 检查任务 |
| 7 | `issue +create` | 创建 v0.1 里程碑任务 |
当传入 `-PublishIssueNumber` 时,会追加 `issue +comment`,用于把初始化摘要发布到指定 GitLink Issue。
## 运行模式
| 模式 | 命令 | 行为 |
| --- | --- | --- |
| dry-run | `.\scripts\run_demo.ps1` | 生成材料和命令计划,不写入 GitLink |
| apply | `.\scripts\run_demo.ps1 -Apply` | 执行真实 GitLink CLI 命令 |
| apply + create repo | `.\scripts\run_demo.ps1 -Apply -CreateRepo` | 先创建仓库,再执行初始化流程 |
| apply + comment | `.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1` | 执行真实命令并回写摘要 |
## 输出产物
运行后会生成以下文件:
| 文件 | 说明 |
| --- | --- |
| `*_bootstrap_report.md` | 初始化报告,展示目标项目、生成文件、分支计划和 Issue 计划 |
| `*_summary.md` | 可发布到 Issue 的初始化摘要 |
| `*_manifest.json` | 结构化初始化清单 |
| `*_files.json` | 生成文件内容包 |
| `command_log_*.json` | gitlink-cli 命令计划或执行结果 |
固定示例输出保存在 `examples/demo_outputs/`,用于评审快速查看产物格式。`outputs/` 是运行时目录,可通过脚本重新生成。
## 工程边界
- 主实现使用 Go便于与 `gitlink-cli` 主仓库技术栈保持一致。
- 默认 dry-run避免演示阶段误写远端仓库。
- 真实写入必须显式传入 `-Apply`
- 命令日志记录每个 CLI 调用的状态,便于复盘和排查。
- 测试覆盖文件生成、CLI 编排、Issue 内容生成、幂等跳过判断和输出 manifest。
## 赛题价值
该工作流不是单个命令封装,而是面向真实开源项目启动流程的组合式方案。它把 `gitlink-cli` 的仓库、分支、Issue 和评论能力整合为一个可复现闭环,符合子赛题三对“串联多个 CLI 命令或 Skill 调用”“真实项目运行展示”“工作流说明文档和架构图”的要求。

View File

@ -0,0 +1,17 @@
# 示例输出
本目录保存 `scripts/bootstrap_project.go` 在 dry-run 模式下生成的固定示例产物,便于快速查看工作流输出格式。
生成命令:
```powershell
go run scripts\bootstrap_project.go --config examples\sample_project.json --output-dir examples\demo_outputs --now 2026-05-24T08:00:00Z
```
产物说明:
- `*_bootstrap_report.md`:项目初始化报告
- `*_summary.md`:可发布到 Issue 的初始化摘要
- `*_manifest.json`:结构化初始化清单
- `*_files.json`:生成文件内容包
- `command_log_*.json`gitlink-cli 命令计划

View File

@ -0,0 +1,144 @@
{
"mode": "dry-run",
"commands": [
{
"command": [
"gitlink-cli",
"repo",
"+info",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
},
{
"command": [
"gitlink-cli",
"branch",
"+list",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
},
{
"command": [
"gitlink-cli",
"branch",
"+create",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--name",
"develop",
"--from",
"master",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
},
{
"command": [
"gitlink-cli",
"branch",
"+create",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--name",
"release/v0.1",
"--from",
"master",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
},
{
"command": [
"gitlink-cli",
"issue",
"+create",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--title",
"完善项目 README 与快速开始文档",
"--body",
"仓库: `puygob236/gitlink-bootstrap-demo`\n\n类型: documentation\n优先级: normal\n\n## 任务清单\n\n- [ ] 补充项目背景和目标用户\n- [ ] 补充安装与运行步骤\n- [ ] 补充最小示例\n\n## 验收标准\n\nREADME 能支撑新贡献者在 10 分钟内完成本地启动。\n",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
},
{
"command": [
"gitlink-cli",
"issue",
"+create",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--title",
"建立基础 CI 检查",
"--body",
"仓库: `puygob236/gitlink-bootstrap-demo`\n\n类型: ci\n优先级: high\n\n## 任务清单\n\n- [ ] 添加测试命令\n- [ ] 添加 lint 或格式检查\n- [ ] 在 PR 中展示检查结果\n\n## 验收标准\n\n每次 push 和 PR 均能触发基础检查。\n",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
},
{
"command": [
"gitlink-cli",
"issue",
"+create",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--title",
"规划 v0.1 版本里程碑",
"--body",
"仓库: `puygob236/gitlink-bootstrap-demo`\n\n类型: release\n优先级: normal\n\n## 任务清单\n\n- [ ] 整理 v0.1 范围\n- [ ] 确定验收标准\n- [ ] 准备 Release Notes 模板\n\n## 验收标准\n\n形成可执行的 v0.1 版本任务列表。\n",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
}
]
}

View File

@ -0,0 +1,41 @@
# GitLink 项目初始化工作流报告
## 目标项目
- 仓库: `puygob236/gitlink-bootstrap-demo`
- 项目名称: Open Research Toolkit
- 描述: A reproducible GitLink project initialized by an end-to-end automation workflow.
- 生成时间: 2026-05-24T08:00:00Z
## 初始化文件
| 文件 | 字节数 |
| --- | ---: |
| `README.md` | 545 |
| `LICENSE` | 179 |
| `.github/workflows/ci.yml` | 232 |
| `docs/CONTRIBUTING.md` | 83 |
| `docs/ROADMAP.md` | 92 |
## 分支计划
| 分支 | 来源 | 保护 |
| --- | --- | --- |
| `develop` | `master` | false |
| `release/v0.1` | `master` | false |
## 初始 Issue 计划
| 序号 | 标题 | 优先级 |
| ---: | --- | --- |
| 1 | 完善项目 README 与快速开始文档 | normal |
| 2 | 建立基础 CI 检查 | high |
| 3 | 规划 v0.1 版本里程碑 | normal |
## 工作流闭环
1. 读取项目配置。
2. 生成 README、LICENSE、CI 和协作文档。
3. 调用 gitlink-cli 检查仓库和分支状态。
4. 调用 gitlink-cli 创建初始化 Issue。
5. 输出报告、摘要和结构化 manifest必要时回写到 GitLink Issue。

View File

@ -0,0 +1,7 @@
{
".github/workflows/ci.yml": "name: Go CI\n\non:\n push:\n pull_request:\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-go@v5\n with:\n go-version: \"1.23\"\n - run: go test ./...\n",
"LICENSE": "# License\n\nThis project is initialized with the `MulanPSL-2.0` license.\n\nThe final repository should keep the complete license text that matches the selected open-source license.\n",
"README.md": "# Open Research Toolkit\n\nA reproducible GitLink project initialized by an end-to-end automation workflow.\n\n## 项目信息\n\n- GitLink 仓库: `puygob236/gitlink-bootstrap-demo`\n- 技术方向: Go\n- 初始化来源: GitLink 项目一键初始化工作流\n\n## 快速开始\n\n```bash\ngit clone https://gitlink.org.cn/puygob236/gitlink-bootstrap-demo.git\ncd gitlink-bootstrap-demo\n```\n\n## 协作约定\n\n- 使用 Issue 跟踪需求、缺陷和文档任务。\n- 使用 Pull Request 合并代码变更。\n- 重要里程碑通过 Release Notes 记录。\n",
"docs/CONTRIBUTING.md": "# 贡献指南\n\n请通过 Issue 讨论需求,通过 Pull Request 提交变更。\n",
"docs/ROADMAP.md": "# Roadmap\n\n- [ ] 完成项目初始化\n- [ ] 建立基础测试\n- [ ] 发布第一个版本\n"
}

View File

@ -0,0 +1,81 @@
{
"repository": "puygob236/gitlink-bootstrap-demo",
"project": {
"name": "Open Research Toolkit",
"description": "A reproducible GitLink project initialized by an end-to-end automation workflow.",
"language": "Go",
"license": "MulanPSL-2.0"
},
"files": [
{
"path": "README.md",
"bytes": 545
},
{
"path": "LICENSE",
"bytes": 179
},
{
"path": ".github/workflows/ci.yml",
"bytes": 232
},
{
"path": "docs/CONTRIBUTING.md",
"bytes": 83
},
{
"path": "docs/ROADMAP.md",
"bytes": 92
}
],
"branches": [
{
"name": "develop",
"from": "master",
"create": true,
"protect": false
},
{
"name": "release/v0.1",
"from": "master",
"create": true,
"protect": false
}
],
"issues": [
{
"title": "完善项目 README 与快速开始文档",
"type": "documentation",
"priority": "normal",
"tasks": [
"补充项目背景和目标用户",
"补充安装与运行步骤",
"补充最小示例"
],
"acceptance": "README 能支撑新贡献者在 10 分钟内完成本地启动。"
},
{
"title": "建立基础 CI 检查",
"type": "ci",
"priority": "high",
"tasks": [
"添加测试命令",
"添加 lint 或格式检查",
"在 PR 中展示检查结果"
],
"acceptance": "每次 push 和 PR 均能触发基础检查。"
},
{
"title": "规划 v0.1 版本里程碑",
"type": "release",
"priority": "normal",
"tasks": [
"整理 v0.1 范围",
"确定验收标准",
"准备 Release Notes 模板"
],
"acceptance": "形成可执行的 v0.1 版本任务列表。"
}
],
"generated_at": "2026-05-24T08:00:00Z"
}

View File

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

View File

@ -0,0 +1,64 @@
{
"project": {
"name": "Open Research Toolkit",
"description": "A reproducible GitLink project initialized by an end-to-end automation workflow.",
"language": "Go",
"license": "MulanPSL-2.0"
},
"repository": {
"owner": "puygob236",
"name": "gitlink-bootstrap-demo"
},
"branches": [
{
"name": "develop",
"from": "master",
"create": true,
"protect": false
},
{
"name": "release/v0.1",
"from": "master",
"create": true,
"protect": false
}
],
"issues": [
{
"title": "完善项目 README 与快速开始文档",
"type": "documentation",
"priority": "normal",
"tasks": [
"补充项目背景和目标用户",
"补充安装与运行步骤",
"补充最小示例"
],
"acceptance": "README 能支撑新贡献者在 10 分钟内完成本地启动。"
},
{
"title": "建立基础 CI 检查",
"type": "ci",
"priority": "high",
"tasks": [
"添加测试命令",
"添加 lint 或格式检查",
"在 PR 中展示检查结果"
],
"acceptance": "每次 push 和 PR 均能触发基础检查。"
},
{
"title": "规划 v0.1 版本里程碑",
"type": "release",
"priority": "normal",
"tasks": [
"整理 v0.1 范围",
"确定验收标准",
"准备 Release Notes 模板"
],
"acceptance": "形成可执行的 v0.1 版本任务列表。"
}
],
"publish": {
"issue_number": 0
}
}

View File

@ -0,0 +1,17 @@
{
"project": {
"name": "Open Research Toolkit",
"description": "A reproducible GitLink project initialized by an end-to-end automation workflow.",
"language": "Go",
"license": "MulanPSL-2.0"
},
"repository": {
"owner": "puygob236",
"name": "gitlink-bootstrap-demo"
},
"branches": [],
"issues": [],
"publish": {
"issue_number": 4
}
}

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