feat(i18n): localize Settings → Integrations → Jira (#2177)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-02 21:59:46 +02:00
committed by GitHub
parent f51a83e4d3
commit 32c927f386
13 changed files with 1097 additions and 427 deletions
@@ -28,7 +28,7 @@ import {
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import {
JIRA_ISSUE_WATCH_PLACEHOLDERS,
jiraIssueWatchPlaceholders,
DEFAULT_JIRA_ISSUE_WATCH_PROMPT,
} from "@/components/jira/jira-issue-watch-placeholders";
import { STEP_DEFAULT, STEP_DEFAULT_LABEL, resolveProfileId } from "@/lib/watcher-profile-default";
@@ -39,6 +39,8 @@ import type {
JiraIssueWatch,
UpdateJiraIssueWatchInput,
} from "@/lib/types/jira";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
type Props = {
open: boolean;
@@ -88,6 +90,10 @@ function parseMaxInflightTasks(raw: string): number | null | "invalid" {
return n;
}
// JQL, not copy. Every token here is parsed by Jira — field names (`project`,
// `status`, `created`), operators and a project key — and the string is
// persisted on the watch and sent upstream verbatim. Translating any of it would
// produce a query Jira rejects.
const DEFAULT_JQL = `project = PROJ AND status = "Open" ORDER BY created DESC`;
function makeEmptyForm(workspaceId: string): FormState {
@@ -192,6 +198,7 @@ function JQLField({
jql: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation();
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
const [testing, setTesting] = useState(false);
const handleTest = useCallback(async () => {
@@ -199,13 +206,14 @@ function JQLField({
setResult(null);
try {
const res = await searchJiraTickets({ jql, maxResults: 5 }, { workspaceId });
setResult({ ok: true, message: `Matched ${res.tickets.length} ticket(s) in this page.` });
// "ticket(s)" was an English plural hack; the count now selects the form.
setResult({ ok: true, message: t("jira:matchedTickets", { count: res.tickets.length }) });
} catch (err) {
setResult({ ok: false, message: `JQL error: ${String(err)}` });
setResult({ ok: false, message: t("jira:jqlError", { error: String(err) }) });
} finally {
setTesting(false);
}
}, [workspaceId, jql]);
}, [workspaceId, jql, t]);
return (
<div className="space-y-1.5">
@@ -219,19 +227,20 @@ function JQLField({
disabled={!jql.trim() || testing}
className="cursor-pointer h-7"
>
{testing ? "Testing…" : "Test JQL"}
{testing ? t("jira:testingJql") : t("jira:testJql")}
</Button>
</div>
<Textarea
value={jql}
onChange={(e) => onChange(e.target.value)}
// A sample JQL query. JQL is a query language, not copy: `project`,
// `status` and `PROJ` are the field names and project key Jira parses,
// so a translated (or pseudo-transliterated) example would not run.
placeholder='project = PROJ AND status = "Open"'
rows={3}
className="font-mono text-xs resize-y"
/>
<p className="text-xs text-muted-foreground">
Atlassian JQL. The watcher polls this query and creates one task per newly-matching ticket.
</p>
<p className="text-xs text-muted-foreground">{t("jira:atlassianJqlHelp")}</p>
{result && (
<p className={`text-xs ${result.ok ? "text-emerald-600" : "text-destructive"}`}>
{result.message}
@@ -242,6 +251,7 @@ function JQLField({
}
function PlaceholdersHelp() {
const { t } = useTranslation();
return (
<TooltipProvider>
<Tooltip>
@@ -249,9 +259,9 @@ function PlaceholdersHelp() {
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground/50 hover:text-muted-foreground cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent className="max-w-xs" align="start">
<p className="text-xs font-medium mb-1">Available placeholders:</p>
<p className="text-xs font-medium mb-1">{t("jira:availablePlaceholders")}</p>
<ul className="text-xs space-y-0.5">
{JIRA_ISSUE_WATCH_PLACEHOLDERS.map((p) => (
{jiraIssueWatchPlaceholders(t).map((p) => (
<li key={p.key}>
<code className="text-[10px] bg-white/15 px-1 rounded">{`{{${p.key}}}`}</code>{" "}
<span className="opacity-70">{p.description}</span>
@@ -265,14 +275,22 @@ function PlaceholdersHelp() {
}
function PromptField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { t } = useTranslation();
// Memoized because `ScriptEditor` keys its Monaco completion-provider
// registration on `placeholders` identity. This used to be a module-scope
// const, so it was stable for free; now that it is built from `t`, a fresh
// array on every render would re-register the provider on every keystroke.
const placeholders = useMemo(() => jiraIssueWatchPlaceholders(t), [t]);
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label>Task Prompt</Label>
<Label>{t("jira:taskPrompt")}</Label>
<PlaceholdersHelp />
</div>
<p className="text-xs text-muted-foreground">
The prompt sent to the agent for each new ticket. Type {"{{"} to insert placeholders.
{/* The `{{` token is passed as a value so it never reaches the catalog,
where i18next would interpolate it away. */}
{t("jira:promptFieldHelp", { token: "{{" })}
</p>
<div className="rounded-md border border-border overflow-hidden">
<ScriptEditor
@@ -281,7 +299,7 @@ function PromptField({ value, onChange }: { value: string; onChange: (v: string)
language="markdown"
height={computeEditorHeight(value)}
lineNumbers="off"
placeholders={JIRA_ISSUE_WATCH_PLACEHOLDERS}
placeholders={placeholders}
/>
</div>
</div>
@@ -297,14 +315,15 @@ function WorkspacePicker({
onChange: (v: string) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const workspaces = useAppStore((s) => s.workspaces.items);
return (
<SelectField
label="Workspace"
description="Tasks created by this watcher land in the selected workspace."
label={t("common:workspace")}
description={t("jira:tasksCreatedByThisWatcherLand")}
value={value}
onChange={onChange}
placeholder="Select workspace"
placeholder={t("jira:selectWorkspace")}
items={workspaces.map((w) => ({ id: w.id, label: w.name }))}
disabled={disabled}
/>
@@ -318,22 +337,23 @@ function AutomationFields({
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
}) {
const { t } = useTranslation();
const { workflows, agentProfiles, allExecutorProfiles } = useFormData(form.workspaceId);
const { steps, loading: stepsLoading } = useWorkflowSteps(form.workflowId);
return (
<>
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Workflow"
description="Tasks are created in this workflow."
label={t("jira:workflow")}
description={t("jira:tasksAreCreatedInThisWorkflow")}
value={form.workflowId}
onChange={(v) => setForm((p) => ({ ...p, workflowId: v, workflowStepId: "" }))}
placeholder="Select workflow"
placeholder={t("jira:selectWorkflow")}
items={workflows.map((w) => ({ id: w.id, label: w.name }))}
/>
<SelectField
label="Workflow Step"
description="Initial step for new tasks."
label={t("jira:workflowStep")}
description={t("jira:initialStepForNewTasks")}
value={form.workflowStepId}
onChange={(v) => setForm((p) => ({ ...p, workflowStepId: v }))}
placeholder={stepPlaceholder(form.workflowId, stepsLoading, steps.length)}
@@ -352,8 +372,8 @@ function AutomationFields({
/>
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Agent Profile"
description="Optional — falls back to step default."
label={t("jira:agentProfile")}
description={t("jira:optionalFallsBackToStepDefault")}
value={form.agentProfileId || STEP_DEFAULT}
onChange={(v) => setForm((p) => ({ ...p, agentProfileId: resolveProfileId(v) }))}
placeholder={STEP_DEFAULT_LABEL}
@@ -367,8 +387,8 @@ function AutomationFields({
]}
/>
<SelectField
label="Executor Profile"
description="Optional — falls back to step default."
label={t("jira:executorProfile")}
description={t("jira:optionalFallsBackToStepDefault")}
value={form.executorProfileId || STEP_DEFAULT}
onChange={(v) => setForm((p) => ({ ...p, executorProfileId: resolveProfileId(v) }))}
placeholder={STEP_DEFAULT_LABEL}
@@ -389,26 +409,24 @@ function MaxInflightTasksField({
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
}) {
const { t } = useTranslation();
const parsed = parseMaxInflightTasks(form.maxInflightTasks);
const invalid = parsed === "invalid";
return (
<div className="space-y-1.5">
<Label>Max in-flight tasks</Label>
<p className="text-xs text-muted-foreground">
Cap on open tasks created by this watcher. Leave blank for no cap. New matches are deferred
to the next poll when the cap is reached.
</p>
<Label>{t("jira:maxInFlightTasks")}</Label>
<p className="text-xs text-muted-foreground">{t("jira:capOnOpenTasksCreatedBy")}</p>
<Input
type="number"
value={form.maxInflightTasks}
onChange={(e) => setForm((p) => ({ ...p, maxInflightTasks: e.target.value }))}
min={1}
step={1}
placeholder="(no cap)"
placeholder={t("jira:noCap")}
aria-invalid={invalid}
/>
{invalid && (
<p className="text-xs text-destructive">Enter a positive integer or leave blank.</p>
<p className="text-xs text-destructive">{t("jira:enterAPositiveIntegerOrLeave")}</p>
)}
</div>
);
@@ -421,13 +439,12 @@ function SettingsFields({
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
}) {
const { t } = useTranslation();
return (
<>
<div className="space-y-1.5">
<Label>Poll Interval (seconds)</Label>
<p className="text-xs text-muted-foreground">
How often to re-run the JQL. Minimum 60s, maximum 3600s.
</p>
<Label>{t("jira:pollIntervalSeconds")}</Label>
<p className="text-xs text-muted-foreground">{t("jira:howOftenToReRunThe")}</p>
<Input
type="number"
value={form.pollInterval}
@@ -439,8 +456,8 @@ function SettingsFields({
<MaxInflightTasksField form={form} setForm={setForm} />
<div className="flex items-center justify-between">
<div>
<Label>Enabled</Label>
<p className="text-xs text-muted-foreground">Pause or resume polling.</p>
<Label>{t("jira:enabled")}</Label>
<p className="text-xs text-muted-foreground">{t("jira:pauseOrResumePolling")}</p>
</div>
<Switch
checked={form.enabled}
@@ -452,9 +469,11 @@ function SettingsFields({
);
}
function savingLabel(saving: boolean, isEdit: boolean): string {
if (saving) return "Saving…";
return isEdit ? "Update" : "Create";
// `t` is threaded in: this is a plain function, and the guard only inspects JSX,
// so a literal returned from here would never be reported.
function savingLabel(t: TFunction, saving: boolean, isEdit: boolean): string {
if (saving) return t("jira:saving");
return isEdit ? t("jira:update") : t("jira:create");
}
export function JiraIssueWatchDialog({
@@ -465,6 +484,7 @@ export function JiraIssueWatchDialog({
onCreate,
onUpdate,
}: Props) {
const { t } = useTranslation();
const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>(() => makeEmptyForm(workspaceId ?? ""));
@@ -534,12 +554,10 @@ export function JiraIssueWatchDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-full sm:w-[800px] sm:max-w-none max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{watch ? "Edit JIRA Watcher" : "Create JIRA Watcher"}</DialogTitle>
<DialogDescription>
Poll a JQL query and auto-create a Kandev task for each newly-matching ticket.
Optionally bind a repository so each task runs against that codebase, or leave it unset
to run with no repository.
</DialogDescription>
<DialogTitle>
{watch ? t("jira:editJiraWatcher") : t("jira:createJiraWatcher")}
</DialogTitle>
<DialogDescription>{t("jira:watchDialogDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<WorkspacePicker
@@ -561,10 +579,10 @@ export function JiraIssueWatchDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
Cancel
{t("common:cancel")}
</Button>
<Button onClick={handleSave} disabled={saving || !canSave} className="cursor-pointer">
{savingLabel(saving, !!watch)}
{savingLabel(t, saving, !!watch)}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,68 +1,90 @@
import type { TFunction } from "i18next";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
export const JIRA_ISSUE_WATCH_PLACEHOLDERS: ScriptPlaceholder[] = [
{
key: "issue.key",
description: "Ticket key",
example: "PROJ-42",
executor_types: [],
},
{
key: "issue.summary",
description: "Ticket summary",
example: "Login fails on mobile",
executor_types: [],
},
{
key: "issue.url",
description: "Ticket URL",
example: "https://acme.atlassian.net/browse/PROJ-42",
executor_types: [],
},
{
key: "issue.status",
description: "Status name",
example: "In Progress",
executor_types: [],
},
{
key: "issue.priority",
description: "Priority",
example: "High",
executor_types: [],
},
{
key: "issue.type",
description: "Issue type",
example: "Bug",
executor_types: [],
},
{
key: "issue.assignee",
description: "Assignee display name",
example: "Alice Example",
executor_types: [],
},
{
key: "issue.reporter",
description: "Reporter display name",
example: "Bob Example",
executor_types: [],
},
{
key: "issue.project",
description: "Project key",
example: "PROJ",
executor_types: [],
},
{
key: "issue.description",
description: "Ticket description",
example: "When clicking login...",
executor_types: [],
},
];
/**
* Placeholder tokens available in the issue-watch prompt.
*
* A function rather than a const because `description` is copy — it is rendered
* in the prompt field's tooltip and in the editor's completion list — and a
* module-scope `t()` would freeze it at the boot locale (see docs/i18n.md).
*
* `key` and `example` are NOT copy. `key` is the token the prompt template is
* interpolated against, and each `example` is sample Jira data: an issue key, a
* status name, a project key, a display name. Both are identifiers, so they stay
* out of the catalog where the pseudo-locale would transliterate them.
*/
export function jiraIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
return [
{
key: "issue.key",
description: t("jira:placeholderTicketKey"),
example: "PROJ-42",
executor_types: [],
},
{
key: "issue.summary",
description: t("jira:placeholderTicketSummary"),
example: "Login fails on mobile",
executor_types: [],
},
{
key: "issue.url",
description: t("jira:placeholderTicketUrl"),
example: "https://acme.atlassian.net/browse/PROJ-42",
executor_types: [],
},
{
key: "issue.status",
description: t("jira:placeholderStatusName"),
example: "In Progress",
executor_types: [],
},
{
key: "issue.priority",
description: t("jira:placeholderPriority"),
example: "High",
executor_types: [],
},
{
key: "issue.type",
description: t("jira:placeholderIssueType"),
example: "Bug",
executor_types: [],
},
{
key: "issue.assignee",
description: t("jira:placeholderAssigneeDisplayName"),
example: "Alice Example",
executor_types: [],
},
{
key: "issue.reporter",
description: t("jira:placeholderReporterDisplayName"),
example: "Bob Example",
executor_types: [],
},
{
key: "issue.project",
description: t("jira:placeholderProjectKey"),
example: "PROJ",
executor_types: [],
},
{
key: "issue.description",
description: t("jira:placeholderTicketDescription"),
example: "When clicking login...",
executor_types: [],
},
];
}
/**
* Seeded into the dialog's prompt field, then persisted on the watch and sent to
* the agent verbatim. Deliberately NOT translated: a watch created under one
* locale would keep that locale's prompt forever, and the agent reads it as an
* instruction rather than as UI copy. Same contract as
* `DEFAULT_ISSUE_WATCH_PROMPT` in components/github/issue-watch-placeholders.ts.
*/
export const DEFAULT_JIRA_ISSUE_WATCH_PROMPT = `You have been assigned a JIRA ticket to work on.
**Ticket:** {{issue.url}}
@@ -13,6 +13,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import type { JiraIssueWatch } from "@/lib/types/jira";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { formatRelative } from "@/lib/i18n/formats";
type JiraIssueWatchTableProps = {
watches: JiraIssueWatch[];
@@ -28,15 +31,17 @@ type JiraIssueWatchTableProps = {
onToggleEnabled: (watch: JiraIssueWatch) => void;
};
function formatLastPolled(dateStr?: string | null): string {
if (!dateStr) return "Never";
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
// `t` is threaded in rather than read from a hook: this is a plain function, and
// the guard never inspects a return value, so a literal here would survive lint.
//
// The bucket copy now comes from `formatRelative`, which owns the same
// just-now/m/h/d ladder for every surface and reads the active app locale. Two
// consequences, both deliberate: the first bucket reads "just now" rather than
// "Just now" (it is shared with the rest of the app), and an unparseable
// timestamp renders empty instead of "NaNm ago".
function formatLastPolled(t: TFunction, dateStr?: string | null): string {
if (!dateStr) return t("jira:never");
return formatRelative(dateStr);
}
function WatchActions({
@@ -54,6 +59,7 @@ function WatchActions({
onReset: (id: string) => void;
onDelete: (id: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-end gap-1">
<Tooltip>
@@ -76,7 +82,7 @@ function WatchActions({
)}
</Button>
</TooltipTrigger>
<TooltipContent>{watch.enabled ? "Pause" : "Enable"}</TooltipContent>
<TooltipContent>{watch.enabled ? t("jira:pause") : t("jira:enable")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -94,7 +100,7 @@ function WatchActions({
<IconRefresh className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Check now</TooltipContent>
<TooltipContent>{t("jira:checkNow")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -103,7 +109,7 @@ function WatchActions({
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
data-testid="watch-reset-button"
aria-label="Reset watch"
aria-label={t("jira:resetWatch")}
onClick={(e) => {
e.stopPropagation();
onReset(watch.id);
@@ -112,7 +118,7 @@ function WatchActions({
<IconRestore className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Reset</TooltipContent>
<TooltipContent>{t("common:reset")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -128,7 +134,7 @@ function WatchActions({
<IconTrash className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
<TooltipContent>{t("jira:delete")}</TooltipContent>
</Tooltip>
</div>
);
@@ -144,13 +150,14 @@ export function JiraIssueWatchTable({
onReset,
onToggleEnabled,
}: JiraIssueWatchTableProps) {
const { t } = useTranslation();
const workspaces = useAppStore((s) => s.workspaces.items);
const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id;
if (watches.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4 text-center">
No JIRA watchers configured. Create one to auto-create tasks from JQL queries.
{t("jira:noJiraWatchersConfiguredCreateOne")}
</p>
);
}
@@ -159,12 +166,12 @@ export function JiraIssueWatchTable({
<Table>
<TableHeader>
<TableRow>
{showWorkspace && <TableHead>Workspace</TableHead>}
{showWorkspace && <TableHead>{t("common:workspace")}</TableHead>}
<TableHead>JQL</TableHead>
<TableHead>Interval</TableHead>
<TableHead>Last Polled</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t("jira:interval")}</TableHead>
<TableHead>{t("jira:lastPolled")}</TableHead>
<TableHead>{t("common:status")}</TableHead>
<TableHead className="text-right">{t("jira:actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -186,14 +193,16 @@ export function JiraIssueWatchTable({
{watch.jql}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{Math.round(watch.pollIntervalSeconds / 60)}m
{t("jira:intervalMinutes", {
count: Math.round(watch.pollIntervalSeconds / 60),
})}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatLastPolled(watch.lastPolledAt)}
{formatLastPolled(t, watch.lastPolledAt)}
</TableCell>
<TableCell>
<Badge variant={watch.enabled ? "default" : "secondary"} className="text-xs">
{watch.enabled ? "Active" : "Paused"}
{watch.enabled ? t("jira:active") : t("jira:paused")}
</Badge>
</TableCell>
<TableCell className="text-right">
@@ -12,6 +12,7 @@ import { ResetWatchDialog, useWatchResetController } from "@/components/watches/
import { JiraIssueWatchTable } from "./jira-issue-watch-table";
import { JiraIssueWatchDialog } from "./jira-issue-watch-dialog";
import type { JiraIssueWatch } from "@/lib/types/jira";
import { useTranslation } from "react-i18next";
// JiraIssueWatchersSection lists watches across every workspace in a single
// flat table on the install-wide settings page. The dialog's create flow asks
@@ -29,45 +30,46 @@ type RawActions = {
// success/failure toasts. Per-row mutations need each watch's own workspaceId,
// so the wrappers take the watch (not just an id) and pass it through.
function useToastedActions({ create, update, remove, trigger, reset }: RawActions) {
const { t } = useTranslation();
const { toast } = useToast();
const wrappedCreate = useCallback(
async (req: Parameters<typeof create>[0]) => {
try {
await create(req);
toast({ description: "Watcher created", variant: "success" });
toast({ description: t("jira:watcherCreated"), variant: "success" });
} catch (err) {
toast({ description: `Create failed: ${String(err)}`, variant: "error" });
toast({ description: t("jira:createFailed", { error: String(err) }), variant: "error" });
throw err;
}
},
[create, toast],
[create, t, toast],
);
const wrappedUpdate = useCallback(
async (id: string, req: Parameters<typeof update>[1], rowWorkspaceId: string) => {
try {
await update(id, req, rowWorkspaceId);
toast({ description: "Watcher updated", variant: "success" });
toast({ description: t("jira:watcherUpdated"), variant: "success" });
} catch (err) {
toast({ description: `Update failed: ${String(err)}`, variant: "error" });
toast({ description: t("jira:updateFailed", { error: String(err) }), variant: "error" });
throw err;
}
},
[update, toast],
[update, t, toast],
);
const wrappedDelete = useCallback(
async (w: JiraIssueWatch) => {
if (!confirm("Delete this JIRA watcher?")) return;
if (!confirm(t("jira:deleteThisJiraWatcher"))) return;
try {
await remove(w.id, w.workspaceId);
toast({ description: "Watcher deleted", variant: "success" });
toast({ description: t("jira:watcherDeleted"), variant: "success" });
} catch (err) {
toast({ description: `Delete failed: ${String(err)}`, variant: "error" });
toast({ description: t("jira:deleteFailed", { error: String(err) }), variant: "error" });
}
},
[remove, toast],
[remove, t, toast],
);
const wrappedTrigger = useCallback(
@@ -75,16 +77,16 @@ function useToastedActions({ create, update, remove, trigger, reset }: RawAction
try {
const res = await trigger(w.id, w.workspaceId);
const n = res?.newIssues ?? 0;
// "ticket(s)" was an English plural hack: the count now selects a form,
// so a language with more than two can express them all.
const description =
n > 0
? `Found ${n} new ticket(s) — tasks will appear shortly.`
: "No new tickets matched.";
n > 0 ? t("jira:foundNewTickets", { count: n }) : t("jira:noNewTicketsMatched");
toast({ description, variant: "success" });
} catch (err) {
toast({ description: `Check failed: ${String(err)}`, variant: "error" });
toast({ description: t("jira:checkFailed", { error: String(err) }), variant: "error" });
}
},
[trigger, toast],
[trigger, t, toast],
);
const wrappedReset = useCallback(
@@ -95,16 +97,16 @@ function useToastedActions({ create, update, remove, trigger, reset }: RawAction
toast({
description:
n > 0
? `Reset complete — deleted ${n} task(s); next poll will re-import matches.`
: "Reset complete — next poll will re-import matches.",
? t("jira:resetCompleteDeletedTasks", { count: n })
: t("jira:resetCompleteNoTasksDeleted"),
variant: "success",
});
} catch (err) {
toast({ description: `Reset failed: ${String(err)}`, variant: "error" });
toast({ description: t("jira:resetFailed", { error: String(err) }), variant: "error" });
throw err;
}
},
[reset, toast],
[reset, t, toast],
);
return {
@@ -127,6 +129,7 @@ function useEnabledDrafts(items: JiraIssueWatch[], update: RawActions["update"])
}
export function JiraIssueWatchersSection() {
const { t } = useTranslation();
// Pass undefined to fetch every watch across every workspace.
const { items, loading, create, update, remove, trigger, previewReset, reset } =
useJiraIssueWatches();
@@ -182,12 +185,12 @@ export function JiraIssueWatchersSection() {
return (
<SettingsSection
icon={<IconBellRinging className="h-5 w-5" />}
title="JIRA watchers"
description="Poll a JQL query and auto-create a Kandev task for each newly-matching ticket."
title={t("jira:jiraWatchers")}
description={t("jira:jiraWatchersDescription")}
action={
<Button size="sm" onClick={openCreate} className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-1" />
New watcher
{t("jira:newWatcher")}
</Button>
}
>
@@ -225,7 +228,7 @@ export function JiraIssueWatchersSection() {
<ResetWatchDialog
open
onOpenChange={resetCtrl.onOpenChange}
integrationLabel="JIRA watcher"
integrationLabel={t("jira:jiraWatcher")}
previewLoader={resetCtrl.previewLoader}
onConfirm={resetCtrl.confirmReset}
/>
@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { CookieExpiry, formatExpiry } from "./jira-secret-help";
import { activateLocale, i18n } from "@/lib/i18n";
// The shared instance's own `t`, rather than the string-typed re-export from
// `@/lib/i18n`, so it satisfies the branded `TFunction` the helper takes.
const t = i18n.t;
beforeAll(async () => {
// The labels come from the real `jira` catalog, so the expected strings below
// double as a check that each key exists and carries the right plural forms.
await activateLocale("en");
});
const IN = (ms: number) => new Date(Date.now() + ms).toISOString();
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
describe("formatExpiry", () => {
it("reports an unknown expiry for an unparseable timestamp", () => {
expect(formatExpiry(t, "not-a-date")).toEqual({ label: "Expiry unknown", tone: "warn" });
});
it("reports an expired cookie once the timestamp is in the past", () => {
expect(formatExpiry(t, IN(-HOUR))).toEqual({
label: "Cookie expired — paste a fresh one",
tone: "danger",
});
});
it("counts down in hours under a day, rounding up to at least one", () => {
expect(formatExpiry(t, IN(5 * HOUR))).toEqual({
label: "Cookie expires in 5h",
tone: "danger",
});
// Anything still valid is at least "1h", never "0h".
expect(formatExpiry(t, IN(60_000)).label).toBe("Cookie expires in 1h");
});
// The day count goes through i18next `count`, so the singular form proves the
// _one/_other split is wired up rather than an English "s" being appended.
it("counts down in days past 24h, and inflects the singular", () => {
expect(formatExpiry(t, IN(30 * HOUR))).toEqual({
label: "Cookie expires in 1 day",
tone: "warn",
});
expect(formatExpiry(t, IN(3 * DAY)).label).toBe("Cookie expires in 3 days");
});
it("escalates the tone as the deadline approaches", () => {
expect(formatExpiry(t, IN(30 * DAY)).tone).toBe("ok");
expect(formatExpiry(t, IN(3 * DAY)).tone).toBe("warn");
expect(formatExpiry(t, IN(2 * HOUR)).tone).toBe("danger");
});
});
describe("CookieExpiry", () => {
it("puts the absolute timestamp in the title attribute", () => {
const expiresAt = IN(3 * DAY);
render(<CookieExpiry expiresAt={expiresAt} />);
const el = screen.getByText("Cookie expires in 3 days");
expect(el.getAttribute("title")).toBeTruthy();
});
// `Intl.DateTimeFormat().format()` throws RangeError on an invalid date, where
// the `toLocaleString()` this replaced returned "Invalid Date". A legacy or
// corrupted `secretExpiresAt` would otherwise take the whole settings card
// down — `formatExpiry` has a branch for exactly that value.
it("renders the unknown-expiry label without a title for an invalid timestamp", () => {
render(<CookieExpiry expiresAt="not-a-date" />);
const el = screen.getByText("Expiry unknown");
expect(el.getAttribute("title")).toBeNull();
});
});
@@ -0,0 +1,191 @@
"use client";
import { useState } from "react";
import { IconCode } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { formatDateTime } from "@/lib/i18n/formats";
import type { JiraAuthMethod } from "@/lib/types/jira";
// Session cookies are HttpOnly so document.cookie can't read them, but
// DevTools → Application → Cookies surfaces them in plain text. Users copy
// the Value cell of a single row; the backend wraps it under both
// cloud.session.token and tenant.session.token so a single paste works for
// password accounts and SSO tenants.
//
// The instructions themselves are copy (`jira:cookieInstructions`, resolved at
// render), but every token below names something the user has to match verbatim
// in DevTools — cookie rows, a host, a keyboard shortcut. They are interpolated
// rather than written into the catalog so no locale, pseudo included, can turn
// one into a name that does not exist.
const SESSION_COOKIE_NAME = "cloud.session.token";
const SSO_SESSION_COOKIE_NAME = "tenant.session.token";
const ATLASSIAN_COOKIE_HOST = "https://*.atlassian.net";
const DEVTOOLS_SHORTCUT = "Cmd+Opt+I / Ctrl+Shift+I";
// Atlassian's API-token page. The visible link text is the URL itself, so it is
// an identifier in both roles and never reaches the catalog.
const API_TOKEN_URL = "https://id.atlassian.com/manage-profile/security/api-tokens";
const API_TOKEN_URL_LABEL = "id.atlassian.com/manage-profile/security/api-tokens";
// Symbol-only stand-in for a stored secret — nothing to translate.
const MASKED_SECRET = "••••••••";
/**
* Field label and empty-state placeholder per auth method.
*
* The record keys are the wire `JiraAuthMethod` values and must never be
* translated; only the label and placeholder are copy. Keyed by JiraAuthMethod
* so adding a new method causes the type system to flag the missing entry.
* Built from `t` at render — a module-scope table would freeze at the boot
* locale and the pseudo-locale could not see it.
*/
export function secretCopy(
t: TFunction,
): Record<JiraAuthMethod, { label: string; placeholder: string }> {
return {
api_token: { label: t("jira:apiToken"), placeholder: t("jira:pasteApiTokenHere") },
pat: {
label: t("jira:personalAccessToken"),
placeholder: t("jira:pastePersonalAccessTokenHere"),
},
session_cookie: {
label: t("jira:sessionTokenValue"),
placeholder: t("jira:pasteSessionTokenValue", { cookieName: SESSION_COOKIE_NAME }),
},
};
}
export function secretPlaceholder(
t: TFunction,
method: JiraAuthMethod,
hasSavedSecret: boolean,
): string {
return hasSavedSecret ? MASKED_SECRET : secretCopy(t)[method].placeholder;
}
function SessionSnippet() {
const { t } = useTranslation();
const [show, setShow] = useState(false);
return (
<div className="text-xs text-muted-foreground space-y-2">
<button
type="button"
onClick={() => setShow((v) => !v)}
className="inline-flex items-center gap-1 underline cursor-pointer"
>
<IconCode className="h-3 w-3" />
{/* Two whole sentences rather than "Show"/"Hide" spliced onto a shared
tail: which word varies, and where it sits, is the translator's. */}
{show ? t("jira:hideSessionTokenHelp") : t("jira:showSessionTokenHelp")}
</button>
{show && (
<pre className="bg-muted rounded p-3 text-[11px] overflow-x-auto whitespace-pre-wrap">
<code>
{t("jira:cookieInstructions", {
shortcut: DEVTOOLS_SHORTCUT,
host: ATLASSIAN_COOKIE_HOST,
cookieName: SESSION_COOKIE_NAME,
ssoCookieName: SSO_SESSION_COOKIE_NAME,
})}
</code>
</pre>
)}
</div>
);
}
// `t` is threaded in rather than read from a hook: this is a plain function, and
// the guard only inspects JSX, so a literal returned from here would never be
// reported. Exported for its unit test.
export function formatExpiry(
t: TFunction,
expiresAt: string,
): { label: string; tone: "ok" | "warn" | "danger" } {
const diffMs = new Date(expiresAt).getTime() - Date.now();
if (Number.isNaN(diffMs)) return { label: t("jira:expiryUnknown"), tone: "warn" };
if (diffMs <= 0) return { label: t("jira:cookieExpired"), tone: "danger" };
const hours = diffMs / (60 * 60 * 1000);
if (hours < 24) {
const h = Math.max(1, Math.round(hours));
return { label: t("jira:cookieExpiresInHours", { count: h }), tone: "danger" };
}
const days = Math.round(hours / 24);
return {
label: t("jira:cookieExpiresInDays", { count: days }),
tone: days < 7 ? "warn" : "ok",
};
}
const TONE_CLASSES: Record<"ok" | "warn" | "danger", string> = {
ok: "text-muted-foreground",
warn: "text-amber-600 dark:text-amber-400",
danger: "text-destructive",
};
export function CookieExpiry({ expiresAt }: { expiresAt: string }) {
const { t } = useTranslation();
const { label, tone } = formatExpiry(t, expiresAt);
// `formatDateTime` follows the active app locale; the previous bare
// `toLocaleString()` followed the browser's, so the tooltip could disagree
// with the rest of the page after a language switch.
//
// Guarded because the two disagree on a malformed date, which a legacy or
// corrupted `secretExpiresAt` really can be — `formatExpiry` has a branch for
// exactly that. `toLocaleString()` returned the string "Invalid Date";
// `Intl.DateTimeFormat` throws `RangeError`, which would take the whole Jira
// settings card down with it. No tooltip is the right fallback: the label
// already says the expiry is unknown.
const parsed = new Date(expiresAt);
const absolute = Number.isNaN(parsed.getTime()) ? undefined : formatDateTime(parsed);
return (
<p className={`text-xs ${TONE_CLASSES[tone]}`} title={absolute}>
{label}
</p>
);
}
/**
* Per-method help under the secret input. Each branch is one whole message
* rather than a stem spliced around a conditional link — where a link sits in a
* sentence is the translator's call, not English word order's.
*/
export function SecretHelp({ method, patHref }: { method: JiraAuthMethod; patHref?: string }) {
const { t } = useTranslation();
if (method === "session_cookie") return <SessionSnippet />;
if (method === "api_token") {
return (
<p className="text-xs text-muted-foreground">
{/* The link's visible text is the token page's own URL — an identifier in
both roles, so it travels through `values` rather than the catalog,
where the pseudo-locale would transliterate it. */}
<Trans i18nKey="jira:createATokenAt" values={{ url: API_TOKEN_URL_LABEL }}>
Create a token at{" "}
<a
className="underline cursor-pointer"
href={API_TOKEN_URL}
target="_blank"
rel="noreferrer"
>
{API_TOKEN_URL_LABEL}
</a>
</Trans>
</p>
);
}
if (method !== "pat") return null;
if (!patHref) {
return <p className="text-xs text-muted-foreground">{t("jira:createPatNoProfileLink")}</p>;
}
return (
<p className="text-xs text-muted-foreground">
{/* The profile URL is derived from the site the user configured, so it is
data and is interpolated. */}
<Trans i18nKey="jira:createPatWithProfileLink" values={{ url: patHref }}>
Create a Personal Access Token from your Jira profile (
<a className="underline cursor-pointer" href={patHref} target="_blank" rel="noreferrer">
{patHref}
</a>
) → Personal Access Tokens. Required scopes: read & write.
</Trans>
</p>
);
}
+133 -186
View File
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { IconTicket, IconCode } from "@tabler/icons-react";
import { IconTicket } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
@@ -34,17 +34,17 @@ import type {
JiraInstanceType,
TestJiraConnectionResult,
} from "@/lib/types/jira";
import { useTranslation } from "react-i18next";
import {
CookieExpiry,
SecretHelp,
secretCopy,
secretPlaceholder,
} from "@/components/jira/jira-secret-help";
// Session cookies are HttpOnly so document.cookie can't read them, but
// DevTools → Application → Cookies surfaces them in plain text. Users copy
// the Value cell of a single row; the backend wraps it under both
// cloud.session.token and tenant.session.token so a single paste works for
// password accounts and SSO tenants.
const COOKIE_INSTRUCTIONS = `Open DevTools (Cmd+Opt+I / Ctrl+Shift+I) on your Atlassian tab →
Application tab → Storage → Cookies → https://*.atlassian.net →
find the row named "cloud.session.token" (or "tenant.session.token"
on SSO tenants) → copy the Value cell → paste it below.
Don't include the cookie name or any "=" — just the token value.`;
// Cloud sites live under this domain; shown as an example, not as prose, so it
// is interpolated rather than written into the catalog.
const ATLASSIAN_CLOUD_DOMAIN = "*.atlassian.net";
type FormState = {
siteUrl: string;
@@ -110,10 +110,11 @@ type InstanceFieldsProps = FieldsRowProps & {
};
function InstanceFields({ form, baseline, loading, setForm }: InstanceFieldsProps) {
const { t } = useTranslation();
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="jira-instance">Instance type</Label>
<Label htmlFor="jira-instance">{t("jira:instanceType")}</Label>
<Select
value={form.instanceType}
onValueChange={(v) => {
@@ -138,18 +139,18 @@ function InstanceFields({ form, baseline, loading, setForm }: InstanceFieldsProp
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cloud">Atlassian Cloud</SelectItem>
<SelectItem value="server">Server / Data Center</SelectItem>
<SelectItem value="cloud">{t("jira:atlassianCloud")}</SelectItem>
<SelectItem value="server">{t("jira:serverDataCenter")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{form.instanceType === "cloud"
? "Sites hosted at *.atlassian.net."
: "Self-hosted Jira (Server or Data Center)."}
? t("jira:sitesHostedAt", { domain: ATLASSIAN_CLOUD_DOMAIN })
: t("jira:selfHostedJiraServerOrDataCenter")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="jira-project">Default project key (optional)</Label>
<Label htmlFor="jira-project">{t("jira:defaultProjectKeyOptional")}</Label>
<Input
id="jira-project"
data-testid="jira-project-input"
@@ -167,11 +168,12 @@ function InstanceFields({ form, baseline, loading, setForm }: InstanceFieldsProp
}
function SiteFields({ form, baseline, loading, update }: FieldsRowProps) {
const { t } = useTranslation();
const placeholder =
form.instanceType === "server" ? "https://jira.your-company.com" : "https://acme.atlassian.net";
return (
<div className="space-y-1.5">
<Label htmlFor="jira-site">Site URL</Label>
<Label htmlFor="jira-site">{t("jira:siteUrl")}</Label>
<Input
id="jira-site"
data-testid="jira-site-input"
@@ -186,11 +188,12 @@ function SiteFields({ form, baseline, loading, update }: FieldsRowProps) {
}
function AuthFields({ form, baseline, loading, update }: FieldsRowProps) {
const { t } = useTranslation();
const showEmail = form.instanceType === "cloud" && form.authMethod === "api_token";
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="jira-auth">Authentication method</Label>
<Label htmlFor="jira-auth">{t("jira:authenticationMethod")}</Label>
<Select
value={form.authMethod}
onValueChange={(v) => update("authMethod", v as JiraAuthMethod)}
@@ -206,18 +209,18 @@ function AuthFields({ form, baseline, loading, update }: FieldsRowProps) {
<SelectContent>
{form.instanceType === "cloud" ? (
<>
<SelectItem value="api_token">API token (recommended)</SelectItem>
<SelectItem value="session_cookie">Browser session cookie</SelectItem>
<SelectItem value="api_token">{t("jira:apiTokenRecommended")}</SelectItem>
<SelectItem value="session_cookie">{t("jira:browserSessionCookie")}</SelectItem>
</>
) : (
<SelectItem value="pat">Personal Access Token</SelectItem>
<SelectItem value="pat">{t("jira:personalAccessToken")}</SelectItem>
)}
</SelectContent>
</Select>
</div>
{showEmail ? (
<div className="space-y-1.5">
<Label htmlFor="jira-email">Email</Label>
<Label htmlFor="jira-email">{t("jira:email")}</Label>
<Input
id="jira-email"
data-testid="jira-email-input"
@@ -237,74 +240,8 @@ function AuthFields({ form, baseline, loading, update }: FieldsRowProps) {
);
}
function SessionSnippet() {
const [show, setShow] = useState(false);
return (
<div className="text-xs text-muted-foreground space-y-2">
<button
type="button"
onClick={() => setShow((v) => !v)}
className="inline-flex items-center gap-1 underline cursor-pointer"
>
<IconCode className="h-3 w-3" />
{show ? "Hide" : "Show"} how to copy the session token
</button>
{show && (
<pre className="bg-muted rounded p-3 text-[11px] overflow-x-auto whitespace-pre-wrap">
<code>{COOKIE_INSTRUCTIONS}</code>
</pre>
)}
</div>
);
}
type SecretFieldProps = FieldsRowProps & { hasSavedSecret: boolean };
// SECRET_COPY centralizes the field label and empty-state placeholder per
// auth method. Keyed by JiraAuthMethod so adding a new method causes the
// type system to flag the missing entry.
const SECRET_COPY: Record<JiraAuthMethod, { label: string; placeholder: string }> = {
api_token: { label: "API token", placeholder: "paste API token here" },
pat: { label: "Personal Access Token", placeholder: "paste personal access token here" },
session_cookie: { label: "Session token value", placeholder: "paste cloud.session.token value" },
};
function secretPlaceholder(method: JiraAuthMethod, hasSavedSecret: boolean): string {
return hasSavedSecret ? "••••••••" : SECRET_COPY[method].placeholder;
}
function formatExpiry(expiresAt: string): { label: string; tone: "ok" | "warn" | "danger" } {
const diffMs = new Date(expiresAt).getTime() - Date.now();
if (Number.isNaN(diffMs)) return { label: "Expiry unknown", tone: "warn" };
if (diffMs <= 0) return { label: "Cookie expired — paste a fresh one", tone: "danger" };
const hours = diffMs / (60 * 60 * 1000);
if (hours < 24) {
const h = Math.max(1, Math.round(hours));
return { label: `Cookie expires in ${h}h`, tone: "danger" };
}
const days = Math.round(hours / 24);
return {
label: `Cookie expires in ${days} day${days === 1 ? "" : "s"}`,
tone: days < 7 ? "warn" : "ok",
};
}
const TONE_CLASSES: Record<"ok" | "warn" | "danger", string> = {
ok: "text-muted-foreground",
warn: "text-amber-600 dark:text-amber-400",
danger: "text-destructive",
};
function CookieExpiry({ expiresAt }: { expiresAt: string }) {
const { label, tone } = formatExpiry(expiresAt);
const absolute = new Date(expiresAt).toLocaleString();
return (
<p className={`text-xs ${TONE_CLASSES[tone]}`} title={absolute}>
{label}
</p>
);
}
type SecretFieldPropsWithExpiry = SecretFieldProps & { secretExpiresAt?: string | null };
function SecretField({
@@ -315,24 +252,23 @@ function SecretField({
hasSavedSecret,
secretExpiresAt,
}: SecretFieldPropsWithExpiry) {
const { t } = useTranslation();
const method = form.authMethod;
const siteUrl = form.siteUrl.replace(/\/+$/, "");
const patHref = siteUrl ? `${siteUrl}/secure/ViewProfile.jspa` : undefined;
return (
<div className="space-y-1.5">
<Label htmlFor="jira-secret">
{SECRET_COPY[method].label}
{secretCopy(t)[method].label}
{hasSavedSecret && (
<span className="text-xs text-muted-foreground ml-2">
(saved — leave blank to keep the current value)
</span>
<span className="text-xs text-muted-foreground ml-2">{t("jira:savedLeaveBlank")}</span>
)}
</Label>
<Input
id="jira-secret"
data-testid="jira-secret-input"
type="password"
placeholder={secretPlaceholder(method, hasSavedSecret)}
placeholder={secretPlaceholder(t, method, hasSavedSecret)}
value={form.secret}
data-settings-dirty={form.secret !== baseline.secret}
onChange={(e) => update("secret", e.target.value)}
@@ -341,55 +277,24 @@ function SecretField({
{method === "session_cookie" && hasSavedSecret && secretExpiresAt && (
<CookieExpiry expiresAt={secretExpiresAt} />
)}
{method === "api_token" && (
<p className="text-xs text-muted-foreground">
Create a token at{" "}
<a
className="underline cursor-pointer"
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noreferrer"
>
id.atlassian.com/manage-profile/security/api-tokens
</a>
</p>
)}
{method === "pat" && (
<p className="text-xs text-muted-foreground">
Create a Personal Access Token from your Jira profile
{patHref ? (
<>
{" "}
(
<a
className="underline cursor-pointer"
href={patHref}
target="_blank"
rel="noreferrer"
>
{patHref}
</a>
){" "}
</>
) : (
" "
)}
→ Personal Access Tokens. Required scopes: read & write.
</p>
)}
{method === "session_cookie" && <SessionSnippet />}
<SecretHelp method={method} patHref={patHref} />
</div>
);
}
function TestResultAlert({ result }: { result: TestJiraConnectionResult | null }) {
const { t } = useTranslation();
if (!result) return null;
return (
<Alert variant={result.ok ? "default" : "destructive"}>
<AlertDescription>
{/* Both values are Jira's own reply — an account name and an upstream
error message — so they travel through `values`, never the catalog. */}
{result.ok
? `Connected as ${result.displayName || result.email || result.accountId}`
: `Failed: ${result.error}`}
? t("jira:connectedAs", {
name: result.displayName || result.email || result.accountId,
})
: t("jira:testFailed", { error: result.error })}
</AlertDescription>
</Alert>
);
@@ -415,6 +320,7 @@ type ActionBarProps = {
};
function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete }: ActionBarProps) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-2">
<Button
@@ -423,10 +329,10 @@ function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete
onClick={onTest}
disabled={testing || loading || disableTest}
className="cursor-pointer"
title={disableTest ? "Paste a token to test the connection" : undefined}
title={disableTest ? t("jira:pasteATokenToTestTheConnection") : undefined}
data-testid="jira-test-button"
>
{testing ? "Testing..." : "Test connection"}
{testing ? t("jira:testingConnection") : t("jira:testConnection")}
</Button>
{hasConfig && (
<Button
@@ -436,7 +342,7 @@ function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete
className="ml-auto cursor-pointer"
data-testid="jira-delete-button"
>
Remove configuration
{t("jira:removeConfiguration")}
</Button>
)}
</div>
@@ -459,7 +365,77 @@ function useJiraConfigRefresh(workspaceId: string, setConfig: (cfg: JiraConfig |
}, [workspaceId, setConfig]);
}
type JiraConfigMutationDeps = {
workspaceId: string;
form: FormState;
setConfig: Dispatch<SetStateAction<JiraConfig | null>>;
setForm: Dispatch<SetStateAction<FormState>>;
setTestResult: (result: TestJiraConnectionResult | null) => void;
setSaving: (saving: boolean) => void;
};
// The two callbacks that write the config and then re-seed the form, split out of
// useJiraSettings to keep each hook inside the function-length limit.
function useJiraConfigMutations({
workspaceId,
form,
setConfig,
setForm,
setTestResult,
setSaving,
}: JiraConfigMutationDeps) {
const { t } = useTranslation();
const { toast } = useToast();
const handleSave = useCallback(async () => {
const submitted = form;
setSaving(true);
try {
const saved = await setJiraConfig(
{
siteUrl: form.siteUrl,
email: form.email,
authMethod: form.authMethod,
instanceType: form.instanceType,
defaultProjectKey: form.defaultProjectKey,
secret: form.secret || undefined,
},
{ workspaceId },
);
setConfig(saved);
setForm((current) =>
JSON.stringify(current) === JSON.stringify(submitted) ? configToForm(saved) : current,
);
// Clear any inline test result from the previous credentials so the
// alert reflects only the currently-saved state.
setTestResult(null);
toast({ description: t("jira:jiraConfigurationSaved"), variant: "success" });
} catch (err) {
toast({ description: t("jira:saveFailed", { error: String(err) }), variant: "error" });
throw err;
} finally {
setSaving(false);
}
}, [workspaceId, form, setConfig, setForm, setTestResult, setSaving, t, toast]);
const handleDelete = useCallback(async () => {
if (!confirm(t("jira:removeJiraConfiguration"))) return;
try {
await deleteJiraConfig({ workspaceId });
setConfig(null);
setForm(emptyForm);
setTestResult(null);
toast({ description: t("jira:jiraConfigurationRemoved"), variant: "success" });
} catch (err) {
toast({ description: t("jira:deleteFailed", { error: String(err) }), variant: "error" });
}
}, [workspaceId, setConfig, setForm, setTestResult, t, toast]);
return { handleSave, handleDelete };
}
function useJiraSettings(workspaceId: string) {
const { t } = useTranslation();
const { toast } = useToast();
const [config, setConfig] = useState<JiraConfig | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
@@ -476,11 +452,14 @@ function useJiraSettings(workspaceId: string) {
setConfig(cfg);
setForm(configToForm(cfg));
} catch (err) {
toast({ description: `Failed to load Jira config: ${String(err)}`, variant: "error" });
toast({
description: t("jira:failedToLoadJiraConfig", { error: String(err) }),
variant: "error",
});
} finally {
setLoading(false);
}
}, [workspaceId, toast]);
}, [workspaceId, t, toast]);
useEffect(() => {
void load();
@@ -507,49 +486,14 @@ function useJiraSettings(workspaceId: string) {
}
}, [workspaceId, form]);
const handleSave = useCallback(async () => {
const submitted = form;
setSaving(true);
try {
const saved = await setJiraConfig(
{
siteUrl: form.siteUrl,
email: form.email,
authMethod: form.authMethod,
instanceType: form.instanceType,
defaultProjectKey: form.defaultProjectKey,
secret: form.secret || undefined,
},
{ workspaceId },
);
setConfig(saved);
setForm((current) =>
JSON.stringify(current) === JSON.stringify(submitted) ? configToForm(saved) : current,
);
// Clear any inline test result from the previous credentials so the
// alert reflects only the currently-saved state.
setTestResult(null);
toast({ description: "Jira configuration saved", variant: "success" });
} catch (err) {
toast({ description: `Save failed: ${String(err)}`, variant: "error" });
throw err;
} finally {
setSaving(false);
}
}, [workspaceId, form, toast]);
const handleDelete = useCallback(async () => {
if (!confirm("Remove Jira configuration?")) return;
try {
await deleteJiraConfig({ workspaceId });
setConfig(null);
setForm(emptyForm);
setTestResult(null);
toast({ description: "Jira configuration removed", variant: "success" });
} catch (err) {
toast({ description: `Delete failed: ${String(err)}`, variant: "error" });
}
}, [workspaceId, toast]);
const { handleSave, handleDelete } = useJiraConfigMutations({
workspaceId,
form,
setConfig,
setForm,
setTestResult,
setSaving,
});
const discard = useCallback(() => setForm(configToForm(config)), [config]);
return {
@@ -594,6 +538,7 @@ function savedSecretMatches(config: JiraConfig | null, form: FormState): boolean
}
export function JiraConnectionSection({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const s = useJiraSettings(workspaceId);
const baseline = configToForm(s.config);
const savedSecretMatchesMode = savedSecretMatches(s.config, s.form);
@@ -604,10 +549,12 @@ export function JiraConnectionSection({ workspaceId }: { workspaceId: string })
const disableTest = missingSecret;
const revision = JSON.stringify(s.form);
const dirty = !s.loading && revision !== JSON.stringify(configToForm(s.config));
// Assigned rather than returned from a helper, but the guard would not see it
// either way — `invalidReason` is a plain string, never a JSX literal.
let invalidReason: string | undefined;
if (!s.form.siteUrl) invalidReason = "A Jira site URL is required.";
else if (emailRequired && !s.form.email) invalidReason = "An email address is required.";
else if (missingSecret) invalidReason = "A credential is required.";
if (!s.form.siteUrl) invalidReason = t("jira:aJiraSiteUrlIsRequired");
else if (emailRequired && !s.form.email) invalidReason = t("jira:anEmailAddressIsRequired");
else if (missingSecret) invalidReason = t("jira:aCredentialIsRequired");
useSettingsSaveContributor({
id: `jira-config:${workspaceId}`,
@@ -622,8 +569,8 @@ export function JiraConnectionSection({ workspaceId }: { workspaceId: string })
return (
<SettingsSection
icon={<IconTicket className="h-5 w-5" />}
title="Jira integration"
description="Connect this workspace to Atlassian Cloud or a self-hosted Jira Server / Data Center instance. Credentials are stored encrypted server-side for the selected workspace."
title={t("jira:jiraIntegration")}
description={t("jira:connectThisWorkspaceToAtlassianCloud")}
action={<JiraEnabledControl />}
>
<SettingsCard isDirty={dirty}>
+22 -9
View File
@@ -40,15 +40,19 @@ export type JiraStoredPreset = {
prompt_template: string;
};
export const PRESET_ICON_CHOICES: { key: JiraPresetIcon; icon: Icon; label: string }[] = [
{ key: "code", icon: IconCode, label: "Code" },
{ key: "search", icon: IconSearch, label: "Search" },
{ key: "eye", icon: IconEye, label: "Eye" },
{ key: "message", icon: IconMessageDots, label: "Message" },
{ key: "tool", icon: IconTool, label: "Tool" },
{ key: "bug", icon: IconBug, label: "Bug" },
{ key: "sparkle", icon: IconSparkles, label: "Sparkle" },
{ key: "check", icon: IconChecks, label: "Check" },
// `key` is the persisted `JiraPresetIcon` enum; only the label is copy, so it
// travels as a catalog key resolved at render (module-scope `t()` would freeze
// at the boot locale — see docs/i18n.md). Same shape as
// `PRESET_ICON_CHOICES` in components/github/my-github/action-presets.ts.
export const PRESET_ICON_CHOICES: { key: JiraPresetIcon; icon: Icon; labelKey: string }[] = [
{ key: "code", icon: IconCode, labelKey: "jira:presetIconCode" },
{ key: "search", icon: IconSearch, labelKey: "jira:presetIconSearch" },
{ key: "eye", icon: IconEye, labelKey: "jira:presetIconEye" },
{ key: "message", icon: IconMessageDots, labelKey: "jira:presetIconMessage" },
{ key: "tool", icon: IconTool, labelKey: "jira:presetIconTool" },
{ key: "bug", icon: IconBug, labelKey: "jira:presetIconBug" },
{ key: "sparkle", icon: IconSparkles, labelKey: "jira:presetIconSparkle" },
{ key: "check", icon: IconChecks, labelKey: "jira:presetIconCheck" },
];
const ICON_BY_KEY: Record<string, Icon> = Object.fromEntries(
@@ -63,6 +67,10 @@ export function iconForPresetKey(key: string | undefined): Icon {
// Interpolate `{{url}}`, `{{key}}`, `{{title}}`, `{{description}}` placeholders.
// Also supports single-brace `{foo}` for convenience. Empty description falls
// back to "(no description)" so prompts read naturally.
//
// That fallback is NOT translated: it is substituted into a prompt sent to the
// agent, not rendered as UI copy, and the agent reads the whole template as one
// instruction in whatever language the user wrote it.
export function interpolateJiraTemplate(
template: string,
opts: { url: string; key: string; title: string; description: string },
@@ -94,6 +102,11 @@ export function toTaskPreset(stored: JiraStoredPreset): JiraTaskPreset {
};
}
// NOTE: `label`, `hint` and `prompt_template` below are NOT translated, and must
// not be. These seed the user's editable preset list and are PERSISTED verbatim
// into user settings on the first save, so a locale-dependent value would be
// written into the user's data and stay there after a locale switch. The prompt
// templates are also sent to the agent as instructions rather than shown as copy.
export const DEFAULT_JIRA_PRESETS: JiraStoredPreset[] = [
{
id: "implement",
+121 -65
View File
@@ -21,34 +21,49 @@ import {
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
import { Trans, useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
const JIRA_PROMPT_PLACEHOLDERS: ScriptPlaceholder[] = [
{
key: "key",
description: "Jira ticket key",
example: "PROJ-123",
executor_types: [],
},
{
key: "url",
description: "URL of the Jira ticket",
example: "https://company.atlassian.net/browse/PROJ-123",
executor_types: [],
},
{
key: "title",
description: "Ticket summary",
example: "Login button broken on Safari",
executor_types: [],
},
{
key: "description",
description: "Ticket description",
example: "Repro: open Safari, click login…",
executor_types: [],
},
];
// A function rather than a const because `description` is copy — it is shown in
// the editor's completion list — and a module-scope `t()` would freeze it at the
// boot locale. `key` is the interpolation token and each `example` is sample
// Jira data; both are identifiers and stay out of the catalog.
function jiraPromptPlaceholders(t: TFunction): ScriptPlaceholder[] {
return [
{
key: "key",
description: t("jira:presetPlaceholderTicketKey"),
example: "PROJ-123",
executor_types: [],
},
{
key: "url",
description: t("jira:presetPlaceholderTicketUrl"),
example: "https://company.atlassian.net/browse/PROJ-123",
executor_types: [],
},
{
key: "title",
description: t("jira:presetPlaceholderTicketSummary"),
example: "Login button broken on Safari",
executor_types: [],
},
{
key: "description",
description: t("jira:presetPlaceholderTicketDescription"),
example: "Repro: open Safari, click login…",
executor_types: [],
},
];
}
// The My Jira page these presets appear on. A route, not copy.
const MY_JIRA_ROUTE = "/jira";
// `label` is PERSISTED to user settings as part of `JiraStoredPreset`, and the
// row below is immediately editable, so it must stay locale-neutral: a preset
// seeded in one locale and saved unedited would keep that locale's text forever.
// Same contract as `newPreset` in components/github/action-presets-section.tsx.
function newPreset(): JiraStoredPreset {
return {
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
@@ -60,9 +75,10 @@ function newPreset(): JiraStoredPreset {
}
function IconSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { t } = useTranslation();
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="!h-8 py-0.5 text-sm cursor-pointer" aria-label="Icon">
<SelectTrigger className="!h-8 py-0.5 text-sm cursor-pointer" aria-label={t("jira:icon")}>
<SelectValue>
{createElement(iconForPresetKey(value), { className: "h-4 w-4" })}
</SelectValue>
@@ -74,7 +90,7 @@ function IconSelect({ value, onChange }: { value: string; onChange: (v: string)
<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>
);
@@ -99,6 +115,7 @@ function PresetRow({
onPatch: (patch: Partial<JiraStoredPreset>) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const fieldIsDirty = <K extends keyof JiraStoredPreset>(field: K) =>
!savedPreset || preset[field] !== savedPreset[field];
const isDirty = !savedPreset || JSON.stringify(preset) !== JSON.stringify(savedPreset);
@@ -111,7 +128,7 @@ function PresetRow({
>
<div className="flex items-end gap-2 p-2">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-muted-foreground">Icon</span>
<span className="text-[10px] text-muted-foreground">{t("jira:icon")}</span>
<div
data-settings-dirty={fieldIsDirty("icon")}
className="rounded-md border border-transparent"
@@ -120,22 +137,22 @@ function PresetRow({
</div>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-muted-foreground">Label</span>
<span className="text-[10px] text-muted-foreground">{t("jira:label")}</span>
<Input
className="h-8 w-40"
value={preset.label}
data-settings-dirty={fieldIsDirty("label")}
placeholder="Label"
placeholder={t("jira:label")}
onChange={(e) => onPatch({ label: e.target.value })}
/>
</div>
<div className="flex flex-col gap-0.5 flex-1">
<span className="text-[10px] text-muted-foreground">Hint</span>
<span className="text-[10px] text-muted-foreground">{t("jira:hint")}</span>
<Input
className="h-8"
value={preset.hint}
data-settings-dirty={fieldIsDirty("hint")}
placeholder="Hint (optional)"
placeholder={t("jira:hintOptional")}
onChange={(e) => onPatch({ hint: e.target.value })}
/>
</div>
@@ -145,47 +162,81 @@ function PresetRow({
className="h-8 cursor-pointer text-xs"
onClick={onToggle}
>
{expanded ? "Hide prompt" : "Edit prompt"}
{expanded ? t("jira:hidePrompt") : t("jira:editPrompt")}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-destructive"
onClick={onRemove}
aria-label="Remove"
aria-label={t("jira:remove")}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
{expanded && (
<div className="px-2 pb-2 space-y-1">
<div
className="rounded-md border overflow-hidden"
data-settings-dirty={fieldIsDirty("prompt_template")}
>
<ScriptEditor
value={preset.prompt_template}
onChange={(v) => onPatch({ prompt_template: v })}
language="markdown"
height={computeEditorHeight(preset.prompt_template)}
lineNumbers="off"
placeholders={JIRA_PROMPT_PLACEHOLDERS}
/>
</div>
<p className="text-[11px] text-muted-foreground/60">
Type {"{{"} to see available placeholders.{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{key}}"}</code>,{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{url}}"}</code>,{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{title}}"}</code>, and{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{description}}"}</code>{" "}
are substituted when the action runs.
</p>
</div>
<PresetPromptEditor
preset={preset}
isDirty={fieldIsDirty("prompt_template")}
onPatch={onPatch}
/>
)}
</div>
);
}
function PresetPromptEditor({
preset,
isDirty,
onPatch,
}: {
preset: JiraStoredPreset;
isDirty: boolean;
onPatch: (patch: Partial<JiraStoredPreset>) => void;
}) {
const { t } = useTranslation();
// Memoized because `ScriptEditor` keys its Monaco completion-provider
// registration on `placeholders` identity. This used to be a module-scope
// const, so it was stable for free; now that it is built from `t`, a fresh
// array on every render would re-register the provider on every keystroke.
const placeholders = useMemo(() => jiraPromptPlaceholders(t), [t]);
return (
<div className="px-2 pb-2 space-y-1">
<div className="rounded-md border overflow-hidden" data-settings-dirty={isDirty}>
<ScriptEditor
value={preset.prompt_template}
onChange={(v) => onPatch({ prompt_template: v })}
language="markdown"
height={computeEditorHeight(preset.prompt_template)}
lineNumbers="off"
placeholders={placeholders}
/>
</div>
<p className="text-[11px] text-muted-foreground/60">
{/* The five `{{…}}` tokens are passed as values, never written into the
catalog, where i18next would interpolate them away. */}
<Trans
i18nKey="jira:presetPromptPlaceholderHelp"
values={{
token: "{{",
key: "{{key}}",
url: "{{url}}",
title: "{{title}}",
description: "{{description}}",
}}
>
Type {"{{token}}"} to see available placeholders.{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{key}}"}</code>,{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{url}}"}</code>,{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{title}}"}</code>, and{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{description}}"}</code> are
substituted when the action runs.
</Trans>
</p>
</div>
);
}
function usePresetDraft() {
const { stored, save: persistSave, loaded } = useJiraTaskPresets();
const [draft, setDraft] = useState<JiraStoredPreset[]>(stored);
@@ -220,6 +271,7 @@ function usePresetDraft() {
}
export function TaskPresetsSection() {
const { t } = useTranslation();
const { toast } = useToast();
const { draft, baseline, setDraft, dirty, save, reset, discard, loaded } = usePresetDraft();
const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -243,12 +295,14 @@ export function TaskPresetsSection() {
const handleSave = useCallback(async () => {
try {
await save();
toast({ description: "Task presets saved", variant: "success" });
toast({ description: t("jira:taskPresetsSaved"), variant: "success" });
} catch {
toast({ description: "Failed to save task presets", variant: "error" });
toast({ description: t("jira:failedToSaveTaskPresets"), variant: "error" });
// Signals failure to the settings save coordinator, which only records the
// contributor id — this message never reaches a user, so it is not copy.
throw new Error("Failed to save task presets");
}
}, [save, toast]);
}, [save, t, toast]);
useSettingsSaveContributor({
id: "jira-task-presets",
@@ -261,8 +315,10 @@ export function TaskPresetsSection() {
return (
<SettingsSection
title="Task presets"
description="Prompts shown on /jira when starting a task from a ticket."
title={t("jira:taskPresets")}
// `/jira` is an in-app route, so it is interpolated rather than written
// into the catalog where a locale could turn it into a dead link.
description={t("jira:taskPresetsDescription", { route: MY_JIRA_ROUTE })}
action={
<div className="flex gap-2">
<Button
@@ -273,7 +329,7 @@ export function TaskPresetsSection() {
className="cursor-pointer"
>
<IconRefresh className="h-3.5 w-3.5 mr-1" />
Reset
{t("common:reset")}
</Button>
</div>
}
@@ -292,7 +348,7 @@ export function TaskPresetsSection() {
))}
<Button size="sm" variant="outline" onClick={add} className="cursor-pointer">
<IconPlus className="h-3.5 w-3.5 mr-1" />
Add preset
{t("jira:addPreset")}
</Button>
</SettingsCard>
</SettingsSection>
+19 -10
View File
@@ -127,16 +127,25 @@ const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [
],
},
{ name: "settings — task actions", url: "/settings/general/task-actions" },
// NOT YET: "settings — integrations github". The page's own copy is fully
// migrated (verified by running this oracle against it — every GitHub-owned
// string renders accented), but the route expands the Workspaces > Integrations
// branch of the settings nav, and `workspaces-group.tsx` / `settings-tree.tsx`
// are not migrated: "Workspaces", "Integrations", "Automations", "Executors",
// "Voice Mode", "Utility Agents", "External MCP", "Plugins", "System" and
// "Toggle theme" all still render plain English there. The /settings/general/*
// screens above pass because their expanded branch is `general-group.tsx`,
// which is migrated. Add this entry in the PR that migrates the settings nav —
// allowlisting those nav labels here would hide real misses instead.
// NOT YET: "settings — integrations github", "… gitlab", "… jira". Each page's
// own copy is fully migrated (verified by running this oracle against it —
// every string the integration owns renders accented), but the route expands
// the Workspaces > Integrations branch of the settings nav, and
// `workspaces-group.tsx` / `settings-tree.tsx` are not migrated: "Workspaces",
// "Integrations", "Automations", "Executors", "Voice Mode", "Utility Agents",
// "External MCP", "Plugins", "System" and "Toggle theme" all still render plain
// English there. The /settings/general/* screens above pass because their
// expanded branch is `general-group.tsx`, which is migrated. Add these entries
// in the PR that migrates the settings nav — allowlisting those nav labels here
// would hide real misses instead.
//
// Two shared components rendered by all three pages are also still English and
// would have to be migrated (or allowlisted, which is worse) first:
// `components/integrations/drafted-integration-enabled-control.tsx`
// ("Enabled"/"Disabled") and `components/watcher-repository-fields.tsx`
// ("Repository", "Base Branch", "(no repository)"), plus `STEP_DEFAULT_LABEL`
// and `stepPlaceholder`. All four are shared with the un-migrated Linear,
// Sentry and Azure DevOps surfaces.
];
/**
+28
View File
@@ -321,4 +321,32 @@ export const i18nGuardFiles = [
"components/gitlab/watch-form.ts",
"components/gitlab/watch-settings.tsx",
"components/gitlab/watch-table.tsx",
// Settings → Integrations → Jira: the page and the settings subtree of
// `components/jira` — the connection card, the JIRA-watcher section with its
// table and dialog, and the task-preset editor. The ticket task surface
// (`jira-ticket-*`), the import bar and the `my-jira/**` browse UI live in the
// same directory and are NOT migrated, which is why this is a file list rather
// than `components/jira/**`.
//
// The three `.ts` entries hold no JSX, so `mode: "jsx-only"` never inspects
// them; the entries record that they are migrated, and only the pseudo-locale
// can prove it stays that way. Each carries copy that is deliberately left in
// English because it is PERSISTED or sent to an agent verbatim — the default
// JQL and issue-watch prompt in `jira-issue-watch-placeholders.ts`, the
// seeded preset labels/hints/templates in `my-jira/presets.ts`.
//
// `my-jira/presets.ts` is shared with the un-migrated My Jira page, which the
// per-file guard leaves unaffected; its icon labels now travel as `labelKey`
// and resolve at render.
"app/settings/integrations/jira/**/*.{ts,tsx}",
"components/jira/jira-enabled-control.tsx",
"components/jira/jira-issue-watch-dialog.tsx",
"components/jira/jira-issue-watch-placeholders.ts",
"components/jira/jira-issue-watch-table.tsx",
"components/jira/jira-issue-watchers-section.tsx",
"components/jira/jira-secret-help.tsx",
"components/jira/jira-settings.tsx",
"components/jira/my-jira/presets.ts",
"components/jira/my-jira/use-task-presets.ts",
"components/jira/task-presets-section.tsx",
];
+149
View File
@@ -0,0 +1,149 @@
{
"aCredentialIsRequired": "A credential is required.",
"aJiraSiteUrlIsRequired": "A Jira site URL is required.",
"actions": "Actions",
"active": "Active",
"addPreset": "Add preset",
"agentProfile": "Agent Profile",
"anEmailAddressIsRequired": "An email address is required.",
"apiToken": "API token",
"apiTokenRecommended": "API token (recommended)",
"atlassianCloud": "Atlassian Cloud",
"atlassianJqlHelp": "Atlassian JQL. The watcher polls this query and creates one task per newly-matching ticket.",
"authenticationMethod": "Authentication method",
"availablePlaceholders": "Available placeholders:",
"browserSessionCookie": "Browser session cookie",
"capOnOpenTasksCreatedBy": "Cap on open tasks created by this watcher. Leave blank for no cap. New matches are deferred to the next poll when the cap is reached.",
"checkFailed": "Check failed: {{error}}",
"checkNow": "Check now",
"connectThisWorkspaceToAtlassianCloud": "Connect this workspace to Atlassian Cloud or a self-hosted Jira Server / Data Center instance. Credentials are stored encrypted server-side for the selected workspace.",
"connectedAs": "Connected as {{name}}",
"cookieExpired": "Cookie expired — paste a fresh one",
"cookieExpiresInDays_one": "Cookie expires in {{count}} day",
"cookieExpiresInDays_other": "Cookie expires in {{count}} days",
"cookieExpiresInHours_one": "Cookie expires in {{count}}h",
"cookieExpiresInHours_other": "Cookie expires in {{count}}h",
"cookieInstructions": "Open DevTools ({{shortcut}}) on your Atlassian tab →\nApplication tab → Storage → Cookies → {{host}} →\nfind the row named \"{{cookieName}}\" (or \"{{ssoCookieName}}\"\non SSO tenants) → copy the Value cell → paste it below.\nDon't include the cookie name or any \"=\" — just the token value.",
"create": "Create",
"createATokenAt": "Create a token at <2>{{url}}</2>",
"createFailed": "Create failed: {{error}}",
"createJiraWatcher": "Create JIRA Watcher",
"createPatNoProfileLink": "Create a Personal Access Token from your Jira profile → Personal Access Tokens. Required scopes: read & write.",
"createPatWithProfileLink": "Create a Personal Access Token from your Jira profile (<1>{{url}}</1>) → Personal Access Tokens. Required scopes: read & write.",
"defaultProjectKeyOptional": "Default project key (optional)",
"delete": "Delete",
"deleteFailed": "Delete failed: {{error}}",
"deleteThisJiraWatcher": "Delete this JIRA watcher?",
"editJiraWatcher": "Edit JIRA Watcher",
"editPrompt": "Edit prompt",
"email": "Email",
"enable": "Enable",
"enabled": "Enabled",
"enterAPositiveIntegerOrLeave": "Enter a positive integer or leave blank.",
"executorProfile": "Executor Profile",
"expiryUnknown": "Expiry unknown",
"failedToLoadJiraConfig": "Failed to load Jira config: {{error}}",
"failedToSaveTaskPresets": "Failed to save task presets",
"foundNewTickets_one": "Found {{count}} new ticket — tasks will appear shortly.",
"foundNewTickets_other": "Found {{count}} new tickets — tasks will appear shortly.",
"hidePrompt": "Hide prompt",
"hideSessionTokenHelp": "Hide how to copy the session token",
"hint": "Hint",
"hintOptional": "Hint (optional)",
"howOftenToReRunThe": "How often to re-run the JQL. Minimum 60s, maximum 3600s.",
"icon": "Icon",
"initialStepForNewTasks": "Initial step for new tasks.",
"instanceType": "Instance type",
"interval": "Interval",
"intervalMinutes_one": "{{count}}m",
"intervalMinutes_other": "{{count}}m",
"jiraConfigurationRemoved": "Jira configuration removed",
"jiraConfigurationSaved": "Jira configuration saved",
"jiraIntegration": "Jira integration",
"jiraWatcher": "JIRA watcher",
"jiraWatchers": "JIRA watchers",
"jiraWatchersDescription": "Poll a JQL query and auto-create a Kandev task for each newly-matching ticket.",
"jqlError": "JQL error: {{error}}",
"label": "Label",
"lastPolled": "Last Polled",
"matchedTickets_one": "Matched {{count}} ticket in this page.",
"matchedTickets_other": "Matched {{count}} tickets in this page.",
"maxInFlightTasks": "Max in-flight tasks",
"never": "Never",
"newWatcher": "New watcher",
"noCap": "(no cap)",
"noJiraWatchersConfiguredCreateOne": "No JIRA watchers configured. Create one to auto-create tasks from JQL queries.",
"noNewTicketsMatched": "No new tickets matched.",
"optionalFallsBackToStepDefault": "Optional — falls back to step default.",
"pasteATokenToTestTheConnection": "Paste a token to test the connection",
"pasteApiTokenHere": "paste API token here",
"pastePersonalAccessTokenHere": "paste personal access token here",
"pasteSessionTokenValue": "paste {{cookieName}} value",
"pause": "Pause",
"pauseOrResumePolling": "Pause or resume polling.",
"paused": "Paused",
"personalAccessToken": "Personal Access Token",
"placeholderAssigneeDisplayName": "Assignee display name",
"placeholderIssueType": "Issue type",
"placeholderPriority": "Priority",
"placeholderProjectKey": "Project key",
"placeholderReporterDisplayName": "Reporter display name",
"placeholderStatusName": "Status name",
"placeholderTicketDescription": "Ticket description",
"placeholderTicketKey": "Ticket key",
"placeholderTicketSummary": "Ticket summary",
"placeholderTicketUrl": "Ticket URL",
"pollIntervalSeconds": "Poll Interval (seconds)",
"presetIconBug": "Bug",
"presetIconCheck": "Check",
"presetIconCode": "Code",
"presetIconEye": "Eye",
"presetIconMessage": "Message",
"presetIconSearch": "Search",
"presetIconSparkle": "Sparkle",
"presetIconTool": "Tool",
"presetPlaceholderTicketDescription": "Ticket description",
"presetPlaceholderTicketKey": "Jira ticket key",
"presetPlaceholderTicketSummary": "Ticket summary",
"presetPlaceholderTicketUrl": "URL of the Jira ticket",
"presetPromptPlaceholderHelp": "Type {{token}} to see available placeholders. <4>{{key}}</4>, <7>{{url}}</7>, <10>{{title}}</10>, and <13>{{description}}</13> are substituted when the action runs.",
"promptFieldHelp": "The prompt sent to the agent for each new ticket. Type {{token}} to insert placeholders.",
"remove": "Remove",
"removeConfiguration": "Remove configuration",
"removeJiraConfiguration": "Remove Jira configuration?",
"resetCompleteDeletedTasks_one": "Reset complete — deleted {{count}} task; next poll will re-import matches.",
"resetCompleteDeletedTasks_other": "Reset complete — deleted {{count}} tasks; next poll will re-import matches.",
"resetCompleteNoTasksDeleted": "Reset complete — next poll will re-import matches.",
"resetFailed": "Reset failed: {{error}}",
"resetWatch": "Reset watch",
"saveFailed": "Save failed: {{error}}",
"savedLeaveBlank": "(saved — leave blank to keep the current value)",
"saving": "Saving…",
"selectWorkflow": "Select workflow",
"selectWorkspace": "Select workspace",
"selfHostedJiraServerOrDataCenter": "Self-hosted Jira (Server or Data Center).",
"serverDataCenter": "Server / Data Center",
"sessionTokenValue": "Session token value",
"showSessionTokenHelp": "Show how to copy the session token",
"siteUrl": "Site URL",
"sitesHostedAt": "Sites hosted at {{domain}}.",
"taskPresets": "Task presets",
"taskPresetsDescription": "Prompts shown on {{route}} when starting a task from a ticket.",
"taskPresetsSaved": "Task presets saved",
"taskPrompt": "Task Prompt",
"tasksAreCreatedInThisWorkflow": "Tasks are created in this workflow.",
"tasksCreatedByThisWatcherLand": "Tasks created by this watcher land in the selected workspace.",
"testConnection": "Test connection",
"testFailed": "Failed: {{error}}",
"testJql": "Test JQL",
"testingConnection": "Testing...",
"testingJql": "Testing…",
"update": "Update",
"updateFailed": "Update failed: {{error}}",
"watchDialogDescription": "Poll a JQL query and auto-create a Kandev task for each newly-matching ticket. Optionally bind a repository so each task runs against that codebase, or leave it unset to run with no repository.",
"watcherCreated": "Watcher created",
"watcherDeleted": "Watcher deleted",
"watcherUpdated": "Watcher updated",
"workflow": "Workflow",
"workflowStep": "Workflow Step"
}
+149
View File
@@ -0,0 +1,149 @@
{
"aCredentialIsRequired": "À ćŕēďēńţĩàĺ ĩś ŕēqũĩŕēď.",
"aJiraSiteUrlIsRequired": "À Ĵĩŕà śĩţē ŨŔĹ ĩś ŕēqũĩŕēď.",
"actions": "Àćţĩōńś",
"active": "Àćţĩvē",
"addPreset": "Àďď ƥŕēśēţ",
"agentProfile": "Àĝēńţ Ƥŕōƒĩĺē",
"anEmailAddressIsRequired": "Àń ēḿàĩĺ àďďŕēśś ĩś ŕēqũĩŕēď.",
"apiToken": "ÀƤĨ ţōķēń",
"apiTokenRecommended": "ÀƤĨ ţōķēń (ŕēćōḿḿēńďēď)",
"atlassianCloud": "Àţĺàśśĩàń Ćĺōũď",
"atlassianJqlHelp": "Àţĺàśśĩàń ĴQĹ. Ţĥē ŵàţćĥēŕ ƥōĺĺś ţĥĩś qũēŕŷ àńď ćŕēàţēś ōńē ţàśķ ƥēŕ ńēŵĺŷ-ḿàţćĥĩńĝ ţĩćķēţ.",
"authenticationMethod": "Àũţĥēńţĩćàţĩōń ḿēţĥōď",
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
"browserSessionCookie": "Ɓŕōŵśēŕ śēśśĩōń ćōōķĩē",
"capOnOpenTasksCreatedBy": "Ćàƥ ōń ōƥēń ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ. Ĺēàvē ƀĺàńķ ƒōŕ ńō ćàƥ. Ńēŵ ḿàţćĥēś àŕē ďēƒēŕŕēď ţō ţĥē ńēxţ ƥōĺĺ ŵĥēń ţĥē ćàƥ ĩś ŕēàćĥēď.",
"checkFailed": "Ćĥēćķ ƒàĩĺēď: {{error}}",
"checkNow": "Ćĥēćķ ńōŵ",
"connectThisWorkspaceToAtlassianCloud": "Ćōńńēćţ ţĥĩś ŵōŕķśƥàćē ţō Àţĺàśśĩàń Ćĺōũď ōŕ à śēĺƒ-ĥōśţēď Ĵĩŕà Śēŕvēŕ / Ďàţà Ćēńţēŕ ĩńśţàńćē. Ćŕēďēńţĩàĺś àŕē śţōŕēď ēńćŕŷƥţēď śēŕvēŕ-śĩďē ƒōŕ ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
"connectedAs": "Ćōńńēćţēď àś {{name}}",
"cookieExpired": "Ćōōķĩē ēxƥĩŕēď — ƥàśţē à ƒŕēśĥ ōńē",
"cookieExpiresInDays_one": "Ćōōķĩē ēxƥĩŕēś ĩń {{count}} ďàŷ",
"cookieExpiresInDays_other": "Ćōōķĩē ēxƥĩŕēś ĩń {{count}} ďàŷś",
"cookieExpiresInHours_one": "Ćōōķĩē ēxƥĩŕēś ĩń {{count}}ĥ",
"cookieExpiresInHours_other": "Ćōōķĩē ēxƥĩŕēś ĩń {{count}}ĥ",
"cookieInstructions": "Ōƥēń ĎēvŢōōĺś ({{shortcut}}) ōń ŷōũŕ Àţĺàśśĩàń ţàƀ →\nÀƥƥĺĩćàţĩōń ţàƀ → Śţōŕàĝē → Ćōōķĩēś → {{host}} →\nƒĩńď ţĥē ŕōŵ ńàḿēď \"{{cookieName}}\" (ōŕ \"{{ssoCookieName}}\"\nōń ŚŚŌ ţēńàńţś) → ćōƥŷ ţĥē Vàĺũē ćēĺĺ → ƥàśţē ĩţ ƀēĺōŵ.\nĎōń'ţ ĩńćĺũďē ţĥē ćōōķĩē ńàḿē ōŕ àńŷ \"=\" — ĵũśţ ţĥē ţōķēń vàĺũē.",
"create": "Ćŕēàţē",
"createATokenAt": "Ćŕēàţē à ţōķēń àţ <2>{{url}}</2>",
"createFailed": "Ćŕēàţē ƒàĩĺēď: {{error}}",
"createJiraWatcher": "Ćŕēàţē ĴĨŔÀ Ŵàţćĥēŕ",
"createPatNoProfileLink": "Ćŕēàţē à Ƥēŕśōńàĺ Àććēśś Ţōķēń ƒŕōḿ ŷōũŕ Ĵĩŕà ƥŕōƒĩĺē → Ƥēŕśōńàĺ Àććēśś Ţōķēńś. Ŕēqũĩŕēď śćōƥēś: ŕēàď & ŵŕĩţē.",
"createPatWithProfileLink": "Ćŕēàţē à Ƥēŕśōńàĺ Àććēśś Ţōķēń ƒŕōḿ ŷōũŕ Ĵĩŕà ƥŕōƒĩĺē (<1>{{url}}</1>) → Ƥēŕśōńàĺ Àććēśś Ţōķēńś. Ŕēqũĩŕēď śćōƥēś: ŕēàď & ŵŕĩţē.",
"defaultProjectKeyOptional": "Ďēƒàũĺţ ƥŕōĵēćţ ķēŷ (ōƥţĩōńàĺ)",
"delete": "Ďēĺēţē",
"deleteFailed": "Ďēĺēţē ƒàĩĺēď: {{error}}",
"deleteThisJiraWatcher": "Ďēĺēţē ţĥĩś ĴĨŔÀ ŵàţćĥēŕ?",
"editJiraWatcher": "Ēďĩţ ĴĨŔÀ Ŵàţćĥēŕ",
"editPrompt": "Ēďĩţ ƥŕōḿƥţ",
"email": "Ēḿàĩĺ",
"enable": "Ēńàƀĺē",
"enabled": "Ēńàƀĺēď",
"enterAPositiveIntegerOrLeave": "Ēńţēŕ à ƥōśĩţĩvē ĩńţēĝēŕ ōŕ ĺēàvē ƀĺàńķ.",
"executorProfile": "Ēxēćũţōŕ Ƥŕōƒĩĺē",
"expiryUnknown": "Ēxƥĩŕŷ ũńķńōŵń",
"failedToLoadJiraConfig": "Ƒàĩĺēď ţō ĺōàď Ĵĩŕà ćōńƒĩĝ: {{error}}",
"failedToSaveTaskPresets": "Ƒàĩĺēď ţō śàvē ţàśķ ƥŕēśēţś",
"foundNewTickets_one": "Ƒōũńď {{count}} ńēŵ ţĩćķēţ — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
"foundNewTickets_other": "Ƒōũńď {{count}} ńēŵ ţĩćķēţś — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
"hidePrompt": "Ĥĩďē ƥŕōḿƥţ",
"hideSessionTokenHelp": "Ĥĩďē ĥōŵ ţō ćōƥŷ ţĥē śēśśĩōń ţōķēń",
"hint": "Ĥĩńţ",
"hintOptional": "Ĥĩńţ (ōƥţĩōńàĺ)",
"howOftenToReRunThe": "Ĥōŵ ōƒţēń ţō ŕē-ŕũń ţĥē ĴQĹ. Ḿĩńĩḿũḿ 60ś, ḿàxĩḿũḿ 3600ś.",
"icon": "Ĩćōń",
"initialStepForNewTasks": "Ĩńĩţĩàĺ śţēƥ ƒōŕ ńēŵ ţàśķś.",
"instanceType": "Ĩńśţàńćē ţŷƥē",
"interval": "Ĩńţēŕvàĺ",
"intervalMinutes_one": "{{count}}ḿ",
"intervalMinutes_other": "{{count}}ḿ",
"jiraConfigurationRemoved": "Ĵĩŕà ćōńƒĩĝũŕàţĩōń ŕēḿōvēď",
"jiraConfigurationSaved": "Ĵĩŕà ćōńƒĩĝũŕàţĩōń śàvēď",
"jiraIntegration": "Ĵĩŕà ĩńţēĝŕàţĩōń",
"jiraWatcher": "ĴĨŔÀ ŵàţćĥēŕ",
"jiraWatchers": "ĴĨŔÀ ŵàţćĥēŕś",
"jiraWatchersDescription": "Ƥōĺĺ à ĴQĹ qũēŕŷ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ţĩćķēţ.",
"jqlError": "ĴQĹ ēŕŕōŕ: {{error}}",
"label": "Ĺàƀēĺ",
"lastPolled": "Ĺàśţ Ƥōĺĺēď",
"matchedTickets_one": "Ḿàţćĥēď {{count}} ţĩćķēţ ĩń ţĥĩś ƥàĝē.",
"matchedTickets_other": "Ḿàţćĥēď {{count}} ţĩćķēţś ĩń ţĥĩś ƥàĝē.",
"maxInFlightTasks": "Ḿàx ĩń-ƒĺĩĝĥţ ţàśķś",
"never": "Ńēvēŕ",
"newWatcher": "Ńēŵ ŵàţćĥēŕ",
"noCap": "(ńō ćàƥ)",
"noJiraWatchersConfiguredCreateOne": "Ńō ĴĨŔÀ ŵàţćĥēŕś ćōńƒĩĝũŕēď. Ćŕēàţē ōńē ţō àũţō-ćŕēàţē ţàśķś ƒŕōḿ ĴQĹ qũēŕĩēś.",
"noNewTicketsMatched": "Ńō ńēŵ ţĩćķēţś ḿàţćĥēď.",
"optionalFallsBackToStepDefault": "Ōƥţĩōńàĺ — ƒàĺĺś ƀàćķ ţō śţēƥ ďēƒàũĺţ.",
"pasteATokenToTestTheConnection": "Ƥàśţē à ţōķēń ţō ţēśţ ţĥē ćōńńēćţĩōń",
"pasteApiTokenHere": "ƥàśţē ÀƤĨ ţōķēń ĥēŕē",
"pastePersonalAccessTokenHere": "ƥàśţē ƥēŕśōńàĺ àććēśś ţōķēń ĥēŕē",
"pasteSessionTokenValue": "ƥàśţē {{cookieName}} vàĺũē",
"pause": "Ƥàũśē",
"pauseOrResumePolling": "Ƥàũśē ōŕ ŕēśũḿē ƥōĺĺĩńĝ.",
"paused": "Ƥàũśēď",
"personalAccessToken": "Ƥēŕśōńàĺ Àććēśś Ţōķēń",
"placeholderAssigneeDisplayName": "Àśśĩĝńēē ďĩśƥĺàŷ ńàḿē",
"placeholderIssueType": "Ĩśśũē ţŷƥē",
"placeholderPriority": "Ƥŕĩōŕĩţŷ",
"placeholderProjectKey": "Ƥŕōĵēćţ ķēŷ",
"placeholderReporterDisplayName": "Ŕēƥōŕţēŕ ďĩśƥĺàŷ ńàḿē",
"placeholderStatusName": "Śţàţũś ńàḿē",
"placeholderTicketDescription": "Ţĩćķēţ ďēśćŕĩƥţĩōń",
"placeholderTicketKey": "Ţĩćķēţ ķēŷ",
"placeholderTicketSummary": "Ţĩćķēţ śũḿḿàŕŷ",
"placeholderTicketUrl": "Ţĩćķēţ ŨŔĹ",
"pollIntervalSeconds": "Ƥōĺĺ Ĩńţēŕvàĺ (śēćōńďś)",
"presetIconBug": "Ɓũĝ",
"presetIconCheck": "Ćĥēćķ",
"presetIconCode": "Ćōďē",
"presetIconEye": "Ēŷē",
"presetIconMessage": "Ḿēśśàĝē",
"presetIconSearch": "Śēàŕćĥ",
"presetIconSparkle": "Śƥàŕķĺē",
"presetIconTool": "Ţōōĺ",
"presetPlaceholderTicketDescription": "Ţĩćķēţ ďēśćŕĩƥţĩōń",
"presetPlaceholderTicketKey": "Ĵĩŕà ţĩćķēţ ķēŷ",
"presetPlaceholderTicketSummary": "Ţĩćķēţ śũḿḿàŕŷ",
"presetPlaceholderTicketUrl": "ŨŔĹ ōƒ ţĥē Ĵĩŕà ţĩćķēţ",
"presetPromptPlaceholderHelp": "Ţŷƥē {{token}} ţō śēē àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś. <4>{{key}}</4>, <7>{{url}}</7>, <10>{{title}}</10>, àńď <13>{{description}}</13> àŕē śũƀśţĩţũţēď ŵĥēń ţĥē àćţĩōń ŕũńś.",
"promptFieldHelp": "Ţĥē ƥŕōḿƥţ śēńţ ţō ţĥē àĝēńţ ƒōŕ ēàćĥ ńēŵ ţĩćķēţ. Ţŷƥē {{token}} ţō ĩńśēŕţ ƥĺàćēĥōĺďēŕś.",
"remove": "Ŕēḿōvē",
"removeConfiguration": "Ŕēḿōvē ćōńƒĩĝũŕàţĩōń",
"removeJiraConfiguration": "Ŕēḿōvē Ĵĩŕà ćōńƒĩĝũŕàţĩōń?",
"resetCompleteDeletedTasks_one": "Ŕēśēţ ćōḿƥĺēţē — ďēĺēţēď {{count}} ţàśķ; ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
"resetCompleteDeletedTasks_other": "Ŕēśēţ ćōḿƥĺēţē — ďēĺēţēď {{count}} ţàśķś; ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
"resetCompleteNoTasksDeleted": "Ŕēśēţ ćōḿƥĺēţē — ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
"resetFailed": "Ŕēśēţ ƒàĩĺēď: {{error}}",
"resetWatch": "Ŕēśēţ ŵàţćĥ",
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
"savedLeaveBlank": "(śàvēď — ĺēàvē ƀĺàńķ ţō ķēēƥ ţĥē ćũŕŕēńţ vàĺũē)",
"saving": "Śàvĩńĝ…",
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
"selectWorkspace": "Śēĺēćţ ŵōŕķśƥàćē",
"selfHostedJiraServerOrDataCenter": "Śēĺƒ-ĥōśţēď Ĵĩŕà (Śēŕvēŕ ōŕ Ďàţà Ćēńţēŕ).",
"serverDataCenter": "Śēŕvēŕ / Ďàţà Ćēńţēŕ",
"sessionTokenValue": "Śēśśĩōń ţōķēń vàĺũē",
"showSessionTokenHelp": "Śĥōŵ ĥōŵ ţō ćōƥŷ ţĥē śēśśĩōń ţōķēń",
"siteUrl": "Śĩţē ŨŔĹ",
"sitesHostedAt": "Śĩţēś ĥōśţēď àţ {{domain}}.",
"taskPresets": "Ţàśķ ƥŕēśēţś",
"taskPresetsDescription": "Ƥŕōḿƥţś śĥōŵń ōń {{route}} ŵĥēń śţàŕţĩńĝ à ţàśķ ƒŕōḿ à ţĩćķēţ.",
"taskPresetsSaved": "Ţàśķ ƥŕēśēţś śàvēď",
"taskPrompt": "Ţàśķ Ƥŕōḿƥţ",
"tasksAreCreatedInThisWorkflow": "Ţàśķś àŕē ćŕēàţēď ĩń ţĥĩś ŵōŕķƒĺōŵ.",
"tasksCreatedByThisWatcherLand": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
"testConnection": "Ţēśţ ćōńńēćţĩōń",
"testFailed": "Ƒàĩĺēď: {{error}}",
"testJql": "Ţēśţ ĴQĹ",
"testingConnection": "Ţēśţĩńĝ...",
"testingJql": "Ţēśţĩńĝ…",
"update": "Ũƥďàţē",
"updateFailed": "Ũƥďàţē ƒàĩĺēď: {{error}}",
"watchDialogDescription": "Ƥōĺĺ à ĴQĹ qũēŕŷ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ţĩćķēţ. Ōƥţĩōńàĺĺŷ ƀĩńď à ŕēƥōśĩţōŕŷ śō ēàćĥ ţàśķ ŕũńś àĝàĩńśţ ţĥàţ ćōďēƀàśē, ōŕ ĺēàvē ĩţ ũńśēţ ţō ŕũń ŵĩţĥ ńō ŕēƥōśĩţōŕŷ.",
"watcherCreated": "Ŵàţćĥēŕ ćŕēàţēď",
"watcherDeleted": "Ŵàţćĥēŕ ďēĺēţēď",
"watcherUpdated": "Ŵàţćĥēŕ ũƥďàţēď",
"workflow": "Ŵōŕķƒĺōŵ",
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ"
}