From 08db9f58f3a4d727c2c8a1cecd5ae1967fb7f3dc Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Wed, 4 Nov 2026 22:18:57 +0100 Subject: [PATCH] feat(jira): tell how to replace a token that Jira refuses Jira's API answers 401 Unauthorized when the credentials are incorrect or missing, as they are once a token expires, which a scoped token does within a year. baton jira now names the email that Jira refused and gives the command that replaces the token in the keychain, `security add-generic-password -U`, with the email quoted for the shell. The client marks a 401 to the gateway's logged-in requests as a LoginError. The site's 401, to the cloud ID's request without the login, stays unmarked, since no token caused it. The user chose to skip a live check of Jira, so fact 41 now says that whether tenant_info answers without a login stays unchecked. Plan row 66 is done. --- README.md | 2 +- docs/plan.md | 7 ++++--- internal/cli/jira.go | 7 +++++++ internal/cli/jira_test.go | 24 +++++++++++++++++++++--- internal/jira/client.go | 10 +++++++++- internal/jira/fail_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f12f2a6..a61b5d7 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,6 @@ When you name a Jira Cloud ticket, the orchestrator reads it with `baton jira `: the ticket as Markdown, for the orchestrator to plan from, with Jira's site and the user's email from the config file and the read-only token from the Keychain; without the text's control characters; the guard runs it unasked in the orchestrator's pane and denies it in a role's (decisions 6 and 60, fact 42, risk 14) | In review (#43) | +| `jira` | `baton jira `: the ticket as Markdown, for the orchestrator to plan from, with Jira's site and the user's email from the config file and the read-only token from the Keychain; without the text's control characters; the guard runs it unasked in the orchestrator's pane and denies it in a role's (decisions 6 and 60, fact 42, risk 14) | Done (#43) | +| `jira-login` | `baton jira`'s answer to a login that Jira refuses (401): the command that replaces the token in the Keychain, as tokens expire within a year (decision 60, fact 42) | In review (#44) | | `deliver-github` | `baton deliver` for GitHub: push the branch and open the pull request with `gh`, the lead's final report as its body | Todo | | `close` | `baton feature close `: ends the feature's session; keeps its branch and, unless asked, its worktree | Todo | | `deliver-bitbucket` | `baton deliver` for Bitbucket Cloud: push the branch and open the pull request with the REST API, a token in the Keychain | Todo | @@ -140,7 +141,7 @@ Checked on 2026-10-31 with Copilot CLI 1.0.105 and tmux 3.8, unless a fact says 38. **What makes git's pushes and gh fail** (git 2.56.0, gh 2.103.2 and Copilot CLI 1.0.105, on 2026-11-04, in throwaway folders, against `.invalid` hosts and local paths). git reads the settings that `GIT_CONFIG_COUNT` counts in `GIT_CONFIG_KEY_` and `GIT_CONFIG_VALUE_` after its config files, and those of `git -c` after them; it reads the count as C's `strtoul` does, so it takes ` 1` and `+1`, and fails with 128 on `x`, and on `-1` (`too many entries`). A push to a remote's URL takes the `url..pushInsteadOf` rule whose prefix matches the most of the URL, and of equal ones the rule whose base git read first, or else an `insteadOf` rule; a remote's push URL (`remote..pushurl`) skips the `pushInsteadOf` rules, but not the `insteadOf` ones. Since git 2.46 (its release notes), an empty `pushurl` clears the remote's push URLs that git read before it, so its pushes go to its URL; an older git takes it for one more push URL, and still pushes to the others. git takes a URL for a path on the machine when it has no colon, or a slash before its first colon, or starts with `file://`; `https://…`, `ssh://…`, `git@host:repo`, `host:repo` and `[::1]:repo` leave the machine. A push to `::`, whose remote helper `git-remote-` isn't there, fails with 128 and `git: 'remote-' is not a git command`, before any network. `git send-pack` takes its URL as it is, with no rule: it went on to look the host up. `git config --get-regexp` exits with 1 when no setting matches. Copilot's `bash` tool adds three settings of its own after those that it inherits, `safe.bareRepository=explicit`, `credential.interactive=never` and `core.fsmonitor=false`, and raises `GIT_CONFIG_COUNT` to match; it keeps `GIT_CONFIG_PARAMETERS`, and reads `BASH_ENV` only with its `bashEnv` setting, off by default, which `--bash-env` saves in the user's config (`copilot help config`). With `GH_CONFIG_DIR` under a file, such as `/dev/null/x`, every `gh` command, `--version` and `auth` too, fails before it starts, with 1 and `failed to create root command: failed to read configuration: open /dev/null/x/config.yml: not a directory`, and writes nothing; with an empty folder, gh still takes the user's login from the Keychain (gh's source). Copilot CLI takes its GitHub token from `COPILOT_GITHUB_TOKEN`, then `GH_TOKEN`, then `GITHUB_TOKEN`, then its own login, which `copilot login` keeps in the Keychain, and last from `gh auth token` (`copilot help environment`, and GitHub's docs on its authentication). The belt (decision 55) was checked with `baton feature start` on a tmux server of its own, with a stand-in `copilot`, and once with the real one, offline (`COPILOT_OFFLINE`) with a stand-in model on 127.0.0.1, for 0 AI credits. In the lead's pane, pushes to `origin`, whose push URL the belt had cleared, and to typed `https` and scp-like URLs failed, pushes to a local remote and to a typed path went through, `gh --version` failed, and copilot's `bash` added its three settings after the belt's; the orchestrator's pane kept `GH_TOKEN`. 39. **The Atlassian Document Format** (Atlassian's docs, and its `@atlaskit/adf-schema` and `@atlaskit/editor-common` packages on npm, on 2026-11-04). Jira's REST API v3 gives a ticket's description and comments as ADF: a JSON tree of nodes, each with a `type`, and as the type needs, `content`, `attrs`, `text` and `marks`. ADF's JSON schema lists ten inline nodes, which flow in a line of text: `text`, `date`, `emoji`, `hardBreak`, `inlineCard`, `mention`, `placeholder`, `status`, `inlineExtension` and `mediaInline`; the rest are blocks. A task item, a decision item and a media's caption hold inline nodes as they are, not in a paragraph, and a task list holds task items, block task items and task lists. A `date`'s `timestamp` is a string of milliseconds since the epoch, which Atlassian's own renderer shows as the day in UTC (`timestampToIsoFormat`), though the docs' example, `1582152559`, reads as seconds. A `mention`, a `status` and an extension carry their text in `attrs.text`, an `emoji` in `attrs.text` or `attrs.shortName`, a card its address in `attrs.url`, and a `media` its alternative text in `attrs.alt`, and an external one its address in `attrs.url`. 40. **A ticket in Jira's REST API v3** (Jira Cloud's OpenAPI spec and the API's introduction, on 2026-11-04). `GET /rest/api/3/issue/` gives the ticket's `key`, and the `fields` that its `fields` parameter names, comma-separated. A ticket's `issuetype` and `status` each hold a `name`, and its `issuelinks` hold each link's `type`, with the text for either side, such as `is blocked by` (`inward`) and `blocks` (`outward`), and the other ticket, as `inwardIssue` or `outwardIssue`, with its `key` and its `fields`, `summary` and `status` among them. The spec gives no shape for the `parent` and `subtasks` fields, which Jira's clients read as such tickets too, with a key and fields (ankitpokhrel/jira-cli's types). `GET /rest/api/3/issue//comment` gives a page of comments, with `startAt`, `maxResults`, 100 by default, and `total`, which can change between pages, so that a page can come empty; `orderBy=created` gives them oldest first. A comment holds its `author`, with a `displayName`, its `body` in ADF, and `created`, an ISO 8601 time in the site's time zone, such as `2021-01-17T12:34:00.000+0000`. `encoding/json/v2` matches names by their case, so baton spells Jira's: `issuetype` and `issuelinks`, not the `issueType` and `issueLinks` of some clients, which `encoding/json` matches whatever the case. -41. **A site's cloud ID** (Atlassian's support article on finding a site's cloud ID, on 2026-11-04). `https://.atlassian.net/_edge/tenant_info` gives the site's cloud ID, which the gateway's addresses hold (fact 13), as one line of JSON, `{"cloudId":""}`. The article has it opened in a browser, and doesn't say whether a site answers without a login: baton sends none, which a first live check of Jira will show to work, or not. +41. **A site's cloud ID** (Atlassian's support article on finding a site's cloud ID, on 2026-11-04). `https://.atlassian.net/_edge/tenant_info` gives the site's cloud ID, which the gateway's addresses hold (fact 13), as one line of JSON, `{"cloudId":""}`. The article has it opened in a browser, and doesn't say whether a site answers without a login: baton sends none. The user chose to skip a live check of Jira (2026-11-04), so that stays unchecked: a site that wants one fails `baton jira` with `read the cloud ID of :` and the site's answer. 42. **The Keychain's passwords through `security`** (macOS's `/usr/bin/security`, its usage text and a read of an item that isn't there, on 2026-11-04; Atlassian's article on API tokens). `security find-generic-password -s -a -w` prints the password of the item with that service and account, and a newline; for an item that isn't there, it exits with 44 and `security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.` `security add-generic-password -s -a -w`, with `-w` last, asks for the password, so it stays out of the shell's history; `-U` replaces an item that is there, which the command otherwise refuses. "By default, the application which creates an item is trusted to access its data without warning", and `-T ""` takes that trust away: so an item that `security` made, `security` reads without asking, for any program that runs it as the user. Atlassian's API tokens with scopes, which its gateway takes with the email by basic auth (fact 13), expire after 1 to 365 days, as the user picks when they create one. ## 3. Design @@ -290,7 +291,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 57. **A ticket's text as Markdown, through `internal/adf`** (lead's call, flagged; decision 6, fact 39). `internal/adf` renders a ticket's ADF as Markdown for the orchestrator and the user to read, which takes far fewer tokens than the JSON, and which nobody needs to render: so the text stays as Jira has it, with no escapes, but for a `|` in a table's cell. Strong, em, strike, code and links show as Markdown's marks, code innermost and a link outermost, and the other marks, such as underline and colours, leave the text plain; code that holds backticks gets a longer fence. A mention, an emoji, a status, a card and an extension show their text or their address, a date its day in UTC, as Jira shows it, and a file or an image `[attachment]`, with its alternative text or its address, since text can't show it (fact 39). A node without a rule of its own, such as one that a later ADF adds, shows its content, or the text that stands in for it, so that each of a ticket's words reaches the orchestrator; a run of inline nodes in a block makes a paragraph. The blocks keep their shape. A heading keeps its level, from 1 to 6. Lists keep their nesting, and an item's blocks stand on the lines under its marker; a task shows as `- [ ]` or `- [x]`, and a decision as a bullet. A code block keeps its language, in a fence that its code can't end. A quote and a panel show as a quote, without the panel's kind, a rule as `---`, and an expand, which Jira shows folded, as its title in bold above its content. A table becomes a Markdown table: its first row is the header, since a Markdown table needs one; each row is as wide as the widest; and each cell stays on one line, with `
` for a line break and `\|` for a `|`. The converter came to about 240 lines, so it came in two steps, `adf`, the walk and the inline text, and `adf-blocks`, the blocks' structure, ahead of `jira`, so that each stays near 150 lines. Rejected: handing the orchestrator the ADF itself, which costs it many more tokens to read. 58. **A ticket as Markdown, with what the orchestrator plans from** (lead's call, flagged; decisions 6 and 57, fact 40). `internal/jira` gives the orchestrator a ticket as Markdown, as `internal/adf` gives its text: the key and the summary as its title; its type, its status and its parent, if it has one, in a list; then its description, its subtasks, its links and its comments, oldest first, each under a heading of its own, and a part that the ticket lacks not at all, heading and all. A subtask, a link's other ticket and the parent show their key, summary and status, and a link its type from the ticket's side, such as `blocks DEMO-4` or `is blocked by DEMO-5`. A comment's heading names its author, or `Someone` when Jira names nobody, and its time as Jira gives it. Left out for now: the people on the ticket, its priority, labels and sprint, which a plan rarely needs, and a site's own fields, such as acceptance criteria, whose IDs differ from site to site, so that a setting would have to name them. Jira's part came in three steps, `jira-text`, `jira-client` and `jira`, so that each stays near 150 lines. 59. **The token goes to Atlassian's gateway alone** (lead's call, flagged; decision 6, facts 13, 40 and 41). `internal/jira`'s client reads the site's cloud ID from the site's `tenant_info` without a login, takes it only as a UUID, in its usual form, and sends the user's email and token to `api.atlassian.com` alone, so that a wrong site in the config gets no token. It takes a ticket's key in any case, but only as a project's key, a dash and a number, which it sends in capitals, as Jira's keys are, so that a key can't change the address. It asks Jira for the fields that baton shows alone, and reads the comments oldest first, a page at a time, until it has as many as Jira counts, or a page comes empty. It reads at most 16 MiB of an answer. When Jira refuses, the error gives the status and the messages that the answer holds, Jira's `errorMessages` or the gateway's `message`, or the status alone for an answer that isn't JSON, such as a proxy's page. Rejected: the cloud ID in the config file, which the user would have to look up; the first page of comments alone, which would leave out the latest of a long discussion. -60. **`baton jira` reads a ticket as the user, for the orchestrator** (lead's call, flagged; the user's call, 2026-11-04, for its `//nolint:gosec`; decisions 6, 34, 49, 52, 54 and 59, fact 42, risk 14). `baton jira ` prints the ticket and its comments as Markdown (decision 58), for the orchestrator to plan from; the user can run it too. It checks the key first, as the client does (decision 59), so that a mistake in it reaches neither the Keychain nor Jira. The config file's `jira` names the site, by its host name alone, such as `example.atlassian.net`, and the user's email address at Atlassian, each a setting like the others (decision 49), so that a repo's section can name another site or account; the file refuses a site that isn't a host name, such as a URL or a name with a port, and an email that isn't an address alone, such as one with a name before it. `baton jira` reads the settings of its folder's repo, as `baton feature start` does, and refuses a repo whose settings lack either. The token lives in the login Keychain, as a password whose service is `baton-jira` and whose account is the email, which the user adds with `security add-generic-password` (README); baton reads it with `/usr/bin/security`, by that path, so that the `PATH` can't put another program in its place, and never prints it. An item that isn't there fails with the command that adds it, the email quoted for the shell. baton prints the ticket without its control characters, but for newlines and tabs, so that a ticket's text, which anyone who can edit the ticket wrote, can't send escape sequences to a terminal, the user's or the orchestrator's pane's. Its HTTP client gives up on a request after a minute, so a site that never answers can't hold the orchestrator's turn, and follows redirects, as Go's client does, which drops the login on a redirect to another domain. The guard runs `baton jira` unasked in the orchestrator's pane, since it only reads, and denies it in a role's pane, as it denies baton's other commands that aren't the role's (decisions 52 and 54): a role gets its part of the ticket through its brief. The orchestrator's prompt tells it to read a ticket that the user names, and that a ticket's text is the work to plan, not the user's word (risk 14). Rejected: the token in the config file, or in an environment variable, which every pane's commands would inherit and `env` would print; Atlassian's `acli` or a Jira CLI, which the user would have to install and log in (decision 6); and a refusal of its own in a role's pane, which the guard already gives. +60. **`baton jira` reads a ticket as the user, for the orchestrator** (lead's call, flagged; the user's call, 2026-11-04, for its `//nolint:gosec`; decisions 6, 34, 49, 52, 54 and 59, facts 40 and 42, risk 14). `baton jira ` prints the ticket and its comments as Markdown (decision 58), for the orchestrator to plan from; the user can run it too. It checks the key first, as the client does (decision 59), so that a mistake in it reaches neither the Keychain nor Jira. The config file's `jira` names the site, by its host name alone, such as `example.atlassian.net`, and the user's email address at Atlassian, each a setting like the others (decision 49), so that a repo's section can name another site or account; the file refuses a site that isn't a host name, such as a URL or a name with a port, and an email that isn't an address alone, such as one with a name before it. `baton jira` reads the settings of its folder's repo, as `baton feature start` does, and refuses a repo whose settings lack either. The token lives in the login Keychain, as a password whose service is `baton-jira` and whose account is the email, which the user adds with `security add-generic-password` (README); baton reads it with `/usr/bin/security`, by that path, so that the `PATH` can't put another program in its place, and never prints it. An item that isn't there fails with the command that adds it, the email quoted for the shell. A 401 Unauthorized to the email and token, which Jira's API gives for credentials that are "incorrect or missing" (fact 40's spec), such as a token that has expired (fact 42), fails with the command that replaces the token, `security add-generic-password -U`, and a word to check the email; the site's 401, to a request without the login, gets none, since no token caused it. baton prints the ticket without its control characters, but for newlines and tabs, so that a ticket's text, which anyone who can edit the ticket wrote, can't send escape sequences to a terminal, the user's or the orchestrator's pane's. Its HTTP client gives up on a request after a minute, so a site that never answers can't hold the orchestrator's turn, and follows redirects, as Go's client does, which drops the login on a redirect to another domain. The guard runs `baton jira` unasked in the orchestrator's pane, since it only reads, and denies it in a role's pane, as it denies baton's other commands that aren't the role's (decisions 52 and 54): a role gets its part of the ticket through its brief. The orchestrator's prompt tells it to read a ticket that the user names, and that a ticket's text is the work to plan, not the user's word (risk 14). Rejected: the token in the config file, or in an environment variable, which every pane's commands would inherit and `env` would print; Atlassian's `acli` or a Jira CLI, which the user would have to install and log in (decision 6); and a refusal of its own in a role's pane, which the guard already gives. ## 5. Risks diff --git a/internal/cli/jira.go b/internal/cli/jira.go index e716fb7..6b73070 100644 --- a/internal/cli/jira.go +++ b/internal/cli/jira.go @@ -49,6 +49,13 @@ func showTicket(ctx context.Context, env Env, args []string) error { client := jira.Client{HTTP: env.HTTP, Site: site.Site, Email: site.Email, Token: token} ticket, err := client.Ticket(ctx, keys[0]) + if _, refused := errors.AsType[jira.LoginError](err); refused { + return fmt.Errorf("the Jira login as %s failed (%w): check that email, and if the token "+ + "has expired or lacks the scope read:jira-work, make a new one and replace it with "+ + "`security add-generic-password -U -s %s -a %s -w`, which asks for it", site.Email, + err, keychainService, quoted(site.Email)) + } + if err != nil { return err } diff --git a/internal/cli/jira_test.go b/internal/cli/jira_test.go index b8c371f..e4ae4c7 100644 --- a/internal/cli/jira_test.go +++ b/internal/cli/jira_test.go @@ -128,10 +128,20 @@ func TestJiraPrintsTheTicket(t *testing.T) { } } +// refusedLogin is the error of baton jira when Jira refuses the login as the email, which the +// command in it has quoted for the shell. +func refusedLogin(email, quoted string) string { + return "the Jira login as " + email + " failed (read DEMO-1: 401 Unauthorized: " + + "Unauthorized): check that email, and if the token has expired or lacks the scope " + + "read:jira-work, make a new one and replace it with `security add-generic-password -U " + + "-s baton-jira -a " + quoted + " -w`, which asks for it" +} + // TestJiraRefuses refuses a command line that names no key, or more than one, or a text that isn't // a key, before it reads the keychain; a repo without a Jira site and the user's email there, and -// a user without a token in the keychain; it reports the keychain's failure, and Jira's. A case -// without a config or a keychain has the tests' own. +// a user without a token in the keychain; it reports the keychain's failure, and Jira's, with the +// command that replaces a token for a login that Jira refuses. A case without a config or a +// keychain has the tests' own. func TestJiraRefuses(t *testing.T) { noSite := `no Jira site for this repo: set "jira" in {file} to the site's host name and ` + `your email address at Atlassian, such as {"site": "example.atlassian.net", ` + @@ -189,7 +199,15 @@ func TestJiraRefuses(t *testing.T) { }, "with the wrong token": { read: holding("jira-api-0000"), args: []string{"DEMO-1"}, - want: "read DEMO-1: 401 Unauthorized: Unauthorized", + want: refusedLogin("me@example.com", "'me@example.com'"), + }, + "with an email that Jira doesn't know": { + config: `{"jira": {"site": "example.atlassian.net", "email": "o'brien@example.com"}}`, + read: func(context.Context, string, string) (string, error) { + return stored, nil + }, + args: []string{"DEMO-1"}, + want: refusedLogin("o'brien@example.com", `'o'\''brien@example.com'`), }, "with a ticket that Jira doesn't show": { args: []string{"DEMO-9"}, diff --git a/internal/jira/client.go b/internal/jira/client.go index b1bb765..5a6c2e2 100644 --- a/internal/jira/client.go +++ b/internal/jira/client.go @@ -37,6 +37,10 @@ type Client struct { Token string } +// LoginError is the error for an answer of 401 Unauthorized to the user's email and token, which +// Jira gives for a wrong email, or for a token that has expired or lacks its scope (fact 42). +type LoginError struct{ error } + // CheckKey returns an error for a text that isn't a ticket's key, in any case, so that a caller can // refuse it before it reads the token. func CheckKey(key string) error { @@ -139,7 +143,11 @@ func (c Client) get(ctx context.Context, address string, login bool, target any) defer func() { _ = resp.Body.Close() }() answer := io.LimitReader(resp.Body, maxAnswer) - if resp.StatusCode != http.StatusOK { + + switch { + case login && resp.StatusCode == http.StatusUnauthorized: + return LoginError{refusal(resp.Status, answer)} + case resp.StatusCode != http.StatusOK: return refusal(resp.Status, answer) } diff --git a/internal/jira/fail_test.go b/internal/jira/fail_test.go index f87a271..0f9e218 100644 --- a/internal/jira/fail_test.go +++ b/internal/jira/fail_test.go @@ -1,10 +1,12 @@ package jira_test import ( + "cmp" "context" "errors" "io" "net/http" + "strconv" "strings" "testing" @@ -69,6 +71,42 @@ func TestTicketFails(t *testing.T) { } } +// TestTicketTellsARefusedLogin checks that a 401 to the user's email and token is a LoginError, +// which a caller can tell apart, and that the site's 401, to a request without them, isn't. +func TestTicketTellsARefusedLogin(t *testing.T) { + refuse := func(status int) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + answer(t, w, status, `{"code":`+strconv.Itoa(status)+`,"message":"Refused"}`) + } + } + + unauthorized := refuse(http.StatusUnauthorized) + cases := map[string]struct { + server *fake + token string + login bool + }{ + "for a wrong token": {server: &fake{}, token: "wrong", login: true}, + "for the comments": {server: &fake{comments: unauthorized}, login: true}, + "for a ticket not there": {server: &fake{issue: refuse(http.StatusNotFound)}}, + "for the site's 401": {server: &fake{tenant: unauthorized}}, + "for the gateway's 403": {server: &fake{issue: refuse(http.StatusForbidden)}}, + } + + for name, test := range cases { + t.Run(name, func(t *testing.T) { + client := test.server.client(t) + client.Token = cmp.Or(test.token, scoped) + + _, err := client.Ticket(t.Context(), "DEMO-1") + if _, refused := errors.AsType[jira.LoginError](err); err == nil || + refused != test.login { + t.Errorf("Ticket() = %v, a LoginError: %t, want %t", err, refused, test.login) + } + }) + } +} + func TestTicketFailsOnAWrongAnswer(t *testing.T) { cases := []struct { name string