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:
@@ -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
|
||||
& 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 & 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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} <instruction></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}>
|
||||
|
||||
@@ -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}",
|
||||
];
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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ũĩŕēď."
|
||||
}
|
||||
@@ -32,5 +32,13 @@
|
||||
"workspace": "Ŵōŕķśƥàćē",
|
||||
"workspaces": "Ŵōŕķśƥàćēś",
|
||||
"title": "Ţĩţĺē",
|
||||
"subtaskTitle": "Śũƀţàśķ ţĩţĺē"
|
||||
"subtaskTitle": "Śũƀţàśķ ţĩţĺē",
|
||||
"presetIconEye": "Ēŷē",
|
||||
"presetIconMessage": "Ḿēśśàĝē",
|
||||
"presetIconTool": "Ţōōĺ",
|
||||
"presetIconCode": "Ćōďē",
|
||||
"presetIconSearch": "Śēàŕćĥ",
|
||||
"presetIconBug": "Ɓũĝ",
|
||||
"presetIconSparkle": "Śƥàŕķĺē",
|
||||
"presetIconCheck": "Ćĥēćķ"
|
||||
}
|
||||
|
||||
@@ -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ƥĩŕēś. Ɓōţ ĩńśţàĺĺś àńď ũśēŕ ŌÀũţĥ àŕē ōń ţĥē ŕōàďḿàƥ."
|
||||
}
|
||||
Reference in New Issue
Block a user