83e01477df
* 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>
82 lines
2.9 KiB
TypeScript
82 lines
2.9 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { IconLockExclamation } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
|
|
// Drops support URLs upstream APIs sometimes inline into error response
|
|
// bodies — they're noise once the user has a clear CTA. Shared because both
|
|
// jira and linear surface verbose, URL-laden messages.
|
|
const URL_RE = /\bhttps?:\/\/\S+/g;
|
|
|
|
export function cleanIntegrationErrorMessage(error: string): string {
|
|
return error.replace(URL_RE, "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
export type IntegrationAuthErrorMessageProps = {
|
|
error: string;
|
|
// Display name shown in the headline / button label, e.g. "Jira", "Linear".
|
|
name: string;
|
|
// Where the "Reconnect" button takes the user; null/undefined falls back
|
|
// to the global settings page.
|
|
reconnectHref: string;
|
|
// Integration-specific check for whether the error is an auth failure
|
|
// (each integration's API formats status codes differently).
|
|
isAuthError: (error: string) => boolean;
|
|
// Long-form copy shown under the headline in the non-compact variant.
|
|
// Integration-specific because the user-facing remediation differs
|
|
// (session vs API key, etc.).
|
|
authErrorBody: string;
|
|
// Inline variant for cases where context is already rendered above.
|
|
compact?: boolean;
|
|
};
|
|
|
|
// IntegrationAuthErrorMessage renders the standard error UI shown when an
|
|
// integration request fails. Auth failures get a Reconnect CTA pointing at
|
|
// the workspace's settings page; everything else just shows the cleaned
|
|
// upstream message.
|
|
export function IntegrationAuthErrorMessage({
|
|
error,
|
|
name,
|
|
reconnectHref,
|
|
isAuthError,
|
|
authErrorBody,
|
|
compact,
|
|
}: IntegrationAuthErrorMessageProps) {
|
|
const isAuth = isAuthError(error);
|
|
|
|
if (compact) {
|
|
return (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<span className={isAuth ? "text-muted-foreground" : "text-destructive"}>
|
|
{isAuth ? `${name} authentication required.` : cleanIntegrationErrorMessage(error)}
|
|
</span>
|
|
{isAuth && (
|
|
<Button asChild size="sm" variant="outline" className="cursor-pointer h-7 text-xs">
|
|
<Link href={reconnectHref}>Reconnect {name}</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">{name} authentication required</h2>
|
|
<p className="text-sm text-muted-foreground">{authErrorBody}</p>
|
|
</div>
|
|
<Button asChild size="sm" className="cursor-pointer">
|
|
<Link href={reconnectHref}>Reconnect {name}</Link>
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-destructive">{cleanIntegrationErrorMessage(error)}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|