diff --git a/README.md b/README.md index e44e23b..2d0b398 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,7 +96,7 @@ 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 | | 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | | 👥 Member | List, add, remove repository members, change roles, create and accept invite links | @@ -114,14 +106,9 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🔧 CI | View builds, logs, CI/CD operations | | ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs | | 🔔 Webhook | Manage repo webhooks and test deliveries | -| 🔔 Notification | List messages, mark read, delete messages, send @ mentions | -| 📖 Wiki | List, view, create, update, and delete wiki pages | | 🔍 Search | Search repositories, users | -| 📊 Dataset | Query research datasets by project | | 👤 User | View user profiles and info | -| 📊 Profile | User ability, role, major, activity, and contribution statistics | | 📋 PM | Sprint management, kanban boards, weekly reports | -| 📝 Template | Manage project templates for issues and pull requests | | 🤖 Workflow | AI-powered issue triage, PR review, release notes | ## Installation & Quick Start @@ -276,52 +263,6 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68 ``` -### Notification Management - -```bash -# List unread notifications for the authenticated user -gitlink-cli notification +list --status unread --limit 20 - -# List @ mention messages for a specific user -gitlink-cli notification +list --user zhangsan --type atme --status unread - -# Preview and mark selected messages as read -gitlink-cli notification +read --ids 740214,740213 --dry-run -gitlink-cli notification +read --ids 740214,740213 --yes - -# Preview and mark all unread system notifications as read -gitlink-cli notification +read --type notification --all-unread --dry-run - -# Preview and delete selected messages -gitlink-cli notification +delete --ids 740214,740213 --dry-run - -# Send an @ mention message for an Issue, PR, or Journal target -gitlink-cli notification +send-atme --receivers alice,bob \ - --atmeable-type Issue --atmeable-id 123 --dry-run -``` - -### 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 @@ -353,6 +294,9 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus # Create an issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..." +# Create an issue from a Markdown file +gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" --body-file issue.md + # Create an issue with metadata gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" --priority-id 3 --tag-ids 4,5 --assigner-ids 7 @@ -362,41 +306,32 @@ gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 # Update issue metadata gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15 +# Update issue description from a file +gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --body-file update.md + # Close an issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 # Preview batch close without changing data gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run +# Preview a shared batch update +gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --numbers 123,124 --state closed --priority-id 4 --dry-run + +# Batch update issues from a CSV file +gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --from issues.csv --assigner-ids 7 --due-date 2026-06-15 + # 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 - -# Export filtered issues to CSV for offline triage or reports -gitlink-cli issue +export --owner Gitlink --repo forgeplus --state open --keyword bug --export-format csv --output issues.csv - # Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" -# Reply to a comment with attachments and mentions -gitlink-cli issue +comment --owner Gitlink --repo forgeplus --number 123 -b "Thanks, please check the log" --parent-id 456 --reply-id 456 --attachment-ids 7,8 --receivers alice,bob +# Add a comment from a file +gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 --body-file comment.md -# List comments only, or include operation records with --category all -gitlink-cli issue +comments --owner Gitlink --repo forgeplus --number 123 --category comment --keyword fixed - -# Update or delete a comment -gitlink-cli issue +comment-update --owner Gitlink --repo forgeplus --number 123 --comment-id 456 -b "Updated comment" -gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus --number 123 --comment-id 456 - -# List replies under a comment -gitlink-cli issue +comment-replies --owner Gitlink --repo forgeplus --number 123 --comment-id 456 +# Batch comment on issues +gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus --numbers 123,124 --body-file comment.md --dry-run # List issue assigners gitlink-cli issue +assigners --owner Gitlink --repo forgeplus @@ -438,30 +373,6 @@ gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00" gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 ``` -### Template Management - -```bash -# List project templates -gitlink-cli template +list --owner Gitlink --repo forgeplus - -# Get template details -gitlink-cli template +get --owner Gitlink --repo forgeplus -i 1 - -# Create an issue template -gitlink-cli template +create --owner Gitlink --repo forgeplus \ - -t "ProjectTemplates::Issue" -n "Bug Report" -c "## Description\n..." - -# Create a pull request template -gitlink-cli template +create --owner Gitlink --repo forgeplus \ - -t "ProjectTemplates::PullRequest" -n "Feature PR" -c "## Summary\n..." - -# Update a template -gitlink-cli template +update --owner Gitlink --repo forgeplus -i 1 -n "Updated Name" - -# Delete a template -gitlink-cli template +delete --owner Gitlink --repo forgeplus -i 1 -``` - ### Pull Requests ```bash @@ -552,49 +463,6 @@ gitlink-cli ci +log --owner Gitlink --repo forgeplus -i gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i ``` -### Health Diagnostics - -`gitlink health diagnose` analyzes repository health across 5 dimensions: Documentation, License, Community, Maturity, and CI/CD. - -```bash -# Basic diagnosis (text output) -gitlink-cli health diagnose --owner Gitlink --repo gitlink-cli - -# JSON output for scripts and AI Agents -gitlink-cli health diagnose --owner Gitlink --repo gitlink-cli --format json - -# Markdown output for reports -gitlink-cli health diagnose --owner Gitlink --repo gitlink-cli --format markdown - -# Verbose mode with detailed breakdown -gitlink-cli health diagnose --owner Gitlink --repo gitlink-cli --verbose - -# Custom database path for historical tracking -gitlink-cli health diagnose --owner Gitlink --repo gitlink-cli --db ./health.db -``` - -**Scoring Dimensions (100 points total):** - -| Dimension | Weight | Criteria | -|-----------|--------|----------| -| Documentation | 20 | README quality, contributing guide, code of conduct | -| License | 15 | License presence and OSI approval | -| Community | 25 | Contributors, activity, bus factor | -| Maturity | 20 | Releases, version stability, age | -| CI/CD | 20 | Build success rate, pipeline configuration | - -**Health Status Thresholds:** - -- `good` (≥70%): Healthy project with active maintenance -- `warning` (≥40%): Needs attention in some areas -- `critical` (<40%): Requires immediate improvement - -**Output Formats:** - -- `text` (default): Human-readable summary with suggestions -- `json`: Structured data for scripts and AI Agents -- `markdown`: Formatted report for documentation - ### Pipeline Operations ```bash @@ -617,16 +485,6 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run ``` -### Ignore File Templates - -```bash -# List all available .gitignore templates -gitlink-cli ignore +list - -# Filter templates by name -gitlink-cli ignore +list --name Go -``` - ### Search ```bash @@ -637,29 +495,6 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` -### User Profile - -`profile` surfaces GitLink's native user statistics (ability, role, major, activity, -contribution). When `--user` is omitted it defaults to the authenticated user. - -```bash -# Development ability scores + language breakdown -gitlink-cli profile +ability --user zhangsan - -# Role positioning / major (discipline) categories -gitlink-cli profile +role --user zhangsan -gitlink-cli profile +major --user zhangsan - -# Ability within a time window (Unix timestamps) -gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600 - -# Recent activity (issues / PRs / commits per day) for the current user -gitlink-cli profile +activity - -# Contribution heatmap for a given year -gitlink-cli profile +contribution --user zhangsan --year 2025 -``` - ### Workflow Agent Commands `workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports: @@ -759,32 +594,6 @@ Safety: - `workflow +pr-summary` does not comment, approve, reject, or merge pull requests. - `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes. -### Dataset - -`dataset` manages and queries GitLink research datasets (title, description, -paper content, license, owning project). - -```bash -# List datasets for one or more projects (by numeric project ID) -gitlink-cli dataset +list --ids 5988 - -# View a repository's dataset and attachments -gitlink-cli dataset +view --owner Gitlink --repo forgeplus - -# Create / update a repository's dataset (preview first with --dry-run) -gitlink-cli dataset +create --owner me --repo proj -t "My dataset" -d "..." --license-id 359 --dry-run -gitlink-cli dataset +update --owner me --repo proj -t "My dataset" -d "updated" - -# Delete a dataset attachment (destructive: preview, then confirm with --yes) -gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --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 For endpoints not covered by shortcuts, use the Raw API directly: @@ -841,13 +650,13 @@ 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-branch` | Branch management (create, delete, list, protect, unprotect) | @@ -859,7 +668,7 @@ See [skills/README.md](./skills/README.md) for details. | `gitlink-user` | User management (profile info, etc.) | | `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) | | `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) | -| `gitlink-health` | Project health diagnostics (5-dimension scoring: Documentation, License, Community, Maturity, CI/CD) | +| `gitlink-health` | Project health analysis (PR/Issue metrics aggregation, health reports) | ## Project Structure @@ -911,7 +720,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 @@ -976,7 +785,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/issue-batch-operations.md b/doc/changes/issue-batch-operations.md new file mode 100644 index 0000000..36d60fe --- /dev/null +++ b/doc/changes/issue-batch-operations.md @@ -0,0 +1,30 @@ +# issue 批量运维能力增强 + +本次变更把 Issue 的批量运维能力从“只能批量关闭”扩展为更完整的日常工作流: + +- 新增 `issue +batch-comment`,支持按 `--numbers` 或 `--from issues.csv` 给多个 Issue 统一追加评论。 +- 新增 `issue +batch-update`,支持批量更新状态、优先级、标签、负责人、关联分支、开始日期和截止日期。 +- `issue +create`、`issue +update`、`issue +comment` 现在支持 `--body-file`,适合读取 Markdown 文件中的长文本。 + +设计上延续了现有 `issue +batch-close` 的安全思路: + +- 批量命令统一支持 `--dry-run`; +- `--body` 与 `--body-file` 互斥; +- 批量更新会先读取当前 Issue,再保留已有标题、描述和元数据,避免误清空字段; +- 输出统一包含逐条结果汇总,便于 Agent 或脚本继续处理。 + +相关文档已同步更新: + +- `README.md` +- `skills/gitlink-issue/SKILL.md` + +本地验证: + +```bash +go test ./shortcuts/issue/... +go test ./shortcuts/... +go build ./... +git diff --check +go run . issue +batch-comment --help +go run . issue +batch-update --help +``` diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 3c91d63..a2b4731 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -2,36 +2,34 @@ package issue import ( "encoding/csv" - "encoding/json" "fmt" - "net/url" "os" - "sort" "strconv" "strings" + "unicode/utf8" + "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -const ( - closedIssueStatusID = 5 - openIssueStatusID = 1 -) +const closedIssueStatusID = 5 -type batchCloseResult struct { +type batchIssueResult struct { Number string `json:"number" yaml:"number"` Action string `json:"action" yaml:"action"` Status string `json:"status" yaml:"status"` Error string `json:"error,omitempty" yaml:"error,omitempty"` } -type batchCloseSummary struct { - Repository string `json:"repository" yaml:"repository"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Total int `json:"total" yaml:"total"` - Succeeded int `json:"succeeded" yaml:"succeeded"` - Failed int `json:"failed" yaml:"failed"` - Results []batchCloseResult `json:"results" yaml:"results"` +type batchIssueSummary struct { + Repository string `json:"repository" yaml:"repository"` + Action string `json:"action" yaml:"action"` + DryRun bool `json:"dry_run" yaml:"dry_run"` + Preview map[string]interface{} `json:"preview,omitempty" yaml:"preview,omitempty"` + Total int `json:"total" yaml:"total"` + Succeeded int `json:"succeeded" yaml:"succeeded"` + Failed int `json:"failed" yaml:"failed"` + Results []batchIssueResult `json:"results" yaml:"results"` } func newBatchCloseShortcut() *common.Shortcut { @@ -47,29 +45,60 @@ func newBatchCloseShortcut() *common.Shortcut { } } +func newBatchCommentShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-comment", + Description: "Add the same comment to multiple issues by issue numbers or a CSV file", + Flags: append(batchIssueTargetFlags(), + common.Flag{Name: "body", Short: "b", Usage: "Comment body"}, + common.Flag{Name: "body-file", Usage: "Read comment body from a file"}, + ), + Run: runBatchComment, + } +} + +func newBatchUpdateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-update", + Description: "Apply the same metadata updates to multiple issues by issue numbers or a CSV file", + Flags: append(batchIssueTargetFlags(), + common.Flag{Name: "title", Short: "t", Usage: "New issue title"}, + common.Flag{Name: "body", Short: "b", Usage: "New issue description"}, + common.Flag{Name: "body-file", Usage: "Read the new issue description from a file"}, + common.Flag{Name: "state", Short: "s", Usage: "New issue state"}, + common.Flag{Name: "priority-id", Usage: "New priority ID"}, + common.Flag{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"}, + common.Flag{Name: "assigner-ids", Usage: "Comma-separated issue assigner IDs"}, + common.Flag{Name: "branch", Usage: "Linked branch name"}, + common.Flag{Name: "start-date", Usage: "Start date (YYYY-MM-DD)"}, + common.Flag{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"}, + ), + Run: runBatchUpdate, + } +} + +func batchIssueTargetFlags() []common.Flag { + return []common.Flag{ + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"}, + {Name: "dry-run", Usage: "Preview the issues that would be changed without changing them", Bool: true, Default: "false"}, + } +} + func runBatchClose(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) + numbers, err := collectBatchIssueTargets(ctx) if err != nil { return err } - if len(numbers) == 0 { - return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") - } - dryRun := parseBool(ctx.Arg("dry-run")) - summary := batchCloseSummary{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: dryRun, - Total: len(numbers), - Results: make([]batchCloseResult, 0, len(numbers)), - } + summary := newBatchIssueSummary(ctx, "close", dryRun, len(numbers), nil) for _, number := range numbers { - result := batchCloseResult{Number: number, Action: "close"} + result := batchIssueResult{Number: number, Action: "close"} if dryRun { result.Status = "planned" summary.Succeeded++ @@ -97,6 +126,102 @@ func runBatchClose(ctx *common.RuntimeContext) error { return nil } +func runBatchComment(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + numbers, err := collectBatchIssueTargets(ctx) + if err != nil { + return err + } + body, err := readIssueTextArg(ctx, "body", "body-file", true) + if err != nil { + return err + } + dryRun := parseBool(ctx.Arg("dry-run")) + summary := newBatchIssueSummary(ctx, "comment", dryRun, len(numbers), map[string]interface{}{ + "body_preview": previewText(body), + "body_length": utf8.RuneCountInString(body), + }) + if file := strings.TrimSpace(ctx.Arg("body-file")); file != "" { + summary.Preview["body_file"] = file + } + + for _, number := range numbers { + result := batchIssueResult{Number: number, Action: "comment"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + if _, err := commentOnIssue(ctx, number, body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "commented" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d issue(s) failed to comment", summary.Failed, summary.Total) + } + return nil +} + +func runBatchUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + numbers, err := collectBatchIssueTargets(ctx) + if err != nil { + return err + } + preview, err := buildIssueUpdatePreview(ctx) + if err != nil { + return err + } + dryRun := parseBool(ctx.Arg("dry-run")) + summary := newBatchIssueSummary(ctx, "update", dryRun, len(numbers), preview) + + for _, number := range numbers { + result := batchIssueResult{Number: number, Action: "update"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + if err := updateIssue(ctx, number); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "updated" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d issue(s) failed to update", summary.Failed, summary.Total) + } + return nil +} + func closeIssue(ctx *common.RuntimeContext, number string) error { current, err := fetchExistingIssue(ctx, number) if err != nil { @@ -114,104 +239,113 @@ func closeIssue(ctx *common.RuntimeContext, number string) error { return nil } -// batch-reopen implementation - -type batchReopenResult struct { - Number string `json:"number" yaml:"number"` - Action string `json:"action" yaml:"action"` - Status string `json:"status" yaml:"status"` - Error string `json:"error,omitempty" yaml:"error,omitempty"` -} - -type batchReopenSummary struct { - Repository string `json:"repository" yaml:"repository"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Total int `json:"total" yaml:"total"` - Succeeded int `json:"succeeded" yaml:"succeeded"` - Failed int `json:"failed" yaml:"failed"` - Results []batchReopenResult `json:"results" yaml:"results"` -} - -func newBatchReopenShortcut() *common.Shortcut { - return &common.Shortcut{ - Name: "batch-reopen", - Description: "Reopen multiple closed issues by issue numbers or a CSV file", - Flags: []common.Flag{ - {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"}, - {Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"}, - {Name: "dry-run", Usage: "Preview the issues that would be reopened without changing them", Bool: true, Default: "false"}, - }, - Run: runBatchReopen, - } -} - -func runBatchReopen(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) - if err != nil { - return err - } - if len(numbers) == 0 { - return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") - } - - dryRun := parseBool(ctx.Arg("dry-run")) - summary := batchReopenSummary{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: dryRun, - Total: len(numbers), - Results: make([]batchReopenResult, 0, len(numbers)), - } - - for _, number := range numbers { - result := batchReopenResult{Number: number, Action: "reopen"} - if dryRun { - result.Status = "planned" - summary.Succeeded++ - summary.Results = append(summary.Results, result) - continue - } - - if err := reopenIssue(ctx, number); err != nil { - result.Status = "failed" - result.Error = err.Error() - summary.Failed++ - } else { - result.Status = "reopened" - summary.Succeeded++ - } - summary.Results = append(summary.Results, result) - } - - if err := ctx.OutputData(summary); err != nil { - return err - } - if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to reopen", summary.Failed, summary.Total) - } - return nil -} - -func reopenIssue(ctx *common.RuntimeContext, number string) error { +func updateIssue(ctx *common.RuntimeContext, number string) error { current, err := fetchExistingIssue(ctx, number) if err != nil { return fmt.Errorf("fetch issue: %w", err) } - - body := map[string]interface{}{ - "subject": current.Subject, - "description": current.Description, - "status_id": openIssueStatusID, + body, err := buildIssueUpdateBody(ctx, current) + if err != nil { + return err } if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { - return fmt.Errorf("reopen issue: %w", err) + return fmt.Errorf("update issue: %w", err) } return nil } +func commentOnIssue(ctx *common.RuntimeContext, number, body string) (*output.Envelope, error) { + payload := map[string]interface{}{ + "notes": body, + } + return ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload) +} + +func newBatchIssueSummary(ctx *common.RuntimeContext, action string, dryRun bool, total int, preview map[string]interface{}) batchIssueSummary { + return batchIssueSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: action, + DryRun: dryRun, + Preview: preview, + Total: total, + Results: make([]batchIssueResult, 0, total), + } +} + +func collectBatchIssueTargets(ctx *common.RuntimeContext) ([]string, error) { + numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) + if err != nil { + return nil, err + } + if len(numbers) == 0 { + return nil, fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + } + return numbers, nil +} + +func buildIssueUpdatePreview(ctx *common.RuntimeContext) (map[string]interface{}, error) { + description, err := readIssueTextArg(ctx, "body", "body-file", false) + if err != nil { + return nil, err + } + preview := map[string]interface{}{} + if title := ctx.Arg("title"); title != "" { + preview["subject"] = title + } + if description != "" { + preview["description_preview"] = previewText(description) + preview["description_length"] = utf8.RuneCountInString(description) + } + if state := ctx.Arg("state"); state != "" { + statusID, err := normalizeIssueStatus(state) + if err != nil { + return nil, err + } + preview["status_id"] = statusID + } + if err := applyIssueMetadataArgs(ctx, preview); err != nil { + return nil, err + } + if file := strings.TrimSpace(ctx.Arg("body-file")); file != "" { + preview["body_file"] = file + } + if len(preview) == 0 { + return nil, fmt.Errorf("at least one update field is required") + } + return preview, nil +} + +func readIssueTextArg(ctx *common.RuntimeContext, inlineArg, fileArg string, required bool) (string, error) { + inline := ctx.Arg(inlineArg) + file := strings.TrimSpace(ctx.Arg(fileArg)) + if inline != "" && file != "" { + return "", fmt.Errorf("--%s cannot be used with --%s", inlineArg, fileArg) + } + if inline != "" { + return inline, nil + } + if file != "" { + content, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("read %s: %w", fileArg, err) + } + return string(content), nil + } + if required { + return "", fmt.Errorf("required flag --%s is missing", inlineArg) + } + return "", nil +} + +func previewText(text string) string { + const maxRunes = 120 + if utf8.RuneCountInString(text) <= maxRunes { + return text + } + runes := []rune(text) + return string(runes[:maxRunes]) + "..." +} + func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { numbers, err := parseIssueNumbers(numbersValue) if err != nil { @@ -314,1135 +448,3 @@ func parseBool(value string) bool { parsed, err := strconv.ParseBool(strings.TrimSpace(value)) return err == nil && parsed } - -type batchMaintenanceDryRun struct { - Repository string `json:"repository" yaml:"repository"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Action string `json:"action" yaml:"action"` - Method string `json:"method" yaml:"method"` - Path string `json:"path" yaml:"path"` - Body map[string]interface{} `json:"body" yaml:"body"` -} - -func newBatchUpdateShortcut() *common.Shortcut { - return &common.Shortcut{ - Name: "batch-update", - Description: "Batch update issue metadata by API issue IDs", - Flags: []common.Flag{ - {Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true}, - {Name: "status-id", Usage: "Issue status ID"}, - {Name: "priority-id", Usage: "Issue priority ID"}, - {Name: "milestone-id", Usage: "Issue milestone ID"}, - {Name: "tag-ids", Usage: "Comma-separated issue tag IDs"}, - {Name: "assigner-ids", Usage: "Comma-separated assignee user IDs"}, - {Name: "dry-run", Usage: "Preview request without updating issues", Bool: true, Default: "false"}, - }, - Run: runBatchUpdate, - } -} - -func newBatchDeleteShortcut() *common.Shortcut { - return &common.Shortcut{ - Name: "batch-delete", - Description: "Batch delete issues by API issue IDs", - Flags: []common.Flag{ - {Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true}, - {Name: "dry-run", Usage: "Preview request without deleting issues", Bool: true, Default: "false"}, - {Name: "yes", Usage: "Confirm real batch deletion", Bool: true, Default: "false"}, - }, - Run: runBatchDelete, - } -} - -func runBatchUpdate(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - body, err := buildBatchUpdateBody(ctx) - if err != nil { - return err - } - path := fmt.Sprintf("%s/issues/batch_update", v1RepoPath(ctx)) - if parseBool(ctx.Arg("dry-run")) { - return ctx.OutputData(batchMaintenanceDryRun{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: true, - Action: "batch_update_issues", - Method: "PATCH", - Path: path, - Body: body, - }) - } - env, err := ctx.CallAPI("PATCH", path, body) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runBatchDelete(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - ids, err := parseIntIDList(ctx.Arg("ids"), "ids") - if err != nil { - return err - } - body := map[string]interface{}{"ids": ids} - path := fmt.Sprintf("%s/issues/batch_destroy", v1RepoPath(ctx)) - dryRun := parseBool(ctx.Arg("dry-run")) - if dryRun { - return ctx.OutputData(batchMaintenanceDryRun{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: true, - Action: "batch_delete_issues", - Method: "DELETE", - Path: path, - Body: body, - }) - } - if !parseBool(ctx.Arg("yes")) { - return fmt.Errorf("batch-delete is destructive; run with --dry-run first, then pass --yes to confirm") - } - env, err := ctx.CallAPI("DELETE", path, body) - if err != nil { - return err - } - return ctx.Output(env) -} - -func buildBatchUpdateBody(ctx *common.RuntimeContext) (map[string]interface{}, error) { - ids, err := parseIntIDList(ctx.Arg("ids"), "ids") - if err != nil { - return nil, err - } - body := map[string]interface{}{"ids": ids} - changed := false - if value := ctx.Arg("status-id"); value != "" { - id, err := parseSingleIntID(value, "status-id") - if err != nil { - return nil, err - } - body["status_id"] = id - changed = true - } - if value := ctx.Arg("priority-id"); value != "" { - id, err := parseSingleIntID(value, "priority-id") - if err != nil { - return nil, err - } - body["priority_id"] = id - changed = true - } - if value := ctx.Arg("milestone-id"); value != "" { - id, err := parseSingleIntID(value, "milestone-id") - if err != nil { - return nil, err - } - body["milestone_id"] = id - changed = true - } - if value := ctx.Arg("tag-ids"); value != "" { - ids, err := parseIntIDList(value, "tag-ids") - if err != nil { - return nil, err - } - body["issue_tag_ids"] = ids - changed = true - } - if value := ctx.Arg("assigner-ids"); value != "" { - ids, err := parseIntIDList(value, "assigner-ids") - if err != nil { - return nil, err - } - body["assigner_ids"] = ids - changed = true - } - if !changed { - return nil, fmt.Errorf("no update fields provided; set at least one of --status-id, --priority-id, --milestone-id, --tag-ids, --assigner-ids") - } - return body, nil -} - -func parseSingleIntID(value, field string) (int, error) { - value = strings.TrimSpace(value) - if value == "" { - return 0, fmt.Errorf("%s cannot be empty", field) - } - id, err := strconv.Atoi(value) - if err != nil || id <= 0 { - return 0, fmt.Errorf("invalid %s %q: must be a positive integer", field, value) - } - return id, nil -} - -func parseIntIDList(value, field string) ([]int, error) { - if strings.TrimSpace(value) == "" { - return nil, fmt.Errorf("%s cannot be empty", field) - } - parts := strings.Split(value, ",") - ids := make([]int, 0, len(parts)) - seen := map[int]bool{} - for _, part := range parts { - id, err := parseSingleIntID(part, field) - if err != nil { - return nil, err - } - if seen[id] { - continue - } - seen[id] = true - ids = append(ids, id) - } - return ids, nil -} - -// batch-label implementation - -type batchLabelResult struct { - ID string `json:"id" yaml:"id"` - Action string `json:"action" yaml:"action"` - Status string `json:"status" yaml:"status"` - Error string `json:"error,omitempty" yaml:"error,omitempty"` -} - -type batchLabelSummary struct { - Repository string `json:"repository" yaml:"repository"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Total int `json:"total" yaml:"total"` - Succeeded int `json:"succeeded" yaml:"succeeded"` - Failed int `json:"failed" yaml:"failed"` - Results []batchLabelResult `json:"results" yaml:"results"` -} - -func newBatchLabelShortcut() *common.Shortcut { - return &common.Shortcut{ - Name: "batch-label", - Description: "Batch add or remove labels from issues by API issue IDs", - Flags: []common.Flag{ - {Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true}, - {Name: "add", Usage: "Comma-separated tag IDs to add to issues"}, - {Name: "remove", Usage: "Comma-separated tag IDs to remove from issues"}, - {Name: "dry-run", Usage: "Preview changes without updating issues", Bool: true, Default: "false"}, - }, - Run: runBatchLabel, - } -} - -func runBatchLabel(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - ids, err := parseIntIDList(ctx.Arg("ids"), "ids") - if err != nil { - return err - } - - addTags, err := parseOptionalIntIDList(ctx.Arg("add")) - if err != nil { - return fmt.Errorf("parse add tags: %w", err) - } - - removeTags, err := parseOptionalIntIDList(ctx.Arg("remove")) - if err != nil { - return fmt.Errorf("parse remove tags: %w", err) - } - - if len(addTags) == 0 && len(removeTags) == 0 { - return fmt.Errorf("no label changes provided; use --add and/or --remove with tag IDs") - } - - dryRun := parseBool(ctx.Arg("dry-run")) - summary := batchLabelSummary{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: dryRun, - Total: len(ids), - Results: make([]batchLabelResult, 0, len(ids)), - } - - for _, id := range ids { - result := batchLabelResult{ID: strconv.Itoa(id), Action: "update_labels"} - if dryRun { - result.Status = "planned" - summary.Succeeded++ - summary.Results = append(summary.Results, result) - continue - } - - if err := updateIssueLabels(ctx, id, addTags, removeTags); err != nil { - result.Status = "failed" - result.Error = err.Error() - summary.Failed++ - } else { - result.Status = "updated" - summary.Succeeded++ - } - summary.Results = append(summary.Results, result) - } - - if err := ctx.OutputData(summary); err != nil { - return err - } - if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to update labels", summary.Failed, summary.Total) - } - return nil -} - -func parseOptionalIntIDList(value string) ([]int, error) { - if strings.TrimSpace(value) == "" { - return nil, nil - } - return parseIntIDList(value, "tag-ids") -} - -func updateIssueLabels(ctx *common.RuntimeContext, issueID int, addTags, removeTags []int) error { - // Fetch current issue to get existing tags - issueData, err := fetchIssueDataByID(ctx, issueID) - if err != nil { - return fmt.Errorf("fetch issue: %w", err) - } - - // Get current tag IDs - currentTagIDs := issueObjectIDs(issueData, "tags", "issue_tags") - currentTags := make(map[int]bool) - for _, id := range currentTagIDs { - if tagID, ok := id.(float64); ok { - currentTags[int(tagID)] = true - } else if tagID, ok := id.(int); ok { - currentTags[tagID] = true - } - } - - // Add new tags - for _, tagID := range addTags { - currentTags[tagID] = true - } - - // Remove tags - for _, tagID := range removeTags { - delete(currentTags, tagID) - } - - // Convert back to slice and sort for consistent ordering - newTagIDs := make([]int, 0, len(currentTags)) - for tagID := range currentTags { - newTagIDs = append(newTagIDs, tagID) - } - sort.Ints(newTagIDs) - - // Convert to []interface{} for JSON - newTags := make([]interface{}, len(newTagIDs)) - for i, id := range newTagIDs { - newTags[i] = id - } - - // Update issue - body := map[string]interface{}{ - "ids": []int{issueID}, - "issue_tag_ids": newTags, - } - path := fmt.Sprintf("%s/issues/batch_update", v1RepoPath(ctx)) - if _, err := ctx.CallAPI("PATCH", path, body); err != nil { - return fmt.Errorf("update issue labels: %w", err) - } - return nil -} - -func fetchIssueDataByID(ctx *common.RuntimeContext, id int) (map[string]interface{}, error) { - path := fmt.Sprintf("%s/issues/%d", v1RepoPath(ctx), id) - env, err := ctx.CallAPI("GET", path, nil) - if err != nil { - return nil, err - } - issueData, ok := env.Data.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("failed to parse issue data") - } - return issueData, nil -} - -// batch-assign implementation - -type batchAssignResult struct { - ID string `json:"id" yaml:"id"` - Action string `json:"action" yaml:"action"` - Status string `json:"status" yaml:"status"` - Error string `json:"error,omitempty" yaml:"error,omitempty"` -} - -type batchAssignSummary struct { - Repository string `json:"repository" yaml:"repository"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Total int `json:"total" yaml:"total"` - Succeeded int `json:"succeeded" yaml:"succeeded"` - Failed int `json:"failed" yaml:"failed"` - Results []batchAssignResult `json:"results" yaml:"results"` -} - -func newBatchAssignShortcut() *common.Shortcut { - return &common.Shortcut{ - Name: "batch-assign", - Description: "Batch assign or unassign users from issues by API issue IDs", - Flags: []common.Flag{ - {Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true}, - {Name: "add", Usage: "Comma-separated user IDs to assign to issues"}, - {Name: "remove", Usage: "Comma-separated user IDs to unassign from issues"}, - {Name: "dry-run", Usage: "Preview changes without updating issues", Bool: true, Default: "false"}, - }, - Run: runBatchAssign, - } -} - -func runBatchAssign(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - ids, err := parseIntIDList(ctx.Arg("ids"), "ids") - if err != nil { - return err - } - - addUsers, err := parseOptionalIntIDList(ctx.Arg("add")) - if err != nil { - return fmt.Errorf("parse add users: %w", err) - } - - removeUsers, err := parseOptionalIntIDList(ctx.Arg("remove")) - if err != nil { - return fmt.Errorf("parse remove users: %w", err) - } - - if len(addUsers) == 0 && len(removeUsers) == 0 { - return fmt.Errorf("no assignee changes provided; use --add and/or --remove with user IDs") - } - - dryRun := parseBool(ctx.Arg("dry-run")) - summary := batchAssignSummary{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: dryRun, - Total: len(ids), - Results: make([]batchAssignResult, 0, len(ids)), - } - - for _, id := range ids { - result := batchAssignResult{ID: strconv.Itoa(id), Action: "update_assignees"} - if dryRun { - result.Status = "planned" - summary.Succeeded++ - summary.Results = append(summary.Results, result) - continue - } - - if err := updateIssueAssignees(ctx, id, addUsers, removeUsers); err != nil { - result.Status = "failed" - result.Error = err.Error() - summary.Failed++ - } else { - result.Status = "updated" - summary.Succeeded++ - } - summary.Results = append(summary.Results, result) - } - - if err := ctx.OutputData(summary); err != nil { - return err - } - if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to update assignees", summary.Failed, summary.Total) - } - return nil -} - -func updateIssueAssignees(ctx *common.RuntimeContext, issueID int, addUsers, removeUsers []int) error { - // Fetch current issue to get existing assignees - issueData, err := fetchIssueDataByID(ctx, issueID) - if err != nil { - return fmt.Errorf("fetch issue: %w", err) - } - - // Get current assignee IDs - currentAssigneeIDs := issueObjectIDs(issueData, "assigners") - currentAssignees := make(map[int]bool) - for _, id := range currentAssigneeIDs { - if userID, ok := id.(float64); ok { - currentAssignees[int(userID)] = true - } else if userID, ok := id.(int); ok { - currentAssignees[userID] = true - } - } - - // Add new assignees - for _, userID := range addUsers { - currentAssignees[userID] = true - } - - // Remove assignees - for _, userID := range removeUsers { - delete(currentAssignees, userID) - } - - // Convert back to slice and sort for consistent ordering - newUserIDs := make([]int, 0, len(currentAssignees)) - for userID := range currentAssignees { - newUserIDs = append(newUserIDs, userID) - } - sort.Ints(newUserIDs) - - // Convert to []interface{} for JSON - newAssignees := make([]interface{}, len(newUserIDs)) - for i, id := range newUserIDs { - newAssignees[i] = id - } - - // Update issue - body := map[string]interface{}{ - "ids": []int{issueID}, - "assigner_ids": newAssignees, - } - path := fmt.Sprintf("%s/issues/batch_update", v1RepoPath(ctx)) - if _, err := ctx.CallAPI("PATCH", path, body); err != nil { - return fmt.Errorf("update issue assignees: %w", err) - } - return nil -} - -// batch-comment implementation - -type batchCommentResult struct { - Number string `json:"number" yaml:"number"` - Action string `json:"action" yaml:"action"` - Status string `json:"status" yaml:"status"` - Error string `json:"error,omitempty" yaml:"error,omitempty"` -} - -type batchCommentSummary struct { - Repository string `json:"repository" yaml:"repository"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Message string `json:"message" yaml:"message"` - Total int `json:"total" yaml:"total"` - Succeeded int `json:"succeeded" yaml:"succeeded"` - Failed int `json:"failed" yaml:"failed"` - Results []batchCommentResult `json:"results" yaml:"results"` -} - -func newBatchCommentShortcut() *common.Shortcut { - return &common.Shortcut{ - Name: "batch-comment", - Description: "Batch add comments to multiple issues by issue numbers or a CSV file", - Flags: []common.Flag{ - {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"}, - {Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"}, - {Name: "message", Short: "m", Usage: "Comment message to add to all issues", Required: true}, - {Name: "dry-run", Usage: "Preview the issues that would receive comments without posting them", Bool: true, Default: "false"}, - }, - Run: runBatchComment, - } -} - -func runBatchComment(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) - if err != nil { - return err - } - if len(numbers) == 0 { - return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") - } - - message, err := ctx.RequireArg("message") - if err != nil { - return err - } - - dryRun := parseBool(ctx.Arg("dry-run")) - summary := batchCommentSummary{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: dryRun, - Message: message, - Total: len(numbers), - Results: make([]batchCommentResult, 0, len(numbers)), - } - - for _, number := range numbers { - result := batchCommentResult{Number: number, Action: "add_comment"} - if dryRun { - result.Status = "planned" - summary.Succeeded++ - summary.Results = append(summary.Results, result) - continue - } - - if err := addIssueComment(ctx, number, message); err != nil { - result.Status = "failed" - result.Error = err.Error() - summary.Failed++ - } else { - result.Status = "commented" - summary.Succeeded++ - } - summary.Results = append(summary.Results, result) - } - - if err := ctx.OutputData(summary); err != nil { - return err - } - if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to add comment", summary.Failed, summary.Total) - } - return nil -} - -func addIssueComment(ctx *common.RuntimeContext, number, message string) error { - payload := map[string]interface{}{ - "notes": message, - } - path := fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number) - if _, err := ctx.CallAPI("POST", path, payload); err != nil { - return fmt.Errorf("add comment: %w", err) - } - return nil -} - -// ============================================================================ -// Batch Export -// ============================================================================ - -type batchExportSummary struct { - Repository string `json:"repository" yaml:"repository"` - Format string `json:"format" yaml:"format"` - Output string `json:"output" yaml:"output"` - Total int `json:"total" yaml:"total"` -} - -func newBatchExportShortcut() *common.Shortcut { - return &common.Shortcut{ - Name: "batch-export", - Description: "Export issues to CSV or JSON file", - Flags: []common.Flag{ - {Name: "format", Short: "f", Usage: "Export format: csv or json", Default: "csv"}, - {Name: "output", Short: "o", Usage: "Output file path (default: issues.csv or issues.json)"}, - {Name: "state", Short: "s", Usage: "Filter by state: open, closed, or all", Default: "open"}, - {Name: "keyword", Short: "k", Usage: "Filter by keyword"}, - {Name: "author-id", Usage: "Filter by author ID"}, - {Name: "assignee-id", Usage: "Filter by assignee ID"}, - {Name: "milestone-id", Usage: "Filter by milestone ID"}, - {Name: "status-id", Usage: "Filter by status ID"}, - {Name: "tag-ids", Usage: "Filter by comma-separated tag IDs"}, - {Name: "limit", Short: "l", Usage: "Maximum number of issues to export (0 for all)", Default: "0"}, - }, - Run: runBatchExport, - } -} - -func runBatchExport(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - // Build query parameters - q := url.Values{} - if s := ctx.Arg("state"); s != "" { - q.Set("category", normalizeIssueListState(s)) - } - if keyword := ctx.Arg("keyword"); keyword != "" { - q.Set("keyword", keyword) - } - if authorID := ctx.Arg("author-id"); authorID != "" { - q.Set("author_id", authorID) - } - if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" { - q.Set("assigner_id", assigneeID) - } - if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" { - q.Set("milestone_id", milestoneID) - } - if statusID := ctx.Arg("status-id"); statusID != "" { - q.Set("status_id", statusID) - } - if tagIDs := ctx.Arg("tag-ids"); tagIDs != "" { - q.Set("issue_tag_ids", tagIDs) - } - - // Fetch all issues with pagination - limitStr := ctx.Arg("limit") - maxLimit := 0 - if limitStr != "" && limitStr != "0" { - if l, err := strconv.Atoi(limitStr); err == nil { - maxLimit = l - } - } - - allIssues := make([]map[string]interface{}, 0) - page := 1 - pageSize := 100 - - for { - q.Set("page", strconv.Itoa(page)) - q.Set("limit", strconv.Itoa(pageSize)) - - env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q) - if err != nil { - return fmt.Errorf("fetch issues: %w", err) - } - - data, ok := env.Data.(map[string]interface{}) - if !ok { - return fmt.Errorf("unexpected response format") - } - - issues, ok := data["issues"].([]interface{}) - if !ok { - break - } - - for _, item := range issues { - issue, ok := item.(map[string]interface{}) - if !ok { - continue - } - // Normalize issue data - if num, ok := issue["project_issues_index"]; ok { - issue["number"] = num - } - if id, ok := issue["id"]; ok { - issue["database_id"] = id - delete(issue, "id") - } - allIssues = append(allIssues, issue) - } - - if len(issues) < pageSize { - break - } - if maxLimit > 0 && len(allIssues) >= maxLimit { - allIssues = allIssues[:maxLimit] - break - } - page++ - } - - // Determine output format and file - format := strings.ToLower(ctx.Arg("format")) - if format != "csv" && format != "json" { - format = "csv" - } - - outputPath := ctx.Arg("output") - if outputPath == "" { - if format == "csv" { - outputPath = "issues.csv" - } else { - outputPath = "issues.json" - } - } - - // Export to file - var err error - if format == "csv" { - err = exportIssuesToCSV(allIssues, outputPath) - } else { - err = exportIssuesToJSON(allIssues, outputPath) - } - if err != nil { - return fmt.Errorf("export issues: %w", err) - } - - summary := batchExportSummary{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - Format: format, - Output: outputPath, - Total: len(allIssues), - } - - return ctx.OutputData(summary) -} - -func exportIssuesToCSV(issues []map[string]interface{}, path string) error { - if len(issues) == 0 { - return fmt.Errorf("no issues to export") - } - - file, err := os.Create(path) - if err != nil { - return fmt.Errorf("create file: %w", err) - } - defer file.Close() - - writer := csv.NewWriter(file) - defer writer.Flush() - - // Define CSV columns - headers := []string{ - "number", "subject", "description", "status", "priority", - "author", "assigners", "tags", "milestone", "branch_name", - "start_date", "due_date", "created_at", "updated_at", - } - if err := writer.Write(headers); err != nil { - return fmt.Errorf("write headers: %w", err) - } - - for _, issue := range issues { - record := make([]string, len(headers)) - record[0] = getStringField(issue, "number") - record[1] = getStringField(issue, "subject") - record[2] = getStringField(issue, "description") - record[3] = getNestedStringField(issue, "status", "name") - record[4] = getNestedStringField(issue, "priority", "name") - record[5] = getNestedStringField(issue, "author", "login") - record[6] = getNestedArrayField(issue, "assigners", "login") - record[7] = getNestedArrayField(issue, "tags", "name") - record[8] = getNestedStringField(issue, "milestone", "name") - record[9] = getStringField(issue, "branch_name") - record[10] = getStringField(issue, "start_date") - record[11] = getStringField(issue, "due_date") - record[12] = getStringField(issue, "created_at") - record[13] = getStringField(issue, "updated_at") - - if err := writer.Write(record); err != nil { - return fmt.Errorf("write record: %w", err) - } - } - - return nil -} - -func exportIssuesToJSON(issues []map[string]interface{}, path string) error { - data, err := json.MarshalIndent(issues, "", " ") - if err != nil { - return fmt.Errorf("marshal JSON: %w", err) - } - - if err := os.WriteFile(path, data, 0644); err != nil { - return fmt.Errorf("write file: %w", err) - } - - return nil -} - -func getStringField(m map[string]interface{}, key string) string { - if v, ok := m[key]; ok { - switch val := v.(type) { - case string: - return val - case float64: - return strconv.FormatFloat(val, 'f', -1, 64) - case int: - return strconv.Itoa(val) - } - } - return "" -} - -func getNestedStringField(m map[string]interface{}, keys ...string) string { - current := m - for i, key := range keys { - if i == len(keys)-1 { - return getStringField(current, key) - } - if v, ok := current[key]; ok { - if nested, ok := v.(map[string]interface{}); ok { - current = nested - } else { - break - } - } else { - break - } - } - return "" -} - -func getNestedArrayField(m map[string]interface{}, arrayKey, fieldKey string) string { - if v, ok := m[arrayKey]; ok { - if arr, ok := v.([]interface{}); ok { - values := make([]string, 0, len(arr)) - for _, item := range arr { - if obj, ok := item.(map[string]interface{}); ok { - if field, ok := obj[fieldKey]; ok { - if s, ok := field.(string); ok { - values = append(values, s) - } - } - } - } - return strings.Join(values, ",") - } - } - return "" -} - -// ============================================================================ -// Batch Import -// ============================================================================ - -type batchImportResult struct { - Row int `json:"row" yaml:"row"` - Title string `json:"title" yaml:"title"` - Status string `json:"status" yaml:"status"` - Error string `json:"error,omitempty" yaml:"error,omitempty"` -} - -type batchImportSummary struct { - Repository string `json:"repository" yaml:"repository"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Input string `json:"input" yaml:"input"` - Total int `json:"total" yaml:"total"` - Succeeded int `json:"succeeded" yaml:"succeeded"` - Failed int `json:"failed" yaml:"failed"` - Results []batchImportResult `json:"results" yaml:"results"` -} - -func newBatchImportShortcut() *common.Shortcut { - return &common.Shortcut{ - Name: "batch-import", - Description: "Create issues from a CSV file", - Flags: []common.Flag{ - {Name: "from", Short: "f", Usage: "CSV file path to import issues from", Required: true}, - {Name: "dry-run", Usage: "Preview the issues that would be created without creating them", Bool: true, Default: "false"}, - }, - Run: runBatchImport, - } -} - -func runBatchImport(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - - csvPath := ctx.Arg("from") - if csvPath == "" { - return fmt.Errorf("--from is required") - } - - issues, err := readIssuesFromCSV(csvPath) - if err != nil { - return fmt.Errorf("read CSV: %w", err) - } - if len(issues) == 0 { - return fmt.Errorf("no issues found in CSV file") - } - - dryRun := parseBool(ctx.Arg("dry-run")) - summary := batchImportSummary{ - Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - DryRun: dryRun, - Input: csvPath, - Total: len(issues), - Results: make([]batchImportResult, 0, len(issues)), - } - - for i, issue := range issues { - result := batchImportResult{ - Row: i + 2, // +2 because row 1 is header - Title: issue.Title, - } - - if dryRun { - result.Status = "planned" - summary.Succeeded++ - summary.Results = append(summary.Results, result) - continue - } - - if err := createIssueFromImport(ctx, issue); err != nil { - result.Status = "failed" - result.Error = err.Error() - summary.Failed++ - } else { - result.Status = "created" - summary.Succeeded++ - } - summary.Results = append(summary.Results, result) - } - - if err := ctx.OutputData(summary); err != nil { - return err - } - if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to create", summary.Failed, summary.Total) - } - return nil -} - -type importIssue struct { - Title string - Description string - PriorityID int - TagIDs []int - AssignerIDs []int - MilestoneID int - BranchName string - StartDate string - DueDate string -} - -func readIssuesFromCSV(path string) ([]importIssue, error) { - file, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("open file: %w", err) - } - defer file.Close() - - reader := csv.NewReader(file) - reader.TrimLeadingSpace = true - records, err := reader.ReadAll() - if err != nil { - return nil, fmt.Errorf("parse CSV: %w", err) - } - if len(records) == 0 { - return nil, nil - } - - // Parse header to find column indices - header := records[0] - colIndex := make(map[string]int) - for i, col := range header { - colIndex[strings.ToLower(strings.TrimSpace(col))] = i - } - - issues := make([]importIssue, 0, len(records)-1) - for i, record := range records[1:] { - if len(record) == 0 { - continue - } - - issue := importIssue{ - PriorityID: 2, // default: normal - } - - // Title (required) - if idx, ok := colIndex["title"]; ok && idx < len(record) { - issue.Title = strings.TrimSpace(record[idx]) - } else if idx, ok := colIndex["subject"]; ok && idx < len(record) { - issue.Title = strings.TrimSpace(record[idx]) - } - if issue.Title == "" { - return nil, fmt.Errorf("row %d: title is required", i+2) - } - - // Description - if idx, ok := colIndex["description"]; ok && idx < len(record) { - issue.Description = strings.TrimSpace(record[idx]) - } else if idx, ok := colIndex["body"]; ok && idx < len(record) { - issue.Description = strings.TrimSpace(record[idx]) - } - - // Priority ID - if idx, ok := colIndex["priority_id"]; ok && idx < len(record) { - if val := strings.TrimSpace(record[idx]); val != "" { - if id, err := strconv.Atoi(val); err == nil { - issue.PriorityID = id - } - } - } - - // Tag IDs - if idx, ok := colIndex["tag_ids"]; ok && idx < len(record) { - if val := strings.TrimSpace(record[idx]); val != "" { - ids, err := parseIntList(val) - if err != nil { - return nil, fmt.Errorf("row %d: invalid tag_ids: %w", i+2, err) - } - issue.TagIDs = ids - } - } - - // Assigner IDs - if idx, ok := colIndex["assigner_ids"]; ok && idx < len(record) { - if val := strings.TrimSpace(record[idx]); val != "" { - ids, err := parseIntList(val) - if err != nil { - return nil, fmt.Errorf("row %d: invalid assigner_ids: %w", i+2, err) - } - issue.AssignerIDs = ids - } - } - - // Milestone ID - if idx, ok := colIndex["milestone_id"]; ok && idx < len(record) { - if val := strings.TrimSpace(record[idx]); val != "" { - if id, err := strconv.Atoi(val); err == nil { - issue.MilestoneID = id - } - } - } - - // Branch name - if idx, ok := colIndex["branch_name"]; ok && idx < len(record) { - issue.BranchName = strings.TrimSpace(record[idx]) - } - - // Start date - if idx, ok := colIndex["start_date"]; ok && idx < len(record) { - issue.StartDate = strings.TrimSpace(record[idx]) - } - - // Due date - if idx, ok := colIndex["due_date"]; ok && idx < len(record) { - issue.DueDate = strings.TrimSpace(record[idx]) - } - - issues = append(issues, issue) - } - - return issues, nil -} - -func parseIntList(value string) ([]int, error) { - parts := strings.Split(value, ",") - ids := make([]int, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - id, err := strconv.Atoi(part) - if err != nil { - return nil, fmt.Errorf("invalid ID %q: %w", part, err) - } - ids = append(ids, id) - } - return ids, nil -} - -func createIssueFromImport(ctx *common.RuntimeContext, issue importIssue) error { - body := map[string]interface{}{ - "subject": issue.Title, - "status_id": 1, // open - "priority_id": issue.PriorityID, - "done_ratio": 0, - } - - if issue.Description != "" { - body["description"] = issue.Description - } - if len(issue.TagIDs) > 0 { - body["issue_tag_ids"] = issue.TagIDs - } - if len(issue.AssignerIDs) > 0 { - body["assigner_ids"] = issue.AssignerIDs - } - if issue.MilestoneID > 0 { - body["fixed_version_id"] = issue.MilestoneID - } - if issue.BranchName != "" { - body["branch_name"] = issue.BranchName - } - if issue.StartDate != "" { - body["start_date"] = issue.StartDate - } - if issue.DueDate != "" { - body["due_date"] = issue.DueDate - } - - if _, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body); err != nil { - return fmt.Errorf("create issue: %w", err) - } - return nil -} diff --git a/shortcuts/issue/batch_test.go b/shortcuts/issue/batch_test.go index bc1b2b7..c7b3e21 100644 --- a/shortcuts/issue/batch_test.go +++ b/shortcuts/issue/batch_test.go @@ -1,11 +1,12 @@ package issue import ( - "net/http" "os" "path/filepath" "reflect" "testing" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) func TestParseIssueNumbers(t *testing.T) { @@ -206,6 +207,51 @@ func TestCollectIssueNumbersCSVReadError(t *testing.T) { } } +func TestReadIssueTextArgInline(t *testing.T) { + ctx := &common.RuntimeContext{Args: map[string]string{"body": "hello"}} + got, err := readIssueTextArg(ctx, "body", "body-file", true) + if err != nil { + t.Fatalf("readIssueTextArg returned error: %v", err) + } + if got != "hello" { + t.Fatalf("readIssueTextArg() = %q, want %q", got, "hello") + } +} + +func TestReadIssueTextArgFromFile(t *testing.T) { + path := writeTempText(t, "hello from file") + ctx := &common.RuntimeContext{Args: map[string]string{"body-file": path}} + got, err := readIssueTextArg(ctx, "body", "body-file", true) + if err != nil { + t.Fatalf("readIssueTextArg returned error: %v", err) + } + if got != "hello from file" { + t.Fatalf("readIssueTextArg() = %q, want %q", got, "hello from file") + } +} + +func TestReadIssueTextArgRejectsMixedSources(t *testing.T) { + path := writeTempText(t, "hello from file") + ctx := &common.RuntimeContext{Args: map[string]string{"body": "inline", "body-file": path}} + if _, err := readIssueTextArg(ctx, "body", "body-file", true); err == nil { + t.Fatal("expected readIssueTextArg to reject mixed inline and file sources") + } +} + +func TestPreviewTextTruncatesLongValue(t *testing.T) { + input := "" + for i := 0; i < 150; i++ { + input += "a" + } + got := previewText(input) + if len([]rune(got)) != 123 { + t.Fatalf("previewText() length = %d, want %d", len([]rune(got)), 123) + } + if got[len(got)-3:] != "..." { + t.Fatalf("previewText() = %q, want trailing ellipsis", got) + } +} + func writeTempCSV(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "issues.csv") @@ -215,186 +261,11 @@ func writeTempCSV(t *testing.T, content string) string { return path } -func TestBatchUpdateDryRun(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - err := runShortcut(t, server, "batch-update", map[string]string{ - "ids": "101,102", - "status-id": "3", - "priority-id": "2", - "tag-ids": "7,8", - "assigner-ids": "11", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("batch-update dry-run failed: %v", err) - } -} - -func TestBatchUpdateCallsAPI(t *testing.T) { - var payload map[string]interface{} - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/batch_update.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - err := runShortcut(t, server, "batch-update", map[string]string{ - "ids": "101,102,101", - "status-id": "3", - "priority-id": "2", - "milestone-id": "9", - "tag-ids": "7,8", - "assigner-ids": "11,12", - }) - if err != nil { - t.Fatalf("batch-update failed: %v", err) - } - assertFloatSlice(t, payload["ids"], []float64{101, 102}) - assertEqual(t, payload["status_id"], float64(3)) - assertEqual(t, payload["priority_id"], float64(2)) - assertEqual(t, payload["milestone_id"], float64(9)) - assertFloatSlice(t, payload["issue_tag_ids"], []float64{7, 8}) - assertFloatSlice(t, payload["assigner_ids"], []float64{11, 12}) -} - -func TestBatchUpdateRequiresUpdateField(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected API call: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - if err := runShortcut(t, server, "batch-update", map[string]string{"ids": "101"}); err == nil { - t.Fatal("expected error when no update fields are provided") - } -} - -func TestBatchUpdateRejectsInvalidIDs(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected API call: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - cases := []map[string]string{ - {"ids": "abc", "status-id": "3"}, - {"ids": "101", "status-id": "bad"}, - {"ids": "101", "tag-ids": "7,,8"}, - } - for _, args := range cases { - if err := runShortcut(t, server, "batch-update", args); err == nil { - t.Fatalf("expected validation error for args %#v", args) - } - } -} - -func TestBatchDeleteDryRun(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - if err := runShortcut(t, server, "batch-delete", map[string]string{"ids": "101,102", "dry-run": "true"}); err != nil { - t.Fatalf("batch-delete dry-run failed: %v", err) - } -} - -func TestBatchDeleteRequiresYes(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected API call without --yes: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - if err := runShortcut(t, server, "batch-delete", map[string]string{"ids": "101"}); err == nil { - t.Fatal("expected --yes confirmation error") - } -} - -func TestBatchDeleteCallsAPIWithYes(t *testing.T) { - var payload map[string]interface{} - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/issues/batch_destroy.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - if err := runShortcut(t, server, "batch-delete", map[string]string{"ids": "101,102,101", "yes": "true"}); err != nil { - t.Fatalf("batch-delete failed: %v", err) - } - assertFloatSlice(t, payload["ids"], []float64{101, 102}) -} - -func assertFloatSlice(t *testing.T, got interface{}, want []float64) { +func writeTempText(t *testing.T, content string) string { t.Helper() - items, ok := got.([]interface{}) - if !ok { - t.Fatalf("got %#v, want []interface{}", got) - } - if len(items) != len(want) { - t.Fatalf("got len %d, want %d: %#v", len(items), len(want), got) - } - for i := range want { - if items[i] != want[i] { - t.Fatalf("item %d = %#v, want %#v", i, items[i], want[i]) - } - } -} - -func TestBatchReopenDryRun(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - if err := runShortcut(t, server, "batch-reopen", map[string]string{ - "numbers": "1,2,3", - "dry-run": "true", - }); err != nil { - t.Fatalf("batch-reopen dry-run failed: %v", err) - } -} - -func TestBatchReopenRequiresNumbers(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected API call: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - if err := runShortcut(t, server, "batch-reopen", map[string]string{}); err == nil { - t.Fatal("expected error when no issue numbers are provided") - } -} - -func TestBatchCommentDryRun(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - if err := runShortcut(t, server, "batch-comment", map[string]string{ - "numbers": "1,2", - "body": "hello", - "dry-run": "true", - }); err != nil { - t.Fatalf("batch-comment dry-run failed: %v", err) - } -} - -func TestBatchCommentRequiresBody(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected API call: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - if err := runShortcut(t, server, "batch-comment", map[string]string{"numbers": "1"}); err == nil { - t.Fatal("expected error when --body is missing") + path := filepath.Join(t.TempDir(), "body.md") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp text: %v", err) } + return path } diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 60f5d1e..abbb994 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -45,9 +45,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) return []*common.Shortcut{ newBatchCloseShortcut(), + newBatchCommentShortcut(), newBatchUpdateShortcut(), - newBatchDeleteShortcut(), - newExportShortcut(tr), { Name: "list", Description: tr.T("cmd.issue.list.short"), @@ -116,6 +115,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Flags: []common.Flag{ {Name: "title", Short: "t", Usage: tr.T("flag.issue.title"), Required: true}, {Name: "body", Short: "b", Usage: tr.T("flag.issue.body")}, + {Name: "body-file", Usage: "Read issue description from a file"}, {Name: "assignee", Short: "a", Usage: tr.T("flag.issue.assignee")}, {Name: "milestone", Short: "m", Usage: tr.T("flag.issue.milestone")}, {Name: "label", Usage: tr.T("flag.issue.label")}, @@ -140,7 +140,11 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { "priority_id": 2, // 2 = normal "done_ratio": 0, } - if desc := ctx.Arg("body"); desc != "" { + desc, err := readIssueTextArg(ctx, "body", "body-file", false) + if err != nil { + return err + } + if desc != "" { body["description"] = desc } if a := ctx.Arg("assignee"); a != "" { @@ -214,6 +218,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Flags: appendIssueNumberFlags( common.Flag{Name: "title", Short: "t", Usage: tr.T("flag.issue.new_title")}, common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.issue.new_body")}, + common.Flag{Name: "body-file", Usage: "Read the updated issue description from a file"}, common.Flag{Name: "state", Short: "s", Usage: tr.T("flag.issue.new_state")}, common.Flag{Name: "priority-id", Usage: "New priority ID"}, common.Flag{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"}, @@ -230,37 +235,16 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - title := ctx.Arg("title") - description := ctx.Arg("body") - state := ctx.Arg("state") - if title == "" && description == "" && state == "" && !hasIssueMetadataArgs(ctx) { - return fmt.Errorf("at least one update field is required") + if _, err := buildIssueUpdatePreview(ctx); err != nil { + return err } - current, err := fetchExistingIssue(ctx, number) if err != nil { return err } - body := map[string]interface{}{ - "subject": current.Subject, - "description": current.Description, - } - preserveIssueMetadata(body, current) - if t := ctx.Arg("title"); t != "" { - body["subject"] = t - } - if b := ctx.Arg("body"); b != "" { - body["description"] = b - } - if s := ctx.Arg("state"); s != "" { - statusID, err := normalizeIssueStatus(s) - if err != nil { - return err - } - body["status_id"] = statusID - } - if err := applyIssueMetadataArgs(ctx, body); err != nil { + body, err := buildIssueUpdateBody(ctx, current) + if err != nil { return err } env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) @@ -274,56 +258,27 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "comment", Description: tr.T("cmd.issue.comment.short"), Flags: appendIssueNumberFlags( - common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, - common.Flag{Name: "parent-id", Usage: "Parent comment ID for a threaded reply"}, - common.Flag{Name: "reply-id", Usage: "Comment ID being replied to"}, - common.Flag{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, - common.Flag{Name: "receivers", Usage: "Comma-separated user logins to mention"}, + common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body")}, + common.Flag{Name: "body-file", Usage: "Read comment body from a file"}, ), - Run: runIssueComment, - }, - { - Name: "comments", - Description: "List issue comments and operation records", - Flags: appendIssueNumberFlags( - common.Flag{Name: "category", Short: "c", Usage: "Filter by all, comment, or operate", Default: "comment"}, - common.Flag{Name: "keyword", Short: "k", Usage: "Search comment content"}, - common.Flag{Name: "sort-by", Usage: "Sort field: created_on or updated_on"}, - common.Flag{Name: "sort-direction", Usage: "Sort direction: asc or desc"}, - common.Flag{Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - common.Flag{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - ), - Run: runIssueComments, - }, - { - Name: "comment-update", - Description: "Update an issue comment", - Flags: appendIssueNumberFlags( - common.Flag{Name: "comment-id", Usage: "Comment ID", Required: true}, - common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, - common.Flag{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, - common.Flag{Name: "receivers", Usage: "Comma-separated user logins to mention"}, - ), - Run: runIssueCommentUpdate, - }, - { - Name: "comment-delete", - Description: "Delete an issue comment", - Flags: appendIssueNumberFlags( - common.Flag{Name: "comment-id", Usage: "Comment ID", Required: true}, - ), - Run: runIssueCommentDelete, - }, - { - Name: "comment-replies", - Description: "List replies under an issue comment", - Flags: appendIssueNumberFlags( - common.Flag{Name: "comment-id", Usage: "Parent comment ID", Required: true}, - common.Flag{Name: "keyword", Short: "k", Usage: "Search reply content"}, - common.Flag{Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - common.Flag{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - ), - Run: runIssueCommentReplies, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + body, err := readIssueTextArg(ctx, "body", "body-file", true) + if err != nil { + return err + } + env, err := commentOnIssue(ctx, number, body) + if err != nil { + return err + } + return ctx.Output(env) + }, }, { Name: "assigners", @@ -473,183 +428,6 @@ func issueNumberArg(ctx *common.RuntimeContext) (string, error) { return "", fmt.Errorf("required flag --number is missing (or use --id as a compatibility alias)") } -func runIssueComment(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, err := issueNumberArg(ctx) - if err != nil { - return err - } - body, err := ctx.RequireArg("body") - if err != nil { - return err - } - payload, err := issueCommentPayload(ctx, body, true) - if err != nil { - return err - } - env, err := ctx.CallAPI("POST", issueJournalPath(ctx, number), payload) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runIssueComments(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, err := issueNumberArg(ctx) - if err != nil { - return err - } - q := url.Values{} - setIssueQueryIfPresent(q, "category", ctx.Arg("category")) - setIssueQueryIfPresent(q, "keyword", ctx.Arg("keyword")) - setIssueQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) - setIssueQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) - setIssueQueryIfPresent(q, "page", ctx.Arg("page")) - setIssueQueryIfPresent(q, "limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", issueJournalPath(ctx, number), q) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runIssueCommentUpdate(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, commentID, err := issueCommentTarget(ctx) - if err != nil { - return err - } - body, err := ctx.RequireArg("body") - if err != nil { - return err - } - payload, err := issueCommentPayload(ctx, body, false) - if err != nil { - return err - } - env, err := ctx.CallAPI("PATCH", issueJournalItemPath(ctx, number, commentID), payload) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runIssueCommentDelete(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, commentID, err := issueCommentTarget(ctx) - if err != nil { - return err - } - env, err := ctx.CallAPI("DELETE", issueJournalItemPath(ctx, number, commentID), nil) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runIssueCommentReplies(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, commentID, err := issueCommentTarget(ctx) - if err != nil { - return err - } - q := url.Values{} - setIssueQueryIfPresent(q, "keyword", ctx.Arg("keyword")) - setIssueQueryIfPresent(q, "page", ctx.Arg("page")) - setIssueQueryIfPresent(q, "limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", issueJournalItemPath(ctx, number, commentID)+"/children_journals", q) - if err != nil { - return err - } - return ctx.Output(env) -} - -func issueJournalPath(ctx *common.RuntimeContext, number string) string { - return fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), url.PathEscape(number)) -} - -func issueJournalItemPath(ctx *common.RuntimeContext, number, commentID string) string { - return fmt.Sprintf("%s/%s", issueJournalPath(ctx, number), url.PathEscape(commentID)) -} - -func issueCommentTarget(ctx *common.RuntimeContext) (string, string, error) { - number, err := issueNumberArg(ctx) - if err != nil { - return "", "", err - } - commentID, err := ctx.RequireArg("comment-id") - if err != nil { - return "", "", err - } - if _, err := parseIssueID(commentID, "comment-id"); err != nil { - return "", "", err - } - return number, strings.TrimSpace(commentID), nil -} - -func issueCommentPayload(ctx *common.RuntimeContext, body string, includeThreading bool) (map[string]interface{}, error) { - payload := map[string]interface{}{"notes": body} - if includeThreading { - if parentID := ctx.Arg("parent-id"); parentID != "" { - id, err := parseIssueID(parentID, "parent-id") - if err != nil { - return nil, err - } - payload["parent_id"] = id - } - if replyID := ctx.Arg("reply-id"); replyID != "" { - id, err := parseIssueID(replyID, "reply-id") - if err != nil { - return nil, err - } - payload["reply_id"] = id - } - } - if attachmentIDs := ctx.Arg("attachment-ids"); attachmentIDs != "" { - ids, err := parseIssueIDList(attachmentIDs, "attachment-ids") - if err != nil { - return nil, err - } - payload["attachment_ids"] = ids - } - if receivers := parseIssueStringList(ctx.Arg("receivers")); len(receivers) > 0 { - payload["receivers_login"] = receivers - } - return payload, nil -} - -func setIssueQueryIfPresent(q url.Values, name, value string) { - if strings.TrimSpace(value) != "" { - q.Set(name, strings.TrimSpace(value)) - } -} - -func parseIssueStringList(value string) []string { - parts := strings.Split(value, ",") - result := make([]string, 0, len(parts)) - seen := map[string]bool{} - for _, part := range parts { - item := strings.TrimSpace(part) - if item == "" || seen[item] { - continue - } - seen[item] = true - result = append(result, item) - } - return result -} - // normalizeIssueListIDs adds "number" (project_issues_index) and renames // "id" to "database_id" so the user-facing output uses the project-level // issue number, not the global database primary key. @@ -781,6 +559,41 @@ func normalizeIssueStatus(state string) (interface{}, error) { } } +func buildIssueUpdateBody(ctx *common.RuntimeContext, current *existingIssue) (map[string]interface{}, error) { + description, err := readIssueTextArg(ctx, "body", "body-file", false) + if err != nil { + return nil, err + } + title := ctx.Arg("title") + state := ctx.Arg("state") + if title == "" && description == "" && state == "" && !hasIssueMetadataArgs(ctx) { + return nil, fmt.Errorf("at least one update field is required") + } + + body := map[string]interface{}{ + "subject": current.Subject, + "description": current.Description, + } + preserveIssueMetadata(body, current) + if title != "" { + body["subject"] = title + } + if description != "" { + body["description"] = description + } + if state != "" { + statusID, err := normalizeIssueStatus(state) + if err != nil { + return nil, err + } + body["status_id"] = statusID + } + if err := applyIssueMetadataArgs(ctx, body); err != nil { + return nil, err + } + return body, nil +} + func hasIssueMetadataArgs(ctx *common.RuntimeContext) bool { for _, name := range []string{"priority-id", "tag-ids", "label", "assigner-ids", "branch", "start-date", "due-date"} { if ctx.Arg(name) != "" { diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 70168ea..d613157 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -4,8 +4,6 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "os" - "path/filepath" "strings" "testing" @@ -96,58 +94,6 @@ func assertNumberSlice(t *testing.T, got interface{}, want []float64) { } } -func assertStringSliceEqual(t *testing.T, got, want []string) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("got %v, want %v", got, want) - } - for i := range got { - if got[i] != want[i] { - t.Fatalf("got %v, want %v", got, want) - } - } -} - -func assertNumberSliceEqual(t *testing.T, got, want []int) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("got %v, want %v", got, want) - } - for i := range got { - if got[i] != want[i] { - t.Fatalf("got %v, want %v", got, want) - } - } -} - -func interfaceSliceToStrings(value interface{}) []string { - items, ok := value.([]interface{}) - if !ok { - return nil - } - out := make([]string, 0, len(items)) - for _, item := range items { - if s, ok := item.(string); ok { - out = append(out, s) - } - } - return out -} - -func interfaceSliceToInts(value interface{}) []int { - items, ok := value.([]interface{}) - if !ok { - return nil - } - out := make([]int, 0, len(items)) - for _, item := range items { - if n, ok := item.(float64); ok { - out = append(out, int(n)) - } - } - return out -} - // --- list --- func TestIssueList(t *testing.T) { @@ -225,183 +171,6 @@ func TestIssueListStateAll(t *testing.T) { } } -// --- export --- - -func TestIssueExportCSVWithFiltersAndPagination(t *testing.T) { - var pages []string - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Fatalf("expected GET, got %s", r.Method) - } - if r.URL.Path != "/v1/owner/repo/issues.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - query := r.URL.Query() - assertEqual(t, query.Get("category"), "opened") - assertEqual(t, query.Get("keyword"), "release") - assertEqual(t, query.Get("participant_category"), "assignedme") - assertEqual(t, query.Get("author_id"), "10") - assertEqual(t, query.Get("assigner_id"), "11") - assertEqual(t, query.Get("milestone_id"), "12") - assertEqual(t, query.Get("status_id"), "1") - assertEqual(t, query.Get("issue_tag_ids"), "2,3") - assertEqual(t, query.Get("sort_by"), "issues.updated_on") - assertEqual(t, query.Get("sort_direction"), "desc") - assertEqual(t, query.Get("limit"), "2") - pages = append(pages, query.Get("page")) - - switch query.Get("page") { - case "1": - writeJSON(t, w, map[string]interface{}{ - "total_count": 3, - "issues": []interface{}{ - map[string]interface{}{ - "id": float64(101), - "project_issues_index": float64(1), - "subject": "release blocker", - "status": map[string]interface{}{"id": float64(1), "name": "New"}, - "priority": map[string]interface{}{"id": float64(2), "name": "Normal"}, - "author": map[string]interface{}{"login": "alice"}, - "assigners": []interface{}{ - map[string]interface{}{"login": "bob"}, - }, - "tags": []interface{}{ - map[string]interface{}{"name": "bug"}, - }, - "updated_on": "2026-06-01", - }, - map[string]interface{}{ - "id": float64(102), - "project_issues_index": float64(2), - "subject": "release notes", - "status": map[string]interface{}{"id": float64(1), "name": "New"}, - "priority": map[string]interface{}{"id": float64(3), "name": "High"}, - "author": map[string]interface{}{"login": "carol"}, - "updated_on": "2026-06-02", - }, - }, - }) - case "2": - writeJSON(t, w, map[string]interface{}{ - "total_count": 3, - "issues": []interface{}{ - map[string]interface{}{ - "id": float64(103), - "project_issues_index": float64(3), - "subject": "release checklist", - "status": map[string]interface{}{"id": float64(5), "name": "Closed"}, - "priority": map[string]interface{}{"id": float64(2), "name": "Normal"}, - "author": map[string]interface{}{"login": "dave"}, - "updated_on": "2026-06-03", - }, - }, - }) - default: - t.Fatalf("unexpected page %s", query.Get("page")) - } - }) - defer server.Close() - - outputPath := filepath.Join(t.TempDir(), "issues.csv") - err := runShortcut(t, server, "export", map[string]string{ - "state": "open", - "keyword": "release", - "participant": "assignedme", - "author-id": "10", - "assignee-id": "11", - "milestone-id": "12", - "status-id": "1", - "tag-ids": "2,3", - "sort-by": "issues.updated_on", - "sort-direction": "desc", - "limit": "2", - "fields": "number,title,status,priority,author,assignees,tags,updated_at,url", - "export-format": "csv", - "output": outputPath, - }) - if err != nil { - t.Fatalf("export failed: %v", err) - } - assertEqual(t, strings.Join(pages, ","), "1,2") - content, err := os.ReadFile(outputPath) - if err != nil { - t.Fatalf("read export file: %v", err) - } - got := string(content) - if !strings.Contains(got, "number,title,status,priority,author,assignees,tags,updated_at,url") { - t.Fatalf("missing csv header: %s", got) - } - if !strings.Contains(got, "1,release blocker,New,Normal,alice,bob,bug,2026-06-01,https://www.gitlink.org.cn/owner/repo/issues/1") { - t.Fatalf("missing first issue row: %s", got) - } - if !strings.Contains(got, "3,release checklist,Closed,Normal,dave,,,2026-06-03,https://www.gitlink.org.cn/owner/repo/issues/3") { - t.Fatalf("missing second page issue row: %s", got) - } -} - -func TestIssueExportMaxStopsWithinPage(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("page") != "1" { - t.Fatalf("unexpected page: %s", r.URL.Query().Get("page")) - } - writeJSON(t, w, map[string]interface{}{ - "total_count": 3, - "issues": []interface{}{ - map[string]interface{}{"project_issues_index": float64(1), "subject": "one"}, - map[string]interface{}{"project_issues_index": float64(2), "subject": "two"}, - }, - }) - }) - defer server.Close() - - outputPath := filepath.Join(t.TempDir(), "issues.json") - err := runShortcut(t, server, "export", map[string]string{ - "limit": "2", - "max": "1", - "fields": "number,title", - "export-format": "json", - "output": outputPath, - }) - if err != nil { - t.Fatalf("export failed: %v", err) - } - content, err := os.ReadFile(outputPath) - if err != nil { - t.Fatalf("read export file: %v", err) - } - if strings.Contains(string(content), `"title": "two"`) { - t.Fatalf("expected max=1 to omit second issue: %s", content) - } - if !strings.Contains(string(content), `"title": "one"`) { - t.Fatalf("expected first issue in json export: %s", content) - } -} - -func TestIssueExportMarkdownEscapesCells(t *testing.T) { - content := renderIssueExportMarkdown([]issueExportRecord{ - {"number": "1", "title": "pipe | newline\ntext"}, - }, []string{"number", "title"}) - got := string(content) - if !strings.Contains(got, "pipe \\| newline text") { - t.Fatalf("markdown cell not escaped: %s", got) - } -} - -func TestIssueExportRejectsInvalidOptions(t *testing.T) { - if _, err := parseIssueExportFields("number,unknown"); err == nil { - t.Fatal("expected invalid field error") - } - if _, err := normalizeIssueExportFormat("xml"); err == nil { - t.Fatal("expected invalid format error") - } - if _, err := boundedPositiveInt("0", 50, 100, "limit"); err == nil { - t.Fatal("expected invalid limit error") - } - if _, err := nonNegativeInt("-1", "max"); err == nil { - t.Fatal("expected invalid max error") - } -} - // --- create --- func TestIssueCreate(t *testing.T) { @@ -466,6 +235,45 @@ func TestIssueCreateSupportsMetadataFields(t *testing.T) { assertEqual(t, createPayload["due_date"], "2026-05-31") } +func TestIssueCreateAcceptsBodyFile(t *testing.T) { + var createPayload map[string]interface{} + bodyPath := writeTempText(t, "Body from file") + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + createPayload = decodeJSON(t, r) + writeJSON(t, w, createPayload) + }) + defer server.Close() + + err := runShortcut(t, server, "create", map[string]string{ + "title": "Issue from file", + "body-file": bodyPath, + }) + if err != nil { + t.Fatalf("create with body-file failed: %v", err) + } + assertEqual(t, createPayload["description"], "Body from file") +} + +func TestIssueCreateRejectsBodyAndBodyFile(t *testing.T) { + bodyPath := writeTempText(t, "Body from file") + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + }) + defer server.Close() + + err := runShortcut(t, server, "create", map[string]string{ + "title": "Issue from file", + "body": "inline", + "body-file": bodyPath, + }) + if err == nil { + t.Fatal("expected create to reject mixed body sources") + } +} + func TestIssueCreateMissingTitle(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") @@ -848,6 +656,35 @@ func TestIssueUpdateSupportsMetadataFields(t *testing.T) { assertEqual(t, updatePayload["due_date"], "2026-06-15") } +func TestIssueUpdateAcceptsBodyFile(t *testing.T) { + var updatePayload map[string]interface{} + bodyPath := writeTempText(t, "Updated body from file") + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "update", map[string]string{ + "number": "42", + "body-file": bodyPath, + }) + if err != nil { + t.Fatalf("update with body-file failed: %v", err) + } + assertEqual(t, updatePayload["description"], "Updated body from file") +} + func TestIssueUpdateInvalidState(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { @@ -948,8 +785,9 @@ func TestIssueCommentAcceptsIDAlias(t *testing.T) { assertEqual(t, commentPayload["notes"], "Fixed") } -func TestIssueCommentSupportsThreadingAttachmentsAndReceivers(t *testing.T) { +func TestIssueCommentAcceptsBodyFile(t *testing.T) { var commentPayload map[string]interface{} + bodyPath := writeTempText(t, "Comment from file") server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) @@ -960,121 +798,29 @@ func TestIssueCommentSupportsThreadingAttachmentsAndReceivers(t *testing.T) { defer server.Close() err := runShortcut(t, server, "comment", map[string]string{ - "number": "42", - "body": "Reply with context", - "parent-id": "10", - "reply-id": "11", - "attachment-ids": "5, 6", - "receivers": "alice, bob, alice", + "number": "42", + "body-file": bodyPath, }) if err != nil { - t.Fatalf("comment shortcut failed: %v", err) + t.Fatalf("comment with body-file failed: %v", err) } - assertEqual(t, commentPayload["notes"], "Reply with context") - assertEqual(t, commentPayload["parent_id"], float64(10)) - assertEqual(t, commentPayload["reply_id"], float64(11)) - assertStringSliceEqual(t, interfaceSliceToStrings(commentPayload["receivers_login"]), []string{"alice", "bob"}) - assertNumberSliceEqual(t, interfaceSliceToInts(commentPayload["attachment_ids"]), []int{5, 6}) + assertEqual(t, commentPayload["notes"], "Comment from file") } -func TestIssueCommentsListSendsFilters(t *testing.T) { +func TestIssueCommentRejectsBodyAndBodyFile(t *testing.T) { + bodyPath := writeTempText(t, "Comment from file") server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - q := r.URL.Query() - assertEqual(t, q.Get("category"), "all") - assertEqual(t, q.Get("keyword"), "panic") - assertEqual(t, q.Get("sort_by"), "updated_on") - assertEqual(t, q.Get("sort_direction"), "desc") - assertEqual(t, q.Get("page"), "2") - assertEqual(t, q.Get("limit"), "50") - writeJSON(t, w, map[string]interface{}{ - "total_count": float64(1), - "journals": []interface{}{ - map[string]interface{}{"id": float64(7), "notes": "panic fixed"}, - }, - }) + t.Fatal("no API call expected") }) defer server.Close() - err := runShortcut(t, server, "comments", map[string]string{ - "number": "42", - "category": "all", - "keyword": "panic", - "sort-by": "updated_on", - "sort-direction": "desc", - "page": "2", - "limit": "50", + err := runShortcut(t, server, "comment", map[string]string{ + "number": "42", + "body": "inline", + "body-file": bodyPath, }) - if err != nil { - t.Fatalf("comments shortcut failed: %v", err) - } -} - -func TestIssueCommentUpdate(t *testing.T) { - var commentPayload map[string]interface{} - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/42/journals/9.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - commentPayload = decodeJSON(t, r) - writeJSON(t, w, commentPayload) - }) - defer server.Close() - - err := runShortcut(t, server, "comment-update", map[string]string{ - "number": "42", - "comment-id": "9", - "body": "Updated", - "attachment-ids": "8", - "receivers": "alice", - }) - if err != nil { - t.Fatalf("comment-update failed: %v", err) - } - assertEqual(t, commentPayload["notes"], "Updated") - assertNumberSliceEqual(t, interfaceSliceToInts(commentPayload["attachment_ids"]), []int{8}) - assertStringSliceEqual(t, interfaceSliceToStrings(commentPayload["receivers_login"]), []string{"alice"}) -} - -func TestIssueCommentDelete(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/issues/42/journals/9.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "success"}) - }) - defer server.Close() - - err := runShortcut(t, server, "comment-delete", map[string]string{"number": "42", "comment-id": "9"}) - if err != nil { - t.Fatalf("comment-delete failed: %v", err) - } -} - -func TestIssueCommentReplies(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals/9/children_journals.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - q := r.URL.Query() - assertEqual(t, q.Get("keyword"), "thanks") - assertEqual(t, q.Get("page"), "3") - assertEqual(t, q.Get("limit"), "10") - writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "journals": []interface{}{}}) - }) - defer server.Close() - - err := runShortcut(t, server, "comment-replies", map[string]string{ - "number": "42", - "comment-id": "9", - "keyword": "thanks", - "page": "3", - "limit": "10", - }) - if err != nil { - t.Fatalf("comment-replies failed: %v", err) + if err == nil { + t.Fatal("expected comment to reject mixed body sources") } } @@ -1104,10 +850,6 @@ func TestIssueNumberOrIDIsRequired(t *testing.T) { {name: "close", args: map[string]string{}}, {name: "update", args: map[string]string{"title": "New title"}}, {name: "comment", args: map[string]string{"body": "Fixed"}}, - {name: "comments", args: map[string]string{}}, - {name: "comment-update", args: map[string]string{"comment-id": "9", "body": "Updated"}}, - {name: "comment-delete", args: map[string]string{"comment-id": "9"}}, - {name: "comment-replies", args: map[string]string{"comment-id": "9"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -1122,32 +864,6 @@ func TestIssueNumberOrIDIsRequired(t *testing.T) { } } -func TestIssueCommentRejectsInvalidIDs(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("invalid IDs should not call API, got %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - cases := []struct { - name string - cmd string - args map[string]string - }{ - {name: "bad parent", cmd: "comment", args: map[string]string{"number": "42", "body": "x", "parent-id": "abc"}}, - {name: "bad attachment", cmd: "comment", args: map[string]string{"number": "42", "body": "x", "attachment-ids": "1,,"}}, - {name: "bad update comment", cmd: "comment-update", args: map[string]string{"number": "42", "comment-id": "0", "body": "x"}}, - {name: "bad delete comment", cmd: "comment-delete", args: map[string]string{"number": "42", "comment-id": "-1"}}, - {name: "bad replies comment", cmd: "comment-replies", args: map[string]string{"number": "42", "comment-id": "abc"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if err := runShortcut(t, server, tc.cmd, tc.args); err == nil { - t.Fatal("expected validation error") - } - }) - } -} - // --- batch-close --- func TestBatchClosePreservesCurrentDescription(t *testing.T) { @@ -1242,6 +958,141 @@ func TestBatchCloseWithFailedClose(t *testing.T) { } } +func TestBatchCommentDryRun(t *testing.T) { + bodyPath := writeTempText(t, "Batch comment from file") + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected in dry-run mode") + }) + defer server.Close() + + err := runShortcut(t, server, "batch-comment", map[string]string{ + "numbers": "1,2", + "body-file": bodyPath, + "dry-run": "true", + }) + if err != nil { + t.Fatalf("batch-comment dry-run failed: %v", err) + } +} + +func TestBatchCommentUsesBodyFile(t *testing.T) { + bodyPath := writeTempText(t, "Batch comment from file") + seen := map[string]string{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Fatalf("expected POST, got %s", r.Method) + } + payload := decodeJSON(t, r) + seen[r.URL.Path] = payload["notes"].(string) + writeJSON(t, w, map[string]interface{}{"ok": true}) + }) + defer server.Close() + + err := runShortcut(t, server, "batch-comment", map[string]string{ + "numbers": "1,2", + "body-file": bodyPath, + }) + if err != nil { + t.Fatalf("batch-comment failed: %v", err) + } + assertEqual(t, seen["/v1/owner/repo/issues/1/journals.json"], "Batch comment from file") + assertEqual(t, seen["/v1/owner/repo/issues/2/journals.json"], "Batch comment from file") +} + +func TestBatchUpdateDryRun(t *testing.T) { + bodyPath := writeTempText(t, "Updated in bulk") + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected in dry-run mode") + }) + defer server.Close() + + err := runShortcut(t, server, "batch-update", map[string]string{ + "numbers": "1,2", + "title": "Bulk title", + "body-file": bodyPath, + "state": "closed", + "priority-id": "4", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("batch-update dry-run failed: %v", err) + } +} + +func TestBatchUpdateAppliesSharedChanges(t *testing.T) { + var updatePayloads []map[string]interface{} + bodyPath := writeTempText(t, "Bulk body from file") + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && (r.URL.Path == "/v1/owner/repo/issues/1.json" || r.URL.Path == "/v1/owner/repo/issues/2.json"): + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + "status": map[string]interface{}{"id": 1}, + "priority": map[string]interface{}{"id": 2}, + "tags": []map[string]interface{}{ + {"id": 9}, + }, + }) + case r.Method == "PATCH" && (r.URL.Path == "/v1/owner/repo/issues/1.json" || r.URL.Path == "/v1/owner/repo/issues/2.json"): + payload := decodeJSON(t, r) + updatePayloads = append(updatePayloads, payload) + writeJSON(t, w, payload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "batch-update", map[string]string{ + "numbers": "1,2", + "title": "Bulk title", + "body-file": bodyPath, + "state": "closed", + "priority-id": "4", + "tag-ids": "7,8", + }) + if err != nil { + t.Fatalf("batch-update failed: %v", err) + } + if len(updatePayloads) != 2 { + t.Fatalf("expected 2 update payloads, got %d", len(updatePayloads)) + } + for _, payload := range updatePayloads { + assertEqual(t, payload["subject"], "Bulk title") + assertEqual(t, payload["description"], "Bulk body from file") + assertEqual(t, payload["status_id"], float64(5)) + assertEqual(t, payload["priority_id"], float64(4)) + assertNumberSlice(t, payload["issue_tag_ids"], []float64{7, 8}) + } +} + +func TestBatchUpdateContinuesAfterFailure(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json": + writeJSON(t, w, map[string]interface{}{"subject": "Issue 1", "description": "desc1"}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/2.json": + writeJSON(t, w, map[string]interface{}{"subject": "Issue 2", "description": "desc2"}) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/1.json": + writeJSON(t, w, map[string]interface{}{"ok": true}) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/2.json": + writeText(t, w, http.StatusInternalServerError, "server error") + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "batch-update", map[string]string{ + "numbers": "1,2", + "state": "closed", + }) + if err == nil { + t.Fatal("expected batch-update to return an error when one issue fails") + } +} + // --- issue users --- func TestIssueAssignersShortcutWithKeyword(t *testing.T) { diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index c96d352..39a2c67 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -1,142 +1,95 @@ --- name: gitlink-issue -version: 3.0.0 -description: "Issue 管理:创建、查看、更新、关闭/批量关闭/批量更新/批量删除 Issue,添加评论,查看动态记录和活动。当用户需要操作 GitLink Issue 时触发。" +version: 2.1.0 +description: "GitLink Issue 管理:创建、查看、更新、关闭、评论,以及批量关闭、批量评论、批量更新。" metadata: requires: bins: ["gitlink-cli"] cliHelp: "gitlink-cli issue --help" --- -# gitlink-issue(Issue 操作) +# gitlink-issue -**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** -**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** -**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** +> 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),确认认证方式、全局参数和安全约束。 -> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 +## 适用场景 -## Shortcuts +- 维护者需要快速创建、更新、关闭或评论 Issue。 +- Agent 需要基于仓库内的 Issue 批量做运营动作,例如统一补评论、统一更新优先级、统一补截止日期。 +- 需要从 Markdown 文件读取长文本,避免把大段内容直接塞进命令行参数。 -| Shortcut | 说明 | 需要认证 | -|----------|------|----------| -| `issue +list` | Issue 列表 | 否(公开项目) | -| `issue +create` | 创建 Issue | 是 | -| `issue +view` | Issue 详情 | 否(公开项目) | -| `issue +update` | 更新 Issue | 是 | -| `issue +close` | 关闭 Issue | 是 | -| `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | -| `issue +batch-update` | 按 API issue id 批量更新状态、优先级、里程碑、标签、负责人 | 是(dry-run 不写入) | -| `issue +batch-delete` | 按 API issue id 批量删除 Issue;真实删除必须 `--yes` | 是(dry-run 不写入) | -| `issue +export` | 按筛选条件批量导出 Issue 到 CSV/JSON/Markdown | 否(公开项目) | -| `issue +comment` | 添加评论 | 是 | -| `issue +journals` | 查询 Issue 动态记录,支持分类和分页 | 否(公开项目) | -| `issue +activity` | 查询 Issue 活动事件,复用 journals 端点 | 否(公开项目) | -| `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) | -| `issue +authors` | 查询 Issue 发布人列表 | 否(公开项目) | -| `issue +statuses` | 查询 Issue 状态列表 | 否(公开项目) | -| `issue +tags` | 查询 Issue 标签列表 | 否(公开项目) | -| `issue +priorities` | 查询 Issue 优先级列表 | 否(公开项目) | +## 常用命令 -## 使用示例 +| Shortcut | 用途 | 是否写操作 | +| --- | --- | --- | +| `issue +list` | 列出 Issue | 否 | +| `issue +view` | 查看单个 Issue 详情 | 否 | +| `issue +create` | 创建 Issue,支持 `--body-file` | 是 | +| `issue +update` | 更新单个 Issue,支持 `--body-file` | 是 | +| `issue +close` | 关闭单个 Issue | 是 | +| `issue +comment` | 给单个 Issue 添加评论,支持 `--body-file` | 是 | +| `issue +batch-close` | 按编号或 CSV 批量关闭 Issue | 是 | +| `issue +batch-comment` | 按编号或 CSV 批量评论 | 是 | +| `issue +batch-update` | 按编号或 CSV 批量更新元数据 | 是 | +| `issue +assigners` | 列出可分配负责人 | 否 | +| `issue +authors` | 列出 Issue 作者 | 否 | +| `issue +priorities` | 列出优先级 | 否 | +| `issue +tags` | 列出标签 | 否 | +| `issue +statuses` | 列出状态 | 否 | + +## 使用方式 ```bash -# 列出 Issue -gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open +# 创建一个 Issue +gitlink-cli issue +create --owner Gitlink --repo forgeplus \ + --title "Bug: 登录失败" \ + --body-file issue.md -# 搜索并排序 Issue -gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open --keyword 登录 --sort-by issues.updated_on --sort-direction desc +# 更新单个 Issue 的优先级和截止日期 +gitlink-cli issue +update --owner Gitlink --repo forgeplus \ + --number 42 \ + --priority-id 4 \ + --due-date 2026-06-15 -# 创建 Issue -gitlink-cli issue +create --owner myuser --repo myrepo --title "Bug: 登录失败" --body "复现步骤:..." +# 通过文件给单个 Issue 添加长评论 +gitlink-cli issue +comment --owner Gitlink --repo forgeplus \ + --number 42 \ + --body-file comment.md -# 查看 Issue 详情(使用网页可见的 Issue 编号) -gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 4 +# 预览批量评论 +gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus \ + --numbers 42,43,44 \ + --body-file comment.md \ + --dry-run -# 更新 Issue -gitlink-cli issue +update --number 4 --title "新标题" --body "更新描述" - -# 关闭 Issue -gitlink-cli issue +close --number 4 - -# 预览批量关闭 Issue,不修改数据 -gitlink-cli issue +batch-close --owner myuser --repo myrepo --numbers 123,124 --dry-run - -# 从 CSV 文件批量关闭 Issue -gitlink-cli issue +batch-close --owner myuser --repo myrepo --from issues.csv - -# 按 API issue id 预览批量更新元数据(注意不是网页 Issue 编号) -gitlink-cli issue +batch-update --owner myuser --repo myrepo --ids 101,102 --status-id 3 --priority-id 2 --dry-run - -# 危险批量删除:必须先 dry-run,真实执行还要 --yes -gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --dry-run -gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --yes - -# 导出打开的 Issue 到 CSV,用于周报、迁移或离线分析 -gitlink-cli issue +export --owner Gitlink --repo forgeplus --state open --keyword 登录 --export-format csv --output issues.csv - -# 添加评论 -gitlink-cli issue +comment --number 4 --body "已修复,请验证" - -# 查询 Issue 评论和活动记录 -gitlink-cli issue +journals --number 4 --category comment --page 1 --limit 50 -gitlink-cli issue +activity --number 4 --page 1 --limit 50 - -# 查询 Issue 负责人 -gitlink-cli issue +assigners --owner Gitlink --repo forgeplus --keyword alice - -# 查询 Issue 发布人 -gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob +# 从 CSV 批量更新 Issue +gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus \ + --from issues.csv \ + --state closed \ + --priority-id 4 \ + --due-date 2026-06-15 ``` -## 批量维护安全约束 +## 批量操作约定 -- `issue +batch-close --numbers` 使用网页 URL 中的 Issue 编号,即 `project_issues_index`。 -- `issue +batch-update --ids` 和 `issue +batch-delete --ids` 使用 OpenAPI 返回的 API issue id,不是网页 Issue 编号。 -- 执行 `batch-update` / `batch-delete` 前,先用 `issue +list` 或 `issue +view` 确认 id 来源。 -- 写操作先执行 `--dry-run`,展示 `method`、`path`、`body` 给用户确认。 -- `batch-delete` 是破坏性操作,真实执行必须显式传 `--yes`。 +- `--numbers` 使用网页 URL 中可见的 Issue 编号,不是数据库内部 ID。 +- `--from` 支持 CSV 文件,优先识别 `number`、`issue_number`、`project_issues_index` 列;如果没有表头,则默认第一列为 Issue 编号。 +- `issue +batch-comment` 和 `issue +batch-update` 会逐条执行,并输出每条 Issue 的结果汇总。 +- 批量命令支持 `--dry-run`,推荐先预览再真实执行。 -## Raw API 补充 +## 文本输入约定 -```bash -# 批量更新 Issue(仍使用旧版 API,需传数据库 ID) -gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3],"status_id":"closed"}' -``` +- `issue +create`、`issue +update`、`issue +comment`、`issue +batch-comment`、`issue +batch-update` 都支持 `--body-file`。 +- `--body` 和 `--body-file` 互斥,避免正文来源不明确。 +- 长文本优先使用 `--body-file`,便于保留换行和 Markdown 格式。 -## GitLink Issue 字段映射 +## 安全建议 -| gitlink-cli 参数 | GitLink API 字段 | 说明 | -|------------------|-----------------|------| -| `--number` / `-n` | `project_issues_index` | Issue 编号(网页 URL 中的序号) | -| `--id` / `-i` | `project_issues_index` | `--number` 的兼容别名,不是数据库内部 ID | -| `--title` | `subject` | Issue 标题 | -| `--body` | `description` | Issue 描述 | -| `--assignee` | `assigned_to_id` | 指派人 ID | -| `--milestone` | `fixed_version_id` | 里程碑 ID | -| `--state` | `status_id` | 状态(open=1,closed=5,也可直接传数字 ID) | -| `--priority-id` | `priority_id` | 优先级 ID | -| `--tag-ids` / `--label` | `issue_tag_ids` | Issue 标签 ID 数组 | -| `--assigner-ids` | `assigner_ids` | 负责人 ID 数组 | -| `--branch` | `branch_name` | 关联分支 | -| `--start-date` | `start_date` | 开始日期 | -| `--due-date` | `due_date` | 截止日期 | +- 对写操作先确认仓库、Issue 编号和目标字段。 +- 批量命令先跑 `--dry-run`,确认数量和目标无误后再执行真实写入。 +- `issue +update` 和 `issue +batch-update` 会先读取当前 Issue,再带上现有标题/描述/元数据发起 PATCH,避免误清空字段。 -## API 注意事项 +## 输出与自动化 -- **Issue 编号(`--number`)是网页 URL 中看到的序号**(如 `issues/4` 中的 `4`),不是数据库内部 ID -- `--id` / `-i` 仅作为 `--number` / `-n` 的兼容别名,传入的仍然是网页 URL 中的 Issue 编号 -- **批量关闭使用 `--numbers`,同样传网页 URL 中的 Issue 编号**,不是数据库内部 ID -- Issue 操作使用 v1 API(`/api/v1/`),支持按 Issue 编号查询和操作 -- **创建 Issue 时 CLI 会自动设置 `status_id: 1`(新增)和 `priority_id: 2`(正常)** -- **更新/关闭 Issue 时必须保留当前 `subject` 和 `description`**,即使只修改状态(CLI 会先读取当前 Issue 并自动带回) -- v1 API 写操作必须使用 `access_token`(非 `token`)认证,CLI 已自动处理 - -## Issue 状态映射(status_id) - -| status_id | 名称 | 说明 | -|-----------|------|------| -| 1 | 新增 | 新建 Issue 的默认状态 | -| 2 | 正在解决 | 处理中 | -| 3 | 已解决 | 已修复 | -| 5 | 关闭 | 关闭(`+close` 命令使用此值) | +- 所有命令都支持全局 `--format json|table|yaml`。 +- 批量命令输出统一包含 `repository`、`action`、`dry_run`、`total`、`succeeded`、`failed` 和逐条 `results`,适合脚本和 Agent 继续处理。