@iceinvein/agent-skills 0.23.9 → 0.23.13
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/package.json +1 -1
- package/skills/index.json +2 -2
- package/skills/magpie/SKILL.md +21 -20
- package/skills/magpie/bin/magpie.ts +63 -2
- package/skills/magpie/evals/quality/README.md +74 -1
- package/skills/magpie/evals/quality/__tests__/probe-specialist.test.ts +498 -0
- package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +9 -0
- package/skills/magpie/evals/quality/probe-specialist.ts +672 -0
- package/skills/magpie/evals/quality/replay-full.ts +6 -2
- package/skills/magpie/fixtures/fake-gh-with-lockfile.sh +37 -2
- package/skills/magpie/fixtures/fake-gh.sh +49 -2
- package/skills/magpie/references/critic.md +20 -4
- package/skills/magpie/references/proof.md +102 -0
- package/skills/magpie/references/scout.md +6 -1
- package/skills/magpie/references/specialists.md +15 -9
- package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +103 -7
- package/skills/magpie/scripts/__tests__/cli.test.ts +69 -0
- package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +46 -0
- package/skills/magpie/scripts/__tests__/critic.test.ts +11 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +20 -1
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +23 -4
- package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +68 -0
- package/skills/magpie/scripts/__tests__/history.test.ts +232 -0
- package/skills/magpie/scripts/__tests__/impact-cmd.test.ts +477 -0
- package/skills/magpie/scripts/__tests__/impact-render.test.ts +365 -0
- package/skills/magpie/scripts/__tests__/impact.test.ts +915 -0
- package/skills/magpie/scripts/__tests__/incremental.test.ts +59 -8
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -1
- package/skills/magpie/scripts/__tests__/post-cmd.test.ts +58 -0
- package/skills/magpie/scripts/__tests__/pr-activity.test.ts +281 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +4 -4
- package/skills/magpie/scripts/__tests__/previous-findings.test.ts +267 -0
- package/skills/magpie/scripts/__tests__/proof-cmd.test.ts +260 -0
- package/skills/magpie/scripts/__tests__/proof.test.ts +226 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +71 -0
- package/skills/magpie/scripts/__tests__/render-annotation.test.ts +34 -0
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +75 -0
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +9 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +376 -1
- package/skills/magpie/scripts/__tests__/shard-coverage.test.ts +79 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +48 -2
- package/skills/magpie/scripts/__tests__/status-cmd.test.ts +20 -8
- package/skills/magpie/scripts/__tests__/types.test.ts +46 -0
- package/skills/magpie/scripts/cleanup-cmd.ts +88 -0
- package/skills/magpie/scripts/critic-cmd.ts +2 -0
- package/skills/magpie/scripts/critic.ts +4 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +4 -0
- package/skills/magpie/scripts/evidence-filter.ts +10 -0
- package/skills/magpie/scripts/git-diff.ts +50 -19
- package/skills/magpie/scripts/handoff-cmd.ts +42 -3
- package/skills/magpie/scripts/history.ts +143 -0
- package/skills/magpie/scripts/impact-cmd.ts +202 -0
- package/skills/magpie/scripts/impact-render.ts +158 -0
- package/skills/magpie/scripts/impact.ts +622 -0
- package/skills/magpie/scripts/incremental.ts +44 -5
- package/skills/magpie/scripts/labels-cmd.ts +7 -3
- package/skills/magpie/scripts/post-cmd.ts +46 -0
- package/skills/magpie/scripts/pr-activity.ts +243 -0
- package/skills/magpie/scripts/preview-cmd.ts +3 -0
- package/skills/magpie/scripts/previous-findings.ts +181 -0
- package/skills/magpie/scripts/proof-cmd.ts +226 -0
- package/skills/magpie/scripts/proof.ts +150 -0
- package/skills/magpie/scripts/refresh.ts +11 -0
- package/skills/magpie/scripts/render-annotation.ts +17 -0
- package/skills/magpie/scripts/render-cmd.ts +12 -0
- package/skills/magpie/scripts/render-findings.ts +37 -1
- package/skills/magpie/scripts/render-progress.ts +3 -0
- package/skills/magpie/scripts/setup-cmd.ts +142 -11
- package/skills/magpie/scripts/shard-coverage.ts +53 -0
- package/skills/magpie/scripts/status-cmd.ts +2 -0
- package/skills/magpie/scripts/types.ts +39 -0
- package/skills/magpie/skill.json +1 -1
- package/skills/magpie/templates/styles.css +30 -0
- package/skills/sluice/scripts/status.sh +32 -13
- package/skills/sluice/skill.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { foldLabels, readJsonLines } from './labels.ts'
|
|
3
|
+
import { type FindingLabel, foldLabels, readJsonLines } from './labels.ts'
|
|
4
4
|
import type { PostStatusMap } from './types.ts'
|
|
5
5
|
|
|
6
|
-
export async function
|
|
6
|
+
export async function readRunLabels(runDir: string): Promise<FindingLabel[]> {
|
|
7
7
|
const findingsPath = join(runDir, 'findings.final.json')
|
|
8
8
|
const raw = JSON.parse(await readFile(findingsPath, 'utf8')) as unknown
|
|
9
9
|
if (!Array.isArray(raw)) throw new Error(`${findingsPath} is not an array`)
|
|
@@ -21,12 +21,16 @@ export async function writeLabels(runDir: string): Promise<number> {
|
|
|
21
21
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
return foldLabels({
|
|
25
25
|
findingIds,
|
|
26
26
|
postStatus,
|
|
27
27
|
events: await readJsonLines(join(runDir, 'state', 'events')),
|
|
28
28
|
log: await readJsonLines(join(runDir, 'log.jsonl')),
|
|
29
29
|
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function writeLabels(runDir: string): Promise<number> {
|
|
33
|
+
const labels = await readRunLabels(runDir)
|
|
30
34
|
await writeFile(join(runDir, 'labels.json'), `${JSON.stringify(labels, null, 2)}\n`)
|
|
31
35
|
return labels.length
|
|
32
36
|
}
|
|
@@ -3,7 +3,10 @@ import { appendFile, readFile, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { buildNewSideLineIndex } from './diff-utils.ts'
|
|
5
5
|
import { formatFindingDescriptionMarkdown } from './finding-description.ts'
|
|
6
|
+
import { readHistory } from './history.ts'
|
|
6
7
|
import type { PostVia } from './labels.ts'
|
|
8
|
+
import { describePreviousPosted } from './previous-findings.ts'
|
|
9
|
+
import { describeUnreviewed, readShardCoverage } from './shard-coverage.ts'
|
|
7
10
|
import {
|
|
8
11
|
type FocusId,
|
|
9
12
|
type PrFileEntry,
|
|
@@ -150,6 +153,31 @@ function buildSuggestion(f: ReviewFinding): SuggestionParts {
|
|
|
150
153
|
}
|
|
151
154
|
}
|
|
152
155
|
|
|
156
|
+
/** GitHub renders the `<summary>` line as HTML, where a raw `<` in a command would start a tag. */
|
|
157
|
+
function escapeHtml(s: string): string {
|
|
158
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The repro rides folded under the finding, so the comment stays short on GitHub. */
|
|
162
|
+
function buildRepro(f: ReviewFinding): string | null {
|
|
163
|
+
if (f.proof?.status !== 'reproduced') return null
|
|
164
|
+
const { command, source, output } = f.proof.repro
|
|
165
|
+
const fence = (code: string) => '`'.repeat(Math.max(3, maxBacktickRun(code) + 1))
|
|
166
|
+
return [
|
|
167
|
+
`<details><summary>Reproduced with <code>${escapeHtml(command)}</code></summary>`,
|
|
168
|
+
'',
|
|
169
|
+
fence(source),
|
|
170
|
+
source,
|
|
171
|
+
fence(source),
|
|
172
|
+
'',
|
|
173
|
+
fence(output),
|
|
174
|
+
output,
|
|
175
|
+
fence(output),
|
|
176
|
+
'',
|
|
177
|
+
'</details>',
|
|
178
|
+
].join('\n')
|
|
179
|
+
}
|
|
180
|
+
|
|
153
181
|
function buildFindingMarker(f: ReviewFinding): string {
|
|
154
182
|
const normalized = JSON.stringify({
|
|
155
183
|
file: f.file || '',
|
|
@@ -180,6 +208,7 @@ export function formatInlineBody(f: ReviewFinding): string {
|
|
|
180
208
|
const metaLine = buildMetaLine([...buildRiskParts(f), focus ? `Focus · ${focus}` : null])
|
|
181
209
|
const { block: suggestion, preamble: sugPre, postamble: sugPost } = buildSuggestion(f)
|
|
182
210
|
const sections = formatFindingDescriptionMarkdown(f.description)
|
|
211
|
+
const repro = buildRepro(f)
|
|
183
212
|
|
|
184
213
|
return joinBlock([
|
|
185
214
|
`### ${icon} ${label}: ${f.title}`,
|
|
@@ -193,6 +222,8 @@ export function formatInlineBody(f: ReviewFinding): string {
|
|
|
193
222
|
suggestion,
|
|
194
223
|
sugPost ? '' : null,
|
|
195
224
|
sugPost,
|
|
225
|
+
repro ? '' : null,
|
|
226
|
+
repro,
|
|
196
227
|
'',
|
|
197
228
|
buildFindingMarker(f),
|
|
198
229
|
])
|
|
@@ -213,6 +244,7 @@ export function formatConversationBody(f: ReviewFinding): string {
|
|
|
213
244
|
...buildRiskParts(f),
|
|
214
245
|
])
|
|
215
246
|
const sections = formatFindingDescriptionMarkdown(f.description)
|
|
247
|
+
const repro = buildRepro(f)
|
|
216
248
|
const { block: suggestion, preamble: sugPre, postamble: sugPost } = buildSuggestion(f)
|
|
217
249
|
|
|
218
250
|
return joinBlock([
|
|
@@ -227,6 +259,8 @@ export function formatConversationBody(f: ReviewFinding): string {
|
|
|
227
259
|
suggestion,
|
|
228
260
|
sugPost ? '' : null,
|
|
229
261
|
sugPost,
|
|
262
|
+
repro ? '' : null,
|
|
263
|
+
repro,
|
|
230
264
|
'',
|
|
231
265
|
buildFindingMarker(f),
|
|
232
266
|
])
|
|
@@ -260,6 +294,10 @@ export type ReviewSummaryOptions = {
|
|
|
260
294
|
files?: PrFileEntry[]
|
|
261
295
|
/** Incremental review context. When present, surfaces "since <prev-sha>" trailer. */
|
|
262
296
|
incremental?: { previousSha: string; sameSha: boolean }
|
|
297
|
+
/** `describePreviousPosted` on a re-review. */
|
|
298
|
+
previousRun?: string
|
|
299
|
+
/** `describeUnreviewed` when shards were skipped at the stage 4 gate. */
|
|
300
|
+
unreviewed?: string
|
|
263
301
|
}
|
|
264
302
|
|
|
265
303
|
/** Effort score 1 (trivial) to 5 (substantial), derived from changed files and lines. */
|
|
@@ -383,6 +421,8 @@ export function formatReviewSummaryBody(
|
|
|
383
421
|
: `Incremental review since \`${prevShort}\``
|
|
384
422
|
lines.push('', `<sub>${msg}</sub>`)
|
|
385
423
|
}
|
|
424
|
+
if (options.previousRun) lines.push('', `<sub>${options.previousRun}</sub>`)
|
|
425
|
+
if (options.unreviewed) lines.push('', `<sub>${options.unreviewed}</sub>`)
|
|
386
426
|
|
|
387
427
|
if (findings.length > 0) {
|
|
388
428
|
const inlineText =
|
|
@@ -862,11 +902,17 @@ export async function runPost(input: PostInput): Promise<PostOutcome> {
|
|
|
862
902
|
// No incremental sidecar; first run for this PR.
|
|
863
903
|
}
|
|
864
904
|
|
|
905
|
+
const { previous } = await readHistory(input.runDir)
|
|
906
|
+
const previousRun = previous ? describePreviousPosted(previous) : null
|
|
907
|
+
const coverage = await readShardCoverage(input.runDir)
|
|
908
|
+
|
|
865
909
|
if (shouldPostSummary) {
|
|
866
910
|
const summaryBody = formatReviewSummaryBody(selectedFindings, {
|
|
867
911
|
commitId: headSha,
|
|
868
912
|
files: prFiles,
|
|
869
913
|
...(incremental ? { incremental } : {}),
|
|
914
|
+
...(previousRun ? { previousRun } : {}),
|
|
915
|
+
...(coverage ? { unreviewed: describeUnreviewed(coverage) } : {}),
|
|
870
916
|
})
|
|
871
917
|
const summaryCmd = buildPrCommentCommand(repo, prNumber, summaryBody)
|
|
872
918
|
if (input.dryRun) {
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type PrCheck = {
|
|
5
|
+
name: string
|
|
6
|
+
/** CheckRun conclusion, or its status while it has none; StatusContext state. Upper case as GitHub sends it. */
|
|
7
|
+
conclusion: string
|
|
8
|
+
url: string | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type PrThread = {
|
|
12
|
+
file: string
|
|
13
|
+
line: number | null
|
|
14
|
+
resolved: boolean
|
|
15
|
+
outdated: boolean
|
|
16
|
+
author: string
|
|
17
|
+
/** The thread's first comment. */
|
|
18
|
+
body: string
|
|
19
|
+
replies: number
|
|
20
|
+
/** Posted by magpie in an earlier run, recognised by its hidden marker. */
|
|
21
|
+
magpie: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PrComment = {
|
|
25
|
+
author: string
|
|
26
|
+
body: string
|
|
27
|
+
magpie: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type PrActivity = {
|
|
31
|
+
checks: PrCheck[]
|
|
32
|
+
threads: PrThread[]
|
|
33
|
+
comments: PrComment[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type FetchPrActivityInput = {
|
|
37
|
+
ghBin: string
|
|
38
|
+
prNumber: number
|
|
39
|
+
/** pr.json's `url`, the source of owner and repo for the thread query. */
|
|
40
|
+
prUrl: string
|
|
41
|
+
runDir: string
|
|
42
|
+
cwd?: string
|
|
43
|
+
env?: Record<string, string>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type FetchPrActivityResult =
|
|
47
|
+
| { ok: true; activity: PrActivity }
|
|
48
|
+
| { ok: false; error: string }
|
|
49
|
+
|
|
50
|
+
const ACTIVITY_VIEW_FIELDS = 'statusCheckRollup,comments,reviews'
|
|
51
|
+
|
|
52
|
+
const FINDING_MARKER = '<!-- magpie:finding'
|
|
53
|
+
const SUMMARY_MARKER = '<!-- magpie:summary -->'
|
|
54
|
+
const REVIEW_BODY_SEPARATOR = '\n\n---\n\n'
|
|
55
|
+
|
|
56
|
+
// gh pr view does not expose review threads, which carry the file, line and
|
|
57
|
+
// resolved state, so they come from GraphQL a page at a time.
|
|
58
|
+
const THREADS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) {
|
|
59
|
+
repository(owner: $owner, name: $repo) {
|
|
60
|
+
pullRequest(number: $number) {
|
|
61
|
+
reviewThreads(first: 100, after: $after) {
|
|
62
|
+
pageInfo { hasNextPage endCursor }
|
|
63
|
+
nodes {
|
|
64
|
+
path line originalLine isResolved isOutdated
|
|
65
|
+
comments(first: 1) { totalCount nodes { author { login } body } }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}`
|
|
71
|
+
|
|
72
|
+
type GhRun = { ok: true; stdout: string } | { ok: false; error: string }
|
|
73
|
+
|
|
74
|
+
async function runGh(input: FetchPrActivityInput, args: string[]): Promise<GhRun> {
|
|
75
|
+
const proc = Bun.spawn([input.ghBin, ...args], {
|
|
76
|
+
stdout: 'pipe',
|
|
77
|
+
stderr: 'pipe',
|
|
78
|
+
...(input.cwd ? { cwd: input.cwd } : {}),
|
|
79
|
+
...(input.env ? { env: { ...process.env, ...input.env } } : {}),
|
|
80
|
+
})
|
|
81
|
+
const [stdout, stderr] = await Promise.all([
|
|
82
|
+
new Response(proc.stdout).text(),
|
|
83
|
+
new Response(proc.stderr).text(),
|
|
84
|
+
])
|
|
85
|
+
const exit = await proc.exited
|
|
86
|
+
if (exit !== 0)
|
|
87
|
+
return { ok: false, error: `gh ${args[0]} ${args[1]} exit ${exit}: ${stderr.trim()}` }
|
|
88
|
+
return { ok: true, stdout }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function str(value: unknown): string {
|
|
92
|
+
return typeof value === 'string' ? value : ''
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// GitHub returns a null author for a deleted account and shows it as "ghost".
|
|
96
|
+
function login(author: unknown): string {
|
|
97
|
+
return author === null ? 'ghost' : str((author as { login?: unknown }).login)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseChecks(rollup: unknown): PrCheck[] {
|
|
101
|
+
if (!Array.isArray(rollup)) return []
|
|
102
|
+
return rollup.map((raw) => {
|
|
103
|
+
const c = raw as Record<string, unknown>
|
|
104
|
+
if (c.__typename === 'StatusContext') {
|
|
105
|
+
return { name: str(c.context), conclusion: str(c.state), url: str(c.targetUrl) || null }
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
name: str(c.name),
|
|
109
|
+
conclusion: str(c.conclusion) || str(c.status),
|
|
110
|
+
url: str(c.detailsUrl) || null,
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseComments(view: Record<string, unknown>): PrComment[] {
|
|
116
|
+
const entries = [
|
|
117
|
+
...(Array.isArray(view.comments) ? view.comments : []),
|
|
118
|
+
...(Array.isArray(view.reviews) ? view.reviews : []),
|
|
119
|
+
] as Array<Record<string, unknown>>
|
|
120
|
+
// The report's Post Selected joins findings with no inline anchor into one
|
|
121
|
+
// review body; split it so each one is its own entry rather than one cut short.
|
|
122
|
+
return entries
|
|
123
|
+
.flatMap((e) =>
|
|
124
|
+
str(e.body)
|
|
125
|
+
.split(REVIEW_BODY_SEPARATOR)
|
|
126
|
+
.map((body) => ({ author: login(e.author), body })),
|
|
127
|
+
)
|
|
128
|
+
.filter((c) => c.body.trim() !== '' && !c.body.includes(SUMMARY_MARKER))
|
|
129
|
+
.map((c) => ({ ...c, magpie: c.body.includes(FINDING_MARKER) }))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Null for a thread whose opening comment was deleted: it has nothing left to repeat. */
|
|
133
|
+
function parseThread(raw: unknown): PrThread | null {
|
|
134
|
+
const t = raw as Record<string, unknown>
|
|
135
|
+
const comments = (t.comments ?? {}) as { totalCount?: unknown; nodes?: unknown }
|
|
136
|
+
const first = (Array.isArray(comments.nodes) ? comments.nodes[0] : undefined) as
|
|
137
|
+
| Record<string, unknown>
|
|
138
|
+
| undefined
|
|
139
|
+
const body = str(first?.body)
|
|
140
|
+
if (!first || body.trim() === '') return null
|
|
141
|
+
const total = typeof comments.totalCount === 'number' ? comments.totalCount : 0
|
|
142
|
+
// An outdated thread has no current line; where it was first left is still the best pointer.
|
|
143
|
+
const line =
|
|
144
|
+
typeof t.line === 'number' ? t.line : typeof t.originalLine === 'number' ? t.originalLine : null
|
|
145
|
+
return {
|
|
146
|
+
file: str(t.path),
|
|
147
|
+
line,
|
|
148
|
+
resolved: t.isResolved === true,
|
|
149
|
+
outdated: t.isOutdated === true,
|
|
150
|
+
author: login(first.author),
|
|
151
|
+
body,
|
|
152
|
+
replies: Math.max(total - 1, 0),
|
|
153
|
+
magpie: body.includes(FINDING_MARKER),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function fetchThreads(
|
|
158
|
+
input: FetchPrActivityInput,
|
|
159
|
+
owner: string,
|
|
160
|
+
repo: string,
|
|
161
|
+
): Promise<{ ok: true; threads: PrThread[] } | { ok: false; error: string }> {
|
|
162
|
+
const threads: PrThread[] = []
|
|
163
|
+
let after: string | null = null
|
|
164
|
+
do {
|
|
165
|
+
const args = [
|
|
166
|
+
'api',
|
|
167
|
+
'graphql',
|
|
168
|
+
'-f',
|
|
169
|
+
`query=${THREADS_QUERY}`,
|
|
170
|
+
'-f',
|
|
171
|
+
`owner=${owner}`,
|
|
172
|
+
'-f',
|
|
173
|
+
`repo=${repo}`,
|
|
174
|
+
'-F',
|
|
175
|
+
`number=${input.prNumber}`,
|
|
176
|
+
...(after ? ['-f', `after=${after}`] : []),
|
|
177
|
+
]
|
|
178
|
+
const run = await runGh(input, args)
|
|
179
|
+
if (!run.ok) return run
|
|
180
|
+
let page: {
|
|
181
|
+
data?: { repository?: { pullRequest?: { reviewThreads?: unknown } } } | null
|
|
182
|
+
errors?: Array<{ message?: unknown }>
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
page = JSON.parse(run.stdout)
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return { ok: false, error: `gh api graphql returned unparseable JSON: ${String(err)}` }
|
|
188
|
+
}
|
|
189
|
+
if (page.errors && page.errors.length > 0) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
error: `gh api graphql: ${page.errors.map((e) => str(e.message)).join('; ')}`,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const connection = page.data?.repository?.pullRequest?.reviewThreads as
|
|
196
|
+
| { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: unknown }
|
|
197
|
+
| undefined
|
|
198
|
+
if (!connection || !Array.isArray(connection.nodes)) {
|
|
199
|
+
return { ok: false, error: 'gh api graphql returned no reviewThreads for the PR' }
|
|
200
|
+
}
|
|
201
|
+
threads.push(...connection.nodes.flatMap((node) => parseThread(node) ?? []))
|
|
202
|
+
after =
|
|
203
|
+
connection.pageInfo?.hasNextPage === true ? str(connection.pageInfo.endCursor) || null : null
|
|
204
|
+
} while (after)
|
|
205
|
+
return { ok: true, threads }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** `https://github.com/<owner>/<repo>/pull/<n>` to owner and repo. */
|
|
209
|
+
function ownerRepo(prUrl: string): { owner: string; repo: string } | null {
|
|
210
|
+
const m = prUrl.match(/^https?:\/\/[^/]+\/([^/]+)\/([^/]+)\/pull\/\d+/)
|
|
211
|
+
return m?.[1] && m[2] ? { owner: m[1], repo: m[2] } : null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function fetchPrActivity(input: FetchPrActivityInput): Promise<FetchPrActivityResult> {
|
|
215
|
+
const target = ownerRepo(input.prUrl)
|
|
216
|
+
if (!target) return { ok: false, error: `cannot read owner and repo from PR url ${input.prUrl}` }
|
|
217
|
+
|
|
218
|
+
const view = await runGh(input, [
|
|
219
|
+
'pr',
|
|
220
|
+
'view',
|
|
221
|
+
String(input.prNumber),
|
|
222
|
+
'--json',
|
|
223
|
+
ACTIVITY_VIEW_FIELDS,
|
|
224
|
+
])
|
|
225
|
+
if (!view.ok) return view
|
|
226
|
+
let viewJson: Record<string, unknown>
|
|
227
|
+
try {
|
|
228
|
+
viewJson = JSON.parse(view.stdout)
|
|
229
|
+
} catch (err) {
|
|
230
|
+
return { ok: false, error: `gh pr view returned unparseable JSON: ${String(err)}` }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const threads = await fetchThreads(input, target.owner, target.repo)
|
|
234
|
+
if (!threads.ok) return threads
|
|
235
|
+
|
|
236
|
+
const activity: PrActivity = {
|
|
237
|
+
checks: parseChecks(viewJson.statusCheckRollup),
|
|
238
|
+
threads: threads.threads,
|
|
239
|
+
comments: parseComments(viewJson),
|
|
240
|
+
}
|
|
241
|
+
await writeFile(join(input.runDir, 'pr-activity.json'), `${JSON.stringify(activity, null, 2)}\n`)
|
|
242
|
+
return { ok: true, activity }
|
|
243
|
+
}
|
|
@@ -96,6 +96,7 @@ export const STAGE_PRESETS = {
|
|
|
96
96
|
dedupe: 'done',
|
|
97
97
|
critic: 'done',
|
|
98
98
|
'peer-review': 'done',
|
|
99
|
+
proof: 'done',
|
|
99
100
|
report: 'done',
|
|
100
101
|
},
|
|
101
102
|
counts: { security: 1, bugs: 2, performance: 2, 'code-smells': 2, architecture: 1 },
|
|
@@ -109,6 +110,7 @@ export const STAGE_PRESETS = {
|
|
|
109
110
|
dedupe: 'done',
|
|
110
111
|
critic: 'done',
|
|
111
112
|
'peer-review': 'done',
|
|
113
|
+
proof: 'done',
|
|
112
114
|
report: 'done',
|
|
113
115
|
post: 'done',
|
|
114
116
|
},
|
|
@@ -124,6 +126,7 @@ function pending(): Record<StageId, StageStatus> {
|
|
|
124
126
|
dedupe: 'pending',
|
|
125
127
|
critic: 'pending',
|
|
126
128
|
'peer-review': 'pending',
|
|
129
|
+
proof: 'pending',
|
|
127
130
|
report: 'pending',
|
|
128
131
|
post: 'pending',
|
|
129
132
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { containsEvidence } from './evidence-filter.ts'
|
|
4
|
+
import { DISMISS_REASONS, type DismissReason, type FindingLabel } from './labels.ts'
|
|
5
|
+
import { readRunLabels } from './labels-cmd.ts'
|
|
6
|
+
import { parseFinding } from './types.ts'
|
|
7
|
+
|
|
8
|
+
type PreviousFindingBase = {
|
|
9
|
+
id: string
|
|
10
|
+
/** The run that produced the finding, which can be older than the previous run when carried. */
|
|
11
|
+
fromRunId: string
|
|
12
|
+
file: string
|
|
13
|
+
line: number | null
|
|
14
|
+
title: string
|
|
15
|
+
domain: string | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `anchor` says whether the code the finding quoted is still in the file. It
|
|
20
|
+
* does not say the finding was fixed: the code can change and keep the defect.
|
|
21
|
+
*/
|
|
22
|
+
export type PreviousFinding =
|
|
23
|
+
| (PreviousFindingBase & { label: 'posted'; anchor: 'unchanged' | 'changed' | 'unanchored' })
|
|
24
|
+
| (PreviousFindingBase & { label: 'dismissed'; reason: DismissReason })
|
|
25
|
+
|
|
26
|
+
export type PreviousFindings = {
|
|
27
|
+
previousRunId: string
|
|
28
|
+
findings: PreviousFinding[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isMissingFile(err: unknown): boolean {
|
|
32
|
+
return (err as NodeJS.ErrnoException)?.code === 'ENOENT'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readOptionalJson(path: string): Promise<unknown | null> {
|
|
36
|
+
let text: string
|
|
37
|
+
try {
|
|
38
|
+
text = await readFile(path, 'utf8')
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (isMissingFile(err)) return null
|
|
41
|
+
throw err
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(text)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
throw new Error(`${path}: ${err instanceof Error ? err.message : String(err)}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** cleanup writes labels.json before archiving; an active run's labels are folded fresh. */
|
|
51
|
+
async function readLabels(runDir: string): Promise<FindingLabel[]> {
|
|
52
|
+
const path = join(runDir, 'labels.json')
|
|
53
|
+
const raw = await readOptionalJson(path)
|
|
54
|
+
if (raw === null) return readRunLabels(runDir)
|
|
55
|
+
if (!Array.isArray(raw)) throw new Error(`${path}: expected a JSON array`)
|
|
56
|
+
return raw as FindingLabel[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function anchorStatus(
|
|
60
|
+
worktree: string,
|
|
61
|
+
file: string,
|
|
62
|
+
line: number | null,
|
|
63
|
+
evidence: string | undefined,
|
|
64
|
+
): Promise<'unchanged' | 'changed' | 'unanchored'> {
|
|
65
|
+
if (line === null || !evidence?.trim()) return 'unanchored'
|
|
66
|
+
let text: string
|
|
67
|
+
try {
|
|
68
|
+
text = await readFile(join(worktree, file), 'utf8')
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (isMissingFile(err)) return 'changed'
|
|
71
|
+
throw err
|
|
72
|
+
}
|
|
73
|
+
return containsEvidence(text, evidence) ? 'unchanged' : 'changed'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Dismissals only travel one run at a time: a finding the critic dropped as
|
|
78
|
+
* already dismissed never reaches findings.final.json, so the next run would
|
|
79
|
+
* forget it unless the previous run's own carried dismissals come along too.
|
|
80
|
+
*/
|
|
81
|
+
function parseCarried(raw: unknown, path: string): PreviousFinding[] {
|
|
82
|
+
if (raw === null) return []
|
|
83
|
+
const findings = (raw as { findings?: unknown }).findings
|
|
84
|
+
if (!Array.isArray(findings)) throw new Error(`${path}: expected { findings: [...] }`)
|
|
85
|
+
return findings.flatMap((entry, i) => {
|
|
86
|
+
const f = entry as Record<string, unknown>
|
|
87
|
+
if (f.label !== 'dismissed') return []
|
|
88
|
+
const reason = DISMISS_REASONS.find((r) => r === f.reason)
|
|
89
|
+
if (
|
|
90
|
+
typeof f.id !== 'string' ||
|
|
91
|
+
typeof f.fromRunId !== 'string' ||
|
|
92
|
+
typeof f.file !== 'string' ||
|
|
93
|
+
typeof f.title !== 'string' ||
|
|
94
|
+
!reason
|
|
95
|
+
) {
|
|
96
|
+
throw new Error(`${path}: findings[${i}] is not a dismissed finding`)
|
|
97
|
+
}
|
|
98
|
+
return [
|
|
99
|
+
{
|
|
100
|
+
id: f.id,
|
|
101
|
+
fromRunId: f.fromRunId,
|
|
102
|
+
file: f.file,
|
|
103
|
+
line: typeof f.line === 'number' ? f.line : null,
|
|
104
|
+
title: f.title,
|
|
105
|
+
domain: typeof f.domain === 'string' ? f.domain : null,
|
|
106
|
+
label: 'dismissed' as const,
|
|
107
|
+
reason,
|
|
108
|
+
},
|
|
109
|
+
]
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The previous run's posted and dismissed findings, for a re-review to carry
|
|
115
|
+
* forward. Ignored findings are left out: nobody judged them. A run aborted
|
|
116
|
+
* before findings.final.json still passes on what it carried. Null when it has
|
|
117
|
+
* neither.
|
|
118
|
+
*/
|
|
119
|
+
export async function readPreviousFindings(input: {
|
|
120
|
+
runDir: string
|
|
121
|
+
runId: string
|
|
122
|
+
worktree: string
|
|
123
|
+
}): Promise<PreviousFindings | null> {
|
|
124
|
+
const finalPath = join(input.runDir, 'findings.final.json')
|
|
125
|
+
const raw = await readOptionalJson(finalPath)
|
|
126
|
+
const carriedPath = join(input.runDir, 'previous-findings.json')
|
|
127
|
+
const carriedRaw = await readOptionalJson(carriedPath)
|
|
128
|
+
if (raw === null && carriedRaw === null) return null
|
|
129
|
+
if (raw !== null && !Array.isArray(raw)) throw new Error(`${finalPath}: expected a JSON array`)
|
|
130
|
+
const labels =
|
|
131
|
+
raw === null ? new Map() : new Map((await readLabels(input.runDir)).map((l) => [l.id, l]))
|
|
132
|
+
|
|
133
|
+
const findings: PreviousFinding[] = []
|
|
134
|
+
for (const entry of raw ?? []) {
|
|
135
|
+
const f = parseFinding(entry)
|
|
136
|
+
const label = labels.get(f.id)
|
|
137
|
+
const base = {
|
|
138
|
+
id: f.id,
|
|
139
|
+
fromRunId: input.runId,
|
|
140
|
+
file: f.file,
|
|
141
|
+
line: f.line,
|
|
142
|
+
title: f.title,
|
|
143
|
+
domain: typeof f.domain === 'string' ? f.domain : null,
|
|
144
|
+
}
|
|
145
|
+
if (label?.label === 'posted') {
|
|
146
|
+
const anchor = await anchorStatus(input.worktree, f.file, f.line, f.evidence)
|
|
147
|
+
findings.push({ ...base, label: 'posted', anchor })
|
|
148
|
+
} else if (label?.label === 'dismissed' && label.reason) {
|
|
149
|
+
findings.push({ ...base, label: 'dismissed', reason: label.reason })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const carried = parseCarried(carriedRaw, carriedPath)
|
|
154
|
+
const seen = new Set(findings.map((f) => `${f.fromRunId}/${f.id}`))
|
|
155
|
+
for (const f of carried) {
|
|
156
|
+
if (!seen.has(`${f.fromRunId}/${f.id}`)) findings.push(f)
|
|
157
|
+
}
|
|
158
|
+
return { previousRunId: input.runId, findings }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function writePreviousFindings(
|
|
162
|
+
runDir: string,
|
|
163
|
+
previous: PreviousFindings,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
await writeFile(join(runDir, 'previous-findings.json'), `${JSON.stringify(previous, null, 2)}\n`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** One line for the report header and the summary comment; null when nothing was posted last review. */
|
|
169
|
+
export function describePreviousPosted(previous: PreviousFindings): string | null {
|
|
170
|
+
const posted = previous.findings.flatMap((f) => (f.label === 'posted' ? [f] : []))
|
|
171
|
+
if (posted.length === 0) return null
|
|
172
|
+
const count = (anchor: string) => posted.filter((f) => f.anchor === anchor).length
|
|
173
|
+
const parts = [
|
|
174
|
+
`Last review: ${posted.length} posted`,
|
|
175
|
+
`${count('changed')} on changed code`,
|
|
176
|
+
`${count('unchanged')} on unchanged code`,
|
|
177
|
+
]
|
|
178
|
+
const unanchored = count('unanchored')
|
|
179
|
+
if (unanchored > 0) parts.push(`${unanchored} not anchored to a line`)
|
|
180
|
+
return parts.join(' · ')
|
|
181
|
+
}
|