@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
@@ -1,27 +1,5 @@
1
- // Finding the canvas zip a human attached to the pull request and downloading it with the gh
2
- // token. The token is read per request, passed to github.com only, and never logged or stored.
3
- import { createHash } from 'node:crypto'
4
- import { importCanvas } from '../canvas/import.js'
5
- import { type ParsedCanvasName, parseCanvasZipName } from '../canvas/name.js'
6
- import { CANVAS_ZIP_MAX_BYTES, hasZipMagic } from '../canvas/zip.js'
7
- import type { ImportResult, SharedCanvasInfo } from '../contract/api.js'
8
- import type { CommentsPayload } from '../contract/comments.js'
9
- import type { Pr, Repo } from '../contract/review-artifact.js'
10
- import { BodyTooLargeError, readCappedBody } from '../server/capped-body.js'
11
- import type { AppContext } from '../server/context.js'
12
- import { AppError } from '../server/errors.js'
13
-
14
- /** Where an attachment may be served from. Everything else is refused before any request. */
15
- export const ALLOWED_ATTACHMENT_HOSTS = new Set(['github.com', 'objects.githubusercontent.com'])
16
- export const MAX_REDIRECTS = 3
17
- export const DOWNLOAD_TIMEOUT_MS = 30_000
18
-
19
- export type DownloadFailure = NonNullable<SharedCanvasInfo['reason']>
20
-
21
- export interface AttachmentLink {
22
- url: string
23
- name: string
24
- }
1
+ import type { AttachmentLink } from '../host/attachments.js'
2
+ import type { HostAttachments } from '../host/host.js'
25
3
 
26
4
  const FILE_LINK_RE = /https:\/\/github\.com\/user-attachments\/files\/\d+\/([A-Za-z0-9._-]+\.zip)/g
27
5
  const ASSET_LINK_RE =
@@ -31,7 +9,7 @@ const ASSET_LINK_RE =
31
9
  * Zip links in one markdown text. GitHub serves an attachment either under `files/<id>/<name>`,
32
10
  * where the name is in the URL, or under `assets/<uuid>`, where only the markdown label has it.
33
11
  */
34
- export function findAttachmentLinks(text: string): AttachmentLink[] {
12
+ export function findGithubAttachmentLinks(text: string): AttachmentLink[] {
35
13
  const links: AttachmentLink[] = []
36
14
  const seen = new Set<string>()
37
15
  for (const m of text.matchAll(FILE_LINK_RE)) {
@@ -51,238 +29,12 @@ export function findAttachmentLinks(text: string): AttachmentLink[] {
51
29
  return links
52
30
  }
53
31
 
54
- export interface AttachmentCandidate extends AttachmentLink {
55
- parsed: ParsedCanvasName
56
- /**
57
- * When the text carrying the link was last edited. A link added to an old comment counts from
58
- * the edit, which is when the attachment appeared.
59
- */
60
- postedAt: string
61
- /** Position in the scan, used when two links carry the same time. */
62
- order: number
63
- }
64
-
65
- export interface DiscoverySources {
66
- /** The PR body and the time the pull request was last edited. */
67
- body: string
68
- bodyUpdatedAt: string
69
- comments: CommentsPayload
70
- }
71
-
72
- /** Every link in the PR body and its comments whose name is a canvas zip of this repository. */
73
- export function collectCandidates(sources: DiscoverySources, repo: Repo): AttachmentCandidate[] {
74
- const texts: Array<{ text: string; postedAt: string }> = [
75
- { text: sources.body, postedAt: sources.bodyUpdatedAt },
76
- ...sources.comments.issueComments.map(c => ({ text: c.body, postedAt: c.updatedAt })),
77
- ...sources.comments.reviewComments.map(c => ({ text: c.body, postedAt: c.updatedAt })),
78
- ]
79
- const candidates: AttachmentCandidate[] = []
80
- let order = 0
81
- for (const { text, postedAt } of texts) {
82
- for (const link of findAttachmentLinks(text)) {
83
- const parsed = parseCanvasZipName(link.name, repo)
84
- if (parsed !== null) {
85
- candidates.push({ ...link, parsed, postedAt, order: order++ })
86
- }
87
- }
88
- }
89
- return candidates
90
- }
91
-
92
- /** The canvas for this head wins, then one exported for this PR, then the one attached last. */
93
- export function rankCandidates(
94
- candidates: AttachmentCandidate[],
95
- target: { headSha: string; prNumber: number }
96
- ): AttachmentCandidate[] {
97
- const shaPrefix = target.headSha.slice(0, 8)
98
- const score = (c: AttachmentCandidate): number =>
99
- (c.parsed.shaPrefix === shaPrefix ? 2 : 0) + (c.parsed.prNumber === target.prNumber ? 1 : 0)
100
- return [...candidates].sort(
101
- (a, b) => score(b) - score(a) || b.postedAt.localeCompare(a.postedAt) || b.order - a.order
102
- )
103
- }
104
-
105
- /** How many attachments one scan tries before it reports the best one as unusable. */
106
- export const MAX_CANDIDATES_TRIED = 3
107
-
108
32
  /**
109
- * A fingerprint of everything discovery reads, so an unchanged pull request is not scanned again.
110
- * It covers the text of every comment, because an edit can swap one zip link for another, and the
111
- * head sha, because the ranking and `matchesHead` are answers about that commit.
33
+ * github.com answers an attachment request with a redirect to signed object storage, which must
34
+ * not see the token: the header is for github.com alone.
112
35
  */
113
- export function discoveryFingerprint(body: string, comments: CommentsPayload, headSha: string): string {
114
- const hash = createHash('sha1')
115
- hash.update(`${headSha}\n${body}`)
116
- for (const c of [...comments.issueComments, ...comments.reviewComments]) {
117
- hash.update(`\u0000${c.id}:${c.createdAt}:${c.body}`)
118
- }
119
- return hash.digest('hex')
120
- }
121
-
122
- export type DownloadResult = { ok: true; bytes: Uint8Array } | { ok: false; reason: DownloadFailure }
123
-
124
- function allowedUrl(raw: string, base?: string): URL | null {
125
- let url: URL
126
- try {
127
- url = new URL(raw, base)
128
- } catch {
129
- return null
130
- }
131
- return url.protocol === 'https:' && ALLOWED_ATTACHMENT_HOSTS.has(url.hostname) ? url : null
132
- }
133
-
134
- const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
135
-
136
- /**
137
- * Downloads one attachment. GitHub answers the first request with a redirect to a signed storage
138
- * URL; that request carries no Authorization header, because storage refuses a request holding
139
- * both a signature and a token.
140
- */
141
- export async function downloadAttachment(ctx: AppContext, rawUrl: string): Promise<DownloadResult> {
142
- const first = allowedUrl(rawUrl)
143
- if (first === null) {
144
- return { ok: false, reason: 'network' }
145
- }
146
- const token = await ctx.gh.authToken()
147
- if (token === null) {
148
- return { ok: false, reason: 'auth-required' }
149
- }
150
- let url = first
151
- // Storage refuses a request that carries both a signature and a token, and the token has no
152
- // business there anyway: only github.com is ever asked with it.
153
- let withToken = url.hostname === 'github.com'
154
- // One deadline for the redirects and the body together, so three slow hops cannot add up.
155
- const deadline = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
156
- try {
157
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
158
- const accept = 'application/octet-stream'
159
- const headers = withToken ? { accept, authorization: `token ${token}` } : { accept }
160
- const res: Response = await ctx.fetch(url, {
161
- headers,
162
- redirect: 'manual',
163
- signal: deadline,
164
- })
165
- if (REDIRECT_STATUSES.has(res.status)) {
166
- const next = allowedUrl(res.headers.get('location') ?? '', url.toString())
167
- if (next === null) {
168
- return { ok: false, reason: 'network' }
169
- }
170
- url = next
171
- withToken = false
172
- continue
173
- }
174
- if (res.status === 401 || res.status === 403 || res.status === 404) {
175
- return { ok: false, reason: 'auth-required' }
176
- }
177
- if (res.status !== 200) {
178
- return { ok: false, reason: 'network' }
179
- }
180
- let bytes: Uint8Array
181
- try {
182
- bytes = await readCappedBody(res, CANVAS_ZIP_MAX_BYTES)
183
- } catch (err) {
184
- if (err instanceof BodyTooLargeError) {
185
- return { ok: false, reason: 'too-large' }
186
- }
187
- throw err
188
- }
189
- return hasZipMagic(bytes) ? { ok: true, bytes } : { ok: false, reason: 'not-zip' }
190
- }
191
- } catch {
192
- // A network failure, a timeout, or a body that stopped mid-stream all read the same here.
193
- return { ok: false, reason: 'network' }
194
- }
195
- return { ok: false, reason: 'network' }
196
- }
197
-
198
- /** A downloaded zip that import refused, told in the words the callout uses. */
199
- export function importFailureReason(err: unknown): DownloadFailure {
200
- if (!(err instanceof AppError)) {
201
- return 'not-zip'
202
- }
203
- if (err.code === 'CANVAS_TOO_LARGE') {
204
- return 'too-large'
205
- }
206
- return err.code === 'CANVAS_REPO_MISMATCH' ? 'name-mismatch' : 'not-zip'
207
- }
208
-
209
- export interface DiscoveryOutcome {
210
- sharedCanvas: SharedCanvasInfo | null
211
- imported: ImportResult | null
212
- warnings: string[]
213
- }
214
-
215
- /**
216
- * Looks for a canvas attached to the PR and imports the best one. A found-but-unusable zip is
217
- * reported to the page, which offers the link and the drop zone instead.
218
- */
219
- export async function discoverSharedCanvas(
220
- ctx: AppContext,
221
- pr: Pr,
222
- comments: CommentsPayload
223
- ): Promise<DiscoveryOutcome> {
224
- if (pr.number === null) {
225
- return { sharedCanvas: null, imported: null, warnings: [] }
226
- }
227
- const sources: DiscoverySources = { body: pr.body, bodyUpdatedAt: pr.updatedAt ?? '', comments }
228
- const ranked = rankCandidates(collectCandidates(sources, ctx.config.repo), {
229
- headSha: pr.headSha,
230
- prNumber: pr.number,
231
- })
232
- const best = ranked[0]
233
- if (best === undefined) {
234
- return { sharedCanvas: null, imported: null, warnings: [] }
235
- }
236
- const warnings: string[] = []
237
- const first = await tryCandidate(ctx, pr, pr.number, best)
238
- warnings.push(...first.warnings)
239
- if (first.imported !== null) {
240
- return { ...first, warnings }
241
- }
242
- // A broken attachment must not hide a good one behind it, so the next ones are tried too.
243
- for (const candidate of ranked.slice(1, MAX_CANDIDATES_TRIED)) {
244
- const attempt = await tryCandidate(ctx, pr, pr.number, candidate)
245
- warnings.push(...attempt.warnings)
246
- if (attempt.imported !== null) {
247
- return { ...attempt, warnings }
248
- }
249
- }
250
- // Every attachment failed; the page shows the best one, its reason, and the drop zone.
251
- return { ...first, warnings }
252
- }
253
-
254
- /** One attachment: download it and import it, or say why that did not work. */
255
- async function tryCandidate(
256
- ctx: AppContext,
257
- pr: Pr,
258
- prNumber: number,
259
- candidate: AttachmentCandidate
260
- ): Promise<DiscoveryOutcome> {
261
- const shared = {
262
- url: candidate.url,
263
- name: candidate.name,
264
- matchesHead: candidate.parsed.shaPrefix === pr.headSha.slice(0, 8),
265
- }
266
- const download = await downloadAttachment(ctx, candidate.url)
267
- if (!download.ok) {
268
- return {
269
- sharedCanvas: { ...shared, downloadable: false, reason: download.reason },
270
- imported: null,
271
- warnings: [],
272
- }
273
- }
274
- try {
275
- const imported = await importCanvas(ctx, {
276
- bytes: download.bytes,
277
- prNumber,
278
- currentHeadSha: pr.headSha,
279
- })
280
- return { sharedCanvas: { ...shared, downloadable: true }, imported, warnings: imported.warnings }
281
- } catch (err) {
282
- return {
283
- sharedCanvas: { ...shared, downloadable: false, reason: importFailureReason(err) },
284
- imported: null,
285
- warnings: [err instanceof Error ? err.message : String(err)],
286
- }
287
- }
36
+ export const GITHUB_ATTACHMENTS: HostAttachments = {
37
+ findLinks: findGithubAttachmentLinks,
38
+ allowedHosts: new Set(['github.com', 'objects.githubusercontent.com']),
39
+ authHeader: token => ({ authorization: `token ${token}` }),
288
40
  }
@@ -0,0 +1,22 @@
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 { mapIssueComment } from './comments.js'
6
+
7
+ export async function shareGithubCanvas(
8
+ client: HostClient,
9
+ repo: Repo,
10
+ number: number,
11
+ body: string
12
+ ): Promise<string> {
13
+ const user = z.object({ login: z.string() }).parse(await client.api('user'))
14
+ const base = `repos/${repo.owner}/${repo.name}`
15
+ const comments = (await fetchAllPages(client, `${base}/issues/${number}/comments`)).map(mapIssueComment)
16
+ const existing = comments.find(c => c.author === user.login && c.body.includes(CANVAS_COMMENT_MARKER))
17
+ const response =
18
+ existing === undefined
19
+ ? await client.post(`${base}/issues/${number}/comments`, { body })
20
+ : await client.post(`${base}/issues/comments/${existing.id}`, { body }, 'PATCH')
21
+ return mapIssueComment(response).url
22
+ }
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod'
2
2
  import type { Capabilities } from '../contract/api.js'
3
3
  import type { Repo } from '../contract/review-artifact.js'
4
- import type { GhResponse, GitHubClient } from './gh.js'
4
+ import type { CliResponse, HostClient } from '../host/client.js'
5
5
 
6
6
  const UserSchema = z.object({ login: z.string() })
7
7
  const RepoBodySchema = z.object({
@@ -9,24 +9,14 @@ const RepoBodySchema = z.object({
9
9
  permissions: z.object({ pull: z.boolean().optional(), push: z.boolean().optional() }).optional(),
10
10
  })
11
11
 
12
- /** What a page assumes before the probe answers: posting is tried and GitHub decides. */
13
- export const UNKNOWN_CAPABILITIES: Capabilities = {
14
- canComment: 'unknown',
15
- tokenKind: 'unprobed',
16
- login: null,
17
- }
18
-
19
12
  export const SCOPE_HINT = 'gh auth refresh -h github.com -s repo'
20
13
 
21
- /** How long a probe answer is reused. A new token needs `?refresh=1` or ten minutes. */
22
- export const CAPABILITY_TTL_MS = 10 * 60 * 1000
23
-
24
14
  /**
25
15
  * Whether this login may post on this repository, from the token's scopes and the repository
26
16
  * permissions. A token without a scopes header is a fine-grained or app token, whose rights this
27
17
  * check cannot read: posting stays enabled and GitHub's own answer decides.
28
18
  */
29
- export function decideCapabilities(login: string | null, response: GhResponse): Capabilities {
19
+ export function decideCapabilities(login: string | null, response: CliResponse): Capabilities {
30
20
  const parsed = RepoBodySchema.safeParse(response.body)
31
21
  const body = parsed.success ? parsed.data : {}
32
22
  const canPull = body.permissions?.pull === true
@@ -67,7 +57,7 @@ export function decideCapabilities(login: string | null, response: GhResponse):
67
57
  }
68
58
 
69
59
  /** The probe itself: who the token belongs to, and what it may do here. */
70
- export async function probeCapabilities(gh: GitHubClient, repo: Repo): Promise<Capabilities> {
60
+ export async function probeCapabilities(gh: HostClient, repo: Repo): Promise<Capabilities> {
71
61
  let login: string | null = null
72
62
  try {
73
63
  login = UserSchema.parse(await gh.api('user')).login
@@ -86,31 +76,3 @@ export async function probeCapabilities(gh: GitHubClient, repo: Repo): Promise<C
86
76
  }
87
77
  }
88
78
  }
89
-
90
- export interface CapabilityProbe {
91
- get(opts?: { refresh?: boolean }): Promise<Capabilities>
92
- }
93
-
94
- /**
95
- * The probe with its cache. One per server process; `refresh` skips the cache after the user
96
- * changed their token.
97
- */
98
- export function createCapabilityProbe(
99
- gh: GitHubClient,
100
- repo: Repo,
101
- now: () => Date,
102
- ttlMs = CAPABILITY_TTL_MS
103
- ): CapabilityProbe {
104
- let cached: { at: number; value: Capabilities } | null = null
105
- return {
106
- get: async (opts = {}) => {
107
- const at = now().getTime()
108
- if (!opts.refresh && cached !== null && at - cached.at < ttlMs) {
109
- return cached.value
110
- }
111
- const value = await probeCapabilities(gh, repo)
112
- cached = { at, value }
113
- return value
114
- },
115
- }
116
- }
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod'
2
- import type { CommentsPayload, IssueComment, ReviewComment } from '../contract/comments.js'
2
+ import type { FetchCommentsResult, IssueComment, ReviewComment } from '../contract/comments.js'
3
3
  import type { Repo } from '../contract/review-artifact.js'
4
- import type { GitHubClient } from './gh.js'
4
+ import { fetchAllPages, type HostClient } from '../host/client.js'
5
5
  import { fetchResolvedCommentIds } from './threads.js'
6
6
 
7
7
  const GhReviewCommentSchema = z.object({
@@ -71,33 +71,12 @@ export function mapIssueComment(raw: unknown): IssueComment {
71
71
  }
72
72
  }
73
73
 
74
- export interface FetchCommentsResult {
75
- payload: CommentsPayload
76
- warnings: string[]
77
- }
78
-
79
- export const COMMENTS_PAGE_SIZE = 100
80
-
81
- /** Every item of a REST list endpoint, following `page=` until a page comes back short. */
82
- export async function fetchAllPages(gh: GitHubClient, path: string): Promise<unknown[]> {
83
- const out: unknown[] = []
84
- for (let page = 1; ; page++) {
85
- const batch = z
86
- .array(z.unknown())
87
- .parse(await gh.api(path, { per_page: String(COMMENTS_PAGE_SIZE), page: String(page) }))
88
- out.push(...batch)
89
- if (batch.length < COMMENTS_PAGE_SIZE) {
90
- return out
91
- }
92
- }
93
- }
94
-
95
74
  /**
96
75
  * Review comments (on diff lines) and issue comments (PR-level), with the resolved flag from
97
76
  * GraphQL. A GraphQL failure degrades to `resolved: false` everywhere plus a warning.
98
77
  */
99
78
  export async function fetchComments(
100
- gh: GitHubClient,
79
+ gh: HostClient,
101
80
  repo: Repo,
102
81
  number: number,
103
82
  headSha: string,
@@ -1,46 +1,13 @@
1
1
  import type { PostCommentInput, PostCommentResult } from '../contract/comments.js'
2
- import type { FileEntry, Repo, Side } from '../contract/review-artifact.js'
3
- import { hunkForLine } from '../git/patch-lines.js'
2
+ import type { Repo, Side } from '../contract/review-artifact.js'
4
3
  import { mapIssueComment, mapReviewComment } from './comments.js'
5
- import type { GitHubClient } from './gh.js'
4
+ import type { HostClient } from '../host/client.js'
6
5
 
7
6
  /** GitHub names the two sides of a diff LEFT and RIGHT. */
8
7
  export function ghSide(side: Side): 'LEFT' | 'RIGHT' {
9
8
  return side === 'old' ? 'LEFT' : 'RIGHT'
10
9
  }
11
10
 
12
- export interface InlineTarget {
13
- path: string
14
- line: number
15
- side: Side
16
- startLine?: number | undefined
17
- }
18
-
19
- /**
20
- * Whether GitHub will accept a comment on these lines: the file is in the diff, and the whole
21
- * range sits inside one hunk on that side. Returns the reason when it will not, so the route can
22
- * refuse before the request leaves the machine.
23
- */
24
- export function checkInlineTarget(files: ReadonlyArray<FileEntry>, target: InlineTarget): string | null {
25
- const file = files.find(f => f.path === target.path)
26
- if (file === undefined) {
27
- return `${target.path} is not in the diff`
28
- }
29
- const hunk = hunkForLine(file.hunks, target.side, target.line)
30
- if (hunk === null) {
31
- return `${target.path}:${target.line} (${target.side}) is not in the diff`
32
- }
33
- if (target.startLine !== undefined) {
34
- if (target.startLine > target.line) {
35
- return `the first line of the range must come before ${target.line}`
36
- }
37
- if (hunkForLine(file.hunks, target.side, target.startLine) !== hunk) {
38
- return `${target.path}:${target.startLine}-${target.line} (${target.side}) spans more than one hunk`
39
- }
40
- }
41
- return null
42
- }
43
-
44
11
  /** The fields GitHub reads for a comment on a diff line. */
45
12
  interface InlineCommentBody {
46
13
  body: string
@@ -92,7 +59,7 @@ export function commentRequest(
92
59
 
93
60
  /** Posts one comment and maps GitHub's answer into the shape the page already renders. */
94
61
  export async function postComment(
95
- gh: GitHubClient,
62
+ gh: HostClient,
96
63
  repo: Repo,
97
64
  number: number,
98
65
  headSha: string,
@@ -1,23 +1,8 @@
1
1
  import { z } from 'zod'
2
- import type { ReviewSummary } from './../contract/api.js'
2
+ import type { ReviewEvent } from '../contract/reviews.js'
3
+ import type { ReviewSummary } from '../contract/api.js'
3
4
  import type { Repo } from '../contract/review-artifact.js'
4
- import type { GitHubClient } from './gh.js'
5
-
6
- export const REVIEW_EVENTS = ['APPROVE', 'REQUEST_CHANGES'] as const
7
- export const ReviewEventSchema = z.enum(REVIEW_EVENTS)
8
- export type ReviewEvent = (typeof REVIEW_EVENTS)[number]
9
-
10
- export const PostReviewInputSchema = z.object({
11
- event: ReviewEventSchema,
12
- /** The dialog sends the body the user read, edited or not. */
13
- body: z.string().min(1).max(65536).optional(),
14
- /** The commit the dialog named. The server refuses the review when the head moved on. */
15
- headSha: z
16
- .string()
17
- .regex(/^[0-9a-f]{40}$/)
18
- .optional(),
19
- })
20
- export type PostReviewInput = z.infer<typeof PostReviewInputSchema>
5
+ import type { HostClient } from '../host/client.js'
21
6
 
22
7
  const GhReviewSchema = z.object({
23
8
  id: z.number().int(),
@@ -28,7 +13,7 @@ const GhReviewSchema = z.object({
28
13
 
29
14
  /** Posts the review. GitHub decides what the event means; the tool only fills the body and sha. */
30
15
  export async function postReview(
31
- gh: GitHubClient,
16
+ gh: HostClient,
32
17
  repo: Repo,
33
18
  number: number,
34
19
  headSha: string,
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>> {