@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
@@ -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
+ }
@@ -36,14 +36,16 @@ const capsShape = {
36
36
  diagram: cap(),
37
37
  } satisfies Record<keyof TextCaps, z.ZodOptional<z.ZodNumber>>
38
38
 
39
- export const PromptOverridesSchema = z.object({
40
- 'generation-format.md': z.string().min(1).optional(),
41
- 'generation-strict.md': z.string().min(1).optional(),
42
- 'generation-surfacing.md': z.string().min(1).optional(),
43
- 'quality-standards.md': z.string().min(1).optional(),
44
- 'layering-guidance.md': z.string().min(1).optional(),
45
- 'chat-seed.md': z.string().min(1).optional(),
46
- }).strict()
39
+ export const PromptOverridesSchema = z
40
+ .object({
41
+ 'generation-format.md': z.string().min(1).optional(),
42
+ 'generation-strict.md': z.string().min(1).optional(),
43
+ 'generation-surfacing.md': z.string().min(1).optional(),
44
+ 'quality-standards.md': z.string().min(1).optional(),
45
+ 'layering-guidance.md': z.string().min(1).optional(),
46
+ 'chat-seed.md': z.string().min(1).optional(),
47
+ })
48
+ .strict()
47
49
  export type PromptOverrides = z.infer<typeof PromptOverridesSchema>
48
50
 
49
51
  export const ProjectConfigSchema = z.object({
@@ -63,6 +65,13 @@ export const ProjectConfigSchema = z.object({
63
65
  /** Which paths count as tests, for the layering rules and the `isTest` flag on a file. */
64
66
  tests: z.object({ patterns: z.array(z.string().min(1)) }),
65
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
+ }),
66
75
  })
67
76
  export type ProjectConfig = z.infer<typeof ProjectConfigSchema>
68
77
 
@@ -84,6 +93,7 @@ const PartialProjectConfigSchema = z.object({
84
93
  .optional(),
85
94
  tests: z.object({ patterns: z.array(z.string().min(1)).optional() }).optional(),
86
95
  chat: z.object({ enabled: z.boolean().optional() }).optional(),
96
+ canvas: z.object({ keepForIdenticalDiff: z.boolean().optional() }).optional(),
87
97
  })
88
98
 
89
99
  export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
@@ -93,6 +103,7 @@ export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
93
103
  generation: { mode: 'strict', maxRepairRounds: 3, inlineDiffMaxLines: 1500, smallPrHunks: 10 },
94
104
  tests: { patterns: [...DEFAULT_TEST_PATTERNS] },
95
105
  chat: { enabled: true },
106
+ canvas: { keepForIdenticalDiff: true },
96
107
  }
97
108
 
98
109
  export interface LoadedProjectConfig {
@@ -119,7 +130,8 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
119
130
  const generation: ProjectConfig['generation'] = {
120
131
  mode: user.generation?.mode ?? DEFAULT_PROJECT_CONFIG.generation.mode,
121
132
  maxRepairRounds: user.generation?.maxRepairRounds ?? DEFAULT_PROJECT_CONFIG.generation.maxRepairRounds,
122
- inlineDiffMaxLines: user.generation?.inlineDiffMaxLines ?? DEFAULT_PROJECT_CONFIG.generation.inlineDiffMaxLines,
133
+ inlineDiffMaxLines:
134
+ user.generation?.inlineDiffMaxLines ?? DEFAULT_PROJECT_CONFIG.generation.inlineDiffMaxLines,
123
135
  smallPrHunks: user.generation?.smallPrHunks ?? DEFAULT_PROJECT_CONFIG.generation.smallPrHunks,
124
136
  }
125
137
  if (user.generation?.caps !== undefined) {
@@ -132,6 +144,10 @@ export function mergeProjectConfig(raw: unknown): { config: ProjectConfig; warni
132
144
  generation,
133
145
  tests: { patterns: user.tests?.patterns ?? [...DEFAULT_TEST_PATTERNS] },
134
146
  chat: { enabled: user.chat?.enabled ?? true },
147
+ canvas: {
148
+ keepForIdenticalDiff:
149
+ user.canvas?.keepForIdenticalDiff ?? DEFAULT_PROJECT_CONFIG.canvas.keepForIdenticalDiff,
150
+ },
135
151
  }
136
152
  if (user.rulebook !== undefined) {
137
153
  config.rulebook = user.rulebook
@@ -15,9 +15,10 @@ export async function loadPromptFile(
15
15
  project?: ProjectPrompts
16
16
  ): Promise<string> {
17
17
  const override = project?.overrides?.[name]
18
- const file = project !== undefined && override !== undefined
19
- ? path.resolve(project.repoRoot, override)
20
- : path.join(dir, name)
18
+ const file =
19
+ project !== undefined && override !== undefined
20
+ ? path.resolve(project.repoRoot, override)
21
+ : path.join(dir, name)
21
22
  try {
22
23
  return await readFile(file, 'utf8')
23
24
  } catch (cause) {
@@ -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. */
@@ -88,7 +93,11 @@ export async function checkSkill(
88
93
  }
89
94
  }
90
95
  if (stale.length > 0) {
91
- return { ok: false, detail: `outdated or modified skill: ${stale.join(', ')}`, hint: 'run `pr-review install-skill`' }
96
+ return {
97
+ ok: false,
98
+ detail: `outdated or modified skill: ${stale.join(', ')}`,
99
+ hint: 'run `pr-review install-skill`',
100
+ }
92
101
  }
93
102
  if (found.length === 0) {
94
103
  return {
@@ -113,7 +122,10 @@ async function checkAcpx(deps: DoctorDeps): Promise<DoctorCheck> {
113
122
  }
114
123
 
115
124
  /** Runs core checks and, with allChecks, checks acpx for AI Chat. */
116
- export async function runDoctorChecks(deps: DoctorDeps, options: { allChecks?: boolean } = {}): Promise<DoctorReport> {
125
+ export async function runDoctorChecks(
126
+ deps: DoctorDeps,
127
+ options: { allChecks?: boolean } = {}
128
+ ): Promise<DoctorReport> {
117
129
  let repoRoot: string | null = null
118
130
  let git: DoctorCheck
119
131
  try {
@@ -123,25 +135,30 @@ export async function runDoctorChecks(deps: DoctorDeps, options: { allChecks?: b
123
135
  git = { ok: false, detail: message(err), hint: 'run from a clone or pass --repo <dir>' }
124
136
  }
125
137
 
138
+ let host = GITHUB_HOST
126
139
  let origin: DoctorCheck
127
140
  try {
128
141
  const url = await deps.git.remoteUrl('origin')
129
- const repo = url === null ? null : parseGithubRemote(url)
130
- origin =
131
- repo === null
132
- ? { ok: false, detail: url ?? 'no origin remote', hint: 'add a github.com origin' }
133
- : { 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
+ }
134
149
  } catch (err) {
135
- origin = { ok: false, detail: message(err), hint: 'add a github.com origin' }
150
+ origin = { ok: false, detail: message(err), hint: ORIGIN_HINT }
136
151
  }
137
152
 
138
- 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]
139
156
  const gh: DoctorCheck = status.installed
140
157
  ? { ok: true, detail: status.detail }
141
- : { 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}` }
142
159
  const ghAuth: DoctorCheck = status.authenticated
143
160
  ? { ok: true, detail: status.detail }
144
- : { ok: false, detail: status.detail, hint: 'run `gh auth login`' }
161
+ : { ok: false, detail: status.detail, hint: `run \`${cli.loginCommand}\`` }
145
162
 
146
163
  let dataDir: DoctorCheck
147
164
  try {
@@ -195,7 +195,9 @@ export function artifactToModelOutput(artifact: ReviewArtifact): ModelOutput {
195
195
  summary: artifact.summary,
196
196
  layers: artifact.layers.map(layer => {
197
197
  const { id: _id, risk, files, ...rest } = layer
198
- const modelRisk = risk.filter(r => r.source === 'model').map(r => ({ label: r.label, reason: r.reason ?? '' }))
198
+ const modelRisk = risk
199
+ .filter(r => r.source === 'model')
200
+ .map(r => ({ label: r.label, reason: r.reason ?? '' }))
199
201
  const out: ModelLayer = { ...rest, files: files.map(({ isTest: _isTest, ...f }) => f) }
200
202
  if (modelRisk.length > 0) {
201
203
  out.risk = modelRisk