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:
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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, ...). */
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': 'この項目に取り組んでいるチャット',
|
||||||
|
|||||||
@@ -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': '이 항목에서 작업 중인 채팅',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 }` |
|
||||||
|
|||||||
@@ -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 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user