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.

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