@weareikko/code-review 0.9.4 → 0.9.6

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.
@@ -2,7 +2,7 @@ import { mkdir, readFile, readdir, realpath, rename, rm, unlink, writeFile } fro
2
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
- import { getEnvApiKey, getModel } from "@earendil-works/pi-ai";
5
+ import { getEnvApiKey, streamSimple } from "@earendil-works/pi-ai/compat";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { homedir } from "node:os";
8
8
  import { parse } from "yaml";
@@ -11,6 +11,7 @@ import { promisify } from "node:util";
11
11
  import { tracingChannel } from "node:diagnostics_channel";
12
12
  import { performance } from "node:perf_hooks";
13
13
  import { Agent } from "@earendil-works/pi-agent-core";
14
+ import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all";
14
15
  import { createReadOnlyTools } from "@earendil-works/pi-coding-agent";
15
16
  import { createTwoFilesPatch } from "diff";
16
17
  import * as git from "isomorphic-git";
@@ -540,6 +541,10 @@ function summarizeDiff(diff) {
540
541
  }
541
542
  //#endregion
542
543
  //#region src/skills.ts
544
+ /** Normalize a filesystem-relative path to the POSIX form used in URLs. */
545
+ function toPosixPath(path) {
546
+ return sep === "/" ? path : path.split(sep).join("/");
547
+ }
543
548
  var SKILL_DIRS = [".agents/skills", ".claude/skills"];
544
549
  var RESOURCE_DIRS = ["references"];
545
550
  function parseFrontmatter(content) {
@@ -562,7 +567,7 @@ function parseFrontmatter(content) {
562
567
  description: trimmedDescription
563
568
  };
564
569
  }
565
- async function loadSkillFromDir(dirPath, source) {
570
+ async function loadSkillFromDir(dirPath, origin) {
566
571
  const skillMdPath = join(dirPath, "SKILL.md");
567
572
  let content;
568
573
  try {
@@ -579,14 +584,17 @@ async function loadSkillFromDir(dirPath, source) {
579
584
  filePath: skillMdPath,
580
585
  rootDir: dirPath,
581
586
  resourceDirs,
582
- source
587
+ origin
583
588
  };
584
589
  }
585
590
  function resolveBuiltinSkillsDir() {
586
591
  return join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
587
592
  }
588
593
  async function loadBuiltinSkill(name) {
589
- return loadSkillFromDir(join(resolveBuiltinSkillsDir(), name), "builtin");
594
+ return loadSkillFromDir(join(resolveBuiltinSkillsDir(), name), {
595
+ kind: "builtin",
596
+ name
597
+ });
590
598
  }
591
599
  async function loadAutoDiscoveredSkills(cwd, gitRoot, warn) {
592
600
  const dirs = [];
@@ -609,7 +617,10 @@ async function loadAutoDiscoveredSkills(cwd, gitRoot, warn) {
609
617
  }
610
618
  for (const entry of entries) {
611
619
  const entryPath = join(skillsPath, entry);
612
- const skill = await loadSkillFromDir(entryPath, "project");
620
+ const skill = await loadSkillFromDir(entryPath, {
621
+ kind: "project",
622
+ path: toPosixPath(relative(gitRoot, entryPath))
623
+ });
613
624
  if (skill) found.set(skill.name, skill);
614
625
  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
626
  }
@@ -897,13 +908,20 @@ async function loadNamedSkill(spec, cwd, options = {}) {
897
908
  const pkgRef = parsed.subpath ? `${parsed.packageName} (subpath "${parsed.subpath}")` : parsed.packageName;
898
909
  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
910
  }
900
- const skill = await loadSkillFromDir(dir, "npm");
911
+ const skill = await loadSkillFromDir(dir, {
912
+ kind: "npm",
913
+ packageName: parsed.packageName,
914
+ subpath: parsed.subpath
915
+ });
901
916
  if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `The package at ${dir} does not contain a valid SKILL.md.` });
902
917
  return skill;
903
918
  }
904
919
  if (parsed.protocol === "file") {
905
920
  const resolvedPath = parsed.path.startsWith("/") ? parsed.path : join(cwd, parsed.path);
906
- const skill = await loadSkillFromDir(resolvedPath, "file");
921
+ const skill = await loadSkillFromDir(resolvedPath, {
922
+ kind: "file",
923
+ path: resolvedPath
924
+ });
907
925
  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
926
  return skill;
909
927
  }
@@ -921,7 +939,12 @@ async function loadNamedSkill(spec, cwd, options = {}) {
921
939
  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
940
  });
923
941
  }
924
- const skill = await loadSkillFromDir(parsed.subpath ? join(repoDir, parsed.subpath) : repoDir, "git");
942
+ const skill = await loadSkillFromDir(parsed.subpath ? join(repoDir, parsed.subpath) : repoDir, {
943
+ kind: "git",
944
+ url: parsed.url,
945
+ ref: parsed.ref,
946
+ path: parsed.subpath
947
+ });
925
948
  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
949
  return skill;
927
950
  }
@@ -1060,7 +1083,7 @@ async function resolveAnthropicSkill(repoDir, mp, spec) {
1060
1083
  const pluginDir = resolvePluginDir(repoDir, manifest, entry.source, mp, spec);
1061
1084
  const realPluginDir = await resolveInside(realRoot, pluginDir, ref);
1062
1085
  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);
1086
+ const skill = await findSkillInBases(realRoot, bases, pluginDir, spec, ref, mp);
1064
1087
  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
1088
  return skill;
1066
1089
  }
@@ -1120,14 +1143,15 @@ function resolveUnderPlugin(repoDir, pluginDir, rel, ref) {
1120
1143
  * symlinks and reject any directory that escapes the cloned marketplace before a
1121
1144
  * file is read.
1122
1145
  */
1123
- async function findSkillInBases(realRoot, bases, pluginDir, skillName, ref) {
1146
+ async function findSkillInBases(realRoot, bases, pluginDir, spec, ref, mp) {
1147
+ const skillName = spec.skill;
1124
1148
  for (const base of bases) {
1125
- const container = await loadSkillIfInside(realRoot, join(base, skillName), ref);
1149
+ const container = await loadSkillIfInside(realRoot, join(base, skillName), ref, mp, spec);
1126
1150
  if (container) return container;
1127
- const single = await loadSkillIfInside(realRoot, base, ref);
1151
+ const single = await loadSkillIfInside(realRoot, base, ref, mp, spec);
1128
1152
  if (single && single.name === skillName) return single;
1129
1153
  }
1130
- const root = await loadSkillIfInside(realRoot, pluginDir, ref);
1154
+ const root = await loadSkillIfInside(realRoot, pluginDir, ref, mp, spec);
1131
1155
  return root && root.name === skillName ? root : null;
1132
1156
  }
1133
1157
  /**
@@ -1172,11 +1196,18 @@ async function readTextInside(realRoot, filePath, ref) {
1172
1196
  * resolve within `realRoot`. Guarding the file (not just the directory) closes
1173
1197
  * the case where a legitimate in-repo dir holds a `SKILL.md` symlinked outside.
1174
1198
  */
1175
- async function loadSkillIfInside(realRoot, dir, ref) {
1199
+ async function loadSkillIfInside(realRoot, dir, ref, mp, spec) {
1176
1200
  const real = await resolveInside(realRoot, dir, ref);
1177
1201
  if (!real) return null;
1178
1202
  if (await readTextInside(realRoot, join(real, "SKILL.md"), ref) === null) return null;
1179
- return loadSkillFromDir(real, "marketplace");
1203
+ return loadSkillFromDir(real, {
1204
+ kind: "marketplace",
1205
+ marketplace: mp.name,
1206
+ plugin: spec.plugin,
1207
+ url: mp.url,
1208
+ ref: mp.ref,
1209
+ path: toPosixPath(relative(realRoot, real))
1210
+ });
1180
1211
  }
1181
1212
  /**
1182
1213
  * Resolve a plugin entry's `source` to an absolute directory inside the cloned
@@ -1303,13 +1334,24 @@ function extractExistingFingerprints(discussions) {
1303
1334
  for (const discussion of discussions) for (const note of discussion.notes ?? []) for (const match of String(note.body ?? "").matchAll(FINGERPRINT_MARKER_RE$2)) set.add(match[1]);
1304
1335
  return set;
1305
1336
  }
1337
+ //#endregion
1338
+ //#region src/product.ts
1339
+ /**
1340
+ * The published package name, shown in review footers. Independent of the review
1341
+ * platform: the same tool posts to GitLab and GitHub, so the footer identifies
1342
+ * the tool, not the backend. Kept as a single source of truth so the inline and
1343
+ * summary footers can never drift apart.
1344
+ */
1345
+ var PRODUCT_NAME = "@weareikko/code-review";
1346
+ /** Canonical project URL used in the footer's markdown link. */
1347
+ var PRODUCT_URL = "https://github.com/weareikko/code-review";
1306
1348
  /**
1307
1349
  * The `[name](url)` markdown link used verbatim in both the inline comment footer
1308
1350
  * ({@link buildCommentBody}) and the reviewed-commit summary footer
1309
1351
  * ({@link buildReviewedCommitFooter}). Changing this changes the reviewed-commit
1310
1352
  * footer format, which is guarded by a migration test.
1311
1353
  */
1312
- var PRODUCT_LINK = `[@weareikko/code-review](https://github.com/weareikko/code-review)`;
1354
+ var PRODUCT_LINK = `[${PRODUCT_NAME}](${PRODUCT_URL})`;
1313
1355
  //#endregion
1314
1356
  //#region src/posting.ts
1315
1357
  var SUMMARY_MARKER = "<!-- code-review:summary -->";
@@ -1389,7 +1431,7 @@ function buildSummaryBody(summary, costFooter, options = {}) {
1389
1431
  return `${withFooter}\n\n${buildSummaryHistoryBlock(historyEntries)}`;
1390
1432
  }
1391
1433
  function buildReviewedCommitFooter(commitSha) {
1392
- return `Reviewed by ${PRODUCT_LINK} v0.9.4 for commit ${commitSha}.`;
1434
+ return `Reviewed by ${PRODUCT_LINK} v0.9.6 for commit ${commitSha}.`;
1393
1435
  }
1394
1436
  function extractReviewedCommitSha(body) {
1395
1437
  return REVIEWED_COMMIT_FOOTER_PATTERN.exec(body)?.[1] ?? null;
@@ -2034,6 +2076,16 @@ function detectPlatform(args, env, readEventFile = readEventFileSync) {
2034
2076
  if (hasGitLab && !hasGitHub) return "gitlab";
2035
2077
  throw new ConfigError(hasGitHub && hasGitLab ? "Ambiguous review platform: both GitHub and GitLab identifiers are present." : "Could not detect the review platform from the environment.", { hint: `Set --platform (or CODE_REVIEW_PLATFORM) to one of: ${PLATFORMS.join(", ")}.` });
2036
2078
  }
2079
+ /**
2080
+ * Web URL of the repository under review, as the CI environment reports it:
2081
+ * GitLab exposes it directly, GitHub composes it from the server and the
2082
+ * `owner/repo` slug. Returns `undefined` outside CI, where no such URL exists.
2083
+ * Shared by the config (skill source links) and the OTel attribute builder.
2084
+ */
2085
+ function resolveProjectWebUrl(env = process.env) {
2086
+ if (env.CI_PROJECT_URL) return env.CI_PROJECT_URL.replace(/\/$/, "");
2087
+ if (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY) return `${env.GITHUB_SERVER_URL.replace(/\/$/, "")}/${env.GITHUB_REPOSITORY}`;
2088
+ }
2037
2089
  function resolveConfig(argv = process.argv.slice(2), env = process.env) {
2038
2090
  const args = parseArgs(argv);
2039
2091
  const platform = detectPlatform(args, env);
@@ -2041,6 +2093,7 @@ function resolveConfig(argv = process.argv.slice(2), env = process.env) {
2041
2093
  const token = resolveGitLabToken(args, env);
2042
2094
  const githubApiUrl = String(args.githubApiUrl ?? env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/, "");
2043
2095
  const githubServerUrl = String(args.githubServerUrl ?? env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/$/, "");
2096
+ const githubRepository = String(args.githubRepository ?? env.GITHUB_REPOSITORY ?? "");
2044
2097
  const model = String(args.model ?? env.CODE_REVIEW_MODEL ?? "");
2045
2098
  const apiKey = String(args.apiKey ?? resolveProviderApiKey(model) ?? "");
2046
2099
  const baseUrl = String(args.baseUrl ?? first(env.CODE_REVIEW_BASE_URL, resolveOllamaBaseUrl(model, env)) ?? "");
@@ -2059,11 +2112,12 @@ function resolveConfig(argv = process.argv.slice(2), env = process.env) {
2059
2112
  gitlabUrl,
2060
2113
  gitlabToken: token.token,
2061
2114
  gitlabAuthHeader: token.header,
2062
- githubRepository: String(args.githubRepository ?? env.GITHUB_REPOSITORY ?? ""),
2115
+ githubRepository,
2063
2116
  githubPr: resolveGitHubPr(args, env),
2064
2117
  githubToken: String(args.githubToken ?? env.GITHUB_TOKEN ?? ""),
2065
2118
  githubApiUrl,
2066
2119
  githubServerUrl,
2120
+ projectWebUrl: resolveProjectWebUrl(env) ?? "",
2067
2121
  model,
2068
2122
  modelPool: resolveModelPool(args, env),
2069
2123
  minSeverity: normalizeChoice(args.minSeverity ?? env.CODE_REVIEW_MIN_SEVERITY ?? "info"),
@@ -3961,6 +4015,26 @@ var SEVERITY_RULE = {
3961
4015
  CRITICAL: "- Only report CRITICAL issues — skip WARN and INFO"
3962
4016
  };
3963
4017
  var exec = promisify(execFile);
4018
+ /**
4019
+ * Stream function for the reviewer agent.
4020
+ *
4021
+ * pi-ai >=0.82 requires an explicit stream function (earlier versions built one
4022
+ * internally from model + getApiKey); `streamSimple` is the drop-in. Critically,
4023
+ * 0.83 also moved cloudflare-ai-gateway base-URL substitution — the
4024
+ * `{CLOUDFLARE_ACCOUNT_ID}` / `{CLOUDFLARE_GATEWAY_ID}` placeholders — from a
4025
+ * direct `process.env` read to an explicit `env` on the stream options. Without
4026
+ * threading `env` through, the gateway URL keeps its literal placeholders and
4027
+ * every request fails with Cloudflare 401 2035 ("Invalid request path"). Passing
4028
+ * `env` is harmless for providers whose base URL has no placeholders.
4029
+ *
4030
+ * `stream` is injectable so the env threading can be unit-tested without a live call.
4031
+ */
4032
+ function createReviewStreamFn(stream = streamSimple) {
4033
+ return (model, context, options) => stream(model, context, {
4034
+ ...options,
4035
+ env: process.env
4036
+ });
4037
+ }
3964
4038
  function defaultCreateAgent(params) {
3965
4039
  return new Agent({
3966
4040
  initialState: {
@@ -3969,7 +4043,8 @@ function defaultCreateAgent(params) {
3969
4043
  tools: params.tools,
3970
4044
  thinkingLevel: params.thinkingLevel
3971
4045
  },
3972
- getApiKey: params.getApiKey
4046
+ getApiKey: params.getApiKey,
4047
+ streamFn: createReviewStreamFn()
3973
4048
  });
3974
4049
  }
3975
4050
  async function findGitRoot(cwd) {
@@ -4447,7 +4522,7 @@ function resolveModel(modelString, baseUrl, maxTokens) {
4447
4522
  const { provider, modelId } = splitModel(modelString);
4448
4523
  if (provider === void 0 || modelId === void 0) throw new ReviewerError(`Invalid model format "${modelString}". Expected "provider/modelId" (e.g. "anthropic/claude-sonnet-4-5").`);
4449
4524
  if (provider === "ollama") return buildOllamaModel(modelId, baseUrl || "http://localhost:11434/v1", maxTokens);
4450
- const model = getModel(provider, modelId);
4525
+ const model = getBuiltinModel(provider, modelId);
4451
4526
  if (!model) throw new ReviewerError(`Unknown model "${modelString}".`, { hint: `Check that "${provider}" is a valid provider and "${modelId}" is a registered model ID.` });
4452
4527
  if (baseUrl || maxTokens > 0) return {
4453
4528
  ...model,
@@ -4712,7 +4787,10 @@ async function runReview(config, options) {
4712
4787
  tokens: aggregated.tokens,
4713
4788
  cost: aggregated.cost,
4714
4789
  byModel: buildByModelUsage(aggregated),
4715
- skills: context.skills.map((s) => s.name),
4790
+ skills: context.skills.map((s) => ({
4791
+ name: s.name,
4792
+ origin: s.origin
4793
+ })),
4716
4794
  sizeNotice
4717
4795
  });
4718
4796
  let outputText;
@@ -5616,7 +5694,7 @@ async function loadDefaultRuntime() {
5616
5694
  const [sdkNode, resources, semconv] = modules;
5617
5695
  const serviceResource = resources.resourceFromAttributes({
5618
5696
  [semconv.ATTR_SERVICE_NAME ?? "service.name"]: SERVICE_NAME,
5619
- [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.9.4"
5697
+ [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.9.6"
5620
5698
  });
5621
5699
  applyOtelExporterDefaults(process.env);
5622
5700
  const sdk = new sdkNode.NodeSDK({ resource: resources.defaultResource().merge(serviceResource) });
@@ -5800,7 +5878,7 @@ function buildCiSpanAttrs(env) {
5800
5878
  if (taskRunId) attrs["cicd.pipeline.task.run.id"] = taskRunId;
5801
5879
  const pipelineRunId = env.CI_PIPELINE_ID ?? env.GITHUB_RUN_ID;
5802
5880
  if (pipelineRunId) attrs["cicd.pipeline.run.id"] = pipelineRunId;
5803
- const repositoryUrl = env.CI_PROJECT_URL ?? (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}` : void 0);
5881
+ const repositoryUrl = resolveProjectWebUrl(env);
5804
5882
  if (repositoryUrl) attrs["vcs.repository.url.full"] = repositoryUrl;
5805
5883
  return attrs;
5806
5884
  }
@@ -6088,7 +6166,7 @@ function boldCommentTitle(body) {
6088
6166
  */
6089
6167
  function buildCommentBody(body, commitSha, confidence) {
6090
6168
  const confidenceLine = `_Confidence: ${confidence}._`;
6091
- const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.9.4 for commit ${commitSha}.</sub>`;
6169
+ const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.9.6 for commit ${commitSha}.</sub>`;
6092
6170
  return `${boldCommentTitle(body.trim())}\n\n${confidenceLine}\n\n---\n\n${footer}`;
6093
6171
  }
6094
6172
  function buildPayload(comment, body, refs, resolved) {
@@ -6661,6 +6739,99 @@ function createPlatform(config) {
6661
6739
  return new GitLabPlatform(config);
6662
6740
  }
6663
6741
  //#endregion
6742
+ //#region src/skill-links.ts
6743
+ /**
6744
+ * Hosts that serve blobs at `/<repo>/blob/<ref>/<path>`. Everything else is
6745
+ * assumed to be GitLab (`/<repo>/-/blob/<ref>/<path>`), which covers both
6746
+ * gitlab.com and the self-hosted instances this tool mostly runs against.
6747
+ */
6748
+ var GITHUB_HOSTS = new Set(["github.com", "www.github.com"]);
6749
+ /** Percent-encode each path segment while keeping the `/` separators intact. */
6750
+ function encodePath(path) {
6751
+ return path.split("/").filter(Boolean).map((segment) => encodeURIComponent(segment)).join("/");
6752
+ }
6753
+ /**
6754
+ * Build a blob URL for `path` at `ref` inside the repository served at
6755
+ * `repoWebUrl`. An empty ref resolves to `HEAD`, which both GitHub and GitLab
6756
+ * accept, so a skill pinned to a remote's default branch still links.
6757
+ */
6758
+ function blobUrl(repoWebUrl, ref, path) {
6759
+ let parsed;
6760
+ try {
6761
+ parsed = new URL(repoWebUrl);
6762
+ } catch {
6763
+ return;
6764
+ }
6765
+ return `${`${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`}/${GITHUB_HOSTS.has(parsed.host) ? "blob" : "-/blob"}/${encodeURIComponent(ref || "HEAD")}/${encodePath(path)}`;
6766
+ }
6767
+ /**
6768
+ * Convert a git clone URL to the repository's web URL: drop any transport
6769
+ * marker, embedded credentials, and the `.git` suffix, and serve it over HTTPS.
6770
+ * Accepts `https://`, `ssh://`, `git+<transport>://`, and scp-style
6771
+ * (`git@host:group/repo.git`) forms. Returns `undefined` for anything it cannot
6772
+ * parse, so an exotic remote simply yields an unlinked skill.
6773
+ */
6774
+ function gitRepoWebUrl(cloneUrl) {
6775
+ let raw = cloneUrl.trim();
6776
+ if (!raw) return void 0;
6777
+ if (raw.startsWith("git+")) raw = raw.slice(4);
6778
+ const scp = /^(?:[^@/]+@)?([^/:]+):(?!\/)(.+)$/.exec(raw);
6779
+ if (scp) raw = `ssh://${scp[1]}/${scp[2]}`;
6780
+ let parsed;
6781
+ try {
6782
+ parsed = new URL(raw);
6783
+ } catch {
6784
+ return;
6785
+ }
6786
+ if (!parsed.host) return void 0;
6787
+ const path = parsed.pathname.replace(/\.git\/?$/, "").replace(/\/+$/, "");
6788
+ if (!path || path === "/") return void 0;
6789
+ return `https://${parsed.host}${path}`;
6790
+ }
6791
+ /**
6792
+ * Resolve a link to a skill's `SKILL.md`, or `undefined` when the source is not
6793
+ * reachable from a browser (a `file:` path, or an in-repo skill on a run with no
6794
+ * CI project URL). Built-in skills link to the published tag of this package, so
6795
+ * the link always shows the skill exactly as the run used it.
6796
+ */
6797
+ function skillSourceUrl(origin, context = {}) {
6798
+ switch (origin.kind) {
6799
+ case "builtin": return blobUrl(PRODUCT_URL, "0.9.6", `skills/${origin.name}/SKILL.md`);
6800
+ case "project":
6801
+ if (!context.projectWebUrl || !context.commitSha) return void 0;
6802
+ return blobUrl(context.projectWebUrl, context.commitSha, `${origin.path}/SKILL.md`);
6803
+ case "npm": return `https://www.npmjs.com/package/${origin.packageName}`;
6804
+ case "file": return;
6805
+ case "git":
6806
+ case "marketplace": {
6807
+ const repoWebUrl = gitRepoWebUrl(origin.url);
6808
+ if (!repoWebUrl) return void 0;
6809
+ const path = origin.path ? `${origin.path}/SKILL.md` : "SKILL.md";
6810
+ return blobUrl(repoWebUrl, origin.ref, path);
6811
+ }
6812
+ }
6813
+ }
6814
+ /**
6815
+ * The name a skill is shown under in the footer. Marketplace skills carry their
6816
+ * full selector (`<marketplace>:<plugin>/<skill>`) because a bare skill name says
6817
+ * nothing about which marketplace and plugin it came from — and that selector is
6818
+ * exactly what a developer would put in `CODE_REVIEW_SKILLS` to use it. Every
6819
+ * other source shows the skill's own name.
6820
+ */
6821
+ function skillDisplayName(skill) {
6822
+ return skill.origin.kind === "marketplace" ? `${skill.origin.marketplace}:${skill.origin.plugin}/${skill.name}` : skill.name;
6823
+ }
6824
+ /**
6825
+ * Render one skill for the summary footer: a link to its source when one can be
6826
+ * resolved, otherwise the bare name. The name keeps its code span either way so
6827
+ * linked and unlinked skills read the same.
6828
+ */
6829
+ function formatSkillLink(skill, context = {}) {
6830
+ const name = skillDisplayName(skill);
6831
+ const url = skillSourceUrl(skill.origin, context);
6832
+ return url ? `[\`${name}\`](${url})` : `\`${name}\``;
6833
+ }
6834
+ //#endregion
6664
6835
  //#region src/summary-carryover.ts
6665
6836
  var FINGERPRINT_MARKER_GLOBAL_RE = new RegExp(FINGERPRINT_MARKER_PATTERN, "gi");
6666
6837
  var RISK_RANK = {
@@ -7036,7 +7207,10 @@ async function run(config, bridges) {
7036
7207
  const summaryBody = withCarriedOverFindings(parsed.summary, discussions, currentFingerprints);
7037
7208
  const result = await platform.upsertSummary(summaryBody, discussions, {
7038
7209
  costFooter: [formatUsageLine(usage), formatPerModelUsage(usage)].filter(Boolean).join("\n\n"),
7039
- skillsFooter: formatSkillsFooter(usage.skills),
7210
+ skillsFooter: formatSkillsFooter(usage.skills, {
7211
+ projectWebUrl: config.projectWebUrl,
7212
+ commitSha: refs.head_sha
7213
+ }),
7040
7214
  reviewedCommitSha: refs.head_sha,
7041
7215
  runId,
7042
7216
  sizeNotice: usage.sizeNotice
@@ -7103,9 +7277,15 @@ function zeroReviewUsage(model, thinkingLevel) {
7103
7277
  sizeNotice: { sizeSkippedFiles: [] }
7104
7278
  };
7105
7279
  }
7106
- function formatSkillsFooter(skills) {
7280
+ /**
7281
+ * The summary footer's skills line. Each skill links to its `SKILL.md` source so
7282
+ * a developer reading the review can check what the reviewer was told; skills
7283
+ * with no reachable source (a `file:` path, or an in-repo skill on a run without
7284
+ * CI project coordinates) stay as plain names.
7285
+ */
7286
+ function formatSkillsFooter(skills, context = {}) {
7107
7287
  if (skills.length === 0) return void 0;
7108
- return `Skills: ${skills.map((s) => `\`${s}\``).join(", ")}`;
7288
+ return `Skills: ${skills.map((s) => formatSkillLink(s, context)).join(", ")}`;
7109
7289
  }
7110
7290
  function formatUsageLine(usage) {
7111
7291
  const formatter = new Intl.NumberFormat("en-US");
@@ -7195,10 +7375,10 @@ async function main(argv = process.argv.slice(2)) {
7195
7375
  return;
7196
7376
  }
7197
7377
  if (argv.includes("--version") || argv.includes("-v")) {
7198
- console.log("0.9.4");
7378
+ console.log("0.9.6");
7199
7379
  return;
7200
7380
  }
7201
- process.stderr.write(`[code-review] @weareikko/code-review v0.9.4\n`);
7381
+ process.stderr.write(`[code-review] @weareikko/code-review v0.9.6\n`);
7202
7382
  assertNodeVersion();
7203
7383
  applyCodeReviewEnvPrefix();
7204
7384
  applyDefaultCacheRetention();
@@ -7219,6 +7399,6 @@ if (isDirectRun()) main().catch((error) => {
7219
7399
  process.exitCode = 1;
7220
7400
  });
7221
7401
  //#endregion
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 };
7402
+ export { normalizeBody as $, SUMMARY_HISTORY_END as A, buildSummaryHistoryEntries as B, createDiagnosticContext as C, traceDiagnosticPhase as D, traceDiagnostic as E, SUMMARY_MARKER as F, findExistingSummaryNoteId as G, extractSummaryHistoryEntries as H, buildArchivedSummaryEntry as I, upsertSummaryNote as J, stripSummaryHistory as K, buildReviewedCommitFooter as L, SUMMARY_HISTORY_ENTRY_START as M, SUMMARY_HISTORY_LIMIT as N, normalizeSeverity as O, SUMMARY_HISTORY_START as P, fingerprints as Q, buildSizeNoticeBlock as R, DIAGNOSTIC_CHANNEL_PREFIX as S, diagnosticChannels as T, findExistingReviewedCommitSha as U, extractReviewedCommitSha as V, findExistingSummaryNote as W, extractDiffHunkContext as X, appendFingerprintMarkers as Y, extractExistingFingerprints as Z, filterDiff as _, main as a, resolveSkillCacheDir as at, parseReviewMarkdownWithWarnings as b, blobUrl as c, skillDisplayName as d, sha256 as et, skillSourceUrl as f, startOtelBridge as g, isOtelEnabled as h, formatUsageLine as i, resolveNpmSkillDir as it, SUMMARY_HISTORY_ENTRY_END as j, toGitLabReviewSeverity as k, formatSkillLink as l, buildPayload as m, formatPerModelUsage as n, loadNamedSkill as nt, run as o, buildGeneratedComments as p, stripSummaryMarker as q, formatSkillsFooter as r, parseSkillSpec as rt, withHttpStamping as s, countPostedBySeverity as t, gitSkillCacheKey as tt, gitRepoWebUrl as u, runReview as v, createDiagnosticRunId as w, DIAGNOSTIC_CHANNEL_NAMES as x, parseReviewMarkdown as y, buildSummaryBody as z };
7223
7403
 
7224
- //# sourceMappingURL=cli-C3kNr0rX.js.map
7404
+ //# sourceMappingURL=cli-D6vNV3kA.js.map