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 { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
|
||||
import type { ForgeComment } from '@/lib/forge/types';
|
||||
import { ForgeMentionTextarea } from './ForgeMentionTextarea';
|
||||
|
||||
interface ForgeCommentComposerProps {
|
||||
provider: ForgeProvider;
|
||||
@@ -47,12 +47,14 @@ export const ForgeCommentComposer: React.FC<ForgeCommentComposerProps> = ({ prov
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Textarea
|
||||
<ForgeMentionTextarea
|
||||
provider={provider}
|
||||
directory={directory}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
onChange={setBody}
|
||||
placeholder={t('forge.actions.commentPlaceholder')}
|
||||
disabled={submitting}
|
||||
aria-label={t('forge.actions.commentPlaceholder')}
|
||||
ariaLabel={t('forge.actions.commentPlaceholder')}
|
||||
className="min-h-[72px]"
|
||||
/>
|
||||
<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 { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { I18nKey } from '@/lib/i18n';
|
||||
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
|
||||
import type { ForgeLabel, ForgeMilestone, ForgeUser } from '@/lib/forge/types';
|
||||
import { ForgeLookupCombobox } from './ForgeLookupCombobox';
|
||||
import type { ForgeLookupOption } from './useForgeLookup';
|
||||
|
||||
interface ForgeMetadataEditorProps {
|
||||
provider: ForgeProvider;
|
||||
@@ -88,6 +89,11 @@ export const ForgeMetadataEditor: React.FC<ForgeMetadataEditorProps> = ({
|
||||
setLabelInput('');
|
||||
};
|
||||
|
||||
const addLabelOption = async (option: ForgeLookupOption): Promise<void> => {
|
||||
setLabelInput(option.label);
|
||||
await addLabel();
|
||||
};
|
||||
|
||||
const removeLabel = async (name: string): Promise<void> => {
|
||||
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('');
|
||||
};
|
||||
|
||||
const addAssigneeOption = async (option: ForgeLookupOption): Promise<void> => {
|
||||
setAssigneeInput(option.label);
|
||||
await addAssignee();
|
||||
};
|
||||
|
||||
const removeAssignee = async (id: string): Promise<void> => {
|
||||
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('');
|
||||
};
|
||||
|
||||
const addMilestoneOption = async (option: ForgeLookupOption): Promise<void> => {
|
||||
setMilestoneInput(option.label);
|
||||
await addMilestone();
|
||||
};
|
||||
|
||||
const removeMilestone = async (): Promise<void> => {
|
||||
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">
|
||||
{canLabels ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Input
|
||||
<ForgeLookupCombobox
|
||||
provider={provider}
|
||||
directory={directory}
|
||||
kind="labels"
|
||||
value={labelInput}
|
||||
onChange={(event) => setLabelInput(event.target.value)}
|
||||
onChange={setLabelInput}
|
||||
onSelect={(option) => void addLabelOption(option)}
|
||||
placeholder={t('forge.actions.addLabel')}
|
||||
aria-label={t('forge.actions.addLabel')}
|
||||
className="h-6 w-36"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void addLabel();
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" size="xs" onClick={() => void addLabel()} disabled={submitting || !labelInput.trim()}>
|
||||
{t('forge.actions.addLabel')}
|
||||
@@ -206,15 +223,16 @@ export const ForgeMetadataEditor: React.FC<ForgeMetadataEditorProps> = ({
|
||||
|
||||
{canAssignees ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Input
|
||||
<ForgeLookupCombobox
|
||||
provider={provider}
|
||||
directory={directory}
|
||||
kind="users"
|
||||
value={assigneeInput}
|
||||
onChange={(event) => setAssigneeInput(event.target.value)}
|
||||
onChange={setAssigneeInput}
|
||||
onSelect={(option) => void addAssigneeOption(option)}
|
||||
placeholder={t('forge.actions.addAssignee')}
|
||||
aria-label={t('forge.actions.addAssignee')}
|
||||
className="h-6 w-36"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void addAssignee();
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" size="xs" onClick={() => void addAssignee()} disabled={submitting || !assigneeInput.trim()}>
|
||||
{t('forge.actions.addAssignee')}
|
||||
@@ -224,15 +242,16 @@ export const ForgeMetadataEditor: React.FC<ForgeMetadataEditorProps> = ({
|
||||
|
||||
{canMilestones ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Input
|
||||
<ForgeLookupCombobox
|
||||
provider={provider}
|
||||
directory={directory}
|
||||
kind="milestones"
|
||||
value={milestoneInput}
|
||||
onChange={(event) => setMilestoneInput(event.target.value)}
|
||||
onChange={setMilestoneInput}
|
||||
onSelect={(option) => void addMilestoneOption(option)}
|
||||
placeholder={t('forge.actions.setMilestone')}
|
||||
aria-label={t('forge.actions.setMilestone')}
|
||||
className="h-6 w-36"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void addMilestone();
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" size="xs" onClick={() => void addMilestone()} disabled={submitting || !milestoneInput.trim()}>
|
||||
{t('forge.actions.setMilestone')}
|
||||
|
||||
@@ -2,11 +2,11 @@ import React, { useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { I18nKey } from '@/lib/i18n';
|
||||
import type { ForgeEntityRef, ForgeProvider, ForgeReviewEvent } from '@/lib/forge/provider';
|
||||
import { ForgeMentionTextarea } from './ForgeMentionTextarea';
|
||||
|
||||
interface ForgeReviewActionsProps {
|
||||
provider: ForgeProvider;
|
||||
@@ -94,12 +94,14 @@ export const ForgeReviewActions: React.FC<ForgeReviewActionsProps> = ({ provider
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('forge.actions.reviewDialogTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
<ForgeMentionTextarea
|
||||
provider={provider}
|
||||
directory={directory}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
onChange={setBody}
|
||||
placeholder={t('forge.actions.reviewBodyPlaceholder')}
|
||||
disabled={submitting}
|
||||
aria-label={t('forge.actions.reviewBodyPlaceholder')}
|
||||
ariaLabel={t('forge.actions.reviewBodyPlaceholder')}
|
||||
className="min-h-[96px]"
|
||||
/>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
|
||||
import type { ForgeComment } from '@/lib/forge/types';
|
||||
import { ForgeMentionTextarea } from './ForgeMentionTextarea';
|
||||
|
||||
/** Anchor of the thread being replied to (see `ForgeComment.inReplyToId`/`path`/`line`). */
|
||||
export interface ForgeThreadTarget {
|
||||
@@ -62,12 +62,14 @@ export const ForgeThreadReply: React.FC<ForgeThreadReplyProps> = ({ provider, di
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Textarea
|
||||
<ForgeMentionTextarea
|
||||
provider={provider}
|
||||
directory={directory}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
onChange={setBody}
|
||||
placeholder={t('forge.actions.commentPlaceholder')}
|
||||
disabled={submitting}
|
||||
aria-label={t('forge.actions.commentPlaceholder')}
|
||||
ariaLabel={t('forge.actions.commentPlaceholder')}
|
||||
className="min-h-[56px]"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -16,3 +16,4 @@ export { ForgeDraftToggle } from './ForgeDraftToggle';
|
||||
export { ForgeMetadataEditor } from './ForgeMetadataEditor';
|
||||
export { ForgeEditForm } from './ForgeEditForm';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { ForgeEntityDetailView } from '@/components/views/forge';
|
||||
import { ForgeCreateIssueDialog } from '@/components/views/forge/actions';
|
||||
import { buildForgeProvider } from '@/lib/forge';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import type { GitHubIssueSummary, GitHubRepoSelector } from '@/lib/api/types';
|
||||
import type { ForgeIssue } from '@/lib/forge';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const issueLabelBadgeClass =
|
||||
@@ -46,6 +48,8 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory
|
||||
>(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 retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
|
||||
@@ -55,6 +59,16 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
const handleIssueCreated = React.useCallback(
|
||||
(issue: ForgeIssue) => {
|
||||
// Open the freshly created issue's detail and refresh the list behind it.
|
||||
setSelectedNumber(issue.number);
|
||||
setSelectedUrl(issue.url ?? null);
|
||||
setRetryToken((value) => value + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// A different repository invalidates the previously loaded list and detail so
|
||||
// a stale repository's issues never leak into the new one.
|
||||
React.useEffect(() => {
|
||||
@@ -180,8 +194,14 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory
|
||||
|
||||
return (
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{!github?.issuesList ? (
|
||||
@@ -255,6 +275,16 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issueProvider?.createIssue ? (
|
||||
<ForgeCreateIssueDialog
|
||||
provider={issueProvider}
|
||||
directory={directory}
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={handleIssueCreated}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,10 +2,12 @@ import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ForgeEntityDetailView } from '@/components/views/forge';
|
||||
import { ForgeCreateIssueDialog } from '@/components/views/forge/actions';
|
||||
import { buildForgeProvider } from '@/lib/forge';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import type { GitLabIssueSummary } from '@/lib/api/types';
|
||||
import type { ForgeIssue } from '@/lib/forge';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const issueLabelBadgeClass =
|
||||
@@ -41,6 +43,8 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory
|
||||
|
||||
const issueProvider = React.useMemo(() => (gitlab ? buildForgeProvider('gitlab', { gitlab }) : null), [gitlab]);
|
||||
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
|
||||
const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
|
||||
|
||||
const openGitLabSettings = React.useCallback(() => {
|
||||
@@ -48,6 +52,12 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
const handleIssueCreated = React.useCallback((issue: ForgeIssue) => {
|
||||
setSelectedNumber(issue.number);
|
||||
setSelectedUrl(issue.url ?? null);
|
||||
setRetryToken((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
// A different repository invalidates the previously loaded list and detail so
|
||||
// a stale repository's issues never leak into the new one.
|
||||
React.useEffect(() => {
|
||||
@@ -163,8 +173,14 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory
|
||||
|
||||
return (
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{!gitlab?.issuesList ? (
|
||||
@@ -238,6 +254,16 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issueProvider?.createIssue ? (
|
||||
<ForgeCreateIssueDialog
|
||||
provider={issueProvider}
|
||||
directory={directory}
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={handleIssueCreated}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,11 +2,13 @@ import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ForgeEntityDetailView } from '@/components/views/forge';
|
||||
import { ForgeCreateIssueDialog } from '@/components/views/forge/actions';
|
||||
import { buildForgeProvider } from '@/lib/forge';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
|
||||
import type { GiteaIssueSummary } from '@/lib/api/types';
|
||||
import type { ForgeIssue } from '@/lib/forge';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const issueLabelBadgeClass =
|
||||
@@ -44,6 +46,8 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory
|
||||
|
||||
const issueProvider = React.useMemo(() => (gitea ? buildForgeProvider('gitea', { gitea }) : null), [gitea]);
|
||||
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
|
||||
const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
|
||||
|
||||
const openGiteaSettings = React.useCallback(() => {
|
||||
@@ -51,6 +55,12 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
const handleIssueCreated = React.useCallback((issue: ForgeIssue) => {
|
||||
setSelectedNumber(issue.number);
|
||||
setSelectedUrl(issue.url ?? null);
|
||||
setRetryToken((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
// The parent PR view already gates on connection, but the auth store is the
|
||||
// authoritative signal when the list API reports connected without having
|
||||
// checked the account yet.
|
||||
@@ -171,8 +181,14 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory
|
||||
|
||||
return (
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{!gitea?.issuesList ? (
|
||||
@@ -246,6 +262,16 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issueProvider?.createIssue ? (
|
||||
<ForgeCreateIssueDialog
|
||||
provider={issueProvider}
|
||||
directory={directory}
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={handleIssueCreated}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -817,6 +817,42 @@ export type GitHubUserSummary = {
|
||||
email?: string;
|
||||
};
|
||||
|
||||
// ---- Rich lookup results (repo-scoped search for pickers/mentions) ----
|
||||
// Each result carries the connected repo so the facade can surface cross-repo /
|
||||
// fork contexts, and the items are always arrays (empty on success with no
|
||||
// matches). `connected: false` means the lookup could not be performed and
|
||||
// must not be treated as an authoritative empty list.
|
||||
|
||||
export type GitHubUsersSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
users: GitHubUserSummary[];
|
||||
};
|
||||
|
||||
export type GitHubLabelsSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
labels: GitHubIssueLabel[];
|
||||
};
|
||||
|
||||
export type GitHubMilestonesSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
milestones: Array<{ title: string; state?: string }>;
|
||||
};
|
||||
|
||||
export type GitHubBranchesSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
branches: string[];
|
||||
};
|
||||
|
||||
export type GitHubTagsSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type GitHubRepoRef = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
@@ -1141,6 +1177,21 @@ export type GitHubIssueCommentResult = {
|
||||
comment?: GitHubIssueComment | null;
|
||||
};
|
||||
|
||||
export type GitHubIssueCreateInput = {
|
||||
directory: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
labels?: string[];
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
};
|
||||
|
||||
export type GitHubIssueCreateResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
issue?: GitHubIssue | null;
|
||||
};
|
||||
|
||||
export type GitHubIssueUpdateInput = {
|
||||
directory: string;
|
||||
number: number;
|
||||
@@ -1236,6 +1287,12 @@ export interface GitHubAPI {
|
||||
authSetGhCliDisabled(disabled: boolean): Promise<{ disabled: boolean }>;
|
||||
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>;
|
||||
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
|
||||
prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest>;
|
||||
@@ -1257,6 +1314,7 @@ export interface GitHubAPI {
|
||||
repoUpstream(directory: string): Promise<GitHubRepoUpstreamResult>;
|
||||
repoBranches(owner: string, repo: string): Promise<string[]>;
|
||||
issueComment?(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult>;
|
||||
issueCreate?(input: GitHubIssueCreateInput): Promise<GitHubIssueCreateResult>;
|
||||
issueUpdate?(input: GitHubIssueUpdateInput): Promise<GitHubIssueUpdateResult>;
|
||||
prComment?(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult>;
|
||||
prReviewComment?(input: GitHubReviewCommentInput): Promise<GitHubReviewCommentResult>;
|
||||
@@ -1392,6 +1450,40 @@ export type GitLabBranchesResult = {
|
||||
defaultBranch?: string | null;
|
||||
};
|
||||
|
||||
// ---- Rich lookup results (repo-scoped search for pickers/mentions) ----
|
||||
// `connected: false` means the lookup could not be performed and must not be
|
||||
// treated as an authoritative empty list.
|
||||
|
||||
export type GitLabUsersSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
users: GitLabUserSummary[];
|
||||
};
|
||||
|
||||
export type GitLabLabelsSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
labels: string[];
|
||||
};
|
||||
|
||||
export type GitLabMilestonesSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
milestones: Array<{ title: string; state?: string }>;
|
||||
};
|
||||
|
||||
export type GitLabBranchesSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
branches: string[];
|
||||
};
|
||||
|
||||
export type GitLabTagsSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type GitLabMergeRequestCommit = {
|
||||
sha: string;
|
||||
shortSha: string;
|
||||
@@ -1438,6 +1530,8 @@ export type GitLabMergeRequestUpdateInput = {
|
||||
description?: string;
|
||||
state?: 'open' | 'closed';
|
||||
labels?: string[];
|
||||
/** Assignee logins; the server resolves them to user IDs via project members. */
|
||||
assignees?: string[];
|
||||
assigneeIds?: number[];
|
||||
milestone?: string | null;
|
||||
};
|
||||
@@ -1480,6 +1574,21 @@ export type GitLabIssueCommentResult = {
|
||||
comment?: GitLabIssueComment | null;
|
||||
};
|
||||
|
||||
export type GitLabIssueCreateInput = {
|
||||
directory: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
labels?: string[];
|
||||
namespace?: string;
|
||||
project?: string;
|
||||
};
|
||||
|
||||
export type GitLabIssueCreateResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
issue?: GitLabIssue | null;
|
||||
};
|
||||
|
||||
export type GitLabIssueUpdateInput = {
|
||||
directory: string;
|
||||
number: number;
|
||||
@@ -1487,6 +1596,8 @@ export type GitLabIssueUpdateInput = {
|
||||
body?: string;
|
||||
state?: 'open' | 'closed';
|
||||
labels?: string[];
|
||||
/** Assignee logins; the server resolves them to user IDs via project members. */
|
||||
assignees?: string[];
|
||||
assigneeIds?: number[];
|
||||
milestone?: string | null;
|
||||
namespace?: string;
|
||||
@@ -1569,10 +1680,17 @@ export interface GitLabAPI {
|
||||
mrTimeline?(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabMergeRequestTimelineResult>;
|
||||
|
||||
issueComment?(input: GitLabIssueCommentInput): Promise<GitLabIssueCommentResult>;
|
||||
issueCreate?(input: GitLabIssueCreateInput): Promise<GitLabIssueCreateResult>;
|
||||
issueUpdate?(input: GitLabIssueUpdateInput): Promise<GitLabIssueUpdateResult>;
|
||||
mrComment?(input: GitLabMrNoteInput): Promise<GitLabMrNoteResult>;
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -1705,6 +1823,21 @@ export type GiteaIssueCommentResult = {
|
||||
comment?: GiteaComment | null;
|
||||
};
|
||||
|
||||
export type GiteaIssueCreateInput = {
|
||||
directory: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
labels?: string[];
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
};
|
||||
|
||||
export type GiteaIssueCreateResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
issue?: GiteaIssue | null;
|
||||
};
|
||||
|
||||
export type GiteaIssueUpdateInput = {
|
||||
directory: string;
|
||||
number: number;
|
||||
@@ -1752,6 +1885,40 @@ export type GiteaRepoLabelsResult = {
|
||||
labels: GiteaRepoLabel[];
|
||||
};
|
||||
|
||||
// ---- Rich lookup results (repo-scoped search for pickers/mentions) ----
|
||||
// `connected: false` means the lookup could not be performed and must not be
|
||||
// treated as an authoritative empty list.
|
||||
|
||||
export type GiteaUsersSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
users: GiteaUserSummary[];
|
||||
};
|
||||
|
||||
export type GiteaLabelsSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
labels: GiteaRepoLabel[];
|
||||
};
|
||||
|
||||
export type GiteaMilestonesSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
milestones: Array<{ title: string; state?: string }>;
|
||||
};
|
||||
|
||||
export type GiteaBranchesSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
branches: string[];
|
||||
};
|
||||
|
||||
export type GiteaTagsSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export interface GiteaAPI {
|
||||
authStatus(): Promise<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>;
|
||||
|
||||
issueComment?(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult>;
|
||||
issueCreate?(input: GiteaIssueCreateInput): Promise<GiteaIssueCreateResult>;
|
||||
issueUpdate?(input: GiteaIssueUpdateInput): Promise<GiteaIssueUpdateResult>;
|
||||
prComment?(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult>;
|
||||
prSubmitReview?(input: GiteaPullReviewInput): Promise<GiteaPullReviewResult>;
|
||||
repoLabels?(directory: string, options?: { owner?: string; repo?: string }): Promise<GiteaRepoLabelsResult>;
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,12 +35,14 @@ import {
|
||||
mapGiteaCommits,
|
||||
mapGiteaComment,
|
||||
mapGiteaContext,
|
||||
mapGiteaAssignee,
|
||||
mapGiteaIssue,
|
||||
mapGiteaPr,
|
||||
mapGiteaRepoRef,
|
||||
mapGiteaReviewsToEvents,
|
||||
mapGiteaReview,
|
||||
mapGiteaStatuses,
|
||||
mapGithubAssignee,
|
||||
mapGithubCommits,
|
||||
mapGithubContext,
|
||||
mapGithubIssue,
|
||||
@@ -53,6 +55,7 @@ import {
|
||||
mapGitlabCommits,
|
||||
mapGitlabContext,
|
||||
mapGitlabIssue,
|
||||
mapGitlabMember,
|
||||
mapGitlabMr,
|
||||
mapGitlabNoteComment,
|
||||
mapGitlabRepoRef,
|
||||
@@ -69,6 +72,11 @@ const GITHUB_CAPABILITIES: ForgeProviderCapabilities = {
|
||||
timelineEvents: true,
|
||||
inlineComments: true,
|
||||
threads: true,
|
||||
userSearch: true,
|
||||
labelSearch: true,
|
||||
milestoneSearch: true,
|
||||
branchSearch: true,
|
||||
tagSearch: true,
|
||||
};
|
||||
|
||||
const GITLAB_CAPABILITIES: ForgeProviderCapabilities = {
|
||||
@@ -81,6 +89,11 @@ const GITLAB_CAPABILITIES: ForgeProviderCapabilities = {
|
||||
timelineEvents: true,
|
||||
inlineComments: false,
|
||||
threads: true,
|
||||
userSearch: true,
|
||||
labelSearch: true,
|
||||
milestoneSearch: true,
|
||||
branchSearch: true,
|
||||
tagSearch: true,
|
||||
};
|
||||
|
||||
const GITEA_CAPABILITIES: ForgeProviderCapabilities = {
|
||||
@@ -93,6 +106,11 @@ const GITEA_CAPABILITIES: ForgeProviderCapabilities = {
|
||||
timelineEvents: true,
|
||||
inlineComments: true,
|
||||
threads: true,
|
||||
userSearch: true,
|
||||
labelSearch: true,
|
||||
milestoneSearch: true,
|
||||
branchSearch: true,
|
||||
tagSearch: true,
|
||||
};
|
||||
|
||||
// Gitea's 'commit-statuses' checks and inline comments land once Slice B adds
|
||||
@@ -341,6 +359,79 @@ export const createGithubForgeProvider = (api: GitHubAPI): ForgeProvider => ({
|
||||
return null;
|
||||
},
|
||||
|
||||
async searchUsers(directory, query, options) {
|
||||
if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const result = await api.searchUsers(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
|
||||
users: (result.users ?? []).map(mapGithubAssignee),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, users: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchLabels(directory, query, options) {
|
||||
if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const result = await api.searchLabels(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
|
||||
labels: (result.labels ?? []).map((label) => ({ name: label.name, color: label.color })),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchMilestones(directory, query, options) {
|
||||
if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const result = await api.searchMilestones(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
|
||||
milestones: (result.milestones ?? []).map((milestone) => ({
|
||||
title: milestone.title,
|
||||
...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchBranches(directory, query, options) {
|
||||
if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const result = await api.searchBranches(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
|
||||
branches: result.branches ?? [],
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchTags(directory, query, options) {
|
||||
if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const result = await api.searchTags(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
|
||||
tags: result.tags ?? [],
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async addComment(directory, ref, input, options) {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const owner = selector?.owner;
|
||||
@@ -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) {
|
||||
if (ref.kind !== 'pull') {
|
||||
// Issues have no inline review comments; reply as a flat thread comment.
|
||||
@@ -651,6 +761,84 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
|
||||
return null;
|
||||
},
|
||||
|
||||
async searchUsers(directory, query, options) {
|
||||
if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
const result = await api.searchUsers(directory, query, { namespace, project });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
|
||||
users: (result.users ?? []).map(mapGitlabMember),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, users: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchLabels(directory, query, options) {
|
||||
if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
const result = await api.searchLabels(directory, query, { namespace, project });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
|
||||
labels: (result.labels ?? []).map((name) => ({ name })),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchMilestones(directory, query, options) {
|
||||
if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
const result = await api.searchMilestones(directory, query, { namespace, project });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
|
||||
milestones: (result.milestones ?? []).map((milestone) => ({
|
||||
title: milestone.title,
|
||||
...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchBranches(directory, query, options) {
|
||||
if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
const result = await api.searchBranches(directory, query, { namespace, project });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
|
||||
branches: result.branches ?? [],
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchTags(directory, query, options) {
|
||||
if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
const result = await api.searchTags(directory, query, { namespace, project });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
|
||||
tags: result.tags ?? [],
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async addComment(directory, ref, input, options) {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
if (ref.kind === 'issue') {
|
||||
@@ -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) {
|
||||
// GitLab's note-reply API is not wired up yet; reply as a flat comment.
|
||||
return this.addComment!(directory, ref, { body: input.body }, options);
|
||||
@@ -754,12 +961,13 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
|
||||
if (ref.kind === 'issue') {
|
||||
if (!api.issueUpdate) return { ok: false, error: WRITE_ERROR };
|
||||
try {
|
||||
// GitLab assigns by user ID, not login; the facade takes logins, so
|
||||
// assignees are left unset until an id lookup exists.
|
||||
// GitLab assigns by user ID; the server resolves the facade's login
|
||||
// list to IDs via project members (see gitlab routes resolveAssigneeIds).
|
||||
const result = await api.issueUpdate({
|
||||
directory,
|
||||
number: ref.number,
|
||||
labels: input.labels,
|
||||
assignees: input.assignees,
|
||||
milestone: input.milestone,
|
||||
namespace,
|
||||
project,
|
||||
@@ -776,6 +984,7 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
|
||||
directory,
|
||||
number: ref.number,
|
||||
labels: input.labels,
|
||||
assignees: input.assignees,
|
||||
milestone: input.milestone,
|
||||
});
|
||||
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) {
|
||||
if (!api.prStatuses) return EMPTY_CHECKS;
|
||||
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) {
|
||||
// Gitea's thread-reply API is not wired up yet; reply as a flat comment.
|
||||
return this.addComment!(directory, ref, { body: input.body }, options);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
aggregateStatusState,
|
||||
firstLine,
|
||||
mapCheckRunState,
|
||||
mapGiteaAssignee,
|
||||
mapGiteaCommits,
|
||||
mapGiteaComment,
|
||||
mapGiteaContext,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
mapGiteaReviewsToEvents,
|
||||
mapGiteaReview,
|
||||
mapGiteaStatuses,
|
||||
mapGithubAssignee,
|
||||
mapGithubCheckSummary,
|
||||
mapGithubCommits,
|
||||
mapGithubContext,
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
mapGitlabCommits,
|
||||
mapGitlabContext,
|
||||
mapGitlabIssue,
|
||||
mapGitlabMember,
|
||||
mapGitlabMr,
|
||||
mapGitlabNoteComment,
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1126,7 +1160,7 @@ describe('write operations: updateMetadata', () => {
|
||||
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> = {};
|
||||
const api = {
|
||||
issueUpdate: async (input: Record<string, unknown>) => {
|
||||
@@ -1142,7 +1176,7 @@ describe('write operations: updateMetadata', () => {
|
||||
expect(result.ok).toBe(true);
|
||||
expect(issueArgs.labels).toEqual(['frontend']);
|
||||
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 () => {
|
||||
@@ -1246,6 +1280,11 @@ describe('buildForgeProvider', () => {
|
||||
timelineEvents: true,
|
||||
inlineComments: true,
|
||||
threads: true,
|
||||
userSearch: true,
|
||||
labelSearch: true,
|
||||
milestoneSearch: true,
|
||||
branchSearch: true,
|
||||
tagSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1262,6 +1301,11 @@ describe('buildForgeProvider', () => {
|
||||
timelineEvents: true,
|
||||
inlineComments: false,
|
||||
threads: true,
|
||||
userSearch: true,
|
||||
labelSearch: true,
|
||||
milestoneSearch: true,
|
||||
branchSearch: true,
|
||||
tagSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1278,6 +1322,11 @@ describe('buildForgeProvider', () => {
|
||||
timelineEvents: true,
|
||||
inlineComments: true,
|
||||
threads: true,
|
||||
userSearch: true,
|
||||
labelSearch: true,
|
||||
milestoneSearch: true,
|
||||
branchSearch: true,
|
||||
tagSearch: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1577,3 +1626,310 @@ describe('getForgeProviderForDirectory', () => {
|
||||
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,
|
||||
ForgeTimelineResult,
|
||||
ForgeChecksResult,
|
||||
ForgeUsersResult,
|
||||
ForgeLabelsResult,
|
||||
ForgeMilestonesResult,
|
||||
ForgeBranchesResult,
|
||||
ForgeTagsResult,
|
||||
ForgeProvider,
|
||||
} from './provider';
|
||||
|
||||
@@ -50,6 +55,7 @@ export {
|
||||
firstLine,
|
||||
normalizeEventType,
|
||||
mapGithubUser,
|
||||
mapGithubAssignee,
|
||||
mapGithubPr,
|
||||
mapGithubIssue,
|
||||
mapGithubIssueComment,
|
||||
@@ -60,6 +66,7 @@ export {
|
||||
mapGithubCommits,
|
||||
mapGithubTimelineEvents,
|
||||
mapGitlabUser,
|
||||
mapGitlabMember,
|
||||
mapGitlabMr,
|
||||
mapGitlabIssue,
|
||||
mapGitlabNoteComment,
|
||||
@@ -68,6 +75,7 @@ export {
|
||||
mapGitlabCommits,
|
||||
mapGitlabTimelineEvents,
|
||||
mapGiteaUser,
|
||||
mapGiteaAssignee,
|
||||
mapGiteaPr,
|
||||
mapGiteaIssue,
|
||||
mapGiteaComment,
|
||||
|
||||
@@ -117,6 +117,36 @@ export const mapGiteaUser = (user: GiteaUserSummary): ForgeUser => ({
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,12 +4,15 @@ import type {
|
||||
ForgeCommit,
|
||||
ForgeFileChange,
|
||||
ForgeIssue,
|
||||
ForgeLabel,
|
||||
ForgeMilestone,
|
||||
ForgeProviderCapabilities,
|
||||
ForgeProviderKind,
|
||||
ForgePullRequest,
|
||||
ForgeRepoRef,
|
||||
ForgeReview,
|
||||
ForgeTimelineEvent,
|
||||
ForgeUser,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -95,6 +98,51 @@ export interface ForgeTimelineResult {
|
||||
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. */
|
||||
export interface ForgeChecksResult {
|
||||
connected: boolean;
|
||||
@@ -135,6 +183,20 @@ export interface ForgeCommentResult {
|
||||
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`. */
|
||||
export interface ForgeUpdateResult {
|
||||
ok: boolean;
|
||||
@@ -230,6 +292,43 @@ export interface ForgeProvider {
|
||||
*/
|
||||
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 ---
|
||||
//
|
||||
// Every write method is optional and capability-flagged: the UI gates on
|
||||
@@ -252,6 +351,17 @@ export interface ForgeProvider {
|
||||
options?: { sourceRepo?: string | null },
|
||||
): 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
|
||||
* review-comment reply via `prReviewComment` (anchored on `inReplyToId`);
|
||||
|
||||
@@ -45,6 +45,16 @@ export interface ForgeProviderCapabilities {
|
||||
inlineComments: boolean;
|
||||
/** Comment threads that can be replied to. */
|
||||
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, ...). */
|
||||
|
||||
@@ -1290,6 +1290,13 @@ export const dict = {
|
||||
'forge.actions.closeConfirm': 'Diesen Vorgang schließen?',
|
||||
'forge.actions.comment': 'Kommentieren',
|
||||
'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.edit': 'Bearbeiten',
|
||||
'forge.actions.error': 'Aktion fehlgeschlagen',
|
||||
@@ -1330,6 +1337,8 @@ export const dict = {
|
||||
'forge.files.empty': 'Keine Dateien geändert',
|
||||
'forge.files.noDiff': 'Kein Diff verfügbar',
|
||||
'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.open': 'Sitzung „{title}“ öffnen',
|
||||
'forge.linkedSessions.title': 'Chats, die daran arbeiten',
|
||||
|
||||
@@ -1542,12 +1542,19 @@ export const dict = {
|
||||
'forge.actions.closeConfirm': 'Close this issue/PR?',
|
||||
'forge.actions.comment': 'Comment',
|
||||
'forge.actions.commentPlaceholder': 'Add a comment…',
|
||||
'forge.actions.createIssue': 'Create issue',
|
||||
'forge.actions.draftChanged': 'Draft status updated',
|
||||
'forge.actions.edit': 'Edit',
|
||||
'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.markReady': 'Mark ready',
|
||||
'forge.actions.metadataChanged': 'Metadata updated',
|
||||
'forge.actions.newIssue': 'New issue',
|
||||
'forge.actions.posting': 'Posting…',
|
||||
'forge.actions.remove': 'Remove',
|
||||
'forge.actions.removed': 'Removed',
|
||||
@@ -1582,6 +1589,8 @@ export const dict = {
|
||||
'forge.files.empty': 'No files changed',
|
||||
'forge.files.noDiff': 'No diff available',
|
||||
'forge.loading': 'Loading...',
|
||||
'forge.lookup.empty': 'No matches',
|
||||
'forge.lookup.loading': 'Searching…',
|
||||
'forge.linkedSessions.count': 'Sessions linked to this entity: {count}',
|
||||
'forge.linkedSessions.open': 'Open session "{title}"',
|
||||
'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.comment': 'Comentar',
|
||||
'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.edit': 'Editar',
|
||||
'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.noDiff': 'No hay diff disponible',
|
||||
'forge.loading': 'Cargando...',
|
||||
'forge.lookup.empty': 'Sin coincidencias',
|
||||
'forge.lookup.loading': 'Buscando…',
|
||||
'forge.linkedSessions.count': 'Sesiones vinculadas a este elemento: {count}',
|
||||
'forge.linkedSessions.open': 'Abrir sesión «{title}»',
|
||||
'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.comment': 'Commenter',
|
||||
'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.edit': 'Modifier',
|
||||
'forge.actions.error': 'Échec de l’action',
|
||||
@@ -3255,6 +3262,8 @@ export const dict = {
|
||||
'forge.files.empty': 'Aucun fichier modifié',
|
||||
'forge.files.noDiff': 'Aucun diff disponible',
|
||||
'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.open': 'Ouvrir la conversation « {title} »',
|
||||
'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.comment': 'コメント',
|
||||
'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.edit': '編集',
|
||||
'forge.actions.error': '操作に失敗しました',
|
||||
@@ -1579,6 +1586,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': '変更されたファイルはありません',
|
||||
'forge.files.noDiff': '差分はありません',
|
||||
'forge.loading': '読み込み中...',
|
||||
'forge.lookup.empty': '一致する項目がありません',
|
||||
'forge.lookup.loading': '検索中…',
|
||||
'forge.linkedSessions.count': 'この項目にリンクされているセッション: {count}',
|
||||
'forge.linkedSessions.open': 'セッション「{title}」を開く',
|
||||
'forge.linkedSessions.title': 'この項目に取り組んでいるチャット',
|
||||
|
||||
@@ -1545,6 +1545,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': '이 이슈/PR을 닫을까요?',
|
||||
'forge.actions.comment': '댓글',
|
||||
'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.edit': '편집',
|
||||
'forge.actions.error': '작업 실패',
|
||||
@@ -1585,6 +1592,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': '변경된 파일이 없습니다',
|
||||
'forge.files.noDiff': 'diff를 사용할 수 없습니다',
|
||||
'forge.loading': '불러오는 중...',
|
||||
'forge.lookup.empty': '일치하는 항목이 없습니다',
|
||||
'forge.lookup.loading': '검색 중…',
|
||||
'forge.linkedSessions.count': '이 항목에 연결된 세션: {count}',
|
||||
'forge.linkedSessions.open': '세션 "{title}" 열기',
|
||||
'forge.linkedSessions.title': '이 항목에서 작업 중인 채팅',
|
||||
|
||||
@@ -2026,6 +2026,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': 'Zamknąć to zgłoszenie/PR?',
|
||||
'forge.actions.comment': 'Skomentuj',
|
||||
'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.edit': 'Edytuj',
|
||||
'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.noDiff': 'Brak dostępnego diff',
|
||||
'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.open': 'Otwórz sesję „{title}“',
|
||||
'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.comment': 'Comentar',
|
||||
'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.edit': 'Editar',
|
||||
'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.noDiff': 'Nenhum diff disponível',
|
||||
'forge.loading': 'Carregando...',
|
||||
'forge.lookup.empty': 'Nenhum resultado',
|
||||
'forge.lookup.loading': 'Buscando…',
|
||||
'forge.linkedSessions.count': 'Sessões vinculadas a este item: {count}',
|
||||
'forge.linkedSessions.open': 'Abrir sessão "{title}"',
|
||||
'forge.linkedSessions.title': 'Chats trabalhando nisso',
|
||||
|
||||
@@ -1509,6 +1509,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': 'Закрити цей issue/PR?',
|
||||
'forge.actions.comment': 'Прокоментувати',
|
||||
'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.edit': 'Редагувати',
|
||||
'forge.actions.error': 'Дія не вдалася',
|
||||
@@ -1549,6 +1556,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': 'Змінених файлів немає',
|
||||
'forge.files.noDiff': 'Diff недоступний',
|
||||
'forge.loading': 'Завантаження...',
|
||||
'forge.lookup.empty': 'Немає збігів',
|
||||
'forge.lookup.loading': 'Пошук…',
|
||||
'forge.linkedSessions.count': 'Сеанси, пов\'язані з цим елементом: {count}',
|
||||
'forge.linkedSessions.open': 'Відкрити сеанс «{title}»',
|
||||
'forge.linkedSessions.title': 'Чати, що працюють над цим',
|
||||
|
||||
@@ -1509,6 +1509,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': '关闭此问题/PR?',
|
||||
'forge.actions.comment': '评论',
|
||||
'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.edit': '编辑',
|
||||
'forge.actions.error': '操作失败',
|
||||
@@ -1549,6 +1556,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': '无文件更改',
|
||||
'forge.files.noDiff': '无可用差异',
|
||||
'forge.loading': '加载中...',
|
||||
'forge.lookup.empty': '无匹配项',
|
||||
'forge.lookup.loading': '搜索中…',
|
||||
'forge.linkedSessions.count': '与此事项关联的会话:{count}',
|
||||
'forge.linkedSessions.open': '打开会话「{title}」',
|
||||
'forge.linkedSessions.title': '正在处理此事项的聊天',
|
||||
|
||||
@@ -1519,6 +1519,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': '關閉此 Issue/PR?',
|
||||
'forge.actions.comment': '留言',
|
||||
'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.edit': '編輯',
|
||||
'forge.actions.error': '操作失敗',
|
||||
@@ -1559,6 +1566,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': '沒有檔案變更',
|
||||
'forge.files.noDiff': '沒有可用的差異',
|
||||
'forge.loading': '載入中...',
|
||||
'forge.lookup.empty': '無相符項目',
|
||||
'forge.lookup.loading': '搜尋中…',
|
||||
'forge.linkedSessions.count': '與此項目關聯的會話:{count}',
|
||||
'forge.linkedSessions.open': '開啟會話「{title}」',
|
||||
'forge.linkedSessions.title': '正在處理此項目的聊天',
|
||||
|
||||
Reference in New Issue
Block a user