@vintasoftware/pr-review-canvas 0.2.0 → 0.4.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 +61 -26
- package/docs/reference.md +231 -121
- package/package.json +18 -4
- package/pr-review.config.example.yml +10 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +77 -40
- package/src/acpx/acpx.ts +21 -4
- package/src/acpx/events.ts +5 -1
- package/src/acpx/ndjson.ts +3 -1
- package/src/acpx/preflight.ts +5 -1
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/export.ts +11 -2
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +25 -2
- package/src/chat/chat-manager.ts +55 -41
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +24 -9
- package/src/commands.ts +86 -25
- package/src/config.ts +24 -24
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +8 -1
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +27 -2
- package/src/contract/review-artifact.ts +14 -8
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -19
- package/src/git/diff-collector.ts +2 -1
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +117 -12
- 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 -37
- package/src/github/comments.ts +9 -27
- package/src/github/post-comment.ts +7 -37
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -84
- package/src/github/threads.ts +6 -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 +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +25 -9
- package/src/prompt-files.ts +4 -3
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +31 -14
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +87 -17
- package/src/review/prompt.ts +8 -2
- package/src/review/publish.ts +76 -13
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +67 -15
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +320 -106
- package/src/server/context.ts +24 -10
- package/src/server/errors.ts +56 -10
- package/src/server/html.ts +36 -12
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +92 -31
- package/src/server/routes/chat-routes.ts +105 -46
- package/src/server/routes/pages.ts +25 -9
- package/src/server/routes/review-routes.ts +101 -35
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +95 -46
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -23
- package/src/store/pr-store.ts +25 -15
- package/src/store/state-store.ts +42 -37
- package/static/brand.svg +19 -0
- package/static/js/api.js +47 -27
- package/static/js/app.js +27 -7
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +41 -17
- package/static/js/composer.js +30 -17
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +7 -2
- package/static/js/dom.js +5 -7
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -16
- package/static/js/errors.js +22 -6
- package/static/js/header.js +32 -9
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +56 -17
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +26 -14
- package/static/js/links.js +9 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +5 -3
- package/static/js/progress.js +2 -1
- package/static/js/proposed-comment.js +4 -1
- package/static/js/quick-questions.js +2 -1
- package/static/js/regenerate.js +4 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +9 -6
- package/static/styles/base.css +16 -6
- package/static/styles/chat-panel.css +81 -0
- package/static/styles/chat-tools.css +28 -0
- package/static/styles/chat.css +1 -1
- package/static/styles/commands.css +10 -4
- package/static/styles/diff.css +1 -1
- package/static/styles/header.css +18 -4
- package/static/styles/layout.css +4 -4
- package/static/styles/panels.css +4 -0
- package/static/styles/responsive.css +1 -15
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +99 -100
- package/static/styles.css +13 -12
- package/src/github/gh.ts +0 -196
package/src/contract/state.ts
CHANGED
|
@@ -11,25 +11,47 @@ export const ChatThreadSchema = z.object({
|
|
|
11
11
|
|
|
12
12
|
export type ChatThread = z.infer<typeof ChatThreadSchema>
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
14
|
+
/**
|
|
15
|
+
* State files written before the marks were keyed to the canvas carry `reviewedHeadSha`, which
|
|
16
|
+
* held the same commit under its older name. The old key is read when the new one is absent, so
|
|
17
|
+
* a reviewer's marks survive the upgrade; the next write stores the new name.
|
|
18
|
+
*/
|
|
19
|
+
const withRenamedMarksKey = (raw: unknown): unknown => {
|
|
20
|
+
if (raw === null || typeof raw !== 'object') {
|
|
21
|
+
return raw
|
|
22
|
+
}
|
|
23
|
+
const { reviewedHeadSha, ...rest } = raw as Record<string, unknown>
|
|
24
|
+
if (reviewedHeadSha === undefined || 'reviewedCanvasSha' in rest) {
|
|
25
|
+
return raw
|
|
26
|
+
}
|
|
27
|
+
return { ...rest, reviewedCanvasSha: reviewedHeadSha }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const PrStateSchema = z.preprocess(
|
|
31
|
+
withRenamedMarksKey,
|
|
32
|
+
z.object({
|
|
33
|
+
version: z.literal(1),
|
|
34
|
+
/**
|
|
35
|
+
* Counts the writes to this file. The page uses it to tell a newer answer from an older one
|
|
36
|
+
* when two of its requests overlap. Absent in state files of older tool versions.
|
|
37
|
+
*/
|
|
38
|
+
rev: z.number().int().nonnegative().optional(),
|
|
39
|
+
reviewed: z.record(z.string(), z.literal(true)),
|
|
40
|
+
/**
|
|
41
|
+
* The commit of the canvas the reviewed marks were made on: the head, or the commit a carried-over
|
|
42
|
+
* canvas was generated for. A canvas for another commit describes other code, so the marks start
|
|
43
|
+
* again with it. Absent in state files of older tool versions.
|
|
44
|
+
*/
|
|
45
|
+
reviewedCanvasSha: z.string().optional(),
|
|
46
|
+
hiddenThreads: z.record(z.string(), z.object({ at: z.string() })),
|
|
47
|
+
posted: z.array(
|
|
48
|
+
z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })
|
|
49
|
+
),
|
|
50
|
+
dismissed: z.record(z.string(), z.object({ at: z.string(), reason: z.string().optional() })),
|
|
51
|
+
chat: z.object({ threads: z.array(ChatThreadSchema), activeThread: z.string().optional() }),
|
|
52
|
+
updatedAt: z.string(),
|
|
53
|
+
})
|
|
54
|
+
)
|
|
33
55
|
export type PrState = z.infer<typeof PrStateSchema>
|
|
34
56
|
|
|
35
57
|
export function emptyState(updatedAt: string): PrState {
|
|
@@ -79,7 +79,8 @@ export function parseBlock(lines: string[]): Omit<CollectedFile, 'key'> | null {
|
|
|
79
79
|
return null
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const isBinary =
|
|
82
|
+
const isBinary =
|
|
83
|
+
header.some(l => l.startsWith('Binary files ')) || header.some(l => l === 'GIT binary patch')
|
|
83
84
|
let status: FileStatus
|
|
84
85
|
if (renameFrom !== null) {
|
|
85
86
|
status = 'renamed'
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Remove hook repository pointers so cwd selects the repository. Keep configuration,
|
|
2
|
+
// credentials, and discovery limits: callers may rely on relocated config files, inline
|
|
3
|
+
// config, SSH agents, proxies, or filesystem discovery fences.
|
|
4
|
+
export const REPO_ENV_VARS = [
|
|
5
|
+
'GIT_DIR',
|
|
6
|
+
'GIT_WORK_TREE',
|
|
7
|
+
'GIT_COMMON_DIR',
|
|
8
|
+
'GIT_INDEX_FILE',
|
|
9
|
+
'GIT_OBJECT_DIRECTORY',
|
|
10
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
11
|
+
'GIT_NAMESPACE',
|
|
12
|
+
'GIT_PREFIX',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `env` without those variables. Everything else is kept, so ssh agents, credential helpers,
|
|
17
|
+
* proxies, and PATH still reach `fetch`.
|
|
18
|
+
* @param {NodeJS.ProcessEnv} env
|
|
19
|
+
* @returns {NodeJS.ProcessEnv}
|
|
20
|
+
*/
|
|
21
|
+
export function envWithoutRepo(env = process.env) {
|
|
22
|
+
const clean = { ...env }
|
|
23
|
+
for (const name of REPO_ENV_VARS) {
|
|
24
|
+
delete clean[name]
|
|
25
|
+
}
|
|
26
|
+
return clean
|
|
27
|
+
}
|
package/src/git/git.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { envWithoutRepo } from './environment.mjs'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* The git operations the tool needs. Routes, stores, and the CLI receive an implementation
|
|
@@ -26,8 +28,56 @@ export interface Git {
|
|
|
26
28
|
topLevel(): Promise<string>
|
|
27
29
|
commonDir(): Promise<string>
|
|
28
30
|
remoteUrl(name: string): Promise<string | null>
|
|
31
|
+
/** The checked-out branch, or null on a detached HEAD. */
|
|
32
|
+
currentBranch(): Promise<string | null>
|
|
33
|
+
/** The first of `refs` that resolves, or null when none of them do. */
|
|
34
|
+
firstExistingRef(refs: readonly string[]): Promise<string | null>
|
|
35
|
+
/** What a symbolic ref points at, in its short form; null when it is not one. */
|
|
36
|
+
symbolicRef(name: string): Promise<string | null>
|
|
37
|
+
/** `git config user.name`, the person this clone commits as; null when it is unset. */
|
|
38
|
+
configuredUser(): Promise<string | null>
|
|
39
|
+
/**
|
|
40
|
+
* A commit that holds the working tree as it is right now, or null when it matches HEAD.
|
|
41
|
+
* Nothing the user staged is touched: the snapshot is built in an index of this tool's own.
|
|
42
|
+
*/
|
|
43
|
+
snapshotWorktree(): Promise<string | null>
|
|
44
|
+
/** Keeps `sha` reachable for good, whatever the working tree does next. */
|
|
45
|
+
anchorCommit(sha: string): Promise<void>
|
|
29
46
|
}
|
|
30
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The index `snapshotWorktree` stages into, inside this worktree's own git directory rather than
|
|
50
|
+
* the one every worktree shares. It is kept between runs so git's stat cache spares a rehash of
|
|
51
|
+
* the whole tree, and `git worktree remove` takes it away with the worktree it belongs to.
|
|
52
|
+
*/
|
|
53
|
+
export const SNAPSHOT_INDEX = 'pr-review-canvas.index'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where the newest snapshot commit is anchored, so `git gc` cannot collect it between `prepare`
|
|
57
|
+
* and `publish`. `refs/worktree/` is git's own per-worktree namespace: two worktrees of one clone
|
|
58
|
+
* each keep their own snapshot instead of overwriting the single ref they would otherwise share.
|
|
59
|
+
*
|
|
60
|
+
* It moves with the working tree, so it protects one commit only. A canvas outlives the tree it
|
|
61
|
+
* was drawn from, and `anchorCommit` gives each published one an anchor of its own.
|
|
62
|
+
*/
|
|
63
|
+
export const SNAPSHOT_REF = 'refs/worktree/pr-review-snapshot'
|
|
64
|
+
|
|
65
|
+
/** One ref per published snapshot canvas, under the same per-worktree namespace. */
|
|
66
|
+
export const CANVAS_ANCHOR_PREFIX = 'refs/worktree/pr-review-canvas'
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A fixed identity and time, so the same working tree always hashes to the same commit: preparing
|
|
70
|
+
* twice without an edit lands on the canvas that already exists instead of making a second one.
|
|
71
|
+
*/
|
|
72
|
+
const SNAPSHOT_ENV = {
|
|
73
|
+
GIT_AUTHOR_NAME: 'pr-review',
|
|
74
|
+
GIT_AUTHOR_EMAIL: 'pr-review@localhost',
|
|
75
|
+
GIT_AUTHOR_DATE: '1970-01-01T00:00:00+0000',
|
|
76
|
+
GIT_COMMITTER_NAME: 'pr-review',
|
|
77
|
+
GIT_COMMITTER_EMAIL: 'pr-review@localhost',
|
|
78
|
+
GIT_COMMITTER_DATE: '1970-01-01T00:00:00+0000',
|
|
79
|
+
} as const
|
|
80
|
+
|
|
31
81
|
export const STDERR_MESSAGE_MAX = 300
|
|
32
82
|
|
|
33
83
|
/**
|
|
@@ -59,21 +109,31 @@ interface ExecResult {
|
|
|
59
109
|
code: number
|
|
60
110
|
}
|
|
61
111
|
|
|
62
|
-
/** Runs git with an argument array; never a shell.
|
|
63
|
-
|
|
112
|
+
/** Runs git with an argument array; never a shell. `extra` puts back the few repo variables a
|
|
113
|
+
* command needs, such as the snapshot index. */
|
|
114
|
+
export function execGit(
|
|
115
|
+
cwd: string,
|
|
116
|
+
args: string[],
|
|
117
|
+
extra: Readonly<Record<string, string>> = {}
|
|
118
|
+
): Promise<ExecResult> {
|
|
64
119
|
return new Promise(resolve => {
|
|
65
|
-
execFile(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
120
|
+
execFile(
|
|
121
|
+
'git',
|
|
122
|
+
args,
|
|
123
|
+
{ cwd, env: { ...envWithoutRepo(), ...extra }, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 },
|
|
124
|
+
(error, stdout, stderr) => {
|
|
125
|
+
const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
|
|
126
|
+
resolve({ stdout, stderr: stderr.toString('utf8'), code })
|
|
127
|
+
}
|
|
128
|
+
)
|
|
69
129
|
})
|
|
70
130
|
}
|
|
71
131
|
|
|
72
132
|
export type GitExec = typeof execGit
|
|
73
133
|
|
|
74
134
|
export function createGit(cwd: string, exec: GitExec = execGit): Git {
|
|
75
|
-
async function run(args: string[]): Promise<string> {
|
|
76
|
-
const r = await exec(cwd, args)
|
|
135
|
+
async function run(args: string[], env: Readonly<Record<string, string>> = {}): Promise<string> {
|
|
136
|
+
const r = await exec(cwd, args, env)
|
|
77
137
|
if (r.code !== 0) {
|
|
78
138
|
throw new GitError(args, r.stderr, r.code)
|
|
79
139
|
}
|
|
@@ -96,12 +156,12 @@ export function createGit(cwd: string, exec: GitExec = execGit): Git {
|
|
|
96
156
|
fetch: async (remote, refspecs) => {
|
|
97
157
|
await run(['fetch', '--no-tags', '--quiet', remote, ...refspecs])
|
|
98
158
|
},
|
|
99
|
-
show: async (ref,
|
|
100
|
-
const r = await exec(cwd, ['show', `${ref}:${
|
|
159
|
+
show: async (ref, file) => {
|
|
160
|
+
const r = await exec(cwd, ['show', `${ref}:${file}`])
|
|
101
161
|
return r.code === 0 ? r.stdout : null
|
|
102
162
|
},
|
|
103
|
-
blobSize: async (ref,
|
|
104
|
-
const r = await exec(cwd, ['cat-file', '-s', `${ref}:${
|
|
163
|
+
blobSize: async (ref, file) => {
|
|
164
|
+
const r = await exec(cwd, ['cat-file', '-s', `${ref}:${file}`])
|
|
105
165
|
return r.code === 0 ? Number(r.stdout.toString('utf8').trim()) : null
|
|
106
166
|
},
|
|
107
167
|
commitAuthor: ref => run(['log', '-1', '--format=%an', ref]),
|
|
@@ -111,5 +171,50 @@ export function createGit(cwd: string, exec: GitExec = execGit): Git {
|
|
|
111
171
|
const r = await exec(cwd, ['remote', 'get-url', name])
|
|
112
172
|
return r.code === 0 ? r.stdout.toString('utf8').trim() : null
|
|
113
173
|
},
|
|
174
|
+
currentBranch: async () => {
|
|
175
|
+
const r = await exec(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
|
|
176
|
+
return r.code === 0 ? r.stdout.toString('utf8').trim() : null
|
|
177
|
+
},
|
|
178
|
+
symbolicRef: async name => {
|
|
179
|
+
const r = await exec(cwd, ['symbolic-ref', '--quiet', '--short', name])
|
|
180
|
+
return r.code === 0 ? r.stdout.toString('utf8').trim() : null
|
|
181
|
+
},
|
|
182
|
+
configuredUser: async () => {
|
|
183
|
+
const r = await exec(cwd, ['config', '--get', 'user.name'])
|
|
184
|
+
const value = r.code === 0 ? r.stdout.toString('utf8').trim() : ''
|
|
185
|
+
return value === '' ? null : value
|
|
186
|
+
},
|
|
187
|
+
firstExistingRef: async refs => {
|
|
188
|
+
for (const ref of refs) {
|
|
189
|
+
const r = await exec(cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
190
|
+
if (r.code === 0) {
|
|
191
|
+
return ref
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null
|
|
195
|
+
},
|
|
196
|
+
snapshotWorktree: async () => {
|
|
197
|
+
const indexEnv = {
|
|
198
|
+
GIT_INDEX_FILE: path.join(
|
|
199
|
+
await run(['rev-parse', '--path-format=absolute', '--git-dir']),
|
|
200
|
+
SNAPSHOT_INDEX
|
|
201
|
+
),
|
|
202
|
+
}
|
|
203
|
+
// `:/` stages the whole repository whatever the cwd is; ignored files stay out of it.
|
|
204
|
+
await run(['add', '-A', '--', ':/'], indexEnv)
|
|
205
|
+
const tree = await run(['write-tree'], indexEnv)
|
|
206
|
+
if (tree === (await run(['rev-parse', 'HEAD^{tree}']))) {
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
const sha = await run(
|
|
210
|
+
['commit-tree', tree, '-p', 'HEAD', '-m', 'pr-review: working tree snapshot'],
|
|
211
|
+
SNAPSHOT_ENV
|
|
212
|
+
)
|
|
213
|
+
await run(['update-ref', SNAPSHOT_REF, sha])
|
|
214
|
+
return sha
|
|
215
|
+
},
|
|
216
|
+
anchorCommit: async sha => {
|
|
217
|
+
await run(['update-ref', `${CANVAS_ANCHOR_PREFIX}/${sha}`, sha])
|
|
218
|
+
},
|
|
114
219
|
}
|
|
115
220
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Where a review of work that has no pull request yet takes its two commits from: the branch the
|
|
2
|
+
// change will be opened against, and either the tip of the current branch or a snapshot of the
|
|
3
|
+
// working tree as it stands.
|
|
4
|
+
import type { Pr, Repo } from '../contract/review-artifact.js'
|
|
5
|
+
import type { LocalKey } from '../contract/review-key.js'
|
|
6
|
+
import { type Git, GitError } from './git.js'
|
|
7
|
+
|
|
8
|
+
/** Tried in order when the user names no base: the remote's default branch first. */
|
|
9
|
+
export const BASE_CANDIDATES = ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master'] as const
|
|
10
|
+
|
|
11
|
+
/** `origin/HEAD` is a pointer, and a canvas that says `origin/main` reads better than one that
|
|
12
|
+
* says the pointer's name. */
|
|
13
|
+
const ORIGIN_HEAD = 'origin/HEAD'
|
|
14
|
+
|
|
15
|
+
export class LocalTargetError extends Error {
|
|
16
|
+
readonly hint: string
|
|
17
|
+
|
|
18
|
+
constructor(message: string, hint: string) {
|
|
19
|
+
super(message)
|
|
20
|
+
this.name = 'LocalTargetError'
|
|
21
|
+
this.hint = hint
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The branch the current work will be opened against. `origin/HEAD` is the remote's own answer,
|
|
27
|
+
* so it is preferred over guessing at names; a repository that never fetched it falls back to the
|
|
28
|
+
* two usual ones.
|
|
29
|
+
*/
|
|
30
|
+
export async function resolveLocalBase(git: Git, override?: string | undefined): Promise<string> {
|
|
31
|
+
if (override !== undefined && override !== '') {
|
|
32
|
+
return override
|
|
33
|
+
}
|
|
34
|
+
const found = await git.firstExistingRef(BASE_CANDIDATES)
|
|
35
|
+
if (found === null) {
|
|
36
|
+
throw new LocalTargetError(
|
|
37
|
+
'no default branch to compare against',
|
|
38
|
+
'pass --base <ref>, or fetch origin so origin/HEAD resolves'
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
if (found !== ORIGIN_HEAD) {
|
|
42
|
+
return found
|
|
43
|
+
}
|
|
44
|
+
return (await git.symbolicRef(`refs/remotes/${ORIGIN_HEAD}`)) ?? ORIGIN_HEAD
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LocalHead {
|
|
48
|
+
headSha: string
|
|
49
|
+
/** The checked-out branch, or null on a detached HEAD. */
|
|
50
|
+
branch: string | null
|
|
51
|
+
/** True when the head is a snapshot of the working tree rather than a commit on the branch. */
|
|
52
|
+
uncommitted: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Who the local work belongs to. Uncommitted edits are nobody's commit yet, so the name this
|
|
57
|
+
* clone commits as says more than the author of whatever HEAD happens to be.
|
|
58
|
+
*/
|
|
59
|
+
export async function localAuthor(git: Git, uncommitted: boolean): Promise<string> {
|
|
60
|
+
if (uncommitted) {
|
|
61
|
+
const configured = await git.configuredUser()
|
|
62
|
+
if (configured !== null) {
|
|
63
|
+
return configured
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return git.commitAuthor('HEAD')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DescribeLocalOptions {
|
|
70
|
+
base: string
|
|
71
|
+
source: LocalKey
|
|
72
|
+
repo: Repo
|
|
73
|
+
now: () => Date
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The local work in the shape the rest of the tool reads a pull request in. `prepare` writes it to
|
|
78
|
+
* `prs/<branch|uncommitted>/pr.json` and the page resolves it again on a refresh, so both have to
|
|
79
|
+
* agree on the head, the title, and the words the header shows; they are decided here, once.
|
|
80
|
+
*
|
|
81
|
+
* The line counts are left at zero: only the collected diff knows them, and `prepare` fills them
|
|
82
|
+
* in once it has one.
|
|
83
|
+
*/
|
|
84
|
+
export async function describeLocalWork(git: Git, opts: DescribeLocalOptions): Promise<Pr> {
|
|
85
|
+
const head = await resolveLocalHead(git, opts.source)
|
|
86
|
+
const branch = head.branch ?? 'HEAD'
|
|
87
|
+
return {
|
|
88
|
+
number: null,
|
|
89
|
+
title: head.uncommitted ? `Uncommitted work on ${branch}` : branch,
|
|
90
|
+
body: '',
|
|
91
|
+
author: await localAuthor(git, head.uncommitted),
|
|
92
|
+
// Nothing is pushed yet, so there is no page on the forge to link to.
|
|
93
|
+
url: '',
|
|
94
|
+
state: head.uncommitted ? UNCOMMITTED_STATE : BRANCH_STATE,
|
|
95
|
+
draft: false,
|
|
96
|
+
updatedAt: opts.now().toISOString(),
|
|
97
|
+
baseRef: opts.base,
|
|
98
|
+
headRef: branch,
|
|
99
|
+
headSha: head.headSha,
|
|
100
|
+
mergeBaseSha: await git.mergeBase(opts.base, head.headSha),
|
|
101
|
+
additions: 0,
|
|
102
|
+
deletions: 0,
|
|
103
|
+
changedFiles: 0,
|
|
104
|
+
repo: opts.repo,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** What `pr.state` reads for each local review. One word, because the header paints it as a pill. */
|
|
109
|
+
export const BRANCH_STATE = 'branch'
|
|
110
|
+
export const UNCOMMITTED_STATE = 'uncommitted'
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The commit a local review describes. The `uncommitted` review snapshots the edits and the
|
|
114
|
+
* untracked files into a commit of their own; when there are none, and for the `branch` review
|
|
115
|
+
* always, that is the tip of the current branch.
|
|
116
|
+
*/
|
|
117
|
+
export async function resolveLocalHead(git: Git, source: LocalKey): Promise<LocalHead> {
|
|
118
|
+
const branch = await git.currentBranch()
|
|
119
|
+
let head: string
|
|
120
|
+
try {
|
|
121
|
+
head = await git.revParse('HEAD')
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof GitError) {
|
|
124
|
+
throw new LocalTargetError(
|
|
125
|
+
'this branch has no commits yet, so there is nothing to compare against',
|
|
126
|
+
'make one commit first, then run the review again'
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
throw err
|
|
130
|
+
}
|
|
131
|
+
if (source === 'branch') {
|
|
132
|
+
return { headSha: head, branch, uncommitted: false }
|
|
133
|
+
}
|
|
134
|
+
const snapshot = await git.snapshotWorktree()
|
|
135
|
+
return snapshot === null
|
|
136
|
+
? { headSha: head, branch, uncommitted: false }
|
|
137
|
+
: { headSha: snapshot, branch, uncommitted: true }
|
|
138
|
+
}
|
package/src/git/patch-lines.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The header parser and the line lookup live in static/js/hunks.js so the browser can load them
|
|
2
2
|
// without a bundler; the server imports the same code here.
|
|
3
|
-
import { parseHunkHeader } from '../../static/js/hunks.js'
|
|
3
|
+
import { hunkForLine, parseHunkHeader } from '../../static/js/hunks.js'
|
|
4
4
|
import { hunkId } from '../contract/keys.js'
|
|
5
|
-
import type { Hunk } from '../contract/review-artifact.js'
|
|
5
|
+
import type { FileEntry, Hunk, Side } from '../contract/review-artifact.js'
|
|
6
6
|
|
|
7
7
|
export { hunkForLine, hunkLineRanges, parseHunkHeader } from '../../static/js/hunks.js'
|
|
8
8
|
|
|
@@ -58,3 +58,35 @@ export function labelPatch(key: string, patch: string): string {
|
|
|
58
58
|
.map((h, i) => [`### hunk ${hunkId(key, i + 1)}`, h.header, ...h.lines].join('\n'))
|
|
59
59
|
.join('\n')
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
export interface InlineTarget {
|
|
63
|
+
path: string
|
|
64
|
+
line: number
|
|
65
|
+
side: Side
|
|
66
|
+
startLine?: number | undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether a comment targets lines in the local diff: the file is in the diff, and the whole
|
|
71
|
+
* range sits inside one hunk on that side. Returns the reason when it will not, so the route can
|
|
72
|
+
* refuse before the request leaves the machine.
|
|
73
|
+
*/
|
|
74
|
+
export function checkInlineTarget(files: ReadonlyArray<FileEntry>, target: InlineTarget): string | null {
|
|
75
|
+
const file = files.find(f => f.path === target.path)
|
|
76
|
+
if (file === undefined) {
|
|
77
|
+
return `${target.path} is not in the diff`
|
|
78
|
+
}
|
|
79
|
+
const hunk = hunkForLine(file.hunks, target.side, target.line)
|
|
80
|
+
if (hunk === null) {
|
|
81
|
+
return `${target.path}:${target.line} (${target.side}) is not in the diff`
|
|
82
|
+
}
|
|
83
|
+
if (target.startLine !== undefined) {
|
|
84
|
+
if (target.startLine > target.line) {
|
|
85
|
+
return `the first line of the range must come before ${target.line}`
|
|
86
|
+
}
|
|
87
|
+
if (hunkForLine(file.hunks, target.side, target.startLine) !== hunk) {
|
|
88
|
+
return `${target.path}:${target.startLine}-${target.line} (${target.side}) spans more than one chunk`
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Git } from './git.js'
|
|
2
|
+
import type { Host } from '../host/host.js'
|
|
3
|
+
import type { PrMeta } from '../host/pr.js'
|
|
4
|
+
|
|
5
|
+
export function prHeadRef(number: number): string {
|
|
6
|
+
return `refs/pr/${number}/head`
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function prBaseRef(number: number): string {
|
|
10
|
+
return `refs/pr/${number}/base`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fetches the review's head and base into local refs and resolves the two commits the diff needs.
|
|
15
|
+
* The local refs are the ones the prior-art skill used, so an existing clone keeps working, and
|
|
16
|
+
* they are the same for both hosts; only the remote ref that holds the head differs.
|
|
17
|
+
*
|
|
18
|
+
* A merged PR is diffed against the base as it was at merge time: the merge commit's first
|
|
19
|
+
* parent (a merge commit, a squash, or the last rebased commit all sit on the base branch, so
|
|
20
|
+
* it is local once the base was fetched). Today's base tip would contain the PR and give an
|
|
21
|
+
* empty diff.
|
|
22
|
+
*/
|
|
23
|
+
export async function fetchPrRefs(
|
|
24
|
+
git: Git,
|
|
25
|
+
host: Host,
|
|
26
|
+
meta: PrMeta
|
|
27
|
+
): Promise<{ headSha: string; mergeBaseSha: string }> {
|
|
28
|
+
await git.fetch('origin', [
|
|
29
|
+
`+${host.remoteHeadRef(meta.number)}:${prHeadRef(meta.number)}`,
|
|
30
|
+
`+refs/heads/${meta.baseRef}:${prBaseRef(meta.number)}`,
|
|
31
|
+
])
|
|
32
|
+
const headSha = await git.revParse(prHeadRef(meta.number))
|
|
33
|
+
const base = meta.mergeCommitSha === null ? prBaseRef(meta.number) : `${meta.mergeCommitSha}^1`
|
|
34
|
+
const mergeBaseSha = await git.mergeBase(base, headSha)
|
|
35
|
+
return { headSha, mergeBaseSha }
|
|
36
|
+
}
|