feat(i18n): localize Settings → Integrations → GitLab (#2160)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,12 @@ import { useSettingsSaveContributor } from "@/components/settings/settings-save-
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useGitLabActionPresets } from "@/hooks/domains/gitlab/use-gitlab-action-presets";
|
||||
import type { GitLabActionPreset, GitLabActionPresets } from "@/lib/types/gitlab";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// `label` is PERSISTED to workspace settings as part of `GitLabActionPreset`, and
|
||||
// the row below is immediately editable, so it must stay locale-neutral: a preset
|
||||
// seeded in one locale and saved unedited would keep that locale's text forever.
|
||||
// Same contract as `newPreset` in components/github/action-presets-section.tsx.
|
||||
function newPreset(): GitLabActionPreset {
|
||||
return {
|
||||
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
@@ -33,6 +38,7 @@ function PresetList({
|
||||
onChange: (next: GitLabActionPreset[]) => void;
|
||||
addLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const patch = (index: number, change: Partial<GitLabActionPreset>) =>
|
||||
onChange(
|
||||
presets.map((preset, current) => (current === index ? { ...preset, ...change } : preset)),
|
||||
@@ -43,15 +49,15 @@ function PresetList({
|
||||
<div key={preset.id} className="space-y-2 rounded-md border p-3">
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||
<Input
|
||||
aria-label={`Action label ${index + 1}`}
|
||||
aria-label={t("gitlab:actionLabel", { index: index + 1 })}
|
||||
value={preset.label}
|
||||
placeholder="Label"
|
||||
placeholder={t("gitlab:label")}
|
||||
onChange={(event) => patch(index, { label: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
aria-label={`Action hint ${index + 1}`}
|
||||
aria-label={t("gitlab:actionHint", { index: index + 1 })}
|
||||
value={preset.hint}
|
||||
placeholder="Short hint"
|
||||
placeholder={t("gitlab:shortHint")}
|
||||
onChange={(event) => patch(index, { hint: event.target.value })}
|
||||
/>
|
||||
<Button
|
||||
@@ -59,16 +65,21 @@ function PresetList({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 cursor-pointer text-destructive sm:h-8 sm:w-8"
|
||||
aria-label={`Remove ${preset.label}`}
|
||||
aria-label={t("gitlab:remove", { label: preset.label })}
|
||||
onClick={() => onChange(presets.filter((_, current) => current !== index))}
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label={`Action prompt ${index + 1}`}
|
||||
aria-label={t("gitlab:actionPrompt", { index: index + 1 })}
|
||||
value={preset.prompt_template}
|
||||
placeholder="Prompt using {{url}} and {{title}}"
|
||||
// The two `{{…}}` tokens are passed as values so they never reach the
|
||||
// catalog, where i18next would interpolate them away.
|
||||
placeholder={t("gitlab:promptUsingUrlAndTitle", {
|
||||
url: "{{url}}",
|
||||
title: "{{title}}",
|
||||
})}
|
||||
className="min-h-24 font-mono text-xs"
|
||||
onChange={(event) => patch(index, { prompt_template: event.target.value })}
|
||||
/>
|
||||
@@ -112,6 +123,7 @@ export function validActionPresets(presets: GitLabActionPreset[]): boolean {
|
||||
}
|
||||
|
||||
export function GitLabActionPresetsSection({ workspaceId }: { workspaceId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { presets, loading, update, reset } = useGitLabActionPresets(workspaceId);
|
||||
const drafts = usePresetDrafts(presets);
|
||||
const { toast } = useToast();
|
||||
@@ -119,15 +131,15 @@ export function GitLabActionPresetsSection({ workspaceId }: { workspaceId: strin
|
||||
try {
|
||||
const result = await update({ mr: drafts.mr, issue: drafts.issue });
|
||||
if (result) drafts.setBaseline({ mr: result.mr, issue: result.issue });
|
||||
toast({ description: "GitLab quick actions saved", variant: "success" });
|
||||
toast({ description: t("gitlab:gitlabQuickActionsSaved"), variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
description: error instanceof Error ? error.message : "Failed to save quick actions",
|
||||
description: error instanceof Error ? error.message : t("gitlab:failedToSaveQuickActions"),
|
||||
variant: "error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}, [drafts, toast, update]);
|
||||
}, [drafts, t, toast, update]);
|
||||
const discard = useCallback(() => {
|
||||
drafts.setMR(drafts.baseline.mr);
|
||||
drafts.setIssue(drafts.baseline.issue);
|
||||
@@ -138,7 +150,7 @@ export function GitLabActionPresetsSection({ workspaceId }: { workspaceId: strin
|
||||
revision: JSON.stringify([drafts.mr, drafts.issue]),
|
||||
isDirty: drafts.dirty,
|
||||
canSave: !loading && valid,
|
||||
invalidReason: valid ? undefined : "Every quick action needs a label and prompt.",
|
||||
invalidReason: valid ? undefined : t("gitlab:everyQuickActionNeedsALabel"),
|
||||
save,
|
||||
discard,
|
||||
});
|
||||
@@ -150,18 +162,18 @@ export function GitLabActionPresetsSection({ workspaceId }: { workspaceId: strin
|
||||
drafts.setIssue(result.issue);
|
||||
drafts.setBaseline({ mr: result.mr, issue: result.issue });
|
||||
}
|
||||
toast({ description: "GitLab quick actions reset", variant: "success" });
|
||||
toast({ description: t("gitlab:gitlabQuickActionsReset"), variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
description: error instanceof Error ? error.message : "Failed to reset quick actions",
|
||||
description: error instanceof Error ? error.message : t("gitlab:failedToResetQuickActions"),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Quick actions"
|
||||
description="Task prompts shown on the GitLab browse page for merge requests and issues."
|
||||
title={t("gitlab:quickActions")}
|
||||
description={t("gitlab:taskPromptsShownOnTheGitlab")}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
@@ -169,10 +181,10 @@ export function GitLabActionPresetsSection({ workspaceId }: { workspaceId: strin
|
||||
variant="outline"
|
||||
className="h-11 cursor-pointer sm:h-8"
|
||||
disabled={loading}
|
||||
aria-label="Reset quick actions to defaults"
|
||||
aria-label={t("gitlab:resetQuickActionsToDefaults")}
|
||||
onClick={() => void resetDefaults()}
|
||||
>
|
||||
<IconRefresh className="h-4 w-4" /> Reset
|
||||
<IconRefresh className="h-4 w-4" /> {t("common:reset")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
@@ -181,20 +193,24 @@ export function GitLabActionPresetsSection({ workspaceId }: { workspaceId: strin
|
||||
<Tabs defaultValue="mr">
|
||||
<TabsList className="w-full sm:w-auto">
|
||||
<TabsTrigger value="mr" className="flex-1 cursor-pointer sm:flex-none">
|
||||
Merge requests
|
||||
{t("gitlab:mergeRequests")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="issue" className="flex-1 cursor-pointer sm:flex-none">
|
||||
Issues
|
||||
{t("gitlab:issues")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="mr">
|
||||
<PresetList presets={drafts.mr} onChange={drafts.setMR} addLabel="Add MR action" />
|
||||
<PresetList
|
||||
presets={drafts.mr}
|
||||
onChange={drafts.setMR}
|
||||
addLabel={t("gitlab:addMrAction")}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="issue">
|
||||
<PresetList
|
||||
presets={drafts.issue}
|
||||
onChange={drafts.setIssue}
|
||||
addLabel="Add issue action"
|
||||
addLabel={t("gitlab:addIssueAction")}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@kandev/ui/alert-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function DeleteWatchDialog({
|
||||
open,
|
||||
@@ -23,6 +24,7 @@ export function DeleteWatchDialog({
|
||||
watchLabel: string;
|
||||
onConfirm: () => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
@@ -32,10 +34,11 @@ export function DeleteWatchDialog({
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {watchLabel}?</AlertDialogTitle>
|
||||
{/* One message, not "Delete" + label: the label's position in the
|
||||
sentence is the translator's to choose. */}
|
||||
<AlertDialogTitle>{t("gitlab:deleteWatchTitle", { label: watchLabel })}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete every task created by this watch, including archived tasks, and remove
|
||||
its polling history. This cannot be undone.
|
||||
{t("gitlab:thisWillDeleteEveryTaskCreated")}
|
||||
</AlertDialogDescription>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
@@ -45,7 +48,7 @@ export function DeleteWatchDialog({
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting} className="cursor-pointer">
|
||||
Cancel
|
||||
{t("common:cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleting}
|
||||
@@ -58,13 +61,13 @@ export function DeleteWatchDialog({
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Watch deletion failed");
|
||||
setError(cause instanceof Error ? cause.message : t("gitlab:watchDeletionFailed"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete watch"}
|
||||
{deleting ? t("gitlab:deleting") : t("gitlab:deleteWatch")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -30,15 +30,22 @@ import { clearGitLabToken, fetchGitLabStatus, setGitLabConfig } from "@/lib/api/
|
||||
import type { GitLabConfig, GitLabStatus } from "@/lib/types/gitlab";
|
||||
import { GitLabWatchSettings } from "./watch-settings";
|
||||
import { GitLabActionPresetsSection } from "./action-presets-section";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
const DEFAULT_HOST = "https://gitlab.com";
|
||||
// The bare hostname as it reads mid-sentence. Interpolated rather than written
|
||||
// into the catalog so no locale — including pseudo — can transliterate it into a
|
||||
// hostname that does not resolve.
|
||||
const DEFAULT_HOST_NAME = "gitlab.com";
|
||||
|
||||
function StatusBadge({ status }: { status: GitLabStatus | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!status) return null;
|
||||
if (status.authenticated) {
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<IconCheck className="h-3 w-3" /> Connected
|
||||
<IconCheck className="h-3 w-3" /> {t("gitlab:connected")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +58,7 @@ function StatusBadge({ status }: { status: GitLabStatus | null }) {
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-500/60 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<IconAlertTriangle className="h-3 w-3" /> Unreachable
|
||||
<IconAlertTriangle className="h-3 w-3" /> {t("gitlab:unreachable")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -61,13 +68,13 @@ function StatusBadge({ status }: { status: GitLabStatus | null }) {
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-500/60 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<IconAlertTriangle className="h-3 w-3" /> Reconnect required
|
||||
<IconAlertTriangle className="h-3 w-3" /> {t("gitlab:reconnectRequired")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<IconX className="h-3 w-3" /> Not connected
|
||||
<IconX className="h-3 w-3" /> {t("gitlab:notConnected")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -77,28 +84,43 @@ function StatusBadge({ status }: { status: GitLabStatus | null }) {
|
||||
// of "your token is broken" during an outage. Hidden when the probe succeeded
|
||||
// or when no token is configured (nothing to probe).
|
||||
function ConnectionErrorAlert({ status }: { status: GitLabStatus | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!status?.connection_error) return null;
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<IconAlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Couldn't reach <code className="font-mono text-xs">{status.host}</code>:{" "}
|
||||
{status.connection_error}
|
||||
{/* One message rather than a stem plus the host: where the host and the
|
||||
upstream error sit in the sentence is the translator's call. Both are
|
||||
server data, so they travel through `values`. */}
|
||||
<Trans
|
||||
i18nKey="gitlab:couldNotReachHost"
|
||||
values={{ host: status.host, error: status.connection_error }}
|
||||
>
|
||||
Couldn't reach <code className="font-mono text-xs">{status.host}</code>:{" "}
|
||||
{status.connection_error}
|
||||
</Trans>
|
||||
<span className="block text-xs opacity-80 mt-1">
|
||||
Your token may still be valid — this looks like a network or upstream issue.
|
||||
{t("gitlab:yourTokenMayStillBeValid")}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// The record keys are the wire `auth_method` values the backend sends and must
|
||||
// never be translated; only the values are copy. Built inside the component so
|
||||
// `t()` runs at render — a module-scope table would freeze at the boot locale.
|
||||
// The `?? method` fallback deliberately echoes the raw wire value for a method
|
||||
// this build does not know about.
|
||||
function AuthMethodBadge({ method }: { method: GitLabStatus["auth_method"] }) {
|
||||
const { t } = useTranslation();
|
||||
const labels: Record<GitLabStatus["auth_method"], string> = {
|
||||
glab_cli: "glab CLI",
|
||||
pat: "Personal access token",
|
||||
environment: "Environment token",
|
||||
none: "Not configured",
|
||||
mock: "Mock (test)",
|
||||
glab_cli: t("gitlab:glabCli"),
|
||||
pat: t("gitlab:personalAccessToken"),
|
||||
environment: t("gitlab:environmentToken"),
|
||||
none: t("gitlab:notConfigured"),
|
||||
mock: t("gitlab:mockTest"),
|
||||
};
|
||||
return <Badge variant="outline">{labels[method] ?? method}</Badge>;
|
||||
}
|
||||
@@ -152,9 +174,11 @@ function isValidGitLabHost(host: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function credentialInvalidReason(validHost: boolean, patNeedsToken: boolean) {
|
||||
if (!validHost) return "Enter a valid HTTP or HTTPS GitLab host URL.";
|
||||
if (patNeedsToken) return "Enter a personal access token to switch to PAT.";
|
||||
// `t` is threaded in: this is a plain function, and the guard only inspects JSX,
|
||||
// so a literal returned from here would never be reported.
|
||||
function credentialInvalidReason(t: TFunction, validHost: boolean, patNeedsToken: boolean) {
|
||||
if (!validHost) return t("gitlab:enterAValidHttpOrHttpsGitlabHost");
|
||||
if (patNeedsToken) return t("gitlab:enterAPersonalAccessTokenToSwitch");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -169,6 +193,7 @@ function useGitLabCredentialDraft({
|
||||
onDirtyChange,
|
||||
onHostChange,
|
||||
}: GitLabCredentialsFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [method, setMethod] = useState(initial);
|
||||
const [baseline, setBaseline] = useState(initial);
|
||||
const [syncedInitial, setSyncedInitial] = useState(initial);
|
||||
@@ -199,17 +224,17 @@ function useGitLabCredentialDraft({
|
||||
setBaseline(method);
|
||||
setHostBaseline(host.trim());
|
||||
setToken((current) => (current.trim() === submittedToken ? "" : current));
|
||||
toast({ description: "GitLab authentication method updated", variant: "success" });
|
||||
toast({ description: t("gitlab:gitlabAuthenticationMethodUpdated"), variant: "success" });
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
toast({
|
||||
description:
|
||||
error instanceof Error ? error.message : "Failed to update authentication method",
|
||||
error instanceof Error ? error.message : t("gitlab:failedToUpdateAuthenticationMethod"),
|
||||
variant: "error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}, [host, method, onSaved, toast, token, workspaceId]);
|
||||
}, [host, method, onSaved, t, toast, token, workspaceId]);
|
||||
const patNeedsToken = method === "pat" && !hasToken && !token.trim();
|
||||
const validHost = isValidGitLabHost(host);
|
||||
useSettingsSaveContributor({
|
||||
@@ -217,7 +242,7 @@ function useGitLabCredentialDraft({
|
||||
revision: JSON.stringify({ host: host.trim(), method, token }),
|
||||
isDirty,
|
||||
canSave: validHost && !patNeedsToken,
|
||||
invalidReason: credentialInvalidReason(validHost, patNeedsToken),
|
||||
invalidReason: credentialInvalidReason(t, validHost, patNeedsToken),
|
||||
save,
|
||||
discard: () => {
|
||||
setMethod(baseline);
|
||||
@@ -235,17 +260,15 @@ function useGitLabCredentialDraft({
|
||||
}
|
||||
|
||||
export function GitLabCredentialsForm(props: GitLabCredentialsFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const draft = useGitLabCredentialDraft(props);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose a workspace PAT, the local glab CLI login, or an environment-provided token. CLI and
|
||||
environment credentials must already be available to the kandev backend.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("gitlab:chooseAWorkspacePatTheLocal")}</p>
|
||||
<Select value={draft.method} onValueChange={draft.selectMethod}>
|
||||
<SelectTrigger
|
||||
aria-label="Authentication method"
|
||||
aria-label={t("gitlab:authenticationMethod")}
|
||||
className="w-full cursor-pointer sm:w-64"
|
||||
data-settings-dirty={draft.isDirty}
|
||||
>
|
||||
@@ -253,13 +276,13 @@ export function GitLabCredentialsForm(props: GitLabCredentialsFormProps) {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pat" className="cursor-pointer">
|
||||
Personal access token
|
||||
{t("gitlab:personalAccessToken")}
|
||||
</SelectItem>
|
||||
<SelectItem value="glab_cli" className="cursor-pointer">
|
||||
glab CLI
|
||||
{t("gitlab:glabCli")}
|
||||
</SelectItem>
|
||||
<SelectItem value="environment" className="cursor-pointer">
|
||||
Environment token
|
||||
{t("gitlab:environmentToken")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -281,7 +304,7 @@ export function GitLabCredentialsForm(props: GitLabCredentialsFormProps) {
|
||||
type="button"
|
||||
onClick={() => setShowToken((value) => !value)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
aria-label={showToken ? t("gitlab:hideToken") : t("gitlab:showToken")}
|
||||
>
|
||||
{showToken ? <IconEyeOff className="h-4 w-4" /> : <IconEye className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -299,6 +322,7 @@ function ClearTokenButton({
|
||||
workspaceId: string;
|
||||
onCleared: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { toast } = useToast();
|
||||
return (
|
||||
@@ -310,11 +334,11 @@ function ClearTokenButton({
|
||||
setBusy(true);
|
||||
try {
|
||||
await clearGitLabToken({ workspaceId });
|
||||
toast({ description: "GitLab token cleared" });
|
||||
toast({ description: t("gitlab:gitlabTokenCleared") });
|
||||
onCleared();
|
||||
} catch (err) {
|
||||
toast({
|
||||
description: err instanceof Error ? err.message : "Failed to clear token",
|
||||
description: err instanceof Error ? err.message : t("gitlab:failedToClearToken"),
|
||||
variant: "error",
|
||||
});
|
||||
} finally {
|
||||
@@ -324,7 +348,7 @@ function ClearTokenButton({
|
||||
className="gap-1 cursor-pointer"
|
||||
>
|
||||
{busy ? <Spinner className="h-3 w-3" /> : <IconTrash className="h-3 w-3" />}
|
||||
Clear token
|
||||
{t("gitlab:clearToken")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -380,7 +404,9 @@ function ConnectionStatusRow({ status }: { status: GitLabStatus | null }) {
|
||||
</div>
|
||||
{status?.username ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Logged in as <span className="font-medium">{status.username}</span>
|
||||
<Trans i18nKey="gitlab:loggedInAs" values={{ username: status.username }}>
|
||||
Logged in as <span className="font-medium">{status.username}</span>
|
||||
</Trans>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -388,6 +414,7 @@ function ConnectionStatusRow({ status }: { status: GitLabStatus | null }) {
|
||||
}
|
||||
|
||||
function GitLabConnectionCard(props: ConnectionCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
workspaceId,
|
||||
status,
|
||||
@@ -403,7 +430,7 @@ function GitLabConnectionCard(props: ConnectionCardProps) {
|
||||
return (
|
||||
<SettingsSection
|
||||
title="GitLab"
|
||||
description="Connect a GitLab account so kandev can open merge requests, read review discussions, and reply to / resolve them on your behalf."
|
||||
description={t("gitlab:connectAGitlabAccountSoKandev")}
|
||||
icon={<IconBrandGitlab className="h-4 w-4" />}
|
||||
action={
|
||||
<Button
|
||||
@@ -413,7 +440,7 @@ function GitLabConnectionCard(props: ConnectionCardProps) {
|
||||
disabled={loading}
|
||||
className="gap-1 cursor-pointer"
|
||||
>
|
||||
<IconRefresh className="h-3 w-3" /> Refresh
|
||||
<IconRefresh className="h-3 w-3" /> {t("gitlab:refresh")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
@@ -424,8 +451,7 @@ function GitLabConnectionCard(props: ConnectionCardProps) {
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GitLab host URL. Override for self-managed instances; leave at the default for
|
||||
gitlab.com.
|
||||
{t("gitlab:gitlabHostUrlOverrideForSelf", { host: DEFAULT_HOST_NAME })}
|
||||
</p>
|
||||
<HostForm
|
||||
host={hostDraft}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ReviewWatchTable } from "./review-watch-table";
|
||||
import { formatDateTime } from "@/lib/i18n/formats";
|
||||
import type { ReviewWatch } from "@/lib/types/gitlab";
|
||||
|
||||
const watch: ReviewWatch = {
|
||||
@@ -50,4 +51,58 @@ describe("ReviewWatchTable", () => {
|
||||
expect(screen.getAllByRole("button", { name: "Reset watch" }).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole("button", { name: "Edit watch" }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The last-checked cell used to call a bare `toLocaleString()`, which follows
|
||||
// the BROWSER locale and so could disagree with the rest of the page after a
|
||||
// language switch. Asserting against `formatDateTime` — which reads the active
|
||||
// i18next language — pins it to the application locale: a revert to
|
||||
// `toLocaleString()` produces a different string ("1/2/2026, 10:00:00 AM" vs
|
||||
// "Jan 2, 2026, 10:00 AM") and fails here.
|
||||
it.each([
|
||||
{ name: "a polled watch", lastPolledAt: "2026-01-02T10:00:00Z" },
|
||||
{ name: "a watch polled at a different instant", lastPolledAt: "2026-06-30T23:15:00Z" },
|
||||
])("renders $name's last-checked time in the application locale", ({ lastPolledAt }) => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReviewWatchTable
|
||||
watches={[{ ...watch, last_polled_at: lastPolledAt }]}
|
||||
dirtyIds={new Set()}
|
||||
authoritativeEnabledById={new Map()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onTrigger={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
onToggleEnabled={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const expected = formatDateTime(lastPolledAt);
|
||||
expect(expected).not.toBe(new Date(lastPolledAt).toLocaleString());
|
||||
// Desktop cell plus the mobile card's "Last checked {{value}}" line.
|
||||
expect(screen.getAllByText(new RegExp(escapeRegExp(expected))).length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("falls back to the translated Never when a watch has not been polled", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ReviewWatchTable
|
||||
watches={[{ ...watch, last_polled_at: undefined }]}
|
||||
dirtyIds={new Set()}
|
||||
authoritativeEnabledById={new Map()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onTrigger={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
onToggleEnabled={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText(/Never/).length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
type GitLabWatchForm,
|
||||
type GitLabWatchKind,
|
||||
} from "./watch-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
type Watch = ReviewWatch | IssueWatch;
|
||||
type CreateRequest = CreateReviewWatchRequest | CreateIssueWatchRequest;
|
||||
@@ -109,13 +111,14 @@ function useDialogData(workspaceId: string, workflowId: string) {
|
||||
}
|
||||
|
||||
function FilterFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionTitle>Match</SectionTitle>
|
||||
<SectionTitle>{t("gitlab:match")}</SectionTitle>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${kind}-watch-projects`}>Project paths</Label>
|
||||
<Label htmlFor={`${kind}-watch-projects`}>{t("gitlab:projectPaths")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional comma-separated namespace/project paths. Empty watches every accessible project.
|
||||
{t("gitlab:optionalCommaSeparatedNamespaceProjectPaths")}
|
||||
</p>
|
||||
<Input
|
||||
id={`${kind}-watch-projects`}
|
||||
@@ -123,29 +126,39 @@ function FilterFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, projectPaths: event.target.value }))
|
||||
}
|
||||
// Sample namespace/project paths, i.e. the shape of the GitLab data the
|
||||
// field accepts. Not copy — a translated "group" would stop being a
|
||||
// usable example.
|
||||
placeholder="group/api, group/web"
|
||||
/>
|
||||
</div>
|
||||
{kind === "issue" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="gitlab-watch-labels">Labels</Label>
|
||||
<Label htmlFor="gitlab-watch-labels">{t("gitlab:labels")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional comma-separated GitLab labels that matching issues must include.
|
||||
{t("gitlab:optionalCommaSeparatedGitlabLabelsThat")}
|
||||
</p>
|
||||
<Input
|
||||
id="gitlab-watch-labels"
|
||||
value={form.labels}
|
||||
onChange={(event) => setForm((current) => ({ ...current, labels: event.target.value }))}
|
||||
// Sample GitLab label names — user data, like the labels the user
|
||||
// will actually type here.
|
||||
placeholder="bug, priority::high"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${kind}-watch-query`}>GitLab query parameters</Label>
|
||||
<Label htmlFor={`${kind}-watch-query`}>{t("gitlab:gitlabQueryParameters")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{kind === "review"
|
||||
? "Leave empty to match merge requests requesting your review. Adding parameters explicitly replaces that reviewer constraint."
|
||||
: "Optional GitLab API query parameters, such as state=opened&milestone=Next."}
|
||||
? t("gitlab:leaveEmptyToMatchMergeRequests")
|
||||
: // The example is a literal GitLab API query string, so it is
|
||||
// interpolated rather than written into the catalog — a translated
|
||||
// (or pseudo-transliterated) copy of it would not parse.
|
||||
t("gitlab:optionalGitlabApiQueryParametersSuch", {
|
||||
example: "state=opened&milestone=Next",
|
||||
})}
|
||||
</p>
|
||||
<Input
|
||||
id={`${kind}-watch-query`}
|
||||
@@ -153,14 +166,16 @@ function FilterFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, customQuery: event.target.value }))
|
||||
}
|
||||
// A literal GitLab API query parameter. It is submitted to GitLab
|
||||
// verbatim, so it is protocol, not copy.
|
||||
placeholder="state=opened"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
{kind === "review" && (
|
||||
<SelectField
|
||||
label="Review scope"
|
||||
description="Choose whether to include only direct reviewer requests or the broader compatible scope."
|
||||
label={t("gitlab:reviewScope")}
|
||||
description={t("gitlab:chooseWhetherToIncludeOnlyDirect")}
|
||||
value={form.reviewScope}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
@@ -168,10 +183,10 @@ function FilterFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
reviewScope: value as GitLabWatchForm["reviewScope"],
|
||||
}))
|
||||
}
|
||||
placeholder="Direct requests"
|
||||
placeholder={t("gitlab:directRequests")}
|
||||
items={[
|
||||
{ id: "user", label: "Direct requests" },
|
||||
{ id: "user_and_teams", label: "Direct and group-compatible requests" },
|
||||
{ id: "user", label: t("gitlab:directRequests") },
|
||||
{ id: "user_and_teams", label: t("gitlab:directAndGroupCompatibleRequests") },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
@@ -186,24 +201,25 @@ type FormFieldsProps = {
|
||||
};
|
||||
|
||||
function AutomationFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = useDialogData(form.workspaceId, form.workflowId);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionTitle>Task automation</SectionTitle>
|
||||
<SectionTitle>{t("gitlab:taskAutomation")}</SectionTitle>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<SelectField
|
||||
label="Workflow"
|
||||
description="Workflow that receives new tasks."
|
||||
label={t("gitlab:workflow")}
|
||||
description={t("gitlab:workflowThatReceivesNewTasks")}
|
||||
value={form.workflowId}
|
||||
onChange={(workflowId) =>
|
||||
setForm((current) => ({ ...current, workflowId, workflowStepId: "" }))
|
||||
}
|
||||
placeholder="Select workflow"
|
||||
placeholder={t("gitlab:selectWorkflow")}
|
||||
items={data.workflows.map((item) => ({ id: item.id, label: item.name }))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Workflow step"
|
||||
description="Initial step for each new task."
|
||||
label={t("gitlab:workflowStep")}
|
||||
description={t("gitlab:initialStepForEachNewTask")}
|
||||
value={form.workflowStepId}
|
||||
onChange={(workflowStepId) => setForm((current) => ({ ...current, workflowStepId }))}
|
||||
placeholder={stepPlaceholder(form.workflowId, data.stepsLoading, data.steps.length)}
|
||||
@@ -222,8 +238,8 @@ function AutomationFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<SelectField
|
||||
label="Agent profile"
|
||||
description="Optional; otherwise uses the workflow step default."
|
||||
label={t("gitlab:agentProfile")}
|
||||
description={t("gitlab:optionalOtherwiseUsesTheWorkflowStep")}
|
||||
value={form.agentProfileId || STEP_DEFAULT}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, agentProfileId: resolveProfileId(value) }))
|
||||
@@ -235,8 +251,8 @@ function AutomationFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
]}
|
||||
/>
|
||||
<SelectField
|
||||
label="Executor profile"
|
||||
description="Optional; otherwise uses the workflow step default."
|
||||
label={t("gitlab:executorProfile")}
|
||||
description={t("gitlab:optionalOtherwiseUsesTheWorkflowStep")}
|
||||
value={form.executorProfileId || STEP_DEFAULT}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, executorProfileId: resolveProfileId(value) }))
|
||||
@@ -249,10 +265,8 @@ function AutomationFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${kind}-watch-prompt`}>Task prompt</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Prompt sent to the selected agent profile for each new match.
|
||||
</p>
|
||||
<Label htmlFor={`${kind}-watch-prompt`}>{t("gitlab:taskPrompt")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("gitlab:promptSentToTheSelectedAgent")}</p>
|
||||
<Textarea
|
||||
id={`${kind}-watch-prompt`}
|
||||
value={form.prompt}
|
||||
@@ -265,13 +279,14 @@ function AutomationFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
}
|
||||
|
||||
function ScheduleFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionTitle>Polling and cleanup</SectionTitle>
|
||||
<SectionTitle>{t("gitlab:pollingAndCleanup")}</SectionTitle>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${kind}-watch-interval`}>Poll interval (seconds)</Label>
|
||||
<p className="text-xs text-muted-foreground">Between 60 and 3600 seconds.</p>
|
||||
<Label htmlFor={`${kind}-watch-interval`}>{t("gitlab:pollIntervalSeconds")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("gitlab:between60And3600Seconds")}</p>
|
||||
<Input
|
||||
id={`${kind}-watch-interval`}
|
||||
type="number"
|
||||
@@ -284,9 +299,9 @@ function ScheduleFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${kind}-watch-inflight`}>Maximum in-flight tasks</Label>
|
||||
<Label htmlFor={`${kind}-watch-inflight`}>{t("gitlab:maximumInFlightTasks")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional cap; polling resumes when active tasks fall below it.
|
||||
{t("gitlab:optionalCapPollingResumesWhenActive")}
|
||||
</p>
|
||||
<Input
|
||||
id={`${kind}-watch-inflight`}
|
||||
@@ -296,13 +311,13 @@ function ScheduleFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, maxInflightTasks: event.target.value }))
|
||||
}
|
||||
placeholder="No limit"
|
||||
placeholder={t("gitlab:noLimit")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SelectField
|
||||
label="Cleanup policy"
|
||||
description="Controls task deletion when the GitLab item closes."
|
||||
label={t("gitlab:cleanupPolicy")}
|
||||
description={t("gitlab:controlsTaskDeletionWhenTheGitlab")}
|
||||
value={form.cleanupPolicy}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
@@ -310,17 +325,24 @@ function ScheduleFields({ kind, form, setForm }: FormFieldsProps) {
|
||||
cleanupPolicy: value as GitLabWatchForm["cleanupPolicy"],
|
||||
}))
|
||||
}
|
||||
placeholder="Auto"
|
||||
placeholder={t("gitlab:auto")}
|
||||
items={[
|
||||
{ id: "auto", label: "Auto; keep engaged tasks" },
|
||||
{ id: "always", label: "Always delete" },
|
||||
{ id: "never", label: "Never delete" },
|
||||
{ id: "auto", label: t("gitlab:autoKeepEngagedTasks") },
|
||||
{ id: "always", label: t("gitlab:alwaysDelete") },
|
||||
{ id: "never", label: t("gitlab:neverDelete") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function dialogTitle(t: TFunction, kind: GitLabWatchKind, editing: boolean): string {
|
||||
if (kind === "review") {
|
||||
return editing ? t("gitlab:editReviewWatch") : t("gitlab:createReviewWatch");
|
||||
}
|
||||
return editing ? t("gitlab:editIssueWatch") : t("gitlab:createIssueWatch");
|
||||
}
|
||||
|
||||
export function GitLabWatchDialog({
|
||||
kind,
|
||||
open,
|
||||
@@ -330,6 +352,7 @@ export function GitLabWatchDialog({
|
||||
onCreate,
|
||||
onUpdate,
|
||||
}: GitLabWatchDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState(() => makeWatchForm(kind, workspaceId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -351,25 +374,27 @@ export function GitLabWatchDialog({
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "GitLab watch could not be saved");
|
||||
setError(cause instanceof Error ? cause.message : t("gitlab:gitlabWatchCouldNotBeSaved"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [onCreate, onOpenChange, onUpdate, payload, watch]);
|
||||
const noun = kind === "review" ? "review" : "issue";
|
||||
let saveLabel = "Create watch";
|
||||
if (watch) saveLabel = "Update watch";
|
||||
if (saving) saveLabel = "Saving...";
|
||||
}, [onCreate, onOpenChange, onUpdate, payload, t, watch]);
|
||||
let saveLabel = t("gitlab:createWatch");
|
||||
if (watch) saveLabel = t("gitlab:updateWatch");
|
||||
if (saving) saveLabel = t("gitlab:saving");
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-none overflow-y-auto sm:max-h-[90vh] sm:w-[min(900px,calc(100vw-2rem))]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{watch ? "Edit" : "Create"} GitLab {noun} watch
|
||||
</DialogTitle>
|
||||
{/* Four whole sentences rather than "Edit"/"Create" + a `kind` noun:
|
||||
`kind` is the wire discriminant and must never be translated, and
|
||||
splicing a translated noun into a translated stem fixes English
|
||||
word order. */}
|
||||
<DialogTitle>{dialogTitle(t, kind, Boolean(watch))}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Automatically create workspace tasks for newly matching GitLab{" "}
|
||||
{kind === "review" ? "merge requests" : "issues"}.
|
||||
{kind === "review"
|
||||
? t("gitlab:reviewWatchDialogDescription")
|
||||
: t("gitlab:issueWatchDialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && (
|
||||
@@ -389,7 +414,7 @@ export function GitLabWatchDialog({
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="min-h-11 cursor-pointer sm:min-h-9"
|
||||
>
|
||||
Cancel
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void save()}
|
||||
|
||||
@@ -24,6 +24,11 @@ export type GitLabWatchForm = {
|
||||
cleanupPolicy: "auto" | "always" | "never";
|
||||
};
|
||||
|
||||
// Seeded into the dialog's prompt field, then persisted on the watch and sent to
|
||||
// the agent verbatim. Deliberately NOT translated: a watch created under one
|
||||
// locale would keep that locale's prompt forever, and the agent reads it as an
|
||||
// instruction rather than as UI copy. Same contract as
|
||||
// DEFAULT_ISSUE_WATCH_PROMPT in components/github/issue-watch-placeholders.ts.
|
||||
const DEFAULT_REVIEW_PROMPT =
|
||||
"Review GitLab merge request {{mr.url}}. Summarize risks and leave actionable feedback.";
|
||||
const DEFAULT_ISSUE_PROMPT =
|
||||
|
||||
@@ -17,6 +17,7 @@ import { IssueWatchTable } from "./issue-watch-table";
|
||||
import { ReviewWatchDialog } from "./review-watch-dialog";
|
||||
import { ReviewWatchTable } from "./review-watch-table";
|
||||
import { DeleteWatchDialog } from "./delete-watch-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ReviewWatches = ReturnType<typeof useGitLabReviewWatches>;
|
||||
type IssueWatches = ReturnType<typeof useGitLabIssueWatches>;
|
||||
@@ -36,6 +37,7 @@ function ActionError({ message }: { message: string }) {
|
||||
}
|
||||
|
||||
function NewWatchButton({ onClick }: { onClick: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -43,7 +45,7 @@ function NewWatchButton({ onClick }: { onClick: () => void }) {
|
||||
className="min-h-11 w-full cursor-pointer sm:min-h-8 sm:w-auto"
|
||||
>
|
||||
<IconPlus className="mr-1 h-4 w-4" />
|
||||
New watch
|
||||
{t("gitlab:newWatch")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +55,7 @@ function useReviewActions(
|
||||
workspaceId: string,
|
||||
setError: (message: string) => void,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const run = useCallback(
|
||||
async (watch: ReviewWatch) => {
|
||||
@@ -61,28 +64,28 @@ function useReviewActions(
|
||||
const result = await watches.trigger(watch.id, watch.workspace_id);
|
||||
toast({
|
||||
description: result.count
|
||||
? `Found ${result.count} matching merge request(s)`
|
||||
: "No new merge requests matched",
|
||||
? t("gitlab:foundMatchingMergeRequests", { count: result.count })
|
||||
: t("gitlab:noNewMergeRequestsMatched"),
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setError(errorMessage(error, "Review watch check failed"));
|
||||
setError(errorMessage(error, t("gitlab:reviewWatchCheckFailed")));
|
||||
}
|
||||
},
|
||||
[setError, toast, watches],
|
||||
[setError, t, toast, watches],
|
||||
);
|
||||
const remove = useCallback(
|
||||
async (watch: ReviewWatch) => {
|
||||
setError("");
|
||||
try {
|
||||
await watches.remove(watch.id, watch.workspace_id);
|
||||
toast({ description: "Review watch deleted", variant: "success" });
|
||||
toast({ description: t("gitlab:reviewWatchDeleted"), variant: "success" });
|
||||
} catch (error) {
|
||||
setError(errorMessage(error, "Review watch deletion failed"));
|
||||
setError(errorMessage(error, t("gitlab:reviewWatchDeletionFailed")));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[setError, toast, watches],
|
||||
[setError, t, toast, watches],
|
||||
);
|
||||
const reset = useWatchResetController<ReviewWatch>({
|
||||
preview: (watch) => watches.previewReset(watch.id, watch.workspace_id),
|
||||
@@ -91,11 +94,11 @@ function useReviewActions(
|
||||
try {
|
||||
const result = await watches.reset(watch.id, watch.workspace_id);
|
||||
toast({
|
||||
description: `Review watch reset; ${result.tasksDeleted} task(s) deleted`,
|
||||
description: t("gitlab:reviewWatchReset", { count: result.tasksDeleted }),
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setError(errorMessage(error, "Review watch reset failed"));
|
||||
setError(errorMessage(error, t("gitlab:reviewWatchResetFailed")));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -105,26 +108,26 @@ function useReviewActions(
|
||||
setError("");
|
||||
try {
|
||||
await watches.create(request);
|
||||
toast({ description: "Review watch created", variant: "success" });
|
||||
toast({ description: t("gitlab:reviewWatchCreated"), variant: "success" });
|
||||
} catch (error) {
|
||||
setError(errorMessage(error, "Review watch creation failed"));
|
||||
setError(errorMessage(error, t("gitlab:reviewWatchCreationFailed")));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[setError, toast, watches],
|
||||
[setError, t, toast, watches],
|
||||
);
|
||||
const update = useCallback(
|
||||
async (id: string, request: Parameters<ReviewWatches["update"]>[1]) => {
|
||||
setError("");
|
||||
try {
|
||||
await watches.update(id, request, workspaceId);
|
||||
toast({ description: "Review watch updated", variant: "success" });
|
||||
toast({ description: t("gitlab:reviewWatchUpdated"), variant: "success" });
|
||||
} catch (error) {
|
||||
setError(errorMessage(error, "Review watch update failed"));
|
||||
setError(errorMessage(error, t("gitlab:reviewWatchUpdateFailed")));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[setError, toast, watches, workspaceId],
|
||||
[setError, t, toast, watches, workspaceId],
|
||||
);
|
||||
return { run, remove, reset, create, update };
|
||||
}
|
||||
@@ -138,6 +141,7 @@ function ReviewDialogs(props: {
|
||||
setDeleting: (watch: ReviewWatch | null) => void;
|
||||
actions: ReturnType<typeof useReviewActions>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<ReviewWatchDialog
|
||||
@@ -153,7 +157,7 @@ function ReviewDialogs(props: {
|
||||
open
|
||||
requirePreviewSuccess
|
||||
onOpenChange={props.actions.reset.onOpenChange}
|
||||
integrationLabel="GitLab review watch"
|
||||
integrationLabel={t("gitlab:gitlabReviewWatch")}
|
||||
previewLoader={props.actions.reset.previewLoader}
|
||||
onConfirm={props.actions.reset.confirmReset}
|
||||
/>
|
||||
@@ -164,7 +168,7 @@ function ReviewDialogs(props: {
|
||||
onOpenChange={(open) => {
|
||||
if (!open) props.setDeleting(null);
|
||||
}}
|
||||
watchLabel="GitLab review watch"
|
||||
watchLabel={t("gitlab:gitlabReviewWatch")}
|
||||
onConfirm={() => props.actions.remove(props.deleting!)}
|
||||
/>
|
||||
)}
|
||||
@@ -173,6 +177,7 @@ function ReviewDialogs(props: {
|
||||
}
|
||||
|
||||
function ReviewWatchSettings({ workspaceId }: { workspaceId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const watches = useGitLabReviewWatches(workspaceId);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ReviewWatch | null>(null);
|
||||
@@ -189,8 +194,8 @@ function ReviewWatchSettings({ workspaceId }: { workspaceId: string }) {
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={<IconGitMerge className="h-5 w-5" />}
|
||||
title="Merge request review watches"
|
||||
description="Poll GitLab for merge requests awaiting review and create one task per new match."
|
||||
title={t("gitlab:mergeRequestReviewWatches")}
|
||||
description={t("gitlab:pollGitlabForMergeRequestsAwaiting")}
|
||||
action={
|
||||
<NewWatchButton
|
||||
onClick={() => {
|
||||
@@ -250,6 +255,7 @@ function useIssueActions(
|
||||
workspaceId: string,
|
||||
setError: (message: string) => void,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const run = useCallback(
|
||||
async (watch: IssueWatch) => {
|
||||
@@ -258,28 +264,28 @@ function useIssueActions(
|
||||
const result = await watches.trigger(watch.id, watch.workspace_id);
|
||||
toast({
|
||||
description: result.count
|
||||
? `Found ${result.count} matching issue(s)`
|
||||
: "No new issues matched",
|
||||
? t("gitlab:foundMatchingIssues", { count: result.count })
|
||||
: t("gitlab:noNewIssuesMatched"),
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setError(errorMessage(error, "Issue watch check failed"));
|
||||
setError(errorMessage(error, t("gitlab:issueWatchCheckFailed")));
|
||||
}
|
||||
},
|
||||
[setError, toast, watches],
|
||||
[setError, t, toast, watches],
|
||||
);
|
||||
const remove = useCallback(
|
||||
async (watch: IssueWatch) => {
|
||||
setError("");
|
||||
try {
|
||||
await watches.remove(watch.id, watch.workspace_id);
|
||||
toast({ description: "Issue watch deleted", variant: "success" });
|
||||
toast({ description: t("gitlab:issueWatchDeleted"), variant: "success" });
|
||||
} catch (error) {
|
||||
setError(errorMessage(error, "Issue watch deletion failed"));
|
||||
setError(errorMessage(error, t("gitlab:issueWatchDeletionFailed")));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[setError, toast, watches],
|
||||
[setError, t, toast, watches],
|
||||
);
|
||||
const reset = useWatchResetController<IssueWatch>({
|
||||
preview: (watch) => watches.previewReset(watch.id, watch.workspace_id),
|
||||
@@ -288,11 +294,11 @@ function useIssueActions(
|
||||
try {
|
||||
const result = await watches.reset(watch.id, watch.workspace_id);
|
||||
toast({
|
||||
description: `Issue watch reset; ${result.tasksDeleted} task(s) deleted`,
|
||||
description: t("gitlab:issueWatchReset", { count: result.tasksDeleted }),
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setError(errorMessage(error, "Issue watch reset failed"));
|
||||
setError(errorMessage(error, t("gitlab:issueWatchResetFailed")));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -302,26 +308,26 @@ function useIssueActions(
|
||||
setError("");
|
||||
try {
|
||||
await watches.create(request);
|
||||
toast({ description: "Issue watch created", variant: "success" });
|
||||
toast({ description: t("gitlab:issueWatchCreated"), variant: "success" });
|
||||
} catch (error) {
|
||||
setError(errorMessage(error, "Issue watch creation failed"));
|
||||
setError(errorMessage(error, t("gitlab:issueWatchCreationFailed")));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[setError, toast, watches],
|
||||
[setError, t, toast, watches],
|
||||
);
|
||||
const update = useCallback(
|
||||
async (id: string, request: Parameters<IssueWatches["update"]>[1]) => {
|
||||
setError("");
|
||||
try {
|
||||
await watches.update(id, request, workspaceId);
|
||||
toast({ description: "Issue watch updated", variant: "success" });
|
||||
toast({ description: t("gitlab:issueWatchUpdated"), variant: "success" });
|
||||
} catch (error) {
|
||||
setError(errorMessage(error, "Issue watch update failed"));
|
||||
setError(errorMessage(error, t("gitlab:issueWatchUpdateFailed")));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[setError, toast, watches, workspaceId],
|
||||
[setError, t, toast, watches, workspaceId],
|
||||
);
|
||||
return { run, remove, reset, create, update };
|
||||
}
|
||||
@@ -335,6 +341,7 @@ function IssueDialogs(props: {
|
||||
setDeleting: (watch: IssueWatch | null) => void;
|
||||
actions: ReturnType<typeof useIssueActions>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<IssueWatchDialog
|
||||
@@ -350,7 +357,7 @@ function IssueDialogs(props: {
|
||||
open
|
||||
requirePreviewSuccess
|
||||
onOpenChange={props.actions.reset.onOpenChange}
|
||||
integrationLabel="GitLab issue watch"
|
||||
integrationLabel={t("gitlab:gitlabIssueWatch")}
|
||||
previewLoader={props.actions.reset.previewLoader}
|
||||
onConfirm={props.actions.reset.confirmReset}
|
||||
/>
|
||||
@@ -361,7 +368,7 @@ function IssueDialogs(props: {
|
||||
onOpenChange={(open) => {
|
||||
if (!open) props.setDeleting(null);
|
||||
}}
|
||||
watchLabel="GitLab issue watch"
|
||||
watchLabel={t("gitlab:gitlabIssueWatch")}
|
||||
onConfirm={() => props.actions.remove(props.deleting!)}
|
||||
/>
|
||||
)}
|
||||
@@ -370,6 +377,7 @@ function IssueDialogs(props: {
|
||||
}
|
||||
|
||||
function IssueWatchSettings({ workspaceId }: { workspaceId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const watches = useGitLabIssueWatches(workspaceId);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<IssueWatch | null>(null);
|
||||
@@ -386,8 +394,8 @@ function IssueWatchSettings({ workspaceId }: { workspaceId: string }) {
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={<IconTicket className="h-5 w-5" />}
|
||||
title="Issue watches"
|
||||
description="Poll GitLab issues and create one task per new match."
|
||||
title={t("gitlab:issueWatches")}
|
||||
description={t("gitlab:pollGitlabIssuesAndCreateOne")}
|
||||
action={
|
||||
<NewWatchButton
|
||||
onClick={() => {
|
||||
|
||||
@@ -13,6 +13,9 @@ import { Button } from "@kandev/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import type { IssueWatch, ReviewWatch } from "@/lib/types/gitlab";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { formatDateTime } from "@/lib/i18n/formats";
|
||||
|
||||
type Watch = ReviewWatch | IssueWatch;
|
||||
|
||||
@@ -32,9 +35,15 @@ type ActionProps<TWatch extends Watch> = Omit<WatchTableProps<TWatch>, "watches"
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
function checkUnavailableReason(dirty: boolean, enabled: boolean): string | undefined {
|
||||
if (dirty) return "Save changes before checking now";
|
||||
if (!enabled) return "Enable this watch before checking now";
|
||||
// `t` is threaded in rather than read from a hook: these are plain functions, and
|
||||
// the guard never inspects a return value, so a literal here would survive lint.
|
||||
function checkUnavailableReason(
|
||||
t: TFunction,
|
||||
dirty: boolean,
|
||||
enabled: boolean,
|
||||
): string | undefined {
|
||||
if (dirty) return t("gitlab:saveChangesBeforeCheckingNow");
|
||||
if (!enabled) return t("gitlab:enableThisWatchBeforeCheckingNow");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -47,14 +56,15 @@ function CheckNowButton({
|
||||
disabledReason?: string;
|
||||
onClick: (event: React.MouseEvent) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const button = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${size} p-0 cursor-pointer`}
|
||||
aria-label="Check now"
|
||||
aria-label={t("gitlab:checkNow")}
|
||||
aria-description={disabledReason}
|
||||
title={disabledReason ? undefined : "Check now"}
|
||||
title={disabledReason ? undefined : t("gitlab:checkNow")}
|
||||
disabled={Boolean(disabledReason)}
|
||||
onClick={onClick}
|
||||
>
|
||||
@@ -85,10 +95,11 @@ function WatchActions<TWatch extends Watch>({
|
||||
onToggleEnabled,
|
||||
mobile,
|
||||
}: ActionProps<TWatch>) {
|
||||
const { t } = useTranslation();
|
||||
const size = mobile ? "h-11 w-11" : "h-8 w-8";
|
||||
const dirty = dirtyIds.has(watch.id);
|
||||
const authoritativeEnabled = authoritativeEnabledById.get(watch.id) ?? watch.enabled;
|
||||
const checkReason = checkUnavailableReason(dirty, authoritativeEnabled);
|
||||
const checkReason = checkUnavailableReason(t, dirty, authoritativeEnabled);
|
||||
const stop = (action: () => void) => (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
action();
|
||||
@@ -99,7 +110,7 @@ function WatchActions<TWatch extends Watch>({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${size} p-0 cursor-pointer`}
|
||||
aria-label="Edit watch"
|
||||
aria-label={t("gitlab:editWatch")}
|
||||
onClick={stop(() => onEdit(watch))}
|
||||
>
|
||||
<IconEdit className="h-4 w-4" />
|
||||
@@ -108,7 +119,7 @@ function WatchActions<TWatch extends Watch>({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${size} p-0 cursor-pointer`}
|
||||
aria-label={watch.enabled ? "Pause watch" : "Enable watch"}
|
||||
aria-label={watch.enabled ? t("gitlab:pauseWatch") : t("gitlab:enableWatch")}
|
||||
data-settings-dirty={dirty}
|
||||
onClick={stop(() => onToggleEnabled(watch))}
|
||||
>
|
||||
@@ -127,7 +138,7 @@ function WatchActions<TWatch extends Watch>({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${size} p-0 cursor-pointer`}
|
||||
aria-label="Reset watch"
|
||||
aria-label={t("gitlab:resetWatch")}
|
||||
onClick={stop(() => onReset(watch.id))}
|
||||
>
|
||||
<IconRestore className="h-4 w-4" />
|
||||
@@ -136,7 +147,7 @@ function WatchActions<TWatch extends Watch>({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${size} p-0 text-destructive hover:text-destructive cursor-pointer`}
|
||||
aria-label="Delete watch"
|
||||
aria-label={t("gitlab:deleteWatch")}
|
||||
onClick={stop(() => onDelete(watch.id))}
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
@@ -145,15 +156,20 @@ function WatchActions<TWatch extends Watch>({
|
||||
);
|
||||
}
|
||||
|
||||
function projectSummary(watch: Watch): string {
|
||||
// Project paths are user data (namespace/project as GitLab stores it) — only the
|
||||
// empty-selection fallback is copy.
|
||||
function projectSummary(t: TFunction, watch: Watch): string {
|
||||
return watch.projects.length > 0
|
||||
? watch.projects.map((project) => project.path).join(", ")
|
||||
: "All projects";
|
||||
: t("gitlab:allProjects");
|
||||
}
|
||||
|
||||
function lastPolled(value?: string): string {
|
||||
if (!value) return "Never";
|
||||
return new Date(value).toLocaleString();
|
||||
// `formatDateTime` follows the active app locale; the previous bare
|
||||
// `toLocaleString()` followed the browser's, so the timestamp could disagree with
|
||||
// the rest of the page after a language switch.
|
||||
function lastPolled(t: TFunction, value?: string): string {
|
||||
if (!value) return t("gitlab:never");
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function WatchError({ watch }: { watch: Watch }) {
|
||||
@@ -166,6 +182,7 @@ function WatchError({ watch }: { watch: Watch }) {
|
||||
}
|
||||
|
||||
function MobileWatchCard<TWatch extends Watch>(props: ActionProps<TWatch>) {
|
||||
const { t } = useTranslation();
|
||||
const { watch, onEdit } = props;
|
||||
return (
|
||||
<div
|
||||
@@ -177,15 +194,19 @@ function MobileWatchCard<TWatch extends Watch>(props: ActionProps<TWatch>) {
|
||||
className="block w-full min-w-0 text-left cursor-pointer"
|
||||
onClick={() => onEdit(watch)}
|
||||
>
|
||||
<span className="block truncate text-sm font-medium" title={projectSummary(watch)}>
|
||||
{projectSummary(watch)}
|
||||
<span className="block truncate text-sm font-medium" title={projectSummary(t, watch)}>
|
||||
{projectSummary(t, watch)}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant={watch.enabled ? "default" : "secondary"}>
|
||||
{watch.enabled ? "Active" : "Paused"}
|
||||
{watch.enabled ? t("gitlab:active") : t("gitlab:paused")}
|
||||
</Badge>
|
||||
<span>{Math.round(watch.poll_interval_seconds / 60)}m interval</span>
|
||||
<span>Last checked {lastPolled(watch.last_polled_at)}</span>
|
||||
<span>
|
||||
{t("gitlab:intervalMinutesLabel", {
|
||||
count: Math.round(watch.poll_interval_seconds / 60),
|
||||
})}
|
||||
</span>
|
||||
<span>{t("gitlab:lastCheckedAt", { value: lastPolled(t, watch.last_polled_at) })}</span>
|
||||
</span>
|
||||
<WatchError watch={watch} />
|
||||
</button>
|
||||
@@ -195,8 +216,13 @@ function MobileWatchCard<TWatch extends Watch>(props: ActionProps<TWatch>) {
|
||||
}
|
||||
|
||||
export function GitLabWatchTable<TWatch extends Watch>(props: WatchTableProps<TWatch>) {
|
||||
const { t } = useTranslation();
|
||||
if (props.watches.length === 0) {
|
||||
return <p className="py-6 text-center text-sm text-muted-foreground">No watches configured.</p>;
|
||||
return (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t("gitlab:noWatchesConfigured")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -212,11 +238,11 @@ export function GitLabWatchTable<TWatch extends Watch>(props: WatchTableProps<TW
|
||||
<Table className="min-w-[680px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Projects</TableHead>
|
||||
<TableHead>Interval</TableHead>
|
||||
<TableHead>Last checked</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead>{t("gitlab:projects")}</TableHead>
|
||||
<TableHead>{t("gitlab:interval")}</TableHead>
|
||||
<TableHead>{t("gitlab:lastChecked")}</TableHead>
|
||||
<TableHead>{t("common:status")}</TableHead>
|
||||
<TableHead className="text-right">{t("gitlab:actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -228,18 +254,22 @@ export function GitLabWatchTable<TWatch extends Watch>(props: WatchTableProps<TW
|
||||
onClick={() => props.onEdit(watch)}
|
||||
>
|
||||
<TableCell className="max-w-sm">
|
||||
<p className="truncate font-medium" title={projectSummary(watch)}>
|
||||
{projectSummary(watch)}
|
||||
<p className="truncate font-medium" title={projectSummary(t, watch)}>
|
||||
{projectSummary(t, watch)}
|
||||
</p>
|
||||
<WatchError watch={watch} />
|
||||
</TableCell>
|
||||
<TableCell>{Math.round(watch.poll_interval_seconds / 60)}m</TableCell>
|
||||
<TableCell>
|
||||
{t("gitlab:intervalMinutes", {
|
||||
count: Math.round(watch.poll_interval_seconds / 60),
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{lastPolled(watch.last_polled_at)}
|
||||
{lastPolled(t, watch.last_polled_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={watch.enabled ? "default" : "secondary"}>
|
||||
{watch.enabled ? "Active" : "Paused"}
|
||||
{watch.enabled ? t("gitlab:active") : t("gitlab:paused")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -81,7 +81,8 @@ test.describe("GitLab watch controls", () => {
|
||||
/delete 1 task/i,
|
||||
);
|
||||
await testPage.getByTestId("reset-watch-dialog-confirm").click();
|
||||
await expect(testPage.getByText(/Review watch reset; 1 task\(s\) deleted/)).toBeVisible();
|
||||
// Singular form: the toast is an i18next `_one`/`_other` plural, not "task(s)".
|
||||
await expect(testPage.getByText(/Review watch reset; 1 task deleted/)).toBeVisible();
|
||||
await kanban.goto();
|
||||
await expect(kanban.taskCardByTitle(taskTitle)).toHaveCount(1, {
|
||||
timeout: 20_000,
|
||||
|
||||
@@ -297,4 +297,28 @@ export const i18nGuardFiles = [
|
||||
"components/github/my-github/search-bar.tsx",
|
||||
"components/github/my-github/use-default-query-presets.ts",
|
||||
"components/github/repo-filter-selector.tsx",
|
||||
// Settings → Integrations → GitLab: the page and the settings subtree of
|
||||
// `components/gitlab` — the connection card, the quick-action presets, the
|
||||
// review/issue watch sections, and the shared watch dialog, watch table and
|
||||
// delete-confirmation they render through. The merge-request task surface
|
||||
// (`mr-*`), `my-gitlab/**` and the task/quick-launcher components live in the
|
||||
// same directory and are NOT migrated, which is why this is a file list rather
|
||||
// than `components/gitlab/**`.
|
||||
//
|
||||
// `watch-form.ts` holds no JSX, so `mode: "jsx-only"` never inspects it; the
|
||||
// entry records that it is migrated (its two default prompt templates are
|
||||
// deliberately untranslated, being persisted and sent to the agent verbatim),
|
||||
// and only the pseudo-locale can prove it stays that way.
|
||||
"app/settings/integrations/gitlab/**/*.{ts,tsx}",
|
||||
"components/gitlab/action-presets-section.tsx",
|
||||
"components/gitlab/delete-watch-dialog.tsx",
|
||||
"components/gitlab/gitlab-settings.tsx",
|
||||
"components/gitlab/issue-watch-dialog.tsx",
|
||||
"components/gitlab/issue-watch-table.tsx",
|
||||
"components/gitlab/review-watch-dialog.tsx",
|
||||
"components/gitlab/review-watch-table.tsx",
|
||||
"components/gitlab/watch-dialog.tsx",
|
||||
"components/gitlab/watch-form.ts",
|
||||
"components/gitlab/watch-settings.tsx",
|
||||
"components/gitlab/watch-table.tsx",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"actionHint": "Action hint {{index}}",
|
||||
"actionLabel": "Action label {{index}}",
|
||||
"actionPrompt": "Action prompt {{index}}",
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
"addIssueAction": "Add issue action",
|
||||
"addMrAction": "Add MR action",
|
||||
"agentProfile": "Agent profile",
|
||||
"allProjects": "All projects",
|
||||
"alwaysDelete": "Always delete",
|
||||
"authenticationMethod": "Authentication method",
|
||||
"auto": "Auto",
|
||||
"autoKeepEngagedTasks": "Auto; keep engaged tasks",
|
||||
"between60And3600Seconds": "Between 60 and 3600 seconds.",
|
||||
"checkNow": "Check now",
|
||||
"chooseAWorkspacePatTheLocal": "Choose a workspace PAT, the local glab CLI login, or an environment-provided token. CLI and environment credentials must already be available to the kandev backend.",
|
||||
"chooseWhetherToIncludeOnlyDirect": "Choose whether to include only direct reviewer requests or the broader compatible scope.",
|
||||
"cleanupPolicy": "Cleanup policy",
|
||||
"clearToken": "Clear token",
|
||||
"connectAGitlabAccountSoKandev": "Connect a GitLab account so kandev can open merge requests, read review discussions, and reply to / resolve them on your behalf.",
|
||||
"connected": "Connected",
|
||||
"controlsTaskDeletionWhenTheGitlab": "Controls task deletion when the GitLab item closes.",
|
||||
"couldNotReachHost": "Couldn't reach <1>{{host}}</1>: {{error}}",
|
||||
"createIssueWatch": "Create GitLab issue watch",
|
||||
"createReviewWatch": "Create GitLab review watch",
|
||||
"createWatch": "Create watch",
|
||||
"deleteWatch": "Delete watch",
|
||||
"deleteWatchTitle": "Delete {{label}}?",
|
||||
"deleting": "Deleting...",
|
||||
"directAndGroupCompatibleRequests": "Direct and group-compatible requests",
|
||||
"directRequests": "Direct requests",
|
||||
"editIssueWatch": "Edit GitLab issue watch",
|
||||
"editReviewWatch": "Edit GitLab review watch",
|
||||
"editWatch": "Edit watch",
|
||||
"enableThisWatchBeforeCheckingNow": "Enable this watch before checking now",
|
||||
"enableWatch": "Enable watch",
|
||||
"enterAPersonalAccessTokenToSwitch": "Enter a personal access token to switch to PAT.",
|
||||
"enterAValidHttpOrHttpsGitlabHost": "Enter a valid HTTP or HTTPS GitLab host URL.",
|
||||
"environmentToken": "Environment token",
|
||||
"everyQuickActionNeedsALabel": "Every quick action needs a label and prompt.",
|
||||
"executorProfile": "Executor profile",
|
||||
"failedToClearToken": "Failed to clear token",
|
||||
"failedToResetQuickActions": "Failed to reset quick actions",
|
||||
"failedToSaveQuickActions": "Failed to save quick actions",
|
||||
"failedToUpdateAuthenticationMethod": "Failed to update authentication method",
|
||||
"foundMatchingIssues_one": "Found {{count}} matching issue",
|
||||
"foundMatchingIssues_other": "Found {{count}} matching issues",
|
||||
"foundMatchingMergeRequests_one": "Found {{count}} matching merge request",
|
||||
"foundMatchingMergeRequests_other": "Found {{count}} matching merge requests",
|
||||
"gitlabAuthenticationMethodUpdated": "GitLab authentication method updated",
|
||||
"gitlabHostUrlOverrideForSelf": "GitLab host URL. Override for self-managed instances; leave at the default for {{host}}.",
|
||||
"gitlabIssueWatch": "GitLab issue watch",
|
||||
"gitlabQueryParameters": "GitLab query parameters",
|
||||
"gitlabQuickActionsReset": "GitLab quick actions reset",
|
||||
"gitlabQuickActionsSaved": "GitLab quick actions saved",
|
||||
"gitlabReviewWatch": "GitLab review watch",
|
||||
"gitlabTokenCleared": "GitLab token cleared",
|
||||
"gitlabWatchCouldNotBeSaved": "GitLab watch could not be saved",
|
||||
"glabCli": "glab CLI",
|
||||
"hideToken": "Hide token",
|
||||
"initialStepForEachNewTask": "Initial step for each new task.",
|
||||
"interval": "Interval",
|
||||
"intervalMinutes_one": "{{count}}m",
|
||||
"intervalMinutes_other": "{{count}}m",
|
||||
"intervalMinutesLabel_one": "{{count}}m interval",
|
||||
"intervalMinutesLabel_other": "{{count}}m interval",
|
||||
"issues": "Issues",
|
||||
"issueWatchCheckFailed": "Issue watch check failed",
|
||||
"issueWatchCreated": "Issue watch created",
|
||||
"issueWatchCreationFailed": "Issue watch creation failed",
|
||||
"issueWatchDeleted": "Issue watch deleted",
|
||||
"issueWatchDeletionFailed": "Issue watch deletion failed",
|
||||
"issueWatchDialogDescription": "Automatically create workspace tasks for newly matching GitLab issues.",
|
||||
"issueWatches": "Issue watches",
|
||||
"issueWatchReset_one": "Issue watch reset; {{count}} task deleted",
|
||||
"issueWatchReset_other": "Issue watch reset; {{count}} tasks deleted",
|
||||
"issueWatchResetFailed": "Issue watch reset failed",
|
||||
"issueWatchUpdated": "Issue watch updated",
|
||||
"issueWatchUpdateFailed": "Issue watch update failed",
|
||||
"label": "Label",
|
||||
"labels": "Labels",
|
||||
"lastChecked": "Last checked",
|
||||
"lastCheckedAt": "Last checked {{value}}",
|
||||
"leaveEmptyToMatchMergeRequests": "Leave empty to match merge requests requesting your review. Adding parameters explicitly replaces that reviewer constraint.",
|
||||
"loggedInAs": "Logged in as <1>{{username}}</1>",
|
||||
"match": "Match",
|
||||
"maximumInFlightTasks": "Maximum in-flight tasks",
|
||||
"mergeRequestReviewWatches": "Merge request review watches",
|
||||
"mergeRequests": "Merge requests",
|
||||
"mockTest": "Mock (test)",
|
||||
"never": "Never",
|
||||
"neverDelete": "Never delete",
|
||||
"newWatch": "New watch",
|
||||
"noLimit": "No limit",
|
||||
"noNewIssuesMatched": "No new issues matched",
|
||||
"noNewMergeRequestsMatched": "No new merge requests matched",
|
||||
"notConfigured": "Not configured",
|
||||
"notConnected": "Not connected",
|
||||
"noWatchesConfigured": "No watches configured.",
|
||||
"optionalCapPollingResumesWhenActive": "Optional cap; polling resumes when active tasks fall below it.",
|
||||
"optionalCommaSeparatedGitlabLabelsThat": "Optional comma-separated GitLab labels that matching issues must include.",
|
||||
"optionalCommaSeparatedNamespaceProjectPaths": "Optional comma-separated namespace/project paths. Empty watches every accessible project.",
|
||||
"optionalGitlabApiQueryParametersSuch": "Optional GitLab API query parameters, such as {{example}}.",
|
||||
"optionalOtherwiseUsesTheWorkflowStep": "Optional; otherwise uses the workflow step default.",
|
||||
"paused": "Paused",
|
||||
"pauseWatch": "Pause watch",
|
||||
"personalAccessToken": "Personal access token",
|
||||
"pollGitlabForMergeRequestsAwaiting": "Poll GitLab for merge requests awaiting review and create one task per new match.",
|
||||
"pollGitlabIssuesAndCreateOne": "Poll GitLab issues and create one task per new match.",
|
||||
"pollingAndCleanup": "Polling and cleanup",
|
||||
"pollIntervalSeconds": "Poll interval (seconds)",
|
||||
"projectPaths": "Project paths",
|
||||
"projects": "Projects",
|
||||
"promptSentToTheSelectedAgent": "Prompt sent to the selected agent profile for each new match.",
|
||||
"promptUsingUrlAndTitle": "Prompt using {{url}} and {{title}}",
|
||||
"quickActions": "Quick actions",
|
||||
"reconnectRequired": "Reconnect required",
|
||||
"refresh": "Refresh",
|
||||
"remove": "Remove {{label}}",
|
||||
"resetQuickActionsToDefaults": "Reset quick actions to defaults",
|
||||
"resetWatch": "Reset watch",
|
||||
"reviewScope": "Review scope",
|
||||
"reviewWatchCheckFailed": "Review watch check failed",
|
||||
"reviewWatchCreated": "Review watch created",
|
||||
"reviewWatchCreationFailed": "Review watch creation failed",
|
||||
"reviewWatchDeleted": "Review watch deleted",
|
||||
"reviewWatchDeletionFailed": "Review watch deletion failed",
|
||||
"reviewWatchDialogDescription": "Automatically create workspace tasks for newly matching GitLab merge requests.",
|
||||
"reviewWatchReset_one": "Review watch reset; {{count}} task deleted",
|
||||
"reviewWatchReset_other": "Review watch reset; {{count}} tasks deleted",
|
||||
"reviewWatchResetFailed": "Review watch reset failed",
|
||||
"reviewWatchUpdated": "Review watch updated",
|
||||
"reviewWatchUpdateFailed": "Review watch update failed",
|
||||
"saveChangesBeforeCheckingNow": "Save changes before checking now",
|
||||
"saving": "Saving...",
|
||||
"selectWorkflow": "Select workflow",
|
||||
"shortHint": "Short hint",
|
||||
"showToken": "Show token",
|
||||
"taskAutomation": "Task automation",
|
||||
"taskPrompt": "Task prompt",
|
||||
"taskPromptsShownOnTheGitlab": "Task prompts shown on the GitLab browse page for merge requests and issues.",
|
||||
"thisWillDeleteEveryTaskCreated": "This will delete every task created by this watch, including archived tasks, and remove its polling history. This cannot be undone.",
|
||||
"unreachable": "Unreachable",
|
||||
"updateWatch": "Update watch",
|
||||
"watchDeletionFailed": "Watch deletion failed",
|
||||
"workflow": "Workflow",
|
||||
"workflowStep": "Workflow step",
|
||||
"workflowThatReceivesNewTasks": "Workflow that receives new tasks.",
|
||||
"yourTokenMayStillBeValid": "Your token may still be valid — this looks like a network or upstream issue."
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"actionHint": "Àćţĩōń ĥĩńţ {{index}}",
|
||||
"actionLabel": "Àćţĩōń ĺàƀēĺ {{index}}",
|
||||
"actionPrompt": "Àćţĩōń ƥŕōḿƥţ {{index}}",
|
||||
"actions": "Àćţĩōńś",
|
||||
"active": "Àćţĩvē",
|
||||
"addIssueAction": "Àďď ĩśśũē àćţĩōń",
|
||||
"addMrAction": "Àďď ḾŔ àćţĩōń",
|
||||
"agentProfile": "Àĝēńţ ƥŕōƒĩĺē",
|
||||
"allProjects": "Àĺĺ ƥŕōĵēćţś",
|
||||
"alwaysDelete": "Àĺŵàŷś ďēĺēţē",
|
||||
"authenticationMethod": "Àũţĥēńţĩćàţĩōń ḿēţĥōď",
|
||||
"auto": "Àũţō",
|
||||
"autoKeepEngagedTasks": "Àũţō; ķēēƥ ēńĝàĝēď ţàśķś",
|
||||
"between60And3600Seconds": "Ɓēţŵēēń 60 àńď 3600 śēćōńďś.",
|
||||
"checkNow": "Ćĥēćķ ńōŵ",
|
||||
"chooseAWorkspacePatTheLocal": "Ćĥōōśē à ŵōŕķśƥàćē ƤÀŢ, ţĥē ĺōćàĺ ĝĺàƀ ĆĹĨ ĺōĝĩń, ōŕ àń ēńvĩŕōńḿēńţ-ƥŕōvĩďēď ţōķēń. ĆĹĨ àńď ēńvĩŕōńḿēńţ ćŕēďēńţĩàĺś ḿũśţ àĺŕēàďŷ ƀē àvàĩĺàƀĺē ţō ţĥē ķàńďēv ƀàćķēńď.",
|
||||
"chooseWhetherToIncludeOnlyDirect": "Ćĥōōśē ŵĥēţĥēŕ ţō ĩńćĺũďē ōńĺŷ ďĩŕēćţ ŕēvĩēŵēŕ ŕēqũēśţś ōŕ ţĥē ƀŕōàďēŕ ćōḿƥàţĩƀĺē śćōƥē.",
|
||||
"cleanupPolicy": "Ćĺēàńũƥ ƥōĺĩćŷ",
|
||||
"clearToken": "Ćĺēàŕ ţōķēń",
|
||||
"connectAGitlabAccountSoKandev": "Ćōńńēćţ à ĜĩţĹàƀ àććōũńţ śō ķàńďēv ćàń ōƥēń ḿēŕĝē ŕēqũēśţś, ŕēàď ŕēvĩēŵ ďĩśćũśśĩōńś, àńď ŕēƥĺŷ ţō / ŕēśōĺvē ţĥēḿ ōń ŷōũŕ ƀēĥàĺƒ.",
|
||||
"connected": "Ćōńńēćţēď",
|
||||
"controlsTaskDeletionWhenTheGitlab": "Ćōńţŕōĺś ţàśķ ďēĺēţĩōń ŵĥēń ţĥē ĜĩţĹàƀ ĩţēḿ ćĺōśēś.",
|
||||
"couldNotReachHost": "Ćōũĺďń'ţ ŕēàćĥ <1>{{host}}</1>: {{error}}",
|
||||
"createIssueWatch": "Ćŕēàţē ĜĩţĹàƀ ĩśśũē ŵàţćĥ",
|
||||
"createReviewWatch": "Ćŕēàţē ĜĩţĹàƀ ŕēvĩēŵ ŵàţćĥ",
|
||||
"createWatch": "Ćŕēàţē ŵàţćĥ",
|
||||
"deleteWatch": "Ďēĺēţē ŵàţćĥ",
|
||||
"deleteWatchTitle": "Ďēĺēţē {{label}}?",
|
||||
"deleting": "Ďēĺēţĩńĝ...",
|
||||
"directAndGroupCompatibleRequests": "Ďĩŕēćţ àńď ĝŕōũƥ-ćōḿƥàţĩƀĺē ŕēqũēśţś",
|
||||
"directRequests": "Ďĩŕēćţ ŕēqũēśţś",
|
||||
"editIssueWatch": "Ēďĩţ ĜĩţĹàƀ ĩśśũē ŵàţćĥ",
|
||||
"editReviewWatch": "Ēďĩţ ĜĩţĹàƀ ŕēvĩēŵ ŵàţćĥ",
|
||||
"editWatch": "Ēďĩţ ŵàţćĥ",
|
||||
"enableThisWatchBeforeCheckingNow": "Ēńàƀĺē ţĥĩś ŵàţćĥ ƀēƒōŕē ćĥēćķĩńĝ ńōŵ",
|
||||
"enableWatch": "Ēńàƀĺē ŵàţćĥ",
|
||||
"enterAPersonalAccessTokenToSwitch": "Ēńţēŕ à ƥēŕśōńàĺ àććēśś ţōķēń ţō śŵĩţćĥ ţō ƤÀŢ.",
|
||||
"enterAValidHttpOrHttpsGitlabHost": "Ēńţēŕ à vàĺĩď ĤŢŢƤ ōŕ ĤŢŢƤŚ ĜĩţĹàƀ ĥōśţ ŨŔĹ.",
|
||||
"environmentToken": "Ēńvĩŕōńḿēńţ ţōķēń",
|
||||
"everyQuickActionNeedsALabel": "Ēvēŕŷ qũĩćķ àćţĩōń ńēēďś à ĺàƀēĺ àńď ƥŕōḿƥţ.",
|
||||
"executorProfile": "Ēxēćũţōŕ ƥŕōƒĩĺē",
|
||||
"failedToClearToken": "Ƒàĩĺēď ţō ćĺēàŕ ţōķēń",
|
||||
"failedToResetQuickActions": "Ƒàĩĺēď ţō ŕēśēţ qũĩćķ àćţĩōńś",
|
||||
"failedToSaveQuickActions": "Ƒàĩĺēď ţō śàvē qũĩćķ àćţĩōńś",
|
||||
"failedToUpdateAuthenticationMethod": "Ƒàĩĺēď ţō ũƥďàţē àũţĥēńţĩćàţĩōń ḿēţĥōď",
|
||||
"foundMatchingIssues_one": "Ƒōũńď {{count}} ḿàţćĥĩńĝ ĩśśũē",
|
||||
"foundMatchingIssues_other": "Ƒōũńď {{count}} ḿàţćĥĩńĝ ĩśśũēś",
|
||||
"foundMatchingMergeRequests_one": "Ƒōũńď {{count}} ḿàţćĥĩńĝ ḿēŕĝē ŕēqũēśţ",
|
||||
"foundMatchingMergeRequests_other": "Ƒōũńď {{count}} ḿàţćĥĩńĝ ḿēŕĝē ŕēqũēśţś",
|
||||
"gitlabAuthenticationMethodUpdated": "ĜĩţĹàƀ àũţĥēńţĩćàţĩōń ḿēţĥōď ũƥďàţēď",
|
||||
"gitlabHostUrlOverrideForSelf": "ĜĩţĹàƀ ĥōśţ ŨŔĹ. Ōvēŕŕĩďē ƒōŕ śēĺƒ-ḿàńàĝēď ĩńśţàńćēś; ĺēàvē àţ ţĥē ďēƒàũĺţ ƒōŕ {{host}}.",
|
||||
"gitlabIssueWatch": "ĜĩţĹàƀ ĩśśũē ŵàţćĥ",
|
||||
"gitlabQueryParameters": "ĜĩţĹàƀ qũēŕŷ ƥàŕàḿēţēŕś",
|
||||
"gitlabQuickActionsReset": "ĜĩţĹàƀ qũĩćķ àćţĩōńś ŕēśēţ",
|
||||
"gitlabQuickActionsSaved": "ĜĩţĹàƀ qũĩćķ àćţĩōńś śàvēď",
|
||||
"gitlabReviewWatch": "ĜĩţĹàƀ ŕēvĩēŵ ŵàţćĥ",
|
||||
"gitlabTokenCleared": "ĜĩţĹàƀ ţōķēń ćĺēàŕēď",
|
||||
"gitlabWatchCouldNotBeSaved": "ĜĩţĹàƀ ŵàţćĥ ćōũĺď ńōţ ƀē śàvēď",
|
||||
"glabCli": "ĝĺàƀ ĆĹĨ",
|
||||
"hideToken": "Ĥĩďē ţōķēń",
|
||||
"initialStepForEachNewTask": "Ĩńĩţĩàĺ śţēƥ ƒōŕ ēàćĥ ńēŵ ţàśķ.",
|
||||
"interval": "Ĩńţēŕvàĺ",
|
||||
"intervalMinutes_one": "{{count}}ḿ",
|
||||
"intervalMinutes_other": "{{count}}ḿ",
|
||||
"intervalMinutesLabel_one": "{{count}}ḿ ĩńţēŕvàĺ",
|
||||
"intervalMinutesLabel_other": "{{count}}ḿ ĩńţēŕvàĺ",
|
||||
"issues": "Ĩśśũēś",
|
||||
"issueWatchCheckFailed": "Ĩśśũē ŵàţćĥ ćĥēćķ ƒàĩĺēď",
|
||||
"issueWatchCreated": "Ĩśśũē ŵàţćĥ ćŕēàţēď",
|
||||
"issueWatchCreationFailed": "Ĩśśũē ŵàţćĥ ćŕēàţĩōń ƒàĩĺēď",
|
||||
"issueWatchDeleted": "Ĩśśũē ŵàţćĥ ďēĺēţēď",
|
||||
"issueWatchDeletionFailed": "Ĩśśũē ŵàţćĥ ďēĺēţĩōń ƒàĩĺēď",
|
||||
"issueWatchDialogDescription": "Àũţōḿàţĩćàĺĺŷ ćŕēàţē ŵōŕķśƥàćē ţàśķś ƒōŕ ńēŵĺŷ ḿàţćĥĩńĝ ĜĩţĹàƀ ĩśśũēś.",
|
||||
"issueWatches": "Ĩśśũē ŵàţćĥēś",
|
||||
"issueWatchReset_one": "Ĩśśũē ŵàţćĥ ŕēśēţ; {{count}} ţàśķ ďēĺēţēď",
|
||||
"issueWatchReset_other": "Ĩśśũē ŵàţćĥ ŕēśēţ; {{count}} ţàśķś ďēĺēţēď",
|
||||
"issueWatchResetFailed": "Ĩśśũē ŵàţćĥ ŕēśēţ ƒàĩĺēď",
|
||||
"issueWatchUpdated": "Ĩśśũē ŵàţćĥ ũƥďàţēď",
|
||||
"issueWatchUpdateFailed": "Ĩśśũē ŵàţćĥ ũƥďàţē ƒàĩĺēď",
|
||||
"label": "Ĺàƀēĺ",
|
||||
"labels": "Ĺàƀēĺś",
|
||||
"lastChecked": "Ĺàśţ ćĥēćķēď",
|
||||
"lastCheckedAt": "Ĺàśţ ćĥēćķēď {{value}}",
|
||||
"leaveEmptyToMatchMergeRequests": "Ĺēàvē ēḿƥţŷ ţō ḿàţćĥ ḿēŕĝē ŕēqũēśţś ŕēqũēśţĩńĝ ŷōũŕ ŕēvĩēŵ. Àďďĩńĝ ƥàŕàḿēţēŕś ēxƥĺĩćĩţĺŷ ŕēƥĺàćēś ţĥàţ ŕēvĩēŵēŕ ćōńśţŕàĩńţ.",
|
||||
"loggedInAs": "Ĺōĝĝēď ĩń àś <1>{{username}}</1>",
|
||||
"match": "Ḿàţćĥ",
|
||||
"maximumInFlightTasks": "Ḿàxĩḿũḿ ĩń-ƒĺĩĝĥţ ţàśķś",
|
||||
"mergeRequestReviewWatches": "Ḿēŕĝē ŕēqũēśţ ŕēvĩēŵ ŵàţćĥēś",
|
||||
"mergeRequests": "Ḿēŕĝē ŕēqũēśţś",
|
||||
"mockTest": "Ḿōćķ (ţēśţ)",
|
||||
"never": "Ńēvēŕ",
|
||||
"neverDelete": "Ńēvēŕ ďēĺēţē",
|
||||
"newWatch": "Ńēŵ ŵàţćĥ",
|
||||
"noLimit": "Ńō ĺĩḿĩţ",
|
||||
"noNewIssuesMatched": "Ńō ńēŵ ĩśśũēś ḿàţćĥēď",
|
||||
"noNewMergeRequestsMatched": "Ńō ńēŵ ḿēŕĝē ŕēqũēśţś ḿàţćĥēď",
|
||||
"notConfigured": "Ńōţ ćōńƒĩĝũŕēď",
|
||||
"notConnected": "Ńōţ ćōńńēćţēď",
|
||||
"noWatchesConfigured": "Ńō ŵàţćĥēś ćōńƒĩĝũŕēď.",
|
||||
"optionalCapPollingResumesWhenActive": "Ōƥţĩōńàĺ ćàƥ; ƥōĺĺĩńĝ ŕēśũḿēś ŵĥēń àćţĩvē ţàśķś ƒàĺĺ ƀēĺōŵ ĩţ.",
|
||||
"optionalCommaSeparatedGitlabLabelsThat": "Ōƥţĩōńàĺ ćōḿḿà-śēƥàŕàţēď ĜĩţĹàƀ ĺàƀēĺś ţĥàţ ḿàţćĥĩńĝ ĩśśũēś ḿũśţ ĩńćĺũďē.",
|
||||
"optionalCommaSeparatedNamespaceProjectPaths": "Ōƥţĩōńàĺ ćōḿḿà-śēƥàŕàţēď ńàḿēśƥàćē/ƥŕōĵēćţ ƥàţĥś. Ēḿƥţŷ ŵàţćĥēś ēvēŕŷ àććēśśĩƀĺē ƥŕōĵēćţ.",
|
||||
"optionalGitlabApiQueryParametersSuch": "Ōƥţĩōńàĺ ĜĩţĹàƀ ÀƤĨ qũēŕŷ ƥàŕàḿēţēŕś, śũćĥ àś {{example}}.",
|
||||
"optionalOtherwiseUsesTheWorkflowStep": "Ōƥţĩōńàĺ; ōţĥēŕŵĩśē ũśēś ţĥē ŵōŕķƒĺōŵ śţēƥ ďēƒàũĺţ.",
|
||||
"paused": "Ƥàũśēď",
|
||||
"pauseWatch": "Ƥàũśē ŵàţćĥ",
|
||||
"personalAccessToken": "Ƥēŕśōńàĺ àććēśś ţōķēń",
|
||||
"pollGitlabForMergeRequestsAwaiting": "Ƥōĺĺ ĜĩţĹàƀ ƒōŕ ḿēŕĝē ŕēqũēśţś àŵàĩţĩńĝ ŕēvĩēŵ àńď ćŕēàţē ōńē ţàśķ ƥēŕ ńēŵ ḿàţćĥ.",
|
||||
"pollGitlabIssuesAndCreateOne": "Ƥōĺĺ ĜĩţĹàƀ ĩśśũēś àńď ćŕēàţē ōńē ţàśķ ƥēŕ ńēŵ ḿàţćĥ.",
|
||||
"pollingAndCleanup": "Ƥōĺĺĩńĝ àńď ćĺēàńũƥ",
|
||||
"pollIntervalSeconds": "Ƥōĺĺ ĩńţēŕvàĺ (śēćōńďś)",
|
||||
"projectPaths": "Ƥŕōĵēćţ ƥàţĥś",
|
||||
"projects": "Ƥŕōĵēćţś",
|
||||
"promptSentToTheSelectedAgent": "Ƥŕōḿƥţ śēńţ ţō ţĥē śēĺēćţēď àĝēńţ ƥŕōƒĩĺē ƒōŕ ēàćĥ ńēŵ ḿàţćĥ.",
|
||||
"promptUsingUrlAndTitle": "Ƥŕōḿƥţ ũśĩńĝ {{url}} àńď {{title}}",
|
||||
"quickActions": "Qũĩćķ àćţĩōńś",
|
||||
"reconnectRequired": "Ŕēćōńńēćţ ŕēqũĩŕēď",
|
||||
"refresh": "Ŕēƒŕēśĥ",
|
||||
"remove": "Ŕēḿōvē {{label}}",
|
||||
"resetQuickActionsToDefaults": "Ŕēśēţ qũĩćķ àćţĩōńś ţō ďēƒàũĺţś",
|
||||
"resetWatch": "Ŕēśēţ ŵàţćĥ",
|
||||
"reviewScope": "Ŕēvĩēŵ śćōƥē",
|
||||
"reviewWatchCheckFailed": "Ŕēvĩēŵ ŵàţćĥ ćĥēćķ ƒàĩĺēď",
|
||||
"reviewWatchCreated": "Ŕēvĩēŵ ŵàţćĥ ćŕēàţēď",
|
||||
"reviewWatchCreationFailed": "Ŕēvĩēŵ ŵàţćĥ ćŕēàţĩōń ƒàĩĺēď",
|
||||
"reviewWatchDeleted": "Ŕēvĩēŵ ŵàţćĥ ďēĺēţēď",
|
||||
"reviewWatchDeletionFailed": "Ŕēvĩēŵ ŵàţćĥ ďēĺēţĩōń ƒàĩĺēď",
|
||||
"reviewWatchDialogDescription": "Àũţōḿàţĩćàĺĺŷ ćŕēàţē ŵōŕķśƥàćē ţàśķś ƒōŕ ńēŵĺŷ ḿàţćĥĩńĝ ĜĩţĹàƀ ḿēŕĝē ŕēqũēśţś.",
|
||||
"reviewWatchReset_one": "Ŕēvĩēŵ ŵàţćĥ ŕēśēţ; {{count}} ţàśķ ďēĺēţēď",
|
||||
"reviewWatchReset_other": "Ŕēvĩēŵ ŵàţćĥ ŕēśēţ; {{count}} ţàśķś ďēĺēţēď",
|
||||
"reviewWatchResetFailed": "Ŕēvĩēŵ ŵàţćĥ ŕēśēţ ƒàĩĺēď",
|
||||
"reviewWatchUpdated": "Ŕēvĩēŵ ŵàţćĥ ũƥďàţēď",
|
||||
"reviewWatchUpdateFailed": "Ŕēvĩēŵ ŵàţćĥ ũƥďàţē ƒàĩĺēď",
|
||||
"saveChangesBeforeCheckingNow": "Śàvē ćĥàńĝēś ƀēƒōŕē ćĥēćķĩńĝ ńōŵ",
|
||||
"saving": "Śàvĩńĝ...",
|
||||
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
|
||||
"shortHint": "Śĥōŕţ ĥĩńţ",
|
||||
"showToken": "Śĥōŵ ţōķēń",
|
||||
"taskAutomation": "Ţàśķ àũţōḿàţĩōń",
|
||||
"taskPrompt": "Ţàśķ ƥŕōḿƥţ",
|
||||
"taskPromptsShownOnTheGitlab": "Ţàśķ ƥŕōḿƥţś śĥōŵń ōń ţĥē ĜĩţĹàƀ ƀŕōŵśē ƥàĝē ƒōŕ ḿēŕĝē ŕēqũēśţś àńď ĩśśũēś.",
|
||||
"thisWillDeleteEveryTaskCreated": "Ţĥĩś ŵĩĺĺ ďēĺēţē ēvēŕŷ ţàśķ ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥ, ĩńćĺũďĩńĝ àŕćĥĩvēď ţàśķś, àńď ŕēḿōvē ĩţś ƥōĺĺĩńĝ ĥĩśţōŕŷ. Ţĥĩś ćàńńōţ ƀē ũńďōńē.",
|
||||
"unreachable": "Ũńŕēàćĥàƀĺē",
|
||||
"updateWatch": "Ũƥďàţē ŵàţćĥ",
|
||||
"watchDeletionFailed": "Ŵàţćĥ ďēĺēţĩōń ƒàĩĺēď",
|
||||
"workflow": "Ŵōŕķƒĺōŵ",
|
||||
"workflowStep": "Ŵōŕķƒĺōŵ śţēƥ",
|
||||
"workflowThatReceivesNewTasks": "Ŵōŕķƒĺōŵ ţĥàţ ŕēćēĩvēś ńēŵ ţàśķś.",
|
||||
"yourTokenMayStillBeValid": "Ŷōũŕ ţōķēń ḿàŷ śţĩĺĺ ƀē vàĺĩď — ţĥĩś ĺōōķś ĺĩķē à ńēţŵōŕķ ōŕ ũƥśţŕēàḿ ĩśśũē."
|
||||
}
|
||||
Reference in New Issue
Block a user