54b98ed9eb
* feat(jira): add Jira integration for ticket browsing, import, and task linking Adds a workspace-scoped Atlassian Cloud integration: per-workspace settings (site URL, email, API token or session cookie), encrypted secret storage, and a REST client for tickets, projects, search, and transitions. A background poller probes credentials on a 90s cadence and the keepalive runs immediately on save so the UI's connected indicator updates without delay. The /jira page provides ticket browsing with filter pills, custom JQL, saved views (including JQL), and configurable task-launch presets that prefill TaskCreateDialog. JiraLinkButton on the task top bar links an existing task to a ticket by prepending its key to the title; the ticket popover surfaces details, transitions, and re-auth prompts on session expiry. Topbar shows separate icon buttons for GitHub and Jira when each is configured. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): address PR review feedback - Token-based pagination (pageToken/isLast) replaces broken offset/total pagination - Race-fix in service.clientFor: re-check cache after acquiring write lock - sync.Mutex guards poller started/cancel state against concurrent Start/Stop - Channel-based + atomic.Int32 test sync replaces time.Sleep polling in poller_test - Async auth probe on SetConfig with detached context.Background goroutine - Distinguish 4xx vs 5xx in httpSetConfig via ErrInvalidConfig sentinel - Propagate 4xx APIErrors from ListTransitions in GetTicket - Distinguish "not found" from real errors in jiraSecretAdapter.Exists - Log secret Exists errors instead of swallowing - walkADF: handle softBreak (was only hardBreak despite the doc-comment) - Consolidate JIRA_KEY_RE in jira-ticket-common; remove duplicates - ticket-row imports formatRelative/statusBadgeClass from common - isFilterState type guard validates SavedView.filters shape - task-presets readStorage preserves user's "cleared all" intent (returns []) - task-presets-section gates draft sync on `loaded` to avoid wiping in-progress edits - jira-api: caller `init` no longer overrides method/body via spread order - jira-settings: disable Test button when no saved/typed secret Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): record auth probe failure even when probe ctx expires - RecordAuthHealth now uses a fresh context.Background-derived ctx with a 5s timeout for the UpdateAuthHealth DB write, so a probe that consumes its full 15s deadline still persists the failure (greptile P1). - SetConfig goroutine drops its redundant outer timeout — RecordAuthHealth manages its own probe timeout, so context.Background is enough. - Add Service.SetProbeHook so tests can synchronize on probe completion; service_test waitForAuthProbe now blocks on the hook channel instead of time.Sleep polling. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): address coderabbit review feedback (round 3) - url.PathEscape ticketKey in GetTicket/ListTransitions/DoTransition paths to harden against malicious keys ('../../foo', 'KEY?override=x'). - factoryHit on the test fixture is now sync/atomic.Int32; the async probe goroutine spawned by SetConfig races the test thread under -race. - GetConfig logs Reveal failures (was silently dropped, leaving the UI to show "no expiry info" indistinguishably from a freshly-saved cookie). - DeleteConfig logs secret-delete errors instead of swallowing them. - clientFor distinguishes a secret-store failure from "not configured" so the UI doesn't render the configure-CTA for transient backend errors. - Unify ErrNotConfigured wire code: HTTP and WebSocket both return "JIRA_NOT_CONFIGURED" via a shared constant. - JiraLinkButton strips a leading "PROJ-123: " before re-prefixing so re-linking a task replaces the prefix instead of stacking it. - jira-settings clears testResult on save/delete so the alert reflects only the currently-saved credentials. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(jira): cover Reveal error path in clientFor Reveal-error case is distinct from "no config row" — the former wraps the underlying error, the latter returns ErrNotConfigured. Without this test the changed contract has no regression coverage. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): route secret adapter Set through Exists Treating every Get error as "not found" turned a transient DB error on an existing row into a constraint-violation Create that masked the real cause. Reuse Exists so the "secret not found:" prefix is the only path that falls through to Create. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(jira): cover jiraSecretAdapter.Set branching Routing Set through Exists changed the contract: a transient DB error in Get now propagates instead of cascading into a constraint-violation Create. Add three tests so a future refactor cannot regress this without CI catching it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): split Reveal errors and guard Save for new configs - resolveCredentials now mirrors the clientFor split: a Reveal error surfaces as `read jira secret: %w` with a warn log instead of collapsing into "no token stored — paste one to test". - disableSave gains the same missing-secret guard as disableTest so a brand-new config can no longer POST an empty secret and leave the workspace stuck with hasSecret=false. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
259 lines
8.4 KiB
TypeScript
259 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import { createElement, useCallback, useMemo, useState } from "react";
|
|
import { IconPlus, IconRefresh, IconTrash } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
|
import { useToast } from "@/components/toast-provider";
|
|
import { SettingsSection } from "@/components/settings/settings-section";
|
|
import { useJiraTaskPresets } from "@/components/jira/my-jira/use-task-presets";
|
|
import {
|
|
DEFAULT_JIRA_PRESETS,
|
|
PRESET_ICON_CHOICES,
|
|
iconForPresetKey,
|
|
type JiraStoredPreset,
|
|
} from "@/components/jira/my-jira/presets";
|
|
import {
|
|
ScriptEditor,
|
|
computeEditorHeight,
|
|
} from "@/components/settings/profile-edit/script-editor";
|
|
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
|
|
|
|
const JIRA_PROMPT_PLACEHOLDERS: ScriptPlaceholder[] = [
|
|
{
|
|
key: "key",
|
|
description: "Jira ticket key",
|
|
example: "PROJ-123",
|
|
executor_types: [],
|
|
},
|
|
{
|
|
key: "url",
|
|
description: "URL of the Jira ticket",
|
|
example: "https://company.atlassian.net/browse/PROJ-123",
|
|
executor_types: [],
|
|
},
|
|
{
|
|
key: "title",
|
|
description: "Ticket summary",
|
|
example: "Login button broken on Safari",
|
|
executor_types: [],
|
|
},
|
|
{
|
|
key: "description",
|
|
description: "Ticket description",
|
|
example: "Repro: open Safari, click login…",
|
|
executor_types: [],
|
|
},
|
|
];
|
|
|
|
function newPreset(): JiraStoredPreset {
|
|
return {
|
|
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
|
label: "New action",
|
|
hint: "",
|
|
icon: "sparkle",
|
|
prompt_template: "",
|
|
};
|
|
}
|
|
|
|
function IconSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
return (
|
|
<Select value={value} onValueChange={onChange}>
|
|
<SelectTrigger className="!h-8 py-0.5 text-sm cursor-pointer" aria-label="Icon">
|
|
<SelectValue>
|
|
{createElement(iconForPresetKey(value), { className: "h-4 w-4" })}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{PRESET_ICON_CHOICES.map((choice) => {
|
|
const ChoiceIcon = choice.icon;
|
|
return (
|
|
<SelectItem key={choice.key} value={choice.key} className="cursor-pointer">
|
|
<span className="flex items-center gap-2">
|
|
<ChoiceIcon className="h-3.5 w-3.5" />
|
|
{choice.label}
|
|
</span>
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
function PresetRow({
|
|
preset,
|
|
expanded,
|
|
onToggle,
|
|
onPatch,
|
|
onRemove,
|
|
}: {
|
|
preset: JiraStoredPreset;
|
|
expanded: boolean;
|
|
onToggle: () => void;
|
|
onPatch: (patch: Partial<JiraStoredPreset>) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
return (
|
|
<div className="rounded-md border">
|
|
<div className="flex items-end gap-2 p-2">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-[10px] text-muted-foreground">Icon</span>
|
|
<IconSelect value={preset.icon} onChange={(v) => onPatch({ icon: v })} />
|
|
</div>
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-[10px] text-muted-foreground">Label</span>
|
|
<Input
|
|
className="h-8 w-40"
|
|
value={preset.label}
|
|
placeholder="Label"
|
|
onChange={(e) => onPatch({ label: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-0.5 flex-1">
|
|
<span className="text-[10px] text-muted-foreground">Hint</span>
|
|
<Input
|
|
className="h-8"
|
|
value={preset.hint}
|
|
placeholder="Hint (optional)"
|
|
onChange={(e) => onPatch({ hint: e.target.value })}
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 cursor-pointer text-xs"
|
|
onClick={onToggle}
|
|
>
|
|
{expanded ? "Hide prompt" : "Edit prompt"}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 cursor-pointer text-destructive"
|
|
onClick={onRemove}
|
|
aria-label="Remove"
|
|
>
|
|
<IconTrash className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
{expanded && (
|
|
<div className="px-2 pb-2 space-y-1">
|
|
<div className="rounded-md border overflow-hidden">
|
|
<ScriptEditor
|
|
value={preset.prompt_template}
|
|
onChange={(v) => onPatch({ prompt_template: v })}
|
|
language="markdown"
|
|
height={computeEditorHeight(preset.prompt_template)}
|
|
lineNumbers="off"
|
|
placeholders={JIRA_PROMPT_PLACEHOLDERS}
|
|
/>
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground/60">
|
|
Type {"{{"} to see available placeholders.{" "}
|
|
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{key}}"}</code>,{" "}
|
|
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{url}}"}</code>,{" "}
|
|
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{title}}"}</code>, and{" "}
|
|
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{description}}"}</code>{" "}
|
|
are substituted when the action runs.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function usePresetDraft() {
|
|
const { stored, save: persistSave, reset: persistReset, loaded } = useJiraTaskPresets();
|
|
const [draft, setDraft] = useState<JiraStoredPreset[]>(stored);
|
|
// Render-time conditional setState is React's documented "adjust state
|
|
// during render" pattern; it resets the draft when the hook's stored value
|
|
// changes (e.g. after reset or cross-tab edit). Gate the sync on `loaded` so
|
|
// an in-progress edit isn't wiped when the initial localStorage read lands.
|
|
const [synced, setSynced] = useState(stored);
|
|
if (loaded && stored !== synced) {
|
|
setSynced(stored);
|
|
setDraft(stored);
|
|
}
|
|
const dirty = useMemo(() => JSON.stringify(stored) !== JSON.stringify(draft), [stored, draft]);
|
|
const save = useCallback(() => persistSave(draft), [persistSave, draft]);
|
|
const reset = useCallback(() => {
|
|
persistReset();
|
|
setDraft(DEFAULT_JIRA_PRESETS);
|
|
}, [persistReset]);
|
|
return { draft, setDraft, dirty, save, reset, loaded };
|
|
}
|
|
|
|
export function TaskPresetsSection() {
|
|
const { toast } = useToast();
|
|
const { draft, setDraft, dirty, save, reset, loaded } = usePresetDraft();
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
|
|
const patch = useCallback(
|
|
(index: number, change: Partial<JiraStoredPreset>) => {
|
|
setDraft(draft.map((p, i) => (i === index ? { ...p, ...change } : p)));
|
|
},
|
|
[draft, setDraft],
|
|
);
|
|
const remove = useCallback(
|
|
(index: number) => setDraft(draft.filter((_, i) => i !== index)),
|
|
[draft, setDraft],
|
|
);
|
|
const add = useCallback(() => {
|
|
const created = newPreset();
|
|
setDraft([...draft, created]);
|
|
setExpandedId(created.id);
|
|
}, [draft, setDraft]);
|
|
|
|
const handleSave = () => {
|
|
save();
|
|
toast({ description: "Task presets saved", variant: "success" });
|
|
};
|
|
const handleReset = () => {
|
|
reset();
|
|
toast({ description: "Task presets reset to defaults", variant: "success" });
|
|
};
|
|
|
|
return (
|
|
<SettingsSection
|
|
title="Task presets"
|
|
description="Prompts shown on /jira when starting a task from a ticket."
|
|
action={
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleReset}
|
|
disabled={!loaded}
|
|
className="cursor-pointer"
|
|
>
|
|
<IconRefresh className="h-3.5 w-3.5 mr-1" />
|
|
Reset
|
|
</Button>
|
|
<Button size="sm" onClick={handleSave} disabled={!dirty} className="cursor-pointer">
|
|
Save changes
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="space-y-2">
|
|
{draft.map((preset, index) => (
|
|
<PresetRow
|
|
key={preset.id}
|
|
preset={preset}
|
|
expanded={expandedId === preset.id}
|
|
onToggle={() => setExpandedId((id) => (id === preset.id ? null : preset.id))}
|
|
onPatch={(p) => patch(index, p)}
|
|
onRemove={() => remove(index)}
|
|
/>
|
|
))}
|
|
<Button size="sm" variant="outline" onClick={add} className="cursor-pointer">
|
|
<IconPlus className="h-3.5 w-3.5 mr-1" />
|
|
Add preset
|
|
</Button>
|
|
</div>
|
|
</SettingsSection>
|
|
);
|
|
}
|