Bitbucket review queue and your open PRs in a pane; the current branch's PR, CI and Jira issue in a bar above the prompt

A Claude Code mod (a plugin of function hooks) that brings your Bitbucket Data Center pull requests and their Jira issues into Claude Code: in the terminal and in the desktop app's Code tab.
/jb pane: the PRs waiting for your review, and your own open PRs. Each one shows build status, approvals, "needs work", conflicts, open tasks, comments and the linked Jira issue's status. One click drafts a Review with Claude or Triage with Claude prompt.| What | Why |
|---|---|
| A recent Claude Code (built on 2.1.293) | Mods (function-hook plugins) |
bkt, logged in to your Bitbucket Data Center | All Bitbucket reads go through bkt api, reusing its stored login |
(optional) mcp-atlassian MCP server connected to Claude Code | Jira issue status and summary. Without it, everything else still works. |
git | Finds the current branch and its Bitbucket repo from origin |
bktbrew install avivsinai/tap/bitbucket-cli
bkt auth login https://bitbucket.example.com
Check that it works:
bkt api "/rest/api/1.0/dashboard/pull-requests?role=AUTHOR&limit=1"
For other platforms, see the bkt releases. If bkt isn't on the PATH Claude Code sees, set the bkt executable option to its full path. The mod also tries /opt/homebrew/bin/bkt and /usr/local/bin/bkt on its own.
mcp-atlassianThe mod calls the jira_search tool of an MCP server named atlassian. Add one at user scope. This needs uv for uvx. For Jira Server / Data Center, use a personal access token:
claude mcp add atlassian --scope user -e JIRA_URL=https://jira.example.com -e JIRA_PERSONAL_TOKEN=<your-token> -- uvx mcp-atlassian
For Jira Cloud, use -e JIRA_USERNAME=<you@company.com> -e JIRA_API_TOKEN=<api-token> instead of JIRA_PERSONAL_TOKEN. See the mcp-atlassian docs for the other options.
Your token stays in your own Claude Code MCP config. The mod never sees or stores it.
In a terminal claude session, run:
/plugin install jira-bitbucket --marketplace Fyzu/claude-atlassian-mode
y to add the Fyzu/claude-atlassian-mode marketplace.You'll see Installed jira-bitbucket. Plugin is now active. Then type /jb.
/pluginis a terminal command. If you mostly use the desktop app, install once from a terminal at user scope and the mod shows up in the app too.
Without the slash command (for example, in a setup script):
claude plugin marketplace add Fyzu/claude-atlassian-mode
claude plugin install jira-bitbucket@claude-atlassian-mode
claude plugin marketplace update claude-atlassian-mode
claude plugin update jira-bitbucket@claude-atlassian-mode
Then run /reload-plugins in an open session, or start a new one.
claude plugin uninstall jira-bitbucket@claude-atlassian-mode
| Command | Does |
|---|---|
/jb | Open the pane |
/jb refresh | Refresh now |
/jb close | Close the pane |
In the pane, press r (or Refresh) to reload. The PR bar above the prompt appears on its own whenever the current branch has an open PR, or one merged or declined within the last 7 days.
You're asked for these on install. Review or change them later with claude plugin configure jira-bitbucket@claude-atlassian-mode.
| Option | Default | Meaning |
|---|---|---|
bkt | bkt | Path or name of the bkt CLI |
jiraServer | atlassian | Name of the connected MCP server that has jira_search |
jiraUrl | (empty) | Jira base URL for issue links, e.g. https://jira.example.com. Empty shows keys without links. |
refreshMinutes | 3 | How often to poll Bitbucket and Jira (1–60) |
reviewLimit | 20 | How many PRs awaiting your review the pane lists (1–100) |
openOnStart | false | Open the pane when a session starts |
Jira keys are taken from the PR title ([ABC-123] …) or else the branch name (feature/ABC-123-…).
Bitbucket: bkt not found: set the bkt option to the full path (which bkt).Bitbucket: …401…: run bkt auth login <your Bitbucket URL> again.Jira: … in yellow: the MCP server isn't connected or its name differs. Run claude mcp list and set jiraServer to match.origin isn't the Bitbucket repo (expects …/<project>/<repo>.git).claude --debug to see why a hook was skipped..claude-plugin/
plugin.json manifest and user options
marketplace.json makes this repo installable with /plugin install
hooks/
hooks.json points at the hooks module
register.tsx the mod
register.test.tsx engine test (pane, PR bar, buttons) with faked bkt/git/Jira
types/index.d.ts state contract ($.state values)
tsconfig.json extends the typings Claude Code writes on load
To run your working copy instead of the installed one, start a session with the folder loaded. Edits hot-reload.
claude --plugin-dir /path/to/claude-atlassian-mode
For the desktop app, add the folder to ~/.claude/settings.json instead:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-atlassian-mode" } }
Check and test:
claude plugin validate .
claude plugin test .
Claude Code writes its API typings to .claude-plugin/types/ (gitignored) the first time it loads the mod. After that you can type-check:
npx -p typescript tsc -p .
hooks/register.tsx 879 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, McpToolResult, PluginOptions, Register, RenderNode } from 'claude-code'
3
4import type { JbBranch, JbBuild, JbCheck, JbDiff, JbIssue, JbMerge, JbPr, JbPrDetail, JbSnapshot } from '../types'
5
6// Read-only by design: the mod reads Bitbucket (through `bkt api`, reusing its
7// keychain token) and Jira (through the connected mcp-atlassian server). Every
8// write — a review comment, an approval, a transition — is handed to Claude as
9// a prompt draft, so it goes through the session's normal permission prompts.
10
11const PANE = 'jb'
12const TITLE_KEY = /(?<![A-Za-z0-9])([A-Z][A-Z0-9]{1,15}-[1-9]\d{0,6})(?!\d)/
13const BRANCH_KEY = /(?<![A-Za-z0-9])([A-Za-z][A-Za-z0-9]{1,15}-[1-9]\d{0,6})(?!\d)/
14
15const EMPTY: JbSnapshot = {
16 updatedAt: 0,
17 isLoading: false,
18 error: null,
19 jiraError: null,
20 review: [],
21 reviewHasMore: false,
22 mine: [],
23 issues: {},
24 branch: null,
25}
26
27const snapshot = atom({ plugin: 'jira-bitbucket', key: 'snapshot' } as const, EMPTY)
28const isBarCollapsed = atom({ plugin: 'jira-bitbucket', key: 'isBarCollapsed' } as const, true)
29const BAR_COLLAPSED_KEY = 'barCollapsed'
30
31type Api = EngineInterface
32
33type Chip = { text: string; color?: string }
34
35type Config = {
36 bkt: string
37 jiraServer: string
38 jiraUrl: string
39 refreshMs: number
40 reviewLimit: number
41 openOnStart: boolean
42}
43
44function configOf(options: PluginOptions): Config {
45 const str = (v: unknown, fallback: string) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : fallback)
46 const num = (v: unknown, fallback: number, min: number, max: number) =>
47 typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : fallback
48
49 return {
50 bkt: str(options.bkt, 'bkt'),
51 jiraServer: str(options.jiraServer, 'atlassian'),
52 jiraUrl: str(options.jiraUrl, '').replace(/\/+$/, ''),
53 refreshMs: num(options.refreshMinutes, 3, 1, 60) * 60_000,
54 reviewLimit: Math.round(num(options.reviewLimit, 20, 1, 100)),
55 openOnStart: options.openOnStart === true,
56 }
57}
58
59const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
60const firstLine = (text: string) => text.trim().split('\n')[0]?.slice(0, 200) ?? ''
61const issueUrl = (cfg: Config, key: string) => (cfg.jiraUrl ? `${cfg.jiraUrl}/browse/${encodeURIComponent(key)}` : null)
62
63// ---------------------------------------------------------------- Bitbucket
64
65let bktBin: string | null = null
66
67async function bkt($: Api, cfg: Config, args: string[]): Promise<any> {
68 const candidates = bktBin ? [bktBin] : [...new Set([cfg.bkt, '/opt/homebrew/bin/bkt', '/usr/local/bin/bkt'])]
69 let lastError = 'bkt not found'
70 for (const bin of candidates) {
71 let run
72 try {
73 run = await $.process.run([bin, 'api', ...args], { timeoutMs: 30_000 })
74 } catch (err) {
75 lastError = `cannot run ${bin}: ${message(err)}`
76 continue
77 }
78 bktBin = bin
79 if (run.exitCode !== 0) {
80 throw new Error(firstLine(run.stderr || run.stdout) || `bkt exited with ${run.exitCode}`)
81 }
82 return JSON.parse(run.stdout)
83 }
84 throw new Error(lastError)
85}
86
87function issueKeyOf(text: unknown, isBranch = false): string | null {
88 const match = (isBranch ? BRANCH_KEY : TITLE_KEY).exec(typeof text === 'string' ? text : '')
89 return match?.[1]?.toUpperCase() ?? null
90}
91
92function toPr(v: any): JbPr {
93 const repo = v.toRef?.repository ?? {}
94 const props = v.properties ?? {}
95 return {
96 id: Number(v.id),
97 project: String(repo.project?.key ?? ''),
98 repo: String(repo.slug ?? ''),
99 title: String(v.title ?? ''),
100 url: String(v.links?.self?.[0]?.href ?? ''),
101 sha: String(v.fromRef?.latestCommit ?? ''),
102 fromBranch: String(v.fromRef?.displayId ?? ''),
103 toBranch: String(v.toRef?.displayId ?? ''),
104 author: String(v.author?.user?.displayName ?? v.author?.user?.name ?? ''),
105 updated: Number(v.updatedDate ?? 0),
106 isDraft: v.draft === true,
107 reviewers: (v.reviewers ?? []).map((r: any) => ({
108 name: String(r.user?.displayName ?? r.user?.name ?? '?'),
109 status: String(r.status ?? 'UNAPPROVED'),
110 })),
111 comments: Number(props.commentCount ?? 0),
112 openTasks: Number(props.openTaskCount ?? 0),
113 isConflicted: props.mergeResult?.outcome === 'CONFLICTED',
114 build: null,
115 issueKey: issueKeyOf(v.title) ?? issueKeyOf(v.fromRef?.displayId, true),
116 }
117}
118
119async function dashboard($: Api, cfg: Config, role: 'REVIEWER' | 'AUTHOR', limit: number) {
120 const unapproved = role === 'REVIEWER' ? '&participantStatus=UNAPPROVED' : ''
121 const page = await bkt($, cfg, [
122 `/rest/api/1.0/dashboard/pull-requests?role=${role}&state=OPEN&order=NEWEST&limit=${limit}${unapproved}`,
123 ])
124 const prs: JbPr[] = (page.values ?? []).map(toPr)
125 return { prs, hasMore: page.isLastPage === false }
126}
127
128async function builds($: Api, cfg: Config, shas: string[]): Promise<Record<string, JbBuild>> {
129 const unique = [...new Set(shas.filter(Boolean))]
130 if (unique.length === 0) return {}
131 const stats = await bkt($, cfg, ['/rest/build-status/1.0/commits/stats', '-X', 'POST', '-d', JSON.stringify(unique)])
132 const out: Record<string, JbBuild> = {}
133 for (const [sha, s] of Object.entries<any>(stats ?? {})) {
134 const build = { ok: Number(s.successful ?? 0), failed: Number(s.failed ?? 0), running: Number(s.inProgress ?? 0) }
135 if (build.ok + build.failed + build.running > 0) out[sha] = build
136 }
137 return out
138}
139
140// ---------------------------------------------------------------- git
141
142async function gitHead($: Api, cwd: string) {
143 const git = (args: string[]) => $.process.run(['git', ...args], { cwd, timeoutMs: 5_000 }).catch(() => null)
144
145 const head = await git(['rev-parse', '--abbrev-ref', 'HEAD'])
146 const name = head?.exitCode === 0 ? head.stdout.trim() : ''
147 if (name === '' || name === 'HEAD') return null
148
149 const remote = await git(['remote', 'get-url', 'origin'])
150 const match =
151 remote?.exitCode === 0 ? /[/:](?:scm\/)?([^/:]+)\/([^/]+?)(?:\.git)?\/?$/.exec(remote.stdout.trim()) : null
152 return {
153 name,
154 project: match?.[1]?.toUpperCase() ?? null,
155 repo: match?.[2]?.toLowerCase() ?? null,
156 }
157}
158
159const CLOSED_PR_DAYS = 7
160
161function toCheck(v: any): JbCheck {
162 const name = String(v.name ?? v.key ?? 'build')
163 const state = v.state === 'SUCCESSFUL' ? 'ok' : v.state === 'INPROGRESS' ? 'running' : 'failed'
164 return { name: name.split(' / ').pop()?.trim() || name, state, url: String(v.url ?? '') }
165}
166
167// The branch's newest PR in any state, as the GitHub bar shows it: merged and
168// declined ones too while they are recent, with its checks and merge status.
169async function branchPr(
170 $: Api,
171 cfg: Config,
172 head: { name: string; project: string | null; repo: string | null },
173): Promise<{ pr: JbPr; detail: JbPrDetail } | null> {
174 if (!head.project || !head.repo) return null
175 const repo = `/rest/api/1.0/projects/${encodeURIComponent(head.project)}/repos/${encodeURIComponent(head.repo)}`
176 let first: any
177 try {
178 const at = encodeURIComponent(`refs/heads/${head.name}`)
179 const page = await bkt($, cfg, [`${repo}/pull-requests?at=${at}&direction=OUTGOING&state=ALL&order=NEWEST&limit=1`])
180 first = page.values?.[0]
181 } catch {
182 return null // not a Bitbucket remote, or no access: the branch just has no PR
183 }
184 if (!first) return null
185
186 const isOpen = first.state === 'OPEN'
187 const closedAt = Number(first.closedDate ?? first.updatedDate ?? 0)
188 if (!isOpen && (await $.clock.now()) - closedAt > CLOSED_PR_DAYS * 86_400_000) return null
189
190 const pr = toPr(first)
191 const [checks, merge, diff] = await Promise.all([
192 bkt($, cfg, [`/rest/build-status/1.0/commits/${pr.sha}?limit=50`])
193 .then(page => (page.values ?? []).map(toCheck) as JbCheck[])
194 .catch(() => [] as JbCheck[]),
195 isOpen
196 ? bkt($, cfg, [`${repo}/pull-requests/${pr.id}/merge`])
197 .then((m): JbMerge => ({
198 canMerge: m.canMerge === true,
199 isConflicted: m.conflicted === true || m.outcome === 'CONFLICTED',
200 vetoes: (m.vetoes ?? []).map((x: any) => String(x.summaryMessage ?? '')).filter(Boolean),
201 }))
202 .catch(() => null)
203 : Promise.resolve(null),
204 bkt($, cfg, [
205 `/rest/api/latest/projects/${encodeURIComponent(pr.project)}/repos/${encodeURIComponent(pr.repo)}/pull-requests/${pr.id}/diff-stats-summary`,
206 ])
207 .then((d): JbDiff => ({
208 files: Number(d.filesChanged ?? 0),
209 added: Number(d.totalInsertions ?? 0),
210 removed: Number(d.totalDeletions ?? 0),
211 }))
212 .catch(() => null),
213 ])
214 const state = isOpen ? (first.draft === true ? 'DRAFT' : 'OPEN') : first.state === 'MERGED' ? 'MERGED' : 'DECLINED'
215 return { pr, detail: { state, checks, merge, diff } }
216}
217
218// ---------------------------------------------------------------- Jira
219
220function mcpText(result: McpToolResult): string {
221 return result.content.map(block => (block.type === 'text' ? block.text : '')).join('')
222}
223
224function mcpJson(result: McpToolResult): any {
225 const parse = (text: string) => {
226 try {
227 return JSON.parse(text)
228 } catch {
229 return null
230 }
231 }
232 let data: any = result.structuredContent ?? parse(mcpText(result))
233 if (data && typeof data.result === 'string') data = parse(data.result)
234 return data && typeof data === 'object' ? data : null
235}
236
237async function jiraIssues($: Api, cfg: Config, keys: string[]): Promise<Record<string, JbIssue>> {
238 const out: Record<string, JbIssue> = {}
239 for (let i = 0; i < keys.length; i += 50) {
240 const chunk = keys.slice(i, i + 50)
241 const result = await $.mcp.call(cfg.jiraServer, 'jira_search', {
242 jql: `key in (${chunk.join(',')})`,
243 fields: 'summary,status',
244 limit: 50,
245 })
246 const data = mcpJson(result)
247 if (result.isError || !data) {
248 throw new Error(firstLine(mcpText(result)) || 'jira_search failed')
249 }
250 for (const issue of data.issues ?? []) {
251 out[issue.key] = {
252 key: String(issue.key),
253 summary: String(issue.summary ?? ''),
254 status: String(issue.status?.name ?? ''),
255 category: String(issue.status?.category ?? ''),
256 }
257 }
258 }
259 return out
260}
261
262// ---------------------------------------------------------------- refresh
263
264let inflight: Promise<void> | null = null
265
266function refresh($: Api, cfg: Config): Promise<void> {
267 inflight ??= load($, cfg).finally(() => {
268 inflight = null
269 })
270 return inflight
271}
272
273async function load($: Api, cfg: Config): Promise<void> {
274 await update($, snapshot, s => ({ ...s, isLoading: true }))
275 const cwd = await $.session.cwd()
276
277 let fresh: Omit<JbSnapshot, 'issues' | 'jiraError'>
278 try {
279 const [head, review, mine] = await Promise.all([
280 gitHead($, cwd),
281 dashboard($, cfg, 'REVIEWER', cfg.reviewLimit + 10),
282 dashboard($, cfg, 'AUTHOR', 50),
283 ])
284 const found = head ? await branchPr($, cfg, head) : null
285 const reviewPrs = review.prs.filter(p => !p.isDraft)
286 const all = [...reviewPrs, ...mine.prs, ...(found ? [found.pr] : [])]
287 const stats = await builds(
288 $,
289 cfg,
290 all.map(p => p.sha),
291 ).catch(() => ({}) as Record<string, JbBuild>)
292 const withBuild = (p: JbPr): JbPr => ({ ...p, build: stats[p.sha] ?? null })
293 const branch: JbBranch | null = head
294 ? {
295 ...head,
296 issueKey: issueKeyOf(head.name, true),
297 pr: found ? withBuild(found.pr) : null,
298 detail: found?.detail ?? null,
299 }
300 : null
301
302 fresh = {
303 updatedAt: await $.clock.now(),
304 isLoading: false,
305 error: null,
306 review: reviewPrs.map(withBuild),
307 reviewHasMore: review.hasMore,
308 mine: mine.prs.map(withBuild),
309 branch,
310 }
311 } catch (err) {
312 await update($, snapshot, s => ({ ...s, isLoading: false, error: message(err) }))
313 return
314 }
315
316 const keys = [
317 ...new Set(
318 [
319 fresh.branch?.issueKey,
320 ...fresh.review.slice(0, cfg.reviewLimit).map(p => p.issueKey),
321 ...fresh.mine.map(p => p.issueKey),
322 ].filter((k): k is string => typeof k === 'string'),
323 ),
324 ]
325 let issues: Record<string, JbIssue> | null = null
326 let jiraError: string | null = null
327 try {
328 issues = await jiraIssues($, cfg, keys)
329 } catch (err) {
330 jiraError = message(err)
331 }
332
333 await update($, snapshot, prev => ({ ...fresh, issues: issues ?? prev.issues, jiraError }))
334}
335
336async function checkBranch($: Api, cfg: Config): Promise<void> {
337 const { value } = await $.state.get({ plugin: 'jira-bitbucket', key: 'snapshot' })
338 const head = await gitHead($, await $.session.cwd())
339 if ((head?.name ?? null) !== (value?.branch?.name ?? null)) await refresh($, cfg)
340}
341
342// ---------------------------------------------------------------- text
343
344const approvedOf = (pr: JbPr) => pr.reviewers.filter(r => r.status === 'APPROVED').length
345const needsWorkOf = (pr: JbPr) => pr.reviewers.some(r => r.status === 'NEEDS_WORK')
346
347function buildText(build: JbBuild | null): string {
348 if (!build) return ''
349 if (build.failed > 0) return 'build ✗'
350 if (build.running > 0) return 'build …'
351 return 'build ✓'
352}
353
354function buildColor(build: JbBuild | null): string | undefined {
355 if (!build) return undefined
356 if (build.failed > 0) return 'red'
357 if (build.running > 0) return 'yellow'
358 return 'green'
359}
360
361function statusColor(category: string): string | undefined {
362 if (category === 'Done') return 'green'
363 if (category === 'In Progress') return 'yellow'
364 return undefined
365}
366
367function age(ms: number): string {
368 const minutes = Math.max(0, Math.round(ms / 60_000))
369 if (minutes < 60) return `${minutes}m`
370 const hours = Math.round(minutes / 60)
371 if (hours < 48) return `${hours}h`
372 return `${Math.round(hours / 24)}d`
373}
374
375function clock(ms: number): string {
376 const d = new Date(ms)
377 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
378}
379
380function reviewPrompt(pr: JbPr): string {
381 return (
382 `Review Bitbucket PR ${pr.project}/${pr.repo}#${pr.id} "${pr.title}" (${pr.url}) with bkt: ` +
383 `read the description, diff and existing comments, then give me a review with risks and suggested comments. ` +
384 `Don't post comments, approve or decline without asking me.`
385 )
386}
387
388function triagePrompt(pr: JbPr): string {
389 return (
390 `Check my Bitbucket PR ${pr.project}/${pr.repo}#${pr.id} "${pr.title}" (${pr.url}) with bkt: ` +
391 `summarize reviewer feedback, open tasks, failing builds and merge conflicts, then propose what to change. ` +
392 `Don't reply, resolve tasks or push without asking me.`
393 )
394}
395
396const prRef = (pr: JbPr) => `${pr.project}/${pr.repo}#${pr.id} "${pr.title}" (${pr.url})`
397
398function fixChecksPrompt(pr: JbPr, failed: JbCheck[]): string {
399 const list = failed.map(c => (c.url ? `${c.name} (${c.url})` : c.name)).join(', ')
400 return (
401 `My Bitbucket PR ${prRef(pr)} has failing builds on its latest commit: ${list}. ` +
402 `Find out why (open the build logs if you can reach them, otherwise reproduce locally) and fix it on this branch. ` +
403 `Don't push without asking me.`
404 )
405}
406
407function addressReviewPrompt(pr: JbPr): string {
408 return (
409 `Go through the review on my Bitbucket PR ${prRef(pr)} with bkt: requested changes, unresolved comments and open tasks. ` +
410 `Fix what's needed on this branch and summarize what you changed. ` +
411 `Don't reply to comments, resolve tasks or push without asking me.`
412 )
413}
414
415function conflictsPrompt(pr: JbPr): string {
416 return (
417 `My Bitbucket PR ${prRef(pr)} has merge conflicts with ${pr.toBranch}. ` +
418 `Fetch origin, merge origin/${pr.toBranch} into this branch and resolve the conflicts. ` +
419 `Show me the resolution before committing, and don't push without asking me.`
420 )
421}
422
423// The PR's state as the bar draws it: the icon's stroke, the text's colour.
424const PR_STATE: Record<JbPrDetail['state'], { label: string; stroke: string; color?: string }> = {
425 OPEN: { label: 'Open', stroke: '#1a7f37', color: 'green' },
426 DRAFT: { label: 'Draft', stroke: '#6e7781' },
427 MERGED: { label: 'Merged', stroke: '#8250df', color: 'magenta' },
428 DECLINED: { label: 'Declined', stroke: '#cf222e', color: 'red' },
429}
430
431function prIconSvg(stroke: string): string {
432 return (
433 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" ` +
434 `stroke="${stroke}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">` +
435 `<circle cx="4" cy="3.5" r="1.75"/><circle cx="4" cy="12.5" r="1.75"/><circle cx="12" cy="12.5" r="1.75"/>` +
436 `<path d="M4 5.25v5.5"/><path d="M12 10.75V6.5a2 2 0 0 0-2-2H7.5"/><path d="M9.25 2.75 7.5 4.5l1.75 1.75"/></svg>`
437 )
438}
439
440const thousands = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
441
442type Row = { key: string; icon: string; color?: string; label: string; value: string }
443
444function checksRow(checks: JbCheck[]): Row {
445 const row = { key: 'checks', label: 'Checks' }
446 if (checks.length === 0) return { ...row, icon: '–', value: 'No builds reported' }
447 const failed = checks.filter(c => c.state === 'failed')
448 const running = checks.filter(c => c.state === 'running').length
449 if (failed.length > 0) {
450 return {
451 ...row,
452 icon: '✕',
453 color: 'red',
454 value: `${failed.length} of ${checks.length} failing · ${failed.map(c => c.name).join(', ')}`,
455 }
456 }
457 if (running > 0) return { ...row, icon: '●', color: 'yellow', value: `${running} of ${checks.length} running` }
458 return { ...row, icon: '✓', color: 'green', value: `${checks.length} passed` }
459}
460
461function reviewsRow(pr: JbPr): Row {
462 const row = { key: 'reviews', label: 'Reviews' }
463 const total = pr.reviewers.length
464 const approved = approvedOf(pr)
465 const changes = pr.reviewers.filter(r => r.status === 'NEEDS_WORK').map(r => r.name)
466 const extras = [
467 pr.openTasks > 0 ? `${pr.openTasks} open task${pr.openTasks === 1 ? '' : 's'}` : '',
468 pr.comments > 0 ? `${pr.comments} comment${pr.comments === 1 ? '' : 's'}` : '',
469 ].filter(Boolean)
470 const value = (head: string) => [head, ...extras].join(' · ')
471 if (changes.length > 0) {
472 return {
473 ...row,
474 icon: '⚠',
475 color: 'yellow',
476 value: value(`Changes requested by ${changes.join(', ')} · ${approved} of ${total} approved`),
477 }
478 }
479 if (total === 0) return { ...row, icon: '–', value: value('No reviewers') }
480 if (approved === total) return { ...row, icon: '✓', color: 'green', value: value(`${approved} of ${total} approved`) }
481 return { ...row, icon: '○', value: value(`${approved} of ${total} approved`) }
482}
483
484function mergeRow(pr: JbPr, detail: JbPrDetail): Row | null {
485 const row = { key: 'merge', label: 'Merge' }
486 if (detail.state === 'MERGED') return { ...row, icon: '✓', color: 'magenta', value: `Merged into ${pr.toBranch}` }
487 if (detail.state === 'DECLINED') return { ...row, icon: '✕', color: 'red', value: 'Declined' }
488 const merge = detail.merge
489 if (!merge) return null
490 if (merge.isConflicted) return { ...row, icon: '✕', color: 'red', value: `Conflicts with ${pr.toBranch}` }
491 if (merge.canMerge) return { ...row, icon: '✓', color: 'green', value: 'Ready to merge' }
492 return { ...row, icon: '○', value: `Blocked · ${merge.vetoes[0] ?? 'merge checks failed'}` }
493}
494
495function ciDot(checks: JbCheck[]): string | undefined {
496 if (checks.some(c => c.state === 'failed')) return 'red'
497 if (checks.some(c => c.state === 'running')) return 'yellow'
498 return checks.length > 0 ? 'green' : undefined
499}
500
501function reviewPill(pr: JbPr): Chip | null {
502 const total = pr.reviewers.length
503 if (total === 0) return null
504 const approved = approvedOf(pr)
505 if (needsWorkOf(pr)) return { text: `⚠ ${approved}/${total}`, color: 'yellow' }
506 return approved === total ? { text: `✓ ${approved}/${total}`, color: 'green' } : { text: `✓ ${approved}/${total}` }
507}
508
509function mergePill(detail: JbPrDetail): Chip | null {
510 if (detail.state !== 'OPEN' || !detail.merge) return null
511 if (detail.merge.isConflicted) return { text: 'Conflicts', color: 'red' }
512 return detail.merge.canMerge ? { text: 'Ready to merge', color: 'green' } : null // blocked: the rows say why
513}
514
515async function draft($: Api, text: string): Promise<void> {
516 const filled = await $.prompt.fill({ text, mode: 'replace' })
517 if (!filled.isFilled) $.ui.toast('jira-bitbucket: could not fill the prompt box')
518}
519
520// ---------------------------------------------------------------- hooks
521
522export const register: Register = (on, options) => {
523 const cfg = configOf(options)
524
525 on('session.start', async ($, e, next) => {
526 const started = await next(e)
527 $.ui.status(undefined)
528 const wasCollapsed = (await $.store.get(BAR_COLLAPSED_KEY)) !== false
529 await update($, isBarCollapsed, () => wasCollapsed)
530 await $.command.register({
531 name: 'jb',
532 description: 'Jira · Bitbucket pane: review queue and your open PRs',
533 argumentHint: '[refresh|close]',
534 })
535 $.clock.every(cfg.refreshMs, () => void refresh($, cfg))
536 void refresh($, cfg)
537 if (cfg.openOnStart && e.isInteractive) void $.ui.open({ id: PANE, title: 'Jira · Bitbucket' })
538 return started
539 })
540
541 on('command.run', { command: 'jb' }, async ($, e) => {
542 const arg = e.args.trim().toLowerCase()
543 if (arg === 'close') {
544 await $.ui.close({ id: PANE })
545 return { text: 'Jira · Bitbucket pane closed.' }
546 }
547 if (arg === 'refresh') {
548 await refresh($, cfg)
549 const { value } = await $.state.get({ plugin: 'jira-bitbucket', key: 'snapshot' })
550 return { text: value?.error ? `Refresh failed: ${value.error}` : 'Jira · Bitbucket refreshed.' }
551 }
552 const opened = await $.ui.open({ id: PANE, title: 'Jira · Bitbucket' })
553 void refresh($, cfg)
554 return { text: opened.isPlaced ? 'Jira · Bitbucket pane opened.' : 'Jira · Bitbucket pane is waiting for room.' }
555 })
556
557 on('turn.complete', ($, e, next) => {
558 if (!e.agentId) void checkBranch($, cfg).catch(() => undefined)
559 return next(e)
560 })
561
562 // The current branch's PR above the prompt, after the desktop's GitHub bar. The
563 // app's own bar above it already names the repo, branch and diff, so this one
564 // carries what that one lacks: number, title, approvals and CI. Hovering the
565 // number floats a summary card over the rows above (a hover reveal: the desktop
566 // clips an absolute box at the band's edge unless a hover reveals it); clicking CI
567 // unfolds the status rows and their actions above the bar.
568 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
569 if (e.props.hasSurvey) return next(e)
570 const s = await read($, snapshot)
571 const pr = s.branch?.pr
572 const detail = s.branch?.detail
573 if (!pr || !detail) return next(e)
574
575 const ui = $.ui.resolve(e)
576 const { Box, Text, Button, Link } = ui
577 const Svg = e.surface !== 'terminal' && 'Svg' in ui ? ui.Svg : null
578 const isTerminal = e.surface === 'terminal'
579 const look = PR_STATE[detail.state] ?? PR_STATE.OPEN
580 const isOpen = detail.state === 'OPEN' || detail.state === 'DRAFT'
581 const failed = detail.checks.filter(c => c.state === 'failed')
582 const issueKey = s.branch?.issueKey ?? pr.issueKey
583 const issue = issueKey ? s.issues[issueKey] : undefined
584 const issueHref = issueKey ? issueUrl(cfg, issueKey) : null
585 const needsReviewWork = needsWorkOf(pr) || pr.openTasks > 0 || pr.comments > 0
586 const isCollapsed = await read($, isBarCollapsed)
587 const toggle = async () => {
588 const collapsed = await update($, isBarCollapsed, value => !value)
589 await $.store.set(BAR_COLLAPSED_KEY, collapsed)
590 }
591 const diff = detail.diff ?? null
592 const columns = e.props.bodyColumns
593
594 const icon = (key: string) =>
595 Svg ? (
596 <Box key={key} flexShrink={0}>
597 <Svg source={prIconSvg(look.stroke)} alt={look.label} width={16} height={16} />
598 </Box>
599 ) : (
600 <Box key={key} flexShrink={0}>
601 <Text color={look.color} dimColor={!look.color}>
602 ⎇
603 </Text>
604 </Box>
605 )
606 const text = (c: Chip) => (
607 <Text color={c.color} dimColor={!c.color}>
608 {c.text}
609 </Text>
610 )
611 const group = (key: string, children: RenderNode[], gap = 1) => (
612 <Box key={key} flexDirection="row" alignItems="center" columnGap={gap} flexShrink={0}>
613 {children}
614 </Box>
615 )
616 const number = pr.url ? <Link href={pr.url} label={`#${pr.id}`} /> : `#${pr.id}`
617
618 // -- the tooltip card: absolute inside the keyed PR area (a hover group scope does
619 // not reveal on the desktop), so it floats over the rows above and moves nothing
620 const card = (
621 <Box
622 position="absolute"
623 bottom={1}
624 left={0}
625 width={Math.min(72, Math.max(40, columns - 24))}
626 display="none"
627 hover={{ display: 'flex' }}
628 flexDirection="column"
629 borderStyle="round"
630 borderDimColor
631 backgroundColor="userMessageBackground"
632 paddingX={1}
633 >
634 <Box flexDirection="row" alignItems="center" columnGap={1}>
635 {group('card-state', [icon('card-icon'), text({ text: look.label, color: look.color })])}
636 <Box flexGrow={1} flexShrink={1} minWidth={0}>
637 <Text dimColor wrap="truncate-end">{`${pr.project}/${pr.repo} #${pr.id}`}</Text>
638 </Box>
639 <Box flexShrink={0}>
640 <Text dimColor>{`${age(s.updatedAt - pr.updated)} ago`}</Text>
641 </Box>
642 </Box>
643 <Text bold>{pr.title}</Text>
644 <Box flexDirection="row" alignItems="center" columnGap={1}>
645 <Box flexGrow={1} flexShrink={1} minWidth={0}>
646 <Text dimColor wrap="truncate-end">
647 {pr.author}
648 </Text>
649 </Box>
650 {diff &&
651 group('card-diff', [
652 <Text color="green">{`+${thousands(diff.added)}`}</Text>,
653 <Text color="red">{`−${thousands(diff.removed)}`}</Text>,
654 text({ text: `· ${thousands(diff.files)} file${diff.files === 1 ? '' : 's'}` }),
655 ])}
656 </Box>
657 </Box>
658 )
659
660 // -- the status rows the CI toggle unfolds
661 const rows = [checksRow(detail.checks), reviewsRow(pr), mergeRow(pr, detail)].filter((r): r is Row => r !== null)
662 const line = (key: string, mark: Chip, label: string, value: RenderNode) => (
663 <Box key={key} flexDirection="row" columnGap={1}>
664 <Box width={2} flexShrink={0}>
665 <Text bold color={mark.color} dimColor={!mark.color}>
666 {mark.text}
667 </Text>
668 </Box>
669 <Box width={8} flexShrink={0}>
670 <Text dimColor>{label}</Text>
671 </Box>
672 <Box flexGrow={1} flexShrink={1} minWidth={0}>
673 {value}
674 </Box>
675 </Box>
676 )
677 const hasActions = isOpen && (failed.length > 0 || needsReviewWork || detail.merge?.isConflicted === true)
678 const details = !isCollapsed && (
679 <Box key="band-details" flexDirection="column" marginBottom={1}>
680 {rows.map(r =>
681 line(`band-row-${r.key}`, { text: r.icon, color: r.color }, r.label, <Text wrap="truncate-end">{r.value}</Text>),
682 )}
683 {issueKey &&
684 line(
685 'band-row-jira',
686 { text: '◆', color: 'blue' },
687 'Jira',
688 <Text wrap="truncate-end">
689 {issueHref ? <Link href={issueHref} label={issueKey} /> : issueKey}
690 {issue ? ` · ${issue.status} · ${issue.summary}` : ''}
691 </Text>,
692 )}
693 {hasActions && (
694 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
695 {failed.length > 0 && (
696 <Button
697 key="band-fix-checks"
698 label="Fix failing checks"
699 variant="primary"
700 onPress={() => void draft($, fixChecksPrompt(pr, failed))}
701 />
702 )}
703 {needsReviewWork && (
704 <Button
705 key="band-review"
706 label="Address review"
707 variant="secondary"
708 onPress={() => void draft($, addressReviewPrompt(pr))}
709 />
710 )}
711 {detail.merge?.isConflicted && (
712 <Button
713 key="band-conflicts"
714 label="Resolve conflicts"
715 variant="secondary"
716 onPress={() => void draft($, conflictsPrompt(pr))}
717 />
718 )}
719 </Box>
720 )}
721 </Box>
722 )
723
724 // -- the bar; only the title gives way, then the approvals and merge state drop
725 const review = columns >= 60 ? reviewPill(pr) : null
726 const merge = columns >= 75 ? mergePill(detail) : null
727 const dot = ciDot(detail.checks)
728 const bar = (
729 <Box flexDirection="row" alignItems="center" columnGap={2}>
730 <Box key="band-pr" flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
731 {icon('bar-icon')}
732 <Text bold color={look.color} underline>
733 {number}
734 </Text>
735 {card}
736 </Box>
737 <Box flexGrow={1} flexShrink={1} minWidth={0}>
738 <Text dimColor wrap="truncate-end">
739 {pr.title}
740 </Text>
741 </Box>
742 {merge && group('bar-merge', [text(merge)])}
743 {review && group('bar-review', [text(review)])}
744 {group(
745 'bar-ci',
746 [
747 <Text color={dot} dimColor={!dot}>
748 ●
749 </Text>,
750 <Button key="band-toggle" label={isCollapsed ? 'CI ▼' : 'CI ▲'} plain dimColor onPress={() => void toggle()} />,
751 ],
752 0,
753 )}
754 </Box>
755 )
756
757 return (
758 <Box flexDirection="column" paddingX={isTerminal ? 1 : 0}>
759 {details}
760 {bar}
761 </Box>
762 )
763 })
764
765 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
766 const { Box, Text, Button, Link } = $.ui.resolve(e)
767 const s = await read($, snapshot)
768 const review = s.review.slice(0, cfg.reviewLimit)
769 const hiddenReviews = s.review.length - review.length
770 const base = [...s.review, ...s.mine].map(pr => pr.url).find(Boolean)
771 const dashboardUrl = base ? `${new URL(base).origin}/dashboard` : null
772
773 const header =
774 s.updatedAt === 0
775 ? s.isLoading
776 ? 'Loading…'
777 : 'Not loaded yet'
778 : `Updated ${clock(s.updatedAt)}${s.isLoading ? ' · refreshing…' : ''}`
779
780 const issueLine = (key: string | null) => {
781 if (!key) return null
782 const issue = s.issues[key]
783 const href = issueUrl(cfg, key)
784 return (
785 <Box flexDirection="row" columnGap={1}>
786 <Text>{href ? <Link href={href} label={key} /> : key}</Text>
787 {issue && <Text color={statusColor(issue.category)}>{issue.status}</Text>}
788 {issue && (
789 <Text dimColor wrap="truncate-end">
790 {issue.summary}
791 </Text>
792 )}
793 </Box>
794 )
795 }
796
797 const prRow = (pr: JbPr, kind: 'review' | 'mine') => {
798 const id = `${kind}-${pr.project}-${pr.repo}-${pr.id}`.replace(/[^A-Za-z0-9_-]/g, '_')
799 const approved = approvedOf(pr)
800 const total = pr.reviewers.length
801 const build = buildText(pr.build)
802 const ago = age(s.updatedAt - pr.updated)
803 return (
804 <Box key={id} flexDirection="column" marginTop={1}>
805 <Text bold wrap="truncate-end">
806 {pr.title}
807 </Text>
808 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
809 <Text dimColor>{`#${pr.id} ${pr.project}/${pr.repo}`}</Text>
810 <Text dimColor>{kind === 'review' ? `${pr.author} · ${ago}` : `→ ${pr.toBranch} · ${ago}`}</Text>
811 {kind === 'mine' && (
812 <Text color={total > 0 && approved === total ? 'green' : undefined}>{`✓${approved}/${total}`}</Text>
813 )}
814 {needsWorkOf(pr) && <Text color="yellow">⚠ needs work</Text>}
815 {build && <Text color={buildColor(pr.build)}>{build}</Text>}
816 {pr.isConflicted && <Text color="red">conflict</Text>}
817 {pr.openTasks > 0 && <Text dimColor>{`${pr.openTasks} tasks`}</Text>}
818 {pr.comments > 0 && <Text dimColor>{`${pr.comments} comments`}</Text>}
819 </Box>
820 {issueLine(pr.issueKey)}
821 <Box flexDirection="row" columnGap={2}>
822 {kind === 'review' ? (
823 <Button
824 key={`${id}-go`}
825 label="Review with Claude"
826 dimColor
827 onPress={() => void draft($, reviewPrompt(pr))}
828 />
829 ) : (
830 <Button
831 key={`${id}-go`}
832 label="Triage with Claude"
833 dimColor
834 onPress={() => void draft($, triagePrompt(pr))}
835 />
836 )}
837 {pr.url && (
838 <Text>
839 <Link href={pr.url} label="Open in Bitbucket" />
840 </Text>
841 )}
842 </Box>
843 </Box>
844 )
845 }
846
847 return (
848 <Box flexDirection="column">
849 <Box flexDirection="row" justifyContent="space-between">
850 <Text dimColor>{header}</Text>
851 <Button key="refresh" label="Refresh" hotkey="r" dimColor onPress={() => void refresh($, cfg)} />
852 </Box>
853 {s.error && <Text color="red">{`Bitbucket: ${s.error}`}</Text>}
854 {s.jiraError && <Text color="yellow">{`Jira: ${s.jiraError}`}</Text>}
855
856 <Box marginTop={1}>
857 <Text bold>{`NEEDS MY REVIEW (${s.review.length}${s.reviewHasMore ? '+' : ''})`}</Text>
858 </Box>
859 {s.updatedAt > 0 && review.length === 0 && <Text dimColor>Nothing is waiting for you.</Text>}
860 {review.map(pr => prRow(pr, 'review'))}
861 {(hiddenReviews > 0 || s.reviewHasMore) && dashboardUrl && (
862 <Box marginTop={1}>
863 <Text dimColor>
864 {`${hiddenReviews > 0 ? `+${hiddenReviews}${s.reviewHasMore ? '+' : ''} more · ` : ''}`}
865 <Link href={dashboardUrl} label="Bitbucket dashboard" />
866 </Text>
867 </Box>
868 )}
869
870 <Box marginTop={2}>
871 <Text bold>{`MY OPEN PRS (${s.mine.length})`}</Text>
872 </Box>
873 {s.updatedAt > 0 && s.mine.length === 0 && <Text dimColor>No open pull requests.</Text>}
874 {s.mine.map(pr => prRow(pr, 'mine'))}
875 </Box>
876 )
877 })
878}
879types/index.d.ts 70 lines1export type JbBuild = { ok: number; failed: number; running: number }
2
3export type JbIssue = { key: string; summary: string; status: string; category: string }
4
5export type JbReviewer = { name: string; status: string }
6
7export type JbPr = {
8 id: number
9 project: string
10 repo: string
11 title: string
12 url: string
13 sha: string
14 fromBranch: string
15 toBranch: string
16 author: string
17 updated: number
18 isDraft: boolean
19 reviewers: JbReviewer[]
20 comments: number
21 openTasks: number
22 isConflicted: boolean
23 build: JbBuild | null
24 issueKey: string | null
25}
26
27export type JbCheck = { name: string; state: 'ok' | 'failed' | 'running'; url: string }
28
29export type JbMerge = { canMerge: boolean; isConflicted: boolean; vetoes: string[] }
30
31export type JbDiff = { files: number; added: number; removed: number }
32
33export type JbPrDetail = {
34 state: 'OPEN' | 'DRAFT' | 'MERGED' | 'DECLINED'
35 checks: JbCheck[]
36 merge: JbMerge | null
37 diff: JbDiff | null
38}
39
40export type JbBranch = {
41 name: string
42 project: string | null
43 repo: string | null
44 issueKey: string | null
45 pr: JbPr | null
46 detail: JbPrDetail | null
47}
48
49export type JbSnapshot = {
50 updatedAt: number
51 isLoading: boolean
52 error: string | null
53 jiraError: string | null
54 review: JbPr[]
55 reviewHasMore: boolean
56 mine: JbPr[]
57 issues: Record<string, JbIssue>
58 branch: JbBranch | null
59}
60
61declare module 'claude-code' {
62 interface PluginState {
63 'jira-bitbucket': {
64 snapshot: JbSnapshot
65 /** The PR bar shows its line alone, the status rows folded; kept across sessions in $.store */
66 isBarCollapsed: boolean
67 }
68 }
69}
70