mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user