@iceinvein/agent-skills 0.23.9 → 0.23.13
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/package.json +1 -1
- package/skills/index.json +2 -2
- package/skills/magpie/SKILL.md +21 -20
- package/skills/magpie/bin/magpie.ts +63 -2
- package/skills/magpie/evals/quality/README.md +74 -1
- package/skills/magpie/evals/quality/__tests__/probe-specialist.test.ts +498 -0
- package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +9 -0
- package/skills/magpie/evals/quality/probe-specialist.ts +672 -0
- package/skills/magpie/evals/quality/replay-full.ts +6 -2
- package/skills/magpie/fixtures/fake-gh-with-lockfile.sh +37 -2
- package/skills/magpie/fixtures/fake-gh.sh +49 -2
- package/skills/magpie/references/critic.md +20 -4
- package/skills/magpie/references/proof.md +102 -0
- package/skills/magpie/references/scout.md +6 -1
- package/skills/magpie/references/specialists.md +15 -9
- package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +103 -7
- package/skills/magpie/scripts/__tests__/cli.test.ts +69 -0
- package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +46 -0
- package/skills/magpie/scripts/__tests__/critic.test.ts +11 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +20 -1
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +23 -4
- package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +68 -0
- package/skills/magpie/scripts/__tests__/history.test.ts +232 -0
- package/skills/magpie/scripts/__tests__/impact-cmd.test.ts +477 -0
- package/skills/magpie/scripts/__tests__/impact-render.test.ts +365 -0
- package/skills/magpie/scripts/__tests__/impact.test.ts +915 -0
- package/skills/magpie/scripts/__tests__/incremental.test.ts +59 -8
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -1
- package/skills/magpie/scripts/__tests__/post-cmd.test.ts +58 -0
- package/skills/magpie/scripts/__tests__/pr-activity.test.ts +281 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +4 -4
- package/skills/magpie/scripts/__tests__/previous-findings.test.ts +267 -0
- package/skills/magpie/scripts/__tests__/proof-cmd.test.ts +260 -0
- package/skills/magpie/scripts/__tests__/proof.test.ts +226 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +71 -0
- package/skills/magpie/scripts/__tests__/render-annotation.test.ts +34 -0
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +75 -0
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +9 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +376 -1
- package/skills/magpie/scripts/__tests__/shard-coverage.test.ts +79 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +48 -2
- package/skills/magpie/scripts/__tests__/status-cmd.test.ts +20 -8
- package/skills/magpie/scripts/__tests__/types.test.ts +46 -0
- package/skills/magpie/scripts/cleanup-cmd.ts +88 -0
- package/skills/magpie/scripts/critic-cmd.ts +2 -0
- package/skills/magpie/scripts/critic.ts +4 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +4 -0
- package/skills/magpie/scripts/evidence-filter.ts +10 -0
- package/skills/magpie/scripts/git-diff.ts +50 -19
- package/skills/magpie/scripts/handoff-cmd.ts +42 -3
- package/skills/magpie/scripts/history.ts +143 -0
- package/skills/magpie/scripts/impact-cmd.ts +202 -0
- package/skills/magpie/scripts/impact-render.ts +158 -0
- package/skills/magpie/scripts/impact.ts +622 -0
- package/skills/magpie/scripts/incremental.ts +44 -5
- package/skills/magpie/scripts/labels-cmd.ts +7 -3
- package/skills/magpie/scripts/post-cmd.ts +46 -0
- package/skills/magpie/scripts/pr-activity.ts +243 -0
- package/skills/magpie/scripts/preview-cmd.ts +3 -0
- package/skills/magpie/scripts/previous-findings.ts +181 -0
- package/skills/magpie/scripts/proof-cmd.ts +226 -0
- package/skills/magpie/scripts/proof.ts +150 -0
- package/skills/magpie/scripts/refresh.ts +11 -0
- package/skills/magpie/scripts/render-annotation.ts +17 -0
- package/skills/magpie/scripts/render-cmd.ts +12 -0
- package/skills/magpie/scripts/render-findings.ts +37 -1
- package/skills/magpie/scripts/render-progress.ts +3 -0
- package/skills/magpie/scripts/setup-cmd.ts +142 -11
- package/skills/magpie/scripts/shard-coverage.ts +53 -0
- package/skills/magpie/scripts/status-cmd.ts +2 -0
- package/skills/magpie/scripts/types.ts +39 -0
- package/skills/magpie/skill.json +1 -1
- package/skills/magpie/templates/styles.css +30 -0
- package/skills/sluice/scripts/status.sh +32 -13
- package/skills/sluice/skill.json +1 -1
|
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
|
2
2
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
-
import { buildIncrementalContext, findPreviousRun } from '../incremental.ts'
|
|
5
|
+
import { buildIncrementalContext, changedFilesSince, findPreviousRun } from '../incremental.ts'
|
|
6
|
+
import { createGitFixtureRepo, git } from './helpers/git-fixture.ts'
|
|
6
7
|
|
|
7
8
|
let home: string
|
|
8
9
|
|
|
@@ -14,15 +15,17 @@ afterEach(async () => {
|
|
|
14
15
|
await rm(home, { recursive: true, force: true })
|
|
15
16
|
})
|
|
16
17
|
|
|
18
|
+
const PR_URL = 'https://github.com/octocat/Hello-World/pull/42'
|
|
19
|
+
|
|
17
20
|
async function seedRun(id: string, prJson: Record<string, unknown>): Promise<string> {
|
|
18
21
|
const dir = join(home, id)
|
|
19
22
|
await mkdir(dir, { recursive: true })
|
|
20
|
-
await writeFile(join(dir, 'pr.json'), JSON.stringify(prJson))
|
|
23
|
+
await writeFile(join(dir, 'pr.json'), JSON.stringify({ url: PR_URL, ...prJson }))
|
|
21
24
|
return dir
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
test('findPreviousRun returns null when no prior runs exist', async () => {
|
|
25
|
-
const result = await findPreviousRun(42, join(home, 'pr-42-100'), home)
|
|
28
|
+
const result = await findPreviousRun(42, PR_URL, join(home, 'pr-42-100'), home)
|
|
26
29
|
expect(result).toBeNull()
|
|
27
30
|
})
|
|
28
31
|
|
|
@@ -30,7 +33,7 @@ test('findPreviousRun returns the most recent prior run for the PR', async () =>
|
|
|
30
33
|
await seedRun('pr-42-100', { headRefOid: 'aaa' })
|
|
31
34
|
await seedRun('pr-42-200', { headRefOid: 'bbb' })
|
|
32
35
|
const currentRun = join(home, 'pr-42-300')
|
|
33
|
-
const result = await findPreviousRun(42, currentRun, home)
|
|
36
|
+
const result = await findPreviousRun(42, PR_URL, currentRun, home)
|
|
34
37
|
expect(result?.runId).toBe('pr-42-200')
|
|
35
38
|
})
|
|
36
39
|
|
|
@@ -38,26 +41,43 @@ test('findPreviousRun excludes the current run from candidates', async () => {
|
|
|
38
41
|
await seedRun('pr-42-100', { headRefOid: 'aaa' })
|
|
39
42
|
const currentRun = join(home, 'pr-42-200')
|
|
40
43
|
await mkdir(currentRun, { recursive: true })
|
|
41
|
-
const result = await findPreviousRun(42, currentRun, home)
|
|
44
|
+
const result = await findPreviousRun(42, PR_URL, currentRun, home)
|
|
42
45
|
expect(result?.runId).toBe('pr-42-100')
|
|
43
46
|
})
|
|
44
47
|
|
|
45
48
|
test('findPreviousRun ignores other PR numbers', async () => {
|
|
46
49
|
await seedRun('pr-99-100', { headRefOid: 'other' })
|
|
47
|
-
const result = await findPreviousRun(42, join(home, 'pr-42-200'), home)
|
|
50
|
+
const result = await findPreviousRun(42, PR_URL, join(home, 'pr-42-200'), home)
|
|
51
|
+
expect(result).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('findPreviousRun ignores a run of the same PR number in another repository', async () => {
|
|
55
|
+
await seedRun('pr-42-100', { headRefOid: 'mine' })
|
|
56
|
+
await seedRun('pr-42-200', {
|
|
57
|
+
headRefOid: 'theirs',
|
|
58
|
+
url: 'https://github.com/someone/else/pull/42',
|
|
59
|
+
})
|
|
60
|
+
const result = await findPreviousRun(42, PR_URL, join(home, 'pr-42-300'), home)
|
|
61
|
+
expect(result?.runId).toBe('pr-42-100')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('findPreviousRun skips a run whose pr.json has no url, since its repository is unknown', async () => {
|
|
65
|
+
await mkdir(join(home, 'pr-42-100'), { recursive: true })
|
|
66
|
+
await writeFile(join(home, 'pr-42-100', 'pr.json'), JSON.stringify({ headRefOid: 'aaa' }))
|
|
67
|
+
const result = await findPreviousRun(42, PR_URL, join(home, 'pr-42-200'), home)
|
|
48
68
|
expect(result).toBeNull()
|
|
49
69
|
})
|
|
50
70
|
|
|
51
71
|
test('findPreviousRun matches archived runs', async () => {
|
|
52
72
|
await seedRun('pr-42-100.archived-200', { headRefOid: 'old' })
|
|
53
|
-
const result = await findPreviousRun(42, join(home, 'pr-42-300'), home)
|
|
73
|
+
const result = await findPreviousRun(42, PR_URL, join(home, 'pr-42-300'), home)
|
|
54
74
|
expect(result?.runId).toBe('pr-42-100.archived-200')
|
|
55
75
|
})
|
|
56
76
|
|
|
57
77
|
test('findPreviousRun skips candidates with missing pr.json', async () => {
|
|
58
78
|
await mkdir(join(home, 'pr-42-100'), { recursive: true })
|
|
59
79
|
await seedRun('pr-42-50', { headRefOid: 'older' })
|
|
60
|
-
const result = await findPreviousRun(42, join(home, 'pr-42-200'), home)
|
|
80
|
+
const result = await findPreviousRun(42, PR_URL, join(home, 'pr-42-200'), home)
|
|
61
81
|
expect(result?.runId).toBe('pr-42-50')
|
|
62
82
|
})
|
|
63
83
|
|
|
@@ -85,3 +105,34 @@ test('buildIncrementalContext returns null when either sha is missing', () => {
|
|
|
85
105
|
const noCurr = buildIncrementalContext({}, { runId: 'x', prJson: { headRefOid: 'a' } })
|
|
86
106
|
expect(noCurr).toBeNull()
|
|
87
107
|
})
|
|
108
|
+
|
|
109
|
+
test('changedFilesSince lists the files changed between the two shas', async () => {
|
|
110
|
+
const fixture = await createGitFixtureRepo()
|
|
111
|
+
try {
|
|
112
|
+
const base = await git(fixture.repoPath, 'rev-parse', 'feature-x~1')
|
|
113
|
+
const files = await changedFilesSince({
|
|
114
|
+
gitBin: 'git',
|
|
115
|
+
repoPath: fixture.repoPath,
|
|
116
|
+
fromSha: base,
|
|
117
|
+
toSha: fixture.head,
|
|
118
|
+
})
|
|
119
|
+
expect(files).toEqual(['a.ts'])
|
|
120
|
+
} finally {
|
|
121
|
+
await rm(fixture.repoPath, { recursive: true, force: true })
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('changedFilesSince is null when the earlier sha is not in the clone', async () => {
|
|
126
|
+
const fixture = await createGitFixtureRepo()
|
|
127
|
+
try {
|
|
128
|
+
const files = await changedFilesSince({
|
|
129
|
+
gitBin: 'git',
|
|
130
|
+
repoPath: fixture.repoPath,
|
|
131
|
+
fromSha: '0123456789abcdef0123456789abcdef01234567',
|
|
132
|
+
toSha: fixture.head,
|
|
133
|
+
})
|
|
134
|
+
expect(files).toBeNull()
|
|
135
|
+
} finally {
|
|
136
|
+
await rm(fixture.repoPath, { recursive: true, force: true })
|
|
137
|
+
}
|
|
138
|
+
})
|
|
@@ -89,7 +89,7 @@ test('setup -> dedupe -> render(progress) -> render(findings) -> cleanup compose
|
|
|
89
89
|
expect(findingsHtml.includes('class="annot-body')).toBe(true)
|
|
90
90
|
expect(findingsHtml.includes('class="issue-card')).toBe(true)
|
|
91
91
|
|
|
92
|
-
expect(await runCleanup({ runDir, repoPath: repo, gitBin: 'git' })).toBe(0)
|
|
92
|
+
expect(await runCleanup({ runDir, repoPath: repo, gitBin: 'git', ghBin: FAKE_GH })).toBe(0)
|
|
93
93
|
const parent = dirname(runDir)
|
|
94
94
|
const entries = await readdir(parent)
|
|
95
95
|
expect(entries.find((e) => e.startsWith(`${basename(runDir)}.archived-`))).toBeDefined()
|
|
@@ -381,6 +381,23 @@ test('formatReviewSummaryBody renders incremental trailer with new commits', ()
|
|
|
381
381
|
expect(body).toContain('abcdef0')
|
|
382
382
|
})
|
|
383
383
|
|
|
384
|
+
test('formatReviewSummaryBody carries the previous-run line under the incremental trailer', () => {
|
|
385
|
+
const body = formatReviewSummaryBody([], {
|
|
386
|
+
incremental: { previousSha: 'abcdef0123456', sameSha: false },
|
|
387
|
+
previousRun: 'Last review: 3 posted · 2 on changed code · 1 on unchanged code',
|
|
388
|
+
})
|
|
389
|
+
expect(body).toContain(
|
|
390
|
+
'<sub>Incremental review since `abcdef0`</sub>\n\n<sub>Last review: 3 posted · 2 on changed code · 1 on unchanged code</sub>',
|
|
391
|
+
)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
test('formatReviewSummaryBody says which shards went unreviewed', () => {
|
|
395
|
+
const body = formatReviewSummaryBody([], {
|
|
396
|
+
unreviewed: 'Not reviewed: shards 2, 3 (3 files)',
|
|
397
|
+
})
|
|
398
|
+
expect(body).toContain('<sub>Not reviewed: shards 2, 3 (3 files)</sub>')
|
|
399
|
+
})
|
|
400
|
+
|
|
384
401
|
test('formatReviewSummaryBody renders re-review trailer when sameSha', () => {
|
|
385
402
|
const body = formatReviewSummaryBody([], {
|
|
386
403
|
incremental: { previousSha: 'abcdef0123456', sameSha: true },
|
|
@@ -622,3 +639,44 @@ test('runPost writes the given via onto each successful post log entry', async (
|
|
|
622
639
|
const ok = (await logEntries(runDir)).filter((e) => e.status === 'ok')
|
|
623
640
|
expect(ok.map((e) => ({ id: e.id, via: e.via }))).toEqual([{ id: 'sec-1', via: 'cli' }])
|
|
624
641
|
})
|
|
642
|
+
|
|
643
|
+
test('formatInlineBody folds a reproduced finding repro into a details block', () => {
|
|
644
|
+
const body = formatInlineBody({
|
|
645
|
+
...findingA,
|
|
646
|
+
line: 12,
|
|
647
|
+
proof: {
|
|
648
|
+
status: 'reproduced',
|
|
649
|
+
reason: 'fails as described',
|
|
650
|
+
repro: {
|
|
651
|
+
path: 'secret.test.ts',
|
|
652
|
+
command: 'bun test secret.test.ts',
|
|
653
|
+
source: 'expect(env).not.toContain("sk_")',
|
|
654
|
+
output: 'expected not to contain sk_',
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
} as never)
|
|
658
|
+
expect(body).toContain(
|
|
659
|
+
[
|
|
660
|
+
'<details><summary>Reproduced with <code>bun test secret.test.ts</code></summary>',
|
|
661
|
+
'',
|
|
662
|
+
'```',
|
|
663
|
+
'expect(env).not.toContain("sk_")',
|
|
664
|
+
'```',
|
|
665
|
+
'',
|
|
666
|
+
'```',
|
|
667
|
+
'expected not to contain sk_',
|
|
668
|
+
'```',
|
|
669
|
+
'',
|
|
670
|
+
'</details>',
|
|
671
|
+
].join('\n'),
|
|
672
|
+
)
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
test('formatInlineBody leaves a not-proven note out of the posted comment', () => {
|
|
676
|
+
const body = formatInlineBody({
|
|
677
|
+
...findingA,
|
|
678
|
+
line: 12,
|
|
679
|
+
proof: { status: 'not-proven', reason: 'needs a live database' },
|
|
680
|
+
} as never)
|
|
681
|
+
expect(body).not.toContain('needs a live database')
|
|
682
|
+
})
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { fetchPrActivity } from '../pr-activity.ts'
|
|
6
|
+
|
|
7
|
+
const PR_URL = 'https://github.com/octocat/Hello-World/pull/7'
|
|
8
|
+
|
|
9
|
+
const VIEW = {
|
|
10
|
+
statusCheckRollup: [
|
|
11
|
+
{
|
|
12
|
+
__typename: 'CheckRun',
|
|
13
|
+
name: 'build',
|
|
14
|
+
status: 'COMPLETED',
|
|
15
|
+
conclusion: 'FAILURE',
|
|
16
|
+
detailsUrl: 'https://ci.example/1',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
__typename: 'StatusContext',
|
|
20
|
+
context: 'lint',
|
|
21
|
+
state: 'SUCCESS',
|
|
22
|
+
targetUrl: 'https://ci.example/2',
|
|
23
|
+
},
|
|
24
|
+
{ __typename: 'CheckRun', name: 'e2e', status: 'IN_PROGRESS', conclusion: '', detailsUrl: '' },
|
|
25
|
+
],
|
|
26
|
+
comments: [
|
|
27
|
+
{ author: { login: 'alice' }, body: 'Does this retry past three attempts?' },
|
|
28
|
+
{ author: { login: 'octocat' }, body: '<!-- magpie:summary -->\n**Magpie review**' },
|
|
29
|
+
],
|
|
30
|
+
reviews: [
|
|
31
|
+
{ author: { login: 'bob' }, body: '', state: 'APPROVED' },
|
|
32
|
+
{ author: { login: 'carol' }, body: 'Please add a test for the timeout.', state: 'COMMENTED' },
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function threadPage(nodes: unknown[], endCursor: string | null): unknown {
|
|
37
|
+
return {
|
|
38
|
+
data: {
|
|
39
|
+
repository: {
|
|
40
|
+
pullRequest: {
|
|
41
|
+
reviewThreads: { pageInfo: { hasNextPage: endCursor !== null, endCursor }, nodes },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const THREAD_OPEN = {
|
|
49
|
+
path: 'src/upload.ts',
|
|
50
|
+
line: 42,
|
|
51
|
+
originalLine: 40,
|
|
52
|
+
isResolved: false,
|
|
53
|
+
isOutdated: false,
|
|
54
|
+
comments: {
|
|
55
|
+
totalCount: 3,
|
|
56
|
+
nodes: [{ author: { login: 'dave' }, body: 'This swallows the timeout error.' }],
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const THREAD_MAGPIE = {
|
|
61
|
+
path: 'src/cache.ts',
|
|
62
|
+
line: null,
|
|
63
|
+
originalLine: 9,
|
|
64
|
+
isResolved: true,
|
|
65
|
+
isOutdated: true,
|
|
66
|
+
comments: {
|
|
67
|
+
totalCount: 1,
|
|
68
|
+
nodes: [
|
|
69
|
+
{
|
|
70
|
+
author: { login: 'octocat' },
|
|
71
|
+
body: '### High\nCache never evicts\n<!-- magpie:finding id=perf-1 hash=abc -->',
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let dir: string
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
dir = await mkdtemp(join(tmpdir(), 'magpie-activity-'))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
afterEach(async () => {
|
|
84
|
+
await rm(dir, { recursive: true, force: true })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A fake gh that answers the activity `pr view` from view.json and each
|
|
89
|
+
* `api graphql` page from page-<cursor>.json (page-start.json with no cursor),
|
|
90
|
+
* recording every invocation's arguments to calls.log.
|
|
91
|
+
*/
|
|
92
|
+
async function fakeGh(view: unknown, pages: Record<string, unknown>): Promise<string> {
|
|
93
|
+
await writeFile(join(dir, 'view.json'), JSON.stringify(view))
|
|
94
|
+
for (const [cursor, page] of Object.entries(pages)) {
|
|
95
|
+
await writeFile(join(dir, `page-${cursor}.json`), JSON.stringify(page))
|
|
96
|
+
}
|
|
97
|
+
const script = join(dir, 'gh.sh')
|
|
98
|
+
await writeFile(
|
|
99
|
+
script,
|
|
100
|
+
[
|
|
101
|
+
'#!/usr/bin/env bash',
|
|
102
|
+
// One line per call: the query argument spans several lines.
|
|
103
|
+
`printf '%s\\n' "$(echo "$*" | tr '\\n' ' ')" >> "${dir}/calls.log"`,
|
|
104
|
+
'if [[ "$1 $2" == "pr view" ]]; then',
|
|
105
|
+
` cat "${dir}/view.json"; exit 0`,
|
|
106
|
+
'fi',
|
|
107
|
+
'if [[ "$1 $2" == "api graphql" ]]; then',
|
|
108
|
+
' cursor=start',
|
|
109
|
+
' for arg in "$@"; do',
|
|
110
|
+
' case "$arg" in after=*) cursor=$(echo "$arg" | sed "s/^after=//") ;; esac',
|
|
111
|
+
' done',
|
|
112
|
+
` cat "${dir}/page-$cursor.json"; exit 0`,
|
|
113
|
+
'fi',
|
|
114
|
+
'echo "unsupported: $*" >&2; exit 1',
|
|
115
|
+
].join('\n'),
|
|
116
|
+
)
|
|
117
|
+
await Bun.spawn(['chmod', '+x', script]).exited
|
|
118
|
+
return script
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test('checks are normalised to name, conclusion and url, in-progress ones as their status', async () => {
|
|
122
|
+
const gh = await fakeGh(VIEW, { start: threadPage([], null) })
|
|
123
|
+
const result = await fetchPrActivity({ ghBin: gh, prNumber: 7, prUrl: PR_URL, runDir: dir })
|
|
124
|
+
expect(result.ok).toBe(true)
|
|
125
|
+
if (!result.ok) return
|
|
126
|
+
expect(result.activity.checks).toEqual([
|
|
127
|
+
{ name: 'build', conclusion: 'FAILURE', url: 'https://ci.example/1' },
|
|
128
|
+
{ name: 'lint', conclusion: 'SUCCESS', url: 'https://ci.example/2' },
|
|
129
|
+
{ name: 'e2e', conclusion: 'IN_PROGRESS', url: null },
|
|
130
|
+
])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('top-level comments and non-empty review bodies are kept, magpie summary excluded', async () => {
|
|
134
|
+
const gh = await fakeGh(VIEW, { start: threadPage([], null) })
|
|
135
|
+
const result = await fetchPrActivity({ ghBin: gh, prNumber: 7, prUrl: PR_URL, runDir: dir })
|
|
136
|
+
if (!result.ok) throw new Error(result.error)
|
|
137
|
+
expect(result.activity.comments).toEqual([
|
|
138
|
+
{ author: 'alice', body: 'Does this retry past three attempts?', magpie: false },
|
|
139
|
+
{ author: 'carol', body: 'Please add a test for the timeout.', magpie: false },
|
|
140
|
+
])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('review threads carry file, line, state, first comment and reply count, flagging magpie', async () => {
|
|
144
|
+
const gh = await fakeGh(VIEW, { start: threadPage([THREAD_OPEN, THREAD_MAGPIE], null) })
|
|
145
|
+
const result = await fetchPrActivity({ ghBin: gh, prNumber: 7, prUrl: PR_URL, runDir: dir })
|
|
146
|
+
if (!result.ok) throw new Error(result.error)
|
|
147
|
+
expect(result.activity.threads).toEqual([
|
|
148
|
+
{
|
|
149
|
+
file: 'src/upload.ts',
|
|
150
|
+
line: 42,
|
|
151
|
+
resolved: false,
|
|
152
|
+
outdated: false,
|
|
153
|
+
author: 'dave',
|
|
154
|
+
body: 'This swallows the timeout error.',
|
|
155
|
+
replies: 2,
|
|
156
|
+
magpie: false,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
file: 'src/cache.ts',
|
|
160
|
+
line: 9,
|
|
161
|
+
resolved: true,
|
|
162
|
+
outdated: true,
|
|
163
|
+
author: 'octocat',
|
|
164
|
+
body: '### High\nCache never evicts\n<!-- magpie:finding id=perf-1 hash=abc -->',
|
|
165
|
+
replies: 0,
|
|
166
|
+
magpie: true,
|
|
167
|
+
},
|
|
168
|
+
])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('every page of review threads is fetched', async () => {
|
|
172
|
+
const second = { ...THREAD_OPEN, path: 'src/second.ts' }
|
|
173
|
+
const gh = await fakeGh(VIEW, {
|
|
174
|
+
start: threadPage([THREAD_OPEN], 'CURSOR1'),
|
|
175
|
+
CURSOR1: threadPage([second], null),
|
|
176
|
+
})
|
|
177
|
+
const result = await fetchPrActivity({ ghBin: gh, prNumber: 7, prUrl: PR_URL, runDir: dir })
|
|
178
|
+
if (!result.ok) throw new Error(result.error)
|
|
179
|
+
expect(result.activity.threads.map((t) => t.file)).toEqual(['src/upload.ts', 'src/second.ts'])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('the activity is written to pr-activity.json in the run dir', async () => {
|
|
183
|
+
const gh = await fakeGh(VIEW, { start: threadPage([THREAD_OPEN], null) })
|
|
184
|
+
const result = await fetchPrActivity({ ghBin: gh, prNumber: 7, prUrl: PR_URL, runDir: dir })
|
|
185
|
+
if (!result.ok) throw new Error(result.error)
|
|
186
|
+
const written = JSON.parse(await readFile(join(dir, 'pr-activity.json'), 'utf8'))
|
|
187
|
+
expect(written.threads[0].file).toBe('src/upload.ts')
|
|
188
|
+
expect(written.checks[0].name).toBe('build')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('the thread query targets the owner, repo and number from the PR url', async () => {
|
|
192
|
+
const gh = await fakeGh(VIEW, { start: threadPage([], null) })
|
|
193
|
+
await fetchPrActivity({ ghBin: gh, prNumber: 7, prUrl: PR_URL, runDir: dir })
|
|
194
|
+
const calls = await readFile(join(dir, 'calls.log'), 'utf8')
|
|
195
|
+
const graphql = calls.split('\n').find((l) => l.startsWith('api graphql')) ?? ''
|
|
196
|
+
expect(graphql).toContain('owner=octocat')
|
|
197
|
+
expect(graphql).toContain('repo=Hello-World')
|
|
198
|
+
expect(graphql).toContain('number=7')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('a failing gh call fails the fetch with its stderr', async () => {
|
|
202
|
+
const script = join(dir, 'gh.sh')
|
|
203
|
+
await writeFile(script, '#!/usr/bin/env bash\necho "HTTP 403: rate limited" >&2\nexit 1\n')
|
|
204
|
+
await Bun.spawn(['chmod', '+x', script]).exited
|
|
205
|
+
const result = await fetchPrActivity({ ghBin: script, prNumber: 7, prUrl: PR_URL, runDir: dir })
|
|
206
|
+
expect(result.ok).toBe(false)
|
|
207
|
+
if (result.ok) return
|
|
208
|
+
expect(result.error).toContain('HTTP 403: rate limited')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('a graphql response with errors fails the fetch, naming them', async () => {
|
|
212
|
+
const gh = await fakeGh(VIEW, {
|
|
213
|
+
start: { data: null, errors: [{ message: 'Could not resolve to a Repository' }] },
|
|
214
|
+
})
|
|
215
|
+
const result = await fetchPrActivity({ ghBin: gh, prNumber: 7, prUrl: PR_URL, runDir: dir })
|
|
216
|
+
expect(result.ok).toBe(false)
|
|
217
|
+
if (result.ok) return
|
|
218
|
+
expect(result.error).toContain('Could not resolve to a Repository')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('a PR url that names no owner and repo fails the fetch', async () => {
|
|
222
|
+
const gh = await fakeGh(VIEW, { start: threadPage([], null) })
|
|
223
|
+
const result = await fetchPrActivity({
|
|
224
|
+
ghBin: gh,
|
|
225
|
+
prNumber: 7,
|
|
226
|
+
prUrl: 'not a url',
|
|
227
|
+
runDir: dir,
|
|
228
|
+
})
|
|
229
|
+
expect(result.ok).toBe(false)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('a deleted user reads as ghost, the name GitHub shows for one', async () => {
|
|
233
|
+
const view = {
|
|
234
|
+
statusCheckRollup: [],
|
|
235
|
+
comments: [{ author: null, body: 'Old question' }],
|
|
236
|
+
reviews: [],
|
|
237
|
+
}
|
|
238
|
+
const gh = await fakeGh(view, { start: threadPage([], null) })
|
|
239
|
+
const result = await fetchPrActivity({ ghBin: gh, prNumber: 7, prUrl: PR_URL, runDir: dir })
|
|
240
|
+
if (!result.ok) throw new Error(result.error)
|
|
241
|
+
expect(result.activity.comments).toEqual([
|
|
242
|
+
{ author: 'ghost', body: 'Old question', magpie: false },
|
|
243
|
+
])
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('a thread whose first comment was deleted is left out', async () => {
|
|
247
|
+
const emptied = { ...THREAD_OPEN, comments: { totalCount: 0, nodes: [] } }
|
|
248
|
+
const gh = await fakeGh(VIEW, { start: threadPage([emptied, THREAD_MAGPIE], null) })
|
|
249
|
+
const result = await fetchPrActivity({ ghBin: gh, prNumber: 7, prUrl: PR_URL, runDir: dir })
|
|
250
|
+
if (!result.ok) throw new Error(result.error)
|
|
251
|
+
expect(result.activity.threads.map((t) => t.file)).toEqual(['src/cache.ts'])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('a review body holding several magpie findings is split into one comment each', async () => {
|
|
255
|
+
const body = [
|
|
256
|
+
'Overall fine.',
|
|
257
|
+
'**First finding**\n<!-- magpie:finding id=bugs-1 hash=a -->',
|
|
258
|
+
'**Second finding**\n<!-- magpie:finding id=bugs-2 hash=b -->',
|
|
259
|
+
].join('\n\n---\n\n')
|
|
260
|
+
const view = {
|
|
261
|
+
statusCheckRollup: [],
|
|
262
|
+
comments: [],
|
|
263
|
+
reviews: [{ author: { login: 'octocat' }, body, state: 'COMMENTED' }],
|
|
264
|
+
}
|
|
265
|
+
const gh = await fakeGh(view, { start: threadPage([], null) })
|
|
266
|
+
const result = await fetchPrActivity({ ghBin: gh, prNumber: 7, prUrl: PR_URL, runDir: dir })
|
|
267
|
+
if (!result.ok) throw new Error(result.error)
|
|
268
|
+
expect(result.activity.comments).toEqual([
|
|
269
|
+
{ author: 'octocat', body: 'Overall fine.', magpie: false },
|
|
270
|
+
{
|
|
271
|
+
author: 'octocat',
|
|
272
|
+
body: '**First finding**\n<!-- magpie:finding id=bugs-1 hash=a -->',
|
|
273
|
+
magpie: true,
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
author: 'octocat',
|
|
277
|
+
body: '**Second finding**\n<!-- magpie:finding id=bugs-2 hash=b -->',
|
|
278
|
+
magpie: true,
|
|
279
|
+
},
|
|
280
|
+
])
|
|
281
|
+
})
|
|
@@ -53,9 +53,9 @@ test('renders both pages by default from the bundled fixture', async () => {
|
|
|
53
53
|
expect(findings).toContain('medium')
|
|
54
54
|
expect(findings).toContain('low')
|
|
55
55
|
expect(progress).toContain('PR #1337')
|
|
56
|
-
// Default stage is report-done so the connector should fill
|
|
56
|
+
// Default stage is report-done so the connector should fill 8/8 segments
|
|
57
57
|
// before the final post stage.
|
|
58
|
-
expect(progress).toContain('--done-count:
|
|
58
|
+
expect(progress).toContain('--done-count: 8')
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
test('honors --page findings: progress is not rendered', async () => {
|
|
@@ -154,8 +154,8 @@ test('--done-count in the progress page reflects the chosen preset', async () =>
|
|
|
154
154
|
{ stage: 'setup-done', expectedDone: 1 },
|
|
155
155
|
{ stage: 'specialists-done', expectedDone: 3 },
|
|
156
156
|
{ stage: 'critic-done', expectedDone: 5 },
|
|
157
|
-
{ stage: 'report-done', expectedDone:
|
|
158
|
-
{ stage: 'post-done', expectedDone:
|
|
157
|
+
{ stage: 'report-done', expectedDone: 8 },
|
|
158
|
+
{ stage: 'post-done', expectedDone: 9 },
|
|
159
159
|
]
|
|
160
160
|
for (const { stage, expectedDone } of cases) {
|
|
161
161
|
const stageDir = join(workDir, stage)
|