diff --git a/README.md b/README.md
index a1466779..58cb831c 100644
--- a/README.md
+++ b/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/README.md).
+The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
**[中文文档](./README.zh-CN.md)**
@@ -78,19 +78,11 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
jiangtx
-
-
## Why gitlink-cli?
-- **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
+- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
- **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
@@ -104,24 +96,19 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| Category | Capabilities |
|----------|-------------|
| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions |
-| 🐛 Issue | Create, update, close, batch close/update/delete, comment on issues |
+| 🐛 Issue | Create, update, close, batch close, comment on issues |
+| 🎯 Milestone | List, create, inspect, summarize, update, close, and reopen milestones |
| 🔖 Label | Create, list, update, delete issue labels |
| 🔀 PR | Create, merge, review pull requests, view changed files |
-| 🧭 Compare | Compare refs, inspect changed files, filter commits, and summarize diff hotspots |
| 👥 Member | List, add, remove repository members, change roles, create and accept invite links |
-| 📨 Invite | Generate invite links, view invite info, accept invites, join/quit projects |
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
| 🏷️ Release | Create, edit, update, view, delete releases |
-| 🏢 Org | Manage organizations, list members, and inspect teams |
+| 🏢 Org | Manage organizations, members, teams |
| 🔧 CI | View builds, logs, CI/CD operations |
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
-| 🔔 Message Settings | Inspect and update personal message delivery preferences |
| 🔔 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, contribution heatmaps, activity and capability statistics |
-| 📊 Profile | User ability, role, major, activity, and contribution statistics |
+| 👤 User | View user profiles and info |
| 📋 PM | Sprint management, kanban boards, weekly reports |
| 🤖 Workflow | AI-powered issue triage, PR review, release notes |
@@ -175,29 +162,6 @@ export GITLINK_TOKEN="your-token" # Or set env var (for CI/CD, non-interactive e
gitlink-cli repo +list
```
-#### Shell Completion
-
-Generate completion scripts for your shell after installation:
-
-```bash
-# Bash
-mkdir -p ~/.local/share/bash-completion/completions
-gitlink-cli completion bash > ~/.local/share/bash-completion/completions/gitlink-cli
-
-# Zsh
-gitlink-cli completion zsh > "${fpath[1]}/_gitlink-cli"
-
-# Fish
-mkdir -p ~/.config/fish/completions
-gitlink-cli completion fish > ~/.config/fish/completions/gitlink-cli.fish
-
-# PowerShell
-gitlink-cli completion powershell > gitlink-cli.ps1
-. ./gitlink-cli.ps1
-```
-
-Use `--no-descriptions` if your shell setup prefers compact completion output.
-
### Quick Start (AI Agent)
> The following steps are for AI Agents. Some steps require the user to complete actions in a browser.
@@ -246,17 +210,8 @@ gitlink-cli repo +list
# View repository info
gitlink-cli repo +info --owner Gitlink --repo forgeplus
-# Clone a repository with git (mirrors `gh repo clone`)
-gitlink-cli repo +clone --owner Gitlink --repo forgeplus
-gitlink-cli repo +clone --owner Gitlink --repo forgeplus -d ./forgeplus -b develop
-
# Read repository README
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
-gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master --path docs
-
-# Read any repository file
-gitlink-cli repo +file --owner Gitlink --repo forgeplus --path go.mod --ref master
-gitlink-cli repo +file --owner Gitlink --repo forgeplus --path .gitignore --content-only
# List repository files at root or a directory
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
@@ -268,12 +223,6 @@ gitlink-cli repo +languages --owner Gitlink --repo forgeplus
# List contributors
gitlink-cli repo +contributors --owner Gitlink --repo forgeplus
-# List users who forked the repository
-gitlink-cli repo +forks --owner Gitlink --repo forgeplus
-
-# Top-line counts (tags, branches, commits, releases, size)
-gitlink-cli repo +top-counts --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
@@ -294,51 +243,8 @@ gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123
# Create a repository
gitlink-cli repo +create -n my-project -d "Project description"
-# Update repository settings (only the given fields change)
-gitlink-cli repo +edit --owner me --repo my-project -d "New description" --website "https://example.org"
-gitlink-cli repo +edit --owner me --repo my-project --private true
-gitlink-cli repo +edit --owner me --repo my-project --default-branch main
-
# Fork a repository
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
-
-# List organizations that can receive a repository transfer
-gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus
-
-# Preview a repository transfer without changing data
-gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --dry-run
-
-# Confirm and send a repository transfer request
-gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --yes
-
-# Preview canceling a pending repository transfer
-gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run
-
-# Confirm canceling a pending repository transfer
-gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --yes
-```
-
-### Message Settings
-
-```bash
-# List the available message setting groups and keys
-gitlink-cli message-settings +catalog
-
-# View the current user's effective message settings
-gitlink-cli message-settings +view
-
-# View another user's managed-repository notification settings
-gitlink-cli message-settings +view --login Mengz --group ManageProject
-
-# Preview disabling notification messages for selected keys
-gitlink-cli message-settings +update \
- --channel notification \
- --state off \
- --keys Normal::Permission,ManageProject::Issue \
- --dry-run
-
-# Apply a preset to every known setting
-gitlink-cli message-settings +preset --name notification-only --all
```
### Webhook Management
@@ -358,28 +264,6 @@ 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
@@ -402,22 +286,6 @@ gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role D
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
```
-### Organization Management
-
-```bash
-# List organizations
-gitlink-cli org +list
-
-# View organization details
-gitlink-cli org +info --id Gitlink
-
-# List organization members
-gitlink-cli org +members --id Gitlink --page 1 --limit 20
-
-# List organization teams
-gitlink-cli org +teams --id Gitlink --page 1 --limit 20
-```
-
### Issue Management
```bash
@@ -445,22 +313,9 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
# Batch close issues from a CSV file
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
-# Preview batch metadata update by API issue IDs
-# Note: --ids uses API issue IDs, not web URL issue numbers.
-gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run
-
-# Destructive batch delete requires both dry-run first and --yes for real execution
-gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run
-gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes
-
# Add a comment
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
-# List, edit, and delete comments
-gitlink-cli issue +comments --owner Gitlink --repo forgeplus -n 123 --category comment
-gitlink-cli issue +comment-edit --owner Gitlink --repo forgeplus -n 123 -c 484049 -b "Updated"
-gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus -n 123 -c 484049
-
# List issue assigners
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
@@ -490,6 +345,29 @@ issue detail endpoints provide those fields.
tracker, version, assignee, tag, and schedule metadata before sending updates,
which avoids clearing required fields on the server by accident.
+### Milestone Management
+
+```bash
+# List milestones
+gitlink-cli milestone +list --owner Gitlink --repo forgeplus
+
+# View a milestone and linked issues
+gitlink-cli milestone +view --owner Gitlink --repo forgeplus --id 2438 --limit 20
+
+# Build a milestone progress report by ID
+gitlink-cli milestone +report --owner Gitlink --repo forgeplus --id 2438 --sample-limit 3
+
+# Build a milestone progress report by name
+gitlink-cli milestone +report --owner Gitlink --repo forgeplus --name "v1.0"
+
+# Create a milestone
+gitlink-cli milestone +create --owner Gitlink --repo forgeplus --name "v1.0" --description "Scope for v1.0" --due-date 2026-07-01
+```
+
+`milestone +report` resolves a milestone by `--id` or `--name`, fetches all
+linked issues across pages, and summarizes close readiness, assignee/tag gaps,
+status distribution, and sample open issues.
+
### Label Management
```bash
@@ -509,31 +387,12 @@ gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00"
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
```
-### Compare
-
-```bash
-# Compare two refs
-gitlink-cli compare +view --owner Gitlink --repo forgeplus --head feature/search --base master
-
-# List changed files between two refs
-gitlink-cli compare +files --owner Gitlink --repo forgeplus --head feature/search --base master
-
-# List commits with author / keyword filters
-gitlink-cli compare +commits --owner Gitlink --repo forgeplus --head feature/search --base master --author alice -k fix -l 10
-
-# Summarize commits, top files, directories, and extensions
-gitlink-cli compare +summary --owner Gitlink --repo forgeplus --head feature/search --base master --top-files 5
-```
-
### Pull Requests
```bash
# List PRs
gitlink-cli pr +list --owner Gitlink --repo forgeplus
-# List PRs with the user-facing PR number column
-gitlink-cli pr +list --owner Gitlink --repo forgeplus --format table
-
# Create a PR (same-repo branch)
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: Search feature" --head feature/search --base master
@@ -552,11 +411,6 @@ gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
# View changed files
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
-# List, edit, and delete PR comments (pull journals)
-gitlink-cli pr +comments --owner Gitlink --repo forgeplus -i 42
-gitlink-cli pr +comment-edit --owner Gitlink --repo forgeplus -i 42 -c 484052 -b "Updated" -s resolved
-gitlink-cli pr +comment-delete --owner Gitlink --repo forgeplus -i 42 -c 484052
-
# List PR patchset versions
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
@@ -569,19 +423,6 @@ gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
# Create a PR review (with dry-run preview)
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
-
-# List inline review comments
-gitlink-cli pr +review-comments --owner Gitlink --repo forgeplus -i 42 --review-id 10 --state opened
-
-# Create an inline review comment and let the CLI fetch the file diff automatically
-gitlink-cli pr +review-comment --owner Gitlink --repo forgeplus -i 42 --review-id 10 \
- --path shortcuts/pr/pr.go --line-code "abc123_0_120" --type problem --note "Please handle the error path"
-
-# Update an inline review comment
-gitlink-cli pr +update-review-comment --owner Gitlink --repo forgeplus -i 42 --comment-id 301 --state resolved --note "Fixed in latest commit"
-
-# Delete an inline review comment
-gitlink-cli pr +delete-review-comment --owner Gitlink --repo forgeplus -i 42 --comment-id 301
```
### Branch Management
@@ -596,10 +437,6 @@ gitlink-cli branch +create --name feature/new-feature
# Delete a branch
gitlink-cli branch +delete --name feature/old-feature
-# All branch names at once (no paging) and default-branch switch
-gitlink-cli branch +all --owner myname --repo myrepo
-gitlink-cli branch +set-default --owner myname --repo myrepo -n develop
-
# Protect a branch
gitlink-cli branch +protect --name main
@@ -623,11 +460,6 @@ gitlink-cli release +view --owner Gitlink --repo forgeplus -i
gitlink-cli release +edit --owner Gitlink --repo forgeplus -i
gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b "Updated changelog" --dry-run
-# List and download release assets
-gitlink-cli release +assets --owner Gitlink --repo forgeplus -i
-gitlink-cli release +download --owner Gitlink --repo forgeplus -i --asset gitlink-cli_linux_amd64.tar.gz -o dist/
-gitlink-cli release +download --owner Gitlink --repo forgeplus -i --archive zip -o dist/source.zip
-
# Preview release deletion before executing it
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --dry-run
```
@@ -636,22 +468,13 @@ gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --d
```bash
# List builds
-gitlink-cli ci +builds --owner Gitlink --repo forgeplus
+gitlink-cli ci +list --owner Gitlink --repo forgeplus
# View build log
-gitlink-cli ci +logs --owner Gitlink --repo forgeplus --build
+gitlink-cli ci +log --owner Gitlink --repo forgeplus -i
# Restart a build
-gitlink-cli ci +restart --owner Gitlink --repo forgeplus --build
-
-# Stop a build
-gitlink-cli ci +stop --owner Gitlink --repo forgeplus --build
-
-# Check CI authorization and safely toggle repository CI
-gitlink-cli ci +authorize --owner Gitlink --repo forgeplus
-gitlink-cli ci +activate --owner Gitlink --repo forgeplus --dry-run
-gitlink-cli ci +activate --owner Gitlink --repo forgeplus --yes
-gitlink-cli ci +deactivate --owner Gitlink --repo forgeplus --dry-run
+gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i
```
### Pipeline Operations
@@ -676,50 +499,6 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
```
-### Project Invite Management
-
-```bash
-# Generate an invite link
-gitlink-cli invite +generate --owner Gitlink --repo forgeplus --role developer --is-apply true
-
-# View invite link information
-gitlink-cli invite +show --owner Gitlink --repo forgeplus --invite-sign abc123
-
-# Accept an invite via link
-gitlink-cli invite +accept --owner Gitlink --repo forgeplus --invite-sign abc123
-
-# Join a project by invite code
-gitlink-cli invite +join --code ABCDEF --role developer
-
-# Quit a project
-gitlink-cli invite +quit --owner Gitlink --repo forgeplus
-```
-
-### Ignore File Templates
-
-```bash
-# List all available .gitignore templates
-gitlink-cli ignore +list
-
-# Filter templates by name
-gitlink-cli ignore +list --name Go
-```
-
-### User Statistics
-
-```bash
-# User profile and current account
-gitlink-cli user +me
-gitlink-cli user +info --login alice
-
-# Contribution and activity analytics
-gitlink-cli user +activity --login alice
-gitlink-cli user +headmap --login alice --year 2026
-gitlink-cli user +develop --login alice --start-time 1717200000 --end-time 1719800000
-gitlink-cli user +role --login alice
-gitlink-cli user +major --login alice
-```
-
### Search
```bash
@@ -728,32 +507,6 @@ gitlink-cli search +repos -k "machine learning"
# Search users
gitlink-cli search +users -k "zhangsan"
-
-# List recommended / featured projects
-gitlink-cli search +recommend
-```
-
-### 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
@@ -763,13 +516,10 @@ gitlink-cli profile +contribution --user zhangsan --year 2025
- `workflow +triage`
- `workflow +health`
- `workflow +pr-summary`
-- `workflow +review-context`
- `workflow +repo-report`
-- `workflow +release-notes`
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
-`workflow +release-notes` defaults to `markdown` when `--format` is omitted.
Examples:
@@ -834,9 +584,6 @@ gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30
# PR review summary by read-only GitLink fetch
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
-# PR review context bundle by read-only GitLink fetch
-gitlink-cli workflow +review-context --owner Gitlink --repo gitlink-cli --number 1 --format json
-
# PR review summary from a local JSON file
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
@@ -845,12 +592,6 @@ gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format ma
# Repository workflow report from a local JSON file
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
-
-# Release notes by read-only GitLink compare fetch
-gitlink-cli workflow +release-notes --owner Gitlink --repo gitlink-cli --from-ref v0.1.0 --to-ref master --version v0.2.0 --format markdown
-
-# Release notes from a local JSON file
-gitlink-cli workflow +release-notes --from shortcuts/workflow/testdata/release_notes.json --format json
```
Output formats:
@@ -865,35 +606,7 @@ Safety:
- They do not modify remote GitLink data.
- They do not depend on LLM APIs.
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
-- `workflow +review-context` bundles repository, PR, file, review, issue, and label context without remote writes.
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
-- `workflow +release-notes` reads compare data and renders release notes without creating releases or comments.
-
-### 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 --dry-run
-gitlink-cli dataset +delete-attachment --owner me --repo proj --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
@@ -924,8 +637,6 @@ gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
| `--repo` | Repository name | `--repo forgeplus` |
| `--format` | Output format (json/table/yaml; workflow also supports markdown) | `--format json` |
| `--debug` | Enable debug output | `--debug` |
-| `--lang` | Interface language (en/zh) | `--lang zh` |
-| `--jq` | Extract a value from the output by dot-separated path | `--jq data.issues.0.subject` |
**Automatic context resolution:** When running inside a git repository, `--owner` and `--repo` are automatically resolved from `git remote origin`.
@@ -953,18 +664,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, insights, etc.) |
-| `gitlink-issue` | Issue operations (create, update, close, batch update/delete, comment, etc.) |
+| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
| `gitlink-member` | Repository member and invite link management |
-| `gitlink-invite` | Project invite management (generate links, accept invites, join/quit projects) |
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
-| `gitlink-release` | Release management (create, edit, update, view, delete, asset download, 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.) |
@@ -1024,7 +734,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
@@ -1089,7 +799,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/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md).
+See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md).
## License
diff --git a/doc/changes/milestone-report-shortcut.md b/doc/changes/milestone-report-shortcut.md
new file mode 100644
index 00000000..f00619ed
--- /dev/null
+++ b/doc/changes/milestone-report-shortcut.md
@@ -0,0 +1,64 @@
+# milestone +report shortcut
+
+## Overview
+
+This change adds a new `gitlink-cli milestone +report` shortcut for repository
+maintainers and AI agents who need a quick milestone health summary before a
+release or iteration close-out.
+
+The command accepts either `--id` or `--name`, resolves the target milestone,
+collects all linked issues across pages, and outputs a structured report with:
+
+- milestone metadata and completion percentage
+- total/open/closed issue counts
+- close-readiness blockers and warnings
+- open issue breakdown by status, priority, assignee, and tag
+- sample open issues for recent activity, unassigned work, and commented threads
+
+## Why it matters
+
+The existing milestone shortcuts cover CRUD and status changes, but they do not
+help a maintainer answer practical questions such as:
+
+- Is this milestone ready to close?
+- How many open issues are still unassigned?
+- Which priorities or tags dominate the remaining work?
+- Which open issues should I inspect first?
+
+`milestone +report` turns those checks into one command and keeps the output
+machine-friendly for scripts and AI agents.
+
+## Pagination safeguard
+
+The report implementation now fetches milestone issues until the API-reported
+total is fully collected, instead of assuming the server always honors the
+requested `limit`.
+
+This avoids undercounting when the service caps each response page below the
+requested size. For filtered milestone issue views, the implementation uses the
+matching filtered totals (`opened_issues_count` / `closed_issues_count`) so it
+does not over-fetch extra pages.
+
+## Example commands
+
+```bash
+gitlink-cli milestone +report --owner Gitlink --repo forgeplus --id 2438 --sample-limit 3
+gitlink-cli milestone +report --owner Gitlink --repo forgeplus --name "v1.0"
+```
+
+## Files changed
+
+- `shortcuts/milestone/milestone.go`
+- `shortcuts/milestone/report.go`
+- `shortcuts/milestone/report_test.go`
+- `README.md`
+
+## Validation
+
+```bash
+go test ./shortcuts/milestone/...
+go test ./shortcuts/...
+go test ./...
+go build ./...
+go run . milestone +report --owner Gitlink --repo forgeplus --id 2438 --sample-limit 3 --format json
+```
diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go
index 5a4213f2..b834e03a 100644
--- a/shortcuts/milestone/milestone.go
+++ b/shortcuts/milestone/milestone.go
@@ -102,6 +102,7 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
+ newMilestoneReportShortcut(),
{
Name: "update",
Description: "Update a milestone",
diff --git a/shortcuts/milestone/report.go b/shortcuts/milestone/report.go
new file mode 100644
index 00000000..219dde9d
--- /dev/null
+++ b/shortcuts/milestone/report.go
@@ -0,0 +1,713 @@
+package milestone
+
+import (
+ "fmt"
+ "math"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gitlink-org/gitlink-cli/shortcuts/common"
+)
+
+const milestoneReportPageSize = 100
+
+var milestoneNow = time.Now
+
+type milestoneReport struct {
+ Repository string `json:"repository" yaml:"repository"`
+ Milestone milestoneReportMetadata `json:"milestone" yaml:"milestone"`
+ Summary milestoneReportSummary `json:"summary" yaml:"summary"`
+ Readiness milestoneReportReadiness `json:"readiness" yaml:"readiness"`
+ Breakdown milestoneReportBreakdown `json:"breakdown" yaml:"breakdown"`
+ Samples milestoneReportSamples `json:"samples" yaml:"samples"`
+}
+
+type milestoneReportMetadata struct {
+ ID int `json:"id" yaml:"id"`
+ Name string `json:"name" yaml:"name"`
+ Description string `json:"description,omitempty" yaml:"description,omitempty"`
+ Status string `json:"status" yaml:"status"`
+ DueDate string `json:"due_date,omitempty" yaml:"due_date,omitempty"`
+ CreatedAt string `json:"created_at,omitempty" yaml:"created_at,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty" yaml:"updated_at,omitempty"`
+ CompletionPct float64 `json:"completion_percent" yaml:"completion_percent"`
+}
+
+type milestoneReportSummary struct {
+ TotalIssues int `json:"total_issues" yaml:"total_issues"`
+ OpenIssues int `json:"open_issues" yaml:"open_issues"`
+ ClosedIssues int `json:"closed_issues" yaml:"closed_issues"`
+ UnassignedOpenIssues int `json:"unassigned_open_issues" yaml:"unassigned_open_issues"`
+ UntaggedOpenIssues int `json:"untagged_open_issues" yaml:"untagged_open_issues"`
+ CommentedOpenIssues int `json:"commented_open_issues" yaml:"commented_open_issues"`
+ Overdue bool `json:"overdue" yaml:"overdue"`
+ DaysUntilDue *int `json:"days_until_due,omitempty" yaml:"days_until_due,omitempty"`
+ SampleLimit int `json:"sample_limit" yaml:"sample_limit"`
+}
+
+type milestoneReportReadiness struct {
+ ReadyToClose bool `json:"ready_to_close" yaml:"ready_to_close"`
+ Blockers []string `json:"blockers,omitempty" yaml:"blockers,omitempty"`
+ Warnings []string `json:"warnings,omitempty" yaml:"warnings,omitempty"`
+}
+
+type milestoneReportBreakdown struct {
+ AllStatuses []milestoneCountItem `json:"all_statuses" yaml:"all_statuses"`
+ OpenPriorities []milestoneCountItem `json:"open_priorities" yaml:"open_priorities"`
+ OpenAssignees []milestoneCountItem `json:"open_assignees" yaml:"open_assignees"`
+ OpenTags []milestoneCountItem `json:"open_tags" yaml:"open_tags"`
+}
+
+type milestoneCountItem struct {
+ Name string `json:"name" yaml:"name"`
+ Count int `json:"count" yaml:"count"`
+}
+
+type milestoneReportSamples struct {
+ RecentOpenIssues []milestoneIssueSample `json:"recent_open_issues" yaml:"recent_open_issues"`
+ UnassignedOpenIssues []milestoneIssueSample `json:"unassigned_open_issues" yaml:"unassigned_open_issues"`
+ MostCommentedOpenIssues []milestoneIssueSample `json:"most_commented_open_issues" yaml:"most_commented_open_issues"`
+}
+
+type milestoneIssueSample struct {
+ Number int `json:"number" yaml:"number"`
+ DatabaseID int `json:"database_id" yaml:"database_id"`
+ Title string `json:"title" yaml:"title"`
+ Status string `json:"status,omitempty" yaml:"status,omitempty"`
+ Priority string `json:"priority,omitempty" yaml:"priority,omitempty"`
+ Author string `json:"author,omitempty" yaml:"author,omitempty"`
+ Assignees []string `json:"assignees,omitempty" yaml:"assignees,omitempty"`
+ Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"`
+ CommentCount int `json:"comment_count" yaml:"comment_count"`
+ UpdatedAt string `json:"updated_at,omitempty" yaml:"updated_at,omitempty"`
+ UpdatedAtUnix int64 `json:"-" yaml:"-"`
+}
+
+type milestoneIssuesPage struct {
+ Milestone map[string]interface{}
+ TotalIssues int
+ OpenIssues int
+ ClosedIssues int
+ Issues []map[string]interface{}
+}
+
+type milestoneListPage struct {
+ TotalCount int
+ Items []map[string]interface{}
+}
+
+func newMilestoneReportShortcut() *common.Shortcut {
+ return &common.Shortcut{
+ Name: "report",
+ Description: "Summarize milestone progress, open issue risks, and close readiness",
+ Flags: []common.Flag{
+ {Name: "id", Short: "i", Usage: "Milestone ID"},
+ {Name: "name", Short: "n", Usage: "Milestone name"},
+ {Name: "sample-limit", Usage: "Number of sample issues to include in each section", Default: "5"},
+ },
+ Run: func(ctx *common.RuntimeContext) error {
+ if err := ctx.ResolveOwnerRepo(); err != nil {
+ return err
+ }
+ report, err := generateMilestoneReport(ctx, ctx.Arg("id"), ctx.Arg("name"), ctx.Arg("sample-limit"))
+ if err != nil {
+ return err
+ }
+ return ctx.OutputData(report)
+ },
+ }
+}
+
+func generateMilestoneReport(ctx *common.RuntimeContext, id, name, sampleLimitArg string) (*milestoneReport, error) {
+ if strings.TrimSpace(id) != "" && strings.TrimSpace(name) != "" {
+ return nil, fmt.Errorf("use either --id or --name, not both")
+ }
+ if strings.TrimSpace(id) == "" && strings.TrimSpace(name) == "" {
+ return nil, fmt.Errorf("required flag --id or --name is missing")
+ }
+
+ sampleLimit, err := parseMilestoneReportSampleLimit(sampleLimitArg)
+ if err != nil {
+ return nil, err
+ }
+
+ milestoneID := strings.TrimSpace(id)
+ if milestoneID == "" {
+ resolvedID, err := resolveMilestoneIDByName(ctx, strings.TrimSpace(name))
+ if err != nil {
+ return nil, err
+ }
+ milestoneID = strconv.Itoa(resolvedID)
+ }
+
+ allPage, err := fetchMilestoneIssuesPage(ctx, milestoneID, "", 1, milestoneReportPageSize)
+ if err != nil {
+ return nil, fmt.Errorf("fetch milestone report: %w", err)
+ }
+ allIssues, err := fetchAllMilestoneIssues(ctx, milestoneID, "")
+ if err != nil {
+ return nil, fmt.Errorf("fetch milestone issues: %w", err)
+ }
+ openIssues, err := fetchAllMilestoneIssues(ctx, milestoneID, "opened")
+ if err != nil {
+ return nil, fmt.Errorf("fetch open milestone issues: %w", err)
+ }
+
+ meta := buildMilestoneReportMetadata(allPage.Milestone)
+ report := &milestoneReport{
+ Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
+ Milestone: meta,
+ Summary: buildMilestoneReportSummary(
+ meta,
+ allPage.TotalIssues,
+ allPage.OpenIssues,
+ allPage.ClosedIssues,
+ openIssues,
+ sampleLimit,
+ ),
+ Breakdown: buildMilestoneReportBreakdown(allIssues, openIssues),
+ Samples: buildMilestoneReportSamples(openIssues, sampleLimit),
+ }
+ report.Readiness = buildMilestoneReportReadiness(report.Milestone, report.Summary)
+ return report, nil
+}
+
+func parseMilestoneReportSampleLimit(value string) (int, error) {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return 5, nil
+ }
+ limit, err := strconv.Atoi(trimmed)
+ if err != nil || limit <= 0 {
+ return 0, fmt.Errorf("--sample-limit must be a positive integer")
+ }
+ return limit, nil
+}
+
+func resolveMilestoneIDByName(ctx *common.RuntimeContext, name string) (int, error) {
+ milestones, err := fetchAllMilestones(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("resolve milestone by name: %w", err)
+ }
+ exact := []map[string]interface{}{}
+ fuzzy := []map[string]interface{}{}
+ target := strings.ToLower(strings.TrimSpace(name))
+ for _, milestone := range milestones {
+ milestoneName := strings.TrimSpace(stringValue(milestone["name"]))
+ if milestoneName == "" {
+ continue
+ }
+ lowerName := strings.ToLower(milestoneName)
+ switch {
+ case lowerName == target:
+ exact = append(exact, milestone)
+ case strings.Contains(lowerName, target):
+ fuzzy = append(fuzzy, milestone)
+ }
+ }
+ switch {
+ case len(exact) == 1:
+ return intValue(exact[0]["id"]), nil
+ case len(exact) > 1:
+ return 0, fmt.Errorf("milestone name %q is ambiguous: %s", name, joinMilestoneMatches(exact))
+ case len(fuzzy) == 1:
+ return intValue(fuzzy[0]["id"]), nil
+ case len(fuzzy) > 1:
+ return 0, fmt.Errorf("milestone name %q matched multiple milestones: %s", name, joinMilestoneMatches(fuzzy))
+ default:
+ return 0, fmt.Errorf("milestone %q not found", name)
+ }
+}
+
+func fetchAllMilestones(ctx *common.RuntimeContext) ([]map[string]interface{}, error) {
+ all := []map[string]interface{}{}
+ for page := 1; ; page++ {
+ pageData, err := fetchMilestoneListPage(ctx, page, milestoneReportPageSize)
+ if err != nil {
+ return nil, err
+ }
+ if len(pageData.Items) == 0 {
+ break
+ }
+ all = append(all, pageData.Items...)
+ if pageData.TotalCount > 0 {
+ if len(all) >= pageData.TotalCount {
+ break
+ }
+ continue
+ }
+ if len(pageData.Items) < milestoneReportPageSize {
+ break
+ }
+ }
+ return all, nil
+}
+
+func fetchMilestoneListPage(ctx *common.RuntimeContext, page, limit int) (*milestoneListPage, error) {
+ q := url.Values{}
+ q.Set("page", strconv.Itoa(page))
+ q.Set("limit", strconv.Itoa(limit))
+ env, err := ctx.CallAPIWithQuery("GET", milestonePath(ctx), q)
+ if err != nil {
+ return nil, err
+ }
+ data, ok := env.Data.(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("milestone list response did not contain an object")
+ }
+ return &milestoneListPage{
+ TotalCount: firstMilestoneInt(data, "total_count"),
+ Items: objectSlice(data["milestones"]),
+ }, nil
+}
+
+func fetchAllMilestoneIssues(ctx *common.RuntimeContext, id, category string) ([]map[string]interface{}, error) {
+ all := []map[string]interface{}{}
+ for page := 1; ; page++ {
+ pageData, err := fetchMilestoneIssuesPage(ctx, id, category, page, milestoneReportPageSize)
+ if err != nil {
+ return nil, err
+ }
+ if len(pageData.Issues) == 0 {
+ break
+ }
+ all = append(all, pageData.Issues...)
+ targetTotal := milestoneIssuesExpectedTotal(pageData, category)
+ if targetTotal > 0 {
+ if len(all) >= targetTotal {
+ break
+ }
+ continue
+ }
+ if len(pageData.Issues) < milestoneReportPageSize {
+ break
+ }
+ }
+ return all, nil
+}
+
+func milestoneIssuesExpectedTotal(pageData *milestoneIssuesPage, category string) int {
+ switch strings.ToLower(strings.TrimSpace(category)) {
+ case "opened", "open", "opening":
+ if pageData.OpenIssues > 0 {
+ return pageData.OpenIssues
+ }
+ case "closed", "close":
+ if pageData.ClosedIssues > 0 {
+ return pageData.ClosedIssues
+ }
+ }
+ return pageData.TotalIssues
+}
+
+func fetchMilestoneIssuesPage(ctx *common.RuntimeContext, id, category string, page, limit int) (*milestoneIssuesPage, error) {
+ q := url.Values{}
+ q.Set("page", strconv.Itoa(page))
+ q.Set("limit", strconv.Itoa(limit))
+ setQueryIfPresent(q, "category", category)
+
+ env, err := ctx.CallAPIWithQuery("GET", milestoneItemPath(ctx, id), q)
+ if err != nil {
+ return nil, err
+ }
+ data, ok := env.Data.(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("milestone view response did not contain an object")
+ }
+
+ return &milestoneIssuesPage{
+ Milestone: objectValue(data["milestone"]),
+ TotalIssues: firstMilestoneInt(data, "total_issues_count", "total_count"),
+ OpenIssues: firstMilestoneInt(data, "opened_issues_count", "open_issues_count"),
+ ClosedIssues: firstMilestoneInt(data, "closed_issues_count", "close_issues_count"),
+ Issues: objectSlice(data["issues"]),
+ }, nil
+}
+
+func buildMilestoneReportMetadata(milestone map[string]interface{}) milestoneReportMetadata {
+ return milestoneReportMetadata{
+ ID: intValue(milestone["id"]),
+ Name: stringValue(milestone["name"]),
+ Description: stringValue(milestone["description"]),
+ Status: stringValue(milestone["status"]),
+ DueDate: stringValue(milestone["effective_date"]),
+ CreatedAt: stringValue(milestone["created_at"]),
+ UpdatedAt: firstMilestoneString(milestone, "updated_on", "updated_at"),
+ CompletionPct: roundMilestonePercent(floatValue(milestone["percent"])),
+ }
+}
+
+func buildMilestoneReportSummary(meta milestoneReportMetadata, totalIssues, openIssues, closedIssues int, openItems []map[string]interface{}, sampleLimit int) milestoneReportSummary {
+ unassigned := 0
+ untagged := 0
+ commented := 0
+ for _, issue := range openItems {
+ if len(issueUserNames(issue["assigners"])) == 0 {
+ unassigned++
+ }
+ if len(issueTagNames(issue["tags"])) == 0 {
+ untagged++
+ }
+ if intValue(issue["comment_journals_count"]) > 0 {
+ commented++
+ }
+ }
+
+ summary := milestoneReportSummary{
+ TotalIssues: totalIssues,
+ OpenIssues: openIssues,
+ ClosedIssues: closedIssues,
+ UnassignedOpenIssues: unassigned,
+ UntaggedOpenIssues: untagged,
+ CommentedOpenIssues: commented,
+ SampleLimit: sampleLimit,
+ }
+
+ if meta.DueDate != "" {
+ if dueDate := parseMilestoneTime(meta.DueDate); !dueDate.IsZero() {
+ days := milestoneDaysUntil(dueDate)
+ summary.DaysUntilDue = &days
+ summary.Overdue = days < 0
+ }
+ }
+ return summary
+}
+
+func buildMilestoneReportReadiness(meta milestoneReportMetadata, summary milestoneReportSummary) milestoneReportReadiness {
+ readiness := milestoneReportReadiness{}
+ if strings.EqualFold(meta.Status, "closed") {
+ readiness.ReadyToClose = true
+ return readiness
+ }
+ if summary.OpenIssues == 0 {
+ readiness.ReadyToClose = true
+ } else {
+ readiness.Blockers = append(readiness.Blockers, fmt.Sprintf("%d open issues remain", summary.OpenIssues))
+ }
+ if summary.UnassignedOpenIssues > 0 {
+ readiness.Warnings = append(readiness.Warnings, fmt.Sprintf("%d open issues have no assignee", summary.UnassignedOpenIssues))
+ }
+ if summary.UntaggedOpenIssues > 0 {
+ readiness.Warnings = append(readiness.Warnings, fmt.Sprintf("%d open issues have no tags", summary.UntaggedOpenIssues))
+ }
+ if meta.DueDate == "" {
+ readiness.Warnings = append(readiness.Warnings, "milestone has no due date")
+ } else if summary.Overdue && summary.DaysUntilDue != nil {
+ readiness.Warnings = append(readiness.Warnings, fmt.Sprintf("milestone is overdue by %d days", -(*summary.DaysUntilDue)))
+ }
+ return readiness
+}
+
+func buildMilestoneReportBreakdown(allIssues, openIssues []map[string]interface{}) milestoneReportBreakdown {
+ return milestoneReportBreakdown{
+ AllStatuses: countSorted(allIssues, issueStatusName),
+ OpenPriorities: countSorted(openIssues, issuePriorityName),
+ OpenAssignees: countSortedMulti(openIssues, func(item map[string]interface{}) []string { return issueUserNames(item["assigners"]) }),
+ OpenTags: countSortedMulti(openIssues, func(item map[string]interface{}) []string { return issueTagNames(item["tags"]) }),
+ }
+}
+
+func buildMilestoneReportSamples(openIssues []map[string]interface{}, sampleLimit int) milestoneReportSamples {
+ recent := buildIssueSamples(openIssues, sampleLimit, func(left, right milestoneIssueSample) bool {
+ if left.UpdatedAtUnix != right.UpdatedAtUnix {
+ return left.UpdatedAtUnix > right.UpdatedAtUnix
+ }
+ return left.Number < right.Number
+ })
+ unassigned := buildIssueSamples(filterMilestoneIssues(openIssues, func(item map[string]interface{}) bool {
+ return len(issueUserNames(item["assigners"])) == 0
+ }), sampleLimit, func(left, right milestoneIssueSample) bool {
+ if left.UpdatedAtUnix != right.UpdatedAtUnix {
+ return left.UpdatedAtUnix > right.UpdatedAtUnix
+ }
+ return left.Number < right.Number
+ })
+ commented := buildIssueSamples(filterMilestoneIssues(openIssues, func(item map[string]interface{}) bool {
+ return intValue(item["comment_journals_count"]) > 0
+ }), sampleLimit, func(left, right milestoneIssueSample) bool {
+ if left.CommentCount != right.CommentCount {
+ return left.CommentCount > right.CommentCount
+ }
+ if left.UpdatedAtUnix != right.UpdatedAtUnix {
+ return left.UpdatedAtUnix > right.UpdatedAtUnix
+ }
+ return left.Number < right.Number
+ })
+
+ return milestoneReportSamples{
+ RecentOpenIssues: recent,
+ UnassignedOpenIssues: unassigned,
+ MostCommentedOpenIssues: commented,
+ }
+}
+
+func buildIssueSamples(items []map[string]interface{}, sampleLimit int, less func(left, right milestoneIssueSample) bool) []milestoneIssueSample {
+ samples := make([]milestoneIssueSample, 0, len(items))
+ for _, item := range items {
+ samples = append(samples, normalizeMilestoneIssueSample(item))
+ }
+ sort.Slice(samples, func(i, j int) bool {
+ return less(samples[i], samples[j])
+ })
+ if len(samples) > sampleLimit {
+ samples = samples[:sampleLimit]
+ }
+ for i := range samples {
+ samples[i].UpdatedAtUnix = 0
+ }
+ return samples
+}
+
+func normalizeMilestoneIssueSample(item map[string]interface{}) milestoneIssueSample {
+ updatedAt := firstMilestoneString(item, "updated_at", "updated_on")
+ updatedAtUnix := int64(0)
+ if parsed := parseMilestoneTime(updatedAt); !parsed.IsZero() {
+ updatedAtUnix = parsed.Unix()
+ }
+ return milestoneIssueSample{
+ Number: firstMilestoneInt(item, "project_issues_index", "number"),
+ DatabaseID: intValue(item["id"]),
+ Title: firstMilestoneString(item, "subject", "title"),
+ Status: issueStatusName(item),
+ Priority: issuePriorityName(item),
+ Author: userDisplayName(objectValue(item["author"])),
+ Assignees: issueUserNames(item["assigners"]),
+ Tags: issueTagNames(item["tags"]),
+ CommentCount: intValue(item["comment_journals_count"]),
+ UpdatedAt: updatedAt,
+ UpdatedAtUnix: updatedAtUnix,
+ }
+}
+
+func filterMilestoneIssues(items []map[string]interface{}, keep func(item map[string]interface{}) bool) []map[string]interface{} {
+ filtered := make([]map[string]interface{}, 0, len(items))
+ for _, item := range items {
+ if keep(item) {
+ filtered = append(filtered, item)
+ }
+ }
+ return filtered
+}
+
+func countSorted(items []map[string]interface{}, name func(item map[string]interface{}) string) []milestoneCountItem {
+ counts := map[string]int{}
+ for _, item := range items {
+ key := strings.TrimSpace(name(item))
+ if key == "" {
+ key = "Unspecified"
+ }
+ counts[key]++
+ }
+ return sortMilestoneCounts(counts)
+}
+
+func countSortedMulti(items []map[string]interface{}, names func(item map[string]interface{}) []string) []milestoneCountItem {
+ counts := map[string]int{}
+ for _, item := range items {
+ values := names(item)
+ if len(values) == 0 {
+ counts["Unspecified"]++
+ continue
+ }
+ for _, value := range values {
+ key := strings.TrimSpace(value)
+ if key == "" {
+ key = "Unspecified"
+ }
+ counts[key]++
+ }
+ }
+ return sortMilestoneCounts(counts)
+}
+
+func sortMilestoneCounts(counts map[string]int) []milestoneCountItem {
+ items := make([]milestoneCountItem, 0, len(counts))
+ for name, count := range counts {
+ items = append(items, milestoneCountItem{Name: name, Count: count})
+ }
+ sort.Slice(items, func(i, j int) bool {
+ if items[i].Count != items[j].Count {
+ return items[i].Count > items[j].Count
+ }
+ return items[i].Name < items[j].Name
+ })
+ return items
+}
+
+func issueStatusName(item map[string]interface{}) string {
+ if name := firstMilestoneString(item, "status_name"); name != "" {
+ return name
+ }
+ return firstMilestoneString(objectValue(item["status"]), "name")
+}
+
+func issuePriorityName(item map[string]interface{}) string {
+ if name := firstMilestoneString(item, "priority_name"); name != "" {
+ return name
+ }
+ return firstMilestoneString(objectValue(item["priority"]), "name")
+}
+
+func issueUserNames(value interface{}) []string {
+ users := objectSlice(value)
+ names := make([]string, 0, len(users))
+ for _, user := range users {
+ if name := userDisplayName(user); name != "" {
+ names = append(names, name)
+ }
+ }
+ return names
+}
+
+func userDisplayName(user map[string]interface{}) string {
+ if name := strings.TrimSpace(stringValue(user["name"])); name != "" {
+ return name
+ }
+ return strings.TrimSpace(stringValue(user["login"]))
+}
+
+func issueTagNames(value interface{}) []string {
+ tags := objectSlice(value)
+ names := make([]string, 0, len(tags))
+ for _, tag := range tags {
+ if name := strings.TrimSpace(stringValue(tag["name"])); name != "" {
+ names = append(names, name)
+ }
+ }
+ return names
+}
+
+func milestoneDaysUntil(dueDate time.Time) int {
+ now := milestoneNow()
+ nowDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ due := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, now.Location())
+ return int(math.Round(due.Sub(nowDate).Hours() / 24))
+}
+
+func parseMilestoneTime(value string) time.Time {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return time.Time{}
+ }
+ for _, layout := range []string{
+ time.RFC3339,
+ time.RFC3339Nano,
+ "2006-01-02 15:04:05",
+ "2006-01-02 15:04",
+ "2006-01-02",
+ } {
+ if parsed, err := time.ParseInLocation(layout, trimmed, time.Local); err == nil {
+ return parsed
+ }
+ }
+ return time.Time{}
+}
+
+func roundMilestonePercent(value float64) float64 {
+ return math.Round(value*10000) / 100
+}
+
+func joinMilestoneMatches(items []map[string]interface{}) string {
+ parts := make([]string, 0, len(items))
+ for _, item := range items {
+ parts = append(parts, fmt.Sprintf("%d:%s", intValue(item["id"]), stringValue(item["name"])))
+ }
+ sort.Strings(parts)
+ return strings.Join(parts, ", ")
+}
+
+func firstMilestoneString(item map[string]interface{}, keys ...string) string {
+ for _, key := range keys {
+ if value := strings.TrimSpace(stringValue(item[key])); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func firstMilestoneInt(item map[string]interface{}, keys ...string) int {
+ for _, key := range keys {
+ if value, ok := item[key]; ok {
+ return intValue(value)
+ }
+ }
+ return 0
+}
+
+func objectValue(value interface{}) map[string]interface{} {
+ item, _ := value.(map[string]interface{})
+ return item
+}
+
+func objectSlice(value interface{}) []map[string]interface{} {
+ switch typed := value.(type) {
+ case []map[string]interface{}:
+ return append([]map[string]interface{}(nil), typed...)
+ case []interface{}:
+ items := make([]map[string]interface{}, 0, len(typed))
+ for _, raw := range typed {
+ if item, ok := raw.(map[string]interface{}); ok {
+ items = append(items, item)
+ }
+ }
+ return items
+ default:
+ return nil
+ }
+}
+
+func stringValue(value interface{}) string {
+ switch typed := value.(type) {
+ case string:
+ return typed
+ case fmt.Stringer:
+ return typed.String()
+ case nil:
+ return ""
+ default:
+ return fmt.Sprintf("%v", typed)
+ }
+}
+
+func intValue(value interface{}) int {
+ switch typed := value.(type) {
+ case int:
+ return typed
+ case int32:
+ return int(typed)
+ case int64:
+ return int(typed)
+ case float64:
+ return int(typed)
+ case float32:
+ return int(typed)
+ case string:
+ n, _ := strconv.Atoi(strings.TrimSpace(typed))
+ return n
+ default:
+ return 0
+ }
+}
+
+func floatValue(value interface{}) float64 {
+ switch typed := value.(type) {
+ case float64:
+ return typed
+ case float32:
+ return float64(typed)
+ case int:
+ return float64(typed)
+ case int32:
+ return float64(typed)
+ case int64:
+ return float64(typed)
+ case string:
+ n, _ := strconv.ParseFloat(strings.TrimSpace(typed), 64)
+ return n
+ default:
+ return 0
+ }
+}
diff --git a/shortcuts/milestone/report_test.go b/shortcuts/milestone/report_test.go
new file mode 100644
index 00000000..0afaa889
--- /dev/null
+++ b/shortcuts/milestone/report_test.go
@@ -0,0 +1,369 @@
+package milestone
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gitlink-org/gitlink-cli/internal/client"
+ "github.com/gitlink-org/gitlink-cli/shortcuts/common"
+)
+
+func TestGenerateMilestoneReportByID(t *testing.T) {
+ oldNow := milestoneNow
+ milestoneNow = func() time.Time {
+ return time.Date(2026, 6, 11, 10, 0, 0, 0, time.Local)
+ }
+ defer func() { milestoneNow = oldNow }()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/milestones/7.json" {
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+
+ page := r.URL.Query().Get("page")
+ if page == "" {
+ page = "1"
+ }
+
+ switch r.URL.Query().Get("category") {
+ case "":
+ if page != "1" {
+ t.Fatalf("unexpected extra all-issues page request: %s", page)
+ }
+ writeJSON(t, w, map[string]interface{}{
+ "milestone": map[string]interface{}{
+ "id": 7,
+ "name": "v1.0",
+ "description": "first release",
+ "effective_date": "2026-06-09",
+ "status": "open",
+ "created_at": "2026-05-01 09:00",
+ "updated_on": "2026-06-10 08:30",
+ "percent": 0.6666667,
+ "issues_count": 3,
+ "opened_issues_count": 2,
+ "close_issues_count": 1,
+ },
+ "total_issues_count": 3,
+ "opened_issues_count": 2,
+ "closed_issues_count": 1,
+ "issues": []interface{}{
+ map[string]interface{}{
+ "id": 101,
+ "project_issues_index": 11,
+ "subject": "login failed",
+ "status_name": "Open",
+ "priority_name": "High",
+ "author": map[string]interface{}{"name": "Alice"},
+ "assigners": []interface{}{map[string]interface{}{"name": "Bob"}},
+ "tags": []interface{}{map[string]interface{}{"name": "bug"}},
+ "comment_journals_count": 2,
+ "updated_at": "2026-06-10 09:30",
+ },
+ map[string]interface{}{
+ "id": 102,
+ "project_issues_index": 12,
+ "subject": "docs cleanup",
+ "status_name": "Open",
+ "priority_name": "Normal",
+ "author": map[string]interface{}{"login": "carol"},
+ "assigners": []interface{}{},
+ "tags": []interface{}{},
+ "comment_journals_count": 0,
+ "updated_at": "2026-06-08 08:00",
+ },
+ map[string]interface{}{
+ "id": 103,
+ "project_issues_index": 13,
+ "subject": "closed bug",
+ "status_name": "Closed",
+ "priority_name": "Normal",
+ "author": map[string]interface{}{"name": "Dave"},
+ "assigners": []interface{}{map[string]interface{}{"name": "Bob"}},
+ "tags": []interface{}{map[string]interface{}{"name": "cleanup"}},
+ "comment_journals_count": 1,
+ "updated_at": "2026-06-07 12:00",
+ },
+ },
+ })
+ case "opened":
+ if page != "1" {
+ t.Fatalf("opened issues should stop after first page, got page %s", page)
+ }
+ writeJSON(t, w, map[string]interface{}{
+ "milestone": map[string]interface{}{
+ "id": 7,
+ "name": "v1.0",
+ "effective_date": "2026-06-09",
+ "status": "open",
+ "percent": 0.6666667,
+ },
+ "total_issues_count": 3,
+ "opened_issues_count": 2,
+ "closed_issues_count": 1,
+ "issues": []interface{}{
+ map[string]interface{}{
+ "id": 101,
+ "project_issues_index": 11,
+ "subject": "login failed",
+ "status_name": "Open",
+ "priority_name": "High",
+ "author": map[string]interface{}{"name": "Alice"},
+ "assigners": []interface{}{map[string]interface{}{"name": "Bob"}},
+ "tags": []interface{}{map[string]interface{}{"name": "bug"}},
+ "comment_journals_count": 2,
+ "updated_at": "2026-06-10 09:30",
+ },
+ map[string]interface{}{
+ "id": 102,
+ "project_issues_index": 12,
+ "subject": "docs cleanup",
+ "status_name": "Open",
+ "priority_name": "Normal",
+ "author": map[string]interface{}{"login": "carol"},
+ "assigners": []interface{}{},
+ "tags": []interface{}{},
+ "comment_journals_count": 0,
+ "updated_at": "2026-06-08 08:00",
+ },
+ },
+ })
+ default:
+ t.Fatalf("unexpected category: %q", r.URL.Query().Get("category"))
+ }
+ }))
+ defer server.Close()
+
+ ctx := &common.RuntimeContext{
+ Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
+ Owner: "owner",
+ Repo: "repo",
+ }
+
+ report, err := generateMilestoneReport(ctx, "7", "", "2")
+ if err != nil {
+ t.Fatalf("generateMilestoneReport failed: %v", err)
+ }
+
+ assertEqual(t, report.Repository, "owner/repo")
+ assertEqual(t, report.Milestone.ID, 7)
+ assertEqual(t, report.Milestone.Name, "v1.0")
+ assertEqual(t, report.Milestone.CompletionPct, 66.67)
+ assertEqual(t, report.Summary.TotalIssues, 3)
+ assertEqual(t, report.Summary.OpenIssues, 2)
+ assertEqual(t, report.Summary.ClosedIssues, 1)
+ assertEqual(t, report.Summary.UnassignedOpenIssues, 1)
+ assertEqual(t, report.Summary.UntaggedOpenIssues, 1)
+ assertEqual(t, report.Summary.CommentedOpenIssues, 1)
+ if report.Summary.DaysUntilDue == nil || *report.Summary.DaysUntilDue != -2 {
+ t.Fatalf("unexpected days_until_due: %+v", report.Summary.DaysUntilDue)
+ }
+ if !report.Summary.Overdue {
+ t.Fatal("expected overdue milestone")
+ }
+ if report.Readiness.ReadyToClose {
+ t.Fatal("milestone should not be ready to close")
+ }
+ if len(report.Readiness.Blockers) != 1 || !strings.Contains(report.Readiness.Blockers[0], "2 open issues remain") {
+ t.Fatalf("unexpected blockers: %+v", report.Readiness.Blockers)
+ }
+ if len(report.Readiness.Warnings) != 3 {
+ t.Fatalf("unexpected warnings: %+v", report.Readiness.Warnings)
+ }
+ assertEqual(t, report.Breakdown.AllStatuses[0].Name, "Open")
+ assertEqual(t, report.Breakdown.AllStatuses[0].Count, 2)
+ assertEqual(t, report.Breakdown.OpenPriorities[0].Name, "High")
+ assertEqual(t, report.Breakdown.OpenPriorities[0].Count, 1)
+ if len(report.Samples.RecentOpenIssues) != 2 || report.Samples.RecentOpenIssues[0].Number != 11 {
+ t.Fatalf("unexpected recent_open_issues: %+v", report.Samples.RecentOpenIssues)
+ }
+ if len(report.Samples.UnassignedOpenIssues) != 1 || report.Samples.UnassignedOpenIssues[0].Number != 12 {
+ t.Fatalf("unexpected unassigned_open_issues: %+v", report.Samples.UnassignedOpenIssues)
+ }
+ if len(report.Samples.MostCommentedOpenIssues) != 1 || report.Samples.MostCommentedOpenIssues[0].Number != 11 {
+ t.Fatalf("unexpected most_commented_open_issues: %+v", report.Samples.MostCommentedOpenIssues)
+ }
+}
+
+func TestGenerateMilestoneReportByNameResolvesExactMatch(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/v1/owner/repo/milestones.json":
+ writeJSON(t, w, map[string]interface{}{
+ "total_count": 2,
+ "milestones": []interface{}{
+ map[string]interface{}{"id": 7, "name": "v1.0"},
+ map[string]interface{}{"id": 8, "name": "v2.0"},
+ },
+ })
+ case "/v1/owner/repo/milestones/7.json":
+ writeJSON(t, w, map[string]interface{}{
+ "milestone": map[string]interface{}{
+ "id": 7,
+ "name": "v1.0",
+ "effective_date": "2026-07-01",
+ "status": "open",
+ "percent": 1,
+ },
+ "total_issues_count": 0,
+ "opened_issues_count": 0,
+ "closed_issues_count": 0,
+ "issues": []interface{}{},
+ })
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ ctx := &common.RuntimeContext{
+ Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
+ Owner: "owner",
+ Repo: "repo",
+ }
+
+ report, err := generateMilestoneReport(ctx, "", "v1.0", "3")
+ if err != nil {
+ t.Fatalf("generateMilestoneReport by name failed: %v", err)
+ }
+ assertEqual(t, report.Milestone.ID, 7)
+ assertEqual(t, report.Milestone.Name, "v1.0")
+ if !report.Readiness.ReadyToClose {
+ t.Fatal("milestone with zero open issues should be ready to close")
+ }
+}
+
+func TestGenerateMilestoneReportByNameAmbiguous(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/owner/repo/milestones.json" {
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ writeJSON(t, w, map[string]interface{}{
+ "total_count": 2,
+ "milestones": []interface{}{
+ map[string]interface{}{"id": 7, "name": "release-v1"},
+ map[string]interface{}{"id": 8, "name": "release-v1-hotfix"},
+ },
+ })
+ }))
+ defer server.Close()
+
+ ctx := &common.RuntimeContext{
+ Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
+ Owner: "owner",
+ Repo: "repo",
+ }
+
+ _, err := generateMilestoneReport(ctx, "", "release", "5")
+ if err == nil || !strings.Contains(err.Error(), "matched multiple") {
+ t.Fatalf("expected ambiguous milestone error, got %v", err)
+ }
+}
+
+func TestFetchAllMilestoneIssuesFollowsReportedTotalAcrossPages(t *testing.T) {
+ pages := []string{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/milestones/7.json" {
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ if r.URL.Query().Get("category") != "" {
+ t.Fatalf("unexpected category: %q", r.URL.Query().Get("category"))
+ }
+
+ page := r.URL.Query().Get("page")
+ pages = append(pages, page)
+ switch page {
+ case "1":
+ writeJSON(t, w, map[string]interface{}{
+ "milestone": map[string]interface{}{"id": 7, "name": "v1.0"},
+ "total_issues_count": 3,
+ "opened_issues_count": 2,
+ "closed_issues_count": 1,
+ "issues": []interface{}{
+ map[string]interface{}{"id": 101, "subject": "one"},
+ map[string]interface{}{"id": 102, "subject": "two"},
+ },
+ })
+ case "2":
+ writeJSON(t, w, map[string]interface{}{
+ "milestone": map[string]interface{}{"id": 7, "name": "v1.0"},
+ "total_issues_count": 3,
+ "opened_issues_count": 2,
+ "closed_issues_count": 1,
+ "issues": []interface{}{
+ map[string]interface{}{"id": 103, "subject": "three"},
+ },
+ })
+ default:
+ t.Fatalf("unexpected page request: %s", page)
+ }
+ }))
+ defer server.Close()
+
+ ctx := &common.RuntimeContext{
+ Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
+ Owner: "owner",
+ Repo: "repo",
+ }
+
+ issues, err := fetchAllMilestoneIssues(ctx, "7", "")
+ if err != nil {
+ t.Fatalf("fetchAllMilestoneIssues failed: %v", err)
+ }
+ if len(issues) != 3 {
+ t.Fatalf("expected 3 issues, got %d", len(issues))
+ }
+ if strings.Join(pages, ",") != "1,2" {
+ t.Fatalf("unexpected pages fetched: %v", pages)
+ }
+}
+
+func TestMilestoneReportShortcutRequiresSelector(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatalf("report shortcut should fail before API call: %s %s", r.Method, r.URL.Path)
+ }))
+ defer server.Close()
+
+ err := runMilestoneShortcut(t, server, "report", map[string]string{})
+ if err == nil {
+ t.Fatal("expected missing selector error")
+ }
+}
+
+func TestMilestoneReportShortcutRuns(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/milestones/7.json" {
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ writeJSON(t, w, map[string]interface{}{
+ "milestone": map[string]interface{}{
+ "id": 7,
+ "name": "v1.0",
+ "effective_date": "2026-07-01",
+ "status": "open",
+ "percent": 1,
+ },
+ "total_issues_count": 0,
+ "opened_issues_count": 0,
+ "closed_issues_count": 0,
+ "issues": []interface{}{},
+ })
+ }))
+ defer server.Close()
+
+ if err := runMilestoneShortcut(t, server, "report", map[string]string{"id": "7", "sample-limit": "2"}); err != nil {
+ t.Fatalf("report shortcut failed: %v", err)
+ }
+}
+
+func TestParseMilestoneReportSampleLimit(t *testing.T) {
+ if _, err := parseMilestoneReportSampleLimit("0"); err == nil {
+ t.Fatal("expected validation error for zero sample-limit")
+ }
+ if got, err := parseMilestoneReportSampleLimit("3"); err != nil || got != 3 {
+ t.Fatalf("parseMilestoneReportSampleLimit = %d, %v; want 3, nil", got, err)
+ }
+}