perf: use batch resolve queries in issues create and update#93
perf: use batch resolve queries in issues create and update#93qorexdev wants to merge 2 commits intolinearis-oss:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR reduces the number of sequential API calls made by issues create and issues update by switching to the existing GraphQL batch resolution queries (BatchResolveForCreate / BatchResolveForUpdate) and using their results to populate mutation inputs.
Changes:
- Add a new
batch-resolvermodule to resolve team/project/labels/parent/milestone/issue-context via single batch GraphQL requests. - Update
issues createandissues updatecommands to use batch resolution where possible, while keeping individual resolvers for non-batched entities (assignee/cycle/status). - Update unit command tests to mock the new batch resolver instead of individual resolvers.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/resolvers/batch-resolver.ts |
Introduces batch resolution functions for create/update flows using existing GraphQL batch queries. |
src/commands/issues.ts |
Rewires issues create/update to use batch results, including UUID short-circuiting and milestone optimizations. |
tests/unit/commands/issues.test.ts |
Updates mocks to reflect the new batch resolver usage in command tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| input.parentId = isUuid(options.parentTicket) | ||
| ? options.parentTicket | ||
| : batch.parentId!; |
There was a problem hiding this comment.
--parent-ticket values that are neither UUIDs nor valid TEAM-123 identifiers can be silently ignored here: for non-UUID inputs you use batch.parentId!, but batchResolveForCreate() only sets parentId when tryParseIssueIdentifier() succeeds. This regresses the old resolveIssueId() behavior (which throws on invalid formats). Validate/parse options.parentTicket up-front (throw if invalid) or fall back to resolveIssueId() when parsing fails.
| input.parentId = isUuid(options.parentTicket) | |
| ? options.parentTicket | |
| : batch.parentId!; | |
| if (isUuid(options.parentTicket)) { | |
| input.parentId = options.parentTicket; | |
| } else { | |
| parseIssueIdentifier(options.parentTicket); | |
| input.parentId = | |
| batch.parentId ?? | |
| (await resolveIssueId(ctx.gql, options.parentTicket)); | |
| } |
| input.projectMilestoneId = await resolveMilestoneId( | ||
| ctx.gql, | ||
| ctx.sdk, | ||
| options.projectMilestone, | ||
| projectName, | ||
| ); |
There was a problem hiding this comment.
When issueContext comes from batchResolveForUpdate(), issueContext.project (from the BatchResolveForUpdate query) does not include name. In that case this code ends up passing projectName = undefined to resolveMilestoneId(), which can trigger ambiguous global milestone resolution (or pick the wrong milestone) even though the issue’s current project/milestones are already known in the batch result. Consider extending the batch query to include project { name } and/or resolving the milestone from issueContext.project.projectMilestones.nodes instead of falling back to an unscoped lookup.
| input.projectMilestoneId = await resolveMilestoneId( | |
| ctx.gql, | |
| ctx.sdk, | |
| options.projectMilestone, | |
| projectName, | |
| ); | |
| const projectMilestones = | |
| p && "projectMilestones" in p | |
| ? ( | |
| p as { | |
| projectMilestones?: { | |
| nodes?: Array<{ id: string; name?: string | null }>; | |
| }; | |
| } | |
| ).projectMilestones?.nodes ?? [] | |
| : []; | |
| const matchedProjectMilestone = projectMilestones.find( | |
| (milestone) => milestone.name === options.projectMilestone, | |
| ); | |
| if (matchedProjectMilestone) { | |
| input.projectMilestoneId = matchedProjectMilestone.id; | |
| } else if (projectName) { | |
| input.projectMilestoneId = await resolveMilestoneId( | |
| ctx.gql, | |
| ctx.sdk, | |
| options.projectMilestone, | |
| projectName, | |
| ); | |
| } else { | |
| throw new Error( | |
| `Unable to resolve project milestone "${options.projectMilestone}" without project context.`, | |
| ); | |
| } |
src/resolvers/batch-resolver.ts
Outdated
| const fromProject = projectMilestones.find( | ||
| (m) => m.name.toLowerCase() === milestoneName.toLowerCase(), | ||
| ); | ||
| const milestone = fromProject ?? data.milestones?.nodes[0]; |
There was a problem hiding this comment.
Milestone resolution here only checks milestones under data.projects?.nodes[0] (i.e., when projectName was provided) and otherwise falls back to the global data.milestones lookup. When updating by identifier (ABC-123) without --project, the batch query already returns the issue’s current project.projectMilestones, but this code doesn’t use that context—so a milestone name can resolve ambiguously/globally instead of within the issue’s project. Consider also searching data.issues.nodes[0].project?.projectMilestones.nodes (when present) before falling back to the global milestone query.
| const fromProject = projectMilestones.find( | |
| (m) => m.name.toLowerCase() === milestoneName.toLowerCase(), | |
| ); | |
| const milestone = fromProject ?? data.milestones?.nodes[0]; | |
| const issueProjectMilestones = | |
| data.issues?.nodes[0]?.project?.projectMilestones?.nodes ?? []; | |
| const fromProject = projectMilestones.find( | |
| (m) => m.name.toLowerCase() === milestoneName.toLowerCase(), | |
| ); | |
| const fromIssueProject = issueProjectMilestones.find( | |
| (m) => m.name.toLowerCase() === milestoneName.toLowerCase(), | |
| ); | |
| const milestone = | |
| fromProject ?? fromIssueProject ?? data.milestones?.nodes[0]; |
| function extractLabelIds(nodes: LabelNode[], names: string[]): string[] { | ||
| const ids: string[] = []; | ||
| for (const name of names) { | ||
| const label = nodes.find( | ||
| (n) => n.name.toLowerCase() === name.toLowerCase(), | ||
| ); | ||
| if (!label) throw notFoundError("Label", name); |
There was a problem hiding this comment.
extractLabelIds() matches names case-insensitively, but the underlying BatchResolveForCreate/Update GraphQL queries filter labels via issueLabels(filter: { name: { in: $labelNames } }) (see graphql/queries/issues.graphql), which is case-sensitive and can omit differently-cased labels. This is a behavior change vs resolveLabelId() (uses eqIgnoreCase, src/resolvers/label-resolver.ts:12) and can cause false "Label not found" errors. Consider normalizing label matching by either changing the batch query to a case-insensitive strategy (if supported) or adding a fallback path for any unresolved names.
| export async function batchResolveForCreate( | ||
| gql: GraphQLClient, | ||
| opts: { | ||
| team?: string; | ||
| project?: string; | ||
| labelNames?: string[]; | ||
| parentTicket?: string; | ||
| }, | ||
| ): Promise<CreateBatchResult> { |
There was a problem hiding this comment.
New resolver logic in batchResolveForCreate() / batchResolveForUpdate() is not covered by unit tests. The repo already has unit tests for other resolvers (e.g., tests/unit/resolvers/label-resolver.test.ts, milestone-resolver.test.ts), so adding similar tests for batch resolution (UUID short-circuiting, not-found errors, label group expansion, milestone resolution paths, issueContext population) would help prevent regressions.
iamfj
left a comment
There was a problem hiding this comment.
Nice work getting the batch queries wired up — reducing 4-6 sequential API calls to 1 is a meaningful improvement. The approach is clean: batch when names need resolution, short-circuit for UUIDs, fall back to individual resolvers for what the batch doesn't cover.
Two things I'd like addressed before merging:
Missing unit tests for the batch resolver. Every other resolver has a dedicated test file, and the acceptance criteria on #63 calls for them. The command tests mock the batch resolver (which validates wiring), but extractLabelIds, batchResolveForCreate, and batchResolveForUpdate have non-trivial logic that isn't covered: label group expansion, not-found error paths, the hasWork short-circuit, and the projectMilestones extraction. A tests/unit/resolvers/batch-resolver.test.ts matching the existing resolver test patterns would close this gap.
Label filter case sensitivity. The old resolveLabelId used eqIgnoreCase, but the batch queries use issueLabels(filter: {name: {in: $labelNames}}). If in is case-sensitive in Linear's API (which I'd expect — the old resolver wouldn't use eqIgnoreCase otherwise), then --labels "bug" would break for a label stored as "Bug." Could you verify this against the API? If it's case-sensitive, the .graphql query would need a different filter approach.
Smaller things (non-blocking):
extractLabelIdsexpands group labels to their children, which the old resolver didn't do. If intentional, a short comment explaining why would help future readers — it's a behavior change.- The
elsefallback in update's milestone handling (around line 546) looks unreachable — the batch resolver throwsnotFoundErrorwhen a non-UUID milestone isn't found, sobatch.milestoneIdis always set if we get that far. Consider removing the dead branch. - The batch resolver uses
GraphQLClientin the resolver layer, which is an intentional deviation from the "resolvers → LinearSdkClient only" invariant. Worth a brief comment in the file noting why.
| @@ -0,0 +1,193 @@ | |||
| import type { GraphQLClient } from "../client/graphql-client.js"; | |||
There was a problem hiding this comment.
Architecture note: this resolver takes GraphQLClient instead of the project's standard LinearSdkClient for resolvers (see AGENTS.md invariant 3). That's a reasonable exception — batch queries combine multiple resolutions into a single GraphQL call, which isn't possible via the SDK. Consider adding a brief comment here documenting this as an intentional deviation, so future contributors don't "fix" it back to LinearSdkClient.
| const ids: string[] = []; | ||
| for (const name of names) { | ||
| const label = nodes.find( | ||
| (n) => n.name.toLowerCase() === name.toLowerCase(), |
There was a problem hiding this comment.
Two things to check here:
-
Case sensitivity: The GraphQL queries filter labels with
{name: {in: $labelNames}}, but the oldresolveLabelIdused{name: {eqIgnoreCase: ...}}. The.toLowerCase()comparison here handles mismatched case between the API response and the user input, but only if the API actually returned the label. Ifinis case-sensitive and the user passes"bug"for a label stored as"Bug", the API returns nothing, this loop has nothing to match, and the user gets a confusing "not found" error. -
Group label expansion: Expanding group labels to their children (lines 25-28) is new behavior — the old resolver returned the group ID directly. If this is an intentional improvement, a one-liner comment explaining why would help. If not, it's a behavioral change that could surprise users who were referencing group labels.
|
Fixed all blocking items: Label case sensitivity — confirmed Unit tests — added
Group label expansion — added a comment in Dead GraphQLClient in resolver layer — added a comment noting it's intentional (fetching labels requires the full |
closes #63
issues create was making 4-6 sequential API calls to resolve team, project, labels, and parent issue before the actual mutation. these batch queries already existed in the codebase but were unused.
the new batch-resolver.ts calls BatchResolveForCreate or BatchResolveForUpdate once per command instead of N times. resolvers that aren't in the batch queries (assignee, cycle, status) still use their individual resolvers.