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..803a088 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,46 @@ 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 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 | null; + 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 +695,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 +772,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 +814,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 +838,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 +1875,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 +1909,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 +2117,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 +2171,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 +2185,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 +2257,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 +2699,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 +2725,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++;