Skip to content

Commit 2724a80

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

2 files changed

Lines changed: 233 additions & 12 deletions

File tree

src/adapter.ts

Lines changed: 130 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,122 @@ 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 linkGithubFileMentions(task: JsonObject, text: string): string {
1225+
const fencePattern = /(```[\s\S]*?```)/g;
1226+
return text
1227+
.split(fencePattern)
1228+
.map((segment, index) => {
1229+
if (index % 2 === 1) {
1230+
return segment;
1231+
}
1232+
return segment.replace(/`([^`\n]+)`/g, (match, rawPath: string, offset: number) => {
1233+
const alreadyLinkText = segment[offset - 1] === "[" && segment.slice(offset + match.length, offset + match.length + 2) === "](";
1234+
if (alreadyLinkText) {
1235+
return match;
1236+
}
1237+
const linked = inlineCodeWithGithubFileLinks(task, rawPath);
1238+
return linked === `\`${rawPath}\`` ? match : linked;
1239+
});
1240+
})
1241+
.join("");
1242+
}
1243+
1244+
function reviewTestCommandMarkdown(task: JsonObject, rawCommand: string): string {
1245+
const command = rawCommand.trim();
1246+
if (!command) {
1247+
return "`unknown command`";
1248+
}
1249+
return inlineCodeWithGithubFileLinks(task, command);
1250+
}
1251+
11361252
function reviewFixLoopLines(task: JsonObject): string[] {
11371253
const loops = Array.isArray(task.review_fix_loops) ? (task.review_fix_loops as JsonObject[]) : [];
11381254
if (!loops.length) {
@@ -1148,7 +1264,7 @@ function reviewFixLoopLines(task: JsonObject): string[] {
11481264
return lines;
11491265
}
11501266

1151-
function structuredReviewLines(review: JsonObject): string[] {
1267+
function structuredReviewLines(review: JsonObject, task: JsonObject): string[] {
11521268
if (!Object.keys(review).length) {
11531269
return ["", "### Structured review", "- No structured review result was emitted."];
11541270
}
@@ -1162,15 +1278,15 @@ function structuredReviewLines(review: JsonObject): string[] {
11621278
const reviewedFiles = Array.isArray(review.reviewed_files) ? review.reviewed_files : [];
11631279
lines.push(`- Reviewed files: ${reviewedFiles.length}`);
11641280
if (reviewedFiles.length) {
1165-
lines.push(`- Reviewed file list: ${reviewedFiles.slice(0, 20).map((path) => `\`${path}\``).join(", ")}`);
1281+
lines.push(`- Reviewed file list: ${reviewedFiles.slice(0, 20).map((path) => githubFileMarkdown(task, String(path))).join(", ")}`);
11661282
if (reviewedFiles.length > 20) {
11671283
lines.push("- Reviewed file list truncated after 20 entries.");
11681284
}
11691285
}
11701286
const supportingFiles = Array.isArray(review.supporting_files) ? review.supporting_files : [];
11711287
lines.push(`- Supporting files inspected: ${supportingFiles.length}`);
11721288
if (supportingFiles.length) {
1173-
lines.push(`- Supporting file list: ${supportingFiles.slice(0, 20).map((path) => `\`${path}\``).join(", ")}`);
1289+
lines.push(`- Supporting file list: ${supportingFiles.slice(0, 20).map((path) => githubFileMarkdown(task, String(path))).join(", ")}`);
11741290
if (supportingFiles.length > 20) {
11751291
lines.push("- Supporting file list truncated after 20 entries.");
11761292
}
@@ -1182,19 +1298,21 @@ function structuredReviewLines(review: JsonObject): string[] {
11821298
if (finding.line !== undefined && finding.line !== null) {
11831299
location = `${location}:${finding.line}`;
11841300
}
1185-
lines.push(` ${index + 1}. \`${finding.severity || "unknown"}\` ${location} - ${finding.title || "Untitled finding"}`);
1301+
const linkedLocation = githubFileMarkdown(task, location, Number(finding.line || 0) || null);
1302+
lines.push(` ${index + 1}. \`${finding.severity || "unknown"}\` ${linkedLocation} - ${finding.title || "Untitled finding"}`);
11861303
});
11871304
if (findings.length > 10) {
11881305
lines.push("- Findings truncated after 10 entries.");
11891306
}
11901307
if (review.no_findings_reason) {
1191-
lines.push(`- No-findings reason: ${review.no_findings_reason}`);
1308+
lines.push(`- No-findings reason: ${linkGithubFileMentions(task, String(review.no_findings_reason))}`);
11921309
}
11931310
const testsRun = Array.isArray(review.tests_run) ? (review.tests_run as JsonObject[]) : [];
11941311
lines.push(`- Tests reported by runtime: ${testsRun.length}`);
11951312
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}`);
1313+
const command = reviewTestCommandMarkdown(task, String(item.command || "unknown command"));
1314+
const summary = item.output_summary ? ` - ${linkGithubFileMentions(task, String(item.output_summary))}` : "";
1315+
lines.push(` - ${command}: \`${item.status || "unknown"}\`${summary}`);
11981316
});
11991317
if (testsRun.length > 10) {
12001318
lines.push("- Test list truncated after 10 entries.");

tests/webhook-adapter.test.ts

Lines changed: 103 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,108 @@ 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+
"- `README.md:12`",
371+
"- `README.md:12-14`",
372+
"- `pnpm test`",
373+
"",
374+
"```ts",
375+
"`src/not-linked-inside-fence.ts`",
376+
"```",
377+
].join("\n"),
378+
review: {},
379+
},
380+
);
381+
382+
assert.match(
383+
body,
384+
/\[`src\/lib\/server\/skills-directory\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/src\/lib\/server\/skills-directory\.ts\)/,
385+
);
386+
assert.match(
387+
body,
388+
/`Read` \[`src\/lib\/server\/skill-scan\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/src\/lib\/server\/skill-scan\.ts\)/,
389+
);
390+
assert.match(
391+
body,
392+
/\[`README\.md:12`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/README\.md#L12\)/,
393+
);
394+
assert.match(
395+
body,
396+
/\[`README\.md:12-14`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/README\.md#L12-L14\)/,
397+
);
398+
assert.match(body, /- `pnpm test`/);
399+
assert.match(body, /`src\/not-linked-inside-fence\.ts`/);
400+
assert.doesNotMatch(body, /\[`src\/not-linked-inside-fence\.ts`\]/);
401+
});
402+
403+
test("publication body links structured review file lists and findings", () => {
404+
const body = publicationCommentBody(
405+
{
406+
task_id: "task-structured-links",
407+
repository: "OpenCoven/coven-github-webhook",
408+
default_branch: "main",
409+
review_evidence: {
410+
head_sha: "feedface",
411+
changed_files: ["src/app.ts"],
412+
changed_file_count: 1,
413+
},
414+
},
415+
{
416+
status: "success",
417+
summary: "Done.",
418+
review: {
419+
mode: "review",
420+
evidence_status: "complete",
421+
reviewed_files: ["src/app.ts"],
422+
supporting_files: ["tests/app.test.ts"],
423+
findings: [
424+
{
425+
severity: "medium",
426+
file: "src/app.ts",
427+
line: 7,
428+
title: "Example finding",
429+
},
430+
],
431+
no_findings_reason: "Checked `tests/app.test.ts` with `npm test`.",
432+
tests_run: [
433+
{
434+
command: "Read src/app.ts",
435+
status: "passed",
436+
output_summary: "inspected `tests/app.test.ts` coverage.",
437+
},
438+
{
439+
command: "npm test",
440+
status: "passed",
441+
},
442+
],
443+
},
444+
},
445+
);
446+
447+
assert.match(body, /\[`src\/app\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/app\.ts\)/);
448+
assert.match(body, /\[`tests\/app\.test\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/tests\/app\.test\.ts\)/);
449+
assert.match(body, /\[`src\/app\.ts:7`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/app\.ts#L7\)/);
450+
assert.match(body, /`Read` \[`src\/app\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/app\.ts\): `passed`/);
451+
assert.match(body, /with `npm test`/);
452+
assert.match(body, /- `npm test`: `passed`/);
453+
});
454+
352455
test("demo mode handles a signed labeled issue without external GitHub calls", async () => {
353456
const secret = "demo-route-secret";
354457
const stateDir = tempStateDir();

0 commit comments

Comments
 (0)