@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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/skills/magpie/README.md +16 -1
- package/skills/magpie/bin/magpie.ts +60 -2
- package/skills/magpie/fixtures/example-pr/diff.patch +86 -0
- package/skills/magpie/fixtures/example-pr/findings.final.json +137 -0
- package/skills/magpie/fixtures/example-pr/post-status.json +5 -0
- package/skills/magpie/fixtures/example-pr/pr.json +21 -0
- package/skills/magpie/fixtures/fake-gh.sh +10 -1
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/scripts/__tests__/cli.test.ts +119 -0
- 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 +210 -0
- 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 -182
- package/skills/magpie/scripts/__tests__/render-issues-list.test.ts +67 -0
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +86 -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 +300 -0
- package/skills/magpie/scripts/refresh.ts +16 -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 +24 -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 +165 -196
- package/skills/magpie/scripts/render-issues-list.ts +78 -0
- package/skills/magpie/scripts/render-progress.ts +74 -32
- package/skills/magpie/scripts/server.ts +43 -0
- package/skills/magpie/scripts/types.ts +13 -0
- package/skills/magpie/skill.json +4 -2
- 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> {
|