From 89baa94ca7d516ab90854d20970d4e3ae55fa5c8 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Mon, 23 Mar 2026 13:34:17 -0400 Subject: [PATCH 1/5] Fix release prepare typecheck regressions --- .github/workflows/release-prepare.yml | 2 + src/cli/prerequisites.ts | 2 +- .../extensions/linear/commands.ts | 21 +- .../extensions/linear/project-artifacts.ts | 2 +- .../extensions/tickets/commands.ts | 69 ++-- .../extensions/tickets/domain.ts | 14 +- .../extensions/tickets/normalized-model.ts | 39 ++- .../extensions/tickets/provenance.ts | 29 +- src/daemon/runtime-cache.ts | 5 +- src/lib/runtime-projects.ts | 19 +- tests/linear-commands.test.ts | 323 ++++++++++-------- tests/runtime-cache.test.ts | 3 + tests/tickets-git-channel.test.ts | 1 + 13 files changed, 314 insertions(+), 215 deletions(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 8247f69c7..9697ac16d 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -16,6 +16,8 @@ concurrency: jobs: semantic-release: runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: - name: Checkout uses: actions/checkout@v4 diff --git a/src/cli/prerequisites.ts b/src/cli/prerequisites.ts index 75ada5de4..b58b2c3f3 100644 --- a/src/cli/prerequisites.ts +++ b/src/cli/prerequisites.ts @@ -722,7 +722,7 @@ export function getCommandPrerequisiteContracts(input: { }): readonly CommandPrerequisiteContract[] { const command = normalizePrerequisiteCommand({ command: input.command }); return COMMAND_PREREQUISITE_CONTRACTS.filter((contract) => - contract.commands.includes(command) + contract.commands.some((candidate) => candidate === command) ); } diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index 492bec092..c99a0f99e 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -12,7 +12,7 @@ import { } from "../../../lib/config.ts"; import { isRecord } from "../../../lib/guards.ts"; import { openUrl } from "../../../lib/os.ts"; -import { display } from "../../../ui/display.ts"; +import { type DisplayCell, display } from "../../../ui/display.ts"; import { findTicketRemoteLink, normalizeTicketFieldName, @@ -2579,6 +2579,7 @@ type LinearSyncClient = Pick< | "getIssueByIdentifier" | "getProject" | "listIssueComments" + | "listProjects" | "listProjectDocuments" | "listProjectIssuesPage" | "listProjectMilestones" @@ -2593,6 +2594,7 @@ type LinearSyncClient = Pick< type ProjectArtifactLinearClient = Pick< ReturnType, + | "listProjects" | "createProjectDocument" | "createProjectMilestone" | "createProjectUpdate" @@ -3228,7 +3230,12 @@ async function runProjectArtifactCommand(input: { } const draftArtifacts = localArtifacts.data.filter( - (artifact) => + ( + artifact + ): artifact is Extract< + LocalLinearProjectArtifact, + { readonly kind: "linear-project-status-update" } + > => artifact.kind === "linear-project-status-update" && isDraftStatusUpdatePath({ path: artifact.path }) ); @@ -3897,10 +3904,10 @@ async function renderProjectArtifactCommandPayload(input: { entries: [ ["profile", input.payload.profileId], ["project_id", input.payload.projectId], - ...Object.entries(input.payload.summary ?? {}).map(([key, value]) => [ - key, - String(value), - ]), + ...Object.entries(input.payload.summary ?? {}).map( + ([key, value]) => + [key, String(value)] as const satisfies readonly [string, DisplayCell] + ), ], }); } @@ -4135,7 +4142,7 @@ function removeLinearAssigneeMapping(input: { type RecordedSyncConflict = { readonly field: string; - readonly authority: "hack" | "linear"; + readonly authority: "hack" | "linear" | "review_required"; readonly summary: string; readonly localValue?: TicketMetadataValue; readonly remoteValue?: TicketMetadataValue; diff --git a/src/control-plane/extensions/linear/project-artifacts.ts b/src/control-plane/extensions/linear/project-artifacts.ts index 6338e24bc..c1f714106 100644 --- a/src/control-plane/extensions/linear/project-artifacts.ts +++ b/src/control-plane/extensions/linear/project-artifacts.ts @@ -109,7 +109,7 @@ export const parseLinearProjectArtifactFile = ({ throw new Error(`Missing YAML frontmatter in ${filePath}`); } - const parsed = YAML.parse(match[1]) as unknown; + const parsed = YAML.parse(match[1] ?? "") as unknown; if (!isRecord(parsed)) { throw new Error(`Invalid YAML frontmatter in ${filePath}`); } diff --git a/src/control-plane/extensions/tickets/commands.ts b/src/control-plane/extensions/tickets/commands.ts index 207139492..0630b5bbb 100644 --- a/src/control-plane/extensions/tickets/commands.ts +++ b/src/control-plane/extensions/tickets/commands.ts @@ -1529,47 +1529,54 @@ function parseTicketDocumentOption(opts: { readonly index: number; readonly state: MutableTicketsArgs; }): ParseHandlerResult { - for (const [flag, kind, error] of [ - [ - "--kind", - (value: string) => (isTicketDocumentKind(value) ? value : null), - "Invalid --kind value. Expected description|spec|notes.", - ], - [ - "--role", - (value: string) => (isTicketDocumentRole(value) ? value : null), - "Invalid --role value. Expected description|spec|notes|handoff.", - ], - ] as const) { - const consumed = consumeOptionValue({ - args: opts.args, - index: opts.index, - flag, - }); - if (!consumed.matched) { - continue; + const kindOption = consumeOptionValue({ + args: opts.args, + index: opts.index, + flag: "--kind", + }); + if (kindOption.matched) { + if (kindOption.error) { + return { + handled: true, + nextIndex: kindOption.nextIndex, + error: kindOption.error, + }; } - if (consumed.error) { + const kind = kindOption.value ?? ""; + if (!isTicketDocumentKind(kind)) { return { handled: true, - nextIndex: consumed.nextIndex, - error: consumed.error, + nextIndex: kindOption.nextIndex, + error: "Invalid --kind value. Expected description|spec|notes.", }; } - const value = kind(consumed.value ?? ""); - if (!value) { + opts.state.kind = kind; + return { handled: true, nextIndex: kindOption.nextIndex }; + } + + const roleOption = consumeOptionValue({ + args: opts.args, + index: opts.index, + flag: "--role", + }); + if (roleOption.matched) { + if (roleOption.error) { return { handled: true, - nextIndex: consumed.nextIndex, - error, + nextIndex: roleOption.nextIndex, + error: roleOption.error, }; } - if (flag === "--kind") { - opts.state.kind = value; - } else { - opts.state.role = value; + const role = roleOption.value ?? ""; + if (!isTicketDocumentRole(role)) { + return { + handled: true, + nextIndex: roleOption.nextIndex, + error: "Invalid --role value. Expected description|spec|notes|handoff.", + }; } - return { handled: true, nextIndex: consumed.nextIndex }; + opts.state.role = role; + return { handled: true, nextIndex: roleOption.nextIndex }; } return { handled: false, nextIndex: opts.index }; diff --git a/src/control-plane/extensions/tickets/domain.ts b/src/control-plane/extensions/tickets/domain.ts index 0c60ad7d1..465d14a43 100644 --- a/src/control-plane/extensions/tickets/domain.ts +++ b/src/control-plane/extensions/tickets/domain.ts @@ -8,6 +8,12 @@ import { projectRemoteLinkToCompatibilityFields, } from "./provenance.ts"; +export type { + TicketDocument, + TicketDocumentKind, + TicketDocumentRole, +} from "./documents.ts"; + export type TicketStatus = "open" | "in_progress" | "blocked" | "done"; export type TicketSummaryCompatibility = { @@ -118,15 +124,11 @@ export type TicketFieldVersion = { readonly value?: TicketMetadataValue; }; -export type { - TicketDocument, - TicketDocumentKind, - TicketDocumentRole, -} from "./documents.ts"; +export type { TicketDocument, TicketDocumentKind, TicketDocumentRole }; export type TicketFieldState = { readonly field: string; - readonly authority: string; + readonly authority: TicketFieldAuthority; readonly conflictIds: readonly string[]; }; diff --git a/src/control-plane/extensions/tickets/normalized-model.ts b/src/control-plane/extensions/tickets/normalized-model.ts index cb1fe9315..c238188a6 100644 --- a/src/control-plane/extensions/tickets/normalized-model.ts +++ b/src/control-plane/extensions/tickets/normalized-model.ts @@ -223,6 +223,7 @@ export const normalizeLegacyTicketSummary = ( id: input.ticket.projectId, name: input.ticket.projectName, }); + const assignee = normalizeOptionalString(input.ticket.assignee); const normalized: NormalizedTicketEntity = { schemaVersion: NORMALIZED_TICKET_SCHEMA_VERSION, @@ -232,9 +233,7 @@ export const normalizeLegacyTicketSummary = ( title: input.ticket.title.trim(), ...(input.ticket.body !== undefined ? { body: input.ticket.body } : {}), status: input.ticket.status, - ...(normalizeOptionalString(input.ticket.assignee) - ? { assignee: input.ticket.assignee.trim() } - : {}), + ...(assignee ? { assignee } : {}), ...(project ? { project } : {}), tags: normalizeStringList(input.ticket.tags), relationships: { @@ -588,9 +587,8 @@ const parseNormalizedTicketEntity = ( Array.isArray(provenanceRecord.links), "normalized ticket links must be an array" ); - const links = provenanceRecord.links.map((link) => - parseNormalizedTicketLink(link) - ); + const linksInput = provenanceRecord.links as readonly unknown[]; + const links = linksInput.map((link) => parseNormalizedTicketLink(link)); const linkIds = new Set(); const originLinks: NormalizedTicketLink[] = []; for (const link of links) { @@ -730,7 +728,8 @@ const parseNormalizedTicketLink = (input: unknown): NormalizedTicketLink => { "normalized ticket remote containers must be an array" ); const containerKeys = new Set(); - for (const container of remoteRecord.containers) { + const containers = remoteRecord.containers as readonly unknown[]; + for (const container of containers) { const containerRecord = asRecord(container, "normalized ticket container"); assertValue( (TICKET_LINK_CONTAINER_KINDS as readonly string[]).includes( @@ -795,7 +794,7 @@ const parseTicketAuthorityRule = ( assertNonEmptyString(winnerRecord.linkId, `${label} winner linkId`); if (linkIds) { assertValue( - linkIds.has(winnerRecord.linkId), + linkIds.has(winnerRecord.linkId as string), `${label} winner linkId must reference an existing link` ); } @@ -836,28 +835,29 @@ const assertTicketSchemaValue = (input: unknown, label: string): void => { const assertStringList = (input: unknown, label: string): void => { assertValue(Array.isArray(input), `${label} must be an array`); const seen = new Set(); - for (const [index, value] of input.entries()) { + const values = input as readonly unknown[]; + for (const [index, value] of values.entries()) { assertNonEmptyString(value, `${label}[${index}]`); assertValue(!seen.has(value), `${label} must not contain duplicates`); seen.add(value); } }; -const assertTicketStatus = (input: unknown): asserts input is TicketStatus => { +function assertTicketStatus(input: unknown): asserts input is TicketStatus { assertValue( (TICKET_STATUSES as readonly string[]).includes(String(input)), "ticket status must be open, in_progress, blocked, or done" ); -}; +} -const assertExternalTicketSystem = ( +function assertExternalTicketSystem( input: unknown -): asserts input is ExternalTicketSystem => { +): asserts input is ExternalTicketSystem { assertValue( (EXTERNAL_TICKET_SYSTEMS as readonly string[]).includes(String(input)), "external ticket system must be linear, github, or asana" ); -}; +} const assertIsoTimestamp = (input: unknown, label: string): void => { assertNonEmptyString(input, label); @@ -876,14 +876,17 @@ const assertUrl = (input: unknown, label: string): void => { } }; -const assertString = (input: unknown, label: string): void => { +function assertString(input: unknown, label: string): asserts input is string { assertValue(typeof input === "string", `${label} must be a string`); -}; +} -const assertNonEmptyString = (input: unknown, label: string): void => { +function assertNonEmptyString( + input: unknown, + label: string +): asserts input is string { assertString(input, label); assertValue(input.trim().length > 0, `${label} must be a non-empty string`); -}; +} const asRecord = (input: unknown, label: string): Record => { assertValue( diff --git a/src/control-plane/extensions/tickets/provenance.ts b/src/control-plane/extensions/tickets/provenance.ts index f6cb855b3..3ca44dbba 100644 --- a/src/control-plane/extensions/tickets/provenance.ts +++ b/src/control-plane/extensions/tickets/provenance.ts @@ -150,8 +150,12 @@ export function buildTicketRemoteLinks(input: { ); if (trackedIndex >= 0) { + const trackedRemote = remotes[trackedIndex]; + if (!trackedRemote) { + continue; + } remotes[trackedIndex] = mergeCheckpointRemote({ - remote: remotes[trackedIndex], + remote: trackedRemote, checkpoint, }); continue; @@ -271,7 +275,8 @@ export function buildTicketFieldAuthorities(input: { readonly remotes: readonly TicketRemoteLink[]; readonly conflicts?: readonly TicketSyncConflictCompatibility[]; }): TicketFieldAuthorityEntry[] { - const defaultAuthority = input.remotes.length > 0 ? "remote" : "local"; + const defaultAuthority: TicketFieldAuthority = + input.remotes.length > 0 ? "remote" : "local"; const byField = new Map(); for (const field of [ @@ -306,15 +311,33 @@ export function buildTicketFieldAuthorities(input: { for (const conflict of input.conflicts ?? []) { const field = normalizeTicketFieldName(conflict.field); const current = byField.get(field); + const authority = normalizeConflictAuthority({ + authority: conflict.authority, + }); byField.set(field, { field, - authority: conflict.authority ?? current?.authority ?? defaultAuthority, + authority: authority ?? current?.authority ?? defaultAuthority, }); } return [...byField.values()]; } +function normalizeConflictAuthority(input: { + readonly authority?: string; +}): TicketFieldAuthority | undefined { + if ( + input.authority === "local" || + input.authority === "remote" || + input.authority === "append_only" || + input.authority === "derived" || + input.authority === "review_required" + ) { + return input.authority; + } + return undefined; +} + export function buildTicketFieldVersions(input: { readonly ticket: TicketProvenanceCompatibility; readonly conflicts?: readonly TicketSyncConflictCompatibility[]; diff --git a/src/daemon/runtime-cache.ts b/src/daemon/runtime-cache.ts index 60bb85857..d78bdf483 100644 --- a/src/daemon/runtime-cache.ts +++ b/src/daemon/runtime-cache.ts @@ -358,7 +358,10 @@ async function resolveRefreshResult(opts: { readonly repairReason: string | null; }> { if (opts.runtimeResult.ok) { - return await resolveHealthyRefreshResult(opts); + return await resolveHealthyRefreshResult({ + ...opts, + runtimeResult: opts.runtimeResult, + }); } return { health: resolveUnavailableRefreshHealth({ diff --git a/src/lib/runtime-projects.ts b/src/lib/runtime-projects.ts index 9fda0045e..fa5e970a4 100644 --- a/src/lib/runtime-projects.ts +++ b/src/lib/runtime-projects.ts @@ -54,12 +54,19 @@ export type RuntimeProject = { readonly isGlobal: boolean; }; -export type RuntimeProjectsResult = { - readonly ok: boolean; - readonly runtime: readonly RuntimeProject[]; - readonly error: string | null; - readonly checkedAtMs: number; -}; +export type RuntimeProjectsResult = + | { + readonly ok: true; + readonly runtime: readonly RuntimeProject[]; + readonly error: null; + readonly checkedAtMs: number; + } + | { + readonly ok: false; + readonly runtime: readonly RuntimeProject[]; + readonly error: string; + readonly checkedAtMs: number; + }; type ContainerInspectData = { readonly labels: Record; diff --git a/tests/linear-commands.test.ts b/tests/linear-commands.test.ts index da08640d4..37477a89a 100644 --- a/tests/linear-commands.test.ts +++ b/tests/linear-commands.test.ts @@ -22,6 +22,60 @@ afterEach(async () => { tempDir = null; }); +type SyncTicketDetail = Awaited< + ReturnType< + Parameters< + typeof __testOnly.syncIssueFromLinearToTicket + >[0]["runtime"]["tickets"]["getTicketDetail"] + > +>; + +type ProjectArtifactLinearClient = Parameters< + typeof __testOnly.runProjectArtifactCommand +>[0]["runtime"]["linear"]; + +function createEmptyTicketDetail( + overrides: Partial = {} +): SyncTicketDetail { + return { + ticket: null, + events: [], + documents: [], + comments: [], + reviewNotes: [], + syncCheckpoints: [], + conflicts: [], + ...overrides, + }; +} + +function createProjectArtifactLinearClient( + overrides: Partial = {} +): ProjectArtifactLinearClient { + return { + listProjects: async () => ({ ok: true as const, data: [] }), + listProjectDocuments: async () => ({ ok: true as const, data: [] }), + createProjectDocument: async () => { + throw new Error("createProjectDocument should not be called"); + }, + updateProjectDocument: async () => { + throw new Error("updateProjectDocument should not be called"); + }, + listProjectMilestones: async () => ({ ok: true as const, data: [] }), + createProjectMilestone: async () => { + throw new Error("createProjectMilestone should not be called"); + }, + updateProjectMilestone: async () => { + throw new Error("updateProjectMilestone should not be called"); + }, + listProjectUpdates: async () => ({ ok: true as const, data: [] }), + createProjectUpdate: async () => { + throw new Error("createProjectUpdate should not be called"); + }, + ...overrides, + }; +} + const minimalLinearBindingConfig = { routing: { mode: "existing_only", @@ -1365,24 +1419,20 @@ test("syncIssueFromLinearToTicket preserves hack authority, appends comments, an throw new Error("createTicket should not be called"); }, getTicket: async () => null, - getTicketDetail: async () => ({ - ticket: null, - events: [], - comments: [ - { - commentId: "comment-1", - ticketId: "T-00001", - body: "Already synced", - source: "linear", - actor: "linear", - createdAt: "2026-03-05T10:00:00.000Z", - externalId: "linear-comment-1", - }, - ], - reviewNotes: [], - syncCheckpoints: [], - conflicts: [], - }), + getTicketDetail: async () => + createEmptyTicketDetail({ + comments: [ + { + commentId: "comment-1", + ticketId: "T-00001", + body: "Already synced", + source: "linear", + actor: "linear", + createdAt: "2026-03-05T10:00:00.000Z", + externalId: "linear-comment-1", + }, + ], + }), appendComment: async (input: { readonly body: string }) => { appendedBodies.push(input.body); return { @@ -1537,14 +1587,7 @@ test("syncIssueFromLinearToTicket records review-required conflicts for dual-hom throw new Error("createTicket should not be called"); }, getTicket: async () => null, - getTicketDetail: async () => ({ - ticket: null, - events: [], - comments: [], - reviewNotes: [], - syncCheckpoints: [], - conflicts: [], - }), + getTicketDetail: async () => createEmptyTicketDetail(), appendComment: async () => ({ ok: true as const, comment: { @@ -1694,31 +1737,27 @@ test("syncTicketToLinearIssue pushes missing local comments and records a checkp }), updateTicket: async () => ({ ok: true as const }), listTickets: async () => [], - getTicketDetail: async () => ({ - ticket: null, - events: [], - comments: [ - { - commentId: "comment-1", - ticketId: "T-00001", - body: "Already remote", - source: "hack", - actor: "dio", - createdAt: "2026-03-05T10:00:00.000Z", - }, - { - commentId: "comment-2", - ticketId: "T-00001", - body: "Push me", - source: "hack", - actor: "dio", - createdAt: "2026-03-05T10:01:00.000Z", - }, - ], - reviewNotes: [], - syncCheckpoints: [], - conflicts: [], - }), + getTicketDetail: async () => + createEmptyTicketDetail({ + comments: [ + { + commentId: "comment-1", + ticketId: "T-00001", + body: "Already remote", + source: "hack", + actor: "dio", + createdAt: "2026-03-05T10:00:00.000Z", + }, + { + commentId: "comment-2", + ticketId: "T-00001", + body: "Push me", + source: "hack", + actor: "dio", + createdAt: "2026-03-05T10:01:00.000Z", + }, + ], + }), linkCommentExternalId: async (input: { readonly commentId: string; readonly externalId: string; @@ -1900,14 +1939,7 @@ test("syncTicketToLinearIssue reuses a linked Linear issue inferred from provena }), updateTicket: async () => ({ ok: true as const }), listTickets: async () => [], - getTicketDetail: async () => ({ - ticket: null, - events: [], - comments: [], - reviewNotes: [], - syncCheckpoints: [], - conflicts: [], - }), + getTicketDetail: async () => createEmptyTicketDetail(), linkCommentExternalId: async () => ({ ok: true as const }), recordSyncCheckpoint: async () => ({ ok: true as const, @@ -1920,7 +1952,9 @@ test("syncTicketToLinearIssue reuses a linked Linear issue inferred from provena createdAt: "2026-03-05T10:10:00.000Z", }, }), - recordSyncConflict: async () => ({ ok: true as const, conflict: null }), + recordSyncConflict: async () => { + throw new Error("recordSyncConflict should not be called"); + }, }, linear: { getIssueById: async () => ({ @@ -2052,14 +2086,7 @@ test("syncTicketToLinearIssue preserves remote issue fields for review-required }), updateTicket: async () => ({ ok: true as const }), listTickets: async () => [], - getTicketDetail: async () => ({ - ticket: null, - events: [], - comments: [], - reviewNotes: [], - syncCheckpoints: [], - conflicts: [], - }), + getTicketDetail: async () => createEmptyTicketDetail(), linkCommentExternalId: async () => ({ ok: true as const }), recordSyncCheckpoint: async () => ({ ok: true as const, @@ -2252,27 +2279,23 @@ test("syncTicketToLinearIssue does not duplicate legacy body conflicts after fie }), updateTicket: async () => ({ ok: true as const }), listTickets: async () => [], - getTicketDetail: async () => ({ - ticket: null, - events: [], - comments: [], - reviewNotes: [], - syncCheckpoints: [], - conflicts: [ - { - conflictId: "conflict-body", - ticketId: "T-00006", - provider: "linear" as const, - field: "body", - status: "open" as const, - authority: "review_required" as const, - localValue: "Local body", - remoteValue: "Linear body", - createdAt: "2026-03-05T10:05:00.000Z", - updatedAt: "2026-03-05T10:05:00.000Z", - }, - ], - }), + getTicketDetail: async () => + createEmptyTicketDetail({ + conflicts: [ + { + conflictId: "conflict-body", + ticketId: "T-00006", + provider: "linear" as const, + field: "body", + status: "open" as const, + authority: "review_required" as const, + localValue: "Local body", + remoteValue: "Linear body", + createdAt: "2026-03-05T10:05:00.000Z", + updatedAt: "2026-03-05T10:05:00.000Z", + }, + ], + }), linkCommentExternalId: async () => ({ ok: true as const }), recordSyncCheckpoint: async () => ({ ok: true as const, @@ -2443,16 +2466,9 @@ test("syncTicketToLinearIssue records a deterministic checkpoint idempotency key }), updateTicket: async () => ({ ok: true as const }), listTickets: async () => [], - getTicketDetail: async () => ({ - ticket: null, - events: [], - comments: [], - reviewNotes: [], - syncCheckpoints: [], - conflicts: [], - }), + getTicketDetail: async () => createEmptyTicketDetail(), linkCommentExternalId: async () => ({ ok: true as const }), - recordSyncCheckpoint: async (input: Record) => { + recordSyncCheckpoint: async (input: { readonly direction?: string }) => { checkpointInputs.push(input); return { ok: true as const, @@ -2466,7 +2482,9 @@ test("syncTicketToLinearIssue records a deterministic checkpoint idempotency key }, }; }, - recordSyncConflict: async () => ({ ok: true as const, conflict: null }), + recordSyncConflict: async () => { + throw new Error("recordSyncConflict should not be called"); + }, }, linear: { getIssueById: async () => ({ @@ -2849,7 +2867,7 @@ test("runProjectArtifactCommand pulls project documents into repo state", async profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ listProjectDocuments: async () => ({ ok: true as const, data: [ @@ -2865,7 +2883,7 @@ test("runProjectArtifactCommand pulls project documents into repo state", async }, ], }), - }, + }), }, }); @@ -2875,14 +2893,18 @@ test("runProjectArtifactCommand pulls project documents into repo state", async } expect(result.payload.changed).toBe(1); - expect(result.payload.writtenPaths).toEqual([ + const writtenPaths = result.payload.writtenPaths; + expect(writtenPaths).toEqual([ resolve( projectDir, ".hack/linear/projects/proj_123/documents/launch-plan.md" ), ]); + if (!writtenPaths) { + throw new Error("Expected writtenPaths for pull payload"); + } - const file = await Bun.file(result.payload.writtenPaths[0] ?? "").text(); + const file = await Bun.file(writtenPaths[0] ?? "").text(); expect(file).toContain("kind: linear-project-document"); expect(file).toContain("linearId: doc_123"); expect(file).toContain("icon: rocket"); @@ -2904,7 +2926,7 @@ test("runProjectArtifactCommand pull refuses to overwrite unmanaged files", asyn profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ listProjectDocuments: async () => ({ ok: true as const, data: [ @@ -2917,7 +2939,7 @@ test("runProjectArtifactCommand pull refuses to overwrite unmanaged files", asyn }, ], }), - }, + }), }, }); @@ -2959,7 +2981,7 @@ archived: false profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ listProjectDocuments: async () => ({ ok: true as const, data: [ @@ -2972,7 +2994,7 @@ archived: false }, ], }), - }, + }), }, }); @@ -3040,7 +3062,7 @@ archived: false profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ listProjectDocuments: async () => ({ ok: true as const, data: [ @@ -3067,7 +3089,7 @@ archived: false }, ], }), - }, + }), }, }); @@ -3076,13 +3098,17 @@ archived: false return; } - expect(result.payload.summary).toEqual({ + const summary = result.payload.summary; + expect(summary).toEqual({ create: 1, update: 1, noop: 1, remoteOnly: 1, errors: 0, }); + if (!summary) { + throw new Error("Expected summary for plan payload"); + } }); test("runProjectArtifactCommand plan fails on duplicate managed mappings", async () => { @@ -3126,12 +3152,12 @@ archived: false profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ listProjectDocuments: async () => ({ ok: true as const, data: [], }), - }, + }), }, }); @@ -3185,7 +3211,7 @@ archived: false profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ listProjectDocuments: async () => ({ ok: true as const, data: [ @@ -3226,7 +3252,7 @@ archived: false }, }; }, - }, + }), }, }); @@ -3237,8 +3263,12 @@ archived: false expect(createCalls).toEqual(["Create me"]); expect(updateCalls).toEqual(["doc_update:Update me"]); - expect(result.payload.summary.created).toBe(1); - expect(result.payload.summary.updated).toBe(1); + const applySummary = result.payload.summary; + if (!applySummary) { + throw new Error("Expected summary for apply payload"); + } + expect(applySummary.created).toBe(1); + expect(applySummary.updated).toBe(1); const createdText = await Bun.file( resolve(documentsDir, "create-me.md") @@ -3276,7 +3306,7 @@ archived: false profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ listProjectDocuments: async () => ({ ok: true as const, data: [ @@ -3300,7 +3330,7 @@ archived: false updatedAt: "2026-03-15T10:00:00.000Z", }, }), - }, + }), }, }); @@ -3342,7 +3372,7 @@ archived: false profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ listProjectDocuments: async () => ({ ok: true as const, data: [ @@ -3358,7 +3388,7 @@ archived: false createProjectDocument: async () => { throw new Error("createProjectDocument should not be called"); }, - }, + }), }, }); @@ -3398,7 +3428,7 @@ archived: false profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ listProjectDocuments: async () => ({ ok: true as const, data: [ @@ -3425,7 +3455,7 @@ archived: false }, }; }, - }, + }), }, }); @@ -3446,7 +3476,7 @@ test("runProjectArtifactCommand pulls project milestones into repo state", async profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ listProjectMilestones: async () => ({ ok: true as const, data: [ @@ -3462,7 +3492,7 @@ test("runProjectArtifactCommand pulls project milestones into repo state", async }, ], }), - }, + }), }, }); @@ -3472,14 +3502,18 @@ test("runProjectArtifactCommand pulls project milestones into repo state", async } expect(result.payload.changed).toBe(1); - expect(result.payload.writtenPaths).toEqual([ + const milestoneWrittenPaths = result.payload.writtenPaths; + expect(milestoneWrittenPaths).toEqual([ resolve( projectDir, ".hack/linear/projects/proj_123/milestones/private-beta.md" ), ]); + if (!milestoneWrittenPaths) { + throw new Error("Expected writtenPaths for milestone pull payload"); + } - const file = await Bun.file(result.payload.writtenPaths[0] ?? "").text(); + const file = await Bun.file(milestoneWrittenPaths[0] ?? "").text(); expect(file).toContain("kind: linear-project-milestone"); expect(file).toContain("linearId: milestone_123"); expect(file).toContain('targetDate: "2026-04-01"'); @@ -3532,7 +3566,7 @@ Prepare launch checklist. profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ listProjectMilestones: async () => ({ ok: true as const, data: [ @@ -3583,7 +3617,7 @@ Prepare launch checklist. }, }; }, - }, + }), }, }); @@ -3598,8 +3632,12 @@ Prepare launch checklist. expect(updateCalls).toEqual([ "milestone_launch:Launch:Prepare launch checklist.\n:planned", ]); - expect(result.payload.summary.created).toBe(1); - expect(result.payload.summary.updated).toBe(1); + const milestoneSummary = result.payload.summary; + if (!milestoneSummary) { + throw new Error("Expected summary for milestone apply payload"); + } + expect(milestoneSummary.created).toBe(1); + expect(milestoneSummary.updated).toBe(1); const createdText = await Bun.file( resolve(milestonesDir, "private-beta.md") @@ -3646,7 +3684,7 @@ archived: false profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ listProjectMilestones: async () => ({ ok: true as const, data: [ @@ -3675,7 +3713,7 @@ archived: false }, }; }, - }, + }), }, }); @@ -3716,7 +3754,7 @@ Still on track for dogfooding. profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ createProjectUpdate: async ({ body, health }) => ({ ok: true as const, data: { @@ -3730,7 +3768,7 @@ Still on track for dogfooding. projectName: "Dogfood", }, }), - }, + }), }, }); @@ -3739,8 +3777,13 @@ Still on track for dogfooding. return; } - expect(result.payload.summary.published).toBe(1); - expect(result.payload.movedPaths).toEqual([ + const publishSummary = result.payload.summary; + const movedPaths = result.payload.movedPaths; + if (!(publishSummary && movedPaths)) { + throw new Error("Expected summary and movedPaths for publish payload"); + } + expect(publishSummary.published).toBe(1); + expect(movedPaths).toEqual([ { from: resolve(draftsDir, "2026-03-14-weekly.md"), to: resolve( @@ -3750,9 +3793,7 @@ Still on track for dogfooding. }, ]); - const publishedText = await Bun.file( - result.payload.movedPaths[0]?.to ?? "" - ).text(); + const publishedText = await Bun.file(movedPaths[0]?.to ?? "").text(); expect(publishedText).toContain("linearId: update_123"); expect(publishedText).toContain('updatedAt: "2026-03-14T10:15:00.000Z"'); }); @@ -3789,11 +3830,11 @@ Still on track for dogfooding. profileId: "work", projectDir, projectId: "proj_123", - linear: { + linear: createProjectArtifactLinearClient({ createProjectUpdate: async () => { throw new Error("should not be called"); }, - }, + }), }, }); diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts index 98fab6922..bb141a6dc 100644 --- a/tests/runtime-cache.test.ts +++ b/tests/runtime-cache.test.ts @@ -177,6 +177,7 @@ test("getProjectsPayload keeps working when resolveProjectMeta fails for one pro branchRuntime: [], sessions: [], lifecycle: null, + ownership: null, kind: "registered", status: "unknown", }); @@ -211,6 +212,8 @@ test("getProjectsPayload keeps working when resolveProjectMeta fails for one pro }, sessions: { sessions: [] }, composeBuild: { services: [] }, + ownership: null, + configError: null, }; return meta; }, diff --git a/tests/tickets-git-channel.test.ts b/tests/tickets-git-channel.test.ts index 5a603b2f4..ab6fee9d9 100644 --- a/tests/tickets-git-channel.test.ts +++ b/tests/tickets-git-channel.test.ts @@ -208,6 +208,7 @@ test("mutation lock heartbeat prevents overlapping prepared mutations past stale branch: "hack/tickets", refMode: "hidden", remote: "", + forceBareClone: false, }, logger: { info: (_input: { message: string }) => {}, From a64d91f843724a52e4584e528ea48857d131eaef Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Mon, 23 Mar 2026 13:39:44 -0400 Subject: [PATCH 2/5] Add TypeScript path aliases --- packages/cli/index.ts | 2 +- packages/db/tests/smoke.test.ts | 4 ++-- packages/db/tsconfig.json | 5 +++++ services/auth-broker/tests/config.test.ts | 2 +- services/auth-broker/tsconfig.json | 5 +++++ src/control-plane/extensions/tickets/domain.ts | 3 +-- tests/config-paths.test.ts | 7 ++----- tsconfig.json | 11 +++++++++++ 8 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 94fb9b10b..28939b101 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -1,4 +1,4 @@ -import { runCli as runCliImpl } from "../../src/cli/run.ts"; +import { runCli as runCliImpl } from "@/cli/run.ts"; /** * Canonical CLI entrypoint for workspace package consumers. diff --git a/packages/db/tests/smoke.test.ts b/packages/db/tests/smoke.test.ts index 10b6aa4de..8798ccc33 100644 --- a/packages/db/tests/smoke.test.ts +++ b/packages/db/tests/smoke.test.ts @@ -1,13 +1,13 @@ import { expect, test } from "bun:test"; -import { createDbClient } from "../src/client.ts"; +import { createDbClient } from "@/client.ts"; import { githubConnections, linearAssigneeMappings, linearConnections, linearSyncSubscriptions, linearWebhookEvents, -} from "../src/schema/core.ts"; +} from "@/schema/core.ts"; test("db package exports createDbClient", () => { expect(typeof createDbClient).toBe("function"); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index 411a7d7bd..c2d54c7d6 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -4,6 +4,11 @@ "target": "ESNext", "module": "Preserve", "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@tests/*": ["tests/*"] + }, "allowImportingTsExtensions": true, "resolveJsonModule": true, "verbatimModuleSyntax": true, diff --git a/services/auth-broker/tests/config.test.ts b/services/auth-broker/tests/config.test.ts index d07c5cbc6..4d8155d69 100644 --- a/services/auth-broker/tests/config.test.ts +++ b/services/auth-broker/tests/config.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { resolveConfig } from "../src/config.ts"; +import { resolveConfig } from "@/config.ts"; type EnvMap = Record; diff --git a/services/auth-broker/tsconfig.json b/services/auth-broker/tsconfig.json index a884da3e0..4957c7c9f 100644 --- a/services/auth-broker/tsconfig.json +++ b/services/auth-broker/tsconfig.json @@ -5,6 +5,11 @@ "target": "ESNext", "module": "Preserve", "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@tests/*": ["tests/*"] + }, "allowImportingTsExtensions": true, "resolveJsonModule": true, "verbatimModuleSyntax": true, diff --git a/src/control-plane/extensions/tickets/domain.ts b/src/control-plane/extensions/tickets/domain.ts index 465d14a43..d0221b200 100644 --- a/src/control-plane/extensions/tickets/domain.ts +++ b/src/control-plane/extensions/tickets/domain.ts @@ -1,3 +1,4 @@ +import type { TicketDocument } from "./documents.ts"; import { buildLegacyDescriptionDocument, getActiveTicketDescription, @@ -124,8 +125,6 @@ export type TicketFieldVersion = { readonly value?: TicketMetadataValue; }; -export type { TicketDocument, TicketDocumentKind, TicketDocumentRole }; - export type TicketFieldState = { readonly field: string; readonly authority: TicketFieldAuthority; diff --git a/tests/config-paths.test.ts b/tests/config-paths.test.ts index 25a819cc2..d14a4c764 100644 --- a/tests/config-paths.test.ts +++ b/tests/config-paths.test.ts @@ -2,11 +2,8 @@ import { afterEach, expect, test } from "bun:test"; import { homedir } from "node:os"; import { resolve } from "node:path"; -import { - GLOBAL_CONFIG_FILENAME, - GLOBAL_HACK_DIR_NAME, -} from "../src/constants.ts"; -import { resolveGlobalConfigPath } from "../src/lib/config-paths.ts"; +import { resolveGlobalConfigPath } from "@lib/config-paths.ts"; +import { GLOBAL_CONFIG_FILENAME, GLOBAL_HACK_DIR_NAME } from "@/constants.ts"; const originalHome = process.env.HOME; const originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; diff --git a/tsconfig.json b/tsconfig.json index 468b706de..9c56d2f3b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,17 @@ // Bundler mode "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@commands/*": ["src/commands/*"], + "@control-plane/*": ["src/control-plane/*"], + "@daemon/*": ["src/daemon/*"], + "@lib/*": ["src/lib/*"], + "@scripts/*": ["scripts/*"], + "@tests/*": ["tests/*"], + "@ui/*": ["src/ui/*"] + }, "allowImportingTsExtensions": true, "resolveJsonModule": true, "verbatimModuleSyntax": true, From 3259730282ad16a55aad4e77b444c5ae604c14be Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Mon, 23 Mar 2026 13:50:09 -0400 Subject: [PATCH 3/5] Fix Linear project conflict fallback --- src/control-plane/extensions/linear/commands.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index c99a0f99e..02e64eb27 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -4245,6 +4245,10 @@ function detectAuthoritativeFieldConflicts(input: { const conflicts: RecordedSyncConflict[] = []; const authorityLabel = input.authority === "review_required" ? "review-required" : input.authority; + const localLinearRemote = findTicketRemoteLink({ + ticket: input.ticket, + provider: "linear", + }); const localTitle = input.ticket.title.trim(); const remoteTitle = input.issue.title.trim(); if (localTitle !== remoteTitle) { @@ -4284,8 +4288,8 @@ function detectAuthoritativeFieldConflicts(input: { } const localProject = normalizeProjectValue({ - projectId: input.ticket.projectId, - projectName: input.ticket.projectName, + projectId: input.ticket.projectId ?? localLinearRemote?.projectId, + projectName: input.ticket.projectName ?? localLinearRemote?.projectName, }); const remoteProject = normalizeProjectValue({ projectId: input.issue.projectId, From b914068eb438add5b1c1a9d545d5938aefc142b9 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Mon, 23 Mar 2026 14:01:57 -0400 Subject: [PATCH 4/5] Paginate Linear project lookup --- src/control-plane/extensions/linear/client.ts | 125 +++++++++++++----- .../extensions/linear/commands.ts | 63 ++++++--- tests/linear-client.test.ts | 6 +- tests/linear-commands.test.ts | 102 +++++++++++--- 4 files changed, 221 insertions(+), 75 deletions(-) diff --git a/src/control-plane/extensions/linear/client.ts b/src/control-plane/extensions/linear/client.ts index 15c68d46b..a435809b4 100644 --- a/src/control-plane/extensions/linear/client.ts +++ b/src/control-plane/extensions/linear/client.ts @@ -133,6 +133,12 @@ type LinearProjectIssuePage = { readonly endCursor?: string; }; +type LinearProjectsPage = { + readonly projects: readonly LinearProject[]; + readonly hasNextPage: boolean; + readonly endCursor?: string; +}; + export type LinearClient = { readonly getViewer: () => Promise< LinearRequestResult<{ @@ -145,6 +151,10 @@ export type LinearClient = { readonly listProjects: (input?: { readonly first?: number; }) => Promise>; + readonly listProjectsPage: (input?: { + readonly first?: number; + readonly after?: string; + }) => Promise>; readonly getProject: (input: { readonly projectId: string; }) => Promise>; @@ -306,6 +316,70 @@ export function createLinearClient(input: { }; }; + async function listProjectsPage( + input: { readonly first?: number; readonly after?: string } = {} + ): Promise> { + const first = normalizePositiveInt({ + value: input.first, + fallback: DEFAULT_PAGE_SIZE, + }); + const after = input.after?.trim(); + const result = await request<{ + readonly projects?: unknown; + }>({ + query: [ + "query LinearProjects($first: Int!, $after: String) {", + " projects(first: $first, after: $after) {", + " pageInfo {", + " hasNextPage", + " endCursor", + " }", + " nodes {", + " id", + " name", + " teams {", + " nodes {", + " id", + " key", + " name", + " }", + " }", + " }", + " }", + "}", + ].join("\n"), + variables: { + first, + ...(after ? { after } : {}), + }, + }); + if (!result.ok) { + return result; + } + const projects = parseProjectsPage(result.data.projects); + if (!projects) { + return { + ok: false, + status: 500, + error: "Linear projects payload missing pagination metadata.", + }; + } + return { ok: true, data: projects }; + } + + async function listProjects( + input: { readonly first?: number } = {} + ): Promise> { + const page = await listProjectsPage(input); + if (!page.ok) { + return page; + } + return { + ok: true, + data: page.data.projects, + }; + } + return { getViewer: async () => { const result = await request<{ @@ -339,39 +413,8 @@ export function createLinearClient(input: { }; }, - listProjects: async (input = {}) => { - const first = normalizePositiveInt({ - value: input.first, - fallback: DEFAULT_PAGE_SIZE, - }); - const result = await request<{ - readonly projects?: unknown; - }>({ - query: [ - "query LinearProjects($first: Int!) {", - " projects(first: $first) {", - " nodes {", - " id", - " name", - " teams {", - " nodes {", - " id", - " key", - " name", - " }", - " }", - " }", - " }", - "}", - ].join("\n"), - variables: { first }, - }); - if (!result.ok) { - return result; - } - const projects = parseProjectsConnection(result.data.projects); - return { ok: true, data: projects }; - }, + listProjects, + listProjectsPage, getProject: async ({ projectId }) => { const id = projectId.trim(); @@ -1451,11 +1494,15 @@ function parseViewer(value: unknown): { }; } -function parseProjectsConnection(value: unknown): LinearProject[] { +function parseProjectsPage(value: unknown): LinearProjectsPage | null { if (!isRecord(value)) { - return []; + return null; } const nodes = Array.isArray(value.nodes) ? value.nodes : []; + const pageInfo = isRecord(value.pageInfo) ? value.pageInfo : null; + if (!(pageInfo && typeof pageInfo.hasNextPage === "boolean")) { + return null; + } const out: LinearProject[] = []; for (const node of nodes) { const parsed = parseProject(node); @@ -1464,7 +1511,13 @@ function parseProjectsConnection(value: unknown): LinearProject[] { } out.push(parsed); } - return out; + return { + projects: out, + hasNextPage: pageInfo.hasNextPage, + ...(typeof pageInfo.endCursor === "string" + ? { endCursor: pageInfo.endCursor } + : {}), + }; } function parseUser(value: unknown): LinearUser | null { diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index 02e64eb27..d044ba47a 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -2594,6 +2594,7 @@ type LinearSyncClient = Pick< type ProjectArtifactLinearClient = Pick< ReturnType, + | "listProjectsPage" | "listProjects" | "createProjectDocument" | "createProjectMilestone" @@ -4140,13 +4141,13 @@ function removeLinearAssigneeMapping(input: { return { removed, mappings: next }; } -type RecordedSyncConflict = { +interface RecordedSyncConflict { readonly field: string; readonly authority: "hack" | "linear" | "review_required"; readonly summary: string; readonly localValue?: TicketMetadataValue; readonly remoteValue?: TicketMetadataValue; -}; +} type SyncTicketFromLinearSuccess = { readonly ok: true; @@ -7391,7 +7392,7 @@ async function resolveProjectArtifactTarget(input: { readonly projectId?: string; readonly projectName?: string; readonly teamId?: string; - readonly linear: Pick; + readonly linear: Pick; }): Promise< | { readonly ok: true; @@ -7440,26 +7441,44 @@ async function resolveProjectArtifactTarget(input: { }; } - const projects = await input.linear.listProjects({ - first: DEFAULT_PROJECT_SYNC_LIMIT, - }); - if (!projects.ok) { - return { - ok: false, - error: projects.error, - }; - } + const matches: Array<{ + readonly id: string; + readonly name: string; + readonly teamId: string; + }> = []; + let after: string | undefined; + let hasNextPage = true; + while (hasNextPage) { + const page = await input.linear.listProjectsPage({ + first: DEFAULT_PROJECT_SYNC_LIMIT, + ...(after ? { after } : {}), + }); + if (!page.ok) { + return { + ok: false, + error: page.error, + }; + } - const matches = projects.data.filter((project) => - projectArtifactTargetMatchesSelector({ - target: { - projectId: project.id, - projectName: project.name, - teamId: project.teamId, - }, - selector, - }) - ); + matches.push( + ...page.data.projects.filter((project) => + projectArtifactTargetMatchesSelector({ + target: { + projectId: project.id, + projectName: project.name, + teamId: project.teamId, + }, + selector, + }) + ) + ); + + hasNextPage = page.data.hasNextPage; + after = page.data.endCursor; + if (matches.length > 1) { + break; + } + } if (matches.length === 0) { return { ok: false, diff --git a/tests/linear-client.test.ts b/tests/linear-client.test.ts index 1a4aa9ffa..892d3786e 100644 --- a/tests/linear-client.test.ts +++ b/tests/linear-client.test.ts @@ -137,6 +137,10 @@ test("listProjects queries the top-level projects connection", async () => { JSON.stringify({ data: { projects: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, nodes: [ { id: "project_123", @@ -169,7 +173,7 @@ test("listProjects queries the top-level projects connection", async () => { expect( String((requestBody as { readonly query?: unknown } | null)?.query ?? "") - ).toContain("projects(first: $first)"); + ).toContain("projects(first: $first, after: $after)"); expect( String((requestBody as { readonly query?: unknown } | null)?.query ?? "") ).not.toContain("viewer {"); diff --git a/tests/linear-commands.test.ts b/tests/linear-commands.test.ts index 37477a89a..bd11f7044 100644 --- a/tests/linear-commands.test.ts +++ b/tests/linear-commands.test.ts @@ -54,6 +54,13 @@ function createProjectArtifactLinearClient( ): ProjectArtifactLinearClient { return { listProjects: async () => ({ ok: true as const, data: [] }), + listProjectsPage: async () => ({ + ok: true as const, + data: { + projects: [], + hasNextPage: false, + }, + }), listProjectDocuments: async () => ({ ok: true as const, data: [] }), createProjectDocument: async () => { throw new Error("createProjectDocument should not be called"); @@ -788,7 +795,7 @@ test("resolveProjectArtifactTarget matches a bound project by explicit name and projectName: "Operations", teamId: "team_ops", linear: { - listProjects: async () => { + listProjectsPage: async () => { throw new Error("should not look up remote projects"); }, }, @@ -815,22 +822,25 @@ test("resolveProjectArtifactTarget falls back to remote lookup for explicit proj projectName: "Roadmap", teamId: "team_strategy", linear: { - listProjects: async () => ({ + listProjectsPage: async () => ({ ok: true as const, - data: [ - { - id: "proj_strategy", - name: "Roadmap", - teamId: "team_strategy", - teamKey: "STRAT", - }, - { - id: "proj_other", - name: "Roadmap", - teamId: "team_other", - teamKey: "OPS", - }, - ], + data: { + projects: [ + { + id: "proj_strategy", + name: "Roadmap", + teamId: "team_strategy", + teamKey: "STRAT", + }, + { + id: "proj_other", + name: "Roadmap", + teamId: "team_other", + teamKey: "OPS", + }, + ], + hasNextPage: false, + }, }), }, }); @@ -846,6 +856,66 @@ test("resolveProjectArtifactTarget falls back to remote lookup for explicit proj }); }); +test("resolveProjectArtifactTarget keeps paging remote projects until a later match is found", async () => { + const afterCalls: string[] = []; + const resolved = await __testOnly.resolveProjectArtifactTarget({ + binding: { + profileId: "work", + additionalProjects: [], + }, + profileId: "work", + projectName: "Roadmap", + teamId: "team_strategy", + linear: { + listProjectsPage: async (input) => { + afterCalls.push(input?.after ?? ""); + if (!input?.after) { + return { + ok: true as const, + data: { + projects: [ + { + id: "proj_other", + name: "Roadmap", + teamId: "team_other", + teamKey: "OPS", + }, + ], + hasNextPage: true, + endCursor: "cursor-1", + }, + }; + } + return { + ok: true as const, + data: { + projects: [ + { + id: "proj_strategy", + name: "Roadmap", + teamId: "team_strategy", + teamKey: "STRAT", + }, + ], + hasNextPage: false, + }, + }; + }, + }, + }); + + expect(afterCalls).toEqual(["", "cursor-1"]); + expect(resolved).toEqual({ + ok: true, + target: { + profileId: "work", + projectId: "proj_strategy", + projectName: "Roadmap", + teamId: "team_strategy", + }, + }); +}); + test("connect falls back to oauth when no token input exists", () => { const envKey = "HACK_LINEAR_TEST_TOKEN"; const previous = process.env[envKey]; From 3009855382ac3d7be3b2b417fbdb0eb8b874abb4 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Mon, 23 Mar 2026 14:05:12 -0400 Subject: [PATCH 5/5] Fix Linear sync client project paging type --- src/control-plane/extensions/linear/commands.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index d044ba47a..e8066ef67 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -2580,6 +2580,7 @@ type LinearSyncClient = Pick< | "getProject" | "listIssueComments" | "listProjects" + | "listProjectsPage" | "listProjectDocuments" | "listProjectIssuesPage" | "listProjectMilestones"