feat(review): PR context panel — summary, comments, and checks (#350)

* feat(review): add PR context panel with summary, comments, and checks tabs

When reviewing a GitHub PR, the ReviewPanel now shows icon tabs for
Summary (PR body, labels, linked issues), Comments (chronological
thread of comments + reviews), and Checks (CI status, merge readiness).
Tabs only appear in PR mode; local review is unchanged.

Data fetched lazily via new /api/pr-context endpoint on first tab click.
Uses DOMPurify for safe HTML rendering of GitHub markdown content.
Inline markdown renderer extended with link and bare URL support.

Closes #348

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

* fix(review): reset fetch guard on error and detect inline HTML in PR bodies

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Ramos
2026-03-19 23:14:21 -07:00
committed by GitHub
parent 0908e234e1
commit 1821943d06
12 changed files with 924 additions and 121 deletions
+9 -1
View File
@@ -6,6 +6,10 @@
"dependencies": {
"@pierre/diffs": "^1.1.0-beta.19",
"diff": "^8.0.3",
"dompurify": "^3.3.3",
},
"devDependencies": {
"@types/dompurify": "^3.2.0",
},
},
"apps/hook": {
@@ -929,6 +933,8 @@
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
@@ -1301,7 +1307,7 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
@@ -2499,6 +2505,8 @@
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"mermaid/dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+5 -1
View File
@@ -33,6 +33,10 @@
},
"dependencies": {
"@pierre/diffs": "^1.1.0-beta.19",
"diff": "^8.0.3"
"diff": "^8.0.3",
"dompurify": "^3.3.3"
},
"devDependencies": {
"@types/dompurify": "^3.2.0"
}
}
+1
View File
@@ -980,6 +980,7 @@ const ReviewApp: React.FC = () => {
width={panelResize.width}
editorAnnotations={editorAnnotations}
onDeleteEditorAnnotation={deleteEditorAnnotation}
prMetadata={prMetadata}
/>
</div>
@@ -0,0 +1,170 @@
import React, { useMemo } from 'react';
import type { PRContext, PRCheck } from '@plannotator/shared/pr-provider';
interface PRChecksTabProps {
context: PRContext;
}
const DECISION_STYLES: Record<string, { bg: string; text: string; label: string }> = {
APPROVED: { bg: 'bg-success/15', text: 'text-success', label: 'Approved' },
CHANGES_REQUESTED: { bg: 'bg-destructive/15', text: 'text-destructive', label: 'Changes Requested' },
REVIEW_REQUIRED: { bg: 'bg-yellow-500/15', text: 'text-yellow-500', label: 'Review Required' },
};
const MERGE_STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
CLEAN: { bg: 'bg-success/15', text: 'text-success', label: 'Ready to merge' },
BLOCKED: { bg: 'bg-yellow-500/15', text: 'text-yellow-500', label: 'Blocked' },
BEHIND: { bg: 'bg-yellow-500/15', text: 'text-yellow-500', label: 'Behind base branch' },
DIRTY: { bg: 'bg-destructive/15', text: 'text-destructive', label: 'Has conflicts' },
UNKNOWN: { bg: 'bg-muted', text: 'text-muted-foreground', label: 'Unknown' },
};
function CheckIcon({ check }: { check: PRCheck }) {
if (check.status !== 'COMPLETED') {
// In progress or queued
return (
<svg className="w-3.5 h-3.5 text-yellow-500 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
);
}
if (check.conclusion === 'SUCCESS') {
return (
<svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
}
if (check.conclusion === 'FAILURE' || check.conclusion === 'TIMED_OUT') {
return (
<svg className="w-3.5 h-3.5 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
if (check.conclusion === 'SKIPPED' || check.conclusion === 'NEUTRAL') {
return (
<svg className="w-3.5 h-3.5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 12H4" />
</svg>
);
}
// Fallback
return (
<svg className="w-3.5 h-3.5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
</svg>
);
}
export const PRChecksTab: React.FC<PRChecksTabProps> = ({ context }) => {
// Group checks by workflow
const groupedChecks = useMemo(() => {
const groups = new Map<string, PRCheck[]>();
for (const check of context.checks) {
const workflow = check.workflowName || 'Other';
const existing = groups.get(workflow) || [];
existing.push(check);
groups.set(workflow, existing);
}
return groups;
}, [context.checks]);
const checkSummary = useMemo(() => {
const total = context.checks.length;
const passed = context.checks.filter(c => c.conclusion === 'SUCCESS').length;
const failed = context.checks.filter(c => c.conclusion === 'FAILURE' || c.conclusion === 'TIMED_OUT').length;
const pending = context.checks.filter(c => c.status !== 'COMPLETED').length;
const skipped = context.checks.filter(c => c.conclusion === 'SKIPPED' || c.conclusion === 'NEUTRAL').length;
return { total, passed, failed, pending, skipped };
}, [context.checks]);
const isMerged = context.state === 'MERGED';
const isClosed = context.state === 'CLOSED';
const decisionStyle = DECISION_STYLES[context.reviewDecision];
const mergeStyle = isMerged
? { bg: 'bg-violet-500/15', text: 'text-violet-400', label: 'Merged' }
: isClosed
? { bg: 'bg-destructive/15', text: 'text-destructive', label: 'Closed' }
: MERGE_STATUS_STYLES[context.mergeStateStatus] ?? MERGE_STATUS_STYLES.UNKNOWN;
const mergeableConflict = !isMerged && !isClosed && context.mergeable === 'CONFLICTING';
return (
<div className="p-3 space-y-4">
{/* Merge readiness */}
<div className="space-y-2">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Merge Status
</h3>
<div className="space-y-1.5">
{/* Review decision */}
{decisionStyle && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded-md ${decisionStyle.bg}`}>
<span className={`text-xs font-medium ${decisionStyle.text}`}>
{decisionStyle.label}
</span>
</div>
)}
{/* Merge state */}
<div className={`flex items-center gap-2 px-2 py-1.5 rounded-md ${mergeStyle.bg}`}>
<span className={`text-xs font-medium ${mergeStyle.text}`}>
{mergeableConflict ? 'Has merge conflicts' : mergeStyle.label}
</span>
</div>
</div>
</div>
{/* Check runs */}
{context.checks.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Checks
</h3>
<span className="text-[10px] font-mono text-muted-foreground">
{checkSummary.passed}/{checkSummary.total} passed
{checkSummary.failed > 0 && <span className="text-destructive ml-1">{checkSummary.failed} failed</span>}
{checkSummary.pending > 0 && <span className="text-yellow-500 ml-1">{checkSummary.pending} pending</span>}
</span>
</div>
<div className="space-y-3">
{Array.from(groupedChecks.entries()).map(([workflow, checks]) => (
<div key={workflow}>
<div className="text-[10px] font-medium text-muted-foreground mb-1">{workflow}</div>
<div className="space-y-0.5">
{checks.map((check, i) => (
<a
key={`${check.name}-${i}`}
href={check.detailsUrl || undefined}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-2 py-1 rounded hover:bg-muted/50 transition-colors group"
>
<CheckIcon check={check} />
<span className="text-xs text-foreground/80 truncate group-hover:text-foreground">
{check.name}
</span>
</a>
))}
</div>
</div>
))}
</div>
</div>
)}
{context.checks.length === 0 && !decisionStyle && (
<div className="flex flex-col items-center justify-center h-24 text-center">
<p className="text-xs text-muted-foreground">No checks configured.</p>
</div>
)}
</div>
);
};
@@ -0,0 +1,120 @@
import React, { useMemo } from 'react';
import type { PRContext, PRComment, PRReview } from '@plannotator/shared/pr-provider';
import { MarkdownBody } from './PRSummaryTab';
interface PRCommentsTabProps {
context: PRContext;
}
type TimelineEntry =
| { kind: 'comment'; data: PRComment }
| { kind: 'review'; data: PRReview };
const REVIEW_STATE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
APPROVED: { bg: 'bg-success/15', text: 'text-success', label: 'Approved' },
CHANGES_REQUESTED: { bg: 'bg-destructive/15', text: 'text-destructive', label: 'Changes Requested' },
COMMENTED: { bg: 'bg-muted', text: 'text-muted-foreground', label: 'Commented' },
DISMISSED: { bg: 'bg-muted', text: 'text-muted-foreground/60', label: 'Dismissed' },
};
function formatRelativeTime(iso: string): string {
if (!iso) return '';
const now = Date.now();
const then = new Date(iso).getTime();
if (isNaN(then)) return '';
const diff = now - then;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 30) return `${days}d ago`;
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export const PRCommentsTab: React.FC<PRCommentsTabProps> = ({ context }) => {
const timeline = useMemo<TimelineEntry[]>(() => {
const entries: TimelineEntry[] = [
...context.comments.map((c): TimelineEntry => ({ kind: 'comment', data: c })),
...context.reviews
.filter((r) => r.state !== 'COMMENTED' || r.body)
.map((r): TimelineEntry => ({ kind: 'review', data: r })),
];
entries.sort((a, b) => {
const timeA = a.kind === 'comment' ? a.data.createdAt : a.data.submittedAt;
const timeB = b.kind === 'comment' ? b.data.createdAt : b.data.submittedAt;
return new Date(timeA).getTime() - new Date(timeB).getTime();
});
return entries;
}, [context.comments, context.reviews]);
if (timeline.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
<div className="w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center mb-3">
<svg className="w-5 h-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<p className="text-xs text-muted-foreground">No comments on this PR.</p>
</div>
);
}
return (
<div className="p-2 space-y-2">
{timeline.map((entry) => {
if (entry.kind === 'review') {
const review = entry.data;
const style = REVIEW_STATE_STYLES[review.state] ?? REVIEW_STATE_STYLES.COMMENTED;
return (
<div key={review.id} className="rounded-lg border border-border/50 p-2.5">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-foreground">
{review.author || 'unknown'}
</span>
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded ${style.bg} ${style.text}`}>
{style.label}
</span>
</div>
<span className="text-[10px] text-muted-foreground/50">
{formatRelativeTime(review.submittedAt)}
</span>
</div>
{review.body && (
<div className="text-xs text-foreground/80 mt-1 review-comment-markdown">
<MarkdownBody markdown={review.body} />
</div>
)}
</div>
);
}
const comment = entry.data;
return (
<div key={comment.id} className="rounded-lg border border-border/50 p-2.5">
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-medium text-foreground">
{comment.author || 'unknown'}
</span>
<span className="text-[10px] text-muted-foreground/50">
{formatRelativeTime(comment.createdAt)}
</span>
</div>
<div className="text-xs text-foreground/80 review-comment-markdown">
<MarkdownBody markdown={comment.body} />
</div>
</div>
);
})}
</div>
);
};
@@ -0,0 +1,187 @@
import React, { useMemo } from 'react';
import DOMPurify from 'dompurify';
import { parseMarkdownToBlocks } from '@plannotator/ui/utils/parser';
import { renderInlineMarkdown } from '../utils/renderInlineMarkdown';
import type { PRContext } from '@plannotator/shared/pr-provider';
import type { PRMetadata } from '@plannotator/shared/pr-provider';
interface PRSummaryTabProps {
context: PRContext;
metadata: PRMetadata;
}
/** Check if content contains HTML tags that should be rendered natively. */
const HTML_TAG_RE = /<[a-z][a-z0-9]*[\s/>]/i;
const containsHtml = (text: string) => HTML_TAG_RE.test(text);
/** Sanitize HTML using DOMPurify — defense-in-depth for GitHub API content. */
function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'sub', 'sup', 'b', 'i', 'em', 'strong', 'br', 'hr', 'p', 'span',
'del', 'ins', 'mark', 'small', 'abbr', 'kbd', 'var', 'samp',
'details', 'summary', 'blockquote', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'a', 'img', 'div',
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'rel', 'target', 'width', 'height', 'align'],
});
}
/**
* Render inline content — if it contains HTML tags, sanitize and render.
* Otherwise use our markdown renderer.
*/
function renderContent(content: string): React.ReactNode {
if (containsHtml(content)) {
return <span dangerouslySetInnerHTML={{ __html: sanitizeHtml(content) }} />;
}
return renderInlineMarkdown(content);
}
/** Render a simplified block-level markdown view (no annotation infrastructure). */
export function MarkdownBody({ markdown }: { markdown: string }) {
const blocks = useMemo(() => parseMarkdownToBlocks(markdown), [markdown]);
return (
<div className="space-y-2 text-xs text-foreground/90">
{blocks.map((block) => {
switch (block.type) {
case 'heading': {
const Tag = `h${Math.min(block.level ?? 1, 6)}` as keyof JSX.IntrinsicElements;
const sizes: Record<number, string> = {
1: 'text-base font-bold',
2: 'text-sm font-semibold',
3: 'text-xs font-semibold',
};
return (
<Tag key={block.id} className={`${sizes[block.level ?? 1] ?? 'text-xs font-medium'} text-foreground`}>
{renderContent(block.content)}
</Tag>
);
}
case 'code':
return (
<pre key={block.id} className="bg-muted/50 rounded-md p-2 text-[11px] font-mono overflow-x-auto whitespace-pre-wrap">
<code>{block.content}</code>
</pre>
);
case 'list-item':
return (
<div key={block.id} className="flex gap-1.5" style={{ paddingLeft: (block.level ?? 0) * 12 }}>
<span className="text-muted-foreground shrink-0">{block.checked !== undefined ? (block.checked ? '☑' : '☐') : '•'}</span>
<span>{renderContent(block.content)}</span>
</div>
);
case 'blockquote':
return (
<blockquote key={block.id} className="border-l-2 border-border pl-2 text-muted-foreground italic">
{renderContent(block.content)}
</blockquote>
);
case 'hr':
return <hr key={block.id} className="border-border/50" />;
default:
if (!block.content) return null;
// If the entire paragraph is HTML, sanitize and render
if (containsHtml(block.content)) {
return <div key={block.id} dangerouslySetInnerHTML={{ __html: sanitizeHtml(block.content) }} />;
}
return <p key={block.id}>{renderInlineMarkdown(block.content)}</p>;
}
})}
</div>
);
}
export const PRSummaryTab: React.FC<PRSummaryTabProps> = ({ context, metadata }) => {
return (
<div className="p-3 space-y-4">
{/* PR title + state */}
<div className="space-y-2">
<div className="flex items-start gap-2">
<span className={`shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded ${
context.state === 'MERGED'
? 'bg-violet-500/15 text-violet-400'
: context.state === 'CLOSED'
? 'bg-destructive/15 text-destructive'
: context.isDraft
? 'bg-muted text-muted-foreground'
: 'bg-success/15 text-success'
}`}>
{context.isDraft ? 'Draft' : context.state}
</span>
<a
href={metadata.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-foreground hover:text-primary transition-colors"
>
{metadata.title}
</a>
</div>
<div className="text-[10px] text-muted-foreground font-mono">
{metadata.author} wants to merge <code className="bg-muted px-1 rounded">{metadata.headBranch}</code> into <code className="bg-muted px-1 rounded">{metadata.baseBranch}</code>
</div>
</div>
{/* Labels */}
{context.labels.length > 0 && (
<div className="flex flex-wrap gap-1">
{context.labels.map((label) => (
<span
key={label.name}
className="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
</div>
)}
{/* Linked issues */}
{context.linkedIssues.length > 0 && (
<div className="space-y-1">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Linked Issues
</h3>
{context.linkedIssues.map((issue) => (
<a
key={issue.url}
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-primary hover:underline"
>
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4m0 4h.01" />
</svg>
#{issue.number}
{issue.repo && <span className="text-muted-foreground text-[10px]">({issue.repo})</span>}
</a>
))}
</div>
)}
{/* PR body */}
{context.body ? (
<div className="space-y-1">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Description
</h3>
<MarkdownBody markdown={context.body} />
</div>
) : (
<p className="text-xs text-muted-foreground italic">No description provided.</p>
)}
</div>
);
};
+219 -114
View File
@@ -1,10 +1,15 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { CodeAnnotation, type EditorAnnotation } from '@plannotator/ui/types';
import { isCurrentUser } from '@plannotator/ui/utils/identity';
import { EditorAnnotationCard } from '@plannotator/ui/components/EditorAnnotationCard';
import { HighlightedCode } from './HighlightedCode';
import { detectLanguage } from '../utils/detectLanguage';
import { renderInlineMarkdown } from '../utils/renderInlineMarkdown';
import { usePRContext } from '../hooks/usePRContext';
import { PRSummaryTab } from './PRSummaryTab';
import { PRCommentsTab } from './PRCommentsTab';
import { PRChecksTab } from './PRChecksTab';
import type { PRMetadata } from '@plannotator/shared/pr-provider';
interface DiffFile {
path: string;
@@ -12,6 +17,8 @@ interface DiffFile {
deletions: number;
}
type ReviewPanelTab = 'annotations' | 'summary' | 'comments' | 'checks';
interface ReviewPanelProps {
isOpen: boolean;
onToggle: () => void;
@@ -24,6 +31,7 @@ interface ReviewPanelProps {
width?: number;
editorAnnotations?: EditorAnnotation[];
onDeleteEditorAnnotation?: (id: string) => void;
prMetadata?: PRMetadata | null;
}
const SuggestionPreview: React.FC<{ code: string; originalCode?: string; language?: string }> = ({ code, originalCode, language }) => {
@@ -87,6 +95,29 @@ function compareCodeAnnotations(a: CodeAnnotation, b: CodeAnnotation): number {
: a.lineStart - b.lineStart;
}
const PR_TABS: { id: ReviewPanelTab; label: string; icon: React.ReactNode }[] = [
{ id: 'annotations', label: 'Annotations', icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
)},
{ id: 'summary', label: 'Summary', icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)},
{ id: 'comments', label: 'Comments', icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)},
{ id: 'checks', label: 'Checks', icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)},
];
export const ReviewPanel: React.FC<ReviewPanelProps> = ({
isOpen,
onToggle,
@@ -99,9 +130,33 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
width,
editorAnnotations,
onDeleteEditorAnnotation,
prMetadata,
}) => {
const totalCount = annotations.length + (editorAnnotations?.length ?? 0);
const [copied, setCopied] = useState(false);
const [activeTab, setActiveTab] = useState<ReviewPanelTab>('annotations');
const hasPRTabs = !!prMetadata;
const { prContext, isLoading: isPRContextLoading, error: prContextError, fetchContext } = usePRContext(prMetadata ?? null);
// Fetch PR context on first click of a PR tab
const handleTabChange = (tab: ReviewPanelTab) => {
setActiveTab(tab);
if (tab !== 'annotations' && !prContext && !isPRContextLoading) {
fetchContext();
}
};
// Small status dot for checks tab
const checksStatusDot = prContext
? prContext.checks.some(c => c.conclusion === 'FAILURE' || c.conclusion === 'TIMED_OUT')
? 'bg-destructive'
: prContext.checks.some(c => c.status !== 'COMPLETED')
? 'bg-yellow-500'
: prContext.checks.length > 0
? 'bg-success'
: null
: null;
const handleQuickCopy = async () => {
if (!feedbackMarkdown) return;
@@ -113,6 +168,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
console.error('Failed to copy:', e);
}
};
// Group annotations by file
const groupedAnnotations = React.useMemo(() => {
const grouped = new Map<string, CodeAnnotation[]>();
@@ -121,7 +177,6 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
existing.push(ann);
grouped.set(ann.filePath, existing);
}
// Sort file comments first, then line comments
for (const [, anns] of grouped) {
anns.sort(compareCodeAnnotations);
}
@@ -134,136 +189,186 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
<aside className="border-l border-border/50 bg-card/30 backdrop-blur-sm flex flex-col flex-shrink-0" style={{ width: width ?? 288 }}>
{/* Header */}
<div className="p-3 border-b border-border/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Annotations
{activeTab === 'annotations' ? 'Annotations' : activeTab === 'summary' ? 'Summary' : activeTab === 'comments' ? 'Comments' : 'Checks'}
</h2>
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
{totalCount}
</span>
{activeTab === 'annotations' && totalCount > 0 && (
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
{totalCount}
</span>
)}
{/* Tab buttons inline in header (PR mode only) */}
{hasPRTabs && (
<div className="flex items-center gap-0.5 ml-auto">
{PR_TABS.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors ${
activeTab === tab.id
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
title={tab.label}
>
{tab.icon}
{tab.id === 'checks' && checksStatusDot && (
<span className={`w-1.5 h-1.5 rounded-full ${checksStatusDot}`} />
)}
</button>
))}
</div>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
{totalCount === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
<div className="w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center mb-3">
<svg className="w-5 h-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</div>
<p className="text-xs text-muted-foreground">
Click on lines to add annotations
</p>
</div>
) : (
<div className="p-2 space-y-4">
{Array.from(groupedAnnotations.entries()).map(([filePath, fileAnnotations]) => (
<div key={filePath}>
{/* File header */}
<div className="px-2 py-1 text-xs font-mono text-muted-foreground truncate">
{filePath.split('/').pop()}
</div>
{/* Annotations for this file */}
<div className="space-y-1">
{fileAnnotations.map((annotation) => {
const isSelected = selectedAnnotationId === annotation.id;
const isFileScope = getAnnotationScope(annotation) === 'file';
return (
<div
key={annotation.id}
onClick={() => onSelectAnnotation(annotation.id)}
className={`group relative p-2.5 rounded-lg border cursor-pointer transition-all ${
isSelected
? 'bg-primary/5 border-primary/30 shadow-sm'
: 'border-transparent hover:bg-muted/50 hover:border-border/50'
}`}
>
{/* Header: Line + Timestamp */}
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
{isFileScope ? (
<span className="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
file
</span>
) : (
<span className="text-[10px] font-mono text-muted-foreground">
{annotation.lineStart === annotation.lineEnd
? `L${annotation.lineStart}`
: `L${annotation.lineStart}-${annotation.lineEnd}`}
</span>
)}
{annotation.author && (
<span className={`text-[10px] truncate max-w-[100px] ${isCurrentUser(annotation.author) ? 'text-muted-foreground/50' : 'text-muted-foreground/70'}`}>
{annotation.author}{isCurrentUser(annotation.author) && ' (me)'}
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground/50">
{formatTimestamp(annotation.createdAt)}
</span>
</div>
{/* Content */}
{annotation.text && (
<div className="text-xs text-foreground/80 line-clamp-2 review-comment-markdown">
{renderInlineMarkdown(annotation.text)}
</div>
)}
{annotation.suggestedCode && (
<div className="mt-1.5">
<SuggestionPreview code={annotation.suggestedCode} originalCode={annotation.originalCode} language={detectLanguage(annotation.filePath)} />
</div>
)}
{/* Delete button */}
<button
onClick={(e) => {
e.stopPropagation();
onDeleteAnnotation(annotation.id);
}}
className="absolute top-2 right-2 p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all"
title="Delete annotation"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
})}
<div className="flex-1 overflow-y-auto">
{/* Annotations tab */}
{activeTab === 'annotations' && (
<div className="p-2 space-y-1.5">
{totalCount === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
<div className="w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center mb-3">
<svg className="w-5 h-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</div>
<p className="text-xs text-muted-foreground">
Click on lines to add annotations
</p>
</div>
))}
) : (
<div className="p-2 space-y-4">
{Array.from(groupedAnnotations.entries()).map(([filePath, fileAnnotations]) => (
<div key={filePath}>
<div className="px-2 py-1 text-xs font-mono text-muted-foreground truncate">
{filePath.split('/').pop()}
</div>
<div className="space-y-1">
{fileAnnotations.map((annotation) => {
const isSelected = selectedAnnotationId === annotation.id;
const isFileScope = getAnnotationScope(annotation) === 'file';
return (
<div
key={annotation.id}
onClick={() => onSelectAnnotation(annotation.id)}
className={`group relative p-2.5 rounded-lg border cursor-pointer transition-all ${
isSelected
? 'bg-primary/5 border-primary/30 shadow-sm'
: 'border-transparent hover:bg-muted/50 hover:border-border/50'
}`}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
{isFileScope ? (
<span className="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
file
</span>
) : (
<span className="text-[10px] font-mono text-muted-foreground">
{annotation.lineStart === annotation.lineEnd
? `L${annotation.lineStart}`
: `L${annotation.lineStart}-${annotation.lineEnd}`}
</span>
)}
{annotation.author && (
<span className={`text-[10px] truncate max-w-[100px] ${isCurrentUser(annotation.author) ? 'text-muted-foreground/50' : 'text-muted-foreground/70'}`}>
{annotation.author}{isCurrentUser(annotation.author) && ' (me)'}
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground/50">
{formatTimestamp(annotation.createdAt)}
</span>
</div>
{annotation.text && (
<div className="text-xs text-foreground/80 line-clamp-2 review-comment-markdown">
{renderInlineMarkdown(annotation.text)}
</div>
)}
{annotation.suggestedCode && (
<div className="mt-1.5">
<SuggestionPreview code={annotation.suggestedCode} originalCode={annotation.originalCode} language={detectLanguage(annotation.filePath)} />
</div>
)}
<button
onClick={(e) => {
e.stopPropagation();
onDeleteAnnotation(annotation.id);
}}
className="absolute top-2 right-2 p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all"
title="Delete annotation"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{/* Editor annotations (VS Code) */}
{editorAnnotations && editorAnnotations.length > 0 && (
<>
{annotations.length > 0 && (
<div className="flex items-center gap-2 pt-2 pb-1">
<div className="flex-1 border-t border-border/30" />
<span className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">Editor</span>
<div className="flex-1 border-t border-border/30" />
</div>
)}
{editorAnnotations.map(ann => (
<EditorAnnotationCard
key={ann.id}
annotation={ann}
onDelete={() => onDeleteEditorAnnotation?.(ann.id)}
/>
))}
</>
)}
</div>
)}
{/* Editor annotations (VS Code) */}
{editorAnnotations && editorAnnotations.length > 0 && (
{/* PR tabs — loading / error / content */}
{activeTab !== 'annotations' && (
<>
{annotations.length > 0 && (
<div className="flex items-center gap-2 pt-2 pb-1">
<div className="flex-1 border-t border-border/30" />
<span className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">Editor</span>
<div className="flex-1 border-t border-border/30" />
{isPRContextLoading && (
<div className="flex items-center justify-center h-40">
<div className="text-xs text-muted-foreground">Loading...</div>
</div>
)}
{editorAnnotations.map(ann => (
<EditorAnnotationCard
key={ann.id}
annotation={ann}
onDelete={() => onDeleteEditorAnnotation?.(ann.id)}
/>
))}
{prContextError && (
<div className="p-4 text-center">
<p className="text-xs text-destructive">{prContextError}</p>
<button
onClick={() => fetchContext()}
className="mt-2 text-xs text-primary hover:underline"
>
Retry
</button>
</div>
)}
{prContext && prMetadata && (
<>
{activeTab === 'summary' && <PRSummaryTab context={prContext} metadata={prMetadata} />}
{activeTab === 'comments' && <PRCommentsTab context={prContext} />}
{activeTab === 'checks' && <PRChecksTab context={prContext} />}
</>
)}
</>
)}
</div>
{/* Quick Copy Footer */}
{feedbackMarkdown && totalCount > 0 && (
{/* Quick Copy Footer — annotations tab only */}
{activeTab === 'annotations' && feedbackMarkdown && totalCount > 0 && (
<div className="p-2 border-t border-border/50">
<button
onClick={handleQuickCopy}
@@ -0,0 +1,35 @@
import { useState, useRef, useCallback } from 'react';
import type { PRContext } from '@plannotator/shared/pr-provider';
import type { PRMetadata } from '@plannotator/shared/pr-provider';
export function usePRContext(prMetadata: PRMetadata | null) {
const [prContext, setPRContext] = useState<PRContext | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetched = useRef(false);
const fetchContext = useCallback(async () => {
if (!prMetadata || fetched.current) return;
fetched.current = true;
setIsLoading(true);
setError(null);
try {
const res = await fetch('/api/pr-context');
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
const context: PRContext = await res.json();
setPRContext(context);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load PR context';
setError(message);
fetched.current = false;
} finally {
setIsLoading(false);
}
}, [prMetadata]);
return { prContext, isLoading, error, fetchContext };
}
@@ -36,8 +36,8 @@ function renderInline(text: string, startKey: number): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
let key = startKey;
// Match inline patterns: `code`, **bold**, *italic*
const regex = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g;
// Match inline patterns: [text](url), `code`, **bold**, *italic*, bare URLs
const regex = /(\[([^\]]+)\]\((https?:\/\/[^)]+)\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|https?:\/\/[^\s<)\]]+)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
@@ -48,12 +48,26 @@ function renderInline(text: string, startKey: number): React.ReactNode[] {
}
const token = match[0];
if (token.startsWith('`')) {
if (match[1] && match[2] && match[3]) {
// Markdown link: [text](url)
nodes.push(
<a key={key++} href={match[3]} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{match[2]}
</a>
);
} else if (token.startsWith('`')) {
nodes.push(<code key={key++} className="inline-code">{token.slice(1, -1)}</code>);
} else if (token.startsWith('**')) {
nodes.push(<strong key={key++}>{token.slice(2, -2)}</strong>);
} else if (token.startsWith('*')) {
nodes.push(<em key={key++}>{token.slice(1, -1)}</em>);
} else if (token.startsWith('http')) {
// Bare URL
nodes.push(
<a key={key++} href={token} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">
{token}
</a>
);
}
lastIndex = match.index + token.length;
+9 -1
View File
@@ -8,14 +8,16 @@
import {
type PRRef,
type PRMetadata,
type PRContext,
type PRRuntime,
parsePRUrl as parsePRUrlCore,
checkGhAuth as checkGhAuthCore,
fetchPR as fetchPRCore,
fetchPRFileContent as fetchPRFileContentCore,
fetchPRContext as fetchPRContextCore,
} from "@plannotator/shared/pr-provider";
export type { PRRef, PRMetadata } from "@plannotator/shared/pr-provider";
export type { PRRef, PRMetadata, PRContext } from "@plannotator/shared/pr-provider";
const runtime: PRRuntime = {
async runCommand(cmd, args) {
@@ -46,6 +48,12 @@ export function fetchPR(
return fetchPRCore(runtime, ref);
}
export function fetchPRContext(
ref: PRRef,
): Promise<PRContext> {
return fetchPRContextCore(runtime, ref);
}
export function fetchPRFileContent(
ref: PRRef,
sha: string,
+25 -1
View File
@@ -15,7 +15,7 @@ import { getRepoInfo } from "./repo";
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers";
import { contentHash, deleteDraft } from "./draft";
import { createEditorAnnotationHandler } from "./editor-annotations";
import { type PRMetadata, fetchPRFileContent } from "./pr";
import { type PRMetadata, fetchPRFileContent, fetchPRContext } from "./pr";
// Re-export utilities
export { isRemoteSession, getServerPort } from "./remote";
@@ -195,6 +195,30 @@ export async function startReviewServer(
}
}
// API: Fetch PR context (comments, checks, merge status) — PR mode only
if (url.pathname === "/api/pr-context" && req.method === "GET") {
if (!isPRMode) {
return Response.json(
{ error: "Not in PR mode" },
{ status: 400 },
);
}
try {
const ref = {
platform: "github" as const,
owner: prMetadata!.owner,
repo: prMetadata!.repo,
number: prMetadata!.number,
};
const context = await fetchPRContext(ref);
return Response.json(context);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch PR context";
return Response.json({ error: message }, { status: 500 });
}
}
// API: Get file content for expandable diff context
if (url.pathname === "/api/file-content" && req.method === "GET") {
const filePath = url.searchParams.get("path");
+127
View File
@@ -41,6 +41,52 @@ export interface PRMetadata {
url: string;
}
// --- PR Context Types ---
export interface PRComment {
id: string;
author: string;
body: string;
createdAt: string;
url: string;
}
export interface PRReview {
id: string;
author: string;
state: string;
body: string;
submittedAt: string;
}
export interface PRCheck {
name: string;
status: string;
conclusion: string | null;
workflowName: string;
detailsUrl: string;
}
export interface PRLinkedIssue {
number: number;
url: string;
repo: string;
}
export interface PRContext {
body: string;
state: string;
isDraft: boolean;
labels: Array<{ name: string; color: string }>;
reviewDecision: string;
mergeable: string;
mergeStateStatus: string;
comments: PRComment[];
reviews: PRReview[];
checks: PRCheck[];
linkedIssues: PRLinkedIssue[];
}
// --- URL Parsing ---
/**
@@ -142,6 +188,87 @@ export async function fetchPR(
return { metadata, rawPatch: diffResult.stdout };
}
// --- PR Context ---
const PR_CONTEXT_FIELDS = [
"body", "state", "isDraft", "labels",
"comments", "reviews", "reviewDecision",
"mergeable", "mergeStateStatus",
"statusCheckRollup", "closingIssuesReferences",
].join(",");
function parsePRContext(raw: Record<string, unknown>): PRContext {
const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : "");
const login = (v: unknown): string =>
typeof v === "object" && v !== null && "login" in v
? String((v as { login: unknown }).login || "")
: "";
return {
body: str(raw.body),
state: str(raw.state),
isDraft: raw.isDraft === true,
labels: arr(raw.labels).map((l: any) => ({
name: str(l?.name),
color: str(l?.color),
})),
reviewDecision: str(raw.reviewDecision),
mergeable: str(raw.mergeable),
mergeStateStatus: str(raw.mergeStateStatus),
comments: arr(raw.comments).map((c: any) => ({
id: str(c?.id),
author: login(c?.author),
body: str(c?.body),
createdAt: str(c?.createdAt),
url: str(c?.url),
})),
reviews: arr(raw.reviews).map((r: any) => ({
id: str(r?.id),
author: login(r?.author),
state: str(r?.state),
body: str(r?.body),
submittedAt: str(r?.submittedAt),
})),
checks: arr(raw.statusCheckRollup).map((c: any) => ({
name: str(c?.name),
status: str(c?.status),
conclusion: typeof c?.conclusion === "string" ? c.conclusion : null,
workflowName: str(c?.workflowName),
detailsUrl: str(c?.detailsUrl),
})),
linkedIssues: arr(raw.closingIssuesReferences).map((i: any) => ({
number: typeof i?.number === "number" ? i.number : 0,
url: str(i?.url),
repo: i?.repository
? `${login(i.repository.owner)}/${str(i.repository.name)}`
: "",
})),
};
}
export async function fetchPRContext(
runtime: PRRuntime,
ref: PRRef,
): Promise<PRContext> {
const repo = `${ref.owner}/${ref.repo}`;
const result = await runtime.runCommand("gh", [
"pr", "view", String(ref.number),
"--repo", repo,
"--json", PR_CONTEXT_FIELDS,
]);
if (result.exitCode !== 0) {
throw new Error(
`Failed to fetch PR context: ${result.stderr.trim() || `exit code ${result.exitCode}`}`,
);
}
const raw = JSON.parse(result.stdout) as Record<string, unknown>;
return parsePRContext(raw);
}
// --- File Content ---
export async function fetchPRFileContent(