ReviewOS

also looking at this

reviewos/reviewos.org

feat(reviews): the suggestions get an interface, priced as designed

#6
Merged glennmichael123 wants to merge feat/suggested-reviewers-panel into main
9 files +741 -10
tests/e2e/suggested-reviewers-panel.test.tsadded+313-0
Changes to tests/e2e/suggested-reviewers-panel.test.ts
@@ -0,0 +1,313 @@
1// The suggested-reviewers panel, through the real routes, with the credential
2// a browser actually carries.
3//
4// The ranking is unit tested and the git-to-forge joins are covered by
5// suggest-reviewers.test.ts, which calls the action in process. What neither
6// can see is the surface this file pins: the endpoint answering a fetch whose
7// only credential is a cookie (docs/todo's "a signed-in browser is not a
8// signed-in test client" - every other caller of this endpoint holds a
9// bearer), and the page offering the panel without paying for it.
10//
11// The panel's design constraint is that the `git log` is spent when the reader
12// opens it, never at render. No browser here runs the script, so that is
13// asserted the only way markup can assert it: the rendered page carries the
14// panel and the URL it would ask, and none of the answer.
15
16import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
17import { mkdirSync, mkdtempSync, rmdirSync, rmSync, writeFileSync } from 'node:fs'
18import { tmpdir } from 'node:os'
19import { join, resolve } from 'node:path'
20import process from 'node:process'
21
22const created = {
23 authorId: 0,
24 expertId: 0,
25 repositoryId: 0,
26 handle: '',
27 expertHandle: '',
28 authorToken: '',
29 name: '',
30 diskPath: '',
31 temp: '',
32}
33
34let available = false
35let port = 0
36let server: any = null
37
38function unique(prefix: string): string {
39 return `${prefix}${Buffer.from(crypto.getRandomValues(new Uint8Array(5))).toString('hex')}`
40}
41
42/** Commit as a specific person, which is what the suggester reads. */
43async function git(cwd: string, as: { name: string, email: string } | null, ...args: string[]): Promise<string> {
44 const child = Bun.spawn(['git', ...args], {
45 cwd,
46 stdout: 'pipe',
47 stderr: 'pipe',
48 env: {
49 ...process.env,
50 GIT_AUTHOR_NAME: as?.name ?? 'E2E',
51 GIT_AUTHOR_EMAIL: as?.email ?? 'e2e@example.com',
52 GIT_COMMITTER_NAME: 'E2E',
53 GIT_COMMITTER_EMAIL: 'e2e@example.com',
54 GIT_TERMINAL_PROMPT: '0',
55 },
56 })
57
58 const [stdout, stderr, code] = await Promise.all([
59 new Response(child.stdout).text(),
60 new Response(child.stderr).text(),
61 child.exited,
62 ])
63
64 if (code !== 0)
65 throw new Error(`git ${args.join(' ')} exited ${code}: ${stderr.trim()}`)
66
67 return stdout.trim()
68}
69
70/** The read a browser makes: a cookie, and nothing else. */
71async function fetchPage(path: string, cookieToken?: string): Promise<{ status: number, html: string }> {
72 const answer = await fetch(`http://127.0.0.1:${port}${path}`, {
73 headers: {
74 Accept: 'text/html',
75 ...(cookieToken ? { Cookie: `auth-token=${cookieToken}` } : {}),
76 },
77 })
78
79 return { status: answer.status, html: await answer.text() }
80}
81
82beforeAll(async () => {
83 created.temp = mkdtempSync(join(tmpdir(), 'reviewos-suggest-panel-'))
84
85 try {
86 const { injectGlobalAutoImports } = await import('@stacksjs/server')
87 const { route } = await import('@stacksjs/router')
88
89 await injectGlobalAutoImports()
90 await (globalThis as any).db.selectFrom('users').select(['id']).limit(1).execute()
91
92 await route.importRoutes()
93 server = await route.serve({ port: 0, hostname: '127.0.0.1' })
94 port = Number((server as any)?.port ?? (server as any)?.server?.port ?? 0)
95
96 if (!port)
97 throw new Error('the router did not report a port')
98
99 const { repositoryPath } = await import('../../app/Actions/Git/storage')
100 const { initBare } = await import('../../app/Actions/Git/git')
101 const { createToken } = await import('@stacksjs/auth')
102
103 const make = async (prefix: string): Promise<{ id: number, handle: string, email: string }> => {
104 const handle = unique(prefix)
105 const email = `${handle}@example.com`
106 const row: any = await (globalThis as any).db
107 .insertInto('users')
108 .values({ name: 'Panel Tester', email, handle, password: 'x' })
109 .returning(['id'])
110 .executeTakeFirst()
111
112 return { id: Number(row?.id), handle, email }
113 }
114
115 const author = await make('spa')
116 const expert = await make('spe')
117
118 created.authorId = author.id
119 created.handle = author.handle
120 created.expertId = expert.id
121 created.expertHandle = expert.handle
122
123 const issued: any = await createToken(author.id, 'suggested reviewers panel test')
124 created.authorToken = String(issued?.plainTextToken ?? issued?.token ?? issued)
125
126 created.name = unique('repo')
127 const resolvedPath = repositoryPath(created.handle, created.name)
128 created.diskPath = resolvedPath.path!
129
130 const repository: any = await (globalThis as any).db
131 .insertInto('repositories')
132 .values({
133 owner_type: 'user',
134 owner_id: created.authorId,
135 name: created.name,
136 description: 'created by the suggested reviewers panel end to end test',
137 visibility: 'public',
138 default_branch: 'main',
139 disk_path: resolvedPath.relative!,
140 })
141 .returning(['id'])
142 .executeTakeFirst()
143
144 created.repositoryId = Number(repository?.id)
145
146 mkdirSync(resolve(created.diskPath, '..'), { recursive: true })
147 await initBare(created.diskPath, 'main')
148
149 const work = join(created.temp, 'seed')
150 mkdirSync(work)
151 await git(work, null, 'init', '--initial-branch=main')
152
153 writeFileSync(join(work, 'touched.ts'), 'export const a = 0\n')
154 await git(work, null, 'add', '.')
155 await git(work, null, 'commit', '-m', 'the base')
156
157 // The expert's history is what the endpoint will name, when asked.
158 for (let round = 1; round <= 3; round += 1) {
159 writeFileSync(join(work, 'touched.ts'), `export const a = ${round}\n`)
160 await git(work, expert, 'add', '.')
161 await git(work, expert, 'commit', '-m', `expert round ${round}`)
162 }
163
164 await git(work, null, 'push', created.diskPath, 'main')
165 const baseSha = await git(work, null, 'rev-parse', 'HEAD')
166
167 await git(work, null, 'checkout', '-b', 'change')
168 writeFileSync(join(work, 'touched.ts'), 'export const a = 99\n')
169 await git(work, { name: 'Author', email: `${created.handle}@example.com` }, 'add', '.')
170 await git(work, { name: 'Author', email: `${created.handle}@example.com` }, 'commit', '-m', 'change the touched file')
171 const headSha = await git(work, null, 'rev-parse', 'HEAD')
172 await git(work, null, 'push', created.diskPath, 'change')
173
174 await (globalThis as any).db
175 .insertInto('pull_requests')
176 .values({
177 repository_id: created.repositoryId,
178 number: 1,
179 title: 'Change the touched file',
180 body: 'Opened by the suggested reviewers panel end to end test.',
181 author_id: created.authorId,
182 state: 'open',
183 head_branch: 'change',
184 head_sha: headSha,
185 base_branch: 'main',
186 base_sha: baseSha,
187 draft: false,
188 additions: 1,
189 deletions: 1,
190 changed_files: 1,
191 })
192 .execute()
193
194 available = true
195 }
196 catch (error) {
197 console.warn(`[e2e] skipping: ${error instanceof Error ? error.message : String(error)}`)
198 available = false
199 }
200}, 120_000)
201
202afterAll(async () => {
203 try {
204 if (created.repositoryId)
205 await (globalThis as any).db.deleteFrom('repositories').where('id', '=', created.repositoryId).execute()
206
207 for (const id of [created.authorId, created.expertId]) {
208 if (id)
209 await (globalThis as any).db.deleteFrom('users').where('id', '=', id).execute()
210 }
211 }
212 catch { /* the temp files still go, below */ }
213
214 if (created.diskPath) {
215 rmSync(created.diskPath, { recursive: true, force: true })
216
217 try {
218 rmdirSync(resolve(created.diskPath, '..'))
219 }
220 catch { /* somebody else's repository lives there too */ }
221 }
222
223 if (created.temp)
224 rmSync(created.temp, { recursive: true, force: true })
225
226 try {
227 server?.stop?.(true)
228 }
229 catch { /* already down */ }
230})
231
232describe('the suggested reviewers panel', () => {
233 /**
234 * The fetch the panel's script makes, with the credential a browser holds.
235 * Every other caller of this endpoint in the suite authenticates with a
236 * bearer, and a bearer works whether or not the cookie path does.
237 */
238 test('the endpoint answers a cookie-only fetch with the expert and the why', async () => {
239 if (!available)
240 return
241
242 const answer = await fetch(
243 `http://127.0.0.1:${port}/api/repos/pulls/suggested-reviewers?owner=${created.handle}&repo=${created.name}&number=1`,
244 { headers: { Accept: 'application/json', Cookie: `auth-token=${created.authorToken}` } },
245 )
246
247 expect(answer.status).toBe(200)
248
249 const body: any = await answer.json()
250 const suggestion = (body?.suggestions ?? []).find((entry: any) => entry.handle === created.expertHandle)
251
252 expect(suggestion).toBeDefined()
253 // The stable prefix only: the "last Nd ago" half reads the clock.
254 expect(String(suggestion.reason)).toContain('3 commits here')
255 }, 30_000)
256
257 test('the page offers the panel to a signed-in reviewer, without paying for it', async () => {
258 if (!available)
259 return
260
261 const { status, html } = await fetchPage(`/${created.handle}/${created.name}/pull/1`, created.authorToken)
262
263 expect(status).toBe(200)
264 expect(html).toContain('data-suggested-reviewers')
265
266 // The URL the script would ask, addressed by the server so the client does
267 // not know how a pull request is named.
268 expect(html).toContain('/api/repos/pulls/suggested-reviewers?owner=')
269 expect(html).toContain(`number=1`)
270
271 // And none of the answer. The expert's handle appearing here would mean
272 // the git log ran at render time, which is the cost the endpoint exists
273 // to defer.
274 expect(html).not.toContain(created.expertHandle)
275 }, 30_000)
276
277 /**
278 * The reader's credential stays out of the document. stx's client bridge
279 * seeds any identifier a client script shares with the server scope into
280 * the page, and the server scope holds the request headers - so a script
281 * that so much as says `headers` serializes the session cookie into the
282 * HTML. That shipped, briefly, as `fetch(url, { headers: ... })`; this is
283 * the assertion that keeps it shipped out.
284 */
285 test('the page never carries the session token that fetched it', async () => {
286 if (!available)
287 return
288
289 const { html } = await fetchPage(`/${created.handle}/${created.name}/pull/1`, created.authorToken)
290
291 expect(html).not.toContain(created.authorToken)
292 expect(html).not.toContain('auth-token')
293 }, 30_000)
294
295 test('the closed panel already says what it is, for readers without scripts', async () => {
296 if (!available)
297 return
298
299 const { html } = await fetchPage(`/${created.handle}/${created.name}/pull/1`, created.authorToken)
300
301 expect(html).toContain('Who has worked on these files.')
302 }, 30_000)
303
304 test('a reader with no session is not offered a panel they cannot use', async () => {
305 if (!available)
306 return
307
308 const { status, html } = await fetchPage(`/${created.handle}/${created.name}/pull/1`)
309
310 expect(status).toBe(200)
311 expect(html).not.toContain('data-suggested-reviewers')
312 }, 30_000)
313})