Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tools/jtk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <issue-key> <comment-id> --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))
Expand Down
11 changes: 11 additions & 0 deletions tools/jtk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |

---

Expand All @@ -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 |
Expand All @@ -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:**
- `<issue-key>` - The issue key (**required**)
Expand Down Expand Up @@ -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:**
- `<issue-key>` - The issue key (**required**)
Expand Down Expand Up @@ -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:**
- `<issue-key>` - The issue key (**required**)
Expand Down
14 changes: 10 additions & 4 deletions tools/jtk/internal/cmd/issues/assign.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

func newAssignCmd(opts *root.Options) *cobra.Command {
var unassign bool
var customFields bool

cmd := &cobra.Command{
Use: "assign <issue-key> [user]",
Expand All @@ -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
Expand Down Expand Up @@ -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, fieldsFetcher(client), issue, model)
}
return model, nil
},
IsFresh: isFresh,
Fallback: func(id string) *present.OutputModel {
Expand Down
10 changes: 5 additions & 5 deletions tools/jtk/internal/cmd/issues/assign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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, "<null>")
testutil.Contains(t, stdout.String(), "PROJ-123")
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 10 additions & 4 deletions tools/jtk/internal/cmd/issues/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
},
}

Expand All @@ -58,14 +59,15 @@ 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")

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
Expand Down Expand Up @@ -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, fieldsFetcher(client), fetched, model)
}
return model, nil
},
Fallback: func(id string) *present.OutputModel {
return jtkpresent.IssuePresenter{}.PresentCreated(id, client.IssueURL(id))
Expand Down
26 changes: 13 additions & 13 deletions tools/jtk/internal/cmd/issues/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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")
}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading