@iceinvein/agent-skills 0.1.28 → 0.1.30
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/dist/cli/index.js +42 -12
- package/package.json +1 -1
- package/skills/index.json +1 -1
- package/skills/magpie/SKILL.md +18 -37
- package/skills/magpie/bin/magpie.ts +13 -2
- package/skills/magpie/fixtures/fake-gh-with-lockfile.sh +52 -0
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +45 -0
- package/skills/magpie/scripts/__tests__/evidence-filter.test.ts +83 -0
- package/skills/magpie/scripts/__tests__/incremental.test.ts +87 -0
- package/skills/magpie/scripts/__tests__/path-filter.test.ts +161 -0
- package/skills/magpie/scripts/__tests__/post-cmd.test.ts +108 -8
- package/skills/magpie/scripts/__tests__/score.test.ts +51 -0
- package/skills/magpie/scripts/__tests__/server.test.ts +4 -1
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +104 -1
- package/skills/magpie/scripts/__tests__/tests-check.test.ts +94 -0
- package/skills/magpie/scripts/__tests__/types.test.ts +9 -2
- package/skills/magpie/scripts/dedupe-cmd.ts +40 -3
- package/skills/magpie/scripts/evidence-filter.ts +73 -0
- package/skills/magpie/scripts/incremental.ts +75 -0
- package/skills/magpie/scripts/path-filter.ts +150 -0
- package/skills/magpie/scripts/post-cmd.ts +133 -9
- package/skills/magpie/scripts/score.ts +39 -0
- package/skills/magpie/scripts/setup-cmd.ts +73 -2
- package/skills/magpie/scripts/tests-check.ts +118 -0
- package/skills/magpie/scripts/types.ts +11 -1
- package/skills/magpie/skill.json +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export type PathFilterConfig = {
|
|
6
|
+
exclude: string[]
|
|
7
|
+
include: string[]
|
|
8
|
+
useDefaults: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_EXCLUDES: readonly string[] = [
|
|
12
|
+
// Lockfiles
|
|
13
|
+
'**/package-lock.json',
|
|
14
|
+
'**/yarn.lock',
|
|
15
|
+
'**/pnpm-lock.yaml',
|
|
16
|
+
'**/bun.lock',
|
|
17
|
+
'**/bun.lockb',
|
|
18
|
+
'**/Cargo.lock',
|
|
19
|
+
'**/Pipfile.lock',
|
|
20
|
+
'**/poetry.lock',
|
|
21
|
+
'**/composer.lock',
|
|
22
|
+
'**/Gemfile.lock',
|
|
23
|
+
'**/uv.lock',
|
|
24
|
+
// Build / vendored / dependency dirs
|
|
25
|
+
'dist/**',
|
|
26
|
+
'build/**',
|
|
27
|
+
'out/**',
|
|
28
|
+
'.next/**',
|
|
29
|
+
'.nuxt/**',
|
|
30
|
+
'.svelte-kit/**',
|
|
31
|
+
'.turbo/**',
|
|
32
|
+
'.cache/**',
|
|
33
|
+
'coverage/**',
|
|
34
|
+
'node_modules/**',
|
|
35
|
+
'vendor/**',
|
|
36
|
+
// Generated source
|
|
37
|
+
'**/*.generated.*',
|
|
38
|
+
'**/*.gen.*',
|
|
39
|
+
'**/*.pb.go',
|
|
40
|
+
'**/*.pb.ts',
|
|
41
|
+
'**/*.pb.js',
|
|
42
|
+
'**/*_pb2.py',
|
|
43
|
+
'**/*.min.js',
|
|
44
|
+
'**/*.min.css',
|
|
45
|
+
'**/*.map',
|
|
46
|
+
// Snapshot fixtures
|
|
47
|
+
'**/*.snap',
|
|
48
|
+
'**/__snapshots__/**',
|
|
49
|
+
'**/.snapshots/**',
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
export function globToRegex(pattern: string): RegExp {
|
|
53
|
+
let re = ''
|
|
54
|
+
let i = 0
|
|
55
|
+
while (i < pattern.length) {
|
|
56
|
+
const ch = pattern[i]
|
|
57
|
+
if (ch === '*') {
|
|
58
|
+
if (pattern[i + 1] === '*') {
|
|
59
|
+
re += '.*'
|
|
60
|
+
i += 2
|
|
61
|
+
if (pattern[i] === '/') i += 1
|
|
62
|
+
} else {
|
|
63
|
+
re += '[^/]*'
|
|
64
|
+
i += 1
|
|
65
|
+
}
|
|
66
|
+
} else if (ch === '?') {
|
|
67
|
+
re += '[^/]'
|
|
68
|
+
i += 1
|
|
69
|
+
} else if (ch && /[.+^$()|[\]\\{}]/.test(ch)) {
|
|
70
|
+
re += `\\${ch}`
|
|
71
|
+
i += 1
|
|
72
|
+
} else {
|
|
73
|
+
re += ch ?? ''
|
|
74
|
+
i += 1
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return new RegExp(`^${re}$`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function matchesAny(path: string, patterns: readonly string[]): string | null {
|
|
81
|
+
for (const pat of patterns) {
|
|
82
|
+
if (globToRegex(pat).test(path)) return pat
|
|
83
|
+
}
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function loadPathFilterConfig(cwd: string): Promise<PathFilterConfig> {
|
|
88
|
+
const cfgPath = join(cwd, '.magpie.json')
|
|
89
|
+
if (!existsSync(cfgPath)) {
|
|
90
|
+
return { exclude: [...DEFAULT_EXCLUDES], include: [], useDefaults: true }
|
|
91
|
+
}
|
|
92
|
+
let parsed: unknown
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(await readFile(cfgPath, 'utf8'))
|
|
95
|
+
} catch {
|
|
96
|
+
return { exclude: [...DEFAULT_EXCLUDES], include: [], useDefaults: true }
|
|
97
|
+
}
|
|
98
|
+
const obj = (parsed ?? {}) as Record<string, unknown>
|
|
99
|
+
const userExclude = Array.isArray(obj.exclude)
|
|
100
|
+
? obj.exclude.filter((s): s is string => typeof s === 'string')
|
|
101
|
+
: []
|
|
102
|
+
const userInclude = Array.isArray(obj.include)
|
|
103
|
+
? obj.include.filter((s): s is string => typeof s === 'string')
|
|
104
|
+
: []
|
|
105
|
+
const useDefaults = obj.useDefaults !== false
|
|
106
|
+
return {
|
|
107
|
+
exclude: useDefaults ? [...DEFAULT_EXCLUDES, ...userExclude] : userExclude,
|
|
108
|
+
include: userInclude,
|
|
109
|
+
useDefaults,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type ExcludedFile = { path: string; pattern: string }
|
|
114
|
+
export type FilterDiffResult = { filtered: string; excluded: ExcludedFile[] }
|
|
115
|
+
|
|
116
|
+
const FILE_HEADER = /^diff --git a\/(.+?) b\/(.+?)$/m
|
|
117
|
+
|
|
118
|
+
export function filterDiff(rawDiff: string, config: PathFilterConfig): FilterDiffResult {
|
|
119
|
+
if (!rawDiff) return { filtered: '', excluded: [] }
|
|
120
|
+
const chunks = rawDiff.split(/^(?=diff --git )/m)
|
|
121
|
+
const kept: string[] = []
|
|
122
|
+
const excluded: ExcludedFile[] = []
|
|
123
|
+
for (const chunk of chunks) {
|
|
124
|
+
if (!chunk.startsWith('diff --git ')) {
|
|
125
|
+
if (chunk.trim()) kept.push(chunk)
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
const m = chunk.match(FILE_HEADER)
|
|
129
|
+
const path = m?.[2] ?? m?.[1]
|
|
130
|
+
if (!path) {
|
|
131
|
+
kept.push(chunk)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (config.include.length > 0) {
|
|
135
|
+
if (matchesAny(path, config.include)) {
|
|
136
|
+
kept.push(chunk)
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
excluded.push({ path, pattern: 'not-in-include' })
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
const hit = matchesAny(path, config.exclude)
|
|
143
|
+
if (hit) {
|
|
144
|
+
excluded.push({ path, pattern: hit })
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
kept.push(chunk)
|
|
148
|
+
}
|
|
149
|
+
return { filtered: kept.join(''), excluded }
|
|
150
|
+
}
|
|
@@ -3,7 +3,13 @@ import { appendFile, readFile, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { parseUnifiedDiffToHunks, splitDiffByFile } from './diff-utils.ts'
|
|
5
5
|
import { formatFindingDescriptionMarkdown } from './finding-description.ts'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type FocusId,
|
|
8
|
+
type PrFileEntry,
|
|
9
|
+
parseFinding,
|
|
10
|
+
type ReviewFinding,
|
|
11
|
+
type Severity,
|
|
12
|
+
} from './types.ts'
|
|
7
13
|
|
|
8
14
|
/**
|
|
9
15
|
* Build a per-file set of RIGHT-side line numbers that GitHub's PR Reviews API
|
|
@@ -37,9 +43,9 @@ export type PostInput = {
|
|
|
37
43
|
/**
|
|
38
44
|
* Whether to also post a top-level review-summary comment before the
|
|
39
45
|
* per-finding posts.
|
|
40
|
-
* - 'auto' (default): post when this batch
|
|
41
|
-
*
|
|
42
|
-
* - 'always': post even
|
|
46
|
+
* - 'auto' (default): post when this batch has at least one finding and no
|
|
47
|
+
* summary has been posted before. Always-on UX.
|
|
48
|
+
* - 'always': post even on empty batches (clean-review comment).
|
|
43
49
|
* - 'never': skip the summary entirely.
|
|
44
50
|
*/
|
|
45
51
|
includeSummary?: 'auto' | 'always' | 'never'
|
|
@@ -87,6 +93,7 @@ const FOCUS_LABEL: Record<FocusId, string> = {
|
|
|
87
93
|
performance: 'Performance',
|
|
88
94
|
'code-smells': 'Code Smells',
|
|
89
95
|
architecture: 'Architecture',
|
|
96
|
+
tests: 'Tests',
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
function formatFocus(f: ReviewFinding): string | null {
|
|
@@ -213,6 +220,81 @@ function formatLocation(f: ReviewFinding): string {
|
|
|
213
220
|
export type ReviewSummaryOptions = {
|
|
214
221
|
/** Optional commit SHA. When present, surfaces a "Reviewed at <sha>" trailer. */
|
|
215
222
|
commitId?: string
|
|
223
|
+
/** PR files changed (from pr.json). Used for the walkthrough table and effort score. */
|
|
224
|
+
files?: PrFileEntry[]
|
|
225
|
+
/** Incremental review context. When present, surfaces "since <prev-sha>" trailer. */
|
|
226
|
+
incremental?: { previousSha: string; sameSha: boolean }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Effort score 1 (trivial) to 5 (substantial), derived from changed files and lines. */
|
|
230
|
+
export function computeEffortScore(files: PrFileEntry[]): number {
|
|
231
|
+
if (files.length === 0) return 1
|
|
232
|
+
const lines = files.reduce((acc, f) => acc + f.additions + f.deletions, 0)
|
|
233
|
+
if (files.length <= 2 && lines <= 50) return 1
|
|
234
|
+
if (files.length <= 5 && lines <= 200) return 2
|
|
235
|
+
if (files.length <= 10 && lines <= 500) return 3
|
|
236
|
+
if (files.length <= 20 && lines <= 1000) return 4
|
|
237
|
+
return 5
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function renderEffortBar(score: number): string {
|
|
241
|
+
const filled = '●'.repeat(score)
|
|
242
|
+
const empty = '○'.repeat(Math.max(0, 5 - score))
|
|
243
|
+
return `${filled}${empty}`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function findingsByFile(findings: ReviewFinding[]): Map<string, ReviewFinding[]> {
|
|
247
|
+
const map = new Map<string, ReviewFinding[]>()
|
|
248
|
+
for (const f of findings) {
|
|
249
|
+
if (!f.file) continue
|
|
250
|
+
const list = map.get(f.file)
|
|
251
|
+
if (list) list.push(f)
|
|
252
|
+
else map.set(f.file, [f])
|
|
253
|
+
}
|
|
254
|
+
return map
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function renderWalkthroughTable(
|
|
258
|
+
files: PrFileEntry[],
|
|
259
|
+
byFile: Map<string, ReviewFinding[]>,
|
|
260
|
+
): string[] {
|
|
261
|
+
if (files.length === 0) return []
|
|
262
|
+
const sorted = [...files].sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions))
|
|
263
|
+
const top = sorted.slice(0, 20)
|
|
264
|
+
const lines = [
|
|
265
|
+
'',
|
|
266
|
+
`<details>`,
|
|
267
|
+
`<summary><b>Files changed</b> (${plural(files.length, 'file')}${files.length > top.length ? `, showing top ${top.length}` : ''})</summary>`,
|
|
268
|
+
'',
|
|
269
|
+
'| File | +/- | Findings |',
|
|
270
|
+
'|---|---|---|',
|
|
271
|
+
]
|
|
272
|
+
for (const f of top) {
|
|
273
|
+
const finds = byFile.get(f.path)?.length ?? 0
|
|
274
|
+
const stats = `+${f.additions} / -${f.deletions}`
|
|
275
|
+
lines.push(`| \`${f.path}\` | ${stats} | ${finds || ''} |`)
|
|
276
|
+
}
|
|
277
|
+
lines.push('', '</details>')
|
|
278
|
+
return lines
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderRiskHotspots(byFile: Map<string, ReviewFinding[]>): string[] {
|
|
282
|
+
const entries = [...byFile.entries()]
|
|
283
|
+
.map(([file, finds]) => ({ file, finds }))
|
|
284
|
+
.filter((e) => e.finds.length >= 2)
|
|
285
|
+
.sort((a, b) => b.finds.length - a.finds.length)
|
|
286
|
+
.slice(0, 3)
|
|
287
|
+
if (entries.length === 0) return []
|
|
288
|
+
const lines = ['', '### Risk hotspots', '']
|
|
289
|
+
for (const e of entries) {
|
|
290
|
+
const counts: Record<Severity, number> = { blocker: 0, high: 0, medium: 0, low: 0 }
|
|
291
|
+
for (const f of e.finds) counts[f.severity] += 1
|
|
292
|
+
const tally = SEVERITY_ORDER.filter((s) => counts[s] > 0)
|
|
293
|
+
.map((s) => `${SEVERITY_ICON[s]} ${counts[s]}`)
|
|
294
|
+
.join(' · ')
|
|
295
|
+
lines.push(`- \`${e.file}\` · ${plural(e.finds.length, 'finding')} · ${tally}`)
|
|
296
|
+
}
|
|
297
|
+
return lines
|
|
216
298
|
}
|
|
217
299
|
|
|
218
300
|
/**
|
|
@@ -233,6 +315,9 @@ export function formatReviewSummaryBody(
|
|
|
233
315
|
|
|
234
316
|
const fileCount = new Set(inlineFindings.map((f) => f.file)).size
|
|
235
317
|
const shortSha = options.commitId ? options.commitId.slice(0, 7) : ''
|
|
318
|
+
const files = options.files ?? []
|
|
319
|
+
const effort = computeEffortScore(files)
|
|
320
|
+
const byFile = findingsByFile(findings)
|
|
236
321
|
|
|
237
322
|
let verdict: string
|
|
238
323
|
if (counts.blocker > 0) {
|
|
@@ -251,6 +336,18 @@ export function formatReviewSummaryBody(
|
|
|
251
336
|
|
|
252
337
|
const lines: string[] = [header]
|
|
253
338
|
|
|
339
|
+
if (files.length > 0) {
|
|
340
|
+
lines.push('', `<sub>Review effort · ${renderEffortBar(effort)} (${effort}/5)</sub>`)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (options.incremental) {
|
|
344
|
+
const prevShort = options.incremental.previousSha.slice(0, 7)
|
|
345
|
+
const msg = options.incremental.sameSha
|
|
346
|
+
? `Re-review (no new commits since \`${prevShort}\`)`
|
|
347
|
+
: `Incremental review since \`${prevShort}\``
|
|
348
|
+
lines.push('', `<sub>${msg}</sub>`)
|
|
349
|
+
}
|
|
350
|
+
|
|
254
351
|
if (findings.length > 0) {
|
|
255
352
|
const inlineText =
|
|
256
353
|
inlineFindings.length > 0
|
|
@@ -282,6 +379,9 @@ export function formatReviewSummaryBody(
|
|
|
282
379
|
}
|
|
283
380
|
}
|
|
284
381
|
|
|
382
|
+
lines.push(...renderRiskHotspots(byFile))
|
|
383
|
+
lines.push(...renderWalkthroughTable(files, byFile))
|
|
384
|
+
|
|
285
385
|
if (findings.length > 0) {
|
|
286
386
|
lines.push(
|
|
287
387
|
'',
|
|
@@ -680,20 +780,44 @@ export async function runPost(input: PostInput): Promise<PostOutcome> {
|
|
|
680
780
|
|
|
681
781
|
// Decide whether to post the review summary up front. It is one extra
|
|
682
782
|
// top-level PR comment that gives the conversation a coherent overview
|
|
683
|
-
// before the per-finding threads land.
|
|
783
|
+
// before the per-finding threads land. Default 'auto' fires for any
|
|
784
|
+
// non-empty batch; 'always' fires even on empty batches (clean-review).
|
|
684
785
|
const summaryMode = input.includeSummary ?? 'auto'
|
|
685
786
|
const selectedFindings = input.findingIds
|
|
686
787
|
.map((id) => byId.get(id))
|
|
687
788
|
.filter((f): f is ReviewFinding => Boolean(f))
|
|
688
|
-
const pendingCount = selectedFindings.filter((f) => status[f.id] !== 'posted').length
|
|
689
789
|
const summaryAlreadyPosted = status.__summary__ === 'posted'
|
|
690
790
|
const shouldPostSummary =
|
|
691
791
|
!summaryAlreadyPosted &&
|
|
692
|
-
|
|
693
|
-
(summaryMode === 'always' ||
|
|
792
|
+
summaryMode !== 'never' &&
|
|
793
|
+
(summaryMode === 'always' || selectedFindings.length >= 1)
|
|
794
|
+
|
|
795
|
+
const prFiles = Array.isArray(prJson.files)
|
|
796
|
+
? (prJson.files as PrFileEntry[]).filter(
|
|
797
|
+
(f): f is PrFileEntry =>
|
|
798
|
+
typeof f?.path === 'string' &&
|
|
799
|
+
typeof f?.additions === 'number' &&
|
|
800
|
+
typeof f?.deletions === 'number',
|
|
801
|
+
)
|
|
802
|
+
: []
|
|
803
|
+
|
|
804
|
+
let incremental: ReviewSummaryOptions['incremental']
|
|
805
|
+
try {
|
|
806
|
+
const raw = await readFile(join(input.runDir, 'incremental.json'), 'utf8')
|
|
807
|
+
const parsed = JSON.parse(raw) as { previousSha?: string; sameSha?: boolean }
|
|
808
|
+
if (typeof parsed.previousSha === 'string') {
|
|
809
|
+
incremental = { previousSha: parsed.previousSha, sameSha: Boolean(parsed.sameSha) }
|
|
810
|
+
}
|
|
811
|
+
} catch {
|
|
812
|
+
// No incremental sidecar; first run for this PR.
|
|
813
|
+
}
|
|
694
814
|
|
|
695
815
|
if (shouldPostSummary) {
|
|
696
|
-
const summaryBody = formatReviewSummaryBody(selectedFindings, {
|
|
816
|
+
const summaryBody = formatReviewSummaryBody(selectedFindings, {
|
|
817
|
+
commitId: headSha,
|
|
818
|
+
files: prFiles,
|
|
819
|
+
...(incremental ? { incremental } : {}),
|
|
820
|
+
})
|
|
697
821
|
const summaryCmd = buildPrCommentCommand(repo, prNumber, summaryBody)
|
|
698
822
|
if (input.dryRun) {
|
|
699
823
|
results.push({ id: '__summary__', status: 'posted', command: summaryCmd })
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Action, Confidence, Impact, Likelihood, Risk } from './types.ts'
|
|
2
|
+
|
|
3
|
+
const IMPACT_WEIGHT: Record<Impact, number> = {
|
|
4
|
+
critical: 10,
|
|
5
|
+
high: 7,
|
|
6
|
+
medium: 4,
|
|
7
|
+
low: 1,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const LIKELIHOOD_WEIGHT: Record<Likelihood, number> = {
|
|
11
|
+
likely: 10,
|
|
12
|
+
possible: 6,
|
|
13
|
+
'edge-case': 3,
|
|
14
|
+
unknown: 4,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CONFIDENCE_WEIGHT: Record<Confidence, number> = {
|
|
18
|
+
high: 10,
|
|
19
|
+
medium: 5,
|
|
20
|
+
low: 2,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ACTION_WEIGHT: Record<Action, number> = {
|
|
24
|
+
'must-fix': 10,
|
|
25
|
+
'should-fix': 7,
|
|
26
|
+
consider: 4,
|
|
27
|
+
optional: 1,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_THRESHOLD = 3
|
|
31
|
+
|
|
32
|
+
export function scoreRisk(risk: Risk): number {
|
|
33
|
+
const raw =
|
|
34
|
+
IMPACT_WEIGHT[risk.impact] * 0.4 +
|
|
35
|
+
LIKELIHOOD_WEIGHT[risk.likelihood] * 0.25 +
|
|
36
|
+
CONFIDENCE_WEIGHT[risk.confidence] * 0.2 +
|
|
37
|
+
ACTION_WEIGHT[risk.action] * 0.15
|
|
38
|
+
return Math.round(raw * 10) / 10
|
|
39
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { appendFile, mkdir, rm } from 'node:fs/promises'
|
|
1
|
+
import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { fetchPr } from './gh.ts'
|
|
4
|
+
import { buildIncrementalContext, findPreviousRun } from './incremental.ts'
|
|
5
|
+
import { filterDiff, loadPathFilterConfig } from './path-filter.ts'
|
|
4
6
|
import { type Deps, defaultDeps, preflight, renderInstallHint } from './preflight.ts'
|
|
7
|
+
import { detectMissingTests } from './tests-check.ts'
|
|
5
8
|
import { createWorktree } from './worktree.ts'
|
|
6
9
|
|
|
7
10
|
export type RunSetupInput = {
|
|
@@ -45,6 +48,12 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
|
|
|
45
48
|
}
|
|
46
49
|
await logLine(input.runDir, { stage: 'fetch-pr', status: 'done' })
|
|
47
50
|
|
|
51
|
+
await applyPathFilter(input.runDir, input.repoPath)
|
|
52
|
+
|
|
53
|
+
await runTestsCheck(input.runDir)
|
|
54
|
+
|
|
55
|
+
await detectIncrementalReview(input.runDir, input.prNumber)
|
|
56
|
+
|
|
48
57
|
const prJson = (await Bun.file(join(input.runDir, 'pr.json')).json()) as { headRefName: string }
|
|
49
58
|
const branch = prJson.headRefName
|
|
50
59
|
|
|
@@ -65,7 +74,69 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
|
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
async function cleanup(runDir: string): Promise<void> {
|
|
68
|
-
for (const name of [
|
|
77
|
+
for (const name of [
|
|
78
|
+
'worktree',
|
|
79
|
+
'pr.json',
|
|
80
|
+
'diff.patch',
|
|
81
|
+
'diff.full.patch',
|
|
82
|
+
'excluded-files.json',
|
|
83
|
+
'incremental.json',
|
|
84
|
+
'findings',
|
|
85
|
+
'screen',
|
|
86
|
+
'state',
|
|
87
|
+
]) {
|
|
69
88
|
await rm(join(runDir, name), { recursive: true, force: true }).catch(() => {})
|
|
70
89
|
}
|
|
71
90
|
}
|
|
91
|
+
|
|
92
|
+
async function runTestsCheck(runDir: string): Promise<void> {
|
|
93
|
+
const diff = await Bun.file(join(runDir, 'diff.patch')).text()
|
|
94
|
+
const findings = detectMissingTests(diff)
|
|
95
|
+
if (findings.length === 0) {
|
|
96
|
+
await logLine(runDir, { stage: 'tests-check', status: 'done', findings: 0 })
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
await writeFile(join(runDir, 'findings', 'tests.json'), `${JSON.stringify(findings, null, 2)}\n`)
|
|
100
|
+
await logLine(runDir, { stage: 'tests-check', status: 'done', findings: findings.length })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function detectIncrementalReview(runDir: string, prNumber: number): Promise<void> {
|
|
104
|
+
const previous = await findPreviousRun(prNumber, runDir)
|
|
105
|
+
if (!previous) {
|
|
106
|
+
await logLine(runDir, { stage: 'incremental', status: 'first-run' })
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const currentPr = (await Bun.file(join(runDir, 'pr.json')).json()) as Record<string, unknown>
|
|
110
|
+
const context = buildIncrementalContext(currentPr, previous)
|
|
111
|
+
if (!context) {
|
|
112
|
+
await logLine(runDir, { stage: 'incremental', status: 'missing-sha' })
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
await writeFile(join(runDir, 'incremental.json'), `${JSON.stringify(context, null, 2)}\n`)
|
|
116
|
+
await logLine(runDir, {
|
|
117
|
+
stage: 'incremental',
|
|
118
|
+
status: 'done',
|
|
119
|
+
previousRunId: context.previousRunId,
|
|
120
|
+
sameSha: context.sameSha,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function applyPathFilter(runDir: string, repoPath: string): Promise<void> {
|
|
125
|
+
const diffPath = join(runDir, 'diff.patch')
|
|
126
|
+
const rawDiff = await Bun.file(diffPath).text()
|
|
127
|
+
const config = await loadPathFilterConfig(repoPath)
|
|
128
|
+
const { filtered, excluded } = filterDiff(rawDiff, config)
|
|
129
|
+
if (excluded.length === 0) {
|
|
130
|
+
await logLine(runDir, { stage: 'filter', status: 'done', excluded: 0 })
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
await writeFile(join(runDir, 'diff.full.patch'), rawDiff)
|
|
134
|
+
await writeFile(diffPath, filtered)
|
|
135
|
+
await writeFile(join(runDir, 'excluded-files.json'), `${JSON.stringify(excluded, null, 2)}\n`)
|
|
136
|
+
await logLine(runDir, {
|
|
137
|
+
stage: 'filter',
|
|
138
|
+
status: 'done',
|
|
139
|
+
excluded: excluded.length,
|
|
140
|
+
patterns: Array.from(new Set(excluded.map((e) => e.pattern))),
|
|
141
|
+
})
|
|
142
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { splitDiffByFile } from './diff-utils.ts'
|
|
2
|
+
import type { ReviewFinding } from './types.ts'
|
|
3
|
+
|
|
4
|
+
const TEST_FILE_PATTERNS: RegExp[] = [
|
|
5
|
+
/\.test\.[jt]sx?$/, // foo.test.ts
|
|
6
|
+
/\.spec\.[jt]sx?$/, // foo.spec.ts
|
|
7
|
+
/(^|\/)__tests__\//, // __tests__/foo.ts
|
|
8
|
+
/(^|\/)tests?\//, // tests/foo.ts, test/foo.ts
|
|
9
|
+
/(^|\/)test_[^/]+\.py$/, // test_foo.py
|
|
10
|
+
/_test\.(go|py|rb)$/, // foo_test.go
|
|
11
|
+
/(^|\/)spec\//, // spec/foo_spec.rb
|
|
12
|
+
/Tests?\.(java|kt|cs)$/, // FooTest.java
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const NON_CODE_EXTENSIONS = new Set([
|
|
16
|
+
'.md',
|
|
17
|
+
'.txt',
|
|
18
|
+
'.json',
|
|
19
|
+
'.yaml',
|
|
20
|
+
'.yml',
|
|
21
|
+
'.toml',
|
|
22
|
+
'.lock',
|
|
23
|
+
'.svg',
|
|
24
|
+
'.png',
|
|
25
|
+
'.jpg',
|
|
26
|
+
'.jpeg',
|
|
27
|
+
'.gif',
|
|
28
|
+
'.ico',
|
|
29
|
+
'.css',
|
|
30
|
+
'.scss',
|
|
31
|
+
'.html',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
export function isTestFile(path: string): boolean {
|
|
35
|
+
return TEST_FILE_PATTERNS.some((p) => p.test(path))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fileExt(path: string): string {
|
|
39
|
+
const dot = path.lastIndexOf('.')
|
|
40
|
+
return dot === -1 ? '' : path.slice(dot)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function looksLikeSourceCode(path: string): boolean {
|
|
44
|
+
return !NON_CODE_EXTENSIONS.has(fileExt(path).toLowerCase())
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const COMMENT_PREFIXES = ['//', '#', '*', '/*', '"""', "'''"]
|
|
48
|
+
|
|
49
|
+
function isLikelyCodeLine(content: string): boolean {
|
|
50
|
+
const trimmed = content.trim()
|
|
51
|
+
if (trimmed.length === 0) return false
|
|
52
|
+
if (COMMENT_PREFIXES.some((p) => trimmed.startsWith(p))) return false
|
|
53
|
+
// Strip leading import statements; they're common churn that doesn't warrant tests.
|
|
54
|
+
if (/^(import|from|require\()/.test(trimmed)) return false
|
|
55
|
+
return /[A-Za-z]/.test(trimmed)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function countAddedCodeLines(chunk: string): number {
|
|
59
|
+
let n = 0
|
|
60
|
+
for (const line of chunk.split('\n')) {
|
|
61
|
+
if (!line.startsWith('+')) continue
|
|
62
|
+
if (line.startsWith('+++')) continue
|
|
63
|
+
if (isLikelyCodeLine(line.slice(1))) n += 1
|
|
64
|
+
}
|
|
65
|
+
return n
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type DetectMissingTestsOptions = {
|
|
69
|
+
/** Minimum added code lines to trigger a missing-tests finding. Default 10. */
|
|
70
|
+
minAddedLines?: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Walk the unified diff. If the diff contains zero test files anywhere, emit
|
|
75
|
+
* one finding per non-test source file that added >= minAddedLines of code-ish
|
|
76
|
+
* lines. Returns an empty array when test files are present (we assume the
|
|
77
|
+
* author has accounted for coverage).
|
|
78
|
+
*/
|
|
79
|
+
export function detectMissingTests(
|
|
80
|
+
diff: string,
|
|
81
|
+
options: DetectMissingTestsOptions = {},
|
|
82
|
+
): ReviewFinding[] {
|
|
83
|
+
if (!diff) return []
|
|
84
|
+
const minAdded = options.minAddedLines ?? 10
|
|
85
|
+
const byFile = splitDiffByFile(diff)
|
|
86
|
+
const filePaths = [...byFile.keys()]
|
|
87
|
+
const hasAnyTestFile = filePaths.some((p) => isTestFile(p))
|
|
88
|
+
if (hasAnyTestFile) return []
|
|
89
|
+
|
|
90
|
+
const findings: ReviewFinding[] = []
|
|
91
|
+
let id = 1
|
|
92
|
+
for (const [path, chunk] of byFile) {
|
|
93
|
+
if (isTestFile(path)) continue
|
|
94
|
+
if (!looksLikeSourceCode(path)) continue
|
|
95
|
+
const added = countAddedCodeLines(chunk)
|
|
96
|
+
if (added < minAdded) continue
|
|
97
|
+
findings.push({
|
|
98
|
+
id: `tests-${id++}`,
|
|
99
|
+
file: path,
|
|
100
|
+
line: null,
|
|
101
|
+
severity: 'medium',
|
|
102
|
+
risk: {
|
|
103
|
+
impact: 'medium',
|
|
104
|
+
likelihood: 'possible',
|
|
105
|
+
confidence: 'medium',
|
|
106
|
+
action: 'should-fix',
|
|
107
|
+
},
|
|
108
|
+
title: `No tests added for changes in ${path}`,
|
|
109
|
+
description: [
|
|
110
|
+
`Observation: This PR adds ${added} new code-ish line${added === 1 ? '' : 's'} in \`${path}\` and the diff contains no test or spec file anywhere.`,
|
|
111
|
+
`Why it matters: Behaviour changes without test coverage are easy to regress and harder to review with confidence.`,
|
|
112
|
+
`Suggested direction: Add or extend a test that exercises the new behaviour, or note explicitly why a test is not warranted (config-only change, type-only change, trivial refactor).`,
|
|
113
|
+
].join('\n\n'),
|
|
114
|
+
domain: 'tests',
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
return findings
|
|
118
|
+
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
export const FOCUS_IDS = [
|
|
1
|
+
export const FOCUS_IDS = [
|
|
2
|
+
'security',
|
|
3
|
+
'bugs',
|
|
4
|
+
'performance',
|
|
5
|
+
'code-smells',
|
|
6
|
+
'architecture',
|
|
7
|
+
'tests',
|
|
8
|
+
] as const
|
|
2
9
|
export type FocusId = (typeof FOCUS_IDS)[number]
|
|
3
10
|
|
|
4
11
|
export const SEVERITIES = ['blocker', 'high', 'medium', 'low'] as const
|
|
@@ -45,6 +52,8 @@ export type ReviewFinding = {
|
|
|
45
52
|
suggestion?: Suggestion
|
|
46
53
|
domain: FocusId | string | null
|
|
47
54
|
mergedFrom?: MergedFromEntry[]
|
|
55
|
+
/** Derived 0-10 importance score from risk fields. Populated during dedupe. */
|
|
56
|
+
score?: number
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
function assertOneOf<T extends string>(
|
|
@@ -102,6 +111,7 @@ export function parseFinding(raw: unknown): ReviewFinding {
|
|
|
102
111
|
suggestion,
|
|
103
112
|
domain: (r.domain as ReviewFinding['domain']) ?? null,
|
|
104
113
|
mergedFrom: Array.isArray(r.mergedFrom) ? (r.mergedFrom as MergedFromEntry[]) : undefined,
|
|
114
|
+
...(typeof r.score === 'number' && Number.isFinite(r.score) ? { score: r.score } : {}),
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
117
|
|
package/skills/magpie/skill.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magpie",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), dedupes findings, applies a critic rubric, peer-reviews via codex exec, and serves an interactive HTML report for selecting findings to post via gh. Bundles a Bun CLI installed onto PATH via the skill's postinstall step. Use when the user asks to review a GitHub pull request.",
|
|
5
5
|
"author": "iceinvein",
|
|
6
6
|
"type": "prompt",
|