Merge PR #226: feat(issue): 增加批量评论、批量更新与文件正文输入能力

# Conflicts:
#	README.md
#	shortcuts/issue/batch.go
#	shortcuts/issue/batch_test.go
#	shortcuts/issue/issue.go
#	shortcuts/issue/issue_test.go
#	skills/gitlink-issue/SKILL.md
This commit is contained in:
wbtiger 2026-07-14 22:50:05 +08:00
commit 928cb2114f
7 changed files with 707 additions and 2378 deletions

239
README.md
View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/README.md).
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
**[中文文档](./README.zh-CN.md)**
@ -78,19 +78,11 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
<a href="https://www.gitlink.org.cn/jiangtx" title="jiangtx"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/J/67_157_94/120.png" width="40" height="40" alt="jiangtx" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/jiangtx">jiangtx</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/luwanzhou" title="luwanzhou"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/165_135_246/120.png" width="40" height="40" alt="luwanzhou" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/luwanzhou">luwanzhou</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/whale_hihihi" title="whale_hihihi"><img src="https://www.gitlink.org.cn/images/avatars/User/137722?t=1778575729" width="40" height="40" alt="whale_hihihi" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/whale_hihihi">whale_hihihi</a></sub>
</div>
</div>
## Why gitlink-cli?
- **Agent-Native Design** — Structured [Skills](./skills/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 <build_id>
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
```
### 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 <uuid> --dry-run
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --yes
```
> Note: `dataset +list` (platform dataset query) is verified on production
> gitlink.org.cn. The per-repo `+view`/`+create`/`+update` routes follow the
> published OpenAPI contract but are not yet deployed on production (they return
> 404 there); they will work once the platform enables them.
### Raw API
For endpoints not covered by shortcuts, use the Raw API directly:
@ -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

View File

@ -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
```

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View File

@ -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) != "" {

View File

@ -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) {

View File

@ -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-issueIssue 操作)
# 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=1closed=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 继续处理。