diff --git a/prompt.md b/prompt.md index ec16792..b0df847 100644 --- a/prompt.md +++ b/prompt.md @@ -39,13 +39,24 @@ never ask `@bot` to make them. For UI changes, check that the result is aligned, clean, and pixel-perfect, and that included screenshots prove the intended result was achieved. -The review must read at a glance: everything outside `
` blocks totals -under 512 bytes. Only core information stays visible: the verdict, the summary -line, and the section headings. Anything verbose goes into a `
` block -whose `` is a few words, such as the `file:line` and title of an issue -with the what, why, and how inside; the same for each strength, each -recommendation, the reasoning, and any compliance notes. Details blocks are -top-level, never inside a list item, because Gitea breaks them there. +Every finding that belongs to one line of the diff goes on that line instead of +into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry +`{"path": "", "line": , "side": "new" | +"old", "body": ""}`. +`side` is `new` for a line in the head file and `old` for one only in the base +file; `line` is that file's own line number, and it must be a line the diff +touches, or Gitea refuses the anchor. Write the file only when there is +something to anchor, and keep each body to the what, the why, and the how, with +no `file:line` prefix; the line carries that. + +The review body must read at a glance: everything outside `
` blocks +totals under 512 bytes. Only core information stays visible: the verdict, the +summary line, and the section headings. Anything verbose goes into a `
` +block whose `` is a few words, such as the title of an issue with the +what, why, and how inside; the same for each strength, each recommendation, the +reasoning, and any compliance notes. A finding you anchored belongs there only +as its title, since its detail is on the line. Details blocks are top-level, +never inside a list item, because Gitea breaks them there. For an `issue_comment` or `pull_request_review_comment` event, treat the `body` in the triggering comment payload below as the user's exact instruction. diff --git a/run.ts b/run.ts index 6c67a3b..21d4672 100644 --- a/run.ts +++ b/run.ts @@ -64,12 +64,50 @@ async function postComment(body: string): Promise { // picks up narration while a file's first line is written on purpose. That line // is the verdict, matched whole; anything unexpected only comments, never // approves. The mark in front is added here, so it is never part of the match. -const REVIEW_PATH = `${await Deno.makeTempDir()}/review.md`; +const REVIEW_DIR = await Deno.makeTempDir(); +const REVIEW_PATH = `${REVIEW_DIR}/review.md`; const VERDICTS: Record = { Approved: ["APPROVED", "✅"], "Changes requested": ["REQUEST_CHANGES", "🛑"], }; +// A finding about one line is posted on that line of the diff rather than as +// `file:line` prose in the body. Those anchors come as JSON, so the file and +// line are structured instead of parsed back out of English; a malformed entry +// fails the run, because a silently dropped finding is worse than a red run. +const ANCHORS_PATH = `${REVIEW_DIR}/anchors.json`; +type Anchor = { path: string; line: number; side: "new" | "old"; body: string }; + +function parseAnchors(text: string): Anchor[] { + const entries: unknown = JSON.parse(text); + if (!Array.isArray(entries)) throw new Error(`${ANCHORS_PATH}: not an array`); + return entries.map((entry: unknown, index) => { + const at = `${ANCHORS_PATH}[${index}]`; + if (typeof entry !== "object" || entry === null) { + throw new Error(`${at}: not an object`); + } + const { path, line, side = "new", body } = entry as Record; + if (typeof path !== "string" || path === "") { + throw new Error(`${at}.path: expected a repository path`); + } + if (typeof line !== "number" || !Number.isInteger(line) || line < 1) { + throw new Error(`${at}.line: expected a line number`); + } + if (side !== "new" && side !== "old") { + throw new Error(`${at}.side: expected "new" or "old"`); + } + if (typeof body !== "string" || body.trim() === "") { + throw new Error(`${at}.body: expected the finding`); + } + return { path, line, side, body }; + }); +} + +async function readAnchors(): Promise { + const written = await Deno.readTextFile(ANCHORS_PATH).catch(() => null); + return written === null ? [] : parseAnchors(written); +} + async function postResult(body: string): Promise { const review = await Deno.readTextFile(REVIEW_PATH).catch(() => null); if (review === null) { @@ -80,9 +118,25 @@ async function postResult(body: string): Promise { } const [verdict, ...rest] = review.split("\n"); const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"]; - await gitea(REVIEWER_TOKEN, `repos/${REPO}/pulls/${INDEX}/reviews`, { - body: stripAnsi([`${mark} ${verdict.trim()}`, ...rest].join("\n")), - event, + const post = (anchors: Anchor[]) => + gitea(REVIEWER_TOKEN, `repos/${REPO}/pulls/${INDEX}/reviews`, { + body: stripAnsi([`${mark} ${verdict.trim()}`, ...rest].join("\n")), + event, + comments: anchors.map(({ path, line, side, body }) => ({ + path, + body: stripAnsi(body), + new_position: side === "new" ? line : 0, + old_position: side === "old" ? line : 0, + })), + }); + const anchors = await readAnchors(); + // Gitea rejects the whole review when an anchor names a line outside the + // diff, and a verdict that never lands blocks the pull request, so the body + // goes up alone rather than not at all. + await post(anchors).catch(async (error: Error) => { + if (anchors.length === 0) throw error; + console.error(`inline comments rejected: ${error.message}`); + await post([]); }); } @@ -128,6 +182,7 @@ async function renderPrompt(): Promise { GITEA_REPOSITORY: REPO, ISSUE_INDEX: INDEX, REVIEW_PATH, + ANCHORS_PATH, }; const template = await Deno.readTextFile( new URL("prompt.md", import.meta.url),