@vintasoftware/pr-review-canvas 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. package/src/github/gh.ts +0 -211
@@ -0,0 +1,246 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { z } from 'zod'
3
+
4
+ /**
5
+ * The forge operations the tool needs, all through the host's own CLI (`gh` or `glab`) so the
6
+ * user's login is used and no token is ever read by this code. The two CLIs share one command
7
+ * grammar, so one client serves both; the spec names the binary and the instance. Routes receive
8
+ * an implementation through AppContext; tests use an in-memory fake.
9
+ */
10
+ export interface HostClient {
11
+ /** `<cli> api --method GET <path>` with optional query params; returns the parsed JSON body. */
12
+ api(path: string, params?: Record<string, string>): Promise<unknown>
13
+ /** `<cli> api -i --method GET <path>`: the response headers as well as the body. */
14
+ apiWithHeaders(path: string): Promise<CliResponse>
15
+ /** Send JSON over stdin. Defaults to POST; comment edits use PATCH or PUT. */
16
+ post(path: string, body: unknown, method?: 'POST' | 'PATCH' | 'PUT' | 'DELETE'): Promise<unknown>
17
+ /** `<cli> api graphql`; returns the parsed `data` object. */
18
+ graphql(query: string, variables: Record<string, string | number>): Promise<unknown>
19
+ /** `<cli> auth status`: is the CLI installed and logged in? */
20
+ authStatus(): Promise<{ installed: boolean; authenticated: boolean; detail: string }>
21
+ /**
22
+ * The token of the current login, or null when there is none. Callers keep it in a local
23
+ * variable for the length of one request; it is never logged or written to disk.
24
+ */
25
+ authToken(): Promise<string | null>
26
+ }
27
+
28
+ export type HostCli = 'gh' | 'glab'
29
+
30
+ /** What differs between the two CLIs. Everything else is the same argument list. */
31
+ export interface HostCliSpec {
32
+ cli: HostCli
33
+ /** Variables the child gets on top of the process environment; `glab` learns its instance here. */
34
+ env: Record<string, string>
35
+ /** The command that prints the current login's token. */
36
+ tokenArgs: string[]
37
+ }
38
+
39
+ /** The words the doctor and error hints use for each CLI. */
40
+ export const CLI_INFO: Record<HostCli, { label: string; installUrl: string; loginCommand: string }> = {
41
+ gh: { label: 'GitHub CLI (gh)', installUrl: 'https://cli.github.com', loginCommand: 'gh auth login' },
42
+ glab: {
43
+ label: 'GitLab CLI (glab)',
44
+ installUrl: 'https://gitlab.com/gitlab-org/cli',
45
+ loginCommand: 'glab auth login',
46
+ },
47
+ }
48
+
49
+ /** One HTTP answer from `<cli> api -i`: the status, the header names in lower case, and the body. */
50
+ export interface CliResponse {
51
+ status: number
52
+ headers: Record<string, string>
53
+ body: unknown
54
+ }
55
+
56
+ /**
57
+ * Splits the output of `<cli> api -i` into the last header block and the body. A redirect prints
58
+ * one block per hop, and only the final one describes the answer.
59
+ */
60
+ export function parseIncludedResponse(stdout: string): CliResponse {
61
+ const normalized = stdout.replace(/\r\n/g, '\n')
62
+ const parts = normalized.split('\n\n')
63
+ let body: unknown = null
64
+ const headerBlocks: string[] = []
65
+ for (const [i, part] of parts.entries()) {
66
+ if (/^HTTP\/[\d.]+ \d{3}/.test(part)) {
67
+ headerBlocks.push(part)
68
+ continue
69
+ }
70
+ // Everything after the last header block is the body, which may hold blank lines itself.
71
+ body = parseJsonOrNull(parts.slice(i).join('\n\n'))
72
+ break
73
+ }
74
+ const last = headerBlocks[headerBlocks.length - 1] ?? ''
75
+ const lines = last.split('\n').filter(l => l.trim() !== '')
76
+ const status = Number(/^HTTP\/[\d.]+ (\d{3})/.exec(lines[0] ?? '')?.[1] ?? 0)
77
+ const headers: Record<string, string> = {}
78
+ for (const line of lines.slice(1)) {
79
+ const at = line.indexOf(':')
80
+ if (at > 0) {
81
+ headers[line.slice(0, at).trim().toLowerCase()] = line.slice(at + 1).trim()
82
+ }
83
+ }
84
+ return { status, headers, body }
85
+ }
86
+
87
+ function parseJsonOrNull(text: string): unknown {
88
+ if (text.trim() === '') {
89
+ return null
90
+ }
91
+ try {
92
+ return JSON.parse(text) as unknown
93
+ } catch {
94
+ return null
95
+ }
96
+ }
97
+
98
+ export class HostCliError extends Error {
99
+ readonly cli: HostCli
100
+ readonly path: string
101
+ readonly stderr: string
102
+ readonly exitCode: number
103
+ /** True when the CLI binary is not on PATH. */
104
+ readonly missingBinary: boolean
105
+
106
+ constructor(cli: HostCli, path: string, stderr: string, exitCode: number, missingBinary = false) {
107
+ super(`${cli} api ${path} failed (${exitCode}): ${stderr.trim()}`)
108
+ this.name = 'HostCliError'
109
+ this.cli = cli
110
+ this.path = path
111
+ this.stderr = stderr
112
+ this.exitCode = exitCode
113
+ this.missingBinary = missingBinary
114
+ }
115
+ /** Both CLIs exit 1 and name the status in stderr for a missing resource. */
116
+ get notFound(): boolean {
117
+ return /HTTP 404|404 Not Found/i.test(this.stderr)
118
+ }
119
+ get unauthenticated(): boolean {
120
+ return /HTTP 401|401 Unauthorized|not logged in|auth login/i.test(this.stderr)
121
+ }
122
+ }
123
+
124
+ interface ExecResult {
125
+ stdout: string
126
+ stderr: string
127
+ code: number
128
+ missingBinary: boolean
129
+ }
130
+
131
+ export interface CliExecOptions {
132
+ binary?: string
133
+ env?: Record<string, string>
134
+ /** Written to the child's stdin, for `api --input -`. */
135
+ input?: string
136
+ }
137
+
138
+ export function execCli(args: string[], opts: CliExecOptions = {}): Promise<ExecResult> {
139
+ return new Promise(resolve => {
140
+ const child = execFile(
141
+ opts.binary ?? 'gh',
142
+ args,
143
+ { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...opts.env } },
144
+ (error, stdout, stderr) => {
145
+ const missingBinary = error !== null && 'code' in error && error.code === 'ENOENT'
146
+ const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
147
+ resolve({ stdout, stderr, code, missingBinary })
148
+ }
149
+ )
150
+ if (opts.input !== undefined) {
151
+ child.stdin?.on('error', () => undefined)
152
+ child.stdin?.end(opts.input)
153
+ }
154
+ })
155
+ }
156
+
157
+ export type CliExec = typeof execCli
158
+
159
+ export const GH_CLI: HostCliSpec = { cli: 'gh', env: {}, tokenArgs: ['auth', 'token'] }
160
+
161
+ /** `glab` reads its instance from GITLAB_HOST, and prints a login's token through `config get`. */
162
+ export function glabCli(hostname: string): HostCliSpec {
163
+ return {
164
+ cli: 'glab',
165
+ env: { GITLAB_HOST: hostname },
166
+ tokenArgs: ['config', 'get', 'token', '--host', hostname],
167
+ }
168
+ }
169
+
170
+ export function createHostClient(spec: HostCliSpec, exec: CliExec = execCli): HostClient {
171
+ const run = async (path: string, args: string[], input?: string): Promise<ExecResult> => {
172
+ const r = await exec(args, { binary: spec.cli, env: spec.env, ...(input === undefined ? {} : { input }) })
173
+ if (r.code !== 0) {
174
+ throw new HostCliError(
175
+ spec.cli,
176
+ path,
177
+ r.missingBinary ? `${spec.cli}: command not found` : r.stderr,
178
+ r.code,
179
+ r.missingBinary
180
+ )
181
+ }
182
+ return r
183
+ }
184
+ return {
185
+ api: async (path, params = {}) => {
186
+ // `api` switches to POST as soon as a field is given; these are reads, so pin GET.
187
+ const args = ['api', '--method', 'GET', path]
188
+ for (const [k, v] of Object.entries(params)) {
189
+ args.push('-F', `${k}=${v}`)
190
+ }
191
+ return JSON.parse((await run(path, args)).stdout) as unknown
192
+ },
193
+ apiWithHeaders: async path =>
194
+ parseIncludedResponse((await run(path, ['api', '-i', '--method', 'GET', path])).stdout),
195
+ post: async (path, body, method = 'POST') => {
196
+ // The payload goes over stdin, so no comment text ever appears in an argument list. The
197
+ // content type is named because `glab` does not infer it and GitLab answers 415 without it.
198
+ const args = ['api', '--method', method, path, '--input', '-', '-H', 'Content-Type: application/json']
199
+ const { stdout } = await run(path, args, JSON.stringify(body))
200
+ return stdout.trim() === '' ? null : (JSON.parse(stdout) as unknown)
201
+ },
202
+ graphql: async (query, variables) => {
203
+ const args = ['api', 'graphql', '-f', `query=${query}`]
204
+ for (const [k, v] of Object.entries(variables)) {
205
+ args.push(typeof v === 'number' ? '-F' : '-f', `${k}=${v}`)
206
+ }
207
+ const body = JSON.parse((await run('graphql', args)).stdout) as {
208
+ data?: unknown
209
+ errors?: Array<{ message: string }>
210
+ }
211
+ if (body.errors && body.errors.length > 0) {
212
+ throw new HostCliError(spec.cli, 'graphql', body.errors.map(e => e.message).join('; '), 1)
213
+ }
214
+ return body.data
215
+ },
216
+ authStatus: async () => {
217
+ const r = await exec(['auth', 'status'], { binary: spec.cli, env: spec.env })
218
+ if (r.missingBinary) {
219
+ return { installed: false, authenticated: false, detail: `${spec.cli} is not on PATH` }
220
+ }
221
+ const detail = (r.stdout + r.stderr).trim().split('\n')[0] ?? ''
222
+ return { installed: true, authenticated: r.code === 0, detail }
223
+ },
224
+ authToken: async () => {
225
+ const r = await exec(spec.tokenArgs, { binary: spec.cli, env: spec.env })
226
+ const token = r.stdout.trim()
227
+ return r.code === 0 && token !== '' ? token : null
228
+ },
229
+ }
230
+ }
231
+
232
+ export const COMMENTS_PAGE_SIZE = 100
233
+
234
+ /** Every item of a REST list endpoint, following `page=` until a page comes back short. */
235
+ export async function fetchAllPages(client: HostClient, path: string): Promise<unknown[]> {
236
+ const out: unknown[] = []
237
+ for (let page = 1; ; page++) {
238
+ const batch = z
239
+ .array(z.unknown())
240
+ .parse(await client.api(path, { per_page: String(COMMENTS_PAGE_SIZE), page: String(page) }))
241
+ out.push(...batch)
242
+ if (batch.length < COMMENTS_PAGE_SIZE) {
243
+ return out
244
+ }
245
+ }
246
+ }
@@ -0,0 +1,154 @@
1
+ import { shareGithubCanvas } from '../github/canvas-comment.js'
2
+ import { shareGitlabCanvas } from '../gitlab/canvas-comment.js'
3
+ import type { Capabilities, PublicHost, ReviewSummary } from '../contract/api.js'
4
+ import type {
5
+ FetchCommentsResult,
6
+ PostCommentInput,
7
+ PostCommentResult,
8
+ ReviewComment,
9
+ } from '../contract/comments.js'
10
+ import type { Repo } from '../contract/review-artifact.js'
11
+ import { GITHUB_ATTACHMENTS } from '../github/attachments.js'
12
+ import { probeCapabilities } from '../github/capabilities.js'
13
+ import { fetchComments } from '../github/comments.js'
14
+ import { postComment } from '../github/post-comment.js'
15
+ import type { PendingComment } from '../contract/pending.js'
16
+ import type { ReviewEvent } from '../contract/reviews.js'
17
+ import { postReview } from '../github/post-review.js'
18
+ import { fetchPrMeta } from '../github/pr.js'
19
+ import type { PrMeta } from './pr.js'
20
+ import { gitlabAttachments } from '../gitlab/attachments.js'
21
+ import { probeGitlabCapabilities } from '../gitlab/capabilities.js'
22
+ import { fetchGitlabComments } from '../gitlab/comments.js'
23
+ import { fetchMrMeta } from '../gitlab/mr.js'
24
+ import { postGitlabComment } from '../gitlab/post-comment.js'
25
+ import { postGitlabReview } from '../gitlab/post-review.js'
26
+ import type { Derived } from '../store/derived-store.js'
27
+ import type { AttachmentLink } from './attachments.js'
28
+ import { GH_CLI, glabCli, type HostClient, type HostCliSpec } from './client.js'
29
+
30
+ export type HostKind = PublicHost['kind']
31
+
32
+ /** The review and the comments created by this submission. */
33
+ export interface PostedReview extends ReviewSummary {
34
+ comments: ReviewComment[]
35
+ warnings: string[]
36
+ }
37
+
38
+ /** How canvas zips attached to a review are found and fetched on one forge. */
39
+ export interface HostAttachments {
40
+ /** Zip links in one markdown text, as absolute URLs this host serves. */
41
+ findLinks(text: string, repo: Repo): AttachmentLink[]
42
+ /** Where an attachment may be served from. Everything else is refused before any request. */
43
+ allowedHosts: ReadonlySet<string>
44
+ /** The header that carries the CLI token to the forge itself; a storage redirect gets none. */
45
+ authHeader(token: string): Record<string, string>
46
+ }
47
+
48
+ /**
49
+ * One forge: the words the page uses for it, the CLI that talks to it, and every operation whose
50
+ * request or answer differs between GitHub and GitLab. Everything that is the same for both (the
51
+ * local refs, the stored Pr, the download loop, the routes) takes a Host and never asks its kind.
52
+ */
53
+ export interface Host {
54
+ kind: HostKind
55
+ hostname: string
56
+ /** `GitHub` or `GitLab`, for messages. */
57
+ label: string
58
+ cli: HostCliSpec
59
+ webBase: string
60
+ /** `pull request` or `merge request`, and its abbreviation. */
61
+ noun: string
62
+ nounShort: string
63
+ /** The remote ref that holds a review's head, fetched into the same local ref for both hosts. */
64
+ remoteHeadRef(number: number): string
65
+ compareUrl(repo: Repo, base: string, head: string): string
66
+ fetchPrMeta(client: HostClient, repo: Repo, number: number): Promise<PrMeta>
67
+ fetchComments(
68
+ client: HostClient,
69
+ repo: Repo,
70
+ number: number,
71
+ headSha: string,
72
+ now: () => Date
73
+ ): Promise<FetchCommentsResult>
74
+ /** GitLab needs the diff for renamed paths and both coordinates of context lines. */
75
+ postComment(
76
+ client: HostClient,
77
+ repo: Repo,
78
+ number: number,
79
+ headSha: string,
80
+ input: PostCommentInput,
81
+ diff: Derived
82
+ ): Promise<PostCommentResult>
83
+ /**
84
+ * Submits the review, with the comments the reviewer had waiting. GitHub takes them in the one
85
+ * call that creates the review; GitLab stages and batch-publishes draft notes, which is why the
86
+ * diff is passed here too.
87
+ */
88
+ postReview(
89
+ client: HostClient,
90
+ repo: Repo,
91
+ number: number,
92
+ headSha: string,
93
+ input: { event: ReviewEvent; body: string; comments?: ReadonlyArray<PendingComment> },
94
+ diff: Derived
95
+ ): Promise<PostedReview>
96
+ probeCapabilities(client: HostClient, repo: Repo): Promise<Capabilities>
97
+ canvasCommentLimit: number
98
+ shareCanvas(client: HostClient, repo: Repo, number: number, body: string): Promise<string>
99
+ attachments: HostAttachments
100
+ }
101
+
102
+ export const GITHUB_HOST: Host = {
103
+ kind: 'github',
104
+ hostname: 'github.com',
105
+ label: 'GitHub',
106
+ cli: GH_CLI,
107
+ webBase: 'https://github.com',
108
+ noun: 'pull request',
109
+ nounShort: 'PR',
110
+ remoteHeadRef: number => `pull/${number}/head`,
111
+ compareUrl: (repo, base, head) => `https://github.com/${repo.owner}/${repo.name}/compare/${base}...${head}`,
112
+ fetchPrMeta,
113
+ fetchComments,
114
+ postComment,
115
+ postReview,
116
+ probeCapabilities,
117
+ canvasCommentLimit: 65_536,
118
+ shareCanvas: shareGithubCanvas,
119
+ attachments: GITHUB_ATTACHMENTS,
120
+ }
121
+
122
+ /** A GitLab instance. `hostname` is gitlab.com or the self-hosted instance the origin names. */
123
+ export function gitlabHost(hostname: string): Host {
124
+ const webUrl = new URL(`https://${hostname}`)
125
+ hostname = webUrl.host
126
+ const webBase = webUrl.origin
127
+ return {
128
+ kind: 'gitlab',
129
+ hostname,
130
+ label: 'GitLab',
131
+ cli: glabCli(hostname),
132
+ webBase,
133
+ noun: 'merge request',
134
+ nounShort: 'MR',
135
+ remoteHeadRef: number => `merge-requests/${number}/head`,
136
+ compareUrl: (repo, base, head) => `${webBase}/${repo.owner}/${repo.name}/-/compare/${base}...${head}`,
137
+ fetchPrMeta: fetchMrMeta,
138
+ fetchComments: (client, repo, number, headSha, now) =>
139
+ fetchGitlabComments(client, repo, number, headSha, now, webBase),
140
+ postComment: (client, repo, number, headSha, input, diff) =>
141
+ postGitlabComment(client, repo, number, headSha, input, { webBase, ...diff }),
142
+ postReview: (client, repo, number, headSha, input, diff) =>
143
+ postGitlabReview(client, repo, number, headSha, input, webBase, diff),
144
+ probeCapabilities: probeGitlabCapabilities,
145
+ canvasCommentLimit: 1_000_000,
146
+ shareCanvas: (client, repo, number, body) => shareGitlabCanvas(client, repo, number, body, webBase),
147
+ attachments: gitlabAttachments(hostname, webBase),
148
+ }
149
+ }
150
+
151
+ /** The part of a Host the page and the health endpoint are told. */
152
+ export function publicHost(host: Host): PublicHost {
153
+ return { kind: host.kind, label: host.label, webBase: host.webBase }
154
+ }
package/src/host/pr.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { Pr, Repo } from '../contract/review-artifact.js'
2
+
3
+ export class PrNotFoundError extends Error {
4
+ readonly number: number
5
+
6
+ constructor(number: number) {
7
+ super(`pull request #${number} not found`)
8
+ this.name = 'PrNotFoundError'
9
+ this.number = number
10
+ }
11
+ }
12
+
13
+ export interface PrMeta {
14
+ number: number
15
+ title: string
16
+ body: string
17
+ author: string
18
+ url: string
19
+ state: string
20
+ draft: boolean
21
+ updatedAt: string
22
+ baseRef: string
23
+ headRef: string
24
+ headSha: string
25
+ /** The commit that merged the PR into its base, once merged. */
26
+ mergeCommitSha: string | null
27
+ additions: number
28
+ deletions: number
29
+ changedFiles: number
30
+ }
31
+
32
+ export function toPr(meta: PrMeta, repo: Repo, shas: { headSha: string; mergeBaseSha: string }): Pr {
33
+ return {
34
+ number: meta.number,
35
+ title: meta.title,
36
+ body: meta.body,
37
+ author: meta.author,
38
+ url: meta.url,
39
+ state: meta.state,
40
+ draft: meta.draft,
41
+ updatedAt: meta.updatedAt,
42
+ baseRef: meta.baseRef,
43
+ headRef: meta.headRef,
44
+ headSha: shas.headSha,
45
+ mergeBaseSha: shas.mergeBaseSha,
46
+ additions: meta.additions,
47
+ deletions: meta.deletions,
48
+ changedFiles: meta.changedFiles,
49
+ repo,
50
+ }
51
+ }
@@ -0,0 +1,42 @@
1
+ import type { Repo } from '../contract/review-artifact.js'
2
+ import { GITHUB_HOST, gitlabHost, type Host } from './host.js'
3
+
4
+ export interface OriginRemote {
5
+ host: Host
6
+ repo: Repo
7
+ }
8
+
9
+ /** Hostname and repository path from the three remote forms git gives out: scp-like ssh, ssh://, https. */
10
+ export function splitGitRemote(url: string): { hostname: string; path: string } | null {
11
+ const m =
12
+ /^ssh:\/\/git@([^:/]+)(?::\d+)?\/(.+)$/.exec(url.trim()) ??
13
+ /^git@([^:/]+):(.+)$/.exec(url.trim()) ??
14
+ /^https?:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/.exec(url.trim())
15
+ if (m?.[1] === undefined || m[2] === undefined) {
16
+ return null
17
+ }
18
+ return { hostname: m[1].toLowerCase(), path: m[2].replace(/\.git$/i, '').replace(/\/$/, '') }
19
+ }
20
+
21
+ /**
22
+ * Classifies origin. github.com is GitHub; gitlab.com, a hostname that contains "gitlab", or any
23
+ * other host when `PR_REVIEW_HOST=gitlab` is set (a self-hosted GitLab whose name does not say
24
+ * so) is GitLab. A GitLab path may nest groups, which become the owner with slashes.
25
+ */
26
+ export function parseOriginRemote(url: string, env: NodeJS.ProcessEnv = {}): OriginRemote | null {
27
+ const split = splitGitRemote(url)
28
+ if (split === null) {
29
+ return null
30
+ }
31
+ const parts = split.path.split('/').filter(p => p !== '')
32
+ const name = parts[parts.length - 1]
33
+ if (parts.length < 2 || name === undefined) {
34
+ return null
35
+ }
36
+ const repo: Repo = { owner: parts.slice(0, -1).join('/'), name }
37
+ if (split.hostname === 'github.com') {
38
+ return parts.length === 2 ? { host: GITHUB_HOST, repo } : null
39
+ }
40
+ const forced = env['PR_REVIEW_HOST']?.trim().toLowerCase() === 'gitlab'
41
+ return forced || split.hostname.includes('gitlab') ? { host: gitlabHost(split.hostname), repo } : null
42
+ }
@@ -41,6 +41,10 @@ export const PromptOverridesSchema = z
41
41
  'generation-format.md': z.string().min(1).optional(),
42
42
  'generation-strict.md': z.string().min(1).optional(),
43
43
  'generation-surfacing.md': z.string().min(1).optional(),
44
+ 'generation-strict-incremental.md': z.string().min(1).optional(),
45
+ 'generation-surfacing-incremental.md': z.string().min(1).optional(),
46
+ 'judging-strict.md': z.string().min(1).optional(),
47
+ 'judging-surfacing.md': z.string().min(1).optional(),
44
48
  'quality-standards.md': z.string().min(1).optional(),
45
49
  'layering-guidance.md': z.string().min(1).optional(),
46
50
  'chat-seed.md': z.string().min(1).optional(),
@@ -65,6 +69,19 @@ export const ProjectConfigSchema = z.object({
65
69
  /** Which paths count as tests, for the layering rules and the `isTest` flag on a file. */
66
70
  tests: z.object({ patterns: z.array(z.string().min(1)) }),
67
71
  chat: z.object({ enabled: z.boolean() }),
72
+ canvas: z.object({
73
+ /**
74
+ * A canvas still stands for a later head whose diff is identical to the one it was generated
75
+ * from, as after merging the base branch in. False marks the canvas outdated on any commit.
76
+ */
77
+ keepForIdenticalDiff: z.boolean(),
78
+ /**
79
+ * Regenerating a canvas for a new head starts from the newest canvas of a commit the head was
80
+ * built on, carrying what the head's diff leaves untouched. False generates every canvas from
81
+ * a blank page, as `--force` always does.
82
+ */
83
+ incremental: z.boolean(),
84
+ }),
68
85
  })
69
86
  export type ProjectConfig = z.infer<typeof ProjectConfigSchema>
70
87
 
@@ -86,6 +103,9 @@ const PartialProjectConfigSchema = z.object({
86
103
  .optional(),
87
104
  tests: z.object({ patterns: z.array(z.string().min(1)).optional() }).optional(),
88
105
  chat: z.object({ enabled: z.boolean().optional() }).optional(),
106
+ canvas: z
107
+ .object({ keepForIdenticalDiff: z.boolean().optional(), incremental: z.boolean().optional() })
108
+ .optional(),
89
109
  })
90
110
 
91
111
  export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
@@ -95,6 +115,7 @@ export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
95
115
  generation: { mode: 'strict', maxRepairRounds: 3, inlineDiffMaxLines: 1500, smallPrHunks: 10 },
96
116
  tests: { patterns: [...DEFAULT_TEST_PATTERNS] },
97
117
  chat: { enabled: true },
118
+ canvas: { keepForIdenticalDiff: true, incremental: true },
98
119
  }
99
120
 
100
121
  export interface LoadedProjectConfig {
@@ -135,6 +156,11 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
135
156
  generation,
136
157
  tests: { patterns: user.tests?.patterns ?? [...DEFAULT_TEST_PATTERNS] },
137
158
  chat: { enabled: user.chat?.enabled ?? true },
159
+ canvas: {
160
+ keepForIdenticalDiff:
161
+ user.canvas?.keepForIdenticalDiff ?? DEFAULT_PROJECT_CONFIG.canvas.keepForIdenticalDiff,
162
+ incremental: user.canvas?.incremental ?? DEFAULT_PROJECT_CONFIG.canvas.incremental,
163
+ },
138
164
  }
139
165
  if (user.rulebook !== undefined) {
140
166
  config.rulebook = user.rulebook
@@ -0,0 +1,131 @@
1
+ // Review marks following an incremental canvas forward. Each canvas names the one it was generated
2
+ // from, and that line of descent decides which of the reviewer's marks may follow — worked out here,
3
+ // on the reviewer's own machine, from the two canvases and the two diffs it can read itself. The
4
+ // published canvas is a pointer along that line and never a statement about what anyone reviewed.
5
+ import { reviewedId } from '../contract/keys.js'
6
+ import type { ReviewArtifact } from '../contract/review-artifact.js'
7
+ import type { PrState } from '../contract/state.js'
8
+ import type { AppContext } from '../server/context.js'
9
+ import { fileDelta } from './incremental.js'
10
+
11
+ function samePaths(a: readonly string[], b: readonly string[]): boolean {
12
+ const set = new Set(b)
13
+ return a.length === b.length && a.every(p => set.has(p))
14
+ }
15
+
16
+ /**
17
+ * The marks that still describe code the reviewer has seen. `marked` is the canvas they made the
18
+ * marks on. A file mark follows when that file is in both canvases under the same layer key and
19
+ * its patch is byte-identical; a layer mark follows only when the layer holds exactly the same
20
+ * files and none of them changed, since a layer mark claims the whole layer was read. Marks on
21
+ * anything else are left behind.
22
+ */
23
+ export function carriedMarks(
24
+ marked: ReviewArtifact,
25
+ next: ReviewArtifact,
26
+ unchangedPaths: ReadonlySet<string>,
27
+ reviewed: Readonly<Record<string, true>>
28
+ ): Record<string, true> {
29
+ const carried: Record<string, true> = {}
30
+ for (const layer of next.layers) {
31
+ const before = marked.layers.find(l => l.key === layer.key)
32
+ if (before === undefined) {
33
+ continue
34
+ }
35
+ const paths = layer.files.map(f => f.path)
36
+ const beforePaths = before.files.map(f => f.path)
37
+ for (const path of paths) {
38
+ const id = reviewedId(layer.key, path)
39
+ if (unchangedPaths.has(path) && beforePaths.includes(path) && reviewed[id] === true) {
40
+ carried[id] = true
41
+ }
42
+ }
43
+ const id = reviewedId(layer.key)
44
+ if (reviewed[id] === true && samePaths(paths, beforePaths) && paths.every(p => unchangedPaths.has(p))) {
45
+ carried[id] = true
46
+ }
47
+ }
48
+ return carried
49
+ }
50
+
51
+ export interface MarksForCanvas {
52
+ /** The stored state as it applies to this canvas: only the marks that count for it. */
53
+ state: PrState
54
+ /** The canvas the marks were made on, when any of them followed. The page says so. */
55
+ carriedFrom?: string
56
+ }
57
+
58
+ /**
59
+ * Whether the line of descent that starts at `basisSha` reaches `ancestorSha`. The canvas on screen
60
+ * states its own basis, so the walk starts from the artifact's own pointer; the canvases further
61
+ * back are read from the index, which one read already holds, so following the line costs no extra
62
+ * canvas reads on the page's hot path.
63
+ *
64
+ * The line matters because the reviewer's marks only move to a canvas generated from the one they
65
+ * were made on. It has to be a line rather than a single step: the stored commit advances only when
66
+ * the reviewer marks something (`setReviewed` is its only writer), so a reviewer who opens a canvas,
67
+ * finds it already ticked, and clicks nothing leaves their marks keyed to a canvas two or more
68
+ * generations back.
69
+ */
70
+ async function descendsFrom(
71
+ ctx: AppContext,
72
+ basisSha: string | undefined,
73
+ ancestorSha: string
74
+ ): Promise<boolean> {
75
+ const index = await ctx.canvases.readIndex()
76
+ const seen = new Set<string>()
77
+ let at = basisSha
78
+ while (at !== undefined && !seen.has(at)) {
79
+ if (at === ancestorSha) {
80
+ return true
81
+ }
82
+ seen.add(at)
83
+ at = index.canvases[at]?.basisCanvasSha
84
+ }
85
+ return false
86
+ }
87
+
88
+ /**
89
+ * The marks to show for one canvas. Marks made on it stand. Marks made on a canvas it descends
90
+ * from follow it for the parts of the diff those two canvases share, whatever the canvases in
91
+ * between did: a mark says the reviewer read one patch, so the patch they read and the patch on
92
+ * screen are compared directly, and a file the intervening canvases changed and changed back reads
93
+ * as untouched because it is. Anything else describes other code and counts for nothing, which is
94
+ * also what happens when this machine cannot read the older canvas or rebuild either diff.
95
+ */
96
+ export async function marksForCanvas(
97
+ ctx: AppContext,
98
+ artifact: ReviewArtifact,
99
+ canvasSha: string,
100
+ stored: PrState
101
+ ): Promise<MarksForCanvas> {
102
+ if (stored.reviewedCanvasSha === canvasSha) {
103
+ return { state: stored }
104
+ }
105
+ const dropped: MarksForCanvas = { state: { ...stored, reviewed: {} } }
106
+ const markedOn = stored.reviewedCanvasSha
107
+ if (markedOn === undefined || !(await descendsFrom(ctx, artifact.basisCanvasSha, markedOn))) {
108
+ return dropped
109
+ }
110
+ const [markedArtifact, markedManifest, manifest] = await Promise.all([
111
+ ctx.canvases.readArtifact(markedOn),
112
+ ctx.canvases.readManifest(markedOn),
113
+ ctx.canvases.readManifest(canvasSha),
114
+ ])
115
+ if (markedArtifact === null || markedManifest === null || manifest === null) {
116
+ return dropped
117
+ }
118
+ const [markedDiff, diff] = await Promise.all([
119
+ ctx.derived.readOrBuild(markedOn, markedManifest.mergeBaseSha),
120
+ ctx.derived.readOrBuild(canvasSha, manifest.mergeBaseSha),
121
+ ])
122
+ if (markedDiff === null || diff === null) {
123
+ return dropped
124
+ }
125
+ const unchanged = new Set(fileDelta(markedDiff, diff).unchanged)
126
+ const reviewed = carriedMarks(markedArtifact, artifact, unchanged, stored.reviewed)
127
+ if (Object.keys(reviewed).length === 0) {
128
+ return dropped
129
+ }
130
+ return { state: { ...stored, reviewed, reviewedCanvasSha: canvasSha }, carriedFrom: markedOn }
131
+ }