@vintasoftware/pr-review-canvas 0.1.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 (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,112 @@
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 { GhResponse, GitHubClient } from './gh.js'
5
+
6
+ const UserSchema = z.object({ login: z.string() })
7
+ const RepoBodySchema = z.object({
8
+ private: z.boolean().optional(),
9
+ permissions: z.object({ pull: z.boolean().optional(), push: z.boolean().optional() }).optional(),
10
+ })
11
+
12
+ /** What a page assumes before the probe answers: posting is tried and GitHub decides. */
13
+ export const UNKNOWN_CAPABILITIES: Capabilities = { canComment: 'unknown', tokenKind: 'unprobed', login: null }
14
+
15
+ export const SCOPE_HINT = 'gh auth refresh -h github.com -s repo'
16
+
17
+ /** How long a probe answer is reused. A new token needs `?refresh=1` or ten minutes. */
18
+ export const CAPABILITY_TTL_MS = 10 * 60 * 1000
19
+
20
+ /**
21
+ * Whether this login may post on this repository, from the token's scopes and the repository
22
+ * permissions. A token without a scopes header is a fine-grained or app token, whose rights this
23
+ * check cannot read: posting stays enabled and GitHub's own answer decides.
24
+ */
25
+ export function decideCapabilities(login: string | null, response: GhResponse): Capabilities {
26
+ const parsed = RepoBodySchema.safeParse(response.body)
27
+ const body = parsed.success ? parsed.data : {}
28
+ const canPull = body.permissions?.pull === true
29
+ const isPrivate = body.private !== false
30
+ const header = response.headers['x-oauth-scopes']
31
+ if (header === undefined) {
32
+ return {
33
+ canComment: 'unknown',
34
+ tokenKind: 'fine-grained',
35
+ login,
36
+ reason: 'this token does not report its scopes, so posting is tried and GitHub decides',
37
+ }
38
+ }
39
+ const scopes = header
40
+ .split(',')
41
+ .map(s => s.trim())
42
+ .filter(s => s !== '')
43
+ const hasScope = scopes.includes('repo') || (!isPrivate && scopes.includes('public_repo'))
44
+ if (!hasScope) {
45
+ return {
46
+ canComment: false,
47
+ tokenKind: 'classic',
48
+ login,
49
+ reason: `this token has no ${isPrivate ? 'repo' : 'public_repo'} scope`,
50
+ hint: SCOPE_HINT,
51
+ }
52
+ }
53
+ if (!canPull) {
54
+ return {
55
+ canComment: false,
56
+ tokenKind: 'classic',
57
+ login,
58
+ reason: 'this login cannot read the repository',
59
+ hint: 'ask for access to the repository',
60
+ }
61
+ }
62
+ return { canComment: true, tokenKind: 'classic', login }
63
+ }
64
+
65
+ /** The probe itself: who the token belongs to, and what it may do here. */
66
+ export async function probeCapabilities(gh: GitHubClient, repo: Repo): Promise<Capabilities> {
67
+ let login: string | null = null
68
+ try {
69
+ login = UserSchema.parse(await gh.api('user')).login
70
+ } catch {
71
+ login = null
72
+ }
73
+ try {
74
+ return decideCapabilities(login, await gh.apiWithHeaders(`repos/${repo.owner}/${repo.name}`))
75
+ } catch (err) {
76
+ return {
77
+ canComment: false,
78
+ tokenKind: 'unknown',
79
+ login,
80
+ reason: err instanceof Error ? err.message : String(err),
81
+ hint: 'run `gh auth status` and log in again',
82
+ }
83
+ }
84
+ }
85
+
86
+ export interface CapabilityProbe {
87
+ get(opts?: { refresh?: boolean }): Promise<Capabilities>
88
+ }
89
+
90
+ /**
91
+ * The probe with its cache. One per server process; `refresh` skips the cache after the user
92
+ * changed their token.
93
+ */
94
+ export function createCapabilityProbe(
95
+ gh: GitHubClient,
96
+ repo: Repo,
97
+ now: () => Date,
98
+ ttlMs = CAPABILITY_TTL_MS
99
+ ): CapabilityProbe {
100
+ let cached: { at: number; value: Capabilities } | null = null
101
+ return {
102
+ get: async (opts = {}) => {
103
+ const at = now().getTime()
104
+ if (!opts.refresh && cached !== null && at - cached.at < ttlMs) {
105
+ return cached.value
106
+ }
107
+ const value = await probeCapabilities(gh, repo)
108
+ cached = { at, value }
109
+ return value
110
+ },
111
+ }
112
+ }
@@ -0,0 +1,132 @@
1
+ import { z } from 'zod'
2
+ import type { CommentsPayload, IssueComment, ReviewComment } from '../contract/comments.js'
3
+ import type { Repo } from '../contract/review-artifact.js'
4
+ import type { GitHubClient } from './gh.js'
5
+ import { fetchResolvedCommentIds } from './threads.js'
6
+
7
+ const GhReviewCommentSchema = z.object({
8
+ id: z.number().int(),
9
+ user: z.object({ login: z.string(), avatar_url: z.string().optional() }).nullable(),
10
+ body: z.string(),
11
+ path: z.string(),
12
+ line: z.number().int().nullable().optional(),
13
+ original_line: z.number().int().nullable().optional(),
14
+ side: z.enum(['LEFT', 'RIGHT']).nullable().optional(),
15
+ start_line: z.number().int().nullable().optional(),
16
+ commit_id: z.string(),
17
+ in_reply_to_id: z.number().int().optional(),
18
+ created_at: z.string(),
19
+ updated_at: z.string().optional(),
20
+ html_url: z.string(),
21
+ })
22
+
23
+ const GhIssueCommentSchema = z.object({
24
+ id: z.number().int(),
25
+ user: z.object({ login: z.string(), avatar_url: z.string().optional() }).nullable(),
26
+ body: z.string().nullable(),
27
+ created_at: z.string(),
28
+ updated_at: z.string().optional(),
29
+ html_url: z.string(),
30
+ })
31
+
32
+ export function mapReviewComment(raw: unknown, resolvedIds: ReadonlySet<number>): ReviewComment {
33
+ const c = GhReviewCommentSchema.parse(raw)
34
+ const line = c.line ?? null
35
+ const out: ReviewComment = {
36
+ id: c.id,
37
+ author: c.user?.login ?? 'ghost',
38
+ ...(c.user?.avatar_url ? { avatarUrl: c.user.avatar_url } : {}),
39
+ body: c.body,
40
+ path: c.path,
41
+ line,
42
+ originalLine: c.original_line ?? null,
43
+ side: c.side === 'LEFT' ? 'old' : 'new',
44
+ // GitHub clears `line` when the commented code is no longer in the diff.
45
+ outdated: line === null,
46
+ commitId: c.commit_id,
47
+ createdAt: c.created_at,
48
+ updatedAt: c.updated_at ?? c.created_at,
49
+ url: c.html_url,
50
+ resolved: resolvedIds.has(c.id),
51
+ }
52
+ if (typeof c.start_line === 'number') {
53
+ out.startLine = c.start_line
54
+ }
55
+ if (c.in_reply_to_id !== undefined) {
56
+ out.inReplyToId = c.in_reply_to_id
57
+ }
58
+ return out
59
+ }
60
+
61
+ export function mapIssueComment(raw: unknown): IssueComment {
62
+ const c = GhIssueCommentSchema.parse(raw)
63
+ return {
64
+ id: c.id,
65
+ author: c.user?.login ?? 'ghost',
66
+ ...(c.user?.avatar_url ? { avatarUrl: c.user.avatar_url } : {}),
67
+ body: c.body ?? '',
68
+ createdAt: c.created_at,
69
+ updatedAt: c.updated_at ?? c.created_at,
70
+ url: c.html_url,
71
+ }
72
+ }
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
+ /**
96
+ * Review comments (on diff lines) and issue comments (PR-level), with the resolved flag from
97
+ * GraphQL. A GraphQL failure degrades to `resolved: false` everywhere plus a warning.
98
+ */
99
+ export async function fetchComments(
100
+ gh: GitHubClient,
101
+ repo: Repo,
102
+ number: number,
103
+ headSha: string,
104
+ now: () => Date
105
+ ): Promise<FetchCommentsResult> {
106
+ const warnings: string[] = []
107
+ const base = `repos/${repo.owner}/${repo.name}`
108
+ const [reviewRaw, issueRaw] = await Promise.all([
109
+ fetchAllPages(gh, `${base}/pulls/${number}/comments`),
110
+ fetchAllPages(gh, `${base}/issues/${number}/comments`),
111
+ ])
112
+ const reviews = (await fetchAllPages(gh, `${base}/pulls/${number}/reviews`)).flatMap(raw => {
113
+ const review = GhIssueCommentSchema.omit({ created_at: true }).extend({
114
+ submitted_at: z.string().nullable().optional(), state: z.string(),
115
+ }).parse(raw)
116
+ if (!review.submitted_at || review.state === 'PENDING') return []
117
+ return [{ ...mapIssueComment({ ...review, created_at: review.submitted_at }), state: review.state }]
118
+ })
119
+ let resolvedIds: Set<number>
120
+ try {
121
+ resolvedIds = await fetchResolvedCommentIds(gh, repo, number)
122
+ } catch (err) {
123
+ resolvedIds = new Set()
124
+ warnings.push(`resolved state unavailable: ${err instanceof Error ? err.message : String(err)}`)
125
+ }
126
+ const reviewComments = reviewRaw.map(c => mapReviewComment(c, resolvedIds))
127
+ const issueComments = issueRaw.map(mapIssueComment)
128
+ return {
129
+ payload: { fetchedAt: now().toISOString(), headSha, reviewComments, issueComments, reviews },
130
+ warnings,
131
+ }
132
+ }
@@ -0,0 +1,196 @@
1
+ import { execFile } from 'node:child_process'
2
+
3
+ /**
4
+ * The GitHub operations the tool needs, all through the `gh` CLI so the user's own login is
5
+ * used and no token is ever read by this code. Routes receive an implementation through
6
+ * AppContext; tests use an in-memory fake.
7
+ */
8
+ export interface GitHubClient {
9
+ /** `gh api --method GET <path>` with optional query params; returns the parsed JSON body. */
10
+ api(path: string, params?: Record<string, string>): Promise<unknown>
11
+ /** `gh api -i --method GET <path>`: the response headers as well as the body. */
12
+ apiWithHeaders(path: string): Promise<GhResponse>
13
+ /** `gh api --method POST <path> --input -`; the JSON body goes over stdin, never the command line. */
14
+ post(path: string, body: unknown): Promise<unknown>
15
+ /** `gh api graphql`; returns the parsed `data` object. */
16
+ graphql(query: string, variables: Record<string, string | number>): Promise<unknown>
17
+ /** `gh auth status`: is the CLI installed and logged in? */
18
+ authStatus(): Promise<{ installed: boolean; authenticated: boolean; detail: string }>
19
+ /**
20
+ * `gh auth token`: the token of the current login, or null when there is none. Callers keep it
21
+ * in a local variable for the length of one request; it is never logged or written to disk.
22
+ */
23
+ authToken(): Promise<string | null>
24
+ }
25
+
26
+ /** One HTTP answer from `gh api -i`: the status, the header names in lower case, and the body. */
27
+ export interface GhResponse {
28
+ status: number
29
+ headers: Record<string, string>
30
+ body: unknown
31
+ }
32
+
33
+ /**
34
+ * Splits the output of `gh api -i` into the last header block and the body. A redirect prints
35
+ * one block per hop, and only the final one describes the answer.
36
+ */
37
+ export function parseIncludedResponse(stdout: string): GhResponse {
38
+ const normalized = stdout.replace(/\r\n/g, '\n')
39
+ const parts = normalized.split('\n\n')
40
+ let body: unknown = null
41
+ const headerBlocks: string[] = []
42
+ for (const [i, part] of parts.entries()) {
43
+ if (/^HTTP\/[\d.]+ \d{3}/.test(part)) {
44
+ headerBlocks.push(part)
45
+ continue
46
+ }
47
+ // Everything after the last header block is the body, which may hold blank lines itself.
48
+ body = parseJsonOrNull(parts.slice(i).join('\n\n'))
49
+ break
50
+ }
51
+ const last = headerBlocks[headerBlocks.length - 1] ?? ''
52
+ const lines = last.split('\n').filter(l => l.trim() !== '')
53
+ const status = Number(/^HTTP\/[\d.]+ (\d{3})/.exec(lines[0] ?? '')?.[1] ?? 0)
54
+ const headers: Record<string, string> = {}
55
+ for (const line of lines.slice(1)) {
56
+ const at = line.indexOf(':')
57
+ if (at > 0) {
58
+ headers[line.slice(0, at).trim().toLowerCase()] = line.slice(at + 1).trim()
59
+ }
60
+ }
61
+ return { status, headers, body }
62
+ }
63
+
64
+ function parseJsonOrNull(text: string): unknown {
65
+ if (text.trim() === '') {
66
+ return null
67
+ }
68
+ try {
69
+ return JSON.parse(text) as unknown
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ export class GitHubApiError extends Error {
76
+ readonly path: string
77
+ readonly stderr: string
78
+ readonly exitCode: number
79
+ /** True when the `gh` binary is not on PATH. */
80
+ readonly missingBinary: boolean
81
+
82
+ constructor(path: string, stderr: string, exitCode: number, missingBinary = false) {
83
+ super(`gh api ${path} failed (${exitCode}): ${stderr.trim()}`)
84
+ this.name = 'GitHubApiError'
85
+ this.path = path
86
+ this.stderr = stderr
87
+ this.exitCode = exitCode
88
+ this.missingBinary = missingBinary
89
+ }
90
+ /** `gh api` exits 1 with "HTTP 404" in stderr for a missing resource. */
91
+ get notFound(): boolean {
92
+ return /HTTP 404/.test(this.stderr)
93
+ }
94
+ get unauthenticated(): boolean {
95
+ return /HTTP 401|not logged into|gh auth login/i.test(this.stderr)
96
+ }
97
+ }
98
+
99
+ interface ExecResult {
100
+ stdout: string
101
+ stderr: string
102
+ code: number
103
+ missingBinary: boolean
104
+ }
105
+
106
+ export interface GhExecOptions {
107
+ binary?: string
108
+ /** Written to the child's stdin, for `gh api --input -`. */
109
+ input?: string
110
+ }
111
+
112
+ export function execGh(args: string[], opts: GhExecOptions = {}): Promise<ExecResult> {
113
+ return new Promise(resolve => {
114
+ const child = execFile(
115
+ opts.binary ?? 'gh',
116
+ args,
117
+ { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
118
+ (error, stdout, stderr) => {
119
+ const missingBinary = error !== null && 'code' in error && error.code === 'ENOENT'
120
+ const code = error && typeof error.code === 'number' ? error.code : error ? 1 : 0
121
+ resolve({ stdout, stderr, code, missingBinary })
122
+ }
123
+ )
124
+ if (opts.input !== undefined) {
125
+ child.stdin?.on('error', () => undefined)
126
+ child.stdin?.end(opts.input)
127
+ }
128
+ })
129
+ }
130
+
131
+ export type GhExec = typeof execGh
132
+
133
+ export function createGitHubClient(exec: GhExec = execGh): GitHubClient {
134
+ return {
135
+ api: async (path, params = {}) => {
136
+ // `gh api` switches to POST as soon as a field is given; these are reads, so pin GET.
137
+ const args = ['api', '--method', 'GET', path]
138
+ for (const [k, v] of Object.entries(params)) {
139
+ args.push('-F', `${k}=${v}`)
140
+ }
141
+ const r = await exec(args)
142
+ if (r.code !== 0) {
143
+ throw new GitHubApiError(path, r.missingBinary ? 'gh: command not found' : r.stderr, r.code, r.missingBinary)
144
+ }
145
+ return JSON.parse(r.stdout) as unknown
146
+ },
147
+ apiWithHeaders: async path => {
148
+ const r = await exec(['api', '-i', '--method', 'GET', path])
149
+ if (r.code !== 0) {
150
+ throw new GitHubApiError(path, r.missingBinary ? 'gh: command not found' : r.stderr, r.code, r.missingBinary)
151
+ }
152
+ return parseIncludedResponse(r.stdout)
153
+ },
154
+ post: async (path, body) => {
155
+ // The payload goes over stdin, so no comment text ever appears in an argument list.
156
+ const r = await exec(['api', '--method', 'POST', path, '--input', '-'], { input: JSON.stringify(body) })
157
+ if (r.code !== 0) {
158
+ throw new GitHubApiError(path, r.missingBinary ? 'gh: command not found' : r.stderr, r.code, r.missingBinary)
159
+ }
160
+ return JSON.parse(r.stdout) as unknown
161
+ },
162
+ graphql: async (query, variables) => {
163
+ const args = ['api', 'graphql', '-f', `query=${query}`]
164
+ for (const [k, v] of Object.entries(variables)) {
165
+ args.push(typeof v === 'number' ? '-F' : '-f', `${k}=${v}`)
166
+ }
167
+ const r = await exec(args)
168
+ if (r.code !== 0) {
169
+ throw new GitHubApiError(
170
+ 'graphql',
171
+ r.missingBinary ? 'gh: command not found' : r.stderr,
172
+ r.code,
173
+ r.missingBinary
174
+ )
175
+ }
176
+ const body = JSON.parse(r.stdout) as { data?: unknown; errors?: Array<{ message: string }> }
177
+ if (body.errors && body.errors.length > 0) {
178
+ throw new GitHubApiError('graphql', body.errors.map(e => e.message).join('; '), 1)
179
+ }
180
+ return body.data
181
+ },
182
+ authStatus: async () => {
183
+ const r = await exec(['auth', 'status'])
184
+ if (r.missingBinary) {
185
+ return { installed: false, authenticated: false, detail: 'gh is not on PATH' }
186
+ }
187
+ const detail = (r.stdout + r.stderr).trim().split('\n')[0] ?? ''
188
+ return { installed: true, authenticated: r.code === 0, detail }
189
+ },
190
+ authToken: async () => {
191
+ const r = await exec(['auth', 'token'])
192
+ const token = r.stdout.trim()
193
+ return r.code === 0 && token !== '' ? token : null
194
+ },
195
+ }
196
+ }
@@ -0,0 +1,104 @@
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'
4
+ import { mapIssueComment, mapReviewComment } from './comments.js'
5
+ import type { GitHubClient } from './gh.js'
6
+
7
+ /** GitHub names the two sides of a diff LEFT and RIGHT. */
8
+ export function ghSide(side: Side): 'LEFT' | 'RIGHT' {
9
+ return side === 'old' ? 'LEFT' : 'RIGHT'
10
+ }
11
+
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
+ /** The fields GitHub reads for a comment on a diff line. */
45
+ interface InlineCommentBody {
46
+ body: string
47
+ commit_id: string
48
+ path: string
49
+ line: number
50
+ side: 'LEFT' | 'RIGHT'
51
+ start_line?: number
52
+ start_side?: 'LEFT' | 'RIGHT'
53
+ }
54
+
55
+ export type CommentRequestBody = InlineCommentBody | { body: string }
56
+
57
+ /** The JSON body of the REST call for one comment, next to the path it goes to. */
58
+ export function commentRequest(
59
+ repo: Repo,
60
+ number: number,
61
+ headSha: string,
62
+ input: PostCommentInput
63
+ ): { path: string; body: CommentRequestBody } {
64
+ const base = `repos/${repo.owner}/${repo.name}`
65
+ switch (input.kind) {
66
+ case 'inline': {
67
+ // A range is sent only when it covers more than the anchor line; GitHub rejects a
68
+ // start_line equal to line.
69
+ const range =
70
+ input.startLine !== undefined && input.startLine !== input.line
71
+ ? { start_line: input.startLine, start_side: ghSide(input.side) }
72
+ : {}
73
+ const body: InlineCommentBody = {
74
+ body: input.body,
75
+ commit_id: headSha,
76
+ path: input.path,
77
+ line: input.line,
78
+ side: ghSide(input.side),
79
+ ...range,
80
+ }
81
+ return { path: `${base}/pulls/${number}/comments`, body }
82
+ }
83
+ case 'reply':
84
+ return { path: `${base}/pulls/${number}/comments/${input.inReplyToId}/replies`, body: { body: input.body } }
85
+ case 'issue':
86
+ return { path: `${base}/issues/${number}/comments`, body: { body: input.body } }
87
+ }
88
+ }
89
+
90
+ /** Posts one comment and maps GitHub's answer into the shape the page already renders. */
91
+ export async function postComment(
92
+ gh: GitHubClient,
93
+ repo: Repo,
94
+ number: number,
95
+ headSha: string,
96
+ input: PostCommentInput
97
+ ): Promise<PostCommentResult> {
98
+ const { path, body } = commentRequest(repo, number, headSha, input)
99
+ const raw = await gh.post(path, body)
100
+ if (input.kind === 'issue') {
101
+ return { kind: 'issue', comment: mapIssueComment(raw) }
102
+ }
103
+ return { kind: 'review', comment: mapReviewComment(raw, new Set()) }
104
+ }
@@ -0,0 +1,44 @@
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 { 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>
21
+
22
+ const GhReviewSchema = z.object({
23
+ id: z.number().int(),
24
+ state: z.string(),
25
+ html_url: z.string(),
26
+ submitted_at: z.string().nullable().optional(),
27
+ })
28
+
29
+ /** Posts the review. GitHub decides what the event means; the tool only fills the body and sha. */
30
+ export async function postReview(
31
+ gh: GitHubClient,
32
+ repo: Repo,
33
+ number: number,
34
+ headSha: string,
35
+ input: { event: ReviewEvent; body: string }
36
+ ): Promise<ReviewSummary> {
37
+ const raw = await gh.post(`repos/${repo.owner}/${repo.name}/pulls/${number}/reviews`, {
38
+ event: input.event,
39
+ body: input.body,
40
+ commit_id: headSha,
41
+ })
42
+ const r = GhReviewSchema.parse(raw)
43
+ return { id: r.id, state: r.state, url: r.html_url, submittedAt: r.submitted_at ?? null }
44
+ }