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.

300 lines · 14.3 KBRaw History
1import { expect, mock, test } from 'claude-code/testing'
2import type { On } from 'claude-code'
3
4import {
5 checksLine,
6 chooseAuth,
7 parsePr,
8 pollDelay,
9 publishes,
10 remoteRepo,
11 statusText,
12 touchesPr,
13 wakeFor,
14 wakeText,
15} from '../hooks/lib'
16
17const TARGET = { host: 'diffinery.com', repo: 'codician/demo', branch: 'feature/x', dir: '/work/demo' }
18
19const run = (name: string, status: string, conclusion: string | null) => ({
20 __typename: 'CheckRun',
21 name,
22 status,
23 conclusion,
24 detailsUrl: `https://diffinery.com/codician/demo/actions/runs/r1/job/${name.length}`,
25 startedAt: '2026-10-23T10:00:00.000Z',
26 completedAt: status === 'COMPLETED' ? '2026-10-23T10:01:05.000Z' : null,
27 checkSuite: { workflowRun: { workflow: { name: 'CI' } } },
28})
29
30const answer = (sha: string, contexts: unknown[], extra: Record<string, unknown> = {}) => ({
31 data: {
32 repository: {
33 pullRequests: {
34 nodes: [
35 {
36 number: 7,
37 title: 'Add retries',
38 url: 'https://diffinery.com/codician/demo/pull/7',
39 state: 'OPEN',
40 isDraft: false,
41 reviewDecision: 'REVIEW_REQUIRED',
42 mergeStateStatus: 'BLOCKED',
43 headRefOid: sha,
44 autoMergeRequest: { enabledAt: '2026-10-23T10:00:00Z' },
45 commits: { nodes: [{ commit: { oid: sha, statusCheckRollup: { state: 'PENDING', contexts: { nodes: contexts } } } }] },
46 ...extra,
47 },
48 ],
49 },
50 },
51 },
52})
53
54test('the instance comes from DIFFINERY_URL, the setting, then the CLI, and tokens stay with theirs', async () => {
55 const none = { envUrl: null, envToken: null, option: null, savedUrl: null, savedToken: null }
56 expect(chooseAuth(none)).toEqual({ url: 'https://diffinery.com', host: 'diffinery.com', token: null, source: '' })
57 expect(chooseAuth({ ...none, option: 'https://diffinery.com', savedUrl: 'https://git.example.com/', savedToken: 't' })).toEqual({
58 url: 'https://git.example.com',
59 host: 'git.example.com',
60 token: 't',
61 source: 'diffinery login',
62 })
63 // The setting picks the instance, but the CLI's diffinery.com token doesn't go there.
64 expect(chooseAuth({ ...none, option: 'https://git.example.com', savedToken: 't', envToken: 'e' })).toEqual({
65 url: 'https://git.example.com',
66 host: 'git.example.com',
67 token: null,
68 source: '',
69 })
70 expect(chooseAuth({ ...none, envUrl: 'http://localhost:8787', envToken: 'e', option: 'https://git.example.com' }).token).toBe('e')
71 expect(chooseAuth({ ...none, envUrl: 'http://localhost:8787', envToken: 'e' }).host).toBe('localhost:8787')
72 expect(chooseAuth({ ...none, option: 'not a url', savedToken: 't' })).toEqual({ url: 'https://diffinery.com', host: 'diffinery.com', token: 't', source: 'diffinery login' })
73 expect(remoteRepo('http://localhost:8787/dev/repo.git', 'localhost:8787')).toBe('dev/repo')
74})
75
76test('remotes on the Diffinery host are read, lookalikes are not', async () => {
77 expect(remoteRepo('https://diffinery.com/codician/demo.git', 'diffinery.com')).toBe('codician/demo')
78 expect(remoteRepo('https://x-token@diffinery.com/codician/demo', 'diffinery.com')).toBe('codician/demo')
79 expect(remoteRepo('git@diffinery.com:codician/demo.git', 'diffinery.com')).toBe('codician/demo')
80 expect(remoteRepo('ssh://git@diffinery.com:22/codician/demo.git', 'diffinery.com')).toBe('codician/demo')
81 expect(remoteRepo('https://diffinery.com.evil.example/codician/demo', 'diffinery.com')).toBe(null)
82 expect(remoteRepo('https://github.com/codician/demo', 'diffinery.com')).toBe(null)
83 expect(remoteRepo('https://diffinery.com/codician/demo/extra', 'diffinery.com')).toBe(null)
84 expect(remoteRepo('https://diffinery.com/../demo', 'diffinery.com')).toBe(null)
85})
86
87test('the answer reads into a pull request with its checks', async () => {
88 const pr = parsePr(
89 answer('abc1234def', [
90 run('Typecheck', 'COMPLETED', 'SUCCESS'),
91 run('Unit tests', 'IN_PROGRESS', null),
92 run('Lint', 'COMPLETED', 'FAILURE'),
93 { __typename: 'StatusContext', context: 'deploy/preview', state: 'SUCCESS', targetUrl: 'https://example.com' },
94 ]),
95 TARGET,
96 )
97 expect(pr?.number).toBe(7)
98 expect(pr?.rollup).toBe('fail')
99 expect(pr?.autoMerge).toBe(true)
100 expect(pr && checksLine(pr.checks)).toBe('✗ 1 failed · ● 1 running · ✓ 2')
101 expect(pr && statusText(pr)).toBe('PR #7 ✗ 1 failed')
102 expect(parsePr({ data: { repository: { pullRequests: { nodes: [] } } } }, TARGET)).toBe(null)
103 expect(() => parsePr({ errors: [{ message: 'nope' }] }, TARGET)).toThrow('nope')
104})
105
106test('Claude is told about failures, and about passes only when asked', async () => {
107 const failing = parsePr(answer('abc1234def', [run('Lint', 'COMPLETED', 'FAILURE')]), TARGET)!
108 const passing = parsePr(answer('abc1234def', [run('Lint', 'COMPLETED', 'SUCCESS')]), TARGET)!
109 expect(wakeText(failing, 'failures')).toContain('Diffinery CI failed on codician/demo#7')
110 expect(wakeText(failing, 'failures')).toContain('CI / Lint')
111 expect(wakeText(failing, 'off')).toBe(null)
112 expect(wakeText(passing, 'failures')).toBe(null)
113 expect(wakeText(passing, 'all')).toContain('Diffinery CI passed')
114 // Unset, it tells about passes too where nothing draws the band.
115 expect(wakeFor('auto', true)).toBe('failures')
116 expect(wakeFor('auto', false)).toBe('all')
117 expect(wakeFor('off', false)).toBe('off')
118})
119
120test('it looks often while checks run, rarely when nothing moves, and backs off on errors', async () => {
121 const running = parsePr(answer('abc1234def', [run('Lint', 'IN_PROGRESS', null)]), TARGET)!
122 const done = parsePr(answer('abc1234def', [run('Lint', 'COMPLETED', 'SUCCESS')]), TARGET)!
123 const merged = parsePr(answer('abc1234def', [], { state: 'MERGED' }), TARGET)!
124 expect(pollDelay(running, 0)).toBe(15)
125 expect(pollDelay(done, 0)).toBe(60)
126 expect(pollDelay(merged, 0)).toBe(300)
127 expect(pollDelay(null, 0)).toBe(60)
128 expect([1, 2, 3, 4, 9].map(n => pollDelay(null, n))).toEqual([30, 60, 120, 240, 240])
129})
130
131test('the commands that publish or move a branch are recognised', async () => {
132 expect(publishes('cd demo && git push -u origin feature/x')).toBe(true)
133 expect(publishes('git -C demo push')).toBe(true)
134 expect(publishes('cd demo && git -c http.extraHeader="Authorization: Bearer $TOKEN" push -u origin feature/x')).toBe(true)
135 expect(publishes("git -c credential.helper='!f() { echo password=$T; }; f' -C demo push")).toBe(true)
136 expect(publishes('git --no-pager -c push.default=current --git-dir=demo/.git push')).toBe(true)
137 expect(publishes('git --work-tree demo -P push origin HEAD')).toBe(true)
138 expect(publishes('git -c core.pager=cat log --oneline')).toBe(false)
139 expect(publishes('git config push.default current')).toBe(false)
140 expect(publishes('git commit -m "push later"')).toBe(false)
141 expect(publishes('diffinery pr create --intent x')).toBe(true)
142 expect(publishes('gh pr view')).toBe(false)
143 expect(touchesPr('gh pr view')).toBe(true)
144 expect(touchesPr('git switch main')).toBe(true)
145 expect(touchesPr('git -c user.name="A B" -c user.email=a@b.c commit -qam x')).toBe(true)
146 expect(touchesPr('git -c core.pager=cat log')).toBe(false)
147 expect(touchesPr('ls -la')).toBe(false)
148})
149
150// A session in /work, with a Diffinery checkout at /work/demo one level down (as in a cloud
151// thread), signed in through DIFFINERY_TOKEN. `server` is what Diffinery answers.
152type Server = { body: unknown; status?: number; head: string; seen: string[]; token?: string | null; surfaces?: ('terminal' | 'mobile')[]; store?: Map<string, unknown> }
153
154function engine(on: On, server: Server) {
155 const store = (server.store ??= new Map())
156 on('store.get', (_, e) => ({ value: store.get(e.key) }))
157 on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
158 on('session.surfaces', () => ({ value: server.surfaces ?? ['terminal'] }))
159 on('command.register', (_, e) => ({ value: { command: e.name } }))
160 on('ui.open', () => ({ value: { isPlaced: true } }))
161 on('ui.status', () => ({ value: undefined }))
162 on('ui.toast', () => ({ value: undefined }))
163 on('ui.log', (_, e) => (server.seen.push(`log ${JSON.stringify(e)}`), { value: undefined }))
164 on('session.start', (_, e) => ({ cwd: e.cwd }))
165 on('session.cwd', () => ({ value: '/work' }))
166 on('env.get', (_, e) => ({ value: e.name === 'DIFFINERY_TOKEN' ? (server.token === null ? undefined : 'dfy_test') : e.name === 'HOME' ? '/home/me' : undefined }))
167 on('fs.read', () => ({ deny: 'no such file' }))
168 on('fs.list', (_, e) => ({
169 value: e.path === '/work' ? [{ name: 'demo', kind: 'dir' as const, size: 0, mtimeMs: 0, isLink: false }] : [],
170 }))
171 on('process.run', (_, e) => {
172 const [cmd, , dir, ...args] = e.argv
173 const ok = (stdout: string) => ({ value: { exitCode: 0, stdout, stderr: '' } })
174 if (cmd !== 'git' || dir !== '/work/demo') return { value: { exitCode: 128, stdout: '', stderr: 'not a git repository' } }
175 if (args[0] === 'config') return ok('remote.origin.url https://diffinery.com/codician/demo.git\n')
176 if (args[0] === 'branch') return ok('feature/x\n')
177 if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') return ok('/work/demo\n')
178 if (args[0] === 'rev-parse') return ok(`${server.head}\n`)
179 return ok('')
180 })
181 on('http.fetch', (_, e) => {
182 server.seen.push(`${e.url} ${e.init?.headers?.Authorization ?? 'no-auth'}`)
183 const status = server.status ?? 200
184 return { value: { status, ok: status < 400, headers: {}, text: JSON.stringify(server.body) } }
185 })
186 on('tool.call', () => ({ result: {} }))
187}
188
189const BAND = {
190 plugin: 'diffinery-ci',
191 component: 'AbovePrompt',
192 props: { hasSurvey: false, isWorking: false, rows: 10, bodyColumns: 100 },
193} as const
194
195const PANE = {
196 plugin: 'diffinery-ci',
197 component: 'Pane',
198 requestId: 'diffinery-ci',
199 props: { title: 'Diffinery CI', isFocused: false, bodyColumns: 60, placement: 'dock', scroll: { offset: 0, bodyRows: 30 }, view: {} },
200} as const
201
202test('the band and the pane show the branch PR and its checks, on every surface', async ($, on) => {
203 const server = {
204 body: answer('abc1234def', [run('Typecheck', 'COMPLETED', 'SUCCESS'), run('Lint', 'COMPLETED', 'FAILURE')]),
205 head: 'abc1234def',
206 seen: [] as string[],
207 }
208 engine(on, server)
209 mock.clock(on, { now: 1_000_000 })
210 await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true })
211 await $.command.run({ command: 'diffinery-ci', args: 'refresh' })
212
213 expect(server.seen[0]).toBe('https://diffinery.com/api/graphql Bearer dfy_test')
214 for (const surface of ['terminal', 'desktop'] as const) {
215 const ui = await $.ui.mount({ ...BAND, surface })
216 expect((await ui.find({ type: 'Text', text: /PR #7/ }))?.text).toBe('PR #7')
217 expect((await ui.find({ type: 'Text', text: /failed/ }))?.text).toBe('✗ 1 failed · ✓ 1')
218 await ui.unmount()
219 }
220 for (const surface of ['terminal', 'desktop', 'vscode', 'mobile'] as const) {
221 const ui = await $.ui.mount({ ...PANE, surface })
222 expect(await ui.find({ type: 'Text', text: /Lint/ })).toBeDefined()
223 expect(await ui.find({ key: 'fix' })).toBeDefined()
224 await ui.unmount()
225 }
226})
227
228test('a push from the session is watched, and Claude hears once its checks fail', async ($, on) => {
229 const server = {
230 body: answer('old0000000', [run('Lint', 'COMPLETED', 'FAILURE')]),
231 head: 'new1111111',
232 seen: [] as string[],
233 }
234 engine(on, server)
235 const clock = mock.clock(on, { now: 1_000_000 })
236 const prompts: string[] = []
237 on('prompt.submit', (_, e) => {
238 prompts.push(e.text)
239 return { text: e.text }
240 })
241 await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true })
242 await $.tool.call({ tool: 'Bash', command: 'cd demo && git push origin feature/x' })
243 await clock.advance(1000)
244
245 // Diffinery still reports the old commit's failure: nothing to say about the new one yet.
246 await $.command.run({ command: 'diffinery-ci', args: 'refresh' })
247 expect(prompts).toEqual([])
248
249 server.body = answer('new1111111', [run('Lint', 'IN_PROGRESS', null)])
250 await $.command.run({ command: 'diffinery-ci', args: 'refresh' })
251 expect(prompts).toEqual([])
252
253 server.body = answer('new1111111', [run('Lint', 'COMPLETED', 'FAILURE')])
254 await $.command.run({ command: 'diffinery-ci', args: 'refresh' })
255 await clock.advance(10)
256 expect(prompts.length).toBe(1)
257 expect(prompts[0]).toContain('Diffinery CI failed on codician/demo#7')
258
259 // Once per pushed commit.
260 await $.command.run({ command: 'diffinery-ci', args: 'refresh' })
261 await clock.advance(10)
262 expect(prompts.length).toBe(1)
263})
264
265test('without a token the band says how to sign in', async ($, on) => {
266 const server = { body: { message: 'Requires authentication' }, status: 401, head: 'abc', seen: [] as string[], token: null }
267 engine(on, server)
268 mock.clock(on, { now: 1_000_000 })
269 await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true })
270 const out = await $.command.run({ command: 'diffinery-ci', args: 'refresh' })
271 expect(out.text).toContain('not signed in to Diffinery')
272 const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
273 expect(await ui.find({ type: 'Text', text: /diffinery login/ })).toBeDefined()
274 await ui.unmount()
275})
276
277// A cloud session draws nothing: the band can't show a pass, so Claude is told about it too.
278test('where nothing draws, Claude also hears when CI passes; notes picks which results', async ($, on) => {
279 const server: Server = { body: answer('new1111111', [run('Lint', 'IN_PROGRESS', null)]), head: 'new1111111', seen: [], surfaces: [] }
280 engine(on, server)
281 const clock = mock.clock(on, { now: 1_000_000 })
282 const prompts: string[] = []
283 on('prompt.submit', (_, e) => {
284 prompts.push(e.text)
285 return { text: e.text }
286 })
287 await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: false })
288 await $.tool.call({ tool: 'Bash', command: 'cd demo && git push origin feature/x' })
289 await clock.advance(1000)
290 server.body = answer('new1111111', [run('Lint', 'COMPLETED', 'SUCCESS')])
291 await $.command.run({ command: 'diffinery-ci', args: 'refresh' })
292 await clock.advance(10)
293 expect(prompts.length).toBe(1)
294 expect(prompts[0]).toContain('Diffinery CI passed on codician/demo#7')
295
296 const out = await $.command.run({ command: 'diffinery-ci', args: 'notes failures' })
297 expect(out.text).toContain('from now on Claude is told about failures')
298 expect(server.store?.get('wake')).toBe('failures')
299 expect((await $.command.run({ command: 'diffinery-ci', args: 'notes' })).text).toContain('Claude is told about failures.')
300})