@vintasoftware/pr-review-canvas 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -26
- package/docs/reference.md +231 -121
- package/package.json +18 -4
- package/pr-review.config.example.yml +10 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +77 -40
- package/src/acpx/acpx.ts +21 -4
- package/src/acpx/events.ts +5 -1
- package/src/acpx/ndjson.ts +3 -1
- package/src/acpx/preflight.ts +5 -1
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/export.ts +11 -2
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +25 -2
- package/src/chat/chat-manager.ts +55 -41
- package/src/chat/context.ts +10 -3
- package/src/chat/seed.ts +3 -1
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +24 -9
- package/src/commands.ts +86 -25
- package/src/config.ts +24 -24
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +8 -1
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +27 -2
- package/src/contract/review-artifact.ts +14 -8
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +17 -0
- package/src/contract/settings.ts +2 -0
- package/src/contract/state.ts +41 -19
- package/src/git/diff-collector.ts +2 -1
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +117 -12
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -37
- package/src/github/comments.ts +9 -27
- package/src/github/post-comment.ts +7 -37
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -84
- package/src/github/threads.ts +6 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +54 -0
- package/src/gitlab/project.ts +13 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +245 -0
- package/src/host/host.ts +136 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +25 -9
- package/src/prompt-files.ts +4 -3
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +31 -14
- package/src/review/normalize.ts +3 -1
- package/src/review/prepare.ts +87 -17
- package/src/review/prompt.ts +8 -2
- package/src/review/publish.ts +76 -13
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/trim-caps.ts +10 -6
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +67 -15
- package/src/server/app.ts +12 -4
- package/src/server/bundle.ts +320 -106
- package/src/server/context.ts +24 -10
- package/src/server/errors.ts +56 -10
- package/src/server/html.ts +36 -12
- package/src/server/node-server.ts +4 -2
- package/src/server/routes/api.ts +92 -31
- package/src/server/routes/chat-routes.ts +105 -46
- package/src/server/routes/pages.ts +25 -9
- package/src/server/routes/review-routes.ts +101 -35
- package/src/server/sse.ts +3 -1
- package/src/store/atomic-json.ts +5 -1
- package/src/store/canvas-store.ts +95 -46
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -23
- package/src/store/pr-store.ts +25 -15
- package/src/store/state-store.ts +42 -37
- package/static/brand.svg +19 -0
- package/static/js/api.js +47 -27
- package/static/js/app.js +27 -7
- package/static/js/chat-panel.js +93 -0
- package/static/js/chat.js +41 -17
- package/static/js/composer.js +30 -17
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diagram.js +2 -1
- package/static/js/diff-decorations.js +5 -3
- package/static/js/diff-renderer.js +7 -2
- package/static/js/dom.js +5 -7
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -16
- package/static/js/errors.js +22 -6
- package/static/js/header.js +32 -9
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +56 -17
- package/static/js/keyboard.js +7 -2
- package/static/js/layers.js +26 -14
- package/static/js/links.js +9 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +5 -2
- package/static/js/overview.js +32 -4
- package/static/js/points.js +5 -3
- package/static/js/progress.js +2 -1
- package/static/js/proposed-comment.js +4 -1
- package/static/js/quick-questions.js +2 -1
- package/static/js/regenerate.js +4 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +9 -6
- package/static/styles/base.css +16 -6
- package/static/styles/chat-panel.css +81 -0
- package/static/styles/chat-tools.css +28 -0
- package/static/styles/chat.css +1 -1
- package/static/styles/commands.css +10 -4
- package/static/styles/diff.css +1 -1
- package/static/styles/header.css +18 -4
- package/static/styles/layout.css +4 -4
- package/static/styles/panels.css +4 -0
- package/static/styles/responsive.css +1 -15
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +24 -3
- package/static/styles/skin-github.css +99 -100
- package/static/styles.css +13 -12
- package/src/github/gh.ts +0 -196
package/src/host/host.ts
ADDED
|
@@ -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
|
+
}
|
package/src/project-config.ts
CHANGED
|
@@ -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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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:
|
|
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
|
package/src/prompt-files.ts
CHANGED
|
@@ -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 =
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
}
|
package/src/review/doctor.ts
CHANGED
|
@@ -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 {
|
|
6
|
+
import { ORIGIN_HINT } from '../config.js'
|
|
7
7
|
import type { Git } from '../git/git.js'
|
|
8
|
-
import type
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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:
|
|
150
|
+
origin = { ok: false, detail: message(err), hint: ORIGIN_HINT }
|
|
136
151
|
}
|
|
137
152
|
|
|
138
|
-
|
|
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:
|
|
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:
|
|
161
|
+
: { ok: false, detail: status.detail, hint: `run \`${cli.loginCommand}\`` }
|
|
145
162
|
|
|
146
163
|
let dataDir: DoctorCheck
|
|
147
164
|
try {
|
package/src/review/normalize.ts
CHANGED
|
@@ -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
|
|
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
|