SLOPSHOPPER

epic-progress

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

newpaneguardcommandstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · epic-progress
│ ┃ Epic-voortgang ✕ › fix the failing auth test and add an audit log call │ ┃ Kies een Epic │ ┃ ⏺ Read(src/auth.ts) │ ┃ Filter op titel of key… ⏎ kies ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Jira: BDY niet gevonden op ⎿ Added 2 lines, removed 1 line │ ┃ quinventa.atlassian.net, ⏺ Bash(bun test) │ ┃ faimstudio.atlassian.net met deze ⎿ 3 pass, 1 fail │ ┃ Atlassian-koppeling; ander Claude-account? │ ┃ (undefined is not an object (evaluating ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 'result.content.map')) │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ Annuleren ] › /epic │ ⎿ epic-progress: Epic-voortgang geopend. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Epic-voortgang
Kies een Epic Filter op titel of key… ⏎ kies Jira: BDY niet gevonden op quinventa.atlassian.net, faimstudio.atlassian.net met deze Atlassian-koppeling; ander Claude-account? (undefined is not an object (evaluating 'result.content.map')) [ Annuleren ]
README

epic-progress

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.

  • Een voortgangsring en -balk per status (Done, In Review, In Progress, To Do). Won't Do telt niet mee.
  • De statusregel, bijvoorbeeld REV-4 ▰▰▰▰▱▱▱▱▱▱ 43% · nog 4 open ≈ 4 sessies. Vuistregel: één sessie per ticket.
  • De tickets van de Epic, met het ticket van je sessie gemarkeerd.
  • Het paneel ververst na elke Jira-wijziging door Claude, en elke 15 minuten.

Installeren

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.

Gebruik

  • Eén sessie is één ticket. Het ticket in de branchnaam (bijvoorbeeld 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.
  • Vastzetten en uitzetten gelden per sessie. Kiezen in het paneel verandert niets in Jira.
  • Tickets met het label awaiting-field-test tellen als Done, maar staan apart vermeld.

Ontwikkelen

cd plugins/epic-progress
claude plugin test .
claude plugin validate .

Gebruik een claude van dezelfde versie als de desktop-app.

Licentie

MIT, zie LICENSE.

Source 3 files
hooks/register.tsx 651 lines
1import { 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}
651
hooks/epic.ts 323 lines
1import 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}
323
types/index.d.ts 53 lines
1export 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