Files
kandev/apps/web/components/linear/linear-issue-common.tsx
T
João Salavisa 83e01477df refactor: extract shared shapes between jira and linear integrations (#759)
* refactor: extract shared shapes between jira and linear integrations

Factors out the duplicated logic between internal/jira + internal/linear
(backend) and components/jira + components/linear (frontend) into reusable
shared shapes. Follow-up to PR #736.

Backend:
- New internal/integrations/secretadapter wraps secrets.SecretStore with
  the upsert API both jira.SecretStore and linear.SecretStore expect;
  drops both adapter copies from cmd/kandev/services.go.
- New internal/integrations/healthpoll owns the 90s auth-health loop via
  a Prober interface; linear/poller.go is a thin wrapper, jira/poller.go
  keeps only its (jira-specific) issue-watch loop.
- provideServices split into initGitHubService / initJiraService /
  initLinearService — now 75 lines (was ~125, exceeded the 80-line limit).

Frontend:
- New hooks/domains/integrations/use-integration-availability.ts and
  use-integration-enabled.ts; jira/linear hooks reduce to one-line
  delegates and move from components/<x>/ to hooks/domains/<x>/ per the
  project convention Greptile flagged on PR #736.
- New components/integrations/{auth-status-banner,auth-error-message,
  validated-popover}.tsx; the four link/import-bar wrappers drop from
  ~110 lines to ~35 each, and JiraLinkButton picks up the
  clear-error-on-close behaviour LinearLinkButton already had.

Docs:
- New 'Adding a new third-party integration' section in CLAUDE.md/AGENTS.md
  documenting the shared shapes and intentional Jira/Linear divergence
  (transition vs state, JQL vs filter object, eventBus only on jira).

* fix: address PR #759 review feedback

- validated-popover: outline-with-label trigger now sets type="button" and
  honours triggerDisabled, matching ghost-icon (Greptile, Claude, CodeRabbit).
- jira-link-button / linear-link-button: move updateTask() into the awaited
  fetch path so a failed title update surfaces as the popover's inline error
  instead of an unhandled rejection (CodeRabbit).
- use-integration-availability: monotonic requestId race guard prevents a
  slow earlier probe from overwriting a newer auth result (CodeRabbit).
- use-integration-enabled: drop unnecessary async on synchronous init
  (Claude).
- jira/linear poller smoke tests: wrap in testing/synctest instead of
  time.After(2s) per backend testing convention (CodeRabbit nitpick).

Skipped with reasoning on the threads:
- secretadapter Set TOCTOU — single-process SQLite + single-writer per
  workspace; constraint violation on conflict surfaces as an error to the
  caller, not silent corruption.
- healthpoll SetInterval validation — the function isn't called from
  production code today; a panic on misuse would be a footgun and silent
  ignore would just hide bugs differently.

* fix: address PR #759 round-2 review feedback

- jira-settings / linear-settings: drop the local STATUS_REFRESH_MS = 90_000
  copies and import the shared INTEGRATION_STATUS_REFRESH_MS so changing the
  cadence only touches one file (Claude).
- healthpoll: rename Poller.ProbeAll → probeAll. The only callers are the
  loop and the in-package tests, so widening the public API wasn't buying
  anything (Claude).

* fix: invalidate ValidatedPopover in-flight submits on close

If the user clicked Submit and dismissed the popover before the request
settled, the next time they re-opened it would inherit `loading=true` (the
disabled-while-loading submit button was stuck) and a late rejection from
the previous session could repopulate `error` in the new one.

Track the submit via a `useRef` token, bump it on close, and ignore any
result/rejection whose token no longer matches. Also reset `loading` and
`error` on close so the next open starts clean. Adds a regression test
covering both the late-resolution and late-rejection paths.

Addresses CodeRabbit review on PR #759.

* ci: trigger workflow re-run

GitHub Actions did not dispatch the pull_request workflows on the previous
two pushes (1d869f0e, 0ab6c034); only pull_request_target and CodeQL fired.
Empty commit to nudge synchronize event.

* fix: address PR #759 round-3 review feedback

- jira.Poller.Stop: add nil-receiver guard so callers using the
  defer-Stop-after-NewPoller pattern don't panic when initJiraService
  returned nil (Greptile + Claude). Matches linear.Poller's guard.
- healthpoll TestStart_RunsImmediateProbe + TestStart_IsIdempotent:
  rewrap in testing/synctest with synctest.Wait() instead of
  time.After(2s) wall-clock waits. Drops the now-unused probedAll signal
  channel from fakeProber (Claude).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-02 11:50:19 +01:00

178 lines
5.8 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { formatDistanceToNow } from "date-fns";
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";
// 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);
}
export { cleanIntegrationErrorMessage as cleanLinearErrorMessage } from "@/components/integrations/auth-error-message";
type LinearErrorMessageProps = {
error: string;
workspaceId?: string | null;
compact?: boolean;
};
export function LinearErrorMessage({ error, workspaceId, compact }: LinearErrorMessageProps) {
return (
<IntegrationAuthErrorMessage
error={error}
name="Linear"
reconnectHref={workspaceId ? `/settings/workspace/${workspaceId}/linear` : "/settings"}
isAuthError={isLinearAuthError}
authErrorBody="Your Linear API key is invalid or has been revoked. Reconnect to view this issue."
compact={compact}
/>
);
}