@vintasoftware/pr-review-canvas 0.3.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 (99) hide show
  1. package/README.md +52 -23
  2. package/docs/reference.md +154 -44
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +6 -0
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +69 -39
  8. package/src/canvas/comment.ts +24 -0
  9. package/src/canvas/import.ts +30 -9
  10. package/src/canvas/name.ts +1 -0
  11. package/src/canvas/zip.ts +21 -1
  12. package/src/chat/chat-manager.ts +40 -39
  13. package/src/chat/threads.ts +27 -26
  14. package/src/cli.ts +12 -7
  15. package/src/commands.ts +45 -16
  16. package/src/config.ts +14 -26
  17. package/src/contract/api.ts +32 -1
  18. package/src/contract/canvas-manifest.ts +2 -0
  19. package/src/contract/comments.ts +5 -0
  20. package/src/contract/discovery.ts +5 -2
  21. package/src/contract/generation-context.ts +24 -1
  22. package/src/contract/review-key.ts +51 -0
  23. package/src/contract/reviews.ts +17 -0
  24. package/src/contract/settings.ts +2 -0
  25. package/src/contract/state.ts +41 -21
  26. package/src/git/environment.mjs +27 -0
  27. package/src/git/git.ts +109 -9
  28. package/src/git/local-target.ts +138 -0
  29. package/src/git/patch-lines.ts +34 -2
  30. package/src/git/pr-refs.ts +36 -0
  31. package/src/github/attachments.ts +9 -257
  32. package/src/github/canvas-comment.ts +22 -0
  33. package/src/github/capabilities.ts +3 -41
  34. package/src/github/comments.ts +3 -24
  35. package/src/github/post-comment.ts +3 -36
  36. package/src/github/post-review.ts +4 -19
  37. package/src/github/pr.ts +6 -87
  38. package/src/github/threads.ts +2 -2
  39. package/src/gitlab/attachments.ts +40 -0
  40. package/src/gitlab/canvas-comment.ts +26 -0
  41. package/src/gitlab/capabilities.ts +64 -0
  42. package/src/gitlab/comments.ts +164 -0
  43. package/src/gitlab/mr.ts +115 -0
  44. package/src/gitlab/post-comment.ts +111 -0
  45. package/src/gitlab/post-review.ts +54 -0
  46. package/src/gitlab/project.ts +13 -0
  47. package/src/host/attachments.ts +293 -0
  48. package/src/host/capabilities.ts +38 -0
  49. package/src/host/client.ts +245 -0
  50. package/src/host/host.ts +136 -0
  51. package/src/host/pr.ts +51 -0
  52. package/src/host/remote.ts +42 -0
  53. package/src/project-config.ts +13 -0
  54. package/src/review/carry-over.ts +79 -0
  55. package/src/review/doctor.ts +22 -12
  56. package/src/review/prepare.ts +64 -10
  57. package/src/review/publish.ts +61 -10
  58. package/src/{github → review}/review-body.ts +17 -5
  59. package/src/review/skill-command.ts +5 -3
  60. package/src/review/validate-folds.ts +2 -2
  61. package/src/review/validate.ts +4 -4
  62. package/src/server/bundle.ts +312 -111
  63. package/src/server/context.ts +10 -8
  64. package/src/server/errors.ts +30 -8
  65. package/src/server/html.ts +34 -10
  66. package/src/server/routes/api.ts +63 -26
  67. package/src/server/routes/chat-routes.ts +76 -38
  68. package/src/server/routes/pages.ts +22 -8
  69. package/src/server/routes/review-routes.ts +84 -33
  70. package/src/store/canvas-store.ts +90 -55
  71. package/src/store/data-dir.ts +2 -1
  72. package/src/store/derived-store.ts +41 -27
  73. package/src/store/pr-store.ts +23 -14
  74. package/src/store/state-store.ts +40 -36
  75. package/static/js/api.js +32 -24
  76. package/static/js/app.js +24 -10
  77. package/static/js/chat.js +3 -2
  78. package/static/js/composer.js +24 -14
  79. package/static/js/contract-types.d.ts +3 -0
  80. package/static/js/diff-renderer.js +1 -1
  81. package/static/js/download.js +1 -1
  82. package/static/js/empty-state.js +85 -18
  83. package/static/js/errors.js +22 -6
  84. package/static/js/header.js +31 -8
  85. package/static/js/host.js +40 -0
  86. package/static/js/import-zone.js +1 -1
  87. package/static/js/interactions.js +1 -2
  88. package/static/js/layers.js +3 -3
  89. package/static/js/links.js +3 -3
  90. package/static/js/markdown.js +28 -1
  91. package/static/js/points.js +2 -1
  92. package/static/js/review-session.js +7 -2
  93. package/static/js/settings.js +2 -1
  94. package/static/js/signoff.js +6 -7
  95. package/static/styles/commands.css +6 -0
  96. package/static/styles/panels.css +4 -0
  97. package/static/styles/review-actions.css +1 -0
  98. package/static/styles/skin-github.css +7 -1
  99. package/src/github/gh.ts +0 -211
@@ -0,0 +1,136 @@
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 { FetchCommentsResult, PostCommentInput, PostCommentResult } from '../contract/comments.js'
5
+ import type { Repo } from '../contract/review-artifact.js'
6
+ import { GITHUB_ATTACHMENTS } from '../github/attachments.js'
7
+ import { probeCapabilities } from '../github/capabilities.js'
8
+ import { fetchComments } from '../github/comments.js'
9
+ import { postComment } from '../github/post-comment.js'
10
+ import type { ReviewEvent } from '../contract/reviews.js'
11
+ import { postReview } from '../github/post-review.js'
12
+ import { fetchPrMeta } from '../github/pr.js'
13
+ import type { PrMeta } from './pr.js'
14
+ import { gitlabAttachments } from '../gitlab/attachments.js'
15
+ import { probeGitlabCapabilities } from '../gitlab/capabilities.js'
16
+ import { fetchGitlabComments } from '../gitlab/comments.js'
17
+ import { fetchMrMeta } from '../gitlab/mr.js'
18
+ import { postGitlabComment } from '../gitlab/post-comment.js'
19
+ import { postGitlabReview } from '../gitlab/post-review.js'
20
+ import type { Derived } from '../store/derived-store.js'
21
+ import type { AttachmentLink } from './attachments.js'
22
+ import { GH_CLI, glabCli, type HostClient, type HostCliSpec } from './client.js'
23
+
24
+ export type HostKind = PublicHost['kind']
25
+
26
+ /** How canvas zips attached to a review are found and fetched on one forge. */
27
+ export interface HostAttachments {
28
+ /** Zip links in one markdown text, as absolute URLs this host serves. */
29
+ findLinks(text: string, repo: Repo): AttachmentLink[]
30
+ /** Where an attachment may be served from. Everything else is refused before any request. */
31
+ allowedHosts: ReadonlySet<string>
32
+ /** The header that carries the CLI token to the forge itself; a storage redirect gets none. */
33
+ authHeader(token: string): Record<string, string>
34
+ }
35
+
36
+ /**
37
+ * One forge: the words the page uses for it, the CLI that talks to it, and every operation whose
38
+ * request or answer differs between GitHub and GitLab. Everything that is the same for both (the
39
+ * local refs, the stored Pr, the download loop, the routes) takes a Host and never asks its kind.
40
+ */
41
+ export interface Host {
42
+ kind: HostKind
43
+ hostname: string
44
+ /** `GitHub` or `GitLab`, for messages. */
45
+ label: string
46
+ cli: HostCliSpec
47
+ webBase: string
48
+ /** `pull request` or `merge request`, and its abbreviation. */
49
+ noun: string
50
+ nounShort: string
51
+ /** The remote ref that holds a review's head, fetched into the same local ref for both hosts. */
52
+ remoteHeadRef(number: number): string
53
+ compareUrl(repo: Repo, base: string, head: string): string
54
+ fetchPrMeta(client: HostClient, repo: Repo, number: number): Promise<PrMeta>
55
+ fetchComments(
56
+ client: HostClient,
57
+ repo: Repo,
58
+ number: number,
59
+ headSha: string,
60
+ now: () => Date
61
+ ): Promise<FetchCommentsResult>
62
+ /** GitLab needs the diff for renamed paths and both coordinates of context lines. */
63
+ postComment(
64
+ client: HostClient,
65
+ repo: Repo,
66
+ number: number,
67
+ headSha: string,
68
+ input: PostCommentInput,
69
+ diff: Derived
70
+ ): Promise<PostCommentResult>
71
+ postReview(
72
+ client: HostClient,
73
+ repo: Repo,
74
+ number: number,
75
+ headSha: string,
76
+ input: { event: ReviewEvent; body: string }
77
+ ): Promise<ReviewSummary>
78
+ probeCapabilities(client: HostClient, repo: Repo): Promise<Capabilities>
79
+ canvasCommentLimit: number
80
+ shareCanvas(client: HostClient, repo: Repo, number: number, body: string): Promise<string>
81
+ attachments: HostAttachments
82
+ }
83
+
84
+ export const GITHUB_HOST: Host = {
85
+ kind: 'github',
86
+ hostname: 'github.com',
87
+ label: 'GitHub',
88
+ cli: GH_CLI,
89
+ webBase: 'https://github.com',
90
+ noun: 'pull request',
91
+ nounShort: 'PR',
92
+ remoteHeadRef: number => `pull/${number}/head`,
93
+ compareUrl: (repo, base, head) => `https://github.com/${repo.owner}/${repo.name}/compare/${base}...${head}`,
94
+ fetchPrMeta,
95
+ fetchComments,
96
+ postComment,
97
+ postReview,
98
+ probeCapabilities,
99
+ canvasCommentLimit: 65_536,
100
+ shareCanvas: shareGithubCanvas,
101
+ attachments: GITHUB_ATTACHMENTS,
102
+ }
103
+
104
+ /** A GitLab instance. `hostname` is gitlab.com or the self-hosted instance the origin names. */
105
+ export function gitlabHost(hostname: string): Host {
106
+ const webUrl = new URL(`https://${hostname}`)
107
+ hostname = webUrl.host
108
+ const webBase = webUrl.origin
109
+ return {
110
+ kind: 'gitlab',
111
+ hostname,
112
+ label: 'GitLab',
113
+ cli: glabCli(hostname),
114
+ webBase,
115
+ noun: 'merge request',
116
+ nounShort: 'MR',
117
+ remoteHeadRef: number => `merge-requests/${number}/head`,
118
+ compareUrl: (repo, base, head) => `${webBase}/${repo.owner}/${repo.name}/-/compare/${base}...${head}`,
119
+ fetchPrMeta: fetchMrMeta,
120
+ fetchComments: (client, repo, number, headSha, now) =>
121
+ fetchGitlabComments(client, repo, number, headSha, now, webBase),
122
+ postComment: (client, repo, number, headSha, input, diff) =>
123
+ postGitlabComment(client, repo, number, headSha, input, { webBase, ...diff }),
124
+ postReview: (client, repo, number, headSha, input) =>
125
+ postGitlabReview(client, repo, number, headSha, input, webBase),
126
+ probeCapabilities: probeGitlabCapabilities,
127
+ canvasCommentLimit: 1_000_000,
128
+ shareCanvas: (client, repo, number, body) => shareGitlabCanvas(client, repo, number, body, webBase),
129
+ attachments: gitlabAttachments(hostname, webBase),
130
+ }
131
+ }
132
+
133
+ /** The part of a Host the page and the health endpoint are told. */
134
+ export function publicHost(host: Host): PublicHost {
135
+ return { kind: host.kind, label: host.label, webBase: host.webBase }
136
+ }
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
+ }
@@ -65,6 +65,13 @@ export const ProjectConfigSchema = z.object({
65
65
  /** Which paths count as tests, for the layering rules and the `isTest` flag on a file. */
66
66
  tests: z.object({ patterns: z.array(z.string().min(1)) }),
67
67
  chat: z.object({ enabled: z.boolean() }),
68
+ canvas: z.object({
69
+ /**
70
+ * A canvas still stands for a later head whose diff is identical to the one it was generated
71
+ * from, as after merging the base branch in. False marks the canvas outdated on any commit.
72
+ */
73
+ keepForIdenticalDiff: z.boolean(),
74
+ }),
68
75
  })
69
76
  export type ProjectConfig = z.infer<typeof ProjectConfigSchema>
70
77
 
@@ -86,6 +93,7 @@ const PartialProjectConfigSchema = z.object({
86
93
  .optional(),
87
94
  tests: z.object({ patterns: z.array(z.string().min(1)).optional() }).optional(),
88
95
  chat: z.object({ enabled: z.boolean().optional() }).optional(),
96
+ canvas: z.object({ keepForIdenticalDiff: z.boolean().optional() }).optional(),
89
97
  })
90
98
 
91
99
  export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
@@ -95,6 +103,7 @@ export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
95
103
  generation: { mode: 'strict', maxRepairRounds: 3, inlineDiffMaxLines: 1500, smallPrHunks: 10 },
96
104
  tests: { patterns: [...DEFAULT_TEST_PATTERNS] },
97
105
  chat: { enabled: true },
106
+ canvas: { keepForIdenticalDiff: true },
98
107
  }
99
108
 
100
109
  export interface LoadedProjectConfig {
@@ -135,6 +144,10 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
135
144
  generation,
136
145
  tests: { patterns: user.tests?.patterns ?? [...DEFAULT_TEST_PATTERNS] },
137
146
  chat: { enabled: user.chat?.enabled ?? true },
147
+ canvas: {
148
+ keepForIdenticalDiff:
149
+ user.canvas?.keepForIdenticalDiff ?? DEFAULT_PROJECT_CONFIG.canvas.keepForIdenticalDiff,
150
+ },
138
151
  }
139
152
  if (user.rulebook !== undefined) {
140
153
  config.rulebook = user.rulebook
@@ -0,0 +1,79 @@
1
+ // A canvas carried over to a later head: the head's diff is identical to the diff the canvas was
2
+ // generated from, so the canvas stands for it. The one place that reads the rule.
3
+ import type { CarriedOverInfo } from '../contract/api.js'
4
+ import type { Pr } from '../contract/review-artifact.js'
5
+ import type { AppContext } from '../server/context.js'
6
+ import type { CanvasLookup } from '../store/canvas-store.js'
7
+ import type { Derived } from '../store/derived-store.js'
8
+
9
+ /** A commit with the merge base its diff runs from; `Pr`, a prepared context, and a canvas manifest all provide it. */
10
+ export interface DiffedCommit {
11
+ headSha: string
12
+ mergeBaseSha: string
13
+ }
14
+
15
+ /**
16
+ * True when `commit` stands for the head: it is the head, or the head's diff against its merge
17
+ * base is identical to `commit`'s diff against its own, file by file and byte for byte. Layers,
18
+ * hunk ids, folds, and attention points all assume the diff on screen is the one the canvas was
19
+ * generated from, and identity is what guarantees that; a base merge that only moves a hunk down
20
+ * already breaks it. How the head reached that diff does not matter. A diff missing on this
21
+ * machine, and a change set that is empty on both sides, keep the strict reading. Off when the
22
+ * project marks the canvas outdated on any commit.
23
+ */
24
+ export async function standsForHead(
25
+ ctx: AppContext,
26
+ pr: DiffedCommit,
27
+ commit: DiffedCommit
28
+ ): Promise<boolean> {
29
+ if (commit.headSha === pr.headSha) {
30
+ return true
31
+ }
32
+ if (!ctx.projectConfig.config.canvas.keepForIdenticalDiff) {
33
+ return false
34
+ }
35
+ const [older, head] = await Promise.all([
36
+ ctx.derived.readOrBuild(commit.headSha, commit.mergeBaseSha),
37
+ ctx.derived.readOrBuild(pr.headSha, pr.mergeBaseSha),
38
+ ])
39
+ if (older === null || head === null) {
40
+ return false
41
+ }
42
+ // Two empty diffs are equal by having nothing to compare, which is no evidence that the canvas
43
+ // explains the head. An empty change set has nothing to review either way, so the strict
44
+ // reading costs the reviewer nothing here.
45
+ return Object.keys(head.patches).length > 0 && samePatches(older, head)
46
+ }
47
+
48
+ /**
49
+ * Whether two diffs change the same code: the same patch keys, each with the same patch body.
50
+ * The patches are the diff; the files array beside them only counts and flags what the patches
51
+ * already say, so comparing the patches is comparing the whole change.
52
+ */
53
+ export function samePatches(a: Derived, b: Derived): boolean {
54
+ const keys = Object.keys(a.patches)
55
+ return keys.length === Object.keys(b.patches).length && keys.every(k => a.patches[k] === b.patches[k])
56
+ }
57
+
58
+ /**
59
+ * The canvas for this pull request: the store's answer, with a canvas of another commit read as
60
+ * ready when that commit stands for the head. A canvas has a manifest naming its merge base;
61
+ * one whose manifest is gone cannot be compared.
62
+ */
63
+ export async function lookupCanvas(ctx: AppContext, number: number, pr: Pr): Promise<CanvasLookup> {
64
+ const found = await ctx.canvases.findForPr(number, pr.headSha)
65
+ if (found.status !== 'stale') {
66
+ return found
67
+ }
68
+ const manifest = await ctx.canvases.readManifest(found.headSha)
69
+ if (manifest !== null && (await standsForHead(ctx, pr, manifest))) {
70
+ const carriedOver: CarriedOverInfo = { canvasHeadSha: found.headSha, currentHeadSha: pr.headSha }
71
+ // How far the head moved, when the head was built on the canvas's commit. A head that
72
+ // reached the identical diff another way, by a rebase, is no distance from it at all.
73
+ if (found.relation === 'ancestor') {
74
+ carriedOver.commitsBehind = found.commitsBehind
75
+ }
76
+ return { status: 'ready', headSha: found.headSha, carriedOver }
77
+ }
78
+ return found
79
+ }
@@ -3,9 +3,11 @@
3
3
  import { randomBytes } from 'node:crypto'
4
4
  import { readFile, rm, writeFile } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
- import { parseGithubRemote } from '../config.js'
6
+ import { ORIGIN_HINT } from '../config.js'
7
7
  import type { Git } from '../git/git.js'
8
- import type { GitHubClient } from '../github/gh.js'
8
+ import { CLI_INFO, type HostClient } from '../host/client.js'
9
+ import { GITHUB_HOST, type Host } from '../host/host.js'
10
+ import { parseOriginRemote } from '../host/remote.js'
9
11
  import { ensureDataDir, resolveDataDir } from '../store/data-dir.js'
10
12
  import { CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR, SKILL_NAME, SKILL_SOURCE_DIR } from './install-skill.js'
11
13
  import { skillContent } from './skill-content.js'
@@ -27,7 +29,10 @@ export interface DoctorReport {
27
29
 
28
30
  export interface DoctorDeps {
29
31
  git: Git
30
- gh: GitHubClient
32
+ /** Where `PR_REVIEW_HOST` is read from. */
33
+ env: NodeJS.ProcessEnv
34
+ /** The CLI client for the host origin names; without a usable origin, GitHub's is checked. */
35
+ client: (host: Host) => HostClient
31
36
  version: string
32
37
  acpxVersion: () => Promise<string | null>
33
38
  /** `--data-dir` or `PR_REVIEW_DATA_DIR`; without it the dir sits next to the git common dir. */
@@ -130,25 +135,30 @@ export async function runDoctorChecks(
130
135
  git = { ok: false, detail: message(err), hint: 'run from a clone or pass --repo <dir>' }
131
136
  }
132
137
 
138
+ let host = GITHUB_HOST
133
139
  let origin: DoctorCheck
134
140
  try {
135
141
  const url = await deps.git.remoteUrl('origin')
136
- const repo = url === null ? null : parseGithubRemote(url)
137
- origin =
138
- repo === null
139
- ? { ok: false, detail: url ?? 'no origin remote', hint: 'add a github.com origin' }
140
- : { ok: true, detail: `${repo.owner}/${repo.name}` }
142
+ const parsed = url === null ? null : parseOriginRemote(url, deps.env)
143
+ if (parsed === null) {
144
+ origin = { ok: false, detail: url ?? 'no origin remote', hint: ORIGIN_HINT }
145
+ } else {
146
+ host = parsed.host
147
+ origin = { ok: true, detail: `${parsed.repo.owner}/${parsed.repo.name} (${host.label})` }
148
+ }
141
149
  } catch (err) {
142
- origin = { ok: false, detail: message(err), hint: 'add a github.com origin' }
150
+ origin = { ok: false, detail: message(err), hint: ORIGIN_HINT }
143
151
  }
144
152
 
145
- const status = await deps.gh.authStatus()
153
+ // The `gh` keys are the report's public names; for a GitLab origin they describe glab.
154
+ const status = await deps.client(host).authStatus()
155
+ const cli = CLI_INFO[host.cli.cli]
146
156
  const gh: DoctorCheck = status.installed
147
157
  ? { ok: true, detail: status.detail }
148
- : { ok: false, detail: status.detail, hint: 'install it from https://cli.github.com' }
158
+ : { ok: false, detail: status.detail, hint: `install it from ${cli.installUrl}` }
149
159
  const ghAuth: DoctorCheck = status.authenticated
150
160
  ? { ok: true, detail: status.detail }
151
- : { ok: false, detail: status.detail, hint: 'run `gh auth login`' }
161
+ : { ok: false, detail: status.detail, hint: `run \`${cli.loginCommand}\`` }
152
162
 
153
163
  let dataDir: DoctorCheck
154
164
  try {
@@ -2,16 +2,25 @@
2
2
  // the canvas directory. The agent reads those two files; publish reads context.json back.
3
3
  import { appendFile, readdir, rm } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
- import { type GenerationContext, isLargePr, type PrepareTarget } from '../contract/generation-context.js'
5
+ import {
6
+ type GenerationContext,
7
+ isLargePr,
8
+ type PrepareTarget,
9
+ type PrepareTargetInput,
10
+ } from '../contract/generation-context.js'
6
11
  import { effectiveCaps, LIMITS, type Pr } from '../contract/review-artifact.js'
7
- import { fetchPrMeta, fetchPrRefs, toPr } from '../github/pr.js'
12
+ import type { LocalKey } from '../contract/review-key.js'
13
+ import { describeLocalWork, resolveLocalBase, UNCOMMITTED_STATE } from '../git/local-target.js'
14
+ import { fetchPrRefs } from '../git/pr-refs.js'
15
+
16
+ import { toPr } from '../host/pr.js'
8
17
  import type { AppContext } from '../server/context.js'
9
18
  import { readText, writeJsonAtomic, writeTextAtomic } from '../store/atomic-json.js'
10
19
  import { loadPromptSources, type PromptSources, renderPrompt } from './prompt.js'
11
20
 
12
21
  export interface PrepareOptions {
13
22
  force: boolean
14
- /** Progress lines: `fetch-pr`, `fetch-refs`, `collect-diffs`, `prompt`. */
23
+ /** Progress lines: `fetch-pr`, `fetch-refs`, `snapshot`, `collect-diffs`, `prompt`. */
15
24
  log: (phase: string) => void
16
25
  promptSources?: PromptSources
17
26
  }
@@ -23,16 +32,40 @@ export interface PrepareResult {
23
32
  promptPath: string
24
33
  contextPath: string
25
34
  status: 'prepared' | 'exists'
35
+ /** Local targets only: which review it is, the base resolved for it, and what its head holds. */
36
+ local?: { review: LocalKey; base: string; headRef: string; uncommitted: boolean }
26
37
  }
27
38
 
28
39
  /** The PR meta, live from GitHub, with the head and base refs fetched into the local clone. */
29
40
  async function resolvePr(ctx: AppContext, number: number, log: PrepareOptions['log']): Promise<Pr> {
30
41
  log('fetch-pr')
31
- const meta = await fetchPrMeta(ctx.gh, ctx.config.repo, number)
42
+ const meta = await ctx.config.host.fetchPrMeta(ctx.gh, ctx.config.repo, number)
32
43
  log('fetch-refs')
33
- const shas = await fetchPrRefs(ctx.git, meta)
44
+ const shas = await fetchPrRefs(ctx.git, ctx.config.host, meta)
34
45
  const pr = toPr(meta, ctx.config.repo, shas)
35
- await ctx.prs.writePr(pr)
46
+ await ctx.prs.writePr(number, pr)
47
+ return pr
48
+ }
49
+
50
+ /**
51
+ * The work in this clone that has no pull request yet: the current branch, or a snapshot commit
52
+ * of the working tree when it carries edits. The meta is cached under `prs/<branch|uncommitted>/`,
53
+ * which is what the matching page reads.
54
+ */
55
+ async function resolveLocal(
56
+ ctx: AppContext,
57
+ target: Extract<PrepareTarget, { kind: 'local' }>,
58
+ log: PrepareOptions['log']
59
+ ): Promise<Pr> {
60
+ log('snapshot')
61
+ const pr = await describeLocalWork(ctx.git, {
62
+ base: target.base,
63
+ source: target.source,
64
+ repo: ctx.config.repo,
65
+ now: ctx.now,
66
+ })
67
+ await ctx.prs.writePr(target.source, pr)
68
+ await ctx.prs.writeLocalTarget(target.source, target)
36
69
  return pr
37
70
  }
38
71
 
@@ -53,7 +86,7 @@ async function resolveRefs(
53
86
  title: head,
54
87
  body: '',
55
88
  author,
56
- url: `https://github.com/${repo.owner}/${repo.name}/compare/${base}...${head}`,
89
+ url: ctx.config.host.compareUrl(repo, base, head),
57
90
  state: 'pre-pr',
58
91
  draft: false,
59
92
  updatedAt: ctx.now().toISOString(),
@@ -98,25 +131,44 @@ async function clearCanvasDir(canvasDir: string): Promise<void> {
98
131
  }
99
132
  }
100
133
 
134
+ /** The target with its base resolved, which is the form `context.json` records. */
135
+ async function resolveTarget(ctx: AppContext, input: PrepareTargetInput): Promise<PrepareTarget> {
136
+ if (input.kind !== 'local') {
137
+ return input
138
+ }
139
+ return { kind: 'local', source: input.source, base: await resolveLocalBase(ctx.git, input.base) }
140
+ }
141
+
101
142
  export async function prepare(
102
143
  ctx: AppContext,
103
- target: PrepareTarget,
144
+ input: PrepareTargetInput,
104
145
  opts: PrepareOptions
105
146
  ): Promise<PrepareResult> {
147
+ const target = await resolveTarget(ctx, input)
106
148
  const pr =
107
149
  target.kind === 'pr'
108
150
  ? await resolvePr(ctx, target.number, opts.log)
109
- : await resolveRefs(ctx, target.base, target.head, opts.log)
151
+ : target.kind === 'local'
152
+ ? await resolveLocal(ctx, target, opts.log)
153
+ : await resolveRefs(ctx, target.base, target.head, opts.log)
110
154
  const canvasDir = ctx.canvases.canvasDir(pr.headSha)
111
155
  const promptPath = path.join(canvasDir, 'prompt.md')
112
156
  const contextPath = path.join(canvasDir, 'context.json')
113
- const result = {
157
+ const result: Omit<PrepareResult, 'status'> = {
114
158
  canvasDir,
115
159
  headSha: pr.headSha,
116
160
  mergeBaseSha: pr.mergeBaseSha,
117
161
  promptPath,
118
162
  contextPath,
119
163
  }
164
+ if (target.kind === 'local') {
165
+ result.local = {
166
+ review: target.source,
167
+ base: target.base,
168
+ headRef: pr.headRef,
169
+ uncommitted: pr.state === UNCOMMITTED_STATE,
170
+ }
171
+ }
120
172
  if (!opts.force && (await ctx.canvases.exists(pr.headSha))) {
121
173
  return { ...result, status: 'exists' }
122
174
  }
@@ -125,6 +177,8 @@ export async function prepare(
125
177
  const derived = await ctx.derived.ensure(pr.headSha, pr.mergeBaseSha)
126
178
  const additions = derived.files.reduce((n, f) => n + f.additions, 0)
127
179
  const deletions = derived.files.reduce((n, f) => n + f.deletions, 0)
180
+ // A pull request's counts are the forge's; anything else is counted from the diff itself. The
181
+ // page derives its own from the files it shows, so this is only what the canvas records.
128
182
  const fullPr: Pr =
129
183
  target.kind === 'pr' ? pr : { ...pr, additions, deletions, changedFiles: derived.files.length }
130
184
  const derivedDir = ctx.derived.derivedDir(pr.headSha)