SLOPSHOPPER

jira-log

Links a worktree to a Jira Cloud issue, records the work done on it, and posts a reviewed daily update comment.

panebandguardcommandtoaststatus
v0.9.1no licenseupdated 2026-10-129akashnp8/claude-code-plugins/plugins/jira-log
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jira-log
│ ┃ jira ✕ › fix the failing auth test and add an audit log call │ ┃ Jira Cloud connection │ ┃ ⏺ Read(src/auth.ts) │ ┃ Site URL : https://your-team.atlassian.net ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Email : the email you sign in to Jira wi ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ API token : create one at id.atlassian.com > ⎿ 3 pass, 1 fail │ ┃ │ ┃ Project : key new issues go to, e.g. CPC ( ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ [ Save and test connection ] ✻ Worked for 42s · done 4:20 PM │ │ › /jira │ ⎿ jira-log: Jira is not set up yet: run /jira setup. │ │ Jira log is not set up [ Set up ] [ Skip ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Jira log is not set up [ Set up ] [ Skip ] ⟨Claude Code's own drawing⟩
Pane · jira
Jira Cloud connection Site URL : https://your-team.atlassian.net Email : the email you sign in to Jira with API token : create one at id.atlassian.com > Security > API Project : key new issues go to, e.g. CPC (optional) [ Save and test connection ]
README

claude-code-auto-jira-log

A Claude Code plugin that keeps Jira Cloud up to date with the work you do in Claude Code, a second one, git-push, that pushes your commits from a button, and a third, worktree-hooks, that starts each worktree from the latest remote branch, and usage-sync, that adds up your Claude Code usage across machines.

  • Link a worktree to a Jira issue: pick one of your open issues, type a key, or create a new issue drafted from the conversation.
  • Record the day's work on that issue as you go: files edited, commits, test runs, pull requests, and Artifacts published.
  • Post one daily comment per issue: a few plain notes on what was done, and on any problem, how it was resolved or what it waits on. Claude drafts it from that record and the conversation, and you review it before it is sent. The notes carry no sections, so a weekly report built from the comments can sort the work its own way.
  • See at a glance whether your work is in Jira: a line above the prompt shows the linked issue, its status, and how many actions are recorded but not yet posted, today's and any earlier day's, with a button to draft the update.
  • Move the issue along: To Do becomes In Progress when you link it, and /jira update suggests the next status (In Review once a pull request is up, Done once it is merged) for you to confirm with one press.

Built on Claude Code's function hooks (early access); the API can change between Claude Code releases.

Install

/plugin marketplace add 9akashnp8/claude-code-auto-jira-log
/plugin install jira-log@auto-jira-log

Set up

  1. Create an API token at id.atlassian.com > Security > API tokens.
  2. Run /jira setup, fill in your site URL (https://your-team.atlassian.net), email and token, then press Save and test connection. Add a project key too if you want to create issues from Claude Code.

The token is encrypted with Windows DPAPI before it is stored, so it opens only for your Windows user on that machine. Token storage needs Windows PowerShell.

Use

CommandWhat it does
/jiraShows which issue this worktree is linked to
/jira setupOpens the connection form
/jira link [KEY]Links this worktree to an issue; without a key, opens the picker
/jira new [what for]Drafts a ticket (a title and a short description) from the conversation, focused on what you type after new if anything: pick its type and sprint, revise it, then create and link it
/jira new --manualOpens a blank ticket form (title, description, type, sprint) and never asks the model; the band's Write it myself button does the same. Every drafted ticket can be edited in the same form
/jira updateDrafts today's comment in the Jira pane: revise it, post it or discard it
/jira statusMoves the linked issue to any status its workflow allows, such as Done
/jira unlinkRemoves the link

The description is a text area: click it to type, Enter starts a new line and Esc leaves it. It grows with the text up to 12 rows, then scrolls. Each line becomes a paragraph in Jira. In VS Code, which draws no such area, it is a one-line field.

A new issue goes into the project's current sprint unless you pick a future sprint or Backlog in the form. The sprints are the active and future ones on the project's scrum boards. When the site has no Jira Software, or the project has no scrum board, the form shows no sprint and the issue goes to the backlog.

Running /jira update again on the same day replaces that day's comment, unless someone edited it in Jira since; then a new comment is added.

A day whose work never reached Jira is not lost: the next /jira update, within 14 days, drafts that work into its comment along with the day's own, and posting it marks the earlier day as covered.

Linking a To Do issue looks for a transition into a status named In Progress (any case); when the workflow has none, a toast says so and the issue stays where it is.

/jira update only suggests moves the issue's workflow allows from its current status, and nothing moves until you press the button. Its evidence is the conversation and the day's record, which notes pull and merge requests opened from Claude Code by gh pr create, glab mr create, az repos pr create, or an MCP tool such as create_pull_request. A pull request opened in the browser counts once you mention it in the conversation.

git-push

A second plugin in this marketplace: push the branch and open its pull request from buttons above the prompt, then follow the pull request there until it is merged.

/plugin install git-push@auto-jira-log

Push

When the branch has commits that are not on origin, the line above the prompt says so, with a Push button:

  • feature/export · not on origin yet · 2 commits: the branch was never pushed. Push runs git push --set-upstream origin HEAD, so origin/feature/export is created and tracked.
  • feature/export · 1 commit not pushed: the branch tracks origin/feature/export and is ahead of it.

A toast says whether the push worked, or the line from git that says why not. The plugin checks git after each turn and every 5 seconds, so commits made with the app's Commit button or in a terminal show up too. The push runs with your own git credentials and never waits on a terminal prompt. Claude Code runs git for plugins with the repository's hooks off, so a pre-push hook does not run on this push.

Pull requests (Azure DevOps)

When origin is an Azure DevOps repository (Azure DevOps Server, such as https://server/tfs/Collection/Project/_git/Repo, or Azure DevOps Services) and the branch is on it with nothing left to push, the line shows Create PR:

  1. The plugin fetches origin and picks the branch to merge into: the branch yours was most likely cut from. That is the origin branch missing the fewest of your branch's commits, then the one with the fewest commits of its own, with the repository's default branch winning a tie. A branch cut from dev merges into dev. With git older than 2.41, which cannot measure this, it uses the default branch.
  2. Claude drafts a title and description from the commits and changed files between that branch and yours, using the conversation only for why the change is needed and how it was tested. A Jira key named in the conversation starts the title.
  3. The draft opens in the Pull request pane: pick another branch under Into (the draft is written again for it), edit the title, ask for a revision in your own words, then press Create pull request.
  4. A note in the conversation records the pull request, so Claude and jira-log's next update know it is up.

From then on the line follows the pull request, checking every 30 seconds: PR #12 · Active · 1 of 2 approved · checks running. Conflicts, a rejection or a failed check turn it red, changes requested yellow, and a merged pull request shows Merged in the merge purple. PR #12 opens it in the browser. A pull request opened in the browser shows up too.

Fix a failed build

When a build validation policy's build fails, the line shows Fix build (once Claude is not busy with a turn):

  1. The plugin reads the build the policy ran (the evaluation's context.buildId), its timeline, and for the first five failed steps their error lines and the last 150 lines of its log, timestamps dropped. A job or stage counts only when it has errors of its own, such as no agent picking it up.
  2. It sends that to Claude as a prompt, in the conversation, so you can see what Claude was given. Claude works out the cause and does one of three things:
  3. Code: fixes it, checks it, and commits only that fix, with a message that starts fix(ci): . When the turn ends, the plugin pushes the commit, and the push queues the build again.
  4. Outside the code (an agent lost, a feed or network error, a timeout): changes nothing and ends with RETRY: <why>. The plugin queues the policy's build again, as the pull request's Re-queue does.
  5. Needs a person (a missing variable, secret, service connection, permission or agent capability): changes nothing and ends with ESCALATE: <what to do>. The line shows Needs you: … and you get a notification. The line clears once the build runs again.
  6. Fix build is not offered again for the same build until it has been seen running again. A requeue or a push makes a new build; Retry failed stages reruns the same one. If the same steps then fail with the same error, Claude is told an earlier fix did not hold, and to escalate unless it can see what that fix missed.

A fix that is interrupted or commits nothing brings the button back. The build output is put to Claude as data, with an instruction not to follow anything written in it.

Fix merge conflicts

When Azure DevOps reports that the pull request has conflicts with its target branch (mergeStatus is conflicts), the line shows Fix conflicts:

  1. The plugin fetches origin and merges the target into your branch here: git merge --no-edit origin/<target>. It uses a merge, not a rebase, because the branch is already published and under review. With uncommitted changes to tracked files it stops before merging and asks you to commit or stash them.
  2. If git merges cleanly, which happens when Azure DevOps and git disagree, the plugin pushes the merge with no turn for Claude.
  3. Otherwise it sends Claude the list of conflicted files. Claude resolves each one keeping the intent of both sides, checks the result, and concludes the merge with git commit --no-edit. When the turn ends, the plugin pushes the merge commit.
  4. If a conflict needs a decision only a person can make, Claude leaves the merge in progress and ends with ESCALATE: <the decision needed>. The line shows Needs you: … and you get a notification.

Fix conflicts is not offered again for the same conflicts until Azure DevOps tries the merge again, which it does after a push to either branch. If Claude leaves the merge unfinished, pressing Fix conflicts again picks up the merge in progress rather than starting a new one. The list of conflicted files comes from the local merge, which is what Claude resolves, so the plugin does not need Azure DevOps's preview conflicts API.

Signing in needs no setup. The plugin asks git for the credential it uses for origin (git credential fill), then sends requests through Windows PowerShell: first as Basic sign-in (a personal access token), then as Windows sign-in with the same credential (a domain password), and with no stored credential as your Windows account. The credential stays in memory and goes to PowerShell on its standard input, never on a command line. Requests use REST API version 6.0, which Azure DevOps Server 2020 and later answer. Pull requests need Windows.

When both plugins are installed, their lines stack in the band above the prompt.

worktree-hooks

A third plugin in this marketplace: start every worktree from the latest commit of the branch it is made from, not from whatever copy of that branch this machine fetched last.

/plugin install worktree-hooks@auto-jira-log

It registers WorktreeCreate, WorktreeRemove and SessionStart command hooks. claude --worktree always branches from the default branch. A WorktreeCreate hook replaces Claude Code's own worktree creation, so worktree-create.sh does all of it:

  1. Finds the main checkout and the default branch from origin/HEAD (main when that is not set).
  2. Runs git fetch origin <branch>. When the fetch fails, for example offline, it carries on from the cached origin/<branch>.
  3. Runs git worktree add for .claude/worktrees/<name> on a new branch, worktree-<name>, from origin/<branch>.

worktree-remove.sh removes the worktree and deletes its worktree-* branch, because Claude Code never deletes the branch of a worktree a hook created.

The desktop app's Code tab makes its worktrees itself and does not run WorktreeCreate. It branches from the branch picked in the branch selector, as this machine last fetched it, which can be behind. For those, session-start.sh catches up when a new session starts in a worktree:

  1. Finds the branch the worktree was made from. The desktop app records it as sourceBranch in its session file under %APPDATA%Claudeclaude-code-sessions. That file belongs to the app and is not documented, so when it is missing or changes shape, the hook falls back to the one branch, other than the worktree's own, that points at the worktree's commit. When several do, it cannot tell them apart and leaves the worktree alone.
  2. Fetches that branch from origin and fast-forwards the worktree's branch to it, then tells Claude in one line.

It leaves the worktree alone when the session is resumed, cleared or compacted rather than new, when there are uncommitted changes, when the branch has commits of its own, or when the branch is not on origin. Set WORKTREE_HOOKS_SYNC=0 to turn this off and only log.

Things to know:

  • The hooks run in every repository where the plugin is enabled, and a broken script stops worktrees from being created in all of them.
  • .worktreeinclude is not processed when a hook creates the worktree, so files such as .env are not copied across.
  • Add .claude/worktrees/ to the repository's .gitignore.
  • The hooks are shell-form commands, which Claude Code runs through Git Bash on Windows, so they need Git for Windows. They are not started as bash directly, because on Windows that name can resolve to WSL's launcher.
  • WorktreeCreate fires for claude --worktree, isolation: "worktree" subagents and background sessions, not for the desktop app's Code tab.
  • In a worktree session, CLAUDE_PROJECT_DIR and a hook's working directory are the main checkout. Only the cwd in the hook's input is the worktree.

Debugging

Every hook appends to ~/.claude/logs/worktree-hooks.log (set WORKTREE_HOOKS_LOG to move it): its input, the Claude Code entry point (CLAUDE_CODE_ENTRYPOINT, such as cli or claude-desktop), which bash ran it, each step, and the line a failure stopped at. SessionStart also logs the session's folder, branch and whether it is a linked worktree, the branch it was made from and how that was found, then either the fast-forward or the reason it skipped one (no sync: …).

To find out whether a way of starting a session runs WorktreeCreate, start one and read the log:

  • A SessionStart entry for the new worktree with no WorktreeCreate entry before it: the worktree was made without the hook. Its branch is not named worktree-<name>.
  • No SessionStart entry either: the plugin's hooks did not load in that session. Check /hooks and the Errors tab of /plugin.
  • A WorktreeCreate entry ending in failed:: the hook ran and stopped at the logged line.

For Claude Code's own record of a hook, run it from the terminal with a debug log, then search the file for Hook :

claude --worktree hook-check --debug-file ./claude-debug.txt

To run a script by hand, pipe it the JSON Claude Code would send:

echo '{"name":"hook-check"}' | CLAUDE_PROJECT_DIR="$PWD" bash plugins/worktree-hooks/scripts/worktree-create.sh

hook-logger

A debugging plugin: it logs every hook event Claude Code fires, so you can see which events fire, when, and with what input. It changes nothing.

/plugin install hook-logger@auto-jira-log

Each event appends two lines to ~/.claude/logs/hook-events.log (set HOOK_LOGGER_LOG to move it): the time, event name, Claude Code entry point (cli or claude-desktop) and project folder, then the full JSON input.

It registers all 33 events in the hooks reference, with no matcher, so each fires on every occurrence. Two of them cannot just log: registering WorktreeCreate or WorktreeRemove replaces Claude Code's own worktree creation or removal. So after logging, the plugin does that work itself, and adds a result= line saying what it did:

  • WorktreeCreate makes .claude/worktrees/<name> on a new branch, worktree-<name>, from the project's current HEAD, and prints its path. Claude Code's own default starts from origin/<default branch> instead. .worktreeinclude is not processed.
  • WorktreeRemove removes the worktree and its worktree-* branch.

Don't enable it alongside worktree-hooks: both would answer WorktreeCreate.

FileChanged only fires for files named in its matcher, so with none it may never fire. Every hook starts Git Bash on Windows, which adds a little time to each tool call: disable the plugin when you are done.

usage-sync

Another plugin in this marketplace: add up your Claude Code usage across machines. Claude Code only reports usage for the machine it runs on, and an individual plan has no API to ask. This plugin reads each machine's transcripts into daily token counts and commits them to a GitHub repository of yours, one file per machine, for a dashboard to merge.

/plugin install usage-sync@auto-jira-log

It needs Node.js 18 or later on the PATH, since its hooks run node.

Set up

  1. Create the data repository on GitHub, for example claude-usage-data, with a README so its branch has a commit. Make it public if a static site reads it without a token.
  2. Create a fine-grained personal access token at github.com > Settings > Developer settings, with access to that repository only and Contents: Read and write.
  3. Fill in the plugin's options when you install it, or later from /plugin:
OptionWhat it is
Data repositoryowner/name, or the repository's URL
GitHub tokenThe token from step 2, kept in the system's credential store
Branchmain unless you use another
FolderWhere the device files go, devices by default
Device nameThis machine's file name, <device>.json; empty uses the host name
Device labelThe name a dashboard shows, such as Work laptop
Time zoneThe IANA zone days and hours are counted in, such as Asia/Kolkata. Set the same one on every machine; empty uses this machine's
Sync intervalMinutes a starting session waits after the last sync before it syncs again, 60 by default

Install it on every machine you use Claude Code on, with a different device name on each.

How it works

Two command hooks run scripts/usage-sync.mjs. SessionEnd syncs every time a session ends; SessionStart syncs when the last sync is older than the interval, which catches a session that was killed before its end hook ran. Each hook starts the sync in a background process and returns at once, so neither starting nor quitting Claude Code waits on the network.

A sync reads the transcripts under ~/.claude/projects (subagents' included), counts each day's sessions, messages and per-model tokens (input, output, cache read, cache write), reads the device's file from the repository, merges, and commits it if anything changed. The first sync reads every transcript Claude Code still keeps, so the history goes back as far as they do. Later syncs read only the transcripts that changed, plus the others that touch the same days, so a day is always counted whole. Days whose transcripts Claude Code has since deleted keep their last count, and for each day the fuller of the repository's copy and the machine's wins, so the record never shrinks.

The upload is the device's file alone, devices/<device>.json:

{
  "version": 2,
  "device": "macbook",
  "label": "MacBook Pro",
  "timeZone": "Asia/Kolkata",
  "updatedAt": "2026-10-12T09:30:00.000Z",
  "days": {
    "2026-10-12": {
      "sessions": 3,
      "messages": 412,
      "hours": [0, 0, 0, 0, 0, 0, 0, 0, 0, 37, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
      "models": {
        "claude-opus-5-5": { "input": 180, "output": 91662, "cacheRead": 19465751, "cacheWrite": 518138, "requests": 90 }
      }
    }
  }
}

The counts follow Claude Code's own stats, so they match what it shows on the machine:

  • messages counts the main conversation's prompts, tool results and responses. A subagent's entries are not messages, but its tokens are counted.
  • Tokens count each API call once, which is what the API processed, from the call's fullest entry (the one with the most output tokens). Claude Code logs a call once per content block and a resumed session replays earlier entries, so its own stats screen sums every entry and shows about twice these tokens. A subagent's calls count like any other.
  • sessions counts each session on the day it started, and hours counts sessions started per hour of the day, which is what a peak hour comes from.

The file carries a version. A file written under older rules (no version) is replaced day by day on the first sync after an update, keeping only days this machine no longer has. No prompt, file path, project name or session id leaves the machine.

Check on it

/usage-sync:status shows the device, where it uploads and how the last sync went. Every sync appends a line to ~/.claude/usage-sync/sync.log, the first place to look when nothing arrives.

To sync by hand from a terminal, give the options as USAGE_SYNC_<KEY> variables:

USAGE_SYNC_REPO=me/claude-usage-data USAGE_SYNC_TOKEN=... node <plugin folder>/scripts/usage-sync.mjs sync

Things to know:

  • Claude Code deletes transcripts after cleanupPeriodDays (30 by default), so install the plugin soon to keep the most history.
  • Sessions on claude.ai/code run in containers the plugin is not in.
  • The state the plugin keeps, ~/.claude/usage-sync/, survives an uninstall; the repository holds the record either way.

Tests: node --test plugins/usage-sync/tests/*.test.mjs.

Develop

claude plugin validate plugins/jira-log
claude plugin test plugins/jira-log
claude --plugin-dir plugins/jira-log

The same commands work for plugins/git-push, plugins/worktree-hooks, plugins/hook-logger and plugins/usage-sync.

Prompts

A plugin's prompts live in its hooks/prompts.ts. prompt-evals/ runs them against fixture commits, activity records and conversations with real model calls, and compares versions side by side:

npm install
npm run eval -- pull-request.draft --baseline main
npm run eval:view

The calls run through headless Claude Code on your Claude subscription; add --api to use the Claude API with ANTHROPIC_API_KEY instead.

Source 5 files
hooks/register.tsx 1122 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { JiraIssue, JiraNewIssue, JiraTransition, JiraUpdate, JiraView } from '../types'
5import {
6  addArtifact,
7  addOnce,
8  cappedSummary,
9  COMMIT,
10  currentSprint,
11  dayOf,
12  earlierDaysIn,
13  emptyIssue,
14  EMPTY_ACTIVITY,
15  IN_PROGRESS,
16  isIssueReady,
17  isReviewTool,
18  isSameStatus,
19  ISSUE_SHAPE,
20  newIssuePrompt,
21  OPEN,
22  parseNewIssue,
23  parseSuggestion,
24  parseUpdate,
25  POWERSHELL,
26  progressOf,
27  publishedArtifactOf,
28  reasonOf,
29  relativeTo,
30  reviewRequestOf,
31  reviewUrlIn,
32  SEAL,
33  sprintOf,
34  sprintOptions,
35  sprintValue,
36  TEST_RUN,
37  toDocument,
38  toIssue,
39  toIssueDocument,
40  toMarkdown,
41  toSprints,
42  toTransition,
43  UPDATE_AND_MOVE_SHAPE,
44  UPDATE_RULES,
45  UPDATE_SHAPE,
46  type CoveredDay,
47  type DailyComment,
48  type DayActivity,
49  type JiraConfig,
50  type RawComment,
51  type RawIssue,
52  type RawSprint,
53  type RawTransition,
54  type UnpostedDay,
55} from './format'
56import type { DescriptionProps } from './description'
57
58type Engine = EngineInterface
59
60const PANE = 'jira'
61const DESCRIPTION_PLACEHOLDER = 'the outcome wanted, why, and how to tell it is done'
62const USAGE =
63  'Usage: /jira [setup | link [KEY] | new [--manual | what the ticket is for] | update | status | unlink]'
64
65const view = atom({ plugin: 'jira-log', key: 'view' } as const, 'setup')
66const isConfigured = atom({ plugin: 'jira-log', key: 'isConfigured' } as const, false)
67const isSkipped = atom({ plugin: 'jira-log', key: 'isSkipped' } as const, false)
68const isProgressHidden = atom({ plugin: 'jira-log', key: 'isProgressHidden' } as const, false)
69const progress = atom({ plugin: 'jira-log', key: 'progress' } as const, null)
70const link = atom({ plugin: 'jira-log', key: 'link' } as const, null)
71const issues = atom({ plugin: 'jira-log', key: 'issues' } as const, [])
72const draft = atom({ plugin: 'jira-log', key: 'draft' } as const, null)
73const suggestion = atom({ plugin: 'jira-log', key: 'suggestion' } as const, null)
74const newIssue = atom({ plugin: 'jira-log', key: 'newIssue' } as const, null)
75const issueTypes = atom({ plugin: 'jira-log', key: 'issueTypes' } as const, [])
76const sprints = atom({ plugin: 'jira-log', key: 'sprints' } as const, [])
77const transitions = atom({ plugin: 'jira-log', key: 'transitions' } as const, [])
78const busy = atom({ plugin: 'jira-log', key: 'busy' } as const, null)
79const notice = atom({ plugin: 'jira-log', key: 'notice' } as const, null)
80
81type SetupField = 'site' | 'email' | 'token' | 'project'
82const SETUP_LABELS: Record<SetupField, string> = {
83  site: 'site URL',
84  email: 'email',
85  token: 'API token',
86  project: 'project key',
87}
88
89// Typed into the setup form but not yet saved; kept out of $.state so the token never lands there.
90const typed: Partial<Record<SetupField, string>> = {}
91let opened: { blob: string; token: string } | undefined
92// The store is read-modify-write, and parallel tool calls finish together.
93let recording: Promise<void> = Promise.resolve()
94// Actions recorded when the open draft was made: work done while reviewing it is not in the comment.
95let drafted: { actions: number | undefined; earlier: { day: string; actions: number }[] } | undefined
96// Earlier days change only when a comment is posted, so their unposted count is kept per issue and day.
97let earlierCount: { issue: string; day: string; actions: number } | undefined
98
99async function powershell($: Engine, script: string, stdin: string, what: string) {
100  const { exitCode, stdout, stderr } = await $.process.run([...POWERSHELL, script], { stdin })
101  if (exitCode !== 0) {
102    throw new Error(`could not ${what} the API token: ${stderr.trim().split('\n')[0]}`)
103  }
104
105  return stdout.trim()
106}
107
108async function jira<T>($: Engine, config: JiraConfig, method: string, path: string, body?: unknown) {
109  if (opened?.blob !== config.tokenBlob) {
110    opened = { blob: config.tokenBlob, token: await powershell($, OPEN, config.tokenBlob, 'decrypt') }
111  }
112  const response = await $.http.fetch(`${config.site}${path}`, {
113    method,
114    headers: {
115      Authorization: `Basic ${btoa(`${config.email}:${opened.token}`)}`,
116      Accept: 'application/json',
117      'Content-Type': 'application/json',
118    },
119    body: body === undefined ? undefined : JSON.stringify(body),
120  })
121  if (!response.ok) {
122    throw new Error(`Jira answered ${response.status} to ${method} ${path}: ${reasonOf(response.text)}`)
123  }
124
125  return (response.text === '' ? undefined : JSON.parse(response.text)) as T
126}
127
128async function whoAmI($: Engine, config: JiraConfig) {
129  return jira<{ displayName: string; accountId: string }>($, config, 'GET', '/rest/api/3/myself')
130}
131
132async function myOpenIssues($: Engine, config: JiraConfig) {
133  const found = await jira<{ issues: RawIssue[] }>($, config, 'POST', '/rest/api/3/search/jql', {
134    jql: 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC',
135    fields: ['summary', 'status'],
136    maxResults: 30,
137  })
138
139  return found.issues.map(toIssue)
140}
141
142async function getIssue($: Engine, config: JiraConfig, key: string) {
143  const path = `/rest/api/3/issue/${encodeURIComponent(key)}?fields=summary,status`
144
145  return toIssue(await jira<RawIssue>($, config, 'GET', path))
146}
147
148async function transitionsOf($: Engine, config: JiraConfig, key: string) {
149  const path = `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`
150
151  return (await jira<{ transitions: RawTransition[] }>($, config, 'GET', path)).transitions.map(toTransition)
152}
153
154async function applyTransition($: Engine, config: JiraConfig, key: string, id: string) {
155  const path = `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`
156  await jira($, config, 'POST', path, { transition: { id } })
157  const moved = await getIssue($, config, key)
158  if ((await read($, link))?.key === key) await setLink($, moved)
159
160  return moved
161}
162
163type RawIssueType = { name: string; subtask: boolean }
164
165async function creatableTypes($: Engine, config: JiraConfig, project: string) {
166  const path = `/rest/api/3/issue/createmeta/${encodeURIComponent(project)}/issuetypes`
167  const found = await jira<{ issueTypes?: RawIssueType[]; values?: RawIssueType[] }>($, config, 'GET', path)
168
169  return (found.issueTypes ?? found.values ?? []).filter(type => !type.subtask).map(type => type.name)
170}
171
172// Sprints live on the project's scrum boards, in Jira Software's own API; kanban boards have none.
173async function openSprints($: Engine, config: JiraConfig, project: string) {
174  const path = `/rest/agile/1.0/board?projectKeyOrId=${encodeURIComponent(project)}&type=scrum`
175  const boards = (await jira<{ values: { id: number }[] }>($, config, 'GET', path)).values
176  const lists = await Promise.all(
177    boards.map(async board => {
178      const sprintsPath = `/rest/agile/1.0/board/${board.id}/sprint?state=active,future`
179
180      return (await jira<{ values: RawSprint[] }>($, config, 'GET', sprintsPath)).values
181    }),
182  )
183
184  return toSprints(lists)
185}
186
187async function addToSprint($: Engine, config: JiraConfig, sprint: number, key: string) {
188  await jira($, config, 'POST', `/rest/agile/1.0/sprint/${sprint}/issue`, { issues: [key] })
189}
190
191// One comment per issue per day: today's earlier comment is replaced, unless
192// someone changed it in Jira since it was posted, in which case a new one is added.
193async function saveDailyComment(
194  $: Engine,
195  config: JiraConfig,
196  key: string,
197  day: string,
198  update: JiraUpdate,
199  previous: DailyComment | undefined,
200): Promise<DailyComment> {
201  const comments = `/rest/api/3/issue/${encodeURIComponent(key)}/comment`
202  const body = { body: toDocument(day, update) }
203  if (previous !== undefined) {
204    const current = await jira<RawComment>($, config, 'GET', `${comments}/${previous.id}`).catch(
205      () => undefined,
206    )
207    if (current?.updated === previous.updated) {
208      const saved = await jira<RawComment>($, config, 'PUT', `${comments}/${previous.id}`, body)
209
210      return { id: saved.id, updated: saved.updated, update }
211    }
212  }
213  const saved = await jira<RawComment>($, config, 'POST', comments, body)
214
215  return { id: saved.id, updated: saved.updated, update }
216}
217
218const activityKey = (issue: string, day: string) => `activity:${issue}:${day}`
219
220async function readActivity($: Engine, issue: string, day: string) {
221  return ((await $.store.get(activityKey(issue, day))) as DayActivity | undefined) ?? EMPTY_ACTIVITY
222}
223
224function record($: Engine, issue: string, day: string, change: (activity: DayActivity) => DayActivity) {
225  recording = recording
226    .then(async () => {
227      const activity = await readActivity($, issue, day)
228      await $.store.set(activityKey(issue, day), { ...change(activity), actions: (activity.actions ?? 0) + 1 })
229      if ((await read($, link))?.key === issue) await refreshProgress($, issue)
230    })
231    .catch(error => $.ui.log(`jira-log: could not record activity: ${error}`, { to: 'debug' }))
232
233  return recording
234}
235
236// A comment without a count predates counting and covered its whole day.
237async function postedActionsOn($: Engine, issue: string, day: string, actions: number) {
238  const posted = (await $.store.get(commentKey(issue, day))) as DailyComment | undefined
239  const covered = (await $.store.get(coveredKey(issue, day))) as CoveredDay | undefined
240
241  return Math.max(posted === undefined ? 0 : (posted.actions ?? actions), covered?.actions ?? 0)
242}
243
244async function unpostedDays($: Engine, issue: string, day: string) {
245  const found: UnpostedDay[] = []
246  for (const earlier of earlierDaysIn(await $.store.keys(), issue, day)) {
247    const activity = await readActivity($, issue, earlier)
248    const actions = activity.actions ?? 0
249    const unposted = actions - (await postedActionsOn($, issue, earlier, actions))
250    if (unposted > 0) found.push({ day: earlier, activity, actions, unposted })
251  }
252
253  return found
254}
255
256async function earlierUnposted($: Engine, issue: string, day: string) {
257  if (earlierCount?.issue !== issue || earlierCount.day !== day) {
258    const days = await unpostedDays($, issue, day)
259    earlierCount = { issue, day, actions: days.reduce((sum, one) => sum + one.unposted, 0) }
260  }
261
262  return earlierCount.actions
263}
264
265async function refreshProgress($: Engine, issue: string) {
266  const day = await today($)
267  const { actions = 0 } = await readActivity($, issue, day)
268  const posted = (await $.store.get(commentKey(issue, day))) as DailyComment | undefined
269  const current = {
270    day,
271    actions,
272    postedActions: posted === undefined ? 0 : (posted.actions ?? actions),
273    postedAt: posted === undefined ? null : (posted.postedAt ?? (Date.parse(posted.updated) || null)),
274    earlier: await earlierUnposted($, issue, day),
275  }
276  await update($, progress, () => current)
277  $.ui.status(`Jira ${issue}${progressOf(current, day).short ? ` · ${progressOf(current, day).short}` : ''}`)
278}
279
280async function recordCommand($: Engine, issue: string, command: string, isPassing: boolean, output: string) {
281  const day = await today($)
282  if (COMMIT.test(command)) {
283    if (!isPassing) return
284    const { exitCode, stdout } = await $.process.run(['git', 'log', '-1', '--format=%s'])
285    const subject = stdout.trim()
286    if (exitCode !== 0 || subject === '') return
287
288    return record($, issue, day, activity => ({ ...activity, commits: addOnce(activity.commits, subject, 50) }))
289  }
290  const pullRequest = isPassing ? reviewRequestOf(command, output) : undefined
291  if (pullRequest !== undefined) return recordReviewRequest($, issue, pullRequest)
292  if (TEST_RUN.test(command)) {
293    const test = { command: command.slice(0, 120), isPassing }
294
295    return record($, issue, day, activity => ({ ...activity, tests: [...activity.tests, test].slice(-20) }))
296  }
297}
298
299async function recordReviewRequest($: Engine, issue: string, pullRequest: string) {
300  return record($, issue, await today($), activity => ({
301    ...activity,
302    pullRequests: addOnce(activity.pullRequests, pullRequest, 20),
303  }))
304}
305
306async function recordArtifact($: Engine, issue: string, artifact: { url: string; title: string }) {
307  return record($, issue, await today($), activity => ({
308    ...activity,
309    artifacts: addArtifact(activity.artifacts, artifact),
310  }))
311}
312
313async function recordEdit($: Engine, issue: string, path: string) {
314  const file = relativeTo(await $.session.root(), path)
315
316  return record($, issue, await today($), activity => ({ ...activity, files: addOnce(activity.files, file, 200) }))
317}
318
319const loadConfig = async ($: Engine) =>
320  ((await $.store.get('config')) ?? {}) as Partial<JiraConfig>
321
322const isComplete = (config: Partial<JiraConfig>): config is JiraConfig =>
323  Boolean(config.site && config.email && config.tokenBlob)
324
325async function requireConfig($: Engine) {
326  const config = await loadConfig($)
327  if (isComplete(config)) return config
328  const missing = [
329    !config.site && 'site URL',
330    !config.email && 'email',
331    !config.tokenBlob && 'API token',
332  ].filter(Boolean)
333  throw new Error(`Jira is not set up yet, missing ${missing.join(', ')}: run /jira setup.`)
334}
335
336async function requireProject($: Engine) {
337  const config = await requireConfig($)
338  if (!config.project) throw new Error('creating issues needs a project key: add one in /jira setup.')
339
340  return { config, project: config.project }
341}
342
343const loadLinks = async ($: Engine) =>
344  ((await $.store.get('links')) ?? {}) as Record<string, JiraIssue>
345
346const commentKey = (issue: string, day: string) => `comment:${issue}:${day}`
347
348const coveredKey = (issue: string, day: string) => `covered:${issue}:${day}`
349
350const today = async ($: Engine) => dayOf(await $.clock.now())
351
352async function showStatus($: Engine, issue: JiraIssue | null) {
353  if (issue !== null) return refreshProgress($, issue.key)
354  await update($, progress, () => null)
355  $.ui.status(undefined)
356}
357
358const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
359
360const fail = ($: Engine) => (error: unknown) => $.ui.toast(`Jira: ${message(error)}`)
361
362async function attempt($: Engine, label: string, work: () => Promise<void>) {
363  await update($, busy, () => label)
364  await update($, notice, () => null)
365  try {
366    await work()
367  } catch (error) {
368    await update($, notice, () => message(error))
369    $.ui.toast(`Jira: ${message(error)}`)
370  } finally {
371    await update($, busy, () => null)
372  }
373}
374
375async function openPane($: Engine, next: JiraView) {
376  await update($, view, () => next)
377  await $.ui.open({ id: PANE, title: 'Jira', focus: true })
378}
379
380async function refresh($: Engine) {
381  const config = await loadConfig($)
382  await update($, isConfigured, () => isComplete(config))
383  const linked = (await loadLinks($))[await $.session.root()] ?? null
384  await update($, link, () => linked)
385  await showStatus($, linked)
386}
387
388async function setLink($: Engine, issue: JiraIssue | null) {
389  const root = await $.session.root()
390  const { [root]: _, ...others } = await loadLinks($)
391  await $.store.set('links', issue === null ? others : { ...others, [root]: issue })
392  await update($, link, () => issue)
393  await showStatus($, issue)
394}
395
396async function openPicker($: Engine) {
397  if (!isComplete(await loadConfig($))) return openPane($, 'setup')
398  await openPane($, 'pick')
399  await attempt($, 'Loading your open issues...', async () => {
400    const found = await myOpenIssues($, await requireConfig($))
401    await update($, issues, () => found)
402  })
403}
404
405async function linkTo($: Engine, issue: JiraIssue) {
406  await setLink($, issue)
407  await update($, isSkipped, () => false)
408  $.ui.toast(`Linked this worktree to ${issue.key}`)
409  await $.ui.close({ id: PANE })
410  if (issue.statusCategory === 'new') await advance($, issue.key, IN_PROGRESS, 'work started')
411}
412
413// Automatic moves never interrupt the work: a missing transition or an error is a toast.
414async function advance($: Engine, key: string, target: string, reason: string) {
415  try {
416    const config = await requireConfig($)
417    const current = await getIssue($, config, key)
418    if (isSameStatus(current.status, target) || current.statusCategory === 'done') return
419    const transition = (await transitionsOf($, config, key)).find(one => isSameStatus(one.to, target))
420    if (transition === undefined) {
421      $.ui.toast(`${key}: no transition from ${current.status} to ${target}; left as is`)
422      return
423    }
424    await applyTransition($, config, key, transition.id)
425    $.ui.toast(`Moved ${key} to ${target} (${reason})`)
426  } catch (error) {
427    $.ui.toast(`Jira: could not move ${key} to ${target}: ${message(error)}`)
428  }
429}
430
431async function openMoves($: Engine) {
432  const issue = await read($, link)
433  if (issue === null) return 'No Jira issue is linked to this worktree: run /jira link first.'
434  await openPane($, 'move')
435  await attempt($, `Loading the moves for ${issue.key}...`, async () => {
436    const found = await transitionsOf($, await requireConfig($), issue.key)
437    await update($, transitions, () => found)
438  })
439
440  return `Pick where ${issue.key} goes in the Jira pane.`
441}
442
443async function move($: Engine, id: string) {
444  const issue = await read($, link)
445  if (issue === null) return
446  await attempt($, `Moving ${issue.key}...`, async () => {
447    const moved = await applyTransition($, await requireConfig($), issue.key, id)
448    $.ui.toast(`Moved ${moved.key} to ${moved.status}`)
449    await $.ui.close({ id: PANE })
450  })
451}
452
453async function linkByKey($: Engine, key: string) {
454  const issue = await getIssue($, await requireConfig($), key.trim().toUpperCase())
455  await linkTo($, issue)
456
457  return `Linked this worktree to ${issue.key}: ${issue.summary} [${issue.status}].`
458}
459
460async function storeSetting($: Engine, field: SetupField, value: string) {
461  const trimmed = value.trim()
462  if (trimmed === '') return
463  let saved: Partial<JiraConfig>
464  if (field === 'token') {
465    saved = { ...(await loadConfig($)), tokenBlob: await powershell($, SEAL, trimmed, 'encrypt') }
466  } else if (field === 'project') {
467    saved = { ...(await loadConfig($)), project: trimmed.toUpperCase() }
468  } else if (field === 'site') {
469    const site = /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`
470    if (!site.startsWith('https://')) throw new Error('the site URL must use https.')
471    saved = { ...(await loadConfig($)), site: site.replace(/\/+$/, '') }
472  } else {
473    saved = { ...(await loadConfig($)), email: trimmed }
474  }
475  await $.store.set('config', saved)
476  delete typed[field]
477  await update($, isConfigured, () => isComplete(saved))
478  $.ui.invalidate('ui.render')
479}
480
481async function saveSetting($: Engine, field: SetupField, value: string) {
482  await attempt($, 'Saving...', async () => {
483    await storeSetting($, field, value)
484    await update($, notice, () => `Saved the ${SETUP_LABELS[field]}.`)
485  })
486}
487
488async function testConnection($: Engine) {
489  await attempt($, 'Connecting...', async () => {
490    for (const [field, value] of Object.entries(typed) as [SetupField, string][]) {
491      await storeSetting($, field, value)
492    }
493    const { displayName: name } = await whoAmI($, await requireConfig($))
494    $.ui.toast(`Connected to Jira as ${name}`)
495    if ((await read($, link)) === null) await openPicker($)
496    else await update($, notice, () => `Connected as ${name}.`)
497  })
498}
499
500// The fork reads this session's transcript; before the first reply there is none.
501async function ask($: Engine, prompt: string) {
502  const forked = await $.model.fork({ prompt })
503  const reply =
504    !forked.isAnswered && forked.reason === 'nothing-to-fork'
505      ? await $.model.complete({ model: await $.session.model(), prompt })
506      : forked
507  if (!reply.isAnswered) throw new Error(`the model gave no draft (${reply.reason})`)
508
509  return reply.text
510}
511
512async function draftPrompt(
513  $: Engine,
514  issue: JiraIssue,
515  day: string,
516  moves: readonly JiraTransition[],
517  unposted: readonly UnpostedDay[],
518) {
519  const activity = await readActivity($, issue.key, day)
520  const posted = (await $.store.get(commentKey(issue.key, day))) as DailyComment | undefined
521  const earlier = posted
522    ? `Already posted today; your draft replaces it, so keep what still holds:\n${JSON.stringify(posted.update)}`
523    : 'Nothing has been posted today yet.'
524  const statuses = moves.map(one => `"${one.to}"`).join(', ')
525  const days = unposted.map(one => one.day)
526  const byDay = Object.fromEntries(unposted.map(one => [one.day, one.activity]))
527
528  return [
529    `Draft today's progress comment for Jira issue ${issue.key} "${issue.summary}" (${day}).` +
530      (days.length === 0 ? '' : ` It also covers ${days.join(', ')}, whose work never reached Jira.`),
531    `Work recorded on this issue today across every Claude Code session:\n${JSON.stringify(activity, null, 2)}`,
532    ...(days.length === 0
533      ? []
534      : [`Work recorded on those earlier days, by day; include it too:\n${JSON.stringify(byDay, null, 2)}`]),
535    earlier,
536    'Use that record and this conversation.',
537    UPDATE_RULES,
538    `The issue is in "${issue.status}". Its workflow can move it to: ${statuses || 'nothing from here'}. ` +
539      'Suggest a move only when this conversation or the record shows the work has reached one of those ' +
540      'statuses: a pull or merge request opened (the record lists it, or the person says they raised one) ' +
541      'points to a review status; merged or finished points to a done status. Then set move.to to that exact ' +
542      'name and move.reason to one short sentence citing the evidence. Otherwise leave move.to empty; work ' +
543      'having happened is no reason to move.',
544    UPDATE_AND_MOVE_SHAPE,
545  ].join('\n\n')
546}
547
548async function startDraft($: Engine) {
549  const issue = await read($, link)
550  if (issue === null) return 'No Jira issue is linked to this worktree: run /jira link first.'
551  const config = await requireConfig($)
552  await update($, draft, () => null)
553  await update($, suggestion, () => null)
554  await openPane($, 'draft')
555  await attempt($, `Drafting today's update for ${issue.key}...`, async () => {
556    const day = await today($)
557    const unposted = await unpostedDays($, issue.key, day)
558    drafted = {
559      actions: (await readActivity($, issue.key, day)).actions,
560      earlier: unposted.map(one => ({ day: one.day, actions: one.actions })),
561    }
562    const current = await getIssue($, config, issue.key)
563    const moves = await transitionsOf($, config, issue.key).catch(() => [])
564    const reply = await ask($, await draftPrompt($, current, day, moves, unposted))
565    await update($, draft, () => parseUpdate(reply))
566    await update($, suggestion, () => parseSuggestion(reply, moves, current.status))
567  })
568
569  return `Drafted today's update for ${issue.key}: review it in the Jira pane.`
570}
571
572async function revise($: Engine, instruction: string) {
573  const current = await read($, draft)
574  if (current === null || instruction.trim() === '') return
575  await attempt($, 'Revising...', async () => {
576    const prompt =
577      `This is the current draft of today's Jira comment:\n${JSON.stringify(current)}\n\n` +
578      `Revise it as follows: ${instruction.trim()}\n\n${UPDATE_RULES}\n\n${UPDATE_SHAPE}`
579    const revised = parseUpdate(await ask($, prompt))
580    await update($, draft, () => revised)
581  })
582}
583
584async function post($: Engine) {
585  const [issue, current] = [await read($, link), await read($, draft)]
586  if (issue === null || current === null) return
587  await attempt($, `Posting to ${issue.key}...`, async () => {
588    const day = await today($)
589    const key = commentKey(issue.key, day)
590    const previous = (await $.store.get(key)) as DailyComment | undefined
591    const saved = await saveDailyComment($, await requireConfig($), issue.key, day, current, previous)
592    const snapshot = drafted ?? { actions: (await readActivity($, issue.key, day)).actions, earlier: [] }
593    await $.store.set(key, { ...saved, actions: snapshot.actions, postedAt: await $.clock.now() })
594    for (const one of snapshot.earlier) {
595      await $.store.set(coveredKey(issue.key, one.day), { actions: one.actions, in: day } satisfies CoveredDay)
596    }
597    earlierCount = undefined
598    await refreshProgress($, issue.key)
599    await update($, draft, () => null)
600    $.ui.toast(`${saved.id === previous?.id ? 'Updated' : 'Posted'} today's comment on ${issue.key}`)
601    if ((await read($, suggestion)) === null) await $.ui.close({ id: PANE })
602  })
603}
604
605async function acceptSuggestion($: Engine) {
606  const [issue, suggested] = [await read($, link), await read($, suggestion)]
607  if (issue === null || suggested === null) return
608  await attempt($, `Moving ${issue.key} to ${suggested.to}...`, async () => {
609    const moved = await applyTransition($, await requireConfig($), issue.key, suggested.id)
610    await update($, suggestion, () => null)
611    $.ui.toast(`Moved ${moved.key} to ${moved.status}`)
612    if ((await read($, draft)) === null) await $.ui.close({ id: PANE })
613  })
614}
615
616async function declineSuggestion($: Engine) {
617  await update($, suggestion, () => null)
618  if ((await read($, draft)) === null) await $.ui.close({ id: PANE })
619}
620
621async function discard($: Engine) {
622  await update($, draft, () => null)
623  await update($, suggestion, () => null)
624  await update($, newIssue, () => null)
625  await $.ui.close({ id: PANE })
626}
627
628// With `isManual` the model is not asked: the person fills in a blank ticket in the pane.
629async function startCreate($: Engine, focus = '', isManual = false) {
630  const { config, project } = await requireProject($)
631  await update($, newIssue, () => null)
632  await openPane($, 'create')
633  await attempt($, isManual ? `Opening a new ${project} issue...` : `Drafting a new ${project} issue...`, async () => {
634    // Without Jira Software, or a scrum board, there are no sprints and the issue goes to the backlog.
635    const [types, open] = await Promise.all([
636      creatableTypes($, config, project),
637      openSprints($, config, project).catch(error => {
638        $.ui.log(`jira-log: could not load the sprints of ${project}: ${message(error)}`, { to: 'debug' })
639        return []
640      }),
641    ])
642    await update($, issueTypes, () => types)
643    await update($, sprints, () => open)
644    const fallback = types.find(type => isSameStatus(type, 'Task')) ?? types[0] ?? 'Task'
645    const blank = emptyIssue(fallback, currentSprint(open))
646    const started = isManual ? blank : { ...blank, ...parseNewIssue(await ask($, newIssuePrompt(focus.trim()))) }
647    await update($, newIssue, () => started)
648  })
649
650  return isManual
651    ? `Opened a blank ${project} issue: write it in the Jira pane.`
652    : `Drafted a new ${project} issue: review it in the Jira pane.`
653}
654
655const editIssue = ($: Engine, change: Partial<JiraNewIssue>) =>
656  update($, newIssue, one => (one === null ? one : { ...one, ...change }))
657
658async function reviseIssue($: Engine, instruction: string) {
659  const current = await read($, newIssue)
660  if (current === null || instruction.trim() === '') return
661  await attempt($, 'Revising...', async () => {
662    const { summary, description } = current
663    const prompt =
664      `This is the current draft of a new Jira ticket:\n${JSON.stringify({ summary, description })}\n\n` +
665      `Revise it as follows: ${instruction.trim()}\n\n` +
666      'Keep it a description of work to be done, under 120 words.\n\n' +
667      ISSUE_SHAPE
668    const revised = parseNewIssue(await ask($, prompt))
669    await update($, newIssue, one => (one === null ? one : { ...one, ...revised }))
670  })
671}
672
673async function createIssue($: Engine) {
674  const current = await read($, newIssue)
675  if (current === null || !isIssueReady(current)) return
676  await attempt($, 'Creating the issue...', async () => {
677    const { config, project } = await requireProject($)
678    const created = await jira<{ key: string }>($, config, 'POST', '/rest/api/3/issue', {
679      fields: {
680        project: { key: project },
681        summary: current.summary.trim(),
682        issuetype: { name: current.issueType },
683        description: toIssueDocument(current),
684      },
685    })
686    if (current.sprint !== null) {
687      const name = (await read($, sprints)).find(one => one.id === current.sprint)?.name ?? 'the sprint'
688      await addToSprint($, config, current.sprint, created.key).catch(error =>
689        $.ui.toast(`Created ${created.key} but could not add it to ${name}: ${message(error)}`),
690      )
691    }
692    const { accountId } = await whoAmI($, config)
693    const assignee = `/rest/api/3/issue/${encodeURIComponent(created.key)}/assignee`
694    await jira($, config, 'PUT', assignee, { accountId }).catch(error =>
695      $.ui.toast(`Created ${created.key} but could not assign it to you: ${message(error)}`),
696    )
697    await update($, newIssue, () => null)
698    await linkTo($, await getIssue($, config, created.key))
699  })
700}
701
702export const register: Register = on => {
703  on('session.start', async ($, e, next) => {
704    await $.command.register({
705      name: 'jira',
706      description: "Link this worktree to a Jira issue and post today's update",
707      argumentHint: '[setup | link [KEY] | new [--manual | what for] | update | status | unlink]',
708    })
709    await refresh($)
710
711    return next(e)
712  })
713
714  on('command.run', { command: 'jira' }, async ($, e) => {
715    const [verb = '', argument = ''] = e.args.trim().split(/\s+/)
716    try {
717      switch (verb.toLowerCase()) {
718        case 'setup':
719          await openPane($, 'setup')
720          return { text: 'Jira setup is open in the Jira pane.' }
721        case 'link':
722          if (argument !== '') return { text: await linkByKey($, argument) }
723          await openPicker($)
724          return { text: 'Pick an issue in the Jira pane.' }
725        case 'unlink':
726          await setLink($, null)
727          return { text: 'This worktree is no longer linked to a Jira issue.' }
728        case 'update':
729          return { text: await startDraft($) }
730        case 'new':
731          if (argument.toLowerCase() === '--manual') return { text: await startCreate($, '', true) }
732          return { text: await startCreate($, e.args.trim().slice(verb.length)) }
733        case 'status':
734          return { text: await openMoves($) }
735        case '': {
736          const issue = await read($, link)
737          if (!isComplete(await loadConfig($))) return { text: 'Jira is not set up yet: run /jira setup.' }
738          return {
739            text:
740              issue === null
741                ? 'No Jira issue is linked to this worktree: run /jira link.'
742                : `Linked to ${issue.key}: ${issue.summary} [${issue.status}]. Run /jira update to draft today's comment.`,
743          }
744        }
745        default:
746          return { text: USAGE }
747      }
748    } catch (error) {
749      return { text: `Jira: ${message(error)}` }
750    }
751  })
752
753  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
754    const ran = await next(e)
755    const issue = await read($, link)
756    if (issue !== null && ran.deny === undefined && ran.isError !== true) {
757      void recordEdit($, issue.key, e.file_path)
758    }
759
760    return ran
761  })
762
763  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
764    const ran = await next(e)
765    const issue = await read($, link)
766    if (issue !== null && ran.deny === undefined && ran.isError !== true) {
767      void recordEdit($, issue.key, e.file_path)
768    }
769
770    return ran
771  })
772
773  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
774    const ran = await next(e)
775    const issue = await read($, link)
776    if (issue !== null && ran.deny === undefined) {
777      void recordCommand($, issue.key, e.command, ran.isError !== true, ran.text ?? '')
778    }
779
780    return ran
781  })
782
783  on('tool.call', { tool: 'Artifact' }, async ($, e, next) => {
784    const ran = await next(e)
785    const issue = await read($, link)
786    const artifact = publishedArtifactOf(e, ran.result, ran.text ?? '')
787    if (issue !== null && artifact !== undefined && ran.deny === undefined && ran.isError !== true) {
788      void recordArtifact($, issue.key, artifact)
789    }
790
791    return ran
792  })
793
794  on('tool.call', async ($, e, next) => {
795    const ran = await next(e)
796    if (!isReviewTool(e.tool) || ran.deny !== undefined || ran.isError === true) return ran
797    const issue = await read($, link)
798    if (issue !== null) void recordReviewRequest($, issue.key, reviewUrlIn(ran.text ?? '') ?? e.tool)
799
800    return ran
801  })
802
803  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
804    if (e.props.hasSurvey) return next(e)
805    const { Box, Button, Text } = $.ui.resolve(e)
806    // The band holds one tree: stack what the plugins beneath draw (git-push's line) under ours,
807    // with a rule between. Nothing beneath leaves the engine's own band, which draws no line.
808    const stacked = async (row: RenderElement) => {
809      const below = await next(e)
810
811      return (
812        <Box flexDirection="column">
813          {row}
814          {below.type !== 'engine' && <Text dimColor wrap="truncate">{'─'.repeat(e.props.bodyColumns)}</Text>}
815          {below}
816        </Box>
817      )
818    }
819    const linked = await read($, link)
820    if (linked !== null) {
821      if (await read($, isProgressHidden)) return next(e)
822      const { tone, text } = progressOf(await read($, progress), await today($))
823
824      return stacked(
825        <Box flexDirection="row" gap={1}>
826          <Text dimColor>
827            {linked.key} · {linked.status} ·
828          </Text>
829          <Text dimColor={tone === 'quiet'} color={tone === 'due' ? 'warning' : tone === 'done' ? 'success' : undefined}>
830            {text}
831          </Text>
832          {tone === 'due' && (
833            <Button
834              key="draft-update"
835              label={text.startsWith('posted') ? 'Update' : 'Draft update'}
836              variant="primary"
837              onPress={() => void startDraft($).catch(fail($))}
838            />
839          )}
840          <Button key="hide-progress" label="Hide" onPress={() => void update($, isProgressHidden, () => true)} />
841        </Box>
842      )
843    }
844    if (await read($, isSkipped)) return next(e)
845    const isReady = await read($, isConfigured)
846
847    return stacked(
848      <Box flexDirection="row" gap={1}>
849        <Text dimColor>{isReady ? 'Jira: no issue linked to this worktree' : 'Jira log is not set up'}</Text>
850        <Button
851          key="link"
852          label={isReady ? 'Pick issue' : 'Set up'}
853          variant="primary"
854          onPress={() => void (isReady ? openPicker($) : openPane($, 'setup'))}
855        />
856        {isReady && <Button key="new" label="Create new" onPress={() => void startCreate($).catch(fail($))} />}
857        {isReady && (
858          <Button key="manual" label="Write it myself" onPress={() => void startCreate($, '', true).catch(fail($))} />
859        )}
860        <Button key="skip" label="Skip" onPress={() => void update($, isSkipped, () => true)} />
861      </Box>
862    )
863  })
864
865  // The description area posts its whole text on each change.
866  on('ui.message', async ($, e, next) => {
867    const { description } = (e.data ?? {}) as { description?: unknown }
868    if (e.requestId !== PANE || e.element !== 'description' || typeof description !== 'string') return next(e)
869    await editIssue($, { description })
870
871    return {}
872  })
873
874  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
875    if (e.surface === 'mobile') {
876      const { Text } = $.ui.resolve(e)
877
878      return <Text dimColor>The Jira pane needs the terminal or the desktop app.</Text>
879    }
880    const { Box, Button, Input, Markdown, Select, Text } = $.ui.resolve(e)
881    // VS Code draws no Client, so there the description is a one-line field.
882    const { Client } = e.surface === 'vscode' ? { Client: undefined } : $.ui.resolve(e)
883    const working = await read($, busy)
884    const said = await read($, notice)
885    const footer = [
886      working !== null && <Text dimColor>{working}</Text>,
887      said !== null && <Text color="yellow">{said}</Text>,
888    ]
889    const current = await read($, view)
890
891    if (current === 'setup') {
892      const config = await loadConfig($)
893
894      return (
895        <Box flexDirection="column" gap={1}>
896          <Text bold>Jira Cloud connection</Text>
897          <Input
898            key="site"
899            label="Site URL  "
900            placeholder="https://your-team.atlassian.net"
901            value={typed.site ?? config.site}
902            onInput={value => void (typed.site = value)}
903            onSubmit={value => void saveSetting($, 'site', value)}
904          />
905          <Input
906            key="email"
907            label="Email     "
908            placeholder="the email you sign in to Jira with"
909            value={typed.email ?? config.email}
910            onInput={value => void (typed.email = value)}
911            onSubmit={value => void saveSetting($, 'email', value)}
912          />
913          <Input
914            key="token"
915            label="API token "
916            placeholder={
917              config.tokenBlob
918                ? 'saved and encrypted; paste a new one to replace it'
919                : 'create one at id.atlassian.com > Security > API tokens'
920            }
921            onInput={value => void (typed.token = value)}
922            onSubmit={value => void saveSetting($, 'token', value)}
923          />
924          <Input
925            key="project"
926            label="Project   "
927            placeholder="key new issues go to, e.g. CPC (optional)"
928            value={typed.project ?? config.project}
929            onInput={value => void (typed.project = value)}
930            onSubmit={value => void saveSetting($, 'project', value)}
931          />
932          <Button
933            key="test"
934            label="Save and test connection"
935            variant="primary"
936            onPress={() => void testConnection($)}
937          />
938          {footer}
939        </Box>
940      )
941    }
942
943    if (current === 'pick') {
944      const list = await read($, issues)
945
946      return (
947        <Box flexDirection="column" gap={1}>
948          <Text bold>Link this worktree to one of your open issues</Text>
949          {list.length > 0 && (
950            <Select
951              key="issue"
952              options={list.map(issue => ({
953                value: issue.key,
954                label: `${issue.key}  ${issue.summary}  [${issue.status}]`,
955              }))}
956              onSelect={key => {
957                const issue = list.find(one => one.key === key)
958                if (issue !== undefined) void linkTo($, issue)
959              }}
960            />
961          )}
962          {working === null && list.length === 0 && <Text dimColor>No open issues are assigned to you.</Text>}
963          <Input
964            key="by-key"
965            label="Or a key "
966            placeholder="PROJ-123"
967            submitLabel="link"
968            onSubmit={value => void attempt($, `Looking up ${value}...`, () => linkByKey($, value).then(() => {}))}
969          />
970          <Button key="cancel" label="Cancel" role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
971          {footer}
972        </Box>
973      )
974    }
975
976    if (current === 'create') {
977      const drafted = await read($, newIssue)
978      const types = await read($, issueTypes)
979      const open = await read($, sprints)
980
981      return (
982        <Box flexDirection="column" gap={1}>
983          <Text bold>New issue</Text>
984          {drafted !== null && working !== null && <Text bold>{drafted.summary}</Text>}
985          {drafted !== null && working !== null && drafted.description !== '' && <Text>{drafted.description}</Text>}
986          {drafted !== null && working === null && (
987            <Box flexDirection="column" gap={1}>
988              <Input
989                key="summary"
990                label="Title       "
991                placeholder="what needs doing, as a short imperative title"
992                value={drafted.summary}
993                onInput={value => void editIssue($, { summary: cappedSummary(value) })}
994                onSubmit={value => void editIssue($, { summary: cappedSummary(value) })}
995              />
996              {Client === undefined ? (
997                <Input
998                  key="description"
999                  label="Description "
1000                  placeholder={DESCRIPTION_PLACEHOLDER}
1001                  value={drafted.description}
1002                  onInput={value => void editIssue($, { description: value })}
1003                  onSubmit={value => void editIssue($, { description: value })}
1004                />
1005              ) : (
1006                <Box flexDirection="column">
1007                  <Box flexDirection="row" gap={1}>
1008                    <Text>Description</Text>
1009                    <Text dimColor>click to write · Enter adds a line · Esc leaves the box</Text>
1010                  </Box>
1011                  <Client
1012                    key="description"
1013                    module="./description.tsx"
1014                    width="100%"
1015                    props={{ text: drafted.description, placeholder: DESCRIPTION_PLACEHOLDER } satisfies DescriptionProps}
1016                  />
1017                </Box>
1018              )}
1019              {types.length > 0 && (
1020                <Select
1021                  key="type"
1022                  label="Type   "
1023                  value={drafted.issueType}
1024                  options={types.map(type => ({ value: type, label: type }))}
1025                  onSelect={type => void editIssue($, { issueType: type })}
1026                />
1027              )}
1028              {open.length > 0 && (
1029                <Select
1030                  key="sprint"
1031                  label="Sprint "
1032                  value={sprintValue(drafted.sprint)}
1033                  options={sprintOptions(open)}
1034                  onSelect={value => void editIssue($, { sprint: sprintOf(value) })}
1035                />
1036              )}
1037              {isIssueReady(drafted) && (
1038                <Input
1039                  key="revise-issue"
1040                  label="Revise "
1041                  placeholder="e.g. make it a bug fix and mention the login page"
1042                  submitLabel="revise"
1043                  onSubmit={value => void reviseIssue($, value)}
1044                />
1045              )}
1046              <Box flexDirection="row" gap={2}>
1047                {isIssueReady(drafted) && (
1048                  <Button key="create" label="Create and link" variant="primary" onPress={() => void createIssue($)} />
1049                )}
1050                <Button key="discard" label="Discard" role="dismiss" onPress={() => void discard($)} />
1051              </Box>
1052            </Box>
1053          )}
1054          {footer}
1055        </Box>
1056      )
1057    }
1058
1059    if (current === 'move') {
1060      const [issue, moves] = [await read($, link), await read($, transitions)]
1061
1062      return (
1063        <Box flexDirection="column" gap={1}>
1064          <Text bold>{issue === null ? 'No linked issue' : `Move ${issue.key} (now ${issue.status})`}</Text>
1065          {moves.length > 0 && (
1066            <Select
1067              key="move"
1068              options={moves.map(one => ({
1069                value: one.id,
1070                label: isSameStatus(one.name, one.to) ? one.to : `${one.name} → ${one.to}`,
1071              }))}
1072              onSelect={id => void move($, id)}
1073            />
1074          )}
1075          {working === null && moves.length === 0 && <Text dimColor>No moves are available from here.</Text>}
1076          <Button key="cancel" label="Cancel" role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
1077          {footer}
1078        </Box>
1079      )
1080    }
1081
1082    const issue = await read($, link)
1083    const drafted = await read($, draft)
1084    const suggested = await read($, suggestion)
1085
1086    return (
1087      <Box flexDirection="column" gap={1}>
1088        <Text bold>{issue === null ? 'No linked issue' : `${issue.key}: today's update`}</Text>
1089        {suggested !== null && issue !== null && working === null && (
1090          <Box flexDirection="column">
1091            <Text>
1092              Suggested: move {issue.key} from {issue.status} to {suggested.to}
1093            </Text>
1094            {suggested.reason !== '' && <Text dimColor>{suggested.reason}</Text>}
1095            <Box flexDirection="row" gap={2}>
1096              <Button key="accept-move" label={`Move to ${suggested.to}`} onPress={() => void acceptSuggestion($)} />
1097              <Button key="decline-move" label="Not now" onPress={() => void declineSuggestion($)} />
1098            </Box>
1099          </Box>
1100        )}
1101        {drafted !== null && <Markdown text={toMarkdown(drafted)} />}
1102        {drafted !== null && working === null && (
1103          <Box flexDirection="column" gap={1}>
1104            <Input
1105              key="revise"
1106              label="Revise "
1107              placeholder="e.g. mention we are waiting on staging access"
1108              submitLabel="revise"
1109              onSubmit={value => void revise($, value)}
1110            />
1111            <Box flexDirection="row" gap={2}>
1112              <Button key="post" label="Post to Jira" variant="primary" onPress={() => void post($)} />
1113              <Button key="discard" label="Discard" role="dismiss" onPress={() => void discard($)} />
1114            </Box>
1115          </Box>
1116        )}
1117        {footer}
1118      </Box>
1119    )
1120  })
1121}
1122
hooks/format.ts 338 lines
1import type { JiraIssue, JiraNewIssue, JiraProgress, JiraSprint, JiraSuggestion, JiraTransition, JiraUpdate } from '../types'
2
3export type JiraConfig = { site: string; email: string; tokenBlob: string; project?: string }
4
5// Older comments carry no `actions` or `postedAt`, and their `update` holds completed, pending, blockers
6// and achievements lists in place of `notes`.
7export type DailyComment = { id: string; updated: string; update: JiraUpdate; actions?: number; postedAt?: number }
8
9export type DayActivity = {
10  files: string[]
11  commits: string[]
12  tests: { command: string; isPassing: boolean }[]
13  // A pull or merge request's URL, or the command or tool that opened it when no URL was printed.
14  pullRequests: string[]
15  // Published Artifacts by URL; the title is empty when no publish named one. Absent on older days.
16  artifacts?: { url: string; title: string }[]
17  // Every action recorded today, counted even when the lists above dedupe or roll it off; absent on older days.
18  actions?: number
19}
20
21// An earlier day posted under a later day's comment rather than one of its own.
22export type CoveredDay = { actions: number; in: string }
23
24export type UnpostedDay = { day: string; activity: DayActivity; actions: number; unposted: number }
25
26export type RawIssue = {
27  key: string
28  fields: { summary: string; status: { name: string; statusCategory: { key: string } } }
29}
30
31export type RawComment = { id: string; updated: string }
32
33export type RawTransition = { id: string; name: string; to: { name: string } }
34
35export type RawSprint = { id: number; name: string; state: string }
36
37export const IN_PROGRESS = 'In Progress'
38
39export const EMPTY_ACTIVITY: DayActivity = {
40  files: [],
41  commits: [],
42  tests: [],
43  pullRequests: [],
44  artifacts: [],
45  actions: 0,
46}
47
48// How far back unposted work is still offered to the draft.
49export const LOOKBACK_DAYS = 14
50
51const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`
52
53const clockOf = (at: number) => {
54  const date = new Date(at)
55
56  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
57}
58
59// What the band and status line say about the linked issue's work that is not in Jira yet.
60export function progressOf(progress: JiraProgress | null, day: string) {
61  const current = progress?.day === day ? progress : null
62  const actions = current?.actions ?? 0
63  const earlier = current?.earlier ?? 0
64  const unposted = Math.max(0, actions - (current?.postedActions ?? 0)) + earlier
65  if (current?.postedAt == null) {
66    if (unposted === 0) return { tone: 'quiet', text: 'nothing recorded today', short: '' }
67    const text =
68      earlier === 0
69        ? `${plural(actions, 'action')} today, not in Jira yet`
70        : actions === 0
71          ? `${plural(earlier, 'action')} from earlier days, not in Jira yet`
72          : `${plural(actions, 'action')} today and ${earlier} earlier, not in Jira yet`
73
74    return { tone: 'due', text, short: `${unposted} unposted` }
75  }
76  if (unposted === 0) {
77    return { tone: 'done', text: `✓ today's update posted ${clockOf(current.postedAt)}`, short: '✓' }
78  }
79
80  return {
81    tone: 'due',
82    text: `posted ${clockOf(current.postedAt)}, ${plural(unposted, 'action')} ${earlier === 0 ? 'since' : 'not in Jira yet'}`,
83    short: `${unposted} unposted`,
84  }
85}
86
87export const TEST_RUN =
88  /\b(pytest|vitest|jest|mocha|unittest|go test|cargo test|(?:npm|pnpm|yarn|bun)(?: run)? test)\b/
89export const COMMIT = /\bgit\s+commit\b/
90const REVIEW_COMMAND = /\b(gh\s+pr\s+create|glab\s+mr\s+create|az\s+repos\s+pr\s+create)\b/
91const REVIEW_TOOL = /create_?(?:pull_?request|merge_?request)/i
92// GitHub /pull/7, GitLab /merge_requests/7, Azure DevOps /pullrequest/7.
93const REVIEW_URL = /https?:\/\/[^\s"'<>]+\/(?:pull|merge_requests|pullrequest)\/\d+/
94const ARTIFACT_URL = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[\w-]+/
95
96// DPAPI: a sealed blob opens only for this Windows user on this machine.
97export const POWERSHELL = ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command']
98export const SEAL =
99  '$t = [Console]::In.ReadToEnd().Trim(); ' +
100  'ConvertTo-SecureString -String $t -AsPlainText -Force | ConvertFrom-SecureString'
101export const OPEN =
102  '$b = [Console]::In.ReadToEnd().Trim(); $s = ConvertTo-SecureString -String $b; ' +
103  '[Runtime.InteropServices.Marshal]::PtrToStringBSTR(' +
104  '[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))'
105
106export const UPDATE_RULES = [
107  'Rules: these are plain notes, not sections. A weekly report is built from them later and sorts the work ' +
108    'into its own sections, so never group or label the notes.',
109  '- 2 to 6 notes, in the order the work happened, each one line of at most 25 words.',
110  '- Each note says what was done and what it achieved. When something went wrong, say what and how it was ' +
111    'resolved, or that it is still open and who or what it waits on.',
112  '- Mention a next step only when it was decided today.',
113  '- Never narrate the session, list files, names or terms, or mention housekeeping such as committing or pushing.',
114  '- Plain words a teammate outside the project understands. No code, diffs, file paths, secrets or internal ' +
115    'hostnames. Never invent anything.',
116].join('\n')
117
118export const UPDATE_SHAPE = 'Answer with only this JSON object and nothing else:\n{"notes": []}'
119
120export const UPDATE_AND_MOVE_SHAPE =
121  'Answer with only this JSON object and nothing else:\n{"notes": [], "move": {"to": "", "reason": ""}}'
122
123export const ISSUE_SHAPE =
124  'Answer with only this JSON object and nothing else:\n{"summary": "", "description": ""}'
125
126export function newIssuePrompt(focus: string) {
127  const subject = focus
128    ? `The person asked for: "${focus}". That decides what the ticket is about. Use this conversation ` +
129      'only for details that serve it, and leave out everything else the conversation covered.'
130    : 'Base it on the main piece of work in this conversation.'
131
132  return [
133    'Draft a Jira ticket: a description of work to be done, not a record of what this conversation did.',
134    subject,
135    [
136      'Rules:',
137      '- Write it as work ahead, for a teammate who has not seen the conversation. Never narrate what was ' +
138        'discussed, tried or found, or what exists so far.',
139      '- summary: an imperative title under 80 characters.',
140      '- description: 2 to 4 plain sentences: the outcome wanted, why, and how to tell it is done. No ' +
141        'headings or lists.',
142      '- Under 120 words in all. No code, file paths, diffs, secrets or internal hostnames.',
143    ].join('\n'),
144    ISSUE_SHAPE,
145  ].join('\n\n')
146}
147
148export function reviewRequestOf(command: string, output: string) {
149  const opened = command.match(REVIEW_COMMAND)?.[1]
150
151  return opened === undefined ? undefined : (output.match(REVIEW_URL)?.[0] ?? opened.replace(/\s+/g, ' '))
152}
153
154export const isReviewTool = (tool: string) => tool.startsWith('mcp__') && REVIEW_TOOL.test(tool)
155
156export const reviewUrlIn = (output: string) => output.match(REVIEW_URL)?.[0]
157
158type ArtifactCall = { action?: string; asset?: boolean; url?: string; title?: string }
159
160// A publish of a page or its files, not an asset upload. A create names its URL only in what it answered,
161// and an update names its page's title only there.
162export function publishedArtifactOf(input: ArtifactCall, result: unknown, output: string) {
163  if ((input.action ?? 'publish') !== 'publish' || input.asset === true) return undefined
164  const answered = (typeof result === 'object' && result !== null ? result : {}) as { url?: unknown; title?: unknown }
165  const url = [input.url, answered.url, output]
166    .map(one => (typeof one === 'string' ? one.match(ARTIFACT_URL)?.[0] : undefined))
167    .find(one => one !== undefined)
168  const title = [input.title, answered.title].find(one => typeof one === 'string' && one.trim() !== '')
169
170  return url === undefined ? undefined : { url, title: typeof title === 'string' ? title.trim() : '' }
171}
172
173export function addArtifact(list: DayActivity['artifacts'] = [], artifact: { url: string; title: string }) {
174  const known = list.find(one => one.url === artifact.url)
175  if (known === undefined) return [...list, artifact].slice(-50)
176  if (known.title !== '' || artifact.title === '') return list
177
178  return list.map(one => (one === known ? artifact : one))
179}
180
181export const toTransition = ({ id, name, to }: RawTransition): JiraTransition => ({ id, name, to: to.name })
182
183export const isSameStatus = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
184
185export const toIssue = ({ key, fields }: RawIssue): JiraIssue => ({
186  key,
187  summary: fields.summary,
188  status: fields.status.name,
189  statusCategory: fields.status.statusCategory.key,
190})
191
192export function reasonOf(text: string) {
193  try {
194    const body = JSON.parse(text) as { errorMessages?: string[]; errors?: Record<string, string> }
195    const reasons = [...(body.errorMessages ?? []), ...Object.values(body.errors ?? {})]
196    if (reasons.length > 0) return reasons.join('; ')
197  } catch {}
198
199  return text.slice(0, 200)
200}
201
202export function dayOf(now: number) {
203  const date = new Date(now)
204  const pad = (n: number) => String(n).padStart(2, '0')
205
206  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
207}
208
209export function dayBefore(day: string, count: number) {
210  const [year = 0, month = 1, date = 1] = day.split('-').map(Number)
211
212  return dayOf(new Date(year, month - 1, date - count).getTime())
213}
214
215// The earlier days within the lookback, oldest first, that the store holds activity for on this issue.
216export function earlierDaysIn(keys: readonly string[], issue: string, day: string) {
217  const prefix = `activity:${issue}:`
218  const oldest = dayBefore(day, LOOKBACK_DAYS)
219
220  return keys
221    .filter(key => key.startsWith(prefix))
222    .map(key => key.slice(prefix.length))
223    .filter(one => one >= oldest && one < day)
224    .sort()
225}
226
227export function relativeTo(root: string, path: string) {
228  const [file, base] = [path.replaceAll('\\', '/'), `${root.replaceAll('\\', '/')}/`]
229
230  return file.toLowerCase().startsWith(base.toLowerCase()) ? file.slice(base.length) : file
231}
232
233export const addOnce = (list: string[], item: string, limit: number) =>
234  list.includes(item) ? list : [...list, item].slice(-limit)
235
236const objectIn = (reply: string) => JSON.parse(reply.slice(reply.indexOf('{'), reply.lastIndexOf('}') + 1))
237
238const listOf = (value: unknown) =>
239  Array.isArray(value)
240    ? value.filter((item): item is string => typeof item === 'string' && item.trim() !== '').map(item => item.trim())
241    : []
242
243// The ticket's text only: its type and sprint are picked in the form, not by the model.
244export function parseNewIssue(reply: string): Pick<JiraNewIssue, 'summary' | 'description'> {
245  const parsed = objectIn(reply) as { summary?: unknown; description?: unknown }
246  const summary = typeof parsed.summary === 'string' ? cappedSummary(parsed.summary.trim()) : ''
247  if (summary === '') throw new Error('the model drafted no summary')
248
249  return { summary, description: typeof parsed.description === 'string' ? parsed.description.trim() : '' }
250}
251
252// Only a move the workflow allows from here is suggested; anything else the model names is dropped.
253export function parseSuggestion(
254  reply: string,
255  moves: readonly JiraTransition[],
256  status: string,
257): JiraSuggestion | null {
258  const { move } = objectIn(reply) as { move?: { to?: unknown; reason?: unknown } }
259  const to = typeof move?.to === 'string' ? move.to : ''
260  const transition = moves.find(one => isSameStatus(one.to, to))
261  if (to === '' || transition === undefined || isSameStatus(to, status)) return null
262
263  return { id: transition.id, to: transition.to, reason: typeof move?.reason === 'string' ? move.reason.trim() : '' }
264}
265
266export function parseUpdate(reply: string): JiraUpdate {
267  return { notes: listOf((objectIn(reply) as { notes?: unknown }).notes) }
268}
269
270export const toMarkdown = (update: JiraUpdate) =>
271  update.notes.map(note => `- ${note}`).join('\n') || '_Nothing to report._'
272
273const SUMMARY_LIMIT = 255
274
275// A ticket the person writes themselves starts blank; only the summary is required.
276export const emptyIssue = (issueType: string, sprint: number | null = null): JiraNewIssue => ({
277  summary: '',
278  description: '',
279  issueType,
280  sprint,
281})
282
283export const isIssueReady = (issue: JiraNewIssue) => issue.summary.trim() !== ''
284
285export const cappedSummary = (summary: string) => summary.slice(0, SUMMARY_LIMIT)
286
287// The open sprints of the project's boards, each once: the active ones first, then the future ones.
288export function toSprints(lists: readonly (readonly RawSprint[])[]): JiraSprint[] {
289  const open = new Map<number, JiraSprint>()
290  for (const { id, name, state } of lists.flat()) {
291    if (!open.has(id) && (state === 'active' || state === 'future')) open.set(id, { id, name, state })
292  }
293  const sprints = [...open.values()]
294
295  return [...sprints.filter(one => one.state === 'active'), ...sprints.filter(one => one.state === 'future')]
296}
297
298// A new issue goes into the current sprint unless the person picks another one, or the backlog.
299export const currentSprint = (sprints: readonly JiraSprint[]) =>
300  sprints.find(one => one.state === 'active')?.id ?? null
301
302const BACKLOG = 'backlog'
303
304export const sprintOptions = (sprints: readonly JiraSprint[]) => [
305  ...sprints.map(one => ({ value: String(one.id), label: one.state === 'active' ? `${one.name} (current)` : one.name })),
306  { value: BACKLOG, label: 'Backlog' },
307]
308
309export const sprintValue = (sprint: number | null) => (sprint === null ? BACKLOG : String(sprint))
310
311export const sprintOf = (value: string) => (value === BACKLOG ? null : Number(value))
312
313const text = (value: string, isStrong = false) =>
314  isStrong ? { type: 'text', text: value, marks: [{ type: 'strong' }] } : { type: 'text', text: value }
315
316const bulletList = (items: readonly string[]) => ({
317  type: 'bulletList',
318  content: items.map(item => ({ type: 'listItem', content: [{ type: 'paragraph', content: [text(item)] }] })),
319})
320
321// Comment and description bodies in the v3 API are Atlassian Document Format, not markdown.
322export function toDocument(day: string, update: JiraUpdate) {
323  const notes = update.notes.length > 0 ? [bulletList(update.notes)] : []
324
325  return {
326    type: 'doc',
327    version: 1,
328    content: [{ type: 'paragraph', content: [text(`Daily update ${day}`, true)] }, ...notes],
329  }
330}
331
332// Each line of the description is a paragraph of its own.
333export function toIssueDocument(issue: JiraNewIssue) {
334  const lines = issue.description.split('\n').map(line => line.trim()).filter(Boolean)
335
336  return { type: 'doc', version: 1, content: lines.map(line => ({ type: 'paragraph', content: [text(line)] })) }
337}
338
hooks/description.tsx 67 lines
1import type { ClientModule } from 'claude-code'
2
3import { editorOf, rowAt, rowsOf, viewOf, withKey, withPress, type Editor } from './editor'
4
5export type DescriptionProps = { text: string; placeholder: string }
6
7// The round border and one column of padding each side, and a column for the cursor past a line's end.
8const FRAME_COLUMNS = 5
9
10// The ticket's description as a text area: it starts from the text drawn with it, grows with what is typed
11// up to MAX_ROWS rows and then scrolls, and posts each change to the hooks module.
12const Description: ClientModule<DescriptionProps, Editor> = (props, surface) => {
13  const { Box, Text } = surface.elements
14  // The region is 0 wide until it is first laid out.
15  const widthNow = () => Math.max(10, (surface.columns || 64) - FRAME_COLUMNS)
16  const width = widthNow()
17  const editor = surface.state ?? editorOf(props.text)
18  if (surface.state === undefined) {
19    const apply = (next: Editor) => {
20      const before = surface.state ?? editor
21      surface.setState(next)
22      if (next.text !== before.text) surface.post({ description: next.text })
23    }
24    surface.onKey(key => apply(withKey(surface.state ?? editor, key, widthNow())))
25    surface.onPointer(event => {
26      if (event.type === 'down') apply(withPress(surface.state ?? editor, event.x - 2, event.y - 1, widthNow()))
27    })
28    surface.setState(editor)
29  }
30  const rows = rowsOf(editor.text, width)
31  const { height, top } = viewOf(editor, rows)
32  const cursorRow = rowAt(rows, editor.cursor)
33  const lines = Array.from({ length: height }, (_, index) => {
34    const row = rows[top + index]
35    const text = row === undefined ? '' : editor.text.slice(row.start, row.end)
36    if (row === undefined || !editor.isActive || top + index !== cursorRow) {
37      return <Text key={`row-${index}`}>{text || ' '}</Text>
38    }
39    const at = editor.cursor - row.start
40
41    // One run of text with the cursor cell nested in it, so the spaces beside the cursor stay where they are.
42    return (
43      <Text key={`row-${index}`}>
44        {text.slice(0, at)}
45        <Text inverse>{text.charAt(at) || ' '}</Text>
46        {text.slice(at + 1)}
47      </Text>
48    )
49  })
50  const isBlank = editor.text === '' && !editor.isActive
51
52  return (
53    <Box flexDirection="column" width="100%" borderStyle="round" borderDimColor paddingX={1}>
54      {isBlank ? (
55        <Box flexDirection="column">
56          <Text dimColor>{props.placeholder}</Text>
57          {lines.slice(1)}
58        </Box>
59      ) : (
60        lines
61      )}
62    </Box>
63  )
64}
65
66export default Description
67
hooks/editor.ts 144 lines
1// A multi-line text box for the surfaces whose Input is one line: the text, where the cursor is in it,
2// and the first visual row shown. Lines wrap at word boundaries to the box's width.
3export type Editor = { text: string; cursor: number; top: number; isActive: boolean }
4
5// A visual row: the text from `start` up to `end`, one line or the part of one that fits the width.
6export type Row = { start: number; end: number }
7
8export type EditorKey = { key: string; ctrl?: true; meta?: true }
9
10// Rows the box shows before it scrolls, and the fewest it draws so it reads as a text area.
11export const MIN_ROWS = 4
12export const MAX_ROWS = 12
13// Jira refuses a description over 32,767 characters.
14const TEXT_LIMIT = 32_000
15
16// The terminal names the keys it maps (`up`, `return`, `backspace`, ...) and sends a space as `space`; a
17// browser-style surface would name them as the DOM does (`ArrowUp`, `Enter`, ` `). Both become one name here.
18const NAMED: Record<string, string> = {
19  up: 'up', arrowup: 'up', down: 'down', arrowdown: 'down', left: 'left', arrowleft: 'left', right: 'right',
20  arrowright: 'right', return: 'return', enter: 'return', backspace: 'backspace', delete: 'delete', del: 'delete',
21  home: 'home', end: 'end', space: 'space', spacebar: 'space',
22}
23
24// Keys that type nothing here.
25const IGNORED = new Set([
26  'tab', 'pageup', 'pagedown', 'escape', 'esc', 'insert', 'clear', 'center', 'undefined', 'unidentified', 'mouse',
27  'wheelup', 'wheeldown', 'shift', 'control', 'ctrl', 'alt', 'meta', 'os', 'capslock', 'numlock', 'scrolllock',
28  'pause', 'printscreen', 'contextmenu', 'dead', 'process', ...Array.from({ length: 24 }, (_, n) => `f${n + 1}`),
29])
30
31const nameOf = (key: string) => (key.length > 1 ? NAMED[key.toLowerCase()] : undefined)
32
33const isIgnored = (key: string) => key === '' || (key.length > 1 && IGNORED.has(key.toLowerCase()))
34
35export const editorOf = (text: string): Editor => ({ text, cursor: text.length, top: 0, isActive: false })
36
37export function rowsOf(text: string, width: number): Row[] {
38  const rows: Row[] = []
39  let start = 0
40  for (const line of text.split('\n')) {
41    const end = start + line.length
42    let from = start
43    while (end - from > width) {
44      const space = text.lastIndexOf(' ', from + width - 1)
45      const cut = space > from ? space + 1 : from + width
46      rows.push({ start: from, end: cut })
47      from = cut
48    }
49    rows.push({ start: from, end })
50    start = end + 1
51  }
52
53  return rows
54}
55
56// Where a wrapped row ends, the next row begins: a cursor there is drawn on the next row.
57export function rowAt(rows: readonly Row[], cursor: number) {
58  let at = 0
59  rows.forEach((row, index) => {
60    if (row.start <= cursor) at = index
61  })
62
63  return at
64}
65
66const lastIn = (rows: readonly Row[], index: number) => {
67  const row = rows[index]
68  if (row === undefined) return 0
69
70  return rows[index + 1]?.start === row.end ? row.end - 1 : row.end
71}
72
73const cursorIn = (rows: readonly Row[], index: number, column: number) =>
74  Math.min((rows[index]?.start ?? 0) + Math.max(0, column), lastIn(rows, index))
75
76const insert = (editor: Editor, typed: string) => {
77  const text = typed.replace(/\r\n?/g, '\n')
78  const room = TEXT_LIMIT - editor.text.length
79  if (room <= 0) return editor
80  const added = text.slice(0, room)
81
82  return {
83    ...editor,
84    text: editor.text.slice(0, editor.cursor) + added + editor.text.slice(editor.cursor),
85    cursor: editor.cursor + added.length,
86  }
87}
88
89// One key from the box: printable text (a paste comes as one string) is typed, Enter starts a new line.
90function keyed(editor: Editor, { key, ctrl, meta }: EditorKey, width: number): Editor {
91  const active = { ...editor, isActive: true }
92  if (ctrl || meta) return active
93  const { text, cursor } = active
94  const rows = rowsOf(text, width)
95  const row = rowAt(rows, cursor)
96  const column = cursor - (rows[row]?.start ?? 0)
97  if (isIgnored(key)) return active
98  switch (nameOf(key)) {
99    case 'space':
100      return insert(active, ' ')
101    case 'return':
102      return insert(active, '\n')
103    case 'backspace':
104      return cursor === 0 ? active : { ...active, text: text.slice(0, cursor - 1) + text.slice(cursor), cursor: cursor - 1 }
105    case 'delete':
106      return { ...active, text: text.slice(0, cursor) + text.slice(cursor + 1) }
107    case 'left':
108      return { ...active, cursor: Math.max(0, cursor - 1) }
109    case 'right':
110      return { ...active, cursor: Math.min(text.length, cursor + 1) }
111    case 'up':
112      return { ...active, cursor: row === 0 ? 0 : cursorIn(rows, row - 1, column) }
113    case 'down':
114      return { ...active, cursor: row === rows.length - 1 ? text.length : cursorIn(rows, row + 1, column) }
115    case 'home':
116      return { ...active, cursor: rows[row]?.start ?? 0 }
117    case 'end':
118      return { ...active, cursor: lastIn(rows, row) }
119    default:
120      return insert(active, key)
121  }
122}
123
124// How many rows the box shows, and the first one, so that the cursor's row is in view.
125export function viewOf(editor: Editor, rows: readonly Row[]) {
126  const height = Math.min(MAX_ROWS, Math.max(MIN_ROWS, rows.length))
127  const row = rowAt(rows, editor.cursor)
128  const top = row < editor.top ? row : row >= editor.top + height ? row - height + 1 : editor.top
129
130  return { height, top: Math.max(0, Math.min(top, rows.length - height)) }
131}
132
133const scrolled = (editor: Editor, width: number) => ({ ...editor, top: viewOf(editor, rowsOf(editor.text, width)).top })
134
135export const withKey = (editor: Editor, key: EditorKey, width: number) => scrolled(keyed(editor, key, width), width)
136
137// A press puts the cursor where it landed, in the box's text cells.
138export function withPress(editor: Editor, column: number, line: number, width: number): Editor {
139  const rows = rowsOf(editor.text, width)
140  const row = Math.min(rows.length - 1, Math.max(0, editor.top + line))
141
142  return scrolled({ ...editor, isActive: true, cursor: cursorIn(rows, row, column) }, width)
143}
144
types/index.d.ts 51 lines
1export type JiraIssue = { key: string; summary: string; status: string; statusCategory: string }
2
3export type JiraUpdate = { notes: string[] }
4
5// `sprint` is the id of the sprint the issue goes into once created; null leaves it in the backlog.
6export type JiraNewIssue = {
7  summary: string
8  description: string
9  issueType: string
10  sprint: number | null
11}
12
13export type JiraSprint = { id: number; name: string; state: 'active' | 'future' }
14
15export type JiraTransition = { id: string; name: string; to: string }
16
17export type JiraSuggestion = { id: string; to: string; reason: string }
18
19// `earlier` counts unposted actions on earlier days; progress saved before it existed has none.
20export type JiraProgress = {
21  day: string
22  actions: number
23  postedActions: number
24  postedAt: number | null
25  earlier?: number
26}
27
28export type JiraView = 'setup' | 'pick' | 'draft' | 'create' | 'move'
29
30declare module 'claude-code' {
31  interface PluginState {
32    'jira-log': {
33      view: JiraView
34      isConfigured: boolean
35      isSkipped: boolean
36      isProgressHidden: boolean
37      progress: JiraProgress | null
38      link: JiraIssue | null
39      issues: JiraIssue[]
40      draft: JiraUpdate | null
41      suggestion: JiraSuggestion | null
42      newIssue: JiraNewIssue | null
43      issueTypes: string[]
44      sprints: JiraSprint[]
45      transitions: JiraTransition[]
46      busy: string | null
47      notice: string | null
48    }
49  }
50}
51