@vintasoftware/pr-review-canvas 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. package/src/github/gh.ts +0 -211
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * The two reviews of work that has no pull request yet. They are separate targets, so reviewing a
5
+ * branch does not disturb the review of what is not committed, and each keeps its own progress
6
+ * marks and chat threads.
7
+ */
8
+ export const LOCAL_KEYS = ['branch', 'uncommitted'] as const
9
+
10
+ /**
11
+ * Which local review: `branch` is the current branch against the base it will be opened against,
12
+ * uncommitted edits left out; `uncommitted` is that branch with the working tree on top.
13
+ */
14
+ export type LocalKey = (typeof LOCAL_KEYS)[number]
15
+
16
+ /**
17
+ * What a canvas, its review state, and its chat threads are filed under: a pull request or merge
18
+ * request number, or one of the local reviews. It is also what the URLs carry, so `/review/12`
19
+ * and `/review/uncommitted` are the same page over different targets.
20
+ */
21
+ export type ReviewKey = number | LocalKey
22
+
23
+ export const LocalKeySchema = z.enum(LOCAL_KEYS)
24
+ export const ReviewKeySchema = z.union([LocalKeySchema, z.number().int().positive()])
25
+
26
+ export function isLocalKey(key: ReviewKey): key is LocalKey {
27
+ return LOCAL_KEYS.some(local => local === key)
28
+ }
29
+
30
+ /** The key as it appears in a URL segment and as a directory name. */
31
+ export function keyToString(key: ReviewKey): string {
32
+ return String(key)
33
+ }
34
+
35
+ /** What the page calls the target, for a title or a heading. */
36
+ export function keyLabel(key: ReviewKey): string {
37
+ if (key === 'branch') {
38
+ return 'Branch review'
39
+ }
40
+ return key === 'uncommitted' ? 'Uncommitted work' : `#${String(key)}`
41
+ }
42
+
43
+ /** The key a URL segment names, or null when it names none of them. */
44
+ export function parseReviewKey(raw: string): ReviewKey | null {
45
+ const local = LocalKeySchema.safeParse(raw)
46
+ if (local.success) {
47
+ return local.data
48
+ }
49
+ const n = Number(raw)
50
+ return Number.isInteger(n) && n > 0 ? n : null
51
+ }
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * `COMMENT` leaves a review with no verdict, the way a thread-level review does: the body and the
5
+ * comments land, and nothing is approved or rejected.
6
+ */
7
+ export const REVIEW_EVENTS = ['COMMENT', 'APPROVE', 'REQUEST_CHANGES'] as const
8
+ export const ReviewEventSchema = z.enum(REVIEW_EVENTS)
9
+ export type ReviewEvent = (typeof REVIEW_EVENTS)[number]
10
+
11
+ export const PostReviewInputSchema = z.object({
12
+ event: ReviewEventSchema,
13
+ /** The dialog sends the body the user read, edited or not. */
14
+ body: z.string().min(1).max(65536).optional(),
15
+ /**
16
+ * Whether the pending comments go out with this review. True unless the page says otherwise, so
17
+ * a reviewer who wrote drafts never submits a review that silently leaves them behind.
18
+ */
19
+ includePending: z.boolean().default(true),
20
+ /** The commit the dialog named. The server refuses the review when the head moved on. */
21
+ headSha: z
22
+ .string()
23
+ .regex(/^[0-9a-f]{40}$/)
24
+ .optional(),
25
+ })
26
+ export type PostReviewInput = z.infer<typeof PostReviewInputSchema>
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import { DEFAULT_FOLD_LEVEL, FOLD_LEVELS } from '../../static/js/fold-levels.js'
2
3
  import { DEFAULT_SKIN, isSkin, SKINS, type Skin } from '../../static/js/skin.js'
3
4
  import { DEFAULT_THEME, isTheme, THEMES, type Theme } from '../../static/js/theme.js'
4
5
 
@@ -24,6 +25,8 @@ export const SettingsSchema = z.object({
24
25
  version: z.literal(1),
25
26
  skin: z.enum(SKINS),
26
27
  theme: z.enum(THEMES),
28
+ /** The reading level every review opens at; the control on the page changes it for one page. */
29
+ foldLevel: z.enum(FOLD_LEVELS),
27
30
  agent: z.enum(CHAT_AGENTS),
28
31
  model: z.string().min(1).nullable(),
29
32
  chatTimeoutSec: z.number().int().min(CHAT_TIMEOUT_MIN_SEC).max(CHAT_TIMEOUT_MAX_SEC),
@@ -35,6 +38,7 @@ export const DEFAULT_SETTINGS: Settings = {
35
38
  version: 1,
36
39
  skin: DEFAULT_SKIN,
37
40
  theme: DEFAULT_THEME,
41
+ foldLevel: DEFAULT_FOLD_LEVEL,
38
42
  agent: 'claude',
39
43
  model: null,
40
44
  chatTimeoutSec: 600,
@@ -45,6 +49,7 @@ export const DEFAULT_SETTINGS: Settings = {
45
49
  export const SettingsInputSchema = z.object({
46
50
  skin: z.enum(SKINS).optional(),
47
51
  theme: z.enum(THEMES).optional(),
52
+ foldLevel: z.enum(FOLD_LEVELS).optional(),
48
53
  agent: z.enum(CHAT_AGENTS).optional(),
49
54
  model: z.string().max(200).nullable().optional(),
50
55
  chatTimeoutSec: z.number().int().min(CHAT_TIMEOUT_MIN_SEC).max(CHAT_TIMEOUT_MAX_SEC).optional(),
@@ -107,6 +112,8 @@ export interface SettingsResponse {
107
112
  maxRepairRounds: number
108
113
  inlineDiffMaxLines: number
109
114
  smallPrHunks: number
115
+ /** Whether a canvas still stands for a later head with an identical diff. */
116
+ keepForIdenticalDiff: boolean
110
117
  layers: number
111
118
  highRisk: number
112
119
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import { PendingCommentSchema } from './pending.js'
2
3
 
3
4
  export const ChatThreadSchema = z.object({
4
5
  name: z.string(),
@@ -11,37 +12,67 @@ export const ChatThreadSchema = z.object({
11
12
 
12
13
  export type ChatThread = z.infer<typeof ChatThreadSchema>
13
14
 
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(
29
- z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })
30
- ),
31
- dismissed: z.record(z.string(), z.object({ at: z.string(), reason: z.string().optional() })),
32
- chat: z.object({ threads: z.array(ChatThreadSchema), activeThread: z.string().optional() }),
33
- updatedAt: z.string(),
34
- })
15
+ /**
16
+ * Version 1 keyed a reviewed mark by the layer's position in the canvas (`layer:layer-2`). Marks
17
+ * are keyed by the layer's own key now, so a regenerated canvas that reorders its layers keeps
18
+ * them straight, and the old positional marks cannot be translated without the canvas they were
19
+ * made on. They are dropped; the rest of the file (dismissals, posted comments, chat threads)
20
+ * carries over untouched.
21
+ */
22
+ const dropPositionalMarks = (raw: unknown): unknown => {
23
+ if (raw === null || typeof raw !== 'object') {
24
+ return raw
25
+ }
26
+ const { reviewedHeadSha: _reviewedHeadSha, ...rest } = raw as Record<string, unknown>
27
+ if (rest['version'] !== 1) {
28
+ return rest
29
+ }
30
+ return { ...rest, version: 2, reviewed: {}, reviewedCanvasSha: undefined }
31
+ }
32
+
33
+ export const PrStateSchema = z.preprocess(
34
+ dropPositionalMarks,
35
+ z.object({
36
+ version: z.literal(2),
37
+ /**
38
+ * Counts the writes to this file. The page uses it to tell a newer answer from an older one
39
+ * when two of its requests overlap. Absent in state files of older tool versions.
40
+ */
41
+ rev: z.number().int().nonnegative().optional(),
42
+ reviewed: z.record(z.string(), z.literal(true)),
43
+ /**
44
+ * The commit of the canvas the reviewed marks were made on: the head, or the commit a carried-over
45
+ * canvas was generated for. A canvas for another commit describes other code, so the marks start
46
+ * again with it, unless that canvas names this one as its basis and the code behind a mark is
47
+ * untouched. Absent in state files of older tool versions.
48
+ */
49
+ reviewedCanvasSha: z.string().optional(),
50
+ hiddenThreads: z.record(z.string(), z.object({ at: z.string() })),
51
+ posted: z.array(
52
+ z.object({ commentId: z.number().int(), pointFingerprint: z.string().optional(), at: z.string() })
53
+ ),
54
+ dismissed: z.record(z.string(), z.object({ at: z.string(), reason: z.string().optional() })),
55
+ /**
56
+ * The comments of the review being written, in the order they were added. They live here and
57
+ * nowhere else until the review is submitted, so a reload does not lose a draft. A state file
58
+ * of an older tool version has none, which reads as an empty list.
59
+ */
60
+ pending: z.array(PendingCommentSchema).default([]),
61
+ chat: z.object({ threads: z.array(ChatThreadSchema), activeThread: z.string().optional() }),
62
+ updatedAt: z.string(),
63
+ })
64
+ )
35
65
  export type PrState = z.infer<typeof PrStateSchema>
36
66
 
37
67
  export function emptyState(updatedAt: string): PrState {
38
68
  return {
39
- version: 1,
69
+ version: 2,
40
70
  rev: 0,
41
71
  reviewed: {},
42
72
  hiddenThreads: {},
43
73
  posted: [],
44
74
  dismissed: {},
75
+ pending: [],
45
76
  chat: { threads: [] },
46
77
  updatedAt,
47
78
  }
@@ -15,6 +15,7 @@ export const VALIDATION_CODES = [
15
15
  'RISK_IN_OTHER',
16
16
  'ANNOTATION_OUTSIDE_HUNK',
17
17
  'FOLD_INVALID',
18
+ 'FOLD_MISSING',
18
19
  'POINT_OUTSIDE_DIFF',
19
20
  'TOO_MANY_POINTS',
20
21
  'TEST_PATH_UNKNOWN',
@@ -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,13 +109,18 @@ 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
120
  execFile(
66
121
  'git',
67
122
  args,
68
- { cwd, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 },
123
+ { cwd, env: { ...envWithoutRepo(), ...extra }, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 },
69
124
  (error, stdout, stderr) => {
70
125
  const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
71
126
  resolve({ stdout, stderr: stderr.toString('utf8'), code })
@@ -77,8 +132,8 @@ export function execGit(cwd: string, args: string[]): Promise<ExecResult> {
77
132
  export type GitExec = typeof execGit
78
133
 
79
134
  export function createGit(cwd: string, exec: GitExec = execGit): Git {
80
- async function run(args: string[]): Promise<string> {
81
- 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)
82
137
  if (r.code !== 0) {
83
138
  throw new GitError(args, r.stderr, r.code)
84
139
  }
@@ -101,12 +156,12 @@ export function createGit(cwd: string, exec: GitExec = execGit): Git {
101
156
  fetch: async (remote, refspecs) => {
102
157
  await run(['fetch', '--no-tags', '--quiet', remote, ...refspecs])
103
158
  },
104
- show: async (ref, path) => {
105
- const r = await exec(cwd, ['show', `${ref}:${path}`])
159
+ show: async (ref, file) => {
160
+ const r = await exec(cwd, ['show', `${ref}:${file}`])
106
161
  return r.code === 0 ? r.stdout : null
107
162
  },
108
- blobSize: async (ref, path) => {
109
- 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}`])
110
165
  return r.code === 0 ? Number(r.stdout.toString('utf8').trim()) : null
111
166
  },
112
167
  commitAuthor: ref => run(['log', '-1', '--format=%an', ref]),
@@ -116,5 +171,50 @@ export function createGit(cwd: string, exec: GitExec = execGit): Git {
116
171
  const r = await exec(cwd, ['remote', 'get-url', name])
117
172
  return r.code === 0 ? r.stdout.toString('utf8').trim() : null
118
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
+ },
119
219
  }
120
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
+ }