Skip to content

Commit b1fd039

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

2 files changed

Lines changed: 267 additions & 12 deletions

File tree

src/adapter.ts

Lines changed: 148 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,10 +1092,10 @@ 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 || "");
1097+
const summary = linkGithubFileMentions(task, String(result.summary || "No summary returned."));
1098+
const prBody = linkGithubFileMentions(task, String(result.pr_body || ""));
10991099
const filesChanged = Array.isArray(result.files_changed) ? result.files_changed : [];
11001100
const commits = Array.isArray(result.commits) ? result.commits : [];
11011101
const taskId = String(task.task_id || "");
@@ -1117,12 +1117,12 @@ function publicationCommentBody(task: JsonObject, result: JsonObject): string {
11171117
`- Review context SHA-256: \`${evidence.review_context_sha256}\``,
11181118
);
11191119
if (changedFiles.length) {
1120-
parts.push(`- Files: ${changedFiles.slice(0, 20).map((file) => `\`${file}\``).join(", ")}`);
1120+
parts.push(`- Files: ${changedFiles.slice(0, 20).map((file) => githubFileMarkdown(task, String(file))).join(", ")}`);
11211121
}
11221122
} else {
11231123
parts.push("- No PR review evidence was captured for this run.");
11241124
}
1125-
parts.push(...structuredReviewLines(review));
1125+
parts.push(...structuredReviewLines(review, task));
11261126
parts.push(
11271127
"",
11281128
`**Files changed:** ${filesChanged.length}`,
@@ -1133,6 +1133,140 @@ function publicationCommentBody(task: JsonObject, result: JsonObject): string {
11331133
return parts.join("\n");
11341134
}
11351135

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

1151-
function structuredReviewLines(review: JsonObject): string[] {
1285+
function structuredReviewLines(review: JsonObject, task: JsonObject): string[] {
11521286
if (!Object.keys(review).length) {
11531287
return ["", "### Structured review", "- No structured review result was emitted."];
11541288
}
@@ -1162,15 +1296,15 @@ function structuredReviewLines(review: JsonObject): string[] {
11621296
const reviewedFiles = Array.isArray(review.reviewed_files) ? review.reviewed_files : [];
11631297
lines.push(`- Reviewed files: ${reviewedFiles.length}`);
11641298
if (reviewedFiles.length) {
1165-
lines.push(`- Reviewed file list: ${reviewedFiles.slice(0, 20).map((path) => `\`${path}\``).join(", ")}`);
1299+
lines.push(`- Reviewed file list: ${reviewedFiles.slice(0, 20).map((path) => githubFileMarkdown(task, String(path))).join(", ")}`);
11661300
if (reviewedFiles.length > 20) {
11671301
lines.push("- Reviewed file list truncated after 20 entries.");
11681302
}
11691303
}
11701304
const supportingFiles = Array.isArray(review.supporting_files) ? review.supporting_files : [];
11711305
lines.push(`- Supporting files inspected: ${supportingFiles.length}`);
11721306
if (supportingFiles.length) {
1173-
lines.push(`- Supporting file list: ${supportingFiles.slice(0, 20).map((path) => `\`${path}\``).join(", ")}`);
1307+
lines.push(`- Supporting file list: ${supportingFiles.slice(0, 20).map((path) => githubFileMarkdown(task, String(path))).join(", ")}`);
11741308
if (supportingFiles.length > 20) {
11751309
lines.push("- Supporting file list truncated after 20 entries.");
11761310
}
@@ -1182,19 +1316,21 @@ function structuredReviewLines(review: JsonObject): string[] {
11821316
if (finding.line !== undefined && finding.line !== null) {
11831317
location = `${location}:${finding.line}`;
11841318
}
1185-
lines.push(` ${index + 1}. \`${finding.severity || "unknown"}\` ${location} - ${finding.title || "Untitled finding"}`);
1319+
const linkedLocation = githubFileMarkdown(task, location, Number(finding.line || 0) || null);
1320+
lines.push(` ${index + 1}. \`${finding.severity || "unknown"}\` ${linkedLocation} - ${finding.title || "Untitled finding"}`);
11861321
});
11871322
if (findings.length > 10) {
11881323
lines.push("- Findings truncated after 10 entries.");
11891324
}
11901325
if (review.no_findings_reason) {
1191-
lines.push(`- No-findings reason: ${review.no_findings_reason}`);
1326+
lines.push(`- No-findings reason: ${linkGithubFileMentions(task, String(review.no_findings_reason))}`);
11921327
}
11931328
const testsRun = Array.isArray(review.tests_run) ? (review.tests_run as JsonObject[]) : [];
11941329
lines.push(`- Tests reported by runtime: ${testsRun.length}`);
11951330
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}`);
1331+
const command = reviewTestCommandMarkdown(task, String(item.command || "unknown command"));
1332+
const summary = item.output_summary ? ` - ${linkGithubFileMentions(task, String(item.output_summary))}` : "";
1333+
lines.push(` - ${command}: \`${item.status || "unknown"}\`${summary}`);
11981334
});
11991335
if (testsRun.length > 10) {
12001336
lines.push("- Test list truncated after 10 entries.");

tests/webhook-adapter.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
buildTaskFromEvent,
1010
createConfig,
1111
handleRequest,
12+
publicationCommentBody,
1213
type JsonObject,
1314
} from "../src/adapter.js";
1415

@@ -349,6 +350,124 @@ test("example policy routes a labeled issue to the configured familiar", () => {
349350
});
350351
});
351352

353+
test("publication body links screenshot-style file mentions to GitHub blobs", () => {
354+
const body = publicationCommentBody(
355+
{
356+
task_id: "task-file-links",
357+
repository: "OpenCoven/coven-github-webhook",
358+
default_branch: "main",
359+
review_evidence: {
360+
head_sha: "abc123def456",
361+
},
362+
},
363+
{
364+
status: "success",
365+
summary: [
366+
"### Files inspected",
367+
"",
368+
"- `src/lib/server/skills-directory.ts`",
369+
"- `Read src/lib/server/skill-scan.ts`",
370+
"- Read src/lib/server/skill-scan.ts - passed: inspected adapter implementation.",
371+
"- Read AGENTS.md - passed: reviewed guidance.",
372+
"- Grep for https://github.com/OpenCoven/coven-github-webhook/blob/main/src/adapter.ts and tests_run[].output_summary.",
373+
"- `README.md:12`",
374+
"- `README.md:12-14`",
375+
"- `tests_run[].output_summary`",
376+
"- `pnpm test`",
377+
"",
378+
"```ts",
379+
"`src/not-linked-inside-fence.ts`",
380+
"```",
381+
].join("\n"),
382+
review: {},
383+
},
384+
);
385+
386+
assert.match(
387+
body,
388+
/\[`src\/lib\/server\/skills-directory\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/src\/lib\/server\/skills-directory\.ts\)/,
389+
);
390+
assert.match(
391+
body,
392+
/`Read` \[`src\/lib\/server\/skill-scan\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/src\/lib\/server\/skill-scan\.ts\)/,
393+
);
394+
assert.match(
395+
body,
396+
/Read \[`src\/lib\/server\/skill-scan\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/src\/lib\/server\/skill-scan\.ts\) - passed/,
397+
);
398+
assert.match(
399+
body,
400+
/Read \[`AGENTS\.md`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/AGENTS\.md\) - passed/,
401+
);
402+
assert.match(body, /https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/main\/src\/adapter\.ts/);
403+
assert.match(
404+
body,
405+
/\[`README\.md:12`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/README\.md#L12\)/,
406+
);
407+
assert.match(
408+
body,
409+
/\[`README\.md:12-14`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/README\.md#L12-L14\)/,
410+
);
411+
assert.match(body, /- `pnpm test`/);
412+
assert.match(body, /- `tests_run\[\]\.output_summary`/);
413+
assert.doesNotMatch(body, /\[`tests_run\[\]\.output_summary`\]/);
414+
assert.doesNotMatch(body, /blob\/main\/\[`src\/adapter\.ts`\]/);
415+
assert.match(body, /`src\/not-linked-inside-fence\.ts`/);
416+
assert.doesNotMatch(body, /\[`src\/not-linked-inside-fence\.ts`\]/);
417+
});
418+
419+
test("publication body links structured review file lists and findings", () => {
420+
const body = publicationCommentBody(
421+
{
422+
task_id: "task-structured-links",
423+
repository: "OpenCoven/coven-github-webhook",
424+
default_branch: "main",
425+
review_evidence: {
426+
head_sha: "feedface",
427+
changed_files: ["src/app.ts"],
428+
changed_file_count: 1,
429+
},
430+
},
431+
{
432+
status: "success",
433+
summary: "Done.",
434+
review: {
435+
mode: "review",
436+
evidence_status: "complete",
437+
reviewed_files: ["src/app.ts"],
438+
supporting_files: ["tests/app.test.ts"],
439+
findings: [
440+
{
441+
severity: "medium",
442+
file: "src/app.ts",
443+
line: 7,
444+
title: "Example finding",
445+
},
446+
],
447+
no_findings_reason: "Checked `tests/app.test.ts` with `npm test`.",
448+
tests_run: [
449+
{
450+
command: "Read src/app.ts",
451+
status: "passed",
452+
output_summary: "inspected `tests/app.test.ts` coverage.",
453+
},
454+
{
455+
command: "npm test",
456+
status: "passed",
457+
},
458+
],
459+
},
460+
},
461+
);
462+
463+
assert.match(body, /\[`src\/app\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/app\.ts\)/);
464+
assert.match(body, /\[`tests\/app\.test\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/tests\/app\.test\.ts\)/);
465+
assert.match(body, /\[`src\/app\.ts:7`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/app\.ts#L7\)/);
466+
assert.match(body, /`Read` \[`src\/app\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/app\.ts\): `passed`/);
467+
assert.match(body, /with `npm test`/);
468+
assert.match(body, /- `npm test`: `passed`/);
469+
});
470+
352471
test("demo mode handles a signed labeled issue without external GitHub calls", async () => {
353472
const secret = "demo-route-secret";
354473
const stateDir = tempStateDir();

0 commit comments

Comments
 (0)