Zijpaneel en statusregel met de voortgang van de Jira-Epic waar het ticket van je sessie onder valt

Een mod voor Claude Code (desktop-app of terminal). Hij toont in een zijpaneel en in de statusregel hoe ver de Jira-Epic is waar het ticket van je sessie onder valt.
REV-4 ▰▰▰▰▱▱▱▱▱▱ 43% · nog 4 open ≈ 4 sessies. Vuistregel: één sessie per ticket.Je hebt nodig: Claude Code 2.1.286 of nieuwer, en in claude.ai de Atlassian-connector, gekoppeld aan je eigen site (Instellingen → Connectors → Atlassian).
claude plugin marketplace add 4innovations/epic-progress
claude plugin install epic-progress@epic-progress --config sites=<jouwsite> --config projects=<KEY>
sites: de naam van je Atlassian-site. jouwnaam staat voor jouwnaam.atlassian.net. Meerdere sites scheid je met komma's.projects: de Jira-projectcodes waarvan je tickets en Epics wilt zien, bijvoorbeeld REV of REV,WEB.Achteraf wijzigen kan in ~/.claude/settings.json:
"pluginConfigs": { "epic-progress@epic-progress": { "options": { "sites": "jouwnaam", "projects": "REV,WEB" } } }
Typ je /epic WEB-12 terwijl WEB nog niet in je projecten staat, dan meldt de mod dat en geeft hij de regel die je moet toevoegen.
Alleen in auto mode: auto mode laat een mod niet zomaar bij Jira. Weigert hij, dan toont het paneel de exacte regel met een knop Kopieer regel. Zet die regel in ~/.claude/settings.json onder permissions.allow en klik daarna op Vernieuwen. De regel geeft alleen leesrecht (zoeken in Jira). Schrijven in Jira gaat nog steeds via de gewone toestemmingscontrole.
rev-12-…) gaat voor. Zonder branch wordt het eerste ticket dat langskomt het sessieticket: een ticketcode in je bericht, of een ticket dat Claude in Jira wijzigt./epic opent het paneel. /epic REV-12 wisselt naar dat ticket en zet zijn Epic vast. /epic <woorden> zoekt een Epic op titel. /epic auto laat de mod weer het sessieticket volgen. /epic uit zet paneel en statusregel uit voor deze sessie.awaiting-field-test tellen als Done, maar staan apart vermeld.cd plugins/epic-progress
claude plugin test .
claude plugin validate .
Gebruik een claude van dezelfde versie als de desktop-app.
MIT, zie LICENSE.
hooks/register.tsx 651 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { EpicRef, EpicView, Picker } from '../types'
5import {
6 BUCKET_COLOR,
7 BUCKET_LABEL,
8 DEFAULT_PROJECTS,
9 DEFAULT_SITES,
10 barSvg,
11 browseUrl,
12 configKey,
13 epicTarget,
14 filterEpics,
15 findTicketKey,
16 fixedServer,
17 parseEpicCommand,
18 parsePage,
19 parseProjects,
20 parseSites,
21 permissionRule,
22 progressOf,
23 projectOf,
24 resultText,
25 ringSvg,
26 savedOutputPath,
27 segmentsOf,
28 serverFromToolNames,
29 sortChildren,
30 statusLine,
31 toChild,
32} from './epic'
33import type { JiraNode, SearchPage } from './epic'
34
35type $ = EngineInterface
36
37const PANE = 'epic-progress'
38const TITLE = 'Epic-voortgang'
39const FIELDS = ['summary', 'status', 'issuetype', 'parent', 'labels']
40// Each refresh of a large Epic leaves a saved result file in the session folder; keep them few.
41const REFRESH_MS = 15 * 60 * 1000
42const MAX_PAGES = 5
43const PICKER_ROWS = 20
44// Tried when no claude.ai searchJiraIssuesUsingJql tool shows up in the tool list.
45const FALLBACK_SERVERS = ['claude.ai Atlassian', 'atlassian']
46// Jira writes that can move the Epic's numbers, and the ones that name "the" ticket (v1 and v2 names).
47const WRITE_TOOL = /__(transitionJiraIssue|editJiraIssue|addCommentToJiraIssue|addOrEditJiraIssueComment|createJiraIssue)$/
48const TICKET_TOOL = /__(transitionJiraIssue|editJiraIssue|addCommentToJiraIssue|addOrEditJiraIssueComment)$/
49
50const ICON = { done: '✓', review: '◐', progress: '▶', todo: '○', dropped: '✕' } as const
51
52const view = atom({ plugin: 'epic-progress', key: 'view' } as const, { children: [], isLoading: false } as EpicView)
53const ticket = atom({ plugin: 'epic-progress', key: 'ticket' } as const, '')
54const pinned = atom({ plugin: 'epic-progress', key: 'pinned' } as const, '')
55const picker = atom({ plugin: 'epic-progress', key: 'picker' } as const, {
56 isOpen: false,
57 query: '',
58 epics: [],
59 isLoading: false,
60} as Picker)
61const server = atom({ plugin: 'epic-progress', key: 'server' } as const, '')
62const autoOpened = atom({ plugin: 'epic-progress', key: 'autoOpened' } as const, false)
63const branch = atom({ plugin: 'epic-progress', key: 'branch' } as const, '')
64const isOff = atom({ plugin: 'epic-progress', key: 'isOff' } as const, false)
65const siteOf = atom({ plugin: 'epic-progress', key: 'siteOf' } as const, {} as Record<string, string>)
66
67// Set from the `projects` and `sites` options each time the module loads.
68let projects: readonly string[] = DEFAULT_PROJECTS
69let sites: readonly string[] = DEFAULT_SITES
70
71class JiraError extends Error {
72 constructor(
73 message: string,
74 readonly rule?: string,
75 ) {
76 super(message)
77 }
78}
79
80const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 200)
81const ruleOf = (error: unknown) => (error instanceof JiraError ? error.rule : undefined)
82
83// Work that should not hold up the dispatch that noticed it (a prompt, a tool call).
84function later($: $, work: () => Promise<unknown>) {
85 $.clock.after(0, () => {
86 void work().catch(() => {})
87 })
88}
89
90async function toolNames($: $): Promise<string[]> {
91 try {
92 return (await $.tool.list()).map(tool => tool.name)
93 } catch {
94 return []
95 }
96}
97
98// A site's fixed connection alone when this session loaded it (asked each time: it may connect
99// late); otherwise the claude.ai connector, found once per session.
100async function connectionsFor($: $, site: string): Promise<{ servers: string[]; isFixed: boolean }> {
101 const names = await toolNames($)
102 const fixed = fixedServer(site, names)
103 if (fixed) return { servers: [fixed], isFixed: true }
104 const known = await read($, server)
105 if (known) return { servers: [known], isFixed: false }
106 const found = serverFromToolNames(names)
107
108 return { servers: found ? [found, ...FALLBACK_SERVERS] : FALLBACK_SERVERS, isFixed: false }
109}
110
111async function search(
112 $: $,
113 site: string,
114 jql: string,
115 fields: string[],
116 { nextPageToken, maxResults = 100 }: { nextPageToken?: string; maxResults?: number } = {},
117): Promise<SearchPage> {
118 const { servers, isFixed } = await connectionsFor($, site)
119 // A v2 connection's default compact view drops status categories, parents and labels.
120 const args = { cloudId: site, jql, fields, maxResults, ...(nextPageToken && { nextPageToken }), ...(isFixed && { view: 'full' }) }
121 let firstError: unknown
122 for (const name of servers) {
123 try {
124 const text = resultText(await $.mcp.call(name, 'searchJiraIssuesUsingJql', args))
125 const saved = savedOutputPath(text)
126 const page = parsePage(saved ? ((await $.fs.read(saved)) as string) : text)
127 if (!isFixed) await update($, server, () => name)
128
129 return page
130 } catch (error) {
131 const rule = permissionRule(messageOf(error), name)
132 firstError ??= rule ? new JiraError(messageOf(error), rule) : error
133 }
134 }
135
136 throw firstError ?? new Error('geen Atlassian-connector gevonden')
137}
138
139async function searchAll($: $, site: string, jql: string, fields: string[]): Promise<JiraNode[]> {
140 const nodes: JiraNode[] = []
141 let token: string | undefined
142 for (let page = 0; page < MAX_PAGES; page++) {
143 const result = await search($, site, jql, fields, { nextPageToken: token })
144 nodes.push(...result.nodes)
145 token = result.nextPageToken
146 if (!token) break
147 }
148
149 return nodes
150}
151
152async function rememberSite($: $, project: string, site: string) {
153 if (!(await read($, siteOf))[project]) await update($, siteOf, current => ({ ...current, [project]: site }))
154}
155
156// The Jira site a project lives on: the first of `sites` where it has tickets, found once per session.
157// A site this session cannot reach is passed over; a call auto mode refused stops the search.
158async function siteFor($: $, project: string): Promise<string> {
159 const known = (await read($, siteOf))[project]
160 if (known) return known
161 let firstError: unknown
162 for (const site of sites) {
163 try {
164 if ((await search($, site, `project = ${project}`, ['summary'], { maxResults: 1 })).nodes.length === 0) continue
165 await rememberSite($, project, site)
166
167 return site
168 } catch (error) {
169 if (ruleOf(error)) throw error
170 firstError ??= error
171 }
172 }
173 const why = firstError ? ` (${messageOf(firstError)})` : ''
174 const names = await toolNames($)
175 // Only the claude.ai connector depends on the Claude account; the fixed connections do not.
176 const account = sites.every(site => fixedServer(site, names)) ? '' : ' met deze Atlassian-koppeling; ander Claude-account?'
177
178 throw new Error(`${project} niet gevonden op ${sites.join(', ')}${account}${why}`)
179}
180
181// False when no site this account reaches has the project: such a ticket is passed over, not
182// made the session ticket. A refusal by auto mode counts as reachable, so its rule gets shown.
183async function isReachable($: $, project: string): Promise<boolean> {
184 try {
185 await siteFor($, project)
186
187 return true
188 } catch (error) {
189 return ruleOf(error) !== undefined
190 }
191}
192
193async function epicForKey($: $, key: string): Promise<EpicRef | undefined> {
194 const site = await siteFor($, projectOf(key))
195 const [node] = (await search($, site, `key = ${key}`, FIELDS)).nodes
196 if (!node) throw new Error(`${key} niet gevonden in Jira`)
197 const target = epicTarget(node)
198 if (target && 'lookup' in target) {
199 const [parent] = (await search($, site, `key = ${target.lookup}`, FIELDS)).nodes
200 const above = parent && epicTarget(parent)
201 if (!above || !('epic' in above)) return undefined
202 await rememberSite($, projectOf(above.epic.key), site)
203
204 return above.epic
205 }
206 if (target) await rememberSite($, projectOf(target.epic.key), site)
207
208 return target?.epic
209}
210
211async function showEpic($: $, epic: EpicRef) {
212 await update($, view, current => ({
213 ...current,
214 epic,
215 children: current.epic?.key === epic.key ? current.children : [],
216 updatedAt: current.epic?.key === epic.key ? current.updatedAt : undefined,
217 isLoading: true,
218 error: undefined,
219 hint: undefined,
220 note: undefined,
221 }))
222 try {
223 const site = await siteFor($, projectOf(epic.key))
224 const children = (await searchAll($, site, `parent = ${epic.key} ORDER BY created ASC`, FIELDS)).map(toChild)
225 const updatedAt = await $.clock.now()
226 await update($, view, () => ({ epic, children, updatedAt, isLoading: false }))
227 if (!(await read($, isOff))) $.ui.status(statusLine(epic, progressOf(children)))
228 } catch (error) {
229 await update($, view, current => ({ ...current, isLoading: false, error: messageOf(error), hint: ruleOf(error) }))
230 }
231}
232
233async function showKey($: $, key: string) {
234 await update($, view, current => ({ ...current, isLoading: true, error: undefined, hint: undefined, note: undefined }))
235 try {
236 const epic = await epicForKey($, key)
237 if (!epic) {
238 await update($, view, () => ({
239 children: [],
240 isLoading: false,
241 note: `${key} hoort niet bij een Epic. Kies er een om in dit paneel te tonen; in Jira verandert er niets.`,
242 }))
243 $.ui.status(undefined)
244
245 return undefined
246 }
247 await showEpic($, epic)
248
249 return epic
250 } catch (error) {
251 await update($, view, current => ({ ...current, isLoading: false, error: messageOf(error), hint: ruleOf(error) }))
252
253 return undefined
254 }
255}
256
257async function autoOpen($: $) {
258 if ((await read($, autoOpened)) || (await read($, isOff))) return
259 if (!(await read($, view)).epic) return
260 await update($, autoOpened, () => true)
261 await $.ui.open({ id: PANE, title: TITLE })
262}
263
264// A ticket came by; true when that loaded an Epic. One session is one ticket: the first one
265// that comes by stays, and only the branch name overrides it. `/epic <ticket>` switches on purpose.
266async function onTicket($: $, key: string, isBranch = false): Promise<boolean> {
267 const current = await read($, ticket)
268 if (key === current || (current !== '' && !isBranch)) return false
269 if (!(await read($, isOff)) && !(await isReachable($, projectOf(key)))) return false
270 await update($, ticket, () => key)
271 if ((await read($, pinned)) || (await read($, isOff))) return false
272 await showKey($, key)
273 await autoOpen($)
274
275 return true
276}
277
278async function refresh($: $) {
279 if (await read($, isOff)) return
280 const { epic } = await read($, view)
281 if (epic) await showEpic($, epic)
282}
283
284async function checkBranch($: $) {
285 try {
286 const { exitCode, stdout } = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 5000 })
287 const name = exitCode === 0 ? stdout.trim() : ''
288 if (name === (await read($, branch))) return
289 await update($, branch, () => name)
290 const key = findTicketKey(name, projects)
291 if (key) await onTicket($, key, true)
292 } catch {}
293}
294
295// The configured projects grouped per Jira site. A project no reachable site has is left out,
296// unless that leaves nothing: then its error is the picker's.
297async function projectsBySite($: $): Promise<{ groups: Map<string, string[]>; skipped: string[] }> {
298 const groups = new Map<string, string[]>()
299 const skipped: string[] = []
300 let firstError: unknown
301 for (const project of projects) {
302 try {
303 const site = await siteFor($, project)
304 groups.set(site, [...(groups.get(site) ?? []), project])
305 } catch (error) {
306 if (ruleOf(error)) throw error
307 firstError ??= error
308 skipped.push(project)
309 }
310 }
311 if (groups.size === 0 && firstError) throw firstError
312
313 return { groups, skipped }
314}
315
316const skippedNote = (skipped: readonly string[] | undefined) =>
317 skipped?.length ? ` Niet doorzocht: ${skipped.join(', ')} (niet bereikbaar met dit Claude-account).` : ''
318
319async function openPicker($: $, query: string): Promise<EpicRef[]> {
320 await update($, picker, () => ({ isOpen: true, query, epics: [], isLoading: true }))
321 try {
322 const nodes: JiraNode[] = []
323 const { groups, skipped } = await projectsBySite($)
324 for (const [site, group] of groups) {
325 nodes.push(
326 ...(await searchAll(
327 $,
328 site,
329 `project in (${group.join(', ')}) AND issuetype = Epic AND statusCategory != Done ORDER BY updated DESC`,
330 ['summary', 'status', 'issuetype'],
331 )),
332 )
333 }
334 const epics = nodes
335 .map(toChild)
336 .filter(one => one.bucket !== 'done' && one.bucket !== 'dropped')
337 .map(({ key, summary }) => ({ key, summary }))
338 await update($, picker, current => ({ ...current, epics, skipped, isLoading: false }))
339
340 return epics
341 } catch (error) {
342 await update($, picker, current => ({ ...current, isLoading: false, error: messageOf(error), hint: ruleOf(error) }))
343
344 return []
345 }
346}
347
348async function pick($: $, epic: EpicRef) {
349 await update($, picker, current => ({ ...current, isOpen: false }))
350 await update($, pinned, () => epic.key)
351 await showEpic($, epic)
352}
353
354async function unpin($: $) {
355 await update($, pinned, () => '')
356 const key = await read($, ticket)
357 if (key) await showKey($, key)
358}
359
360function updatedText(ms: number | undefined) {
361 if (ms === undefined) return ''
362 try {
363 return ` · bijgewerkt ${new Date(ms).toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })}`
364 } catch {
365 return ''
366 }
367}
368
369export const register: Register = (on, options) => {
370 projects = parseProjects(String(options.projects ?? ''))
371 sites = parseSites(String(options.sites ?? ''))
372
373 on('session.start', async ($, e, next) => {
374 await $.command.register({
375 name: 'epic',
376 description:
377 'Voortgang van de Epic van dit ticket; /epic BDY-123 of /epic <titel> zet er een vast, /epic auto volgt weer het ticket, /epic uit zet hem uit',
378 argumentHint: '[ticket | titel | auto | uit]',
379 })
380 $.clock.every(REFRESH_MS, () => void refresh($).catch(() => {}))
381 later($, async () => {
382 const { epic, children, updatedAt } = await read($, view)
383 if (epic && updatedAt !== undefined && !(await read($, isOff))) $.ui.status(statusLine(epic, progressOf(children)))
384 await checkBranch($)
385 })
386
387 return next(e)
388 })
389
390 on('turn.complete', async ($, e, next) => {
391 later($, () => checkBranch($))
392
393 return next(e)
394 })
395
396 on('prompt.submit', async ($, e, next) => {
397 const key = findTicketKey(e.text, projects)
398 if (key) later($, () => onTicket($, key))
399
400 return next(e)
401 })
402
403 on('tool.call', async ($, e, next) => {
404 const result = await next(e)
405 if (WRITE_TOOL.test(e.tool)) {
406 const named = (e as { issueIdOrKey?: unknown }).issueIdOrKey
407 const key = typeof named === 'string' && TICKET_TOOL.test(e.tool) ? findTicketKey(named, projects) : undefined
408 later($, async () => {
409 if (key && (await onTicket($, key))) return
410 await refresh($)
411 })
412 }
413
414 return result
415 })
416
417 on('command.run', { command: 'epic' }, async ($, e) => {
418 const command = parseEpicCommand(e.args, projects)
419 if (command.kind === 'off') {
420 await update($, isOff, () => true)
421 await $.ui.close({ id: PANE })
422 $.ui.status(undefined)
423
424 return { text: 'Epic-voortgang staat uit voor deze sessie; typ /epic om hem weer aan te zetten.' }
425 }
426 if (command.kind === 'unconfigured') {
427 const wanted = [...projects, command.project].join(',')
428
429 return {
430 text:
431 `${command.project} staat niet in je projecten (nu: ${projects.join(',')}). ` +
432 `Voeg het toe in ~/.claude/settings.json: "pluginConfigs": { "${configKey($.plugin.root)}": { "options": { "projects": "${wanted}" } } }`,
433 }
434 }
435 await update($, isOff, () => false)
436 await update($, autoOpened, () => true)
437 await $.ui.open({ id: PANE, title: TITLE })
438
439 if (command.kind === 'auto') {
440 await unpin($)
441
442 return { text: 'Epic-voortgang volgt weer het ticket van deze sessie.' }
443 }
444 if (command.kind === 'key') {
445 const epic = await showKey($, command.key)
446 if (epic) {
447 await update($, pinned, () => epic.key)
448 if (epic.key !== command.key) await update($, ticket, () => command.key)
449 }
450
451 if (epic) return { text: `Epic ${epic.key} vastgezet.` }
452 const { error } = await read($, view)
453
454 return { text: error ? `Jira-fout: ${error}` : `Geen Epic gevonden voor ${command.key}.` }
455 }
456 if (command.kind === 'search') {
457 const matches = filterEpics(await openPicker($, command.query), command.query)
458 if (matches.length === 1) {
459 await pick($, matches[0]!)
460
461 return { text: `Epic ${matches[0]!.key} vastgezet.` }
462 }
463 const { error, skipped } = await read($, picker)
464 if (error) return { text: `Jira-fout: ${error}` }
465 if (matches.length === 0) return { text: `Geen open Epic voor "${command.query}".${skippedNote(skipped)}` }
466
467 return { text: `${matches.length} Epics gevonden voor "${command.query}"; kies er een in het paneel.` }
468 }
469 const { epic } = await read($, view)
470 const current = await read($, ticket)
471 if (epic) await refresh($)
472 else if (current) await showKey($, current)
473 else await openPicker($, '')
474
475 return { text: 'Epic-voortgang geopend.' }
476 })
477
478 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
479 const elements = $.ui.resolve(e)
480 const { Box, Text, Button, Link, Code } = elements
481 const Svg = 'Svg' in elements ? elements.Svg : undefined
482 const Input = 'Input' in elements ? elements.Input : undefined
483 const columns = Math.max(20, e.props.bodyColumns)
484 const choose = await read($, picker)
485 const shown = await read($, view)
486 const current = await read($, ticket)
487 const isPinned = (await read($, pinned)) !== ''
488 const siteOfProject = await read($, siteOf)
489 const ruleBlock = (rule: string | undefined) =>
490 rule && (
491 <Box key="rule" flexDirection="column">
492 <Text dimColor>Auto mode laat de mod niet bij Jira. Zet deze regel in ~/.claude/settings.json onder permissions.allow:</Text>
493 <Code source={rule} />
494 <Box flexDirection="row">
495 <Button key="copy-rule" onPress={press => void $.ui.copy({ text: rule, surface: press.surface })}>
496 Kopieer regel
497 </Button>
498 </Box>
499 </Box>
500 )
501
502 if (choose.isOpen) {
503 const matches = filterEpics(choose.epics, choose.query)
504
505 return (
506 <Box flexDirection="column" gap={1}>
507 <Text bold>Kies een Epic</Text>
508 {Input && (
509 <Input
510 key="filter"
511 placeholder="Filter op titel of key…"
512 value={choose.query}
513 submitLabel="kies"
514 autoFocus
515 onInput={value => void update($, picker, p => ({ ...p, query: value }))}
516 onSubmit={async value => {
517 const only = filterEpics((await read($, picker)).epics, value)
518 if (only.length === 1) await pick($, only[0]!)
519 else await update($, picker, p => ({ ...p, query: value }))
520 }}
521 />
522 )}
523 {choose.isLoading && <Text dimColor>Epics laden…</Text>}
524 {choose.error && <Text color="#D64545">{`Jira: ${choose.error}`}</Text>}
525 {ruleBlock(choose.hint)}
526 <Box flexDirection="column">
527 {matches.slice(0, PICKER_ROWS).map(epic => (
528 <Button key={`pick:${epic.key}`} plain onPress={() => pick($, epic)}>
529 {`${epic.key} ${epic.summary}`}
530 </Button>
531 ))}
532 </Box>
533 {matches.length > PICKER_ROWS && <Text dimColor>{`+${matches.length - PICKER_ROWS} meer; verfijn je filter`}</Text>}
534 {!choose.isLoading && !choose.error && matches.length === 0 && (
535 <Text dimColor>{`Geen open Epic met "${choose.query}".${skippedNote(choose.skipped)}`}</Text>
536 )}
537 <Box flexDirection="row">
538 <Button key="cancel" onPress={() => update($, picker, p => ({ ...p, isOpen: false }))}>
539 Annuleren
540 </Button>
541 </Box>
542 </Box>
543 )
544 }
545
546 const { epic } = shown
547 if (!epic) {
548 return (
549 <Box flexDirection="column" gap={1}>
550 {shown.isLoading && <Text dimColor>Laden…</Text>}
551 {shown.error && <Text color="#D64545">{`Jira: ${shown.error}`}</Text>}
552 {ruleBlock(shown.hint)}
553 <Text>{shown.note ?? `Nog geen Epic: werk aan een ${projects.join('/')}-ticket, of kies er een.`}</Text>
554 <Box flexDirection="row">
555 <Button key="choose" variant="primary" onPress={() => openPicker($, '')}>
556 Kies een Epic
557 </Button>
558 </Box>
559 </Box>
560 )
561 }
562
563 // Only a load that succeeded has numbers worth drawing; a failed one would read as "no tickets".
564 const hasData = shown.updatedAt !== undefined
565 const progress = progressOf(shown.children)
566 const sessions =
567 progress.total === 0
568 ? 'Nog geen tickets onder deze Epic.'
569 : progress.remaining === 0
570 ? 'Alle tickets zijn klaar.'
571 : `Nog ${progress.remaining} open ≈ ${progress.remaining} ${progress.remaining === 1 ? 'sessie' : 'sessies'}`
572 const legend = (['done', 'review', 'progress', 'todo'] as const).filter(bucket => progress.counts[bucket] > 0)
573 const cells = Math.min(columns - 2, 48)
574
575 return (
576 <Box flexDirection="column" gap={1}>
577 <Box flexDirection="column">
578 <Link href={browseUrl(siteOfProject[projectOf(epic.key)] ?? sites[0]!, epic.key)} label={epic.key} />
579 <Text bold wrap="wrap">
580 {epic.summary}
581 </Text>
582 </Box>
583 {hasData && (
584 <Box flexDirection="row" gap={2} alignItems="center">
585 {Svg && <Svg source={ringSvg(progress)} alt={`${progress.percent}% klaar`} width={72} height={72} />}
586 <Box flexDirection="column">
587 <Text bold color={BUCKET_COLOR.done}>{`${progress.percent}% klaar`}</Text>
588 <Text dimColor>{`${progress.counts.done} van ${progress.total} tickets done`}</Text>
589 <Text>{sessions}</Text>
590 </Box>
591 </Box>
592 )}
593 {hasData &&
594 (Svg ? (
595 <Svg source={barSvg(progress, 480)} alt={legend.map(b => `${BUCKET_LABEL[b]} ${progress.counts[b]}`).join(', ')} />
596 ) : (
597 <Box flexDirection="row">
598 {segmentsOf(progress, cells).map(({ bucket, size }) => (
599 <Text color={BUCKET_COLOR[bucket]} dimColor={bucket === 'todo'}>
600 {(bucket === 'todo' ? '░' : '█').repeat(size)}
601 </Text>
602 ))}
603 </Box>
604 ))}
605 {hasData && (
606 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
607 {legend.map(bucket => (
608 <Text color={BUCKET_COLOR[bucket]}>{`● ${BUCKET_LABEL[bucket]} ${progress.counts[bucket]}`}</Text>
609 ))}
610 </Box>
611 )}
612 {progress.fieldTest > 0 && (
613 <Text dimColor>{`🧪 ${progress.fieldTest} ${progress.fieldTest === 1 ? 'wacht' : 'wachten'} op veldtest`}</Text>
614 )}
615 {shown.isLoading && <Text dimColor>{hasData ? 'Vernieuwen…' : 'Laden…'}</Text>}
616 {shown.error && <Text color="#D64545">{`Jira: ${shown.error}`}</Text>}
617 {ruleBlock(shown.hint)}
618 <Box flexDirection="column">
619 {sortChildren(shown.children).map(child => (
620 <Box key={`row:${child.key}`} flexDirection="row" gap={1}>
621 <Text color={BUCKET_COLOR[child.bucket]}>{ICON[child.bucket]}</Text>
622 <Text
623 bold={child.key === current}
624 dimColor={child.bucket === 'done' || child.bucket === 'dropped'}
625 strikethrough={child.bucket === 'dropped'}
626 wrap="truncate-end"
627 >
628 {`${child.key} ${child.summary}${child.key === current ? ' ← deze sessie' : ''}`}
629 </Text>
630 </Box>
631 ))}
632 </Box>
633 <Box flexDirection="row" gap={1}>
634 <Button key="refresh" onPress={() => refresh($)}>
635 Vernieuwen
636 </Button>
637 <Button key="choose" onPress={() => openPicker($, '')}>
638 Wissel Epic
639 </Button>
640 {isPinned && (
641 <Button key="auto" onPress={() => unpin($)}>
642 Automatisch volgen
643 </Button>
644 )}
645 </Box>
646 <Text dimColor>{`${isPinned ? 'Vastgezet' : 'Volgt het ticket van deze sessie'}${updatedText(shown.updatedAt)}`}</Text>
647 </Box>
648 )
649 })
650}
651hooks/epic.ts 323 lines1import type { Child, EpicRef, StatusBucket } from '../types'
2
3// The Jira projects whose keys count as "the ticket of this session", unless the `projects` option says otherwise.
4export const DEFAULT_PROJECTS: readonly string[] = ['BDY']
5
6// The Jira sites a project is looked for on, in this order, unless the `sites` option says otherwise.
7export const DEFAULT_SITES: readonly string[] = ['quinventa.atlassian.net', 'faimstudio.atlassian.net']
8
9// The fixed v2 connections on user scope (`~/.claude.json`), one per site, each with its own token.
10// Where one is loaded it is the only way to that site; elsewhere the claude.ai connector is used.
11export const SITE_SERVERS: Readonly<Record<string, string>> = {
12 'quinventa.atlassian.net': 'buddy-atlassian',
13 'faimstudio.atlassian.net': 'faim-atlassian',
14}
15
16const FIXED_SERVERS = new Set(Object.values(SITE_SERVERS))
17
18const searchTool = (server: string) => `mcp__${server}__searchJiraIssuesUsingJql`
19
20const keyPattern = (projects: readonly string[]) =>
21 new RegExp(`(?<![A-Za-z0-9])(${projects.join('|')})-(\\d+)(?!\\d)`, 'i')
22
23export const BUCKETS: readonly StatusBucket[] = ['progress', 'review', 'todo', 'done', 'dropped']
24
25export const BUCKET_LABEL: Record<StatusBucket, string> = {
26 done: 'Done',
27 review: 'In Review',
28 progress: 'In Progress',
29 todo: 'To Do',
30 dropped: "Won't Do",
31}
32
33// Mid-tone colors that read on a light and a dark background alike.
34export const BUCKET_COLOR: Record<StatusBucket, string> = {
35 done: '#2E9E5B',
36 review: '#8B6CD9',
37 progress: '#D99A1E',
38 todo: '#8A8F98',
39 dropped: '#B4B8BF',
40}
41
42export type Progress = {
43 total: number
44 counts: Record<StatusBucket, number>
45 percent: number
46 remaining: number
47 fieldTest: number
48}
49
50export type JiraNode = {
51 key: string
52 fields: {
53 summary?: string
54 labels?: string[]
55 status?: { name?: string; statusCategory?: { key?: string } }
56 issuetype?: { name?: string; hierarchyLevel?: number }
57 parent?: JiraNode
58 }
59}
60
61export type SearchPage = { nodes: JiraNode[]; nextPageToken?: string }
62
63export type EpicCommand =
64 | { kind: 'open' }
65 | { kind: 'auto' }
66 | { kind: 'off' }
67 | { kind: 'key'; key: string }
68 | { kind: 'unconfigured'; key: string; project: string }
69 | { kind: 'search'; query: string }
70
71export function findTicketKey(text: string, projects: readonly string[] = DEFAULT_PROJECTS): string | undefined {
72 const match = keyPattern(projects).exec(text)
73
74 return match ? `${match[1]!.toUpperCase()}-${match[2]}` : undefined
75}
76
77export function parseProjects(value: string): string[] {
78 const projects = value
79 .split(/[\s,;]+/)
80 .map(word => word.toUpperCase())
81 .filter(word => /^[A-Z][A-Z0-9]{1,9}$/.test(word))
82
83 return projects.length > 0 ? projects : [...DEFAULT_PROJECTS]
84}
85
86// A short name stands for <name>.atlassian.net; a URL keeps just its host.
87export function parseSites(value: string): string[] {
88 const sites = value
89 .split(/[\s,;]+/)
90 .map(word => word.toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''))
91 .filter(word => /^[a-z0-9][a-z0-9.-]*$/.test(word))
92 .map(word => (word.includes('.') ? word : `${word}.atlassian.net`))
93
94 return sites.length > 0 ? sites : [...DEFAULT_SITES]
95}
96
97export function projectOf(key: string): string {
98 return key.split('-')[0]!.toUpperCase()
99}
100
101export function browseUrl(site: string, key: string): string {
102 return `https://${site}/browse/${key}`
103}
104
105// The rule that lets a mod's call past auto mode, when that is what refused it.
106export function permissionRule(message: string, server: string): string | undefined {
107 if (!/refused|classifier|permission/i.test(message)) return undefined
108
109 return searchTool(server.replace(/[^A-Za-z0-9_-]/g, '_'))
110}
111
112export function bucketOf(status: string, category: string): StatusBucket {
113 if (/won'?t do|cancel|geannuleerd|vervallen|duplicate/i.test(status)) return 'dropped'
114 if (category === 'done') return 'done'
115 if (/review/i.test(status)) return 'review'
116 if (category === 'indeterminate') return 'progress'
117
118 return 'todo'
119}
120
121export function progressOf(children: readonly Child[]): Progress {
122 const counts: Record<StatusBucket, number> = { done: 0, review: 0, progress: 0, todo: 0, dropped: 0 }
123 for (const one of children) counts[one.bucket]++
124 const total = children.length - counts.dropped
125 const fieldTest = children.filter(one => one.isFieldTest && (one.bucket === 'done' || one.bucket === 'review')).length
126
127 return {
128 total,
129 counts,
130 percent: total === 0 ? 0 : Math.round((counts.done / total) * 100),
131 remaining: total - counts.done,
132 fieldTest,
133 }
134}
135
136type ToolResult = { content: { type: string; text?: string }[]; isError: boolean }
137
138export function resultText(result: ToolResult): string {
139 const text = result.content.map(block => block.text ?? '').join('')
140 if (result.isError) throw new Error(text || 'Jira gaf een fout terug')
141
142 return text
143}
144
145// A result too large to hand over inline comes back as a notice naming the file it went to.
146export function savedOutputPath(text: string): string | undefined {
147 return /Output has been saved to ((?:\/|[A-Za-z]:\\)\S+?)\.?(?=\s|$)/.exec(text)?.[1]
148}
149
150// The claude.ai connector answers `{ issues: { nodes, pageInfo } }`, a v2 connection `{ data: { issues, nextPageToken, isLast } }`.
151export function parsePage(text: string): SearchPage {
152 let parsed: {
153 issues?: { nodes?: JiraNode[]; pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } }
154 data?: { issues?: JiraNode[]; nextPageToken?: string; isLast?: boolean }
155 }
156 try {
157 parsed = JSON.parse(text)
158 } catch {
159 throw new Error(`onverwacht antwoord van Jira: ${text.slice(0, 120)}`)
160 }
161 if (parsed.data) {
162 const { issues, nextPageToken, isLast } = parsed.data
163
164 return { nodes: issues ?? [], nextPageToken: !isLast && nextPageToken ? nextPageToken : undefined }
165 }
166 const pageInfo = parsed.issues?.pageInfo
167
168 return {
169 nodes: parsed.issues?.nodes ?? [],
170 nextPageToken: pageInfo?.hasNextPage && pageInfo.endCursor ? pageInfo.endCursor : undefined,
171 }
172}
173
174export function parseSearch(result: ToolResult): SearchPage {
175 return parsePage(resultText(result))
176}
177
178export function toChild(node: JiraNode): Child {
179 const status = node.fields.status?.name ?? '?'
180
181 return {
182 key: node.key,
183 summary: node.fields.summary ?? '',
184 status,
185 bucket: bucketOf(status, node.fields.status?.statusCategory?.key ?? ''),
186 isFieldTest: (node.fields.labels ?? []).includes('awaiting-field-test'),
187 }
188}
189
190const isEpic = (node: JiraNode) =>
191 node.fields.issuetype?.name === 'Epic' || node.fields.issuetype?.hierarchyLevel === 1
192
193export function epicTarget(node: JiraNode): { epic: EpicRef } | { lookup: string } | undefined {
194 if (isEpic(node)) return { epic: { key: node.key, summary: node.fields.summary ?? '' } }
195 const parent = node.fields.parent
196 if (!parent) return undefined
197 if (isEpic(parent)) return { epic: { key: parent.key, summary: parent.fields.summary ?? '' } }
198
199 return { lookup: parent.key }
200}
201
202export function filterEpics(epics: readonly EpicRef[], query: string): EpicRef[] {
203 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
204
205 return epics.filter(epic => {
206 const haystack = `${epic.key} ${epic.summary}`.toLowerCase()
207
208 return words.every(word => haystack.includes(word))
209 })
210}
211
212// The claude.ai connector behind searchJiraIssuesUsingJql; the fixed connections are not it.
213export function serverFromToolNames(names: readonly string[]): string | undefined {
214 for (const name of names) {
215 const match = /^mcp__(.+)__searchJiraIssuesUsingJql$/.exec(name)
216 if (match && !FIXED_SERVERS.has(match[1]!)) return match[1]
217 }
218
219 return undefined
220}
221
222// The fixed connection of a site, when this session loaded it.
223export function fixedServer(site: string, names: readonly string[]): string | undefined {
224 const server = SITE_SERVERS[site]
225
226 return server && names.includes(searchTool(server)) ? server : undefined
227}
228
229export function parseEpicCommand(args: string, projects: readonly string[] = DEFAULT_PROJECTS): EpicCommand {
230 const text = args.trim()
231 if (text === '') return { kind: 'open' }
232 if (text.toLowerCase() === 'auto') return { kind: 'auto' }
233 if (/^(uit|off|stop)$/i.test(text)) return { kind: 'off' }
234 const key = findTicketKey(text, projects)
235 if (key && text.toUpperCase() === key) return { kind: 'key', key }
236 const other = /^([A-Z][A-Z0-9]{1,9})-\d+$/i.exec(text)
237 if (other) return { kind: 'unconfigured', key: text.toUpperCase(), project: other[1]!.toUpperCase() }
238
239 return { kind: 'search', query: text }
240}
241
242// The key settings.json files the plugin's options under: plain when loaded from a folder, `@marketplace` when installed.
243export function configKey(root: string): string {
244 const marketplace = /[\\/]plugins[\\/]cache[\\/]([^\\/]+)[\\/]/.exec(root)?.[1]
245
246 return marketplace ? `epic-progress@${marketplace}` : 'epic-progress'
247}
248
249export function statusLine(epic: EpicRef, progress: Progress): string {
250 const filled = Math.round(progress.percent / 10)
251 const bar = '▰'.repeat(filled) + '▱'.repeat(10 - filled)
252 const tail =
253 progress.total === 0
254 ? 'nog geen tickets'
255 : progress.remaining === 0
256 ? 'klaar'
257 : `nog ${progress.remaining} open ≈ ${progress.remaining} ${progress.remaining === 1 ? 'sessie' : 'sessies'}`
258
259 return `${epic.key} ${bar} ${progress.percent}% · ${tail}`
260}
261
262export function sortChildren(children: readonly Child[]): Child[] {
263 return [...children].sort((a, b) => BUCKETS.indexOf(a.bucket) - BUCKETS.indexOf(b.bucket))
264}
265
266// Left to right on a bar: what is finished first, what is untouched last.
267const BAR_ORDER: readonly StatusBucket[] = ['done', 'review', 'progress', 'todo']
268
269export function segmentsOf(progress: Progress, cells: number): { bucket: StatusBucket; size: number }[] {
270 if (progress.total === 0) return []
271 let counted = 0
272 let drawn = 0
273
274 return BAR_ORDER.filter(bucket => progress.counts[bucket] > 0).map(bucket => {
275 counted += progress.counts[bucket]
276 const end = Math.round((counted / progress.total) * cells)
277 const size = end - drawn
278 drawn = end
279
280 return { bucket, size }
281 })
282}
283
284export function ringSvg(progress: Progress): string {
285 const size = 72
286 const r = 26
287 const circumference = 2 * Math.PI * r
288 const round = (n: number) => Math.round(n * 100) / 100
289 let offset = 0
290 const arcs = BAR_ORDER.filter(bucket => bucket !== 'todo' && progress.counts[bucket] > 0).map(bucket => {
291 const length = (progress.counts[bucket] / progress.total) * circumference
292 const arc = `<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${BUCKET_COLOR[bucket]}" stroke-width="10" stroke-dasharray="${round(length)} ${round(circumference)}" stroke-dashoffset="${round(-offset)}"><title>${BUCKET_LABEL[bucket]}: ${progress.counts[bucket]}</title></circle>`
293 offset += length
294
295 return arc
296 })
297
298 return (
299 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">` +
300 `<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${BUCKET_COLOR.todo}" stroke-opacity="0.3" stroke-width="10"/>` +
301 `<g transform="rotate(-90 ${size / 2} ${size / 2})">${arcs.join('')}</g>` +
302 `</svg>`
303 )
304}
305
306export function barSvg(progress: Progress, width: number): string {
307 const height = 14
308 let x = 0
309 const segments = segmentsOf(progress, width).map(({ bucket, size }) => {
310 const rect = `<rect x="${x}" y="0" width="${size}" height="${height}" fill="${BUCKET_COLOR[bucket]}"><title>${BUCKET_LABEL[bucket]}: ${progress.counts[bucket]}</title></rect>`
311 x += size
312
313 return rect
314 })
315
316 return (
317 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
318 `<clipPath id="r"><rect width="${width}" height="${height}" rx="7"/></clipPath>` +
319 `<g clip-path="url(#r)"><rect width="${width}" height="${height}" fill="${BUCKET_COLOR.todo}" opacity="0.25"/>${segments.join('')}</g>` +
320 `</svg>`
321 )
322}
323types/index.d.ts 53 lines1export type StatusBucket = 'done' | 'review' | 'progress' | 'todo' | 'dropped'
2
3export type EpicRef = { key: string; summary: string }
4
5export type Child = {
6 key: string
7 summary: string
8 status: string
9 bucket: StatusBucket
10 isFieldTest: boolean
11}
12
13export type EpicView = {
14 epic?: EpicRef
15 children: Child[]
16 updatedAt?: number
17 isLoading: boolean
18 error?: string
19 // The allow rule to add when auto mode refused the Jira call.
20 hint?: string
21 note?: string
22}
23
24export type Picker = {
25 isOpen: boolean
26 query: string
27 epics: EpicRef[]
28 isLoading: boolean
29 error?: string
30 hint?: string
31 // Configured projects no site this account reaches has; the picker could not search them.
32 skipped?: string[]
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 'epic-progress': {
38 view: EpicView
39 ticket: string
40 pinned: string
41 picker: Picker
42 // The claude.ai Atlassian connector found this session; the fixed connections are not kept here.
43 server: string
44 autoOpened: boolean
45 branch: string
46 // `/epic uit`: no pane, status line or following until the next /epic.
47 isOff: boolean
48 // The Jira site (host) each project was found on this session.
49 siteOf: Record<string, string>
50 }
51 }
52}
53