feat(issue): 增加批量评论与批量更新能力

This commit is contained in:
Mengz 2026-06-12 13:06:04 +08:00
parent 52b7093846
commit 4145b671bc
7 changed files with 723 additions and 142 deletions

View File

@ -294,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
@ -303,18 +306,33 @@ 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
# Add a comment
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
# Add a comment from a file
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 --body-file comment.md
# 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

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

View File

@ -6,26 +6,30 @@ import (
"os"
"strconv"
"strings"
"unicode/utf8"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
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 {
@ -41,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++
@ -91,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 {
@ -108,6 +239,113 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
return nil
}
func updateIssue(ctx *common.RuntimeContext, number string) error {
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("fetch issue: %w", err)
}
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("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 {

View File

@ -5,6 +5,8 @@ import (
"path/filepath"
"reflect"
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestParseIssueNumbers(t *testing.T) {
@ -205,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")
@ -213,3 +260,12 @@ func writeTempCSV(t *testing.T, content string) string {
}
return path
}
func writeTempText(t *testing.T, content string) string {
t.Helper()
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,6 +45,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchCommentShortcut(),
newBatchUpdateShortcut(),
{
Name: "list",
Description: tr.T("cmd.issue.list.short"),
@ -113,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")},
@ -137,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 != "" {
@ -211,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"},
@ -227,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)
@ -271,7 +258,8 @@ 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: "body", Short: "b", Usage: tr.T("flag.comment.body")},
common.Flag{Name: "body-file", Usage: "Read comment body from a file"},
),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -281,14 +269,11 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
body, err := readIssueTextArg(ctx, "body", "body-file", true)
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload)
env, err := commentOnIssue(ctx, number, body)
if err != nil {
return err
}
@ -574,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

@ -235,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")
@ -617,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 {
@ -717,6 +785,45 @@ func TestIssueCommentAcceptsIDAlias(t *testing.T) {
assertEqual(t, commentPayload["notes"], "Fixed")
}
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)
}
commentPayload = decodeJSON(t, r)
writeJSON(t, w, commentPayload)
})
defer server.Close()
err := runShortcut(t, server, "comment", map[string]string{
"number": "42",
"body-file": bodyPath,
})
if err != nil {
t.Fatalf("comment with body-file failed: %v", err)
}
assertEqual(t, commentPayload["notes"], "Comment from file")
}
func TestIssueCommentRejectsBodyAndBodyFile(t *testing.T) {
bodyPath := writeTempText(t, "Comment 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, "comment", map[string]string{
"number": "42",
"body": "inline",
"body-file": bodyPath,
})
if err == nil {
t.Fatal("expected comment to reject mixed body sources")
}
}
func TestIssueCommentMissingBody(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected")
@ -851,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,118 +1,95 @@
---
name: gitlink-issue
version: 2.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 +comment` | 添加评论 | 是 |
| `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
# 添加评论
gitlink-cli issue +comment --number 4 --body "已修复,请验证"
# 查询 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
```
## Raw API 补充
## 批量操作约定
```bash
# 获取 Issue 评论列表(使用 v1 API按 issue number 查询)
gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals
- `--numbers` 使用网页 URL 中可见的 Issue 编号,不是数据库内部 ID。
- `--from` 支持 CSV 文件,优先识别 `number`、`issue_number`、`project_issues_index` 列;如果没有表头,则默认第一列为 Issue 编号。
- `issue +batch-comment``issue +batch-update` 会逐条执行,并输出每条 Issue 的结果汇总。
- 批量命令支持 `--dry-run`,推荐先预览再真实执行。
# 批量更新 Issue仍使用旧版 API需传数据库 ID
gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3],"status_id":"closed"}'
```
## 文本输入约定
## GitLink Issue 字段映射
- `issue +create`、`issue +update`、`issue +comment`、`issue +batch-comment`、`issue +batch-update` 都支持 `--body-file`
- `--body``--body-file` 互斥,避免正文来源不明确。
- 长文本优先使用 `--body-file`,便于保留换行和 Markdown 格式。
| 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` | 截止日期 |
## 安全建议
## API 注意事项
- 对写操作先确认仓库、Issue 编号和目标字段。
- 批量命令先跑 `--dry-run`,确认数量和目标无误后再执行真实写入。
- `issue +update``issue +batch-update` 会先读取当前 Issue再带上现有标题/描述/元数据发起 PATCH避免误清空字段。
- **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 继续处理。