From 593d9dbc0be9204f8c0692fce567d4b0349d4480 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 21 Mar 2026 14:45:15 -0700 Subject: [PATCH 1/3] feat: GitLab merge request review support Add full GitLab MR review parity with existing GitHub PR review: - Auto-detect platform from URL (github.com vs any GitLab host) - Extract GitHub logic into pr-github.ts, new pr-gitlab.ts implementation - Widen PRRef/PRMetadata to discriminated unions for type safety - Dispatch functions route to correct platform implementation - Platform-aware UI labels (PR/MR, #/!, GitHub/GitLab icons) - Self-hosted GitLab support via --hostname flag - Normalize glab diff output to standard git format - Handle glab CLI differences (no --jq, Content-Type header for --input) - Defensive JSON parsing for GitLab context API responses Tested against gitlab.com with inline comments, multi-line ranges, approval, and PR context tabs (summary, comments, checks). For provenance purposes, this commit was AI assisted. --- apps/hook/server/index.ts | 23 +- apps/opencode-plugin/commands.ts | 17 +- bun.lock | 7 +- packages/review-editor/App.tsx | 219 ++++---- .../review-editor/utils/exportFeedback.ts | 3 +- packages/server/pr.ts | 28 +- packages/server/review.ts | 35 +- packages/shared/pr-github.ts | 245 +++++++++ packages/shared/pr-gitlab.ts | 489 ++++++++++++++++++ packages/shared/pr-provider.ts | 348 +++++-------- packages/ui/components/GitLabIcon.tsx | 18 + 11 files changed, 1057 insertions(+), 375 deletions(-) create mode 100644 packages/shared/pr-github.ts create mode 100644 packages/shared/pr-gitlab.ts create mode 100644 packages/ui/components/GitLabIcon.tsx diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index e03681fe..85d27545 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -44,7 +44,7 @@ import { handleAnnotateServerReady, } from "@plannotator/server/annotate"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; -import { parsePRUrl, checkGhAuth, fetchPR } from "@plannotator/server/pr"; +import { parsePRUrl, checkAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; @@ -149,29 +149,34 @@ if (args[0] === "sessions") { // --- PR Review Mode --- const prRef = parsePRUrl(urlArg); if (!prRef) { - console.error(`Invalid PR URL: ${urlArg}`); - console.error("Supported formats: https://github.com/owner/repo/pull/123"); + console.error(`Invalid PR/MR URL: ${urlArg}`); + console.error("Supported formats:"); + console.error(" GitHub: https://github.com/owner/repo/pull/123"); + console.error(" GitLab: https://gitlab.com/group/project/-/merge_requests/42"); process.exit(1); } + const cliName = getCliName(prRef); + const cliUrl = getCliInstallUrl(prRef); + try { - await checkGhAuth(); + await checkAuth(prRef); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("not found") || msg.includes("ENOENT")) { - console.error("GitHub CLI (gh) is not installed."); - console.error("Install it from https://cli.github.com"); + console.error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`); + console.error(`Install it from ${cliUrl}`); } else { console.error(msg); } process.exit(1); } - console.error(`Fetching PR #${prRef.number} from ${prRef.owner}/${prRef.repo}...`); + console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`); try { const pr = await fetchPR(prRef); rawPatch = pr.rawPatch; - gitRef = `PR #${prRef.number}`; + gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`; prMetadata = pr.metadata; } catch (err) { console.error(err instanceof Error ? err.message : "Failed to fetch PR"); @@ -216,7 +221,7 @@ if (args[0] === "sessions") { mode: "review", project: reviewProject, startedAt: new Date().toISOString(), - label: isPRMode ? `pr-review-${prMetadata!.owner}/${prMetadata!.repo}#${prMetadata!.number}` : `review-${reviewProject}`, + label: isPRMode ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` : `review-${reviewProject}`, }); // Wait for user feedback diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 964feff0..38e15cee 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -14,7 +14,7 @@ import { handleAnnotateServerReady, } from "@plannotator/server/annotate"; import { getGitContext, runGitDiffWithContext } from "@plannotator/server/git"; -import { parsePRUrl, checkGhAuth, fetchPR } from "@plannotator/server/pr"; +import { parsePRUrl, checkAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; /** Shared dependencies injected by the plugin */ @@ -44,28 +44,29 @@ export async function handleReviewCommand( let prMetadata: Awaited>["metadata"] | undefined; if (isPRMode) { - client.app.log({ level: "info", message: "Fetching PR for review..." }); - const prRef = parsePRUrl(urlArg); if (!prRef) { - client.app.log({ level: "error", message: `Invalid PR URL: ${urlArg}` }); + client.app.log({ level: "error", message: `Invalid PR/MR URL: ${urlArg}` }); return; } + client.app.log({ level: "info", message: `Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...` }); + try { - await checkGhAuth(); + await checkAuth(prRef); } catch (err) { - client.app.log({ level: "error", message: err instanceof Error ? err.message : "GitHub CLI auth check failed" }); + const cliName = getCliName(prRef); + client.app.log({ level: "error", message: err instanceof Error ? err.message : `${cliName} auth check failed` }); return; } try { const pr = await fetchPR(prRef); rawPatch = pr.rawPatch; - gitRef = `PR #${prRef.number}`; + gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`; prMetadata = pr.metadata; } catch (err) { - client.app.log({ level: "error", message: err instanceof Error ? err.message : "Failed to fetch PR" }); + client.app.log({ level: "error", message: err instanceof Error ? err.message : `Failed to fetch ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}` }); return; } } else { diff --git a/bun.lock b/bun.lock index b6f076a0..1f179058 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "plannotator", @@ -56,7 +55,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.14.3", + "version": "0.14.4", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -78,7 +77,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.14.3", + "version": "0.14.4", "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.53.0", }, @@ -159,7 +158,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.14.3", + "version": "0.14.4", "dependencies": { "@plannotator/shared": "workspace:*", }, diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index bc9dde53..27cbbf0b 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -7,8 +7,10 @@ import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; import { storage } from '@plannotator/ui/utils/storage'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; import { GitHubIcon } from '@plannotator/ui/components/GitHubIcon'; +import { GitLabIcon } from '@plannotator/ui/components/GitLabIcon'; import { RepoIcon } from '@plannotator/ui/components/RepoIcon'; import { PullRequestIcon } from '@plannotator/ui/components/PullRequestIcon'; +import { getPlatformLabel, getMRLabel, getMRNumberLabel, getDisplayRepo } from '@plannotator/shared/pr-provider'; import { getIdentity } from '@plannotator/ui/utils/identity'; import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; import { CodeAnnotation, CodeAnnotationType, SelectedLineRange } from '@plannotator/ui/types'; @@ -122,19 +124,26 @@ const ReviewApp: React.FC = () => { }, [repoInfo]); const [prMetadata, setPrMetadata] = useState(null); - const [reviewDestination, setReviewDestination] = useState<'agent' | 'github'>(() => - storage.getItem('plannotator-review-dest') === 'agent' ? 'agent' : 'github' - ); + const [reviewDestination, setReviewDestination] = useState<'agent' | 'platform'>(() => { + const stored = storage.getItem('plannotator-review-dest'); + return stored === 'agent' ? 'agent' : 'platform'; // 'github' (legacy) → 'platform' + }); const [showDestinationMenu, setShowDestinationMenu] = useState(false); - const [isGitHubActioning, setIsGitHubActioning] = useState(false); - const [githubActionError, setGithubActionError] = useState(null); - const [ghUser, setGhUser] = useState(null); - const [githubCommentDialog, setGithubCommentDialog] = useState<{ action: 'approve' | 'comment' } | null>(null); - const [githubGeneralComment, setGithubGeneralComment] = useState(''); - const [githubOpenPR, setGithubOpenPR] = useState(() => storage.getItem('plannotator-github-open-pr') !== 'false'); - - // Derived: GitHub mode is active when destination is GitHub AND we have PR metadata - const githubMode = reviewDestination === 'github' && !!prMetadata; + const [isPlatformActioning, setIsPlatformActioning] = useState(false); + const [platformActionError, setPlatformActionError] = useState(null); + const [platformUser, setPlatformUser] = useState(null); + const [platformCommentDialog, setPlatformCommentDialog] = useState<{ action: 'approve' | 'comment' } | null>(null); + const [platformGeneralComment, setPlatformGeneralComment] = useState(''); + const [platformOpenPR, setPlatformOpenPR] = useState(() => storage.getItem('plannotator-github-open-pr') !== 'false'); + + // Derived: Platform mode is active when destination is platform AND we have PR/MR metadata + const platformMode = reviewDestination === 'platform' && !!prMetadata; + + // Platform-aware labels + const platformLabel = prMetadata ? getPlatformLabel(prMetadata) : 'GitHub'; + const mrLabel = prMetadata ? getMRLabel(prMetadata) : 'PR'; + const mrNumberLabel = prMetadata ? getMRNumberLabel(prMetadata) : ''; + const displayRepo = prMetadata ? getDisplayRepo(prMetadata) : ''; const identity = useMemo(() => getIdentity(), []); @@ -253,7 +262,7 @@ const ReviewApp: React.FC = () => { sharingEnabled?: boolean; repoInfo?: { display: string; branch?: string }; prMetadata?: PRMetadata; - ghUser?: string; + platformUser?: string; error?: string; }) => { const apiFiles = parseDiffToFiles(data.rawPatch); @@ -273,7 +282,7 @@ const ReviewApp: React.FC = () => { if (data.sharingEnabled !== undefined) setSharingEnabled(data.sharingEnabled); if (data.repoInfo) setRepoInfo(data.repoInfo); if (data.prMetadata) setPrMetadata(data.prMetadata); - if (data.ghUser) setGhUser(data.ghUser); + if (data.platformUser) setPlatformUser(data.platformUser); if (data.error) setDiffError(data.error); }) .catch(() => { @@ -668,9 +677,9 @@ const ReviewApp: React.FC = () => { }, [annotations, editorAnnotations, files]); // Submit a review directly to GitHub - const handleGitHubAction = useCallback(async (action: 'approve' | 'comment', generalComment?: string) => { - setIsGitHubActioning(true); - setGithubActionError(null); + const handlePlatformAction = useCallback(async (action: 'approve' | 'comment', generalComment?: string) => { + setIsPlatformActioning(true); + setPlatformActionError(null); try { const payload = buildPRReviewPayload(action, generalComment); const prRes = await fetch('/api/pr-action', { @@ -680,13 +689,13 @@ const ReviewApp: React.FC = () => { }); const prData = await prRes.json() as { ok?: boolean; prUrl?: string; error?: string }; if (!prRes.ok || prData.error) { - setGithubActionError(prData.error ?? 'Failed to submit PR review'); - setIsGitHubActioning(false); + setPlatformActionError(prData.error ?? 'Failed to submit PR review'); + setIsPlatformActioning(false); return; } // Open PR in browser (if opted in) - if (prData.prUrl && githubOpenPR) { + if (prData.prUrl && platformOpenPR) { window.open(prData.prUrl, '_blank'); } @@ -695,8 +704,8 @@ const ReviewApp: React.FC = () => { const effectiveAgent = getEffectiveAgentName(agentSwitchSettings); const prLink = prData.prUrl ?? ''; const statusMessage = action === 'approve' - ? `Pull request approved on GitHub${prLink ? ': ' + prLink : ''}` - : `Pull request reviewed on GitHub${prLink ? ': ' + prLink : ''}`; + ? `${mrLabel === 'MR' ? 'Merge request' : 'Pull request'} approved on ${platformLabel}${prLink ? ': ' + prLink : ''}` + : `${mrLabel === 'MR' ? 'Merge request' : 'Pull request'} reviewed on ${platformLabel}${prLink ? ': ' + prLink : ''}`; await fetch('/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -709,10 +718,10 @@ const ReviewApp: React.FC = () => { }); setSubmitted(action === 'approve' ? 'approved' : 'feedback'); } catch (err) { - setGithubActionError(err instanceof Error ? err.message : 'Failed to submit PR review'); - setIsGitHubActioning(false); + setPlatformActionError(err instanceof Error ? err.message : 'Failed to submit PR review'); + setIsPlatformActioning(false); } - }, [buildPRReviewPayload, githubOpenPR]); + }, [buildPRReviewPayload, platformOpenPR]); // Double-tap Option/Alt to toggle review destination (PR mode only) useEffect(() => { @@ -731,9 +740,9 @@ const ReviewApp: React.FC = () => { const now = Date.now(); if (now - lastAltUp < DOUBLE_TAP_WINDOW) { setReviewDestination(prev => { - const next = prev === 'github' ? 'agent' : 'github'; + const next = prev === 'platform' ? 'agent' : 'platform'; storage.setItem('plannotator-review-dest', next); - setGithubActionError(null); + setPlatformActionError(null); return next; }); lastAltUp = 0; @@ -756,35 +765,35 @@ const ReviewApp: React.FC = () => { if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return; // If the GitHub comment dialog is open, Cmd+Enter submits it - if (githubCommentDialog) { - if (submitted || isGitHubActioning) return; - const isApproveAction = githubCommentDialog.action === 'approve'; - const canSubmit = isApproveAction || totalAnnotationCount > 0 || githubGeneralComment.trim(); + if (platformCommentDialog) { + if (submitted || isPlatformActioning) return; + const isApproveAction = platformCommentDialog.action === 'approve'; + const canSubmit = isApproveAction || totalAnnotationCount > 0 || platformGeneralComment.trim(); if (!canSubmit) return; e.preventDefault(); - const { action } = githubCommentDialog; - setGithubCommentDialog(null); - handleGitHubAction(action, githubGeneralComment); + const { action } = platformCommentDialog; + setPlatformCommentDialog(null); + handlePlatformAction(action, platformGeneralComment); return; } const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (showExportModal || showNoAnnotationsDialog || showApproveWarning) return; - if (submitted || isSendingFeedback || isApproving || isGitHubActioning) return; + if (submitted || isSendingFeedback || isApproving || isPlatformActioning) return; if (!origin) return; // Demo mode e.preventDefault(); - if (githubMode) { + if (platformMode) { // GitHub mode: No annotations → Approve on GitHub, otherwise → Post Review - const isOwnPR = !!ghUser && prMetadata?.author === ghUser; + const isOwnPR = !!platformUser && prMetadata?.author === platformUser; if (totalAnnotationCount === 0 && !isOwnPR) { - setGithubGeneralComment(''); - setGithubCommentDialog({ action: 'approve' }); + setPlatformGeneralComment(''); + setPlatformCommentDialog({ action: 'approve' }); } else { - setGithubGeneralComment(''); - setGithubCommentDialog({ action: 'comment' }); + setPlatformGeneralComment(''); + setPlatformCommentDialog({ action: 'comment' }); } } else { // Agent mode: No annotations → Approve, otherwise → Send Feedback @@ -800,10 +809,10 @@ const ReviewApp: React.FC = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [ showExportModal, showNoAnnotationsDialog, showApproveWarning, - githubCommentDialog, githubGeneralComment, - submitted, isSendingFeedback, isApproving, isGitHubActioning, - origin, githubMode, ghUser, prMetadata, totalAnnotationCount, - handleApprove, handleSendFeedback, handleGitHubAction + platformCommentDialog, platformGeneralComment, + submitted, isSendingFeedback, isApproving, isPlatformActioning, + origin, platformMode, platformUser, prMetadata, totalAnnotationCount, + handleApprove, handleSendFeedback, handlePlatformAction ]); if (isLoading) { @@ -841,14 +850,14 @@ const ReviewApp: React.FC = () => { {prMetadata ? ( <> | - {prMetadata.owner}/{prMetadata.repo} + {displayRepo} { title={prMetadata.title} > - #{prMetadata.number} {prMetadata.title} + {mrNumberLabel} {prMetadata.title} ) : repoInfo ? ( @@ -928,12 +937,12 @@ const ReviewApp: React.FC = () => { @@ -1036,10 +1045,10 @@ const ReviewApp: React.FC = () => {
{/* Tooltip: own PR warning OR annotations-lost warning */} - {githubMode && ghUser && prMetadata?.author === ghUser ? ( + {platformMode && platformUser && prMetadata?.author === platformUser ? (
- You can't approve your own pull request on GitHub. + You can't approve your own {mrLabel === 'MR' ? 'merge request' : 'pull request'} on {platformLabel}.
- ) : !githubMode && totalAnnotationCount > 0 ? ( + ) : !platformMode && totalAnnotationCount > 0 ? (
@@ -1365,10 +1374,10 @@ const ReviewApp: React.FC = () => { submitted={submitted} title={submitted === 'approved' ? 'Changes Approved' : 'Feedback Sent'} subtitle={ - githubMode + platformMode ? submitted === 'approved' - ? 'Your approval was submitted to GitHub.' - : 'Your feedback was submitted to GitHub.' + ? `Your approval was submitted to ${platformLabel}.` + : `Your feedback was submitted to ${platformLabel}.` : submitted === 'approved' ? `${origin === 'claude-code' ? 'Claude Code' : origin === 'opencode' ? 'OpenCode' : origin === 'pi' ? 'Pi' : 'Your agent'} will proceed with the changes.` : `${origin === 'claude-code' ? 'Claude Code' : origin === 'opencode' ? 'OpenCode' : origin === 'pi' ? 'Pi' : 'Your agent'} will address your review feedback.` @@ -1380,19 +1389,19 @@ const ReviewApp: React.FC = () => { {/* GitHub general comment dialog */} - {githubCommentDialog && ( + {platformCommentDialog && (

- {githubCommentDialog.action === 'approve' ? 'Approve PR' : 'Post Review Comment'} + {platformCommentDialog.action === 'approve' ? `Approve ${mrLabel}` : 'Post Review Comment'}

Add a general comment to the review (optional).