@iceinvein/agent-skills 0.1.29 → 0.1.31
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 +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 +163 -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 +194 -15
- 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
package/package.json
CHANGED
package/skills/index.json
CHANGED
|
@@ -197,7 +197,7 @@
|
|
|
197
197
|
"name": "magpie",
|
|
198
198
|
"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.",
|
|
199
199
|
"type": "prompt",
|
|
200
|
-
"version": "0.
|
|
200
|
+
"version": "0.4.1"
|
|
201
201
|
},
|
|
202
202
|
{
|
|
203
203
|
"name": "module-secret-auditor",
|
package/skills/magpie/SKILL.md
CHANGED
|
@@ -32,6 +32,12 @@ magpie setup "$RUN_DIR" --pr $PR_NUMBER --repo "$REPO"
|
|
|
32
32
|
|
|
33
33
|
If exit is non-zero, surface stderr verbatim and stop. The CLI removes the worktree and subdirs on failure; the run directory itself plus `log.jsonl` are kept for diagnostics.
|
|
34
34
|
|
|
35
|
+
Setup automatically filters lockfiles, build output, generated source, and snapshot fixtures from `diff.patch` before specialists see it. Users can override by placing `.magpie.json` at the repo root: `{"exclude": [...glob], "include": [...glob], "useDefaults": true|false}`. When anything is filtered, the raw diff is preserved as `$RUN_DIR/diff.full.patch` and the exclusion list as `$RUN_DIR/excluded-files.json`.
|
|
36
|
+
|
|
37
|
+
When a prior run exists for the same PR (active or archived under `~/.magpie/`), setup writes `$RUN_DIR/incremental.json` with `{previousRunId, previousSha, currentSha, sameSha}`. The post stage surfaces this as a "Incremental review since `<sha>`" trailer on the summary comment.
|
|
38
|
+
|
|
39
|
+
Setup also runs a deterministic test-coverage check: when the diff contains zero test or spec files anywhere, each non-test source file with `>= 10` added code lines gets a `domain: "tests"` finding written to `$RUN_DIR/findings/tests.json`. This is a sixth domain that flows through dedupe/critic/peer-review alongside the five LLM specialists. No specialist subagent is dispatched for it.
|
|
40
|
+
|
|
35
41
|
### 2. Serve
|
|
36
42
|
|
|
37
43
|
Start the HTML server in the background using the Bash tool with `run_in_background: true`:
|
|
@@ -48,35 +54,7 @@ Render the first progress paint:
|
|
|
48
54
|
magpie render "$RUN_DIR" progress
|
|
49
55
|
```
|
|
50
56
|
|
|
51
|
-
### 3.
|
|
52
|
-
|
|
53
|
-
If the `mcp__code-intelligence__search_code` tool is available in this conversation, build the context bundle by calling code-intelligence MCP tools for each changed file and writing the result to `$RUN_DIR/pr-context.json`. If MCP is not available, log `{stage: context, status: skipped, reason: mcp-unavailable}` to `$RUN_DIR/log.jsonl` and continue. Re-render progress.
|
|
54
|
-
|
|
55
|
-
Expected shape of `pr-context.json`:
|
|
56
|
-
|
|
57
|
-
```json
|
|
58
|
-
{
|
|
59
|
-
"symbols": [
|
|
60
|
-
{
|
|
61
|
-
"name": "createProject",
|
|
62
|
-
"kind": "function",
|
|
63
|
-
"file": "apps/server/src/resolvers/Mutation/createProject.ts",
|
|
64
|
-
"line": 8,
|
|
65
|
-
"definition": "<source of the symbol, trimmed>",
|
|
66
|
-
"references": [
|
|
67
|
-
{ "file": "apps/server/src/resolvers/Mutation/index.ts", "line": 5, "context": "<one-line snippet>" }
|
|
68
|
-
],
|
|
69
|
-
"tests": [
|
|
70
|
-
{ "file": "apps/server/src/resolvers/Mutation/__tests__/createProject.test.ts", "line": 12, "context": "<one-line snippet>" }
|
|
71
|
-
]
|
|
72
|
-
}
|
|
73
|
-
]
|
|
74
|
-
}
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Cap `references` at 20 per symbol; include only tests that import or call the symbol.
|
|
78
|
-
|
|
79
|
-
### 4. Specialists
|
|
57
|
+
### 3. Specialists
|
|
80
58
|
|
|
81
59
|
Dispatch the five specialist subagents in a single message using five Agent tool calls in parallel. For each focus in (security, bugs, performance, code-smells, architecture), the prompt is:
|
|
82
60
|
|
|
@@ -86,7 +64,6 @@ Dispatch the five specialist subagents in a single message using five Agent tool
|
|
|
86
64
|
You are reviewing PR #<PR_NUMBER>.
|
|
87
65
|
Working directory: $RUN_DIR/worktree
|
|
88
66
|
Diff: $RUN_DIR/diff.patch
|
|
89
|
-
Code context (if exists): $RUN_DIR/pr-context.json
|
|
90
67
|
|
|
91
68
|
## Output Contract
|
|
92
69
|
|
|
@@ -129,19 +106,23 @@ After each subagent returns, append `{stage: specialist, focus: <focus>, status:
|
|
|
129
106
|
|
|
130
107
|
If all five specialists fail (no findings files written), log `{stage: specialists, status: error}` and stop. Otherwise mark `{stage: specialists, status: done}`.
|
|
131
108
|
|
|
132
|
-
###
|
|
109
|
+
### 4. Dedupe
|
|
133
110
|
|
|
134
111
|
```
|
|
135
|
-
magpie dedupe "$RUN_DIR"
|
|
112
|
+
magpie dedupe "$RUN_DIR" [--threshold <0-10>]
|
|
136
113
|
```
|
|
137
114
|
|
|
115
|
+
`magpie dedupe` also runs a deterministic evidence check against the worktree: findings whose `file` is missing or whose `line` is out of range are dropped. Drops are logged and recorded to `$RUN_DIR/evidence-dropped.json`. The check is skipped if the worktree is no longer present (archived run replay).
|
|
116
|
+
|
|
117
|
+
Each finding receives a derived 0-10 `score` from its risk fields. Findings below `--threshold` (default 3) are dropped before the critic LLM runs and recorded to `$RUN_DIR/threshold-dropped.json`. Pass `--threshold 0` to keep everything.
|
|
118
|
+
|
|
138
119
|
Re-render progress.
|
|
139
120
|
|
|
140
|
-
###
|
|
121
|
+
### 5. Critic
|
|
141
122
|
|
|
142
123
|
Read `$RUN_DIR/findings.deduped.json`. Apply the critic rubric from this SKILL.md verbatim (one verdict per finding). Write the kept subset to `$RUN_DIR/findings.kept.json`. Append `{stage: critic, status: done}` and re-render progress.
|
|
143
124
|
|
|
144
|
-
###
|
|
125
|
+
### 6. Peer review
|
|
145
126
|
|
|
146
127
|
Build the peer-review prompt by taking the `magpie-peer-review` block from this SKILL.md and substituting the placeholders listed in its `## Substitute before use` preamble. Write the substituted prompt to `$RUN_DIR/peer-prompt.md`. Then run codex with the prompt piped on stdin:
|
|
147
128
|
|
|
@@ -155,7 +136,7 @@ If codex returns non-zero, ask the user once: "Codex peer-review failed: <stderr
|
|
|
155
136
|
|
|
156
137
|
Otherwise parse the verdicts JSON, apply them (drop / downgrade), and write `findings.final.json`. Append `{stage: peer-review, status: done}` and re-render progress.
|
|
157
138
|
|
|
158
|
-
###
|
|
139
|
+
### 7. Report
|
|
159
140
|
|
|
160
141
|
```
|
|
161
142
|
magpie render "$RUN_DIR" findings
|
|
@@ -165,7 +146,7 @@ Print to the terminal: "Findings ready at <url>. Click checkboxes to select what
|
|
|
165
146
|
|
|
166
147
|
End the turn.
|
|
167
148
|
|
|
168
|
-
###
|
|
149
|
+
### 8. Post
|
|
169
150
|
|
|
170
151
|
Most users will tick the checkboxes in the served report and click "Post to PR"; the report server handles the rest. The agent only handles posts when the user explicitly types `post` (optionally `post 1,3,7` for indices) in the conversation.
|
|
171
152
|
|
|
@@ -188,7 +169,7 @@ Pass `--dry-run` to record the would-be gh commands without invoking gh. After p
|
|
|
188
169
|
magpie render "$RUN_DIR" findings
|
|
189
170
|
```
|
|
190
171
|
|
|
191
|
-
###
|
|
172
|
+
### 9. Cleanup
|
|
192
173
|
|
|
193
174
|
```
|
|
194
175
|
magpie cleanup "$RUN_DIR" --repo "$REPO"
|
|
@@ -99,11 +99,22 @@ const HANDLERS: Record<string, Handler> = {
|
|
|
99
99
|
dedupe: async (args) => {
|
|
100
100
|
const runDir = args[0]
|
|
101
101
|
if (!runDir) {
|
|
102
|
-
process.stderr.write('dedupe: missing <run-dir
|
|
102
|
+
process.stderr.write('dedupe: missing <run-dir> [--threshold <0-10>]\n')
|
|
103
103
|
return 2
|
|
104
104
|
}
|
|
105
|
+
const thresholdFlag = args.indexOf('--threshold')
|
|
106
|
+
const thresholdRaw = thresholdFlag !== -1 ? args[thresholdFlag + 1] : undefined
|
|
107
|
+
let threshold: number | undefined
|
|
108
|
+
if (thresholdRaw !== undefined) {
|
|
109
|
+
const n = Number(thresholdRaw)
|
|
110
|
+
if (!Number.isFinite(n) || n < 0 || n > 10) {
|
|
111
|
+
process.stderr.write(`dedupe: invalid --threshold ${thresholdRaw} (want 0-10)\n`)
|
|
112
|
+
return 2
|
|
113
|
+
}
|
|
114
|
+
threshold = n
|
|
115
|
+
}
|
|
105
116
|
const { runDedupe } = await import('../scripts/dedupe-cmd.ts')
|
|
106
|
-
return runDedupe(runDir)
|
|
117
|
+
return runDedupe(runDir, threshold !== undefined ? { threshold } : {})
|
|
107
118
|
},
|
|
108
119
|
render: async (args) => {
|
|
109
120
|
const runDir = args[0]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
case "$1 $2" in
|
|
3
|
+
"pr view")
|
|
4
|
+
cat <<'JSON'
|
|
5
|
+
{
|
|
6
|
+
"number": 1234,
|
|
7
|
+
"title": "Fake PR",
|
|
8
|
+
"headRefName": "feature-x",
|
|
9
|
+
"baseRefName": "main",
|
|
10
|
+
"headRefOid": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
|
11
|
+
"baseRefOid": "cafebabecafebabecafebabecafebabecafebabe",
|
|
12
|
+
"author": { "login": "octocat" },
|
|
13
|
+
"body": "Fake body",
|
|
14
|
+
"url": "https://github.com/octocat/Hello-World/pull/1234",
|
|
15
|
+
"files": [
|
|
16
|
+
{ "path": "src/a.ts", "additions": 1, "deletions": 0, "changeType": "modified" },
|
|
17
|
+
{ "path": "bun.lock", "additions": 1, "deletions": 0, "changeType": "modified" },
|
|
18
|
+
{ "path": "dist/x.js", "additions": 1, "deletions": 0, "changeType": "modified" }
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
JSON
|
|
22
|
+
;;
|
|
23
|
+
"pr diff")
|
|
24
|
+
cat <<'DIFF'
|
|
25
|
+
diff --git a/src/a.ts b/src/a.ts
|
|
26
|
+
index 1111111..2222222 100644
|
|
27
|
+
--- a/src/a.ts
|
|
28
|
+
+++ b/src/a.ts
|
|
29
|
+
@@ -1 +1 @@
|
|
30
|
+
-export const x = 1
|
|
31
|
+
+export const x = 2
|
|
32
|
+
diff --git a/bun.lock b/bun.lock
|
|
33
|
+
index 3333333..4444444 100644
|
|
34
|
+
--- a/bun.lock
|
|
35
|
+
+++ b/bun.lock
|
|
36
|
+
@@ -1 +1 @@
|
|
37
|
+
-{}
|
|
38
|
+
+{"a":1}
|
|
39
|
+
diff --git a/dist/x.js b/dist/x.js
|
|
40
|
+
index 5555555..6666666 100644
|
|
41
|
+
--- a/dist/x.js
|
|
42
|
+
+++ b/dist/x.js
|
|
43
|
+
@@ -1 +1 @@
|
|
44
|
+
-1
|
|
45
|
+
+2
|
|
46
|
+
DIFF
|
|
47
|
+
;;
|
|
48
|
+
*)
|
|
49
|
+
echo "fake-gh: unsupported args: $*" >&2
|
|
50
|
+
exit 1
|
|
51
|
+
;;
|
|
52
|
+
esac
|
|
@@ -77,3 +77,48 @@ test('runDedupe with no findings files writes empty array', async () => {
|
|
|
77
77
|
const out = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
|
|
78
78
|
expect(out).toHaveLength(0)
|
|
79
79
|
})
|
|
80
|
+
|
|
81
|
+
test('runDedupe attaches score to each finding', async () => {
|
|
82
|
+
await writeFile(
|
|
83
|
+
join(runDir, 'findings', 'bugs.json'),
|
|
84
|
+
JSON.stringify([f('1', 'a.ts', 10, 'mid title', 'bugs')]),
|
|
85
|
+
)
|
|
86
|
+
const exit = await runDedupe(runDir)
|
|
87
|
+
expect(exit).toBe(0)
|
|
88
|
+
const out = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
|
|
89
|
+
expect(out[0]?.score).toBeGreaterThan(0)
|
|
90
|
+
expect(out[0]?.score).toBeLessThanOrEqual(10)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('runDedupe drops findings below threshold and writes sidecar', async () => {
|
|
94
|
+
const low: ReviewFinding = {
|
|
95
|
+
...f('low-1', 'a.ts', 10, 'tiny cleanup', 'code-smells'),
|
|
96
|
+
severity: 'low',
|
|
97
|
+
risk: { impact: 'low', likelihood: 'edge-case', confidence: 'low', action: 'optional' },
|
|
98
|
+
}
|
|
99
|
+
const high: ReviewFinding = {
|
|
100
|
+
...f('high-1', 'b.ts', 10, 'serious bug', 'bugs'),
|
|
101
|
+
severity: 'blocker',
|
|
102
|
+
risk: { impact: 'critical', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
|
|
103
|
+
}
|
|
104
|
+
await writeFile(join(runDir, 'findings', 'bugs.json'), JSON.stringify([low, high]))
|
|
105
|
+
const exit = await runDedupe(runDir, { threshold: 5 })
|
|
106
|
+
expect(exit).toBe(0)
|
|
107
|
+
const kept = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
|
|
108
|
+
expect(kept.map((x: ReviewFinding) => x.id)).toEqual(['high-1'])
|
|
109
|
+
const dropped = JSON.parse(await readFile(join(runDir, 'threshold-dropped.json'), 'utf8'))
|
|
110
|
+
expect(dropped[0]?.id).toBe('low-1')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('runDedupe with threshold 0 keeps everything', async () => {
|
|
114
|
+
const low: ReviewFinding = {
|
|
115
|
+
...f('low-1', 'a.ts', 10, 'tiny cleanup', 'code-smells'),
|
|
116
|
+
severity: 'low',
|
|
117
|
+
risk: { impact: 'low', likelihood: 'edge-case', confidence: 'low', action: 'optional' },
|
|
118
|
+
}
|
|
119
|
+
await writeFile(join(runDir, 'findings', 'bugs.json'), JSON.stringify([low]))
|
|
120
|
+
const exit = await runDedupe(runDir, { threshold: 0 })
|
|
121
|
+
expect(exit).toBe(0)
|
|
122
|
+
const kept = JSON.parse(await readFile(join(runDir, 'findings.deduped.json'), 'utf8'))
|
|
123
|
+
expect(kept).toHaveLength(1)
|
|
124
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { verifyEvidence } from '../evidence-filter.ts'
|
|
6
|
+
import type { ReviewFinding } from '../types.ts'
|
|
7
|
+
|
|
8
|
+
let worktree: string
|
|
9
|
+
|
|
10
|
+
function finding(overrides: Partial<ReviewFinding>): ReviewFinding {
|
|
11
|
+
return {
|
|
12
|
+
id: 'x-1',
|
|
13
|
+
file: 'src/a.ts',
|
|
14
|
+
line: 1,
|
|
15
|
+
severity: 'medium',
|
|
16
|
+
risk: { impact: 'medium', likelihood: 'possible', confidence: 'medium', action: 'should-fix' },
|
|
17
|
+
title: 't',
|
|
18
|
+
description: 'd',
|
|
19
|
+
domain: 'bugs',
|
|
20
|
+
...overrides,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
worktree = await mkdtemp(join(tmpdir(), 'magpie-evidence-'))
|
|
26
|
+
await mkdir(join(worktree, 'src'), { recursive: true })
|
|
27
|
+
await writeFile(join(worktree, 'src/a.ts'), 'line1\nline2\nline3\n')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await rm(worktree, { recursive: true, force: true })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('keeps finding with valid file and line', async () => {
|
|
35
|
+
const result = await verifyEvidence([finding({ line: 2 })], worktree)
|
|
36
|
+
expect(result.kept).toHaveLength(1)
|
|
37
|
+
expect(result.dropped).toHaveLength(0)
|
|
38
|
+
expect(result.skipped).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('drops finding referencing missing file', async () => {
|
|
42
|
+
const result = await verifyEvidence([finding({ file: 'src/ghost.ts', line: 1 })], worktree)
|
|
43
|
+
expect(result.kept).toHaveLength(0)
|
|
44
|
+
expect(result.dropped[0]?.reason).toBe('hallucinated-file')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('drops finding when line exceeds file length', async () => {
|
|
48
|
+
const result = await verifyEvidence([finding({ line: 999 })], worktree)
|
|
49
|
+
expect(result.kept).toHaveLength(0)
|
|
50
|
+
expect(result.dropped[0]?.reason).toBe('invented-line')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('drops finding when line is < 1', async () => {
|
|
54
|
+
const result = await verifyEvidence([finding({ line: 0 })], worktree)
|
|
55
|
+
expect(result.kept).toHaveLength(0)
|
|
56
|
+
expect(result.dropped[0]?.reason).toBe('invented-line')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('keeps unanchored finding (line=null) without checking file', async () => {
|
|
60
|
+
const result = await verifyEvidence([finding({ file: 'src/ghost.ts', line: null })], worktree)
|
|
61
|
+
expect(result.kept).toHaveLength(1)
|
|
62
|
+
expect(result.dropped).toHaveLength(0)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('skips verification when worktree is missing', async () => {
|
|
66
|
+
await rm(worktree, { recursive: true, force: true })
|
|
67
|
+
const result = await verifyEvidence([finding({ line: 999 })], worktree)
|
|
68
|
+
expect(result.skipped).toBe(true)
|
|
69
|
+
expect(result.kept).toHaveLength(1)
|
|
70
|
+
expect(result.dropped).toHaveLength(0)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('caches line counts across findings in the same file', async () => {
|
|
74
|
+
const findings = [finding({ id: 'a', line: 1 }), finding({ id: 'b', line: 3 })]
|
|
75
|
+
const result = await verifyEvidence(findings, worktree)
|
|
76
|
+
expect(result.kept.map((f) => f.id)).toEqual(['a', 'b'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('rejects directory paths', async () => {
|
|
80
|
+
const result = await verifyEvidence([finding({ file: 'src', line: 1 })], worktree)
|
|
81
|
+
expect(result.kept).toHaveLength(0)
|
|
82
|
+
expect(result.dropped[0]?.reason).toBe('hallucinated-file')
|
|
83
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { buildIncrementalContext, findPreviousRun } from '../incremental.ts'
|
|
6
|
+
|
|
7
|
+
let home: string
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
home = await mkdtemp(join(tmpdir(), 'magpie-incremental-home-'))
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await rm(home, { recursive: true, force: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
async function seedRun(id: string, prJson: Record<string, unknown>): Promise<string> {
|
|
18
|
+
const dir = join(home, id)
|
|
19
|
+
await mkdir(dir, { recursive: true })
|
|
20
|
+
await writeFile(join(dir, 'pr.json'), JSON.stringify(prJson))
|
|
21
|
+
return dir
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('findPreviousRun returns null when no prior runs exist', async () => {
|
|
25
|
+
const result = await findPreviousRun(42, join(home, 'pr-42-100'), home)
|
|
26
|
+
expect(result).toBeNull()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('findPreviousRun returns the most recent prior run for the PR', async () => {
|
|
30
|
+
await seedRun('pr-42-100', { headRefOid: 'aaa' })
|
|
31
|
+
await seedRun('pr-42-200', { headRefOid: 'bbb' })
|
|
32
|
+
const currentRun = join(home, 'pr-42-300')
|
|
33
|
+
const result = await findPreviousRun(42, currentRun, home)
|
|
34
|
+
expect(result?.runId).toBe('pr-42-200')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('findPreviousRun excludes the current run from candidates', async () => {
|
|
38
|
+
await seedRun('pr-42-100', { headRefOid: 'aaa' })
|
|
39
|
+
const currentRun = join(home, 'pr-42-200')
|
|
40
|
+
await mkdir(currentRun, { recursive: true })
|
|
41
|
+
const result = await findPreviousRun(42, currentRun, home)
|
|
42
|
+
expect(result?.runId).toBe('pr-42-100')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('findPreviousRun ignores other PR numbers', async () => {
|
|
46
|
+
await seedRun('pr-99-100', { headRefOid: 'other' })
|
|
47
|
+
const result = await findPreviousRun(42, join(home, 'pr-42-200'), home)
|
|
48
|
+
expect(result).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('findPreviousRun matches archived runs', async () => {
|
|
52
|
+
await seedRun('pr-42-100.archived-200', { headRefOid: 'old' })
|
|
53
|
+
const result = await findPreviousRun(42, join(home, 'pr-42-300'), home)
|
|
54
|
+
expect(result?.runId).toBe('pr-42-100.archived-200')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('findPreviousRun skips candidates with missing pr.json', async () => {
|
|
58
|
+
await mkdir(join(home, 'pr-42-100'), { recursive: true })
|
|
59
|
+
await seedRun('pr-42-50', { headRefOid: 'older' })
|
|
60
|
+
const result = await findPreviousRun(42, join(home, 'pr-42-200'), home)
|
|
61
|
+
expect(result?.runId).toBe('pr-42-50')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('buildIncrementalContext detects new commits', () => {
|
|
65
|
+
const ctx = buildIncrementalContext(
|
|
66
|
+
{ headRefOid: 'newsha' },
|
|
67
|
+
{ runId: 'pr-42-100', prJson: { headRefOid: 'oldsha' } },
|
|
68
|
+
)
|
|
69
|
+
expect(ctx?.previousSha).toBe('oldsha')
|
|
70
|
+
expect(ctx?.currentSha).toBe('newsha')
|
|
71
|
+
expect(ctx?.sameSha).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('buildIncrementalContext flags same-sha re-review', () => {
|
|
75
|
+
const ctx = buildIncrementalContext(
|
|
76
|
+
{ headRefOid: 'samesha' },
|
|
77
|
+
{ runId: 'pr-42-100', prJson: { headRefOid: 'samesha' } },
|
|
78
|
+
)
|
|
79
|
+
expect(ctx?.sameSha).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('buildIncrementalContext returns null when either sha is missing', () => {
|
|
83
|
+
const noPrev = buildIncrementalContext({ headRefOid: 'new' }, { runId: 'x', prJson: {} })
|
|
84
|
+
expect(noPrev).toBeNull()
|
|
85
|
+
const noCurr = buildIncrementalContext({}, { runId: 'x', prJson: { headRefOid: 'a' } })
|
|
86
|
+
expect(noCurr).toBeNull()
|
|
87
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_EXCLUDES,
|
|
7
|
+
filterDiff,
|
|
8
|
+
globToRegex,
|
|
9
|
+
loadPathFilterConfig,
|
|
10
|
+
matchesAny,
|
|
11
|
+
} from '../path-filter.ts'
|
|
12
|
+
|
|
13
|
+
let cwd: string
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
cwd = await mkdtemp(join(tmpdir(), 'magpie-pathfilter-'))
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await rm(cwd, { recursive: true, force: true })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('globToRegex: * does not cross path segments', () => {
|
|
24
|
+
expect(globToRegex('*.lock').test('bun.lock')).toBe(true)
|
|
25
|
+
expect(globToRegex('*.lock').test('a/bun.lock')).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('globToRegex: ** matches across segments', () => {
|
|
29
|
+
expect(globToRegex('**/*.lock').test('bun.lock')).toBe(true)
|
|
30
|
+
expect(globToRegex('**/*.lock').test('a/b/bun.lock')).toBe(true)
|
|
31
|
+
expect(globToRegex('dist/**').test('dist/index.js')).toBe(true)
|
|
32
|
+
expect(globToRegex('dist/**').test('dist/sub/index.js')).toBe(true)
|
|
33
|
+
expect(globToRegex('dist/**').test('src/index.js')).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('globToRegex: dot is literal', () => {
|
|
37
|
+
expect(globToRegex('*.generated.*').test('user.generated.ts')).toBe(true)
|
|
38
|
+
expect(globToRegex('*.generated.*').test('usergeneratedts')).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('matchesAny returns the matched pattern or null', () => {
|
|
42
|
+
expect(matchesAny('bun.lock', ['**/*.lock'])).toBe('**/*.lock')
|
|
43
|
+
expect(matchesAny('src/a.ts', ['**/*.lock', 'dist/**'])).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('DEFAULT_EXCLUDES covers common targets', () => {
|
|
47
|
+
const cases = [
|
|
48
|
+
'package-lock.json',
|
|
49
|
+
'pnpm-lock.yaml',
|
|
50
|
+
'bun.lock',
|
|
51
|
+
'dist/index.js',
|
|
52
|
+
'node_modules/foo/index.js',
|
|
53
|
+
'coverage/lcov.info',
|
|
54
|
+
'src/types.generated.ts',
|
|
55
|
+
'src/types.pb.go',
|
|
56
|
+
'src/__snapshots__/foo.test.ts.snap',
|
|
57
|
+
]
|
|
58
|
+
for (const c of cases) {
|
|
59
|
+
expect(matchesAny(c, DEFAULT_EXCLUDES)).not.toBeNull()
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('DEFAULT_EXCLUDES keeps normal source', () => {
|
|
64
|
+
for (const c of ['src/index.ts', 'apps/server/src/main.ts', 'README.md']) {
|
|
65
|
+
expect(matchesAny(c, DEFAULT_EXCLUDES)).toBeNull()
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('loadPathFilterConfig: missing file returns defaults', async () => {
|
|
70
|
+
const cfg = await loadPathFilterConfig(cwd)
|
|
71
|
+
expect(cfg.useDefaults).toBe(true)
|
|
72
|
+
expect(cfg.exclude).toEqual([...DEFAULT_EXCLUDES])
|
|
73
|
+
expect(cfg.include).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('loadPathFilterConfig: user exclude appends to defaults', async () => {
|
|
77
|
+
await writeFile(join(cwd, '.magpie.json'), JSON.stringify({ exclude: ['fixtures/**'] }))
|
|
78
|
+
const cfg = await loadPathFilterConfig(cwd)
|
|
79
|
+
expect(cfg.exclude).toContain('fixtures/**')
|
|
80
|
+
expect(cfg.exclude.length).toBe(DEFAULT_EXCLUDES.length + 1)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('loadPathFilterConfig: useDefaults false drops defaults', async () => {
|
|
84
|
+
await writeFile(
|
|
85
|
+
join(cwd, '.magpie.json'),
|
|
86
|
+
JSON.stringify({ useDefaults: false, exclude: ['fixtures/**'] }),
|
|
87
|
+
)
|
|
88
|
+
const cfg = await loadPathFilterConfig(cwd)
|
|
89
|
+
expect(cfg.exclude).toEqual(['fixtures/**'])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('loadPathFilterConfig: malformed JSON falls back to defaults', async () => {
|
|
93
|
+
await writeFile(join(cwd, '.magpie.json'), 'not json {')
|
|
94
|
+
const cfg = await loadPathFilterConfig(cwd)
|
|
95
|
+
expect(cfg.useDefaults).toBe(true)
|
|
96
|
+
expect(cfg.exclude).toEqual([...DEFAULT_EXCLUDES])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const DIFF = `diff --git a/src/a.ts b/src/a.ts
|
|
100
|
+
index 1..2 100644
|
|
101
|
+
--- a/src/a.ts
|
|
102
|
+
+++ b/src/a.ts
|
|
103
|
+
@@ -1 +1 @@
|
|
104
|
+
-x
|
|
105
|
+
+y
|
|
106
|
+
diff --git a/bun.lock b/bun.lock
|
|
107
|
+
index 1..2 100644
|
|
108
|
+
--- a/bun.lock
|
|
109
|
+
+++ b/bun.lock
|
|
110
|
+
@@ -1 +1 @@
|
|
111
|
+
-{}
|
|
112
|
+
+{"a":1}
|
|
113
|
+
diff --git a/dist/index.js b/dist/index.js
|
|
114
|
+
index 1..2 100644
|
|
115
|
+
--- a/dist/index.js
|
|
116
|
+
+++ b/dist/index.js
|
|
117
|
+
@@ -1 +1 @@
|
|
118
|
+
-1
|
|
119
|
+
+2
|
|
120
|
+
`
|
|
121
|
+
|
|
122
|
+
test('filterDiff: keeps source, drops lockfile and dist/', () => {
|
|
123
|
+
const { filtered, excluded } = filterDiff(DIFF, {
|
|
124
|
+
exclude: [...DEFAULT_EXCLUDES],
|
|
125
|
+
include: [],
|
|
126
|
+
useDefaults: true,
|
|
127
|
+
})
|
|
128
|
+
expect(filtered).toContain('src/a.ts')
|
|
129
|
+
expect(filtered).not.toContain('bun.lock')
|
|
130
|
+
expect(filtered).not.toContain('dist/index.js')
|
|
131
|
+
expect(excluded.map((e) => e.path).sort()).toEqual(['bun.lock', 'dist/index.js'])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('filterDiff: empty diff', () => {
|
|
135
|
+
const { filtered, excluded } = filterDiff('', {
|
|
136
|
+
exclude: ['**/*'],
|
|
137
|
+
include: [],
|
|
138
|
+
useDefaults: false,
|
|
139
|
+
})
|
|
140
|
+
expect(filtered).toBe('')
|
|
141
|
+
expect(excluded).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('filterDiff: include overrides excludes', () => {
|
|
145
|
+
const { filtered, excluded } = filterDiff(DIFF, {
|
|
146
|
+
exclude: ['**/*'],
|
|
147
|
+
include: ['src/**'],
|
|
148
|
+
useDefaults: false,
|
|
149
|
+
})
|
|
150
|
+
expect(filtered).toContain('src/a.ts')
|
|
151
|
+
expect(excluded.map((e) => e.path).sort()).toEqual(['bun.lock', 'dist/index.js'])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('filterDiff: include records not-in-include reason', () => {
|
|
155
|
+
const { excluded } = filterDiff(DIFF, {
|
|
156
|
+
exclude: [],
|
|
157
|
+
include: ['src/**'],
|
|
158
|
+
useDefaults: false,
|
|
159
|
+
})
|
|
160
|
+
expect(excluded.find((e) => e.path === 'bun.lock')?.pattern).toBe('not-in-include')
|
|
161
|
+
})
|