Files
kandev/apps/web/components/linear/linear-issue-common.test.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

107 lines
3.6 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
cleanLinearErrorMessage,
extractLinearKey,
isLinearAuthError,
LINEAR_KEY_RE,
priorityClass,
stateBadgeClass,
} from "./linear-issue-common";
describe("LINEAR_KEY_RE / extractLinearKey", () => {
it("matches uppercase TEAM-N identifiers anywhere in the string", () => {
expect(extractLinearKey("ENG-123")).toBe("ENG-123");
expect(extractLinearKey("[ABC-1] fix login")).toBe("ABC-1");
expect(extractLinearKey("Migrate Z9-42 to new shape")).toBe("Z9-42");
});
it("rejects identifiers that don't start with a capital letter", () => {
expect(extractLinearKey("eng-123")).toBeNull();
expect(extractLinearKey("123-456")).toBeNull();
});
it("does not match version-style strings like v1-2", () => {
expect(extractLinearKey("upgrade v1-2 to v1-3")).toBeNull();
});
it("returns null for nullish or empty input", () => {
expect(extractLinearKey(null)).toBeNull();
expect(extractLinearKey(undefined)).toBeNull();
expect(extractLinearKey("")).toBeNull();
});
it("requires a word-boundary after the numeric tail", () => {
// The regex is digit-greedy, so a trailing "999" is part of the key.
expect("ENG-123999".match(LINEAR_KEY_RE)?.[0]).toBe("ENG-123999");
// A trailing letter breaks the closing word boundary, so the whole
// candidate is rejected rather than being truncated.
expect(extractLinearKey("ENG-123abc")).toBeNull();
});
});
describe("isLinearAuthError", () => {
it("flags 401 and 403 as auth errors", () => {
expect(isLinearAuthError("linear api: status 401: invalid api key")).toBe(true);
expect(isLinearAuthError("linear api: status 403: forbidden")).toBe(true);
});
it("does not flag 400 / 404 / 500 as auth errors", () => {
expect(isLinearAuthError("linear api: status 400: bad input")).toBe(false);
expect(isLinearAuthError("linear api: status 404: not found")).toBe(false);
expect(isLinearAuthError("linear api: status 500: server error")).toBe(false);
});
it("matches case-insensitively on the status keyword", () => {
expect(isLinearAuthError("STATUS 401")).toBe(true);
});
});
describe("cleanLinearErrorMessage", () => {
it("strips URLs", () => {
expect(cleanLinearErrorMessage("Error see https://example.com/docs/x for details")).toBe(
"Error see for details",
);
});
it("collapses runs of whitespace", () => {
expect(cleanLinearErrorMessage("foo bar\n\nbaz")).toBe("foo bar baz");
});
it("trims leading and trailing whitespace", () => {
expect(cleanLinearErrorMessage(" hi ")).toBe("hi");
});
});
describe("stateBadgeClass", () => {
it("returns the green palette for done", () => {
expect(stateBadgeClass("done")).toContain("green");
});
it("returns the amber palette for indeterminate (in-progress)", () => {
expect(stateBadgeClass("indeterminate")).toContain("amber");
});
it("returns the blue palette for new", () => {
expect(stateBadgeClass("new")).toContain("blue");
});
it("returns an empty string for unknown / empty category", () => {
expect(stateBadgeClass("")).toBe("");
expect(stateBadgeClass(undefined)).toBe("");
});
});
describe("priorityClass", () => {
it("flags urgent, high, medium with distinct color tones", () => {
expect(priorityClass(1)).toContain("red");
expect(priorityClass(2)).toContain("orange");
expect(priorityClass(3)).toContain("amber");
});
it("falls back to muted for low (4), none (0), and undefined", () => {
expect(priorityClass(4)).toContain("muted");
expect(priorityClass(0)).toContain("muted");
expect(priorityClass(undefined)).toContain("muted");
});
});