@iceinvein/agent-skills 0.1.26 → 0.1.27
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
CHANGED
|
@@ -157,6 +157,10 @@ The CLI fetches skills from GitHub and installs them into the right locations fo
|
|
|
157
157
|
|
|
158
158
|
Skills install to the **current project** by default. Use `-g` to install to your **home directory** so the skill is available everywhere. A `.agent-skills.lock` file tracks installations for update and remove.
|
|
159
159
|
|
|
160
|
+
## Contributing
|
|
161
|
+
|
|
162
|
+
- [Releasing](docs/RELEASING.md) — how versions are bumped, tagged, and published to npm
|
|
163
|
+
|
|
160
164
|
## License
|
|
161
165
|
|
|
162
166
|
MIT
|
package/package.json
CHANGED
|
@@ -1,9 +1,33 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { appendFile, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
+
import { parseUnifiedDiffToHunks, splitDiffByFile } from './diff-utils.ts'
|
|
4
5
|
import { formatFindingDescriptionMarkdown } from './finding-description.ts'
|
|
5
6
|
import { type FocusId, parseFinding, type ReviewFinding, type Severity } from './types.ts'
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Build a per-file set of RIGHT-side line numbers that GitHub's PR Reviews API
|
|
10
|
+
* will accept as inline-comment anchors (added or context lines within hunks).
|
|
11
|
+
* Returns an empty map when the diff is empty/unavailable.
|
|
12
|
+
*/
|
|
13
|
+
function buildValidRightLines(diff: string): Map<string, Set<number>> {
|
|
14
|
+
const result = new Map<string, Set<number>>()
|
|
15
|
+
if (!diff) return result
|
|
16
|
+
for (const [file, chunk] of splitDiffByFile(diff)) {
|
|
17
|
+
const hunks = parseUnifiedDiffToHunks(chunk)
|
|
18
|
+
const set = new Set<number>()
|
|
19
|
+
for (const h of hunks) {
|
|
20
|
+
for (const l of h.lines) {
|
|
21
|
+
if (l.newLineNo != null && (l.type === 'added' || l.type === 'context')) {
|
|
22
|
+
set.add(l.newLineNo)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
result.set(file, set)
|
|
27
|
+
}
|
|
28
|
+
return result
|
|
29
|
+
}
|
|
30
|
+
|
|
7
31
|
export type PostInput = {
|
|
8
32
|
runDir: string
|
|
9
33
|
findingIds: string[]
|
|
@@ -289,6 +313,12 @@ export type PostReviewInput = {
|
|
|
289
313
|
findingIds: string[]
|
|
290
314
|
prNumber: number
|
|
291
315
|
headSha: string
|
|
316
|
+
/**
|
|
317
|
+
* Target repo as "owner/name". When omitted, the `{owner}/{repo}` gh
|
|
318
|
+
* placeholder is used, which resolves from the gh process's cwd. The server
|
|
319
|
+
* always passes this explicitly so the call does not depend on cwd.
|
|
320
|
+
*/
|
|
321
|
+
repo?: string
|
|
292
322
|
reviewBody?: string
|
|
293
323
|
ghBin?: string
|
|
294
324
|
dryRun?: boolean
|
|
@@ -364,25 +394,59 @@ export async function postFindingsAsReview(input: PostReviewInput): Promise<Post
|
|
|
364
394
|
// findings.final.json missing or unparseable; strictById stays empty
|
|
365
395
|
}
|
|
366
396
|
|
|
367
|
-
//
|
|
397
|
+
// Build the set of (file, RIGHT-side line) pairs GitHub will accept as inline
|
|
398
|
+
// anchors. Inline comments on lines outside this set get 422'd and would
|
|
399
|
+
// poison the whole batch; instead we demote them to the review body.
|
|
400
|
+
let validRightLines: Map<string, Set<number>> | null = null
|
|
401
|
+
try {
|
|
402
|
+
const diff = await readFile(join(input.runDir, 'diff.patch'), 'utf8')
|
|
403
|
+
validRightLines = buildValidRightLines(diff)
|
|
404
|
+
} catch {
|
|
405
|
+
// diff.patch absent (archived/legacy runs); skip validation and trust caller.
|
|
406
|
+
validRightLines = null
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Partition into inline (has file + line in diff) and unplaced (everything else).
|
|
368
410
|
type InlineComment = { path: string; line: number; side: 'RIGHT'; body: string }
|
|
369
411
|
const inlineComments: InlineComment[] = []
|
|
370
412
|
const unplacedBodies: string[] = []
|
|
413
|
+
const demotedIds: string[] = []
|
|
371
414
|
|
|
372
415
|
for (const id of input.findingIds) {
|
|
373
|
-
const f = byId.get(id)
|
|
374
|
-
if (!f) continue
|
|
375
416
|
const strict = strictById.get(id)
|
|
376
|
-
|
|
417
|
+
const f =
|
|
418
|
+
byId.get(id) ??
|
|
419
|
+
(strict
|
|
420
|
+
? {
|
|
421
|
+
id: strict.id,
|
|
422
|
+
file: strict.file,
|
|
423
|
+
line: strict.line,
|
|
424
|
+
title: strict.title,
|
|
425
|
+
description: strict.description,
|
|
426
|
+
}
|
|
427
|
+
: null)
|
|
428
|
+
if (!f) continue
|
|
429
|
+
const lineInDiff =
|
|
430
|
+
f.line != null &&
|
|
431
|
+
f.file != null &&
|
|
432
|
+
(validRightLines == null || validRightLines.get(f.file)?.has(f.line) === true)
|
|
433
|
+
if (lineInDiff) {
|
|
377
434
|
const body = strict
|
|
378
435
|
? formatInlineBody(strict)
|
|
379
436
|
: formatFindingDescriptionMarkdown(f.description)
|
|
380
437
|
inlineComments.push({
|
|
381
|
-
path: f.file,
|
|
382
|
-
line: f.line,
|
|
438
|
+
path: f.file as string,
|
|
439
|
+
line: f.line as number,
|
|
383
440
|
side: 'RIGHT',
|
|
384
441
|
body,
|
|
385
442
|
})
|
|
443
|
+
} else if (f.line != null && f.file != null) {
|
|
444
|
+
demotedIds.push(id)
|
|
445
|
+
const anchor = `\`${f.file}:${f.line}\` (anchor not in PR diff, posted in review body)`
|
|
446
|
+
const inner = strict
|
|
447
|
+
? formatConversationBody(strict)
|
|
448
|
+
: `**${f.title}**\n\n${formatFindingDescriptionMarkdown(f.description)}`
|
|
449
|
+
unplacedBodies.push(`${anchor}\n\n${inner}`)
|
|
386
450
|
} else {
|
|
387
451
|
const body = strict
|
|
388
452
|
? formatConversationBody(strict)
|
|
@@ -406,20 +470,30 @@ export async function postFindingsAsReview(input: PostReviewInput): Promise<Post
|
|
|
406
470
|
}
|
|
407
471
|
const payload = JSON.stringify(payloadObj)
|
|
408
472
|
|
|
473
|
+
const repoSlug = input.repo ?? '{owner}/{repo}'
|
|
409
474
|
const command = [
|
|
410
475
|
bin,
|
|
411
476
|
'api',
|
|
412
|
-
`repos
|
|
477
|
+
`repos/${repoSlug}/pulls/${input.prNumber}/reviews`,
|
|
413
478
|
'--method',
|
|
414
479
|
'POST',
|
|
415
480
|
'--input',
|
|
416
481
|
'-',
|
|
417
482
|
]
|
|
418
483
|
|
|
484
|
+
const demoted = new Set(demotedIds)
|
|
485
|
+
const buildCommentResults = (status: 'posted' | 'failed', message?: string) =>
|
|
486
|
+
input.findingIds.map((id) => {
|
|
487
|
+
const base: PostReviewCommentResult = { id, status }
|
|
488
|
+
const m =
|
|
489
|
+
message ?? (demoted.has(id) ? 'anchor not in PR diff, posted in review body' : undefined)
|
|
490
|
+
return m ? { ...base, message: m } : base
|
|
491
|
+
})
|
|
492
|
+
|
|
419
493
|
if (input.dryRun) {
|
|
420
494
|
return {
|
|
421
495
|
reviewId: null,
|
|
422
|
-
comments:
|
|
496
|
+
comments: buildCommentResults('posted'),
|
|
423
497
|
command,
|
|
424
498
|
payload,
|
|
425
499
|
}
|
|
@@ -444,7 +518,7 @@ export async function postFindingsAsReview(input: PostReviewInput): Promise<Post
|
|
|
444
518
|
const msg = (err as Error).message ?? `cannot spawn ${bin}`
|
|
445
519
|
return {
|
|
446
520
|
reviewId: null,
|
|
447
|
-
comments:
|
|
521
|
+
comments: buildCommentResults('failed', msg),
|
|
448
522
|
command,
|
|
449
523
|
payload,
|
|
450
524
|
}
|
|
@@ -453,11 +527,7 @@ export async function postFindingsAsReview(input: PostReviewInput): Promise<Post
|
|
|
453
527
|
if (exit !== 0) {
|
|
454
528
|
return {
|
|
455
529
|
reviewId: null,
|
|
456
|
-
comments:
|
|
457
|
-
id,
|
|
458
|
-
status: 'failed' as const,
|
|
459
|
-
message: stderrText.trim(),
|
|
460
|
-
})),
|
|
530
|
+
comments: buildCommentResults('failed', stderrText.trim()),
|
|
461
531
|
command,
|
|
462
532
|
payload,
|
|
463
533
|
}
|
|
@@ -476,7 +546,7 @@ export async function postFindingsAsReview(input: PostReviewInput): Promise<Post
|
|
|
476
546
|
|
|
477
547
|
return {
|
|
478
548
|
reviewId,
|
|
479
|
-
comments:
|
|
549
|
+
comments: buildCommentResults('posted'),
|
|
480
550
|
command,
|
|
481
551
|
payload,
|
|
482
552
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { readdir, unlink } from 'node:fs/promises'
|
|
1
|
+
import { readdir, readFile, unlink } from 'node:fs/promises'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
3
|
import { type PostStatusMap, renderFindingsToDisk } from './render-findings.ts'
|
|
4
|
-
import { parseFinding } from './types.ts'
|
|
4
|
+
import { type PrFileEntry, parseFinding } from './types.ts'
|
|
5
5
|
|
|
6
6
|
export type RefreshResult = {
|
|
7
7
|
refreshed: boolean
|
|
@@ -60,6 +60,7 @@ export async function refreshFindings(runDir: string): Promise<RefreshResult> {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
let pr: { number: number; branch: string; headSha: string } | undefined
|
|
63
|
+
let files: PrFileEntry[] = []
|
|
63
64
|
try {
|
|
64
65
|
const prJson = (await Bun.file(join(runDir, 'pr.json')).json()) as Record<string, unknown>
|
|
65
66
|
const prNumber = Number(prJson.number ?? 0)
|
|
@@ -70,12 +71,23 @@ export async function refreshFindings(runDir: string): Promise<RefreshResult> {
|
|
|
70
71
|
headSha: String(prJson.headRefOid ?? '?'),
|
|
71
72
|
}
|
|
72
73
|
}
|
|
74
|
+
const filesArray = Array.isArray(prJson.files) ? (prJson.files as unknown[]) : []
|
|
75
|
+
files = filesArray.map((f) => {
|
|
76
|
+
const entry = f as Record<string, unknown>
|
|
77
|
+
return {
|
|
78
|
+
path: String(entry.path ?? ''),
|
|
79
|
+
additions: Number(entry.additions ?? 0),
|
|
80
|
+
deletions: Number(entry.deletions ?? 0),
|
|
81
|
+
}
|
|
82
|
+
})
|
|
73
83
|
} catch {
|
|
74
84
|
// optional file; archived runs may not include pr.json
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
const diff = await readFile(join(runDir, 'diff.patch'), 'utf8').catch(() => '')
|
|
88
|
+
|
|
77
89
|
await renderFindingsToDisk(
|
|
78
|
-
{ findings, postStatus, runId: basename(runDir), pr },
|
|
90
|
+
{ findings, postStatus, runId: basename(runDir), pr, files, diff },
|
|
79
91
|
join(screenDir, 'findings.html'),
|
|
80
92
|
)
|
|
81
93
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { appendFile, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { postFindingsAsReview } from './post-cmd.ts'
|
|
3
|
+
import { parseRepoFromUrl, postFindingsAsReview } from './post-cmd.ts'
|
|
4
4
|
|
|
5
5
|
export type ServerHandle = {
|
|
6
6
|
url: string
|
|
@@ -130,12 +130,23 @@ export async function startServer(input: StartServerInput): Promise<ServerHandle
|
|
|
130
130
|
const prJson = JSON.parse(await readFile(join(runDir, 'pr.json'), 'utf8')) as {
|
|
131
131
|
number: number
|
|
132
132
|
headRefOid: string
|
|
133
|
+
url?: string
|
|
134
|
+
}
|
|
135
|
+
const repo = prJson.url ? parseRepoFromUrl(prJson.url) : null
|
|
136
|
+
if (!repo) {
|
|
137
|
+
return new Response(
|
|
138
|
+
JSON.stringify({
|
|
139
|
+
error: 'Cannot resolve target repo from pr.json (missing or unparseable url).',
|
|
140
|
+
}),
|
|
141
|
+
{ status: 500, headers: { 'content-type': 'application/json' } },
|
|
142
|
+
)
|
|
133
143
|
}
|
|
134
144
|
const result = await postFindingsAsReview({
|
|
135
145
|
runDir,
|
|
136
146
|
findingIds: ids,
|
|
137
147
|
prNumber: prJson.number,
|
|
138
148
|
headSha: prJson.headRefOid,
|
|
149
|
+
repo,
|
|
139
150
|
dryRun: process.env.MAGPIE_DRY_RUN_POST === '1',
|
|
140
151
|
})
|
|
141
152
|
const status =
|
package/skills/magpie/skill.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magpie",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "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. Bundles a Bun CLI installed separately via the skill's install.sh. Use when the user asks to review a GitHub pull request.",
|
|
5
5
|
"author": "iceinvein",
|
|
6
6
|
"type": "prompt",
|