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:
@@ -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}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 l’action',
|
||||
'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',
|
||||
|
||||
@@ -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': 'チェックなし',
|
||||
|
||||
@@ -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': '확인 항목 없음',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '無檢查項目',
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
### Client (`client.js`)
|
||||
|
||||
- `createGiteaClient({ token, baseUrl })`: raw-fetch REST v1 client with `request(path, { method, query, body, signal, raw })` plus convenience methods `user()`, `repo(owner, repo)`, `issues(owner, repo, params)`, `issue(owner, repo, number)`, `issueComments(owner, repo, number, params)`, `pullRequests(owner, repo, params)`, `pullRequest(owner, repo, number)`, `pullRequestDiff(owner, repo, number)` (raw `.diff` text via the `raw` option), `pullRequestFiles(owner, repo, number, params)`, `pullRequestCommits(owner, repo, number, params)`, `pullRequestReviews(owner, repo, number, params)`, `commitStatuses(owner, repo, sha, params)`, `createPullRequest(owner, repo, body)`, `updatePullRequest(owner, repo, number, body)` (PATCH), `mergePullRequest(owner, repo, number, body)` (POST), `branches(owner, repo, params)`.
|
||||
- `createGiteaClient({ token, baseUrl })`: raw-fetch REST v1 client with `request(path, { method, query, body, signal, raw })` plus convenience methods `user()`, `repo(owner, repo)`, `issues(owner, repo, params)`, `issue(owner, repo, number)`, `issueComments(owner, repo, number, params)`, `createIssueComment(owner, repo, number, body)`, `updateIssue(owner, repo, number, params)` (PATCH), `milestones(owner, repo, params)`, `repoLabels(owner, repo, params)`, `pullRequests(owner, repo, params)`, `pullRequest(owner, repo, number)`, `pullRequestDiff(owner, repo, number)` (raw `.diff` text via the `raw` option), `pullRequestFiles(owner, repo, number, params)`, `pullRequestCommits(owner, repo, number, params)`, `pullRequestReviews(owner, repo, number, params)`, `createPullReview(owner, repo, number, params)` (POST), `commitStatuses(owner, repo, sha, params)`, `createPullRequest(owner, repo, body)`, `updatePullRequest(owner, repo, number, body)` (PATCH), `mergePullRequest(owner, repo, number, body)` (POST), `branches(owner, repo, params)`.
|
||||
- `getGiteaClientOrNull()`: client for the current account, or `null`.
|
||||
- `isGiteaRateLimited()` / `noteGiteaRateLimit(error)`: own module-level rate-limit cooldown (not shared with the GitHub/GitLab modules).
|
||||
|
||||
@@ -76,8 +76,13 @@
|
||||
- PR reviews: `GET /repos/{owner}/{repo}/pulls/{number}/reviews?limit=100` (mapped to `{ id, state, author, submittedAt, body, commitSha }`; `state` passes through, e.g. `APPROVED`/`REQUEST_CHANGES`).
|
||||
- Commit statuses: `GET /repos/{owner}/{repo}/commits/{sha}/statuses?limit=100` (the `prs/statuses` route resolves the PR `head.sha` first, then maps statuses to `{ state, name, description, url, createdAt }` with `state` lowercased).
|
||||
- PR create: `POST /repos/{owner}/{repo}/pulls` with `{ title, head, base, body? }` (body omitted when absent).
|
||||
- PR update: `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{ title?, body? }` (undefined fields omitted).
|
||||
- PR update: `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{ title?, body?, state? }` (undefined fields omitted; the PR number IS the issue index, so the edit-issue `state` transition applies directly).
|
||||
- PR merge: `POST /repos/{owner}/{repo}/pulls/{number}/merge` with `{ Do: true, MergeMethod: 'merge' | 'squash' | 'rebase' }` (`method` defaults to `'merge'`).
|
||||
- Issue comment write: `POST /repos/{owner}/{repo}/issues/{number}/comments` with `{ body }` (PRs are issues at the API level, so `prs/comment` uses the same endpoint with the PR number as the index).
|
||||
- Issue update: `PATCH /repos/{owner}/{repo}/issues/{number}` with `{ title?, body?, state?, labels?, assignees?, milestone?, unset_milestone? }` (labels are label **names**, assignees are logins; `milestone` is resolved from a title to a milestone id and `null` sets `unset_milestone: true`).
|
||||
- Pull review write: `POST /repos/{owner}/{repo}/pulls/{number}/reviews` with `{ event, body? }` (`event` is `APPROVED`/`REQUEST_CHANGES`/`COMMENT`).
|
||||
- Milestones: `GET /repos/{owner}/{repo}/milestones?state=all&limit=50` (first page) for title-to-id resolution on issue updates.
|
||||
- Repo labels: `GET /repos/{owner}/{repo}/labels?limit=100` (first page) so metadata editors can offer existing labels.
|
||||
- Branches: `GET /repos/{owner}/{repo}/branches?limit=50&page=N` mapped to names, plus `GET /repos/{owner}/{repo}` for `default_branch` (Gitea branch objects carry no default flag).
|
||||
- There is **no ready-for-review endpoint** in this module (Gitea has no GitLab-style ready_for_review action).
|
||||
|
||||
@@ -99,8 +104,13 @@
|
||||
| GET | `/api/gitea/prs/reviews` | `?directory&number&owner&repo` -> `{ connected, repo?, reviews[] }` |
|
||||
| GET | `/api/gitea/prs/statuses` | `?directory&number&owner&repo` -> `{ connected, repo?, statuses[] }` (resolves the PR `head.sha` first, then lists commit statuses for that SHA) |
|
||||
| POST | `/api/gitea/pr/create` | body `{ directory, title, sourceBranch, targetBranch, description? }` -> `{ connected, repo?, pr }`; `400` for missing fields or an unresolvable repo |
|
||||
| PATCH | `/api/gitea/pr/update` | body `{ directory, number, title?, description? }` -> `{ connected, repo?, pr }`; `404` when the PR does not exist |
|
||||
| PATCH | `/api/gitea/pr/update` | body `{ directory, number, title?, description?, state? }` -> `{ connected, repo?, pr }`; `404` when the PR does not exist |
|
||||
| POST | `/api/gitea/pr/merge` | body `{ directory, number, method? }` -> `{ connected, merged: true }` on success; non-mergeable PRs -> the Gitea status (`405`/`409`/`422`) with `{ connected, merged: false, message }` |
|
||||
| POST | `/api/gitea/issues/comment` | body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment }` |
|
||||
| PATCH | `/api/gitea/issues/update` | body `{ directory, number, title?, body?, state?, labels?, assignees?, milestone?, owner?, repo? }` -> `{ connected, repo?, issue }`; `400 'Milestone not found'` when a milestone title does not match |
|
||||
| POST | `/api/gitea/prs/comment` | body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment }` (PRs are issues at the API level, so the PR number is the issue index) |
|
||||
| POST | `/api/gitea/prs/review` | body `{ directory, number, event, body?, owner?, repo? }` -> `{ connected, repo?, review }`; `400` when `event` is not `APPROVED`/`REQUEST_CHANGES`/`COMMENT` |
|
||||
| GET | `/api/gitea/repo/labels` | `?directory&owner&repo` -> `{ connected, repo?, labels[] }` |
|
||||
| GET | `/api/gitea/repo/branches` | `?owner&repo` -> `{ branches[], defaultBranch? }` (`defaultBranch` is `null` when Gitea is disconnected or the repo has no default) |
|
||||
|
||||
Conventions mirror `github/routes.js` and `gitlab/routes.js`:
|
||||
@@ -110,8 +120,8 @@ Conventions mirror `github/routes.js` and `gitlab/routes.js`:
|
||||
- Hard failures -> `4xx`/`5xx` with `{ error }`.
|
||||
- A Gitea `429` -> `503 { error: 'Gitea rate limited' }`.
|
||||
- Lazy-import pattern: route handlers import `./index.js` on first use, so the module never loads unless Gitea endpoints are hit.
|
||||
- Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout.
|
||||
- Repo targeting: `owner`/`repo` query params override the directory-local git remote.
|
||||
- Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout. Write routes deliberately skip the route-level timeout (a timeout can orphan a write); the client's per-request timeout still bounds them.
|
||||
- Repo targeting: `owner`/`repo` query params override the directory-local git remote; write routes also accept them in the JSON body.
|
||||
|
||||
## Consumers
|
||||
|
||||
@@ -124,6 +134,7 @@ Conventions mirror `github/routes.js` and `gitlab/routes.js`:
|
||||
- A repo that does not resolve from the local git remote yields `repo: null` with empty lists, matching GitHub/GitLab behavior. Write routes reject an unresolvable repo with `400 { error: 'Unable to resolve Gitea repo from directory' }`.
|
||||
- Invalid/expired tokens are cleared on `401`/`403` and reported as disconnected.
|
||||
- Gitea `403` on write routes means the token lacks repository write scope; they respond `400 { error: 'Your Gitea token needs write:repository scope to ...' }`.
|
||||
- Milestone titles on issue updates are resolved against `GET /repos/{owner}/{repo}/milestones`; an unmatched title yields `400 { error: 'Milestone not found' }` and `null` sets `unset_milestone: true`.
|
||||
- PR merge rejections (`405`/`409`/`422` from Gitea) are surfaced as `{ connected, merged: false, message }` with the Gitea status so clients can show the message without treating it as a transport error (mirrors `github/pr/merge`).
|
||||
- The pull-files endpoint returning `404` (older Gitea) yields `files: []` instead of failing the whole PR context; a missing `.diff` falls back to concatenated patches.
|
||||
- Rate-limit and timeout failures surface explicit `503` responses so clients keep last-known state rather than clearing UI.
|
||||
@@ -134,4 +145,4 @@ Conventions mirror `github/routes.js` and `gitlab/routes.js`:
|
||||
- Never log tokens. Error messages must not include the access token.
|
||||
- The ETag cache and rate-limit cooldown are module-level and per-instance — they are NOT shared with the GitHub or GitLab modules.
|
||||
- Gitea `GET /user` returns `login`/`full_name`/`html_url`; the route mappers accept the GitHub-style `username`/`name`/`web_url` variants too, so Forgejo versions that differ still map.
|
||||
- To add further Gitea write operations (comment, assign, issue writes), add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing PR write routes and the GitHub PR write routes.
|
||||
- To add further Gitea write operations, add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing issue/PR write routes and the GitHub PR write routes.
|
||||
|
||||
@@ -263,6 +263,14 @@ export function createGiteaClient({ token, baseUrl }) {
|
||||
request(`/repos/${owner}/${repo}/issues/${number}`),
|
||||
issueComments: (owner, repo, number, params = {}) =>
|
||||
request(`/repos/${owner}/${repo}/issues/${number}/comments`, { query: params }),
|
||||
createIssueComment: (owner, repo, number, body) =>
|
||||
request(`/repos/${owner}/${repo}/issues/${number}/comments`, { method: 'POST', body: { body } }),
|
||||
updateIssue: (owner, repo, number, params) =>
|
||||
request(`/repos/${owner}/${repo}/issues/${number}`, { method: 'PATCH', body: params }),
|
||||
milestones: (owner, repo, params = {}) =>
|
||||
request(`/repos/${owner}/${repo}/milestones`, { query: params }),
|
||||
repoLabels: (owner, repo, params = {}) =>
|
||||
request(`/repos/${owner}/${repo}/labels`, { query: params }),
|
||||
pullRequests: (owner, repo, params = {}) =>
|
||||
request(`/repos/${owner}/${repo}/pulls`, { query: params }),
|
||||
pullRequest: (owner, repo, number) =>
|
||||
@@ -275,6 +283,8 @@ export function createGiteaClient({ token, baseUrl }) {
|
||||
request(`/repos/${owner}/${repo}/pulls/${number}/commits`, { query: params }),
|
||||
pullRequestReviews: (owner, repo, number, params = {}) =>
|
||||
request(`/repos/${owner}/${repo}/pulls/${number}/reviews`, { query: params }),
|
||||
createPullReview: (owner, repo, number, params) =>
|
||||
request(`/repos/${owner}/${repo}/pulls/${number}/reviews`, { method: 'POST', body: params }),
|
||||
commitStatuses: (owner, repo, sha, params = {}) =>
|
||||
request(`/repos/${owner}/${repo}/commits/${sha}/statuses`, { query: params }),
|
||||
createPullRequest: (owner, repo, body) =>
|
||||
|
||||
@@ -266,6 +266,70 @@ describe('pull request write methods', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue, review, and repo write methods', () => {
|
||||
test('createIssueComment POSTs a body to the issue comments endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ id: 5, body: 'hi' }, { status: 201 }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
const result = await client.createIssueComment('owner', 'repo', 7, 'Nice catch');
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/issues/7/comments');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
|
||||
expect(result.status).toBe(201);
|
||||
});
|
||||
|
||||
test('updateIssue PATCHes params to the issue endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ number: 7, title: 'Updated' }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.updateIssue('owner', 'repo', 7, { state: 'closed', labels: ['bug'], milestone: 33 });
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/issues/7');
|
||||
expect(options.method).toBe('PATCH');
|
||||
expect(JSON.parse(options.body)).toEqual({ state: 'closed', labels: ['bug'], milestone: 33 });
|
||||
});
|
||||
|
||||
test('createPullReview POSTs event/body to the reviews endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ id: 101, state: 'APPROVED' }, { status: 201 }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.createPullReview('owner', 'repo', 12, { event: 'APPROVED', body: 'LGTM' });
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls/12/reviews');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ event: 'APPROVED', body: 'LGTM' });
|
||||
});
|
||||
|
||||
test('milestones GETs the repo milestones list', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse([{ id: 33, title: 'v1.0' }]));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.milestones('owner', 'repo', { state: 'all', limit: 50 });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/milestones?state=all&limit=50');
|
||||
});
|
||||
|
||||
test('repoLabels GETs the repo labels list', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse([{ id: 1, name: 'bug', color: 'd73a4a' }]));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.repoLabels('owner', 'repo', { limit: 100 });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/labels?limit=100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limiting', () => {
|
||||
// NOTE: these tests run last in this file. The rate-limit cooldown is
|
||||
// module-level and has no reset export, so earlier tests must not set one.
|
||||
|
||||
@@ -23,9 +23,12 @@ function withTimeout(promise, timeoutMs, label) {
|
||||
|
||||
const asString = (value) => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
// Resolve the requested repo from the query (read routes) or the JSON body
|
||||
// (write routes). `owner`/`repo` override the directory-local git remote for
|
||||
// repos checked out from non-Gitea remotes.
|
||||
const getRequestedRepo = (req) => {
|
||||
const owner = asString(req.query?.owner);
|
||||
const repo = asString(req.query?.repo);
|
||||
const owner = asString(req.query?.owner) || asString(req.body?.owner);
|
||||
const repo = asString(req.query?.repo) || asString(req.body?.repo);
|
||||
return owner && repo ? { owner, repo } : null;
|
||||
};
|
||||
|
||||
@@ -130,6 +133,13 @@ const mapGiteaComment = (comment) => ({
|
||||
createdAt: typeof comment.created_at === 'string' ? comment.created_at : undefined,
|
||||
});
|
||||
|
||||
const mapGiteaIssue = (item) => ({
|
||||
...mapGiteaIssueSummary(item),
|
||||
body: typeof item.body === 'string' ? item.body : '',
|
||||
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
|
||||
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
|
||||
});
|
||||
|
||||
// Gitea's pull-files endpoint returns capitalized JSON fields
|
||||
// (Filename/Status/Additions/Deletions/Patch); tolerate the lowercase GitHub
|
||||
// style too for Forgejo versions that match GitHub output.
|
||||
@@ -156,6 +166,24 @@ const giteaErrorMessage = (data) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Gitea issue/PR update endpoints take `milestone` (numeric), not the title.
|
||||
// Resolve a title via the repo milestones list (first page is enough for
|
||||
// title-based lookups); unmatched titles yield `milestoneId: null` so routes
|
||||
// can surface `400 { error: 'Milestone not found' }`.
|
||||
const resolveMilestoneId = async (client, owner, repo, title) => {
|
||||
const resp = await client.milestones(owner, repo, { state: 'all', limit: 50 });
|
||||
if (resp.status === 429) {
|
||||
return { milestoneId: null, rateLimited: true };
|
||||
}
|
||||
if (resp.status !== 200 || !Array.isArray(resp.data)) {
|
||||
return { milestoneId: null, rateLimited: false };
|
||||
}
|
||||
const match = resp.data.find(
|
||||
(item) => typeof item?.title === 'string' && item.title.toLowerCase() === title.toLowerCase(),
|
||||
);
|
||||
return { milestoneId: typeof match?.id === 'number' ? match.id : null, rateLimited: false };
|
||||
};
|
||||
|
||||
const repoRefFromOwnerRepo = (owner, repo, baseUrl) => {
|
||||
let host = null;
|
||||
let normalizedBaseUrl = null;
|
||||
@@ -442,12 +470,7 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
}
|
||||
|
||||
const item = resp.data;
|
||||
const issue = {
|
||||
...mapGiteaIssueSummary(item),
|
||||
body: typeof item.body === 'string' ? item.body : '',
|
||||
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
|
||||
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
|
||||
};
|
||||
const issue = mapGiteaIssue(item);
|
||||
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), issue });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Gitea issue:', error);
|
||||
@@ -497,6 +520,135 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gitea/issues/comment', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||
if (!directory || !number || !body) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
||||
if (!owner || !repo) {
|
||||
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
||||
}
|
||||
|
||||
const resp = await client.createIssueComment(owner, repo, number, body);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'Gitea rate limited' });
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to comment on issues' });
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return res.status(404).json({ error: 'Issue not found' });
|
||||
}
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
const status = resp.status >= 500 ? 500 : 400;
|
||||
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while creating the comment' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'Gitea returned an empty response while creating the comment' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
||||
comment: mapGiteaComment(resp.data),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create Gitea issue comment:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to create Gitea issue comment' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/gitea/issues/update', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
if (!directory || !number) {
|
||||
return res.status(400).json({ error: 'directory and number are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
||||
if (!owner || !repo) {
|
||||
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
||||
}
|
||||
|
||||
const body = {};
|
||||
if (typeof req.body?.title === 'string') {
|
||||
body.title = req.body.title.trim();
|
||||
}
|
||||
if (typeof req.body?.body === 'string') {
|
||||
body.body = req.body.body;
|
||||
}
|
||||
if (req.body?.state === 'open' || req.body?.state === 'closed') {
|
||||
body.state = req.body.state;
|
||||
}
|
||||
// Gitea accepts label names (not ids) in the edit-issue payload.
|
||||
if (Array.isArray(req.body?.labels)) {
|
||||
body.labels = req.body.labels.filter((label) => typeof label === 'string');
|
||||
}
|
||||
if (Array.isArray(req.body?.assignees)) {
|
||||
body.assignees = req.body.assignees.filter((login) => typeof login === 'string');
|
||||
}
|
||||
if (req.body?.milestone !== undefined) {
|
||||
if (req.body.milestone === null) {
|
||||
body.unset_milestone = true;
|
||||
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
|
||||
const { milestoneId, rateLimited } = await resolveMilestoneId(client, owner, repo, req.body.milestone.trim());
|
||||
if (rateLimited) {
|
||||
return res.status(503).json({ error: 'Gitea rate limited' });
|
||||
}
|
||||
if (milestoneId === null) {
|
||||
return res.status(400).json({ error: 'Milestone not found' });
|
||||
}
|
||||
body.milestone = milestoneId;
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await client.updateIssue(owner, repo, number, body);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'Gitea rate limited' });
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to update issues' });
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return res.status(404).json({ error: 'Issue not found' });
|
||||
}
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
const status = resp.status >= 500 ? 500 : 400;
|
||||
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while updating the issue' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'Gitea returned an empty response while updating the issue' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
||||
issue: mapGiteaIssue(resp.data),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update Gitea issue:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to update Gitea issue' });
|
||||
}
|
||||
});
|
||||
|
||||
// ================= Gitea Pull Request APIs =================
|
||||
|
||||
app.get('/api/gitea/prs/list', async (req, res) => {
|
||||
@@ -963,6 +1115,11 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
if (description !== undefined) {
|
||||
body.body = description;
|
||||
}
|
||||
// PRs are issues at the API level in Gitea (the PR number IS the issue
|
||||
// index), so the edit-issue `state` transition applies directly.
|
||||
if (req.body?.state === 'open' || req.body?.state === 'closed') {
|
||||
body.state = req.body.state;
|
||||
}
|
||||
|
||||
const resp = await withTimeout(client.updatePullRequest(owner, repo, number, body), ROUTE_TIMEOUT_MS, 'gitea pr update');
|
||||
if (resp.status === 429) {
|
||||
@@ -1047,6 +1204,122 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// PRs are issues at the API level in Gitea, so a PR comment is an issue
|
||||
// comment addressed by the PR number (which IS the issue index).
|
||||
app.post('/api/gitea/prs/comment', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||
if (!directory || !number || !body) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
||||
if (!owner || !repo) {
|
||||
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
||||
}
|
||||
|
||||
const resp = await client.createIssueComment(owner, repo, number, body);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'Gitea rate limited' });
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to comment on pull requests' });
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return res.status(404).json({ error: 'Pull request not found' });
|
||||
}
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
const status = resp.status >= 500 ? 500 : 400;
|
||||
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while creating the comment' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'Gitea returned an empty response while creating the comment' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
||||
comment: mapGiteaComment(resp.data),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create Gitea pull request comment:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to create Gitea pull request comment' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gitea/prs/review', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const event = typeof req.body?.event === 'string' ? req.body.event : '';
|
||||
if (!directory || !number || !event) {
|
||||
return res.status(400).json({ error: 'directory, number, event are required' });
|
||||
}
|
||||
if (event !== 'APPROVED' && event !== 'REQUEST_CHANGES' && event !== 'COMMENT') {
|
||||
return res.status(400).json({ error: 'event must be APPROVED, REQUEST_CHANGES, or COMMENT' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
||||
if (!owner || !repo) {
|
||||
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
||||
}
|
||||
|
||||
const params = { event };
|
||||
if (typeof req.body?.body === 'string' && req.body.body) {
|
||||
params.body = req.body.body;
|
||||
}
|
||||
|
||||
const resp = await client.createPullReview(owner, repo, number, params);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'Gitea rate limited' });
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to review pull requests' });
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return res.status(404).json({ error: 'Pull request not found' });
|
||||
}
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
const status = resp.status >= 500 ? 500 : 400;
|
||||
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while submitting the review' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'Gitea returned an empty response while submitting the review' });
|
||||
}
|
||||
|
||||
const review = resp.data;
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
||||
review: {
|
||||
id: String(review.id),
|
||||
state: typeof review.state === 'string' ? review.state : event,
|
||||
author: mapGiteaAuthor(review.user) || null,
|
||||
...(typeof review.submitted_at === 'string' ? { submittedAt: review.submitted_at } : {}),
|
||||
body: typeof review.body === 'string' ? review.body : null,
|
||||
...(typeof review.commit_id === 'string' ? { commitSha: review.commit_id } : null),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to submit Gitea pull request review:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to submit Gitea pull request review' });
|
||||
}
|
||||
});
|
||||
|
||||
// ================= Gitea Repo APIs =================
|
||||
|
||||
app.get('/api/gitea/repo/branches', async (req, res) => {
|
||||
@@ -1101,4 +1374,48 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea repo branches' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/gitea/repo/labels', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.query?.directory);
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
if (!directory && !requestedRepo) {
|
||||
return res.status(400).json({ error: 'directory or owner/repo is required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false, labels: [] });
|
||||
}
|
||||
|
||||
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
||||
if (!owner || !repo) {
|
||||
return res.json({ connected: true, repo: null, labels: [] });
|
||||
}
|
||||
|
||||
const resp = await withTimeout(
|
||||
client.repoLabels(owner, repo, { limit: 100 }),
|
||||
ROUTE_TIMEOUT_MS,
|
||||
'gitea repo labels',
|
||||
);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'Gitea rate limited' });
|
||||
}
|
||||
if (resp.status !== 200) {
|
||||
return res.status(502).json({ error: 'Gitea returned an error while fetching repo labels' });
|
||||
}
|
||||
|
||||
const labels = (Array.isArray(resp.data) ? resp.data : []).map((label) => ({
|
||||
...(typeof label.id === 'number' ? { id: label.id } : {}),
|
||||
name: typeof label.name === 'string' ? label.name : '',
|
||||
...(typeof label.color === 'string' ? { color: label.color } : {}),
|
||||
...(typeof label.description === 'string' ? { description: label.description } : {}),
|
||||
}));
|
||||
|
||||
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), labels });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Gitea repo labels:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea repo labels' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1046,6 +1046,301 @@ describe('Gitea data routes', () => {
|
||||
expect(statuses.body).toMatchObject({ connected: false, statuses: [] });
|
||||
});
|
||||
|
||||
test('issues/comment POSTs a comment and maps it', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7\/comments$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({
|
||||
id: 5,
|
||||
body: 'Nice catch',
|
||||
html_url: 'https://gitea.example.com/owner/repo/issues/7#issuecomment-5',
|
||||
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
|
||||
created_at: '2026-01-02T11:00:00Z',
|
||||
}, { status: 201 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitea/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7, body: 'Nice catch' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' },
|
||||
comment: {
|
||||
id: 5,
|
||||
body: 'Nice catch',
|
||||
url: 'https://gitea.example.com/owner/repo/issues/7#issuecomment-5',
|
||||
author: { username: 'alice', id: 42 },
|
||||
createdAt: '2026-01-02T11:00:00Z',
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
|
||||
});
|
||||
|
||||
test('issues/comment reports connected:false when not authenticated', async () => {
|
||||
clearGiteaAuth();
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitea/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7, body: 'hello' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ connected: false });
|
||||
});
|
||||
|
||||
test('issues/update maps labels, assignees, state, and resolves the milestone title', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url) => (matches(/\/milestones\?/)(url)
|
||||
? jsonResponse([{ id: 33, title: 'v1.0', state: 'open' }])
|
||||
: null),
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url) && options.method === 'PATCH') {
|
||||
return jsonResponse({
|
||||
number: 7,
|
||||
title: 'Updated issue',
|
||||
html_url: 'https://gitea.example.com/owner/repo/issues/7',
|
||||
state: 'closed',
|
||||
body: 'New body',
|
||||
user: { id: 42, login: 'alice' },
|
||||
labels: [{ id: 1, name: 'bug' }],
|
||||
assignees: [{ id: 43, login: 'bob' }],
|
||||
milestone: { id: 33, title: 'v1.0', state: 'open' },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.patch('/api/gitea/issues/update')
|
||||
.send({
|
||||
directory: '/tmp/work',
|
||||
number: 7,
|
||||
title: 'Updated issue',
|
||||
body: 'New body',
|
||||
state: 'closed',
|
||||
labels: ['bug'],
|
||||
assignees: ['bob'],
|
||||
milestone: 'v1.0',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
issue: {
|
||||
number: 7,
|
||||
title: 'Updated issue',
|
||||
state: 'closed',
|
||||
body: 'New body',
|
||||
labels: ['bug'],
|
||||
assignees: [{ username: 'bob', id: 43 }],
|
||||
milestone: { title: 'v1.0', state: 'open' },
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[1];
|
||||
expect(options.method).toBe('PATCH');
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
title: 'Updated issue',
|
||||
body: 'New body',
|
||||
state: 'closed',
|
||||
labels: ['bug'],
|
||||
assignees: ['bob'],
|
||||
milestone: 33,
|
||||
});
|
||||
});
|
||||
|
||||
test('issues/update clears the milestone with unset_milestone', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url) && options.method === 'PATCH') {
|
||||
return jsonResponse({ number: 7, title: 'T', html_url: 'u', state: 'open', user: { login: 'alice' } });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
await request(app)
|
||||
.patch('/api/gitea/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, milestone: null });
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ unset_milestone: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('issues/update returns 400 when the milestone title does not match', async () => {
|
||||
scriptedFetch([
|
||||
(url) => (matches(/\/milestones\?/)(url)
|
||||
? jsonResponse([{ id: 33, title: 'v1.0' }])
|
||||
: null),
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.patch('/api/gitea/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, milestone: 'v2.0' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Milestone not found' });
|
||||
});
|
||||
|
||||
test('prs/comment POSTs a comment on the PR index and maps it', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/12\/comments$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({
|
||||
id: 8,
|
||||
body: 'LGTM',
|
||||
html_url: 'https://gitea.example.com/owner/repo/pulls/12#issuecomment-8',
|
||||
user: { id: 43, login: 'bob' },
|
||||
}, { status: 201 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitea/prs/comment')
|
||||
.send({ directory: '/tmp/work', number: 12, body: 'LGTM' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
comment: {
|
||||
id: 8,
|
||||
body: 'LGTM',
|
||||
url: 'https://gitea.example.com/owner/repo/pulls/12#issuecomment-8',
|
||||
author: { username: 'bob', id: 43 },
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'LGTM' });
|
||||
});
|
||||
|
||||
test('prs/review POSTs event/body and maps the review', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/pulls\/12\/reviews$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({
|
||||
id: 101,
|
||||
state: 'APPROVED',
|
||||
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
|
||||
submitted_at: '2026-01-02T11:00:00Z',
|
||||
body: 'LGTM',
|
||||
commit_id: 'abc123def4567890',
|
||||
}, { status: 201 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitea/prs/review')
|
||||
.send({ directory: '/tmp/work', number: 12, event: 'APPROVED', body: 'LGTM' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
review: {
|
||||
id: '101',
|
||||
state: 'APPROVED',
|
||||
author: { username: 'alice', id: 42 },
|
||||
submittedAt: '2026-01-02T11:00:00Z',
|
||||
body: 'LGTM',
|
||||
commitSha: 'abc123def4567890',
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ event: 'APPROVED', body: 'LGTM' });
|
||||
});
|
||||
|
||||
test('prs/review rejects an unsupported event with 400', async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitea/prs/review')
|
||||
.send({ directory: '/tmp/work', number: 12, event: 'PENDING' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'event must be APPROVED, REQUEST_CHANGES, or COMMENT' });
|
||||
});
|
||||
|
||||
test('repo/labels returns mapped labels', async () => {
|
||||
scriptedFetch([
|
||||
(url) => (matches(/\/labels\?/)(url)
|
||||
? jsonResponse([
|
||||
{ id: 1, name: 'bug', color: 'd73a4a', description: 'A bug' },
|
||||
{ id: 2, name: 'enhancement', color: 'a2eeef' },
|
||||
])
|
||||
: null),
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app).get('/api/gitea/repo/labels?directory=%2Ftmp%2Fwork');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' },
|
||||
labels: [
|
||||
{ id: 1, name: 'bug', color: 'd73a4a', description: 'A bug' },
|
||||
{ id: 2, name: 'enhancement', color: 'a2eeef' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('repo/labels requires directory or owner/repo', async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app).get('/api/gitea/repo/labels');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'directory or owner/repo is required' });
|
||||
});
|
||||
|
||||
test('pr/update passes state through to the PATCH payload', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/pulls\/12$/)(url) && options.method === 'PATCH') {
|
||||
return jsonResponse({
|
||||
number: 12,
|
||||
title: 'T',
|
||||
html_url: 'u',
|
||||
state: 'closed',
|
||||
merged: false,
|
||||
draft: false,
|
||||
user: { login: 'alice' },
|
||||
head: { ref: 'feat/add' },
|
||||
base: { ref: 'main' },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.patch('/api/gitea/pr/update')
|
||||
.send({ directory: '/tmp/work', number: 12, state: 'closed' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
pr: { number: 12, state: 'closed' },
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ state: 'closed' });
|
||||
});
|
||||
|
||||
// NOTE: keep this test last in the file. The rate-limit cooldown is
|
||||
// module-level and has no reset export, so tests after it would short-circuit.
|
||||
test('data routes surface a 503 when Gitea rate limits', async () => {
|
||||
scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse({}, { status: 429, headers: { 'retry-after': '30' } }) : null)]);
|
||||
|
||||
|
||||
@@ -67,6 +67,17 @@
|
||||
- `GET /api/github/pulls/timeline?directory&number&owner&repo` -> `{ connected, repo?, events[] }` (via `octokit.rest.issues.listEventsForTimeline`, each event `{ id, type, author, createdAt, body, commitSha }` with the event name lowercased).
|
||||
- Both follow the `issues/comments` envelope pattern: unauthenticated -> `connected: false`, unresolvable repo -> `repo: null` with an empty list, `429` -> `503 { error: 'GitHub rate limited' }`, other provider `4xx` -> `502`.
|
||||
|
||||
## Write APIs
|
||||
|
||||
All write routes accept an optional `owner`/`repo` in the body to target a fork-network repo; otherwise the repo is resolved from `directory`. Unauthenticated -> `{ connected: false }`; `429` -> `503 { error: 'GitHub rate limited' }`; generic failures -> `500` with a generic error (raw upstream text is never leaked).
|
||||
|
||||
- `POST /api/github/issues/comment` — body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment? }` (via `octokit.rest.issues.createComment`, mapped to `GitHubIssueComment`).
|
||||
- `PATCH /api/github/issues/update` — body `{ directory, number, title?, body?, state?, labels?, assignees?, milestone?, owner?, repo? }` -> `{ connected, repo?, issue? }` (via `octokit.rest.issues.update`; `labels`/`assignees` replace the full set, `milestone` is a title resolved to a milestone number — `400 { error: 'Milestone not found' }` when it matches nothing, `null` clears it). Also works for pull requests (PRs are issues), so it serves PR metadata/state changes too.
|
||||
- `POST /api/github/pulls/comment` — same input/result shape as `issues/comment`; posts to the PR's issue thread via `octokit.rest.issues.createComment`. Invalidates the PR context cache.
|
||||
- `POST /api/github/pulls/review-comment` — body `{ directory, number, body, inReplyToId?, path?, line?, owner?, repo? }` -> `{ connected, repo?, comment? }` (via `octokit.rest.pulls.createReviewComment`). With `inReplyToId` it is a reply; otherwise `path` + `line` are required and the PR head commit is resolved first. Invalidates the PR context cache.
|
||||
- `POST /api/github/pulls/review` — body `{ directory, number, event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', body?, owner?, repo? }` -> `{ connected, repo?, review? }` (via `octokit.rest.pulls.createReview`, mapped to `{ id, state, author, submittedAt, body, commitSha }`). Invalidates the PR context cache.
|
||||
- `POST /api/github/pr/update` — existing route extended with optional `state`, `draft`, `labels`, `assignees`, `milestone`. When any extended field is present it branches to `octokit.rest.issues.update` (milestone title -> number; `draft` applied separately via `octokit.rest.pulls.update`); title/body-only updates keep using `pulls.update`. Invalidates the PR context cache and the repo pulls cache.
|
||||
|
||||
## Consumers of PR data
|
||||
|
||||
- `packages/ui/src/components/session/SessionSidebar.tsx` reads all PR entries and maps them to `directory::branch`.
|
||||
|
||||
@@ -120,8 +120,14 @@ function withTimeout(promise, timeoutMs, label) {
|
||||
}
|
||||
|
||||
function getRequestedRepo(req) {
|
||||
const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : '';
|
||||
const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : '';
|
||||
// GET routes carry owner/repo in the query string; write routes (POST/PATCH)
|
||||
// carry them in the body. Accept both so the same resolver guards every route.
|
||||
const owner = typeof req.body?.owner === 'string' && req.body.owner.trim()
|
||||
? req.body.owner.trim()
|
||||
: (typeof req.query?.owner === 'string' ? req.query.owner.trim() : '');
|
||||
const repo = typeof req.body?.repo === 'string' && req.body.repo.trim()
|
||||
? req.body.repo.trim()
|
||||
: (typeof req.query?.repo === 'string' ? req.query.repo.trim() : '');
|
||||
return owner && repo ? { owner, repo } : null;
|
||||
}
|
||||
|
||||
@@ -976,15 +982,74 @@ export function registerGitHubRoutes(app) {
|
||||
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
||||
}
|
||||
|
||||
const state = req.body?.state === 'open' || req.body?.state === 'closed' ? req.body.state : undefined;
|
||||
const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined;
|
||||
const labels = Array.isArray(req.body?.labels) ? req.body.labels : undefined;
|
||||
const assignees = Array.isArray(req.body?.assignees) ? req.body.assignees : undefined;
|
||||
const milestoneProvided = req.body?.milestone !== undefined;
|
||||
const hasExtendedFields = state !== undefined
|
||||
|| draft !== undefined
|
||||
|| labels !== undefined
|
||||
|| assignees !== undefined
|
||||
|| milestoneProvided;
|
||||
|
||||
let updated;
|
||||
try {
|
||||
updated = await octokit.rest.pulls.update({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
pull_number: number,
|
||||
title,
|
||||
...(typeof body === 'string' ? { body } : {}),
|
||||
});
|
||||
if (hasExtendedFields) {
|
||||
// PRs are issues: issues.update carries state/labels/assignees/milestone
|
||||
// (plus title/body) and works on pull requests. draft is not an
|
||||
// issues.update field, so it is applied through pulls.update instead.
|
||||
const issuesParams = {
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
issue_number: number,
|
||||
title,
|
||||
...(typeof body === 'string' ? { body } : {}),
|
||||
...(state !== undefined ? { state } : {}),
|
||||
...(labels !== undefined ? { labels } : {}),
|
||||
...(assignees !== undefined ? { assignees } : {}),
|
||||
};
|
||||
if (milestoneProvided) {
|
||||
if (req.body.milestone === null) {
|
||||
issuesParams.milestone = null;
|
||||
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
|
||||
// issues.update accepts the milestone number, not the title — resolve it.
|
||||
const milestoneTitle = req.body.milestone.trim();
|
||||
const milestones = await octokit.rest.issues.listMilestonesForRepo({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
state: 'all',
|
||||
per_page: 100,
|
||||
});
|
||||
const milestone = (Array.isArray(milestones?.data) ? milestones.data : []).find(
|
||||
(item) => typeof item?.title === 'string' && item.title.toLowerCase() === milestoneTitle.toLowerCase()
|
||||
);
|
||||
if (!milestone) {
|
||||
return res.status(400).json({ error: 'Milestone not found' });
|
||||
}
|
||||
issuesParams.milestone = milestone.number;
|
||||
}
|
||||
}
|
||||
updated = await octokit.rest.issues.update(issuesParams);
|
||||
if (draft !== undefined) {
|
||||
const draftUpdated = await octokit.rest.pulls.update({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
pull_number: number,
|
||||
draft,
|
||||
});
|
||||
// The draft write returns the freshest full PR payload.
|
||||
updated = draftUpdated;
|
||||
}
|
||||
} else {
|
||||
updated = await octokit.rest.pulls.update({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
pull_number: number,
|
||||
title,
|
||||
...(typeof body === 'string' ? { body } : {}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.status === 401) {
|
||||
return res.status(401).json({ error: 'GitHub not connected' });
|
||||
@@ -1012,6 +1077,8 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
invalidatePrContextCache(directory, number);
|
||||
const { invalidateRepoPullsCache } = await import('./pr-status.js');
|
||||
invalidateRepoPullsCache(repo.owner, repo.repo);
|
||||
return res.json({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
@@ -1132,6 +1199,214 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
// PRs are issues at the API level, so issues.createComment posts a PR
|
||||
// "comment" (the issue-thread comment, not a review comment).
|
||||
app.post('/api/github/pulls/comment', async (req, res) => {
|
||||
try {
|
||||
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||
if (!directory || !number || !body) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
|
||||
if (!repo) {
|
||||
return res.json({ connected: true, repo: null, comment: null });
|
||||
}
|
||||
|
||||
const result = await octokit.rest.issues.createComment({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
issue_number: number,
|
||||
body,
|
||||
});
|
||||
const comment = result?.data;
|
||||
if (!comment) {
|
||||
return res.status(500).json({ error: 'GitHub returned an error while creating the comment' });
|
||||
}
|
||||
|
||||
invalidatePrContextCache(directory, number);
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo,
|
||||
comment: {
|
||||
id: comment.id,
|
||||
url: comment.html_url,
|
||||
body: comment.body || '',
|
||||
createdAt: comment.created_at,
|
||||
updatedAt: comment.updated_at,
|
||||
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status === 429) {
|
||||
return res.status(503).json({ error: 'GitHub rate limited' });
|
||||
}
|
||||
if (error?.status === 401) {
|
||||
const { clearGitHubAuth } = await getGitHubLibraries();
|
||||
clearGitHubAuth();
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
console.error('Failed to create GitHub PR comment:', error);
|
||||
return res.status(500).json({ error: 'GitHub returned an error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/github/pulls/review-comment', async (req, res) => {
|
||||
try {
|
||||
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||
const inReplyToId = typeof req.body?.inReplyToId === 'number' ? req.body.inReplyToId : undefined;
|
||||
if (!directory || !number || !body) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
|
||||
if (!repo) {
|
||||
return res.json({ connected: true, repo: null, comment: null });
|
||||
}
|
||||
|
||||
const createParams = {
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
pull_number: number,
|
||||
body,
|
||||
};
|
||||
if (inReplyToId !== undefined) {
|
||||
createParams.in_reply_to_id = inReplyToId;
|
||||
} else {
|
||||
// New inline comment: requires a path/line anchor and the PR head commit.
|
||||
const path = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
|
||||
const line = typeof req.body?.line === 'number' ? req.body.line : null;
|
||||
if (!path || !line) {
|
||||
return res.status(400).json({ error: 'path and line are required for a new review comment' });
|
||||
}
|
||||
const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
|
||||
const headSha = prResp?.data?.head?.sha;
|
||||
if (!headSha) {
|
||||
return res.status(500).json({ error: 'GitHub returned an error while resolving the PR head commit' });
|
||||
}
|
||||
createParams.commit_id = headSha;
|
||||
createParams.path = path;
|
||||
createParams.line = line;
|
||||
}
|
||||
|
||||
const result = await octokit.rest.pulls.createReviewComment(createParams);
|
||||
const comment = result?.data;
|
||||
if (!comment) {
|
||||
return res.status(500).json({ error: 'GitHub returned an error while creating the review comment' });
|
||||
}
|
||||
|
||||
invalidatePrContextCache(directory, number);
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo,
|
||||
comment: {
|
||||
id: comment.id,
|
||||
url: comment.html_url,
|
||||
body: comment.body || '',
|
||||
createdAt: comment.created_at,
|
||||
updatedAt: comment.updated_at,
|
||||
path: comment.path,
|
||||
line: typeof comment.line === 'number' ? comment.line : null,
|
||||
position: typeof comment.position === 'number' ? comment.position : null,
|
||||
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status === 429) {
|
||||
return res.status(503).json({ error: 'GitHub rate limited' });
|
||||
}
|
||||
if (error?.status === 401) {
|
||||
const { clearGitHubAuth } = await getGitHubLibraries();
|
||||
clearGitHubAuth();
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
console.error('Failed to create GitHub review comment:', error);
|
||||
return res.status(500).json({ error: 'GitHub returned an error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/github/pulls/review', async (req, res) => {
|
||||
try {
|
||||
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const event = typeof req.body?.event === 'string' ? req.body.event : '';
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
|
||||
if (!directory || !number || !event) {
|
||||
return res.status(400).json({ error: 'directory, number, event are required' });
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
|
||||
if (!repo) {
|
||||
return res.json({ connected: true, repo: null, review: null });
|
||||
}
|
||||
|
||||
const result = await octokit.rest.pulls.createReview({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
pull_number: number,
|
||||
event,
|
||||
...(typeof body === 'string' ? { body } : {}),
|
||||
});
|
||||
const review = result?.data;
|
||||
if (!review) {
|
||||
return res.status(500).json({ error: 'GitHub returned an error while submitting the review' });
|
||||
}
|
||||
|
||||
invalidatePrContextCache(directory, number);
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo,
|
||||
review: {
|
||||
id: String(review.id),
|
||||
state: typeof review.state === 'string' ? review.state : '',
|
||||
author: mapGitHubUserSummary(review.user),
|
||||
submittedAt: review.submitted_at,
|
||||
body: typeof review.body === 'string' ? review.body : null,
|
||||
commitSha: typeof review.commit_id === 'string' ? review.commit_id : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status === 429) {
|
||||
return res.status(503).json({ error: 'GitHub rate limited' });
|
||||
}
|
||||
if (error?.status === 401) {
|
||||
const { clearGitHubAuth } = await getGitHubLibraries();
|
||||
clearGitHubAuth();
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
console.error('Failed to submit GitHub review:', error);
|
||||
return res.status(500).json({ error: 'GitHub returned an error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ================= GitHub Repo APIs =================
|
||||
|
||||
app.get('/api/github/repo/upstream', async (req, res) => {
|
||||
@@ -1453,6 +1728,182 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/github/issues/comment', async (req, res) => {
|
||||
try {
|
||||
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||
if (!directory || !number || !body) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
|
||||
if (!repo) {
|
||||
return res.json({ connected: true, repo: null, comment: null });
|
||||
}
|
||||
|
||||
const result = await octokit.rest.issues.createComment({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
issue_number: number,
|
||||
body,
|
||||
});
|
||||
const comment = result?.data;
|
||||
if (!comment) {
|
||||
return res.status(500).json({ error: 'GitHub returned an error while creating the comment' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo,
|
||||
comment: {
|
||||
id: comment.id,
|
||||
url: comment.html_url,
|
||||
body: comment.body || '',
|
||||
createdAt: comment.created_at,
|
||||
updatedAt: comment.updated_at,
|
||||
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status === 429) {
|
||||
return res.status(503).json({ error: 'GitHub rate limited' });
|
||||
}
|
||||
if (error?.status === 401) {
|
||||
const { clearGitHubAuth } = await getGitHubLibraries();
|
||||
clearGitHubAuth();
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
console.error('Failed to create GitHub issue comment:', error);
|
||||
return res.status(500).json({ error: 'GitHub returned an error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/github/issues/update', async (req, res) => {
|
||||
try {
|
||||
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
if (!directory || !number) {
|
||||
return res.status(400).json({ error: 'directory and number are required' });
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
|
||||
if (!repo) {
|
||||
return res.json({ connected: true, repo: null, issue: null });
|
||||
}
|
||||
|
||||
const params = {
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
issue_number: number,
|
||||
};
|
||||
if (typeof req.body?.title === 'string') {
|
||||
params.title = req.body.title.trim();
|
||||
}
|
||||
if (typeof req.body?.body === 'string') {
|
||||
params.body = req.body.body;
|
||||
}
|
||||
if (req.body?.state === 'open' || req.body?.state === 'closed') {
|
||||
params.state = req.body.state;
|
||||
}
|
||||
if (Array.isArray(req.body?.labels)) {
|
||||
params.labels = req.body.labels;
|
||||
}
|
||||
if (Array.isArray(req.body?.assignees)) {
|
||||
params.assignees = req.body.assignees;
|
||||
}
|
||||
if (req.body?.milestone !== undefined) {
|
||||
if (req.body.milestone === null) {
|
||||
params.milestone = null;
|
||||
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
|
||||
// issues.update accepts the milestone number, not the title — resolve it.
|
||||
const milestoneTitle = req.body.milestone.trim();
|
||||
const milestones = await octokit.rest.issues.listMilestonesForRepo({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
state: 'all',
|
||||
per_page: 100,
|
||||
});
|
||||
const milestone = (Array.isArray(milestones?.data) ? milestones.data : []).find(
|
||||
(item) => typeof item?.title === 'string' && item.title.toLowerCase() === milestoneTitle.toLowerCase()
|
||||
);
|
||||
if (!milestone) {
|
||||
return res.status(400).json({ error: 'Milestone not found' });
|
||||
}
|
||||
params.milestone = milestone.number;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await octokit.rest.issues.update(params);
|
||||
const issue = result?.data;
|
||||
if (!issue) {
|
||||
return res.status(500).json({ error: 'GitHub returned an error while updating the issue' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo,
|
||||
issue: {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
state: issue.state === 'closed' ? 'closed' : 'open',
|
||||
body: issue.body || '',
|
||||
createdAt: issue.created_at,
|
||||
updatedAt: issue.updated_at,
|
||||
author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null,
|
||||
assignees: Array.isArray(issue.assignees)
|
||||
? issue.assignees
|
||||
.map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null))
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
labels: Array.isArray(issue.labels)
|
||||
? issue.labels
|
||||
.map((label) => {
|
||||
if (typeof label === 'string') return null;
|
||||
const name = typeof label?.name === 'string' ? label.name : '';
|
||||
if (!name) return null;
|
||||
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
milestone: issue.milestone && typeof issue.milestone === 'object'
|
||||
? {
|
||||
title: typeof issue.milestone.title === 'string' ? issue.milestone.title : '',
|
||||
...(typeof issue.milestone.state === 'string' ? { state: issue.milestone.state } : {}),
|
||||
}
|
||||
: null,
|
||||
commentsCount: typeof issue.comments === 'number' ? issue.comments : undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status === 429) {
|
||||
return res.status(503).json({ error: 'GitHub rate limited' });
|
||||
}
|
||||
if (error?.status === 401) {
|
||||
const { clearGitHubAuth } = await getGitHubLibraries();
|
||||
clearGitHubAuth();
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
console.error('Failed to update GitHub issue:', error);
|
||||
return res.status(500).json({ error: 'GitHub returned an error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ================= GitHub Pull Request Context APIs =================
|
||||
|
||||
app.get('/api/github/pulls/list', async (req, res) => {
|
||||
|
||||
@@ -10,8 +10,22 @@ const mockState = vi.hoisted(() => ({
|
||||
clearGitHubAuth: vi.fn(),
|
||||
octokit: {
|
||||
rest: {
|
||||
pulls: { listCommits: vi.fn() },
|
||||
issues: { listEventsForTimeline: vi.fn() },
|
||||
pulls: {
|
||||
listCommits: vi.fn(),
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
createReview: vi.fn(),
|
||||
createReviewComment: vi.fn(),
|
||||
listReviewComments: vi.fn(),
|
||||
listFiles: vi.fn(),
|
||||
},
|
||||
issues: {
|
||||
listEventsForTimeline: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
update: vi.fn(),
|
||||
listMilestonesForRepo: vi.fn(),
|
||||
listComments: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -37,7 +51,17 @@ beforeEach(() => {
|
||||
mockState.getOctokitOrNull.mockReset();
|
||||
mockState.clearGitHubAuth.mockReset();
|
||||
mockState.octokit.rest.pulls.listCommits.mockReset();
|
||||
mockState.octokit.rest.pulls.get.mockReset();
|
||||
mockState.octokit.rest.pulls.update.mockReset();
|
||||
mockState.octokit.rest.pulls.createReview.mockReset();
|
||||
mockState.octokit.rest.pulls.createReviewComment.mockReset();
|
||||
mockState.octokit.rest.pulls.listReviewComments.mockReset();
|
||||
mockState.octokit.rest.pulls.listFiles.mockReset();
|
||||
mockState.octokit.rest.issues.listEventsForTimeline.mockReset();
|
||||
mockState.octokit.rest.issues.createComment.mockReset();
|
||||
mockState.octokit.rest.issues.update.mockReset();
|
||||
mockState.octokit.rest.issues.listMilestonesForRepo.mockReset();
|
||||
mockState.octokit.rest.issues.listComments.mockReset();
|
||||
mockState.getOctokitOrNull.mockImplementation(() => mockState.octokit);
|
||||
});
|
||||
|
||||
@@ -132,3 +156,475 @@ describe('GitHub pull request enrichment routes', () => {
|
||||
expect(response.body).toEqual({ error: 'directory and number are required' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub write routes', () => {
|
||||
test('issues/comment creates a comment and returns the envelope', async () => {
|
||||
mockState.octokit.rest.issues.createComment.mockResolvedValue({
|
||||
data: {
|
||||
id: 1001,
|
||||
html_url: 'https://github.com/owner/repo/issues/7#issuecomment-1001',
|
||||
body: 'Hello',
|
||||
created_at: '2026-01-01T10:00:00Z',
|
||||
updated_at: '2026-01-01T10:00:00Z',
|
||||
user: { login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/github/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7, body: 'Hello' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
comment: {
|
||||
id: 1001,
|
||||
url: 'https://github.com/owner/repo/issues/7#issuecomment-1001',
|
||||
body: 'Hello',
|
||||
createdAt: '2026-01-01T10:00:00Z',
|
||||
author: { login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' },
|
||||
},
|
||||
});
|
||||
expect(mockState.octokit.rest.issues.createComment).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
issue_number: 7,
|
||||
body: 'Hello',
|
||||
});
|
||||
});
|
||||
|
||||
test('issues/comment returns connected:false when not authenticated', async () => {
|
||||
mockState.getOctokitOrNull.mockImplementation(() => null);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/github/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7, body: 'Hello' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ connected: false });
|
||||
});
|
||||
|
||||
test('issues/comment requires directory, number, and body', async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/github/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7 });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'directory, number, body are required' });
|
||||
});
|
||||
|
||||
test('issues/update passes state and labels through', async () => {
|
||||
mockState.octokit.rest.issues.update.mockResolvedValue({
|
||||
data: {
|
||||
number: 7,
|
||||
title: 'Bug',
|
||||
body: 'desc',
|
||||
html_url: 'https://github.com/owner/repo/issues/7',
|
||||
state: 'closed',
|
||||
created_at: '2026-01-01T10:00:00Z',
|
||||
updated_at: '2026-01-02T10:00:00Z',
|
||||
user: { login: 'alice', id: 42, avatar_url: 'u' },
|
||||
labels: [{ name: 'bug', color: 'd73a4a' }],
|
||||
assignees: [{ login: 'bob', id: 43, avatar_url: 'u' }],
|
||||
milestone: { title: 'v1.0', state: 'open' },
|
||||
comments: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.patch('/api/github/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, state: 'closed', labels: ['bug'] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
issue: {
|
||||
number: 7,
|
||||
title: 'Bug',
|
||||
state: 'closed',
|
||||
labels: [{ name: 'bug', color: 'd73a4a' }],
|
||||
assignees: [{ login: 'bob', id: 43 }],
|
||||
milestone: { title: 'v1.0', state: 'open' },
|
||||
commentsCount: 3,
|
||||
},
|
||||
});
|
||||
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
issue_number: 7,
|
||||
state: 'closed',
|
||||
labels: ['bug'],
|
||||
});
|
||||
});
|
||||
|
||||
test('issues/update resolves milestone title to a number (case-insensitive)', async () => {
|
||||
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({
|
||||
data: [{ number: 5, title: 'v1.0', state: 'open' }],
|
||||
});
|
||||
mockState.octokit.rest.issues.update.mockResolvedValue({
|
||||
data: { number: 7, title: 'Bug', html_url: 'u', state: 'open', user: null, labels: [], assignees: [], milestone: null, body: '' },
|
||||
});
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.patch('/api/github/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, milestone: 'V1.0' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockState.octokit.rest.issues.listMilestonesForRepo).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
state: 'all',
|
||||
per_page: 100,
|
||||
});
|
||||
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ milestone: 5 })
|
||||
);
|
||||
});
|
||||
|
||||
test('issues/update returns 400 when the milestone title matches nothing', async () => {
|
||||
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({ data: [] });
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.patch('/api/github/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, milestone: 'nope' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Milestone not found' });
|
||||
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('issues/update passes milestone null through to clear it', async () => {
|
||||
mockState.octokit.rest.issues.update.mockResolvedValue({
|
||||
data: { number: 7, title: 'Bug', html_url: 'u', state: 'open', user: null, labels: [], assignees: [], milestone: null, body: '' },
|
||||
});
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.patch('/api/github/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, milestone: null });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockState.octokit.rest.issues.listMilestonesForRepo).not.toHaveBeenCalled();
|
||||
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ milestone: null })
|
||||
);
|
||||
});
|
||||
|
||||
test('pulls/comment posts to the PR issue thread', async () => {
|
||||
mockState.octokit.rest.issues.createComment.mockResolvedValue({
|
||||
data: {
|
||||
id: 2001,
|
||||
html_url: 'https://github.com/owner/repo/pull/9#issuecomment-2001',
|
||||
body: 'Thanks',
|
||||
created_at: '2026-01-01T10:00:00Z',
|
||||
updated_at: '2026-01-01T10:00:00Z',
|
||||
user: { login: 'alice', id: 42, avatar_url: 'u' },
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/github/pulls/comment')
|
||||
.send({ directory: '/tmp/work', number: 9, body: 'Thanks' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
comment: { id: 2001, body: 'Thanks', author: { login: 'alice', id: 42 } },
|
||||
});
|
||||
expect(mockState.octokit.rest.issues.createComment).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
issue_number: 9,
|
||||
body: 'Thanks',
|
||||
});
|
||||
});
|
||||
|
||||
test('pulls/review-comment creates a reply when inReplyToId is provided', async () => {
|
||||
mockState.octokit.rest.pulls.createReviewComment.mockResolvedValue({
|
||||
data: {
|
||||
id: 3001,
|
||||
html_url: 'u',
|
||||
body: 'reply',
|
||||
path: 'src/a.ts',
|
||||
line: 3,
|
||||
position: null,
|
||||
created_at: '2026-01-01T10:00:00Z',
|
||||
updated_at: '2026-01-01T10:00:00Z',
|
||||
user: { login: 'alice', id: 42, avatar_url: 'u' },
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/github/pulls/review-comment')
|
||||
.send({ directory: '/tmp/work', number: 9, body: 'reply', inReplyToId: 2999 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
comment: { id: 3001, body: 'reply', path: 'src/a.ts', line: 3 },
|
||||
});
|
||||
expect(mockState.octokit.rest.pulls.createReviewComment).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
pull_number: 9,
|
||||
body: 'reply',
|
||||
in_reply_to_id: 2999,
|
||||
});
|
||||
expect(mockState.octokit.rest.pulls.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('pulls/review-comment resolves the PR head sha for a new inline comment', async () => {
|
||||
mockState.octokit.rest.pulls.get.mockResolvedValue({ data: { head: { sha: 'abc123def4567890' } } });
|
||||
mockState.octokit.rest.pulls.createReviewComment.mockResolvedValue({
|
||||
data: {
|
||||
id: 3002,
|
||||
html_url: 'u',
|
||||
body: 'nit',
|
||||
path: 'src/a.ts',
|
||||
line: 5,
|
||||
position: 1,
|
||||
created_at: '2026-01-01T10:00:00Z',
|
||||
updated_at: '2026-01-01T10:00:00Z',
|
||||
user: { login: 'alice', id: 42, avatar_url: 'u' },
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/github/pulls/review-comment')
|
||||
.send({ directory: '/tmp/work', number: 9, body: 'nit', path: 'src/a.ts', line: 5 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
comment: { id: 3002, body: 'nit', path: 'src/a.ts', line: 5, position: 1 },
|
||||
});
|
||||
expect(mockState.octokit.rest.pulls.get).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
pull_number: 9,
|
||||
});
|
||||
expect(mockState.octokit.rest.pulls.createReviewComment).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
pull_number: 9,
|
||||
body: 'nit',
|
||||
commit_id: 'abc123def4567890',
|
||||
path: 'src/a.ts',
|
||||
line: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test('pulls/review-comment requires path and line for a new inline comment', async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/github/pulls/review-comment')
|
||||
.send({ directory: '/tmp/work', number: 9, body: 'nit' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'path and line are required for a new review comment' });
|
||||
expect(mockState.octokit.rest.pulls.createReviewComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('pulls/review maps the submitted review and invalidates the PR context cache', async () => {
|
||||
mockState.octokit.rest.pulls.get.mockResolvedValue({
|
||||
data: {
|
||||
number: 9,
|
||||
title: 'T',
|
||||
body: '',
|
||||
html_url: 'u',
|
||||
state: 'open',
|
||||
draft: false,
|
||||
base: { ref: 'main' },
|
||||
head: { ref: 'feature' },
|
||||
user: { login: 'alice', id: 42, avatar_url: 'u' },
|
||||
},
|
||||
});
|
||||
mockState.octokit.rest.issues.listComments.mockResolvedValue({ data: [] });
|
||||
mockState.octokit.rest.pulls.listReviewComments.mockResolvedValue({ data: [] });
|
||||
mockState.octokit.rest.pulls.listFiles.mockResolvedValue({ data: [] });
|
||||
|
||||
const app = createApp();
|
||||
await request(app).get('/api/github/pulls/context?directory=%2Ftmp%2Fwork&number=9');
|
||||
const pullsGetCallsAfterContext = mockState.octokit.rest.pulls.get.mock.calls.length;
|
||||
|
||||
mockState.octokit.rest.pulls.createReview.mockResolvedValue({
|
||||
data: {
|
||||
id: 4001,
|
||||
state: 'APPROVED',
|
||||
submitted_at: '2026-01-01T10:00:00Z',
|
||||
body: 'LGTM',
|
||||
commit_id: 'abc123def4567890',
|
||||
user: { login: 'alice', id: 42, avatar_url: 'u' },
|
||||
},
|
||||
});
|
||||
|
||||
const reviewResponse = await request(app)
|
||||
.post('/api/github/pulls/review')
|
||||
.send({ directory: '/tmp/work', number: 9, event: 'APPROVE', body: 'LGTM' });
|
||||
|
||||
expect(reviewResponse.status).toBe(200);
|
||||
expect(reviewResponse.body).toMatchObject({
|
||||
connected: true,
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
review: {
|
||||
id: '4001',
|
||||
state: 'APPROVED',
|
||||
submittedAt: '2026-01-01T10:00:00Z',
|
||||
body: 'LGTM',
|
||||
commitSha: 'abc123def4567890',
|
||||
author: { login: 'alice', id: 42 },
|
||||
},
|
||||
});
|
||||
expect(mockState.octokit.rest.pulls.createReview).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
pull_number: 9,
|
||||
event: 'APPROVE',
|
||||
body: 'LGTM',
|
||||
});
|
||||
|
||||
// The PR context cache must have been invalidated: the next context fetch
|
||||
// re-resolves the PR instead of serving the cached copy.
|
||||
await request(app).get('/api/github/pulls/context?directory=%2Ftmp%2Fwork&number=9');
|
||||
expect(mockState.octokit.rest.pulls.get.mock.calls.length).toBe(pullsGetCallsAfterContext + 1);
|
||||
});
|
||||
|
||||
test('pulls/review requires directory, number, and event', async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/github/pulls/review')
|
||||
.send({ directory: '/tmp/work', number: 9 });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'directory, number, event are required' });
|
||||
});
|
||||
|
||||
test('pr/update branches to issues.update and applies draft via pulls.update', async () => {
|
||||
mockState.octokit.rest.issues.update.mockResolvedValue({
|
||||
data: {
|
||||
number: 9,
|
||||
title: 'T',
|
||||
body: '',
|
||||
html_url: 'u',
|
||||
state: 'open',
|
||||
draft: false,
|
||||
base: { ref: 'main' },
|
||||
head: { ref: 'feature' },
|
||||
mergeable: true,
|
||||
mergeable_state: 'clean',
|
||||
user: null,
|
||||
labels: [{ name: 'bug', color: 'd73a4a' }],
|
||||
assignees: [],
|
||||
milestone: null,
|
||||
},
|
||||
});
|
||||
mockState.octokit.rest.pulls.update.mockResolvedValue({
|
||||
data: {
|
||||
number: 9,
|
||||
title: 'T',
|
||||
body: '',
|
||||
html_url: 'u',
|
||||
state: 'open',
|
||||
draft: true,
|
||||
base: { ref: 'main' },
|
||||
head: { ref: 'feature' },
|
||||
mergeable: true,
|
||||
mergeable_state: 'clean',
|
||||
user: null,
|
||||
labels: [{ name: 'bug', color: 'd73a4a' }],
|
||||
assignees: [],
|
||||
milestone: null,
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/github/pr/update')
|
||||
.send({
|
||||
directory: '/tmp/work',
|
||||
number: 9,
|
||||
title: 'T',
|
||||
state: 'closed',
|
||||
draft: true,
|
||||
labels: ['bug'],
|
||||
assignees: ['alice'],
|
||||
milestone: null,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({ number: 9, state: 'open', draft: true });
|
||||
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
issue_number: 9,
|
||||
state: 'closed',
|
||||
labels: ['bug'],
|
||||
assignees: ['alice'],
|
||||
milestone: null,
|
||||
})
|
||||
);
|
||||
expect(mockState.octokit.rest.pulls.update).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
pull_number: 9,
|
||||
draft: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('pr/update keeps title/body on pulls.update when no extended fields are present', async () => {
|
||||
mockState.octokit.rest.pulls.update.mockResolvedValue({
|
||||
data: {
|
||||
number: 9,
|
||||
title: 'New title',
|
||||
body: '',
|
||||
html_url: 'u',
|
||||
state: 'open',
|
||||
draft: false,
|
||||
base: { ref: 'main' },
|
||||
head: { ref: 'feature' },
|
||||
mergeable: true,
|
||||
mergeable_state: 'clean',
|
||||
user: null,
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/github/pr/update')
|
||||
.send({ directory: '/tmp/work', number: 9, title: 'New title' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockState.octokit.rest.pulls.update).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
pull_number: 9,
|
||||
title: 'New title',
|
||||
});
|
||||
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('pr/update returns 400 when the milestone title matches nothing', async () => {
|
||||
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({ data: [] });
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/github/pr/update')
|
||||
.send({ directory: '/tmp/work', number: 9, title: 'T', milestone: 'nope' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Milestone not found' });
|
||||
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
### Client (`client.js`)
|
||||
|
||||
- `createGitLabClient({ token, baseUrl })`: raw-fetch REST v4 client with `request(path, { method, query, body })` plus convenience methods `user()`, `project(path)`, `issues(path, params)`, `issue(path, iid)`, `issueNotes(path, iid, params)`, `mergeRequests(path, params)`, `mergeRequest(path, iid)`, `mergeRequestDiffs(path, iid, params)`, `createMergeRequest(path, body)`, `updateMergeRequest(path, iid, body)`, `mergeMergeRequest(path, iid, body)`, `branches(path, params)`.
|
||||
- `createGitLabClient({ token, baseUrl })`: raw-fetch REST v4 client with `request(path, { method, query, body })` plus convenience methods `user()`, `project(path)`, `issues(path, params)`, `issue(path, iid)`, `issueNotes(path, iid, params)`, `createIssueNote(path, iid, body)`, `updateIssue(path, iid, params)`, `mergeRequests(path, params)`, `mergeRequest(path, iid)`, `mergeRequestDiffs(path, iid, params)`, `createMergeRequest(path, body)`, `updateMergeRequest(path, iid, body)`, `mergeMergeRequest(path, iid, body)`, `createMrNote(path, iid, body)`, `approveMr(path, iid)`, `milestones(path, params)`, `branches(path, params)`.
|
||||
- `getGitLabClientOrNull()`: client for the current account, or `null`.
|
||||
- `isGitLabRateLimited()` / `noteGitLabRateLimit(error)`: own module-level rate-limit cooldown (not shared with the GitHub module's `rate-limit.js`).
|
||||
|
||||
@@ -81,8 +81,13 @@ Nothing in the client or repo layers assumes the token came from a PAT.
|
||||
- MR notes: `GET /projects/:id/merge_requests/:merge_request_iid/notes?per_page=100`; the timeline route keeps `system: true` notes only and infers the event `type` from the note body text (best-effort heuristic, falls back to `'other'`).
|
||||
- MR notes: `GET /projects/:id/merge_requests/:merge_request_iid/notes?per_page=100`.
|
||||
- MR create: `POST /projects/:id/merge_requests` with `{ source_branch, target_branch, title, description?, remove_source_branch }` (description omitted when absent; `remove_source_branch` defaults to `false`).
|
||||
- MR update: `PUT /projects/:id/merge_requests/:merge_request_iid` with `{ title?, description? }` (undefined fields omitted).
|
||||
- MR update: `PUT /projects/:id/merge_requests/:merge_request_iid` with `{ title?, description?, state_event?, labels?, assignee_ids?, milestone_id? }` (undefined fields omitted; `state_event` is derived from `state`, milestone titles are resolved to ids).
|
||||
- MR merge: `PUT /projects/:id/merge_requests/:merge_request_iid/merge` with `{ squash? }`.
|
||||
- Issue comment write: `POST /projects/:id/issues/:issue_iid/notes` with `{ body }` (the route resolves the issue `web_url` first so the note links as `{issue_web_url}#note_{id}`).
|
||||
- Issue update: `PUT /projects/:id/issues/:issue_iid` with `{ title?, description?, state_event?, labels?, assignee_ids?, milestone_id? }` (`state: 'open'|'closed'` maps to `state_event: 'reopen'|'close'`; labels/assignees are full-set replaces per GitLab semantics; `milestone` titles are resolved to ids and `null` clears).
|
||||
- MR comment write: `POST /projects/:id/merge_requests/:merge_request_iid/notes` with `{ body }`.
|
||||
- MR approve: `POST /projects/:id/merge_requests/:merge_request_iid/approve` (approve-only; GitLab has no request-changes event via this API — the facade capability reflects that).
|
||||
- Milestones: `GET /projects/:id/milestones?state=all&per_page=100` (first page) for title-to-id resolution on issue/MR updates.
|
||||
- Branches: `GET /projects/:id/repository/branches?per_page=100&page=N`.
|
||||
- User: `GET /user` -> `{ id, username, name, state, avatar_url, web_url, email, ... }`.
|
||||
|
||||
@@ -103,8 +108,12 @@ Nothing in the client or repo layers assumes the token came from a PAT.
|
||||
| GET | `/api/gitlab/mrs/commits` | `?directory&number&namespace&project` -> `{ connected, repo?, commits[] }` |
|
||||
| GET | `/api/gitlab/mrs/timeline` | `?directory&number&namespace&project` -> `{ connected, repo?, events[] }` (system notes only; event `type` inferred from note body text — best-effort heuristic) |
|
||||
| POST | `/api/gitlab/mrs/create` | body `{ directory, title, sourceBranch, targetBranch, description?, removeSourceBranch? }` -> `{ connected, repo?, mr }`; `400` for missing fields, unresolvable repo, or a token without the `api` scope |
|
||||
| PUT | `/api/gitlab/mrs/update` | body `{ directory, number, title?, description? }` -> `{ connected, repo?, mr }`; `404` when the MR does not exist |
|
||||
| PUT | `/api/gitlab/mrs/update` | body `{ directory, number, title?, description?, state?, labels?, assigneeIds?, milestone? }` -> `{ connected, repo?, mr }`; `404` when the MR does not exist; `400 'Milestone not found'` when a milestone title does not match |
|
||||
| PUT | `/api/gitlab/mrs/merge` | body `{ directory, number, squash? }` -> `{ connected, merged: true }` on success; non-mergeable MRs -> the GitLab status (`405`/`406`/`409`/`422`) with `{ connected, merged: false, message }` |
|
||||
| POST | `/api/gitlab/issues/comment` | body `{ directory, number, body, namespace?, project? }` -> `{ connected, repo?, comment }` |
|
||||
| PUT | `/api/gitlab/issues/update` | body `{ directory, number, title?, body?, state?, labels?, assigneeIds?, milestone?, namespace?, project? }` -> `{ connected, repo?, issue }`; `400 'Milestone not found'` when a milestone title does not match |
|
||||
| POST | `/api/gitlab/mrs/comment` | body `{ directory, number, body, namespace?, project? }` -> `{ connected, repo?, comment }` |
|
||||
| POST | `/api/gitlab/mrs/approve` | body `{ directory, number, namespace?, project? }` -> `{ connected, repo?, approved: true }` |
|
||||
| GET | `/api/gitlab/repo/branches` | `?namespace&project` -> `{ branches[], defaultBranch? }` (`defaultBranch` is `null` when the repo has no marked default branch or GitLab is disconnected) |
|
||||
|
||||
Conventions mirror `github/routes.js`:
|
||||
@@ -114,7 +123,7 @@ Conventions mirror `github/routes.js`:
|
||||
- Hard failures -> `4xx`/`5xx` with `{ error }`.
|
||||
- A GitLab `429` -> `503 { error: 'GitLab rate limited' }`.
|
||||
- Lazy-import pattern: route handlers import `./index.js` on first use, so the module never loads unless GitLab endpoints are hit.
|
||||
- Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout.
|
||||
- Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout. Write routes deliberately skip the route-level timeout (a timeout can orphan a write); the client's per-request timeout still bounds them.
|
||||
|
||||
## Consumers
|
||||
|
||||
@@ -127,6 +136,7 @@ Conventions mirror `github/routes.js`:
|
||||
- A repo that does not resolve from the local git remote yields `repo: null` with empty lists, matching the GitHub behavior. Write routes reject an unresolvable repo with `400 { error: 'Unable to resolve GitLab repo from directory' }`.
|
||||
- Invalid/expired tokens are cleared on `401`/`403` and reported as disconnected.
|
||||
- GitLab `403` on write routes means the token lacks the `api` scope; they respond `400 { error: 'Your GitLab token needs the api scope to ...' }`.
|
||||
- Milestone titles on issue/MR updates are resolved against `GET /projects/:id/milestones`; an unmatched title yields `400 { error: 'Milestone not found' }` and `null` clears the milestone (`milestone_id: null`).
|
||||
- MR merge rejections (`405`/`406`/`409`/`422` from GitLab) are surfaced as `{ connected, merged: false, message }` with the GitLab status so clients can show the message without treating it as a transport error (mirrors `github/pr/merge`).
|
||||
- Rate-limit and timeout failures surface explicit `503` responses so clients keep last-known state rather than clearing UI.
|
||||
|
||||
@@ -136,4 +146,4 @@ Conventions mirror `github/routes.js`:
|
||||
- Never log tokens. Error messages must not include the access token.
|
||||
- Do not double-encode project paths; convenience methods already call `encodeURIComponent` on the `pathWithNamespace`.
|
||||
- The ETag cache and rate-limit cooldown are module-level and per-instance — they are NOT shared with the GitHub module.
|
||||
- To add further GitLab write operations (comment, assign, issue writes), add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing MR write routes and the GitHub PR write routes.
|
||||
- To add further GitLab write operations, add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing issue/MR write routes and the GitHub PR write routes.
|
||||
|
||||
@@ -261,6 +261,10 @@ export function createGitLabClient({ token, baseUrl }) {
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}`),
|
||||
issueNotes: (pathWithNamespace, iid, params = {}) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { query: params }),
|
||||
createIssueNote: (pathWithNamespace, iid, body) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { method: 'POST', body: { body } }),
|
||||
updateIssue: (pathWithNamespace, iid, params) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}`, { method: 'PUT', body: params }),
|
||||
mergeRequests: (pathWithNamespace, params = {}) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests`, { query: params }),
|
||||
mergeRequest: (pathWithNamespace, iid) =>
|
||||
@@ -271,6 +275,12 @@ export function createGitLabClient({ token, baseUrl }) {
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/commits`, { query: params }),
|
||||
mergeRequestNotes: (pathWithNamespace, iid, params = {}) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/notes`, { query: params }),
|
||||
createMrNote: (pathWithNamespace, iid, body) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/notes`, { method: 'POST', body: { body } }),
|
||||
approveMr: (pathWithNamespace, iid) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/approve`, { method: 'POST' }),
|
||||
milestones: (pathWithNamespace, params = {}) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/milestones`, { query: params }),
|
||||
createMergeRequest: (pathWithNamespace, body) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests`, { method: 'POST', body }),
|
||||
updateMergeRequest: (pathWithNamespace, iid, body) =>
|
||||
|
||||
@@ -273,6 +273,71 @@ describe('merge request write methods', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue and review write methods', () => {
|
||||
test('createIssueNote POSTs a body to the issue notes endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ id: 5, body: 'hi' }, { status: 201 }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
const result = await client.createIssueNote('group/sub', 7, 'Nice catch');
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/issues/7/notes');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
|
||||
expect(result.status).toBe(201);
|
||||
});
|
||||
|
||||
test('createMrNote POSTs a body to the MR notes endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ id: 8, body: 'LGTM' }, { status: 201 }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.createMrNote('group/sub', 12, 'LGTM');
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/merge_requests/12/notes');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'LGTM' });
|
||||
});
|
||||
|
||||
test('updateIssue PUTs params to the issue endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ iid: 7, title: 'Updated' }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.updateIssue('group/sub', 7, { state_event: 'close', labels: ['bug'], milestone_id: 33 });
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/issues/7');
|
||||
expect(options.method).toBe('PUT');
|
||||
expect(JSON.parse(options.body)).toEqual({ state_event: 'close', labels: ['bug'], milestone_id: 33 });
|
||||
});
|
||||
|
||||
test('approveMr POSTs to the approve endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ id: 1, state: 'approved' }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.approveMr('group/sub', 12);
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/merge_requests/12/approve');
|
||||
expect(options.method).toBe('POST');
|
||||
});
|
||||
|
||||
test('milestones GETs the project milestones list', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse([{ id: 33, title: 'v1.0' }]));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.milestones('group/sub', { state: 'all', per_page: 100 });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/milestones?state=all&per_page=100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limiting', () => {
|
||||
// NOTE: these tests run last in this file. The rate-limit cooldown is
|
||||
// module-level and has no reset export, so earlier tests must not set one.
|
||||
|
||||
@@ -23,9 +23,12 @@ function withTimeout(promise, timeoutMs, label) {
|
||||
|
||||
const asString = (value) => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
// Resolve the requested project from the query (read routes) or the JSON body
|
||||
// (write routes). `namespace`/`project` override the directory-local git
|
||||
// remote for repos checked out from non-GitLab remotes.
|
||||
const getRequestedProject = (req) => {
|
||||
const namespace = asString(req.query?.namespace);
|
||||
const project = asString(req.query?.project);
|
||||
const namespace = asString(req.query?.namespace) || asString(req.body?.namespace);
|
||||
const project = asString(req.query?.project) || asString(req.body?.project);
|
||||
return namespace && project ? `${namespace}/${project}` : null;
|
||||
};
|
||||
|
||||
@@ -71,6 +74,23 @@ const mapIssueSummary = (item) => ({
|
||||
labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [],
|
||||
});
|
||||
|
||||
const mapIssue = (item) => ({
|
||||
...mapIssueSummary(item),
|
||||
body: typeof item.description === 'string' ? item.description : '',
|
||||
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
|
||||
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
|
||||
assignees: Array.isArray(item.assignees)
|
||||
? item.assignees.map(mapAuthor).filter(Boolean)
|
||||
: [],
|
||||
milestone: item.milestone && typeof item.milestone === 'object'
|
||||
? {
|
||||
title: typeof item.milestone.title === 'string' ? item.milestone.title : '',
|
||||
...(typeof item.milestone.state === 'string' ? { state: item.milestone.state } : {}),
|
||||
}
|
||||
: null,
|
||||
commentsCount: typeof item.user_notes_count === 'number' ? item.user_notes_count : undefined,
|
||||
});
|
||||
|
||||
const mapMergeRequestSummary = (item) => ({
|
||||
number: typeof item.iid === 'number' ? item.iid : Number(item.iid),
|
||||
title: typeof item.title === 'string' ? item.title : '',
|
||||
@@ -152,6 +172,24 @@ const gitLabErrorMessage = (data) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// GitLab update endpoints take `milestone_id` (numeric), not the title. Resolve
|
||||
// a title via the project milestones list (first page is enough for title-based
|
||||
// lookups); unmatched titles yield `milestoneId: null` so routes can surface
|
||||
// `400 { error: 'Milestone not found' }`.
|
||||
const resolveMilestoneId = async (client, projectPath, title) => {
|
||||
const resp = await client.milestones(projectPath, { state: 'all', per_page: 100 });
|
||||
if (resp.status === 429) {
|
||||
return { milestoneId: null, rateLimited: true };
|
||||
}
|
||||
if (resp.status !== 200 || !Array.isArray(resp.data)) {
|
||||
return { milestoneId: null, rateLimited: false };
|
||||
}
|
||||
const match = resp.data.find(
|
||||
(item) => typeof item?.title === 'string' && item.title.toLowerCase() === title.toLowerCase(),
|
||||
);
|
||||
return { milestoneId: typeof match?.id === 'number' ? match.id : null, rateLimited: false };
|
||||
};
|
||||
|
||||
const countDiffLines = (diffText) => {
|
||||
if (typeof diffText !== 'string') {
|
||||
return { additions: 0, deletions: 0, changes: 0 };
|
||||
@@ -478,27 +516,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
}
|
||||
|
||||
const item = resp.data;
|
||||
const issue = {
|
||||
number: typeof item.iid === 'number' ? item.iid : Number(item.iid),
|
||||
title: typeof item.title === 'string' ? item.title : '',
|
||||
url: typeof item.web_url === 'string' ? item.web_url : '',
|
||||
state: typeof item.state === 'string' ? item.state : 'opened',
|
||||
body: typeof item.description === 'string' ? item.description : '',
|
||||
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
|
||||
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
|
||||
author: mapAuthor(item.author) || {},
|
||||
assignees: Array.isArray(item.assignees)
|
||||
? item.assignees.map(mapAuthor).filter(Boolean)
|
||||
: [],
|
||||
labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [],
|
||||
milestone: item.milestone && typeof item.milestone === 'object'
|
||||
? {
|
||||
title: typeof item.milestone.title === 'string' ? item.milestone.title : '',
|
||||
...(typeof item.milestone.state === 'string' ? { state: item.milestone.state } : {}),
|
||||
}
|
||||
: null,
|
||||
commentsCount: typeof item.user_notes_count === 'number' ? item.user_notes_count : undefined,
|
||||
};
|
||||
const issue = mapIssue(item);
|
||||
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), issue });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitLab issue:', error);
|
||||
@@ -564,6 +582,146 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gitlab/issues/comment', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||
if (!directory || !number || !body) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedProject = getRequestedProject(req);
|
||||
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
|
||||
if (!projectPath) {
|
||||
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
|
||||
}
|
||||
|
||||
// GitLab notes carry no web URL; resolve the issue web_url first so the
|
||||
// note links as `{issue_web_url}#note_{id}` (mirrors issues/comments).
|
||||
const issueResp = await client.issue(projectPath, number);
|
||||
if (issueResp.status === 429) {
|
||||
return res.status(503).json({ error: 'GitLab rate limited' });
|
||||
}
|
||||
if (issueResp.status === 404) {
|
||||
return res.status(404).json({ error: 'Issue not found' });
|
||||
}
|
||||
if (issueResp.status !== 200 || !issueResp.data) {
|
||||
return res.status(502).json({ error: 'GitLab returned an error while fetching the issue' });
|
||||
}
|
||||
const webUrl = typeof issueResp.data.web_url === 'string' ? issueResp.data.web_url : '';
|
||||
|
||||
const resp = await client.createIssueNote(projectPath, number, body);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'GitLab rate limited' });
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return res.status(400).json({ error: 'Your GitLab token needs the api scope to create issue comments' });
|
||||
}
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
const status = resp.status >= 500 ? 500 : 400;
|
||||
return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while creating the comment' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'GitLab returned an empty response while creating the comment' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
|
||||
comment: mapComment(resp.data, webUrl),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create GitLab issue comment:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to create GitLab issue comment' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/gitlab/issues/update', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
if (!directory || !number) {
|
||||
return res.status(400).json({ error: 'directory and number are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedProject = getRequestedProject(req);
|
||||
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
|
||||
if (!projectPath) {
|
||||
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
|
||||
}
|
||||
|
||||
const body = {};
|
||||
if (typeof req.body?.title === 'string') {
|
||||
body.title = req.body.title.trim();
|
||||
}
|
||||
if (typeof req.body?.body === 'string') {
|
||||
body.description = req.body.body;
|
||||
}
|
||||
// GitLab maps state transitions through `state_event` ('close'/'reopen').
|
||||
if (req.body?.state === 'open' || req.body?.state === 'closed') {
|
||||
body.state_event = req.body.state === 'closed' ? 'close' : 'reopen';
|
||||
}
|
||||
if (Array.isArray(req.body?.labels)) {
|
||||
body.labels = req.body.labels.filter((label) => typeof label === 'string');
|
||||
}
|
||||
if (Array.isArray(req.body?.assigneeIds)) {
|
||||
body.assignee_ids = req.body.assigneeIds.filter((id) => typeof id === 'number');
|
||||
}
|
||||
if (req.body?.milestone !== undefined) {
|
||||
if (req.body.milestone === null) {
|
||||
body.milestone_id = null;
|
||||
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
|
||||
const { milestoneId, rateLimited } = await resolveMilestoneId(client, projectPath, req.body.milestone.trim());
|
||||
if (rateLimited) {
|
||||
return res.status(503).json({ error: 'GitLab rate limited' });
|
||||
}
|
||||
if (milestoneId === null) {
|
||||
return res.status(400).json({ error: 'Milestone not found' });
|
||||
}
|
||||
body.milestone_id = milestoneId;
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await client.updateIssue(projectPath, number, body);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'GitLab rate limited' });
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return res.status(400).json({ error: 'Your GitLab token needs the api scope to update issues' });
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return res.status(404).json({ error: 'Issue not found' });
|
||||
}
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
const status = resp.status >= 500 ? 500 : 400;
|
||||
return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while updating the issue' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'GitLab returned an empty response while updating the issue' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
|
||||
issue: mapIssue(resp.data),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update GitLab issue:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to update GitLab issue' });
|
||||
}
|
||||
});
|
||||
|
||||
// ================= GitLab Merge Request APIs =================
|
||||
|
||||
app.get('/api/gitlab/mrs/list', async (req, res) => {
|
||||
@@ -938,6 +1096,30 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
if (description !== undefined) {
|
||||
body.description = description;
|
||||
}
|
||||
// GitLab maps state transitions through `state_event` ('close'/'reopen').
|
||||
if (req.body?.state === 'open' || req.body?.state === 'closed') {
|
||||
body.state_event = req.body.state === 'closed' ? 'close' : 'reopen';
|
||||
}
|
||||
if (Array.isArray(req.body?.labels)) {
|
||||
body.labels = req.body.labels.filter((label) => typeof label === 'string');
|
||||
}
|
||||
if (Array.isArray(req.body?.assigneeIds)) {
|
||||
body.assignee_ids = req.body.assigneeIds.filter((id) => typeof id === 'number');
|
||||
}
|
||||
if (req.body?.milestone !== undefined) {
|
||||
if (req.body.milestone === null) {
|
||||
body.milestone_id = null;
|
||||
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
|
||||
const { milestoneId, rateLimited } = await resolveMilestoneId(client, projectPath, req.body.milestone.trim());
|
||||
if (rateLimited) {
|
||||
return res.status(503).json({ error: 'GitLab rate limited' });
|
||||
}
|
||||
if (milestoneId === null) {
|
||||
return res.status(400).json({ error: 'Milestone not found' });
|
||||
}
|
||||
body.milestone_id = milestoneId;
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await withTimeout(client.updateMergeRequest(projectPath, number, body), ROUTE_TIMEOUT_MS, 'gitlab mr update');
|
||||
if (resp.status === 429) {
|
||||
@@ -1025,6 +1207,111 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gitlab/mrs/comment', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||
if (!directory || !number || !body) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedProject = getRequestedProject(req);
|
||||
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
|
||||
if (!projectPath) {
|
||||
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
|
||||
}
|
||||
|
||||
// MR notes carry no web URL; resolve the MR web_url first so the note
|
||||
// links as `{mr_web_url}#note_{id}` (mirrors mrs/context).
|
||||
const mrResp = await client.mergeRequest(projectPath, number);
|
||||
if (mrResp.status === 429) {
|
||||
return res.status(503).json({ error: 'GitLab rate limited' });
|
||||
}
|
||||
if (mrResp.status === 404) {
|
||||
return res.status(404).json({ error: 'Merge request not found' });
|
||||
}
|
||||
if (mrResp.status !== 200 || !mrResp.data) {
|
||||
return res.status(502).json({ error: 'GitLab returned an error while fetching the merge request' });
|
||||
}
|
||||
const webUrl = typeof mrResp.data.web_url === 'string' ? mrResp.data.web_url : '';
|
||||
|
||||
const resp = await client.createMrNote(projectPath, number, body);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'GitLab rate limited' });
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return res.status(400).json({ error: 'Your GitLab token needs the api scope to comment on merge requests' });
|
||||
}
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
const status = resp.status >= 500 ? 500 : 400;
|
||||
return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while creating the comment' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'GitLab returned an empty response while creating the comment' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
|
||||
comment: mapComment(resp.data, webUrl),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create GitLab merge request comment:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to create GitLab merge request comment' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gitlab/mrs/approve', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
if (!directory || !number) {
|
||||
return res.status(400).json({ error: 'directory and number are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedProject = getRequestedProject(req);
|
||||
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
|
||||
if (!projectPath) {
|
||||
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
|
||||
}
|
||||
|
||||
const resp = await client.approveMr(projectPath, number);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'GitLab rate limited' });
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return res.status(400).json({ error: 'Your GitLab token needs the api scope to approve merge requests' });
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return res.status(404).json({ error: 'Merge request not found' });
|
||||
}
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
const status = resp.status >= 500 ? 500 : 400;
|
||||
return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while approving the merge request' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
|
||||
approved: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to approve GitLab merge request:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to approve GitLab merge request' });
|
||||
}
|
||||
});
|
||||
|
||||
// ================= GitLab Repo APIs =================
|
||||
|
||||
app.get('/api/gitlab/repo/branches', async (req, res) => {
|
||||
|
||||
@@ -896,6 +896,332 @@ describe('GitLab data routes', () => {
|
||||
expect(timeline.body).toMatchObject({ connected: false, events: [] });
|
||||
});
|
||||
|
||||
test('issues/comment POSTs a note and links it to the issue URL', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url) && (!options.method || options.method === 'GET')) {
|
||||
return jsonResponse({ iid: 7, web_url: 'https://gitlab.com/group/sub/-/issues/7' });
|
||||
}
|
||||
if (matches(/\/issues\/7\/notes$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({ id: 5, body: 'Nice catch', author: { id: 42, username: 'alice', name: 'Alice Example' } }, { status: 201 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitlab/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7, body: 'Nice catch' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
repo: { namespace: 'group', project: 'sub', host: 'gitlab.com' },
|
||||
comment: {
|
||||
id: 5,
|
||||
url: 'https://gitlab.com/group/sub/-/issues/7#note_5',
|
||||
body: 'Nice catch',
|
||||
author: { username: 'alice', name: 'Alice Example', id: 42 },
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[1];
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
|
||||
});
|
||||
|
||||
test('issues/comment reports connected:false when not authenticated', async () => {
|
||||
clearGitLabAuth();
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitlab/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7, body: 'hello' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ connected: false });
|
||||
});
|
||||
|
||||
test('issues/comment surfaces a 403 as an api-scope error', async () => {
|
||||
scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url)) {
|
||||
return jsonResponse({ iid: 7, web_url: 'https://gitlab.com/group/sub/-/issues/7' });
|
||||
}
|
||||
if (matches(/\/issues\/7\/notes$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({ message: '403 Forbidden' }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitlab/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7, body: 'hello' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Your GitLab token needs the api scope to create issue comments' });
|
||||
});
|
||||
|
||||
test('issues/update maps state_event, labels, assignee_ids, and resolves the milestone title', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url) => (matches(/\/milestones\?/)(url)
|
||||
? jsonResponse([{ id: 33, title: 'v1.0', state: 'active' }])
|
||||
: null),
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url) && options.method === 'PUT') {
|
||||
return jsonResponse({
|
||||
iid: 7,
|
||||
title: 'Updated issue',
|
||||
web_url: 'https://gitlab.com/group/sub/-/issues/7',
|
||||
state: 'closed',
|
||||
description: 'New body',
|
||||
author: { id: 42, username: 'alice', name: 'Alice Example' },
|
||||
labels: ['bug'],
|
||||
assignees: [{ id: 43, username: 'bob' }],
|
||||
milestone: { id: 33, title: 'v1.0', state: 'active' },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.put('/api/gitlab/issues/update')
|
||||
.send({
|
||||
directory: '/tmp/work',
|
||||
number: 7,
|
||||
title: 'Updated issue',
|
||||
body: 'New body',
|
||||
state: 'closed',
|
||||
labels: ['bug'],
|
||||
assigneeIds: [43],
|
||||
milestone: 'v1.0',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
issue: {
|
||||
number: 7,
|
||||
title: 'Updated issue',
|
||||
state: 'closed',
|
||||
body: 'New body',
|
||||
labels: ['bug'],
|
||||
assignees: [{ username: 'bob', id: 43 }],
|
||||
milestone: { title: 'v1.0', state: 'active' },
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[1];
|
||||
expect(options.method).toBe('PUT');
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
title: 'Updated issue',
|
||||
description: 'New body',
|
||||
state_event: 'close',
|
||||
labels: ['bug'],
|
||||
assignee_ids: [43],
|
||||
milestone_id: 33,
|
||||
});
|
||||
});
|
||||
|
||||
test('issues/update maps state open to state_event reopen', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url) && options.method === 'PUT') {
|
||||
return jsonResponse({ iid: 7, title: 'T', web_url: 'u', state: 'opened', author: {} });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
await request(app)
|
||||
.put('/api/gitlab/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, state: 'open' });
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ state_event: 'reopen' });
|
||||
});
|
||||
|
||||
test('issues/update clears the milestone when null is sent', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url) && options.method === 'PUT') {
|
||||
return jsonResponse({ iid: 7, title: 'T', web_url: 'u', state: 'opened', author: {} });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
await request(app)
|
||||
.put('/api/gitlab/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, milestone: null });
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ milestone_id: null });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('issues/update returns 400 when the milestone title does not match', async () => {
|
||||
scriptedFetch([
|
||||
(url) => (matches(/\/milestones\?/)(url)
|
||||
? jsonResponse([{ id: 33, title: 'v1.0' }])
|
||||
: null),
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.put('/api/gitlab/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, milestone: 'v2.0' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Milestone not found' });
|
||||
});
|
||||
|
||||
test('mrs/comment POSTs an MR note and links it to the MR URL', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/merge_requests\/12$/)(url) && (!options.method || options.method === 'GET')) {
|
||||
return jsonResponse({ iid: 12, web_url: 'https://gitlab.com/group/sub/-/merge_requests/12' });
|
||||
}
|
||||
if (matches(/\/merge_requests\/12\/notes$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({ id: 8, body: 'LGTM', author: { id: 43, username: 'bob' } }, { status: 201 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitlab/mrs/comment')
|
||||
.send({ directory: '/tmp/work', number: 12, body: 'LGTM' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
comment: {
|
||||
id: 8,
|
||||
url: 'https://gitlab.com/group/sub/-/merge_requests/12#note_8',
|
||||
body: 'LGTM',
|
||||
author: { username: 'bob', id: 43 },
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[1];
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'LGTM' });
|
||||
});
|
||||
|
||||
test('mrs/approve POSTs the approve request and reports approved:true', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/merge_requests\/12\/approve$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({ id: 1, state: 'approved' });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitlab/mrs/approve')
|
||||
.send({ directory: '/tmp/work', number: 12 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
repo: { namespace: 'group', project: 'sub', host: 'gitlab.com' },
|
||||
approved: true,
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe('POST');
|
||||
});
|
||||
|
||||
test('mrs/approve returns 404 for a missing merge request', async () => {
|
||||
scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/merge_requests\/999\/approve$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({ message: '404 Not Found' }, { status: 404 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitlab/mrs/approve')
|
||||
.send({ directory: '/tmp/work', number: 999 });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Merge request not found' });
|
||||
});
|
||||
|
||||
test('mrs/update maps state, labels, assignee_ids, and resolves the milestone title', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url) => (matches(/\/milestones\?/)(url)
|
||||
? jsonResponse([{ id: 33, title: 'v1.0', state: 'active' }])
|
||||
: null),
|
||||
(url, options) => {
|
||||
if (matches(/\/merge_requests\/12$/)(url) && options.method === 'PUT') {
|
||||
return jsonResponse({
|
||||
iid: 12,
|
||||
title: 'Updated title',
|
||||
web_url: 'https://gitlab.com/group/sub/-/merge_requests/12',
|
||||
state: 'opened',
|
||||
draft: false,
|
||||
work_in_progress: false,
|
||||
author: { id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png' },
|
||||
source_branch: 'feat/add',
|
||||
target_branch: 'main',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.put('/api/gitlab/mrs/update')
|
||||
.send({
|
||||
directory: '/tmp/work',
|
||||
number: 12,
|
||||
title: 'Updated title',
|
||||
state: 'open',
|
||||
labels: ['ready'],
|
||||
assigneeIds: [43],
|
||||
milestone: 'v1.0',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
mr: { number: 12, title: 'Updated title', state: 'opened' },
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[1];
|
||||
expect(options.method).toBe('PUT');
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
title: 'Updated title',
|
||||
state_event: 'reopen',
|
||||
labels: ['ready'],
|
||||
assignee_ids: [43],
|
||||
milestone_id: 33,
|
||||
});
|
||||
});
|
||||
|
||||
test('mrs/update returns 400 when the milestone title does not match', async () => {
|
||||
scriptedFetch([
|
||||
(url) => (matches(/\/milestones\?/)(url)
|
||||
? jsonResponse([{ id: 33, title: 'v1.0' }])
|
||||
: null),
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.put('/api/gitlab/mrs/update')
|
||||
.send({ directory: '/tmp/work', number: 12, milestone: 'nope' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Milestone not found' });
|
||||
});
|
||||
|
||||
// NOTE: keep this test last in the file. The rate-limit cooldown is
|
||||
// module-level and has no reset export, so tests after it would short-circuit.
|
||||
test('data routes surface a 503 when GitLab rate limits', async () => {
|
||||
scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse({}, { status: 429, headers: { 'retry-after': '30' } }) : null)]);
|
||||
|
||||
|
||||
@@ -2,9 +2,13 @@ import type {
|
||||
GiteaAPI,
|
||||
GiteaAuthStatus,
|
||||
GiteaBranchesResult,
|
||||
GiteaIssueCommentInput,
|
||||
GiteaIssueCommentResult,
|
||||
GiteaIssueCommentsResult,
|
||||
GiteaIssueGetResult,
|
||||
GiteaIssuesListResult,
|
||||
GiteaIssueUpdateInput,
|
||||
GiteaIssueUpdateResult,
|
||||
GiteaPullRequest,
|
||||
GiteaPullRequestCommitsResult,
|
||||
GiteaPullRequestContextResult,
|
||||
@@ -15,6 +19,9 @@ import type {
|
||||
GiteaPullRequestsListResult,
|
||||
GiteaPullRequestStatusesResult,
|
||||
GiteaPullRequestUpdateInput,
|
||||
GiteaPullReviewInput,
|
||||
GiteaPullReviewResult,
|
||||
GiteaRepoLabelsResult,
|
||||
GiteaUserSummary,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
|
||||
@@ -287,6 +294,77 @@ export const createWebGiteaAPI = ({ urls }: WebGiteaAPIOptions): GiteaAPI => ({
|
||||
};
|
||||
},
|
||||
|
||||
async issueComment(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult> {
|
||||
const response = await runtimeFetch('/api/gitea/issues/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GiteaIssueCommentResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post Gitea issue comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async issueUpdate(input: GiteaIssueUpdateInput): Promise<GiteaIssueUpdateResult> {
|
||||
const response = await runtimeFetch('/api/gitea/issues/update', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GiteaIssueUpdateResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to update Gitea issue');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prComment(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult> {
|
||||
const response = await runtimeFetch('/api/gitea/prs/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GiteaIssueCommentResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post Gitea pull request comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prSubmitReview(input: GiteaPullReviewInput): Promise<GiteaPullReviewResult> {
|
||||
const response = await runtimeFetch('/api/gitea/prs/review', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GiteaPullReviewResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to submit Gitea pull request review');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async repoLabels(directory: string, options?: { owner?: string; repo?: string }): Promise<GiteaRepoLabelsResult> {
|
||||
const params = new URLSearchParams({ directory });
|
||||
if (options?.owner) {
|
||||
params.set('owner', options.owner);
|
||||
}
|
||||
if (options?.repo) {
|
||||
params.set('repo', options.repo);
|
||||
}
|
||||
const response = await runtimeFetch(
|
||||
`/api/gitea/repo/labels?${params.toString()}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const body = await jsonOrNull<GiteaRepoLabelsResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to fetch Gitea repo labels');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async repoBranches(owner: string, repo: string): Promise<GiteaBranchesResult> {
|
||||
const response = await runtimeFetch(
|
||||
`/api/gitea/repo/branches?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`,
|
||||
|
||||
@@ -2,7 +2,11 @@ import type {
|
||||
GitHubAPI,
|
||||
GitHubAuthStatus,
|
||||
GitHubIssueCommentsResult,
|
||||
GitHubIssueCommentInput,
|
||||
GitHubIssueCommentResult,
|
||||
GitHubIssueGetResult,
|
||||
GitHubIssueUpdateInput,
|
||||
GitHubIssueUpdateResult,
|
||||
GitHubIssuesListResult,
|
||||
GitHubPullRequestContextResult,
|
||||
GitHubPullRequestCommitsResult,
|
||||
@@ -16,7 +20,11 @@ import type {
|
||||
GitHubPullRequestReadyResult,
|
||||
GitHubPullRequestUpdateInput,
|
||||
GitHubPullRequestStatus,
|
||||
GitHubPullRequestReviewInput,
|
||||
GitHubPullRequestReviewResult,
|
||||
GitHubRepoUpstreamResult,
|
||||
GitHubReviewCommentInput,
|
||||
GitHubReviewCommentResult,
|
||||
GitHubDeviceFlowComplete,
|
||||
GitHubDeviceFlowStart,
|
||||
GitHubUserSummary,
|
||||
@@ -325,4 +333,69 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async issueComment(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult> {
|
||||
const response = await runtimeFetch('/api/github/issues/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubIssueCommentResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post GitHub comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async issueUpdate(input: GitHubIssueUpdateInput): Promise<GitHubIssueUpdateResult> {
|
||||
const response = await runtimeFetch('/api/github/issues/update', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubIssueUpdateResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to update GitHub issue');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prComment(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult> {
|
||||
const response = await runtimeFetch('/api/github/pulls/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubIssueCommentResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post GitHub PR comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prReviewComment(input: GitHubReviewCommentInput): Promise<GitHubReviewCommentResult> {
|
||||
const response = await runtimeFetch('/api/github/pulls/review-comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubReviewCommentResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post GitHub review comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prSubmitReview(input: GitHubPullRequestReviewInput): Promise<GitHubPullRequestReviewResult> {
|
||||
const response = await runtimeFetch('/api/github/pulls/review', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubPullRequestReviewResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to submit GitHub review');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,9 +2,13 @@ import type {
|
||||
GitLabAPI,
|
||||
GitLabAuthStatus,
|
||||
GitLabBranchesResult,
|
||||
GitLabIssueCommentResult,
|
||||
GitLabIssueCommentsResult,
|
||||
GitLabIssueCommentInput,
|
||||
GitLabIssueGetResult,
|
||||
GitLabIssuesListResult,
|
||||
GitLabIssueUpdateInput,
|
||||
GitLabIssueUpdateResult,
|
||||
GitLabMergeRequest,
|
||||
GitLabMergeRequestCommitsResult,
|
||||
GitLabMergeRequestContextResult,
|
||||
@@ -16,6 +20,10 @@ import type {
|
||||
GitLabMergeRequestTimelineResult,
|
||||
GitLabMergeRequestUpdateInput,
|
||||
GitLabMergeRequestUpdateResult,
|
||||
GitLabMrApproveInput,
|
||||
GitLabMrApproveResult,
|
||||
GitLabMrNoteInput,
|
||||
GitLabMrNoteResult,
|
||||
GitLabUserSummary,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
|
||||
@@ -266,6 +274,58 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
|
||||
};
|
||||
},
|
||||
|
||||
async issueComment(input: GitLabIssueCommentInput): Promise<GitLabIssueCommentResult> {
|
||||
const response = await runtimeFetch('/api/gitlab/issues/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitLabIssueCommentResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post GitLab issue comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async issueUpdate(input: GitLabIssueUpdateInput): Promise<GitLabIssueUpdateResult> {
|
||||
const response = await runtimeFetch('/api/gitlab/issues/update', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitLabIssueUpdateResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to update GitLab issue');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async mrComment(input: GitLabMrNoteInput): Promise<GitLabMrNoteResult> {
|
||||
const response = await runtimeFetch('/api/gitlab/mrs/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitLabMrNoteResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post GitLab merge request comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async mrApprove(input: GitLabMrApproveInput): Promise<GitLabMrApproveResult> {
|
||||
const response = await runtimeFetch('/api/gitlab/mrs/approve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitLabMrApproveResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to approve GitLab merge request');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async repoBranches(namespace: string, project: string): Promise<GitLabBranchesResult> {
|
||||
const response = await runtimeFetch(
|
||||
`/api/gitlab/repo/branches?namespace=${encodeURIComponent(namespace)}&project=${encodeURIComponent(project)}`,
|
||||
|
||||
Reference in New Issue
Block a user