@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
@@ -52,6 +52,49 @@ test('renderProgressHtml renders empty specialists-line before any focus reports
52
52
  expect(html).toContain('awaiting specialist output')
53
53
  })
54
54
 
55
+ test('pipeline carries a --done-count style for the filling connector line', () => {
56
+ const html = renderProgressHtml({
57
+ prNumber: 1,
58
+ headSha: 'abcd1234',
59
+ branch: 'main',
60
+ stages: {
61
+ setup: 'done',
62
+ context: 'done',
63
+ specialists: 'done',
64
+ dedupe: 'running',
65
+ critic: 'pending',
66
+ 'peer-review': 'pending',
67
+ report: 'pending',
68
+ post: 'pending',
69
+ },
70
+ specialistCounts: {},
71
+ })
72
+ // CSS uses --done-count / 7 to compute the connector fill width.
73
+ expect(html).toContain('--done-count: 3')
74
+ })
75
+
76
+ test('each pipeline stage exposes a short sublabel so first-timers can learn what it does', () => {
77
+ const html = renderProgressHtml({
78
+ prNumber: 1,
79
+ headSha: 'abcd1234',
80
+ branch: 'main',
81
+ stages: {
82
+ setup: 'done',
83
+ context: 'pending',
84
+ specialists: 'pending',
85
+ dedupe: 'pending',
86
+ critic: 'pending',
87
+ 'peer-review': 'pending',
88
+ report: 'pending',
89
+ post: 'pending',
90
+ },
91
+ specialistCounts: {},
92
+ })
93
+ expect(html).toContain('class="hint"')
94
+ expect(html).toContain('five reviewers in parallel')
95
+ expect(html).toContain('second opinion via codex')
96
+ })
97
+
55
98
  test('renderProgressHtml includes the archived banner element', () => {
56
99
  const html = renderProgressHtml({
57
100
  prNumber: 1,
@@ -71,3 +114,46 @@ test('renderProgressHtml includes the archived banner element', () => {
71
114
  })
72
115
  expect(html).toContain('archived-banner')
73
116
  })
117
+
118
+ test('emits the shared pr-header chrome', () => {
119
+ const html = renderProgressHtml({
120
+ prNumber: 1,
121
+ headSha: 'abc123def456789012345678',
122
+ branch: 'feat',
123
+ stages: {
124
+ setup: 'running',
125
+ context: 'pending',
126
+ specialists: 'pending',
127
+ dedupe: 'pending',
128
+ critic: 'pending',
129
+ 'peer-review': 'pending',
130
+ report: 'pending',
131
+ post: 'pending',
132
+ },
133
+ specialistCounts: {},
134
+ })
135
+ expect(html).toContain('class="pr-header"')
136
+ expect(html).toContain('data-page="progress"')
137
+ expect(html).toContain('class="segmented"')
138
+ })
139
+
140
+ test('progress-pane block replaces diff-pane on the progress page', () => {
141
+ const html = renderProgressHtml({
142
+ prNumber: 1,
143
+ headSha: 'abc123def456789012345678',
144
+ branch: 'feat',
145
+ stages: {
146
+ setup: 'done',
147
+ context: 'running',
148
+ specialists: 'pending',
149
+ dedupe: 'pending',
150
+ critic: 'pending',
151
+ 'peer-review': 'pending',
152
+ report: 'pending',
153
+ post: 'pending',
154
+ },
155
+ specialistCounts: { security: 0 },
156
+ })
157
+ expect(html).toContain('data-role="progress-pane"')
158
+ expect(html).toContain('Indexing repo symbols')
159
+ })
@@ -137,3 +137,57 @@ test('writes server-info on start with url and port', async () => {
137
137
  expect(typeof info.port).toBe('number')
138
138
  expect(info.pid).toBe(process.pid)
139
139
  })
140
+
141
+ test('POST /api/post-review returns reviewId under dry-run', async () => {
142
+ process.env.MAGPIE_DRY_RUN_POST = '1'
143
+ await writeFile(join(runDir, 'screen', 'a.html'), '<h1>X</h1>')
144
+ await writeFile(
145
+ join(runDir, 'pr.json'),
146
+ JSON.stringify({
147
+ number: 7,
148
+ headRefOid: 'abcdef00',
149
+ url: 'https://github.com/o/r/pull/7',
150
+ }),
151
+ )
152
+ await writeFile(
153
+ join(runDir, 'findings.json'),
154
+ JSON.stringify([
155
+ {
156
+ id: '1',
157
+ file: 'a.ts',
158
+ line: 1,
159
+ severity: 'blocker',
160
+ risk: { impact: 'critical', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
161
+ title: 't1',
162
+ description: 'd1',
163
+ domain: 'security',
164
+ },
165
+ {
166
+ id: '2',
167
+ file: 'b.ts',
168
+ line: 2,
169
+ severity: 'warning',
170
+ risk: {
171
+ impact: 'medium',
172
+ likelihood: 'possible',
173
+ confidence: 'medium',
174
+ action: 'should-fix',
175
+ },
176
+ title: 't2',
177
+ description: 'd2',
178
+ domain: 'performance',
179
+ },
180
+ ]),
181
+ )
182
+ server = await startServer({ runDir, idleMs: 60_000 })
183
+ const res = await fetch(`${server.url}/api/post-review`, {
184
+ method: 'POST',
185
+ headers: { 'content-type': 'application/json' },
186
+ body: JSON.stringify({ findingIds: ['1', '2'] }),
187
+ })
188
+ expect(res.status).toBe(200)
189
+ const body = (await res.json()) as { comments: Array<{ id: string; status: string }> }
190
+ expect(Array.isArray(body.comments)).toBe(true)
191
+ expect(body.comments).toHaveLength(2)
192
+ delete process.env.MAGPIE_DRY_RUN_POST
193
+ })
@@ -1,6 +1,6 @@
1
- import { expect, test } from 'bun:test'
2
- import type { FocusId, ReviewFinding } from '../types.ts'
3
- import { FOCUS_IDS, parseFinding } from '../types.ts'
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { FocusId, PrFileEntry, ReviewFinding } from '../types.ts'
3
+ import { FOCUS_IDS, isSuggestion, parseFinding } from '../types.ts'
4
4
 
5
5
  test('FOCUS_IDS contains the five default focuses', () => {
6
6
  expect(FOCUS_IDS).toEqual(['security', 'bugs', 'performance', 'code-smells', 'architecture'])
@@ -51,3 +51,48 @@ test('parseFinding accepts null line', () => {
51
51
  })
52
52
  expect(parsed.line).toBeNull()
53
53
  })
54
+
55
+ describe('PrFileEntry', () => {
56
+ test('shape is { path, additions, deletions }', () => {
57
+ const f: PrFileEntry = { path: 'src/a.ts', additions: 10, deletions: 2 }
58
+ expect(f.path).toBe('src/a.ts')
59
+ expect(f.additions).toBe(10)
60
+ expect(f.deletions).toBe(2)
61
+ })
62
+ })
63
+
64
+ describe('isSuggestion', () => {
65
+ const base = {
66
+ id: '1',
67
+ file: 'a.ts',
68
+ line: 1,
69
+ severity: 'low' as const,
70
+ title: 't',
71
+ description: 'd',
72
+ risk: {
73
+ impact: 'low' as const,
74
+ likelihood: 'unknown' as const,
75
+ confidence: 'medium' as const,
76
+ action: 'optional' as const,
77
+ },
78
+ domain: 'security' as const,
79
+ }
80
+ test('returns true for action=optional', () => {
81
+ expect(isSuggestion(parseFinding({ ...base }))).toBe(true)
82
+ })
83
+ test('returns true for action=consider', () => {
84
+ expect(
85
+ isSuggestion(parseFinding({ ...base, risk: { ...base.risk, action: 'consider' } })),
86
+ ).toBe(true)
87
+ })
88
+ test('returns false for action=must-fix', () => {
89
+ expect(
90
+ isSuggestion(parseFinding({ ...base, risk: { ...base.risk, action: 'must-fix' } })),
91
+ ).toBe(false)
92
+ })
93
+ test('returns false for action=should-fix', () => {
94
+ expect(
95
+ isSuggestion(parseFinding({ ...base, risk: { ...base.risk, action: 'should-fix' } })),
96
+ ).toBe(false)
97
+ })
98
+ })
@@ -0,0 +1,129 @@
1
+ export type DiffLine = {
2
+ type: 'context' | 'added' | 'removed'
3
+ content: string
4
+ oldLineNo: number | null
5
+ newLineNo: number | null
6
+ }
7
+
8
+ export type DiffHunk = {
9
+ oldStart: number
10
+ oldLines: number
11
+ newStart: number
12
+ newLines: number
13
+ header: string
14
+ lines: DiffLine[]
15
+ }
16
+
17
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/m
18
+ const FILE_HEADER = /^diff --git a\/(.+?) b\/(.+)$/m
19
+
20
+ export function parseUnifiedDiffToHunks(diff: string): DiffHunk[] {
21
+ if (!diff) return []
22
+ const lines = diff.split('\n')
23
+ const hunks: DiffHunk[] = []
24
+ let current: DiffHunk | null = null
25
+ let oldNo = 0
26
+ let newNo = 0
27
+
28
+ for (const raw of lines) {
29
+ const m = raw.match(HUNK_HEADER)
30
+ if (m) {
31
+ current = {
32
+ oldStart: Number(m[1]),
33
+ oldLines: m[2] ? Number(m[2]) : 1,
34
+ newStart: Number(m[3]),
35
+ newLines: m[4] ? Number(m[4]) : 1,
36
+ header: m[5] ?? '',
37
+ lines: [],
38
+ }
39
+ oldNo = current.oldStart
40
+ newNo = current.newStart
41
+ hunks.push(current)
42
+ continue
43
+ }
44
+ if (!current) continue
45
+ if (raw.startsWith('\\')) continue
46
+ if (raw.startsWith('+++') || raw.startsWith('---')) continue
47
+ if (raw.length === 0) continue
48
+ const first = raw[0]
49
+ if (first === '+') {
50
+ current.lines.push({
51
+ type: 'added',
52
+ content: raw.slice(1),
53
+ oldLineNo: null,
54
+ newLineNo: newNo,
55
+ })
56
+ newNo += 1
57
+ } else if (first === '-') {
58
+ current.lines.push({
59
+ type: 'removed',
60
+ content: raw.slice(1),
61
+ oldLineNo: oldNo,
62
+ newLineNo: null,
63
+ })
64
+ oldNo += 1
65
+ } else if (first === ' ') {
66
+ current.lines.push({
67
+ type: 'context',
68
+ content: raw.slice(1),
69
+ oldLineNo: oldNo,
70
+ newLineNo: newNo,
71
+ })
72
+ oldNo += 1
73
+ newNo += 1
74
+ }
75
+ }
76
+ return hunks
77
+ }
78
+
79
+ export function splitDiffByFile(diff: string): Map<string, string> {
80
+ const m = new Map<string, string>()
81
+ if (!diff) return m
82
+ const chunks = diff.split(/^(?=diff --git )/m)
83
+ for (const chunk of chunks) {
84
+ if (!chunk.startsWith('diff --git ')) continue
85
+ const headerMatch = chunk.match(FILE_HEADER)
86
+ if (!headerMatch || !headerMatch[2]) continue
87
+ m.set(headerMatch[2], chunk)
88
+ }
89
+ return m
90
+ }
91
+
92
+ export function filePathMatches(a: string, b: string): boolean {
93
+ const norm = (p: string) => p.replace(/^[ab]\//, '')
94
+ return norm(a) === norm(b)
95
+ }
96
+
97
+ export type PairedLine = { left: DiffLine | null; right: DiffLine | null }
98
+
99
+ export function buildPairedLines(hunks: DiffHunk[]): PairedLine[] {
100
+ const rows: PairedLine[] = []
101
+ for (const h of hunks) {
102
+ let i = 0
103
+ while (i < h.lines.length) {
104
+ const cur = h.lines[i]
105
+ if (!cur) {
106
+ i += 1
107
+ continue
108
+ }
109
+ const next = h.lines[i + 1] ?? null
110
+ if (cur.type === 'context') {
111
+ rows.push({ left: cur, right: cur })
112
+ i += 1
113
+ continue
114
+ }
115
+ if (cur.type === 'removed' && next?.type === 'added') {
116
+ rows.push({ left: cur, right: next })
117
+ i += 2
118
+ continue
119
+ }
120
+ if (cur.type === 'removed') {
121
+ rows.push({ left: cur, right: null })
122
+ } else {
123
+ rows.push({ left: null, right: cur })
124
+ }
125
+ i += 1
126
+ }
127
+ }
128
+ return rows
129
+ }
@@ -0,0 +1,74 @@
1
+ import { filePathMatches } from './diff-utils.ts'
2
+ import { type PrFileEntry, type ReviewFinding, SEVERITIES, type Severity } from './types.ts'
3
+
4
+ export type DirNode = {
5
+ name: string
6
+ fullPath: string
7
+ files: PrFileEntry[]
8
+ dirs: Map<string, DirNode>
9
+ }
10
+
11
+ export function buildTree(files: PrFileEntry[]): DirNode {
12
+ const root: DirNode = { name: '', fullPath: '', files: [], dirs: new Map() }
13
+ for (const file of files) {
14
+ const parts = file.path.split('/')
15
+ let node = root
16
+ for (let i = 0; i < parts.length - 1; i++) {
17
+ const dirName = parts[i]
18
+ if (!dirName) continue
19
+ if (!node.dirs.has(dirName)) {
20
+ const parentPath = node.fullPath ? `${node.fullPath}/${dirName}` : dirName
21
+ node.dirs.set(dirName, { name: dirName, fullPath: parentPath, files: [], dirs: new Map() })
22
+ }
23
+ const next = node.dirs.get(dirName)
24
+ if (!next) throw new Error('unreachable')
25
+ node = next
26
+ }
27
+ node.files.push(file)
28
+ }
29
+ return root
30
+ }
31
+
32
+ export function collapseTree(node: DirNode): DirNode {
33
+ const collapsedDirs = new Map<string, DirNode>()
34
+ for (const [, dir] of node.dirs) {
35
+ let current = dir
36
+ let label = current.name
37
+ while (current.files.length === 0 && current.dirs.size === 1) {
38
+ const child = [...current.dirs.values()][0]
39
+ if (!child) break
40
+ label = `${label}/${child.name}`
41
+ current = child
42
+ }
43
+ const collapsed = collapseTree(current)
44
+ collapsed.name = label
45
+ collapsedDirs.set(label, collapsed)
46
+ }
47
+ return { ...node, dirs: collapsedDirs }
48
+ }
49
+
50
+ export function findingCountsBySeverity(
51
+ findings: ReviewFinding[],
52
+ filePath: string,
53
+ ): { severity: Severity; count: number }[] {
54
+ const fileFindings = findings.filter((f) => f.file && filePathMatches(f.file, filePath))
55
+ if (fileFindings.length === 0) return []
56
+ const counts = new Map<Severity, number>()
57
+ for (const f of fileFindings) counts.set(f.severity, (counts.get(f.severity) ?? 0) + 1)
58
+ return SEVERITIES.filter((s) => counts.has(s)).map((s) => ({
59
+ severity: s,
60
+ count: counts.get(s) ?? 0,
61
+ }))
62
+ }
63
+
64
+ function collectPaths(node: DirNode): string[] {
65
+ const paths: string[] = node.files.map((f) => f.path)
66
+ for (const dir of node.dirs.values()) paths.push(...collectPaths(dir))
67
+ return paths
68
+ }
69
+
70
+ export function countDirFindings(findings: ReviewFinding[], node: DirNode): number {
71
+ const paths = collectPaths(node)
72
+ return findings.filter((f) => f.file && paths.some((p) => filePathMatches(f.file as string, p)))
73
+ .length
74
+ }
@@ -25,6 +25,7 @@ const PR_VIEW_FIELDS = [
25
25
  // `url` is included so server-side posting can parse owner/repo without
26
26
  // depending on cwd (the worktree is gone after cleanup).
27
27
  'url',
28
+ 'files',
28
29
  ].join(',')
29
30
 
30
31
  export async function fetchPr(input: FetchPrInput): Promise<FetchPrResult> {