forked from Gitlink/gitlink-cli
feat(issue): 增加批量评论与批量更新能力
This commit is contained in:
parent
52b7093846
commit
4145b671bc
18
README.md
18
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) != "" {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 继续处理。
|
||||
|
|
|
|||
Loading…
Reference in New Issue