@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.
Files changed (99) hide show
  1. package/README.md +52 -23
  2. package/docs/reference.md +154 -44
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +6 -0
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +3 -0
  7. package/skills/pr-review-canvas/SKILL.md +69 -39
  8. package/src/canvas/comment.ts +24 -0
  9. package/src/canvas/import.ts +30 -9
  10. package/src/canvas/name.ts +1 -0
  11. package/src/canvas/zip.ts +21 -1
  12. package/src/chat/chat-manager.ts +40 -39
  13. package/src/chat/threads.ts +27 -26
  14. package/src/cli.ts +12 -7
  15. package/src/commands.ts +45 -16
  16. package/src/config.ts +14 -26
  17. package/src/contract/api.ts +32 -1
  18. package/src/contract/canvas-manifest.ts +2 -0
  19. package/src/contract/comments.ts +5 -0
  20. package/src/contract/discovery.ts +5 -2
  21. package/src/contract/generation-context.ts +24 -1
  22. package/src/contract/review-key.ts +51 -0
  23. package/src/contract/reviews.ts +17 -0
  24. package/src/contract/settings.ts +2 -0
  25. package/src/contract/state.ts +41 -21
  26. package/src/git/environment.mjs +27 -0
  27. package/src/git/git.ts +109 -9
  28. package/src/git/local-target.ts +138 -0
  29. package/src/git/patch-lines.ts +34 -2
  30. package/src/git/pr-refs.ts +36 -0
  31. package/src/github/attachments.ts +9 -257
  32. package/src/github/canvas-comment.ts +22 -0
  33. package/src/github/capabilities.ts +3 -41
  34. package/src/github/comments.ts +3 -24
  35. package/src/github/post-comment.ts +3 -36
  36. package/src/github/post-review.ts +4 -19
  37. package/src/github/pr.ts +6 -87
  38. package/src/github/threads.ts +2 -2
  39. package/src/gitlab/attachments.ts +40 -0
  40. package/src/gitlab/canvas-comment.ts +26 -0
  41. package/src/gitlab/capabilities.ts +64 -0
  42. package/src/gitlab/comments.ts +164 -0
  43. package/src/gitlab/mr.ts +115 -0
  44. package/src/gitlab/post-comment.ts +111 -0
  45. package/src/gitlab/post-review.ts +54 -0
  46. package/src/gitlab/project.ts +13 -0
  47. package/src/host/attachments.ts +293 -0
  48. package/src/host/capabilities.ts +38 -0
  49. package/src/host/client.ts +245 -0
  50. package/src/host/host.ts +136 -0
  51. package/src/host/pr.ts +51 -0
  52. package/src/host/remote.ts +42 -0
  53. package/src/project-config.ts +13 -0
  54. package/src/review/carry-over.ts +79 -0
  55. package/src/review/doctor.ts +22 -12
  56. package/src/review/prepare.ts +64 -10
  57. package/src/review/publish.ts +61 -10
  58. package/src/{github → review}/review-body.ts +17 -5
  59. package/src/review/skill-command.ts +5 -3
  60. package/src/review/validate-folds.ts +2 -2
  61. package/src/review/validate.ts +4 -4
  62. package/src/server/bundle.ts +312 -111
  63. package/src/server/context.ts +10 -8
  64. package/src/server/errors.ts +30 -8
  65. package/src/server/html.ts +34 -10
  66. package/src/server/routes/api.ts +63 -26
  67. package/src/server/routes/chat-routes.ts +76 -38
  68. package/src/server/routes/pages.ts +22 -8
  69. package/src/server/routes/review-routes.ts +84 -33
  70. package/src/store/canvas-store.ts +90 -55
  71. package/src/store/data-dir.ts +2 -1
  72. package/src/store/derived-store.ts +41 -27
  73. package/src/store/pr-store.ts +23 -14
  74. package/src/store/state-store.ts +40 -36
  75. package/static/js/api.js +32 -24
  76. package/static/js/app.js +24 -10
  77. package/static/js/chat.js +3 -2
  78. package/static/js/composer.js +24 -14
  79. package/static/js/contract-types.d.ts +3 -0
  80. package/static/js/diff-renderer.js +1 -1
  81. package/static/js/download.js +1 -1
  82. package/static/js/empty-state.js +85 -18
  83. package/static/js/errors.js +22 -6
  84. package/static/js/header.js +31 -8
  85. package/static/js/host.js +40 -0
  86. package/static/js/import-zone.js +1 -1
  87. package/static/js/interactions.js +1 -2
  88. package/static/js/layers.js +3 -3
  89. package/static/js/links.js +3 -3
  90. package/static/js/markdown.js +28 -1
  91. package/static/js/points.js +2 -1
  92. package/static/js/review-session.js +7 -2
  93. package/static/js/settings.js +2 -1
  94. package/static/js/signoff.js +6 -7
  95. package/static/styles/commands.css +6 -0
  96. package/static/styles/panels.css +4 -0
  97. package/static/styles/review-actions.css +1 -0
  98. package/static/styles/skin-github.css +7 -1
  99. package/src/github/gh.ts +0 -211
@@ -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
+ }
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod'
2
+ import type { ReviewSummary } from '../contract/api.js'
3
+ import type { Repo } from '../contract/review-artifact.js'
4
+ import type { ReviewEvent } from '../contract/reviews.js'
5
+ import type { HostClient } from '../host/client.js'
6
+ import { gitlabMrUrl, gitlabNoteUrl, gitlabProjectApi } from './project.js'
7
+
8
+ const GlNoteSchema = z.object({
9
+ id: z.number().int(),
10
+ created_at: z.string().optional(),
11
+ })
12
+
13
+ const GlMrSchema = z.object({
14
+ id: z.number().int().optional(),
15
+ iid: z.number().int().optional(),
16
+ web_url: z.string().optional(),
17
+ })
18
+
19
+ /**
20
+ * GitLab has no GitHub-style review event. Approve calls the approvals API; request-changes posts
21
+ * the review body as an MR note so the text still lands on the merge request.
22
+ */
23
+ export async function postGitlabReview(
24
+ client: HostClient,
25
+ repo: Repo,
26
+ number: number,
27
+ headSha: string,
28
+ input: { event: ReviewEvent; body: string },
29
+ webBase: string
30
+ ): Promise<ReviewSummary> {
31
+ const base = `${gitlabProjectApi(repo)}/merge_requests/${number}`
32
+ const mrUrl = gitlabMrUrl(webBase, repo, number)
33
+ if (input.event === 'APPROVE') {
34
+ if (input.body.trim() !== '') {
35
+ await client.post(`${base}/notes`, { body: input.body })
36
+ }
37
+ const raw = await client.post(`${base}/approve`, { sha: headSha })
38
+ const mr = GlMrSchema.safeParse(raw).data ?? {}
39
+ return {
40
+ id: mr.id ?? mr.iid ?? number,
41
+ state: 'APPROVED',
42
+ url: mr.web_url ?? mrUrl,
43
+ submittedAt: new Date().toISOString(),
44
+ }
45
+ }
46
+ const raw = await client.post(`${base}/notes`, { body: input.body })
47
+ const note = GlNoteSchema.parse(raw)
48
+ return {
49
+ id: note.id,
50
+ state: 'CHANGES_REQUESTED',
51
+ url: gitlabNoteUrl(webBase, repo, number, note.id),
52
+ submittedAt: note.created_at ?? new Date().toISOString(),
53
+ }
54
+ }
@@ -0,0 +1,13 @@
1
+ import type { Repo } from '../contract/review-artifact.js'
2
+
3
+ export function gitlabProjectApi(repo: Repo): string {
4
+ return `projects/${encodeURIComponent(`${repo.owner}/${repo.name}`)}`
5
+ }
6
+
7
+ export function gitlabMrUrl(webBase: string, repo: Repo, iid: number): string {
8
+ return `${webBase}/${repo.owner}/${repo.name}/-/merge_requests/${iid}`
9
+ }
10
+
11
+ export function gitlabNoteUrl(webBase: string, repo: Repo, iid: number, noteId: number): string {
12
+ return `${gitlabMrUrl(webBase, repo, iid)}#note_${noteId}`
13
+ }