diff --git a/README.md b/README.md
index e44e23b..2d0b398 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
[](https://golang.org)
[](https://www.npmjs.com/package/@gitlink-ai/cli)
-The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/README.md).
+The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
**[中文文档](./README.zh-CN.md)**
@@ -78,19 +78,11 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
jiangtx
-
-
## Why gitlink-cli?
-- **Agent-Native Design** — Structured [Skills](./skills/README.md) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
+- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
@@ -104,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 继续处理。