From 8f9faa0dc9d57ee1e2a1683a029a4c29749ffd8b Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Thu, 27 Aug 2026 10:40:21 +0200 Subject: [PATCH 1/8] feat: add explicit contexts and OAuth Assets --- AGENTS.md | 244 +++++++++++++++++-------------- README.md | 240 ++++++++++++++++-------------- internal/api/assets.go | 127 +++++++--------- internal/api/assets_test.go | 123 ++++++++++++++++ internal/api/client.go | 5 +- internal/auth/oauth.go | 3 + internal/auth/oauth_test.go | 20 +++ internal/cmd/assets/aql.go | 4 +- internal/cmd/assets/assets.go | 50 ++----- internal/cmd/assets/count.go | 4 +- internal/cmd/assets/object.go | 60 ++++++++ internal/cmd/auth/setup.go | 6 +- internal/cmd/auth/status.go | 14 +- internal/cmd/auth/status_test.go | 17 +++ internal/cmd/root.go | 14 +- internal/cmd/root_test.go | 14 ++ internal/config/config.go | 10 ++ internal/config/config_test.go | 30 ++++ 18 files changed, 638 insertions(+), 347 deletions(-) create mode 100644 internal/api/assets_test.go create mode 100644 internal/auth/oauth_test.go create mode 100644 internal/cmd/assets/object.go diff --git a/AGENTS.md b/AGENTS.md index 02e88a8..e0e53d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,8 @@ Jira commands live under `atl jira` (`atl jira issue`, `atl jira board`, `atl ji ## Authentication ```bash -atl auth status # Check authentication status -atl auth login # Authenticate (opens browser) +atl auth status --hostname prod # Check one authenticated site +atl auth login --hostname enthus.atlassian.net # Authenticate one explicit site ``` ## Context Switching (Multi-Environment) @@ -24,7 +24,7 @@ Switch between Atlassian instances using aliases: atl config set-alias prod # alias "prod" → current host atl config set-alias sandbox mycompany-sandbox.atlassian.net # alias → specific host -# Switch active host +# Switch the persistent interactive default atl config use-context prod # by alias atl config use-context mycompany.atlassian.net # by hostname @@ -41,32 +41,60 @@ atl config list # shows Aliases section with (current) marker Aliases also work with `--hostname` flags: `atl auth status --hostname prod` +For every Jira, Confluence, or Assets operation, pass an invocation-scoped +context. Agents and automation must never rely on the persistent default because +other processes share and can change it: + +```bash +atl --context prod jira issue view PROJ-1234 +atl --context sandbox jira assets object 9244 +``` + +`ATLASSIAN_CONTEXT=prod atl ...` is equivalent, but `--context` is preferred. +The explicit context selects the host and its token without changing +`current_host`. + +## Jira Assets + +Assets uses the selected host's OAuth token and auto-discovers its workspace. +Tokens minted before the CMDB scopes were configured must be replaced with +`atl auth login --hostname ` before Assets requests will work. + +```bash +atl --context sandbox jira assets count +atl --context sandbox jira assets aql 'objectType = Customer' --limit 25 +atl --context sandbox jira assets object 9244 --json +``` + ## Jira Issues +The examples below focus on their subcommand flags; add +`--context ` to every actual API invocation. + ### View Issues ```bash -atl jira issue view PROJ-1234 # View issue details (includes custom fields) -atl jira issue view PROJ-1234 --json # View as JSON (includes custom_fields section) -atl jira issue view PROJ-1234 --web # Open in browser +atl --context prod jira issue view PROJ-1234 # View issue details (includes custom fields) +atl --context prod jira issue view PROJ-1234 --json # View as JSON (includes custom_fields section) +atl --context prod jira issue view PROJ-1234 --web # Open in browser ``` ### List Issues ```bash -atl jira issue list --assignee @me # Your assigned issues -atl jira issue list --project PROJ # Issues in project -atl jira issue list --jql "status = Open" # Custom JQL query -atl jira issue list --jql "sprint in openSprints() AND assignee = currentUser()" +atl --context prod jira issue list --assignee @me # Your assigned issues +atl --context prod jira issue list --project PROJ # Issues in project +atl --context prod jira issue list --jql "status = Open" # Custom JQL query +atl --context prod jira issue list --jql "sprint in openSprints() AND assignee = currentUser()" ``` ### Create Issues ```bash -atl jira issue create --project PROJ --type Bug --summary "Title" -atl jira issue create --project PROJ --type Task --summary "Title" --description "Details" -atl jira issue create --project PROJ --parent PROJ-123 --summary "Subtask" -atl jira issue create --project PROJ --type Bug --summary "Title" --security "Developer only" +atl --context prod jira issue create --project PROJ --type Bug --summary "Title" +atl --context prod jira issue create --project PROJ --type Task --summary "Title" --description "Details" +atl --context prod jira issue create --project PROJ --parent PROJ-123 --summary "Subtask" +atl --context prod jira issue create --project PROJ --type Bug --summary "Title" --security "Developer only" ``` **`--security`**: restricts issue visibility to an issue security level, by name @@ -76,15 +104,15 @@ atl jira issue create --project PROJ --type Bug --summary "Title" --security "De ### Edit Issues ```bash -atl jira issue edit PROJ-1234 --summary "New summary" -atl jira issue edit PROJ-1234 --description "New description content" -atl jira issue edit PROJ-1234 --description "Additional notes" --append # Append to existing -atl jira issue edit PROJ-1234 --assignee @me -atl jira issue edit PROJ-1234 --add-label bug --remove-label wontfix -atl jira issue edit PROJ-1234 --field "Story Points=8" -atl jira issue edit PROJ-1234 --field "Custom Field=Some **markdown** text" # Auto-converts to ADF -atl jira issue edit PROJ-1234 --security "Developer only" # Set issue security level -atl jira issue edit PROJ-1234 --security "" # Clear issue security level +atl --context prod jira issue edit PROJ-1234 --summary "New summary" +atl --context prod jira issue edit PROJ-1234 --description "New description content" +atl --context prod jira issue edit PROJ-1234 --description "Additional notes" --append # Append to existing +atl --context prod jira issue edit PROJ-1234 --assignee @me +atl --context prod jira issue edit PROJ-1234 --add-label bug --remove-label wontfix +atl --context prod jira issue edit PROJ-1234 --field "Story Points=8" +atl --context prod jira issue edit PROJ-1234 --field "Custom Field=Some **markdown** text" # Auto-converts to ADF +atl --context prod jira issue edit PROJ-1234 --security "Developer only" # Set issue security level +atl --context prod jira issue edit PROJ-1234 --security "" # Clear issue security level ``` **Notes**: @@ -95,58 +123,58 @@ atl jira issue edit PROJ-1234 --security "" # Clear issue securi ### Transitions and Workflow ```bash -atl jira issue transition PROJ-1234 "In Progress" -atl jira issue transition PROJ-1234 --list # List available transitions -atl jira issue transition PROJ-1234 "Done" --field "Resolution=Fixed" # With required fields +atl --context prod jira issue transition PROJ-1234 "In Progress" +atl --context prod jira issue transition PROJ-1234 --list # List available transitions +atl --context prod jira issue transition PROJ-1234 "Done" --field "Resolution=Fixed" # With required fields ``` ### Comments ```bash -atl jira issue comment list PROJ-1234 # List comments -atl jira issue comment add PROJ-1234 --body "Comment" # Add comment -atl jira issue comment add PROJ-1234 --body-file msg.md # Add from file (avoids shell escaping) -atl jira issue comment edit PROJ-1234 --id 123 --body "Updated" -atl jira issue comment edit PROJ-1234 --id 123 --body-file msg.md -atl jira issue comment delete PROJ-1234 --id 123 +atl --context prod jira issue comment list PROJ-1234 # List comments +atl --context prod jira issue comment add PROJ-1234 --body "Comment" # Add comment +atl --context prod jira issue comment add PROJ-1234 --body-file msg.md # Add from file (avoids shell escaping) +atl --context prod jira issue comment edit PROJ-1234 --id 123 --body "Updated" +atl --context prod jira issue comment edit PROJ-1234 --id 123 --body-file msg.md +atl --context prod jira issue comment delete PROJ-1234 --id 123 ``` ### Issue Links ```bash -atl jira issue link PROJ-1234 PROJ-5678 # Link issues (default: Relates) -atl jira issue link PROJ-1234 PROJ-5678 --type Blocks # Link with specific type +atl --context prod jira issue link PROJ-1234 PROJ-5678 # Link issues (default: Relates) +atl --context prod jira issue link PROJ-1234 PROJ-5678 --type Blocks # Link with specific type ``` ### Web Links ```bash -atl jira issue weblink PROJ-1234 --url "https://..." --title "Title" +atl --context prod jira issue weblink PROJ-1234 --url "https://..." --title "Title" ``` ### Sprint Management ```bash -atl jira issue sprint PROJ-1234 --sprint-id 123 # Move to sprint -atl jira issue sprint PROJ-1234 --backlog # Move to backlog +atl --context prod jira issue sprint PROJ-1234 --sprint-id 123 # Move to sprint +atl --context prod jira issue sprint PROJ-1234 --backlog # Move to backlog ``` ### Attachments ```bash -atl jira issue attachment PROJ-1234 --list # List attachments -atl jira issue attachment PROJ-1234 --download # Download attachment +atl --context prod jira issue attachment PROJ-1234 --list # List attachments +atl --context prod jira issue attachment PROJ-1234 --download # Download attachment ``` ### Metadata Discovery ```bash -atl jira issue types --project PROJ # List issue types -atl jira issue priorities # List available priorities -atl jira issue fields # List all fields -atl jira issue fields --search "story points" # Search for field by name -atl jira issue field-options --project PROJ --type Bug # Show allowed values for fields -atl jira issue field-options --project PROJ --type Bug --field "Priority" # Specific field +atl --context prod jira issue types --project PROJ # List issue types +atl --context prod jira issue priorities # List available priorities +atl --context prod jira issue fields # List all fields +atl --context prod jira issue fields --search "story points" # Search for field by name +atl --context prod jira issue field-options --project PROJ --type Bug # Show allowed values for fields +atl --context prod jira issue field-options --project PROJ --type Bug --field "Priority" # Specific field ``` `field-options` also lists the project's issue **security levels** as a synthetic @@ -160,9 +188,9 @@ project metadata, and similar lookups), use the GET passthrough. Only GET is supported — there is no write passthrough by design. ```bash -atl jira api GET issue/PROJ-1234/editmeta # Allowed fields + values for an edit -atl jira api project/PROJ/securitylevel # Method arg optional; defaults to GET -atl jira api GET issue/PROJ-1234/editmeta | jq '.fields | keys' +atl --context prod jira api GET issue/PROJ-1234/editmeta # Allowed fields + values for an edit +atl --context prod jira api project/PROJ/securitylevel # Method arg optional; defaults to GET +atl --context prod jira api GET issue/PROJ-1234/editmeta | jq '.fields | keys' ``` The `` is relative to the Jira REST v3 base (`.../rest/api/3`), with or @@ -171,11 +199,11 @@ without a leading slash. The JSON response is pretty-printed. ## Jira Boards ```bash -atl jira board list # List all boards -atl jira board list --project PROJ # List boards for project -atl jira board rank PROJ-123 --before PROJ-456 # Rank issue before another -atl jira board rank PROJ-123 --after PROJ-456 # Rank issue after another -atl jira board rank PROJ-123 --top --board-id 42 # Move to top of backlog +atl --context prod jira board list # List all boards +atl --context prod jira board list --project PROJ # List boards for project +atl --context prod jira board rank PROJ-123 --before PROJ-456 # Rank issue before another +atl --context prod jira board rank PROJ-123 --after PROJ-456 # Rank issue after another +atl --context prod jira board rank PROJ-123 --top --board-id 42 # Move to top of backlog ``` ## Jira Sprints @@ -193,39 +221,39 @@ Full sprint lifecycle under `atl jira sprint`. ```bash # Create a sprint as undated future sprint (required: --board, --name) -atl jira sprint create --board 42 --name "Sprint 30" --goal "Ship MI cutover" +atl --context prod jira sprint create --board 42 --name "Sprint 30" --goal "Ship MI cutover" # Create and start immediately (--start applies default 14d duration; override with --duration) -atl jira sprint create --board 42 --name "Sprint 31" --start -atl jira sprint create --board 42 --name "Sprint 32" --start --duration 3w +atl --context prod jira sprint create --board 42 --name "Sprint 31" --start +atl --context prod jira sprint create --board 42 --name "Sprint 32" --start --duration 3w # Edit sprint name, goal, or dates (date changes may fail for active/closed sprints) # Date format: YYYY-MM-DD -atl jira sprint edit 123 --goal "Updated goal" -atl jira sprint edit 123 --name "Sprint 30 (extended)" --start-date 2026-06-23 --end-date 2026-07-14 +atl --context prod jira sprint edit 123 --goal "Updated goal" +atl --context prod jira sprint edit 123 --name "Sprint 30 (extended)" --start-date 2026-06-23 --end-date 2026-07-14 # Start a sprint (only future sprints; sets dates via duration OR explicit dates) # Duration-based (calculates end date; default 14d) -atl jira sprint start 123 -atl jira sprint start 123 --duration 2w +atl --context prod jira sprint start 123 +atl --context prod jira sprint start 123 --duration 2w # Or use explicit dates (YYYY-MM-DD) -atl jira sprint start 123 --start-date 2026-06-23 --end-date 2026-07-07 +atl --context prod jira sprint start 123 --start-date 2026-06-23 --end-date 2026-07-07 # Close a sprint (only active sprints; incomplete issues move to backlog; prompts unless --force) -atl jira sprint close 123 -atl jira sprint close 123 --force +atl --context prod jira sprint close 123 +atl --context prod jira sprint close 123 --force # List sprints on a board (required: --board; state: active|future|closed, default: active,future) -atl jira sprint list --board 42 -atl jira sprint list --board 42 --state closed -atl jira sprint list --board 42 --state active,future,closed +atl --context prod jira sprint list --board 42 +atl --context prod jira sprint list --board 42 --state closed +atl --context prod jira sprint list --board 42 --state active,future,closed # Move issues by sprint ID (RECOMMENDED: unambiguous, safe) # Note: Issues must belong to target sprint's board; Jira will error if they don't -atl jira sprint move NX-1 NX-2 --to 123 +atl --context prod jira sprint move NX-1 NX-2 --to 123 # Move issues to their native board backlogs (works cross-board; independent of sprint) -atl jira sprint backlog NX-1 NX-2 +atl --context prod jira sprint backlog NX-1 NX-2 ``` ## Confluence @@ -233,48 +261,48 @@ atl jira sprint backlog NX-1 NX-2 ### Spaces ```bash -atl confluence space list # List spaces -atl confluence space list --json # List as JSON +atl --context prod confluence space list # List spaces +atl --context prod confluence space list --json # List as JSON ``` ### Pages ```bash -atl confluence page view # View page by ID -atl confluence page view --space DOCS --title "Title" -atl confluence page list --space DOCS # List pages in space -atl confluence page list --space DOCS --status draft # List draft pages -atl confluence page list --space DOCS --status archived # List archived pages -atl confluence page search "query" # Search pages -atl confluence page children # List child pages -atl confluence page create --space DOCS --title "New Page" --body "

Content

" -atl confluence page create --space DOCS --title "Draft" --draft # Create as draft -atl confluence page edit --body "

New content

" -atl confluence page delete # Delete page (prompts for confirmation) -atl confluence page delete --force # Delete without confirmation -atl confluence page publish # Publish a draft page -atl confluence page move --target -atl confluence page archive # Archive page (unarchive not supported via API) +atl --context prod confluence page view # View page by ID +atl --context prod confluence page view --space DOCS --title "Title" +atl --context prod confluence page list --space DOCS # List pages in space +atl --context prod confluence page list --space DOCS --status draft # List draft pages +atl --context prod confluence page list --space DOCS --status archived # List archived pages +atl --context prod confluence page search "query" # Search pages +atl --context prod confluence page children # List child pages +atl --context prod confluence page create --space DOCS --title "New Page" --body "

Content

" +atl --context prod confluence page create --space DOCS --title "Draft" --draft # Create as draft +atl --context prod confluence page edit --body "

New content

" +atl --context prod confluence page delete # Delete page (prompts for confirmation) +atl --context prod confluence page delete --force # Delete without confirmation +atl --context prod confluence page publish # Publish a draft page +atl --context prod confluence page move --target +atl --context prod confluence page archive # Archive page (unarchive not supported via API) ``` ### Templates ```bash -atl confluence template view # View template -atl confluence template create --space DOCS --name "Name" --body "" -atl confluence template update --body "" +atl --context prod confluence template view # View template +atl --context prod confluence template create --space DOCS --name "Name" --body "" +atl --context prod confluence template update --body "" ``` ### Attachments ```bash -atl confluence page attachment --list # List attachments -atl confluence page attachment --list --json # List as JSON -atl confluence page attachment --download --id # Download specific -atl confluence page attachment --download-all # Download all -atl confluence page attachment --download-all -o ./dir # Download to directory -atl confluence page attachment --upload ./file.pdf # Upload file -atl confluence page attachment --upload a.pdf --upload b.png # Upload multiple +atl --context prod confluence page attachment --list # List attachments +atl --context prod confluence page attachment --list --json # List as JSON +atl --context prod confluence page attachment --download --id # Download specific +atl --context prod confluence page attachment --download-all # Download all +atl --context prod confluence page attachment --download-all -o ./dir # Download to directory +atl --context prod confluence page attachment --upload ./file.pdf # Upload file +atl --context prod confluence page attachment --upload a.pdf --upload b.png # Upload multiple ``` ## Formatting Guidelines @@ -327,13 +355,13 @@ Use `--json` flag for structured output suitable for parsing: ```bash # Get issue data as JSON -atl jira issue view PROJ-1234 --json | jq '.status' +atl --context prod jira issue view PROJ-1234 --json | jq '.status' # List issues and extract keys -atl jira issue list --assignee @me --json | jq '.[].key' +atl --context prod jira issue list --assignee @me --json | jq '.[].key' # Get page content -atl confluence page view 12345 --json | jq '.body' +atl --context prod confluence page view 12345 --json | jq '.body' ``` ## Common Workflows @@ -342,38 +370,38 @@ atl confluence page view 12345 --json | jq '.body' ```bash # Find the issue -atl jira issue list --jql "summary ~ 'login bug'" --json +atl --context prod jira issue list --jql "summary ~ 'login bug'" --json # View details -atl jira issue view PROJ-1234 +atl --context prod jira issue view PROJ-1234 # Update it -atl jira issue edit PROJ-1234 --assignee @me -atl jira issue transition PROJ-1234 "In Progress" -atl jira issue comment PROJ-1234 --body "Starting work on this" +atl --context prod jira issue edit PROJ-1234 --assignee @me +atl --context prod jira issue transition PROJ-1234 "In Progress" +atl --context prod jira issue comment PROJ-1234 --body "Starting work on this" ``` ### Create a Linked Issue ```bash # Create the issue -atl jira issue create --project PROJ --type Task --summary "Implement feature X" +atl --context prod jira issue create --project PROJ --type Task --summary "Implement feature X" # Link it to a parent story -atl jira issue link PROJ-1235 PROJ-1000 --type "is part of" +atl --context prod jira issue link PROJ-1235 PROJ-1000 --type "is part of" ``` ### Update Confluence Documentation ```bash # Find the page -atl confluence page search "API documentation" --json +atl --context prod confluence page search "API documentation" --json # View current content -atl confluence page view 12345 +atl --context prod confluence page view 12345 # Update it -atl confluence page edit 12345 --body "

Updated API Docs

New content...

" +atl --context prod confluence page edit 12345 --body "

Updated API Docs

New content...

" ``` ## Error Handling diff --git a/README.md b/README.md index d77265b..6d1bcd5 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ atl auth setup atl auth login # 3. Start using the CLI -atl jira issue list --assignee @me -atl confluence space list +atl --context prod jira issue list --assignee @me +atl --context prod confluence space list ``` ## OAuth Setup @@ -80,16 +80,16 @@ export ATLASSIAN_CLIENT_SECRET="your-client-secret" ```bash # View an issue -atl jira issue view PROJ-1234 +atl --context prod jira issue view PROJ-1234 # List your assigned issues -atl jira issue list --assignee @me +atl --context prod jira issue list --assignee @me # Output as JSON for LLM processing -atl jira issue view PROJ-1234 --json +atl --context prod jira issue view PROJ-1234 --json # View a Confluence page -atl confluence page view --space DOCS --title "Getting Started" +atl --context prod confluence page view --space DOCS --title "Getting Started" ``` ## LLM-Friendly Output @@ -98,13 +98,13 @@ All commands support `--json` flag for structured JSON output, making it easy to ```bash # Get issue data as JSON -atl jira issue view PROJ-1234 --json +atl --context prod jira issue view PROJ-1234 --json # List issues as JSON -atl jira issue list --project PROJ --json +atl --context prod jira issue list --project PROJ --json # Get spaces as JSON -atl confluence space list --json +atl --context prod confluence space list --json ``` Plain text output is also structured for easy parsing by LLMs. @@ -115,7 +115,7 @@ Issue descriptions and comments support **Markdown syntax**, which is automatica ```bash # Create issue with markdown description -atl jira issue create --project PROJ --type Task --summary "Feature" --description "## Goals +atl --context prod jira issue create --project PROJ --type Task --summary "Feature" --description "## Goals - Goal 1 - Goal 2 @@ -123,7 +123,7 @@ atl jira issue create --project PROJ --type Task --summary "Feature" --descripti **Important**: See [docs](https://example.com) for details." # Add comment with markdown -atl jira issue comment PROJ-1234 --body "## Summary +atl --context prod jira issue comment PROJ-1234 --body "## Summary Fixed the **critical** bug in \`main.go\`. @@ -167,120 +167,120 @@ atl auth status # View authentication status ### Jira Issues ```bash -atl jira issue view # View an issue -atl jira issue view --json # View as JSON -atl jira issue view --web # Open in browser - -atl jira issue list # List recent issues -atl jira issue list --assignee @me # Your assigned issues -atl jira issue list --project PROJ # Issues in project -atl jira issue list --jql "status = Open" # Custom JQL query -atl jira issue list --json # Output as JSON - -atl jira issue create --project PROJ --type Bug --summary "Title" -atl jira issue create --project PROJ --type Task --summary "Title" --description "Details" -atl jira issue create --project PROJ --type Story --summary "Title" --field "Story Points=5" -atl jira issue create --project PROJ --type Task --summary "Title" --field-file fields.json -atl jira issue create --project PROJ --parent PROJ-123 --summary "Subtask" # Auto-discovers subtask type - -atl jira issue edit --summary "New summary" -atl jira issue edit --assignee @me -atl jira issue edit --add-label bug --remove-label wontfix -atl jira issue edit --field "Story Points=8" # Set custom field by name -atl jira issue edit --field-file fields.json # Complex fields from JSON file - -atl jira issue transition "In Progress" -atl jira issue transition --list # List available transitions - -atl jira issue comment --body "Comment text" -atl jira issue comment --list # List comments -atl jira issue comment --edit --comment-id 12345 --body "Updated text" -atl jira issue comment --delete --comment-id 12345 -atl jira issue comment --reply-to 12345 --body "Reply text" -atl jira issue comment --body "Internal note" --visibility-type role --visibility-name Developers - -atl jira issue assign --assignee @me -atl jira issue assign --assignee - # Unassign - -atl jira issue link # Link issues (default: Relates) -atl jira issue link --type Blocks # Link with specific type -atl jira issue link --list-types # List available link types - -atl jira issue weblink --url "https://..." --title "Title" # Add web link -atl jira issue weblink --list # List web links -atl jira issue weblink --delete 12345 # Delete web link by ID - -atl jira issue types --project PROJ # List issue types (shows subtask types) - -atl jira issue fields # List all fields -atl jira issue fields --custom # List custom fields only -atl jira issue fields --search "story" # Search for fields by name - -atl jira issue sprint --sprint-id 123 # Move issue to sprint -atl jira issue sprint --backlog # Move issue to backlog -atl jira issue sprint --list-sprints --board-id 1 # List sprints - -atl jira issue flag # Flag issue (mark as blocked) -atl jira issue flag --unflag # Remove flag -atl jira issue flag --status # Check if flagged - -atl jira issue attachment --list # List attachments -atl jira issue attachment --download --id 12345 # Download specific file -atl jira issue attachment --download-all # Download all attachments -atl jira issue attachment --download-all -o ./dir # Download to directory +atl --context prod jira issue view # View an issue +atl --context prod jira issue view --json # View as JSON +atl --context prod jira issue view --web # Open in browser + +atl --context prod jira issue list # List recent issues +atl --context prod jira issue list --assignee @me # Your assigned issues +atl --context prod jira issue list --project PROJ # Issues in project +atl --context prod jira issue list --jql "status = Open" # Custom JQL query +atl --context prod jira issue list --json # Output as JSON + +atl --context prod jira issue create --project PROJ --type Bug --summary "Title" +atl --context prod jira issue create --project PROJ --type Task --summary "Title" --description "Details" +atl --context prod jira issue create --project PROJ --type Story --summary "Title" --field "Story Points=5" +atl --context prod jira issue create --project PROJ --type Task --summary "Title" --field-file fields.json +atl --context prod jira issue create --project PROJ --parent PROJ-123 --summary "Subtask" # Auto-discovers subtask type + +atl --context prod jira issue edit --summary "New summary" +atl --context prod jira issue edit --assignee @me +atl --context prod jira issue edit --add-label bug --remove-label wontfix +atl --context prod jira issue edit --field "Story Points=8" # Set custom field by name +atl --context prod jira issue edit --field-file fields.json # Complex fields from JSON file + +atl --context prod jira issue transition "In Progress" +atl --context prod jira issue transition --list # List available transitions + +atl --context prod jira issue comment --body "Comment text" +atl --context prod jira issue comment --list # List comments +atl --context prod jira issue comment --edit --comment-id 12345 --body "Updated text" +atl --context prod jira issue comment --delete --comment-id 12345 +atl --context prod jira issue comment --reply-to 12345 --body "Reply text" +atl --context prod jira issue comment --body "Internal note" --visibility-type role --visibility-name Developers + +atl --context prod jira issue assign --assignee @me +atl --context prod jira issue assign --assignee - # Unassign + +atl --context prod jira issue link # Link issues (default: Relates) +atl --context prod jira issue link --type Blocks # Link with specific type +atl --context prod jira issue link --list-types # List available link types + +atl --context prod jira issue weblink --url "https://..." --title "Title" # Add web link +atl --context prod jira issue weblink --list # List web links +atl --context prod jira issue weblink --delete 12345 # Delete web link by ID + +atl --context prod jira issue types --project PROJ # List issue types (shows subtask types) + +atl --context prod jira issue fields # List all fields +atl --context prod jira issue fields --custom # List custom fields only +atl --context prod jira issue fields --search "story" # Search for fields by name + +atl --context prod jira issue sprint --sprint-id 123 # Move issue to sprint +atl --context prod jira issue sprint --backlog # Move issue to backlog +atl --context prod jira issue sprint --list-sprints --board-id 1 # List sprints + +atl --context prod jira issue flag # Flag issue (mark as blocked) +atl --context prod jira issue flag --unflag # Remove flag +atl --context prod jira issue flag --status # Check if flagged + +atl --context prod jira issue attachment --list # List attachments +atl --context prod jira issue attachment --download --id 12345 # Download specific file +atl --context prod jira issue attachment --download-all # Download all attachments +atl --context prod jira issue attachment --download-all -o ./dir # Download to directory ``` ### Boards ```bash -atl jira board list # List all boards -atl jira board list --project PROJ # List boards for a project +atl --context prod jira board list # List all boards +atl --context prod jira board list --project PROJ # List boards for a project -atl jira board rank PROJ-123 --before PROJ-456 # Rank issue before another -atl jira board rank PROJ-123 --after PROJ-456 # Rank issue after another -atl jira board rank PROJ-1 PROJ-2 PROJ-3 --before PROJ-4 # Rank multiple issues in order -atl jira board rank PROJ-123 --top --board-id 42 # Move to top of backlog +atl --context prod jira board rank PROJ-123 --before PROJ-456 # Rank issue before another +atl --context prod jira board rank PROJ-123 --after PROJ-456 # Rank issue after another +atl --context prod jira board rank PROJ-1 PROJ-2 PROJ-3 --before PROJ-4 # Rank multiple issues in order +atl --context prod jira board rank PROJ-123 --top --board-id 42 # Move to top of backlog ``` ### Confluence ```bash -atl confluence space list # List spaces -atl confluence space list --json # Output as JSON +atl --context prod confluence space list # List spaces +atl --context prod confluence space list --json # Output as JSON -atl confluence page view # View page by ID -atl confluence page view --space DOCS --title "Title" -atl confluence page view --json # Output as JSON -atl confluence page view --web # Open in browser +atl --context prod confluence page view # View page by ID +atl --context prod confluence page view --space DOCS --title "Title" +atl --context prod confluence page view --json # Output as JSON +atl --context prod confluence page view --web # Open in browser -atl confluence page list --space DOCS # List pages in space +atl --context prod confluence page list --space DOCS # List pages in space -atl confluence page create --space DOCS --title "New Page" -atl confluence page create --space DOCS --title "New Page" --body "Content" +atl --context prod confluence page create --space DOCS --title "New Page" +atl --context prod confluence page create --space DOCS --title "New Page" --body "Content" -atl confluence page edit --title "Updated Title" -atl confluence page edit --body "New content" +atl --context prod confluence page edit --title "Updated Title" +atl --context prod confluence page edit --body "New content" -atl confluence page children # List child pages -atl confluence page children --descendants # Include all descendants +atl --context prod confluence page children # List child pages +atl --context prod confluence page children --descendants # Include all descendants -atl confluence page search "query" # Search pages by title -atl confluence page search "query" --space DOCS # Search within space +atl --context prod confluence page search "query" # Search pages by title +atl --context prod confluence page search "query" --space DOCS # Search within space -atl confluence page archive # Archive a page -atl confluence page archive --unarchive # Restore archived page +atl --context prod confluence page archive # Archive a page +atl --context prod confluence page archive --unarchive # Restore archived page -atl confluence page move --target # Move as child of target -atl confluence page move --target --position before # Move before sibling -atl confluence page move --space NEWSPACE # Move to different space +atl --context prod confluence page move --target # Move as child of target +atl --context prod confluence page move --target --position before # Move before sibling +atl --context prod confluence page move --space NEWSPACE # Move to different space -atl confluence page attachment --list # List attachments -atl confluence page attachment --list --json # List as JSON -atl confluence page attachment --download --id # Download specific -atl confluence page attachment --download-all # Download all -atl confluence page attachment --download-all -o ./dir # Download to directory -atl confluence page attachment --upload ./file.pdf # Upload file -atl confluence page attachment --upload a.pdf --upload b.png # Upload multiple +atl --context prod confluence page attachment --list # List attachments +atl --context prod confluence page attachment --list --json # List as JSON +atl --context prod confluence page attachment --download --id # Download specific +atl --context prod confluence page attachment --download-all # Download all +atl --context prod confluence page attachment --download-all -o ./dir # Download to directory +atl --context prod confluence page attachment --upload ./file.pdf # Upload file +atl --context prod confluence page attachment --upload a.pdf --upload b.png # Upload multiple ``` ### Configuration @@ -298,6 +298,22 @@ Available config keys: - `editor` - Editor for editing content - `pager` - Pager for long output +### Explicit invocation context + +The persistent `current_host` is convenient for an interactive shell, but it is +shared by every process using the same config file. Agents and automation should +select a host per invocation instead: + +```bash +atl --context prod jira issue view PROJ-1234 +atl --context sandbox confluence space list +atl --context sandbox jira assets object 9244 --json +``` + +The value may be a configured alias or hostname. `--context` does not mutate +`current_host`. `ATLASSIAN_CONTEXT=prod atl ...` provides the same process-local +override. + ## Configuration Configuration is stored in `~/.config/atlassian/config.yaml`. @@ -320,7 +336,7 @@ default_output_format: text - `ATLASSIAN_CLIENT_ID` - OAuth client ID (highest-precedence source for login; otherwise OS keychain, then config file) - `ATLASSIAN_CLIENT_SECRET` - OAuth client secret (highest-precedence source for login; otherwise OS keychain, then config file) - `ATLASSIAN_TOKEN` - Override access token -- `ATLASSIAN_HOST` - Override default host +- `ATLASSIAN_CONTEXT` - Select a configured host or alias for this invocation - `ATLASSIAN_CONFIG_DIR` - Override config directory - `NO_COLOR` - Disable colored output @@ -351,17 +367,15 @@ atl completion powershell >> $PROFILE ### "Scope does not match" or 403 errors after updating -When the CLI adds new features that require additional OAuth scopes (like sprint management), you may get permission errors even after adding the scopes to your OAuth app. +When the CLI adds new features that require additional OAuth scopes (like Jira Assets), you may get permission errors even after adding the scopes to your OAuth app. Existing tokens do not gain scopes retroactively. -**Solution:** Perform a full logout and login to refresh your token with the new scopes: +**Solution:** Authenticate each affected site explicitly to replace its token: ```bash -atl auth logout -atl auth login +atl auth login --hostname mycompany.atlassian.net +atl auth login --hostname mycompany-sandbox.atlassian.net ``` -Simply running `atl auth login` again may not be sufficient as the existing token retains its original scopes. - ### Token expired errors The CLI automatically refreshes expired tokens. If you see persistent token errors: @@ -381,7 +395,7 @@ If authentication fails, verify your OAuth app configuration at https://develope **Jira API** (under "Jira API" in Developer Console): - Classic scopes: `read:jira-work`, `write:jira-work`, `read:jira-user` - - Granular scopes: `read:project:jira`, `read:issue-details:jira` + - Granular scopes: `read:project:jira`, `read:issue-details:jira`, `read:cmdb-object:jira`, `read:cmdb-schema:jira` - Granular scopes for boards/sprints/ranking: `read:board-scope:jira-software`, `write:board-scope:jira-software`, `read:issue:jira-software`, `write:issue:jira-software`, `read:sprint:jira-software`, `write:sprint:jira-software` **Confluence API** (under "Confluence API"): diff --git a/internal/api/assets.go b/internal/api/assets.go index b74a92c..2f4a7af 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -1,81 +1,34 @@ package api import ( - "bytes" "context" - "encoding/json" "fmt" - "io" "net/http" "net/url" - "strings" - "time" ) // AssetsClient talks to the Jira Service Management Assets (CMDB) REST API. // -// Assets lives on a different base URL than the Jira site API -// (https://api.atlassian.com/jsm/assets/workspace/{workspaceId}/v1) and the -// granular OAuth scopes the rest of atl uses do not cover CMDB objects, so this -// client authenticates with Basic auth (account email + API token) instead of -// the shared OAuth client. The token is read from the environment so it never -// lands in the on-disk config. +// Assets lives below a different path than the Jira platform API, but supports +// the same OAuth 2.0 access token when requests use the cloud gateway URL. type AssetsClient struct { - httpClient *http.Client - email string - token string + client *Client workspaceID string - siteBase string // https://, used only for workspace discovery + baseURL string } -const assetsAPIBase = "https://api.atlassian.com/jsm/assets/workspace" - -// NewAssetsClient builds an Assets client. email and token are required; -// workspaceID may be empty, in which case it is discovered from the site. -func NewAssetsClient(siteBase, email, token, workspaceID string) *AssetsClient { +// NewAssetsClient builds an OAuth-backed Assets client. workspaceID may be +// empty, in which case it is discovered through Jira Service Management. +func NewAssetsClient(client *Client, workspaceID string) *AssetsClient { return &AssetsClient{ - httpClient: &http.Client{Timeout: 60 * time.Second}, - email: email, - token: token, + client: client, workspaceID: workspaceID, - siteBase: strings.TrimRight(siteBase, "/"), + baseURL: fmt.Sprintf("%s/ex/jira/%s", AtlassianAPIURL, client.CloudID()), } } -func (c *AssetsClient) do(ctx context.Context, method, fullURL string, body []byte, out interface{}) error { - var rdr io.Reader - if body != nil { - rdr = bytes.NewReader(body) - } - req, err := http.NewRequestWithContext(ctx, method, fullURL, rdr) - if err != nil { - return err - } - req.SetBasicAuth(c.email, c.token) - req.Header.Set("Accept", "application/json") - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - dec := json.NewDecoder(resp.Body) - var apiErr struct { - ErrorMessages []string `json:"errorMessages"` - } - _ = dec.Decode(&apiErr) - if len(apiErr.ErrorMessages) > 0 { - return fmt.Errorf("assets API %s: %d: %s", method, resp.StatusCode, strings.Join(apiErr.ErrorMessages, "; ")) - } - return fmt.Errorf("assets API %s: unexpected status %d", method, resp.StatusCode) - } - if out == nil { - return nil - } - return json.NewDecoder(resp.Body).Decode(out) +func (c *AssetsClient) do(ctx context.Context, method, fullURL string, body, out interface{}) error { + return c.client.Request(ctx, method, fullURL, body, out) } // WorkspaceID returns the resolved workspace id, discovering it from the site if @@ -84,19 +37,16 @@ func (c *AssetsClient) WorkspaceID(ctx context.Context) (string, error) { if c.workspaceID != "" { return c.workspaceID, nil } - if c.siteBase == "" { - return "", fmt.Errorf("workspace id not set and no site to discover it from (pass --workspace or set ATLASSIAN_ASSETS_WORKSPACE)") - } var out struct { Values []struct { WorkspaceID string `json:"workspaceId"` } `json:"values"` } - if err := c.do(ctx, http.MethodGet, c.siteBase+"/rest/servicedeskapi/assets/workspace", nil, &out); err != nil { + if err := c.do(ctx, http.MethodGet, c.baseURL+"/rest/servicedeskapi/assets/workspace", nil, &out); err != nil { return "", fmt.Errorf("discovering assets workspace: %w", err) } if len(out.Values) == 0 { - return "", fmt.Errorf("no assets workspace found for site %s", c.siteBase) + return "", fmt.Errorf("no assets workspace found for %s", c.client.Hostname()) } c.workspaceID = out.Values[0].WorkspaceID return c.workspaceID, nil @@ -107,7 +57,7 @@ func (c *AssetsClient) v1(ctx context.Context) (string, error) { if err != nil { return "", err } - return fmt.Sprintf("%s/%s/v1", assetsAPIBase, ws), nil + return fmt.Sprintf("%s/jsm/assets/workspace/%s/v1", c.baseURL, ws), nil } // AssetSchema is one object schema with its current object count. @@ -136,14 +86,35 @@ func (c *AssetsClient) Schemas(ctx context.Context) ([]AssetSchema, error) { // AssetObject is a single Assets object (trimmed to the useful fields). type AssetObject struct { - ID string `json:"id"` - ObjectKey string `json:"objectKey"` - Label string `json:"label"` - Created string `json:"created"` - Updated string `json:"updated"` - ObjectType struct { + WorkspaceID string `json:"workspaceId,omitempty"` + GlobalID string `json:"globalId,omitempty"` + ID string `json:"id"` + ObjectKey string `json:"objectKey"` + Label string `json:"label"` + Created string `json:"created"` + Updated string `json:"updated"` + ObjectType struct { + ID string `json:"id,omitempty"` Name string `json:"name"` } `json:"objectType"` + Attributes []AssetAttribute `json:"attributes,omitempty"` +} + +// AssetAttribute is one named attribute and its values on an Assets object. +type AssetAttribute struct { + ID string `json:"id,omitempty"` + ObjectTypeAttributeID string `json:"objectTypeAttributeId"` + ObjectTypeAttribute struct { + Name string `json:"name"` + } `json:"objectTypeAttribute"` + ObjectAttributeValues []AssetAttributeValue `json:"objectAttributeValues,omitempty"` +} + +// AssetAttributeValue preserves both the API value and its display form. +type AssetAttributeValue struct { + Value interface{} `json:"value,omitempty"` + DisplayValue string `json:"displayValue,omitempty"` + SearchValue string `json:"searchValue,omitempty"` } type aqlPage struct { @@ -161,14 +132,26 @@ func (c *AssetsClient) AQLPage(ctx context.Context, ql string, startAt, maxResul q.Set("startAt", fmt.Sprint(startAt)) q.Set("maxResults", fmt.Sprint(maxResults)) q.Set("includeAttributes", "false") - body, _ := json.Marshal(map[string]string{"qlQuery": ql}) var page aqlPage - if err := c.do(ctx, http.MethodPost, base+"/object/aql?"+q.Encode(), body, &page); err != nil { + if err := c.do(ctx, http.MethodPost, base+"/object/aql?"+q.Encode(), map[string]string{"qlQuery": ql}, &page); err != nil { return nil, false, err } return page.Values, page.IsLast, nil } +// Object loads an Assets object and all attributes returned by the API. +func (c *AssetsClient) Object(ctx context.Context, objectID string) (*AssetObject, error) { + base, err := c.v1(ctx) + if err != nil { + return nil, err + } + var object AssetObject + if err := c.do(ctx, http.MethodGet, base+"/object/"+url.PathEscape(objectID), nil, &object); err != nil { + return nil, err + } + return &object, nil +} + // AQLCount returns the exact number of objects matching an AQL query by // paginating through every page. The object/aql endpoint caps its reported // `total` at 1000, so it cannot be trusted for counting — this walks instead. diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go new file mode 100644 index 0000000..0345b4e --- /dev/null +++ b/internal/api/assets_test.go @@ -0,0 +1,123 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/enthus-appdev/atl-cli/internal/auth" +) + +func newTestAssetsClient(server *httptest.Server, workspaceID string) *AssetsClient { + client := &Client{ + httpClient: server.Client(), + hostname: "test.atlassian.net", + cloudID: "cloud-123", + tokens: &auth.TokenSet{ + AccessToken: "test-token", + ExpiresAt: time.Now().Add(time.Hour), + }, + } + return &AssetsClient{ + client: client, + workspaceID: workspaceID, + baseURL: server.URL + "/ex/jira/cloud-123", + } +} + +func requireBearer(t *testing.T, request *http.Request) { + t.Helper() + if got := request.Header.Get("Authorization"); got != "Bearer test-token" { + t.Fatalf("Authorization = %q, want Bearer test-token", got) + } +} + +func TestAssetsWorkspaceIDUsesOAuthGateway(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + requireBearer(t, request) + if request.URL.Path != "/ex/jira/cloud-123/rest/servicedeskapi/assets/workspace" { + t.Fatalf("path = %q", request.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"values":[{"workspaceId":"workspace-456"}]}`)) + })) + defer server.Close() + + client := newTestAssetsClient(server, "") + workspaceID, err := client.WorkspaceID(context.Background()) + if err != nil { + t.Fatal(err) + } + if workspaceID != "workspace-456" { + t.Fatalf("WorkspaceID = %q, want workspace-456", workspaceID) + } +} + +func TestAssetsAQLPageUsesOAuthGateway(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + requireBearer(t, request) + if request.URL.Path != "/ex/jira/cloud-123/jsm/assets/workspace/workspace-456/v1/object/aql" { + t.Fatalf("path = %q", request.URL.Path) + } + wantQuery := url.Values{"includeAttributes": {"false"}, "maxResults": {"25"}, "startAt": {"5"}} + if request.URL.Query().Encode() != wantQuery.Encode() { + t.Fatalf("query = %q, want %q", request.URL.Query().Encode(), wantQuery.Encode()) + } + var body map[string]string + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["qlQuery"] != "objectId > 0" { + t.Fatalf("qlQuery = %q", body["qlQuery"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"values":[{"id":"9244","objectKey":"CUS-9244","label":"Customer"}],"isLast":true}`)) + })) + defer server.Close() + + client := newTestAssetsClient(server, "workspace-456") + objects, isLast, err := client.AQLPage(context.Background(), "objectId > 0", 5, 25) + if err != nil { + t.Fatal(err) + } + if !isLast || len(objects) != 1 || objects[0].ID != "9244" { + t.Fatalf("objects = %#v, isLast = %v", objects, isLast) + } +} + +func TestAssetsObjectIncludesAttributes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + requireBearer(t, request) + if request.URL.Path != "/ex/jira/cloud-123/jsm/assets/workspace/workspace-456/v1/object/9244" { + t.Fatalf("path = %q", request.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"9244", + "objectKey":"CUS-9244", + "label":"Example customer", + "attributes":[{ + "objectTypeAttributeId":"77", + "objectTypeAttribute":{"name":"MTS CustomerID"}, + "objectAttributeValues":[{"value":"145166","displayValue":"145166"}] + }] + }`)) + })) + defer server.Close() + + client := newTestAssetsClient(server, "workspace-456") + object, err := client.Object(context.Background(), "9244") + if err != nil { + t.Fatal(err) + } + if len(object.Attributes) != 1 || object.Attributes[0].ObjectTypeAttribute.Name != "MTS CustomerID" { + t.Fatalf("attributes = %#v", object.Attributes) + } + if got := object.Attributes[0].ObjectAttributeValues[0].DisplayValue; got != "145166" { + t.Fatalf("display value = %q, want 145166", got) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index 196715c..2e96b54 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -140,11 +140,12 @@ func NewClientFromConfig() (*Client, error) { return nil, fmt.Errorf("failed to load config: %w", err) } - if cfg.CurrentHost == "" { + hostname := cfg.InvocationHost() + if hostname == "" { return nil, fmt.Errorf("no host configured. Run 'atl auth login' first") } - return NewClient(cfg.CurrentHost) + return NewClient(hostname) } // Hostname returns the configured hostname. diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index fb2174e..08e3f04 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -78,6 +78,9 @@ func DefaultScopes() []string { // request types (what the sm commands call); a bare read:servicedesk is // not a grantable Atlassian scope and is silently dropped from the token. "read:servicedesk-request", + // Jira Assets scopes - AQL/object reads and schema counts. + "read:cmdb-object:jira", + "read:cmdb-schema:jira", // Token refresh "offline_access", } diff --git a/internal/auth/oauth_test.go b/internal/auth/oauth_test.go new file mode 100644 index 0000000..1ac369e --- /dev/null +++ b/internal/auth/oauth_test.go @@ -0,0 +1,20 @@ +package auth + +import "testing" + +func TestDefaultScopesIncludeAssetsReads(t *testing.T) { + want := map[string]bool{ + "read:cmdb-object:jira": false, + "read:cmdb-schema:jira": false, + } + for _, scope := range DefaultScopes() { + if _, ok := want[scope]; ok { + want[scope] = true + } + } + for scope, found := range want { + if !found { + t.Errorf("DefaultScopes missing %s", scope) + } + } +} diff --git a/internal/cmd/assets/aql.go b/internal/cmd/assets/aql.go index a1ece79..7577801 100644 --- a/internal/cmd/assets/aql.go +++ b/internal/cmd/assets/aql.go @@ -27,10 +27,10 @@ paginates, because the Assets endpoint caps its reported total at 1000). Otherwise the first matching objects are listed.`, Args: cobra.ExactArgs(1), Example: ` # Exact count of every object in the workspace - atl assets aql 'objectId > 0' --count + atl --context sandbox jira assets aql 'objectId > 0' --count # Newest objects of one object type - atl assets aql 'objectTypeId = 36 ORDER BY created DESC' --limit 20`, + atl --context prod jira assets aql 'objectTypeId = 36 ORDER BY created DESC' --limit 20`, RunE: func(cmd *cobra.Command, args []string) error { ql := args[0] client, err := common.client() diff --git a/internal/cmd/assets/assets.go b/internal/cmd/assets/assets.go index c736c49..a7bccda 100644 --- a/internal/cmd/assets/assets.go +++ b/internal/cmd/assets/assets.go @@ -1,67 +1,36 @@ package assets import ( - "fmt" "os" "github.com/spf13/cobra" "github.com/enthus-appdev/atl-cli/internal/api" - "github.com/enthus-appdev/atl-cli/internal/config" "github.com/enthus-appdev/atl-cli/internal/iostreams" ) // commonOptions holds the auth/target flags shared by every assets subcommand. type commonOptions struct { - Email string Workspace string } func (o *commonOptions) addFlags(cmd *cobra.Command) { - cmd.PersistentFlags().StringVar(&o.Email, "email", "", "Atlassian account email (default: $ATLASSIAN_EMAIL or current host user)") cmd.PersistentFlags().StringVar(&o.Workspace, "workspace", "", "Assets workspace id (default: $ATLASSIAN_ASSETS_WORKSPACE or auto-discovered)") } -// client builds a Basic-auth Assets client from flags, environment, and the -// current atl host. The API token is only ever read from the environment. +// client builds an Assets client from the current host's OAuth session. func (o *commonOptions) client() (*api.AssetsClient, error) { - token := os.Getenv("ATLASSIAN_API_TOKEN") - if token == "" { - return nil, fmt.Errorf("ATLASSIAN_API_TOKEN is not set — Assets uses Basic auth, not atl's OAuth login; create a token at https://id.atlassian.com/manage-profile/security/api-tokens") - } - - email := o.Email - if email == "" { - email = os.Getenv("ATLASSIAN_EMAIL") - } workspace := o.Workspace if workspace == "" { workspace = os.Getenv("ATLASSIAN_ASSETS_WORKSPACE") } - cfg, err := config.Load() + client, err := api.NewClientFromConfig() if err != nil { - return nil, fmt.Errorf("loading atl config: %w", err) - } - siteBase := "" - if hc := cfg.CurrentHostConfig(); hc != nil { - if email == "" { - email = hc.User - } - proto := hc.Protocol - if proto == "" { - proto = "https" - } - if hc.Hostname != "" { - siteBase = proto + "://" + hc.Hostname - } + return nil, err } - if email == "" { - return nil, fmt.Errorf("no account email — pass --email, set $ATLASSIAN_EMAIL, or log in to a host that records a user") - } - - return api.NewAssetsClient(siteBase, email, token, workspace), nil + return api.NewAssetsClient(client, workspace), nil } // NewCmdAssets creates the assets command group. @@ -73,15 +42,18 @@ func NewCmdAssets(ios *iostreams.IOStreams) *cobra.Command { Short: "Work with Jira Assets (CMDB)", Long: `Query the Jira Service Management Assets (CMDB) workspace. -Assets has its own API and uses Basic auth rather than atl's OAuth login -(the granular OAuth scopes do not cover CMDB objects). Set ATLASSIAN_API_TOKEN; -the account email and site default to your current atl host, and the workspace -id is auto-discovered if not supplied.`, +Assets uses the current atl host and OAuth login. The workspace id is +auto-discovered if it is not supplied. + +Existing tokens do not gain newly configured CMDB scopes automatically. If an +Assets request returns 403 after the app scopes changed, re-run +'atl auth login --hostname ' for that site.`, } opts.addFlags(cmd) cmd.AddCommand(newCmdCount(ios, opts)) cmd.AddCommand(newCmdAQL(ios, opts)) + cmd.AddCommand(newCmdObject(ios, opts)) return cmd } diff --git a/internal/cmd/assets/count.go b/internal/cmd/assets/count.go index 4be6548..21b7cba 100644 --- a/internal/cmd/assets/count.go +++ b/internal/cmd/assets/count.go @@ -18,8 +18,8 @@ func newCmdCount(ios *iostreams.IOStreams, common *commonOptions) *cobra.Command Long: `List every object schema with its current object count, plus the workspace-wide total — useful for tracking how close the workspace is to the Assets object limit.`, - Example: ` atl assets count - atl assets count --json`, + Example: ` atl --context sandbox jira assets count + atl --context prod jira assets count --json`, RunE: func(cmd *cobra.Command, args []string) error { client, err := common.client() if err != nil { diff --git a/internal/cmd/assets/object.go b/internal/cmd/assets/object.go new file mode 100644 index 0000000..ca29c1e --- /dev/null +++ b/internal/cmd/assets/object.go @@ -0,0 +1,60 @@ +package assets + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/iostreams" + "github.com/enthus-appdev/atl-cli/internal/output" +) + +func newCmdObject(ios *iostreams.IOStreams, common *commonOptions) *cobra.Command { + var jsonOut bool + + cmd := &cobra.Command{ + Use: "object ", + Short: "Get an Assets object and its attributes", + Example: ` atl --context sandbox jira assets object 9244 + atl --context prod jira assets object 9244 --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := common.client() + if err != nil { + return err + } + + object, err := client.Object(cmd.Context(), args[0]) + if err != nil { + return err + } + if jsonOut { + return output.JSON(ios.Out, object) + } + + fmt.Fprintf(ios.Out, "%s\t%s\t%s\n", object.ObjectKey, object.ObjectType.Name, strings.TrimSpace(object.Label)) + rows := make([][]string, 0, len(object.Attributes)) + for _, attribute := range object.Attributes { + name := attribute.ObjectTypeAttribute.Name + if name == "" { + name = attribute.ObjectTypeAttributeID + } + values := make([]string, 0, len(attribute.ObjectAttributeValues)) + for _, value := range attribute.ObjectAttributeValues { + if value.DisplayValue != "" { + values = append(values, value.DisplayValue) + } else { + values = append(values, fmt.Sprint(value.Value)) + } + } + rows = append(rows, []string{name, strings.Join(values, ", ")}) + } + output.SimpleTable(ios.Out, []string{"ATTRIBUTE", "VALUE"}, rows) + return nil + }, + } + + cmd.Flags().BoolVarP(&jsonOut, "json", "j", false, "Output as JSON") + return cmd +} diff --git a/internal/cmd/auth/setup.go b/internal/cmd/auth/setup.go index 698ef47..1f610af 100644 --- a/internal/cmd/auth/setup.go +++ b/internal/cmd/auth/setup.go @@ -144,9 +144,11 @@ func runSetup(opts *SetupOptions) error { fmt.Fprintln(opts.IO.Out, " "+output.Faint.Render("read:jira-work")) fmt.Fprintln(opts.IO.Out, " "+output.Faint.Render("write:jira-work")) fmt.Fprintln(opts.IO.Out, " "+output.Faint.Render("read:jira-user")) - fmt.Fprintln(opts.IO.Out, " • In the "+output.Bold.Render("Granular Scopes")+" tab, click "+output.Bold.Render("Edit Scopes")+" to enable the following 2 or 8 scopes:") + fmt.Fprintln(opts.IO.Out, " • In the "+output.Bold.Render("Granular Scopes")+" tab, click "+output.Bold.Render("Edit Scopes")+" to enable the following 4 or 10 scopes:") fmt.Fprintln(opts.IO.Out, " "+output.Faint.Render("read:project:jira")) fmt.Fprintln(opts.IO.Out, " "+output.Faint.Render("read:issue-details:jira")) + fmt.Fprintln(opts.IO.Out, " "+output.Faint.Render("read:cmdb-object:jira")) + fmt.Fprintln(opts.IO.Out, " "+output.Faint.Render("read:cmdb-schema:jira")) fmt.Fprintln(opts.IO.Out, " For boards/sprints/ranking, also enable:") fmt.Fprintln(opts.IO.Out, " "+output.Faint.Render("read:board-scope:jira-software")) fmt.Fprintln(opts.IO.Out, " "+output.Faint.Render("write:board-scope:jira-software")) @@ -155,7 +157,7 @@ func runSetup(opts *SetupOptions) error { fmt.Fprintln(opts.IO.Out, " "+output.Faint.Render("read:sprint:jira-software")) fmt.Fprintln(opts.IO.Out, " "+output.Faint.Render("write:sprint:jira-software")) fmt.Fprintln(opts.IO.Out, "") - fmt.Fprintln(opts.IO.Out, " • Click "+output.Bold.Render("Permissions")+" in the left menu (you should see \"Jira API\" with 5 or 11 scopes used)") + fmt.Fprintln(opts.IO.Out, " • Click "+output.Bold.Render("Permissions")+" in the left menu (you should see \"Jira API\" with 7 or 13 scopes used)") fmt.Fprintln(opts.IO.Out, " • Click "+output.Bold.Render("Add")+" and then "+output.Bold.Render("Configure")+" next to \"Confluence API\"") fmt.Fprintln(opts.IO.Out, " • In the "+output.Bold.Render("Classic Scopes")+" tab, click "+output.Bold.Render("Edit Scopes")+" to enable the following 3 scopes:") fmt.Fprintln(opts.IO.Out, " "+output.Faint.Render("read:confluence-content.all")) diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go index 858223c..d279e23 100644 --- a/internal/cmd/auth/status.go +++ b/internal/cmd/auth/status.go @@ -50,12 +50,13 @@ func NewCmdStatus(ios *iostreams.IOStreams) *cobra.Command { // AuthStatus represents the authentication status for a host. type AuthStatus struct { - Hostname string `json:"hostname"` - CloudID string `json:"cloud_id,omitempty"` - Authenticated bool `json:"authenticated"` - TokenExpired bool `json:"token_expired,omitempty"` - ExpiresAt string `json:"expires_at,omitempty"` - Current bool `json:"current"` + Hostname string `json:"hostname"` + CloudID string `json:"cloud_id,omitempty"` + Authenticated bool `json:"authenticated"` + TokenExpired bool `json:"token_expired,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + Scopes []string `json:"scopes,omitempty"` + Current bool `json:"current"` } func runStatus(opts *StatusOptions) error { @@ -112,6 +113,7 @@ func runStatus(opts *StatusOptions) error { status.Authenticated = true status.TokenExpired = tokens.IsExpired() status.ExpiresAt = tokens.ExpiresAt.Format(time.RFC3339) + status.Scopes = append([]string(nil), tokens.Scopes...) } statuses = append(statuses, status) diff --git a/internal/cmd/auth/status_test.go b/internal/cmd/auth/status_test.go index 9adb99c..b02f608 100644 --- a/internal/cmd/auth/status_test.go +++ b/internal/cmd/auth/status_test.go @@ -2,6 +2,7 @@ package auth import ( "bytes" + "encoding/json" "strings" "testing" @@ -58,3 +59,19 @@ func TestRunStatusJSONOmitsCredentialLine(t *testing.T) { t.Fatalf("JSON output must not contain the credential-source line, got:\n%s", buf.String()) } } + +func TestAuthStatusJSONIncludesTokenScopes(t *testing.T) { + data, err := json.Marshal(AuthStatus{ + Hostname: "sandbox.atlassian.net", + Authenticated: true, + Scopes: []string{"read:cmdb-object:jira", "read:cmdb-schema:jira"}, + }) + if err != nil { + t.Fatal(err) + } + for _, scope := range []string{"read:cmdb-object:jira", "read:cmdb-schema:jira"} { + if !strings.Contains(string(data), scope) { + t.Fatalf("JSON missing scope %q: %s", scope, data) + } + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 5f5c8a0..15fd340 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "runtime/debug" "github.com/spf13/cobra" @@ -30,6 +31,7 @@ func Execute(ios *iostreams.IOStreams, version string) int { // NewRootCmd creates the root command for the CLI. func NewRootCmd(ios *iostreams.IOStreams, version string) *cobra.Command { commit, date := vcsInfo() + var contextName string cmd := &cobra.Command{ Use: "atl", Short: "Atlassian CLI - Work with Jira and Confluence from the command line", @@ -42,11 +44,21 @@ It provides commands for: Get started by running 'atl auth login' to authenticate with your Atlassian account. Environment variables: - ATL_DEBUG=1 Enable debug logging (shows API requests/responses)`, + ATL_DEBUG=1 Enable debug logging (shows API requests/responses) + ATLASSIAN_CONTEXT= Select a host for this invocation`, SilenceUsage: true, SilenceErrors: true, Version: version, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if contextName == "" { + return nil + } + // The API client resolves this process-local override before the + // persisted current_host, so --context never mutates shared config. + return os.Setenv("ATLASSIAN_CONTEXT", contextName) + }, } + cmd.PersistentFlags().StringVar(&contextName, "context", "", "Atlassian host or alias for this invocation") // Set custom version template cmd.SetVersionTemplate(fmt.Sprintf("atl version %s\ncommit: %s\nbuilt: %s\n", diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index a91b99a..09d851f 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "os" "strings" "testing" @@ -62,6 +63,19 @@ func TestDeprecationWarning(t *testing.T) { } } +func TestContextFlagSetsInvocationOverride(t *testing.T) { + t.Setenv("ATLASSIAN_CONTEXT", "from-environment") + root := NewRootCmd(iostreams.Test(), "test") + root.SetArgs([]string{"version", "--context", "sandbox"}) + + if err := root.Execute(); err != nil { + t.Fatalf("execute with --context: %v", err) + } + if got := os.Getenv("ATLASSIAN_CONTEXT"); got != "sandbox" { + t.Fatalf("ATLASSIAN_CONTEXT = %q, want sandbox", got) + } +} + // findChild returns the direct subcommand with the given name, including hidden ones. func findChild(parent *cobra.Command, name string) *cobra.Command { for _, c := range parent.Commands() { diff --git a/internal/config/config.go b/internal/config/config.go index 415fd8b..668d89f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -184,6 +184,16 @@ func (c *Config) ResolveHost(nameOrHostname string) string { return NormalizeHostname(nameOrHostname) } +// InvocationHost returns the host selected for this process. An invocation +// override wins over the persistent default because multiple shells and agents +// may share config.yaml while targeting different Atlassian sites. +func (c *Config) InvocationHost() string { + if contextName := strings.TrimSpace(os.Getenv("ATLASSIAN_CONTEXT")); contextName != "" { + return c.ResolveHost(contextName) + } + return c.CurrentHost +} + // SetAlias creates or updates an alias mapping to a hostname. // The hostname must exist in the Hosts map. func (c *Config) SetAlias(alias, hostname string) error { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c319d1f..a736284 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -333,6 +333,36 @@ func TestResolveHostNilAliases(t *testing.T) { } } +func TestInvocationHost(t *testing.T) { + cfg := &Config{ + CurrentHost: "default.atlassian.net", + Aliases: map[string]string{ + "sandbox": "sandbox.atlassian.net", + }, + } + + t.Run("persistent default", func(t *testing.T) { + t.Setenv("ATLASSIAN_CONTEXT", "") + if got := cfg.InvocationHost(); got != "default.atlassian.net" { + t.Fatalf("InvocationHost() = %q, want persistent default", got) + } + }) + + t.Run("alias override", func(t *testing.T) { + t.Setenv("ATLASSIAN_CONTEXT", "sandbox") + if got := cfg.InvocationHost(); got != "sandbox.atlassian.net" { + t.Fatalf("InvocationHost() = %q, want resolved alias", got) + } + }) + + t.Run("hostname override", func(t *testing.T) { + t.Setenv("ATLASSIAN_CONTEXT", "https://other.atlassian.net/") + if got := cfg.InvocationHost(); got != "other.atlassian.net" { + t.Fatalf("InvocationHost() = %q, want normalized hostname", got) + } + }) +} + // TestSetAlias tests creating aliases. func TestSetAlias(t *testing.T) { cfg := &Config{ From cde4a683aaa16752f694b0368a66cac691c9c3df Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Thu, 27 Aug 2026 12:27:20 +0200 Subject: [PATCH 2/8] fix: skip null asset values --- internal/cmd/assets/object.go | 22 ++++++++++++++-------- internal/cmd/assets/object_test.go | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 internal/cmd/assets/object_test.go diff --git a/internal/cmd/assets/object.go b/internal/cmd/assets/object.go index ca29c1e..88bdacd 100644 --- a/internal/cmd/assets/object.go +++ b/internal/cmd/assets/object.go @@ -6,10 +6,23 @@ import ( "github.com/spf13/cobra" + "github.com/enthus-appdev/atl-cli/internal/api" "github.com/enthus-appdev/atl-cli/internal/iostreams" "github.com/enthus-appdev/atl-cli/internal/output" ) +func attributeValues(values []api.AssetAttributeValue) []string { + formatted := make([]string, 0, len(values)) + for _, value := range values { + if value.DisplayValue != "" { + formatted = append(formatted, value.DisplayValue) + } else if value.Value != nil { + formatted = append(formatted, fmt.Sprint(value.Value)) + } + } + return formatted +} + func newCmdObject(ios *iostreams.IOStreams, common *commonOptions) *cobra.Command { var jsonOut bool @@ -40,14 +53,7 @@ func newCmdObject(ios *iostreams.IOStreams, common *commonOptions) *cobra.Comman if name == "" { name = attribute.ObjectTypeAttributeID } - values := make([]string, 0, len(attribute.ObjectAttributeValues)) - for _, value := range attribute.ObjectAttributeValues { - if value.DisplayValue != "" { - values = append(values, value.DisplayValue) - } else { - values = append(values, fmt.Sprint(value.Value)) - } - } + values := attributeValues(attribute.ObjectAttributeValues) rows = append(rows, []string{name, strings.Join(values, ", ")}) } output.SimpleTable(ios.Out, []string{"ATTRIBUTE", "VALUE"}, rows) diff --git a/internal/cmd/assets/object_test.go b/internal/cmd/assets/object_test.go new file mode 100644 index 0000000..72fd8c0 --- /dev/null +++ b/internal/cmd/assets/object_test.go @@ -0,0 +1,21 @@ +package assets + +import ( + "reflect" + "testing" + + "github.com/enthus-appdev/atl-cli/internal/api" +) + +func TestAttributeValuesSkipsNullValues(t *testing.T) { + values := []api.AssetAttributeValue{ + {DisplayValue: "Customer 145166", Value: "145166"}, + {Value: float64(42)}, + {}, + } + + want := []string{"Customer 145166", "42"} + if got := attributeValues(values); !reflect.DeepEqual(got, want) { + t.Fatalf("attributeValues() = %#v, want %#v", got, want) + } +} From b7038a51719ef1559f5b3f63b036f07018454973 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Thu, 27 Aug 2026 12:53:03 +0200 Subject: [PATCH 3/8] fix: align context diagnostics and docs --- README.md | 17 +++++++------- internal/cmd/auth/status.go | 3 ++- internal/cmd/config/current_context.go | 11 ++++----- internal/cmd/root.go | 21 ++++++++++++++--- internal/cmd/root_test.go | 31 +++++++++++++++++++++++--- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 6d1bcd5..73a4f0d 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,9 @@ make install # 1. Set up OAuth (one-time, interactive wizard) atl auth setup -# 2. Log in to your Atlassian account -atl auth login +# 2. Log in and name the host used by the examples below +atl auth login --hostname mycompany.atlassian.net +atl config set-alias prod mycompany.atlassian.net # 3. Start using the CLI atl --context prod jira issue list --assignee @me @@ -192,12 +193,12 @@ atl --context prod jira issue edit --field-file fields.json # Complex f atl --context prod jira issue transition "In Progress" atl --context prod jira issue transition --list # List available transitions -atl --context prod jira issue comment --body "Comment text" -atl --context prod jira issue comment --list # List comments -atl --context prod jira issue comment --edit --comment-id 12345 --body "Updated text" -atl --context prod jira issue comment --delete --comment-id 12345 -atl --context prod jira issue comment --reply-to 12345 --body "Reply text" -atl --context prod jira issue comment --body "Internal note" --visibility-type role --visibility-name Developers +atl --context prod jira issue comment add --body "Comment text" +atl --context prod jira issue comment list # List comments +atl --context prod jira issue comment edit --id 12345 --body "Updated text" +atl --context prod jira issue comment delete --id 12345 +atl --context prod jira issue comment add --reply-to 12345 --body "Reply text" +atl --context prod jira issue comment add --body "Internal note" --visibility-type role --visibility-name Developers atl --context prod jira issue assign --assignee @me atl --context prod jira issue assign --assignee - # Unassign diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go index d279e23..94bb64a 100644 --- a/internal/cmd/auth/status.go +++ b/internal/cmd/auth/status.go @@ -92,6 +92,7 @@ func runStatus(opts *StatusOptions) error { } var statuses []AuthStatus + activeHost := cfg.InvocationHost() for hostname, hostCfg := range cfg.Hosts { if opts.Hostname != "" && opts.Hostname != hostname { @@ -101,7 +102,7 @@ func runStatus(opts *StatusOptions) error { status := AuthStatus{ Hostname: hostname, CloudID: hostCfg.CloudID, - Current: hostname == cfg.CurrentHost, + Current: hostname == activeHost, } tokens, err := auth.GetToken(hostname) diff --git a/internal/cmd/config/current_context.go b/internal/cmd/config/current_context.go index 140bc3d..18b2f15 100644 --- a/internal/cmd/config/current_context.go +++ b/internal/cmd/config/current_context.go @@ -39,7 +39,8 @@ func runCurrentContext(ios *iostreams.IOStreams, jsonOutput bool) error { return fmt.Errorf("failed to load config: %w", err) } - if cfg.CurrentHost == "" { + activeHost := cfg.InvocationHost() + if activeHost == "" { if jsonOutput { return output.JSON(ios.Out, CurrentContextOutput{}) } @@ -48,19 +49,19 @@ func runCurrentContext(ios *iostreams.IOStreams, jsonOutput bool) error { return nil } - alias := cfg.AliasForHost(cfg.CurrentHost) + alias := cfg.AliasForHost(activeHost) if jsonOutput { return output.JSON(ios.Out, CurrentContextOutput{ - Hostname: cfg.CurrentHost, + Hostname: activeHost, Alias: alias, }) } if alias != "" { - fmt.Fprintf(ios.Out, "%s (%s)\n", alias, cfg.CurrentHost) + fmt.Fprintf(ios.Out, "%s (%s)\n", alias, activeHost) } else { - fmt.Fprintln(ios.Out, cfg.CurrentHost) + fmt.Fprintln(ios.Out, activeHost) } return nil diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 15fd340..88e8752 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -32,6 +32,7 @@ func Execute(ios *iostreams.IOStreams, version string) int { func NewRootCmd(ios *iostreams.IOStreams, version string) *cobra.Command { commit, date := vcsInfo() var contextName string + var restoreContext func() cmd := &cobra.Command{ Use: "atl", Short: "Atlassian CLI - Work with Jira and Confluence from the command line", @@ -53,9 +54,23 @@ Environment variables: if contextName == "" { return nil } - // The API client resolves this process-local override before the - // persisted current_host, so --context never mutates shared config. - return os.Setenv("ATLASSIAN_CONTEXT", contextName) + previous, existed := os.LookupEnv("ATLASSIAN_CONTEXT") + if err := os.Setenv("ATLASSIAN_CONTEXT", contextName); err != nil { + return err + } + restoreContext = func() { + if existed { + _ = os.Setenv("ATLASSIAN_CONTEXT", previous) + } else { + _ = os.Unsetenv("ATLASSIAN_CONTEXT") + } + } + return nil + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + if restoreContext != nil { + restoreContext() + } }, } cmd.PersistentFlags().StringVar(&contextName, "context", "", "Atlassian host or alias for this invocation") diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 09d851f..7490154 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -66,14 +66,39 @@ func TestDeprecationWarning(t *testing.T) { func TestContextFlagSetsInvocationOverride(t *testing.T) { t.Setenv("ATLASSIAN_CONTEXT", "from-environment") root := NewRootCmd(iostreams.Test(), "test") - root.SetArgs([]string{"version", "--context", "sandbox"}) + var during string + root.AddCommand(&cobra.Command{ + Use: "capture-context", + Run: func(cmd *cobra.Command, args []string) { + during = os.Getenv("ATLASSIAN_CONTEXT") + }, + }) + root.SetArgs([]string{"capture-context", "--context", "sandbox"}) if err := root.Execute(); err != nil { t.Fatalf("execute with --context: %v", err) } - if got := os.Getenv("ATLASSIAN_CONTEXT"); got != "sandbox" { - t.Fatalf("ATLASSIAN_CONTEXT = %q, want sandbox", got) + if during != "sandbox" { + t.Fatalf("ATLASSIAN_CONTEXT during command = %q, want sandbox", during) } + if got := os.Getenv("ATLASSIAN_CONTEXT"); got != "from-environment" { + t.Fatalf("ATLASSIAN_CONTEXT after command = %q, want restored value", got) + } +} + +func TestSubcommandsDoNotOverrideRootPersistentHooks(t *testing.T) { + root := NewRootCmd(iostreams.Test(), "test") + var check func(*cobra.Command) + check = func(parent *cobra.Command) { + for _, child := range parent.Commands() { + if child.PersistentPreRun != nil || child.PersistentPreRunE != nil || + child.PersistentPostRun != nil || child.PersistentPostRunE != nil { + t.Errorf("%s overrides root invocation-context hooks", child.CommandPath()) + } + check(child) + } + } + check(root) } // findChild returns the direct subcommand with the given name, including hidden ones. From f4198ba62e3e404d0c802ef6a93df46fd9b01f70 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Thu, 27 Aug 2026 12:53:50 +0200 Subject: [PATCH 4/8] test: cover assets gateway construction --- internal/api/assets_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index 0345b4e..8b206b1 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -36,6 +36,15 @@ func requireBearer(t *testing.T, request *http.Request) { } } +func TestNewAssetsClientUsesCloudGateway(t *testing.T) { + client := &Client{cloudID: "cloud-123"} + assets := NewAssetsClient(client, "workspace-456") + + if got, want := assets.baseURL, "https://api.atlassian.com/ex/jira/cloud-123"; got != want { + t.Fatalf("baseURL = %q, want %q", got, want) + } +} + func TestAssetsWorkspaceIDUsesOAuthGateway(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { requireBearer(t, request) From 525e1c52894c9cb704e9de92aa5cce9b7e9cca05 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Thu, 27 Aug 2026 13:15:45 +0200 Subject: [PATCH 5/8] fix: scope invocation context lifecycle --- AGENTS.md | 1 + internal/api/assets.go | 2 +- internal/api/assets_test.go | 14 ++++++++ internal/cmd/doctor/doctor.go | 9 +++--- internal/cmd/root.go | 60 +++++++++++++++++++++-------------- internal/cmd/root_test.go | 30 +++++++++++------- 6 files changed, 76 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e0e53d6..1745e0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ Jira commands live under `atl jira` (`atl jira issue`, `atl jira board`, `atl ji ```bash atl auth status --hostname prod # Check one authenticated site atl auth login --hostname enthus.atlassian.net # Authenticate one explicit site +atl config set-alias prod enthus.atlassian.net # Name the host used below ``` ## Context Switching (Multi-Environment) diff --git a/internal/api/assets.go b/internal/api/assets.go index 2f4a7af..9d127a6 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -57,7 +57,7 @@ func (c *AssetsClient) v1(ctx context.Context) (string, error) { if err != nil { return "", err } - return fmt.Sprintf("%s/jsm/assets/workspace/%s/v1", c.baseURL, ws), nil + return fmt.Sprintf("%s/jsm/assets/workspace/%s/v1", c.baseURL, url.PathEscape(ws)), nil } // AssetSchema is one object schema with its current object count. diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index 8b206b1..106adc3 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -66,6 +66,20 @@ func TestAssetsWorkspaceIDUsesOAuthGateway(t *testing.T) { } } +func TestAssetsV1EscapesWorkspaceID(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + client := newTestAssetsClient(server, "workspace/456") + base, err := client.v1(context.Background()) + if err != nil { + t.Fatal(err) + } + if got, want := base, server.URL+"/ex/jira/cloud-123/jsm/assets/workspace/workspace%2F456/v1"; got != want { + t.Fatalf("v1() = %q, want %q", got, want) + } +} + func TestAssetsAQLPageUsesOAuthGateway(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { requireBearer(t, request) diff --git a/internal/cmd/doctor/doctor.go b/internal/cmd/doctor/doctor.go index 453a17f..82ebace 100644 --- a/internal/cmd/doctor/doctor.go +++ b/internal/cmd/doctor/doctor.go @@ -168,15 +168,16 @@ func checkHosts(r *Report, cfg *config.Config) { return } - if cfg.CurrentHost == "" { + activeHost := cfg.InvocationHost() + if activeHost == "" { r.add("warn", "current host", "no current host set", "Run 'atl config use-context '") - } else if cfg.GetHost(cfg.CurrentHost) == nil { + } else if cfg.GetHost(activeHost) == nil { r.add("error", "current host", - fmt.Sprintf("current host %q is not in the hosts list", cfg.CurrentHost), + fmt.Sprintf("current host %q is not in the hosts list", activeHost), "Run 'atl config use-context ' with a configured host") } else { - r.add("ok", "current host", cfg.CurrentHost, "") + r.add("ok", "current host", activeHost, "") } for hostname, hostCfg := range cfg.Hosts { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 88e8752..b6f9c12 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -32,7 +32,6 @@ func Execute(ios *iostreams.IOStreams, version string) int { func NewRootCmd(ios *iostreams.IOStreams, version string) *cobra.Command { commit, date := vcsInfo() var contextName string - var restoreContext func() cmd := &cobra.Command{ Use: "atl", Short: "Atlassian CLI - Work with Jira and Confluence from the command line", @@ -50,28 +49,6 @@ Environment variables: SilenceUsage: true, SilenceErrors: true, Version: version, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if contextName == "" { - return nil - } - previous, existed := os.LookupEnv("ATLASSIAN_CONTEXT") - if err := os.Setenv("ATLASSIAN_CONTEXT", contextName); err != nil { - return err - } - restoreContext = func() { - if existed { - _ = os.Setenv("ATLASSIAN_CONTEXT", previous) - } else { - _ = os.Unsetenv("ATLASSIAN_CONTEXT") - } - } - return nil - }, - PersistentPostRun: func(cmd *cobra.Command, args []string) { - if restoreContext != nil { - restoreContext() - } - }, } cmd.PersistentFlags().StringVar(&contextName, "context", "", "Atlassian host or alias for this invocation") @@ -98,10 +75,47 @@ Environment variables: cmd.AddCommand(deprecatedAlias(issueCmd.NewCmdIssue(ios), ios, "jira issue")) cmd.AddCommand(deprecatedAlias(boardCmd.NewCmdBoard(ios), ios, "jira board")) cmd.AddCommand(deprecatedAlias(smCmd.NewCmdSM(ios), ios, "jira sm")) + wrapInvocationContext(cmd, &contextName) return cmd } +func wrapInvocationContext(parent *cobra.Command, contextName *string) { + if parent.RunE != nil { + runE := parent.RunE + parent.RunE = func(cmd *cobra.Command, args []string) error { + return runWithInvocationContext(*contextName, func() error { return runE(cmd, args) }) + } + } else if parent.Run != nil { + run := parent.Run + parent.Run = nil + parent.RunE = func(cmd *cobra.Command, args []string) error { + return runWithInvocationContext(*contextName, func() error { run(cmd, args); return nil }) + } + } + for _, child := range parent.Commands() { + wrapInvocationContext(child, contextName) + } +} + +func runWithInvocationContext(contextName string, run func() error) error { + if contextName == "" { + return run() + } + previous, existed := os.LookupEnv("ATLASSIAN_CONTEXT") + if err := os.Setenv("ATLASSIAN_CONTEXT", contextName); err != nil { + return err + } + defer func() { + if existed { + _ = os.Setenv("ATLASSIAN_CONTEXT", previous) + } else { + _ = os.Unsetenv("ATLASSIAN_CONTEXT") + } + }() + return run() +} + // deprecatedAlias hides a relocated command and warns once on use. // // The warning wraps each leaf's PreRun rather than the parent's PersistentPreRun: diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 7490154..4f2edc0 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -2,6 +2,8 @@ package cmd import ( "bytes" + "errors" + "fmt" "os" "strings" "testing" @@ -65,7 +67,9 @@ func TestDeprecationWarning(t *testing.T) { func TestContextFlagSetsInvocationOverride(t *testing.T) { t.Setenv("ATLASSIAN_CONTEXT", "from-environment") - root := NewRootCmd(iostreams.Test(), "test") + root := &cobra.Command{Use: "atl"} + var contextName string + root.PersistentFlags().StringVar(&contextName, "context", "", "") var during string root.AddCommand(&cobra.Command{ Use: "capture-context", @@ -73,6 +77,7 @@ func TestContextFlagSetsInvocationOverride(t *testing.T) { during = os.Getenv("ATLASSIAN_CONTEXT") }, }) + wrapInvocationContext(root, &contextName) root.SetArgs([]string{"capture-context", "--context", "sandbox"}) if err := root.Execute(); err != nil { @@ -86,19 +91,20 @@ func TestContextFlagSetsInvocationOverride(t *testing.T) { } } -func TestSubcommandsDoNotOverrideRootPersistentHooks(t *testing.T) { - root := NewRootCmd(iostreams.Test(), "test") - var check func(*cobra.Command) - check = func(parent *cobra.Command) { - for _, child := range parent.Commands() { - if child.PersistentPreRun != nil || child.PersistentPreRunE != nil || - child.PersistentPostRun != nil || child.PersistentPostRunE != nil { - t.Errorf("%s overrides root invocation-context hooks", child.CommandPath()) - } - check(child) +func TestContextFlagRestoresOverrideAfterError(t *testing.T) { + t.Setenv("ATLASSIAN_CONTEXT", "from-environment") + wantErr := fmt.Errorf("command failed") + if err := runWithInvocationContext("sandbox", func() error { + if got := os.Getenv("ATLASSIAN_CONTEXT"); got != "sandbox" { + t.Fatalf("ATLASSIAN_CONTEXT during command = %q, want sandbox", got) } + return wantErr + }); !errors.Is(err, wantErr) { + t.Fatalf("runWithInvocationContext() error = %v, want %v", err, wantErr) + } + if got := os.Getenv("ATLASSIAN_CONTEXT"); got != "from-environment" { + t.Fatalf("ATLASSIAN_CONTEXT after error = %q, want restored value", got) } - check(root) } // findChild returns the direct subcommand with the given name, including hidden ones. From e7c75bf60c52f5eb107c83183fb5f6defe2535d7 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Thu, 27 Aug 2026 13:18:56 +0200 Subject: [PATCH 6/8] fix: harden assets context boundaries --- AGENTS.md | 2 +- internal/api/assets.go | 7 +++++++ internal/api/assets_test.go | 13 +++++++++++++ internal/api/client.go | 15 +++++++++++++++ internal/api/client_test.go | 9 +++++++++ internal/cmd/assets/assets.go | 7 +++++++ internal/cmd/assets/object.go | 20 +++++++++++++++----- internal/cmd/assets/object_test.go | 6 ++++++ 8 files changed, 73 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1745e0e..b7f8950 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -379,7 +379,7 @@ atl --context prod jira issue view PROJ-1234 # Update it atl --context prod jira issue edit PROJ-1234 --assignee @me atl --context prod jira issue transition PROJ-1234 "In Progress" -atl --context prod jira issue comment PROJ-1234 --body "Starting work on this" +atl --context prod jira issue comment add PROJ-1234 --body "Starting work on this" ``` ### Create a Linked Issue diff --git a/internal/api/assets.go b/internal/api/assets.go index 9d127a6..847fbed 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -141,6 +141,10 @@ func (c *AssetsClient) AQLPage(ctx context.Context, ql string, startAt, maxResul // Object loads an Assets object and all attributes returned by the API. func (c *AssetsClient) Object(ctx context.Context, objectID string) (*AssetObject, error) { + workspaceID, err := c.WorkspaceID(ctx) + if err != nil { + return nil, err + } base, err := c.v1(ctx) if err != nil { return nil, err @@ -149,6 +153,9 @@ func (c *AssetsClient) Object(ctx context.Context, objectID string) (*AssetObjec if err := c.do(ctx, http.MethodGet, base+"/object/"+url.PathEscape(objectID), nil, &object); err != nil { return nil, err } + if object.WorkspaceID != "" && object.WorkspaceID != workspaceID { + return nil, fmt.Errorf("assets object %s belongs to workspace %s, expected %s", objectID, object.WorkspaceID, workspaceID) + } return &object, nil } diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index 106adc3..dab64ae 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -144,3 +144,16 @@ func TestAssetsObjectIncludesAttributes(t *testing.T) { t.Fatalf("display value = %q, want 145166", got) } } + +func TestAssetsObjectRejectsWorkspaceMismatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"9244","workspaceId":"other-workspace"}`)) + })) + defer server.Close() + + client := newTestAssetsClient(server, "workspace-456") + if _, err := client.Object(context.Background(), "9244"); err == nil { + t.Fatal("Object() succeeded for a different workspace") + } +} diff --git a/internal/api/client.go b/internal/api/client.go index 2e96b54..fcdea3d 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -158,6 +158,21 @@ func (c *Client) CloudID() string { return c.cloudID } +// MissingScopes returns required OAuth scopes absent from the stored token. +func (c *Client) MissingScopes(required ...string) []string { + granted := make(map[string]struct{}, len(c.tokens.Scopes)) + for _, scope := range c.tokens.Scopes { + granted[scope] = struct{}{} + } + missing := make([]string, 0, len(required)) + for _, scope := range required { + if _, ok := granted[scope]; !ok { + missing = append(missing, scope) + } + } + return missing +} + // BaseURL returns the base URL for Jira API requests. func (c *Client) JiraBaseURL() string { return fmt.Sprintf("%s/ex/jira/%s/rest/api/3", AtlassianAPIURL, c.cloudID) diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 3996289..2b27de2 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -346,6 +346,15 @@ func TestClientAccessors(t *testing.T) { } } +func TestClientMissingScopes(t *testing.T) { + client := &Client{tokens: &auth.TokenSet{Scopes: []string{"read:cmdb-object:jira"}}} + + got := client.MissingScopes("read:cmdb-object:jira", "read:cmdb-schema:jira") + if len(got) != 1 || got[0] != "read:cmdb-schema:jira" { + t.Fatalf("MissingScopes() = %#v, want read:cmdb-schema:jira", got) + } +} + // Helper function to check string containment func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr, 0)) diff --git a/internal/cmd/assets/assets.go b/internal/cmd/assets/assets.go index a7bccda..f4bd26a 100644 --- a/internal/cmd/assets/assets.go +++ b/internal/cmd/assets/assets.go @@ -1,7 +1,9 @@ package assets import ( + "fmt" "os" + "strings" "github.com/spf13/cobra" @@ -29,6 +31,11 @@ func (o *commonOptions) client() (*api.AssetsClient, error) { if err != nil { return nil, err } + missing := client.MissingScopes("read:cmdb-object:jira", "read:cmdb-schema:jira") + if len(missing) > 0 { + return nil, fmt.Errorf("OAuth token is missing Assets scopes %s; re-authenticate with 'atl auth login --hostname %s'", + strings.Join(missing, ", "), client.Hostname()) + } return api.NewAssetsClient(client, workspace), nil } diff --git a/internal/cmd/assets/object.go b/internal/cmd/assets/object.go index 88bdacd..6a70eda 100644 --- a/internal/cmd/assets/object.go +++ b/internal/cmd/assets/object.go @@ -3,6 +3,7 @@ package assets import ( "fmt" "strings" + "unicode" "github.com/spf13/cobra" @@ -15,14 +16,23 @@ func attributeValues(values []api.AssetAttributeValue) []string { formatted := make([]string, 0, len(values)) for _, value := range values { if value.DisplayValue != "" { - formatted = append(formatted, value.DisplayValue) + formatted = append(formatted, terminalText(value.DisplayValue)) } else if value.Value != nil { - formatted = append(formatted, fmt.Sprint(value.Value)) + formatted = append(formatted, terminalText(fmt.Sprint(value.Value))) } } return formatted } +func terminalText(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, value) +} + func newCmdObject(ios *iostreams.IOStreams, common *commonOptions) *cobra.Command { var jsonOut bool @@ -46,12 +56,12 @@ func newCmdObject(ios *iostreams.IOStreams, common *commonOptions) *cobra.Comman return output.JSON(ios.Out, object) } - fmt.Fprintf(ios.Out, "%s\t%s\t%s\n", object.ObjectKey, object.ObjectType.Name, strings.TrimSpace(object.Label)) + fmt.Fprintf(ios.Out, "%s\t%s\t%s\n", terminalText(object.ObjectKey), terminalText(object.ObjectType.Name), strings.TrimSpace(terminalText(object.Label))) rows := make([][]string, 0, len(object.Attributes)) for _, attribute := range object.Attributes { - name := attribute.ObjectTypeAttribute.Name + name := terminalText(attribute.ObjectTypeAttribute.Name) if name == "" { - name = attribute.ObjectTypeAttributeID + name = terminalText(attribute.ObjectTypeAttributeID) } values := attributeValues(attribute.ObjectAttributeValues) rows = append(rows, []string{name, strings.Join(values, ", ")}) diff --git a/internal/cmd/assets/object_test.go b/internal/cmd/assets/object_test.go index 72fd8c0..b1f9588 100644 --- a/internal/cmd/assets/object_test.go +++ b/internal/cmd/assets/object_test.go @@ -19,3 +19,9 @@ func TestAttributeValuesSkipsNullValues(t *testing.T) { t.Fatalf("attributeValues() = %#v, want %#v", got, want) } } + +func TestTerminalTextReplacesControlCharacters(t *testing.T) { + if got, want := terminalText("Customer\n\x1b]52;c;payload\a"), "Customer ]52;c;payload "; got != want { + t.Fatalf("terminalText() = %q, want %q", got, want) + } +} From 52d3db442bc3fa058745c1f6870d2a32ee76469b Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Thu, 27 Aug 2026 13:39:43 +0200 Subject: [PATCH 7/8] fix: finalize assets context safety --- AGENTS.md | 2 +- README.md | 2 +- internal/api/assets.go | 30 +++++++++++++++++++++++---- internal/api/assets_test.go | 28 +++++++++++++++++++++++++ internal/api/client.go | 9 ++++++-- internal/api/client_test.go | 6 +++--- internal/auth/oauth.go | 9 ++++++-- internal/cmd/assets/assets.go | 8 -------- internal/cmd/assets/object.go | 9 ++++++-- internal/cmd/assets/object_test.go | 12 +++++++++-- internal/cmd/root.go | 31 ++++++++++++++-------------- internal/cmd/root_test.go | 33 ++++++++++++++++++------------ 12 files changed, 125 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b7f8950..95a9317 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,9 +11,9 @@ Jira commands live under `atl jira` (`atl jira issue`, `atl jira board`, `atl ji ## Authentication ```bash -atl auth status --hostname prod # Check one authenticated site atl auth login --hostname enthus.atlassian.net # Authenticate one explicit site atl config set-alias prod enthus.atlassian.net # Name the host used below +atl auth status --hostname prod # Check one authenticated site ``` ## Context Switching (Multi-Environment) diff --git a/README.md b/README.md index 73a4f0d..cf5c4d8 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ atl --context prod jira issue create --project PROJ --type Task --summary "Featu **Important**: See [docs](https://example.com) for details." # Add comment with markdown -atl --context prod jira issue comment PROJ-1234 --body "## Summary +atl --context prod jira issue comment add PROJ-1234 --body "## Summary Fixed the **critical** bug in \`main.go\`. diff --git a/internal/api/assets.go b/internal/api/assets.go index 847fbed..8a66b42 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -2,9 +2,13 @@ package api import ( "context" + "encoding/json" "fmt" "net/http" "net/url" + "strings" + + "github.com/enthus-appdev/atl-cli/internal/auth" ) // AssetsClient talks to the Jira Service Management Assets (CMDB) REST API. @@ -23,7 +27,7 @@ func NewAssetsClient(client *Client, workspaceID string) *AssetsClient { return &AssetsClient{ client: client, workspaceID: workspaceID, - baseURL: fmt.Sprintf("%s/ex/jira/%s", AtlassianAPIURL, client.CloudID()), + baseURL: client.JiraGatewayBaseURL(), } } @@ -31,6 +35,15 @@ func (c *AssetsClient) do(ctx context.Context, method, fullURL string, body, out return c.client.Request(ctx, method, fullURL, body, out) } +func (c *AssetsClient) requireScopes(required ...string) error { + missing := c.client.MissingScopes(required...) + if len(missing) == 0 { + return nil + } + return fmt.Errorf("OAuth token is missing Assets scopes %s; re-authenticate with 'atl auth login --hostname %s'", + strings.Join(missing, ", "), c.client.Hostname()) +} + // WorkspaceID returns the resolved workspace id, discovering it from the site if // it was not supplied. func (c *AssetsClient) WorkspaceID(ctx context.Context) (string, error) { @@ -71,6 +84,9 @@ type AssetSchema struct { // Schemas returns all object schemas in the workspace. func (c *AssetsClient) Schemas(ctx context.Context) ([]AssetSchema, error) { + if err := c.requireScopes(auth.AssetsSchemaReadScope); err != nil { + return nil, err + } base, err := c.v1(ctx) if err != nil { return nil, err @@ -112,9 +128,9 @@ type AssetAttribute struct { // AssetAttributeValue preserves both the API value and its display form. type AssetAttributeValue struct { - Value interface{} `json:"value,omitempty"` - DisplayValue string `json:"displayValue,omitempty"` - SearchValue string `json:"searchValue,omitempty"` + Value json.RawMessage `json:"value,omitempty"` + DisplayValue string `json:"displayValue,omitempty"` + SearchValue string `json:"searchValue,omitempty"` } type aqlPage struct { @@ -124,6 +140,9 @@ type aqlPage struct { // AQLPage runs an AQL query and returns one page of objects. func (c *AssetsClient) AQLPage(ctx context.Context, ql string, startAt, maxResults int) ([]AssetObject, bool, error) { + if err := c.requireScopes(auth.AssetsObjectReadScope); err != nil { + return nil, false, err + } base, err := c.v1(ctx) if err != nil { return nil, false, err @@ -141,6 +160,9 @@ func (c *AssetsClient) AQLPage(ctx context.Context, ql string, startAt, maxResul // Object loads an Assets object and all attributes returned by the API. func (c *AssetsClient) Object(ctx context.Context, objectID string) (*AssetObject, error) { + if err := c.requireScopes(auth.AssetsObjectReadScope); err != nil { + return nil, err + } workspaceID, err := c.WorkspaceID(ctx) if err != nil { return nil, err diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index dab64ae..4d7a578 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -20,6 +20,7 @@ func newTestAssetsClient(server *httptest.Server, workspaceID string) *AssetsCli tokens: &auth.TokenSet{ AccessToken: "test-token", ExpiresAt: time.Now().Add(time.Hour), + Scopes: []string{auth.AssetsObjectReadScope, auth.AssetsSchemaReadScope}, }, } return &AssetsClient{ @@ -45,6 +46,33 @@ func TestNewAssetsClientUsesCloudGateway(t *testing.T) { } } +func TestAssetsScopesAreOperationSpecific(t *testing.T) { + client := &AssetsClient{client: &Client{ + hostname: "test.atlassian.net", + tokens: &auth.TokenSet{Scopes: []string{auth.AssetsObjectReadScope}}, + }} + if err := client.requireScopes(auth.AssetsObjectReadScope); err != nil { + t.Fatalf("object scope rejected: %v", err) + } + if err := client.requireScopes(auth.AssetsSchemaReadScope); err == nil { + t.Fatal("missing schema scope accepted") + } +} + +func TestAssetAttributeValuePreservesLargeNumber(t *testing.T) { + var value AssetAttributeValue + if err := json.Unmarshal([]byte(`{"value":9007199254740993}`), &value); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if got, want := string(data), `{"value":9007199254740993}`; got != want { + t.Fatalf("round trip = %s, want %s", got, want) + } +} + func TestAssetsWorkspaceIDUsesOAuthGateway(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { requireBearer(t, request) diff --git a/internal/api/client.go b/internal/api/client.go index fcdea3d..b264311 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -173,9 +173,14 @@ func (c *Client) MissingScopes(required ...string) []string { return missing } -// BaseURL returns the base URL for Jira API requests. +// JiraGatewayBaseURL returns the cloud gateway root for this Jira site. +func (c *Client) JiraGatewayBaseURL() string { + return fmt.Sprintf("%s/ex/jira/%s", AtlassianAPIURL, c.cloudID) +} + +// JiraBaseURL returns the base URL for Jira API requests. func (c *Client) JiraBaseURL() string { - return fmt.Sprintf("%s/ex/jira/%s/rest/api/3", AtlassianAPIURL, c.cloudID) + return c.JiraGatewayBaseURL() + "/rest/api/3" } // ConfluenceBaseURL returns the base URL for Confluence API requests. diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 2b27de2..b92f680 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -347,10 +347,10 @@ func TestClientAccessors(t *testing.T) { } func TestClientMissingScopes(t *testing.T) { - client := &Client{tokens: &auth.TokenSet{Scopes: []string{"read:cmdb-object:jira"}}} + client := &Client{tokens: &auth.TokenSet{Scopes: []string{auth.AssetsObjectReadScope}}} - got := client.MissingScopes("read:cmdb-object:jira", "read:cmdb-schema:jira") - if len(got) != 1 || got[0] != "read:cmdb-schema:jira" { + got := client.MissingScopes(auth.AssetsObjectReadScope, auth.AssetsSchemaReadScope) + if len(got) != 1 || got[0] != auth.AssetsSchemaReadScope { t.Fatalf("MissingScopes() = %#v, want read:cmdb-schema:jira", got) } } diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 08e3f04..33bef12 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -14,6 +14,11 @@ import ( "time" ) +const ( + AssetsObjectReadScope = "read:cmdb-object:jira" + AssetsSchemaReadScope = "read:cmdb-schema:jira" +) + const ( // AtlassianAuthURL is the authorization endpoint for Atlassian OAuth. AtlassianAuthURL = "https://auth.atlassian.com/authorize" @@ -79,8 +84,8 @@ func DefaultScopes() []string { // not a grantable Atlassian scope and is silently dropped from the token. "read:servicedesk-request", // Jira Assets scopes - AQL/object reads and schema counts. - "read:cmdb-object:jira", - "read:cmdb-schema:jira", + AssetsObjectReadScope, + AssetsSchemaReadScope, // Token refresh "offline_access", } diff --git a/internal/cmd/assets/assets.go b/internal/cmd/assets/assets.go index f4bd26a..ab34e37 100644 --- a/internal/cmd/assets/assets.go +++ b/internal/cmd/assets/assets.go @@ -1,9 +1,7 @@ package assets import ( - "fmt" "os" - "strings" "github.com/spf13/cobra" @@ -31,12 +29,6 @@ func (o *commonOptions) client() (*api.AssetsClient, error) { if err != nil { return nil, err } - missing := client.MissingScopes("read:cmdb-object:jira", "read:cmdb-schema:jira") - if len(missing) > 0 { - return nil, fmt.Errorf("OAuth token is missing Assets scopes %s; re-authenticate with 'atl auth login --hostname %s'", - strings.Join(missing, ", "), client.Hostname()) - } - return api.NewAssetsClient(client, workspace), nil } diff --git a/internal/cmd/assets/object.go b/internal/cmd/assets/object.go index 6a70eda..520416c 100644 --- a/internal/cmd/assets/object.go +++ b/internal/cmd/assets/object.go @@ -1,6 +1,7 @@ package assets import ( + "encoding/json" "fmt" "strings" "unicode" @@ -17,8 +18,12 @@ func attributeValues(values []api.AssetAttributeValue) []string { for _, value := range values { if value.DisplayValue != "" { formatted = append(formatted, terminalText(value.DisplayValue)) - } else if value.Value != nil { - formatted = append(formatted, terminalText(fmt.Sprint(value.Value))) + } else if len(value.Value) > 0 && string(value.Value) != "null" { + var text string + if err := json.Unmarshal(value.Value, &text); err != nil { + text = string(value.Value) + } + formatted = append(formatted, terminalText(text)) } } return formatted diff --git a/internal/cmd/assets/object_test.go b/internal/cmd/assets/object_test.go index b1f9588..a1e14f9 100644 --- a/internal/cmd/assets/object_test.go +++ b/internal/cmd/assets/object_test.go @@ -1,6 +1,7 @@ package assets import ( + "encoding/json" "reflect" "testing" @@ -9,8 +10,8 @@ import ( func TestAttributeValuesSkipsNullValues(t *testing.T) { values := []api.AssetAttributeValue{ - {DisplayValue: "Customer 145166", Value: "145166"}, - {Value: float64(42)}, + {DisplayValue: "Customer 145166", Value: json.RawMessage(`"145166"`)}, + {Value: json.RawMessage(`42`)}, {}, } @@ -20,6 +21,13 @@ func TestAttributeValuesSkipsNullValues(t *testing.T) { } } +func TestAttributeValuesPreservesLargeNumber(t *testing.T) { + values := []api.AssetAttributeValue{{Value: json.RawMessage(`9007199254740993`)}} + if got, want := attributeValues(values), []string{"9007199254740993"}; !reflect.DeepEqual(got, want) { + t.Fatalf("attributeValues() = %#v, want %#v", got, want) + } +} + func TestTerminalTextReplacesControlCharacters(t *testing.T) { if got, want := terminalText("Customer\n\x1b]52;c;payload\a"), "Customer ]52;c;payload "; got != want { t.Fatalf("terminalText() = %q, want %q", got, want) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index b6f9c12..712dc07 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "runtime/debug" + "strings" "github.com/spf13/cobra" @@ -21,7 +22,8 @@ import ( // Execute runs the root command and returns an exit code. func Execute(ios *iostreams.IOStreams, version string) int { rootCmd := NewRootCmd(ios, version) - if err := rootCmd.Execute(); err != nil { + err := runWithInvocationContext(invocationContextArg(os.Args[1:]), rootCmd.Execute) + if err != nil { fmt.Fprintf(ios.ErrOut, "Error: %s\n", err) return 1 } @@ -75,27 +77,24 @@ Environment variables: cmd.AddCommand(deprecatedAlias(issueCmd.NewCmdIssue(ios), ios, "jira issue")) cmd.AddCommand(deprecatedAlias(boardCmd.NewCmdBoard(ios), ios, "jira board")) cmd.AddCommand(deprecatedAlias(smCmd.NewCmdSM(ios), ios, "jira sm")) - wrapInvocationContext(cmd, &contextName) - return cmd } -func wrapInvocationContext(parent *cobra.Command, contextName *string) { - if parent.RunE != nil { - runE := parent.RunE - parent.RunE = func(cmd *cobra.Command, args []string) error { - return runWithInvocationContext(*contextName, func() error { return runE(cmd, args) }) +func invocationContextArg(args []string) string { + var contextName string + for i := 0; i < len(args); i++ { + if args[i] == "--" { + break } - } else if parent.Run != nil { - run := parent.Run - parent.Run = nil - parent.RunE = func(cmd *cobra.Command, args []string) error { - return runWithInvocationContext(*contextName, func() error { run(cmd, args); return nil }) + switch { + case args[i] == "--context" && i+1 < len(args): + contextName = args[i+1] + i++ + case strings.HasPrefix(args[i], "--context="): + contextName = strings.TrimPrefix(args[i], "--context=") } } - for _, child := range parent.Commands() { - wrapInvocationContext(child, contextName) - } + return contextName } func runWithInvocationContext(contextName string, run func() error) error { diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 4f2edc0..dd415b5 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -67,20 +67,11 @@ func TestDeprecationWarning(t *testing.T) { func TestContextFlagSetsInvocationOverride(t *testing.T) { t.Setenv("ATLASSIAN_CONTEXT", "from-environment") - root := &cobra.Command{Use: "atl"} - var contextName string - root.PersistentFlags().StringVar(&contextName, "context", "", "") var during string - root.AddCommand(&cobra.Command{ - Use: "capture-context", - Run: func(cmd *cobra.Command, args []string) { - during = os.Getenv("ATLASSIAN_CONTEXT") - }, - }) - wrapInvocationContext(root, &contextName) - root.SetArgs([]string{"capture-context", "--context", "sandbox"}) - - if err := root.Execute(); err != nil { + if err := runWithInvocationContext(invocationContextArg([]string{"jira", "--context", "sandbox", "issue", "list"}), func() error { + during = os.Getenv("ATLASSIAN_CONTEXT") + return nil + }); err != nil { t.Fatalf("execute with --context: %v", err) } if during != "sandbox" { @@ -107,6 +98,22 @@ func TestContextFlagRestoresOverrideAfterError(t *testing.T) { } } +func TestInvocationContextArg(t *testing.T) { + for _, test := range []struct { + args []string + want string + }{ + {args: []string{"--context", "prod", "jira", "issue", "list"}, want: "prod"}, + {args: []string{"jira", "--context=sandbox", "assets", "count"}, want: "sandbox"}, + {args: []string{"jira", "issue", "create", "--", "--context=positional"}, want: ""}, + {args: []string{"jira", "issue", "list"}, want: ""}, + } { + if got := invocationContextArg(test.args); got != test.want { + t.Errorf("invocationContextArg(%q) = %q, want %q", test.args, got, test.want) + } + } +} + // findChild returns the direct subcommand with the given name, including hidden ones. func findChild(parent *cobra.Command, name string) *cobra.Command { for _, c := range parent.Commands() { From 76dd99e4f69ca7862bc65236f78f22145681e2dd Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Thu, 27 Aug 2026 13:59:47 +0200 Subject: [PATCH 8/8] fix: parse context with cobra semantics --- internal/cmd/root.go | 26 ++++++++++---------------- internal/cmd/root_test.go | 20 ++++++++++++++++---- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 712dc07..04532a5 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "runtime/debug" - "strings" "github.com/spf13/cobra" @@ -22,7 +21,8 @@ import ( // Execute runs the root command and returns an exit code. func Execute(ios *iostreams.IOStreams, version string) int { rootCmd := NewRootCmd(ios, version) - err := runWithInvocationContext(invocationContextArg(os.Args[1:]), rootCmd.Execute) + contextName, _ := parsedInvocationContext(rootCmd, os.Args[1:]) + err := runWithInvocationContext(contextName, rootCmd.Execute) if err != nil { fmt.Fprintf(ios.ErrOut, "Error: %s\n", err) return 1 @@ -80,21 +80,15 @@ Environment variables: return cmd } -func invocationContextArg(args []string) string { - var contextName string - for i := 0; i < len(args); i++ { - if args[i] == "--" { - break - } - switch { - case args[i] == "--context" && i+1 < len(args): - contextName = args[i+1] - i++ - case strings.HasPrefix(args[i], "--context="): - contextName = strings.TrimPrefix(args[i], "--context=") - } +func parsedInvocationContext(root *cobra.Command, args []string) (string, error) { + command, commandArgs, err := root.Find(args) + if err != nil { + return "", err + } + if err := command.ParseFlags(commandArgs); err != nil { + return "", err } - return contextName + return command.Flags().GetString("context") } func runWithInvocationContext(contextName string, run func() error) error { diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index dd415b5..3ac22f3 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -67,8 +67,13 @@ func TestDeprecationWarning(t *testing.T) { func TestContextFlagSetsInvocationOverride(t *testing.T) { t.Setenv("ATLASSIAN_CONTEXT", "from-environment") + root := NewRootCmd(iostreams.Test(), "test") + contextName, err := parsedInvocationContext(root, []string{"jira", "--context", "sandbox", "issue", "list"}) + if err != nil { + t.Fatalf("parse --context: %v", err) + } var during string - if err := runWithInvocationContext(invocationContextArg([]string{"jira", "--context", "sandbox", "issue", "list"}), func() error { + if err := runWithInvocationContext(contextName, func() error { during = os.Getenv("ATLASSIAN_CONTEXT") return nil }); err != nil { @@ -98,18 +103,25 @@ func TestContextFlagRestoresOverrideAfterError(t *testing.T) { } } -func TestInvocationContextArg(t *testing.T) { +func TestParsedInvocationContext(t *testing.T) { for _, test := range []struct { args []string want string }{ {args: []string{"--context", "prod", "jira", "issue", "list"}, want: "prod"}, {args: []string{"jira", "--context=sandbox", "assets", "count"}, want: "sandbox"}, + {args: []string{"jira", "issue", "comment", "add", "NX-1", "--body", "--context=positional"}, want: ""}, {args: []string{"jira", "issue", "create", "--", "--context=positional"}, want: ""}, {args: []string{"jira", "issue", "list"}, want: ""}, } { - if got := invocationContextArg(test.args); got != test.want { - t.Errorf("invocationContextArg(%q) = %q, want %q", test.args, got, test.want) + root := NewRootCmd(iostreams.Test(), "test") + got, err := parsedInvocationContext(root, test.args) + if err != nil { + t.Errorf("parsedInvocationContext(%q) error: %v", test.args, err) + continue + } + if got != test.want { + t.Errorf("parsedInvocationContext(%q) = %q, want %q", test.args, got, test.want) } } }