diff --git a/.changeset/named-lookup-errors.md b/.changeset/named-lookup-errors.md new file mode 100644 index 0000000..0897701 --- /dev/null +++ b/.changeset/named-lookup-errors.md @@ -0,0 +1,12 @@ +--- +"@bdsqqq/lnr-core": patch +"@bdsqqq/lnr-cli": patch +--- + +preserve initiative and roadmap lookup errors while retaining direct identifier +lookup before name fallback, including for initiative actions. fallback is allowed +only for a null result or an SDK InvalidInputLinearError whose nonempty parsed +errors all identify exactly the requested root. this is rejected-identifier +fallback, not proof that an entity is absent. network, structured permission-error +types, mixed and lazy-relation errors propagate; direct slug success does not +require list access. core direct lookups continue forwarding arbitrary strings. diff --git a/.changeset/nullable-read-errors.md b/.changeset/nullable-read-errors.md new file mode 100644 index 0000000..fb813f4 --- /dev/null +++ b/.changeset/nullable-read-errors.md @@ -0,0 +1,7 @@ +--- +"@bdsqqq/lnr-core": patch +--- + +preserve API and relationship errors in nullable read helpers instead of +misreporting failed reads as missing entities or preferences. genuine absent +results remain nullable. diff --git a/.changeset/project-creation-receipt.md b/.changeset/project-creation-receipt.md new file mode 100644 index 0000000..8228da3 --- /dev/null +++ b/.changeset/project-creation-receipt.md @@ -0,0 +1,7 @@ +--- +"@bdsqqq/lnr-cli": patch +--- + +include the immutable id in successful project creation acknowledgements. +register sandbox ownership before independent list readback so a failed read +does not discard the returned id. diff --git a/.todo.md b/.todo.md index f9e622d..ab9c088 100644 --- a/.todo.md +++ b/.todo.md @@ -201,6 +201,8 @@ test dispatch + payload wiring, not schema validation. requires DI refactor for - [x] 0100 - pin cycle issue reads to the resolved immutable cycle id, with distinct selected/current fixtures and failure propagation - verify filters, ordering, cursor traversal, archived data, and full json output; a first-page list is not full read parity - [-] 0095 - verify operation errors and lifecycle behavior across the inventory --blocks 0098 + - [x] 0124 - preserve initiative/roadmap detail errors with direct-first identifier precedence; name fallback only for genuine null or real SDK InvalidInputLinearError with nonempty uniform InvalidInput errors at the exact requested root; rejected identifier does not prove absence; preserve core arbitrary-string forwarding, optional owners and direct-slug mutation targets without list access; 866 core/real-core argv regressions pass, including intercepted SDK wrapping and mixed/lazy-error guards; negative control against rejected UUID dispatch: 516 fail/288 pass before adding 62 guard/absence controls; offline check/test (3227 pass)/api:check/build pass with cached nix Bun 1.3.13; nix develop --offline blocked by unavailable dependencies; no new live calls or pagination changes, full parity remains unaccepted + - [x] 0122 - propagate errors from 14 nullable read helpers; retain true missing roots/relations and production error classification, with 559 core/argv regressions; initiative/roadmap fallback contracts remain separate - [x] 0120 - propagate only getIssue/getCycle detail errors; preserve missing roots, optional null relations, and unmatched cycle lookup; 116 core/real-core argv regressions cover error identity, production network/auth/permission/rate-limit/notfound classification, single fetches and name/number lookup; before-fix control: 90 fail, 26 pass; offline check/test (1723 pass)/api:check/build pass with cached nix Bun 1.3.13; no live verification or pagination changes - [x] 0118 - propagate only document list errors; preserve Promise.all mapping and empty results; 36 core/real-core argv regressions cover original error identity, connection/project failures, JSON/quiet/table diagnostics and exit classification, payloads, and single fetches; before-fix control: 14 fail, 22 pass; offline check/test (1607 pass)/api:check/build pass with cached nix Bun 1.3.13; nix develop --offline blocked by unavailable dependencies; no live verification - [x] 0117 - propagate only view/template list errors; preserve empty/missing-team and valid payloads; 111 core/real-core argv regressions use production error classification (network/auth/rate-limit/permission), including later-team failures and single attempts; before-fix control: 84 fail, 27 pass; offline check/test/api:check/build pass, no live verification; unrelated catches deferred @@ -217,6 +219,8 @@ test dispatch + payload wiring, not schema validation. requires DI refactor for - detect added/removed/changed operations, arguments, fields, enums, nullability, and deprecations; fail on unclassified changes - retain explicit gaps in the report; prove checks catch missing dispatch, dropped payload fields, and unsupported operations - [-] 0097 - make live verification fail honestly and isolate sandbox ownership --blocks 0098 + - [x] 0123 - return project creation id and register fixture ownership before independent list/read assertions; offline verified, live proof/push held; does not recover #48's unknown-id project + - run 36641067664 (#48) left unknown-id project `e2e-project-ffecd613-ff51-410d-8751-12d00a58043e`; authorized read-only run 36645163754 found no accessible exact-name match, including archived. no deletion attempted; historical outcome remains unknown. later CI uses 0123 ownership receipts before readback - [x] 0119 - add label-role/id receipts, independent original-assignment preconditions, batch CLI completion metadata, and immediate per-issue label observations without retrying writes or weakening assertions; offline extracted-body scenarios and safety tests/check pass, live cause remains unresolved - [x] 0112 - separately authorized #40 view recovery: read-only run 36481626839 identified `54429643-f5fd-4ee9-b3bb-9948cf7245e8`; run 36483871681 deleted only that id once and verified archived-inclusive id-query absence; no retries. evidence: `docs/evidence/view-recovery-36483871681.json` - run 36041211485 (#41) failed the original immediate cycle archive assertion, while all six subsequent SDK/raw observations confirmed archival of `7e61cc29-364d-48fa-9586-168d0f46f50b`; no unknown-id teardown warning in that run diff --git a/docs/evidence/missing-lookups-36650753534.json b/docs/evidence/missing-lookups-36650753534.json new file mode 100644 index 0000000..8989e23 --- /dev/null +++ b/docs/evidence/missing-lookups-36650753534.json @@ -0,0 +1,17 @@ +{ + "run": "https://github.com/bdsqqq/lnr/actions/runs/36650753534", + "scope": "pinned bdsqqq-sandbox organization verified before fixed read-only probes", + "identifier": "00000000-0000-4000-8000-000000000099", + "roots": ["initiative", "roadmap"], + "observed": { + "status": 200, + "constructor": "InvalidInputLinearError", + "type": "InvalidInput", + "dataIsNull": true, + "parsedErrorType": "InvalidInput", + "parsedErrorPath": "exact root", + "rawExtensionType": "invalid input" + }, + "limitation": "two missing-identifier observations, not evidence that every InvalidInput means absence or that permissions are granted", + "provenance": "selected allowlisted output manually transcribed from inspected CI logs; not an automated attestation" +} diff --git a/packages/cli/src/e2e-mutations.test.ts b/packages/cli/src/e2e-mutations.test.ts index 6444835..e043dac 100644 --- a/packages/cli/src/e2e-mutations.test.ts +++ b/packages/cli/src/e2e-mutations.test.ts @@ -9,7 +9,7 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test"; import { executeApi, getClient, type Cycle, type GitAutomationState } from "@bdsqqq/lnr-core"; -import { cleanupOwned, liveChildEnv, liveCredentials, registerViewReceipt, type OwnedFixture } from "./live-test-support"; +import { cleanupOwned, liveChildEnv, liveCredentials, registerViewReceipt, registerProjectReceipt, type OwnedFixture } from "./live-test-support"; const { key: API_KEY, confirmOrg } = liveCredentials(process.env, true); const client = getClient(API_KEY); @@ -407,14 +407,14 @@ describe("e2e: mutations", () => { const out = await lnr("project", "new", "--new-name", TEST_PROJECT_NAME, "--team", TEST_TEAM_KEY, "--content", "e2e payload content", "--lead", "@me", "--priority", "0", "--start-date", "2026-10-01", "--target-date", "2026-11-01", "--status", status.id); + projectId = registerProjectReceipt(out, projectFixture); const json = await lnr("projects", "--json"); const projects = JSON.parse(json); const matches = projects.filter((p: any) => p.name === TEST_PROJECT_NAME); expect(matches).toHaveLength(1); const testProject = matches[0]; expect(testProject).toBeTruthy(); - projectId = testProject.id; - projectFixture.id = projectId; + expect(testProject.id).toBe(projectId); expect(out).toContain("created"); const read = await client.client.rawRequest<{ project: unknown }, { id: string }>( "query($id: String!) { project(id: $id) { id content startDate targetDate priority lead { id } status { id } } }", diff --git a/packages/cli/src/generated/project.ts b/packages/cli/src/generated/project.ts index 46bb7e3..582a6a0 100644 --- a/packages/cli/src/generated/project.ts +++ b/packages/cli/src/generated/project.ts @@ -416,7 +416,7 @@ async function handleCreateProject(input: ProjectInput): Promise { const project = await createProject(client, createPayload); if (project) { - console.log(`created project: ${project.name}`); + console.log(`created project: ${project.name} (${project.id})`); } else { exitWithError("project creation returned no project; verify the outcome before retrying"); } diff --git a/packages/cli/src/live-test-support.ts b/packages/cli/src/live-test-support.ts index 496393a..8470c8b 100644 --- a/packages/cli/src/live-test-support.ts +++ b/packages/cli/src/live-test-support.ts @@ -30,6 +30,16 @@ export function registerViewReceipt(output: string, fixture: OwnedFixture): stri return fixture.id = id; } +/** Preserve project ownership before listing or readback can fail. */ +export function registerProjectReceipt(output: string, fixture: OwnedFixture): string { + const prefix = `created project: ${fixture.name} (`; + const id = output.startsWith(prefix) + ? /^([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\)$/.exec(output.slice(prefix.length))?.[1] + : undefined; + if (!id || output !== `${prefix}${id})`) throw new Error("missing or invalid project creation receipt"); + return fixture.id = id; +} + /** unknown write outcomes require manual recovery, never a speculative retry. */ export async function cleanupOwned(fixtures: OwnedFixture[]): Promise { const failures: Error[] = []; diff --git a/packages/cli/src/project-receipt.test.ts b/packages/cli/src/project-receipt.test.ts new file mode 100644 index 0000000..9f80853 --- /dev/null +++ b/packages/cli/src/project-receipt.test.ts @@ -0,0 +1,34 @@ +import { expect, mock, test } from "bun:test"; +import { cleanupOwned, registerProjectReceipt, type OwnedFixture } from "./live-test-support"; + +const id = "12345678-1234-1234-1234-123456789abc"; +const name = "audit (project).*"; +const receipt = `created project: ${name} (${id})`; + +test("project receipt retains ownership when the independent listing fails", async () => { + const remove = mock(async (_id: string) => ({ success: true })); + const fixture: OwnedFixture = { name, remove }; + const failure = new Error("listing failed"); + const list = mock(async () => { throw failure; }); + await expect((async () => { + expect(registerProjectReceipt(receipt, fixture)).toBe(id); + await list(); + })()).rejects.toBe(failure); + expect(fixture.id).toBe(id); + await cleanupOwned([fixture]); + expect(remove).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledWith(id); +}); + +for (const output of [ + "", `created project: ${name}`, `created project: other (${id})`, + `created view: ${name} (${id})`, `created project: ${name} (not-a-uuid)`, + `prefix ${receipt}`, `${receipt} suffix`, `${receipt}\n`, `${receipt}\nextra`, + `${receipt}\n${receipt}`, receipt.replace(id, id.slice(1)), +]) { + test(`invalid project receipt leaves ownership unchanged: ${JSON.stringify(output)}`, () => { + const fixture: OwnedFixture = { name, remove: async () => ({ success: true }) }; + expect(() => registerProjectReceipt(output, fixture)).toThrow("missing or invalid project creation receipt"); + expect(fixture.id).toBeUndefined(); + }); +} diff --git a/packages/cli/src/router/initiatives.ts b/packages/cli/src/router/initiatives.ts index 9efbc7a..d1b72cb 100644 --- a/packages/cli/src/router/initiatives.ts +++ b/packages/cli/src/router/initiatives.ts @@ -4,6 +4,7 @@ import { getClient, listInitiatives, getInitiative, + isRootIdentifierRejection, findInitiativeByName, getInitiativeUpdates, getInitiativeExternalLinks, @@ -35,7 +36,7 @@ export const listInitiativesInput = type({ }); export const initiativeInput = type({ - nameOrId: type("string").configure({ positional: true }).describe("initiative name, slugId, or id"), + nameOrId: type("string").configure({ positional: true }).describe("initiative name, slugId, or id (direct identifier lookup takes precedence over name lookup)"), "json?": type("boolean").describe("output as json"), "quiet?": type("boolean").describe("output id only"), "verbose?": type("boolean").describe("show all columns"), @@ -129,8 +130,12 @@ export const initiativesRouter = router({ validateMutationOutput("initiative mutation", input); } const client = getClient(); - let initiative = await getInitiative(client, input.nameOrId); - + let initiative: Initiative | null = null; + try { + initiative = await getInitiative(client, input.nameOrId); + } catch (error) { + if (!isRootIdentifierRejection(error, "initiative")) throw error; + } if (!initiative) { initiative = await findInitiativeByName(client, input.nameOrId); } diff --git a/packages/cli/src/router/named-lookup-errors.test.ts b/packages/cli/src/router/named-lookup-errors.test.ts new file mode 100644 index 0000000..4f4679f --- /dev/null +++ b/packages/cli/src/router/named-lookup-errors.test.ts @@ -0,0 +1,144 @@ +import { expect, mock, spyOn, test } from "bun:test"; +import { createCli, FailedToExitError } from "trpc-cli"; + +const core = await import("@bdsqqq/lnr-core"); +const id = "aabbccdd-1234-5678-9abc-123456789abc"; +const common = { id, name: "Example", slugId: "example-slug", description: "", + createdAt: new Date(0), updatedAt: new Date(1), url: "https://example.com", color: null }; +let entity: "initiative" | "roadmap"; +let phase: string; +let failure: Error | undefined; +function rejected(root: string, type = "invalid input", path: (string | number)[] = [root]) { + return { message: "rejected identifier", extensions: { type }, path }; +} +async function sdkError(root: "initiative" | "roadmap", errors: ReturnType[]) { + const transport = spyOn(globalThis, "fetch").mockImplementation(Object.assign( + async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + expect(body.query).toContain(`query ${root}(`); + expect(body.variables).toEqual({ id: "fixture-input" }); + return Response.json({ data: null, errors }); + }, { preconnect() { throw new Error("unexpected preconnect"); } }, + )); + try { + // Fixture key, intercepted transport: exercise SDK wrapping without a CLI SDK dependency. + const client = core.createClientWithKey("fixture"); + const error = await client[root]("fixture-input").then(() => { throw new Error("expected SDK rejection"); }, error => error); + expect(transport).toHaveBeenCalledTimes(1); + expect(error).toBeInstanceOf(Error); + return error as Error; + } finally { transport.mockRestore(); } +} +const identifierErrors = { + initiative: await sdkError("initiative", [rejected("initiative")]), + roadmap: await sdkError("roadmap", [rejected("roadmap")]), +}; +const owner = mock(async () => { + if (phase === "owner") throw failure; + return phase === "populated" ? { id: "owner-id", name: "Owner" } : null; +}); +function value() { + return entity === "initiative" ? { ...common, status: "Planned", health: null, icon: null, + targetDate: null, startedAt: null, completedAt: null } : { ...common, get owner() { return owner(); } }; +} +const direct = mock(async (..._args: unknown[]) => { + if (phase === "root") throw failure; + if (phase.startsWith("fallback")) throw identifierErrors[entity]; + return ["missing", "null-fallback"].includes(phase) ? null : value(); +}); +const list = mock(async (..._args: unknown[]) => { + if (phase === "fallback-error") throw failure; + if (phase === "present") throw new Error("list permission denied"); + return { nodes: ["missing", "fallback-missing"].includes(phase) ? [] : + [phase === "populated" ? { ...value(), id: "wrong-collision-id", name: "EXAMPLE-SLUG" } : value()] }; +}); +const reaction = mock(async (..._args: unknown[]) => ({ success: true })); +const subscription = mock(async (..._args: unknown[]) => ({ success: true, lastSyncId: 123 })); +mock.module("@bdsqqq/lnr-core", () => ({ + ...core, getConfigValue: () => "table", + getClient: () => ({ [entity]: direct, [`${entity}s`]: list, deleteReaction: reaction, createNotificationSubscription: subscription }), +})); +const { appRouter } = await import("./index"); +const failures = [ + { message: "network unavailable", code: 1, diagnostic: "network unavailable" }, + { message: "unauthorized", code: 2, diagnostic: "not authenticated" }, + { message: "permission denied", code: 5, diagnostic: "requires a Linear Business or Enterprise plan" }, + { message: "entity not accessible", code: 5, diagnostic: "requires a Linear Business or Enterprise plan" }, + { message: "rate limit exceeded", code: 4, diagnostic: "rate limited" }, + { message: "entity not found", code: 3, diagnostic: "entity not found" }, +]; +for (const kind of ["initiative", "roadmap"] as const) { + const sdkFailures: { message: string; code: number; diagnostic: string; error: Error }[] = []; + for (const entry of [ + { name: "nested SDK path", errors: [rejected(kind, "invalid input", [kind, "owner"])] }, + { name: "other SDK root", errors: [rejected(kind, "invalid input", ["user"])] }, + { name: "mixed forbidden", errors: [rejected(kind), rejected(kind, "forbidden")] }, + { name: "mixed network", errors: [rejected(kind), rejected(kind, "network error")] }, + ]) sdkFailures.push({ message: entry.name, code: 1, diagnostic: "rejected identifier", + error: await sdkError(kind, entry.errors) }); + for (const target of [id, id.toUpperCase(), "eXaMpLe", "EXAMPLE-SLUG", "not-a-uuid"]) { + for (const format of ["table", "json", "quiet", ...(kind === "initiative" ? ["mutation", "subscribe"] : [])]) { + for (const scenario of ["root", "fallback-error", ...(kind === "roadmap" ? ["owner"] : []), + "missing", "fallback-missing", "present", "populated", ...(["eXaMpLe", "EXAMPLE-SLUG"].includes(target) ? ["fallback", "null-fallback"] : [])]) { + const errors = [ + ...failures.map(outcome => ({ ...outcome, error: new Error(outcome.message) })), + ...(["root", "owner"].includes(scenario) ? sdkFailures : []), + ]; + for (const outcome of ["root", "owner", "fallback-error"].includes(scenario) ? errors : [undefined]) { + // Unmatched non-UUID input is an explicit missing control, not a fixture name alias. + if (target === "not-a-uuid" && scenario !== "missing") continue; + test(`${kind} ${target} ${format} ${scenario} ${outcome?.message ?? "control"}`, async () => { + entity = kind; phase = scenario; failure = outcome?.error; + for (const fn of [direct, list, owner, reaction, subscription]) fn.mockClear(); + const stdout = spyOn(console, "log").mockImplementation(() => {}); + const stderr = spyOn(console, "error").mockImplementation(() => {}); + const exit = spyOn(process, "exit").mockImplementation((code): never => { + throw new FailedToExitError("production exit", { exitCode: Number(code), cause: undefined }); + }); + const previous = process.exitCode; + const absent = ["missing", "fallback-missing"].includes(scenario); + const code = outcome?.code ?? (absent ? 3 : 0); + try { + await expect(createCli({ router: appRouter }).run({ + argv: [kind, target, ...(format === "subscribe" ? ["--subscribe"] : format === "mutation" ? ["--unreact", "reaction-id"] : + format === "table" ? [] : [`--${format}`])], + logger: { info() {}, error() {} }, + process: { exit(value): never { + throw new FailedToExitError("cli exit", { exitCode: value, cause: undefined }); + } }, + })).rejects.toMatchObject({ exitCode: absent ? 1 : code }); + expect(direct.mock.calls).toEqual([[target]]); + expect(list.mock.calls).toEqual(scenario.startsWith("fallback") || ["missing", "null-fallback"].includes(scenario) ? [[]] : []); + expect(owner.mock.calls).toEqual(kind === "roadmap" && !["root", "missing", "fallback-missing", "fallback-error"].includes(scenario) ? [[]] : []); + expect(reaction.mock.calls).toEqual(format === "mutation" && code === 0 ? [["reaction-id"]] : []); + expect(subscription.mock.calls).toEqual(format === "subscribe" && code === 0 ? [[{ + initiativeId: id, notificationSubscriptionTypes: [ + "initiativeNewComment", "initiativeDescriptionContentChange", "initiativeUpdateCreated", "initiativeUpdatePrompt", + ], + }]] : []); + if (code) { + // A thrown test exit is caught by the route; genuine null exits first with 3. + expect(exit.mock.calls).toEqual(absent ? [[3], [1]] : [[code]]); + expect(stdout.mock.calls).toEqual([]); + const diagnostic = stderr.mock.calls.flat().join(" "); + expect(diagnostic).toContain(outcome?.diagnostic ?? "not found"); + if (outcome && code !== 3) expect(diagnostic).not.toContain("not found"); + } else { + expect(exit).not.toHaveBeenCalled(); + expect(stderr.mock.calls).toEqual([]); + if (format === "json") expect(JSON.parse(String(stdout.mock.calls[0]?.[0]))).toMatchObject( + kind === "initiative" ? { id, description: "", status: "Planned" } : + { id, description: "", ownerId: scenario === "populated" ? "owner-id" : null, + ownerName: scenario === "populated" ? "Owner" : null }); + else if (format === "quiet") expect(stdout.mock.calls).toEqual([[id]]); + else expect(stdout.mock.calls.length).toBeGreaterThan(0); + } + } finally { + exit.mockRestore(); stdout.mockRestore(); stderr.mockRestore(); process.exitCode = previous; + } + }); + } + } + } + } +} diff --git a/packages/cli/src/router/nullable-read-errors-a.test.ts b/packages/cli/src/router/nullable-read-errors-a.test.ts new file mode 100644 index 0000000..d9e0c9a --- /dev/null +++ b/packages/cli/src/router/nullable-read-errors-a.test.ts @@ -0,0 +1,136 @@ +import { expect, mock, spyOn, test } from "bun:test"; +import { createCli, FailedToExitError } from "trpc-cli"; + +const core = await import("@bdsqqq/lnr-core"); +let entity: string; +let phase: string; +let failure: Error | undefined; +const dates = { createdAt: new Date(0), updatedAt: new Date(1) }; +const view = { id: "view-id", name: "Example", description: "", filterData: {}, shared: false, ...dates }; +const template = { id: "template-id", name: "Example", type: "issue", description: "", ...dates }; +const label = { id: "label-id", name: "Example", color: "#000", description: "" }; +const doc = { id: "doc-id", title: "Example", content: "", url: "https://example.com", ...dates }; +const relation = mock((key: string) => phase === key ? Promise.reject(failure) : Promise.resolve(null)); +const templates = mock(async () => { + if (phase === "templates") throw failure; + return { nodes: phase === "missing" ? [] : [template] }; +}); +const laterTemplates = mock(async () => { + if (phase === "later-team") throw failure; + return { nodes: [] }; +}); +const root = mock(async (..._args: unknown[]) => { + if (phase === "root") throw failure; + if (entity === "view" || entity === "preferences") return { nodes: phase === "missing" ? [] : [view] }; + if (entity.includes("template")) return { nodes: phase === "missing-team" ? [] : + [{ key: "ENG", templates }, ...(entity === "template" ? [{ key: "OPS", templates: laterTemplates }] : [])] }; + if (entity === "label") return phase === "missing" ? null : label; + return phase === "missing" ? null : { ...doc, get project() { return relation("project"); } }; +}); +const customView = mock(async (..._args: unknown[]) => { + if (phase === "preferences-root") throw failure; + if (phase === "missing-preferences") return null; + return { + get userViewPreferences() { return relation("user"); }, + get organizationViewPreferences() { return relation("organization"); }, + get viewPreferencesValues() { return relation("effective"); }, + }; +}); +// Only client/config boundaries are stubbed: argv, helpers, rendering and classifier remain real. +mock.module("@bdsqqq/lnr-core", () => ({ + ...core, + getClient: () => { + if (entity === "preferences") return { customViews: root, customView }; + const method = entity === "view" ? "customViews" : + entity.includes("template") ? "teams" : entity === "label" ? "issueLabel" : "document"; + return { [method]: root }; + }, + getConfigValue: () => "table", +})); +const { appRouter } = await import("./index"); +const failures = [ + { message: "network unavailable", code: 1, diagnostic: "network unavailable" }, + { message: "unauthorized", code: 2, diagnostic: "not authenticated" }, + { message: "permission denied", code: 5, diagnostic: "requires a Linear Business or Enterprise plan" }, + { message: "rate limit exceeded", code: 4, diagnostic: "rate limited" }, + { message: "entity not accessible", code: 5, diagnostic: "requires a Linear Business or Enterprise plan" }, + { message: "entity not found", code: 3, diagnostic: "entity not found" }, +]; +for (const entry of [ + { name: "view", phases: ["root"], controls: ["missing", "unmatched"] }, + { name: "preferences", phases: ["root", "preferences-root", "user", "organization", "effective"], controls: ["missing", "missing-preferences"] }, + { name: "template", phases: ["root", "templates", "later-team"], controls: ["missing", "missing-team", "unmatched"] }, + { name: "scoped-template", phases: ["root", "templates"], controls: ["missing", "missing-team", "unmatched"] }, + { name: "label", phases: ["root"], controls: ["missing"] }, + { name: "doc", phases: ["root", "project"], controls: ["missing"] }, +]) { + for (const format of entry.name === "label" ? ["table", "json"] : ["table", "json", "quiet"]) { + for (const scenario of [...entry.phases, ...entry.controls, "present"]) { + for (const outcome of entry.phases.includes(scenario) ? failures : [undefined]) { + test(`${entry.name} ${format} ${scenario} ${outcome?.message ?? "control"}`, async () => { + entity = entry.name; phase = scenario; failure = outcome ? new Error(outcome.message) : undefined; + for (const fn of [root, customView, templates, laterTemplates, relation]) fn.mockClear(); + const stdout = spyOn(console, "log").mockImplementation(() => {}); + const stderr = spyOn(console, "error").mockImplementation(() => {}); + const exit = spyOn(process, "exit").mockImplementation((code): never => { + throw new FailedToExitError("production exit", { exitCode: Number(code), cause: undefined }); + }); + const previous = process.exitCode; + const absent = entry.controls.includes(scenario); + const code = outcome?.code ?? (absent ? scenario === "missing-preferences" ? 1 : 3 : 0); + const target = scenario === "unmatched" ? "unknown" : "eXaMpLe"; + try { + await expect(createCli({ router: appRouter }).run({ + argv: [entity === "preferences" ? "view" : entity === "scoped-template" ? "template" : entity, target, + ...(entity === "preferences" ? ["--preferences"] : []), + ...(entity === "scoped-template" ? ["--team", "eng"] : []), + ...(format === "table" ? [] : [`--${format}`])], + logger: { info() {}, error() {} }, + process: { exit(value): never { + throw new FailedToExitError("cli exit", { exitCode: value, cause: undefined }); + } }, + })).rejects.toMatchObject({ exitCode: absent ? 1 : code }); + expect(root.mock.calls).toEqual(entity === "scoped-template" + ? [[{ filter: { key: { eq: "ENG" } } }]] + : ["view", "preferences", "template"].includes(entity) ? [[]] : [[target]]); + expect(customView.mock.calls).toEqual(entity === "preferences" && !["root", "missing"].includes(scenario) ? [["view-id"]] : []); + expect(templates.mock.calls).toEqual(entity.includes("template") && !["root", "missing-team"].includes(scenario) ? [[]] : []); + expect(laterTemplates.mock.calls).toEqual(entity === "template" && !["root", "missing-team", "templates"].includes(scenario) ? [[]] : []); + // Preferences use Promise.all; document.project is only reached after its root succeeds. + expect(relation.mock.calls).toEqual( + entity === "preferences" && !["root", "missing", "preferences-root", "missing-preferences"].includes(scenario) + ? [["user"], ["organization"], ["effective"]] : + entity === "doc" && !["root", "missing"].includes(scenario) ? [["project"]] : [], + ); + if (code) { + expect(exit.mock.calls).toEqual(absent ? [[code], [1]] : [[code]]); + expect(stdout.mock.calls).toEqual([]); + const diagnostic = stderr.mock.calls.flat().join(" "); + expect(diagnostic).toContain(outcome?.diagnostic ?? (scenario === "missing-preferences" ? "no preferences found" : "not found")); + if (outcome && outcome.code !== 3) { + expect(diagnostic).not.toContain("not found"); + expect(diagnostic).not.toContain("no preferences found"); + } + } else { + expect(exit).not.toHaveBeenCalled(); + expect(stderr.mock.calls).toEqual([]); + if (format === "json") { + expect(JSON.parse(String(stdout.mock.calls[0]?.[0]))).toMatchObject( + entity === "preferences" ? { user: null, organization: null, + effective: { issueGrouping: null, showCompletedIssues: null, viewOrdering: null } } : + entity === "doc" ? { id: "doc-id", content: "", project: null } : + entity.includes("template") ? { id: "template-id", teamKey: "ENG", description: "" } : + entity === "view" ? { id: "view-id", shared: false, filterData: {} } : label, + ); + } else if (format === "quiet" && entity !== "preferences") { + expect(stdout.mock.calls).toEqual([[entity.includes("template") ? "template-id" : `${entity}-id`]]); + } else if (format === "table") expect(stdout.mock.calls.length).toBeGreaterThan(0); + } + } finally { + exit.mockRestore(); stdout.mockRestore(); stderr.mockRestore(); process.exitCode = previous; + } + }); + } + } + } +} diff --git a/packages/cli/src/router/nullable-read-errors-b.test.ts b/packages/cli/src/router/nullable-read-errors-b.test.ts new file mode 100644 index 0000000..7637429 --- /dev/null +++ b/packages/cli/src/router/nullable-read-errors-b.test.ts @@ -0,0 +1,114 @@ +import { expect, mock, spyOn, test } from "bun:test"; +import { createCli, FailedToExitError } from "trpc-cli"; + +const core = await import("@bdsqqq/lnr-core"); +let phase: string; +let failure: Error | undefined; +const date = new Date(0); +const cycle = { id: "cycle-id", number: 1, name: "cycle", startsAt: date, endsAt: date, progress: 0 }; +const relation = mock((name: string) => phase === name ? Promise.reject(failure) + : Promise.resolve(name === "activeCycle" && phase === "present" ? cycle : null)); +const team = mock(async (..._args: unknown[]) => { + if (phase === "root") throw failure; + if (phase === "missing") return null; + return { get activeCycle() { return relation("activeCycle"); } }; +}); +const notification = mock(async (..._args: unknown[]) => { + if (phase === "root") throw failure; + if (phase === "missing") return null; + return { id: "notification-id", type: "issue", category: "issue", createdAt: date, + actorId: "actor-id", get actor() { return relation("actor"); } }; +}); +const agentSession = mock(async (..._args: unknown[]) => { + if (phase === "root") throw failure; + if (phase === "missing") return null; + return { id: "session-id", status: "active", createdAt: date, updatedAt: date, + creatorId: "creator-id", appUserId: "app-id", issueId: "issue-id", + get creator() { return relation("creator"); }, get appUser() { return relation("appUser"); }, + get issue() { return relation("issue"); } }; +}); +// Only client acquisition/config are replaced: argv dispatch, core reads, output, +// and API error classification remain production code. +let client: object; +mock.module("@bdsqqq/lnr-core", () => ({ + ...core, getClient: () => client, getConfigValue: () => "table", +})); +const { appRouter } = await import("./index"); +const failures = [ + { message: "network unavailable", code: 1, diagnostic: "network unavailable" }, + { message: "unauthorized", code: 2, diagnostic: "not authenticated" }, + { message: "permission denied", code: 5, diagnostic: "requires a Linear Business or Enterprise plan" }, + { message: "rate limit exceeded", code: 4, diagnostic: "rate limited" }, + { message: "entity not accessible", code: 5, diagnostic: "requires a Linear Business or Enterprise plan" }, + { message: "entity not found", code: 3, diagnostic: "entity not found" }, +]; +const routes = [ + { name: "current cycle", argv: ["cycle", "current", "--current", "--team", "ENG"], root: team, args: ["ENG"], phases: ["root", "activeCycle"], id: "cycle-id" }, + { name: "notification", argv: ["notification", "id"], root: notification, args: ["id"], phases: ["root", "actor"], id: "notification-id" }, + { name: "agent session", argv: ["agent-session", "id"], root: agentSession, args: ["id"], phases: ["root", "creator", "appUser", "issue"], id: "session-id" }, +]; +for (const route of routes) { + for (const format of ["table", "json", "quiet"]) { + for (const scenario of [...route.phases, "missing", "present", ...(route.root === team ? ["inactive"] : [])]) { + for (const outcome of route.phases.includes(scenario) ? failures : [undefined]) { + test(`${route.name} ${format} ${scenario} ${outcome?.message ?? "control"}`, async () => { + phase = scenario; + failure = outcome ? new Error(outcome.message) : undefined; + client = route.root === team ? { team } : route.root === notification ? { notification } : { agentSession }; + relation.mockClear(); + for (const fn of [team, notification, agentSession]) fn.mockClear(); + const stdout = spyOn(console, "log").mockImplementation(() => {}); + const stderr = spyOn(console, "error").mockImplementation(() => {}); + const exit = spyOn(process, "exit").mockImplementation((code): never => { + throw new FailedToExitError("production exit", { exitCode: Number(code), cause: undefined }); + }); + const previous = process.exitCode; + const absent = scenario === "missing" || scenario === "inactive"; + const code = outcome?.code ?? (absent ? route.root === team ? 1 : 3 : 0); + try { + await expect(createCli({ router: appRouter }).run({ + argv: [...route.argv, ...(format === "table" ? [] : [`--${format}`])], + logger: { info() {}, error() {} }, + process: { exit(value): never { + throw new FailedToExitError("cli exit", { exitCode: value, cause: undefined }); + } }, + // Throwing instead of exiting is caught by the route on null results; + // assert the original production exit separately. + })).rejects.toMatchObject({ exitCode: absent ? 1 : code }); + for (const fn of [team, notification, agentSession]) { + expect(fn.mock.calls).toEqual(fn === route.root ? [route.args] : []); + } + const expectedRelations = ["root", "missing"].includes(scenario) ? [] + : route.phases.slice(1, outcome ? route.phases.indexOf(scenario) + 1 : undefined); + // Exact ordered calls prove one access per relation and no later + // relation fetch after an earlier rejection. + expect(relation.mock.calls).toEqual(expectedRelations.map(name => [name])); + if (code) { + expect(exit.mock.calls).toEqual(absent ? [[code], [1]] : [[code]]); + expect(stdout.mock.calls).toEqual([]); + const diagnostic = stderr.mock.calls.flat().join(" "); + expect(diagnostic).toContain(outcome?.diagnostic ?? (route.root === team ? "no active cycle" : "not found")); + if (outcome && outcome.code !== 3) { + expect(diagnostic).not.toContain("not found"); + expect(diagnostic).not.toContain("no active cycle"); + } + } else { + expect(exit).not.toHaveBeenCalled(); + expect(stderr.mock.calls).toEqual([]); + if (format === "json") { + expect(JSON.parse(String(stdout.mock.calls[0]?.[0]))).toMatchObject({ + id: route.id, + ...(route.root === notification ? { actorName: null } : {}), + ...(route.root === agentSession ? { creatorName: null, appUserName: null, issueIdentifier: null } : {}), + }); + } else if (format === "quiet") expect(stdout.mock.calls).toEqual([[route.id]]); + else expect(stdout.mock.calls.length).toBeGreaterThan(0); + } + } finally { + exit.mockRestore(); stdout.mockRestore(); stderr.mockRestore(); process.exitCode = previous; + } + }); + } + } + } +} diff --git a/packages/cli/src/router/project-create-receipt.test.ts b/packages/cli/src/router/project-create-receipt.test.ts new file mode 100644 index 0000000..3585015 --- /dev/null +++ b/packages/cli/src/router/project-create-receipt.test.ts @@ -0,0 +1,71 @@ +import { expect, mock, spyOn, test } from "bun:test"; +import { createCli, FailedToExitError } from "trpc-cli"; + +const core = await import("@bdsqqq/lnr-core"); +const id = "12345678-1234-1234-1234-123456789abc"; +const project = { + id, name: "audit", description: "", state: "planned", progress: 0, + createdAt: new Date(0), +}; +let success: boolean | undefined; +let entity: typeof project | null; +let failure: Error | undefined; +let readFailure: Error | undefined; +const read = mock(async () => { + if (readFailure) throw readFailure; + return entity; +}); +const createProject = mock(async (..._args: unknown[]) => { + if (failure) throw failure; + return { success, get project() { return read(); } }; +}); +mock.module("@bdsqqq/lnr-core", () => ({ + ...core, getClient: () => ({ createProject }), +})); +const handleApiError = mock((error: unknown) => { throw error; }); +mock.module("../lib/error", () => ({ + handleApiError, + exitWithError: (message: string) => { throw new Error(message); }, + EXIT_CODES: { GENERAL_ERROR: 1, NOT_FOUND: 3 }, +})); +const { generatedProjectsRouter } = await import("../generated/project"); + +for (const scenario of ["confirmed", "false", "missing-success", "missing-project", "rejected", "read-rejected"]) { + test(`project creation receipt through argv and real core: ${scenario}`, async () => { + success = scenario === "false" ? false : scenario === "missing-success" ? undefined : true; + entity = scenario === "missing-project" ? null : project; + failure = scenario === "rejected" ? new Error("sdk rejected create") : undefined; + readFailure = scenario === "read-rejected" ? new Error("sdk rejected project read") : undefined; + createProject.mockClear(); read.mockClear(); handleApiError.mockClear(); + const output = spyOn(console, "log").mockImplementation(() => {}); + const errors: unknown[][] = []; + const previous = process.exitCode; + try { + await expect(createCli({ router: generatedProjectsRouter }).run({ + argv: ["project", "new", "--new-name", "audit"], + logger: { info() {}, error(...args) { errors.push(args); } }, + process: { exit(code): never { + throw new FailedToExitError("test exit", { exitCode: code, cause: undefined }); + } }, + })).rejects.toMatchObject({ exitCode: scenario === "confirmed" ? 0 : 1 }); + expect(createProject).toHaveBeenCalledTimes(1); + expect(createProject).toHaveBeenCalledWith({ + name: "audit", description: undefined, teamIds: [], content: undefined, + leadId: undefined, startDate: undefined, targetDate: undefined, + priority: undefined, statusId: undefined, + }); + expect(read).toHaveBeenCalledTimes(success && !failure ? 1 : 0); + expect(output.mock.calls).toEqual(scenario === "confirmed" ? [[`created project: audit (${id})`]] : []); + if (failure || readFailure) { + expect(handleApiError).toHaveBeenCalledWith(failure ?? readFailure); + } else if (scenario !== "confirmed") { + expect(errors.flat().map(String).join(" ")).toContain( + "project creation returned no project; verify the outcome before retrying", + ); + } + } finally { + output.mockRestore(); + process.exitCode = previous; + } + }); +} diff --git a/packages/cli/src/router/roadmaps.ts b/packages/cli/src/router/roadmaps.ts index 539de77..41043d0 100644 --- a/packages/cli/src/router/roadmaps.ts +++ b/packages/cli/src/router/roadmaps.ts @@ -4,6 +4,7 @@ import { getClient, listRoadmaps, getRoadmap, + isRootIdentifierRejection, findRoadmapByName, getRoadmapProjects, type Roadmap, @@ -27,7 +28,7 @@ export const listRoadmapsInput = type({ }); export const roadmapInput = type({ - nameOrId: type("string").configure({ positional: true }).describe("roadmap name, slugId, or id"), + nameOrId: type("string").configure({ positional: true }).describe("roadmap name, slugId, or id (direct identifier lookup takes precedence over name lookup)"), "json?": type("boolean").describe("output as json"), "quiet?": type("boolean").describe("output id only"), "verbose?": type("boolean").describe("show all columns"), @@ -97,8 +98,12 @@ export const roadmapsRouter = router({ .query(async ({ input }) => { try { const client = getClient(); - let roadmap = await getRoadmap(client, input.nameOrId); - + let roadmap: Roadmap | null = null; + try { + roadmap = await getRoadmap(client, input.nameOrId); + } catch (error) { + if (!isRootIdentifierRejection(error, "roadmap")) throw error; + } if (!roadmap) { roadmap = await findRoadmapByName(client, input.nameOrId); } diff --git a/packages/codegen/generate-commands.ts b/packages/codegen/generate-commands.ts index df37944..1cf2f21 100644 --- a/packages/codegen/generate-commands.ts +++ b/packages/codegen/generate-commands.ts @@ -2122,7 +2122,7 @@ function generateProjectCreateHandler(): string { const project = await createProject(client, createPayload); if (project) { - console.log(\`created project: \${project.name}\`); + console.log(\`created project: \${project.name} (\${project.id})\`); } else { exitWithError("project creation returned no project; verify the outcome before retrying"); } diff --git a/packages/core/src/agent-sessions.ts b/packages/core/src/agent-sessions.ts index 943cd83..d7a375f 100644 --- a/packages/core/src/agent-sessions.ts +++ b/packages/core/src/agent-sessions.ts @@ -58,37 +58,34 @@ export async function getAgentSession( client: LinearClient, id: string ): Promise { - try { - const s = await client.agentSession(id); - const creator = s.creatorId ? await s.creator : null; - const appUser = s.appUserId ? await s.appUser : null; - const issue = s.issueId ? await s.issue : null; + const s = await client.agentSession(id); + if (!s) return null; + const creator = s.creatorId ? await s.creator : null; + const appUser = s.appUserId ? await s.appUser : null; + const issue = s.issueId ? await s.issue : null; - return { - id: s.id, - status: s.status, - type: s.type ?? null, - summary: s.summary ?? null, - externalLink: s.externalLink ?? null, - plan: s.plan ?? null, - sourceMetadata: s.sourceMetadata ?? null, - createdAt: s.createdAt, - updatedAt: s.updatedAt, - startedAt: s.startedAt ?? null, - endedAt: s.endedAt ?? null, - dismissedAt: s.dismissedAt ?? null, - archivedAt: s.archivedAt ?? null, - issueId: s.issueId ?? null, - issueIdentifier: issue?.identifier ?? null, - commentId: s.commentId ?? null, - creatorId: s.creatorId ?? null, - creatorName: creator?.name ?? null, - appUserId: s.appUserId ?? null, - appUserName: appUser?.name ?? null, - }; - } catch { - return null; - } + return { + id: s.id, + status: s.status, + type: s.type ?? null, + summary: s.summary ?? null, + externalLink: s.externalLink ?? null, + plan: s.plan ?? null, + sourceMetadata: s.sourceMetadata ?? null, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + startedAt: s.startedAt ?? null, + endedAt: s.endedAt ?? null, + dismissedAt: s.dismissedAt ?? null, + archivedAt: s.archivedAt ?? null, + issueId: s.issueId ?? null, + issueIdentifier: issue?.identifier ?? null, + commentId: s.commentId ?? null, + creatorId: s.creatorId ?? null, + creatorName: creator?.name ?? null, + appUserId: s.appUserId ?? null, + appUserName: appUser?.name ?? null, + }; } export async function updateAgentSession( diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 5dab4e9..e93f19e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,4 +1,4 @@ -import { LinearClient } from "@linear/sdk"; +import { InvalidInputLinearError, LinearClient, LinearErrorType } from "@linear/sdk"; import { getApiKey } from "./config"; let clientInstance: LinearClient | null = null; @@ -20,6 +20,24 @@ export class NotAuthenticatedError extends Error { } } +/** + * Allows name fallback after a rejected root identifier, not proof of entity absence. + * Only the SDK's uniform, exact-root invalid-input errors qualify; mixed failures + * and errors from lazy relations must propagate instead of selecting another entity. + */ +export function isRootIdentifierRejection( + error: unknown, + root: "initiative" | "roadmap", +): boolean { + return error instanceof InvalidInputLinearError && + Array.isArray(error.errors) && + error.errors.length > 0 && + error.errors.every(entry => + entry.type === LinearErrorType.InvalidInput && + entry.path?.length === 1 && entry.path[0] === root + ); +} + /** Per-call transport policies use an uncached client rather than mutate shared defaults. */ export function getClient( apiKeyOverride?: string, diff --git a/packages/core/src/cycles.ts b/packages/core/src/cycles.ts index 97d69ca..f2dc85c 100644 --- a/packages/core/src/cycles.ts +++ b/packages/core/src/cycles.ts @@ -59,58 +59,50 @@ export async function getCurrentCycle( client: LinearClient, teamKey: string ): Promise { - try { - const team = await client.team(teamKey); - - if (!team) { - return null; - } + const team = await client.team(teamKey); - const activeCycle = await team.activeCycle; + if (!team) { + return null; + } - if (!activeCycle) { - return null; - } + const activeCycle = await team.activeCycle; - return { - id: activeCycle.id, - number: activeCycle.number, - name: activeCycle.name, - description: activeCycle.description, - startsAt: activeCycle.startsAt, - endsAt: activeCycle.endsAt, - completedAt: activeCycle.completedAt, - progress: activeCycle.progress, - }; - } catch { + if (!activeCycle) { return null; } + + return { + id: activeCycle.id, + number: activeCycle.number, + name: activeCycle.name, + description: activeCycle.description, + startsAt: activeCycle.startsAt, + endsAt: activeCycle.endsAt, + completedAt: activeCycle.completedAt, + progress: activeCycle.progress, + }; } export async function getCycleById( client: LinearClient, cycleId: string ): Promise { - try { - const cycle = await client.cycle(cycleId); - - if (!cycle) { - return null; - } + const cycle = await client.cycle(cycleId); - return { - id: cycle.id, - number: cycle.number, - name: cycle.name, - description: cycle.description, - startsAt: cycle.startsAt, - endsAt: cycle.endsAt, - completedAt: cycle.completedAt, - progress: cycle.progress, - }; - } catch { + if (!cycle) { return null; } + + return { + id: cycle.id, + number: cycle.number, + name: cycle.name, + description: cycle.description, + startsAt: cycle.startsAt, + endsAt: cycle.endsAt, + completedAt: cycle.completedAt, + progress: cycle.progress, + }; } export async function createCycle( diff --git a/packages/core/src/documents.ts b/packages/core/src/documents.ts index 6d78198..cda84ba 100644 --- a/packages/core/src/documents.ts +++ b/packages/core/src/documents.ts @@ -38,24 +38,20 @@ export async function getDocument( client: LinearClient, id: string ): Promise { - try { - const doc = await client.document(id); - if (!doc) { - return null; - } - - return { - id: doc.id, - title: doc.title, - content: doc.content ?? null, - createdAt: doc.createdAt, - updatedAt: doc.updatedAt, - url: doc.url, - project: (await doc.project)?.name ?? null, - }; - } catch { + const doc = await client.document(id); + if (!doc) { return null; } + + return { + id: doc.id, + title: doc.title, + content: doc.content ?? null, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + url: doc.url, + project: (await doc.project)?.name ?? null, + }; } export async function createDocument( diff --git a/packages/core/src/git-automation-target-branches.ts b/packages/core/src/git-automation-target-branches.ts index d013dac..1839441 100644 --- a/packages/core/src/git-automation-target-branches.ts +++ b/packages/core/src/git-automation-target-branches.ts @@ -42,12 +42,8 @@ export async function getGitAutomationTargetBranch( teamKey: string, id: string ): Promise { - try { - const branches = await listGitAutomationTargetBranches(client, teamKey); - return branches.find((b) => b.id === id) ?? null; - } catch { - return null; - } + const branches = await listGitAutomationTargetBranches(client, teamKey); + return branches.find((b) => b.id === id) ?? null; } export async function findGitAutomationTargetBranchByPattern( @@ -55,12 +51,8 @@ export async function findGitAutomationTargetBranchByPattern( teamKey: string, pattern: string ): Promise { - try { - const branches = await listGitAutomationTargetBranches(client, teamKey); - return branches.find((b) => b.branchPattern === pattern) ?? null; - } catch { - return null; - } + const branches = await listGitAutomationTargetBranches(client, teamKey); + return branches.find((b) => b.branchPattern === pattern) ?? null; } export async function createGitAutomationTargetBranch( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aff47cf..8628da8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -28,6 +28,7 @@ export { createClientWithKey, resetClient, NotAuthenticatedError, + isRootIdentifierRejection, } from "./client"; // config diff --git a/packages/core/src/initiatives.ts b/packages/core/src/initiatives.ts index 86740ce..08b5cfa 100644 --- a/packages/core/src/initiatives.ts +++ b/packages/core/src/initiatives.ts @@ -25,27 +25,24 @@ export async function getInitiative( client: LinearClient, id: string ): Promise { - try { - const i = await client.initiative(id); - return { - id: i.id, - name: i.name, - slugId: i.slugId, - description: i.description ?? null, - status: i.status, - health: i.health ?? null, - color: i.color ?? null, - icon: i.icon ?? null, - targetDate: i.targetDate ?? null, - startedAt: i.startedAt ?? null, - completedAt: i.completedAt ?? null, - createdAt: i.createdAt, - updatedAt: i.updatedAt, - url: i.url, - }; - } catch { - return null; - } + const i = await client.initiative(id); + if (!i) return null; + return { + id: i.id, + name: i.name, + slugId: i.slugId, + description: i.description ?? null, + status: i.status, + health: i.health ?? null, + color: i.color ?? null, + icon: i.icon ?? null, + targetDate: i.targetDate ?? null, + startedAt: i.startedAt ?? null, + completedAt: i.completedAt ?? null, + createdAt: i.createdAt, + updatedAt: i.updatedAt, + url: i.url, + }; } export async function findInitiativeByName( diff --git a/packages/core/src/labels.ts b/packages/core/src/labels.ts index 13464e3..9d58e4e 100644 --- a/packages/core/src/labels.ts +++ b/packages/core/src/labels.ts @@ -44,17 +44,14 @@ export async function getLabel( client: LinearClient, id: string ): Promise