@weareikko/code-review 0.9.2 → 0.9.4
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 -1
- package/dist/{cli-D47EQ1qq.js → cli-C3kNr0rX.js} +1000 -649
- package/dist/cli-C3kNr0rX.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +4 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/diagnostics.d.ts +5 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/gitlab-review.d.ts +3 -0
- package/dist/gitlab-review.d.ts.map +1 -1
- package/dist/marketplaces.d.ts +63 -0
- package/dist/marketplaces.d.ts.map +1 -0
- package/dist/review.js +1 -1
- package/dist/skills.d.ts +33 -2
- package/dist/skills.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/cli-D47EQ1qq.js.map +0 -1
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import { mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
1
|
+
import { mkdir, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import nodeFs, { existsSync, readFileSync } from "node:fs";
|
|
5
5
|
import { getEnvApiKey, getModel } from "@earendil-works/pi-ai";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { parse } from "yaml";
|
|
9
9
|
import { execFile } from "node:child_process";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
|
+
import { tracingChannel } from "node:diagnostics_channel";
|
|
12
|
+
import { performance } from "node:perf_hooks";
|
|
11
13
|
import { Agent } from "@earendil-works/pi-agent-core";
|
|
12
14
|
import { createReadOnlyTools } from "@earendil-works/pi-coding-agent";
|
|
13
15
|
import { createTwoFilesPatch } from "diff";
|
|
14
16
|
import * as git from "isomorphic-git";
|
|
15
17
|
import { Type } from "typebox";
|
|
16
|
-
import { homedir } from "node:os";
|
|
17
|
-
import { parse } from "yaml";
|
|
18
18
|
import { SpanKind, SpanStatusCode, context, metrics, trace } from "@opentelemetry/api";
|
|
19
19
|
import { SeverityNumber, logs } from "@opentelemetry/api-logs";
|
|
20
20
|
//#region src/errors.ts
|
|
@@ -336,46 +336,875 @@ var GitHubClient = class {
|
|
|
336
336
|
responseBody: text,
|
|
337
337
|
hint: "Ensure the token can read pull-request review threads (pull-requests: read / repo scope)."
|
|
338
338
|
});
|
|
339
|
-
return parsed.data;
|
|
339
|
+
return parsed.data;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Return the database IDs of review comments that belong to a **settled**
|
|
343
|
+
* review thread — one that is either resolved or outdated. GitHub's REST
|
|
344
|
+
* comment endpoints omit both states; they are only exposed via GraphQL
|
|
345
|
+
* `reviewThreads.isResolved` / `isOutdated`. Callers use this set to mark
|
|
346
|
+
* normalized notes resolved so settled threads are excluded from summary
|
|
347
|
+
* carry-over and prior-thread context. Paginates over threads.
|
|
348
|
+
*
|
|
349
|
+
* Outdated counts as settled because GitHub, unlike GitLab, does not
|
|
350
|
+
* auto-resolve a thread when the line it anchors to changes: fixing a finding
|
|
351
|
+
* flips the thread to outdated but leaves `isResolved` false until someone
|
|
352
|
+
* manually resolves it. Treating outdated as settled mirrors GitLab's
|
|
353
|
+
* "automatically resolve outdated diff discussions" behaviour, so a fixed
|
|
354
|
+
* finding stops being re-listed under "Still open from earlier reviews" (#133).
|
|
355
|
+
*/
|
|
356
|
+
async listSettledReviewCommentIds(owner, repo, pull) {
|
|
357
|
+
const settled = /* @__PURE__ */ new Set();
|
|
358
|
+
let cursor = null;
|
|
359
|
+
let hasNext = true;
|
|
360
|
+
while (hasNext) {
|
|
361
|
+
const threads = (await this.graphql(REVIEW_THREADS_QUERY, {
|
|
362
|
+
owner,
|
|
363
|
+
repo,
|
|
364
|
+
pull,
|
|
365
|
+
cursor
|
|
366
|
+
})).repository?.pullRequest?.reviewThreads;
|
|
367
|
+
if (!threads) break;
|
|
368
|
+
for (const thread of threads.nodes ?? []) {
|
|
369
|
+
if (!thread.isResolved && !thread.isOutdated) continue;
|
|
370
|
+
for (const comment of thread.comments?.nodes ?? []) if (typeof comment.databaseId === "number") settled.add(comment.databaseId);
|
|
371
|
+
}
|
|
372
|
+
hasNext = threads.pageInfo?.hasNextPage ?? false;
|
|
373
|
+
cursor = threads.pageInfo?.endCursor ?? null;
|
|
374
|
+
if (!cursor) hasNext = false;
|
|
375
|
+
}
|
|
376
|
+
return settled;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
//#endregion
|
|
380
|
+
//#region src/git.ts
|
|
381
|
+
var exec$1 = promisify(execFile);
|
|
382
|
+
var DEFAULT_DIFF_CONTEXT = 20;
|
|
383
|
+
var DEFAULT_CODEQUALITY_ARTIFACTS = [
|
|
384
|
+
"gl-code-quality-report.json",
|
|
385
|
+
"codequality.json",
|
|
386
|
+
"codeclimate.json",
|
|
387
|
+
"code-quality-report.json"
|
|
388
|
+
];
|
|
389
|
+
function gitErrorMessage(error) {
|
|
390
|
+
const err = error;
|
|
391
|
+
return [
|
|
392
|
+
err.message,
|
|
393
|
+
err.stderr,
|
|
394
|
+
err.stdout
|
|
395
|
+
].filter(Boolean).join("\n").trim();
|
|
396
|
+
}
|
|
397
|
+
async function git$1(args, options = {}) {
|
|
398
|
+
try {
|
|
399
|
+
const { stdout } = await exec$1("git", args, {
|
|
400
|
+
cwd: options.cwd,
|
|
401
|
+
maxBuffer: 50 * 1024 * 1024
|
|
402
|
+
});
|
|
403
|
+
return stdout;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
throw new GitError(`git ${args.join(" ")} failed.`, {
|
|
406
|
+
cause: error,
|
|
407
|
+
hint: gitErrorMessage(error)
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function remoteRef(remote, branch) {
|
|
412
|
+
return `refs/remotes/${remote}/${branch}`;
|
|
413
|
+
}
|
|
414
|
+
function getMergeDiffArguments(targetBranch, options = {}) {
|
|
415
|
+
const remote = options.remote ?? "origin";
|
|
416
|
+
const context = options.context ?? DEFAULT_DIFF_CONTEXT;
|
|
417
|
+
return [
|
|
418
|
+
`${remoteRef(remote, targetBranch)}...HEAD`,
|
|
419
|
+
`--unified=${context}`,
|
|
420
|
+
"--"
|
|
421
|
+
];
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Full git argv for the merge diff. `-c core.quotepath=false` keeps non-ASCII
|
|
425
|
+
* paths literal instead of git's default octal-escaped + double-quoted form
|
|
426
|
+
* (`"a/caf\303\251.ts"`), which the comment-position parser cannot match —
|
|
427
|
+
* leaving such comments with invalid one-sided positions that 500 on
|
|
428
|
+
* `bulk_publish`. Diffing literally also gives the reviewer readable paths.
|
|
429
|
+
*/
|
|
430
|
+
function getMergeDiffCommand(targetBranch, options = {}) {
|
|
431
|
+
return [
|
|
432
|
+
"-c",
|
|
433
|
+
"core.quotepath=false",
|
|
434
|
+
"diff",
|
|
435
|
+
...getMergeDiffArguments(targetBranch, options)
|
|
436
|
+
];
|
|
437
|
+
}
|
|
438
|
+
async function fetchBranch(remote, branch, options) {
|
|
439
|
+
await git$1([
|
|
440
|
+
"fetch",
|
|
441
|
+
"--no-tags",
|
|
442
|
+
remote,
|
|
443
|
+
`+refs/heads/${branch}:${remoteRef(remote, branch)}`
|
|
444
|
+
], options);
|
|
445
|
+
}
|
|
446
|
+
async function isTracked(path, options) {
|
|
447
|
+
try {
|
|
448
|
+
await git$1([
|
|
449
|
+
"ls-files",
|
|
450
|
+
"--error-unmatch",
|
|
451
|
+
"--",
|
|
452
|
+
path
|
|
453
|
+
], options);
|
|
454
|
+
return true;
|
|
455
|
+
} catch {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
async function removeGeneratedCodeQualityArtifacts(paths = DEFAULT_CODEQUALITY_ARTIFACTS, options = {}) {
|
|
460
|
+
const removed = [];
|
|
461
|
+
for (const path of paths) {
|
|
462
|
+
if (await isTracked(path, options)) continue;
|
|
463
|
+
try {
|
|
464
|
+
await unlink(options.cwd ? join(options.cwd, path) : path);
|
|
465
|
+
removed.push(path);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (error.code !== "ENOENT") throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return removed;
|
|
471
|
+
}
|
|
472
|
+
async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
|
|
473
|
+
const remote = options.remote ?? "origin";
|
|
474
|
+
await removeGeneratedCodeQualityArtifacts(options.codeQualityArtifacts, options);
|
|
475
|
+
await git$1([
|
|
476
|
+
"fetch",
|
|
477
|
+
"--unshallow",
|
|
478
|
+
"--no-tags",
|
|
479
|
+
remote
|
|
480
|
+
], options).catch(() => void 0);
|
|
481
|
+
const fetchErrors = [];
|
|
482
|
+
for (const branch of [targetBranch, sourceBranch]) try {
|
|
483
|
+
await fetchBranch(remote, branch, options);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
fetchErrors.push(`${branch}: ${gitErrorMessage(error)}`);
|
|
486
|
+
}
|
|
487
|
+
if (fetchErrors.length === 2) throw new GitError(`Unable to fetch MR source/target branches from ${remote}.`, { hint: fetchErrors.join("\n") });
|
|
488
|
+
try {
|
|
489
|
+
await git$1([
|
|
490
|
+
"merge-base",
|
|
491
|
+
remoteRef(remote, targetBranch),
|
|
492
|
+
"HEAD"
|
|
493
|
+
], options);
|
|
494
|
+
} catch (error) {
|
|
495
|
+
const fetchDetail = fetchErrors.length > 0 ? `\nFetch warnings:\n${fetchErrors.join("\n")}` : "";
|
|
496
|
+
throw new GitError(`Unable to prepare Git history for MR review: merge-base ${remoteRef(remote, targetBranch)} HEAD failed.`, {
|
|
497
|
+
cause: error,
|
|
498
|
+
hint: `Set GIT_DEPTH: 0 or ensure ${remote}/${targetBranch} is fetchable.${fetchDetail}\n${gitErrorMessage(error)}`
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
async function getMergeDiff(targetBranch, options = {}) {
|
|
503
|
+
return git$1(getMergeDiffCommand(targetBranch, options), options);
|
|
504
|
+
}
|
|
505
|
+
function getMergeCommitLogArguments(targetBranch, options = {}) {
|
|
506
|
+
return [
|
|
507
|
+
`${remoteRef(options.remote ?? "origin", targetBranch)}...HEAD`,
|
|
508
|
+
"--pretty=tformat:commit %h%nAuthor: %an%nDate: %as%n%n%s%n%n%b",
|
|
509
|
+
"--reverse",
|
|
510
|
+
"--no-merges"
|
|
511
|
+
];
|
|
512
|
+
}
|
|
513
|
+
async function getMergeCommitLog(targetBranch, options = {}) {
|
|
514
|
+
return git$1(["log", ...getMergeCommitLogArguments(targetBranch, options)], options);
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Summarize a unified diff into file/line counts for telemetry. Counts one file
|
|
518
|
+
* per `diff --git` header and counts `+`/`-` lines only inside a hunk (after a
|
|
519
|
+
* `@@` header), so the `--- a/file` / `+++ b/file` header lines are excluded and
|
|
520
|
+
* a genuine content line whose text starts with `++`/`--` is still counted. Pure
|
|
521
|
+
* and allocation-light so it can run on the full merge diff without an extra git
|
|
522
|
+
* invocation.
|
|
523
|
+
*/
|
|
524
|
+
function summarizeDiff(diff) {
|
|
525
|
+
let filesChanged = 0;
|
|
526
|
+
let linesAdded = 0;
|
|
527
|
+
let linesRemoved = 0;
|
|
528
|
+
let inHunk = false;
|
|
529
|
+
for (const line of diff.split("\n")) if (line.startsWith("diff --git ")) {
|
|
530
|
+
filesChanged += 1;
|
|
531
|
+
inHunk = false;
|
|
532
|
+
} else if (line.startsWith("@@ ")) inHunk = true;
|
|
533
|
+
else if (inHunk && line.startsWith("+")) linesAdded += 1;
|
|
534
|
+
else if (inHunk && line.startsWith("-")) linesRemoved += 1;
|
|
535
|
+
return {
|
|
536
|
+
filesChanged,
|
|
537
|
+
linesAdded,
|
|
538
|
+
linesRemoved
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
//#endregion
|
|
542
|
+
//#region src/skills.ts
|
|
543
|
+
var SKILL_DIRS = [".agents/skills", ".claude/skills"];
|
|
544
|
+
var RESOURCE_DIRS = ["references"];
|
|
545
|
+
function parseFrontmatter(content) {
|
|
546
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
547
|
+
if (!match) return null;
|
|
548
|
+
let data;
|
|
549
|
+
try {
|
|
550
|
+
data = parse(match[1]);
|
|
551
|
+
} catch {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
if (!data || typeof data !== "object") return null;
|
|
555
|
+
const { name, description } = data;
|
|
556
|
+
if (typeof name !== "string" || typeof description !== "string") return null;
|
|
557
|
+
const trimmedName = name.trim();
|
|
558
|
+
const trimmedDescription = description.trim();
|
|
559
|
+
if (!trimmedName || !trimmedDescription) return null;
|
|
560
|
+
return {
|
|
561
|
+
name: trimmedName,
|
|
562
|
+
description: trimmedDescription
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
async function loadSkillFromDir(dirPath, source) {
|
|
566
|
+
const skillMdPath = join(dirPath, "SKILL.md");
|
|
567
|
+
let content;
|
|
568
|
+
try {
|
|
569
|
+
content = await readFile(skillMdPath, "utf8");
|
|
570
|
+
} catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
const parsed = parseFrontmatter(content);
|
|
574
|
+
if (!parsed) return null;
|
|
575
|
+
const resourceDirs = RESOURCE_DIRS.filter((d) => existsSync(join(dirPath, d)));
|
|
576
|
+
return {
|
|
577
|
+
name: parsed.name,
|
|
578
|
+
description: parsed.description,
|
|
579
|
+
filePath: skillMdPath,
|
|
580
|
+
rootDir: dirPath,
|
|
581
|
+
resourceDirs,
|
|
582
|
+
source
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function resolveBuiltinSkillsDir() {
|
|
586
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
|
|
587
|
+
}
|
|
588
|
+
async function loadBuiltinSkill(name) {
|
|
589
|
+
return loadSkillFromDir(join(resolveBuiltinSkillsDir(), name), "builtin");
|
|
590
|
+
}
|
|
591
|
+
async function loadAutoDiscoveredSkills(cwd, gitRoot, warn) {
|
|
592
|
+
const dirs = [];
|
|
593
|
+
let current = cwd;
|
|
594
|
+
while (true) {
|
|
595
|
+
dirs.unshift(current);
|
|
596
|
+
if (current === gitRoot) break;
|
|
597
|
+
const parent = dirname(current);
|
|
598
|
+
if (parent === current) break;
|
|
599
|
+
current = parent;
|
|
600
|
+
}
|
|
601
|
+
const found = /* @__PURE__ */ new Map();
|
|
602
|
+
for (const dir of dirs) for (const skillDir of SKILL_DIRS) {
|
|
603
|
+
const skillsPath = join(dir, skillDir);
|
|
604
|
+
let entries;
|
|
605
|
+
try {
|
|
606
|
+
entries = await readdir(skillsPath);
|
|
607
|
+
} catch {
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
for (const entry of entries) {
|
|
611
|
+
const entryPath = join(skillsPath, entry);
|
|
612
|
+
const skill = await loadSkillFromDir(entryPath, "project");
|
|
613
|
+
if (skill) found.set(skill.name, skill);
|
|
614
|
+
else if (warn && existsSync(join(entryPath, "SKILL.md"))) warn(`Skill at ${entryPath} has a SKILL.md but is missing required frontmatter fields (name, description) — skill not loaded.`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return [...found.values()];
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Parse a skill spec string into a typed `SkillSpec` descriptor.
|
|
621
|
+
*
|
|
622
|
+
* Supported spec formats:
|
|
623
|
+
*
|
|
624
|
+
* | Input | Result |
|
|
625
|
+
* |------------------------------------|---------------------------------------------------|
|
|
626
|
+
* | `code-review` | `{ protocol: 'builtin', name: 'code-review' }` |
|
|
627
|
+
* | `npm:my-skill` | `{ protocol: 'npm', packageName: 'my-skill', ... }`|
|
|
628
|
+
* | `npm:@scope/pkg` | `{ protocol: 'npm', packageName: '@scope/pkg', ... }`|
|
|
629
|
+
* | `npm:@scope/bundle/security` | `{ protocol: 'npm', packageName: '@scope/bundle', subpath: 'security' }`|
|
|
630
|
+
* | `npm:bundle/security` | `{ protocol: 'npm', packageName: 'bundle', subpath: 'security' }`|
|
|
631
|
+
* | `file:./path/to/skill` | `{ protocol: 'file', path: './path/to/skill' }` |
|
|
632
|
+
* | `file:/absolute/path` | `{ protocol: 'file', path: '/absolute/path' }` |
|
|
633
|
+
* | `git:https://host/org/s.git` | `{ protocol: 'git', url: 'https://host/org/s.git', ref: '', subpath: '' }` |
|
|
634
|
+
* | `git:https://host/org/b.git#v1/sec`| `{ protocol: 'git', url: 'https://host/org/b.git', ref: 'v1', subpath: 'sec' }` |
|
|
635
|
+
* | `git+ssh://git@host/org/s.git` | `{ protocol: 'git', url: 'ssh://git@host/org/s.git', ref: '', subpath: '' }` |
|
|
636
|
+
* | `acme:dev/aria-apg` (acme known) | `{ protocol: 'marketplace', marketplace: 'acme', plugin: 'dev', skill: 'aria-apg' }` |
|
|
637
|
+
*
|
|
638
|
+
* A spec whose portion before the first `:` matches a registered marketplace
|
|
639
|
+
* name (from `knownMarketplaces`) is parsed as a `marketplace` reference. The
|
|
640
|
+
* remainder must be `<plugin>/<skill>` — an explicit skill is always required.
|
|
641
|
+
*/
|
|
642
|
+
function parseSkillSpec(spec, knownMarketplaces = /* @__PURE__ */ new Set()) {
|
|
643
|
+
if (spec.startsWith("file:")) return {
|
|
644
|
+
protocol: "file",
|
|
645
|
+
path: spec.slice(5)
|
|
646
|
+
};
|
|
647
|
+
if (spec.startsWith("npm:")) {
|
|
648
|
+
const rest = spec.slice(4);
|
|
649
|
+
if (rest.startsWith("@")) {
|
|
650
|
+
const parts = rest.split("/");
|
|
651
|
+
if (parts.length < 2) return {
|
|
652
|
+
protocol: "npm",
|
|
653
|
+
packageName: rest,
|
|
654
|
+
subpath: ""
|
|
655
|
+
};
|
|
656
|
+
return {
|
|
657
|
+
protocol: "npm",
|
|
658
|
+
packageName: `${parts[0]}/${parts[1]}`,
|
|
659
|
+
subpath: parts.slice(2).join("/")
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
const slashIdx = rest.indexOf("/");
|
|
663
|
+
if (slashIdx === -1) return {
|
|
664
|
+
protocol: "npm",
|
|
665
|
+
packageName: rest,
|
|
666
|
+
subpath: ""
|
|
667
|
+
};
|
|
668
|
+
return {
|
|
669
|
+
protocol: "npm",
|
|
670
|
+
packageName: rest.slice(0, slashIdx),
|
|
671
|
+
subpath: rest.slice(slashIdx + 1)
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
if (spec.startsWith("git+") || spec.startsWith("git:")) return parseGitSpec(spec);
|
|
675
|
+
const colonIdx = spec.indexOf(":");
|
|
676
|
+
if (colonIdx > 0 && knownMarketplaces.has(spec.slice(0, colonIdx))) return parseMarketplaceSkillSpec(spec, colonIdx);
|
|
677
|
+
return {
|
|
678
|
+
protocol: "builtin",
|
|
679
|
+
name: spec
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Parse the `<marketplace>:<plugin>/<skill>` selector into its parts. The
|
|
684
|
+
* marketplace name has already been matched against the registry by the caller;
|
|
685
|
+
* `colonIdx` is the index of the separating `:`. Throws a `ConfigError` when the
|
|
686
|
+
* `<plugin>/<skill>` remainder is malformed (a skill name is always required).
|
|
687
|
+
*/
|
|
688
|
+
function parseMarketplaceSkillSpec(spec, colonIdx) {
|
|
689
|
+
const marketplace = spec.slice(0, colonIdx);
|
|
690
|
+
const rest = spec.slice(colonIdx + 1);
|
|
691
|
+
const slashIdx = rest.indexOf("/");
|
|
692
|
+
const plugin = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
|
|
693
|
+
const skill = slashIdx === -1 ? "" : rest.slice(slashIdx + 1);
|
|
694
|
+
if (!plugin || !skill || skill.includes("/")) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `Marketplace skills use the form "<marketplace>:<plugin>/<skill>", e.g. "${marketplace}:dev/aria-apg".` });
|
|
695
|
+
return {
|
|
696
|
+
protocol: "marketplace",
|
|
697
|
+
marketplace,
|
|
698
|
+
plugin,
|
|
699
|
+
skill
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Parse a `git:` / `git+ssh:` skill spec into its URL, pinned ref, and subpath.
|
|
704
|
+
*
|
|
705
|
+
* - `git:<url>` strips the `git:` marker; what follows is the clone URL
|
|
706
|
+
* (e.g. `git:https://host/org/repo.git`).
|
|
707
|
+
* - `git+<transport>://…` strips the leading `git+`, leaving a URL git
|
|
708
|
+
* understands directly (`git+ssh://git@host/…` → `ssh://git@host/…`), matching
|
|
709
|
+
* npm's `package.json` git-dependency convention.
|
|
710
|
+
*
|
|
711
|
+
* An optional `#<ref>[/<subpath>]` fragment pins the ref (tag, branch, or
|
|
712
|
+
* commit) and, after the first `/`, points at a skill directory inside the repo.
|
|
713
|
+
*/
|
|
714
|
+
function parseGitSpec(spec) {
|
|
715
|
+
const raw = spec.startsWith("git+") ? spec.slice(4) : spec.slice(4);
|
|
716
|
+
let parsed;
|
|
717
|
+
try {
|
|
718
|
+
parsed = new URL(raw);
|
|
719
|
+
} catch {
|
|
720
|
+
return {
|
|
721
|
+
protocol: "git",
|
|
722
|
+
url: raw,
|
|
723
|
+
ref: "",
|
|
724
|
+
subpath: ""
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
const fragment = parsed.hash ? parsed.hash.slice(1) : "";
|
|
728
|
+
parsed.hash = "";
|
|
729
|
+
const url = parsed.toString();
|
|
730
|
+
const slashIdx = fragment.indexOf("/");
|
|
731
|
+
if (slashIdx === -1) return {
|
|
732
|
+
protocol: "git",
|
|
733
|
+
url,
|
|
734
|
+
ref: fragment,
|
|
735
|
+
subpath: ""
|
|
736
|
+
};
|
|
737
|
+
return {
|
|
738
|
+
protocol: "git",
|
|
739
|
+
url,
|
|
740
|
+
ref: fragment.slice(0, slashIdx),
|
|
741
|
+
subpath: fragment.slice(slashIdx + 1)
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Resolve the directory for an npm-installed skill by walking `node_modules`
|
|
746
|
+
* upward from `cwd` (supports monorepo hoisting). Returns the resolved path
|
|
747
|
+
* or `null` if the package / subpath cannot be found.
|
|
748
|
+
*/
|
|
749
|
+
async function resolveNpmSkillDir(packageName, subpath, cwd) {
|
|
750
|
+
let current = cwd;
|
|
751
|
+
while (true) {
|
|
752
|
+
const candidate = subpath ? join(current, "node_modules", packageName, subpath) : join(current, "node_modules", packageName);
|
|
753
|
+
if (existsSync(candidate)) return candidate;
|
|
754
|
+
const parent = dirname(current);
|
|
755
|
+
if (parent === current) break;
|
|
756
|
+
current = parent;
|
|
757
|
+
}
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
/** Strip a leading `git+` transport marker (`git+ssh://…` → `ssh://…`). */
|
|
761
|
+
function normalizeGitUrl(raw) {
|
|
762
|
+
return raw.startsWith("git+") ? raw.slice(4) : raw;
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Remove embedded credentials from a URL so it is safe to show in logs, hints,
|
|
766
|
+
* and errors. `https://user:token@host/path` becomes `https://***@host/path`.
|
|
767
|
+
* Non-URL strings are returned with a best-effort `//user@` → `//***@` scrub.
|
|
768
|
+
*/
|
|
769
|
+
function redactUrl(url) {
|
|
770
|
+
try {
|
|
771
|
+
const parsed = new URL(url);
|
|
772
|
+
if (parsed.username || parsed.password) {
|
|
773
|
+
parsed.username = "***";
|
|
774
|
+
parsed.password = "";
|
|
775
|
+
}
|
|
776
|
+
return parsed.toString();
|
|
777
|
+
} catch {
|
|
778
|
+
return url.replace(/\/\/[^/@]+@/, "//***@");
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/** Base directory for cached git-skill clones (honours `XDG_CACHE_HOME`). */
|
|
782
|
+
function resolveSkillCacheDir() {
|
|
783
|
+
return join(process.env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "code-review", "skills");
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Stable cache-directory name for a git skill. Keyed on the clone URL plus the
|
|
787
|
+
* pinned ref so that two refs of the same repo never share a cache entry.
|
|
788
|
+
*/
|
|
789
|
+
function gitSkillCacheKey(url, ref) {
|
|
790
|
+
return createHash("sha256").update(`${url}#${ref}`).digest("hex").slice(0, 16);
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Shallow-clone `url` at `ref` into `dir`. Using init + a single-ref fetch +
|
|
794
|
+
* `checkout FETCH_HEAD` (rather than `clone --branch`) means a branch, tag, or
|
|
795
|
+
* commit SHA all resolve through the same path; an empty `ref` fetches the
|
|
796
|
+
* remote's default branch via `HEAD`.
|
|
797
|
+
*/
|
|
798
|
+
async function gitShallowClone(url, ref, dir) {
|
|
799
|
+
await git$1([
|
|
800
|
+
"init",
|
|
801
|
+
"--quiet",
|
|
802
|
+
dir
|
|
803
|
+
]);
|
|
804
|
+
await git$1([
|
|
805
|
+
"remote",
|
|
806
|
+
"add",
|
|
807
|
+
"origin",
|
|
808
|
+
url
|
|
809
|
+
], { cwd: dir });
|
|
810
|
+
await git$1([
|
|
811
|
+
"fetch",
|
|
812
|
+
"--depth",
|
|
813
|
+
"1",
|
|
814
|
+
"--quiet",
|
|
815
|
+
"origin",
|
|
816
|
+
ref || "HEAD"
|
|
817
|
+
], { cwd: dir });
|
|
818
|
+
await git$1([
|
|
819
|
+
"checkout",
|
|
820
|
+
"--quiet",
|
|
821
|
+
"FETCH_HEAD"
|
|
822
|
+
], { cwd: dir });
|
|
823
|
+
}
|
|
824
|
+
/** Monotonic counter making each clone's temp dir unique within the process. */
|
|
825
|
+
var cloneSeq = 0;
|
|
826
|
+
/**
|
|
827
|
+
* Resolve a git URL + ref to a local clone directory, reusing the on-disk cache
|
|
828
|
+
* when possible. The clone lands in a temp sibling first and is renamed into
|
|
829
|
+
* place atomically, so a crashed or concurrent clone never leaves a half-written
|
|
830
|
+
* cache entry. With `refresh`, any cached copy is discarded first. Shared by
|
|
831
|
+
* `git:` skills and Claude-plugin marketplaces (see `marketplaces.ts`).
|
|
832
|
+
*/
|
|
833
|
+
async function cloneGitRepo(url, ref, options) {
|
|
834
|
+
const repoDir = join(options.cacheDir, gitSkillCacheKey(url, ref));
|
|
835
|
+
if (options.refresh) await rm(repoDir, {
|
|
836
|
+
recursive: true,
|
|
837
|
+
force: true
|
|
838
|
+
});
|
|
839
|
+
else if (existsSync(join(repoDir, ".git"))) return repoDir;
|
|
840
|
+
else if (existsSync(repoDir)) await rm(repoDir, {
|
|
841
|
+
recursive: true,
|
|
842
|
+
force: true
|
|
843
|
+
});
|
|
844
|
+
await mkdir(options.cacheDir, { recursive: true });
|
|
845
|
+
const tmpDir = `${repoDir}.tmp-${process.pid}-${cloneSeq += 1}`;
|
|
846
|
+
await rm(tmpDir, {
|
|
847
|
+
recursive: true,
|
|
848
|
+
force: true
|
|
849
|
+
});
|
|
850
|
+
try {
|
|
851
|
+
await gitShallowClone(url, ref, tmpDir);
|
|
852
|
+
try {
|
|
853
|
+
await rename(tmpDir, repoDir);
|
|
854
|
+
} catch (error) {
|
|
855
|
+
if (existsSync(join(repoDir, ".git"))) {
|
|
856
|
+
await rm(tmpDir, {
|
|
857
|
+
recursive: true,
|
|
858
|
+
force: true
|
|
859
|
+
});
|
|
860
|
+
return repoDir;
|
|
861
|
+
}
|
|
862
|
+
throw error;
|
|
863
|
+
}
|
|
864
|
+
} catch (error) {
|
|
865
|
+
await rm(tmpDir, {
|
|
866
|
+
recursive: true,
|
|
867
|
+
force: true
|
|
868
|
+
});
|
|
869
|
+
throw error;
|
|
870
|
+
}
|
|
871
|
+
return repoDir;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Load a skill by its spec string (`code-review`, `npm:@scope/pkg`, `file:./path`,
|
|
875
|
+
* `git:https://…`, …).
|
|
876
|
+
*
|
|
877
|
+
* Resolution order:
|
|
878
|
+
* 1. `builtin` — package-bundled `skills/<name>/`
|
|
879
|
+
* 2. `npm:` — `node_modules/<packageName>[/subpath]` walked up from `cwd`
|
|
880
|
+
* 3. `file:` — direct filesystem path (relative paths resolved from `cwd`)
|
|
881
|
+
* 4. `git:` / `git+ssh:` — shallow clone at the pinned ref, cached on disk,
|
|
882
|
+
* loading `SKILL.md` from the repo root or the `#<ref>/<subpath>` directory
|
|
883
|
+
*
|
|
884
|
+
* Throws a `ConfigError` if the spec cannot be resolved or the resolved
|
|
885
|
+
* directory does not contain a valid `SKILL.md`.
|
|
886
|
+
*/
|
|
887
|
+
async function loadNamedSkill(spec, cwd, options = {}) {
|
|
888
|
+
const parsed = parseSkillSpec(spec);
|
|
889
|
+
if (parsed.protocol === "builtin") {
|
|
890
|
+
const skill = await loadBuiltinSkill(parsed.name);
|
|
891
|
+
if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `No built-in skill named "${parsed.name}" was found. Check the skill name, or use npm: / file: to reference external skills.` });
|
|
892
|
+
return skill;
|
|
893
|
+
}
|
|
894
|
+
if (parsed.protocol === "npm") {
|
|
895
|
+
const dir = await resolveNpmSkillDir(parsed.packageName, parsed.subpath, cwd);
|
|
896
|
+
if (dir === null) {
|
|
897
|
+
const pkgRef = parsed.subpath ? `${parsed.packageName} (subpath "${parsed.subpath}")` : parsed.packageName;
|
|
898
|
+
throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `Package ${pkgRef} was not found in node_modules. Run \`npm install ${parsed.packageName}\` in the project.` });
|
|
899
|
+
}
|
|
900
|
+
const skill = await loadSkillFromDir(dir, "npm");
|
|
901
|
+
if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `The package at ${dir} does not contain a valid SKILL.md.` });
|
|
902
|
+
return skill;
|
|
903
|
+
}
|
|
904
|
+
if (parsed.protocol === "file") {
|
|
905
|
+
const resolvedPath = parsed.path.startsWith("/") ? parsed.path : join(cwd, parsed.path);
|
|
906
|
+
const skill = await loadSkillFromDir(resolvedPath, "file");
|
|
907
|
+
if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `No valid SKILL.md was found at "${resolvedPath}". Check that the path points to a skill directory.` });
|
|
908
|
+
return skill;
|
|
909
|
+
}
|
|
910
|
+
if (parsed.protocol === "marketplace") throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: "Marketplace skills must be resolved through a registered marketplace, not loaded directly." });
|
|
911
|
+
let repoDir;
|
|
912
|
+
try {
|
|
913
|
+
repoDir = await cloneGitRepo(parsed.url, parsed.ref, {
|
|
914
|
+
cacheDir: options.cacheDir ?? resolveSkillCacheDir(),
|
|
915
|
+
refresh: options.refresh ?? false
|
|
916
|
+
});
|
|
917
|
+
} catch (error) {
|
|
918
|
+
const atRef = parsed.ref ? ` at ref "${parsed.ref}"` : "";
|
|
919
|
+
throw new ConfigError(`Cannot load skill: "${spec}"`, {
|
|
920
|
+
cause: error,
|
|
921
|
+
hint: `Failed to clone "${redactUrl(parsed.url)}"${atRef}. Check the URL, the ref, and your git credentials. For GitLab, prefer the SSH form: git+ssh://git@host/group/project.git`
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
const skill = await loadSkillFromDir(parsed.subpath ? join(repoDir, parsed.subpath) : repoDir, "git");
|
|
925
|
+
if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: parsed.subpath ? `The cloned repository has no valid SKILL.md at subpath "${parsed.subpath}".` : "The cloned repository has no valid SKILL.md at its root. If the skill lives in a subdirectory, point at it with \"#<ref>/<subpath>\"." });
|
|
926
|
+
return skill;
|
|
927
|
+
}
|
|
928
|
+
//#endregion
|
|
929
|
+
//#region src/marketplaces.ts
|
|
930
|
+
/**
|
|
931
|
+
* Marketplace manifest formats we know how to read. Different vendors ship the
|
|
932
|
+
* same open SKILL.md standard but package it differently: Claude Code uses a
|
|
933
|
+
* `.claude-plugin/marketplace.json` catalog (`anthropic`), OpenAI Codex uses a
|
|
934
|
+
* `.codex-plugin/` layout (a future `codex` format). Only `anthropic` is
|
|
935
|
+
* implemented today; the type is the extension point for the rest.
|
|
936
|
+
*/
|
|
937
|
+
var MARKETPLACE_FORMATS = ["anthropic"];
|
|
938
|
+
/** The default format assumed when a marketplace declaration omits a prefix. */
|
|
939
|
+
var DEFAULT_MARKETPLACE_FORMAT = "anthropic";
|
|
940
|
+
/**
|
|
941
|
+
* Marketplace names that collide with skill-spec protocol prefixes
|
|
942
|
+
* (`npm:`, `file:`, `git:`) or the bare-name builtin lookup, and so cannot be
|
|
943
|
+
* used as a marketplace name — `<name>:<plugin>/<skill>` would be ambiguous.
|
|
944
|
+
*/
|
|
945
|
+
var RESERVED_MARKETPLACE_NAMES = new Set([
|
|
946
|
+
"npm",
|
|
947
|
+
"file",
|
|
948
|
+
"git",
|
|
949
|
+
"builtin"
|
|
950
|
+
]);
|
|
951
|
+
var NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
952
|
+
var FORMAT_PREFIX_PATTERN = /^([a-z][a-z0-9-]*):(?!\/\/)/;
|
|
953
|
+
/**
|
|
954
|
+
* Parse a single marketplace declaration: `<name>=<[format:]url[#ref]>`.
|
|
955
|
+
*
|
|
956
|
+
* - `acme=https://host/group/tools.git#1.0.0` → default `anthropic` format
|
|
957
|
+
* - `acme=anthropic:https://host/group/tools.git#1.0.0` → explicit format
|
|
958
|
+
* - `acme=git+ssh://git@host/group/tools.git#main` → SSH transport
|
|
959
|
+
*
|
|
960
|
+
* The `#<ref>` fragment is taken whole as the ref (branch names may contain
|
|
961
|
+
* `/`). Throws a `ConfigError` with an actionable hint on any malformed input.
|
|
962
|
+
*/
|
|
963
|
+
function parseMarketplaceEntry(entry) {
|
|
964
|
+
const eqIdx = entry.indexOf("=");
|
|
965
|
+
if (eqIdx <= 0) throw new ConfigError(`Invalid marketplace declaration: "${redactUrl(entry)}"`, { hint: "Use <name>=<git-url>[#ref], e.g. acme=https://host/group/tools.git#1.0.0." });
|
|
966
|
+
const name = entry.slice(0, eqIdx).trim();
|
|
967
|
+
if (!NAME_PATTERN.test(name)) throw new ConfigError(`Invalid marketplace name: "${name}"`, { hint: "Names must start with a letter or digit and contain only letters, digits, \".\", \"_\", or \"-\"." });
|
|
968
|
+
if (RESERVED_MARKETPLACE_NAMES.has(name)) throw new ConfigError(`Marketplace name "${name}" is reserved`, { hint: `Choose another name — ${[...RESERVED_MARKETPLACE_NAMES].join(", ")} collide with skill-spec protocols.` });
|
|
969
|
+
let rhs = entry.slice(eqIdx + 1).trim();
|
|
970
|
+
let format = DEFAULT_MARKETPLACE_FORMAT;
|
|
971
|
+
const formatMatch = rhs.match(FORMAT_PREFIX_PATTERN);
|
|
972
|
+
if (formatMatch) {
|
|
973
|
+
const token = formatMatch[1];
|
|
974
|
+
if (!MARKETPLACE_FORMATS.includes(token)) throw new ConfigError(`Unknown marketplace format "${token}" in "${redactUrl(entry)}"`, { hint: `Supported formats: ${MARKETPLACE_FORMATS.join(", ")}. Omit the prefix (e.g. "${name}=https://…") to default to "${DEFAULT_MARKETPLACE_FORMAT}".` });
|
|
975
|
+
format = token;
|
|
976
|
+
rhs = rhs.slice(formatMatch[0].length);
|
|
977
|
+
}
|
|
978
|
+
let parsed;
|
|
979
|
+
try {
|
|
980
|
+
parsed = new URL(normalizeGitUrl(rhs));
|
|
981
|
+
} catch {
|
|
982
|
+
throw new ConfigError(`Invalid marketplace URL for "${name}": "${redactUrl(rhs)}"`, { hint: "Expected a git URL like https://host/group/tools.git or git+ssh://git@host/group/tools.git. scp-style (git@host:group/repo.git) is not supported — use the ssh:// form." });
|
|
983
|
+
}
|
|
984
|
+
const ref = parsed.hash ? parsed.hash.slice(1) : "";
|
|
985
|
+
parsed.hash = "";
|
|
986
|
+
return {
|
|
987
|
+
name,
|
|
988
|
+
format,
|
|
989
|
+
url: parsed.toString(),
|
|
990
|
+
ref
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Build a name→ref registry from parsed marketplace declarations, rejecting
|
|
995
|
+
* duplicate names (a later entry silently shadowing an earlier one is a config
|
|
996
|
+
* bug worth surfacing).
|
|
997
|
+
*/
|
|
998
|
+
function buildMarketplaceRegistry(refs) {
|
|
999
|
+
const registry = /* @__PURE__ */ new Map();
|
|
1000
|
+
for (const ref of refs) {
|
|
1001
|
+
if (registry.has(ref.name)) throw new ConfigError(`Duplicate marketplace name "${ref.name}"`, { hint: "Each marketplace must be declared once. Remove the duplicate declaration." });
|
|
1002
|
+
registry.set(ref.name, ref);
|
|
1003
|
+
}
|
|
1004
|
+
return registry;
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Resolve a `<marketplace>:<plugin>/<skill>` reference to a loaded {@link Skill}.
|
|
1008
|
+
*
|
|
1009
|
+
* Clones the marketplace repo (reusing the shared on-disk clone cache), then
|
|
1010
|
+
* dispatches to the format-specific resolver. Throws a `ConfigError` with a
|
|
1011
|
+
* redacted, actionable hint when the marketplace, plugin, or skill cannot be
|
|
1012
|
+
* resolved — the caller treats that as a skip-and-warn, not a fatal error.
|
|
1013
|
+
*/
|
|
1014
|
+
async function loadMarketplaceSkill(spec, registry, options = {}) {
|
|
1015
|
+
const ref = `${spec.marketplace}:${spec.plugin}/${spec.skill}`;
|
|
1016
|
+
const mp = registry.get(spec.marketplace);
|
|
1017
|
+
if (!mp) throw new ConfigError(`Cannot load skill: "${ref}"`, { hint: `No marketplace named "${spec.marketplace}" is registered. Declare it with CODE_REVIEW_MARKETPLACES or --marketplace.` });
|
|
1018
|
+
let repoDir;
|
|
1019
|
+
try {
|
|
1020
|
+
repoDir = await cloneGitRepo(mp.url, mp.ref, {
|
|
1021
|
+
cacheDir: options.cacheDir ?? resolveSkillCacheDir(),
|
|
1022
|
+
refresh: options.refresh ?? false
|
|
1023
|
+
});
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
const atRef = mp.ref ? ` at ref "${mp.ref}"` : "";
|
|
1026
|
+
throw new ConfigError(`Cannot load marketplace "${mp.name}"`, {
|
|
1027
|
+
cause: error,
|
|
1028
|
+
hint: `Failed to clone "${redactUrl(mp.url)}"${atRef}. Check the URL, the ref, and your git credentials. For private GitLab, prefer git+ssh://git@host/group/project.git.`
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
switch (mp.format) {
|
|
1032
|
+
case "anthropic": return resolveAnthropicSkill(repoDir, mp, spec);
|
|
1033
|
+
default: {
|
|
1034
|
+
const exhaustive = mp.format;
|
|
1035
|
+
throw new ConfigError(`Unsupported marketplace format: "${String(exhaustive)}"`, { hint: `Supported formats: ${MARKETPLACE_FORMATS.join(", ")}.` });
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
/** Resolve a skill inside a Claude Code (`anthropic`) plugin marketplace. */
|
|
1040
|
+
async function resolveAnthropicSkill(repoDir, mp, spec) {
|
|
1041
|
+
const ref = `${mp.name}:${spec.plugin}/${spec.skill}`;
|
|
1042
|
+
const realRoot = await realpath(repoDir);
|
|
1043
|
+
const manifestRaw = await readTextInside(realRoot, join(repoDir, ".claude-plugin", "marketplace.json"), ref);
|
|
1044
|
+
if (manifestRaw === null) throw new ConfigError(`Marketplace "${mp.name}" is not a valid Anthropic plugin marketplace`, { hint: `Expected a readable .claude-plugin/marketplace.json at "${redactUrl(mp.url)}"${mp.ref ? ` (ref "${mp.ref}")` : ""}.` });
|
|
1045
|
+
let manifest;
|
|
1046
|
+
try {
|
|
1047
|
+
manifest = JSON.parse(manifestRaw);
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
throw new ConfigError(`Marketplace "${mp.name}" is not a valid Anthropic plugin marketplace`, {
|
|
1050
|
+
cause: error,
|
|
1051
|
+
hint: `.claude-plugin/marketplace.json at "${redactUrl(mp.url)}" is not valid JSON.`
|
|
1052
|
+
});
|
|
340
1053
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
for (const comment of thread.comments?.nodes ?? []) if (typeof comment.databaseId === "number") settled.add(comment.databaseId);
|
|
371
|
-
}
|
|
372
|
-
hasNext = threads.pageInfo?.hasNextPage ?? false;
|
|
373
|
-
cursor = threads.pageInfo?.endCursor ?? null;
|
|
374
|
-
if (!cursor) hasNext = false;
|
|
375
|
-
}
|
|
376
|
-
return settled;
|
|
1054
|
+
const plugins = Array.isArray(manifest.plugins) ? manifest.plugins : [];
|
|
1055
|
+
const entry = plugins.find((p) => p && p.name === spec.plugin);
|
|
1056
|
+
if (!entry) {
|
|
1057
|
+
const available = plugins.map((p) => typeof p?.name === "string" ? p.name : null).filter((n) => Boolean(n));
|
|
1058
|
+
throw new ConfigError(`Plugin "${spec.plugin}" not found in marketplace "${mp.name}"`, { hint: available.length ? `Available plugins: ${available.join(", ")}.` : "The marketplace lists no plugins." });
|
|
1059
|
+
}
|
|
1060
|
+
const pluginDir = resolvePluginDir(repoDir, manifest, entry.source, mp, spec);
|
|
1061
|
+
const realPluginDir = await resolveInside(realRoot, pluginDir, ref);
|
|
1062
|
+
const bases = computeSkillBases(repoDir, pluginDir, entry, entry.strict !== false && realPluginDir ? await readPluginManifest(realRoot, realPluginDir, ref) : null, ref);
|
|
1063
|
+
const skill = await findSkillInBases(realRoot, bases, pluginDir, spec.skill, ref);
|
|
1064
|
+
if (!skill) throw new ConfigError(`Cannot load skill: "${ref}"`, { hint: `No skill "${spec.skill}" found in plugin "${spec.plugin}". Looked under the default skills/ directory${bases.length > 1 ? " and the plugin's custom \"skills\" paths" : ""}. Check the skill name against the marketplace.` });
|
|
1065
|
+
return skill;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Read a plugin's optional `.claude-plugin/plugin.json`. A missing file returns
|
|
1069
|
+
* `null` (the manifest is optional), but a malformed one throws a `ConfigError`
|
|
1070
|
+
* rather than being silently ignored — otherwise a typo would quietly drop the
|
|
1071
|
+
* plugin's declared `skills` paths.
|
|
1072
|
+
*/
|
|
1073
|
+
async function readPluginManifest(realRoot, pluginDir, ref) {
|
|
1074
|
+
const raw = await readTextInside(realRoot, join(pluginDir, ".claude-plugin", "plugin.json"), ref);
|
|
1075
|
+
if (raw === null) return null;
|
|
1076
|
+
try {
|
|
1077
|
+
return JSON.parse(raw);
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
throw new ConfigError(`Malformed plugin.json for "${ref}"`, {
|
|
1080
|
+
cause: error,
|
|
1081
|
+
hint: "The plugin's .claude-plugin/plugin.json is not valid JSON. Fix it in the marketplace repository."
|
|
1082
|
+
});
|
|
377
1083
|
}
|
|
378
|
-
}
|
|
1084
|
+
}
|
|
1085
|
+
/** Normalize a `skills`/`commands` manifest field (string | string[]) to a path list. */
|
|
1086
|
+
function toPathArray(value) {
|
|
1087
|
+
return (typeof value === "string" ? [value] : Array.isArray(value) ? value : []).filter((v) => typeof v === "string").map((v) => v.trim()).filter(Boolean);
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Compute the directories to search for a named skill, per the plugin schema:
|
|
1091
|
+
* the default `<plugin>/skills/` scan plus any directories declared in the
|
|
1092
|
+
* `skills` field of the marketplace entry and (unless `strict: false`) the
|
|
1093
|
+
* plugin's own `plugin.json`. When the plugin source resolves to the marketplace
|
|
1094
|
+
* root and the entry lists specific `skills` subdirectories, those replace the
|
|
1095
|
+
* default scan rather than adding to it.
|
|
1096
|
+
*/
|
|
1097
|
+
function computeSkillBases(repoDir, pluginDir, entry, pluginManifest, ref) {
|
|
1098
|
+
const strict = entry.strict !== false;
|
|
1099
|
+
const entrySkills = toPathArray(entry.skills);
|
|
1100
|
+
const custom = (strict ? [...entrySkills, ...toPathArray(pluginManifest?.skills)] : entrySkills).map((p) => resolveUnderPlugin(repoDir, pluginDir, p, ref));
|
|
1101
|
+
const bases = resolve(pluginDir) === resolve(repoDir) && entrySkills.length > 0 ? custom : [join(pluginDir, "skills"), ...custom];
|
|
1102
|
+
return [...new Set(bases)];
|
|
1103
|
+
}
|
|
1104
|
+
/** Resolve a plugin-relative `skills` path to an absolute dir, guarding escapes. */
|
|
1105
|
+
function resolveUnderPlugin(repoDir, pluginDir, rel, ref) {
|
|
1106
|
+
let p = rel.trim();
|
|
1107
|
+
if (p === "." || p === "./") p = "";
|
|
1108
|
+
else if (p.startsWith("./")) p = p.slice(2);
|
|
1109
|
+
const dir = join(pluginDir, p);
|
|
1110
|
+
ensureInside(repoDir, dir, ref);
|
|
1111
|
+
return dir;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Find `skillName` across the candidate base directories. Each base is either a
|
|
1115
|
+
* container of skill sub-directories (`<base>/<skill>/SKILL.md`) or a single
|
|
1116
|
+
* skill directory (`<base>/SKILL.md`, matched by its frontmatter `name`). Falls
|
|
1117
|
+
* back to a single `SKILL.md` at the plugin root (single-skill plugins).
|
|
1118
|
+
*
|
|
1119
|
+
* Every candidate is verified with {@link loadSkillIfInside} to resolve through
|
|
1120
|
+
* symlinks and reject any directory that escapes the cloned marketplace before a
|
|
1121
|
+
* file is read.
|
|
1122
|
+
*/
|
|
1123
|
+
async function findSkillInBases(realRoot, bases, pluginDir, skillName, ref) {
|
|
1124
|
+
for (const base of bases) {
|
|
1125
|
+
const container = await loadSkillIfInside(realRoot, join(base, skillName), ref);
|
|
1126
|
+
if (container) return container;
|
|
1127
|
+
const single = await loadSkillIfInside(realRoot, base, ref);
|
|
1128
|
+
if (single && single.name === skillName) return single;
|
|
1129
|
+
}
|
|
1130
|
+
const root = await loadSkillIfInside(realRoot, pluginDir, ref);
|
|
1131
|
+
return root && root.name === skillName ? root : null;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Resolve `dir` through symlinks and confirm it stays within `realRoot`. Returns
|
|
1135
|
+
* the real path, or `null` when the directory does not exist (nothing to read).
|
|
1136
|
+
* A directory that resolves — via `..` or a symlink — outside the marketplace
|
|
1137
|
+
* throws a `ConfigError`, so a malicious manifest cannot reach outside the clone.
|
|
1138
|
+
*/
|
|
1139
|
+
async function resolveInside(realRoot, dir, ref) {
|
|
1140
|
+
let real;
|
|
1141
|
+
try {
|
|
1142
|
+
real = await realpath(dir);
|
|
1143
|
+
} catch {
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
assertInside(realRoot, real, ref);
|
|
1147
|
+
return real;
|
|
1148
|
+
}
|
|
1149
|
+
/** Throw if `real` (an already-resolved real path) is not within `realRoot`. */
|
|
1150
|
+
function assertInside(realRoot, real, ref) {
|
|
1151
|
+
if (real !== realRoot && !real.startsWith(realRoot + sep)) throw new ConfigError(`Refusing to load "${ref}": path escapes the marketplace repository`, { hint: "A plugin \"source\", manifest, or skill path resolved (via \"..\" or a symlink) outside the cloned marketplace." });
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Read a file only if its real path (following any symlink on the file itself,
|
|
1155
|
+
* not just its parent directory) stays within `realRoot`. Returns `null` when
|
|
1156
|
+
* the file does not exist; throws a `ConfigError` when it resolves outside the
|
|
1157
|
+
* marketplace, so a symlinked `SKILL.md` / manifest cannot exfiltrate an outside
|
|
1158
|
+
* file's contents.
|
|
1159
|
+
*/
|
|
1160
|
+
async function readTextInside(realRoot, filePath, ref) {
|
|
1161
|
+
let real;
|
|
1162
|
+
try {
|
|
1163
|
+
real = await realpath(filePath);
|
|
1164
|
+
} catch {
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
assertInside(realRoot, real, ref);
|
|
1168
|
+
return readFile(real, "utf8");
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Load a skill from `dir` only if both the directory AND its `SKILL.md` file
|
|
1172
|
+
* resolve within `realRoot`. Guarding the file (not just the directory) closes
|
|
1173
|
+
* the case where a legitimate in-repo dir holds a `SKILL.md` symlinked outside.
|
|
1174
|
+
*/
|
|
1175
|
+
async function loadSkillIfInside(realRoot, dir, ref) {
|
|
1176
|
+
const real = await resolveInside(realRoot, dir, ref);
|
|
1177
|
+
if (!real) return null;
|
|
1178
|
+
if (await readTextInside(realRoot, join(real, "SKILL.md"), ref) === null) return null;
|
|
1179
|
+
return loadSkillFromDir(real, "marketplace");
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Resolve a plugin entry's `source` to an absolute directory inside the cloned
|
|
1183
|
+
* marketplace. Honors `metadata.pluginRoot` for bare (non-`./`) sources, per the
|
|
1184
|
+
* marketplace schema. Remote sources (github/url/git-subdir/npm) are rejected —
|
|
1185
|
+
* we only resolve plugins that live in the marketplace repository itself.
|
|
1186
|
+
*/
|
|
1187
|
+
function resolvePluginDir(repoDir, manifest, source, mp, spec) {
|
|
1188
|
+
const ref = `${mp.name}:${spec.plugin}/${spec.skill}`;
|
|
1189
|
+
if (typeof source !== "string" || !source.trim()) throw new ConfigError(`Plugin "${spec.plugin}" in marketplace "${mp.name}" has no local source`, { hint: "Only plugins whose \"source\" is a relative path within the marketplace repo are supported. Remote plugin sources (github/url/git-subdir/npm) are not fetched." });
|
|
1190
|
+
let rel = source.trim();
|
|
1191
|
+
if (rel === "." || rel === "./") rel = "";
|
|
1192
|
+
else if (rel.startsWith("./")) rel = rel.slice(2);
|
|
1193
|
+
else {
|
|
1194
|
+
const pluginRoot = typeof manifest.metadata?.pluginRoot === "string" ? manifest.metadata.pluginRoot.trim() : "";
|
|
1195
|
+
const root = pluginRoot.startsWith("./") ? pluginRoot.slice(2) : pluginRoot;
|
|
1196
|
+
rel = root ? `${root.replace(/\/+$/, "")}/${rel}` : rel;
|
|
1197
|
+
}
|
|
1198
|
+
const dir = join(repoDir, rel);
|
|
1199
|
+
ensureInside(repoDir, dir, ref);
|
|
1200
|
+
return dir;
|
|
1201
|
+
}
|
|
1202
|
+
/** Guard against `..`/symlink escapes: `target` must stay within `root`. */
|
|
1203
|
+
function ensureInside(root, target, ref) {
|
|
1204
|
+
const rootResolved = resolve(root);
|
|
1205
|
+
const targetResolved = resolve(target);
|
|
1206
|
+
if (targetResolved !== rootResolved && !targetResolved.startsWith(rootResolved + sep)) throw new ConfigError(`Refusing to load "${ref}": path escapes the marketplace repository`, { hint: "A plugin \"source\" or skill path resolved outside the cloned marketplace. Check the marketplace manifest." });
|
|
1207
|
+
}
|
|
379
1208
|
//#endregion
|
|
380
1209
|
//#region src/fingerprints.ts
|
|
381
1210
|
/**
|
|
@@ -560,7 +1389,7 @@ function buildSummaryBody(summary, costFooter, options = {}) {
|
|
|
560
1389
|
return `${withFooter}\n\n${buildSummaryHistoryBlock(historyEntries)}`;
|
|
561
1390
|
}
|
|
562
1391
|
function buildReviewedCommitFooter(commitSha) {
|
|
563
|
-
return `Reviewed by ${PRODUCT_LINK} v0.9.
|
|
1392
|
+
return `Reviewed by ${PRODUCT_LINK} v0.9.4 for commit ${commitSha}.`;
|
|
564
1393
|
}
|
|
565
1394
|
function extractReviewedCommitSha(body) {
|
|
566
1395
|
return REVIEWED_COMMIT_FOOTER_PATTERN.exec(body)?.[1] ?? null;
|
|
@@ -896,6 +1725,7 @@ var RESERVED_ENV_SUFFIXES = [
|
|
|
896
1725
|
"FORCE_REVIEW",
|
|
897
1726
|
"VERBOSE",
|
|
898
1727
|
"SKILLS",
|
|
1728
|
+
"MARKETPLACES",
|
|
899
1729
|
"REFRESH_SKILLS",
|
|
900
1730
|
"THINKING_LEVEL"
|
|
901
1731
|
];
|
|
@@ -964,7 +1794,7 @@ var BOOLEAN_FLAGS = new Set([
|
|
|
964
1794
|
"help",
|
|
965
1795
|
"version"
|
|
966
1796
|
]);
|
|
967
|
-
var MULTI_FLAGS = new Set(["skill"]);
|
|
1797
|
+
var MULTI_FLAGS = new Set(["skill", "marketplace"]);
|
|
968
1798
|
function parseArgs(argv) {
|
|
969
1799
|
const args = {};
|
|
970
1800
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -1084,6 +1914,16 @@ function resolveSkills(args, env) {
|
|
|
1084
1914
|
return [];
|
|
1085
1915
|
}
|
|
1086
1916
|
/**
|
|
1917
|
+
* Resolve marketplace declarations from `--marketplace` (repeatable, preferred)
|
|
1918
|
+
* or the comma-separated `CODE_REVIEW_MARKETPLACES`. Each entry is
|
|
1919
|
+
* `<name>=<[format:]url[#ref]>`; parsing throws a `ConfigError` on malformed
|
|
1920
|
+
* entries, reserved names, or unknown formats (fail fast on misconfiguration).
|
|
1921
|
+
*/
|
|
1922
|
+
function resolveMarketplaces(args, env) {
|
|
1923
|
+
const argMarketplace = args.marketplace;
|
|
1924
|
+
return (Array.isArray(argMarketplace) ? argMarketplace : typeof argMarketplace === "string" && argMarketplace.length > 0 ? [argMarketplace] : (env.CODE_REVIEW_MARKETPLACES ?? "").split(",")).map((entry) => entry.trim()).filter(Boolean).map((entry) => parseMarketplaceEntry(entry));
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1087
1927
|
* Resolve the model pool from `--model-pool` (preferred) or
|
|
1088
1928
|
* `CODE_REVIEW_MODEL_POOL`. Both are comma-separated `provider/modelId` lists.
|
|
1089
1929
|
* Entries are trimmed and empty entries dropped. Returns `[]` when unset, which
|
|
@@ -1248,6 +2088,7 @@ function resolveConfig(argv = process.argv.slice(2), env = process.env) {
|
|
|
1248
2088
|
verbose: toBoolean(args.verbose) || toBoolean(env.CODE_REVIEW_VERBOSE),
|
|
1249
2089
|
cwd: String(args.cwd ?? process.cwd()),
|
|
1250
2090
|
skills: resolveSkills(args, env),
|
|
2091
|
+
marketplaces: resolveMarketplaces(args, env),
|
|
1251
2092
|
refreshGitSkills: toBoolean(env.CODE_REVIEW_REFRESH_SKILLS)
|
|
1252
2093
|
};
|
|
1253
2094
|
}
|
|
@@ -1358,7 +2199,7 @@ function createDiagnosticContext(phase, config, runId, overrides = {}) {
|
|
|
1358
2199
|
phase,
|
|
1359
2200
|
project: config.project,
|
|
1360
2201
|
mr: config.mr,
|
|
1361
|
-
gitlabUrl: config.gitlabUrl,
|
|
2202
|
+
gitlabUrl: config.platform === "github" ? config.githubServerUrl : config.gitlabUrl,
|
|
1362
2203
|
platform: config.platform,
|
|
1363
2204
|
cwd: config.cwd,
|
|
1364
2205
|
model: config.model,
|
|
@@ -1392,179 +2233,17 @@ function traceDiagnosticPhase(phase, config, runId, operation, overrides = {}) {
|
|
|
1392
2233
|
function toDiagnosticError(error, secretValues = []) {
|
|
1393
2234
|
if (error instanceof Error) {
|
|
1394
2235
|
const code = "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
1395
|
-
const timeout = "timeout" in error && error.timeout === true ? true : void 0;
|
|
1396
|
-
const status = "status" in error && typeof error.status === "number" ? error.status : void 0;
|
|
1397
|
-
return {
|
|
1398
|
-
name: error.name,
|
|
1399
|
-
message: scrubSecrets(error.message, secretValues),
|
|
1400
|
-
code,
|
|
1401
|
-
timeout,
|
|
1402
|
-
status
|
|
1403
|
-
};
|
|
1404
|
-
}
|
|
1405
|
-
return { message: scrubSecrets(String(error), secretValues) };
|
|
1406
|
-
}
|
|
1407
|
-
//#endregion
|
|
1408
|
-
//#region src/git.ts
|
|
1409
|
-
var exec$1 = promisify(execFile);
|
|
1410
|
-
var DEFAULT_DIFF_CONTEXT = 20;
|
|
1411
|
-
var DEFAULT_CODEQUALITY_ARTIFACTS = [
|
|
1412
|
-
"gl-code-quality-report.json",
|
|
1413
|
-
"codequality.json",
|
|
1414
|
-
"codeclimate.json",
|
|
1415
|
-
"code-quality-report.json"
|
|
1416
|
-
];
|
|
1417
|
-
function gitErrorMessage(error) {
|
|
1418
|
-
const err = error;
|
|
1419
|
-
return [
|
|
1420
|
-
err.message,
|
|
1421
|
-
err.stderr,
|
|
1422
|
-
err.stdout
|
|
1423
|
-
].filter(Boolean).join("\n").trim();
|
|
1424
|
-
}
|
|
1425
|
-
async function git$1(args, options = {}) {
|
|
1426
|
-
try {
|
|
1427
|
-
const { stdout } = await exec$1("git", args, {
|
|
1428
|
-
cwd: options.cwd,
|
|
1429
|
-
maxBuffer: 50 * 1024 * 1024
|
|
1430
|
-
});
|
|
1431
|
-
return stdout;
|
|
1432
|
-
} catch (error) {
|
|
1433
|
-
throw new GitError(`git ${args.join(" ")} failed.`, {
|
|
1434
|
-
cause: error,
|
|
1435
|
-
hint: gitErrorMessage(error)
|
|
1436
|
-
});
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
function remoteRef(remote, branch) {
|
|
1440
|
-
return `refs/remotes/${remote}/${branch}`;
|
|
1441
|
-
}
|
|
1442
|
-
function getMergeDiffArguments(targetBranch, options = {}) {
|
|
1443
|
-
const remote = options.remote ?? "origin";
|
|
1444
|
-
const context = options.context ?? DEFAULT_DIFF_CONTEXT;
|
|
1445
|
-
return [
|
|
1446
|
-
`${remoteRef(remote, targetBranch)}...HEAD`,
|
|
1447
|
-
`--unified=${context}`,
|
|
1448
|
-
"--"
|
|
1449
|
-
];
|
|
1450
|
-
}
|
|
1451
|
-
/**
|
|
1452
|
-
* Full git argv for the merge diff. `-c core.quotepath=false` keeps non-ASCII
|
|
1453
|
-
* paths literal instead of git's default octal-escaped + double-quoted form
|
|
1454
|
-
* (`"a/caf\303\251.ts"`), which the comment-position parser cannot match —
|
|
1455
|
-
* leaving such comments with invalid one-sided positions that 500 on
|
|
1456
|
-
* `bulk_publish`. Diffing literally also gives the reviewer readable paths.
|
|
1457
|
-
*/
|
|
1458
|
-
function getMergeDiffCommand(targetBranch, options = {}) {
|
|
1459
|
-
return [
|
|
1460
|
-
"-c",
|
|
1461
|
-
"core.quotepath=false",
|
|
1462
|
-
"diff",
|
|
1463
|
-
...getMergeDiffArguments(targetBranch, options)
|
|
1464
|
-
];
|
|
1465
|
-
}
|
|
1466
|
-
async function fetchBranch(remote, branch, options) {
|
|
1467
|
-
await git$1([
|
|
1468
|
-
"fetch",
|
|
1469
|
-
"--no-tags",
|
|
1470
|
-
remote,
|
|
1471
|
-
`+refs/heads/${branch}:${remoteRef(remote, branch)}`
|
|
1472
|
-
], options);
|
|
1473
|
-
}
|
|
1474
|
-
async function isTracked(path, options) {
|
|
1475
|
-
try {
|
|
1476
|
-
await git$1([
|
|
1477
|
-
"ls-files",
|
|
1478
|
-
"--error-unmatch",
|
|
1479
|
-
"--",
|
|
1480
|
-
path
|
|
1481
|
-
], options);
|
|
1482
|
-
return true;
|
|
1483
|
-
} catch {
|
|
1484
|
-
return false;
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
async function removeGeneratedCodeQualityArtifacts(paths = DEFAULT_CODEQUALITY_ARTIFACTS, options = {}) {
|
|
1488
|
-
const removed = [];
|
|
1489
|
-
for (const path of paths) {
|
|
1490
|
-
if (await isTracked(path, options)) continue;
|
|
1491
|
-
try {
|
|
1492
|
-
await unlink(options.cwd ? join(options.cwd, path) : path);
|
|
1493
|
-
removed.push(path);
|
|
1494
|
-
} catch (error) {
|
|
1495
|
-
if (error.code !== "ENOENT") throw error;
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
return removed;
|
|
1499
|
-
}
|
|
1500
|
-
async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
|
|
1501
|
-
const remote = options.remote ?? "origin";
|
|
1502
|
-
await removeGeneratedCodeQualityArtifacts(options.codeQualityArtifacts, options);
|
|
1503
|
-
await git$1([
|
|
1504
|
-
"fetch",
|
|
1505
|
-
"--unshallow",
|
|
1506
|
-
"--no-tags",
|
|
1507
|
-
remote
|
|
1508
|
-
], options).catch(() => void 0);
|
|
1509
|
-
const fetchErrors = [];
|
|
1510
|
-
for (const branch of [targetBranch, sourceBranch]) try {
|
|
1511
|
-
await fetchBranch(remote, branch, options);
|
|
1512
|
-
} catch (error) {
|
|
1513
|
-
fetchErrors.push(`${branch}: ${gitErrorMessage(error)}`);
|
|
1514
|
-
}
|
|
1515
|
-
if (fetchErrors.length === 2) throw new GitError(`Unable to fetch MR source/target branches from ${remote}.`, { hint: fetchErrors.join("\n") });
|
|
1516
|
-
try {
|
|
1517
|
-
await git$1([
|
|
1518
|
-
"merge-base",
|
|
1519
|
-
remoteRef(remote, targetBranch),
|
|
1520
|
-
"HEAD"
|
|
1521
|
-
], options);
|
|
1522
|
-
} catch (error) {
|
|
1523
|
-
const fetchDetail = fetchErrors.length > 0 ? `\nFetch warnings:\n${fetchErrors.join("\n")}` : "";
|
|
1524
|
-
throw new GitError(`Unable to prepare Git history for MR review: merge-base ${remoteRef(remote, targetBranch)} HEAD failed.`, {
|
|
1525
|
-
cause: error,
|
|
1526
|
-
hint: `Set GIT_DEPTH: 0 or ensure ${remote}/${targetBranch} is fetchable.${fetchDetail}\n${gitErrorMessage(error)}`
|
|
1527
|
-
});
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
async function getMergeDiff(targetBranch, options = {}) {
|
|
1531
|
-
return git$1(getMergeDiffCommand(targetBranch, options), options);
|
|
1532
|
-
}
|
|
1533
|
-
function getMergeCommitLogArguments(targetBranch, options = {}) {
|
|
1534
|
-
return [
|
|
1535
|
-
`${remoteRef(options.remote ?? "origin", targetBranch)}...HEAD`,
|
|
1536
|
-
"--pretty=tformat:commit %h%nAuthor: %an%nDate: %as%n%n%s%n%n%b",
|
|
1537
|
-
"--reverse",
|
|
1538
|
-
"--no-merges"
|
|
1539
|
-
];
|
|
1540
|
-
}
|
|
1541
|
-
async function getMergeCommitLog(targetBranch, options = {}) {
|
|
1542
|
-
return git$1(["log", ...getMergeCommitLogArguments(targetBranch, options)], options);
|
|
1543
|
-
}
|
|
1544
|
-
/**
|
|
1545
|
-
* Summarize a unified diff into file/line counts for telemetry. Counts one file
|
|
1546
|
-
* per `diff --git` header and counts `+`/`-` lines only inside a hunk (after a
|
|
1547
|
-
* `@@` header), so the `--- a/file` / `+++ b/file` header lines are excluded and
|
|
1548
|
-
* a genuine content line whose text starts with `++`/`--` is still counted. Pure
|
|
1549
|
-
* and allocation-light so it can run on the full merge diff without an extra git
|
|
1550
|
-
* invocation.
|
|
1551
|
-
*/
|
|
1552
|
-
function summarizeDiff(diff) {
|
|
1553
|
-
let filesChanged = 0;
|
|
1554
|
-
let linesAdded = 0;
|
|
1555
|
-
let linesRemoved = 0;
|
|
1556
|
-
let inHunk = false;
|
|
1557
|
-
for (const line of diff.split("\n")) if (line.startsWith("diff --git ")) {
|
|
1558
|
-
filesChanged += 1;
|
|
1559
|
-
inHunk = false;
|
|
1560
|
-
} else if (line.startsWith("@@ ")) inHunk = true;
|
|
1561
|
-
else if (inHunk && line.startsWith("+")) linesAdded += 1;
|
|
1562
|
-
else if (inHunk && line.startsWith("-")) linesRemoved += 1;
|
|
1563
|
-
return {
|
|
1564
|
-
filesChanged,
|
|
1565
|
-
linesAdded,
|
|
1566
|
-
linesRemoved
|
|
1567
|
-
};
|
|
2236
|
+
const timeout = "timeout" in error && error.timeout === true ? true : void 0;
|
|
2237
|
+
const status = "status" in error && typeof error.status === "number" ? error.status : void 0;
|
|
2238
|
+
return {
|
|
2239
|
+
name: error.name,
|
|
2240
|
+
message: scrubSecrets(error.message, secretValues),
|
|
2241
|
+
code,
|
|
2242
|
+
timeout,
|
|
2243
|
+
status
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
return { message: scrubSecrets(String(error), secretValues) };
|
|
1568
2247
|
}
|
|
1569
2248
|
//#endregion
|
|
1570
2249
|
//#region src/git-tool.ts
|
|
@@ -2905,452 +3584,117 @@ function parseInlineSection(markdown, out, warnings) {
|
|
|
2905
3584
|
flush();
|
|
2906
3585
|
if (sawBodyBeforeHeader) warnings.push("Ignored text in the inline comments section before the first parseable comment header.");
|
|
2907
3586
|
}
|
|
2908
|
-
function parseReviewMarkdownWithWarnings(markdown) {
|
|
2909
|
-
const comments = [];
|
|
2910
|
-
const warnings = [];
|
|
2911
|
-
const { summary, malformed } = parseJsonComments(markdown, comments, warnings);
|
|
2912
|
-
parseInlineSection(markdown, comments, warnings);
|
|
2913
|
-
if (malformed && comments.length > 0) {
|
|
2914
|
-
warnings.push(`A reviewer JSON block was unparseable (${malformed.reason}), but ${comments.length} comment(s) were recovered from other sections; continuing.`);
|
|
2915
|
-
return {
|
|
2916
|
-
comments,
|
|
2917
|
-
summary,
|
|
2918
|
-
warnings,
|
|
2919
|
-
malformed: null
|
|
2920
|
-
};
|
|
2921
|
-
}
|
|
2922
|
-
return {
|
|
2923
|
-
comments,
|
|
2924
|
-
summary,
|
|
2925
|
-
warnings,
|
|
2926
|
-
malformed
|
|
2927
|
-
};
|
|
2928
|
-
}
|
|
2929
|
-
function parseReviewMarkdown(markdown) {
|
|
2930
|
-
return parseReviewMarkdownWithWarnings(markdown).comments;
|
|
2931
|
-
}
|
|
2932
|
-
//#endregion
|
|
2933
|
-
//#region src/prior-threads.ts
|
|
2934
|
-
var FINGERPRINT_MARKER_RE = new RegExp(FINGERPRINT_MARKER_PATTERN, "i");
|
|
2935
|
-
/**
|
|
2936
|
-
* Returns true when the note body contains a code-review fingerprint marker
|
|
2937
|
-
* (current or legacy prefix).
|
|
2938
|
-
* Used to identify notes posted by the bot without needing a getCurrentUser() call.
|
|
2939
|
-
*/
|
|
2940
|
-
function isBotNote(note) {
|
|
2941
|
-
return FINGERPRINT_MARKER_RE.test(note.body ?? "");
|
|
2942
|
-
}
|
|
2943
|
-
/**
|
|
2944
|
-
* Parses the `+++ b/<path>` lines from a unified diff and returns the set of
|
|
2945
|
-
* new file paths. `/dev/null` (deleted files) is excluded.
|
|
2946
|
-
*/
|
|
2947
|
-
function extractChangedFiles(diff) {
|
|
2948
|
-
const files = /* @__PURE__ */ new Set();
|
|
2949
|
-
for (const line of diff.split("\n")) {
|
|
2950
|
-
const match = line.match(/^\+\+\+ b\/(.+)$/);
|
|
2951
|
-
if (match && match[1] !== "/dev/null") files.add(match[1]);
|
|
2952
|
-
}
|
|
2953
|
-
return files;
|
|
2954
|
-
}
|
|
2955
|
-
/**
|
|
2956
|
-
* Returns the line number for a discussion note's position.
|
|
2957
|
-
* Prefers the new-side line (`new_line`) then falls back to `old_line`.
|
|
2958
|
-
*/
|
|
2959
|
-
function positionLine$1(note) {
|
|
2960
|
-
return note.position?.new_line ?? note.position?.old_line ?? null;
|
|
2961
|
-
}
|
|
2962
|
-
/**
|
|
2963
|
-
* Returns the file path for a discussion note's position.
|
|
2964
|
-
* Prefers the new path then falls back to the old path.
|
|
2965
|
-
*/
|
|
2966
|
-
function positionFile$1(note) {
|
|
2967
|
-
return note.position?.new_path ?? note.position?.old_path ?? null;
|
|
2968
|
-
}
|
|
2969
|
-
/**
|
|
2970
|
-
* Extracts prior review threads from existing MR discussions that are relevant
|
|
2971
|
-
* to the current diff.
|
|
2972
|
-
*
|
|
2973
|
-
* A thread is included when:
|
|
2974
|
-
* - It contains at least one bot note (identified by fingerprint marker).
|
|
2975
|
-
* - It contains at least one non-system human reply after the bot note.
|
|
2976
|
-
* - The thread's file appears in `changedFiles`.
|
|
2977
|
-
*
|
|
2978
|
-
* Resolved threads are included but marked with `resolved: true` so the
|
|
2979
|
-
* reviewer can reference them without re-raising the concern.
|
|
2980
|
-
*/
|
|
2981
|
-
function extractPriorThreads(discussions, changedFiles) {
|
|
2982
|
-
const threads = [];
|
|
2983
|
-
for (const discussion of discussions) {
|
|
2984
|
-
const notes = discussion.notes ?? [];
|
|
2985
|
-
const botNoteIndex = notes.findIndex(isBotNote);
|
|
2986
|
-
if (botNoteIndex === -1) continue;
|
|
2987
|
-
const botNote = notes[botNoteIndex];
|
|
2988
|
-
const file = positionFile$1(botNote);
|
|
2989
|
-
if (!file || !changedFiles.has(file)) continue;
|
|
2990
|
-
const replies = notes.slice(botNoteIndex + 1).filter((n) => !n.system && (n.body?.trim() ?? "")).filter((n) => !isBotNote(n)).map((n) => n.body?.trim() ?? "");
|
|
2991
|
-
if (replies.length === 0) continue;
|
|
2992
|
-
const resolved = notes.some((n) => n.resolved === true);
|
|
2993
|
-
threads.push({
|
|
2994
|
-
file,
|
|
2995
|
-
line: positionLine$1(botNote),
|
|
2996
|
-
resolved,
|
|
2997
|
-
botComment: normalizeBody(botNote.body ?? ""),
|
|
2998
|
-
replies
|
|
2999
|
-
});
|
|
3000
|
-
}
|
|
3001
|
-
return threads;
|
|
3002
|
-
}
|
|
3003
|
-
/**
|
|
3004
|
-
* Renders a `<prior_review_feedback>` XML block from a list of prior threads.
|
|
3005
|
-
* Returns an empty string when `threads` is empty.
|
|
3006
|
-
*/
|
|
3007
|
-
function renderPriorThreadsBlock(threads) {
|
|
3008
|
-
if (threads.length === 0) return "";
|
|
3009
|
-
return `<prior_review_feedback>\n${threads.map((t) => {
|
|
3010
|
-
return ` <thread ${[
|
|
3011
|
-
`file="${t.file}"`,
|
|
3012
|
-
t.line !== null ? `line="${t.line}"` : null,
|
|
3013
|
-
`resolved="${t.resolved}"`
|
|
3014
|
-
].filter(Boolean).join(" ")}>\n${` <comment>${escapeXml(t.botComment)}</comment>`}\n${t.replies.map((r) => ` <reply>${escapeXml(r)}</reply>`).join("\n")}\n </thread>`;
|
|
3015
|
-
}).join("\n")}\n</prior_review_feedback>`;
|
|
3016
|
-
}
|
|
3017
|
-
function escapeXml(text) {
|
|
3018
|
-
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3019
|
-
}
|
|
3020
|
-
//#endregion
|
|
3021
|
-
//#region src/skills.ts
|
|
3022
|
-
var SKILL_DIRS = [".agents/skills", ".claude/skills"];
|
|
3023
|
-
var RESOURCE_DIRS = ["references"];
|
|
3024
|
-
function parseFrontmatter(content) {
|
|
3025
|
-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
3026
|
-
if (!match) return null;
|
|
3027
|
-
let data;
|
|
3028
|
-
try {
|
|
3029
|
-
data = parse(match[1]);
|
|
3030
|
-
} catch {
|
|
3031
|
-
return null;
|
|
3032
|
-
}
|
|
3033
|
-
if (!data || typeof data !== "object") return null;
|
|
3034
|
-
const { name, description } = data;
|
|
3035
|
-
if (typeof name !== "string" || typeof description !== "string") return null;
|
|
3036
|
-
const trimmedName = name.trim();
|
|
3037
|
-
const trimmedDescription = description.trim();
|
|
3038
|
-
if (!trimmedName || !trimmedDescription) return null;
|
|
3039
|
-
return {
|
|
3040
|
-
name: trimmedName,
|
|
3041
|
-
description: trimmedDescription
|
|
3042
|
-
};
|
|
3043
|
-
}
|
|
3044
|
-
async function loadSkillFromDir(dirPath, source) {
|
|
3045
|
-
const skillMdPath = join(dirPath, "SKILL.md");
|
|
3046
|
-
let content;
|
|
3047
|
-
try {
|
|
3048
|
-
content = await readFile(skillMdPath, "utf8");
|
|
3049
|
-
} catch {
|
|
3050
|
-
return null;
|
|
3051
|
-
}
|
|
3052
|
-
const parsed = parseFrontmatter(content);
|
|
3053
|
-
if (!parsed) return null;
|
|
3054
|
-
const resourceDirs = RESOURCE_DIRS.filter((d) => existsSync(join(dirPath, d)));
|
|
3055
|
-
return {
|
|
3056
|
-
name: parsed.name,
|
|
3057
|
-
description: parsed.description,
|
|
3058
|
-
filePath: skillMdPath,
|
|
3059
|
-
rootDir: dirPath,
|
|
3060
|
-
resourceDirs,
|
|
3061
|
-
source
|
|
3062
|
-
};
|
|
3063
|
-
}
|
|
3064
|
-
function resolveBuiltinSkillsDir() {
|
|
3065
|
-
return join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
|
|
3066
|
-
}
|
|
3067
|
-
async function loadBuiltinSkill(name) {
|
|
3068
|
-
return loadSkillFromDir(join(resolveBuiltinSkillsDir(), name), "builtin");
|
|
3069
|
-
}
|
|
3070
|
-
async function loadAutoDiscoveredSkills(cwd, gitRoot, warn) {
|
|
3071
|
-
const dirs = [];
|
|
3072
|
-
let current = cwd;
|
|
3073
|
-
while (true) {
|
|
3074
|
-
dirs.unshift(current);
|
|
3075
|
-
if (current === gitRoot) break;
|
|
3076
|
-
const parent = dirname(current);
|
|
3077
|
-
if (parent === current) break;
|
|
3078
|
-
current = parent;
|
|
3079
|
-
}
|
|
3080
|
-
const found = /* @__PURE__ */ new Map();
|
|
3081
|
-
for (const dir of dirs) for (const skillDir of SKILL_DIRS) {
|
|
3082
|
-
const skillsPath = join(dir, skillDir);
|
|
3083
|
-
let entries;
|
|
3084
|
-
try {
|
|
3085
|
-
entries = await readdir(skillsPath);
|
|
3086
|
-
} catch {
|
|
3087
|
-
continue;
|
|
3088
|
-
}
|
|
3089
|
-
for (const entry of entries) {
|
|
3090
|
-
const entryPath = join(skillsPath, entry);
|
|
3091
|
-
const skill = await loadSkillFromDir(entryPath, "project");
|
|
3092
|
-
if (skill) found.set(skill.name, skill);
|
|
3093
|
-
else if (warn && existsSync(join(entryPath, "SKILL.md"))) warn(`Skill at ${entryPath} has a SKILL.md but is missing required frontmatter fields (name, description) — skill not loaded.`);
|
|
3094
|
-
}
|
|
3095
|
-
}
|
|
3096
|
-
return [...found.values()];
|
|
3097
|
-
}
|
|
3098
|
-
/**
|
|
3099
|
-
* Parse a skill spec string into a typed `SkillSpec` descriptor.
|
|
3100
|
-
*
|
|
3101
|
-
* Supported spec formats:
|
|
3102
|
-
*
|
|
3103
|
-
* | Input | Result |
|
|
3104
|
-
* |------------------------------------|---------------------------------------------------|
|
|
3105
|
-
* | `code-review` | `{ protocol: 'builtin', name: 'code-review' }` |
|
|
3106
|
-
* | `npm:my-skill` | `{ protocol: 'npm', packageName: 'my-skill', ... }`|
|
|
3107
|
-
* | `npm:@scope/pkg` | `{ protocol: 'npm', packageName: '@scope/pkg', ... }`|
|
|
3108
|
-
* | `npm:@scope/bundle/security` | `{ protocol: 'npm', packageName: '@scope/bundle', subpath: 'security' }`|
|
|
3109
|
-
* | `npm:bundle/security` | `{ protocol: 'npm', packageName: 'bundle', subpath: 'security' }`|
|
|
3110
|
-
* | `file:./path/to/skill` | `{ protocol: 'file', path: './path/to/skill' }` |
|
|
3111
|
-
* | `file:/absolute/path` | `{ protocol: 'file', path: '/absolute/path' }` |
|
|
3112
|
-
* | `git:https://host/org/s.git` | `{ protocol: 'git', url: 'https://host/org/s.git', ref: '', subpath: '' }` |
|
|
3113
|
-
* | `git:https://host/org/b.git#v1/sec`| `{ protocol: 'git', url: 'https://host/org/b.git', ref: 'v1', subpath: 'sec' }` |
|
|
3114
|
-
* | `git+ssh://git@host/org/s.git` | `{ protocol: 'git', url: 'ssh://git@host/org/s.git', ref: '', subpath: '' }` |
|
|
3115
|
-
*/
|
|
3116
|
-
function parseSkillSpec(spec) {
|
|
3117
|
-
if (spec.startsWith("file:")) return {
|
|
3118
|
-
protocol: "file",
|
|
3119
|
-
path: spec.slice(5)
|
|
3120
|
-
};
|
|
3121
|
-
if (spec.startsWith("npm:")) {
|
|
3122
|
-
const rest = spec.slice(4);
|
|
3123
|
-
if (rest.startsWith("@")) {
|
|
3124
|
-
const parts = rest.split("/");
|
|
3125
|
-
if (parts.length < 2) return {
|
|
3126
|
-
protocol: "npm",
|
|
3127
|
-
packageName: rest,
|
|
3128
|
-
subpath: ""
|
|
3129
|
-
};
|
|
3130
|
-
return {
|
|
3131
|
-
protocol: "npm",
|
|
3132
|
-
packageName: `${parts[0]}/${parts[1]}`,
|
|
3133
|
-
subpath: parts.slice(2).join("/")
|
|
3134
|
-
};
|
|
3135
|
-
}
|
|
3136
|
-
const slashIdx = rest.indexOf("/");
|
|
3137
|
-
if (slashIdx === -1) return {
|
|
3138
|
-
protocol: "npm",
|
|
3139
|
-
packageName: rest,
|
|
3140
|
-
subpath: ""
|
|
3141
|
-
};
|
|
3142
|
-
return {
|
|
3143
|
-
protocol: "npm",
|
|
3144
|
-
packageName: rest.slice(0, slashIdx),
|
|
3145
|
-
subpath: rest.slice(slashIdx + 1)
|
|
3146
|
-
};
|
|
3147
|
-
}
|
|
3148
|
-
if (spec.startsWith("git+") || spec.startsWith("git:")) return parseGitSpec(spec);
|
|
3149
|
-
return {
|
|
3150
|
-
protocol: "builtin",
|
|
3151
|
-
name: spec
|
|
3152
|
-
};
|
|
3153
|
-
}
|
|
3154
|
-
/**
|
|
3155
|
-
* Parse a `git:` / `git+ssh:` skill spec into its URL, pinned ref, and subpath.
|
|
3156
|
-
*
|
|
3157
|
-
* - `git:<url>` strips the `git:` marker; what follows is the clone URL
|
|
3158
|
-
* (e.g. `git:https://host/org/repo.git`).
|
|
3159
|
-
* - `git+<transport>://…` strips the leading `git+`, leaving a URL git
|
|
3160
|
-
* understands directly (`git+ssh://git@host/…` → `ssh://git@host/…`), matching
|
|
3161
|
-
* npm's `package.json` git-dependency convention.
|
|
3162
|
-
*
|
|
3163
|
-
* An optional `#<ref>[/<subpath>]` fragment pins the ref (tag, branch, or
|
|
3164
|
-
* commit) and, after the first `/`, points at a skill directory inside the repo.
|
|
3165
|
-
*/
|
|
3166
|
-
function parseGitSpec(spec) {
|
|
3167
|
-
const raw = spec.startsWith("git+") ? spec.slice(4) : spec.slice(4);
|
|
3168
|
-
let parsed;
|
|
3169
|
-
try {
|
|
3170
|
-
parsed = new URL(raw);
|
|
3171
|
-
} catch {
|
|
3587
|
+
function parseReviewMarkdownWithWarnings(markdown) {
|
|
3588
|
+
const comments = [];
|
|
3589
|
+
const warnings = [];
|
|
3590
|
+
const { summary, malformed } = parseJsonComments(markdown, comments, warnings);
|
|
3591
|
+
parseInlineSection(markdown, comments, warnings);
|
|
3592
|
+
if (malformed && comments.length > 0) {
|
|
3593
|
+
warnings.push(`A reviewer JSON block was unparseable (${malformed.reason}), but ${comments.length} comment(s) were recovered from other sections; continuing.`);
|
|
3172
3594
|
return {
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3595
|
+
comments,
|
|
3596
|
+
summary,
|
|
3597
|
+
warnings,
|
|
3598
|
+
malformed: null
|
|
3177
3599
|
};
|
|
3178
3600
|
}
|
|
3179
|
-
const fragment = parsed.hash ? parsed.hash.slice(1) : "";
|
|
3180
|
-
parsed.hash = "";
|
|
3181
|
-
const url = parsed.toString();
|
|
3182
|
-
const slashIdx = fragment.indexOf("/");
|
|
3183
|
-
if (slashIdx === -1) return {
|
|
3184
|
-
protocol: "git",
|
|
3185
|
-
url,
|
|
3186
|
-
ref: fragment,
|
|
3187
|
-
subpath: ""
|
|
3188
|
-
};
|
|
3189
3601
|
return {
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3602
|
+
comments,
|
|
3603
|
+
summary,
|
|
3604
|
+
warnings,
|
|
3605
|
+
malformed
|
|
3194
3606
|
};
|
|
3195
3607
|
}
|
|
3608
|
+
function parseReviewMarkdown(markdown) {
|
|
3609
|
+
return parseReviewMarkdownWithWarnings(markdown).comments;
|
|
3610
|
+
}
|
|
3611
|
+
//#endregion
|
|
3612
|
+
//#region src/prior-threads.ts
|
|
3613
|
+
var FINGERPRINT_MARKER_RE = new RegExp(FINGERPRINT_MARKER_PATTERN, "i");
|
|
3196
3614
|
/**
|
|
3197
|
-
*
|
|
3198
|
-
*
|
|
3199
|
-
*
|
|
3615
|
+
* Returns true when the note body contains a code-review fingerprint marker
|
|
3616
|
+
* (current or legacy prefix).
|
|
3617
|
+
* Used to identify notes posted by the bot without needing a getCurrentUser() call.
|
|
3200
3618
|
*/
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
while (true) {
|
|
3204
|
-
const candidate = subpath ? join(current, "node_modules", packageName, subpath) : join(current, "node_modules", packageName);
|
|
3205
|
-
if (existsSync(candidate)) return candidate;
|
|
3206
|
-
const parent = dirname(current);
|
|
3207
|
-
if (parent === current) break;
|
|
3208
|
-
current = parent;
|
|
3209
|
-
}
|
|
3210
|
-
return null;
|
|
3211
|
-
}
|
|
3212
|
-
/** Base directory for cached git-skill clones (honours `XDG_CACHE_HOME`). */
|
|
3213
|
-
function resolveSkillCacheDir() {
|
|
3214
|
-
return join(process.env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "code-review", "skills");
|
|
3619
|
+
function isBotNote(note) {
|
|
3620
|
+
return FINGERPRINT_MARKER_RE.test(note.body ?? "");
|
|
3215
3621
|
}
|
|
3216
3622
|
/**
|
|
3217
|
-
*
|
|
3218
|
-
*
|
|
3623
|
+
* Parses the `+++ b/<path>` lines from a unified diff and returns the set of
|
|
3624
|
+
* new file paths. `/dev/null` (deleted files) is excluded.
|
|
3219
3625
|
*/
|
|
3220
|
-
function
|
|
3221
|
-
|
|
3626
|
+
function extractChangedFiles(diff) {
|
|
3627
|
+
const files = /* @__PURE__ */ new Set();
|
|
3628
|
+
for (const line of diff.split("\n")) {
|
|
3629
|
+
const match = line.match(/^\+\+\+ b\/(.+)$/);
|
|
3630
|
+
if (match && match[1] !== "/dev/null") files.add(match[1]);
|
|
3631
|
+
}
|
|
3632
|
+
return files;
|
|
3222
3633
|
}
|
|
3223
3634
|
/**
|
|
3224
|
-
*
|
|
3225
|
-
*
|
|
3226
|
-
* commit SHA all resolve through the same path; an empty `ref` fetches the
|
|
3227
|
-
* remote's default branch via `HEAD`.
|
|
3635
|
+
* Returns the line number for a discussion note's position.
|
|
3636
|
+
* Prefers the new-side line (`new_line`) then falls back to `old_line`.
|
|
3228
3637
|
*/
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
"init",
|
|
3232
|
-
"--quiet",
|
|
3233
|
-
dir
|
|
3234
|
-
]);
|
|
3235
|
-
await git$1([
|
|
3236
|
-
"remote",
|
|
3237
|
-
"add",
|
|
3238
|
-
"origin",
|
|
3239
|
-
url
|
|
3240
|
-
], { cwd: dir });
|
|
3241
|
-
await git$1([
|
|
3242
|
-
"fetch",
|
|
3243
|
-
"--depth",
|
|
3244
|
-
"1",
|
|
3245
|
-
"--quiet",
|
|
3246
|
-
"origin",
|
|
3247
|
-
ref || "HEAD"
|
|
3248
|
-
], { cwd: dir });
|
|
3249
|
-
await git$1([
|
|
3250
|
-
"checkout",
|
|
3251
|
-
"--quiet",
|
|
3252
|
-
"FETCH_HEAD"
|
|
3253
|
-
], { cwd: dir });
|
|
3638
|
+
function positionLine$1(note) {
|
|
3639
|
+
return note.position?.new_line ?? note.position?.old_line ?? null;
|
|
3254
3640
|
}
|
|
3255
3641
|
/**
|
|
3256
|
-
*
|
|
3257
|
-
*
|
|
3258
|
-
* into place atomically, so a crashed or concurrent clone never leaves a
|
|
3259
|
-
* half-written cache entry. With `refresh`, any cached copy is discarded first.
|
|
3642
|
+
* Returns the file path for a discussion note's position.
|
|
3643
|
+
* Prefers the new path then falls back to the old path.
|
|
3260
3644
|
*/
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
if (options.refresh) await rm(repoDir, {
|
|
3264
|
-
recursive: true,
|
|
3265
|
-
force: true
|
|
3266
|
-
});
|
|
3267
|
-
else if (existsSync(join(repoDir, ".git"))) return repoDir;
|
|
3268
|
-
else if (existsSync(repoDir)) await rm(repoDir, {
|
|
3269
|
-
recursive: true,
|
|
3270
|
-
force: true
|
|
3271
|
-
});
|
|
3272
|
-
await mkdir(options.cacheDir, { recursive: true });
|
|
3273
|
-
const tmpDir = `${repoDir}.tmp-${process.pid}`;
|
|
3274
|
-
await rm(tmpDir, {
|
|
3275
|
-
recursive: true,
|
|
3276
|
-
force: true
|
|
3277
|
-
});
|
|
3278
|
-
try {
|
|
3279
|
-
await gitShallowClone(url, ref, tmpDir);
|
|
3280
|
-
try {
|
|
3281
|
-
await rename(tmpDir, repoDir);
|
|
3282
|
-
} catch (error) {
|
|
3283
|
-
if (existsSync(join(repoDir, ".git"))) {
|
|
3284
|
-
await rm(tmpDir, {
|
|
3285
|
-
recursive: true,
|
|
3286
|
-
force: true
|
|
3287
|
-
});
|
|
3288
|
-
return repoDir;
|
|
3289
|
-
}
|
|
3290
|
-
throw error;
|
|
3291
|
-
}
|
|
3292
|
-
} catch (error) {
|
|
3293
|
-
await rm(tmpDir, {
|
|
3294
|
-
recursive: true,
|
|
3295
|
-
force: true
|
|
3296
|
-
});
|
|
3297
|
-
throw error;
|
|
3298
|
-
}
|
|
3299
|
-
return repoDir;
|
|
3645
|
+
function positionFile$1(note) {
|
|
3646
|
+
return note.position?.new_path ?? note.position?.old_path ?? null;
|
|
3300
3647
|
}
|
|
3301
3648
|
/**
|
|
3302
|
-
*
|
|
3303
|
-
*
|
|
3649
|
+
* Extracts prior review threads from existing MR discussions that are relevant
|
|
3650
|
+
* to the current diff.
|
|
3304
3651
|
*
|
|
3305
|
-
*
|
|
3306
|
-
*
|
|
3307
|
-
*
|
|
3308
|
-
*
|
|
3309
|
-
* 4. `git:` / `git+ssh:` — shallow clone at the pinned ref, cached on disk,
|
|
3310
|
-
* loading `SKILL.md` from the repo root or the `#<ref>/<subpath>` directory
|
|
3652
|
+
* A thread is included when:
|
|
3653
|
+
* - It contains at least one bot note (identified by fingerprint marker).
|
|
3654
|
+
* - It contains at least one non-system human reply after the bot note.
|
|
3655
|
+
* - The thread's file appears in `changedFiles`.
|
|
3311
3656
|
*
|
|
3312
|
-
*
|
|
3313
|
-
*
|
|
3657
|
+
* Resolved threads are included but marked with `resolved: true` so the
|
|
3658
|
+
* reviewer can reference them without re-raising the concern.
|
|
3314
3659
|
*/
|
|
3315
|
-
|
|
3316
|
-
const
|
|
3317
|
-
|
|
3318
|
-
const
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
const resolvedPath = parsed.path.startsWith("/") ? parsed.path : join(cwd, parsed.path);
|
|
3334
|
-
const skill = await loadSkillFromDir(resolvedPath, "file");
|
|
3335
|
-
if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `No valid SKILL.md was found at "${resolvedPath}". Check that the path points to a skill directory.` });
|
|
3336
|
-
return skill;
|
|
3337
|
-
}
|
|
3338
|
-
let repoDir;
|
|
3339
|
-
try {
|
|
3340
|
-
repoDir = await cloneGitSkill(parsed.url, parsed.ref, {
|
|
3341
|
-
cacheDir: options.cacheDir ?? resolveSkillCacheDir(),
|
|
3342
|
-
refresh: options.refresh ?? false
|
|
3343
|
-
});
|
|
3344
|
-
} catch (error) {
|
|
3345
|
-
const atRef = parsed.ref ? ` at ref "${parsed.ref}"` : "";
|
|
3346
|
-
throw new ConfigError(`Cannot load skill: "${spec}"`, {
|
|
3347
|
-
cause: error,
|
|
3348
|
-
hint: `Failed to clone "${parsed.url}"${atRef}. Check the URL, the ref, and your git credentials. For GitLab, prefer the SSH form: git+ssh://git@host/group/project.git`
|
|
3660
|
+
function extractPriorThreads(discussions, changedFiles) {
|
|
3661
|
+
const threads = [];
|
|
3662
|
+
for (const discussion of discussions) {
|
|
3663
|
+
const notes = discussion.notes ?? [];
|
|
3664
|
+
const botNoteIndex = notes.findIndex(isBotNote);
|
|
3665
|
+
if (botNoteIndex === -1) continue;
|
|
3666
|
+
const botNote = notes[botNoteIndex];
|
|
3667
|
+
const file = positionFile$1(botNote);
|
|
3668
|
+
if (!file || !changedFiles.has(file)) continue;
|
|
3669
|
+
const replies = notes.slice(botNoteIndex + 1).filter((n) => !n.system && (n.body?.trim() ?? "")).filter((n) => !isBotNote(n)).map((n) => n.body?.trim() ?? "");
|
|
3670
|
+
if (replies.length === 0) continue;
|
|
3671
|
+
const resolved = notes.some((n) => n.resolved === true);
|
|
3672
|
+
threads.push({
|
|
3673
|
+
file,
|
|
3674
|
+
line: positionLine$1(botNote),
|
|
3675
|
+
resolved,
|
|
3676
|
+
botComment: normalizeBody(botNote.body ?? ""),
|
|
3677
|
+
replies
|
|
3349
3678
|
});
|
|
3350
3679
|
}
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3680
|
+
return threads;
|
|
3681
|
+
}
|
|
3682
|
+
/**
|
|
3683
|
+
* Renders a `<prior_review_feedback>` XML block from a list of prior threads.
|
|
3684
|
+
* Returns an empty string when `threads` is empty.
|
|
3685
|
+
*/
|
|
3686
|
+
function renderPriorThreadsBlock(threads) {
|
|
3687
|
+
if (threads.length === 0) return "";
|
|
3688
|
+
return `<prior_review_feedback>\n${threads.map((t) => {
|
|
3689
|
+
return ` <thread ${[
|
|
3690
|
+
`file="${t.file}"`,
|
|
3691
|
+
t.line !== null ? `line="${t.line}"` : null,
|
|
3692
|
+
`resolved="${t.resolved}"`
|
|
3693
|
+
].filter(Boolean).join(" ")}>\n${` <comment>${escapeXml(t.botComment)}</comment>`}\n${t.replies.map((r) => ` <reply>${escapeXml(r)}</reply>`).join("\n")}\n </thread>`;
|
|
3694
|
+
}).join("\n")}\n</prior_review_feedback>`;
|
|
3695
|
+
}
|
|
3696
|
+
function escapeXml(text) {
|
|
3697
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3354
3698
|
}
|
|
3355
3699
|
//#endregion
|
|
3356
3700
|
//#region src/skipped-retrieval.ts
|
|
@@ -3689,10 +4033,14 @@ async function loadReviewContext(cwd, skillNames = [], warn, options = {}) {
|
|
|
3689
4033
|
walkUpContextFiles(cwd, REVIEW_RULE_FILES, gitRoot),
|
|
3690
4034
|
loadAutoDiscoveredSkills(cwd, gitRoot, warn)
|
|
3691
4035
|
]);
|
|
4036
|
+
const registry = buildMarketplaceRegistry(options.marketplaces ?? []);
|
|
4037
|
+
const knownMarketplaces = new Set(registry.keys());
|
|
3692
4038
|
const skills = [...discovered];
|
|
3693
4039
|
const discoveredNames = new Set(discovered.map((s) => s.name));
|
|
3694
4040
|
const named = await Promise.all(skillNames.filter((n) => !discoveredNames.has(n)).map(async (n) => {
|
|
3695
4041
|
try {
|
|
4042
|
+
const spec = parseSkillSpec(n, knownMarketplaces);
|
|
4043
|
+
if (spec.protocol === "marketplace") return await loadMarketplaceSkill(spec, registry, { refresh: options.refreshGitSkills });
|
|
3696
4044
|
return await loadNamedSkill(n, cwd, { refresh: options.refreshGitSkills });
|
|
3697
4045
|
} catch (error) {
|
|
3698
4046
|
warn?.(`Skipping skill "${n}": ${formatError(error)}`);
|
|
@@ -4329,7 +4677,10 @@ async function runReview(config, options) {
|
|
|
4329
4677
|
retrieved: retrievableSkipped.length > 0
|
|
4330
4678
|
};
|
|
4331
4679
|
}
|
|
4332
|
-
const context = await loadReviewContext(cwd, config.skills, (msg) => logger.warn(msg), {
|
|
4680
|
+
const context = await loadReviewContext(cwd, config.skills, (msg) => logger.warn(msg), {
|
|
4681
|
+
refreshGitSkills: config.refreshGitSkills,
|
|
4682
|
+
marketplaces: config.marketplaces
|
|
4683
|
+
});
|
|
4333
4684
|
const systemPrompt = buildJSONSystemPrompt(context, minSeverity);
|
|
4334
4685
|
const userPrompt = buildUserPrompt(promptDiff, promptSkippedFiles, options.commitLog, options.priorThreads, options.intent, promptCoverage, retrievableSkipped, diskMode, commitsMode ? { sinceRef: options.sinceRef } : void 0);
|
|
4335
4686
|
const skillNames = context.skills.map((s) => s.name);
|
|
@@ -5265,7 +5616,7 @@ async function loadDefaultRuntime() {
|
|
|
5265
5616
|
const [sdkNode, resources, semconv] = modules;
|
|
5266
5617
|
const serviceResource = resources.resourceFromAttributes({
|
|
5267
5618
|
[semconv.ATTR_SERVICE_NAME ?? "service.name"]: SERVICE_NAME,
|
|
5268
|
-
[semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.9.
|
|
5619
|
+
[semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.9.4"
|
|
5269
5620
|
});
|
|
5270
5621
|
applyOtelExporterDefaults(process.env);
|
|
5271
5622
|
const sdk = new sdkNode.NodeSDK({ resource: resources.defaultResource().merge(serviceResource) });
|
|
@@ -5737,7 +6088,7 @@ function boldCommentTitle(body) {
|
|
|
5737
6088
|
*/
|
|
5738
6089
|
function buildCommentBody(body, commitSha, confidence) {
|
|
5739
6090
|
const confidenceLine = `_Confidence: ${confidence}._`;
|
|
5740
|
-
const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.9.
|
|
6091
|
+
const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.9.4 for commit ${commitSha}.</sub>`;
|
|
5741
6092
|
return `${boldCommentTitle(body.trim())}\n\n${confidenceLine}\n\n---\n\n${footer}`;
|
|
5742
6093
|
}
|
|
5743
6094
|
function buildPayload(comment, body, refs, resolved) {
|
|
@@ -6844,10 +7195,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
6844
7195
|
return;
|
|
6845
7196
|
}
|
|
6846
7197
|
if (argv.includes("--version") || argv.includes("-v")) {
|
|
6847
|
-
console.log("0.9.
|
|
7198
|
+
console.log("0.9.4");
|
|
6848
7199
|
return;
|
|
6849
7200
|
}
|
|
6850
|
-
process.stderr.write(`[code-review] @weareikko/code-review v0.9.
|
|
7201
|
+
process.stderr.write(`[code-review] @weareikko/code-review v0.9.4\n`);
|
|
6851
7202
|
assertNodeVersion();
|
|
6852
7203
|
applyCodeReviewEnvPrefix();
|
|
6853
7204
|
applyDefaultCacheRetention();
|
|
@@ -6868,6 +7219,6 @@ if (isDirectRun()) main().catch((error) => {
|
|
|
6868
7219
|
process.exitCode = 1;
|
|
6869
7220
|
});
|
|
6870
7221
|
//#endregion
|
|
6871
|
-
export {
|
|
7222
|
+
export { resolveNpmSkillDir as $, SUMMARY_MARKER as A, findExistingSummaryNoteId as B, normalizeSeverity as C, SUMMARY_HISTORY_ENTRY_START as D, SUMMARY_HISTORY_ENTRY_END as E, buildSummaryHistoryEntries as F, extractDiffHunkContext as G, stripSummaryMarker as H, extractReviewedCommitSha as I, normalizeBody as J, extractExistingFingerprints as K, extractSummaryHistoryEntries as L, buildReviewedCommitFooter as M, buildSizeNoticeBlock as N, SUMMARY_HISTORY_LIMIT as O, buildSummaryBody as P, parseSkillSpec as Q, findExistingReviewedCommitSha as R, traceDiagnosticPhase as S, SUMMARY_HISTORY_END as T, upsertSummaryNote as U, stripSummaryHistory as V, appendFingerprintMarkers as W, gitSkillCacheKey as X, sha256 as Y, loadNamedSkill as Z, DIAGNOSTIC_CHANNEL_PREFIX as _, main as a, diagnosticChannels as b, buildGeneratedComments as c, startOtelBridge as d, resolveSkillCacheDir as et, filterDiff as f, DIAGNOSTIC_CHANNEL_NAMES as g, parseReviewMarkdownWithWarnings as h, formatUsageLine as i, buildArchivedSummaryEntry as j, SUMMARY_HISTORY_START as k, buildPayload as l, parseReviewMarkdown as m, formatPerModelUsage as n, run as o, runReview as p, fingerprints as q, formatSkillsFooter as r, withHttpStamping as s, countPostedBySeverity as t, isOtelEnabled as u, createDiagnosticContext as v, toGitLabReviewSeverity as w, traceDiagnostic as x, createDiagnosticRunId as y, findExistingSummaryNote as z };
|
|
6872
7223
|
|
|
6873
|
-
//# sourceMappingURL=cli-
|
|
7224
|
+
//# sourceMappingURL=cli-C3kNr0rX.js.map
|