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>
212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { IconLockExclamation } from "@tabler/icons-react";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@kandev/ui/avatar";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { getLinearIssue, setLinearIssueState } from "@/lib/api/domains/linear-api";
|
|
import type { LinearIssue, LinearStateCategory } from "@/lib/types/linear";
|
|
|
|
// Matches Linear identifiers like ENG-123. Linear team keys are always
|
|
// uppercase and 1+ chars; we require a leading capital letter to avoid catching
|
|
// random UUID fragments or version strings (v1-2). Anchored on word boundaries
|
|
// so we can extract from "ENG-12: fix login".
|
|
export const LINEAR_KEY_RE = /\b[A-Z][A-Z0-9]*-\d+\b/;
|
|
|
|
export function extractLinearKey(title: string | undefined | null): string | null {
|
|
if (!title) return null;
|
|
const match = title.match(LINEAR_KEY_RE);
|
|
return match ? match[0] : null;
|
|
}
|
|
|
|
// Map Linear's stateCategory to Tailwind colour classes — same three buckets
|
|
// the Jira integration uses so status pills look consistent.
|
|
export function stateBadgeClass(category: LinearStateCategory | undefined): string {
|
|
switch (category) {
|
|
case "done":
|
|
return "bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/30";
|
|
case "indeterminate":
|
|
return "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/30";
|
|
case "new":
|
|
return "bg-blue-500/15 text-blue-700 dark:text-blue-400 border-blue-500/30";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function formatRelative(iso: string | undefined): string {
|
|
if (!iso) return "";
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
return formatDistanceToNow(d, { addSuffix: true });
|
|
}
|
|
|
|
export function PersonCell({ name, avatar }: { name?: string; avatar?: string }) {
|
|
if (!name) return <span className="text-muted-foreground">Unassigned</span>;
|
|
return (
|
|
<>
|
|
<Avatar size="sm" className="size-5">
|
|
{avatar && <AvatarImage src={avatar} alt={name} />}
|
|
<AvatarFallback className="text-[10px]">{name.charAt(0)}</AvatarFallback>
|
|
</Avatar>
|
|
<span className="truncate">{name}</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Linear priority enum: 0=none, 1=urgent, 2=high, 3=medium, 4=low. The label
|
|
// the API returns is already humanised — we just keep the colour cue.
|
|
export function priorityClass(priority: number | undefined): string {
|
|
switch (priority) {
|
|
case 1:
|
|
return "text-red-600 dark:text-red-400";
|
|
case 2:
|
|
return "text-orange-600 dark:text-orange-400";
|
|
case 3:
|
|
return "text-amber-600 dark:text-amber-400";
|
|
case 4:
|
|
return "text-muted-foreground";
|
|
default:
|
|
return "text-muted-foreground";
|
|
}
|
|
}
|
|
|
|
export type IssueState = {
|
|
issue: LinearIssue | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
pendingState: string | null;
|
|
load: () => Promise<void>;
|
|
handleStateChange: (stateId: string) => Promise<void>;
|
|
};
|
|
|
|
// Shared hook for loading a Linear issue and changing its workflow state.
|
|
// Mirrors the Jira useTicketState hook.
|
|
export function useIssueState(
|
|
workspaceId: string,
|
|
identifier: string,
|
|
enabled: boolean,
|
|
): IssueState {
|
|
const [issue, setIssue] = useState<LinearIssue | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [pendingState, setPendingState] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const i = await getLinearIssue(workspaceId, identifier);
|
|
setIssue(i);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [workspaceId, identifier]);
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !workspaceId || !identifier) return;
|
|
let cancelled = false;
|
|
async function run() {
|
|
setIssue(null);
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const i = await getLinearIssue(workspaceId, identifier);
|
|
if (!cancelled) setIssue(i);
|
|
} catch (err) {
|
|
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
}
|
|
void run();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [enabled, workspaceId, identifier]);
|
|
|
|
const handleStateChange = useCallback(
|
|
async (stateId: string) => {
|
|
if (!issue) return;
|
|
setPendingState(stateId);
|
|
setError(null);
|
|
try {
|
|
await setLinearIssueState(workspaceId, issue.id, stateId);
|
|
await load();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setPendingState(null);
|
|
}
|
|
},
|
|
[workspaceId, issue, load],
|
|
);
|
|
|
|
return { issue, loading, error, pendingState, load, handleStateChange };
|
|
}
|
|
|
|
// Backend wraps Linear errors as `linear api: status N: …`. 401/403 mean the
|
|
// API key is invalid; everything else is propagated verbatim.
|
|
const AUTH_STATUS_RE = /\bstatus (?:401|403)\b/i;
|
|
|
|
export function isLinearAuthError(error: string): boolean {
|
|
return AUTH_STATUS_RE.test(error);
|
|
}
|
|
|
|
const URL_RE = /\bhttps?:\/\/\S+/g;
|
|
|
|
export function cleanLinearErrorMessage(error: string): string {
|
|
return error.replace(URL_RE, "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
type LinearErrorMessageProps = {
|
|
error: string;
|
|
workspaceId?: string | null;
|
|
compact?: boolean;
|
|
};
|
|
|
|
export function LinearErrorMessage({ error, workspaceId, compact }: LinearErrorMessageProps) {
|
|
const isAuth = isLinearAuthError(error);
|
|
const settingsHref = workspaceId ? `/settings/workspace/${workspaceId}/linear` : "/settings";
|
|
|
|
if (compact) {
|
|
return (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<span className={isAuth ? "text-muted-foreground" : "text-destructive"}>
|
|
{isAuth ? "Linear authentication required." : cleanLinearErrorMessage(error)}
|
|
</span>
|
|
{isAuth && (
|
|
<Button asChild size="sm" variant="outline" className="cursor-pointer h-7 text-xs">
|
|
<Link href={settingsHref}>Reconnect Linear</Link>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-md text-center space-y-4">
|
|
{isAuth ? (
|
|
<>
|
|
<IconLockExclamation className="h-10 w-10 mx-auto text-muted-foreground" />
|
|
<div className="space-y-1.5">
|
|
<h2 className="text-lg font-semibold">Linear authentication required</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Your Linear API key is invalid or has been revoked. Reconnect to view this issue.
|
|
</p>
|
|
</div>
|
|
<Button asChild size="sm" className="cursor-pointer">
|
|
<Link href={settingsHref}>Reconnect Linear</Link>
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-destructive">{cleanLinearErrorMessage(error)}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|