Skip to content

Commit 2fcb731

Browse files
fix webhook review file links
1 parent 52c6d2b commit 2fcb731

2 files changed

Lines changed: 310 additions & 12 deletions

File tree

src/adapter.ts

Lines changed: 180 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,15 +1092,16 @@ async function publishResultIfConfigured(task: JsonObject, resultPath: string, t
10921092
}
10931093
}
10941094

1095-
function publicationCommentBody(task: JsonObject, result: JsonObject): string {
1095+
export function publicationCommentBody(task: JsonObject, result: JsonObject): string {
10961096
const status = result.status || "unknown";
1097-
const summary = String(result.summary || "No summary returned.");
1098-
const prBody = String(result.pr_body || "");
10991097
const filesChanged = Array.isArray(result.files_changed) ? result.files_changed : [];
11001098
const commits = Array.isArray(result.commits) ? result.commits : [];
11011099
const taskId = String(task.task_id || "");
11021100
const evidence = (task.review_evidence as JsonObject | undefined) || {};
11031101
const review = (result.review as JsonObject | undefined) || {};
1102+
const knownFiles = githubKnownFileSet(task, review);
1103+
const summary = linkGithubFileMentions(task, String(result.summary || "No summary returned."), knownFiles);
1104+
const prBody = linkGithubFileMentions(task, String(result.pr_body || ""), knownFiles);
11041105
const parts = ["## Cody dogfood result", "", `**Status:** ${status}`, "", summary.trim()];
11051106
if (prBody.trim() && prBody.trim() !== summary.trim()) {
11061107
parts.push("", prBody.trim());
@@ -1117,12 +1118,12 @@ function publicationCommentBody(task: JsonObject, result: JsonObject): string {
11171118
`- Review context SHA-256: \`${evidence.review_context_sha256}\``,
11181119
);
11191120
if (changedFiles.length) {
1120-
parts.push(`- Files: ${changedFiles.slice(0, 20).map((file) => `\`${file}\``).join(", ")}`);
1121+
parts.push(`- Files: ${changedFiles.slice(0, 20).map((file) => githubFileMarkdown(task, String(file))).join(", ")}`);
11211122
}
11221123
} else {
11231124
parts.push("- No PR review evidence was captured for this run.");
11241125
}
1125-
parts.push(...structuredReviewLines(review));
1126+
parts.push(...structuredReviewLines(review, task, knownFiles));
11261127
parts.push(
11271128
"",
11281129
`**Files changed:** ${filesChanged.length}`,
@@ -1133,6 +1134,171 @@ function publicationCommentBody(task: JsonObject, result: JsonObject): string {
11331134
return parts.join("\n");
11341135
}
11351136

1137+
function githubBlobBase(task: JsonObject): string | null {
1138+
const repository = String(task.repository || "").trim();
1139+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
1140+
return null;
1141+
}
1142+
const evidence = (task.review_evidence as JsonObject | undefined) || {};
1143+
const ref = String(evidence.head_sha || evidence.workspace_head_sha || task.default_branch || "").trim();
1144+
if (!ref) {
1145+
return null;
1146+
}
1147+
const encodedRef = encodeURIComponent(ref).replaceAll("%2F", "/");
1148+
return `https://github.com/${repository}/blob/${encodedRef}`;
1149+
}
1150+
1151+
function parseRepoRelativePath(rawPath: string): {display: string; path: string; line: number | null; endLine: number | null} | null {
1152+
const display = rawPath.trim();
1153+
if (!display || display !== rawPath || /[\s<>\[\]]/.test(display)) {
1154+
return null;
1155+
}
1156+
if (/^[\\/]/.test(display) || /^[A-Za-z]:[\\/]/.test(display)) {
1157+
return null;
1158+
}
1159+
1160+
let path = display.replaceAll("\\", "/");
1161+
let line: number | null = null;
1162+
let endLine: number | null = null;
1163+
const lineMatch = /^(.*):(\d+)(?:-(\d+))?$/.exec(path);
1164+
if (lineMatch) {
1165+
path = lineMatch[1];
1166+
line = Number(lineMatch[2]);
1167+
endLine = lineMatch[3] ? Number(lineMatch[3]) : null;
1168+
}
1169+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(path)) {
1170+
return null;
1171+
}
1172+
while (path.startsWith("./")) {
1173+
path = path.slice(2);
1174+
}
1175+
const segments = path.split("/");
1176+
const filename = segments.at(-1) || "";
1177+
if (
1178+
!path ||
1179+
path.includes("//") ||
1180+
segments.some((segment) => !segment || segment === "." || segment === "..") ||
1181+
!/\.[A-Za-z][A-Za-z0-9._-]{0,15}$/.test(filename)
1182+
) {
1183+
return null;
1184+
}
1185+
return {display, path, line, endLine};
1186+
}
1187+
1188+
function githubFileMarkdown(task: JsonObject, rawPath: string, lineOverride: number | null = null): string {
1189+
const target = parseRepoRelativePath(rawPath);
1190+
const base = githubBlobBase(task);
1191+
if (!target || !base) {
1192+
return `\`${rawPath}\``;
1193+
}
1194+
const encodedPath = target.path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
1195+
const line = lineOverride ?? target.line;
1196+
const endLine = lineOverride === null ? target.endLine : null;
1197+
const lineAnchor = line !== null && Number.isFinite(line) && line > 0
1198+
? `#L${line}${endLine !== null && Number.isFinite(endLine) && endLine > line ? `-L${endLine}` : ""}`
1199+
: "";
1200+
return `[\`${target.display}\`](${base}/${encodedPath}${lineAnchor})`;
1201+
}
1202+
1203+
function githubKnownFileSet(task: JsonObject, review: JsonObject = {}): Set<string> {
1204+
const knownFiles = new Set<string>();
1205+
const evidence = (task.review_evidence as JsonObject | undefined) || {};
1206+
const addFiles = (value: JsonValue | undefined) => {
1207+
if (!Array.isArray(value)) {
1208+
return;
1209+
}
1210+
for (const item of value) {
1211+
const target = parseRepoRelativePath(String(item));
1212+
if (target) {
1213+
knownFiles.add(target.path);
1214+
}
1215+
}
1216+
};
1217+
addFiles(evidence.changed_files);
1218+
addFiles(review.reviewed_files);
1219+
addFiles(review.supporting_files);
1220+
return knownFiles;
1221+
}
1222+
1223+
function inlineCodeWithGithubFileLinks(task: JsonObject, rawText: string): string {
1224+
const direct = githubFileMarkdown(task, rawText);
1225+
if (direct !== `\`${rawText}\``) {
1226+
return direct;
1227+
}
1228+
1229+
let linkedAny = false;
1230+
const parts = rawText.split(/(\s+)/).map((part) => {
1231+
if (!part || /^\s+$/.test(part)) {
1232+
return part;
1233+
}
1234+
const linked = githubFileMarkdown(task, part);
1235+
if (linked !== `\`${part}\``) {
1236+
linkedAny = true;
1237+
return linked;
1238+
}
1239+
return `\`${part}\``;
1240+
});
1241+
1242+
return linkedAny ? parts.join("") : `\`${rawText}\``;
1243+
}
1244+
1245+
function bareFileMentionAllowed(task: JsonObject, rawPath: string, knownFiles: Set<string>): boolean {
1246+
const target = parseRepoRelativePath(rawPath);
1247+
if (!target) {
1248+
return false;
1249+
}
1250+
return target.path.includes("/") || knownFiles.has(target.path);
1251+
}
1252+
1253+
function linkBareGithubFileMentions(task: JsonObject, text: string, knownFiles: Set<string>): string {
1254+
const markdownLinkPattern = /(\[[^\]]+\]\([^)]+\))/g;
1255+
const barePathPattern = /(^|[^\w./:[\]()`-])([A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*\.[A-Za-z][A-Za-z0-9_-]{0,15}(?::\d+(?:-\d+)?)?)(?=$|[^\w/-])/g;
1256+
return text
1257+
.split(markdownLinkPattern)
1258+
.map((segment, index) => {
1259+
if (index % 2 === 1) {
1260+
return segment;
1261+
}
1262+
return segment.replace(barePathPattern, (match, prefix: string, rawPath: string) => {
1263+
if (!bareFileMentionAllowed(task, rawPath, knownFiles)) {
1264+
return match;
1265+
}
1266+
const linked = githubFileMarkdown(task, rawPath);
1267+
return linked === `\`${rawPath}\`` ? match : `${prefix}${linked}`;
1268+
});
1269+
})
1270+
.join("");
1271+
}
1272+
1273+
function linkGithubFileMentions(task: JsonObject, text: string, knownFiles: Set<string> = githubKnownFileSet(task)): string {
1274+
const fencePattern = /(```[\s\S]*?```)/g;
1275+
return text
1276+
.split(fencePattern)
1277+
.map((segment, index) => {
1278+
if (index % 2 === 1) {
1279+
return segment;
1280+
}
1281+
const linkedInlineCode = segment.replace(/`([^`\n]+)`/g, (match, rawPath: string, offset: number) => {
1282+
const alreadyLinkText = segment[offset - 1] === "[" && segment.slice(offset + match.length, offset + match.length + 2) === "](";
1283+
if (alreadyLinkText) {
1284+
return match;
1285+
}
1286+
const linked = inlineCodeWithGithubFileLinks(task, rawPath);
1287+
return linked === `\`${rawPath}\`` ? match : linked;
1288+
});
1289+
return linkBareGithubFileMentions(task, linkedInlineCode, knownFiles);
1290+
})
1291+
.join("");
1292+
}
1293+
1294+
function reviewTestCommandMarkdown(task: JsonObject, rawCommand: string): string {
1295+
const command = rawCommand.trim();
1296+
if (!command) {
1297+
return "`unknown command`";
1298+
}
1299+
return inlineCodeWithGithubFileLinks(task, command);
1300+
}
1301+
11361302
function reviewFixLoopLines(task: JsonObject): string[] {
11371303
const loops = Array.isArray(task.review_fix_loops) ? (task.review_fix_loops as JsonObject[]) : [];
11381304
if (!loops.length) {
@@ -1148,7 +1314,7 @@ function reviewFixLoopLines(task: JsonObject): string[] {
11481314
return lines;
11491315
}
11501316

1151-
function structuredReviewLines(review: JsonObject): string[] {
1317+
function structuredReviewLines(review: JsonObject, task: JsonObject, knownFiles: Set<string> = githubKnownFileSet(task, review)): string[] {
11521318
if (!Object.keys(review).length) {
11531319
return ["", "### Structured review", "- No structured review result was emitted."];
11541320
}
@@ -1162,15 +1328,15 @@ function structuredReviewLines(review: JsonObject): string[] {
11621328
const reviewedFiles = Array.isArray(review.reviewed_files) ? review.reviewed_files : [];
11631329
lines.push(`- Reviewed files: ${reviewedFiles.length}`);
11641330
if (reviewedFiles.length) {
1165-
lines.push(`- Reviewed file list: ${reviewedFiles.slice(0, 20).map((path) => `\`${path}\``).join(", ")}`);
1331+
lines.push(`- Reviewed file list: ${reviewedFiles.slice(0, 20).map((path) => githubFileMarkdown(task, String(path))).join(", ")}`);
11661332
if (reviewedFiles.length > 20) {
11671333
lines.push("- Reviewed file list truncated after 20 entries.");
11681334
}
11691335
}
11701336
const supportingFiles = Array.isArray(review.supporting_files) ? review.supporting_files : [];
11711337
lines.push(`- Supporting files inspected: ${supportingFiles.length}`);
11721338
if (supportingFiles.length) {
1173-
lines.push(`- Supporting file list: ${supportingFiles.slice(0, 20).map((path) => `\`${path}\``).join(", ")}`);
1339+
lines.push(`- Supporting file list: ${supportingFiles.slice(0, 20).map((path) => githubFileMarkdown(task, String(path))).join(", ")}`);
11741340
if (supportingFiles.length > 20) {
11751341
lines.push("- Supporting file list truncated after 20 entries.");
11761342
}
@@ -1182,19 +1348,21 @@ function structuredReviewLines(review: JsonObject): string[] {
11821348
if (finding.line !== undefined && finding.line !== null) {
11831349
location = `${location}:${finding.line}`;
11841350
}
1185-
lines.push(` ${index + 1}. \`${finding.severity || "unknown"}\` ${location} - ${finding.title || "Untitled finding"}`);
1351+
const linkedLocation = githubFileMarkdown(task, location, Number(finding.line || 0) || null);
1352+
lines.push(` ${index + 1}. \`${finding.severity || "unknown"}\` ${linkedLocation} - ${finding.title || "Untitled finding"}`);
11861353
});
11871354
if (findings.length > 10) {
11881355
lines.push("- Findings truncated after 10 entries.");
11891356
}
11901357
if (review.no_findings_reason) {
1191-
lines.push(`- No-findings reason: ${review.no_findings_reason}`);
1358+
lines.push(`- No-findings reason: ${linkGithubFileMentions(task, String(review.no_findings_reason), knownFiles)}`);
11921359
}
11931360
const testsRun = Array.isArray(review.tests_run) ? (review.tests_run as JsonObject[]) : [];
11941361
lines.push(`- Tests reported by runtime: ${testsRun.length}`);
11951362
testsRun.slice(0, 10).forEach((item) => {
1196-
const summary = item.output_summary ? ` - ${item.output_summary}` : "";
1197-
lines.push(` - \`${item.command || "unknown command"}\`: \`${item.status || "unknown"}\`${summary}`);
1363+
const command = reviewTestCommandMarkdown(task, String(item.command || "unknown command"));
1364+
const summary = item.output_summary ? ` - ${linkGithubFileMentions(task, String(item.output_summary), knownFiles)}` : "";
1365+
lines.push(` - ${command}: \`${item.status || "unknown"}\`${summary}`);
11981366
});
11991367
if (testsRun.length > 10) {
12001368
lines.push("- Test list truncated after 10 entries.");

0 commit comments

Comments
 (0)