@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.
- package/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- 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
|
+
}
|
package/src/github/gh.ts
ADDED
|
@@ -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
|
+
}
|