@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.
Files changed (138) hide show
  1. package/README.md +61 -26
  2. package/docs/reference.md +231 -121
  3. package/package.json +18 -4
  4. package/pr-review.config.example.yml +10 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +77 -40
  8. package/src/acpx/acpx.ts +21 -4
  9. package/src/acpx/events.ts +5 -1
  10. package/src/acpx/ndjson.ts +3 -1
  11. package/src/acpx/preflight.ts +5 -1
  12. package/src/canvas/comment.ts +24 -0
  13. package/src/canvas/export.ts +11 -2
  14. package/src/canvas/import.ts +30 -9
  15. package/src/canvas/name.ts +1 -0
  16. package/src/canvas/zip.ts +25 -2
  17. package/src/chat/chat-manager.ts +55 -41
  18. package/src/chat/context.ts +10 -3
  19. package/src/chat/seed.ts +3 -1
  20. package/src/chat/threads.ts +27 -26
  21. package/src/cli.ts +24 -9
  22. package/src/commands.ts +86 -25
  23. package/src/config.ts +24 -24
  24. package/src/contract/api.ts +32 -1
  25. package/src/contract/canvas-manifest.ts +2 -0
  26. package/src/contract/comments.ts +8 -1
  27. package/src/contract/discovery.ts +5 -2
  28. package/src/contract/generation-context.ts +27 -2
  29. package/src/contract/review-artifact.ts +14 -8
  30. package/src/contract/review-key.ts +51 -0
  31. package/src/contract/reviews.ts +17 -0
  32. package/src/contract/settings.ts +2 -0
  33. package/src/contract/state.ts +41 -19
  34. package/src/git/diff-collector.ts +2 -1
  35. package/src/git/environment.mjs +27 -0
  36. package/src/git/git.ts +117 -12
  37. package/src/git/local-target.ts +138 -0
  38. package/src/git/patch-lines.ts +34 -2
  39. package/src/git/pr-refs.ts +36 -0
  40. package/src/github/attachments.ts +9 -257
  41. package/src/github/canvas-comment.ts +22 -0
  42. package/src/github/capabilities.ts +3 -37
  43. package/src/github/comments.ts +9 -27
  44. package/src/github/post-comment.ts +7 -37
  45. package/src/github/post-review.ts +4 -19
  46. package/src/github/pr.ts +6 -84
  47. package/src/github/threads.ts +6 -2
  48. package/src/gitlab/attachments.ts +40 -0
  49. package/src/gitlab/canvas-comment.ts +26 -0
  50. package/src/gitlab/capabilities.ts +64 -0
  51. package/src/gitlab/comments.ts +164 -0
  52. package/src/gitlab/mr.ts +115 -0
  53. package/src/gitlab/post-comment.ts +111 -0
  54. package/src/gitlab/post-review.ts +54 -0
  55. package/src/gitlab/project.ts +13 -0
  56. package/src/host/attachments.ts +293 -0
  57. package/src/host/capabilities.ts +38 -0
  58. package/src/host/client.ts +245 -0
  59. package/src/host/host.ts +136 -0
  60. package/src/host/pr.ts +51 -0
  61. package/src/host/remote.ts +42 -0
  62. package/src/project-config.ts +25 -9
  63. package/src/prompt-files.ts +4 -3
  64. package/src/review/carry-over.ts +79 -0
  65. package/src/review/doctor.ts +31 -14
  66. package/src/review/normalize.ts +3 -1
  67. package/src/review/prepare.ts +87 -17
  68. package/src/review/prompt.ts +8 -2
  69. package/src/review/publish.ts +76 -13
  70. package/src/{github → review}/review-body.ts +17 -5
  71. package/src/review/skill-command.ts +5 -3
  72. package/src/review/trim-caps.ts +10 -6
  73. package/src/review/validate-folds.ts +2 -2
  74. package/src/review/validate.ts +67 -15
  75. package/src/server/app.ts +12 -4
  76. package/src/server/bundle.ts +320 -106
  77. package/src/server/context.ts +24 -10
  78. package/src/server/errors.ts +56 -10
  79. package/src/server/html.ts +36 -12
  80. package/src/server/node-server.ts +4 -2
  81. package/src/server/routes/api.ts +92 -31
  82. package/src/server/routes/chat-routes.ts +105 -46
  83. package/src/server/routes/pages.ts +25 -9
  84. package/src/server/routes/review-routes.ts +101 -35
  85. package/src/server/sse.ts +3 -1
  86. package/src/store/atomic-json.ts +5 -1
  87. package/src/store/canvas-store.ts +95 -46
  88. package/src/store/data-dir.ts +2 -1
  89. package/src/store/derived-store.ts +41 -23
  90. package/src/store/pr-store.ts +25 -15
  91. package/src/store/state-store.ts +42 -37
  92. package/static/brand.svg +19 -0
  93. package/static/js/api.js +47 -27
  94. package/static/js/app.js +27 -7
  95. package/static/js/chat-panel.js +93 -0
  96. package/static/js/chat.js +41 -17
  97. package/static/js/composer.js +30 -17
  98. package/static/js/contract-types.d.ts +3 -0
  99. package/static/js/diagram.js +2 -1
  100. package/static/js/diff-decorations.js +5 -3
  101. package/static/js/diff-renderer.js +7 -2
  102. package/static/js/dom.js +5 -7
  103. package/static/js/download.js +1 -1
  104. package/static/js/empty-state.js +85 -16
  105. package/static/js/errors.js +22 -6
  106. package/static/js/header.js +32 -9
  107. package/static/js/host.js +40 -0
  108. package/static/js/import-zone.js +1 -1
  109. package/static/js/interactions.js +56 -17
  110. package/static/js/keyboard.js +7 -2
  111. package/static/js/layers.js +26 -14
  112. package/static/js/links.js +9 -3
  113. package/static/js/markdown.js +28 -1
  114. package/static/js/nav.js +5 -2
  115. package/static/js/overview.js +32 -4
  116. package/static/js/points.js +5 -3
  117. package/static/js/progress.js +2 -1
  118. package/static/js/proposed-comment.js +4 -1
  119. package/static/js/quick-questions.js +2 -1
  120. package/static/js/regenerate.js +4 -1
  121. package/static/js/review-session.js +7 -2
  122. package/static/js/settings.js +2 -1
  123. package/static/js/signoff.js +9 -6
  124. package/static/styles/base.css +16 -6
  125. package/static/styles/chat-panel.css +81 -0
  126. package/static/styles/chat-tools.css +28 -0
  127. package/static/styles/chat.css +1 -1
  128. package/static/styles/commands.css +10 -4
  129. package/static/styles/diff.css +1 -1
  130. package/static/styles/header.css +18 -4
  131. package/static/styles/layout.css +4 -4
  132. package/static/styles/panels.css +4 -0
  133. package/static/styles/responsive.css +1 -15
  134. package/static/styles/review-actions.css +1 -0
  135. package/static/styles/review.css +24 -3
  136. package/static/styles/skin-github.css +99 -100
  137. package/static/styles.css +13 -12
  138. package/src/github/gh.ts +0 -196
@@ -11,25 +11,47 @@ export const ChatThreadSchema = z.object({
11
11
 
12
12
  export type ChatThread = z.infer<typeof ChatThreadSchema>
13
13
 
14
- export const PrStateSchema = z.object({
15
- version: z.literal(1),
16
- /**
17
- * Counts the writes to this file. The page uses it to tell a newer answer from an older one
18
- * when two of its requests overlap. Absent in state files of older tool versions.
19
- */
20
- rev: z.number().int().nonnegative().optional(),
21
- reviewed: z.record(z.string(), z.literal(true)),
22
- /**
23
- * The commit the reviewed marks describe. A canvas for another commit describes other code,
24
- * so the marks start again when the head moves. Absent in state files of older tool versions.
25
- */
26
- reviewedHeadSha: z.string().optional(),
27
- hiddenThreads: z.record(z.string(), z.object({ at: z.string() })),
28
- posted: z.array(z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })),
29
- dismissed: z.record(z.string(), z.object({ at: z.string(), reason: z.string().optional() })),
30
- chat: z.object({ threads: z.array(ChatThreadSchema), activeThread: z.string().optional() }),
31
- updatedAt: z.string(),
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 = header.some(l => l.startsWith('Binary files ')) || header.some(l => l === 'GIT binary patch')
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
- export function execGit(cwd: string, args: string[]): Promise<ExecResult> {
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('git', args, { cwd, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 }, (error, stdout, stderr) => {
66
- const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
67
- resolve({ stdout, stderr: stderr.toString('utf8'), code })
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, path) => {
100
- const r = await exec(cwd, ['show', `${ref}:${path}`])
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, path) => {
104
- const r = await exec(cwd, ['cat-file', '-s', `${ref}:${path}`])
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
+ }
@@ -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
+ }