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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Ŵōŕķƒĺōŵ Śţēƥ"
|
||||
}
|
||||
Reference in New Issue
Block a user