@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.
- package/README.md +79 -31
- package/docs/reference.md +396 -101
- package/package.json +9 -4
- package/pr-review.config.example.yml +44 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +123 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +81 -42
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +21 -1
- package/src/chat/chat-manager.ts +67 -40
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +70 -8
- package/src/commands.ts +50 -17
- package/src/config.ts +14 -26
- package/src/contract/api.ts +58 -2
- package/src/contract/canvas-manifest.ts +7 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +76 -2
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +26 -0
- package/src/contract/settings.ts +7 -0
- package/src/contract/state.ts +53 -22
- package/src/contract/validation.ts +1 -0
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +109 -9
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -41
- package/src/github/comments.ts +3 -24
- package/src/github/post-comment.ts +3 -36
- package/src/github/post-review.ts +64 -23
- package/src/github/pr.ts +6 -87
- package/src/github/threads.ts +2 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +93 -0
- package/src/gitlab/project.ts +13 -0
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +246 -0
- package/src/host/host.ts +154 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +26 -0
- package/src/review/carry-marks.ts +131 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +82 -38
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +111 -10
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +62 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +349 -24
- package/src/review/validate.ts +14 -5
- package/src/server/bundle.ts +324 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +35 -11
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +91 -44
- package/src/server/routes/pages.ts +26 -9
- package/src/server/routes/review-routes.ts +278 -67
- package/src/store/canvas-store.ts +93 -55
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -27
- package/src/store/pr-store.ts +23 -14
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +105 -36
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +87 -25
- package/static/js/app.js +50 -15
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +30 -6
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +132 -17
- package/static/js/contract-types.d.ts +7 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +102 -18
- package/static/js/errors.js +22 -6
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +65 -15
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +274 -46
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +295 -32
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +70 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +113 -29
- package/static/js/settings.js +55 -24
- package/static/js/signoff.js +79 -10
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/commands.css +6 -0
- package/static/styles/header.css +21 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +4 -0
- package/static/styles/skin-github.css +7 -1
- package/static/styles.css +1 -0
- 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
|
+
}
|
package/src/host/host.ts
ADDED
|
@@ -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
|
+
}
|
package/src/project-config.ts
CHANGED
|
@@ -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
|
+
}
|