From 2fcb731d423d5b41614a5dea11901b3d957e7f9f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Wed, 8 Jul 2026 14:13:35 -0400 Subject: [PATCH 1/4] fix webhook review file links --- src/adapter.ts | 192 +++++++++++++++++++++++++++++++--- tests/webhook-adapter.test.ts | 130 +++++++++++++++++++++++ 2 files changed, 310 insertions(+), 12 deletions(-) diff --git a/src/adapter.ts b/src/adapter.ts index 503bd2e..60c346c 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -1092,15 +1092,16 @@ async function publishResultIfConfigured(task: JsonObject, resultPath: string, t } } -function publicationCommentBody(task: JsonObject, result: JsonObject): string { +export function publicationCommentBody(task: JsonObject, result: JsonObject): string { const status = result.status || "unknown"; - const summary = String(result.summary || "No summary returned."); - const prBody = String(result.pr_body || ""); const filesChanged = Array.isArray(result.files_changed) ? result.files_changed : []; const commits = Array.isArray(result.commits) ? result.commits : []; const taskId = String(task.task_id || ""); const evidence = (task.review_evidence as JsonObject | undefined) || {}; const review = (result.review as JsonObject | undefined) || {}; + const knownFiles = githubKnownFileSet(task, review); + const summary = linkGithubFileMentions(task, String(result.summary || "No summary returned."), knownFiles); + const prBody = linkGithubFileMentions(task, String(result.pr_body || ""), knownFiles); const parts = ["## Cody dogfood result", "", `**Status:** ${status}`, "", summary.trim()]; if (prBody.trim() && prBody.trim() !== summary.trim()) { parts.push("", prBody.trim()); @@ -1117,12 +1118,12 @@ function publicationCommentBody(task: JsonObject, result: JsonObject): string { `- Review context SHA-256: \`${evidence.review_context_sha256}\``, ); if (changedFiles.length) { - parts.push(`- Files: ${changedFiles.slice(0, 20).map((file) => `\`${file}\``).join(", ")}`); + parts.push(`- Files: ${changedFiles.slice(0, 20).map((file) => githubFileMarkdown(task, String(file))).join(", ")}`); } } else { parts.push("- No PR review evidence was captured for this run."); } - parts.push(...structuredReviewLines(review)); + parts.push(...structuredReviewLines(review, task, knownFiles)); parts.push( "", `**Files changed:** ${filesChanged.length}`, @@ -1133,6 +1134,171 @@ function publicationCommentBody(task: JsonObject, result: JsonObject): string { return parts.join("\n"); } +function githubBlobBase(task: JsonObject): string | null { + const repository = String(task.repository || "").trim(); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + return null; + } + const evidence = (task.review_evidence as JsonObject | undefined) || {}; + const ref = String(evidence.head_sha || evidence.workspace_head_sha || task.default_branch || "").trim(); + if (!ref) { + return null; + } + const encodedRef = encodeURIComponent(ref).replaceAll("%2F", "/"); + return `https://github.com/${repository}/blob/${encodedRef}`; +} + +function parseRepoRelativePath(rawPath: string): {display: string; path: string; line: number | null; endLine: number | null} | null { + const display = rawPath.trim(); + if (!display || display !== rawPath || /[\s<>\[\]]/.test(display)) { + return null; + } + if (/^[\\/]/.test(display) || /^[A-Za-z]:[\\/]/.test(display)) { + return null; + } + + let path = display.replaceAll("\\", "/"); + let line: number | null = null; + let endLine: number | null = null; + const lineMatch = /^(.*):(\d+)(?:-(\d+))?$/.exec(path); + if (lineMatch) { + path = lineMatch[1]; + line = Number(lineMatch[2]); + endLine = lineMatch[3] ? Number(lineMatch[3]) : null; + } + if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(path)) { + return null; + } + while (path.startsWith("./")) { + path = path.slice(2); + } + const segments = path.split("/"); + const filename = segments.at(-1) || ""; + if ( + !path || + path.includes("//") || + segments.some((segment) => !segment || segment === "." || segment === "..") || + !/\.[A-Za-z][A-Za-z0-9._-]{0,15}$/.test(filename) + ) { + return null; + } + return {display, path, line, endLine}; +} + +function githubFileMarkdown(task: JsonObject, rawPath: string, lineOverride: number | null = null): string { + const target = parseRepoRelativePath(rawPath); + const base = githubBlobBase(task); + if (!target || !base) { + return `\`${rawPath}\``; + } + const encodedPath = target.path.split("/").map((segment) => encodeURIComponent(segment)).join("/"); + const line = lineOverride ?? target.line; + const endLine = lineOverride === null ? target.endLine : null; + const lineAnchor = line !== null && Number.isFinite(line) && line > 0 + ? `#L${line}${endLine !== null && Number.isFinite(endLine) && endLine > line ? `-L${endLine}` : ""}` + : ""; + return `[\`${target.display}\`](${base}/${encodedPath}${lineAnchor})`; +} + +function githubKnownFileSet(task: JsonObject, review: JsonObject = {}): Set { + const knownFiles = new Set(); + const evidence = (task.review_evidence as JsonObject | undefined) || {}; + const addFiles = (value: JsonValue | undefined) => { + if (!Array.isArray(value)) { + return; + } + for (const item of value) { + const target = parseRepoRelativePath(String(item)); + if (target) { + knownFiles.add(target.path); + } + } + }; + addFiles(evidence.changed_files); + addFiles(review.reviewed_files); + addFiles(review.supporting_files); + return knownFiles; +} + +function inlineCodeWithGithubFileLinks(task: JsonObject, rawText: string): string { + const direct = githubFileMarkdown(task, rawText); + if (direct !== `\`${rawText}\``) { + return direct; + } + + let linkedAny = false; + const parts = rawText.split(/(\s+)/).map((part) => { + if (!part || /^\s+$/.test(part)) { + return part; + } + const linked = githubFileMarkdown(task, part); + if (linked !== `\`${part}\``) { + linkedAny = true; + return linked; + } + return `\`${part}\``; + }); + + return linkedAny ? parts.join("") : `\`${rawText}\``; +} + +function bareFileMentionAllowed(task: JsonObject, rawPath: string, knownFiles: Set): boolean { + const target = parseRepoRelativePath(rawPath); + if (!target) { + return false; + } + return target.path.includes("/") || knownFiles.has(target.path); +} + +function linkBareGithubFileMentions(task: JsonObject, text: string, knownFiles: Set): string { + const markdownLinkPattern = /(\[[^\]]+\]\([^)]+\))/g; + const barePathPattern = /(^|[^\w./:[\]()`-])([A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*\.[A-Za-z][A-Za-z0-9_-]{0,15}(?::\d+(?:-\d+)?)?)(?=$|[^\w/-])/g; + return text + .split(markdownLinkPattern) + .map((segment, index) => { + if (index % 2 === 1) { + return segment; + } + return segment.replace(barePathPattern, (match, prefix: string, rawPath: string) => { + if (!bareFileMentionAllowed(task, rawPath, knownFiles)) { + return match; + } + const linked = githubFileMarkdown(task, rawPath); + return linked === `\`${rawPath}\`` ? match : `${prefix}${linked}`; + }); + }) + .join(""); +} + +function linkGithubFileMentions(task: JsonObject, text: string, knownFiles: Set = githubKnownFileSet(task)): string { + const fencePattern = /(```[\s\S]*?```)/g; + return text + .split(fencePattern) + .map((segment, index) => { + if (index % 2 === 1) { + return segment; + } + const linkedInlineCode = segment.replace(/`([^`\n]+)`/g, (match, rawPath: string, offset: number) => { + const alreadyLinkText = segment[offset - 1] === "[" && segment.slice(offset + match.length, offset + match.length + 2) === "]("; + if (alreadyLinkText) { + return match; + } + const linked = inlineCodeWithGithubFileLinks(task, rawPath); + return linked === `\`${rawPath}\`` ? match : linked; + }); + return linkBareGithubFileMentions(task, linkedInlineCode, knownFiles); + }) + .join(""); +} + +function reviewTestCommandMarkdown(task: JsonObject, rawCommand: string): string { + const command = rawCommand.trim(); + if (!command) { + return "`unknown command`"; + } + return inlineCodeWithGithubFileLinks(task, command); +} + function reviewFixLoopLines(task: JsonObject): string[] { const loops = Array.isArray(task.review_fix_loops) ? (task.review_fix_loops as JsonObject[]) : []; if (!loops.length) { @@ -1148,7 +1314,7 @@ function reviewFixLoopLines(task: JsonObject): string[] { return lines; } -function structuredReviewLines(review: JsonObject): string[] { +function structuredReviewLines(review: JsonObject, task: JsonObject, knownFiles: Set = githubKnownFileSet(task, review)): string[] { if (!Object.keys(review).length) { return ["", "### Structured review", "- No structured review result was emitted."]; } @@ -1162,7 +1328,7 @@ function structuredReviewLines(review: JsonObject): string[] { const reviewedFiles = Array.isArray(review.reviewed_files) ? review.reviewed_files : []; lines.push(`- Reviewed files: ${reviewedFiles.length}`); if (reviewedFiles.length) { - lines.push(`- Reviewed file list: ${reviewedFiles.slice(0, 20).map((path) => `\`${path}\``).join(", ")}`); + lines.push(`- Reviewed file list: ${reviewedFiles.slice(0, 20).map((path) => githubFileMarkdown(task, String(path))).join(", ")}`); if (reviewedFiles.length > 20) { lines.push("- Reviewed file list truncated after 20 entries."); } @@ -1170,7 +1336,7 @@ function structuredReviewLines(review: JsonObject): string[] { const supportingFiles = Array.isArray(review.supporting_files) ? review.supporting_files : []; lines.push(`- Supporting files inspected: ${supportingFiles.length}`); if (supportingFiles.length) { - lines.push(`- Supporting file list: ${supportingFiles.slice(0, 20).map((path) => `\`${path}\``).join(", ")}`); + lines.push(`- Supporting file list: ${supportingFiles.slice(0, 20).map((path) => githubFileMarkdown(task, String(path))).join(", ")}`); if (supportingFiles.length > 20) { lines.push("- Supporting file list truncated after 20 entries."); } @@ -1182,19 +1348,21 @@ function structuredReviewLines(review: JsonObject): string[] { if (finding.line !== undefined && finding.line !== null) { location = `${location}:${finding.line}`; } - lines.push(` ${index + 1}. \`${finding.severity || "unknown"}\` ${location} - ${finding.title || "Untitled finding"}`); + const linkedLocation = githubFileMarkdown(task, location, Number(finding.line || 0) || null); + lines.push(` ${index + 1}. \`${finding.severity || "unknown"}\` ${linkedLocation} - ${finding.title || "Untitled finding"}`); }); if (findings.length > 10) { lines.push("- Findings truncated after 10 entries."); } if (review.no_findings_reason) { - lines.push(`- No-findings reason: ${review.no_findings_reason}`); + lines.push(`- No-findings reason: ${linkGithubFileMentions(task, String(review.no_findings_reason), knownFiles)}`); } const testsRun = Array.isArray(review.tests_run) ? (review.tests_run as JsonObject[]) : []; lines.push(`- Tests reported by runtime: ${testsRun.length}`); testsRun.slice(0, 10).forEach((item) => { - const summary = item.output_summary ? ` - ${item.output_summary}` : ""; - lines.push(` - \`${item.command || "unknown command"}\`: \`${item.status || "unknown"}\`${summary}`); + const command = reviewTestCommandMarkdown(task, String(item.command || "unknown command")); + const summary = item.output_summary ? ` - ${linkGithubFileMentions(task, String(item.output_summary), knownFiles)}` : ""; + lines.push(` - ${command}: \`${item.status || "unknown"}\`${summary}`); }); if (testsRun.length > 10) { lines.push("- Test list truncated after 10 entries."); diff --git a/tests/webhook-adapter.test.ts b/tests/webhook-adapter.test.ts index 4488c99..bb4342d 100644 --- a/tests/webhook-adapter.test.ts +++ b/tests/webhook-adapter.test.ts @@ -9,6 +9,7 @@ import { buildTaskFromEvent, createConfig, handleRequest, + publicationCommentBody, type JsonObject, } from "../src/adapter.js"; @@ -349,6 +350,135 @@ test("example policy routes a labeled issue to the configured familiar", () => { }); }); +test("publication body links screenshot-style file mentions to GitHub blobs", () => { + const body = publicationCommentBody( + { + task_id: "task-file-links", + repository: "OpenCoven/coven-github-webhook", + default_branch: "main", + review_evidence: { + head_sha: "abc123def456", + }, + }, + { + status: "success", + summary: [ + "### Files inspected", + "", + "- `src/lib/server/skills-directory.ts`", + "- `Read src/lib/server/skill-scan.ts`", + "- Read src/lib/server/skill-scan.ts - passed: inspected adapter implementation.", + "- Read AGENTS.md - passed: reviewed guidance.", + "- Fixed a bug, e.g. the parser broke.", + "- In other words, i.e. no bogus abbreviation links.", + "- Mentioned foo.bar.baz.qux in prose.", + "- Grep for https://github.com/OpenCoven/coven-github-webhook/blob/main/src/adapter.ts and tests_run[].output_summary.", + "- `README.md:12`", + "- `README.md:12-14`", + "- `tests_run[].output_summary`", + "- `pnpm test`", + "", + "```ts", + "`src/not-linked-inside-fence.ts`", + "```", + ].join("\n"), + review: { + supporting_files: ["AGENTS.md"], + }, + }, + ); + + assert.match( + body, + /\[`src\/lib\/server\/skills-directory\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/src\/lib\/server\/skills-directory\.ts\)/, + ); + assert.match( + body, + /`Read` \[`src\/lib\/server\/skill-scan\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/src\/lib\/server\/skill-scan\.ts\)/, + ); + assert.match( + body, + /Read \[`src\/lib\/server\/skill-scan\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/src\/lib\/server\/skill-scan\.ts\) - passed/, + ); + assert.match( + body, + /Read \[`AGENTS\.md`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/AGENTS\.md\) - passed/, + ); + assert.match(body, /https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/main\/src\/adapter\.ts/); + assert.match(body, /e\.g\. the parser broke/); + assert.match(body, /i\.e\. no bogus abbreviation links/); + assert.match(body, /foo\.bar\.baz\.qux in prose/); + assert.doesNotMatch(body, /\[`e\.g`\]/); + assert.doesNotMatch(body, /\[`i\.e`\]/); + assert.doesNotMatch(body, /\[`foo\.bar\.baz\.qux`\]/); + assert.match( + body, + /\[`README\.md:12`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/README\.md#L12\)/, + ); + assert.match( + body, + /\[`README\.md:12-14`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/README\.md#L12-L14\)/, + ); + assert.match(body, /- `pnpm test`/); + assert.match(body, /- `tests_run\[\]\.output_summary`/); + assert.doesNotMatch(body, /\[`tests_run\[\]\.output_summary`\]/); + assert.doesNotMatch(body, /blob\/main\/\[`src\/adapter\.ts`\]/); + assert.match(body, /`src\/not-linked-inside-fence\.ts`/); + assert.doesNotMatch(body, /\[`src\/not-linked-inside-fence\.ts`\]/); +}); + +test("publication body links structured review file lists and findings", () => { + const body = publicationCommentBody( + { + task_id: "task-structured-links", + repository: "OpenCoven/coven-github-webhook", + default_branch: "main", + review_evidence: { + head_sha: "feedface", + changed_files: ["src/app.ts"], + changed_file_count: 1, + }, + }, + { + status: "success", + summary: "Done.", + review: { + mode: "review", + evidence_status: "complete", + reviewed_files: ["src/app.ts"], + supporting_files: ["tests/app.test.ts"], + findings: [ + { + severity: "medium", + file: "src/app.ts", + line: 7, + title: "Example finding", + }, + ], + no_findings_reason: "Checked `tests/app.test.ts` with `npm test`.", + tests_run: [ + { + command: "Read src/app.ts", + status: "passed", + output_summary: "inspected `tests/app.test.ts` coverage.", + }, + { + command: "npm test", + status: "passed", + }, + ], + }, + }, + ); + + assert.match(body, /\[`src\/app\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/app\.ts\)/); + assert.match(body, /\[`tests\/app\.test\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/tests\/app\.test\.ts\)/); + assert.match(body, /\[`src\/app\.ts:7`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/app\.ts#L7\)/); + assert.match(body, /`Read` \[`src\/app\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/app\.ts\): `passed`/); + assert.match(body, /with `npm test`/); + assert.match(body, /- `npm test`: `passed`/); +}); + test("demo mode handles a signed labeled issue without external GitHub calls", async () => { const secret = "demo-route-secret"; const stateDir = tempStateDir(); From d60fe686dbb02f0ed07de99ae36fd39197711812 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 9 Jul 2026 21:19:27 -0400 Subject: [PATCH 2/4] fix issue webhook trigger gating --- src/adapter.ts | 55 +++++++++++++++++++++++++++++++++-- tests/webhook-adapter.test.ts | 45 +++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/adapter.ts b/src/adapter.ts index 60c346c..297c6f0 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -404,6 +404,36 @@ function labelsIncludeTrigger(labels: JsonValue | undefined, policy: JsonObject) return false; } +function issueAssignedToBot(issue: JsonObject, policy: JsonObject): boolean { + const wanted = new Set(((policy.bot_usernames as JsonValue[]) || []).map((username) => String(username).toLowerCase())); + if (wanted.size === 0) { + return false; + } + const assignees: JsonValue[] = []; + if (issue.assignee) { + assignees.push(issue.assignee); + } + if (Array.isArray(issue.assignees)) { + assignees.push(...issue.assignees); + } + for (const assignee of assignees) { + const login = typeof assignee === "object" && assignee !== null && !Array.isArray(assignee) + ? String((assignee as JsonObject).login || "") + : String(assignee); + if (wanted.has(login.toLowerCase())) { + return true; + } + } + return false; +} + +function triggerEnabled(policy: JsonObject, trigger: string): boolean { + if (policy.enabled_triggers === undefined) { + return true; + } + return new Set(((policy.enabled_triggers as JsonValue[]) || []).map((value) => String(value))).has(trigger); +} + export function buildTaskFromEvent( eventName: string, deliveryId: string, @@ -437,6 +467,12 @@ export function buildTaskFromEvent( if (eventName === "issue_comment") { const issue = (payload.issue as JsonObject | undefined) || {}; const comment = (payload.comment as JsonObject | undefined) || {}; + if (payload.action !== "created") { + return ignored(base, "unsupported_issue_comment_action"); + } + if (!triggerEnabled(policy, "issue_comment.created")) { + return ignored(base, "issue_comment_not_enabled"); + } if (!mentioned(comment.body, policy)) { return ignored(base, "issue_comment_without_mention"); } @@ -468,6 +504,12 @@ export function buildTaskFromEvent( if (eventName === "pull_request_review_comment") { const comment = (payload.comment as JsonObject | undefined) || {}; const pullRequest = (payload.pull_request as JsonObject | undefined) || {}; + if (payload.action !== "created") { + return ignored(base, "unsupported_pr_review_comment_action"); + } + if (!triggerEnabled(policy, "pull_request_review_comment.created")) { + return ignored(base, "pr_review_comment_not_enabled"); + } if (!mentioned(comment.body, policy)) { return ignored(base, "pr_review_comment_without_mention"); } @@ -492,14 +534,23 @@ export function buildTaskFromEvent( if (eventName === "issues") { const issue = (payload.issue as JsonObject | undefined) || {}; const action = payload.action; - if (action !== "assigned" && action !== "labeled" && action !== "opened") { + if (action !== "assigned" && action !== "labeled") { return ignored(base, "unsupported_issue_action"); } + if (action === "assigned" && !triggerEnabled(policy, "issues.assigned")) { + return ignored(base, "issue_assigned_not_enabled"); + } + if (action === "assigned" && !issueAssignedToBot(issue, policy)) { + return ignored(base, "issue_assigned_to_unmanaged_user"); + } + if (action === "labeled" && !triggerEnabled(policy, "issues.labeled")) { + return ignored(base, "issue_label_not_enabled"); + } if (action === "labeled" && !labelsIncludeTrigger(issue.labels, policy)) { return ignored(base, "issue_label_not_enabled"); } Object.assign(base, { - trigger: action === "assigned" ? "issue_assigned" : "issue_mention", + trigger: "issue_assigned", task: { kind: "fix_issue", issue_number: Number(issue.number || 0), diff --git a/tests/webhook-adapter.test.ts b/tests/webhook-adapter.test.ts index bb4342d..28a68ce 100644 --- a/tests/webhook-adapter.test.ts +++ b/tests/webhook-adapter.test.ts @@ -335,7 +335,7 @@ test("example policy routes a labeled issue to the configured familiar", () => { ); assert.equal(task.state, "queued"); - assert.equal(task.trigger, "issue_mention"); + assert.equal(task.trigger, "issue_assigned"); assert.deepEqual(task.task, { kind: "fix_issue", issue_number: 42, @@ -350,6 +350,49 @@ test("example policy routes a labeled issue to the configured familiar", () => { }); }); +test("new issue creation is ignored unless a supported trigger is enabled", () => { + const task = buildTaskFromEvent( + "issues", + "delivery-issue-opened", + { + action: "opened", + installation: {id: 123456}, + repository: { + id: 987654321, + full_name: "OpenCoven/example", + clone_url: "https://github.com/OpenCoven/example.git", + default_branch: "main", + }, + issue: { + number: 43, + title: "Installer is slow", + body: "A diagnostic issue, not a bot task.", + labels: [], + assignees: [], + }, + } as JsonObject, + { + enabled_triggers: [ + "issues.labeled", + "issue_comment.created", + "pull_request_review_comment.created", + ], + trigger_labels: ["coven:fix"], + bot_usernames: ["coven-cody[bot]"], + familiar: { + id: "cody", + display_name: "Cody", + model: "openai/gpt-5.5", + skills: [], + }, + publication: {mode: "record_only"}, + } as JsonObject, + ); + + assert.equal(task.state, "ignored"); + assert.equal(task.ignored_reason, "unsupported_issue_action"); +}); + test("publication body links screenshot-style file mentions to GitHub blobs", () => { const body = publicationCommentBody( { From 9b6c287937daed1437877e862816bc9f10a7a499 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:44:01 -0400 Subject: [PATCH 3/4] fix: avoid misleading GitHub file links --- src/adapter.ts | 4 +++- tests/webhook-adapter.test.ts | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/adapter.ts b/src/adapter.ts index 7ce9fb5..1b5fdf4 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -4556,7 +4556,9 @@ function parseRepoRelativePath(rawPath: string): {display: string; path: string; } function githubFileMentionAllowed(target: NonNullable>, knownFiles: Set): boolean { - return knownFiles.has(target.path) || (!/\s/.test(target.display) && target.path.includes("/")); + const filename = target.path.split("/").at(-1) || ""; + return knownFiles.has(target.path) + || (!/\s/.test(target.display) && target.path.includes("/") && /\.[A-Za-z][A-Za-z0-9._-]{0,15}$/.test(filename)); } function githubFileMarkdown( diff --git a/tests/webhook-adapter.test.ts b/tests/webhook-adapter.test.ts index 4da3814..5f6947b 100644 --- a/tests/webhook-adapter.test.ts +++ b/tests/webhook-adapter.test.ts @@ -1280,6 +1280,7 @@ test("publication body links screenshot-style file mentions to GitHub blobs", () "- Fixed a bug, e.g. the parser broke.", "- In other words, i.e. no bogus abbreviation links.", "- Mentioned foo.bar.baz.qux in prose.", + "- Compared `agent/pr9-resolution`, `release/v1.2.3`, and `OpenCoven/coven-github`.", "- Grep for https://github.com/OpenCoven/coven-github-webhook/blob/main/src/adapter.ts and tests_run[].output_summary.", "- `README.md:12`", "- `README.md:12-14`", @@ -1339,6 +1340,10 @@ test("publication body links screenshot-style file mentions to GitHub blobs", () assert.doesNotMatch(body, /\[`e\.g`\]/); assert.doesNotMatch(body, /\[`i\.e`\]/); assert.doesNotMatch(body, /\[`foo\.bar\.baz\.qux`\]/); + assert.match(body, /Compared `agent\/pr9-resolution`, `release\/v1\.2\.3`, and `OpenCoven\/coven-github`/); + assert.doesNotMatch(body, /blob\/abc123def456\/agent\/pr9-resolution/); + assert.doesNotMatch(body, /blob\/abc123def456\/release\/v1\.2\.3/); + assert.doesNotMatch(body, /blob\/abc123def456\/OpenCoven\/coven-github/); assert.match( body, /\[`README\.md:12`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/README\.md#L12\)/, From 3b27edbee760be933da3fa15bc730154a9cda53d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:21:15 -0400 Subject: [PATCH 4/4] fix: harden review publication links --- src/adapter.ts | 878 +++++++++++++++++++++++++++++++--- tests/webhook-adapter.test.ts | 386 ++++++++++++++- 2 files changed, 1196 insertions(+), 68 deletions(-) diff --git a/src/adapter.ts b/src/adapter.ts index e32ab8c..4b84e08 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -3521,6 +3521,606 @@ function safePublicationText(value: string, maxLength = 60_000): string { return redactTokenish(value).slice(0, maxLength); } +function markdownCodeSpanEnd(value: string, start: number, limit = value.length): number | null { + let openingEnd = start; + while (openingEnd < limit && value[openingEnd] === "`") openingEnd += 1; + const delimiterLength = openingEnd - start; + let cursor = openingEnd; + while (cursor < limit) { + if (value[cursor] !== "`") { + cursor += 1; + continue; + } + let runEnd = cursor; + while (runEnd < limit && value[runEnd] === "`") runEnd += 1; + if (runEnd - cursor === delimiterLength) return runEnd; + cursor = runEnd; + } + return null; +} + +interface MarkdownBacktickSpans { + starts: number[]; + ends: number[]; +} + +function markdownFenceSpans(value: string): MarkdownBacktickSpans { + const starts: number[] = []; + const ends: number[] = []; + let active: {start: number; marker: "`" | "~"; length: number} | null = null; + let lineStart = 0; + while (lineStart <= value.length) { + const newline = value.indexOf("\n", lineStart); + const lineEnd = newline < 0 ? value.length : newline; + const line = value.slice(lineStart, lineEnd).replace(/\r$/, ""); + if (active) { + const closing = /^ {0,3}(`+|~+)[ \t]*$/.exec(line); + if (closing && closing[1][0] === active.marker && closing[1].length >= active.length) { + starts.push(active.start); + ends.push(newline < 0 ? lineEnd : newline + 1); + active = null; + } + } else { + const opening = /^ {0,3}(`{3,}|~{3,})/.exec(line); + if (opening) active = {start: lineStart, marker: opening[1][0] as "`" | "~", length: opening[1].length}; + } + if (newline < 0) break; + lineStart = newline + 1; + } + if (active) { + starts.push(active.start); + ends.push(value.length); + } + return {starts, ends}; +} + +function markdownBacktickSpans(value: string, fenceSpans: MarkdownBacktickSpans = markdownFenceSpans(value)): MarkdownBacktickSpans { + const runStarts: number[] = []; + const runEnds: number[] = []; + const runLengths: number[] = []; + const runEscaped: boolean[] = []; + let cursor = 0; + let nextFence = 0; + while (cursor < value.length) { + const start = value.indexOf("`", cursor); + if (start < 0) break; + while (nextFence < fenceSpans.starts.length && fenceSpans.ends[nextFence] <= start) nextFence += 1; + if (nextFence < fenceSpans.starts.length && fenceSpans.starts[nextFence] <= start && start < fenceSpans.ends[nextFence]) { + cursor = fenceSpans.ends[nextFence]; + continue; + } + let end = start + 1; + while (value[end] === "`") end += 1; + let escapeCursor = start - 1; + let escapeCount = 0; + while (escapeCursor >= 0 && value[escapeCursor] === "\\") { + escapeCount += 1; + escapeCursor -= 1; + } + runStarts.push(start); + runEnds.push(end); + runLengths.push(end - start); + runEscaped.push(escapeCount % 2 === 1); + cursor = end; + } + + const nextSame = new Int32Array(runStarts.length); + nextSame.fill(-1); + const nextByLength = new Map(); + for (let index = runStarts.length - 1; index >= 0; index -= 1) { + nextSame[index] = nextByLength.get(runLengths[index]) ?? -1; + nextByLength.set(runLengths[index], index); + } + + const starts: number[] = []; + const ends: number[] = []; + for (let index = 0; index < runStarts.length;) { + if (runEscaped[index]) { + index += 1; + continue; + } + const closing = nextSame[index]; + if (closing >= 0) { + starts.push(runStarts[index]); + ends.push(runEnds[closing]); + index = closing + 1; + } else { + index += 1; + } + } + return {starts, ends}; +} + +function indexedBacktickSpanEnd(spans: MarkdownBacktickSpans, start: number): number | null { + let low = 0; + let high = spans.starts.length - 1; + while (low <= high) { + const middle = (low + high) >>> 1; + if (spans.starts[middle] === start) return spans.ends[middle]; + if (spans.starts[middle] < start) low = middle + 1; + else high = middle - 1; + } + return null; +} + +function markdownBracketEnd( + value: string, + start: number, + limit = value.length, + backtickSpans?: MarkdownBacktickSpans, +): number | null { + let depth = 0; + let cursor = start; + while (cursor < limit) { + if (value[cursor] === "\\") { + cursor = Math.min(limit, cursor + 2); + continue; + } + if (value[cursor] === "`") { + const codeEnd = backtickSpans ? indexedBacktickSpanEnd(backtickSpans, cursor) : markdownCodeSpanEnd(value, cursor, limit); + if (codeEnd !== null && codeEnd <= limit) { + cursor = codeEnd; + continue; + } + if (codeEnd !== null && codeEnd > limit) return null; + while (cursor < limit && value[cursor] === "`") cursor += 1; + continue; + } + if (value[cursor] === "[") depth += 1; + if (value[cursor] === "]") { + depth -= 1; + if (depth === 0) return cursor + 1; + } + cursor += 1; + } + return null; +} + +function markdownParenthesizedEnd(value: string, start: number, limit = value.length): number | null { + let depth = 0; + let angleDestination = false; + let destinationStarted = false; + let destinationComplete = false; + let quotedTitle: "\"" | "'" | null = null; + let cursor = start; + while (cursor < limit) { + if (value[cursor] === "\\") { + cursor = Math.min(limit, cursor + 2); + continue; + } + if (quotedTitle) { + if (value[cursor] === quotedTitle) quotedTitle = null; + cursor += 1; + continue; + } + if (angleDestination) { + if (value[cursor] === ">") { + angleDestination = false; + destinationComplete = true; + } + cursor += 1; + continue; + } + if (depth === 1 && destinationComplete && (value[cursor] === "\"" || value[cursor] === "'")) { + quotedTitle = value[cursor] as "\"" | "'"; + cursor += 1; + continue; + } + if (value[cursor] === "(") depth += 1; + if (value[cursor] === ")") { + depth -= 1; + if (depth === 0) return cursor + 1; + } + if (depth === 1 && cursor > start) { + if (!destinationStarted && /\s/.test(value[cursor])) { + cursor += 1; + continue; + } + if (!destinationStarted) { + destinationStarted = true; + if (value[cursor] === "<") angleDestination = true; + } else if (/\s/.test(value[cursor])) { + destinationComplete = true; + } + } + cursor += 1; + } + return null; +} + +function markdownFenceEnd(value: string, start: number, limit = value.length): number | null | undefined { + if (value[start] !== "`" && value[start] !== "~") return undefined; + let prefix = start - 1; + let indentation = 0; + while (prefix >= 0 && value[prefix] === " " && indentation < 4) { + prefix -= 1; + indentation += 1; + } + if (indentation > 3 || (prefix >= 0 && value[prefix] !== "\n")) return undefined; + let openingEnd = start; + while (openingEnd < limit && value[openingEnd] === value[start]) openingEnd += 1; + const markerLength = openingEnd - start; + if (markerLength < 3) return undefined; + const openingLineEnd = value.indexOf("\n", openingEnd); + if (openingLineEnd < 0 || openingLineEnd >= limit) return null; + + let cursor = openingLineEnd + 1; + while (cursor <= limit) { + const newline = value.indexOf("\n", cursor); + if (newline > limit || (newline < 0 && limit < value.length)) return null; + const lineEnd = newline < 0 ? value.length : newline; + const line = value.slice(cursor, lineEnd).replace(/\r$/, ""); + const closing = /^ {0,3}(`+|~+)[ \t]*$/.exec(line); + if (closing && closing[1][0] === value[start] && closing[1].length >= markerLength) { + return newline < 0 || newline >= limit ? lineEnd : newline + 1; + } + if (newline < 0 || newline >= limit) break; + cursor = newline + 1; + } + return null; +} + +function markdownAngleEnd(value: string, start: number, limit = value.length): number | null | undefined { + if (value[start] !== "<" || !/[A-Za-z/!?]/.test(value[start + 1] || "")) return undefined; + let quote: "\"" | "'" | null = null; + let cursor = start + 1; + while (cursor < limit) { + if (quote) { + if (value[cursor] === quote) quote = null; + cursor += 1; + continue; + } + if (value[cursor] === "\"" || value[cursor] === "'") { + quote = value[cursor] as "\"" | "'"; + cursor += 1; + continue; + } + if (value[cursor] === ">") return cursor + 1; + if (value[cursor] === "\n" || value[cursor] === "\r") return null; + cursor += 1; + } + return null; +} + +function markdownAtomicEnd( + value: string, + start: number, + limit: number, + backtickSpans?: MarkdownBacktickSpans, + bracketPairs?: Map, + parenthesisPairs?: Map, +): number | null { + const fenceEnd = markdownFenceEnd(value, start, limit); + if (fenceEnd !== undefined) return fenceEnd; + const angleEnd = markdownAngleEnd(value, start, limit); + if (angleEnd !== undefined) return angleEnd; + if (value[start] === "`") { + const indexedEnd = backtickSpans ? indexedBacktickSpanEnd(backtickSpans, start) : markdownCodeSpanEnd(value, start, limit); + return indexedEnd !== null && indexedEnd <= limit ? indexedEnd : null; + } + const labelStart = value[start] === "!" && value[start + 1] === "[" ? start + 1 : start; + if (value[labelStart] !== "[") return start + (Number(value.codePointAt(start)) > 0xFFFF ? 2 : 1); + const pairedBracketEnd = bracketPairs?.get(labelStart); + const labelEnd = pairedBracketEnd !== undefined + ? (pairedBracketEnd <= limit ? pairedBracketEnd : null) + : markdownBracketEnd(value, labelStart, limit, backtickSpans); + if (labelEnd === null) return null; + if (labelEnd < value.length && value[labelEnd] === "(") { + if (parenthesisPairs) { + const parenthesizedEnd = parenthesisPairs.get(labelEnd); + if (parenthesizedEnd === undefined) return labelEnd; + return parenthesizedEnd <= limit ? parenthesizedEnd : null; + } + return markdownParenthesizedEnd(value, labelEnd, limit); + } + if (labelEnd < limit && value[labelEnd] === "[") { + const pairedReferenceEnd = bracketPairs?.get(labelEnd); + return pairedReferenceEnd !== undefined + ? (pairedReferenceEnd <= limit ? pairedReferenceEnd : null) + : markdownBracketEnd(value, labelEnd, limit, backtickSpans); + } + return labelEnd; +} + +interface MarkdownLinkDestinationState { + start: number; + destinationStarted: boolean; + destinationComplete: boolean; + angleDestination: boolean; + quotedTitle: "\"" | "'" | null; +} + +function markdownParenthesisPairs( + value: string, + backtickSpans: MarkdownBacktickSpans, + fenceSpans: MarkdownBacktickSpans, + bracketPairs: Map, +): Map { + const linkOpeners = new Set(); + for (const labelEnd of bracketPairs.values()) { + if (value[labelEnd] === "(") linkOpeners.add(labelEnd); + } + + const pairs = new Map(); + // Keep nested balancing compact: negative entries are link openers and + // positive entries are ordinary parentheses, both encoded as offset + 1. + const stack: number[] = []; + let activeLink: MarkdownLinkDestinationState | null = null; + let cursor = 0; + let nextFence = 0; + while (cursor < value.length) { + if (value[cursor] === "\\") { + if (activeLink && Math.abs(stack.at(-1) || 0) - 1 === activeLink.start && !activeLink.destinationStarted) { + activeLink.destinationStarted = true; + } + cursor = Math.min(value.length, cursor + 2); + continue; + } + if (activeLink?.quotedTitle) { + if (value[cursor] === activeLink.quotedTitle) activeLink.quotedTitle = null; + cursor += 1; + continue; + } + if (activeLink?.angleDestination) { + if (value[cursor] === ">") { + activeLink.angleDestination = false; + activeLink.destinationComplete = true; + } + cursor += 1; + continue; + } + + if (!activeLink) { + while (nextFence < fenceSpans.starts.length && fenceSpans.ends[nextFence] <= cursor) nextFence += 1; + if (nextFence < fenceSpans.starts.length && fenceSpans.starts[nextFence] <= cursor && cursor < fenceSpans.ends[nextFence]) { + cursor = fenceSpans.ends[nextFence]; + continue; + } + if (value[cursor] === "`") { + const codeEnd = indexedBacktickSpanEnd(backtickSpans, cursor); + if (codeEnd !== null) { + cursor = codeEnd; + continue; + } + while (cursor < value.length && value[cursor] === "`") cursor += 1; + continue; + } + } + + const activeAtTopLevel = activeLink !== null && Math.abs(stack.at(-1) || 0) - 1 === activeLink.start; + if ( + activeLink + && activeAtTopLevel + && activeLink.destinationComplete + && (value[cursor] === "\"" || value[cursor] === "'") + ) { + activeLink.quotedTitle = value[cursor] as "\"" | "'"; + cursor += 1; + continue; + } + if (value[cursor] === "(") { + const isLinkOpener = linkOpeners.has(cursor); + if (!activeLink && !isLinkOpener) { + cursor += 1; + continue; + } + const startsLinkDestination = activeLink === null; + stack.push(isLinkOpener ? -(cursor + 1) : cursor + 1); + if (startsLinkDestination) activeLink = { + start: cursor, + destinationStarted: false, + destinationComplete: false, + angleDestination: false, + quotedTitle: null, + }; + cursor += 1; + continue; + } + if (value[cursor] === ")") { + const encodedStart = stack.pop(); + if (encodedStart !== undefined) { + const start = Math.abs(encodedStart) - 1; + if (encodedStart < 0) pairs.set(start, cursor + 1); + if (activeLink?.start === start) { + activeLink = null; + } else if (activeLink && Math.abs(stack.at(-1) || 0) - 1 === activeLink.start && !activeLink.destinationStarted) { + activeLink.destinationStarted = true; + } + } + cursor += 1; + continue; + } + if (activeAtTopLevel && activeLink) { + if (!activeLink.destinationStarted && /\s/.test(value[cursor])) { + cursor += 1; + continue; + } + if (!activeLink.destinationStarted) { + activeLink.destinationStarted = true; + if (value[cursor] === "<") activeLink.angleDestination = true; + } else if (/\s/.test(value[cursor])) { + activeLink.destinationComplete = true; + } + } + cursor += Number(value.codePointAt(cursor)) > 0xFFFF ? 2 : 1; + } + return pairs; +} + +function markdownBracketPairs( + value: string, + backtickSpans: MarkdownBacktickSpans, + fenceSpans: MarkdownBacktickSpans, +): Map { + const stack: number[] = []; + const matched = new Map(); + let cursor = 0; + let nextFence = 0; + while (cursor < value.length) { + while (nextFence < fenceSpans.starts.length && fenceSpans.ends[nextFence] <= cursor) nextFence += 1; + if (nextFence < fenceSpans.starts.length && fenceSpans.starts[nextFence] <= cursor && cursor < fenceSpans.ends[nextFence]) { + cursor = fenceSpans.ends[nextFence]; + continue; + } + if (value[cursor] === "\\") { + cursor = Math.min(value.length, cursor + 2); + continue; + } + if (value[cursor] === "`") { + const codeEnd = indexedBacktickSpanEnd(backtickSpans, cursor); + if (codeEnd !== null) { + cursor = codeEnd; + continue; + } + while (cursor < value.length && value[cursor] === "`") cursor += 1; + continue; + } + if (value[cursor] === "[") stack.push(cursor); + if (value[cursor] === "]" && stack.length) matched.set(stack.pop() as number, cursor + 1); + cursor += 1; + } + return matched; +} + +function markdownSafePrefix(value: string, maxLength: number): string { + let cursor = 0; + let safeEnd = 0; + const fenceSpans = markdownFenceSpans(value); + const backtickSpans = markdownBacktickSpans(value, fenceSpans); + const bracketPairs = markdownBracketPairs(value, backtickSpans, fenceSpans); + const parenthesisPairs = markdownParenthesisPairs(value, backtickSpans, fenceSpans, bracketPairs); + let ignoreUnmatchedAnglesUntil = -1; + while (cursor < value.length && cursor < maxLength) { + const labelStart = value[cursor] === "!" && value[cursor + 1] === "[" ? cursor + 1 : cursor; + const labelEnd = bracketPairs.get(labelStart); + if (value[labelStart] === "[" && labelEnd === undefined) { + cursor += 1; + safeEnd = cursor; + continue; + } + if (value[cursor] === "<" && cursor < ignoreUnmatchedAnglesUntil) { + cursor += 1; + safeEnd = cursor; + continue; + } + const end = markdownAtomicEnd(value, cursor, maxLength, backtickSpans, bracketPairs, parenthesisPairs); + if (end === null) { + const fullEnd = markdownAtomicEnd(value, cursor, value.length, backtickSpans, bracketPairs, parenthesisPairs); + if (fullEnd !== null) break; + const fenceEnd = markdownFenceEnd(value, cursor, value.length); + if (fenceEnd === null) break; + if (markdownAngleEnd(value, cursor, value.length) === null) { + const newline = value.indexOf("\n", cursor); + ignoreUnmatchedAnglesUntil = newline < 0 ? value.length : newline + 1; + } + if (value[cursor] === "`") { + while (cursor < maxLength && value[cursor] === "`") cursor += 1; + } else { + cursor += Number(value.codePointAt(cursor)) > 0xFFFF ? 2 : 1; + } + safeEnd = cursor; + continue; + } + if (end > maxLength) break; + cursor = end; + safeEnd = end; + } + return value.slice(0, safeEnd); +} + +function markdownProtectedRanges(value: string): Array<{start: number; end: number}> { + const ranges: Array<{start: number; end: number}> = []; + let cursor = 0; + const fenceSpans = markdownFenceSpans(value); + const backtickSpans = markdownBacktickSpans(value, fenceSpans); + const bracketPairs = markdownBracketPairs(value, backtickSpans, fenceSpans); + const parenthesisPairs = markdownParenthesisPairs(value, backtickSpans, fenceSpans, bracketPairs); + while (cursor < value.length) { + const ordinaryEnd = cursor + (Number(value.codePointAt(cursor)) > 0xFFFF ? 2 : 1); + const labelStart = value[cursor] === "!" && value[cursor + 1] === "[" ? cursor + 1 : cursor; + const labelEnd = bracketPairs.get(labelStart); + if (value[labelStart] === "[" && labelEnd === undefined) { + cursor = ordinaryEnd; + continue; + } + const end = markdownAtomicEnd(value, cursor, value.length, backtickSpans, bracketPairs, parenthesisPairs); + if (end === null) { + const fenceEnd = markdownFenceEnd(value, cursor, value.length); + if (fenceEnd === null) { + ranges.push({start: cursor, end: value.length}); + break; + } + if ((labelEnd !== undefined && value[labelEnd] === "(") || markdownAngleEnd(value, cursor, value.length) === null) { + ranges.push({start: cursor, end: value.length}); + break; + } + if (value[cursor] === "`") { + while (cursor < value.length && value[cursor] === "`") cursor += 1; + } else { + cursor = ordinaryEnd; + } + continue; + } + if (end > ordinaryEnd || value[cursor] === "`" || value[cursor] === "[") { + ranges.push({start: cursor, end}); + } + cursor = end; + } + return ranges; +} + +function markdownLinkRanges(value: string): Array<{start: number; end: number}> { + const ranges: Array<{start: number; end: number}> = []; + const fenceSpans = markdownFenceSpans(value); + const backtickSpans = markdownBacktickSpans(value, fenceSpans); + const bracketPairs = markdownBracketPairs(value, backtickSpans, fenceSpans); + const parenthesisPairs = markdownParenthesisPairs(value, backtickSpans, fenceSpans, bracketPairs); + let cursor = 0; + while (cursor < value.length) { + if (value[cursor] === "\\") { + cursor = Math.min(value.length, cursor + 2); + continue; + } + if (value[cursor] === "`") { + const codeEnd = indexedBacktickSpanEnd(backtickSpans, cursor); + if (codeEnd === null) { + while (cursor < value.length && value[cursor] === "`") cursor += 1; + continue; + } + cursor = codeEnd; + continue; + } + const isBracketConstruct = value[cursor] === "[" || (value[cursor] === "!" && value[cursor + 1] === "["); + const isAngleConstruct = markdownAngleEnd(value, cursor) !== undefined; + if (isBracketConstruct || isAngleConstruct) { + const end = markdownAtomicEnd(value, cursor, value.length, backtickSpans, bracketPairs, parenthesisPairs); + if (end === null) { + ranges.push({start: cursor, end: value.length}); + break; + } + ranges.push({start: cursor, end}); + cursor = end; + continue; + } + cursor += Number(value.codePointAt(cursor)) > 0xFFFF ? 2 : 1; + } + return ranges; +} + +function safeMarkdownPublicationText(value: string, maxLength = 60_000): string { + const safeValue = redactTokenish(value); + if (safeValue.length <= maxLength) return safeValue; + const notice = "\n\n_Review output truncated to fit GitHub's publication limit._"; + const prefix = markdownSafePrefix(safeValue, Math.max(0, maxLength - notice.length)).trimEnd(); + return prefix ? `${prefix}${notice}` : notice.trim().slice(0, maxLength); +} + +function publicationBodyWithMarker(markdown: string, marker: string, maxLength = 60_000): string { + const separator = "\n\n"; + const contentBudget = Math.max(0, Math.min(59_700, maxLength - separator.length - marker.length)); + return `${safeMarkdownPublicationText(markdown, contentBudget)}${separator}${marker}`; +} + function safeReviewNotice(body: string, notice: string, maxLength = 60_000): string { const safeBody = redactTokenish(body); const safeNotice = redactTokenish(notice).trim(); @@ -3530,7 +4130,7 @@ function safeReviewNotice(body: string, notice: string, maxLength = 60_000): str : safeBody.trimEnd(); const suffix = marker.raw ? `${safeNotice}\n\n${marker.raw}` : safeNotice; const prefixLength = Math.max(0, maxLength - suffix.length - 2); - const prefix = withoutMarker.slice(0, prefixLength).trimEnd(); + const prefix = markdownSafePrefix(withoutMarker, prefixLength).trimEnd(); return prefix ? `${prefix}\n\n${suffix}` : suffix.slice(0, maxLength); } @@ -4319,7 +4919,8 @@ export async function publishResultIfConfigured( for (const pending of trustedReviews.filter((review) => String(review.state || "").toUpperCase() === "PENDING")) { await githubRequest("DELETE", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${Number(pending.id)}`, token); } - let publishedBody = `${safePublicationText(publicationReviewBody(task, result, normalized, previous, identity), 59_700)}\n\n${publicationMarker(trust, identity, task.created_at, evidence.base_sha)}`; + const marker = publicationMarker(trust, identity, task.created_at, evidence.base_sha); + let publishedBody = publicationBodyWithMarker(publicationReviewBody(task, result, normalized, previous, identity), marker); const reviewPayload: JsonObject = {body: publishedBody, commit_id: evidence.head_sha}; if (normalized.inlineComments.length) reviewPayload.comments = normalized.inlineComments; let pendingReview: JsonObject; @@ -4347,7 +4948,8 @@ export async function publishResultIfConfigured( } const issueTrust = publicationTrust(config, task, `${repo}#issue:${Number(number)}`); - const body = `${safePublicationText(publicationCommentBody(task, result, "Coven task result"), 59_700)}\n\n${publicationMarker(issueTrust, identity, task.created_at)}`; + const marker = publicationMarker(issueTrust, identity, task.created_at); + const body = publicationBodyWithMarker(publicationCommentBody(task, result, "Coven task result"), marker); if (identities.includes(String(task.publication_identity || "")) && task.publication_comment_id) { try { const candidateResponse = await githubRequest("GET", `https://api.github.com/repos/${repo}/issues/comments/${Number(task.publication_comment_id)}`, token); @@ -4485,9 +5087,11 @@ export function publicationCommentBody(task: JsonObject, result: JsonObject, hea } function markdownCodeSpan(value: string): string { - const longestRun = Math.max(0, ...(value.match(/`+/g) || []).map((run) => run.length)); + let longestRun = 0; + for (const match of value.matchAll(/`+/g)) longestRun = Math.max(longestRun, match[0].length); const delimiter = "`".repeat(longestRun + 1); - return `${delimiter}${value}${delimiter}`; + const padding = value.startsWith("`") || value.endsWith("`") ? " " : ""; + return `${delimiter}${padding}${value}${padding}${delimiter}`; } function encodeGithubUrlComponent(value: string): string { @@ -4497,15 +5101,21 @@ function encodeGithubUrlComponent(value: string): string { function githubFileRef(task: JsonObject, path: string): string { const evidence = (task.review_evidence as JsonObject | undefined) || {}; const details = Array.isArray(evidence.changed_file_details) ? (evidence.changed_file_details as JsonObject[]) : []; + const headRef = String(evidence.head_sha || evidence.workspace_head_sha || task.default_branch || "").trim(); + for (const detail of details) { + const currentPath = canonicalRepoPath(String(detail.path || "")); + const status = String(detail.status || "").toLowerCase(); + if (currentPath === path && status !== "removed") return headRef; + } for (const detail of details) { - const currentPath = String(detail.path || "").replaceAll("\\", "/"); - const previousPath = String(detail.previous_path || "").replaceAll("\\", "/"); + const currentPath = canonicalRepoPath(String(detail.path || "")); + const previousPath = canonicalRepoPath(String(detail.previous_path || "")); const status = String(detail.status || "").toLowerCase(); - if ((currentPath === path && status === "removed") || (previousPath === path && previousPath !== currentPath)) { - return String(evidence.base_sha || evidence.head_sha || evidence.workspace_head_sha || task.default_branch || "").trim(); + if ((currentPath === path && status === "removed") || (status === "renamed" && previousPath === path && previousPath !== currentPath)) { + return String(evidence.merge_base_sha || evidence.base_sha || evidence.head_sha || evidence.workspace_head_sha || task.default_branch || "").trim(); } } - return String(evidence.head_sha || evidence.workspace_head_sha || task.default_branch || "").trim(); + return headRef; } function githubBlobBase(task: JsonObject, path: string): string | null { @@ -4521,38 +5131,52 @@ function githubBlobBase(task: JsonObject, path: string): string | null { return `https://github.com/${repository}/blob/${encodedRef}`; } -function parseRepoRelativePath(rawPath: string): {display: string; path: string; line: number | null; endLine: number | null} | null { +function canonicalRepoPath(rawPath: string): string | null { const display = rawPath.trim(); - if (!display || display !== rawPath || /[\0\r\n]/.test(display)) { - return null; - } - if (/^[\\/]/.test(display) || /^[A-Za-z]:[\\/]/.test(display)) { + if (!display || display !== rawPath || /[\0\r\n]/.test(display)) return null; + let path = display; + while (path.startsWith("./")) path = path.slice(2); + const segments = path.split("/"); + if ( + !path + || path.startsWith("/") + || path.includes("//") + || segments.some((segment) => !segment || segment === "." || segment === ".." || /[\0\r\n]/.test(segment)) + ) return null; + return path; +} + +function parseRepoRelativePath( + rawPath: string, + knownFiles: Set = new Set(), +): {display: string; path: string; line: number | null; endLine: number | null} | null { + const display = rawPath.trim(); + if (!display || display !== rawPath || /[\0\r\n]/.test(display)) return null; + + const resolveKnownPath = (candidate: string): string | null => { + const exact = canonicalRepoPath(candidate); + if (exact && knownFiles.has(exact)) return exact; + const slashNormalized = canonicalRepoPath(candidate.replaceAll("\\", "/")); + if (slashNormalized && knownFiles.has(slashNormalized)) return slashNormalized; return null; - } + }; + const exactKnown = resolveKnownPath(display); + if (exactKnown) return {display, path: exactKnown, line: null, endLine: null}; - let path = display.replaceAll("\\", "/"); + let pathCandidate = display; let line: number | null = null; let endLine: number | null = null; - const lineMatch = /^(.*):(\d+)(?:-(\d+))?$/.exec(path); + const lineMatch = /^(.*):(\d+)(?:-(\d+))?$/.exec(pathCandidate); if (lineMatch) { - path = lineMatch[1]; + pathCandidate = lineMatch[1]; line = Number(lineMatch[2]); endLine = lineMatch[3] ? Number(lineMatch[3]) : null; } - if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(path)) { - return null; - } - while (path.startsWith("./")) { - path = path.slice(2); - } - const segments = path.split("/"); - if ( - !path || - path.includes("//") || - segments.some((segment) => !segment || segment === "." || segment === ".." || /[\0\r\n]/.test(segment)) - ) { - return null; - } + const knownPath = resolveKnownPath(pathCandidate); + if (knownPath) return {display, path: knownPath, line, endLine}; + if (/^[\\/]/.test(pathCandidate) || /^[A-Za-z]:[\\/]/.test(pathCandidate)) return null; + const path = canonicalRepoPath(pathCandidate.replaceAll("\\", "/")); + if (!path || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(path)) return null; return {display, path, line, endLine}; } @@ -4568,7 +5192,7 @@ function githubFileMarkdown( lineOverride: number | null = null, knownFiles: Set = githubKnownFileSet(task), ): string { - const target = parseRepoRelativePath(rawPath); + const target = parseRepoRelativePath(rawPath, knownFiles); const plain = markdownCodeSpan(rawPath); if (!target || !githubFileMentionAllowed(target, knownFiles)) { return plain; @@ -4590,8 +5214,8 @@ function githubKnownFileSet(task: JsonObject, review: JsonObject = {}): Set(); const evidence = (task.review_evidence as JsonObject | undefined) || {}; const addFile = (value: JsonValue | undefined) => { - const target = parseRepoRelativePath(String(value || "")); - if (target) knownFiles.add(target.path); + const path = canonicalRepoPath(String(value || "")); + if (path) knownFiles.add(path); }; const addFiles = (value: JsonValue | undefined) => { if (Array.isArray(value)) value.forEach(addFile); @@ -4634,36 +5258,131 @@ function inlineCodeWithGithubFileLinks(task: JsonObject, rawText: string, knownF } function bareFileMentionAllowed(rawPath: string, knownFiles: Set): boolean { - const target = parseRepoRelativePath(rawPath); + const target = parseRepoRelativePath(rawPath, knownFiles); return Boolean(target && githubFileMentionAllowed(target, knownFiles)); } +function urlAuthorityLooksValid(authority: string): boolean { + if (/^\[[0-9A-F:.%]+\](?::\d{1,5})?$/i.test(authority)) return true; + let host = authority; + const port = /:(\d{1,5})$/.exec(host); + if (port) host = host.slice(0, -port[0].length); + if (host.toLowerCase() === "localhost") return true; + const labels = host.split("."); + if (labels.length === 4 && labels.every((label) => /^\d{1,3}$/.test(label) && Number(label) <= 255)) return true; + if (labels.length < 2 || !/^[A-Za-z]{2,63}$/.test(labels.at(-1) || "")) return false; + return labels.every((label) => /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label)); +} + +function markdownUrlLikeCandidate(candidate: string): boolean { + if (/^[A-Za-z][A-Za-z0-9+.-]{0,31}:/.test(candidate)) return true; + const authorityCandidate = candidate.startsWith("//") ? candidate.slice(2) : candidate; + const authority = authorityCandidate.split(/[/?#]/, 1)[0]; + return urlAuthorityLooksValid(authority); +} + +function markdownUrlLikeRanges(text: string, knownFiles: Set): Array<{start: number; end: number}> { + const ranges: Array<{start: number; end: number}> = []; + for (const match of text.matchAll(/\S+/g)) { + const token = match[0]; + let leading = /^[({<"']*/.exec(token)?.[0].length || 0; + const protectedLeading = leading; + let candidateEnd = token.length; + while (candidateEnd > leading && /[>)}\],.;!?"']/.test(token[candidateEnd - 1])) candidateEnd -= 1; + let candidate = token.slice(leading, candidateEnd); + if (!candidate) continue; + const emphasis = /^(\*{1,3}|_{1,3}|~{2,})(.+)\1$/.exec(candidate); + if (emphasis) { + leading += emphasis[1].length; + candidateEnd -= emphasis[1].length; + candidate = token.slice(leading, candidateEnd); + } + const repoTarget = parseRepoRelativePath(candidate, knownFiles); + if (repoTarget && knownFiles.has(repoTarget.path)) continue; + if (!markdownUrlLikeCandidate(candidate)) { + const assignment = /^(?:url|uri|href|endpoint|website|link)=/i.exec(candidate); + if (!assignment) continue; + candidate = candidate.slice(assignment[0].length); + if (!markdownUrlLikeCandidate(candidate)) continue; + leading += assignment[0].length; + } + ranges.push({start: match.index + protectedLeading, end: match.index + token.length}); + } + return ranges; +} + function linkBareGithubFileMentions(task: JsonObject, text: string, knownFiles: Set): string { - const markdownLinkPattern = /(\[[^\]]+\]\([^)]+\))/g; - const barePathPattern = /(^|[^\w./:[\]()`-])([A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*\.[A-Za-z][A-Za-z0-9_-]{0,15}(?::\d+(?:-\d+)?)?)(?=$|[^\w/-])/g; - return text - .split(markdownLinkPattern) - .map((segment, index) => { - if (index % 2 === 1) { - return segment; + const protectedRanges = [ + ...markdownProtectedRanges(text), + ...markdownUrlLikeRanges(text, knownFiles), + ].sort((left, right) => left.start - right.start || right.end - left.end); + let nextRange = 0; + let furthestProtectedEnd = -1; + let output = ""; + let cursor = 0; + for (const candidate of text.matchAll(/[A-Za-z0-9_.:/-]+/g)) { + const match = candidate[0]; + const pathOffset = candidate.index; + output += text.slice(cursor, pathOffset); + cursor = pathOffset + match.length; + while (nextRange < protectedRanges.length && protectedRanges[nextRange].start <= pathOffset) { + furthestProtectedEnd = Math.max(furthestProtectedEnd, protectedRanges[nextRange].end); + nextRange += 1; + } + const previous = pathOffset > 0 ? text[pathOffset - 1] : ""; + if (furthestProtectedEnd > pathOffset || /[[\]()`]/.test(previous)) { + output += match; + continue; + } + let rawPath = match; + const exactPath = canonicalRepoPath(rawPath); + const firstSegment = rawPath.split("/")[0]; + if (!(exactPath && knownFiles.has(exactPath)) && /^(?:www\.)?(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,63}$/i.test(firstSegment)) { + output += match; + continue; + } + while (rawPath && /[.:]$/.test(rawPath) && !(exactPath && knownFiles.has(exactPath))) { + rawPath = rawPath.slice(0, -1); + } + let rawOffset = 0; + if (!(exactPath && knownFiles.has(exactPath))) { + const emphasis = /^(_{1,3})(.+)\1$/.exec(rawPath); + if (emphasis && bareFileMentionAllowed(emphasis[2], knownFiles)) { + rawOffset = emphasis[1].length; + rawPath = emphasis[2]; } - return segment.replace(barePathPattern, (match, prefix: string, rawPath: string) => { - if (!bareFileMentionAllowed(rawPath, knownFiles)) { - return match; - } - const plain = markdownCodeSpan(rawPath); - const linked = githubFileMarkdown(task, rawPath, null, knownFiles); - return linked === plain ? match : `${prefix}${linked}`; - }); - }) - .join(""); + } + if (!rawPath || !bareFileMentionAllowed(rawPath, knownFiles)) { + output += match; + continue; + } + const plain = markdownCodeSpan(rawPath); + const linked = githubFileMarkdown(task, rawPath, null, knownFiles); + output += linked === plain + ? match + : `${match.slice(0, rawOffset)}${linked}${match.slice(rawOffset + rawPath.length)}`; + } + return `${output}${text.slice(cursor)}`; } function linkInlineCodeFileMentions(task: JsonObject, text: string, knownFiles: Set): string { let output = ""; let cursor = 0; + const protectedRanges = markdownLinkRanges(text); + let nextRange = 0; while (cursor < text.length) { + if (nextRange < protectedRanges.length && cursor === protectedRanges[nextRange].start) { + output += text.slice(cursor, protectedRanges[nextRange].end); + cursor = protectedRanges[nextRange].end; + nextRange += 1; + continue; + } if (text[cursor] !== "`") { + if (text[cursor] === "\\" && cursor + 1 < text.length) { + output += text.slice(cursor, cursor + 2); + cursor += 2; + continue; + } output += text[cursor]; cursor += 1; continue; @@ -4694,8 +5413,7 @@ function linkInlineCodeFileMentions(task: JsonObject, text: string, knownFiles: } const match = text.slice(cursor, closingEnd); const rawPath = text.slice(openingEnd, closingStart); - const alreadyLinkText = text[cursor - 1] === "[" && text.slice(closingEnd, closingEnd + 2) === "]("; - output += alreadyLinkText ? match : inlineCodeWithGithubFileLinks(task, rawPath, knownFiles); + output += inlineCodeWithGithubFileLinks(task, rawPath, knownFiles); cursor = closingEnd; } return output; @@ -4707,6 +5425,7 @@ function linkMarkdownProseLine(task: JsonObject, line: string, knownFiles: Set = githubKnownFileSet(task)): string { let activeFence: {marker: "`" | "~"; length: number} | null = null; + let referenceContinuation: "destination" | "title" | null = null; return text .split(/(\r?\n)/) .map((segment, index) => { @@ -4718,9 +5437,24 @@ function linkGithubFileMentions(task: JsonObject, text: string, knownFiles: Set< } const opening = /^ {0,3}(`{3,}|~{3,})/.exec(segment); if (opening) { + referenceContinuation = null; activeFence = {marker: opening[1][0] as "`" | "~", length: opening[1].length}; return segment; } + if (referenceContinuation === "destination") { + referenceContinuation = segment.trim() ? "title" : null; + return segment; + } + if (referenceContinuation === "title") { + referenceContinuation = null; + if (/^ {0,3}["'(]/.test(segment)) return segment; + } + if (/^(?: {4}|\t)/.test(segment)) return segment; + const referenceDefinition = /^ {0,3}\[(?:\\.|[^\\\]\r\n])+\]:[ \t]*(.*)$/.exec(segment); + if (referenceDefinition) { + referenceContinuation = referenceDefinition[1].trim() ? "title" : "destination"; + return segment; + } return linkMarkdownProseLine(task, segment, knownFiles); }) .join(""); @@ -4926,7 +5660,6 @@ export function runtimeDiagnostic(result: CommandResult): string | null { return `Coven Code exited ${result.returncode} without a diagnostic.`; } const safe = redactTokenish(raw) - .replace(/[A-Z0-9._%+-]+(?:\[[A-Z0-9_-]+\])?@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted email]") .replace(/\bon account\s+[^\n.]+/gi, "on the configured account") .replace(/\s+/g, " ") .trim(); @@ -4957,18 +5690,43 @@ export function runtimeProcessEnvironment(source: NodeJS.ProcessEnv, codexAccess }; } +function redactPrivateKeyBlocks(text: string): string { + const active = new Map(); + const ranges: Array<{start: number; end: number}> = []; + for (const token of text.matchAll(/-----(BEGIN|END) ([A-Z ]{0,64}PRIVATE KEY)-----/g)) { + const label = token[2]; + if (token[1] === "BEGIN") { + if (!active.has(label)) active.set(label, token.index); + continue; + } + const start = active.get(label); + if (start === undefined) continue; + ranges.push({start, end: token.index + token[0].length}); + active.delete(label); + } + if (!ranges.length) return text; + ranges.sort((left, right) => left.start - right.start || left.end - right.end); + let output = ""; + let cursor = 0; + for (const range of ranges) { + if (range.end <= cursor) continue; + output += `${text.slice(cursor, Math.max(cursor, range.start))}[redacted private key]`; + cursor = range.end; + } + return `${output}${text.slice(cursor)}`; +} + export function redactTokenish(text: string): string { if (!text) { return text; } - return text - .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[redacted private key]") + return redactPrivateKeyBlocks(text) .replace(/(https?:\/\/)[^/\s:@]+:[^@\s/]+@/gi, "$1[redacted]@") .replace(/x-access-token:[^@\s'\"]+/gi, "x-access-token:[redacted]") .replace(/\b(?:gh[pousr]_|github_pat_)[A-Za-z0-9_-]{6,}/g, "[redacted github token]") .replace(/\bsk-(?:proj-)?[A-Za-z0-9_-]{8,}/g, "[redacted OpenAI token]") .replace(/\bBearer\s+[^\s'\"]+/gi, "Bearer [redacted]") - .replace(/[A-Z0-9._%+-]+(?:\[[A-Z0-9_-]+\])?@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted email]") + .replace(/(^|[^A-Z0-9._%+-])([A-Z0-9._%+-]{1,64}(?:\[[A-Z0-9_-]{1,64}\])?@[A-Z0-9.-]{1,253}\.[A-Z]{2,63})(?![A-Z0-9.-])/gi, "$1[redacted email]") .replace(/\bon account\s+`?[^`\n.]+`?/gi, "on the configured account") .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[redacted JWT]"); } diff --git a/tests/webhook-adapter.test.ts b/tests/webhook-adapter.test.ts index 54225e8..8e74d10 100644 --- a/tests/webhook-adapter.test.ts +++ b/tests/webhook-adapter.test.ts @@ -1290,13 +1290,52 @@ test("publication body links screenshot-style file mentions to GitHub blobs", () "- Grep for https://github.com/OpenCoven/coven-github-webhook/blob/main/src/adapter.ts and tests_run[].output_summary.", "- `README.md:12`", "- `README.md:12-14`", + "- `Dockerfile:7`", + "- `report:2026`", + "- `x:foo`", "- `docs/My Guide.md`", "- `Dockerfile`", "- `docs)/odd(file).ts`", "- ``docs/a`b.ts``", + "- `docs\\guide.ts`", + "- `docs/report:2026`", "- `tests_run[].output_summary`", "- `pnpm test`", "- [`src/already.ts`](https://example.com/already)", + "- [see `src/mixed.ts`](https://example.com/mixed)", + "- [`src/reference.ts`][source]", + "- [`src/collapsed.ts`][]", + "- [`src/shortcut.ts`]", + "- ![see `src/image.ts`](https://example.com/image.png)", + "- [see src/bare-reference.ts][bare-source]", + "- [title](https://example.com/path \"see `src/title.ts`\")", + "- [see ` src/unmatched-label.ts](https://example.com/unmatched-label)", + "- attribute", + "[source]: src/reference-target.ts", + "[bare-source]: src/bare-reference.ts", + "[escaped\\]source]: src/escaped-definition.ts", + "[multiline-source]:", + " src/multiline-definition.ts", + " \"Optional title\"", + "- Plain https://example.com/view?file=src/query.ts#L2", + "- Other URLs ftp://example.com/?file=src/ftp.ts file:///tmp/?file=src/file-url.ts mailto:user@example.com?body=src/mail.ts", + "- Short URI x:foo?path=src/short-scheme.ts and localhost/docs/local.ts", + "- Hostnames www.example.com/docs/www.ts, example.com/docs/domain.ts, www.example.com/?file=src/www-query.ts, example.com/?file=src/domain-query.ts, 192.168.1.2/?file=src/ip-query.ts, [::1]/?file=src/ipv6-query.ts, and //example.com/?file=src/protocol-relative.ts", + "- Assigned URLs url=www.example.com/?file=src/assigned-url.ts and href=https://example.com/?file=src/assigned-href.ts", + "- Emphasized URLs **https://example.com/?file=src/emphasis-bold.ts**, _https://example.com/?file=src/emphasis-underscore.ts_, ~~https://example.com/?file=src/emphasis-strike.ts~~, and **www.example.com/?file=src/emphasis-www.ts**", + "- Emphasized file _src/lib/server/skills-directory.ts_", + "- Escaped backtick \\` [see `src/escaped-prefix.ts`](https://example.com/escaped-prefix)", + "- Backticks in a destination [x](dest`) src/backtick-after-link.ts `)", + "- Nested destination [x](foo`(`bar)q=src/backtick-in-link.ts)", + "- Nested leading destination [x](() \"title ) q=src/nested-title.ts\")", + "- Empty destination title [x]( \"title ) q=src/empty-title.ts\")", + "- Escaped leading destination [x](\\( \"title ) q=src/escaped-title.ts\")", + "- Sentence final src/sentence.ts.", + "[unused-reference]:", + "```ts", + "`src/not-linked-after-reference.ts`", + "```", + "- After reference fence src/after-reference.ts", "", "```ts", "`src/not-linked-inside-fence.ts`", @@ -1307,6 +1346,8 @@ test("publication body links screenshot-style file mentions to GitHub blobs", () "````ts", "`src/not-linked-inside-long-fence.ts`", "````", + " `src/not-linked-inside-indent.ts`", + "\t`src/not-linked-inside-tab.ts`", ].join("\n"), pr_body: "Follow-up evidence is in `src/follow-up.ts`.", review: { @@ -1315,8 +1356,14 @@ test("publication body links screenshot-style file mentions to GitHub blobs", () "README.md", "docs/My Guide.md", "Dockerfile", + "report:2026", + "x:foo", "docs)/odd(file).ts", "docs/a`b.ts", + "docs\\guide.ts", + "docs/report:2026", + "`leading.ts", + "trailing.ts`", "src/follow-up.ts", ], }, @@ -1358,6 +1405,9 @@ test("publication body links screenshot-style file mentions to GitHub blobs", () body, /\[`README\.md:12-14`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/README\.md#L12-L14\)/, ); + assert.match(body, /\[`Dockerfile:7`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/Dockerfile#L7\)/); + assert.match(body, /\[`report:2026`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/report%3A2026\)/); + assert.match(body, /\[`x:foo`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/x%3Afoo\)/); assert.match(body, /- `pnpm test`/); assert.match(body, /- `tests_run\[\]\.output_summary`/); assert.doesNotMatch(body, /\[`tests_run\[\]\.output_summary`\]/); @@ -1366,14 +1416,102 @@ test("publication body links screenshot-style file mentions to GitHub blobs", () assert.match(body, /\[`Dockerfile`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/Dockerfile\)/); assert.match(body, /docs%29\/odd%28file%29\.ts/); assert.match(body, /\[``docs\/a`b\.ts``\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/docs\/a%60b\.ts\)/); + assert.match(body, /blob\/abc123def456\/docs%5Cguide\.ts\)/); + assert.match(body, /blob\/abc123def456\/docs\/report%3A2026\)/); + assert.doesNotMatch(body, /docs\/report#L2026/); + assert.ok(body.includes("[`` `leading.ts ``](https://github.com/OpenCoven/coven-github-webhook/blob/abc123def456/%60leading.ts)")); + assert.ok(body.includes("[`` trailing.ts` ``](https://github.com/OpenCoven/coven-github-webhook/blob/abc123def456/trailing.ts%60)")); assert.match(body, /Follow-up evidence is in \[`src\/follow-up\.ts`\]/); assert.match(body, /\[`src\/already\.ts`\]\(https:\/\/example\.com\/already\)/); + assert.ok(body.includes("[see `src/mixed.ts`](https://example.com/mixed)")); + assert.ok(body.includes("[`src/reference.ts`][source]")); + assert.ok(body.includes("[`src/collapsed.ts`][]")); + assert.ok(body.includes("[`src/shortcut.ts`]")); + assert.ok(body.includes("![see `src/image.ts`](https://example.com/image.png)")); + assert.ok(body.includes("[see src/bare-reference.ts][bare-source]")); + assert.ok(body.includes("[title](https://example.com/path \"see `src/title.ts`\")")); + assert.ok(body.includes("[see ` src/unmatched-label.ts](https://example.com/unmatched-label)")); + assert.ok(body.includes("attribute")); + assert.ok(body.includes("[source]: src/reference-target.ts")); + assert.ok(body.includes("[bare-source]: src/bare-reference.ts")); + assert.ok(body.includes("[escaped\\]source]: src/escaped-definition.ts")); + assert.ok(body.includes("[multiline-source]:\n src/multiline-definition.ts\n \"Optional title\"")); + assert.ok(body.includes("Plain https://example.com/view?file=src/query.ts#L2")); + assert.ok(body.includes("Other URLs ftp://example.com/?file=src/ftp.ts file:///tmp/?file=src/file-url.ts mailto:user@example.com?body=src/mail.ts")); + assert.ok(body.includes("Short URI x:foo?path=src/short-scheme.ts and localhost/docs/local.ts")); + assert.ok(body.includes("Hostnames www.example.com/docs/www.ts, example.com/docs/domain.ts, www.example.com/?file=src/www-query.ts, example.com/?file=src/domain-query.ts, 192.168.1.2/?file=src/ip-query.ts, [::1]/?file=src/ipv6-query.ts, and //example.com/?file=src/protocol-relative.ts")); + assert.ok(body.includes("Assigned URLs url=www.example.com/?file=src/assigned-url.ts and href=https://example.com/?file=src/assigned-href.ts")); + assert.ok(body.includes("Emphasized URLs **https://example.com/?file=src/emphasis-bold.ts**, _https://example.com/?file=src/emphasis-underscore.ts_, ~~https://example.com/?file=src/emphasis-strike.ts~~, and **www.example.com/?file=src/emphasis-www.ts**")); + assert.match(body, /Emphasized file _\[`src\/lib\/server\/skills-directory\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/src\/lib\/server\/skills-directory\.ts\)_/); + assert.ok(body.includes("Escaped backtick \\` [see `src/escaped-prefix.ts`](https://example.com/escaped-prefix)")); + assert.match(body, /Backticks in a destination \[x\]\(dest`\) \[`src\/backtick-after-link\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/src\/backtick-after-link\.ts\) `\)/); + assert.ok(body.includes("Nested destination [x](foo`(`bar)q=src/backtick-in-link.ts)")); + assert.ok(body.includes("Nested leading destination [x](() \"title ) q=src/nested-title.ts\")")); + assert.match(body, /Empty destination title \[x\]\( "title \) q=\[`src\/empty-title\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/src\/empty-title\.ts\)"\)/); + assert.ok(body.includes("Escaped leading destination [x](\\( \"title ) q=src/escaped-title.ts\")")); + assert.match(body, /Sentence final \[`src\/sentence\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/abc123def456\/src\/sentence\.ts\)\./); + assert.match(body, /```ts\n`src\/not-linked-after-reference\.ts`\n```/); + assert.doesNotMatch(body, /\[`src\/not-linked-after-reference\.ts`\]/); + assert.match(body, /After reference fence \[`src\/after-reference\.ts`\]/); + assert.doesNotMatch(body, /blob\/abc123def456\/src\/(?:mixed|reference|collapsed|shortcut|image|bare-reference|title|unmatched-label|attribute|reference-target|escaped-definition|multiline-definition|query|ftp|file-url|mail|short-scheme|local|www|domain|www-query|domain-query|ip-query|ipv6-query|protocol-relative|assigned-url|assigned-href|emphasis-bold|emphasis-underscore|emphasis-strike|emphasis-www|escaped-prefix|backtick-in-link|nested-title|escaped-title|not-linked-after-reference)\.ts/); assert.match(body, /`src\/not-linked-inside-fence\.ts`/); assert.doesNotMatch(body, /\[`src\/not-linked-inside-fence\.ts`\]/); assert.match(body, /`src\/not-linked-inside-tilde-fence\.ts`/); assert.doesNotMatch(body, /\[`src\/not-linked-inside-tilde-fence\.ts`\]/); assert.match(body, /`src\/not-linked-inside-long-fence\.ts`/); assert.doesNotMatch(body, /\[`src\/not-linked-inside-long-fence\.ts`\]/); + assert.match(body, / `src\/not-linked-inside-indent\.ts`/); + assert.doesNotMatch(body, /\[`src\/not-linked-inside-indent\.ts`\]/); + assert.match(body, /\t`src\/not-linked-inside-tab\.ts`/); + assert.doesNotMatch(body, /\[`src\/not-linked-inside-tab\.ts`\]/); +}); + +test("publication body handles a bounded code span with many backtick runs", () => { + const rawCode = `${"a`".repeat(150_000)}x.ts`; + assert.doesNotThrow(() => publicationCommentBody( + { + task_id: "task-many-backticks", + repository: "OpenCoven/coven-github-webhook", + default_branch: "main", + review_evidence: {head_sha: "abc123def456"}, + }, + { + status: "success", + summary: `\`\`${rawCode}\`\``, + files_changed: [], + commits: [], + }, + )); + + const delimiterHeavy = "a~".repeat(250_000); + const started = Date.now(); + publicationCommentBody( + {task_id: "task-many-tildes", repository: "OpenCoven/example", default_branch: "main"}, + {status: "success", summary: delimiterHeavy, files_changed: [], commits: []}, + ); + assert.ok(Date.now() - started < 2_000, "delimiter-heavy publication rendering should remain linear"); + + const bracketStarted = Date.now(); + publicationCommentBody( + {task_id: "task-many-brackets", repository: "OpenCoven/example", default_branch: "main"}, + {status: "success", summary: `${"[".repeat(100_000)}]`, files_changed: [], commits: []}, + ); + assert.ok(Date.now() - bracketStarted < 2_000, "unbalanced bracket publication rendering should remain linear"); + + const distinctRuns = Array.from({length: 1_000}, (_, index) => `${"`".repeat(index + 1)}a`).join(""); + const distinctStarted = Date.now(); + publicationCommentBody( + {task_id: "task-distinct-backticks", repository: "OpenCoven/example", default_branch: "main"}, + {status: "success", summary: distinctRuns, files_changed: [], commits: []}, + ); + assert.ok(Date.now() - distinctStarted < 2_000, "distinct unmatched backtick runs should be indexed once"); + + const malformedStarted = Date.now(); + publicationCommentBody( + {task_id: "task-malformed-markdown", repository: "OpenCoven/example", default_branch: "main"}, + {status: "success", summary: `${" { @@ -1385,12 +1523,14 @@ test("publication body links structured review file lists and findings", () => { review_evidence: { head_sha: "feedface", base_sha: "baseface", - changed_files: ["src/app.ts", "src/deleted.ts"], + merge_base_sha: "mergeface", + changed_files: ["src/app.ts", "src/deleted.ts", "src/new.ts"], changed_file_details: [ {path: "src/app.ts", status: "modified"}, {path: "src/deleted.ts", status: "removed"}, + {path: "src/new.ts", previous_path: "src/old.ts", status: "renamed"}, ], - changed_file_count: 2, + changed_file_count: 3, }, }, { @@ -1399,8 +1539,8 @@ test("publication body links structured review file lists and findings", () => { review: { mode: "review", evidence_status: "complete", - reviewed_files: ["src/app.ts", "src/deleted.ts"], - supporting_files: ["tests/app.test.ts"], + reviewed_files: ["src/app.ts", "src/deleted.ts", "src/new.ts"], + supporting_files: ["tests/app.test.ts", "src/old.ts"], findings: [ { severity: "medium", @@ -1426,7 +1566,9 @@ test("publication body links structured review file lists and findings", () => { ); assert.match(body, /\[`src\/app\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/app\.ts\)/); - assert.match(body, /\[`src\/deleted\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/baseface\/src\/deleted\.ts\)/); + assert.match(body, /\[`src\/deleted\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/mergeface\/src\/deleted\.ts\)/); + assert.match(body, /\[`src\/new\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/new\.ts\)/); + assert.match(body, /\[`src\/old\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/mergeface\/src\/old\.ts\)/); assert.match(body, /\[`tests\/app\.test\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/tests\/app\.test\.ts\)/); assert.match(body, /\[`src\/app\.ts:7`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/app\.ts#L7\)/); assert.match(body, /`Read` \[`src\/app\.ts`\]\(https:\/\/github\.com\/OpenCoven\/coven-github-webhook\/blob\/feedface\/src\/app\.ts\): `passed`/); @@ -1434,6 +1576,36 @@ test("publication body links structured review file lists and findings", () => { assert.match(body, /- `npm test`: `passed`/); }); +test("file links prefer the head for rename-and-readd and copied source paths", () => { + const body = publicationCommentBody( + { + task_id: "task-revision-selection", + repository: "OpenCoven/example", + review_evidence: { + head_sha: "headface", + base_sha: "baseface", + merge_base_sha: "mergeface", + changed_files: ["src/new.ts", "src/old.ts", "src/copied.ts", "src/newer.ts"], + changed_file_details: [ + {path: "src/new.ts", previous_path: "src/old.ts", status: "renamed"}, + {path: "src/old.ts", status: "added"}, + {path: "src/copied.ts", previous_path: "src/source.ts", status: "copied"}, + {path: "src/newer.ts", previous_path: "src/legacy.ts", status: "renamed"}, + ], + }, + }, + { + status: "success", + summary: "Done.", + review: {supporting_files: ["src/old.ts", "src/source.ts", "src/legacy.ts"]}, + }, + ); + + assert.match(body, /blob\/headface\/src\/old\.ts/); + assert.match(body, /blob\/headface\/src\/source\.ts/); + assert.match(body, /blob\/mergeface\/src\/legacy\.ts/); +}); + test("routing enforces exact enabled event actions before spending compute", async () => { const secret = "enabled-trigger-secret"; const stateDir = tempStateDir(); @@ -2370,11 +2542,14 @@ test("near-limit inline fallback preserves a complete trusted marker and remains const stateDir = tempStateDir(); const config = testConfig(stateDir); const task = reviewTask("near-limit-inline-fallback"); + const maximumBaseRef = "b".repeat(128); + (task.review_evidence as JsonObject).base_sha = maximumBaseRef; + task.created_at = "2026-07-14T12:00:00.000Z"; prepareReviewWorkspace(config, task); const result = completeReview([{ severity: "high", file: "src/app.ts", line: 12, title: "Finding", body: "Body", recommendation: null, }]); - result.summary = "x".repeat(59_500); + result.summary = Array.from({length: 900}, () => "`src/app.ts`").join(" "); const resultPath = join(stateDir, "near-limit-result.json"); writeFileSync(resultPath, JSON.stringify(result)); let publishedBody = ""; @@ -2382,7 +2557,7 @@ test("near-limit inline fallback preserves a complete trusted marker and remains let reviewPosts = 0; let submissions = 0; await withGithubApiMock((url, init) => { - if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: maximumBaseRef}}; if (init.method === "GET" && /\/pulls\/7\/reviews\/4021$/.test(url)) { return {id: 4021, state: reviewState, commit_id: "abc123", body: publishedBody, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-4021", user: covencatBot()}; } @@ -2419,7 +2594,8 @@ test("near-limit inline fallback preserves a complete trusted marker and remains assert.equal(submissions, 1); assert.ok(publishedBody.length <= 60_000); assert.match(publishedBody, /Inline publication was unavailable/); - assert.match(publishedBody, /\n\n$/); + assert.doesNotMatch(publishedBody.slice(0, publishedBody.indexOf("\\n\\n\\n$`)); assert.equal(task.publication_state, "publication_skipped_duplicate", String(task.publication_error || "")); }); @@ -3597,6 +3773,21 @@ test("redacts credentials and passes only allowlisted ambient environment keys", ].join("\n"); const redacted = redactTokenish(secretText); assert.doesNotMatch(redacted, /1234567890|topsecret|password|private-data|eyJabc|reviewer@example\.com|reviewer \(/); + const longPlainText = "x".repeat(80_000); + const redactionStarted = Date.now(); + assert.equal(redactTokenish(longPlainText), longPlainText); + assert.ok(Date.now() - redactionStarted < 1_000, "redaction should remain linear on long non-email text"); + const diagnosticStarted = Date.now(); + assert.equal(runtimeDiagnostic({ + args: ["coven-code"], returncode: 1, stdout: "", stderr: longPlainText, + signal: null, timed_out: false, duration_ms: 1, stdout_truncated: false, + stderr_truncated: false, output_limit_bytes: 100_000, spawn_error: "", + }), longPlainText.slice(0, 1_000)); + assert.ok(Date.now() - diagnosticStarted < 1_000, "runtime diagnostics should reuse linear credential redaction"); + const unmatchedPrivateKeyHeaders = "-----BEGIN PRIVATE KEY-----\n".repeat(12_000); + const privateKeyStarted = Date.now(); + assert.equal(redactTokenish(unmatchedPrivateKeyHeaders), unmatchedPrivateKeyHeaders); + assert.ok(Date.now() - privateKeyStarted < 1_000, "unterminated private-key headers should be scanned once"); const artifact = redactedCommandResult({ args: ["git", "-c", "user.email=covencat[bot]@users.noreply.github.com", "https://x-access-token:ghs_1234567890@github.com/OpenCoven/example.git"], returncode: 1, @@ -3736,6 +3927,185 @@ test("keeps idempotency marker after truncating and redacts issue publication te assert.ok(body.length < 60_000); }); +test("truncates publication output only between complete generated Markdown links", async () => { + const stateDir = tempStateDir(); + const task: JsonObject = { + task_id: "long-linked-issue", + repository: "OpenCoven/example", + publication: {mode: "comment"}, + task: {issue_number: 13}, + review_evidence: { + head_sha: "headface", + changed_files: ["src/app.ts"], + changed_file_details: [{path: "src/app.ts", status: "modified"}], + }, + }; + const resultPath = join(stateDir, "result.json"); + const repeatedLinks = Array.from({length: 6_000}, () => "`src/app.ts`").join(" "); + writeFileSync(resultPath, JSON.stringify({status: "success", summary: repeatedLinks, pr_body: "", files_changed: [], commits: []})); + let body = ""; + await withGithubApiMock((_url, init) => { + if (init.method === "GET") return []; + body = String((JSON.parse(String(init.body)) as JsonObject).body); + return {id: 410, html_url: "https://github.com/OpenCoven/example/issues/13#issuecomment-410"}; + }, async () => publishResultIfConfigured(testConfig(stateDir), task, resultPath, "token")); + + const markerStart = body.indexOf("$/); +}); + +test("truncates unmatched Markdown brackets without repeatedly rescanning the body", async () => { + const stateDir = tempStateDir(); + const task: JsonObject = { + task_id: "unmatched-bracket-issue", + repository: "OpenCoven/example", + publication: {mode: "comment"}, + task: {issue_number: 14}, + }; + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify({status: "success", summary: `[${"`".repeat(58_000)}${"x".repeat(7_000)}`, files_changed: [], commits: []})); + let body = ""; + const started = Date.now(); + await withGithubApiMock((_url, init) => { + if (init.method === "GET") return []; + body = String((JSON.parse(String(init.body)) as JsonObject).body); + return {id: 411, html_url: "https://github.com/OpenCoven/example/issues/14#issuecomment-411"}; + }, async () => publishResultIfConfigured(testConfig(stateDir), task, resultPath, "token")); + + assert.ok(Date.now() - started < 2_000, "publication should remain linear for unmatched Markdown labels"); + assert.ok(body.length > 59_000, "an unmatched literal delimiter should not discard the available publication budget"); + assert.match(body, /Review output truncated/); + assert.match(body, /$/); +}); + +test("keeps an angle-bracket link destination whole at the publication limit", async () => { + const stateDir = tempStateDir(); + const task: JsonObject = { + task_id: "angle-link-limit-issue", + repository: "OpenCoven/example", + publication: {mode: "comment"}, + task: {issue_number: 15}, + }; + const resultPath = join(stateDir, "result.json"); + const angleLink = "[source]()"; + writeFileSync(resultPath, JSON.stringify({ + status: "success", + summary: `${"x".repeat(59_580)}${angleLink}${"z".repeat(1_000)}`, + files_changed: [], + commits: [], + })); + let body = ""; + await withGithubApiMock((_url, init) => { + if (init.method === "GET") return []; + body = String((JSON.parse(String(init.body)) as JsonObject).body); + return {id: 412, html_url: "https://github.com/OpenCoven/example/issues/15#issuecomment-412"}; + }, async () => publishResultIfConfigured(testConfig(stateDir), task, resultPath, "token")); + + assert.match(body, /Review output truncated/); + assert.ok(!body.includes("[source]") || body.includes(angleLink)); + assert.doesNotMatch(body.slice(0, body.indexOf("$/); +}); + +test("truncates before complete backtick and tilde fenced blocks", async () => { + for (const [name, opener, closer] of [ + ["backtick", "````ts", "`````"], + ["tilde", "~~~~ts", "~~~~~"], + ] as const) { + const stateDir = tempStateDir(); + const task: JsonObject = { + task_id: `${name}-fence-limit`, + repository: "OpenCoven/example", + publication: {mode: "comment"}, + task: {issue_number: 17}, + }; + const resultPath = join(stateDir, "result.json"); + const fencedBlock = `${opener}\n[label\n\`\`\`\n]\n${"y".repeat(1_000)}\n${closer}`; + writeFileSync(resultPath, JSON.stringify({ + status: "success", + summary: `${"x".repeat(59_550)}\n${fencedBlock}\ntail`, + files_changed: [], + commits: [], + })); + let body = ""; + await withGithubApiMock((_url, init) => { + if (init.method === "GET") return []; + body = String((JSON.parse(String(init.body)) as JsonObject).body); + return {id: 414, html_url: "https://github.com/OpenCoven/example/issues/17#issuecomment-414"}; + }, async () => publishResultIfConfigured(testConfig(stateDir), task, resultPath, "token")); + + const publishedMarkdown = body.slice(0, body.indexOf("$/); + } +}); + +test("truncates repeated incomplete inline links without rescanning the suffix", async () => { + const stateDir = tempStateDir(); + const task: JsonObject = { + task_id: "incomplete-inline-links-limit", + repository: "OpenCoven/example", + publication: {mode: "comment"}, + task: {issue_number: 18}, + }; + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify({ + status: "success", + summary: `${"[x](".repeat(10_000)})${"z".repeat(60_000)}`, + files_changed: [], + commits: [], + })); + let body = ""; + const started = Date.now(); + await withGithubApiMock((_url, init) => { + if (init.method === "GET") return []; + body = String((JSON.parse(String(init.body)) as JsonObject).body); + return {id: 415, html_url: "https://github.com/OpenCoven/example/issues/18#issuecomment-415"}; + }, async () => publishResultIfConfigured(testConfig(stateDir), task, resultPath, "token")); + + assert.ok(Date.now() - started < 2_000, "incomplete inline links should be indexed once during truncation"); + assert.ok(body.length > 59_000, "malformed link syntax should not discard the available publication budget"); + assert.match(body, /Review output truncated/); + assert.match(body, /$/); +}); + test("concurrent signed deliveries initialize one task without overwriting the winner", async () => { const secret = "concurrent-delivery-secret"; const stateDir = tempStateDir();