feat(i18n): localize Settings → Integrations → Azure DevOps and Slack (#2187)

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-03 02:48:02 +02:00
committed by GitHub
parent 5f4f5ca9b0
commit 282e118b14
19 changed files with 1597 additions and 533 deletions
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconPlus, IconRefresh, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
@@ -21,15 +22,43 @@ import {
DEFAULT_AZURE_WORK_ITEM_QUERIES,
} from "./azure-devops-workspace-defaults";
type QueryKind = "Work item" | "Pull request";
/**
* Wire discriminant for the two query kinds. These used to be the English
* phrases "Work item" / "Pull request", which made the same value both a
* `===` comparand and display copy; they are now identifiers, and the labels
* resolve through the key maps below.
*/
type QueryKind = "work-item" | "pull-request";
/** Title-cased kind noun, e.g. "Work item query label 1". */
const KIND_TITLE_KEYS: Record<QueryKind, string> = {
"work-item": "azuredevops:workItemKindTitle",
"pull-request": "azuredevops:pullRequestKindTitle",
};
/** Lower-cased kind noun for mid-sentence use, e.g. "Remove work item query 1". */
const KIND_LOWER_KEYS: Record<QueryKind, string> = {
"work-item": "azuredevops:workItemKindLower",
"pull-request": "azuredevops:pullRequestKindLower",
};
/** Azure DevOps identity shorthand accepted by the creator/reviewer filters. */
const AZURE_ME_TOKEN = "@me";
/** Route these presets drive; a path, so it is interpolated rather than translated. */
const AZURE_DEVOPS_ROUTE = "/azure-devops";
// `label` is PERSISTED as part of `AzureDevOpsQueryPreset` and is editable in
// the row below, so it must stay locale-neutral — the same contract as
// `newPreset` in components/github/default-queries-section.tsx. The seeded WIQL
// is Azure DevOps' query language, not copy.
function newQuery(kind: QueryKind): AzureDevOpsQueryPreset {
return {
id: `query_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
label: "New query",
group: "inbox",
filters:
kind === "Work item"
kind === "work-item"
? {
wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project",
top: 50,
@@ -59,21 +88,26 @@ function QueryGroupSelect({
dirty: boolean;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<Select value={value === "created" ? "created" : "inbox"} onValueChange={onChange}>
<SelectTrigger
className="h-11 w-full cursor-pointer sm:h-8"
aria-label={`${kind} query group ${index + 1}`}
aria-label={t("azuredevops:queryGroupAria", {
kind: t(KIND_TITLE_KEYS[kind]),
index: index + 1,
})}
data-settings-dirty={dirty}
>
<SelectValue />
</SelectTrigger>
{/* `value` is the persisted preset group; only the label is copy. */}
<SelectContent>
<SelectItem value="inbox" className="cursor-pointer">
Inbox
{t("azuredevops:groupInbox")}
</SelectItem>
<SelectItem value="created" className="cursor-pointer">
Created
{t("azuredevops:groupCreated")}
</SelectItem>
</SelectContent>
</Select>
@@ -115,13 +149,17 @@ function RemoveQueryButton({
index: number;
onRemove: () => void;
}) {
const { t } = useTranslation();
return (
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-full cursor-pointer text-destructive sm:h-8 sm:w-8"
aria-label={`Remove ${kind.toLowerCase()} query ${index + 1}`}
aria-label={t("azuredevops:removeQueryAria", {
kind: t(KIND_LOWER_KEYS[kind]),
index: index + 1,
})}
onClick={onRemove}
>
<IconTrash className="h-4 w-4" />
@@ -137,53 +175,56 @@ function WorkItemQueryRow({
onPatchFilters,
onRemove,
}: QueryRowProps) {
const { t } = useTranslation();
const kind = t(KIND_TITLE_KEYS["work-item"]);
return (
<div
className="grid grid-cols-2 gap-2 rounded-md border p-3 sm:grid-cols-[10rem_minmax(0,1fr)_5rem_7rem_auto] sm:items-end sm:p-2"
data-settings-dirty={JSON.stringify(preset) !== JSON.stringify(baseline)}
data-settings-dirty-level="container"
>
<QueryField label="Label">
<QueryField label={t("azuredevops:label")}>
<Input
className="h-11 w-full sm:h-8"
value={preset.label}
aria-label={`Work item query label ${index + 1}`}
aria-label={t("azuredevops:queryLabelAria", { kind, index: index + 1 })}
data-settings-dirty={preset.label !== baseline?.label}
onChange={(event) => onPatch({ label: event.target.value })}
/>
</QueryField>
{/* WIQL is the Azure DevOps query language, not copy. */}
<QueryField label="WIQL" className="col-span-2 row-start-2 sm:col-span-1 sm:row-auto">
<Input
className="h-11 w-full font-mono text-xs sm:h-8"
value={stringFilter(preset, "wiql")}
aria-label={`Work item query WIQL ${index + 1}`}
aria-label={t("azuredevops:queryWiqlAria", { kind, index: index + 1 })}
data-settings-dirty={
stringFilter(preset, "wiql") !== (baseline ? stringFilter(baseline, "wiql") : undefined)
}
onChange={(event) => onPatchFilters({ wiql: event.target.value })}
/>
</QueryField>
<QueryField label="Top">
<QueryField label={t("azuredevops:top")}>
<Input
type="number"
min={1}
max={200}
className="h-11 w-full sm:h-8"
value={numberFilter(preset, "top", 50)}
aria-label={`Work item query top ${index + 1}`}
aria-label={t("azuredevops:queryTopAria", { kind, index: index + 1 })}
onChange={(event) => onPatchFilters({ top: Number(event.target.value) || 1 })}
/>
</QueryField>
<QueryField label="Group">
<QueryField label={t("azuredevops:group")}>
<QueryGroupSelect
kind="Work item"
kind="work-item"
index={index}
value={preset.group}
dirty={preset.group !== baseline?.group}
onChange={(group) => onPatch({ group })}
/>
</QueryField>
<RemoveQueryButton kind="Work item" index={index} onRemove={onRemove} />
<RemoveQueryButton kind="work-item" index={index} onRemove={onRemove} />
</div>
);
}
@@ -196,68 +237,73 @@ function PullRequestQueryRow({
onPatchFilters,
onRemove,
}: QueryRowProps) {
const { t } = useTranslation();
const kind = t(KIND_TITLE_KEYS["pull-request"]);
const identityPlaceholder = t("azuredevops:identityIdPlaceholder", { token: AZURE_ME_TOKEN });
return (
<div
className="grid grid-cols-2 gap-2 rounded-md border p-3 sm:grid-cols-[10rem_8rem_minmax(0,1fr)_minmax(0,1fr)_7rem_auto] sm:items-end sm:p-2"
data-settings-dirty={JSON.stringify(preset) !== JSON.stringify(baseline)}
data-settings-dirty-level="container"
>
<QueryField label="Label">
<QueryField label={t("azuredevops:label")}>
<Input
className="h-11 w-full sm:h-8"
value={preset.label}
aria-label={`Pull request query label ${index + 1}`}
aria-label={t("azuredevops:queryLabelAria", { kind, index: index + 1 })}
data-settings-dirty={preset.label !== baseline?.label}
onChange={(event) => onPatch({ label: event.target.value })}
/>
</QueryField>
<QueryField label="Status">
<QueryField label={t("azuredevops:status")}>
<Select
value={stringFilter(preset, "status", "active")}
onValueChange={(status) => onPatchFilters({ status })}
>
<SelectTrigger
className="h-11 w-full cursor-pointer sm:h-8"
aria-label={`Pull request query status ${index + 1}`}
aria-label={t("azuredevops:queryStatusAria", { kind, index: index + 1 })}
>
<SelectValue />
</SelectTrigger>
{/* `value` is the Azure DevOps pull-request status sent on the wire;
only the item label is copy. */}
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="abandoned">Abandoned</SelectItem>
<SelectItem value="all">All</SelectItem>
<SelectItem value="active">{t("azuredevops:statusActive")}</SelectItem>
<SelectItem value="completed">{t("azuredevops:statusCompleted")}</SelectItem>
<SelectItem value="abandoned">{t("azuredevops:statusAbandoned")}</SelectItem>
<SelectItem value="all">{t("azuredevops:statusAll")}</SelectItem>
</SelectContent>
</Select>
</QueryField>
<QueryField label="Creator" className="col-span-2 sm:col-span-1">
<QueryField label={t("azuredevops:creator")} className="col-span-2 sm:col-span-1">
<Input
className="h-11 w-full sm:h-8"
value={stringFilter(preset, "creator")}
aria-label={`Pull request query creator ${index + 1}`}
placeholder="@me or identity ID"
aria-label={t("azuredevops:queryCreatorAria", { kind, index: index + 1 })}
placeholder={identityPlaceholder}
onChange={(event) => onPatchFilters({ creator: event.target.value })}
/>
</QueryField>
<QueryField label="Reviewer" className="col-span-2 sm:col-span-1">
<QueryField label={t("azuredevops:reviewer")} className="col-span-2 sm:col-span-1">
<Input
className="h-11 w-full sm:h-8"
value={stringFilter(preset, "reviewer")}
aria-label={`Pull request query reviewer ${index + 1}`}
placeholder="@me or identity ID"
aria-label={t("azuredevops:queryReviewerAria", { kind, index: index + 1 })}
placeholder={identityPlaceholder}
onChange={(event) => onPatchFilters({ reviewer: event.target.value })}
/>
</QueryField>
<QueryField label="Group">
<QueryField label={t("azuredevops:group")}>
<QueryGroupSelect
kind="Pull request"
kind="pull-request"
index={index}
value={preset.group}
dirty={preset.group !== baseline?.group}
onChange={(group) => onPatch({ group })}
/>
</QueryField>
<RemoveQueryButton kind="Pull request" index={index} onRemove={onRemove} />
<RemoveQueryButton kind="pull-request" index={index} onRemove={onRemove} />
</div>
);
}
@@ -273,13 +319,14 @@ function QueryEditor({
baseline: AzureDevOpsQueryPreset[];
onChange: (queries: AzureDevOpsQueryPreset[]) => void;
}) {
const { t } = useTranslation();
const patch = (index: number, values: Partial<AzureDevOpsQueryPreset>) =>
onChange(
queries.map((query, current) => (current === index ? { ...query, ...values } : query)),
);
const patchFilters = (index: number, values: Record<string, unknown>) =>
patch(index, { filters: { ...queries[index].filters, ...values } });
const Row = kind === "Work item" ? WorkItemQueryRow : PullRequestQueryRow;
const Row = kind === "work-item" ? WorkItemQueryRow : PullRequestQueryRow;
return (
<div className="space-y-2">
{queries.map((query, index) => (
@@ -300,7 +347,8 @@ function QueryEditor({
className="h-11 w-full cursor-pointer sm:h-8 sm:w-auto"
onClick={() => onChange([...queries, newQuery(kind)])}
>
<IconPlus className="h-4 w-4" /> Add {kind === "Pull request" ? "PR" : "work item"} query
<IconPlus className="h-4 w-4" />{" "}
{kind === "pull-request" ? t("azuredevops:addPrQuery") : t("azuredevops:addWorkItemQuery")}
</Button>
</div>
);
@@ -383,7 +431,7 @@ function validQueries(queries: AzureDevOpsQueryPreset[], kind: QueryKind): boole
queries.every(
(query) =>
query.label.trim() &&
(kind === "Pull request"
(kind === "pull-request"
? stringFilter(query, "status", "active")
: stringFilter(query, "wiql")),
)
@@ -391,38 +439,40 @@ function validQueries(queries: AzureDevOpsQueryPreset[], kind: QueryKind): boole
}
export function AzureDevOpsDefaultQueriesSection({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const drafts = useQueryDrafts(workspaceId);
const { toast } = useToast();
const valid =
validQueries(drafts.pullRequests, "Pull request") &&
validQueries(drafts.workItems, "Work item");
validQueries(drafts.pullRequests, "pull-request") &&
validQueries(drafts.workItems, "work-item");
const save = useCallback(async () => {
try {
await drafts.save();
toast({ description: "Azure DevOps default queries saved", variant: "success" });
toast({ description: t("azuredevops:defaultQueriesSaved"), variant: "success" });
} catch (error) {
toast({
description: error instanceof Error ? error.message : "Failed to save default queries",
description:
error instanceof Error ? error.message : t("azuredevops:failedToSaveDefaultQueries"),
variant: "error",
});
throw error;
}
}, [drafts, toast]);
}, [drafts, t, toast]);
useSettingsSaveContributor({
id: `azure-devops-default-queries:${workspaceId}`,
revision: JSON.stringify([drafts.pullRequests, drafts.workItems]),
isDirty: drafts.dirty,
canSave: !drafts.loading && valid,
invalidReason: valid ? undefined : "Every default query needs a label and valid filters.",
invalidReason: valid ? undefined : t("azuredevops:defaultQueryInvalid"),
save,
discard: drafts.discard,
});
return (
<SettingsSection
title="Default queries"
description="Query shortcuts shown on /azure-devops for pull requests and work items."
title={t("azuredevops:defaultQueries")}
description={t("azuredevops:defaultQueriesDescription", { path: AZURE_DEVOPS_ROUTE })}
action={
<Button
type="button"
@@ -432,7 +482,7 @@ export function AzureDevOpsDefaultQueriesSection({ workspaceId }: { workspaceId:
disabled={drafts.loading}
onClick={drafts.reset}
>
<IconRefresh className="h-4 w-4" /> Reset
<IconRefresh className="h-4 w-4" /> {t("common:reset")}
</Button>
}
>
@@ -442,15 +492,15 @@ export function AzureDevOpsDefaultQueriesSection({ workspaceId }: { workspaceId:
<Tabs defaultValue="pull-request">
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="pull-request" className="flex-1 cursor-pointer sm:flex-none">
Pull requests
{t("azuredevops:pullRequestsTab")}
</TabsTrigger>
<TabsTrigger value="work-item" className="flex-1 cursor-pointer sm:flex-none">
Work items
{t("azuredevops:workItemsTab")}
</TabsTrigger>
</TabsList>
<TabsContent value="pull-request">
<QueryEditor
kind="Pull request"
kind="pull-request"
queries={drafts.pullRequests}
baseline={drafts.baseline.pullRequests}
onChange={drafts.setPullRequests}
@@ -458,7 +508,7 @@ export function AzureDevOpsDefaultQueriesSection({ workspaceId }: { workspaceId:
</TabsContent>
<TabsContent value="work-item">
<QueryEditor
kind="Work item"
kind="work-item"
queries={drafts.workItems}
baseline={drafts.baseline.workItems}
onChange={drafts.setWorkItems}
@@ -68,7 +68,15 @@ describe("AzureDevOpsQuickActionsSection", () => {
});
expect(screen.getByRole("button", { name: "Reset" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Edit prompt" }));
expect(screen.getByText(/available placeholders/)).toBeTruthy();
// The hint is a `<Trans>` whose `<n>` indices address the JSX children
// positionally, so a reflow of those children silently reassembles the
// sentence into fragments. Assert the whole reconstructed hint, including
// the three prompt tokens, which are passed as values so neither i18next
// interpolation nor the pseudo-locale rewrites them.
const hint = screen.getByText(/available placeholders/);
expect(hint.textContent).toBe(
"Type {{ to see available placeholders. {{url}} and {{title}} are substituted when the action runs.",
);
});
it("keeps edits local until the shared Save changes action is pressed", async () => {
@@ -1,6 +1,7 @@
"use client";
import { createElement, useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { IconPlus, IconRefresh, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
@@ -30,23 +31,65 @@ import {
DEFAULT_AZURE_WORK_ITEM_ACTIONS,
} from "./azure-devops-workspace-defaults";
const ACTION_PROMPT_PLACEHOLDERS: ScriptPlaceholder[] = [
{
key: "url",
description: "URL of the Azure DevOps work item or pull request",
example: "https://dev.azure.com/acme/Platform/_workitems/edit/42",
executor_types: [],
},
{
key: "title",
description: "Title of the Azure DevOps work item or pull request",
example: "Rotate integration credentials",
executor_types: [],
},
];
type Translate = (key: string, values?: Record<string, unknown>) => string;
type ActionKind = "Work item" | "Pull request";
/**
* Prompt-template placeholders offered by the ScriptEditor completions.
*
* A function rather than a module-scope constant: `t()` at module scope would
* freeze the descriptions at the boot locale. `key` is the placeholder token
* the backend substitutes and `example` is sample data — neither is copy.
* Memoize at every call site: ScriptEditor keys its completion-provider
* registration on array identity.
*/
function actionPromptPlaceholders(t: Translate): ScriptPlaceholder[] {
return [
{
key: "url",
description: t("azuredevops:placeholderUrlDescription"),
example: "https://dev.azure.com/acme/Platform/_workitems/edit/42",
executor_types: [],
},
{
key: "title",
description: t("azuredevops:placeholderTitleDescription"),
example: "Rotate integration credentials",
executor_types: [],
},
];
}
/**
* Wire discriminant for the two action kinds. These used to be the English
* phrases "Work item" / "Pull request", which made the same value both a
* `===` comparand and display copy; they are now identifiers, and the labels
* resolve through the key maps below.
*/
type ActionKind = "work-item" | "pull-request";
/** Title-cased kind noun, e.g. "Work item action label 1". */
const KIND_TITLE_KEYS: Record<ActionKind, string> = {
"work-item": "azuredevops:workItemKindTitle",
"pull-request": "azuredevops:pullRequestKindTitle",
};
/** Lower-cased kind noun for mid-sentence use, e.g. "Remove work item action 1". */
const KIND_LOWER_KEYS: Record<ActionKind, string> = {
"work-item": "azuredevops:workItemKindLower",
"pull-request": "azuredevops:pullRequestKindLower",
};
/** Route these presets drive; a path, so it is interpolated rather than translated. */
const AZURE_DEVOPS_ROUTE = "/azure-devops";
/** Prompt-template tokens, shown verbatim in the hint below the editor. */
const PROMPT_OPEN_BRACES = "{{";
const PROMPT_URL_TOKEN = "{{url}}";
const PROMPT_TITLE_TOKEN = "{{title}}";
// `label` is PERSISTED as part of `AzureDevOpsActionPreset` and is editable in
// the row below, so it must stay locale-neutral — the same contract as
// `newPreset` in components/github/action-presets-section.tsx.
function newAction(): AzureDevOpsActionPreset {
return {
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
@@ -66,11 +109,12 @@ function ActionIconSelect({
dirty: boolean;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
className="h-11 w-full cursor-pointer sm:h-8"
aria-label="Icon"
aria-label={t("azuredevops:icon")}
data-settings-dirty={dirty}
>
<SelectValue>
@@ -84,7 +128,7 @@ function ActionIconSelect({
<SelectItem key={choice.key} value={choice.key} className="cursor-pointer">
<span className="flex items-center gap-2">
<ChoiceIcon className="h-3.5 w-3.5" />
{choice.label}
{t(choice.labelKey)}
</span>
</SelectItem>
);
@@ -111,6 +155,50 @@ function Field({
);
}
function ActionPromptPanel({
action,
baseline,
onPatch,
}: {
action: AzureDevOpsActionPreset;
baseline?: AzureDevOpsActionPreset;
onPatch: (patch: Partial<AzureDevOpsActionPreset>) => void;
}) {
const { t } = useTranslation();
// ScriptEditor keys its completion-provider registration on array identity, so
// a fresh array per render would re-register on every keystroke.
const placeholders = useMemo(() => actionPromptPlaceholders(t), [t]);
return (
<div className="space-y-1 px-3 pb-3 sm:px-2 sm:pb-2">
<div
className="overflow-hidden rounded-md border"
data-settings-dirty={action.promptTemplate !== baseline?.promptTemplate}
data-settings-dirty-level="container"
>
<ScriptEditor
value={action.promptTemplate}
onChange={(promptTemplate) => onPatch({ promptTemplate })}
language="markdown"
height={computeEditorHeight(action.promptTemplate)}
lineNumbers="off"
placeholders={placeholders}
/>
</div>
<p className="text-[11px] text-muted-foreground/60">
{/* The three tokens are prompt syntax, passed as values so neither
i18next interpolation nor the pseudo-locale rewrites them. */}
<Trans
i18nKey="azuredevops:promptPlaceholdersHint"
values={{ open: PROMPT_OPEN_BRACES, url: PROMPT_URL_TOKEN, title: PROMPT_TITLE_TOKEN }}
>
Type {PROMPT_OPEN_BRACES} to see available placeholders. <code>{PROMPT_URL_TOKEN}</code>{" "}
and <code>{PROMPT_TITLE_TOKEN}</code> are substituted when the action runs.
</Trans>
</p>
</div>
);
}
function ActionRow({
kind,
index,
@@ -130,6 +218,8 @@ function ActionRow({
onPatch: (patch: Partial<AzureDevOpsActionPreset>) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const kindTitle = t(KIND_TITLE_KEYS[kind]);
return (
<div
className="rounded-md border"
@@ -137,28 +227,28 @@ function ActionRow({
data-settings-dirty-level="container"
>
<div className="grid grid-cols-2 gap-2 p-3 sm:grid-cols-[5rem_10rem_minmax(0,1fr)_auto_auto] sm:items-end sm:p-2">
<Field label="Icon">
<Field label={t("azuredevops:icon")}>
<ActionIconSelect
value={action.icon}
dirty={action.icon !== baseline?.icon}
onChange={(icon) => onPatch({ icon })}
/>
</Field>
<Field label="Label">
<Field label={t("azuredevops:label")}>
<Input
className="h-11 w-full sm:h-8"
value={action.label}
aria-label={`${kind} action label ${index + 1}`}
aria-label={t("azuredevops:actionLabelAria", { kind: kindTitle, index: index + 1 })}
data-settings-dirty={action.label !== baseline?.label}
onChange={(event) => onPatch({ label: event.target.value })}
/>
</Field>
<Field label="Hint" className="col-span-2 sm:col-span-1">
<Field label={t("azuredevops:hint")} className="col-span-2 sm:col-span-1">
<Input
className="h-11 w-full sm:h-8"
value={action.hint}
aria-label={`${kind} action hint ${index + 1}`}
placeholder="Hint (optional)"
aria-label={t("azuredevops:actionHintAria", { kind: kindTitle, index: index + 1 })}
placeholder={t("azuredevops:hintOptional")}
data-settings-dirty={action.hint !== baseline?.hint}
onChange={(event) => onPatch({ hint: event.target.value })}
/>
@@ -170,7 +260,7 @@ function ActionRow({
className="h-11 cursor-pointer text-xs sm:h-8"
onClick={onToggle}
>
{expanded ? "Hide prompt" : "Edit prompt"}
{expanded ? t("azuredevops:hidePrompt") : t("azuredevops:editPrompt")}
</Button>
<Button
type="button"
@@ -178,33 +268,15 @@ function ActionRow({
size="icon"
className="h-11 w-full cursor-pointer text-destructive sm:h-8 sm:w-8"
onClick={onRemove}
aria-label={`Remove ${kind.toLowerCase()} action ${index + 1}`}
aria-label={t("azuredevops:removeActionAria", {
kind: t(KIND_LOWER_KEYS[kind]),
index: index + 1,
})}
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
{expanded && (
<div className="space-y-1 px-3 pb-3 sm:px-2 sm:pb-2">
<div
className="overflow-hidden rounded-md border"
data-settings-dirty={action.promptTemplate !== baseline?.promptTemplate}
data-settings-dirty-level="container"
>
<ScriptEditor
value={action.promptTemplate}
onChange={(promptTemplate) => onPatch({ promptTemplate })}
language="markdown"
height={computeEditorHeight(action.promptTemplate)}
lineNumbers="off"
placeholders={ACTION_PROMPT_PLACEHOLDERS}
/>
</div>
<p className="text-[11px] text-muted-foreground/60">
Type {"{{"} to see available placeholders. <code>{"{{url}}"}</code> and{" "}
<code>{"{{title}}"}</code> are substituted when the action runs.
</p>
</div>
)}
{expanded && <ActionPromptPanel action={action} baseline={baseline} onPatch={onPatch} />}
</div>
);
}
@@ -220,6 +292,7 @@ function ActionEditor({
baseline: AzureDevOpsActionPreset[];
onChange: (actions: AzureDevOpsActionPreset[]) => void;
}) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<string | null>(null);
const patch = (index: number, values: Partial<AzureDevOpsActionPreset>) =>
onChange(
@@ -252,13 +325,16 @@ function ActionEditor({
className="h-11 w-full cursor-pointer sm:h-8 sm:w-auto"
onClick={add}
>
<IconPlus className="h-4 w-4" /> Add {kind === "Pull request" ? "PR" : "work item"} action
<IconPlus className="h-4 w-4" />{" "}
{kind === "pull-request"
? t("azuredevops:addPrAction")
: t("azuredevops:addWorkItemAction")}
</Button>
</div>
);
}
function useActionDrafts(workspaceId: string) {
function useActionDrafts(workspaceId: string, t: Translate) {
const [workItems, setWorkItems] = useState(DEFAULT_AZURE_WORK_ITEM_ACTIONS);
const [pullRequests, setPullRequests] = useState(DEFAULT_AZURE_PULL_REQUEST_ACTIONS);
const [baseline, setBaseline] = useState({ workItems, pullRequests });
@@ -287,13 +363,15 @@ function useActionDrafts(workspaceId: string) {
})
.catch((error: unknown) => {
if (!current) return;
setLoadError(error instanceof Error ? error.message : "Failed to load quick actions.");
setLoadError(
error instanceof Error ? error.message : t("azuredevops:failedToLoadQuickActions"),
);
})
.finally(() => current && setLoading(false));
return () => {
current = false;
};
}, [workspaceId]);
}, [t, workspaceId]);
const save = useCallback(async () => {
const response = await updateAzureDevOpsWorkspaceSettings(
@@ -344,37 +422,38 @@ function validActions(actions: AzureDevOpsActionPreset[]): boolean {
}
export function AzureDevOpsQuickActionsSection({ workspaceId }: { workspaceId: string }) {
const drafts = useActionDrafts(workspaceId);
const { t } = useTranslation();
const drafts = useActionDrafts(workspaceId, t);
const { toast } = useToast();
const valid = validActions(drafts.workItems) && validActions(drafts.pullRequests);
const save = useCallback(async () => {
try {
await drafts.save();
toast({ description: "Azure DevOps quick actions saved", variant: "success" });
toast({ description: t("azuredevops:quickActionsSaved"), variant: "success" });
} catch (error) {
toast({
description: error instanceof Error ? error.message : "Failed to save quick actions",
description:
error instanceof Error ? error.message : t("azuredevops:failedToSaveQuickActions"),
variant: "error",
});
throw error;
}
}, [drafts, toast]);
}, [drafts, t, toast]);
useSettingsSaveContributor({
id: `azure-devops-actions:${workspaceId}`,
revision: JSON.stringify([drafts.workItems, drafts.pullRequests]),
isDirty: drafts.dirty,
canSave: !drafts.loading && !drafts.loadError && valid,
invalidReason:
drafts.loadError ?? (valid ? undefined : "Every quick action needs a label and prompt."),
invalidReason: drafts.loadError ?? (valid ? undefined : t("azuredevops:quickActionInvalid")),
save,
discard: drafts.discard,
});
return (
<SettingsSection
title="Quick actions"
description="Prompts shown on /azure-devops when starting a task from a pull request or work item."
title={t("azuredevops:quickActions")}
description={t("azuredevops:quickActionsDescription", { path: AZURE_DEVOPS_ROUTE })}
action={
<Button
type="button"
@@ -384,7 +463,7 @@ export function AzureDevOpsQuickActionsSection({ workspaceId }: { workspaceId: s
disabled={drafts.loading || !!drafts.loadError}
onClick={drafts.reset}
>
<IconRefresh className="h-4 w-4" /> Reset
<IconRefresh className="h-4 w-4" /> {t("common:reset")}
</Button>
}
>
@@ -392,22 +471,22 @@ export function AzureDevOpsQuickActionsSection({ workspaceId }: { workspaceId: s
<CardContent className="pt-4 sm:pt-6">
{drafts.loadError && (
<p role="alert" className="mb-4 text-sm text-destructive">
Could not load existing quick actions: {drafts.loadError}
{t("azuredevops:couldNotLoadQuickActions", { error: drafts.loadError })}
</p>
)}
<fieldset disabled={drafts.loading || !!drafts.loadError} className="contents">
<Tabs defaultValue="pull-request">
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="pull-request" className="flex-1 cursor-pointer sm:flex-none">
Pull requests
{t("azuredevops:pullRequestsTab")}
</TabsTrigger>
<TabsTrigger value="work-item" className="flex-1 cursor-pointer sm:flex-none">
Work items
{t("azuredevops:workItemsTab")}
</TabsTrigger>
</TabsList>
<TabsContent value="pull-request">
<ActionEditor
kind="Pull request"
kind="pull-request"
actions={drafts.pullRequests}
baseline={drafts.baseline.pullRequests}
onChange={drafts.setPullRequests}
@@ -415,7 +494,7 @@ export function AzureDevOpsQuickActionsSection({ workspaceId }: { workspaceId: s
</TabsContent>
<TabsContent value="work-item">
<ActionEditor
kind="Work item"
kind="work-item"
actions={drafts.workItems}
baseline={drafts.baseline.workItems}
onChange={drafts.setWorkItems}
@@ -107,9 +107,13 @@ describe("AzureDevOpsConnectionSection", () => {
`${OLD_ORGANIZATION_URL}/_usersSettings/tokens`,
);
expect(patHelp.textContent).toContain("Custom defined");
expect(patHelp.textContent).toContain("Work Items");
expect(patHelp.textContent).toContain("Code");
expect(patHelp.textContent).toContain("Read");
// The scope step is a `<Trans>` whose `<n>` indices address the JSX children
// positionally, so a reflow of those children silently reassembles the
// sentence into fragments. Assert the whole reconstructed clause, not just
// that the two scope names appear somewhere.
expect(patHelp.textContent).toContain("Under Work Items, check Read & write.");
expect(patHelp.textContent).toContain("Under Code, check Read.");
expect(patHelp.textContent).toContain("Leave all other scopes unchecked.");
});
it("does not create a token link from a non-Azure organization URL", async () => {
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import {
IconBrandAzure,
IconDeviceFloppy,
@@ -142,6 +143,7 @@ function useConfigRefresh(
}
function useAzureDevOpsSettings(workspaceId: string) {
const { t } = useTranslation();
const { toast } = useToast();
const [config, setConfig] = useState<AzureDevOpsConfig | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
@@ -158,13 +160,13 @@ function useAzureDevOpsSettings(workspaceId: string) {
setForm(configToForm(next));
} catch (err) {
toast({
description: `Failed to load Azure DevOps config: ${String(err)}`,
description: t("azuredevops:failedToLoadConfig", { error: String(err) }),
variant: "error",
});
} finally {
setLoading(false);
}
}, [toast, workspaceId]);
}, [t, toast, workspaceId]);
useEffect(() => void load(), [load]);
useConfigRefresh(workspaceId, setConfig);
@@ -201,26 +203,32 @@ function useAzureDevOpsSettings(workspaceId: string) {
setConfig(next);
setForm(configToForm(next));
setTestResult(null);
toast({ description: "Azure DevOps configuration saved", variant: "success" });
toast({ description: t("azuredevops:configurationSaved"), variant: "success" });
} catch (err) {
toast({ description: `Save failed: ${String(err)}`, variant: "error" });
toast({
description: t("azuredevops:saveFailed", { error: String(err) }),
variant: "error",
});
} finally {
setSaving(false);
}
}, [config, form, toast, workspaceId]);
}, [config, form, t, toast, workspaceId]);
const remove = useCallback(async () => {
if (!confirm("Remove Azure DevOps configuration?")) return;
if (!confirm(t("azuredevops:removeConfigurationConfirm"))) return;
try {
await deleteAzureDevOpsConfig(workspaceId);
setConfig(null);
setForm(EMPTY_FORM);
setTestResult(null);
toast({ description: "Azure DevOps configuration removed", variant: "success" });
toast({ description: t("azuredevops:configurationRemoved"), variant: "success" });
} catch (err) {
toast({ description: `Remove failed: ${String(err)}`, variant: "error" });
toast({
description: t("azuredevops:removeFailed", { error: String(err) }),
variant: "error",
});
}
}, [toast, workspaceId]);
}, [t, toast, workspaceId]);
return {
config,
@@ -237,15 +245,24 @@ function useAzureDevOpsSettings(workspaceId: string) {
};
}
function testResultMessage(
t: (key: string, values?: Record<string, unknown>) => string,
result: TestAzureDevOpsConnectionResult,
): string {
if (!result.ok) return result.error || t("azuredevops:connectionFailed");
// `displayName` is the Azure DevOps identity — data, so it is interpolated
// rather than concatenated into the message.
return result.displayName
? t("azuredevops:connectedAs", { name: result.displayName })
: t("azuredevops:connected");
}
function TestResult({ result }: { result: TestAzureDevOpsConnectionResult | null }) {
const { t } = useTranslation();
if (!result) return null;
return (
<Alert variant={result.ok ? "default" : "destructive"} data-testid="azure-devops-test-result">
<AlertDescription>
{result.ok
? `Connected${result.displayName ? ` as ${result.displayName}` : ""}`
: result.error || "Connection failed"}
</AlertDescription>
<AlertDescription>{testResultMessage(t, result)}</AlertDescription>
</Alert>
);
}
@@ -253,7 +270,14 @@ function TestResult({ result }: { result: TestAzureDevOpsConnectionResult | null
type SettingsState = ReturnType<typeof useAzureDevOpsSettings>;
type ProjectsState = ReturnType<typeof useAzureDevOpsProjects>;
// Azure DevOps names these two PAT scope groups in its own token UI. They are
// pointers into that screen, not copy, so they are interpolated as values — the
// pseudo-locale must not transliterate a label the user has to find verbatim.
const PAT_SCOPE_WORK_ITEMS = "Work Items";
const PAT_SCOPE_CODE = "Code";
function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) {
const { t } = useTranslation();
const patURL = azureDevOpsPATURL(organizationUrl);
const [open, setOpen] = useState(false);
@@ -266,7 +290,7 @@ function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) {
variant="ghost"
size="icon-sm"
className="size-11 shrink-0 cursor-help text-muted-foreground sm:size-7"
aria-label="How to create a personal access token"
aria-label={t("azuredevops:howToCreatePat")}
onClick={() => setOpen((current) => !current)}
>
<IconInfoCircle className="size-4" />
@@ -279,18 +303,22 @@ function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) {
className="pointer-events-auto max-w-sm space-y-2 p-3 text-left text-xs leading-relaxed"
data-testid="azure-devops-pat-help"
>
<p className="font-medium text-foreground">
Create a personal access token for board editing
</p>
<p className="font-medium text-foreground">{t("azuredevops:patHelpTitle")}</p>
<ol className="list-decimal space-y-1 pl-4 text-muted-foreground">
<li>Open token settings and select New Token.</li>
<li>Choose this organization, a short expiration, and Custom defined scopes.</li>
<li>{t("azuredevops:patStepOpenTokenSettings")}</li>
<li>{t("azuredevops:patStepChooseScopes")}</li>
<li>
Under <span className="font-medium text-foreground">Work Items</span>, check Read
&amp; write. Under <span className="font-medium text-foreground">Code</span>, check
Read. Leave all other scopes unchecked.
<Trans
i18nKey="azuredevops:patStepCheckScopes"
values={{ workItems: PAT_SCOPE_WORK_ITEMS, code: PAT_SCOPE_CODE }}
>
Under <span className="font-medium text-foreground">{PAT_SCOPE_WORK_ITEMS}</span>,
check Read &amp; write. Under{" "}
<span className="font-medium text-foreground">{PAT_SCOPE_CODE}</span>, check Read.
Leave all other scopes unchecked.
</Trans>
</li>
<li>Create the token, copy it, and paste it into this field.</li>
<li>{t("azuredevops:patStepCreateAndPaste")}</li>
</ol>
{patURL ? (
<a
@@ -300,12 +328,10 @@ function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) {
className="inline-flex cursor-pointer items-center gap-1 font-medium text-foreground underline underline-offset-4"
>
<IconExternalLink className="size-3.5" />
Create personal access token
{t("azuredevops:createPersonalAccessToken")}
</a>
) : (
<p className="text-muted-foreground">
Enter a valid organization URL to open its token settings.
</p>
<p className="text-muted-foreground">{t("azuredevops:enterValidOrganizationUrl")}</p>
)}
</TooltipContent>
</Tooltip>
@@ -324,12 +350,15 @@ function ConnectionFields({
canReusePAT: boolean;
projectSelectionEnabled: boolean;
}) {
const projectPlaceholder = projects.loading ? "Loading projects..." : "Optional";
const { t } = useTranslation();
const projectPlaceholder = projects.loading
? t("azuredevops:loadingProjects")
: t("azuredevops:optional");
return (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="azure-devops-organization">Organization URL</Label>
<Label htmlFor="azure-devops-organization">{t("azuredevops:organizationUrl")}</Label>
<Input
id="azure-devops-organization"
value={state.form.organizationUrl}
@@ -342,7 +371,7 @@ function ConnectionFields({
</div>
<div className="space-y-1.5">
<div className="sm:flex sm:min-h-7 sm:items-center">
<Label htmlFor="azure-devops-project">Default project</Label>
<Label htmlFor="azure-devops-project">{t("azuredevops:defaultProject")}</Label>
</div>
<Select
value={state.form.defaultProjectId || undefined}
@@ -372,7 +401,7 @@ function ConnectionFields({
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-1 sm:min-h-7">
<Label htmlFor="azure-devops-pat">Personal Access Token</Label>
<Label htmlFor="azure-devops-pat">{t("azuredevops:personalAccessToken")}</Label>
<PATSetupHelp organizationUrl={state.form.organizationUrl} />
</div>
<Input
@@ -380,7 +409,7 @@ function ConnectionFields({
type="password"
value={state.form.pat}
onChange={(event) => state.update("pat", event.target.value)}
placeholder={canReusePAT ? "Saved credential" : "Paste PAT"}
placeholder={canReusePAT ? t("azuredevops:savedCredential") : t("azuredevops:pastePat")}
disabled={state.loading}
autoComplete="new-password"
aria-describedby="azure-devops-pat-help"
@@ -397,12 +426,13 @@ function ConnectionFields({
);
}
function saveButtonLabel(state: SettingsState): string {
if (state.saving) return "Saving...";
return state.config ? "Update" : "Save";
function saveButtonLabel(t: (key: string) => string, state: SettingsState): string {
if (state.saving) return t("azuredevops:saving");
return state.config ? t("azuredevops:update") : t("azuredevops:save");
}
function ConnectionActions({ state, disabled }: { state: SettingsState; disabled: boolean }) {
const { t } = useTranslation();
return (
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<Button
@@ -414,7 +444,7 @@ function ConnectionActions({ state, disabled }: { state: SettingsState; disabled
data-testid="azure-devops-test-button"
>
<IconPlugConnected className="h-4 w-4" />
{state.testing ? "Testing..." : "Test connection"}
{state.testing ? t("azuredevops:testing") : t("azuredevops:testConnection")}
</Button>
<Button
type="button"
@@ -424,7 +454,7 @@ function ConnectionActions({ state, disabled }: { state: SettingsState; disabled
data-testid="azure-devops-save-button"
>
<IconDeviceFloppy className="h-4 w-4" />
{saveButtonLabel(state)}
{saveButtonLabel(t, state)}
</Button>
{state.config && (
<Button
@@ -435,7 +465,7 @@ function ConnectionActions({ state, disabled }: { state: SettingsState; disabled
data-testid="azure-devops-delete-button"
>
<IconTrash className="h-4 w-4" />
Remove
{t("azuredevops:remove")}
</Button>
)}
</div>
@@ -443,6 +473,7 @@ function ConnectionActions({ state, disabled }: { state: SettingsState; disabled
}
export function AzureDevOpsConnectionSection({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const state = useAzureDevOpsSettings(workspaceId);
const projects = useAzureDevOpsProjects(workspaceId, !!state.config?.hasSecret);
const canReusePAT = savedPATMatches(state.config, state.form);
@@ -456,8 +487,8 @@ export function AzureDevOpsConnectionSection({ workspaceId }: { workspaceId: str
return (
<SettingsSection
icon={<IconBrandAzure className="h-5 w-5" />}
title="Azure DevOps integration"
description="Azure DevOps Services organization, project, and PAT for this workspace."
title={t("azuredevops:integrationTitle")}
description={t("azuredevops:integrationDescription")}
>
<Card>
<CardContent className="space-y-4 pt-6">
@@ -0,0 +1,119 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@/lib/i18n";
import type {
AzureDevOpsPullRequestWatch,
AzureDevOpsWorkItemWatch,
} from "@/lib/types/azure-devops";
const PR_CARD = "azure-pull-request-watch-pr-1";
const WI_CARD = "azure-work-item-watch-wi-1";
const mocks = vi.hoisted(() => ({ watches: vi.fn() }));
vi.mock("@/hooks/domains/azure-devops/use-azure-devops-watches", () => ({
useAzureDevOpsWatches: mocks.watches,
}));
import { AzureDevOpsWatchSettings } from "@/components/azure-devops/azure-devops-watch-settings";
const PULL_REQUEST: AzureDevOpsPullRequestWatch = {
id: "pr-1",
projectId: "project-1",
status: "completed",
enabled: true,
pollIntervalSeconds: 300,
cleanupPolicy: "auto",
} as AzureDevOpsPullRequestWatch;
const WORK_ITEM: AzureDevOpsWorkItemWatch = {
id: "wi-1",
projectId: "project-1",
wiql: "SELECT [System.Id] FROM WorkItems",
enabled: false,
pollIntervalSeconds: 600,
cleanupPolicy: "never",
} as AzureDevOpsWorkItemWatch;
function state(overrides: Partial<AzureDevOpsPullRequestWatch> = {}) {
return {
workItems: [WORK_ITEM],
pullRequests: [{ ...PULL_REQUEST, ...overrides }],
loading: false,
error: null,
refresh: vi.fn(),
createWorkItem: vi.fn(),
createPullRequest: vi.fn(),
updateWorkItem: vi.fn(),
updatePullRequest: vi.fn(),
remove: vi.fn(),
trigger: vi.fn(),
previewReset: vi.fn(),
reset: vi.fn(),
};
}
beforeEach(() => {
vi.clearAllMocks();
mocks.watches.mockReturnValue(state());
});
afterEach(async () => {
cleanup();
await i18n.changeLanguage("en");
});
describe("AzureDevOpsWatchSettings", () => {
it("renders the watch summary", () => {
render(<AzureDevOpsWatchSettings workspaceId="workspace-a" />);
const prCard = screen.getByTestId(PR_CARD);
expect(prCard.textContent).toContain("Status: completed");
expect(prCard.textContent).toContain("project-1 · every 300s · cleanup auto");
expect(prCard.textContent).toContain("Not checked yet");
const wiCard = screen.getByTestId(WI_CARD);
expect(wiCard.textContent).toContain("project-1 · every 600s · cleanup never");
});
// `status` and `cleanupPolicy` are Azure DevOps wire enums whose values happen
// to equal their own English labels, so an English assertion cannot tell a
// catalog lookup from raw interpolation — it passes either way. Only a
// non-English locale distinguishes them, which is the whole point of the
// pseudo-locale.
it("resolves both wire enums through the catalog rather than interpolating them raw", async () => {
await i18n.changeLanguage("pseudo");
render(<AzureDevOpsWatchSettings workspaceId="workspace-a" />);
const prCard = screen.getByTestId(PR_CARD);
expect(prCard.textContent).toContain("Śţàţũś: ćōḿƥĺēţēď");
expect(prCard.textContent).toContain("project-1 · ēvēŕŷ 300ś · ćĺēàńũƥ àũţō");
// The wire values must not leak through anywhere on the card.
expect(prCard.textContent).not.toContain("completed");
expect(prCard.textContent).not.toContain("auto");
const wiCard = screen.getByTestId(WI_CARD);
expect(wiCard.textContent).toContain("ćĺēàńũƥ ńēvēŕ");
expect(wiCard.textContent).not.toContain("never");
// `projectId` is Azure DevOps data and the WIQL is a query language; both
// stay verbatim under pseudo.
expect(wiCard.textContent).toContain("project-1");
expect(wiCard.textContent).toContain("SELECT [System.Id] FROM WorkItems");
});
it("treats an `all` status filter the same as no status filter", async () => {
mocks.watches.mockReturnValue(state({ status: "all" }));
await i18n.changeLanguage("pseudo");
render(<AzureDevOpsWatchSettings workspaceId="workspace-a" />);
expect(screen.getByTestId(PR_CARD).textContent).toContain("Śţàţũś: àńŷ");
});
it("echoes an unrecognized status rather than blanking it", async () => {
mocks.watches.mockReturnValue(
state({ status: "draft" as AzureDevOpsPullRequestWatch["status"] }),
);
await i18n.changeLanguage("pseudo");
render(<AzureDevOpsWatchSettings workspaceId="workspace-a" />);
expect(screen.getByTestId(PR_CARD).textContent).toContain("Śţàţũś: draft");
});
});
@@ -3,6 +3,7 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IconPlayerPlay, IconPlus, IconRefresh, IconTrash, IconX } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Badge } from "@kandev/ui/badge";
@@ -14,6 +15,7 @@ import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { SettingsSection } from "@/components/settings/settings-section";
import { formatDateTime } from "@/lib/i18n/formats";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useAzureDevOpsWatches } from "@/hooks/domains/azure-devops/use-azure-devops-watches";
import type {
@@ -26,12 +28,63 @@ import type {
type Kind = "work-item" | "pull-request";
function formatLastChecked(value?: string | null): string {
if (!value) return "Not checked yet";
const date = new Date(value);
return Number.isNaN(date.valueOf()) ? "Not checked yet" : `checked ${date.toLocaleString()}`;
type Translate = (key: string, values?: Record<string, unknown>) => string;
/** Azure DevOps identity shorthand accepted by the creator/reviewer filters. */
const AZURE_ME_TOKEN = "@me";
/**
* Catalog keys for the two watch kinds. `Kind` is a wire discriminant compared
* with `===` and baked into `data-testid`s, so it is never rendered directly —
* only the label resolves at render.
*/
const KIND_NOUN_KEYS: Record<Kind, string> = {
"work-item": "azuredevops:workItemKindNoun",
"pull-request": "azuredevops:pullRequestKindNoun",
};
/**
* Sentence-cased cleanup-policy labels for the watch summary line. The map key
* is the persisted `AzureDevOpsCleanupPolicy` value; the menu items in the
* editor use the standalone (capitalized) forms instead.
*/
const CLEANUP_POLICY_KEYS: Record<string, string> = {
auto: "azuredevops:cleanupAuto",
always: "azuredevops:cleanupAlways",
never: "azuredevops:cleanupNever",
};
/**
* Sentence-cased labels for the watch card's status line. The map key is the
* Azure DevOps pull-request status sent on the wire; `all` shares the label used
* when no status is set, since both mean "not filtered by status". The editor's
* menu items use the standalone (capitalized) forms instead.
*/
const WATCH_STATUS_KEYS: Record<string, string> = {
active: "azuredevops:watchStatusActive",
completed: "azuredevops:watchStatusCompleted",
abandoned: "azuredevops:watchStatusAbandoned",
all: "azuredevops:pullRequestAny",
};
/** An unrecognized status is echoed verbatim rather than blanked. */
function watchStatusLabel(t: Translate, status: string | undefined): string {
if (!status) return t("azuredevops:pullRequestAny");
const key = WATCH_STATUS_KEYS[status];
return key ? t(key) : status;
}
function formatLastChecked(t: Translate, value?: string | null): string {
if (!value) return t("azuredevops:notCheckedYet");
const date = new Date(value);
if (Number.isNaN(date.valueOf())) return t("azuredevops:notCheckedYet");
return t("azuredevops:checkedAt", { at: formatDateTime(date) });
}
// The two default prompts are PERSISTED on the watch and sent to the agent
// verbatim, and they carry `{{work_item.title}}` / `{{pull_request.title}}`
// placeholders the backend substitutes. They are deliberately NOT translated —
// see the GitLab and Sentry watch defaults for the same contract.
const emptyWorkItem: AzureDevOpsWorkItemWatchInput = {
workflowId: "",
workflowStepId: "",
@@ -81,6 +134,7 @@ function WatchActions({
onReset: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
<Button
@@ -91,7 +145,7 @@ function WatchActions({
onClick={onEdit}
data-testid={`azure-${kind}-watch-edit-${watch.id}`}
>
Edit
{t("azuredevops:edit")}
</Button>
<Button
type="button"
@@ -101,7 +155,7 @@ function WatchActions({
onClick={onToggle}
data-testid={`azure-${kind}-watch-toggle-${watch.id}`}
>
{watch.enabled ? "Disable" : "Enable"}
{watch.enabled ? t("azuredevops:disable") : t("azuredevops:enable")}
</Button>
<Button
type="button"
@@ -111,7 +165,7 @@ function WatchActions({
onClick={onTrigger}
data-testid={`azure-${kind}-watch-trigger-${watch.id}`}
>
<IconPlayerPlay className="h-4 w-4" /> Run now
<IconPlayerPlay className="h-4 w-4" /> {t("azuredevops:runNow")}
</Button>
<Button
type="button"
@@ -120,7 +174,7 @@ function WatchActions({
className="min-h-11 cursor-pointer"
onClick={onReset}
>
<IconRefresh className="h-4 w-4" /> Reset
<IconRefresh className="h-4 w-4" /> {t("common:reset")}
</Button>
<Button
type="button"
@@ -129,7 +183,7 @@ function WatchActions({
className="min-h-11 cursor-pointer text-destructive"
onClick={onDelete}
>
<IconTrash className="h-4 w-4" /> Delete
<IconTrash className="h-4 w-4" /> {t("azuredevops:delete")}
</Button>
</div>
);
@@ -152,19 +206,32 @@ function WatchCard({
onReset: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
return (
<Card data-testid={`azure-${kind}-watch-${watch.id}`}>
<CardHeader className="space-y-2">
<CardTitle className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>{kind === "work-item" ? "Work-item query" : "Pull-request filter"}</span>
<span>
{kind === "work-item"
? t("azuredevops:workItemQuery")
: t("azuredevops:pullRequestFilter")}
</span>
<Badge variant={watch.enabled ? "default" : "secondary"}>
{watch.enabled ? "Enabled" : "Disabled"}
{watch.enabled ? t("azuredevops:enabled") : t("azuredevops:disabled")}
</Badge>
</CardTitle>
<div className="text-xs text-muted-foreground">
{watch.projectId} · every {watch.pollIntervalSeconds}s · cleanup {watch.cleanupPolicy}
{/* `projectId` is Azure DevOps data and `cleanupPolicy` a wire enum;
both are interpolated rather than written into the message. */}
{t("azuredevops:watchSummary", {
projectId: watch.projectId,
count: watch.pollIntervalSeconds,
policy: t(CLEANUP_POLICY_KEYS[watch.cleanupPolicy] ?? CLEANUP_POLICY_KEYS.auto),
})}
</div>
<div className="text-xs text-muted-foreground">
{formatLastChecked(t, watch.lastPolledAt)}
</div>
<div className="text-xs text-muted-foreground">{formatLastChecked(watch.lastPolledAt)}</div>
</CardHeader>
<CardContent className="space-y-3">
{kind === "work-item" && (
@@ -174,7 +241,9 @@ function WatchCard({
)}
{kind === "pull-request" && (
<div className="text-xs text-muted-foreground">
Status: {(watch as AzureDevOpsPullRequestWatch).status || "any"}
{t("azuredevops:statusLine", {
status: watchStatusLabel(t, (watch as AzureDevOpsPullRequestWatch).status),
})}
</div>
)}
{watch.lastError && (
@@ -214,6 +283,7 @@ function WatchEditor({
input: AzureDevOpsWorkItemWatchInput | AzureDevOpsPullRequestWatchInput,
) => Promise<unknown>;
}) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const [workItem, setWorkItem] = useState<AzureDevOpsWorkItemWatchInput>(
kind === "work-item" && initial ? (initial as AzureDevOpsWorkItemWatchInput) : emptyWorkItem,
@@ -242,8 +312,8 @@ function WatchEditor({
) {
setError(
kind === "work-item"
? "Project, WIQL, repository, workflow, step, agent, and executor are required."
: "Project, repository, workflow, step, agent, and executor are required.",
? t("azuredevops:workItemWatchFieldsRequired")
: t("azuredevops:pullRequestWatchFieldsRequired"),
);
return;
}
@@ -258,10 +328,12 @@ function WatchEditor({
setSaving(false);
}
};
let submitLabel = editing ? "Update watch" : "Create watch";
if (saving) submitLabel = "Saving…";
const watchLabel = kind === "work-item" ? "work-item" : "pull-request";
const editorTitle = `${editing ? "Edit" : "New"} ${watchLabel} watch`;
let submitLabel = editing ? t("azuredevops:updateWatch") : t("azuredevops:createWatch");
if (saving) submitLabel = t("azuredevops:savingEllipsis");
const kindNoun = t(KIND_NOUN_KEYS[kind]);
const editorTitle = editing
? t("azuredevops:editWatchTitle", { kind: kindNoun })
: t("azuredevops:newWatchTitle", { kind: kindNoun });
const body = (
<div className="min-h-0 space-y-4 overflow-y-auto p-4 pb-[env(safe-area-inset-bottom,0px)] sm:p-6">
{error && (
@@ -271,7 +343,7 @@ function WatchEditor({
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Azure project ID</Label>
<Label>{t("azuredevops:azureProjectId")}</Label>
<Input
value={current.projectId}
onChange={(event) => set("projectId", event.target.value)}
@@ -279,7 +351,7 @@ function WatchEditor({
/>
</div>
<div className="space-y-1.5">
<Label>Poll interval (seconds)</Label>
<Label>{t("azuredevops:pollIntervalSeconds")}</Label>
<Input
type="number"
min={60}
@@ -290,6 +362,7 @@ function WatchEditor({
</div>
{kind === "work-item" ? (
<div className="space-y-1.5">
{/* WIQL is the Azure DevOps query language, not copy. */}
<Label>WIQL</Label>
<textarea
className="min-h-28 w-full rounded-md border bg-background p-3 text-sm"
@@ -301,14 +374,14 @@ function WatchEditor({
) : (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Azure repository ID (optional)</Label>
<Label>{t("azuredevops:azureRepositoryIdOptional")}</Label>
<Input
value={pullRequest.azureRepositoryId ?? ""}
onChange={(event) => set("azureRepositoryId", event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>Status</Label>
<Label>{t("azuredevops:status")}</Label>
<Select
value={pullRequest.status || "active"}
onValueChange={(value) => set("status", value)}
@@ -316,35 +389,37 @@ function WatchEditor({
<SelectTrigger className="min-h-11">
<SelectValue />
</SelectTrigger>
{/* `value` is the Azure DevOps pull-request status sent on the
wire; only the item label is copy. */}
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="abandoned">Abandoned</SelectItem>
<SelectItem value="all">Any status</SelectItem>
<SelectItem value="active">{t("azuredevops:statusActive")}</SelectItem>
<SelectItem value="completed">{t("azuredevops:statusCompleted")}</SelectItem>
<SelectItem value="abandoned">{t("azuredevops:statusAbandoned")}</SelectItem>
<SelectItem value="all">{t("azuredevops:statusAny")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Creator ID</Label>
<Label>{t("azuredevops:creatorId")}</Label>
<Input
value={pullRequest.creatorId ?? ""}
onChange={(event) => set("creatorId", event.target.value)}
placeholder="@me or identity"
placeholder={t("azuredevops:identityPlaceholder", { token: AZURE_ME_TOKEN })}
/>
</div>
<div className="space-y-1.5">
<Label>Reviewer ID</Label>
<Label>{t("azuredevops:reviewerId")}</Label>
<Input
value={pullRequest.reviewerId ?? ""}
onChange={(event) => set("reviewerId", event.target.value)}
placeholder="@me or identity"
placeholder={t("azuredevops:identityPlaceholder", { token: AZURE_ME_TOKEN })}
/>
</div>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Kandev repository ID</Label>
<Label>{t("azuredevops:kandevRepositoryId")}</Label>
<Input
value={current.repositoryId}
onChange={(event) => set("repositoryId", event.target.value)}
@@ -352,7 +427,7 @@ function WatchEditor({
/>
</div>
<div className="space-y-1.5">
<Label>Base branch</Label>
<Label>{t("azuredevops:baseBranch")}</Label>
<Input
value={current.baseBranch}
onChange={(event) => set("baseBranch", event.target.value)}
@@ -361,7 +436,7 @@ function WatchEditor({
/>
</div>
<div className="space-y-1.5">
<Label>Workflow ID</Label>
<Label>{t("azuredevops:workflowId")}</Label>
<Input
value={current.workflowId}
onChange={(event) => set("workflowId", event.target.value)}
@@ -369,7 +444,7 @@ function WatchEditor({
/>
</div>
<div className="space-y-1.5">
<Label>Workflow step ID</Label>
<Label>{t("azuredevops:workflowStepId")}</Label>
<Input
value={current.workflowStepId}
onChange={(event) => set("workflowStepId", event.target.value)}
@@ -377,7 +452,7 @@ function WatchEditor({
/>
</div>
<div className="space-y-1.5">
<Label>Agent profile ID</Label>
<Label>{t("azuredevops:agentProfileId")}</Label>
<Input
value={current.agentProfileId}
onChange={(event) => set("agentProfileId", event.target.value)}
@@ -385,7 +460,7 @@ function WatchEditor({
/>
</div>
<div className="space-y-1.5">
<Label>Executor profile ID</Label>
<Label>{t("azuredevops:executorProfileId")}</Label>
<Input
value={current.executorProfileId}
onChange={(event) => set("executorProfileId", event.target.value)}
@@ -394,7 +469,7 @@ function WatchEditor({
</div>
</div>
<div className="space-y-1.5">
<Label>Prompt</Label>
<Label>{t("azuredevops:prompt")}</Label>
<textarea
className="min-h-24 w-full rounded-md border bg-background p-3 text-sm"
value={current.prompt}
@@ -403,25 +478,25 @@ function WatchEditor({
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Cleanup policy</Label>
<Label>{t("azuredevops:cleanupPolicy")}</Label>
<Select
value={current.cleanupPolicy}
onValueChange={(value: AzureDevOpsCleanupPolicy) =>
set(kind === "work-item" ? "cleanupPolicy" : "cleanupPolicy", value)
}
onValueChange={(value: AzureDevOpsCleanupPolicy) => set("cleanupPolicy", value)}
>
<SelectTrigger className="min-h-11">
<SelectValue />
</SelectTrigger>
{/* `value` is the persisted `AzureDevOpsCleanupPolicy`; only the
item label is copy. */}
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="always">Always</SelectItem>
<SelectItem value="never">Never</SelectItem>
<SelectItem value="auto">{t("azuredevops:cleanupPolicyAuto")}</SelectItem>
<SelectItem value="always">{t("azuredevops:cleanupPolicyAlways")}</SelectItem>
<SelectItem value="never">{t("azuredevops:cleanupPolicyNever")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Max in-flight tasks (0 = unlimited)</Label>
<Label>{t("azuredevops:maxInflightTasks")}</Label>
<Input
type="number"
min={0}
@@ -451,7 +526,7 @@ function WatchEditor({
variant="ghost"
size="icon"
className="min-h-11 min-w-11 cursor-pointer"
aria-label="Close watch editor"
aria-label={t("azuredevops:closeWatchEditor")}
data-testid="azure-watch-editor-close"
onClick={() => onOpenChange(false)}
>
@@ -472,7 +547,7 @@ function WatchEditor({
variant="ghost"
size="icon"
className="min-h-11 min-w-11 cursor-pointer"
aria-label="Close watch editor"
aria-label={t("azuredevops:closeWatchEditor")}
data-testid="azure-watch-editor-close"
onClick={() => onOpenChange(false)}
>
@@ -487,6 +562,7 @@ function WatchEditor({
// eslint-disable-next-line max-lines-per-function -- settings coordinates watch lists, editor state, and destructive confirmations.
export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const watches = useAzureDevOpsWatches(workspaceId);
const [editor, setEditor] = useState<{
kind: Kind;
@@ -497,7 +573,7 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string
const run = async (kind: Kind, id: string) => {
try {
const result = await watches.trigger(kind, id);
setMessage(`${result.matched} new match${result.matched === 1 ? "" : "es"}.`);
setMessage(t("azuredevops:newMatches", { count: result.matched }));
} catch (error) {
setMessage(String(error));
}
@@ -505,9 +581,15 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string
const reset = async (kind: Kind, id: string, policy: string) => {
try {
const preview = await watches.previewReset(kind, id);
if (!confirm(`Reset this ${policy} watch and remove ${preview.taskCount} task(s)?`)) return;
const confirmed = confirm(
t("azuredevops:resetWatchConfirm", {
policy: t(CLEANUP_POLICY_KEYS[policy] ?? CLEANUP_POLICY_KEYS.auto),
count: preview.taskCount,
}),
);
if (!confirmed) return;
await watches.reset(kind, id);
setMessage("Watch reset.");
setMessage(t("azuredevops:watchReset"));
} catch (error) {
setMessage(String(error));
}
@@ -516,16 +598,16 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string
try {
if (kind === "work-item") await watches.updateWorkItem(id, { enabled });
else await watches.updatePullRequest(id, { enabled });
setMessage(enabled ? "Watch enabled." : "Watch disabled.");
setMessage(enabled ? t("azuredevops:watchEnabled") : t("azuredevops:watchDisabled"));
} catch (error) {
setMessage(String(error));
}
};
const remove = async (kind: Kind, id: string) => {
if (!confirm("Delete this watch?")) return;
if (!confirm(t("azuredevops:deleteWatchConfirm"))) return;
try {
await watches.remove(kind, id);
setMessage("Watch deleted.");
setMessage(t("azuredevops:watchDeleted"));
} catch (error) {
setMessage(String(error));
}
@@ -542,10 +624,12 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string
<AlertDescription>{watches.error}</AlertDescription>
</Alert>
)}
{watches.loading && <p className="text-sm text-muted-foreground">Loading watchers…</p>}
{watches.loading && (
<p className="text-sm text-muted-foreground">{t("azuredevops:loadingWatchers")}</p>
)}
<SettingsSection
title="Pull-request watches"
description="Create tasks automatically from matching Azure DevOps pull requests."
title={t("azuredevops:pullRequestWatches")}
description={t("azuredevops:pullRequestWatchesDescription")}
action={
<Button
type="button"
@@ -553,14 +637,14 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string
onClick={() => setEditor({ kind: "pull-request" })}
data-testid="azure-add-pull-request-watch"
>
<IconPlus className="h-4 w-4" /> Add PR watch
<IconPlus className="h-4 w-4" /> {t("azuredevops:addPrWatch")}
</Button>
}
>
{!watches.loading && watches.pullRequests.length === 0 && (
<Card>
<CardContent className="p-6 text-sm text-muted-foreground">
No pull-request watches yet.
{t("azuredevops:noPullRequestWatches")}
</CardContent>
</Card>
)}
@@ -580,8 +664,8 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string
</div>
</SettingsSection>
<SettingsSection
title="Work-item watches"
description="Create tasks automatically from matching Azure DevOps work-item queries."
title={t("azuredevops:workItemWatches")}
description={t("azuredevops:workItemWatchesDescription")}
action={
<Button
type="button"
@@ -589,14 +673,14 @@ export function AzureDevOpsWatchSettings({ workspaceId }: { workspaceId: string
onClick={() => setEditor({ kind: "work-item" })}
data-testid="azure-add-work-item-watch"
>
<IconPlus className="h-4 w-4" /> Add work-item watch
<IconPlus className="h-4 w-4" /> {t("azuredevops:addWorkItemWatch")}
</Button>
}
>
{!watches.loading && watches.workItems.length === 0 && (
<Card>
<CardContent className="p-6 text-sm text-muted-foreground">
No work-item watches yet.
{t("azuredevops:noWorkItemWatches")}
</CardContent>
</Card>
)}
@@ -1,3 +1,27 @@
/**
* Seed presets for a workspace that has never saved Azure DevOps settings.
*
* NOTHING in this file is translated, and nothing in it should be.
*
* - The `wiql` filters are Azure DevOps' query language — `[System.State]`,
* `@Me`, `ORDER BY` — i.e. code, on the same footing as Jira's JQL and
* Sentry's search syntax.
* - `label`, `hint` and `promptTemplate` are PERSISTED. They seed the
* editable drafts in azure-devops-default-queries.tsx and
* azure-devops-quick-actions.tsx and are written back to workspace settings
* as `AzureDevOpsQueryPreset` / `AzureDevOpsActionPreset`, so translating
* them would write locale-dependent values into a user's saved presets and
* leave them there after a locale switch. `promptTemplate` is additionally
* sent to the agent verbatim.
* - They must also keep matching the server-side defaults in
* `apps/backend/internal/azuredevops/workspace_settings.go`, which has no
* locale: the backend returns those records whenever the client asks for a
* reset, so a translated copy here would flip back to English on save.
*
* Localizing them needs a key/persisted-value split in the preset type — the
* same open item recorded for GitHub's `DEFAULT_PR_PRESETS` and Jira's
* `my-jira/presets.ts`.
*/
import type { AzureDevOpsActionPreset, AzureDevOpsQueryPreset } from "@/lib/types/azure-devops";
const WIQL_START = "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project";
@@ -10,15 +10,20 @@ import {
type Icon,
} from "@tabler/icons-react";
export const ACTION_PRESET_ICON_CHOICES: Array<{ key: string; icon: Icon; label: string }> = [
{ key: "eye", icon: IconEye, label: "Eye" },
{ key: "message", icon: IconMessageDots, label: "Message" },
{ key: "tool", icon: IconTool, label: "Tool" },
{ key: "code", icon: IconCode, label: "Code" },
{ key: "search", icon: IconSearch, label: "Search" },
{ key: "bug", icon: IconBug, label: "Bug" },
{ key: "sparkle", icon: IconSparkles, label: "Sparkle" },
{ key: "check", icon: IconChecks, label: "Check" },
// `key` is the persisted icon enum stored on an action preset; only the label is
// copy, so it travels as a catalog key resolved at render (a module-scope `t()`
// would freeze at the boot locale — see docs/i18n.md). The keys live in
// `common` because this catalog is integration-agnostic; the GitHub and Jira
// copies of this shape predate it and keep their own namespaced keys.
export const ACTION_PRESET_ICON_CHOICES: Array<{ key: string; icon: Icon; labelKey: string }> = [
{ key: "eye", icon: IconEye, labelKey: "common:presetIconEye" },
{ key: "message", icon: IconMessageDots, labelKey: "common:presetIconMessage" },
{ key: "tool", icon: IconTool, labelKey: "common:presetIconTool" },
{ key: "code", icon: IconCode, labelKey: "common:presetIconCode" },
{ key: "search", icon: IconSearch, labelKey: "common:presetIconSearch" },
{ key: "bug", icon: IconBug, labelKey: "common:presetIconBug" },
{ key: "sparkle", icon: IconSparkles, labelKey: "common:presetIconSparkle" },
{ key: "check", icon: IconChecks, labelKey: "common:presetIconCheck" },
];
const ICONS = new Map(ACTION_PRESET_ICON_CHOICES.map((choice) => [choice.key, choice.icon]));
@@ -0,0 +1,266 @@
"use client";
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/components/toast-provider";
import { type IntegrationAuthHealth } from "@/components/integrations/auth-status-banner";
import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability";
import {
getSlackConfig,
setSlackConfig,
deleteSlackConfig,
testSlackConnection,
} from "@/lib/api/domains/slack-api";
import { listUtilityAgents, type UtilityAgent } from "@/lib/api/domains/utility-api";
import type { SlackConfig, TestSlackConnectionResult } from "@/lib/types/slack";
export const DEFAULT_PREFIX = "!kandev";
export const DEFAULT_POLL_INTERVAL_SECONDS = 30;
export const MIN_POLL_INTERVAL_SECONDS = 5;
export const MAX_POLL_INTERVAL_SECONDS = 600;
/**
* Slack credential and prompt-template tokens. Every one of these is a value the
* user has to find or type verbatim — a cookie name, a token prefix, a
* placeholder the backend substitutes — so they are interpolated into catalog
* messages rather than written into them, where the pseudo-locale would
* transliterate them into dead pointers.
*/
export const DEFAULT_PREFIX_EXAMPLE = `${DEFAULT_PREFIX} <instruction>`;
export const SLACK_TOKEN_PREFIX = "xoxc-";
export const SLACK_COOKIE_NAME = "d";
export const SLACK_TOKEN_PLACEHOLDER = "xoxc-...";
export const SLACK_COOKIE_PLACEHOLDER = "xoxd-...";
export const SECRET_MASK = "••••••••";
export const UTILITY_AGENTS_ROUTE = "/settings/utility-agents";
export const PROMPT_TOKENS = [
"{{SlackInstruction}}",
"{{SlackThread}}",
"{{SlackPermalink}}",
"{{SlackUser}}",
"{{SlackChannelID}}",
"{{SlackTS}}",
] as const;
export type FormState = {
utilityAgentId: string;
commandPrefix: string;
pollIntervalSeconds: number;
token: string;
cookie: string;
};
export const emptyForm: FormState = {
utilityAgentId: "",
commandPrefix: DEFAULT_PREFIX,
pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS,
token: "",
cookie: "",
};
export function configToForm(cfg: SlackConfig | null): FormState {
if (!cfg) return emptyForm;
return {
utilityAgentId: cfg.utilityAgentId,
commandPrefix: cfg.commandPrefix || DEFAULT_PREFIX,
pollIntervalSeconds: cfg.pollIntervalSeconds || DEFAULT_POLL_INTERVAL_SECONDS,
token: "",
cookie: "",
};
}
export function configToHealth(config: SlackConfig | null): IntegrationAuthHealth | null {
if (!config?.hasToken || !config.hasCookie) return null;
if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null };
return {
ok: !!config.lastOk,
error: config.lastError ?? "",
checkedAt: new Date(config.lastCheckedAt),
};
}
function useUtilityAgentsLoader() {
const [agents, setAgents] = useState<UtilityAgent[] | null>(null);
useEffect(() => {
let cancelled = false;
listUtilityAgents({ cache: "no-store" })
.then((res) => {
if (!cancelled) setAgents(res.agents ?? []);
})
.catch(() => {
if (!cancelled) setAgents([]);
});
return () => {
cancelled = true;
};
}, []);
return { agents: agents ?? [], loadingAgents: agents === null };
}
type SettingsActionsArgs = {
workspaceId: string;
form: FormState;
setConfig: (cfg: SlackConfig | null) => void;
setForm: Dispatch<SetStateAction<FormState>>;
setTestResult: (r: TestSlackConnectionResult | null) => void;
};
function useSettingsActions({
workspaceId,
form,
setConfig,
setForm,
setTestResult,
}: SettingsActionsArgs) {
const { t } = useTranslation();
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleTest = useCallback(async () => {
setTesting(true);
setTestResult(null);
try {
const res = await testSlackConnection(
{
authMethod: "cookie",
utilityAgentId: form.utilityAgentId,
commandPrefix: form.commandPrefix,
pollIntervalSeconds: form.pollIntervalSeconds,
token: form.token || undefined,
cookie: form.cookie || undefined,
},
{ workspaceId },
);
setTestResult(res);
} catch (err) {
setTestResult({ ok: false, error: String(err) });
} finally {
setTesting(false);
}
}, [workspaceId, form, setTestResult]);
const handleSave = useCallback(async () => {
const submitted = form;
setSaving(true);
try {
const saved = await setSlackConfig(
{
authMethod: "cookie",
utilityAgentId: form.utilityAgentId,
commandPrefix: form.commandPrefix,
pollIntervalSeconds: form.pollIntervalSeconds,
token: form.token || undefined,
cookie: form.cookie || undefined,
},
{ workspaceId },
);
setConfig(saved);
setForm((current) =>
JSON.stringify(current) === JSON.stringify(submitted) ? configToForm(saved) : current,
);
setTestResult(null);
toast({ description: t("slack:configurationSaved"), variant: "success" });
} catch (err) {
toast({ description: t("slack:saveFailed", { error: String(err) }), variant: "error" });
throw err;
} finally {
setSaving(false);
}
}, [workspaceId, form, t, toast, setConfig, setForm, setTestResult]);
const handleDelete = useCallback(async () => {
if (!confirm(t("slack:removeConfigurationConfirm"))) return;
setDeleting(true);
try {
await deleteSlackConfig({ workspaceId });
setConfig(null);
setForm(emptyForm);
setTestResult(null);
toast({ description: t("slack:configurationRemoved"), variant: "success" });
} catch (err) {
toast({ description: t("slack:deleteFailed", { error: String(err) }), variant: "error" });
} finally {
setDeleting(false);
}
}, [workspaceId, t, toast, setConfig, setForm, setTestResult]);
return { saving, testing, deleting, handleTest, handleSave, handleDelete };
}
export function useSlackSettings(workspaceId: string) {
const { t } = useTranslation();
const { toast } = useToast();
const [config, setConfig] = useState<SlackConfig | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [loading, setLoading] = useState(true);
const [testResult, setTestResult] = useState<TestSlackConnectionResult | null>(null);
const health = configToHealth(config);
const { agents, loadingAgents } = useUtilityAgentsLoader();
const load = useCallback(async () => {
setLoading(true);
try {
const cfg = await getSlackConfig({ workspaceId });
setConfig(cfg);
setForm(configToForm(cfg));
} catch (err) {
toast({
description: t("slack:failedToLoadConfig", { error: String(err) }),
variant: "error",
});
} finally {
setLoading(false);
}
}, [workspaceId, t, toast]);
useEffect(() => {
void load();
}, [load]);
// Background refresh so the auth-health banner picks up new probe results.
useEffect(() => {
const id = setInterval(() => {
getSlackConfig({ workspaceId })
.then((cfg) => setConfig(cfg))
.catch(() => {
/* transient failures are fine — next tick retries */
});
}, INTEGRATION_STATUS_REFRESH_MS);
return () => clearInterval(id);
}, [workspaceId]);
const update = useCallback(
<K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value })),
[],
);
const discard = useCallback(() => setForm(configToForm(config)), [config]);
const { saving, testing, deleting, handleTest, handleSave, handleDelete } = useSettingsActions({
workspaceId,
form,
setConfig,
setForm,
setTestResult,
});
return {
config,
form,
loading,
saving,
testing,
deleting,
testResult,
health,
agents,
loadingAgents,
update,
discard,
handleTest,
handleSave,
handleDelete,
};
}
@@ -0,0 +1,127 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
import type { SlackConfig } from "@/lib/types/slack";
import type { UtilityAgent } from "@/lib/api/domains/utility-api";
const mocks = vi.hoisted(() => ({
getConfig: vi.fn(),
setConfig: vi.fn(),
deleteConfig: vi.fn(),
testConnection: vi.fn(),
listUtilityAgents: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/hooks/domains/integrations/use-integration-availability", () => ({
INTEGRATION_STATUS_REFRESH_MS: 100_000,
}));
vi.mock("@/hooks/domains/slack/use-slack-enabled", () => ({
useSlackEnabled: () => ({ enabled: true, setEnabled: vi.fn() }),
}));
vi.mock("@/lib/api/domains/slack-api", () => ({
getSlackConfig: mocks.getConfig,
setSlackConfig: mocks.setConfig,
deleteSlackConfig: mocks.deleteConfig,
testSlackConnection: mocks.testConnection,
}));
vi.mock("@/lib/api/domains/utility-api", () => ({
listUtilityAgents: mocks.listUtilityAgents,
}));
import { SlackConnectionSection } from "@/components/slack/slack-settings";
const CONFIG: SlackConfig = {
authMethod: "cookie",
utilityAgentId: "agent-1",
commandPrefix: "!kandev",
pollIntervalSeconds: 30,
hasToken: true,
hasCookie: true,
lastOk: true,
createdAt: "2026-08-01T00:00:00Z",
updatedAt: "2026-08-01T00:00:00Z",
};
const AGENTS = [
{ id: "agent-1", name: "Triage", model: "claude-opus-5", enabled: true, agent_id: "claude" },
{ id: "agent-2", name: "Builtin", builtin: true },
{ id: "agent-3", name: "Paused", enabled: false, agent_id: "claude" },
] as unknown as UtilityAgent[];
function renderSection() {
return render(
<StateProvider>
<SettingsSaveProvider>
<SlackConnectionSection workspaceId="workspace-a" />
</SettingsSaveProvider>
</StateProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mocks.getConfig.mockResolvedValue(CONFIG);
mocks.listUtilityAgents.mockResolvedValue({ agents: AGENTS });
});
afterEach(cleanup);
describe("SlackConnectionSection", () => {
// Each block below is a `<Trans>` whose `<n>` indices address the JSX children
// positionally, so a reflow of those children silently reassembles the
// sentence into fragments. Assert the whole reconstructed paragraph.
it("renders the credential and prompt help with their verbatim tokens", async () => {
renderSection();
await waitFor(() => expect(mocks.getConfig).toHaveBeenCalled());
expect(
screen.getByText(
"Open Slack in your browser, copy the value of the `d` cookie and the `xoxc-` token from local storage. Both are required.",
),
).toBeTruthy();
const promptHelp = screen.getByText(/Custom prompts can reference/);
expect(promptHelp.textContent).toBe(
"Custom prompts can reference {{SlackInstruction}}, {{SlackThread}}, {{SlackPermalink}}, " +
"{{SlackUser}}, {{SlackChannelID}}, {{SlackTS}}. When at least one is used, your template " +
"owns the full prompt; otherwise the default Slack-triage system prompt is prepended " +
"automatically.",
);
const prefixHelp = screen.getByText(/Messages you write in Slack/);
expect(prefixHelp.textContent).toBe(
"Messages you write in Slack starting with this prefix become Kandev tasks. Default: " +
"!kandev <instruction>.",
);
const pollHelp = screen.getByText(/How often Slack is checked/);
expect(pollHelp.textContent).toBe(
"How often Slack is checked for new !kandev messages. Lower = more responsive, higher = " +
"fewer Slack API calls. Range: 5–600s. Default: 30s.",
);
// `<0>` is the <strong> lead-in, so getByText matches it rather than the
// paragraph — assert on the parent to cover the whole reassembled sentence.
const warningLead = screen.getByText("Browser session auth (unsupported):");
expect(warningLead.tagName).toBe("STRONG");
expect(warningLead.parentElement?.textContent).toBe(
"Browser session auth (unsupported): Slack rotates session cookies often, so you may need " +
"to reconnect when authentication expires. Bot installs and user OAuth are on the roadmap.",
);
});
it("labels utility agents from the record rather than concatenated copy", async () => {
renderSection();
await waitFor(() => expect(mocks.listUtilityAgents).toHaveBeenCalled());
// The trigger renders the selected agent's label.
await waitFor(() =>
expect(screen.getByText("Triage (claude-opus-5)", { exact: false })).toBeTruthy(),
);
});
});
+120 -301
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { Trans, useTranslation } from "react-i18next";
import Link from "@/components/routing/app-link";
import { IconBrandSlack } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -10,68 +10,32 @@ import { Label } from "@kandev/ui/label";
import { Separator } from "@kandev/ui/separator";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { SettingsCard } from "@/components/settings/settings-card";
import { useSlackEnabled } from "@/hooks/domains/slack/use-slack-enabled";
import {
IntegrationAuthStatusBanner,
type IntegrationAuthHealth,
} from "@/components/integrations/auth-status-banner";
import { IntegrationAuthStatusBanner } from "@/components/integrations/auth-status-banner";
import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section";
import { DraftedIntegrationEnabledControl } from "@/components/integrations/drafted-integration-enabled-control";
import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability";
import { type UtilityAgent } from "@/lib/api/domains/utility-api";
import type { TestSlackConnectionResult } from "@/lib/types/slack";
import {
getSlackConfig,
setSlackConfig,
deleteSlackConfig,
testSlackConnection,
} from "@/lib/api/domains/slack-api";
import { listUtilityAgents, type UtilityAgent } from "@/lib/api/domains/utility-api";
import type { SlackConfig, TestSlackConnectionResult } from "@/lib/types/slack";
const DEFAULT_PREFIX = "!kandev";
const DEFAULT_POLL_INTERVAL_SECONDS = 30;
const MIN_POLL_INTERVAL_SECONDS = 5;
const MAX_POLL_INTERVAL_SECONDS = 600;
type FormState = {
utilityAgentId: string;
commandPrefix: string;
pollIntervalSeconds: number;
token: string;
cookie: string;
};
const emptyForm: FormState = {
utilityAgentId: "",
commandPrefix: DEFAULT_PREFIX,
pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS,
token: "",
cookie: "",
};
function configToForm(cfg: SlackConfig | null): FormState {
if (!cfg) return emptyForm;
return {
utilityAgentId: cfg.utilityAgentId,
commandPrefix: cfg.commandPrefix || DEFAULT_PREFIX,
pollIntervalSeconds: cfg.pollIntervalSeconds || DEFAULT_POLL_INTERVAL_SECONDS,
token: "",
cookie: "",
};
}
function configToHealth(config: SlackConfig | null): IntegrationAuthHealth | null {
if (!config?.hasToken || !config.hasCookie) return null;
if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null };
return {
ok: !!config.lastOk,
error: config.lastError ?? "",
checkedAt: new Date(config.lastCheckedAt),
};
}
DEFAULT_PREFIX,
DEFAULT_PREFIX_EXAMPLE,
DEFAULT_POLL_INTERVAL_SECONDS,
MAX_POLL_INTERVAL_SECONDS,
MIN_POLL_INTERVAL_SECONDS,
PROMPT_TOKENS,
SECRET_MASK,
SLACK_COOKIE_NAME,
SLACK_COOKIE_PLACEHOLDER,
SLACK_TOKEN_PLACEHOLDER,
SLACK_TOKEN_PREFIX,
UTILITY_AGENTS_ROUTE,
configToForm,
useSlackSettings,
type FormState,
} from "./slack-settings-state";
type SecretFieldsProps = {
form: FormState;
@@ -90,21 +54,20 @@ function SecretFields({
hasSavedCookie,
update,
}: SecretFieldsProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="slack-token">
Session token (xoxc-…)
{t("slack:sessionToken", { prefix: SLACK_TOKEN_PREFIX })}
{hasSavedToken && (
<span className="text-xs text-muted-foreground ml-2">
(saved — leave blank to keep)
</span>
<span className="text-xs text-muted-foreground ml-2">{t("slack:savedLeaveBlank")}</span>
)}
</Label>
<Input
id="slack-token"
type="password"
placeholder={hasSavedToken ? "••••••••" : "xoxc-..."}
placeholder={hasSavedToken ? SECRET_MASK : SLACK_TOKEN_PLACEHOLDER}
value={form.token}
data-settings-dirty={form.token !== baseline.token}
onChange={(e) => update("token", e.target.value)}
@@ -113,25 +76,25 @@ function SecretFields({
</div>
<div className="space-y-1.5">
<Label htmlFor="slack-cookie">
d cookie value
{t("slack:cookieValue", { cookie: SLACK_COOKIE_NAME })}
{hasSavedCookie && (
<span className="text-xs text-muted-foreground ml-2">
(saved — leave blank to keep)
</span>
<span className="text-xs text-muted-foreground ml-2">{t("slack:savedLeaveBlank")}</span>
)}
</Label>
<Input
id="slack-cookie"
type="password"
placeholder={hasSavedCookie ? "••••••••" : "xoxd-..."}
placeholder={hasSavedCookie ? SECRET_MASK : SLACK_COOKIE_PLACEHOLDER}
value={form.cookie}
data-settings-dirty={form.cookie !== baseline.cookie}
onChange={(e) => update("cookie", e.target.value)}
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
Open Slack in your browser, copy the value of the `d` cookie and the `xoxc-` token from
local storage. Both are required.
{t("slack:credentialsHelp", {
cookie: SLACK_COOKIE_NAME,
prefix: SLACK_TOKEN_PREFIX,
})}
</p>
</div>
</div>
@@ -147,10 +110,12 @@ type UtilityAgentPickerProps = {
update: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
};
function utilityAgentPlaceholder(agents: UtilityAgent[], loading: boolean): string {
if (loading) return "Loading…";
if (agents.length === 0) return "Create one in Settings → Utility agents";
return "Choose a utility agent";
type Translate = (key: string, values?: Record<string, unknown>) => string;
function utilityAgentPlaceholder(t: Translate, agents: UtilityAgent[], loading: boolean): string {
if (loading) return t("slack:loadingEllipsis");
if (agents.length === 0) return t("slack:createUtilityAgentFirst");
return t("slack:chooseUtilityAgent");
}
function isAgentSelectable(a: UtilityAgent): boolean {
@@ -158,17 +123,19 @@ function isAgentSelectable(a: UtilityAgent): boolean {
return a.enabled && !!a.agent_id && !!a.model;
}
function modelSuffix(a: UtilityAgent): string {
if (a.model) return ` (${a.model})`;
if (a.builtin) return " (uses default model)";
return "";
// `name` and `model` are the agent record — data, so the label is assembled from
// catalog messages that interpolate them rather than by concatenating copy.
function utilityAgentBaseLabel(t: Translate, a: UtilityAgent): string {
if (a.model) return t("slack:agentWithModel", { name: a.name, model: a.model });
if (a.builtin) return t("slack:agentUsesDefaultModel", { name: a.name });
return a.name;
}
function utilityAgentLabel(a: UtilityAgent): string {
const base = `${a.name}${modelSuffix(a)}`;
function utilityAgentLabel(t: Translate, a: UtilityAgent): string {
const base = utilityAgentBaseLabel(t, a);
if (a.builtin) return base;
if (!a.enabled) return `${base} — disabled`;
if (!a.agent_id || !a.model) return `${base} — not configured`;
if (!a.enabled) return t("slack:agentDisabled", { agent: base });
if (!a.agent_id || !a.model) return t("slack:agentNotConfigured", { agent: base });
return base;
}
@@ -180,9 +147,10 @@ function UtilityAgentPicker({
loadingAgents,
update,
}: UtilityAgentPickerProps) {
const { t } = useTranslation();
return (
<div className="space-y-1.5">
<Label htmlFor="slack-utility-agent">Triage agent</Label>
<Label htmlFor="slack-utility-agent">{t("slack:triageAgent")}</Label>
<Select
value={form.utilityAgentId || ""}
onValueChange={(v) => update("utilityAgentId", v)}
@@ -193,32 +161,42 @@ function UtilityAgentPicker({
className="w-full"
data-settings-dirty={form.utilityAgentId !== baseline.utilityAgentId}
>
<SelectValue placeholder={utilityAgentPlaceholder(agents, loadingAgents)} />
<SelectValue placeholder={utilityAgentPlaceholder(t, agents, loadingAgents)} />
</SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id} disabled={!isAgentSelectable(a)}>
{utilityAgentLabel(a)}
{utilityAgentLabel(t, a)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
The utility agent that interprets each Slack message and creates the Kandev task. It runs
with Kandev MCP tools wired in (list_workspaces_kandev, create_task_kandev, …) so it picks
the destination Kandev workspace + workflow + repo from context. Built-in agents use your
default model from{" "}
<Link href="/settings/utility-agents" className="underline cursor-pointer">
Settings → Utility agents
</Link>
.
{/* The three MCP tool names are identifiers, so they are interpolated
rather than written into the catalog. */}
<Trans
i18nKey="slack:triageAgentHelp"
values={{ tools: "list_workspaces_kandev, create_task_kandev, …" }}
>
The utility agent that interprets each Slack message and creates the Kandev task. It runs
with Kandev MCP tools wired in ({"{{tools}}"}) so it picks the destination Kandev
workspace + workflow + repo from context. Built-in agents use your default model from{" "}
<Link href={UTILITY_AGENTS_ROUTE} className="underline cursor-pointer">
Settings → Utility agents
</Link>
.
</Trans>
</p>
<p className="text-xs text-muted-foreground">
Custom prompts can reference <code>{"{{SlackInstruction}}"}</code>,{" "}
<code>{"{{SlackThread}}"}</code>, <code>{"{{SlackPermalink}}"}</code>,{" "}
<code>{"{{SlackUser}}"}</code>, <code>{"{{SlackChannelID}}"}</code>, and{" "}
<code>{"{{SlackTS}}"}</code>. When at least one is used, your template owns the full prompt;
otherwise the default Slack-triage system prompt is prepended automatically.
{/* The six tokens are prompt syntax the backend substitutes, so they are
interpolated as one monospace run rather than written into the
catalog — six separate <code> children would make the tag indices
drift on any reflow. */}
<Trans i18nKey="slack:promptTokensHelp" values={{ tokens: PROMPT_TOKENS.join(", ") }}>
Custom prompts can reference <code>{"{{tokens}}"}</code>. When at least one is used, your
template owns the full prompt; otherwise the default Slack-triage system prompt is
prepended automatically.
</Trans>
</p>
</div>
);
@@ -235,9 +213,10 @@ function PrefixField({
loading: boolean;
update: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-1.5">
<Label htmlFor="slack-prefix">Command prefix</Label>
<Label htmlFor="slack-prefix">{t("slack:commandPrefix")}</Label>
<Input
id="slack-prefix"
type="text"
@@ -248,8 +227,9 @@ function PrefixField({
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
Messages you write in Slack starting with this prefix become Kandev tasks. Default:{" "}
<code>{DEFAULT_PREFIX} &lt;instruction&gt;</code>.
{/* Not a <Trans>: the example contains literal angle brackets, which
html-parse-stringify would read as a tag inside the message. */}
{t("slack:commandPrefixHelp")} <code>{DEFAULT_PREFIX_EXAMPLE}</code>.
</p>
</div>
);
@@ -266,9 +246,10 @@ function PollIntervalField({
loading: boolean;
update: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-1.5">
<Label htmlFor="slack-poll-interval">Polling interval (seconds)</Label>
<Label htmlFor="slack-poll-interval">{t("slack:pollingInterval")}</Label>
<Input
id="slack-poll-interval"
type="number"
@@ -284,25 +265,40 @@ function PollIntervalField({
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
How often Slack is checked for new <code>{form.commandPrefix || DEFAULT_PREFIX}</code>{" "}
messages. Lower = more responsive, higher = fewer Slack API calls. Range:{" "}
{MIN_POLL_INTERVAL_SECONDS}–{MAX_POLL_INTERVAL_SECONDS}s. Default:{" "}
{DEFAULT_POLL_INTERVAL_SECONDS}s.
<Trans
i18nKey="slack:pollingIntervalHelp"
values={{
prefix: form.commandPrefix || DEFAULT_PREFIX,
min: MIN_POLL_INTERVAL_SECONDS,
max: MAX_POLL_INTERVAL_SECONDS,
fallback: DEFAULT_POLL_INTERVAL_SECONDS,
}}
>
How often Slack is checked for new <code>{"{{prefix}}"}</code> messages. Lower = more
responsive, higher = fewer Slack API calls. Range: {"{{min}}"}–{"{{max}}"}s. Default:{" "}
{"{{fallback}}"}s.
</Trans>
</p>
</div>
);
}
// `displayName` / `userId` / `teamName` all come from the Slack API — data, so
// they are interpolated into the message rather than concatenated around it.
function testResultMessage(t: Translate, result: TestSlackConnectionResult): string {
if (!result.ok) return t("slack:testFailed", { error: result.error });
const name = result.displayName || result.userId;
return result.teamName
? t("slack:connectedAsWithTeam", { name, team: result.teamName })
: t("slack:connectedAs", { name });
}
function TestResultAlert({ result }: { result: TestSlackConnectionResult | null }) {
const { t } = useTranslation();
if (!result) return null;
const teamSuffix = result.teamName ? ` (${result.teamName})` : "";
return (
<Alert variant={result.ok ? "default" : "destructive"}>
<AlertDescription>
{result.ok
? `Connected as ${result.displayName || result.userId}${teamSuffix}`
: `Failed: ${result.error}`}
</AlertDescription>
<AlertDescription>{testResultMessage(t, result)}</AlertDescription>
</Alert>
);
}
@@ -311,9 +307,11 @@ function UnsupportedWarning() {
return (
<Alert>
<AlertDescription className="text-xs">
<strong>Browser session auth (unsupported):</strong> Slack rotates session cookies often, so
you may need to reconnect when authentication expires. Bot installs and user OAuth are on
the roadmap.
<Trans i18nKey="slack:unsupportedAuthWarning">
<strong>Browser session auth (unsupported):</strong> Slack rotates session cookies often,
so you may need to reconnect when authentication expires. Bot installs and user OAuth are
on the roadmap.
</Trans>
</AlertDescription>
</Alert>
);
@@ -338,6 +336,7 @@ function ActionBar({
onTest,
onDelete,
}: ActionBarProps) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-2">
<Button
@@ -346,9 +345,9 @@ function ActionBar({
onClick={onTest}
disabled={testing || loading || disableTest}
className="cursor-pointer"
title={disableTest ? "Paste a token and cookie to test the connection" : undefined}
title={disableTest ? t("slack:pasteCredentialsToTest") : undefined}
>
{testing ? "Testing..." : "Test connection"}
{testing ? t("slack:testing") : t("slack:testConnection")}
</Button>
{hasConfig && (
<Button
@@ -358,200 +357,20 @@ function ActionBar({
disabled={deleting}
className="ml-auto cursor-pointer"
>
{deleting ? "Removing..." : "Remove configuration"}
{deleting ? t("slack:removing") : t("slack:removeConfiguration")}
</Button>
)}
</div>
);
}
function useUtilityAgentsLoader() {
const [agents, setAgents] = useState<UtilityAgent[] | null>(null);
useEffect(() => {
let cancelled = false;
listUtilityAgents({ cache: "no-store" })
.then((res) => {
if (!cancelled) setAgents(res.agents ?? []);
})
.catch(() => {
if (!cancelled) setAgents([]);
});
return () => {
cancelled = true;
};
}, []);
return { agents: agents ?? [], loadingAgents: agents === null };
}
type SettingsActionsArgs = {
workspaceId: string;
form: FormState;
setConfig: (cfg: SlackConfig | null) => void;
setForm: Dispatch<SetStateAction<FormState>>;
setTestResult: (r: TestSlackConnectionResult | null) => void;
};
function useSettingsActions({
workspaceId,
form,
setConfig,
setForm,
setTestResult,
}: SettingsActionsArgs) {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleTest = useCallback(async () => {
setTesting(true);
setTestResult(null);
try {
const res = await testSlackConnection(
{
authMethod: "cookie",
utilityAgentId: form.utilityAgentId,
commandPrefix: form.commandPrefix,
pollIntervalSeconds: form.pollIntervalSeconds,
token: form.token || undefined,
cookie: form.cookie || undefined,
},
{ workspaceId },
);
setTestResult(res);
} catch (err) {
setTestResult({ ok: false, error: String(err) });
} finally {
setTesting(false);
}
}, [workspaceId, form, setTestResult]);
const handleSave = useCallback(async () => {
const submitted = form;
setSaving(true);
try {
const saved = await setSlackConfig(
{
authMethod: "cookie",
utilityAgentId: form.utilityAgentId,
commandPrefix: form.commandPrefix,
pollIntervalSeconds: form.pollIntervalSeconds,
token: form.token || undefined,
cookie: form.cookie || undefined,
},
{ workspaceId },
);
setConfig(saved);
setForm((current) =>
JSON.stringify(current) === JSON.stringify(submitted) ? configToForm(saved) : current,
);
setTestResult(null);
toast({ description: "Slack configuration saved", variant: "success" });
} catch (err) {
toast({ description: `Save failed: ${String(err)}`, variant: "error" });
throw err;
} finally {
setSaving(false);
}
}, [workspaceId, form, toast, setConfig, setForm, setTestResult]);
const handleDelete = useCallback(async () => {
if (!confirm("Remove Slack configuration?")) return;
setDeleting(true);
try {
await deleteSlackConfig({ workspaceId });
setConfig(null);
setForm(emptyForm);
setTestResult(null);
toast({ description: "Slack configuration removed", variant: "success" });
} catch (err) {
toast({ description: `Delete failed: ${String(err)}`, variant: "error" });
} finally {
setDeleting(false);
}
}, [workspaceId, toast, setConfig, setForm, setTestResult]);
return { saving, testing, deleting, handleTest, handleSave, handleDelete };
}
function useSlackSettings(workspaceId: string) {
const { toast } = useToast();
const [config, setConfig] = useState<SlackConfig | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [loading, setLoading] = useState(true);
const [testResult, setTestResult] = useState<TestSlackConnectionResult | null>(null);
const health = configToHealth(config);
const { agents, loadingAgents } = useUtilityAgentsLoader();
const load = useCallback(async () => {
setLoading(true);
try {
const cfg = await getSlackConfig({ workspaceId });
setConfig(cfg);
setForm(configToForm(cfg));
} catch (err) {
toast({ description: `Failed to load Slack config: ${String(err)}`, variant: "error" });
} finally {
setLoading(false);
}
}, [workspaceId, toast]);
useEffect(() => {
void load();
}, [load]);
// Background refresh so the auth-health banner picks up new probe results.
useEffect(() => {
const id = setInterval(() => {
getSlackConfig({ workspaceId })
.then((cfg) => setConfig(cfg))
.catch(() => {
/* transient failures are fine — next tick retries */
});
}, INTEGRATION_STATUS_REFRESH_MS);
return () => clearInterval(id);
}, [workspaceId]);
const update = useCallback(
<K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value })),
[],
);
const discard = useCallback(() => setForm(configToForm(config)), [config]);
const { saving, testing, deleting, handleTest, handleSave, handleDelete } = useSettingsActions({
workspaceId,
form,
setConfig,
setForm,
setTestResult,
});
return {
config,
form,
loading,
saving,
testing,
deleting,
testResult,
health,
agents,
loadingAgents,
update,
discard,
handleTest,
handleSave,
handleDelete,
};
}
function EnabledPill() {
const { enabled, setEnabled } = useSlackEnabled();
return <DraftedIntegrationEnabledControl id="slack" enabled={enabled} persist={setEnabled} />;
}
export function SlackConnectionSection({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const s = useSlackSettings(workspaceId);
const baseline = configToForm(s.config);
const missingSecrets =
@@ -562,8 +381,8 @@ export function SlackConnectionSection({ workspaceId }: { workspaceId: string })
const revision = JSON.stringify(s.form);
const dirty = !s.loading && revision !== JSON.stringify(configToForm(s.config));
let invalidReason: string | undefined;
if (missingSecrets) invalidReason = "A session token and cookie are required.";
else if (missingAgent) invalidReason = "A triage agent is required.";
if (missingSecrets) invalidReason = t("slack:secretsRequired");
else if (missingAgent) invalidReason = t("slack:triageAgentRequired");
useSettingsSaveContributor({
id: `slack-config:${workspaceId}`,
@@ -578,8 +397,8 @@ export function SlackConnectionSection({ workspaceId }: { workspaceId: string })
return (
<SettingsSection
icon={<IconBrandSlack className="h-5 w-5" />}
title="Slack integration"
description="Capture Slack threads as tasks for the selected workspace. Type !kandev <instruction> in any thread you can see and the configured utility agent creates the task."
title={t("slack:integrationTitle")}
description={t("slack:integrationDescription", { example: DEFAULT_PREFIX_EXAMPLE })}
action={<EnabledPill />}
>
<SettingsCard isDirty={dirty}>
+42
View File
@@ -412,4 +412,46 @@ export const i18nGuardFiles = [
"components/sentry/sentry-issue-watch-throttle-field.tsx",
"components/sentry/sentry-issue-watchers-section.tsx",
"components/sentry/sentry-settings.tsx",
// Settings → Integrations → Azure DevOps: the page and the settings subtree of
// `components/azure-devops` — the connection card with its PAT help tooltip,
// the work-item / pull-request watch settings, the quick-action presets and
// the default-query editor. The board, work-item and pull-request task
// surfaces (`azure-devops-board*`, `azure-devops-results`, `-filters`,
// `-scope-bar`, `-task-*`, `-work-item-detail`, `-feedback-dialog`,
// `-save-view-dialog`, `-task-launcher`) live in the same directory and are
// NOT migrated, which is why this is a file list rather than
// `components/azure-devops/**`.
//
// `azure-devops-workspace-defaults.ts` holds no JSX, so `mode: "jsx-only"`
// never inspects it; the entry records that it has been reviewed, and only the
// pseudo-locale can prove it stays that way. Everything in it is deliberately
// left in English: the WIQL filters are Azure DevOps' query language, and the
// preset `label` / `hint` / `promptTemplate` records are PERSISTED to
// workspace settings and must keep matching the server-side defaults in
// `apps/backend/internal/azuredevops/workspace_settings.go`, which has no
// locale.
"app/settings/integrations/azure-devops/**/*.{ts,tsx}",
"components/azure-devops/azure-devops-default-queries.tsx",
"components/azure-devops/azure-devops-quick-actions.tsx",
"components/azure-devops/azure-devops-settings.tsx",
"components/azure-devops/azure-devops-watch-settings.tsx",
"components/azure-devops/azure-devops-workspace-defaults.ts",
// Shared preset-icon catalog rendered by the Azure DevOps quick actions. Also
// `.ts`-only, so the entry records the review rather than enforcing it; the
// icon `key`s are the persisted enum and only the labels are copy, which now
// travel as `labelKey` and resolve at render.
"components/integrations/action-preset-icons.ts",
// Settings → Integrations → Slack: the page and the whole of
// `components/slack`, which holds nothing else. `slack-settings-state.ts` is
// the form state and API actions split out of the component so it stays under
// the 600-line cap; it holds no JSX, so `mode: "jsx-only"` never inspects it
// and only the pseudo-locale can prove its toast/confirm copy stays migrated.
//
// Deliberately left in English, each a value the user must find or type
// verbatim rather than copy: the `xoxc-` / `xoxd-` token prefixes, the `d`
// cookie name, the `••••••••` mask, the `!kandev <instruction>` command
// example, and the six `{{Slack…}}` prompt tokens. All are interpolated as
// values so the pseudo-locale cannot turn them into dead pointers.
"app/settings/integrations/slack/**/*.{ts,tsx}",
"components/slack/**/*.{ts,tsx}",
];
+156
View File
@@ -0,0 +1,156 @@
{
"actionHintAria": "{{kind}} action hint {{index}}",
"actionLabelAria": "{{kind}} action label {{index}}",
"addPrAction": "Add PR action",
"addPrQuery": "Add PR query",
"addPrWatch": "Add PR watch",
"addWorkItemAction": "Add work item action",
"addWorkItemQuery": "Add work item query",
"addWorkItemWatch": "Add work-item watch",
"agentProfileId": "Agent profile ID",
"azureProjectId": "Azure project ID",
"azureRepositoryIdOptional": "Azure repository ID (optional)",
"baseBranch": "Base branch",
"checkedAt": "checked {{at}}",
"cleanupAlways": "always",
"cleanupAuto": "auto",
"cleanupNever": "never",
"cleanupPolicy": "Cleanup policy",
"cleanupPolicyAlways": "Always",
"cleanupPolicyAuto": "Auto",
"cleanupPolicyNever": "Never",
"closeWatchEditor": "Close watch editor",
"configurationRemoved": "Azure DevOps configuration removed",
"configurationSaved": "Azure DevOps configuration saved",
"connected": "Connected",
"connectedAs": "Connected as {{name}}",
"connectionFailed": "Connection failed",
"couldNotLoadQuickActions": "Could not load existing quick actions: {{error}}",
"createPersonalAccessToken": "Create personal access token",
"createWatch": "Create watch",
"creator": "Creator",
"creatorId": "Creator ID",
"defaultProject": "Default project",
"defaultQueries": "Default queries",
"defaultQueriesDescription": "Query shortcuts shown on {{path}} for pull requests and work items.",
"defaultQueriesSaved": "Azure DevOps default queries saved",
"defaultQueryInvalid": "Every default query needs a label and valid filters.",
"delete": "Delete",
"deleteWatchConfirm": "Delete this watch?",
"disable": "Disable",
"disabled": "Disabled",
"edit": "Edit",
"editPrompt": "Edit prompt",
"editWatchTitle": "Edit {{kind}} watch",
"enable": "Enable",
"enabled": "Enabled",
"enterValidOrganizationUrl": "Enter a valid organization URL to open its token settings.",
"executorProfileId": "Executor profile ID",
"failedToLoadConfig": "Failed to load Azure DevOps config: {{error}}",
"failedToLoadQuickActions": "Failed to load quick actions.",
"failedToSaveDefaultQueries": "Failed to save default queries",
"failedToSaveQuickActions": "Failed to save quick actions",
"group": "Group",
"groupCreated": "Created",
"groupInbox": "Inbox",
"hidePrompt": "Hide prompt",
"hint": "Hint",
"hintOptional": "Hint (optional)",
"howToCreatePat": "How to create a personal access token",
"icon": "Icon",
"identityIdPlaceholder": "{{token}} or identity ID",
"identityPlaceholder": "{{token}} or identity",
"integrationDescription": "Azure DevOps Services organization, project, and PAT for this workspace.",
"integrationTitle": "Azure DevOps integration",
"kandevRepositoryId": "Kandev repository ID",
"label": "Label",
"loadingProjects": "Loading projects...",
"loadingWatchers": "Loading watchers…",
"maxInflightTasks": "Max in-flight tasks (0 = unlimited)",
"newMatches_one": "{{count}} new match.",
"newMatches_other": "{{count}} new matches.",
"newWatchTitle": "New {{kind}} watch",
"noPullRequestWatches": "No pull-request watches yet.",
"notCheckedYet": "Not checked yet",
"noWorkItemWatches": "No work-item watches yet.",
"optional": "Optional",
"organizationUrl": "Organization URL",
"pastePat": "Paste PAT",
"patHelpTitle": "Create a personal access token for board editing",
"patStepCheckScopes": "Under <1>{{workItems}}</1>, check Read & write. Under <4>{{code}}</4>, check Read. Leave all other scopes unchecked.",
"patStepChooseScopes": "Choose this organization, a short expiration, and Custom defined scopes.",
"patStepCreateAndPaste": "Create the token, copy it, and paste it into this field.",
"patStepOpenTokenSettings": "Open token settings and select New Token.",
"personalAccessToken": "Personal Access Token",
"placeholderTitleDescription": "Title of the Azure DevOps work item or pull request",
"placeholderUrlDescription": "URL of the Azure DevOps work item or pull request",
"pollIntervalSeconds": "Poll interval (seconds)",
"prompt": "Prompt",
"promptPlaceholdersHint": "Type {{open}} to see available placeholders. <3>{{url}}</3> and <6>{{title}}</6> are substituted when the action runs.",
"pullRequestAny": "any",
"pullRequestFilter": "Pull-request filter",
"pullRequestKindLower": "pull request",
"pullRequestKindNoun": "pull-request",
"pullRequestKindTitle": "Pull request",
"pullRequestsTab": "Pull requests",
"pullRequestWatches": "Pull-request watches",
"pullRequestWatchesDescription": "Create tasks automatically from matching Azure DevOps pull requests.",
"pullRequestWatchFieldsRequired": "Project, repository, workflow, step, agent, and executor are required.",
"queryCreatorAria": "{{kind}} query creator {{index}}",
"queryGroupAria": "{{kind}} query group {{index}}",
"queryLabelAria": "{{kind}} query label {{index}}",
"queryReviewerAria": "{{kind}} query reviewer {{index}}",
"queryStatusAria": "{{kind}} query status {{index}}",
"queryTopAria": "{{kind}} query top {{index}}",
"queryWiqlAria": "{{kind}} query WIQL {{index}}",
"quickActionInvalid": "Every quick action needs a label and prompt.",
"quickActions": "Quick actions",
"quickActionsDescription": "Prompts shown on {{path}} when starting a task from a pull request or work item.",
"quickActionsSaved": "Azure DevOps quick actions saved",
"remove": "Remove",
"removeActionAria": "Remove {{kind}} action {{index}}",
"removeConfigurationConfirm": "Remove Azure DevOps configuration?",
"removeFailed": "Remove failed: {{error}}",
"removeQueryAria": "Remove {{kind}} query {{index}}",
"resetWatchConfirm_one": "Reset this {{policy}} watch and remove {{count}} task?",
"resetWatchConfirm_other": "Reset this {{policy}} watch and remove {{count}} tasks?",
"reviewer": "Reviewer",
"reviewerId": "Reviewer ID",
"runNow": "Run now",
"save": "Save",
"savedCredential": "Saved credential",
"saveFailed": "Save failed: {{error}}",
"saving": "Saving...",
"savingEllipsis": "Saving…",
"status": "Status",
"statusAbandoned": "Abandoned",
"statusActive": "Active",
"statusAll": "All",
"statusAny": "Any status",
"statusCompleted": "Completed",
"statusLine": "Status: {{status}}",
"testConnection": "Test connection",
"testing": "Testing...",
"top": "Top",
"update": "Update",
"updateWatch": "Update watch",
"watchDeleted": "Watch deleted.",
"watchDisabled": "Watch disabled.",
"watchEnabled": "Watch enabled.",
"watchReset": "Watch reset.",
"watchStatusAbandoned": "abandoned",
"watchStatusActive": "active",
"watchStatusCompleted": "completed",
"watchSummary_one": "{{projectId}} · every {{count}}s · cleanup {{policy}}",
"watchSummary_other": "{{projectId}} · every {{count}}s · cleanup {{policy}}",
"workflowId": "Workflow ID",
"workflowStepId": "Workflow step ID",
"workItemKindLower": "work item",
"workItemKindNoun": "work-item",
"workItemKindTitle": "Work item",
"workItemQuery": "Work-item query",
"workItemsTab": "Work items",
"workItemWatches": "Work-item watches",
"workItemWatchesDescription": "Create tasks automatically from matching Azure DevOps work-item queries.",
"workItemWatchFieldsRequired": "Project, WIQL, repository, workflow, step, agent, and executor are required."
}
+9 -1
View File
@@ -32,5 +32,13 @@
"workspace": "Workspace",
"workspaces": "Workspaces",
"title": "Title",
"subtaskTitle": "Subtask title"
"subtaskTitle": "Subtask title",
"presetIconEye": "Eye",
"presetIconMessage": "Message",
"presetIconTool": "Tool",
"presetIconCode": "Code",
"presetIconSearch": "Search",
"presetIconBug": "Bug",
"presetIconSparkle": "Sparkle",
"presetIconCheck": "Check"
}
+39
View File
@@ -0,0 +1,39 @@
{
"agentDisabled": "{{agent}} — disabled",
"agentNotConfigured": "{{agent}} — not configured",
"agentUsesDefaultModel": "{{name}} (uses default model)",
"agentWithModel": "{{name}} ({{model}})",
"chooseUtilityAgent": "Choose a utility agent",
"commandPrefix": "Command prefix",
"commandPrefixHelp": "Messages you write in Slack starting with this prefix become Kandev tasks. Default:",
"configurationRemoved": "Slack configuration removed",
"configurationSaved": "Slack configuration saved",
"connectedAs": "Connected as {{name}}",
"connectedAsWithTeam": "Connected as {{name}} ({{team}})",
"cookieValue": "{{cookie}} cookie value",
"createUtilityAgentFirst": "Create one in Settings → Utility agents",
"credentialsHelp": "Open Slack in your browser, copy the value of the `{{cookie}}` cookie and the `{{prefix}}` token from local storage. Both are required.",
"deleteFailed": "Delete failed: {{error}}",
"failedToLoadConfig": "Failed to load Slack config: {{error}}",
"integrationDescription": "Capture Slack threads as tasks for the selected workspace. Type {{example}} in any thread you can see and the configured utility agent creates the task.",
"integrationTitle": "Slack integration",
"loadingEllipsis": "Loading…",
"pasteCredentialsToTest": "Paste a token and cookie to test the connection",
"pollingInterval": "Polling interval (seconds)",
"pollingIntervalHelp": "How often Slack is checked for new <1>{{prefix}}</1> messages. Lower = more responsive, higher = fewer Slack API calls. Range: {{min}}–{{max}}s. Default: {{fallback}}s.",
"promptTokensHelp": "Custom prompts can reference <1>{{tokens}}</1>. When at least one is used, your template owns the full prompt; otherwise the default Slack-triage system prompt is prepended automatically.",
"removeConfiguration": "Remove configuration",
"removeConfigurationConfirm": "Remove Slack configuration?",
"removing": "Removing...",
"saveFailed": "Save failed: {{error}}",
"savedLeaveBlank": "(saved — leave blank to keep)",
"secretsRequired": "A session token and cookie are required.",
"sessionToken": "Session token ({{prefix}}…)",
"testConnection": "Test connection",
"testFailed": "Failed: {{error}}",
"testing": "Testing...",
"triageAgent": "Triage agent",
"triageAgentHelp": "The utility agent that interprets each Slack message and creates the Kandev task. It runs with Kandev MCP tools wired in ({{tools}}) so it picks the destination Kandev workspace + workflow + repo from context. Built-in agents use your default model from <4>Settings → Utility agents</4>.",
"triageAgentRequired": "A triage agent is required.",
"unsupportedAuthWarning": "<0>Browser session auth (unsupported):</0> Slack rotates session cookies often, so you may need to reconnect when authentication expires. Bot installs and user OAuth are on the roadmap."
}
@@ -0,0 +1,156 @@
{
"actionHintAria": "{{kind}} àćţĩōń ĥĩńţ {{index}}",
"actionLabelAria": "{{kind}} àćţĩōń ĺàƀēĺ {{index}}",
"addPrAction": "Àďď ƤŔ àćţĩōń",
"addPrQuery": "Àďď ƤŔ qũēŕŷ",
"addPrWatch": "Àďď ƤŔ ŵàţćĥ",
"addWorkItemAction": "Àďď ŵōŕķ ĩţēḿ àćţĩōń",
"addWorkItemQuery": "Àďď ŵōŕķ ĩţēḿ qũēŕŷ",
"addWorkItemWatch": "Àďď ŵōŕķ-ĩţēḿ ŵàţćĥ",
"agentProfileId": "Àĝēńţ ƥŕōƒĩĺē ĨĎ",
"azureProjectId": "Àźũŕē ƥŕōĵēćţ ĨĎ",
"azureRepositoryIdOptional": "Àźũŕē ŕēƥōśĩţōŕŷ ĨĎ (ōƥţĩōńàĺ)",
"baseBranch": "Ɓàśē ƀŕàńćĥ",
"checkedAt": "ćĥēćķēď {{at}}",
"cleanupAlways": "àĺŵàŷś",
"cleanupAuto": "àũţō",
"cleanupNever": "ńēvēŕ",
"cleanupPolicy": "Ćĺēàńũƥ ƥōĺĩćŷ",
"cleanupPolicyAlways": "Àĺŵàŷś",
"cleanupPolicyAuto": "Àũţō",
"cleanupPolicyNever": "Ńēvēŕ",
"closeWatchEditor": "Ćĺōśē ŵàţćĥ ēďĩţōŕ",
"configurationRemoved": "Àźũŕē ĎēvŌƥś ćōńƒĩĝũŕàţĩōń ŕēḿōvēď",
"configurationSaved": "Àźũŕē ĎēvŌƥś ćōńƒĩĝũŕàţĩōń śàvēď",
"connected": "Ćōńńēćţēď",
"connectedAs": "Ćōńńēćţēď àś {{name}}",
"connectionFailed": "Ćōńńēćţĩōń ƒàĩĺēď",
"couldNotLoadQuickActions": "Ćōũĺď ńōţ ĺōàď ēxĩśţĩńĝ qũĩćķ àćţĩōńś: {{error}}",
"createPersonalAccessToken": "Ćŕēàţē ƥēŕśōńàĺ àććēśś ţōķēń",
"createWatch": "Ćŕēàţē ŵàţćĥ",
"creator": "Ćŕēàţōŕ",
"creatorId": "Ćŕēàţōŕ ĨĎ",
"defaultProject": "Ďēƒàũĺţ ƥŕōĵēćţ",
"defaultQueries": "Ďēƒàũĺţ qũēŕĩēś",
"defaultQueriesDescription": "Qũēŕŷ śĥōŕţćũţś śĥōŵń ōń {{path}} ƒōŕ ƥũĺĺ ŕēqũēśţś àńď ŵōŕķ ĩţēḿś.",
"defaultQueriesSaved": "Àźũŕē ĎēvŌƥś ďēƒàũĺţ qũēŕĩēś śàvēď",
"defaultQueryInvalid": "Ēvēŕŷ ďēƒàũĺţ qũēŕŷ ńēēďś à ĺàƀēĺ àńď vàĺĩď ƒĩĺţēŕś.",
"delete": "Ďēĺēţē",
"deleteWatchConfirm": "Ďēĺēţē ţĥĩś ŵàţćĥ?",
"disable": "Ďĩśàƀĺē",
"disabled": "Ďĩśàƀĺēď",
"edit": "Ēďĩţ",
"editPrompt": "Ēďĩţ ƥŕōḿƥţ",
"editWatchTitle": "Ēďĩţ {{kind}} ŵàţćĥ",
"enable": "Ēńàƀĺē",
"enabled": "Ēńàƀĺēď",
"enterValidOrganizationUrl": "Ēńţēŕ à vàĺĩď ōŕĝàńĩźàţĩōń ŨŔĹ ţō ōƥēń ĩţś ţōķēń śēţţĩńĝś.",
"executorProfileId": "Ēxēćũţōŕ ƥŕōƒĩĺē ĨĎ",
"failedToLoadConfig": "Ƒàĩĺēď ţō ĺōàď Àźũŕē ĎēvŌƥś ćōńƒĩĝ: {{error}}",
"failedToLoadQuickActions": "Ƒàĩĺēď ţō ĺōàď qũĩćķ àćţĩōńś.",
"failedToSaveDefaultQueries": "Ƒàĩĺēď ţō śàvē ďēƒàũĺţ qũēŕĩēś",
"failedToSaveQuickActions": "Ƒàĩĺēď ţō śàvē qũĩćķ àćţĩōńś",
"group": "Ĝŕōũƥ",
"groupCreated": "Ćŕēàţēď",
"groupInbox": "Ĩńƀōx",
"hidePrompt": "Ĥĩďē ƥŕōḿƥţ",
"hint": "Ĥĩńţ",
"hintOptional": "Ĥĩńţ (ōƥţĩōńàĺ)",
"howToCreatePat": "Ĥōŵ ţō ćŕēàţē à ƥēŕśōńàĺ àććēśś ţōķēń",
"icon": "Ĩćōń",
"identityIdPlaceholder": "{{token}} ōŕ ĩďēńţĩţŷ ĨĎ",
"identityPlaceholder": "{{token}} ōŕ ĩďēńţĩţŷ",
"integrationDescription": "Àźũŕē ĎēvŌƥś Śēŕvĩćēś ōŕĝàńĩźàţĩōń, ƥŕōĵēćţ, àńď ƤÀŢ ƒōŕ ţĥĩś ŵōŕķśƥàćē.",
"integrationTitle": "Àźũŕē ĎēvŌƥś ĩńţēĝŕàţĩōń",
"kandevRepositoryId": "Ķàńďēv ŕēƥōśĩţōŕŷ ĨĎ",
"label": "Ĺàƀēĺ",
"loadingProjects": "Ĺōàďĩńĝ ƥŕōĵēćţś...",
"loadingWatchers": "Ĺōàďĩńĝ ŵàţćĥēŕś…",
"maxInflightTasks": "Ḿàx ĩń-ƒĺĩĝĥţ ţàśķś (0 = ũńĺĩḿĩţēď)",
"newMatches_one": "{{count}} ńēŵ ḿàţćĥ.",
"newMatches_other": "{{count}} ńēŵ ḿàţćĥēś.",
"newWatchTitle": "Ńēŵ {{kind}} ŵàţćĥ",
"noPullRequestWatches": "Ńō ƥũĺĺ-ŕēqũēśţ ŵàţćĥēś ŷēţ.",
"notCheckedYet": "Ńōţ ćĥēćķēď ŷēţ",
"noWorkItemWatches": "Ńō ŵōŕķ-ĩţēḿ ŵàţćĥēś ŷēţ.",
"optional": "Ōƥţĩōńàĺ",
"organizationUrl": "Ōŕĝàńĩźàţĩōń ŨŔĹ",
"pastePat": "Ƥàśţē ƤÀŢ",
"patHelpTitle": "Ćŕēàţē à ƥēŕśōńàĺ àććēśś ţōķēń ƒōŕ ƀōàŕď ēďĩţĩńĝ",
"patStepCheckScopes": "Ũńďēŕ <1>{{workItems}}</1>, ćĥēćķ Ŕēàď & ŵŕĩţē. Ũńďēŕ <4>{{code}}</4>, ćĥēćķ Ŕēàď. Ĺēàvē àĺĺ ōţĥēŕ śćōƥēś ũńćĥēćķēď.",
"patStepChooseScopes": "Ćĥōōśē ţĥĩś ōŕĝàńĩźàţĩōń, à śĥōŕţ ēxƥĩŕàţĩōń, àńď Ćũśţōḿ ďēƒĩńēď śćōƥēś.",
"patStepCreateAndPaste": "Ćŕēàţē ţĥē ţōķēń, ćōƥŷ ĩţ, àńď ƥàśţē ĩţ ĩńţō ţĥĩś ƒĩēĺď.",
"patStepOpenTokenSettings": "Ōƥēń ţōķēń śēţţĩńĝś àńď śēĺēćţ Ńēŵ Ţōķēń.",
"personalAccessToken": "Ƥēŕśōńàĺ Àććēśś Ţōķēń",
"placeholderTitleDescription": "Ţĩţĺē ōƒ ţĥē Àźũŕē ĎēvŌƥś ŵōŕķ ĩţēḿ ōŕ ƥũĺĺ ŕēqũēśţ",
"placeholderUrlDescription": "ŨŔĹ ōƒ ţĥē Àźũŕē ĎēvŌƥś ŵōŕķ ĩţēḿ ōŕ ƥũĺĺ ŕēqũēśţ",
"pollIntervalSeconds": "Ƥōĺĺ ĩńţēŕvàĺ (śēćōńďś)",
"prompt": "Ƥŕōḿƥţ",
"promptPlaceholdersHint": "Ţŷƥē {{open}} ţō śēē àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś. <3>{{url}}</3> àńď <6>{{title}}</6> àŕē śũƀśţĩţũţēď ŵĥēń ţĥē àćţĩōń ŕũńś.",
"pullRequestAny": "àńŷ",
"pullRequestFilter": "Ƥũĺĺ-ŕēqũēśţ ƒĩĺţēŕ",
"pullRequestKindLower": "ƥũĺĺ ŕēqũēśţ",
"pullRequestKindNoun": "ƥũĺĺ-ŕēqũēśţ",
"pullRequestKindTitle": "Ƥũĺĺ ŕēqũēśţ",
"pullRequestsTab": "Ƥũĺĺ ŕēqũēśţś",
"pullRequestWatches": "Ƥũĺĺ-ŕēqũēśţ ŵàţćĥēś",
"pullRequestWatchesDescription": "Ćŕēàţē ţàśķś àũţōḿàţĩćàĺĺŷ ƒŕōḿ ḿàţćĥĩńĝ Àźũŕē ĎēvŌƥś ƥũĺĺ ŕēqũēśţś.",
"pullRequestWatchFieldsRequired": "Ƥŕōĵēćţ, ŕēƥōśĩţōŕŷ, ŵōŕķƒĺōŵ, śţēƥ, àĝēńţ, àńď ēxēćũţōŕ àŕē ŕēqũĩŕēď.",
"queryCreatorAria": "{{kind}} qũēŕŷ ćŕēàţōŕ {{index}}",
"queryGroupAria": "{{kind}} qũēŕŷ ĝŕōũƥ {{index}}",
"queryLabelAria": "{{kind}} qũēŕŷ ĺàƀēĺ {{index}}",
"queryReviewerAria": "{{kind}} qũēŕŷ ŕēvĩēŵēŕ {{index}}",
"queryStatusAria": "{{kind}} qũēŕŷ śţàţũś {{index}}",
"queryTopAria": "{{kind}} qũēŕŷ ţōƥ {{index}}",
"queryWiqlAria": "{{kind}} qũēŕŷ ŴĨQĹ {{index}}",
"quickActionInvalid": "Ēvēŕŷ qũĩćķ àćţĩōń ńēēďś à ĺàƀēĺ àńď ƥŕōḿƥţ.",
"quickActions": "Qũĩćķ àćţĩōńś",
"quickActionsDescription": "Ƥŕōḿƥţś śĥōŵń ōń {{path}} ŵĥēń śţàŕţĩńĝ à ţàśķ ƒŕōḿ à ƥũĺĺ ŕēqũēśţ ōŕ ŵōŕķ ĩţēḿ.",
"quickActionsSaved": "Àźũŕē ĎēvŌƥś qũĩćķ àćţĩōńś śàvēď",
"remove": "Ŕēḿōvē",
"removeActionAria": "Ŕēḿōvē {{kind}} àćţĩōń {{index}}",
"removeConfigurationConfirm": "Ŕēḿōvē Àźũŕē ĎēvŌƥś ćōńƒĩĝũŕàţĩōń?",
"removeFailed": "Ŕēḿōvē ƒàĩĺēď: {{error}}",
"removeQueryAria": "Ŕēḿōvē {{kind}} qũēŕŷ {{index}}",
"resetWatchConfirm_one": "Ŕēśēţ ţĥĩś {{policy}} ŵàţćĥ àńď ŕēḿōvē {{count}} ţàśķ?",
"resetWatchConfirm_other": "Ŕēśēţ ţĥĩś {{policy}} ŵàţćĥ àńď ŕēḿōvē {{count}} ţàśķś?",
"reviewer": "Ŕēvĩēŵēŕ",
"reviewerId": "Ŕēvĩēŵēŕ ĨĎ",
"runNow": "Ŕũń ńōŵ",
"save": "Śàvē",
"savedCredential": "Śàvēď ćŕēďēńţĩàĺ",
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
"saving": "Śàvĩńĝ...",
"savingEllipsis": "Śàvĩńĝ…",
"status": "Śţàţũś",
"statusAbandoned": "Àƀàńďōńēď",
"statusActive": "Àćţĩvē",
"statusAll": "Àĺĺ",
"statusAny": "Àńŷ śţàţũś",
"statusCompleted": "Ćōḿƥĺēţēď",
"statusLine": "Śţàţũś: {{status}}",
"testConnection": "Ţēśţ ćōńńēćţĩōń",
"testing": "Ţēśţĩńĝ...",
"top": "Ţōƥ",
"update": "Ũƥďàţē",
"updateWatch": "Ũƥďàţē ŵàţćĥ",
"watchDeleted": "Ŵàţćĥ ďēĺēţēď.",
"watchDisabled": "Ŵàţćĥ ďĩśàƀĺēď.",
"watchEnabled": "Ŵàţćĥ ēńàƀĺēď.",
"watchReset": "Ŵàţćĥ ŕēśēţ.",
"watchStatusAbandoned": "àƀàńďōńēď",
"watchStatusActive": "àćţĩvē",
"watchStatusCompleted": "ćōḿƥĺēţēď",
"watchSummary_one": "{{projectId}} · ēvēŕŷ {{count}}ś · ćĺēàńũƥ {{policy}}",
"watchSummary_other": "{{projectId}} · ēvēŕŷ {{count}}ś · ćĺēàńũƥ {{policy}}",
"workflowId": "Ŵōŕķƒĺōŵ ĨĎ",
"workflowStepId": "Ŵōŕķƒĺōŵ śţēƥ ĨĎ",
"workItemKindLower": "ŵōŕķ ĩţēḿ",
"workItemKindNoun": "ŵōŕķ-ĩţēḿ",
"workItemKindTitle": "Ŵōŕķ ĩţēḿ",
"workItemQuery": "Ŵōŕķ-ĩţēḿ qũēŕŷ",
"workItemsTab": "Ŵōŕķ ĩţēḿś",
"workItemWatches": "Ŵōŕķ-ĩţēḿ ŵàţćĥēś",
"workItemWatchesDescription": "Ćŕēàţē ţàśķś àũţōḿàţĩćàĺĺŷ ƒŕōḿ ḿàţćĥĩńĝ Àźũŕē ĎēvŌƥś ŵōŕķ-ĩţēḿ qũēŕĩēś.",
"workItemWatchFieldsRequired": "Ƥŕōĵēćţ, ŴĨQĹ, ŕēƥōśĩţōŕŷ, ŵōŕķƒĺōŵ, śţēƥ, àĝēńţ, àńď ēxēćũţōŕ àŕē ŕēqũĩŕēď."
}
+9 -1
View File
@@ -32,5 +32,13 @@
"workspace": "Ŵōŕķśƥàćē",
"workspaces": "Ŵōŕķśƥàćēś",
"title": "Ţĩţĺē",
"subtaskTitle": "Śũƀţàśķ ţĩţĺē"
"subtaskTitle": "Śũƀţàśķ ţĩţĺē",
"presetIconEye": "Ēŷē",
"presetIconMessage": "Ḿēśśàĝē",
"presetIconTool": "Ţōōĺ",
"presetIconCode": "Ćōďē",
"presetIconSearch": "Śēàŕćĥ",
"presetIconBug": "Ɓũĝ",
"presetIconSparkle": "Śƥàŕķĺē",
"presetIconCheck": "Ćĥēćķ"
}
+39
View File
@@ -0,0 +1,39 @@
{
"agentDisabled": "{{agent}} — ďĩśàƀĺēď",
"agentNotConfigured": "{{agent}} — ńōţ ćōńƒĩĝũŕēď",
"agentUsesDefaultModel": "{{name}} (ũśēś ďēƒàũĺţ ḿōďēĺ)",
"agentWithModel": "{{name}} ({{model}})",
"chooseUtilityAgent": "Ćĥōōśē à ũţĩĺĩţŷ àĝēńţ",
"commandPrefix": "Ćōḿḿàńď ƥŕēƒĩx",
"commandPrefixHelp": "Ḿēśśàĝēś ŷōũ ŵŕĩţē ĩń Śĺàćķ śţàŕţĩńĝ ŵĩţĥ ţĥĩś ƥŕēƒĩx ƀēćōḿē Ķàńďēv ţàśķś. Ďēƒàũĺţ:",
"configurationRemoved": "Śĺàćķ ćōńƒĩĝũŕàţĩōń ŕēḿōvēď",
"configurationSaved": "Śĺàćķ ćōńƒĩĝũŕàţĩōń śàvēď",
"connectedAs": "Ćōńńēćţēď àś {{name}}",
"connectedAsWithTeam": "Ćōńńēćţēď àś {{name}} ({{team}})",
"cookieValue": "{{cookie}} ćōōķĩē vàĺũē",
"createUtilityAgentFirst": "Ćŕēàţē ōńē ĩń Śēţţĩńĝś → Ũţĩĺĩţŷ àĝēńţś",
"credentialsHelp": "Ōƥēń Śĺàćķ ĩń ŷōũŕ ƀŕōŵśēŕ, ćōƥŷ ţĥē vàĺũē ōƒ ţĥē `{{cookie}}` ćōōķĩē àńď ţĥē `{{prefix}}` ţōķēń ƒŕōḿ ĺōćàĺ śţōŕàĝē. Ɓōţĥ àŕē ŕēqũĩŕēď.",
"deleteFailed": "Ďēĺēţē ƒàĩĺēď: {{error}}",
"failedToLoadConfig": "Ƒàĩĺēď ţō ĺōàď Śĺàćķ ćōńƒĩĝ: {{error}}",
"integrationDescription": "Ćàƥţũŕē Śĺàćķ ţĥŕēàďś àś ţàśķś ƒōŕ ţĥē śēĺēćţēď ŵōŕķśƥàćē. Ţŷƥē {{example}} ĩń àńŷ ţĥŕēàď ŷōũ ćàń śēē àńď ţĥē ćōńƒĩĝũŕēď ũţĩĺĩţŷ àĝēńţ ćŕēàţēś ţĥē ţàśķ.",
"integrationTitle": "Śĺàćķ ĩńţēĝŕàţĩōń",
"loadingEllipsis": "Ĺōàďĩńĝ…",
"pasteCredentialsToTest": "Ƥàśţē à ţōķēń àńď ćōōķĩē ţō ţēśţ ţĥē ćōńńēćţĩōń",
"pollingInterval": "Ƥōĺĺĩńĝ ĩńţēŕvàĺ (śēćōńďś)",
"pollingIntervalHelp": "Ĥōŵ ōƒţēń Śĺàćķ ĩś ćĥēćķēď ƒōŕ ńēŵ <1>{{prefix}}</1> ḿēśśàĝēś. Ĺōŵēŕ = ḿōŕē ŕēśƥōńśĩvē, ĥĩĝĥēŕ = ƒēŵēŕ Śĺàćķ ÀƤĨ ćàĺĺś. Ŕàńĝē: {{min}}–{{max}}ś. Ďēƒàũĺţ: {{fallback}}ś.",
"promptTokensHelp": "Ćũśţōḿ ƥŕōḿƥţś ćàń ŕēƒēŕēńćē <1>{{tokens}}</1>. Ŵĥēń àţ ĺēàśţ ōńē ĩś ũśēď, ŷōũŕ ţēḿƥĺàţē ōŵńś ţĥē ƒũĺĺ ƥŕōḿƥţ; ōţĥēŕŵĩśē ţĥē ďēƒàũĺţ Śĺàćķ-ţŕĩàĝē śŷśţēḿ ƥŕōḿƥţ ĩś ƥŕēƥēńďēď àũţōḿàţĩćàĺĺŷ.",
"removeConfiguration": "Ŕēḿōvē ćōńƒĩĝũŕàţĩōń",
"removeConfigurationConfirm": "Ŕēḿōvē Śĺàćķ ćōńƒĩĝũŕàţĩōń?",
"removing": "Ŕēḿōvĩńĝ...",
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
"savedLeaveBlank": "(śàvēď — ĺēàvē ƀĺàńķ ţō ķēēƥ)",
"secretsRequired": "À śēśśĩōń ţōķēń àńď ćōōķĩē àŕē ŕēqũĩŕēď.",
"sessionToken": "Śēśśĩōń ţōķēń ({{prefix}}…)",
"testConnection": "Ţēśţ ćōńńēćţĩōń",
"testFailed": "Ƒàĩĺēď: {{error}}",
"testing": "Ţēśţĩńĝ...",
"triageAgent": "Ţŕĩàĝē àĝēńţ",
"triageAgentHelp": "Ţĥē ũţĩĺĩţŷ àĝēńţ ţĥàţ ĩńţēŕƥŕēţś ēàćĥ Śĺàćķ ḿēśśàĝē àńď ćŕēàţēś ţĥē Ķàńďēv ţàśķ. Ĩţ ŕũńś ŵĩţĥ Ķàńďēv ḾĆƤ ţōōĺś ŵĩŕēď ĩń ({{tools}}) śō ĩţ ƥĩćķś ţĥē ďēśţĩńàţĩōń Ķàńďēv ŵōŕķśƥàćē + ŵōŕķƒĺōŵ + ŕēƥō ƒŕōḿ ćōńţēxţ. Ɓũĩĺţ-ĩń àĝēńţś ũśē ŷōũŕ ďēƒàũĺţ ḿōďēĺ ƒŕōḿ <4>Śēţţĩńĝś → Ũţĩĺĩţŷ àĝēńţś</4>.",
"triageAgentRequired": "À ţŕĩàĝē àĝēńţ ĩś ŕēqũĩŕēď.",
"unsupportedAuthWarning": "<0>Ɓŕōŵśēŕ śēśśĩōń àũţĥ (ũńśũƥƥōŕţēď):</0> Śĺàćķ ŕōţàţēś śēśśĩōń ćōōķĩēś ōƒţēń, śō ŷōũ ḿàŷ ńēēď ţō ŕēćōńńēćţ ŵĥēń àũţĥēńţĩćàţĩōń ēxƥĩŕēś. Ɓōţ ĩńśţàĺĺś àńď ũśēŕ ŌÀũţĥ àŕē ōń ţĥē ŕōàďḿàƥ."
}