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.

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