@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.
- package/README.md +52 -23
- package/docs/reference.md +154 -44
- package/package.json +9 -4
- package/pr-review.config.example.yml +6 -0
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +3 -0
- package/skills/pr-review-canvas/SKILL.md +69 -39
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +21 -1
- package/src/chat/chat-manager.ts +40 -39
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +12 -7
- package/src/commands.ts +45 -16
- package/src/config.ts +14 -26
- package/src/contract/api.ts +32 -1
- package/src/contract/canvas-manifest.ts +2 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +24 -1
- 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 -21
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +109 -9
- 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 -41
- package/src/github/comments.ts +3 -24
- package/src/github/post-comment.ts +3 -36
- package/src/github/post-review.ts +4 -19
- package/src/github/pr.ts +6 -87
- package/src/github/threads.ts +2 -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 +13 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +22 -12
- package/src/review/prepare.ts +64 -10
- package/src/review/publish.ts +61 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/validate-folds.ts +2 -2
- package/src/review/validate.ts +4 -4
- package/src/server/bundle.ts +312 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +34 -10
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +76 -38
- package/src/server/routes/pages.ts +22 -8
- package/src/server/routes/review-routes.ts +84 -33
- package/src/store/canvas-store.ts +90 -55
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -27
- package/src/store/pr-store.ts +23 -14
- package/src/store/state-store.ts +40 -36
- package/static/js/api.js +32 -24
- package/static/js/app.js +24 -10
- package/static/js/chat.js +3 -2
- package/static/js/composer.js +24 -14
- package/static/js/contract-types.d.ts +3 -0
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +85 -18
- package/static/js/errors.js +22 -6
- package/static/js/header.js +31 -8
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +1 -2
- package/static/js/layers.js +3 -3
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/points.js +2 -1
- package/static/js/review-session.js +7 -2
- package/static/js/settings.js +2 -1
- package/static/js/signoff.js +6 -7
- package/static/styles/commands.css +6 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/skin-github.css +7 -1
- package/src/github/gh.ts +0 -211
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
|
@@ -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
|
+
}
|
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. */
|
|
@@ -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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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:
|
|
150
|
+
origin = { ok: false, detail: message(err), hint: ORIGIN_HINT }
|
|
143
151
|
}
|
|
144
152
|
|
|
145
|
-
|
|
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:
|
|
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:
|
|
161
|
+
: { ok: false, detail: status.detail, hint: `run \`${cli.loginCommand}\`` }
|
|
152
162
|
|
|
153
163
|
let dataDir: DoctorCheck
|
|
154
164
|
try {
|
package/src/review/prepare.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
:
|
|
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)
|