- {t('session.newWorktree.localBranches')}
+ {hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
{existingBranchRankedGroups.otherLocal.map((branch) => (
@@ -1399,10 +1923,10 @@ export function NewWorktreeDialog({
)}
- {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
+ {existingBranchRankedGroups.otherRemote.length > 0 && (
- {t('session.newWorktree.remoteBranches')}
+ {hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
{existingBranchRankedGroups.otherRemote.map((branch) => (
@@ -1437,11 +1961,47 @@ export function NewWorktreeDialog({
) : (
-
-
+
+
{t('session.newWorktree.branchName')}
- {startFromIssueButtons}
+ {mode === 'new-branch' && (showGitHubStartFrom || showGitLabStartFrom || showGiteaStartFrom) && (
+
+ {showGitHubStartFrom && (
+ setGithubDialogOpen(true)}
+ className="gap-1.5 h-7"
+ >
+
+ {newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
+
+ )}
+ {showGitLabStartFrom && (
+ setGitlabDialogOpen(true)}
+ className="gap-1.5 h-7"
+ >
+
+ {newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitLabIssueMr')}
+
+ )}
+ {showGiteaStartFrom && (
+ setGiteaDialogOpen(true)}
+ className="gap-1.5 h-7"
+ >
+
+ {newBranchState.linkedGiteaIssue || newBranchState.linkedGiteaPr ? t('session.newWorktree.actions.change') : t('session.giteaIntegration.title')}
+
+ )}
+
+ )}
setValidation(prev => ({ ...prev, touched: true }))}
placeholder={t('session.newWorktree.branchNamePlaceholder')}
- disabled={!!newBranchState.linkedPr}
+ disabled={!!newBranchState.linkedPr || !!newBranchState.linkedGitLabMr || !!newBranchState.linkedGiteaPr}
className={cn(
'h-8',
validation.touched && validation.branchError && 'border-destructive',
- newBranchState.linkedPr && 'bg-muted text-muted-foreground'
+ (newBranchState.linkedPr || newBranchState.linkedGitLabMr || newBranchState.linkedGiteaPr) && 'bg-muted text-muted-foreground'
)}
/>
{newBranchState.linkedPr && (
@@ -1472,6 +2035,22 @@ export function NewWorktreeDialog({
)}
+ {newBranchState.linkedGitLabMr && (
+
+
+
+ {t('session.newWorktree.usingMrBranch', { branch: newBranchState.linkedGitLabMr.sourceBranch })}
+
+
+ )}
+ {newBranchState.linkedGiteaPr && (
+
+
+
+ {t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedGiteaPr.sourceBranch })}
+
+
+ )}
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
@@ -1480,14 +2059,19 @@ export function NewWorktreeDialog({
)}
- {newBranchState.linkedLinearIssue && (
+ {newBranchState.linkedGitLabIssue && !newBranchState.linkedGitLabMr && (
- {t('session.newWorktree.fromLinearIssue', {
- identifier: newBranchState.linkedLinearIssue.identifier,
- title: newBranchState.linkedLinearIssue.title,
- })}
+ {t('session.newWorktree.fromIssue', { number: newBranchState.linkedGitLabIssue.number, title: newBranchState.linkedGitLabIssue.title })}
+
+
+ )}
+ {newBranchState.linkedGiteaIssue && !newBranchState.linkedGiteaPr && (
+
+
+
+ {t('session.newWorktree.fromIssue', { number: newBranchState.linkedGiteaIssue.number, title: newBranchState.linkedGiteaIssue.title })}
)}
@@ -1549,8 +2133,8 @@ export function NewWorktreeDialog({
/>
- {/* Source Branch - Only for New Branch mode, hide when PR is selected */}
- {mode === 'new-branch' && !newBranchState.linkedPr && (
+ {/* Source Branch - Only for New Branch mode, hide when a linked PR/MR is selected */}
+ {mode === 'new-branch' && !newBranchState.linkedPr && !newBranchState.linkedGitLabMr && !newBranchState.linkedGiteaPr && (
{t('session.newWorktree.sourceBranch')}
@@ -1571,7 +2155,7 @@ export function NewWorktreeDialog({
{t('session.newWorktree.newBranchFromSource', { source: newBranchState.sourceBranch })}
)}
-
+
{/* Mobile Source Branch Picker Overlay */}
)}
- {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
+ {sourceBranchRankedGroups.otherLocal.length > 0 && (
- {t('session.newWorktree.localBranches')}
+ {hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
{sourceBranchRankedGroups.otherLocal.map((branch) => (
@@ -1655,10 +2239,10 @@ export function NewWorktreeDialog({
)}
- {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
+ {sourceBranchRankedGroups.otherRemote.length > 0 && (
- {t('session.newWorktree.remoteBranches')}
+ {hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
{sourceBranchRankedGroups.otherRemote.map((branch) => (
@@ -1689,23 +2273,18 @@ export function NewWorktreeDialog({
)}
{/* Linked Item Preview - Two row minimal display */}
- {(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
+ {(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr || newBranchState.linkedGiteaIssue || newBranchState.linkedGiteaPr) && mode === 'new-branch' && (
{/* Row 1: Type, number, title, actions */}
-
-
- {newBranchState.linkedLinearIssue && (
-
- {newBranchState.linkedLinearIssue.identifier}
-
- )}
+ {newBranchState.linkedIssue || newBranchState.linkedPr ? (
+
+ ) : newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? (
+
+ ) : (
+
+ )}
+
{newBranchState.linkedIssue && (
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
@@ -1716,13 +2295,33 @@ export function NewWorktreeDialog({
{t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })}
)}
-
+ {newBranchState.linkedGitLabIssue && (
+
+ {t('session.newWorktree.issueNumber', { number: newBranchState.linkedGitLabIssue.number })}
+
+ )}
+ {newBranchState.linkedGitLabMr && (
+
+ {t('session.newWorktree.mrNumber', { number: newBranchState.linkedGitLabMr.number })}
+
+ )}
+ {newBranchState.linkedGiteaIssue && (
+
+ {t('session.newWorktree.issueNumber', { number: newBranchState.linkedGiteaIssue.number })}
+
+ )}
+ {newBranchState.linkedGiteaPr && (
+
+ {t('session.newWorktree.prNumber', { number: newBranchState.linkedGiteaPr.number })}
+
+ )}
+
- {newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
+ {newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title || newBranchState.linkedGiteaIssue?.title || newBranchState.linkedGiteaPr?.title}
-
+
-
+
-
- {/* Row 2: PR branch info + diff indicator */}
+
+ {/* Row 2: PR/MR branch info + diff indicator */}
{newBranchState.linkedPr && (
@@ -1752,6 +2351,30 @@ export function NewWorktreeDialog({
)}
)}
+ {newBranchState.linkedGitLabMr && (
+
+
+ {newBranchState.linkedGitLabMr.sourceBranch}
+
+ {newBranchState.includeGitLabMrDiff && (
+
+ {t('session.newWorktree.includeDiffBadge')}
+
+ )}
+
+ )}
+ {newBranchState.linkedGiteaPr && (
+
+
+ {newBranchState.linkedGiteaPr.sourceBranch}
+
+ {newBranchState.includeGiteaPrDiff && (
+
+ {t('session.newWorktree.includeDiffBadge')}
+
+ )}
+
+ )}
)}
@@ -1765,7 +2388,7 @@ export function NewWorktreeDialog({
{t('session.newWorktree.title')}
-
+
{/* Mode Selection - using SortableTabsStrip */}
)}
- {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
+ {existingBranchRankedGroups.otherLocal.length > 0 && (
<>
-
+ {hasExistingBranchQuery && }
+
{existingBranchRankedGroups.otherLocal.map((branch) => (
)}
- {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
+ {existingBranchRankedGroups.otherRemote.length > 0 && (
<>
- {existingBranchRankedGroups.otherLocal.length > 0 && (
+ {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && (
)}
-
+
{existingBranchRankedGroups.otherRemote.map((branch) => (
) : (
-
-
+
+
{t('session.newWorktree.branchName')}
- {startFromIssueButtons}
+ {mode === 'new-branch' && (showGitHubStartFrom || showGitLabStartFrom || showGiteaStartFrom) && (
+
+ {showGitHubStartFrom && (
+ setGithubDialogOpen(true)}
+ className="gap-1.5 h-7"
+ >
+
+ {newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
+
+ )}
+ {showGitLabStartFrom && (
+ setGitlabDialogOpen(true)}
+ className="gap-1.5 h-7"
+ >
+
+ {newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitLabIssueMr')}
+
+ )}
+ {showGiteaStartFrom && (
+ setGiteaDialogOpen(true)}
+ className="gap-1.5 h-7"
+ >
+
+ {newBranchState.linkedGiteaIssue || newBranchState.linkedGiteaPr ? t('session.newWorktree.actions.change') : t('session.giteaIntegration.title')}
+
+ )}
+
+ )}
setValidation(prev => ({ ...prev, touched: true }))}
placeholder={t('session.newWorktree.branchNamePlaceholder')}
- disabled={!!newBranchState.linkedPr}
+ disabled={!!newBranchState.linkedPr || !!newBranchState.linkedGitLabMr || !!newBranchState.linkedGiteaPr}
className={cn(
'h-8',
validation.touched && validation.branchError && 'border-destructive',
- newBranchState.linkedPr && 'bg-muted text-muted-foreground'
+ (newBranchState.linkedPr || newBranchState.linkedGitLabMr || newBranchState.linkedGiteaPr) && 'bg-muted text-muted-foreground'
)}
/>
{newBranchState.linkedPr && (
@@ -1957,6 +2620,22 @@ export function NewWorktreeDialog({
)}
+ {newBranchState.linkedGitLabMr && (
+
+
+
+ {t('session.newWorktree.usingMrBranch', { branch: newBranchState.linkedGitLabMr.sourceBranch })}
+
+
+ )}
+ {newBranchState.linkedGiteaPr && (
+
+
+
+ {t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedGiteaPr.sourceBranch })}
+
+
+ )}
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
@@ -1965,14 +2644,19 @@ export function NewWorktreeDialog({
)}
- {newBranchState.linkedLinearIssue && (
+ {newBranchState.linkedGitLabIssue && !newBranchState.linkedGitLabMr && (
- {t('session.newWorktree.fromLinearIssue', {
- identifier: newBranchState.linkedLinearIssue.identifier,
- title: newBranchState.linkedLinearIssue.title,
- })}
+ {t('session.newWorktree.fromIssue', { number: newBranchState.linkedGitLabIssue.number, title: newBranchState.linkedGitLabIssue.title })}
+
+
+ )}
+ {newBranchState.linkedGiteaIssue && !newBranchState.linkedGiteaPr && (
+
+
+
+ {t('session.newWorktree.fromIssue', { number: newBranchState.linkedGiteaIssue.number, title: newBranchState.linkedGiteaIssue.title })}
)}
@@ -2034,8 +2718,8 @@ export function NewWorktreeDialog({
/>
- {/* Source Branch - Only for New Branch mode, hide when PR is selected */}
- {mode === 'new-branch' && !newBranchState.linkedPr && (
+ {/* Source Branch - Only for New Branch mode, hide when a linked PR/MR is selected */}
+ {mode === 'new-branch' && !newBranchState.linkedPr && !newBranchState.linkedGitLabMr && !newBranchState.linkedGiteaPr && (
{t('session.newWorktree.sourceBranch')}
@@ -2092,9 +2776,10 @@ export function NewWorktreeDialog({
)}
- {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
+ {sourceBranchRankedGroups.otherLocal.length > 0 && (
<>
-
+ {hasSourceBranchQuery && }
+
{sourceBranchRankedGroups.otherLocal.map((branch) => (
)}
- {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
+ {sourceBranchRankedGroups.otherRemote.length > 0 && (
<>
- {sourceBranchRankedGroups.otherLocal.length > 0 && (
+ {(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && (
)}
-
+
{sourceBranchRankedGroups.otherRemote.map((branch) => (
{/* Row 1: Type, number, title, actions */}
-
-
- {newBranchState.linkedLinearIssue && (
-
- {newBranchState.linkedLinearIssue.identifier}
-
+ {newBranchState.linkedIssue || newBranchState.linkedPr ? (
+
+ ) : newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? (
+
+ ) : (
+
)}
+
{newBranchState.linkedIssue && (
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
@@ -2174,13 +2854,33 @@ export function NewWorktreeDialog({
{t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })}
)}
-
+ {newBranchState.linkedGitLabIssue && (
+
+ {t('session.newWorktree.issueNumber', { number: newBranchState.linkedGitLabIssue.number })}
+
+ )}
+ {newBranchState.linkedGitLabMr && (
+
+ {t('session.newWorktree.mrNumber', { number: newBranchState.linkedGitLabMr.number })}
+
+ )}
+ {newBranchState.linkedGiteaIssue && (
+
+ {t('session.newWorktree.issueNumber', { number: newBranchState.linkedGiteaIssue.number })}
+
+ )}
+ {newBranchState.linkedGiteaPr && (
+
+ {t('session.newWorktree.prNumber', { number: newBranchState.linkedGiteaPr.number })}
+
+ )}
+
- {newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
+ {newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title || newBranchState.linkedGiteaIssue?.title || newBranchState.linkedGiteaPr?.title}
-
+
-
+
-
- {/* Row 2: PR branch info + diff indicator */}
+
+ {/* Row 2: PR/MR branch info + diff indicator */}
{newBranchState.linkedPr && (
@@ -2210,6 +2910,30 @@ export function NewWorktreeDialog({
)}
)}
+ {newBranchState.linkedGitLabMr && (
+
+
+ {newBranchState.linkedGitLabMr.sourceBranch}
+
+ {newBranchState.includeGitLabMrDiff && (
+
+ {t('session.newWorktree.includeDiffBadge')}
+
+ )}
+
+ )}
+ {newBranchState.linkedGiteaPr && (
+
+
+ {newBranchState.linkedGiteaPr.sourceBranch}
+
+ {newBranchState.includeGiteaPrDiff && (
+
+ {t('session.newWorktree.includeDiffBadge')}
+
+ )}
+
+ )}
)}
@@ -2227,7 +2951,7 @@ export function NewWorktreeDialog({
>
)}
-
+
-
+
+
>
);
diff --git a/packages/ui/src/components/views/GitLabMrView.tsx b/packages/ui/src/components/views/GitLabMrView.tsx
new file mode 100644
index 00000000..fd762188
--- /dev/null
+++ b/packages/ui/src/components/views/GitLabMrView.tsx
@@ -0,0 +1,891 @@
+import React from 'react';
+import { useShallow } from 'zustand/react/shallow';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { ScrollShadow } from '@/components/ui/ScrollShadow';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
+import { GitLabIssuesSection } from '@/components/views/git/GitLabIssuesSection';
+import { ForgeEntityDetailView } from '@/components/views/forge';
+import { buildForgeProvider } from '@/lib/forge';
+import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
+import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
+import { useGitStatus, useGitStore } from '@/stores/useGitStore';
+import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { openExternalUrl } from '@/lib/url';
+import type { GitLabMergeRequestContextResult, GitLabMergeRequestSummary, GitLabRepoRef } from '@/lib/api/types';
+import { useI18n } from '@/lib/i18n';
+import { toast } from '@/components/ui';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Input } from '@/components/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Textarea } from '@/components/ui/textarea';
+
+const mrStateColor = (state: string): string => {
+ switch (state) {
+ case 'merged':
+ return 'var(--pr-merged)';
+ case 'closed':
+ return 'var(--pr-closed)';
+ default:
+ return 'var(--pr-open)';
+ }
+};
+
+const mrAuthorLabel = (mr: GitLabMergeRequestSummary): string =>
+ mr.author?.name?.trim() || mr.author?.username || '';
+
+const draftBadgeClass =
+ 'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground';
+
+/**
+ * Read-only GitLab merge request surface for the context panel. Resolves the
+ * same repository context GitView uses (effective directory + current branch
+ * from the shared git stores) and renders the branch's merge request plus the
+ * repository's open merge requests. v1 is intentionally read-only: no create,
+ * update, or merge actions.
+ */
+export const GitLabMrView: React.FC = () => {
+ const { t } = useI18n();
+ const { git, gitlab } = useRuntimeAPIs();
+ const currentDirectory = useEffectiveDirectory();
+ const status = useGitStatus(currentDirectory ?? null);
+ const { ensureAll } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll })));
+
+ const gitlabAuthStatus = useGitLabAuthStore((state) => state.status);
+ const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked);
+ const refreshGitLabStatus = useGitLabAuthStore((state) => state.refreshStatus);
+
+ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
+ const setSettingsPage = useUIStore((state) => state.setSettingsPage);
+
+ React.useEffect(() => {
+ if (!currentDirectory || !git) {
+ return;
+ }
+ void ensureAll(currentDirectory, git);
+ }, [currentDirectory, ensureAll, git]);
+
+ // Settle the connection state exactly once; the store dedupes in-flight
+ // refreshes so remounts never pile up status requests.
+ React.useEffect(() => {
+ if (gitlabAuthChecked) {
+ return;
+ }
+ void refreshGitLabStatus(gitlab);
+ }, [gitlab, gitlabAuthChecked, refreshGitLabStatus]);
+
+ const currentBranch = status?.current ?? null;
+ const connected = gitlabAuthChecked ? gitlabAuthStatus?.connected === true : null;
+
+ const openGitLabSettings = React.useCallback(() => {
+ setSettingsPage('git');
+ setSettingsDialogOpen(true);
+ }, [setSettingsDialogOpen, setSettingsPage]);
+
+ // Local tab selection between the merge-request and issues surfaces. Not
+ // persisted: reopening the panel always lands on merge requests.
+ const [activeTab, setActiveTab] = React.useState<'mr' | 'issues'>('mr');
+
+ // ---- Current-branch merge request --------------------------------------
+
+ const [branchMr, setBranchMr] = React.useState
(null);
+ const [branchMrLoading, setBranchMrLoading] = React.useState(false);
+ const [branchMrError, setBranchMrError] = React.useState(null);
+ const [retryToken, setRetryToken] = React.useState(0);
+ const [repoRef, setRepoRef] = React.useState(null);
+
+ const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
+
+ React.useEffect(() => {
+ if (!currentDirectory || !currentBranch || !connected || !gitlab?.mrsList) {
+ return;
+ }
+ let cancelled = false;
+ setBranchMrLoading(true);
+ setBranchMrError(null);
+ // Re-resolving the repo context invalidates the previously fetched branch
+ // list so a stale repo's branches never leak into the create form.
+ setRepoRef(null);
+ setBranches([]);
+ setDefaultBranch(null);
+ void gitlab
+ .mrsList(currentDirectory, { sourceBranch: currentBranch })
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ const candidates = result.mrs ?? [];
+ // Prefer the open MR for the branch; fall back to a merged one so a
+ // just-merged branch still shows its request instead of nothing.
+ const matching =
+ candidates.find((mr) => mr.state === 'opened')
+ ?? candidates.find((mr) => mr.state === 'merged')
+ ?? null;
+ setBranchMr(matching);
+ setRepoRef(result.repo ?? null);
+ })
+ .catch((error) => {
+ if (!cancelled) {
+ setBranchMrError(error instanceof Error ? error.message : String(error));
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setBranchMrLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [connected, currentBranch, currentDirectory, gitlab, retryToken]);
+
+ // ---- Open merge requests in this repository ----------------------------
+
+ const [openMrs, setOpenMrs] = React.useState([]);
+ const [listPage, setListPage] = React.useState(1);
+ const [listHasMore, setListHasMore] = React.useState(false);
+ const [listLoading, setListLoading] = React.useState(false);
+ const [listLoadingMore, setListLoadingMore] = React.useState(false);
+ const [listError, setListError] = React.useState(null);
+
+ React.useEffect(() => {
+ if (!currentDirectory || !connected || !gitlab?.mrsList) {
+ return;
+ }
+ let cancelled = false;
+ setListLoading(true);
+ setListError(null);
+ void gitlab
+ .mrsList(currentDirectory, { page: 1 })
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ setOpenMrs(result.mrs ?? []);
+ setListPage(result.page ?? 1);
+ setListHasMore(Boolean(result.hasMore));
+ })
+ .catch((error) => {
+ if (!cancelled) {
+ setListError(error instanceof Error ? error.message : String(error));
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setListLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [connected, currentDirectory, gitlab, retryToken]);
+
+ const loadMore = React.useCallback(async () => {
+ if (!currentDirectory || !connected || !gitlab?.mrsList) {
+ return;
+ }
+ if (listLoadingMore || listLoading || !listHasMore) {
+ return;
+ }
+ setListLoadingMore(true);
+ try {
+ const next = await gitlab.mrsList(currentDirectory, { page: listPage + 1 });
+ setOpenMrs((previous) => [...previous, ...(next.mrs ?? [])]);
+ setListPage(next.page ?? listPage + 1);
+ setListHasMore(Boolean(next.hasMore));
+ } catch (error) {
+ setListError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setListLoadingMore(false);
+ }
+ }, [connected, currentDirectory, gitlab, listHasMore, listLoading, listLoadingMore, listPage]);
+
+ // ---- Inline MR context (current-branch MR only) ------------------------
+
+ const [contextOpen, setContextOpen] = React.useState(false);
+ const [contextResult, setContextResult] = React.useState(null);
+ const [contextLoading, setContextLoading] = React.useState(false);
+
+ // A different branch MR invalidates any previously loaded context.
+ React.useEffect(() => {
+ setContextOpen(false);
+ setContextResult(null);
+ }, [branchMr?.number]);
+
+ // A different branch MR invalidates the update/merge transient state so the
+ // previous MR's edit form, squash flag, and in-flight requests don't leak.
+ React.useEffect(() => {
+ setUpdateOpen(false);
+ setEditTitle('');
+ setEditDescription('');
+ setEditDescriptionKnown(false);
+ setEditDescriptionLoading(false);
+ setUpdating(false);
+ setMergeSquash(false);
+ setMerging(false);
+ }, [branchMr?.number]);
+
+ const toggleContext = React.useCallback(async (mr: GitLabMergeRequestSummary) => {
+ if (!currentDirectory || !gitlab?.mrContext) {
+ return;
+ }
+ if (contextOpen) {
+ setContextOpen(false);
+ setContextResult(null);
+ return;
+ }
+ setContextOpen(true);
+ setContextLoading(true);
+ try {
+ const result = await gitlab.mrContext(currentDirectory, mr.number, { includeDiff: false });
+ setContextResult(result.connected === false ? null : result);
+ } catch {
+ setContextResult(null);
+ } finally {
+ setContextLoading(false);
+ }
+ }, [contextOpen, currentDirectory, gitlab]);
+
+ // Shared rich view for the branch MR's detail (title/body/chips/commits/
+ // files/timeline). Owns its own fetching through the forge facade.
+ const mrProvider = React.useMemo(() => (gitlab ? buildForgeProvider('gitlab', { gitlab }) : null), [gitlab]);
+
+ // ---- Create / update / merge actions -----------------------------------
+
+ const [createTitle, setCreateTitle] = React.useState('');
+ const [createDescription, setCreateDescription] = React.useState('');
+ const [createSourceBranch, setCreateSourceBranch] = React.useState(currentBranch ?? '');
+ const [createTargetBranch, setCreateTargetBranch] = React.useState('main');
+ const [createRemoveSourceBranch, setCreateRemoveSourceBranch] = React.useState(false);
+ const [creating, setCreating] = React.useState(false);
+ const createTargetTouchedRef = React.useRef(false);
+
+ // Repository branches for the source/target dropdowns, fetched lazily once
+ // the create form is visible.
+ const [branches, setBranches] = React.useState([]);
+ const [defaultBranch, setDefaultBranch] = React.useState(null);
+ const [branchesLoading, setBranchesLoading] = React.useState(false);
+
+ // The current branch is only known after git status resolves, so adopt it as
+ // the default source branch when it arrives without clobbering a pick.
+ React.useEffect(() => {
+ if (currentBranch) {
+ setCreateSourceBranch((previous) => previous || currentBranch);
+ }
+ }, [currentBranch]);
+
+ // The default target branch is the target of the repository's previously
+ // listed open MRs when available; otherwise fall back to main.
+ const defaultTargetBranch = React.useMemo(
+ () => openMrs.find((mr) => mr.targetBranch)?.targetBranch ?? 'main',
+ [openMrs],
+ );
+
+ // Adopt the repository's target branch default once the open-MR list
+ // resolves, unless the user has already typed into the field.
+ React.useEffect(() => {
+ if (branchMrLoading || branchMr || createTargetTouchedRef.current) {
+ return;
+ }
+ setCreateTargetBranch(defaultBranch ?? defaultTargetBranch);
+ }, [branchMr, branchMrLoading, defaultBranch, defaultTargetBranch]);
+
+ // The source dropdown must always offer the picked/current branch, even
+ // before the branch list resolves.
+ const sourceBranchOptions = React.useMemo(() => {
+ if (!createSourceBranch) {
+ return branches;
+ }
+ return branches.includes(createSourceBranch) ? branches : [createSourceBranch, ...branches];
+ }, [branches, createSourceBranch]);
+
+ // A merge request cannot target its own source branch once there is more
+ // than one branch to choose from.
+ const targetBranchOptions = React.useMemo(
+ () => (branches.length >= 2 ? branches.filter((branch) => branch !== createSourceBranch) : branches),
+ [branches, createSourceBranch],
+ );
+
+ // Fetch the repository's branches lazily once the create form is visible so
+ // the source/target dropdowns can offer real values. Failure surfaces as a
+ // toast and leaves the dropdowns on the current-branch fallback.
+ React.useEffect(() => {
+ if (!repoRef || branchMr || !connected || !gitlab?.repoBranches) {
+ return;
+ }
+ let cancelled = false;
+ setBranchesLoading(true);
+ void gitlab
+ .repoBranches(repoRef.namespace, repoRef.project)
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ setBranches(result.branches ?? []);
+ setDefaultBranch(result.defaultBranch ?? null);
+ })
+ .catch((error) => {
+ if (cancelled) {
+ return;
+ }
+ setBranches([]);
+ setDefaultBranch(null);
+ toast.error(t('contextPanel.gitlabMr.error.loadFailed'), {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setBranchesLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [branchMr, connected, gitlab, repoRef, t]);
+
+ const [updateOpen, setUpdateOpen] = React.useState(false);
+ const [editTitle, setEditTitle] = React.useState('');
+ const [editDescription, setEditDescription] = React.useState('');
+ const [editDescriptionKnown, setEditDescriptionKnown] = React.useState(false);
+ const [editDescriptionLoading, setEditDescriptionLoading] = React.useState(false);
+ const [updating, setUpdating] = React.useState(false);
+
+ const [mergeSquash, setMergeSquash] = React.useState(false);
+ const [merging, setMerging] = React.useState(false);
+
+ const createMr = React.useCallback(async () => {
+ if (!currentDirectory || !currentBranch || !gitlab?.mrCreate) {
+ return;
+ }
+ const targetBranch = createTargetBranch.trim();
+ if (!targetBranch) {
+ return;
+ }
+ setCreating(true);
+ try {
+ const created = await gitlab.mrCreate({
+ directory: currentDirectory,
+ title: createTitle.trim() || currentBranch,
+ sourceBranch: createSourceBranch,
+ targetBranch,
+ ...(createDescription.trim() ? { description: createDescription } : {}),
+ ...(createRemoveSourceBranch ? { removeSourceBranch: true } : {}),
+ });
+ toast.success(t('contextPanel.gitlabMr.createMr.toast.created'));
+ // Show the created MR immediately and refresh both the branch MR and
+ // the open list so the card flips to the opened state.
+ setBranchMr(created);
+ setRetryToken((value) => value + 1);
+ // Clear the form.
+ setCreateTitle('');
+ setCreateDescription('');
+ setCreateRemoveSourceBranch(false);
+ createTargetTouchedRef.current = false;
+ setCreateTargetBranch(defaultBranch ?? defaultTargetBranch);
+ } catch (error) {
+ toast.error(t('contextPanel.gitlabMr.createMr.toast.createFailed'), {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ setCreating(false);
+ }
+ }, [createDescription, createRemoveSourceBranch, createSourceBranch, createTargetBranch, createTitle, currentBranch, currentDirectory, defaultBranch, defaultTargetBranch, gitlab, t]);
+
+ const toggleUpdate = React.useCallback(async () => {
+ if (!branchMr) {
+ return;
+ }
+ if (updateOpen) {
+ setUpdateOpen(false);
+ return;
+ }
+ setUpdateOpen(true);
+ setEditTitle(branchMr.title);
+ const knownBody = contextResult?.mr?.body;
+ if (typeof knownBody === 'string') {
+ setEditDescription(knownBody);
+ setEditDescriptionKnown(true);
+ return;
+ }
+ setEditDescription('');
+ setEditDescriptionKnown(false);
+ if (!currentDirectory || !gitlab?.mrContext) {
+ return;
+ }
+ setEditDescriptionLoading(true);
+ try {
+ const result = await gitlab.mrContext(currentDirectory, branchMr.number, { includeDiff: false });
+ if (result.connected === false) {
+ setEditDescription('');
+ return;
+ }
+ setEditDescription(result.mr?.body ?? '');
+ setEditDescriptionKnown(true);
+ } catch {
+ // Leave the description empty; the title can still be edited.
+ } finally {
+ setEditDescriptionLoading(false);
+ }
+ }, [branchMr, contextResult?.mr?.body, currentDirectory, gitlab, updateOpen]);
+
+ const saveMr = React.useCallback(async () => {
+ if (!currentDirectory || !branchMr || !gitlab?.mrUpdate) {
+ return;
+ }
+ const trimmedTitle = editTitle.trim();
+ if (!trimmedTitle) {
+ return;
+ }
+ setUpdating(true);
+ try {
+ await gitlab.mrUpdate({
+ directory: currentDirectory,
+ number: branchMr.number,
+ title: trimmedTitle,
+ // Only send the description when it was actually loaded so an
+ // unresolved description can never be wiped out by a title-only save.
+ ...(editDescriptionKnown ? { description: editDescription } : {}),
+ });
+ toast.success(t('contextPanel.gitlabMr.updateMr.toast.updated'));
+ setUpdateOpen(false);
+ setRetryToken((value) => value + 1);
+ } catch (error) {
+ toast.error(t('contextPanel.gitlabMr.updateMr.toast.updateFailed'), {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ setUpdating(false);
+ }
+ }, [branchMr, currentDirectory, editDescription, editDescriptionKnown, editTitle, gitlab, t]);
+
+ const mergeMr = React.useCallback(async () => {
+ if (!currentDirectory || !branchMr || !gitlab?.mrMerge) {
+ return;
+ }
+ setMerging(true);
+ try {
+ const result = await gitlab.mrMerge({
+ directory: currentDirectory,
+ number: branchMr.number,
+ ...(mergeSquash ? { squash: true } : {}),
+ });
+ if (result.merged) {
+ toast.success(t('contextPanel.gitlabMr.mergeMr.toast.merged'));
+ } else {
+ toast.error(t('contextPanel.gitlabMr.mergeMr.toast.mergeFailed'), {
+ ...(result.message ? { description: result.message } : {}),
+ });
+ }
+ // Refresh the branch MR (flips to the merged state) and the open list.
+ setRetryToken((value) => value + 1);
+ } catch (error) {
+ toast.error(t('contextPanel.gitlabMr.mergeMr.toast.mergeFailed'), {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ setMerging(false);
+ }
+ }, [branchMr, currentDirectory, gitlab, mergeSquash, t]);
+
+ // ---- Render ------------------------------------------------------------
+
+ if (!currentDirectory) {
+ return (
+
+
+
{t('contextPanel.gitlabMr.title')}
+
{t('contextPanel.gitlabMr.empty.noActiveProject')}
+
+ );
+ }
+
+ if (connected === null) {
+ return (
+
+
+
{t('contextPanel.gitlabMr.loading')}
+
+ );
+ }
+
+ if (connected === false) {
+ return (
+
+
+
{t('contextPanel.gitlabMr.error.notConnected')}
+
+ {t('contextPanel.gitlabMr.actions.openSettings')}
+
+
+ );
+ }
+
+ const branchMrStateLabel = branchMr
+ ? branchMr.state === 'merged'
+ ? t('contextPanel.gitlabMr.state.merged')
+ : branchMr.state === 'closed'
+ ? t('contextPanel.gitlabMr.state.closed')
+ : t('contextPanel.gitlabMr.state.opened')
+ : '';
+ const branchMrAuthor = branchMr ? mrAuthorLabel(branchMr) : '';
+
+ return (
+
+
+
+ setActiveTab(tabId as 'mr' | 'issues')}
+ layoutMode="fit"
+ variant="active-pill"
+ activePillButtonClassName="h-7"
+ />
+
+
+ {activeTab === 'mr' ? (
+ <>
+
+
{t('contextPanel.gitlabMr.title')}
+
{t('contextPanel.gitlabMr.listSectionTitle')}
+
+
+ {/* Current-branch merge request */}
+
+ {t('contextPanel.gitlabMr.branchSectionTitle')}
+
+ {branchMrLoading ? (
+
+
+ {t('contextPanel.gitlabMr.loading')}
+
+ ) : branchMrError ? (
+
+
{t('contextPanel.gitlabMr.error.loadFailed')}
+
{branchMrError}
+
+ {t('contextPanel.preview.actions.retry')}
+
+
+ ) : branchMr ? (
+
+
+
+ !{branchMr.number} {branchMr.title}
+
+
+ {branchMr.draft ? (
+ {t('contextPanel.gitlabMr.draft')}
+ ) : null}
+
+
+ {branchMrStateLabel}
+
+ {branchMr.sourceBranch} → {branchMr.targetBranch}
+
+ {branchMrAuthor ? (
+
{branchMrAuthor}
+ ) : null}
+
+
+
+
+
+
+ {t('contextPanel.gitlabMr.openInGitLab')}
+
+
+
void toggleContext(branchMr)}
+ disabled={contextLoading}
+ >
+ {contextLoading ? (
+
+ ) : contextOpen ? (
+
+ ) : (
+
+ )}
+ {contextOpen ? t('contextPanel.gitlabMr.hideContext') : t('contextPanel.gitlabMr.loadContext')}
+
+ {branchMr.state === 'opened' ? (
+ <>
+
void toggleUpdate()}
+ disabled={updating}
+ >
+
+ {t('contextPanel.gitlabMr.updateMr.toggle')}
+
+
setMergeSquash((value) => !value)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setMergeSquash((value) => !value);
+ }
+ }}
+ >
+ setMergeSquash(next)}
+ ariaLabel={t('contextPanel.gitlabMr.mergeMr.squash')}
+ />
+ {t('contextPanel.gitlabMr.mergeMr.squash')}
+
+
void mergeMr()}
+ disabled={merging || updating}
+ >
+ {merging ? : }
+ {merging ? t('contextPanel.gitlabMr.mergeMr.merging') : t('contextPanel.gitlabMr.mergeMr.action')}
+
+ >
+ ) : null}
+
+
+ {updateOpen && branchMr.state === 'opened' ? (
+
+
+ {t('contextPanel.gitlabMr.createMr.titleLabel')}
+ setEditTitle(event.target.value)}
+ placeholder={t('contextPanel.gitlabMr.createMr.titlePlaceholder')}
+ />
+
+
+ {t('contextPanel.gitlabMr.createMr.descriptionLabel')}
+ {editDescriptionLoading ? (
+
+
+ {t('contextPanel.gitlabMr.loading')}
+
+ ) : (
+
+
+ void saveMr()}
+ disabled={updating || editDescriptionLoading || !editTitle.trim()}
+ >
+ {updating ? : }
+ {updating ? t('contextPanel.gitlabMr.updateMr.saving') : t('contextPanel.gitlabMr.updateMr.save')}
+
+
+
+ ) : null}
+
+ {contextOpen && mrProvider ? (
+
+
+
+ ) : null}
+
+ ) : currentBranch ? (
+
+
{t('contextPanel.gitlabMr.createMr.title')}
+
+
+ {t('contextPanel.gitlabMr.createMr.sourceBranch')}
+ setCreateSourceBranch(value)}>
+
+ {branchesLoading ? t('contextPanel.gitlabMr.createMr.branchesLoading') : createSourceBranch}
+
+
+ {sourceBranchOptions.map((branch) => (
+ {branch}
+ ))}
+
+
+
+
+
+ {t('contextPanel.gitlabMr.createMr.targetBranch')}
+ {
+ createTargetTouchedRef.current = true;
+ setCreateTargetBranch(value);
+ }}
+ >
+
+ {branchesLoading ? t('contextPanel.gitlabMr.createMr.branchesLoading') : createTargetBranch}
+
+
+ {targetBranchOptions.map((branch) => (
+ {branch}
+ ))}
+
+
+
+
+
+ {t('contextPanel.gitlabMr.createMr.titleLabel')}
+ setCreateTitle(event.target.value)}
+ placeholder={currentBranch}
+ />
+
+
+
+ {t('contextPanel.gitlabMr.createMr.descriptionLabel')}
+
+
+
setCreateRemoveSourceBranch((value) => !value)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setCreateRemoveSourceBranch((value) => !value);
+ }
+ }}
+ >
+ setCreateRemoveSourceBranch(next)}
+ ariaLabel={t('contextPanel.gitlabMr.createMr.removeSourceBranch')}
+ />
+ {t('contextPanel.gitlabMr.createMr.removeSourceBranch')}
+
+
+
+ void createMr()}
+ disabled={creating || !createTargetBranch.trim()}
+ >
+ {creating ? : }
+ {creating ? t('contextPanel.gitlabMr.createMr.submitting') : t('contextPanel.gitlabMr.createMr.submit')}
+
+
+
+ ) : (
+ {t('contextPanel.gitlabMr.noMrForBranch')}
+ )}
+
+
+ {/* Open merge requests in this repository */}
+
+ {t('contextPanel.gitlabMr.openMrTitle')}
+
+ {listLoading ? (
+
+
+ {t('contextPanel.gitlabMr.loading')}
+
+ ) : listError ? (
+
+
{t('contextPanel.gitlabMr.error.loadFailed')}
+
{listError}
+
+ {t('contextPanel.preview.actions.retry')}
+
+
+ ) : openMrs.length === 0 ? (
+ {t('contextPanel.gitlabMr.openMrEmpty')}
+ ) : (
+
+ {openMrs.map((mr) => (
+
+ ))}
+
+ {listHasMore ? (
+
+ void loadMore()} disabled={listLoadingMore}>
+ {listLoadingMore ? (
+
+ ) : null}
+ {t('contextPanel.gitlabMr.loadMore')}
+
+
+ ) : null}
+
+ )}
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+};
diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx
index d5ba4cb2..ef60a836 100644
--- a/packages/ui/src/components/views/GitView.tsx
+++ b/packages/ui/src/components/views/GitView.tsx
@@ -64,6 +64,8 @@ import { InProgressOperationBanner } from './git/InProgressOperationBanner';
import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
import { deriveBaseBranch } from './git/baseBranch';
import { getFreshestPrStatusForBranch, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
+import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus';
+import { useGiteaPrForBranch } from '@/lib/giteaPrStatus';
import { createGitIndexMutationQueue, type GitIndexMutationDirection, type GitIndexMutationQueue } from './git/gitIndexMutationQueue';
import type { GitRemote } from '@/lib/gitApi';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
@@ -326,6 +328,8 @@ export const GitView: React.FC = ({ isActive }) => {
const openContextSurface = useUIStore((state) => state.openContextSurface);
const prStatusBranch = status?.current ?? null;
+ const { mr: gitLabMr } = useGitLabMrForBranch(currentDirectory, prStatusBranch);
+ const { pr: giteaPr } = useGiteaPrForBranch(currentDirectory, prStatusBranch);
const prChipStatus = useGitHubPrStatusStore((state) => {
if (!gitDirectory || !prStatusBranch) {
return null;
@@ -2502,6 +2506,14 @@ export const GitView: React.FC = ({ isActive }) => {
: undefined
}
repositoryRoot={gitDirectory !== currentDirectory ? currentDirectory : undefined}
+ gitLabMr={gitLabMr}
+ onOpenGitLabMr={
+ currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined
+ }
+ giteaPr={giteaPr}
+ onOpenGiteaPr={
+ currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined
+ }
/>
{/* In-progress operation banner */}
diff --git a/packages/ui/src/components/views/GiteaPrView.tsx b/packages/ui/src/components/views/GiteaPrView.tsx
new file mode 100644
index 00000000..2e863b3e
--- /dev/null
+++ b/packages/ui/src/components/views/GiteaPrView.tsx
@@ -0,0 +1,847 @@
+import React from 'react';
+import { useShallow } from 'zustand/react/shallow';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { ScrollShadow } from '@/components/ui/ScrollShadow';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
+import { GiteaIssuesSection } from '@/components/views/git/GiteaIssuesSection';
+import { ForgeEntityDetailView } from '@/components/views/forge';
+import { buildForgeProvider } from '@/lib/forge';
+import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
+import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
+import { useGitStatus, useGitStore } from '@/stores/useGitStore';
+import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { openExternalUrl } from '@/lib/url';
+import type { GiteaPullRequestContextResult, GiteaPullRequestSummary } from '@/lib/api/types';
+import { useI18n } from '@/lib/i18n';
+import { toast } from '@/components/ui';
+import { Input } from '@/components/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Textarea } from '@/components/ui/textarea';
+
+const prStateColor = (state: string): string => {
+ switch (state) {
+ case 'merged':
+ return 'var(--pr-merged)';
+ case 'closed':
+ return 'var(--pr-closed)';
+ default:
+ return 'var(--pr-open)';
+ }
+};
+
+const prAuthorLabel = (pr: GiteaPullRequestSummary): string => pr.author?.username || '';
+
+const draftBadgeClass =
+ 'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground';
+
+/**
+ * Read-only Gitea pull request surface for the context panel. Resolves the
+ * same repository context GitView uses (effective directory + current branch
+ * from the shared git stores) and renders the branch's pull request plus the
+ * repository's open pull requests. Create, update, and merge actions are
+ * offered for the current-branch PR.
+ */
+export const GiteaPrView: React.FC = () => {
+ const { t } = useI18n();
+ const { git, gitea } = useRuntimeAPIs();
+ const currentDirectory = useEffectiveDirectory();
+ const status = useGitStatus(currentDirectory ?? null);
+ const { ensureAll } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll })));
+
+ const giteaAuthStatus = useGiteaAuthStore((state) => state.status);
+ const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked);
+ const refreshGiteaStatus = useGiteaAuthStore((state) => state.refreshStatus);
+
+ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
+ const setSettingsPage = useUIStore((state) => state.setSettingsPage);
+
+ React.useEffect(() => {
+ if (!currentDirectory || !git) {
+ return;
+ }
+ void ensureAll(currentDirectory, git);
+ }, [currentDirectory, ensureAll, git]);
+
+ // Settle the connection state exactly once; the store dedupes in-flight
+ // refreshes so remounts never pile up status requests.
+ React.useEffect(() => {
+ if (giteaAuthChecked) {
+ return;
+ }
+ void refreshGiteaStatus(gitea);
+ }, [gitea, giteaAuthChecked, refreshGiteaStatus]);
+
+ const currentBranch = status?.current ?? null;
+ const connected = giteaAuthChecked ? giteaAuthStatus?.connected === true : null;
+
+ const openGiteaSettings = React.useCallback(() => {
+ setSettingsPage('git');
+ setSettingsDialogOpen(true);
+ }, [setSettingsDialogOpen, setSettingsPage]);
+
+ // Local tab selection between the pull-request and issues surfaces. Not
+ // persisted: reopening the panel always lands on pull requests.
+ const [activeTab, setActiveTab] = React.useState<'pr' | 'issues'>('pr');
+
+ // ---- Current-branch pull request --------------------------------------
+
+ const [branchPr, setBranchPr] = React.useState(null);
+ const [branchPrLoading, setBranchPrLoading] = React.useState(false);
+ const [branchPrError, setBranchPrError] = React.useState(null);
+ const [retryToken, setRetryToken] = React.useState(0);
+ const [repoRef, setRepoRef] = React.useState<{ owner: string; repo: string; url?: string } | null>(null);
+
+ const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
+
+ React.useEffect(() => {
+ if (!currentDirectory || !currentBranch || !connected || !gitea?.prsList) {
+ return;
+ }
+ let cancelled = false;
+ setBranchPrLoading(true);
+ setBranchPrError(null);
+ // Re-resolving the repo context invalidates the previously fetched branch
+ // list so a stale repo's branches never leak into the create form.
+ setRepoRef(null);
+ setBranches([]);
+ setDefaultBranch(null);
+ void gitea
+ .prsList(currentDirectory, { sourceBranch: currentBranch })
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ const candidates = result.prs ?? [];
+ // Prefer the open PR for the branch; fall back to a merged one so a
+ // just-merged branch still shows its request instead of nothing.
+ const matching =
+ candidates.find((pr) => pr.state === 'open')
+ ?? candidates.find((pr) => pr.state === 'merged')
+ ?? null;
+ setBranchPr(matching);
+ setRepoRef(result.repo ?? null);
+ })
+ .catch((error) => {
+ if (!cancelled) {
+ setBranchPrError(error instanceof Error ? error.message : String(error));
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setBranchPrLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [connected, currentBranch, currentDirectory, gitea, retryToken]);
+
+ // ---- Open pull requests in this repository ----------------------------
+
+ const [openPrs, setOpenPrs] = React.useState([]);
+ const [listPage, setListPage] = React.useState(1);
+ const [listHasMore, setListHasMore] = React.useState(false);
+ const [listLoading, setListLoading] = React.useState(false);
+ const [listLoadingMore, setListLoadingMore] = React.useState(false);
+ const [listError, setListError] = React.useState(null);
+
+ React.useEffect(() => {
+ if (!currentDirectory || !connected || !gitea?.prsList) {
+ return;
+ }
+ let cancelled = false;
+ setListLoading(true);
+ setListError(null);
+ void gitea
+ .prsList(currentDirectory, { page: 1 })
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ setOpenPrs(result.prs ?? []);
+ setListPage(result.page ?? 1);
+ setListHasMore(Boolean(result.hasMore));
+ })
+ .catch((error) => {
+ if (!cancelled) {
+ setListError(error instanceof Error ? error.message : String(error));
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setListLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [connected, currentDirectory, gitea, retryToken]);
+
+ const loadMore = React.useCallback(async () => {
+ if (!currentDirectory || !connected || !gitea?.prsList) {
+ return;
+ }
+ if (listLoadingMore || listLoading || !listHasMore) {
+ return;
+ }
+ setListLoadingMore(true);
+ try {
+ const next = await gitea.prsList(currentDirectory, { page: listPage + 1 });
+ setOpenPrs((previous) => [...previous, ...(next.prs ?? [])]);
+ setListPage(next.page ?? listPage + 1);
+ setListHasMore(Boolean(next.hasMore));
+ } catch (error) {
+ setListError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setListLoadingMore(false);
+ }
+ }, [connected, currentDirectory, gitea, listHasMore, listLoading, listLoadingMore, listPage]);
+
+ // ---- Inline PR context (current-branch PR only) -----------------------
+
+ const [contextOpen, setContextOpen] = React.useState(false);
+ const [contextResult, setContextResult] = React.useState(null);
+ const [contextLoading, setContextLoading] = React.useState(false);
+
+ // A different branch PR invalidates any previously loaded context.
+ React.useEffect(() => {
+ setContextOpen(false);
+ setContextResult(null);
+ }, [branchPr?.number]);
+
+ // A different branch PR invalidates the update/merge transient state so the
+ // previous PR's edit form and in-flight requests don't leak.
+ React.useEffect(() => {
+ setUpdateOpen(false);
+ setEditTitle('');
+ setEditDescription('');
+ setEditDescriptionKnown(false);
+ setEditDescriptionLoading(false);
+ setUpdating(false);
+ setMerging(false);
+ }, [branchPr?.number]);
+
+ const toggleContext = React.useCallback(async (pr: GiteaPullRequestSummary) => {
+ if (!currentDirectory || !gitea?.prContext) {
+ return;
+ }
+ if (contextOpen) {
+ setContextOpen(false);
+ setContextResult(null);
+ return;
+ }
+ setContextOpen(true);
+ setContextLoading(true);
+ try {
+ const result = await gitea.prContext(currentDirectory, pr.number, { includeDiff: false });
+ setContextResult(result.connected === false ? null : result);
+ } catch {
+ setContextResult(null);
+ } finally {
+ setContextLoading(false);
+ }
+ }, [contextOpen, currentDirectory, gitea]);
+
+ // Shared rich view for the branch PR's detail (title/body/chips/commits/
+ // files/timeline/status strip). Owns its own fetching through the forge
+ // facade; the commit-status capability renders the status strip in the
+ // checks section automatically.
+ const prProvider = React.useMemo(() => (gitea ? buildForgeProvider('gitea', { gitea }) : null), [gitea]);
+
+ // ---- Create / update / merge actions -----------------------------------
+
+ const [createTitle, setCreateTitle] = React.useState('');
+ const [createDescription, setCreateDescription] = React.useState('');
+ const [createSourceBranch, setCreateSourceBranch] = React.useState(currentBranch ?? '');
+ const [createTargetBranch, setCreateTargetBranch] = React.useState('main');
+ const [creating, setCreating] = React.useState(false);
+ const createTargetTouchedRef = React.useRef(false);
+
+ // Repository branches for the source/target dropdowns, fetched lazily once
+ // the create form is visible.
+ const [branches, setBranches] = React.useState([]);
+ const [defaultBranch, setDefaultBranch] = React.useState(null);
+ const [branchesLoading, setBranchesLoading] = React.useState(false);
+
+ // The current branch is only known after git status resolves, so adopt it as
+ // the default source branch when it arrives without clobbering a pick.
+ React.useEffect(() => {
+ if (currentBranch) {
+ setCreateSourceBranch((previous) => previous || currentBranch);
+ }
+ }, [currentBranch]);
+
+ // The default target branch is the target of the repository's previously
+ // listed open PRs when available; otherwise fall back to main.
+ const defaultTargetBranch = React.useMemo(
+ () => openPrs.find((pr) => pr.targetBranch)?.targetBranch ?? 'main',
+ [openPrs],
+ );
+
+ // Adopt the repository's target branch default once the open-PR list
+ // resolves, unless the user has already typed into the field.
+ React.useEffect(() => {
+ if (branchPrLoading || branchPr || createTargetTouchedRef.current) {
+ return;
+ }
+ setCreateTargetBranch(defaultBranch ?? defaultTargetBranch);
+ }, [branchPr, branchPrLoading, defaultBranch, defaultTargetBranch]);
+
+ // The source dropdown must always offer the picked/current branch, even
+ // before the branch list resolves.
+ const sourceBranchOptions = React.useMemo(() => {
+ if (!createSourceBranch) {
+ return branches;
+ }
+ return branches.includes(createSourceBranch) ? branches : [createSourceBranch, ...branches];
+ }, [branches, createSourceBranch]);
+
+ // A pull request cannot target its own source branch once there is more
+ // than one branch to choose from.
+ const targetBranchOptions = React.useMemo(
+ () => (branches.length >= 2 ? branches.filter((branch) => branch !== createSourceBranch) : branches),
+ [branches, createSourceBranch],
+ );
+
+ // Fetch the repository's branches lazily once the create form is visible so
+ // the source/target dropdowns can offer real values. Gitea's branch API is
+ // keyed by owner/repo, which the PR list result carries. Failure surfaces as
+ // a toast and leaves the dropdowns on the current-branch fallback.
+ React.useEffect(() => {
+ if (!repoRef || branchPr || !connected || !gitea?.repoBranches) {
+ return;
+ }
+ let cancelled = false;
+ setBranchesLoading(true);
+ void gitea
+ .repoBranches(repoRef.owner, repoRef.repo)
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ setBranches(result.branches ?? []);
+ setDefaultBranch(result.defaultBranch ?? null);
+ })
+ .catch((error) => {
+ if (cancelled) {
+ return;
+ }
+ setBranches([]);
+ setDefaultBranch(null);
+ toast.error(t('contextPanel.giteaPr.error.loadFailed'), {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setBranchesLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [branchPr, connected, gitea, repoRef, t]);
+
+ const [updateOpen, setUpdateOpen] = React.useState(false);
+ const [editTitle, setEditTitle] = React.useState('');
+ const [editDescription, setEditDescription] = React.useState('');
+ const [editDescriptionKnown, setEditDescriptionKnown] = React.useState(false);
+ const [editDescriptionLoading, setEditDescriptionLoading] = React.useState(false);
+ const [updating, setUpdating] = React.useState(false);
+
+ const [merging, setMerging] = React.useState(false);
+
+ const createPr = React.useCallback(async () => {
+ if (!currentDirectory || !currentBranch || !gitea?.prCreate) {
+ return;
+ }
+ const targetBranch = createTargetBranch.trim();
+ if (!targetBranch) {
+ return;
+ }
+ setCreating(true);
+ try {
+ const created = await gitea.prCreate({
+ directory: currentDirectory,
+ title: createTitle.trim() || currentBranch,
+ sourceBranch: createSourceBranch,
+ targetBranch,
+ ...(createDescription.trim() ? { description: createDescription } : {}),
+ });
+ toast.success(t('contextPanel.giteaPr.createPr.toast.created'));
+ // Show the created PR immediately and refresh both the branch PR and
+ // the open list so the card flips to the opened state.
+ setBranchPr(created);
+ setRetryToken((value) => value + 1);
+ // Clear the form.
+ setCreateTitle('');
+ setCreateDescription('');
+ createTargetTouchedRef.current = false;
+ setCreateTargetBranch(defaultBranch ?? defaultTargetBranch);
+ } catch (error) {
+ toast.error(t('contextPanel.giteaPr.createPr.toast.createFailed'), {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ setCreating(false);
+ }
+ }, [createDescription, createSourceBranch, createTargetBranch, createTitle, currentBranch, currentDirectory, defaultBranch, defaultTargetBranch, gitea, t]);
+
+ const toggleUpdate = React.useCallback(async () => {
+ if (!branchPr) {
+ return;
+ }
+ if (updateOpen) {
+ setUpdateOpen(false);
+ return;
+ }
+ setUpdateOpen(true);
+ setEditTitle(branchPr.title);
+ const knownBody = contextResult?.pr?.body;
+ if (typeof knownBody === 'string') {
+ setEditDescription(knownBody);
+ setEditDescriptionKnown(true);
+ return;
+ }
+ setEditDescription('');
+ setEditDescriptionKnown(false);
+ if (!currentDirectory || !gitea?.prContext) {
+ return;
+ }
+ setEditDescriptionLoading(true);
+ try {
+ const result = await gitea.prContext(currentDirectory, branchPr.number, { includeDiff: false });
+ if (result.connected === false) {
+ setEditDescription('');
+ return;
+ }
+ setEditDescription(result.pr?.body ?? '');
+ setEditDescriptionKnown(true);
+ } catch {
+ // Leave the description empty; the title can still be edited.
+ } finally {
+ setEditDescriptionLoading(false);
+ }
+ }, [branchPr, contextResult?.pr?.body, currentDirectory, gitea, updateOpen]);
+
+ const savePr = React.useCallback(async () => {
+ if (!currentDirectory || !branchPr || !gitea?.prUpdate) {
+ return;
+ }
+ const trimmedTitle = editTitle.trim();
+ if (!trimmedTitle) {
+ return;
+ }
+ setUpdating(true);
+ try {
+ await gitea.prUpdate({
+ directory: currentDirectory,
+ number: branchPr.number,
+ title: trimmedTitle,
+ // Only send the description when it was actually loaded so an
+ // unresolved description can never be wiped out by a title-only save.
+ ...(editDescriptionKnown ? { description: editDescription } : {}),
+ });
+ toast.success(t('contextPanel.giteaPr.updatePr.toast.updated'));
+ setUpdateOpen(false);
+ setRetryToken((value) => value + 1);
+ } catch (error) {
+ toast.error(t('contextPanel.giteaPr.updatePr.toast.updateFailed'), {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ setUpdating(false);
+ }
+ }, [branchPr, currentDirectory, editDescription, editDescriptionKnown, editTitle, gitea, t]);
+
+ // Gitea merges with a method (merge/squash/rebase); there are no
+ // method-selector labels in the gitea key set, so the default 'merge' method
+ // is used without a selector.
+ const mergePr = React.useCallback(async () => {
+ if (!currentDirectory || !branchPr || !gitea?.prMerge) {
+ return;
+ }
+ setMerging(true);
+ try {
+ const result = await gitea.prMerge({
+ directory: currentDirectory,
+ number: branchPr.number,
+ method: 'merge',
+ });
+ if (result.merged) {
+ toast.success(t('contextPanel.giteaPr.mergePr.toast.merged'));
+ } else {
+ toast.error(t('contextPanel.giteaPr.mergePr.toast.mergeFailed'), {
+ ...(result.message ? { description: result.message } : {}),
+ });
+ }
+ // Refresh the branch PR (flips to the merged state) and the open list.
+ setRetryToken((value) => value + 1);
+ } catch (error) {
+ toast.error(t('contextPanel.giteaPr.mergePr.toast.mergeFailed'), {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ setMerging(false);
+ }
+ }, [branchPr, currentDirectory, gitea, t]);
+
+ // ---- Render ------------------------------------------------------------
+
+ if (!currentDirectory) {
+ return (
+
+
+
{t('contextPanel.giteaPr.title')}
+
{t('contextPanel.giteaPr.empty.noActiveProject')}
+
+ );
+ }
+
+ if (connected === null) {
+ return (
+
+
+
{t('contextPanel.giteaPr.loading')}
+
+ );
+ }
+
+ if (connected === false) {
+ return (
+
+
+
{t('contextPanel.giteaPr.error.notConnected')}
+
+ {t('contextPanel.giteaPr.actions.openSettings')}
+
+
+ );
+ }
+
+ const branchPrStateLabel = branchPr
+ ? branchPr.state === 'merged'
+ ? t('contextPanel.giteaPr.state.merged')
+ : branchPr.state === 'closed'
+ ? t('contextPanel.giteaPr.state.closed')
+ : t('contextPanel.giteaPr.state.opened')
+ : '';
+ const branchPrAuthor = branchPr ? prAuthorLabel(branchPr) : '';
+
+ return (
+
+
+
+ setActiveTab(tabId as 'pr' | 'issues')}
+ layoutMode="fit"
+ variant="active-pill"
+ activePillButtonClassName="h-7"
+ />
+
+
+ {activeTab === 'pr' ? (
+ <>
+
+
{t('contextPanel.giteaPr.title')}
+
{t('contextPanel.giteaPr.listSectionTitle')}
+
+
+ {/* Current-branch pull request */}
+
+ {t('contextPanel.giteaPr.branchSectionTitle')}
+
+ {branchPrLoading ? (
+
+
+ {t('contextPanel.giteaPr.loading')}
+
+ ) : branchPrError ? (
+
+
{t('contextPanel.giteaPr.error.loadFailed')}
+
{branchPrError}
+
+ {t('contextPanel.preview.actions.retry')}
+
+
+ ) : branchPr ? (
+
+
+
+ #{branchPr.number} {branchPr.title}
+
+
+ {branchPr.draft ? (
+ {t('contextPanel.giteaPr.draft')}
+ ) : null}
+
+
+ {branchPrStateLabel}
+
+ {branchPr.sourceBranch} → {branchPr.targetBranch}
+
+ {branchPrAuthor ? (
+
{branchPrAuthor}
+ ) : null}
+
+
+
+
+
+
+ {t('contextPanel.giteaPr.openInGitea')}
+
+
+
void toggleContext(branchPr)}
+ disabled={contextLoading}
+ >
+ {contextLoading ? (
+
+ ) : contextOpen ? (
+
+ ) : (
+
+ )}
+ {contextOpen ? t('contextPanel.giteaPr.hideContext') : t('contextPanel.giteaPr.loadContext')}
+
+ {branchPr.state === 'open' ? (
+ <>
+
void toggleUpdate()}
+ disabled={updating}
+ >
+
+ {t('contextPanel.giteaPr.updatePr.toggle')}
+
+
void mergePr()}
+ disabled={merging || updating}
+ >
+ {merging ? : }
+ {merging ? t('contextPanel.giteaPr.mergePr.merging') : t('contextPanel.giteaPr.mergePr.action')}
+
+ >
+ ) : null}
+
+
+ {updateOpen && branchPr.state === 'open' ? (
+
+
+ {t('contextPanel.giteaPr.createPr.titleLabel')}
+ setEditTitle(event.target.value)}
+ placeholder={t('contextPanel.giteaPr.createPr.titlePlaceholder')}
+ />
+
+
+ {t('contextPanel.giteaPr.createPr.descriptionLabel')}
+ {editDescriptionLoading ? (
+
+
+ {t('contextPanel.giteaPr.loading')}
+
+ ) : (
+
+
+ void savePr()}
+ disabled={updating || editDescriptionLoading || !editTitle.trim()}
+ >
+ {updating ? : }
+ {updating ? t('contextPanel.giteaPr.updatePr.saving') : t('contextPanel.giteaPr.updatePr.save')}
+
+
+
+ ) : null}
+
+ {contextOpen && prProvider ? (
+
+
+
+ ) : null}
+
+ ) : currentBranch ? (
+
+
{t('contextPanel.giteaPr.createPr.title')}
+
+
+ {t('contextPanel.giteaPr.createPr.sourceBranch')}
+ setCreateSourceBranch(value)}>
+
+ {branchesLoading ? t('contextPanel.giteaPr.createPr.branchesLoading') : createSourceBranch}
+
+
+ {sourceBranchOptions.map((branch) => (
+ {branch}
+ ))}
+
+
+
+
+
+ {t('contextPanel.giteaPr.createPr.targetBranch')}
+ {
+ createTargetTouchedRef.current = true;
+ setCreateTargetBranch(value);
+ }}
+ >
+
+ {branchesLoading ? t('contextPanel.giteaPr.createPr.branchesLoading') : createTargetBranch}
+
+
+ {targetBranchOptions.map((branch) => (
+ {branch}
+ ))}
+
+
+
+
+
+ {t('contextPanel.giteaPr.createPr.titleLabel')}
+ setCreateTitle(event.target.value)}
+ placeholder={currentBranch}
+ />
+
+
+
+ {t('contextPanel.giteaPr.createPr.descriptionLabel')}
+
+
+
+ void createPr()}
+ disabled={creating || !createTargetBranch.trim()}
+ >
+ {creating ? : }
+ {creating ? t('contextPanel.giteaPr.createPr.submitting') : t('contextPanel.giteaPr.createPr.submit')}
+
+
+
+ ) : (
+ {t('contextPanel.giteaPr.noPrForBranch')}
+ )}
+
+
+ {/* Open pull requests in this repository */}
+
+ {t('contextPanel.giteaPr.openPrTitle')}
+
+ {listLoading ? (
+
+
+ {t('contextPanel.giteaPr.loading')}
+
+ ) : listError ? (
+
+
{t('contextPanel.giteaPr.error.loadFailed')}
+
{listError}
+
+ {t('contextPanel.preview.actions.retry')}
+
+
+ ) : openPrs.length === 0 ? (
+ {t('contextPanel.giteaPr.openPrEmpty')}
+ ) : (
+
+ {openPrs.map((pr) => (
+
+ ))}
+
+ {listHasMore ? (
+
+ void loadMore()} disabled={listLoadingMore}>
+ {listLoadingMore ? (
+
+ ) : null}
+ {t('contextPanel.giteaPr.loadMore')}
+
+
+ ) : null}
+
+ )}
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+};
diff --git a/packages/ui/src/components/views/PullRequestView.tsx b/packages/ui/src/components/views/PullRequestView.tsx
index 34951ce2..eb71c8fb 100644
--- a/packages/ui/src/components/views/PullRequestView.tsx
+++ b/packages/ui/src/components/views/PullRequestView.tsx
@@ -14,9 +14,11 @@ import type { GitRemote } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { PullRequestSection } from './git/PullRequestSection';
import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates';
import { NestedRepoPicker } from './git/NestedRepoPicker';
+import { GitHubIssuesSection } from './git/GitHubIssuesSection';
import { deriveBaseBranch } from './git/baseBranch';
const normalizePath = (value?: string | null): string =>
@@ -251,14 +253,23 @@ export const PullRequestView: React.FC = () => {
worktreeMetadata?.createdFromBranch,
]);
+ // Local tab selection between the pull-request and issues surfaces. Not
+ // persisted: reopening the panel always lands on pull requests.
+ const [activeTab, setActiveTab] = React.useState<'pr' | 'issues'>('pr');
+
+ // Empty state for the pull-request surface: returned full-height when there
+ // is no effective directory, and shown inside the "Pull requests" tab when
+ // the current branch has not resolved (issues need no branch, PRs do).
+ const prEmptyState = (
+
+
+
{t('gitView.pullRequest.title')}
+
{t('gitView.pullRequest.createHint')}
+
+ );
+
if (!currentDirectory) {
- return (
-
-
-
{t('gitView.pullRequest.title')}
-
{t('gitView.pullRequest.createHint')}
-
- );
+ return prEmptyState;
}
// Non-repo root: surface nested-repository resolution while the operating
@@ -277,51 +288,64 @@ export const PullRequestView: React.FC = () => {
);
}
- if (!currentBranch) {
- return (
-
-
-
{t('gitView.pullRequest.title')}
-
{t('gitView.pullRequest.createHint')}
-
- );
- }
-
// Repository switcher for non-repo roots with discovered nested
// repositories; the pick is shared per root across git surfaces.
const showRepositoryPicker =
rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0;
return (
-
- {showRepositoryPicker ? (
-
-
{
- if (currentDirectory) selectNestedRepo(currentDirectory, repository);
- }}
- repositoryRoot={currentDirectory ?? undefined}
+
+
+ {showRepositoryPicker ? (
+
+ {
+ if (currentDirectory) selectNestedRepo(currentDirectory, repository);
+ }}
+ repositoryRoot={currentDirectory ?? undefined}
+ />
+
+ ) : null}
+
+ setActiveTab(tabId as 'pr' | 'issues')}
+ layoutMode="fit"
+ variant="active-pill"
+ activePillButtonClassName="h-7"
/>
- ) : null}
-
-
-
-
+
+ {activeTab === 'pr' ? (
+ currentBranch ? (
+
+ ) : (
+ {prEmptyState}
+ )
+ ) : (
+
+ )}
+
+
);
};
diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx
index 40872210..740fcd01 100644
--- a/packages/ui/src/components/views/SettingsView.tsx
+++ b/packages/ui/src/components/views/SettingsView.tsx
@@ -14,6 +14,9 @@ import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useConfigStore } from '@/stores/useConfigStore';
+import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
+import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
+import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
import { Tooltip, TooltipTrigger } from '@/components/ui/tooltip';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
@@ -243,6 +246,31 @@ export const SettingsView: React.FC
= ({ onClose, forceMobile
const runtimeCtx = React.useMemo(() => buildRuntimeContext(isDesktopApp, isMobile), [isDesktopApp, isMobile]);
+ const githubConnected = useGitHubAuthStore((state) => state.status?.connected ?? false);
+ const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
+ const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
+ const gitlabConnected = useGitLabAuthStore((state) => state.status?.connected ?? false);
+ const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked);
+ const refreshGitLabAuthStatus = useGitLabAuthStore((state) => state.refreshStatus);
+ const giteaConnected = useGiteaAuthStore((state) => state.status?.connected ?? false);
+ const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked);
+ const refreshGiteaAuthStatus = useGiteaAuthStore((state) => state.refreshStatus);
+
+ // Populate git provider connection state on mount so search availability for
+ // the provider override fields matches what the settings page will render.
+ // refreshStatus dedupes when already checked and falls back to runtimeFetch.
+ React.useEffect(() => {
+ if (!githubAuthChecked) {
+ void refreshGitHubAuthStatus();
+ }
+ if (!gitlabAuthChecked) {
+ void refreshGitLabAuthStatus();
+ }
+ if (!giteaAuthChecked) {
+ void refreshGiteaAuthStatus();
+ }
+ }, [githubAuthChecked, refreshGitHubAuthStatus, gitlabAuthChecked, refreshGitLabAuthStatus, giteaAuthChecked, refreshGiteaAuthStatus]);
+
const visiblePages = React.useMemo(() => {
const allowedPages = visiblePageSlugs ? new Set(visiblePageSlugs) : null;
return SETTINGS_PAGE_METADATA
@@ -386,12 +414,20 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
const settingsSearchResults = React.useMemo(() => {
return buildSettingsSearchResults({
query: settingsSearchQuery,
- runtimeCtx: { ...runtimeCtx, isDesktopLocalOrigin, isMac, isWindows, isLinux, isWindowsArm64 },
+ runtimeCtx: {
+ ...runtimeCtx,
+ isDesktopLocalOrigin,
+ isMac,
+ isWindows,
+ isLinux,
+ isWindowsArm64,
+ gitProvidersConnected: { github: githubConnected, gitlab: gitlabConnected, gitea: giteaConnected },
+ },
visiblePageSlugs,
t,
getPageTitle,
});
- }, [getPageTitle, isWindowsArm64, isDesktopLocalOrigin, isMac, isWindows, isLinux, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]);
+ }, [getPageTitle, githubConnected, gitlabConnected, giteaConnected, isWindowsArm64, isDesktopLocalOrigin, isMac, isWindows, isLinux, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]);
const prepareSettingsSearchTarget = React.useCallback((result: SettingsSearchResult): string => {
if (result.id.startsWith('agents.')) {
@@ -659,7 +695,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
case 'snippets':
return ;
case 'git':
- return ;
+ return ;
case 'integrations':
return ;
case 'general':
@@ -677,7 +713,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
default:
return null;
}
- }, [openChamberSectionBySlug, renderUnavailable, runtimeCtx, t]);
+ }, [openChamberSectionBySlug, pendingSearchItemId, renderUnavailable, runtimeCtx, t]);
// Mobile: if opened via deep-link / palette to a non-home page, jump into it once.
React.useEffect(() => {
diff --git a/packages/ui/src/components/views/forge/ForgeChecksSection.tsx b/packages/ui/src/components/views/forge/ForgeChecksSection.tsx
new file mode 100644
index 00000000..df10cb9c
--- /dev/null
+++ b/packages/ui/src/components/views/forge/ForgeChecksSection.tsx
@@ -0,0 +1,256 @@
+import React, { useState } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Skeleton } from '@/components/ui/skeleton';
+import { useI18n } from '@/lib/i18n';
+import type { IconName } from '@/components/icon/icons';
+import type { ForgeCheckState, ForgeChecksCapability, ForgeChecksSummary } from '@/lib/forge/types';
+
+interface ForgeChecksSectionProps {
+ kind: ForgeChecksCapability;
+ summary: ForgeChecksSummary | null;
+ loading?: boolean;
+ error?: string | null;
+}
+
+const stateColor = (state: ForgeCheckState): string => {
+ switch (state) {
+ case 'success':
+ return 'var(--status-success)';
+ case 'failure':
+ return 'var(--status-error)';
+ case 'pending':
+ return 'var(--status-warning)';
+ default:
+ return 'var(--surface-muted-foreground)';
+ }
+};
+
+const stateIcon = (state: ForgeCheckState): IconName => {
+ switch (state) {
+ case 'success':
+ return 'checkbox-circle';
+ case 'failure':
+ return 'close-circle';
+ case 'pending':
+ return 'loader-4';
+ case 'cancelled':
+ return 'close-circle';
+ case 'skipped':
+ return 'subtract';
+ default:
+ return 'question';
+ }
+};
+
+const formatElapsed = (start?: string, end?: string): string | null => {
+ if (!start) return null;
+ const startTs = Date.parse(start);
+ if (!Number.isFinite(startTs)) return null;
+ const endTs = end ? Date.parse(end) : Date.now();
+ if (!Number.isFinite(endTs) || endTs <= startTs) return null;
+ const totalMinutes = Math.floor((endTs - startTs) / 60_000);
+ if (totalMinutes < 1) return '<1m';
+ if (totalMinutes < 60) return `${totalMinutes}m`;
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
+};
+
+const CheckRunRow: React.FC<{
+ name: string;
+ state: ForgeCheckState;
+ startedAt?: string;
+ completedAt?: string;
+ description?: string;
+ details?: ForgeChecksSummary['checks'][number]['details'];
+ expanded: boolean;
+ onToggle: () => void;
+}> = ({ name, state, startedAt, completedAt, description, details, expanded, onToggle }) => {
+ const { t } = useI18n();
+ const isPending = state === 'pending';
+ const duration = formatElapsed(startedAt, isPending ? undefined : completedAt);
+ const hasDetails = Boolean(
+ details?.title || details?.summary || details?.text || (details?.annotations?.length ?? 0) > 0,
+ );
+
+ return (
+
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+ {name}
+ {duration ? {duration} : null}
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {t(`forge.checks.state.${state}` as never)}
+ {hasDetails ? (
+
+ ) : null}
+
+ {expanded && hasDetails ? (
+
+ {details?.title ?
{details.title}
: null}
+ {details?.summary ? (
+
{details.summary}
+ ) : null}
+ {details?.text ? (
+
+ {details.text}
+
+ ) : null}
+ {details?.annotations && details.annotations.length > 0 ? (
+
+ {details.annotations.map((annotation, idx) => (
+
+
+ {annotation.title || annotation.level || 'Issue'}
+ {annotation.path ? ` · ${annotation.path}` : ''}
+ {typeof annotation.startLine === 'number' ? `:${annotation.startLine}` : ''}
+ {typeof annotation.endLine === 'number' && annotation.endLine !== annotation.startLine
+ ? `-${annotation.endLine}`
+ : ''}
+
+ {annotation.message ? (
+
+ {annotation.message}
+
+ ) : null}
+
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+ );
+};
+
+const CommitStatusStrip: React.FC<{ summary: ForgeChecksSummary }> = ({ summary }) => {
+ const { t } = useI18n();
+ return (
+
+
{t('forge.checks.statusStrip')}
+
+ {summary.checks.map((check, idx) => (
+
+
+ {check.name}
+
+ ))}
+
+
+ );
+};
+
+/**
+ * CI/status summary for a pull request, gated by the provider's checks
+ * capability. `'check-runs'` renders an aggregate bar plus expandable per-run
+ * rows (title/summary/text + annotations); `'commit-statuses'` renders a strip
+ * of status chips. Returns null for `'none'`. Pure presentation.
+ */
+export const ForgeChecksSection = React.memo(function ForgeChecksSection({ kind, summary, loading, error }) {
+ const { t } = useI18n();
+ const [expandedKeys, setExpandedKeys] = useState>(new Set());
+
+ const toggle = React.useCallback((key: string) => {
+ setExpandedKeys((previous) => {
+ const next = new Set(previous);
+ if (next.has(key)) {
+ next.delete(key);
+ } else {
+ next.add(key);
+ }
+ return next;
+ });
+ }, []);
+
+ if (kind === 'none') return null;
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ {error}
+
+ );
+ }
+
+ if (!summary || summary.checks.length === 0) {
+ return {t('forge.checks.empty')}
;
+ }
+
+ if (kind === 'commit-statuses') {
+ return ;
+ }
+
+ return (
+
+
+
+ {summary.success > 0 ? (
+
+ ) : null}
+ {summary.failure > 0 ? (
+
+ ) : null}
+ {summary.pending > 0 ? (
+
+ ) : null}
+
+
+ {summary.success}/{summary.total} {t('gitView.pr.checks.label')}
+
+
+
+ {summary.checks.map((check, idx) => {
+ const key = `${check.name}:${idx}`;
+ return (
+ toggle(key)}
+ />
+ );
+ })}
+
+
+ );
+});
diff --git a/packages/ui/src/components/views/forge/ForgeCommitsSection.tsx b/packages/ui/src/components/views/forge/ForgeCommitsSection.tsx
new file mode 100644
index 00000000..79e3fae1
--- /dev/null
+++ b/packages/ui/src/components/views/forge/ForgeCommitsSection.tsx
@@ -0,0 +1,181 @@
+import React, { useMemo, useState } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { toast } from '@/components/ui/toast';
+import { useI18n } from '@/lib/i18n';
+import { formatDateTimeForPreference } from '@/lib/timeFormat';
+import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
+import { copyTextToClipboard } from '@/lib/clipboard';
+import type { GitLogEntry } from '@/lib/api/types';
+import type { ForgeCommit } from '@/lib/forge/types';
+import { assignLanes } from '@/components/views/git/gitGraph';
+import { GitGraphSegment } from '@/components/views/git/GitGraphSegment';
+
+interface ForgeCommitsSectionProps {
+ commits: ForgeCommit[] | null;
+ loading?: boolean;
+ error?: string | null;
+}
+
+/**
+ * Map a normalized forge commit onto the `GitLogEntry`-shaped input
+ * `assignLanes` consumes. The commit list is authoritative; the synthesized
+ * fields are only used for lane geometry and display text.
+ */
+const toLaneEntry = (commit: ForgeCommit): GitLogEntry => ({
+ hash: commit.sha,
+ date: commit.committedAt ?? '',
+ message: commit.summary ?? commit.message,
+ refs: '',
+ body: commit.message,
+ author_name: commit.author?.name ?? commit.author?.login ?? 'Unknown',
+ author_email: '',
+ filesChanged: 0,
+ insertions: 0,
+ deletions: 0,
+ parents: commit.parents,
+});
+
+const formatCommitDate = (value: string | undefined, timeFormatPreference: TimeFormatPreference): string => {
+ if (!value) return '';
+ const ts = Date.parse(value);
+ if (!Number.isFinite(ts)) return value;
+ return formatDateTimeForPreference(ts, timeFormatPreference, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+};
+
+/**
+ * Commits on a pull request, rendered with the git-graph lane visual language
+ * (GitGraphSegment over `assignLanes` output). Each row expands to the full
+ * message and parent shas. Pure presentation.
+ */
+export const ForgeCommitsSection = React.memo(function ForgeCommitsSection({ commits, loading, error }) {
+ const { t } = useI18n();
+ const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
+ const [expandedShas, setExpandedShas] = useState>(new Set());
+
+ const bySha = useMemo(() => new Map((commits ?? []).map((commit) => [commit.sha, commit])), [commits]);
+ const laned = useMemo(() => assignLanes((commits ?? []).map(toLaneEntry)), [commits]);
+ const totalLanes = useMemo(
+ () => laned.reduce((max, item) => Math.max(max, item.lane), -1) + 1,
+ [laned],
+ );
+
+ const toggle = React.useCallback((sha: string) => {
+ setExpandedShas((previous) => {
+ const next = new Set(previous);
+ if (next.has(sha)) {
+ next.delete(sha);
+ } else {
+ next.add(sha);
+ }
+ return next;
+ });
+ }, []);
+
+ const copyHash = React.useCallback(async (sha: string) => {
+ const result = await copyTextToClipboard(sha);
+ if (result.ok) {
+ toast.success(t('forge.copied'));
+ }
+ }, [t]);
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ {error}
+
+ );
+ }
+
+ if (!commits || commits.length === 0) {
+ return {t('forge.commits.empty')}
;
+ }
+
+ return (
+
+ {laned.map((item) => {
+ const commit = bySha.get(item.commit.hash);
+ if (!commit) return null;
+ const isExpanded = expandedShas.has(commit.sha);
+ const author = commit.author?.name ?? commit.author?.login ?? null;
+ return (
+
+ toggle(commit.sha)}
+ className="flex w-full items-start gap-3 px-3 py-2 text-left transition-colors hover:bg-[var(--interactive-hover)]/40"
+ aria-expanded={isExpanded}
+ >
+
+
+
+
+
+ {commit.summary ?? commit.message}
+
+
+ {author ? {author} : null}
+ {author && commit.committedAt ? · : null}
+ {commit.committedAt ? (
+ {formatCommitDate(commit.committedAt, timeFormatPreference)}
+ ) : null}
+ ·
+ {commit.shortSha}
+
+
+ {
+ event.stopPropagation();
+ void copyHash(commit.sha);
+ }}
+ >
+
+
+
+ {t('gitView.history.copySha')}
+
+
+
+
+ {isExpanded ? (
+
+
{commit.message}
+ {commit.parents.length > 0 ? (
+
+ {t('forge.commits.parents')}:
+ {commit.parents.map((parent) => (
+ {parent.slice(0, 7)}
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+ );
+ })}
+
+ );
+});
diff --git a/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx b/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx
new file mode 100644
index 00000000..0854beec
--- /dev/null
+++ b/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx
@@ -0,0 +1,391 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useShallow } from 'zustand/react/shallow';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import { useI18n } from '@/lib/i18n';
+import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
+import { normalizePath } from '@/lib/pathNormalization';
+import { useUIStore } from '@/stores/useUIStore';
+import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
+import { useSessionUIStore } from '@/sync/session-ui-store';
+import { findLinkedSessionsForEntity, linkedEntityCandidateIds } from '@/lib/linkedSessionMatches';
+import type {
+ ForgeChecksResult,
+ ForgeCommitsResult,
+ ForgeEntityRef,
+ ForgeIssueDetail,
+ ForgeProvider,
+ ForgePullRequestContext,
+ ForgeTimelineResult,
+} from '@/lib/forge/provider';
+import type { ForgeComment, ForgeTimelineEvent } from '@/lib/forge/types';
+import { ForgeMetadataChips } from './ForgeMetadataChips';
+import { ForgeCommitsSection } from './ForgeCommitsSection';
+import { ForgeFilesDiffSection } from './ForgeFilesDiffSection';
+import { ForgeTimelineSection } from './ForgeTimelineSection';
+import { ForgeChecksSection } from './ForgeChecksSection';
+import { LinkedSessionsSection } from './LinkedSessionsSection';
+import {
+ ForgeCommentComposer,
+ ForgeEntityActions,
+ ForgeMetadataEditor,
+ ForgeThreadReply,
+} from './actions';
+
+interface ForgeEntityDetailViewProps {
+ provider: ForgeProvider;
+ directory: string;
+ number: number;
+ options?: {
+ sourceRepo?: string | null;
+ kind?: 'pull' | 'issue';
+ };
+ /** Optional CTA target for the not-connected notice. */
+ onOpenSettings?: () => void;
+}
+
+interface PullData {
+ context: ForgePullRequestContext | null;
+ commits: ForgeCommitsResult | null;
+ timeline: ForgeTimelineResult | null;
+ checks: ForgeChecksResult | null;
+}
+
+const markdownClassName =
+ 'typography-markdown-body text-foreground break-words [&_a]:no-underline [&_a:hover]:no-underline';
+
+const SectionTitle: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+ {children}
+);
+
+const LoadingBlock: React.FC<{ label: string }> = ({ label }) => (
+
+
+
+ {label}
+
+
+
+
+
+
+);
+
+const ErrorBlock: React.FC<{ message: string }> = ({ message }) => (
+
+
+ {message}
+
+);
+
+const NotConnectedBlock: React.FC<{ onOpenSettings?: () => void }> = ({ onOpenSettings }) => {
+ const { t } = useI18n();
+ return (
+
+
{t('forge.notConnected')}
+ {onOpenSettings ? (
+
+ {t('gitView.pr.actions.openSettings')}
+
+ ) : null}
+
+ );
+};
+
+/**
+ * Self-loading detail view for a forge pull request or issue. Owns all data
+ * fetching through the provider facade (context/issue plus commits, timeline,
+ * and checks where the provider implements them) and renders the presentational
+ * section components. Sections stay capability-gated: GitHub check runs ride on
+ * the pull-request context, Gitea statuses come from `getChecks`, GitLab has no
+ * checks surface.
+ */
+export const ForgeEntityDetailView: React.FC = ({ provider, directory, number, options, onOpenSettings }) => {
+ const { t } = useI18n();
+ const isIssue = (options?.kind ?? 'pull') === 'issue';
+ const sourceRepo = options?.sourceRepo ?? null;
+
+ const [pull, setPull] = useState(null);
+ const [issueDetail, setIssueDetail] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ // Bumped after a successful write so the owning load effect re-runs; never
+ // bumped on render, so writes are the only trigger.
+ const [reloadToken, setReloadToken] = useState(0);
+ // Comments posted through this view are appended locally so they appear
+ // immediately; a later context refresh reconciles them with authoritative
+ // server data (and the load effect clears the local list).
+ const [localComments, setLocalComments] = useState([]);
+ // Id of the thread root the user is replying to (renders ForgeThreadReply
+ // under that thread card).
+ const [replyingTo, setReplyingTo] = useState(null);
+
+ // Sessions in the same project as this view, from the same authoritative
+ // store the sidebar consumes. Derived client-side: no extra fetching.
+ const allSessions = useGlobalSessionsStore(useShallow((state) => state.activeSessions));
+ const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
+
+ // The repo this entity lives on, resolved from the loaded context/issue.
+ const repoRef = isIssue ? (issueDetail?.repo ?? null) : (pull?.context?.repo ?? null);
+
+ const projectSessions = useMemo(() => {
+ const base = normalizePath(directory);
+ if (!base) return allSessions;
+ return allSessions.filter((session) => {
+ const sessionDirectory = resolveGlobalSessionDirectory(session);
+ return sessionDirectory === base || (sessionDirectory !== null && sessionDirectory.startsWith(`${base}/`));
+ });
+ }, [allSessions, directory]);
+
+ const linkedSessions = useMemo(() => {
+ if (!repoRef) return [];
+ const candidateIds = linkedEntityCandidateIds(repoRef, number);
+ return findLinkedSessionsForEntity(projectSessions, provider.kind, candidateIds);
+ }, [number, projectSessions, provider.kind, repoRef]);
+
+ const openSession = useCallback((sessionId: string) => {
+ useUIStore.getState().closeMainSurfaces();
+ setCurrentSession(sessionId);
+ }, [setCurrentSession]);
+
+ const reload = useCallback(() => {
+ setReloadToken((value) => value + 1);
+ }, []);
+
+ const ref = useMemo(() => ({ kind: isIssue ? 'issue' : 'pull', number }), [isIssue, number]);
+
+ const appendComment = useCallback((comment: ForgeComment) => {
+ setLocalComments((previous) => [...previous, comment]);
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ setPull(null);
+ setIssueDetail(null);
+ setLocalComments([]);
+ setIsLoading(true);
+
+ if (isIssue) {
+ if (!provider.getIssue) {
+ setIsLoading(false);
+ return;
+ }
+ void provider
+ .getIssue(directory, number, { sourceRepo })
+ .then((detail) => {
+ if (cancelled) return;
+ setIssueDetail(detail);
+ setIsLoading(false);
+ })
+ .catch(() => {
+ if (cancelled) return;
+ setIsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }
+
+ const canCommits = typeof provider.getCommits === 'function';
+ const canTimeline = typeof provider.getTimeline === 'function';
+ const canChecks = provider.capabilities.checks === 'commit-statuses' && typeof provider.getChecks === 'function';
+
+ void (async () => {
+ const context = provider.getPullRequestContext
+ ? await provider.getPullRequestContext(directory, number, { includeDiff: true, sourceRepo })
+ : null;
+ const [commits, timeline, checks] = await Promise.all([
+ canCommits ? provider.getCommits!(directory, number, { sourceRepo }) : Promise.resolve(null),
+ canTimeline ? provider.getTimeline!(directory, number, { sourceRepo }) : Promise.resolve(null),
+ canChecks ? provider.getChecks!(directory, number, { sourceRepo }) : Promise.resolve(null),
+ ]);
+ if (cancelled) return;
+ setPull({ context, commits, timeline, checks });
+ setIsLoading(false);
+ })().catch(() => {
+ if (cancelled) return;
+ setIsLoading(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [directory, isIssue, number, provider, reloadToken, sourceRepo]);
+
+ const mergedComments = useMemo(() => {
+ const derived = isIssue
+ ? issueDetail?.comments ?? []
+ : [...(pull?.context?.issueComments ?? []), ...(pull?.context?.reviewComments ?? [])];
+ return [...localComments, ...derived];
+ }, [isIssue, issueDetail?.comments, localComments, pull?.context]);
+
+ const timelineEvents = useMemo(() => pull?.timeline?.events ?? [], [pull?.timeline]);
+
+ const checksForPull = useMemo<{ kind: 'check-runs' | 'commit-statuses'; summary: ForgeChecksResult['checks'] } | null>(() => {
+ const context = pull?.context;
+ if (!context) return null;
+ const checksResult = pull?.checks;
+ if (provider.capabilities.checks === 'check-runs') {
+ return context.checks ? { kind: 'check-runs', summary: context.checks } : null;
+ }
+ if (provider.capabilities.checks === 'commit-statuses') {
+ return checksResult ? { kind: 'commit-statuses', summary: checksResult.checks } : null;
+ }
+ return null;
+ }, [pull?.context, provider.capabilities.checks, pull?.checks]);
+
+ const canReply = typeof provider.replyToThread === 'function';
+
+ const handleReply = useCallback((comment: ForgeComment) => {
+ setReplyingTo(comment.id);
+ }, []);
+
+ const renderThreadReply = useCallback(
+ (comment: ForgeComment): React.ReactNode => {
+ if (comment.id !== replyingTo) return null;
+ return (
+ {
+ appendComment(created);
+ setReplyingTo(null);
+ }}
+ onCancel={() => setReplyingTo(null)}
+ />
+ );
+ },
+ [appendComment, directory, provider, ref, replyingTo],
+ );
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (isIssue) {
+ if (!issueDetail || !issueDetail.connected) {
+ return ;
+ }
+ const issue = issueDetail.issue;
+ if (!issue) {
+ return ;
+ }
+ const issueState = issue.state === 'closed' ? 'closed' : 'open';
+ const stateColor = `var(--pr-${issueState})`;
+ return (
+
+
+
+
{issue.title}
+ #{issue.number}
+
+ {t(`forge.state.${issueState}`)}
+
+
+
+
+
+
+ {issue.body ? (
+
+ ) : null}
+
+ {t('forge.section.timeline')}
+
+
+
+
+ );
+ }
+
+ if (!pull || !pull.context || !pull.context.connected) {
+ return ;
+ }
+ const context = pull.context;
+ const pr = context.pr;
+ if (!pr) {
+ return ;
+ }
+
+ const stateColor = `var(--pr-${pr.state})`;
+ const stateIcon = pr.state === 'merged'
+ ? 'git-merge'
+ : pr.state === 'closed'
+ ? 'git-close-pull-request'
+ : 'git-pull-request';
+
+ return (
+
+
+
+
{pr.title}
+ #{pr.number}
+
+ {t(`forge.state.${pr.state}` as never)}
+
+ {pr.draft ? (
+
+ {t('forge.draft')}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {checksForPull ? (
+
+ {t('forge.section.checks')}
+
+
+ ) : null}
+
+ {typeof provider.getCommits === 'function' ? (
+
+ {t('forge.section.commits')}
+
+
+ ) : null}
+
+
+ {t('forge.section.files')}
+
+
+
+
+ {t('forge.section.timeline')}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/views/forge/ForgeFilesDiffSection.tsx b/packages/ui/src/components/views/forge/ForgeFilesDiffSection.tsx
new file mode 100644
index 00000000..02fc70c1
--- /dev/null
+++ b/packages/ui/src/components/views/forge/ForgeFilesDiffSection.tsx
@@ -0,0 +1,174 @@
+import React, { useMemo, useState } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Skeleton } from '@/components/ui/skeleton';
+import { useI18n } from '@/lib/i18n';
+import { getLanguageFromExtension } from '@/lib/toolHelpers';
+import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff';
+import type { FileDiffMetadata } from '@pierre/diffs';
+import type { ForgeFileChange } from '@/lib/forge/types';
+import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
+
+interface ForgeFilesDiffSectionProps {
+ files: ForgeFileChange[] | null;
+ diff?: string | null;
+ loading?: boolean;
+ error?: string | null;
+}
+
+const CHANGE_DESCRIPTORS: Record = {
+ added: { code: 'A', color: 'var(--status-success)' },
+ removed: { code: 'D', color: 'var(--status-error)' },
+ renamed: { code: 'R', color: 'var(--status-info)' },
+ modified: { code: 'M', color: 'var(--status-warning)' },
+};
+
+const DEFAULT_DESCRIPTOR = CHANGE_DESCRIPTORS.modified;
+
+const descriptorFor = (status?: string): { code: string; color: string } => {
+ if (!status) return DEFAULT_DESCRIPTOR;
+ const key = status.toLowerCase();
+ return CHANGE_DESCRIPTORS[key] ?? DEFAULT_DESCRIPTOR;
+};
+
+/**
+ * Split a combined multi-file diff into per-file sections so a file without
+ * its own `patch` field can still show an inline diff.
+ */
+const splitDiffSections = (diff: string): string[] => {
+ if (!diff) return [];
+ const sections: string[] = [];
+ let current: string[] = [];
+ for (const line of diff.split('\n')) {
+ if (/^diff --(git|cc|combined) /.test(line)) {
+ if (current.length > 0) {
+ sections.push(current.join('\n'));
+ current = [];
+ }
+ }
+ current.push(line);
+ }
+ if (current.length > 0) {
+ sections.push(current.join('\n'));
+ }
+ return sections;
+};
+
+const diffSectionFor = (diff: string | null | undefined, filename: string): string | null => {
+ if (!diff) return null;
+ const needle = ` b/${filename}`;
+ const section = splitDiffSections(diff).find((sectionText) => sectionText.includes(needle));
+ return section && section.trim() ? section : null;
+};
+
+/**
+ * File-change list for a pull request. Each row shows the change symbol
+ * (A/M/D/R), filename, and add/delete counts, and expands to an inline diff
+ * rendered by PierreDiffViewer (per-file `patch` when present, else the
+ * matching section sliced from the combined `diff`). Pure presentation.
+ */
+export const ForgeFilesDiffSection = React.memo(function ForgeFilesDiffSection({ files, diff, loading, error }) {
+ const { t } = useI18n();
+ const [openPaths, setOpenPaths] = useState>(new Set());
+
+ const toggle = React.useCallback((path: string) => {
+ setOpenPaths((previous) => {
+ const next = new Set(previous);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ }, []);
+
+ const fileDiffs = useMemo(() => {
+ const map = new Map();
+ for (const file of files ?? []) {
+ const patch = file.patch?.trim() ? file.patch : diffSectionFor(diff, file.filename);
+ map.set(file.filename, patch && patch.trim() ? fileDiffFromPatch(file.filename, patch) : null);
+ }
+ return map;
+ }, [diff, files]);
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ {error}
+
+ );
+ }
+
+ if (!files || files.length === 0) {
+ return {t('forge.files.empty')}
;
+ }
+
+ return (
+
+ {files.map((file) => {
+ const descriptor = descriptorFor(file.status);
+ const isOpen = openPaths.has(file.filename);
+ const fileDiff = fileDiffs.get(file.filename) ?? null;
+ return (
+
+ toggle(file.filename)}
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-[var(--interactive-hover)]/40"
+ aria-expanded={isOpen}
+ >
+
+ {descriptor.code}
+
+
+ {file.filename}
+
+
+ +{file.additions ?? 0}
+ /
+ -{file.deletions ?? 0}
+
+
+
+ {isOpen ? (
+
+ {fileDiff ? (
+
+ ) : (
+
{t('forge.files.noDiff')}
+ )}
+
+ ) : null}
+
+ );
+ })}
+
+ );
+});
diff --git a/packages/ui/src/components/views/forge/ForgeMetadataChips.tsx b/packages/ui/src/components/views/forge/ForgeMetadataChips.tsx
new file mode 100644
index 00000000..32174a92
--- /dev/null
+++ b/packages/ui/src/components/views/forge/ForgeMetadataChips.tsx
@@ -0,0 +1,148 @@
+import React from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { useI18n } from '@/lib/i18n';
+import { formatDateTimeForPreference } from '@/lib/timeFormat';
+import { useUIStore } from '@/stores/useUIStore';
+import type { ForgeIssue, ForgePullRequest, ForgeUser } from '@/lib/forge/types';
+
+interface ForgeMetadataChipsProps {
+ kind: 'pull' | 'issue';
+ pr?: ForgePullRequest | null;
+ issue?: ForgeIssue | null;
+}
+
+const chipClassName =
+ 'inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-surface-elevated px-2 py-0.5 typography-micro text-foreground';
+
+const avatarSize = 'size-3.5 rounded-full';
+
+/** GitHub label colors arrive without the `#` prefix; normalize both spellings. */
+const resolveLabelColor = (color?: string): string | null => {
+ if (!color) return null;
+ const value = color.trim();
+ if (!value) return null;
+ return value.startsWith('#') ? value : `#${value}`;
+};
+
+const Avatar: React.FC<{ user: ForgeUser }> = ({ user }) => {
+ const initial = (user.login || user.name || '?').charAt(0).toUpperCase();
+ if (user.avatarUrl) {
+ return ;
+ }
+ return (
+
+ {initial}
+
+ );
+};
+
+/**
+ * Metadata chips for a pull request or issue: labels, assignees, milestone,
+ * author, created/updated dates, and (for PRs) the base→head branch pair.
+ * Pure presentation — all data arrives via props. Renders nothing when every
+ * metadata group is absent.
+ */
+export const ForgeMetadataChips = React.memo(function ForgeMetadataChips({ kind, pr, issue }) {
+ const { t } = useI18n();
+ const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
+
+ const entity = pr ?? issue;
+ if (!entity) return null;
+
+ const labels = entity.labels ?? [];
+ const assignees = entity.assignees ?? [];
+ const pull = pr ?? null;
+ const baseRef = pull?.base?.ref;
+ const headRef = pull?.head?.ref;
+ const hasAny =
+ labels.length > 0
+ || assignees.length > 0
+ || Boolean(entity.milestone)
+ || Boolean(entity.author)
+ || Boolean(entity.createdAt)
+ || Boolean(entity.updatedAt)
+ || (kind === 'pull' && Boolean(baseRef && headRef));
+
+ if (!hasAny) return null;
+
+ const formatDate = (value?: string): string => {
+ if (!value) return '';
+ const ts = Date.parse(value);
+ if (!Number.isFinite(ts)) return value;
+ return formatDateTimeForPreference(ts, timeFormatPreference, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+ };
+
+ return (
+
+ {labels.map((label) => {
+ const color = resolveLabelColor(label.color);
+ return (
+
+
+ {label.name}
+
+ );
+ })}
+
+ {assignees.map((assignee) => (
+
+
+ {assignee.login}
+
+ ))}
+
+ {entity.milestone ? (
+
+
+ {entity.milestone.title}
+
+ ) : null}
+
+ {entity.author ? (
+
+
+ {entity.author.login}
+
+ ) : null}
+
+ {entity.createdAt ? (
+
+
+ {formatDate(entity.createdAt)}
+
+ ) : null}
+
+ {entity.updatedAt ? (
+
+
+ {formatDate(entity.updatedAt)}
+
+ ) : null}
+
+ {kind === 'pull' && baseRef && headRef ? (
+
+ {baseRef}
+
+ {headRef}
+
+ ) : null}
+
+ );
+});
diff --git a/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx b/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx
new file mode 100644
index 00000000..fdb89211
--- /dev/null
+++ b/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx
@@ -0,0 +1,280 @@
+import React, { useMemo } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import { useI18n } from '@/lib/i18n';
+import { formatDateTimeForPreference } from '@/lib/timeFormat';
+import { useUIStore } from '@/stores/useUIStore';
+import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
+import type { IconName } from '@/components/icon/icons';
+import type { ForgeComment, ForgeTimelineEvent, ForgeTimelineEventType, ForgeUser } from '@/lib/forge/types';
+
+interface ForgeTimelineSectionProps {
+ events: ForgeTimelineEvent[];
+ comments: ForgeComment[];
+ loading?: boolean;
+ error?: string | null;
+ /** Optional: asked when the user hits Reply on an inline-comment thread (its root comment). */
+ onReply?: (comment: ForgeComment) => void;
+ /** Optional: rendered under a thread card the parent is replying to. */
+ renderReply?: (comment: ForgeComment) => React.ReactNode;
+}
+
+const EVENT_ICONS: Record = {
+ opened: 'git-pull-request',
+ reopened: 'git-pull-request',
+ closed: 'git-close-pull-request',
+ merged: 'git-merge',
+ committed: 'git-commit',
+ reviewed: 'eye',
+ approved: 'checkbox-circle',
+ 'requested-changes': 'alert',
+ commented: 'chat-1',
+ referenced: 'external-link',
+ labeled: 'pushpin',
+ unlabeled: 'pushpin',
+ assigned: 'user',
+ unassigned: 'user',
+ milestoned: 'target',
+ demilestoned: 'target',
+ other: 'more',
+};
+
+const EVENT_COLORS: Partial> = {
+ approved: 'var(--status-success)',
+ 'requested-changes': 'var(--status-error)',
+ merged: 'var(--pr-merged)',
+ closed: 'var(--pr-closed)',
+};
+
+const toTimestamp = (value?: string): number => {
+ if (!value) return 0;
+ const parsed = Date.parse(value);
+ return Number.isFinite(parsed) ? parsed : 0;
+};
+
+const CommentAvatar: React.FC<{ author?: ForgeUser | null }> = ({ author }) => {
+ const label = author?.name ?? author?.login ?? '?';
+ const initial = label.charAt(0).toUpperCase();
+ return (
+
+ {author?.avatarUrl ? (
+
+ ) : (
+
{initial}
+ )}
+
+ );
+};
+
+const InlineContextChip: React.FC<{ comment: ForgeComment; label: string }> = ({ comment, label }) => {
+ if (!comment.path) return null;
+ const text = comment.line ? `${comment.path}:${comment.line}` : comment.path;
+ return (
+
+
+ {text}
+
+ );
+};
+
+type TimelineItem =
+ | { kind: 'event'; event: ForgeTimelineEvent }
+ | { kind: 'thread'; thread: ForgeComment[] };
+
+/**
+ * Chronologically merged activity timeline for a pull request or issue: event
+ * markers interleaved with comment threads. Inline review comments are grouped
+ * by `inReplyToId` chains or (path, line) buckets; a thread renders as one
+ * card with its comments stacked. Pure presentation.
+ */
+export const ForgeTimelineSection = React.memo(function ForgeTimelineSection({ events, comments, loading, error, onReply, renderReply }) {
+ const { t } = useI18n();
+ const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
+
+ const formatTime = React.useCallback((value?: string): string => {
+ if (!value) return '';
+ const ts = Date.parse(value);
+ if (!Number.isFinite(ts)) return value;
+ return formatDateTimeForPreference(ts, timeFormatPreference, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+ }, [timeFormatPreference]);
+
+ const threads = useMemo(() => {
+ const byId = new Map(comments.map((comment) => [comment.id, comment]));
+ const repliesByParent = new Map();
+ for (const comment of comments) {
+ if (comment.inReplyToId && byId.has(comment.inReplyToId)) {
+ const list = repliesByParent.get(comment.inReplyToId) ?? [];
+ list.push(comment);
+ repliesByParent.set(comment.inReplyToId, list);
+ }
+ }
+
+ const collect = (root: ForgeComment): ForgeComment[] => {
+ const members: ForgeComment[] = [];
+ const visit = (comment: ForgeComment): void => {
+ members.push(comment);
+ for (const reply of repliesByParent.get(comment.id) ?? []) {
+ visit(reply);
+ }
+ };
+ visit(root);
+ return members.sort((a, b) => toTimestamp(a.createdAt) - toTimestamp(b.createdAt));
+ };
+
+ const roots = comments.filter((comment) => !comment.inReplyToId || !byId.has(comment.inReplyToId));
+ const buckets = new Map();
+ const standalone: ForgeComment[] = [];
+ for (const root of roots) {
+ if (root.path) {
+ const key = root.line ? `${root.path}:${root.line}` : `path:${root.path}`;
+ const list = buckets.get(key) ?? [];
+ list.push(root);
+ buckets.set(key, list);
+ } else {
+ standalone.push(root);
+ }
+ }
+
+ const seen = new Set();
+ const dedupe = (members: ForgeComment[]): ForgeComment[] =>
+ members.filter((member) => {
+ if (seen.has(member.id)) return false;
+ seen.add(member.id);
+ return true;
+ });
+
+ const result: ForgeComment[][] = [];
+ for (const root of buckets.values()) {
+ result.push(dedupe(root.flatMap(collect)));
+ }
+ for (const root of standalone) {
+ result.push(dedupe(collect(root)));
+ }
+ return result;
+ }, [comments]);
+
+ const items = useMemo(() => {
+ const all: TimelineItem[] = [
+ ...events.map((event) => ({ kind: 'event' as const, event })),
+ ...threads.map((thread) => ({ kind: 'thread' as const, thread })),
+ ];
+ all.sort((a, b) => {
+ const aTs = a.kind === 'event' ? toTimestamp(a.event.createdAt) : toTimestamp(a.thread[0]?.createdAt);
+ const bTs = b.kind === 'event' ? toTimestamp(b.event.createdAt) : toTimestamp(b.thread[0]?.createdAt);
+ return aTs - bTs;
+ });
+ return all;
+ }, [events, threads]);
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ {error}
+
+ );
+ }
+
+ if (items.length === 0) {
+ return {t('forge.timeline.empty')}
;
+ }
+
+ return (
+
+
+ {items.map((item, idx) => {
+ const isLast = idx === items.length - 1;
+ if (item.kind === 'event') {
+ const { event } = item;
+ return (
+
+ {!isLast ?
: null}
+
+
+
+
+ {t(`forge.timeline.event.${event.type}` as never)}
+ {event.author ? {event.author.login} : null}
+ {event.createdAt ? {formatTime(event.createdAt)} : null}
+
+ {event.body ? (
+
{event.body}
+ ) : null}
+
+ );
+ }
+ const { thread } = item;
+ const root = thread[0];
+ return (
+
+ {!isLast ?
: null}
+
+
+
+ {thread.map((comment, commentIdx) => (
+
0 ? 'border-t border-border/40 pt-3' : ''}
+ >
+
+
+ {comment.author?.name ?? comment.author?.login ?? 'Unknown'}
+
+ {comment.createdAt ? {formatTime(comment.createdAt)} : null}
+
+
+
+
+ ))}
+
+ {root.path && onReply ? (
+
+ onReply(root)}
+ aria-label={t('forge.actions.reply')}
+ >
+ {t('forge.actions.reply')}
+
+
+ ) : null}
+ {renderReply ? renderReply(root) : null}
+
+
+ );
+ })}
+
+
+ );
+});
diff --git a/packages/ui/src/components/views/forge/LinkedSessionsSection.tsx b/packages/ui/src/components/views/forge/LinkedSessionsSection.tsx
new file mode 100644
index 00000000..3315cc31
--- /dev/null
+++ b/packages/ui/src/components/views/forge/LinkedSessionsSection.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { useI18n } from '@/lib/i18n';
+import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
+import type { LinkedSessionRow } from '@/lib/linkedSessionMatches';
+
+interface LinkedSessionsSectionProps {
+ sessions: LinkedSessionRow[];
+ /** Called with the session id when a row is clicked to open its chat. */
+ onOpenSession: (sessionId: string) => void;
+}
+
+/**
+ * "Chats working on this" — sessions in the current project that have this
+ * forge entity linked (`metadata.openchamber.linked_issues`). Purely derived
+ * from the already-loaded session list; rows open the session's chat. Renders
+ * nothing when there are no matches.
+ */
+export const LinkedSessionsSection = React.memo(function LinkedSessionsSection({
+ sessions,
+ onOpenSession,
+}) {
+ const { t } = useI18n();
+
+ if (sessions.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
{t('forge.linkedSessions.title')}
+
+ {sessions.length}
+
+
+
+ {sessions.map((session) => (
+
+ onOpenSession(session.sessionId)}
+ className="flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-1 py-1.5 text-left transition-colors hover:bg-interactive-hover/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
+ aria-label={t('forge.linkedSessions.open', { title: session.title })}
+ title={session.title}
+ >
+
+ {session.title}
+ {typeof session.linkedAt === 'number' ? (
+
+ {formatSessionCompactDateLabel(session.linkedAt)}
+
+ ) : null}
+
+
+ ))}
+
+
+ );
+});
diff --git a/packages/ui/src/components/views/forge/actions/ForgeCommentComposer.tsx b/packages/ui/src/components/views/forge/actions/ForgeCommentComposer.tsx
new file mode 100644
index 00000000..10661f7b
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/ForgeCommentComposer.tsx
@@ -0,0 +1,77 @@
+import React, { useState } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { toast } from '@/components/ui/toast';
+import { useI18n } from '@/lib/i18n';
+import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
+import type { ForgeComment } from '@/lib/forge/types';
+import { ForgeMentionTextarea } from './ForgeMentionTextarea';
+
+interface ForgeCommentComposerProps {
+ provider: ForgeProvider;
+ directory: string;
+ ref: ForgeEntityRef;
+ onPosted?: (comment: ForgeComment) => void;
+}
+
+/**
+ * Comment composer for an issue or pull request thread. Renders nothing when
+ * the provider has no `addComment` method. Posts through the facade and reports
+ * the created comment via `onPosted`; failures toast a stable message.
+ */
+export const ForgeCommentComposer: React.FC = ({ provider, directory, ref, onPosted }) => {
+ const { t } = useI18n();
+ const [body, setBody] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ const addComment = provider.addComment;
+ if (!addComment) return null;
+
+ const canSubmit = body.trim().length > 0 && !submitting;
+
+ const submit = async (): Promise => {
+ if (!canSubmit) return;
+ setSubmitting(true);
+ try {
+ const result = await addComment(directory, ref, { body: body.trim() });
+ if (!result.ok) {
+ toast.error(t('forge.actions.error'));
+ return;
+ }
+ setBody('');
+ if (result.comment) onPosted?.(result.comment);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ void submit()} disabled={!canSubmit}>
+ {submitting ? (
+ <>
+
+ {t('forge.actions.posting')}
+ >
+ ) : (
+ <>
+
+ {t('forge.actions.comment')}
+ >
+ )}
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/views/forge/actions/ForgeCreateIssueDialog.tsx b/packages/ui/src/components/views/forge/actions/ForgeCreateIssueDialog.tsx
new file mode 100644
index 00000000..c94d2ccf
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/ForgeCreateIssueDialog.tsx
@@ -0,0 +1,124 @@
+import React, { useState } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { toast } from '@/components/ui/toast';
+import { useI18n } from '@/lib/i18n';
+import type { ForgeIssue } from '@/lib/forge';
+import type { ForgeProvider } from '@/lib/forge/provider';
+
+interface ForgeCreateIssueDialogProps {
+ provider: ForgeProvider;
+ directory: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onCreated?: (issue: ForgeIssue) => void;
+}
+
+/**
+ * Create-issue dialog for a forge provider. Renders nothing when the provider
+ * has no `createIssue` method. Submits title/body/labels (comma-separated
+ * input) through the facade and reports success via `onCreated` so the list
+ * can refresh.
+ */
+export const ForgeCreateIssueDialog: React.FC = ({
+ provider,
+ directory,
+ open,
+ onOpenChange,
+ onCreated,
+}) => {
+ const { t } = useI18n();
+ const [title, setTitle] = useState('');
+ const [body, setBody] = useState('');
+ const [labels, setLabels] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ const createIssue = provider.createIssue;
+ if (!createIssue) return null;
+
+ const reset = (): void => {
+ setTitle('');
+ setBody('');
+ setLabels('');
+ };
+
+ const handleOpenChange = (next: boolean): void => {
+ if (!next) {
+ reset();
+ }
+ onOpenChange(next);
+ };
+
+ const submit = async (): Promise => {
+ const trimmedTitle = title.trim();
+ if (!trimmedTitle || submitting) return;
+ setSubmitting(true);
+ try {
+ const labelList = labels
+ .split(',')
+ .map((label) => label.trim())
+ .filter(Boolean);
+ const result = await createIssue(directory, {
+ title: trimmedTitle,
+ ...(body.trim() ? { body: body.trim() } : {}),
+ ...(labelList.length > 0 ? { labels: labelList } : {}),
+ });
+ if (!result.ok || !result.issue) {
+ toast.error(t('forge.actions.error'));
+ return;
+ }
+ toast.success(t('forge.actions.issueCreated'));
+ reset();
+ onOpenChange(false);
+ onCreated?.(result.issue);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ {t('forge.actions.issueDialogTitle')}
+
+
+ setTitle(event.target.value)}
+ placeholder={t('forge.actions.issueTitlePlaceholder')}
+ disabled={submitting}
+ aria-label={t('forge.actions.issueTitlePlaceholder')}
+ />
+
+
+ handleOpenChange(false)} disabled={submitting}>
+ {t('forge.actions.cancel')}
+
+ void submit()} disabled={submitting || title.trim().length === 0}>
+ {submitting ? : }
+ {t('forge.actions.createIssue')}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/views/forge/actions/ForgeDraftToggle.tsx b/packages/ui/src/components/views/forge/actions/ForgeDraftToggle.tsx
new file mode 100644
index 00000000..e90fd76a
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/ForgeDraftToggle.tsx
@@ -0,0 +1,63 @@
+import React, { useState } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { toast } from '@/components/ui/toast';
+import { useI18n } from '@/lib/i18n';
+import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
+
+interface ForgeDraftToggleProps {
+ provider: ForgeProvider;
+ directory: string;
+ ref: ForgeEntityRef;
+ draft: boolean;
+ onChanged?: (draft: boolean) => void;
+}
+
+/**
+ * Draft <-> ready toggle for a pull request. Renders nothing unless the
+ * provider supports drafts (`capabilities.draft`) and implements
+ * `toggleDraft`. Marks the PR ready when it is a draft, and back to draft
+ * otherwise.
+ */
+export const ForgeDraftToggle: React.FC = ({ provider, directory, ref, draft, onChanged }) => {
+ const { t } = useI18n();
+ const [submitting, setSubmitting] = useState(false);
+
+ const toggleDraft = provider.toggleDraft;
+ if (!toggleDraft || !provider.capabilities.draft) return null;
+
+ const nextDraft = !draft;
+
+ const run = async (): Promise => {
+ if (submitting) return;
+ setSubmitting(true);
+ try {
+ const result = await toggleDraft(directory, ref, nextDraft);
+ if (!result.ok) {
+ toast.error(t('forge.actions.error'));
+ return;
+ }
+ toast.success(t('forge.actions.draftChanged'));
+ onChanged?.(nextDraft);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+ void run()}
+ disabled={submitting}
+ aria-label={t(draft ? 'forge.actions.markReady' : 'forge.actions.markDraft')}
+ >
+ {submitting ? (
+
+ ) : (
+
+ )}
+ {t(draft ? 'forge.actions.markReady' : 'forge.actions.markDraft')}
+
+ );
+};
diff --git a/packages/ui/src/components/views/forge/actions/ForgeEditForm.tsx b/packages/ui/src/components/views/forge/actions/ForgeEditForm.tsx
new file mode 100644
index 00000000..67ca3104
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/ForgeEditForm.tsx
@@ -0,0 +1,84 @@
+import React, { useState } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { toast } from '@/components/ui/toast';
+import { useI18n } from '@/lib/i18n';
+import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
+
+interface ForgeEditFormProps {
+ provider: ForgeProvider;
+ directory: string;
+ ref: ForgeEntityRef;
+ title: string;
+ body?: string;
+ onSaved?: () => void;
+ onCancel?: () => void;
+}
+
+/**
+ * Inline edit form for an issue/PR title and body. Renders nothing when the
+ * provider has no `updateEntity` method. Saves both fields in one write and
+ * reports through `onSaved`.
+ */
+export const ForgeEditForm: React.FC = ({ provider, directory, ref, title, body, onSaved, onCancel }) => {
+ const { t } = useI18n();
+ const [editTitle, setEditTitle] = useState(title);
+ const [editBody, setEditBody] = useState(body ?? '');
+ const [submitting, setSubmitting] = useState(false);
+
+ const updateEntity = provider.updateEntity;
+ if (!updateEntity) return null;
+
+ const canSubmit = editTitle.trim().length > 0 && !submitting;
+
+ const save = async (): Promise => {
+ if (!canSubmit) return;
+ setSubmitting(true);
+ try {
+ const result = await updateEntity(directory, ref, { title: editTitle.trim(), body: editBody });
+ if (!result.ok) {
+ toast.error(t('forge.actions.error'));
+ return;
+ }
+ toast.success(t('forge.actions.updated'));
+ onSaved?.();
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/packages/ui/src/components/views/forge/actions/ForgeEntityActions.tsx b/packages/ui/src/components/views/forge/actions/ForgeEntityActions.tsx
new file mode 100644
index 00000000..df93eb01
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/ForgeEntityActions.tsx
@@ -0,0 +1,106 @@
+import React, { useCallback, useState } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { useI18n } from '@/lib/i18n';
+import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
+import type { ForgeEntityState, ForgeIssue, ForgePullRequest } from '@/lib/forge/types';
+import { ForgeDraftToggle } from './ForgeDraftToggle';
+import { ForgeEditForm } from './ForgeEditForm';
+import { ForgeReviewActions } from './ForgeReviewActions';
+import { ForgeStateActions } from './ForgeStateActions';
+
+interface ForgeEntityActionsProps {
+ provider: ForgeProvider;
+ directory: string;
+ ref: ForgeEntityRef;
+ pr?: ForgePullRequest | null;
+ issue?: ForgeIssue | null;
+ onChanged?: () => void;
+}
+
+/**
+ * Header action bar for a forge issue or pull request: Edit (expands an inline
+ * form), draft toggle + review actions (pulls only), and close/reopen. Each
+ * affordance is capability- and method-gated; the bar renders nothing when no
+ * write operation applies. Successes funnel through `onChanged` so the owning
+ * view can refetch.
+ */
+export const ForgeEntityActions: React.FC = ({ provider, directory, ref, pr, issue, onChanged }) => {
+ const { t } = useI18n();
+ const [editing, setEditing] = useState(false);
+
+ const entity = pr ?? issue;
+ const entityState: ForgeEntityState = entity?.state ?? 'open';
+ const isPull = ref.kind === 'pull';
+
+ const updateEntity = provider.updateEntity;
+ const hasEdit = Boolean(updateEntity) && Boolean(entity);
+ const hasDraft = isPull && provider.capabilities.draft && typeof provider.toggleDraft === 'function';
+ const hasState = Boolean(updateEntity) && entityState !== 'merged';
+ const hasReview = isPull && provider.capabilities.reviews !== 'none' && typeof provider.submitReview === 'function';
+
+ const showOtherActions = !editing && (hasDraft || hasState || hasReview);
+
+ const onSaved = useCallback(() => {
+ setEditing(false);
+ onChanged?.();
+ }, [onChanged]);
+
+ if (!hasEdit && !showOtherActions) return null;
+
+ return (
+
+
+ {hasEdit && !editing ? (
+
setEditing(true)}
+ aria-label={t('forge.actions.edit')}
+ >
+
+ {t('forge.actions.edit')}
+
+ ) : null}
+
+ {hasEdit && showOtherActions ?
: null}
+
+ {showOtherActions ? (
+ <>
+ {hasDraft && pr ? (
+
+ ) : null}
+ {hasState ? (
+
+ ) : null}
+ {hasReview ?
: null}
+ >
+ ) : null}
+
+
+ {editing && entity ? (
+
setEditing(false)}
+ />
+ ) : null}
+
+ );
+};
diff --git a/packages/ui/src/components/views/forge/actions/ForgeLookupCombobox.tsx b/packages/ui/src/components/views/forge/actions/ForgeLookupCombobox.tsx
new file mode 100644
index 00000000..66fbfac7
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/ForgeLookupCombobox.tsx
@@ -0,0 +1,249 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { cn } from '@/lib/utils';
+import { Icon } from '@/components/icon/Icon';
+import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { useI18n } from '@/lib/i18n';
+import type { ForgeProvider } from '@/lib/forge/provider';
+import { useForgeLookup } from './useForgeLookup';
+import type { ForgeLookupKind, ForgeLookupOption } from './useForgeLookup';
+
+export interface ForgeLookupComboboxProps {
+ provider: ForgeProvider;
+ directory: string;
+ /** Cross-repo (fork) selector, passed through to the facade. */
+ sourceRepo?: string | null;
+ kind: ForgeLookupKind;
+ value: string;
+ onChange: (value: string) => void;
+ /** Called when the user picks an option (not when they type free text). */
+ onSelect: (option: ForgeLookupOption) => void;
+ placeholder?: string;
+ ariaLabel?: string;
+ disabled?: boolean;
+ className?: string;
+}
+
+const normalizeColor = (color?: string): string | null => {
+ if (!color) return null;
+ const value = color.trim();
+ if (!value) return null;
+ return value.startsWith('#') ? value : `#${value}`;
+};
+
+/**
+ * Search-as-you-type combobox for forge metadata fields (assignees, labels,
+ * milestones, branches, tags). Renders a plain input until the provider offers
+ * a matching `search*` method; once it does, typing opens a dropdown of
+ * repo-scoped options with keyboard navigation. Selecting an option calls
+ * `onSelect`; free text still passes through `onChange` so surfaces keep their
+ * free-entry fallback.
+ */
+export const ForgeLookupCombobox: React.FC = ({
+ provider,
+ directory,
+ sourceRepo,
+ kind,
+ value,
+ onChange,
+ onSelect,
+ placeholder,
+ ariaLabel,
+ disabled,
+ className,
+}) => {
+ const { t } = useI18n();
+ const rootRef = useRef(null);
+ const inputRef = useRef(null);
+ const panelRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [highlighted, setHighlighted] = useState(0);
+ const [panelPos, setPanelPos] = useState<{ top: number; left: number; width: number; flip: boolean } | null>(null);
+ const { options, loading, initialized } = useForgeLookup({ provider, directory, sourceRepo, kind, query: value });
+
+ const hasLookup = useMemo(() => {
+ switch (kind) {
+ case 'users':
+ return typeof provider.searchUsers === 'function' && provider.capabilities.userSearch;
+ case 'labels':
+ return typeof provider.searchLabels === 'function' && provider.capabilities.labelSearch;
+ case 'milestones':
+ return typeof provider.searchMilestones === 'function' && provider.capabilities.milestoneSearch;
+ case 'branches':
+ return typeof provider.searchBranches === 'function' && provider.capabilities.branchSearch;
+ case 'tags':
+ return typeof provider.searchTags === 'function' && provider.capabilities.tagSearch;
+ }
+ }, [kind, provider]);
+
+ useEffect(() => {
+ setHighlighted(0);
+ }, [options]);
+
+ // Close on outside click. The dropdown renders in a portal (so it escapes
+ // the clipped, scrollable forge surfaces), so both the trigger and the
+ // portal panel count as "inside".
+ useEffect(() => {
+ if (!open) return;
+ const handlePointerDown = (event: MouseEvent | TouchEvent) => {
+ const target = event.target as Node | null;
+ if (target && rootRef.current && panelRef.current) {
+ if (rootRef.current.contains(target) || panelRef.current.contains(target)) return;
+ }
+ setOpen(false);
+ };
+ document.addEventListener('pointerdown', handlePointerDown, true);
+ return () => document.removeEventListener('pointerdown', handlePointerDown, true);
+ }, [open]);
+
+ // Position the portal panel from the input's viewport rect. `flip` renders
+ // the panel above the field when there is no room below it.
+ useEffect(() => {
+ if (!open || !hasLookup) {
+ setPanelPos(null);
+ return;
+ }
+ const input = inputRef.current;
+ if (!input) return;
+ const rect = input.getBoundingClientRect();
+ const gap = 4;
+ const maxHeight = 176; // matches max-h-44
+ const edge = 8;
+ const width = Math.min(rect.width, window.innerWidth - edge * 2);
+ const left = Math.max(edge, Math.min(rect.left, window.innerWidth - width - edge));
+ const flip = rect.bottom + gap + maxHeight > window.innerHeight - edge && rect.top - gap - maxHeight > edge;
+ setPanelPos({ top: flip ? rect.top - gap : rect.bottom + gap, left, width, flip });
+ }, [hasLookup, open]);
+
+ // Scrolling the page/surfaces under a portal dropdown would leave it
+ // detached from its field; close unless the interaction is inside the
+ // panel (its own scrollable list) or the trigger.
+ useEffect(() => {
+ if (!open) return;
+ const closeOnScroll = (event: Event) => {
+ const target = event.target as Node | null;
+ if (target && rootRef.current && panelRef.current) {
+ if (rootRef.current.contains(target) || panelRef.current.contains(target)) return;
+ }
+ setOpen(false);
+ };
+ const closeOnResize = () => setOpen(false);
+ document.addEventListener('scroll', closeOnScroll, true);
+ window.addEventListener('resize', closeOnResize);
+ return () => {
+ document.removeEventListener('scroll', closeOnScroll, true);
+ window.removeEventListener('resize', closeOnResize);
+ };
+ }, [open]);
+
+ const choose = useCallback((option: ForgeLookupOption) => {
+ setOpen(false);
+ onSelect(option);
+ }, [onSelect]);
+
+ return (
+
+
{
+ onChange(event.target.value);
+ if (event.target.value.trim()) setOpen(true);
+ }}
+ onFocus={() => {
+ if (hasLookup) setOpen(true);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'ArrowDown') {
+ if (!open) {
+ setOpen(true);
+ return;
+ }
+ event.preventDefault();
+ setHighlighted((prev) => (options.length ? (prev + 1) % options.length : 0));
+ return;
+ }
+ if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ setHighlighted((prev) => (options.length ? (prev - 1 + options.length) % options.length : 0));
+ return;
+ }
+ if (event.key === 'Escape') {
+ if (open) {
+ event.preventDefault();
+ setOpen(false);
+ }
+ return;
+ }
+ if (event.key === 'Enter' && open && options[highlighted]) {
+ event.preventDefault();
+ choose(options[highlighted]);
+ return;
+ }
+ }}
+ placeholder={placeholder}
+ aria-label={ariaLabel}
+ disabled={disabled}
+ aria-expanded={open}
+ aria-autocomplete="list"
+ aria-controls={open ? 'forge-lookup-list' : undefined}
+ aria-activedescendant={open && options[highlighted] ? `forge-lookup-${kind}-${options[highlighted].key}` : undefined}
+ className={cn('h-6 w-36 appearance-none rounded-md bg-[var(--surface-elevated)] px-2 typography-micro text-foreground placeholder:text-muted-foreground', 'ring-1 ring-inset ring-border/60 focus:ring-2 focus:ring-[var(--interactive-focus-ring)] focus-visible:outline-none', className)}
+ />
+ {open && hasLookup && panelPos
+ ? createPortal(
+
+
+ {loading || !initialized ? (
+
+
+ {t('forge.lookup.loading')}
+
+ ) : options.length === 0 ? (
+ {t('forge.lookup.empty')}
+ ) : (
+ options.map((option, index) => (
+ choose(option)}
+ onMouseMove={() => setHighlighted(index)}
+ >
+ {option.avatarUrl ? (
+
+ ) : option.color ? (
+
+ ) : null}
+
{option.label}
+ {option.secondary ? (
+
{option.secondary}
+ ) : null}
+
+ ))
+ )}
+
+
,
+ document.body,
+ )
+ : null}
+
+ );
+};
\ No newline at end of file
diff --git a/packages/ui/src/components/views/forge/actions/ForgeMentionTextarea.tsx b/packages/ui/src/components/views/forge/actions/ForgeMentionTextarea.tsx
new file mode 100644
index 00000000..af4e2374
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/ForgeMentionTextarea.tsx
@@ -0,0 +1,266 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { cn } from '@/lib/utils';
+import { Icon } from '@/components/icon/Icon';
+import { Textarea } from '@/components/ui/textarea';
+import { useI18n } from '@/lib/i18n';
+import type { ForgeProvider } from '@/lib/forge/provider';
+import { useForgeLookup } from './useForgeLookup';
+import type { ForgeLookupOption } from './useForgeLookup';
+
+export interface ForgeMentionTextareaProps {
+ provider: ForgeProvider;
+ directory: string;
+ /** Cross-repo (fork) selector, passed through to the user lookup. */
+ sourceRepo?: string | null;
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ ariaLabel?: string;
+ disabled?: boolean;
+ className?: string;
+ autoFocus?: boolean;
+}
+
+/** `@`-prefixed token before the caret, e.g. `{ start: 4, query: 'octo' }` for `hey @octo|`. */
+interface MentionToken {
+ start: number;
+ query: string;
+}
+
+const MENTION_RE = /(^|\s|[,;(])@([a-zA-Z0-9][a-zA-Z0-9-_.]*)$/;
+
+/**
+ * Detect the mention token ending at `caret` in `text`. Returns null when there
+ * is no `@`-trigger in flight.
+ */
+const findMentionToken = (text: string, caret: number): MentionToken | null => {
+ const before = text.slice(0, caret);
+ const match = MENTION_RE.exec(before);
+ if (!match) return null;
+ const prefix = match[1] ?? '';
+ return { start: caret - match[0].length + prefix.length, query: match[2] };
+};
+
+/**
+ * Textarea with repo-scoped @-mention autocomplete for forge comment bodies.
+ *
+ * Typing `@` followed by a prefix opens a dropdown of assignable users from
+ * `provider.searchUsers` (debounced). Arrow keys move the highlight, Enter/Tab
+ * insert `@login ` in place of the partial token, and Escape closes the list.
+ * Rendering is gated on `capabilities.userSearch` + method presence; otherwise
+ * it behaves as a plain textarea.
+ */
+export const ForgeMentionTextarea: React.FC = ({
+ provider,
+ directory,
+ sourceRepo,
+ value,
+ onChange,
+ placeholder,
+ ariaLabel,
+ disabled,
+ className,
+ autoFocus,
+}) => {
+ const { t } = useI18n();
+ const rootRef = useRef(null);
+ const textareaRef = useRef(null);
+ const panelRef = useRef(null);
+ const [token, setToken] = useState(null);
+ const [highlighted, setHighlighted] = useState(0);
+ const [panelPos, setPanelPos] = useState<{ top: number; left: number; width: number; flip: boolean } | null>(null);
+
+ const hasLookup = typeof provider.searchUsers === 'function' && provider.capabilities.userSearch;
+ const { options, loading, initialized } = useForgeLookup({
+ provider,
+ directory,
+ sourceRepo,
+ kind: 'users',
+ query: token?.query ?? '',
+ });
+
+ useEffect(() => {
+ setHighlighted(0);
+ }, [options]);
+
+ // Close on outside click. The mention list renders in a portal (so it
+ // escapes the clipped, scrollable forge surfaces), so both the trigger and
+ // the portal panel count as "inside".
+ useEffect(() => {
+ if (!token) return;
+ const handlePointerDown = (event: MouseEvent | TouchEvent) => {
+ const target = event.target as Node | null;
+ if (target && rootRef.current && panelRef.current) {
+ if (rootRef.current.contains(target) || panelRef.current.contains(target)) return;
+ }
+ setToken(null);
+ };
+ document.addEventListener('pointerdown', handlePointerDown, true);
+ return () => document.removeEventListener('pointerdown', handlePointerDown, true);
+ }, [token]);
+
+ // Position the portal panel from the textarea's viewport rect. It opens
+ // above the field and flips below when there is no room above it.
+ const mentionOpen = token !== null && hasLookup;
+ useEffect(() => {
+ if (!mentionOpen) {
+ setPanelPos(null);
+ return;
+ }
+ const textarea = textareaRef.current;
+ if (!textarea) return;
+ const rect = textarea.getBoundingClientRect();
+ const gap = 4;
+ const maxHeight = 176; // matches max-h-44
+ const edge = 8;
+ const width = Math.min(rect.width, window.innerWidth - edge * 2);
+ const left = Math.max(edge, Math.min(rect.left, window.innerWidth - width - edge));
+ const flip = rect.top - gap < edge && rect.bottom + gap + maxHeight <= window.innerHeight - edge;
+ setPanelPos({ top: flip ? rect.bottom + gap : rect.top - gap, left, width, flip });
+ }, [mentionOpen]);
+
+ // Scrolling the page/surfaces under a portal dropdown would leave it
+ // detached from its field; close unless the interaction is inside the
+ // panel (its own scrollable list) or the trigger.
+ useEffect(() => {
+ if (!mentionOpen) return;
+ const closeOnScroll = (event: Event) => {
+ const target = event.target as Node | null;
+ if (target && rootRef.current && panelRef.current) {
+ if (rootRef.current.contains(target) || panelRef.current.contains(target)) return;
+ }
+ setToken(null);
+ };
+ const closeOnResize = () => setToken(null);
+ document.addEventListener('scroll', closeOnScroll, true);
+ window.addEventListener('resize', closeOnResize);
+ return () => {
+ document.removeEventListener('scroll', closeOnScroll, true);
+ window.removeEventListener('resize', closeOnResize);
+ };
+ }, [mentionOpen]);
+
+ const insertMention = useCallback((option: ForgeLookupOption) => {
+ if (!token) return;
+ const next = `${value.slice(0, token.start)}@${option.label} ${value.slice(textareaRef.current?.selectionStart ?? token.start + token.query.length)}`;
+ onChange(next);
+ setToken(null);
+ // Restore the caret after the inserted mention.
+ requestAnimationFrame(() => {
+ const el = textareaRef.current;
+ if (el) {
+ const caret = token.start + option.label.length + 2;
+ el.focus();
+ el.setSelectionRange(caret, caret);
+ }
+ });
+ }, [onChange, token, value]);
+
+ const handleKeyDown = (event: React.KeyboardEvent): void => {
+ if (!token) return;
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ setHighlighted((prev) => (options.length ? (prev + 1) % options.length : 0));
+ return;
+ }
+ if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ setHighlighted((prev) => (options.length ? (prev - 1 + options.length) % options.length : 0));
+ return;
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ setToken(null);
+ return;
+ }
+ if (event.key === 'Enter' || event.key === 'Tab') {
+ if (options[highlighted]) {
+ event.preventDefault();
+ insertMention(options[highlighted]);
+ }
+ return;
+ }
+ };
+
+ const handleChange = (event: React.ChangeEvent): void => {
+ const next = event.target.value;
+ onChange(next);
+ if (hasLookup) {
+ setToken(findMentionToken(next, event.target.selectionStart ?? next.length));
+ }
+ };
+
+ const openToken = token && hasLookup;
+ const filtered = useMemo(
+ () => (token?.query ? options.filter((option) => option.label.toLowerCase().includes(token.query.toLowerCase())) : options),
+ [options, token?.query],
+ );
+
+ return (
+
+
+ {openToken && panelPos
+ ? createPortal(
+
+ {loading || !initialized ? (
+
+
+ {t('forge.lookup.loading')}
+
+ ) : filtered.length === 0 ? (
+
{t('forge.lookup.empty')}
+ ) : (
+ filtered.map((option, index) => (
+
insertMention(option)}
+ onMouseMove={() => setHighlighted(index)}
+ >
+ {option.avatarUrl ? (
+
+ ) : null}
+
{option.label}
+ {option.secondary ?
{option.secondary} : null}
+
+ ))
+ )}
+
,
+ document.body,
+ )
+ : null}
+
+ );
+};
\ No newline at end of file
diff --git a/packages/ui/src/components/views/forge/actions/ForgeMetadataEditor.tsx b/packages/ui/src/components/views/forge/actions/ForgeMetadataEditor.tsx
new file mode 100644
index 00000000..adbb3536
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/ForgeMetadataEditor.tsx
@@ -0,0 +1,264 @@
+import React, { useState } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { toast } from '@/components/ui/toast';
+import { useI18n } from '@/lib/i18n';
+import type { I18nKey } from '@/lib/i18n';
+import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
+import type { ForgeLabel, ForgeMilestone, ForgeUser } from '@/lib/forge/types';
+import { ForgeLookupCombobox } from './ForgeLookupCombobox';
+import type { ForgeLookupOption } from './useForgeLookup';
+
+interface ForgeMetadataEditorProps {
+ provider: ForgeProvider;
+ directory: string;
+ ref: ForgeEntityRef;
+ labels: ForgeLabel[];
+ assignees: ForgeUser[];
+ milestone: ForgeMilestone | null | undefined;
+ onChanged?: () => void;
+}
+
+const chipClassName =
+ 'inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-surface-elevated px-2 py-0.5 typography-micro text-foreground';
+
+const removeButtonClassName =
+ 'inline-flex size-4 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-interactive-hover/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-50';
+
+const avatarSize = 'size-3.5 rounded-full';
+
+/** GitHub label colors arrive without the `#` prefix; normalize both spellings. */
+const resolveLabelColor = (color?: string): string | null => {
+ if (!color) return null;
+ const value = color.trim();
+ if (!value) return null;
+ return value.startsWith('#') ? value : `#${value}`;
+};
+
+/**
+ * Metadata editor for an issue: labels / assignees / milestone chips with a
+ * remove affordance plus per-category add inputs. Every write replaces the
+ * full set of the changed field only (`provider.updateMetadata` semantics).
+ * Renders nothing when `updateMetadata` is missing or no category is enabled.
+ */
+export const ForgeMetadataEditor: React.FC = ({
+ provider,
+ directory,
+ ref,
+ labels,
+ assignees,
+ milestone,
+ onChanged,
+}) => {
+ const { t } = useI18n();
+ const [labelInput, setLabelInput] = useState('');
+ const [assigneeInput, setAssigneeInput] = useState('');
+ const [milestoneInput, setMilestoneInput] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ const updateMetadata = provider.updateMetadata;
+ const canLabels = Boolean(updateMetadata) && provider.capabilities.labels;
+ const canAssignees = Boolean(updateMetadata) && provider.capabilities.assignees;
+ const canMilestones = Boolean(updateMetadata) && provider.capabilities.milestones;
+
+ if (!updateMetadata || (!canLabels && !canAssignees && !canMilestones)) return null;
+
+ const runMetadata = async (
+ input: { labels?: string[]; assignees?: string[]; milestone?: string | null },
+ successKey: I18nKey,
+ ): Promise => {
+ if (submitting) return;
+ setSubmitting(true);
+ try {
+ const result = await updateMetadata(directory, ref, input);
+ if (!result.ok) {
+ toast.error(t('forge.actions.error'));
+ return;
+ }
+ toast.success(t(successKey));
+ onChanged?.();
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const addLabel = async (): Promise => {
+ const name = labelInput.trim();
+ if (!name) return;
+ await runMetadata({ labels: [...labels.map((label) => label.name), name] }, 'forge.actions.added');
+ setLabelInput('');
+ };
+
+ const addLabelOption = async (option: ForgeLookupOption): Promise => {
+ setLabelInput(option.label);
+ await addLabel();
+ };
+
+ const removeLabel = async (name: string): Promise => {
+ await runMetadata({ labels: labels.filter((label) => label.name !== name).map((label) => label.name) }, 'forge.actions.removed');
+ };
+
+ const addAssignee = async (): Promise => {
+ const login = assigneeInput.trim();
+ if (!login) return;
+ await runMetadata({ assignees: [...assignees.map((assignee) => assignee.login), login] }, 'forge.actions.added');
+ setAssigneeInput('');
+ };
+
+ const addAssigneeOption = async (option: ForgeLookupOption): Promise => {
+ setAssigneeInput(option.label);
+ await addAssignee();
+ };
+
+ const removeAssignee = async (id: string): Promise => {
+ await runMetadata({ assignees: assignees.filter((assignee) => assignee.id !== id).map((assignee) => assignee.login) }, 'forge.actions.removed');
+ };
+
+ const addMilestone = async (): Promise => {
+ const title = milestoneInput.trim();
+ if (!title) return;
+ await runMetadata({ milestone: title }, 'forge.actions.metadataChanged');
+ setMilestoneInput('');
+ };
+
+ const addMilestoneOption = async (option: ForgeLookupOption): Promise => {
+ setMilestoneInput(option.label);
+ await addMilestone();
+ };
+
+ const removeMilestone = async (): Promise => {
+ await runMetadata({ milestone: null }, 'forge.actions.removed');
+ };
+
+ const renderAvatar = (user: ForgeUser): React.ReactElement => {
+ if (user.avatarUrl) {
+ return ;
+ }
+ return (
+
+ {(user.login || user.name || '?').charAt(0).toUpperCase()}
+
+ );
+ };
+
+ return (
+
+
+ {canLabels
+ ? labels.map((label) => (
+
+
+ {label.name}
+ void removeLabel(label.name)}
+ disabled={submitting}
+ aria-label={`${t('forge.actions.remove')}: ${label.name}`}
+ >
+
+
+
+ ))
+ : null}
+
+ {canAssignees
+ ? assignees.map((assignee) => (
+
+ {renderAvatar(assignee)}
+ {assignee.login}
+ void removeAssignee(assignee.id)}
+ disabled={submitting}
+ aria-label={`${t('forge.actions.remove')}: ${assignee.login}`}
+ >
+
+
+
+ ))
+ : null}
+
+ {canMilestones && milestone ? (
+
+
+ {milestone.title}
+ void removeMilestone()}
+ disabled={submitting}
+ aria-label={`${t('forge.actions.remove')}: ${milestone.title}`}
+ >
+
+
+
+ ) : null}
+
+
+
+ {canLabels ? (
+
+ void addLabelOption(option)}
+ placeholder={t('forge.actions.addLabel')}
+ aria-label={t('forge.actions.addLabel')}
+ className="h-6 w-36"
+ />
+ void addLabel()} disabled={submitting || !labelInput.trim()}>
+ {t('forge.actions.addLabel')}
+
+
+ ) : null}
+
+ {canAssignees ? (
+
+ void addAssigneeOption(option)}
+ placeholder={t('forge.actions.addAssignee')}
+ aria-label={t('forge.actions.addAssignee')}
+ className="h-6 w-36"
+ />
+ void addAssignee()} disabled={submitting || !assigneeInput.trim()}>
+ {t('forge.actions.addAssignee')}
+
+
+ ) : null}
+
+ {canMilestones ? (
+
+ void addMilestoneOption(option)}
+ placeholder={t('forge.actions.setMilestone')}
+ aria-label={t('forge.actions.setMilestone')}
+ className="h-6 w-36"
+ />
+ void addMilestone()} disabled={submitting || !milestoneInput.trim()}>
+ {t('forge.actions.setMilestone')}
+
+
+ ) : null}
+
+
+ );
+};
diff --git a/packages/ui/src/components/views/forge/actions/ForgeReviewActions.tsx b/packages/ui/src/components/views/forge/actions/ForgeReviewActions.tsx
new file mode 100644
index 00000000..7c2e3093
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/ForgeReviewActions.tsx
@@ -0,0 +1,124 @@
+import React, { useState } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { toast } from '@/components/ui/toast';
+import { useI18n } from '@/lib/i18n';
+import type { I18nKey } from '@/lib/i18n';
+import type { ForgeEntityRef, ForgeProvider, ForgeReviewEvent } from '@/lib/forge/provider';
+import { ForgeMentionTextarea } from './ForgeMentionTextarea';
+
+interface ForgeReviewActionsProps {
+ provider: ForgeProvider;
+ directory: string;
+ ref: ForgeEntityRef;
+ onReviewed?: () => void;
+}
+
+const EVENT_LABEL_KEYS: Record = {
+ approve: 'forge.actions.approve',
+ 'request-changes': 'forge.actions.requestChanges',
+ comment: 'forge.actions.reviewComment',
+};
+
+/**
+ * Review submission controls for a pull request. Renders nothing unless the
+ * provider exposes reviews (`capabilities.reviews !== 'none'`) and a
+ * `submitReview` method. `approve-only` providers (GitLab) get a single direct
+ * Approve button; `submit` providers (GitHub/Gitea) get Approve / Request
+ * changes / Comment, each opening a small dialog with an optional body.
+ */
+export const ForgeReviewActions: React.FC = ({ provider, directory, ref, onReviewed }) => {
+ const { t } = useI18n();
+ const [pendingEvent, setPendingEvent] = useState(null);
+ const [body, setBody] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ const submitReview = provider.submitReview;
+ if (!submitReview || provider.capabilities.reviews === 'none') return null;
+
+ const canRequestChanges = provider.capabilities.reviews === 'submit';
+
+ const openDialog = (event: ForgeReviewEvent): void => {
+ setBody('');
+ setPendingEvent(event);
+ };
+
+ const submit = async (): Promise => {
+ if (!pendingEvent || submitting) return;
+ setSubmitting(true);
+ try {
+ const result = await submitReview(directory, ref, {
+ event: pendingEvent,
+ ...(body.trim() ? { body: body.trim() } : {}),
+ });
+ if (!result.ok) {
+ toast.error(t('forge.actions.error'));
+ return;
+ }
+ toast.success(t('forge.actions.reviewed'));
+ setPendingEvent(null);
+ setBody('');
+ onReviewed?.();
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const renderEventButton = (event: ForgeReviewEvent): React.ReactElement => (
+ openDialog(event)} disabled={submitting}>
+ {event === 'approve' ? : }
+ {t(EVENT_LABEL_KEYS[event])}
+
+ );
+
+ return (
+ <>
+
+ {renderEventButton('approve')}
+ {canRequestChanges ? (
+ <>
+ {renderEventButton('request-changes')}
+ {renderEventButton('comment')}
+ >
+ ) : null}
+
+
+ {
+ if (!open) setPendingEvent(null);
+ }}
+ >
+
+
+ {t('forge.actions.reviewDialogTitle')}
+
+
+
+ setPendingEvent(null)} disabled={submitting}>
+ {t('forge.actions.cancel')}
+
+ void submit()} disabled={submitting}>
+ {submitting ? (
+
+ ) : (
+
+ )}
+ {pendingEvent ? t(EVENT_LABEL_KEYS[pendingEvent]) : t('forge.actions.reviewDialogTitle')}
+
+
+
+
+ >
+ );
+};
diff --git a/packages/ui/src/components/views/forge/actions/ForgeStateActions.tsx b/packages/ui/src/components/views/forge/actions/ForgeStateActions.tsx
new file mode 100644
index 00000000..1bd6b69e
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/ForgeStateActions.tsx
@@ -0,0 +1,65 @@
+import React, { useState } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { toast } from '@/components/ui/toast';
+import { useI18n } from '@/lib/i18n';
+import type { ForgeEntityRef, ForgeProvider, ForgeWriteState } from '@/lib/forge/provider';
+import type { ForgeEntityState } from '@/lib/forge/types';
+
+interface ForgeStateActionsProps {
+ provider: ForgeProvider;
+ directory: string;
+ ref: ForgeEntityRef;
+ state: ForgeEntityState;
+ onChanged?: (state: ForgeEntityState) => void;
+}
+
+/**
+ * Close/reopen control for an issue or pull request. Renders nothing when the
+ * provider has no `updateEntity` method, or when the entity is merged (a
+ * terminal, non-writable state). Closing asks for confirmation first.
+ */
+export const ForgeStateActions: React.FC = ({ provider, directory, ref, state, onChanged }) => {
+ const { t } = useI18n();
+ const [submitting, setSubmitting] = useState(false);
+
+ const updateEntity = provider.updateEntity;
+ if (!updateEntity || state === 'merged') return null;
+
+ const isOpen = state === 'open';
+ const nextState: ForgeWriteState = isOpen ? 'closed' : 'open';
+
+ const run = async (): Promise => {
+ if (submitting) return;
+ if (isOpen && !window.confirm(t('forge.actions.closeConfirm'))) return;
+ setSubmitting(true);
+ try {
+ const result = await updateEntity(directory, ref, { state: nextState });
+ if (!result.ok) {
+ toast.error(t('forge.actions.error'));
+ return;
+ }
+ toast.success(t('forge.actions.stateChanged'));
+ onChanged?.(nextState);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+ void run()}
+ disabled={submitting}
+ aria-label={t(isOpen ? 'forge.actions.close' : 'forge.actions.reopen')}
+ >
+ {submitting ? (
+
+ ) : (
+
+ )}
+ {t(isOpen ? 'forge.actions.close' : 'forge.actions.reopen')}
+
+ );
+};
diff --git a/packages/ui/src/components/views/forge/actions/ForgeThreadReply.tsx b/packages/ui/src/components/views/forge/actions/ForgeThreadReply.tsx
new file mode 100644
index 00000000..c990a905
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/ForgeThreadReply.tsx
@@ -0,0 +1,91 @@
+import React, { useState } from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { toast } from '@/components/ui/toast';
+import { useI18n } from '@/lib/i18n';
+import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
+import type { ForgeComment } from '@/lib/forge/types';
+import { ForgeMentionTextarea } from './ForgeMentionTextarea';
+
+/** Anchor of the thread being replied to (see `ForgeComment.inReplyToId`/`path`/`line`). */
+export interface ForgeThreadTarget {
+ inReplyToId: string;
+ path?: string | null;
+ line?: number | null;
+}
+
+interface ForgeThreadReplyProps {
+ provider: ForgeProvider;
+ directory: string;
+ ref: ForgeEntityRef;
+ thread: ForgeThreadTarget;
+ onPosted?: (comment: ForgeComment) => void;
+ onCancel?: () => void;
+}
+
+/**
+ * Inline reply editor for one comment thread. Renders nothing when the
+ * provider has no `replyToThread` method. The parent decides when the editor
+ * is visible (expansion is driven from outside); posting clears the editor and
+ * reports the created comment via `onPosted`.
+ */
+export const ForgeThreadReply: React.FC = ({ provider, directory, ref, thread, onPosted, onCancel }) => {
+ const { t } = useI18n();
+ const [body, setBody] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ const replyToThread = provider.replyToThread;
+ if (!replyToThread) return null;
+
+ const canSubmit = body.trim().length > 0 && !submitting;
+
+ const submit = async (): Promise => {
+ if (!canSubmit) return;
+ setSubmitting(true);
+ try {
+ const result = await replyToThread(directory, ref, {
+ body: body.trim(),
+ inReplyToId: thread.inReplyToId,
+ path: thread.path ?? null,
+ line: thread.line ?? null,
+ });
+ if (!result.ok) {
+ toast.error(t('forge.actions.error'));
+ return;
+ }
+ setBody('');
+ if (result.comment) onPosted?.(result.comment);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {t('forge.actions.cancel')}
+
+ void submit()} disabled={!canSubmit}>
+ {submitting ? (
+
+ ) : (
+
+ )}
+ {t('forge.actions.reply')}
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/views/forge/actions/index.ts b/packages/ui/src/components/views/forge/actions/index.ts
new file mode 100644
index 00000000..c906525f
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/index.ts
@@ -0,0 +1,18 @@
+/**
+ * Write-action UI for forge issues and pull requests.
+ *
+ * Every component is capability- and method-gated: it renders nothing (or a
+ * sub-affordance) unless the provider implements the underlying write method
+ * and its capability flag is set. Components call the facade directly, toast
+ * stable i18n messages on failure (never raw error text), and report success
+ * through `onChanged`/`onPosted` callbacks so the owning view can refetch or
+ * update local state.
+ */
+export { ForgeCommentComposer } from './ForgeCommentComposer';
+export { ForgeThreadReply } from './ForgeThreadReply';
+export { ForgeStateActions } from './ForgeStateActions';
+export { ForgeReviewActions } from './ForgeReviewActions';
+export { ForgeDraftToggle } from './ForgeDraftToggle';
+export { ForgeMetadataEditor } from './ForgeMetadataEditor';
+export { ForgeEntityActions } from './ForgeEntityActions';
+export { ForgeCreateIssueDialog } from './ForgeCreateIssueDialog';
diff --git a/packages/ui/src/components/views/forge/actions/useForgeLookup.ts b/packages/ui/src/components/views/forge/actions/useForgeLookup.ts
new file mode 100644
index 00000000..9c8051ac
--- /dev/null
+++ b/packages/ui/src/components/views/forge/actions/useForgeLookup.ts
@@ -0,0 +1,211 @@
+import { useEffect, useState } from 'react';
+import type { ForgeProvider } from '@/lib/forge/provider';
+import type { ForgeLabel, ForgeMilestone, ForgeUser } from '@/lib/forge/types';
+
+/** Which picker a lookup feeds; each maps onto one `provider.search*` method. */
+export type ForgeLookupKind = 'users' | 'labels' | 'milestones' | 'branches' | 'tags';
+
+/**
+ * A normalized, display-ready row for the shared forge lookup dropdown.
+ * The owning surface maps provider result shapes onto this.
+ */
+export interface ForgeLookupOption {
+ /** Stable key (login / label name / milestone title / branch / tag). */
+ key: string;
+ /** Primary display text. */
+ label: string;
+ /** Secondary line (e.g. a user's real name). */
+ secondary?: string;
+ avatarUrl?: string;
+ /** Label color dot (hex as returned by the provider). */
+ color?: string;
+}
+
+/** Resolve the dropdown option shape for a given provider/kind result. */
+const toLookupOptions = (
+ kind: ForgeLookupKind,
+ users: ForgeUser[],
+ labels: ForgeLabel[],
+ milestones: ForgeMilestone[],
+ branches: string[],
+ tags: string[],
+): ForgeLookupOption[] => {
+ switch (kind) {
+ case 'users':
+ return users.map((user) => ({
+ key: user.login,
+ label: user.login,
+ ...(user.name ? { secondary: user.name } : {}),
+ ...(user.avatarUrl ? { avatarUrl: user.avatarUrl } : {}),
+ }));
+ case 'labels':
+ return labels.map((label) => ({
+ key: label.name,
+ label: label.name,
+ ...(label.color ? { color: label.color } : {}),
+ }));
+ case 'milestones':
+ return milestones.map((milestone) => ({ key: milestone.title, label: milestone.title }));
+ case 'branches':
+ return branches.map((branch) => ({ key: branch, label: branch }));
+ case 'tags':
+ return tags.map((tag) => ({ key: tag, label: tag }));
+ }
+};
+
+// --- Short-TTL lookup cache ---
+//
+// The lookup is debounced but still fires once per settled (kind, directory,
+// repo, query), so a picker interaction that re-asks for the same repo/query
+// (reopening the dropdown, switching fields back and forth) would re-hit the
+// provider. A short module-local TTL serves a fresh-enough result synchronously,
+// skipping both the network call and the debounce timer.
+//
+// Only `connected: true` results are cached: a failed or disconnected lookup must
+// never masquerade as an authoritative empty list (correctness invariant), so it
+// is never stored and is always re-fetched.
+
+const CACHE_TTL_MS = 30_000;
+const CACHE_MAX_ENTRIES = 200;
+
+interface ForgeLookupCacheEntry {
+ options: ForgeLookupOption[];
+ expiresAt: number;
+}
+
+const lookupCache = new Map();
+
+const cacheKey = (
+ kind: ForgeLookupKind,
+ directory: string,
+ sourceRepo: string | null | undefined,
+ query: string,
+): string => `${kind}|${directory}|${sourceRepo ?? ''}|${query}`;
+
+/** Drop expired entries and bound the map size on each write. */
+const pruneCache = (now: number): void => {
+ for (const [key, entry] of lookupCache) {
+ if (entry.expiresAt <= now) lookupCache.delete(key);
+ }
+ // Map iteration is insertion-ordered, so dropping oldest first keeps the
+ // most recently written entries when the map overflows.
+ let excess = lookupCache.size - CACHE_MAX_ENTRIES;
+ if (excess > 0) {
+ for (const key of lookupCache.keys()) {
+ if (excess <= 0) break;
+ lookupCache.delete(key);
+ excess -= 1;
+ }
+ }
+};
+
+/**
+ * Debounced repo-scoped lookup for forge pickers. Fetches through the facade
+ * `search*` method for `kind` 250ms after the query settles, keeps the dropdown
+ * from firing on every keystroke, and never surfaces stale results (an
+ * out-of-order response is dropped). `connected: false` results are treated as
+ * "no authoritative options", never as a valid empty list.
+ *
+ * Successful results are cached per (kind, directory, repo, query) for
+ * `CACHE_TTL_MS`; a hit serves synchronously without a network call or debounce.
+ */
+export const useForgeLookup = ({
+ provider,
+ directory,
+ sourceRepo,
+ kind,
+ query,
+}: {
+ provider: ForgeProvider;
+ directory: string;
+ sourceRepo?: string | null;
+ kind: ForgeLookupKind;
+ query: string;
+}): { options: ForgeLookupOption[]; loading: boolean; initialized: boolean } => {
+ const [options, setOptions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [initialized, setInitialized] = useState(false);
+
+ useEffect(() => {
+ const key = cacheKey(kind, directory, sourceRepo, query);
+ const now = Date.now();
+ const cached = lookupCache.get(key);
+ if (cached && cached.expiresAt > now) {
+ // Fresh enough: serve without the network call or the debounce timer.
+ setOptions(cached.options);
+ setLoading(false);
+ setInitialized(true);
+ return;
+ }
+ if (cached) lookupCache.delete(key);
+
+ let cancelled = false;
+
+ const timer = window.setTimeout(() => {
+ setLoading(true);
+ void (async () => {
+ try {
+ if (cancelled) return;
+ let next: ForgeLookupOption[] = [];
+ let connected = false;
+ if (kind === 'users') {
+ const run = provider.searchUsers?.(directory, query, { sourceRepo });
+ if (run) {
+ const result = await run;
+ connected = result.connected;
+ if (connected) next = toLookupOptions('users', result.users ?? [], [], [], [], []);
+ }
+ } else if (kind === 'labels') {
+ const run = provider.searchLabels?.(directory, query, { sourceRepo });
+ if (run) {
+ const result = await run;
+ connected = result.connected;
+ if (connected) next = toLookupOptions('labels', [], result.labels ?? [], [], [], []);
+ }
+ } else if (kind === 'milestones') {
+ const run = provider.searchMilestones?.(directory, query, { sourceRepo });
+ if (run) {
+ const result = await run;
+ connected = result.connected;
+ if (connected) next = toLookupOptions('milestones', [], [], result.milestones ?? [], [], []);
+ }
+ } else if (kind === 'branches') {
+ const run = provider.searchBranches?.(directory, query, { sourceRepo });
+ if (run) {
+ const result = await run;
+ connected = result.connected;
+ if (connected) next = toLookupOptions('branches', [], [], [], result.branches ?? [], []);
+ }
+ } else if (kind === 'tags') {
+ const run = provider.searchTags?.(directory, query, { sourceRepo });
+ if (run) {
+ const result = await run;
+ connected = result.connected;
+ if (connected) next = toLookupOptions('tags', [], [], [], [], result.tags ?? []);
+ }
+ }
+ if (cancelled) return;
+ setOptions(next);
+ if (connected) {
+ lookupCache.set(key, { options: next, expiresAt: Date.now() + CACHE_TTL_MS });
+ pruneCache(Date.now());
+ }
+ } catch {
+ if (!cancelled) setOptions([]);
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ setInitialized(true);
+ }
+ }
+ })();
+ }, 250);
+
+ return () => {
+ cancelled = true;
+ window.clearTimeout(timer);
+ };
+ }, [directory, kind, provider, query, sourceRepo]);
+
+ return { options, loading, initialized };
+};
diff --git a/packages/ui/src/components/views/forge/index.ts b/packages/ui/src/components/views/forge/index.ts
new file mode 100644
index 00000000..d6bb464c
--- /dev/null
+++ b/packages/ui/src/components/views/forge/index.ts
@@ -0,0 +1,11 @@
+/**
+ * Shared rich-view sections for forge pull requests and issues.
+ *
+ * Every component in this directory is presentational — all data arrives via
+ * props. `ForgeEntityDetailView` is the one self-loading orchestrator that owns
+ * fetching through the `ForgeProvider` facade and composes the sections.
+ */
+export { ForgeMetadataChips } from './ForgeMetadataChips';
+export { ForgeCommitsSection } from './ForgeCommitsSection';
+export { ForgeFilesDiffSection } from './ForgeFilesDiffSection';
+export { ForgeEntityDetailView } from './ForgeEntityDetailView';
diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx
index bbcaa6ef..e7e089ef 100644
--- a/packages/ui/src/components/views/git/GitHeader.tsx
+++ b/packages/ui/src/components/views/git/GitHeader.tsx
@@ -20,6 +20,8 @@ import type {
GitRemoteComparison,
GitHubPullRequest,
GitHubChecksSummary,
+ GitLabMergeRequestSummary,
+ GiteaPullRequestSummary,
} from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
@@ -61,6 +63,10 @@ interface GitHeaderProps {
selectedRepository?: string | null;
onSelectRepository?: (repository: string) => void;
repositoryRoot?: string;
+ gitLabMr?: GitLabMergeRequestSummary | null;
+ onOpenGitLabMr?: () => void;
+ giteaPr?: GiteaPullRequestSummary | null;
+ onOpenGiteaPr?: () => void;
}
const IDENTITY_ICON_MAP: Record = {
@@ -273,6 +279,10 @@ export const GitHeader: React.FC = ({
selectedRepository,
onSelectRepository,
repositoryRoot,
+ gitLabMr,
+ onOpenGitLabMr,
+ giteaPr,
+ onOpenGiteaPr,
}) => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
@@ -389,6 +399,74 @@ export const GitHeader: React.FC = ({
) : null;
+ // GitLab merge request chip, mirroring the GitHub PR chip above. GitLab
+ // states are surfaced with the same PR state palette so merged/closed/open
+ // read identically across providers.
+ const gitLabMrVisualState = gitLabMr
+ ? gitLabMr.state === 'merged'
+ ? 'merged'
+ : gitLabMr.state === 'closed'
+ ? 'closed'
+ : gitLabMr.draft
+ ? 'draft'
+ : 'open'
+ : null;
+
+ const gitLabMrChip = gitLabMr && onOpenGitLabMr ? (
+
+
+
+
+ !{gitLabMr.number}
+
+
+ {t('gitView.header.openMergeRequest')}
+
+ ) : null;
+
+ // Gitea pull request chip, mirroring the GitLab MR chip above. Gitea states
+ // are surfaced with the same PR state palette so merged/closed/open read
+ // identically across providers.
+ const giteaPrVisualState = giteaPr
+ ? giteaPr.state === 'merged'
+ ? 'merged'
+ : giteaPr.state === 'closed'
+ ? 'closed'
+ : giteaPr.draft
+ ? 'draft'
+ : 'open'
+ : null;
+
+ const giteaPrChip = giteaPr && onOpenGiteaPr ? (
+
+
+
+
+ #{giteaPr.number}
+
+
+ {t('gitView.header.openPullRequest')}
+
+ ) : null;
+
const syncButtons = (
= ({
{prChip ?
{prChip}
: null}
+ {gitLabMrChip ?
{gitLabMrChip}
: null}
+ {giteaPrChip ?
{giteaPrChip}
: null}
{upstreamStatusPill ? (
{upstreamStatusPill}
diff --git a/packages/ui/src/components/views/git/GitHubIssuesSection.tsx b/packages/ui/src/components/views/git/GitHubIssuesSection.tsx
new file mode 100644
index 00000000..3286867d
--- /dev/null
+++ b/packages/ui/src/components/views/git/GitHubIssuesSection.tsx
@@ -0,0 +1,290 @@
+import React from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { ForgeEntityDetailView } from '@/components/views/forge';
+import { ForgeCreateIssueDialog } from '@/components/views/forge/actions';
+import { buildForgeProvider } from '@/lib/forge';
+import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
+import { useUIStore } from '@/stores/useUIStore';
+import type { GitHubIssueSummary, GitHubRepoSelector } from '@/lib/api/types';
+import type { ForgeIssue } from '@/lib/forge';
+import { useI18n } from '@/lib/i18n';
+
+const issueLabelBadgeClass =
+ 'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground';
+
+/**
+ * Open GitHub issues for the context panel's pull-request view. The list is
+ * fetched lazily (the parent only mounts this component while the Issues tab
+ * is active); selecting a row mounts the shared `ForgeEntityDetailView` for
+ * the issue detail. Read-only by design — no create, update, or close actions.
+ *
+ * The parent does not gate on GitHub auth state, so the connection state is
+ * derived from the API results themselves (`connected === false` renders a
+ * not-connected state with a settings CTA).
+ */
+export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory }) => {
+ const { t } = useI18n();
+ const { github } = useRuntimeAPIs();
+ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
+ const setSettingsPage = useUIStore((state) => state.setSettingsPage);
+
+ // ---- Open issues list ----------------------------------------------------
+
+ const [issues, setIssues] = React.useState
([]);
+ const [listPage, setListPage] = React.useState(1);
+ const [listHasMore, setListHasMore] = React.useState(false);
+ const [listLoading, setListLoading] = React.useState(false);
+ const [listLoadingMore, setListLoadingMore] = React.useState(false);
+ const [listError, setListError] = React.useState(null);
+ const [listNotConnected, setListNotConnected] = React.useState(false);
+ const [retryToken, setRetryToken] = React.useState(0);
+
+ // ---- Selected issue detail ------------------------------------------------
+
+ const [selectedNumber, setSelectedNumber] = React.useState(null);
+ const [selectedSourceRepo, setSelectedSourceRepo] = React.useState<
+ (GitHubRepoSelector & { source: string }) | null
+ >(null);
+ const [selectedUrl, setSelectedUrl] = React.useState(null);
+
+ const [createOpen, setCreateOpen] = React.useState(false);
+
+ const issueProvider = React.useMemo(() => (github ? buildForgeProvider('github', { github }) : null), [github]);
+
+ const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
+
+ const openGitHubSettings = React.useCallback(() => {
+ setSettingsPage('git');
+ setSettingsDialogOpen(true);
+ }, [setSettingsDialogOpen, setSettingsPage]);
+
+ const handleIssueCreated = React.useCallback(
+ (issue: ForgeIssue) => {
+ // Open the freshly created issue's detail and refresh the list behind it.
+ setSelectedNumber(issue.number);
+ setSelectedUrl(issue.url ?? null);
+ setRetryToken((value) => value + 1);
+ },
+ [],
+ );
+
+ // A different repository invalidates the previously loaded list and detail so
+ // a stale repository's issues never leak into the new one.
+ React.useEffect(() => {
+ setIssues([]);
+ setListPage(1);
+ setListHasMore(false);
+ setListLoading(false);
+ setListLoadingMore(false);
+ setListError(null);
+ setListNotConnected(false);
+ setSelectedNumber(null);
+ setSelectedSourceRepo(null);
+ setSelectedUrl(null);
+ }, [directory]);
+
+ React.useEffect(() => {
+ if (!github?.issuesList) {
+ return;
+ }
+ let cancelled = false;
+ setListLoading(true);
+ setListError(null);
+ setListNotConnected(false);
+ void github
+ .issuesList(directory, { page: 1 })
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ if (result.connected === false) {
+ setListNotConnected(true);
+ return;
+ }
+ setIssues(result.issues ?? []);
+ setListPage(result.page ?? 1);
+ setListHasMore(Boolean(result.hasMore));
+ })
+ .catch((error) => {
+ if (!cancelled) {
+ setListError(error instanceof Error ? error.message : String(error));
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setListLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [directory, github, retryToken]);
+
+ const loadMore = React.useCallback(async () => {
+ if (!github?.issuesList || listLoadingMore || listLoading || !listHasMore) {
+ return;
+ }
+ setListLoadingMore(true);
+ try {
+ const next = await github.issuesList(directory, { page: listPage + 1 });
+ if (next.connected === false) {
+ setListNotConnected(true);
+ return;
+ }
+ setIssues((previous) => [...previous, ...(next.issues ?? [])]);
+ setListPage(next.page ?? listPage + 1);
+ setListHasMore(Boolean(next.hasMore));
+ } catch (error) {
+ setListError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setListLoadingMore(false);
+ }
+ }, [directory, github, listHasMore, listLoading, listLoadingMore, listPage]);
+
+ // Selecting a row remembers the summary's sourceRepo and url too: the server
+ // route resolves the repo from the directory, but cross-repo issues need the
+ // explicit sourceRepo for the shared detail view to fetch the issue and its
+ // comments from the right repository.
+ const selectIssue = React.useCallback((item: GitHubIssueSummary) => {
+ setSelectedNumber(item.number);
+ setSelectedSourceRepo(item.sourceRepo ?? null);
+ setSelectedUrl(item.url ?? null);
+ }, []);
+
+ const backToIssues = React.useCallback(() => {
+ setSelectedNumber(null);
+ setSelectedSourceRepo(null);
+ setSelectedUrl(null);
+ }, []);
+
+ if (selectedNumber !== null) {
+ return (
+
+
+
+ {issueProvider ? (
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+
+
{t('gitView.pullRequest.issues.listSectionTitle')}
+ {issueProvider?.createIssue ? (
+
setCreateOpen(true)}>
+
+ {t('forge.actions.newIssue')}
+
+ ) : null}
+
+
+ {!github?.issuesList ? (
+
{t('gitView.pullRequest.issues.empty')}
+ ) : listNotConnected ? (
+
+
+
{t('gitView.pr.githubNotConnected')}
+
+ {t('gitView.pr.actions.openSettings')}
+
+
+ ) : listLoading ? (
+
+
+ {t('session.githubIssuePicker.loading.issues')}
+
+ ) : listError ? (
+
+
{t('gitView.pullRequest.issues.error.loadFailed')}
+
{listError}
+
+ {t('contextPanel.preview.actions.retry')}
+
+
+ ) : issues.length === 0 ? (
+
{t('gitView.pullRequest.issues.empty')}
+ ) : (
+
+ {issues.map((item) => (
+
+ ))}
+
+ {listHasMore ? (
+
+ void loadMore()} disabled={listLoadingMore}>
+ {listLoadingMore ? (
+
+ ) : null}
+ {t('session.githubIssuePicker.actions.loadMore')}
+
+
+ ) : null}
+
+ )}
+
+ {issueProvider?.createIssue ? (
+
+ ) : null}
+
+ );
+};
diff --git a/packages/ui/src/components/views/git/GitLabIssuesSection.tsx b/packages/ui/src/components/views/git/GitLabIssuesSection.tsx
new file mode 100644
index 00000000..3d24e17c
--- /dev/null
+++ b/packages/ui/src/components/views/git/GitLabIssuesSection.tsx
@@ -0,0 +1,269 @@
+import React from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { ForgeEntityDetailView } from '@/components/views/forge';
+import { ForgeCreateIssueDialog } from '@/components/views/forge/actions';
+import { buildForgeProvider } from '@/lib/forge';
+import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
+import { useUIStore } from '@/stores/useUIStore';
+import type { GitLabIssueSummary } from '@/lib/api/types';
+import type { ForgeIssue } from '@/lib/forge';
+import { useI18n } from '@/lib/i18n';
+
+const issueLabelBadgeClass =
+ 'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground';
+
+/**
+ * Open GitLab issues for the context panel's MR view. The list is fetched
+ * lazily (the parent only mounts this component while the Issues tab is
+ * active); selecting a row mounts the shared `ForgeEntityDetailView` for the
+ * issue detail. Read-only by design — no create, update, or close actions.
+ */
+export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory }) => {
+ const { t } = useI18n();
+ const { gitlab } = useRuntimeAPIs();
+ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
+ const setSettingsPage = useUIStore((state) => state.setSettingsPage);
+
+ // ---- Open issues list ----------------------------------------------------
+
+ const [issues, setIssues] = React.useState([]);
+ const [listPage, setListPage] = React.useState(1);
+ const [listHasMore, setListHasMore] = React.useState(false);
+ const [listLoading, setListLoading] = React.useState(false);
+ const [listLoadingMore, setListLoadingMore] = React.useState(false);
+ const [listError, setListError] = React.useState(null);
+ const [listNotConnected, setListNotConnected] = React.useState(false);
+ const [retryToken, setRetryToken] = React.useState(0);
+
+ // ---- Selected issue detail ------------------------------------------------
+
+ const [selectedNumber, setSelectedNumber] = React.useState(null);
+ const [selectedUrl, setSelectedUrl] = React.useState(null);
+
+ const issueProvider = React.useMemo(() => (gitlab ? buildForgeProvider('gitlab', { gitlab }) : null), [gitlab]);
+
+ const [createOpen, setCreateOpen] = React.useState(false);
+
+ const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
+
+ const openGitLabSettings = React.useCallback(() => {
+ setSettingsPage('git');
+ setSettingsDialogOpen(true);
+ }, [setSettingsDialogOpen, setSettingsPage]);
+
+ const handleIssueCreated = React.useCallback((issue: ForgeIssue) => {
+ setSelectedNumber(issue.number);
+ setSelectedUrl(issue.url ?? null);
+ setRetryToken((value) => value + 1);
+ }, []);
+
+ // A different repository invalidates the previously loaded list and detail so
+ // a stale repository's issues never leak into the new one.
+ React.useEffect(() => {
+ setIssues([]);
+ setListPage(1);
+ setListHasMore(false);
+ setListLoading(false);
+ setListLoadingMore(false);
+ setListError(null);
+ setListNotConnected(false);
+ setSelectedNumber(null);
+ setSelectedUrl(null);
+ }, [directory]);
+
+ React.useEffect(() => {
+ if (!gitlab?.issuesList) {
+ return;
+ }
+ let cancelled = false;
+ setListLoading(true);
+ setListError(null);
+ setListNotConnected(false);
+ void gitlab
+ .issuesList(directory, { page: 1 })
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ if (result.connected === false) {
+ setListNotConnected(true);
+ return;
+ }
+ setIssues(result.issues ?? []);
+ setListPage(result.page ?? 1);
+ setListHasMore(Boolean(result.hasMore));
+ })
+ .catch((error) => {
+ if (!cancelled) {
+ setListError(error instanceof Error ? error.message : String(error));
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setListLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [directory, gitlab, retryToken]);
+
+ const loadMore = React.useCallback(async () => {
+ if (!gitlab?.issuesList || listLoadingMore || listLoading || !listHasMore) {
+ return;
+ }
+ setListLoadingMore(true);
+ try {
+ const next = await gitlab.issuesList(directory, { page: listPage + 1 });
+ if (next.connected === false) {
+ setListNotConnected(true);
+ return;
+ }
+ setIssues((previous) => [...previous, ...(next.issues ?? [])]);
+ setListPage(next.page ?? listPage + 1);
+ setListHasMore(Boolean(next.hasMore));
+ } catch (error) {
+ setListError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setListLoadingMore(false);
+ }
+ }, [directory, gitlab, listHasMore, listLoading, listLoadingMore, listPage]);
+
+ const selectIssue = React.useCallback((item: GitLabIssueSummary) => {
+ setSelectedNumber(item.number);
+ setSelectedUrl(item.url);
+ }, []);
+
+ const backToIssues = React.useCallback(() => {
+ setSelectedNumber(null);
+ setSelectedUrl(null);
+ }, []);
+
+ if (selectedNumber !== null) {
+ return (
+
+
+
+ {issueProvider ? (
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+
+
{t('contextPanel.gitlabMr.issues.listSectionTitle')}
+ {issueProvider?.createIssue ? (
+
setCreateOpen(true)}>
+
+ {t('forge.actions.newIssue')}
+
+ ) : null}
+
+
+ {!gitlab?.issuesList ? (
+
{t('contextPanel.gitlabMr.issues.empty')}
+ ) : listNotConnected ? (
+
+
+
{t('contextPanel.gitlabMr.error.notConnected')}
+
+ {t('contextPanel.gitlabMr.actions.openSettings')}
+
+
+ ) : listLoading ? (
+
+
+ {t('contextPanel.gitlabMr.loading')}
+
+ ) : listError ? (
+
+
{t('contextPanel.gitlabMr.issues.error.loadFailed')}
+
{listError}
+
+ {t('contextPanel.preview.actions.retry')}
+
+
+ ) : issues.length === 0 ? (
+
{t('contextPanel.gitlabMr.issues.empty')}
+ ) : (
+
+ {issues.map((item) => (
+
+ ))}
+
+ {listHasMore ? (
+
+ void loadMore()} disabled={listLoadingMore}>
+ {listLoadingMore ? (
+
+ ) : null}
+ {t('contextPanel.gitlabMr.loadMore')}
+
+
+ ) : null}
+
+ )}
+
+ {issueProvider?.createIssue ? (
+
+ ) : null}
+
+ );
+};
diff --git a/packages/ui/src/components/views/git/GiteaIssuesSection.tsx b/packages/ui/src/components/views/git/GiteaIssuesSection.tsx
new file mode 100644
index 00000000..890e78a8
--- /dev/null
+++ b/packages/ui/src/components/views/git/GiteaIssuesSection.tsx
@@ -0,0 +1,279 @@
+import React from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Button } from '@/components/ui/button';
+import { ForgeEntityDetailView } from '@/components/views/forge';
+import { ForgeCreateIssueDialog } from '@/components/views/forge/actions';
+import { buildForgeProvider } from '@/lib/forge';
+import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
+import { useUIStore } from '@/stores/useUIStore';
+import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
+import type { GiteaIssueSummary } from '@/lib/api/types';
+import type { ForgeIssue } from '@/lib/forge';
+import { useI18n } from '@/lib/i18n';
+
+const issueLabelBadgeClass =
+ 'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground';
+
+/**
+ * Open Gitea issues for the context panel's PR view. The list is fetched
+ * lazily (the parent only mounts this component while the Issues tab is
+ * active); selecting a row mounts the shared `ForgeEntityDetailView` for the
+ * issue detail. Issue creation is supported here via the "new issue" button
+ * and `ForgeCreateIssueDialog`; the detail view also offers edit, close/reopen,
+ * comments, and metadata editing through the Gitea provider's write methods.
+ */
+export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory }) => {
+ const { t } = useI18n();
+ const { gitea } = useRuntimeAPIs();
+ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
+ const setSettingsPage = useUIStore((state) => state.setSettingsPage);
+ const giteaAuthStatus = useGiteaAuthStore((state) => state.status);
+ const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked);
+
+ // ---- Open issues list ----------------------------------------------------
+
+ const [issues, setIssues] = React.useState([]);
+ const [listPage, setListPage] = React.useState(1);
+ const [listHasMore, setListHasMore] = React.useState(false);
+ const [listLoading, setListLoading] = React.useState(false);
+ const [listLoadingMore, setListLoadingMore] = React.useState(false);
+ const [listError, setListError] = React.useState(null);
+ const [listNotConnected, setListNotConnected] = React.useState(false);
+ const [retryToken, setRetryToken] = React.useState(0);
+
+ // ---- Selected issue detail ------------------------------------------------
+
+ const [selectedNumber, setSelectedNumber] = React.useState(null);
+ const [selectedUrl, setSelectedUrl] = React.useState(null);
+
+ const issueProvider = React.useMemo(() => (gitea ? buildForgeProvider('gitea', { gitea }) : null), [gitea]);
+
+ const [createOpen, setCreateOpen] = React.useState(false);
+
+ const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
+
+ const openGiteaSettings = React.useCallback(() => {
+ setSettingsPage('git');
+ setSettingsDialogOpen(true);
+ }, [setSettingsDialogOpen, setSettingsPage]);
+
+ const handleIssueCreated = React.useCallback((issue: ForgeIssue) => {
+ setSelectedNumber(issue.number);
+ setSelectedUrl(issue.url ?? null);
+ setRetryToken((value) => value + 1);
+ }, []);
+
+ // The parent PR view already gates on connection, but the auth store is the
+ // authoritative signal when the list API reports connected without having
+ // checked the account yet.
+ const authNotConnected = giteaAuthChecked && giteaAuthStatus?.connected === false;
+
+ // A different repository invalidates the previously loaded list and detail so
+ // a stale repository's issues never leak into the new one.
+ React.useEffect(() => {
+ setIssues([]);
+ setListPage(1);
+ setListHasMore(false);
+ setListLoading(false);
+ setListLoadingMore(false);
+ setListError(null);
+ setListNotConnected(false);
+ setSelectedNumber(null);
+ setSelectedUrl(null);
+ }, [directory]);
+
+ React.useEffect(() => {
+ if (!gitea?.issuesList) {
+ return;
+ }
+ let cancelled = false;
+ setListLoading(true);
+ setListError(null);
+ setListNotConnected(false);
+ void gitea
+ .issuesList(directory, { page: 1 })
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ if (result.connected === false) {
+ setListNotConnected(true);
+ return;
+ }
+ setIssues(result.issues ?? []);
+ setListPage(result.page ?? 1);
+ setListHasMore(Boolean(result.hasMore));
+ })
+ .catch((error) => {
+ if (!cancelled) {
+ setListError(error instanceof Error ? error.message : String(error));
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setListLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [directory, gitea, retryToken]);
+
+ const loadMore = React.useCallback(async () => {
+ if (!gitea?.issuesList || listLoadingMore || listLoading || !listHasMore) {
+ return;
+ }
+ setListLoadingMore(true);
+ try {
+ const next = await gitea.issuesList(directory, { page: listPage + 1 });
+ if (next.connected === false) {
+ setListNotConnected(true);
+ return;
+ }
+ setIssues((previous) => [...previous, ...(next.issues ?? [])]);
+ setListPage(next.page ?? listPage + 1);
+ setListHasMore(Boolean(next.hasMore));
+ } catch (error) {
+ setListError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setListLoadingMore(false);
+ }
+ }, [directory, gitea, listHasMore, listLoading, listLoadingMore, listPage]);
+
+ const selectIssue = React.useCallback((item: GiteaIssueSummary) => {
+ setSelectedNumber(item.number);
+ setSelectedUrl(item.url);
+ }, []);
+
+ const backToIssues = React.useCallback(() => {
+ setSelectedNumber(null);
+ setSelectedUrl(null);
+ }, []);
+
+ if (selectedNumber !== null) {
+ return (
+
+
+
+ {issueProvider ? (
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+
+
{t('contextPanel.giteaPr.issues.listSectionTitle')}
+ {issueProvider?.createIssue ? (
+
setCreateOpen(true)}>
+
+ {t('forge.actions.newIssue')}
+
+ ) : null}
+
+
+ {!gitea?.issuesList ? (
+
{t('contextPanel.giteaPr.issues.empty')}
+ ) : listNotConnected || authNotConnected ? (
+
+
+
{t('contextPanel.giteaPr.error.notConnected')}
+
+ {t('contextPanel.giteaPr.actions.openSettings')}
+
+
+ ) : listLoading ? (
+
+
+ {t('contextPanel.giteaPr.loading')}
+
+ ) : listError ? (
+
+
{t('contextPanel.giteaPr.issues.error.loadFailed')}
+
{listError}
+
+ {t('contextPanel.preview.actions.retry')}
+
+
+ ) : issues.length === 0 ? (
+
{t('contextPanel.giteaPr.issues.empty')}
+ ) : (
+
+ {issues.map((item) => (
+
+ ))}
+
+ {listHasMore ? (
+
+ void loadMore()} disabled={listLoadingMore}>
+ {listLoadingMore ? (
+
+ ) : null}
+ {t('contextPanel.giteaPr.loadMore')}
+
+
+ ) : null}
+
+ )}
+
+ {issueProvider?.createIssue ? (
+
+ ) : null}
+
+ );
+};
diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx
index 20c9d87d..cfe62633 100644
--- a/packages/ui/src/components/views/git/PullRequestSection.tsx
+++ b/packages/ui/src/components/views/git/PullRequestSection.tsx
@@ -32,6 +32,15 @@ import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { getPrContextKey, usePrContextStore } from '@/stores/usePrContextStore';
import { summarizeCheckRuns } from '@/lib/githubChecks';
+import { buildForgeProvider, mapGithubPr } from '@/lib/forge';
+import type { ForgeCommit, ForgeFileChange } from '@/lib/forge';
+import { ForgeCommitsSection, ForgeFilesDiffSection, ForgeMetadataChips } from '@/components/views/forge';
+import {
+ ForgeCommentComposer,
+ ForgeDraftToggle,
+ ForgeReviewActions,
+ ForgeStateActions,
+} from '@/components/views/forge/actions';
import type {
GitHubPullRequest,
GitHubCheckRun,
@@ -42,7 +51,7 @@ import type {
import { useI18n } from '@/lib/i18n';
type MergeMethod = 'merge' | 'squash' | 'rebase';
-type PrSegment = 'overview' | 'checks' | 'comments';
+type PrSegment = 'overview' | 'checks' | 'comments' | 'commits' | 'files';
const PR_CHECKS_AUTO_REFRESH_MS = 35_000;
@@ -371,6 +380,7 @@ export const PullRequestSection: React.FC<{
}
return normalizeBranchRef(baseBranch);
});
+ const [headBranch, setHeadBranch] = React.useState(branch);
const [mergeMethod, setMergeMethod] = React.useState('squash');
const [isGenerating, setIsGenerating] = React.useState(false);
@@ -447,6 +457,37 @@ export const PullRequestSection: React.FC<{
return Array.from(unique).sort((a, b) => a.localeCompare(b));
}, [baseBranch, remoteBranches, selectedRemote?.name, targetBaseBranch, upstreamBranches, useDetectedUpstream]);
+ const availableHeadBranches = React.useMemo(() => {
+ const selectedRemoteName = useDetectedUpstream ? null : (selectedRemote?.name?.trim() || null);
+ const unique = new Set();
+
+ // The current local branch must always be offered, even before the branch list resolves.
+ unique.add(branch);
+
+ for (const remoteBranch of remoteBranches) {
+ const branchName = remoteBranchToName(remoteBranch, selectedRemoteName);
+ if (!branchName || branchName === 'HEAD') {
+ continue;
+ }
+ unique.add(branchName);
+ }
+
+ // When using detected upstream, include all upstream repo branches
+ if (useDetectedUpstream) {
+ for (const b of upstreamBranches) {
+ if (b && b !== 'HEAD') {
+ unique.add(b);
+ }
+ }
+ }
+
+ const sorted = Array.from(unique).sort((a, b) => a.localeCompare(b));
+ if (branch && sorted[0] !== branch) {
+ return [branch, ...sorted.filter((candidate) => candidate !== branch)];
+ }
+ return sorted;
+ }, [branch, remoteBranches, selectedRemote?.name, upstreamBranches, useDetectedUpstream]);
+
// Update selected remote when remotes change
React.useEffect(() => {
if (remotes.length === 0) {
@@ -518,6 +559,100 @@ export const PullRequestSection: React.FC<{
const isHistoricalPr = pr?.state === 'merged' || pr?.state === 'closed';
const livePr = isHistoricalPr ? null : pr;
+ // Forge rich-view tabs (commits / files): the provider facade wraps the raw
+ // GitHub API with normalized result envelopes. `forgePr` is the status PR
+ // projected onto the forge vocabulary (labels/assignees/milestone come from
+ // the enriched summary the server already returns).
+ const forgeProvider = React.useMemo(() => (github ? buildForgeProvider('github', { github }) : null), [github]);
+ const forgePr = React.useMemo(() => (pr ? mapGithubPr(pr) : null), [pr]);
+ const prSourceRepo = React.useMemo(() => {
+ if (!status?.repo) {
+ return null;
+ }
+ return `${status.repo.owner}/${status.repo.repo}`;
+ }, [status?.repo]);
+
+ const [commits, setCommits] = React.useState(null);
+ const [commitsLoading, setCommitsLoading] = React.useState(false);
+ const [commitsError, setCommitsError] = React.useState(null);
+ const [prFiles, setPrFiles] = React.useState(null);
+ const [prDiff, setPrDiff] = React.useState(null);
+ const [filesLoading, setFilesLoading] = React.useState(false);
+ const [filesError, setFilesError] = React.useState(null);
+
+ // Key on the PR number, not the status object: periodic status refreshes
+ // create a new object identity for the same PR, which must not re-trigger a
+ // refetch (and a loading flicker) of an already loaded tab.
+ const prNumber = pr?.number ?? null;
+
+ // Commits and files load lazily per segment and bypass the shared
+ // usePrContextStore flow that Overview/Checks/Comments rely on. Leaving the
+ // segment cancels the in-flight request so a stale result never overwrites
+ // a newer segment's data.
+ React.useEffect(() => {
+ if (activeSegment !== 'commits' || prNumber === null || !forgeProvider?.getCommits) {
+ return;
+ }
+ let cancelled = false;
+ setCommitsLoading(true);
+ setCommitsError(null);
+ void forgeProvider
+ .getCommits(directory, prNumber, { sourceRepo: prSourceRepo })
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ setCommits(result.commits);
+ setCommitsError(result.error ?? null);
+ })
+ .catch((e) => {
+ if (cancelled) {
+ return;
+ }
+ setCommitsError(e instanceof Error ? e.message : String(e));
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setCommitsLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [activeSegment, directory, forgeProvider, prNumber, prSourceRepo]);
+
+ React.useEffect(() => {
+ if (activeSegment !== 'files' || prNumber === null || !forgeProvider) {
+ return;
+ }
+ let cancelled = false;
+ setFilesLoading(true);
+ setFilesError(null);
+ void forgeProvider
+ .getPullRequestContext(directory, prNumber, { includeDiff: true, sourceRepo: prSourceRepo })
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ setPrFiles(result.files ?? null);
+ setPrDiff(result.diff ?? null);
+ })
+ .catch((e) => {
+ if (cancelled) {
+ return;
+ }
+ setFilesError(e instanceof Error ? e.message : String(e));
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setFilesLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [activeSegment, directory, forgeProvider, prNumber, prSourceRepo]);
+
const prContextKey = livePr ? getPrContextKey(directory, livePr.number) : null;
const prContextEntry = usePrContextStore((state) => (prContextKey ? state.entries[prContextKey] : undefined));
const ensurePrContext = usePrContextStore((state) => state.ensure);
@@ -1079,6 +1214,19 @@ export const PullRequestSection: React.FC<{
}, delayMs));
}, [refresh]);
+ // Forge write actions in the Overview refresh the status store so chips,
+ // checks, and the header stay coherent after a state/draft/review change.
+ const refreshPr = React.useCallback(() => {
+ void refresh({ force: true });
+ }, [refresh]);
+
+ // A posted comment lives in the context store (Comments tab), so refresh it
+ // in place; the status store is unaffected by comments.
+ const refreshPrContext = React.useCallback(() => {
+ if (!github?.prContext || !pr) return;
+ void ensurePrContext(github, directory, pr.number, { force: true, sourceRepo: status?.repo ?? null });
+ }, [directory, ensurePrContext, github, pr, status?.repo]);
+
React.useEffect(() => {
if (!github?.prStatus || !canShow || remotes.length <= 1) {
return;
@@ -1167,6 +1315,7 @@ export const PullRequestSection: React.FC<{
setBody(snapshot?.body ?? '');
setDraft(snapshot?.draft ?? false);
setTargetBaseBranch(snapshot?.targetBaseBranch ? normalizeBranchRef(snapshot.targetBaseBranch) : normalizeBranchRef(baseBranch));
+ setHeadBranch(branch);
const nextRemote = pickInitialPrRemote(remotes, {
selectedRemoteName: snapshot?.selectedRemoteName,
trackingBranch,
@@ -1304,7 +1453,7 @@ export const PullRequestSection: React.FC<{
toast.error(t('gitView.pr.toast.baseBranchRequired'));
return;
}
- if (!useDetectedUpstream && trimmedBase === branch) {
+ if (!useDetectedUpstream && trimmedBase === headBranch) {
toast.error(t('gitView.pr.toast.baseMustDifferFromHead'));
return;
}
@@ -1318,7 +1467,7 @@ export const PullRequestSection: React.FC<{
const pr = await github.prCreate({
directory,
title: trimmedTitle,
- head: branch,
+ head: headBranch,
base: trimmedBase,
...(body.trim() ? { body } : {}),
draft,
@@ -1341,7 +1490,7 @@ export const PullRequestSection: React.FC<{
} finally {
setIsCreating(false);
}
- }, [body, branch, detectedUpstream, directory, draft, github, prStatusKey, refresh, scheduleActionRefresh, selectedRemote, targetBaseBranch, title, trackingBranch, updatePrStatus, useDetectedUpstream, t]);
+ }, [body, detectedUpstream, directory, draft, github, headBranch, prStatusKey, refresh, scheduleActionRefresh, selectedRemote, targetBaseBranch, title, trackingBranch, updatePrStatus, useDetectedUpstream, t]);
const mergePr = React.useCallback(async (pr: GitHubPullRequest) => {
if (!github?.prMerge) {
@@ -1669,6 +1818,14 @@ export const PullRequestSection: React.FC<{
? `${t('gitView.pr.segment.comments')} ${(prContext.issueComments?.length ?? 0) + (prContext.reviewComments?.length ?? 0)}`
: t('gitView.pr.segment.comments'),
},
+ {
+ id: 'commits',
+ label: t('forge.section.commits'),
+ },
+ {
+ id: 'files',
+ label: t('forge.section.files'),
+ },
]}
activeId={activeSegment}
onSelect={(segmentId) => setActiveSegment(segmentId as PrSegment)}
@@ -1761,6 +1918,33 @@ export const PullRequestSection: React.FC<{
) : null}
+ {forgePr ? : null}
+
+ {forgeProvider && forgePr && forgePr.state === 'open' ? (
+
+
+
+
+
+ ) : null}
+
{isEditingPr ? (
) : null}
@@ -1997,6 +2190,14 @@ export const PullRequestSection: React.FC<{
)}
) : null}
+
+ {activeSegment === 'commits' ? (
+
+ ) : null}
+
+ {activeSegment === 'files' ? (
+
+ ) : null}
@@ -2028,7 +2229,7 @@ export const PullRequestSection: React.FC<{
{t('gitView.pr.createTitle')}
- {branch} (local) → {targetBaseBranch} ({useDetectedUpstream && detectedUpstream ? 'upstream' : 'remote'})
+ {headBranch}{headBranch === branch ? (local) : null} → {targetBaseBranch} ({useDetectedUpstream && detectedUpstream ? 'upstream' : 'remote'})
{repoUrl ? (
@@ -2053,6 +2254,20 @@ export const PullRequestSection: React.FC<{
/>
+
+ {t('gitView.pr.field.headBranch')}
+
+
+
+
+
+ {availableHeadBranches.map((candidate) => (
+ {candidate}
+ ))}
+
+
+
+
{t('gitView.pr.field.baseBranch')}
{availableBaseBranches.length > 0 ? (
@@ -2203,7 +2418,7 @@ export const PullRequestSection: React.FC<{
size="sm"
className="min-w-[7.5rem] justify-center gap-2"
onClick={createPr}
- disabled={isCreating || !isConnected || !targetBaseBranch.trim() || (!useDetectedUpstream && targetBaseBranch.trim() === branch)}
+ disabled={isCreating || !isConnected || !targetBaseBranch.trim() || (!useDetectedUpstream && targetBaseBranch.trim() === headBranch)}
>
{isCreating ? : }
diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx
index a504eb34..02cad5be 100644
--- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx
+++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx
@@ -13,6 +13,9 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n, type Locale } from '@/lib/i18n';
import { openExternalUrl } from '@/lib/url';
+import { useGitProvider } from '@/lib/gitProvider';
+import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus';
+import { useGiteaPrForBranch } from '@/lib/giteaPrStatus';
import { buildWalkthroughView } from '@/lib/walkthrough/model';
import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types';
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
@@ -223,9 +226,12 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
+ const gitProvider = useGitProvider(directory);
+ const gitLabMr = useGitLabMrForBranch(directory, currentBranch);
+ const giteaPr = useGiteaPrForBranch(directory, currentBranch);
useEffect(() => {
- if (!directory || !currentBranch || !githubAuthChecked || !githubConnected) return;
+ if (!directory || !currentBranch || !githubAuthChecked || !githubConnected || gitProvider !== 'github') return;
const key = getGitHubPrStatusKey(directory, currentBranch);
ensurePrStatusEntry(key);
setPrStatusParams(key, {
@@ -245,6 +251,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
github,
githubAuthChecked,
githubConnected,
+ gitProvider,
refreshPrStatusTargets,
setPrStatusParams,
]);
@@ -262,12 +269,21 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
[requestedSource, scope]
);
- // Offer whichever pull request we know about: the one already selected, or
- // the one this branch has.
+ // Offer whichever pull request or merge request we know about: the one
+ // already selected, or the one this branch has. GitLab repos get their MR
+ // number from the branch lookup and Gitea repos their PR number the same
+ // way; everything else falls back to the GitHub PR status store, which the
+ // polling effect above only fills for GitHub repos.
const prSource = useMemo | null>(() => {
if (source.kind === 'pr') return source;
+ if (gitProvider === 'gitlab') {
+ const number = gitLabMr.mr?.number;
+ return number ? { kind: 'pr', number } : null;
+ }
+ // Gitea PR diff is not yet supported server-side; omit the source to
+ // avoid offering a review that would fail with "no GitHub remote".
return branchPrNumber ? { kind: 'pr', number: branchPrNumber } : null;
- }, [branchPrNumber, source]);
+ }, [branchPrNumber, giteaPr.pr, gitLabMr.mr, gitProvider, source]);
const selectWorkingTree = useCallback(
(value: WalkthroughWorkingTreeScope) => {
@@ -353,7 +369,9 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
const sourceLabel = source.kind === 'branch'
? t('walkthrough.scope.branch')
: source.kind === 'pr'
- ? t('walkthrough.scope.pullRequest', { number: source.number })
+ ? gitProvider === 'gitlab'
+ ? t('walkthrough.scope.mergeRequest', { number: source.number })
+ : t('walkthrough.scope.pullRequest', { number: source.number })
: scope === 'all'
? t('walkthrough.scope.all')
: scope === 'staged'
@@ -588,7 +606,9 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
)}
{prSource && (
- {t('walkthrough.scope.pullRequest', { number: prSource.number })}
+ {gitProvider === 'gitlab'
+ ? t('walkthrough.scope.mergeRequest', { number: prSource.number })
+ : t('walkthrough.scope.pullRequest', { number: prSource.number })}
)}
diff --git a/packages/ui/src/hooks/useForgeProvider.ts b/packages/ui/src/hooks/useForgeProvider.ts
new file mode 100644
index 00000000..6d9875b7
--- /dev/null
+++ b/packages/ui/src/hooks/useForgeProvider.ts
@@ -0,0 +1,39 @@
+import { resolveGitProvider, buildGitProviderHosts } from '@/lib/gitProvider';
+import type { GitProviderHosts } from '@/lib/gitProvider';
+import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
+import { buildForgeProvider } from '@/lib/forge/adapters';
+import type { ForgeProvider } from '@/lib/forge/provider';
+import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
+import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
+import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
+
+/**
+ * Provider-host sets derived from the connected accounts, the configured api
+ * base urls and the user-configured custom domains, mirroring the `hosts` memo
+ * inside `useGitProvider` so the imperative resolver classifies directories the
+ * same way the hook does.
+ */
+const buildProviderHosts = (): GitProviderHosts => {
+ const gitlabAccounts = useGitLabAuthStore.getState().status?.accounts;
+ const giteaAccounts = useGiteaAuthStore.getState().status?.accounts;
+ const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState();
+ return buildGitProviderHosts({ domains, apiBaseUrls, gitlabAccounts, giteaAccounts });
+};
+
+/**
+ * Resolve the forge provider for `directory` for non-React code paths.
+ * Resolves the directory's provider from the auth stores' connected accounts
+ * and the runtime's registered APIs in one async step.
+ */
+export const getForgeProviderForDirectory = async (directory: string): Promise => {
+ const hosts = buildProviderHosts();
+ const kind = await resolveGitProvider(directory, hosts);
+ if (!kind || kind === 'other') return null;
+ const apis = getRegisteredRuntimeAPIs();
+ if (!apis) return null;
+ return buildForgeProvider(kind, {
+ github: apis.github,
+ gitlab: apis.gitlab,
+ gitea: apis.gitea,
+ });
+};
diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts
index bb069914..1618dfc3 100644
--- a/packages/ui/src/hooks/useKeyboardShortcuts.ts
+++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts
@@ -28,9 +28,9 @@ import { ShortcutRegistry } from '@/lib/shortcuts/registry';
import { getVisibleContextRailSurfaces } from '@/lib/surfaces/registry';
import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
+import { useGitProvider } from '@/lib/gitProvider';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
-import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
@@ -58,6 +58,9 @@ export const useKeyboardShortcuts = () => {
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const effectiveDirectory = useEffectiveDirectory();
const activeProject = useProjectsStore((s) => s.getActiveProject());
+ // Mirrors the rail's provider-aware 'pr' surface: the digit-shortcut list
+ // must agree with the rail on whether the PR/MR surface is visible.
+ const gitProvider = useGitProvider(effectiveDirectory);
const { themeMode, setThemeMode } = useThemeSystem();
const { phase: sessionPhase } = useCurrentSessionActivity();
const abortPrimedUntilRef = React.useRef(null);
@@ -508,7 +511,7 @@ export const useKeyboardShortcuts = () => {
screenWidth: window.innerWidth,
tabs: panel?.tabs ?? [],
linearConnected: useLinearAuthStore.getState().status?.connected === true,
- githubConnected: useGitHubAuthStore.getState().status?.connected === true,
+ gitProvider,
});
const target = visibleSurfaces[switchSurfaceDigit - 1];
if (target) {
@@ -573,7 +576,7 @@ export const useKeyboardShortcuts = () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleBlur);
};
- }, [armAbortPrompt, currentSessionId, dispatcher, effectiveDirectory, resetAbortPriming, selectionToolbarDispatcher, sessionPhase]);
+ }, [armAbortPrompt, currentSessionId, dispatcher, effectiveDirectory, gitProvider, resetAbortPriming, selectionToolbarDispatcher, sessionPhase]);
React.useEffect(() => () => resetAbortPriming(), [resetAbortPriming]);
};
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts
index c94ded88..001f21b1 100644
--- a/packages/ui/src/lib/api/types.ts
+++ b/packages/ui/src/lib/api/types.ts
@@ -894,6 +894,42 @@ export type GitHubUserSummary = {
email?: string;
};
+// ---- Rich lookup results (repo-scoped search for pickers/mentions) ----
+// Each result carries the connected repo so the facade can surface cross-repo /
+// fork contexts, and the items are always arrays (empty on success with no
+// matches). `connected: false` means the lookup could not be performed and
+// must not be treated as an authoritative empty list.
+
+export type GitHubUsersSearchResult = {
+ connected: boolean;
+ repo?: GitHubRepoRef | null;
+ users: GitHubUserSummary[];
+};
+
+export type GitHubLabelsSearchResult = {
+ connected: boolean;
+ repo?: GitHubRepoRef | null;
+ labels: GitHubIssueLabel[];
+};
+
+export type GitHubMilestonesSearchResult = {
+ connected: boolean;
+ repo?: GitHubRepoRef | null;
+ milestones: Array<{ title: string; state?: string }>;
+};
+
+export type GitHubBranchesSearchResult = {
+ connected: boolean;
+ repo?: GitHubRepoRef | null;
+ branches: string[];
+};
+
+export type GitHubTagsSearchResult = {
+ connected: boolean;
+ repo?: GitHubRepoRef | null;
+ tags: string[];
+};
+
type GitHubRepoRef = {
owner: string;
repo: string;
@@ -987,6 +1023,10 @@ export type GitHubPullRequestSummary = GitHubPullRequest & {
headLabel?: string;
headRepo?: GitHubPullRequestHeadRepo | null;
sourceRepo?: (GitHubRepoSelector & { source: string }) | null;
+ labels?: GitHubIssueLabel[];
+ assignees?: GitHubUserSummary[];
+ milestone?: { title: string; state?: string } | null;
+ commentsCount?: number;
};
type GitHubPullRequestFile = {
@@ -1032,6 +1072,38 @@ export type GitHubPullRequestContextResult = {
checkRuns?: GitHubCheckRun[];
};
+export type GitHubPullRequestCommit = {
+ sha: string;
+ shortSha: string;
+ message: string;
+ summary?: string;
+ author?: GitHubUserSummary | null;
+ committer?: GitHubUserSummary | null;
+ committedAt?: string;
+ parents: string[];
+};
+
+export type GitHubPullRequestCommitsResult = {
+ connected: boolean;
+ repo?: GitHubRepoRef | null;
+ commits: GitHubPullRequestCommit[];
+};
+
+export type GitHubTimelineEvent = {
+ id: string;
+ type: string;
+ author?: GitHubUserSummary | null;
+ createdAt?: string;
+ body?: string | null;
+ commitSha?: string | null;
+};
+
+export type GitHubPullRequestTimelineResult = {
+ connected: boolean;
+ repo?: GitHubRepoRef | null;
+ events: GitHubTimelineEvent[];
+};
+
export type GitHubPullRequestStatus = {
connected: boolean;
/** Server-side stamp of when the data was fetched from GitHub (ms epoch); survives server cache serves. */
@@ -1065,6 +1137,11 @@ export type GitHubPullRequestUpdateInput = {
number: number;
title: string;
body?: string;
+ state?: 'open' | 'closed';
+ draft?: boolean;
+ labels?: string[];
+ assignees?: string[];
+ milestone?: string | null;
};
export type GitHubPullRequestMergeInput = {
@@ -1104,6 +1181,9 @@ export type GitHubIssueSummary = {
state: 'open' | 'closed';
author?: GitHubUserSummary | null;
labels?: GitHubIssueLabel[];
+ assignees?: GitHubUserSummary[];
+ milestone?: { title: string; state?: string } | null;
+ commentsCount?: number;
sourceRepo?: (GitHubRepoSelector & { source: string }) | null;
};
@@ -1149,6 +1229,97 @@ export type GitHubIssueCommentsResult = {
comments?: GitHubIssueComment[];
};
+export type GitHubPullRequestReviewEvent = 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT';
+
+export type GitHubPullRequestReview = {
+ id: string;
+ state: string;
+ author?: GitHubUserSummary | null;
+ submittedAt?: string;
+ body?: string | null;
+ commitSha?: string | null;
+};
+
+export type GitHubIssueCommentInput = {
+ directory: string;
+ number: number;
+ body: string;
+ owner?: string;
+ repo?: string;
+};
+
+export type GitHubIssueCommentResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ comment?: GitHubIssueComment | null;
+};
+
+export type GitHubIssueCreateInput = {
+ directory: string;
+ title: string;
+ body?: string;
+ labels?: string[];
+ owner?: string;
+ repo?: string;
+};
+
+export type GitHubIssueCreateResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ issue?: GitHubIssue | null;
+};
+
+export type GitHubIssueUpdateInput = {
+ directory: string;
+ number: number;
+ title?: string;
+ body?: string;
+ state?: 'open' | 'closed';
+ labels?: string[];
+ assignees?: string[];
+ milestone?: string | null;
+ owner?: string;
+ repo?: string;
+};
+
+export type GitHubIssueUpdateResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ issue?: GitHubIssue | null;
+};
+
+export type GitHubReviewCommentInput = {
+ directory: string;
+ number: number;
+ body: string;
+ inReplyToId?: number;
+ path?: string;
+ line?: number;
+ owner?: string;
+ repo?: string;
+};
+
+export type GitHubPullRequestReviewInput = {
+ directory: string;
+ number: number;
+ event: GitHubPullRequestReviewEvent;
+ body?: string;
+ owner?: string;
+ repo?: string;
+};
+
+export type GitHubPullRequestReviewResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ review?: GitHubPullRequestReview | null;
+};
+
+export type GitHubReviewCommentResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ comment?: GitHubPullRequestReviewComment | null;
+};
+
export type GitHubAuthStatus = {
connected: boolean;
user?: GitHubUserSummary | null;
@@ -1386,6 +1557,12 @@ export interface GitHubAPI {
authSetGhCliDisabled(disabled: boolean): Promise<{ disabled: boolean }>;
me?(): Promise;
+ searchUsers?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise;
+ searchLabels?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise;
+ searchMilestones?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise;
+ searchBranches?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise;
+ searchTags?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise;
+
prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise;
prCreate(payload: GitHubPullRequestCreateInput): Promise;
prUpdate(payload: GitHubPullRequestUpdateInput): Promise;
@@ -1402,8 +1579,654 @@ export interface GitHubAPI {
issuesList(directory: string, options?: { page?: number; query?: string }): Promise;
issueGet(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise;
issueComments(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise;
+ prCommits?(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise;
+ prTimeline?(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise;
repoUpstream(directory: string): Promise;
repoBranches(owner: string, repo: string): Promise;
+ issueComment?(input: GitHubIssueCommentInput): Promise;
+ issueCreate?(input: GitHubIssueCreateInput): Promise;
+ issueUpdate?(input: GitHubIssueUpdateInput): Promise;
+ prComment?(input: GitHubIssueCommentInput): Promise;
+ prReviewComment?(input: GitHubReviewCommentInput): Promise;
+ prSubmitReview?(input: GitHubPullRequestReviewInput): Promise;
+}
+
+export type GitLabUserSummary = {
+ username: string;
+ id: number;
+ name?: string;
+ avatarUrl?: string;
+ webUrl?: string;
+ email?: string;
+};
+
+export type GitLabRepoRef = {
+ namespace: string;
+ project: string;
+ host: string;
+ url: string;
+ baseUrl: string;
+};
+
+export type GitLabIssueSummary = {
+ number: number;
+ title: string;
+ url: string;
+ state: string;
+ author: GitLabUserSummary;
+ labels: string[];
+};
+
+export type GitLabIssue = {
+ number: number;
+ title: string;
+ url: string;
+ state: string;
+ body?: string;
+ createdAt?: string;
+ updatedAt?: string;
+ author: GitLabUserSummary;
+ assignees?: GitLabUserSummary[];
+ labels: string[];
+};
+
+export type GitLabIssueComment = {
+ id: number;
+ url: string;
+ body: string;
+ createdAt?: string;
+ updatedAt?: string;
+ author: GitLabUserSummary;
+};
+
+export type GitLabIssuesListResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ issues: GitLabIssueSummary[];
+ page: number;
+ hasMore: boolean;
+};
+
+export type GitLabIssueGetResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ issue?: GitLabIssue;
+};
+
+export type GitLabIssueCommentsResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ comments: GitLabIssueComment[];
+};
+
+export type GitLabMergeRequestSummary = {
+ number: number;
+ title: string;
+ url: string;
+ state: string;
+ draft: boolean;
+ author: GitLabUserSummary;
+ sourceBranch: string;
+ targetBranch: string;
+ labels?: string[];
+ assignees?: GitLabUserSummary[];
+ milestone?: { title: string; state?: string } | null;
+ commentsCount?: number;
+};
+
+export type GitLabMergeRequest = {
+ number: number;
+ title: string;
+ url: string;
+ state: string;
+ draft: boolean;
+ body?: string;
+ createdAt?: string;
+ updatedAt?: string;
+ author: GitLabUserSummary;
+ sourceBranch: string;
+ targetBranch: string;
+ headSha?: string;
+};
+
+type GitLabMergeRequestFile = {
+ filename: string;
+ status?: string;
+ additions?: number;
+ deletions?: number;
+ changes?: number;
+ patch?: string;
+};
+
+export type GitLabMergeRequestsListResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ mrs: GitLabMergeRequestSummary[];
+ page: number;
+ hasMore: boolean;
+};
+
+export type GitLabMergeRequestContextResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ mr?: GitLabMergeRequest;
+ comments?: GitLabIssueComment[];
+ files?: GitLabMergeRequestFile[];
+ diff?: string;
+};
+
+export type GitLabBranchesResult = {
+ branches: string[];
+ defaultBranch?: string | null;
+};
+
+// ---- Rich lookup results (repo-scoped search for pickers/mentions) ----
+// `connected: false` means the lookup could not be performed and must not be
+// treated as an authoritative empty list.
+
+export type GitLabUsersSearchResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ users: GitLabUserSummary[];
+};
+
+export type GitLabLabelsSearchResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ labels: string[];
+};
+
+export type GitLabMilestonesSearchResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ milestones: Array<{ title: string; state?: string }>;
+};
+
+export type GitLabBranchesSearchResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ branches: string[];
+};
+
+export type GitLabTagsSearchResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ tags: string[];
+};
+
+export type GitLabMergeRequestCommit = {
+ sha: string;
+ shortSha: string;
+ message: string;
+ summary?: string;
+ authorName?: string;
+ committedAt?: string;
+ parents: string[];
+};
+
+export type GitLabMergeRequestCommitsResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ commits: GitLabMergeRequestCommit[];
+};
+
+export type GitLabTimelineEvent = {
+ id: string;
+ type: string;
+ body?: string | null;
+ author?: GitLabUserSummary | null;
+ createdAt?: string;
+};
+
+export type GitLabMergeRequestTimelineResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ events: GitLabTimelineEvent[];
+};
+
+export type GitLabMergeRequestCreateInput = {
+ directory: string;
+ title: string;
+ sourceBranch: string;
+ targetBranch: string;
+ description?: string;
+ removeSourceBranch?: boolean;
+};
+
+export type GitLabMergeRequestUpdateInput = {
+ directory: string;
+ number: number;
+ title?: string;
+ description?: string;
+ state?: 'open' | 'closed';
+ labels?: string[];
+ /** Assignee logins; the server resolves them to user IDs via project members. */
+ assignees?: string[];
+ assigneeIds?: number[];
+ milestone?: string | null;
+};
+
+export type GitLabMergeRequestMergeInput = {
+ directory: string;
+ number: number;
+ squash?: boolean;
+};
+
+export type GitLabMergeRequestCreateResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ mr?: GitLabMergeRequest;
+};
+
+export type GitLabMergeRequestUpdateResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ mr?: GitLabMergeRequest;
+};
+
+export type GitLabMergeRequestMergeResult = {
+ connected: boolean;
+ merged: boolean;
+ message?: string;
+};
+
+export type GitLabIssueCommentInput = {
+ directory: string;
+ number: number;
+ body: string;
+ namespace?: string;
+ project?: string;
+};
+
+export type GitLabIssueCommentResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ comment?: GitLabIssueComment | null;
+};
+
+export type GitLabIssueCreateInput = {
+ directory: string;
+ title: string;
+ body?: string;
+ labels?: string[];
+ namespace?: string;
+ project?: string;
+};
+
+export type GitLabIssueCreateResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ issue?: GitLabIssue | null;
+};
+
+export type GitLabIssueUpdateInput = {
+ directory: string;
+ number: number;
+ title?: string;
+ body?: string;
+ state?: 'open' | 'closed';
+ labels?: string[];
+ /** Assignee logins; the server resolves them to user IDs via project members. */
+ assignees?: string[];
+ assigneeIds?: number[];
+ milestone?: string | null;
+ namespace?: string;
+ project?: string;
+};
+
+export type GitLabIssueUpdateResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ issue?: GitLabIssue | null;
+};
+
+export type GitLabMrNoteInput = {
+ directory: string;
+ number: number;
+ body: string;
+ namespace?: string;
+ project?: string;
+};
+
+export type GitLabMrNoteResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ comment?: GitLabIssueComment | null;
+};
+
+export type GitLabMrApproveInput = {
+ directory: string;
+ number: number;
+ namespace?: string;
+ project?: string;
+};
+
+export type GitLabMrApproveResult = {
+ connected: boolean;
+ repo?: GitLabRepoRef | null;
+ approved: boolean;
+};
+
+type GitLabAuthAccount = {
+ id: string;
+ user: {
+ username: string;
+ name?: string;
+ avatarUrl?: string;
+ webUrl?: string;
+ };
+ baseUrl: string;
+ current: boolean;
+};
+
+export type GitLabAuthStatus = {
+ connected: boolean;
+ user?: GitLabUserSummary;
+ accounts: GitLabAuthAccount[];
+ defaultBaseUrl: string;
+};
+
+export interface GitLabAPI {
+ authStatus(): Promise;
+ authConnect(input: { accessToken: string; baseUrl?: string }): Promise;
+ authActivate(accountId: string): Promise;
+ authDisconnect(): Promise<{ removed: boolean }>;
+ me(): Promise;
+
+ issuesList(directory: string, options?: { page?: number; query?: string }): Promise;
+ issueGet(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise;
+ issueComments(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise;
+
+ mrsList(directory: string, options?: { page?: number; query?: string; sourceBranch?: string }): Promise;
+ mrContext(
+ directory: string,
+ number: number,
+ options?: { includeDiff?: boolean; namespace?: string; project?: string }
+ ): Promise;
+ mrCreate(input: GitLabMergeRequestCreateInput): Promise;
+ mrUpdate(input: GitLabMergeRequestUpdateInput): Promise;
+ mrMerge(input: GitLabMergeRequestMergeInput): Promise;
+ mrCommits?(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise;
+ mrTimeline?(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise;
+
+ issueComment?(input: GitLabIssueCommentInput): Promise;
+ issueCreate?(input: GitLabIssueCreateInput): Promise;
+ issueUpdate?(input: GitLabIssueUpdateInput): Promise;
+ mrComment?(input: GitLabMrNoteInput): Promise;
+ mrApprove?(input: GitLabMrApproveInput): Promise;
+
+ searchUsers?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise;
+ searchLabels?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise;
+ searchMilestones?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise;
+ searchBranches?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise;
+ searchTags?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise;
+
+ repoBranches(namespace: string, project: string): Promise;
+}
+
+// ============== Gitea / Forgejo Provider ==============
+// Gitea and Forgejo share the same REST v1 API (GitHub-style). Repos are flat
+// `owner/repo` (no multi-segment namespaces) and remote work is called
+// "pull requests" (PR), matching GitHub terminology.
+
+type GiteaAuthAccount = {
+ id: string;
+ user: { username: string; name?: string; avatarUrl?: string; webUrl?: string };
+ baseUrl: string;
+ current: boolean;
+};
+
+export type GiteaAuthStatus = {
+ connected: boolean;
+ user?: GiteaUserSummary;
+ accounts: GiteaAuthAccount[];
+};
+
+export type GiteaUserSummary = {
+ username: string;
+ id?: number;
+ name?: string;
+ avatarUrl?: string;
+ webUrl?: string;
+ email?: string;
+};
+
+export type GiteaIssueSummary = {
+ number: number;
+ title: string;
+ url: string;
+ state: string;
+ author: { username: string; id?: number };
+ labels: string[];
+ assignees?: GiteaUserSummary[];
+ milestone?: { title: string; state?: string } | null;
+ commentsCount?: number;
+};
+
+export type GiteaIssue = GiteaIssueSummary & { body?: string; createdAt?: string; updatedAt?: string };
+
+export type GiteaComment = {
+ id: number;
+ body: string;
+ url?: string;
+ author: { username: string; id?: number };
+ createdAt?: string;
+};
+
+export type GiteaPullRequestSummary = {
+ number: number;
+ title: string;
+ url: string;
+ state: 'open' | 'closed' | 'merged';
+ draft?: boolean;
+ author: { username: string; id?: number };
+ labels: string[];
+ assignees?: GiteaUserSummary[];
+ milestone?: { title: string; state?: string } | null;
+ commentsCount?: number;
+ sourceBranch: string;
+ targetBranch: string;
+};
+
+export type GiteaPullRequest = GiteaPullRequestSummary & {
+ body?: string;
+ mergeable?: boolean;
+ merged?: boolean;
+ createdAt?: string;
+ updatedAt?: string;
+};
+
+export type GiteaIssuesListResult = { connected: boolean; repo?: { owner: string; repo: string; url?: string } | null; issues: GiteaIssueSummary[]; page: number; hasMore: boolean };
+export type GiteaIssueGetResult = { connected: boolean; repo?: { owner: string; repo: string; url?: string } | null; issue?: GiteaIssue | null };
+export type GiteaIssueCommentsResult = { connected: boolean; repo?: { owner: string; repo: string; url?: string } | null; comments: GiteaComment[] };
+export type GiteaPullRequestsListResult = { connected: boolean; repo?: { owner: string; repo: string; url?: string } | null; prs: GiteaPullRequestSummary[]; page: number; hasMore: boolean };
+export type GiteaPullRequestContextResult = { connected: boolean; repo?: { owner: string; repo: string; url?: string } | null; pr?: GiteaPullRequest | null; comments: GiteaComment[]; files: Array<{ filename: string; status?: string; additions?: number; deletions?: number; patch?: string }>; diff?: string };
+export type GiteaPullRequestCreateInput = { directory: string; title: string; sourceBranch: string; targetBranch: string; description?: string };
+export type GiteaPullRequestUpdateInput = { directory: string; number: number; title?: string; description?: string; state?: 'open' | 'closed' };
+export type GiteaPullRequestMergeInput = { directory: string; number: number; method?: 'merge' | 'squash' | 'rebase' };
+export type GiteaPullRequestMergeResult = { connected: boolean; merged: boolean; message?: string };
+export type GiteaBranchesResult = { branches: string[]; defaultBranch?: string | null };
+
+export type GiteaPullRequestCommit = {
+ sha: string;
+ message: string;
+ summary?: string;
+ author?: GiteaUserSummary | null;
+ committedAt?: string;
+ parents: string[];
+};
+
+export type GiteaPullRequestCommitsResult = { connected: boolean; repo?: { owner: string; repo: string; url?: string } | null; commits: GiteaPullRequestCommit[] };
+
+export type GiteaCommitStatus = {
+ state: 'success' | 'failure' | 'pending' | 'error' | 'warning' | 'unknown';
+ name: string;
+ description?: string | null;
+ url?: string | null;
+ createdAt?: string;
+};
+
+export type GiteaPullRequestStatusesResult = { connected: boolean; repo?: { owner: string; repo: string; url?: string } | null; statuses: GiteaCommitStatus[] };
+
+export type GiteaReview = {
+ id: string;
+ state: 'APPROVED' | 'REQUEST_CHANGES' | 'COMMENT' | 'PENDING' | 'DISMISSED' | string;
+ author?: GiteaUserSummary | null;
+ submittedAt?: string;
+ body?: string | null;
+ commitSha?: string | null;
+};
+
+export type GiteaPullRequestReviewsResult = { connected: boolean; repo?: { owner: string; repo: string; url?: string } | null; reviews: GiteaReview[] };
+
+export type GiteaIssueCommentInput = {
+ directory: string;
+ number: number;
+ body: string;
+ owner?: string;
+ repo?: string;
+};
+
+export type GiteaIssueCommentResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ comment?: GiteaComment | null;
+};
+
+export type GiteaIssueCreateInput = {
+ directory: string;
+ title: string;
+ body?: string;
+ labels?: string[];
+ owner?: string;
+ repo?: string;
+};
+
+export type GiteaIssueCreateResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ issue?: GiteaIssue | null;
+};
+
+export type GiteaIssueUpdateInput = {
+ directory: string;
+ number: number;
+ title?: string;
+ body?: string;
+ state?: 'open' | 'closed';
+ labels?: string[];
+ assignees?: string[];
+ milestone?: string | null;
+ owner?: string;
+ repo?: string;
+};
+
+export type GiteaIssueUpdateResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ issue?: GiteaIssue | null;
+};
+
+export type GiteaPullReviewInput = {
+ directory: string;
+ number: number;
+ event: 'APPROVED' | 'REQUEST_CHANGES' | 'COMMENT';
+ body?: string;
+ owner?: string;
+ repo?: string;
+};
+
+export type GiteaPullReviewResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ review?: GiteaReview | null;
+};
+
+export type GiteaRepoLabel = {
+ id?: number;
+ name: string;
+ color?: string;
+ description?: string;
+};
+
+export type GiteaRepoLabelsResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ labels: GiteaRepoLabel[];
+};
+
+// ---- Rich lookup results (repo-scoped search for pickers/mentions) ----
+// `connected: false` means the lookup could not be performed and must not be
+// treated as an authoritative empty list.
+
+export type GiteaUsersSearchResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ users: GiteaUserSummary[];
+};
+
+export type GiteaLabelsSearchResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ labels: GiteaRepoLabel[];
+};
+
+export type GiteaMilestonesSearchResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ milestones: Array<{ title: string; state?: string }>;
+};
+
+export type GiteaBranchesSearchResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ branches: string[];
+};
+
+export type GiteaTagsSearchResult = {
+ connected: boolean;
+ repo?: { owner: string; repo: string; url?: string } | null;
+ tags: string[];
+};
+
+export interface GiteaAPI {
+ authStatus(): Promise;
+ authConnect(input: { accessToken: string; baseUrl: string }): Promise;
+ authActivate(accountId: string): Promise;
+ authDisconnect(): Promise<{ removed: boolean }>;
+ me(): Promise;
+
+ issuesList(directory: string, options?: { page?: number; query?: string }): Promise;
+ issueGet(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise;
+ issueComments(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise;
+
+ prsList(directory: string, options?: { page?: number; query?: string; sourceBranch?: string }): Promise;
+ prContext(
+ directory: string,
+ number: number,
+ options?: { includeDiff?: boolean; owner?: string; repo?: string }
+ ): Promise;
+ prCreate(input: GiteaPullRequestCreateInput): Promise;
+ prUpdate(input: GiteaPullRequestUpdateInput): Promise;
+ prMerge(input: GiteaPullRequestMergeInput): Promise;
+ prCommits?(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise;
+ prStatuses?(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise;
+ prReviews?(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise;
+
+ issueComment?(input: GiteaIssueCommentInput): Promise;
+ issueCreate?(input: GiteaIssueCreateInput): Promise;
+ issueUpdate?(input: GiteaIssueUpdateInput): Promise;
+ prComment?(input: GiteaIssueCommentInput): Promise;
+ prSubmitReview?(input: GiteaPullReviewInput): Promise;
+ repoLabels?(directory: string, options?: { owner?: string; repo?: string }): Promise;
+
+ searchUsers?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise;
+ searchLabels?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise;
+ searchMilestones?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise;
+ searchBranches?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise;
+ searchTags?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise;
+
+ repoBranches(owner: string, repo: string): Promise;
}
export interface RemoteClientRecord {
@@ -1502,6 +2325,8 @@ export interface RuntimeAPIs {
notifications: NotificationsAPI;
github?: GitHubAPI;
linear?: LinearAPI;
+ gitlab?: GitLabAPI;
+ gitea?: GiteaAPI;
push?: PushAPI;
diagnostics?: DiagnosticsAPI;
clientAuth?: ClientAuthAPI;
diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts
index 9e157773..56b8e095 100644
--- a/packages/ui/src/lib/desktop.ts
+++ b/packages/ui/src/lib/desktop.ts
@@ -231,6 +231,14 @@ export type DesktopSettings = {
sttModel?: string;
sttLocalModel?: string;
sttLanguage?: string;
+ // Per-provider git forge configuration (server-side settings.json): the API
+ // base URL for provider API calls and the bare hosts that auto-detect the
+ // provider. Server-authoritative; the client stores only a localStorage cache.
+ gitProviders?: {
+ github?: { apiBaseUrl?: string; detectUrls?: string[] };
+ gitlab?: { apiBaseUrl?: string; detectUrls?: string[] };
+ gitea?: { apiBaseUrl?: string; detectUrls?: string[] };
+ };
// Global draft welcome starters (pinned commands/skills), persisted to settings.json
draftStarters?: DraftStarterRef[];
draftStartersVisible?: boolean;
diff --git a/packages/ui/src/lib/forge/adapters.ts b/packages/ui/src/lib/forge/adapters.ts
new file mode 100644
index 00000000..793135c0
--- /dev/null
+++ b/packages/ui/src/lib/forge/adapters.ts
@@ -0,0 +1,1386 @@
+/**
+ * Forge provider adapters.
+ *
+ * Each adapter implements `ForgeProvider` against one provider's wire API
+ * (`GitHubAPI` / `GitLabAPI` / `GiteaAPI` from `@/lib/api/types.ts`) and
+ * normalizes results through `./normalize`. Adapters are deliberately
+ * defensive: when the underlying runtime API or a specific method is missing,
+ * or the wire call throws, they return the graceful envelope (`connected:
+ * false`, empty collections) instead of throwing — the caller treats
+ * `connected: false` as "no authoritative data", never as an empty success.
+ */
+
+import type {
+ GiteaAPI,
+ GiteaPullReviewInput,
+ GitHubAPI,
+ GitHubPullRequestReviewEvent,
+ GitHubRepoSelector,
+ GitLabAPI,
+} from '@/lib/api/types';
+import type {
+ ForgeChecksResult,
+ ForgeCommitsResult,
+ ForgeIssueDetail,
+ ForgeIssuesResult,
+ ForgeProvider,
+ ForgePullRequestContext,
+ ForgePullRequestsResult,
+ ForgeReviewEvent,
+ ForgeTimelineResult,
+ ForgeUpdateResult,
+} from './provider';
+import type { ForgeProviderCapabilities, ForgeProviderKind } from './types';
+import {
+ mapGiteaCommits,
+ mapGiteaComment,
+ mapGiteaContext,
+ mapGiteaAssignee,
+ mapGiteaIssue,
+ mapGiteaPr,
+ mapGiteaRepoRef,
+ mapGiteaReviewsToEvents,
+ mapGiteaReview,
+ mapGiteaStatuses,
+ mapGithubAssignee,
+ mapGithubCommits,
+ mapGithubContext,
+ mapGithubIssue,
+ mapGithubIssueComment,
+ mapGithubPr,
+ mapGithubRepoRef,
+ mapGithubReview,
+ mapGithubReviewCommentReply,
+ mapGithubTimelineEvents,
+ mapGitlabCommits,
+ mapGitlabContext,
+ mapGitlabIssue,
+ mapGitlabMember,
+ mapGitlabMr,
+ mapGitlabNoteComment,
+ mapGitlabRepoRef,
+ mapGitlabTimelineEvents,
+} from './normalize';
+
+const GITHUB_CAPABILITIES: ForgeProviderCapabilities = {
+ checks: 'check-runs',
+ reviews: 'submit',
+ draft: true,
+ labels: true,
+ assignees: true,
+ milestones: true,
+ timelineEvents: true,
+ inlineComments: true,
+ threads: true,
+ userSearch: true,
+ labelSearch: true,
+ milestoneSearch: true,
+ branchSearch: true,
+ tagSearch: true,
+};
+
+const GITLAB_CAPABILITIES: ForgeProviderCapabilities = {
+ checks: 'none',
+ reviews: 'approve-only',
+ draft: true,
+ labels: true,
+ assignees: true,
+ milestones: true,
+ timelineEvents: true,
+ inlineComments: false,
+ threads: true,
+ userSearch: true,
+ labelSearch: true,
+ milestoneSearch: true,
+ branchSearch: true,
+ tagSearch: true,
+};
+
+const GITEA_CAPABILITIES: ForgeProviderCapabilities = {
+ checks: 'commit-statuses',
+ reviews: 'submit',
+ draft: false,
+ labels: true,
+ assignees: true,
+ milestones: true,
+ timelineEvents: true,
+ inlineComments: true,
+ threads: true,
+ userSearch: true,
+ labelSearch: true,
+ milestoneSearch: true,
+ branchSearch: true,
+ tagSearch: true,
+};
+
+// Gitea's 'commit-statuses' checks and inline comments land once Slice B adds
+// the commit-status / review-comment routes to the Gitea wire API.
+
+const EMPTY_PR_LIST = (page: number): ForgePullRequestsResult => ({
+ connected: false,
+ repo: null,
+ prs: [],
+ page,
+ hasMore: false,
+});
+
+const EMPTY_ISSUE_LIST = (page: number): ForgeIssuesResult => ({
+ connected: false,
+ repo: null,
+ issues: [],
+ page,
+ hasMore: false,
+});
+
+const EMPTY_CONTEXT: ForgePullRequestContext = {
+ connected: false,
+ repo: null,
+ pr: null,
+ issueComments: [],
+ reviewComments: [],
+ files: [],
+ checks: null,
+};
+
+const EMPTY_ISSUE_DETAIL: ForgeIssueDetail = {
+ connected: false,
+ repo: null,
+ issue: null,
+ comments: [],
+ commentsError: null,
+};
+
+// Stable, detail-free marker for comment-fetch failures: surfaces the partial
+// result without leaking the underlying error message.
+const COMMENTS_ERROR = 'comments failed to load';
+
+// Stable, detail-free marker for rich-view (commits/timeline/checks) fetch
+// failures; callers distinguish "not attempted" (no error) from "failed".
+const LOAD_ERROR = 'failed to load';
+
+const EMPTY_COMMITS: ForgeCommitsResult = { connected: false, repo: null, commits: [] };
+
+const EMPTY_TIMELINE: ForgeTimelineResult = { connected: false, repo: null, events: [] };
+
+const EMPTY_CHECKS: ForgeChecksResult = { connected: false, repo: null, checks: null };
+
+/**
+ * Split a `"owner/repo"` selector into its parts, as used by the
+ * `sourceRepo` option of the forge interface. Returns null for anything that
+ * does not carry both segments.
+ */
+const parseOwnerRepo = (sourceRepo?: string | null): GitHubRepoSelector | null => {
+ if (!sourceRepo) return null;
+ const [owner, repo] = sourceRepo.split('/');
+ if (!owner || !repo) return null;
+ return { owner, repo };
+};
+
+/**
+ * Split a GitLab `"group/sub/project"` selector into namespace + project: the
+ * last segment is the project, everything before it the (possibly multi-segment)
+ * namespace. Returns an empty object for anything without both parts.
+ */
+const parseGitlabNamespace = (sourceRepo?: string | null): { namespace?: string; project?: string } => {
+ if (!sourceRepo) return {};
+ const segments = sourceRepo.split('/').filter((segment) => segment.length > 0);
+ if (segments.length < 2) return {};
+ const project = segments.pop() as string;
+ return { namespace: segments.join('/'), project };
+};
+
+// Stable, detail-free marker for write failures: surfaces the failure without
+// leaking the underlying error message, mirroring LOAD_ERROR for rich views.
+const WRITE_ERROR = 'failed to load';
+
+/**
+ * Fetch the current PR title so a write that omits it can still satisfy the
+ * provider's title-required update route (GitHub). Returns null when the title
+ * cannot be resolved (missing API or wire failure) so callers degrade.
+ */
+const resolvePrTitle = async (
+ api: Pick,
+ directory: string,
+ number: number,
+): Promise => {
+ if (!api.prContext) return null;
+ try {
+ const context = await api.prContext(directory, number);
+ return context.pr?.title ?? null;
+ } catch {
+ return null;
+ }
+};
+
+// Normalized review events → provider wire events. Explicit maps, because a
+// simple toUpperCase() would mangle 'request-changes' (hyphen) into
+// 'REQUEST-CHANGES' while GitHub/Gitea expect 'REQUEST_CHANGES' (underscore).
+const GITHUB_REVIEW_EVENTS: Record = {
+ approve: 'APPROVE',
+ 'request-changes': 'REQUEST_CHANGES',
+ comment: 'COMMENT',
+};
+
+const GITEA_REVIEW_EVENTS: Record = {
+ approve: 'APPROVED',
+ 'request-changes': 'REQUEST_CHANGES',
+ comment: 'COMMENT',
+};
+
+const WRITE_NOT_SUPPORTED: ForgeUpdateResult = { ok: false, error: 'not supported' };
+
+export const createGithubForgeProvider = (api: GitHubAPI): ForgeProvider => ({
+ kind: 'github',
+ capabilities: GITHUB_CAPABILITIES,
+
+ async getPullRequestForBranch(directory, branch, options) {
+ if (!api.prStatus) return null;
+ try {
+ const status = await api.prStatus(directory, branch, options?.remote);
+ return status.pr ? mapGithubPr(status.pr) : null;
+ } catch {
+ return null;
+ }
+ },
+
+ async listPullRequests(directory, options) {
+ if (!api.prsList) return EMPTY_PR_LIST(options?.page ?? 1);
+ try {
+ const result = await api.prsList(directory, { page: options?.page, query: options?.query });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGithubRepoRef(result.repo) : null,
+ prs: (result.prs ?? []).map(mapGithubPr),
+ page: result.page ?? 1,
+ hasMore: result.hasMore ?? false,
+ };
+ } catch {
+ return EMPTY_PR_LIST(options?.page ?? 1);
+ }
+ },
+
+ async getPullRequestContext(directory, number, options) {
+ if (!api.prContext) return EMPTY_CONTEXT;
+ try {
+ const result = await api.prContext(directory, number, {
+ includeDiff: options?.includeDiff,
+ sourceRepo: parseOwnerRepo(options?.sourceRepo),
+ });
+ return mapGithubContext(result);
+ } catch {
+ return EMPTY_CONTEXT;
+ }
+ },
+
+ async listIssues(directory, options) {
+ if (!api.issuesList) return EMPTY_ISSUE_LIST(options?.page ?? 1);
+ try {
+ const result = await api.issuesList(directory, { page: options?.page, query: options?.query });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGithubRepoRef(result.repo) : null,
+ issues: (result.issues ?? []).map(mapGithubIssue),
+ page: result.page ?? 1,
+ hasMore: result.hasMore ?? false,
+ };
+ } catch {
+ return EMPTY_ISSUE_LIST(options?.page ?? 1);
+ }
+ },
+
+ async getIssue(directory, number, options) {
+ if (!api.issueGet) return EMPTY_ISSUE_DETAIL;
+ try {
+ const result = await api.issueGet(directory, number, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
+ if (!result.connected) {
+ return { connected: false, repo: result.repo ? mapGithubRepoRef(result.repo) : null, issue: null, comments: [], commentsError: null };
+ }
+ let comments: ForgePullRequestContext['issueComments'] = [];
+ let commentsError: string | null = null;
+ if (api.issueComments) {
+ try {
+ const commentsResult = await api.issueComments(directory, number, {
+ sourceRepo: parseOwnerRepo(options?.sourceRepo),
+ });
+ comments = (commentsResult.comments ?? []).map(mapGithubIssueComment);
+ } catch {
+ // The issue itself is authoritative; a comment failure must not hide
+ // it, but it also must not masquerade as an authoritative empty list.
+ comments = [];
+ commentsError = COMMENTS_ERROR;
+ }
+ }
+ return {
+ connected: true,
+ repo: result.repo ? mapGithubRepoRef(result.repo) : null,
+ issue: result.issue ? mapGithubIssue(result.issue) : null,
+ comments,
+ commentsError,
+ };
+ } catch {
+ return EMPTY_ISSUE_DETAIL;
+ }
+ },
+
+ async getCommits(directory, number, options) {
+ if (!api.prCommits) return EMPTY_COMMITS;
+ try {
+ const result = await api.prCommits(directory, number, {
+ sourceRepo: parseOwnerRepo(options?.sourceRepo),
+ });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGithubRepoRef(result.repo) : null,
+ commits: result.commits ? mapGithubCommits(result.commits) : [],
+ };
+ } catch {
+ return { ...EMPTY_COMMITS, error: LOAD_ERROR };
+ }
+ },
+
+ async getTimeline(directory, number, options) {
+ if (!api.prTimeline) return EMPTY_TIMELINE;
+ try {
+ const result = await api.prTimeline(directory, number, {
+ sourceRepo: parseOwnerRepo(options?.sourceRepo),
+ });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGithubRepoRef(result.repo) : null,
+ events: result.events ? mapGithubTimelineEvents(result.events) : [],
+ };
+ } catch {
+ return { ...EMPTY_TIMELINE, error: LOAD_ERROR };
+ }
+ },
+
+ // GitHub check runs ride on getPullRequestContext().checks.
+ async getChecks() {
+ return null;
+ },
+
+ async searchUsers(directory, query, options) {
+ if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
+ try {
+ const result = await api.searchUsers(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGithubRepoRef(result.repo) : null,
+ users: (result.users ?? []).map(mapGithubAssignee),
+ };
+ } catch {
+ return { connected: false, repo: null, users: [], error: LOAD_ERROR };
+ }
+ },
+
+ async searchLabels(directory, query, options) {
+ if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
+ try {
+ const result = await api.searchLabels(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGithubRepoRef(result.repo) : null,
+ labels: (result.labels ?? []).map((label) => ({ name: label.name, color: label.color })),
+ };
+ } catch {
+ return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
+ }
+ },
+
+ async searchMilestones(directory, query, options) {
+ if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
+ try {
+ const result = await api.searchMilestones(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGithubRepoRef(result.repo) : null,
+ milestones: (result.milestones ?? []).map((milestone) => ({
+ title: milestone.title,
+ ...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
+ })),
+ };
+ } catch {
+ return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
+ }
+ },
+
+ async searchBranches(directory, query, options) {
+ if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
+ try {
+ const result = await api.searchBranches(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGithubRepoRef(result.repo) : null,
+ branches: result.branches ?? [],
+ };
+ } catch {
+ return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
+ }
+ },
+
+ async searchTags(directory, query, options) {
+ if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
+ try {
+ const result = await api.searchTags(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGithubRepoRef(result.repo) : null,
+ tags: result.tags ?? [],
+ };
+ } catch {
+ return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
+ }
+ },
+
+ async addComment(directory, ref, input, options) {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const owner = selector?.owner;
+ const repo = selector?.repo;
+ if (ref.kind === 'issue') {
+ if (!api.issueComment) return { ok: false, error: WRITE_ERROR };
+ try {
+ const result = await api.issueComment({ directory, number: ref.number, body: input.body, owner, repo });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, comment: result.comment ? mapGithubIssueComment(result.comment) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ }
+ if (!api.prComment) return { ok: false, error: WRITE_ERROR };
+ try {
+ const result = await api.prComment({ directory, number: ref.number, body: input.body, owner, repo });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, comment: result.comment ? mapGithubIssueComment(result.comment) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async createIssue(directory, input, options) {
+ if (!api.issueCreate) return { ok: false, error: WRITE_ERROR };
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.issueCreate({
+ directory,
+ title: input.title,
+ ...(input.body !== undefined ? { body: input.body } : {}),
+ ...(input.labels !== undefined ? { labels: input.labels } : {}),
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, issue: result.issue ? mapGithubIssue(result.issue) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async replyToThread(directory, ref, input, options) {
+ if (ref.kind !== 'pull') {
+ // Issues have no inline review comments; reply as a flat thread comment.
+ return this.addComment!(directory, ref, { body: input.body }, options);
+ }
+ if (!api.prReviewComment) return { ok: false, error: WRITE_ERROR };
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.prReviewComment({
+ directory,
+ number: ref.number,
+ body: input.body,
+ inReplyToId: input.inReplyToId != null ? Number(input.inReplyToId) : undefined,
+ path: input.path ?? undefined,
+ line: input.line ?? undefined,
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, comment: result.comment ? mapGithubReviewCommentReply(result.comment) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async updateEntity(directory, ref, input, options) {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const owner = selector?.owner;
+ const repo = selector?.repo;
+ if (ref.kind === 'issue') {
+ if (!api.issueUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ const result = await api.issueUpdate({
+ directory,
+ number: ref.number,
+ title: input.title,
+ body: input.body,
+ state: input.state,
+ owner,
+ repo,
+ });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, entity: result.issue ? mapGithubIssue(result.issue) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ }
+ if (!api.prUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ // GitHub's PR update route requires a title and resolves the repo from
+ // the directory (no sourceRepo override), so resolve the current title
+ // when the caller only changes state/metadata.
+ const title = input.title ?? await resolvePrTitle(api, directory, ref.number);
+ if (!title) return { ok: false, error: WRITE_ERROR };
+ const pr = await api.prUpdate({
+ directory,
+ number: ref.number,
+ title,
+ body: input.body,
+ state: input.state,
+ });
+ return { ok: true, entity: mapGithubPr(pr) };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async submitReview(directory, ref, input, options) {
+ if (ref.kind !== 'pull') return WRITE_NOT_SUPPORTED;
+ if (!api.prSubmitReview) return { ok: false, error: WRITE_ERROR };
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.prSubmitReview({
+ directory,
+ number: ref.number,
+ event: GITHUB_REVIEW_EVENTS[input.event],
+ body: input.body,
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, review: result.review ? mapGithubReview(result.review) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async toggleDraft(directory, ref, draft) {
+ if (ref.kind !== 'pull') return WRITE_NOT_SUPPORTED;
+ if (!api.prUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ const title = await resolvePrTitle(api, directory, ref.number);
+ if (!title) return { ok: false, error: WRITE_ERROR };
+ const pr = await api.prUpdate({
+ directory,
+ number: ref.number,
+ title,
+ draft,
+ });
+ return { ok: true, entity: mapGithubPr(pr) };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async updateMetadata(directory, ref, input, options) {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const owner = selector?.owner;
+ const repo = selector?.repo;
+ if (ref.kind === 'issue') {
+ if (!api.issueUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ const result = await api.issueUpdate({
+ directory,
+ number: ref.number,
+ labels: input.labels,
+ assignees: input.assignees,
+ milestone: input.milestone,
+ owner,
+ repo,
+ });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, entity: result.issue ? mapGithubIssue(result.issue) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ }
+ if (!api.prUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ const title = await resolvePrTitle(api, directory, ref.number);
+ if (!title) return { ok: false, error: WRITE_ERROR };
+ const pr = await api.prUpdate({
+ directory,
+ number: ref.number,
+ title,
+ labels: input.labels,
+ assignees: input.assignees,
+ milestone: input.milestone,
+ });
+ return { ok: true, entity: mapGithubPr(pr) };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+});
+
+export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
+ kind: 'gitlab',
+ capabilities: GITLAB_CAPABILITIES,
+
+ async getPullRequestForBranch(directory, branch) {
+ if (!api.mrsList) return null;
+ try {
+ const result = await api.mrsList(directory, { sourceBranch: branch });
+ const mrs = result.mrs ?? [];
+ const mr = mrs.find((item) => item.state === 'opened')
+ ?? mrs.find((item) => item.state === 'merged')
+ ?? null;
+ return mr ? mapGitlabMr(mr) : null;
+ } catch {
+ return null;
+ }
+ },
+
+ async listPullRequests(directory, options) {
+ if (!api.mrsList) return EMPTY_PR_LIST(options?.page ?? 1);
+ try {
+ const result = await api.mrsList(directory, { page: options?.page, query: options?.query });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
+ prs: result.mrs.map(mapGitlabMr),
+ page: result.page,
+ hasMore: result.hasMore,
+ };
+ } catch {
+ return EMPTY_PR_LIST(options?.page ?? 1);
+ }
+ },
+
+ async getPullRequestContext(directory, number, options) {
+ if (!api.mrContext) return EMPTY_CONTEXT;
+ try {
+ const result = await api.mrContext(directory, number, { includeDiff: options?.includeDiff });
+ return mapGitlabContext(result);
+ } catch {
+ return EMPTY_CONTEXT;
+ }
+ },
+
+ async listIssues(directory, options) {
+ if (!api.issuesList) return EMPTY_ISSUE_LIST(options?.page ?? 1);
+ try {
+ const result = await api.issuesList(directory, { page: options?.page, query: options?.query });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
+ issues: result.issues.map(mapGitlabIssue),
+ page: result.page,
+ hasMore: result.hasMore,
+ };
+ } catch {
+ return EMPTY_ISSUE_LIST(options?.page ?? 1);
+ }
+ },
+
+ async getIssue(directory, number, options) {
+ if (!api.issueGet) return EMPTY_ISSUE_DETAIL;
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.issueGet(directory, number, {
+ namespace: selector?.owner,
+ project: selector?.repo,
+ });
+ if (!result.connected) {
+ return { connected: false, repo: result.repo ? mapGitlabRepoRef(result.repo) : null, issue: null, comments: [], commentsError: null };
+ }
+ let comments: ForgePullRequestContext['issueComments'] = [];
+ let commentsError: string | null = null;
+ if (api.issueComments) {
+ try {
+ const commentsResult = await api.issueComments(directory, number, {
+ namespace: selector?.owner,
+ project: selector?.repo,
+ });
+ comments = commentsResult.comments.map(mapGitlabNoteComment);
+ } catch {
+ // The issue itself is authoritative; a comment failure must not hide
+ // it, but it also must not masquerade as an authoritative empty list.
+ comments = [];
+ commentsError = COMMENTS_ERROR;
+ }
+ }
+ return {
+ connected: true,
+ repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
+ issue: result.issue ? mapGitlabIssue(result.issue) : null,
+ comments,
+ commentsError,
+ };
+ } catch {
+ return EMPTY_ISSUE_DETAIL;
+ }
+ },
+
+ async getCommits(directory, number, options) {
+ if (!api.mrCommits) return EMPTY_COMMITS;
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.mrCommits(directory, number, {
+ namespace: selector?.owner,
+ project: selector?.repo,
+ });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
+ commits: result.commits ? mapGitlabCommits(result.commits) : [],
+ };
+ } catch {
+ return { ...EMPTY_COMMITS, error: LOAD_ERROR };
+ }
+ },
+
+ async getTimeline(directory, number, options) {
+ if (!api.mrTimeline) return EMPTY_TIMELINE;
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.mrTimeline(directory, number, {
+ namespace: selector?.owner,
+ project: selector?.repo,
+ });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
+ events: result.events ? mapGitlabTimelineEvents(result.events) : [],
+ };
+ } catch {
+ return { ...EMPTY_TIMELINE, error: LOAD_ERROR };
+ }
+ },
+
+ // GitLab exposes no checks surface.
+ async getChecks() {
+ return null;
+ },
+
+ async searchUsers(directory, query, options) {
+ if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
+ try {
+ const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
+ const result = await api.searchUsers(directory, query, { namespace, project });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
+ users: (result.users ?? []).map(mapGitlabMember),
+ };
+ } catch {
+ return { connected: false, repo: null, users: [], error: LOAD_ERROR };
+ }
+ },
+
+ async searchLabels(directory, query, options) {
+ if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
+ try {
+ const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
+ const result = await api.searchLabels(directory, query, { namespace, project });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
+ labels: (result.labels ?? []).map((name) => ({ name })),
+ };
+ } catch {
+ return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
+ }
+ },
+
+ async searchMilestones(directory, query, options) {
+ if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
+ try {
+ const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
+ const result = await api.searchMilestones(directory, query, { namespace, project });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
+ milestones: (result.milestones ?? []).map((milestone) => ({
+ title: milestone.title,
+ ...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
+ })),
+ };
+ } catch {
+ return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
+ }
+ },
+
+ async searchBranches(directory, query, options) {
+ if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
+ try {
+ const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
+ const result = await api.searchBranches(directory, query, { namespace, project });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
+ branches: result.branches ?? [],
+ };
+ } catch {
+ return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
+ }
+ },
+
+ async searchTags(directory, query, options) {
+ if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
+ try {
+ const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
+ const result = await api.searchTags(directory, query, { namespace, project });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
+ tags: result.tags ?? [],
+ };
+ } catch {
+ return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
+ }
+ },
+
+ async addComment(directory, ref, input, options) {
+ const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
+ if (ref.kind === 'issue') {
+ if (!api.issueComment) return { ok: false, error: WRITE_ERROR };
+ try {
+ const result = await api.issueComment({ directory, number: ref.number, body: input.body, namespace, project });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, comment: result.comment ? mapGitlabNoteComment(result.comment) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ }
+ if (!api.mrComment) return { ok: false, error: WRITE_ERROR };
+ try {
+ const result = await api.mrComment({ directory, number: ref.number, body: input.body, namespace, project });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, comment: result.comment ? mapGitlabNoteComment(result.comment) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async createIssue(directory, input, options) {
+ if (!api.issueCreate) return { ok: false, error: WRITE_ERROR };
+ try {
+ const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
+ const result = await api.issueCreate({
+ directory,
+ title: input.title,
+ ...(input.body !== undefined ? { body: input.body } : {}),
+ ...(input.labels !== undefined ? { labels: input.labels } : {}),
+ namespace,
+ project,
+ });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, issue: result.issue ? mapGitlabIssue(result.issue) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async replyToThread(directory, ref, input, options) {
+ // GitLab's note-reply API is not wired up yet; reply as a flat comment.
+ return this.addComment!(directory, ref, { body: input.body }, options);
+ },
+
+ async updateEntity(directory, ref, input, options) {
+ const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
+ if (ref.kind === 'issue') {
+ if (!api.issueUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ const result = await api.issueUpdate({
+ directory,
+ number: ref.number,
+ title: input.title,
+ body: input.body,
+ state: input.state,
+ namespace,
+ project,
+ });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, entity: result.issue ? mapGitlabIssue(result.issue) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ }
+ if (!api.mrUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ // The MR update route takes `description` (not `body`) and has no
+ // namespace/project override fields.
+ const mr = await api.mrUpdate({
+ directory,
+ number: ref.number,
+ title: input.title,
+ description: input.body,
+ state: input.state,
+ });
+ return { ok: true, entity: mapGitlabMr(mr) };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async submitReview(directory, ref, input, options) {
+ if (ref.kind !== 'pull') return { ok: false, error: 'not supported' };
+ // GitLab exposes approvals only; request-changes/comment have no MR review
+ // events on the wire API.
+ if (input.event !== 'approve') return { ok: false, error: 'not supported' };
+ if (!api.mrApprove) return { ok: false, error: WRITE_ERROR };
+ try {
+ const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
+ const result = await api.mrApprove({ directory, number: ref.number, namespace, project });
+ if (!result.connected || !result.approved) return { ok: false, error: WRITE_ERROR };
+ // GitLab approvals return no review object; synthesize a minimal marker.
+ return { ok: true, review: { id: '', state: 'approved' } };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async toggleDraft(directory, ref, draft, options) {
+ if (ref.kind !== 'pull') return WRITE_NOT_SUPPORTED;
+ if (!api.mrUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ const context = await this.getPullRequestContext(directory, ref.number, options);
+ const title = context.pr?.title;
+ if (!title) return { ok: false, error: WRITE_ERROR };
+ const nextTitle = draft
+ ? (/^Draft:\s*/.test(title) ? title : `Draft: ${title}`)
+ : title.replace(/^Draft:\s*/, '');
+ const mr = await api.mrUpdate({ directory, number: ref.number, title: nextTitle });
+ return { ok: true, entity: mapGitlabMr(mr) };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async updateMetadata(directory, ref, input, options) {
+ const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
+ if (ref.kind === 'issue') {
+ if (!api.issueUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ // GitLab assigns by user ID; the server resolves the facade's login
+ // list to IDs via project members (see gitlab routes resolveAssigneeIds).
+ const result = await api.issueUpdate({
+ directory,
+ number: ref.number,
+ labels: input.labels,
+ assignees: input.assignees,
+ milestone: input.milestone,
+ namespace,
+ project,
+ });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, entity: result.issue ? mapGitlabIssue(result.issue) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ }
+ if (!api.mrUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ const mr = await api.mrUpdate({
+ directory,
+ number: ref.number,
+ labels: input.labels,
+ assignees: input.assignees,
+ milestone: input.milestone,
+ });
+ return { ok: true, entity: mapGitlabMr(mr) };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+});
+
+export const createGiteaForgeProvider = (api: GiteaAPI): ForgeProvider => ({
+ kind: 'gitea',
+ capabilities: GITEA_CAPABILITIES,
+
+ async getPullRequestForBranch(directory, branch) {
+ if (!api.prsList) return null;
+ try {
+ const result = await api.prsList(directory, { sourceBranch: branch });
+ const prs = result.prs ?? [];
+ const pr = prs.find((item) => item.state === 'open')
+ ?? prs.find((item) => item.state === 'merged')
+ ?? null;
+ return pr ? mapGiteaPr(pr) : null;
+ } catch {
+ return null;
+ }
+ },
+
+ async listPullRequests(directory, options) {
+ if (!api.prsList) return EMPTY_PR_LIST(options?.page ?? 1);
+ try {
+ const result = await api.prsList(directory, { page: options?.page, query: options?.query });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
+ prs: result.prs.map(mapGiteaPr),
+ page: result.page,
+ hasMore: result.hasMore,
+ };
+ } catch {
+ return EMPTY_PR_LIST(options?.page ?? 1);
+ }
+ },
+
+ async getPullRequestContext(directory, number, options) {
+ if (!api.prContext) return EMPTY_CONTEXT;
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.prContext(directory, number, {
+ includeDiff: options?.includeDiff,
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ return mapGiteaContext(result);
+ } catch {
+ return EMPTY_CONTEXT;
+ }
+ },
+
+ async listIssues(directory, options) {
+ if (!api.issuesList) return EMPTY_ISSUE_LIST(options?.page ?? 1);
+ try {
+ const result = await api.issuesList(directory, { page: options?.page, query: options?.query });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
+ issues: result.issues.map(mapGiteaIssue),
+ page: result.page,
+ hasMore: result.hasMore,
+ };
+ } catch {
+ return EMPTY_ISSUE_LIST(options?.page ?? 1);
+ }
+ },
+
+ async getIssue(directory, number, options) {
+ if (!api.issueGet) return EMPTY_ISSUE_DETAIL;
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.issueGet(directory, number, {
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ if (!result.connected) {
+ return { connected: false, repo: result.repo ? mapGiteaRepoRef(result.repo) : null, issue: null, comments: [], commentsError: null };
+ }
+ let comments: ForgePullRequestContext['issueComments'] = [];
+ let commentsError: string | null = null;
+ if (api.issueComments) {
+ try {
+ const commentsResult = await api.issueComments(directory, number, {
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ comments = commentsResult.comments.map(mapGiteaComment);
+ } catch {
+ // The issue itself is authoritative; a comment failure must not hide
+ // it, but it also must not masquerade as an authoritative empty list.
+ comments = [];
+ commentsError = COMMENTS_ERROR;
+ }
+ }
+ return {
+ connected: true,
+ repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
+ issue: result.issue ? mapGiteaIssue(result.issue) : null,
+ comments,
+ commentsError,
+ };
+ } catch {
+ return EMPTY_ISSUE_DETAIL;
+ }
+ },
+
+ async getCommits(directory, number, options) {
+ if (!api.prCommits) return EMPTY_COMMITS;
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.prCommits(directory, number, {
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
+ commits: result.commits ? mapGiteaCommits(result.commits) : [],
+ };
+ } catch {
+ return { ...EMPTY_COMMITS, error: LOAD_ERROR };
+ }
+ },
+
+ // Gitea has no timeline endpoint; synthesize one from its reviews.
+ async getTimeline(directory, number, options) {
+ if (!api.prReviews) return EMPTY_TIMELINE;
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.prReviews(directory, number, {
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
+ events: result.reviews ? mapGiteaReviewsToEvents(result.reviews) : [],
+ };
+ } catch {
+ return { ...EMPTY_TIMELINE, error: LOAD_ERROR };
+ }
+ },
+
+ async searchUsers(directory, query, options) {
+ if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.searchUsers(directory, query, { owner: selector?.owner, repo: selector?.repo });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
+ users: (result.users ?? []).map(mapGiteaAssignee),
+ };
+ } catch {
+ return { connected: false, repo: null, users: [], error: LOAD_ERROR };
+ }
+ },
+
+ async searchLabels(directory, query, options) {
+ if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.searchLabels(directory, query, { owner: selector?.owner, repo: selector?.repo });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
+ labels: (result.labels ?? []).map((label) => ({ name: label.name, color: label.color })),
+ };
+ } catch {
+ return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
+ }
+ },
+
+ async searchMilestones(directory, query, options) {
+ if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.searchMilestones(directory, query, { owner: selector?.owner, repo: selector?.repo });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
+ milestones: (result.milestones ?? []).map((milestone) => ({
+ title: milestone.title,
+ ...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
+ })),
+ };
+ } catch {
+ return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
+ }
+ },
+
+ async searchBranches(directory, query, options) {
+ if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.searchBranches(directory, query, { owner: selector?.owner, repo: selector?.repo });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
+ branches: result.branches ?? [],
+ };
+ } catch {
+ return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
+ }
+ },
+
+ async searchTags(directory, query, options) {
+ if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.searchTags(directory, query, { owner: selector?.owner, repo: selector?.repo });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
+ tags: result.tags ?? [],
+ };
+ } catch {
+ return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
+ }
+ },
+
+ async getChecks(directory, number, options) {
+ if (!api.prStatuses) return EMPTY_CHECKS;
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.prStatuses(directory, number, {
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ return {
+ connected: result.connected,
+ repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
+ checks: result.statuses ? mapGiteaStatuses(result.statuses) : null,
+ };
+ } catch {
+ return { ...EMPTY_CHECKS, error: LOAD_ERROR };
+ }
+ },
+
+ async addComment(directory, ref, input, options) {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const owner = selector?.owner;
+ const repo = selector?.repo;
+ if (ref.kind === 'issue') {
+ if (!api.issueComment) return { ok: false, error: WRITE_ERROR };
+ try {
+ const result = await api.issueComment({ directory, number: ref.number, body: input.body, owner, repo });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, comment: result.comment ? mapGiteaComment(result.comment) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ }
+ if (!api.prComment) return { ok: false, error: WRITE_ERROR };
+ try {
+ const result = await api.prComment({ directory, number: ref.number, body: input.body, owner, repo });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, comment: result.comment ? mapGiteaComment(result.comment) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async createIssue(directory, input, options) {
+ if (!api.issueCreate) return { ok: false, error: WRITE_ERROR };
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.issueCreate({
+ directory,
+ title: input.title,
+ ...(input.body !== undefined ? { body: input.body } : {}),
+ ...(input.labels !== undefined ? { labels: input.labels } : {}),
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, issue: result.issue ? mapGiteaIssue(result.issue) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async replyToThread(directory, ref, input, options) {
+ // Gitea's thread-reply API is not wired up yet; reply as a flat comment.
+ return this.addComment!(directory, ref, { body: input.body }, options);
+ },
+
+ async updateEntity(directory, ref, input, options) {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ if (ref.kind === 'issue') {
+ if (!api.issueUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ const result = await api.issueUpdate({
+ directory,
+ number: ref.number,
+ title: input.title,
+ body: input.body,
+ state: input.state,
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, entity: result.issue ? mapGiteaIssue(result.issue) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ }
+ if (!api.prUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ // The Gitea PR update route takes `description` (not `body`) and carries
+ // no owner/repo override fields.
+ const pr = await api.prUpdate({
+ directory,
+ number: ref.number,
+ title: input.title,
+ description: input.body,
+ state: input.state,
+ });
+ return { ok: true, entity: mapGiteaPr(pr) };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ async submitReview(directory, ref, input, options) {
+ if (ref.kind !== 'pull') return { ok: false, error: 'not supported' };
+ if (!api.prSubmitReview) return { ok: false, error: WRITE_ERROR };
+ try {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ const result = await api.prSubmitReview({
+ directory,
+ number: ref.number,
+ event: GITEA_REVIEW_EVENTS[input.event],
+ body: input.body,
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, review: result.review ? mapGiteaReview(result.review) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ },
+
+ // Gitea has no draft concept (`capabilities.draft: false`); toggleDraft is
+ // intentionally left undefined so the UI gates on method presence.
+
+ async updateMetadata(directory, ref, input, options) {
+ const selector = parseOwnerRepo(options?.sourceRepo);
+ if (ref.kind === 'issue') {
+ if (!api.issueUpdate) return { ok: false, error: WRITE_ERROR };
+ try {
+ const result = await api.issueUpdate({
+ directory,
+ number: ref.number,
+ labels: input.labels,
+ assignees: input.assignees,
+ milestone: input.milestone,
+ owner: selector?.owner,
+ repo: selector?.repo,
+ });
+ if (!result.connected) return { ok: false, error: WRITE_ERROR };
+ return { ok: true, entity: result.issue ? mapGiteaIssue(result.issue) : null };
+ } catch {
+ return { ok: false, error: WRITE_ERROR };
+ }
+ }
+ // Gitea's PR update route carries only title/description/state — no
+ // labels/assignees/milestone — so PR metadata writes are unsupported.
+ return WRITE_NOT_SUPPORTED;
+ },
+});
+
+/**
+ * Build the adapter for `kind` from the available runtime APIs, or null when
+ * the provider's API is not present in the runtime.
+ */
+export const buildForgeProvider = (
+ kind: ForgeProviderKind,
+ apis: { github?: GitHubAPI; gitlab?: GitLabAPI; gitea?: GiteaAPI },
+): ForgeProvider | null => {
+ switch (kind) {
+ case 'github':
+ return apis.github ? createGithubForgeProvider(apis.github) : null;
+ case 'gitlab':
+ return apis.gitlab ? createGitlabForgeProvider(apis.gitlab) : null;
+ case 'gitea':
+ return apis.gitea ? createGiteaForgeProvider(apis.gitea) : null;
+ default:
+ return null;
+ }
+};
diff --git a/packages/ui/src/lib/forge/forge.test.ts b/packages/ui/src/lib/forge/forge.test.ts
new file mode 100644
index 00000000..688cb9f7
--- /dev/null
+++ b/packages/ui/src/lib/forge/forge.test.ts
@@ -0,0 +1,1935 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import type {
+ GiteaAPI,
+ GiteaComment,
+ GiteaCommitStatus,
+ GiteaIssue,
+ GiteaPullRequest,
+ GiteaUserSummary,
+ GitHubAPI,
+ GitHubCheckRun,
+ GitHubChecksSummary,
+ GitHubIssue,
+ GitHubIssueComment,
+ GitHubPullRequestSummary,
+ GitHubUserSummary,
+ GitLabAPI,
+ GitLabIssue,
+ GitLabIssueComment,
+ GitLabMergeRequest,
+ GitLabUserSummary,
+} from '@/lib/api/types';
+import { buildForgeProvider, createGiteaForgeProvider, createGithubForgeProvider, createGitlabForgeProvider } from '@/lib/forge/adapters';
+import {
+ aggregateStatusState,
+ firstLine,
+ mapCheckRunState,
+ mapGiteaAssignee,
+ mapGiteaCommits,
+ mapGiteaComment,
+ mapGiteaContext,
+ mapGiteaIssue,
+ mapGiteaPr,
+ mapGiteaReviewsToEvents,
+ mapGiteaReview,
+ mapGiteaStatuses,
+ mapGithubAssignee,
+ mapGithubCheckSummary,
+ mapGithubCommits,
+ mapGithubContext,
+ mapGithubIssue,
+ mapGithubIssueComment,
+ mapGithubPr,
+ mapGithubReview,
+ mapGithubReviewComment,
+ mapGithubTimelineEvents,
+ mapGitlabCommits,
+ mapGitlabContext,
+ mapGitlabIssue,
+ mapGitlabMember,
+ mapGitlabMr,
+ mapGitlabNoteComment,
+ mapGitlabTimelineEvents,
+ mapReviewState,
+ mapStatusState,
+ normalizeEventType,
+ stateOf,
+} from '@/lib/forge/normalize';
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const githubUser = (): GitHubUserSummary => ({
+ login: 'octocat',
+ id: 1,
+ name: 'Octo Cat',
+ avatarUrl: 'https://avatars.example/octocat',
+});
+
+const githubPr: GitHubPullRequestSummary = {
+ number: 42,
+ title: 'Add forge facade',
+ body: 'A body',
+ url: 'https://github.com/acme/widget/pull/42',
+ state: 'open',
+ draft: true,
+ base: 'main',
+ head: 'feat/forge',
+ headSha: 'abc123',
+ mergeable: true,
+ mergeableState: 'clean',
+ author: githubUser(),
+ createdAt: '2026-01-02T03:04:05Z',
+ updatedAt: '2026-01-03T04:05:06Z',
+};
+
+const githubIssue: GitHubIssue = {
+ number: 7,
+ title: 'Bug in forge',
+ body: 'Details',
+ url: 'https://github.com/acme/widget/issues/7',
+ state: 'closed',
+ author: githubUser(),
+ labels: [{ name: 'bug', color: 'd73a4a' }],
+ assignees: [githubUser()],
+ createdAt: '2026-01-02T03:04:05Z',
+ updatedAt: '2026-01-04T05:06:07Z',
+};
+
+const githubIssueComment: GitHubIssueComment = {
+ id: 1001,
+ body: 'First!',
+ url: 'https://github.com/acme/widget/issues/7#issuecomment-1001',
+ author: githubUser(),
+ createdAt: '2026-01-02T04:00:00Z',
+};
+
+const gitlabUser = (): GitLabUserSummary => ({
+ username: 'gluser',
+ id: 5,
+ name: 'GL User',
+ avatarUrl: 'https://avatars.example/gluser',
+ webUrl: 'https://gitlab.example/gluser',
+});
+
+const gitlabMr: GitLabMergeRequest = {
+ number: 99,
+ title: 'Draft: Add MR support',
+ body: 'MR body',
+ url: 'https://gitlab.example/acme/widget/-/merge_requests/99',
+ state: 'opened',
+ draft: false,
+ author: gitlabUser(),
+ sourceBranch: 'feat/mr',
+ targetBranch: 'main',
+ createdAt: '2026-02-01T00:00:00Z',
+ updatedAt: '2026-02-02T00:00:00Z',
+ headSha: 'def456',
+};
+
+const gitlabIssue: GitLabIssue = {
+ number: 8,
+ title: 'GL issue',
+ body: 'GL body',
+ url: 'https://gitlab.example/acme/widget/-/issues/8',
+ state: 'opened',
+ author: gitlabUser(),
+ assignees: [gitlabUser()],
+ labels: ['frontend', 'bug'],
+ createdAt: '2026-02-01T00:00:00Z',
+ updatedAt: '2026-02-03T00:00:00Z',
+};
+
+const gitlabNote: GitLabIssueComment = {
+ id: 3003,
+ body: 'a note',
+ url: 'https://gitlab.example/acme/widget/-/issues/8#note_3003',
+ author: gitlabUser(),
+ createdAt: '2026-02-01T01:00:00Z',
+};
+
+const giteaUser = (): GiteaUserSummary => ({
+ username: 'guser',
+ id: 3,
+ name: 'G User',
+ avatarUrl: 'https://avatars.example/guser',
+ webUrl: 'https://gitea.example/guser',
+});
+
+const giteaPr: GiteaPullRequest = {
+ number: 11,
+ title: 'Add gitea PR',
+ body: 'gitea body',
+ url: 'https://gitea.example/acme/widget/pulls/11',
+ state: 'merged',
+ author: giteaUser(),
+ labels: ['backend'],
+ sourceBranch: 'feat/gitea',
+ targetBranch: 'main',
+ mergeable: true,
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-02T00:00:00Z',
+};
+
+const giteaIssue: GiteaIssue = {
+ number: 12,
+ title: 'gitea issue',
+ body: 'issue body',
+ url: 'https://gitea.example/acme/widget/issues/12',
+ state: 'open',
+ author: giteaUser(),
+ labels: ['bug'],
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-02T00:00:00Z',
+};
+
+const giteaComment: GiteaComment = {
+ id: 4004,
+ body: 'gitea note',
+ url: 'https://gitea.example/acme/widget/issues/12#issuecomment-4004',
+ author: giteaUser(),
+ createdAt: '2026-03-01T01:00:00Z',
+};
+
+const checks: GitHubChecksSummary = { state: 'failure', total: 3, success: 1, failure: 1, pending: 1 };
+
+const checkRuns: GitHubCheckRun[] = [
+ {
+ name: 'build',
+ status: 'completed',
+ conclusion: 'success',
+ startedAt: '2026-01-01T00:00:00Z',
+ completedAt: '2026-01-01T00:01:00Z',
+ detailsUrl: 'https://github.com/acme/widget/actions/runs/1',
+ output: { title: 'Build', summary: 'all green' },
+ },
+ { name: 'lint', status: 'in_progress', startedAt: '2026-01-01T00:00:00Z' },
+ { name: 'test', status: 'completed', conclusion: 'cancelled' },
+ { name: 'doc', status: 'completed', conclusion: 'skipped' },
+ { name: 'perf', status: 'completed', conclusion: 'timed_out' },
+ {
+ name: 'annotated',
+ status: 'completed',
+ conclusion: 'failure',
+ annotations: [{ path: 'src/a.ts', startLine: 3, endLine: 3, level: 'error', message: 'boom', title: 'TS error' }],
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Normalization
+// ---------------------------------------------------------------------------
+
+describe('stateOf', () => {
+ test('maps provider state strings onto the normalized lifecycle state', () => {
+ expect(stateOf('open')).toBe('open');
+ expect(stateOf('opened')).toBe('open');
+ expect(stateOf('closed')).toBe('closed');
+ expect(stateOf('merged')).toBe('merged');
+ expect(stateOf(undefined)).toBe('closed');
+ expect(stateOf(null)).toBe('closed');
+ expect(stateOf('unexpected')).toBe('closed');
+ });
+});
+
+describe('github normalization', () => {
+ test('maps a GitHub PR', () => {
+ const pr = mapGithubPr(githubPr);
+ expect(pr.number).toBe(42);
+ expect(pr.state).toBe('open');
+ expect(pr.draft).toBe(true);
+ expect(pr.base.ref).toBe('main');
+ expect(pr.head.ref).toBe('feat/forge');
+ expect(pr.head.repo).toBeNull();
+ expect(pr.headSha).toBe('abc123');
+ expect(pr.mergeable).toBe(true);
+ expect(pr.mergeableState).toBe('clean');
+ expect(pr.labels).toEqual([]);
+ expect(pr.assignees).toEqual([]);
+ expect(pr.author?.id).toBe('octocat');
+ expect(pr.author?.login).toBe('octocat');
+ expect(pr.url).toBe('https://github.com/acme/widget/pull/42');
+ });
+
+ test('maps enriched GitHub PR metadata (labels/assignees/milestone/comments)', () => {
+ const pr = mapGithubPr({
+ ...githubPr,
+ labels: [{ name: 'bug', color: 'd73a4a' }],
+ assignees: [githubUser()],
+ milestone: { title: 'v2.0' },
+ commentsCount: 3,
+ });
+ expect(pr.labels).toEqual([{ name: 'bug', color: 'd73a4a' }]);
+ expect(pr.assignees).toHaveLength(1);
+ expect(pr.assignees?.[0]?.id).toBe('octocat');
+ expect(pr.milestone?.title).toBe('v2.0');
+ expect(pr.commentsCount).toBe(3);
+ });
+
+ test('maps a GitHub issue', () => {
+ const issue = mapGithubIssue(githubIssue);
+ expect(issue.number).toBe(7);
+ expect(issue.state).toBe('closed');
+ expect(issue.body).toBe('Details');
+ expect(issue.labels).toEqual([{ name: 'bug', color: 'd73a4a' }]);
+ expect(issue.assignees).toHaveLength(1);
+ expect(issue.assignees?.[0]?.id).toBe('octocat');
+ expect(issue.milestone).toBeNull();
+ expect(issue.url).toBe('https://github.com/acme/widget/issues/7');
+ });
+
+ test('maps GitHub issue and review comments', () => {
+ const comment = mapGithubIssueComment(githubIssueComment);
+ expect(comment.id).toBe('1001');
+ expect(comment.body).toBe('First!');
+ expect(comment.author?.id).toBe('octocat');
+ expect(comment.inReplyToId).toBeNull();
+ expect(comment.path).toBeNull();
+ expect(comment.line).toBeNull();
+
+ const reviewComment = {
+ id: 2002,
+ body: 'Lint this',
+ url: 'https://github.com/acme/widget/pull/42#discussion_r2002',
+ author: githubUser(),
+ path: 'src/forge.ts',
+ position: 12,
+ createdAt: '2026-01-03T05:00:00Z',
+ }; // Shape of GitHubPullRequestReviewComment, which api/types keeps local.
+ const mapped = mapGithubReviewComment(reviewComment);
+ expect(mapped.id).toBe('2002');
+ expect(mapped.path).toBe('src/forge.ts');
+ expect(mapped.line).toBe(12);
+ expect(mapped.inReplyToId).toBeNull();
+ expect(mapped.commitSha).toBeNull();
+ });
+
+ test('maps a GitHub PR context', () => {
+ const context = mapGithubContext({
+ connected: true,
+ repo: { owner: 'acme', repo: 'widget', url: 'https://github.com/acme/widget' },
+ pr: githubPr,
+ issueComments: [githubIssueComment],
+ reviewComments: [],
+ files: [{ filename: 'src/forge.ts', status: 'modified', additions: 2, deletions: 1, patch: '@@' }],
+ diff: '--- a/src/forge.ts',
+ checks,
+ checkRuns,
+ });
+ expect(context.connected).toBe(true);
+ expect(context.repo?.owner).toBe('acme');
+ expect(context.repo?.provider).toBe('github');
+ expect(context.pr?.number).toBe(42);
+ expect(context.issueComments).toHaveLength(1);
+ expect(context.reviewComments).toEqual([]);
+ expect(context.files[0]).toEqual({
+ filename: 'src/forge.ts',
+ status: 'modified',
+ additions: 2,
+ deletions: 1,
+ patch: '@@',
+ });
+ expect(context.diff).toContain('forge.ts');
+ expect(context.checks?.state).toBe('failure');
+ });
+});
+
+describe('github checks', () => {
+ test('maps the check summary and check runs', () => {
+ const summary = mapGithubCheckSummary(checks, checkRuns);
+ expect(summary.state).toBe('failure');
+ expect(summary.total).toBe(3);
+ expect(summary.success).toBe(1);
+ expect(summary.checks).toHaveLength(6);
+
+ const byName = Object.fromEntries(summary.checks.map((c) => [c.name, c.state]));
+ expect(byName['build']).toBe('success');
+ expect(byName['lint']).toBe('pending');
+ expect(byName['test']).toBe('cancelled');
+ expect(byName['doc']).toBe('skipped');
+ expect(byName['perf']).toBe('failure');
+
+ const build = summary.checks.find((c) => c.name === 'build');
+ expect(build?.kind).toBe('check-run');
+ expect(build?.url).toBe('https://github.com/acme/widget/actions/runs/1');
+ expect(build?.details?.title).toBe('Build');
+ expect(build?.details?.summary).toBe('all green');
+
+ const annotated = summary.checks.find((c) => c.name === 'annotated');
+ expect(annotated?.details?.annotations?.[0]).toEqual({
+ path: 'src/a.ts',
+ startLine: 3,
+ endLine: 3,
+ level: 'error',
+ message: 'boom',
+ title: 'TS error',
+ });
+ });
+
+ test('omits checks when the context carries none', () => {
+ const context = mapGithubContext({ connected: true, pr: githubPr });
+ expect(context.checks).toBeNull();
+ });
+
+ test('maps check run states from status/conclusion pairs', () => {
+ expect(mapCheckRunState('queued')).toBe('pending');
+ expect(mapCheckRunState('in_progress')).toBe('pending');
+ expect(mapCheckRunState('completed')).toBe('unknown');
+ expect(mapCheckRunState('completed', 'success')).toBe('success');
+ expect(mapCheckRunState('completed', 'neutral')).toBe('success');
+ expect(mapCheckRunState('completed', 'failure')).toBe('failure');
+ expect(mapCheckRunState('completed', 'timed_out')).toBe('failure');
+ expect(mapCheckRunState('completed', 'cancelled')).toBe('cancelled');
+ expect(mapCheckRunState('completed', 'skipped')).toBe('skipped');
+ expect(mapCheckRunState('completed', 'stale')).toBe('skipped');
+ expect(mapCheckRunState('completed', 'action_required')).toBe('pending');
+ expect(mapCheckRunState('completed', 'made-up')).toBe('unknown');
+ });
+});
+
+describe('gitlab normalization', () => {
+ test('maps a GitLab MR, mapping opened state and draft-by-title-prefix', () => {
+ const mr = mapGitlabMr(gitlabMr);
+ expect(mr.number).toBe(99);
+ expect(mr.state).toBe('open');
+ expect(mr.draft).toBe(true);
+ expect(mr.base.ref).toBe('main');
+ expect(mr.head.ref).toBe('feat/mr');
+ expect(mr.headSha).toBe('def456');
+ expect(mr.labels).toEqual([]);
+ expect(mr.assignees).toEqual([]);
+ expect(mr.author?.id).toBe('5');
+ expect(mr.author?.url).toBe('https://gitlab.example/gluser');
+ expect(mr.url).toBe('https://gitlab.example/acme/widget/-/merge_requests/99');
+ });
+
+ test('GitLab MR draft detection follows the draft flag when the title has no prefix', () => {
+ expect(mapGitlabMr({ ...gitlabMr, title: 'Add MR support' }).draft).toBe(false);
+ expect(mapGitlabMr({ ...gitlabMr, title: 'Add MR support', draft: true }).draft).toBe(true);
+ });
+
+ test('maps a GitLab issue', () => {
+ const issue = mapGitlabIssue(gitlabIssue);
+ expect(issue.number).toBe(8);
+ expect(issue.state).toBe('open');
+ expect(issue.body).toBe('GL body');
+ expect(issue.labels).toEqual([{ name: 'frontend' }, { name: 'bug' }]);
+ expect(issue.assignees).toHaveLength(1);
+ expect(issue.assignees?.[0]?.id).toBe('5');
+ expect(issue.milestone).toBeNull();
+ });
+
+ test('maps a GitLab note comment', () => {
+ const comment = mapGitlabNoteComment(gitlabNote);
+ expect(comment.id).toBe('3003');
+ expect(comment.body).toBe('a note');
+ expect(comment.author?.login).toBe('gluser');
+ });
+
+ test('maps a GitLab MR context', () => {
+ const context = mapGitlabContext({
+ connected: true,
+ repo: {
+ namespace: 'acme',
+ project: 'widget',
+ host: 'gitlab.example',
+ url: 'https://gitlab.example/acme/widget',
+ baseUrl: 'https://gitlab.example',
+ },
+ mr: gitlabMr,
+ comments: [gitlabNote],
+ files: [{ filename: 'src/gl.ts', status: 'added', additions: 1, deletions: 0 }],
+ diff: '--- a/src/gl.ts',
+ });
+ expect(context.connected).toBe(true);
+ expect(context.repo?.owner).toBe('acme');
+ expect(context.repo?.repo).toBe('widget');
+ expect(context.pr?.number).toBe(99);
+ expect(context.issueComments).toHaveLength(1);
+ expect(context.reviewComments).toEqual([]);
+ expect(context.files[0]?.filename).toBe('src/gl.ts');
+ expect(context.checks).toBeNull();
+ });
+});
+
+describe('gitea normalization', () => {
+ test('maps a Gitea PR', () => {
+ const pr = mapGiteaPr(giteaPr);
+ expect(pr.number).toBe(11);
+ expect(pr.state).toBe('merged');
+ expect(pr.draft).toBe(false);
+ expect(pr.base.ref).toBe('main');
+ expect(pr.head.ref).toBe('feat/gitea');
+ expect(pr.labels).toEqual([{ name: 'backend' }]);
+ expect(pr.assignees).toEqual([]);
+ expect(pr.mergeable).toBe(true);
+ expect(pr.author?.id).toBe('3');
+ expect(pr.url).toBe('https://gitea.example/acme/widget/pulls/11');
+ });
+
+ test('maps a Gitea issue', () => {
+ const issue = mapGiteaIssue(giteaIssue);
+ expect(issue.number).toBe(12);
+ expect(issue.state).toBe('open');
+ expect(issue.labels).toEqual([{ name: 'bug' }]);
+ expect(issue.assignees).toEqual([]);
+ expect(issue.body).toBe('issue body');
+ });
+
+ test('maps a Gitea comment', () => {
+ const comment = mapGiteaComment(giteaComment);
+ expect(comment.id).toBe('4004');
+ expect(comment.body).toBe('gitea note');
+ expect(comment.author?.login).toBe('guser');
+ expect(comment.url).toBe('https://gitea.example/acme/widget/issues/12#issuecomment-4004');
+ });
+
+ test('maps a Gitea PR context', () => {
+ const context = mapGiteaContext({
+ connected: true,
+ repo: { owner: 'acme', repo: 'widget', url: 'https://gitea.example/acme/widget' },
+ pr: giteaPr,
+ comments: [giteaComment],
+ files: [{ filename: 'src/gitea.ts', status: 'modified', additions: 3, deletions: 1, patch: '@@' }],
+ diff: '--- a/src/gitea.ts',
+ });
+ expect(context.connected).toBe(true);
+ expect(context.repo?.provider).toBe('gitea');
+ expect(context.pr?.number).toBe(11);
+ expect(context.issueComments).toHaveLength(1);
+ expect(context.reviewComments).toEqual([]);
+ expect(context.files[0]?.additions).toBe(3);
+ expect(context.checks).toBeNull();
+ });
+});
+
+describe('repo-scoped lookup normalization', () => {
+ test('maps a GitHub repo assignee', () => {
+ expect(mapGithubAssignee(githubUser())).toEqual({
+ id: 'octocat',
+ login: 'octocat',
+ name: 'Octo Cat',
+ avatarUrl: 'https://avatars.example/octocat',
+ });
+ });
+
+ test('maps a GitLab project member', () => {
+ expect(mapGitlabMember(gitlabUser())).toEqual({
+ id: '5',
+ login: 'gluser',
+ name: 'GL User',
+ avatarUrl: 'https://avatars.example/gluser',
+ url: 'https://gitlab.example/gluser',
+ });
+ });
+
+ test('maps a Gitea repo assignee', () => {
+ expect(mapGiteaAssignee(giteaUser())).toEqual({
+ id: '3',
+ login: 'guser',
+ name: 'G User',
+ avatarUrl: 'https://avatars.example/guser',
+ url: 'https://gitea.example/guser',
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Rich-view normalization (commits / timeline / checks)
+// ---------------------------------------------------------------------------
+
+describe('shared rich-view helpers', () => {
+ test('firstLine extracts the first message line', () => {
+ expect(firstLine('one line')).toBe('one line');
+ expect(firstLine('first\nsecond')).toBe('first');
+ expect(firstLine('')).toBe('');
+ });
+
+ test('normalizeEventType maps provider types onto the vocabulary', () => {
+ expect(normalizeEventType('opened')).toBe('opened');
+ expect(normalizeEventType('merged')).toBe('merged');
+ expect(normalizeEventType('labeled')).toBe('labeled');
+ expect(normalizeEventType('cross-referenced')).toBe('referenced');
+ expect(normalizeEventType('mystery-event')).toBe('other');
+ });
+
+ test('mapStatusState collapses error/warning onto failure/pending', () => {
+ expect(mapStatusState('success')).toBe('success');
+ expect(mapStatusState('failure')).toBe('failure');
+ expect(mapStatusState('error')).toBe('failure');
+ expect(mapStatusState('pending')).toBe('pending');
+ expect(mapStatusState('warning')).toBe('pending');
+ expect(mapStatusState('unknown')).toBe('unknown');
+ expect(mapStatusState('something-else')).toBe('unknown');
+ });
+
+ test('aggregateStatusState is failure > pending > success', () => {
+ expect(aggregateStatusState([])).toBe('success');
+ expect(aggregateStatusState([{ state: 'success', name: 'a' }])).toBe('success');
+ expect(aggregateStatusState([{ state: 'success', name: 'a' }, { state: 'pending', name: 'b' }])).toBe('pending');
+ expect(aggregateStatusState([{ state: 'pending', name: 'a' }, { state: 'error', name: 'b' }])).toBe('failure');
+ expect(aggregateStatusState([{ state: 'failure', name: 'a' }])).toBe('failure');
+ });
+});
+
+describe('commit normalization', () => {
+ test('maps GitHub commits using shortSha and first-line summaries', () => {
+ const commits = mapGithubCommits([{
+ sha: 'abc123',
+ shortSha: 'abc1234',
+ message: 'Summary line\n\nFull body',
+ author: githubUser(),
+ committedAt: '2026-01-02T03:04:05Z',
+ parents: ['parent-1'],
+ }]);
+ expect(commits[0]).toEqual({
+ sha: 'abc123',
+ shortSha: 'abc1234',
+ message: 'Summary line\n\nFull body',
+ summary: 'Summary line',
+ author: { id: 'octocat', login: 'octocat', name: 'Octo Cat', avatarUrl: 'https://avatars.example/octocat' },
+ committedAt: '2026-01-02T03:04:05Z',
+ parents: ['parent-1'],
+ });
+ });
+
+ test('GitHub commits fall back to the first message line when summary is absent', () => {
+ const [commit] = mapGithubCommits([{
+ sha: 'abc123',
+ shortSha: 'abc1234',
+ message: 'Title line\n\nBody',
+ parents: [],
+ }]);
+ expect(commit.summary).toBe('Title line');
+ expect(commit.author).toBeFalsy();
+ expect(commit.parents).toEqual([]);
+ });
+
+ test('maps GitLab commits with an author synthesized from authorName', () => {
+ const commits = mapGitlabCommits([{
+ sha: 'def456',
+ shortSha: 'def4567',
+ message: 'MR commit',
+ authorName: 'GL User',
+ committedAt: '2026-02-01T00:00:00Z',
+ parents: [],
+ }]);
+ expect(commits[0].shortSha).toBe('def4567');
+ expect(commits[0].author).toEqual({ id: 'GL User', login: 'GL User', name: 'GL User' });
+ });
+
+ test('maps Gitea commits, deriving shortSha from the full sha', () => {
+ const commits = mapGiteaCommits([{
+ sha: '0123456789abcdef0123456789abcdef01234567',
+ message: 'gitea commit',
+ author: giteaUser(),
+ committedAt: '2026-03-01T00:00:00Z',
+ parents: [],
+ }]);
+ expect(commits[0].shortSha).toBe('0123456');
+ expect(commits[0].author?.login).toBe('guser');
+ });
+});
+
+describe('timeline normalization', () => {
+ test('maps GitHub timeline events with source provenance', () => {
+ const events = mapGithubTimelineEvents([
+ { id: '1', type: 'opened', author: githubUser(), createdAt: '2026-01-02T03:04:05Z' },
+ { id: '2', type: 'cross-referenced' },
+ { id: '3', type: 'mystery-type', body: 'x' },
+ ]);
+ expect(events[0].type).toBe('opened');
+ expect(events[0].id).toBe('1');
+ expect(events[0].author?.login).toBe('octocat');
+ expect(events[0].source).toBe('github-timeline');
+ expect(events[1].type).toBe('referenced');
+ expect(events[2].type).toBe('other');
+ });
+
+ test('maps GitLab timeline events as system notes', () => {
+ const events = mapGitlabTimelineEvents([
+ { id: '9', type: 'approved', author: gitlabUser(), createdAt: '2026-02-01T00:00:00Z' },
+ ]);
+ expect(events[0].type).toBe('approved');
+ expect(events[0].author?.login).toBe('gluser');
+ expect(events[0].source).toBe('gitlab-system-note');
+ });
+
+ test('synthesizes Gitea timeline events from reviews, skipping PENDING', () => {
+ const events = mapGiteaReviewsToEvents([
+ { id: '1', state: 'APPROVED', author: giteaUser(), submittedAt: '2026-03-01T00:00:00Z', body: 'LGTM', commitSha: 'abc123' },
+ { id: '2', state: 'REQUEST_CHANGES', author: giteaUser() },
+ { id: '3', state: 'COMMENT' },
+ { id: '4', state: 'PENDING' },
+ { id: '5', state: 'DISMISSED' },
+ ]);
+ expect(events).toHaveLength(4);
+ expect(events[0]).toEqual({
+ id: '1',
+ type: 'approved',
+ author: { id: '3', login: 'guser', name: 'G User', avatarUrl: 'https://avatars.example/guser', url: 'https://gitea.example/guser' },
+ createdAt: '2026-03-01T00:00:00Z',
+ body: 'LGTM',
+ commitSha: 'abc123',
+ source: 'gitea-review',
+ });
+ expect(events[1].type).toBe('requested-changes');
+ expect(events[2].type).toBe('commented');
+ expect(events[3].type).toBe('other');
+ });
+});
+
+describe('gitea commit-status normalization', () => {
+ const statuses: GiteaCommitStatus[] = [
+ { state: 'success', name: 'ci' },
+ { state: 'failure', name: 'lint' },
+ { state: 'pending', name: 'test' },
+ { state: 'error', name: 'build' },
+ { state: 'warning', name: 'docs' },
+ { state: 'unknown', name: 'mystery' },
+ ];
+
+ test('maps statuses onto a checks summary with aggregated state', () => {
+ const summary = mapGiteaStatuses(statuses);
+ expect(summary.state).toBe('failure');
+ expect(summary.total).toBe(6);
+ expect(summary.success).toBe(1);
+ expect(summary.failure).toBe(2);
+ expect(summary.pending).toBe(2);
+ expect(summary.checks[0]).toEqual({
+ kind: 'commit-status',
+ name: 'ci',
+ state: 'success',
+ url: undefined,
+ description: undefined,
+ startedAt: undefined,
+ completedAt: undefined,
+ });
+ expect(summary.checks[1].state).toBe('failure');
+ expect(summary.checks[3].state).toBe('failure');
+ expect(summary.checks[4].state).toBe('pending');
+ expect(summary.checks[5].state).toBe('unknown');
+ });
+
+ test('carries url and description onto the normalized checks', () => {
+ const [check] = mapGiteaStatuses([
+ { state: 'pending', name: 'deploy', url: 'https://gitea.example/status/1', description: 'Deploying…', createdAt: '2026-03-01T00:00:00Z' },
+ ]).checks;
+ expect(check.url).toBe('https://gitea.example/status/1');
+ expect(check.description).toBe('Deploying…');
+ expect(check.startedAt).toBe('2026-03-01T00:00:00Z');
+ expect(check.completedAt).toBe('2026-03-01T00:00:00Z');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Review normalization
+// ---------------------------------------------------------------------------
+
+describe('review normalization', () => {
+ test('mapReviewState maps provider states onto the normalized vocabulary', () => {
+ expect(mapReviewState('APPROVED')).toBe('approved');
+ expect(mapReviewState('approved')).toBe('approved');
+ expect(mapReviewState('CHANGES_REQUESTED')).toBe('requested-changes');
+ expect(mapReviewState('REQUEST_CHANGES')).toBe('requested-changes');
+ expect(mapReviewState('request_changes')).toBe('requested-changes');
+ expect(mapReviewState('COMMENTED')).toBe('commented');
+ expect(mapReviewState('COMMENT')).toBe('commented');
+ expect(mapReviewState('DISMISSED')).toBe('dismissed');
+ expect(mapReviewState('mystery')).toBe('pending');
+ });
+
+ test('maps a GitHub review', () => {
+ const review = mapGithubReview({
+ id: 'r1',
+ state: 'APPROVED',
+ author: githubUser(),
+ submittedAt: '2026-01-01T00:00:00Z',
+ body: 'LGTM',
+ commitSha: 'abc123',
+ });
+ expect(review).toEqual({
+ id: 'r1',
+ state: 'approved',
+ author: { id: 'octocat', login: 'octocat', name: 'Octo Cat', avatarUrl: 'https://avatars.example/octocat' },
+ submittedAt: '2026-01-01T00:00:00Z',
+ body: 'LGTM',
+ commitSha: 'abc123',
+ });
+ });
+
+ test('maps a Gitea review, collapsing REQUEST_CHANGES', () => {
+ const review = mapGiteaReview({ id: 'r2', state: 'REQUEST_CHANGES', author: giteaUser(), body: 'fix it' });
+ expect(review.state).toBe('requested-changes');
+ expect(review.author?.login).toBe('guser');
+ expect(review.submittedAt).toBe(undefined);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Write operations
+// ---------------------------------------------------------------------------
+
+describe('write operations: addComment', () => {
+ test('github routes issue/pull and parses sourceRepo', async () => {
+ let issueArgs: Record = {};
+ let prArgs: Record = {};
+ const api = {
+ issueComment: async (input: Record) => {
+ issueArgs = input;
+ return { connected: true, comment: githubIssueComment };
+ },
+ prComment: async (input: Record) => {
+ prArgs = input;
+ return { connected: true, comment: githubIssueComment };
+ },
+ } as unknown as GitHubAPI;
+ const provider = createGithubForgeProvider(api);
+
+ const issueResult = await provider.addComment!(
+ '/repo', { kind: 'issue', number: 7 }, { body: 'hi' }, { sourceRepo: 'upstream/widget' },
+ );
+ expect(issueResult.ok).toBe(true);
+ expect(issueResult.comment?.id).toBe('1001');
+ expect(issueResult.comment?.body).toBe('First!');
+ expect(issueArgs).toEqual({ directory: '/repo', number: 7, body: 'hi', owner: 'upstream', repo: 'widget' });
+
+ const prResult = await provider.addComment!('/repo', { kind: 'pull', number: 42 }, { body: 'yo' });
+ expect(prResult.ok).toBe(true);
+ expect(prArgs).toEqual({ directory: '/repo', number: 42, body: 'yo', owner: undefined, repo: undefined });
+ });
+
+ test('gitlab routes issue/pull and parses multi-segment namespaces', async () => {
+ let issueArgs: Record = {};
+ let mrArgs: Record = {};
+ const api = {
+ issueComment: async (input: Record) => {
+ issueArgs = input;
+ return { connected: true, comment: gitlabNote };
+ },
+ mrComment: async (input: Record) => {
+ mrArgs = input;
+ return { connected: true, comment: gitlabNote };
+ },
+ } as unknown as GitLabAPI;
+ const provider = createGitlabForgeProvider(api);
+
+ const issueResult = await provider.addComment!(
+ '/repo', { kind: 'issue', number: 8 }, { body: 'hi' }, { sourceRepo: 'group/sub/proj' },
+ );
+ expect(issueResult.ok).toBe(true);
+ expect(issueResult.comment?.author?.login).toBe('gluser');
+ expect(issueArgs).toEqual({ directory: '/repo', number: 8, body: 'hi', namespace: 'group/sub', project: 'proj' });
+
+ const mrResult = await provider.addComment!('/repo', { kind: 'pull', number: 99 }, { body: 'yo' });
+ expect(mrResult.ok).toBe(true);
+ expect(mrArgs).toEqual({ directory: '/repo', number: 99, body: 'yo', namespace: undefined, project: undefined });
+ });
+
+ test('gitea routes issue/pull and parses sourceRepo', async () => {
+ let issueArgs: Record = {};
+ let prArgs: Record = {};
+ const api = {
+ issueComment: async (input: Record) => {
+ issueArgs = input;
+ return { connected: true, comment: giteaComment };
+ },
+ prComment: async (input: Record) => {
+ prArgs = input;
+ return { connected: true, comment: giteaComment };
+ },
+ } as unknown as GiteaAPI;
+ const provider = createGiteaForgeProvider(api);
+
+ const issueResult = await provider.addComment!(
+ '/repo', { kind: 'issue', number: 12 }, { body: 'hi' }, { sourceRepo: 'acme/widget' },
+ );
+ expect(issueResult.ok).toBe(true);
+ expect(issueResult.comment?.id).toBe('4004');
+ expect(issueArgs).toEqual({ directory: '/repo', number: 12, body: 'hi', owner: 'acme', repo: 'widget' });
+
+ const prResult = await provider.addComment!('/repo', { kind: 'pull', number: 11 }, { body: 'yo' });
+ expect(prResult.ok).toBe(true);
+ expect(prArgs).toEqual({ directory: '/repo', number: 11, body: 'yo', owner: undefined, repo: undefined });
+ });
+});
+
+describe('write operations: replyToThread', () => {
+ test('github posts a review-comment reply on pulls with the numeric inReplyToId', async () => {
+ let reviewArgs: Record = {};
+ const api = {
+ prReviewComment: async (input: Record) => {
+ reviewArgs = input;
+ return {
+ connected: true,
+ comment: {
+ id: 2002,
+ body: 'reply',
+ url: 'u',
+ author: githubUser(),
+ path: 'src/a.ts',
+ line: 5,
+ createdAt: '2026-01-01T00:00:00Z',
+ },
+ };
+ },
+ } as unknown as GitHubAPI;
+ const provider = createGithubForgeProvider(api);
+
+ const result = await provider.replyToThread!(
+ '/repo', { kind: 'pull', number: 42 },
+ { body: 'reply', inReplyToId: '2002', path: 'src/a.ts', line: 5 },
+ );
+ expect(result.ok).toBe(true);
+ expect(reviewArgs.inReplyToId).toBe(2002);
+ expect(reviewArgs.path).toBe('src/a.ts');
+ expect(reviewArgs.line).toBe(5);
+ expect(result.comment?.id).toBe('2002');
+ expect(result.comment?.path).toBe('src/a.ts');
+ });
+
+ test('github falls back to a flat comment on issues', async () => {
+ let issueArgs: Record = {};
+ const api = {
+ issueComment: async (input: Record) => {
+ issueArgs = input;
+ return { connected: true, comment: githubIssueComment };
+ },
+ } as unknown as GitHubAPI;
+ const provider = createGithubForgeProvider(api);
+
+ const result = await provider.replyToThread!(
+ '/repo', { kind: 'issue', number: 7 }, { body: 'thread reply', inReplyToId: '1001' },
+ );
+ expect(result.ok).toBe(true);
+ expect(issueArgs.body).toBe('thread reply');
+ });
+
+ test('gitlab and gitea reply as flat comments, ignoring the thread anchor', async () => {
+ let mrArgs: Record = {};
+ const gitlab = createGitlabForgeProvider({
+ mrComment: async (input: Record) => {
+ mrArgs = input;
+ return { connected: true, comment: gitlabNote };
+ },
+ } as unknown as GitLabAPI);
+ const glResult = await gitlab.replyToThread!(
+ '/repo', { kind: 'pull', number: 99 }, { body: 'gl reply', inReplyToId: '3003' },
+ );
+ expect(glResult.ok).toBe(true);
+ expect(mrArgs.body).toBe('gl reply');
+ expect(mrArgs.inReplyToId).toBe(undefined);
+
+ let giteaArgs: Record = {};
+ const gitea = createGiteaForgeProvider({
+ prComment: async (input: Record) => {
+ giteaArgs = input;
+ return { connected: true, comment: giteaComment };
+ },
+ } as unknown as GiteaAPI);
+ const gtResult = await gitea.replyToThread!(
+ '/repo', { kind: 'pull', number: 11 }, { body: 'gt reply', inReplyToId: '4004' },
+ );
+ expect(gtResult.ok).toBe(true);
+ expect(giteaArgs.body).toBe('gt reply');
+ expect(giteaArgs.inReplyToId).toBe(undefined);
+ });
+});
+
+describe('write operations: updateEntity', () => {
+ test('github resolves the current title and passes state through on pulls', async () => {
+ let updateArgs: Record = {};
+ const api = {
+ prContext: async () => ({ connected: true, pr: { ...githubPr, title: 'Add forge facade' } }),
+ prUpdate: async (input: Record) => {
+ updateArgs = input;
+ return { number: 42, title: 'Add forge facade', url: 'u', state: 'closed', draft: false, base: 'main', head: 'feat' };
+ },
+ } as unknown as GitHubAPI;
+ const provider = createGithubForgeProvider(api);
+
+ const result = await provider.updateEntity!('/repo', { kind: 'pull', number: 42 }, { state: 'closed' });
+ expect(result.ok).toBe(true);
+ expect(updateArgs).toEqual({ directory: '/repo', number: 42, title: 'Add forge facade', state: 'closed' });
+ expect(result.entity?.number).toBe(42);
+ expect(result.entity?.state).toBe('closed');
+ });
+
+ test('github issue updates pass title/body/state straight through', async () => {
+ let updateArgs: Record = {};
+ const api = {
+ issueUpdate: async (input: Record) => {
+ updateArgs = input;
+ return { connected: true, issue: { ...githubIssue, state: 'open', title: 'Renamed' } };
+ },
+ } as unknown as GitHubAPI;
+ const provider = createGithubForgeProvider(api);
+
+ const result = await provider.updateEntity!(
+ '/repo', { kind: 'issue', number: 7 }, { title: 'Renamed', body: 'New body', state: 'open' },
+ );
+ expect(result.ok).toBe(true);
+ expect(updateArgs).toEqual({
+ directory: '/repo', number: 7, title: 'Renamed', body: 'New body', state: 'open', owner: undefined, repo: undefined,
+ });
+ expect(result.entity?.title).toBe('Renamed');
+ expect(result.entity?.state).toBe('open');
+ });
+
+ test('gitlab passes state and the parsed namespace through on issues', async () => {
+ let issueArgs: Record = {};
+ const api = {
+ issueUpdate: async (input: Record) => {
+ issueArgs = input;
+ return { connected: true, issue: { ...gitlabIssue, state: 'closed' } };
+ },
+ } as unknown as GitLabAPI;
+ const provider = createGitlabForgeProvider(api);
+
+ const result = await provider.updateEntity!(
+ '/repo', { kind: 'issue', number: 8 }, { state: 'closed' }, { sourceRepo: 'acme/widget' },
+ );
+ expect(result.ok).toBe(true);
+ expect(issueArgs.state).toBe('closed');
+ expect(issueArgs.namespace).toBe('acme');
+ expect(issueArgs.project).toBe('widget');
+ expect(result.entity?.state).toBe('closed');
+ });
+
+ test('gitlab maps the MR body onto the description field', async () => {
+ let mrArgs: Record = {};
+ const api = {
+ mrUpdate: async (input: Record) => {
+ mrArgs = input;
+ return { ...gitlabMr, title: 'New MR title' };
+ },
+ } as unknown as GitLabAPI;
+ const provider = createGitlabForgeProvider(api);
+
+ const result = await provider.updateEntity!(
+ '/repo', { kind: 'pull', number: 99 }, { title: 'New MR title', body: 'MR body 2', state: 'closed' },
+ );
+ expect(result.ok).toBe(true);
+ expect(mrArgs.description).toBe('MR body 2');
+ expect(mrArgs.state).toBe('closed');
+ expect(result.entity?.title).toBe('New MR title');
+ });
+
+ test('gitea passes state through on pulls', async () => {
+ let prArgs: Record = {};
+ const api = {
+ prUpdate: async (input: Record) => {
+ prArgs = input;
+ return { number: 11, title: 't', url: 'u', state: 'closed', labels: [], sourceBranch: 'feat/gitea', targetBranch: 'main', author: giteaUser() };
+ },
+ } as unknown as GiteaAPI;
+ const provider = createGiteaForgeProvider(api);
+
+ const result = await provider.updateEntity!('/repo', { kind: 'pull', number: 11 }, { state: 'closed' });
+ expect(result.ok).toBe(true);
+ expect(prArgs.state).toBe('closed');
+ expect(result.entity?.state).toBe('closed');
+ });
+});
+
+describe('write operations: submitReview', () => {
+ test('github maps normalized events onto the wire events', async () => {
+ const events: Record[] = [];
+ const api = {
+ prSubmitReview: async (input: Record) => {
+ events.push(input);
+ return { connected: true, review: { id: 'r1', state: 'APPROVED', author: githubUser(), submittedAt: '2026-01-01T00:00:00Z', body: 'LGTM', commitSha: 'abc123' } };
+ },
+ } as unknown as GitHubAPI;
+ const provider = createGithubForgeProvider(api);
+
+ const result = await provider.submitReview!('/repo', { kind: 'pull', number: 42 }, { event: 'approve', body: 'LGTM' });
+ expect(result.ok).toBe(true);
+ expect(result.review?.state).toBe('approved');
+ expect(result.review?.author?.login).toBe('octocat');
+ expect(result.review?.commitSha).toBe('abc123');
+
+ await provider.submitReview!('/repo', { kind: 'pull', number: 42 }, { event: 'request-changes' });
+ await provider.submitReview!('/repo', { kind: 'pull', number: 42 }, { event: 'comment' });
+ expect(events.map((e) => e.event)).toEqual(['APPROVE', 'REQUEST_CHANGES', 'COMMENT']);
+ });
+
+ test('gitea maps events onto APPROVED/REQUEST_CHANGES/COMMENT', async () => {
+ const events: Record[] = [];
+ const api = {
+ prSubmitReview: async (input: Record) => {
+ events.push(input);
+ return { connected: true, review: { id: 'r2', state: 'APPROVED', author: giteaUser() } };
+ },
+ } as unknown as GiteaAPI;
+ const provider = createGiteaForgeProvider(api);
+
+ await provider.submitReview!('/repo', { kind: 'pull', number: 11 }, { event: 'approve' });
+ await provider.submitReview!('/repo', { kind: 'pull', number: 11 }, { event: 'request-changes' });
+ await provider.submitReview!('/repo', { kind: 'pull', number: 11 }, { event: 'comment' });
+ expect(events.map((e) => e.event)).toEqual(['APPROVED', 'REQUEST_CHANGES', 'COMMENT']);
+ });
+
+ test('gitlab approves only; request-changes and comment are unsupported', async () => {
+ let approveArgs: Record = {};
+ const api = {
+ mrApprove: async (input: Record) => {
+ approveArgs = input;
+ return { connected: true, approved: true };
+ },
+ } as unknown as GitLabAPI;
+ const provider = createGitlabForgeProvider(api);
+
+ const okResult = await provider.submitReview!('/repo', { kind: 'pull', number: 99 }, { event: 'approve' });
+ expect(okResult.ok).toBe(true);
+ expect(okResult.review).toEqual({ id: '', state: 'approved' });
+ expect(approveArgs).toEqual({ directory: '/repo', number: 99, namespace: undefined, project: undefined });
+
+ expect(await provider.submitReview!('/repo', { kind: 'pull', number: 99 }, { event: 'request-changes' }))
+ .toEqual({ ok: false, error: 'not supported' });
+ expect(await provider.submitReview!('/repo', { kind: 'pull', number: 99 }, { event: 'comment' }))
+ .toEqual({ ok: false, error: 'not supported' });
+ });
+});
+
+describe('write operations: toggleDraft', () => {
+ test('github passes the draft flag along with the current title', async () => {
+ let updateArgs: Record = {};
+ const api = {
+ prContext: async () => ({ connected: true, pr: { ...githubPr, title: 'Add forge facade' } }),
+ prUpdate: async (input: Record) => {
+ updateArgs = input;
+ return { number: 42, title: 'Add forge facade', url: 'u', state: 'open', draft: true, base: 'main', head: 'feat' };
+ },
+ } as unknown as GitHubAPI;
+ const provider = createGithubForgeProvider(api);
+
+ const result = await provider.toggleDraft!('/repo', { kind: 'pull', number: 42 }, true);
+ expect(result.ok).toBe(true);
+ expect(updateArgs.draft).toBe(true);
+ expect(updateArgs.title).toBe('Add forge facade');
+ expect((result.entity as { draft?: boolean } | null)?.draft).toBe(true);
+ });
+
+ test('gitlab prepends and strips the Draft: prefix idempotently', async () => {
+ let currentTitle = 'Add MR support';
+ const updatedTitles: string[] = [];
+ const api = {
+ mrContext: async () => ({ connected: true, mr: { ...gitlabMr, title: currentTitle } }),
+ mrUpdate: async (input: Record) => {
+ currentTitle = input.title as string;
+ updatedTitles.push(currentTitle);
+ return { ...gitlabMr, title: currentTitle };
+ },
+ } as unknown as GitLabAPI;
+ const provider = createGitlabForgeProvider(api);
+
+ await provider.toggleDraft!('/repo', { kind: 'pull', number: 99 }, true);
+ expect(updatedTitles.at(-1)).toBe('Draft: Add MR support');
+
+ await provider.toggleDraft!('/repo', { kind: 'pull', number: 99 }, true);
+ expect(updatedTitles.at(-1)).toBe('Draft: Add MR support');
+
+ await provider.toggleDraft!('/repo', { kind: 'pull', number: 99 }, false);
+ expect(updatedTitles.at(-1)).toBe('Add MR support');
+ });
+
+ test('gitea does not implement toggleDraft (capability draft:false)', () => {
+ const provider = createGiteaForgeProvider({} as unknown as GiteaAPI);
+ expect(provider.toggleDraft).toBe(undefined);
+ });
+});
+
+describe('write operations: updateMetadata', () => {
+ test('github passes labels/assignees/milestone through', async () => {
+ let issueArgs: Record = {};
+ const api = {
+ issueUpdate: async (input: Record) => {
+ issueArgs = input;
+ return { connected: true, issue: githubIssue };
+ },
+ } as unknown as GitHubAPI;
+ const provider = createGithubForgeProvider(api);
+
+ const result = await provider.updateMetadata!(
+ '/repo', { kind: 'issue', number: 7 }, { labels: ['bug'], assignees: ['octocat'], milestone: 'v2.0' },
+ );
+ expect(result.ok).toBe(true);
+ expect(issueArgs.labels).toEqual(['bug']);
+ expect(issueArgs.assignees).toEqual(['octocat']);
+ expect(issueArgs.milestone).toBe('v2.0');
+ });
+
+ test('gitlab sends labels/assignee logins/milestone; the server resolves logins to IDs', async () => {
+ let issueArgs: Record = {};
+ const api = {
+ issueUpdate: async (input: Record