@iceinvein/agent-skills 0.1.24 → 0.1.26

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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/skills/magpie/README.md +16 -1
  4. package/skills/magpie/bin/magpie.ts +60 -2
  5. package/skills/magpie/fixtures/example-pr/diff.patch +86 -0
  6. package/skills/magpie/fixtures/example-pr/findings.final.json +137 -0
  7. package/skills/magpie/fixtures/example-pr/post-status.json +5 -0
  8. package/skills/magpie/fixtures/example-pr/pr.json +21 -0
  9. package/skills/magpie/fixtures/fake-gh.sh +10 -1
  10. package/skills/magpie/package.json +1 -1
  11. package/skills/magpie/scripts/__tests__/cli.test.ts +119 -0
  12. package/skills/magpie/scripts/__tests__/diff-utils.test.ts +106 -0
  13. package/skills/magpie/scripts/__tests__/file-tree.test.ts +97 -0
  14. package/skills/magpie/scripts/__tests__/gh.test.ts +8 -0
  15. package/skills/magpie/scripts/__tests__/helper.test.ts +63 -0
  16. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +10 -0
  17. package/skills/magpie/scripts/__tests__/post-cmd.test.ts +90 -1
  18. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +210 -0
  19. package/skills/magpie/scripts/__tests__/refresh.test.ts +2 -2
  20. package/skills/magpie/scripts/__tests__/render-action-bar.test.ts +66 -0
  21. package/skills/magpie/scripts/__tests__/render-annotation.test.ts +67 -0
  22. package/skills/magpie/scripts/__tests__/render-cmd.test.ts +46 -0
  23. package/skills/magpie/scripts/__tests__/render-diff.test.ts +84 -0
  24. package/skills/magpie/scripts/__tests__/render-file-tree.test.ts +54 -0
  25. package/skills/magpie/scripts/__tests__/render-findings.test.ts +104 -182
  26. package/skills/magpie/scripts/__tests__/render-issues-list.test.ts +67 -0
  27. package/skills/magpie/scripts/__tests__/render-progress.test.ts +86 -0
  28. package/skills/magpie/scripts/__tests__/server.test.ts +54 -0
  29. package/skills/magpie/scripts/__tests__/types.test.ts +48 -3
  30. package/skills/magpie/scripts/diff-utils.ts +129 -0
  31. package/skills/magpie/scripts/file-tree.ts +74 -0
  32. package/skills/magpie/scripts/gh.ts +1 -0
  33. package/skills/magpie/scripts/helper.js +402 -192
  34. package/skills/magpie/scripts/post-cmd.ts +202 -0
  35. package/skills/magpie/scripts/preview-cmd.ts +300 -0
  36. package/skills/magpie/scripts/refresh.ts +16 -1
  37. package/skills/magpie/scripts/render-action-bar.ts +51 -0
  38. package/skills/magpie/scripts/render-annotation.ts +96 -0
  39. package/skills/magpie/scripts/render-cmd.ts +24 -1
  40. package/skills/magpie/scripts/render-diff.ts +125 -0
  41. package/skills/magpie/scripts/render-file-tree.ts +86 -0
  42. package/skills/magpie/scripts/render-findings.ts +165 -196
  43. package/skills/magpie/scripts/render-issues-list.ts +78 -0
  44. package/skills/magpie/scripts/render-progress.ts +74 -32
  45. package/skills/magpie/scripts/server.ts +43 -0
  46. package/skills/magpie/scripts/types.ts +13 -0
  47. package/skills/magpie/skill.json +4 -2
  48. package/skills/magpie/templates/styles.css +1288 -629
package/README.md CHANGED
@@ -104,6 +104,7 @@ Skills that compose the other audit skills into higher-level workflows.
104
104
  | Skill | What it does |
105
105
  |-------|--------------|
106
106
  | **improve-my-codebase** | Runs every applicable audit skill in parallel against your codebase, ranks findings by convergence (multiple audits agreeing) and per-file rollup, and writes a prioritized improvement report. Default sweeps everything; positional args switch modes (`quick`, `diff`, `interactive`) or narrow scope (`focus <area>`, `module <path>`). |
107
+ | **magpie** | 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`. |
107
108
 
108
109
  ## Commands
109
110
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Install agent skills into AI coding tools",
5
5
  "author": "iceinvein",
6
6
  "license": "MIT",
@@ -33,11 +33,26 @@ Inside Claude Code, ask: "Review PR 1234" (or paste a PR URL). The agent reads S
33
33
 
34
34
  ```
35
35
  bun install # Installs @types/bun
36
- bun test # Run all tests (145 tests, 24 files)
36
+ bun test # Run all tests
37
37
  bun run lint # Biome check
38
38
  bun run typecheck # tsc --noEmit
39
39
  ```
40
40
 
41
+ ### Previewing the UI
42
+
43
+ Use the bundled example PR fixture to render either page without a real review. Useful for design iteration.
44
+
45
+ ```
46
+ magpie preview # render both pages, open findings.html
47
+ magpie preview --page progress --stage fresh # progress page at "everything pending"
48
+ magpie preview --page progress --stage specialists-running
49
+ magpie preview --page progress --stage peer-review-error
50
+ magpie preview --no-open --out /tmp/magpie-ui # write files only
51
+ magpie preview --list-stages # see all stage presets
52
+ ```
53
+
54
+ The fixture lives at `fixtures/example-pr/` (pr.json + findings.final.json + post-status.json) and covers all four severities, all five focus domains, every section combination (with/without suggestion code block, with/without verification, raw-prose fallback), and a mix of posted / failed / fresh badges.
55
+
41
56
  ## Layout
42
57
 
43
58
  - `SKILL.md` is the agent-facing prompt; installed by the agent-skills CLI.
@@ -15,11 +15,32 @@ Subcommands:
15
15
  status <run-dir> Print highest completed stage
16
16
  open [id] Open findings.html in your browser (defaults to latest run)
17
17
  post <run-dir> --ids a,b Post the given finding ids via gh (rich body + optional summary)
18
+ preview [opts] Render the UI from a bundled fixture (no PR needed). See --help-preview.
18
19
  --list-runs List archived runs
19
20
  --cleanup-run <id> Delete an archived run
20
21
  --version Print the magpie version
21
22
  --help Show this message`
22
23
 
24
+ const USAGE_PREVIEW = `Usage: magpie preview [options]
25
+
26
+ Render the findings and/or progress pages from a bundled example PR fixture so
27
+ you can iterate on the UI without running a real review.
28
+
29
+ Options:
30
+ --page <findings|progress|both> Which page to render (default: both)
31
+ --stage <preset> Pipeline state preset for the progress page
32
+ (default: report-done)
33
+ --fixture <dir> Override the fixture directory
34
+ --out <dir> Where to write the HTML (default: ~/.magpie/preview-<ts>)
35
+ --no-open Don't open the page in your browser
36
+ --dry-run Print the paths that would be written, write nothing
37
+ --list-stages List the known --stage presets and exit
38
+ --help Show this message
39
+
40
+ Stage presets: fresh, setup-running, setup-done, context-skipped,
41
+ specialists-running, specialists-done, dedupe-done, critic-done,
42
+ peer-review-error, report-done, post-done.`
43
+
23
44
  type Handler = (args: string[]) => Promise<number> | number
24
45
 
25
46
  const HANDLERS: Record<string, Handler> = {
@@ -178,6 +199,45 @@ const HANDLERS: Record<string, Handler> = {
178
199
  process.stdout.write(`${JSON.stringify(outcome)}\n`)
179
200
  return outcome.ok ? 0 : 1
180
201
  },
202
+ preview: async (args) => {
203
+ if (args.includes('--help') || args.includes('-h')) {
204
+ process.stdout.write(`${USAGE_PREVIEW}\n`)
205
+ return 0
206
+ }
207
+ const { KNOWN_STAGE_PRESETS, DEFAULT_STAGE, runPreview } = await import(
208
+ '../scripts/preview-cmd.ts'
209
+ )
210
+ if (args.includes('--list-stages')) {
211
+ for (const s of KNOWN_STAGE_PRESETS) process.stdout.write(`${s}\n`)
212
+ return 0
213
+ }
214
+ const pageFlag = args.indexOf('--page')
215
+ const pageRaw = pageFlag !== -1 ? args[pageFlag + 1] : 'both'
216
+ if (pageRaw !== 'findings' && pageRaw !== 'progress' && pageRaw !== 'both') {
217
+ process.stderr.write(`preview: invalid --page ${pageRaw} (want findings|progress|both)\n`)
218
+ return 2
219
+ }
220
+ const stageFlag = args.indexOf('--stage')
221
+ const stageRaw = stageFlag !== -1 ? args[stageFlag + 1] : DEFAULT_STAGE
222
+ if (!stageRaw || !KNOWN_STAGE_PRESETS.includes(stageRaw as never)) {
223
+ process.stderr.write(
224
+ `preview: invalid --stage ${stageRaw}. Use --list-stages to see available presets.\n`,
225
+ )
226
+ return 2
227
+ }
228
+ const fixtureFlag = args.indexOf('--fixture')
229
+ const outFlag = args.indexOf('--out')
230
+ const result = await runPreview({
231
+ page: pageRaw,
232
+ stage: stageRaw as never,
233
+ ...(fixtureFlag !== -1 && args[fixtureFlag + 1] ? { fixtureDir: args[fixtureFlag + 1] } : {}),
234
+ ...(outFlag !== -1 && args[outFlag + 1] ? { outDir: args[outFlag + 1] } : {}),
235
+ openInBrowser: !args.includes('--no-open') && !args.includes('--dry-run'),
236
+ dryRun: args.includes('--dry-run'),
237
+ })
238
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
239
+ return 0
240
+ },
181
241
  '--list-runs': async () => {
182
242
  const { listRuns } = await import('../scripts/housekeeping-cmd.ts')
183
243
  const runs = await listRuns()
@@ -221,5 +281,3 @@ async function main(argv: string[]): Promise<number> {
221
281
 
222
282
  const code = await main(process.argv.slice(2))
223
283
  process.exit(code)
224
-
225
- export {}
@@ -0,0 +1,86 @@
1
+ diff --git a/src/api/session.ts b/src/api/session.ts
2
+ index abc1234..def5678 100644
3
+ --- a/src/api/session.ts
4
+ +++ b/src/api/session.ts
5
+ @@ -80,20 +80,35 @@ function validateToken(token: string): boolean {
6
+ return token.length > 16
7
+ }
8
+
9
+ -export async function createSession(userId: string, token: string) {
10
+ - // Session key uses tenant id for isolation
11
+ - const key = `session:${userId}`
12
+ - await redis.set(key, token)
13
+ - return {
14
+ - userId,
15
+ - createdAt: new Date(),
16
+ - }
17
+ +const SESSION_TTL_SECONDS = 60 * 60 * 8 // 8h
18
+ +const sessionKey = (tenantId: string, userId: string) => `t:${tenantId}:session:${userId}`
19
+ +
20
+ +export async function createSession(tenantId: string, userId: string, token: string) {
21
+ + const key = sessionKey(tenantId, userId)
22
+ + await redis.set(key, token, 'EX', SESSION_TTL_SECONDS)
23
+ + const sessionToken = await redis.get(key)
24
+ + return {
25
+ + tenantId,
26
+ + userId,
27
+ + token: sessionToken,
28
+ + createdAt: new Date(),
29
+ + expiresAt: new Date(Date.now() + SESSION_TTL_SECONDS * 1000),
30
+ + }
31
+ }
32
+
33
+ diff --git a/src/cache/invalidation.ts b/src/cache/invalidation.ts
34
+ index 1111111..2222222 100644
35
+ --- a/src/cache/invalidation.ts
36
+ +++ b/src/cache/invalidation.ts
37
+ @@ -138,10 +138,8 @@ export async function getCacheVersion(): Promise<number> {
38
+ return parseInt(version, 10)
39
+ }
40
+
41
+ -export async function invalidate() {
42
+ - const version = await redis.get(versionKey)
43
+ - const newVersion = parseInt(version, 10) + 1
44
+ - await redis.set(versionKey, String(newVersion))
45
+ +export async function invalidate(): Promise<number> {
46
+ + const newVersion = await redis.incr(versionKey)
47
+ return newVersion
48
+ }
49
+
50
+ diff --git a/src/dashboard/recent-activity.tsx b/src/dashboard/recent-activity.tsx
51
+ index 3333333..4444444 100644
52
+ --- a/src/dashboard/recent-activity.tsx
53
+ +++ b/src/dashboard/recent-activity.tsx
54
+ @@ -55,10 +55,13 @@ export interface RecentActivityProps {
55
+ onEventSelect?: (event: Event) => void
56
+ }
57
+
58
+ -export function RecentActivity({ events, onEventSelect }: RecentActivityProps) {
59
+ - const timeline = events
60
+ - .map(toTimelineEntry)
61
+ - .sort(byRecency)
62
+ +import { useMemo } from 'react'
63
+ +
64
+ +export function RecentActivity({ events, onEventSelect }: RecentActivityProps) {
65
+ + const timeline = useMemo(
66
+ + () => events.map(toTimelineEntry).sort(byRecency),
67
+ + [events]
68
+ + )
69
+
70
+ return (
71
+ <div className="timeline-container">
72
+
73
+ diff --git a/src/services/auth/index.ts b/src/services/auth/index.ts
74
+ index 5555555..6666666 100644
75
+ --- a/src/services/auth/index.ts
76
+ +++ b/src/services/auth/index.ts
77
+ @@ -20,7 +20,8 @@ export class AuthService {
78
+ }
79
+
80
+ async signIn(email: string, password: string) {
81
+ - const user = await userDb.getUserById(email)
82
+ + const user = await this.userRepo.findById(email)
83
+ + const lastLoginAt = await this.userRepo.findLastLoginAt(email)
84
+ if (!user || !this.verifyPassword(password, user.passwordHash)) {
85
+ throw new Error('Invalid credentials')
86
+ }
@@ -0,0 +1,137 @@
1
+ [
2
+ {
3
+ "id": "security-1",
4
+ "file": "src/api/session.ts",
5
+ "line": 87,
6
+ "severity": "blocker",
7
+ "risk": {
8
+ "impact": "critical",
9
+ "likelihood": "likely",
10
+ "confidence": "high",
11
+ "action": "must-fix"
12
+ },
13
+ "title": "Session token written to Redis with no TTL and no namespace, every tenant shares the same keyspace",
14
+ "description": "Observation: createSession() (src/api/session.ts:87) writes `session:${userId}` straight into the shared Redis instance with redis.set(key, token) and no SET EX argument. The same connection pool is shared with the marketing job runner introduced in #1102.\n\nWhy it matters: sessions never expire so a stolen token is valid forever, and the marketing runner can read every active session via SCAN. Two tenants on the same cluster will overwrite each other's sessions because user ids are not globally unique across tenants in this codebase (verified in `getUserById`).\n\nSuggested direction: namespace the key with tenant id (`t:${tenantId}:session:${userId}`), use SET ... EX <ttl>, and move sessions to a dedicated logical DB or a separate Redis with auth. Add an integration test that two tenants with the same numeric user id do not collide.\n\nNeeds verification: confirm whether the marketing job runner has SCAN access; if so, this is exploitable today.",
15
+ "suggestion": {
16
+ "body": "// src/api/session.ts\nconst SESSION_TTL_SECONDS = 60 * 60 * 8 // 8h\nconst sessionKey = (tenantId: string, userId: string) => `t:${tenantId}:session:${userId}`\n\nexport async function createSession(tenantId: string, userId: string, token: string) {\n await redis.set(sessionKey(tenantId, userId), token, 'EX', SESSION_TTL_SECONDS)\n}",
17
+ "startLine": 80,
18
+ "endLine": 95
19
+ },
20
+ "domain": "security"
21
+ },
22
+ {
23
+ "id": "bugs-1",
24
+ "file": "src/cache/invalidation.ts",
25
+ "line": 142,
26
+ "severity": "high",
27
+ "risk": {
28
+ "impact": "high",
29
+ "likelihood": "likely",
30
+ "confidence": "high",
31
+ "action": "must-fix"
32
+ },
33
+ "title": "invalidate() reads the cache version then writes it back without atomicity, two concurrent invalidations drop the second one",
34
+ "description": "Observation: invalidate() reads cache.version (src/cache/invalidation.ts:142), increments it locally, then writes it back with cache.setVersion(v + 1). Two concurrent invalidations triggered by webhooks both read v=4, both write v=5, and the second invalidation is silently lost.\n\nWhy it matters: stale entries continue serving for the next read window (up to 30s), which the team has previously flagged as the root cause of two billing incidents this quarter. The pattern is easy to miss because it only fails under burst load.\n\nSuggested direction: replace the read-then-write with redis.incr on the version key (atomic at the Redis level), or wrap the read/write in WATCH/MULTI. Add a load test that fires 50 parallel invalidations and asserts the final version is exactly initial + 50.",
35
+ "suggestion": {
36
+ "body": "// src/cache/invalidation.ts\nconst newVersion = await redis.incr(versionKey)\nreturn newVersion",
37
+ "startLine": 140,
38
+ "endLine": 150
39
+ },
40
+ "domain": "bugs"
41
+ },
42
+ {
43
+ "id": "perf-1",
44
+ "file": "src/dashboard/recent-activity.tsx",
45
+ "line": 58,
46
+ "severity": "high",
47
+ "risk": {
48
+ "impact": "high",
49
+ "likelihood": "possible",
50
+ "confidence": "medium",
51
+ "action": "should-fix"
52
+ },
53
+ "title": "RecentActivity rebuilds the entire timeline on every keystroke in the unrelated search box",
54
+ "description": "Observation: RecentActivity (src/dashboard/recent-activity.tsx:58) recomputes `timeline = events.map(toTimelineEntry).sort(byRecency)` inline in the render, with `events` coming from the parent Dashboard component whose state includes the search box value.\n\nWhy it matters: with the production median of ~3200 events per workspace, every keystroke in the search box rebuilds the whole timeline and re-renders 200+ TimelineRow components. Lighthouse traces show 380ms of scripting per keystroke on a mid-tier laptop.\n\nSuggested direction: move the search input into its own component, memoize `timeline` with useMemo keyed on events, or hoist the timeline into a selector. The fix is small but the impact is felt on every dashboard interaction.",
55
+ "domain": "performance"
56
+ },
57
+ {
58
+ "id": "code-smells-1",
59
+ "file": "src/lib/parser.ts",
60
+ "line": 156,
61
+ "severity": "medium",
62
+ "risk": {
63
+ "impact": "medium",
64
+ "likelihood": "possible",
65
+ "confidence": "high",
66
+ "action": "should-fix"
67
+ },
68
+ "title": "Three near-identical parseX functions diverge in their error handling for the same edge case",
69
+ "description": "Observation: parseHeader (src/lib/parser.ts:156), parseFooter (line 198), and parseBody (line 240) all handle the empty-line case differently: parseHeader returns null, parseFooter throws, parseBody returns an empty object. All three are called from the same dispatch loop in normalize().\n\nWhy it matters: the dispatch loop swallows the parseFooter throw inside a try/catch added in this PR (line 88), so currently parseFooter silently becomes \"no result\". Future authors adding a fourth parser will have no obvious convention to follow and the loop's catch will hide whatever they pick.\n\nSuggested direction: extract a shared parseSection(label, lines) helper that returns Result<Section, ParseError>, replace the three functions with thin wrappers, and remove the swallowing catch from normalize(). This is one of those refactors that gets harder every release.",
70
+ "domain": "code-smells"
71
+ },
72
+ {
73
+ "id": "arch-1",
74
+ "file": "src/services/auth/index.ts",
75
+ "line": 24,
76
+ "severity": "medium",
77
+ "risk": {
78
+ "impact": "medium",
79
+ "likelihood": "likely",
80
+ "confidence": "high",
81
+ "action": "should-fix"
82
+ },
83
+ "title": "AuthService now imports from src/db/users directly, breaking the layered architecture set up last quarter",
84
+ "description": "Observation: AuthService.signIn (src/services/auth/index.ts:24) now imports `userRepo` from `src/db/users` directly to read the last-login column. Every other service goes through the `UserRepository` interface in `src/domain/user.ts` so that the storage layer can be swapped during tests.\n\nWhy it matters: this is the first service to skip the interface. Once one does it the others will follow within a few sprints, and the layering set up to make in-memory tests cheap erodes. Tests that depend on real Postgres are 20x slower than the in-memory ones.\n\nSuggested direction: route the last-login read through UserRepository.findById and add the column to the User domain type, or add a UserRepository.findLastLoginAt method to keep the projection narrow. Keep the storage import inside `src/db/`.",
85
+ "suggestion": {
86
+ "body": "// src/domain/user.ts\nexport interface UserRepository {\n findById(id: UserId): Promise<User | null>\n findLastLoginAt(id: UserId): Promise<Date | null>\n}\n\n// src/services/auth/index.ts\nconst lastLoginAt = await this.userRepo.findLastLoginAt(userId)",
87
+ "startLine": 20,
88
+ "endLine": 40
89
+ },
90
+ "domain": "architecture"
91
+ },
92
+ {
93
+ "id": "bugs-2",
94
+ "file": "src/utils/format-currency.ts",
95
+ "line": 8,
96
+ "severity": "medium",
97
+ "risk": {
98
+ "impact": "medium",
99
+ "likelihood": "edge-case",
100
+ "confidence": "medium",
101
+ "action": "should-fix"
102
+ },
103
+ "title": "formatCurrency uses toFixed(2) which loses precision on JPY, KRW, and other zero-decimal currencies",
104
+ "description": "formatCurrency in src/utils/format-currency.ts:8 hardcodes value.toFixed(2) and tacks on a currency symbol from a map. For JPY a payment of 1500 yen renders as ¥1500.00, which is technically wrong but harmless visually. For KRW the localization team flagged this as a translation bug in their style guide and asked us to use Intl.NumberFormat instead. The change is small and worth doing while the formatter is being touched anyway.",
105
+ "domain": "bugs"
106
+ },
107
+ {
108
+ "id": "code-smells-2",
109
+ "file": "src/components/Button.tsx",
110
+ "line": 12,
111
+ "severity": "low",
112
+ "risk": {
113
+ "impact": "low",
114
+ "likelihood": "possible",
115
+ "confidence": "high",
116
+ "action": "consider"
117
+ },
118
+ "title": "Button accepts a boolean `primary` and a boolean `danger` and a boolean `ghost`, three flags that should be one variant prop",
119
+ "description": "Observation: Button (src/components/Button.tsx:12) now accepts `primary`, `danger`, `ghost`, and the new `secondary` flag added in this PR. The render branches on them with a chain of ternaries and the four-flag combinatorics are not all valid (primary && danger renders as danger but consumers don't know).\n\nWhy it matters: the next variant added will make the ternary unreadable and the type system isn't preventing nonsense combinations. The fix is small now and gets worse with each addition.\n\nSuggested direction: collapse to `variant: 'primary' | 'danger' | 'ghost' | 'secondary'` (default 'primary'). Migrate callers in one sweep; the four flags only have ~40 call sites across the repo.",
120
+ "domain": "code-smells"
121
+ },
122
+ {
123
+ "id": "perf-2",
124
+ "file": "vendor/legacy/heavy-parser.js",
125
+ "line": 412,
126
+ "severity": "low",
127
+ "risk": {
128
+ "impact": "low",
129
+ "likelihood": "edge-case",
130
+ "confidence": "low",
131
+ "action": "optional"
132
+ },
133
+ "title": "Synchronous regex compilation in a hot loop, but only matters if the input crosses ~50k chars",
134
+ "description": "Observation: heavy-parser.js:412 compiles a regex inside the tokenize loop instead of hoisting it to module scope.\n\nWhy it matters: at the input sizes this repo actually parses today (<5k chars) the overhead is negligible (<1ms). It would matter if the parser were ever pointed at the new bulk-import payloads the team has been discussing.\n\nNeeds verification: confirm whether the bulk-import payloads actually flow through this parser; if not, this can be left alone.",
135
+ "domain": "performance"
136
+ }
137
+ ]
@@ -0,0 +1,5 @@
1
+ {
2
+ "security-1": "posted",
3
+ "arch-1": "posted",
4
+ "perf-1": { "status": "failed", "message": "HTTP 422: pull_request_review_thread: line out of diff" }
5
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "number": 1337,
3
+ "title": "Migrate session store to Redis and introduce TextOnlyPrompt abstraction",
4
+ "author": { "login": "asha-platform" },
5
+ "headRefName": "feat/session-redis-and-prompts",
6
+ "baseRefName": "main",
7
+ "headRefOid": "9f3a8c0211dbb5fe7a82a2c1b08e0a45c2d1ee01",
8
+ "url": "https://github.com/example/repo/pull/1337",
9
+ "files": [
10
+ { "path": "src/api/session.ts", "additions": 42, "deletions": 18 },
11
+ { "path": "src/cache/invalidation.ts", "additions": 28, "deletions": 12 },
12
+ { "path": "src/dashboard/recent-activity.tsx", "additions": 35, "deletions": 8 },
13
+ { "path": "src/lib/parser.ts", "additions": 64, "deletions": 45 },
14
+ { "path": "src/services/auth/index.ts", "additions": 31, "deletions": 15 },
15
+ { "path": "src/utils/format-currency.ts", "additions": 16, "deletions": 9 },
16
+ { "path": "src/components/Button.tsx", "additions": 22, "deletions": 11 },
17
+ { "path": "vendor/legacy/heavy-parser.js", "additions": 8, "deletions": 3 },
18
+ { "path": "README.md", "additions": 5, "deletions": 2 },
19
+ { "path": "package.json", "additions": 1, "deletions": 1 }
20
+ ]
21
+ }
@@ -10,7 +10,16 @@ case "$1 $2" in
10
10
  "headRefOid": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
11
11
  "baseRefOid": "cafebabecafebabecafebabecafebabecafebabe",
12
12
  "author": { "login": "octocat" },
13
- "body": "Fake body"
13
+ "body": "Fake body",
14
+ "url": "https://github.com/octocat/Hello-World/pull/1234",
15
+ "files": [
16
+ {
17
+ "path": "src/a.ts",
18
+ "additions": 1,
19
+ "deletions": 0,
20
+ "changeType": "modified"
21
+ }
22
+ ]
14
23
  }
15
24
  JSON
16
25
  ;;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -155,3 +155,122 @@ test('post rejects missing --ids', async () => {
155
155
  expect(exit).toBe(2)
156
156
  expect(stderr).toContain('post: missing --ids')
157
157
  })
158
+
159
+ test('preview surfaces its own --help block with the stage preset list', async () => {
160
+ const proc = Bun.spawn(['bun', CLI, 'preview', '--help'], { stdout: 'pipe' })
161
+ const stdout = await new Response(proc.stdout).text()
162
+ const exit = await proc.exited
163
+ expect(exit).toBe(0)
164
+ expect(stdout).toContain('Usage: magpie preview')
165
+ expect(stdout).toContain('--stage')
166
+ // Sanity-check that the preset list mentions both endpoints of the pipeline.
167
+ expect(stdout).toContain('fresh')
168
+ expect(stdout).toContain('post-done')
169
+ })
170
+
171
+ test('preview --list-stages prints every known preset, one per line', async () => {
172
+ const proc = Bun.spawn(['bun', CLI, 'preview', '--list-stages'], { stdout: 'pipe' })
173
+ const stdout = await new Response(proc.stdout).text()
174
+ const exit = await proc.exited
175
+ expect(exit).toBe(0)
176
+ const presets = stdout.trim().split('\n')
177
+ expect(presets).toContain('fresh')
178
+ expect(presets).toContain('specialists-running')
179
+ expect(presets).toContain('peer-review-error')
180
+ expect(presets).toContain('report-done')
181
+ expect(presets).toContain('post-done')
182
+ })
183
+
184
+ test('preview --dry-run --no-open writes nothing and prints the planned paths', async () => {
185
+ const out = await mkdtemp(join(tmpdir(), 'magpie-cli-preview-'))
186
+ try {
187
+ // mkdtemp creates the directory; remove it so we can confirm the dry-run
188
+ // truly writes nothing on its own. The handler should still report it
189
+ // would have written here.
190
+ await rm(out, { recursive: true, force: true })
191
+ const proc = Bun.spawn(
192
+ ['bun', CLI, 'preview', '--dry-run', '--no-open', '--out', out, '--stage', 'critic-done'],
193
+ { stdout: 'pipe', stderr: 'pipe' },
194
+ )
195
+ const stdout = await new Response(proc.stdout).text()
196
+ const exit = await proc.exited
197
+ expect(exit).toBe(0)
198
+ const result = JSON.parse(stdout.trim())
199
+ expect(result.dryRun).toBe(true)
200
+ expect(result.outDir).toBe(out)
201
+ expect(result.findingsHtml).toBe(join(out, 'findings.html'))
202
+ expect(result.progressHtml).toBe(join(out, 'progress.html'))
203
+ // Dry run must not create the directory or any files inside it.
204
+ const findingsFile = Bun.file(join(out, 'findings.html'))
205
+ expect(await findingsFile.exists()).toBe(false)
206
+ } finally {
207
+ await rm(out, { recursive: true, force: true })
208
+ }
209
+ })
210
+
211
+ test('preview --no-open renders the requested page to disk and returns its path', async () => {
212
+ const out = await mkdtemp(join(tmpdir(), 'magpie-cli-preview-render-'))
213
+ try {
214
+ const proc = Bun.spawn(
215
+ [
216
+ 'bun',
217
+ CLI,
218
+ 'preview',
219
+ '--no-open',
220
+ '--page',
221
+ 'progress',
222
+ '--stage',
223
+ 'specialists-running',
224
+ '--out',
225
+ out,
226
+ ],
227
+ { stdout: 'pipe', stderr: 'pipe' },
228
+ )
229
+ const stdout = await new Response(proc.stdout).text()
230
+ const exit = await proc.exited
231
+ expect(exit).toBe(0)
232
+ const result = JSON.parse(stdout.trim())
233
+ expect(result.progressHtml).toBe(join(out, 'progress.html'))
234
+ expect(result.findingsHtml).toBeUndefined()
235
+ const html = await Bun.file(join(out, 'progress.html')).text()
236
+ // The preset has setup + context done (2/7 segments filled) and
237
+ // specialists running.
238
+ expect(html).toContain('--done-count: 2')
239
+ expect(html).toContain('class="step running"')
240
+ expect(html).toContain('data-stage="specialists"')
241
+ } finally {
242
+ await rm(out, { recursive: true, force: true })
243
+ }
244
+ })
245
+
246
+ test('preview rejects an unknown --stage with exit 2 and a hint', async () => {
247
+ const proc = Bun.spawn(['bun', CLI, 'preview', '--no-open', '--stage', 'bogus'], {
248
+ stdout: 'pipe',
249
+ stderr: 'pipe',
250
+ })
251
+ const stderr = await new Response(proc.stderr).text()
252
+ const exit = await proc.exited
253
+ expect(exit).toBe(2)
254
+ expect(stderr).toContain('preview: invalid --stage bogus')
255
+ expect(stderr).toContain('--list-stages')
256
+ })
257
+
258
+ test('preview rejects an unknown --page with exit 2', async () => {
259
+ const proc = Bun.spawn(['bun', CLI, 'preview', '--no-open', '--page', 'sidebar'], {
260
+ stdout: 'pipe',
261
+ stderr: 'pipe',
262
+ })
263
+ const stderr = await new Response(proc.stderr).text()
264
+ const exit = await proc.exited
265
+ expect(exit).toBe(2)
266
+ expect(stderr).toContain('preview: invalid --page sidebar')
267
+ })
268
+
269
+ test('top-level --help surfaces the preview subcommand', async () => {
270
+ const proc = Bun.spawn(['bun', CLI, '--help'], { stdout: 'pipe' })
271
+ const stdout = await new Response(proc.stdout).text()
272
+ const exit = await proc.exited
273
+ expect(exit).toBe(0)
274
+ expect(stdout).toContain('preview')
275
+ expect(stdout).toContain('--help-preview')
276
+ })
@@ -0,0 +1,106 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { filePathMatches, parseUnifiedDiffToHunks, splitDiffByFile } from '../diff-utils.ts'
3
+
4
+ const SAMPLE = `diff --git a/src/a.ts b/src/a.ts
5
+ index abc..def 100644
6
+ --- a/src/a.ts
7
+ +++ b/src/a.ts
8
+ @@ -1,3 +1,4 @@
9
+ line1
10
+ -old
11
+ +new
12
+ +added
13
+ line3
14
+ diff --git a/src/b.ts b/src/b.ts
15
+ new file mode 100644
16
+ --- /dev/null
17
+ +++ b/src/b.ts
18
+ @@ -0,0 +1,2 @@
19
+ +first
20
+ +second
21
+ `
22
+
23
+ describe('parseUnifiedDiffToHunks', () => {
24
+ test('parses a single-file two-hunk diff', () => {
25
+ const single = SAMPLE.split('diff --git a/src/b.ts')[0] ?? ''
26
+ const hunks = parseUnifiedDiffToHunks(single)
27
+ expect(hunks).toHaveLength(1)
28
+ const hunk = hunks[0]
29
+ if (!hunk) throw new Error('expected one hunk')
30
+ expect(hunk.oldStart).toBe(1)
31
+ expect(hunk.newStart).toBe(1)
32
+ expect(hunk.lines).toHaveLength(5)
33
+ const types = hunk.lines.map((l) => l.type)
34
+ expect(types).toEqual(['context', 'removed', 'added', 'added', 'context'])
35
+ })
36
+
37
+ test('assigns new line numbers only to added and context lines', () => {
38
+ const single = SAMPLE.split('diff --git a/src/b.ts')[0] ?? ''
39
+ const hunks = parseUnifiedDiffToHunks(single)
40
+ const hunk = hunks[0]
41
+ if (!hunk) throw new Error('expected one hunk')
42
+ const lines = hunk.lines
43
+ expect(lines[0]?.newLineNo).toBe(1)
44
+ expect(lines[1]?.newLineNo).toBeNull()
45
+ expect(lines[2]?.newLineNo).toBe(2)
46
+ expect(lines[3]?.newLineNo).toBe(3)
47
+ expect(lines[4]?.newLineNo).toBe(4)
48
+ })
49
+
50
+ test('assigns old line numbers only to removed and context lines', () => {
51
+ const single = SAMPLE.split('diff --git a/src/b.ts')[0] ?? ''
52
+ const hunks = parseUnifiedDiffToHunks(single)
53
+ const hunk = hunks[0]
54
+ if (!hunk) throw new Error('expected one hunk')
55
+ const lines = hunk.lines
56
+ expect(lines[0]?.oldLineNo).toBe(1)
57
+ expect(lines[1]?.oldLineNo).toBe(2)
58
+ expect(lines[2]?.oldLineNo).toBeNull()
59
+ expect(lines[3]?.oldLineNo).toBeNull()
60
+ expect(lines[4]?.oldLineNo).toBe(3)
61
+ })
62
+
63
+ test('returns empty array for empty input', () => {
64
+ expect(parseUnifiedDiffToHunks('')).toEqual([])
65
+ })
66
+
67
+ test('handles "no newline at end of file" marker by ignoring it', () => {
68
+ const diff = `--- a/x
69
+ +++ b/x
70
+ @@ -1 +1 @@
71
+ -old
72
+ \
73
+ +new
74
+ \
75
+ `
76
+ const hunks = parseUnifiedDiffToHunks(diff)
77
+ const hunk = hunks[0]
78
+ if (!hunk) throw new Error('expected one hunk')
79
+ expect(hunk.lines.map((l) => l.type)).toEqual(['removed', 'added'])
80
+ })
81
+ })
82
+
83
+ describe('splitDiffByFile', () => {
84
+ test('returns one entry per file using the "b/" path', () => {
85
+ const m = splitDiffByFile(SAMPLE)
86
+ expect(m.size).toBe(2)
87
+ expect(m.get('src/a.ts')).toContain('@@ -1,3 +1,4 @@')
88
+ expect(m.get('src/b.ts')).toContain('@@ -0,0 +1,2 @@')
89
+ })
90
+
91
+ test('returns empty map for empty input', () => {
92
+ expect(splitDiffByFile('').size).toBe(0)
93
+ })
94
+ })
95
+
96
+ describe('filePathMatches', () => {
97
+ test('exact match', () => {
98
+ expect(filePathMatches('src/a.ts', 'src/a.ts')).toBe(true)
99
+ })
100
+ test('tolerates "b/" prefix on one side', () => {
101
+ expect(filePathMatches('b/src/a.ts', 'src/a.ts')).toBe(true)
102
+ })
103
+ test('non-match returns false', () => {
104
+ expect(filePathMatches('src/a.ts', 'src/b.ts')).toBe(false)
105
+ })
106
+ })