From e1f8f0651c87245bdca312893dbf4620aa9a3775 Mon Sep 17 00:00:00 2001 From: piekstra Date: Tue, 13 Oct 2026 10:48:43 -0400 Subject: [PATCH 1/3] feat(jtk): add --custom-fields to create, update, assign and transitions do The post-write issue view of these commands can now append the same Custom Fields section as issues get --custom-fields, so a custom field set with --field can be confirmed without a second command. The section builder moves to internal/present so the issues and transitions packages share it. --- tools/jtk/README.md | 11 +++ tools/jtk/internal/cmd/issues/assign.go | 14 ++- tools/jtk/internal/cmd/issues/assign_test.go | 10 +- tools/jtk/internal/cmd/issues/create.go | 14 ++- tools/jtk/internal/cmd/issues/create_test.go | 26 ++--- .../cmd/issues/custom_fields_mutation_test.go | 97 +++++++++++++++++++ tools/jtk/internal/cmd/issues/get.go | 16 +-- tools/jtk/internal/cmd/issues/update.go | 14 ++- tools/jtk/internal/cmd/issues/update_test.go | 62 ++++++------ .../cmd/transitions/custom_fields_test.go | 73 ++++++++++++++ .../internal/cmd/transitions/transitions.go | 14 ++- .../cmd/transitions/transitions_test.go | 6 +- tools/jtk/internal/present/custom_fields.go | 23 +++++ 13 files changed, 298 insertions(+), 82 deletions(-) create mode 100644 tools/jtk/internal/cmd/issues/custom_fields_mutation_test.go create mode 100644 tools/jtk/internal/cmd/transitions/custom_fields_test.go create mode 100644 tools/jtk/internal/present/custom_fields.go diff --git a/tools/jtk/README.md b/tools/jtk/README.md index 4c55bc5c..e2d1f0bd 100644 --- a/tools/jtk/README.md +++ b/tools/jtk/README.md @@ -361,6 +361,9 @@ jtk issues create -p MYPROJECT -s "Custom field issue" --field priority=High --f jtk issues create -p MYPROJECT -t Task -s "My task" --assignee me jtk issues create -p MYPROJECT -t Task -s "Their task" --assignee user@example.com jtk issues create -p MYPROJECT -t Task -s "Their task" --assignee "Aaron Wong" + +# Show custom fields in the created-issue view +jtk issues create -p MYPROJECT -s "With custom fields" --field customfield_10005=Feature --custom-fields ``` | Flag | Short | Default | Description | @@ -372,6 +375,7 @@ jtk issues create -p MYPROJECT -t Task -s "Their task" --assignee "Aaron Wong" | `--parent` | | | Parent issue key (epic or parent issue) | | `--assignee` | `-a` | | Assignee (account ID, email, display name, or `"me"`) | | `--field` | `-f` | | Additional field in `key=value` format (can be repeated) | +| `--custom-fields` | | `false` | Append the issue's custom fields (non-empty only) to the created-issue view | --- @@ -393,6 +397,9 @@ jtk issues update PROJ-123 --status "Done" # Multi-value fields: repeat --field with the same key to accumulate values jtk issues update PROJ-123 --field customfield_10050=Option1 --field customfield_10050=Option2 + +# Confirm a custom field change in the post-update view +jtk issues update PROJ-123 --field customfield_10005=Feature --custom-fields ``` | Flag | Short | Default | Description | @@ -404,6 +411,7 @@ jtk issues update PROJ-123 --field customfield_10050=Option1 --field customfield | `--type` | `-t` | | New issue type (uses Jira Cloud bulk move API) | | `--status` | | | New workflow status (uses Jira transitions API; resolved before any writes) | | `--field` | `-f` | | Field to update in `key=value` format (can be repeated; repeating the same key accumulates values for multi-select fields) | +| `--custom-fields` | | `false` | Append the issue's custom fields (non-empty only) to the post-update view | **Arguments:** - `` - The issue key (**required**) @@ -449,6 +457,7 @@ jtk issues assign PROJ-123 --unassign | Flag | Default | Description | |------|---------|-------------| | `--unassign` | `false` | Remove current assignee | +| `--custom-fields` | `false` | Append the issue's custom fields (non-empty only) to the post-assign view | **Arguments:** - `` - The issue key (**required**) @@ -747,11 +756,13 @@ a transition by ID. jtk transitions do PROJ-123 "In Progress" jtk transitions do PROJ-123 "Done" jtk transitions do PROJ-123 "Done" --field resolution=Fixed +jtk transitions do PROJ-123 "Done" --custom-fields ``` | Flag | Short | Default | Description | |------|-------|---------|-------------| | `--field` | `-f` | | Field to set during transition in `key=value` format (can be repeated) | +| `--custom-fields` | | `false` | Append the issue's custom fields (non-empty only) to the post-transition view | **Arguments:** - `` - The issue key (**required**) diff --git a/tools/jtk/internal/cmd/issues/assign.go b/tools/jtk/internal/cmd/issues/assign.go index 7277ad9d..32c2cd31 100644 --- a/tools/jtk/internal/cmd/issues/assign.go +++ b/tools/jtk/internal/cmd/issues/assign.go @@ -16,6 +16,7 @@ import ( func newAssignCmd(opts *root.Options) *cobra.Command { var unassign bool + var customFields bool cmd := &cobra.Command{ Use: "assign [user]", @@ -35,16 +36,17 @@ func newAssignCmd(opts *root.Options) *cobra.Command { if len(args) > 1 { accountID = args[1] } - return runAssign(cmd.Context(), opts, args[0], accountID, unassign) + return runAssign(cmd.Context(), opts, args[0], accountID, unassign, customFields) }, } cmd.Flags().BoolVar(&unassign, "unassign", false, "Remove current assignee") + cmd.Flags().BoolVar(&customFields, "custom-fields", false, "Append the issue's custom fields to the post-assign view") return cmd } -func runAssign(ctx context.Context, opts *root.Options, issueKey, userInput string, unassign bool) error { +func runAssign(ctx context.Context, opts *root.Options, issueKey, userInput string, unassign, customFields bool) error { client, err := opts.APIClient() if err != nil { return err @@ -94,9 +96,13 @@ func runAssign(ctx context.Context, opts *root.Options, issueKey, userInput stri if err != nil { return nil, err } - return jtkpresent.IssuePresenter{}.PresentDetail( + model := jtkpresent.IssuePresenter{}.PresentDetail( issue, client.IssueURL(id), false, opts.IsFullText(), - ), nil + ) + if customFields { + jtkpresent.AppendIssueCustomFields(ctx, client, issue, model) + } + return model, nil }, IsFresh: isFresh, Fallback: func(id string) *present.OutputModel { diff --git a/tools/jtk/internal/cmd/issues/assign_test.go b/tools/jtk/internal/cmd/issues/assign_test.go index 16b847e2..44bd85ae 100644 --- a/tools/jtk/internal/cmd/issues/assign_test.go +++ b/tools/jtk/internal/cmd/issues/assign_test.go @@ -90,7 +90,7 @@ func TestRunAssign_ResolvesDisplayName(t *testing.T) { opts.SetAPIClient(client) // "User One" is seeded with AccountID=abc123 in seedCacheForIssues. - err = runAssign(context.Background(), opts, "PROJ-123", "User One", false) + err = runAssign(context.Background(), opts, "PROJ-123", "User One", false, false) testutil.RequireNoError(t, err) testutil.Equal(t, captured, "abc123") testutil.Contains(t, stdout.String(), "PROJ-123") @@ -113,7 +113,7 @@ func TestRunAssign_ResolvesByAccountID(t *testing.T) { opts.SetAPIClient(client) // Raw accountId passes cache lookup directly. - err = runAssign(context.Background(), opts, "PROJ-123", "61292e4c4f29230069621c5f", false) + err = runAssign(context.Background(), opts, "PROJ-123", "61292e4c4f29230069621c5f", false, false) testutil.RequireNoError(t, err) testutil.Equal(t, captured, "61292e4c4f29230069621c5f") testutil.Contains(t, stdout.String(), "PROJ-123") @@ -138,7 +138,7 @@ func TestRunAssign_SyntheticUserFallsBackToAccountID(t *testing.T) { opts.SetAPIClient(client) rawID := "557058:295fe89c-10c2-4b0c-ba84-a4dd14ea7729" - err = runAssign(context.Background(), opts, "PROJ-123", rawID, false) + err = runAssign(context.Background(), opts, "PROJ-123", rawID, false, false) testutil.RequireNoError(t, err) testutil.Equal(t, captured, rawID) testutil.Contains(t, stdout.String(), "PROJ-123") @@ -165,7 +165,7 @@ func TestRunAssign_Unassign(t *testing.T) { // --unassign with an accountId arg: the flag wins and the resolver is // bypassed (see runAssign's guard). Confirms we don't accidentally resolve // the ignored positional. - err = runAssign(context.Background(), opts, "PROJ-123", "some-ignored-id", true) + err = runAssign(context.Background(), opts, "PROJ-123", "some-ignored-id", true, false) testutil.RequireNoError(t, err) testutil.Equal(t, captured, "") testutil.Contains(t, stdout.String(), "PROJ-123") @@ -201,7 +201,7 @@ func TestRunAssign_ResolverNotFoundPropagates(t *testing.T) { opts := &root.Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} opts.SetAPIClient(client) - err = runAssign(context.Background(), opts, "PROJ-123", "Zzznonexistent", false) + err = runAssign(context.Background(), opts, "PROJ-123", "Zzznonexistent", false, false) var nf *resolve.NotFoundError if !errors.As(err, &nf) { t.Fatalf("expected NotFoundError, got %T: %v", err, err) diff --git a/tools/jtk/internal/cmd/issues/create.go b/tools/jtk/internal/cmd/issues/create.go index abbb1cb4..7eaa3644 100644 --- a/tools/jtk/internal/cmd/issues/create.go +++ b/tools/jtk/internal/cmd/issues/create.go @@ -24,6 +24,7 @@ func newCreateCmd(opts *root.Options) *cobra.Command { var parent string var assignee string var fields []string + var customFields bool cmd := &cobra.Command{ Use: "create", @@ -47,7 +48,7 @@ func newCreateCmd(opts *root.Options) *cobra.Command { # Create with custom fields jtk issues create --project MYPROJECT --type Story --summary "New feature" --field priority=High`, RunE: func(cmd *cobra.Command, _ []string) error { - return runCreate(cmd.Context(), opts, project, issueType, summary, description, parent, assignee, fields) + return runCreate(cmd.Context(), opts, project, issueType, summary, description, parent, assignee, fields, customFields) }, } @@ -58,6 +59,7 @@ func newCreateCmd(opts *root.Options) *cobra.Command { cmd.Flags().StringVar(&parent, "parent", "", "Parent issue key (epic or parent issue)") cmd.Flags().StringVarP(&assignee, "assignee", "a", "", "Assignee: accountId, email, display name, or \"me\"") cmd.Flags().StringArrayVarP(&fields, "field", "f", nil, "Additional fields (key=value)") + cmd.Flags().BoolVar(&customFields, "custom-fields", false, "Append the issue's custom fields to the created-issue view") _ = cmd.MarkFlagRequired("project") _ = cmd.MarkFlagRequired("summary") @@ -65,7 +67,7 @@ func newCreateCmd(opts *root.Options) *cobra.Command { return cmd } -func runCreate(ctx context.Context, opts *root.Options, project, issueType, summary, description, parent, assignee string, fieldArgs []string) error { +func runCreate(ctx context.Context, opts *root.Options, project, issueType, summary, description, parent, assignee string, fieldArgs []string, customFields bool) error { client, err := opts.APIClient() if err != nil { return err @@ -147,9 +149,13 @@ func runCreate(ctx context.Context, opts *root.Options, project, issueType, summ if err != nil { return nil, err } - return jtkpresent.IssuePresenter{}.PresentDetail( + model := jtkpresent.IssuePresenter{}.PresentDetail( fetched, client.IssueURL(id), false, opts.IsFullText(), - ), nil + ) + if customFields { + jtkpresent.AppendIssueCustomFields(ctx, client, fetched, model) + } + return model, nil }, Fallback: func(id string) *present.OutputModel { return jtkpresent.IssuePresenter{}.PresentCreated(id, client.IssueURL(id)) diff --git a/tools/jtk/internal/cmd/issues/create_test.go b/tools/jtk/internal/cmd/issues/create_test.go index f2874744..3041d39c 100644 --- a/tools/jtk/internal/cmd/issues/create_test.go +++ b/tools/jtk/internal/cmd/issues/create_test.go @@ -48,7 +48,7 @@ func TestRunCreate_RequestBodyNoDoubleQuoting(t *testing.T) { } opts.SetAPIClient(client) - err = runCreate(context.Background(), opts, "MYPROJECT", "Task", "Fix login bug", "Users cannot log in with SSO credentials", "", "", nil) + err = runCreate(context.Background(), opts, "MYPROJECT", "Task", "Fix login bug", "Users cannot log in with SSO credentials", "", "", nil, false) testutil.RequireNoError(t, err) // Parse the captured request body @@ -133,7 +133,7 @@ func TestRunCreate_RawADFDescriptionPassthrough(t *testing.T) { } opts.SetAPIClient(client) - err = runCreate(context.Background(), opts, "MYPROJECT", "Task", "Fix login bug", rawADF, "", "", nil) + err = runCreate(context.Background(), opts, "MYPROJECT", "Task", "Fix login bug", rawADF, "", "", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -195,7 +195,7 @@ func TestRunCreate_SummaryWithSpecialCharacters(t *testing.T) { } opts.SetAPIClient(client) - err = runCreate(context.Background(), opts, "PROJ", "Bug", `Error: "unexpected token" in parser`, "", "", "", nil) + err = runCreate(context.Background(), opts, "PROJ", "Bug", `Error: "unexpected token" in parser`, "", "", "", nil, false) testutil.RequireNoError(t, err) var reqBody map[string]any @@ -318,7 +318,7 @@ func TestRunCreate_WithParent(t *testing.T) { } opts.SetAPIClient(client) - err = runCreate(context.Background(), opts, "PROJ", "Task", "Child task", "", "PROJ-100", "", nil) + err = runCreate(context.Background(), opts, "PROJ", "Task", "Child task", "", "PROJ-100", "", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -362,7 +362,7 @@ func TestRunCreate_WithoutParent(t *testing.T) { } opts.SetAPIClient(client) - err = runCreate(context.Background(), opts, "PROJ", "Task", "Standalone task", "", "", "", nil) + err = runCreate(context.Background(), opts, "PROJ", "Task", "Standalone task", "", "", "", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -453,7 +453,7 @@ func TestRunCreate_WithAssigneeAccountID(t *testing.T) { } opts.SetAPIClient(client) - err = runCreate(context.Background(), opts, "PROJ", "Task", "Assigned task", "", "", "61292e4c4f29230069621c5f", nil) + err = runCreate(context.Background(), opts, "PROJ", "Task", "Assigned task", "", "", "61292e4c4f29230069621c5f", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -502,7 +502,7 @@ func TestRunCreate_WithAssigneeMe(t *testing.T) { } opts.SetAPIClient(client) - err = runCreate(context.Background(), opts, "PROJ", "Task", "My task", "", "", "me", nil) + err = runCreate(context.Background(), opts, "PROJ", "Task", "My task", "", "", "me", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -551,7 +551,7 @@ func TestRunCreate_WithAssigneeEmail(t *testing.T) { } opts.SetAPIClient(client) - err = runCreate(context.Background(), opts, "PROJ", "Task", "Their task", "", "", "user@example.com", nil) + err = runCreate(context.Background(), opts, "PROJ", "Task", "Their task", "", "", "user@example.com", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -595,7 +595,7 @@ func TestRunCreate_DescriptionEscapeSequences(t *testing.T) { // Simulate what the shell passes when user types: --description "First paragraph.\n\nSecond paragraph." // The shell delivers literal backslash-n, not actual newlines. - err = runCreate(context.Background(), opts, "PROJ", "Task", "Test", `First paragraph.\n\nSecond paragraph.`, "", "", nil) + err = runCreate(context.Background(), opts, "PROJ", "Task", "Test", `First paragraph.\n\nSecond paragraph.`, "", "", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -642,7 +642,7 @@ func TestRunCreate_WithoutAssignee(t *testing.T) { } opts.SetAPIClient(client) - err = runCreate(context.Background(), opts, "PROJ", "Task", "Unassigned task", "", "", "", nil) + err = runCreate(context.Background(), opts, "PROJ", "Task", "Unassigned task", "", "", "", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -692,7 +692,7 @@ func TestRunCreate_ShowsPostState(t *testing.T) { opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}} opts.SetAPIClient(client) - err = runCreate(context.Background(), opts, "PROJ", "Task", "New issue", "", "", "", nil) + err = runCreate(context.Background(), opts, "PROJ", "Task", "New issue", "", "", "", nil, false) testutil.RequireNoError(t, err) out := stdout.String() @@ -713,7 +713,7 @@ func TestRunCreate_IDOnly(t *testing.T) { opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}, IDOnly: true} opts.SetAPIClient(client) - err = runCreate(context.Background(), opts, "PROJ", "Task", "New issue", "", "", "", nil) + err = runCreate(context.Background(), opts, "PROJ", "Task", "New issue", "", "", "", nil, false) testutil.RequireNoError(t, err) testutil.Equal(t, stdout.String(), "TEST-1\n") } @@ -760,7 +760,7 @@ func TestRunCreate_FieldComponentArrayAndOptionMerge(t *testing.T) { "components=Frontend", "customfield_10100=Opt1", "customfield_10100=Opt2", - }) + }, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) diff --git a/tools/jtk/internal/cmd/issues/custom_fields_mutation_test.go b/tools/jtk/internal/cmd/issues/custom_fields_mutation_test.go new file mode 100644 index 00000000..75e1f311 --- /dev/null +++ b/tools/jtk/internal/cmd/issues/custom_fields_mutation_test.go @@ -0,0 +1,97 @@ +package issues + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/open-cli-collective/atlassian-go/testutil" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" +) + +// customFieldsMutationServer serves create, update and assign writes for +// TEST-1, the post-write GET with one populated custom field, and /field so +// the custom field's name can be resolved. +func customFieldsMutationServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/rest/api/3/field" && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode([]api.Field{ + {ID: "customfield_10005", Name: "Change type", Custom: true, Schema: api.FieldSchema{Type: "option"}}, + }) + case r.URL.Path == "/rest/api/3/issue" && r.Method == http.MethodPost: + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(api.Issue{Key: "TEST-1", ID: "10001"}) + case r.URL.Path == "/rest/api/3/issue/TEST-1" && r.Method == http.MethodPut, + r.URL.Path == "/rest/api/3/issue/TEST-1/assignee" && r.Method == http.MethodPut: + w.WriteHeader(http.StatusNoContent) + case r.URL.Path == "/rest/api/3/issue/TEST-1" && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(map[string]any{ + "key": "TEST-1", + "fields": map[string]any{ + "summary": "New issue", + "status": map[string]any{"name": "Backlog"}, + "issuetype": map[string]any{"name": "Task"}, + "priority": map[string]any{"name": "Medium"}, + "updated": "2026-04-16T00:00:00.000+0000", + "customfield_10005": map[string]any{"value": "Feature"}, + }, + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func TestMutations_CustomFieldsFlag(t *testing.T) { + commands := []struct { + name string + run func(ctx context.Context, opts *root.Options, customFields bool) error + }{ + {"create", func(ctx context.Context, opts *root.Options, cf bool) error { + return runCreate(ctx, opts, "PROJ", "Task", "New issue", "", "", "", nil, cf) + }}, + {"update", func(ctx context.Context, opts *root.Options, cf bool) error { + return runUpdate(ctx, opts, "TEST-1", "New issue", "", "", "", "", "", nil, cf) + }}, + {"assign", func(ctx context.Context, opts *root.Options, cf bool) error { + return runAssign(ctx, opts, "TEST-1", "", true, cf) + }}, + } + for _, c := range commands { + for _, customFields := range []bool{true, false} { + name := c.name + map[bool]string{true: " with --custom-fields", false: " without --custom-fields"}[customFields] + t.Run(name, func(t *testing.T) { + seedCacheForIssues(t) + server := customFieldsMutationServer(t) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "e@x", APIToken: "t"}) + testutil.RequireNoError(t, err) + var stdout bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + testutil.RequireNoError(t, c.run(context.Background(), opts, customFields)) + + out := stdout.String() + testutil.Contains(t, out, "TEST-1") + if customFields { + testutil.Contains(t, out, "Custom Fields:") + testutil.Contains(t, out, "Change type: Feature") + } else if strings.Contains(out, "Custom Fields:") { + t.Fatalf("custom fields shown without the flag:\n%s", out) + } + }) + } + } +} diff --git a/tools/jtk/internal/cmd/issues/get.go b/tools/jtk/internal/cmd/issues/get.go index 3ed2383e..0ab622f2 100644 --- a/tools/jtk/internal/cmd/issues/get.go +++ b/tools/jtk/internal/cmd/issues/get.go @@ -6,10 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/open-cli-collective/atlassian-go/present" - "github.com/open-cli-collective/jira-ticket-cli/api" - "github.com/open-cli-collective/jira-ticket-cli/internal/cache" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" jtkpresent "github.com/open-cli-collective/jira-ticket-cli/internal/present" "github.com/open-cli-collective/jira-ticket-cli/internal/present/projection" @@ -88,13 +85,13 @@ func runGet(ctx context.Context, opts *root.Options, issueKey string, noTruncate jtkpresent.AppendDynamicDetailFields(model, issue, projection.DynamicSpecs(selected)) projection.ApplyToDetailInModel(model, selected) if customFields { - appendCustomFields(ctx, client, issue, model) + jtkpresent.AppendIssueCustomFields(ctx, client, issue, model) } return jtkpresent.Emit(opts, model) } model := presenter.PresentDetail(issue, client.IssueURL(issue.Key), false, noTruncate) if customFields { - appendCustomFields(ctx, client, issue, model) + jtkpresent.AppendIssueCustomFields(ctx, client, issue, model) } return jtkpresent.Emit(opts, model) } @@ -129,12 +126,3 @@ func runGetMulti(ctx context.Context, opts *root.Options, issueKeys []string) er model := jtkpresent.IssuePresenter{}.PresentList(issues, false) return jtkpresent.Emit(opts, model) } - -func appendCustomFields(ctx context.Context, client *api.Client, issue *api.Issue, model *present.OutputModel) { - fields, err := cache.GetFieldsCacheFirst(ctx, client) - if err != nil { - return - } - entries := api.ExtractIssueFieldValues(issue, fields) - jtkpresent.AppendCustomFieldsSection(model, entries) -} diff --git a/tools/jtk/internal/cmd/issues/update.go b/tools/jtk/internal/cmd/issues/update.go index fd27c5a3..7f3a2ac5 100644 --- a/tools/jtk/internal/cmd/issues/update.go +++ b/tools/jtk/internal/cmd/issues/update.go @@ -27,6 +27,7 @@ func newUpdateCmd(opts *root.Options) *cobra.Command { var issueType string var status string var fields []string + var customFields bool cmd := &cobra.Command{ Use: "update ", @@ -76,7 +77,7 @@ the description as a raw ADF document, which replaces it exactly as given.`, jtk issues update PROJ-123 --field "Story Points"=none`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runUpdate(cmd.Context(), opts, args[0], summary, description, parent, assignee, issueType, status, fields) + return runUpdate(cmd.Context(), opts, args[0], summary, description, parent, assignee, issueType, status, fields, customFields) }, } @@ -87,6 +88,7 @@ the description as a raw ADF document, which replaces it exactly as given.`, cmd.Flags().StringVarP(&issueType, "type", "t", "", "New issue type (uses bulk move API)") cmd.Flags().StringVar(&status, "status", "", "New workflow status (uses transitions API)") cmd.Flags().StringArrayVarP(&fields, "field", "f", nil, "Fields to update (key=value)") + cmd.Flags().BoolVar(&customFields, "custom-fields", false, "Append the issue's custom fields to the post-update view") return cmd } @@ -100,7 +102,7 @@ type statusChange struct { targetStatus string // resolved To.Name; used by mutation.ModelContainsStatus } -func runUpdate(ctx context.Context, opts *root.Options, issueKey, summary, description, parent, assignee, issueType, status string, fieldArgs []string) error { +func runUpdate(ctx context.Context, opts *root.Options, issueKey, summary, description, parent, assignee, issueType, status string, fieldArgs []string, customFields bool) error { // Validate that at least one field is being updated before making API calls if summary == "" && description == "" && parent == "" && assignee == "" && issueType == "" && status == "" && len(fieldArgs) == 0 { return fmt.Errorf("no fields specified to update") @@ -258,9 +260,13 @@ func runUpdate(ctx context.Context, opts *root.Options, issueKey, summary, descr if err != nil { return nil, err } - return jtkpresent.IssuePresenter{}.PresentDetail( + model := jtkpresent.IssuePresenter{}.PresentDetail( issue, client.IssueURL(id), false, opts.IsFullText(), - ), nil + ) + if customFields { + jtkpresent.AppendIssueCustomFields(ctx, client, issue, model) + } + return model, nil }, IsFresh: isFresh, Fallback: func(id string) *present.OutputModel { diff --git a/tools/jtk/internal/cmd/issues/update_test.go b/tools/jtk/internal/cmd/issues/update_test.go index 38bbc80a..aa72bfb5 100644 --- a/tools/jtk/internal/cmd/issues/update_test.go +++ b/tools/jtk/internal/cmd/issues/update_test.go @@ -53,7 +53,7 @@ func TestRunUpdate_RequestBodyNoDoubleQuoting(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-123", "Updated summary", "Updated description", "", "", "", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-123", "Updated summary", "Updated description", "", "", "", "", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -110,7 +110,7 @@ func TestRunUpdate_DescriptionPreservesMedia(t *testing.T) { opts := &root.Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-123", "", "Replacement text only.", "", "", "", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-123", "", "Replacement text only.", "", "", "", "", nil, false) testutil.RequireNoError(t, err) var reqBody struct { @@ -151,7 +151,7 @@ func TestRunUpdate_DescriptionReadFailureDoesNotWrite(t *testing.T) { opts := &root.Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-123", "New summary", "Replacement text only.", "", "", "", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-123", "New summary", "Replacement text only.", "", "", "", "", nil, false) testutil.Error(t, err) testutil.Contains(t, err.Error(), "reading current description to preserve its media") testutil.False(t, putCalled.Load()) @@ -207,7 +207,7 @@ func TestRunUpdate_RawADFDescriptionPassthrough(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-123", "", rawADF, "", "", "", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-123", "", rawADF, "", "", "", "", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -301,7 +301,7 @@ func TestRunUpdate_FieldTextareaRawADFPassthrough(t *testing.T) { err = runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "", []string{ "customfield_10099=" + rawADF, - }) + }, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -423,7 +423,7 @@ func TestRunUpdate_TypeChange(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "Task", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "Task", "", nil, false) testutil.RequireNoError(t, err) testutil.True(t, moveCompleted, "should have called the move API") @@ -477,7 +477,7 @@ func TestRunUpdate_TypeAlreadyCorrect(t *testing.T) { // Should succeed without calling move API since it's already the right type. // The silent changeIssueType returns nil (no-op), then WriteAndPresent // re-fetches and shows post-state detail. - err = runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "Task", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "Task", "", nil, false) testutil.RequireNoError(t, err) testutil.Contains(t, stdout.String(), "PROJ-123") } @@ -509,7 +509,7 @@ func TestRunUpdate_SummaryOnly(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "", nil, false) testutil.RequireNoError(t, err) var reqBody map[string]any @@ -539,7 +539,7 @@ func TestRunUpdate_IDOnly(t *testing.T) { opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}, IDOnly: true} opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "", nil, false) testutil.RequireNoError(t, err) testutil.Equal(t, stdout.String(), "PROJ-123\n") } @@ -550,7 +550,7 @@ func TestRunUpdate_NoFieldsError(t *testing.T) { Stderr: &bytes.Buffer{}, } - err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "", nil, false) testutil.Error(t, err) testutil.Contains(t, err.Error(), "no fields specified") } @@ -582,7 +582,7 @@ func TestRunUpdate_ParentOnly(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-456", "", "", "PROJ-100", "", "", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-456", "", "", "PROJ-100", "", "", "", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -624,7 +624,7 @@ func TestRunUpdate_ParentWithSummary(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-456", "Updated title", "", "PROJ-200", "", "", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-456", "Updated title", "", "PROJ-200", "", "", "", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -714,7 +714,7 @@ func TestRunUpdate_AssigneeOnly(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-789", "", "", "", "61292e4c4f29230069621c5f", "", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-789", "", "", "", "61292e4c4f29230069621c5f", "", "", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -762,7 +762,7 @@ func TestRunUpdate_AssigneeMe(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-789", "", "", "", "me", "", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-789", "", "", "", "me", "", "", nil, false) testutil.RequireNoError(t, err) testutil.NotEmpty(t, capturedBody) @@ -857,7 +857,7 @@ func TestRunUpdate_TypeChange_MoveNotFound_ServerDCError(t *testing.T) { opts := &root.Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "Task", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "Task", "", nil, false) if err == nil { t.Fatal("expected error for Server/DC detection") } @@ -908,7 +908,7 @@ func TestRunUpdate_TypeChange_PollNotFound_ContinuesFieldUpdates(t *testing.T) { opts := &root.Options{Stdout: &stdout, Stderr: &stderr} opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "Task", "", nil) + err = runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "Task", "", nil, false) testutil.RequireNoError(t, err) testutil.Contains(t, stderr.String(), "could not be verified") @@ -1005,7 +1005,7 @@ func TestRunUpdate_StatusOnly_HappyPath(t *testing.T) { }) opts, stdout, _ := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Done", nil, false) testutil.RequireNoError(t, err) testutil.Equal(t, rec.postTransition, 1) testutil.Equal(t, rec.putIssue, 0) @@ -1061,7 +1061,7 @@ func TestRunUpdate_Status_FreshnessRetries(t *testing.T) { defer srv.Close() opts, stdout, _ := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Done", nil, false) testutil.RequireNoError(t, err) testutil.Equal(t, postCount, 1) testutil.Contains(t, stdout.String(), "Done") @@ -1074,7 +1074,7 @@ func TestRunUpdate_StatusOnly_AlreadyCurrent(t *testing.T) { }) opts, _, stderr := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "done", nil, false) testutil.RequireNoError(t, err) testutil.Equal(t, rec.postTransition, 0) testutil.Equal(t, rec.getTransitions, 0) @@ -1085,7 +1085,7 @@ func TestRunUpdate_StatusAndSummary_AlreadyCurrent(t *testing.T) { srv, rec := newStatusServer(t, statusServerConfig{currentStatus: "Done"}) opts, _, _ := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "Done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "Done", nil, false) testutil.RequireNoError(t, err) testutil.Equal(t, rec.postTransition, 0) testutil.Equal(t, rec.putIssue, 1) @@ -1100,7 +1100,7 @@ func TestRunUpdate_Status_NotFound(t *testing.T) { }) opts, _, stderr := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Bogus", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Bogus", nil, false) testutil.Error(t, err) testutil.True(t, errors.Is(err, root.ErrAlreadyReported), "expected ErrAlreadyReported sentinel") testutil.Equal(t, rec.postTransition, 0) @@ -1119,7 +1119,7 @@ func TestRunUpdate_Status_NotFound_DoesNotWriteSummary(t *testing.T) { }) opts, _, _ := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "Bogus", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "Bogus", nil, false) testutil.Error(t, err) testutil.Equal(t, rec.putIssue, 0) } @@ -1134,7 +1134,7 @@ func TestRunUpdate_Status_Ambiguous(t *testing.T) { }) opts, _, stderr := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Done", nil, false) testutil.Error(t, err) testutil.True(t, errors.Is(err, root.ErrAlreadyReported)) testutil.Equal(t, rec.postTransition, 0) @@ -1171,7 +1171,7 @@ func TestRunUpdate_StatusAndSummary_HappyPath_OrdersWritesPutBeforePost(t *testi defer srv.Close() opts, _, _ := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "Done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "Done", nil, false) testutil.RequireNoError(t, err) testutil.Equal(t, len(order), 2) testutil.Equal(t, order[0], "PUT") @@ -1190,7 +1190,7 @@ func TestRunUpdate_Status_TransitionPostFailsAfterPut(t *testing.T) { }) opts, _, _ := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "Done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "Done", nil, false) testutil.Error(t, err) testutil.Equal(t, rec.putIssue, 1) testutil.Equal(t, rec.postTransition, 1) @@ -1206,7 +1206,7 @@ func TestRunUpdate_Status_IDOnly(t *testing.T) { opts, stdout, _ := newOptsForServer(t, srv.URL) opts.IDOnly = true - err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Done", nil, false) testutil.RequireNoError(t, err) testutil.Equal(t, rec.postTransition, 1) testutil.Equal(t, stdout.String(), "PROJ-123\n") @@ -1217,7 +1217,7 @@ func TestRunUpdate_Status_IDOnly_AlreadyCurrent_EmitsKeyNoAdvisory(t *testing.T) opts, stdout, stderr := newOptsForServer(t, srv.URL) opts.IDOnly = true - err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Done", nil, false) testutil.RequireNoError(t, err) testutil.Equal(t, rec.postTransition, 0) testutil.Equal(t, rec.getTransitions, 0) @@ -1284,7 +1284,7 @@ func TestRunUpdate_TypeAndStatus_HappyPath(t *testing.T) { defer srv.Close() opts, _, _ := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "Task", "Done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "Task", "Done", nil, false) testutil.RequireNoError(t, err) testutil.True(t, moveCalled, "bulk move must be called") testutil.True(t, transitionPosted, "transition POST must follow the move") @@ -1345,7 +1345,7 @@ func TestRunUpdate_TypeAndStatus_TransitionPostFailsAfterMove(t *testing.T) { defer srv.Close() opts, _, _ := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "Task", "Done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "Task", "Done", nil, false) testutil.Error(t, err) testutil.True(t, moveCalled, "type change happened before the transition failed (non-rollback)") } @@ -1360,7 +1360,7 @@ func TestRunUpdate_Status_GetTransitionsError(t *testing.T) { }) opts, _, _ := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "", "", "", "", "", "Done", nil, false) testutil.Error(t, err) testutil.Contains(t, err.Error(), "failed to get transitions") testutil.Equal(t, rec.postTransition, 0) @@ -1386,7 +1386,7 @@ func TestRunUpdate_Status_GetIssueError(t *testing.T) { defer srv.Close() opts, _, _ := newOptsForServer(t, srv.URL) - err := runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "Done", nil) + err := runUpdate(context.Background(), opts, "PROJ-123", "New summary", "", "", "", "", "Done", nil, false) testutil.Error(t, err) testutil.Contains(t, err.Error(), "failed to get issue") testutil.False(t, putCalled, "preflight failure must block the field PUT") diff --git a/tools/jtk/internal/cmd/transitions/custom_fields_test.go b/tools/jtk/internal/cmd/transitions/custom_fields_test.go new file mode 100644 index 00000000..0fb5c552 --- /dev/null +++ b/tools/jtk/internal/cmd/transitions/custom_fields_test.go @@ -0,0 +1,73 @@ +package transitions + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/open-cli-collective/atlassian-go/testutil" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cache" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" +) + +// Not parallel: the field cache root is process-global. +func TestRunDo_CustomFieldsFlag(t *testing.T) { + for _, customFields := range []bool{true, false} { + t.Run(map[bool]string{true: "with flag", false: "without flag"}[customFields], func(t *testing.T) { + t.Cleanup(cache.SetRootForTest(t.TempDir())) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/rest/api/3/field" && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode([]api.Field{ + {ID: "customfield_10005", Name: "Change type", Custom: true, Schema: api.FieldSchema{Type: "option"}}, + }) + case strings.HasSuffix(r.URL.Path, "/transitions") && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(api.TransitionsResponse{Transitions: []api.Transition{ + {ID: "31", Name: "In Development", To: api.Status{Name: "In Development"}}, + }}) + case strings.HasSuffix(r.URL.Path, "/transitions") && r.Method == http.MethodPost: + w.WriteHeader(http.StatusNoContent) + case r.URL.Path == "/rest/api/3/issue/TEST-1" && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(map[string]any{ + "key": "TEST-1", + "fields": map[string]any{ + "summary": "Test issue", + "status": map[string]any{"name": "In Development"}, + "issuetype": map[string]any{"name": "SDLC"}, + "priority": map[string]any{"name": "Medium"}, + "updated": "2026-04-16T00:00:00.000+0000", + "customfield_10005": map[string]any{"value": "Feature"}, + }, + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "e@x", APIToken: "t"}) + testutil.RequireNoError(t, err) + var stdout bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + testutil.RequireNoError(t, runDo(context.Background(), opts, "TEST-1", "In Development", nil, customFields)) + + out := stdout.String() + testutil.Contains(t, out, "In Development") + if customFields { + testutil.Contains(t, out, "Change type: Feature") + } else if strings.Contains(out, "Custom Fields:") { + t.Fatalf("custom fields shown without the flag:\n%s", out) + } + }) + } +} diff --git a/tools/jtk/internal/cmd/transitions/transitions.go b/tools/jtk/internal/cmd/transitions/transitions.go index 085fc8de..d924ecdc 100644 --- a/tools/jtk/internal/cmd/transitions/transitions.go +++ b/tools/jtk/internal/cmd/transitions/transitions.go @@ -90,6 +90,7 @@ func getRequiredFields(t api.Transition) string { func newDoCmd(opts *root.Options) *cobra.Command { var fields []string + var customFields bool cmd := &cobra.Command{ Use: "do ", @@ -108,16 +109,17 @@ Some transitions require additional fields to be set. Use --field to provide the jtk transitions do PROJ-123 "Done" --field customfield_10001="some value"`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - return runDo(cmd.Context(), opts, args[0], args[1], fields) + return runDo(cmd.Context(), opts, args[0], args[1], fields, customFields) }, } cmd.Flags().StringArrayVarP(&fields, "field", "f", nil, "Fields to set during transition (key=value)") + cmd.Flags().BoolVar(&customFields, "custom-fields", false, "Append the issue's custom fields to the post-transition view") return cmd } -func runDo(ctx context.Context, opts *root.Options, issueKey, transitionNameOrID string, fieldArgs []string) error { +func runDo(ctx context.Context, opts *root.Options, issueKey, transitionNameOrID string, fieldArgs []string, customFields bool) error { client, err := opts.APIClient() if err != nil { return err @@ -192,9 +194,13 @@ func runDo(ctx context.Context, opts *root.Options, issueKey, transitionNameOrID if err != nil { return nil, err } - return jtkpresent.IssuePresenter{}.PresentDetail( + model := jtkpresent.IssuePresenter{}.PresentDetail( issue, client.IssueURL(id), false, opts.IsFullText(), - ), nil + ) + if customFields { + jtkpresent.AppendIssueCustomFields(ctx, client, issue, model) + } + return model, nil }, IsFresh: func(m *present.OutputModel) bool { if targetStatus == "" { diff --git a/tools/jtk/internal/cmd/transitions/transitions_test.go b/tools/jtk/internal/cmd/transitions/transitions_test.go index e2448ac3..1f16da50 100644 --- a/tools/jtk/internal/cmd/transitions/transitions_test.go +++ b/tools/jtk/internal/cmd/transitions/transitions_test.go @@ -337,7 +337,7 @@ func TestRunDo_ShowsPostState(t *testing.T) { opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}} opts.SetAPIClient(client) - err = runDo(context.Background(), opts, "TEST-1", "In Development", nil) + err = runDo(context.Background(), opts, "TEST-1", "In Development", nil, false) testutil.RequireNoError(t, err) out := stdout.String() @@ -358,7 +358,7 @@ func TestRunDo_IDOnly(t *testing.T) { opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}, IDOnly: true} opts.SetAPIClient(client) - err = runDo(context.Background(), opts, "TEST-1", "In Development", nil) + err = runDo(context.Background(), opts, "TEST-1", "In Development", nil, false) testutil.RequireNoError(t, err) testutil.Equal(t, stdout.String(), "TEST-1\n") } @@ -389,7 +389,7 @@ func TestRunDo_FallbackOnFetchError(t *testing.T) { opts := &root.Options{Stdout: &stdout, Stderr: &stderr} opts.SetAPIClient(client) - err = runDo(context.Background(), opts, "TEST-1", "In Development", nil) + err = runDo(context.Background(), opts, "TEST-1", "In Development", nil, false) testutil.RequireNoError(t, err) testutil.Contains(t, stdout.String(), "TEST-1") testutil.Contains(t, stderr.String(), "post-state unavailable") diff --git a/tools/jtk/internal/present/custom_fields.go b/tools/jtk/internal/present/custom_fields.go new file mode 100644 index 00000000..29a20476 --- /dev/null +++ b/tools/jtk/internal/present/custom_fields.go @@ -0,0 +1,23 @@ +package present + +import ( + "context" + + "github.com/open-cli-collective/atlassian-go/present" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cache" +) + +// AppendIssueCustomFields appends the "Custom Fields:" section for issue to +// model, resolving field names from the field cache (fetched when missing or +// stale). It backs --custom-fields on every command that renders an issue +// detail view. When field metadata cannot be loaded the section is left out +// and the rest of the view still renders. +func AppendIssueCustomFields(ctx context.Context, client *api.Client, issue *api.Issue, model *present.OutputModel) { + fields, err := cache.GetFieldsCacheFirst(ctx, client) + if err != nil { + return + } + AppendCustomFieldsSection(model, api.ExtractIssueFieldValues(issue, fields)) +} From 2406d95a103c5804ed3c8a1bd8a5a45ffa69d4a1 Mon Sep 17 00:00:00 2001 From: piekstra Date: Tue, 13 Oct 2026 10:49:18 -0400 Subject: [PATCH 2/3] docs(jtk): changelog entry for --custom-fields on mutations --- tools/jtk/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/jtk/CHANGELOG.md b/tools/jtk/CHANGELOG.md index c266cb8a..124aba3d 100644 --- a/tools/jtk/CHANGELOG.md +++ b/tools/jtk/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `--custom-fields` on `issues create`, `issues update`, `issues assign` and `transitions do` appends the issue's non-empty custom fields to the post-write view, matching `issues get --custom-fields`. ([#503](https://github.com/open-cli-collective/atlassian-cli/pull/503)) - `comments update --body` replaces the body of an existing comment in place, with the same markdown, escape-sequence, and raw ADF handling as `comments add`. Prompts for confirmation because the previous body is not recoverable; `--force` skips the prompt. ([#498](https://github.com/open-cli-collective/atlassian-cli/issues/498)) - Raw ADF JSON passthrough for `--description` (`issues create`/`update`), `--body` (`comments add`), and rich-text `--field` overrides: a value that parses as `{"type":"doc","version":1,...}` with content is sent as a structured ADF document instead of going through markdown conversion, so documents with node types markdown can't express (e.g. `inlineCard` smart links) can be written directly. ([#486](https://github.com/open-cli-collective/atlassian-cli/pull/486)) - `issues get` now accepts multiple issue keys and renders a summary table for the batch. ([#327](https://github.com/open-cli-collective/atlassian-cli/pull/327)) From 77c526f0a5fd12d6e5d77b44a23f9ce7abf32a41 Mon Sep 17 00:00:00 2001 From: piekstra Date: Tue, 13 Oct 2026 10:53:21 -0400 Subject: [PATCH 3/3] refactor(jtk): inject the field fetcher into AppendIssueCustomFields --- tools/jtk/internal/cmd/issues/assign.go | 2 +- tools/jtk/internal/cmd/issues/create.go | 2 +- tools/jtk/internal/cmd/issues/get.go | 4 +- tools/jtk/internal/cmd/issues/update.go | 2 +- .../internal/cmd/transitions/transitions.go | 5 +- tools/jtk/internal/present/custom_fields.go | 14 ++--- .../internal/present/custom_fields_test.go | 55 +++++++++++++++++++ 7 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 tools/jtk/internal/present/custom_fields_test.go diff --git a/tools/jtk/internal/cmd/issues/assign.go b/tools/jtk/internal/cmd/issues/assign.go index 32c2cd31..90e6ba0d 100644 --- a/tools/jtk/internal/cmd/issues/assign.go +++ b/tools/jtk/internal/cmd/issues/assign.go @@ -100,7 +100,7 @@ func runAssign(ctx context.Context, opts *root.Options, issueKey, userInput stri issue, client.IssueURL(id), false, opts.IsFullText(), ) if customFields { - jtkpresent.AppendIssueCustomFields(ctx, client, issue, model) + jtkpresent.AppendIssueCustomFields(ctx, fieldsFetcher(client), issue, model) } return model, nil }, diff --git a/tools/jtk/internal/cmd/issues/create.go b/tools/jtk/internal/cmd/issues/create.go index 7eaa3644..64a5fa7f 100644 --- a/tools/jtk/internal/cmd/issues/create.go +++ b/tools/jtk/internal/cmd/issues/create.go @@ -153,7 +153,7 @@ func runCreate(ctx context.Context, opts *root.Options, project, issueType, summ fetched, client.IssueURL(id), false, opts.IsFullText(), ) if customFields { - jtkpresent.AppendIssueCustomFields(ctx, client, fetched, model) + jtkpresent.AppendIssueCustomFields(ctx, fieldsFetcher(client), fetched, model) } return model, nil }, diff --git a/tools/jtk/internal/cmd/issues/get.go b/tools/jtk/internal/cmd/issues/get.go index 0ab622f2..d994122e 100644 --- a/tools/jtk/internal/cmd/issues/get.go +++ b/tools/jtk/internal/cmd/issues/get.go @@ -85,13 +85,13 @@ func runGet(ctx context.Context, opts *root.Options, issueKey string, noTruncate jtkpresent.AppendDynamicDetailFields(model, issue, projection.DynamicSpecs(selected)) projection.ApplyToDetailInModel(model, selected) if customFields { - jtkpresent.AppendIssueCustomFields(ctx, client, issue, model) + jtkpresent.AppendIssueCustomFields(ctx, fieldsFetcher(client), issue, model) } return jtkpresent.Emit(opts, model) } model := presenter.PresentDetail(issue, client.IssueURL(issue.Key), false, noTruncate) if customFields { - jtkpresent.AppendIssueCustomFields(ctx, client, issue, model) + jtkpresent.AppendIssueCustomFields(ctx, fieldsFetcher(client), issue, model) } return jtkpresent.Emit(opts, model) } diff --git a/tools/jtk/internal/cmd/issues/update.go b/tools/jtk/internal/cmd/issues/update.go index 7f3a2ac5..7d37aa91 100644 --- a/tools/jtk/internal/cmd/issues/update.go +++ b/tools/jtk/internal/cmd/issues/update.go @@ -264,7 +264,7 @@ func runUpdate(ctx context.Context, opts *root.Options, issueKey, summary, descr issue, client.IssueURL(id), false, opts.IsFullText(), ) if customFields { - jtkpresent.AppendIssueCustomFields(ctx, client, issue, model) + jtkpresent.AppendIssueCustomFields(ctx, fieldsFetcher(client), issue, model) } return model, nil }, diff --git a/tools/jtk/internal/cmd/transitions/transitions.go b/tools/jtk/internal/cmd/transitions/transitions.go index d924ecdc..c4d3c9be 100644 --- a/tools/jtk/internal/cmd/transitions/transitions.go +++ b/tools/jtk/internal/cmd/transitions/transitions.go @@ -10,6 +10,7 @@ import ( "github.com/open-cli-collective/atlassian-go/present" "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cache" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" "github.com/open-cli-collective/jira-ticket-cli/internal/mutation" jtkpresent "github.com/open-cli-collective/jira-ticket-cli/internal/present" @@ -198,7 +199,9 @@ func runDo(ctx context.Context, opts *root.Options, issueKey, transitionNameOrID issue, client.IssueURL(id), false, opts.IsFullText(), ) if customFields { - jtkpresent.AppendIssueCustomFields(ctx, client, issue, model) + jtkpresent.AppendIssueCustomFields(ctx, func(ctx context.Context) ([]api.Field, error) { + return cache.GetFieldsCacheFirst(ctx, client) + }, issue, model) } return model, nil }, diff --git a/tools/jtk/internal/present/custom_fields.go b/tools/jtk/internal/present/custom_fields.go index 29a20476..16ce8929 100644 --- a/tools/jtk/internal/present/custom_fields.go +++ b/tools/jtk/internal/present/custom_fields.go @@ -6,16 +6,16 @@ import ( "github.com/open-cli-collective/atlassian-go/present" "github.com/open-cli-collective/jira-ticket-cli/api" - "github.com/open-cli-collective/jira-ticket-cli/internal/cache" ) // AppendIssueCustomFields appends the "Custom Fields:" section for issue to -// model, resolving field names from the field cache (fetched when missing or -// stale). It backs --custom-fields on every command that renders an issue -// detail view. When field metadata cannot be loaded the section is left out -// and the rest of the view still renders. -func AppendIssueCustomFields(ctx context.Context, client *api.Client, issue *api.Issue, model *present.OutputModel) { - fields, err := cache.GetFieldsCacheFirst(ctx, client) +// model, labeling values with names from fetchFields. It backs +// --custom-fields on every command that renders an issue detail view. +// fetchFields has the same shape projection.Resolve takes, so the command +// owns where field metadata comes from. When it fails the section is left +// out and the rest of the view still renders. +func AppendIssueCustomFields(ctx context.Context, fetchFields func(context.Context) ([]api.Field, error), issue *api.Issue, model *present.OutputModel) { + fields, err := fetchFields(ctx) if err != nil { return } diff --git a/tools/jtk/internal/present/custom_fields_test.go b/tools/jtk/internal/present/custom_fields_test.go new file mode 100644 index 00000000..ece73793 --- /dev/null +++ b/tools/jtk/internal/present/custom_fields_test.go @@ -0,0 +1,55 @@ +package present + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/open-cli-collective/atlassian-go/present" + + "github.com/open-cli-collective/jira-ticket-cli/api" +) + +func renderMessages(m *present.OutputModel) string { + var b strings.Builder + for _, s := range m.Sections { + if msg, ok := s.(*present.MessageSection); ok { + b.WriteString(msg.Message) + b.WriteString("\n") + } + } + return b.String() +} + +func TestAppendIssueCustomFields(t *testing.T) { + issue := &api.Issue{Key: "TEST-1", Fields: api.IssueFields{CustomFields: map[string]any{ + "customfield_10005": map[string]any{"value": "Feature"}, + "customfield_10099": nil, + }}} + fields := []api.Field{{ID: "customfield_10005", Name: "Change type", Custom: true}} + + t.Run("appends named non-empty fields", func(t *testing.T) { + model := &present.OutputModel{} + AppendIssueCustomFields(context.Background(), func(context.Context) ([]api.Field, error) { + return fields, nil + }, issue, model) + out := renderMessages(model) + if !strings.Contains(out, "Custom Fields:") || !strings.Contains(out, " Change type: Feature") { + t.Fatalf("missing custom fields section:\n%s", out) + } + if strings.Contains(out, "customfield_10099") { + t.Fatalf("empty field rendered:\n%s", out) + } + }) + + t.Run("omits the section when field metadata fails", func(t *testing.T) { + model := &present.OutputModel{} + AppendIssueCustomFields(context.Background(), func(context.Context) ([]api.Field, error) { + return nil, errors.New("offline") + }, issue, model) + if len(model.Sections) != 0 { + t.Fatalf("expected no sections, got %d", len(model.Sections)) + } + }) +}