From 4145b671bc64efec67b2dac632ba2f1eeac29f13 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Fri, 12 Jun 2026 13:06:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(issue):=20=E5=A2=9E=E5=8A=A0=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E8=AF=84=E8=AE=BA=E4=B8=8E=E6=89=B9=E9=87=8F=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 ++ doc/changes/issue-batch-operations.md | 30 +++ shortcuts/issue/batch.go | 278 ++++++++++++++++++++++++-- shortcuts/issue/batch_test.go | 56 ++++++ shortcuts/issue/issue.go | 84 +++++--- shortcuts/issue/issue_test.go | 242 ++++++++++++++++++++++ skills/gitlink-issue/SKILL.md | 157 +++++++-------- 7 files changed, 723 insertions(+), 142 deletions(-) create mode 100644 doc/changes/issue-batch-operations.md diff --git a/README.md b/README.md index b43dcd1..2d0b398 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/doc/changes/issue-batch-operations.md b/doc/changes/issue-batch-operations.md new file mode 100644 index 0000000..36d60fe --- /dev/null +++ b/doc/changes/issue-batch-operations.md @@ -0,0 +1,30 @@ +# issue 批量运维能力增强 + +本次变更把 Issue 的批量运维能力从“只能批量关闭”扩展为更完整的日常工作流: + +- 新增 `issue +batch-comment`,支持按 `--numbers` 或 `--from issues.csv` 给多个 Issue 统一追加评论。 +- 新增 `issue +batch-update`,支持批量更新状态、优先级、标签、负责人、关联分支、开始日期和截止日期。 +- `issue +create`、`issue +update`、`issue +comment` 现在支持 `--body-file`,适合读取 Markdown 文件中的长文本。 + +设计上延续了现有 `issue +batch-close` 的安全思路: + +- 批量命令统一支持 `--dry-run`; +- `--body` 与 `--body-file` 互斥; +- 批量更新会先读取当前 Issue,再保留已有标题、描述和元数据,避免误清空字段; +- 输出统一包含逐条结果汇总,便于 Agent 或脚本继续处理。 + +相关文档已同步更新: + +- `README.md` +- `skills/gitlink-issue/SKILL.md` + +本地验证: + +```bash +go test ./shortcuts/issue/... +go test ./shortcuts/... +go build ./... +git diff --check +go run . issue +batch-comment --help +go run . issue +batch-update --help +``` diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 2345b6c..a2b4731 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -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 { diff --git a/shortcuts/issue/batch_test.go b/shortcuts/issue/batch_test.go index cf94cda..c7b3e21 100644 --- a/shortcuts/issue/batch_test.go +++ b/shortcuts/issue/batch_test.go @@ -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 +} diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 9e5a117..abbb994 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -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) != "" { diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 48be057..d613157 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -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) { diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index c5a4aa2..39a2c67 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -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-issue(Issue 操作) +# gitlink-issue -**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** -**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** -**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** +> 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),确认认证方式、全局参数和安全约束。 -> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 +## 适用场景 -## Shortcuts +- 维护者需要快速创建、更新、关闭或评论 Issue。 +- Agent 需要基于仓库内的 Issue 批量做运营动作,例如统一补评论、统一更新优先级、统一补截止日期。 +- 需要从 Markdown 文件读取长文本,避免把大段内容直接塞进命令行参数。 -| Shortcut | 说明 | 需要认证 | -|----------|------|----------| -| `issue +list` | Issue 列表 | 否(公开项目) | -| `issue +create` | 创建 Issue | 是 | -| `issue +view` | Issue 详情 | 否(公开项目) | -| `issue +update` | 更新 Issue | 是 | -| `issue +close` | 关闭 Issue | 是 | -| `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | -| `issue +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=1,closed=5,也可直接传数字 ID) | -| `--priority-id` | `priority_id` | 优先级 ID | -| `--tag-ids` / `--label` | `issue_tag_ids` | Issue 标签 ID 数组 | -| `--assigner-ids` | `assigner_ids` | 负责人 ID 数组 | -| `--branch` | `branch_name` | 关联分支 | -| `--start-date` | `start_date` | 开始日期 | -| `--due-date` | `due_date` | 截止日期 | +## 安全建议 -## 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 继续处理。