Skip to content

perf: use batch resolve queries in issues create and update#93

Open
qorexdev wants to merge 2 commits intolinearis-oss:mainfrom
qorexdev:perf/batch-resolve-issue-commands
Open

perf: use batch resolve queries in issues create and update#93
qorexdev wants to merge 2 commits intolinearis-oss:mainfrom
qorexdev:perf/batch-resolve-issue-commands

Conversation

@qorexdev
Copy link
Copy Markdown
Contributor

@qorexdev qorexdev commented Apr 8, 2026

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.

  • uuid inputs short-circuit without any api calls
  • milestone can be extracted from the project data returned by the batch (no separate lookup needed)
  • update also uses the batch for issue context, replacing the getIssue call when the issue is given as ABC-123

@qorexdev qorexdev requested a review from iamfj as a code owner April 8, 2026 04:00
Copilot AI review requested due to automatic review settings April 8, 2026 04:00
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-resolver module to resolve team/project/labels/parent/milestone/issue-context via single batch GraphQL requests.
  • Update issues create and issues update commands 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.

Comment on lines +357 to +359
input.parentId = isUuid(options.parentTicket)
? options.parentTicket
: batch.parentId!;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Suggested change
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));
}

Copilot uses AI. Check for mistakes.
Comment on lines +550 to +555
input.projectMilestoneId = await resolveMilestoneId(
ctx.gql,
ctx.sdk,
options.projectMilestone,
projectName,
);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.`,
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +174 to +177
const fromProject = projectMilestones.find(
(m) => m.name.toLowerCase() === milestoneName.toLowerCase(),
);
const milestone = fromProject ?? data.milestones?.nodes[0];
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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];

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +24
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);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +50
export async function batchResolveForCreate(
gql: GraphQLClient,
opts: {
team?: string;
project?: string;
labelNames?: string[];
parentTicket?: string;
},
): Promise<CreateBatchResult> {
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@iamfj iamfj left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

  • extractLabelIds expands 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 else fallback in update's milestone handling (around line 546) looks unreachable — the batch resolver throws notFoundError when a non-UUID milestone isn't found, so batch.milestoneId is always set if we get that far. Consider removing the dead branch.
  • The batch resolver uses GraphQLClient in 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";
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things to check here:

  1. Case sensitivity: The GraphQL queries filter labels with {name: {in: $labelNames}}, but the old resolveLabelId used {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. If in is 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.

  2. 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.

@qorexdev
Copy link
Copy Markdown
Contributor Author

qorexdev commented Apr 8, 2026

Fixed all blocking items:

Label case sensitivity — confirmed in is case-sensitive. Changed the resolver to use a separate or: [{ name: { eqIgnoreCase: X } }] query per label name (built dynamically with graphql.parse, since the static in-based variable can't do case-insensitive matching). This runs in parallel with the other batch query via Promise.all, so there's no regression in the common case where you have both label names and team/project names.

Unit tests — added tests/unit/resolvers/batch-resolver.test.ts with 17 tests covering:

  • extractLabelIds: regular labels, group label expansion, not-found
  • batchResolveForCreate: UUID short-circuit, hasWork=false, team/project/parent resolution + not-found paths
  • batchResolveForUpdate: labels, project, milestone (project-priority vs global fallback), milestone not-found, issueContext

Group label expansion — added a comment in extractLabelIds explaining it's intentional: applying a group label applies all its children.

Dead else branch — removed the unreachable else path in update's milestone handling.

GraphQLClient in resolver layer — added a comment noting it's intentional (fetching labels requires the full or+eqIgnoreCase filter which isn't possible via LinearSdkClient).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: use BatchResolve queries to eliminate N+1 API calls in issue create/update

3 participants