Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

419 lines · 17.3 KBRaw History
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
16import { atom, read, update } from 'claude-code'
17import type { Register } from 'claude-code'
18
19import type { Note, Pr } from '../types'
20import {
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
42const PLUGIN = 'diffinery-ci'
43const 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.
47const prAtom = atom({ plugin: 'diffinery-ci', key: 'pr' } as const, null, { shape: 'pr-1' })
48const noteAtom = atom({ plugin: 'diffinery-ci', key: 'note' } as const, null, { shape: 'note-1' })
49const watchingAtom = atom({ plugin: 'diffinery-ci', key: 'watching' } as const, true)
50const hiddenAtom = atom({ plugin: 'diffinery-ci', key: 'isHidden' } as const, false)
51
52type Auth = { url: string; host: string; token: string | null; source: string }
53type Look = { pr: Pr | null; note: Note | null }
54
55const COLOR = { pass: 'success', fail: 'error', run: 'warning', wait: 'subtle', skip: 'subtle' } as const
56
57const 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
64export 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}