feat(i18n): localize Settings → Integrations → Linear (#2179)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,6 @@ import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@kandev/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { CliModeIcon } from "@/components/cli-mode-icon";
|
||||
@@ -25,36 +23,26 @@ import {
|
||||
ScriptEditor,
|
||||
computeEditorHeight,
|
||||
} from "@/components/settings/profile-edit/script-editor";
|
||||
import {
|
||||
LabelMultiSelect,
|
||||
PriorityMultiSelect,
|
||||
SettingsFields,
|
||||
StateMultiSelect,
|
||||
useTeamsAndStates,
|
||||
} from "./linear-issue-watch-fields";
|
||||
import { LINEAR_ISSUE_WATCH_PLACEHOLDERS } from "./linear-issue-watch-placeholders";
|
||||
import { SettingsFields } from "./linear-issue-watch-fields";
|
||||
import { FilterFields, SelectField } from "./linear-issue-watch-filter-fields";
|
||||
import { linearIssueWatchPlaceholders } from "./linear-issue-watch-placeholders";
|
||||
import { STEP_DEFAULT, STEP_DEFAULT_LABEL, resolveProfileId } from "@/lib/watcher-profile-default";
|
||||
import { WatcherRepositoryFields } from "@/components/watcher-repository-fields";
|
||||
import { clearWorkspaceScopedForm } from "@/lib/watcher-repository-default";
|
||||
import {
|
||||
ASSIGNED_ANY,
|
||||
CREATOR_ANY,
|
||||
type FormState,
|
||||
type LinearPriority,
|
||||
buildWatchPayload,
|
||||
creatorPlaceholder,
|
||||
formStateFromWatch,
|
||||
isWatchFormReady,
|
||||
makeEmptyForm,
|
||||
userOptionLabel,
|
||||
} from "./linear-issue-watch-form";
|
||||
import type {
|
||||
CreateLinearIssueWatchInput,
|
||||
LinearIssueWatch,
|
||||
LinearTeam,
|
||||
LinearUser,
|
||||
UpdateLinearIssueWatchInput,
|
||||
} from "@/lib/types/linear";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -82,254 +70,9 @@ function useFormData(workspaceId: string) {
|
||||
return { workflows, agentProfiles, allExecutorProfiles };
|
||||
}
|
||||
|
||||
type SelectFieldItem = { id: string; label: string; icon?: React.ReactNode };
|
||||
|
||||
function SelectField(props: {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
items: SelectFieldItem[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{props.label}</Label>
|
||||
{props.description && <p className="text-xs text-muted-foreground">{props.description}</p>}
|
||||
<Select
|
||||
value={props.value || undefined}
|
||||
onValueChange={props.onChange}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<SelectTrigger className="cursor-pointer">
|
||||
<SelectValue placeholder={props.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.items.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.icon ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>{item.label}</span>
|
||||
{item.icon}
|
||||
</span>
|
||||
) : (
|
||||
item.label
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterFields({
|
||||
form,
|
||||
setForm,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>;
|
||||
}) {
|
||||
const { teams, states, labels, users, loadingStates, loadingLabels, loadingUsers } =
|
||||
useTeamsAndStates(form.workspaceId, form.teamKey);
|
||||
const toggleState = useCallback(
|
||||
(id: string) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
stateIds: p.stateIds.includes(id)
|
||||
? p.stateIds.filter((s) => s !== id)
|
||||
: [...p.stateIds, id],
|
||||
})),
|
||||
[setForm],
|
||||
);
|
||||
const toggleLabel = useCallback(
|
||||
(id: string) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
labelIds: p.labelIds.includes(id)
|
||||
? p.labelIds.filter((l) => l !== id)
|
||||
: [...p.labelIds, id],
|
||||
})),
|
||||
[setForm],
|
||||
);
|
||||
const togglePriority = useCallback(
|
||||
(priority: LinearPriority) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
priorities: p.priorities.includes(priority)
|
||||
? p.priorities.filter((x) => x !== priority)
|
||||
: [...p.priorities, priority],
|
||||
})),
|
||||
[setForm],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TeamRow form={form} setForm={setForm} teams={teams} />
|
||||
<AssigneeAndCreatorRow
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
users={users}
|
||||
loadingUsers={loadingUsers}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Priority</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click to toggle. Matches issues at ANY of the selected priorities.
|
||||
</p>
|
||||
<PriorityMultiSelect selected={form.priorities} onToggle={togglePriority} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>States</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{form.teamKey
|
||||
? "Click states to toggle. Empty matches every state on the team."
|
||||
: "Pick a team to choose specific workflow states."}
|
||||
</p>
|
||||
<StateMultiSelect
|
||||
states={states}
|
||||
loading={loadingStates}
|
||||
selected={form.stateIds}
|
||||
onToggle={toggleState}
|
||||
disabled={!form.teamKey}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Labels</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{form.teamKey
|
||||
? "Click to toggle. Matches ANY of the selected labels."
|
||||
: "Pick a team to choose specific labels."}
|
||||
</p>
|
||||
<LabelMultiSelect
|
||||
labels={labels}
|
||||
loading={loadingLabels}
|
||||
selected={form.labelIds}
|
||||
onToggle={toggleLabel}
|
||||
disabled={!form.teamKey}
|
||||
/>
|
||||
</div>
|
||||
<EstimateRow form={form} setForm={setForm} />
|
||||
<QueryField form={form} setForm={setForm} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type FormSetter = React.Dispatch<React.SetStateAction<FormState>>;
|
||||
|
||||
function TeamRow({
|
||||
form,
|
||||
setForm,
|
||||
teams,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: FormSetter;
|
||||
teams: LinearTeam[];
|
||||
}) {
|
||||
return (
|
||||
<SelectField
|
||||
label="Team"
|
||||
description="Restrict matches to one team."
|
||||
value={form.teamKey}
|
||||
onChange={(v) =>
|
||||
setForm((p) => ({ ...p, teamKey: v, stateIds: [], labelIds: [], creatorId: "" }))
|
||||
}
|
||||
placeholder="(any team)"
|
||||
items={teams.map((t) => ({ id: t.key, label: `${t.name} (${t.key})` }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AssigneeAndCreatorRow({
|
||||
form,
|
||||
setForm,
|
||||
users,
|
||||
loadingUsers,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: FormSetter;
|
||||
users: LinearUser[];
|
||||
loadingUsers: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<SelectField
|
||||
label="Assignee"
|
||||
description="Filter by who an issue is assigned to."
|
||||
value={form.assigned || ASSIGNED_ANY}
|
||||
onChange={(v) => setForm((p) => ({ ...p, assigned: v === ASSIGNED_ANY ? "" : v }))}
|
||||
placeholder="(any)"
|
||||
items={[
|
||||
{ id: ASSIGNED_ANY, label: "(any)" },
|
||||
{ id: "me", label: "Me" },
|
||||
{ id: "unassigned", label: "Unassigned" },
|
||||
]}
|
||||
/>
|
||||
<SelectField
|
||||
label="Creator"
|
||||
description="Match issues created by one user."
|
||||
value={form.creatorId || CREATOR_ANY}
|
||||
onChange={(v) => setForm((p) => ({ ...p, creatorId: v === CREATOR_ANY ? "" : v }))}
|
||||
placeholder={creatorPlaceholder(form.teamKey, loadingUsers)}
|
||||
items={[
|
||||
{ id: CREATOR_ANY, label: "(any)" },
|
||||
...users.map((u) => ({ id: u.id, label: userOptionLabel(u) })),
|
||||
]}
|
||||
disabled={!form.teamKey || loadingUsers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EstimateRow({ form, setForm }: { form: FormState; setForm: FormSetter }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Estimate min</Label>
|
||||
<p className="text-xs text-muted-foreground">Lower bound in points (optional).</p>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.estimateMin}
|
||||
onChange={(e) => setForm((p) => ({ ...p, estimateMin: e.target.value }))}
|
||||
min={0}
|
||||
step="0.5"
|
||||
placeholder="e.g. 1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Estimate max</Label>
|
||||
<p className="text-xs text-muted-foreground">Upper bound in points (optional).</p>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.estimateMax}
|
||||
onChange={(e) => setForm((p) => ({ ...p, estimateMax: e.target.value }))}
|
||||
min={0}
|
||||
step="0.5"
|
||||
placeholder="e.g. 5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryField({ form, setForm }: { form: FormState; setForm: FormSetter }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Query</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Free-text match across title and description (optional).
|
||||
</p>
|
||||
<Input
|
||||
value={form.query}
|
||||
onChange={(e) => setForm((p) => ({ ...p, query: e.target.value }))}
|
||||
placeholder="auth bug"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceholdersHelp() {
|
||||
const { t } = useTranslation();
|
||||
const placeholders = useMemo(() => linearIssueWatchPlaceholders(t), [t]);
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -337,9 +80,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("linear:availablePlaceholders")}</p>
|
||||
<ul className="text-xs space-y-0.5">
|
||||
{LINEAR_ISSUE_WATCH_PLACEHOLDERS.map((p) => (
|
||||
{placeholders.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>
|
||||
@@ -353,14 +96,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(() => linearIssueWatchPlaceholders(t), [t]);
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Task Prompt</Label>
|
||||
<Label>{t("linear:taskPrompt")}</Label>
|
||||
<PlaceholdersHelp />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The prompt sent to the agent for each new issue. Type {"{{"} to insert placeholders.
|
||||
{/* The `{{` token is passed as a value so it never reaches the catalog,
|
||||
where i18next would interpolate it away. */}
|
||||
{t("linear:promptFieldHelp", { token: "{{" })}
|
||||
</p>
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<ScriptEditor
|
||||
@@ -369,7 +120,7 @@ function PromptField({ value, onChange }: { value: string; onChange: (v: string)
|
||||
language="markdown"
|
||||
height={computeEditorHeight(value)}
|
||||
lineNumbers="off"
|
||||
placeholders={LINEAR_ISSUE_WATCH_PLACEHOLDERS}
|
||||
placeholders={placeholders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,14 +136,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("linear:workspaceHelp")}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select workspace"
|
||||
placeholder={t("linear:selectWorkspace")}
|
||||
items={workspaces.map((w) => ({ id: w.id, label: w.name }))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@@ -406,22 +158,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("linear:workflow")}
|
||||
description={t("linear:workflowHelp")}
|
||||
value={form.workflowId}
|
||||
onChange={(v) => setForm((p) => ({ ...p, workflowId: v, workflowStepId: "" }))}
|
||||
placeholder="Select workflow"
|
||||
placeholder={t("linear:selectWorkflow")}
|
||||
items={workflows.map((w) => ({ id: w.id, label: w.name }))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Workflow Step"
|
||||
description="Initial step for new tasks."
|
||||
label={t("linear:workflowStep")}
|
||||
description={t("linear:workflowStepHelp")}
|
||||
value={form.workflowStepId}
|
||||
onChange={(v) => setForm((p) => ({ ...p, workflowStepId: v }))}
|
||||
placeholder={stepPlaceholder(form.workflowId, stepsLoading, steps.length)}
|
||||
@@ -440,8 +193,8 @@ function AutomationFields({
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<SelectField
|
||||
label="Agent Profile"
|
||||
description="Optional — falls back to step default."
|
||||
label={t("linear:agentProfile")}
|
||||
description={t("linear:fallsBackToStepDefault")}
|
||||
value={form.agentProfileId || STEP_DEFAULT}
|
||||
onChange={(v) => setForm((p) => ({ ...p, agentProfileId: resolveProfileId(v) }))}
|
||||
placeholder={STEP_DEFAULT_LABEL}
|
||||
@@ -455,8 +208,8 @@ function AutomationFields({
|
||||
]}
|
||||
/>
|
||||
<SelectField
|
||||
label="Executor Profile"
|
||||
description="Optional — falls back to step default."
|
||||
label={t("linear:executorProfile")}
|
||||
description={t("linear:fallsBackToStepDefault")}
|
||||
value={form.executorProfileId || STEP_DEFAULT}
|
||||
onChange={(v) => setForm((p) => ({ ...p, executorProfileId: resolveProfileId(v) }))}
|
||||
placeholder={STEP_DEFAULT_LABEL}
|
||||
@@ -470,9 +223,9 @@ function AutomationFields({
|
||||
);
|
||||
}
|
||||
|
||||
function savingLabel(saving: boolean, isEdit: boolean): string {
|
||||
if (saving) return "Saving…";
|
||||
return isEdit ? "Update" : "Create";
|
||||
function savingLabel(t: TFunction, saving: boolean, isEdit: boolean): string {
|
||||
if (saving) return t("linear:saving");
|
||||
return isEdit ? t("linear:update") : t("linear:create");
|
||||
}
|
||||
|
||||
export function LinearIssueWatchDialog({
|
||||
@@ -483,6 +236,7 @@ export function LinearIssueWatchDialog({
|
||||
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 ?? ""));
|
||||
@@ -520,12 +274,10 @@ export function LinearIssueWatchDialog({
|
||||
<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 Linear Watcher" : "Create Linear Watcher"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Poll Linear with a structured filter and auto-create a Kandev task for each
|
||||
newly-matching issue. Optionally bind a repository so each task runs against that
|
||||
codebase, or leave it unset to run with no repository.
|
||||
</DialogDescription>
|
||||
<DialogTitle>
|
||||
{watch ? t("linear:editLinearWatcher") : t("linear:createLinearWatcher")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("linear:watchDialogDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5">
|
||||
<WorkspacePicker
|
||||
@@ -551,10 +303,10 @@ export function LinearIssueWatchDialog({
|
||||
</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>
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
type LinearPriority,
|
||||
} from "./linear-issue-watch-form";
|
||||
import type { LinearLabel, LinearTeam, LinearUser, LinearWorkflowState } from "@/lib/types/linear";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resolveOptionLabel } from "@/lib/i18n/option-label";
|
||||
|
||||
// useTeamsAndStates loads the team list once Linear is configured, plus the
|
||||
// states, labels, and users for the currently-selected team. Each per-team
|
||||
@@ -128,16 +130,17 @@ export function StateMultiSelect({
|
||||
onToggle: (id: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (disabled) {
|
||||
// Caller's row description already explains the disabled state — render
|
||||
// nothing here to avoid duplicate prose.
|
||||
return null;
|
||||
}
|
||||
if (loading) {
|
||||
return <p className="text-xs text-muted-foreground">Loading states…</p>;
|
||||
return <p className="text-xs text-muted-foreground">{t("linear:loadingStates")}</p>;
|
||||
}
|
||||
if (states.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground">No workflow states available.</p>;
|
||||
return <p className="text-xs text-muted-foreground">{t("linear:noWorkflowStatesAvailable")}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -151,6 +154,7 @@ export function StateMultiSelect({
|
||||
aria-pressed={active}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{/* Workflow state names come from the Linear API — user data. */}
|
||||
<Badge variant={active ? "default" : "outline"}>{s.name}</Badge>
|
||||
</button>
|
||||
);
|
||||
@@ -166,6 +170,7 @@ export function PriorityMultiSelect({
|
||||
selected: LinearPriority[];
|
||||
onToggle: (p: LinearPriority) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PRIORITY_OPTIONS.map((opt) => {
|
||||
@@ -178,7 +183,7 @@ export function PriorityMultiSelect({
|
||||
aria-pressed={active}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge variant={active ? "default" : "outline"}>{opt.label}</Badge>
|
||||
<Badge variant={active ? "default" : "outline"}>{resolveOptionLabel(t, opt)}</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -199,16 +204,19 @@ export function LabelMultiSelect({
|
||||
onToggle: (id: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (disabled) {
|
||||
// Caller's row description already explains the disabled state — render
|
||||
// nothing here to avoid duplicate prose.
|
||||
return null;
|
||||
}
|
||||
if (loading) {
|
||||
return <p className="text-xs text-muted-foreground">Loading labels…</p>;
|
||||
return <p className="text-xs text-muted-foreground">{t("linear:loadingLabels")}</p>;
|
||||
}
|
||||
if (labels.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground">No labels available for this team.</p>;
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">{t("linear:noLabelsAvailableForThisTeam")}</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -222,6 +230,7 @@ export function LabelMultiSelect({
|
||||
aria-pressed={active}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{/* Label names come from the Linear API — user data. */}
|
||||
<Badge variant={active ? "default" : "outline"}>{l.name}</Badge>
|
||||
</button>
|
||||
);
|
||||
@@ -238,13 +247,11 @@ type FormSetter = Dispatch<SetStateAction<FormState>>;
|
||||
const SORT_BY_DEFAULT_SENTINEL = "__default__";
|
||||
|
||||
export function SortByField({ form, setForm }: { form: FormState; setForm: FormSetter }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Dispatch order</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When the in-flight cap is reached, issues are dispatched in this order so the most important
|
||||
ones run first.
|
||||
</p>
|
||||
<Label>{t("linear:dispatchOrder")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("linear:dispatchOrderHelp")}</p>
|
||||
<Select
|
||||
value={form.sortBy || SORT_BY_DEFAULT_SENTINEL}
|
||||
onValueChange={(v) =>
|
||||
@@ -263,7 +270,7 @@ export function SortByField({ form, setForm }: { form: FormState; setForm: FormS
|
||||
key={o.value || SORT_BY_DEFAULT_SENTINEL}
|
||||
value={o.value || SORT_BY_DEFAULT_SENTINEL}
|
||||
>
|
||||
{o.label}
|
||||
{resolveOptionLabel(t, o)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -276,26 +283,24 @@ export function SortByField({ form, setForm }: { form: FormState; setForm: FormS
|
||||
// validation. Lives here (rather than the dialog) to keep the dialog file under
|
||||
// its line ceiling.
|
||||
export function MaxInflightTasksField({ form, setForm }: { form: FormState; setForm: FormSetter }) {
|
||||
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("linear:maxInflightTasks")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("linear:maxInflightTasksHelp")}</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("linear: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("linear:enterAPositiveIntegerOrBlank")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -304,13 +309,12 @@ export function MaxInflightTasksField({ form, setForm }: { form: FormState; setF
|
||||
// SettingsFields renders the poll-interval, throttle-cap, and enabled toggle —
|
||||
// the trailing "Settings" block of the watcher dialog.
|
||||
export function SettingsFields({ form, setForm }: { form: FormState; setForm: FormSetter }) {
|
||||
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 search. Minimum 60s, maximum 3600s.
|
||||
</p>
|
||||
<Label>{t("linear:pollIntervalSeconds")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("linear:pollIntervalHelp")}</p>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.pollInterval}
|
||||
@@ -323,8 +327,8 @@ export function SettingsFields({ form, setForm }: { form: FormState; setForm: Fo
|
||||
<SortByField 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("linear:enabled")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("linear:enabledHelp")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LabelMultiSelect,
|
||||
PriorityMultiSelect,
|
||||
StateMultiSelect,
|
||||
useTeamsAndStates,
|
||||
} from "./linear-issue-watch-fields";
|
||||
import {
|
||||
ASSIGNED_ANY,
|
||||
CREATOR_ANY,
|
||||
type FormState,
|
||||
type LinearPriority,
|
||||
creatorPlaceholder,
|
||||
userOptionLabel,
|
||||
} from "./linear-issue-watch-form";
|
||||
import type { LinearTeam, LinearUser } from "@/lib/types/linear";
|
||||
|
||||
// The filter half of the watcher dialog — the "which issues match?" block.
|
||||
// Split out of linear-issue-watch-dialog.tsx so that file stays under the
|
||||
// 600-line ceiling once every string routes through `t()`.
|
||||
|
||||
export type SelectFieldItem = { id: string; label: string; icon?: React.ReactNode };
|
||||
|
||||
export function SelectField(props: {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
items: SelectFieldItem[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{props.label}</Label>
|
||||
{props.description && <p className="text-xs text-muted-foreground">{props.description}</p>}
|
||||
<Select
|
||||
value={props.value || undefined}
|
||||
onValueChange={props.onChange}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<SelectTrigger className="cursor-pointer">
|
||||
<SelectValue placeholder={props.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.items.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.icon ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>{item.label}</span>
|
||||
{item.icon}
|
||||
</span>
|
||||
) : (
|
||||
item.label
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FormSetter = React.Dispatch<React.SetStateAction<FormState>>;
|
||||
|
||||
export function FilterFields({ form, setForm }: { form: FormState; setForm: FormSetter }) {
|
||||
const { t } = useTranslation();
|
||||
const { teams, states, labels, users, loadingStates, loadingLabels, loadingUsers } =
|
||||
useTeamsAndStates(form.workspaceId, form.teamKey);
|
||||
const toggleState = useCallback(
|
||||
(id: string) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
stateIds: p.stateIds.includes(id)
|
||||
? p.stateIds.filter((s) => s !== id)
|
||||
: [...p.stateIds, id],
|
||||
})),
|
||||
[setForm],
|
||||
);
|
||||
const toggleLabel = useCallback(
|
||||
(id: string) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
labelIds: p.labelIds.includes(id)
|
||||
? p.labelIds.filter((l) => l !== id)
|
||||
: [...p.labelIds, id],
|
||||
})),
|
||||
[setForm],
|
||||
);
|
||||
const togglePriority = useCallback(
|
||||
(priority: LinearPriority) =>
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
priorities: p.priorities.includes(priority)
|
||||
? p.priorities.filter((x) => x !== priority)
|
||||
: [...p.priorities, priority],
|
||||
})),
|
||||
[setForm],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TeamRow form={form} setForm={setForm} teams={teams} />
|
||||
<AssigneeAndCreatorRow
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
users={users}
|
||||
loadingUsers={loadingUsers}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("linear:priority")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("linear:priorityHelp")}</p>
|
||||
<PriorityMultiSelect selected={form.priorities} onToggle={togglePriority} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("linear:states")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{form.teamKey ? t("linear:statesHelp") : t("linear:statesHelpNoTeam")}
|
||||
</p>
|
||||
<StateMultiSelect
|
||||
states={states}
|
||||
loading={loadingStates}
|
||||
selected={form.stateIds}
|
||||
onToggle={toggleState}
|
||||
disabled={!form.teamKey}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("linear:labels")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{form.teamKey ? t("linear:labelsHelp") : t("linear:labelsHelpNoTeam")}
|
||||
</p>
|
||||
<LabelMultiSelect
|
||||
labels={labels}
|
||||
loading={loadingLabels}
|
||||
selected={form.labelIds}
|
||||
onToggle={toggleLabel}
|
||||
disabled={!form.teamKey}
|
||||
/>
|
||||
</div>
|
||||
<EstimateRow form={form} setForm={setForm} />
|
||||
<QueryField form={form} setForm={setForm} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamRow({
|
||||
form,
|
||||
setForm,
|
||||
teams,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: FormSetter;
|
||||
teams: LinearTeam[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SelectField
|
||||
label={t("linear:team")}
|
||||
description={t("linear:teamHelp")}
|
||||
value={form.teamKey}
|
||||
onChange={(v) =>
|
||||
setForm((p) => ({ ...p, teamKey: v, stateIds: [], labelIds: [], creatorId: "" }))
|
||||
}
|
||||
placeholder={t("linear:anyTeam")}
|
||||
// Team names and keys come from the Linear API — user data, never
|
||||
// translated, so they travel as a plain `label`.
|
||||
items={teams.map((team) => ({ id: team.key, label: `${team.name} (${team.key})` }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AssigneeAndCreatorRow({
|
||||
form,
|
||||
setForm,
|
||||
users,
|
||||
loadingUsers,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: FormSetter;
|
||||
users: LinearUser[];
|
||||
loadingUsers: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<SelectField
|
||||
label={t("linear:assignee")}
|
||||
description={t("linear:assigneeHelp")}
|
||||
value={form.assigned || ASSIGNED_ANY}
|
||||
onChange={(v) => setForm((p) => ({ ...p, assigned: v === ASSIGNED_ANY ? "" : v }))}
|
||||
placeholder={t("linear:any")}
|
||||
// `id` values are sentinels the filter payload is built from — "me" and
|
||||
// "unassigned" are sent to the backend verbatim and must never be
|
||||
// translated. Only the labels are copy.
|
||||
items={[
|
||||
{ id: ASSIGNED_ANY, label: t("linear:any") },
|
||||
{ id: "me", label: t("linear:me") },
|
||||
{ id: "unassigned", label: t("linear:unassigned") },
|
||||
]}
|
||||
/>
|
||||
<SelectField
|
||||
label={t("linear:creator")}
|
||||
description={t("linear:creatorHelp")}
|
||||
value={form.creatorId || CREATOR_ANY}
|
||||
onChange={(v) => setForm((p) => ({ ...p, creatorId: v === CREATOR_ANY ? "" : v }))}
|
||||
placeholder={creatorPlaceholder(t, form.teamKey, loadingUsers)}
|
||||
items={[
|
||||
{ id: CREATOR_ANY, label: t("linear:any") },
|
||||
...users.map((u) => ({ id: u.id, label: userOptionLabel(u) })),
|
||||
]}
|
||||
disabled={!form.teamKey || loadingUsers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EstimateRow({ form, setForm }: { form: FormState; setForm: FormSetter }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("linear:estimateMin")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("linear:estimateMinHelp")}</p>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.estimateMin}
|
||||
onChange={(e) => setForm((p) => ({ ...p, estimateMin: e.target.value }))}
|
||||
min={0}
|
||||
step="0.5"
|
||||
placeholder={t("linear:estimateMinPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("linear:estimateMax")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("linear:estimateMaxHelp")}</p>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.estimateMax}
|
||||
onChange={(e) => setForm((p) => ({ ...p, estimateMax: e.target.value }))}
|
||||
min={0}
|
||||
step="0.5"
|
||||
placeholder={t("linear:estimateMaxPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryField({ form, setForm }: { form: FormState; setForm: FormSetter }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("linear:query")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("linear:queryHelp")}</p>
|
||||
<Input
|
||||
value={form.query}
|
||||
onChange={(e) => setForm((p) => ({ ...p, query: e.target.value }))}
|
||||
placeholder={t("linear:queryPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,25 +13,34 @@ export type LinearPriority = 0 | 1 | 2 | 3 | 4;
|
||||
|
||||
// Linear priorities: 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low. Rendered as
|
||||
// toggle chips, mirroring the States and Labels multi-selects.
|
||||
export const PRIORITY_OPTIONS: { value: LinearPriority; label: string }[] = [
|
||||
{ value: 1, label: "Urgent" },
|
||||
{ value: 2, label: "High" },
|
||||
{ value: 3, label: "Medium" },
|
||||
{ value: 4, label: "Low" },
|
||||
{ value: 0, label: "No priority" },
|
||||
//
|
||||
// `value` is a numeric union sent to the backend as the filter's priority list
|
||||
// — never translated. The chip text travels as `labelKey` and resolves at
|
||||
// render through `resolveOptionLabel`; a module-scope `t()` here would freeze
|
||||
// the copy at the boot locale (see docs/i18n.md).
|
||||
export const PRIORITY_OPTIONS: { value: LinearPriority; labelKey: string }[] = [
|
||||
{ value: 1, labelKey: "linear:priorityUrgent" },
|
||||
{ value: 2, labelKey: "linear:priorityHigh" },
|
||||
{ value: 3, labelKey: "linear:priorityMedium" },
|
||||
{ value: 4, labelKey: "linear:priorityLow" },
|
||||
{ value: 0, labelKey: "linear:priorityNone" },
|
||||
];
|
||||
|
||||
// Dispatch order applied when the in-flight cap limits how many matched issues
|
||||
// run at once. Order matters — most useful first; the empty value is Linear's
|
||||
// natural (recently-updated) order.
|
||||
export const SORT_BY_OPTIONS: { value: LinearIssueSortBy; label: string }[] = [
|
||||
{ value: "priority", label: "Priority (high → low)" },
|
||||
{ value: "priority_asc", label: "Priority (low → high)" },
|
||||
{ value: "created_desc", label: "Created (newest first)" },
|
||||
{ value: "created_asc", label: "Created (oldest first)" },
|
||||
{ value: "updated_desc", label: "Updated (recently updated first)" },
|
||||
{ value: "updated_asc", label: "Updated (least recently updated first)" },
|
||||
{ value: "", label: "Default (Linear order)" },
|
||||
//
|
||||
// `value` is a `LinearIssueSortBy` string-literal union: persisted on the
|
||||
// watch, compared against `form.sortBy`, and sent upstream. Only `labelKey` is
|
||||
// copy.
|
||||
export const SORT_BY_OPTIONS: { value: LinearIssueSortBy; labelKey: string }[] = [
|
||||
{ value: "priority", labelKey: "linear:sortPriorityDesc" },
|
||||
{ value: "priority_asc", labelKey: "linear:sortPriorityAsc" },
|
||||
{ value: "created_desc", labelKey: "linear:sortCreatedDesc" },
|
||||
{ value: "created_asc", labelKey: "linear:sortCreatedAsc" },
|
||||
{ value: "updated_desc", labelKey: "linear:sortUpdatedDesc" },
|
||||
{ value: "updated_asc", labelKey: "linear:sortUpdatedAsc" },
|
||||
{ value: "", labelKey: "linear:sortDefault" },
|
||||
];
|
||||
|
||||
export interface FormState {
|
||||
@@ -243,14 +252,27 @@ export function buildFilterPayload(form: FormState): LinearSearchFilter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Display text for one Linear user in the Creator picker. Every branch is user
|
||||
* data straight from the Linear API — a display name, a full name, an email
|
||||
* address, or the raw id — so nothing here is translatable copy.
|
||||
*/
|
||||
export function userOptionLabel(u: LinearUser): string {
|
||||
const name = u.displayName?.trim() || u.name?.trim() || u.email?.trim() || u.id;
|
||||
if (u.email && u.email !== name) return `${name} (${u.email})`;
|
||||
return name;
|
||||
}
|
||||
|
||||
export function creatorPlaceholder(teamKey: string, loadingUsers: boolean): string {
|
||||
if (loadingUsers) return "Loading…";
|
||||
if (!teamKey) return "Pick a team first";
|
||||
return "(any)";
|
||||
// `t` is threaded in rather than read from a hook: this is a plain function in a
|
||||
// `.ts` module, and `mode: "jsx-only"` means the guard never inspects this file,
|
||||
// so a literal here would survive lint. The caller passes its `useTranslation`
|
||||
// `t`, which keeps the copy resolving at render.
|
||||
export function creatorPlaceholder(
|
||||
t: (key: string) => string,
|
||||
teamKey: string,
|
||||
loadingUsers: boolean,
|
||||
): string {
|
||||
if (loadingUsers) return t("linear:loadingEllipsis");
|
||||
if (!teamKey) return t("linear:pickATeamFirst");
|
||||
return t("linear:any");
|
||||
}
|
||||
|
||||
@@ -1,67 +1,91 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
|
||||
|
||||
export const LINEAR_ISSUE_WATCH_PLACEHOLDERS: ScriptPlaceholder[] = [
|
||||
{
|
||||
key: "issue.url",
|
||||
description: "Linear issue URL",
|
||||
example: "https://linear.app/acme/issue/ENG-7",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.identifier",
|
||||
description: "Issue identifier",
|
||||
example: "ENG-7",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.title",
|
||||
description: "Issue title",
|
||||
example: "Login fails on mobile",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.team",
|
||||
description: "Team key",
|
||||
example: "ENG",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.state",
|
||||
description: "Workflow state name",
|
||||
example: "In Progress",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.priority",
|
||||
description: "Priority label",
|
||||
example: "High",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.assignee",
|
||||
description: "Assignee display name",
|
||||
example: "Alice",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.creator",
|
||||
description: "Issue creator display name",
|
||||
example: "Bob",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.description",
|
||||
description: "Issue description body",
|
||||
example: "Tap submit, nothing happens.",
|
||||
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 Linear data: an issue
|
||||
* identifier, a team key, a workflow state name, a priority label, a display
|
||||
* name. Both are identifiers, so they stay out of the catalog where the
|
||||
* pseudo-locale would transliterate them. Same contract as
|
||||
* `jiraIssueWatchPlaceholders` in components/jira.
|
||||
*/
|
||||
export function linearIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
|
||||
return [
|
||||
{
|
||||
key: "issue.url",
|
||||
description: t("linear:placeholderIssueUrl"),
|
||||
example: "https://linear.app/acme/issue/ENG-7",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.identifier",
|
||||
description: t("linear:placeholderIssueIdentifier"),
|
||||
example: "ENG-7",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.title",
|
||||
description: t("linear:placeholderIssueTitle"),
|
||||
example: "Login fails on mobile",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.team",
|
||||
description: t("linear:placeholderTeamKey"),
|
||||
example: "ENG",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.state",
|
||||
description: t("linear:placeholderWorkflowStateName"),
|
||||
example: "In Progress",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.priority",
|
||||
description: t("linear:placeholderPriorityLabel"),
|
||||
example: "High",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.assignee",
|
||||
description: t("linear:placeholderAssigneeDisplayName"),
|
||||
example: "Alice",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.creator",
|
||||
description: t("linear:placeholderCreatorDisplayName"),
|
||||
example: "Bob",
|
||||
executor_types: [],
|
||||
},
|
||||
{
|
||||
key: "issue.description",
|
||||
description: t("linear:placeholderIssueDescription"),
|
||||
example: "Tap submit, nothing happens.",
|
||||
executor_types: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// DEFAULT_LINEAR_ISSUE_WATCH_PROMPT mirrors apps/backend/config/prompts/linear-issue-watch-default.md.
|
||||
// Kept in sync by hand: the UI shows this when the user clears the field, and
|
||||
// the backend reads the .md when the saved prompt is empty. Diverging would
|
||||
// surprise the user — they'd see one default in the dialog and another get
|
||||
// sent to the agent.
|
||||
//
|
||||
// Deliberately NOT translated, for the same reason as
|
||||
// DEFAULT_JIRA_ISSUE_WATCH_PROMPT: it is seeded into the field, then persisted
|
||||
// on the watch and sent to the agent verbatim. 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 — it also has to keep matching the backend
|
||||
// .md, which has no locale at all.
|
||||
export const DEFAULT_LINEAR_ISSUE_WATCH_PROMPT = `You have been assigned a Linear issue to work on.
|
||||
|
||||
**Issue:** {{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 { LinearIssueWatch, LinearSearchFilter } from "@/lib/types/linear";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { formatRelative } from "@/lib/i18n/formats";
|
||||
|
||||
type LinearIssueWatchTableProps = {
|
||||
watches: LinearIssueWatch[];
|
||||
@@ -27,21 +30,29 @@ type LinearIssueWatchTableProps = {
|
||||
onToggleEnabled: (watch: LinearIssueWatch) => 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 and matching the Jira table: 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("linear:never");
|
||||
return formatRelative(dateStr);
|
||||
}
|
||||
|
||||
// summarizeFilter renders the structured filter as a short tag-style label
|
||||
// the user can scan at a glance. Falls back to "(any)" when somehow empty —
|
||||
// the backend rejects empty filters at create/update time, so this is just
|
||||
// defensive for forward-compat.
|
||||
//
|
||||
// Deliberately NOT translated. This is a filter *expression*, not prose: the
|
||||
// `team:` / `state:` / `assigned:` / `q:` prefixes are Linear filter field
|
||||
// names, the values are user data (a team key, a free-text query), and it is
|
||||
// rendered in a monospace column. It is the direct analogue of the Jira watch
|
||||
// table's column, which shows the raw JQL verbatim for the same reason.
|
||||
function summarizeFilter(filter: LinearSearchFilter | undefined): string {
|
||||
if (!filter) return "(any)";
|
||||
const parts: string[] = [];
|
||||
@@ -69,6 +80,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>
|
||||
@@ -91,7 +103,7 @@ function WatchActions({
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{watch.enabled ? "Pause" : "Enable"}</TooltipContent>
|
||||
<TooltipContent>{watch.enabled ? t("linear:pause") : t("linear:enable")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -107,7 +119,7 @@ function WatchActions({
|
||||
<IconRefresh className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Check now</TooltipContent>
|
||||
<TooltipContent>{t("linear:checkNow")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -116,7 +128,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("linear:resetWatch")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReset(watch.id);
|
||||
@@ -125,7 +137,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>
|
||||
@@ -141,7 +153,7 @@ function WatchActions({
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
<TooltipContent>{t("linear:delete")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
@@ -157,13 +169,14 @@ export function LinearIssueWatchTable({
|
||||
onReset,
|
||||
onToggleEnabled,
|
||||
}: LinearIssueWatchTableProps) {
|
||||
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 Linear watchers configured. Create one to auto-create tasks from filtered issues.
|
||||
{t("linear:noLinearWatchersConfiguredCreateOne")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -172,12 +185,12 @@ export function LinearIssueWatchTable({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{showWorkspace && <TableHead>Workspace</TableHead>}
|
||||
<TableHead>Filter</TableHead>
|
||||
<TableHead>Interval</TableHead>
|
||||
<TableHead>Last Polled</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
{showWorkspace && <TableHead>{t("common:workspace")}</TableHead>}
|
||||
<TableHead>{t("linear:filter")}</TableHead>
|
||||
<TableHead>{t("linear:interval")}</TableHead>
|
||||
<TableHead>{t("linear:lastPolled")}</TableHead>
|
||||
<TableHead>{t("common:status")}</TableHead>
|
||||
<TableHead className="text-right">{t("linear:actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -202,14 +215,16 @@ export function LinearIssueWatchTable({
|
||||
{summarizeFilter(watch.filter)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{Math.round(watch.pollIntervalSeconds / 60)}m
|
||||
{t("linear: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("linear:active") : t("linear:paused")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ResetWatchDialog, useWatchResetController } from "@/components/watches/
|
||||
import { LinearIssueWatchTable } from "./linear-issue-watch-table";
|
||||
import { LinearIssueWatchDialog } from "./linear-issue-watch-dialog";
|
||||
import type { LinearIssueWatch } from "@/lib/types/linear";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// LinearIssueWatchersSection lists watches across every workspace in a single
|
||||
// flat table on the install-wide settings page. The dialog's create flow asks
|
||||
@@ -26,45 +27,46 @@ type RawActions = {
|
||||
};
|
||||
|
||||
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("linear:watcherCreated"), variant: "success" });
|
||||
} catch (err) {
|
||||
toast({ description: `Create failed: ${String(err)}`, variant: "error" });
|
||||
toast({ description: t("linear: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("linear:watcherUpdated"), variant: "success" });
|
||||
} catch (err) {
|
||||
toast({ description: `Update failed: ${String(err)}`, variant: "error" });
|
||||
toast({ description: t("linear:updateFailed", { error: String(err) }), variant: "error" });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[update, toast],
|
||||
[update, t, toast],
|
||||
);
|
||||
|
||||
const wrappedDelete = useCallback(
|
||||
async (w: LinearIssueWatch) => {
|
||||
if (!confirm("Delete this Linear watcher?")) return;
|
||||
if (!confirm(t("linear:deleteThisLinearWatcher"))) return;
|
||||
try {
|
||||
await remove(w.id, w.workspaceId);
|
||||
toast({ description: "Watcher deleted", variant: "success" });
|
||||
toast({ description: t("linear:watcherDeleted"), variant: "success" });
|
||||
} catch (err) {
|
||||
toast({ description: `Delete failed: ${String(err)}`, variant: "error" });
|
||||
toast({ description: t("linear:deleteFailed", { error: String(err) }), variant: "error" });
|
||||
}
|
||||
},
|
||||
[remove, toast],
|
||||
[remove, t, toast],
|
||||
);
|
||||
|
||||
const wrappedTrigger = useCallback(
|
||||
@@ -72,14 +74,16 @@ function useToastedActions({ create, update, remove, trigger, reset }: RawAction
|
||||
try {
|
||||
const res = await trigger(w.id, w.workspaceId);
|
||||
const n = res?.newIssues ?? 0;
|
||||
// "issue(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 issue(s) — tasks will appear shortly.` : "No new issues matched.";
|
||||
n > 0 ? t("linear:foundNewIssues", { count: n }) : t("linear:noNewIssuesMatched");
|
||||
toast({ description, variant: "success" });
|
||||
} catch (err) {
|
||||
toast({ description: `Check failed: ${String(err)}`, variant: "error" });
|
||||
toast({ description: t("linear:checkFailed", { error: String(err) }), variant: "error" });
|
||||
}
|
||||
},
|
||||
[trigger, toast],
|
||||
[trigger, t, toast],
|
||||
);
|
||||
|
||||
const wrappedReset = useCallback(
|
||||
@@ -90,16 +94,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("linear:resetCompleteDeletedTasks", { count: n })
|
||||
: t("linear:resetCompleteNoTasksDeleted"),
|
||||
variant: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({ description: `Reset failed: ${String(err)}`, variant: "error" });
|
||||
toast({ description: t("linear:resetFailed", { error: String(err) }), variant: "error" });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[reset, toast],
|
||||
[reset, t, toast],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -122,6 +126,7 @@ function useEnabledDrafts(items: LinearIssueWatch[], update: RawActions["update"
|
||||
}
|
||||
|
||||
export function LinearIssueWatchersSection() {
|
||||
const { t } = useTranslation();
|
||||
const { items, loading, create, update, remove, trigger, previewReset, reset } =
|
||||
useLinearIssueWatches();
|
||||
const actions = useToastedActions({ create, update, remove, trigger, reset });
|
||||
@@ -175,12 +180,12 @@ export function LinearIssueWatchersSection() {
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={<IconBellRinging className="h-5 w-5" />}
|
||||
title="Linear watchers"
|
||||
description="Poll a Linear filter and auto-create a Kandev task for each newly-matching issue."
|
||||
title={t("linear:linearWatchers")}
|
||||
description={t("linear:linearWatchersDescription")}
|
||||
action={
|
||||
<Button size="sm" onClick={openCreate} className="cursor-pointer">
|
||||
<IconPlus className="h-4 w-4 mr-1" />
|
||||
New watcher
|
||||
{t("linear:newWatcher")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
@@ -216,7 +221,7 @@ export function LinearIssueWatchersSection() {
|
||||
<ResetWatchDialog
|
||||
open
|
||||
onOpenChange={resetCtrl.onOpenChange}
|
||||
integrationLabel="Linear watcher"
|
||||
integrationLabel={t("linear:linearWatcher")}
|
||||
previewLoader={resetCtrl.previewLoader}
|
||||
onConfirm={resetCtrl.confirmReset}
|
||||
/>
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
listLinearTeams,
|
||||
} from "@/lib/api/domains/linear-api";
|
||||
import type { LinearConfig, LinearTeam, TestLinearConnectionResult } from "@/lib/types/linear";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { LinearIssueWatchersSection } from "./linear-issue-watchers-section";
|
||||
|
||||
type FormState = {
|
||||
@@ -38,6 +40,16 @@ type FormState = {
|
||||
|
||||
const emptyForm: FormState = { defaultTeamKey: "", secret: "" };
|
||||
|
||||
// Not copy. The mask is a glyph run, `lin_api_...` is the literal shape of a
|
||||
// Linear personal API key, and the link is a Linear-owned URL — its visible
|
||||
// text is the same URL with the scheme trimmed. All four would be corrupted by
|
||||
// the pseudo-locale into something the user cannot match against Linear's own
|
||||
// UI, so they stay out of the catalog.
|
||||
const SECRET_MASK = "••••••••";
|
||||
const SECRET_EXAMPLE = "lin_api_...";
|
||||
const API_KEY_SETTINGS_URL = "https://linear.app/settings/account/security";
|
||||
const API_KEY_SETTINGS_LABEL = "linear.app/settings/account/security";
|
||||
|
||||
function configToForm(cfg: LinearConfig | null): FormState {
|
||||
if (!cfg) return emptyForm;
|
||||
return { defaultTeamKey: cfg.defaultTeamKey, secret: "" };
|
||||
@@ -60,35 +72,34 @@ function SecretField({
|
||||
update,
|
||||
hasSavedSecret,
|
||||
}: Omit<FieldsRowProps, "teams" | "loadingTeams">) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="linear-secret">
|
||||
API key
|
||||
{t("linear:apiKey")}
|
||||
{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("linear:savedLeaveBlank")}</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="linear-secret"
|
||||
data-testid="linear-secret-input"
|
||||
type="password"
|
||||
placeholder={hasSavedSecret ? "••••••••" : "lin_api_..."}
|
||||
placeholder={hasSavedSecret ? SECRET_MASK : SECRET_EXAMPLE}
|
||||
value={form.secret}
|
||||
data-settings-dirty={form.secret !== baseline.secret}
|
||||
onChange={(e) => update("secret", e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a personal API key at{" "}
|
||||
{t("linear:createPersonalApiKeyAt")}{" "}
|
||||
<a
|
||||
className="underline cursor-pointer"
|
||||
href="https://linear.app/settings/account/security"
|
||||
href={API_KEY_SETTINGS_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
linear.app/settings/account/security
|
||||
{API_KEY_SETTINGS_LABEL}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -96,9 +107,10 @@ function SecretField({
|
||||
}
|
||||
|
||||
function TeamSelector({ form, baseline, loading, update, teams, loadingTeams }: FieldsRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="linear-team">Default team (optional)</Label>
|
||||
<Label htmlFor="linear-team">{t("linear:defaultTeamOptional")}</Label>
|
||||
<Select
|
||||
value={form.defaultTeamKey || "__none__"}
|
||||
onValueChange={(v) => update("defaultTeamKey", v === "__none__" ? "" : v)}
|
||||
@@ -109,13 +121,17 @@ function TeamSelector({ form, baseline, loading, update, teams, loadingTeams }:
|
||||
className="w-full"
|
||||
data-settings-dirty={form.defaultTeamKey !== baseline.defaultTeamKey}
|
||||
>
|
||||
<SelectValue placeholder={loadingTeams ? "Loading teams…" : "Choose a team"} />
|
||||
<SelectValue
|
||||
placeholder={loadingTeams ? t("linear:loadingTeams") : t("linear:chooseATeam")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No default</SelectItem>
|
||||
{teams.map((t) => (
|
||||
<SelectItem key={t.id} value={t.key}>
|
||||
{t.name} ({t.key})
|
||||
<SelectItem value="__none__">{t("linear:noDefault")}</SelectItem>
|
||||
{/* Renamed from `t` so it cannot shadow the translation function
|
||||
above; team names and keys are Linear API data, not copy. */}
|
||||
{teams.map((team) => (
|
||||
<SelectItem key={team.id} value={team.key}>
|
||||
{team.name} ({team.key})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -124,15 +140,30 @@ function TeamSelector({ form, baseline, loading, update, teams, loadingTeams }:
|
||||
);
|
||||
}
|
||||
|
||||
// The identity and the org name are Linear API data, so they are interpolated
|
||||
// rather than written into the catalog — a locale (pseudo included) must not
|
||||
// rewrite the account the user is checking they connected as.
|
||||
//
|
||||
// Every field on the result is optional, so each interpolated value needs a
|
||||
// fallback: i18next leaves an unmatched placeholder in place, so a partial
|
||||
// response would render the literal "{{name}}" to the user rather than the
|
||||
// old template's "undefined".
|
||||
function testResultMessage(t: TFunction, result: TestLinearConnectionResult): string {
|
||||
if (!result.ok) {
|
||||
return t("linear:testFailed", { error: result.error || t("linear:unknownError") });
|
||||
}
|
||||
const name = result.displayName || result.email || result.userId || t("linear:unknownAccount");
|
||||
return result.orgName
|
||||
? t("linear:connectedAsWithOrg", { name, org: result.orgName })
|
||||
: t("linear:connectedAs", { name });
|
||||
}
|
||||
|
||||
function TestResultAlert({ result }: { result: TestLinearConnectionResult | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!result) return null;
|
||||
return (
|
||||
<Alert variant={result.ok ? "default" : "destructive"}>
|
||||
<AlertDescription>
|
||||
{result.ok
|
||||
? `Connected as ${result.displayName || result.email || result.userId}${result.orgName ? ` (${result.orgName})` : ""}`
|
||||
: `Failed: ${result.error}`}
|
||||
</AlertDescription>
|
||||
<AlertDescription>{testResultMessage(t, result)}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -157,6 +188,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
|
||||
@@ -165,10 +197,10 @@ function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete
|
||||
onClick={onTest}
|
||||
disabled={testing || loading || disableTest}
|
||||
className="cursor-pointer"
|
||||
title={disableTest ? "Paste an API key to test the connection" : undefined}
|
||||
title={disableTest ? t("linear:pasteAnApiKeyToTest") : undefined}
|
||||
data-testid="linear-test-button"
|
||||
>
|
||||
{testing ? "Testing..." : "Test connection"}
|
||||
{testing ? t("linear:testing") : t("linear:testConnection")}
|
||||
</Button>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
@@ -178,7 +210,7 @@ function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete
|
||||
className="ml-auto cursor-pointer"
|
||||
data-testid="linear-delete-button"
|
||||
>
|
||||
Remove configuration
|
||||
{t("linear:removeConfiguration")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -202,6 +234,7 @@ function useSettingsActions({
|
||||
setForm,
|
||||
setTestResult,
|
||||
}: SettingsActionsArgs) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
@@ -243,28 +276,28 @@ function useSettingsActions({
|
||||
JSON.stringify(current) === JSON.stringify(submitted) ? configToForm(saved) : current,
|
||||
);
|
||||
setTestResult(null);
|
||||
toast({ description: "Linear configuration saved", variant: "success" });
|
||||
toast({ description: t("linear:configurationSaved"), variant: "success" });
|
||||
} catch (err) {
|
||||
toast({ description: `Save failed: ${String(err)}`, variant: "error" });
|
||||
toast({ description: t("linear:saveFailed", { error: String(err) }), variant: "error" });
|
||||
throw err;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [workspaceId, form, toast, setConfig, setBaselineConfig, setForm, setTestResult]);
|
||||
}, [workspaceId, form, t, toast, setConfig, setBaselineConfig, setForm, setTestResult]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!confirm("Remove Linear configuration?")) return;
|
||||
if (!confirm(t("linear:removeLinearConfigurationConfirm"))) return;
|
||||
try {
|
||||
await deleteLinearConfig({ workspaceId });
|
||||
setConfig(null);
|
||||
setBaselineConfig(null);
|
||||
setForm(emptyForm);
|
||||
setTestResult(null);
|
||||
toast({ description: "Linear configuration removed", variant: "success" });
|
||||
toast({ description: t("linear:configurationRemoved"), variant: "success" });
|
||||
} catch (err) {
|
||||
toast({ description: `Delete failed: ${String(err)}`, variant: "error" });
|
||||
toast({ description: t("linear:deleteFailed", { error: String(err) }), variant: "error" });
|
||||
}
|
||||
}, [workspaceId, toast, setConfig, setBaselineConfig, setForm, setTestResult]);
|
||||
}, [workspaceId, t, toast, setConfig, setBaselineConfig, setForm, setTestResult]);
|
||||
|
||||
return { saving, testing, handleTest, handleSave, handleDelete };
|
||||
}
|
||||
@@ -301,6 +334,7 @@ function useTeamsLoader(
|
||||
}
|
||||
|
||||
function useLinearSettings(workspaceId: string) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [config, setConfig] = useState<LinearConfig | null>(null);
|
||||
const [baselineConfig, setBaselineConfig] = useState<LinearConfig | null>(null);
|
||||
@@ -318,11 +352,14 @@ function useLinearSettings(workspaceId: string) {
|
||||
setBaselineConfig(cfg);
|
||||
setForm(configToForm(cfg));
|
||||
} catch (err) {
|
||||
toast({ description: `Failed to load Linear config: ${String(err)}`, variant: "error" });
|
||||
toast({
|
||||
description: t("linear:failedToLoadConfig", { error: String(err) }),
|
||||
variant: "error",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId, toast]);
|
||||
}, [workspaceId, t, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -381,6 +418,7 @@ function EnabledPill() {
|
||||
}
|
||||
|
||||
export function LinearConnectionSection({ workspaceId }: { workspaceId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const s = useLinearSettings(workspaceId);
|
||||
const baseline = configToForm(s.baselineConfig);
|
||||
const missingSecret = !s.config?.hasSecret && !s.form.secret;
|
||||
@@ -394,7 +432,7 @@ export function LinearConnectionSection({ workspaceId }: { workspaceId: string }
|
||||
revision,
|
||||
isDirty: dirty,
|
||||
canSave: !disableSave,
|
||||
invalidReason: missingSecret ? "An API key is required." : undefined,
|
||||
invalidReason: missingSecret ? t("linear:anApiKeyIsRequired") : undefined,
|
||||
save: s.handleSave,
|
||||
discard: s.discard,
|
||||
});
|
||||
@@ -402,8 +440,8 @@ export function LinearConnectionSection({ workspaceId }: { workspaceId: string }
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={<IconHexagon className="h-5 w-5" />}
|
||||
title="Linear integration"
|
||||
description="Connect this workspace to Linear with a personal API key. Credentials are stored encrypted server-side for the selected workspace."
|
||||
title={t("linear:linearIntegration")}
|
||||
description={t("linear:linearIntegrationDescription")}
|
||||
action={<EnabledPill />}
|
||||
>
|
||||
<SettingsCard isDirty={dirty}>
|
||||
|
||||
@@ -127,7 +127,8 @@ const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [
|
||||
],
|
||||
},
|
||||
{ name: "settings — task actions", url: "/settings/general/task-actions" },
|
||||
// NOT YET: "settings — integrations github", "… gitlab", "… jira". Each page's
|
||||
// NOT YET: "settings — integrations github", "… gitlab", "… jira",
|
||||
// "… linear". 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
|
||||
@@ -139,13 +140,14 @@ const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [
|
||||
// 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
|
||||
// Two shared components rendered by all four 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.
|
||||
// and `stepPlaceholder`. All four are shared with the un-migrated Sentry and
|
||||
// Azure DevOps surfaces, plus `components/integrations/settings-section.tsx`
|
||||
// chrome and the watcher card's own empty/loading states.
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -349,4 +349,31 @@ export const i18nGuardFiles = [
|
||||
"components/jira/my-jira/presets.ts",
|
||||
"components/jira/my-jira/use-task-presets.ts",
|
||||
"components/jira/task-presets-section.tsx",
|
||||
// Settings → Integrations → Linear: the page and the settings subtree of
|
||||
// `components/linear` — the connection card, the Linear-watcher section with
|
||||
// its table, and the watch dialog with its filter/settings field modules. The
|
||||
// issue task surface (`linear-issue-button`, `linear-issue-dialog`,
|
||||
// `linear-issue-common`), the import bar and the quick-task launcher live in
|
||||
// the same directory and are NOT migrated, which is why this is a file list
|
||||
// rather than `components/linear/**`.
|
||||
//
|
||||
// The two `.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_LINEAR_ISSUE_WATCH_PROMPT` in
|
||||
// `linear-issue-watch-placeholders.ts`, which must also keep matching
|
||||
// `apps/backend/config/prompts/linear-issue-watch-default.md`. The placeholder
|
||||
// `example` values in the same file, and the option `value`s in
|
||||
// `linear-issue-watch-form.ts`, are identifiers rather than copy; the option
|
||||
// labels there now travel as `labelKey` and resolve at render.
|
||||
"app/settings/integrations/linear/**/*.{ts,tsx}",
|
||||
"components/linear/linear-issue-watch-dialog.tsx",
|
||||
"components/linear/linear-issue-watch-fields.tsx",
|
||||
"components/linear/linear-issue-watch-filter-fields.tsx",
|
||||
"components/linear/linear-issue-watch-form.ts",
|
||||
"components/linear/linear-issue-watch-placeholders.ts",
|
||||
"components/linear/linear-issue-watch-table.tsx",
|
||||
"components/linear/linear-issue-watchers-section.tsx",
|
||||
"components/linear/linear-settings.tsx",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
"agentProfile": "Agent Profile",
|
||||
"anApiKeyIsRequired": "An API key is required.",
|
||||
"any": "(any)",
|
||||
"anyTeam": "(any team)",
|
||||
"apiKey": "API key",
|
||||
"assignee": "Assignee",
|
||||
"assigneeHelp": "Filter by who an issue is assigned to.",
|
||||
"availablePlaceholders": "Available placeholders:",
|
||||
"checkFailed": "Check failed: {{error}}",
|
||||
"checkNow": "Check now",
|
||||
"chooseATeam": "Choose a team",
|
||||
"configurationRemoved": "Linear configuration removed",
|
||||
"configurationSaved": "Linear configuration saved",
|
||||
"connectedAs": "Connected as {{name}}",
|
||||
"connectedAsWithOrg": "Connected as {{name}} ({{org}})",
|
||||
"create": "Create",
|
||||
"createFailed": "Create failed: {{error}}",
|
||||
"createLinearWatcher": "Create Linear Watcher",
|
||||
"createPersonalApiKeyAt": "Create a personal API key at",
|
||||
"creator": "Creator",
|
||||
"creatorHelp": "Match issues created by one user.",
|
||||
"defaultTeamOptional": "Default team (optional)",
|
||||
"delete": "Delete",
|
||||
"deleteFailed": "Delete failed: {{error}}",
|
||||
"deleteThisLinearWatcher": "Delete this Linear watcher?",
|
||||
"dispatchOrder": "Dispatch order",
|
||||
"dispatchOrderHelp": "When the in-flight cap is reached, issues are dispatched in this order so the most important ones run first.",
|
||||
"editLinearWatcher": "Edit Linear Watcher",
|
||||
"enable": "Enable",
|
||||
"enabled": "Enabled",
|
||||
"enabledHelp": "Pause or resume polling.",
|
||||
"enterAPositiveIntegerOrBlank": "Enter a positive integer or leave blank.",
|
||||
"estimateMax": "Estimate max",
|
||||
"estimateMaxHelp": "Upper bound in points (optional).",
|
||||
"estimateMaxPlaceholder": "e.g. 5",
|
||||
"estimateMin": "Estimate min",
|
||||
"estimateMinHelp": "Lower bound in points (optional).",
|
||||
"estimateMinPlaceholder": "e.g. 1",
|
||||
"executorProfile": "Executor Profile",
|
||||
"failedToLoadConfig": "Failed to load Linear config: {{error}}",
|
||||
"fallsBackToStepDefault": "Optional — falls back to step default.",
|
||||
"filter": "Filter",
|
||||
"foundNewIssues_one": "Found {{count}} new issue — tasks will appear shortly.",
|
||||
"foundNewIssues_other": "Found {{count}} new issues — tasks will appear shortly.",
|
||||
"interval": "Interval",
|
||||
"intervalMinutes_one": "{{count}}m",
|
||||
"intervalMinutes_other": "{{count}}m",
|
||||
"labels": "Labels",
|
||||
"labelsHelp": "Click to toggle. Matches ANY of the selected labels.",
|
||||
"labelsHelpNoTeam": "Pick a team to choose specific labels.",
|
||||
"lastPolled": "Last Polled",
|
||||
"linearIntegration": "Linear integration",
|
||||
"linearIntegrationDescription": "Connect this workspace to Linear with a personal API key. Credentials are stored encrypted server-side for the selected workspace.",
|
||||
"linearWatcher": "Linear watcher",
|
||||
"linearWatchers": "Linear watchers",
|
||||
"linearWatchersDescription": "Poll a Linear filter and auto-create a Kandev task for each newly-matching issue.",
|
||||
"loadingEllipsis": "Loading…",
|
||||
"loadingLabels": "Loading labels…",
|
||||
"loadingStates": "Loading states…",
|
||||
"loadingTeams": "Loading teams…",
|
||||
"maxInflightTasks": "Max in-flight tasks",
|
||||
"maxInflightTasksHelp": "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.",
|
||||
"me": "Me",
|
||||
"never": "Never",
|
||||
"newWatcher": "New watcher",
|
||||
"noCap": "(no cap)",
|
||||
"noDefault": "No default",
|
||||
"noLabelsAvailableForThisTeam": "No labels available for this team.",
|
||||
"noLinearWatchersConfiguredCreateOne": "No Linear watchers configured. Create one to auto-create tasks from filtered issues.",
|
||||
"noNewIssuesMatched": "No new issues matched.",
|
||||
"noWorkflowStatesAvailable": "No workflow states available.",
|
||||
"pasteAnApiKeyToTest": "Paste an API key to test the connection",
|
||||
"pause": "Pause",
|
||||
"paused": "Paused",
|
||||
"pickATeamFirst": "Pick a team first",
|
||||
"placeholderAssigneeDisplayName": "Assignee display name",
|
||||
"placeholderCreatorDisplayName": "Issue creator display name",
|
||||
"placeholderIssueDescription": "Issue description body",
|
||||
"placeholderIssueIdentifier": "Issue identifier",
|
||||
"placeholderIssueTitle": "Issue title",
|
||||
"placeholderIssueUrl": "Linear issue URL",
|
||||
"placeholderPriorityLabel": "Priority label",
|
||||
"placeholderTeamKey": "Team key",
|
||||
"placeholderWorkflowStateName": "Workflow state name",
|
||||
"pollIntervalHelp": "How often to re-run the search. Minimum 60s, maximum 3600s.",
|
||||
"pollIntervalSeconds": "Poll Interval (seconds)",
|
||||
"priority": "Priority",
|
||||
"priorityHelp": "Click to toggle. Matches issues at ANY of the selected priorities.",
|
||||
"priorityHigh": "High",
|
||||
"priorityLow": "Low",
|
||||
"priorityMedium": "Medium",
|
||||
"priorityNone": "No priority",
|
||||
"priorityUrgent": "Urgent",
|
||||
"promptFieldHelp": "The prompt sent to the agent for each new issue. Type {{token}} to insert placeholders.",
|
||||
"query": "Query",
|
||||
"queryHelp": "Free-text match across title and description (optional).",
|
||||
"queryPlaceholder": "auth bug",
|
||||
"removeConfiguration": "Remove configuration",
|
||||
"removeLinearConfigurationConfirm": "Remove Linear 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",
|
||||
"sortCreatedAsc": "Created (oldest first)",
|
||||
"sortCreatedDesc": "Created (newest first)",
|
||||
"sortDefault": "Default (Linear order)",
|
||||
"sortPriorityAsc": "Priority (low → high)",
|
||||
"sortPriorityDesc": "Priority (high → low)",
|
||||
"sortUpdatedAsc": "Updated (least recently updated first)",
|
||||
"sortUpdatedDesc": "Updated (recently updated first)",
|
||||
"states": "States",
|
||||
"statesHelp": "Click states to toggle. Empty matches every state on the team.",
|
||||
"statesHelpNoTeam": "Pick a team to choose specific workflow states.",
|
||||
"taskPrompt": "Task Prompt",
|
||||
"team": "Team",
|
||||
"teamHelp": "Restrict matches to one team.",
|
||||
"testConnection": "Test connection",
|
||||
"testFailed": "Failed: {{error}}",
|
||||
"testing": "Testing…",
|
||||
"unassigned": "Unassigned",
|
||||
"unknownAccount": "Unknown account",
|
||||
"unknownError": "Unknown error",
|
||||
"update": "Update",
|
||||
"updateFailed": "Update failed: {{error}}",
|
||||
"watchDialogDescription": "Poll Linear with a structured filter and auto-create a Kandev task for each newly-matching issue. 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",
|
||||
"workflowHelp": "Tasks are created in this workflow.",
|
||||
"workflowStep": "Workflow Step",
|
||||
"workflowStepHelp": "Initial step for new tasks.",
|
||||
"workspaceHelp": "Tasks created by this watcher land in the selected workspace."
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"actions": "Àćţĩōńś",
|
||||
"active": "Àćţĩvē",
|
||||
"agentProfile": "Àĝēńţ Ƥŕōƒĩĺē",
|
||||
"anApiKeyIsRequired": "Àń ÀƤĨ ķēŷ ĩś ŕēqũĩŕēď.",
|
||||
"any": "(àńŷ)",
|
||||
"anyTeam": "(àńŷ ţēàḿ)",
|
||||
"apiKey": "ÀƤĨ ķēŷ",
|
||||
"assignee": "Àśśĩĝńēē",
|
||||
"assigneeHelp": "Ƒĩĺţēŕ ƀŷ ŵĥō àń ĩśśũē ĩś àśśĩĝńēď ţō.",
|
||||
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
|
||||
"checkFailed": "Ćĥēćķ ƒàĩĺēď: {{error}}",
|
||||
"checkNow": "Ćĥēćķ ńōŵ",
|
||||
"chooseATeam": "Ćĥōōśē à ţēàḿ",
|
||||
"configurationRemoved": "Ĺĩńēàŕ ćōńƒĩĝũŕàţĩōń ŕēḿōvēď",
|
||||
"configurationSaved": "Ĺĩńēàŕ ćōńƒĩĝũŕàţĩōń śàvēď",
|
||||
"connectedAs": "Ćōńńēćţēď àś {{name}}",
|
||||
"connectedAsWithOrg": "Ćōńńēćţēď àś {{name}} ({{org}})",
|
||||
"create": "Ćŕēàţē",
|
||||
"createFailed": "Ćŕēàţē ƒàĩĺēď: {{error}}",
|
||||
"createLinearWatcher": "Ćŕēàţē Ĺĩńēàŕ Ŵàţćĥēŕ",
|
||||
"createPersonalApiKeyAt": "Ćŕēàţē à ƥēŕśōńàĺ ÀƤĨ ķēŷ àţ",
|
||||
"creator": "Ćŕēàţōŕ",
|
||||
"creatorHelp": "Ḿàţćĥ ĩśśũēś ćŕēàţēď ƀŷ ōńē ũśēŕ.",
|
||||
"defaultTeamOptional": "Ďēƒàũĺţ ţēàḿ (ōƥţĩōńàĺ)",
|
||||
"delete": "Ďēĺēţē",
|
||||
"deleteFailed": "Ďēĺēţē ƒàĩĺēď: {{error}}",
|
||||
"deleteThisLinearWatcher": "Ďēĺēţē ţĥĩś Ĺĩńēàŕ ŵàţćĥēŕ?",
|
||||
"dispatchOrder": "Ďĩśƥàţćĥ ōŕďēŕ",
|
||||
"dispatchOrderHelp": "Ŵĥēń ţĥē ĩń-ƒĺĩĝĥţ ćàƥ ĩś ŕēàćĥēď, ĩśśũēś àŕē ďĩśƥàţćĥēď ĩń ţĥĩś ōŕďēŕ śō ţĥē ḿōśţ ĩḿƥōŕţàńţ ōńēś ŕũń ƒĩŕśţ.",
|
||||
"editLinearWatcher": "Ēďĩţ Ĺĩńēàŕ Ŵàţćĥēŕ",
|
||||
"enable": "Ēńàƀĺē",
|
||||
"enabled": "Ēńàƀĺēď",
|
||||
"enabledHelp": "Ƥàũśē ōŕ ŕēśũḿē ƥōĺĺĩńĝ.",
|
||||
"enterAPositiveIntegerOrBlank": "Ēńţēŕ à ƥōśĩţĩvē ĩńţēĝēŕ ōŕ ĺēàvē ƀĺàńķ.",
|
||||
"estimateMax": "Ēśţĩḿàţē ḿàx",
|
||||
"estimateMaxHelp": "Ũƥƥēŕ ƀōũńď ĩń ƥōĩńţś (ōƥţĩōńàĺ).",
|
||||
"estimateMaxPlaceholder": "ē.ĝ. 5",
|
||||
"estimateMin": "Ēśţĩḿàţē ḿĩń",
|
||||
"estimateMinHelp": "Ĺōŵēŕ ƀōũńď ĩń ƥōĩńţś (ōƥţĩōńàĺ).",
|
||||
"estimateMinPlaceholder": "ē.ĝ. 1",
|
||||
"executorProfile": "Ēxēćũţōŕ Ƥŕōƒĩĺē",
|
||||
"failedToLoadConfig": "Ƒàĩĺēď ţō ĺōàď Ĺĩńēàŕ ćōńƒĩĝ: {{error}}",
|
||||
"fallsBackToStepDefault": "Ōƥţĩōńàĺ — ƒàĺĺś ƀàćķ ţō śţēƥ ďēƒàũĺţ.",
|
||||
"filter": "Ƒĩĺţēŕ",
|
||||
"foundNewIssues_one": "Ƒōũńď {{count}} ńēŵ ĩśśũē — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
|
||||
"foundNewIssues_other": "Ƒōũńď {{count}} ńēŵ ĩśśũēś — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
|
||||
"interval": "Ĩńţēŕvàĺ",
|
||||
"intervalMinutes_one": "{{count}}ḿ",
|
||||
"intervalMinutes_other": "{{count}}ḿ",
|
||||
"labels": "Ĺàƀēĺś",
|
||||
"labelsHelp": "Ćĺĩćķ ţō ţōĝĝĺē. Ḿàţćĥēś ÀŃŶ ōƒ ţĥē śēĺēćţēď ĺàƀēĺś.",
|
||||
"labelsHelpNoTeam": "Ƥĩćķ à ţēàḿ ţō ćĥōōśē śƥēćĩƒĩć ĺàƀēĺś.",
|
||||
"lastPolled": "Ĺàśţ Ƥōĺĺēď",
|
||||
"linearIntegration": "Ĺĩńēàŕ ĩńţēĝŕàţĩōń",
|
||||
"linearIntegrationDescription": "Ćōńńēćţ ţĥĩś ŵōŕķśƥàćē ţō Ĺĩńēàŕ ŵĩţĥ à ƥēŕśōńàĺ ÀƤĨ ķēŷ. Ćŕēďēńţĩàĺś àŕē śţōŕēď ēńćŕŷƥţēď śēŕvēŕ-śĩďē ƒōŕ ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
|
||||
"linearWatcher": "Ĺĩńēàŕ ŵàţćĥēŕ",
|
||||
"linearWatchers": "Ĺĩńēàŕ ŵàţćĥēŕś",
|
||||
"linearWatchersDescription": "Ƥōĺĺ à Ĺĩńēàŕ ƒĩĺţēŕ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ĩśśũē.",
|
||||
"loadingEllipsis": "Ĺōàďĩńĝ…",
|
||||
"loadingLabels": "Ĺōàďĩńĝ ĺàƀēĺś…",
|
||||
"loadingStates": "Ĺōàďĩńĝ śţàţēś…",
|
||||
"loadingTeams": "Ĺōàďĩńĝ ţēàḿś…",
|
||||
"maxInflightTasks": "Ḿàx ĩń-ƒĺĩĝĥţ ţàśķś",
|
||||
"maxInflightTasksHelp": "Ćàƥ ōń ōƥēń ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ. Ĺēàvē ƀĺàńķ ƒōŕ ńō ćàƥ. Ńēŵ ḿàţćĥēś àŕē ďēƒēŕŕēď ţō ţĥē ńēxţ ƥōĺĺ ŵĥēń ţĥē ćàƥ ĩś ŕēàćĥēď.",
|
||||
"me": "Ḿē",
|
||||
"never": "Ńēvēŕ",
|
||||
"newWatcher": "Ńēŵ ŵàţćĥēŕ",
|
||||
"noCap": "(ńō ćàƥ)",
|
||||
"noDefault": "Ńō ďēƒàũĺţ",
|
||||
"noLabelsAvailableForThisTeam": "Ńō ĺàƀēĺś àvàĩĺàƀĺē ƒōŕ ţĥĩś ţēàḿ.",
|
||||
"noLinearWatchersConfiguredCreateOne": "Ńō Ĺĩńēàŕ ŵàţćĥēŕś ćōńƒĩĝũŕēď. Ćŕēàţē ōńē ţō àũţō-ćŕēàţē ţàśķś ƒŕōḿ ƒĩĺţēŕēď ĩśśũēś.",
|
||||
"noNewIssuesMatched": "Ńō ńēŵ ĩśśũēś ḿàţćĥēď.",
|
||||
"noWorkflowStatesAvailable": "Ńō ŵōŕķƒĺōŵ śţàţēś àvàĩĺàƀĺē.",
|
||||
"pasteAnApiKeyToTest": "Ƥàśţē àń ÀƤĨ ķēŷ ţō ţēśţ ţĥē ćōńńēćţĩōń",
|
||||
"pause": "Ƥàũśē",
|
||||
"paused": "Ƥàũśēď",
|
||||
"pickATeamFirst": "Ƥĩćķ à ţēàḿ ƒĩŕśţ",
|
||||
"placeholderAssigneeDisplayName": "Àśśĩĝńēē ďĩśƥĺàŷ ńàḿē",
|
||||
"placeholderCreatorDisplayName": "Ĩśśũē ćŕēàţōŕ ďĩśƥĺàŷ ńàḿē",
|
||||
"placeholderIssueDescription": "Ĩśśũē ďēśćŕĩƥţĩōń ƀōďŷ",
|
||||
"placeholderIssueIdentifier": "Ĩśśũē ĩďēńţĩƒĩēŕ",
|
||||
"placeholderIssueTitle": "Ĩśśũē ţĩţĺē",
|
||||
"placeholderIssueUrl": "Ĺĩńēàŕ ĩśśũē ŨŔĹ",
|
||||
"placeholderPriorityLabel": "Ƥŕĩōŕĩţŷ ĺàƀēĺ",
|
||||
"placeholderTeamKey": "Ţēàḿ ķēŷ",
|
||||
"placeholderWorkflowStateName": "Ŵōŕķƒĺōŵ śţàţē ńàḿē",
|
||||
"pollIntervalHelp": "Ĥōŵ ōƒţēń ţō ŕē-ŕũń ţĥē śēàŕćĥ. Ḿĩńĩḿũḿ 60ś, ḿàxĩḿũḿ 3600ś.",
|
||||
"pollIntervalSeconds": "Ƥōĺĺ Ĩńţēŕvàĺ (śēćōńďś)",
|
||||
"priority": "Ƥŕĩōŕĩţŷ",
|
||||
"priorityHelp": "Ćĺĩćķ ţō ţōĝĝĺē. Ḿàţćĥēś ĩśśũēś àţ ÀŃŶ ōƒ ţĥē śēĺēćţēď ƥŕĩōŕĩţĩēś.",
|
||||
"priorityHigh": "Ĥĩĝĥ",
|
||||
"priorityLow": "Ĺōŵ",
|
||||
"priorityMedium": "Ḿēďĩũḿ",
|
||||
"priorityNone": "Ńō ƥŕĩōŕĩţŷ",
|
||||
"priorityUrgent": "Ũŕĝēńţ",
|
||||
"promptFieldHelp": "Ţĥē ƥŕōḿƥţ śēńţ ţō ţĥē àĝēńţ ƒōŕ ēàćĥ ńēŵ ĩśśũē. Ţŷƥē {{token}} ţō ĩńśēŕţ ƥĺàćēĥōĺďēŕś.",
|
||||
"query": "Qũēŕŷ",
|
||||
"queryHelp": "Ƒŕēē-ţēxţ ḿàţćĥ àćŕōśś ţĩţĺē àńď ďēśćŕĩƥţĩōń (ōƥţĩōńàĺ).",
|
||||
"queryPlaceholder": "àũţĥ ƀũĝ",
|
||||
"removeConfiguration": "Ŕēḿōvē ćōńƒĩĝũŕàţĩōń",
|
||||
"removeLinearConfigurationConfirm": "Ŕēḿō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": "Śēĺēćţ ŵōŕķśƥàćē",
|
||||
"sortCreatedAsc": "Ćŕēàţēď (ōĺďēśţ ƒĩŕśţ)",
|
||||
"sortCreatedDesc": "Ćŕēàţēď (ńēŵēśţ ƒĩŕśţ)",
|
||||
"sortDefault": "Ďēƒàũĺţ (Ĺĩńēàŕ ōŕďēŕ)",
|
||||
"sortPriorityAsc": "Ƥŕĩōŕĩţŷ (ĺōŵ → ĥĩĝĥ)",
|
||||
"sortPriorityDesc": "Ƥŕĩōŕĩţŷ (ĥĩĝĥ → ĺōŵ)",
|
||||
"sortUpdatedAsc": "Ũƥďàţēď (ĺēàśţ ŕēćēńţĺŷ ũƥďàţēď ƒĩŕśţ)",
|
||||
"sortUpdatedDesc": "Ũƥďàţēď (ŕēćēńţĺŷ ũƥďàţēď ƒĩŕśţ)",
|
||||
"states": "Śţàţēś",
|
||||
"statesHelp": "Ćĺĩćķ śţàţēś ţō ţōĝĝĺē. Ēḿƥţŷ ḿàţćĥēś ēvēŕŷ śţàţē ōń ţĥē ţēàḿ.",
|
||||
"statesHelpNoTeam": "Ƥĩćķ à ţēàḿ ţō ćĥōōśē śƥēćĩƒĩć ŵōŕķƒĺōŵ śţàţēś.",
|
||||
"taskPrompt": "Ţàśķ Ƥŕōḿƥţ",
|
||||
"team": "Ţēàḿ",
|
||||
"teamHelp": "Ŕēśţŕĩćţ ḿàţćĥēś ţō ōńē ţēàḿ.",
|
||||
"testConnection": "Ţēśţ ćōńńēćţĩōń",
|
||||
"testFailed": "Ƒàĩĺēď: {{error}}",
|
||||
"testing": "Ţēśţĩńĝ…",
|
||||
"unassigned": "Ũńàśśĩĝńēď",
|
||||
"unknownAccount": "Ũńķńōŵń àććōũńţ",
|
||||
"unknownError": "Ũńķńōŵń ēŕŕōŕ",
|
||||
"update": "Ũƥďàţē",
|
||||
"updateFailed": "Ũƥďàţē ƒàĩĺēď: {{error}}",
|
||||
"watchDialogDescription": "Ƥōĺĺ Ĺĩńēàŕ ŵĩţĥ à śţŕũćţũŕēď ƒĩĺţēŕ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ĩśśũē. Ōƥţĩōńàĺĺŷ ƀĩńď à ŕēƥōśĩţōŕŷ śō ēàćĥ ţàśķ ŕũńś àĝàĩńśţ ţĥàţ ćōďēƀàśē, ōŕ ĺēàvē ĩţ ũńśēţ ţō ŕũń ŵĩţĥ ńō ŕēƥōśĩţōŕŷ.",
|
||||
"watcherCreated": "Ŵàţćĥēŕ ćŕēàţēď",
|
||||
"watcherDeleted": "Ŵàţćĥēŕ ďēĺēţēď",
|
||||
"watcherUpdated": "Ŵàţćĥēŕ ũƥďàţēď",
|
||||
"workflow": "Ŵōŕķƒĺōŵ",
|
||||
"workflowHelp": "Ţàśķś àŕē ćŕēàţēď ĩń ţĥĩś ŵōŕķƒĺōŵ.",
|
||||
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ",
|
||||
"workflowStepHelp": "Ĩńĩţĩàĺ śţēƥ ƒōŕ ńēŵ ţàśķś.",
|
||||
"workspaceHelp": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē."
|
||||
}
|
||||
Reference in New Issue
Block a user