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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/plan.md

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions internal/jira/testdata/DEMO-1.comments.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"comments": [
{
"id": "30001",
"author": {"accountId": "000000:00000000-0000-0000-0000-000000000001", "displayName": "Ann Example", "active": true},
"body": {
"type": "doc",
"version": 1,
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Which browsers do we support?"}]}]
},
"created": "2026-10-30T09:15:00.000+0100",
"updated": "2026-10-30T09:15:00.000+0100",
"jsdPublic": true
},
{
"id": "30002",
"body": {
"type": "doc",
"version": 1,
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Safari, Chrome and Firefox."}]}]
},
"created": "2026-10-30T10:02:11.000+0100",
"updated": "2026-10-30T10:02:11.000+0100",
"jsdPublic": true
},
{
"id": "30003",
"author": {"accountId": "000000:00000000-0000-0000-0000-000000000002", "displayName": "Bo Example", "active": true},
"body": {"type": "doc", "version": 1, "content": []},
"created": "2026-10-31T08:00:00.000+0100",
"updated": "2026-10-31T08:00:00.000+0100",
"jsdPublic": true
}
],
"maxResults": 100,
"startAt": 0,
"total": 3
}
87 changes: 87 additions & 0 deletions internal/jira/testdata/DEMO-1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
{
"expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations",
"id": "10001",
"self": "https://api.atlassian.com/ex/jira/0f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b/rest/api/3/issue/10001",
"key": "DEMO-1",
"fields": {
"summary": "Let users sign in with a passkey",
"issuetype": {"id": "10004", "name": "Story", "subtask": false, "hierarchyLevel": 0},
"status": {
"id": "3",
"name": "In Progress",
"statusCategory": {"id": 4, "key": "indeterminate", "name": "In Progress"}
},
"parent": {
"id": "10010",
"key": "DEMO-10",
"fields": {
"summary": "Sign-in without passwords",
"status": {"id": "3", "name": "In Progress"},
"issuetype": {"id": "10000", "name": "Epic", "hierarchyLevel": 1}
}
},
"description": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{"type": "text", "text": "Users sign in with a "},
{"type": "text", "text": "passkey", "marks": [{"type": "strong"}]},
{"type": "text", "text": " instead of a password."}
]
},
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "Offer a passkey after sign-in"}]}
]
},
{
"type": "listItem",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "Keep the password as a fallback"}]}
]
}
]
}
]
},
"subtasks": [
{
"id": "10002",
"key": "DEMO-2",
"fields": {"summary": "Store the passkeys", "status": {"name": "To Do"}, "issuetype": {"name": "Subtask"}}
},
{
"id": "10003",
"key": "DEMO-3",
"fields": {"summary": "Show the passkey prompt", "status": {"name": "Done"}, "issuetype": {"name": "Subtask"}}
}
],
"issuelinks": [
{
"id": "20001",
"type": {"id": "10000", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"},
"outwardIssue": {
"id": "10004",
"key": "DEMO-4",
"fields": {"summary": "Retire the passwords", "status": {"name": "To Do"}}
}
},
{
"id": "20002",
"type": {"id": "10000", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"},
"inwardIssue": {
"id": "10005",
"key": "DEMO-5",
"fields": {"summary": "Pick a passkey library", "status": {"name": "Done"}}
}
}
]
}
}
34 changes: 34 additions & 0 deletions internal/jira/testdata/DEMO-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# DEMO-1: Let users sign in with a passkey

- Type: Story
- Status: In Progress
- Parent: DEMO-10: Sign-in without passwords (In Progress)

## Description

Users sign in with a **passkey** instead of a password.

- Offer a passkey after sign-in
- Keep the password as a fallback

## Subtasks

- DEMO-2: Store the passkeys (To Do)
- DEMO-3: Show the passkey prompt (Done)

## Links

- blocks DEMO-4: Retire the passwords (To Do)
- is blocked by DEMO-5: Pick a passkey library (Done)

## Comments

### Ann Example, 2026-10-30T09:15:00.000+0100

Which browsers do we support?

### Someone, 2026-10-30T10:02:11.000+0100

Safari, Chrome and Firefox.

### Bo Example, 2026-10-31T08:00:00.000+0100
1 change: 1 addition & 0 deletions internal/jira/testdata/DEMO-6.comments.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"comments": [], "maxResults": 100, "startAt": 0, "total": 0}
12 changes: 12 additions & 0 deletions internal/jira/testdata/DEMO-6.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"id": "10006",
"key": "DEMO-6",
"fields": {
"summary": "Tidy the README",
"issuetype": {"name": "Task"},
"status": {"name": "To Do"},
"description": null,
"subtasks": [],
"issuelinks": []
}
}
4 changes: 4 additions & 0 deletions internal/jira/testdata/DEMO-6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# DEMO-6: Tidy the README

- Type: Task
- Status: To Do
130 changes: 130 additions & 0 deletions internal/jira/ticket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Package jira reads a Jira Cloud ticket, which the orchestrator plans from, and gives it as
// Markdown to read (decisions 6 and 58).
package jira

import (
"cmp"
"slices"
"strings"

"github.com/codedogapp/baton/internal/adf"
)

// Ticket is a ticket as Jira's REST API gives it, with the fields that baton shows, and its
// comments, oldest first.
type Ticket struct {
Key string `json:"key"`
Fields Fields `json:"fields"`
Comments []Comment `json:"-"`
}

// Fields are the fields of a ticket that baton shows.
type Fields struct {
Summary string `json:"summary"`
Type Named `json:"issuetype"`
Status Named `json:"status"`
Parent Issue `json:"parent"`
Description adf.Node `json:"description"`
Subtasks []Issue `json:"subtasks"`
Links []Link `json:"issuelinks"`
}

// Named is a type or a status, which a ticket shows by its name.
type Named struct {
Name string `json:"name"`
}

// Issue is another ticket that a ticket names, such as its parent or a subtask.
type Issue struct {
Key string `json:"key"`
Fields struct {
Summary string `json:"summary"`
Status Named `json:"status"`
} `json:"fields"`
}

// Link links a ticket with another, its inward or its outward ticket, and its type says how, from
// the ticket's side: such as "blocks" for its outward ticket, and "is blocked by" for its inward.
type Link struct {
Type struct {
Inward string `json:"inward"`
Outward string `json:"outward"`
} `json:"type"`
Inward Issue `json:"inwardIssue"`
Outward Issue `json:"outwardIssue"`
}

// Comment is a comment on a ticket: who wrote it, when, as Jira gives the time, and its text.
type Comment struct {
Author struct {
Name string `json:"displayName"`
} `json:"author"`
Created string `json:"created"`
Body adf.Node `json:"body"`
}

// Markdown renders the ticket as Markdown to read: its key and summary, its type, status and
// parent, then its description, subtasks, links and comments, each under its heading. A part that
// the ticket lacks shows nothing, not even its heading.
func (t Ticket) Markdown() string {
fields := t.Fields

about := "- Type: " + fields.Type.Name + "\n- Status: " + fields.Status.Name
if fields.Parent.Key != "" {
about += "\n- Parent: " + fields.Parent.line()
}

comments := make([]string, len(t.Comments))
for i, comment := range t.Comments {
author := cmp.Or(comment.Author.Name, "Someone")
comments[i] = join("### "+author+", "+comment.Created, adf.Markdown(comment.Body))
}

return join(
"# "+t.Key+": "+fields.Summary,
about,
section("Description", adf.Markdown(fields.Description)),
section("Subtasks", list(fields.Subtasks, Issue.line)),
section("Links", list(fields.Links, Link.line)),
section("Comments", join(comments...)),
) + "\n"
}

// section gives a part of the ticket under its heading, or nothing for an empty part.
func section(heading, text string) string {
if text == "" {
return ""
}

return "## " + heading + "\n\n" + text
}

// join joins the parts that aren't empty, with a blank line between them.
func join(parts ...string) string {
return strings.Join(slices.DeleteFunc(parts, func(part string) bool { return part == "" }),
"\n\n")
}

// list renders items as a bullet list, a line each.
func list[T any](items []T, line func(T) string) string {
lines := make([]string, len(items))
for i, item := range items {
lines[i] = "- " + line(item)
}

return strings.Join(lines, "\n")
}

// line shows a ticket that another names by its key, summary and status.
func (i Issue) line() string {
return i.Key + ": " + i.Fields.Summary + " (" + i.Fields.Status.Name + ")"
}

// line shows a link from the ticket's side, by its type and the other ticket.
func (l Link) line() string {
if l.Inward.Key != "" {
return l.Type.Inward + " " + l.Inward.line()
}

return l.Type.Outward + " " + l.Outward.line()
}
61 changes: 61 additions & 0 deletions internal/jira/ticket_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package jira_test

import (
"encoding/json/v2"
"io/fs"
"os"
"strings"
"testing"

"github.com/codedogapp/baton/internal/jira"
)

func TestMarkdown(t *testing.T) {
testdata := os.DirFS("testdata")

golden, err := fs.Glob(testdata, "*.md")
if err != nil || len(golden) == 0 {
t.Fatalf("no tickets in testdata: %v", err)
}

for _, name := range golden {
key := strings.TrimSuffix(name, ".md")
t.Run(key, func(t *testing.T) {
var ticket jira.Ticket
decode(t, testdata, key+".json", &ticket)

var page struct {
Comments []jira.Comment `json:"comments"`
}

decode(t, testdata, key+".comments.json", &page)
ticket.Comments = page.Comments

want := string(read(t, testdata, name))
if got := ticket.Markdown(); got != want {
t.Errorf("Markdown() =\n%s\nwant:\n%s", got, want)
}
})
}
}

// decode reads a file of testdata as JSON into v.
func decode(t *testing.T, testdata fs.FS, name string, v any) {
t.Helper()

if err := json.Unmarshal(read(t, testdata, name), v); err != nil {
t.Fatalf("decode %s: %v", name, err)
}
}

// read returns a file of testdata.
func read(t *testing.T, testdata fs.FS, name string) []byte {
t.Helper()

data, err := fs.ReadFile(testdata, name)
if err != nil {
t.Fatal(err)
}

return data
}
Loading