@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,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Install agent skills into AI coding tools",
5
5
  "author": "iceinvein",
6
6
  "license": "MIT",
@@ -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
- // Partition into inline (has file + line) and unplaced.
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
- if (f.line != null && f.file != null) {
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/{owner}/{repo}/pulls/${input.prNumber}/reviews`,
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: input.findingIds.map((id) => ({ id, status: 'posted' as const })),
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: input.findingIds.map((id) => ({ id, status: 'failed' as const, message: msg })),
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: input.findingIds.map((id) => ({
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: input.findingIds.map((id) => ({ id, status: 'posted' as const })),
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 =
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie",
3
- "version": "0.2.0",
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",