diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 73383ea..5aa59e5 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -1,13 +1,11 @@ package issue import ( - "errors" "fmt" "net/url" "strconv" "strings" - "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -17,68 +15,22 @@ func v1RepoPath(ctx *common.RuntimeContext) string { return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) } -func normalizeIssueListState(state string) string { - switch strings.ToLower(strings.TrimSpace(state)) { - case "open", "opened": - return "opened" - case "closed": - return "closed" - case "all", "": - return "all" - default: - return state - } -} - type existingIssue struct { - Subject string - Description string - StatusID interface{} - PriorityID interface{} - TagIDs []interface{} - AssignerIDs []interface{} - AssignedToID interface{} - FixedVersionID interface{} - TrackerID interface{} - IssueType interface{} - BranchName string - StartDate string - DueDate string + Subject string + Description string + Metadata map[string]interface{} } -func legacyIssuePath(ctx *common.RuntimeContext, number string) string { - return fmt.Sprintf("%s/issues/%s", ctx.RepoPath(), number) -} - -func legacyIssueEditPath(ctx *common.RuntimeContext, number string) string { - return legacyIssuePath(ctx, number) + "/edit" -} - -func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { - tr := shortcutTranslator(translators...) +func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ newBatchCloseShortcut(), - newBatchReopenShortcut(), - newBatchCommentShortcut(), - newBatchUpdateShortcut(), - newBatchDeleteShortcut(), - newExportShortcut(tr), { Name: "list", - Description: tr.T("cmd.issue.list.short"), + Description: "List issues", Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"}, - {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword")}, - {Name: "participant", Usage: tr.T("flag.issue.participant")}, - {Name: "author-id", Usage: tr.T("flag.issue.author_id")}, - {Name: "assignee-id", Usage: tr.T("flag.issue.assignee_id")}, - {Name: "milestone-id", Usage: tr.T("flag.issue.milestone")}, - {Name: "status-id", Usage: tr.T("flag.issue.status_id")}, - {Name: "tag-ids", Usage: tr.T("flag.issue.tag_ids")}, - {Name: "sort-by", Usage: tr.T("flag.sort_by")}, - {Name: "sort-direction", Usage: tr.T("flag.sort_direction")}, - {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, - {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + {Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -88,34 +40,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) if s := ctx.Arg("state"); s != "" { - q.Set("category", normalizeIssueListState(s)) - } - if keyword := ctx.Arg("keyword"); keyword != "" { - q.Set("keyword", keyword) - } - if participant := ctx.Arg("participant"); participant != "" { - q.Set("participant_category", participant) - } - if authorID := ctx.Arg("author-id"); authorID != "" { - q.Set("author_id", authorID) - } - if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" { - q.Set("assigner_id", assigneeID) - } - if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" { - q.Set("milestone_id", milestoneID) - } - if statusID := ctx.Arg("status-id"); statusID != "" { - q.Set("status_id", statusID) - } - if tagIDs := ctx.Arg("tag-ids"); tagIDs != "" { - q.Set("issue_tag_ids", tagIDs) - } - if sortBy := ctx.Arg("sort-by"); sortBy != "" { - q.Set("sort_by", sortBy) - } - if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" { - q.Set("sort_direction", sortDirection) + q.Set("state", s) } env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q) if err != nil { @@ -127,19 +52,13 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "create", - Description: tr.T("cmd.issue.create.short"), + Description: "Create a new issue", 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: "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")}, - {Name: "priority-id", Usage: "Priority ID", Default: "2"}, - {Name: "tag-ids", Usage: "Comma-separated issue tag IDs"}, - {Name: "assigner-ids", Usage: "Comma-separated issue assigner IDs"}, - {Name: "branch", Usage: "Linked branch name"}, - {Name: "start-date", Usage: "Start date (YYYY-MM-DD)"}, - {Name: "due-date", Usage: "Due date (YYYY-MM-DD)"}, + {Name: "title", Short: "t", Usage: "Issue title", Required: true}, + {Name: "body", Short: "b", Usage: "Issue description"}, + {Name: "assignee", Short: "a", Usage: "Assignee login"}, + {Name: "milestone", Short: "m", Usage: "Milestone ID"}, + {Name: "label", Usage: "Label ID"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -164,9 +83,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if m := ctx.Arg("milestone"); m != "" { body["fixed_version_id"] = m } - if err := applyIssueMetadataArgs(ctx, body); err != nil { - return err - } env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) if err != nil { return err @@ -176,13 +92,15 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "view", - Description: tr.T("cmd.issue.view.short"), - Flags: issueNumberFlags(), + Description: "View issue details", + Flags: []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, + }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := issueNumberArg(ctx) + number, err := ctx.RequireArg("number") if err != nil { return err } @@ -190,44 +108,35 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - enrichIssueView(ctx, number, env) return ctx.Output(env) }, }, { Name: "close", - Description: tr.T("cmd.issue.close.short"), - Flags: issueNumberFlags(), - Run: func(ctx *common.RuntimeContext) error { - return setIssueStatus(ctx, 5) // 5 = closed + Description: "Close an issue", + Flags: []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, }, - }, - { - Name: "reopen", - Description: tr.T("cmd.issue.reopen.short"), - Flags: issueNumberFlags(), - Run: func(ctx *common.RuntimeContext) error { - return setIssueStatus(ctx, 1) // 1 = open - }, - }, - { - Name: "delete", - Description: tr.T("cmd.issue.delete.short"), - Flags: appendIssueNumberFlags( - common.Flag{Name: "yes", Usage: tr.T("flag.issue.delete.yes"), Bool: true, Default: "false"}, - ), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := issueNumberArg(ctx) + number, err := ctx.RequireArg("number") if err != nil { return err } - if !parseBool(ctx.Arg("yes")) { - return fmt.Errorf("delete is destructive; pass --yes to confirm deleting issue #%s", number) + current, err := fetchExistingIssue(ctx, number) + if err != nil { + return err } - env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) + + body := map[string]interface{}{ + "subject": current.Subject, + "description": current.Description, + "status_id": 5, // 5 = closed + } + copyIssueMetadata(body, current.Metadata) + env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { return err } @@ -236,31 +145,26 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "update", - Description: tr.T("cmd.issue.update.short"), - 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: "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"}, - 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)"}, - ), + Description: "Update an issue", + Flags: []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, + {Name: "title", Short: "t", Usage: "New title"}, + {Name: "body", Short: "b", Usage: "New description"}, + {Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"}, + }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := issueNumberArg(ctx) + number, err := ctx.RequireArg("number") 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 title == "" && description == "" && state == "" { + return fmt.Errorf("at least one of --title, --body, or --state is required") } current, err := fetchExistingIssue(ctx, number) @@ -272,7 +176,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { "subject": current.Subject, "description": current.Description, } - preserveIssueMetadata(body, current) + copyIssueMetadata(body, current.Metadata) if t := ctx.Arg("title"); t != "" { body["subject"] = t } @@ -286,9 +190,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } body["status_id"] = statusID } - if err := applyIssueMetadataArgs(ctx, body); err != nil { - return err - } env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { return err @@ -298,108 +199,16 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "comment", - Description: tr.T("cmd.issue.comment.short"), - Flags: appendIssueNumberFlags( - common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, - common.Flag{Name: "parent-id", Usage: "Parent comment ID for a threaded reply"}, - common.Flag{Name: "reply-id", Usage: "Comment ID being replied to"}, - common.Flag{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, - common.Flag{Name: "receivers", Usage: "Comma-separated user logins to mention"}, - ), - Run: runIssueComment, - }, - { - Name: "comments", - Description: "List issue comments and operation records", - Flags: appendIssueNumberFlags( - common.Flag{Name: "category", Short: "c", Usage: "Filter by all, comment, or operate", Default: "comment"}, - common.Flag{Name: "keyword", Short: "k", Usage: "Search comment content"}, - common.Flag{Name: "sort-by", Usage: "Sort field: created_on or updated_on"}, - common.Flag{Name: "sort-direction", Usage: "Sort direction: asc or desc"}, - common.Flag{Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - common.Flag{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - ), - Run: runIssueComments, - }, - { - Name: "comment-update", - Description: "Update an issue comment", - Flags: appendIssueNumberFlags( - common.Flag{Name: "comment-id", Usage: "Comment ID", Required: true}, - common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, - common.Flag{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, - common.Flag{Name: "receivers", Usage: "Comma-separated user logins to mention"}, - ), - Run: runIssueCommentUpdate, - }, - { - Name: "comment-delete", - Description: "Delete an issue comment", - Flags: appendIssueNumberFlags( - common.Flag{Name: "comment-id", Usage: "Comment ID", Required: true}, - ), - Run: runIssueCommentDelete, - }, - { - Name: "comment-replies", - Description: "List replies under an issue comment", - Flags: appendIssueNumberFlags( - common.Flag{Name: "comment-id", Usage: "Parent comment ID", Required: true}, - common.Flag{Name: "keyword", Short: "k", Usage: "Search reply content"}, - common.Flag{Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - common.Flag{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - ), - Run: runIssueCommentReplies, - }, - { - Name: "comments", - Description: tr.T("cmd.issue.comments.short"), - Flags: appendIssueNumberFlags( - common.Flag{Name: "keyword", Short: "k", Usage: tr.T("flag.issue.comments_keyword")}, - common.Flag{Name: "category", Usage: tr.T("flag.issue.comments_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: "20"}, - ), - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, err := issueNumberArg(ctx) - if err != nil { - return err - } - q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - if keyword := ctx.Arg("keyword"); keyword != "" { - q.Set("keyword", keyword) - } - if category := ctx.Arg("category"); category != "" { - q.Set("category", category) - } - env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), q) - if err != nil { - return err - } - return ctx.Output(env) + Description: "Add a comment to an issue", + Flags: []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, + {Name: "body", Short: "b", Usage: "Comment body", Required: true}, }, - }, - { - Name: "comment-edit", - Description: tr.T("cmd.issue.comment_edit.short"), - Flags: appendIssueNumberFlags( - common.Flag{Name: "comment-id", Short: "c", Usage: tr.T("flag.issue.comment_id"), Required: true}, - common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, - ), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := issueNumberArg(ctx) - if err != nil { - return err - } - commentID, err := parseIssueID(ctx.Arg("comment-id"), "comment-id") + number, err := ctx.RequireArg("number") if err != nil { return err } @@ -407,32 +216,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s/journals/%d", v1RepoPath(ctx), number, commentID), map[string]interface{}{"notes": body}) - if err != nil { - return err + payload := map[string]interface{}{ + "notes": body, } - return ctx.Output(env) - }, - }, - { - Name: "comment-delete", - Description: tr.T("cmd.issue.comment_delete.short"), - Flags: appendIssueNumberFlags( - common.Flag{Name: "comment-id", Short: "c", Usage: tr.T("flag.issue.comment_id"), Required: true}, - ), - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, err := issueNumberArg(ctx) - if err != nil { - return err - } - commentID, err := parseIssueID(ctx.Arg("comment-id"), "comment-id") - if err != nil { - return err - } - env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%d", v1RepoPath(ctx), number, commentID), nil) + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload) if err != nil { return err } @@ -481,289 +268,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, - { - Name: "priorities", - Description: "List issue priorities", - Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "Search keyword"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - q := url.Values{} - if keyword := ctx.Arg("keyword"); keyword != "" { - q.Set("keyword", keyword) - } - env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_priorities", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "tags", - Description: "List issue tags", - Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "Search keyword"}, - {Name: "only-name", Usage: "Only return tag names and IDs", Bool: true, Default: "false"}, - {Name: "order-by", Usage: "Order by: updated_on, created_on, issues_count"}, - {Name: "order-direction", Usage: "Order direction: asc or desc"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - q := url.Values{} - if keyword := ctx.Arg("keyword"); keyword != "" { - q.Set("keyword", keyword) - } - if parseBool(ctx.Arg("only-name")) { - q.Set("only_name", "true") - } - if orderBy := ctx.Arg("order-by"); orderBy != "" { - q.Set("order_by", orderBy) - } - if orderDirection := ctx.Arg("order-direction"); orderDirection != "" { - q.Set("order_direction", orderDirection) - } - env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_tags", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "statuses", - Description: "List issue statuses", - Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_statues", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, } } -func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { - if len(translators) > 0 && translators[0] != nil { - return translators[0] - } - return i18n.Default() -} - -func issueNumberFlags() []common.Flag { - return []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number from the web URL (preferred)"}, - {Name: "id", Short: "i", Usage: "Compatibility alias for --number; this is not the database ID"}, - } -} - -func appendIssueNumberFlags(flags ...common.Flag) []common.Flag { - return append(issueNumberFlags(), flags...) -} - -func issueNumberArg(ctx *common.RuntimeContext) (string, error) { - if number := strings.TrimSpace(ctx.Arg("number")); number != "" { - return number, nil - } - if id := strings.TrimSpace(ctx.Arg("id")); id != "" { - return id, nil - } - return "", fmt.Errorf("required flag --number is missing (or use --id as a compatibility alias)") -} - -func runIssueComment(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, err := issueNumberArg(ctx) - if err != nil { - return err - } - body, err := ctx.RequireArg("body") - if err != nil { - return err - } - payload, err := issueCommentPayload(ctx, body, true) - if err != nil { - return err - } - env, err := ctx.CallAPI("POST", issueJournalPath(ctx, number), payload) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runIssueComments(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, err := issueNumberArg(ctx) - if err != nil { - return err - } - q := url.Values{} - setIssueQueryIfPresent(q, "category", ctx.Arg("category")) - setIssueQueryIfPresent(q, "keyword", ctx.Arg("keyword")) - setIssueQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) - setIssueQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) - setIssueQueryIfPresent(q, "page", ctx.Arg("page")) - setIssueQueryIfPresent(q, "limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", issueJournalPath(ctx, number), q) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runIssueCommentUpdate(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, commentID, err := issueCommentTarget(ctx) - if err != nil { - return err - } - body, err := ctx.RequireArg("body") - if err != nil { - return err - } - payload, err := issueCommentPayload(ctx, body, false) - if err != nil { - return err - } - env, err := ctx.CallAPI("PATCH", issueJournalItemPath(ctx, number, commentID), payload) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runIssueCommentDelete(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, commentID, err := issueCommentTarget(ctx) - if err != nil { - return err - } - env, err := ctx.CallAPI("DELETE", issueJournalItemPath(ctx, number, commentID), nil) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runIssueCommentReplies(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, commentID, err := issueCommentTarget(ctx) - if err != nil { - return err - } - q := url.Values{} - setIssueQueryIfPresent(q, "keyword", ctx.Arg("keyword")) - setIssueQueryIfPresent(q, "page", ctx.Arg("page")) - setIssueQueryIfPresent(q, "limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", issueJournalItemPath(ctx, number, commentID)+"/children_journals", q) - if err != nil { - return err - } - return ctx.Output(env) -} - -func issueJournalPath(ctx *common.RuntimeContext, number string) string { - return fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), url.PathEscape(number)) -} - -func issueJournalItemPath(ctx *common.RuntimeContext, number, commentID string) string { - return fmt.Sprintf("%s/%s", issueJournalPath(ctx, number), url.PathEscape(commentID)) -} - -func issueCommentTarget(ctx *common.RuntimeContext) (string, string, error) { - number, err := issueNumberArg(ctx) - if err != nil { - return "", "", err - } - commentID, err := ctx.RequireArg("comment-id") - if err != nil { - return "", "", err - } - if _, err := parseIssueID(commentID, "comment-id"); err != nil { - return "", "", err - } - return number, strings.TrimSpace(commentID), nil -} - -func issueCommentPayload(ctx *common.RuntimeContext, body string, includeThreading bool) (map[string]interface{}, error) { - payload := map[string]interface{}{"notes": body} - if includeThreading { - if parentID := ctx.Arg("parent-id"); parentID != "" { - id, err := parseIssueID(parentID, "parent-id") - if err != nil { - return nil, err - } - payload["parent_id"] = id - } - if replyID := ctx.Arg("reply-id"); replyID != "" { - id, err := parseIssueID(replyID, "reply-id") - if err != nil { - return nil, err - } - payload["reply_id"] = id - } - } - if attachmentIDs := ctx.Arg("attachment-ids"); attachmentIDs != "" { - ids, err := parseIssueIDList(attachmentIDs, "attachment-ids") - if err != nil { - return nil, err - } - payload["attachment_ids"] = ids - } - if receivers := parseIssueStringList(ctx.Arg("receivers")); len(receivers) > 0 { - payload["receivers_login"] = receivers - } - return payload, nil -} - -func setIssueQueryIfPresent(q url.Values, name, value string) { - if strings.TrimSpace(value) != "" { - q.Set(name, strings.TrimSpace(value)) - } -} - -func parseIssueStringList(value string) []string { - parts := strings.Split(value, ",") - result := make([]string, 0, len(parts)) - seen := map[string]bool{} - for _, part := range parts { - item := strings.TrimSpace(part) - if item == "" || seen[item] { - continue - } - seen[item] = true - result = append(result, item) - } - return result -} - // normalizeIssueListIDs adds "number" (project_issues_index) and renames // "id" to "database_id" so the user-facing output uses the project-level // issue number, not the global database primary key. @@ -794,34 +301,6 @@ func normalizeIssueListIDs(env *output.Envelope) { } } -// setIssueStatus flips an issue to statusID. The v1 PATCH is read-modify-write, -// so the current issue is fetched and its metadata replayed to avoid clearing -// fields that were not part of the status change. -func setIssueStatus(ctx *common.RuntimeContext, statusID int) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, err := issueNumberArg(ctx) - if 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) - body["status_id"] = statusID - env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) - if err != nil { - return err - } - return ctx.Output(env) -} - func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) { getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) if err != nil { @@ -836,292 +315,93 @@ func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIss return nil, fmt.Errorf("failed to parse issue subject") } description, _ := issueData["description"].(string) - editData, err := fetchLegacyIssueEdit(ctx, number) - if err != nil { - return nil, fmt.Errorf("fetch issue edit metadata: %w", err) - } return &existingIssue{ - Subject: subject, - Description: description, - StatusID: firstNonNil(nestedIssueID(issueData, "status"), editData["status_id"]), - PriorityID: firstNonNil(nestedIssueID(issueData, "priority"), editData["priority_id"]), - TagIDs: firstNonEmptyIDs(issueObjectIDs(issueData, "tags", "issue_tags"), issueValueIDs(editData, "issue_tags")), - AssignerIDs: issueObjectIDs(issueData, "assigners"), - AssignedToID: firstNonNil(issueData["assigned_to_id"], editData["assigned_to_id"]), - FixedVersionID: firstNonNil(issueData["fixed_version_id"], editData["fixed_version_id"]), - TrackerID: firstNonNil(issueData["tracker_id"], editData["tracker_id"], nestedIssueID(issueData, "tracker")), - IssueType: firstNonNil(issueData["issue_type"], editData["issue_type"]), - BranchName: firstNonEmptyString(stringField(issueData, "branch_name"), stringField(editData, "branch_name")), - StartDate: firstNonEmptyString(stringField(issueData, "start_date"), stringField(editData, "start_date")), - DueDate: firstNonEmptyString(stringField(issueData, "due_date"), stringField(editData, "due_date")), + Subject: subject, + Description: description, + Metadata: existingIssueMetadata(issueData), }, nil } -func preserveIssueMetadata(body map[string]interface{}, issue *existingIssue) { - if issue.StatusID != nil { - body["status_id"] = issue.StatusID +func existingIssueMetadata(issueData map[string]interface{}) map[string]interface{} { + metadata := map[string]interface{}{} + copyIDValue(metadata, "priority_id", issueData["priority_id"]) + copyNestedIDValue(metadata, "priority_id", issueData["priority"]) + copyIDValue(metadata, "tracker_id", issueData["tracker_id"]) + copyNestedIDValue(metadata, "tracker_id", issueData["tracker"]) + copyIDValue(metadata, "fixed_version_id", issueData["fixed_version_id"]) + copyNestedIDValue(metadata, "fixed_version_id", issueData["fixed_version"]) + copyIDValue(metadata, "assigned_to_id", issueData["assigned_to_id"]) + copyNestedIDValue(metadata, "assigned_to_id", issueData["assigned_to"]) + + if ids := issueTagIDs(issueData["issue_tags"]); len(ids) > 0 { + metadata["issue_tag_ids"] = ids } - if issue.PriorityID != nil { - body["priority_id"] = issue.PriorityID - } - if len(issue.TagIDs) > 0 { - body["issue_tag_ids"] = issue.TagIDs - } - if len(issue.AssignerIDs) > 0 { - body["assigner_ids"] = issue.AssignerIDs - } - if issue.AssignedToID != nil { - body["assigned_to_id"] = issue.AssignedToID - } - if issue.FixedVersionID != nil { - body["fixed_version_id"] = issue.FixedVersionID - } - if issue.TrackerID != nil { - body["tracker_id"] = issue.TrackerID - } - if issue.IssueType != nil { - body["issue_type"] = issue.IssueType - } - if issue.BranchName != "" { - body["branch_name"] = issue.BranchName - } - if issue.StartDate != "" { - body["start_date"] = issue.StartDate - } - if issue.DueDate != "" { - body["due_date"] = issue.DueDate + return metadata +} + +func copyIssueMetadata(body map[string]interface{}, metadata map[string]interface{}) { + for key, value := range metadata { + body[key] = value } } -func nestedIssueID(data map[string]interface{}, key string) interface{} { - item, ok := data[key].(map[string]interface{}) +func copyNestedIDValue(dst map[string]interface{}, dstKey string, value interface{}) { + object, ok := value.(map[string]interface{}) + if !ok { + return + } + copyIDValue(dst, dstKey, object["id"]) +} + +func copyIDValue(dst map[string]interface{}, dstKey string, value interface{}) { + switch v := value.(type) { + case int: + dst[dstKey] = v + case int64: + dst[dstKey] = v + case float64: + dst[dstKey] = int(v) + case string: + if strings.TrimSpace(v) != "" { + dst[dstKey] = v + } + } +} + +func issueTagIDs(value interface{}) []interface{} { + tags, ok := value.([]interface{}) if !ok { return nil } - return item["id"] -} - -func issueObjectIDs(data map[string]interface{}, keys ...string) []interface{} { - for _, key := range keys { - items, ok := interfaceSlice(data[key]) + ids := make([]interface{}, 0, len(tags)) + for _, tag := range tags { + tagData, ok := tag.(map[string]interface{}) if !ok { continue } - ids := make([]interface{}, 0, len(items)) - for _, item := range items { - switch value := item.(type) { - case float64, int, int64, string: - ids = append(ids, value) - continue - } - obj, ok := item.(map[string]interface{}) - if !ok { - continue - } - if id, ok := obj["id"]; ok { - ids = append(ids, id) - } - } - if len(ids) > 0 { - return ids + if id, ok := normalizedIDValue(tagData["id"]); ok { + ids = append(ids, id) } } - return nil + return ids } -func issueObjectNames(data map[string]interface{}, key string) []string { - items, ok := interfaceSlice(data[key]) - if !ok { - return nil - } - names := make([]string, 0, len(items)) - for _, item := range items { - obj, ok := item.(map[string]interface{}) - if !ok { - continue +func normalizedIDValue(value interface{}) (interface{}, bool) { + switch v := value.(type) { + case int: + return v, true + case int64: + return v, true + case float64: + return int(v), true + case string: + if strings.TrimSpace(v) != "" { + return v, true } - if name, ok := obj["name"].(string); ok && name != "" { - names = append(names, name) - } - } - if len(names) == 0 { - return nil - } - return names -} - -func stringField(data map[string]interface{}, key string) string { - value, _ := data[key].(string) - return value -} - -func mapField(data map[string]interface{}, key string) map[string]interface{} { - item, _ := data[key].(map[string]interface{}) - return item -} - -func interfaceSlice(value interface{}) ([]interface{}, bool) { - items, ok := value.([]interface{}) - if ok { - return items, true - } - switch typed := value.(type) { - case []map[string]interface{}: - items = make([]interface{}, 0, len(typed)) - for _, item := range typed { - items = append(items, item) - } - return items, true } return nil, false } -func issueValueIDs(data map[string]interface{}, keys ...string) []interface{} { - return issueObjectIDs(data, keys...) -} - -func firstNonNil(values ...interface{}) interface{} { - for _, value := range values { - if !isNilValue(value) { - return value - } - } - return nil -} - -func firstNonEmptyString(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} - -func firstNonEmptyIDs(values ...[]interface{}) []interface{} { - for _, ids := range values { - if len(ids) > 0 { - return ids - } - } - return nil -} - -func isNilValue(value interface{}) bool { - if value == nil { - return true - } - switch typed := value.(type) { - case map[string]interface{}: - return len(typed) == 0 - } - return false -} - -func fetchLegacyIssueDetail(ctx *common.RuntimeContext, number string) (map[string]interface{}, error) { - return fetchIssueMap(ctx, legacyIssuePath(ctx, number), "failed to parse legacy issue detail") -} - -func fetchLegacyIssueEdit(ctx *common.RuntimeContext, number string) (map[string]interface{}, error) { - return fetchIssueMap(ctx, legacyIssueEditPath(ctx, number), "failed to parse legacy issue edit data") -} - -func fetchIssueMap(ctx *common.RuntimeContext, path, parseErr string) (map[string]interface{}, error) { - env, err := ctx.CallAPI("GET", path, nil) - if err != nil { - return nil, err - } - data, ok := env.Data.(map[string]interface{}) - if !ok { - return nil, errors.New(parseErr) - } - return data, nil -} - -func enrichIssueView(ctx *common.RuntimeContext, number string, env *output.Envelope) { - if env == nil { - return - } - issueData, ok := env.Data.(map[string]interface{}) - if !ok { - return - } - legacyDetail, _ := fetchLegacyIssueDetail(ctx, number) - legacyEdit, _ := fetchLegacyIssueEdit(ctx, number) - env.Data = mergeIssueViewData(issueData, legacyDetail, legacyEdit) -} - -func mergeIssueViewData(v1Data, legacyDetail, legacyEdit map[string]interface{}) map[string]interface{} { - issue := cloneIssueMap(v1Data) - if issue == nil { - return v1Data - } - - if number := firstNonNil(issue["number"], issue["project_issues_index"], legacyDetail["project_issues_index"]); number != nil { - issue["number"] = number - } - if databaseID := firstNonNil(issue["id"], legacyDetail["id"]); databaseID != nil { - issue["database_id"] = databaseID - delete(issue, "id") - } - - status := firstNonNil(mapField(issue, "status"), mapField(legacyDetail, "issue_status")) - if status != nil { - issue["status"] = status - if statusMap, ok := status.(map[string]interface{}); ok { - if name := stringField(statusMap, "name"); name != "" { - issue["status_name"] = name - } - } - } - if priority := firstNonNil(mapField(issue, "priority"), mapField(legacyDetail, "priority")); priority != nil { - issue["priority"] = priority - if priorityMap, ok := priority.(map[string]interface{}); ok { - if name := stringField(priorityMap, "name"); name != "" { - issue["priority_name"] = name - } - } - } - if tracker := firstNonNil(mapField(issue, "tracker"), mapField(legacyDetail, "tracker")); tracker != nil { - issue["tracker"] = tracker - } - if trackerID := firstNonNil(issue["tracker_id"], nestedIssueID(issue, "tracker"), nestedIssueID(legacyDetail, "tracker"), legacyEdit["tracker_id"]); trackerID != nil { - issue["tracker_id"] = trackerID - } - if issueType := firstNonNil(issue["issue_type"], legacyDetail["issue_type"], legacyEdit["issue_type"]); issueType != nil { - issue["issue_type"] = issueType - } - if assignedToID := firstNonNil(issue["assigned_to_id"], legacyDetail["assigned_to_id"], legacyEdit["assigned_to_id"]); assignedToID != nil { - issue["assigned_to_id"] = assignedToID - } - if fixedVersionID := firstNonNil(issue["fixed_version_id"], legacyDetail["fixed_version_id"], legacyDetail["version_id"], legacyEdit["fixed_version_id"]); fixedVersionID != nil { - issue["fixed_version_id"] = fixedVersionID - } - if versionID := firstNonNil(issue["version_id"], legacyDetail["version_id"], legacyEdit["fixed_version_id"]); versionID != nil { - issue["version_id"] = versionID - } - - if tagIDs := firstNonEmptyIDs(issueObjectIDs(issue, "tags", "issue_tags"), issueObjectIDs(legacyDetail, "issue_tags"), issueValueIDs(legacyEdit, "issue_tags")); len(tagIDs) > 0 { - issue["issue_tag_ids"] = tagIDs - } - if tagNames := issueObjectNames(legacyDetail, "issue_tags"); len(tagNames) > 0 { - issue["issue_tag_names"] = tagNames - } - - return issue -} - -func cloneIssueMap(data map[string]interface{}) map[string]interface{} { - if data == nil { - return nil - } - cloned := make(map[string]interface{}, len(data)) - for key, value := range data { - cloned[key] = value - } - return cloned -} - func normalizeIssueStatus(state string) (interface{}, error) { switch strings.ToLower(strings.TrimSpace(state)) { case "open": @@ -1135,81 +415,3 @@ func normalizeIssueStatus(state string) (interface{}, error) { return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state) } } - -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) != "" { - return true - } - } - return false -} - -func applyIssueMetadataArgs(ctx *common.RuntimeContext, body map[string]interface{}) error { - if priority := ctx.Arg("priority-id"); priority != "" { - priorityID, err := parseIssueID(priority, "priority-id") - if err != nil { - return err - } - body["priority_id"] = priorityID - } - tagIDs := ctx.Arg("tag-ids") - if label := ctx.Arg("label"); label != "" { - if tagIDs != "" { - return fmt.Errorf("--label cannot be used with --tag-ids") - } - tagIDs = label - } - if tagIDs != "" { - ids, err := parseIssueIDList(tagIDs, "tag-ids") - if err != nil { - return err - } - body["issue_tag_ids"] = ids - } - if assignerIDs := ctx.Arg("assigner-ids"); assignerIDs != "" { - ids, err := parseIssueIDList(assignerIDs, "assigner-ids") - if err != nil { - return err - } - body["assigner_ids"] = ids - if len(ids) == 1 { - body["assigned_to_id"] = ids[0] - } - } - if branch := ctx.Arg("branch"); branch != "" { - body["branch_name"] = branch - } - if startDate := ctx.Arg("start-date"); startDate != "" { - body["start_date"] = startDate - } - if dueDate := ctx.Arg("due-date"); dueDate != "" { - body["due_date"] = dueDate - } - return nil -} - -func parseIssueIDList(value, flagName string) ([]int, error) { - parts := strings.Split(value, ",") - ids := make([]int, 0, len(parts)) - for _, part := range parts { - id, err := parseIssueID(part, flagName) - if err != nil { - return nil, err - } - ids = append(ids, id) - } - return ids, nil -} - -func parseIssueID(value, flagName string) (int, error) { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return 0, fmt.Errorf("--%s contains an empty ID", flagName) - } - id, err := strconv.Atoi(trimmed) - if err != nil || id <= 0 { - return 0, fmt.Errorf("--%s must contain positive numeric IDs", flagName) - } - return id, nil -} diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 70168ea..e234600 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -4,582 +4,13 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "os" - "path/filepath" - "strings" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -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) - } - } -} - -func assertStringSliceEqual(t *testing.T, got, want []string) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("got %v, want %v", got, want) - } - for i := range got { - if got[i] != want[i] { - t.Fatalf("got %v, want %v", got, want) - } - } -} - -func assertNumberSliceEqual(t *testing.T, got, want []int) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("got %v, want %v", got, want) - } - for i := range got { - if got[i] != want[i] { - t.Fatalf("got %v, want %v", got, want) - } - } -} - -func interfaceSliceToStrings(value interface{}) []string { - items, ok := value.([]interface{}) - if !ok { - return nil - } - out := make([]string, 0, len(items)) - for _, item := range items { - if s, ok := item.(string); ok { - out = append(out, s) - } - } - return out -} - -func interfaceSliceToInts(value interface{}) []int { - items, ok := value.([]interface{}) - if !ok { - return nil - } - out := make([]int, 0, len(items)) - for _, item := range items { - if n, ok := item.(float64); ok { - out = append(out, int(n)) - } - } - return out -} - -// --- list --- - -func TestIssueList(t *testing.T) { - 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) - } -} - -// --- export --- - -func TestIssueExportCSVWithFiltersAndPagination(t *testing.T) { - var pages []string - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Fatalf("expected GET, got %s", r.Method) - } - if r.URL.Path != "/v1/owner/repo/issues.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - query := r.URL.Query() - assertEqual(t, query.Get("category"), "opened") - assertEqual(t, query.Get("keyword"), "release") - assertEqual(t, query.Get("participant_category"), "assignedme") - assertEqual(t, query.Get("author_id"), "10") - assertEqual(t, query.Get("assigner_id"), "11") - assertEqual(t, query.Get("milestone_id"), "12") - assertEqual(t, query.Get("status_id"), "1") - assertEqual(t, query.Get("issue_tag_ids"), "2,3") - assertEqual(t, query.Get("sort_by"), "issues.updated_on") - assertEqual(t, query.Get("sort_direction"), "desc") - assertEqual(t, query.Get("limit"), "2") - pages = append(pages, query.Get("page")) - - switch query.Get("page") { - case "1": - writeJSON(t, w, map[string]interface{}{ - "total_count": 3, - "issues": []interface{}{ - map[string]interface{}{ - "id": float64(101), - "project_issues_index": float64(1), - "subject": "release blocker", - "status": map[string]interface{}{"id": float64(1), "name": "New"}, - "priority": map[string]interface{}{"id": float64(2), "name": "Normal"}, - "author": map[string]interface{}{"login": "alice"}, - "assigners": []interface{}{ - map[string]interface{}{"login": "bob"}, - }, - "tags": []interface{}{ - map[string]interface{}{"name": "bug"}, - }, - "updated_on": "2026-06-01", - }, - map[string]interface{}{ - "id": float64(102), - "project_issues_index": float64(2), - "subject": "release notes", - "status": map[string]interface{}{"id": float64(1), "name": "New"}, - "priority": map[string]interface{}{"id": float64(3), "name": "High"}, - "author": map[string]interface{}{"login": "carol"}, - "updated_on": "2026-06-02", - }, - }, - }) - case "2": - writeJSON(t, w, map[string]interface{}{ - "total_count": 3, - "issues": []interface{}{ - map[string]interface{}{ - "id": float64(103), - "project_issues_index": float64(3), - "subject": "release checklist", - "status": map[string]interface{}{"id": float64(5), "name": "Closed"}, - "priority": map[string]interface{}{"id": float64(2), "name": "Normal"}, - "author": map[string]interface{}{"login": "dave"}, - "updated_on": "2026-06-03", - }, - }, - }) - default: - t.Fatalf("unexpected page %s", query.Get("page")) - } - }) - defer server.Close() - - outputPath := filepath.Join(t.TempDir(), "issues.csv") - err := runShortcut(t, server, "export", map[string]string{ - "state": "open", - "keyword": "release", - "participant": "assignedme", - "author-id": "10", - "assignee-id": "11", - "milestone-id": "12", - "status-id": "1", - "tag-ids": "2,3", - "sort-by": "issues.updated_on", - "sort-direction": "desc", - "limit": "2", - "fields": "number,title,status,priority,author,assignees,tags,updated_at,url", - "export-format": "csv", - "output": outputPath, - }) - if err != nil { - t.Fatalf("export failed: %v", err) - } - assertEqual(t, strings.Join(pages, ","), "1,2") - content, err := os.ReadFile(outputPath) - if err != nil { - t.Fatalf("read export file: %v", err) - } - got := string(content) - if !strings.Contains(got, "number,title,status,priority,author,assignees,tags,updated_at,url") { - t.Fatalf("missing csv header: %s", got) - } - if !strings.Contains(got, "1,release blocker,New,Normal,alice,bob,bug,2026-06-01,https://www.gitlink.org.cn/owner/repo/issues/1") { - t.Fatalf("missing first issue row: %s", got) - } - if !strings.Contains(got, "3,release checklist,Closed,Normal,dave,,,2026-06-03,https://www.gitlink.org.cn/owner/repo/issues/3") { - t.Fatalf("missing second page issue row: %s", got) - } -} - -func TestIssueExportMaxStopsWithinPage(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("page") != "1" { - t.Fatalf("unexpected page: %s", r.URL.Query().Get("page")) - } - writeJSON(t, w, map[string]interface{}{ - "total_count": 3, - "issues": []interface{}{ - map[string]interface{}{"project_issues_index": float64(1), "subject": "one"}, - map[string]interface{}{"project_issues_index": float64(2), "subject": "two"}, - }, - }) - }) - defer server.Close() - - outputPath := filepath.Join(t.TempDir(), "issues.json") - err := runShortcut(t, server, "export", map[string]string{ - "limit": "2", - "max": "1", - "fields": "number,title", - "export-format": "json", - "output": outputPath, - }) - if err != nil { - t.Fatalf("export failed: %v", err) - } - content, err := os.ReadFile(outputPath) - if err != nil { - t.Fatalf("read export file: %v", err) - } - if strings.Contains(string(content), `"title": "two"`) { - t.Fatalf("expected max=1 to omit second issue: %s", content) - } - if !strings.Contains(string(content), `"title": "one"`) { - t.Fatalf("expected first issue in json export: %s", content) - } -} - -func TestIssueExportMarkdownEscapesCells(t *testing.T) { - content := renderIssueExportMarkdown([]issueExportRecord{ - {"number": "1", "title": "pipe | newline\ntext"}, - }, []string{"number", "title"}) - got := string(content) - if !strings.Contains(got, "pipe \\| newline text") { - t.Fatalf("markdown cell not escaped: %s", got) - } -} - -func TestIssueExportRejectsInvalidOptions(t *testing.T) { - if _, err := parseIssueExportFields("number,unknown"); err == nil { - t.Fatal("expected invalid field error") - } - if _, err := normalizeIssueExportFormat("xml"); err == nil { - t.Fatal("expected invalid format error") - } - if _, err := boundedPositiveInt("0", 50, 100, "limit"); err == nil { - t.Fatal("expected invalid limit error") - } - if _, err := nonNegativeInt("-1", "max"); err == nil { - t.Fatal("expected invalid max error") - } -} - -// --- create --- - -func TestIssueCreate(t *testing.T) { - 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) { +func TestIssueClosePreservesCurrentDescription(t *testing.T) { var updatePayload map[string]interface{} server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { @@ -597,141 +28,17 @@ func TestIssueCloseAcceptsIDAlias(t *testing.T) { }) defer server.Close() - err := runShortcut(t, server, "close", map[string]string{"id": "42"}) + err := runIssueShortcut(t, server, "close", map[string]string{"number": "42"}) if err != nil { t.Fatalf("close shortcut failed: %v", err) } - assertEqual(t, updatePayload["status_id"], float64(5)) -} -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", - "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": - 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, "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["description"], "Existing description") 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) { +func TestIssueUpdatePreservesCurrentDescriptionWhenChangingTitleAndState(t *testing.T) { var updatePayload map[string]interface{} server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { @@ -749,15 +56,48 @@ func TestIssueUpdateAcceptsIDAlias(t *testing.T) { }) defer server.Close() - err := runShortcut(t, server, "update", map[string]string{ - "id": "42", - "title": "New title", + 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) { @@ -766,20 +106,16 @@ func TestIssueUpdatePreservesCurrentMetadata(t *testing.T) { 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}, + "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"}, }, - "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) @@ -790,366 +126,29 @@ func TestIssueUpdatePreservesCurrentMetadata(t *testing.T) { }) defer server.Close() - err := runShortcut(t, server, "update", map[string]string{ + err := runIssueShortcut(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)) - 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") + 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)) } -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 TestIssueCommentSupportsThreadingAttachmentsAndReceivers(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{ - "number": "42", - "body": "Reply with context", - "parent-id": "10", - "reply-id": "11", - "attachment-ids": "5, 6", - "receivers": "alice, bob, alice", - }) - if err != nil { - t.Fatalf("comment shortcut failed: %v", err) - } - assertEqual(t, commentPayload["notes"], "Reply with context") - assertEqual(t, commentPayload["parent_id"], float64(10)) - assertEqual(t, commentPayload["reply_id"], float64(11)) - assertStringSliceEqual(t, interfaceSliceToStrings(commentPayload["receivers_login"]), []string{"alice", "bob"}) - assertNumberSliceEqual(t, interfaceSliceToInts(commentPayload["attachment_ids"]), []int{5, 6}) -} - -func TestIssueCommentsListSendsFilters(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) - } - q := r.URL.Query() - assertEqual(t, q.Get("category"), "all") - assertEqual(t, q.Get("keyword"), "panic") - assertEqual(t, q.Get("sort_by"), "updated_on") - assertEqual(t, q.Get("sort_direction"), "desc") - assertEqual(t, q.Get("page"), "2") - assertEqual(t, q.Get("limit"), "50") - writeJSON(t, w, map[string]interface{}{ - "total_count": float64(1), - "journals": []interface{}{ - map[string]interface{}{"id": float64(7), "notes": "panic fixed"}, - }, - }) - }) - defer server.Close() - - err := runShortcut(t, server, "comments", map[string]string{ - "number": "42", - "category": "all", - "keyword": "panic", - "sort-by": "updated_on", - "sort-direction": "desc", - "page": "2", - "limit": "50", - }) - if err != nil { - t.Fatalf("comments shortcut failed: %v", err) - } -} - -func TestIssueCommentUpdate(t *testing.T) { - var commentPayload map[string]interface{} - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/42/journals/9.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - commentPayload = decodeJSON(t, r) - writeJSON(t, w, commentPayload) - }) - defer server.Close() - - err := runShortcut(t, server, "comment-update", map[string]string{ - "number": "42", - "comment-id": "9", - "body": "Updated", - "attachment-ids": "8", - "receivers": "alice", - }) - if err != nil { - t.Fatalf("comment-update failed: %v", err) - } - assertEqual(t, commentPayload["notes"], "Updated") - assertNumberSliceEqual(t, interfaceSliceToInts(commentPayload["attachment_ids"]), []int{8}) - assertStringSliceEqual(t, interfaceSliceToStrings(commentPayload["receivers_login"]), []string{"alice"}) -} - -func TestIssueCommentDelete(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/issues/42/journals/9.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "success"}) - }) - defer server.Close() - - err := runShortcut(t, server, "comment-delete", map[string]string{"number": "42", "comment-id": "9"}) - if err != nil { - t.Fatalf("comment-delete failed: %v", err) - } -} - -func TestIssueCommentReplies(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals/9/children_journals.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - q := r.URL.Query() - assertEqual(t, q.Get("keyword"), "thanks") - assertEqual(t, q.Get("page"), "3") - assertEqual(t, q.Get("limit"), "10") - writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "journals": []interface{}{}}) - }) - defer server.Close() - - err := runShortcut(t, server, "comment-replies", map[string]string{ - "number": "42", - "comment-id": "9", - "keyword": "thanks", - "page": "3", - "limit": "10", - }) - if err != nil { - t.Fatalf("comment-replies failed: %v", err) - } -} - -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") - } -} - -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: "comments", args: map[string]string{}}, - {name: "comment-update", args: map[string]string{"comment-id": "9", "body": "Updated"}}, - {name: "comment-delete", args: map[string]string{"comment-id": "9"}}, - {name: "comment-replies", args: map[string]string{"comment-id": "9"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - 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) - } - }) - } -} - -func TestIssueCommentRejectsInvalidIDs(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("invalid IDs should not call API, got %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - cases := []struct { - name string - cmd string - args map[string]string - }{ - {name: "bad parent", cmd: "comment", args: map[string]string{"number": "42", "body": "x", "parent-id": "abc"}}, - {name: "bad attachment", cmd: "comment", args: map[string]string{"number": "42", "body": "x", "attachment-ids": "1,,"}}, - {name: "bad update comment", cmd: "comment-update", args: map[string]string{"number": "42", "comment-id": "0", "body": "x"}}, - {name: "bad delete comment", cmd: "comment-delete", args: map[string]string{"number": "42", "comment-id": "-1"}}, - {name: "bad replies comment", cmd: "comment-replies", args: map[string]string{"number": "42", "comment-id": "abc"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if err := runShortcut(t, server, tc.cmd, tc.args); err == nil { - t.Fatal("expected validation error") - } - }) - } -} - -// --- batch-close --- - func TestBatchClosePreservesCurrentDescription(t *testing.T) { var updatePayload map[string]interface{} server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { @@ -1168,82 +167,19 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) { }) defer server.Close() - err := runShortcut(t, server, "batch-close", map[string]string{ + err := runIssueShortcut(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" { @@ -1259,7 +195,9 @@ func TestIssueAssignersShortcutWithKeyword(t *testing.T) { }) defer server.Close() - err := runShortcut(t, server, "assigners", map[string]string{"keyword": "alice"}) + err := runIssueShortcut(t, server, "assigners", map[string]string{ + "keyword": "alice", + }) if err != nil { t.Fatalf("assigners shortcut failed: %v", err) } @@ -1280,241 +218,66 @@ func TestIssueAuthorsShortcutWithKeyword(t *testing.T) { }) defer server.Close() - err := runShortcut(t, server, "authors", map[string]string{"keyword": "bob"}) + err := runIssueShortcut(t, server, "authors", map[string]string{ + "keyword": "bob", + }) if err != nil { t.Fatalf("authors shortcut failed: %v", err) } } -// --- metadata lookup shortcuts --- - -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) - } - assertEqual(t, r.URL.Query().Get("keyword"), "normal") - writeJSON(t, w, []map[string]interface{}{ - {"id": 2, "name": "Normal"}, - }) - }) - defer server.Close() - - err := runShortcut(t, server, "priorities", map[string]string{"keyword": "normal"}) - if err != nil { - t.Fatalf("priorities shortcut failed: %v", err) - } -} - -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) - } -} - -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() - - 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{}{ - "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, "update", map[string]string{"number": "42", "title": "new"}) - if err == nil { - t.Fatal("expected error for PATCH HTTP 500") - } -} - -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/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, "close", map[string]string{"number": "42"}) - if err == nil { - t.Fatal("expected error for PATCH HTTP 500") - } -} - -func TestFetchExistingIssueBadData(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, "not a map") - }) - defer server.Close() - +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}, + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, Owner: "owner", Repo: "repo", + Format: "json", + Args: args, } - _, err := fetchExistingIssue(ctx, "1") - if err == nil { - t.Fatal("expected error for non-map response") - } + return shortcut.Run(ctx) } -func TestFetchExistingIssueNoSubject(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, map[string]interface{}{"id": float64(1)}) - }) - defer server.Close() - - 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 for missing subject") - } -} - -// --- normalizeIssueStatus --- - -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) - } +func findIssueShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut } } + 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 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) + } +} + +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) + } }