Compare commits

...

90 Commits

Author SHA1 Message Date
wyxfzgg 23a2cb81fd feat(skills): add gitlink-compliance-check(许可证合规检查 Skill)
子任务二「许可证合规检查」场景:扫描仓库许可证合规性与敏感信息泄露风险,输出合规评分与整改建议。
检查维度:License 合规性 / 文档完整性 / CI-CD 完善度 / 敏感信息泄露风险。
遵循 skills 规范(SKILL.md + frontmatter),只读,兼容 Claude Code 等 Agent 平台。
来源:GitLink 大赛 2026 子赛题二 Skill 收录申请。
2026-07-09 22:31:57 +08:00
wbtiger 9749a4c832 Merge pull request 'feat(skills): 新增 编程竞赛管理 的skill : gitlink-competition-manager' (#326) from yangsai/gitlink-cli:feat/gitlink-competition-manager into master 2026-07-05 12:52:05 +08:00
yangsai01 11ecb9b79a feat(skills): add skill gitlink-competition-manager 2026-07-04 15:46:07 +08:00
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
papertager 7d8c33a938 docs: fix skills badge repository link 2026-05-27 21:57:20 +08:00
160 changed files with 21478 additions and 360 deletions

183
README.md
View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/README.md).
**[中文文档](./README.zh-CN.md)**
@ -35,7 +35,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
<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/system/lets/letter_avatars/2/Z/144_206_212/120.png" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/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">
@ -58,11 +58,39 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
<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** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Agent-Native Design** — Structured [Skills](./skills/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
@ -75,19 +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 |
@ -192,6 +223,33 @@ 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"
@ -216,6 +274,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
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
@ -265,6 +345,14 @@ 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"
@ -371,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
@ -413,6 +508,16 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow
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
@ -423,6 +528,29 @@ 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:
@ -522,6 +650,32 @@ Safety:
- `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:
@ -578,17 +732,17 @@ git push gitlink
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.) |
@ -596,6 +750,7 @@ See [skills/README.md](skills/README.md) for details.
| `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
@ -647,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
@ -712,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,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/README.md)。
**[English](./README.md)**
@ -35,7 +35,7 @@
<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/system/lets/letter_avatars/2/Z/144_206_212/120.png" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/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">
@ -58,11 +58,39 @@
<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 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/README.md),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
@ -75,18 +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 |
@ -203,6 +234,33 @@ 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 "项目描述"
@ -227,6 +285,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
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
@ -276,6 +356,14 @@ 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 "已修复"
@ -362,11 +450,18 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved
# 列出 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
```
### 流水线管理
@ -391,6 +486,16 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
```
### 忽略文件模板
```bash
# 列出所有可用的 .gitignore 模板
gitlink-cli ignore +list
# 按名称筛选模板
gitlink-cli ignore +list --name Go
```
### 搜索
```bash
@ -401,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 直接调用:
@ -457,16 +606,16 @@ git push 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-member` | 仓库成员与邀请链接管理 |
| `gitlink-release` | 发布管理(创建、查看、删除等) |
| `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) |
| `gitlink-org` | 组织管理(成员、团队等) |
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
@ -525,7 +674,7 @@ gitlink-cli/
## 文档
- [Skills 使用指南](skills/README.md) — AI Agent Skills 详细说明
- [Skills 使用指南](./skills/README.md) — AI Agent Skills 详细说明
- [设计文档](doc/design.md) — 架构设计和开发计划
## 常见问题
@ -588,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

@ -23,14 +23,16 @@ func NewAPICmd(translators ...*i18n.Translator) *cobra.Command {
tr = translators[0]
}
apiCmd := &cobra.Command{
Use: "api <METHOD> <PATH>",
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":"..."}'
gitlink-cli api POST /:owner/:repo/issues --body-file issue.json`,
Args: cobra.ExactArgs(2),
gitlink-cli api POST /:owner/:repo/issues --body-file issue.json
gitlink-cli api --batch-file plan.json --dry-run
gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=gitlink-cli`,
Args: validateAPIArgs,
RunE: runAPI,
}
@ -39,11 +41,31 @@ func NewAPICmd(translators ...*i18n.Translator) *cobra.Command {
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]

View File

@ -34,7 +34,7 @@ func TestResolveFormat(t *testing.T) {
func TestNewAPICmd(t *testing.T) {
cmd := NewAPICmd()
if cmd.Use != "api <METHOD> <PATH>" {
if cmd.Use != "api (<METHOD> <PATH> | --batch-file <FILE>)" {
t.Fatalf("Use = %q", cmd.Use)
}
if cmd.Short == "" {
@ -42,7 +42,7 @@ func TestNewAPICmd(t *testing.T) {
}
// Verify flags exist
flags := []string{"body", "query", "header"}
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)
@ -201,3 +201,204 @@ func TestRunAPINoPrefix(t *testing.T) {
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

@ -3,6 +3,7 @@ package auth
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/spf13/cobra"
@ -11,6 +12,13 @@ import (
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)
@ -61,7 +69,7 @@ func TestLoginTokenFlag(t *testing.T) {
func TestStatusCmdNotLoggedIn(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
t.Setenv("HOME", t.TempDir())
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
_ = internalAuth.DeleteToken()
@ -76,7 +84,7 @@ func TestStatusCmdNotLoggedIn(t *testing.T) {
func TestStatusCmdEnvToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
t.Setenv("HOME", t.TempDir())
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "env-token-123")
_ = internalAuth.DeleteToken()
@ -86,12 +94,11 @@ func TestStatusCmdEnvToken(t *testing.T) {
func TestStatusCmdStoredToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := t.TempDir()
t.Setenv("HOME", dir)
dir := tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
os.MkdirAll(dir+"/.config/gitlink-cli", 0700)
os.WriteFile(dir+"/.config/gitlink-cli/credentials", []byte("cookie:test=abc"), 0600)
os.MkdirAll(dir, 0700)
os.WriteFile(filepath.Join(dir, "credentials"), []byte("cookie:test=abc"), 0600)
cmd := findSub(NewAuthCmd(), "status")
cmd.RunE(cmd, nil)
@ -99,12 +106,11 @@ func TestStatusCmdStoredToken(t *testing.T) {
func TestStatusCmdEnvAndStoredToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := t.TempDir()
t.Setenv("HOME", dir)
dir := tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "env-token")
os.MkdirAll(dir+"/.config/gitlink-cli", 0700)
os.WriteFile(dir+"/.config/gitlink-cli/credentials", []byte("stored-token"), 0600)
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 {
@ -114,9 +120,9 @@ func TestStatusCmdEnvAndStoredToken(t *testing.T) {
func TestStatusCmdStoredTokenButLoadFails(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
t.Setenv("HOME", t.TempDir())
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
// Don't create credentials file — LoadToken returns empty
// Do not create credentials; LoadToken should return empty.
cmd := findSub(NewAuthCmd(), "status")
if err := cmd.RunE(cmd, nil); err != nil {
@ -124,29 +130,26 @@ func TestStatusCmdStoredTokenButLoadFails(t *testing.T) {
}
}
func TestLogoutCmdError(t *testing.T) {
func TestLogoutCmdNoStoredToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
t.Setenv("HOME", t.TempDir())
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
// Don't create credentials dir — DeleteToken will fail
// Do not create credentials; logout should be idempotent.
cmd := findSub(NewAuthCmd(), "logout")
err := cmd.RunE(cmd, nil)
if err == nil {
t.Fatal("expected error when DeleteToken fails")
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"))
home := t.TempDir()
t.Setenv("HOME", home)
dir := tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
// Store a token first so DeleteToken has something to delete
credDir := home + "/.config/gitlink-cli"
os.MkdirAll(credDir, 0700)
os.WriteFile(credDir+"/credentials", []byte("some-token"), 0600)
os.MkdirAll(dir, 0700)
os.WriteFile(filepath.Join(dir, "credentials"), []byte("some-token"), 0600)
cmd := findSub(NewAuthCmd(), "logout")
if cmd == nil {
@ -159,8 +162,7 @@ func TestLogoutCmd(t *testing.T) {
func TestLoginWithToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
home := t.TempDir()
t.Setenv("HOME", home)
dir := tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
// Mock stdin
@ -185,7 +187,7 @@ func TestLoginWithToken(t *testing.T) {
}
// Verify token was saved to file
data, err := os.ReadFile(home + "/.config/gitlink-cli/credentials")
data, err := os.ReadFile(filepath.Join(dir, "credentials"))
if err != nil {
t.Fatalf("read credentials: %v", err)
}
@ -196,7 +198,7 @@ func TestLoginWithToken(t *testing.T) {
func TestLoginWithTokenEmpty(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
t.Setenv("HOME", t.TempDir())
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
oldStdin := os.Stdin
@ -219,7 +221,7 @@ func TestLoginWithTokenEmpty(t *testing.T) {
func TestLoginWithPasswordNoTerminal(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
t.Setenv("HOME", t.TempDir())
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
// term.ReadPassword will fail because test has no terminal

View File

@ -57,7 +57,7 @@ func TestRootCmdHasSubcommands(t *testing.T) {
for _, sub := range root.Commands() {
names[sub.Use] = true
}
for _, want := range []string{"auth", "config", "version"} {
for _, want := range []string{"auth", "config", "doctor", "version"} {
if !names[want] {
t.Fatalf("missing subcommand: %s", want)
}

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

@ -11,6 +11,7 @@ import (
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"
@ -56,6 +57,7 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
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)

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

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

View File

@ -0,0 +1,478 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
type ProjectConfig struct {
Project ProjectInfo `json:"project"`
Repository RepositoryInfo `json:"repository"`
Branches []BranchPlan `json:"branches"`
Issues []IssuePlan `json:"issues"`
Publish PublishConfig `json:"publish"`
}
type ProjectInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Language string `json:"language"`
License string `json:"license"`
}
type RepositoryInfo struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
type BranchPlan struct {
Name string `json:"name"`
From string `json:"from"`
Create *bool `json:"create"`
Protect bool `json:"protect"`
}
type IssuePlan struct {
Title string `json:"title"`
Type string `json:"type"`
Priority string `json:"priority"`
Tasks []string `json:"tasks"`
Acceptance string `json:"acceptance"`
}
type PublishConfig struct {
IssueNumber int `json:"issue_number"`
}
type FileManifestItem struct {
Path string `json:"path"`
Bytes int `json:"bytes"`
}
type OutputManifest struct {
Repository string `json:"repository"`
Project ProjectInfo `json:"project"`
Files []FileManifestItem `json:"files"`
Branches []BranchPlan `json:"branches"`
Issues []IssuePlan `json:"issues"`
GeneratedAt string `json:"generated_at"`
}
type CommandResult struct {
Command []string `json:"command"`
Status string `json:"status"`
ReturnCode *int `json:"returncode"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
}
type CommandLog struct {
Mode string `json:"mode"`
Commands []CommandResult `json:"commands"`
}
type OutputPaths struct {
Report string
Summary string
Manifest string
Files string
}
type Options struct {
ConfigPath string
OutputDir string
CLIBin string
Apply bool
CreateRepo bool
PublishIssueNumber int
Now string
}
func parseFlags(args []string) Options {
var opts Options
fs := flag.NewFlagSet("bootstrap-project", flag.ExitOnError)
fs.StringVar(&opts.ConfigPath, "config", filepath.FromSlash("examples/sample_project.json"), "配置文件路径")
fs.StringVar(&opts.OutputDir, "output-dir", "outputs", "输出目录")
fs.StringVar(&opts.CLIBin, "cli-bin", firstNonEmpty(os.Getenv("GITLINK_CLI_BIN"), "gitlink-cli"), "gitlink-cli 可执行文件路径")
fs.BoolVar(&opts.Apply, "apply", false, "执行真实 GitLink 写操作")
fs.BoolVar(&opts.CreateRepo, "create-repo", false, "仓库不存在时创建仓库")
fs.IntVar(&opts.PublishIssueNumber, "publish-issue-number", 0, "把初始化摘要评论到指定 Issue")
fs.StringVar(&opts.Now, "now", "", "固定当前时间ISO8601 格式")
_ = fs.Parse(args)
return opts
}
func loadConfig(path string) (ProjectConfig, error) {
var config ProjectConfig
data, err := os.ReadFile(path)
if err != nil {
return config, fmt.Errorf("配置文件不存在: %s", path)
}
data = bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf})
if err := json.Unmarshal(data, &config); err != nil {
return config, err
}
return config, nil
}
func parseNow(value string) (time.Time, error) {
if strings.TrimSpace(value) == "" {
return time.Now().UTC(), nil
}
text := strings.ReplaceAll(strings.TrimSpace(value), "Z", "+00:00")
dt, err := time.Parse(time.RFC3339, text)
if err != nil {
return time.Time{}, err
}
return dt.UTC(), nil
}
func isoTime(value time.Time) string {
return value.UTC().Format(time.RFC3339)
}
func safeName(value string) string {
replacer := strings.NewReplacer("/", "_", "\\", "_", " ", "_")
return replacer.Replace(value)
}
func renderReadme(config ProjectConfig) string {
language := firstNonEmpty(config.Project.Language, "未指定")
return fmt.Sprintf("# %s\n\n%s\n\n## 项目信息\n\n- GitLink 仓库: `%s/%s`\n- 技术方向: %s\n- 初始化来源: GitLink 项目一键初始化工作流\n\n## 快速开始\n\n```bash\ngit clone https://gitlink.org.cn/%s/%s.git\ncd %s\n```\n\n## 协作约定\n\n- 使用 Issue 跟踪需求、缺陷和文档任务。\n- 使用 Pull Request 合并代码变更。\n- 重要里程碑通过 Release Notes 记录。\n",
config.Project.Name,
config.Project.Description,
config.Repository.Owner,
config.Repository.Name,
language,
config.Repository.Owner,
config.Repository.Name,
config.Repository.Name,
)
}
func renderLicense(config ProjectConfig) string {
licenseName := firstNonEmpty(config.Project.License, "MulanPSL-2.0")
return fmt.Sprintf("# License\n\nThis project is initialized with the `%s` license.\n\nThe final repository should keep the complete license text that matches the selected open-source license.\n", licenseName)
}
func renderCI(config ProjectConfig) string {
if strings.Contains(strings.ToLower(config.Project.Language), "go") {
return `name: Go CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
- run: go test ./...
`
}
return `name: Basic CI
on:
push:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Add project-specific checks here."
`
}
func plannedFiles(config ProjectConfig) map[string]string {
return map[string]string{
"README.md": renderReadme(config),
"LICENSE": renderLicense(config),
".github/workflows/ci.yml": renderCI(config),
"docs/CONTRIBUTING.md": "# 贡献指南\n\n请通过 Issue 讨论需求,通过 Pull Request 提交变更。\n",
"docs/ROADMAP.md": "# Roadmap\n\n- [ ] 完成项目初始化\n- [ ] 建立基础测试\n- [ ] 发布第一个版本\n",
}
}
func issueBody(item IssuePlan, config ProjectConfig) string {
repo := fmt.Sprintf("%s/%s", config.Repository.Owner, config.Repository.Name)
tasks := "- [ ] 待补充"
if len(item.Tasks) > 0 {
lines := make([]string, 0, len(item.Tasks))
for _, task := range item.Tasks {
lines = append(lines, "- [ ] "+task)
}
tasks = strings.Join(lines, "\n")
}
return fmt.Sprintf("仓库: `%s`\n\n类型: %s\n优先级: %s\n\n## 任务清单\n\n%s\n\n## 验收标准\n\n%s\n",
repo,
firstNonEmpty(item.Type, "task"),
firstNonEmpty(item.Priority, "normal"),
tasks,
firstNonEmpty(item.Acceptance, "完成后在本 Issue 中说明验证结果。"),
)
}
func shouldCreateBranch(branch BranchPlan) bool {
return branch.Create == nil || *branch.Create
}
func branchFrom(branch BranchPlan) string {
return firstNonEmpty(branch.From, "master")
}
func buildCLIPlan(config ProjectConfig, summary string, createRepo bool) [][]string {
owner := config.Repository.Owner
repo := config.Repository.Name
commands := [][]string{}
if createRepo {
commands = append(commands, []string{"repo", "+create", "--name", repo, "--description", config.Project.Description, "--format", "json"})
}
commands = append(commands,
[]string{"repo", "+info", "--owner", owner, "--repo", repo, "--format", "json"},
[]string{"branch", "+list", "--owner", owner, "--repo", repo, "--format", "json"},
)
for _, branch := range config.Branches {
if shouldCreateBranch(branch) {
commands = append(commands, []string{"branch", "+create", "--owner", owner, "--repo", repo, "--name", branch.Name, "--from", branchFrom(branch), "--format", "json"})
}
if branch.Protect {
commands = append(commands, []string{"branch", "+protect", "--owner", owner, "--repo", repo, "--name", branch.Name, "--format", "json"})
}
}
for _, issue := range config.Issues {
commands = append(commands, []string{"issue", "+create", "--owner", owner, "--repo", repo, "--title", issue.Title, "--body", issueBody(issue, config), "--format", "json"})
}
if summary != "" && config.Publish.IssueNumber > 0 {
commands = append(commands, []string{"issue", "+comment", "--owner", owner, "--repo", repo, "--number", strconv.Itoa(config.Publish.IssueNumber), "--body", summary, "--format", "json"})
}
return commands
}
func runCommand(cliBin string, args []string, apply bool) CommandResult {
command := append([]string{cliBin}, args...)
if !apply {
return CommandResult{Command: command, Status: "planned"}
}
cmd := exec.Command(cliBin, args...)
if strings.HasSuffix(strings.ToLower(cliBin), ".cmd") || strings.HasSuffix(strings.ToLower(cliBin), ".bat") {
cmd = exec.Command("cmd", append([]string{"/c", cliBin}, args...)...)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
returnCode := 0
status := "ok"
if err != nil {
status = "failed"
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
returnCode = exitErr.ExitCode()
} else {
returnCode = 1
}
if isIdempotentSkip(stderr.String()) {
status = "skipped"
}
}
return CommandResult{
Command: command,
Status: status,
ReturnCode: &returnCode,
Stdout: strings.TrimSpace(stdout.String()),
Stderr: strings.TrimSpace(stderr.String()),
}
}
func isIdempotentSkip(stderr string) bool {
knownMessages := []string{
"新分支已存在",
"branch already exists",
"repository already exists",
"仓库已存在",
}
for _, message := range knownMessages {
if strings.Contains(stderr, message) {
return true
}
}
return false
}
func writeOutputs(config ProjectConfig, outputDir string, now time.Time) (OutputPaths, error) {
owner := config.Repository.Owner
repo := config.Repository.Name
prefix := fmt.Sprintf("%s_%s_%s", safeName(owner), safeName(repo), now.UTC().Format("20060102_150405"))
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return OutputPaths{}, err
}
files := plannedFiles(config)
fileManifest := make([]FileManifestItem, 0, len(files))
for _, path := range []string{"README.md", "LICENSE", ".github/workflows/ci.yml", "docs/CONTRIBUTING.md", "docs/ROADMAP.md"} {
if content, ok := files[path]; ok {
fileManifest = append(fileManifest, FileManifestItem{Path: path, Bytes: len([]byte(content))})
}
}
summary := fmt.Sprintf("# GitLink 项目初始化摘要\n\n- 目标仓库: `%s/%s`\n- 项目名称: %s\n- 生成时间: %s\n- 初始化文件: %d 个\n- 初始 Issue: %d 个\n- 分支动作: %d 个\n",
owner,
repo,
config.Project.Name,
isoTime(now),
len(files),
len(config.Issues),
len(config.Branches),
)
report := renderReport(config, fileManifest, now)
manifest := OutputManifest{
Repository: fmt.Sprintf("%s/%s", owner, repo),
Project: config.Project,
Files: fileManifest,
Branches: config.Branches,
Issues: config.Issues,
GeneratedAt: isoTime(now),
}
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return OutputPaths{}, err
}
filesJSON, err := json.MarshalIndent(files, "", " ")
if err != nil {
return OutputPaths{}, err
}
paths := OutputPaths{
Report: filepath.Join(outputDir, prefix+"_bootstrap_report.md"),
Summary: filepath.Join(outputDir, prefix+"_summary.md"),
Manifest: filepath.Join(outputDir, prefix+"_manifest.json"),
Files: filepath.Join(outputDir, prefix+"_files.json"),
}
writes := map[string][]byte{
paths.Report: []byte(report),
paths.Summary: []byte(summary),
paths.Manifest: manifestJSON,
paths.Files: filesJSON,
}
for path, data := range writes {
if err := os.WriteFile(path, data, 0o644); err != nil {
return OutputPaths{}, err
}
}
return paths, nil
}
func renderReport(config ProjectConfig, fileManifest []FileManifestItem, now time.Time) string {
fileRows := []string{}
for _, item := range fileManifest {
fileRows = append(fileRows, fmt.Sprintf("| `%s` | %d |", item.Path, item.Bytes))
}
branchRows := []string{}
for _, item := range config.Branches {
branchRows = append(branchRows, fmt.Sprintf("| `%s` | `%s` | %t |", item.Name, branchFrom(item), item.Protect))
}
if len(branchRows) == 0 {
branchRows = append(branchRows, "| 无 | 无 | false |")
}
issueRows := []string{}
for idx, item := range config.Issues {
issueRows = append(issueRows, fmt.Sprintf("| %d | %s | %s |", idx+1, item.Title, firstNonEmpty(item.Priority, "normal")))
}
if len(issueRows) == 0 {
issueRows = append(issueRows, "| 0 | 无 | normal |")
}
return fmt.Sprintf("# GitLink 项目初始化工作流报告\n\n## 目标项目\n\n- 仓库: `%s/%s`\n- 项目名称: %s\n- 描述: %s\n- 生成时间: %s\n\n## 初始化文件\n\n| 文件 | 字节数 |\n| --- | ---: |\n%s\n\n## 分支计划\n\n| 分支 | 来源 | 保护 |\n| --- | --- | --- |\n%s\n\n## 初始 Issue 计划\n\n| 序号 | 标题 | 优先级 |\n| ---: | --- | --- |\n%s\n\n## 工作流闭环\n\n1. 读取项目配置。\n2. 生成 README、LICENSE、CI 和协作文档。\n3. 调用 gitlink-cli 检查仓库和分支状态。\n4. 调用 gitlink-cli 创建初始化 Issue。\n5. 输出报告、摘要和结构化 manifest必要时回写到 GitLink Issue。\n",
config.Repository.Owner,
config.Repository.Name,
config.Project.Name,
config.Project.Description,
isoTime(now),
strings.Join(fileRows, "\n"),
strings.Join(branchRows, "\n"),
strings.Join(issueRows, "\n"),
)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func main() {
opts := parseFlags(os.Args[1:])
config, err := loadConfig(opts.ConfigPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
now, err := parseNow(opts.Now)
if err != nil {
fmt.Fprintf(os.Stderr, "无法解析 --now 的值: %v\n", err)
os.Exit(1)
}
if opts.PublishIssueNumber > 0 {
config.Publish.IssueNumber = opts.PublishIssueNumber
}
outputPaths, err := writeOutputs(config, opts.OutputDir, now)
if err != nil {
fmt.Fprintf(os.Stderr, "写入输出失败: %v\n", err)
os.Exit(1)
}
summaryBytes, err := os.ReadFile(outputPaths.Summary)
if err != nil {
fmt.Fprintf(os.Stderr, "读取摘要失败: %v\n", err)
os.Exit(1)
}
plan := buildCLIPlan(config, string(summaryBytes), opts.CreateRepo)
results := make([]CommandResult, 0, len(plan))
for _, command := range plan {
results = append(results, runCommand(opts.CLIBin, command, opts.Apply))
}
mode := "dry-run"
if opts.Apply {
mode = "apply"
}
commandLog := CommandLog{Mode: mode, Commands: results}
commandLogJSON, err := json.MarshalIndent(commandLog, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "生成命令日志失败: %v\n", err)
os.Exit(1)
}
commandLogPath := filepath.Join(opts.OutputDir, fmt.Sprintf("command_log_%s.json", now.UTC().Format("20060102_150405")))
if err := os.WriteFile(commandLogPath, commandLogJSON, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "写入命令日志失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("已生成初始化报告: %s\n", outputPaths.Report)
fmt.Printf("已生成初始化摘要: %s\n", outputPaths.Summary)
fmt.Printf("已生成文件清单: %s\n", outputPaths.Manifest)
fmt.Printf("已生成命令日志: %s\n", commandLogPath)
fmt.Printf("模式: %s\n", mode)
fmt.Printf("计划/执行 gitlink-cli 调用: %d 个\n", len(results))
failed := 0
for _, result := range results {
if result.Status == "failed" {
failed++
}
}
if failed > 0 {
fmt.Printf("失败命令: %d 个\n", failed)
os.Exit(1)
}
}

View File

@ -0,0 +1,126 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func sampleConfig() ProjectConfig {
create := true
return ProjectConfig{
Project: ProjectInfo{
Name: "Demo Project",
Description: "Demo description",
Language: "Go",
License: "MulanPSL-2.0",
},
Repository: RepositoryInfo{Owner: "alice", Name: "demo"},
Branches: []BranchPlan{{Name: "develop", From: "master", Create: &create}},
Issues: []IssuePlan{
{
Title: "Write README",
Type: "documentation",
Priority: "normal",
Tasks: []string{"Add quickstart", "Add license"},
},
},
}
}
func TestPlannedFilesIncludeRequiredProjectArtifacts(t *testing.T) {
files := plannedFiles(sampleConfig())
for _, path := range []string{"README.md", "LICENSE", ".github/workflows/ci.yml", "docs/CONTRIBUTING.md"} {
if _, ok := files[path]; !ok {
t.Fatalf("expected planned file %s", path)
}
}
}
func TestBuildCLIPlanChainsMoreThanThreeGitlinkCommands(t *testing.T) {
plan := buildCLIPlan(sampleConfig(), "", false)
if len(plan) < 4 {
t.Fatalf("expected at least 4 commands, got %d", len(plan))
}
if strings.Join(plan[0][:2], " ") != "repo +info" {
t.Fatalf("unexpected first command: %#v", plan[0])
}
if !containsCommand(plan, "branch +list") {
t.Fatalf("branch +list command missing: %#v", plan)
}
if !containsCommand(plan, "issue +create") {
t.Fatalf("issue +create command missing: %#v", plan)
}
}
func TestBuildCLIPlanCanCreateRepositoryFirst(t *testing.T) {
plan := buildCLIPlan(sampleConfig(), "", true)
if strings.Join(plan[0][:2], " ") != "repo +create" {
t.Fatalf("unexpected first command: %#v", plan[0])
}
if strings.Join(plan[1][:2], " ") != "repo +info" {
t.Fatalf("unexpected second command: %#v", plan[1])
}
}
func TestIssueBodyContainsChecklistAndRepository(t *testing.T) {
config := sampleConfig()
body := issueBody(config.Issues[0], config)
if !strings.Contains(body, "`alice/demo`") {
t.Fatalf("repository missing from body: %s", body)
}
if !strings.Contains(body, "- [ ] Add quickstart") {
t.Fatalf("checklist missing from body: %s", body)
}
}
func TestWriteOutputsCreatesReportManifestAndSummary(t *testing.T) {
tmp := t.TempDir()
paths, err := writeOutputs(sampleConfig(), tmp, time.Date(2026, 5, 24, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("writeOutputs returned error: %v", err)
}
for _, path := range []string{paths.Report, paths.Summary, paths.Manifest, paths.Files} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected output %s: %v", path, err)
}
}
data, err := os.ReadFile(paths.Manifest)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
var manifest OutputManifest
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatalf("unmarshal manifest: %v", err)
}
if manifest.Repository != "alice/demo" {
t.Fatalf("unexpected repository: %s", manifest.Repository)
}
if len(manifest.Files) < 4 {
t.Fatalf("expected at least 4 files, got %d", len(manifest.Files))
}
if filepath.Base(paths.Report) == "" {
t.Fatal("report path should include filename")
}
}
func TestBranchAlreadyExistsIsIdempotentSkip(t *testing.T) {
if !isIdempotentSkip("[-1] 新分支已存在!") {
t.Fatal("expected existing branch error to be skipped")
}
if isIdempotentSkip("[401] 请登录后再操作") {
t.Fatal("auth error should not be skipped")
}
}
func containsCommand(plan [][]string, command string) bool {
for _, item := range plan {
if len(item) >= 2 && strings.Join(item[:2], " ") == command {
return true
}
}
return false
}

View File

@ -0,0 +1,38 @@
param(
[string]$Config = "examples/sample_project.json",
[string]$OutputDir = "outputs",
[switch]$Apply,
[switch]$CreateRepo,
[int]$PublishIssueNumber = 0
)
$ErrorActionPreference = "Stop"
$args = @(
"run",
"scripts\bootstrap_project.go",
"--config", $Config,
"--output-dir", $OutputDir
)
if ($Apply.IsPresent) {
$cliCandidates = npm.cmd exec --yes --package=@gitlink-ai/cli -- cmd /c where gitlink-cli 2>$null
$cliPath = $cliCandidates | Where-Object { $_ -match 'gitlink-cli\.cmd$' } | Select-Object -First 1
if (-not $cliPath) {
$cliPath = $cliCandidates | Select-Object -First 1
}
if (-not $cliPath) {
throw "未能通过 npm exec 找到 gitlink-cli"
}
$args += @("--cli-bin", $cliPath, "--apply")
}
if ($CreateRepo.IsPresent) {
$args += "--create-repo"
}
if ($PublishIssueNumber -gt 0) {
$args += @("--publish-issue-number", "$PublishIssueNumber")
}
go @args

11
go.mod
View File

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

51
go.sum
View File

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

View File

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

View File

@ -13,11 +13,19 @@ func tempHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
return dir
}
func tempConfigDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
return dir
}
func TestStoreLoadDeleteTokenFile(t *testing.T) {
tempHome(t)
tempConfigDir(t)
// First, delete any existing token
_ = deleteTokenFile()
@ -55,20 +63,31 @@ func TestStoreLoadDeleteTokenFile(t *testing.T) {
}
func TestCredentialPath(t *testing.T) {
tempHome(t)
dir := tempConfigDir(t)
got := credentialPath()
expected := filepath.Join(os.Getenv("HOME"), ".config", "gitlink-cli", "credentials")
expected := filepath.Join(dir, "credentials")
if got != expected {
t.Fatalf("credentialPath = %q, want %q", got, expected)
}
}
func TestCredentialPathDefaultsToConfigDir(t *testing.T) {
home := tempHome(t)
t.Setenv("GITLINK_CONFIG_DIR", "")
got := credentialPath()
expected := filepath.Join(home, ".config", "gitlink-cli", "credentials")
if got != expected {
t.Fatalf("credentialPath = %q, want %q", got, expected)
}
}
func TestStoreTokenFileCreatesDir(t *testing.T) {
home := tempHome(t)
dir := tempConfigDir(t)
_ = deleteTokenFile()
// Config dir shouldn't exist yet
credDir := filepath.Join(home, ".config", "gitlink-cli")
credDir := dir
os.RemoveAll(credDir)
if err := storeTokenFile("new-token"); err != nil {
@ -86,17 +105,16 @@ func TestStoreTokenFileCreatesDir(t *testing.T) {
}
func TestDeleteTokenFileNonExistent(t *testing.T) {
tempHome(t)
tempConfigDir(t)
_ = deleteTokenFile()
// Deleting non-existent file should return an error from os.Remove
err := deleteTokenFile()
if err == nil {
t.Fatal("expected error deleting non-existent file")
// Logout-style cleanup should be idempotent when fallback credentials do not exist.
if err := deleteTokenFile(); err != nil {
t.Fatalf("deleteTokenFile error: %v", err)
}
}
func TestStoreLoadTokenFileEmpty(t *testing.T) {
tempHome(t)
tempConfigDir(t)
_ = deleteTokenFile()
if err := storeTokenFile(""); err != nil {
@ -114,14 +132,14 @@ func TestStoreLoadTokenFileEmpty(t *testing.T) {
func TestStoreTokenFallback(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
home := tempHome(t)
dir := tempConfigDir(t)
_ = deleteTokenFile()
if err := StoreToken("keychain-fallback-token"); err != nil {
t.Fatalf("StoreToken error: %v", err)
}
data, err := os.ReadFile(filepath.Join(home, ".config", "gitlink-cli", "credentials"))
data, err := os.ReadFile(filepath.Join(dir, "credentials"))
if err != nil {
t.Fatalf("read error: %v", err)
}
@ -132,10 +150,10 @@ func TestStoreTokenFallback(t *testing.T) {
func TestDeleteTokenFallback(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
home := tempHome(t)
dir := tempConfigDir(t)
_ = deleteTokenFile()
p := filepath.Join(home, ".config", "gitlink-cli", "credentials")
p := filepath.Join(dir, "credentials")
os.MkdirAll(filepath.Dir(p), 0700)
os.WriteFile(p, []byte("delete-me"), 0600)

View File

@ -115,22 +115,35 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
}
// Check GitLink error-in-body pattern
// Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."}
var bodyCode float64
var bodyMsg string
if status, ok := raw["status"]; ok {
var statusCode float64
switch v := status.(type) {
case float64:
statusCode = v
bodyCode = v
case int:
statusCode = float64(v)
bodyCode = float64(v)
}
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
msg, _ := raw["message"].(string)
suggestion := suggestFix(int(statusCode))
return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
}
bodyMsg, _ = raw["message"].(string)
} else if code, ok := raw["code"]; ok {
switch v := code.(type) {
case float64:
bodyCode = v
case int:
bodyCode = float64(v)
}
bodyMsg, _ = raw["msg"].(string)
if bodyMsg == "" {
bodyMsg, _ = raw["message"].(string)
}
}
if bodyCode != 0 && bodyCode != 200 && bodyCode != 201 && bodyCode != 204 && bodyCode != 1 {
suggestion := suggestFix(int(bodyCode))
return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{
StatusCode: int(bodyCode),
Code: int(bodyCode),
Message: bodyMsg,
}
}
@ -170,6 +183,10 @@ func shouldAppendJSONSuffix(path string) bool {
return false
}
}
// Wiki open API endpoints do not use .json suffix
if len(parts) >= 3 && parts[0] == "wiki" && parts[1] == "open" {
return false
}
return true
}

View File

@ -169,6 +169,45 @@ func TestClientDoStatusError(t *testing.T) {
}
}
func TestClientDoGatewayCodeError(t *testing.T) {
// Gateway returns {"code":N, "msg":"..."} instead of {"status":N, "message":"..."}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":400,"msg":"Bad Request"}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Do("GET", "/api/test", nil, nil)
if err == nil {
t.Fatal("expected error for code=400")
}
if env == nil {
t.Fatal("expected envelope for code error")
}
if env.OK {
t.Fatal("expected OK=false for code=400")
}
}
func TestClientDoGatewayCode201Success(t *testing.T) {
// Gateway returns code=201 with JSON string data — should be treated as success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":201,"msg":"","data":"{\"title\":\"test\"}"}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Do("POST", "/api/test", map[string]string{"title": "test"}, nil)
if err != nil {
t.Fatalf("unexpected error for code=201: %v", err)
}
if !env.OK {
t.Fatal("expected OK=true for code=201")
}
}
func TestClientDoStatusZero(t *testing.T) {
// status=0, 200, 1 are treated as success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -525,3 +564,18 @@ func TestShouldAppendJSONSuffixSkipsExistingJSONPath(t *testing.T) {
t.Fatal("existing .json path should not get another suffix")
}
}
func TestShouldAppendJSONSuffixSkipsWikiOpenPaths(t *testing.T) {
paths := []string{
"/wiki/open/createWiki",
"/wiki/open/getWiki",
"/wiki/open/updateWiki",
"/wiki/open/deleteWiki",
"/wiki/open/wikiPages",
}
for _, p := range paths {
if shouldAppendJSONSuffix(p) {
t.Errorf("wiki/open path %q should not get .json suffix", p)
}
}
}

View File

@ -8,22 +8,25 @@ import (
)
const (
DefaultBaseURL = "https://www.gitlink.org.cn/api"
DefaultFormat = "table"
DefaultBaseURL = "https://www.gitlink.org.cn/api"
DefaultGatewayURL = "https://gateway.gitlink.org.cn/api"
DefaultFormat = "table"
)
type Config struct {
BaseURL string `yaml:"base_url"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
Lang string `yaml:"lang,omitempty"`
BaseURL string `yaml:"base_url"`
GatewayURL string `yaml:"gateway_url"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
Lang string `yaml:"lang,omitempty"`
}
func DefaultConfig() *Config {
return &Config{
BaseURL: DefaultBaseURL,
Format: DefaultFormat,
BaseURL: DefaultBaseURL,
GatewayURL: DefaultGatewayURL,
Format: DefaultFormat,
}
}
@ -54,6 +57,9 @@ func Load() (*Config, error) {
if cfg.BaseURL == "" {
cfg.BaseURL = DefaultBaseURL
}
if cfg.GatewayURL == "" {
cfg.GatewayURL = DefaultGatewayURL
}
if cfg.Format == "" {
cfg.Format = DefaultFormat
}
@ -80,6 +86,8 @@ func Get(key string) (string, error) {
switch key {
case "base_url":
return cfg.BaseURL, nil
case "gateway_url":
return cfg.GatewayURL, nil
case "default_format":
return cfg.Format, nil
case "editor":
@ -101,6 +109,8 @@ func Set(key, value string) error {
switch key {
case "base_url":
cfg.BaseURL = value
case "gateway_url":
cfg.GatewayURL = value
case "default_format":
cfg.Format = value
case "editor":

View File

@ -44,6 +44,7 @@ func main() {
}
func checkLocaleFormat(fix bool) error {
// #nosec G703 -- pattern is repo-local and not user-controlled.
files, err := filepath.Glob(filepath.Join("internal", "i18n", "locales", "*.json"))
if err != nil {
return err

View File

@ -21,6 +21,19 @@
"cmd.config.list.short": "List all configuration values",
"cmd.config.set.short": "Set a configuration value",
"cmd.config.short": "Manage gitlink-cli configuration",
"cmd.dataset.create.long": "Create the dataset of a repository with a title, description, optional license and research paper content.",
"cmd.dataset.create.short": "Create a repository's dataset",
"cmd.dataset.delete_attachment.long": "Delete a dataset attachment by its UUID. This is destructive: preview with --dry-run, then pass --yes to confirm.",
"cmd.dataset.delete_attachment.short": "Delete a dataset attachment by UUID",
"cmd.dataset.list.long": "List datasets for one or more GitLink projects by their numeric project IDs.",
"cmd.dataset.list.short": "List datasets by project IDs",
"cmd.dataset.short": "Dataset operations",
"cmd.dataset.update.long": "Update the dataset of a repository (title, description, optional license and research paper content).",
"cmd.dataset.update.short": "Update a repository's dataset",
"cmd.dataset.view.long": "View a repository's dataset and its attachments. Use --page/--limit to paginate attachments.",
"cmd.dataset.view.short": "View a repository's dataset",
"cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.",
"cmd.doctor.short": "Diagnose gitlink-cli environment problems",
"cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes",
"cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.",
"cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes",
@ -52,6 +65,17 @@
"cmd.pr.version_diff.short": "Show diff for a pull request patchset version",
"cmd.pr.versions.short": "List pull request patchset versions",
"cmd.pr.view.short": "View pull request details",
"cmd.profile.ability.long": "Show a user's development ability scores (influence, contribution, activity, experience, language) and language breakdown, sourced from the GitLink platform statistics API.",
"cmd.profile.ability.short": "Show a user's development ability scores",
"cmd.profile.activity.long": "Show a user's recent activity statistics: daily issues, pull requests, and commits over a time window.",
"cmd.profile.activity.short": "Show a user's recent activity statistics",
"cmd.profile.contribution.long": "Show a user's contribution heatmap (daily contribution counts) for a given year.",
"cmd.profile.contribution.short": "Show a user's contribution heatmap",
"cmd.profile.major.long": "Show a user's major/discipline categories (e.g. deep learning, quantum computing) inferred from their projects.",
"cmd.profile.major.short": "Show a user's major/discipline categories",
"cmd.profile.role.long": "Show a user's role positioning derived from the GitLink platform statistics API.",
"cmd.profile.role.short": "Show a user's role positioning",
"cmd.profile.short": "User profile and statistics operations",
"cmd.release.create.short": "Create a release",
"cmd.release.delete.short": "Delete a release",
"cmd.release.list.short": "List releases",
@ -63,6 +87,7 @@
"cmd.repo.info.short": "Show repository details",
"cmd.repo.list.short": "List repositories for a user or organization",
"cmd.repo.short": "Repository operations",
"cmd.repo.tree.short": "List repository files and directories",
"cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.",
"cmd.root.short": "GitLink CLI - command-line tool for GitLink",
"cmd.search.repos.short": "Search repositories",
@ -85,8 +110,14 @@
"error.auth.store_token_failed": "failed to store token: {message}",
"error.auth.token_empty": "token cannot be empty",
"error.config.save_failed": "failed to save config: {message}",
"error.dataset.delete_confirm": "dataset attachment deletion is destructive; run --dry-run first, then pass --yes to confirm",
"error.missing_required_flag": "required flag --{name} is missing",
"error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login",
"error.unsupported_language": "unsupported language: {lang}",
"flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure",
"flag.api.batch_dry_run": "Preview batch requests without sending remote requests",
"flag.api.batch_file": "Read an API batch plan from a JSON file",
"flag.api.batch_var": "Override a batch template variable (key=value, repeatable)",
"flag.api.body": "Request body (JSON string)",
"flag.api.body_file": "Read request body JSON from a file",
"flag.api.body_stdin": "Read request body JSON from stdin",
@ -99,12 +130,26 @@
"flag.ci.stage": "Stage number",
"flag.ci.step": "Step number",
"flag.comment.body": "Comment body",
"flag.dataset.description": "Dataset description",
"flag.dataset.dry_run": "Preview the request without writing the dataset",
"flag.dataset.dry_run_delete": "Preview the request without deleting the attachment",
"flag.dataset.ids": "Comma-separated project IDs to query datasets for",
"flag.dataset.license_id": "License ID",
"flag.dataset.limit": "Attachment page size",
"flag.dataset.page": "Attachment page number",
"flag.dataset.paper_content": "Research paper content",
"flag.dataset.title": "Dataset title",
"flag.dataset.uuid": "Attachment UUID",
"flag.dataset.yes": "Confirm the destructive deletion",
"flag.debug": "Enable debug output",
"flag.description": "Description",
"flag.doctor.skip_network": "Skip authenticated API connectivity checks",
"flag.dry_run": "Preview the request without creating it",
"flag.format": "Output format: json, table, yaml (default: table)",
"flag.issue.add_label": "Label to add to each matching issue",
"flag.issue.assignee": "Assignee login",
"flag.issue.assignee_id": "Assignee user ID",
"flag.issue.author_id": "Author user ID",
"flag.issue.batch.reason": "Optional reason shown in the batch result",
"flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.",
"flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7",
@ -121,7 +166,10 @@
"flag.issue.new_title": "New title",
"flag.issue.number": "Issue number (as shown in the web URL)",
"flag.issue.older_than_days": "Only include issues inactive for at least this many days",
"flag.issue.participant": "Participant filter: all, aboutme, authoredme, assignedme, atme",
"flag.issue.state": "Filter by state: open, closed, all",
"flag.issue.status_id": "Issue status ID",
"flag.issue.tag_ids": "Comma-separated issue tag IDs",
"flag.issue.title": "Issue title",
"flag.lang": "Display language",
"flag.limit": "Items per page",
@ -130,19 +178,28 @@
"flag.org.name": "Organization name",
"flag.owner": "Repository owner (auto-detected from git remote)",
"flag.page": "Page number",
"flag.pr.assignee_id": "Assignee user ID",
"flag.pr.base": "Target branch",
"flag.pr.body": "PR description",
"flag.pr.file": "Filter diff by file path",
"flag.pr.head": "Source branch",
"flag.pr.id": "PR number",
"flag.pr.merge_method": "Merge method: merge, rebase, squash",
"flag.pr.milestone_id": "Milestone ID",
"flag.pr.priority_id": "Priority ID",
"flag.pr.review_commit": "Commit SHA to attach the review to",
"flag.pr.review_content": "Review content",
"flag.pr.review_status": "Review status: common, approved, rejected",
"flag.pr.review_status_filter": "Filter review status: common, approved, rejected",
"flag.pr.reviewer_id": "Reviewer user ID",
"flag.pr.state": "Filter: open, merged, closed",
"flag.pr.tag_id": "Issue tag ID",
"flag.pr.title": "PR title",
"flag.pr.version_id": "Patchset version ID",
"flag.profile.end_time": "End time (Unix timestamp)",
"flag.profile.start_time": "Start time (Unix timestamp)",
"flag.profile.user": "Target user login (defaults to the authenticated user)",
"flag.profile.year": "Year for the contribution heatmap (e.g. 2025)",
"flag.release.body": "Release notes",
"flag.release.id": "Release ID",
"flag.release.id_or_tag": "Release ID or tag",
@ -155,7 +212,11 @@
"flag.repo.description": "Repository description",
"flag.repo.name": "Repository name",
"flag.repo.private": "Make repository private (true/false)",
"flag.repo.tree.path": "Directory path to list (default: repository root)",
"flag.repo.tree.ref": "Branch, tag, or commit ref",
"flag.search.keyword": "Search keyword",
"flag.sort_by": "Sort field",
"flag.sort_direction": "Sort direction: asc, desc",
"flag.user": "User login (default: current user)",
"flag.user.login": "User login name",
"flag.webhook.active": "Whether the webhook is active: true or false",
@ -172,6 +233,28 @@
"output.auth.login_hint": " Run: gitlink-cli auth login",
"output.config.file": "Config file: {path}",
"output.config.not_set": "(not set)",
"output.doctor.api_auth.config_skipped": "API authentication check skipped because the configuration file is invalid.",
"output.doctor.api_auth.failed": "Authenticated API request failed: {message}",
"output.doctor.api_auth.no_login": "Authenticated API response did not include a login field.",
"output.doctor.api_auth.ok": "Authenticated API request succeeded as {login}.",
"output.doctor.api_auth.skipped": "Authenticated API connectivity check skipped.",
"output.doctor.auth_token.env": "GITLINK_TOKEN environment variable is set.",
"output.doctor.auth_token.missing": "No stored token or GITLINK_TOKEN environment variable was found.",
"output.doctor.auth_token.stored": "Stored credentials were found.",
"output.doctor.config_file.invalid": "Configuration file exists but cannot be parsed: {message}",
"output.doctor.config_file.missing": "Configuration file was not found; built-in defaults will be used.",
"output.doctor.config_file.ok": "Configuration file is readable.",
"output.doctor.config_file.unreadable": "Configuration file cannot be read: {message}",
"output.doctor.config_values.bad_base_url": "base_url is invalid: {message}",
"output.doctor.config_values.bad_format": "default_format is {format}, expected json, table or yaml.",
"output.doctor.config_values.ok": "Configuration values are valid.",
"output.doctor.config_values.skipped": "Configuration value checks skipped because the configuration file is invalid.",
"output.doctor.repo_context.missing": "Repository context could not be resolved: {message}",
"output.doctor.repo_context.ok": "Repository context resolved to {owner}/{repo}.",
"output.doctor.suggestion.check_config_permissions": "Check file permissions for the gitlink-cli config directory.",
"output.doctor.suggestion.check_token": "Check whether the stored token is valid, or run gitlink-cli auth login again.",
"output.doctor.suggestion.fix_config_yaml": "Fix the YAML syntax in the gitlink-cli config file.",
"output.doctor.suggestion.pass_owner_repo": "Run the command with --owner and --repo when not inside a GitLink repository.",
"output.version": "gitlink-cli {version}",
"prompt.auth.password": "Password: ",
"prompt.auth.token": "Paste your access token: ",

View File

@ -21,6 +21,19 @@
"cmd.config.list.short": "列出所有配置项",
"cmd.config.set.short": "设置配置项",
"cmd.config.short": "管理 gitlink-cli 配置",
"cmd.dataset.create.long": "为仓库创建数据集,包含标题、描述、可选许可证和研究论文内容。",
"cmd.dataset.create.short": "创建仓库数据集",
"cmd.dataset.delete_attachment.long": "按 UUID 删除数据集附件。该操作具有破坏性:先用 --dry-run 预览,再传 --yes 确认。",
"cmd.dataset.delete_attachment.short": "按 UUID 删除数据集附件",
"cmd.dataset.list.long": "按数字项目 ID 列出一个或多个 GitLink 项目的数据集。",
"cmd.dataset.list.short": "按项目 ID 列出数据集",
"cmd.dataset.short": "数据集操作",
"cmd.dataset.update.long": "更新仓库数据集(标题、描述、可选许可证和研究论文内容)。",
"cmd.dataset.update.short": "更新仓库数据集",
"cmd.dataset.view.long": "查看仓库的数据集及其附件。用 --page/--limit 对附件分页。",
"cmd.dataset.view.short": "查看仓库数据集",
"cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。",
"cmd.doctor.short": "诊断 gitlink-cli 环境问题",
"cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes",
"cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run传入 --yes 后执行。",
"cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes",
@ -52,6 +65,17 @@
"cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff",
"cmd.pr.versions.short": "列出拉取请求补丁集版本",
"cmd.pr.view.short": "查看拉取请求详情",
"cmd.profile.ability.long": "显示用户的开发能力评分(影响力、贡献度、活跃度、项目经验、语言能力)及语言分布,数据来自 GitLink 平台统计接口。",
"cmd.profile.ability.short": "显示用户开发能力评分",
"cmd.profile.activity.long": "显示用户近期活动统计:时间范围内每日的疑修、合并请求和提交数量。",
"cmd.profile.activity.short": "显示用户近期活动统计",
"cmd.profile.contribution.long": "显示用户在指定年份的贡献热力图(每日贡献次数)。",
"cmd.profile.contribution.short": "显示用户贡献热力图",
"cmd.profile.major.long": "显示根据用户项目推断的专业/学科领域(如深度学习、量子计算)。",
"cmd.profile.major.short": "显示用户专业/学科定位",
"cmd.profile.role.long": "显示来自 GitLink 平台统计接口的用户角色定位。",
"cmd.profile.role.short": "显示用户角色定位",
"cmd.profile.short": "用户画像与统计操作",
"cmd.release.create.short": "创建发布",
"cmd.release.delete.short": "删除发布",
"cmd.release.list.short": "列出发布",
@ -63,6 +87,7 @@
"cmd.repo.info.short": "显示仓库详情",
"cmd.repo.list.short": "列出用户或组织的仓库",
"cmd.repo.short": "仓库操作",
"cmd.repo.tree.short": "列出仓库文件和目录",
"cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。",
"cmd.root.short": "GitLink CLI - GitLink 命令行工具",
"cmd.search.repos.short": "搜索仓库",
@ -85,8 +110,14 @@
"error.auth.store_token_failed": "保存 Token 失败:{message}",
"error.auth.token_empty": "Token 不能为空",
"error.config.save_failed": "保存配置失败:{message}",
"error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认",
"error.missing_required_flag": "缺少必需参数 --{name}",
"error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录",
"error.unsupported_language": "不支持的语言:{lang}",
"flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求",
"flag.api.batch_dry_run": "预览批处理请求,不发送远端请求",
"flag.api.batch_file": "从 JSON 文件读取 API 批处理计划",
"flag.api.batch_var": "覆盖批处理模板变量key=value可重复",
"flag.api.body": "请求体JSON 字符串)",
"flag.api.body_file": "从文件读取 JSON 请求体",
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
@ -99,12 +130,26 @@
"flag.ci.stage": "阶段编号",
"flag.ci.step": "步骤编号",
"flag.comment.body": "评论内容",
"flag.dataset.description": "数据集描述",
"flag.dataset.dry_run": "预览请求,不写入数据集",
"flag.dataset.dry_run_delete": "预览请求,不删除附件",
"flag.dataset.ids": "用于查询数据集的项目 ID逗号分隔",
"flag.dataset.license_id": "许可证 ID",
"flag.dataset.limit": "附件每页数量",
"flag.dataset.page": "附件页码",
"flag.dataset.paper_content": "研究论文内容",
"flag.dataset.title": "数据集标题",
"flag.dataset.uuid": "附件 UUID",
"flag.dataset.yes": "确认执行破坏性删除",
"flag.debug": "启用调试输出",
"flag.description": "描述",
"flag.doctor.skip_network": "跳过需要访问 GitLink 的认证连通性检查",
"flag.dry_run": "预览请求,不实际创建",
"flag.format": "输出格式json、table、yaml默认table",
"flag.issue.add_label": "要添加到每个匹配议题的标签",
"flag.issue.assignee": "负责人登录名",
"flag.issue.assignee_id": "负责人用户 ID",
"flag.issue.author_id": "作者用户 ID",
"flag.issue.batch.reason": "批量结果中显示的可选原因",
"flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。",
"flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7",
@ -121,7 +166,10 @@
"flag.issue.new_title": "新标题",
"flag.issue.number": "议题编号(网页 URL 中显示的编号)",
"flag.issue.older_than_days": "只包含至少这么多天未活动的议题",
"flag.issue.participant": "参与范围all、aboutme、authoredme、assignedme、atme",
"flag.issue.state": "按状态筛选open、closed、all",
"flag.issue.status_id": "议题状态 ID",
"flag.issue.tag_ids": "逗号分隔的议题标签 ID",
"flag.issue.title": "议题标题",
"flag.lang": "显示语言",
"flag.limit": "每页条目数",
@ -130,19 +178,28 @@
"flag.org.name": "组织名称",
"flag.owner": "仓库所有者(自动从 git remote 检测)",
"flag.page": "页码",
"flag.pr.assignee_id": "指派人用户 ID",
"flag.pr.base": "目标分支",
"flag.pr.body": "PR 描述",
"flag.pr.file": "按文件路径筛选 diff",
"flag.pr.head": "源分支",
"flag.pr.id": "PR 编号",
"flag.pr.merge_method": "合并方式merge、rebase、squash",
"flag.pr.milestone_id": "里程碑 ID",
"flag.pr.priority_id": "优先级 ID",
"flag.pr.review_commit": "关联评审的 Commit SHA",
"flag.pr.review_content": "评审内容",
"flag.pr.review_status": "评审状态common、approved、rejected",
"flag.pr.review_status_filter": "按评审状态筛选common、approved、rejected",
"flag.pr.reviewer_id": "评审人用户 ID",
"flag.pr.state": "筛选open、merged、closed",
"flag.pr.tag_id": "议题标签 ID",
"flag.pr.title": "PR 标题",
"flag.pr.version_id": "补丁集版本 ID",
"flag.profile.end_time": "结束时间Unix 时间戳)",
"flag.profile.start_time": "开始时间Unix 时间戳)",
"flag.profile.user": "目标用户登录名(默认为当前认证用户)",
"flag.profile.year": "贡献热力图的年份(如 2025",
"flag.release.body": "发布说明",
"flag.release.id": "发布 ID",
"flag.release.id_or_tag": "发布 ID 或标签",
@ -155,7 +212,11 @@
"flag.repo.description": "仓库描述",
"flag.repo.name": "仓库名称",
"flag.repo.private": "设为私有仓库true/false",
"flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)",
"flag.repo.tree.ref": "分支、标签或提交引用",
"flag.search.keyword": "搜索关键词",
"flag.sort_by": "排序字段",
"flag.sort_direction": "排序方向asc、desc",
"flag.user": "用户登录名(默认:当前用户)",
"flag.user.login": "用户登录名",
"flag.webhook.active": "Webhook 是否启用true 或 false",
@ -172,6 +233,28 @@
"output.auth.login_hint": " 运行gitlink-cli auth login",
"output.config.file": "配置文件:{path}",
"output.config.not_set": "(未设置)",
"output.doctor.api_auth.config_skipped": "配置文件无效,已跳过 API 认证检查。",
"output.doctor.api_auth.failed": "认证 API 请求失败:{message}",
"output.doctor.api_auth.no_login": "认证 API 响应中缺少 login 字段。",
"output.doctor.api_auth.ok": "认证 API 请求成功,当前用户为 {login}。",
"output.doctor.api_auth.skipped": "已跳过认证 API 连通性检查。",
"output.doctor.auth_token.env": "已设置 GITLINK_TOKEN 环境变量。",
"output.doctor.auth_token.missing": "未找到已保存的 Token也未设置 GITLINK_TOKEN 环境变量。",
"output.doctor.auth_token.stored": "已找到本地保存的认证凭据。",
"output.doctor.config_file.invalid": "配置文件存在,但无法解析:{message}",
"output.doctor.config_file.missing": "未找到配置文件,将使用内置默认值。",
"output.doctor.config_file.ok": "配置文件可读取。",
"output.doctor.config_file.unreadable": "配置文件无法读取:{message}",
"output.doctor.config_values.bad_base_url": "base_url 无效:{message}",
"output.doctor.config_values.bad_format": "default_format 当前为 {format},应为 json、table 或 yaml。",
"output.doctor.config_values.ok": "配置项有效。",
"output.doctor.config_values.skipped": "配置文件无效,已跳过配置项检查。",
"output.doctor.repo_context.missing": "无法解析仓库上下文:{message}",
"output.doctor.repo_context.ok": "仓库上下文已解析为 {owner}/{repo}。",
"output.doctor.suggestion.check_config_permissions": "检查 gitlink-cli 配置目录的文件权限。",
"output.doctor.suggestion.check_token": "检查已保存的 Token 是否有效,或重新运行 gitlink-cli auth login。",
"output.doctor.suggestion.fix_config_yaml": "修复 gitlink-cli 配置文件中的 YAML 语法。",
"output.doctor.suggestion.pass_owner_repo": "不在 GitLink 仓库目录内时,请通过 --owner 和 --repo 指定仓库。",
"output.version": "gitlink-cli {version}",
"prompt.auth.password": "密码:",
"prompt.auth.token": "粘贴你的访问 Token",

View File

@ -7,6 +7,6 @@
"minLength": 1
},
"propertyNames": {
"pattern": "^(cmd|flag|error|output|prompt|success|warning|table)\\.[a-z0-9_.-]+$"
"pattern": "^(cmd|flag|error|prompt|success|warning|confirm|table|output)\\.[a-z0-9_.-]+$"
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@gitlink-ai/cli",
"version": "0.1.13",
"version": "0.2.0",
"description": "GitLink 平台官方命令行工具 — 代码托管、协作开发和自动化",
"bin": {
"gitlink-cli": "bin/cli.js",

View File

@ -0,0 +1,218 @@
// Package dataset implements shortcuts for managing and querying GitLink
// research datasets: per-repository dataset detail/create/update, the
// platform-wide dataset query, and dataset attachment deletion.
//
// Datasets carry research-oriented metadata (title, description, paper_content,
// license, owning project) that is valuable for research/scientometric
// scenarios. Per-repository CRUD wraps /api/v1/{owner}/{repo}/dataset; the
// platform query wraps /api/v1/project_datasets.
package dataset
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Shortcuts returns dataset management and query shortcuts.
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
writeFlags := []common.Flag{
{Name: "title", Short: "t", Usage: tr.T("flag.dataset.title"), Required: true},
{Name: "description", Short: "d", Usage: tr.T("flag.dataset.description"), Required: true},
{Name: "license-id", Usage: tr.T("flag.dataset.license_id")},
{Name: "paper-content", Usage: tr.T("flag.dataset.paper_content")},
{Name: "dry-run", Usage: tr.T("flag.dataset.dry_run"), Bool: true, Default: "false"},
}
return []*common.Shortcut{
{
Name: "view",
Description: tr.T("cmd.dataset.view.short"),
Long: tr.T("cmd.dataset.view.long"),
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: tr.T("flag.dataset.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.dataset.limit"), Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
setIfPresent(q, "page", ctx.Arg("page"))
setIfPresent(q, "limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", repoDatasetPath(ctx), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "list",
Description: tr.T("cmd.dataset.list.short"),
Long: tr.T("cmd.dataset.list.long"),
Flags: []common.Flag{
{Name: "ids", Usage: tr.T("flag.dataset.ids"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
ids, err := ctx.RequireArg("ids")
if err != nil {
return err
}
normalized, err := normalizeIDs(ids)
if err != nil {
return err
}
q := url.Values{}
q.Set("ids", normalized)
env, err := ctx.CallAPIWithQuery("GET", "/v1/project_datasets", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: tr.T("cmd.dataset.create.short"),
Long: tr.T("cmd.dataset.create.long"),
Flags: writeFlags,
Run: runWrite("POST", "create_dataset"),
},
{
Name: "update",
Description: tr.T("cmd.dataset.update.short"),
Long: tr.T("cmd.dataset.update.long"),
Flags: writeFlags,
Run: runWrite("PUT", "update_dataset"),
},
{
Name: "delete-attachment",
Description: tr.T("cmd.dataset.delete_attachment.short"),
Long: tr.T("cmd.dataset.delete_attachment.long"),
Flags: []common.Flag{
{Name: "uuid", Short: "u", Usage: tr.T("flag.dataset.uuid"), Required: true},
{Name: "dry-run", Usage: tr.T("flag.dataset.dry_run_delete"), Bool: true, Default: "false"},
{Name: "yes", Usage: tr.T("flag.dataset.yes"), Bool: true, Default: "false"},
},
Run: runDeleteAttachment,
},
}
}
// runWrite builds the create/update handlers, which share the same request body.
func runWrite(method, action string) func(ctx *common.RuntimeContext) error {
return func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
body, err := datasetBody(ctx)
if err != nil {
return err
}
path := repoDatasetPath(ctx)
if ctx.Arg("dry-run") == "true" {
return ctx.OutputData(map[string]interface{}{
"dry_run": true, "action": action, "method": method, "path": path, "body": body,
})
}
env, err := ctx.CallAPI(method, path, body)
if err != nil {
return err
}
return ctx.Output(env)
}
}
func runDeleteAttachment(ctx *common.RuntimeContext) error {
uuid, err := ctx.RequireArg("uuid")
if err != nil {
return err
}
uuid = strings.TrimSpace(uuid)
path := fmt.Sprintf("/attachments/%s", uuid)
if ctx.Arg("dry-run") == "true" {
return ctx.OutputData(map[string]interface{}{
"dry_run": true, "action": "delete_dataset_attachment", "method": "DELETE", "path": path, "uuid": uuid,
})
}
if ctx.Arg("yes") != "true" {
return fmt.Errorf("%s", ctx.Tr.T("error.dataset.delete_confirm"))
}
env, err := ctx.CallAPI("DELETE", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
}
// datasetBody builds the create/update request body and validates inputs.
func datasetBody(ctx *common.RuntimeContext) (map[string]interface{}, error) {
title, err := ctx.RequireArg("title")
if err != nil {
return nil, err
}
description, err := ctx.RequireArg("description")
if err != nil {
return nil, err
}
body := map[string]interface{}{
"title": strings.TrimSpace(title),
"description": strings.TrimSpace(description),
}
if v := strings.TrimSpace(ctx.Arg("license-id")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return nil, fmt.Errorf("invalid --license-id %q: use a positive integer", v)
}
body["license_id"] = n
}
if v := strings.TrimSpace(ctx.Arg("paper-content")); v != "" {
body["paper_content"] = v
}
return body, nil
}
func repoDatasetPath(ctx *common.RuntimeContext) string {
return "/v1" + ctx.RepoPath() + "/dataset"
}
// normalizeIDs validates a comma-separated list of positive integer project IDs.
func normalizeIDs(raw string) (string, error) {
parts := strings.Split(raw, ",")
cleaned := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.ParseInt(part, 10, 64)
if err != nil || n <= 0 {
return "", fmt.Errorf("invalid --ids %q: use comma-separated positive project IDs", raw)
}
cleaned = append(cleaned, strconv.FormatInt(n, 10))
}
if len(cleaned) == 0 {
return "", fmt.Errorf("invalid --ids %q: provide at least one project ID", raw)
}
return strings.Join(cleaned, ","), nil
}
func setIfPresent(q url.Values, key, value string) {
if v := strings.TrimSpace(value); v != "" {
q.Set(key, v)
}
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}

View File

@ -0,0 +1,228 @@
package dataset
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "alice",
Repo: "demo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func decodeBody(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
data, _ := io.ReadAll(r.Body)
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("decode body: %v (raw: %s)", err, string(data))
}
return m
}
// --- view ---
func TestDatasetView(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/alice/demo/dataset.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.URL.Query().Get("page"); got != "2" {
t.Fatalf("page = %q, want 2", got)
}
if got := r.URL.Query().Get("limit"); got != "5" {
t.Fatalf("limit = %q, want 5", got)
}
writeJSON(w, map[string]interface{}{"id": float64(1), "attachments": []interface{}{}})
}))
defer server.Close()
if err := runShortcut(t, server, "view", map[string]string{"page": "2", "limit": "5"}); err != nil {
t.Fatalf("view failed: %v", err)
}
}
// --- list ---
func TestDatasetListNormalizesIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/project_datasets.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.URL.Query().Get("ids"); got != "1,2,3" {
t.Fatalf("ids = %q, want 1,2,3", got)
}
writeJSON(w, map[string]interface{}{"total_count": float64(0), "project_datasets": []interface{}{}})
}))
defer server.Close()
if err := runShortcut(t, server, "list", map[string]string{"ids": " 1, 2 ,3 "}); err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestDatasetListInvalidIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected for invalid ids")
}))
defer server.Close()
if err := runShortcut(t, server, "list", map[string]string{"ids": "abc"}); err == nil {
t.Fatal("expected error for non-numeric --ids")
}
}
// --- create ---
func TestDatasetCreate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/alice/demo/dataset.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
body := decodeBody(t, r)
if body["title"] != "DS" || body["description"] != "desc" {
t.Fatalf("unexpected body: %v", body)
}
if body["license_id"] != float64(359) {
t.Fatalf("license_id = %v, want 359", body["license_id"])
}
writeJSON(w, map[string]interface{}{"status": float64(0), "message": "success"})
}))
defer server.Close()
args := map[string]string{"title": "DS", "description": "desc", "license-id": "359", "paper-content": "x"}
if err := runShortcut(t, server, "create", args); err != nil {
t.Fatalf("create failed: %v", err)
}
}
func TestDatasetCreateMissingTitle(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected without required flags")
}))
defer server.Close()
if err := runShortcut(t, server, "create", map[string]string{"description": "desc"}); err == nil {
t.Fatal("expected error for missing --title")
}
}
func TestDatasetCreateInvalidLicense(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected for invalid license id")
}))
defer server.Close()
args := map[string]string{"title": "DS", "description": "desc", "license-id": "abc"}
if err := runShortcut(t, server, "create", args); err == nil {
t.Fatal("expected error for invalid --license-id")
}
}
func TestDatasetCreateDryRun(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected in dry-run")
}))
defer server.Close()
args := map[string]string{"title": "DS", "description": "desc", "dry-run": "true"}
if err := runShortcut(t, server, "create", args); err != nil {
t.Fatalf("create dry-run failed: %v", err)
}
}
// --- update ---
func TestDatasetUpdate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/alice/demo/dataset.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodPut {
t.Fatalf("method = %s, want PUT", r.Method)
}
writeJSON(w, map[string]interface{}{"status": float64(0), "message": "success"})
}))
defer server.Close()
args := map[string]string{"title": "DS2", "description": "desc2"}
if err := runShortcut(t, server, "update", args); err != nil {
t.Fatalf("update failed: %v", err)
}
}
// --- delete-attachment ---
func TestDatasetDeleteAttachmentDryRun(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected in dry-run")
}))
defer server.Close()
args := map[string]string{"uuid": "abc-123", "dry-run": "true"}
if err := runShortcut(t, server, "delete-attachment", args); err != nil {
t.Fatalf("delete dry-run failed: %v", err)
}
}
func TestDatasetDeleteAttachmentRequiresConfirm(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected without --yes")
}))
defer server.Close()
if err := runShortcut(t, server, "delete-attachment", map[string]string{"uuid": "abc-123"}); err == nil {
t.Fatal("expected error without --yes confirmation")
}
}
func TestDatasetDeleteAttachmentConfirmed(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/attachments/abc-123.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodDelete {
t.Fatalf("method = %s, want DELETE", r.Method)
}
writeJSON(w, map[string]interface{}{"status": float64(0), "message": "删除成功"})
}))
defer server.Close()
args := map[string]string{"uuid": "abc-123", "yes": "true"}
if err := runShortcut(t, server, "delete-attachment", args); err != nil {
t.Fatalf("delete failed: %v", err)
}
}

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

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

View File

@ -0,0 +1,108 @@
package health
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestFetchPRListPageUsesV1StatusFilter(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("method=%s, want GET", r.Method)
}
if r.URL.Path != "/v1/owner/repo/pulls.json" {
t.Fatalf("path=%s, want /v1/owner/repo/pulls.json", r.URL.Path)
}
if got := r.URL.Query().Get("status"); got != "1" {
t.Fatalf("status=%q, want 1", got)
}
if got := r.URL.Query().Get("state"); got != "" {
t.Fatalf("state should not be sent, got %q", got)
}
writeHealthJSON(t, w, map[string]interface{}{
"total_count": 1,
"pulls": []map[string]interface{}{
{"id": 15414, "index": 109, "status": "merged"},
},
})
}))
defer server.Close()
pulls, err := fetchPRListPage(healthTestContext(server), "merged", 2, 5)
if err != nil {
t.Fatalf("fetchPRListPage failed: %v", err)
}
if len(pulls) != 1 {
t.Fatalf("len(pulls)=%d, want 1", len(pulls))
}
}
func TestFetchIssueListPageUsesCategoryFilter(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("method=%s, want GET", r.Method)
}
if r.URL.Path != "/v1/owner/repo/issues.json" {
t.Fatalf("path=%s, want /v1/owner/repo/issues.json", r.URL.Path)
}
if got := r.URL.Query().Get("category"); got != "closed" {
t.Fatalf("category=%q, want closed", got)
}
if got := r.URL.Query().Get("state"); got != "" {
t.Fatalf("state should not be sent, got %q", got)
}
writeHealthJSON(t, w, map[string]interface{}{
"total_count": 1,
"issues": []map[string]interface{}{
{"id": 140801, "project_issues_index": 1},
},
})
}))
defer server.Close()
issues, err := fetchIssueListPage(healthTestContext(server), "owner", "repo", "closed", 1, 20)
if err != nil {
t.Fatalf("fetchIssueListPage failed: %v", err)
}
if len(issues) != 1 {
t.Fatalf("len(issues)=%d, want 1", len(issues))
}
}
func TestNormalizeListFilters(t *testing.T) {
prCases := map[string]string{"open": "0", "opened": "0", "merged": "1", "closed": "2", "all": ""}
for input, want := range prCases {
if got := normalizePRListStatus(input); got != want {
t.Fatalf("normalizePRListStatus(%q)=%q, want %q", input, got, want)
}
}
issueCases := map[string]string{"open": "opened", "opened": "opened", "closed": "closed", "all": "all"}
for input, want := range issueCases {
if got := normalizeIssueListCategory(input); got != want {
t.Fatalf("normalizeIssueListCategory(%q)=%q, want %q", input, got, want)
}
}
}
func healthTestContext(server *httptest.Server) *common.RuntimeContext {
return &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: map[string]string{},
}
}
func writeHealthJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("write json: %v", err)
}
}

351
shortcuts/health/db.go Normal file
View File

@ -0,0 +1,351 @@
package health
import (
"database/sql"
_ "embed"
"fmt"
"os"
_ "modernc.org/sqlite"
)
//go:embed schema.sql
var schemaSQL string
// extractLogin extracts a login string from either a nested object {"login": "..."} or a flat string field.
func extractLogin(data map[string]interface{}, objKey, flatKey string) string {
if obj, ok := data[objKey].(map[string]interface{}); ok {
if login, _ := obj["login"].(string); login != "" {
return login
}
}
if login, _ := data[flatKey].(string); login != "" {
return login
}
return ""
}
// extractStatusName extracts the status name from either a nested object {"name": "关闭"} or an issue_status string.
func extractStatusName(data map[string]interface{}) string {
if obj, ok := data["status"].(map[string]interface{}); ok {
if name, _ := obj["name"].(string); name != "" {
return name
}
}
if name, _ := data["issue_status"].(string); name != "" {
return name
}
return ""
}
func openDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("set WAL mode: %w", err)
}
if _, err := db.Exec(schemaSQL); err != nil {
db.Close()
return nil, fmt.Errorf("init schema: %w", err)
}
return db, nil
}
func getOrCreateUser(db *sql.DB, username string) (int, error) {
if username == "" {
return 0, nil
}
if _, err := db.Exec("INSERT OR IGNORE INTO users (user_name) VALUES (?)", username); err != nil {
return 0, fmt.Errorf("insert user %q: %w", username, err)
}
var id int
err := db.QueryRow("SELECT id FROM users WHERE user_name = ?", username).Scan(&id)
if err != nil {
return 0, fmt.Errorf("get user %q: %w", username, err)
}
return id, nil
}
func getOrCreateRepo(db *sql.DB, repoName, owner string) (int, error) {
ownerID, err := getOrCreateUser(db, owner)
if err != nil {
return 0, err
}
var id int
err = db.QueryRow("SELECT id FROM repos WHERE repo_name = ? AND owner_id = ?", repoName, ownerID).Scan(&id)
if err == nil {
return id, nil
}
res, err := db.Exec("INSERT INTO repos (repo_name, owner_id) VALUES (?, ?)", repoName, ownerID)
if err != nil {
return 0, fmt.Errorf("insert repo %s/%s: %w", owner, repoName, err)
}
lastID, _ := res.LastInsertId()
return int(lastID), nil
}
func getOrCreateTag(db *sql.DB, repoID int, tagName string) (int, error) {
if _, err := db.Exec("INSERT OR IGNORE INTO tags (repo_id, name) VALUES (?, ?)", repoID, tagName); err != nil {
return 0, fmt.Errorf("insert tag %q for repo %d: %w", tagName, repoID, err)
}
var id int
err := db.QueryRow("SELECT id FROM tags WHERE repo_id = ? AND name = ?", repoID, tagName).Scan(&id)
if err != nil {
return 0, fmt.Errorf("get tag %q for repo %d: %w", tagName, repoID, err)
}
return id, nil
}
func savePullTags(db *sql.DB, pullID int, tagIDs []int) {
if len(tagIDs) == 0 {
return
}
for _, tagID := range tagIDs {
if _, err := db.Exec("INSERT OR IGNORE INTO pull_tags (pull_id, tag_id) VALUES (?, ?)", pullID, tagID); err != nil {
fmt.Fprintf(os.Stderr, " DB error: save pull_tags (pull=%d tag=%d): %v\n", pullID, tagID, err)
}
}
}
func saveIssueTags(db *sql.DB, issueID int, tagIDs []int) {
if len(tagIDs) == 0 {
return
}
for _, tagID := range tagIDs {
if _, err := db.Exec("INSERT OR IGNORE INTO issue_tags (issue_id, tag_id) VALUES (?, ?)", issueID, tagID); err != nil {
fmt.Fprintf(os.Stderr, " DB error: save issue_tags (issue=%d tag=%d): %v\n", issueID, tagID, err)
}
}
}
func extractTagNames(data map[string]interface{}, key string) []string {
rawTags, ok := data[key].([]interface{})
if !ok {
return nil
}
var names []string
for _, item := range rawTags {
tag, ok := item.(map[string]interface{})
if !ok {
continue
}
if name, ok := tag["name"].(string); ok && name != "" {
names = append(names, name)
}
}
return names
}
func nestedMap(data map[string]interface{}, key string) map[string]interface{} {
obj, _ := data[key].(map[string]interface{})
return obj
}
func extractPullNumber(pr map[string]interface{}) int {
for _, key := range []string{"pull_request_number", "index"} {
if v, ok := pr[key].(float64); ok && v > 0 {
return int(v)
}
if v, ok := pr[key].(int); ok && v > 0 {
return v
}
}
return 0
}
func extractPullAuthorLogin(pr map[string]interface{}) string {
if login, _ := pr["author_login"].(string); login != "" {
return login
}
if login := extractLogin(pr, "author", ""); login != "" {
return login
}
if issue := nestedMap(pr, "issue"); issue != nil {
return extractLogin(issue, "author", "")
}
return ""
}
func extractPullAssigneeLogin(pr map[string]interface{}) string {
if login, _ := pr["assign_user_login"].(string); login != "" {
return login
}
if issue := nestedMap(pr, "issue"); issue != nil {
if login := extractLogin(issue, "assign_user", "assign_user_login"); login != "" {
return login
}
}
return ""
}
func extractPullStatus(pr map[string]interface{}) string {
statusMap := map[int]string{0: "open", 1: "merged", 2: "closed"}
if v, ok := pr["pull_request_status"].(float64); ok {
return statusMap[int(v)]
}
if v, ok := pr["pull_request_status"].(int); ok {
return statusMap[v]
}
for _, key := range []string{"pull_request_staus", "status", "state"} {
if status, _ := pr[key].(string); status != "" {
switch status {
case "open", "opened":
return "open"
case "merged":
return "merged"
case "closed", "close":
return "closed"
}
}
}
return "open"
}
func extractPullCreateTime(pr map[string]interface{}) string {
for _, key := range []string{"pr_full_time", "created_at", "create_time"} {
if v, _ := pr[key].(string); v != "" {
return v
}
}
if issue := nestedMap(pr, "issue"); issue != nil {
for _, key := range []string{"created_at", "create_time"} {
if v, _ := issue[key].(string); v != "" {
return v
}
}
}
return ""
}
func extractPullTagNames(pr map[string]interface{}) []string {
if names := extractTagNames(pr, "issue_tags"); len(names) > 0 {
return names
}
if issue := nestedMap(pr, "issue"); issue != nil {
return extractTagNames(issue, "issue_tags")
}
return nil
}
// mergeTimeFromList tries to extract merged_at directly from the list API response
// (preferred, avoids an extra API call per PR).
func mergeTimeFromList(pr map[string]interface{}) string {
for _, key := range []string{"merged_at", "mergedAt", "pr_merge_time", "merge_time"} {
if v, _ := pr[key].(string); v != "" {
return v
}
// Some APIs return numeric timestamps
if v, ok := pr[key].(float64); ok && v > 0 {
return fmt.Sprintf("%.0f", v)
}
}
return ""
}
func savePull(db *sql.DB, repoID int, pr map[string]interface{}, mergedAt string) {
// id: pull_request_id preferred, fallback to id
var prID float64
if v, ok := pr["pull_request_id"].(float64); ok && v > 0 {
prID = v
} else if v, ok := pr["id"].(float64); ok {
prID = v
} else {
return
}
prNumber := extractPullNumber(pr)
if prNumber == 0 {
return
}
createrID, _ := getOrCreateUser(db, extractPullAuthorLogin(pr))
status := extractPullStatus(pr)
if status == "" {
status = "open"
}
createTime := extractPullCreateTime(pr)
var processorID *int
if assignee := extractPullAssigneeLogin(pr); assignee != "" {
pid, _ := getOrCreateUser(db, assignee)
processorID = &pid
}
// Priority: explicit mergedAt arg > list response field > nil
var mergedAtVal interface{} = nil
if mergedAt != "" {
mergedAtVal = mergedAt
} else if ma := mergeTimeFromList(pr); ma != "" {
mergedAtVal = ma
}
if _, err := db.Exec(`INSERT OR REPLACE INTO pulls (id, repo_id, number, creater_id, status, processor_id, create_time, merged_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
int(prID), repoID, prNumber, createrID, status, processorID, createTime, mergedAtVal); err != nil {
fmt.Fprintf(os.Stderr, " DB error: save pull %d: %v\n", int(prID), err)
}
// Save tags
tagNames := extractPullTagNames(pr)
var tagIDs []int
for _, name := range tagNames {
if tid, err := getOrCreateTag(db, repoID, name); err == nil {
tagIDs = append(tagIDs, tid)
}
}
savePullTags(db, int(prID), tagIDs)
}
func saveIssue(db *sql.DB, repoID int, issue map[string]interface{}, issueNumber int, listUpdatedAt string) {
issueID, ok := issue["id"].(float64)
if !ok || issueID == 0 {
return
}
createrID, _ := getOrCreateUser(db, extractLogin(issue, "author", "author_login"))
var processorID *int
if login := extractLogin(issue, "assign_user", "assign_user_login"); login != "" {
pid, _ := getOrCreateUser(db, login)
processorID = &pid
}
createTime, _ := issue["created_at"].(string)
statusName := extractStatusName(issue)
var status string
var closeTime interface{}
if statusName == "关闭" || statusName == "Closed" {
status = "close"
if v, _ := issue["closed_on"].(string); v != "" {
closeTime = v
} else if v, _ := issue["updated_at"].(string); v != "" {
closeTime = v
} else if listUpdatedAt != "" {
closeTime = listUpdatedAt
}
} else {
status = "open"
}
if _, err := db.Exec(`INSERT OR REPLACE INTO issues (id, repo_id, number, creater_id, processor_id, create_time, close_time, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
int(issueID), repoID, issueNumber, createrID, processorID, createTime, closeTime, status); err != nil {
fmt.Fprintf(os.Stderr, " DB error: save issue %d: %v\n", int(issueID), err)
}
// Save tags
tagNames := extractTagNames(issue, "tags")
var tagIDs []int
for _, name := range tagNames {
if tid, err := getOrCreateTag(db, repoID, name); err == nil {
tagIDs = append(tagIDs, tid)
}
}
saveIssueTags(db, int(issueID), tagIDs)
}

View File

@ -0,0 +1,62 @@
package health
import (
"path/filepath"
"testing"
)
func TestSavePullAcceptsV1ListShape(t *testing.T) {
db, err := openDB(filepath.Join(t.TempDir(), "health.db"))
if err != nil {
t.Fatalf("openDB: %v", err)
}
defer db.Close()
repoID, err := getOrCreateRepo(db, "repo", "owner")
if err != nil {
t.Fatalf("getOrCreateRepo: %v", err)
}
savePull(db, repoID, map[string]interface{}{
"id": float64(15414),
"index": float64(109),
"status": "merged",
"issue": map[string]interface{}{
"author": map[string]interface{}{"login": "alice"},
"issue_tags": []interface{}{
map[string]interface{}{"name": "docs"},
},
},
}, "2026-06-05T12:00:00+08:00")
var number int
var status string
var author string
var mergedAt string
if err := db.QueryRow(`
SELECT pulls.number, pulls.status, users.user_name, pulls.merged_at
FROM pulls JOIN users ON users.id = pulls.creater_id
WHERE pulls.id = ?`, 15414).Scan(&number, &status, &author, &mergedAt); err != nil {
t.Fatalf("query saved pull: %v", err)
}
if number != 109 {
t.Fatalf("number=%d, want 109", number)
}
if status != "merged" {
t.Fatalf("status=%q, want merged", status)
}
if author != "alice" {
t.Fatalf("author=%q, want alice", author)
}
if mergedAt == "" {
t.Fatal("merged_at was not saved")
}
var tagCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM pull_tags WHERE pull_id = ?`, 15414).Scan(&tagCount); err != nil {
t.Fatalf("query tags: %v", err)
}
if tagCount != 1 {
t.Fatalf("tagCount=%d, want 1", tagCount)
}
}

226
shortcuts/health/health.go Normal file
View File

@ -0,0 +1,226 @@
package health
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"golang.org/x/sync/errgroup"
"golang.org/x/time/rate"
)
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return []*common.Shortcut{
{
Name: "fetch",
Description: "Fetch PR and Issue data into SQLite for health analysis",
Flags: []common.Flag{
{Name: "db", Short: "d", Usage: "SQLite database path (default: ~/.agents/skills/gitlink-health/data/gitlink_health.db)"},
{Name: "max-pages", Short: "M", Usage: "Maximum pages per query (default: unlimited)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
maxPages := 0
if v := ctx.Arg("max-pages"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
maxPages = n
}
}
dbPath := ctx.Arg("db")
if dbPath == "" {
home, _ := os.UserHomeDir()
dbPath = filepath.Join(home, ".agents", "skills", "gitlink-health", "data", "gitlink_health.db")
}
fmt.Fprintf(os.Stderr, "\nFetching data for %s/%s...\n", ctx.Owner, ctx.Repo)
db, err := openDB(dbPath)
if err != nil {
return err
}
defer db.Close()
repoID, err := getOrCreateRepo(db, ctx.Repo, ctx.Owner)
if err != nil {
return fmt.Errorf("create repo: %w", err)
}
limiter := rate.NewLimiter(rate.Limit(1.0/0.6), 5)
s := &sharedState{
seenPRs: make(map[int]bool),
seenIssues: make(map[int]bool),
}
fmt.Fprintf(os.Stderr, "\n=== Fetching Pull Requests ===\n")
fmt.Fprintf(os.Stderr, "=== Fetching Issues ===\n")
g, egCtx := errgroup.WithContext(context.Background())
for _, state := range []string{"open", "closed", "merged"} {
state := state
g.Go(func() error {
return fetchPRs(ctx, db, repoID, state, maxPages, limiter, egCtx, s)
})
}
for _, state := range []string{"open", "closed"} {
state := state
g.Go(func() error {
return fetchIssues(ctx, db, repoID, state, maxPages, limiter, egCtx, s)
})
}
if err := g.Wait(); err != nil {
return err
}
fmt.Fprintf(os.Stderr, " Total PRs: %d\n", s.prCount)
fmt.Fprintf(os.Stderr, " Total Issues: %d\n", s.issueCount)
fmt.Fprintf(os.Stderr, "\nData saved to %s\n", dbPath)
return nil
},
},
}
}
type sharedState struct {
mu sync.Mutex
seenPRs map[int]bool
seenIssues map[int]bool
prCount int
issueCount int
}
func fetchPRs(ctx *common.RuntimeContext, db *sql.DB, repoID int, state string, maxPages int, limiter *rate.Limiter, egCtx context.Context, s *sharedState) error {
page := 1
for maxPages == 0 || page <= maxPages {
if egCtx.Err() != nil {
return nil
}
if err := limiter.Wait(egCtx); err != nil {
return nil
}
fmt.Fprintf(os.Stderr, " PR list: state=%s, page=%d...\n", state, page)
prs, err := fetchPRListPage(ctx, state, page, 20)
if err != nil {
return err
}
if len(prs) == 0 {
break
}
for _, item := range prs {
pr, ok := item.(map[string]interface{})
if !ok {
continue
}
var prID int
if v, ok := pr["pull_request_id"].(float64); ok && v > 0 {
prID = int(v)
} else if v, ok := pr["id"].(float64); ok {
prID = int(v)
} else {
continue
}
s.mu.Lock()
dup := s.seenPRs[prID]
if !dup {
s.seenPRs[prID] = true
s.prCount++
}
s.mu.Unlock()
if dup {
continue
}
// For merged PRs: try list response first, fall back to detail API
mergedAt := ""
if state == "merged" {
mergedAt = mergeTimeFromList(pr)
if mergedAt == "" {
if prNumFloat, ok := pr["pull_request_number"].(float64); ok && prNumFloat > 0 {
prNum := int(prNumFloat)
if err := limiter.Wait(egCtx); err != nil {
return nil
}
fmt.Fprintf(os.Stderr, " PR detail #%d...\n", prNum)
if ma, err := fetchPRDetail(ctx, prNum); err != nil {
fmt.Fprintf(os.Stderr, " PR detail #%d: %v\n", prNum, err)
} else {
mergedAt = ma
}
}
}
}
savePull(db, repoID, pr, mergedAt)
}
if len(prs) < 20 {
break
}
page++
}
return nil
}
func fetchIssues(ctx *common.RuntimeContext, db *sql.DB, repoID int, state string, maxPages int, limiter *rate.Limiter, egCtx context.Context, s *sharedState) error {
page := 1
for maxPages == 0 || page <= maxPages {
if egCtx.Err() != nil {
return nil
}
if err := limiter.Wait(egCtx); err != nil {
return nil
}
fmt.Fprintf(os.Stderr, " Issue list: state=%s, page=%d...\n", state, page)
issues, err := fetchIssueListPage(ctx, ctx.Owner, ctx.Repo, state, page, 20)
if err != nil {
return err
}
if len(issues) == 0 {
break
}
for _, item := range issues {
issue, ok := item.(map[string]interface{})
if !ok {
continue
}
issueID, ok := issue["id"].(float64)
if !ok {
continue
}
s.mu.Lock()
dup := s.seenIssues[int(issueID)]
if !dup {
s.seenIssues[int(issueID)] = true
s.issueCount++
}
s.mu.Unlock()
if dup {
continue
}
issueNumber := int(issueID)
if v, ok := issue["project_issues_index"].(float64); ok && v > 0 {
issueNumber = int(v)
}
listUpdatedAt, _ := issue["updated_at"].(string)
saveIssue(db, repoID, issue, issueNumber, listUpdatedAt)
}
if len(issues) < 20 {
break
}
page++
}
return nil
}

View File

@ -0,0 +1,84 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS repos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_name TEXT NOT NULL,
owner_id INTEGER NOT NULL,
FOREIGN KEY (owner_id) REFERENCES users(id),
UNIQUE(repo_name, owner_id)
);
CREATE TABLE IF NOT EXISTS issues (
id INTEGER PRIMARY KEY,
repo_id INTEGER NOT NULL,
number INTEGER,
creater_id INTEGER,
processor_id INTEGER,
create_time TIMESTAMP,
close_time TIMESTAMP,
status TEXT CHECK(status IN ('close', 'open')),
FOREIGN KEY (repo_id) REFERENCES repos(id),
FOREIGN KEY (creater_id) REFERENCES users(id),
FOREIGN KEY (processor_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_issues_repo_id ON issues(repo_id);
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
CREATE INDEX IF NOT EXISTS idx_issues_create_time ON issues(create_time);
CREATE INDEX IF NOT EXISTS idx_issues_close_time ON issues(close_time);
CREATE INDEX IF NOT EXISTS idx_issues_creater_id ON issues(creater_id);
CREATE TABLE IF NOT EXISTS pulls (
id INTEGER PRIMARY KEY,
repo_id INTEGER NOT NULL,
number INTEGER NOT NULL,
creater_id INTEGER,
status TEXT CHECK(status IN ('merged', 'closed', 'open')),
processor_id INTEGER,
create_time TIMESTAMP,
merged_at TIMESTAMP,
FOREIGN KEY (repo_id) REFERENCES repos(id),
FOREIGN KEY (creater_id) REFERENCES users(id),
FOREIGN KEY (processor_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_pulls_merged_at ON pulls(merged_at);
CREATE INDEX IF NOT EXISTS idx_pulls_repo_id ON pulls(repo_id);
CREATE INDEX IF NOT EXISTS idx_pulls_status ON pulls(status);
CREATE INDEX IF NOT EXISTS idx_pulls_create_time ON pulls(create_time);
CREATE INDEX IF NOT EXISTS idx_pulls_creater_id ON pulls(creater_id);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (repo_id) REFERENCES repos(id),
UNIQUE(repo_id, name)
);
CREATE INDEX IF NOT EXISTS idx_tags_repo_id ON tags(repo_id);
CREATE TABLE IF NOT EXISTS issue_tags (
issue_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (issue_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_issue_tags_issue_id ON issue_tags(issue_id);
CREATE INDEX IF NOT EXISTS idx_issue_tags_tag_id ON issue_tags(tag_id);
CREATE TABLE IF NOT EXISTS pull_tags (
pull_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
FOREIGN KEY (pull_id) REFERENCES pulls(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (pull_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_pull_tags_pull_id ON pull_tags(pull_id);
CREATE INDEX IF NOT EXISTS idx_pull_tags_tag_id ON pull_tags(tag_id);

View File

@ -0,0 +1,36 @@
package ignore
import (
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Shortcuts returns ignore-file management shortcuts.
//
// These shortcuts provide access to the GitLink ignore-file registry,
// which lists all available .gitignore templates supported by the platform.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List available ignore-file templates",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Filter ignore templates by name"},
},
Run: runList,
},
}
}
func runList(ctx *common.RuntimeContext) error {
q := url.Values{}
if name := ctx.Arg("name"); name != "" {
q.Set("name", name)
}
env, err := ctx.CallAPIWithQuery("GET", "/ignores", q)
if err != nil {
return err
}
return ctx.Output(env)
}

View File

@ -0,0 +1,124 @@
package ignore
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func TestIgnoreList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("got method %s, want GET", r.Method)
}
if r.URL.Path != "/ignores.json" {
t.Fatalf("got path %s, want /ignores.json", r.URL.Path)
}
if got := r.URL.Query().Get("name"); got != "" {
t.Fatalf("expected no name filter, got %q", got)
}
writeJSON(t, w, map[string]interface{}{
"ignores": []interface{}{
map[string]interface{}{"id": 1, "name": "Go"},
map[string]interface{}{"id": 2, "name": "Ada"},
},
})
}))
defer server.Close()
if err := runShortcut(t, server, "list", map[string]string{}); err != nil {
t.Fatalf("ignore list failed: %v", err)
}
}
func TestIgnoreListWithNameFilter(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("got method %s, want GET", r.Method)
}
if r.URL.Path != "/ignores.json" {
t.Fatalf("got path %s, want /ignores.json", r.URL.Path)
}
if got := r.URL.Query().Get("name"); got != "Ada" {
t.Fatalf("expected name=Ada, got %q", got)
}
writeJSON(t, w, map[string]interface{}{
"ignores": []interface{}{
map[string]interface{}{"id": 2, "name": "Ada"},
},
})
}))
defer server.Close()
if err := runShortcut(t, server, "list", map[string]string{"name": "Ada"}); err != nil {
t.Fatalf("ignore list with name filter failed: %v", err)
}
}
func TestIgnoreListHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestIgnoreListEmptyResult(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/ignores.json" {
t.Fatalf("got path %s, want /ignores.json", r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"ignores": []interface{}{},
})
}))
defer server.Close()
if err := runShortcut(t, server, "list", map[string]string{"name": "NONEXISTENT"}); err != nil {
t.Fatalf("ignore list with empty result failed: %v", err)
}
}

View File

@ -210,3 +210,185 @@ func parseBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
}
type batchMaintenanceDryRun struct {
Repository string `json:"repository" yaml:"repository"`
DryRun bool `json:"dry_run" yaml:"dry_run"`
Action string `json:"action" yaml:"action"`
Method string `json:"method" yaml:"method"`
Path string `json:"path" yaml:"path"`
Body map[string]interface{} `json:"body" yaml:"body"`
}
func newBatchUpdateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-update",
Description: "Batch update issue metadata by API issue IDs",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true},
{Name: "status-id", Usage: "Issue status ID"},
{Name: "priority-id", Usage: "Issue priority ID"},
{Name: "milestone-id", Usage: "Issue milestone ID"},
{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"},
{Name: "assigner-ids", Usage: "Comma-separated assignee user IDs"},
{Name: "dry-run", Usage: "Preview request without updating issues", Bool: true, Default: "false"},
},
Run: runBatchUpdate,
}
}
func newBatchDeleteShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-delete",
Description: "Batch delete issues by API issue IDs",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true},
{Name: "dry-run", Usage: "Preview request without deleting issues", Bool: true, Default: "false"},
{Name: "yes", Usage: "Confirm real batch deletion", Bool: true, Default: "false"},
},
Run: runBatchDelete,
}
}
func runBatchUpdate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
body, err := buildBatchUpdateBody(ctx)
if err != nil {
return err
}
path := fmt.Sprintf("%s/issues/batch_update", v1RepoPath(ctx))
if parseBool(ctx.Arg("dry-run")) {
return ctx.OutputData(batchMaintenanceDryRun{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: true,
Action: "batch_update_issues",
Method: "PATCH",
Path: path,
Body: body,
})
}
env, err := ctx.CallAPI("PATCH", path, body)
if err != nil {
return err
}
return ctx.Output(env)
}
func runBatchDelete(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
ids, err := parseIntIDList(ctx.Arg("ids"), "ids")
if err != nil {
return err
}
body := map[string]interface{}{"ids": ids}
path := fmt.Sprintf("%s/issues/batch_destroy", v1RepoPath(ctx))
dryRun := parseBool(ctx.Arg("dry-run"))
if dryRun {
return ctx.OutputData(batchMaintenanceDryRun{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: true,
Action: "batch_delete_issues",
Method: "DELETE",
Path: path,
Body: body,
})
}
if !parseBool(ctx.Arg("yes")) {
return fmt.Errorf("batch-delete is destructive; run with --dry-run first, then pass --yes to confirm")
}
env, err := ctx.CallAPI("DELETE", path, body)
if err != nil {
return err
}
return ctx.Output(env)
}
func buildBatchUpdateBody(ctx *common.RuntimeContext) (map[string]interface{}, error) {
ids, err := parseIntIDList(ctx.Arg("ids"), "ids")
if err != nil {
return nil, err
}
body := map[string]interface{}{"ids": ids}
changed := false
if value := ctx.Arg("status-id"); value != "" {
id, err := parseSingleIntID(value, "status-id")
if err != nil {
return nil, err
}
body["status_id"] = id
changed = true
}
if value := ctx.Arg("priority-id"); value != "" {
id, err := parseSingleIntID(value, "priority-id")
if err != nil {
return nil, err
}
body["priority_id"] = id
changed = true
}
if value := ctx.Arg("milestone-id"); value != "" {
id, err := parseSingleIntID(value, "milestone-id")
if err != nil {
return nil, err
}
body["milestone_id"] = id
changed = true
}
if value := ctx.Arg("tag-ids"); value != "" {
ids, err := parseIntIDList(value, "tag-ids")
if err != nil {
return nil, err
}
body["issue_tag_ids"] = ids
changed = true
}
if value := ctx.Arg("assigner-ids"); value != "" {
ids, err := parseIntIDList(value, "assigner-ids")
if err != nil {
return nil, err
}
body["assigner_ids"] = ids
changed = true
}
if !changed {
return nil, fmt.Errorf("no update fields provided; set at least one of --status-id, --priority-id, --milestone-id, --tag-ids, --assigner-ids")
}
return body, nil
}
func parseSingleIntID(value, field string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, fmt.Errorf("%s cannot be empty", field)
}
id, err := strconv.Atoi(value)
if err != nil || id <= 0 {
return 0, fmt.Errorf("invalid %s %q: must be a positive integer", field, value)
}
return id, nil
}
func parseIntIDList(value, field string) ([]int, error) {
if strings.TrimSpace(value) == "" {
return nil, fmt.Errorf("%s cannot be empty", field)
}
parts := strings.Split(value, ",")
ids := make([]int, 0, len(parts))
seen := map[int]bool{}
for _, part := range parts {
id, err := parseSingleIntID(part, field)
if err != nil {
return nil, err
}
if seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
return ids, nil
}

View File

@ -1,6 +1,7 @@
package issue
import (
"net/http"
"os"
"path/filepath"
"reflect"
@ -213,3 +214,136 @@ func writeTempCSV(t *testing.T, content string) string {
}
return path
}
func TestBatchUpdateDryRun(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runShortcut(t, server, "batch-update", map[string]string{
"ids": "101,102",
"status-id": "3",
"priority-id": "2",
"tag-ids": "7,8",
"assigner-ids": "11",
"dry-run": "true",
})
if err != nil {
t.Fatalf("batch-update dry-run failed: %v", err)
}
}
func TestBatchUpdateCallsAPI(t *testing.T) {
var payload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/batch_update.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
})
defer server.Close()
err := runShortcut(t, server, "batch-update", map[string]string{
"ids": "101,102,101",
"status-id": "3",
"priority-id": "2",
"milestone-id": "9",
"tag-ids": "7,8",
"assigner-ids": "11,12",
})
if err != nil {
t.Fatalf("batch-update failed: %v", err)
}
assertFloatSlice(t, payload["ids"], []float64{101, 102})
assertEqual(t, payload["status_id"], float64(3))
assertEqual(t, payload["priority_id"], float64(2))
assertEqual(t, payload["milestone_id"], float64(9))
assertFloatSlice(t, payload["issue_tag_ids"], []float64{7, 8})
assertFloatSlice(t, payload["assigner_ids"], []float64{11, 12})
}
func TestBatchUpdateRequiresUpdateField(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected API call: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
if err := runShortcut(t, server, "batch-update", map[string]string{"ids": "101"}); err == nil {
t.Fatal("expected error when no update fields are provided")
}
}
func TestBatchUpdateRejectsInvalidIDs(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected API call: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
cases := []map[string]string{
{"ids": "abc", "status-id": "3"},
{"ids": "101", "status-id": "bad"},
{"ids": "101", "tag-ids": "7,,8"},
}
for _, args := range cases {
if err := runShortcut(t, server, "batch-update", args); err == nil {
t.Fatalf("expected validation error for args %#v", args)
}
}
}
func TestBatchDeleteDryRun(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path)
})
defer server.Close()
if err := runShortcut(t, server, "batch-delete", map[string]string{"ids": "101,102", "dry-run": "true"}); err != nil {
t.Fatalf("batch-delete dry-run failed: %v", err)
}
}
func TestBatchDeleteRequiresYes(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected API call without --yes: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
if err := runShortcut(t, server, "batch-delete", map[string]string{"ids": "101"}); err == nil {
t.Fatal("expected --yes confirmation error")
}
}
func TestBatchDeleteCallsAPIWithYes(t *testing.T) {
var payload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/issues/batch_destroy.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
})
defer server.Close()
if err := runShortcut(t, server, "batch-delete", map[string]string{"ids": "101,102,101", "yes": "true"}); err != nil {
t.Fatalf("batch-delete failed: %v", err)
}
assertFloatSlice(t, payload["ids"], []float64{101, 102})
}
func assertFloatSlice(t *testing.T, got interface{}, want []float64) {
t.Helper()
items, ok := got.([]interface{})
if !ok {
t.Fatalf("got %#v, want []interface{}", got)
}
if len(items) != len(want) {
t.Fatalf("got len %d, want %d: %#v", len(items), len(want), got)
}
for i := range want {
if items[i] != want[i] {
t.Fatalf("item %d = %#v, want %#v", i, items[i], want[i])
}
}
}

View File

@ -16,6 +16,19 @@ func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func normalizeIssueListState(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open", "opened":
return "opened"
case "closed":
return "closed"
case "all", "":
return "all"
default:
return state
}
}
type existingIssue struct {
Subject string
Description string
@ -32,11 +45,22 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchUpdateShortcut(),
newBatchDeleteShortcut(),
{
Name: "list",
Description: tr.T("cmd.issue.list.short"),
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"},
{Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword")},
{Name: "participant", Usage: tr.T("flag.issue.participant")},
{Name: "author-id", Usage: tr.T("flag.issue.author_id")},
{Name: "assignee-id", Usage: tr.T("flag.issue.assignee_id")},
{Name: "milestone-id", Usage: tr.T("flag.issue.milestone")},
{Name: "status-id", Usage: tr.T("flag.issue.status_id")},
{Name: "tag-ids", Usage: tr.T("flag.issue.tag_ids")},
{Name: "sort-by", Usage: tr.T("flag.sort_by")},
{Name: "sort-direction", Usage: tr.T("flag.sort_direction")},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
@ -48,7 +72,34 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("state"); s != "" {
q.Set("state", s)
q.Set("category", normalizeIssueListState(s))
}
if keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
if participant := ctx.Arg("participant"); participant != "" {
q.Set("participant_category", participant)
}
if authorID := ctx.Arg("author-id"); authorID != "" {
q.Set("author_id", authorID)
}
if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" {
q.Set("assigner_id", assigneeID)
}
if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" {
q.Set("milestone_id", milestoneID)
}
if statusID := ctx.Arg("status-id"); statusID != "" {
q.Set("status_id", statusID)
}
if tagIDs := ctx.Arg("tag-ids"); tagIDs != "" {
q.Set("issue_tag_ids", tagIDs)
}
if sortBy := ctx.Arg("sort-by"); sortBy != "" {
q.Set("sort_by", sortBy)
}
if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" {
q.Set("sort_direction", sortDirection)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {

View File

@ -104,8 +104,8 @@ func TestIssueList(t *testing.T) {
if r.URL.Path != "/v1/owner/repo/issues.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("state") != "open" {
t.Fatalf("expected state=open, got %s", r.URL.Query().Get("state"))
if r.URL.Query().Get("category") != "opened" {
t.Fatalf("expected category=opened, got %s", r.URL.Query().Get("category"))
}
writeJSON(t, w, []interface{}{
map[string]interface{}{"id": float64(1), "subject": "bug"},
@ -119,6 +119,58 @@ func TestIssueList(t *testing.T) {
}
}
func TestIssueListWithFilters(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/owner/repo/issues.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
query := r.URL.Query()
assertEqual(t, query.Get("category"), "closed")
assertEqual(t, query.Get("keyword"), "release")
assertEqual(t, query.Get("participant_category"), "assignedme")
assertEqual(t, query.Get("author_id"), "10")
assertEqual(t, query.Get("assigner_id"), "11")
assertEqual(t, query.Get("milestone_id"), "12")
assertEqual(t, query.Get("status_id"), "5")
assertEqual(t, query.Get("issue_tag_ids"), "1,2")
assertEqual(t, query.Get("sort_by"), "issues.updated_on")
assertEqual(t, query.Get("sort_direction"), "desc")
writeJSON(t, w, map[string]interface{}{"issues": []interface{}{}})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{
"state": "closed",
"keyword": "release",
"participant": "assignedme",
"author-id": "10",
"assignee-id": "11",
"milestone-id": "12",
"status-id": "5",
"tag-ids": "1,2",
"sort-by": "issues.updated_on",
"sort-direction": "desc",
"page": "2",
"limit": "50",
})
if err != nil {
t.Fatalf("list with filters failed: %v", err)
}
}
func TestIssueListStateAll(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertEqual(t, r.URL.Query().Get("category"), "all")
writeJSON(t, w, map[string]interface{}{"issues": []interface{}{}})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{"state": "all", "page": "1", "limit": "20"})
if err != nil {
t.Fatalf("list all failed: %v", err)
}
}
// --- create ---
func TestIssueCreate(t *testing.T) {

View File

@ -0,0 +1,36 @@
package license
import (
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Shortcuts returns license management shortcuts.
//
// These shortcuts provide access to the GitLink license registry,
// which lists all available open-source licenses supported by the platform.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List available licenses",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Filter licenses by name"},
},
Run: runList,
},
}
}
func runList(ctx *common.RuntimeContext) error {
q := url.Values{}
if name := ctx.Arg("name"); name != "" {
q.Set("name", name)
}
env, err := ctx.CallAPIWithQuery("GET", "/licenses", q)
if err != nil {
return err
}
return ctx.Output(env)
}

View File

@ -0,0 +1,126 @@
package license
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func TestLicenseList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("got method %s, want GET", r.Method)
}
if r.URL.Path != "/licenses.json" {
t.Fatalf("got path %s, want /licenses.json", r.URL.Path)
}
if got := r.URL.Query().Get("name"); got != "" {
t.Fatalf("expected no name filter, got %q", got)
}
writeJSON(t, w, map[string]interface{}{
"licenses": []interface{}{
map[string]interface{}{"id": 5, "name": "AFL-1.1"},
map[string]interface{}{"id": 6, "name": "AFL-1.2"},
map[string]interface{}{"id": 7, "name": "AFL-2.0"},
},
})
}))
defer server.Close()
if err := runShortcut(t, server, "list", map[string]string{}); err != nil {
t.Fatalf("license list failed: %v", err)
}
}
func TestLicenseListWithNameFilter(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("got method %s, want GET", r.Method)
}
if r.URL.Path != "/licenses.json" {
t.Fatalf("got path %s, want /licenses.json", r.URL.Path)
}
if got := r.URL.Query().Get("name"); got != "AFL" {
t.Fatalf("expected name=AFL, got %q", got)
}
writeJSON(t, w, map[string]interface{}{
"licenses": []interface{}{
map[string]interface{}{"id": 5, "name": "AFL-1.1"},
map[string]interface{}{"id": 6, "name": "AFL-1.2"},
},
})
}))
defer server.Close()
if err := runShortcut(t, server, "list", map[string]string{"name": "AFL"}); err != nil {
t.Fatalf("license list with name filter failed: %v", err)
}
}
func TestLicenseListHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestLicenseListEmptyResult(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/licenses.json" {
t.Fatalf("got path %s, want /licenses.json", r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"licenses": []interface{}{},
})
}))
defer server.Close()
if err := runShortcut(t, server, "list", map[string]string{"name": "NONEXISTENT"}); err != nil {
t.Fatalf("license list with empty result failed: %v", err)
}
}

View File

@ -10,6 +10,25 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func normalizePullRequestListState(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open", "opened":
return "0"
case "merged":
return "1"
case "closed":
return "2"
case "all", "":
return ""
default:
return state
}
}
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
@ -18,6 +37,14 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
Description: tr.T("cmd.pr.list.short"),
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: tr.T("flag.pr.state"), Default: "open"},
{Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword")},
{Name: "priority-id", Usage: tr.T("flag.pr.priority_id")},
{Name: "tag-id", Usage: tr.T("flag.pr.tag_id")},
{Name: "milestone-id", Usage: tr.T("flag.pr.milestone_id")},
{Name: "reviewer-id", Usage: tr.T("flag.pr.reviewer_id")},
{Name: "assignee-id", Usage: tr.T("flag.pr.assignee_id")},
{Name: "sort-by", Usage: tr.T("flag.sort_by")},
{Name: "sort-direction", Usage: tr.T("flag.sort_direction")},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
@ -28,10 +55,34 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("state"); s != "" {
q.Set("state", s)
if s := normalizePullRequestListState(ctx.Arg("state")); s != "" {
q.Set("status", s)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
if keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
if priorityID := ctx.Arg("priority-id"); priorityID != "" {
q.Set("priority_id", priorityID)
}
if tagID := ctx.Arg("tag-id"); tagID != "" {
q.Set("issue_tag_id", tagID)
}
if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" {
q.Set("version_id", milestoneID)
}
if reviewerID := ctx.Arg("reviewer-id"); reviewerID != "" {
q.Set("reviewer_id", reviewerID)
}
if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" {
q.Set("assign_user_id", assigneeID)
}
if sortBy := ctx.Arg("sort-by"); sortBy != "" {
q.Set("sort_by", sortBy)
}
if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" {
q.Set("sort_direction", sortDirection)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/pulls", q)
if err != nil {
return err
}
@ -120,8 +171,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
},
},
{
Name: "close",
Description: tr.T("cmd.pr.close.short"),
Name: "refuse",
Description: "Refuse and close a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},

View File

@ -99,11 +99,11 @@ func TestPRList(t *testing.T) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/owner/repo/pulls.json" {
if r.URL.Path != "/v1/owner/repo/pulls.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("state") != "open" {
t.Fatalf("expected state=open, got %s", r.URL.Query().Get("state"))
if r.URL.Query().Get("status") != "0" {
t.Fatalf("expected status=0, got %s", r.URL.Query().Get("status"))
}
if r.URL.Query().Get("page") != "1" {
t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page"))
@ -120,6 +120,61 @@ func TestPRList(t *testing.T) {
}
}
func TestPRListWithFilters(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/owner/repo/pulls.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
query := r.URL.Query()
assertEqual(t, query.Get("status"), "1")
assertEqual(t, query.Get("keyword"), "release")
assertEqual(t, query.Get("priority_id"), "2")
assertEqual(t, query.Get("issue_tag_id"), "3")
assertEqual(t, query.Get("version_id"), "4")
assertEqual(t, query.Get("reviewer_id"), "5")
assertEqual(t, query.Get("assign_user_id"), "6")
assertEqual(t, query.Get("sort_by"), "updated_at")
assertEqual(t, query.Get("sort_direction"), "desc")
writeJSON(t, w, map[string]interface{}{"pulls": []interface{}{}})
}))
defer server.Close()
err := runPRShortcut(t, server, "list", map[string]string{
"state": "merged",
"keyword": "release",
"priority-id": "2",
"tag-id": "3",
"milestone-id": "4",
"reviewer-id": "5",
"assignee-id": "6",
"sort-by": "updated_at",
"sort-direction": "desc",
"page": "2",
"limit": "50",
})
if err != nil {
t.Fatalf("list with filters failed: %v", err)
}
}
func TestPRListStateAllOmitsStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/owner/repo/pulls.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.URL.Query().Get("status"); got != "" {
t.Fatalf("status should be omitted for all, got %q", got)
}
writeJSON(t, w, map[string]interface{}{"pulls": []interface{}{}})
}))
defer server.Close()
err := runPRShortcut(t, server, "list", map[string]string{"state": "all", "page": "1", "limit": "20"})
if err != nil {
t.Fatalf("list all failed: %v", err)
}
}
// --- create ---
func TestPRCreate(t *testing.T) {
@ -232,9 +287,9 @@ func TestPRMergeSquash(t *testing.T) {
assertEqual(t, payload["do"], "squash")
}
// --- close ---
// --- refuse ---
func TestPRClose(t *testing.T) {
func TestPRRefuse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
@ -246,9 +301,9 @@ func TestPRClose(t *testing.T) {
}))
defer server.Close()
err := runPRShortcut(t, server, "close", map[string]string{"id": "42"})
err := runPRShortcut(t, server, "refuse", map[string]string{"id": "42"})
if err != nil {
t.Fatalf("close failed: %v", err)
t.Fatalf("refuse failed: %v", err)
}
}
@ -387,14 +442,14 @@ func TestPRMergeHTTPError(t *testing.T) {
}
}
func TestPRCloseHTTPError(t *testing.T) {
func TestPRRefuseHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runPRShortcut(t, server, "close", map[string]string{"id": "42"})
err := runPRShortcut(t, server, "refuse", map[string]string{"id": "42"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}

View File

@ -0,0 +1,149 @@
// Package profile implements user profile shortcuts that surface GitLink's
// platform statistics (develop ability, role, major, activity, contribution).
// These power "research subject portrait" scenarios for the gitlink-cli skills.
package profile
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Shortcuts returns user profile (research subject portrait) shortcuts.
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
userFlag := common.Flag{Name: "user", Short: "u", Usage: tr.T("flag.profile.user")}
timeFlags := []common.Flag{
{Name: "start-time", Usage: tr.T("flag.profile.start_time")},
{Name: "end-time", Usage: tr.T("flag.profile.end_time")},
}
statFlags := append([]common.Flag{userFlag}, timeFlags...)
return []*common.Shortcut{
{
Name: "ability",
Description: tr.T("cmd.profile.ability.short"),
Long: tr.T("cmd.profile.ability.long"),
Flags: statFlags,
Run: statRun("develop"),
},
{
Name: "role",
Description: tr.T("cmd.profile.role.short"),
Long: tr.T("cmd.profile.role.long"),
Flags: statFlags,
Run: statRun("role"),
},
{
Name: "major",
Description: tr.T("cmd.profile.major.short"),
Long: tr.T("cmd.profile.major.long"),
Flags: statFlags,
Run: statRun("major"),
},
{
Name: "activity",
Description: tr.T("cmd.profile.activity.short"),
Long: tr.T("cmd.profile.activity.long"),
Flags: []common.Flag{userFlag},
Run: func(ctx *common.RuntimeContext) error {
user, err := resolveUser(ctx)
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/activity", user), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "contribution",
Description: tr.T("cmd.profile.contribution.short"),
Long: tr.T("cmd.profile.contribution.long"),
Flags: []common.Flag{
userFlag,
{Name: "year", Usage: tr.T("flag.profile.year")},
},
Run: func(ctx *common.RuntimeContext) error {
user, err := resolveUser(ctx)
if err != nil {
return err
}
q := url.Values{}
if v := ctx.Arg("year"); v != "" {
q.Set("year", v)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/headmaps", user), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
// statRun builds a Run that calls /users/{user}/statistics/{kind} with an
// optional start_time/end_time window (Unix timestamps).
func statRun(kind string) func(ctx *common.RuntimeContext) error {
return func(ctx *common.RuntimeContext) error {
user, err := resolveUser(ctx)
if err != nil {
return err
}
q := url.Values{}
if v := ctx.Arg("start-time"); v != "" {
q.Set("start_time", v)
}
if v := ctx.Arg("end-time"); v != "" {
q.Set("end_time", v)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/statistics/%s", user, kind), q)
if err != nil {
return err
}
return ctx.Output(env)
}
}
// resolveUser returns the target user identifier from the --user flag, falling
// back to the currently authenticated user (/users/me) when the flag is omitted.
func resolveUser(ctx *common.RuntimeContext) (string, error) {
if v := ctx.Arg("user"); v != "" {
return v, nil
}
env, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return "", err
}
if login := extractLogin(env); login != "" {
return login, nil
}
return "", fmt.Errorf("%s", ctx.Tr.T("error.profile.user_required"))
}
// extractLogin pulls the "login" identifier out of a /users/me envelope.
func extractLogin(env *output.Envelope) string {
data, ok := env.Data.(map[string]interface{})
if !ok {
return ""
}
if v, ok := data["login"].(string); ok {
return v
}
return ""
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}

View File

@ -0,0 +1,185 @@
package profile
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
// --- ability / role / major share statRun ---
func TestProfileAbilityExplicitUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/alice/statistics/develop.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{
"user": map[string]interface{}{"contribution": float64(78)},
})
}))
defer server.Close()
if err := runShortcut(t, server, "ability", map[string]string{"user": "alice"}); err != nil {
t.Fatalf("ability failed: %v", err)
}
}
func TestProfileAbilityTimeWindow(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("start_time"); got != "100" {
t.Fatalf("start_time = %q, want 100", got)
}
if got := r.URL.Query().Get("end_time"); got != "200" {
t.Fatalf("end_time = %q, want 200", got)
}
writeJSON(w, map[string]interface{}{"user": map[string]interface{}{}})
}))
defer server.Close()
args := map[string]string{"user": "alice", "start-time": "100", "end-time": "200"}
if err := runShortcut(t, server, "ability", args); err != nil {
t.Fatalf("ability with window failed: %v", err)
}
}
func TestProfileMajor(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/bob/statistics/major.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"categories": []string{"深度学习"}})
}))
defer server.Close()
if err := runShortcut(t, server, "major", map[string]string{"user": "bob"}); err != nil {
t.Fatalf("major failed: %v", err)
}
}
func TestProfileRole(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/bob/statistics/role.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{})
}))
defer server.Close()
if err := runShortcut(t, server, "role", map[string]string{"user": "bob"}); err != nil {
t.Fatalf("role failed: %v", err)
}
}
// --- activity ---
func TestProfileActivity(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/bob/statistics/activity.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"dates": []string{}})
}))
defer server.Close()
if err := runShortcut(t, server, "activity", map[string]string{"user": "bob"}); err != nil {
t.Fatalf("activity failed: %v", err)
}
}
// --- contribution ---
func TestProfileContributionWithYear(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/bob/headmaps.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.URL.Query().Get("year"); got != "2025" {
t.Fatalf("year = %q, want 2025", got)
}
writeJSON(w, map[string]interface{}{"total_contributions": float64(12)})
}))
defer server.Close()
if err := runShortcut(t, server, "contribution", map[string]string{"user": "bob", "year": "2025"}); err != nil {
t.Fatalf("contribution failed: %v", err)
}
}
// --- resolveUser falls back to /users/me ---
func TestProfileDefaultsToCurrentUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/users/me.json":
writeJSON(w, map[string]interface{}{"login": "currentuser"})
case "/users/currentuser/statistics/develop.json":
writeJSON(w, map[string]interface{}{"user": map[string]interface{}{}})
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()
if err := runShortcut(t, server, "ability", nil); err != nil {
t.Fatalf("ability (default user) failed: %v", err)
}
}
func TestProfileDefaultUserMissingLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/me.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"name": "no login here"})
}))
defer server.Close()
if err := runShortcut(t, server, "ability", nil); err == nil {
t.Fatal("expected error when /users/me has no login")
}
}
// --- HTTP error path ---
func TestProfileHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("server error"))
}))
defer server.Close()
if err := runShortcut(t, server, "major", map[string]string{"user": "bob"}); err == nil {
t.Fatal("expected error for HTTP 500")
}
}

View File

@ -8,18 +8,24 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/compare"
"github.com/gitlink-org/gitlink-cli/shortcuts/dataset"
"github.com/gitlink-org/gitlink-cli/shortcuts/health"
"github.com/gitlink-org/gitlink-cli/shortcuts/ignore"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/license"
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
"github.com/gitlink-org/gitlink-cli/shortcuts/profile"
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
@ -33,10 +39,12 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"license": license.Shortcuts(),
"member": member.Shortcuts(),
"milestone": milestone.Shortcuts(),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr),
"profile": profile.Shortcuts(tr),
"release": release.Shortcuts(tr),
"branch": branch.Shortcuts(tr),
"org": org.Shortcuts(tr),
@ -44,7 +52,11 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"search": search.Shortcuts(tr),
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"dataset": dataset.Shortcuts(tr),
"webhook": webhook.Shortcuts(tr),
"wiki": wiki.Shortcuts(),
"health": health.Shortcuts(tr),
"ignore": ignore.Shortcuts(),
"workflow": workflow.Shortcuts(),
}
@ -52,10 +64,12 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"license": "License operations",
"member": "Repository member operations",
"milestone": "Milestone operations",
"pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"),
"profile": tr.T("cmd.profile.short"),
"release": tr.T("cmd.release.short"),
"branch": tr.T("cmd.branch.short"),
"org": tr.T("cmd.org.short"),
@ -63,7 +77,11 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"search": tr.T("cmd.search.short"),
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"dataset": tr.T("cmd.dataset.short"),
"webhook": tr.T("cmd.webhook.short"),
"wiki": "Wiki page management",
"health": "Project health data collection",
"ignore": tr.T("cmd.ignore.short"),
"workflow": "AI agent workflow analysis",
}

View File

@ -11,9 +11,10 @@ func TestRegisterAll(t *testing.T) {
RegisterAll(root)
expectedGroups := []string{
"repo", "issue", "label", "pr", "release", "branch",
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"dataset", "health", "ignore", "wiki",
}
groupSet := map[string]bool{}

View File

@ -3,6 +3,8 @@ package release
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
@ -42,16 +44,34 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
{Name: "body", Short: "b", Usage: tr.T("flag.release.body")},
{Name: "target", Usage: tr.T("flag.release.target"), Default: "master"},
{Name: "prerelease", Usage: tr.T("flag.release.prerelease"), Default: "false"},
{Name: "draft", Usage: "Mark as draft (true/false)", Default: "false"},
{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
tag, _ := ctx.RequireArg("tag")
name, _ := ctx.RequireArg("name")
tag, err := ctx.RequireArg("tag")
if err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
draft, err := releaseBoolArg(ctx, "draft", false)
if err != nil {
return err
}
prerelease, err := releaseBoolArg(ctx, "prerelease", false)
if err != nil {
return err
}
payload := map[string]interface{}{
"tag_name": tag,
"name": name,
"tag_name": tag,
"name": name,
"draft": draft,
"prerelease": prerelease,
}
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
@ -59,8 +79,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if t := ctx.Arg("target"); t != "" {
payload["target_commitish"] = t
}
if ctx.Arg("prerelease") == "true" {
payload["prerelease"] = true
if attachmentIDs := ctx.Arg("attachment-ids"); attachmentIDs != "" {
ids, err := parseReleaseAttachmentIDs(attachmentIDs)
if err != nil {
return err
}
payload["attachment_ids"] = ids
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload)
if err != nil {
@ -69,6 +93,27 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "edit",
Description: "Get release edit data",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release version ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s/edit", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: tr.T("cmd.release.view.short"),
@ -79,7 +124,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if err != nil {
return err
@ -87,22 +135,52 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a release while preserving unspecified fields",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release version ID", Required: true},
{Name: "tag", Short: "t", Usage: "Tag name"},
{Name: "name", Short: "n", Usage: "Release name"},
{Name: "body", Short: "b", Usage: "Release notes"},
{Name: "target", Usage: "Target branch"},
{Name: "prerelease", Usage: "Mark as prerelease (true/false)"},
{Name: "draft", Usage: "Mark as draft (true/false)"},
{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"},
{Name: "dry-run", Usage: "Preview the update request without changing release state", Bool: true, Default: "false"},
},
Run: runUpdate,
},
{
Name: "delete",
Description: tr.T("cmd.release.delete.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: tr.T("flag.release.id"), Required: true},
{Name: "dry-run", Usage: "Preview the delete request without changing release state", Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
path := fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id)
if ctx.Arg("dry-run") == "true" {
return ctx.OutputData(map[string]interface{}{
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"dry_run": true,
"action": "delete_release",
"method": "DELETE",
"path": path,
})
}
_, delErr := ctx.CallAPI("DELETE", path, nil)
if delErr != nil {
// GitLink API bug: delete succeeds but returns error status.
// Verify by checking if the release still exists.
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
_, viewErr := ctx.CallAPI("GET", path, nil)
if viewErr != nil {
// Release no longer exists — delete actually succeeded
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
@ -126,3 +204,223 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
}
return i18n.Default()
}
func runUpdate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
if !hasReleaseUpdateArgs(ctx) {
return fmt.Errorf("at least one of --tag, --name, --body, --target, --draft, --prerelease, or --attachment-ids is required")
}
if err := validateReleaseUpdateArgs(ctx); err != nil {
return err
}
current, err := fetchReleaseEdit(ctx, id)
if err != nil {
return fmt.Errorf("fetch release edit data: %w", err)
}
payload, err := releaseUpdatePayload(ctx, current)
if err != nil {
return err
}
path := fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id)
if ctx.Arg("dry-run") == "true" {
return ctx.OutputData(map[string]interface{}{
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"dry_run": true,
"action": "update_release",
"method": "PUT",
"path": path,
"payload": payload,
})
}
env, err := ctx.CallAPI("PUT", path, payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func fetchReleaseEdit(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) {
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s/edit", ctx.RepoPath(), id), nil)
if err != nil {
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("failed to parse release edit data")
}
return data, nil
}
func releaseUpdatePayload(ctx *common.RuntimeContext, current map[string]interface{}) (map[string]interface{}, error) {
name := firstReleaseValue(ctx.Arg("name"), releaseString(current, "name"))
if name == "" {
return nil, fmt.Errorf("required release name is missing; pass --name")
}
tag := firstReleaseValue(ctx.Arg("tag"), releaseString(current, "tag_name"))
if tag == "" {
return nil, fmt.Errorf("required release tag is missing; pass --tag")
}
body := firstReleaseValue(ctx.Arg("body"), releaseString(current, "body"))
target := firstReleaseValue(ctx.Arg("target"), releaseString(current, "target_commitish"))
draft, err := releaseBoolFromArgsOrMap(ctx, "draft", current, false)
if err != nil {
return nil, err
}
prerelease, err := releaseBoolFromArgsOrMap(ctx, "prerelease", current, false)
if err != nil {
return nil, err
}
payload := map[string]interface{}{
"name": name,
"tag_name": tag,
"body": body,
"target_commitish": target,
"draft": draft,
"prerelease": prerelease,
}
if attachmentIDs := ctx.Arg("attachment-ids"); attachmentIDs != "" {
ids, err := parseReleaseAttachmentIDs(attachmentIDs)
if err != nil {
return nil, err
}
payload["attachment_ids"] = ids
} else if ids := releaseAttachmentIDs(current); len(ids) > 0 {
payload["attachment_ids"] = ids
}
return payload, nil
}
func hasReleaseUpdateArgs(ctx *common.RuntimeContext) bool {
for _, name := range []string{"tag", "name", "body", "target", "draft", "prerelease", "attachment-ids"} {
if ctx.Arg(name) != "" {
return true
}
}
return false
}
func validateReleaseUpdateArgs(ctx *common.RuntimeContext) error {
for _, name := range []string{"draft", "prerelease"} {
if ctx.Arg(name) == "" {
continue
}
if _, err := releaseBoolArg(ctx, name, false); err != nil {
return err
}
}
if ctx.Arg("attachment-ids") != "" {
_, err := parseReleaseAttachmentIDs(ctx.Arg("attachment-ids"))
return err
}
return nil
}
func releaseBoolArg(ctx *common.RuntimeContext, name string, defaultValue bool) (bool, error) {
value := strings.TrimSpace(ctx.Arg(name))
if value == "" {
return defaultValue, nil
}
switch strings.ToLower(value) {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, fmt.Errorf("invalid --%s value %q: use true or false", name, value)
}
}
func releaseBoolFromArgsOrMap(ctx *common.RuntimeContext, name string, current map[string]interface{}, defaultValue bool) (bool, error) {
if ctx.Arg(name) != "" {
return releaseBoolArg(ctx, name, defaultValue)
}
if current != nil {
if value, ok := current[name].(bool); ok {
return value, nil
}
}
return defaultValue, nil
}
func parseReleaseAttachmentIDs(value string) ([]string, error) {
parts := strings.Split(value, ",")
ids := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, part := range parts {
id := strings.TrimSpace(part)
if id == "" {
continue
}
if seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, fmt.Errorf("--attachment-ids must include at least one ID")
}
return ids, nil
}
func releaseAttachmentIDs(current map[string]interface{}) []string {
if current == nil {
return nil
}
attachments, ok := current["attachments"].([]interface{})
if !ok {
return nil
}
ids := make([]string, 0, len(attachments))
for _, attachment := range attachments {
item, ok := attachment.(map[string]interface{})
if !ok {
continue
}
if id := releaseIDString(item["id"]); id != "" {
ids = append(ids, id)
}
}
return ids
}
func releaseIDString(value interface{}) string {
switch id := value.(type) {
case string:
return strings.TrimSpace(id)
case float64:
if id == float64(int64(id)) {
return strconv.FormatInt(int64(id), 10)
}
return fmt.Sprintf("%v", id)
case int:
return strconv.Itoa(id)
default:
return ""
}
}
func releaseString(values map[string]interface{}, key string) string {
if values == nil {
return ""
}
value, _ := values[key].(string)
return strings.TrimSpace(value)
}
func firstReleaseValue(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@ -2,91 +2,67 @@ package release
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
shortcuts := Shortcuts()
for _, s := range shortcuts {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// --- list ---
func TestReleaseList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo/releases.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, []interface{}{
map[string]interface{}{"tag_name": "v1.0"},
map[string]interface{}{"tag_name": "v1.1"},
})
}))
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertReleaseRequest(t, r, "GET", "/owner/repo/releases.json")
writeReleaseJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{{"tag_name": "v1.0"}}})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"})
if err != nil {
if err := runReleaseShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}); err != nil {
t.Fatalf("list failed: %v", err)
}
}
// --- create ---
func TestReleaseCreate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo/releases.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"tag_name": "v2.0"})
}))
func TestReleaseCreatePayload(t *testing.T) {
var payload map[string]interface{}
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertReleaseRequest(t, r, "POST", "/owner/repo/releases.json")
payload = decodeReleaseJSON(t, r)
writeReleaseJSON(t, w, map[string]interface{}{"status": 0, "message": "created"})
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{
"tag": "v2.0",
"name": "Version 2.0",
err := runReleaseShortcut(t, server, "create", map[string]string{
"tag": "v1.0.0",
"name": "v1.0.0",
"body": "Release notes",
"target": "main",
"draft": "true",
"prerelease": "true",
"attachment-ids": "12,34,12",
})
if err != nil {
t.Fatalf("create failed: %v", err)
t.Fatalf("create shortcut failed: %v", err)
}
assertReleaseEqual(t, payload["tag_name"], "v1.0.0")
assertReleaseEqual(t, payload["name"], "v1.0.0")
assertReleaseEqual(t, payload["body"], "Release notes")
assertReleaseEqual(t, payload["target_commitish"], "main")
assertReleaseEqual(t, payload["draft"], true)
assertReleaseEqual(t, payload["prerelease"], true)
assertReleaseStringSlice(t, payload["attachment_ids"], []string{"12", "34"})
}
func TestReleaseCreateWithBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"tag_name": "v2.1"})
}))
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertReleaseRequest(t, r, "POST", "/owner/repo/releases.json")
writeReleaseJSON(t, w, map[string]interface{}{"tag_name": "v2.1"})
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{
err := runReleaseShortcut(t, server, "create", map[string]string{
"tag": "v2.1",
"name": "Version 2.1",
"body": "Release notes here",
@ -98,126 +74,382 @@ func TestReleaseCreateWithBody(t *testing.T) {
}
}
// --- view ---
func TestReleaseView(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo/releases/v1.0.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"tag_name": "v1.0", "name": "Version 1.0"})
}))
func TestReleaseEdit(t *testing.T) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertReleaseRequest(t, r, "GET", "/owner/repo/releases/7/edit.json")
writeReleaseJSON(t, w, releaseEditFixture())
})
defer server.Close()
err := runShortcut(t, server, "view", map[string]string{"id": "v1.0"})
if err != nil {
if err := runReleaseShortcut(t, server, "edit", map[string]string{"id": "7"}); err != nil {
t.Fatalf("edit shortcut failed: %v", err)
}
}
func TestReleaseView(t *testing.T) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertReleaseRequest(t, r, "GET", "/owner/repo/releases/v1.0.json")
writeReleaseJSON(t, w, map[string]interface{}{"tag_name": "v1.0", "name": "Version 1.0"})
})
defer server.Close()
if err := runReleaseShortcut(t, server, "view", map[string]string{"id": "v1.0"}); err != nil {
t.Fatalf("view failed: %v", err)
}
}
// --- delete (normal path: delete succeeds) ---
func TestReleaseDeleteSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" {
writeJSON(w, map[string]interface{}{"message": "deleted"})
return
func TestReleaseUpdatePreservesExistingFields(t *testing.T) {
var payload map[string]interface{}
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/7/edit.json":
writeReleaseJSON(t, w, releaseEditFixture())
case r.Method == "PUT" && r.URL.Path == "/owner/repo/releases/7.json":
payload = decodeReleaseJSON(t, r)
writeReleaseJSON(t, w, map[string]interface{}{"status": 0, "message": "updated"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
})
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"id": "1"})
err := runReleaseShortcut(t, server, "update", map[string]string{
"id": "7",
"body": "Updated notes",
"prerelease": "true",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertReleaseEqual(t, payload["tag_name"], "v1.0.0")
assertReleaseEqual(t, payload["name"], "Old release")
assertReleaseEqual(t, payload["body"], "Updated notes")
assertReleaseEqual(t, payload["target_commitish"], "master")
assertReleaseEqual(t, payload["draft"], false)
assertReleaseEqual(t, payload["prerelease"], true)
assertReleaseStringSlice(t, payload["attachment_ids"], []string{"12", "34"})
}
func TestReleaseUpdateOverridesAttachmentIDs(t *testing.T) {
var payload map[string]interface{}
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/7/edit.json":
writeReleaseJSON(t, w, releaseEditFixture())
case r.Method == "PUT" && r.URL.Path == "/owner/repo/releases/7.json":
payload = decodeReleaseJSON(t, r)
writeReleaseJSON(t, w, map[string]interface{}{"status": 0, "message": "updated"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runReleaseShortcut(t, server, "update", map[string]string{
"id": "7",
"name": "New release",
"attachment-ids": "99,100",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertReleaseEqual(t, payload["name"], "New release")
assertReleaseStringSlice(t, payload["attachment_ids"], []string{"99", "100"})
}
func TestReleaseUpdateDryRunDoesNotWrite(t *testing.T) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PUT" {
t.Fatalf("dry-run should not update release, got %s %s", r.Method, r.URL.Path)
}
assertReleaseRequest(t, r, "GET", "/owner/repo/releases/7/edit.json")
writeReleaseJSON(t, w, releaseEditFixture())
})
defer server.Close()
err := runReleaseShortcut(t, server, "update", map[string]string{
"id": "7",
"body": "Preview notes",
"dry-run": "true",
})
if err != nil {
t.Fatalf("update dry-run failed: %v", err)
}
}
func TestReleaseDeleteSuccess(t *testing.T) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertReleaseRequest(t, r, "DELETE", "/owner/repo/releases/1.json")
writeReleaseJSON(t, w, map[string]interface{}{"message": "deleted"})
})
defer server.Close()
if err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"}); err != nil {
t.Fatalf("delete failed: %v", err)
}
}
// --- delete (API bug workaround: delete fails but release was actually deleted) ---
func TestReleaseDeleteDryRunDoesNotCallAPI(t *testing.T) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("delete dry-run should not call API, got %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runReleaseShortcut(t, server, "delete", map[string]string{
"id": "7",
"dry-run": "true",
})
if err != nil {
t.Fatalf("delete dry-run failed: %v", err)
}
}
func TestReleaseDeleteBugWorkaround(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "DELETE":
// API bug: delete returns error even when successful
w.WriteHeader(http.StatusInternalServerError)
writeJSON(w, map[string]interface{}{"status": float64(500), "message": "server error"})
writeReleaseJSON(t, w, map[string]interface{}{"status": float64(500), "message": "server error"})
case "GET":
// Verify shows the release no longer exists
w.WriteHeader(http.StatusNotFound)
writeJSON(w, map[string]interface{}{"status": float64(404), "message": "not found"})
writeReleaseJSON(t, w, map[string]interface{}{"status": float64(404), "message": "not found"})
default:
t.Fatalf("unexpected method: %s", r.Method)
}
}))
})
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"id": "1"})
if err != nil {
if err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"}); err != nil {
t.Fatalf("delete bug workaround failed: %v", err)
}
}
// --- delete (delete truly fails: release still exists) ---
func TestReleaseDeleteTrulyFails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "DELETE":
w.WriteHeader(http.StatusInternalServerError)
writeJSON(w, map[string]interface{}{"status": float64(500), "message": "server error"})
writeReleaseJSON(t, w, map[string]interface{}{"status": float64(500), "message": "server error"})
case "GET":
// Release still exists — delete truly failed
writeJSON(w, map[string]interface{}{"id": float64(1), "tag_name": "v1.0"})
writeReleaseJSON(t, w, map[string]interface{}{"id": float64(1), "tag_name": "v1.0"})
default:
t.Fatalf("unexpected method: %s", r.Method)
}
}))
})
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"id": "1"})
if err == nil {
if err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"}); err == nil {
t.Fatal("expected error when delete truly fails")
}
}
// --- HTTP error paths ---
func TestReleaseListHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
_, _ = w.Write([]byte("server error"))
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"})
if err == nil {
if err := runReleaseShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}); err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestReleaseCreateHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
_, _ = w.Write([]byte("server error"))
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{"tag": "v1.0", "name": "v1.0"})
if err == nil {
if err := runReleaseShortcut(t, server, "create", map[string]string{"tag": "v1.0", "name": "v1.0"}); err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestReleaseViewHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
_, _ = w.Write([]byte("server error"))
})
defer server.Close()
err := runShortcut(t, server, "view", map[string]string{"id": "v1.0"})
if err == nil {
if err := runReleaseShortcut(t, server, "view", map[string]string{"id": "v1.0"}); err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestReleaseRejectsInvalidCreateBool(t *testing.T) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid bool should not call API, got %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runReleaseShortcut(t, server, "create", map[string]string{
"tag": "v1.0.0",
"name": "v1.0.0",
"prerelease": "maybe",
})
if err == nil {
t.Fatal("expected invalid prerelease value to return an error")
}
}
func TestReleaseUpdateRejectsNoFields(t *testing.T) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("empty update should not call API, got %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runReleaseShortcut(t, server, "update", map[string]string{"id": "7"})
if err == nil {
t.Fatal("expected empty update to return an error")
}
}
func TestReleaseUpdateRejectsInvalidBoolBeforeFetch(t *testing.T) {
server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid update bool should not call API, got %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runReleaseShortcut(t, server, "update", map[string]string{
"id": "7",
"draft": "maybe",
})
if err == nil {
t.Fatal("expected invalid draft value to return an error")
}
}
func TestReleaseShortcutNames(t *testing.T) {
got := map[string]bool{}
for _, shortcut := range Shortcuts() {
got[shortcut.Name] = true
}
want := []string{"list", "create", "edit", "view", "update", "delete"}
for _, name := range want {
if !got[name] {
t.Fatalf("missing shortcut %q in %v", name, got)
}
}
if len(got) != len(want) {
t.Fatalf("shortcut count = %d, want %d: %v", len(got), len(want), got)
}
}
func runReleaseShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findReleaseShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findReleaseShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func newReleaseTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func releaseEditFixture() map[string]interface{} {
return map[string]interface{}{
"id": 7,
"name": "Old release",
"body": "Old notes",
"tag_name": "v1.0.0",
"target_commitish": "master",
"draft": false,
"prerelease": false,
"attachments": []map[string]interface{}{
{"id": 12, "title": "a.zip"},
{"id": "34", "title": "b.zip"},
},
}
}
func assertReleaseRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
}
}
func decodeReleaseJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return payload
}
func writeReleaseJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func assertReleaseEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func assertReleaseStringSlice(t *testing.T, got interface{}, want []string) {
t.Helper()
values, ok := got.([]interface{})
if !ok {
t.Fatalf("got %T, want []interface{}", got)
}
result := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok {
t.Fatalf("got value %v (%T), want string", value, value)
}
result = append(result, text)
}
if !reflect.DeepEqual(result, want) {
t.Fatalf("got %v, want %v", result, want)
}
}
func ExampleShortcuts() {
for _, shortcut := range Shortcuts() {
fmt.Println(shortcut.Name)
}
// Output:
// list
// create
// edit
// view
// update
// delete
}

View File

@ -3,6 +3,8 @@ package repo
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -79,6 +81,108 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "tree",
Description: tr.T("cmd.repo.tree.short"),
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: tr.T("flag.repo.tree.path")},
{Name: "ref", Short: "r", Usage: tr.T("flag.repo.tree.ref"), Default: "master"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
ref := ctx.Arg("ref")
if ref == "" {
ref = "master"
}
if path := ctx.Arg("path"); path != "" {
q.Set("filepath", path)
}
q.Set("ref", ref)
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "languages",
Description: "Show repository language statistics",
Run: runLanguages,
},
{
Name: "contributors",
Description: "List repository contributors",
Run: runContributors,
},
{
Name: "contributor-stats",
Description: "List contributor statistics with code line counts",
Flags: []common.Flag{
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
{Name: "pass-year", Usage: "Number of past years to include"},
},
Run: runContributorStats,
},
{
Name: "code-stats",
Description: "Show repository code statistics",
Flags: []common.Flag{
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
},
Run: runCodeStats,
},
{
Name: "watchers",
Description: "List repository watchers",
Flags: communityListFlags(),
Run: func(ctx *common.RuntimeContext) error {
return runCommunityList(ctx, "watchers")
},
},
{
Name: "stargazers",
Description: "List repository stargazers",
Flags: communityListFlags(),
Run: func(ctx *common.RuntimeContext) error {
return runCommunityList(ctx, "stargazers")
},
},
{
Name: "follow",
Description: "Follow a repository",
Flags: repoInteractionFlags(),
Run: func(ctx *common.RuntimeContext) error {
return runRepoFollowAction(ctx, "POST", "/watchers/follow", "follow")
},
},
{
Name: "unfollow",
Description: "Unfollow a repository",
Flags: repoInteractionFlags(),
Run: func(ctx *common.RuntimeContext) error {
return runRepoFollowAction(ctx, "DELETE", "/watchers/unfollow", "unfollow")
},
},
{
Name: "like",
Description: "Like a repository",
Flags: repoInteractionFlags(),
Run: func(ctx *common.RuntimeContext) error {
return runRepoPraiseAction(ctx, "POST", "like")
},
},
{
Name: "unlike",
Description: "Unlike a repository",
Flags: repoInteractionFlags(),
Run: func(ctx *common.RuntimeContext) error {
return runRepoPraiseAction(ctx, "DELETE", "unlike")
},
},
{
Name: "create",
Description: tr.T("cmd.repo.create.short"),
@ -158,3 +262,241 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
}
return i18n.Default()
}
func runLanguages(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/languages", nil)
if err != nil {
return err
}
return ctx.Output(env)
}
func runContributors(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/contributors", nil)
if err != nil {
return err
}
return ctx.Output(env)
}
func runContributorStats(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
setRepoQueryIfPresent(q, "ref", ctx.Arg("ref"))
if passYear := ctx.Arg("pass-year"); strings.TrimSpace(passYear) != "" {
year, err := parseRepoPositiveInt(passYear, "pass-year")
if err != nil {
return err
}
q.Set("pass_year", strconv.Itoa(year))
}
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/contributors/stat", q)
if err != nil {
return err
}
return ctx.Output(env)
}
func runCodeStats(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
setRepoQueryIfPresent(q, "ref", ctx.Arg("ref"))
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/code_stats", q)
if err != nil {
return err
}
return ctx.Output(env)
}
func runCommunityList(ctx *common.RuntimeContext, path string) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q, err := communityTimeRangeQuery(ctx)
if err != nil {
return err
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/"+path, q)
if err != nil {
return err
}
return ctx.Output(env)
}
func communityListFlags() []common.Flag {
return []common.Flag{
{Name: "start-at", Usage: "Start timestamp"},
{Name: "end-at", Usage: "End timestamp"},
}
}
func repoInteractionFlags() []common.Flag {
return []common.Flag{
{Name: "project-id", Usage: "GitLink project ID. If omitted, it is resolved from --owner/--repo."},
{Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"},
}
}
func runRepoFollowAction(ctx *common.RuntimeContext, method, path, action string) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveRepoProjectID(ctx)
if err != nil {
return err
}
query := url.Values{}
query.Set("target_type", "project")
query.Set("id", projectID)
if ctx.Arg("dry-run") == "true" {
return repoInteractionDryRun(ctx, method, path, action, projectID, query)
}
env, err := ctx.CallAPIWithQuery(method, path, query)
if err != nil {
return err
}
return ctx.Output(env)
}
func runRepoPraiseAction(ctx *common.RuntimeContext, method, action string) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveRepoProjectID(ctx)
if err != nil {
return err
}
path := fmt.Sprintf("/projects/%s/praise_tread/%s", projectID, action)
if ctx.Arg("dry-run") == "true" {
return repoInteractionDryRun(ctx, method, path, action, projectID, nil)
}
env, err := ctx.CallAPI(method, path, nil)
if err != nil {
return err
}
return ctx.Output(env)
}
func repoInteractionDryRun(ctx *common.RuntimeContext, method, path, action, projectID string, query url.Values) error {
preview := map[string]interface{}{
"dry_run": true,
"action": action,
"method": method,
"path": path,
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"project_id": projectID,
}
if len(query) > 0 {
preview["query"] = query.Encode()
}
return ctx.OutputData(preview)
}
func resolveRepoProjectID(ctx *common.RuntimeContext) (string, error) {
if raw := strings.TrimSpace(ctx.Arg("project-id")); raw != "" {
return normalizeRepoProjectID(raw)
}
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return "", fmt.Errorf("resolve project id: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("resolve project id: unexpected repository response")
}
for _, key := range []string{"id", "project_id"} {
if id := repoProjectIDString(data[key]); id != "" {
return id, nil
}
}
return "", fmt.Errorf("resolve project id: repository response did not include id")
}
func normalizeRepoProjectID(value string) (string, error) {
value = strings.TrimSpace(value)
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
return "", fmt.Errorf("invalid --project-id %q: use a positive numeric project ID", value)
}
return strconv.FormatInt(parsed, 10), nil
}
func repoProjectIDString(value interface{}) string {
switch v := value.(type) {
case float64:
if v > 0 {
return strconv.FormatInt(int64(v), 10)
}
case int:
if v > 0 {
return strconv.Itoa(v)
}
case int64:
if v > 0 {
return strconv.FormatInt(v, 10)
}
case string:
id, err := normalizeRepoProjectID(v)
if err == nil {
return id
}
}
return ""
}
func communityTimeRangeQuery(ctx *common.RuntimeContext) (url.Values, error) {
q := url.Values{}
start, hasStart, err := parseOptionalRepoNonNegativeInt(ctx.Arg("start-at"), "start-at")
if err != nil {
return nil, err
}
end, hasEnd, err := parseOptionalRepoNonNegativeInt(ctx.Arg("end-at"), "end-at")
if err != nil {
return nil, err
}
if hasStart {
q.Set("start_at", strconv.Itoa(start))
}
if hasEnd {
q.Set("end_at", strconv.Itoa(end))
}
if hasStart && hasEnd && start > end {
return nil, fmt.Errorf("--start-at must be less than or equal to --end-at")
}
return q, nil
}
func setRepoQueryIfPresent(q url.Values, key, value string) {
if value := strings.TrimSpace(value); value != "" {
q.Set(key, value)
}
}
func parseOptionalRepoNonNegativeInt(value, name string) (int, bool, error) {
if strings.TrimSpace(value) == "" {
return 0, false, nil
}
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || parsed < 0 {
return 0, false, fmt.Errorf("invalid --%s %q: use a non-negative integer", name, value)
}
return parsed, true, nil
}
func parseRepoPositiveInt(value, name string) (int, error) {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || parsed <= 0 {
return 0, fmt.Errorf("invalid --%s %q: use a positive integer", name, value)
}
return parsed, nil
}

View File

@ -2,6 +2,7 @@ package repo
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@ -14,12 +15,18 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st
t.Helper()
s := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return s.Run(ctx)
}
@ -34,9 +41,20 @@ func findShortcut(t *testing.T, name string) *common.Shortcut {
return nil
}
func writeJSON(w http.ResponseWriter, v interface{}) {
func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("write response: %v", err)
}
}
func writeText(t *testing.T, w http.ResponseWriter, code int, text string) {
t.Helper()
w.WriteHeader(code)
if _, err := w.Write([]byte(text)); err != nil {
t.Fatalf("write response: %v", err)
}
}
// --- list ---
@ -49,7 +67,7 @@ func TestRepoListDefault(t *testing.T) {
if r.URL.Path != "/projects.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{
writeJSON(t, w, map[string]interface{}{
"total_count": float64(2),
"data": []interface{}{map[string]interface{}{"name": "repo1"}},
})
@ -67,7 +85,7 @@ func TestRepoListForUser(t *testing.T) {
if r.URL.Path != "/users/alice/projects.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"total_count": float64(0), "data": []interface{}{}})
writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "data": []interface{}{}})
}))
defer server.Close()
@ -82,7 +100,7 @@ func TestRepoListWithCategory(t *testing.T) {
if r.URL.Query().Get("category") != "mirror" {
t.Fatalf("expected category=mirror, got %s", r.URL.Query().Get("category"))
}
writeJSON(w, map[string]interface{}{"data": []interface{}{}})
writeJSON(t, w, map[string]interface{}{"data": []interface{}{}})
}))
defer server.Close()
@ -92,14 +110,14 @@ func TestRepoListWithCategory(t *testing.T) {
}
}
// --- info ---
// --- info/readme/insights ---
func TestRepoInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{
writeJSON(t, w, map[string]interface{}{
"name": "repo",
"description": "test repo",
})
@ -112,7 +130,269 @@ func TestRepoInfo(t *testing.T) {
}
}
// --- fork ---
func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/owner/repo/readme.json")
assertEqual(t, r.URL.Query().Get("ref"), "main")
assertEqual(t, r.URL.Query().Get("filepath"), "docs")
writeJSON(t, w, map[string]interface{}{
"type": "file",
"name": "README.md",
"content": "# docs\n",
})
}))
defer server.Close()
err := runShortcut(t, server, "readme", map[string]string{
"ref": "main",
"path": "docs",
})
if err != nil {
t.Fatalf("readme shortcut failed: %v", err)
}
}
func TestRepoTreeListsRootOnDefaultRef(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/owner/repo/sub_entries.json")
if _, ok := r.URL.Query()["filepath"]; ok {
t.Fatalf("did not expect filepath query for repository root, got %q", r.URL.Query().Get("filepath"))
}
assertEqual(t, r.URL.Query().Get("ref"), "master")
writeJSON(t, w, map[string]interface{}{
"entries": []map[string]interface{}{
{"name": "README.md", "type": "file"},
},
})
}))
defer server.Close()
if err := runShortcut(t, server, "tree", nil); err != nil {
t.Fatalf("tree shortcut failed: %v", err)
}
}
func TestRepoTreeUsesPathAndRef(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/owner/repo/sub_entries.json")
assertEqual(t, r.URL.Query().Get("filepath"), "cmd")
assertEqual(t, r.URL.Query().Get("ref"), "main")
writeJSON(t, w, map[string]interface{}{
"entries": []map[string]interface{}{
{"name": "main.go", "type": "file"},
},
})
}))
defer server.Close()
if err := runShortcut(t, server, "tree", map[string]string{"path": "cmd", "ref": "main"}); err != nil {
t.Fatalf("tree shortcut failed: %v", err)
}
}
func TestRepoTreeShortcutRegistersHelpFlags(t *testing.T) {
tree := findShortcut(t, "tree")
if tree.Description == "" {
t.Fatal("tree shortcut description is empty")
}
flags := map[string]common.Flag{}
for _, flag := range tree.Flags {
flags[flag.Name] = flag
}
pathFlag, ok := flags["path"]
if !ok {
t.Fatal("tree shortcut missing path flag")
}
if pathFlag.Short != "p" || pathFlag.Usage == "" {
t.Fatalf("unexpected path flag: %+v", pathFlag)
}
refFlag, ok := flags["ref"]
if !ok {
t.Fatal("tree shortcut missing ref flag")
}
if refFlag.Short != "r" || refFlag.Default != "master" || refFlag.Usage == "" {
t.Fatalf("unexpected ref flag: %+v", refFlag)
}
}
func TestRepoLanguagesUsesLanguagesEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/owner/repo/languages.json")
writeJSON(t, w, map[string]interface{}{"Go": "92.4%", "Shell": "7.6%"})
}))
defer server.Close()
if err := runShortcut(t, server, "languages", nil); err != nil {
t.Fatalf("languages shortcut failed: %v", err)
}
}
func TestRepoContributorsUsesContributorsEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/owner/repo/contributors.json")
writeJSON(t, w, map[string]interface{}{"total_count": 1, "list": []interface{}{}})
}))
defer server.Close()
if err := runShortcut(t, server, "contributors", nil); err != nil {
t.Fatalf("contributors shortcut failed: %v", err)
}
}
func TestRepoContributorStatsBuildsQuery(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/contributors/stat.json")
assertEqual(t, r.URL.Query().Get("ref"), "main")
assertEqual(t, r.URL.Query().Get("pass_year"), "2")
writeJSON(t, w, map[string]interface{}{"total_count": 1, "contributors": []interface{}{}})
}))
defer server.Close()
err := runShortcut(t, server, "contributor-stats", map[string]string{
"ref": " main ",
"pass-year": "2",
})
if err != nil {
t.Fatalf("contributor-stats shortcut failed: %v", err)
}
}
func TestRepoCodeStatsUsesRefQuery(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/code_stats.json")
assertEqual(t, r.URL.Query().Get("ref"), "release/v1")
writeJSON(t, w, map[string]interface{}{"author_count": 1, "commit_count": 3})
}))
defer server.Close()
if err := runShortcut(t, server, "code-stats", map[string]string{"ref": "release/v1"}); err != nil {
t.Fatalf("code-stats shortcut failed: %v", err)
}
}
func TestRepoWatchersBuildsTimeRangeQuery(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/owner/repo/watchers.json")
assertEqual(t, r.URL.Query().Get("start_at"), "1714521600")
assertEqual(t, r.URL.Query().Get("end_at"), "1717200000")
writeJSON(t, w, map[string]interface{}{"count": 1, "users": []interface{}{}})
}))
defer server.Close()
err := runShortcut(t, server, "watchers", map[string]string{
"start-at": "1714521600",
"end-at": "1717200000",
})
if err != nil {
t.Fatalf("watchers shortcut failed: %v", err)
}
}
func TestRepoStargazersUsesStargazersEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/owner/repo/stargazers.json")
writeJSON(t, w, map[string]interface{}{"count": 0, "users": []interface{}{}})
}))
defer server.Close()
if err := runShortcut(t, server, "stargazers", nil); err != nil {
t.Fatalf("stargazers shortcut failed: %v", err)
}
}
func TestRepoFollowResolvesProjectID(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
switch requests {
case 1:
assertRequest(t, r, "GET", "/owner/repo.json")
writeJSON(t, w, map[string]interface{}{"id": float64(123)})
case 2:
assertRequest(t, r, "POST", "/watchers/follow.json")
assertEqual(t, r.URL.Query().Get("target_type"), "project")
assertEqual(t, r.URL.Query().Get("id"), "123")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success", "watched": true})
default:
t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runShortcut(t, server, "follow", nil); err != nil {
t.Fatalf("follow shortcut failed: %v", err)
}
assertEqual(t, requests, 2)
}
func TestRepoUnfollowUsesExplicitProjectID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "DELETE", "/watchers/unfollow.json")
assertEqual(t, r.URL.Query().Get("target_type"), "project")
assertEqual(t, r.URL.Query().Get("id"), "456")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success", "watched": false})
}))
defer server.Close()
if err := runShortcut(t, server, "unfollow", map[string]string{"project-id": "456"}); err != nil {
t.Fatalf("unfollow shortcut failed: %v", err)
}
}
func TestRepoLikeUsesExplicitProjectID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/projects/456/praise_tread/like.json")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
if err := runShortcut(t, server, "like", map[string]string{"project-id": "456"}); err != nil {
t.Fatalf("like shortcut failed: %v", err)
}
}
func TestRepoUnlikeResolvesStringProjectID(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
switch requests {
case 1:
assertRequest(t, r, "GET", "/owner/repo.json")
writeJSON(t, w, map[string]interface{}{"project_id": "789"})
case 2:
assertRequest(t, r, "DELETE", "/projects/789/praise_tread/unlike.json")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
default:
t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runShortcut(t, server, "unlike", nil); err != nil {
t.Fatalf("unlike shortcut failed: %v", err)
}
assertEqual(t, requests, 2)
}
func TestRepoInteractionDryRunDoesNotCallAPIWithExplicitProjectID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run with explicit project-id should not call API, got: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "like", map[string]string{
"project-id": "456",
"dry-run": "true",
})
if err != nil {
t.Fatalf("dry-run shortcut failed: %v", err)
}
}
// --- fork/delete/create ---
func TestRepoFork(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -122,7 +402,7 @@ func TestRepoFork(t *testing.T) {
if r.URL.Path != "/owner/repo/forks.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"message": "forked"})
writeJSON(t, w, map[string]interface{}{"message": "forked"})
}))
defer server.Close()
@ -132,8 +412,6 @@ func TestRepoFork(t *testing.T) {
}
}
// --- delete ---
func TestRepoDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
@ -142,7 +420,7 @@ func TestRepoDelete(t *testing.T) {
if r.URL.Path != "/owner/repo.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"message": "deleted"})
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
}))
defer server.Close()
@ -152,18 +430,16 @@ func TestRepoDelete(t *testing.T) {
}
}
// --- create ---
func TestRepoCreate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/users/me.json" && r.Method == "GET":
writeJSON(w, map[string]interface{}{
writeJSON(t, w, map[string]interface{}{
"login": "creator",
"user_id": float64(42),
})
case r.URL.Path == "/creator/new-repo.json" && r.Method == "POST":
writeJSON(w, map[string]interface{}{"name": "new-repo"})
writeJSON(t, w, map[string]interface{}{"name": "new-repo"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
@ -181,13 +457,15 @@ func TestRepoCreateWithOptions(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/users/me.json":
writeJSON(w, map[string]interface{}{
writeJSON(t, w, map[string]interface{}{
"login": "creator",
"user_id": float64(42),
})
case "/creator/my-repo.json":
json.NewDecoder(r.Body).Decode(&body)
writeJSON(w, map[string]interface{}{"name": "my-repo"})
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeJSON(t, w, map[string]interface{}{"name": "my-repo"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
@ -210,6 +488,55 @@ func TestRepoCreateWithOptions(t *testing.T) {
}
}
// --- validation/error paths ---
func TestRepoInsightValidation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid input should not call API, got: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
cases := []struct {
name string
shortcut string
args map[string]string
}{
{
name: "invalid pass year",
shortcut: "contributor-stats",
args: map[string]string{"pass-year": "0"},
},
{
name: "invalid start timestamp",
shortcut: "watchers",
args: map[string]string{"start-at": "abc"},
},
{
name: "start after end",
shortcut: "stargazers",
args: map[string]string{"start-at": "20", "end-at": "10"},
},
{
name: "invalid project id",
shortcut: "follow",
args: map[string]string{"project-id": "repo"},
},
{
name: "negative project id",
shortcut: "unlike",
args: map[string]string{"project-id": "-1"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := runShortcut(t, server, tc.shortcut, tc.args); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestRepoCreateFailsWithoutName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call should be made")
@ -222,12 +549,9 @@ func TestRepoCreateFailsWithoutName(t *testing.T) {
}
}
// --- HTTP error paths ---
func TestRepoListHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
writeText(t, w, http.StatusInternalServerError, "server error")
}))
defer server.Close()
@ -239,8 +563,7 @@ func TestRepoListHTTPError(t *testing.T) {
func TestRepoInfoHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
writeText(t, w, http.StatusInternalServerError, "server error")
}))
defer server.Close()
@ -252,8 +575,7 @@ func TestRepoInfoHTTPError(t *testing.T) {
func TestRepoForkHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
writeText(t, w, http.StatusInternalServerError, "server error")
}))
defer server.Close()
@ -265,8 +587,7 @@ func TestRepoForkHTTPError(t *testing.T) {
func TestRepoDeleteHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
writeText(t, w, http.StatusInternalServerError, "server error")
}))
defer server.Close()
@ -278,8 +599,7 @@ func TestRepoDeleteHTTPError(t *testing.T) {
func TestRepoCreateGetUserHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
writeText(t, w, http.StatusInternalServerError, "server error")
}))
defer server.Close()
@ -291,7 +611,7 @@ func TestRepoCreateGetUserHTTPError(t *testing.T) {
func TestRepoCreateUserNoLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"user_id": float64(42)})
writeJSON(t, w, map[string]interface{}{"user_id": float64(42)})
}))
defer server.Close()
@ -300,3 +620,17 @@ func TestRepoCreateUserNoLogin(t *testing.T) {
t.Fatal("expected error when user response has no login")
}
}
func assertRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}
func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

200
shortcuts/wiki/wiki.go Normal file
View File

@ -0,0 +1,200 @@
package wiki
import (
"encoding/base64"
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// switchToGateway overrides the client base URL with the gateway URL from config.
func switchToGateway(ctx *common.RuntimeContext) error {
cfg, err := config.Load()
if err != nil {
return err
}
if cfg.GatewayURL == "" {
cfg.GatewayURL = config.DefaultGatewayURL
}
ctx.Client.BaseURL = cfg.GatewayURL
return nil
}
// gatewayFlag returns the common --gateway flag definition.
func gatewayFlag() common.Flag {
return common.Flag{Name: "gateway", Short: "g", Usage: "Use gateway API endpoint", Bool: true}
}
// Shortcuts returns all wiki shortcuts.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List wiki pages",
Flags: []common.Flag{
{Name: "project-id", Usage: "GitLink project ID", Required: true},
gatewayFlag(),
},
Run: func(ctx *common.RuntimeContext) error {
if ctx.Arg("gateway") == "true" {
if err := switchToGateway(ctx); err != nil {
return err
}
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", ctx.Arg("project-id"))
env, err := ctx.CallAPIWithQuery("GET", "/wiki/open/wikiPages", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View a wiki page by page name",
Flags: []common.Flag{
{Name: "project-id", Usage: "GitLink project ID", Required: true},
{Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true},
gatewayFlag(),
},
Run: func(ctx *common.RuntimeContext) error {
if ctx.Arg("gateway") == "true" {
if err := switchToGateway(ctx); err != nil {
return err
}
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", ctx.Arg("project-id"))
q.Set("pageName", ctx.Arg("page-name"))
env, err := ctx.CallAPIWithQuery("GET", "/wiki/open/getWiki", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a new wiki page",
Flags: []common.Flag{
{Name: "project-id", Usage: "GitLink project ID", Required: true},
{Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true},
{Name: "title", Short: "t", Usage: "Wiki page title", Required: true},
{Name: "content", Short: "c", Usage: "Wiki page content (markdown)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message"},
gatewayFlag(),
},
Run: func(ctx *common.RuntimeContext) error {
if ctx.Arg("gateway") == "true" {
if err := switchToGateway(ctx); err != nil {
return err
}
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
content := ctx.Arg("content")
payload := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": ctx.Arg("project-id"),
"pageName": ctx.Arg("page-name"),
"title": ctx.Arg("title"),
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
"message": ctx.Arg("message"),
}
env, err := ctx.CallAPI("POST", "/wiki/open/createWiki", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an existing wiki page",
Flags: []common.Flag{
{Name: "project-id", Usage: "GitLink project ID", Required: true},
{Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true},
{Name: "title", Short: "t", Usage: "Wiki page title", Required: true},
{Name: "content", Short: "c", Usage: "Wiki page content (markdown)"},
{Name: "message", Short: "m", Usage: "Commit message"},
gatewayFlag(),
},
Run: func(ctx *common.RuntimeContext) error {
if ctx.Arg("gateway") == "true" {
if err := switchToGateway(ctx); err != nil {
return err
}
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title := ctx.Arg("title")
if title == "" {
return fmt.Errorf("--title is required")
}
content := ctx.Arg("content")
payload := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": ctx.Arg("project-id"),
"pageName": ctx.Arg("page-name"),
"title": title,
"message": ctx.Arg("message"),
}
if content != "" {
payload["content_base64"] = base64.StdEncoding.EncodeToString([]byte(content))
}
env, err := ctx.CallAPI("PUT", "/wiki/open/updateWiki", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a wiki page",
Flags: []common.Flag{
{Name: "project-id", Usage: "GitLink project ID", Required: true},
{Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true},
gatewayFlag(),
},
Run: func(ctx *common.RuntimeContext) error {
if ctx.Arg("gateway") == "true" {
if err := switchToGateway(ctx); err != nil {
return err
}
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
payload := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": ctx.Arg("project-id"),
"pageName": ctx.Arg("page-name"),
}
env, err := ctx.CallAPI("DELETE", "/wiki/open/deleteWiki", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

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