diff --git a/README.md b/README.md index 497eb4c..fa1d33d 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,10 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs # Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" +# List issue journals or comment activity +gitlink-cli issue +journals --owner Gitlink --repo forgeplus --number 123 --page 1 --limit 50 +gitlink-cli issue +activity --owner Gitlink --repo forgeplus --number 123 --category comment + # List issue assigners gitlink-cli issue +assigners --owner Gitlink --repo forgeplus diff --git a/README.zh-CN.md b/README.zh-CN.md index 5f9e2f1..987dcab 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -368,6 +368,10 @@ gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 - # 添加评论 gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" +# 列出 Issue 动态记录或评论活动 +gitlink-cli issue +journals --owner Gitlink --repo forgeplus --number 123 --page 1 --limit 50 +gitlink-cli issue +activity --owner Gitlink --repo forgeplus --number 123 --category comment + # 列出 Issue 负责人 gitlink-cli issue +assigners --owner Gitlink --repo forgeplus diff --git a/doc/changes/issue-journal-shortcuts.md b/doc/changes/issue-journal-shortcuts.md new file mode 100644 index 0000000..7cb2396 --- /dev/null +++ b/doc/changes/issue-journal-shortcuts.md @@ -0,0 +1,63 @@ +# Issue journal shortcuts + +## Background + +Issue comments and journal events are useful for stale issue detection, audit +trails, and triage workflows. Those reads were still documented as Raw API calls +against `/v1/:owner/:repo/issues/:number/journals`. + +This change adds first-class read-only shortcuts for issue journals. + +## New shortcuts + +- `issue +journals` lists raw issue journal records. +- `issue +activity` uses the same journal endpoint as an activity-oriented + alias. + +Both commands preserve the existing issue number convention: + +- `--number` / `-n` is the preferred web-visible issue number. +- `--id` / `-i` remains a compatibility alias for the same web-visible number, + not the database ID. + +## Options + +- `--category` filters journal category, for example `comment`. +- `--page` defaults to `1`. +- `--limit` defaults to `50`. + +## Examples + +```bash +gitlink-cli issue +journals --owner Gitlink --repo forgeplus --number 123 --page 1 --limit 50 +gitlink-cli issue +journals --owner Gitlink --repo forgeplus --number 123 --category comment +gitlink-cli issue +activity --owner Gitlink --repo forgeplus --number 123 --category comment +``` + +## Documentation updates + +- README and README.zh-CN include journal and activity examples. +- `skills/gitlink-issue` documents the new read-only shortcuts. +- `skills/gitlink-stale-issue-manager` now uses `issue +journals` instead of + Raw API for comment history lookup. + +## Tests + +Unit tests cover: + +- endpoint path and query mapping; +- `--number` and `--id` alias behavior; +- missing issue number validation; +- HTTP error propagation. + +Suggested verification: + +```bash +go test ./shortcuts/issue ./shortcuts +``` + +Full project verification: + +```bash +go test ./... +``` diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0c2e37d..d152eb3 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -1,13 +1,4 @@ { - "cmd.action.disable.short": "Disable Actions for the repository", - "cmd.action.enable.short": "Enable Actions for the repository", - "cmd.action.job_rerun.short": "Rerun a single job of a run", - "cmd.action.list.short": "List workflow files (.gitea/workflows)", - "cmd.action.logs.short": "Fetch the raw logs of a workflow job", - "cmd.action.rerun.short": "Rerun all jobs of a run", - "cmd.action.run.short": "Trigger a workflow run", - "cmd.action.runs.short": "List runs of a workflow", - "cmd.action.short": "Gitea Actions (CI workflow) operations", "cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.", "cmd.api.short": "Make raw API requests to GitLink", "cmd.auth.login.short": "Login to GitLink", @@ -43,13 +34,7 @@ "cmd.dataset.view.short": "View a repository's dataset", "cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.", "cmd.doctor.short": "Diagnose gitlink-cli environment problems", - "cmd.file.batch.short": "Apply multiple file operations in a single commit", - "cmd.file.create.short": "Create a new file in the repository", - "cmd.file.delete.short": "Delete a file from the repository", - "cmd.file.search.short": "Search files in the repository by name", - "cmd.file.short": "Repository file content operations", - "cmd.file.update.short": "Update an existing file in the repository", - "cmd.file.view.short": "View the contents of a file", + "cmd.issue.activity.short": "List issue activity events", "cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", "cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", "cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", @@ -59,6 +44,7 @@ "cmd.issue.close.short": "Close an issue", "cmd.issue.comment.short": "Add a comment to an issue", "cmd.issue.create.short": "Create a new issue", + "cmd.issue.journals.short": "List issue journals", "cmd.issue.list.short": "List issues", "cmd.issue.short": "Issue operations", "cmd.issue.update.short": "Update an issue", @@ -68,10 +54,8 @@ "cmd.org.list.short": "List organizations", "cmd.org.members.short": "List organization members", "cmd.org.short": "Organization operations", - "cmd.pr.check_merge.short": "Pre-flight check whether a merge request can be created between two branches", "cmd.pr.close.short": "Close a pull request", "cmd.pr.comment.short": "Add a comment to a pull request", - "cmd.pr.commits.short": "List commits of a pull request", "cmd.pr.create.short": "Create a pull request", "cmd.pr.diff.short": "Show diff for a pull request", "cmd.pr.files.short": "List changed files in a pull request", @@ -132,10 +116,6 @@ "error.missing_required_flag": "required flag --{name} is missing", "error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login", "error.unsupported_language": "unsupported language: {lang}", - "flag.action.job": "Job name within the run", - "flag.action.ref": "Branch or tag to run on", - "flag.action.run_id": "Run ID from action +runs", - "flag.action.workflow": "Workflow file name (e.g. ci.yml)", "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", "flag.api.batch_dry_run": "Preview batch requests without sending remote requests", "flag.api.batch_file": "Read an API batch plan from a JSON file", @@ -167,15 +147,6 @@ "flag.description": "Description", "flag.doctor.skip_network": "Skip authenticated API connectivity checks", "flag.dry_run": "Preview the request without creating it", - "flag.file.batch_spec": "Path to a JSON array of file operations: [{action_type, file_path, content, encoding}]", - "flag.file.branch": "Branch to commit to", - "flag.file.content": "File content", - "flag.file.content_file": "Read file content from a local file", - "flag.file.message": "Commit message", - "flag.file.new_branch": "Create a new branch from --branch for the commit", - "flag.file.path": "File path", - "flag.file.raw": "Print only the decoded file content", - "flag.file.ref": "Branch, tag, or commit SHA (defaults to the default branch)", "flag.format": "Output format: json, table, yaml (default: table)", "flag.issue.add_label": "Label to add to each matching issue", "flag.issue.assignee": "Assignee login", @@ -189,6 +160,7 @@ "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", "flag.issue.body": "Issue description", + "flag.issue.journal_category": "Journal category filter, for example: comment", "flag.issue.label": "Label ID", "flag.issue.label_filter": "Filter by existing label", "flag.issue.milestone": "Milestone ID", @@ -213,7 +185,6 @@ "flag.pr.base": "Target branch", "flag.pr.body": "PR description", "flag.pr.file": "Filter diff by file path", - "flag.pr.fork_project_id": "Fork project ID for cross-fork merge checks", "flag.pr.head": "Source branch", "flag.pr.id": "PR number", "flag.pr.merge_method": "Merge method: merge, rebase, squash", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 5eb1afc..56c5cf5 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -1,13 +1,4 @@ { - "cmd.action.disable.short": "为仓库停用 Actions", - "cmd.action.enable.short": "为仓库启用 Actions", - "cmd.action.job_rerun.short": "重跑运行中的单个任务", - "cmd.action.list.short": "列出工作流文件(.gitea/workflows)", - "cmd.action.logs.short": "获取流水线任务的原始日志", - "cmd.action.rerun.short": "重跑一次运行的全部任务", - "cmd.action.run.short": "触发一次工作流运行", - "cmd.action.runs.short": "列出工作流的运行记录", - "cmd.action.short": "Gitea Actions(CI 工作流)操作", "cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。", "cmd.api.short": "向 GitLink 发起原始 API 请求", "cmd.auth.login.short": "登录 GitLink", @@ -43,13 +34,7 @@ "cmd.dataset.view.short": "查看仓库数据集", "cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。", "cmd.doctor.short": "诊断 gitlink-cli 环境问题", - "cmd.file.batch.short": "在单个提交中应用多个文件操作", - "cmd.file.create.short": "在仓库中创建文件", - "cmd.file.delete.short": "删除仓库中的文件", - "cmd.file.search.short": "按文件名搜索仓库文件", - "cmd.file.short": "仓库文件内容操作", - "cmd.file.update.short": "更新仓库中的文件", - "cmd.file.view.short": "查看文件内容", + "cmd.issue.activity.short": "列出议题活动事件", "cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", "cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。", "cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", @@ -59,6 +44,7 @@ "cmd.issue.close.short": "关闭议题", "cmd.issue.comment.short": "给议题添加评论", "cmd.issue.create.short": "创建新议题", + "cmd.issue.journals.short": "列出议题动态记录", "cmd.issue.list.short": "列出议题", "cmd.issue.short": "议题操作", "cmd.issue.update.short": "更新议题", @@ -68,10 +54,8 @@ "cmd.org.list.short": "列出组织", "cmd.org.members.short": "列出组织成员", "cmd.org.short": "组织操作", - "cmd.pr.check_merge.short": "预检两个分支之间能否创建合并请求", "cmd.pr.close.short": "关闭拉取请求", "cmd.pr.comment.short": "给拉取请求添加评论", - "cmd.pr.commits.short": "列出合并请求的提交", "cmd.pr.create.short": "创建拉取请求", "cmd.pr.diff.short": "显示拉取请求 diff", "cmd.pr.files.short": "列出拉取请求中的变更文件", @@ -132,10 +116,6 @@ "error.missing_required_flag": "缺少必需参数 --{name}", "error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", "error.unsupported_language": "不支持的语言:{lang}", - "flag.action.job": "运行中的任务名", - "flag.action.ref": "运行所在的分支或标签", - "flag.action.run_id": "运行 ID(来自 action +runs)", - "flag.action.workflow": "工作流文件名(如 ci.yml)", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", "flag.api.batch_dry_run": "预览批处理请求,不发送远端请求", "flag.api.batch_file": "从 JSON 文件读取 API 批处理计划", @@ -167,15 +147,6 @@ "flag.description": "描述", "flag.doctor.skip_network": "跳过需要访问 GitLink 的认证连通性检查", "flag.dry_run": "预览请求,不实际创建", - "flag.file.batch_spec": "文件操作 JSON 数组路径:[{action_type, file_path, content, encoding}]", - "flag.file.branch": "提交到的分支", - "flag.file.content": "文件内容", - "flag.file.content_file": "从本地文件读取内容", - "flag.file.message": "提交信息", - "flag.file.new_branch": "从 --branch 新建分支并提交", - "flag.file.path": "文件路径", - "flag.file.raw": "仅输出解码后的文件正文", - "flag.file.ref": "分支、标签或提交 SHA(默认使用默认分支)", "flag.format": "输出格式:json、table、yaml(默认:table)", "flag.issue.add_label": "要添加到每个匹配议题的标签", "flag.issue.assignee": "负责人登录名", @@ -189,6 +160,7 @@ "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", "flag.issue.body": "议题描述", + "flag.issue.journal_category": "动态记录分类筛选,例如:comment", "flag.issue.label": "标签 ID", "flag.issue.label_filter": "按已有标签筛选", "flag.issue.milestone": "里程碑 ID", @@ -213,7 +185,6 @@ "flag.pr.base": "目标分支", "flag.pr.body": "PR 描述", "flag.pr.file": "按文件路径筛选 diff", - "flag.pr.fork_project_id": "跨 fork 合并检查用的 fork 项目 ID", "flag.pr.head": "源分支", "flag.pr.id": "PR 编号", "flag.pr.merge_method": "合并方式:merge、rebase、squash", diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 8914004..605dc02 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -303,6 +303,18 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "journals", + Description: tr.T("cmd.issue.journals.short"), + Flags: issueJournalFlags(tr), + Run: runIssueJournals, + }, + { + Name: "activity", + Description: tr.T("cmd.issue.activity.short"), + Flags: issueJournalFlags(tr), + Run: runIssueJournals, + }, { Name: "assigners", Description: "List issue assigners", @@ -441,6 +453,14 @@ func appendIssueNumberFlags(flags ...common.Flag) []common.Flag { return append(issueNumberFlags(), flags...) } +func issueJournalFlags(tr *i18n.Translator) []common.Flag { + return appendIssueNumberFlags( + common.Flag{Name: "category", Short: "c", Usage: tr.T("flag.issue.journal_category")}, + common.Flag{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + common.Flag{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "50"}, + ) +} + func issueNumberArg(ctx *common.RuntimeContext) (string, error) { if number := strings.TrimSpace(ctx.Arg("number")); number != "" { return number, nil @@ -451,6 +471,25 @@ func issueNumberArg(ctx *common.RuntimeContext) (string, error) { return "", fmt.Errorf("required flag --number is missing (or use --id as a compatibility alias)") } +func runIssueJournals(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + q := url.Values{} + setIssueQueryIfPresent(q, "category", ctx.Arg("category")) + setIssueQueryIfPresent(q, "page", ctx.Arg("page")) + setIssueQueryIfPresent(q, "limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), q) + if err != nil { + return err + } + return ctx.Output(env) +} + // normalizeIssueListIDs adds "number" (project_issues_index) and renames // "id" to "database_id" so the user-facing output uses the project-level // issue number, not the global database primary key. @@ -656,3 +695,9 @@ func parseIssueID(value, flagName string) (int, error) { } return id, nil } + +func setIssueQueryIfPresent(q url.Values, key, value string) { + if value := strings.TrimSpace(value); value != "" { + q.Set(key, value) + } +} diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index b7d4b89..96c6c0b 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -4,13 +4,351 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func TestIssueClosePreservesCurrentDescription(t *testing.T) { +func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func newIssueTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func writeText(t *testing.T, w http.ResponseWriter, status int, text string) { + t.Helper() + w.WriteHeader(status) + if _, err := w.Write([]byte(text)); err != nil { + t.Fatalf("write response: %v", err) + } +} + +func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode body: %v", err) + } + return payload +} + +func assertEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if got != want { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} + +func assertNumberSlice(t *testing.T, got interface{}, want []float64) { + t.Helper() + values, ok := got.([]interface{}) + if !ok { + t.Fatalf("got %v (%T), want numeric slice", got, got) + } + if len(values) != len(want) { + t.Fatalf("got %v, want %v", values, want) + } + for i, value := range values { + if value != want[i] { + t.Fatalf("got %v, want %v", values, want) + } + } +} + +// --- list --- + +func TestIssueList(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("category") != "opened" { + t.Fatalf("expected category=opened, got %s", r.URL.Query().Get("category")) + } + writeJSON(t, w, []interface{}{ + map[string]interface{}{"id": float64(1), "subject": "bug"}, + }) + }) + defer server.Close() + + err := runShortcut(t, server, "list", map[string]string{"state": "open", "page": "1", "limit": "20"}) + if err != nil { + t.Fatalf("list failed: %v", err) + } +} + +func TestIssueListWithFilters(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + query := r.URL.Query() + assertEqual(t, query.Get("category"), "closed") + assertEqual(t, query.Get("keyword"), "release") + assertEqual(t, query.Get("participant_category"), "assignedme") + assertEqual(t, query.Get("author_id"), "10") + assertEqual(t, query.Get("assigner_id"), "11") + assertEqual(t, query.Get("milestone_id"), "12") + assertEqual(t, query.Get("status_id"), "5") + assertEqual(t, query.Get("issue_tag_ids"), "1,2") + assertEqual(t, query.Get("sort_by"), "issues.updated_on") + assertEqual(t, query.Get("sort_direction"), "desc") + writeJSON(t, w, map[string]interface{}{"issues": []interface{}{}}) + }) + defer server.Close() + + err := runShortcut(t, server, "list", map[string]string{ + "state": "closed", + "keyword": "release", + "participant": "assignedme", + "author-id": "10", + "assignee-id": "11", + "milestone-id": "12", + "status-id": "5", + "tag-ids": "1,2", + "sort-by": "issues.updated_on", + "sort-direction": "desc", + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("list with filters failed: %v", err) + } +} + +func TestIssueListStateAll(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, r.URL.Query().Get("category"), "all") + writeJSON(t, w, map[string]interface{}{"issues": []interface{}{}}) + }) + defer server.Close() + + err := runShortcut(t, server, "list", map[string]string{"state": "all", "page": "1", "limit": "20"}) + if err != nil { + t.Fatalf("list all failed: %v", err) + } +} + +// --- create --- + +func TestIssueCreate(t *testing.T) { + var payload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"id": float64(1), "subject": "bug"}) + }) + defer server.Close() + + err := runShortcut(t, server, "create", map[string]string{ + "title": "bug: crash", + "body": "description", + "assignee": "alice", + }) + if err != nil { + t.Fatalf("create failed: %v", err) + } + assertEqual(t, payload["subject"], "bug: crash") + assertEqual(t, payload["status_id"], float64(1)) + assertEqual(t, payload["assigned_to_id"], "alice") +} + +func TestIssueCreateSupportsMetadataFields(t *testing.T) { + var createPayload map[string]interface{} + 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": "New issue", + "body": "With metadata", + "priority-id": "3", + "tag-ids": "4,5", + "assigner-ids": "7,8", + "branch": "feature/metadata", + "start-date": "2026-05-01", + "due-date": "2026-05-31", + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + + assertEqual(t, createPayload["subject"], "New issue") + assertEqual(t, createPayload["description"], "With metadata") + assertEqual(t, createPayload["priority_id"], float64(3)) + assertNumberSlice(t, createPayload["issue_tag_ids"], []float64{4, 5}) + assertNumberSlice(t, createPayload["assigner_ids"], []float64{7, 8}) + assertEqual(t, createPayload["branch_name"], "feature/metadata") + assertEqual(t, createPayload["start_date"], "2026-05-01") + assertEqual(t, createPayload["due_date"], "2026-05-31") +} + +func TestIssueCreateMissingTitle(t *testing.T) { + 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{}) + if err == nil { + t.Fatal("expected error for missing title") + } +} + +// --- view/id alias --- + +func TestIssueView(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/v1/owner/repo/issues/42.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"id": float64(42), "subject": "bug"}) + }) + defer server.Close() + + err := runShortcut(t, server, "view", map[string]string{"number": "42"}) + if err != nil { + t.Fatalf("view failed: %v", err) + } +} + +func TestIssueViewMissingNumber(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + }) + defer server.Close() + + err := runShortcut(t, server, "view", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing number") + } +} + +func TestIssueViewAcceptsIDAlias(t *testing.T) { + var requestedPath string + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + requestedPath = r.URL.Path + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "project_issues_index": 42, + "subject": "Issue from web URL", + }) + }) + defer server.Close() + + err := runShortcut(t, server, "view", map[string]string{"id": "42"}) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } + assertEqual(t, requestedPath, "/v1/owner/repo/issues/42.json") +} + +func TestIssueNumberTakesPrecedenceOverIDAlias(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"subject": "Existing title"}) + }) + defer server.Close() + + err := runShortcut(t, server, "view", map[string]string{ + "number": "42", + "id": "99", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } +} + +// --- close --- + +func TestIssueClose(t *testing.T) { + var patchPayload map[string]interface{} + 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{}{ + "id": float64(42), + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + patchPayload = decodeJSON(t, r) + writeJSON(t, w, patchPayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "close", map[string]string{"number": "42"}) + if err != nil { + t.Fatalf("close failed: %v", err) + } + assertEqual(t, patchPayload["subject"], "Existing title") + assertEqual(t, patchPayload["description"], "Existing description") + assertEqual(t, patchPayload["status_id"], float64(5)) +} + +func TestIssueCloseAcceptsIDAlias(t *testing.T) { var updatePayload map[string]interface{} server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { @@ -28,93 +366,23 @@ func TestIssueClosePreservesCurrentDescription(t *testing.T) { }) defer server.Close() - err := runIssueShortcut(t, server, "close", map[string]string{"number": "42"}) + err := runShortcut(t, server, "close", map[string]string{"id": "42"}) if err != nil { t.Fatalf("close shortcut failed: %v", err) } - - assertEqual(t, updatePayload["subject"], "Existing title") - assertEqual(t, updatePayload["description"], "Existing description") assertEqual(t, updatePayload["status_id"], float64(5)) } -func TestIssueUpdatePreservesCurrentDescriptionWhenChangingTitleAndState(t *testing.T) { +func TestIssueClosePreservesCurrentMetadata(t *testing.T) { var updatePayload map[string]interface{} 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 := runIssueShortcut(t, server, "update", map[string]string{ - "number": "42", - "title": "New title", - "state": "closed", - }) - if err != nil { - t.Fatalf("update shortcut failed: %v", err) - } - - assertEqual(t, updatePayload["subject"], "New title") - assertEqual(t, updatePayload["description"], "Existing description") - assertEqual(t, updatePayload["status_id"], float64(5)) -} - -func TestIssueUpdatePreservesCurrentSubjectWhenChangingDescription(t *testing.T) { - var updatePayload map[string]interface{} - 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 := runIssueShortcut(t, server, "update", map[string]string{ - "number": "42", - "body": "New description", - }) - if err != nil { - t.Fatalf("update shortcut failed: %v", err) - } - - assertEqual(t, updatePayload["subject"], "Existing title") - assertEqual(t, updatePayload["description"], "New description") -} - -func TestIssueUpdatePreservesCurrentMetadata(t *testing.T) { - var updatePayload map[string]interface{} - 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", - "priority": map[string]interface{}{"id": 2, "name": "normal"}, - "tracker": map[string]interface{}{"id": 1, "name": "bug"}, - "fixed_version": map[string]interface{}{"id": 9, "name": "v1"}, - "assigned_to_id": 7, - "issue_tags": []map[string]interface{}{ - {"id": 3, "name": "bug"}, - {"id": 4, "name": "cli"}, + "subject": "Existing title", + "priority": map[string]interface{}{"id": 3}, + "tags": []map[string]interface{}{ + {"id": 4}, }, }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": @@ -126,29 +394,441 @@ func TestIssueUpdatePreservesCurrentMetadata(t *testing.T) { }) defer server.Close() - err := runIssueShortcut(t, server, "update", map[string]string{ + err := runShortcut(t, server, "close", map[string]string{"number": "42"}) + if err != nil { + t.Fatalf("close shortcut failed: %v", err) + } + assertEqual(t, updatePayload["subject"], "Existing title") + assertEqual(t, updatePayload["status_id"], float64(5)) + assertEqual(t, updatePayload["priority_id"], float64(3)) + assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{4}) +} + +func TestIssueCloseFetchFails(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + writeJSON(t, w, map[string]interface{}{"error": "not found"}) + }) + defer server.Close() + + err := runShortcut(t, server, "close", map[string]string{"number": "999"}) + if err == nil { + t.Fatal("expected error when issue not found") + } +} + +// --- update --- + +func TestIssueUpdateTitle(t *testing.T) { + var patchPayload map[string]interface{} + 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{}{ + "id": float64(42), + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + patchPayload = decodeJSON(t, r) + writeJSON(t, w, patchPayload) + 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", "title": "New title", "state": "closed"}) + if err != nil { + t.Fatalf("update failed: %v", err) + } + assertEqual(t, patchPayload["subject"], "New title") + assertEqual(t, patchPayload["description"], "Existing description") + assertEqual(t, patchPayload["status_id"], float64(5)) +} + +func TestIssueUpdateDescription(t *testing.T) { + var patchPayload map[string]interface{} + 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{}{ + "id": float64(42), + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + patchPayload = decodeJSON(t, r) + writeJSON(t, w, patchPayload) + 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": "New description"}) + if err != nil { + t.Fatalf("update failed: %v", err) + } + assertEqual(t, patchPayload["subject"], "Existing title") + assertEqual(t, patchPayload["description"], "New description") +} + +func TestIssueUpdateNumericState(t *testing.T) { + var patchPayload map[string]interface{} + 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{}{ + "id": float64(42), + "subject": "bug", + "description": "desc", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + patchPayload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"id": float64(42)}) + 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", "state": "3"}) + if err != nil { + t.Fatalf("update numeric state failed: %v", err) + } + assertEqual(t, patchPayload["status_id"], float64(3)) +} + +func TestIssueUpdateAcceptsIDAlias(t *testing.T) { + var updatePayload map[string]interface{} + 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{ + "id": "42", + "title": "New title", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + assertEqual(t, updatePayload["subject"], "New title") + assertEqual(t, updatePayload["description"], "Existing description") +} + +func TestIssueUpdatePreservesCurrentMetadata(t *testing.T) { + var updatePayload map[string]interface{} + 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", + "status": map[string]interface{}{"id": 1}, + "priority": map[string]interface{}{"id": 2}, + "tags": []map[string]interface{}{ + {"id": 7}, + {"id": 8}, + }, + "assigners": []map[string]interface{}{ + {"id": 9}, + }, + "branch_name": "main", + "start_date": "2026-05-01", + "due_date": "2026-05-31", + }) + 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", "title": "New title", }) if err != nil { t.Fatalf("update shortcut failed: %v", err) } - assertEqual(t, updatePayload["subject"], "New title") assertEqual(t, updatePayload["description"], "Existing description") + assertEqual(t, updatePayload["status_id"], float64(1)) assertEqual(t, updatePayload["priority_id"], float64(2)) - assertEqual(t, updatePayload["tracker_id"], float64(1)) - assertEqual(t, updatePayload["fixed_version_id"], float64(9)) - assertEqual(t, updatePayload["assigned_to_id"], float64(7)) - - tagIDs, ok := updatePayload["issue_tag_ids"].([]interface{}) - if !ok { - t.Fatalf("issue_tag_ids = %T, want []interface{}", updatePayload["issue_tag_ids"]) - } - assertEqual(t, tagIDs[0], float64(3)) - assertEqual(t, tagIDs[1], float64(4)) + assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{7, 8}) + assertNumberSlice(t, updatePayload["assigner_ids"], []float64{9}) + assertEqual(t, updatePayload["branch_name"], "main") + assertEqual(t, updatePayload["start_date"], "2026-05-01") + assertEqual(t, updatePayload["due_date"], "2026-05-31") } +func TestIssueUpdateSupportsMetadataFields(t *testing.T) { + var updatePayload map[string]interface{} + 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", + "priority-id": "4", + "tag-ids": "6,7", + "assigner-ids": "8", + "branch": "bugfix/metadata", + "start-date": "2026-06-01", + "due-date": "2026-06-15", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + assertEqual(t, updatePayload["subject"], "Existing title") + assertEqual(t, updatePayload["description"], "Existing description") + assertEqual(t, updatePayload["priority_id"], float64(4)) + assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{6, 7}) + assertNumberSlice(t, updatePayload["assigner_ids"], []float64{8}) + assertEqual(t, updatePayload["branch_name"], "bugfix/metadata") + assertEqual(t, updatePayload["start_date"], "2026-06-01") + assertEqual(t, updatePayload["due_date"], "2026-06-15") +} + +func TestIssueUpdateInvalidState(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/42.json": + writeJSON(t, w, map[string]interface{}{ + "id": float64(42), + "subject": "bug", + "description": "desc", + }) + 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", "state": "invalid"}) + if err == nil { + t.Fatal("expected error for invalid state") + } +} + +func TestIssueUpdateNoChanges(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + }) + defer server.Close() + + err := runShortcut(t, server, "update", map[string]string{"number": "42"}) + if err == nil { + t.Fatal("expected error when no changes specified") + } +} + +func TestIssueRejectsInvalidMetadataIDs(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid metadata should not call API, got %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + cases := []struct { + name string + args map[string]string + }{ + {name: "bad priority", args: map[string]string{"title": "x", "priority-id": "abc"}}, + {name: "empty tag", args: map[string]string{"title": "x", "tag-ids": "1,,2"}}, + {name: "label conflicts with tag ids", args: map[string]string{"title": "x", "label": "1", "tag-ids": "2"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := runShortcut(t, server, "create", tc.args); err == nil { + t.Fatal("expected metadata validation error") + } + }) + } +} + +// --- comment --- + +func TestIssueComment(t *testing.T) { + var payload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"id": float64(1), "message": "ok"}) + }) + defer server.Close() + + err := runShortcut(t, server, "comment", map[string]string{"number": "42", "body": "test comment"}) + if err != nil { + t.Fatalf("comment failed: %v", err) + } + assertEqual(t, payload["notes"], "test comment") +} + +func TestIssueCommentAcceptsIDAlias(t *testing.T) { + var commentPayload map[string]interface{} + 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{ + "id": "42", + "body": "Fixed", + }) + if err != nil { + t.Fatalf("comment shortcut failed: %v", err) + } + assertEqual(t, commentPayload["notes"], "Fixed") +} + +func TestIssueCommentMissingBody(t *testing.T) { + 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"}) + if err == nil { + t.Fatal("expected error for missing body") + } +} + +// --- journals / activity --- + +func TestIssueJournals(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + assertEqual(t, r.URL.Query().Get("category"), "comment") + assertEqual(t, r.URL.Query().Get("page"), "2") + assertEqual(t, r.URL.Query().Get("limit"), "50") + writeJSON(t, w, map[string]interface{}{ + "journals": []interface{}{ + map[string]interface{}{"id": float64(1), "notes": "hello"}, + }, + }) + }) + defer server.Close() + + err := runShortcut(t, server, "journals", map[string]string{ + "number": "42", + "category": "comment", + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("journals failed: %v", err) + } +} + +func TestIssueActivityUsesSameJournalEndpoint(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("category"); got != "" { + t.Fatalf("category should be omitted when not passed, got %q", got) + } + assertEqual(t, r.URL.Query().Get("page"), "1") + assertEqual(t, r.URL.Query().Get("limit"), "20") + writeJSON(t, w, map[string]interface{}{"journals": []interface{}{}}) + }) + defer server.Close() + + err := runShortcut(t, server, "activity", map[string]string{ + "id": "42", + "page": "1", + "limit": "20", + }) + if err != nil { + t.Fatalf("activity failed: %v", err) + } +} + +func TestIssueJournalsMissingNumber(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runShortcut(t, server, "journals", map[string]string{"category": "comment"}) + if err == nil { + t.Fatal("expected error for missing number") + } +} + +func TestIssueNumberOrIDIsRequired(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + cases := []struct { + name string + args map[string]string + }{ + {name: "view", args: map[string]string{}}, + {name: "close", args: map[string]string{}}, + {name: "update", args: map[string]string{"title": "New title"}}, + {name: "comment", args: map[string]string{"body": "Fixed"}}, + {name: "journals", args: map[string]string{}}, + {name: "activity", args: map[string]string{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runShortcut(t, server, tc.name, tc.args) + if err == nil { + t.Fatal("expected missing issue number error") + } + if !strings.Contains(err.Error(), "--number") || !strings.Contains(err.Error(), "--id") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +// --- batch-close --- + func TestBatchClosePreservesCurrentDescription(t *testing.T) { var updatePayload map[string]interface{} server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { @@ -167,19 +847,82 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) { }) defer server.Close() - err := runIssueShortcut(t, server, "batch-close", map[string]string{ + err := runShortcut(t, server, "batch-close", map[string]string{ "numbers": "42", "dry-run": "false", }) if err != nil { t.Fatalf("batch-close shortcut failed: %v", err) } - assertEqual(t, updatePayload["subject"], "Existing title") assertEqual(t, updatePayload["description"], "Existing description") assertEqual(t, updatePayload["status_id"], float64(5)) } +func TestBatchCloseDryRun(t *testing.T) { + 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-close", map[string]string{ + "numbers": "1, 2, 3", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("batch-close dry-run failed: %v", err) + } +} + +func TestBatchCloseNoNumbers(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + }) + defer server.Close() + + err := runShortcut(t, server, "batch-close", map[string]string{}) + if err == nil { + t.Fatal("expected error when no issue numbers provided") + } +} + +func TestBatchCloseFetchFails(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusNotFound, "not found") + }) + defer server.Close() + + err := runShortcut(t, server, "batch-close", map[string]string{"numbers": "99"}) + if err == nil { + t.Fatal("expected error when fetch fails") + } +} + +func TestBatchCloseWithFailedClose(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{}{"subject": "Issue 1", "description": "desc1", "status_id": float64(5)}) + 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-close", map[string]string{"numbers": "1, 2"}) + if err == nil { + t.Fatal("expected error when some issues fail to close") + } +} + +// --- issue users --- + func TestIssueAssignersShortcutWithKeyword(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_assigners.json" { @@ -195,9 +938,7 @@ func TestIssueAssignersShortcutWithKeyword(t *testing.T) { }) defer server.Close() - err := runIssueShortcut(t, server, "assigners", map[string]string{ - "keyword": "alice", - }) + err := runShortcut(t, server, "assigners", map[string]string{"keyword": "alice"}) if err != nil { t.Fatalf("assigners shortcut failed: %v", err) } @@ -218,613 +959,253 @@ func TestIssueAuthorsShortcutWithKeyword(t *testing.T) { }) defer server.Close() - err := runIssueShortcut(t, server, "authors", map[string]string{ - "keyword": "bob", - }) + err := runShortcut(t, server, "authors", map[string]string{"keyword": "bob"}) if err != nil { t.Fatalf("authors shortcut failed: %v", err) } } -func runIssueShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findIssueShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - return shortcut.Run(ctx) -} +// --- metadata lookup shortcuts --- -func findIssueShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut +func TestIssuePrioritiesShortcut(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_priorities.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - } - t.Fatalf("shortcut %q not found", name) - return nil -} + assertEqual(t, r.URL.Query().Get("keyword"), "normal") + writeJSON(t, w, []map[string]interface{}{ + {"id": 2, "name": "Normal"}, + }) + }) + defer server.Close() -func newIssueTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { - t.Helper() - return httptest.NewServer(handler) -} - -func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { - t.Helper() - var payload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("failed to decode request body: %v", err) - } - return payload -} - -func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) + err := runShortcut(t, server, "priorities", map[string]string{"keyword": "normal"}) + if err != nil { + t.Fatalf("priorities shortcut failed: %v", err) } } -func assertEqual(t *testing.T, got interface{}, want interface{}) { - t.Helper() - if got != want { - t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) +func TestIssueTagsShortcutWithFilters(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_tags.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + query := r.URL.Query() + assertEqual(t, query.Get("keyword"), "bug") + assertEqual(t, query.Get("only_name"), "true") + assertEqual(t, query.Get("order_by"), "issues_count") + assertEqual(t, query.Get("order_direction"), "desc") + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "issue_tags": []map[string]interface{}{ + {"id": 3, "name": "bug"}, + }, + }) + }) + defer server.Close() + + err := runShortcut(t, server, "tags", map[string]string{ + "keyword": "bug", + "only-name": "true", + "order-by": "issues_count", + "order-direction": "desc", + }) + if err != nil { + t.Fatalf("tags shortcut failed: %v", err) } } -// --- batch-reopen --- +func TestIssueStatusesShortcut(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_statues.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + assertEqual(t, r.URL.Query().Get("page"), "2") + assertEqual(t, r.URL.Query().Get("limit"), "10") + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "statues": []map[string]interface{}{ + {"id": 1, "name": "Open"}, + }, + }) + }) + defer server.Close() -func TestBatchReopenPreservesCurrentDescription(t *testing.T) { - var updatePayload map[string]interface{} + err := runShortcut(t, server, "statuses", map[string]string{"page": "2", "limit": "10"}) + if err != nil { + t.Fatalf("statuses shortcut failed: %v", err) + } +} + +// --- HTTP error paths --- + +func TestIssueListHTTPError(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + }) + defer server.Close() + + err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +func TestIssueCreateHTTPError(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + }) + defer server.Close() + + err := runShortcut(t, server, "create", map[string]string{"title": "test"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +func TestIssueViewHTTPError(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + }) + defer server.Close() + + err := runShortcut(t, server, "view", map[string]string{"number": "42"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +func TestIssueCommentHTTPError(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + }) + defer server.Close() + + err := runShortcut(t, server, "comment", map[string]string{"number": "42", "body": "test"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +func TestIssueUpdateHTTPError(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/42.json": writeJSON(t, w, map[string]interface{}{ - "subject": "Existing title", - "description": "Existing description", + "id": float64(42), "subject": "bug", "description": "desc", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": - updatePayload = decodeJSON(t, r) - writeJSON(t, w, updatePayload) + 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-reopen", map[string]string{ - "numbers": "42", - "dry-run": "false", - }) - if err != nil { - t.Fatalf("batch-reopen shortcut failed: %v", err) - } - assertEqual(t, updatePayload["subject"], "Existing title") - assertEqual(t, updatePayload["description"], "Existing description") - assertEqual(t, updatePayload["status_id"], float64(1)) -} - -func TestBatchReopenDryRun(t *testing.T) { - 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-reopen", map[string]string{ - "numbers": "1, 2, 3", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("batch-reopen dry-run failed: %v", err) - } -} - -func TestBatchReopenNoNumbers(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected") - }) - defer server.Close() - - err := runShortcut(t, server, "batch-reopen", map[string]string{}) + err := runShortcut(t, server, "update", map[string]string{"number": "42", "title": "new"}) if err == nil { - t.Fatal("expected error when no issue numbers provided") + t.Fatal("expected error for PATCH HTTP 500") } } -func TestBatchReopenFetchFails(t *testing.T) { +func TestIssueJournalsHTTPError(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeText(t, w, http.StatusNotFound, "not found") + writeText(t, w, http.StatusInternalServerError, "server error") }) defer server.Close() - err := runShortcut(t, server, "batch-reopen", map[string]string{"numbers": "99"}) + err := runShortcut(t, server, "journals", map[string]string{"number": "42"}) if err == nil { - t.Fatal("expected error when fetch fails") + t.Fatal("expected error for HTTP 500") } } -// --- batch-label --- - -func TestBatchLabelAddLabels(t *testing.T) { - var patchPayloads []map[string]interface{} - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - // Handle GET requests to fetch issue data - if r.Method == "GET" { - writeJSON(t, w, map[string]interface{}{ - "id": float64(1), - "tags": []interface{}{}, - "issue_tags": []interface{}{}, - }) - return - } - // Handle PATCH request to update labels - if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/batch_update.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - payload := decodeJSON(t, r) - patchPayloads = append(patchPayloads, payload) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "ok"}) - }) - defer server.Close() - - err := runShortcut(t, server, "batch-label", map[string]string{ - "ids": "1,2,3", - "add": "1,2", - "dry-run": "false", - }) - if err != nil { - t.Fatalf("batch-label shortcut failed: %v", err) - } - // Should have 3 PATCH requests (one per issue) - if len(patchPayloads) != 3 { - t.Fatalf("expected 3 PATCH requests, got %d", len(patchPayloads)) - } - // Each PATCH should have a single issue ID and the new tags - for i, payload := range patchPayloads { - assertNumberSlice(t, payload["ids"], []float64{float64(i + 1)}) - assertNumberSlice(t, payload["issue_tag_ids"], []float64{1, 2}) - } -} - -func TestBatchLabelRemoveLabels(t *testing.T) { - var patchPayloads []map[string]interface{} - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - // Handle GET requests to fetch issue data - if r.Method == "GET" { - writeJSON(t, w, map[string]interface{}{ - "id": float64(1), - "tags": []interface{}{map[string]interface{}{"id": float64(1)}}, - "issue_tags": []interface{}{map[string]interface{}{"id": float64(1)}}, - }) - return - } - // Handle PATCH request to update labels - if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/batch_update.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - payload := decodeJSON(t, r) - patchPayloads = append(patchPayloads, payload) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "ok"}) - }) - defer server.Close() - - err := runShortcut(t, server, "batch-label", map[string]string{ - "ids": "1,2,3", - "remove": "1", - "dry-run": "false", - }) - if err != nil { - t.Fatalf("batch-label shortcut failed: %v", err) - } - // Should have 3 PATCH requests (one per issue) - if len(patchPayloads) != 3 { - t.Fatalf("expected 3 PATCH requests, got %d", len(patchPayloads)) - } - // Each PATCH should have a single issue ID and empty tags (after removing tag 1) - for i, payload := range patchPayloads { - assertNumberSlice(t, payload["ids"], []float64{float64(i + 1)}) - assertNumberSlice(t, payload["issue_tag_ids"], []float64{}) - } -} - -func TestBatchLabelDryRun(t *testing.T) { - 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-label", map[string]string{ - "ids": "1,2,3", - "add": "1", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("batch-label dry-run failed: %v", err) - } -} - -func TestBatchLabelNoIDs(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected") - }) - defer server.Close() - - err := runShortcut(t, server, "batch-label", map[string]string{"add": "1"}) - if err == nil { - t.Fatal("expected error when no issue IDs provided") - } -} - -func TestBatchLabelNoLabels(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected") - }) - defer server.Close() - - err := runShortcut(t, server, "batch-label", map[string]string{"ids": "1,2,3"}) - if err == nil { - t.Fatal("expected error when no labels specified") - } -} - -// --- batch-assign --- - -func TestBatchAssignAddAssigners(t *testing.T) { - var patchPayloads []map[string]interface{} - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - // Handle GET requests to fetch issue data - if r.Method == "GET" { - writeJSON(t, w, map[string]interface{}{ - "id": float64(1), - "assigners": []interface{}{}, - }) - return - } - // Handle PATCH request to update assigners - if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/batch_update.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - payload := decodeJSON(t, r) - patchPayloads = append(patchPayloads, payload) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "ok"}) - }) - defer server.Close() - - err := runShortcut(t, server, "batch-assign", map[string]string{ - "ids": "1,2,3", - "add": "1,2", - "dry-run": "false", - }) - if err != nil { - t.Fatalf("batch-assign shortcut failed: %v", err) - } - // Should have 3 PATCH requests (one per issue) - if len(patchPayloads) != 3 { - t.Fatalf("expected 3 PATCH requests, got %d", len(patchPayloads)) - } - // Each PATCH should have a single issue ID and the new assigners - for i, payload := range patchPayloads { - assertNumberSlice(t, payload["ids"], []float64{float64(i + 1)}) - assertNumberSlice(t, payload["assigner_ids"], []float64{1, 2}) - } -} - -func TestBatchAssignRemoveAssigners(t *testing.T) { - var patchPayloads []map[string]interface{} - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - // Handle GET requests to fetch issue data - if r.Method == "GET" { - writeJSON(t, w, map[string]interface{}{ - "id": float64(1), - "assigners": []interface{}{map[string]interface{}{"id": float64(1)}}, - }) - return - } - // Handle PATCH request to update assigners - if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/batch_update.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - payload := decodeJSON(t, r) - patchPayloads = append(patchPayloads, payload) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "ok"}) - }) - defer server.Close() - - err := runShortcut(t, server, "batch-assign", map[string]string{ - "ids": "1,2,3", - "remove": "1", - "dry-run": "false", - }) - if err != nil { - t.Fatalf("batch-assign shortcut failed: %v", err) - } - // Should have 3 PATCH requests (one per issue) - if len(patchPayloads) != 3 { - t.Fatalf("expected 3 PATCH requests, got %d", len(patchPayloads)) - } - // Each PATCH should have a single issue ID and empty assigners (after removing assigner 1) - for i, payload := range patchPayloads { - assertNumberSlice(t, payload["ids"], []float64{float64(i + 1)}) - assertNumberSlice(t, payload["assigner_ids"], []float64{}) - } -} - -func TestBatchAssignDryRun(t *testing.T) { - 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-assign", map[string]string{ - "ids": "1,2,3", - "add": "1", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("batch-assign dry-run failed: %v", err) - } -} - -func TestBatchAssignNoIDs(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected") - }) - defer server.Close() - - err := runShortcut(t, server, "batch-assign", map[string]string{"add": "1"}) - if err == nil { - t.Fatal("expected error when no issue IDs provided") - } -} - -func TestBatchAssignNoAssigners(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected") - }) - defer server.Close() - - err := runShortcut(t, server, "batch-assign", map[string]string{"ids": "1,2,3"}) - if err == nil { - t.Fatal("expected error when no assigners specified") - } -} - -// --- batch-comment --- - -func TestBatchCommentAddsComments(t *testing.T) { - commentCount := 0 +func TestIssueCloseHTTPError(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"}) - case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/2.json": - writeJSON(t, w, map[string]interface{}{"subject": "Issue 2"}) - case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/1/journals.json": - commentCount++ - writeJSON(t, w, map[string]interface{}{"id": float64(1)}) - case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/2/journals.json": - commentCount++ - writeJSON(t, w, map[string]interface{}{"id": float64(2)}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "id": float64(42), "subject": "bug", "description": "desc", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.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-comment", map[string]string{ - "numbers": "1,2", - "message": "Batch comment", - "dry-run": "false", - }) - if err != nil { - t.Fatalf("batch-comment shortcut failed: %v", err) - } - assertEqual(t, commentCount, 2) -} - -func TestBatchCommentDryRun(t *testing.T) { - 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,3", - "message": "Test comment", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("batch-comment dry-run failed: %v", err) - } -} - -func TestBatchCommentNoNumbers(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected") - }) - defer server.Close() - - err := runShortcut(t, server, "batch-comment", map[string]string{"message": "test"}) + err := runShortcut(t, server, "close", map[string]string{"number": "42"}) if err == nil { - t.Fatal("expected error when no issue numbers provided") + t.Fatal("expected error for PATCH HTTP 500") } } -func TestBatchCommentNoBody(t *testing.T) { +func TestFetchExistingIssueBadData(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected") + writeJSON(t, w, "not a map") }) defer server.Close() - err := runShortcut(t, server, "batch-comment", map[string]string{"numbers": "1,2"}) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", + Repo: "repo", + } + _, err := fetchExistingIssue(ctx, "1") if err == nil { - t.Fatal("expected error when no body provided") + t.Fatal("expected error for non-map response") } } -// --- batch-export --- - -func TestBatchExportToJSON(t *testing.T) { +func TestFetchExistingIssueNoSubject(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - writeJSON(t, w, map[string]interface{}{ - "issues": []interface{}{ - map[string]interface{}{ - "id": float64(1), - "subject": "Bug 1", - "status": map[string]interface{}{"id": float64(1), "name": "Open"}, - "priority": map[string]interface{}{"id": float64(2), "name": "Normal"}, - "assigners": []interface{}{}, - "tags": []interface{}{}, - "author": map[string]interface{}{"id": float64(1), "login": "alice"}, - "created_on": "2026-01-01T00:00:00Z", - }, - }, - }) + writeJSON(t, w, map[string]interface{}{"id": float64(1)}) }) defer server.Close() - err := runShortcut(t, server, "batch-export", map[string]string{ - "state": "open", - "limit": "100", - }) - if err != nil { - t.Fatalf("batch-export shortcut failed: %v", err) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", + Repo: "repo", } -} - -func TestBatchExportToCSV(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - writeJSON(t, w, map[string]interface{}{ - "issues": []interface{}{ - map[string]interface{}{ - "id": float64(1), - "subject": "Bug 1", - "status": map[string]interface{}{"id": float64(1), "name": "Open"}, - "priority": map[string]interface{}{"id": float64(2), "name": "Normal"}, - "assigners": []interface{}{}, - "tags": []interface{}{}, - "author": map[string]interface{}{"id": float64(1), "login": "alice"}, - "created_on": "2026-01-01T00:00:00Z", - }, - }, - }) - }) - defer server.Close() - - err := runShortcut(t, server, "batch-export", map[string]string{ - "state": "open", - "format": "csv", - "limit": "100", - }) - if err != nil { - t.Fatalf("batch-export shortcut failed: %v", err) - } -} - -func TestBatchExportWithFilters(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - query := r.URL.Query() - assertEqual(t, query.Get("category"), "closed") - assertEqual(t, query.Get("keyword"), "release") - assertEqual(t, query.Get("status_id"), "5") - writeJSON(t, w, map[string]interface{}{ - "issues": []interface{}{ - map[string]interface{}{ - "id": float64(1), - "subject": "Filtered Issue", - "status": map[string]interface{}{"id": float64(5), "name": "Closed"}, - "priority": map[string]interface{}{"id": float64(2), "name": "Normal"}, - "assigners": []interface{}{}, - "tags": []interface{}{}, - "author": map[string]interface{}{"id": float64(1), "login": "alice"}, - "created_on": "2026-01-01T00:00:00Z", - }, - }, - }) - }) - defer server.Close() - - err := runShortcut(t, server, "batch-export", map[string]string{ - "state": "closed", - "keyword": "release", - "status-id": "5", - "limit": "100", - }) - if err != nil { - t.Fatalf("batch-export with filters failed: %v", err) - } -} - -// --- batch-import --- - -func TestBatchImportFromCSV(t *testing.T) { - createCount := 0 - 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) - } - payload := decodeJSON(t, r) - assertEqual(t, payload["subject"], "Imported Issue") - assertEqual(t, payload["status_id"], float64(1)) - createCount++ - writeJSON(t, w, map[string]interface{}{"id": float64(createCount)}) - }) - defer server.Close() - - // Note: This test will fail because the file doesn't exist - // In a real test, we would create the file first - _ = runShortcut(t, server, "batch-import", map[string]string{ - "file": "/tmp/test-import.csv", - "dry-run": "false", - }) - _ = createCount // Avoid unused variable warning -} - -func TestBatchImportDryRun(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected in dry-run mode") - }) - defer server.Close() - - // This test will fail because the file doesn't exist - // In a real test, we would create the file first - err := runShortcut(t, server, "batch-import", map[string]string{ - "file": "/tmp/test-import.csv", - "dry-run": "true", - }) - // We expect an error about missing file + _, err := fetchExistingIssue(ctx, "1") if err == nil { - t.Fatal("expected error for missing file") + t.Fatal("expected error for missing subject") } } -func TestBatchImportNoFile(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected") - }) - defer server.Close() +// --- normalizeIssueStatus --- - err := runShortcut(t, server, "batch-import", map[string]string{}) - if err == nil { - t.Fatal("expected error when no file provided") +func TestNormalizeIssueStatus(t *testing.T) { + tests := []struct { + input string + want interface{} + wantErr bool + }{ + {"open", 1, false}, + {"OPEN", 1, false}, + {" open ", 1, false}, + {"closed", 5, false}, + {"CLOSED", 5, false}, + {"0", 0, false}, + {"10", 10, false}, + {"invalid", nil, true}, + {"", nil, true}, + } + for _, tt := range tests { + got, err := normalizeIssueStatus(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("normalizeIssueStatus(%q) expected error", tt.input) + } + } else { + if err != nil { + t.Errorf("normalizeIssueStatus(%q) error: %v", tt.input, err) + } + if got != tt.want { + t.Errorf("normalizeIssueStatus(%q) = %v, want %v", tt.input, got, tt.want) + } + } } } diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 2131ce1..9f0259b 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-issue -version: 2.0.0 -description: "Issue 管理:创建、查看、更新、关闭/批量关闭/批量更新/批量删除/批量重开/批量标签/批量指派/批量评论/批量导出/批量导入 Issue,添加评论。当用户需要操作 GitLink Issue 时触发。" +version: 3.0.0 +description: "Issue 管理:创建、查看、更新、关闭/批量关闭/批量更新/批量删除 Issue,添加评论,查看动态记录和活动。当用户需要操作 GitLink Issue 时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -26,15 +26,11 @@ metadata: | `issue +update` | 更新 Issue | 是 | | `issue +close` | 关闭 Issue | 是 | | `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | -| `issue +batch-reopen` | 批量重开已关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | | `issue +batch-update` | 按 API issue id 批量更新状态、优先级、里程碑、标签、负责人 | 是(dry-run 不写入) | | `issue +batch-delete` | 按 API issue id 批量删除 Issue;真实删除必须 `--yes` | 是(dry-run 不写入) | -| `issue +batch-label` | 按 API issue id 批量添加/移除标签 | 是(dry-run 不写入) | -| `issue +batch-assign` | 按 API issue id 批量指派/取消指派负责人 | 是(dry-run 不写入) | -| `issue +batch-comment` | 批量添加评论到多个 Issue | 是(dry-run 不写入) | -| `issue +batch-export` | 导出 Issue 到 CSV 或 JSON 格式 | 否(公开项目) | -| `issue +batch-import` | 从 CSV 文件批量创建 Issue | 是(dry-run 不写入) | | `issue +comment` | 添加评论 | 是 | +| `issue +journals` | 查询 Issue 动态记录,支持分类和分页 | 否(公开项目) | +| `issue +activity` | 查询 Issue 活动事件,复用 journals 端点 | 否(公开项目) | | `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) | | `issue +authors` | 查询 Issue 发布人列表 | 否(公开项目) | | `issue +statuses` | 查询 Issue 状态列表 | 否(公开项目) | @@ -75,36 +71,13 @@ gitlink-cli issue +batch-update --owner myuser --repo myrepo --ids 101,102 --sta gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --dry-run gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --yes -# 批量重开已关闭的 Issue -gitlink-cli issue +batch-reopen --owner myuser --repo myrepo --numbers 123,124 --dry-run - -# 批量添加标签(使用 API issue id 和标签 id) -gitlink-cli issue +batch-label --owner myuser --repo myrepo --ids 101,102 --add 1,2 --dry-run - -# 批量移除标签 -gitlink-cli issue +batch-label --owner myuser --repo myrepo --ids 101,102 --remove 3,4 - -# 批量指派负责人(使用 API issue id 和用户 id) -gitlink-cli issue +batch-assign --owner myuser --repo myrepo --ids 101,102 --add 5,6 --dry-run - -# 批量取消指派 -gitlink-cli issue +batch-assign --owner myuser --repo myrepo --ids 101,102 --remove 5,6 - -# 批量添加评论 -gitlink-cli issue +batch-comment --owner myuser --repo myrepo --numbers 123,124 --message "已修复,请验证" --dry-run - -# 导出 Issue 到 CSV -gitlink-cli issue +batch-export --owner myuser --repo myrepo --output issues.csv - -# 导出 Issue 到 JSON(带过滤条件) -gitlink-cli issue +batch-export --owner myuser --repo myrepo --format json --status-id 5 --output closed_issues.json - -# 从 CSV 文件批量创建 Issue -gitlink-cli issue +batch-import --owner myuser --repo myrepo --file issues.csv --dry-run - # 添加评论 gitlink-cli issue +comment --number 4 --body "已修复,请验证" +# 查询 Issue 评论和活动记录 +gitlink-cli issue +journals --number 4 --category comment --page 1 --limit 50 +gitlink-cli issue +activity --number 4 --page 1 --limit 50 + # 查询 Issue 负责人 gitlink-cli issue +assigners --owner Gitlink --repo forgeplus --keyword alice @@ -114,50 +87,17 @@ gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob ## 批量维护安全约束 -### ID 类型说明 - -- **网页 Issue 编号**(`project_issues_index`):用于 `--numbers` 参数 - - `issue +batch-close --numbers` - - `issue +batch-reopen --numbers` - - `issue +batch-comment --numbers` - -- **API Issue ID**(数据库内部 ID):用于 `--ids` 参数 - - `issue +batch-update --ids` - - `issue +batch-delete --ids` - - `issue +batch-label --ids` - - `issue +batch-assign --ids` - -### 安全操作流程 - -- 执行 `batch-update` / `batch-delete` / `batch-label` / `batch-assign` 前,先用 `issue +list` 或 `issue +view` 确认 API issue id 来源。 +- `issue +batch-close --numbers` 使用网页 URL 中的 Issue 编号,即 `project_issues_index`。 +- `issue +batch-update --ids` 和 `issue +batch-delete --ids` 使用 OpenAPI 返回的 API issue id,不是网页 Issue 编号。 +- 执行 `batch-update` / `batch-delete` 前,先用 `issue +list` 或 `issue +view` 确认 id 来源。 - 写操作先执行 `--dry-run`,展示 `method`、`path`、`body` 给用户确认。 - `batch-delete` 是破坏性操作,真实执行必须显式传 `--yes`。 -- `batch-label` 和 `batch-assign` 会保留现有标签/负责人,仅添加/移除指定的项。 - -### CSV 文件格式 - -`batch-import` 支持的 CSV 列: -- `subject`(必需):Issue 标题 -- `description`(可选):Issue 描述 -- `priority_id`(可选):优先级 ID -- `status_id`(可选):状态 ID(默认为 1) -- `assigner_ids`(可选):负责人 ID 列表(逗号分隔) -- `issue_tag_ids`(可选):标签 ID 列表(逗号分隔) ## Raw API 补充 ```bash -# 获取 Issue 评论列表(使用 v1 API,按 issue number 查询) -gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals - # 批量更新 Issue(仍使用旧版 API,需传数据库 ID) gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3],"status_id":"closed"}' - -# 批量添加评论(使用 v1 API) -gitlink-cli api POST /v1/:owner/:repo/issues/:number/journals --body '{"notes":"评论内容"}' - -# 导出 Issue 列表(使用 v1 API) -gitlink-cli api GET /v1/:owner/:repo/issues.json ``` ## GitLink Issue 字段映射 diff --git a/skills/gitlink-stale-issue-manager/SKILL.md b/skills/gitlink-stale-issue-manager/SKILL.md index c7c7a91..c3b888d 100644 --- a/skills/gitlink-stale-issue-manager/SKILL.md +++ b/skills/gitlink-stale-issue-manager/SKILL.md @@ -52,7 +52,7 @@ gitlink-cli issue +list --state open --owner --repo --page 1 --li ```bash # 获取某个 Issue 的评论列表(如果数据量过大,可能需要翻到最后一页取最后十条) -gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals?category=comment&page=1&limit=50 --format json +gitlink-cli issue +journals --owner --repo --number --category comment --page 1 --limit 50 --format json ``` **AI 判断逻辑**: @@ -412,7 +412,7 @@ gitlink-cli issue +list --state open --owner --repo --format json # Step 2:判断 Issue 最后活动时间 # a. 如果 created_at == updated_at → 最后活动时间 = created_at(无需查询评论) # b. 如果 created_at != updated_at → 查询评论列表获取最新评论时间 -gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals --format json +gitlink-cli issue +journals --owner --repo --number --category comment --page 1 --limit 50 --format json # - 有评论 → 最后活动时间 = 最新评论的 created_at # - 无评论 → 最后活动时间 = updated_at # 需注意排除skill自动发送的评论内容