SLOPSHOPPER

jira-closer

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…

newguardtoaststatus
v0.1.0no licenseupdated 2026-10-21brand-bridge-media/bbm-mods/claude-mods/jira-closer
A shopper browsing a rack in a slop shop
README

bbm-mods

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 user
Source 2 files
hooks/register.ts 161 lines
1import { 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}
161
types/index.d.ts 9 lines
1// 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