@iceinvein/agent-skills 0.1.39 → 0.2.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/README.md +18 -2
- package/dist/cli/index.js +105 -28
- package/package.json +1 -1
- package/skills/index.json +14 -2
- package/skills/magpie/README.md +2 -1
- package/skills/magpie/SKILL.md +138 -45
- package/skills/magpie/bin/magpie.ts +43 -0
- package/skills/magpie/fixtures/example-pr/brief.json +18 -0
- package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
- package/skills/magpie/fixtures/fake-gh.sh +14 -0
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/references/peer-review.md +7 -2
- package/skills/magpie/references/scout.md +90 -0
- package/skills/magpie/references/specialists.md +117 -6
- package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
- package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
- package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
- package/skills/magpie/scripts/__tests__/gh.test.ts +90 -0
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
- package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
- package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +16 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
- package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +207 -7
- package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
- package/skills/magpie/scripts/diff-chunks.ts +28 -0
- package/skills/magpie/scripts/findings-files.ts +32 -0
- package/skills/magpie/scripts/gh.ts +68 -14
- package/skills/magpie/scripts/git-diff.ts +111 -0
- package/skills/magpie/scripts/path-filter.ts +9 -5
- package/skills/magpie/scripts/preview-cmd.ts +11 -1
- package/skills/magpie/scripts/refresh.ts +32 -3
- package/skills/magpie/scripts/render-cmd.ts +35 -11
- package/skills/magpie/scripts/render-findings.ts +78 -2
- package/skills/magpie/scripts/render-progress.ts +6 -1
- package/skills/magpie/scripts/setup-cmd.ts +38 -1
- package/skills/magpie/scripts/shard.ts +171 -0
- package/skills/magpie/scripts/status-cmd.ts +7 -4
- package/skills/magpie/scripts/types.ts +50 -0
- package/skills/magpie/skill.json +2 -2
- package/skills/magpie/templates/styles.css +66 -0
- package/skills/migrate/README.md +194 -0
- package/skills/migrate/SKILL.md +197 -0
- package/skills/migrate/bin/migrate +15 -0
- package/skills/migrate/bin/migrate.ts +309 -0
- package/skills/migrate/biome.json +35 -0
- package/skills/migrate/bun.lock +24 -0
- package/skills/migrate/docs/architecture.md +294 -0
- package/skills/migrate/docs/reference.md +590 -0
- package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
- package/skills/migrate/fixtures/tiny-express/app.js +29 -0
- package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
- package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
- package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
- package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
- package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
- package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
- package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
- package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
- package/skills/migrate/install.sh +68 -0
- package/skills/migrate/package.json +17 -0
- package/skills/migrate/references/phases/enumerate.md +291 -0
- package/skills/migrate/references/phases/extract.md +652 -0
- package/skills/migrate/references/phases/parity.md +275 -0
- package/skills/migrate/references/phases/probe.md +135 -0
- package/skills/migrate/references/phases/queue.md +242 -0
- package/skills/migrate/references/phases/seam.md +416 -0
- package/skills/migrate/references/recipes/README.md +116 -0
- package/skills/migrate/references/recipes/aspnet.md +287 -0
- package/skills/migrate/references/run-ops.md +280 -0
- package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
- package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
- package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
- package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
- package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
- package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
- package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
- package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
- package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
- package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
- package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
- package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
- package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
- package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
- package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
- package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
- package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
- package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
- package/skills/migrate/scripts/census-cmd.ts +109 -0
- package/skills/migrate/scripts/census.ts +342 -0
- package/skills/migrate/scripts/check-cmd.ts +24 -0
- package/skills/migrate/scripts/check.ts +376 -0
- package/skills/migrate/scripts/citations.ts +92 -0
- package/skills/migrate/scripts/config.ts +237 -0
- package/skills/migrate/scripts/ids.ts +31 -0
- package/skills/migrate/scripts/import-cmd.ts +141 -0
- package/skills/migrate/scripts/init-cmd.ts +118 -0
- package/skills/migrate/scripts/leaks.ts +184 -0
- package/skills/migrate/scripts/lock.ts +188 -0
- package/skills/migrate/scripts/paths.ts +103 -0
- package/skills/migrate/scripts/phase-cmd.ts +63 -0
- package/skills/migrate/scripts/phases.ts +113 -0
- package/skills/migrate/scripts/queue-cmd.ts +98 -0
- package/skills/migrate/scripts/queue.ts +258 -0
- package/skills/migrate/scripts/report-cmd.ts +47 -0
- package/skills/migrate/scripts/report.ts +131 -0
- package/skills/migrate/scripts/reset-cmd.ts +120 -0
- package/skills/migrate/scripts/status-cmd.ts +52 -0
- package/skills/migrate/scripts/store.ts +159 -0
- package/skills/migrate/scripts/types.ts +137 -0
- package/skills/migrate/scripts/validate.ts +221 -0
- package/skills/migrate/skill.json +33 -0
- package/skills/migrate/templates/config.toml +27 -0
- package/skills/migrate/templates/queue-item.md +17 -0
- package/skills/migrate/tsconfig.json +18 -0
- package/skills/migrate/uninstall.sh +31 -0
- package/skills/sluice/SKILL.md +82 -0
- package/skills/sluice/references/deep-channel.md +94 -0
- package/skills/sluice/references/finish.md +35 -0
- package/skills/sluice/references/intent.md +29 -0
- package/skills/sluice/references/review.md +42 -0
- package/skills/sluice/references/root-cause.md +38 -0
- package/skills/sluice/references/show-or-say.md +36 -0
- package/skills/sluice/references/test-first.md +35 -0
- package/skills/sluice/references/verify.md +26 -0
- package/skills/sluice/skill.json +32 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export type LocalDiffInput = {
|
|
2
|
+
gitBin: string
|
|
3
|
+
repoPath: string
|
|
4
|
+
prNumber: number
|
|
5
|
+
baseRefName: string
|
|
6
|
+
headRefOid: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type LocalDiffResult =
|
|
10
|
+
| { ok: true; diff: string; mergeBase: string }
|
|
11
|
+
| { ok: false; error: string }
|
|
12
|
+
|
|
13
|
+
async function git(
|
|
14
|
+
gitBin: string,
|
|
15
|
+
cwd: string,
|
|
16
|
+
args: string[],
|
|
17
|
+
): Promise<{ exit: number; stdout: string; stderr: string }> {
|
|
18
|
+
const proc = Bun.spawn([gitBin, ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
19
|
+
const stdout = await new Response(proc.stdout).text()
|
|
20
|
+
const stderr = await new Response(proc.stderr).text()
|
|
21
|
+
const exit = await proc.exited
|
|
22
|
+
return { exit, stdout, stderr }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function resolveCommit(gitBin: string, cwd: string, ref: string): Promise<string | null> {
|
|
26
|
+
const r = await git(gitBin, cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
27
|
+
const sha = r.stdout.trim()
|
|
28
|
+
return r.exit === 0 && sha.length > 0 ? sha : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render a fetch's outcome for an error message, so a genuine failure (wrong
|
|
33
|
+
* remote, auth failure, rate limiting) can be told apart from a benign one
|
|
34
|
+
* (no origin, offline, non-GitHub remote) when resolution ends up failing.
|
|
35
|
+
* Empty when the fetch succeeded, since a successful fetch explains nothing.
|
|
36
|
+
*/
|
|
37
|
+
function fetchDiagnostic(result: { exit: number; stderr: string }): string {
|
|
38
|
+
if (result.exit === 0) return ''
|
|
39
|
+
const stderr = result.stderr.trim()
|
|
40
|
+
return stderr ? `; git fetch: ${stderr}` : `; git fetch exited ${result.exit}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reproduce `gh pr diff` from the local clone. GitHub refuses the `.diff` media
|
|
45
|
+
* type above roughly 300 files, and a review that cannot get a diff is worth
|
|
46
|
+
* less than one built from local objects.
|
|
47
|
+
*
|
|
48
|
+
* Diffs from the merge base rather than the base tip so the output matches
|
|
49
|
+
* `gh pr diff`'s three-dot semantics: the PR's own changes, not every commit
|
|
50
|
+
* the base branch gained since the branch point.
|
|
51
|
+
*/
|
|
52
|
+
export async function localPrDiff(input: LocalDiffInput): Promise<LocalDiffResult> {
|
|
53
|
+
const { gitBin, repoPath, prNumber, baseRefName, headRefOid } = input
|
|
54
|
+
const prRef = `refs/magpie/pr-${prNumber}`
|
|
55
|
+
|
|
56
|
+
// Best effort. `pull/<n>/head` is a GitHub convention and resolves fork PRs
|
|
57
|
+
// that `headRefName` cannot, but a repo with no `origin`, an offline run, or
|
|
58
|
+
// a non-GitHub remote must still work when the commits are already local.
|
|
59
|
+
// Each fetch's own result is kept (not discarded) so, if resolution below
|
|
60
|
+
// still fails, the error can say why the fetch didn't help.
|
|
61
|
+
const headFetch = await git(gitBin, repoPath, [
|
|
62
|
+
'fetch',
|
|
63
|
+
'origin',
|
|
64
|
+
`pull/${prNumber}/head:${prRef}`,
|
|
65
|
+
])
|
|
66
|
+
const baseFetch = await git(gitBin, repoPath, ['fetch', 'origin', baseRefName])
|
|
67
|
+
|
|
68
|
+
let head: string | null = null
|
|
69
|
+
for (const ref of [prRef, headRefOid]) {
|
|
70
|
+
head = await resolveCommit(gitBin, repoPath, ref)
|
|
71
|
+
if (head) break
|
|
72
|
+
}
|
|
73
|
+
if (!head) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error: `cannot resolve PR head ${headRefOid} locally (tried ${prRef} and the SHA); fetch the PR branch and retry${fetchDiagnostic(headFetch)}`,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Reviewing a stale local ref and reporting it as a review of the PR is worse
|
|
80
|
+
// than failing, so a mismatch is fatal rather than a warning.
|
|
81
|
+
if (head !== headRefOid) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: `local PR head ${head} does not match the PR head ${headRefOid}; the local ref is stale`,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let base: string | null = null
|
|
89
|
+
for (const ref of [`origin/${baseRefName}`, baseRefName]) {
|
|
90
|
+
base = await resolveCommit(gitBin, repoPath, ref)
|
|
91
|
+
if (base) break
|
|
92
|
+
}
|
|
93
|
+
if (!base) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `cannot resolve base branch ${baseRefName} locally (tried origin/${baseRefName} and ${baseRefName})${fetchDiagnostic(baseFetch)}`,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const mb = await git(gitBin, repoPath, ['merge-base', base, head])
|
|
101
|
+
if (mb.exit !== 0) {
|
|
102
|
+
return { ok: false, error: `git merge-base exit ${mb.exit}: ${mb.stderr.trim()}` }
|
|
103
|
+
}
|
|
104
|
+
const mergeBase = mb.stdout.trim()
|
|
105
|
+
|
|
106
|
+
const d = await git(gitBin, repoPath, ['diff', '--no-color', '--find-renames', mergeBase, head])
|
|
107
|
+
if (d.exit !== 0) {
|
|
108
|
+
return { ok: false, error: `git diff exit ${d.exit}: ${d.stderr.trim()}` }
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, diff: d.stdout, mergeBase }
|
|
111
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
+
import { chunkPath, splitRawChunks } from './diff-chunks.ts'
|
|
4
5
|
|
|
5
6
|
export type PathFilterConfig = {
|
|
6
7
|
exclude: string[]
|
|
@@ -43,6 +44,12 @@ export const DEFAULT_EXCLUDES: readonly string[] = [
|
|
|
43
44
|
'**/*.min.js',
|
|
44
45
|
'**/*.min.css',
|
|
45
46
|
'**/*.map',
|
|
47
|
+
// Generated .NET / C#
|
|
48
|
+
'**/*.Designer.cs',
|
|
49
|
+
'**/*ModelSnapshot.cs',
|
|
50
|
+
'**/*.g.cs',
|
|
51
|
+
'**/*.g.i.cs',
|
|
52
|
+
'**/*.designer.vb',
|
|
46
53
|
// Snapshot fixtures
|
|
47
54
|
'**/*.snap',
|
|
48
55
|
'**/__snapshots__/**',
|
|
@@ -113,11 +120,9 @@ export async function loadPathFilterConfig(cwd: string): Promise<PathFilterConfi
|
|
|
113
120
|
export type ExcludedFile = { path: string; pattern: string }
|
|
114
121
|
export type FilterDiffResult = { filtered: string; excluded: ExcludedFile[] }
|
|
115
122
|
|
|
116
|
-
const FILE_HEADER = /^diff --git a\/(.+?) b\/(.+?)$/m
|
|
117
|
-
|
|
118
123
|
export function filterDiff(rawDiff: string, config: PathFilterConfig): FilterDiffResult {
|
|
119
124
|
if (!rawDiff) return { filtered: '', excluded: [] }
|
|
120
|
-
const chunks = rawDiff
|
|
125
|
+
const chunks = splitRawChunks(rawDiff)
|
|
121
126
|
const kept: string[] = []
|
|
122
127
|
const excluded: ExcludedFile[] = []
|
|
123
128
|
for (const chunk of chunks) {
|
|
@@ -125,8 +130,7 @@ export function filterDiff(rawDiff: string, config: PathFilterConfig): FilterDif
|
|
|
125
130
|
if (chunk.trim()) kept.push(chunk)
|
|
126
131
|
continue
|
|
127
132
|
}
|
|
128
|
-
const
|
|
129
|
-
const path = m?.[2] ?? m?.[1]
|
|
133
|
+
const path = chunkPath(chunk)
|
|
130
134
|
if (!path) {
|
|
131
135
|
kept.push(chunk)
|
|
132
136
|
continue
|
|
@@ -4,7 +4,7 @@ import type { PostStatusMap } from './render-findings.ts'
|
|
|
4
4
|
import { renderFindingsToDisk } from './render-findings.ts'
|
|
5
5
|
import type { RenderProgressInput, StageId, StageStatus } from './render-progress.ts'
|
|
6
6
|
import { renderProgressToDisk } from './render-progress.ts'
|
|
7
|
-
import { parseFinding } from './types.ts'
|
|
7
|
+
import { type PrBrief, parseBrief, parseFinding } from './types.ts'
|
|
8
8
|
|
|
9
9
|
export type PreviewPage = 'findings' | 'progress' | 'both'
|
|
10
10
|
|
|
@@ -181,6 +181,7 @@ async function readFixture(fixtureDir: string): Promise<{
|
|
|
181
181
|
postStatus: PostStatusMap
|
|
182
182
|
files: Array<{ path: string; additions: number; deletions: number }>
|
|
183
183
|
diff: string
|
|
184
|
+
brief: PrBrief | undefined
|
|
184
185
|
}> {
|
|
185
186
|
const prRaw = JSON.parse(await readFile(join(fixtureDir, 'pr.json'), 'utf8')) as Record<
|
|
186
187
|
string,
|
|
@@ -214,6 +215,13 @@ async function readFixture(fixtureDir: string): Promise<{
|
|
|
214
215
|
} catch {
|
|
215
216
|
// optional file
|
|
216
217
|
}
|
|
218
|
+
let brief: PrBrief | undefined
|
|
219
|
+
try {
|
|
220
|
+
const briefRaw = JSON.parse(await readFile(join(fixtureDir, 'brief.json'), 'utf8'))
|
|
221
|
+
brief = parseBrief(briefRaw) ?? undefined
|
|
222
|
+
} catch {
|
|
223
|
+
// optional file
|
|
224
|
+
}
|
|
217
225
|
return {
|
|
218
226
|
pr: {
|
|
219
227
|
number: Number(prRaw.number ?? 0),
|
|
@@ -224,6 +232,7 @@ async function readFixture(fixtureDir: string): Promise<{
|
|
|
224
232
|
postStatus: postStatusRaw as PostStatusMap,
|
|
225
233
|
files: filesArray,
|
|
226
234
|
diff,
|
|
235
|
+
brief,
|
|
227
236
|
}
|
|
228
237
|
}
|
|
229
238
|
|
|
@@ -270,6 +279,7 @@ export async function runPreview(opts: PreviewOptions): Promise<PreviewResult> {
|
|
|
270
279
|
pr: fixture.pr,
|
|
271
280
|
files: fixture.files,
|
|
272
281
|
diff: fixture.diff,
|
|
282
|
+
brief: fixture.brief,
|
|
273
283
|
},
|
|
274
284
|
findingsPath,
|
|
275
285
|
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdir, readFile, unlink } from 'node:fs/promises'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
|
-
import { type PostStatusMap, renderFindingsToDisk } from './render-findings.ts'
|
|
4
|
-
import { type PrFileEntry, parseFinding } from './types.ts'
|
|
3
|
+
import { type PostStatusMap, parseClosingIssues, renderFindingsToDisk } from './render-findings.ts'
|
|
4
|
+
import { type PrFileEntry, parseBrief, parseFinding } from './types.ts'
|
|
5
5
|
|
|
6
6
|
export type RefreshResult = {
|
|
7
7
|
refreshed: boolean
|
|
@@ -61,6 +61,7 @@ export async function refreshFindings(runDir: string): Promise<RefreshResult> {
|
|
|
61
61
|
|
|
62
62
|
let pr: { number: number; branch: string; headSha: string } | undefined
|
|
63
63
|
let files: PrFileEntry[] = []
|
|
64
|
+
let issues: ReturnType<typeof parseClosingIssues> = []
|
|
64
65
|
try {
|
|
65
66
|
const prJson = (await Bun.file(join(runDir, 'pr.json')).json()) as Record<string, unknown>
|
|
66
67
|
const prNumber = Number(prJson.number ?? 0)
|
|
@@ -80,14 +81,42 @@ export async function refreshFindings(runDir: string): Promise<RefreshResult> {
|
|
|
80
81
|
deletions: Number(entry.deletions ?? 0),
|
|
81
82
|
}
|
|
82
83
|
})
|
|
84
|
+
issues = parseClosingIssues(prJson)
|
|
83
85
|
} catch {
|
|
84
86
|
// optional file; archived runs may not include pr.json
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
// Scout-produced summary. Same lenient-degrade contract as render-cmd.ts: a
|
|
90
|
+
// missing or malformed brief.json simply omits the header rather than
|
|
91
|
+
// failing the refresh (older archives predate the scout stage entirely).
|
|
92
|
+
let brief: ReturnType<typeof parseBrief> | undefined
|
|
93
|
+
try {
|
|
94
|
+
brief = parseBrief(await Bun.file(join(runDir, 'brief.json')).json())
|
|
95
|
+
} catch {
|
|
96
|
+
// optional file
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
const diff = await readFile(join(runDir, 'diff.patch'), 'utf8').catch(() => '')
|
|
88
100
|
|
|
101
|
+
let diffSource: { source: 'gh' | 'git'; mergeBase: string | null } | undefined
|
|
102
|
+
try {
|
|
103
|
+
diffSource = (await Bun.file(join(runDir, 'diff-source.json')).json()) as typeof diffSource
|
|
104
|
+
} catch {
|
|
105
|
+
diffSource = undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
89
108
|
await renderFindingsToDisk(
|
|
90
|
-
{
|
|
109
|
+
{
|
|
110
|
+
findings,
|
|
111
|
+
postStatus,
|
|
112
|
+
runId: basename(runDir),
|
|
113
|
+
pr,
|
|
114
|
+
files,
|
|
115
|
+
diff,
|
|
116
|
+
brief: brief ?? undefined,
|
|
117
|
+
issues,
|
|
118
|
+
diffSource,
|
|
119
|
+
},
|
|
91
120
|
join(screenDir, 'findings.html'),
|
|
92
121
|
)
|
|
93
122
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
|
-
import { type PostStatusMap, renderFindingsToDisk } from './render-findings.ts'
|
|
3
|
+
import { type PostStatusMap, parseClosingIssues, renderFindingsToDisk } from './render-findings.ts'
|
|
4
4
|
import { renderProgressToDisk } from './render-progress.ts'
|
|
5
|
-
import { parseFinding } from './types.ts'
|
|
5
|
+
import { parseBrief, parseFinding } from './types.ts'
|
|
6
6
|
|
|
7
7
|
async function nextVersionedPath(screenDir: string, base: string): Promise<string> {
|
|
8
8
|
const entries: string[] = await readdir(screenDir).catch(() => [] as string[])
|
|
@@ -48,12 +48,15 @@ function summarizeStages(log: Array<Record<string, unknown>>): {
|
|
|
48
48
|
report: 'pending',
|
|
49
49
|
post: 'pending',
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
// focus -> shard key -> count. Keyed by shard so a re-dispatched (focus, shard)
|
|
52
|
+
// pair replaces its own count rather than doubling it, while distinct shards
|
|
53
|
+
// of the same focus add up.
|
|
54
|
+
const perShard: Record<string, Record<string, number>> = {
|
|
55
|
+
security: {},
|
|
56
|
+
bugs: {},
|
|
57
|
+
performance: {},
|
|
58
|
+
'code-smells': {},
|
|
59
|
+
architecture: {},
|
|
57
60
|
}
|
|
58
61
|
for (const entry of log) {
|
|
59
62
|
const stage = entry.stage as string
|
|
@@ -63,11 +66,16 @@ function summarizeStages(log: Array<Record<string, unknown>>): {
|
|
|
63
66
|
if (stage in stages && status === 'error') stages[stage] = 'error'
|
|
64
67
|
if (stage in stages && status === 'skipped') stages[stage] = 'skipped'
|
|
65
68
|
if (stage === 'specialist' && typeof entry.focus === 'string') {
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
const bucket = perShard[entry.focus]
|
|
70
|
+
if (bucket && typeof entry.findings === 'number') {
|
|
71
|
+
bucket[String(entry.shard ?? 'all')] = entry.findings
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
}
|
|
75
|
+
const specialistCounts: Record<string, number> = {}
|
|
76
|
+
for (const [focus, bucket] of Object.entries(perShard)) {
|
|
77
|
+
specialistCounts[focus] = Object.values(bucket).reduce((a, b) => a + b, 0)
|
|
78
|
+
}
|
|
71
79
|
return { stages, specialistCounts }
|
|
72
80
|
}
|
|
73
81
|
|
|
@@ -81,6 +89,11 @@ export async function runRender(runDir: string, page: 'progress' | 'findings'):
|
|
|
81
89
|
})
|
|
82
90
|
const log = await readLog(runDir)
|
|
83
91
|
const summary = summarizeStages(log)
|
|
92
|
+
const manifest = await readJson<{ shards?: unknown[] }>(
|
|
93
|
+
join(runDir, 'shards', 'manifest.json'),
|
|
94
|
+
{},
|
|
95
|
+
)
|
|
96
|
+
const shardCount = Array.isArray(manifest.shards) ? manifest.shards.length : 1
|
|
84
97
|
const outPath = await nextVersionedPath(screenDir, 'progress')
|
|
85
98
|
await renderProgressToDisk(
|
|
86
99
|
{
|
|
@@ -89,6 +102,7 @@ export async function runRender(runDir: string, page: 'progress' | 'findings'):
|
|
|
89
102
|
branch: String(prJson.headRefName ?? '?'),
|
|
90
103
|
stages: summary.stages as never,
|
|
91
104
|
specialistCounts: summary.specialistCounts,
|
|
105
|
+
shardCount,
|
|
92
106
|
},
|
|
93
107
|
outPath,
|
|
94
108
|
)
|
|
@@ -119,9 +133,19 @@ export async function runRender(runDir: string, page: 'progress' | 'findings'):
|
|
|
119
133
|
}
|
|
120
134
|
})
|
|
121
135
|
const diff = await readFile(join(runDir, 'diff.patch'), 'utf8').catch(() => '')
|
|
136
|
+
// `readJson` swallows both a missing file and malformed JSON, and `parseBrief`
|
|
137
|
+
// returns null for a brief that parsed but is unusable. Either way the header
|
|
138
|
+
// is simply omitted.
|
|
139
|
+
const brief = parseBrief(await readJson<unknown>(join(runDir, 'brief.json'), null)) ?? undefined
|
|
140
|
+
const issues = parseClosingIssues(prJson)
|
|
141
|
+
const diffSource =
|
|
142
|
+
(await readJson<{ source: 'gh' | 'git'; mergeBase: string | null } | null>(
|
|
143
|
+
join(runDir, 'diff-source.json'),
|
|
144
|
+
null,
|
|
145
|
+
)) ?? undefined
|
|
122
146
|
const outPath = await nextVersionedPath(screenDir, 'findings')
|
|
123
147
|
await renderFindingsToDisk(
|
|
124
|
-
{ findings, postStatus, runId: basename(runDir), pr, files, diff },
|
|
148
|
+
{ findings, postStatus, runId: basename(runDir), pr, files, diff, brief, issues, diffSource },
|
|
125
149
|
outPath,
|
|
126
150
|
)
|
|
127
151
|
return 0
|
|
@@ -7,7 +7,7 @@ import { renderActionBar } from './render-action-bar.ts'
|
|
|
7
7
|
import { renderSplitDiff, renderUnifiedDiff } from './render-diff.ts'
|
|
8
8
|
import { renderFileTree } from './render-file-tree.ts'
|
|
9
9
|
import { renderIssuesList } from './render-issues-list.ts'
|
|
10
|
-
import type { PostStatusMap, PrFileEntry, ReviewFinding } from './types.ts'
|
|
10
|
+
import type { PostStatusMap, PrBrief, PrFileEntry, ReviewFinding } from './types.ts'
|
|
11
11
|
|
|
12
12
|
export type { PostStatusEntry, PostStatusMap } from './types.ts'
|
|
13
13
|
|
|
@@ -17,6 +17,31 @@ export type FindingsPrMeta = {
|
|
|
17
17
|
headSha: string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export type BriefIssue = {
|
|
21
|
+
number: number
|
|
22
|
+
title: string
|
|
23
|
+
url: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Maps pr.json's `closingIssuesReferences` into the brief header's issue links.
|
|
28
|
+
* Shared by render-cmd's findings branch and refresh (which re-renders
|
|
29
|
+
* findings.html for `magpie open` / `magpie serve <archived-id>`), so both
|
|
30
|
+
* call sites stay in lockstep instead of drifting apart.
|
|
31
|
+
*/
|
|
32
|
+
export function parseClosingIssues(prJson: Record<string, unknown>): BriefIssue[] {
|
|
33
|
+
const issuesRaw = Array.isArray(prJson.closingIssuesReferences)
|
|
34
|
+
? (prJson.closingIssuesReferences as unknown[])
|
|
35
|
+
: []
|
|
36
|
+
return issuesRaw.flatMap((entry) => {
|
|
37
|
+
if (!entry || typeof entry !== 'object') return []
|
|
38
|
+
const e = entry as Record<string, unknown>
|
|
39
|
+
const number = Number(e.number ?? 0)
|
|
40
|
+
if (!Number.isFinite(number) || number <= 0) return []
|
|
41
|
+
return [{ number, title: String(e.title ?? ''), url: String(e.url ?? '') }]
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
20
45
|
export type RenderFindingsInput = {
|
|
21
46
|
findings: ReviewFinding[]
|
|
22
47
|
postStatus: PostStatusMap
|
|
@@ -28,8 +53,14 @@ export type RenderFindingsInput = {
|
|
|
28
53
|
files?: PrFileEntry[]
|
|
29
54
|
/** Raw unified diff text for the PR. */
|
|
30
55
|
diff?: string
|
|
56
|
+
/** Scout-produced PR summary. When absent, the report renders no brief header. */
|
|
57
|
+
brief?: PrBrief
|
|
58
|
+
/** Issues this PR closes, from pr.json's closingIssuesReferences. */
|
|
59
|
+
issues?: BriefIssue[]
|
|
31
60
|
/** Shiki highlighter, prepared by the caller. */
|
|
32
61
|
highlighter: Highlighter
|
|
62
|
+
/** Where diff.patch came from. Absent means gh, which needs no note. */
|
|
63
|
+
diffSource?: { source: 'gh' | 'git'; mergeBase: string | null }
|
|
33
64
|
}
|
|
34
65
|
|
|
35
66
|
function esc(s: string): string {
|
|
@@ -57,13 +88,21 @@ function brandBlock(): string {
|
|
|
57
88
|
|
|
58
89
|
function prHeader(input: RenderFindingsInput): string {
|
|
59
90
|
const pr = input.pr
|
|
91
|
+
const src = input.diffSource
|
|
92
|
+
const diffNote =
|
|
93
|
+
src?.source === 'git'
|
|
94
|
+
? `<span class="diff-source" title="gh pr diff could not serve this PR, so the diff was rebuilt from the local clone">diff from local clone${
|
|
95
|
+
src.mergeBase ? ` at <code>${esc(src.mergeBase.slice(0, 12))}</code>` : ''
|
|
96
|
+
}</span>`
|
|
97
|
+
: ''
|
|
60
98
|
const prMeta = pr
|
|
61
99
|
? `<div class="pr-meta">
|
|
62
100
|
<span class="pr-number">PR #${pr.number}</span>
|
|
63
101
|
<span class="pr-branch">${esc(pr.branch)}</span>
|
|
64
102
|
<code>${esc(pr.headSha.slice(0, 12))}</code>
|
|
103
|
+
${diffNote}
|
|
65
104
|
</div>`
|
|
66
|
-
: `<div class="pr-meta"
|
|
105
|
+
: `<div class="pr-meta">${diffNote}</div>`
|
|
67
106
|
const total = input.findings.length
|
|
68
107
|
return `<header class="pr-header">
|
|
69
108
|
${brandBlock()}
|
|
@@ -76,6 +115,40 @@ function prHeader(input: RenderFindingsInput): string {
|
|
|
76
115
|
</header>`
|
|
77
116
|
}
|
|
78
117
|
|
|
118
|
+
function briefBlock(brief: PrBrief | undefined, issues: BriefIssue[]): string {
|
|
119
|
+
// Absent brief renders nothing: archived runs from before the scout stage
|
|
120
|
+
// existed must still open cleanly.
|
|
121
|
+
if (!brief) return ''
|
|
122
|
+
const changes =
|
|
123
|
+
brief.changes.length > 0
|
|
124
|
+
? `<ul class="brief-changes">${brief.changes.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
|
|
125
|
+
: ''
|
|
126
|
+
const subsystems =
|
|
127
|
+
brief.subsystems.length > 0
|
|
128
|
+
? `<div class="brief-subsystems">${brief.subsystems
|
|
129
|
+
.map((s) => `<span class="brief-chip" title="${esc(s.role)}">${esc(s.name)}</span>`)
|
|
130
|
+
.join('')}</div>`
|
|
131
|
+
: ''
|
|
132
|
+
const issueLinks =
|
|
133
|
+
issues.length > 0
|
|
134
|
+
? `<div class="brief-issues">${issues
|
|
135
|
+
.map(
|
|
136
|
+
(i) =>
|
|
137
|
+
`<a class="brief-issue" href="${esc(i.url)}" title="${esc(i.title)}">#${i.number}</a>`,
|
|
138
|
+
)
|
|
139
|
+
.join('')}</div>`
|
|
140
|
+
: ''
|
|
141
|
+
return `<details class="pr-brief" open>
|
|
142
|
+
<summary class="brief-summary">What this PR is for</summary>
|
|
143
|
+
<div class="brief-body">
|
|
144
|
+
<p class="brief-purpose">${esc(brief.purpose)}</p>
|
|
145
|
+
${changes}
|
|
146
|
+
${subsystems}
|
|
147
|
+
${issueLinks}
|
|
148
|
+
</div>
|
|
149
|
+
</details>`
|
|
150
|
+
}
|
|
151
|
+
|
|
79
152
|
function filePane(opts: {
|
|
80
153
|
file: PrFileEntry
|
|
81
154
|
fileDiff: string
|
|
@@ -163,6 +236,7 @@ export function renderFindingsHtml(input: RenderFindingsInput): string {
|
|
|
163
236
|
const diff = input.diff ?? ''
|
|
164
237
|
const splitDiffs = splitDiffByFile(diff)
|
|
165
238
|
const selectedIds = new Set<string>()
|
|
239
|
+
const briefHtml = briefBlock(input.brief, input.issues ?? [])
|
|
166
240
|
|
|
167
241
|
if (input.findings.length === 0) {
|
|
168
242
|
return `<!DOCTYPE html>
|
|
@@ -175,6 +249,7 @@ export function renderFindingsHtml(input: RenderFindingsInput): string {
|
|
|
175
249
|
</head>
|
|
176
250
|
<body data-run-id="${esc(runId)}" data-page="findings" data-view="files" data-diff-mode="unified" data-show-suggestions="false">
|
|
177
251
|
${prHeader(input)}
|
|
252
|
+
${briefHtml}
|
|
178
253
|
<main class="page-main">
|
|
179
254
|
<div class="empty-state"><h1>No findings</h1><p>All specialists returned cleanly.</p></div>
|
|
180
255
|
</main>
|
|
@@ -223,6 +298,7 @@ ${prHeader(input)}
|
|
|
223
298
|
</head>
|
|
224
299
|
<body data-run-id="${esc(runId)}" data-page="findings" data-view="files" data-diff-mode="unified" data-show-suggestions="false">
|
|
225
300
|
${prHeader(input)}
|
|
301
|
+
${briefHtml}
|
|
226
302
|
<main class="page-main">
|
|
227
303
|
<div class="view files-view">
|
|
228
304
|
${tree}
|
|
@@ -41,6 +41,8 @@ export type RenderProgressInput = {
|
|
|
41
41
|
branch: string
|
|
42
42
|
stages: Record<StageId, StageStatus>
|
|
43
43
|
specialistCounts: Record<string, number>
|
|
44
|
+
/** Shard count from shards/manifest.json. Absent or 1 means unsharded. */
|
|
45
|
+
shardCount?: number
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
function esc(s: string): string {
|
|
@@ -82,7 +84,10 @@ function progressPane(input: RenderProgressInput): string {
|
|
|
82
84
|
const cur = current(input.stages)
|
|
83
85
|
let lead: string
|
|
84
86
|
if (cur.status === 'running') {
|
|
85
|
-
lead =
|
|
87
|
+
lead =
|
|
88
|
+
cur.id === 'specialists' && (input.shardCount ?? 1) > 1
|
|
89
|
+
? `Five reviewers across ${input.shardCount} shards`
|
|
90
|
+
: STAGE_NOW_DOING[cur.id]
|
|
86
91
|
} else if (cur.status === 'error') {
|
|
87
92
|
lead = `${STAGE_NOW_DOING[cur.id]} stalled`
|
|
88
93
|
} else if (cur.status === 'pending') {
|
|
@@ -4,6 +4,7 @@ import { fetchPr } from './gh.ts'
|
|
|
4
4
|
import { buildIncrementalContext, findPreviousRun } from './incremental.ts'
|
|
5
5
|
import { filterDiff, loadPathFilterConfig } from './path-filter.ts'
|
|
6
6
|
import { type Deps, defaultDeps, preflight, renderInstallHint } from './preflight.ts'
|
|
7
|
+
import { shardDiff } from './shard.ts'
|
|
7
8
|
import { detectMissingTests } from './tests-check.ts'
|
|
8
9
|
import { createWorktree } from './worktree.ts'
|
|
9
10
|
|
|
@@ -47,6 +48,7 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
|
|
|
47
48
|
|
|
48
49
|
const fetched = await fetchPr({
|
|
49
50
|
ghBin: deps.gh,
|
|
51
|
+
gitBin: deps.git,
|
|
50
52
|
prNumber: input.prNumber,
|
|
51
53
|
runDir: input.runDir,
|
|
52
54
|
cwd: input.repoPath,
|
|
@@ -57,10 +59,31 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
|
|
|
57
59
|
await cleanup(input.runDir)
|
|
58
60
|
return 4
|
|
59
61
|
}
|
|
60
|
-
await logLine(input.runDir, {
|
|
62
|
+
await logLine(input.runDir, {
|
|
63
|
+
stage: 'fetch-pr',
|
|
64
|
+
status: 'done',
|
|
65
|
+
source: fetched.source,
|
|
66
|
+
...(fetched.mergeBase ? { mergeBase: fetched.mergeBase } : {}),
|
|
67
|
+
})
|
|
68
|
+
// Sidecar so the report can say where the diff came from without re-parsing
|
|
69
|
+
// log.jsonl, the same way incremental.json carries the incremental context.
|
|
70
|
+
await writeFile(
|
|
71
|
+
join(input.runDir, 'diff-source.json'),
|
|
72
|
+
`${JSON.stringify({ source: fetched.source, mergeBase: fetched.mergeBase ?? null }, null, 2)}\n`,
|
|
73
|
+
)
|
|
61
74
|
|
|
62
75
|
await applyPathFilter(input.runDir, input.repoPath)
|
|
63
76
|
|
|
77
|
+
try {
|
|
78
|
+
await runShard(input.runDir)
|
|
79
|
+
} catch (err) {
|
|
80
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
81
|
+
await logLine(input.runDir, { stage: 'shard', status: 'error', error: message })
|
|
82
|
+
process.stderr.write(`magpie: ${message}\n`)
|
|
83
|
+
await cleanup(input.runDir)
|
|
84
|
+
return 6
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
await runTestsCheck(input.runDir)
|
|
65
88
|
|
|
66
89
|
await detectIncrementalReview(input.runDir, input.prNumber)
|
|
@@ -90,7 +113,9 @@ async function cleanup(runDir: string): Promise<void> {
|
|
|
90
113
|
'pr.json',
|
|
91
114
|
'diff.patch',
|
|
92
115
|
'diff.full.patch',
|
|
116
|
+
'diff-source.json',
|
|
93
117
|
'excluded-files.json',
|
|
118
|
+
'shards',
|
|
94
119
|
'incremental.json',
|
|
95
120
|
'findings',
|
|
96
121
|
'screen',
|
|
@@ -100,6 +125,18 @@ async function cleanup(runDir: string): Promise<void> {
|
|
|
100
125
|
}
|
|
101
126
|
}
|
|
102
127
|
|
|
128
|
+
async function runShard(runDir: string): Promise<void> {
|
|
129
|
+
const manifest = await shardDiff(runDir)
|
|
130
|
+
await logLine(runDir, {
|
|
131
|
+
stage: 'shard',
|
|
132
|
+
status: 'done',
|
|
133
|
+
shards: manifest.shards.length,
|
|
134
|
+
budget: manifest.budget,
|
|
135
|
+
files: manifest.totalFiles,
|
|
136
|
+
lines: manifest.totalLines,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
103
140
|
async function runTestsCheck(runDir: string): Promise<void> {
|
|
104
141
|
const diff = await Bun.file(join(runDir, 'diff.patch')).text()
|
|
105
142
|
const findings = detectMissingTests(diff)
|