feat(ui): forge write operations — comments, replies, close/reopen, edit, reviews, draft, metadata

- server: write routes for all three providers (issue/PR comments, inline review-comment replies, issue/MR updates w/ labels-assignees-milestone, review submit, draft toggle)
- ui: ForgeProvider gains six write ops; shared action components (composer, thread reply, state/review/draft/metadata/edit) wired into ForgeEntityDetailView and GitHub PR Overview
This commit is contained in:
2026-08-16 16:29:24 +00:00
parent 92f0eced34
commit 8f5cfdcd62
44 changed files with 5383 additions and 62 deletions
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
@@ -7,6 +7,7 @@ import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import type {
ForgeChecksResult,
ForgeCommitsResult,
ForgeEntityRef,
ForgeIssueDetail,
ForgeProvider,
ForgePullRequestContext,
@@ -18,6 +19,12 @@ import { ForgeCommitsSection } from './ForgeCommitsSection';
import { ForgeFilesDiffSection } from './ForgeFilesDiffSection';
import { ForgeTimelineSection } from './ForgeTimelineSection';
import { ForgeChecksSection } from './ForgeChecksSection';
import {
ForgeCommentComposer,
ForgeEntityActions,
ForgeMetadataEditor,
ForgeThreadReply,
} from './actions';
interface ForgeEntityDetailViewProps {
provider: ForgeProvider;
@@ -95,11 +102,32 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
const [pull, setPull] = useState<PullData | null>(null);
const [issueDetail, setIssueDetail] = useState<ForgeIssueDetail | null>(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<ForgeComment[]>([]);
// Id of the thread root the user is replying to (renders ForgeThreadReply
// under that thread card).
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const reload = useCallback(() => {
setReloadToken((value) => value + 1);
}, []);
const ref = useMemo<ForgeEntityRef>(() => ({ 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) {
@@ -147,13 +175,14 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
return () => {
cancelled = true;
};
}, [directory, isIssue, number, provider, sourceRepo]);
}, [directory, isIssue, number, provider, reloadToken, sourceRepo]);
const mergedComments = useMemo<ForgeComment[]>(() => {
if (isIssue) return issueDetail?.comments ?? [];
const context = pull?.context;
return [...(context?.issueComments ?? []), ...(context?.reviewComments ?? [])];
}, [isIssue, issueDetail?.comments, pull?.context]);
const derived = isIssue
? issueDetail?.comments ?? []
: [...(pull?.context?.issueComments ?? []), ...(pull?.context?.reviewComments ?? [])];
return [...localComments, ...derived];
}, [isIssue, issueDetail?.comments, localComments, pull?.context]);
const timelineEvents = useMemo<ForgeTimelineEvent[]>(() => pull?.timeline?.events ?? [], [pull?.timeline]);
@@ -170,6 +199,32 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
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 (
<ForgeThreadReply
provider={provider}
directory={directory}
ref={ref}
thread={{ inReplyToId: comment.id, path: comment.path ?? null, line: comment.line ?? null }}
onPosted={(created) => {
appendComment(created);
setReplyingTo(null);
}}
onCancel={() => setReplyingTo(null)}
/>
);
},
[appendComment, directory, provider, ref, replyingTo],
);
if (isLoading) {
return <LoadingBlock label={t('forge.loading')} />;
}
@@ -194,7 +249,17 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
{t(`forge.state.${issueState}`)}
</span>
</div>
<ForgeEntityActions provider={provider} directory={directory} ref={ref} issue={issue} onChanged={reload} />
<ForgeMetadataChips kind="issue" issue={issue} />
<ForgeMetadataEditor
provider={provider}
directory={directory}
ref={ref}
labels={issue.labels ?? []}
assignees={issue.assignees ?? []}
milestone={issue.milestone}
onChanged={reload}
/>
{issue.body ? (
<SimpleMarkdownRenderer content={issue.body} className={markdownClassName} enableFileReferences={false} />
) : null}
@@ -202,10 +267,13 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
<SectionTitle>{t('forge.section.timeline')}</SectionTitle>
<ForgeTimelineSection
events={[]}
comments={issueDetail.comments ?? []}
comments={mergedComments}
error={issueDetail.commentsError ?? null}
onReply={canReply ? handleReply : undefined}
renderReply={canReply ? renderThreadReply : undefined}
/>
</section>
<ForgeCommentComposer provider={provider} directory={directory} ref={ref} onPosted={appendComment} />
</div>
);
}
@@ -242,6 +310,8 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
) : null}
</div>
<ForgeEntityActions provider={provider} directory={directory} ref={ref} pr={pr} onChanged={reload} />
<ForgeMetadataChips kind="pull" pr={pr} />
{checksForPull ? (
@@ -273,8 +343,11 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
events={timelineEvents}
comments={mergedComments}
error={pull.timeline?.error ?? null}
onReply={canReply ? handleReply : undefined}
renderReply={canReply ? renderThreadReply : undefined}
/>
</section>
<ForgeCommentComposer provider={provider} directory={directory} ref={ref} onPosted={appendComment} />
</div>
);
};
@@ -1,5 +1,6 @@
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';
@@ -13,6 +14,10 @@ interface ForgeTimelineSectionProps {
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<ForgeTimelineEventType, IconName> = {
@@ -83,7 +88,7 @@ type TimelineItem =
* by `inReplyToId` chains or (path, line) buckets; a thread renders as one
* card with its comments stacked. Pure presentation.
*/
export const ForgeTimelineSection = React.memo<ForgeTimelineSectionProps>(function ForgeTimelineSection({ events, comments, loading, error }) {
export const ForgeTimelineSection = React.memo<ForgeTimelineSectionProps>(function ForgeTimelineSection({ events, comments, loading, error, onReply, renderReply }) {
const { t } = useI18n();
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
@@ -252,6 +257,19 @@ export const ForgeTimelineSection = React.memo<ForgeTimelineSectionProps>(functi
</div>
))}
</div>
{root.path && onReply ? (
<div className="flex items-center gap-1.5 pt-2">
<Button
variant="link"
size="xs"
onClick={() => onReply(root)}
aria-label={t('forge.actions.reply')}
>
{t('forge.actions.reply')}
</Button>
</div>
) : null}
{renderReply ? renderReply(root) : null}
</div>
</div>
);
@@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
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';
import type { ForgeComment } from '@/lib/forge/types';
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<ForgeCommentComposerProps> = ({ 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<void> => {
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 (
<div className="flex flex-col gap-2">
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={t('forge.actions.commentPlaceholder')}
disabled={submitting}
aria-label={t('forge.actions.commentPlaceholder')}
className="min-h-[72px]"
/>
<div className="flex justify-end">
<Button size="sm" onClick={() => void submit()} disabled={!canSubmit}>
{submitting ? (
<>
<Icon name="loader-4" className="size-4 animate-spin" />
{t('forge.actions.posting')}
</>
) : (
<>
<Icon name="chat-1" className="size-4" />
{t('forge.actions.comment')}
</>
)}
</Button>
</div>
</div>
);
};
@@ -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<ForgeDraftToggleProps> = ({ 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<void> => {
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 (
<Button
variant="outline"
size="sm"
onClick={() => void run()}
disabled={submitting}
aria-label={t(draft ? 'forge.actions.markReady' : 'forge.actions.markDraft')}
>
{submitting ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name={draft ? 'checkbox-circle' : 'git-pr-draft'} className="size-4" />
)}
{t(draft ? 'forge.actions.markReady' : 'forge.actions.markDraft')}
</Button>
);
};
@@ -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<ForgeEditFormProps> = ({ 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<void> => {
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 (
<div className="flex flex-col gap-2">
<Input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
placeholder={t('forge.actions.edit')}
aria-label={t('forge.actions.edit')}
disabled={submitting}
/>
<Textarea
value={editBody}
onChange={(event) => setEditBody(event.target.value)}
placeholder={t('forge.actions.commentPlaceholder')}
disabled={submitting}
aria-label={t('forge.actions.commentPlaceholder')}
className="min-h-[96px]"
/>
<div className="flex items-center justify-end gap-1.5">
<Button variant="ghost" size="sm" onClick={onCancel} disabled={submitting}>
{t('forge.actions.cancel')}
</Button>
<Button size="sm" onClick={() => void save()} disabled={!canSubmit}>
{submitting ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name="check" className="size-4" />
)}
{t('forge.actions.save')}
</Button>
</div>
</div>
);
};
@@ -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<ForgeEntityActionsProps> = ({ 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 (
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{hasEdit && !editing ? (
<Button
variant="outline"
size="sm"
onClick={() => setEditing(true)}
aria-label={t('forge.actions.edit')}
>
<Icon name="edit" className="size-4" />
{t('forge.actions.edit')}
</Button>
) : null}
{hasEdit && showOtherActions ? <div className="h-4 w-px shrink-0 bg-border/60" aria-hidden /> : null}
{showOtherActions ? (
<>
{hasDraft && pr ? (
<ForgeDraftToggle
provider={provider}
directory={directory}
ref={ref}
draft={pr.draft}
onChanged={onChanged}
/>
) : null}
{hasState ? (
<ForgeStateActions
provider={provider}
directory={directory}
ref={ref}
state={entityState}
onChanged={onChanged}
/>
) : null}
{hasReview ? <ForgeReviewActions provider={provider} directory={directory} ref={ref} onReviewed={onChanged} /> : null}
</>
) : null}
</div>
{editing && entity ? (
<ForgeEditForm
provider={provider}
directory={directory}
ref={ref}
title={entity.title}
body={entity.body}
onSaved={onSaved}
onCancel={() => setEditing(false)}
/>
) : null}
</div>
);
};
@@ -0,0 +1,245 @@
import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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';
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<ForgeMetadataEditorProps> = ({
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<void> => {
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<void> => {
const name = labelInput.trim();
if (!name) return;
await runMetadata({ labels: [...labels.map((label) => label.name), name] }, 'forge.actions.added');
setLabelInput('');
};
const removeLabel = async (name: string): Promise<void> => {
await runMetadata({ labels: labels.filter((label) => label.name !== name).map((label) => label.name) }, 'forge.actions.removed');
};
const addAssignee = async (): Promise<void> => {
const login = assigneeInput.trim();
if (!login) return;
await runMetadata({ assignees: [...assignees.map((assignee) => assignee.login), login] }, 'forge.actions.added');
setAssigneeInput('');
};
const removeAssignee = async (id: string): Promise<void> => {
await runMetadata({ assignees: assignees.filter((assignee) => assignee.id !== id).map((assignee) => assignee.login) }, 'forge.actions.removed');
};
const addMilestone = async (): Promise<void> => {
const title = milestoneInput.trim();
if (!title) return;
await runMetadata({ milestone: title }, 'forge.actions.metadataChanged');
setMilestoneInput('');
};
const removeMilestone = async (): Promise<void> => {
await runMetadata({ milestone: null }, 'forge.actions.removed');
};
const renderAvatar = (user: ForgeUser): React.ReactElement => {
if (user.avatarUrl) {
return <img src={user.avatarUrl} alt={user.login} className={`${avatarSize} object-cover`} />;
}
return (
<span className={`${avatarSize} flex items-center justify-center bg-interactive-hover text-[10px] font-medium text-foreground`}>
{(user.login || user.name || '?').charAt(0).toUpperCase()}
</span>
);
};
return (
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
{canLabels
? labels.map((label) => (
<span key={label.name} className={chipClassName} title={label.description || label.name}>
<span
aria-hidden
className="size-2 rounded-full"
style={{ backgroundColor: resolveLabelColor(label.color) ?? 'var(--status-info)' }}
/>
{label.name}
<button
type="button"
className={removeButtonClassName}
onClick={() => void removeLabel(label.name)}
disabled={submitting}
aria-label={`${t('forge.actions.remove')}: ${label.name}`}
>
<Icon name="close" className="size-3" />
</button>
</span>
))
: null}
{canAssignees
? assignees.map((assignee) => (
<span key={assignee.id} className={chipClassName} title={assignee.login}>
{renderAvatar(assignee)}
{assignee.login}
<button
type="button"
className={removeButtonClassName}
onClick={() => void removeAssignee(assignee.id)}
disabled={submitting}
aria-label={`${t('forge.actions.remove')}: ${assignee.login}`}
>
<Icon name="close" className="size-3" />
</button>
</span>
))
: null}
{canMilestones && milestone ? (
<span className={chipClassName} title={milestone.title}>
<Icon name="target" className="size-3 text-muted-foreground" />
{milestone.title}
<button
type="button"
className={removeButtonClassName}
onClick={() => void removeMilestone()}
disabled={submitting}
aria-label={`${t('forge.actions.remove')}: ${milestone.title}`}
>
<Icon name="close" className="size-3" />
</button>
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{canLabels ? (
<span className="flex items-center gap-1">
<Input
value={labelInput}
onChange={(event) => setLabelInput(event.target.value)}
placeholder={t('forge.actions.addLabel')}
aria-label={t('forge.actions.addLabel')}
className="h-6 w-36"
onKeyDown={(event) => {
if (event.key === 'Enter') void addLabel();
}}
/>
<Button variant="ghost" size="xs" onClick={() => void addLabel()} disabled={submitting || !labelInput.trim()}>
{t('forge.actions.addLabel')}
</Button>
</span>
) : null}
{canAssignees ? (
<span className="flex items-center gap-1">
<Input
value={assigneeInput}
onChange={(event) => setAssigneeInput(event.target.value)}
placeholder={t('forge.actions.addAssignee')}
aria-label={t('forge.actions.addAssignee')}
className="h-6 w-36"
onKeyDown={(event) => {
if (event.key === 'Enter') void addAssignee();
}}
/>
<Button variant="ghost" size="xs" onClick={() => void addAssignee()} disabled={submitting || !assigneeInput.trim()}>
{t('forge.actions.addAssignee')}
</Button>
</span>
) : null}
{canMilestones ? (
<span className="flex items-center gap-1">
<Input
value={milestoneInput}
onChange={(event) => setMilestoneInput(event.target.value)}
placeholder={t('forge.actions.setMilestone')}
aria-label={t('forge.actions.setMilestone')}
className="h-6 w-36"
onKeyDown={(event) => {
if (event.key === 'Enter') void addMilestone();
}}
/>
<Button variant="ghost" size="xs" onClick={() => void addMilestone()} disabled={submitting || !milestoneInput.trim()}>
{t('forge.actions.setMilestone')}
</Button>
</span>
) : null}
</div>
</div>
);
};
@@ -0,0 +1,122 @@
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 { Textarea } from '@/components/ui/textarea';
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';
interface ForgeReviewActionsProps {
provider: ForgeProvider;
directory: string;
ref: ForgeEntityRef;
onReviewed?: () => void;
}
const EVENT_LABEL_KEYS: Record<ForgeReviewEvent, I18nKey> = {
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<ForgeReviewActionsProps> = ({ provider, directory, ref, onReviewed }) => {
const { t } = useI18n();
const [pendingEvent, setPendingEvent] = useState<ForgeReviewEvent | null>(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<void> => {
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 => (
<Button variant="outline" size="sm" onClick={() => openDialog(event)} disabled={submitting}>
{event === 'approve' ? <Icon name="checkbox-circle" className="size-4" /> : <Icon name="chat-1" className="size-4" />}
{t(EVENT_LABEL_KEYS[event])}
</Button>
);
return (
<>
<div className="flex flex-wrap items-center gap-1.5">
{renderEventButton('approve')}
{canRequestChanges ? (
<>
{renderEventButton('request-changes')}
{renderEventButton('comment')}
</>
) : null}
</div>
<Dialog
open={pendingEvent !== null}
onOpenChange={(open) => {
if (!open) setPendingEvent(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('forge.actions.reviewDialogTitle')}</DialogTitle>
</DialogHeader>
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={t('forge.actions.reviewBodyPlaceholder')}
disabled={submitting}
aria-label={t('forge.actions.reviewBodyPlaceholder')}
className="min-h-[96px]"
/>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setPendingEvent(null)} disabled={submitting}>
{t('forge.actions.cancel')}
</Button>
<Button size="sm" onClick={() => void submit()} disabled={submitting}>
{submitting ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name="check" className="size-4" />
)}
{pendingEvent ? t(EVENT_LABEL_KEYS[pendingEvent]) : t('forge.actions.reviewDialogTitle')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -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<ForgeStateActionsProps> = ({ 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<void> => {
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 (
<Button
variant="outline"
size="sm"
onClick={() => void run()}
disabled={submitting}
aria-label={t(isOpen ? 'forge.actions.close' : 'forge.actions.reopen')}
>
{submitting ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name={isOpen ? 'git-close-pull-request' : 'git-pull-request'} className="size-4" />
)}
{t(isOpen ? 'forge.actions.close' : 'forge.actions.reopen')}
</Button>
);
};
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
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';
import type { ForgeComment } from '@/lib/forge/types';
/** 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<ForgeThreadReplyProps> = ({ 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<void> => {
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 (
<div className="flex flex-col gap-1.5">
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={t('forge.actions.commentPlaceholder')}
disabled={submitting}
aria-label={t('forge.actions.commentPlaceholder')}
className="min-h-[56px]"
autoFocus
/>
<div className="flex items-center justify-end gap-1.5">
<Button variant="ghost" size="sm" onClick={onCancel} disabled={submitting}>
{t('forge.actions.cancel')}
</Button>
<Button size="sm" onClick={() => void submit()} disabled={!canSubmit}>
{submitting ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name="chat-1" className="size-4" />
)}
{t('forge.actions.reply')}
</Button>
</div>
</div>
);
};
@@ -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 { ForgeEditForm } from './ForgeEditForm';
export { ForgeEntityActions } from './ForgeEntityActions';
@@ -34,6 +34,12 @@ 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,
@@ -1182,6 +1188,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;
@@ -1869,6 +1888,31 @@ export const PullRequestSection: React.FC<{
{forgePr ? <ForgeMetadataChips kind="pull" pr={forgePr} /> : null}
{forgeProvider && forgePr && forgePr.state === 'open' ? (
<div className="flex flex-wrap items-center gap-1.5">
<ForgeDraftToggle
provider={forgeProvider}
directory={directory}
ref={{ kind: 'pull', number: pr.number }}
draft={!!forgePr.draft}
onChanged={refreshPr}
/>
<ForgeStateActions
provider={forgeProvider}
directory={directory}
ref={{ kind: 'pull', number: pr.number }}
state={forgePr.state}
onChanged={refreshPr}
/>
<ForgeReviewActions
provider={forgeProvider}
directory={directory}
ref={{ kind: 'pull', number: pr.number }}
onReviewed={refreshPr}
/>
</div>
) : null}
{isEditingPr ? (
<Textarea
value={editBody}
@@ -1894,6 +1938,15 @@ export const PullRequestSection: React.FC<{
</div>
)
) : null}
{forgeProvider && forgePr && forgePr.state === 'open' ? (
<ForgeCommentComposer
provider={forgeProvider}
directory={directory}
ref={{ kind: 'pull', number: pr.number }}
onPosted={refreshPrContext}
/>
) : null}
</div>
) : null}
+223 -1
View File
@@ -1024,6 +1024,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 = {
@@ -1111,6 +1116,82 @@ 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 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;
@@ -1175,6 +1256,11 @@ export interface GitHubAPI {
prTimeline?(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubPullRequestTimelineResult>;
repoUpstream(directory: string): Promise<GitHubRepoUpstreamResult>;
repoBranches(owner: string, repo: string): Promise<string[]>;
issueComment?(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult>;
issueUpdate?(input: GitHubIssueUpdateInput): Promise<GitHubIssueUpdateResult>;
prComment?(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult>;
prReviewComment?(input: GitHubReviewCommentInput): Promise<GitHubReviewCommentResult>;
prSubmitReview?(input: GitHubPullRequestReviewInput): Promise<GitHubPullRequestReviewResult>;
}
export type GitLabUserSummary = {
@@ -1350,6 +1436,10 @@ export type GitLabMergeRequestUpdateInput = {
number: number;
title?: string;
description?: string;
state?: 'open' | 'closed';
labels?: string[];
assigneeIds?: number[];
milestone?: string | null;
};
export type GitLabMergeRequestMergeInput = {
@@ -1376,6 +1466,66 @@ export type GitLabMergeRequestMergeResult = {
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 GitLabIssueUpdateInput = {
directory: string;
number: number;
title?: string;
body?: string;
state?: 'open' | 'closed';
labels?: 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: {
@@ -1418,6 +1568,11 @@ export interface GitLabAPI {
mrCommits?(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabMergeRequestCommitsResult>;
mrTimeline?(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabMergeRequestTimelineResult>;
issueComment?(input: GitLabIssueCommentInput): Promise<GitLabIssueCommentResult>;
issueUpdate?(input: GitLabIssueUpdateInput): Promise<GitLabIssueUpdateResult>;
mrComment?(input: GitLabMrNoteInput): Promise<GitLabMrNoteResult>;
mrApprove?(input: GitLabMrApproveInput): Promise<GitLabMrApproveResult>;
repoBranches(namespace: string, project: string): Promise<GitLabBranchesResult>;
}
@@ -1499,7 +1654,7 @@ export type GiteaIssueCommentsResult = { connected: boolean; repo?: { owner: str
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 };
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 };
@@ -1536,6 +1691,67 @@ export type GiteaReview = {
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 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[];
};
export interface GiteaAPI {
authStatus(): Promise<GiteaAuthStatus>;
authConnect(input: { accessToken: string; baseUrl: string }): Promise<GiteaAuthStatus>;
@@ -1560,6 +1776,12 @@ export interface GiteaAPI {
prStatuses?(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestStatusesResult>;
prReviews?(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestReviewsResult>;
issueComment?(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult>;
issueUpdate?(input: GiteaIssueUpdateInput): Promise<GiteaIssueUpdateResult>;
prComment?(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult>;
prSubmitReview?(input: GiteaPullReviewInput): Promise<GiteaPullReviewResult>;
repoLabels?(directory: string, options?: { owner?: string; repo?: string }): Promise<GiteaRepoLabelsResult>;
repoBranches(owner: string, repo: string): Promise<GiteaBranchesResult>;
}
+476
View File
@@ -12,7 +12,9 @@
import type {
GiteaAPI,
GiteaPullReviewInput,
GitHubAPI,
GitHubPullRequestReviewEvent,
GitHubRepoSelector,
GitLabAPI,
} from '@/lib/api/types';
@@ -24,7 +26,9 @@ import type {
ForgeProvider,
ForgePullRequestContext,
ForgePullRequestsResult,
ForgeReviewEvent,
ForgeTimelineResult,
ForgeUpdateResult,
} from './provider';
import type { ForgeProviderCapabilities, ForgeProviderKind } from './types';
import {
@@ -35,6 +39,7 @@ import {
mapGiteaPr,
mapGiteaRepoRef,
mapGiteaReviewsToEvents,
mapGiteaReview,
mapGiteaStatuses,
mapGithubCommits,
mapGithubContext,
@@ -42,6 +47,8 @@ import {
mapGithubIssueComment,
mapGithubPr,
mapGithubRepoRef,
mapGithubReview,
mapGithubReviewCommentReply,
mapGithubTimelineEvents,
mapGitlabCommits,
mapGitlabContext,
@@ -151,6 +158,59 @@ const parseOwnerRepo = (sourceRepo?: string | null): GitHubRepoSelector | 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<GitHubAPI, 'prContext'>,
directory: string,
number: number,
): Promise<string | null> => {
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<ForgeReviewEvent, GitHubPullRequestReviewEvent> = {
approve: 'APPROVE',
'request-changes': 'REQUEST_CHANGES',
comment: 'COMMENT',
};
const GITEA_REVIEW_EVENTS: Record<ForgeReviewEvent, GiteaPullReviewInput['event']> = {
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,
@@ -280,6 +340,175 @@ export const createGithubForgeProvider = (api: GitHubAPI): ForgeProvider => ({
async getChecks() {
return null;
},
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 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 => ({
@@ -421,6 +650,139 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
async getChecks() {
return null;
},
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 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, not login; the facade takes logins, so
// assignees are left unset until an id lookup exists.
const result = await api.issueUpdate({
directory,
number: ref.number,
labels: input.labels,
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,
milestone: input.milestone,
});
return { ok: true, entity: mapGitlabMr(mr) };
} catch {
return { ok: false, error: WRITE_ERROR };
}
},
});
export const createGiteaForgeProvider = (api: GiteaAPI): ForgeProvider => ({
@@ -581,6 +943,120 @@ export const createGiteaForgeProvider = (api: GiteaAPI): ForgeProvider => ({
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 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;
},
});
/**
+537
View File
@@ -30,6 +30,7 @@ import {
mapGiteaIssue,
mapGiteaPr,
mapGiteaReviewsToEvents,
mapGiteaReview,
mapGiteaStatuses,
mapGithubCheckSummary,
mapGithubCommits,
@@ -37,6 +38,7 @@ import {
mapGithubIssue,
mapGithubIssueComment,
mapGithubPr,
mapGithubReview,
mapGithubReviewComment,
mapGithubTimelineEvents,
mapGitlabCommits,
@@ -45,6 +47,7 @@ import {
mapGitlabMr,
mapGitlabNoteComment,
mapGitlabTimelineEvents,
mapReviewState,
mapStatusState,
normalizeEventType,
stateOf,
@@ -685,6 +688,540 @@ describe('gitea commit-status normalization', () => {
});
});
// ---------------------------------------------------------------------------
// 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<string, unknown> = {};
let prArgs: Record<string, unknown> = {};
const api = {
issueComment: async (input: Record<string, unknown>) => {
issueArgs = input;
return { connected: true, comment: githubIssueComment };
},
prComment: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
let mrArgs: Record<string, unknown> = {};
const api = {
issueComment: async (input: Record<string, unknown>) => {
issueArgs = input;
return { connected: true, comment: gitlabNote };
},
mrComment: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
let prArgs: Record<string, unknown> = {};
const api = {
issueComment: async (input: Record<string, unknown>) => {
issueArgs = input;
return { connected: true, comment: giteaComment };
},
prComment: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
const api = {
prReviewComment: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
const api = {
issueComment: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
const gitlab = createGitlabForgeProvider({
mrComment: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
const gitea = createGiteaForgeProvider({
prComment: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
const api = {
prContext: async () => ({ connected: true, pr: { ...githubPr, title: 'Add forge facade' } }),
prUpdate: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
const api = {
issueUpdate: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
const api = {
issueUpdate: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
const api = {
mrUpdate: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
const api = {
prUpdate: async (input: Record<string, unknown>) => {
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<string, unknown>[] = [];
const api = {
prSubmitReview: async (input: Record<string, unknown>) => {
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<string, unknown>[] = [];
const api = {
prSubmitReview: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
const api = {
mrApprove: async (input: Record<string, unknown>) => {
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<string, unknown> = {};
const api = {
prContext: async () => ({ connected: true, pr: { ...githubPr, title: 'Add forge facade' } }),
prUpdate: async (input: Record<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown> = {};
const api = {
issueUpdate: async (input: Record<string, unknown>) => {
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/milestone but never assignees (id-based)', async () => {
let issueArgs: Record<string, unknown> = {};
const api = {
issueUpdate: async (input: Record<string, unknown>) => {
issueArgs = input;
return { connected: true, issue: gitlabIssue };
},
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
const result = await provider.updateMetadata!(
'/repo', { kind: 'issue', number: 8 }, { labels: ['frontend'], assignees: ['gluser'], milestone: null },
);
expect(result.ok).toBe(true);
expect(issueArgs.labels).toEqual(['frontend']);
expect(issueArgs.milestone).toBeNull();
expect(issueArgs.assignees).toBe(undefined);
});
test('gitea issue metadata passes through; PR metadata is unsupported', async () => {
let issueArgs: Record<string, unknown> = {};
const api = {
issueUpdate: async (input: Record<string, unknown>) => {
issueArgs = input;
return { connected: true, issue: giteaIssue };
},
} as unknown as GiteaAPI;
const provider = createGiteaForgeProvider(api);
const issueResult = await provider.updateMetadata!(
'/repo', { kind: 'issue', number: 12 }, { labels: ['bug'], assignees: ['guser'] },
);
expect(issueResult.ok).toBe(true);
expect(issueArgs.labels).toEqual(['bug']);
expect(issueArgs.assignees).toEqual(['guser']);
const prResult = await provider.updateMetadata!('/repo', { kind: 'pull', number: 11 }, { labels: ['backend'] });
expect(prResult).toEqual({ ok: false, error: 'not supported' });
});
});
describe('write operations degrade gracefully', () => {
test('absent runtime methods return {ok:false} without throwing', async () => {
const github = createGithubForgeProvider({} as unknown as GitHubAPI);
expect(await github.addComment!('/repo', { kind: 'issue', number: 1 }, { body: 'x' }))
.toEqual({ ok: false, error: 'failed to load' });
expect(await github.replyToThread!('/repo', { kind: 'pull', number: 1 }, { body: 'x' }))
.toEqual({ ok: false, error: 'failed to load' });
expect(await github.updateEntity!('/repo', { kind: 'issue', number: 1 }, { state: 'closed' }))
.toEqual({ ok: false, error: 'failed to load' });
expect(await github.submitReview!('/repo', { kind: 'pull', number: 1 }, { event: 'approve' }))
.toEqual({ ok: false, error: 'failed to load' });
expect(await github.toggleDraft!('/repo', { kind: 'pull', number: 1 }, true))
.toEqual({ ok: false, error: 'failed to load' });
expect(await github.updateMetadata!('/repo', { kind: 'issue', number: 1 }, {}))
.toEqual({ ok: false, error: 'failed to load' });
const gitlab = createGitlabForgeProvider({} as unknown as GitLabAPI);
expect(await gitlab.submitReview!('/repo', { kind: 'pull', number: 1 }, { event: 'approve' }))
.toEqual({ ok: false, error: 'failed to load' });
expect(await gitlab.toggleDraft!('/repo', { kind: 'pull', number: 1 }, true))
.toEqual({ ok: false, error: 'failed to load' });
const gitea = createGiteaForgeProvider({} as unknown as GiteaAPI);
expect(await gitea.submitReview!('/repo', { kind: 'pull', number: 1 }, { event: 'approve' }))
.toEqual({ ok: false, error: 'failed to load' });
});
test('github updateEntity/toggleDraft degrade when the title cannot be resolved', async () => {
const github = createGithubForgeProvider({} as unknown as GitHubAPI);
// prContext absent → resolvePrTitle returns null → no title, graceful failure.
expect(await github.updateEntity!('/repo', { kind: 'pull', number: 42 }, { state: 'closed' }))
.toEqual({ ok: false, error: 'failed to load' });
expect(await github.toggleDraft!('/repo', { kind: 'pull', number: 42 }, true))
.toEqual({ ok: false, error: 'failed to load' });
});
test('wire failures degrade to {ok:false} instead of throwing', async () => {
const api = {
issueComment: async () => {
throw new Error('boom');
},
mrApprove: async () => {
throw new Error('boom');
},
} as unknown as GitHubAPI;
const github = createGithubForgeProvider(api);
expect(await github.addComment!('/repo', { kind: 'issue', number: 1 }, { body: 'x' }))
.toEqual({ ok: false, error: 'failed to load' });
const gitlab = createGitlabForgeProvider(api as unknown as GitLabAPI);
expect(await gitlab.submitReview!('/repo', { kind: 'pull', number: 1 }, { event: 'approve' }))
.toEqual({ ok: false, error: 'failed to load' });
});
});
// ---------------------------------------------------------------------------
// Adapter factory
// ---------------------------------------------------------------------------
+57
View File
@@ -27,6 +27,7 @@ import type {
GitHubIssueSummary,
GitHubPullRequestCommit,
GitHubPullRequestContextResult,
GitHubPullRequestReview,
GitHubPullRequestSummary,
GitHubTimelineEvent,
GitHubUserSummary,
@@ -52,6 +53,7 @@ import type {
ForgeIssue,
ForgePullRequest,
ForgeRepoRef,
ForgeReview,
ForgeTimelineEvent,
ForgeTimelineEventType,
ForgeUser,
@@ -283,6 +285,61 @@ export const mapGiteaComment = (comment: GiteaComment): ForgeComment => ({
url: comment.url,
});
/**
* GitHub review-comment replies carry the same wire shape as review comments,
* so the reply maps through the review-comment projection. Kept as a distinct
* export so the adapter's reply path is self-documenting.
*/
export const mapGithubReviewCommentReply = (comment: GithubReviewComment): ForgeComment => ({
...mapGithubReviewComment(comment),
});
// ---------------------------------------------------------------------------
// Reviews
// ---------------------------------------------------------------------------
/**
* Map a provider review state onto the normalized vocabulary. GitHub uses
* 'CHANGES_REQUESTED' where Gitea uses 'REQUEST_CHANGES'; both collapse to
* 'requested-changes'. Anything unrecognized collapses to 'pending' so an
* unknown marker never renders as a completed review.
*/
export const mapReviewState = (state: string): ForgeReview['state'] => {
switch (state.toLowerCase()) {
case 'approved':
return 'approved';
case 'changes_requested':
case 'request_changes':
case 'requested-changes':
return 'requested-changes';
case 'commented':
case 'comment':
return 'commented';
case 'dismissed':
return 'dismissed';
default:
return 'pending';
}
};
export const mapGithubReview = (review: GitHubPullRequestReview): ForgeReview => ({
id: review.id,
state: mapReviewState(review.state),
author: review.author ? mapGithubUser(review.author) : undefined,
submittedAt: review.submittedAt,
body: review.body ?? undefined,
commitSha: review.commitSha ?? undefined,
});
export const mapGiteaReview = (review: GiteaReview): ForgeReview => ({
id: review.id,
state: mapReviewState(review.state),
author: review.author ? mapGiteaUser(review.author) : undefined,
submittedAt: review.submittedAt,
body: review.body ?? undefined,
commitSha: review.commitSha ?? undefined,
});
// ---------------------------------------------------------------------------
// Checks
// ---------------------------------------------------------------------------
+135
View File
@@ -8,6 +8,7 @@ import type {
ForgeProviderKind,
ForgePullRequest,
ForgeRepoRef,
ForgeReview,
ForgeTimelineEvent,
} from './types';
@@ -102,6 +103,52 @@ export interface ForgeChecksResult {
error?: string | null;
}
// --- Write operations ---
/** Open/closed state a write may set on an issue or PR ('merged' is not writable). */
export type ForgeWriteState = 'open' | 'closed';
/** Review events the facade can submit; providers map them onto their own vocabulary. */
export type ForgeReviewEvent = 'approve' | 'request-changes' | 'comment';
/** Target of a write operation: an issue or a pull request, by number. */
export interface ForgeEntityRef {
kind: 'issue' | 'pull';
number: number;
}
/**
* Reply/inline comment input. `inReplyToId` anchors a thread reply; `path` and
* `line` place a new inline review comment (GitHub review comments only).
*/
export interface ForgeCommentInput {
body: string;
inReplyToId?: string | null;
path?: string | null;
line?: number | null;
}
/** Result of posting a comment; `ok: false` with `error` means nothing was posted. */
export interface ForgeCommentResult {
ok: boolean;
error?: string | null;
comment?: ForgeComment | null;
}
/** Result of an entity update; `entity` is the refreshed issue/PR when `ok`. */
export interface ForgeUpdateResult {
ok: boolean;
error?: string | null;
entity?: ForgeIssue | ForgePullRequest | null;
}
/** Result of submitting a review; GitLab approvals synthesize a minimal review. */
export interface ForgeReviewResult {
ok: boolean;
error?: string | null;
review?: ForgeReview | null;
}
/**
* Provider-agnostic forge operations. Every method resolves the target
* repository from the working directory (remotes + connected accounts) and
@@ -182,4 +229,92 @@ export interface ForgeProvider {
* through this method.
*/
getChecks?(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeChecksResult | null>;
// --- Write operations ---
//
// Every write method is optional and capability-flagged: the UI gates on
// method presence plus `capabilities` before enabling an affordance. Methods
// return `{ ok: false, error }` — never throw — when the runtime API is
// missing, the wire call fails, or the provider does not support the
// operation.
/**
* Post a comment on an issue or pull request (the issue-thread comment, not a
* review comment). Providers route by `ref.kind`, so issues and PRs both get
* their thread comment. Wraps `github issueComment`/`prComment`, `gitlab
* issueComment`/`mrComment`, and `gitea issueComment`/`prComment`.
* `sourceRepo` selects a cross-repo (fork) repository.
*/
addComment?(
directory: string,
ref: ForgeEntityRef,
input: { body: string },
options?: { sourceRepo?: string | null },
): Promise<ForgeCommentResult>;
/**
* Reply to a comment thread. On GitHub this posts a proper inline
* review-comment reply via `prReviewComment` (anchored on `inReplyToId`);
* GitLab and Gitea have no thread-reply API wired up yet, so they fall back
* to a flat comment on the thread.
*/
replyToThread?(
directory: string,
ref: ForgeEntityRef,
input: ForgeCommentInput,
options?: { sourceRepo?: string | null },
): Promise<ForgeCommentResult>;
/**
* Update an issue/PR: title, body, or open/closed state. Wraps `github
* issueUpdate`/`prUpdate`, `gitlab issueUpdate`/`mrUpdate`, and `gitea
* issueUpdate`/`prUpdate`. GitHub's PR route requires a title, so the adapter
* fetches the current title first when the input omits it.
*/
updateEntity?(
directory: string,
ref: ForgeEntityRef,
input: { title?: string; body?: string; state?: ForgeWriteState },
options?: { sourceRepo?: string | null },
): Promise<ForgeUpdateResult>;
/**
* Submit a review on a pull request. GitHub maps the normalized events onto
* `APPROVE`/`REQUEST_CHANGES`/`COMMENT`; Gitea onto `APPROVED`/
* `REQUEST_CHANGES`/`COMMENT`; GitLab supports approvals only
* `request-changes` and `comment` return `{ ok: false, error: 'not
* supported' }` because GitLab exposes no such MR review events.
*/
submitReview?(
directory: string,
ref: ForgeEntityRef,
input: { event: ForgeReviewEvent; body?: string },
options?: { sourceRepo?: string | null },
): Promise<ForgeReviewResult>;
/**
* Toggle draft state. GitHub sets `pulls.update.draft`; GitLab prepends or
* strips the `Draft: ` title prefix (fetching the current title first);
* Gitea has no draft concept (`capabilities.draft: false`) and leaves this
* method undefined.
*/
toggleDraft?(
directory: string,
ref: ForgeEntityRef,
draft: boolean,
options?: { sourceRepo?: string | null },
): Promise<ForgeUpdateResult>;
/**
* Update issue/PR metadata: labels (full-set replace), assignees (logins;
* not applied on GitLab, which assigns by user ID no id lookup yet), and
* milestone (a title the server resolves, or `null` to clear). Wraps the
* per-provider issue/PR update routes.
*/
updateMetadata?(
directory: string,
ref: ForgeEntityRef,
input: { labels?: string[]; assignees?: string[]; milestone?: string | null },
options?: { sourceRepo?: string | null },
): Promise<ForgeUpdateResult>;
}
+29
View File
@@ -1281,6 +1281,35 @@ export const dict = {
'filesView.diagram.closeDiagramView': 'Diagrammansicht schließen',
'filesView.diagram.saveDiagram': 'Diagramm speichern',
'forge.actions.addAssignee': 'Bearbeiter hinzufügen',
'forge.actions.addLabel': 'Label hinzufügen',
'forge.actions.added': 'Hinzugefügt',
'forge.actions.approve': 'Genehmigen',
'forge.actions.cancel': 'Abbrechen',
'forge.actions.close': 'Schließen',
'forge.actions.closeConfirm': 'Diesen Vorgang schließen?',
'forge.actions.comment': 'Kommentieren',
'forge.actions.commentPlaceholder': 'Kommentar hinzufügen…',
'forge.actions.draftChanged': 'Entwurfsstatus aktualisiert',
'forge.actions.edit': 'Bearbeiten',
'forge.actions.error': 'Aktion fehlgeschlagen',
'forge.actions.markDraft': 'Als Entwurf markieren',
'forge.actions.markReady': 'Als bereit markieren',
'forge.actions.metadataChanged': 'Metadaten aktualisiert',
'forge.actions.posting': 'Wird gesendet…',
'forge.actions.remove': 'Entfernen',
'forge.actions.removed': 'Entfernt',
'forge.actions.reopen': 'Wieder öffnen',
'forge.actions.reply': 'Antworten',
'forge.actions.requestChanges': 'Änderungen anfordern',
'forge.actions.reviewBodyPlaceholder': 'Kommentar hinterlassen (optional)',
'forge.actions.reviewComment': 'Kommentieren',
'forge.actions.reviewDialogTitle': 'Review übermitteln',
'forge.actions.reviewed': 'Review gesendet',
'forge.actions.save': 'Speichern',
'forge.actions.setMilestone': 'Meilenstein festlegen',
'forge.actions.stateChanged': 'Status aktualisiert',
'forge.actions.updated': 'Aktualisiert',
'forge.author': 'Autor',
'forge.baseToHead': '{head} in {base}',
'forge.checks.empty': 'Keine Prüfungen',
+29
View File
@@ -1533,6 +1533,35 @@ export const dict = {
'filesView.editor.htmlPreviewTitle': 'HTML Preview',
'filesView.diagram.closeDiagramView': 'Close diagram view',
'filesView.diagram.saveDiagram': 'Save diagram',
'forge.actions.addAssignee': 'Add assignee',
'forge.actions.addLabel': 'Add label',
'forge.actions.added': 'Added',
'forge.actions.approve': 'Approve',
'forge.actions.cancel': 'Cancel',
'forge.actions.close': 'Close',
'forge.actions.closeConfirm': 'Close this issue/PR?',
'forge.actions.comment': 'Comment',
'forge.actions.commentPlaceholder': 'Add a comment…',
'forge.actions.draftChanged': 'Draft status updated',
'forge.actions.edit': 'Edit',
'forge.actions.error': 'Action failed',
'forge.actions.markDraft': 'Mark as draft',
'forge.actions.markReady': 'Mark ready',
'forge.actions.metadataChanged': 'Metadata updated',
'forge.actions.posting': 'Posting…',
'forge.actions.remove': 'Remove',
'forge.actions.removed': 'Removed',
'forge.actions.reopen': 'Reopen',
'forge.actions.reply': 'Reply',
'forge.actions.requestChanges': 'Request changes',
'forge.actions.reviewBodyPlaceholder': 'Leave a comment (optional)',
'forge.actions.reviewComment': 'Comment',
'forge.actions.reviewDialogTitle': 'Submit review',
'forge.actions.reviewed': 'Review submitted',
'forge.actions.save': 'Save',
'forge.actions.setMilestone': 'Set milestone',
'forge.actions.stateChanged': 'State updated',
'forge.actions.updated': 'Updated',
'forge.author': 'Author',
'forge.baseToHead': '{head} into {base}',
'forge.checks.empty': 'No checks',
+29
View File
@@ -1500,6 +1500,35 @@ export const dict: Record<I18nKey, string> = {
"filesView.diagram.closeDiagramView": "Cerrar vista de diagrama",
"filesView.diagram.saveDiagram": "Guardar diagrama",
'forge.actions.addAssignee': 'Agregar asignado',
'forge.actions.addLabel': 'Agregar etiqueta',
'forge.actions.added': 'Agregado',
'forge.actions.approve': 'Aprobar',
'forge.actions.cancel': 'Cancelar',
'forge.actions.close': 'Cerrar',
'forge.actions.closeConfirm': '¿Cerrar este problema/PR?',
'forge.actions.comment': 'Comentar',
'forge.actions.commentPlaceholder': 'Añadir un comentario…',
'forge.actions.draftChanged': 'Estado de borrador actualizado',
'forge.actions.edit': 'Editar',
'forge.actions.error': 'La acción falló',
'forge.actions.markDraft': 'Marcar como borrador',
'forge.actions.markReady': 'Marcar como listo',
'forge.actions.metadataChanged': 'Metadatos actualizados',
'forge.actions.posting': 'Publicando…',
'forge.actions.remove': 'Quitar',
'forge.actions.removed': 'Quitado',
'forge.actions.reopen': 'Reabrir',
'forge.actions.reply': 'Responder',
'forge.actions.requestChanges': 'Solicitar cambios',
'forge.actions.reviewBodyPlaceholder': 'Dejar un comentario (opcional)',
'forge.actions.reviewComment': 'Comentar',
'forge.actions.reviewDialogTitle': 'Enviar revisión',
'forge.actions.reviewed': 'Revisión enviada',
'forge.actions.save': 'Guardar',
'forge.actions.setMilestone': 'Establecer hito',
'forge.actions.stateChanged': 'Estado actualizado',
'forge.actions.updated': 'Actualizado',
'forge.author': 'Autor',
'forge.baseToHead': '{head} en {base}',
'forge.checks.empty': 'Sin comprobaciones',
+29
View File
@@ -3206,6 +3206,35 @@ export const dict = {
'filesView.diagram.closeDiagramView': 'Fermer la vue diagramme',
'filesView.diagram.saveDiagram': 'Enregistrer le diagramme',
'forge.actions.addAssignee': 'Ajouter un responsable',
'forge.actions.addLabel': 'Ajouter un label',
'forge.actions.added': 'Ajouté',
'forge.actions.approve': 'Approuver',
'forge.actions.cancel': 'Annuler',
'forge.actions.close': 'Fermer',
'forge.actions.closeConfirm': 'Fermer ce problème/pull request ?',
'forge.actions.comment': 'Commenter',
'forge.actions.commentPlaceholder': 'Ajouter un commentaire…',
'forge.actions.draftChanged': 'Statut de brouillon mis à jour',
'forge.actions.edit': 'Modifier',
'forge.actions.error': 'Échec de laction',
'forge.actions.markDraft': 'Marquer comme brouillon',
'forge.actions.markReady': 'Marquer comme prêt',
'forge.actions.metadataChanged': 'Métadonnées mises à jour',
'forge.actions.posting': 'Envoi…',
'forge.actions.remove': 'Retirer',
'forge.actions.removed': 'Retiré',
'forge.actions.reopen': 'Rouvrir',
'forge.actions.reply': 'Répondre',
'forge.actions.requestChanges': 'Demander des modifications',
'forge.actions.reviewBodyPlaceholder': 'Laisser un commentaire (facultatif)',
'forge.actions.reviewComment': 'Commenter',
'forge.actions.reviewDialogTitle': 'Soumettre une revue',
'forge.actions.reviewed': 'Revue soumise',
'forge.actions.save': 'Enregistrer',
'forge.actions.setMilestone': 'Définir le jalon',
'forge.actions.stateChanged': 'Statut mis à jour',
'forge.actions.updated': 'Mis à jour',
'forge.author': 'Auteur',
'forge.baseToHead': '{head} dans {base}',
'forge.checks.empty': 'Aucune vérification',
+29
View File
@@ -1530,6 +1530,35 @@ export const dict: Record<I18nKey, string> = {
'filesView.diagram.closeDiagramView': 'ダイアグラムビューを閉じる',
'filesView.diagram.saveDiagram': 'ダイアグラムを保存',
'forge.actions.addAssignee': '担当者を追加',
'forge.actions.addLabel': 'ラベルを追加',
'forge.actions.added': '追加しました',
'forge.actions.approve': '承認',
'forge.actions.cancel': 'キャンセル',
'forge.actions.close': 'クローズ',
'forge.actions.closeConfirm': 'このIssue/PRをクローズしますか?',
'forge.actions.comment': 'コメント',
'forge.actions.commentPlaceholder': 'コメントを追加…',
'forge.actions.draftChanged': '下書きの状態を更新しました',
'forge.actions.edit': '編集',
'forge.actions.error': '操作に失敗しました',
'forge.actions.markDraft': '下書きにマーク',
'forge.actions.markReady': '準備完了にマーク',
'forge.actions.metadataChanged': 'メタデータを更新しました',
'forge.actions.posting': '投稿中…',
'forge.actions.remove': '削除',
'forge.actions.removed': '削除しました',
'forge.actions.reopen': '再オープン',
'forge.actions.reply': '返信',
'forge.actions.requestChanges': '変更をリクエスト',
'forge.actions.reviewBodyPlaceholder': 'コメントを残す(任意)',
'forge.actions.reviewComment': 'コメント',
'forge.actions.reviewDialogTitle': 'レビューを送信',
'forge.actions.reviewed': 'レビューを送信しました',
'forge.actions.save': '保存',
'forge.actions.setMilestone': 'マイルストーンを設定',
'forge.actions.stateChanged': '状態を更新しました',
'forge.actions.updated': '更新しました',
'forge.author': '作成者',
'forge.baseToHead': '{head} を {base} に',
'forge.checks.empty': 'チェックなし',
+29
View File
@@ -1536,6 +1536,35 @@ export const dict: Record<I18nKey, string> = {
'filesView.diagram.closeDiagramView': '다이어그램 보기 닫기',
'filesView.diagram.saveDiagram': '다이어그램 저장',
'forge.actions.addAssignee': '담당자 추가',
'forge.actions.addLabel': '레이블 추가',
'forge.actions.added': '추가됨',
'forge.actions.approve': '승인',
'forge.actions.cancel': '취소',
'forge.actions.close': '닫기',
'forge.actions.closeConfirm': '이 이슈/PR을 닫을까요?',
'forge.actions.comment': '댓글',
'forge.actions.commentPlaceholder': '댓글 추가…',
'forge.actions.draftChanged': '초안 상태가 업데이트됨',
'forge.actions.edit': '편집',
'forge.actions.error': '작업 실패',
'forge.actions.markDraft': '초안으로 표시',
'forge.actions.markReady': '준비 완료로 표시',
'forge.actions.metadataChanged': '메타데이터가 업데이트됨',
'forge.actions.posting': '게시 중…',
'forge.actions.remove': '제거',
'forge.actions.removed': '제거됨',
'forge.actions.reopen': '다시 열기',
'forge.actions.reply': '답글',
'forge.actions.requestChanges': '변경 요청',
'forge.actions.reviewBodyPlaceholder': '댓글 남기기(선택 사항)',
'forge.actions.reviewComment': '댓글',
'forge.actions.reviewDialogTitle': '리뷰 제출',
'forge.actions.reviewed': '리뷰가 제출됨',
'forge.actions.save': '저장',
'forge.actions.setMilestone': '마일스톤 설정',
'forge.actions.stateChanged': '상태가 업데이트됨',
'forge.actions.updated': '업데이트됨',
'forge.author': '작성자',
'forge.baseToHead': '{head} → {base}',
'forge.checks.empty': '확인 항목 없음',
+29
View File
@@ -2017,6 +2017,35 @@ export const dict: Record<I18nKey, string> = {
'filesView.diagram.closeDiagramView': 'Zamknij widok diagramu',
'filesView.diagram.saveDiagram': 'Zapisz diagram',
'forge.actions.addAssignee': 'Dodaj przydzielonego',
'forge.actions.addLabel': 'Dodaj etykietę',
'forge.actions.added': 'Dodano',
'forge.actions.approve': 'Zatwierdź',
'forge.actions.cancel': 'Anuluj',
'forge.actions.close': 'Zamknij',
'forge.actions.closeConfirm': 'Zamknąć to zgłoszenie/PR?',
'forge.actions.comment': 'Skomentuj',
'forge.actions.commentPlaceholder': 'Dodaj komentarz…',
'forge.actions.draftChanged': 'Zaktualizowano status wersji roboczej',
'forge.actions.edit': 'Edytuj',
'forge.actions.error': 'Operacja nie powiodła się',
'forge.actions.markDraft': 'Oznacz jako wersję roboczą',
'forge.actions.markReady': 'Oznacz jako gotowe',
'forge.actions.metadataChanged': 'Zaktualizowano metadane',
'forge.actions.posting': 'Publikowanie…',
'forge.actions.remove': 'Usuń',
'forge.actions.removed': 'Usunięto',
'forge.actions.reopen': 'Otwórz ponownie',
'forge.actions.reply': 'Odpowiedz',
'forge.actions.requestChanges': 'Zażądaj zmian',
'forge.actions.reviewBodyPlaceholder': 'Zostaw komentarz (opcjonalnie)',
'forge.actions.reviewComment': 'Skomentuj',
'forge.actions.reviewDialogTitle': 'Prześlij przegląd',
'forge.actions.reviewed': 'Przesłano przegląd',
'forge.actions.save': 'Zapisz',
'forge.actions.setMilestone': 'Ustaw kamień milowy',
'forge.actions.stateChanged': 'Zaktualizowano stan',
'forge.actions.updated': 'Zaktualizowano',
'forge.author': 'Autor',
'forge.baseToHead': '{head} do {base}',
'forge.checks.empty': 'Brak kontroli',
@@ -1500,6 +1500,35 @@ export const dict: Record<I18nKey, string> = {
"filesView.diagram.closeDiagramView": "Fechar visualização de diagrama",
"filesView.diagram.saveDiagram": "Salvar diagrama",
'forge.actions.addAssignee': 'Adicionar responsável',
'forge.actions.addLabel': 'Adicionar etiqueta',
'forge.actions.added': 'Adicionado',
'forge.actions.approve': 'Aprovar',
'forge.actions.cancel': 'Cancelar',
'forge.actions.close': 'Fechar',
'forge.actions.closeConfirm': 'Fechar este problema/PR?',
'forge.actions.comment': 'Comentar',
'forge.actions.commentPlaceholder': 'Adicionar um comentário…',
'forge.actions.draftChanged': 'Status de rascunho atualizado',
'forge.actions.edit': 'Editar',
'forge.actions.error': 'A ação falhou',
'forge.actions.markDraft': 'Marcar como rascunho',
'forge.actions.markReady': 'Marcar como pronto',
'forge.actions.metadataChanged': 'Metadados atualizados',
'forge.actions.posting': 'Publicando…',
'forge.actions.remove': 'Remover',
'forge.actions.removed': 'Removido',
'forge.actions.reopen': 'Reabrir',
'forge.actions.reply': 'Responder',
'forge.actions.requestChanges': 'Solicitar alterações',
'forge.actions.reviewBodyPlaceholder': 'Deixar um comentário (opcional)',
'forge.actions.reviewComment': 'Comentar',
'forge.actions.reviewDialogTitle': 'Enviar revisão',
'forge.actions.reviewed': 'Revisão enviada',
'forge.actions.save': 'Salvar',
'forge.actions.setMilestone': 'Definir marco',
'forge.actions.stateChanged': 'Status atualizado',
'forge.actions.updated': 'Atualizado',
'forge.author': 'Autor',
'forge.baseToHead': '{head} em {base}',
'forge.checks.empty': 'Sem verificações',
+29
View File
@@ -1500,6 +1500,35 @@ export const dict: Record<I18nKey, string> = {
"filesView.diagram.closeDiagramView": "Закрити перегляд діаграми",
"filesView.diagram.saveDiagram": "Зберегти діаграму",
'forge.actions.addAssignee': 'Додати виконавця',
'forge.actions.addLabel': 'Додати мітку',
'forge.actions.added': 'Додано',
'forge.actions.approve': 'Схвалити',
'forge.actions.cancel': 'Скасувати',
'forge.actions.close': 'Закрити',
'forge.actions.closeConfirm': 'Закрити цей issue/PR?',
'forge.actions.comment': 'Прокоментувати',
'forge.actions.commentPlaceholder': 'Додати коментар…',
'forge.actions.draftChanged': 'Статус чернетки оновлено',
'forge.actions.edit': 'Редагувати',
'forge.actions.error': 'Дія не вдалася',
'forge.actions.markDraft': 'Позначити як чернетку',
'forge.actions.markReady': 'Позначити як готовий',
'forge.actions.metadataChanged': 'Метадані оновлено',
'forge.actions.posting': 'Публікація…',
'forge.actions.remove': 'Видалити',
'forge.actions.removed': 'Видалено',
'forge.actions.reopen': 'Відкрити знову',
'forge.actions.reply': 'Відповісти',
'forge.actions.requestChanges': 'Запитати зміни',
'forge.actions.reviewBodyPlaceholder': 'Залишити коментар (необов’язково)',
'forge.actions.reviewComment': 'Прокоментувати',
'forge.actions.reviewDialogTitle': 'Надіслати рев’ю',
'forge.actions.reviewed': 'Рев’ю надіслано',
'forge.actions.save': 'Зберегти',
'forge.actions.setMilestone': 'Встановити віху',
'forge.actions.stateChanged': 'Статус оновлено',
'forge.actions.updated': 'Оновлено',
'forge.author': 'Автор',
'forge.baseToHead': '{head} у {base}',
'forge.checks.empty': 'Перевірок немає',
@@ -1500,6 +1500,35 @@ export const dict: Record<I18nKey, string> = {
'filesView.diagram.closeDiagramView': '关闭图表视图',
'filesView.diagram.saveDiagram': '保存图表',
'forge.actions.addAssignee': '添加指派对象',
'forge.actions.addLabel': '添加标签',
'forge.actions.added': '已添加',
'forge.actions.approve': '批准',
'forge.actions.cancel': '取消',
'forge.actions.close': '关闭',
'forge.actions.closeConfirm': '关闭此问题/PR',
'forge.actions.comment': '评论',
'forge.actions.commentPlaceholder': '添加评论…',
'forge.actions.draftChanged': '草稿状态已更新',
'forge.actions.edit': '编辑',
'forge.actions.error': '操作失败',
'forge.actions.markDraft': '标记为草稿',
'forge.actions.markReady': '标记为就绪',
'forge.actions.metadataChanged': '元数据已更新',
'forge.actions.posting': '发布中…',
'forge.actions.remove': '移除',
'forge.actions.removed': '已移除',
'forge.actions.reopen': '重新打开',
'forge.actions.reply': '回复',
'forge.actions.requestChanges': '请求更改',
'forge.actions.reviewBodyPlaceholder': '留下评论(可选)',
'forge.actions.reviewComment': '评论',
'forge.actions.reviewDialogTitle': '提交审查',
'forge.actions.reviewed': '审查已提交',
'forge.actions.save': '保存',
'forge.actions.setMilestone': '设置里程碑',
'forge.actions.stateChanged': '状态已更新',
'forge.actions.updated': '已更新',
'forge.author': '作者',
'forge.baseToHead': '{head} 合入 {base}',
'forge.checks.empty': '无检查项',
@@ -1510,6 +1510,35 @@ export const dict: Record<I18nKey, string> = {
'filesView.diagram.closeDiagramView': '關閉圖表檢視',
'filesView.diagram.saveDiagram': '儲存圖表',
'forge.actions.addAssignee': '新增指派對象',
'forge.actions.addLabel': '新增標籤',
'forge.actions.added': '已新增',
'forge.actions.approve': '核准',
'forge.actions.cancel': '取消',
'forge.actions.close': '關閉',
'forge.actions.closeConfirm': '關閉此 Issue/PR',
'forge.actions.comment': '留言',
'forge.actions.commentPlaceholder': '新增留言…',
'forge.actions.draftChanged': '草稿狀態已更新',
'forge.actions.edit': '編輯',
'forge.actions.error': '操作失敗',
'forge.actions.markDraft': '標記為草稿',
'forge.actions.markReady': '標記為就緒',
'forge.actions.metadataChanged': '中繼資料已更新',
'forge.actions.posting': '發布中…',
'forge.actions.remove': '移除',
'forge.actions.removed': '已移除',
'forge.actions.reopen': '重新開啟',
'forge.actions.reply': '回覆',
'forge.actions.requestChanges': '要求變更',
'forge.actions.reviewBodyPlaceholder': '留下留言(選填)',
'forge.actions.reviewComment': '留言',
'forge.actions.reviewDialogTitle': '提交審查',
'forge.actions.reviewed': '審查已提交',
'forge.actions.save': '儲存',
'forge.actions.setMilestone': '設定里程碑',
'forge.actions.stateChanged': '狀態已更新',
'forge.actions.updated': '已更新',
'forge.author': '作者',
'forge.baseToHead': '{head} 合入 {base}',
'forge.checks.empty': '無檢查項目',