@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.
Files changed (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. 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 type { Pr, Repo } from '../contract/review-artifact.js'
3
- import type { Git } from '../git/git.js'
4
- import { GitHubApiError, type GitHubClient } from './gh.js'
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(gh: GitHubClient, repo: Repo, number: number): Promise<PrMeta> {
46
+ export async function fetchPrMeta(client: HostClient, repo: Repo, number: number): Promise<PrMeta> {
76
47
  try {
77
- return mapPull(await gh.api(`repos/${repo.owner}/${repo.name}/pulls/${number}`))
48
+ return mapPull(await client.api(`repos/${repo.owner}/${repo.name}/pulls/${number}`))
78
49
  } catch (err) {
79
- if (err instanceof GitHubApiError && err.notFound) {
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
- }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import type { Repo } from '../contract/review-artifact.js'
3
- import type { GitHubClient } from './gh.js'
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: GitHubClient,
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
+ }
@@ -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
+ }