From adc4e7c683a411c7bfe81e6bda033c6d30f960c4 Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Wed, 4 Nov 2026 21:19:56 +0100 Subject: [PATCH] feat(jira): read a ticket and its comments from Jira Cloud internal/jira's Client reads a ticket, with the fields that baton shows, and all its comments, oldest first, through Atlassian's gateway with the user's email and scoped token. It reads the site's cloud ID from the site's tenant_info without a login, so that the token goes to the gateway alone. It checks the key and the cloud ID, reads at most 16 MiB of an answer, and gives Jira's messages when Jira refuses (decision 59, fact 41). --- docs/plan.md | 10 +- internal/jira/client.go | 156 +++++++++++++++++++++++++++ internal/jira/client_test.go | 102 ++++++++++++++++++ internal/jira/fail_test.go | 143 ++++++++++++++++++++++++ internal/jira/fake_test.go | 203 +++++++++++++++++++++++++++++++++++ 5 files changed, 610 insertions(+), 4 deletions(-) create mode 100644 internal/jira/client.go create mode 100644 internal/jira/client_test.go create mode 100644 internal/jira/fail_test.go create mode 100644 internal/jira/fake_test.go diff --git a/docs/plan.md b/docs/plan.md index 3e8ef44..dcf86af 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -61,8 +61,8 @@ The first milestone is a walking skeleton (`skeleton`): one feature on a throwaw | `gates` | The repo's gate, a new setting: the user's command that checks the repo's code, which the engineer's handover, QA's approval and the lead's final report run in the feature's worktree, and which refuses the handover while it fails; `baton start` and `baton feature start` refuse a repo without one, and the guard denies a role's `create` and `edit` in baton's config folder (decision 56, risk 13) | Done (#38) | | `adf` | `internal/adf`: the Atlassian Document Format, the JSON of a Jira ticket's description and comments, as Markdown to read: the walk over a document's nodes, and the inline text, with its marks, mentions, emoji, dates, cards and attachments; a node without a rule of its own shows its content, or the text that stands in for it (decision 57, fact 39) | Done (#39) | | `adf-blocks` | `internal/adf`'s blocks: headings, lists and task lists, code blocks, quotes and panels, rules, tables and expands (decision 57) | Done (#40) | -| `jira-text` | `internal/jira`'s ticket: the fields of a Jira ticket that baton shows, as Jira's REST API gives them, and the ticket as Markdown to read: its key and summary, type, status and parent, then its description, subtasks, links and comments (decision 58, fact 40) | In review (#41) | -| `jira-client` | `internal/jira`'s client: a ticket and all its comments from Jira Cloud's REST API, through Atlassian's gateway with the user's email and scoped token, and the site's cloud ID from the site (decision 6, fact 13) | Todo | +| `jira-text` | `internal/jira`'s ticket: the fields of a Jira ticket that baton shows, as Jira's REST API gives them, and the ticket as Markdown to read: its key and summary, type, status and parent, then its description, subtasks, links and comments (decision 58, fact 40) | Done (#41) | +| `jira-client` | `internal/jira`'s client: a ticket and all its comments from Jira Cloud's REST API, through Atlassian's gateway with the user's email and scoped token, and the site's cloud ID from the site, which gets neither (decision 59, facts 13 and 41) | In review (#42) | | `jira` | `baton jira `: the ticket as text, in the orchestrator's pane, with Jira's site and email from the config file and the read-only token from the Keychain (decision 6) | Todo | | `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 | @@ -140,6 +140,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. ## 3. Design @@ -219,7 +220,7 @@ Every change takes an exclusive lock on the feature (`flock` on its `lock` file, } ``` -**Jira** (decisions 6, 57 and 58). `baton jira DEMO-1` prints the ticket as Markdown: its key, summary, type, status and parent, then its description (the Atlassian Document Format turned into Markdown by `internal/adf`), subtasks, links and comments. It reads the token from the Keychain with `/usr/bin/security`, and the site's `cloudId` from the site. +**Jira** (decisions 6, 57, 58 and 59). `baton jira DEMO-1` prints the ticket as Markdown: its key, summary, type, status and parent, then its description (the Atlassian Document Format turned into Markdown by `internal/adf`), subtasks, links and comments. It reads the token from the Keychain with `/usr/bin/security`, and the site's cloud ID from the site, without a login, so that the token goes to Atlassian's gateway alone. **Delivery.** `baton deliver ` pushes the branch and opens the pull request against the base branch, titled from the brief, with the lead's final report as its body: on GitHub with `gh pr create`, on Bitbucket Cloud with its REST API and a token from the Keychain. It never merges. @@ -287,6 +288,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 56. **A repo's gate checks the code before each handover that vouches for it** (lead's call, flagged; the user's call, 2026-11-04, for its `//nolint:gosec`; decisions 13, 34, 49 and 52, fact 16, risk 13). The config file's `gate`, a setting like the others (decision 49), is the user's command that checks the repo's code, such as `mise run check`. The engineer's handover, QA's approval and the lead's final report run it, since each of these roles vouches for the code that it hands on; the rejections, the reviewer's approval, which follows QA's on the same code, the lead's brief and the orchestrator's moves don't. baton reads the gate at each of these handovers, for the repo of the role's folder, and runs it with `/bin/sh -c` in the feature's worktree, wherever in the repo the role runs baton, with baton's environment, the belt's in a role's pane (decision 55), nothing on its input, and its output and its errors on baton's output, in order, after a line that names the gate and the folder, so the role reads what failed. It runs once baton has checked the move and the role's open question on the feature as it stands, so a move that baton refuses runs no gate, and without the feature's lock, so that the other roles' `baton wait` and `baton status` don't wait for it; baton checks the move again under the lock when it writes the handover. A gate that fails refuses the handover, which changes nothing: the role fixes what failed, or, for QA, rejects with the failures. The journal logs the gate that a handover ran, with the handover. The gate has no time limit, since a check can take minutes; Ctrl-C or a hangup stops it, as it stops baton (decision 42): baton kills the gate's shell, and Ctrl-C reaches the commands that the shell started too, but a signal to baton alone leaves them to run to their end. Every repo needs a gate, so that none is forgotten: `baton start`, when it makes the orchestrator's session, and `baton feature start` refuse a repo without one, and say where to set it; `"gate": "true"` checks nothing, for a repo that has no checks. A gated handover refuses too, if the user takes the gate out while a feature runs; the others still go. The config file refuses a gate that is only spaces, and one that starts with `-`, which `sh -c` reads as its own options (bash, dash and zsh all do). The gate is a command line by design, the user's own, from the config file, which only the user writes, so its one place to run carries `//nolint:gosec` for G204, with its reason; decision 34's reason, arguments that no shell reads, doesn't fit it. The guard denies a role's `create` and `edit` in baton's config folder (decision 52), so a role's file tools can't change the gate, and a config folder that isn't there holds nothing, as no feature runs without the file; a role's `bash` still can, and a role can weaken the checks that the gate runs from inside the repo, such as its `mise.toml` (risk 13). Rejected: running the gate in the role's working folder, which may be any folder of the repo; a time limit, which would fail a slow but sound check; a repo without a gate, which would make the gate easy to forget; and gating QA's rejection, which reports a failure rather than vouching for the code. 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. ## 5. Risks @@ -314,5 +316,5 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The - Copilot CLI: `copilot --help` and `copilot help config|environment|permissions|sandbox` (1.0.105); the hooks reference, the plugins overview, the CLI plugin reference, the custom agents reference and the CLI's authentication page on docs.github.com; the Agent Plugins 1.0.0 schema at agent-plugins.org; the `agent-wake` spike (facts 3 to 10); the `live-run` step (facts 26 to 30). - git's manual and its 2.46 release notes, and gh's source, v2.103.2 (fact 38). - tmux 3.8 manual and its `spawn.c`; [tmux/tmux-builds](https://github.com/tmux/tmux-builds); GitHub's [runner images](https://github.com/actions/runner-images). -- Jira Cloud REST API v3 (its OpenAPI spec and its introduction, on 2026-11-04) and Bitbucket Cloud REST API 2.0 reference; ankitpokhrel/jira-cli's types (fact 40). +- Jira Cloud REST API v3 (its OpenAPI spec and its introduction, on 2026-11-04), Atlassian's support article "How to Find Your Atlassian Cloud Site's Cloud ID" (fact 41) and Bitbucket Cloud REST API 2.0 reference; ankitpokhrel/jira-cli's types (fact 40). - Bakhyard's AGENTS.md (the pipeline baton runs), nudge (this repo's rules, CI and gates), zeroshot (ideas only, fact 16). diff --git a/internal/jira/client.go b/internal/jira/client.go new file mode 100644 index 0000000..1a43ee8 --- /dev/null +++ b/internal/jira/client.go @@ -0,0 +1,156 @@ +package jira + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "uuid" +) + +// gateway is the address of Atlassian's API gateway for Jira, which takes a scoped API token, as a +// site's own address doesn't (fact 13). +const gateway = "https://api.atlassian.com/ex/jira/" + +// fields names the fields of a ticket that baton shows, so that Jira leaves the others out. +const fields = "summary,issuetype,status,parent,description,subtasks,issuelinks" + +// maxAnswer is the most of an answer that baton reads, so that a wrong answer can't fill its +// memory. +const maxAnswer = 16 << 20 + +// keyPattern matches a ticket's key, in any case: its project's key, a dash and its number. So a +// key can't change the address that it goes into. +var keyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*-[0-9]+$`) + +// Client reads tickets from a Jira Cloud site as the user, with their email and a scoped API token +// that reads Jira (decision 6). +type Client struct { + HTTP *http.Client + Site string // the site's host, such as example.atlassian.net + Email string + Token string +} + +// Ticket reads the ticket with the key, which it takes in any case, as Jira's keys are in capitals, +// and all its comments. +func (c Client) Ticket(ctx context.Context, key string) (Ticket, error) { + if !keyPattern.MatchString(key) { + return Ticket{}, fmt.Errorf("want a ticket's key, such as DEMO-1, not %q", key) + } + + key = strings.ToUpper(key) + + cloud, err := c.cloudID(ctx) + if err != nil { + return Ticket{}, err + } + + issue := gateway + cloud + "/rest/api/3/issue/" + key + + var ticket Ticket + if err := c.get(ctx, issue+"?fields="+fields, true, &ticket); err != nil { + return Ticket{}, fmt.Errorf("read %s: %w", key, err) + } + + if ticket.Comments, err = c.comments(ctx, issue+"/comment"); err != nil { + return Ticket{}, fmt.Errorf("read the comments on %s: %w", key, err) + } + + return ticket, nil +} + +// cloudID reads the site's cloud ID, which the gateway's addresses hold, from the site's +// tenant_info, which takes no login, so that the token goes to the gateway alone (decision 59). +func (c Client) cloudID(ctx context.Context) (string, error) { + var tenant struct { + CloudID string `json:"cloudId"` + } + + if err := c.get(ctx, "https://"+c.Site+"/_edge/tenant_info", false, &tenant); err != nil { + return "", fmt.Errorf("read the cloud ID of %s: %w", c.Site, err) + } + + id, err := uuid.Parse(tenant.CloudID) + if err != nil { + return "", fmt.Errorf("read the cloud ID of %s: %w", c.Site, err) + } + + return id.String(), nil +} + +// comments reads a ticket's comments, oldest first, a page at a time, until it has as many as Jira +// counts, or a page comes empty, as one can, since the count can change between pages (fact 40). +func (c Client) comments(ctx context.Context, address string) ([]Comment, error) { + var comments []Comment + + for { + var page struct { + Comments []Comment `json:"comments"` + Total int `json:"total"` + } + + query := "?orderBy=created&startAt=" + strconv.Itoa(len(comments)) + if err := c.get(ctx, address+query, true, &page); err != nil { + return nil, err + } + + comments = append(comments, page.Comments...) + if len(page.Comments) == 0 || len(comments) >= page.Total { + return comments, nil + } + } +} + +// get reads the JSON answer to a GET of the address into target. With login, it sends the user's +// email and token, which only the gateway gets. +func (c Client) get(ctx context.Context, address string, login bool, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, address, http.NoBody) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/json") + + if login { + req.SetBasicAuth(c.Email, c.Token) + } + + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + + answer := io.LimitReader(resp.Body, maxAnswer) + if resp.StatusCode != http.StatusOK { + return refusal(resp.Status, answer) + } + + return json.UnmarshalRead(answer, target) +} + +// refusal gives the error for an answer other than 200 OK: its status, and the messages that it +// holds, Jira's in errorMessages, or the gateway's in message. +func refusal(status string, answer io.Reader) error { + var body struct { + Messages []string `json:"errorMessages"` + Message string `json:"message"` + } + + // An answer that isn't JSON, such as a proxy's page, leaves the status alone. + _ = json.UnmarshalRead(answer, &body) + + text := strings.TrimSpace(strings.Join(body.Messages, " ") + " " + body.Message) + if text == "" { + return errors.New(status) + } + + return fmt.Errorf("%s: %s", status, text) +} diff --git a/internal/jira/client_test.go b/internal/jira/client_test.go new file mode 100644 index 0000000..4589ef4 --- /dev/null +++ b/internal/jira/client_test.go @@ -0,0 +1,102 @@ +package jira_test + +import ( + "io" + "net/http" + "os" + "strconv" + "strings" + "testing" +) + +func TestTicket(t *testing.T) { + cases := []struct { + key, golden, cloudID string + pages int32 + }{ + {"DEMO-1", "DEMO-1", cloud, 2}, + {"demo-6", "DEMO-6", "{" + strings.ToUpper(cloud) + "}", 1}, + } + + for _, test := range cases { + t.Run(test.key, func(t *testing.T) { + server := &fake{tenant: tenantInfo(t, test.cloudID)} + + got, err := server.client(t).Ticket(t.Context(), test.key) + if err != nil { + t.Fatal(err) + } + + want := string(read(t, os.DirFS("testdata"), test.golden+".md")) + if got.Markdown() != want { + t.Errorf("Markdown() =\n%s\nwant:\n%s", got.Markdown(), want) + } + + if pages := server.pages.Load(); pages != test.pages { + t.Errorf("read %d pages of comments, want %d", pages, test.pages) + } + }) + } +} + +func TestTicketStopsAtAnEmptyPage(t *testing.T) { + server := &fake{extra: 2} + + got, err := server.client(t).Ticket(t.Context(), "DEMO-1") + if err != nil { + t.Fatal(err) + } + + if comments, pages := len(got.Comments), server.pages.Load(); comments != 3 || pages != 3 { + t.Errorf("read %d comments on %d pages, want 3 on 3", comments, pages) + } +} + +func TestTicketReadsAnAnswerOfUpTo16MiB(t *testing.T) { + head, tail := `{"key":"DEMO-1","fields":{"summary":"`, `"}}` + summary := strings.Repeat("a", 16<<20-len(head)-len(tail)) + + cases := []struct { + name, summary string + fails bool + }{ + {"16 MiB", summary, false}, + {"a byte more", summary + "a", true}, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + server := &fake{issue: func(w http.ResponseWriter, _ *http.Request) { + // The client stops reading at its limit, so the rest of a longer answer may not + // reach it. + _, _ = io.WriteString(w, head+test.summary+tail) + }} + + got, err := server.client(t).Ticket(t.Context(), "DEMO-1") + if test.fails != (err != nil) || !test.fails && got.Fields.Summary != test.summary { + t.Errorf("Ticket() = a summary of %d bytes, %v", len(got.Fields.Summary), err) + } + }) + } +} + +func TestTicketRefusesAKeyThatIsNotOne(t *testing.T) { + for _, key := range []string{ + "", "DEMO", "DEMO1", "DEMO-", "-1", "1DEMO-1", "_DEMO-1", "DEMO-1x", "x/demo-1", + "DEMO-1/comment", "DEMO-1?fields=x", "DEMO-1 ", + } { + t.Run(key, func(t *testing.T) { + server := &fake{} + want := "want a ticket's key, such as DEMO-1, not " + strconv.Quote(key) + + _, err := server.client(t).Ticket(t.Context(), key) + if err == nil || err.Error() != want { + t.Errorf("Ticket() = %v, want %s", err, want) + } + + if requests := server.requests.Load(); requests != 0 { + t.Errorf("sent %d requests, want none", requests) + } + }) + } +} diff --git a/internal/jira/fail_test.go b/internal/jira/fail_test.go new file mode 100644 index 0000000..f87a271 --- /dev/null +++ b/internal/jira/fail_test.go @@ -0,0 +1,143 @@ +package jira_test + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/codedogapp/baton/internal/jira" +) + +func TestTicketFails(t *testing.T) { + refuse := func(status int, body string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + } + } + + cases := []struct { + name, key, token string + server *fake + want string + }{ + { + "a site without Jira", "DEMO-1", scoped, + &fake{tenant: refuse(http.StatusNotFound, "Not here")}, + "read the cloud ID of example.atlassian.net: 404 Not Found", + }, + { + "a wrong token", "DEMO-1", "wrong", &fake{}, + "read DEMO-1: 401 Unauthorized: Unauthorized", + }, + { + "a ticket that isn't there", "DEMO-99", scoped, &fake{}, + "read DEMO-99: 404 Not Found: Issue does not exist or you do not have permission " + + "to see it.", + }, + { + "a proxy's page", "DEMO-1", scoped, + &fake{issue: refuse(http.StatusBadGateway, "Bad gateway")}, + "read DEMO-1: 502 Bad Gateway", + }, + { + "Jira's messages and the gateway's", "DEMO-1", scoped, + &fake{issue: refuse(http.StatusBadRequest, + `{"errorMessages":["One."," Two. "],"message":"Three."}`)}, + "read DEMO-1: 400 Bad Request: One. Two. Three.", + }, + { + "refused comments", "DEMO-1", scoped, + &fake{comments: refuse(http.StatusBadRequest, `{"errorMessages":["Bad order."]}`)}, + "read the comments on DEMO-1: 400 Bad Request: Bad order.", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + client := test.server.client(t) + client.Token = test.token + + _, err := client.Ticket(t.Context(), test.key) + if err == nil || err.Error() != test.want { + t.Errorf("Ticket() = %v, want %s", err, test.want) + } + }) + } +} + +func TestTicketFailsOnAWrongAnswer(t *testing.T) { + cases := []struct { + name string + server *fake + want string + }{ + { + "a cloud ID that isn't one", + &fake{tenant: func(w http.ResponseWriter, _ *http.Request) { + answer(t, w, http.StatusOK, `{"cloudId":"example"}`) + }}, + "read the cloud ID of example.atlassian.net: invalid uuid", + }, + { + "a ticket that isn't JSON", + &fake{issue: func(w http.ResponseWriter, _ *http.Request) { + answer(t, w, http.StatusOK, "A ticket") + }}, + "read DEMO-1: ", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + _, err := test.server.client(t).Ticket(t.Context(), "DEMO-1") + if err == nil || !strings.HasPrefix(err.Error(), test.want) { + t.Errorf("Ticket() = %v, want an error that starts with %q", err, test.want) + } + }) + } +} + +// errOffline is the error of a transport without a network. +var errOffline = errors.New("offline") + +type offline struct{} + +func (offline) RoundTrip(*http.Request) (*http.Response, error) { return nil, errOffline } + +func TestTicketFailsToConnect(t *testing.T) { + cases := []struct { + name string + site string + want func(error) bool + }{ + {"offline", site, func(err error) bool { return errors.Is(err, errOffline) }}, + {"a site that isn't a host", "example atlassian.net", func(err error) bool { + return strings.Contains(err.Error(), "invalid character") + }}, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + client := jira.Client{HTTP: &http.Client{Transport: offline{}}, Site: test.site} + + if _, err := client.Ticket(t.Context(), "DEMO-1"); err == nil || !test.want(err) { + t.Errorf("Ticket() = %v", err) + } + }) + } +} + +func TestTicketStopsWhenItsContextEnds(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + server := &fake{} + + if _, err := server.client(t).Ticket(ctx, "DEMO-1"); !errors.Is(err, context.Canceled) { + t.Errorf("Ticket() = %v, want %v", err, context.Canceled) + } +} diff --git a/internal/jira/fake_test.go b/internal/jira/fake_test.go new file mode 100644 index 0000000..2c59c02 --- /dev/null +++ b/internal/jira/fake_test.go @@ -0,0 +1,203 @@ +package jira_test + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync/atomic" + "testing" + + "github.com/codedogapp/baton/internal/jira" +) + +// The made-up site and its cloud ID, and the user's email and token, which only the gateway gets. +const ( + site = "example.atlassian.net" + cloud = "0f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b" + email = "user@example.com" + scoped = "scoped-api-token" +) + +// pageSize is the most comments that the fake gives on a page, so that DEMO-1's three take two. +const pageSize = 2 + +// fake stands in for Jira: the site, which gives its cloud ID, and the gateway, which gives the +// user the tickets in testdata and their comments. A test can set a handler of its own in place of +// the fake's, and comments that the pages count but don't hold. +type fake struct { + tenant, issue, comments http.HandlerFunc + extra int + + requests, pages atomic.Int32 +} + +// client returns a client of the fake, as the user, whose requests reach the fake whatever their +// host. +func (f *fake) client(t *testing.T) jira.Client { + t.Helper() + + issue := "GET api.atlassian.com/ex/jira/" + cloud + "/rest/api/3/issue/{key}" + mux := http.NewServeMux() + mux.HandleFunc("GET "+site+"/_edge/tenant_info", or(f.tenant, tenantInfo(t, cloud))) + mux.HandleFunc(issue, gateway(or(f.issue, ticket(t)))) + mux.HandleFunc(issue+"/comment", gateway(or(f.comments, f.page(t)))) + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.requests.Add(1) + mux.ServeHTTP(w, r) + })) + t.Cleanup(server.Close) + + transport := toServer{addr: server.Listener.Addr().String(), next: server.Client().Transport} + + return jira.Client{ + HTTP: &http.Client{Transport: transport}, + Site: site, + Email: email, + Token: scoped, + } +} + +// or returns the test's handler, if it set one, or else the fake's. +func or(handler, fallback http.HandlerFunc) http.HandlerFunc { + if handler != nil { + return handler + } + + return fallback +} + +// tenantInfo answers as the site's tenant_info, with the cloud ID, and fails the test if the site +// gets the user's login. +func tenantInfo(t *testing.T, cloudID string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + t.Error("the site got the user's login") + } + + answer(t, w, http.StatusOK, `{"cloudId":"`+cloudID+`"}`) + } +} + +// gateway refuses a request without the user's login, or that doesn't take JSON, as the gateway +// would, and passes the others on. +func gateway(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + + switch { + case !ok || user != email || pass != scoped: + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"code":401,"message":"Unauthorized"}`) + case r.Header.Get("Accept") != "application/json": + w.WriteHeader(http.StatusNotAcceptable) + default: + next(w, r) + } + } +} + +// ticket answers with a ticket in testdata, if the request names baton's fields, or as Jira +// answers for a ticket that isn't there. It fails the test if the key isn't in capitals, as +// Jira's keys are, which a file system that ignores case wouldn't. +func ticket(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + want := "summary,issuetype,status,parent,description,subtasks,issuelinks" + if got := r.URL.Query().Get("fields"); got != want { + t.Errorf("fields = %q, want %q", got, want) + } + + key := r.PathValue("key") + if key != strings.ToUpper(key) { + t.Errorf("asked for %s, not in capitals", key) + } + + data, err := fs.ReadFile(os.DirFS("testdata"), key+".json") + if err != nil { + answer(t, w, http.StatusNotFound, `{"errorMessages":["Issue does not exist or you `+ + `do not have permission to see it."],"errors":{}}`) + + return + } + + answer(t, w, http.StatusOK, string(data)) + } +} + +// page answers with a page of a ticket's comments in testdata, oldest first, from the request's +// startAt, and fails the test if the client asks for more pages than there are. It runs on the +// server's goroutine, so it fails the test with t.Errorf, not t.Fatal. +func (f *fake) page(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var all struct { + Comments []jsontext.Value `json:"comments"` + } + + data, err := fs.ReadFile(os.DirFS("testdata"), r.PathValue("key")+".comments.json") + if err == nil { + err = json.Unmarshal(data, &all) + } + + query := r.URL.Query() + start, atoi := strconv.Atoi(query.Get("startAt")) + + switch { + case f.pages.Add(1) > 5: + fail(t, w, "the client asked for more pages than there are") + case err != nil: + fail(t, w, "read the comments: %v", err) + case atoi != nil || start > len(all.Comments) || query.Get("orderBy") != "created": + fail(t, w, "asked for comments with %s", r.URL.RawQuery) + default: + comments := all.Comments[start:min(start+pageSize, len(all.Comments))] + total := len(all.Comments) + f.extra + + page, err := json.Marshal(map[string]any{"comments": comments, "total": total}) + if err != nil { + fail(t, w, "write a page: %v", err) + + return + } + + answer(t, w, http.StatusOK, string(page)) + } + } +} + +// fail fails the test, and answers the client with an error. +func fail(t *testing.T, w http.ResponseWriter, format string, args ...any) { + t.Helper() + t.Errorf(format, args...) + w.WriteHeader(http.StatusInternalServerError) +} + +// answer writes the fake's answer. +func answer(t *testing.T, w http.ResponseWriter, status int, body string) { + t.Helper() + + w.WriteHeader(status) + + if _, err := io.WriteString(w, body); err != nil { + t.Errorf("answer: %v", err) + } +} + +// toServer sends each request to the test server, whatever its host, which stays in the request's +// Host header for the server's routes. +type toServer struct { + addr string + next http.RoundTripper +} + +func (s toServer) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.URL.Host = s.addr + + return s.next.RoundTrip(req) +}