Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .agents/pm/chores/pm-linear-pfmo.toon
Original file line number Diff line number Diff line change
@@ -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<T> 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<TData> { 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<unknown> (no invented schema mirror). JSON.parse wire boundary keeps its single deserialization cast, now as LinearResponse<TData>. (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: ""
4 changes: 4 additions & 0 deletions .agents/pm/history/pm-linear-pfmo.jsonl
Original file line number Diff line number Diff line change
@@ -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<T> 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<TData> { 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<unknown> (no invented schema mirror). JSON.parse wire boundary keeps its single deserialization cast, now as LinearResponse<TData>. (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"}
123 changes: 95 additions & 28 deletions index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<TData> {
data?: TData | null;
errors?: LinearGraphQLError[];
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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
Expand Down Expand Up @@ -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<LinearIssuesData>(
apiKey,
plan.query,
plan.variables
Expand Down Expand Up @@ -734,11 +772,11 @@ function parseRetryAfter(header: string | string[] | undefined): number | undefi
return undefined;
}

function linearRequestOnce(
function linearRequestOnce<TData>(
apiKey: string,
query: string,
variables: Record<string, unknown>
): Promise<LinearResponse> {
): Promise<LinearResponse<TData>> {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ query, variables });

Expand Down Expand Up @@ -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<TData>);
} catch (err) {
reject(new Error(`Failed to parse Linear response: ${String(err)}`));
}
Expand All @@ -796,15 +838,15 @@ function linearRequestOnce(
const sleep = (ms: number): Promise<void> =>
new Promise((r) => setTimeout(r, ms));

async function linearRequest(
async function linearRequest<TData>(
apiKey: string,
query: string,
variables: Record<string, unknown>
): Promise<LinearResponse> {
): Promise<LinearResponse<TData>> {
let lastErr: unknown;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await linearRequestOnce(apiKey, query, variables);
return await linearRequestOnce<TData>(apiKey, query, variables);
} catch (err) {
lastErr = err;
if (!(err instanceof RetriableHttpError) || attempt === MAX_RETRIES) break;
Expand Down Expand Up @@ -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.
Expand All @@ -1849,10 +1909,10 @@ interface TeamContext {
}

async function resolveTeamContext(apiKey: string, teamKey: string): Promise<TeamContext> {
const resp: any = await linearRequest(apiKey, TEAM_QUERY, { key: teamKey.toUpperCase() });
const resp = await linearRequest<LinearTeamData>(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];
Expand Down Expand Up @@ -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<LinearViewerData>(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.";
Expand Down Expand Up @@ -2111,9 +2171,12 @@ function assertPreflightOk(options: Record<string, unknown>): 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);
}

Expand All @@ -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<string, unknown> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<unknown>(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++;
Expand All @@ -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<unknown>(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++;
Expand Down