7b5b3f329d
* feat: add Linear integration
Mirrors the existing Jira integration so a workspace can connect to Linear
with a personal API key, browse and search issues, and start a Kandev task
from any issue. Backend uses Linear's GraphQL API with a workspace-scoped
config + secret store and a 90s auth-health poller; the frontend adds a
settings page, an issue dialog with state transitions, link/import buttons
in the task UI, and a paginated /linear browse page with a Start task flow.
* fix(linear): address PR review feedback
- Route httpListStates through Service.ListStates instead of clientFor
- Extract repeated workspace_id required string into a constant
- Log GetConfig errors during credential resolution instead of dropping them
- Clear popover error state on close in import + link buttons
- Pick the first workflow with steps in the quick task launcher (don't hard-pin to workflows[0])
- Reset useLinearEnabled state on workspace change via tag-based pattern
- Add cursor-pointer to anchor + Link elements per project convention
- Add tests: handlers_test.go, linear-api.test.ts, use-linear-enabled.test.ts, linear-issue-common.test.tsx
- Document internal/jira and internal/linear in CLAUDE.md/AGENTS.md
* fix(linear): address second-round PR review feedback
- Round Linear's fractional state position instead of truncating it via
int(math.Round(s.Position)) so adjacent fractional positions don't collapse
- Force LinearSettings to remount on workspace change with key={id} so the
previous workspace's config / form / testResult / teams cannot bleed into
the next workspace before the new fetches settle
- Broaden the legacy-value useLinearEnabled test to cover "true", "1", "yes",
and "legacy" so a regression to strict value === "true" would fail
* fix(linear): address third-round PR review feedback
- Propagate context.Canceled / context.DeadlineExceeded from ListStates in
GetIssue instead of swallowing them — a caller cancellation must not return
a partial-success response
- Drop the one-off contains() test helper in handlers_test.go and use
strings.Contains directly so the assertion is self-documenting
- Add getLinearIssue URL-shape test that locks identifier-as-path-segment
vs workspaceId-as-query-param so accidentally swapping them would fail
* test(linear): extract repeated literals in linear-api.test.ts
Pulls "ws-1", the API base URL, and the auth-method literal into module-
level constants so the assertions read as the route shape they care about
and the duplicate-strings ESLint rule (currently warn, threshold 4)
stays quiet as the file grows.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
495 lines
14 KiB
TypeScript
495 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { IconHexagon, IconCheck, IconAlertTriangle } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Card, CardContent } from "@kandev/ui/card";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { Label } from "@kandev/ui/label";
|
|
import { Separator } from "@kandev/ui/separator";
|
|
import { Alert, AlertDescription } from "@kandev/ui/alert";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
|
import { Switch } from "@kandev/ui/switch";
|
|
import { useToast } from "@/components/toast-provider";
|
|
import { SettingsSection } from "@/components/settings/settings-section";
|
|
import { useLinearEnabled } from "./use-linear-enabled";
|
|
import {
|
|
getLinearConfig,
|
|
setLinearConfig,
|
|
deleteLinearConfig,
|
|
testLinearConnection,
|
|
listLinearTeams,
|
|
} from "@/lib/api/domains/linear-api";
|
|
import type { LinearConfig, LinearTeam, TestLinearConnectionResult } from "@/lib/types/linear";
|
|
|
|
// How often to re-fetch the config so the auth-health indicator picks up new
|
|
// probe results from the backend poller (which runs every 90s).
|
|
const STATUS_REFRESH_MS = 90_000;
|
|
|
|
type FormState = {
|
|
defaultTeamKey: string;
|
|
secret: string;
|
|
};
|
|
|
|
const emptyForm: FormState = { defaultTeamKey: "", secret: "" };
|
|
|
|
function configToForm(cfg: LinearConfig | null): FormState {
|
|
if (!cfg) return emptyForm;
|
|
return { defaultTeamKey: cfg.defaultTeamKey, secret: "" };
|
|
}
|
|
|
|
function saveLabel(saving: boolean, hasConfig: boolean): string {
|
|
if (saving) return "Saving...";
|
|
return hasConfig ? "Update" : "Save";
|
|
}
|
|
|
|
type FieldsRowProps = {
|
|
form: FormState;
|
|
loading: boolean;
|
|
update: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
|
|
hasSavedSecret: boolean;
|
|
teams: LinearTeam[];
|
|
loadingTeams: boolean;
|
|
};
|
|
|
|
function SecretField({
|
|
form,
|
|
loading,
|
|
update,
|
|
hasSavedSecret,
|
|
}: Omit<FieldsRowProps, "teams" | "loadingTeams">) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="linear-secret">
|
|
API key
|
|
{hasSavedSecret && (
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
(saved — leave blank to keep the current value)
|
|
</span>
|
|
)}
|
|
</Label>
|
|
<Input
|
|
id="linear-secret"
|
|
type="password"
|
|
placeholder={hasSavedSecret ? "••••••••" : "lin_api_..."}
|
|
value={form.secret}
|
|
onChange={(e) => update("secret", e.target.value)}
|
|
disabled={loading}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Create a personal API key at{" "}
|
|
<a
|
|
className="underline cursor-pointer"
|
|
href="https://linear.app/settings/account/security"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
linear.app/settings/account/security
|
|
</a>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TeamSelector({ form, loading, update, teams, loadingTeams }: FieldsRowProps) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="linear-team">Default team (optional)</Label>
|
|
<Select
|
|
value={form.defaultTeamKey || "__none__"}
|
|
onValueChange={(v) => update("defaultTeamKey", v === "__none__" ? "" : v)}
|
|
disabled={loading || loadingTeams}
|
|
>
|
|
<SelectTrigger id="linear-team" className="w-full">
|
|
<SelectValue placeholder={loadingTeams ? "Loading teams…" : "Choose a team"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">No default</SelectItem>
|
|
{teams.map((t) => (
|
|
<SelectItem key={t.id} value={t.key}>
|
|
{t.name} ({t.key})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TestResultAlert({ result }: { result: TestLinearConnectionResult | null }) {
|
|
if (!result) return null;
|
|
return (
|
|
<Alert variant={result.ok ? "default" : "destructive"}>
|
|
<AlertDescription>
|
|
{result.ok
|
|
? `Connected as ${result.displayName || result.email || result.userId}${result.orgName ? ` (${result.orgName})` : ""}`
|
|
: `Failed: ${result.error}`}
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
type AuthHealth = {
|
|
ok: boolean;
|
|
error: string;
|
|
checkedAt: Date | null;
|
|
};
|
|
|
|
function configToHealth(config: LinearConfig | null): AuthHealth | null {
|
|
if (!config?.hasSecret) return null;
|
|
if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null };
|
|
return {
|
|
ok: !!config.lastOk,
|
|
error: config.lastError ?? "",
|
|
checkedAt: new Date(config.lastCheckedAt),
|
|
};
|
|
}
|
|
|
|
function useTick(intervalMs: number) {
|
|
const [, setTick] = useState(0);
|
|
useEffect(() => {
|
|
const id = setInterval(() => setTick((n) => n + 1), intervalMs);
|
|
return () => clearInterval(id);
|
|
}, [intervalMs]);
|
|
}
|
|
|
|
function LastCheckedLabel({ checkedAt }: { checkedAt: Date | null }) {
|
|
useTick(30_000);
|
|
if (!checkedAt) return null;
|
|
return (
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
· checked {formatDistanceToNow(checkedAt, { addSuffix: true })}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function AuthStatusBanner({ health }: { health: AuthHealth | null }) {
|
|
if (!health) return null;
|
|
if (!health.checkedAt) {
|
|
return (
|
|
<Alert>
|
|
<AlertDescription className="text-sm">
|
|
Waiting for the next backend health check…
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
if (health.ok) {
|
|
return (
|
|
<Alert className="border-green-500/40 bg-green-500/10 dark:border-green-400/30 dark:bg-green-400/10">
|
|
<IconCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
|
|
<AlertDescription className="text-sm font-medium">
|
|
Authenticated
|
|
<LastCheckedLabel checkedAt={health.checkedAt} />
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
return (
|
|
<Alert variant="destructive">
|
|
<IconAlertTriangle className="h-4 w-4" />
|
|
<AlertDescription className="text-sm">
|
|
Authentication failed: {health.error || "unknown error"}
|
|
<LastCheckedLabel checkedAt={health.checkedAt} />
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
type ActionBarProps = {
|
|
saving: boolean;
|
|
testing: boolean;
|
|
loading: boolean;
|
|
hasConfig: boolean;
|
|
disableSave: boolean;
|
|
disableTest: boolean;
|
|
onTest: () => void;
|
|
onSave: () => void;
|
|
onDelete: () => void;
|
|
};
|
|
|
|
function ActionBar({
|
|
saving,
|
|
testing,
|
|
loading,
|
|
hasConfig,
|
|
disableSave,
|
|
disableTest,
|
|
onTest,
|
|
onSave,
|
|
onDelete,
|
|
}: ActionBarProps) {
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onTest}
|
|
disabled={testing || loading || disableTest}
|
|
className="cursor-pointer"
|
|
title={disableTest ? "Paste an API key to test the connection" : undefined}
|
|
>
|
|
{testing ? "Testing..." : "Test connection"}
|
|
</Button>
|
|
<Button type="button" onClick={onSave} disabled={disableSave} className="cursor-pointer">
|
|
{saveLabel(saving, hasConfig)}
|
|
</Button>
|
|
{hasConfig && (
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={onDelete}
|
|
className="ml-auto cursor-pointer"
|
|
>
|
|
Remove configuration
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type LinearSettingsProps = {
|
|
workspaceId: string;
|
|
};
|
|
|
|
type SettingsActionsArgs = {
|
|
workspaceId: string;
|
|
form: FormState;
|
|
setConfig: (cfg: LinearConfig | null) => void;
|
|
setForm: (form: FormState) => void;
|
|
setTestResult: (r: TestLinearConnectionResult | null) => void;
|
|
};
|
|
|
|
function useSettingsActions({
|
|
workspaceId,
|
|
form,
|
|
setConfig,
|
|
setForm,
|
|
setTestResult,
|
|
}: SettingsActionsArgs) {
|
|
const { toast } = useToast();
|
|
const [saving, setSaving] = useState(false);
|
|
const [testing, setTesting] = useState(false);
|
|
|
|
const handleTest = useCallback(async () => {
|
|
setTesting(true);
|
|
setTestResult(null);
|
|
try {
|
|
const res = await testLinearConnection({
|
|
workspaceId,
|
|
authMethod: "api_key",
|
|
secret: form.secret || undefined,
|
|
});
|
|
setTestResult(res);
|
|
} catch (err) {
|
|
setTestResult({ ok: false, error: String(err) });
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
}, [workspaceId, form, setTestResult]);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
setSaving(true);
|
|
try {
|
|
const saved = await setLinearConfig({
|
|
workspaceId,
|
|
authMethod: "api_key",
|
|
defaultTeamKey: form.defaultTeamKey,
|
|
secret: form.secret || undefined,
|
|
});
|
|
setConfig(saved);
|
|
setForm(configToForm(saved));
|
|
setTestResult(null);
|
|
toast({ description: "Linear configuration saved", variant: "success" });
|
|
} catch (err) {
|
|
toast({ description: `Save failed: ${String(err)}`, variant: "error" });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [workspaceId, form, toast, setConfig, setForm, setTestResult]);
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
if (!confirm("Remove Linear configuration for this workspace?")) return;
|
|
try {
|
|
await deleteLinearConfig(workspaceId);
|
|
setConfig(null);
|
|
setForm(emptyForm);
|
|
setTestResult(null);
|
|
toast({ description: "Linear configuration removed", variant: "success" });
|
|
} catch (err) {
|
|
toast({ description: `Delete failed: ${String(err)}`, variant: "error" });
|
|
}
|
|
}, [workspaceId, toast, setConfig, setForm, setTestResult]);
|
|
|
|
return { saving, testing, handleTest, handleSave, handleDelete };
|
|
}
|
|
|
|
function useTeamsLoader(
|
|
workspaceId: string,
|
|
hasSecret: boolean | undefined,
|
|
lastOk: boolean | undefined,
|
|
) {
|
|
// `teams === null` means "no fetch attempt yet", so the dropdown can show a
|
|
// "Loading…" placeholder without us calling setState synchronously inside
|
|
// the effect (which the lint rule forbids). Once a fetch settles we always
|
|
// store an array, even if empty.
|
|
const [teams, setTeams] = useState<LinearTeam[] | null>(null);
|
|
// Fetch teams once a working configuration exists. Skips when there's no
|
|
// saved secret (the API call would 503). Stale teams from a previous save
|
|
// remain visible after deletion, but the dropdown is gated on hasSecret so
|
|
// the user never sees them.
|
|
useEffect(() => {
|
|
if (!hasSecret) return;
|
|
let cancelled = false;
|
|
listLinearTeams(workspaceId)
|
|
.then((res) => {
|
|
if (!cancelled) setTeams(res.teams ?? []);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setTeams([]);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [workspaceId, hasSecret, lastOk]);
|
|
return { teams: teams ?? [], loadingTeams: teams === null && !!hasSecret };
|
|
}
|
|
|
|
function useLinearSettings(workspaceId: string) {
|
|
const { toast } = useToast();
|
|
const [config, setConfig] = useState<LinearConfig | null>(null);
|
|
const [form, setForm] = useState<FormState>(emptyForm);
|
|
const [loading, setLoading] = useState(true);
|
|
const [testResult, setTestResult] = useState<TestLinearConnectionResult | null>(null);
|
|
const health = configToHealth(config);
|
|
const { teams, loadingTeams } = useTeamsLoader(workspaceId, config?.hasSecret, config?.lastOk);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const cfg = await getLinearConfig(workspaceId);
|
|
setConfig(cfg);
|
|
setForm(configToForm(cfg));
|
|
} catch (err) {
|
|
toast({ description: `Failed to load Linear config: ${String(err)}`, variant: "error" });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [workspaceId, toast]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
// Background refresh so the auth-health banner picks up new probe results.
|
|
useEffect(() => {
|
|
const id = setInterval(() => {
|
|
getLinearConfig(workspaceId)
|
|
.then((cfg) => setConfig(cfg))
|
|
.catch(() => {
|
|
/* transient failures are fine — next tick retries */
|
|
});
|
|
}, STATUS_REFRESH_MS);
|
|
return () => clearInterval(id);
|
|
}, [workspaceId]);
|
|
|
|
const update = useCallback(
|
|
<K extends keyof FormState>(key: K, value: FormState[K]) =>
|
|
setForm((prev) => ({ ...prev, [key]: value })),
|
|
[],
|
|
);
|
|
|
|
const { saving, testing, handleTest, handleSave, handleDelete } = useSettingsActions({
|
|
workspaceId,
|
|
form,
|
|
setConfig,
|
|
setForm,
|
|
setTestResult,
|
|
});
|
|
|
|
return {
|
|
config,
|
|
form,
|
|
loading,
|
|
saving,
|
|
testing,
|
|
testResult,
|
|
health,
|
|
teams,
|
|
loadingTeams,
|
|
update,
|
|
handleTest,
|
|
handleSave,
|
|
handleDelete,
|
|
};
|
|
}
|
|
|
|
function EnabledPill({ workspaceId }: { workspaceId: string }) {
|
|
const { enabled, setEnabled } = useLinearEnabled(workspaceId);
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-full border bg-muted/30 px-3 py-1">
|
|
<Switch
|
|
id="linear-enabled"
|
|
checked={enabled}
|
|
onCheckedChange={setEnabled}
|
|
className="cursor-pointer"
|
|
/>
|
|
<Label htmlFor="linear-enabled" className="text-xs cursor-pointer">
|
|
{enabled ? "Enabled" : "Disabled"}
|
|
</Label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function LinearSettings({ workspaceId }: LinearSettingsProps) {
|
|
const s = useLinearSettings(workspaceId);
|
|
const missingSecret = !s.config?.hasSecret && !s.form.secret;
|
|
const disableSave = s.saving || missingSecret;
|
|
const disableTest = missingSecret;
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<SettingsSection
|
|
icon={<IconHexagon className="h-5 w-5" />}
|
|
title="Linear integration"
|
|
description="Connect this workspace to Linear with a personal API key. Credentials are stored encrypted server-side."
|
|
action={<EnabledPill workspaceId={workspaceId} />}
|
|
>
|
|
<Card>
|
|
<CardContent className="space-y-4 pt-6">
|
|
<AuthStatusBanner health={s.health} />
|
|
<SecretField
|
|
form={s.form}
|
|
loading={s.loading}
|
|
update={s.update}
|
|
hasSavedSecret={!!s.config?.hasSecret}
|
|
/>
|
|
<TeamSelector
|
|
form={s.form}
|
|
loading={s.loading}
|
|
update={s.update}
|
|
hasSavedSecret={!!s.config?.hasSecret}
|
|
teams={s.teams}
|
|
loadingTeams={s.loadingTeams}
|
|
/>
|
|
<TestResultAlert result={s.testResult} />
|
|
<Separator />
|
|
<ActionBar
|
|
saving={s.saving}
|
|
testing={s.testing}
|
|
loading={s.loading}
|
|
hasConfig={!!s.config}
|
|
disableSave={disableSave}
|
|
disableTest={disableTest}
|
|
onTest={s.handleTest}
|
|
onSave={s.handleSave}
|
|
onDelete={s.handleDelete}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</SettingsSection>
|
|
</div>
|
|
);
|
|
}
|