Skip to content

feat: PR description annotations with paragraph-level targeting - #55

Merged
sam-phinizy merged 3 commits into
mainfrom
feat/pr-description-annotations
Apr 2, 2026
Merged

feat: PR description annotations with paragraph-level targeting#55
sam-phinizy merged 3 commits into
mainfrom
feat/pr-description-annotations

Conversation

@sam-phinizy

Copy link
Copy Markdown
Contributor

Summary

Closes #53

Allow reviewers to annotate the PR description using the same gutter dot / collapsed bubble system used for code annotations.

  • Virtual file approach: PR body is treated as __redpen__/pr-body.md, reusing the existing annotation store, sidecar system, and bubble components
  • splitMarkdownBlocks utility: Parses markdown into annotatable blocks (headings, paragraphs, checklist items, code blocks, etc.) with sequential line numbers
  • PrBodyAnnotatable component: Renders PR body with hover-to-annotate gutter, numbered amber dots on annotated blocks, collapsed/expanded AnnotationBubble components
  • Clean-until-hover: No annotation UI visible by default — gutter + button appears on paragraph hover
  • Review summary bundling: PR body annotations are auto-formatted into the review summary message when submitting (formatPrBodyAnnotations)

Test plan

  • Open a GitHub PR review and navigate to the PR tab
  • Verify the PR description renders as clean markdown (no gutter visible)
  • Hover over a paragraph — verify + button appears in left gutter with subtle highlight
  • Click + — verify annotation popover opens, create an annotation
  • Verify numbered amber dot appears on the annotated paragraph
  • Verify collapsed bubble renders below the paragraph
  • Click the dot or bubble — verify it expands with full thread
  • Add a second annotation on a different paragraph — verify sequential numbering
  • Hover over a checklist item — verify it's individually targetable
  • Open Submit Review dialog — verify PR body annotations are pre-formatted in the message textarea
  • Delete an annotation — verify dot and bubble disappear

🤖 Generated with Claude Code

sam-phinizy and others added 2 commits March 30, 2026 10:06
- Create splitMarkdownBlocks utility for parsing PR body into
  annotatable blocks with line numbers
- Create PrBodyAnnotatable component with hover gutter, numbered
  dots, annotation bubbles, and popover for creating annotations
- Wire into PullRequestView replacing raw HTML rendering
- Uses virtual file path (__redpen__/pr-body.md) to reuse
  existing annotation store and sidecar infrastructure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Create formatPrBodyAnnotations() that collects annotations from
  the virtual PR body file and formats them as markdown
- Pre-populate submit modal textarea with formatted annotations
  when opening the submit review dialog

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 30, 2026 14:09
The Rust backend reads the file from disk to build the annotation
anchor (line content, surrounding lines). The virtual file path
didn't exist on disk, causing create_annotation to fail with a
file-not-found error. Now we write the PR body markdown to
__redpen__/pr-body.md on mount, creating the directory if needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for annotating pull request descriptions (PR body) using the existing annotation/bubble system by treating the PR body as a “virtual file” and bundling those annotations into the submit-review summary.

Changes:

  • Introduces a markdown block parser (splitMarkdownBlocks) and preview helper for paragraph-level anchoring.
  • Adds a PrBodyAnnotatable component to render PR descriptions with gutter add buttons and inline annotation bubbles.
  • Pre-fills the Submit Review modal message with formatted PR-body annotations via formatPrBodyAnnotations.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/lib/review/prBodySummary.ts Formats PR-body annotations into a markdown summary for the submit-review message.
src/lib/markdown/blocks.ts Splits PR markdown into annotatable blocks and generates short previews.
src/components/review-header/ReviewSubmitControl.svelte Prefills the submit-review message with PR-body annotation summary.
src/components/PullRequestView.svelte Switches PR body rendering to PrBodyAnnotatable.
src/components/PrBodyAnnotatable.svelte New annotatable PR-body renderer with gutter + bubbles + popover.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +12 to +16
getAnnotationsState,
} from "$lib/stores/annotations.svelte";
import { updateChoices } from "$lib/stores/annotations.svelte";
import AnnotationPopover from "./AnnotationPopover.svelte";
import AnnotationBubble from "./AnnotationBubble.svelte";

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getAnnotationsState is imported but never used. With noUnusedLocals: true in tsconfig.json, this should fail the build; remove the import (or use it) and consider consolidating the two imports from the same module into a single import statement.

Suggested change
getAnnotationsState,
} from "$lib/stores/annotations.svelte";
import { updateChoices } from "$lib/stores/annotations.svelte";
import AnnotationPopover from "./AnnotationPopover.svelte";
import AnnotationBubble from "./AnnotationBubble.svelte";
updateChoices,
} from "$lib/stores/annotations.svelte";
import AnnotationPopover from "./AnnotationPopover.svelte";
import AnnotationBubble from "./AnnotationBubble.svelte";
import AnnotationBubble from "./AnnotationBubble.svelte";

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +44
(async () => {
try {
await mkdir(dirPath, { recursive: true });
await writeTextFile(virtualFilePath, body);

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

annotationCount is computed but never used. With noUnusedLocals: true, this unused reactive declaration should cause a type-check failure; remove it or wire it into the UI.

Copilot uses AI. Check for mistakes.
Comment on lines +25 to +41
async function openSubmitModal(action: "comment" | "approve" | "requestChanges") {
showSubmitMenu = false;
submitModalAction = action;
submitModalMessage = "";
submitModalStatus = "editing";
submitModalError = null;
submitModalResult = null;

const githubReview = getGitHubReviewState();
const session = githubReview.activeSession;
if (session) {
submitModalMessage = await formatPrBodyAnnotations(
session.worktreePath,
session.body ?? "",
);
} else {
submitModalMessage = "";
}

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openSubmitModal no longer clears submitModalMessage before awaiting formatPrBodyAnnotations(...), so the modal can briefly show the previous message (and if formatting fails, may leave stale content). Set submitModalMessage = "" before the await (or introduce an explicit loading state) to avoid stale UI.

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +18
let sidecar;
try {
sidecar = await getAnnotations(virtualPath);
} catch {
return "";

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sidecar is declared without a type or initializer (let sidecar;), which becomes an implicit any under strict/noImplicitAny and should fail type-checking. Type it explicitly (e.g., SidecarFile | null) and initialize it (or inline the await getAnnotations(...) into the try block).

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +58
lines.push(`> ${root.body}`);

const replies = replyMap.get(root.id) ?? [];
for (const reply of replies) {
lines.push(`> > \u21B3 ${reply.author}: ${reply.body}`);

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The markdown quote formatting breaks for multi-line annotation bodies: lines.push("> ${root.body}") (and the reply line) only prefixes the first line with >, so subsequent lines render outside the quote block. Prefix each line of root.body/reply.body with the appropriate quote marker before joining.

Suggested change
lines.push(`> ${root.body}`);
const replies = replyMap.get(root.id) ?? [];
for (const reply of replies) {
lines.push(`> > \u21B3 ${reply.author}: ${reply.body}`);
const rootBodyLines = root.body.split(/\r?\n/);
for (const bodyLine of rootBodyLines) {
lines.push(`> ${bodyLine}`);
}
const replies = replyMap.get(root.id) ?? [];
for (const reply of replies) {
const replyBodyLines = reply.body.split(/\r?\n/);
replyBodyLines.forEach((replyLine, index) => {
const content = index === 0
? `\u21B3 ${reply.author}: ${replyLine}`
: replyLine;
lines.push(`> > ${content}`);
});

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +41
const replyMap = new Map<string, Annotation[]>();
for (const ann of sidecar.annotations) {
if (ann.replyTo) {
const group = replyMap.get(ann.replyTo) ?? [];
group.push(ann);
replyMap.set(ann.replyTo, group);
}
}

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replies are emitted in whatever order sidecar.annotations happens to be in. For stable and chronologically correct summaries, sort each reply group (e.g., by createdAt like sortedAnnotations() does in src/lib/stores/annotations.svelte.ts:69-86) before appending them to lines.

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +129
export function splitMarkdownBlocks(body: string): MarkdownBlock[] {
const lines = body.split("\n");
const blocks: MarkdownBlock[] = [];
let i = 0;

while (i < lines.length) {
const line = lines[i];

// Skip blank lines
if (line.trim() === "") {
i++;
continue;
}

// Skip separators
if (SEPARATOR_RE.test(line.trim())) {
i++;
continue;
}

// Code block (fenced)
if (CODE_FENCE_RE.test(line.trim())) {
const startLine = i;
const collected = [line];
i++;
while (i < lines.length) {
collected.push(lines[i]);
if (i > startLine && CODE_FENCE_RE.test(lines[i].trim())) {
i++;
break;
}
i++;
}
blocks.push({
lineNumber: startLine + 1,
content: collected.join("\n"),
type: "codeBlock",
});
continue;
}

// Heading
if (HEADING_RE.test(line)) {
blocks.push({ lineNumber: i + 1, content: line, type: "heading" });
i++;
continue;
}

// Checklist item
if (CHECKLIST_RE.test(line.trim())) {
blocks.push({ lineNumber: i + 1, content: line, type: "checklist" });
i++;
continue;
}

// List item (unordered or ordered)
if (UNORDERED_RE.test(line.trim()) || ORDERED_RE.test(line.trim())) {
blocks.push({ lineNumber: i + 1, content: line, type: "listItem" });
i++;
continue;
}

// Blockquote (consume consecutive `>` lines)
if (BLOCKQUOTE_RE.test(line)) {
const startLine = i;
const collected = [line];
i++;
while (i < lines.length && BLOCKQUOTE_RE.test(lines[i])) {
collected.push(lines[i]);
i++;
}
blocks.push({
lineNumber: startLine + 1,
content: collected.join("\n"),
type: "blockquote",
});
continue;
}

// Paragraph: consume consecutive non-blank, non-special lines
{
const startLine = i;
const collected = [line];
i++;
while (
i < lines.length &&
lines[i].trim() !== "" &&
!SEPARATOR_RE.test(lines[i].trim()) &&
!HEADING_RE.test(lines[i]) &&
!CODE_FENCE_RE.test(lines[i].trim()) &&
!CHECKLIST_RE.test(lines[i].trim()) &&
!UNORDERED_RE.test(lines[i].trim()) &&
!ORDERED_RE.test(lines[i].trim()) &&
!BLOCKQUOTE_RE.test(lines[i])
) {
collected.push(lines[i]);
i++;
}
blocks.push({
lineNumber: startLine + 1,
content: collected.join("\n"),
type: "paragraph",
});
}
}

return blocks;
}

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file introduces new parsing/formatting behavior but doesn't add tests. There are existing Vitest unit tests for markdown utilities (src/lib/markdown/render.test.ts), so adding a blocks.test.ts covering common structures (headings, paragraphs, ordered/unordered lists, checklists, fenced code blocks, blockquotes, separators, and line numbers) would help prevent regressions.

Copilot uses AI. Check for mistakes.
@sam-phinizy
sam-phinizy merged commit 4881c35 into main Apr 2, 2026
7 of 15 checks passed
@sam-phinizy
sam-phinizy deleted the feat/pr-description-annotations branch April 2, 2026 00:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: PR description comment editor with paragraph-level annotations

2 participants