@iceinvein/agent-skills 0.1.25 → 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 (42) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/skills/magpie/fixtures/example-pr/diff.patch +86 -0
  4. package/skills/magpie/fixtures/example-pr/pr.json +13 -1
  5. package/skills/magpie/fixtures/fake-gh.sh +10 -1
  6. package/skills/magpie/package.json +1 -1
  7. package/skills/magpie/scripts/__tests__/diff-utils.test.ts +106 -0
  8. package/skills/magpie/scripts/__tests__/file-tree.test.ts +97 -0
  9. package/skills/magpie/scripts/__tests__/gh.test.ts +8 -0
  10. package/skills/magpie/scripts/__tests__/helper.test.ts +63 -0
  11. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +10 -0
  12. package/skills/magpie/scripts/__tests__/post-cmd.test.ts +90 -1
  13. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +3 -3
  14. package/skills/magpie/scripts/__tests__/refresh.test.ts +2 -2
  15. package/skills/magpie/scripts/__tests__/render-action-bar.test.ts +66 -0
  16. package/skills/magpie/scripts/__tests__/render-annotation.test.ts +67 -0
  17. package/skills/magpie/scripts/__tests__/render-cmd.test.ts +46 -0
  18. package/skills/magpie/scripts/__tests__/render-diff.test.ts +84 -0
  19. package/skills/magpie/scripts/__tests__/render-file-tree.test.ts +54 -0
  20. package/skills/magpie/scripts/__tests__/render-findings.test.ts +104 -229
  21. package/skills/magpie/scripts/__tests__/render-issues-list.test.ts +67 -0
  22. package/skills/magpie/scripts/__tests__/render-progress.test.ts +43 -0
  23. package/skills/magpie/scripts/__tests__/server.test.ts +54 -0
  24. package/skills/magpie/scripts/__tests__/types.test.ts +48 -3
  25. package/skills/magpie/scripts/diff-utils.ts +129 -0
  26. package/skills/magpie/scripts/file-tree.ts +74 -0
  27. package/skills/magpie/scripts/gh.ts +1 -0
  28. package/skills/magpie/scripts/helper.js +402 -192
  29. package/skills/magpie/scripts/post-cmd.ts +202 -0
  30. package/skills/magpie/scripts/preview-cmd.ts +24 -1
  31. package/skills/magpie/scripts/render-action-bar.ts +51 -0
  32. package/skills/magpie/scripts/render-annotation.ts +96 -0
  33. package/skills/magpie/scripts/render-cmd.ts +14 -1
  34. package/skills/magpie/scripts/render-diff.ts +125 -0
  35. package/skills/magpie/scripts/render-file-tree.ts +86 -0
  36. package/skills/magpie/scripts/render-findings.ts +151 -341
  37. package/skills/magpie/scripts/render-issues-list.ts +78 -0
  38. package/skills/magpie/scripts/render-progress.ts +44 -104
  39. package/skills/magpie/scripts/server.ts +43 -0
  40. package/skills/magpie/scripts/types.ts +13 -0
  41. package/skills/magpie/skill.json +1 -1
  42. package/skills/magpie/templates/styles.css +1082 -965
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.25",
3
+ "version": "0.1.26",
4
4
  "description": "Install agent skills into AI coding tools",
5
5
  "author": "iceinvein",
6
6
  "license": "MIT",
@@ -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
+ }
@@ -5,5 +5,17 @@
5
5
  "headRefName": "feat/session-redis-and-prompts",
6
6
  "baseRefName": "main",
7
7
  "headRefOid": "9f3a8c0211dbb5fe7a82a2c1b08e0a45c2d1ee01",
8
- "url": "https://github.com/example/repo/pull/1337"
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
+ ]
9
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": {
@@ -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
+ })
@@ -0,0 +1,97 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ buildTree,
4
+ collapseTree,
5
+ countDirFindings,
6
+ type DirNode,
7
+ findingCountsBySeverity,
8
+ } from '../file-tree.ts'
9
+ import type { PrFileEntry, ReviewFinding } from '../types.ts'
10
+
11
+ const files: PrFileEntry[] = [
12
+ { path: 'src/a.ts', additions: 5, deletions: 1 },
13
+ { path: 'src/b.ts', additions: 2, deletions: 0 },
14
+ { path: 'src/deep/only.ts', additions: 1, deletions: 0 },
15
+ { path: 'README.md', additions: 3, deletions: 0 },
16
+ ]
17
+
18
+ const findings: ReviewFinding[] = [
19
+ {
20
+ id: '1',
21
+ file: 'src/a.ts',
22
+ line: 1,
23
+ severity: 'blocker',
24
+ title: 't',
25
+ description: 'd',
26
+ risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
27
+ domain: 'security',
28
+ },
29
+ {
30
+ id: '2',
31
+ file: 'src/a.ts',
32
+ line: 2,
33
+ severity: 'high',
34
+ title: 't',
35
+ description: 'd',
36
+ risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'should-fix' },
37
+ domain: 'bugs',
38
+ },
39
+ {
40
+ id: '3',
41
+ file: 'src/deep/only.ts',
42
+ line: 1,
43
+ severity: 'medium',
44
+ title: 't',
45
+ description: 'd',
46
+ risk: { impact: 'medium', likelihood: 'possible', confidence: 'medium', action: 'consider' },
47
+ domain: 'bugs',
48
+ },
49
+ ] as ReviewFinding[]
50
+
51
+ describe('buildTree', () => {
52
+ test('groups files by directory', () => {
53
+ const tree = buildTree(files)
54
+ expect(tree.dirs.has('src')).toBe(true)
55
+ expect(tree.files.find((f) => f.path === 'README.md')).toBeDefined()
56
+ })
57
+
58
+ test('nests deep directories', () => {
59
+ const tree = buildTree(files)
60
+ const src = tree.dirs.get('src')
61
+ if (!src) throw new Error('expected src dir')
62
+ expect(src.dirs.has('deep')).toBe(true)
63
+ expect(src.dirs.get('deep')?.files.map((f) => f.path)).toEqual(['src/deep/only.ts'])
64
+ })
65
+ })
66
+
67
+ describe('collapseTree', () => {
68
+ test('collapses nested single-child dirs like apps/server/lib', () => {
69
+ const f: PrFileEntry[] = [{ path: 'apps/server/lib/x.ts', additions: 1, deletions: 0 }]
70
+ const tree = collapseTree(buildTree(f))
71
+ const first = [...tree.dirs.values()][0]
72
+ if (!first) throw new Error('expected one dir')
73
+ expect(first.name).toBe('apps/server/lib')
74
+ })
75
+ })
76
+
77
+ describe('findingCountsBySeverity', () => {
78
+ test('returns severity counts for a file in canonical order', () => {
79
+ const counts = findingCountsBySeverity(findings, 'src/a.ts')
80
+ expect(counts).toEqual([
81
+ { severity: 'blocker', count: 1 },
82
+ { severity: 'high', count: 1 },
83
+ ])
84
+ })
85
+
86
+ test('returns empty array when the file has no findings', () => {
87
+ expect(findingCountsBySeverity(findings, 'README.md')).toEqual([])
88
+ })
89
+ })
90
+
91
+ describe('countDirFindings', () => {
92
+ test('sums findings across all files in a subtree', () => {
93
+ const tree = collapseTree(buildTree(files))
94
+ const src = tree.dirs.get('src') as DirNode | undefined
95
+ if (src) expect(countDirFindings(findings, src)).toBe(3)
96
+ })
97
+ })
@@ -26,6 +26,14 @@ test('fetchPr writes pr.json and diff.patch', async () => {
26
26
  expect(diff).toContain('export const x = 2')
27
27
  })
28
28
 
29
+ test('fetchPr includes files in pr.json', async () => {
30
+ const result = await fetchPr({ ghBin: FAKE_GH, prNumber: 1234, runDir })
31
+ expect(result.ok).toBe(true)
32
+ const pr = JSON.parse(await readFile(join(runDir, 'pr.json'), 'utf8'))
33
+ expect(Array.isArray(pr.files)).toBe(true)
34
+ expect(pr.files[0]).toMatchObject({ path: 'src/a.ts', additions: 1, deletions: 0 })
35
+ })
36
+
29
37
  test('fetchPr returns ok=false when gh exits non-zero', async () => {
30
38
  const result = await fetchPr({
31
39
  ghBin: '/usr/bin/false',
@@ -57,3 +57,66 @@ test('helper.js sends periodic heartbeats', async () => {
57
57
  expect(src).toContain("fetch('/heartbeat'")
58
58
  expect(src).toMatch(/setInterval/)
59
59
  })
60
+
61
+ test('helper.js dispatches set-view and toggles body.dataset.view', async () => {
62
+ const src = await readFile(HELPER, 'utf8')
63
+ expect(src).toContain('handleSetView')
64
+ expect(src).toMatch(/document\.body\.dataset\.view\s*=\s*view/)
65
+ })
66
+
67
+ test('helper.js handles select-file by toggling [data-file-pane] hidden', async () => {
68
+ const src = await readFile(HELPER, 'utf8')
69
+ expect(src).toContain('handleSelectFile')
70
+ expect(src).toContain('data-file-pane')
71
+ })
72
+
73
+ test('helper.js handles set-diff-mode by toggling .diff-unified / .diff-split', async () => {
74
+ const src = await readFile(HELPER, 'utf8')
75
+ expect(src).toContain('handleSetDiffMode')
76
+ expect(src).toContain('diff-unified')
77
+ expect(src).toContain('diff-split')
78
+ })
79
+
80
+ test('helper.js implements per-file finding navigation', async () => {
81
+ const src = await readFile(HELPER, 'utf8')
82
+ expect(src).toContain('navigateFinding')
83
+ expect(src).toContain('fileFindingIdx')
84
+ expect(src).toContain('is-focused')
85
+ })
86
+
87
+ test('helper.js toggles suggestions visibility via body.dataset.showSuggestions', async () => {
88
+ const src = await readFile(HELPER, 'utf8')
89
+ expect(src).toContain('handleToggleSuggestions')
90
+ expect(src).toContain('showSuggestions')
91
+ })
92
+
93
+ test('helper.js toggles severity filter via body.hide-sev-* classes', async () => {
94
+ const src = await readFile(HELPER, 'utf8')
95
+ expect(src).toContain('handleFilterSev')
96
+ expect(src).toContain('hide-sev-')
97
+ })
98
+
99
+ test('helper.js wires select-sev and select-recommended', async () => {
100
+ const src = await readFile(HELPER, 'utf8')
101
+ expect(src).toContain('handleSelectSev')
102
+ expect(src).toContain('handleSelectRecommended')
103
+ })
104
+
105
+ test('helper.js updates selected-count via [data-role="selected-count"]', async () => {
106
+ const src = await readFile(HELPER, 'utf8')
107
+ expect(src).toContain('updateSelectedCount')
108
+ expect(src).toContain('selected-count')
109
+ })
110
+
111
+ test('helper.js POSTs to /api/post-review when Post Selected is wired', async () => {
112
+ const src = await readFile(HELPER, 'utf8')
113
+ expect(src).toContain('/api/post-review')
114
+ expect(src).toContain('handlePostSelected')
115
+ expect(src).toContain('handlePostRecommended')
116
+ })
117
+
118
+ test('helper.js applies review result to data-posted on matching annotations', async () => {
119
+ const src = await readFile(HELPER, 'utf8')
120
+ expect(src).toContain('applyReviewResult')
121
+ expect(src).toContain("setAttribute('data-posted'")
122
+ })
@@ -77,6 +77,16 @@ test('setup -> dedupe -> render(progress) -> render(findings) -> cleanup compose
77
77
  expect(screen).toContain('progress.html')
78
78
  expect(screen).toContain('findings.html')
79
79
 
80
+ // Verify Pylon-style DOM markers in findings.html
81
+ const findingsHtml = await Bun.file(join(runDir, 'screen', 'findings.html')).text()
82
+ expect(findingsHtml.includes('class="pr-header"')).toBe(true)
83
+ expect(findingsHtml.includes('data-finding-id')).toBe(true)
84
+ expect(findingsHtml.includes('data-file-pane')).toBe(true)
85
+ expect(findingsHtml.includes('data-role="action-bar"')).toBe(true)
86
+ expect(findingsHtml.includes('data-action="post-recommended"')).toBe(true)
87
+ expect(findingsHtml.includes('class="annot-body')).toBe(true)
88
+ expect(findingsHtml.includes('class="issue-card')).toBe(true)
89
+
80
90
  expect(await runCleanup({ runDir, repoPath: repo, gitBin: 'git' })).toBe(0)
81
91
  const parent = dirname(runDir)
82
92
  const entries = await readdir(parent)
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, expect, test } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
2
  import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
@@ -8,6 +8,7 @@ import {
8
8
  formatPostBody,
9
9
  formatReviewSummaryBody,
10
10
  parseRepoFromUrl,
11
+ postFindingsAsReview,
11
12
  runPost,
12
13
  } from '../post-cmd.ts'
13
14
 
@@ -331,3 +332,91 @@ test('runPost appends post stage events to log.jsonl', async () => {
331
332
  expect(log).toContain('"status":"start"')
332
333
  expect(log).toContain('"status":"dry-run"')
333
334
  })
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // postFindingsAsReview
338
+ // ---------------------------------------------------------------------------
339
+
340
+ async function scaffoldRunDir(findings: unknown[]): Promise<string> {
341
+ const dir = await mkdtemp(join(tmpdir(), 'magpie-post-review-'))
342
+ await writeFile(join(dir, 'findings.json'), JSON.stringify(findings))
343
+ return dir
344
+ }
345
+
346
+ const inlineFinding = (id: string, line: number) => ({
347
+ id,
348
+ file: 'src/a.ts',
349
+ line,
350
+ severity: 'high',
351
+ title: `Finding ${id}`,
352
+ description: 'd',
353
+ risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
354
+ domain: 'security',
355
+ })
356
+
357
+ describe('postFindingsAsReview', () => {
358
+ test('builds a payload with N inline comments under dryRun', async () => {
359
+ const dir = await scaffoldRunDir([
360
+ inlineFinding('1', 10),
361
+ inlineFinding('2', 20),
362
+ inlineFinding('3', 30),
363
+ ])
364
+ const r = await postFindingsAsReview({
365
+ runDir: dir,
366
+ findingIds: ['1', '2', '3'],
367
+ prNumber: 42,
368
+ headSha: 'abc123',
369
+ dryRun: true,
370
+ })
371
+ expect(r.command?.[0]).toBe('gh')
372
+ expect(r.command?.[2]).toBe('repos/{owner}/{repo}/pulls/42/reviews')
373
+ expect(r.payload).toBeDefined()
374
+ const parsed = JSON.parse(r.payload as string) as {
375
+ comments: Array<{ path: string; line: number; side: string }>
376
+ }
377
+ expect(parsed.comments).toHaveLength(3)
378
+ expect(parsed.comments[0]).toMatchObject({ path: 'src/a.ts', line: 10, side: 'RIGHT' })
379
+ expect(parsed.comments[1]).toMatchObject({ path: 'src/a.ts', line: 20, side: 'RIGHT' })
380
+ expect(parsed.comments[2]).toMatchObject({ path: 'src/a.ts', line: 30, side: 'RIGHT' })
381
+ await rm(dir, { recursive: true, force: true })
382
+ })
383
+
384
+ test('routes line-less findings into the review body', async () => {
385
+ const dir = await scaffoldRunDir([
386
+ {
387
+ id: '1',
388
+ file: null,
389
+ line: null,
390
+ severity: 'high',
391
+ title: 'general note',
392
+ description: 'd',
393
+ risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
394
+ domain: 'architecture',
395
+ },
396
+ ])
397
+ const r = await postFindingsAsReview({
398
+ runDir: dir,
399
+ findingIds: ['1'],
400
+ prNumber: 42,
401
+ headSha: 'abc123',
402
+ dryRun: true,
403
+ })
404
+ const parsed = JSON.parse(r.payload as string) as { body: string; comments: unknown[] }
405
+ expect(parsed.comments).toHaveLength(0)
406
+ expect(parsed.body).toContain('general note')
407
+ await rm(dir, { recursive: true, force: true })
408
+ })
409
+
410
+ test('returns comments for unknown ids under dryRun', async () => {
411
+ const dir = await scaffoldRunDir([])
412
+ const r = await postFindingsAsReview({
413
+ runDir: dir,
414
+ findingIds: ['missing'],
415
+ prNumber: 42,
416
+ headSha: 'abc123',
417
+ dryRun: true,
418
+ })
419
+ expect(r.comments.find((c) => c.id === 'missing')).toBeDefined()
420
+ await rm(dir, { recursive: true, force: true })
421
+ })
422
+ })
@@ -190,8 +190,8 @@ test('mixed post-status in the fixture surfaces both posted and failed badges',
190
190
  openInBrowser: false,
191
191
  })
192
192
  const html = await readFile(join(workDir, 'findings.html'), 'utf8')
193
- expect(html).toContain('class="badge posted"')
194
- expect(html).toContain('class="badge failed"')
193
+ expect(html).toContain('class="status-chip posted"')
194
+ expect(html).toContain('class="status-chip failed"')
195
195
  // The fixture's failed entry includes a 422 message; make sure it survives.
196
196
  expect(html).toContain('422')
197
197
  })
@@ -205,6 +205,6 @@ test('bundled fixture covers all five focus domains', async () => {
205
205
  })
206
206
  const html = await readFile(join(workDir, 'findings.html'), 'utf8')
207
207
  for (const domain of ['security', 'bugs', 'performance', 'code-smells', 'architecture']) {
208
- expect(html).toContain(`data-filter-value="${domain}"`)
208
+ expect(html).toContain(`data-domain="${domain}"`)
209
209
  }
210
210
  })
@@ -60,8 +60,8 @@ test('refreshFindings writes findings.html and prunes any older findings*.html',
60
60
  const fresh = await readFile(join(runDir, 'screen', 'findings.html'), 'utf8')
61
61
  // The new file is rendered, not the stale OLD content.
62
62
  expect(fresh).not.toContain('OLD')
63
- // Contains the new interactivity surface (filter bar, data-run-id).
64
- expect(fresh).toContain('class="filter-bar"')
63
+ // Contains the new interactivity surface (segmented tabs, data-run-id).
64
+ expect(fresh).toContain('data-action="set-view"')
65
65
  expect(fresh).toMatch(/data-run-id="[^"]+"/)
66
66
  // And the rendered finding itself.
67
67
  expect(fresh).toContain('tsst')
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { renderActionBar } from '../render-action-bar.ts'
3
+ import type { ReviewFinding } from '../types.ts'
4
+
5
+ const findings: ReviewFinding[] = [
6
+ {
7
+ id: '1',
8
+ file: 'a.ts',
9
+ line: 1,
10
+ severity: 'blocker',
11
+ title: 'A',
12
+ description: 'd',
13
+ risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
14
+ domain: 'security',
15
+ },
16
+ {
17
+ id: '2',
18
+ file: 'b.ts',
19
+ line: 1,
20
+ severity: 'high',
21
+ title: 'B',
22
+ description: 'd',
23
+ risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'should-fix' },
24
+ domain: 'bugs',
25
+ },
26
+ {
27
+ id: '3',
28
+ file: 'c.ts',
29
+ line: 1,
30
+ severity: 'low',
31
+ title: 'C',
32
+ description: 'd',
33
+ risk: { impact: 'low', likelihood: 'unknown', confidence: 'medium', action: 'optional' },
34
+ domain: 'code-smells',
35
+ },
36
+ ] as ReviewFinding[]
37
+
38
+ describe('renderActionBar', () => {
39
+ test('Post Recommended count excludes suggestions', () => {
40
+ const html = renderActionBar({ findings })
41
+ expect(html).toContain('Post Recommended (2)')
42
+ })
43
+
44
+ test('initial Post Selected reads 0', () => {
45
+ const html = renderActionBar({ findings })
46
+ expect(html).toContain('Post Selected (')
47
+ expect(html).toMatch(/data-role="selected-count">0</)
48
+ })
49
+
50
+ test('renders excluded suggestions hint when suggestions present', () => {
51
+ const html = renderActionBar({ findings })
52
+ expect(html).toContain('1 suggestion excluded')
53
+ })
54
+
55
+ test('renders severity selection pills with counts', () => {
56
+ const html = renderActionBar({ findings })
57
+ expect(html).toContain('data-action="select-sev"')
58
+ expect(html).toContain('data-sev="blocker"')
59
+ expect(html).toContain('data-sev="high"')
60
+ })
61
+
62
+ test('renders Select recommended link', () => {
63
+ const html = renderActionBar({ findings })
64
+ expect(html).toContain('data-action="select-recommended"')
65
+ })
66
+ })