forked from Gitlink/gitlink-cli
Compare commits
90 Commits
master
...
pr-skill-c
| Author | SHA1 | Date |
|---|---|---|
|
|
23a2cb81fd | |
|
|
9749a4c832 | |
|
|
11ecb9b79a | |
|
|
71ca2bb683 | |
|
|
593b5e8f0d | |
|
|
dbf0f45ce3 | |
|
|
2c9a8b6192 | |
|
|
982f2cb336 | |
|
|
0703d3eba9 | |
|
|
ef7a2c6ac1 | |
|
|
6eebfeb40d | |
|
|
6454645f50 | |
|
|
d3485fce45 | |
|
|
67b46eece4 | |
|
|
1806382459 | |
|
|
46154f7c5e | |
|
|
a61a8c4ef4 | |
|
|
789db5e5ae | |
|
|
ecc5578cf4 | |
|
|
8c29d98a57 | |
|
|
1c84cac060 | |
|
|
706a1396ad | |
|
|
29ca94db76 | |
|
|
7f731eb04f | |
|
|
da4c8c0d10 | |
|
|
2729cc69ba | |
|
|
7b880cb8ad | |
|
|
fb2d60f67a | |
|
|
968ea0d7b4 | |
|
|
85a67a875f | |
|
|
17c555620b | |
|
|
9be51d34fc | |
|
|
52b7093846 | |
|
|
d0ca6b24ec | |
|
|
462ae7b012 | |
|
|
220aa70e51 | |
|
|
21966bf1ea | |
|
|
caad409185 | |
|
|
60e8ab7768 | |
|
|
21ebb2f89c | |
|
|
f1dd01bebc | |
|
|
0da1d8d7bf | |
|
|
a580562922 | |
|
|
6e45fbb3e1 | |
|
|
2d963ebf41 | |
|
|
7947d4dfd6 | |
|
|
68aa9ae6e6 | |
|
|
27cc58328d | |
|
|
ee0d882e1c | |
|
|
ca578a74be | |
|
|
55805aa13d | |
|
|
dd4ed35448 | |
|
|
723e698db4 | |
|
|
03a9d7a942 | |
|
|
34f14b079f | |
|
|
f3e5d4aba0 | |
|
|
64dad994ae | |
|
|
7e863398aa | |
|
|
9c085fb246 | |
|
|
a9ee74efb0 | |
|
|
d6321e7af5 | |
|
|
dec10afaeb | |
|
|
35e50a1828 | |
|
|
dedba6e49f | |
|
|
a1d3a85ab8 | |
|
|
0b48ec90d8 | |
|
|
b884a7478c | |
|
|
6353987267 | |
|
|
3255884c6e | |
|
|
82ac20ae74 | |
|
|
5711aa3654 | |
|
|
a2fd36b4a6 | |
|
|
b319c44b02 | |
|
|
33ad8e62a0 | |
|
|
9bfa59c7e8 | |
|
|
cedd8b6263 | |
|
|
b45241dcda | |
|
|
633bed3360 | |
|
|
c263a2fa34 | |
|
|
6636175bd8 | |
|
|
83fe18f8c0 | |
|
|
0e13e8bdd4 | |
|
|
559e1a74f3 | |
|
|
17e8c113f7 | |
|
|
41b89976aa | |
|
|
0a578bbab0 | |
|
|
0da45def2b | |
|
|
f4820c03b4 | |
|
|
d478de59a3 | |
|
|
7d8c33a938 |
183
README.md
183
README.md
|
|
@ -5,7 +5,7 @@
|
|||
[](https://golang.org)
|
||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
||||
|
||||
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
|
||||
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, 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
|
||||
|
||||
|
|
|
|||
177
README.zh-CN.md
177
README.zh-CN.md
|
|
@ -5,7 +5,7 @@
|
|||
[](https://golang.org)
|
||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
||||
|
||||
[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
|
||||
[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、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)
|
||||
|
||||
## 许可证
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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{}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 不发请求、变量覆盖、模板缺失报错、失败默认中断以及失败继续执行等关键行为。
|
||||
|
|
@ -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` 解析差异污染真实用户凭据目录。
|
||||
|
|
@ -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` 正整数校验。
|
||||
|
|
@ -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 输出,确保诊断命令在常见失败场景下返回结构化结果而不是直接崩溃。
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Ignore shortcut
|
||||
|
||||
新增 `ignore` Shortcut 组,补齐 GitLink 忽略文件模板(`.gitignore`)查询:
|
||||
|
||||
- `ignore +list`
|
||||
|
||||
同时补充了单元测试、README 示例。
|
||||
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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 已同步更新。
|
||||
|
|
@ -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`
|
||||
命令组直接暴露平台原生画像数据,为"科研主体画像"等场景提供数据底座。
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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` 查询参数,语义更清晰,对现有功能无破坏性影响。
|
||||
|
|
@ -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` 框架,与其他模块保持一致
|
||||
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
outputs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
@ -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` 策略算出**确定性 0–100 评分卡**与**三态裁决**(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 | 本仓库真实 PR(pull_request_id 15222) | ✅ PASS 90/100,8 个变更文件路由正确 |
|
||||
| 注入真实审查发现 | 同一 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.5;96% 未关联 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)。
|
||||
|
|
@ -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))。
|
||||
|
|
@ -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 API;Python 3.9+ 与 gitlink-cli 的安装方式按实际
|
||||
# runner 镜像调整(下面 install 步骤仅为占位示意)。
|
||||
#
|
||||
# 触发:对 PR 的 open / 同步事件运行门禁,脚本返回码 2(REQUEST_CHANGES)
|
||||
# 会让本 job 失败,从而在 CI 上挡住该 PR(PASS/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/
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# config.example.yaml — PR 看门人闭环的工作流配置(gatekeeper_workflow.py --config 用)
|
||||
#
|
||||
# 命令行参数(--owner/--repo/--pr/--policy/--owner-rules/--findings)会覆盖这里的同名字段。
|
||||
# 相对路径以本配置文件所在目录为基准解析。
|
||||
|
||||
owner: Gitlink # 目标仓库 owner(GitLink 主分支为 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
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
# 架构与数据流 — PR 看门人闭环
|
||||
|
||||
本工作流采用「**采集 → 路由 → 裁决 → 回写/善后**」四段式流水线,把 `gitlink-gatekeeper` 的 Policy-as-Code 门禁包成一条可复现闭环。所有数值/字段/算法以已收录的 [`gitlink-gatekeeper` Skill REFERENCE](../../../../skills/gitlink-gatekeeper/REFERENCE.md) 为准。
|
||||
|
||||
## 设计目标
|
||||
|
||||
- **可复现**:同策略 + 同 PR 上下文 → 同评分卡 + 同裁决(确定性算法,SSOT 第 3–5 节)。
|
||||
- **可审计**:评分卡逐维列分 + 备注,硬门禁逐条列出命中原因,裁决可追溯到具体规则与 `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)
|
||||
- 五维加权评分(权重和=100,SSOT 第 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. 形成从「数据获取」到「治理动作落地」的端到端闭环,并能接入 CI(REQUEST_CHANGES 返回码 2)。
|
||||
3. 复用本作品自研的 `label` 命令组(子赛题一)与 gatekeeper 策略(子赛题二),三个子赛题在同一作品内闭环,相互增强。
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# 最短复现路径(3 步)
|
||||
|
||||
## 1. 安装与认证
|
||||
|
||||
```bash
|
||||
npm install -g @gitlink-ai/cli # ≥0.2.0(label 命令与 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 Actions,PR 触发自动门禁)。
|
||||
- **改门禁松紧**:复制一份 `gatekeeper.yaml` 改 `weights/hard_gates/thresholds`,字段说明见 [Skill REFERENCE](../../../../skills/gitlink-gatekeeper/REFERENCE.md)。
|
||||
|
||||
## 验证自己改动没破坏确定性
|
||||
|
||||
```bash
|
||||
python3 tests/test_scoring.py # 同输入 → 同分 → 同裁决 的回归护栏
|
||||
```
|
||||
|
|
@ -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. 运行步骤
|
||||
|
||||
### 步骤 A:dry-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`(其余忽略)。
|
||||
|
||||
### 步骤 C:apply 执行写操作
|
||||
|
||||
确认 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` | 工作流配置 YAML(owner/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`。
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# 真实平台验证记录
|
||||
|
||||
> 全部针对 **GitLink 线上真实平台** 运行(`gitlink-cli` + Token 认证),非 mock。
|
||||
> 他人仓库一律 dry-run(只读零写入);写操作只在自有 fork 演练。
|
||||
> 运行环境:macOS(Apple Silicon)· Python 3.9 · `@gitlink-ai/cli` 0.2.0(npm 官方发布版,零本地构建)。
|
||||
|
||||
## A. dry-run:真实 PR → PASS 90/100
|
||||
|
||||
对本仓库真实 PR(`pull_request_id 15222`,feat/org-team-projects,8 个变更文件):
|
||||
|
||||
- 路由正确分流:README/docs/skill → doc-maintainer;`org.go` → go-reviewer;`org_test.go` → qa-reviewer
|
||||
- 评分(确定性,可手算复现):review 40/40 · test 20/20(1 src/1 test)· hygiene 10/15(desc✓/issue✗/size✓)· commit 15/15 · ci 5/10(unknown)= **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/40(1 major + 2 minor),总分 90 → 55,**裁决翻转为 REQUEST_CHANGES**。
|
||||
|
||||
**发现是真的,不是编的**——三条均来自对该 PR 真实 diff(head `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. 评分卡评论回写到 PR(comment id `472741`)
|
||||
2. 自动创建 tracking issue(id `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 起官方发布版自带,整条闭环零本地构建跑通。
|
||||
|
|
@ -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 中 action(add_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*
|
||||
|
|
@ -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*
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
# gatekeeper 仓库体检报告 —— Gitlink/gitlink-cli(2026-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 得到同样的分数与裁决。
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"_comment": "对 Gitlink/gitlink-cli PR #15222(feat/org-team-projects,head 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 中 action(add_all_team_projects/remove_all_team_projects)与实际请求 path 段(create_all/destroy_all,见 :134)是两套词汇,排查问题时易误导;建议统一或在 payload 同时给出两者映射"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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 审查发现(--findings),review_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
|
|
@ -0,0 +1,248 @@
|
|||
#!/usr/bin/env python3
|
||||
"""可复现评分单测 —— 把「同输入 → 同分 → 同裁决」从口号变成可验证事实。
|
||||
|
||||
纯标准库 unittest(Python 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/10;severity_penalty blocker=100/major=25/minor=5/nit=1;
|
||||
thresholds pass=85 / request_changes=60;max_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 维度计 len,src/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.md:3 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.md:4 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.md:2 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.md:4 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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
outputs/
|
||||
|
|
@ -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 两种模式,兼顾演示稳定性和真实落地。
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# 架构说明
|
||||
|
||||
本工作流采用“配置输入 -> 资产生成 -> CLI 编排 -> GitLink 落地 -> 结果归档”的五段式架构。正式架构图见 `docs/assets/bootstrap-architecture.svg`。
|
||||
|
||||

|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["项目配置<br/>sample_project.json"] --> B["资产生成<br/>README / LICENSE / CI / 协作文档"]
|
||||
B --> C["CLI 编排<br/>repo / branch / issue / comment"]
|
||||
C --> D["GitLink 项目空间<br/>仓库 / 分支 / Issue"]
|
||||
D --> E["结果归档<br/>报告 / 摘要 / manifest / 命令日志"]
|
||||
C --> E
|
||||
```
|
||||
|
||||
## 模块职责
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| 配置输入 | 描述项目名称、目标仓库、初始化分支和初始 Issue |
|
||||
| 资产生成 | 生成 README、LICENSE、CI 配置、贡献指南和路线图 |
|
||||
| CLI 编排 | 规划或执行 `gitlink-cli` 命令,串联仓库、分支、Issue 和评论能力 |
|
||||
| GitLink 落地 | 在真实 GitLink 仓库中创建分支、Issue,并可回写摘要 |
|
||||
| 结果归档 | 输出 Markdown 报告、摘要、JSON manifest 和命令日志 |
|
||||
|
||||
## 端到端链路
|
||||
|
||||
1. 读取 `examples/sample_project.json`。
|
||||
2. 生成初始化文件包。
|
||||
3. 规划 `repo +info` 和 `branch +list` 检查目标状态。
|
||||
4. 规划或执行 `branch +create` 创建协作分支。
|
||||
5. 规划或执行 `issue +create` 创建初始任务。
|
||||
6. 可选执行 `issue +comment` 发布初始化摘要。
|
||||
7. 生成报告与命令日志,支撑复现和审计。
|
||||
|
|
@ -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 |
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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`,便于复盘和审计。
|
||||
|
|
@ -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/` 用于保存固定示例产物。
|
||||
|
|
@ -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。
|
||||
|
|
@ -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 调用”“真实项目运行展示”“工作流说明文档和架构图”的要求。
|
||||
|
|
@ -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 命令计划
|
||||
|
|
@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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。
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# GitLink 项目初始化摘要
|
||||
|
||||
- 目标仓库: `puygob236/gitlink-bootstrap-demo`
|
||||
- 项目名称: Open Research Toolkit
|
||||
- 生成时间: 2026-05-24T08:00:00Z
|
||||
- 初始化文件: 5 个
|
||||
- 初始 Issue: 3 个
|
||||
- 分支动作: 2 个
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
11
go.mod
|
|
@ -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
51
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: ",
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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_.-]+$"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@gitlink-ai/cli",
|
||||
"version": "0.1.13",
|
||||
"version": "0.2.0",
|
||||
"description": "GitLink 平台官方命令行工具 — 代码托管、协作开发和自动化",
|
||||
"bin": {
|
||||
"gitlink-cli": "bin/cli.js",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue