From 67cd65272934db056ed279e3c60a1d3ee0dd3c26 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 30 Aug 2026 18:24:22 -0500 Subject: [PATCH] feat(linear): add issue priority label Co-authored-by: ikatkov <1211343+ikatkov@users.noreply.github.com> --- README.md | 2 + apps/web/app/docs/linear/page.mdx | 2 + packages/@emulators/linear/README.md | 1 + .../linear/src/__tests__/linear.test.ts | 68 ++++++++++++++++++- .../@emulators/linear/src/routes/graphql.ts | 8 +++ packages/emulate/src/index.ts | 3 + skills/linear/SKILL.md | 2 + 7 files changed, 85 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 49e971116..03ccccc23 100644 --- a/README.md +++ b/README.md @@ -899,6 +899,8 @@ Stateful Linear GraphQL API emulation with seeded organizations, users, teams, w - Queries: `viewer`, `organization`, `users`, `user`, `teams`, `team`, `workflowStates`, `workflowState`, `issues`, `issue`, `comments`, `comment`, `issueLabels`, `issueLabel`, `projects`, `project`, `cycles`, `cycle`, `webhooks`, `webhook`, `agentSessions`, `agentSession` - Mutations: `issueCreate`, `issueUpdate`, `issueDelete`, `issueArchive`, `issueUnarchive`, `commentCreate`, `commentUpdate`, `commentDelete`, `issueLabelCreate`, `issueLabelUpdate`, `issueLabelDelete`, `issueAddLabel`, `issueRemoveLabel`, `webhookCreate`, `webhookDelete`, `agentSessionCreateOnIssue`, `agentSessionCreateOnComment`, `agentSessionUpdate`, `agentActivityCreate` +Issue selections expose both numeric `priority` and Linear's derived `priorityLabel` values: `No priority`, `Urgent`, `High`, `Medium`, and `Low`. + ### OAuth - `GET /oauth/authorize` - authorization endpoint with local user picker diff --git a/apps/web/app/docs/linear/page.mdx b/apps/web/app/docs/linear/page.mdx index b05e3246a..8dd1511d1 100644 --- a/apps/web/app/docs/linear/page.mdx +++ b/apps/web/app/docs/linear/page.mdx @@ -51,6 +51,8 @@ Supported mutations: Connections use Relay-style cursors with `nodes`, `edges`, and `pageInfo`. +Issue selections expose both numeric `priority` and Linear's derived `priorityLabel` values: `No priority`, `Urgent`, `High`, `Medium`, and `Low`. + ## Auth GraphQL accepts `Authorization: Bearer ` or a bare personal API key value. The default seeded token is `lin_test_admin`. diff --git a/packages/@emulators/linear/README.md b/packages/@emulators/linear/README.md index 7cd1e372d..cabae1706 100644 --- a/packages/@emulators/linear/README.md +++ b/packages/@emulators/linear/README.md @@ -20,6 +20,7 @@ npx emulate --service linear - `POST /graphql` for a focused Linear GraphQL subset. - Queries for viewer, organization, users, teams, workflow states, issues, comments, labels, projects, cycles, webhooks, and agent sessions. +- Issue selections include numeric `priority` and the derived `priorityLabel`. - Mutations for issues, comments, labels, webhooks, and basic agent sessions and activities. - OAuth authorize, token, refresh, revoke, PKCE, client credentials, and app actor tokens. - Personal API key and OAuth bearer token auth. diff --git a/packages/@emulators/linear/src/__tests__/linear.test.ts b/packages/@emulators/linear/src/__tests__/linear.test.ts index a8a87e0de..1bbe466c9 100644 --- a/packages/@emulators/linear/src/__tests__/linear.test.ts +++ b/packages/@emulators/linear/src/__tests__/linear.test.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { LinearClient } from "@linear/sdk"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Hono, Store, WebhookDispatcher, authMiddleware, type TokenMap } from "@emulators/core"; -import { getLinearStore, linearPlugin, seedFromConfig } from "../index.js"; +import { getLinearStore, linearPlugin, seedFromConfig, type LinearIssue } from "../index.js"; const base = "http://localhost:4300"; @@ -62,6 +62,72 @@ describe("Linear emulator", () => { expect(body.data.issues.nodes[0].comments.nodes[0].body).toContain("seeded"); }); + it("returns priority labels for issue queries and mutations", async () => { + seedFromConfig(store, base, { + issues: [ + { team: "ENG", title: "No priority issue", priority: 0 }, + { team: "ENG", title: "Urgent issue", priority: 1 }, + { team: "ENG", title: "High priority issue", priority: 2 }, + { team: "ENG", title: "Medium priority issue", priority: 3 }, + { team: "ENG", title: "Low priority issue", priority: 4 }, + ], + }); + + const queryRes = await gql(app, `query { issues { nodes { title priority priorityLabel } } }`); + expect(queryRes.status).toBe(200); + const queryBody = (await queryRes.json()) as any; + expect(queryBody.errors).toBeUndefined(); + expect( + Object.fromEntries( + queryBody.data.issues.nodes + .filter((issue: { title: string }) => issue.title.endsWith("issue")) + .map((issue: { title: string; priorityLabel: string }) => [issue.title, issue.priorityLabel]), + ), + ).toEqual({ + "No priority issue": "No priority", + "Urgent issue": "Urgent", + "High priority issue": "High", + "Medium priority issue": "Medium", + "Low priority issue": "Low", + }); + + const team = getLinearStore(store).teams.findOneBy("key", "ENG")!; + const mutationRes = await gql( + app, + `mutation CreateIssue($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { priority priorityLabel } + } + }`, + { input: { teamId: team.linear_id, title: "Created without priority" } }, + ); + expect(mutationRes.status).toBe(200); + const mutationBody = (await mutationRes.json()) as any; + expect(mutationBody.errors).toBeUndefined(); + expect(mutationBody.data.issueCreate.issue).toEqual({ priority: 0, priorityLabel: "No priority" }); + + const linearStore = getLinearStore(store); + const fractionalIssue = linearStore.issues.findOneBy("title", "High priority issue")!; + const outOfRangeIssue = linearStore.issues.findOneBy("title", "Low priority issue")!; + linearStore.issues.update(fractionalIssue.id, { priority: 2.5 as LinearIssue["priority"] }); + linearStore.issues.update(outOfRangeIssue.id, { priority: 10 as LinearIssue["priority"] }); + + const irregularRes = await gql( + app, + `query { + fractional: issue(id: "${fractionalIssue.linear_id}") { priorityLabel } + outOfRange: issue(id: "${outOfRangeIssue.linear_id}") { priorityLabel } + }`, + ); + const irregularBody = (await irregularRes.json()) as any; + expect(irregularBody.errors).toBeUndefined(); + expect(irregularBody.data).toEqual({ + fractional: { priorityLabel: "Medium" }, + outOfRange: { priorityLabel: "No priority" }, + }); + }); + it("creates issues and comments that can be read back", async () => { const team = getLinearStore(store).teams.findOneBy("key", "ENG")!; const state = getLinearStore(store).workflowStates.findOneBy("name", "Todo")!; diff --git a/packages/@emulators/linear/src/routes/graphql.ts b/packages/@emulators/linear/src/routes/graphql.ts index 3fa690578..ddeefa45b 100644 --- a/packages/@emulators/linear/src/routes/graphql.ts +++ b/packages/@emulators/linear/src/routes/graphql.ts @@ -31,6 +31,8 @@ import type { } from "../entities.js"; import { dispatchLinearWebhook } from "../webhooks.js"; +const PRIORITY_LABELS = ["No priority", "Urgent", "High", "Medium", "Low"] as const; + const schema = buildSchema(` scalar TeamFilter scalar PaginationOrderBy @@ -243,6 +245,7 @@ const schema = buildSchema(` title: String! description: String priority: Int! + priorityLabel: String! url: String! createdAt: String! updatedAt: String! @@ -1354,6 +1357,7 @@ function formatIssue(context: LinearGraphQLContext, issue: LinearIssue) { title: issue.title, description: issue.description, priority: issue.priority, + priorityLabel: priorityLabelFor(issue.priority), url: issue.url, createdAt: issue.created_at, updatedAt: issue.updated_at, @@ -1995,6 +1999,10 @@ function normalizePriority(value: unknown): LinearIssuePriority { return value as LinearIssuePriority; } +function priorityLabelFor(priority: number): string { + return PRIORITY_LABELS[Math.round(priority)] ?? PRIORITY_LABELS[0]; +} + function normalizeSessionState(value: string | undefined | null): LinearAgentSession["state"] | undefined { if ( value === "pending" || diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index 2b67c3a3d..162296440 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -24,6 +24,9 @@ Framework adapters: GitHub API coverage: Includes repository contents, raw downloads, commit history, commit details, and ref comparisons. +Linear API coverage: + Issue queries and mutations include numeric priority and derived priorityLabel fields. + Webhook signatures: Stripe webhook secrets produce a Stripe-Signature header for raw-body verification. `, diff --git a/skills/linear/SKILL.md b/skills/linear/SKILL.md index 556603878..2511a752f 100644 --- a/skills/linear/SKILL.md +++ b/skills/linear/SKILL.md @@ -103,6 +103,8 @@ Supported mutations: Connections use Relay-style cursors with `nodes`, `edges`, and `pageInfo`. +Issue selections expose both numeric `priority` and Linear's derived `priorityLabel` values: `No priority`, `Urgent`, `High`, `Medium`, and `Low`. + ## OAuth - `GET /oauth/authorize` - authorization endpoint with local user picker