@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.
- package/dist/{cli-C3kNr0rX.js → cli-D6vNV3kA.js} +211 -31
- package/dist/cli-D6vNV3kA.js.map +1 -0
- package/dist/cli.d.ts +8 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/gitlab-review.d.ts +19 -1
- package/dist/gitlab-review.d.ts.map +1 -1
- package/dist/marketplaces.d.ts.map +1 -1
- package/dist/otel.d.ts.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +2 -2
- package/dist/skill-links.d.ts +52 -0
- package/dist/skill-links.d.ts.map +1 -0
- package/dist/skills.d.ts +35 -2
- package/dist/skills.d.ts.map +1 -1
- package/package.json +4 -4
- package/dist/cli-C3kNr0rX.js.map +0 -1
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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),
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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 = `[
|
|
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.
|
|
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
|
|
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 =
|
|
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) =>
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
7378
|
+
console.log("0.9.6");
|
|
7199
7379
|
return;
|
|
7200
7380
|
}
|
|
7201
|
-
process.stderr.write(`[code-review] @weareikko/code-review v0.9.
|
|
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 {
|
|
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-
|
|
7404
|
+
//# sourceMappingURL=cli-D6vNV3kA.js.map
|