Skip to content

Commit 1821943

Browse files
backnotpropclaude
andauthored
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>
1 parent 0908e23 commit 1821943

12 files changed

Lines changed: 920 additions & 117 deletions

File tree

bun.lock

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
},
3434
"dependencies": {
3535
"@pierre/diffs": "^1.1.0-beta.19",
36-
"diff": "^8.0.3"
36+
"diff": "^8.0.3",
37+
"dompurify": "^3.3.3"
38+
},
39+
"devDependencies": {
40+
"@types/dompurify": "^3.2.0"
3741
}
3842
}

packages/review-editor/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,7 @@ const ReviewApp: React.FC = () => {
980980
width={panelResize.width}
981981
editorAnnotations={editorAnnotations}
982982
onDeleteEditorAnnotation={deleteEditorAnnotation}
983+
prMetadata={prMetadata}
983984
/>
984985
</div>
985986

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import React, { useMemo } from 'react';
2+
import type { PRContext, PRCheck } from '@plannotator/shared/pr-provider';
3+
4+
interface PRChecksTabProps {
5+
context: PRContext;
6+
}
7+
8+
const DECISION_STYLES: Record<string, { bg: string; text: string; label: string }> = {
9+
APPROVED: { bg: 'bg-success/15', text: 'text-success', label: 'Approved' },
10+
CHANGES_REQUESTED: { bg: 'bg-destructive/15', text: 'text-destructive', label: 'Changes Requested' },
11+
REVIEW_REQUIRED: { bg: 'bg-yellow-500/15', text: 'text-yellow-500', label: 'Review Required' },
12+
};
13+
14+
const MERGE_STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
15+
CLEAN: { bg: 'bg-success/15', text: 'text-success', label: 'Ready to merge' },
16+
BLOCKED: { bg: 'bg-yellow-500/15', text: 'text-yellow-500', label: 'Blocked' },
17+
BEHIND: { bg: 'bg-yellow-500/15', text: 'text-yellow-500', label: 'Behind base branch' },
18+
DIRTY: { bg: 'bg-destructive/15', text: 'text-destructive', label: 'Has conflicts' },
19+
UNKNOWN: { bg: 'bg-muted', text: 'text-muted-foreground', label: 'Unknown' },
20+
};
21+
22+
function CheckIcon({ check }: { check: PRCheck }) {
23+
if (check.status !== 'COMPLETED') {
24+
// In progress or queued
25+
return (
26+
<svg className="w-3.5 h-3.5 text-yellow-500 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
27+
<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" />
28+
</svg>
29+
);
30+
}
31+
32+
if (check.conclusion === 'SUCCESS') {
33+
return (
34+
<svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
35+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
36+
</svg>
37+
);
38+
}
39+
40+
if (check.conclusion === 'FAILURE' || check.conclusion === 'TIMED_OUT') {
41+
return (
42+
<svg className="w-3.5 h-3.5 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
43+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
44+
</svg>
45+
);
46+
}
47+
48+
if (check.conclusion === 'SKIPPED' || check.conclusion === 'NEUTRAL') {
49+
return (
50+
<svg className="w-3.5 h-3.5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
51+
<path strokeLinecap="round" strokeLinejoin="round" d="M20 12H4" />
52+
</svg>
53+
);
54+
}
55+
56+
// Fallback
57+
return (
58+
<svg className="w-3.5 h-3.5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
59+
<circle cx="12" cy="12" r="10" />
60+
</svg>
61+
);
62+
}
63+
64+
export const PRChecksTab: React.FC<PRChecksTabProps> = ({ context }) => {
65+
// Group checks by workflow
66+
const groupedChecks = useMemo(() => {
67+
const groups = new Map<string, PRCheck[]>();
68+
for (const check of context.checks) {
69+
const workflow = check.workflowName || 'Other';
70+
const existing = groups.get(workflow) || [];
71+
existing.push(check);
72+
groups.set(workflow, existing);
73+
}
74+
return groups;
75+
}, [context.checks]);
76+
77+
const checkSummary = useMemo(() => {
78+
const total = context.checks.length;
79+
const passed = context.checks.filter(c => c.conclusion === 'SUCCESS').length;
80+
const failed = context.checks.filter(c => c.conclusion === 'FAILURE' || c.conclusion === 'TIMED_OUT').length;
81+
const pending = context.checks.filter(c => c.status !== 'COMPLETED').length;
82+
const skipped = context.checks.filter(c => c.conclusion === 'SKIPPED' || c.conclusion === 'NEUTRAL').length;
83+
return { total, passed, failed, pending, skipped };
84+
}, [context.checks]);
85+
86+
const isMerged = context.state === 'MERGED';
87+
const isClosed = context.state === 'CLOSED';
88+
const decisionStyle = DECISION_STYLES[context.reviewDecision];
89+
const mergeStyle = isMerged
90+
? { bg: 'bg-violet-500/15', text: 'text-violet-400', label: 'Merged' }
91+
: isClosed
92+
? { bg: 'bg-destructive/15', text: 'text-destructive', label: 'Closed' }
93+
: MERGE_STATUS_STYLES[context.mergeStateStatus] ?? MERGE_STATUS_STYLES.UNKNOWN;
94+
const mergeableConflict = !isMerged && !isClosed && context.mergeable === 'CONFLICTING';
95+
96+
return (
97+
<div className="p-3 space-y-4">
98+
{/* Merge readiness */}
99+
<div className="space-y-2">
100+
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
101+
Merge Status
102+
</h3>
103+
104+
<div className="space-y-1.5">
105+
{/* Review decision */}
106+
{decisionStyle && (
107+
<div className={`flex items-center gap-2 px-2 py-1.5 rounded-md ${decisionStyle.bg}`}>
108+
<span className={`text-xs font-medium ${decisionStyle.text}`}>
109+
{decisionStyle.label}
110+
</span>
111+
</div>
112+
)}
113+
114+
{/* Merge state */}
115+
<div className={`flex items-center gap-2 px-2 py-1.5 rounded-md ${mergeStyle.bg}`}>
116+
<span className={`text-xs font-medium ${mergeStyle.text}`}>
117+
{mergeableConflict ? 'Has merge conflicts' : mergeStyle.label}
118+
</span>
119+
</div>
120+
</div>
121+
</div>
122+
123+
{/* Check runs */}
124+
{context.checks.length > 0 && (
125+
<div className="space-y-2">
126+
<div className="flex items-center justify-between">
127+
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
128+
Checks
129+
</h3>
130+
<span className="text-[10px] font-mono text-muted-foreground">
131+
{checkSummary.passed}/{checkSummary.total} passed
132+
{checkSummary.failed > 0 && <span className="text-destructive ml-1">{checkSummary.failed} failed</span>}
133+
{checkSummary.pending > 0 && <span className="text-yellow-500 ml-1">{checkSummary.pending} pending</span>}
134+
</span>
135+
</div>
136+
137+
<div className="space-y-3">
138+
{Array.from(groupedChecks.entries()).map(([workflow, checks]) => (
139+
<div key={workflow}>
140+
<div className="text-[10px] font-medium text-muted-foreground mb-1">{workflow}</div>
141+
<div className="space-y-0.5">
142+
{checks.map((check, i) => (
143+
<a
144+
key={`${check.name}-${i}`}
145+
href={check.detailsUrl || undefined}
146+
target="_blank"
147+
rel="noopener noreferrer"
148+
className="flex items-center gap-2 px-2 py-1 rounded hover:bg-muted/50 transition-colors group"
149+
>
150+
<CheckIcon check={check} />
151+
<span className="text-xs text-foreground/80 truncate group-hover:text-foreground">
152+
{check.name}
153+
</span>
154+
</a>
155+
))}
156+
</div>
157+
</div>
158+
))}
159+
</div>
160+
</div>
161+
)}
162+
163+
{context.checks.length === 0 && !decisionStyle && (
164+
<div className="flex flex-col items-center justify-center h-24 text-center">
165+
<p className="text-xs text-muted-foreground">No checks configured.</p>
166+
</div>
167+
)}
168+
</div>
169+
);
170+
};
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import React, { useMemo } from 'react';
2+
import type { PRContext, PRComment, PRReview } from '@plannotator/shared/pr-provider';
3+
import { MarkdownBody } from './PRSummaryTab';
4+
5+
interface PRCommentsTabProps {
6+
context: PRContext;
7+
}
8+
9+
type TimelineEntry =
10+
| { kind: 'comment'; data: PRComment }
11+
| { kind: 'review'; data: PRReview };
12+
13+
const REVIEW_STATE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
14+
APPROVED: { bg: 'bg-success/15', text: 'text-success', label: 'Approved' },
15+
CHANGES_REQUESTED: { bg: 'bg-destructive/15', text: 'text-destructive', label: 'Changes Requested' },
16+
COMMENTED: { bg: 'bg-muted', text: 'text-muted-foreground', label: 'Commented' },
17+
DISMISSED: { bg: 'bg-muted', text: 'text-muted-foreground/60', label: 'Dismissed' },
18+
};
19+
20+
function formatRelativeTime(iso: string): string {
21+
if (!iso) return '';
22+
const now = Date.now();
23+
const then = new Date(iso).getTime();
24+
if (isNaN(then)) return '';
25+
26+
const diff = now - then;
27+
const minutes = Math.floor(diff / 60000);
28+
const hours = Math.floor(minutes / 60);
29+
const days = Math.floor(hours / 24);
30+
31+
if (minutes < 1) return 'just now';
32+
if (minutes < 60) return `${minutes}m ago`;
33+
if (hours < 24) return `${hours}h ago`;
34+
if (days < 30) return `${days}d ago`;
35+
36+
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
37+
}
38+
39+
export const PRCommentsTab: React.FC<PRCommentsTabProps> = ({ context }) => {
40+
const timeline = useMemo<TimelineEntry[]>(() => {
41+
const entries: TimelineEntry[] = [
42+
...context.comments.map((c): TimelineEntry => ({ kind: 'comment', data: c })),
43+
...context.reviews
44+
.filter((r) => r.state !== 'COMMENTED' || r.body)
45+
.map((r): TimelineEntry => ({ kind: 'review', data: r })),
46+
];
47+
48+
entries.sort((a, b) => {
49+
const timeA = a.kind === 'comment' ? a.data.createdAt : a.data.submittedAt;
50+
const timeB = b.kind === 'comment' ? b.data.createdAt : b.data.submittedAt;
51+
return new Date(timeA).getTime() - new Date(timeB).getTime();
52+
});
53+
54+
return entries;
55+
}, [context.comments, context.reviews]);
56+
57+
if (timeline.length === 0) {
58+
return (
59+
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
60+
<div className="w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center mb-3">
61+
<svg className="w-5 h-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
62+
<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" />
63+
</svg>
64+
</div>
65+
<p className="text-xs text-muted-foreground">No comments on this PR.</p>
66+
</div>
67+
);
68+
}
69+
70+
return (
71+
<div className="p-2 space-y-2">
72+
{timeline.map((entry) => {
73+
if (entry.kind === 'review') {
74+
const review = entry.data;
75+
const style = REVIEW_STATE_STYLES[review.state] ?? REVIEW_STATE_STYLES.COMMENTED;
76+
77+
return (
78+
<div key={review.id} className="rounded-lg border border-border/50 p-2.5">
79+
<div className="flex items-center justify-between mb-1">
80+
<div className="flex items-center gap-1.5">
81+
<span className="text-[11px] font-medium text-foreground">
82+
{review.author || 'unknown'}
83+
</span>
84+
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded ${style.bg} ${style.text}`}>
85+
{style.label}
86+
</span>
87+
</div>
88+
<span className="text-[10px] text-muted-foreground/50">
89+
{formatRelativeTime(review.submittedAt)}
90+
</span>
91+
</div>
92+
{review.body && (
93+
<div className="text-xs text-foreground/80 mt-1 review-comment-markdown">
94+
<MarkdownBody markdown={review.body} />
95+
</div>
96+
)}
97+
</div>
98+
);
99+
}
100+
101+
const comment = entry.data;
102+
return (
103+
<div key={comment.id} className="rounded-lg border border-border/50 p-2.5">
104+
<div className="flex items-center justify-between mb-1">
105+
<span className="text-[11px] font-medium text-foreground">
106+
{comment.author || 'unknown'}
107+
</span>
108+
<span className="text-[10px] text-muted-foreground/50">
109+
{formatRelativeTime(comment.createdAt)}
110+
</span>
111+
</div>
112+
<div className="text-xs text-foreground/80 review-comment-markdown">
113+
<MarkdownBody markdown={comment.body} />
114+
</div>
115+
</div>
116+
);
117+
})}
118+
</div>
119+
);
120+
};

0 commit comments

Comments
 (0)