From aa590f2ff4e3a7a88a596dd2ba7c54bcc2aa523b Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:42:49 +0200 Subject: [PATCH 1/2] refactor: eliminate all 11 source any with real Linear GraphQL and SDK context types Mirror the pm-github PR #18 precedent. Two clusters: (A) Linear GraphQL responses: LinearResponse is now a generic envelope LinearResponse carrying data?: TData and errors?: LinearGraphQLError[], and linearRequest/linearRequestOnce take a type parameter so each call site gets a typed response with no cast. Per-operation data types declare only the fields actually read: LinearIssuesData (paginated issues query), LinearTeamData/LinearTeamNode (TEAM_QUERY team resolution), LinearViewerData (viewer{id} credential probe). The issueCreate/issueUpdate mutation sites consume only the error envelope, so they are typed linearRequest rather than mirroring an unread schema. The JSON.parse wire boundary keeps its single deserialization cast, now as LinearResponse. (B) SDK handler/context types: isJsonMode and renderImportDryRun take CommandHandlerContext | ImportExportContext, and the registerPreflight callback takes PreflightOverrideContext, all imported type-only from @unbrained/pm-cli/sdk/authoring (same subpath as the existing ExtensionApi imports, so no runtime module edge). --json continues to be read from ctx.global.json per the SDK contract; defensive ?. chains are retained. Behaviour is unchanged. Gates: build, npm test (63 pass), tsc --noEmit, changelog:check, and a zero-any grep all pass; real activation proof in a throwaway workspace shows pm linear --help, sync/validate, JSON mode, and the offline dry-run all working with host commands intact. pm-item: pm-linear-pfmo --- .agents/pm/chores/pm-linear-pfmo.toon | 15 +++ .agents/pm/history/pm-linear-pfmo.jsonl | 4 + index.ts | 121 ++++++++++++++++++------ 3 files changed, 112 insertions(+), 28 deletions(-) create mode 100644 .agents/pm/chores/pm-linear-pfmo.toon create mode 100644 .agents/pm/history/pm-linear-pfmo.jsonl diff --git a/.agents/pm/chores/pm-linear-pfmo.toon b/.agents/pm/chores/pm-linear-pfmo.toon new file mode 100644 index 0000000..a860005 --- /dev/null +++ b/.agents/pm/chores/pm-linear-pfmo.toon @@ -0,0 +1,15 @@ +id: pm-linear-pfmo +title: Eliminate all source any with real Linear GraphQL and SDK handler types +description: "Remove the 11 remaining any annotations in index.ts: (A) type the four Linear GraphQL response sites (team query, viewer probe, issue create/update mutations) with a generic LinearResponse envelope plus per-operation data types, and (B) replace the any handler/context params (isJsonMode, renderImportDryRun, registerPreflight callback) with the real SDK types CommandHandlerContext, ImportExportContext, and PreflightOverrideContext from @unbrained/pm-cli/sdk/authoring. Mirrors the pm-github PR #18 precedent. Typing refactor only: behaviour must not change, all gates (build, test, typecheck, changelog check, zero-any grep) must pass, plus real activation proof in a throwaway workspace." +type: Chore +status: closed +priority: 2 +tags: [] +created_at: "2026-07-27T21:38:19.988Z" +updated_at: "2026-07-27T21:42:26.676Z" +closed_at: "2026-07-27T21:42:26.675Z" +author: pi-agent +notes[1]{created_at,author,text}: + "2026-07-27T21:42:18.672Z",pi-agent,"Two clusters fixed. (A) Linear GraphQL: LinearResponse is now generic LinearResponse { data?: TData; errors?: LinearGraphQLError[] }; linearRequest/linearRequestOnce take a type parameter. New data types: LinearIssuesData (paginated issues query), LinearTeamData + LinearTeamNode (TEAM_QUERY), LinearViewerData (viewer{id} credential probe). The issueCreate/issueUpdate mutation sites read only the error envelope, so they are typed linearRequest (no invented schema mirror). JSON.parse wire boundary keeps its single deserialization cast, now as LinearResponse. (B) SDK contexts: isJsonMode and renderImportDryRun now take CommandHandlerContext | ImportExportContext, registerPreflight callback takes PreflightOverrideContext; all imported type-only from @unbrained/pm-cli/sdk/authoring (same subpath as the existing ExtensionApi/ExtensionModule imports, so no runtime module edge). --json stays read from ctx.global.json (correct contract); defensive ?. chains kept. Gates: build ok, npm test 63/63 pass, tsc --noEmit clean, changelog:check ok, zero-any grep empty. Activation proof in /tmp/pm-linear-proof: pm linear --help exit=0 listing export/import/sync/validate; pm linear sync --help exit=0 with full flags; pm list --json exit=0; pm health exit=0; pm linear validate exit=0 (stderr text) and --json exit=0 (structured object); pm linear sync --team ENG --dry-run --skip-preflight-network exit=0 printing the offline GraphQL plan." +close_reason: "All 11 source any eliminated with real Linear GraphQL + SDK types; build/test(63)/typecheck/changelog gates green; zero-any grep empty; real activation proof in throwaway workspace (pm linear --help exit 0 with all subcommands, host commands intact, validate + dry-run paths exercised, JSON and stderr modes correct)." +body: "" diff --git a/.agents/pm/history/pm-linear-pfmo.jsonl b/.agents/pm/history/pm-linear-pfmo.jsonl new file mode 100644 index 0000000..ec692b9 --- /dev/null +++ b/.agents/pm/history/pm-linear-pfmo.jsonl @@ -0,0 +1,4 @@ +{"ts":"2026-07-27T21:38:19.988Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-linear-pfmo"},{"op":"add","path":"/metadata/title","value":"Eliminate all source any with real Linear GraphQL and SDK handler types"},{"op":"add","path":"/metadata/description","value":"Remove the 11 remaining any annotations in index.ts: (A) type the four Linear GraphQL response sites (team query, viewer probe, issue create/update mutations) with a generic LinearResponse envelope plus per-operation data types, and (B) replace the any handler/context params (isJsonMode, renderImportDryRun, registerPreflight callback) with the real SDK types CommandHandlerContext, ImportExportContext, and PreflightOverrideContext from @unbrained/pm-cli/sdk/authoring. Mirrors the pm-github PR #18 precedent. Typing refactor only: behaviour must not change, all gates (build, test, typecheck, changelog check, zero-any grep) must pass, plus real activation proof in a throwaway workspace."},{"op":"add","path":"/metadata/type","value":"Chore"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-07-27T21:38:19.988Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-07-27T21:38:19.988Z"},{"op":"add","path":"/metadata/author","value":"pi-agent"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"8b0316660ff3cce2faf6d911c0932753efae35e5a64526de84cf05b7fa690d77","message":""} +{"ts":"2026-07-27T21:38:26.736Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T21:38:26.736Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"8b0316660ff3cce2faf6d911c0932753efae35e5a64526de84cf05b7fa690d77","after_hash":"6d144eefd2e8c0ada13a64cb362a3466c86b5862be0dd6da131f20b3b6445e28"} +{"ts":"2026-07-27T21:42:18.673Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T21:42:18.673Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-07-27T21:42:18.672Z","author":"pi-agent","text":"Two clusters fixed. (A) Linear GraphQL: LinearResponse is now generic LinearResponse { data?: TData; errors?: LinearGraphQLError[] }; linearRequest/linearRequestOnce take a type parameter. New data types: LinearIssuesData (paginated issues query), LinearTeamData + LinearTeamNode (TEAM_QUERY), LinearViewerData (viewer{id} credential probe). The issueCreate/issueUpdate mutation sites read only the error envelope, so they are typed linearRequest (no invented schema mirror). JSON.parse wire boundary keeps its single deserialization cast, now as LinearResponse. (B) SDK contexts: isJsonMode and renderImportDryRun now take CommandHandlerContext | ImportExportContext, registerPreflight callback takes PreflightOverrideContext; all imported type-only from @unbrained/pm-cli/sdk/authoring (same subpath as the existing ExtensionApi/ExtensionModule imports, so no runtime module edge). --json stays read from ctx.global.json (correct contract); defensive ?. chains kept. Gates: build ok, npm test 63/63 pass, tsc --noEmit clean, changelog:check ok, zero-any grep empty. Activation proof in /tmp/pm-linear-proof: pm linear --help exit=0 listing export/import/sync/validate; pm linear sync --help exit=0 with full flags; pm list --json exit=0; pm health exit=0; pm linear validate exit=0 (stderr text) and --json exit=0 (structured object); pm linear sync --team ENG --dry-run --skip-preflight-network exit=0 printing the offline GraphQL plan."}]}],"before_hash":"6d144eefd2e8c0ada13a64cb362a3466c86b5862be0dd6da131f20b3b6445e28","after_hash":"ee66c4c078ba66df785750687022295b4d5932fb08712dc7a8b412d9d2f42ace"} +{"ts":"2026-07-27T21:42:26.676Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T21:42:26.676Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-27T21:42:26.675Z"},{"op":"add","path":"/metadata/close_reason","value":"All 11 source any eliminated with real Linear GraphQL + SDK types; build/test(63)/typecheck/changelog gates green; zero-any grep empty; real activation proof in throwaway workspace (pm linear --help exit 0 with all subcommands, host commands intact, validate + dry-run paths exercised, JSON and stderr modes correct)."}],"before_hash":"ee66c4c078ba66df785750687022295b4d5932fb08712dc7a8b412d9d2f42ace","after_hash":"0e1a0064447eaa71453276aab804ef3fa15818b0dcfb4d9326da2607a4417cde"} diff --git a/index.ts b/index.ts index 685c224..ca01e30 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,10 @@ -import type { ExtensionApi, ExtensionModule } from "@unbrained/pm-cli/sdk/authoring"; +import type { + CommandHandlerContext, + ExtensionApi, + ExtensionModule, + ImportExportContext, + PreflightOverrideContext, +} from "@unbrained/pm-cli/sdk/authoring"; import { spawnSync } from "node:child_process"; import https from "node:https"; import crypto from "node:crypto"; @@ -71,14 +77,44 @@ export interface LinearIssue { url?: string | null; } -interface LinearResponse { - data?: { - issues?: { - nodes: LinearIssue[]; - pageInfo?: { hasNextPage: boolean; endCursor: string | null }; - }; +/** + * One entry of Linear's top-level `errors` array. Linear reports GraphQL + * failures out-of-band (HTTP 200 + `errors`), so every caller checks this + * envelope field before trusting `data`; only `message` is ever read. + */ +interface LinearGraphQLError { + message: string; +} + +/** + * Linear's GraphQL response envelope, generic over the per-operation `data` + * payload so each call site types exactly the selection set it reads instead + * of casting the whole response to `any`. `data` is optional because Linear + * can return `errors` with a partial (or absent) payload. + */ +interface LinearResponse { + data?: TData; + errors?: LinearGraphQLError[]; +} + +/** + * The `data` payload of the paginated issues query: only the connection + * fields `fetchAllLinearIssues` actually reads while following cursors. + */ +interface LinearIssuesData { + issues?: { + nodes: LinearIssue[]; + pageInfo?: { hasNextPage: boolean; endCursor: string | null }; }; - errors?: Array<{ message: string }>; +} + +/** + * The `data` payload of the `viewer { id }` credential probe. Only the + * viewer id is read: its presence proves the API key is accepted, so the + * preflight can distinguish "key rejected" from "reachable but unscoped". + */ +interface LinearViewerData { + viewer?: { id?: string }; } // Linear's GraphQL API caps `first` at 250 per page; request at most that and @@ -657,7 +693,7 @@ async function fetchAllLinearIssues( // it caps `first` at the page size. Pass `remaining` so the last page does // not over-fetch. const plan = buildImportRequestPlan(team, remaining, filters, after); - const response: LinearResponse = await linearRequest( + const response = await linearRequest( apiKey, plan.query, plan.variables @@ -734,11 +770,11 @@ function parseRetryAfter(header: string | string[] | undefined): number | undefi return undefined; } -function linearRequestOnce( +function linearRequestOnce( apiKey: string, query: string, variables: Record -): Promise { +): Promise> { return new Promise((resolve, reject) => { const body = JSON.stringify({ query, variables }); @@ -776,7 +812,11 @@ function linearRequestOnce( } try { const raw = Buffer.concat(chunks).toString("utf8"); - resolve(JSON.parse(raw) as LinearResponse); + // Wire-boundary deserialization: the body cannot be validated + // field-by-field cheaply, so the envelope is trusted per the + // operation's selection set and every reader still defends with + // optional chaining (Linear may return partial data + errors). + resolve(JSON.parse(raw) as LinearResponse); } catch (err) { reject(new Error(`Failed to parse Linear response: ${String(err)}`)); } @@ -796,15 +836,15 @@ function linearRequestOnce( const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); -async function linearRequest( +async function linearRequest( apiKey: string, query: string, variables: Record -): Promise { +): Promise> { let lastErr: unknown; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { - return await linearRequestOnce(apiKey, query, variables); + return await linearRequestOnce(apiKey, query, variables); } catch (err) { lastErr = err; if (!(err instanceof RetriableHttpError) || attempt === MAX_RETRIES) break; @@ -1833,6 +1873,24 @@ mutation($id: String!, $input: IssueUpdateInput!) { } `.trim(); +/** + * The single team node TEAM_QUERY selects, restricted to the fields + * resolveTeamContext reads. Every field is optional: Linear may return a + * partial node alongside GraphQL `errors`, and the resolver treats each + * nested value as suspect until it has been checked (hence the `?.` guards). + */ +interface LinearTeamNode { + id?: string; + states?: { nodes: Array<{ id?: string; name?: string }> }; + labels?: { nodes: Array<{ id?: string; name?: string }> }; + cycles?: { nodes: Array<{ id?: string; name?: string | null; number?: number | null }> }; +} + +/** The `data` payload of TEAM_QUERY: the team-key lookup connection. */ +interface LinearTeamData { + teams?: { nodes: LinearTeamNode[] }; +} + interface TeamContext { teamId: string; // Linear state name (lower-cased) -> state id, for status push. @@ -1849,10 +1907,10 @@ interface TeamContext { } async function resolveTeamContext(apiKey: string, teamKey: string): Promise { - const resp: any = await linearRequest(apiKey, TEAM_QUERY, { key: teamKey.toUpperCase() }); + const resp = await linearRequest(apiKey, TEAM_QUERY, { key: teamKey.toUpperCase() }); if (resp.errors?.length) { throw new CommandError( - `Linear API error resolving team ${teamKey}: ${resp.errors.map((e: any) => e.message).join("; ")}` + `Linear API error resolving team ${teamKey}: ${resp.errors.map((e) => e.message).join("; ")}` ); } const node = resp.data?.teams?.nodes?.[0]; @@ -2057,9 +2115,9 @@ async function preflightLinear( } if (!checkReachability) return null; try { - const resp: any = await linearRequest(apiKey, "query { viewer { id } }", {}); + const resp = await linearRequest(apiKey, "query { viewer { id } }", {}); if (resp.errors?.length) { - return `Linear API rejected the credentials: ${resp.errors.map((e: any) => e.message).join("; ")}`; + return `Linear API rejected the credentials: ${resp.errors.map((e) => e.message).join("; ")}`; } if (!resp.data?.viewer?.id) { return "Linear API reachable but returned no viewer; check the API key scope."; @@ -2111,9 +2169,12 @@ function assertPreflightOk(options: Record): void { } // True when the caller passed the GLOBAL --json flag. pm exposes it on -// ctx.global.json; in JSON mode handlers return the object and must NOT write -// their own stdout (the runtime serializes the return value). -function isJsonMode(ctx: any): boolean { +// ctx.global.json (NOT ctx.options); in JSON mode handlers return the object +// and must NOT write their own stdout (the runtime serializes the return +// value). Both command and importer/exporter contexts carry `global`, hence +// the union; the `?.` chain stays because it costs nothing and keeps the +// helper safe against a host that hands through a partial context. +function isJsonMode(ctx: CommandHandlerContext | ImportExportContext): boolean { return Boolean(ctx?.global?.json); } @@ -2122,7 +2183,7 @@ function isJsonMode(ctx: any): boolean { // preview to stderr. Shared by `linear sync` and the `linear` importer so both // dry-run paths are identical and network-free. Returns the JSON-mode payload. function renderImportDryRun( - ctx: any, + ctx: CommandHandlerContext | ImportExportContext, options: SyncOptions, teamSource?: TeamSource ): Record { @@ -2194,7 +2255,7 @@ export default defineExtension({ // Linear command runs. On failure it injects a sentinel option (it cannot // abort by throwing) that the handlers convert into a clean USAGE error. // ----------------------------------------------------------------------- - api.registerPreflight(async (ctx: any) => { + api.registerPreflight(async (ctx: PreflightOverrideContext) => { if (!commandMutatesLinear(ctx.command, ctx.options)) return {}; // Reachability uses the network; allow opting out (CI/offline/tests). // pm strips a leading `--no-` as boolean negation, so the user-facing flag @@ -2636,13 +2697,16 @@ export default defineExtension({ if (labelIds.length > 0) input.labelIds = labelIds; if (payload.dueDate) input.dueDate = payload.dueDate; applyPushDynamicFields(input, payload, teamCtx.cyclesByName); - const resp: any = await linearRequest(apiKey, ISSUE_UPDATE_MUTATION, { + // Only the error envelope is consumed here — the mutation's + // `success`/`issue` payload is never read, so the data type is + // honestly `unknown` rather than an invented schema mirror. + const resp = await linearRequest(apiKey, ISSUE_UPDATE_MUTATION, { id: payload.linearId, input, }); if (resp.errors?.length) { throw new Error( - `Linear issueUpdate failed: ${resp.errors.map((e: any) => e.message).join("; ")}` + `Linear issueUpdate failed: ${resp.errors.map((e) => e.message).join("; ")}` ); } updated++; @@ -2659,10 +2723,11 @@ export default defineExtension({ if (labelIds.length > 0) input.labelIds = labelIds; if (payload.dueDate) input.dueDate = payload.dueDate; applyPushDynamicFields(input, payload, teamCtx.cyclesByName); - const resp: any = await linearRequest(apiKey, ISSUE_CREATE_MUTATION, { input }); + // Same contract as the update above: only `errors` is read. + const resp = await linearRequest(apiKey, ISSUE_CREATE_MUTATION, { input }); if (resp.errors?.length) { throw new Error( - `Linear issueCreate failed: ${resp.errors.map((e: any) => e.message).join("; ")}` + `Linear issueCreate failed: ${resp.errors.map((e) => e.message).join("; ")}` ); } created++; From 5e3fef479f1d31a4f68de6589d56f121234e1657 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:59:54 +0200 Subject: [PATCH 2/2] fix: model an explicit GraphQL null data payload in LinearResponse The GraphQL spec permits a response carrying an explicit 'data: null' root payload alongside errors, not only an omitted data field. Typing it as 'data?: TData' modelled the omitted case alone, which is unsound for any future consumer reading data without optional chaining -- the runtime already handled null correctly, so this closes a type-level gap rather than a behavioural one. Raised by CodeRabbit on review. --- index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index ca01e30..803a088 100644 --- a/index.ts +++ b/index.ts @@ -89,11 +89,13 @@ interface LinearGraphQLError { /** * Linear's GraphQL response envelope, generic over the per-operation `data` * payload so each call site types exactly the selection set it reads instead - * of casting the whole response to `any`. `data` is optional because Linear - * can return `errors` with a partial (or absent) payload. + * of casting the whole response to `any`. `data` is optional AND nullable + * because the GraphQL spec permits both an omitted `data` field and an explicit + * `data: null` root payload alongside `errors` — modelling only the omitted case + * would be unsound for any consumer that reads `data` without optional chaining. */ interface LinearResponse { - data?: TData; + data?: TData | null; errors?: LinearGraphQLError[]; }