diff --git a/src/adapter.ts b/src/adapter.ts index 503bd2e..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), @@ -1092,15 +1143,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 +1169,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 +1185,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 +1365,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 +1379,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 +1387,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 +1399,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..28a68ce 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"; @@ -334,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, @@ -349,6 +350,178 @@ 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( + { + 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();