@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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/skills/magpie/fixtures/example-pr/diff.patch +86 -0
- package/skills/magpie/fixtures/example-pr/pr.json +13 -1
- package/skills/magpie/fixtures/fake-gh.sh +10 -1
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/scripts/__tests__/diff-utils.test.ts +106 -0
- package/skills/magpie/scripts/__tests__/file-tree.test.ts +97 -0
- package/skills/magpie/scripts/__tests__/gh.test.ts +8 -0
- package/skills/magpie/scripts/__tests__/helper.test.ts +63 -0
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +10 -0
- package/skills/magpie/scripts/__tests__/post-cmd.test.ts +90 -1
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +3 -3
- package/skills/magpie/scripts/__tests__/refresh.test.ts +2 -2
- package/skills/magpie/scripts/__tests__/render-action-bar.test.ts +66 -0
- package/skills/magpie/scripts/__tests__/render-annotation.test.ts +67 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +46 -0
- package/skills/magpie/scripts/__tests__/render-diff.test.ts +84 -0
- package/skills/magpie/scripts/__tests__/render-file-tree.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +104 -229
- package/skills/magpie/scripts/__tests__/render-issues-list.test.ts +67 -0
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +43 -0
- package/skills/magpie/scripts/__tests__/server.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/types.test.ts +48 -3
- package/skills/magpie/scripts/diff-utils.ts +129 -0
- package/skills/magpie/scripts/file-tree.ts +74 -0
- package/skills/magpie/scripts/gh.ts +1 -0
- package/skills/magpie/scripts/helper.js +402 -192
- package/skills/magpie/scripts/post-cmd.ts +202 -0
- package/skills/magpie/scripts/preview-cmd.ts +24 -1
- package/skills/magpie/scripts/render-action-bar.ts +51 -0
- package/skills/magpie/scripts/render-annotation.ts +96 -0
- package/skills/magpie/scripts/render-cmd.ts +14 -1
- package/skills/magpie/scripts/render-diff.ts +125 -0
- package/skills/magpie/scripts/render-file-tree.ts +86 -0
- package/skills/magpie/scripts/render-findings.ts +151 -341
- package/skills/magpie/scripts/render-issues-list.ts +78 -0
- package/skills/magpie/scripts/render-progress.ts +44 -104
- package/skills/magpie/scripts/server.ts +43 -0
- package/skills/magpie/scripts/types.ts +13 -0
- package/skills/magpie/skill.json +1 -1
- 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
|
@@ -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
|
;;
|
|
@@ -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="
|
|
194
|
-
expect(html).toContain('class="
|
|
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-
|
|
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 (
|
|
64
|
-
expect(fresh).toContain('
|
|
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
|
+
})
|