Files
kandev/apps/web/components/linear/linear-issue-common.tsx
T
João Salavisa 7b5b3f329d feat: add Linear integration (#736)
* 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>
2026-05-01 10:50:14 +01:00

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>
);
}