| 1 | // diffinery-ci: the current branch's Diffinery pull request and its CI checks, live. |
| 2 | // |
| 3 | // In the terminal, the desktop app's Code tab and VS Code, a band above the prompt and the |
| 4 | // status line show the PR and its checks, and /diffinery-ci opens a pane with each check and |
| 5 | // a link to its log. Everywhere hooks run, cloud sessions and project threads included: |
| 6 | // when this session pushes the branch or opens its pull request, the mod watches that commit |
| 7 | // and, once its checks settle, tells Claude, the way GitHub auto-fix wakes a session: failures |
| 8 | // where the band shows the rest, passes too where nothing draws, so a cloud thread hears either |
| 9 | // way. /diffinery-ci answers in text where nothing draws. Nothing to set up. |
| 10 | // |
| 11 | // It reads the GraphQL API of the Diffinery instance the session uses (DIFFINERY_URL, the |
| 12 | // plugin's Diffinery URL setting, the CLI's saved URL, else diffinery.com) with the token of |
| 13 | // the diffinery CLI (DIFFINERY_TOKEN or `diffinery login`) or of gh signed in to that host, |
| 14 | // and sends a token only to the instance it belongs to. |
| 15 | |
| 16 | import { atom, read, update } from 'claude-code' |
| 17 | import type { Register } from 'claude-code' |
| 18 | |
| 19 | import type { Note, Pr } from '../types' |
| 20 | import { |
| 21 | QUERY, |
| 22 | SYMBOL, |
| 23 | checksLine, |
| 24 | chooseAuth, |
| 25 | duration, |
| 26 | factsLine, |
| 27 | ordered, |
| 28 | parsePr, |
| 29 | parseRemotes, |
| 30 | pickRemote, |
| 31 | pollDelay, |
| 32 | publishes, |
| 33 | statusText, |
| 34 | touchesPr, |
| 35 | wakeChoice, |
| 36 | wakeFor, |
| 37 | wakeText, |
| 38 | type Target, |
| 39 | type WakeChoice, |
| 40 | } from './lib' |
| 41 | |
| 42 | const PLUGIN = 'diffinery-ci' |
| 43 | const PANE = 'diffinery-ci' |
| 44 | |
| 45 | // Change a tag when the stored value's fields change: it declines what an earlier version |
| 46 | // stored, which outlives a reload. |
| 47 | const prAtom = atom({ plugin: 'diffinery-ci', key: 'pr' } as const, null, { shape: 'pr-1' }) |
| 48 | const noteAtom = atom({ plugin: 'diffinery-ci', key: 'note' } as const, null, { shape: 'note-1' }) |
| 49 | const watchingAtom = atom({ plugin: 'diffinery-ci', key: 'watching' } as const, true) |
| 50 | const hiddenAtom = atom({ plugin: 'diffinery-ci', key: 'isHidden' } as const, false) |
| 51 | |
| 52 | type Auth = { url: string; host: string; token: string | null; source: string } |
| 53 | type Look = { pr: Pr | null; note: Note | null } |
| 54 | |
| 55 | const COLOR = { pass: 'success', fail: 'error', run: 'warning', wait: 'subtle', skip: 'subtle' } as const |
| 56 | |
| 57 | const NOTES: Record<WakeChoice, string> = { |
| 58 | auto: 'failures, and passes too where nothing draws the band (cloud sessions)', |
| 59 | failures: 'failures', |
| 60 | all: 'passes and failures', |
| 61 | off: 'never', |
| 62 | } |
| 63 | |
| 64 | export const register: Register = (on, options) => { |
| 65 | // What the person chose with `/diffinery-ci notes`, kept across sessions. |
| 66 | let choice: WakeChoice = 'auto' |
| 67 | |
| 68 | // Set by session.start: look again now (resolves once the look is done), and note that this |
| 69 | // session just published the branch. Other hooks reach the session's loop through these. |
| 70 | let refreshNow: (() => Promise<Look>) | null = null |
| 71 | let notePublished: (() => Promise<void>) | null = null |
| 72 | let nextAt = 0 |
| 73 | // Mirrors watchingAtom for the session's loop, whose own reads see the state as it began. |
| 74 | let watching = true |
| 75 | |
| 76 | on('session.start', async ($, e, next) => { |
| 77 | const started = await next(e) |
| 78 | choice = wakeChoice(await $.store.get('wake')) ?? 'auto' |
| 79 | |
| 80 | let auth: Auth | null = null |
| 81 | let lastDir: string | null = null |
| 82 | let failures = 0 |
| 83 | let busy: Promise<Look> | null = null |
| 84 | // repo#branch → the local commit a push from this session published, until its checks settle. |
| 85 | const awaiting = new Map<string, string>() |
| 86 | // repo#number → sha:rollup last seen, to toast a change once. |
| 87 | const seen = new Map<string, string>() |
| 88 | |
| 89 | const readAuth = async (): Promise<Auth> => { |
| 90 | let savedUrl: string | null = null |
| 91 | let savedToken: string | null = null |
| 92 | const xdg = await $.env.get('XDG_CONFIG_HOME') |
| 93 | const home = await $.env.get('HOME') |
| 94 | const base = xdg && xdg.startsWith('/') ? xdg : home ? `${home}/.config` : null |
| 95 | if (base) { |
| 96 | try { |
| 97 | const cfg = JSON.parse(await $.fs.read(`${base}/diffinery/config.json`)) as Record<string, unknown> |
| 98 | if (typeof cfg.url === 'string') savedUrl = cfg.url |
| 99 | if (typeof cfg.token === 'string' && cfg.token) savedToken = cfg.token |
| 100 | } catch { |
| 101 | // No config file, or not JSON: the CLI isn't signed in here. |
| 102 | } |
| 103 | } |
| 104 | const picked = chooseAuth({ |
| 105 | envUrl: (await $.env.get('DIFFINERY_URL')) ?? null, |
| 106 | envToken: (await $.env.get('DIFFINERY_TOKEN')) ?? null, |
| 107 | option: typeof options?.url === 'string' ? options.url : null, |
| 108 | savedUrl, |
| 109 | savedToken, |
| 110 | }) |
| 111 | const { url, host } = picked |
| 112 | let { token, source } = picked |
| 113 | if (!token) { |
| 114 | try { |
| 115 | const gh = await $.process.run(['gh', 'auth', 'token', '--hostname', host], { timeoutMs: 5000 }) |
| 116 | const t = gh.exitCode === 0 ? gh.stdout.trim() : '' |
| 117 | if (t && !/\s/.test(t)) { |
| 118 | token = t |
| 119 | source = 'gh' |
| 120 | } |
| 121 | } catch { |
| 122 | // gh isn't installed. |
| 123 | } |
| 124 | } |
| 125 | return { url, host, token, source } |
| 126 | } |
| 127 | |
| 128 | const git = async (dir: string, args: string[]): Promise<string | null> => { |
| 129 | try { |
| 130 | const r = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: 5000 }) |
| 131 | return r.exitCode === 0 ? r.stdout.trim() : null |
| 132 | } catch { |
| 133 | return null |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | const targetIn = async (dir: string, host: string): Promise<Target | null> => { |
| 138 | const remotes = await git(dir, ['config', '--get-regexp', '^remote\\..*\\.url$']) |
| 139 | if (!remotes) return null |
| 140 | const repo = pickRemote(parseRemotes(remotes), host) |
| 141 | if (!repo) return null |
| 142 | const branch = await git(dir, ['branch', '--show-current']) |
| 143 | if (!branch) return null |
| 144 | const top = (await git(dir, ['rev-parse', '--show-toplevel'])) ?? dir |
| 145 | return { host, repo, branch, dir: top } |
| 146 | } |
| 147 | |
| 148 | // The session's directory first, then the last Diffinery checkout seen, then the |
| 149 | // checkouts one level down (cloud threads start above the repositories they clone). |
| 150 | const findTarget = async (host: string): Promise<Target | null> => { |
| 151 | const cwd = await $.session.cwd() |
| 152 | const tried = new Set<string>() |
| 153 | for (const dir of [cwd, lastDir]) { |
| 154 | if (!dir || tried.has(dir)) continue |
| 155 | tried.add(dir) |
| 156 | const t = await targetIn(dir, host) |
| 157 | if (t) return t |
| 158 | } |
| 159 | try { |
| 160 | const entries = await $.fs.list(cwd) |
| 161 | for (const entry of entries.slice(0, 40)) { |
| 162 | if (entry.kind !== 'dir' || entry.name.startsWith('.')) continue |
| 163 | const dir = `${cwd.replace(/\/+$/, '')}/${entry.name}` |
| 164 | if (tried.has(dir)) continue |
| 165 | const t = await targetIn(dir, host) |
| 166 | if (t) return t |
| 167 | } |
| 168 | } catch { |
| 169 | // Unreadable directory: nothing below it to show. |
| 170 | } |
| 171 | return null |
| 172 | } |
| 173 | |
| 174 | const fetchPr = async (a: Auth, t: Target): Promise<Pr | null> => { |
| 175 | const [owner, name] = t.repo.split('/') as [string, string] |
| 176 | const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' } |
| 177 | if (a.token) headers.Authorization = `Bearer ${a.token}` |
| 178 | const res = await $.http.fetch(`${a.url}/api/graphql`, { |
| 179 | method: 'POST', |
| 180 | headers, |
| 181 | body: JSON.stringify({ query: QUERY, variables: { o: owner, r: name, b: t.branch } }), |
| 182 | }) |
| 183 | if (res.status === 401 || res.status === 403) { |
| 184 | const err = new Error(a.token ? `Diffinery refused the token from ${a.source}` : 'not signed in to Diffinery') |
| 185 | err.name = 'SignIn' |
| 186 | throw err |
| 187 | } |
| 188 | if (!res.ok) throw new Error(`Diffinery answered ${res.status}`) |
| 189 | return parsePr(JSON.parse(res.text) as unknown, t) |
| 190 | } |
| 191 | |
| 192 | // Toasts a change of the checks, and tells Claude once the commit this session pushed settles. |
| 193 | const settle = async (pr: Pr): Promise<void> => { |
| 194 | const key = `${pr.repo}#${pr.number}` |
| 195 | const before = seen.get(key) |
| 196 | const now = `${pr.sha}:${pr.rollup}` |
| 197 | seen.set(key, now) |
| 198 | if (before && before !== now && before.startsWith(pr.sha) && pr.state === 'OPEN') { |
| 199 | if (pr.rollup === 'pass') $.ui.toast(`PR #${pr.number}: CI passed`) |
| 200 | if (pr.rollup === 'fail') $.ui.toast(`PR #${pr.number}: CI failed`) |
| 201 | } |
| 202 | const branchKey = `${pr.repo}#${pr.branch}` |
| 203 | const pushed = awaiting.get(branchKey) |
| 204 | if (!pushed || pushed !== pr.sha || (pr.rollup !== 'pass' && pr.rollup !== 'fail')) return |
| 205 | awaiting.delete(branchKey) |
| 206 | if (!watching) return |
| 207 | const text = wakeText(pr, wakeFor(choice, (await $.session.surfaces()).length > 0)) |
| 208 | // From a timer of its own: a look the person asked for (/diffinery-ci) holds a turn, |
| 209 | // and a prompt submitted from inside it would wait on that turn. |
| 210 | if (text) $.clock.after(0, () => void $.prompt.submit({ text }).catch(() => undefined)) |
| 211 | } |
| 212 | |
| 213 | const refresh = (): Promise<Look> => { |
| 214 | if (busy) return busy |
| 215 | busy = (async () => { |
| 216 | try { |
| 217 | auth ??= await readAuth() |
| 218 | const t = await findTarget(auth.host) |
| 219 | if (!t) { |
| 220 | await update($, prAtom, () => null) |
| 221 | await update($, noteAtom, () => null) |
| 222 | $.ui.status(undefined) |
| 223 | failures = 0 |
| 224 | nextAt = (await $.clock.now()) + 60_000 |
| 225 | return { pr: null, note: null } |
| 226 | } |
| 227 | lastDir = t.dir |
| 228 | const pr = await fetchPr(auth, t) |
| 229 | failures = 0 |
| 230 | await update($, noteAtom, () => null) |
| 231 | await update($, prAtom, () => pr) |
| 232 | $.ui.status(pr ? statusText(pr) : undefined) |
| 233 | if (pr) await settle(pr) |
| 234 | nextAt = (await $.clock.now()) + pollDelay(pr, 0) * 1000 |
| 235 | return { pr, note: null } |
| 236 | } catch (err) { |
| 237 | failures += 1 |
| 238 | const e = err instanceof Error ? err : new Error(String(err)) |
| 239 | const note: Note = |
| 240 | e.name === 'SignIn' |
| 241 | ? { kind: 'signin', text: `${e.message}. Set DIFFINERY_TOKEN, run \`diffinery login\`, or \`diffinery auth setup-gh\`.` } |
| 242 | : { kind: 'error', text: `Couldn't read CI from Diffinery: ${e.message}` } |
| 243 | if (e.name === 'SignIn') auth = null // read the token again next time: the person may sign in meanwhile |
| 244 | await update($, noteAtom, () => note) |
| 245 | $.ui.log(`${PLUGIN}: ${note.text}`, { to: 'debug' }) |
| 246 | nextAt = (await $.clock.now()) + pollDelay(null, failures) * 1000 |
| 247 | return { pr: await read($, prAtom), note } |
| 248 | } finally { |
| 249 | busy = null |
| 250 | } |
| 251 | })() |
| 252 | return busy |
| 253 | } |
| 254 | |
| 255 | refreshNow = refresh |
| 256 | notePublished = async () => { |
| 257 | auth ??= await readAuth() |
| 258 | const t = await findTarget(auth.host) |
| 259 | const head = t ? await git(t.dir, ['rev-parse', 'HEAD']) : null |
| 260 | if (t && head) awaiting.set(`${t.repo}#${t.branch}`, head) |
| 261 | nextAt = (await $.clock.now()) + 3000 |
| 262 | } |
| 263 | |
| 264 | await $.command.register({ |
| 265 | name: 'diffinery-ci', |
| 266 | description: "Show this branch's Diffinery pull request and its CI checks (on/off: tell Claude when CI settles; notes: which results)", |
| 267 | argumentHint: '[on|off|refresh|notes auto|failures|all|off]', |
| 268 | }) |
| 269 | void refresh() |
| 270 | // A cheap tick: it only looks at the clock until a look is due. |
| 271 | $.clock.every(2000, () => { |
| 272 | void $.clock.now().then(now => { |
| 273 | if (now >= nextAt) void refresh() |
| 274 | }) |
| 275 | }) |
| 276 | return started |
| 277 | }) |
| 278 | |
| 279 | on('command.run', { command: 'diffinery-ci' }, async ($, e) => { |
| 280 | const arg = e.args.trim().toLowerCase() |
| 281 | const [verb, value] = arg.split(/\s+/) |
| 282 | if (verb === 'notes') { |
| 283 | const picked = wakeChoice(value) |
| 284 | if (!picked) return { text: `Diffinery CI: Claude is told about ${NOTES[choice]}. Choose with /diffinery-ci notes auto|failures|all|off.` } |
| 285 | choice = picked |
| 286 | await $.store.set('wake', picked) |
| 287 | return { text: `Diffinery CI: from now on Claude is told about ${NOTES[picked]}, in this and later sessions.` } |
| 288 | } |
| 289 | if (arg === 'off' || arg === 'on') { |
| 290 | watching = arg === 'on' |
| 291 | await update($, watchingAtom, () => watching) |
| 292 | return { |
| 293 | text: |
| 294 | arg === 'on' |
| 295 | ? `Diffinery CI: Claude will be told when CI settles on a commit this session pushed (${NOTES[choice]}).` |
| 296 | : 'Diffinery CI: Claude will not be told when CI settles in this session. The band and status line still update.', |
| 297 | } |
| 298 | } |
| 299 | await update($, hiddenAtom, () => false) |
| 300 | // What this look found: a hook's own reads see the state as its dispatch began. |
| 301 | const { pr, note } = (await refreshNow?.()) ?? { pr: null, note: null } |
| 302 | if (arg !== 'refresh') await $.ui.open({ id: PANE, title: 'Diffinery CI' }) |
| 303 | if (note) return { text: `Diffinery CI: ${note.text}` } |
| 304 | if (!pr) return { text: 'Diffinery CI: no pull request for this branch on Diffinery.' } |
| 305 | const facts = factsLine(pr) |
| 306 | return { text: `Diffinery CI: PR #${pr.number} ${pr.title}: ${checksLine(pr.checks)}${facts ? ` · ${facts}` : ''}. ${pr.url}` } |
| 307 | }) |
| 308 | |
| 309 | // After a command that may change the branch, its PR or its checks, look again soon; after |
| 310 | // a push or `pr create` from this session, watch the pushed commit. |
| 311 | on('tool.call', { tool: 'Bash' }, async ($, e, next) => { |
| 312 | const ran = await next(e) |
| 313 | const command = e.command |
| 314 | if (!touchesPr(command) || 'deny' in ran || ran.isError) return ran |
| 315 | // Never let the look-again get in the way of the call's own result. |
| 316 | if (publishes(command)) void notePublished?.().catch(() => undefined) |
| 317 | else void $.clock.now().then(now => (nextAt = now + 3000), () => undefined) |
| 318 | return ran |
| 319 | }) |
| 320 | |
| 321 | on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { |
| 322 | if (e.props.hasSurvey || (await read($, hiddenAtom))) return next(e) |
| 323 | const pr = await read($, prAtom) |
| 324 | const note = await read($, noteAtom) |
| 325 | const { Box, Text, Button, Link } = $.ui.resolve(e) |
| 326 | if (!pr) { |
| 327 | if (note?.kind !== 'signin') return next(e) |
| 328 | return ( |
| 329 | <Box flexDirection="row" flexWrap="wrap" columnGap={1}> |
| 330 | <Text color="warning">Diffinery CI:</Text> |
| 331 | <Text dimColor>{note.text}</Text> |
| 332 | </Box> |
| 333 | ) |
| 334 | } |
| 335 | const facts = factsLine(pr) |
| 336 | const symbol = pr.state === 'MERGED' ? '◆' : pr.state === 'CLOSED' ? '◇' : SYMBOL[pr.rollup === 'none' ? 'wait' : pr.rollup] |
| 337 | const color = pr.state !== 'OPEN' ? 'merged' : COLOR[pr.rollup === 'none' ? 'wait' : pr.rollup] |
| 338 | return ( |
| 339 | <Box flexDirection="row" flexWrap="wrap" columnGap={1}> |
| 340 | <Text color={color}>{symbol}</Text> |
| 341 | <Text bold>PR #{pr.number}</Text> |
| 342 | <Text wrap="truncate-end">{pr.title.length > 60 ? `${pr.title.slice(0, 59)}…` : pr.title}</Text> |
| 343 | {pr.state === 'OPEN' && <Text color={color}>{checksLine(pr.checks)}</Text>} |
| 344 | {facts !== '' && <Text dimColor>{facts}</Text>} |
| 345 | <Button key="details" label="Checks" plain onPress={() => void $.ui.open({ id: PANE, title: 'Diffinery CI' })} /> |
| 346 | <Link href={pr.url} label="Open" /> |
| 347 | <Button key="hide" label="Hide" plain dimColor onPress={() => void update($, hiddenAtom, () => true)} /> |
| 348 | </Box> |
| 349 | ) |
| 350 | }) |
| 351 | |
| 352 | on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => { |
| 353 | const { Box, Text, Button, Link } = $.ui.resolve(e) |
| 354 | const pr = await read($, prAtom) |
| 355 | const note = await read($, noteAtom) |
| 356 | const isWatching = await read($, watchingAtom) |
| 357 | const now = await $.clock.now() |
| 358 | const refreshButton = <Button key="refresh" label="Refresh" onPress={() => void refreshNow?.()} /> |
| 359 | if (!pr) { |
| 360 | return ( |
| 361 | <Box flexDirection="column" rowGap={1}> |
| 362 | <Text dimColor>{note ? note.text : 'No pull request for this branch on Diffinery yet.'}</Text> |
| 363 | {refreshButton} |
| 364 | </Box> |
| 365 | ) |
| 366 | } |
| 367 | const facts = factsLine(pr) |
| 368 | const failed = pr.state === 'OPEN' && pr.rollup === 'fail' |
| 369 | return ( |
| 370 | <Box flexDirection="column" rowGap={1}> |
| 371 | <Box flexDirection="column"> |
| 372 | <Text bold> |
| 373 | PR #{pr.number} {pr.title} |
| 374 | </Text> |
| 375 | <Text dimColor> |
| 376 | {pr.repo} · {pr.branch} · {pr.sha.slice(0, 7)} |
| 377 | {facts ? ` · ${facts}` : ''} |
| 378 | </Text> |
| 379 | <Link href={pr.url} label="Open on Diffinery" /> |
| 380 | </Box> |
| 381 | <Box flexDirection="column"> |
| 382 | {pr.checks.length === 0 && <Text dimColor>No checks on this commit yet.</Text>} |
| 383 | {ordered(pr.checks).map(c => ( |
| 384 | <Box key={`check-${c.workflow ?? ''}-${c.name}`} flexDirection="row" columnGap={1}> |
| 385 | <Text color={COLOR[c.state]}>{SYMBOL[c.state]}</Text> |
| 386 | <Text>{c.name}</Text> |
| 387 | {c.workflow && <Text dimColor>{c.workflow}</Text>} |
| 388 | <Text dimColor>{duration(c, now)}</Text> |
| 389 | {c.url && <Link href={c.url} label="log" />} |
| 390 | </Box> |
| 391 | ))} |
| 392 | </Box> |
| 393 | {note && <Text color="warning">{note.text}</Text>} |
| 394 | <Box flexDirection="row" columnGap={2}> |
| 395 | {refreshButton} |
| 396 | {failed && ( |
| 397 | <Button |
| 398 | key="fix" |
| 399 | label="Fix CI" |
| 400 | variant="primary" |
| 401 | onPress={() => { |
| 402 | const text = wakeText(pr, 'failures') |
| 403 | if (text) void $.prompt.submit({ text }) |
| 404 | }} |
| 405 | /> |
| 406 | )} |
| 407 | <Button |
| 408 | key="watch" |
| 409 | label={isWatching ? 'Stop telling Claude' : 'Tell Claude when CI settles'} |
| 410 | onPress={() => { |
| 411 | watching = !isWatching |
| 412 | void update($, watchingAtom, () => watching) |
| 413 | }} |
| 414 | /> |
| 415 | </Box> |
| 416 | </Box> |
| 417 | ) |
| 418 | }) |
| 419 | } |