feat(i18n): localize the Azure DevOps, Jira, Linear and Sentry task surfaces (#2284)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-05 17:42:20 +02:00
committed by GitHub
parent ad69533c10
commit 409fdcd3da
43 changed files with 1093 additions and 321 deletions
@@ -56,6 +56,7 @@ import type {
AzureDevOpsSavedView,
AzureDevOpsWorkItem,
} from "@/lib/types/azure-devops";
import { Trans, useTranslation } from "react-i18next";
const PAGE_SIZE = 25;
const WORK_ITEMS_MODE: AzureDevOpsBrowseMode = "work-items";
@@ -89,27 +90,31 @@ function NotConfigured({ workspaceId }: { workspaceId?: string }) {
<div className="max-w-2xl p-6">
<Alert>
<AlertDescription>
Azure DevOps is not connected for this workspace.{" "}
<Link href={href} className="cursor-pointer font-medium underline">
Configure Azure DevOps
</Link>
<Trans i18nKey="azuredevops:notConnectedNotice">
Azure DevOps is not connected for this workspace.{" "}
<Link href={href} className="cursor-pointer font-medium underline" />
</Trans>
</AlertDescription>
</Alert>
</div>
);
}
const RESULT_LABELS: Record<AzureDevOpsBrowseMode, string> = {
[WORK_ITEMS_MODE]: "Work items",
[PULL_REQUESTS_MODE]: "Pull requests",
[BOARD_MODE]: "Board",
// Keys, not text: resolving at module scope would freeze these at the boot locale.
const RESULT_LABEL_KEYS: Record<AzureDevOpsBrowseMode, string> = {
[WORK_ITEMS_MODE]: "azuredevops:workItems",
[PULL_REQUESTS_MODE]: "azuredevops:pullRequests",
[BOARD_MODE]: "azuredevops:board",
};
function ResultHeader({ mode, count }: { mode: AzureDevOpsBrowseMode; count: number }) {
const { t } = useTranslation();
return (
<div className="flex min-h-12 items-center justify-between border-b px-4">
<h2 className="text-sm font-semibold">{RESULT_LABELS[mode]}</h2>
<span className="text-xs text-muted-foreground">{count} results</span>
<h2 className="text-sm font-semibold">{t(RESULT_LABEL_KEYS[mode])}</h2>
<span className="text-xs text-muted-foreground">
{t("azuredevops:resultsCount", { count })}
</span>
</div>
);
}
@@ -553,11 +558,12 @@ function MobileFilters({
state: PageState;
filterProps: Omit<ComponentProps<typeof AzureDevOpsFilters>, "idSuffix">;
}) {
const { t } = useTranslation();
return (
<Sheet open={state.mobileFiltersOpen} onOpenChange={state.setMobileFiltersOpen}>
<SheetContent side="left" className="w-80 max-w-[90vw] overflow-y-auto">
<SheetHeader className="mb-5 text-left">
<SheetTitle>Azure DevOps filters</SheetTitle>
<SheetTitle>{t("azuredevops:azureDevopsFilters")}</SheetTitle>
</SheetHeader>
<AzureDevOpsFilters {...filterProps} idSuffix="-mobile" />
</SheetContent>
@@ -567,6 +573,7 @@ function MobileFilters({
// eslint-disable-next-line max-lines-per-function -- the page content owns the responsive browse composition.
function AzureDevOpsPageContent({ workspaceId, workflows, steps, repositories }: PageProps) {
const { t } = useTranslation();
const state = useAzureDevOpsPageState(workspaceId);
if (!state.preferencesLoaded) return null;
@@ -589,7 +596,9 @@ function AzureDevOpsPageContent({ workspaceId, workflows, steps, repositories }:
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
<PageTopbar
title="Azure DevOps"
subtitle={`${state.connection.data.organizationUrl} · Boards and Repos`}
subtitle={t("azuredevops:boardsAndRepos", {
organizationUrl: state.connection.data.organizationUrl,
})}
icon={<AzureDevOpsIcon className="h-4 w-4" />}
actions={
<Button
@@ -598,7 +607,7 @@ function AzureDevOpsPageContent({ workspaceId, workflows, steps, repositories }:
size="icon-lg"
onClick={() => state.setMobileFiltersOpen(true)}
className="cursor-pointer md:hidden"
aria-label="Open Azure DevOps filters"
aria-label={t("azuredevops:openAzureDevopsFilters")}
data-testid="azure-devops-mobile-filter-button"
>
<IconAdjustments className="h-4 w-4" />
+20 -8
View File
@@ -32,6 +32,7 @@ import { ResultsPagination } from "@/components/jira/my-jira/results-pagination"
import { JqlEditor } from "@/components/jira/my-jira/jql-editor";
import { useJiraTaskPresets } from "@/components/jira/my-jira/use-task-presets";
import type { JiraTaskPreset } from "@/components/jira/my-jira/presets";
import { Trans, useTranslation } from "react-i18next";
type JiraPageClientProps = {
workspaceId?: string;
@@ -44,11 +45,14 @@ function NotConfiguredNotice() {
<div className="p-6 max-w-2xl">
<Alert>
<AlertDescription>
Jira is not configured.{" "}
<Link href="/settings/integrations/jira" className="underline font-medium cursor-pointer">
Configure Jira
</Link>{" "}
to see your tickets here.
<Trans i18nKey="jira:notConfiguredNotice">
Jira is not configured.{" "}
<Link
href="/settings/integrations/jira"
className="underline font-medium cursor-pointer"
/>{" "}
to see your tickets here.
</Trans>
</AlertDescription>
</Alert>
</div>
@@ -134,6 +138,7 @@ function TicketResults({
onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void;
onOpen: (ticket: JiraTicket) => void;
}) {
const { t } = useTranslation();
if (error) {
return (
<div className="flex justify-center py-16">
@@ -142,7 +147,11 @@ function TicketResults({
);
}
if (!loading && items.length === 0) {
return <div className="text-sm text-muted-foreground py-8 text-center">No tickets found.</div>;
return (
<div className="text-sm text-muted-foreground py-8 text-center">
{t("jira:noTicketsFound")}
</div>
);
}
return (
<div>
@@ -316,6 +325,7 @@ function useFilterState(defaultProjectKey: string) {
}
export function JiraPageClient({ workspaceId, workflows, steps }: JiraPageClientProps) {
const { t } = useTranslation();
const { loaded, configured, projects, defaultProjectKey } = useJiraPageData(workspaceId);
const { taskPresets } = useJiraTaskPresets();
const [launchPayload, setLaunchPayload] = useState<JiraLaunchPayload | null>(null);
@@ -331,10 +341,12 @@ export function JiraPageClient({ workspaceId, workflows, steps }: JiraPageClient
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<PageTopbar
title="Jira"
subtitle="Tickets across your Atlassian projects."
subtitle={t("jira:ticketsAcrossYourAtlassianProjects")}
icon={<IconTicket className="h-4 w-4" />}
/>
{!loaded && <div className="p-6 text-sm text-muted-foreground">Checking Jira status…</div>}
{!loaded && (
<div className="p-6 text-sm text-muted-foreground">{t("jira:checkingJiraStatus")}</div>
)}
{loaded && !configured && <NotConfiguredNotice />}
{loaded && configured && (
<AuthenticatedView
+40 -25
View File
@@ -32,6 +32,7 @@ import {
} from "@/components/linear/use-linear-issue-search";
import type { LinearIssue, LinearTeam } from "@/lib/types/linear";
import type { Workflow, WorkflowStep } from "@/lib/types/http";
import { Trans, useTranslation } from "react-i18next";
type LinearPageClientProps = {
workspaceId?: string;
@@ -44,14 +45,14 @@ function NotConfiguredNotice() {
<div className="p-6 max-w-2xl">
<Alert>
<AlertDescription>
Linear is not configured.{" "}
<Link
href="/settings/integrations/linear"
className="underline font-medium cursor-pointer"
>
Configure Linear
</Link>{" "}
to see your issues here.
<Trans i18nKey="linear:notConfiguredNotice">
Linear is not configured.{" "}
<Link
href="/settings/integrations/linear"
className="underline font-medium cursor-pointer"
/>{" "}
to see your issues here.
</Trans>
</AlertDescription>
</Alert>
</div>
@@ -97,8 +98,9 @@ function useLinearPageData(workspaceId?: string) {
}
function AssigneeCell({ issue }: { issue: LinearIssue }) {
const { t } = useTranslation();
if (!issue.assigneeName) {
return <span className="text-xs text-muted-foreground">Unassigned</span>;
return <span className="text-xs text-muted-foreground">{t("linear:unassigned")}</span>;
}
return (
<div className="flex items-center gap-1.5 min-w-0">
@@ -120,6 +122,7 @@ function IssueRow({
onOpen: (i: LinearIssue) => void;
onStartTask: (i: LinearIssue) => void;
}) {
const { t } = useTranslation();
const relative = formatRelative(issue.updated);
return (
<div className="flex items-start gap-3 py-3 border-b last:border-b-0">
@@ -127,7 +130,7 @@ function IssueRow({
type="button"
onClick={() => onOpen(issue)}
className="flex-1 min-w-0 space-y-1 text-left cursor-pointer rounded -mx-2 px-2 py-1 hover:bg-muted/50 transition-colors"
title="Open issue details"
title={t("linear:openIssueDetails")}
>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{issue.identifier}</span>
@@ -143,12 +146,16 @@ function IssueRow({
</Badge>
)}
<AssigneeCell issue={issue} />
{relative && <span className="text-xs text-muted-foreground">· updated {relative}</span>}
{relative && (
<span className="text-xs text-muted-foreground">
{t("linear:updatedAgo", { time: relative })}
</span>
)}
</div>
</button>
<div className="flex items-center gap-1 shrink-0">
<Button asChild variant="ghost" size="icon-sm" className="cursor-pointer">
<a href={issue.url} target="_blank" rel="noreferrer" title="Open in Linear">
<a href={issue.url} target="_blank" rel="noreferrer" title={t("linear:openInLinear")}>
<IconExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
@@ -159,7 +166,7 @@ function IssueRow({
onClick={() => onStartTask(issue)}
>
<IconPlus className="h-3.5 w-3.5" />
Start task
{t("linear:startTask")}
</Button>
</div>
</div>
@@ -183,12 +190,13 @@ function FilterControls({
setAssigned: (v: string) => void;
teams: LinearTeam[];
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-2 px-6 py-3 border-b">
<div className="relative flex-1 min-w-[280px]">
<IconSearch className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by ID, title, or description"
placeholder={t("linear:searchByIdTitleOrDescription")}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-8"
@@ -199,10 +207,10 @@ function FilterControls({
onValueChange={(v) => setTeamKey(v === "__all__" ? "" : v)}
>
<SelectTrigger className="w-44">
<SelectValue placeholder="All teams" />
<SelectValue placeholder={t("linear:allTeams")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All teams</SelectItem>
<SelectItem value="__all__">{t("linear:allTeams")}</SelectItem>
{teams.map((t) => (
<SelectItem key={t.id} value={t.key}>
{t.name}
@@ -218,9 +226,9 @@ function FilterControls({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__any__">Any assignee</SelectItem>
<SelectItem value="me">Assigned to me</SelectItem>
<SelectItem value="unassigned">Unassigned</SelectItem>
<SelectItem value="__any__">{t("linear:anyAssignee")}</SelectItem>
<SelectItem value="me">{t("linear:assignedToMe")}</SelectItem>
<SelectItem value="unassigned">{t("linear:unassigned")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -242,6 +250,7 @@ function PaginationBar({
onNext: () => void;
onPrev: () => void;
}) {
const { t } = useTranslation();
if (page === 1 && isLast) return null;
const start = (page - 1) * pageSize + 1;
const end = (page - 1) * pageSize + itemCount;
@@ -250,7 +259,7 @@ function PaginationBar({
return (
<div className="flex items-center justify-between px-6 py-3 border-t shrink-0">
<div className="text-xs text-muted-foreground tabular-nums">
{itemCount === 0 ? "No results" : `${start}–${end}`}
{itemCount === 0 ? t("linear:noResults") : `${start}–${end}`}
</div>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
@@ -266,7 +275,9 @@ function PaginationBar({
/>
</PaginationItem>
<PaginationItem>
<span className="px-3 text-sm tabular-nums">Page {page}</span>
<span className="px-3 text-sm tabular-nums">
{t("linear:page")} {page}
</span>
</PaginationItem>
<PaginationItem>
<PaginationNext
@@ -295,16 +306,17 @@ function PageShell({ children }: { children: React.ReactNode }) {
}
function DisabledNotice() {
const { t } = useTranslation();
return (
<div className="p-6 max-w-2xl">
<Alert>
<AlertDescription>
Linear integration is disabled.{" "}
{t("linear:integrationDisabled")}{" "}
<Link
href="/settings/integrations/linear"
className="underline font-medium cursor-pointer"
>
Re-enable it in settings
{t("linear:reEnableItInSettings")}
</Link>
.
</AlertDescription>
@@ -324,6 +336,7 @@ function ResultsArea({
onOpen: (issue: LinearIssue) => void;
onStartTask: (issue: LinearIssue) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex-1 overflow-y-auto px-6 py-3">
{search.error && !search.loading && (
@@ -333,14 +346,16 @@ function ResultsArea({
)}
{!search.error && empty && (
<div className="text-sm text-muted-foreground py-12 text-center">
No issues match your filters.
{t("linear:noIssuesMatchYourFilters")}
</div>
)}
{search.items.map((issue) => (
<IssueRow key={issue.id} issue={issue} onOpen={onOpen} onStartTask={onStartTask} />
))}
{search.loading && search.items.length === 0 && (
<div className="text-sm text-muted-foreground py-12 text-center">Loading issues…</div>
<div className="text-sm text-muted-foreground py-12 text-center">
{t("linear:loadingIssues")}
</div>
)}
</div>
);
@@ -20,6 +20,7 @@ import type {
} from "@/lib/types/azure-devops";
import { groupAzureDevOpsBoardItems } from "./azure-devops-board-view";
import { AzureDevOpsWorkItemDetail } from "./azure-devops-work-item-detail";
import { useTranslation } from "react-i18next";
type Props = {
workspaceId?: string;
@@ -41,6 +42,7 @@ function BoardCard({
onOpen: () => void;
onDragStart: () => void;
}) {
const { t } = useTranslation();
const pointerStart = useRef<{ x: number; y: number } | null>(null);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
pointerStart.current = { x: event.clientX, y: event.clientY };
@@ -71,7 +73,7 @@ function BoardCard({
<CardHeader className="px-3 py-0">
<CardTitle className="flex items-start gap-2 text-sm font-medium">
<IconGripVertical className="mt-0.5 hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
<span className="line-clamp-2">{item.title || "Untitled work item"}</span>
<span className="line-clamp-2">{item.title || t("azuredevops:untitledWorkItem")}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 px-3 text-xs text-muted-foreground">
@@ -97,6 +99,7 @@ function MobileColumnNavigator({
counts: Map<string, AzureDevOpsBoardWorkItem[]>;
onChange: (columnId: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const index = Math.max(
0,
@@ -114,7 +117,7 @@ function MobileColumnNavigator({
variant="outline"
size="icon"
className="h-11 w-11 cursor-pointer"
aria-label="Previous board column"
aria-label={t("azuredevops:previousBoardColumn")}
disabled={index === 0}
onClick={() => onChange(columns[index - 1].id)}
>
@@ -127,7 +130,7 @@ function MobileColumnNavigator({
data-testid="azure-board-column-picker"
onClick={() => setOpen(true)}
>
<span>{column?.name ?? "Board column"}</span>
<span>{column?.name ?? t("azuredevops:boardColumn")}</span>
<span>{counts.get(column?.id ?? "")?.length ?? 0}</span>
<IconList className="h-4 w-4" />
</Button>
@@ -136,7 +139,7 @@ function MobileColumnNavigator({
variant="outline"
size="icon"
className="h-11 w-11 cursor-pointer"
aria-label="Next board column"
aria-label={t("azuredevops:nextBoardColumn")}
disabled={index >= columns.length - 1}
onClick={() => onChange(columns[index + 1].id)}
>
@@ -145,7 +148,7 @@ function MobileColumnNavigator({
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="max-h-[min(32rem,calc(100dvh-16px-env(safe-area-inset-bottom,0px)))]">
<DrawerHeader>
<DrawerTitle>Board columns</DrawerTitle>
<DrawerTitle>{t("azuredevops:boardColumns")}</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 overflow-y-auto p-4 pb-[env(safe-area-inset-bottom,0px)]">
{columns.map((candidate) => (
@@ -189,11 +192,12 @@ function BoardSelectors({
boards: Array<{ id: string; name: string }>;
onBoardChange: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
<Select value={projectId} onValueChange={onProjectChange}>
<SelectTrigger className="w-52" data-testid="azure-board-project-select">
<SelectValue placeholder="Project" />
<SelectValue placeholder={t("azuredevops:project")} />
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
@@ -205,7 +209,7 @@ function BoardSelectors({
</Select>
<Select value={teamId} onValueChange={onTeamChange} disabled={!teams.length}>
<SelectTrigger className="w-52" data-testid="azure-board-team-select">
<SelectValue placeholder="Team" />
<SelectValue placeholder={t("azuredevops:team")} />
</SelectTrigger>
<SelectContent>
{teams.map((team) => (
@@ -217,7 +221,7 @@ function BoardSelectors({
</Select>
<Select value={boardId} onValueChange={onBoardChange} disabled={!boards.length}>
<SelectTrigger className="w-52" data-testid="azure-board-select">
<SelectValue placeholder="Board" />
<SelectValue placeholder={t("azuredevops:board")} />
</SelectTrigger>
<SelectContent>
{boards.map((board) => (
@@ -295,8 +299,11 @@ function useBoardPresentation(
}
function BoardEmptyState() {
const { t } = useTranslation();
return (
<div className="p-6 text-sm text-muted-foreground">Select a project to view its board.</div>
<div className="p-6 text-sm text-muted-foreground">
{t("azuredevops:selectAProjectToViewIts")}
</div>
);
}
@@ -311,6 +318,7 @@ export function AzureDevOpsBoard({
quickActions = [],
onStartTask,
}: Props) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const board = useAzureDevOpsBoard(workspaceId, projectId, initialPreference);
const [editing, setEditing] = useState<AzureDevOpsBoardWorkItem | null>(null);
@@ -338,7 +346,9 @@ export function AzureDevOpsBoard({
<AlertDescription>{board.error}</AlertDescription>
</Alert>
)}
{board.loading && <div className="p-6 text-sm text-muted-foreground">Loading board…</div>}
{board.loading && (
<div className="p-6 text-sm text-muted-foreground">{t("azuredevops:loadingBoard")}</div>
)}
{!board.loading && board.snapshot && (
<>
{isMobile && (
@@ -10,38 +10,52 @@ import {
} from "@kandev/ui/dialog";
import { Separator } from "@kandev/ui/separator";
import type { AzureDevOpsPullRequestFeedback } from "@/lib/types/azure-devops";
import { useTranslation } from "react-i18next";
function voteLabel(vote: number): string {
if (vote >= 10) return "Approved";
if (vote >= 5) return "Approved with suggestions";
if (vote <= -10) return "Rejected";
if (vote <= -5) return "Waiting for author";
return "No vote";
/** `vote` is Azure DevOps' numeric vote; only the catalog key is copy. */
function voteLabelKey(vote: number): string {
if (vote >= 10) return "azuredevops:voteApproved";
if (vote >= 5) return "azuredevops:voteApprovedWithSuggestions";
if (vote <= -10) return "azuredevops:voteRejected";
if (vote <= -5) return "azuredevops:voteWaitingForAuthor";
return "azuredevops:voteNone";
}
function Summary({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
<Badge variant="outline">Review: {feedback.reviewState || "pending"}</Badge>
<Badge variant="outline">Policies: {feedback.policyState || "none"}</Badge>
<Badge variant="secondary">{feedback.linkedWorkItems.length} linked items</Badge>
<Badge variant="outline">
{t("azuredevops:reviewLabelled", {
state: feedback.reviewState || t("azuredevops:pending"),
})}
</Badge>
<Badge variant="outline">
{t("azuredevops:policiesLabelled", {
state: feedback.policyState || t("azuredevops:none"),
})}
</Badge>
<Badge variant="secondary">
{t("azuredevops:linkedItemsCount", { count: feedback.linkedWorkItems.length })}
</Badge>
</div>
);
}
function Reviewers({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) {
const { t } = useTranslation();
return (
<section className="space-y-2">
<h3 className="text-sm font-semibold">Reviewers</h3>
<h3 className="text-sm font-semibold">{t("azuredevops:reviewers")}</h3>
{feedback.reviewers.length === 0 ? (
<p className="text-sm text-muted-foreground">No reviewers.</p>
<p className="text-sm text-muted-foreground">{t("azuredevops:noReviewers")}</p>
) : (
<div className="space-y-2">
{feedback.reviewers.map((reviewer) => (
<div key={reviewer.id} className="flex items-center justify-between gap-3 text-sm">
<span className="min-w-0 truncate">{reviewer.displayName}</span>
<Badge variant="outline" className="shrink-0">
{voteLabel(reviewer.vote)}
{t(voteLabelKey(reviewer.vote))}
</Badge>
</div>
))}
@@ -52,11 +66,12 @@ function Reviewers({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) {
}
function Policies({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) {
const { t } = useTranslation();
return (
<section className="space-y-2">
<h3 className="text-sm font-semibold">Branch policies</h3>
<h3 className="text-sm font-semibold">{t("azuredevops:branchPolicies")}</h3>
{feedback.policies.length === 0 ? (
<p className="text-sm text-muted-foreground">No policy evaluations.</p>
<p className="text-sm text-muted-foreground">{t("azuredevops:noPolicyEvaluations")}</p>
) : (
<div className="space-y-2">
{feedback.policies.map((policy) => (
@@ -74,14 +89,15 @@ function Policies({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) {
}
function Threads({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) {
const { t } = useTranslation();
const comments = feedback.threads.flatMap((thread) =>
thread.comments.map((comment) => ({ ...comment, threadId: thread.id })),
);
return (
<section className="space-y-2">
<h3 className="text-sm font-semibold">Discussion</h3>
<h3 className="text-sm font-semibold">{t("azuredevops:discussion")}</h3>
{comments.length === 0 ? (
<p className="text-sm text-muted-foreground">No comments.</p>
<p className="text-sm text-muted-foreground">{t("azuredevops:noComments")}</p>
) : (
<div className="space-y-3">
{comments.map((comment) => (
@@ -111,20 +127,29 @@ export function AzureDevOpsFeedbackDialog({
feedback: AzureDevOpsPullRequestFeedback | null;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85dvh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="break-words">
{feedback?.pullRequest.title ?? "Pull request feedback"}
{feedback?.pullRequest.title ?? t("azuredevops:pullRequestFeedback")}
</DialogTitle>
<DialogDescription>
{feedback
? `${feedback.pullRequest.repositoryName} · PR ${feedback.pullRequest.id}`
: "Azure DevOps review and policy state"}
? // The repository name and id are provider data; the `PR` label
// and the separator around them are not, so this is a message
// with the data interpolated rather than a bare template.
t("azuredevops:pullRequestSubtitle", {
repository: feedback.pullRequest.repositoryName,
id: feedback.pullRequest.id,
})
: t("azuredevops:azureDevopsReviewAndPolicyState")}
</DialogDescription>
</DialogHeader>
{loading && <p className="text-sm text-muted-foreground">Loading feedback...</p>}
{loading && (
<p className="text-sm text-muted-foreground">{t("azuredevops:loadingFeedback")}</p>
)}
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Textarea } from "@kandev/ui/textarea";
import type { AzureDevOpsProject, AzureDevOpsRepository } from "@/lib/types/azure-devops";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
export type AzureDevOpsBrowseMode = "board" | "work-items" | "pull-requests";
@@ -55,13 +56,14 @@ function ProjectFilter({
onChange: (value: string) => void;
compact?: boolean;
}) {
const { t } = useTranslation();
const controlId = `azure-devops-filter-project${idSuffix}`;
return (
<Field compact={compact}>
<Label htmlFor={controlId}>Project</Label>
<Label htmlFor={controlId}>{t("azuredevops:project")}</Label>
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger id={controlId} className="w-full">
<SelectValue placeholder="Select project" />
<SelectValue placeholder={t("azuredevops:selectProject")} />
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
@@ -82,18 +84,21 @@ function PullRequestFilters({
onChange,
compact,
}: Pick<FiltersProps, "idSuffix" | "filters" | "repositories" | "onChange" | "compact">) {
const { t } = useTranslation();
const [advanced, setAdvanced] = useState(false);
return (
<>
<Field compact={compact}>
<Label htmlFor={`azure-devops-filter-repository${idSuffix}`}>Repository</Label>
<Label htmlFor={`azure-devops-filter-repository${idSuffix}`}>
{t("azuredevops:repository")}
</Label>
<Select
value={filters.repositoryId || undefined}
onValueChange={(value) => onChange("repositoryId", value)}
disabled={repositories.length === 0}
>
<SelectTrigger id={`azure-devops-filter-repository${idSuffix}`} className="w-full">
<SelectValue placeholder="Select repository" />
<SelectValue placeholder={t("azuredevops:selectRepository")} />
</SelectTrigger>
<SelectContent>
{repositories.map((repository) => (
@@ -105,22 +110,24 @@ function PullRequestFilters({
</Select>
</Field>
<Field compact={compact}>
<Label htmlFor={`azure-devops-filter-status${idSuffix}`}>Status</Label>
<Label htmlFor={`azure-devops-filter-status${idSuffix}`}>{t("azuredevops:status")}</Label>
<Select value={filters.status} onValueChange={(value) => onChange("status", value)}>
<SelectTrigger id={`azure-devops-filter-status${idSuffix}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="abandoned">Abandoned</SelectItem>
<SelectItem value="all">All</SelectItem>
<SelectItem value="active">{t("azuredevops:statusActive")}</SelectItem>
<SelectItem value="completed">{t("azuredevops:statusCompleted")}</SelectItem>
<SelectItem value="abandoned">{t("azuredevops:statusAbandoned")}</SelectItem>
<SelectItem value="all">{t("azuredevops:statusAll")}</SelectItem>
</SelectContent>
</Select>
</Field>
<AdvancedFilters open={advanced} onOpenChange={setAdvanced} compact={compact}>
<Field compact={compact}>
<Label htmlFor={`azure-devops-filter-creator${idSuffix}`}>Creator ID</Label>
<Label htmlFor={`azure-devops-filter-creator${idSuffix}`}>
{t("azuredevops:creatorId")}
</Label>
<Input
id={`azure-devops-filter-creator${idSuffix}`}
value={filters.creator}
@@ -128,7 +135,9 @@ function PullRequestFilters({
/>
</Field>
<Field compact={compact}>
<Label htmlFor={`azure-devops-filter-reviewer${idSuffix}`}>Reviewer ID</Label>
<Label htmlFor={`azure-devops-filter-reviewer${idSuffix}`}>
{t("azuredevops:reviewerId")}
</Label>
<Input
id={`azure-devops-filter-reviewer${idSuffix}`}
value={filters.reviewer}
@@ -151,6 +160,7 @@ function AdvancedFilters({
compact?: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation();
return (
<Collapsible open={open} onOpenChange={onOpenChange} className={cn(compact && "contents")}>
<Button
@@ -162,7 +172,7 @@ function AdvancedFilters({
aria-expanded={open}
>
<IconAdjustments className="h-4 w-4" />
Advanced
{t("azuredevops:advanced")}
<IconChevronDown className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-180")} />
</Button>
<CollapsibleContent className={cn(compact && "basis-full")}>
@@ -180,11 +190,14 @@ function WorkItemFilters({
onChange,
compact,
}: Pick<FiltersProps, "idSuffix" | "filters" | "onChange" | "compact">) {
const { t } = useTranslation();
const [advanced, setAdvanced] = useState(false);
return (
<>
<Field compact={compact}>
<Label htmlFor={`azure-devops-filter-limit${idSuffix}`}>Result limit</Label>
<Label htmlFor={`azure-devops-filter-limit${idSuffix}`}>
{t("azuredevops:resultLimit")}
</Label>
<Select
value={String(filters.top)}
onValueChange={(value) => onChange("top", Number(value))}
@@ -227,6 +240,7 @@ export function AzureDevOpsFilters({
onSearch,
compact,
}: FiltersProps) {
const { t } = useTranslation();
const disabled =
loading ||
!filters.projectId ||
@@ -267,7 +281,7 @@ export function AzureDevOpsFilters({
data-testid={`azure-devops-search-button${idSuffix}`}
>
<IconSearch className="h-4 w-4" />
{loading ? "Loading..." : "Search"}
{loading ? t("azuredevops:loading") : t("common:presetIconSearch")}
</Button>
</div>
);
@@ -1,5 +1,6 @@
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useTranslation } from "react-i18next";
type Props = {
skip: number;
@@ -16,6 +17,7 @@ export function AzureDevOpsPullRequestPagination({
pageSize,
onPage,
}: Props) {
const { t } = useTranslation();
if (skip === 0 && count < pageSize) return null;
return (
<div className="flex items-center justify-between border-t px-4 py-2">
@@ -30,7 +32,7 @@ export function AzureDevOpsPullRequestPagination({
onClick={() => onPage(Math.max(0, skip - pageSize))}
disabled={loading || skip === 0}
className="cursor-pointer"
aria-label="Previous pull request page"
aria-label={t("azuredevops:previousPullRequestPage")}
>
<IconChevronLeft className="h-4 w-4" />
</Button>
@@ -41,7 +43,7 @@ export function AzureDevOpsPullRequestPagination({
onClick={() => onPage(skip + pageSize)}
disabled={loading || count < pageSize}
className="cursor-pointer"
aria-label="Next pull request page"
aria-label={t("azuredevops:nextPullRequestPage")}
>
<IconChevronRight className="h-4 w-4" />
</Button>
@@ -19,29 +19,46 @@ import type {
AzureDevOpsPullRequest,
AzureDevOpsWorkItem,
} from "@/lib/types/azure-devops";
import { useTranslation } from "react-i18next";
function EmptyResult({ loading, error }: { loading: boolean; error: string | null }) {
if (loading) return <div className="p-6 text-sm text-muted-foreground">Loading results...</div>;
const { t } = useTranslation();
if (loading)
return (
<div className="p-6 text-sm text-muted-foreground">{t("azuredevops:loadingResults")}</div>
);
if (error)
return (
<div className="p-6 text-sm text-destructive" role="alert">
{error}
</div>
);
return <div className="p-6 text-sm text-muted-foreground">No matching results.</div>;
return (
<div className="p-6 text-sm text-muted-foreground">{t("azuredevops:noMatchingResults")}</div>
);
}
// A discriminant, not copy: the aria-label is a whole sentence per kind, because
// interpolating an English noun into a translated sentence is untranslatable.
type TaskActionsItemKind = "workItem" | "pullRequest";
const TASK_ACTIONS_LABEL_KEYS: Record<TaskActionsItemKind, string> = {
workItem: "azuredevops:taskActionsForWorkItem",
pullRequest: "azuredevops:taskActionsForPullRequest",
};
function TaskActionsMenu<T extends { id: number }>({
item,
itemLabel,
itemKind,
actions,
onSelect,
}: {
item: T;
itemLabel: string;
itemKind: TaskActionsItemKind;
actions: AzureDevOpsActionPreset[];
onSelect: (item: T, action: AzureDevOpsActionPreset) => void;
}) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -50,9 +67,10 @@ function TaskActionsMenu<T extends { id: number }>({
size="sm"
variant="outline"
className="cursor-pointer"
aria-label={`Task actions for ${itemLabel} ${item.id}`}
aria-label={t(TASK_ACTIONS_LABEL_KEYS[itemKind], { id: item.id })}
>
<IconPlus className="h-4 w-4" /> Task <IconChevronDown className="h-3 w-3" />
<IconPlus className="h-4 w-4" /> {t("azuredevops:task")}{" "}
<IconChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -86,6 +104,7 @@ export function AzureDevOpsWorkItemResults({
quickActions?: AzureDevOpsActionPreset[];
onQuickAction?: (item: AzureDevOpsWorkItem, action: AzureDevOpsActionPreset) => void;
}) {
const { t } = useTranslation();
if (loading || error || items.length === 0)
return <EmptyResult loading={loading} error={error} />;
return (
@@ -112,7 +131,7 @@ export function AzureDevOpsWorkItemResults({
</div>
<div className="break-words text-sm font-medium">{item.title}</div>
<div className="text-xs text-muted-foreground">
{item.assignedTo || "Unassigned"}
{item.assignedTo || t("azuredevops:unassigned")}
{item.areaPath ? ` · ${item.areaPath}` : ""}
</div>
</div>
@@ -123,7 +142,7 @@ export function AzureDevOpsWorkItemResults({
href={item.webUrl}
target="_blank"
rel="noreferrer"
aria-label={`Open work item ${item.id} in Azure DevOps`}
aria-label={t("azuredevops:openWorkItemInAzureDevops", { id: item.id })}
>
<IconExternalLink className="h-4 w-4" />
</a>
@@ -132,7 +151,7 @@ export function AzureDevOpsWorkItemResults({
{quickActions.length > 0 && onQuickAction ? (
<TaskActionsMenu
item={item}
itemLabel="work item"
itemKind="workItem"
actions={quickActions}
onSelect={onQuickAction}
/>
@@ -145,7 +164,7 @@ export function AzureDevOpsWorkItemResults({
onClick={() => onStartTask(item)}
>
<IconPlus className="h-4 w-4" />
Start task
{t("azuredevops:startTask")}
</Button>
)}
</div>
@@ -176,6 +195,7 @@ export function AzureDevOpsPullRequestResults({
quickActions?: AzureDevOpsActionPreset[];
onQuickAction?: (pullRequest: AzureDevOpsPullRequest, action: AzureDevOpsActionPreset) => void;
}) {
const { t } = useTranslation();
if (loading || error || items.length === 0)
return <EmptyResult loading={loading} error={error} />;
return (
@@ -189,7 +209,7 @@ export function AzureDevOpsPullRequestResults({
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">PR {pullRequest.id}</span>
<Badge variant="outline">{pullRequest.status}</Badge>
{pullRequest.isDraft && <Badge variant="secondary">Draft</Badge>}
{pullRequest.isDraft && <Badge variant="secondary">{t("azuredevops:draft")}</Badge>}
<span>{pullRequest.repositoryName}</span>
</div>
<div className="break-words text-sm font-medium">{pullRequest.title}</div>
@@ -206,12 +226,12 @@ export function AzureDevOpsPullRequestResults({
onClick={() => onFeedback(pullRequest)}
>
<IconMessageCircle className="h-4 w-4" />
Feedback
{t("azuredevops:feedback")}
</Button>
{quickActions.length > 0 && onQuickAction ? (
<TaskActionsMenu
item={pullRequest}
itemLabel="pull request"
itemKind="pullRequest"
actions={quickActions}
onSelect={onQuickAction}
/>
@@ -224,7 +244,7 @@ export function AzureDevOpsPullRequestResults({
onClick={() => onStartTask(pullRequest)}
>
<IconPlus className="h-4 w-4" />
Start task
{t("azuredevops:startTask")}
</Button>
)}
<Button asChild variant="ghost" size="icon-sm" className="cursor-pointer">
@@ -232,7 +252,7 @@ export function AzureDevOpsPullRequestResults({
href={pullRequest.webUrl}
target="_blank"
rel="noreferrer"
aria-label={`Open pull request ${pullRequest.id} in Azure DevOps`}
aria-label={t("azuredevops:openPullRequestInAzureDevops", { id: pullRequest.id })}
>
<IconExternalLink className="h-4 w-4" />
</a>
@@ -13,6 +13,7 @@ import {
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import type { AzureDevOpsPresetKind } from "./azure-devops-presets";
import { useTranslation } from "react-i18next";
function SaveViewForm({
kind,
@@ -23,6 +24,7 @@ function SaveViewForm({
onSave: (label: string) => Promise<void>;
onClose: () => void;
}) {
const { t } = useTranslation();
const [label, setLabel] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
@@ -34,7 +36,7 @@ function SaveViewForm({
await onSave(trimmed);
onClose();
} catch {
setError("Could not save this view. Try again.");
setError(t("azuredevops:couldNotSaveView"));
} finally {
setSaving(false);
}
@@ -42,14 +44,18 @@ function SaveViewForm({
return (
<>
<DialogHeader>
<DialogTitle>Save Azure DevOps view</DialogTitle>
<DialogTitle>{t("azuredevops:saveAzureDevopsView")}</DialogTitle>
<DialogDescription>
Save the current {kind === "work_item" ? "work-item query" : "pull-request filters"} for
this workspace.
{t("azuredevops:saveViewDescription", {
kind:
kind === "work_item"
? t("azuredevops:workItemQuery2")
: t("azuredevops:pullRequestFilters"),
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label htmlFor="azure-devops-view-name">Name</Label>
<Label htmlFor="azure-devops-view-name">{t("azuredevops:name")}</Label>
<Input
id="azure-devops-view-name"
autoFocus
@@ -58,7 +64,7 @@ function SaveViewForm({
setLabel(event.target.value);
setError("");
}}
placeholder="e.g. Platform triage"
placeholder={t("azuredevops:eGPlatformTriage")}
/>
{error && (
<p role="alert" className="text-xs text-destructive">
@@ -74,7 +80,7 @@ function SaveViewForm({
disabled={saving}
onClick={onClose}
>
Cancel
{t("common:cancel")}
</Button>
<Button
type="button"
@@ -82,7 +88,7 @@ function SaveViewForm({
disabled={!trimmed || saving}
onClick={() => void handleSave()}
>
{saving ? "Saving..." : "Save"}
{saving ? t("azuredevops:saving") : t("azuredevops:save")}
</Button>
</DialogFooter>
</>
@@ -27,6 +27,7 @@ import type {
AzureDevOpsWorkItem,
} from "@/lib/types/azure-devops";
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
import { useTranslation } from "react-i18next";
type BoardContext = {
board: AzureDevOpsBoard;
@@ -50,17 +51,24 @@ type Props = {
onStartTask?: (item: AzureDevOpsWorkItem, action?: AzureDevOpsActionPreset) => void;
};
export function azureDevOpsDetailFields(item: AzureDevOpsWorkItem): Array<[string, string]> {
export function azureDevOpsDetailFields(
item: AzureDevOpsWorkItem,
t: (key: string, values?: Record<string, unknown>) => string,
): Array<[string, string]> {
// Row labels are copy; the values are provider data, with copy fallbacks when
// Azure DevOps returns nothing.
return [
["Type", item.type || "Work item"],
["State", item.state || "Unknown"],
["Assignee", item.assignedTo || "Unassigned"],
...(item.areaPath ? [["Area", item.areaPath] as [string, string]] : []),
[t("azuredevops:fieldType"), item.type || t("azuredevops:fallbackWorkItem")],
[t("azuredevops:fieldState"), item.state || t("azuredevops:fallbackUnknown")],
[t("azuredevops:fieldAssignee"), item.assignedTo || t("azuredevops:fallbackUnassigned")],
...(item.areaPath ? [[t("azuredevops:fieldArea"), item.areaPath] as [string, string]] : []),
];
}
function Description({ value }: { value: string }) {
if (!value.trim()) return <p className="text-sm text-muted-foreground">No description.</p>;
const { t } = useTranslation();
if (!value.trim())
return <p className="text-sm text-muted-foreground">{t("azuredevops:noDescription")}</p>;
return (
<div
data-testid="azure-work-item-detail-description"
@@ -86,6 +94,7 @@ function AssignmentActions({
saving: boolean;
onChange: (action: "assign_current_user" | "unassign") => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
<Button
@@ -96,7 +105,7 @@ function AssignmentActions({
onClick={() => onChange("assign_current_user")}
data-testid="azure-work-item-assign-current-user"
>
{saving ? "Updating…" : "Assign to me"}
{saving ? t("azuredevops:updating") : t("azuredevops:assignToMe")}
</Button>
{assignedTo && (
<Button
@@ -108,7 +117,7 @@ function AssignmentActions({
onClick={() => onChange("unassign")}
data-testid="azure-work-item-unassign"
>
Unassign
{t("azuredevops:unassign")}
</Button>
)}
</div>
@@ -128,6 +137,7 @@ function BoardActions({
columnDone?: boolean,
) => Promise<AzureDevOpsBoardWorkItem | undefined>;
}) {
const { t } = useTranslation();
const [currentItem, setCurrentItem] = useState(context.item);
const [columnId, setColumnId] = useState(context.item.columnId);
const [columnDone, setColumnDone] = useState(context.item.columnDone);
@@ -139,7 +149,7 @@ function BoardActions({
const selected = context.board.columns.find((column) => column.id === columnId);
return (
<div className="space-y-2" data-testid="azure-work-item-board-actions">
<Label htmlFor="azure-work-item-column">Move to column</Label>
<Label htmlFor="azure-work-item-column">{t("azuredevops:moveToColumn")}</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Select value={columnId} onValueChange={setColumnId}>
<SelectTrigger
@@ -166,8 +176,8 @@ function BoardActions({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">In progress</SelectItem>
<SelectItem value="true">Done</SelectItem>
<SelectItem value="false">{t("azuredevops:inProgress")}</SelectItem>
<SelectItem value="true">{t("azuredevops:done")}</SelectItem>
</SelectContent>
</Select>
)}
@@ -183,7 +193,7 @@ function BoardActions({
})
}
>
Move
{t("azuredevops:move")}
</Button>
</div>
</div>
@@ -199,10 +209,11 @@ function QuickActions({
item: AzureDevOpsWorkItem;
onStartTask?: (item: AzureDevOpsWorkItem, action?: AzureDevOpsActionPreset) => void;
}) {
const { t } = useTranslation();
if (!onStartTask || actions.length === 0) return null;
return (
<div className="space-y-2" data-testid="azure-work-item-quick-actions">
<Label>Task actions</Label>
<Label>{t("azuredevops:taskActions")}</Label>
<div className="flex flex-wrap gap-2">
{actions.map((action) => (
<Button
@@ -233,13 +244,14 @@ function DetailBody({
quickActions: AzureDevOpsActionPreset[];
onStartTask?: (item: AzureDevOpsWorkItem, action?: AzureDevOpsActionPreset) => void;
}) {
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const [mutationError, setMutationError] = useState<string | null>(null);
const item = state.item;
if (!item) {
return (
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
{state.loading ? "Loading work item…" : "Work item unavailable."}
{state.loading ? t("azuredevops:loadingWorkItem") : t("azuredevops:workItemUnavailable")}
</div>
);
}
@@ -274,7 +286,7 @@ function DetailBody({
className="min-h-11 cursor-pointer"
onClick={() => void state.refresh()}
>
<IconRefresh className="h-4 w-4" /> Retry
<IconRefresh className="h-4 w-4" /> {t("azuredevops:retry")}
</Button>
</AlertDescription>
</Alert>
@@ -285,7 +297,7 @@ function DetailBody({
</Alert>
)}
<div className="grid gap-3 sm:grid-cols-2">
{azureDevOpsDetailFields(item).map(([label, value]) => (
{azureDevOpsDetailFields(item, t).map(([label, value]) => (
<div key={label} className="rounded-md border p-3">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="break-words text-sm font-medium">{value}</div>
@@ -302,12 +314,12 @@ function DetailBody({
</div>
)}
<section className="space-y-2">
<h3 className="text-sm font-semibold">Description</h3>
<h3 className="text-sm font-semibold">{t("azuredevops:description")}</h3>
<Description value={item.description ?? ""} />
</section>
{planningFields.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-semibold">Planning and effort</h3>
<h3 className="text-sm font-semibold">{t("azuredevops:planningAndEffort")}</h3>
<div className="grid gap-2 sm:grid-cols-2">
{planningFields.map((field) => (
<div key={field.referenceName} className="rounded-md bg-muted/40 p-3 text-sm">
@@ -319,7 +331,7 @@ function DetailBody({
</section>
)}
<section className="space-y-2">
<h3 className="text-sm font-semibold">Assignment</h3>
<h3 className="text-sm font-semibold">{t("azuredevops:assignment")}</h3>
<AssignmentActions
assignedTo={item.assignedTo}
saving={saving}
@@ -352,7 +364,7 @@ function DetailBody({
<QuickActions actions={quickActions} item={item} onStartTask={onStartTask} />
<section className="space-y-2" data-testid="azure-work-item-detail-comments">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">Discussion</h3>
<h3 className="text-sm font-semibold">{t("azuredevops:discussion")}</h3>
<Button
type="button"
size="sm"
@@ -361,14 +373,16 @@ function DetailBody({
onClick={() => void state.retryComments()}
disabled={state.commentsLoading}
>
<IconRefresh className="h-4 w-4" /> Retry
<IconRefresh className="h-4 w-4" /> {t("azuredevops:retry")}
</Button>
</div>
{state.commentsError && <p className="text-sm text-destructive">{state.commentsError}</p>}
{state.comments.map((comment) => (
<article key={comment.id} className="rounded-md border p-3">
<div className="mb-1 text-xs text-muted-foreground">
{comment.author.displayName || comment.author.uniqueName || "Azure DevOps"}
{comment.author.displayName ||
comment.author.uniqueName ||
t("azuredevops:fallbackAuthor")}
</div>
<div className="whitespace-pre-wrap text-sm">{comment.content}</div>
</article>
@@ -381,20 +395,22 @@ function DetailBody({
onClick={state.loadOlderComments}
disabled={state.commentsLoading}
>
{state.commentsLoading ? "Loading…" : "Load older discussion"}
{state.commentsLoading
? t("azuredevops:loading2")
: t("azuredevops:loadOlderDiscussion")}
</Button>
)}
{!state.commentsLoading && !state.commentsError && state.comments.length === 0 && (
<p className="text-sm text-muted-foreground">No discussion yet.</p>
<p className="text-sm text-muted-foreground">{t("azuredevops:noDiscussionYet")}</p>
)}
</section>
<section className="space-y-2">
<h3 className="text-sm font-semibold">Linked Kandev tasks</h3>
<h3 className="text-sm font-semibold">{t("azuredevops:linkedKandevTasks")}</h3>
{state.linkedTasksLoading && (
<p className="text-sm text-muted-foreground">Loading linked tasks…</p>
<p className="text-sm text-muted-foreground">{t("azuredevops:loadingLinkedTasks")}</p>
)}
{!state.linkedTasksLoading && state.linkedTasks.length === 0 && (
<p className="text-sm text-muted-foreground">No linked tasks.</p>
<p className="text-sm text-muted-foreground">{t("azuredevops:noLinkedTasks")}</p>
)}
{state.linkedTasks.map((task) => (
<div key={task.id} className="rounded-md border p-3 text-sm">
@@ -417,9 +433,10 @@ export function AzureDevOpsWorkItemDetail({
quickActions = [],
onStartTask,
}: Props) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const state = useAzureDevOpsWorkItemDetail(workspaceId, projectId, initialItem, open);
const title = state.item?.title ?? initialItem?.title ?? "Work item";
const title = state.item?.title ?? initialItem?.title ?? t("azuredevops:fallbackWorkItem");
const header = (
<div className="flex items-start justify-between gap-3 border-b px-4 py-4 sm:px-6">
<div className="min-w-0">
@@ -443,7 +460,7 @@ export function AzureDevOpsWorkItemDetail({
href={state.item?.webUrl ?? initialItem?.webUrl}
target="_blank"
rel="noreferrer"
aria-label="Open work item in Azure DevOps"
aria-label={t("azuredevops:openWorkItemInAzureDevops2")}
>
<IconExternalLink className="h-4 w-4" />
</a>
@@ -454,7 +471,7 @@ export function AzureDevOpsWorkItemDetail({
variant="ghost"
size="icon"
className="min-h-11 min-w-11 cursor-pointer"
aria-label="Close work item details"
aria-label={t("azuredevops:closeWorkItemDetails")}
data-testid="azure-work-item-detail-close"
onClick={() => onOpenChange(false)}
>
@@ -491,7 +508,7 @@ export function AzureDevOpsWorkItemDetail({
<DialogContent className="flex max-h-[90dvh] w-[min(900px,95vw)] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>Azure DevOps work-item details</DialogDescription>
<DialogDescription>{t("azuredevops:azureDevopsWorkItemDetails")}</DialogDescription>
</DialogHeader>
<div data-testid={open ? "azure-work-item-detail" : undefined} className="contents">
{header}
@@ -3,6 +3,7 @@
import Link from "@/components/routing/app-link";
import { IconLockExclamation } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useTranslation } from "react-i18next";
// Drops support URLs upstream APIs sometimes inline into error response
// bodies — they're noise once the user has a clear CTA. Shared because both
@@ -43,17 +44,20 @@ export function IntegrationAuthErrorMessage({
authErrorBody,
compact,
}: IntegrationAuthErrorMessageProps) {
const { t } = useTranslation();
const isAuth = isAuthError(error);
if (compact) {
return (
<div className="flex items-center gap-3 text-sm">
<span className={isAuth ? "text-muted-foreground" : "text-destructive"}>
{isAuth ? `${name} authentication required.` : cleanIntegrationErrorMessage(error)}
{isAuth
? t("integrations:authRequiredInline", { name })
: cleanIntegrationErrorMessage(error)}
</span>
{isAuth && (
<Button asChild size="sm" variant="outline" className="cursor-pointer h-7 text-xs">
<Link href={reconnectHref}>Reconnect {name}</Link>
<Link href={reconnectHref}>{t("integrations:reconnectProvider", { name })}</Link>
</Button>
)}
</div>
@@ -66,11 +70,13 @@ export function IntegrationAuthErrorMessage({
<>
<IconLockExclamation className="h-10 w-10 mx-auto text-muted-foreground" />
<div className="space-y-1.5">
<h2 className="text-lg font-semibold">{name} authentication required</h2>
<h2 className="text-lg font-semibold">
{t("integrations:authRequiredHeading", { name })}
</h2>
<p className="text-sm text-muted-foreground">{authErrorBody}</p>
</div>
<Button asChild size="sm" className="cursor-pointer">
<Link href={reconnectHref}>Reconnect {name}</Link>
<Link href={reconnectHref}>{t("integrations:reconnectProvider", { name })}</Link>
</Button>
</>
) : (
+9 -7
View File
@@ -6,6 +6,7 @@ import type { JiraTicket } from "@/lib/types/jira";
import { JIRA_KEY_RE } from "./jira-ticket-common";
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { ValidatedPopover } from "@/components/integrations/validated-popover";
import { useTranslation } from "react-i18next";
type JiraImportBarProps = {
workspaceId: string | null;
@@ -14,6 +15,7 @@ type JiraImportBarProps = {
};
export function JiraImportBar({ workspaceId, disabled, onImport }: JiraImportBarProps) {
const { t } = useTranslation();
const available = useJiraAvailable(workspaceId);
if (!available || !workspaceId) return null;
@@ -21,19 +23,19 @@ export function JiraImportBar({ workspaceId, disabled, onImport }: JiraImportBar
<ValidatedPopover
triggerStyle="ghost-icon"
triggerIcon={<IconTicket className="h-4 w-4" />}
triggerAriaLabel="Import from Jira"
triggerAriaLabel={t("jira:importFromJira")}
triggerDisabled={disabled}
testIdPrefix="jira-import"
tooltip="Import from Jira ticket URL or key"
tooltip={t("jira:importFromJiraTicketUrlOr")}
align="start"
headline="Import Jira ticket"
placeholder="PROJ-123 or paste ticket URL"
headline={t("jira:importJiraTicket")}
placeholder={t("jira:proj123OrPasteTicketUrl")}
extractKey={(raw) => raw.toUpperCase().match(JIRA_KEY_RE)?.[0] ?? null}
validationHint="Paste a Jira ticket URL or key (PROJ-123)"
validationHint={t("jira:pasteAJiraTicketUrlOr")}
fetch={(key) => getJiraTicket(key, { workspaceId })}
onSuccess={(_key, ticket) => onImport(ticket)}
submitLabel="Import"
submittingLabel="Loading..."
submitLabel={t("jira:import")}
submittingLabel={t("jira:loading")}
/>
);
}
@@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { JiraTicketDialog } from "./jira-ticket-dialog";
import { extractJiraKey } from "./jira-ticket-common";
import { useTranslation } from "react-i18next";
export { extractJiraKey };
@@ -17,6 +18,7 @@ type JiraTicketButtonProps = {
// JiraTicketButton sits in the task top bar. It extracts a Jira key from the
// task title and opens a full ticket dialog on click.
export function JiraTicketButton({ workspaceId, taskTitle }: JiraTicketButtonProps) {
const { t } = useTranslation();
const ticketKey = extractJiraKey(taskTitle);
const [open, setOpen] = useState(false);
@@ -36,7 +38,9 @@ export function JiraTicketButton({ workspaceId, taskTitle }: JiraTicketButtonPro
<span className="text-xs font-medium">{ticketKey}</span>
</Button>
</TooltipTrigger>
<TooltipContent>Open Jira ticket {ticketKey}</TooltipContent>
<TooltipContent>
{t("jira:openJiraTicket")} {ticketKey}
</TooltipContent>
</Tooltip>
<JiraTicketDialog
open={open}
@@ -6,6 +6,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@kandev/ui/avatar";
import { getJiraTicket, transitionJiraTicket } from "@/lib/api/domains/jira-api";
import type { JiraStatusCategory, JiraTicket } from "@/lib/types/jira";
import { IntegrationAuthErrorMessage } from "@/components/integrations/auth-error-message";
import { useTranslation } from "react-i18next";
// Matches PROJECT-123 anywhere in the string. Jira keys start with letters and
// include an uppercase prefix, followed by a dash and one or more digits.
@@ -40,7 +41,8 @@ export function formatRelative(iso: string | undefined): string {
}
export function PersonCell({ name, avatar }: { name?: string; avatar?: string }) {
if (!name) return <span className="text-muted-foreground">Unassigned</span>;
const { t } = useTranslation();
if (!name) return <span className="text-muted-foreground">{t("jira:unassigned")}</span>;
return (
<>
<Avatar size="sm" className="size-5">
@@ -160,13 +162,14 @@ type JiraErrorMessageProps = {
};
export function JiraErrorMessage({ error, compact }: JiraErrorMessageProps) {
const { t } = useTranslation();
return (
<IntegrationAuthErrorMessage
error={error}
name="Jira"
reconnectHref="/settings/integrations/jira"
isAuthError={isJiraAuthError}
authErrorBody="Your Jira session expired or needs step-up authentication. Reconnect to view this ticket."
authErrorBody={t("jira:yourJiraSessionExpiredOrNeeds")}
compact={compact}
/>
);
+29 -15
View File
@@ -21,6 +21,7 @@ import {
type TicketState,
} from "./jira-ticket-common";
import type { JiraTaskPreset } from "./my-jira/presets";
import { useTranslation } from "react-i18next";
type JiraTicketDialogProps = {
open: boolean;
@@ -35,8 +36,12 @@ type JiraTicketDialogProps = {
onStartTask?: (ticket: JiraTicket, preset: JiraTaskPreset) => void;
};
function dialogTitle(ticket: JiraTicket | null, ticketKey: string | null | undefined): string {
return ticket?.summary ?? ticketKey ?? "Jira ticket";
function dialogTitle(
ticket: JiraTicket | null,
ticketKey: string | null | undefined,
t: (key: string, values?: Record<string, unknown>) => string,
): string {
return ticket?.summary ?? ticketKey ?? t("jira:fallbackTicket");
}
function effectiveTicketKey(
@@ -55,6 +60,7 @@ export function JiraTicketDialog({
presets,
onStartTask,
}: JiraTicketDialogProps) {
const { t } = useTranslation();
const enabled = open && !!workspaceId && !!ticketKey;
const state = useTicketState(workspaceId ?? "", ticketKey ?? "", enabled);
const ticket = state.ticket ?? initialTicket ?? null;
@@ -65,7 +71,7 @@ export function JiraTicketDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[min(1280px,95vw)] w-[95vw] max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0 sm:rounded-lg">
<DialogTitle className="sr-only">{dialogTitle(ticket, ticketKey)}</DialogTitle>
<DialogTitle className="sr-only">{dialogTitle(ticket, ticketKey, t)}</DialogTitle>
{errorOnly ? (
<div className="flex-1 flex items-center justify-center px-8 py-16">
<JiraErrorMessage error={state.error ?? ""} />
@@ -101,6 +107,7 @@ function DialogBody({
state: TicketState;
ticketKey: string;
}) {
const { t } = useTranslation();
return (
<div className="flex-1 overflow-y-auto">
{state.error && ticket && (
@@ -109,7 +116,9 @@ function DialogBody({
</div>
)}
{!ticket && state.loading && (
<div className="text-sm text-muted-foreground py-16 text-center">Loading ticket…</div>
<div className="text-sm text-muted-foreground py-16 text-center">
{t("jira:loadingTicket")}
</div>
)}
{ticket && <TicketBody ticket={ticket} state={state} ticketKey={ticketKey} />}
</div>
@@ -125,13 +134,14 @@ function TicketFooter({
presets: JiraTaskPreset[];
onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-end gap-2 px-6 py-3 border-t bg-muted/20 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="default" className="cursor-pointer gap-1.5">
<IconPlus className="h-4 w-4" />
Start task
{t("jira:startTask")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
@@ -164,6 +174,7 @@ type TopBarProps = {
};
function TicketTopBar({ ticket, loading, onRefresh }: TopBarProps) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-end gap-1 pl-4 pr-12 py-2 border-b shrink-0">
{ticket?.url && (
@@ -172,7 +183,7 @@ function TicketTopBar({ ticket, loading, onRefresh }: TopBarProps) {
variant="ghost"
size="icon-sm"
className="cursor-pointer"
title="Open in Atlassian"
title={t("jira:openInAtlassian")}
>
<a href={ticket.url} target="_blank" rel="noreferrer">
<IconExternalLink className="h-4 w-4" />
@@ -185,7 +196,7 @@ function TicketTopBar({ ticket, loading, onRefresh }: TopBarProps) {
className="cursor-pointer"
onClick={onRefresh}
disabled={loading}
title="Refresh"
title={t("jira:refresh")}
>
<IconRefresh className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
@@ -244,14 +255,15 @@ function SectionHeading({ children }: { children: React.ReactNode }) {
}
function DescriptionSection({ description }: { description: string }) {
const { t } = useTranslation();
return (
<section className="space-y-2">
<SectionHeading>Description</SectionHeading>
<SectionHeading>{t("jira:description")}</SectionHeading>
<div className="rounded-md border bg-muted/30 px-4 py-3">
{description ? (
<div className="text-sm whitespace-pre-wrap leading-relaxed">{description}</div>
) : (
<div className="text-sm text-muted-foreground italic">No description.</div>
<div className="text-sm text-muted-foreground italic">{t("jira:noDescription")}</div>
)}
</div>
</section>
@@ -301,22 +313,23 @@ function StatusBlock({ ticket, transitions, pending, onTransition }: StatusBlock
}
function DetailsCard({ ticket }: { ticket: JiraTicket }) {
const { t } = useTranslation();
return (
<section className="rounded-md border bg-background overflow-hidden">
<div className="px-4 py-3 border-b bg-muted/30">
<SectionHeading>Details</SectionHeading>
<SectionHeading>{t("jira:details")}</SectionHeading>
</div>
<div className="px-4 py-3 space-y-3 text-sm">
<DetailRow label="Assignee">
<DetailRow label={t("jira:assignee")}>
<PersonCell name={ticket.assigneeName} avatar={ticket.assigneeAvatar} />
</DetailRow>
<DetailRow label="Reporter">
<DetailRow label={t("jira:reporter")}>
<PersonCell name={ticket.reporterName} avatar={ticket.reporterAvatar} />
</DetailRow>
<DetailRow label="Priority">
<DetailRow label={t("jira:placeholderPriority")}>
<IconLabel icon={ticket.priorityIcon} label={ticket.priority} />
</DetailRow>
<DetailRow label="Project">
<DetailRow label={t("jira:project")}>
<span className="font-mono text-xs">{ticket.projectKey}</span>
</DetailRow>
</div>
@@ -334,11 +347,12 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
}
function MetaFooter({ ticket }: { ticket: JiraTicket }) {
const { t } = useTranslation();
const updated = formatRelative(ticket.updated);
if (!updated) return null;
return (
<div className="text-xs text-muted-foreground px-1" title={ticket.updated}>
Updated {updated}
{t("jira:updatedTime", { time: updated })}
</div>
);
}
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import type { JiraProject, JiraStatus } from "@/lib/types/jira";
import { AssigneePill, ProjectPill, StatusPill } from "./filter-pills";
@@ -23,6 +24,7 @@ export function FilterBar({
hasActiveFilters,
onClear,
}: FilterBarProps) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2 flex-wrap px-6 py-2.5 border-b shrink-0 bg-muted/20">
<ProjectPill
@@ -47,7 +49,7 @@ export function FilterBar({
onClick={onClear}
className="cursor-pointer h-7 text-xs ml-1"
>
Clear filters
{t("jira:clearFilters")}
</Button>
)}
</div>
@@ -8,8 +8,18 @@ import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import type { JiraProject, JiraStatus } from "@/lib/types/jira";
import { type AssigneeFilter } from "./filter-model";
import { useTranslation } from "react-i18next";
type PillShellProps = {
/**
* Stable pill identifier, never display copy. The `data-testid` was built as
* `jira-filter-pill-${label.toLowerCase()}`, so translating the label moved
* the selector with it — and `e2e/tests/integrations/jira-default-project.spec.ts`
* selects `jira-filter-pill-project` and `-status` by name, so this was a live
* break, not a hypothetical one. apps/web/AGENTS.md: a `data-testid` is never
* translated, and deriving one from copy translates it by the back door.
*/
id: string;
label: string;
summary: string | null;
active: boolean;
@@ -23,21 +33,23 @@ type PillShellProps = {
};
function DisabledPill({
id,
label,
summary,
disabledHint,
}: Pick<PillShellProps, "label" | "summary" | "disabledHint">) {
}: Pick<PillShellProps, "id" | "label" | "summary" | "disabledHint">) {
const { t } = useTranslation();
// Radix Tooltip on a focusable wrapper (per apps/web/AGENTS.md) so keyboard
// and screen-reader users reach the disabled pill and learn why it's off,
// instead of the reason being mouse-hover-only via a raw title attribute.
const pill = (
<div
data-testid={`jira-filter-pill-${label.toLowerCase()}`}
data-testid={`jira-filter-pill-${id}`}
data-disabled="true"
tabIndex={0}
role="button"
aria-disabled="true"
aria-label={disabledHint ?? `${label} filter disabled`}
aria-label={disabledHint ?? t("jira:filterDisabled", { label })}
className="inline-flex items-stretch rounded-md border text-xs overflow-hidden bg-background opacity-50"
>
<span className="px-2.5 py-1.5 flex items-center gap-1.5 cursor-not-allowed">
@@ -57,6 +69,7 @@ function DisabledPill({
}
function PillShell({
id,
label,
summary,
active,
@@ -65,9 +78,10 @@ function PillShell({
disabledHint,
children,
}: PillShellProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
if (disabled) {
return <DisabledPill label={label} summary={summary} disabledHint={disabledHint} />;
return <DisabledPill id={id} label={label} summary={summary} disabledHint={disabledHint} />;
}
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -79,7 +93,7 @@ function PillShell({
<PopoverTrigger asChild>
<button
type="button"
data-testid={`jira-filter-pill-${label.toLowerCase()}`}
data-testid={`jira-filter-pill-${id}`}
className="cursor-pointer px-2.5 py-1.5 flex items-center gap-1.5 hover:bg-muted/50 transition-colors"
>
<span className="text-muted-foreground">{label}</span>
@@ -92,7 +106,7 @@ function PillShell({
type="button"
onClick={onClear}
className="cursor-pointer px-1.5 border-l hover:bg-muted flex items-center"
title={`Clear ${label.toLowerCase()}`}
title={t("jira:clear", { lowerCase: label.toLowerCase() })}
>
<IconX className="h-3 w-3 text-muted-foreground" />
</button>
@@ -117,6 +131,7 @@ type ProjectPillProps = {
};
export function ProjectPill({ projects, value, onChange }: ProjectPillProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -134,7 +149,8 @@ export function ProjectPill({ projects, value, onChange }: ProjectPillProps) {
return (
<PillShell
label="Project"
id="project"
label={t("jira:project")}
summary={value.length > 0 ? joinSummary(value, 2) : null}
active={value.length > 0}
onClear={() => onChange([])}
@@ -143,13 +159,13 @@ export function ProjectPill({ projects, value, onChange }: ProjectPillProps) {
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search projects…"
placeholder={t("jira:searchProjects")}
className="h-7 text-xs"
/>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{filtered.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">No projects match.</div>
<div className="px-3 py-2 text-xs text-muted-foreground">{t("jira:noProjectsMatch")}</div>
)}
{filtered.map((p) => (
<label
@@ -180,10 +196,8 @@ type StatusPillProps = {
hasProjectSelected: boolean;
};
const NO_PROJECT_HINT = "Select a project to filter by status";
const NO_STATUSES_HINT = "No statuses available for the selected project";
export function StatusPill({ options, value, onChange, hasProjectSelected }: StatusPillProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -200,24 +214,27 @@ export function StatusPill({ options, value, onChange, hasProjectSelected }: Sta
return (
<PillShell
label="Status"
id="status"
label={t("common:status")}
summary={summary}
active={value.length > 0}
onClear={() => onChange([])}
disabled={options.length === 0}
disabledHint={hasProjectSelected ? NO_STATUSES_HINT : NO_PROJECT_HINT}
disabledHint={
hasProjectSelected ? t("jira:noStatusesForProject") : t("jira:selectProjectToFilterStatus")
}
>
<div className="p-2 border-b">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search statuses…"
placeholder={t("jira:searchStatuses")}
className="h-7 text-xs"
/>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{filtered.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">No statuses match.</div>
<div className="px-3 py-2 text-xs text-muted-foreground">{t("jira:noStatusesMatch")}</div>
)}
{filtered.map((o) => (
<label
@@ -239,18 +256,22 @@ type AssigneePillProps = {
onChange: (a: AssigneeFilter) => void;
};
const ASSIGNEE_OPTIONS: { value: AssigneeFilter; label: string }[] = [
{ value: "anyone", label: "Anyone" },
{ value: "me", label: "Me" },
{ value: "unassigned", label: "Unassigned" },
/** `value` is the persisted AssigneeFilter; only the catalog key is copy. */
const ASSIGNEE_OPTIONS: { value: AssigneeFilter; labelKey: string }[] = [
{ value: "anyone", labelKey: "jira:assigneeAnyone" },
{ value: "me", labelKey: "jira:assigneeMe" },
{ value: "unassigned", labelKey: "jira:assigneeUnassigned" },
];
export function AssigneePill({ value, onChange }: AssigneePillProps) {
const { t } = useTranslation();
const active = value !== "anyone";
const summary = active ? (ASSIGNEE_OPTIONS.find((o) => o.value === value)?.label ?? null) : null;
const activeOption = active ? ASSIGNEE_OPTIONS.find((o) => o.value === value) : undefined;
const summary = activeOption ? t(activeOption.labelKey) : null;
return (
<PillShell
label="Assignee"
id="assignee"
label={t("jira:assignee")}
summary={summary}
active={active}
onClear={() => onChange("anyone")}
@@ -265,7 +286,7 @@ export function AssigneePill({ value, onChange }: AssigneePillProps) {
value === o.value ? "font-medium" : ""
}`}
>
{o.label}
{t(o.labelKey)}
</button>
))}
</div>
@@ -4,6 +4,7 @@ import { useState } from "react";
import { IconInfoCircle } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Textarea } from "@kandev/ui/textarea";
import { useTranslation } from "react-i18next";
type JqlEditorProps = {
composedJql: string;
@@ -15,6 +16,7 @@ type JqlEditorProps = {
// Inline editor that exposes the JQL being sent to Jira. Users can tweak it
// for one-off advanced queries; pressing Reset returns to pill-driven mode.
export function JqlEditor({ composedJql, customJql, onApply, onReset }: JqlEditorProps) {
const { t } = useTranslation();
const source = customJql ?? composedJql;
const [draft, setDraft] = useState(source);
const [prevSource, setPrevSource] = useState(source);
@@ -33,9 +35,9 @@ export function JqlEditor({ composedJql, customJql, onApply, onReset }: JqlEdito
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<IconInfoCircle className="h-3.5 w-3.5" />
{overriding ? (
<span>Custom JQL override — pill filters are paused.</span>
<span>{t("jira:customJqlOverridePillFiltersAre")}</span>
) : (
<span>JQL is generated from the pills above. Edit to override.</span>
<span>{t("jira:jqlIsGeneratedFromThePills")}</span>
)}
</div>
<Textarea
@@ -51,7 +53,7 @@ export function JqlEditor({ composedJql, customJql, onApply, onReset }: JqlEdito
disabled={!dirty || !draft.trim()}
className="cursor-pointer h-7 text-xs"
>
Apply
{t("jira:apply")}
</Button>
{overriding && (
<Button
@@ -60,7 +62,7 @@ export function JqlEditor({ composedJql, customJql, onApply, onReset }: JqlEdito
onClick={onReset}
className="cursor-pointer h-7 text-xs"
>
Reset to pills
{t("jira:resetToPills")}
</Button>
)}
</div>
@@ -22,11 +22,13 @@ import {
} from "@kandev/ui/dropdown-menu";
import type { SavedView } from "./use-saved-views";
import type { SortKey } from "./filter-model";
import { useTranslation } from "react-i18next";
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
{ value: "updated", label: "Updated" },
{ value: "created", label: "Created" },
{ value: "priority", label: "Priority" },
/** `value` is the persisted SortKey; only the catalog key is copy. */
const SORT_OPTIONS: { value: SortKey; labelKey: string }[] = [
{ value: "updated", labelKey: "jira:sortUpdated" },
{ value: "created", labelKey: "jira:sortCreated" },
{ value: "priority", labelKey: "jira:sortPriority" },
];
type ListToolbarProps = {
@@ -62,8 +64,10 @@ export function ListToolbar({
showJqlEditor,
onToggleJqlEditor,
}: ListToolbarProps) {
const { t } = useTranslation();
const activeView = views.find((v) => v.id === activeViewId);
const sortLabel = SORT_OPTIONS.find((o) => o.value === sort)?.label ?? "Updated";
const sortOption = SORT_OPTIONS.find((o) => o.value === sort);
const sortLabel = t(sortOption?.labelKey ?? "jira:sortUpdated");
return (
<div className="flex items-center gap-2 px-6 py-2.5 border-b shrink-0 flex-wrap">
<SearchInput value={searchText} onChange={onSearchChange} />
@@ -77,7 +81,7 @@ export function ListToolbar({
<SaveViewButton onSave={onSaveView} />
<div className="ml-auto flex items-center gap-1">
<span className="text-xs text-muted-foreground tabular-nums mr-2">
{loading ? "Loading…" : `${count} ticket${count === 1 ? "" : "s"} on this page`}
{loading ? t("jira:loading2") : t("jira:ticketOnThisPage", { count })}
</span>
<SortDropdown sort={sort} sortLabel={sortLabel} onSortChange={onSortChange} />
<Button
@@ -86,7 +90,7 @@ export function ListToolbar({
onClick={onRefresh}
disabled={loading}
className="cursor-pointer h-7 w-7"
title="Refresh"
title={t("jira:refresh")}
>
<IconRefresh className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</Button>
@@ -95,7 +99,7 @@ export function ListToolbar({
size="sm"
onClick={onToggleJqlEditor}
className="cursor-pointer h-7 text-xs gap-1.5"
title="Toggle raw JQL editor"
title={t("jira:toggleRawJqlEditor")}
>
<IconCode className="h-3.5 w-3.5" />
JQL
@@ -114,12 +118,13 @@ function SortDropdown({
sortLabel: string;
onSortChange: (sort: SortKey) => void;
}) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="cursor-pointer h-7 text-xs gap-1.5">
<IconArrowsSort className="h-3.5 w-3.5" />
Sort: {sortLabel}
{t("jira:sortLabelled", { label: sortLabel })}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
@@ -130,7 +135,7 @@ function SortDropdown({
onCheckedChange={() => onSortChange(o.value)}
className="cursor-pointer"
>
{o.label}
{t(o.labelKey)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
@@ -139,13 +144,14 @@ function SortDropdown({
}
function SearchInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { t } = useTranslation();
return (
<div className="relative flex-1 max-w-md min-w-[200px]">
<IconSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search ticket key or text…"
placeholder={t("jira:searchTicketKeyOrText")}
className="h-8 text-xs pl-8"
/>
</div>
@@ -165,6 +171,7 @@ function ViewsDropdown({
onDelete: (id: string) => void;
activeName: string | undefined;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const builtin = views.filter((v) => v.builtin);
const custom = views.filter((v) => !v.builtin);
@@ -173,12 +180,12 @@ function ViewsDropdown({
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="cursor-pointer h-8 text-xs gap-1.5">
<IconBookmark className="h-3.5 w-3.5" />
{activeName ?? "No view"}
{activeName ?? t("jira:noView")}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-60 p-0">
<ViewsGroup
label="Built-in"
label={t("jira:builtIn")}
views={builtin}
activeViewId={activeViewId}
onSelect={(id) => {
@@ -191,7 +198,7 @@ function ViewsDropdown({
<>
<div className="border-t" />
<ViewsGroup
label="Saved"
label={t("jira:saved")}
views={custom}
activeViewId={activeViewId}
onSelect={(id) => {
@@ -249,6 +256,7 @@ function ViewRow({
onSelect: (id: string) => void;
onDelete: (id: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="group flex items-center px-2">
<button
@@ -267,7 +275,7 @@ function ViewRow({
onDelete(view.id);
}}
className="cursor-pointer opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-muted"
title="Delete view"
title={t("jira:deleteView")}
>
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
</button>
@@ -277,6 +285,7 @@ function ViewRow({
}
function SaveViewButton({ onSave }: { onSave: (name: string) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const submit = () => {
@@ -293,14 +302,14 @@ function SaveViewButton({ onSave }: { onSave: (name: string) => void }) {
variant="ghost"
size="sm"
className="cursor-pointer h-8 text-xs gap-1.5"
title="Save current filters as a view"
title={t("jira:saveCurrentFiltersAsAView")}
>
<IconPlus className="h-3.5 w-3.5" />
Save view
{t("jira:saveView")}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-3 space-y-2">
<div className="text-xs font-semibold">Save current filters as…</div>
<div className="text-xs font-semibold">{t("jira:saveCurrentFiltersAs")}</div>
<Input
autoFocus
value={name}
@@ -308,7 +317,7 @@ function SaveViewButton({ onSave }: { onSave: (name: string) => void }) {
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
placeholder="My open bugs"
placeholder={t("jira:myOpenBugs")}
className="h-8 text-xs"
/>
<div className="flex justify-end gap-1">
@@ -318,7 +327,7 @@ function SaveViewButton({ onSave }: { onSave: (name: string) => void }) {
onClick={() => setOpen(false)}
className="cursor-pointer h-7 text-xs"
>
Cancel
{t("common:cancel")}
</Button>
<Button
size="sm"
@@ -326,7 +335,7 @@ function SaveViewButton({ onSave }: { onSave: (name: string) => void }) {
disabled={!name.trim()}
className="cursor-pointer h-7 text-xs"
>
Save
{t("common:save")}
</Button>
</div>
</PopoverContent>
@@ -7,6 +7,7 @@ import {
PaginationNext,
PaginationPrevious,
} from "@kandev/ui/pagination";
import { useTranslation } from "react-i18next";
type ResultsPaginationProps = {
page: number;
@@ -27,6 +28,7 @@ export function ResultsPagination({
onNext,
onPrev,
}: ResultsPaginationProps) {
const { t } = useTranslation();
if (page === 1 && isLast) return null;
const start = (page - 1) * pageSize + 1;
const end = (page - 1) * pageSize + itemCount;
@@ -36,7 +38,7 @@ export function ResultsPagination({
return (
<div className="flex items-center justify-between px-6 py-3 border-t shrink-0">
<div className="text-xs text-muted-foreground tabular-nums">
{itemCount === 0 ? "No results" : `${start}–${end}`}
{itemCount === 0 ? t("jira:noResults") : `${start}–${end}`}
</div>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
@@ -52,7 +54,7 @@ export function ResultsPagination({
/>
</PaginationItem>
<PaginationItem>
<span className="px-3 text-sm tabular-nums">Page {page}</span>
<span className="px-3 text-sm tabular-nums">{t("jira:pageNumber", { page })}</span>
</PaginationItem>
<PaginationItem>
<PaginationNext
@@ -13,6 +13,7 @@ import {
import type { JiraTicket } from "@/lib/types/jira";
import { formatRelative, statusBadgeClass } from "@/components/jira/jira-ticket-common";
import type { JiraTaskPreset } from "./presets";
import { useTranslation } from "react-i18next";
type TicketRowProps = {
ticket: JiraTicket;
@@ -22,8 +23,9 @@ type TicketRowProps = {
};
function AssigneeCell({ ticket }: { ticket: JiraTicket }) {
const { t } = useTranslation();
if (!ticket.assigneeName) {
return <span className="text-xs text-muted-foreground">Unassigned</span>;
return <span className="text-xs text-muted-foreground">{t("jira:unassigned")}</span>;
}
return (
<div className="flex items-center gap-1.5 min-w-0">
@@ -47,12 +49,13 @@ function StartTaskMenu({
presets: JiraTaskPreset[];
onStartTask: TicketRowProps["onStartTask"];
}) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer h-7 px-2 gap-1 text-xs">
<IconPlus className="h-3.5 w-3.5" />
Start task
{t("jira:startTask")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
@@ -78,6 +81,7 @@ function StartTaskMenu({
}
export function TicketRow({ ticket, presets, onStartTask, onOpen }: TicketRowProps) {
const { t } = useTranslation();
const relative = formatRelative(ticket.updated);
return (
<div className="flex items-start gap-3 py-3 border-b last:border-b-0">
@@ -85,7 +89,7 @@ export function TicketRow({ ticket, presets, onStartTask, onOpen }: TicketRowPro
type="button"
onClick={() => onOpen?.(ticket)}
className="flex-1 min-w-0 space-y-1 text-left cursor-pointer rounded -mx-2 px-2 py-1 hover:bg-muted/50 transition-colors"
title="Open ticket details"
title={t("jira:openTicketDetails")}
>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{ticket.key}</span>
@@ -102,12 +106,16 @@ export function TicketRow({ ticket, presets, onStartTask, onOpen }: TicketRowPro
</Badge>
)}
<AssigneeCell ticket={ticket} />
{relative && <span className="text-xs text-muted-foreground">· updated {relative}</span>}
{relative && (
<span className="text-xs text-muted-foreground">
{t("jira:updatedAgo", { time: relative })}
</span>
)}
</div>
</button>
<div className="flex items-center gap-1 shrink-0">
<Button asChild variant="ghost" size="icon-sm" className="cursor-pointer">
<a href={ticket.url} target="_blank" rel="noreferrer" title="Open in Atlassian">
<a href={ticket.url} target="_blank" rel="noreferrer" title={t("jira:openInAtlassian")}>
<IconExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
@@ -6,6 +6,7 @@ import type { LinearIssue } from "@/lib/types/linear";
import { LINEAR_KEY_RE } from "./linear-issue-common";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import { ValidatedPopover } from "@/components/integrations/validated-popover";
import { useTranslation } from "react-i18next";
type LinearImportBarProps = {
workspaceId: string | null;
@@ -14,6 +15,7 @@ type LinearImportBarProps = {
};
export function LinearImportBar({ workspaceId, disabled, onImport }: LinearImportBarProps) {
const { t } = useTranslation();
const available = useLinearAvailable(workspaceId);
if (!available || !workspaceId) return null;
@@ -21,19 +23,19 @@ export function LinearImportBar({ workspaceId, disabled, onImport }: LinearImpor
<ValidatedPopover
triggerStyle="ghost-icon"
triggerIcon={<IconHexagon className="h-4 w-4" />}
triggerAriaLabel="Import from Linear"
triggerAriaLabel={t("linear:importFromLinear")}
triggerDisabled={disabled}
testIdPrefix="linear-import"
tooltip="Import from Linear issue URL or identifier"
tooltip={t("linear:importFromLinearIssueUrlOr")}
align="start"
headline="Import Linear issue"
placeholder="ENG-123 or paste issue URL"
headline={t("linear:importLinearIssue")}
placeholder={t("linear:eng123OrPasteIssueUrl")}
extractKey={(raw) => raw.toUpperCase().match(LINEAR_KEY_RE)?.[0] ?? null}
validationHint="Paste a Linear issue URL or identifier (ENG-123)"
validationHint={t("linear:pasteALinearIssueUrlOr")}
fetch={(key) => getLinearIssue(key, { workspaceId })}
onSuccess={(_key, issue) => onImport(issue)}
submitLabel="Import"
submittingLabel="Loading..."
submitLabel={t("linear:import")}
submittingLabel={t("linear:loading")}
/>
);
}
@@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { LinearIssueDialog } from "./linear-issue-dialog";
import { extractLinearKey } from "./linear-issue-common";
import { useTranslation } from "react-i18next";
export { extractLinearKey };
@@ -17,6 +18,7 @@ type LinearIssueButtonProps = {
// LinearIssueButton sits in the task top bar. It extracts a Linear identifier
// from the task title and opens a full issue dialog on click.
export function LinearIssueButton({ workspaceId, taskTitle }: LinearIssueButtonProps) {
const { t } = useTranslation();
const identifier = extractLinearKey(taskTitle);
const [open, setOpen] = useState(false);
@@ -36,7 +38,9 @@ export function LinearIssueButton({ workspaceId, taskTitle }: LinearIssueButtonP
<span className="text-xs font-medium">{identifier}</span>
</Button>
</TooltipTrigger>
<TooltipContent>Open Linear issue {identifier}</TooltipContent>
<TooltipContent>
{t("linear:openLinearIssue")} {identifier}
</TooltipContent>
</Tooltip>
<LinearIssueDialog
open={open}
@@ -6,6 +6,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@kandev/ui/avatar";
import { getLinearIssue, setLinearIssueState } from "@/lib/api/domains/linear-api";
import type { LinearIssue, LinearStateCategory } from "@/lib/types/linear";
import { IntegrationAuthErrorMessage } from "@/components/integrations/auth-error-message";
import { useTranslation } from "react-i18next";
// Matches Linear identifiers like ENG-123. Linear team keys are always
// uppercase and 1+ chars; we require a leading capital letter to avoid catching
@@ -42,7 +43,8 @@ export function formatRelative(iso: string | undefined): string {
}
export function PersonCell({ name, avatar }: { name?: string; avatar?: string }) {
if (!name) return <span className="text-muted-foreground">Unassigned</span>;
const { t } = useTranslation();
if (!name) return <span className="text-muted-foreground">{t("linear:unassigned")}</span>;
return (
<>
<Avatar size="sm" className="size-5">
@@ -163,13 +165,14 @@ type LinearErrorMessageProps = {
};
export function LinearErrorMessage({ error, compact }: LinearErrorMessageProps) {
const { t } = useTranslation();
return (
<IntegrationAuthErrorMessage
error={error}
name="Linear"
reconnectHref="/settings/integrations/linear"
isAuthError={isLinearAuthError}
authErrorBody="Your Linear API key is invalid or has been revoked. Reconnect to view this issue."
authErrorBody={t("linear:yourLinearApiKeyIsInvalid")}
compact={compact}
/>
);
@@ -14,6 +14,7 @@ import {
useIssueState,
type IssueState,
} from "./linear-issue-common";
import { useTranslation } from "react-i18next";
type LinearIssueDialogProps = {
open: boolean;
@@ -26,8 +27,12 @@ type LinearIssueDialogProps = {
onStartTask?: (issue: LinearIssue) => void;
};
function dialogTitle(issue: LinearIssue | null, identifier: string | null | undefined): string {
return issue?.title ?? identifier ?? "Linear issue";
function dialogTitle(
issue: LinearIssue | null,
identifier: string | null | undefined,
t: (key: string, values?: Record<string, unknown>) => string,
): string {
return issue?.title ?? identifier ?? t("linear:fallbackIssue");
}
function effectiveIdentifier(
@@ -45,6 +50,7 @@ export function LinearIssueDialog({
initialIssue,
onStartTask,
}: LinearIssueDialogProps) {
const { t } = useTranslation();
const enabled = open && !!workspaceId && !!identifier;
const state = useIssueState(workspaceId ?? "", identifier ?? "", enabled);
const issue = state.issue ?? initialIssue ?? null;
@@ -64,7 +70,7 @@ export function LinearIssueDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[min(1280px,95vw)] w-[95vw] max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0 sm:rounded-lg">
<DialogTitle className="sr-only">{dialogTitle(issue, identifier)}</DialogTitle>
<DialogTitle className="sr-only">{dialogTitle(issue, identifier, t)}</DialogTitle>
{errorOnly ? (
<div className="flex-1 flex items-center justify-center px-8 py-16">
<LinearErrorMessage error={state.error ?? ""} />
@@ -90,11 +96,12 @@ export function LinearIssueDialog({
}
function IssueFooter({ onStart }: { onStart: () => void }) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-end gap-2 px-6 py-3 border-t bg-muted/20 shrink-0">
<Button size="sm" variant="default" className="cursor-pointer gap-1.5" onClick={onStart}>
<IconPlus className="h-4 w-4" />
Start task
{t("linear:startTask")}
</Button>
</div>
);
@@ -109,6 +116,7 @@ function DialogBody({
state: IssueState;
identifier: string;
}) {
const { t } = useTranslation();
return (
<div className="flex-1 overflow-y-auto">
{state.error && issue && (
@@ -117,7 +125,9 @@ function DialogBody({
</div>
)}
{!issue && state.loading && (
<div className="text-sm text-muted-foreground py-16 text-center">Loading issue…</div>
<div className="text-sm text-muted-foreground py-16 text-center">
{t("linear:loadingIssue")}
</div>
)}
{issue && <IssueBody issue={issue} state={state} identifier={identifier} />}
</div>
@@ -131,6 +141,7 @@ type TopBarProps = {
};
function IssueTopBar({ issue, loading, onRefresh }: TopBarProps) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-end gap-1 pl-4 pr-12 py-2 border-b shrink-0">
{issue?.url && (
@@ -139,7 +150,7 @@ function IssueTopBar({ issue, loading, onRefresh }: TopBarProps) {
variant="ghost"
size="icon-sm"
className="cursor-pointer"
title="Open in Linear"
title={t("linear:openInLinear")}
>
<a href={issue.url} target="_blank" rel="noreferrer">
<IconExternalLink className="h-4 w-4" />
@@ -152,7 +163,7 @@ function IssueTopBar({ issue, loading, onRefresh }: TopBarProps) {
className="cursor-pointer"
onClick={onRefresh}
disabled={loading}
title="Refresh"
title={t("linear:refresh")}
>
<IconRefresh className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
@@ -211,14 +222,15 @@ function SectionHeading({ children }: { children: React.ReactNode }) {
}
function DescriptionSection({ description }: { description: string }) {
const { t } = useTranslation();
return (
<section className="space-y-2">
<SectionHeading>Description</SectionHeading>
<SectionHeading>{t("linear:description")}</SectionHeading>
<div className="rounded-md border bg-muted/30 px-4 py-3">
{description ? (
<div className="text-sm whitespace-pre-wrap leading-relaxed">{description}</div>
) : (
<div className="text-sm text-muted-foreground italic">No description.</div>
<div className="text-sm text-muted-foreground italic">{t("linear:noDescription")}</div>
)}
</div>
</section>
@@ -274,22 +286,23 @@ function StateBlock({ issue, states, pending, onChange }: StateBlockProps) {
}
function DetailsCard({ issue }: { issue: LinearIssue }) {
const { t } = useTranslation();
return (
<section className="rounded-md border bg-background overflow-hidden">
<div className="px-4 py-3 border-b bg-muted/30">
<SectionHeading>Details</SectionHeading>
<SectionHeading>{t("linear:details")}</SectionHeading>
</div>
<div className="px-4 py-3 space-y-3 text-sm">
<DetailRow label="Assignee">
<DetailRow label={t("linear:assignee")}>
<PersonCell name={issue.assigneeName} avatar={issue.assigneeIcon} />
</DetailRow>
<DetailRow label="Creator">
<DetailRow label={t("linear:creator")}>
<PersonCell name={issue.creatorName} avatar={issue.creatorIcon} />
</DetailRow>
<DetailRow label="Priority">
<DetailRow label={t("linear:priority")}>
<span className={priorityClass(issue.priority)}>{issue.priorityLabel || "—"}</span>
</DetailRow>
<DetailRow label="Team">
<DetailRow label={t("linear:team")}>
<span className="font-mono text-xs">{issue.teamKey}</span>
</DetailRow>
</div>
@@ -307,11 +320,12 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
}
function MetaFooter({ issue }: { issue: LinearIssue }) {
const { t } = useTranslation();
const updated = formatRelative(issue.updated);
if (!updated) return null;
return (
<div className="text-xs text-muted-foreground px-1" title={issue.updated}>
Updated {updated}
{t("linear:updatedTime", { time: updated })}
</div>
);
}
@@ -7,10 +7,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability";
import { useAppStore } from "@/components/state-provider";
import { SentryIssueDialog } from "./sentry-issue-dialog";
import { useTranslation } from "react-i18next";
// SentryIssueButton opens the browse/search dialog. It renders nothing when
// the Sentry integration is not available (toggle off or unauthenticated).
export function SentryIssueButton() {
const { t } = useTranslation();
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const available = useSentryAvailable(workspaceId);
const [open, setOpen] = useState(false);
@@ -31,7 +33,7 @@ export function SentryIssueButton() {
<span className="text-xs font-medium">Sentry</span>
</Button>
</TooltipTrigger>
<TooltipContent>Browse Sentry issues</TooltipContent>
<TooltipContent>{t("sentry:browseSentryIssues")}</TooltipContent>
</Tooltip>
<SentryIssueDialog open={open} onOpenChange={setOpen} workspaceId={workspaceId ?? ""} />
</>
@@ -4,6 +4,7 @@ import { formatDistanceToNow } from "date-fns";
import { Badge } from "@kandev/ui/badge";
import type { SentryIssue, SentryLevel, SentryStatus } from "@/lib/types/sentry";
import { IntegrationAuthErrorMessage } from "@/components/integrations/auth-error-message";
import { useTranslation } from "react-i18next";
// Sentry short IDs look like "PROJ-123" — alphanumeric uppercase project slug
// followed by a numeric counter. Underscores and hyphens are allowed in the
@@ -86,11 +87,20 @@ export function SentryIssueRow({ issue }: { issue: SentryIssue }) {
}
function SentryIssueMeta({ issue, lastSeen }: { issue: SentryIssue; lastSeen: string }) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{issue.count != null && issue.count !== "" && <span>events {issue.count}</span>}
{typeof issue.userCount === "number" && <span>users {issue.userCount}</span>}
{lastSeen && <span title={issue.lastSeen}>Last seen {lastSeen}</span>}
{issue.count != null && issue.count !== "" && (
<span>{t("sentry:eventsCount", { count: issue.count })}</span>
)}
{typeof issue.userCount === "number" && (
<span>{t("sentry:usersCount", { count: issue.userCount })}</span>
)}
{lastSeen && (
<span title={issue.lastSeen}>
{t("sentry:lastSeen")} {lastSeen}
</span>
)}
</div>
);
}
@@ -109,13 +119,14 @@ type SentryErrorMessageProps = {
};
export function SentryErrorMessage({ error, compact }: SentryErrorMessageProps) {
const { t } = useTranslation();
return (
<IntegrationAuthErrorMessage
error={error}
name="Sentry"
reconnectHref="/settings/integrations/sentry"
isAuthError={isSentryAuthError}
authErrorBody="Your Sentry auth token is invalid or has been revoked. Reconnect to view this issue."
authErrorBody={t("sentry:yourSentryAuthTokenIsInvalid")}
compact={compact}
/>
);
@@ -109,4 +109,19 @@ describe("SentryIssueDialog", () => {
expect(document.querySelector(".animate-spin")).toBeNull();
});
// The query placeholder is Sentry search syntax the user copies. It must stay
// outside the catalog: a translated `is:` or `release:` is invalid syntax and
// silently returns nothing. Only the surrounding "e.g." wrapper is localized.
it("keeps the Sentry query syntax out of the translated placeholder", async () => {
mockAvailability();
vi.mocked(listSentryProjects).mockResolvedValue({
projects: [{ id: "project-1", slug: "web", name: "Web", orgSlug: "acme" }],
} as never);
render(<SentryIssueDialog open={true} onOpenChange={vi.fn()} workspaceId="workspace-1" />);
const query = await screen.findByLabelText("Query");
expect(query.getAttribute("placeholder")).toContain("is:unresolved release:1.2.3");
});
});
@@ -27,10 +27,15 @@ import {
levelBadgeClass,
statusBadgeClass,
} from "./sentry-issue-common";
import { useTranslation } from "react-i18next";
const LEVELS: SentryLevel[] = ["fatal", "error", "warning", "info", "debug"];
const STATUSES: SentryStatus[] = ["unresolved", "resolved", "ignored"];
const PERIODS = ["1h", "24h", "7d", "14d", "30d"] as const;
// Sentry search syntax, shown as the query placeholder. Deliberately not a
// catalog value: a translated `is:` or `release:` is invalid syntax, and the
// placeholder is text users copy. Only the "e.g." wrapper is translated.
const SENTRY_QUERY_EXAMPLE = "is:unresolved release:1.2.3";
type Period = (typeof PERIODS)[number];
type FilterState = {
@@ -60,12 +65,13 @@ type SentryIssueDialogProps = {
};
export function SentryIssueDialog({ open, onOpenChange, workspaceId }: SentryIssueDialogProps) {
const { t } = useTranslation();
const dialog = useDialogState(open, workspaceId);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[min(1080px,95vw)] w-[95vw] max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0 sm:rounded-lg">
<DialogTitle className="sr-only">Browse Sentry issues</DialogTitle>
<DialogTitle className="sr-only">{t("sentry:browseSentryIssues")}</DialogTitle>
<FiltersBar state={dialog} />
<ResultsBody state={dialog} />
</DialogContent>
@@ -95,7 +101,17 @@ interface DialogState {
instancesState: SentryAvailabilityState;
}
// Catalog key for the first unmet search precondition, or null when the search
// can run. Returns the key rather than the message so the caller resolves it at
// render-time locale.
function searchBlockerKey(instanceId: string, orgSlug: string): string | null {
if (!instanceId) return "sentry:selectInstanceToSearch";
if (!orgSlug) return "sentry:orgSlugRequired";
return null;
}
function useDialogState(open: boolean, workspaceId?: string): DialogState {
const { t } = useTranslation();
const { healthy, state: instancesState } = useSentryInstances(workspaceId);
const [instanceId, setInstanceId] = useState("");
const [filter, setFilter] = useState<FilterState>(initialFilter);
@@ -169,12 +185,9 @@ function useDialogState(open: boolean, workspaceId?: string): DialogState {
const search = useCallback(
async (nextCursorValue?: string) => {
if (!instanceId) {
setError("Select a Sentry instance to search");
return;
}
if (!filter.orgSlug) {
setError("Organization slug is required");
const blockerKey = searchBlockerKey(instanceId, filter.orgSlug);
if (blockerKey) {
setError(t(blockerKey));
return;
}
if (!workspaceId) return;
@@ -198,7 +211,10 @@ function useDialogState(open: boolean, workspaceId?: string): DialogState {
if (seq === searchSeq.current) setLoading(false);
}
},
[workspaceId, instanceId, filter],
// `t` is a dependency now that the precondition messages are translated:
// i18next hands out a new `t` per language, so omitting it would leave this
// closure reporting the previous locale's message after a switch.
[workspaceId, instanceId, filter, t],
);
return {
@@ -289,19 +305,20 @@ function useBrowseProjects({
}
function FiltersBar({ state }: { state: DialogState }) {
const { t } = useTranslation();
return (
<div className="border-b px-5 py-4 space-y-3 shrink-0">
<InstanceSelectRow state={state} />
<FilterTopRow state={state} />
<FilterChipRow
label="Level"
label={t("sentry:level")}
options={LEVELS}
selected={state.filter.levels}
onToggle={(next) => state.updateFilter("levels", next as SentryLevel[])}
chipClass={(v) => levelBadgeClass(v as SentryLevel)}
/>
<FilterChipRow
label="Status"
label={t("common:status")}
options={STATUSES}
selected={state.filter.statuses}
onToggle={(next) => state.updateFilter("statuses", next as SentryStatus[])}
@@ -316,28 +333,27 @@ function FiltersBar({ state }: { state: DialogState }) {
// healthy instances, a hint when none are usable, and nothing when a sole
// instance was auto-selected.
function InstanceSelectRow({ state }: { state: DialogState }) {
const { t } = useTranslation();
const { healthy, instanceId, setInstanceId, instancesState } = state;
if (instancesState === "empty") {
return (
<p className="text-xs text-muted-foreground">
No Sentry instances configured for this workspace.
{t("sentry:noSentryInstancesConfiguredForThis")}
</p>
);
}
if (instancesState === "unhealthy") {
return (
<p className="text-xs text-muted-foreground">
No healthy Sentry instance — check the integration settings.
</p>
<p className="text-xs text-muted-foreground">{t("sentry:noHealthySentryInstanceCheckThe")}</p>
);
}
if (healthy.length <= 1) return null;
return (
<div className="space-y-1 max-w-sm">
<Label className="text-xs text-muted-foreground">Sentry instance</Label>
<Label className="text-xs text-muted-foreground">{t("sentry:sentryInstance")}</Label>
<Select value={instanceId || undefined} onValueChange={setInstanceId}>
<SelectTrigger className="h-8 text-xs" data-testid="sentry-browse-instance-select">
<SelectValue placeholder="Select an instance to browse" />
<SelectValue placeholder={t("sentry:selectAnInstanceToBrowse")} />
</SelectTrigger>
<SelectContent>
{healthy.map((instance) => (
@@ -352,12 +368,13 @@ function InstanceSelectRow({ state }: { state: DialogState }) {
}
function FilterTopRow({ state }: { state: DialogState }) {
const { t } = useTranslation();
const { filter, updateFilter, projects } = state;
return (
<div className="grid gap-2 sm:grid-cols-[1fr_1fr_180px_120px]">
<LabeledInput
id="sentry-search-org"
label="Organization"
label={t("sentry:organization")}
value={filter.orgSlug}
onChange={(v) => updateFilter("orgSlug", v)}
placeholder="my-org"
@@ -369,10 +386,10 @@ function FilterTopRow({ state }: { state: DialogState }) {
/>
<LabeledInput
id="sentry-search-env"
label="Environment"
label={t("sentry:environment")}
value={filter.environment}
onChange={(v) => updateFilter("environment", v)}
placeholder="production"
placeholder={t("sentry:production")}
/>
<PeriodSelect value={filter.statsPeriod} onChange={(v) => updateFilter("statsPeriod", v)} />
</div>
@@ -411,15 +428,16 @@ type ProjectFilterSelectProps = {
};
function ProjectFilterSelect({ value, projects, onChange }: ProjectFilterSelectProps) {
const { t } = useTranslation();
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Project</Label>
<Label className="text-xs text-muted-foreground">{t("sentry:project")}</Label>
<Select value={value || "__all__"} onValueChange={(v) => onChange(v === "__all__" ? "" : v)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="All projects" />
<SelectValue placeholder={t("sentry:allProjects")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All projects</SelectItem>
<SelectItem value="__all__">{t("sentry:allProjects")}</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.slug}>
{p.name}
@@ -432,9 +450,10 @@ function ProjectFilterSelect({ value, projects, onChange }: ProjectFilterSelectP
}
function PeriodSelect({ value, onChange }: { value: Period; onChange: (v: Period) => void }) {
const { t } = useTranslation();
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Period</Label>
<Label className="text-xs text-muted-foreground">{t("sentry:period")}</Label>
<Select value={value} onValueChange={(v) => onChange(v as Period)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@@ -487,18 +506,19 @@ function FilterChipRow({ label, options, selected, onToggle, chipClass }: ChipRo
}
function SearchActionRow({ state }: { state: DialogState }) {
const { t } = useTranslation();
const { filter, updateFilter, loading, search, instanceId } = state;
return (
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1">
<Label htmlFor="sentry-search-query" className="text-xs text-muted-foreground">
Query
{t("sentry:query")}
</Label>
<Input
id="sentry-search-query"
value={filter.query}
onChange={(e) => updateFilter("query", e.target.value)}
placeholder="is:unresolved release:1.2.3"
placeholder={t("sentry:queryExample", { query: SENTRY_QUERY_EXAMPLE })}
className="h-8 text-xs"
onKeyDown={(e) => {
if (e.key === "Enter") {
@@ -520,13 +540,14 @@ function SearchActionRow({ state }: { state: DialogState }) {
) : (
<IconSearch className="h-4 w-4" />
)}
Search
{t("common:presetIconSearch")}
</Button>
</div>
);
}
function ResultsBody({ state }: { state: DialogState }) {
const { t } = useTranslation();
const { issues, loading, error, isLast, nextCursor, search, instanceId } = state;
const empty = useMemo(() => !loading && !error && issues.length === 0, [loading, error, issues]);
@@ -536,8 +557,8 @@ function ResultsBody({ state }: { state: DialogState }) {
{empty && (
<div className="text-sm text-muted-foreground py-12 text-center">
{instanceId
? "No issues yet. Run a search to begin."
: "Select a Sentry instance to browse its issues."}
? t("sentry:noIssuesYetRunASearch")
: t("sentry:selectASentryInstanceToBrowse")}
</div>
)}
{issues.map((issue) => (
@@ -561,7 +582,7 @@ function ResultsBody({ state }: { state: DialogState }) {
disabled={loading}
className="cursor-pointer"
>
Load more
{t("sentry:loadMore")}
</Button>
</div>
)}
@@ -9,11 +9,13 @@ const MULTI_REPO_SUPPORTED_EXECUTOR_TYPES = new Set(["worktree", "local_docker",
*
* The three messages are copy: they render as the `title` on a disabled option
* in the automation editor's Executor Profile picker and in the task-create
* dialog. This module is shared by both surfaces and belongs to neither, so the
* Automations migration translated the strings its own picker renders without
* adding the file to `i18nGuardFiles` — allowlisting it would claim a
* completeness the task-create dialog has not had. Resolved at call time rather
* than module scope, so a locale switch is picked up.
* dialog. The Automations migration translated all three but left the file off
* `i18nGuardFiles`, arguing that allowlisting it would claim a completeness the
* task-create dialog has not had. That reasoning was wrong, and the entry was
* added here: the completeness the allowlist tracks is the FILE's, not the
* consuming directory's, and this file holds no other copy. Left off, it could
* silently drift back — which is the one thing the list exists to prevent.
* Resolved at call time rather than module scope, so a locale switch is picked up.
*/
export function getMultiRepoExecutorDisabledReason(executorType: string | null | undefined) {
if (MULTI_REPO_SUPPORTED_EXECUTOR_TYPES.has(executorType ?? "")) return null;
+55
View File
@@ -1439,4 +1439,59 @@ export const i18nGuardFiles = [
"components/integrations/presets-scope-bar-base.tsx",
"components/github/my-github/presets-scope-bar.tsx",
"components/gitlab/my-gitlab/presets-scope-bar.tsx",
// Azure DevOps, Jira, Linear and Sentry TASK surfaces: the board / work-item /
// PR views, the ticket and issue dialogs and their launchers, the My-Jira and
// My-Linear browse pages, and the four `/azure-devops`, `/jira`, `/linear`
// page clients. With the settings halves listed far above, this completes all
// four directories.
//
// Two shared files ride along because each is now converted COMPLETELY, which
// is the criterion for listing — shared-ness is not:
// - `components/integrations/auth-error-message.tsx`: props-driven apart from
// one heading and one link label, rendered by the Jira, Linear and Sentry
// issue-common surfaces.
// - `components/task-create-dialog-multi-repo-guard.ts`: migrated by the
// Automations PR, which then argued itself out of listing it. See the
// corrected comment in that file.
//
// `components/jira/my-jira/filter-pills.tsx` carried the same defect review
// found in the GitLab sidebar: `data-testid` was built as
// `jira-filter-pill-${label.toLowerCase()}` from a label this PR translates.
// Here it was a LIVE break — `e2e/tests/integrations/jira-default-project.spec.ts`
// selects `jira-filter-pill-project` and `-status` by name. The pill id and the
// visible label are now separate props.
//
// Deliberately left in English: JQL and WIQL (query languages, and the bare
// acronyms label them), every Azure DevOps / Jira / Linear / Sentry state,
// level, priority and work-item type, all provider ids, keys and names, and
// the persisted prompt templates the settings halves already recorded.
"app/azure-devops/azure-devops-page-client.tsx",
"app/jira/jira-page-client.tsx",
"app/linear/linear-page-client.tsx",
"components/azure-devops/azure-devops-board.tsx",
"components/azure-devops/azure-devops-feedback-dialog.tsx",
"components/azure-devops/azure-devops-filters.tsx",
"components/azure-devops/azure-devops-pull-request-pagination.tsx",
"components/azure-devops/azure-devops-results.tsx",
"components/azure-devops/azure-devops-save-view-dialog.tsx",
"components/azure-devops/azure-devops-work-item-detail.tsx",
"components/integrations/auth-error-message.tsx",
"components/jira/jira-import-bar.tsx",
"components/jira/jira-ticket-button.tsx",
"components/jira/jira-ticket-common.tsx",
"components/jira/jira-ticket-dialog.tsx",
"components/jira/my-jira/filter-pills.tsx",
"components/jira/my-jira/jql-editor.tsx",
"components/jira/my-jira/list-toolbar.tsx",
"components/jira/my-jira/results-pagination.tsx",
"components/jira/my-jira/ticket-row.tsx",
"components/linear/linear-import-bar.tsx",
"components/linear/linear-issue-button.tsx",
"components/linear/linear-issue-common.tsx",
"components/linear/linear-issue-dialog.tsx",
"components/sentry/sentry-issue-button.tsx",
"components/sentry/sentry-issue-common.tsx",
"components/jira/my-jira/filter-bar.tsx",
"components/sentry/sentry-issue-dialog.tsx",
"components/task-create-dialog-multi-repo-guard.ts",
];
+9 -1
View File
@@ -128,7 +128,15 @@ const NAMESPACE_RULES = [
[/^(app|components)\/jira\//, "jira"],
[/^(app|components)\/linear\//, "linear"],
[/^components\/sentry\//, "sentry"],
[/^(app|components)\/azure-devops\//, "azureDevops"],
// `azuredevops`, not `azureDevops`: the shipped catalog is
// `src/locales/en/azuredevops.json` and every existing reference is
// `t("azuredevops:…")`. The camelCase form wrote a SECOND catalog file
// beside the real one — verified by running this on
// `azure-devops-work-item-detail.tsx`, which produced `azureDevops.json`
// alongside `azuredevops.json`. Nothing would have failed: the new keys
// resolve from the new namespace, so `i18n:check` stays green while the
// directory's copy is split across two files.
[/^(app|components)\/azure-devops\//, "azuredevops"],
[/^components\/automations\//, "automations"],
[/^components\/plugins\//, "plugins"],
[/^components\/(integrations|vcs)\//, "integrations"],
+97
View File
@@ -7,10 +7,21 @@
"addWorkItemAction": "Add work item action",
"addWorkItemQuery": "Add work item query",
"addWorkItemWatch": "Add work-item watch",
"advanced": "Advanced",
"agentProfileId": "Agent profile ID",
"assignment": "Assignment",
"assignToMe": "Assign to me",
"azureDevopsFilters": "Azure DevOps filters",
"azureDevopsReviewAndPolicyState": "Azure DevOps review and policy state",
"azureDevopsWorkItemDetails": "Azure DevOps work-item details",
"azureProjectId": "Azure project ID",
"azureRepositoryIdOptional": "Azure repository ID (optional)",
"baseBranch": "Base branch",
"board": "Board",
"boardColumn": "Board column",
"boardColumns": "Board columns",
"boardsAndRepos": "{{organizationUrl}} · Boards and Repos",
"branchPolicies": "Branch policies",
"checkedAt": "checked {{at}}",
"cleanupAlways": "always",
"cleanupAuto": "auto",
@@ -20,12 +31,14 @@
"cleanupPolicyAuto": "Auto",
"cleanupPolicyNever": "Never",
"closeWatchEditor": "Close watch editor",
"closeWorkItemDetails": "Close work item details",
"configurationRemoved": "Azure DevOps configuration removed",
"configurationSaved": "Azure DevOps configuration saved",
"connected": "Connected",
"connectedAs": "Connected as {{name}}",
"connectionFailed": "Connection failed",
"couldNotLoadQuickActions": "Could not load existing quick actions: {{error}}",
"couldNotSaveView": "Could not save this view. Try again.",
"createPersonalAccessToken": "Create personal access token",
"createWatch": "Create watch",
"creator": "Creator",
@@ -37,11 +50,16 @@
"defaultQueryInvalid": "Every default query needs a label and valid filters.",
"delete": "Delete",
"deleteWatchConfirm": "Delete this watch?",
"description": "Description",
"disable": "Disable",
"disabled": "Disabled",
"discussion": "Discussion",
"done": "Done",
"draft": "Draft",
"edit": "Edit",
"editPrompt": "Edit prompt",
"editWatchTitle": "Edit {{kind}} watch",
"eGPlatformTriage": "e.g. Platform triage",
"enable": "Enable",
"enabled": "Enabled",
"enterValidOrganizationUrl": "Enter a valid organization URL to open its token settings.",
@@ -50,6 +68,15 @@
"failedToLoadQuickActions": "Failed to load quick actions.",
"failedToSaveDefaultQueries": "Failed to save default queries",
"failedToSaveQuickActions": "Failed to save quick actions",
"fallbackAuthor": "Azure DevOps",
"fallbackUnassigned": "Unassigned",
"fallbackUnknown": "Unknown",
"fallbackWorkItem": "Work item",
"feedback": "Feedback",
"fieldArea": "Area",
"fieldAssignee": "Assignee",
"fieldState": "State",
"fieldType": "Type",
"group": "Group",
"groupCreated": "Created",
"groupInbox": "Inbox",
@@ -60,19 +87,49 @@
"icon": "Icon",
"identityIdPlaceholder": "{{token}} or identity ID",
"identityPlaceholder": "{{token}} or identity",
"inProgress": "In progress",
"integrationDescription": "Azure DevOps Services organization, project, and PAT for this workspace.",
"integrationTitle": "Azure DevOps integration",
"kandevRepositoryId": "Kandev repository ID",
"label": "Label",
"linkedItemsCount_one": "{{count}} linked item",
"linkedItemsCount_other": "{{count}} linked items",
"linkedKandevTasks": "Linked Kandev tasks",
"loading": "Loading...",
"loading2": "Loading…",
"loadingBoard": "Loading board…",
"loadingFeedback": "Loading feedback...",
"loadingLinkedTasks": "Loading linked tasks…",
"loadingProjects": "Loading projects...",
"loadingResults": "Loading results...",
"loadingWatchers": "Loading watchers…",
"loadingWorkItem": "Loading work item…",
"loadOlderDiscussion": "Load older discussion",
"maxInflightTasks": "Max in-flight tasks (0 = unlimited)",
"move": "Move",
"moveToColumn": "Move to column",
"name": "Name",
"newMatches_one": "{{count}} new match.",
"newMatches_other": "{{count}} new matches.",
"newWatchTitle": "New {{kind}} watch",
"nextBoardColumn": "Next board column",
"nextPullRequestPage": "Next pull request page",
"noComments": "No comments.",
"noDescription": "No description.",
"noDiscussionYet": "No discussion yet.",
"noLinkedTasks": "No linked tasks.",
"noMatchingResults": "No matching results.",
"none": "none",
"noPolicyEvaluations": "No policy evaluations.",
"noPullRequestWatches": "No pull-request watches yet.",
"noReviewers": "No reviewers.",
"notCheckedYet": "Not checked yet",
"notConnectedNotice": "Azure DevOps is not connected for this workspace. <2>Configure Azure DevOps</2>",
"noWorkItemWatches": "No work-item watches yet.",
"openAzureDevopsFilters": "Open Azure DevOps filters",
"openPullRequestInAzureDevops": "Open pull request {{id}} in Azure DevOps",
"openWorkItemInAzureDevops": "Open work item {{id}} in Azure DevOps",
"openWorkItemInAzureDevops2": "Open work item in Azure DevOps",
"optional": "Optional",
"organizationUrl": "Organization URL",
"pastePat": "Paste PAT",
@@ -81,18 +138,28 @@
"patStepChooseScopes": "Choose this organization, a short expiration, and Custom defined scopes.",
"patStepCreateAndPaste": "Create the token, copy it, and paste it into this field.",
"patStepOpenTokenSettings": "Open token settings and select New Token.",
"pending": "pending",
"personalAccessToken": "Personal Access Token",
"placeholderTitleDescription": "Title of the Azure DevOps work item or pull request",
"placeholderUrlDescription": "URL of the Azure DevOps work item or pull request",
"planningAndEffort": "Planning and effort",
"policiesLabelled": "Policies: {{state}}",
"pollIntervalSeconds": "Poll interval (seconds)",
"previousBoardColumn": "Previous board column",
"previousPullRequestPage": "Previous pull request page",
"project": "Project",
"prompt": "Prompt",
"promptPlaceholdersHint": "Type {{open}} to see available placeholders. <3>{{url}}</3> and <6>{{title}}</6> are substituted when the action runs.",
"pullRequestAny": "any",
"pullRequestFeedback": "Pull request feedback",
"pullRequestFilter": "Pull-request filter",
"pullRequestFilters": "pull-request filters",
"pullRequestKindLower": "pull request",
"pullRequestKindNoun": "pull-request",
"pullRequestKindTitle": "Pull request",
"pullRequests": "Pull requests",
"pullRequestsTab": "Pull requests",
"pullRequestSubtitle": "{{repository}} · PR {{id}}",
"pullRequestWatches": "Pull-request watches",
"pullRequestWatchesDescription": "Create tasks automatically from matching Azure DevOps pull requests.",
"pullRequestWatchFieldsRequired": "Project, repository, workflow, step, agent, and executor are required.",
@@ -112,16 +179,29 @@
"removeConfigurationConfirm": "Remove Azure DevOps configuration?",
"removeFailed": "Remove failed: {{error}}",
"removeQueryAria": "Remove {{kind}} query {{index}}",
"repository": "Repository",
"resetWatchConfirm_one": "Reset this {{policy}} watch and remove {{count}} task?",
"resetWatchConfirm_other": "Reset this {{policy}} watch and remove {{count}} tasks?",
"resultLimit": "Result limit",
"resultsCount_one": "{{count}} result",
"resultsCount_other": "{{count}} results",
"retry": "Retry",
"reviewer": "Reviewer",
"reviewerId": "Reviewer ID",
"reviewers": "Reviewers",
"reviewLabelled": "Review: {{state}}",
"runNow": "Run now",
"save": "Save",
"saveAzureDevopsView": "Save Azure DevOps view",
"savedCredential": "Saved credential",
"saveFailed": "Save failed: {{error}}",
"saveViewDescription": "Save the current {{kind}} for this workspace.",
"saving": "Saving...",
"savingEllipsis": "Saving…",
"selectAProjectToViewIts": "Select a project to view its board.",
"selectProject": "Select project",
"selectRepository": "Select repository",
"startTask": "Start task",
"status": "Status",
"statusAbandoned": "Abandoned",
"statusActive": "Active",
@@ -129,11 +209,25 @@
"statusAny": "Any status",
"statusCompleted": "Completed",
"statusLine": "Status: {{status}}",
"task": "Task",
"taskActions": "Task actions",
"taskActionsForPullRequest": "Task actions for pull request {{id}}",
"taskActionsForWorkItem": "Task actions for work item {{id}}",
"team": "Team",
"testConnection": "Test connection",
"testing": "Testing...",
"top": "Top",
"unassign": "Unassign",
"unassigned": "Unassigned",
"untitledWorkItem": "Untitled work item",
"update": "Update",
"updateWatch": "Update watch",
"updating": "Updating…",
"voteApproved": "Approved",
"voteApprovedWithSuggestions": "Approved with suggestions",
"voteNone": "No vote",
"voteRejected": "Rejected",
"voteWaitingForAuthor": "Waiting for author",
"watchDeleted": "Watch deleted.",
"watchDisabled": "Watch disabled.",
"watchEnabled": "Watch enabled.",
@@ -149,7 +243,10 @@
"workItemKindNoun": "work-item",
"workItemKindTitle": "Work item",
"workItemQuery": "Work-item query",
"workItemQuery2": "work-item query",
"workItems": "Work items",
"workItemsTab": "Work items",
"workItemUnavailable": "Work item unavailable.",
"workItemWatches": "Work-item watches",
"workItemWatchesDescription": "Create tasks automatically from matching Azure DevOps work-item queries.",
"workItemWatchFieldsRequired": "Project, WIQL, repository, workflow, step, agent, and executor are required."
@@ -1,6 +1,9 @@
{
"authRequiredHeading": "{{name}} authentication required",
"authRequiredInline": "{{name}} authentication required.",
"deleteSavedQuery": "Delete saved query",
"noSavedQueriesYet": "No saved queries yet",
"reconnectProvider": "Reconnect {{name}}",
"saveCurrentQuery": "Save current query",
"savedQueries": "Saved"
}
+71 -8
View File
@@ -1,23 +1,32 @@
{
"aCredentialIsRequired": "A credential is required.",
"aJiraSiteUrlIsRequired": "A Jira site URL is required.",
"actions": "Actions",
"active": "Active",
"addPreset": "Add preset",
"agentProfile": "Agent Profile",
"aJiraSiteUrlIsRequired": "A Jira site URL is required.",
"anEmailAddressIsRequired": "An email address is required.",
"apiToken": "API token",
"apiTokenRecommended": "API token (recommended)",
"apply": "Apply",
"assignee": "Assignee",
"assigneeAnyone": "Anyone",
"assigneeMe": "Me",
"assigneeUnassigned": "Unassigned",
"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",
"builtIn": "Built-in",
"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}}",
"checkingJiraStatus": "Checking Jira status…",
"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.",
"clear": "Clear {{lowerCase}}",
"clearFilters": "Clear filters",
"connectedAs": "Connected as {{name}}",
"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.",
"cookieExpired": "Cookie expired — paste a fresh one",
"cookieExpiresInDays_one": "Cookie expires in {{count}} day",
"cookieExpiresInDays_other": "Cookie expires in {{count}} days",
@@ -30,10 +39,14 @@
"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.",
"customJqlOverridePillFiltersAre": "Custom JQL override — pill filters are paused.",
"defaultProjectKeyOptional": "Default project key (optional)",
"delete": "Delete",
"deleteFailed": "Delete failed: {{error}}",
"deleteThisJiraWatcher": "Delete this JIRA watcher?",
"deleteView": "Delete view",
"description": "Description",
"details": "Details",
"editJiraWatcher": "Edit JIRA Watcher",
"editPrompt": "Edit prompt",
"email": "Email",
@@ -44,6 +57,8 @@
"expiryUnknown": "Expiry unknown",
"failedToLoadJiraConfig": "Failed to load Jira config: {{error}}",
"failedToSaveTaskPresets": "Failed to save task presets",
"fallbackTicket": "Jira ticket",
"filterDisabled": "{{label}} filter disabled",
"foundNewTickets_one": "Found {{count}} new ticket — tasks will appear shortly.",
"foundNewTickets_other": "Found {{count}} new tickets — tasks will appear shortly.",
"hidePrompt": "Hide prompt",
@@ -52,6 +67,10 @@
"hintOptional": "Hint (optional)",
"howOftenToReRunThe": "How often to re-run the JQL. Minimum 60s, maximum 3600s.",
"icon": "Icon",
"import": "Import",
"importFromJira": "Import from Jira",
"importFromJiraTicketUrlOr": "Import from Jira ticket URL or key",
"importJiraTicket": "Import Jira ticket",
"initialStepForNewTasks": "Initial step for new tasks.",
"instanceType": "Instance type",
"interval": "Interval",
@@ -64,24 +83,42 @@
"jiraWatchers": "JIRA watchers",
"jiraWatchersDescription": "Poll a JQL query and auto-create a Kandev task for each newly-matching ticket.",
"jqlError": "JQL error: {{error}}",
"jqlIsGeneratedFromThePills": "JQL is generated from the pills above. Edit to override.",
"label": "Label",
"lastPolled": "Last Polled",
"loading": "Loading...",
"loading2": "Loading…",
"loadingTicket": "Loading ticket…",
"matchedTickets_one": "Matched {{count}} ticket in this page.",
"matchedTickets_other": "Matched {{count}} tickets in this page.",
"maxInFlightTasks": "Max in-flight tasks",
"myOpenBugs": "My open bugs",
"never": "Never",
"newWatcher": "New watcher",
"noCap": "(no cap)",
"noDescription": "No description.",
"noJiraWatchersConfiguredCreateOne": "No JIRA watchers configured. Create one to auto-create tasks from JQL queries.",
"noNewTicketsMatched": "No new tickets matched.",
"noProjectsMatch": "No projects match.",
"noResults": "No results",
"noStatusesForProject": "No statuses available for the selected project",
"noStatusesMatch": "No statuses match.",
"notConfiguredNotice": "Jira is not configured. <2>Configure Jira</2> to see your tickets here.",
"noTicketsFound": "No tickets found.",
"noView": "No view",
"openInAtlassian": "Open in Atlassian",
"openJiraTicket": "Open Jira ticket",
"openTicketDetails": "Open ticket details",
"optionalFallsBackToStepDefault": "Optional — falls back to step default.",
"pasteATokenToTestTheConnection": "Paste a token to test the connection",
"pageNumber": "Page {{page}}",
"pasteAJiraTicketUrlOr": "Paste a Jira ticket URL or key (PROJ-123)",
"pasteApiTokenHere": "paste API token here",
"pasteATokenToTestTheConnection": "Paste a token to test the connection",
"pastePersonalAccessTokenHere": "paste personal access token here",
"pasteSessionTokenValue": "paste {{cookieName}} value",
"pause": "Pause",
"pauseOrResumePolling": "Pause or resume polling.",
"paused": "Paused",
"pauseOrResumePolling": "Pause or resume polling.",
"personalAccessToken": "Personal Access Token",
"placeholderAssigneeDisplayName": "Assignee display name",
"placeholderIssueType": "Issue type",
@@ -107,26 +144,44 @@
"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.",
"proj123OrPasteTicketUrl": "PROJ-123 or paste ticket URL",
"project": "Project",
"promptFieldHelp": "The prompt sent to the agent for each new ticket. Type {{token}} to insert placeholders.",
"refresh": "Refresh",
"remove": "Remove",
"removeConfiguration": "Remove configuration",
"removeJiraConfiguration": "Remove Jira configuration?",
"reporter": "Reporter",
"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}}",
"resetToPills": "Reset to pills",
"resetWatch": "Reset watch",
"saveFailed": "Save failed: {{error}}",
"saveCurrentFiltersAs": "Save current filters as…",
"saveCurrentFiltersAsAView": "Save current filters as a view",
"saved": "Saved",
"savedLeaveBlank": "(saved — leave blank to keep the current value)",
"saveFailed": "Save failed: {{error}}",
"saveView": "Save view",
"saving": "Saving…",
"searchProjects": "Search projects…",
"searchStatuses": "Search statuses…",
"searchTicketKeyOrText": "Search ticket key or text…",
"selectProjectToFilterStatus": "Select a project to filter by status",
"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}}.",
"siteUrl": "Site URL",
"sortCreated": "Created",
"sortLabelled": "Sort: {{label}}",
"sortPriority": "Priority",
"sortUpdated": "Updated",
"startTask": "Start task",
"taskPresets": "Task presets",
"taskPresetsDescription": "Prompts shown on {{route}} when starting a task from a ticket.",
"taskPresetsSaved": "Task presets saved",
@@ -135,15 +190,23 @@
"tasksCreatedByThisWatcherLand": "Tasks created by this watcher land in the selected workspace.",
"testConnection": "Test connection",
"testFailed": "Failed: {{error}}",
"testJql": "Test JQL",
"testingConnection": "Testing...",
"testingJql": "Testing…",
"testJql": "Test JQL",
"ticketOnThisPage_one": "{{count}} ticket on this page",
"ticketOnThisPage_other": "{{count}} tickets on this page",
"ticketsAcrossYourAtlassianProjects": "Tickets across your Atlassian projects.",
"toggleRawJqlEditor": "Toggle raw JQL editor",
"unassigned": "Unassigned",
"update": "Update",
"updatedAgo": "· updated {{time}}",
"updatedTime": "Updated {{time}}",
"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"
"workflowStep": "Workflow Step",
"yourJiraSessionExpiredOrNeeds": "Your Jira session expired or needs step-up authentication. Reconnect to view this ticket."
}
+33 -2
View File
@@ -2,10 +2,13 @@
"actions": "Actions",
"active": "Active",
"agentProfile": "Agent Profile",
"allTeams": "All teams",
"anApiKeyIsRequired": "An API key is required.",
"any": "(any)",
"anyAssignee": "Any assignee",
"anyTeam": "(any team)",
"apiKey": "API key",
"assignedToMe": "Assigned to me",
"assignee": "Assignee",
"assigneeHelp": "Filter by who an issue is assigned to.",
"availablePlaceholders": "Available placeholders:",
@@ -26,12 +29,15 @@
"delete": "Delete",
"deleteFailed": "Delete failed: {{error}}",
"deleteThisLinearWatcher": "Delete this Linear watcher?",
"description": "Description",
"details": "Details",
"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.",
"eng123OrPasteIssueUrl": "ENG-123 or paste issue URL",
"enterAPositiveIntegerOrBlank": "Enter a positive integer or leave blank.",
"estimateMax": "Estimate max",
"estimateMaxHelp": "Upper bound in points (optional).",
@@ -41,10 +47,16 @@
"estimateMinPlaceholder": "e.g. 1",
"executorProfile": "Executor Profile",
"failedToLoadConfig": "Failed to load Linear config: {{error}}",
"fallbackIssue": "Linear issue",
"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.",
"import": "Import",
"importFromLinear": "Import from Linear",
"importFromLinearIssueUrlOr": "Import from Linear issue URL or identifier",
"importLinearIssue": "Import Linear issue",
"integrationDisabled": "Linear integration is disabled.",
"interval": "Interval",
"intervalMinutes_one": "{{count}}m",
"intervalMinutes_other": "{{count}}m",
@@ -57,7 +69,10 @@
"linearWatcher": "Linear watcher",
"linearWatchers": "Linear watchers",
"linearWatchersDescription": "Poll a Linear filter and auto-create a Kandev task for each newly-matching issue.",
"loading": "Loading...",
"loadingEllipsis": "Loading…",
"loadingIssue": "Loading issue…",
"loadingIssues": "Loading issues…",
"loadingLabels": "Loading labels…",
"loadingStates": "Loading states…",
"loadingTeams": "Loading teams…",
@@ -68,10 +83,19 @@
"newWatcher": "New watcher",
"noCap": "(no cap)",
"noDefault": "No default",
"noDescription": "No description.",
"noIssuesMatchYourFilters": "No issues match your filters.",
"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.",
"noResults": "No results",
"notConfiguredNotice": "Linear is not configured. <2>Configure Linear</2> to see your issues here.",
"noWorkflowStatesAvailable": "No workflow states available.",
"openInLinear": "Open in Linear",
"openIssueDetails": "Open issue details",
"openLinearIssue": "Open Linear issue",
"page": "Page",
"pasteALinearIssueUrlOr": "Paste a Linear issue URL or identifier (ENG-123)",
"pasteAnApiKeyToTest": "Paste an API key to test the connection",
"pause": "Pause",
"paused": "Paused",
@@ -98,6 +122,8 @@
"query": "Query",
"queryHelp": "Free-text match across title and description (optional).",
"queryPlaceholder": "auth bug",
"reEnableItInSettings": "Re-enable it in settings",
"refresh": "Refresh",
"removeConfiguration": "Remove configuration",
"removeLinearConfigurationConfirm": "Remove Linear configuration?",
"resetCompleteDeletedTasks_one": "Reset complete — deleted {{count}} task; next poll will re-import matches.",
@@ -105,9 +131,10 @@
"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)",
"saveFailed": "Save failed: {{error}}",
"saving": "Saving…",
"searchByIdTitleOrDescription": "Search by ID, title, or description",
"selectWorkflow": "Select workflow",
"selectWorkspace": "Select workspace",
"sortCreatedAsc": "Created (oldest first)",
@@ -117,6 +144,7 @@
"sortPriorityDesc": "Priority (high → low)",
"sortUpdatedAsc": "Updated (least recently updated first)",
"sortUpdatedDesc": "Updated (recently updated first)",
"startTask": "Start task",
"states": "States",
"statesHelp": "Click states to toggle. Empty matches every state on the team.",
"statesHelpNoTeam": "Pick a team to choose specific workflow states.",
@@ -130,6 +158,8 @@
"unknownAccount": "Unknown account",
"unknownError": "Unknown error",
"update": "Update",
"updatedAgo": "· updated {{time}}",
"updatedTime": "Updated {{time}}",
"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",
@@ -139,5 +169,6 @@
"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."
"workspaceHelp": "Tasks created by this watcher land in the selected workspace.",
"yourLinearApiKeyIsInvalid": "Your Linear API key is invalid or has been revoked. Reconnect to view this issue."
}
+24 -2
View File
@@ -3,9 +3,11 @@
"active": "Active",
"addInstance": "Add instance",
"agentProfile": "Agent Profile",
"allProjects": "All projects",
"any": "(any)",
"authToken": "Auth token",
"availablePlaceholders": "Available placeholders:",
"browseSentryIssues": "Browse Sentry issues",
"cantDeleteInstanceInUse_one": "Can't delete \"{{name}}\": {{count}} watch still bound to it. Reassign or remove those watchers first.",
"cantDeleteInstanceInUse_other": "Can't delete \"{{name}}\": {{count}} watches still bound to it. Reassign or remove those watchers first.",
"checkFailed": "Check failed: {{error}}",
@@ -28,6 +30,8 @@
"enterNameAndTokenBeforeSaving": "Enter an instance name and auth token before saving.",
"environment": "Environment",
"environmentHelp": "Optional — restrict to one environment.",
"eventsCount_one": "events {{count}}",
"eventsCount_other": "events {{count}}",
"executorProfile": "Executor Profile",
"failedToLoadInstances": "Failed to load Sentry instances: {{error}}",
"fallsBackToStepDefault": "Optional — falls back to step default.",
@@ -47,6 +51,8 @@
"intervalMinutes_one": "{{count}}m",
"intervalMinutes_other": "{{count}}m",
"lastPolled": "Last Polled",
"lastSeen": "Last seen",
"level": "Level",
"levelDebug": "debug",
"levelError": "error",
"levelFatal": "fatal",
@@ -54,6 +60,7 @@
"levelWarning": "warning",
"levels": "Levels",
"levelsHelp": "Click to toggle. Matches issues at ANY of the selected levels.",
"loadMore": "Load more",
"loadingEllipsis": "Loading…",
"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.",
@@ -64,18 +71,24 @@
"newInstance": "New Instance",
"newWatcher": "New watcher",
"noCap": "(no cap)",
"noHealthySentryInstanceCheckThe": "No healthy Sentry instance — check the integration settings.",
"noInstancesInWorkspace": "No Sentry instances in this workspace",
"noInstancesYet": "No Sentry instances yet. Add one to connect this workspace.",
"noIssuesYetRunASearch": "No issues yet. Run a search to begin.",
"noNewIssuesMatched": "No new issues matched.",
"noOrganizationsAvailable": "No organizations available",
"noProjectsAvailable": "No projects available",
"noProjectsAvailableSentence": "No projects available.",
"noSentryInstancesConfiguredForThis": "No Sentry instances configured for this workspace.",
"noWatchersConfigured": "No Sentry watchers configured. Create one to auto-create tasks from filtered issues.",
"orgSlugRequired": "Organization slug is required",
"organization": "Organization",
"organizationSlug": "Organization slug",
"organizationSlugHelp": "The Sentry org to poll.",
"pasteAnAuthTokenToTest": "Paste an auth token to test the connection",
"pause": "Pause",
"paused": "Paused",
"period": "Period",
"placeholderAssigneeDisplayName": "Assignee display name",
"placeholderCulprit": "Culprit (function or file)",
"placeholderEventCount": "Total event count",
@@ -90,12 +103,15 @@
"placeholderUserCount": "Unique users affected",
"pollIntervalHelp": "How often to re-run the search. Minimum 60s, maximum 3600s.",
"pollIntervalSeconds": "Poll Interval (seconds)",
"production": "production",
"project": "Project",
"projectSlug": "Project slug",
"promptFieldHelp": "The prompt sent to the agent for each new issue. Type {{token}} to insert placeholders.",
"projectSlugHelp": "The Sentry project(s) to poll. Select one or more.",
"projectsSelected_one": "{{count}} project selected",
"projectsSelected_other": "{{count}} projects selected",
"promptFieldHelp": "The prompt sent to the agent for each new issue. Type {{token}} to insert placeholders.",
"query": "Query",
"queryExample": "e.g. {{query}}",
"queryHelp": "Free-text Sentry search query (optional).",
"removeInstanceConfirm": "Remove Sentry instance \"{{name}}\"?",
"requiredTokenScopes": "Required token scopes",
@@ -111,7 +127,10 @@
"scopeOrgRead": "Organization — resolve the org and list issues",
"scopeProjectRead": "Project — list projects and scope searches",
"searchProjects": "Search projects...",
"selectASentryInstanceToBrowse": "Select a Sentry instance to browse its issues.",
"selectAnInstance": "Select an instance",
"selectAnInstanceToBrowse": "Select an instance to browse",
"selectInstanceToSearch": "Select a Sentry instance to search",
"selectOrganization": "Select organization",
"selectProjects": "Select projects",
"selectWorkflow": "Select workflow",
@@ -142,6 +161,8 @@
"update": "Update",
"updateFailed": "Update failed: {{error}}",
"useStepDefault": "(use step default)",
"usersCount_one": "users {{count}}",
"usersCount_other": "users {{count}}",
"watchDialogDescription": "Poll Sentry 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",
@@ -150,5 +171,6 @@
"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."
"workspaceHelp": "Tasks created by this watcher land in the selected workspace.",
"yourSentryAuthTokenIsInvalid": "Your Sentry auth token is invalid or has been revoked. Reconnect to view this issue."
}
@@ -7,10 +7,21 @@
"addWorkItemAction": "Àďď ŵōŕķ ĩţēḿ àćţĩōń",
"addWorkItemQuery": "Àďď ŵōŕķ ĩţēḿ qũēŕŷ",
"addWorkItemWatch": "Àďď ŵōŕķ-ĩţēḿ ŵàţćĥ",
"advanced": "Àďvàńćēď",
"agentProfileId": "Àĝēńţ ƥŕōƒĩĺē ĨĎ",
"assignment": "Àśśĩĝńḿēńţ",
"assignToMe": "Àśśĩĝń ţō ḿē",
"azureDevopsFilters": "Àźũŕē ĎēvŌƥś ƒĩĺţēŕś",
"azureDevopsReviewAndPolicyState": "Àźũŕē ĎēvŌƥś ŕēvĩēŵ àńď ƥōĺĩćŷ śţàţē",
"azureDevopsWorkItemDetails": "Àźũŕē ĎēvŌƥś ŵōŕķ-ĩţēḿ ďēţàĩĺś",
"azureProjectId": "Àźũŕē ƥŕōĵēćţ ĨĎ",
"azureRepositoryIdOptional": "Àźũŕē ŕēƥōśĩţōŕŷ ĨĎ (ōƥţĩōńàĺ)",
"baseBranch": "Ɓàśē ƀŕàńćĥ",
"board": "Ɓōàŕď",
"boardColumn": "Ɓōàŕď ćōĺũḿń",
"boardColumns": "Ɓōàŕď ćōĺũḿńś",
"boardsAndRepos": "{{organizationUrl}} · Ɓōàŕďś àńď Ŕēƥōś",
"branchPolicies": "Ɓŕàńćĥ ƥōĺĩćĩēś",
"checkedAt": "ćĥēćķēď {{at}}",
"cleanupAlways": "àĺŵàŷś",
"cleanupAuto": "àũţō",
@@ -20,12 +31,14 @@
"cleanupPolicyAuto": "Àũţō",
"cleanupPolicyNever": "Ńēvēŕ",
"closeWatchEditor": "Ćĺōśē ŵàţćĥ ēďĩţōŕ",
"closeWorkItemDetails": "Ćĺōśē ŵōŕķ ĩţēḿ ďēţàĩĺś",
"configurationRemoved": "Àźũŕē ĎēvŌƥś ćōńƒĩĝũŕàţĩōń ŕēḿōvēď",
"configurationSaved": "Àźũŕē ĎēvŌƥś ćōńƒĩĝũŕàţĩōń śàvēď",
"connected": "Ćōńńēćţēď",
"connectedAs": "Ćōńńēćţēď àś {{name}}",
"connectionFailed": "Ćōńńēćţĩōń ƒàĩĺēď",
"couldNotLoadQuickActions": "Ćōũĺď ńōţ ĺōàď ēxĩśţĩńĝ qũĩćķ àćţĩōńś: {{error}}",
"couldNotSaveView": "Ćōũĺď ńōţ śàvē ţĥĩś vĩēŵ. Ţŕŷ àĝàĩń.",
"createPersonalAccessToken": "Ćŕēàţē ƥēŕśōńàĺ àććēśś ţōķēń",
"createWatch": "Ćŕēàţē ŵàţćĥ",
"creator": "Ćŕēàţōŕ",
@@ -37,11 +50,16 @@
"defaultQueryInvalid": "Ēvēŕŷ ďēƒàũĺţ qũēŕŷ ńēēďś à ĺàƀēĺ àńď vàĺĩď ƒĩĺţēŕś.",
"delete": "Ďēĺēţē",
"deleteWatchConfirm": "Ďēĺēţē ţĥĩś ŵàţćĥ?",
"description": "Ďēśćŕĩƥţĩōń",
"disable": "Ďĩśàƀĺē",
"disabled": "Ďĩśàƀĺēď",
"discussion": "Ďĩśćũśśĩōń",
"done": "Ďōńē",
"draft": "Ďŕàƒţ",
"edit": "Ēďĩţ",
"editPrompt": "Ēďĩţ ƥŕōḿƥţ",
"editWatchTitle": "Ēďĩţ {{kind}} ŵàţćĥ",
"eGPlatformTriage": "ē.ĝ. Ƥĺàţƒōŕḿ ţŕĩàĝē",
"enable": "Ēńàƀĺē",
"enabled": "Ēńàƀĺēď",
"enterValidOrganizationUrl": "Ēńţēŕ à vàĺĩď ōŕĝàńĩźàţĩōń ŨŔĹ ţō ōƥēń ĩţś ţōķēń śēţţĩńĝś.",
@@ -50,6 +68,15 @@
"failedToLoadQuickActions": "Ƒàĩĺēď ţō ĺōàď qũĩćķ àćţĩōńś.",
"failedToSaveDefaultQueries": "Ƒàĩĺēď ţō śàvē ďēƒàũĺţ qũēŕĩēś",
"failedToSaveQuickActions": "Ƒàĩĺēď ţō śàvē qũĩćķ àćţĩōńś",
"fallbackAuthor": "Àźũŕē ĎēvŌƥś",
"fallbackUnassigned": "Ũńàśśĩĝńēď",
"fallbackUnknown": "Ũńķńōŵń",
"fallbackWorkItem": "Ŵōŕķ ĩţēḿ",
"feedback": "Ƒēēďƀàćķ",
"fieldArea": "Àŕēà",
"fieldAssignee": "Àśśĩĝńēē",
"fieldState": "Śţàţē",
"fieldType": "Ţŷƥē",
"group": "Ĝŕōũƥ",
"groupCreated": "Ćŕēàţēď",
"groupInbox": "Ĩńƀōx",
@@ -60,19 +87,49 @@
"icon": "Ĩćōń",
"identityIdPlaceholder": "{{token}} ōŕ ĩďēńţĩţŷ ĨĎ",
"identityPlaceholder": "{{token}} ōŕ ĩďēńţĩţŷ",
"inProgress": "Ĩń ƥŕōĝŕēśś",
"integrationDescription": "Àźũŕē ĎēvŌƥś Śēŕvĩćēś ōŕĝàńĩźàţĩōń, ƥŕōĵēćţ, àńď ƤÀŢ ƒōŕ ţĥĩś ŵōŕķśƥàćē.",
"integrationTitle": "Àźũŕē ĎēvŌƥś ĩńţēĝŕàţĩōń",
"kandevRepositoryId": "Ķàńďēv ŕēƥōśĩţōŕŷ ĨĎ",
"label": "Ĺàƀēĺ",
"linkedItemsCount_one": "{{count}} ĺĩńķēď ĩţēḿ",
"linkedItemsCount_other": "{{count}} ĺĩńķēď ĩţēḿś",
"linkedKandevTasks": "Ĺĩńķēď Ķàńďēv ţàśķś",
"loading": "Ĺōàďĩńĝ...",
"loading2": "Ĺōàďĩńĝ…",
"loadingBoard": "Ĺōàďĩńĝ ƀōàŕď…",
"loadingFeedback": "Ĺōàďĩńĝ ƒēēďƀàćķ...",
"loadingLinkedTasks": "Ĺōàďĩńĝ ĺĩńķēď ţàśķś…",
"loadingProjects": "Ĺōàďĩńĝ ƥŕōĵēćţś...",
"loadingResults": "Ĺōàďĩńĝ ŕēśũĺţś...",
"loadingWatchers": "Ĺōàďĩńĝ ŵàţćĥēŕś…",
"loadingWorkItem": "Ĺōàďĩńĝ ŵōŕķ ĩţēḿ…",
"loadOlderDiscussion": "Ĺōàď ōĺďēŕ ďĩśćũśśĩōń",
"maxInflightTasks": "Ḿàx ĩń-ƒĺĩĝĥţ ţàśķś (0 = ũńĺĩḿĩţēď)",
"move": "Ḿōvē",
"moveToColumn": "Ḿōvē ţō ćōĺũḿń",
"name": "Ńàḿē",
"newMatches_one": "{{count}} ńēŵ ḿàţćĥ.",
"newMatches_other": "{{count}} ńēŵ ḿàţćĥēś.",
"newWatchTitle": "Ńēŵ {{kind}} ŵàţćĥ",
"nextBoardColumn": "Ńēxţ ƀōàŕď ćōĺũḿń",
"nextPullRequestPage": "Ńēxţ ƥũĺĺ ŕēqũēśţ ƥàĝē",
"noComments": "Ńō ćōḿḿēńţś.",
"noDescription": "Ńō ďēśćŕĩƥţĩōń.",
"noDiscussionYet": "Ńō ďĩśćũśśĩōń ŷēţ.",
"noLinkedTasks": "Ńō ĺĩńķēď ţàśķś.",
"noMatchingResults": "Ńō ḿàţćĥĩńĝ ŕēśũĺţś.",
"none": "ńōńē",
"noPolicyEvaluations": "Ńō ƥōĺĩćŷ ēvàĺũàţĩōńś.",
"noPullRequestWatches": "Ńō ƥũĺĺ-ŕēqũēśţ ŵàţćĥēś ŷēţ.",
"noReviewers": "Ńō ŕēvĩēŵēŕś.",
"notCheckedYet": "Ńōţ ćĥēćķēď ŷēţ",
"notConnectedNotice": "Àźũŕē ĎēvŌƥś ĩś ńōţ ćōńńēćţēď ƒōŕ ţĥĩś ŵōŕķśƥàćē. <2>Ćōńƒĩĝũŕē Àźũŕē ĎēvŌƥś</2>",
"noWorkItemWatches": "Ńō ŵōŕķ-ĩţēḿ ŵàţćĥēś ŷēţ.",
"openAzureDevopsFilters": "Ōƥēń Àźũŕē ĎēvŌƥś ƒĩĺţēŕś",
"openPullRequestInAzureDevops": "Ōƥēń ƥũĺĺ ŕēqũēśţ {{id}} ĩń Àźũŕē ĎēvŌƥś",
"openWorkItemInAzureDevops": "Ōƥēń ŵōŕķ ĩţēḿ {{id}} ĩń Àźũŕē ĎēvŌƥś",
"openWorkItemInAzureDevops2": "Ōƥēń ŵōŕķ ĩţēḿ ĩń Àźũŕē ĎēvŌƥś",
"optional": "Ōƥţĩōńàĺ",
"organizationUrl": "Ōŕĝàńĩźàţĩōń ŨŔĹ",
"pastePat": "Ƥàśţē ƤÀŢ",
@@ -81,18 +138,28 @@
"patStepChooseScopes": "Ćĥōōśē ţĥĩś ōŕĝàńĩźàţĩōń, à śĥōŕţ ēxƥĩŕàţĩōń, àńď Ćũśţōḿ ďēƒĩńēď śćōƥēś.",
"patStepCreateAndPaste": "Ćŕēàţē ţĥē ţōķēń, ćōƥŷ ĩţ, àńď ƥàśţē ĩţ ĩńţō ţĥĩś ƒĩēĺď.",
"patStepOpenTokenSettings": "Ōƥēń ţōķēń śēţţĩńĝś àńď śēĺēćţ Ńēŵ Ţōķēń.",
"pending": "ƥēńďĩńĝ",
"personalAccessToken": "Ƥēŕśōńàĺ Àććēśś Ţōķēń",
"placeholderTitleDescription": "Ţĩţĺē ōƒ ţĥē Àźũŕē ĎēvŌƥś ŵōŕķ ĩţēḿ ōŕ ƥũĺĺ ŕēqũēśţ",
"placeholderUrlDescription": "ŨŔĹ ōƒ ţĥē Àźũŕē ĎēvŌƥś ŵōŕķ ĩţēḿ ōŕ ƥũĺĺ ŕēqũēśţ",
"planningAndEffort": "Ƥĺàńńĩńĝ àńď ēƒƒōŕţ",
"policiesLabelled": "Ƥōĺĩćĩēś: {{state}}",
"pollIntervalSeconds": "Ƥōĺĺ ĩńţēŕvàĺ (śēćōńďś)",
"previousBoardColumn": "Ƥŕēvĩōũś ƀōàŕď ćōĺũḿń",
"previousPullRequestPage": "Ƥŕēvĩōũś ƥũĺĺ ŕēqũēśţ ƥàĝē",
"project": "Ƥŕōĵēćţ",
"prompt": "Ƥŕōḿƥţ",
"promptPlaceholdersHint": "Ţŷƥē {{open}} ţō śēē àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś. <3>{{url}}</3> àńď <6>{{title}}</6> àŕē śũƀśţĩţũţēď ŵĥēń ţĥē àćţĩōń ŕũńś.",
"pullRequestAny": "àńŷ",
"pullRequestFeedback": "Ƥũĺĺ ŕēqũēśţ ƒēēďƀàćķ",
"pullRequestFilter": "Ƥũĺĺ-ŕēqũēśţ ƒĩĺţēŕ",
"pullRequestFilters": "ƥũĺĺ-ŕēqũēśţ ƒĩĺţēŕś",
"pullRequestKindLower": "ƥũĺĺ ŕēqũēśţ",
"pullRequestKindNoun": "ƥũĺĺ-ŕēqũēśţ",
"pullRequestKindTitle": "Ƥũĺĺ ŕēqũēśţ",
"pullRequests": "Ƥũĺĺ ŕēqũēśţś",
"pullRequestsTab": "Ƥũĺĺ ŕēqũēśţś",
"pullRequestSubtitle": "{{repository}} · ƤŔ {{id}}",
"pullRequestWatches": "Ƥũĺĺ-ŕēqũēśţ ŵàţćĥēś",
"pullRequestWatchesDescription": "Ćŕēàţē ţàśķś àũţōḿàţĩćàĺĺŷ ƒŕōḿ ḿàţćĥĩńĝ Àźũŕē ĎēvŌƥś ƥũĺĺ ŕēqũēśţś.",
"pullRequestWatchFieldsRequired": "Ƥŕōĵēćţ, ŕēƥōśĩţōŕŷ, ŵōŕķƒĺōŵ, śţēƥ, àĝēńţ, àńď ēxēćũţōŕ àŕē ŕēqũĩŕēď.",
@@ -112,16 +179,29 @@
"removeConfigurationConfirm": "Ŕēḿōvē Àźũŕē ĎēvŌƥś ćōńƒĩĝũŕàţĩōń?",
"removeFailed": "Ŕēḿōvē ƒàĩĺēď: {{error}}",
"removeQueryAria": "Ŕēḿōvē {{kind}} qũēŕŷ {{index}}",
"repository": "Ŕēƥōśĩţōŕŷ",
"resetWatchConfirm_one": "Ŕēśēţ ţĥĩś {{policy}} ŵàţćĥ àńď ŕēḿōvē {{count}} ţàśķ?",
"resetWatchConfirm_other": "Ŕēśēţ ţĥĩś {{policy}} ŵàţćĥ àńď ŕēḿōvē {{count}} ţàśķś?",
"resultLimit": "Ŕēśũĺţ ĺĩḿĩţ",
"resultsCount_one": "{{count}} ŕēśũĺţ",
"resultsCount_other": "{{count}} ŕēśũĺţś",
"retry": "Ŕēţŕŷ",
"reviewer": "Ŕēvĩēŵēŕ",
"reviewerId": "Ŕēvĩēŵēŕ ĨĎ",
"reviewers": "Ŕēvĩēŵēŕś",
"reviewLabelled": "Ŕēvĩēŵ: {{state}}",
"runNow": "Ŕũń ńōŵ",
"save": "Śàvē",
"saveAzureDevopsView": "Śàvē Àźũŕē ĎēvŌƥś vĩēŵ",
"savedCredential": "Śàvēď ćŕēďēńţĩàĺ",
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
"saveViewDescription": "Śàvē ţĥē ćũŕŕēńţ {{kind}} ƒōŕ ţĥĩś ŵōŕķśƥàćē.",
"saving": "Śàvĩńĝ...",
"savingEllipsis": "Śàvĩńĝ…",
"selectAProjectToViewIts": "Śēĺēćţ à ƥŕōĵēćţ ţō vĩēŵ ĩţś ƀōàŕď.",
"selectProject": "Śēĺēćţ ƥŕōĵēćţ",
"selectRepository": "Śēĺēćţ ŕēƥōśĩţōŕŷ",
"startTask": "Śţàŕţ ţàśķ",
"status": "Śţàţũś",
"statusAbandoned": "Àƀàńďōńēď",
"statusActive": "Àćţĩvē",
@@ -129,11 +209,25 @@
"statusAny": "Àńŷ śţàţũś",
"statusCompleted": "Ćōḿƥĺēţēď",
"statusLine": "Śţàţũś: {{status}}",
"task": "Ţàśķ",
"taskActions": "Ţàśķ àćţĩōńś",
"taskActionsForPullRequest": "Ţàśķ àćţĩōńś ƒōŕ ƥũĺĺ ŕēqũēśţ {{id}}",
"taskActionsForWorkItem": "Ţàśķ àćţĩōńś ƒōŕ ŵōŕķ ĩţēḿ {{id}}",
"team": "Ţēàḿ",
"testConnection": "Ţēśţ ćōńńēćţĩōń",
"testing": "Ţēśţĩńĝ...",
"top": "Ţōƥ",
"unassign": "Ũńàśśĩĝń",
"unassigned": "Ũńàśśĩĝńēď",
"untitledWorkItem": "Ũńţĩţĺēď ŵōŕķ ĩţēḿ",
"update": "Ũƥďàţē",
"updateWatch": "Ũƥďàţē ŵàţćĥ",
"updating": "Ũƥďàţĩńĝ…",
"voteApproved": "Àƥƥŕōvēď",
"voteApprovedWithSuggestions": "Àƥƥŕōvēď ŵĩţĥ śũĝĝēśţĩōńś",
"voteNone": "Ńō vōţē",
"voteRejected": "Ŕēĵēćţēď",
"voteWaitingForAuthor": "Ŵàĩţĩńĝ ƒōŕ àũţĥōŕ",
"watchDeleted": "Ŵàţćĥ ďēĺēţēď.",
"watchDisabled": "Ŵàţćĥ ďĩśàƀĺēď.",
"watchEnabled": "Ŵàţćĥ ēńàƀĺēď.",
@@ -149,7 +243,10 @@
"workItemKindNoun": "ŵōŕķ-ĩţēḿ",
"workItemKindTitle": "Ŵōŕķ ĩţēḿ",
"workItemQuery": "Ŵōŕķ-ĩţēḿ qũēŕŷ",
"workItemQuery2": "ŵōŕķ-ĩţēḿ qũēŕŷ",
"workItems": "Ŵōŕķ ĩţēḿś",
"workItemsTab": "Ŵōŕķ ĩţēḿś",
"workItemUnavailable": "Ŵōŕķ ĩţēḿ ũńàvàĩĺàƀĺē.",
"workItemWatches": "Ŵōŕķ-ĩţēḿ ŵàţćĥēś",
"workItemWatchesDescription": "Ćŕēàţē ţàśķś àũţōḿàţĩćàĺĺŷ ƒŕōḿ ḿàţćĥĩńĝ Àźũŕē ĎēvŌƥś ŵōŕķ-ĩţēḿ qũēŕĩēś.",
"workItemWatchFieldsRequired": "Ƥŕōĵēćţ, ŴĨQĹ, ŕēƥōśĩţōŕŷ, ŵōŕķƒĺōŵ, śţēƥ, àĝēńţ, àńď ēxēćũţōŕ àŕē ŕēqũĩŕēď."
+1 -1
View File
@@ -20,7 +20,7 @@
"commandGoToStats": "Ĝō ţō Śţàţś",
"commandGoToStatsKeywords": "śţàţś, śţàţĩśţĩćś, àńàĺŷţĩćś, ḿēţŕĩćś",
"commandGroupActions": "Àćţĩōńś",
"commandGroupCommands": "Ćōmmàńďś",
"commandGroupCommands": "Ćōḿḿàńďś",
"commandGroupNavigation": "Ńàvĩĝàţĩōń",
"commandGroupPreferences": "Ƥŕēƒēŕēńćēś",
"commandGroupSettings": "Śēţţĩńĝś",
@@ -1,6 +1,9 @@
{
"authRequiredHeading": "{{name}} àũţĥēńţĩćàţĩōń ŕēqũĩŕēď",
"authRequiredInline": "{{name}} àũţĥēńţĩćàţĩōń ŕēqũĩŕēď.",
"deleteSavedQuery": "Ďēĺēţē śàvēď qũēŕŷ",
"noSavedQueriesYet": "Ńō śàvēď qũēŕĩēś ŷēţ",
"reconnectProvider": "Ŕēćōńńēćţ {{name}}",
"saveCurrentQuery": "Śàvē ćũŕŕēńţ qũēŕŷ",
"savedQueries": "Śàvēď"
}
+71 -8
View File
@@ -1,23 +1,32 @@
{
"aCredentialIsRequired": "À ćŕēďēńţĩàĺ ĩś ŕēqũĩŕēď.",
"aJiraSiteUrlIsRequired": "À Ĵĩŕà śĩţē ŨŔĹ ĩś ŕēqũĩŕēď.",
"actions": "Àćţĩōńś",
"active": "Àćţĩvē",
"addPreset": "Àďď ƥŕēśēţ",
"agentProfile": "Àĝēńţ Ƥŕōƒĩĺē",
"aJiraSiteUrlIsRequired": "À Ĵĩŕà śĩţē ŨŔĹ ĩś ŕēqũĩŕēď.",
"anEmailAddressIsRequired": "Àń ēḿàĩĺ àďďŕēśś ĩś ŕēqũĩŕēď.",
"apiToken": "ÀƤĨ ţōķēń",
"apiTokenRecommended": "ÀƤĨ ţōķēń (ŕēćōḿḿēńďēď)",
"apply": "Àƥƥĺŷ",
"assignee": "Àśśĩĝńēē",
"assigneeAnyone": "Àńŷōńē",
"assigneeMe": "Ḿē",
"assigneeUnassigned": "Ũńàśśĩĝńēď",
"atlassianCloud": "Àţĺàśśĩàń Ćĺōũď",
"atlassianJqlHelp": "Àţĺàśśĩàń ĴQĹ. Ţĥē ŵàţćĥēŕ ƥōĺĺś ţĥĩś qũēŕŷ àńď ćŕēàţēś ōńē ţàśķ ƥēŕ ńēŵĺŷ-ḿàţćĥĩńĝ ţĩćķēţ.",
"authenticationMethod": "Àũţĥēńţĩćàţĩōń ḿēţĥōď",
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
"browserSessionCookie": "Ɓŕōŵśēŕ śēśśĩōń ćōōķĩē",
"builtIn": "Ɓũĩĺţ-ĩń",
"capOnOpenTasksCreatedBy": "Ćàƥ ōń ōƥēń ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ. Ĺēàvē ƀĺàńķ ƒōŕ ńō ćàƥ. Ńēŵ ḿàţćĥēś àŕē ďēƒēŕŕēď ţō ţĥē ńēxţ ƥōĺĺ ŵĥēń ţĥē ćàƥ ĩś ŕēàćĥēď.",
"checkFailed": "Ćĥēćķ ƒàĩĺēď: {{error}}",
"checkingJiraStatus": "Ćĥēćķĩńĝ Ĵĩŕà śţàţũś…",
"checkNow": "Ćĥēćķ ńōŵ",
"connectThisWorkspaceToAtlassianCloud": "Ćōńńēćţ ţĥĩś ŵōŕķśƥàćē ţō Àţĺàśśĩàń Ćĺōũď ōŕ à śēĺƒ-ĥōśţēď Ĵĩŕà Śēŕvēŕ / Ďàţà Ćēńţēŕ ĩńśţàńćē. Ćŕēďēńţĩàĺś àŕē śţōŕēď ēńćŕŷƥţēď śēŕvēŕ-śĩďē ƒōŕ ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
"clear": "Ćĺēàŕ {{lowerCase}}",
"clearFilters": "Ćĺēàŕ ƒĩĺţēŕś",
"connectedAs": "Ćōńńēćţēď àś {{name}}",
"connectThisWorkspaceToAtlassianCloud": "Ćōńńēćţ ţĥĩś ŵōŕķśƥàćē ţō Àţĺàśśĩàń Ćĺōũď ōŕ à śēĺƒ-ĥōśţēď Ĵĩŕà Śēŕvēŕ / Ďàţà Ćēńţēŕ ĩńśţàńćē. Ćŕēďēńţĩàĺś àŕē śţōŕēď ēńćŕŷƥţēď śēŕvēŕ-śĩďē ƒōŕ ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
"cookieExpired": "Ćōōķĩē ēxƥĩŕēď — ƥàśţē à ƒŕēśĥ ōńē",
"cookieExpiresInDays_one": "Ćōōķĩē ēxƥĩŕēś ĩń {{count}} ďàŷ",
"cookieExpiresInDays_other": "Ćōōķĩē ēxƥĩŕēś ĩń {{count}} ďàŷś",
@@ -30,10 +39,14 @@
"createJiraWatcher": "Ćŕēàţē ĴĨŔÀ Ŵàţćĥēŕ",
"createPatNoProfileLink": "Ćŕēàţē à Ƥēŕśōńàĺ Àććēśś Ţōķēń ƒŕōḿ ŷōũŕ Ĵĩŕà ƥŕōƒĩĺē → Ƥēŕśōńàĺ Àććēśś Ţōķēńś. Ŕēqũĩŕēď śćōƥēś: ŕēàď & ŵŕĩţē.",
"createPatWithProfileLink": "Ćŕēàţē à Ƥēŕśōńàĺ Àććēśś Ţōķēń ƒŕōḿ ŷōũŕ Ĵĩŕà ƥŕōƒĩĺē (<1>{{url}}</1>) → Ƥēŕśōńàĺ Àććēśś Ţōķēńś. Ŕēqũĩŕēď śćōƥēś: ŕēàď & ŵŕĩţē.",
"customJqlOverridePillFiltersAre": "Ćũśţōḿ ĴQĹ ōvēŕŕĩďē — ƥĩĺĺ ƒĩĺţēŕś àŕē ƥàũśēď.",
"defaultProjectKeyOptional": "Ďēƒàũĺţ ƥŕōĵēćţ ķēŷ (ōƥţĩōńàĺ)",
"delete": "Ďēĺēţē",
"deleteFailed": "Ďēĺēţē ƒàĩĺēď: {{error}}",
"deleteThisJiraWatcher": "Ďēĺēţē ţĥĩś ĴĨŔÀ ŵàţćĥēŕ?",
"deleteView": "Ďēĺēţē vĩēŵ",
"description": "Ďēśćŕĩƥţĩōń",
"details": "Ďēţàĩĺś",
"editJiraWatcher": "Ēďĩţ ĴĨŔÀ Ŵàţćĥēŕ",
"editPrompt": "Ēďĩţ ƥŕōḿƥţ",
"email": "Ēḿàĩĺ",
@@ -44,6 +57,8 @@
"expiryUnknown": "Ēxƥĩŕŷ ũńķńōŵń",
"failedToLoadJiraConfig": "Ƒàĩĺēď ţō ĺōàď Ĵĩŕà ćōńƒĩĝ: {{error}}",
"failedToSaveTaskPresets": "Ƒàĩĺēď ţō śàvē ţàśķ ƥŕēśēţś",
"fallbackTicket": "Ĵĩŕà ţĩćķēţ",
"filterDisabled": "{{label}} ƒĩĺţēŕ ďĩśàƀĺēď",
"foundNewTickets_one": "Ƒōũńď {{count}} ńēŵ ţĩćķēţ — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
"foundNewTickets_other": "Ƒōũńď {{count}} ńēŵ ţĩćķēţś — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
"hidePrompt": "Ĥĩďē ƥŕōḿƥţ",
@@ -52,6 +67,10 @@
"hintOptional": "Ĥĩńţ (ōƥţĩōńàĺ)",
"howOftenToReRunThe": "Ĥōŵ ōƒţēń ţō ŕē-ŕũń ţĥē ĴQĹ. Ḿĩńĩḿũḿ 60ś, ḿàxĩḿũḿ 3600ś.",
"icon": "Ĩćōń",
"import": "Ĩḿƥōŕţ",
"importFromJira": "Ĩḿƥōŕţ ƒŕōḿ Ĵĩŕà",
"importFromJiraTicketUrlOr": "Ĩḿƥōŕţ ƒŕōḿ Ĵĩŕà ţĩćķēţ ŨŔĹ ōŕ ķēŷ",
"importJiraTicket": "Ĩḿƥōŕţ Ĵĩŕà ţĩćķēţ",
"initialStepForNewTasks": "Ĩńĩţĩàĺ śţēƥ ƒōŕ ńēŵ ţàśķś.",
"instanceType": "Ĩńśţàńćē ţŷƥē",
"interval": "Ĩńţēŕvàĺ",
@@ -64,24 +83,42 @@
"jiraWatchers": "ĴĨŔÀ ŵàţćĥēŕś",
"jiraWatchersDescription": "Ƥōĺĺ à ĴQĹ qũēŕŷ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ţĩćķēţ.",
"jqlError": "ĴQĹ ēŕŕōŕ: {{error}}",
"jqlIsGeneratedFromThePills": "ĴQĹ ĩś ĝēńēŕàţēď ƒŕōḿ ţĥē ƥĩĺĺś àƀōvē. Ēďĩţ ţō ōvēŕŕĩďē.",
"label": "Ĺàƀēĺ",
"lastPolled": "Ĺàśţ Ƥōĺĺēď",
"loading": "Ĺōàďĩńĝ...",
"loading2": "Ĺōàďĩńĝ…",
"loadingTicket": "Ĺōàďĩńĝ ţĩćķēţ…",
"matchedTickets_one": "Ḿàţćĥēď {{count}} ţĩćķēţ ĩń ţĥĩś ƥàĝē.",
"matchedTickets_other": "Ḿàţćĥēď {{count}} ţĩćķēţś ĩń ţĥĩś ƥàĝē.",
"maxInFlightTasks": "Ḿàx ĩń-ƒĺĩĝĥţ ţàśķś",
"myOpenBugs": "Ḿŷ ōƥēń ƀũĝś",
"never": "Ńēvēŕ",
"newWatcher": "Ńēŵ ŵàţćĥēŕ",
"noCap": "(ńō ćàƥ)",
"noDescription": "Ńō ďēśćŕĩƥţĩōń.",
"noJiraWatchersConfiguredCreateOne": "Ńō ĴĨŔÀ ŵàţćĥēŕś ćōńƒĩĝũŕēď. Ćŕēàţē ōńē ţō àũţō-ćŕēàţē ţàśķś ƒŕōḿ ĴQĹ qũēŕĩēś.",
"noNewTicketsMatched": "Ńō ńēŵ ţĩćķēţś ḿàţćĥēď.",
"noProjectsMatch": "Ńō ƥŕōĵēćţś ḿàţćĥ.",
"noResults": "Ńō ŕēśũĺţś",
"noStatusesForProject": "Ńō śţàţũśēś àvàĩĺàƀĺē ƒōŕ ţĥē śēĺēćţēď ƥŕōĵēćţ",
"noStatusesMatch": "Ńō śţàţũśēś ḿàţćĥ.",
"notConfiguredNotice": "Ĵĩŕà ĩś ńōţ ćōńƒĩĝũŕēď. <2>Ćōńƒĩĝũŕē Ĵĩŕà</2> ţō śēē ŷōũŕ ţĩćķēţś ĥēŕē.",
"noTicketsFound": "Ńō ţĩćķēţś ƒōũńď.",
"noView": "Ńō vĩēŵ",
"openInAtlassian": "Ōƥēń ĩń Àţĺàśśĩàń",
"openJiraTicket": "Ōƥēń Ĵĩŕà ţĩćķēţ",
"openTicketDetails": "Ōƥēń ţĩćķēţ ďēţàĩĺś",
"optionalFallsBackToStepDefault": "Ōƥţĩōńàĺ — ƒàĺĺś ƀàćķ ţō śţēƥ ďēƒàũĺţ.",
"pasteATokenToTestTheConnection": "Ƥàśţē à ţōķēń ţō ţēśţ ţĥē ćōńńēćţĩōń",
"pageNumber": "Ƥàĝē {{page}}",
"pasteAJiraTicketUrlOr": "Ƥàśţē à Ĵĩŕà ţĩćķēţ ŨŔĹ ōŕ ķēŷ (ƤŔŌĴ-123)",
"pasteApiTokenHere": "ƥàśţē ÀƤĨ ţōķēń ĥēŕē",
"pasteATokenToTestTheConnection": "Ƥàśţē à ţōķēń ţō ţēśţ ţĥē ćōńńēćţĩōń",
"pastePersonalAccessTokenHere": "ƥàśţē ƥēŕśōńàĺ àććēśś ţōķēń ĥēŕē",
"pasteSessionTokenValue": "ƥàśţē {{cookieName}} vàĺũē",
"pause": "Ƥàũśē",
"pauseOrResumePolling": "Ƥàũśē ōŕ ŕēśũḿē ƥōĺĺĩńĝ.",
"paused": "Ƥàũśēď",
"pauseOrResumePolling": "Ƥàũśē ōŕ ŕēśũḿē ƥōĺĺĩńĝ.",
"personalAccessToken": "Ƥēŕśōńàĺ Àććēśś Ţōķēń",
"placeholderAssigneeDisplayName": "Àśśĩĝńēē ďĩśƥĺàŷ ńàḿē",
"placeholderIssueType": "Ĩśśũē ţŷƥē",
@@ -107,26 +144,44 @@
"presetPlaceholderTicketSummary": "Ţĩćķēţ śũḿḿàŕŷ",
"presetPlaceholderTicketUrl": "ŨŔĹ ōƒ ţĥē Ĵĩŕà ţĩćķēţ",
"presetPromptPlaceholderHelp": "Ţŷƥē {{token}} ţō śēē àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś. <4>{{key}}</4>, <7>{{url}}</7>, <10>{{title}}</10>, àńď <13>{{description}}</13> àŕē śũƀśţĩţũţēď ŵĥēń ţĥē àćţĩōń ŕũńś.",
"proj123OrPasteTicketUrl": "ƤŔŌĴ-123 ōŕ ƥàśţē ţĩćķēţ ŨŔĹ",
"project": "Ƥŕōĵēćţ",
"promptFieldHelp": "Ţĥē ƥŕōḿƥţ śēńţ ţō ţĥē àĝēńţ ƒōŕ ēàćĥ ńēŵ ţĩćķēţ. Ţŷƥē {{token}} ţō ĩńśēŕţ ƥĺàćēĥōĺďēŕś.",
"refresh": "Ŕēƒŕēśĥ",
"remove": "Ŕēḿōvē",
"removeConfiguration": "Ŕēḿōvē ćōńƒĩĝũŕàţĩōń",
"removeJiraConfiguration": "Ŕēḿōvē Ĵĩŕà ćōńƒĩĝũŕàţĩōń?",
"reporter": "Ŕēƥōŕţēŕ",
"resetCompleteDeletedTasks_one": "Ŕēśēţ ćōḿƥĺēţē — ďēĺēţēď {{count}} ţàśķ; ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
"resetCompleteDeletedTasks_other": "Ŕēśēţ ćōḿƥĺēţē — ďēĺēţēď {{count}} ţàśķś; ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
"resetCompleteNoTasksDeleted": "Ŕēśēţ ćōḿƥĺēţē — ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
"resetFailed": "Ŕēśēţ ƒàĩĺēď: {{error}}",
"resetToPills": "Ŕēśēţ ţō ƥĩĺĺś",
"resetWatch": "Ŕēśēţ ŵàţćĥ",
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
"saveCurrentFiltersAs": "Śàvē ćũŕŕēńţ ƒĩĺţēŕś àś…",
"saveCurrentFiltersAsAView": "Śàvē ćũŕŕēńţ ƒĩĺţēŕś àś à vĩēŵ",
"saved": "Śàvēď",
"savedLeaveBlank": "(śàvēď — ĺēàvē ƀĺàńķ ţō ķēēƥ ţĥē ćũŕŕēńţ vàĺũē)",
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
"saveView": "Śàvē vĩēŵ",
"saving": "Śàvĩńĝ…",
"searchProjects": "Śēàŕćĥ ƥŕōĵēćţś…",
"searchStatuses": "Śēàŕćĥ śţàţũśēś…",
"searchTicketKeyOrText": "Śēàŕćĥ ţĩćķēţ ķēŷ ōŕ ţēxţ…",
"selectProjectToFilterStatus": "Śēĺēćţ à ƥŕōĵēćţ ţō ƒĩĺţēŕ ƀŷ śţàţũś",
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
"selectWorkspace": "Śēĺēćţ ŵōŕķśƥàćē",
"selfHostedJiraServerOrDataCenter": "Śēĺƒ-ĥōśţēď Ĵĩŕà (Śēŕvēŕ ōŕ Ďàţà Ćēńţēŕ).",
"serverDataCenter": "Śēŕvēŕ / Ďàţà Ćēńţēŕ",
"sessionTokenValue": "Śēśśĩōń ţōķēń vàĺũē",
"showSessionTokenHelp": "Śĥōŵ ĥōŵ ţō ćōƥŷ ţĥē śēśśĩōń ţōķēń",
"siteUrl": "Śĩţē ŨŔĹ",
"sitesHostedAt": "Śĩţēś ĥōśţēď àţ {{domain}}.",
"siteUrl": "Śĩţē ŨŔĹ",
"sortCreated": "Ćŕēàţēď",
"sortLabelled": "Śōŕţ: {{label}}",
"sortPriority": "Ƥŕĩōŕĩţŷ",
"sortUpdated": "Ũƥďàţēď",
"startTask": "Śţàŕţ ţàśķ",
"taskPresets": "Ţàśķ ƥŕēśēţś",
"taskPresetsDescription": "Ƥŕōḿƥţś śĥōŵń ōń {{route}} ŵĥēń śţàŕţĩńĝ à ţàśķ ƒŕōḿ à ţĩćķēţ.",
"taskPresetsSaved": "Ţàśķ ƥŕēśēţś śàvēď",
@@ -135,15 +190,23 @@
"tasksCreatedByThisWatcherLand": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
"testConnection": "Ţēśţ ćōńńēćţĩōń",
"testFailed": "Ƒàĩĺēď: {{error}}",
"testJql": "Ţēśţ ĴQĹ",
"testingConnection": "Ţēśţĩńĝ...",
"testingJql": "Ţēśţĩńĝ…",
"testJql": "Ţēśţ ĴQĹ",
"ticketOnThisPage_one": "{{count}} ţĩćķēţ ōń ţĥĩś ƥàĝē",
"ticketOnThisPage_other": "{{count}} ţĩćķēţś ōń ţĥĩś ƥàĝē",
"ticketsAcrossYourAtlassianProjects": "Ţĩćķēţś àćŕōśś ŷōũŕ Àţĺàśśĩàń ƥŕōĵēćţś.",
"toggleRawJqlEditor": "Ţōĝĝĺē ŕàŵ ĴQĹ ēďĩţōŕ",
"unassigned": "Ũńàśśĩĝńēď",
"update": "Ũƥďàţē",
"updatedAgo": "· ũƥďàţēď {{time}}",
"updatedTime": "Ũƥďàţēď {{time}}",
"updateFailed": "Ũƥďàţē ƒàĩĺēď: {{error}}",
"watchDialogDescription": "Ƥōĺĺ à ĴQĹ qũēŕŷ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ţĩćķēţ. Ōƥţĩōńàĺĺŷ ƀĩńď à ŕēƥōśĩţōŕŷ śō ēàćĥ ţàśķ ŕũńś àĝàĩńśţ ţĥàţ ćōďēƀàśē, ōŕ ĺēàvē ĩţ ũńśēţ ţō ŕũń ŵĩţĥ ńō ŕēƥōśĩţōŕŷ.",
"watcherCreated": "Ŵàţćĥēŕ ćŕēàţēď",
"watcherDeleted": "Ŵàţćĥēŕ ďēĺēţēď",
"watcherUpdated": "Ŵàţćĥēŕ ũƥďàţēď",
"workflow": "Ŵōŕķƒĺōŵ",
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ"
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ",
"yourJiraSessionExpiredOrNeeds": "Ŷōũŕ Ĵĩŕà śēśśĩōń ēxƥĩŕēď ōŕ ńēēďś śţēƥ-ũƥ àũţĥēńţĩćàţĩōń. Ŕēćōńńēćţ ţō vĩēŵ ţĥĩś ţĩćķēţ."
}
+33 -2
View File
@@ -2,10 +2,13 @@
"actions": "Àćţĩōńś",
"active": "Àćţĩvē",
"agentProfile": "Àĝēńţ Ƥŕōƒĩĺē",
"allTeams": "Àĺĺ ţēàḿś",
"anApiKeyIsRequired": "Àń ÀƤĨ ķēŷ ĩś ŕēqũĩŕēď.",
"any": "(àńŷ)",
"anyAssignee": "Àńŷ àśśĩĝńēē",
"anyTeam": "(àńŷ ţēàḿ)",
"apiKey": "ÀƤĨ ķēŷ",
"assignedToMe": "Àśśĩĝńēď ţō ḿē",
"assignee": "Àśśĩĝńēē",
"assigneeHelp": "Ƒĩĺţēŕ ƀŷ ŵĥō àń ĩśśũē ĩś àśśĩĝńēď ţō.",
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
@@ -26,12 +29,15 @@
"delete": "Ďēĺēţē",
"deleteFailed": "Ďēĺēţē ƒàĩĺēď: {{error}}",
"deleteThisLinearWatcher": "Ďēĺēţē ţĥĩś Ĺĩńēàŕ ŵàţćĥēŕ?",
"description": "Ďēśćŕĩƥţĩōń",
"details": "Ďēţàĩĺś",
"dispatchOrder": "Ďĩśƥàţćĥ ōŕďēŕ",
"dispatchOrderHelp": "Ŵĥēń ţĥē ĩń-ƒĺĩĝĥţ ćàƥ ĩś ŕēàćĥēď, ĩśśũēś àŕē ďĩśƥàţćĥēď ĩń ţĥĩś ōŕďēŕ śō ţĥē ḿōśţ ĩḿƥōŕţàńţ ōńēś ŕũń ƒĩŕśţ.",
"editLinearWatcher": "Ēďĩţ Ĺĩńēàŕ Ŵàţćĥēŕ",
"enable": "Ēńàƀĺē",
"enabled": "Ēńàƀĺēď",
"enabledHelp": "Ƥàũśē ōŕ ŕēśũḿē ƥōĺĺĩńĝ.",
"eng123OrPasteIssueUrl": "ĒŃĜ-123 ōŕ ƥàśţē ĩśśũē ŨŔĹ",
"enterAPositiveIntegerOrBlank": "Ēńţēŕ à ƥōśĩţĩvē ĩńţēĝēŕ ōŕ ĺēàvē ƀĺàńķ.",
"estimateMax": "Ēśţĩḿàţē ḿàx",
"estimateMaxHelp": "Ũƥƥēŕ ƀōũńď ĩń ƥōĩńţś (ōƥţĩōńàĺ).",
@@ -41,10 +47,16 @@
"estimateMinPlaceholder": "ē.ĝ. 1",
"executorProfile": "Ēxēćũţōŕ Ƥŕōƒĩĺē",
"failedToLoadConfig": "Ƒàĩĺēď ţō ĺōàď Ĺĩńēàŕ ćōńƒĩĝ: {{error}}",
"fallbackIssue": "Ĺĩńēàŕ ĩśśũē",
"fallsBackToStepDefault": "Ōƥţĩōńàĺ — ƒàĺĺś ƀàćķ ţō śţēƥ ďēƒàũĺţ.",
"filter": "Ƒĩĺţēŕ",
"foundNewIssues_one": "Ƒōũńď {{count}} ńēŵ ĩśśũē — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
"foundNewIssues_other": "Ƒōũńď {{count}} ńēŵ ĩśśũēś — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
"import": "Ĩḿƥōŕţ",
"importFromLinear": "Ĩḿƥōŕţ ƒŕōḿ Ĺĩńēàŕ",
"importFromLinearIssueUrlOr": "Ĩḿƥōŕţ ƒŕōḿ Ĺĩńēàŕ ĩśśũē ŨŔĹ ōŕ ĩďēńţĩƒĩēŕ",
"importLinearIssue": "Ĩḿƥōŕţ Ĺĩńēàŕ ĩśśũē",
"integrationDisabled": "Ĺĩńēàŕ ĩńţēĝŕàţĩōń ĩś ďĩśàƀĺēď.",
"interval": "Ĩńţēŕvàĺ",
"intervalMinutes_one": "{{count}}ḿ",
"intervalMinutes_other": "{{count}}ḿ",
@@ -57,7 +69,10 @@
"linearWatcher": "Ĺĩńēàŕ ŵàţćĥēŕ",
"linearWatchers": "Ĺĩńēàŕ ŵàţćĥēŕś",
"linearWatchersDescription": "Ƥōĺĺ à Ĺĩńēàŕ ƒĩĺţēŕ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ĩśśũē.",
"loading": "Ĺōàďĩńĝ...",
"loadingEllipsis": "Ĺōàďĩńĝ…",
"loadingIssue": "Ĺōàďĩńĝ ĩśśũē…",
"loadingIssues": "Ĺōàďĩńĝ ĩśśũēś…",
"loadingLabels": "Ĺōàďĩńĝ ĺàƀēĺś…",
"loadingStates": "Ĺōàďĩńĝ śţàţēś…",
"loadingTeams": "Ĺōàďĩńĝ ţēàḿś…",
@@ -68,10 +83,19 @@
"newWatcher": "Ńēŵ ŵàţćĥēŕ",
"noCap": "(ńō ćàƥ)",
"noDefault": "Ńō ďēƒàũĺţ",
"noDescription": "Ńō ďēśćŕĩƥţĩōń.",
"noIssuesMatchYourFilters": "Ńō ĩśśũēś ḿàţćĥ ŷōũŕ ƒĩĺţēŕś.",
"noLabelsAvailableForThisTeam": "Ńō ĺàƀēĺś àvàĩĺàƀĺē ƒōŕ ţĥĩś ţēàḿ.",
"noLinearWatchersConfiguredCreateOne": "Ńō Ĺĩńēàŕ ŵàţćĥēŕś ćōńƒĩĝũŕēď. Ćŕēàţē ōńē ţō àũţō-ćŕēàţē ţàśķś ƒŕōḿ ƒĩĺţēŕēď ĩśśũēś.",
"noNewIssuesMatched": "Ńō ńēŵ ĩśśũēś ḿàţćĥēď.",
"noResults": "Ńō ŕēśũĺţś",
"notConfiguredNotice": "Ĺĩńēàŕ ĩś ńōţ ćōńƒĩĝũŕēď. <2>Ćōńƒĩĝũŕē Ĺĩńēàŕ</2> ţō śēē ŷōũŕ ĩśśũēś ĥēŕē.",
"noWorkflowStatesAvailable": "Ńō ŵōŕķƒĺōŵ śţàţēś àvàĩĺàƀĺē.",
"openInLinear": "Ōƥēń ĩń Ĺĩńēàŕ",
"openIssueDetails": "Ōƥēń ĩśśũē ďēţàĩĺś",
"openLinearIssue": "Ōƥēń Ĺĩńēàŕ ĩśśũē",
"page": "Ƥàĝē",
"pasteALinearIssueUrlOr": "Ƥàśţē à Ĺĩńēàŕ ĩśśũē ŨŔĹ ōŕ ĩďēńţĩƒĩēŕ (ĒŃĜ-123)",
"pasteAnApiKeyToTest": "Ƥàśţē àń ÀƤĨ ķēŷ ţō ţēśţ ţĥē ćōńńēćţĩōń",
"pause": "Ƥàũśē",
"paused": "Ƥàũśēď",
@@ -98,6 +122,8 @@
"query": "Qũēŕŷ",
"queryHelp": "Ƒŕēē-ţēxţ ḿàţćĥ àćŕōśś ţĩţĺē àńď ďēśćŕĩƥţĩōń (ōƥţĩōńàĺ).",
"queryPlaceholder": "àũţĥ ƀũĝ",
"reEnableItInSettings": "Ŕē-ēńàƀĺē ĩţ ĩń śēţţĩńĝś",
"refresh": "Ŕēƒŕēśĥ",
"removeConfiguration": "Ŕēḿōvē ćōńƒĩĝũŕàţĩōń",
"removeLinearConfigurationConfirm": "Ŕēḿōvē Ĺĩńēàŕ ćōńƒĩĝũŕàţĩōń?",
"resetCompleteDeletedTasks_one": "Ŕēśēţ ćōḿƥĺēţē — ďēĺēţēď {{count}} ţàśķ; ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
@@ -105,9 +131,10 @@
"resetCompleteNoTasksDeleted": "Ŕēśēţ ćōḿƥĺēţē — ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
"resetFailed": "Ŕēśēţ ƒàĩĺēď: {{error}}",
"resetWatch": "Ŕēśēţ ŵàţćĥ",
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
"savedLeaveBlank": "(śàvēď — ĺēàvē ƀĺàńķ ţō ķēēƥ ţĥē ćũŕŕēńţ vàĺũē)",
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
"saving": "Śàvĩńĝ…",
"searchByIdTitleOrDescription": "Śēàŕćĥ ƀŷ ĨĎ, ţĩţĺē, ōŕ ďēśćŕĩƥţĩōń",
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
"selectWorkspace": "Śēĺēćţ ŵōŕķśƥàćē",
"sortCreatedAsc": "Ćŕēàţēď (ōĺďēśţ ƒĩŕśţ)",
@@ -117,6 +144,7 @@
"sortPriorityDesc": "Ƥŕĩōŕĩţŷ (ĥĩĝĥ → ĺōŵ)",
"sortUpdatedAsc": "Ũƥďàţēď (ĺēàśţ ŕēćēńţĺŷ ũƥďàţēď ƒĩŕśţ)",
"sortUpdatedDesc": "Ũƥďàţēď (ŕēćēńţĺŷ ũƥďàţēď ƒĩŕśţ)",
"startTask": "Śţàŕţ ţàśķ",
"states": "Śţàţēś",
"statesHelp": "Ćĺĩćķ śţàţēś ţō ţōĝĝĺē. Ēḿƥţŷ ḿàţćĥēś ēvēŕŷ śţàţē ōń ţĥē ţēàḿ.",
"statesHelpNoTeam": "Ƥĩćķ à ţēàḿ ţō ćĥōōśē śƥēćĩƒĩć ŵōŕķƒĺōŵ śţàţēś.",
@@ -130,6 +158,8 @@
"unknownAccount": "Ũńķńōŵń àććōũńţ",
"unknownError": "Ũńķńōŵń ēŕŕōŕ",
"update": "Ũƥďàţē",
"updatedAgo": "· ũƥďàţēď {{time}}",
"updatedTime": "Ũƥďàţēď {{time}}",
"updateFailed": "Ũƥďàţē ƒàĩĺēď: {{error}}",
"watchDialogDescription": "Ƥōĺĺ Ĺĩńēàŕ ŵĩţĥ à śţŕũćţũŕēď ƒĩĺţēŕ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ĩśśũē. Ōƥţĩōńàĺĺŷ ƀĩńď à ŕēƥōśĩţōŕŷ śō ēàćĥ ţàśķ ŕũńś àĝàĩńśţ ţĥàţ ćōďēƀàśē, ōŕ ĺēàvē ĩţ ũńśēţ ţō ŕũń ŵĩţĥ ńō ŕēƥōśĩţōŕŷ.",
"watcherCreated": "Ŵàţćĥēŕ ćŕēàţēď",
@@ -139,5 +169,6 @@
"workflowHelp": "Ţàśķś àŕē ćŕēàţēď ĩń ţĥĩś ŵōŕķƒĺōŵ.",
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ",
"workflowStepHelp": "Ĩńĩţĩàĺ śţēƥ ƒōŕ ńēŵ ţàśķś.",
"workspaceHelp": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē."
"workspaceHelp": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
"yourLinearApiKeyIsInvalid": "Ŷōũŕ Ĺĩńēàŕ ÀƤĨ ķēŷ ĩś ĩńvàĺĩď ōŕ ĥàś ƀēēń ŕēvōķēď. Ŕēćōńńēćţ ţō vĩēŵ ţĥĩś ĩśśũē."
}
+24 -2
View File
@@ -3,9 +3,11 @@
"active": "Àćţĩvē",
"addInstance": "Àďď ĩńśţàńćē",
"agentProfile": "Àĝēńţ Ƥŕōƒĩĺē",
"allProjects": "Àĺĺ ƥŕōĵēćţś",
"any": "(àńŷ)",
"authToken": "Àũţĥ ţōķēń",
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
"browseSentryIssues": "Ɓŕōŵśē Śēńţŕŷ ĩśśũēś",
"cantDeleteInstanceInUse_one": "Ćàń'ţ ďēĺēţē \"{{name}}\": {{count}} ŵàţćĥ śţĩĺĺ ƀōũńď ţō ĩţ. Ŕēàśśĩĝń ōŕ ŕēḿōvē ţĥōśē ŵàţćĥēŕś ƒĩŕśţ.",
"cantDeleteInstanceInUse_other": "Ćàń'ţ ďēĺēţē \"{{name}}\": {{count}} ŵàţćĥēś śţĩĺĺ ƀōũńď ţō ĩţ. Ŕēàśśĩĝń ōŕ ŕēḿōvē ţĥōśē ŵàţćĥēŕś ƒĩŕśţ.",
"checkFailed": "Ćĥēćķ ƒàĩĺēď: {{error}}",
@@ -28,6 +30,8 @@
"enterNameAndTokenBeforeSaving": "Ēńţēŕ àń ĩńśţàńćē ńàḿē àńď àũţĥ ţōķēń ƀēƒōŕē śàvĩńĝ.",
"environment": "Ēńvĩŕōńḿēńţ",
"environmentHelp": "Ōƥţĩōńàĺ — ŕēśţŕĩćţ ţō ōńē ēńvĩŕōńḿēńţ.",
"eventsCount_one": "ēvēńţś {{count}}",
"eventsCount_other": "ēvēńţś {{count}}",
"executorProfile": "Ēxēćũţōŕ Ƥŕōƒĩĺē",
"failedToLoadInstances": "Ƒàĩĺēď ţō ĺōàď Śēńţŕŷ ĩńśţàńćēś: {{error}}",
"fallsBackToStepDefault": "Ōƥţĩōńàĺ — ƒàĺĺś ƀàćķ ţō śţēƥ ďēƒàũĺţ.",
@@ -47,6 +51,8 @@
"intervalMinutes_one": "{{count}}ḿ",
"intervalMinutes_other": "{{count}}ḿ",
"lastPolled": "Ĺàśţ Ƥōĺĺēď",
"lastSeen": "Ĺàśţ śēēń",
"level": "Ĺēvēĺ",
"levelDebug": "ďēƀũĝ",
"levelError": "ēŕŕōŕ",
"levelFatal": "ƒàţàĺ",
@@ -54,6 +60,7 @@
"levelWarning": "ŵàŕńĩńĝ",
"levels": "Ĺēvēĺś",
"levelsHelp": "Ćĺĩćķ ţō ţōĝĝĺē. Ḿàţćĥēś ĩśśũēś àţ ÀŃŶ ōƒ ţĥē śēĺēćţēď ĺēvēĺś.",
"loadMore": "Ĺōàď ḿōŕē",
"loadingEllipsis": "Ĺōàďĩńĝ…",
"maxInflightTasks": "Ḿàx ĩń-ƒĺĩĝĥţ ţàśķś",
"maxInflightTasksHelp": "Ćàƥ ōń ōƥēń ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ. Ĺēàvē ƀĺàńķ ƒōŕ ńō ćàƥ. Ńēŵ ḿàţćĥēś àŕē ďēƒēŕŕēď ţō ţĥē ńēxţ ƥōĺĺ ŵĥēń ţĥē ćàƥ ĩś ŕēàćĥēď.",
@@ -64,18 +71,24 @@
"newInstance": "Ńēŵ Ĩńśţàńćē",
"newWatcher": "Ńēŵ ŵàţćĥēŕ",
"noCap": "(ńō ćàƥ)",
"noHealthySentryInstanceCheckThe": "Ńō ĥēàĺţĥŷ Śēńţŕŷ ĩńśţàńćē — ćĥēćķ ţĥē ĩńţēĝŕàţĩōń śēţţĩńĝś.",
"noInstancesInWorkspace": "Ńō Śēńţŕŷ ĩńśţàńćēś ĩń ţĥĩś ŵōŕķśƥàćē",
"noInstancesYet": "Ńō Śēńţŕŷ ĩńśţàńćēś ŷēţ. Àďď ōńē ţō ćōńńēćţ ţĥĩś ŵōŕķśƥàćē.",
"noIssuesYetRunASearch": "Ńō ĩśśũēś ŷēţ. Ŕũń à śēàŕćĥ ţō ƀēĝĩń.",
"noNewIssuesMatched": "Ńō ńēŵ ĩśśũēś ḿàţćĥēď.",
"noOrganizationsAvailable": "Ńō ōŕĝàńĩźàţĩōńś àvàĩĺàƀĺē",
"noProjectsAvailable": "Ńō ƥŕōĵēćţś àvàĩĺàƀĺē",
"noProjectsAvailableSentence": "Ńō ƥŕōĵēćţś àvàĩĺàƀĺē.",
"noSentryInstancesConfiguredForThis": "Ńō Śēńţŕŷ ĩńśţàńćēś ćōńƒĩĝũŕēď ƒōŕ ţĥĩś ŵōŕķśƥàćē.",
"noWatchersConfigured": "Ńō Śēńţŕŷ ŵàţćĥēŕś ćōńƒĩĝũŕēď. Ćŕēàţē ōńē ţō àũţō-ćŕēàţē ţàśķś ƒŕōḿ ƒĩĺţēŕēď ĩśśũēś.",
"orgSlugRequired": "Ōŕĝàńĩźàţĩōń śĺũĝ ĩś ŕēqũĩŕēď",
"organization": "Ōŕĝàńĩźàţĩōń",
"organizationSlug": "Ōŕĝàńĩźàţĩōń śĺũĝ",
"organizationSlugHelp": "Ţĥē Śēńţŕŷ ōŕĝ ţō ƥōĺĺ.",
"pasteAnAuthTokenToTest": "Ƥàśţē àń àũţĥ ţōķēń ţō ţēśţ ţĥē ćōńńēćţĩōń",
"pause": "Ƥàũśē",
"paused": "Ƥàũśēď",
"period": "Ƥēŕĩōď",
"placeholderAssigneeDisplayName": "Àśśĩĝńēē ďĩśƥĺàŷ ńàḿē",
"placeholderCulprit": "Ćũĺƥŕĩţ (ƒũńćţĩōń ōŕ ƒĩĺē)",
"placeholderEventCount": "Ţōţàĺ ēvēńţ ćōũńţ",
@@ -90,12 +103,15 @@
"placeholderUserCount": "Ũńĩqũē ũśēŕś àƒƒēćţēď",
"pollIntervalHelp": "Ĥōŵ ōƒţēń ţō ŕē-ŕũń ţĥē śēàŕćĥ. Ḿĩńĩḿũḿ 60ś, ḿàxĩḿũḿ 3600ś.",
"pollIntervalSeconds": "Ƥōĺĺ Ĩńţēŕvàĺ (śēćōńďś)",
"production": "ƥŕōďũćţĩōń",
"project": "Ƥŕōĵēćţ",
"projectSlug": "Ƥŕōĵēćţ śĺũĝ",
"promptFieldHelp": "Ţĥē ƥŕōḿƥţ śēńţ ţō ţĥē àĝēńţ ƒōŕ ēàćĥ ńēŵ ĩśśũē. Ţŷƥē {{token}} ţō ĩńśēŕţ ƥĺàćēĥōĺďēŕś.",
"projectSlugHelp": "Ţĥē Śēńţŕŷ ƥŕōĵēćţ(ś) ţō ƥōĺĺ. Śēĺēćţ ōńē ōŕ ḿōŕē.",
"projectsSelected_one": "{{count}} ƥŕōĵēćţ śēĺēćţēď",
"projectsSelected_other": "{{count}} ƥŕōĵēćţś śēĺēćţēď",
"promptFieldHelp": "Ţĥē ƥŕōḿƥţ śēńţ ţō ţĥē àĝēńţ ƒōŕ ēàćĥ ńēŵ ĩśśũē. Ţŷƥē {{token}} ţō ĩńśēŕţ ƥĺàćēĥōĺďēŕś.",
"query": "Qũēŕŷ",
"queryExample": "ē.ĝ. {{query}}",
"queryHelp": "Ƒŕēē-ţēxţ Śēńţŕŷ śēàŕćĥ qũēŕŷ (ōƥţĩōńàĺ).",
"removeInstanceConfirm": "Ŕēḿōvē Śēńţŕŷ ĩńśţàńćē \"{{name}}\"?",
"requiredTokenScopes": "Ŕēqũĩŕēď ţōķēń śćōƥēś",
@@ -111,7 +127,10 @@
"scopeOrgRead": "Ōŕĝàńĩźàţĩōń — ŕēśōĺvē ţĥē ōŕĝ àńď ĺĩśţ ĩśśũēś",
"scopeProjectRead": "Ƥŕōĵēćţ — ĺĩśţ ƥŕōĵēćţś àńď śćōƥē śēàŕćĥēś",
"searchProjects": "Śēàŕćĥ ƥŕōĵēćţś...",
"selectASentryInstanceToBrowse": "Śēĺēćţ à Śēńţŕŷ ĩńśţàńćē ţō ƀŕōŵśē ĩţś ĩśśũēś.",
"selectAnInstance": "Śēĺēćţ àń ĩńśţàńćē",
"selectAnInstanceToBrowse": "Śēĺēćţ àń ĩńśţàńćē ţō ƀŕōŵśē",
"selectInstanceToSearch": "Śēĺēćţ à Śēńţŕŷ ĩńśţàńćē ţō śēàŕćĥ",
"selectOrganization": "Śēĺēćţ ōŕĝàńĩźàţĩōń",
"selectProjects": "Śēĺēćţ ƥŕōĵēćţś",
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
@@ -142,6 +161,8 @@
"update": "Ũƥďàţē",
"updateFailed": "Ũƥďàţē ƒàĩĺēď: {{error}}",
"useStepDefault": "(ũśē śţēƥ ďēƒàũĺţ)",
"usersCount_one": "ũśēŕś {{count}}",
"usersCount_other": "ũśēŕś {{count}}",
"watchDialogDescription": "Ƥōĺĺ Śēńţŕŷ ŵĩţĥ à śţŕũćţũŕēď ƒĩĺţēŕ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ĩśśũē. Ōƥţĩōńàĺĺŷ ƀĩńď à ŕēƥōśĩţōŕŷ śō ēàćĥ ţàśķ ŕũńś àĝàĩńśţ ţĥàţ ćōďēƀàśē, ōŕ ĺēàvē ĩţ ũńśēţ ţō ŕũń ŵĩţĥ ńō ŕēƥōśĩţōŕŷ.",
"watcherCreated": "Ŵàţćĥēŕ ćŕēàţēď",
"watcherDeleted": "Ŵàţćĥēŕ ďēĺēţēď",
@@ -150,5 +171,6 @@
"workflowHelp": "Ţàśķś àŕē ćŕēàţēď ĩń ţĥĩś ŵōŕķƒĺōŵ.",
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ",
"workflowStepHelp": "Ĩńĩţĩàĺ śţēƥ ƒōŕ ńēŵ ţàśķś.",
"workspaceHelp": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē."
"workspaceHelp": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
"yourSentryAuthTokenIsInvalid": "Ŷōũŕ Śēńţŕŷ àũţĥ ţōķēń ĩś ĩńvàĺĩď ōŕ ĥàś ƀēēń ŕēvōķēď. Ŕēćōńńēćţ ţō vĩēŵ ţĥĩś ĩśśũē."
}