diff --git a/README.md b/README.md index a1466779..58cb831c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/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
jiangtx -
- luwanzhou -
luwanzhou -
-
- whale_hihihi -
whale_hihihi -
## 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) + } +}