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:
João Salavisa
2026-08-02 19:45:46 +02:00
committed by GitHub
parent 2b960f63f3
commit 7326796df2
12 changed files with 680 additions and 185 deletions
@@ -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>
+62 -36
View File
@@ -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&apos;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&apos;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, "\\$&");
}
+76 -51
View File
@@ -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()}
+5
View File
@@ -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 =
+47 -39
View File
@@ -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={() => {
+61 -31
View File
@@ -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,
+24
View File
@@ -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",
];
+151
View File
@@ -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."
}
+151
View File
@@ -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àĺĩď — ţĥĩś ĺōōķś ĺĩķē à ńēţŵōŕķ ōŕ ũƥśţŕēàḿ ĩśśũē."
}