feat(ui): forge user lookup — assignee combobox, @-mentions, repo-scoped user search

Repo-scoped assignable-user search for GitHub, GitLab, and Gitea, surfaced as
an assignee combobox in the metadata editor and @-mention autocomplete in
forge comment/reply/review surfaces.

- server: GET /api/{provider}/users/search (assignees / project members),
  query + directory/override repo resolution, 429 -> 503, connected:false
  degradation; GitLab assignee writes resolve login -> ID server-side
- wire: searchUsers (+ searchLabels/milestones/branches/tags) on the three
  API clients with tests
- facade: userSearch capability (all three), searchUsers adapters,
  mapGithubAssignee/mapGitlabMember/mapGiteaAssignee -> ForgeUser
- ui: ForgeLookupCombobox (keyboard nav, debounced 30s-TTL cache,
  connected-only caching), ForgeMentionTextarea (@ token parsing, caret
  restore), free-text fallback when lookup is unavailable; i18n in 12 locales
- extras sharing the same infrastructure: GitLab create-issue dialog and
  label/milestone/branch/tag lookups in the metadata editor
This commit is contained in:
2026-08-16 16:29:25 +00:00
parent 1f28b61c5a
commit 3800c84948
47 changed files with 4052 additions and 35 deletions
@@ -1,11 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider'; import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
import type { ForgeComment } from '@/lib/forge/types'; import type { ForgeComment } from '@/lib/forge/types';
import { ForgeMentionTextarea } from './ForgeMentionTextarea';
interface ForgeCommentComposerProps { interface ForgeCommentComposerProps {
provider: ForgeProvider; provider: ForgeProvider;
@@ -47,12 +47,14 @@ export const ForgeCommentComposer: React.FC<ForgeCommentComposerProps> = ({ prov
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Textarea <ForgeMentionTextarea
provider={provider}
directory={directory}
value={body} value={body}
onChange={(event) => setBody(event.target.value)} onChange={setBody}
placeholder={t('forge.actions.commentPlaceholder')} placeholder={t('forge.actions.commentPlaceholder')}
disabled={submitting} disabled={submitting}
aria-label={t('forge.actions.commentPlaceholder')} ariaLabel={t('forge.actions.commentPlaceholder')}
className="min-h-[72px]" className="min-h-[72px]"
/> />
<div className="flex justify-end"> <div className="flex justify-end">
@@ -0,0 +1,124 @@
import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
import type { ForgeIssue } from '@/lib/forge';
import type { ForgeProvider } from '@/lib/forge/provider';
interface ForgeCreateIssueDialogProps {
provider: ForgeProvider;
directory: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated?: (issue: ForgeIssue) => void;
}
/**
* Create-issue dialog for a forge provider. Renders nothing when the provider
* has no `createIssue` method. Submits title/body/labels (comma-separated
* input) through the facade and reports success via `onCreated` so the list
* can refresh.
*/
export const ForgeCreateIssueDialog: React.FC<ForgeCreateIssueDialogProps> = ({
provider,
directory,
open,
onOpenChange,
onCreated,
}) => {
const { t } = useI18n();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [labels, setLabels] = useState('');
const [submitting, setSubmitting] = useState(false);
const createIssue = provider.createIssue;
if (!createIssue) return null;
const reset = (): void => {
setTitle('');
setBody('');
setLabels('');
};
const handleOpenChange = (next: boolean): void => {
if (!next) {
reset();
}
onOpenChange(next);
};
const submit = async (): Promise<void> => {
const trimmedTitle = title.trim();
if (!trimmedTitle || submitting) return;
setSubmitting(true);
try {
const labelList = labels
.split(',')
.map((label) => label.trim())
.filter(Boolean);
const result = await createIssue(directory, {
title: trimmedTitle,
...(body.trim() ? { body: body.trim() } : {}),
...(labelList.length > 0 ? { labels: labelList } : {}),
});
if (!result.ok || !result.issue) {
toast.error(t('forge.actions.error'));
return;
}
toast.success(t('forge.actions.issueCreated'));
reset();
onOpenChange(false);
onCreated?.(result.issue);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('forge.actions.issueDialogTitle')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={t('forge.actions.issueTitlePlaceholder')}
disabled={submitting}
aria-label={t('forge.actions.issueTitlePlaceholder')}
/>
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={t('forge.actions.issueBodyPlaceholder')}
disabled={submitting}
aria-label={t('forge.actions.issueBodyPlaceholder')}
className="min-h-[96px]"
/>
<Input
value={labels}
onChange={(event) => setLabels(event.target.value)}
placeholder={t('forge.actions.issueLabelsPlaceholder')}
disabled={submitting}
aria-label={t('forge.actions.issueLabelsPlaceholder')}
/>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => handleOpenChange(false)} disabled={submitting}>
{t('forge.actions.cancel')}
</Button>
<Button size="sm" onClick={() => void submit()} disabled={submitting || title.trim().length === 0}>
{submitting ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="check" className="size-4" />}
{t('forge.actions.createIssue')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,190 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { Icon } from '@/components/icon/Icon';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useI18n } from '@/lib/i18n';
import type { ForgeProvider } from '@/lib/forge/provider';
import { useForgeLookup } from './useForgeLookup';
import type { ForgeLookupKind, ForgeLookupOption } from './useForgeLookup';
export interface ForgeLookupComboboxProps {
provider: ForgeProvider;
directory: string;
/** Cross-repo (fork) selector, passed through to the facade. */
sourceRepo?: string | null;
kind: ForgeLookupKind;
value: string;
onChange: (value: string) => void;
/** Called when the user picks an option (not when they type free text). */
onSelect: (option: ForgeLookupOption) => void;
placeholder?: string;
ariaLabel?: string;
disabled?: boolean;
className?: string;
}
const normalizeColor = (color?: string): string | null => {
if (!color) return null;
const value = color.trim();
if (!value) return null;
return value.startsWith('#') ? value : `#${value}`;
};
/**
* Search-as-you-type combobox for forge metadata fields (assignees, labels,
* milestones, branches, tags). Renders a plain input until the provider offers
* a matching `search*` method; once it does, typing opens a dropdown of
* repo-scoped options with keyboard navigation. Selecting an option calls
* `onSelect`; free text still passes through `onChange` so surfaces keep their
* free-entry fallback.
*/
export const ForgeLookupCombobox: React.FC<ForgeLookupComboboxProps> = ({
provider,
directory,
sourceRepo,
kind,
value,
onChange,
onSelect,
placeholder,
ariaLabel,
disabled,
className,
}) => {
const { t } = useI18n();
const rootRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const { options, loading } = useForgeLookup({ provider, directory, sourceRepo, kind, query: value });
const hasLookup = useMemo(() => {
switch (kind) {
case 'users':
return typeof provider.searchUsers === 'function' && provider.capabilities.userSearch;
case 'labels':
return typeof provider.searchLabels === 'function' && provider.capabilities.labelSearch;
case 'milestones':
return typeof provider.searchMilestones === 'function' && provider.capabilities.milestoneSearch;
case 'branches':
return typeof provider.searchBranches === 'function' && provider.capabilities.branchSearch;
case 'tags':
return typeof provider.searchTags === 'function' && provider.capabilities.tagSearch;
}
}, [kind, provider]);
useEffect(() => {
setHighlighted(0);
}, [options]);
// Close on outside click.
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null;
if (target && rootRef.current && !rootRef.current.contains(target)) setOpen(false);
};
document.addEventListener('pointerdown', handlePointerDown, true);
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
}, [open]);
const choose = useCallback((option: ForgeLookupOption) => {
setOpen(false);
onSelect(option);
}, [onSelect]);
return (
<div ref={rootRef} className="relative">
<input
ref={inputRef}
value={value}
onChange={(event) => {
onChange(event.target.value);
if (event.target.value.trim()) setOpen(true);
}}
onFocus={() => {
if (hasLookup) setOpen(true);
}}
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
if (!open) {
setOpen(true);
return;
}
event.preventDefault();
setHighlighted((prev) => (options.length ? (prev + 1) % options.length : 0));
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlighted((prev) => (options.length ? (prev - 1 + options.length) % options.length : 0));
return;
}
if (event.key === 'Escape') {
if (open) {
event.preventDefault();
setOpen(false);
}
return;
}
if (event.key === 'Enter' && open && options[highlighted]) {
event.preventDefault();
choose(options[highlighted]);
return;
}
}}
placeholder={placeholder}
aria-label={ariaLabel}
disabled={disabled}
aria-expanded={open}
aria-autocomplete="list"
aria-controls={open ? 'forge-lookup-list' : undefined}
aria-activedescendant={open && options[highlighted] ? `forge-lookup-${kind}-${options[highlighted].key}` : undefined}
className={cn('h-6 w-36 appearance-none rounded-md bg-[var(--surface-elevated)] px-2 typography-micro text-foreground placeholder:text-muted-foreground', 'ring-1 ring-inset ring-border/60 focus:ring-2 focus:ring-[var(--interactive-focus-ring)] focus-visible:outline-none', className)}
/>
{open && hasLookup ? (
<div
id="forge-lookup-list"
role="listbox"
className="absolute left-0 right-0 top-full z-50 mt-1 min-w-0 max-w-full overflow-hidden rounded-md border border-border/60 bg-[var(--surface-elevated)] shadow-lg"
>
<ScrollableOverlay preventOverscroll outerClassName="max-h-44 min-h-0">
{loading ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-3 animate-spin" />
{t('forge.lookup.loading')}
</div>
) : options.length === 0 ? (
<div className="px-2 py-1.5 typography-micro text-muted-foreground">{t('forge.lookup.empty')}</div>
) : (
options.map((option, index) => (
<div
key={option.key}
id={`forge-lookup-${kind}-${option.key}`}
role="option"
aria-selected={index === highlighted}
className={cn(
'flex cursor-pointer items-center gap-1.5 px-2 py-1 typography-micro text-foreground',
index === highlighted && 'bg-interactive-selection',
)}
onClick={() => choose(option)}
onMouseMove={() => setHighlighted(index)}
>
{option.avatarUrl ? (
<img src={option.avatarUrl} alt="" className="size-3.5 shrink-0 rounded-full object-cover" />
) : option.color ? (
<span aria-hidden className="size-2 shrink-0 rounded-full" style={{ backgroundColor: normalizeColor(option.color) ?? 'var(--status-info)' }} />
) : null}
<span className="min-w-0 truncate">{option.label}</span>
{option.secondary ? (
<span className="truncate text-muted-foreground">{option.secondary}</span>
) : null}
</div>
))
)}
</ScrollableOverlay>
</div>
) : null}
</div>
);
};
@@ -0,0 +1,206 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { Icon } from '@/components/icon/Icon';
import { Textarea } from '@/components/ui/textarea';
import { useI18n } from '@/lib/i18n';
import type { ForgeProvider } from '@/lib/forge/provider';
import { useForgeLookup } from './useForgeLookup';
import type { ForgeLookupOption } from './useForgeLookup';
export interface ForgeMentionTextareaProps {
provider: ForgeProvider;
directory: string;
/** Cross-repo (fork) selector, passed through to the user lookup. */
sourceRepo?: string | null;
value: string;
onChange: (value: string) => void;
placeholder?: string;
ariaLabel?: string;
disabled?: boolean;
className?: string;
autoFocus?: boolean;
}
/** `@`-prefixed token before the caret, e.g. `{ start: 4, query: 'octo' }` for `hey @octo|`. */
interface MentionToken {
start: number;
query: string;
}
const MENTION_RE = /(^|\s|[,;(])@([a-zA-Z0-9][a-zA-Z0-9-_.]*)$/;
/**
* Detect the mention token ending at `caret` in `text`. Returns null when there
* is no `@`-trigger in flight.
*/
const findMentionToken = (text: string, caret: number): MentionToken | null => {
const before = text.slice(0, caret);
const match = MENTION_RE.exec(before);
if (!match) return null;
const prefix = match[1] ?? '';
return { start: caret - match[0].length + prefix.length, query: match[2] };
};
/**
* Textarea with repo-scoped @-mention autocomplete for forge comment bodies.
*
* Typing `@` followed by a prefix opens a dropdown of assignable users from
* `provider.searchUsers` (debounced). Arrow keys move the highlight, Enter/Tab
* insert `@login ` in place of the partial token, and Escape closes the list.
* Rendering is gated on `capabilities.userSearch` + method presence; otherwise
* it behaves as a plain textarea.
*/
export const ForgeMentionTextarea: React.FC<ForgeMentionTextareaProps> = ({
provider,
directory,
sourceRepo,
value,
onChange,
placeholder,
ariaLabel,
disabled,
className,
autoFocus,
}) => {
const { t } = useI18n();
const rootRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [token, setToken] = useState<MentionToken | null>(null);
const [highlighted, setHighlighted] = useState(0);
const hasLookup = typeof provider.searchUsers === 'function' && provider.capabilities.userSearch;
const { options, loading } = useForgeLookup({
provider,
directory,
sourceRepo,
kind: 'users',
query: token?.query ?? '',
});
useEffect(() => {
setHighlighted(0);
}, [options]);
// Close on outside click.
useEffect(() => {
if (!token) return;
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null;
if (target && rootRef.current && !rootRef.current.contains(target)) setToken(null);
};
document.addEventListener('pointerdown', handlePointerDown, true);
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
}, [token]);
const insertMention = useCallback((option: ForgeLookupOption) => {
if (!token) return;
const next = `${value.slice(0, token.start)}@${option.label} ${value.slice(textareaRef.current?.selectionStart ?? token.start + token.query.length)}`;
onChange(next);
setToken(null);
// Restore the caret after the inserted mention.
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
const caret = token.start + option.label.length + 2;
el.focus();
el.setSelectionRange(caret, caret);
}
});
}, [onChange, token, value]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>): void => {
if (!token) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlighted((prev) => (options.length ? (prev + 1) % options.length : 0));
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlighted((prev) => (options.length ? (prev - 1 + options.length) % options.length : 0));
return;
}
if (event.key === 'Escape') {
event.preventDefault();
setToken(null);
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
if (options[highlighted]) {
event.preventDefault();
insertMention(options[highlighted]);
}
return;
}
};
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>): void => {
const next = event.target.value;
onChange(next);
if (hasLookup) {
setToken(findMentionToken(next, event.target.selectionStart ?? next.length));
}
};
const openToken = token && hasLookup;
const filtered = useMemo(
() => (token?.query ? options.filter((option) => option.label.toLowerCase().includes(token.query.toLowerCase())) : options),
[options, token?.query],
);
return (
<div ref={rootRef} className="relative">
<Textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
aria-label={ariaLabel}
disabled={disabled}
autoFocus={autoFocus}
className={className}
aria-expanded={Boolean(openToken)}
aria-controls={openToken ? 'forge-mention-list' : undefined}
aria-activedescendant={openToken && filtered[highlighted] ? `forge-mention-${filtered[highlighted].key}` : undefined}
/>
{openToken ? (
<div
id="forge-mention-list"
role="listbox"
className="absolute left-0 right-0 bottom-full z-50 mb-1 max-h-44 min-w-0 overflow-hidden rounded-md border border-border/60 bg-[var(--surface-elevated)] shadow-lg"
>
{loading ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-3 animate-spin" />
{t('forge.lookup.loading')}
</div>
) : filtered.length === 0 ? (
<div className="px-2 py-1.5 typography-micro text-muted-foreground">{t('forge.lookup.empty')}</div>
) : (
filtered.map((option, index) => (
<div
key={option.key}
id={`forge-mention-${option.key}`}
role="option"
aria-selected={index === highlighted}
className={cn(
'flex cursor-pointer items-center gap-1.5 px-2 py-1 typography-micro text-foreground',
index === highlighted && 'bg-interactive-selection',
)}
onClick={() => insertMention(option)}
onMouseMove={() => setHighlighted(index)}
>
{option.avatarUrl ? (
<img src={option.avatarUrl} alt="" className="size-3.5 shrink-0 rounded-full object-cover" />
) : null}
<span className="min-w-0 truncate">{option.label}</span>
{option.secondary ? <span className="truncate text-muted-foreground">{option.secondary}</span> : null}
</div>
))
)}
</div>
) : null}
</div>
);
};
@@ -1,12 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { I18nKey } from '@/lib/i18n'; import type { I18nKey } from '@/lib/i18n';
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider'; import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
import type { ForgeLabel, ForgeMilestone, ForgeUser } from '@/lib/forge/types'; import type { ForgeLabel, ForgeMilestone, ForgeUser } from '@/lib/forge/types';
import { ForgeLookupCombobox } from './ForgeLookupCombobox';
import type { ForgeLookupOption } from './useForgeLookup';
interface ForgeMetadataEditorProps { interface ForgeMetadataEditorProps {
provider: ForgeProvider; provider: ForgeProvider;
@@ -88,6 +89,11 @@ export const ForgeMetadataEditor: React.FC<ForgeMetadataEditorProps> = ({
setLabelInput(''); setLabelInput('');
}; };
const addLabelOption = async (option: ForgeLookupOption): Promise<void> => {
setLabelInput(option.label);
await addLabel();
};
const removeLabel = async (name: string): Promise<void> => { const removeLabel = async (name: string): Promise<void> => {
await runMetadata({ labels: labels.filter((label) => label.name !== name).map((label) => label.name) }, 'forge.actions.removed'); await runMetadata({ labels: labels.filter((label) => label.name !== name).map((label) => label.name) }, 'forge.actions.removed');
}; };
@@ -99,6 +105,11 @@ export const ForgeMetadataEditor: React.FC<ForgeMetadataEditorProps> = ({
setAssigneeInput(''); setAssigneeInput('');
}; };
const addAssigneeOption = async (option: ForgeLookupOption): Promise<void> => {
setAssigneeInput(option.label);
await addAssignee();
};
const removeAssignee = async (id: string): Promise<void> => { const removeAssignee = async (id: string): Promise<void> => {
await runMetadata({ assignees: assignees.filter((assignee) => assignee.id !== id).map((assignee) => assignee.login) }, 'forge.actions.removed'); await runMetadata({ assignees: assignees.filter((assignee) => assignee.id !== id).map((assignee) => assignee.login) }, 'forge.actions.removed');
}; };
@@ -110,6 +121,11 @@ export const ForgeMetadataEditor: React.FC<ForgeMetadataEditorProps> = ({
setMilestoneInput(''); setMilestoneInput('');
}; };
const addMilestoneOption = async (option: ForgeLookupOption): Promise<void> => {
setMilestoneInput(option.label);
await addMilestone();
};
const removeMilestone = async (): Promise<void> => { const removeMilestone = async (): Promise<void> => {
await runMetadata({ milestone: null }, 'forge.actions.removed'); await runMetadata({ milestone: null }, 'forge.actions.removed');
}; };
@@ -188,15 +204,16 @@ export const ForgeMetadataEditor: React.FC<ForgeMetadataEditorProps> = ({
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{canLabels ? ( {canLabels ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Input <ForgeLookupCombobox
provider={provider}
directory={directory}
kind="labels"
value={labelInput} value={labelInput}
onChange={(event) => setLabelInput(event.target.value)} onChange={setLabelInput}
onSelect={(option) => void addLabelOption(option)}
placeholder={t('forge.actions.addLabel')} placeholder={t('forge.actions.addLabel')}
aria-label={t('forge.actions.addLabel')} aria-label={t('forge.actions.addLabel')}
className="h-6 w-36" 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()}> <Button variant="ghost" size="xs" onClick={() => void addLabel()} disabled={submitting || !labelInput.trim()}>
{t('forge.actions.addLabel')} {t('forge.actions.addLabel')}
@@ -206,15 +223,16 @@ export const ForgeMetadataEditor: React.FC<ForgeMetadataEditorProps> = ({
{canAssignees ? ( {canAssignees ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Input <ForgeLookupCombobox
provider={provider}
directory={directory}
kind="users"
value={assigneeInput} value={assigneeInput}
onChange={(event) => setAssigneeInput(event.target.value)} onChange={setAssigneeInput}
onSelect={(option) => void addAssigneeOption(option)}
placeholder={t('forge.actions.addAssignee')} placeholder={t('forge.actions.addAssignee')}
aria-label={t('forge.actions.addAssignee')} aria-label={t('forge.actions.addAssignee')}
className="h-6 w-36" 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()}> <Button variant="ghost" size="xs" onClick={() => void addAssignee()} disabled={submitting || !assigneeInput.trim()}>
{t('forge.actions.addAssignee')} {t('forge.actions.addAssignee')}
@@ -224,15 +242,16 @@ export const ForgeMetadataEditor: React.FC<ForgeMetadataEditorProps> = ({
{canMilestones ? ( {canMilestones ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Input <ForgeLookupCombobox
provider={provider}
directory={directory}
kind="milestones"
value={milestoneInput} value={milestoneInput}
onChange={(event) => setMilestoneInput(event.target.value)} onChange={setMilestoneInput}
onSelect={(option) => void addMilestoneOption(option)}
placeholder={t('forge.actions.setMilestone')} placeholder={t('forge.actions.setMilestone')}
aria-label={t('forge.actions.setMilestone')} aria-label={t('forge.actions.setMilestone')}
className="h-6 w-36" 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()}> <Button variant="ghost" size="xs" onClick={() => void addMilestone()} disabled={submitting || !milestoneInput.trim()}>
{t('forge.actions.setMilestone')} {t('forge.actions.setMilestone')}
@@ -2,11 +2,11 @@ import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { I18nKey } from '@/lib/i18n'; import type { I18nKey } from '@/lib/i18n';
import type { ForgeEntityRef, ForgeProvider, ForgeReviewEvent } from '@/lib/forge/provider'; import type { ForgeEntityRef, ForgeProvider, ForgeReviewEvent } from '@/lib/forge/provider';
import { ForgeMentionTextarea } from './ForgeMentionTextarea';
interface ForgeReviewActionsProps { interface ForgeReviewActionsProps {
provider: ForgeProvider; provider: ForgeProvider;
@@ -94,12 +94,14 @@ export const ForgeReviewActions: React.FC<ForgeReviewActionsProps> = ({ provider
<DialogHeader> <DialogHeader>
<DialogTitle>{t('forge.actions.reviewDialogTitle')}</DialogTitle> <DialogTitle>{t('forge.actions.reviewDialogTitle')}</DialogTitle>
</DialogHeader> </DialogHeader>
<Textarea <ForgeMentionTextarea
provider={provider}
directory={directory}
value={body} value={body}
onChange={(event) => setBody(event.target.value)} onChange={setBody}
placeholder={t('forge.actions.reviewBodyPlaceholder')} placeholder={t('forge.actions.reviewBodyPlaceholder')}
disabled={submitting} disabled={submitting}
aria-label={t('forge.actions.reviewBodyPlaceholder')} ariaLabel={t('forge.actions.reviewBodyPlaceholder')}
className="min-h-[96px]" className="min-h-[96px]"
/> />
<DialogFooter> <DialogFooter>
@@ -1,11 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider'; import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
import type { ForgeComment } from '@/lib/forge/types'; import type { ForgeComment } from '@/lib/forge/types';
import { ForgeMentionTextarea } from './ForgeMentionTextarea';
/** Anchor of the thread being replied to (see `ForgeComment.inReplyToId`/`path`/`line`). */ /** Anchor of the thread being replied to (see `ForgeComment.inReplyToId`/`path`/`line`). */
export interface ForgeThreadTarget { export interface ForgeThreadTarget {
@@ -62,12 +62,14 @@ export const ForgeThreadReply: React.FC<ForgeThreadReplyProps> = ({ provider, di
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Textarea <ForgeMentionTextarea
provider={provider}
directory={directory}
value={body} value={body}
onChange={(event) => setBody(event.target.value)} onChange={setBody}
placeholder={t('forge.actions.commentPlaceholder')} placeholder={t('forge.actions.commentPlaceholder')}
disabled={submitting} disabled={submitting}
aria-label={t('forge.actions.commentPlaceholder')} ariaLabel={t('forge.actions.commentPlaceholder')}
className="min-h-[56px]" className="min-h-[56px]"
autoFocus autoFocus
/> />
@@ -16,3 +16,4 @@ export { ForgeDraftToggle } from './ForgeDraftToggle';
export { ForgeMetadataEditor } from './ForgeMetadataEditor'; export { ForgeMetadataEditor } from './ForgeMetadataEditor';
export { ForgeEditForm } from './ForgeEditForm'; export { ForgeEditForm } from './ForgeEditForm';
export { ForgeEntityActions } from './ForgeEntityActions'; export { ForgeEntityActions } from './ForgeEntityActions';
export { ForgeCreateIssueDialog } from './ForgeCreateIssueDialog';
@@ -0,0 +1,206 @@
import { useEffect, useState } from 'react';
import type { ForgeProvider } from '@/lib/forge/provider';
import type { ForgeLabel, ForgeMilestone, ForgeUser } from '@/lib/forge/types';
/** Which picker a lookup feeds; each maps onto one `provider.search*` method. */
export type ForgeLookupKind = 'users' | 'labels' | 'milestones' | 'branches' | 'tags';
/**
* A normalized, display-ready row for the shared forge lookup dropdown.
* The owning surface maps provider result shapes onto this.
*/
export interface ForgeLookupOption {
/** Stable key (login / label name / milestone title / branch / tag). */
key: string;
/** Primary display text. */
label: string;
/** Secondary line (e.g. a user's real name). */
secondary?: string;
avatarUrl?: string;
/** Label color dot (hex as returned by the provider). */
color?: string;
}
/** Resolve the dropdown option shape for a given provider/kind result. */
export const toLookupOptions = (
kind: ForgeLookupKind,
users: ForgeUser[],
labels: ForgeLabel[],
milestones: ForgeMilestone[],
branches: string[],
tags: string[],
): ForgeLookupOption[] => {
switch (kind) {
case 'users':
return users.map((user) => ({
key: user.login,
label: user.login,
...(user.name ? { secondary: user.name } : {}),
...(user.avatarUrl ? { avatarUrl: user.avatarUrl } : {}),
}));
case 'labels':
return labels.map((label) => ({
key: label.name,
label: label.name,
...(label.color ? { color: label.color } : {}),
}));
case 'milestones':
return milestones.map((milestone) => ({ key: milestone.title, label: milestone.title }));
case 'branches':
return branches.map((branch) => ({ key: branch, label: branch }));
case 'tags':
return tags.map((tag) => ({ key: tag, label: tag }));
}
};
// --- Short-TTL lookup cache ---
//
// The lookup is debounced but still fires once per settled (kind, directory,
// repo, query), so a picker interaction that re-asks for the same repo/query
// (reopening the dropdown, switching fields back and forth) would re-hit the
// provider. A short module-local TTL serves a fresh-enough result synchronously,
// skipping both the network call and the debounce timer.
//
// Only `connected: true` results are cached: a failed or disconnected lookup must
// never masquerade as an authoritative empty list (correctness invariant), so it
// is never stored and is always re-fetched.
const CACHE_TTL_MS = 30_000;
const CACHE_MAX_ENTRIES = 200;
interface ForgeLookupCacheEntry {
options: ForgeLookupOption[];
expiresAt: number;
}
const lookupCache = new Map<string, ForgeLookupCacheEntry>();
const cacheKey = (
kind: ForgeLookupKind,
directory: string,
sourceRepo: string | null | undefined,
query: string,
): string => `${kind}|${directory}|${sourceRepo ?? ''}|${query}`;
/** Drop expired entries and bound the map size on each write. */
const pruneCache = (now: number): void => {
for (const [key, entry] of lookupCache) {
if (entry.expiresAt <= now) lookupCache.delete(key);
}
// Map iteration is insertion-ordered, so dropping oldest first keeps the
// most recently written entries when the map overflows.
let excess = lookupCache.size - CACHE_MAX_ENTRIES;
if (excess > 0) {
for (const key of lookupCache.keys()) {
if (excess <= 0) break;
lookupCache.delete(key);
excess -= 1;
}
}
};
/**
* Debounced repo-scoped lookup for forge pickers. Fetches through the facade
* `search*` method for `kind` 250ms after the query settles, keeps the dropdown
* from firing on every keystroke, and never surfaces stale results (an
* out-of-order response is dropped). `connected: false` results are treated as
* "no authoritative options", never as a valid empty list.
*
* Successful results are cached per (kind, directory, repo, query) for
* `CACHE_TTL_MS`; a hit serves synchronously without a network call or debounce.
*/
export const useForgeLookup = ({
provider,
directory,
sourceRepo,
kind,
query,
}: {
provider: ForgeProvider;
directory: string;
sourceRepo?: string | null;
kind: ForgeLookupKind;
query: string;
}): { options: ForgeLookupOption[]; loading: boolean } => {
const [options, setOptions] = useState<ForgeLookupOption[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const key = cacheKey(kind, directory, sourceRepo, query);
const now = Date.now();
const cached = lookupCache.get(key);
if (cached && cached.expiresAt > now) {
// Fresh enough: serve without the network call or the debounce timer.
setOptions(cached.options);
setLoading(false);
return;
}
if (cached) lookupCache.delete(key);
let cancelled = false;
const timer = window.setTimeout(() => {
setLoading(true);
void (async () => {
try {
if (cancelled) return;
let next: ForgeLookupOption[] = [];
let connected = false;
if (kind === 'users') {
const run = provider.searchUsers?.(directory, query, { sourceRepo });
if (run) {
const result = await run;
connected = result.connected;
if (connected) next = toLookupOptions('users', result.users ?? [], [], [], [], []);
}
} else if (kind === 'labels') {
const run = provider.searchLabels?.(directory, query, { sourceRepo });
if (run) {
const result = await run;
connected = result.connected;
if (connected) next = toLookupOptions('labels', [], result.labels ?? [], [], [], []);
}
} else if (kind === 'milestones') {
const run = provider.searchMilestones?.(directory, query, { sourceRepo });
if (run) {
const result = await run;
connected = result.connected;
if (connected) next = toLookupOptions('milestones', [], [], result.milestones ?? [], [], []);
}
} else if (kind === 'branches') {
const run = provider.searchBranches?.(directory, query, { sourceRepo });
if (run) {
const result = await run;
connected = result.connected;
if (connected) next = toLookupOptions('branches', [], [], [], result.branches ?? [], []);
}
} else if (kind === 'tags') {
const run = provider.searchTags?.(directory, query, { sourceRepo });
if (run) {
const result = await run;
connected = result.connected;
if (connected) next = toLookupOptions('tags', [], [], [], [], result.tags ?? []);
}
}
if (cancelled) return;
setOptions(next);
if (connected) {
lookupCache.set(key, { options: next, expiresAt: Date.now() + CACHE_TTL_MS });
pruneCache(Date.now());
}
} catch {
if (!cancelled) setOptions([]);
} finally {
if (!cancelled) setLoading(false);
}
})();
}, 250);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [directory, kind, provider, query, sourceRepo]);
return { options, loading };
};
@@ -2,10 +2,12 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ForgeEntityDetailView } from '@/components/views/forge'; import { ForgeEntityDetailView } from '@/components/views/forge';
import { ForgeCreateIssueDialog } from '@/components/views/forge/actions';
import { buildForgeProvider } from '@/lib/forge'; import { buildForgeProvider } from '@/lib/forge';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import type { GitHubIssueSummary, GitHubRepoSelector } from '@/lib/api/types'; import type { GitHubIssueSummary, GitHubRepoSelector } from '@/lib/api/types';
import type { ForgeIssue } from '@/lib/forge';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
const issueLabelBadgeClass = const issueLabelBadgeClass =
@@ -46,6 +48,8 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory
>(null); >(null);
const [selectedUrl, setSelectedUrl] = React.useState<string | null>(null); const [selectedUrl, setSelectedUrl] = React.useState<string | null>(null);
const [createOpen, setCreateOpen] = React.useState(false);
const issueProvider = React.useMemo(() => (github ? buildForgeProvider('github', { github }) : null), [github]); const issueProvider = React.useMemo(() => (github ? buildForgeProvider('github', { github }) : null), [github]);
const retry = React.useCallback(() => setRetryToken((value) => value + 1), []); const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
@@ -55,6 +59,16 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory
setSettingsDialogOpen(true); setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]); }, [setSettingsDialogOpen, setSettingsPage]);
const handleIssueCreated = React.useCallback(
(issue: ForgeIssue) => {
// Open the freshly created issue's detail and refresh the list behind it.
setSelectedNumber(issue.number);
setSelectedUrl(issue.url ?? null);
setRetryToken((value) => value + 1);
},
[],
);
// A different repository invalidates the previously loaded list and detail so // A different repository invalidates the previously loaded list and detail so
// a stale repository's issues never leak into the new one. // a stale repository's issues never leak into the new one.
React.useEffect(() => { React.useEffect(() => {
@@ -180,8 +194,14 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory
return ( return (
<div className="flex min-w-0 flex-col gap-2"> <div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-col gap-0.5"> <div className="flex min-w-0 items-center justify-between gap-2">
<div className="typography-ui-header font-semibold text-foreground">{t('gitView.pullRequest.issues.listSectionTitle')}</div> <div className="typography-ui-header font-semibold text-foreground">{t('gitView.pullRequest.issues.listSectionTitle')}</div>
{issueProvider?.createIssue ? (
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2" onClick={() => setCreateOpen(true)}>
<Icon name="add" className="size-4" />
{t('forge.actions.newIssue')}
</Button>
) : null}
</div> </div>
{!github?.issuesList ? ( {!github?.issuesList ? (
@@ -255,6 +275,16 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory
) : null} ) : null}
</div> </div>
)} )}
{issueProvider?.createIssue ? (
<ForgeCreateIssueDialog
provider={issueProvider}
directory={directory}
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={handleIssueCreated}
/>
) : null}
</div> </div>
); );
}; };
@@ -2,10 +2,12 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ForgeEntityDetailView } from '@/components/views/forge'; import { ForgeEntityDetailView } from '@/components/views/forge';
import { ForgeCreateIssueDialog } from '@/components/views/forge/actions';
import { buildForgeProvider } from '@/lib/forge'; import { buildForgeProvider } from '@/lib/forge';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import type { GitLabIssueSummary } from '@/lib/api/types'; import type { GitLabIssueSummary } from '@/lib/api/types';
import type { ForgeIssue } from '@/lib/forge';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
const issueLabelBadgeClass = const issueLabelBadgeClass =
@@ -41,6 +43,8 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory
const issueProvider = React.useMemo(() => (gitlab ? buildForgeProvider('gitlab', { gitlab }) : null), [gitlab]); const issueProvider = React.useMemo(() => (gitlab ? buildForgeProvider('gitlab', { gitlab }) : null), [gitlab]);
const [createOpen, setCreateOpen] = React.useState(false);
const retry = React.useCallback(() => setRetryToken((value) => value + 1), []); const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
const openGitLabSettings = React.useCallback(() => { const openGitLabSettings = React.useCallback(() => {
@@ -48,6 +52,12 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory
setSettingsDialogOpen(true); setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]); }, [setSettingsDialogOpen, setSettingsPage]);
const handleIssueCreated = React.useCallback((issue: ForgeIssue) => {
setSelectedNumber(issue.number);
setSelectedUrl(issue.url ?? null);
setRetryToken((value) => value + 1);
}, []);
// A different repository invalidates the previously loaded list and detail so // A different repository invalidates the previously loaded list and detail so
// a stale repository's issues never leak into the new one. // a stale repository's issues never leak into the new one.
React.useEffect(() => { React.useEffect(() => {
@@ -163,8 +173,14 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory
return ( return (
<div className="flex min-w-0 flex-col gap-2"> <div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-col gap-0.5"> <div className="flex min-w-0 items-center justify-between gap-2">
<div className="typography-ui-header font-semibold text-foreground">{t('contextPanel.gitlabMr.issues.listSectionTitle')}</div> <div className="typography-ui-header font-semibold text-foreground">{t('contextPanel.gitlabMr.issues.listSectionTitle')}</div>
{issueProvider?.createIssue ? (
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2" onClick={() => setCreateOpen(true)}>
<Icon name="add" className="size-4" />
{t('forge.actions.newIssue')}
</Button>
) : null}
</div> </div>
{!gitlab?.issuesList ? ( {!gitlab?.issuesList ? (
@@ -238,6 +254,16 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory
) : null} ) : null}
</div> </div>
)} )}
{issueProvider?.createIssue ? (
<ForgeCreateIssueDialog
provider={issueProvider}
directory={directory}
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={handleIssueCreated}
/>
) : null}
</div> </div>
); );
}; };
@@ -2,11 +2,13 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ForgeEntityDetailView } from '@/components/views/forge'; import { ForgeEntityDetailView } from '@/components/views/forge';
import { ForgeCreateIssueDialog } from '@/components/views/forge/actions';
import { buildForgeProvider } from '@/lib/forge'; import { buildForgeProvider } from '@/lib/forge';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
import type { GiteaIssueSummary } from '@/lib/api/types'; import type { GiteaIssueSummary } from '@/lib/api/types';
import type { ForgeIssue } from '@/lib/forge';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
const issueLabelBadgeClass = const issueLabelBadgeClass =
@@ -44,6 +46,8 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory
const issueProvider = React.useMemo(() => (gitea ? buildForgeProvider('gitea', { gitea }) : null), [gitea]); const issueProvider = React.useMemo(() => (gitea ? buildForgeProvider('gitea', { gitea }) : null), [gitea]);
const [createOpen, setCreateOpen] = React.useState(false);
const retry = React.useCallback(() => setRetryToken((value) => value + 1), []); const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
const openGiteaSettings = React.useCallback(() => { const openGiteaSettings = React.useCallback(() => {
@@ -51,6 +55,12 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory
setSettingsDialogOpen(true); setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]); }, [setSettingsDialogOpen, setSettingsPage]);
const handleIssueCreated = React.useCallback((issue: ForgeIssue) => {
setSelectedNumber(issue.number);
setSelectedUrl(issue.url ?? null);
setRetryToken((value) => value + 1);
}, []);
// The parent PR view already gates on connection, but the auth store is the // The parent PR view already gates on connection, but the auth store is the
// authoritative signal when the list API reports connected without having // authoritative signal when the list API reports connected without having
// checked the account yet. // checked the account yet.
@@ -171,8 +181,14 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory
return ( return (
<div className="flex min-w-0 flex-col gap-2"> <div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-col gap-0.5"> <div className="flex min-w-0 items-center justify-between gap-2">
<div className="typography-ui-header font-semibold text-foreground">{t('contextPanel.giteaPr.issues.listSectionTitle')}</div> <div className="typography-ui-header font-semibold text-foreground">{t('contextPanel.giteaPr.issues.listSectionTitle')}</div>
{issueProvider?.createIssue ? (
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2" onClick={() => setCreateOpen(true)}>
<Icon name="add" className="size-4" />
{t('forge.actions.newIssue')}
</Button>
) : null}
</div> </div>
{!gitea?.issuesList ? ( {!gitea?.issuesList ? (
@@ -246,6 +262,16 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory
) : null} ) : null}
</div> </div>
)} )}
{issueProvider?.createIssue ? (
<ForgeCreateIssueDialog
provider={issueProvider}
directory={directory}
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={handleIssueCreated}
/>
) : null}
</div> </div>
); );
}; };
+174
View File
@@ -817,6 +817,42 @@ export type GitHubUserSummary = {
email?: string; email?: string;
}; };
// ---- Rich lookup results (repo-scoped search for pickers/mentions) ----
// Each result carries the connected repo so the facade can surface cross-repo /
// fork contexts, and the items are always arrays (empty on success with no
// matches). `connected: false` means the lookup could not be performed and
// must not be treated as an authoritative empty list.
export type GitHubUsersSearchResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
users: GitHubUserSummary[];
};
export type GitHubLabelsSearchResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
labels: GitHubIssueLabel[];
};
export type GitHubMilestonesSearchResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
milestones: Array<{ title: string; state?: string }>;
};
export type GitHubBranchesSearchResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
branches: string[];
};
export type GitHubTagsSearchResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
tags: string[];
};
type GitHubRepoRef = { type GitHubRepoRef = {
owner: string; owner: string;
repo: string; repo: string;
@@ -1141,6 +1177,21 @@ export type GitHubIssueCommentResult = {
comment?: GitHubIssueComment | null; comment?: GitHubIssueComment | null;
}; };
export type GitHubIssueCreateInput = {
directory: string;
title: string;
body?: string;
labels?: string[];
owner?: string;
repo?: string;
};
export type GitHubIssueCreateResult = {
connected: boolean;
repo?: { owner: string; repo: string; url?: string } | null;
issue?: GitHubIssue | null;
};
export type GitHubIssueUpdateInput = { export type GitHubIssueUpdateInput = {
directory: string; directory: string;
number: number; number: number;
@@ -1236,6 +1287,12 @@ export interface GitHubAPI {
authSetGhCliDisabled(disabled: boolean): Promise<{ disabled: boolean }>; authSetGhCliDisabled(disabled: boolean): Promise<{ disabled: boolean }>;
me?(): Promise<GitHubUserSummary>; me?(): Promise<GitHubUserSummary>;
searchUsers?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubUsersSearchResult>;
searchLabels?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubLabelsSearchResult>;
searchMilestones?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubMilestonesSearchResult>;
searchBranches?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubBranchesSearchResult>;
searchTags?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubTagsSearchResult>;
prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus>; prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus>;
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>; prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest>; prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest>;
@@ -1257,6 +1314,7 @@ export interface GitHubAPI {
repoUpstream(directory: string): Promise<GitHubRepoUpstreamResult>; repoUpstream(directory: string): Promise<GitHubRepoUpstreamResult>;
repoBranches(owner: string, repo: string): Promise<string[]>; repoBranches(owner: string, repo: string): Promise<string[]>;
issueComment?(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult>; issueComment?(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult>;
issueCreate?(input: GitHubIssueCreateInput): Promise<GitHubIssueCreateResult>;
issueUpdate?(input: GitHubIssueUpdateInput): Promise<GitHubIssueUpdateResult>; issueUpdate?(input: GitHubIssueUpdateInput): Promise<GitHubIssueUpdateResult>;
prComment?(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult>; prComment?(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult>;
prReviewComment?(input: GitHubReviewCommentInput): Promise<GitHubReviewCommentResult>; prReviewComment?(input: GitHubReviewCommentInput): Promise<GitHubReviewCommentResult>;
@@ -1392,6 +1450,40 @@ export type GitLabBranchesResult = {
defaultBranch?: string | null; defaultBranch?: string | null;
}; };
// ---- Rich lookup results (repo-scoped search for pickers/mentions) ----
// `connected: false` means the lookup could not be performed and must not be
// treated as an authoritative empty list.
export type GitLabUsersSearchResult = {
connected: boolean;
repo?: GitLabRepoRef | null;
users: GitLabUserSummary[];
};
export type GitLabLabelsSearchResult = {
connected: boolean;
repo?: GitLabRepoRef | null;
labels: string[];
};
export type GitLabMilestonesSearchResult = {
connected: boolean;
repo?: GitLabRepoRef | null;
milestones: Array<{ title: string; state?: string }>;
};
export type GitLabBranchesSearchResult = {
connected: boolean;
repo?: GitLabRepoRef | null;
branches: string[];
};
export type GitLabTagsSearchResult = {
connected: boolean;
repo?: GitLabRepoRef | null;
tags: string[];
};
export type GitLabMergeRequestCommit = { export type GitLabMergeRequestCommit = {
sha: string; sha: string;
shortSha: string; shortSha: string;
@@ -1438,6 +1530,8 @@ export type GitLabMergeRequestUpdateInput = {
description?: string; description?: string;
state?: 'open' | 'closed'; state?: 'open' | 'closed';
labels?: string[]; labels?: string[];
/** Assignee logins; the server resolves them to user IDs via project members. */
assignees?: string[];
assigneeIds?: number[]; assigneeIds?: number[];
milestone?: string | null; milestone?: string | null;
}; };
@@ -1480,6 +1574,21 @@ export type GitLabIssueCommentResult = {
comment?: GitLabIssueComment | null; comment?: GitLabIssueComment | null;
}; };
export type GitLabIssueCreateInput = {
directory: string;
title: string;
body?: string;
labels?: string[];
namespace?: string;
project?: string;
};
export type GitLabIssueCreateResult = {
connected: boolean;
repo?: GitLabRepoRef | null;
issue?: GitLabIssue | null;
};
export type GitLabIssueUpdateInput = { export type GitLabIssueUpdateInput = {
directory: string; directory: string;
number: number; number: number;
@@ -1487,6 +1596,8 @@ export type GitLabIssueUpdateInput = {
body?: string; body?: string;
state?: 'open' | 'closed'; state?: 'open' | 'closed';
labels?: string[]; labels?: string[];
/** Assignee logins; the server resolves them to user IDs via project members. */
assignees?: string[];
assigneeIds?: number[]; assigneeIds?: number[];
milestone?: string | null; milestone?: string | null;
namespace?: string; namespace?: string;
@@ -1569,10 +1680,17 @@ export interface GitLabAPI {
mrTimeline?(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabMergeRequestTimelineResult>; mrTimeline?(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabMergeRequestTimelineResult>;
issueComment?(input: GitLabIssueCommentInput): Promise<GitLabIssueCommentResult>; issueComment?(input: GitLabIssueCommentInput): Promise<GitLabIssueCommentResult>;
issueCreate?(input: GitLabIssueCreateInput): Promise<GitLabIssueCreateResult>;
issueUpdate?(input: GitLabIssueUpdateInput): Promise<GitLabIssueUpdateResult>; issueUpdate?(input: GitLabIssueUpdateInput): Promise<GitLabIssueUpdateResult>;
mrComment?(input: GitLabMrNoteInput): Promise<GitLabMrNoteResult>; mrComment?(input: GitLabMrNoteInput): Promise<GitLabMrNoteResult>;
mrApprove?(input: GitLabMrApproveInput): Promise<GitLabMrApproveResult>; mrApprove?(input: GitLabMrApproveInput): Promise<GitLabMrApproveResult>;
searchUsers?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise<GitLabUsersSearchResult>;
searchLabels?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise<GitLabLabelsSearchResult>;
searchMilestones?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise<GitLabMilestonesSearchResult>;
searchBranches?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise<GitLabBranchesSearchResult>;
searchTags?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise<GitLabTagsSearchResult>;
repoBranches(namespace: string, project: string): Promise<GitLabBranchesResult>; repoBranches(namespace: string, project: string): Promise<GitLabBranchesResult>;
} }
@@ -1705,6 +1823,21 @@ export type GiteaIssueCommentResult = {
comment?: GiteaComment | null; comment?: GiteaComment | null;
}; };
export type GiteaIssueCreateInput = {
directory: string;
title: string;
body?: string;
labels?: string[];
owner?: string;
repo?: string;
};
export type GiteaIssueCreateResult = {
connected: boolean;
repo?: { owner: string; repo: string; url?: string } | null;
issue?: GiteaIssue | null;
};
export type GiteaIssueUpdateInput = { export type GiteaIssueUpdateInput = {
directory: string; directory: string;
number: number; number: number;
@@ -1752,6 +1885,40 @@ export type GiteaRepoLabelsResult = {
labels: GiteaRepoLabel[]; labels: GiteaRepoLabel[];
}; };
// ---- Rich lookup results (repo-scoped search for pickers/mentions) ----
// `connected: false` means the lookup could not be performed and must not be
// treated as an authoritative empty list.
export type GiteaUsersSearchResult = {
connected: boolean;
repo?: { owner: string; repo: string; url?: string } | null;
users: GiteaUserSummary[];
};
export type GiteaLabelsSearchResult = {
connected: boolean;
repo?: { owner: string; repo: string; url?: string } | null;
labels: GiteaRepoLabel[];
};
export type GiteaMilestonesSearchResult = {
connected: boolean;
repo?: { owner: string; repo: string; url?: string } | null;
milestones: Array<{ title: string; state?: string }>;
};
export type GiteaBranchesSearchResult = {
connected: boolean;
repo?: { owner: string; repo: string; url?: string } | null;
branches: string[];
};
export type GiteaTagsSearchResult = {
connected: boolean;
repo?: { owner: string; repo: string; url?: string } | null;
tags: string[];
};
export interface GiteaAPI { export interface GiteaAPI {
authStatus(): Promise<GiteaAuthStatus>; authStatus(): Promise<GiteaAuthStatus>;
authConnect(input: { accessToken: string; baseUrl: string }): Promise<GiteaAuthStatus>; authConnect(input: { accessToken: string; baseUrl: string }): Promise<GiteaAuthStatus>;
@@ -1777,11 +1944,18 @@ export interface GiteaAPI {
prReviews?(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestReviewsResult>; prReviews?(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestReviewsResult>;
issueComment?(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult>; issueComment?(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult>;
issueCreate?(input: GiteaIssueCreateInput): Promise<GiteaIssueCreateResult>;
issueUpdate?(input: GiteaIssueUpdateInput): Promise<GiteaIssueUpdateResult>; issueUpdate?(input: GiteaIssueUpdateInput): Promise<GiteaIssueUpdateResult>;
prComment?(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult>; prComment?(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult>;
prSubmitReview?(input: GiteaPullReviewInput): Promise<GiteaPullReviewResult>; prSubmitReview?(input: GiteaPullReviewInput): Promise<GiteaPullReviewResult>;
repoLabels?(directory: string, options?: { owner?: string; repo?: string }): Promise<GiteaRepoLabelsResult>; repoLabels?(directory: string, options?: { owner?: string; repo?: string }): Promise<GiteaRepoLabelsResult>;
searchUsers?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise<GiteaUsersSearchResult>;
searchLabels?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise<GiteaLabelsSearchResult>;
searchMilestones?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise<GiteaMilestonesSearchResult>;
searchBranches?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise<GiteaBranchesSearchResult>;
searchTags?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise<GiteaTagsSearchResult>;
repoBranches(owner: string, repo: string): Promise<GiteaBranchesResult>; repoBranches(owner: string, repo: string): Promise<GiteaBranchesResult>;
} }
+308 -2
View File
@@ -35,12 +35,14 @@ import {
mapGiteaCommits, mapGiteaCommits,
mapGiteaComment, mapGiteaComment,
mapGiteaContext, mapGiteaContext,
mapGiteaAssignee,
mapGiteaIssue, mapGiteaIssue,
mapGiteaPr, mapGiteaPr,
mapGiteaRepoRef, mapGiteaRepoRef,
mapGiteaReviewsToEvents, mapGiteaReviewsToEvents,
mapGiteaReview, mapGiteaReview,
mapGiteaStatuses, mapGiteaStatuses,
mapGithubAssignee,
mapGithubCommits, mapGithubCommits,
mapGithubContext, mapGithubContext,
mapGithubIssue, mapGithubIssue,
@@ -53,6 +55,7 @@ import {
mapGitlabCommits, mapGitlabCommits,
mapGitlabContext, mapGitlabContext,
mapGitlabIssue, mapGitlabIssue,
mapGitlabMember,
mapGitlabMr, mapGitlabMr,
mapGitlabNoteComment, mapGitlabNoteComment,
mapGitlabRepoRef, mapGitlabRepoRef,
@@ -69,6 +72,11 @@ const GITHUB_CAPABILITIES: ForgeProviderCapabilities = {
timelineEvents: true, timelineEvents: true,
inlineComments: true, inlineComments: true,
threads: true, threads: true,
userSearch: true,
labelSearch: true,
milestoneSearch: true,
branchSearch: true,
tagSearch: true,
}; };
const GITLAB_CAPABILITIES: ForgeProviderCapabilities = { const GITLAB_CAPABILITIES: ForgeProviderCapabilities = {
@@ -81,6 +89,11 @@ const GITLAB_CAPABILITIES: ForgeProviderCapabilities = {
timelineEvents: true, timelineEvents: true,
inlineComments: false, inlineComments: false,
threads: true, threads: true,
userSearch: true,
labelSearch: true,
milestoneSearch: true,
branchSearch: true,
tagSearch: true,
}; };
const GITEA_CAPABILITIES: ForgeProviderCapabilities = { const GITEA_CAPABILITIES: ForgeProviderCapabilities = {
@@ -93,6 +106,11 @@ const GITEA_CAPABILITIES: ForgeProviderCapabilities = {
timelineEvents: true, timelineEvents: true,
inlineComments: true, inlineComments: true,
threads: true, threads: true,
userSearch: true,
labelSearch: true,
milestoneSearch: true,
branchSearch: true,
tagSearch: true,
}; };
// Gitea's 'commit-statuses' checks and inline comments land once Slice B adds // Gitea's 'commit-statuses' checks and inline comments land once Slice B adds
@@ -341,6 +359,79 @@ export const createGithubForgeProvider = (api: GitHubAPI): ForgeProvider => ({
return null; return null;
}, },
async searchUsers(directory, query, options) {
if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
try {
const result = await api.searchUsers(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
return {
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
users: (result.users ?? []).map(mapGithubAssignee),
};
} catch {
return { connected: false, repo: null, users: [], error: LOAD_ERROR };
}
},
async searchLabels(directory, query, options) {
if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
try {
const result = await api.searchLabels(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
return {
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
labels: (result.labels ?? []).map((label) => ({ name: label.name, color: label.color })),
};
} catch {
return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
}
},
async searchMilestones(directory, query, options) {
if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
try {
const result = await api.searchMilestones(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
return {
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
milestones: (result.milestones ?? []).map((milestone) => ({
title: milestone.title,
...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
})),
};
} catch {
return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
}
},
async searchBranches(directory, query, options) {
if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
try {
const result = await api.searchBranches(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
return {
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
branches: result.branches ?? [],
};
} catch {
return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
}
},
async searchTags(directory, query, options) {
if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
try {
const result = await api.searchTags(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
return {
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
tags: result.tags ?? [],
};
} catch {
return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
}
},
async addComment(directory, ref, input, options) { async addComment(directory, ref, input, options) {
const selector = parseOwnerRepo(options?.sourceRepo); const selector = parseOwnerRepo(options?.sourceRepo);
const owner = selector?.owner; const owner = selector?.owner;
@@ -365,6 +456,25 @@ export const createGithubForgeProvider = (api: GitHubAPI): ForgeProvider => ({
} }
}, },
async createIssue(directory, input, options) {
if (!api.issueCreate) return { ok: false, error: WRITE_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.issueCreate({
directory,
title: input.title,
...(input.body !== undefined ? { body: input.body } : {}),
...(input.labels !== undefined ? { labels: input.labels } : {}),
owner: selector?.owner,
repo: selector?.repo,
});
if (!result.connected) return { ok: false, error: WRITE_ERROR };
return { ok: true, issue: result.issue ? mapGithubIssue(result.issue) : null };
} catch {
return { ok: false, error: WRITE_ERROR };
}
},
async replyToThread(directory, ref, input, options) { async replyToThread(directory, ref, input, options) {
if (ref.kind !== 'pull') { if (ref.kind !== 'pull') {
// Issues have no inline review comments; reply as a flat thread comment. // Issues have no inline review comments; reply as a flat thread comment.
@@ -651,6 +761,84 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
return null; return null;
}, },
async searchUsers(directory, query, options) {
if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
try {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
const result = await api.searchUsers(directory, query, { namespace, project });
return {
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
users: (result.users ?? []).map(mapGitlabMember),
};
} catch {
return { connected: false, repo: null, users: [], error: LOAD_ERROR };
}
},
async searchLabels(directory, query, options) {
if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
try {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
const result = await api.searchLabels(directory, query, { namespace, project });
return {
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
labels: (result.labels ?? []).map((name) => ({ name })),
};
} catch {
return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
}
},
async searchMilestones(directory, query, options) {
if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
try {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
const result = await api.searchMilestones(directory, query, { namespace, project });
return {
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
milestones: (result.milestones ?? []).map((milestone) => ({
title: milestone.title,
...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
})),
};
} catch {
return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
}
},
async searchBranches(directory, query, options) {
if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
try {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
const result = await api.searchBranches(directory, query, { namespace, project });
return {
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
branches: result.branches ?? [],
};
} catch {
return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
}
},
async searchTags(directory, query, options) {
if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
try {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
const result = await api.searchTags(directory, query, { namespace, project });
return {
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
tags: result.tags ?? [],
};
} catch {
return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
}
},
async addComment(directory, ref, input, options) { async addComment(directory, ref, input, options) {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo); const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
if (ref.kind === 'issue') { if (ref.kind === 'issue') {
@@ -673,6 +861,25 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
} }
}, },
async createIssue(directory, input, options) {
if (!api.issueCreate) return { ok: false, error: WRITE_ERROR };
try {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
const result = await api.issueCreate({
directory,
title: input.title,
...(input.body !== undefined ? { body: input.body } : {}),
...(input.labels !== undefined ? { labels: input.labels } : {}),
namespace,
project,
});
if (!result.connected) return { ok: false, error: WRITE_ERROR };
return { ok: true, issue: result.issue ? mapGitlabIssue(result.issue) : null };
} catch {
return { ok: false, error: WRITE_ERROR };
}
},
async replyToThread(directory, ref, input, options) { async replyToThread(directory, ref, input, options) {
// GitLab's note-reply API is not wired up yet; reply as a flat comment. // GitLab's note-reply API is not wired up yet; reply as a flat comment.
return this.addComment!(directory, ref, { body: input.body }, options); return this.addComment!(directory, ref, { body: input.body }, options);
@@ -754,12 +961,13 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
if (ref.kind === 'issue') { if (ref.kind === 'issue') {
if (!api.issueUpdate) return { ok: false, error: WRITE_ERROR }; if (!api.issueUpdate) return { ok: false, error: WRITE_ERROR };
try { try {
// GitLab assigns by user ID, not login; the facade takes logins, so // GitLab assigns by user ID; the server resolves the facade's login
// assignees are left unset until an id lookup exists. // list to IDs via project members (see gitlab routes resolveAssigneeIds).
const result = await api.issueUpdate({ const result = await api.issueUpdate({
directory, directory,
number: ref.number, number: ref.number,
labels: input.labels, labels: input.labels,
assignees: input.assignees,
milestone: input.milestone, milestone: input.milestone,
namespace, namespace,
project, project,
@@ -776,6 +984,7 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
directory, directory,
number: ref.number, number: ref.number,
labels: input.labels, labels: input.labels,
assignees: input.assignees,
milestone: input.milestone, milestone: input.milestone,
}); });
return { ok: true, entity: mapGitlabMr(mr) }; return { ok: true, entity: mapGitlabMr(mr) };
@@ -926,6 +1135,84 @@ export const createGiteaForgeProvider = (api: GiteaAPI): ForgeProvider => ({
} }
}, },
async searchUsers(directory, query, options) {
if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.searchUsers(directory, query, { owner: selector?.owner, repo: selector?.repo });
return {
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
users: (result.users ?? []).map(mapGiteaAssignee),
};
} catch {
return { connected: false, repo: null, users: [], error: LOAD_ERROR };
}
},
async searchLabels(directory, query, options) {
if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.searchLabels(directory, query, { owner: selector?.owner, repo: selector?.repo });
return {
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
labels: (result.labels ?? []).map((label) => ({ name: label.name, color: label.color })),
};
} catch {
return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
}
},
async searchMilestones(directory, query, options) {
if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.searchMilestones(directory, query, { owner: selector?.owner, repo: selector?.repo });
return {
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
milestones: (result.milestones ?? []).map((milestone) => ({
title: milestone.title,
...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
})),
};
} catch {
return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
}
},
async searchBranches(directory, query, options) {
if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.searchBranches(directory, query, { owner: selector?.owner, repo: selector?.repo });
return {
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
branches: result.branches ?? [],
};
} catch {
return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
}
},
async searchTags(directory, query, options) {
if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.searchTags(directory, query, { owner: selector?.owner, repo: selector?.repo });
return {
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
tags: result.tags ?? [],
};
} catch {
return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
}
},
async getChecks(directory, number, options) { async getChecks(directory, number, options) {
if (!api.prStatuses) return EMPTY_CHECKS; if (!api.prStatuses) return EMPTY_CHECKS;
try { try {
@@ -968,6 +1255,25 @@ export const createGiteaForgeProvider = (api: GiteaAPI): ForgeProvider => ({
} }
}, },
async createIssue(directory, input, options) {
if (!api.issueCreate) return { ok: false, error: WRITE_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.issueCreate({
directory,
title: input.title,
...(input.body !== undefined ? { body: input.body } : {}),
...(input.labels !== undefined ? { labels: input.labels } : {}),
owner: selector?.owner,
repo: selector?.repo,
});
if (!result.connected) return { ok: false, error: WRITE_ERROR };
return { ok: true, issue: result.issue ? mapGiteaIssue(result.issue) : null };
} catch {
return { ok: false, error: WRITE_ERROR };
}
},
async replyToThread(directory, ref, input, options) { async replyToThread(directory, ref, input, options) {
// Gitea's thread-reply API is not wired up yet; reply as a flat comment. // Gitea's thread-reply API is not wired up yet; reply as a flat comment.
return this.addComment!(directory, ref, { body: input.body }, options); return this.addComment!(directory, ref, { body: input.body }, options);
+358 -2
View File
@@ -24,6 +24,7 @@ import {
aggregateStatusState, aggregateStatusState,
firstLine, firstLine,
mapCheckRunState, mapCheckRunState,
mapGiteaAssignee,
mapGiteaCommits, mapGiteaCommits,
mapGiteaComment, mapGiteaComment,
mapGiteaContext, mapGiteaContext,
@@ -32,6 +33,7 @@ import {
mapGiteaReviewsToEvents, mapGiteaReviewsToEvents,
mapGiteaReview, mapGiteaReview,
mapGiteaStatuses, mapGiteaStatuses,
mapGithubAssignee,
mapGithubCheckSummary, mapGithubCheckSummary,
mapGithubCommits, mapGithubCommits,
mapGithubContext, mapGithubContext,
@@ -44,6 +46,7 @@ import {
mapGitlabCommits, mapGitlabCommits,
mapGitlabContext, mapGitlabContext,
mapGitlabIssue, mapGitlabIssue,
mapGitlabMember,
mapGitlabMr, mapGitlabMr,
mapGitlabNoteComment, mapGitlabNoteComment,
mapGitlabTimelineEvents, mapGitlabTimelineEvents,
@@ -500,6 +503,37 @@ describe('gitea normalization', () => {
}); });
}); });
describe('repo-scoped lookup normalization', () => {
test('maps a GitHub repo assignee', () => {
expect(mapGithubAssignee(githubUser())).toEqual({
id: 'octocat',
login: 'octocat',
name: 'Octo Cat',
avatarUrl: 'https://avatars.example/octocat',
});
});
test('maps a GitLab project member', () => {
expect(mapGitlabMember(gitlabUser())).toEqual({
id: '5',
login: 'gluser',
name: 'GL User',
avatarUrl: 'https://avatars.example/gluser',
url: 'https://gitlab.example/gluser',
});
});
test('maps a Gitea repo assignee', () => {
expect(mapGiteaAssignee(giteaUser())).toEqual({
id: '3',
login: 'guser',
name: 'G User',
avatarUrl: 'https://avatars.example/guser',
url: 'https://gitea.example/guser',
});
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Rich-view normalization (commits / timeline / checks) // Rich-view normalization (commits / timeline / checks)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1126,7 +1160,7 @@ describe('write operations: updateMetadata', () => {
expect(issueArgs.milestone).toBe('v2.0'); expect(issueArgs.milestone).toBe('v2.0');
}); });
test('gitlab sends labels/milestone but never assignees (id-based)', async () => { test('gitlab sends labels/assignee logins/milestone; the server resolves logins to IDs', async () => {
let issueArgs: Record<string, unknown> = {}; let issueArgs: Record<string, unknown> = {};
const api = { const api = {
issueUpdate: async (input: Record<string, unknown>) => { issueUpdate: async (input: Record<string, unknown>) => {
@@ -1142,7 +1176,7 @@ describe('write operations: updateMetadata', () => {
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(issueArgs.labels).toEqual(['frontend']); expect(issueArgs.labels).toEqual(['frontend']);
expect(issueArgs.milestone).toBeNull(); expect(issueArgs.milestone).toBeNull();
expect(issueArgs.assignees).toBe(undefined); expect(issueArgs.assignees).toEqual(['gluser']);
}); });
test('gitea issue metadata passes through; PR metadata is unsupported', async () => { test('gitea issue metadata passes through; PR metadata is unsupported', async () => {
@@ -1246,6 +1280,11 @@ describe('buildForgeProvider', () => {
timelineEvents: true, timelineEvents: true,
inlineComments: true, inlineComments: true,
threads: true, threads: true,
userSearch: true,
labelSearch: true,
milestoneSearch: true,
branchSearch: true,
tagSearch: true,
}); });
}); });
@@ -1262,6 +1301,11 @@ describe('buildForgeProvider', () => {
timelineEvents: true, timelineEvents: true,
inlineComments: false, inlineComments: false,
threads: true, threads: true,
userSearch: true,
labelSearch: true,
milestoneSearch: true,
branchSearch: true,
tagSearch: true,
}); });
}); });
@@ -1278,6 +1322,11 @@ describe('buildForgeProvider', () => {
timelineEvents: true, timelineEvents: true,
inlineComments: true, inlineComments: true,
threads: true, threads: true,
userSearch: true,
labelSearch: true,
milestoneSearch: true,
branchSearch: true,
tagSearch: true,
}); });
}); });
}); });
@@ -1577,3 +1626,310 @@ describe('getForgeProviderForDirectory', () => {
expect(await getForgeProviderForDirectory('/repo')).toBeNull(); expect(await getForgeProviderForDirectory('/repo')).toBeNull();
}); });
}); });
describe('write operations: createIssue', () => {
test('github passes title/body/labels and parses sourceRepo', async () => {
let args: Record<string, unknown> = {};
const api = {
issueCreate: async (input: Record<string, unknown>) => {
args = input;
return { connected: true, issue: githubIssue };
},
} as unknown as GitHubAPI;
const provider = createGithubForgeProvider(api);
const result = await provider.createIssue!(
'/repo',
{ title: 'Add feature', body: 'The body', labels: ['bug'] },
{ sourceRepo: 'upstream/widget' },
);
expect(result.ok).toBe(true);
expect(result.issue?.number).toBe(7);
expect(args).toEqual({
directory: '/repo',
title: 'Add feature',
body: 'The body',
labels: ['bug'],
owner: 'upstream',
repo: 'widget',
});
});
test('github omits optional fields and fails closed when the api is absent', async () => {
let called = false;
const api = {
issueCreate: async () => {
called = true;
return { connected: true, issue: githubIssue };
},
} as unknown as GitHubAPI;
const provider = createGithubForgeProvider(api);
const result = await provider.createIssue!('/repo', { title: 'T' });
expect(result.ok).toBe(true);
expect(called).toBe(true);
const empty = createGithubForgeProvider({} as unknown as GitHubAPI);
const failed = await empty.createIssue!('/repo', { title: 'T' });
expect(failed.ok).toBe(false);
});
test('gitlab passes namespace/project from a multi-segment sourceRepo', async () => {
let args: Record<string, unknown> = {};
const api = {
issueCreate: async (input: Record<string, unknown>) => {
args = input;
return { connected: true, issue: gitlabIssue };
},
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
const result = await provider.createIssue!(
'/repo',
{ title: 'Add feature', body: 'The body' },
{ sourceRepo: 'group/sub/proj' },
);
expect(result.ok).toBe(true);
expect(result.issue?.number).toBe(8);
expect(args).toEqual({
directory: '/repo',
title: 'Add feature',
body: 'The body',
namespace: 'group/sub',
project: 'proj',
});
});
test('gitea passes owner/repo and maps the created issue', async () => {
let args: Record<string, unknown> = {};
const api = {
issueCreate: async (input: Record<string, unknown>) => {
args = input;
return { connected: true, issue: giteaIssue };
},
} as unknown as GiteaAPI;
const provider = createGiteaForgeProvider(api);
const result = await provider.createIssue!('/repo', { title: 'Add feature', labels: ['bug'] });
expect(result.ok).toBe(true);
expect(result.issue?.number).toBe(12);
expect(args).toEqual({
directory: '/repo',
title: 'Add feature',
labels: ['bug'],
owner: undefined,
repo: undefined,
});
});
test('createIssue degrades to ok:false without throwing on wire failure', async () => {
const api = {
issueCreate: async () => {
throw new Error('boom');
},
} as unknown as GitHubAPI;
const provider = createGithubForgeProvider(api);
const result = await provider.createIssue!('/repo', { title: 'T' });
expect(result.ok).toBe(false);
expect(result.error).toBeTruthy();
});
});
describe('repo-scoped user search (searchUsers)', () => {
test('github parses sourceRepo and maps assignees by login', async () => {
let receivedDirectory = '';
let receivedQuery = '';
let receivedOptions: { sourceRepo?: { owner: string; repo: string } | null } | undefined;
const api = {
searchUsers: async (
directory: string,
query: string,
options?: { sourceRepo?: { owner: string; repo: string } | null },
) => {
receivedDirectory = directory;
receivedQuery = query;
receivedOptions = options;
return {
connected: true,
repo: { owner: 'acme', repo: 'widget', url: 'https://github.com/acme/widget' },
users: [githubUser()],
};
},
} as unknown as GitHubAPI;
const provider = createGithubForgeProvider(api);
const result = await provider.searchUsers!('/repo', 'octo', { sourceRepo: 'upstream/widget' });
expect(receivedDirectory).toBe('/repo');
expect(receivedQuery).toBe('octo');
expect(receivedOptions).toEqual({ sourceRepo: { owner: 'upstream', repo: 'widget' } });
expect(result.connected).toBe(true);
expect(result.repo?.owner).toBe('acme');
expect(result.users).toEqual([{
id: 'octocat',
login: 'octocat',
name: 'Octo Cat',
avatarUrl: 'https://avatars.example/octocat',
}]);
});
test('github passes connected:false through without an authoritative list', async () => {
const api = {
searchUsers: async () => ({ connected: false, repo: null, users: [] }),
} as unknown as GitHubAPI;
const provider = createGithubForgeProvider(api);
const result = await provider.searchUsers!('/repo', 'octo');
expect(result.connected).toBe(false);
expect(result.users).toEqual([]);
expect(result.error).toBeFalsy();
});
test('github fails closed with an error when searchUsers is missing', async () => {
const provider = createGithubForgeProvider({} as unknown as GitHubAPI);
expect(await provider.searchUsers!('/repo', 'octo')).toEqual({
connected: false,
repo: null,
users: [],
error: 'failed to load',
});
});
test('github fails closed with an error when the wire call throws', async () => {
const api = {
searchUsers: async () => { throw new Error('boom'); },
} as unknown as GitHubAPI;
const provider = createGithubForgeProvider(api);
expect(await provider.searchUsers!('/repo', 'octo')).toEqual({
connected: false,
repo: null,
users: [],
error: 'failed to load',
});
});
test('gitlab parses namespace/project from a multi-segment sourceRepo and maps members', async () => {
let receivedOptions: { namespace?: string; project?: string } | undefined;
const api = {
searchUsers: async (
_directory: string,
_query: string,
options?: { namespace?: string; project?: string },
) => {
receivedOptions = options;
return { connected: true, repo: null, users: [gitlabUser()] };
},
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
const result = await provider.searchUsers!('/repo', 'gl', { sourceRepo: 'group/sub/proj' });
expect(receivedOptions).toEqual({ namespace: 'group/sub', project: 'proj' });
expect(result.connected).toBe(true);
expect(result.users).toEqual([{
id: '5',
login: 'gluser',
name: 'GL User',
avatarUrl: 'https://avatars.example/gluser',
url: 'https://gitlab.example/gluser',
}]);
});
test('gitlab passes connected:false through without an authoritative list', async () => {
const api = {
searchUsers: async () => ({ connected: false, repo: null, users: [] }),
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
const result = await provider.searchUsers!('/repo', 'gl');
expect(result.connected).toBe(false);
expect(result.users).toEqual([]);
expect(result.error).toBeFalsy();
});
test('gitlab fails closed with an error when searchUsers is missing', async () => {
const provider = createGitlabForgeProvider({} as unknown as GitLabAPI);
expect(await provider.searchUsers!('/repo', 'gl')).toEqual({
connected: false,
repo: null,
users: [],
error: 'failed to load',
});
});
test('gitlab fails closed with an error when the wire call throws', async () => {
const api = {
searchUsers: async () => { throw new Error('boom'); },
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
expect(await provider.searchUsers!('/repo', 'gl')).toEqual({
connected: false,
repo: null,
users: [],
error: 'failed to load',
});
});
test('gitea parses owner/repo and maps repo assignees', async () => {
let receivedOptions: { owner?: string; repo?: string } | undefined;
const api = {
searchUsers: async (
_directory: string,
_query: string,
options?: { owner?: string; repo?: string },
) => {
receivedOptions = options;
return { connected: true, repo: null, users: [giteaUser()] };
},
} as unknown as GiteaAPI;
const provider = createGiteaForgeProvider(api);
const result = await provider.searchUsers!('/repo', 'g', { sourceRepo: 'acme/widget' });
expect(receivedOptions).toEqual({ owner: 'acme', repo: 'widget' });
expect(result.connected).toBe(true);
expect(result.users).toEqual([{
id: '3',
login: 'guser',
name: 'G User',
avatarUrl: 'https://avatars.example/guser',
url: 'https://gitea.example/guser',
}]);
});
test('gitea passes connected:false through without an authoritative list', async () => {
const api = {
searchUsers: async () => ({ connected: false, repo: null, users: [] }),
} as unknown as GiteaAPI;
const provider = createGiteaForgeProvider(api);
const result = await provider.searchUsers!('/repo', 'g');
expect(result.connected).toBe(false);
expect(result.users).toEqual([]);
expect(result.error).toBeFalsy();
});
test('gitea fails closed with an error when searchUsers is missing', async () => {
const provider = createGiteaForgeProvider({} as unknown as GiteaAPI);
expect(await provider.searchUsers!('/repo', 'g')).toEqual({
connected: false,
repo: null,
users: [],
error: 'failed to load',
});
});
test('gitea fails closed with an error when the wire call throws', async () => {
const api = {
searchUsers: async () => { throw new Error('boom'); },
} as unknown as GiteaAPI;
const provider = createGiteaForgeProvider(api);
expect(await provider.searchUsers!('/repo', 'g')).toEqual({
connected: false,
repo: null,
users: [],
error: 'failed to load',
});
});
});
+8
View File
@@ -41,6 +41,11 @@ export type {
ForgeCommitsResult, ForgeCommitsResult,
ForgeTimelineResult, ForgeTimelineResult,
ForgeChecksResult, ForgeChecksResult,
ForgeUsersResult,
ForgeLabelsResult,
ForgeMilestonesResult,
ForgeBranchesResult,
ForgeTagsResult,
ForgeProvider, ForgeProvider,
} from './provider'; } from './provider';
@@ -50,6 +55,7 @@ export {
firstLine, firstLine,
normalizeEventType, normalizeEventType,
mapGithubUser, mapGithubUser,
mapGithubAssignee,
mapGithubPr, mapGithubPr,
mapGithubIssue, mapGithubIssue,
mapGithubIssueComment, mapGithubIssueComment,
@@ -60,6 +66,7 @@ export {
mapGithubCommits, mapGithubCommits,
mapGithubTimelineEvents, mapGithubTimelineEvents,
mapGitlabUser, mapGitlabUser,
mapGitlabMember,
mapGitlabMr, mapGitlabMr,
mapGitlabIssue, mapGitlabIssue,
mapGitlabNoteComment, mapGitlabNoteComment,
@@ -68,6 +75,7 @@ export {
mapGitlabCommits, mapGitlabCommits,
mapGitlabTimelineEvents, mapGitlabTimelineEvents,
mapGiteaUser, mapGiteaUser,
mapGiteaAssignee,
mapGiteaPr, mapGiteaPr,
mapGiteaIssue, mapGiteaIssue,
mapGiteaComment, mapGiteaComment,
+30
View File
@@ -117,6 +117,36 @@ export const mapGiteaUser = (user: GiteaUserSummary): ForgeUser => ({
url: user.webUrl, url: user.webUrl,
}); });
// ---------------------------------------------------------------------------
// Repo-scoped lookup results
// ---------------------------------------------------------------------------
/** Map a GitHub repo assignee item onto `ForgeUser` (same shape as GitHubUserSummary). */
export const mapGithubAssignee = (assignee: GitHubUserSummary): ForgeUser => ({
id: assignee.login,
login: assignee.login,
name: assignee.name,
avatarUrl: assignee.avatarUrl,
});
/** Map a GitLab project member (wire `members/all` item) onto `ForgeUser`. */
export const mapGitlabMember = (member: GitLabUserSummary): ForgeUser => ({
id: String(member.id ?? member.username),
login: member.username,
name: member.name,
avatarUrl: member.avatarUrl,
url: member.webUrl,
});
/** Map a Gitea repo-assignee item onto `ForgeUser` (same shape as GiteaUserSummary). */
export const mapGiteaAssignee = (assignee: GiteaUserSummary): ForgeUser => ({
id: String(assignee.id ?? assignee.username),
login: assignee.username,
name: assignee.name,
avatarUrl: assignee.avatarUrl,
url: assignee.webUrl,
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Pull requests / merge requests // Pull requests / merge requests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+110
View File
@@ -4,12 +4,15 @@ import type {
ForgeCommit, ForgeCommit,
ForgeFileChange, ForgeFileChange,
ForgeIssue, ForgeIssue,
ForgeLabel,
ForgeMilestone,
ForgeProviderCapabilities, ForgeProviderCapabilities,
ForgeProviderKind, ForgeProviderKind,
ForgePullRequest, ForgePullRequest,
ForgeRepoRef, ForgeRepoRef,
ForgeReview, ForgeReview,
ForgeTimelineEvent, ForgeTimelineEvent,
ForgeUser,
} from './types'; } from './types';
/** /**
@@ -95,6 +98,51 @@ export interface ForgeTimelineResult {
error?: string | null; error?: string | null;
} }
/**
* Repo-scoped user lookup for mentions/assignees. Wraps the per-provider
* assignable-user endpoints (github `issues.listAssignees`, gitlab project
* members, gitea repo assignees). `connected: false` means the lookup failed —
* never treat it as an authoritative empty list.
*/
export interface ForgeUsersResult {
connected: boolean;
repo?: ForgeRepoRef | null;
users: ForgeUser[];
error?: string | null;
}
/** Repo-scoped label lookup for pickers. */
export interface ForgeLabelsResult {
connected: boolean;
repo?: ForgeRepoRef | null;
labels: ForgeLabel[];
error?: string | null;
}
/** Repo-scoped milestone lookup for pickers. */
export interface ForgeMilestonesResult {
connected: boolean;
repo?: ForgeRepoRef | null;
milestones: ForgeMilestone[];
error?: string | null;
}
/** Repo-scoped branch lookup. */
export interface ForgeBranchesResult {
connected: boolean;
repo?: ForgeRepoRef | null;
branches: string[];
error?: string | null;
}
/** Repo-scoped tag lookup. */
export interface ForgeTagsResult {
connected: boolean;
repo?: ForgeRepoRef | null;
tags: string[];
error?: string | null;
}
/** Rolled-up checks for a PR/MR; only non-null for providers with a dedicated checks surface. */ /** Rolled-up checks for a PR/MR; only non-null for providers with a dedicated checks surface. */
export interface ForgeChecksResult { export interface ForgeChecksResult {
connected: boolean; connected: boolean;
@@ -135,6 +183,20 @@ export interface ForgeCommentResult {
comment?: ForgeComment | null; comment?: ForgeComment | null;
} }
/** Input for creating an issue; `labels` is a full-set list of label names. */
export interface ForgeIssueCreateInput {
title: string;
body?: string;
labels?: string[];
}
/** Result of creating an issue; `issue` is the created entity when `ok`. */
export interface ForgeIssueCreateResult {
ok: boolean;
error?: string | null;
issue?: ForgeIssue | null;
}
/** Result of an entity update; `entity` is the refreshed issue/PR when `ok`. */ /** Result of an entity update; `entity` is the refreshed issue/PR when `ok`. */
export interface ForgeUpdateResult { export interface ForgeUpdateResult {
ok: boolean; ok: boolean;
@@ -230,6 +292,43 @@ export interface ForgeProvider {
*/ */
getChecks?(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeChecksResult | null>; getChecks?(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeChecksResult | null>;
// --- Rich lookups (pickers / mentions) ---
//
// Repo-scoped searches for the fields the UI offers as pickers. Every method
// resolves the target repo from the working directory (remotes + connected
// accounts) and takes a directory argument, matching the per-provider APIs.
// All are optional and capability-flagged (`capabilities.userSearch`,
// `.labelSearch`, `.milestoneSearch`, `.branchSearch`, `.tagSearch`); the UI
// gates on method presence plus the flag before enabling an affordance.
// Results return `{ connected: false }` — never throw — when the runtime API
// is missing, the wire call fails, or the provider cannot resolve the repo.
/**
* Search the repo's assignable users (matches for assignees and mentions).
* Wraps github `issues.listAssignees`, gitlab project members, and gitea repo
* assignees; `query` is a free-text substring (case-insensitive). `sourceRepo`
* selects a cross-repo (fork) repository.
*/
searchUsers?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeUsersResult>;
/**
* Search the repo's labels. Wraps github `issues.listLabelsForRepo`, gitlab
* project labels, and gitea repo labels.
*/
searchLabels?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeLabelsResult>;
/**
* Search the repo's milestones. Wraps github `issues.listMilestonesForRepo`,
* gitlab project milestones, and gitea repo milestones.
*/
searchMilestones?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeMilestonesResult>;
/** Search the repo's branches. */
searchBranches?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeBranchesResult>;
/** Search the repo's tags. */
searchTags?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeTagsResult>;
// --- Write operations --- // --- Write operations ---
// //
// Every write method is optional and capability-flagged: the UI gates on // Every write method is optional and capability-flagged: the UI gates on
@@ -252,6 +351,17 @@ export interface ForgeProvider {
options?: { sourceRepo?: string | null }, options?: { sourceRepo?: string | null },
): Promise<ForgeCommentResult>; ): Promise<ForgeCommentResult>;
/**
* Create a new issue in the repository. Wraps `github issueCreate`, `gitlab
* issueCreate`, and `gitea issueCreate`; labels are a full-set list of names
* on the create payload (provider-dependent support).
*/
createIssue?(
directory: string,
input: ForgeIssueCreateInput,
options?: { sourceRepo?: string | null },
): Promise<ForgeIssueCreateResult>;
/** /**
* Reply to a comment thread. On GitHub this posts a proper inline * Reply to a comment thread. On GitHub this posts a proper inline
* review-comment reply via `prReviewComment` (anchored on `inReplyToId`); * review-comment reply via `prReviewComment` (anchored on `inReplyToId`);
+10
View File
@@ -45,6 +45,16 @@ export interface ForgeProviderCapabilities {
inlineComments: boolean; inlineComments: boolean;
/** Comment threads that can be replied to. */ /** Comment threads that can be replied to. */
threads: boolean; threads: boolean;
/** Repo-scoped user search (assignable users) for mentions/assignees. */
userSearch: boolean;
/** Repo-scoped label search for the label picker. */
labelSearch: boolean;
/** Repo-scoped milestone search for the milestone picker. */
milestoneSearch: boolean;
/** Repo-scoped branch search. */
branchSearch: boolean;
/** Repo-scoped tag search. */
tagSearch: boolean;
} }
/** A person as surfaced by the forge (issue author, reviewer, commit author, ...). */ /** A person as surfaced by the forge (issue author, reviewer, commit author, ...). */
+9
View File
@@ -1290,6 +1290,13 @@ export const dict = {
'forge.actions.closeConfirm': 'Diesen Vorgang schließen?', 'forge.actions.closeConfirm': 'Diesen Vorgang schließen?',
'forge.actions.comment': 'Kommentieren', 'forge.actions.comment': 'Kommentieren',
'forge.actions.commentPlaceholder': 'Kommentar hinzufügen…', 'forge.actions.commentPlaceholder': 'Kommentar hinzufügen…',
'forge.actions.createIssue': 'Issue erstellen',
'forge.actions.issueBodyPlaceholder': 'Problem beschreiben…',
'forge.actions.issueCreated': 'Issue erstellt',
'forge.actions.issueDialogTitle': 'Neues Issue',
'forge.actions.issueLabelsPlaceholder': 'Labels (durch Kommas getrennt)',
'forge.actions.issueTitlePlaceholder': 'Titel',
'forge.actions.newIssue': 'Neues Issue',
'forge.actions.draftChanged': 'Entwurfsstatus aktualisiert', 'forge.actions.draftChanged': 'Entwurfsstatus aktualisiert',
'forge.actions.edit': 'Bearbeiten', 'forge.actions.edit': 'Bearbeiten',
'forge.actions.error': 'Aktion fehlgeschlagen', 'forge.actions.error': 'Aktion fehlgeschlagen',
@@ -1330,6 +1337,8 @@ export const dict = {
'forge.files.empty': 'Keine Dateien geändert', 'forge.files.empty': 'Keine Dateien geändert',
'forge.files.noDiff': 'Kein Diff verfügbar', 'forge.files.noDiff': 'Kein Diff verfügbar',
'forge.loading': 'Wird geladen...', 'forge.loading': 'Wird geladen...',
'forge.lookup.empty': 'Keine Treffer',
'forge.lookup.loading': 'Suche…',
'forge.linkedSessions.count': 'Mit diesem Element verknüpfte Chats: {count}', 'forge.linkedSessions.count': 'Mit diesem Element verknüpfte Chats: {count}',
'forge.linkedSessions.open': 'Sitzung „{title}“ öffnen', 'forge.linkedSessions.open': 'Sitzung „{title}“ öffnen',
'forge.linkedSessions.title': 'Chats, die daran arbeiten', 'forge.linkedSessions.title': 'Chats, die daran arbeiten',
+9
View File
@@ -1542,12 +1542,19 @@ export const dict = {
'forge.actions.closeConfirm': 'Close this issue/PR?', 'forge.actions.closeConfirm': 'Close this issue/PR?',
'forge.actions.comment': 'Comment', 'forge.actions.comment': 'Comment',
'forge.actions.commentPlaceholder': 'Add a comment…', 'forge.actions.commentPlaceholder': 'Add a comment…',
'forge.actions.createIssue': 'Create issue',
'forge.actions.draftChanged': 'Draft status updated', 'forge.actions.draftChanged': 'Draft status updated',
'forge.actions.edit': 'Edit', 'forge.actions.edit': 'Edit',
'forge.actions.error': 'Action failed', 'forge.actions.error': 'Action failed',
'forge.actions.issueBodyPlaceholder': 'Describe the issue…',
'forge.actions.issueCreated': 'Issue created',
'forge.actions.issueDialogTitle': 'New issue',
'forge.actions.issueLabelsPlaceholder': 'Labels (comma-separated)',
'forge.actions.issueTitlePlaceholder': 'Title',
'forge.actions.markDraft': 'Mark as draft', 'forge.actions.markDraft': 'Mark as draft',
'forge.actions.markReady': 'Mark ready', 'forge.actions.markReady': 'Mark ready',
'forge.actions.metadataChanged': 'Metadata updated', 'forge.actions.metadataChanged': 'Metadata updated',
'forge.actions.newIssue': 'New issue',
'forge.actions.posting': 'Posting…', 'forge.actions.posting': 'Posting…',
'forge.actions.remove': 'Remove', 'forge.actions.remove': 'Remove',
'forge.actions.removed': 'Removed', 'forge.actions.removed': 'Removed',
@@ -1582,6 +1589,8 @@ export const dict = {
'forge.files.empty': 'No files changed', 'forge.files.empty': 'No files changed',
'forge.files.noDiff': 'No diff available', 'forge.files.noDiff': 'No diff available',
'forge.loading': 'Loading...', 'forge.loading': 'Loading...',
'forge.lookup.empty': 'No matches',
'forge.lookup.loading': 'Searching…',
'forge.linkedSessions.count': 'Sessions linked to this entity: {count}', 'forge.linkedSessions.count': 'Sessions linked to this entity: {count}',
'forge.linkedSessions.open': 'Open session "{title}"', 'forge.linkedSessions.open': 'Open session "{title}"',
'forge.linkedSessions.title': 'Chats working on this', 'forge.linkedSessions.title': 'Chats working on this',
+9
View File
@@ -1509,6 +1509,13 @@ export const dict: Record<I18nKey, string> = {
'forge.actions.closeConfirm': '¿Cerrar este problema/PR?', 'forge.actions.closeConfirm': '¿Cerrar este problema/PR?',
'forge.actions.comment': 'Comentar', 'forge.actions.comment': 'Comentar',
'forge.actions.commentPlaceholder': 'Añadir un comentario…', 'forge.actions.commentPlaceholder': 'Añadir un comentario…',
'forge.actions.createIssue': 'Crear incidencia',
'forge.actions.issueBodyPlaceholder': 'Describe el problema…',
'forge.actions.issueCreated': 'Incidencia creada',
'forge.actions.issueDialogTitle': 'Nueva incidencia',
'forge.actions.issueLabelsPlaceholder': 'Etiquetas (separadas por comas)',
'forge.actions.issueTitlePlaceholder': 'Título',
'forge.actions.newIssue': 'Nueva incidencia',
'forge.actions.draftChanged': 'Estado de borrador actualizado', 'forge.actions.draftChanged': 'Estado de borrador actualizado',
'forge.actions.edit': 'Editar', 'forge.actions.edit': 'Editar',
'forge.actions.error': 'La acción falló', 'forge.actions.error': 'La acción falló',
@@ -1549,6 +1556,8 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': 'No hay archivos modificados', 'forge.files.empty': 'No hay archivos modificados',
'forge.files.noDiff': 'No hay diff disponible', 'forge.files.noDiff': 'No hay diff disponible',
'forge.loading': 'Cargando...', 'forge.loading': 'Cargando...',
'forge.lookup.empty': 'Sin coincidencias',
'forge.lookup.loading': 'Buscando…',
'forge.linkedSessions.count': 'Sesiones vinculadas a este elemento: {count}', 'forge.linkedSessions.count': 'Sesiones vinculadas a este elemento: {count}',
'forge.linkedSessions.open': 'Abrir sesión «{title}»', 'forge.linkedSessions.open': 'Abrir sesión «{title}»',
'forge.linkedSessions.title': 'Chats trabajando en esto', 'forge.linkedSessions.title': 'Chats trabajando en esto',
+9
View File
@@ -3215,6 +3215,13 @@ export const dict = {
'forge.actions.closeConfirm': 'Fermer ce problème/pull request ?', 'forge.actions.closeConfirm': 'Fermer ce problème/pull request ?',
'forge.actions.comment': 'Commenter', 'forge.actions.comment': 'Commenter',
'forge.actions.commentPlaceholder': 'Ajouter un commentaire…', 'forge.actions.commentPlaceholder': 'Ajouter un commentaire…',
'forge.actions.createIssue': 'Créer une issue',
'forge.actions.issueBodyPlaceholder': 'Décrivez le problème…',
'forge.actions.issueCreated': 'Issue créée',
'forge.actions.issueDialogTitle': 'Nouvelle issue',
'forge.actions.issueLabelsPlaceholder': 'Étiquettes (séparées par des virgules)',
'forge.actions.issueTitlePlaceholder': 'Titre',
'forge.actions.newIssue': 'Nouvelle issue',
'forge.actions.draftChanged': 'Statut de brouillon mis à jour', 'forge.actions.draftChanged': 'Statut de brouillon mis à jour',
'forge.actions.edit': 'Modifier', 'forge.actions.edit': 'Modifier',
'forge.actions.error': 'Échec de l’action', 'forge.actions.error': 'Échec de l’action',
@@ -3255,6 +3262,8 @@ export const dict = {
'forge.files.empty': 'Aucun fichier modifié', 'forge.files.empty': 'Aucun fichier modifié',
'forge.files.noDiff': 'Aucun diff disponible', 'forge.files.noDiff': 'Aucun diff disponible',
'forge.loading': 'Chargement...', 'forge.loading': 'Chargement...',
'forge.lookup.empty': 'Aucun résultat',
'forge.lookup.loading': 'Recherche…',
'forge.linkedSessions.count': 'Conversations liées à cet élément : {count}', 'forge.linkedSessions.count': 'Conversations liées à cet élément : {count}',
'forge.linkedSessions.open': 'Ouvrir la conversation « {title} »', 'forge.linkedSessions.open': 'Ouvrir la conversation « {title} »',
'forge.linkedSessions.title': 'Conversations en cours sur cet élément', 'forge.linkedSessions.title': 'Conversations en cours sur cet élément',
+9
View File
@@ -1539,6 +1539,13 @@ export const dict: Record<I18nKey, string> = {
'forge.actions.closeConfirm': 'このIssue/PRをクローズしますか?', 'forge.actions.closeConfirm': 'このIssue/PRをクローズしますか?',
'forge.actions.comment': 'コメント', 'forge.actions.comment': 'コメント',
'forge.actions.commentPlaceholder': 'コメントを追加…', 'forge.actions.commentPlaceholder': 'コメントを追加…',
'forge.actions.createIssue': 'イシューを作成',
'forge.actions.issueBodyPlaceholder': '問題を説明…',
'forge.actions.issueCreated': 'イシューを作成しました',
'forge.actions.issueDialogTitle': '新しいイシュー',
'forge.actions.issueLabelsPlaceholder': 'ラベル(カンマ区切り)',
'forge.actions.issueTitlePlaceholder': 'タイトル',
'forge.actions.newIssue': '新しいイシュー',
'forge.actions.draftChanged': '下書きの状態を更新しました', 'forge.actions.draftChanged': '下書きの状態を更新しました',
'forge.actions.edit': '編集', 'forge.actions.edit': '編集',
'forge.actions.error': '操作に失敗しました', 'forge.actions.error': '操作に失敗しました',
@@ -1579,6 +1586,8 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': '変更されたファイルはありません', 'forge.files.empty': '変更されたファイルはありません',
'forge.files.noDiff': '差分はありません', 'forge.files.noDiff': '差分はありません',
'forge.loading': '読み込み中...', 'forge.loading': '読み込み中...',
'forge.lookup.empty': '一致する項目がありません',
'forge.lookup.loading': '検索中…',
'forge.linkedSessions.count': 'この項目にリンクされているセッション: {count}', 'forge.linkedSessions.count': 'この項目にリンクされているセッション: {count}',
'forge.linkedSessions.open': 'セッション「{title}」を開く', 'forge.linkedSessions.open': 'セッション「{title}」を開く',
'forge.linkedSessions.title': 'この項目に取り組んでいるチャット', 'forge.linkedSessions.title': 'この項目に取り組んでいるチャット',
+9
View File
@@ -1545,6 +1545,13 @@ export const dict: Record<I18nKey, string> = {
'forge.actions.closeConfirm': '이 이슈/PR을 닫을까요?', 'forge.actions.closeConfirm': '이 이슈/PR을 닫을까요?',
'forge.actions.comment': '댓글', 'forge.actions.comment': '댓글',
'forge.actions.commentPlaceholder': '댓글 추가…', 'forge.actions.commentPlaceholder': '댓글 추가…',
'forge.actions.createIssue': '이슈 만들기',
'forge.actions.issueBodyPlaceholder': '문제를 설명하세요…',
'forge.actions.issueCreated': '이슈 생성됨',
'forge.actions.issueDialogTitle': '새 이슈',
'forge.actions.issueLabelsPlaceholder': '라벨(쉼표로 구분)',
'forge.actions.issueTitlePlaceholder': '제목',
'forge.actions.newIssue': '새 이슈',
'forge.actions.draftChanged': '초안 상태가 업데이트됨', 'forge.actions.draftChanged': '초안 상태가 업데이트됨',
'forge.actions.edit': '편집', 'forge.actions.edit': '편집',
'forge.actions.error': '작업 실패', 'forge.actions.error': '작업 실패',
@@ -1585,6 +1592,8 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': '변경된 파일이 없습니다', 'forge.files.empty': '변경된 파일이 없습니다',
'forge.files.noDiff': 'diff를 사용할 수 없습니다', 'forge.files.noDiff': 'diff를 사용할 수 없습니다',
'forge.loading': '불러오는 중...', 'forge.loading': '불러오는 중...',
'forge.lookup.empty': '일치하는 항목이 없습니다',
'forge.lookup.loading': '검색 중…',
'forge.linkedSessions.count': '이 항목에 연결된 세션: {count}', 'forge.linkedSessions.count': '이 항목에 연결된 세션: {count}',
'forge.linkedSessions.open': '세션 "{title}" 열기', 'forge.linkedSessions.open': '세션 "{title}" 열기',
'forge.linkedSessions.title': '이 항목에서 작업 중인 채팅', 'forge.linkedSessions.title': '이 항목에서 작업 중인 채팅',
+9
View File
@@ -2026,6 +2026,13 @@ export const dict: Record<I18nKey, string> = {
'forge.actions.closeConfirm': 'Zamknąć to zgłoszenie/PR?', 'forge.actions.closeConfirm': 'Zamknąć to zgłoszenie/PR?',
'forge.actions.comment': 'Skomentuj', 'forge.actions.comment': 'Skomentuj',
'forge.actions.commentPlaceholder': 'Dodaj komentarz…', 'forge.actions.commentPlaceholder': 'Dodaj komentarz…',
'forge.actions.createIssue': 'Utwórz zgłoszenie',
'forge.actions.issueBodyPlaceholder': 'Opisz problem…',
'forge.actions.issueCreated': 'Zgłoszenie utworzone',
'forge.actions.issueDialogTitle': 'Nowe zgłoszenie',
'forge.actions.issueLabelsPlaceholder': 'Etykiety (rozdzielone przecinkami)',
'forge.actions.issueTitlePlaceholder': 'Tytuł',
'forge.actions.newIssue': 'Nowe zgłoszenie',
'forge.actions.draftChanged': 'Zaktualizowano status wersji roboczej', 'forge.actions.draftChanged': 'Zaktualizowano status wersji roboczej',
'forge.actions.edit': 'Edytuj', 'forge.actions.edit': 'Edytuj',
'forge.actions.error': 'Operacja nie powiodła się', 'forge.actions.error': 'Operacja nie powiodła się',
@@ -2066,6 +2073,8 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': 'Brak zmienionych plików', 'forge.files.empty': 'Brak zmienionych plików',
'forge.files.noDiff': 'Brak dostępnego diff', 'forge.files.noDiff': 'Brak dostępnego diff',
'forge.loading': 'Wczytywanie...', 'forge.loading': 'Wczytywanie...',
'forge.lookup.empty': 'Brak wyników',
'forge.lookup.loading': 'Szukanie…',
'forge.linkedSessions.count': 'Sesje powiązane z tym elementem: {count}', 'forge.linkedSessions.count': 'Sesje powiązane z tym elementem: {count}',
'forge.linkedSessions.open': 'Otwórz sesję „{title}“', 'forge.linkedSessions.open': 'Otwórz sesję „{title}“',
'forge.linkedSessions.title': 'Czaty pracujące nad tym', 'forge.linkedSessions.title': 'Czaty pracujące nad tym',
@@ -1509,6 +1509,13 @@ export const dict: Record<I18nKey, string> = {
'forge.actions.closeConfirm': 'Fechar este problema/PR?', 'forge.actions.closeConfirm': 'Fechar este problema/PR?',
'forge.actions.comment': 'Comentar', 'forge.actions.comment': 'Comentar',
'forge.actions.commentPlaceholder': 'Adicionar um comentário…', 'forge.actions.commentPlaceholder': 'Adicionar um comentário…',
'forge.actions.createIssue': 'Criar issue',
'forge.actions.issueBodyPlaceholder': 'Descreva o problema…',
'forge.actions.issueCreated': 'Issue criada',
'forge.actions.issueDialogTitle': 'Nova issue',
'forge.actions.issueLabelsPlaceholder': 'Etiquetas (separadas por vírgulas)',
'forge.actions.issueTitlePlaceholder': 'Título',
'forge.actions.newIssue': 'Nova issue',
'forge.actions.draftChanged': 'Status de rascunho atualizado', 'forge.actions.draftChanged': 'Status de rascunho atualizado',
'forge.actions.edit': 'Editar', 'forge.actions.edit': 'Editar',
'forge.actions.error': 'A ação falhou', 'forge.actions.error': 'A ação falhou',
@@ -1549,6 +1556,8 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': 'Nenhum arquivo alterado', 'forge.files.empty': 'Nenhum arquivo alterado',
'forge.files.noDiff': 'Nenhum diff disponível', 'forge.files.noDiff': 'Nenhum diff disponível',
'forge.loading': 'Carregando...', 'forge.loading': 'Carregando...',
'forge.lookup.empty': 'Nenhum resultado',
'forge.lookup.loading': 'Buscando…',
'forge.linkedSessions.count': 'Sessões vinculadas a este item: {count}', 'forge.linkedSessions.count': 'Sessões vinculadas a este item: {count}',
'forge.linkedSessions.open': 'Abrir sessão "{title}"', 'forge.linkedSessions.open': 'Abrir sessão "{title}"',
'forge.linkedSessions.title': 'Chats trabalhando nisso', 'forge.linkedSessions.title': 'Chats trabalhando nisso',
+9
View File
@@ -1509,6 +1509,13 @@ export const dict: Record<I18nKey, string> = {
'forge.actions.closeConfirm': 'Закрити цей issue/PR?', 'forge.actions.closeConfirm': 'Закрити цей issue/PR?',
'forge.actions.comment': 'Прокоментувати', 'forge.actions.comment': 'Прокоментувати',
'forge.actions.commentPlaceholder': 'Додати коментар…', 'forge.actions.commentPlaceholder': 'Додати коментар…',
'forge.actions.createIssue': 'Створити завдання',
'forge.actions.issueBodyPlaceholder': 'Опишіть проблему…',
'forge.actions.issueCreated': 'Завдання створено',
'forge.actions.issueDialogTitle': 'Нове завдання',
'forge.actions.issueLabelsPlaceholder': 'Мітки (через кому)',
'forge.actions.issueTitlePlaceholder': 'Назва',
'forge.actions.newIssue': 'Нове завдання',
'forge.actions.draftChanged': 'Статус чернетки оновлено', 'forge.actions.draftChanged': 'Статус чернетки оновлено',
'forge.actions.edit': 'Редагувати', 'forge.actions.edit': 'Редагувати',
'forge.actions.error': 'Дія не вдалася', 'forge.actions.error': 'Дія не вдалася',
@@ -1549,6 +1556,8 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': 'Змінених файлів немає', 'forge.files.empty': 'Змінених файлів немає',
'forge.files.noDiff': 'Diff недоступний', 'forge.files.noDiff': 'Diff недоступний',
'forge.loading': 'Завантаження...', 'forge.loading': 'Завантаження...',
'forge.lookup.empty': 'Немає збігів',
'forge.lookup.loading': 'Пошук…',
'forge.linkedSessions.count': 'Сеанси, пов\'язані з цим елементом: {count}', 'forge.linkedSessions.count': 'Сеанси, пов\'язані з цим елементом: {count}',
'forge.linkedSessions.open': 'Відкрити сеанс «{title}»', 'forge.linkedSessions.open': 'Відкрити сеанс «{title}»',
'forge.linkedSessions.title': 'Чати, що працюють над цим', 'forge.linkedSessions.title': 'Чати, що працюють над цим',
@@ -1509,6 +1509,13 @@ export const dict: Record<I18nKey, string> = {
'forge.actions.closeConfirm': '关闭此问题/PR?', 'forge.actions.closeConfirm': '关闭此问题/PR?',
'forge.actions.comment': '评论', 'forge.actions.comment': '评论',
'forge.actions.commentPlaceholder': '添加评论…', 'forge.actions.commentPlaceholder': '添加评论…',
'forge.actions.createIssue': '创建问题',
'forge.actions.issueBodyPlaceholder': '描述问题…',
'forge.actions.issueCreated': '问题已创建',
'forge.actions.issueDialogTitle': '新问题',
'forge.actions.issueLabelsPlaceholder': '标签(逗号分隔)',
'forge.actions.issueTitlePlaceholder': '标题',
'forge.actions.newIssue': '新问题',
'forge.actions.draftChanged': '草稿状态已更新', 'forge.actions.draftChanged': '草稿状态已更新',
'forge.actions.edit': '编辑', 'forge.actions.edit': '编辑',
'forge.actions.error': '操作失败', 'forge.actions.error': '操作失败',
@@ -1549,6 +1556,8 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': '无文件更改', 'forge.files.empty': '无文件更改',
'forge.files.noDiff': '无可用差异', 'forge.files.noDiff': '无可用差异',
'forge.loading': '加载中...', 'forge.loading': '加载中...',
'forge.lookup.empty': '无匹配项',
'forge.lookup.loading': '搜索中…',
'forge.linkedSessions.count': '与此事项关联的会话:{count}', 'forge.linkedSessions.count': '与此事项关联的会话:{count}',
'forge.linkedSessions.open': '打开会话「{title}」', 'forge.linkedSessions.open': '打开会话「{title}」',
'forge.linkedSessions.title': '正在处理此事项的聊天', 'forge.linkedSessions.title': '正在处理此事项的聊天',
@@ -1519,6 +1519,13 @@ export const dict: Record<I18nKey, string> = {
'forge.actions.closeConfirm': '關閉此 Issue/PR?', 'forge.actions.closeConfirm': '關閉此 Issue/PR?',
'forge.actions.comment': '留言', 'forge.actions.comment': '留言',
'forge.actions.commentPlaceholder': '新增留言…', 'forge.actions.commentPlaceholder': '新增留言…',
'forge.actions.createIssue': '建立問題',
'forge.actions.issueBodyPlaceholder': '描述問題…',
'forge.actions.issueCreated': '問題已建立',
'forge.actions.issueDialogTitle': '新問題',
'forge.actions.issueLabelsPlaceholder': '標籤(以逗號分隔)',
'forge.actions.issueTitlePlaceholder': '標題',
'forge.actions.newIssue': '新問題',
'forge.actions.draftChanged': '草稿狀態已更新', 'forge.actions.draftChanged': '草稿狀態已更新',
'forge.actions.edit': '編輯', 'forge.actions.edit': '編輯',
'forge.actions.error': '操作失敗', 'forge.actions.error': '操作失敗',
@@ -1559,6 +1566,8 @@ export const dict: Record<I18nKey, string> = {
'forge.files.empty': '沒有檔案變更', 'forge.files.empty': '沒有檔案變更',
'forge.files.noDiff': '沒有可用的差異', 'forge.files.noDiff': '沒有可用的差異',
'forge.loading': '載入中...', 'forge.loading': '載入中...',
'forge.lookup.empty': '無相符項目',
'forge.lookup.loading': '搜尋中…',
'forge.linkedSessions.count': '與此項目關聯的會話:{count}', 'forge.linkedSessions.count': '與此項目關聯的會話:{count}',
'forge.linkedSessions.open': '開啟會話「{title}」', 'forge.linkedSessions.open': '開啟會話「{title}」',
'forge.linkedSessions.title': '正在處理此項目的聊天', 'forge.linkedSessions.title': '正在處理此項目的聊天',
@@ -107,6 +107,7 @@
| PATCH | `/api/gitea/pr/update` | body `{ directory, number, title?, description?, state? }` -> `{ 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/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 }` | | POST | `/api/gitea/issues/comment` | body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment }` |
| POST | `/api/gitea/issues/create` | body `{ directory, title, body?, labels?, owner?, repo? }` -> `{ connected, repo?, issue }` |
| 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 | | 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/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` | | POST | `/api/gitea/prs/review` | body `{ directory, number, event, body?, owner?, repo? }` -> `{ connected, repo?, review }`; `400` when `event` is not `APPROVED`/`REQUEST_CHANGES`/`COMMENT` |
+8
View File
@@ -265,6 +265,8 @@ export function createGiteaClient({ token, baseUrl }) {
request(`/repos/${owner}/${repo}/issues/${number}/comments`, { query: params }), request(`/repos/${owner}/${repo}/issues/${number}/comments`, { query: params }),
createIssueComment: (owner, repo, number, body) => createIssueComment: (owner, repo, number, body) =>
request(`/repos/${owner}/${repo}/issues/${number}/comments`, { method: 'POST', body: { body } }), request(`/repos/${owner}/${repo}/issues/${number}/comments`, { method: 'POST', body: { body } }),
createIssue: (owner, repo, params) =>
request(`/repos/${owner}/${repo}/issues`, { method: 'POST', body: params }),
updateIssue: (owner, repo, number, params) => updateIssue: (owner, repo, number, params) =>
request(`/repos/${owner}/${repo}/issues/${number}`, { method: 'PATCH', body: params }), request(`/repos/${owner}/${repo}/issues/${number}`, { method: 'PATCH', body: params }),
milestones: (owner, repo, params = {}) => milestones: (owner, repo, params = {}) =>
@@ -295,6 +297,12 @@ export function createGiteaClient({ token, baseUrl }) {
request(`/repos/${owner}/${repo}/pulls/${number}/merge`, { method: 'POST', body }), request(`/repos/${owner}/${repo}/pulls/${number}/merge`, { method: 'POST', body }),
branches: (owner, repo, params = {}) => branches: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/branches`, { query: params }), request(`/repos/${owner}/${repo}/branches`, { query: params }),
// Assignable users (collaborators with role access + org members) are the
// mention/assign candidate set; Gitea mirrors the GitHub assignees route.
assignees: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/assignees`, { query: params }),
tags: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/tags`, { query: params }),
}; };
} }
+267
View File
@@ -569,6 +569,60 @@ export function registerGiteaRoutes(app, options = {}) {
} }
}); });
app.post('/api/gitea/issues/create', async (req, res) => {
try {
const directory = asString(req.body?.directory);
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!directory || !title) {
return res.status(400).json({ error: 'directory and title are required' });
}
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : undefined;
const labels = Array.isArray(req.body?.labels)
? req.body.labels.filter((label) => typeof label === 'string' && label.length > 0)
: undefined;
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 = {
title,
...(body !== undefined ? { body } : {}),
...(labels !== undefined ? { labels } : {}),
};
const resp = await client.createIssue(owner, repo, 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 create issues' });
}
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 issue' });
}
if (!resp.data) {
return res.status(500).json({ error: 'Gitea returned an empty response while creating the issue' });
}
return res.json({
connected: true,
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
issue: mapGiteaIssue(resp.data),
});
} catch (error) {
console.error('Failed to create Gitea issue:', error);
return res.status(500).json({ error: error.message || 'Failed to create Gitea issue' });
}
});
app.patch('/api/gitea/issues/update', async (req, res) => { app.patch('/api/gitea/issues/update', async (req, res) => {
try { try {
const directory = asString(req.body?.directory); const directory = asString(req.body?.directory);
@@ -1418,4 +1472,217 @@ export function registerGiteaRoutes(app, options = {}) {
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea repo labels' }); return res.status(500).json({ error: error.message || 'Failed to fetch Gitea repo labels' });
} }
}); });
// ================= Gitea Rich Lookup APIs =================
// Repo-scoped lookups for pickers/mentions. Each resolves the target repo
// (directory remote + owner/repo override) and hits a Gitea endpoint that
// does not support server-side search, then filters client-side
// (case-insensitive substring on the primary field). `connected: false`
// means the lookup could not be performed — never an authoritative empty list.
const lookupRepo = async (req) => {
const directory = asString(req.query?.directory);
const requestedRepo = getRequestedRepo(req);
if (!directory && !requestedRepo) {
return { error: 'directory or owner/repo is required' };
}
const client = await getClient();
if (!client) {
return { client: null };
}
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
return { client, owner, repo, repoRef };
};
app.get('/api/gitea/users/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupRepo(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, users: [] });
}
const { client, owner, repo, repoRef } = resolved;
if (!owner || !repo) {
return res.json({ connected: true, repo: null, users: [] });
}
const resp = await withTimeout(client.assignees(owner, repo, { limit: 100 }), ROUTE_TIMEOUT_MS, 'gitea users search');
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 searching users' });
}
const needle = query.toLowerCase();
const users = (Array.isArray(resp.data) ? resp.data : [])
.map(mapGiteaUser)
.filter((user) => user && user.username)
.filter((user) => !needle || user.username.toLowerCase().includes(needle) || (user.name || '').toLowerCase().includes(needle));
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), users });
} catch (error) {
console.error('Failed to search Gitea users:', error);
return res.status(500).json({ error: error.message || 'Failed to search Gitea users' });
}
});
app.get('/api/gitea/labels/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupRepo(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, labels: [] });
}
const { client, owner, repo, repoRef } = resolved;
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 labels search');
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 searching labels' });
}
const needle = query.toLowerCase();
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 } : {}),
}))
.filter((label) => label.name && (!needle || label.name.toLowerCase().includes(needle)));
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), labels });
} catch (error) {
console.error('Failed to search Gitea labels:', error);
return res.status(500).json({ error: error.message || 'Failed to search Gitea labels' });
}
});
app.get('/api/gitea/milestones/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupRepo(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, milestones: [] });
}
const { client, owner, repo, repoRef } = resolved;
if (!owner || !repo) {
return res.json({ connected: true, repo: null, milestones: [] });
}
const resp = await withTimeout(client.milestones(owner, repo, { state: 'all', limit: 100 }), ROUTE_TIMEOUT_MS, 'gitea milestones search');
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 searching milestones' });
}
const needle = query.toLowerCase();
const milestones = (Array.isArray(resp.data) ? resp.data : [])
.map((item) => ({
title: typeof item?.title === 'string' ? item.title : '',
...(typeof item?.state === 'string' ? { state: item.state } : {}),
}))
.filter((item) => item.title && (!needle || item.title.toLowerCase().includes(needle)));
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), milestones });
} catch (error) {
console.error('Failed to search Gitea milestones:', error);
return res.status(500).json({ error: error.message || 'Failed to search Gitea milestones' });
}
});
app.get('/api/gitea/branches/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupRepo(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, branches: [] });
}
const { client, owner, repo, repoRef } = resolved;
if (!owner || !repo) {
return res.json({ connected: true, repo: null, branches: [] });
}
const branches = [];
const needle = query.toLowerCase();
let page = 1;
while (page <= 10) {
const resp = await withTimeout(client.branches(owner, repo, { limit: 50, page }), ROUTE_TIMEOUT_MS, 'gitea branches search');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status !== 200 || !Array.isArray(resp.data)) {
break;
}
const chunk = resp.data;
for (const branch of chunk) {
const name = typeof branch?.name === 'string' ? branch.name : '';
if (!name) continue;
if (!needle || name.toLowerCase().includes(needle)) branches.push(name);
}
if (chunk.length < 50 || !resp.page?.hasMore) {
break;
}
page += 1;
}
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), branches });
} catch (error) {
console.error('Failed to search Gitea branches:', error);
return res.status(500).json({ error: error.message || 'Failed to search Gitea branches' });
}
});
app.get('/api/gitea/tags/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupRepo(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, tags: [] });
}
const { client, owner, repo, repoRef } = resolved;
if (!owner || !repo) {
return res.json({ connected: true, repo: null, tags: [] });
}
const resp = await withTimeout(client.tags(owner, repo, { limit: 100 }), ROUTE_TIMEOUT_MS, 'gitea tags search');
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 searching tags' });
}
const needle = query.toLowerCase();
const tags = (Array.isArray(resp.data) ? resp.data : [])
.map((tag) => (typeof tag?.name === 'string' ? tag.name : ''))
.filter(Boolean)
.filter((name) => !needle || name.toLowerCase().includes(needle));
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), tags });
} catch (error) {
console.error('Failed to search Gitea tags:', error);
return res.status(500).json({ error: error.message || 'Failed to search Gitea tags' });
}
});
} }
@@ -1339,6 +1339,174 @@ describe('Gitea data routes', () => {
expect(JSON.parse(options.body)).toEqual({ state: 'closed' }); expect(JSON.parse(options.body)).toEqual({ state: 'closed' });
}); });
test('issues/create POSTs an issue and maps it', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/issues$/)(url) && options.method === 'POST') {
return jsonResponse({
number: 9,
title: 'New issue',
html_url: 'https://gitea.example.com/owner/repo/issues/9',
state: 'open',
body: 'The body',
labels: [{ id: 1, name: 'bug', color: 'd73a4a' }],
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
created_at: '2026-01-02T11:00:00Z',
updated_at: '2026-01-02T11:00:00Z',
}, { status: 201 });
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/issues/create')
.send({ directory: '/tmp/work', title: 'New issue', body: 'The body', labels: ['bug'] });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' },
issue: {
number: 9,
title: 'New issue',
url: 'https://gitea.example.com/owner/repo/issues/9',
state: 'open',
body: 'The body',
labels: ['bug'],
author: { username: 'alice', id: 42 },
},
});
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ title: 'New issue', body: 'The body', labels: ['bug'] });
});
test('issues/create requires directory and title', async () => {
const app = createApp();
const response = await request(app)
.post('/api/gitea/issues/create')
.send({ directory: '/tmp/work' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory and title are required' });
});
test('issues/create reports connected:false when not authenticated', async () => {
clearGiteaAuth();
const app = createApp();
const response = await request(app)
.post('/api/gitea/issues/create')
.send({ directory: '/tmp/work', title: 'Hi' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
});
describe('Gitea rich lookup routes', () => {
beforeEach(() => {
setGiteaAuth({ accessToken: 'gitea-secret', baseUrl: 'https://gitea.example.com', user: aliceUser });
});
test('users/search maps repo assignees and filters by query', async () => {
scriptedFetch([
(url) => (matches(/\/repos\/owner\/repo\/assignees\?/)(url)
? jsonResponse([
{ id: 42, login: 'alice', full_name: 'Alice Example', avatar_url: 'https://gitea.example.com/alice.png', html_url: 'https://gitea.example.com/alice' },
{ id: 43, login: 'bob', full_name: 'Bob', avatar_url: 'https://gitea.example.com/bob.png', html_url: 'https://gitea.example.com/bob' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/users/search?directory=%2Ftmp%2Fwork&query=ali');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
users: [{ username: 'alice', id: 42, name: 'Alice Example', avatarUrl: 'https://gitea.example.com/alice.png' }],
});
});
test('users/search reports connected:false when not authenticated', async () => {
clearGiteaAuth();
const app = createApp();
const response = await request(app).get('/api/gitea/users/search?directory=%2Ftmp%2Fwork&query=ali');
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false, users: [] });
});
test('labels/search returns labeled objects filtered by name', async () => {
scriptedFetch([
(url) => (matches(/\/repos\/owner\/repo\/labels\?/)(url)
? jsonResponse([
{ id: 1, name: 'bug', color: 'd73a4a', description: 'a bug' },
{ id: 2, name: 'feature', color: '0e8a16' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/labels/search?directory=%2Ftmp%2Fwork&query=bug');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
labels: [{ name: 'bug', color: 'd73a4a', description: 'a bug' }],
});
});
test('milestones/search maps milestone titles and states', async () => {
scriptedFetch([
(url) => (matches(/\/repos\/owner\/repo\/milestones\?/)(url)
? jsonResponse([
{ id: 1, title: 'v1.0', state: 'open' },
{ id: 2, title: 'v2.0', state: 'closed' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/milestones/search?directory=%2Ftmp%2Fwork&query=v1');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
milestones: [{ title: 'v1.0', state: 'open' }],
});
});
test('branches/search returns branch names', async () => {
scriptedFetch([
(url) => (matches(/\/repos\/owner\/repo\/branches\?/)(url)
? jsonResponse([{ name: 'main' }, { name: 'feat/x' }])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/branches/search?directory=%2Ftmp%2Fwork&query=main');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true, branches: ['main'] });
});
test('tags/search returns tag names', async () => {
scriptedFetch([
(url) => (matches(/\/repos\/owner\/repo\/tags\?/)(url)
? jsonResponse([{ name: 'v1.0.0' }, { name: 'v1.1.0' }])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/tags/search?directory=%2Ftmp%2Fwork&query=v1.0');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true, tags: ['v1.0.0'] });
});
});
// NOTE: keep this test last in the file. The rate-limit cooldown is // 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. // 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 () => { test('data routes surface a 503 when Gitea rate limits', async () => {
@@ -72,6 +72,7 @@
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). 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`). - `POST /api/github/issues/comment` — body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment? }` (via `octokit.rest.issues.createComment`, mapped to `GitHubIssueComment`).
- `POST /api/github/issues/create` — body `{ directory, title, body?, labels?, owner?, repo? }` -> `{ connected, repo?, issue? }` (via `octokit.rest.issues.create`; `labels` is a full-set list of names).
- `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. - `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/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-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.
+319
View File
@@ -1508,6 +1508,251 @@ export function registerGitHubRoutes(app) {
} }
}); });
// ================= GitHub Rich Lookup APIs =================
// Repo-scoped lookups for pickers/mentions. Each resolves the target repo
// (directory remote + owner/repo override), hits a GitHub endpoint that does
// not support server-side search, then filters client-side (case-insensitive
// substring on the primary field). `connected: false` means the lookup could
// not be performed — never an authoritative empty list.
app.get('/api/github/users/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, users: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, users: [] });
}
const list = await withTimeout(
octokit.rest.issues.listAssignees({ owner: repo.owner, repo: repo.repo, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'github users search',
);
const needle = query.toLowerCase();
const users = (Array.isArray(list?.data) ? list.data : [])
.map(mapGitHubUserSummary)
.filter(Boolean)
.filter((user) => !needle || user.login.toLowerCase().includes(needle) || (user.name || '').toLowerCase().includes(needle));
return res.json({ connected: true, repo, users });
} 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, users: [] });
}
console.error('Failed to search GitHub users:', error);
return res.json({ connected: false, users: [] });
}
});
app.get('/api/github/labels/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, labels: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, labels: [] });
}
const list = await withTimeout(
octokit.rest.issues.listLabelsForRepo({ owner: repo.owner, repo: repo.repo, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'github labels search',
);
const needle = query.toLowerCase();
const labels = mapGitHubLabels(list?.data).filter(
(label) => !needle || label.name.toLowerCase().includes(needle),
);
return res.json({ connected: true, repo, labels });
} 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, labels: [] });
}
console.error('Failed to search GitHub labels:', error);
return res.json({ connected: false, labels: [] });
}
});
app.get('/api/github/milestones/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, milestones: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, milestones: [] });
}
const list = await withTimeout(
octokit.rest.issues.listMilestonesForRepo({ owner: repo.owner, repo: repo.repo, state: 'all', per_page: 100 }),
ROUTE_TIMEOUT_MS,
'github milestones search',
);
const needle = query.toLowerCase();
const milestones = (Array.isArray(list?.data) ? list.data : [])
.map(mapGitHubMilestone)
.filter(Boolean)
.filter((milestone) => !needle || milestone.title.toLowerCase().includes(needle));
return res.json({ connected: true, repo, milestones });
} 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, milestones: [] });
}
console.error('Failed to search GitHub milestones:', error);
return res.json({ connected: false, milestones: [] });
}
});
app.get('/api/github/branches/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, branches: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, branches: [] });
}
const branches = [];
const needle = query.toLowerCase();
let page = 1;
while (true) {
const response = await withTimeout(
octokit.rest.repos.listBranches({ owner: repo.owner, repo: repo.repo, per_page: 100, page }),
ROUTE_TIMEOUT_MS,
'github branches search',
);
if (!response.data || response.data.length === 0) break;
for (const branch of response.data) {
const name = typeof branch?.name === 'string' ? branch.name : '';
if (!name) continue;
if (!needle || name.toLowerCase().includes(needle)) branches.push(name);
}
if (response.data.length < 100) break;
page++;
}
return res.json({ connected: true, repo, branches });
} 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, branches: [] });
}
console.error('Failed to search GitHub branches:', error);
return res.json({ connected: false, branches: [] });
}
});
app.get('/api/github/tags/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, tags: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, tags: [] });
}
const list = await withTimeout(
octokit.rest.repos.listTags({ owner: repo.owner, repo: repo.repo, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'github tags search',
);
const needle = query.toLowerCase();
const tags = (Array.isArray(list?.data) ? list.data : [])
.map((tag) => (typeof tag?.name === 'string' ? tag.name : ''))
.filter(Boolean)
.filter((name) => !needle || name.toLowerCase().includes(needle));
return res.json({ connected: true, repo, tags });
} 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, tags: [] });
}
console.error('Failed to search GitHub tags:', error);
return res.json({ connected: false, tags: [] });
}
});
// ================= GitHub Issue APIs ================= // ================= GitHub Issue APIs =================
app.get('/api/github/issues/list', async (req, res) => { app.get('/api/github/issues/list', async (req, res) => {
@@ -1786,6 +2031,80 @@ export function registerGitHubRoutes(app) {
} }
}); });
app.post('/api/github/issues/create', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!directory || !title) {
return res.status(400).json({ error: 'directory and title are required' });
}
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : undefined;
const labels = Array.isArray(req.body?.labels)
? req.body.labels.filter((label) => typeof label === 'string' && label.length > 0)
: undefined;
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 result = await octokit.rest.issues.create({
owner: repo.owner,
repo: repo.repo,
title,
...(body !== undefined ? { body } : {}),
...(labels !== undefined ? { labels } : {}),
});
const item = result?.data;
if (!item) {
return res.status(500).json({ error: 'GitHub returned an error while creating the issue' });
}
return res.json({
connected: true,
repo,
issue: {
number: item.number,
title: typeof item.title === 'string' ? item.title : title,
url: typeof item.html_url === 'string' ? item.html_url : '',
state: item.state === 'closed' ? 'closed' : 'open',
author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
body: typeof item.body === 'string' ? item.body : '',
createdAt: item.created_at,
updatedAt: item.updated_at,
labels: Array.isArray(item.labels)
? item.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)
: [],
},
});
} 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:', error);
return res.status(500).json({ error: 'GitHub returned an error' });
}
});
app.patch('/api/github/issues/update', async (req, res) => { app.patch('/api/github/issues/update', async (req, res) => {
try { try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
@@ -22,9 +22,16 @@ const mockState = vi.hoisted(() => ({
issues: { issues: {
listEventsForTimeline: vi.fn(), listEventsForTimeline: vi.fn(),
createComment: vi.fn(), createComment: vi.fn(),
create: vi.fn(),
update: vi.fn(), update: vi.fn(),
listMilestonesForRepo: vi.fn(), listMilestonesForRepo: vi.fn(),
listComments: vi.fn(), listComments: vi.fn(),
listAssignees: vi.fn(),
listLabelsForRepo: vi.fn(),
},
repos: {
listBranches: vi.fn(),
listTags: vi.fn(),
}, },
}, },
}, },
@@ -59,9 +66,14 @@ beforeEach(() => {
mockState.octokit.rest.pulls.listFiles.mockReset(); mockState.octokit.rest.pulls.listFiles.mockReset();
mockState.octokit.rest.issues.listEventsForTimeline.mockReset(); mockState.octokit.rest.issues.listEventsForTimeline.mockReset();
mockState.octokit.rest.issues.createComment.mockReset(); mockState.octokit.rest.issues.createComment.mockReset();
mockState.octokit.rest.issues.create.mockReset();
mockState.octokit.rest.issues.update.mockReset(); mockState.octokit.rest.issues.update.mockReset();
mockState.octokit.rest.issues.listMilestonesForRepo.mockReset(); mockState.octokit.rest.issues.listMilestonesForRepo.mockReset();
mockState.octokit.rest.issues.listComments.mockReset(); mockState.octokit.rest.issues.listComments.mockReset();
mockState.octokit.rest.issues.listAssignees.mockReset();
mockState.octokit.rest.issues.listLabelsForRepo.mockReset();
mockState.octokit.rest.repos.listBranches.mockReset();
mockState.octokit.rest.repos.listTags.mockReset();
mockState.getOctokitOrNull.mockImplementation(() => mockState.octokit); mockState.getOctokitOrNull.mockImplementation(() => mockState.octokit);
}); });
@@ -628,3 +640,235 @@ describe('GitHub write routes', () => {
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled(); expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
}); });
}); });
describe('GitHub issues/create route', () => {
test('issues/create calls issues.create and returns the created issue', async () => {
mockState.octokit.rest.issues.create.mockResolvedValue({
data: {
number: 12,
title: 'Add feature',
html_url: 'https://github.com/owner/repo/issues/12',
state: 'open',
body: 'The body',
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' },
labels: [{ name: 'bug', color: 'd73a4a' }],
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/issues/create')
.send({ directory: '/tmp/work', title: 'Add feature', body: 'The body', labels: ['bug'] });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
issue: {
number: 12,
title: 'Add feature',
url: 'https://github.com/owner/repo/issues/12',
state: 'open',
body: 'The body',
author: { login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' },
labels: [{ name: 'bug', color: 'd73a4a' }],
},
});
expect(mockState.octokit.rest.issues.create).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
title: 'Add feature',
body: 'The body',
labels: ['bug'],
});
});
test('issues/create omits body and labels when not provided', async () => {
mockState.octokit.rest.issues.create.mockResolvedValue({
data: {
number: 13,
title: 'Title only',
html_url: 'https://github.com/owner/repo/issues/13',
state: 'open',
user: null,
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/issues/create')
.send({ directory: '/tmp/work', title: 'Title only' });
expect(response.status).toBe(200);
expect(response.body.issue.title).toBe('Title only');
expect(mockState.octokit.rest.issues.create).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
title: 'Title only',
});
});
test('issues/create returns connected:false when not authenticated', async () => {
mockState.getOctokitOrNull.mockImplementation(() => null);
const app = createApp();
const response = await request(app)
.post('/api/github/issues/create')
.send({ directory: '/tmp/work', title: 'Hi' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
expect(mockState.octokit.rest.issues.create).not.toHaveBeenCalled();
});
test('issues/create requires directory and title', async () => {
const app = createApp();
const response = await request(app)
.post('/api/github/issues/create')
.send({ directory: '/tmp/work' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory and title are required' });
});
});
describe('GitHub rich lookup routes', () => {
describe('users/search', () => {
test('maps assignable users and filters by query', async () => {
mockState.octokit.rest.issues.listAssignees.mockResolvedValue({
data: [
{ login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
{ login: 'bob', id: 43, avatar_url: 'https://avatars.githubusercontent.com/u/43' },
{ login: 'carol', id: 44, avatar_url: null, name: 'Carol Coder' },
],
});
const app = createApp();
const response = await request(app).get('/api/github/users/search?directory=%2Ftmp%2Fwork&query=al');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
users: [{ login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' }],
});
expect(mockState.octokit.rest.issues.listAssignees).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
per_page: 100,
});
});
test('returns connected:false when not authenticated', async () => {
mockState.getOctokitOrNull.mockImplementation(() => null);
const app = createApp();
const response = await request(app).get('/api/github/users/search?directory=%2Ftmp%2Fwork&query=al');
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false, users: [] });
});
});
describe('labels/search', () => {
test('maps repo labels and filters by query', async () => {
mockState.octokit.rest.issues.listLabelsForRepo.mockResolvedValue({
data: [
{ name: 'bug', color: 'd73a4a' },
{ name: 'feature', color: '0e8a16' },
],
});
const app = createApp();
const response = await request(app).get('/api/github/labels/search?directory=%2Ftmp%2Fwork&query=bug');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
labels: [{ name: 'bug', color: 'd73a4a' }],
});
expect(mockState.octokit.rest.issues.listLabelsForRepo).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
per_page: 100,
});
});
});
describe('milestones/search', () => {
test('maps milestone titles and states', async () => {
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({
data: [
{ title: 'v1.0', state: 'open' },
{ title: 'v2.0', state: 'closed' },
],
});
const app = createApp();
const response = await request(app).get('/api/github/milestones/search?directory=%2Ftmp%2Fwork&query=v1');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
milestones: [{ title: 'v1.0', state: 'open' }],
});
expect(mockState.octokit.rest.issues.listMilestonesForRepo).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
state: 'all',
per_page: 100,
});
});
});
describe('branches/search', () => {
test('aggregates branch names across pages and filters by query', async () => {
mockState.octokit.rest.repos.listBranches
.mockResolvedValueOnce({ data: [{ name: 'main' }, { name: 'feat/x' }] })
.mockResolvedValueOnce({ data: [] });
const app = createApp();
const response = await request(app).get('/api/github/branches/search?directory=%2Ftmp%2Fwork&query=main');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
branches: ['main'],
});
expect(mockState.octokit.rest.repos.listBranches).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
per_page: 100,
page: 1,
});
});
});
describe('tags/search', () => {
test('maps tag names and filters by query', async () => {
mockState.octokit.rest.repos.listTags.mockResolvedValue({
data: [{ name: 'v1.0.0' }, { name: 'v1.1.0' }],
});
const app = createApp();
const response = await request(app).get('/api/github/tags/search?directory=%2Ftmp%2Fwork&query=v1.0');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
tags: ['v1.0.0'],
});
expect(mockState.octokit.rest.repos.listTags).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
per_page: 100,
});
});
});
});
@@ -111,6 +111,7 @@ Nothing in the client or repo layers assumes the token came from a PAT.
| 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/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 }` | | 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 }` | | POST | `/api/gitlab/issues/comment` | body `{ directory, number, body, namespace?, project? }` -> `{ connected, repo?, comment }` |
| POST | `/api/gitlab/issues/create` | body `{ directory, title, body?, labels?, namespace?, project? }` -> `{ connected, repo?, issue }` |
| 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 | | 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/comment` | body `{ directory, number, body, namespace?, project? }` -> `{ connected, repo?, comment }` |
| POST | `/api/gitlab/mrs/approve` | body `{ directory, number, namespace?, project? }` -> `{ connected, repo?, approved: true }` | | POST | `/api/gitlab/mrs/approve` | body `{ directory, number, namespace?, project? }` -> `{ connected, repo?, approved: true }` |
+11
View File
@@ -263,6 +263,8 @@ export function createGitLabClient({ token, baseUrl }) {
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { query: params }), request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { query: params }),
createIssueNote: (pathWithNamespace, iid, body) => createIssueNote: (pathWithNamespace, iid, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { method: 'POST', body: { body } }), request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { method: 'POST', body: { body } }),
createIssue: (pathWithNamespace, params) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues`, { method: 'POST', body: params }),
updateIssue: (pathWithNamespace, iid, params) => updateIssue: (pathWithNamespace, iid, params) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}`, { method: 'PUT', body: params }), request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}`, { method: 'PUT', body: params }),
mergeRequests: (pathWithNamespace, params = {}) => mergeRequests: (pathWithNamespace, params = {}) =>
@@ -289,6 +291,15 @@ export function createGitLabClient({ token, baseUrl }) {
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/merge`, { method: 'PUT', body }), request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/merge`, { method: 'PUT', body }),
branches: (pathWithNamespace, params = {}) => branches: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/repository/branches`, { query: params }), request(`/projects/${encodeProject(pathWithNamespace)}/repository/branches`, { query: params }),
// Project members (direct + inherited) are the assignable/mentionable user
// set. `members/all` includes inherited group members; `query` filters
// server-side by username/name/email.
members: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/members/all`, { query: params }),
labels: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/labels`, { query: params }),
tags: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/repository/tags`, { query: params }),
}; };
} }
+305
View File
@@ -232,6 +232,32 @@ const mapDiffItem = (item) => {
}; };
}; };
// GitLab assigns by numeric user ID, while the facade deals in logins. Resolve
// a set of assignee logins to IDs via the project members list; unmatched
// logins yield `{ unknown: login }` so update routes can surface a precise
// `400 { error: 'Unknown assignee: ...' }` instead of silently dropping a user.
const resolveAssigneeIds = async (client, projectPath, logins) => {
const uniqueLogins = [...new Set(logins)];
const ids = [];
for (const login of uniqueLogins) {
const resp = await client.members(projectPath, { per_page: 100, query: login });
if (resp.status === 429) {
return { ids: null, rateLimited: true, unknown: null };
}
if (resp.status !== 200 || !Array.isArray(resp.data)) {
return { ids: null, rateLimited: false, unknown: null };
}
const match = resp.data.find(
(item) => typeof item?.username === 'string' && item.username.toLowerCase() === login.toLowerCase(),
);
if (typeof match?.id !== 'number') {
return { ids: null, rateLimited: false, unknown: login };
}
ids.push(match.id);
}
return { ids, rateLimited: false, unknown: null };
};
const repoRefFromProjectPath = (projectPath, baseUrl) => { const repoRefFromProjectPath = (projectPath, baseUrl) => {
const segments = projectPath.split('/'); const segments = projectPath.split('/');
const project = segments[segments.length - 1] || ''; const project = segments[segments.length - 1] || '';
@@ -642,6 +668,60 @@ export function registerGitLabRoutes(app, options = {}) {
} }
}); });
app.post('/api/gitlab/issues/create', async (req, res) => {
try {
const directory = asString(req.body?.directory);
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!directory || !title) {
return res.status(400).json({ error: 'directory and title are required' });
}
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : undefined;
const labels = Array.isArray(req.body?.labels)
? req.body.labels.filter((label) => typeof label === 'string' && label.length > 0)
: undefined;
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 params = {
title,
...(body !== undefined ? { description: body } : {}),
...(labels !== undefined ? { labels } : {}),
};
const resp = await client.createIssue(projectPath, params);
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 issues' });
}
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 issue' });
}
if (!resp.data) {
return res.status(500).json({ error: 'GitLab returned an empty response while creating the issue' });
}
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
issue: mapIssue(resp.data),
});
} catch (error) {
console.error('Failed to create GitLab issue:', error);
return res.status(500).json({ error: error.message || 'Failed to create GitLab issue' });
}
});
app.put('/api/gitlab/issues/update', async (req, res) => { app.put('/api/gitlab/issues/update', async (req, res) => {
try { try {
const directory = asString(req.body?.directory); const directory = asString(req.body?.directory);
@@ -675,6 +755,16 @@ export function registerGitLabRoutes(app, options = {}) {
if (Array.isArray(req.body?.labels)) { if (Array.isArray(req.body?.labels)) {
body.labels = req.body.labels.filter((label) => typeof label === 'string'); body.labels = req.body.labels.filter((label) => typeof label === 'string');
} }
if (Array.isArray(req.body?.assignees)) {
const { ids, rateLimited, unknown } = await resolveAssigneeIds(client, projectPath, req.body.assignees.filter((login) => typeof login === 'string'));
if (rateLimited) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (unknown !== null) {
return res.status(400).json({ error: `Unknown assignee: ${unknown}` });
}
body.assignee_ids = ids;
}
if (Array.isArray(req.body?.assigneeIds)) { if (Array.isArray(req.body?.assigneeIds)) {
body.assignee_ids = req.body.assigneeIds.filter((id) => typeof id === 'number'); body.assignee_ids = req.body.assigneeIds.filter((id) => typeof id === 'number');
} }
@@ -1103,6 +1193,16 @@ export function registerGitLabRoutes(app, options = {}) {
if (Array.isArray(req.body?.labels)) { if (Array.isArray(req.body?.labels)) {
body.labels = req.body.labels.filter((label) => typeof label === 'string'); body.labels = req.body.labels.filter((label) => typeof label === 'string');
} }
if (Array.isArray(req.body?.assignees)) {
const { ids, rateLimited, unknown } = await resolveAssigneeIds(client, projectPath, req.body.assignees.filter((login) => typeof login === 'string'));
if (rateLimited) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (unknown !== null) {
return res.status(400).json({ error: `Unknown assignee: ${unknown}` });
}
body.assignee_ids = ids;
}
if (Array.isArray(req.body?.assigneeIds)) { if (Array.isArray(req.body?.assigneeIds)) {
body.assignee_ids = req.body.assigneeIds.filter((id) => typeof id === 'number'); body.assignee_ids = req.body.assigneeIds.filter((id) => typeof id === 'number');
} }
@@ -1312,6 +1412,211 @@ export function registerGitLabRoutes(app, options = {}) {
} }
}); });
// ================= GitLab Rich Lookup APIs =================
// Repo-scoped lookups for pickers/mentions. Each resolves the target project
// (directory remote + namespace/project override) and hits a GitLab endpoint
// that supports server-side `query` filtering where available. `connected:
// false` means the lookup could not be performed — never an authoritative
// empty list.
const lookupProject = async (req) => {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
if (!directory && !requestedProject) {
return { error: 'directory or namespace/project is required' };
}
const client = await getClient();
if (!client) {
return { client: null };
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
return { client, projectPath, repo };
};
app.get('/api/gitlab/users/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, users: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, users: [] });
}
const resp = await withTimeout(
client.members(projectPath, { per_page: 100, ...(query ? { query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab users search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching users' });
}
const users = (Array.isArray(resp.data) ? resp.data : [])
.map(mapGitLabUser)
.filter((user) => user && user.username);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), users });
} catch (error) {
console.error('Failed to search GitLab users:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab users' });
}
});
app.get('/api/gitlab/labels/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, labels: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, labels: [] });
}
const resp = await withTimeout(
client.labels(projectPath, { per_page: 100, ...(query ? { search: query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab labels search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching labels' });
}
const labels = (Array.isArray(resp.data) ? resp.data : [])
.map((label) => (typeof label?.name === 'string' ? label.name : ''))
.filter(Boolean);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), labels });
} catch (error) {
console.error('Failed to search GitLab labels:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab labels' });
}
});
app.get('/api/gitlab/milestones/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, milestones: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, milestones: [] });
}
const resp = await withTimeout(
client.milestones(projectPath, { state: 'all', per_page: 100, ...(query ? { search: query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab milestones search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching milestones' });
}
const milestones = (Array.isArray(resp.data) ? resp.data : [])
.map((item) => ({
title: typeof item?.title === 'string' ? item.title : '',
...(typeof item?.state === 'string' ? { state: item.state } : {}),
}))
.filter((item) => item.title);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), milestones });
} catch (error) {
console.error('Failed to search GitLab milestones:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab milestones' });
}
});
app.get('/api/gitlab/branches/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, branches: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, branches: [] });
}
const resp = await withTimeout(
client.branches(projectPath, { per_page: 100, ...(query ? { search: query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab branches search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching branches' });
}
const branches = (Array.isArray(resp.data) ? resp.data : [])
.map((branch) => (typeof branch?.name === 'string' ? branch.name : ''))
.filter(Boolean);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), branches });
} catch (error) {
console.error('Failed to search GitLab branches:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab branches' });
}
});
app.get('/api/gitlab/tags/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, tags: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, tags: [] });
}
const resp = await withTimeout(
client.tags(projectPath, { per_page: 100, ...(query ? { search: query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab tags search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching tags' });
}
const tags = (Array.isArray(resp.data) ? resp.data : [])
.map((tag) => (typeof tag?.name === 'string' ? tag.name : ''))
.filter(Boolean);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), tags });
} catch (error) {
console.error('Failed to search GitLab tags:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab tags' });
}
});
// ================= GitLab Repo APIs ================= // ================= GitLab Repo APIs =================
app.get('/api/gitlab/repo/branches', async (req, res) => { app.get('/api/gitlab/repo/branches', async (req, res) => {
@@ -1220,6 +1220,234 @@ describe('GitLab data routes', () => {
expect(response.body).toEqual({ error: 'Milestone not found' }); expect(response.body).toEqual({ error: 'Milestone not found' });
}); });
test('issues/create POSTs an issue and maps it', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/issues$/)(url) && options.method === 'POST') {
return jsonResponse(
{
iid: 8,
title: 'New issue',
web_url: 'https://gitlab.com/group/sub/-/issues/8',
state: 'opened',
description: 'The body',
labels: ['bug'],
author: { id: 42, username: 'alice', name: 'Alice Example' },
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
},
{ status: 201 },
);
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.post('/api/gitlab/issues/create')
.send({ directory: '/tmp/work', title: 'New issue', body: 'The body', labels: ['bug'] });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { namespace: 'group', project: 'sub', host: 'gitlab.com' },
issue: {
number: 8,
title: 'New issue',
url: 'https://gitlab.com/group/sub/-/issues/8',
state: 'opened',
body: 'The body',
labels: ['bug'],
author: { username: 'alice', name: 'Alice Example', id: 42 },
},
});
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ title: 'New issue', description: 'The body', labels: ['bug'] });
});
test('issues/create requires directory and title', async () => {
const app = createApp();
const response = await request(app)
.post('/api/gitlab/issues/create')
.send({ directory: '/tmp/work' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory and title are required' });
});
test('issues/create reports connected:false when not authenticated', async () => {
clearGitLabAuth();
const app = createApp();
const response = await request(app)
.post('/api/gitlab/issues/create')
.send({ directory: '/tmp/work', title: 'Hi' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
});
describe('GitLab rich lookup routes', () => {
beforeEach(() => {
setGitLabAuth({ accessToken: 'gitlab-secret', baseUrl: 'https://gitlab.com', user: aliceUser });
});
test('users/search maps project members and passes the query', async () => {
const fetchMock = scriptedFetch([
(url) => (matches(/\/members\/all\?/)(url)
? jsonResponse([
{ id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png', web_url: 'https://gitlab.com/alice' },
{ id: 43, username: 'bob', name: 'Bob', avatar_url: 'https://gitlab.com/bob.png', web_url: 'https://gitlab.com/bob' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/users/search?directory=%2Ftmp%2Fwork&query=ali');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { namespace: 'group', project: 'sub' },
users: [
{ username: 'alice', id: 42, name: 'Alice Example', avatarUrl: 'https://gitlab.com/alice.png' },
{ username: 'bob', id: 43, name: 'Bob' },
],
});
const requestedUrl = String(fetchMock.mock.calls[0][0]);
expect(requestedUrl).toContain('query=ali');
});
test('users/search reports connected:false when not authenticated', async () => {
clearGitLabAuth();
const app = createApp();
const response = await request(app).get('/api/gitlab/users/search?directory=%2Ftmp%2Fwork&query=ali');
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false, users: [] });
});
test('labels/search returns label names and passes the search param', async () => {
const fetchMock = scriptedFetch([
(url) => (matches(/\/labels\?/)(url)
? jsonResponse([
{ id: 1, name: 'bug', color: '#d73a4a' },
{ id: 2, name: 'feature', color: '#0e8a16' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/labels/search?directory=%2Ftmp%2Fwork&query=bug');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true, labels: ['bug', 'feature'] });
const requestedUrl = String(fetchMock.mock.calls[0][0]);
expect(requestedUrl).toContain('search=bug');
});
test('milestones/search maps milestone titles and states', async () => {
scriptedFetch([
(url) => (matches(/\/milestones\?/)(url)
? jsonResponse([
{ id: 1, title: 'v1.0', state: 'active' },
{ id: 2, title: 'v2.0', state: 'closed' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/milestones/search?directory=%2Ftmp%2Fwork&query=v');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
milestones: [
{ title: 'v1.0', state: 'active' },
{ title: 'v2.0', state: 'closed' },
],
});
});
test('branches/search returns branch names', async () => {
scriptedFetch([
(url) => (matches(/\/repository\/branches\?/)(url)
? jsonResponse([
{ name: 'main', default: true },
{ name: 'feat/x', default: false },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/branches/search?directory=%2Ftmp%2Fwork&query=main');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true, branches: ['main', 'feat/x'] });
});
test('tags/search returns tag names', async () => {
scriptedFetch([
(url) => (matches(/\/repository\/tags\?/)(url)
? jsonResponse([
{ name: 'v1.0.0' },
{ name: 'v1.1.0' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/tags/search?directory=%2Ftmp%2Fwork&query=v1');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true, tags: ['v1.0.0', 'v1.1.0'] });
});
test('issues/update resolves assignee logins to IDs via project members', async () => {
const fetchMock = scriptedFetch([
(url) => (matches(/\/members\/all\?/)(url) && url.includes('query=alice')
? jsonResponse([{ id: 42, username: 'alice', name: 'Alice Example' }])
: null),
(url, options) => (matches(/\/issues\/7$/)(url) && options?.method === 'PUT'
? jsonResponse({
iid: 7,
title: 'Broken import',
web_url: 'https://gitlab.com/group/sub/-/issues/7',
state: 'opened',
author: { id: 42, username: 'alice', name: 'Alice Example' },
}, { status: 200 })
: null),
]);
const app = createApp();
const response = await request(app)
.put('/api/gitlab/issues/update')
.send({ directory: '/tmp/work', number: 7, assignees: ['alice'] });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true });
const patchCall = fetchMock.mock.calls.find(([url, options]) => String(url).includes('/issues/7') && options.method === 'PUT');
expect(patchCall).toBeTruthy();
expect(JSON.parse(patchCall[1].body)).toEqual({ assignee_ids: [42] });
});
test('issues/update rejects an unknown assignee login with a precise error', async () => {
scriptedFetch([
(url) => (matches(/\/members\/all\?/)(url)
? jsonResponse([{ id: 42, username: 'alice', name: 'Alice Example' }])
: null),
]);
const app = createApp();
const response = await request(app)
.put('/api/gitlab/issues/update')
.send({ directory: '/tmp/work', number: 7, assignees: ['nobody'] });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Unknown assignee: nobody' });
});
});
// NOTE: keep this test last in the file. The rate-limit cooldown is // 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. // 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 () => { test('data routes surface a 503 when GitLab rate limits', async () => {
+94
View File
@@ -306,4 +306,98 @@ describe('createWebGiteaAPI', () => {
const api = await createAPI(); const api = await createAPI();
await expect(api.issuesList('/workspace')).rejects.toThrow('Internal Server Error'); await expect(api.issuesList('/workspace')).rejects.toThrow('Internal Server Error');
}); });
it('passes directory/query/owner/repo to searchUsers and maps the response', async () => {
const result = {
connected: true,
repo: null,
users: [{ username: 'octocat', id: 1, name: 'Octo Cat', avatarUrl: 'https://gitea.example/octocat.png' }],
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'octo', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('omits owner/repo from searchUsers when not provided', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, repo: null, users: [] }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
const params = new URLSearchParams({ directory: '/workspace', query: 'octo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('throws the server error message when searchUsers fails', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'Gitea rate limited' }, { status: 503 }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).rejects.toThrow('Gitea rate limited');
});
it('passes directory/query to searchLabels and maps the response', async () => {
const result = { connected: true, repo: null, labels: [{ name: 'bug', color: 'd73a4a' }] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchLabels!('/workspace', 'feat', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/labels/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchMilestones and maps the response', async () => {
const result = { connected: true, repo: null, milestones: [{ title: 'v2.0', state: 'open' }] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchMilestones!('/workspace', 'v2', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'v2', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/milestones/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchBranches and maps the response', async () => {
const result = { connected: true, repo: null, branches: ['main', 'feat/api'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchBranches!('/workspace', 'feat', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/branches/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchTags and maps the response', async () => {
const result = { connected: true, repo: null, tags: ['v1.0', 'v1.1'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchTags!('/workspace', 'v1', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'v1', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/tags/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
}); });
+80
View File
@@ -2,13 +2,18 @@ import type {
GiteaAPI, GiteaAPI,
GiteaAuthStatus, GiteaAuthStatus,
GiteaBranchesResult, GiteaBranchesResult,
GiteaBranchesSearchResult,
GiteaIssueCommentInput, GiteaIssueCommentInput,
GiteaIssueCommentResult, GiteaIssueCommentResult,
GiteaIssueCommentsResult, GiteaIssueCommentsResult,
GiteaIssueCreateInput,
GiteaIssueCreateResult,
GiteaIssueGetResult, GiteaIssueGetResult,
GiteaIssuesListResult, GiteaIssuesListResult,
GiteaIssueUpdateInput, GiteaIssueUpdateInput,
GiteaIssueUpdateResult, GiteaIssueUpdateResult,
GiteaLabelsSearchResult,
GiteaMilestonesSearchResult,
GiteaPullRequest, GiteaPullRequest,
GiteaPullRequestCommitsResult, GiteaPullRequestCommitsResult,
GiteaPullRequestContextResult, GiteaPullRequestContextResult,
@@ -22,7 +27,9 @@ import type {
GiteaPullReviewInput, GiteaPullReviewInput,
GiteaPullReviewResult, GiteaPullReviewResult,
GiteaRepoLabelsResult, GiteaRepoLabelsResult,
GiteaTagsSearchResult,
GiteaUserSummary, GiteaUserSummary,
GiteaUsersSearchResult,
} from '@openchamber/ui/lib/api/types'; } from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch'; import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
@@ -98,6 +105,66 @@ export const createWebGiteaAPI = ({ urls }: WebGiteaAPIOptions): GiteaAPI => ({
return payload; return payload;
}, },
async searchUsers(directory, query, options): Promise<GiteaUsersSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/users/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaUsersSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea users');
}
return { connected: body.connected, repo: body.repo ?? null, users: body.users ?? [] };
},
async searchLabels(directory, query, options): Promise<GiteaLabelsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/labels/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaLabelsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea labels');
}
return { connected: body.connected, repo: body.repo ?? null, labels: body.labels ?? [] };
},
async searchMilestones(directory, query, options): Promise<GiteaMilestonesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/milestones/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaMilestonesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea milestones');
}
return { connected: body.connected, repo: body.repo ?? null, milestones: body.milestones ?? [] };
},
async searchBranches(directory, query, options): Promise<GiteaBranchesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/branches/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaBranchesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea branches');
}
return { connected: body.connected, repo: body.repo ?? null, branches: body.branches ?? [] };
},
async searchTags(directory, query, options): Promise<GiteaTagsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/tags/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaTagsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea tags');
}
return { connected: body.connected, repo: body.repo ?? null, tags: body.tags ?? [] };
},
async issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GiteaIssuesListResult> { async issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GiteaIssuesListResult> {
const page = options?.page ?? 1; const page = options?.page ?? 1;
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -307,6 +374,19 @@ export const createWebGiteaAPI = ({ urls }: WebGiteaAPIOptions): GiteaAPI => ({
return body; return body;
}, },
async issueCreate(input: GiteaIssueCreateInput): Promise<GiteaIssueCreateResult> {
const response = await runtimeFetch('/api/gitea/issues/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GiteaIssueCreateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to create Gitea issue');
}
return body;
},
async issueUpdate(input: GiteaIssueUpdateInput): Promise<GiteaIssueUpdateResult> { async issueUpdate(input: GiteaIssueUpdateInput): Promise<GiteaIssueUpdateResult> {
const response = await runtimeFetch('/api/gitea/issues/update', { const response = await runtimeFetch('/api/gitea/issues/update', {
method: 'PATCH', method: 'PATCH',
+99
View File
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { RuntimeUrlQuery, RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
const runtimeFetchMock = vi.fn();
vi.mock('@openchamber/ui/lib/runtime-fetch', () => ({
runtimeFetch: runtimeFetchMock,
}));
const toUrl = (path: string, query?: RuntimeUrlQuery): string => {
const params = query instanceof URLSearchParams ? query : new URLSearchParams();
const queryString = params.toString();
return queryString ? `${path}?${queryString}` : path;
};
const urls: RuntimeUrlResolver = {
api: toUrl,
authenticatedAsset: toUrl,
auth: toUrl,
health: (query?: RuntimeUrlQuery) => toUrl('/health', query),
rawFile: (path: string) => toUrl('/api/fs/raw', new URLSearchParams({ path })),
sse: toUrl,
websocket: toUrl,
};
const createAPI = async () => {
const { createWebGitHubAPI } = await import('./github');
return createWebGitHubAPI({ urls });
};
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
runtimeFetchMock.mockReset();
});
describe('createWebGitHubAPI', () => {
it('passes directory/query/owner/repo to searchUsers and maps the response', async () => {
const result = {
connected: true,
repo: null,
users: [{ login: 'octocat', id: 1, name: 'Octo Cat', avatarUrl: 'https://avatars.example/octocat.png' }],
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo', { sourceRepo: { owner: 'acme', repo: 'widget' } }))
.resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'octo', owner: 'acme', repo: 'widget' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/github/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('omits owner/repo from searchUsers when no sourceRepo is provided', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, repo: null, users: [] }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
const params = new URLSearchParams({ directory: '/workspace', query: 'octo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/github/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('defaults missing repo/users fields to null/empty when the response omits them', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
});
it('throws the server error message when searchUsers fails', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'GitHub rate limited' }, { status: 503 }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).rejects.toThrow('GitHub rate limited');
});
it('passes directory/query to searchLabels and maps the response', async () => {
const result = { connected: true, repo: null, labels: [{ name: 'bug', color: 'd73a4a' }] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchLabels!('/workspace', 'feat', { sourceRepo: { owner: 'acme', repo: 'widget' } }))
.resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', owner: 'acme', repo: 'widget' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/github/labels/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
});
+90
View File
@@ -1,13 +1,18 @@
import type { import type {
GitHubAPI, GitHubAPI,
GitHubAuthStatus, GitHubAuthStatus,
GitHubBranchesSearchResult,
GitHubIssueCommentsResult, GitHubIssueCommentsResult,
GitHubIssueCommentInput, GitHubIssueCommentInput,
GitHubIssueCommentResult, GitHubIssueCommentResult,
GitHubIssueCreateInput,
GitHubIssueCreateResult,
GitHubIssueGetResult, GitHubIssueGetResult,
GitHubIssueUpdateInput, GitHubIssueUpdateInput,
GitHubIssueUpdateResult, GitHubIssueUpdateResult,
GitHubIssuesListResult, GitHubIssuesListResult,
GitHubLabelsSearchResult,
GitHubMilestonesSearchResult,
GitHubPullRequestContextResult, GitHubPullRequestContextResult,
GitHubPullRequestCommitsResult, GitHubPullRequestCommitsResult,
GitHubPullRequestTimelineResult, GitHubPullRequestTimelineResult,
@@ -27,7 +32,9 @@ import type {
GitHubReviewCommentResult, GitHubReviewCommentResult,
GitHubDeviceFlowComplete, GitHubDeviceFlowComplete,
GitHubDeviceFlowStart, GitHubDeviceFlowStart,
GitHubTagsSearchResult,
GitHubUserSummary, GitHubUserSummary,
GitHubUsersSearchResult,
} from '@openchamber/ui/lib/api/types'; } from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch'; import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
@@ -120,6 +127,76 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
return payload; return payload;
}, },
async searchUsers(directory, query, options): Promise<GitHubUsersSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/users/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubUsersSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub users');
}
return { connected: body.connected, repo: body.repo ?? null, users: body.users ?? [] };
},
async searchLabels(directory, query, options): Promise<GitHubLabelsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/labels/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubLabelsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub labels');
}
return { connected: body.connected, repo: body.repo ?? null, labels: body.labels ?? [] };
},
async searchMilestones(directory, query, options): Promise<GitHubMilestonesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/milestones/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubMilestonesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub milestones');
}
return { connected: body.connected, repo: body.repo ?? null, milestones: body.milestones ?? [] };
},
async searchBranches(directory, query, options): Promise<GitHubBranchesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/branches/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubBranchesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub branches');
}
return { connected: body.connected, repo: body.repo ?? null, branches: body.branches ?? [] };
},
async searchTags(directory, query, options): Promise<GitHubTagsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/tags/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubTagsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub tags');
}
return { connected: body.connected, repo: body.repo ?? null, tags: body.tags ?? [] };
},
async prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus> { async prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus> {
const params = new URLSearchParams({ const params = new URLSearchParams({
directory, directory,
@@ -347,6 +424,19 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
return body; return body;
}, },
async issueCreate(input: GitHubIssueCreateInput): Promise<GitHubIssueCreateResult> {
const response = await runtimeFetch('/api/github/issues/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitHubIssueCreateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to create GitHub issue');
}
return body;
},
async issueUpdate(input: GitHubIssueUpdateInput): Promise<GitHubIssueUpdateResult> { async issueUpdate(input: GitHubIssueUpdateInput): Promise<GitHubIssueUpdateResult> {
const response = await runtimeFetch('/api/github/issues/update', { const response = await runtimeFetch('/api/github/issues/update', {
method: 'PATCH', method: 'PATCH',
+94
View File
@@ -309,4 +309,98 @@ describe('createWebGitLabAPI', () => {
const api = await createAPI(); const api = await createAPI();
await expect(api.issuesList('/workspace')).rejects.toThrow('Internal Server Error'); await expect(api.issuesList('/workspace')).rejects.toThrow('Internal Server Error');
}); });
it('passes directory/query/namespace/project to searchUsers and maps the response', async () => {
const result = {
connected: true,
repo: null,
users: [{ username: 'octocat', id: 1, name: 'Octo Cat', avatarUrl: 'https://gitlab.example/octocat.png' }],
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'octo', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('omits namespace/project from searchUsers when not provided', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, repo: null, users: [] }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
const params = new URLSearchParams({ directory: '/workspace', query: 'octo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('throws the server error message when searchUsers fails', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'GitLab rate limited' }, { status: 503 }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).rejects.toThrow('GitLab rate limited');
});
it('passes directory/query to searchLabels and maps the response', async () => {
const result = { connected: true, repo: null, labels: ['bug', 'frontend'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchLabels!('/workspace', 'feat', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/labels/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchMilestones and maps the response', async () => {
const result = { connected: true, repo: null, milestones: [{ title: 'v2.0', state: 'active' }] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchMilestones!('/workspace', 'v2', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'v2', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/milestones/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchBranches and maps the response', async () => {
const result = { connected: true, repo: null, branches: ['main', 'feat/api'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchBranches!('/workspace', 'feat', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/branches/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchTags and maps the response', async () => {
const result = { connected: true, repo: null, tags: ['v1.0', 'v1.1'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchTags!('/workspace', 'v1', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'v1', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/tags/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
}); });
+80
View File
@@ -2,13 +2,17 @@ import type {
GitLabAPI, GitLabAPI,
GitLabAuthStatus, GitLabAuthStatus,
GitLabBranchesResult, GitLabBranchesResult,
GitLabBranchesSearchResult,
GitLabIssueCommentResult, GitLabIssueCommentResult,
GitLabIssueCommentsResult, GitLabIssueCommentsResult,
GitLabIssueCommentInput, GitLabIssueCommentInput,
GitLabIssueCreateInput,
GitLabIssueCreateResult,
GitLabIssueGetResult, GitLabIssueGetResult,
GitLabIssuesListResult, GitLabIssuesListResult,
GitLabIssueUpdateInput, GitLabIssueUpdateInput,
GitLabIssueUpdateResult, GitLabIssueUpdateResult,
GitLabLabelsSearchResult,
GitLabMergeRequest, GitLabMergeRequest,
GitLabMergeRequestCommitsResult, GitLabMergeRequestCommitsResult,
GitLabMergeRequestContextResult, GitLabMergeRequestContextResult,
@@ -20,11 +24,14 @@ import type {
GitLabMergeRequestTimelineResult, GitLabMergeRequestTimelineResult,
GitLabMergeRequestUpdateInput, GitLabMergeRequestUpdateInput,
GitLabMergeRequestUpdateResult, GitLabMergeRequestUpdateResult,
GitLabMilestonesSearchResult,
GitLabMrApproveInput, GitLabMrApproveInput,
GitLabMrApproveResult, GitLabMrApproveResult,
GitLabMrNoteInput, GitLabMrNoteInput,
GitLabMrNoteResult, GitLabMrNoteResult,
GitLabTagsSearchResult,
GitLabUserSummary, GitLabUserSummary,
GitLabUsersSearchResult,
} from '@openchamber/ui/lib/api/types'; } from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch'; import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
@@ -94,6 +101,66 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
return payload; return payload;
}, },
async searchUsers(directory, query, options): Promise<GitLabUsersSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/users/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabUsersSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab users');
}
return { connected: body.connected, repo: body.repo ?? null, users: body.users ?? [] };
},
async searchLabels(directory, query, options): Promise<GitLabLabelsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/labels/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabLabelsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab labels');
}
return { connected: body.connected, repo: body.repo ?? null, labels: body.labels ?? [] };
},
async searchMilestones(directory, query, options): Promise<GitLabMilestonesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/milestones/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabMilestonesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab milestones');
}
return { connected: body.connected, repo: body.repo ?? null, milestones: body.milestones ?? [] };
},
async searchBranches(directory, query, options): Promise<GitLabBranchesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/branches/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabBranchesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab branches');
}
return { connected: body.connected, repo: body.repo ?? null, branches: body.branches ?? [] };
},
async searchTags(directory, query, options): Promise<GitLabTagsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/tags/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabTagsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab tags');
}
return { connected: body.connected, repo: body.repo ?? null, tags: body.tags ?? [] };
},
async issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GitLabIssuesListResult> { async issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GitLabIssuesListResult> {
const page = options?.page ?? 1; const page = options?.page ?? 1;
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -287,6 +354,19 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
return body; return body;
}, },
async issueCreate(input: GitLabIssueCreateInput): Promise<GitLabIssueCreateResult> {
const response = await runtimeFetch('/api/gitlab/issues/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitLabIssueCreateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to create GitLab issue');
}
return body;
},
async issueUpdate(input: GitLabIssueUpdateInput): Promise<GitLabIssueUpdateResult> { async issueUpdate(input: GitLabIssueUpdateInput): Promise<GitLabIssueUpdateResult> {
const response = await runtimeFetch('/api/gitlab/issues/update', { const response = await runtimeFetch('/api/gitlab/issues/update', {
method: 'PUT', method: 'PUT',