@vintasoftware/pr-review-canvas 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -31
- package/docs/reference.md +396 -101
- package/package.json +9 -4
- package/pr-review.config.example.yml +44 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +123 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +81 -42
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- 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 +67 -40
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +70 -8
- package/src/commands.ts +50 -17
- package/src/config.ts +14 -26
- package/src/contract/api.ts +58 -2
- package/src/contract/canvas-manifest.ts +7 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +76 -2
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +26 -0
- package/src/contract/settings.ts +7 -0
- package/src/contract/state.ts +53 -22
- package/src/contract/validation.ts +1 -0
- 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 +64 -23
- 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 +93 -0
- package/src/gitlab/project.ts +13 -0
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +246 -0
- package/src/host/host.ts +154 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +26 -0
- package/src/review/carry-marks.ts +131 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +82 -38
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +111 -10
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +62 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +349 -24
- package/src/review/validate.ts +14 -5
- package/src/server/bundle.ts +324 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +35 -11
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +91 -44
- package/src/server/routes/pages.ts +26 -9
- package/src/server/routes/review-routes.ts +278 -67
- package/src/store/canvas-store.ts +93 -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/settings-store.ts +9 -1
- package/src/store/state-store.ts +105 -36
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +87 -25
- package/static/js/app.js +50 -15
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +30 -6
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +132 -17
- package/static/js/contract-types.d.ts +7 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +102 -18
- package/static/js/errors.js +22 -6
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +65 -15
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +274 -46
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +295 -32
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +70 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +113 -29
- package/static/js/settings.js +55 -24
- package/static/js/signoff.js +79 -10
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/commands.css +6 -0
- package/static/styles/header.css +21 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +4 -0
- package/static/styles/skin-github.css +7 -1
- package/static/styles.css +1 -0
- package/src/github/gh.ts +0 -211
package/src/github/pr.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import
|
|
3
|
-
import type {
|
|
4
|
-
import {
|
|
2
|
+
import { PrNotFoundError, type PrMeta } from '../host/pr.js'
|
|
3
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
4
|
+
import { type HostClient, HostCliError } from '../host/client.js'
|
|
5
5
|
|
|
6
6
|
/** The subset of GitHub's pull request object the tool reads. */
|
|
7
7
|
const GhPullSchema = z.object({
|
|
@@ -22,35 +22,6 @@ const GhPullSchema = z.object({
|
|
|
22
22
|
head: z.object({ ref: z.string(), sha: z.string() }),
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
export class PrNotFoundError extends Error {
|
|
26
|
-
readonly number: number
|
|
27
|
-
|
|
28
|
-
constructor(number: number) {
|
|
29
|
-
super(`pull request #${number} not found`)
|
|
30
|
-
this.name = 'PrNotFoundError'
|
|
31
|
-
this.number = number
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface PrMeta {
|
|
36
|
-
number: number
|
|
37
|
-
title: string
|
|
38
|
-
body: string
|
|
39
|
-
author: string
|
|
40
|
-
url: string
|
|
41
|
-
state: string
|
|
42
|
-
draft: boolean
|
|
43
|
-
updatedAt: string
|
|
44
|
-
baseRef: string
|
|
45
|
-
headRef: string
|
|
46
|
-
headSha: string
|
|
47
|
-
/** The commit that merged the PR into its base, once merged. */
|
|
48
|
-
mergeCommitSha: string | null
|
|
49
|
-
additions: number
|
|
50
|
-
deletions: number
|
|
51
|
-
changedFiles: number
|
|
52
|
-
}
|
|
53
|
-
|
|
54
25
|
export function mapPull(raw: unknown): PrMeta {
|
|
55
26
|
const p = GhPullSchema.parse(raw)
|
|
56
27
|
return {
|
|
@@ -72,65 +43,13 @@ export function mapPull(raw: unknown): PrMeta {
|
|
|
72
43
|
}
|
|
73
44
|
}
|
|
74
45
|
|
|
75
|
-
export async function fetchPrMeta(
|
|
46
|
+
export async function fetchPrMeta(client: HostClient, repo: Repo, number: number): Promise<PrMeta> {
|
|
76
47
|
try {
|
|
77
|
-
return mapPull(await
|
|
48
|
+
return mapPull(await client.api(`repos/${repo.owner}/${repo.name}/pulls/${number}`))
|
|
78
49
|
} catch (err) {
|
|
79
|
-
if (err instanceof
|
|
50
|
+
if (err instanceof HostCliError && err.notFound) {
|
|
80
51
|
throw new PrNotFoundError(number)
|
|
81
52
|
}
|
|
82
53
|
throw err
|
|
83
54
|
}
|
|
84
55
|
}
|
|
85
|
-
|
|
86
|
-
export function prHeadRef(number: number): string {
|
|
87
|
-
return `refs/pr/${number}/head`
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function prBaseRef(number: number): string {
|
|
91
|
-
return `refs/pr/${number}/base`
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Fetches the PR head and base into local refs and resolves the two commits the diff needs.
|
|
96
|
-
* The refs are the same ones the prior-art skill used, so an existing clone keeps working.
|
|
97
|
-
*
|
|
98
|
-
* A merged PR is diffed against the base as it was at merge time: the merge commit's first
|
|
99
|
-
* parent (a merge commit, a squash, or the last rebased commit all sit on the base branch, so
|
|
100
|
-
* it is local once the base was fetched). Today's base tip would contain the PR and give an
|
|
101
|
-
* empty diff.
|
|
102
|
-
*/
|
|
103
|
-
export async function fetchPrRefs(
|
|
104
|
-
git: Git,
|
|
105
|
-
meta: PrMeta
|
|
106
|
-
): Promise<{ headSha: string; mergeBaseSha: string }> {
|
|
107
|
-
await git.fetch('origin', [
|
|
108
|
-
`+pull/${meta.number}/head:${prHeadRef(meta.number)}`,
|
|
109
|
-
`+refs/heads/${meta.baseRef}:${prBaseRef(meta.number)}`,
|
|
110
|
-
])
|
|
111
|
-
const headSha = await git.revParse(prHeadRef(meta.number))
|
|
112
|
-
const base = meta.mergeCommitSha === null ? prBaseRef(meta.number) : `${meta.mergeCommitSha}^1`
|
|
113
|
-
const mergeBaseSha = await git.mergeBase(base, headSha)
|
|
114
|
-
return { headSha, mergeBaseSha }
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function toPr(meta: PrMeta, repo: Repo, shas: { headSha: string; mergeBaseSha: string }): Pr {
|
|
118
|
-
return {
|
|
119
|
-
number: meta.number,
|
|
120
|
-
title: meta.title,
|
|
121
|
-
body: meta.body,
|
|
122
|
-
author: meta.author,
|
|
123
|
-
url: meta.url,
|
|
124
|
-
state: meta.state,
|
|
125
|
-
draft: meta.draft,
|
|
126
|
-
updatedAt: meta.updatedAt,
|
|
127
|
-
baseRef: meta.baseRef,
|
|
128
|
-
headRef: meta.headRef,
|
|
129
|
-
headSha: shas.headSha,
|
|
130
|
-
mergeBaseSha: shas.mergeBaseSha,
|
|
131
|
-
additions: meta.additions,
|
|
132
|
-
deletions: meta.deletions,
|
|
133
|
-
changedFiles: meta.changedFiles,
|
|
134
|
-
repo,
|
|
135
|
-
}
|
|
136
|
-
}
|
package/src/github/threads.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import type { Repo } from '../contract/review-artifact.js'
|
|
3
|
-
import type {
|
|
3
|
+
import type { HostClient } from '../host/client.js'
|
|
4
4
|
|
|
5
5
|
const ThreadsPageSchema = z.object({
|
|
6
6
|
repository: z.object({
|
|
@@ -34,7 +34,7 @@ export const THREADS_QUERY = `query($owner: String!, $name: String!, $number: In
|
|
|
34
34
|
* so this one GraphQL query is joined to the REST comments by id.
|
|
35
35
|
*/
|
|
36
36
|
export async function fetchResolvedCommentIds(
|
|
37
|
-
gh:
|
|
37
|
+
gh: HostClient,
|
|
38
38
|
repo: Repo,
|
|
39
39
|
number: number
|
|
40
40
|
): Promise<Set<number>> {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
2
|
+
import type { HostAttachments } from '../host/host.js'
|
|
3
|
+
|
|
4
|
+
const MARKDOWN_LINK_RE = /\[([A-Za-z0-9._-]+\.zip)\]\(([^)\s]+)\)/g
|
|
5
|
+
const UPLOAD_URL_RE = /https?:\/\/[^\s)]+\/uploads\/[A-Za-z0-9/_-]+\/([A-Za-z0-9._-]+\.zip)/gi
|
|
6
|
+
const UPLOAD_PATH_RE = /(?:^|[\s(])(\/uploads\/[A-Za-z0-9/_-]+\/([A-Za-z0-9._-]+\.zip))/g
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Zip links in GitLab markdown: `[name.zip](url)`, a project-relative `/uploads/.../name.zip`
|
|
10
|
+
* path as the editor inserts it, or a full URL on this instance. The instance serves its uploads
|
|
11
|
+
* itself, so that one hostname is the whole allow list and the token goes in a Bearer header.
|
|
12
|
+
*/
|
|
13
|
+
export function gitlabAttachments(hostname: string, webBase: string): HostAttachments {
|
|
14
|
+
const resolve = (raw: string, repo: Repo): string | null => {
|
|
15
|
+
const projectBase = `${webBase}/${repo.owner}/${repo.name}`
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(raw.startsWith('/uploads/') ? `${projectBase}${raw}` : raw, `${projectBase}/`)
|
|
18
|
+
return url.protocol === 'https:' && url.host === hostname ? url.toString() : null
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
findLinks(text, repo) {
|
|
25
|
+
const found = new Map<string, string>()
|
|
26
|
+
const add = (href: string | undefined, name: string | undefined): void => {
|
|
27
|
+
const url = href === undefined || name === undefined ? null : resolve(href, repo)
|
|
28
|
+
if (url !== null && !found.has(url)) {
|
|
29
|
+
found.set(url, name as string)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const m of text.matchAll(MARKDOWN_LINK_RE)) add(m[2], m[1])
|
|
33
|
+
for (const m of text.matchAll(UPLOAD_URL_RE)) add(m[0], m[1])
|
|
34
|
+
for (const m of text.matchAll(UPLOAD_PATH_RE)) add(m[1], m[2])
|
|
35
|
+
return [...found].map(([url, name]) => ({ url, name }))
|
|
36
|
+
},
|
|
37
|
+
allowedHosts: new Set([hostname]),
|
|
38
|
+
authHeader: token => ({ authorization: `Bearer ${token}` }),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { CANVAS_COMMENT_MARKER } from '../canvas/comment.js'
|
|
3
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
4
|
+
import { fetchAllPages, type HostClient } from '../host/client.js'
|
|
5
|
+
import { GlNoteSchema } from './comments.js'
|
|
6
|
+
import { gitlabNoteUrl, gitlabProjectApi } from './project.js'
|
|
7
|
+
|
|
8
|
+
export async function shareGitlabCanvas(
|
|
9
|
+
client: HostClient,
|
|
10
|
+
repo: Repo,
|
|
11
|
+
number: number,
|
|
12
|
+
body: string,
|
|
13
|
+
webBase: string
|
|
14
|
+
): Promise<string> {
|
|
15
|
+
const user = z.object({ username: z.string() }).parse(await client.api('user'))
|
|
16
|
+
const base = `${gitlabProjectApi(repo)}/merge_requests/${number}/notes`
|
|
17
|
+
const notes = (await fetchAllPages(client, base)).map(raw => GlNoteSchema.parse(raw))
|
|
18
|
+
const existing = notes.find(
|
|
19
|
+
n => n.author?.username === user.username && n.body?.includes(CANVAS_COMMENT_MARKER)
|
|
20
|
+
)
|
|
21
|
+
const response =
|
|
22
|
+
existing === undefined
|
|
23
|
+
? await client.post(base, { body })
|
|
24
|
+
: await client.post(`${base}/${existing.id}`, { body }, 'PUT')
|
|
25
|
+
return gitlabNoteUrl(webBase, repo, number, GlNoteSchema.parse(response).id)
|
|
26
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { Capabilities } from '../contract/api.js'
|
|
3
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
4
|
+
import type { HostClient } from '../host/client.js'
|
|
5
|
+
import { gitlabProjectApi } from './project.js'
|
|
6
|
+
|
|
7
|
+
const UserSchema = z.object({ username: z.string() })
|
|
8
|
+
const AccessSchema = z.object({ access_level: z.number().int() }).nullable().optional()
|
|
9
|
+
const ProjectSchema = z.object({
|
|
10
|
+
permissions: z.object({ project_access: AccessSchema, group_access: AccessSchema }).optional(),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
/** Reporter (20) and above can comment on merge requests. */
|
|
14
|
+
export const GITLAB_COMMENT_ACCESS_LEVEL = 20
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether this login may post on this project, from the access level the project object reports
|
|
18
|
+
* for the token. A token that gets no permissions block is one this check cannot read: posting
|
|
19
|
+
* stays enabled and GitLab's own answer decides.
|
|
20
|
+
*/
|
|
21
|
+
export function decideGitlabCapabilities(login: string | null, projectRaw: unknown): Capabilities {
|
|
22
|
+
const permissions = ProjectSchema.safeParse(projectRaw).data?.permissions
|
|
23
|
+
if (permissions === undefined) {
|
|
24
|
+
return {
|
|
25
|
+
canComment: 'unknown',
|
|
26
|
+
tokenKind: 'glab',
|
|
27
|
+
login,
|
|
28
|
+
reason: 'this GitLab token does not report project permissions, so posting is tried and GitLab decides',
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const level = Math.max(
|
|
32
|
+
permissions.project_access?.access_level ?? 0,
|
|
33
|
+
permissions.group_access?.access_level ?? 0
|
|
34
|
+
)
|
|
35
|
+
if (level < GITLAB_COMMENT_ACCESS_LEVEL) {
|
|
36
|
+
return {
|
|
37
|
+
canComment: false,
|
|
38
|
+
tokenKind: 'glab',
|
|
39
|
+
login,
|
|
40
|
+
reason: 'this login cannot comment on merge requests in this project',
|
|
41
|
+
hint: 'ask for Reporter access or higher',
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { canComment: true, tokenKind: 'glab', login }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The probe itself: who the token belongs to, and what it may do here. */
|
|
48
|
+
export async function probeGitlabCapabilities(client: HostClient, repo: Repo): Promise<Capabilities> {
|
|
49
|
+
const login = await client
|
|
50
|
+
.api('user')
|
|
51
|
+
.then(raw => UserSchema.parse(raw).username)
|
|
52
|
+
.catch(() => null)
|
|
53
|
+
try {
|
|
54
|
+
return decideGitlabCapabilities(login, await client.api(gitlabProjectApi(repo)))
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return {
|
|
57
|
+
canComment: false,
|
|
58
|
+
tokenKind: 'unknown',
|
|
59
|
+
login,
|
|
60
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
61
|
+
hint: 'run `glab auth status` and log in again',
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { IssueComment, ReviewComment } from '../contract/comments.js'
|
|
3
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
4
|
+
import type { FetchCommentsResult } from '../contract/comments.js'
|
|
5
|
+
import { fetchAllPages, type HostClient } from '../host/client.js'
|
|
6
|
+
import { gitlabNoteUrl, gitlabProjectApi } from './project.js'
|
|
7
|
+
|
|
8
|
+
const GlPositionSchema = z.object({
|
|
9
|
+
new_path: z.string().nullable().optional(),
|
|
10
|
+
old_path: z.string().nullable().optional(),
|
|
11
|
+
new_line: z.number().int().nullable().optional(),
|
|
12
|
+
old_line: z.number().int().nullable().optional(),
|
|
13
|
+
head_sha: z.string().optional(),
|
|
14
|
+
line_range: z
|
|
15
|
+
.object({
|
|
16
|
+
start: z
|
|
17
|
+
.object({
|
|
18
|
+
new_line: z.number().int().nullable().optional(),
|
|
19
|
+
old_line: z.number().int().nullable().optional(),
|
|
20
|
+
})
|
|
21
|
+
.optional(),
|
|
22
|
+
})
|
|
23
|
+
.nullable()
|
|
24
|
+
.optional(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/** The subset of a GitLab note the tool reads. A note on a diff line carries a `position`. */
|
|
28
|
+
export const GlNoteSchema = z.object({
|
|
29
|
+
id: z.number().int(),
|
|
30
|
+
body: z.string().nullable().optional(),
|
|
31
|
+
system: z.boolean().optional(),
|
|
32
|
+
author: z.object({ username: z.string(), avatar_url: z.string().optional() }).nullable().optional(),
|
|
33
|
+
created_at: z.string(),
|
|
34
|
+
updated_at: z.string().optional(),
|
|
35
|
+
resolved: z.boolean().optional(),
|
|
36
|
+
position: GlPositionSchema.nullable().optional(),
|
|
37
|
+
})
|
|
38
|
+
type GlNote = z.infer<typeof GlNoteSchema>
|
|
39
|
+
type GlPosition = z.infer<typeof GlPositionSchema>
|
|
40
|
+
|
|
41
|
+
const GlDiscussionSchema = z.object({ id: z.string(), notes: z.array(GlNoteSchema) })
|
|
42
|
+
|
|
43
|
+
function isDiffNote(note: GlNote): boolean {
|
|
44
|
+
return Boolean(note.position?.new_path || note.position?.old_path)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Which side and line the note sits on, and where a range starts when it spans more than one line. */
|
|
48
|
+
function sideAndLine(
|
|
49
|
+
pos: GlPosition
|
|
50
|
+
): Pick<ReviewComment, 'side' | 'line' | 'originalLine'> & { startLine?: number } {
|
|
51
|
+
const side = pos.new_line !== null && pos.new_line !== undefined ? 'new' : 'old'
|
|
52
|
+
const line = (side === 'new' ? pos.new_line : pos.old_line) ?? null
|
|
53
|
+
const start = pos.line_range?.start
|
|
54
|
+
const startLine = (side === 'new' ? start?.new_line : start?.old_line) ?? null
|
|
55
|
+
return {
|
|
56
|
+
side,
|
|
57
|
+
line,
|
|
58
|
+
originalLine: pos.old_line ?? pos.new_line ?? null,
|
|
59
|
+
...(startLine !== null && startLine !== line ? { startLine } : {}),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface NoteContext {
|
|
64
|
+
webBase: string
|
|
65
|
+
repo: Repo
|
|
66
|
+
iid: number
|
|
67
|
+
headSha: string
|
|
68
|
+
inReplyToId?: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function mapGitLabReviewComment(note: GlNote, ctx: NoteContext): ReviewComment {
|
|
72
|
+
const pos = note.position ?? null
|
|
73
|
+
const loc = pos === null ? { side: 'new' as const, line: null, originalLine: null } : sideAndLine(pos)
|
|
74
|
+
const comment: ReviewComment = {
|
|
75
|
+
...mapGitLabIssueComment(note, ctx),
|
|
76
|
+
path: pos?.new_path || pos?.old_path || '',
|
|
77
|
+
line: loc.line,
|
|
78
|
+
originalLine: loc.originalLine,
|
|
79
|
+
side: loc.side,
|
|
80
|
+
// GitLab keeps the note's position on the head it was made for; a note whose head is not the
|
|
81
|
+
// current one, or that lost its line, is about code the diff no longer shows.
|
|
82
|
+
outdated: loc.line === null || (pos?.head_sha !== undefined && pos.head_sha !== ctx.headSha),
|
|
83
|
+
commitId: pos?.head_sha ?? ctx.headSha,
|
|
84
|
+
resolved: note.resolved === true,
|
|
85
|
+
}
|
|
86
|
+
if (loc.startLine !== undefined) {
|
|
87
|
+
comment.startLine = loc.startLine
|
|
88
|
+
}
|
|
89
|
+
if (ctx.inReplyToId !== undefined) {
|
|
90
|
+
comment.inReplyToId = ctx.inReplyToId
|
|
91
|
+
}
|
|
92
|
+
return comment
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function mapGitLabIssueComment(
|
|
96
|
+
note: GlNote,
|
|
97
|
+
ctx: Pick<NoteContext, 'webBase' | 'repo' | 'iid'>
|
|
98
|
+
): IssueComment {
|
|
99
|
+
return {
|
|
100
|
+
id: note.id,
|
|
101
|
+
author: note.author?.username ?? 'ghost',
|
|
102
|
+
...(note.author?.avatar_url ? { avatarUrl: note.author.avatar_url } : {}),
|
|
103
|
+
body: note.body ?? '',
|
|
104
|
+
createdAt: note.created_at,
|
|
105
|
+
updatedAt: note.updated_at ?? note.created_at,
|
|
106
|
+
url: gitlabNoteUrl(ctx.webBase, ctx.repo, ctx.iid, note.id),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Every discussion of the merge request that the tool can read; anything else GitLab lists is skipped. */
|
|
111
|
+
async function fetchDiscussions(client: HostClient, repo: Repo, number: number) {
|
|
112
|
+
const raw = await fetchAllPages(client, `${gitlabProjectApi(repo)}/merge_requests/${number}/discussions`)
|
|
113
|
+
return raw.flatMap(item => {
|
|
114
|
+
const parsed = GlDiscussionSchema.safeParse(item)
|
|
115
|
+
return parsed.success ? [parsed.data] : []
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* GitLab has one list of discussions; each is a thread on a diff line or a thread of merge request
|
|
121
|
+
* notes. The former become review comments with the thread's first note as the parent, the latter
|
|
122
|
+
* issue comments. System notes (assigned, pushed) are not comments.
|
|
123
|
+
*/
|
|
124
|
+
export async function fetchGitlabComments(
|
|
125
|
+
client: HostClient,
|
|
126
|
+
repo: Repo,
|
|
127
|
+
number: number,
|
|
128
|
+
headSha: string,
|
|
129
|
+
now: () => Date,
|
|
130
|
+
webBase: string
|
|
131
|
+
): Promise<FetchCommentsResult> {
|
|
132
|
+
const reviewComments: ReviewComment[] = []
|
|
133
|
+
const issueComments: IssueComment[] = []
|
|
134
|
+
const ctx: NoteContext = { webBase, repo, iid: number, headSha }
|
|
135
|
+
for (const discussion of await fetchDiscussions(client, repo, number)) {
|
|
136
|
+
const [first, ...replies] = discussion.notes.filter(n => n.system !== true)
|
|
137
|
+
if (first === undefined) {
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
if (isDiffNote(first)) {
|
|
141
|
+
reviewComments.push(
|
|
142
|
+
mapGitLabReviewComment(first, ctx),
|
|
143
|
+
...replies.map(note => mapGitLabReviewComment(note, { ...ctx, inReplyToId: first.id }))
|
|
144
|
+
)
|
|
145
|
+
} else {
|
|
146
|
+
issueComments.push(...[first, ...replies].map(note => mapGitLabIssueComment(note, ctx)))
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
payload: { fetchedAt: now().toISOString(), headSha, reviewComments, issueComments },
|
|
151
|
+
warnings: [],
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Finds the discussion that contains a note, so a reply can be posted to that thread. */
|
|
156
|
+
export async function findDiscussionId(
|
|
157
|
+
client: HostClient,
|
|
158
|
+
repo: Repo,
|
|
159
|
+
number: number,
|
|
160
|
+
noteId: number
|
|
161
|
+
): Promise<string | null> {
|
|
162
|
+
const discussions = await fetchDiscussions(client, repo, number)
|
|
163
|
+
return discussions.find(d => d.notes.some(n => n.id === noteId))?.id ?? null
|
|
164
|
+
}
|
package/src/gitlab/mr.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { Repo } from '../contract/review-artifact.js'
|
|
3
|
+
import { type PrMeta, PrNotFoundError } from '../host/pr.js'
|
|
4
|
+
import { type HostClient, HostCliError } from '../host/client.js'
|
|
5
|
+
import { gitlabProjectApi } from './project.js'
|
|
6
|
+
|
|
7
|
+
/** The subset of GitLab's merge request object the tool reads. */
|
|
8
|
+
const GlMergeRequestSchema = z.object({
|
|
9
|
+
iid: z.number().int(),
|
|
10
|
+
title: z.string(),
|
|
11
|
+
description: z.string().nullable(),
|
|
12
|
+
web_url: z.string(),
|
|
13
|
+
state: z.enum(['opened', 'closed', 'locked', 'merged']),
|
|
14
|
+
draft: z.boolean().optional(),
|
|
15
|
+
updated_at: z.string(),
|
|
16
|
+
merge_commit_sha: z.string().nullable().optional(),
|
|
17
|
+
target_branch: z.string(),
|
|
18
|
+
source_branch: z.string(),
|
|
19
|
+
sha: z.string(),
|
|
20
|
+
/** The number of changed files, which REST reports as a string ("7" or "1000+"). */
|
|
21
|
+
changes_count: z.string().nullable().optional(),
|
|
22
|
+
author: z.object({ username: z.string() }).nullable(),
|
|
23
|
+
diff_refs: z
|
|
24
|
+
.object({ base_sha: z.string(), head_sha: z.string(), start_sha: z.string() })
|
|
25
|
+
.nullable()
|
|
26
|
+
.optional(),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** Line counts live in GraphQL only; REST has the file count. */
|
|
30
|
+
export const MR_STATS_QUERY = `query($path: ID!, $iid: String!) {
|
|
31
|
+
project(fullPath: $path) {
|
|
32
|
+
mergeRequest(iid: $iid) {
|
|
33
|
+
diffStatsSummary { additions deletions }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}`
|
|
37
|
+
|
|
38
|
+
const DiffStatsSchema = z.object({
|
|
39
|
+
project: z
|
|
40
|
+
.object({
|
|
41
|
+
mergeRequest: z
|
|
42
|
+
.object({
|
|
43
|
+
diffStatsSummary: z.object({ additions: z.number().int(), deletions: z.number().int() }).nullable(),
|
|
44
|
+
})
|
|
45
|
+
.nullable(),
|
|
46
|
+
})
|
|
47
|
+
.nullable(),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** The three commits GitLab anchors an inline comment to. */
|
|
51
|
+
export type MrDiffRefs = NonNullable<z.infer<typeof GlMergeRequestSchema>['diff_refs']>
|
|
52
|
+
|
|
53
|
+
const STATES: Record<z.infer<typeof GlMergeRequestSchema>['state'], string> = {
|
|
54
|
+
opened: 'open',
|
|
55
|
+
closed: 'closed',
|
|
56
|
+
locked: 'closed',
|
|
57
|
+
merged: 'merged',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function mapMergeRequest(raw: unknown, stats: { additions: number; deletions: number }): PrMeta {
|
|
61
|
+
const m = GlMergeRequestSchema.parse(raw)
|
|
62
|
+
return {
|
|
63
|
+
number: m.iid,
|
|
64
|
+
title: m.title,
|
|
65
|
+
body: m.description ?? '',
|
|
66
|
+
author: m.author?.username ?? 'ghost',
|
|
67
|
+
url: m.web_url,
|
|
68
|
+
state: STATES[m.state],
|
|
69
|
+
draft: m.draft === true,
|
|
70
|
+
updatedAt: m.updated_at,
|
|
71
|
+
baseRef: m.target_branch,
|
|
72
|
+
headRef: m.source_branch,
|
|
73
|
+
headSha: m.diff_refs?.head_sha ?? m.sha,
|
|
74
|
+
mergeCommitSha: m.state === 'merged' ? (m.merge_commit_sha ?? null) : null,
|
|
75
|
+
additions: stats.additions,
|
|
76
|
+
deletions: stats.deletions,
|
|
77
|
+
changedFiles: Number.parseInt(m.changes_count ?? '0', 10) || 0,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function fetchMr(client: HostClient, repo: Repo, number: number): Promise<unknown> {
|
|
82
|
+
try {
|
|
83
|
+
return await client.api(`${gitlabProjectApi(repo)}/merge_requests/${number}`)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err instanceof HostCliError && err.notFound) {
|
|
86
|
+
throw new PrNotFoundError(number)
|
|
87
|
+
}
|
|
88
|
+
throw err
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The merge request as a PrMeta. The line counts are a separate GraphQL query; an instance that
|
|
94
|
+
* refuses it still gets a review, with the header showing +0 −0.
|
|
95
|
+
*/
|
|
96
|
+
export async function fetchMrMeta(client: HostClient, repo: Repo, number: number): Promise<PrMeta> {
|
|
97
|
+
const [raw, stats] = await Promise.all([
|
|
98
|
+
fetchMr(client, repo, number),
|
|
99
|
+
client
|
|
100
|
+
.graphql(MR_STATS_QUERY, { path: `${repo.owner}/${repo.name}`, iid: String(number) })
|
|
101
|
+
.then(page => DiffStatsSchema.parse(page).project?.mergeRequest?.diffStatsSummary ?? null)
|
|
102
|
+
.catch(() => null),
|
|
103
|
+
])
|
|
104
|
+
return mapMergeRequest(raw, stats ?? { additions: 0, deletions: 0 })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The refs an inline comment is positioned against, read live so they name the current head. */
|
|
108
|
+
export async function fetchMrDiffRefs(client: HostClient, repo: Repo, number: number): Promise<MrDiffRefs> {
|
|
109
|
+
const m = GlMergeRequestSchema.parse(await fetchMr(client, repo, number))
|
|
110
|
+
const refs = m.diff_refs
|
|
111
|
+
if (refs === undefined || refs === null) {
|
|
112
|
+
throw new Error(`merge request !${number} reports no diff refs; GitLab may still be computing its diff`)
|
|
113
|
+
}
|
|
114
|
+
return refs
|
|
115
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { PostCommentInput, PostCommentResult } from '../contract/comments.js'
|
|
4
|
+
import type { Repo, Side } from '../contract/review-artifact.js'
|
|
5
|
+
import { splitHunks } from '../git/patch-lines.js'
|
|
6
|
+
import type { Derived } from '../store/derived-store.js'
|
|
7
|
+
import type { HostClient } from '../host/client.js'
|
|
8
|
+
import { findDiscussionId, GlNoteSchema, mapGitLabIssueComment, mapGitLabReviewComment } from './comments.js'
|
|
9
|
+
import { fetchMrDiffRefs, type MrDiffRefs } from './mr.js'
|
|
10
|
+
import { gitlabProjectApi } from './project.js'
|
|
11
|
+
|
|
12
|
+
/** A new discussion comes back as the thread holding its first note. */
|
|
13
|
+
const GlDiscussionSchema = z.object({ notes: z.tuple([GlNoteSchema]).rest(z.unknown()) })
|
|
14
|
+
|
|
15
|
+
/** Context lines need both coordinates, including offsets introduced by earlier changes. */
|
|
16
|
+
function diffLine(patch: string, side: Side, line: number): { old_line?: number; new_line?: number } {
|
|
17
|
+
for (const hunk of splitHunks(patch)) {
|
|
18
|
+
let oldLine = hunk.oldStart
|
|
19
|
+
let newLine = hunk.newStart
|
|
20
|
+
for (const text of hunk.lines) {
|
|
21
|
+
if (![' ', '+', '-'].includes(text[0] ?? '')) continue
|
|
22
|
+
const point = {
|
|
23
|
+
...(text.startsWith('+') ? {} : { old_line: oldLine++ }),
|
|
24
|
+
...(text.startsWith('-') ? {} : { new_line: newLine++ }),
|
|
25
|
+
}
|
|
26
|
+
if ((side === 'old' ? point.old_line : point.new_line) === line) return point
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`${side}-side line ${line} is not in the patch`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The `position` GitLab anchors an inline comment to. A range names both of its ends by line code. */
|
|
33
|
+
export function inlinePosition(
|
|
34
|
+
refs: MrDiffRefs,
|
|
35
|
+
input: Extract<PostCommentInput, { kind: 'inline' }>,
|
|
36
|
+
diff: Derived
|
|
37
|
+
): Record<string, unknown> {
|
|
38
|
+
const newPath = input.path
|
|
39
|
+
const file = diff.files.find(f => f.path === input.path)
|
|
40
|
+
const patch = file === undefined ? undefined : diff.patches[file.key]
|
|
41
|
+
if (file === undefined || patch === undefined) {
|
|
42
|
+
throw new Error(`no diff available for ${input.path}`)
|
|
43
|
+
}
|
|
44
|
+
const oldPath = file.oldPath ?? input.path
|
|
45
|
+
const position: Record<string, unknown> = {
|
|
46
|
+
...refs,
|
|
47
|
+
position_type: 'text',
|
|
48
|
+
old_path: oldPath,
|
|
49
|
+
new_path: newPath,
|
|
50
|
+
...diffLine(patch, input.side, input.line),
|
|
51
|
+
}
|
|
52
|
+
if (input.startLine !== undefined && input.startLine !== input.line) {
|
|
53
|
+
const hash = createHash('sha1').update(newPath).digest('hex')
|
|
54
|
+
const endpoint = (line: number) => {
|
|
55
|
+
const point = diffLine(patch, input.side, line)
|
|
56
|
+
return {
|
|
57
|
+
...point,
|
|
58
|
+
line_code: `${hash}_${point.old_line ?? ''}_${point.new_line ?? ''}`,
|
|
59
|
+
type: point.old_line === undefined ? 'new' : 'old',
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
position['line_range'] = { start: endpoint(input.startLine), end: endpoint(input.line) }
|
|
63
|
+
}
|
|
64
|
+
return position
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Posts one comment and maps GitLab's answer into the shape the page already renders. An inline
|
|
69
|
+
* comment opens a discussion positioned on the live diff refs; a reply joins the discussion that
|
|
70
|
+
* holds its parent; a review-level comment is a plain note.
|
|
71
|
+
*/
|
|
72
|
+
export async function postGitlabComment(
|
|
73
|
+
client: HostClient,
|
|
74
|
+
repo: Repo,
|
|
75
|
+
number: number,
|
|
76
|
+
headSha: string,
|
|
77
|
+
input: PostCommentInput,
|
|
78
|
+
opts: Derived & { webBase: string }
|
|
79
|
+
): Promise<PostCommentResult> {
|
|
80
|
+
const base = `${gitlabProjectApi(repo)}/merge_requests/${number}`
|
|
81
|
+
const mapped = { webBase: opts.webBase, repo, iid: number, headSha }
|
|
82
|
+
switch (input.kind) {
|
|
83
|
+
case 'inline': {
|
|
84
|
+
const refs = await fetchMrDiffRefs(client, repo, number)
|
|
85
|
+
const raw = await client.post(`${base}/discussions`, {
|
|
86
|
+
body: input.body,
|
|
87
|
+
position: inlinePosition(refs, input, opts),
|
|
88
|
+
})
|
|
89
|
+
return {
|
|
90
|
+
kind: 'review',
|
|
91
|
+
comment: mapGitLabReviewComment(GlDiscussionSchema.parse(raw).notes[0], mapped),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
case 'reply': {
|
|
95
|
+
const discussionId = await findDiscussionId(client, repo, number, input.inReplyToId)
|
|
96
|
+
if (discussionId === null) {
|
|
97
|
+
throw new Error(`no GitLab discussion contains note ${input.inReplyToId}`)
|
|
98
|
+
}
|
|
99
|
+
const raw = await client.post(`${base}/discussions/${discussionId}/notes`, { body: input.body })
|
|
100
|
+
const note = GlNoteSchema.parse(raw)
|
|
101
|
+
return {
|
|
102
|
+
kind: 'review',
|
|
103
|
+
comment: mapGitLabReviewComment(note, { ...mapped, inReplyToId: input.inReplyToId }),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
case 'issue': {
|
|
107
|
+
const raw = await client.post(`${base}/notes`, { body: input.body })
|
|
108
|
+
return { kind: 'issue', comment: mapGitLabIssueComment(GlNoteSchema.parse(raw), mapped) }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|