When a session merges a PR (or arms auto-merge), makes it update the PR's Jira ticket: Done, or a state comment. Blocks the turn's end once while a ticket is…

Read-only public mirror of the Claude Code mods (plugins) that Brand Bridge Media sessions run. The source of truth is claude-mods/ in the private bbm-infrastructure repo; every merge there that touches a mod overwrites this repo with a fresh snapshot. Edits made here are lost on the next sync.
Pull requests are not accepted. One opened by anyone but the owner is closed automatically and the owner is notified.
Install (what the cloud environment setup script runs):
claude plugin marketplace add brand-bridge-media/bbm-mods
claude plugin install fleet-tracker@bbm-mods --scope userhooks/register.ts 161 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { PendingTicket } from '../types'
5
6// Why this exists (LC-1729): on 2026-10-08 LC-1537 and LC-1551 were put in
7// front of the operator as open work weeks after their code shipped. Measured that day:
8// 34 of the 64 tickets with code merged since Sept 1 were still not Done.
9// Closing a ticket was a manual step no session owned; sessions stop at merge.
10// So the session that merges a PR now owns its ticket: it is told at merge time
11// and held back ONCE at the end of the turn if it skipped it.
12//
13// Sibling: session-close nudges for a Jira comment at /close-out. This mod is
14// the sharper one, tied to the merge itself and asking for Done-or-state.
15// Tools are matched by suffix so the MCP server's name does not matter.
16
17const KEY = /\b(LC|LCH|LCC|BBM)-\d+\b/gi
18const IS_KEY = /^(LC|LCH|LCC|BBM)-\d+$/
19const MERGE = /__(merge_pull_request|enable_pr_auto_merge)$/
20// Arming auto-merge still makes this session the ticket's owner (the merge train merges later with
21// no tool call to hook), but the PR is NOT merged yet: never ask for Done on it (LC-1884).
22const QUEUE = /__enable_pr_auto_merge$/
23const CREATE_PR = /__create_pull_request$/
24const JIRA_TOUCH = /__(transitionJiraIssue|addCommentToJiraIssue|editJiraIssue)$/
25const WATCHED = /^mcp__[^_].*__(merge_pull_request|enable_pr_auto_merge|create_pull_request|transitionJiraIssue|addCommentToJiraIssue|editJiraIssue)$/
26
27const pending = atom({ plugin: 'jira-closer', key: 'pending' } as const, [] as PendingTicket[])
28const titles = atom({ plugin: 'jira-closer', key: 'titles' } as const, {} as Record<string, string>)
29
30// Callout convention (claude-mods/README.md): prefix for the model, toast for the operator.
31function say($: any, text: string): string {
32 const msg = `[mod:jira-closer] ${text}`
33 $.ui.toast(msg)
34 return msg
35}
36
37function prId(e: Record<string, unknown>, num?: unknown): string {
38 return `${String(e.owner ?? '')}/${String(e.repo ?? '')}#${String(num ?? e.pullNumber ?? e.pull_number ?? '')}`
39}
40
41function keysIn(s: string): string[] {
42 return [...new Set((s.match(KEY) ?? []).map(k => k.toUpperCase()))]
43}
44
45function textOf(v: unknown): string {
46 try { return typeof v === 'string' ? v : JSON.stringify(v) } catch { return '' }
47}
48
49function closesLines(body: string): string {
50 return body.split('\n').filter(l => /\b(closes|fixes|resolves)\b/i.test(l)).join(' ')
51}
52
53// The PR's own identity: title, head branch and Closes/Fixes/Resolves lines of
54// the body. undefined when the text is not a PR object we recognise.
55function prFields(text: string): string | undefined {
56 try {
57 const pr = JSON.parse(text) as { title?: unknown; head?: { ref?: unknown }; body?: unknown }
58 if (typeof pr.title !== 'string') return undefined
59 const closes = closesLines(String(pr.body ?? ''))
60 return `${pr.title} ${String(pr.head?.ref ?? '')} ${closes}`
61 } catch { return undefined }
62}
63
64function waitingStatus($: any, list: PendingTicket[]): void {
65 $.ui.status(list.length ? `jira-closer: ${list.map(p => p.key).join(', ')} awaiting update` : undefined)
66}
67
68export const register: Register = on => {
69 on('tool.call', { tool: WATCHED }, async ($, ev, next) => {
70 const e = ev as unknown as Record<string, unknown>
71 const tool = String(e.tool)
72 const ran = await next(ev)
73 if (ran.deny !== undefined || ran.isError) return ran
74
75 // Remember titles of PRs this session opens, so their merge needs no fetch.
76 if (CREATE_PR.test(tool)) {
77 const m = textOf(ran.result).match(/"number"\s*:\s*(\d+)|\/pull\/(\d+)/)
78 const num = m?.[1] ?? m?.[2]
79 const closes = closesLines(String(e.body ?? ''))
80 if (num) await update($, titles, t => ({ ...t, [prId(e, num)]: `${String(e.title ?? '')} ${String(e.head ?? '')} ${closes}` }))
81 return ran
82 }
83
84 if (MERGE.test(tool)) {
85 const id = prId(e)
86 const queued = QUEUE.test(tool)
87 let source = (await read($, titles))[id] ?? String(e.commit_title ?? '')
88 let unread = false
89 if (keysIn(source).length === 0) {
90 // A PR opened elsewhere: read its title, branch and Closes/Fixes lines
91 // through the same server. Refs: lines are deliberately ignored.
92 try {
93 const server = tool.split('__')[1] ?? 'github'
94 const r = await $.mcp.call(server, 'pull_request_read', {
95 method: 'get', owner: e.owner, repo: e.repo, pullNumber: e.pullNumber,
96 })
97 const text = (r.content ?? []).map(c => textOf((c as { text?: unknown }).text ?? c)).join(' ')
98 const pr = prFields(text)
99 if (r.isError || pr === undefined) unread = true
100 else source = pr
101 } catch { unread = true }
102 }
103 const keys = keysIn(source)
104 if (keys.length === 0 && unread) {
105 // Could not examine the PR: say so rather than pass silently, and hold
106 // the turn's end once until the session touches some Jira ticket.
107 const key = `?${id}`
108 await update($, pending, list => [...list.filter(p => p.key !== key), { key, pr: id, nagged: false, queued }])
109 return {
110 ...ran,
111 context: [...(ran.context ?? []), say($, `could not read ${id}'s title to find its Jira ticket. ` +
112 `Find the ticket it served and transition it to Done or post a state comment.`)],
113 }
114 }
115 if (keys.length === 0) return ran
116
117 await update($, pending, list => [
118 ...list.filter(p => !keys.includes(p.key)),
119 ...keys.map(key => ({ key, pr: id, nagged: false, queued })),
120 ])
121 waitingStatus($, await read($, pending))
122 const ask = queued
123 ? `${id} is QUEUED to merge (auto-merge armed), not merged, and references ${keys.join(', ')}. Before ` +
124 `this turn ends, post a one-line state comment on each (PR ready, auto-merge armed, what remains). ` +
125 `Do not transition to Done until the merge lands: the train can still evict the PR.`
126 : `${id} references ${keys.join(', ')}. Before this turn ends, for each: read the ticket's ` +
127 `NEWEST comment and its done-criteria, check live state, then either transition it to Done ` +
128 `(this PR finished it) or post a one-line state comment (what shipped, what remains).`
129 return { ...ran, context: [...(ran.context ?? []), say($, ask)] }
130 }
131
132 if (JIRA_TOUCH.test(tool)) {
133 const key = String(e.issueIdOrKey ?? '').toUpperCase()
134 // A touch on a real ticket also settles a PR whose ticket could not be
135 // read; a touch naming no ticket settles nothing.
136 if (!IS_KEY.test(key)) return ran
137 await update($, pending, list => list.filter(p => p.key !== key && !p.key.startsWith('?')))
138 waitingStatus($, await read($, pending))
139 }
140 return ran
141 })
142
143 // Once per ticket, so a ticket the session judges should stay as-is cannot
144 // trap it in a loop: the second Stop always goes through.
145 on('classic.Stop', async ($, e, next) => {
146 const due = (await read($, pending)).filter(p => !p.nagged)
147 if (due.length === 0) return next(e)
148 await update($, pending, l => l.map(p => ({ ...p, nagged: true })))
149 const list = (ps: PendingTicket[]) => ps.map(p => `${p.key} (${p.pr})`).join(', ')
150 const merged = due.filter(p => !p.queued)
151 const queued = due.filter(p => p.queued)
152 const parts = [
153 merged.length ? `merged PR(s) left these Jira tickets untouched: ${list(merged)}. Read each newest ` +
154 `comment, then transition to Done or post a state comment.` : '',
155 queued.length ? `PR(s) queued to merge (auto-merge armed, NOT merged yet) left these Jira tickets ` +
156 `untouched: ${list(queued)}. Post a state comment on each; Done only after the merge lands.` : '',
157 ]
158 return { block: say($, parts.filter(Boolean).join(' ')) }
159 })
160}
161types/index.d.ts 9 lines1// queued: the PR only had auto-merge armed, so it has not merged yet (LC-1884).
2export type PendingTicket = { key: string; pr: string; nagged: boolean; queued?: boolean }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'jira-closer': { pending: PendingTicket[]; titles: Record<string, string> }
7 }
8}
9