From 83b8a0d593bb9c2cb8bdb8395b6f09b3f01aab9f Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 8 Jun 2026 19:04:21 +0900 Subject: [PATCH 1/2] feat: add Linear GraphQL API emulator Port vercel-labs/emulate#91 (Phase 1 Linear emulator) to the @pleaseai fork conventions: - New @pleaseai/emulate-linear package under packages/linear/ (bun export condition, @emulators/core ^0.6.0 + graphql dependency, tsgo type-check) - Register linear in the CLI service registry (packages/emulate) - bun:test instead of vitest; explicit 405 handlers for non-POST /graphql since core's router has no `all`; Hono type sourced from @emulators/core - skills/linear/SKILL.md and README entries adapted to fork CLI usage Read-only GraphQL: issues, projects, teams, users, organizations, labels, and workflow states with Relay-style pagination. 26 tests pass. Original PR: vercel-labs/emulate#91 by @mvanhorn --- README.md | 2 + bun.lock | 17 + packages/emulate/package.json | 2 + packages/emulate/src/registry.ts | 28 +- packages/linear/README.md | 69 +++ packages/linear/package.json | 41 ++ packages/linear/src/__tests__/linear.test.ts | 324 ++++++++++++++ packages/linear/src/entities.ts | 76 ++++ packages/linear/src/helpers.ts | 111 +++++ packages/linear/src/index.ts | 418 +++++++++++++++++++ packages/linear/src/resolvers.ts | 309 ++++++++++++++ packages/linear/src/routes/graphql.ts | 88 ++++ packages/linear/src/schema.ts | 146 +++++++ packages/linear/src/store.ts | 35 ++ packages/linear/tsconfig.json | 4 + packages/linear/tsup.config.ts | 8 + skills/linear/SKILL.md | 92 ++++ 17 files changed, 1769 insertions(+), 1 deletion(-) create mode 100644 packages/linear/README.md create mode 100644 packages/linear/package.json create mode 100644 packages/linear/src/__tests__/linear.test.ts create mode 100644 packages/linear/src/entities.ts create mode 100644 packages/linear/src/helpers.ts create mode 100644 packages/linear/src/index.ts create mode 100644 packages/linear/src/resolvers.ts create mode 100644 packages/linear/src/routes/graphql.ts create mode 100644 packages/linear/src/schema.ts create mode 100644 packages/linear/src/store.ts create mode 100644 packages/linear/tsconfig.json create mode 100644 packages/linear/tsup.config.ts create mode 100644 skills/linear/SKILL.md diff --git a/README.md b/README.md index d4e6aba..e46af21 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ using [`@emulators/core`](https://www.npmjs.com/package/@emulators/core). | `tosspayments` | 4002 | Payment confirm/lookup/cancel, order lookup, checkout simulation, webhooks | | `firebase` | 4003 | Auth (Identity Toolkit REST), Secure Token, FCM v1 | | `supabase` | 4004 | GoTrue Auth (signup/token/user), PostgREST table CRUD + filters | +| `linear` | 4005 | Linear GraphQL API (read-only): issues, projects, teams, users, orgs, labels, workflow states, Relay pagination | ## Getting started @@ -111,6 +112,7 @@ packages/ toss-payments/ # @pleaseai/emulate-toss-payments firebase/ # @pleaseai/emulate-firebase supabase/ # @pleaseai/emulate-supabase + linear/ # @pleaseai/emulate-linear docs/ EMULATOR-CONVENTIONS.md # guide for adding new emulators ``` diff --git a/bun.lock b/bun.lock index f14444c..5e22061 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "@emulators/core": "^0.6.0", "@pleaseai/emulate-firebase": "workspace:*", "@pleaseai/emulate-kakao": "workspace:*", + "@pleaseai/emulate-linear": "workspace:*", "@pleaseai/emulate-naver": "workspace:*", "@pleaseai/emulate-supabase": "workspace:*", "@pleaseai/emulate-toss-payments": "workspace:*", @@ -58,6 +59,18 @@ "typescript": "^6", }, }, + "packages/linear": { + "name": "@pleaseai/emulate-linear", + "version": "0.1.0", + "dependencies": { + "@emulators/core": "^0.6.0", + "graphql": "^16.13.2", + }, + "devDependencies": { + "tsup": "^8", + "typescript": "^6", + }, + }, "packages/naver": { "name": "@pleaseai/emulate-naver", "version": "0.1.0", @@ -219,6 +232,8 @@ "@pleaseai/emulate-kakao": ["@pleaseai/emulate-kakao@workspace:packages/kakao"], + "@pleaseai/emulate-linear": ["@pleaseai/emulate-linear@workspace:packages/linear"], + "@pleaseai/emulate-naver": ["@pleaseai/emulate-naver@workspace:packages/naver"], "@pleaseai/emulate-supabase": ["@pleaseai/emulate-supabase@workspace:packages/supabase"], @@ -567,6 +582,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphql": ["graphql@16.14.1", "", {}, "sha512-cQOsSMS/IrDz82PVyRDvf/Q1F/bRbBVjJlh+xYOkI1qw2bWRvWGiWc+m2O0d6l4Bt1fyY+8kzJ8JFWGJqNeDBg=="], + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], diff --git a/packages/emulate/package.json b/packages/emulate/package.json index c5629c6..dfe7b2f 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -21,6 +21,7 @@ "tosspayments", "firebase", "supabase", + "linear", "oauth", "testing", "ci", @@ -55,6 +56,7 @@ "@emulators/core": "^0.6.0", "@pleaseai/emulate-firebase": "workspace:*", "@pleaseai/emulate-kakao": "workspace:*", + "@pleaseai/emulate-linear": "workspace:*", "@pleaseai/emulate-naver": "workspace:*", "@pleaseai/emulate-supabase": "workspace:*", "@pleaseai/emulate-toss-payments": "workspace:*", diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index 6ec4896..4f42d2a 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -20,7 +20,7 @@ export interface ServiceEntry { initConfig: Record } -const SERVICE_NAME_LIST = ['kakao', 'naver', 'tosspayments', 'firebase', 'supabase'] as const +const SERVICE_NAME_LIST = ['kakao', 'naver', 'tosspayments', 'firebase', 'supabase', 'linear'] as const export type ServiceName = (typeof SERVICE_NAME_LIST)[number] export const SERVICE_NAMES: readonly ServiceName[] = SERVICE_NAME_LIST @@ -173,4 +173,30 @@ export const SERVICE_REGISTRY: Record = { }, }, }, + + linear: { + label: 'Linear GraphQL API emulator', + endpoints: + 'GraphQL queries for issues, projects, teams, users, organizations, labels, workflow states with Relay-style pagination', + async load() { + const mod = await import('@pleaseai/emulate-linear') + return { plugin: mod.linearPlugin, seedFromConfig: widenSeed(mod.seedFromConfig) } + }, + defaultFallback() { + return { login: 'linear-admin', id: 1, scopes: ['read'] } + }, + initConfig: { + linear: { + api_keys: ['lin_api_test'], + organizations: [{ id: 'org-1', name: 'My Org' }], + teams: [{ id: 'team-1', name: 'Engineering', key: 'ENG', organization: 'org-1' }], + users: [{ id: 'user-1', name: 'Developer', email: 'dev@example.com', organization: 'org-1' }], + workflow_states: [ + { id: 'ws-1', name: 'Todo', type: 'unstarted', team: 'team-1' }, + { id: 'ws-2', name: 'In Progress', type: 'started', team: 'team-1' }, + ], + issues: [{ id: 'issue-1', title: 'First issue', team: 'team-1', state: 'ws-1', assignee: 'user-1' }], + }, + }, + }, } diff --git a/packages/linear/README.md b/packages/linear/README.md new file mode 100644 index 0000000..3da6e7b --- /dev/null +++ b/packages/linear/README.md @@ -0,0 +1,69 @@ +# @pleaseai/emulate-linear + +Linear GraphQL API emulator for local development and CI. + +This package is Phase 1. It includes `POST /graphql`, schema introspection, PAT authentication, read only query resolvers, and Relay style pagination for issues, projects, teams, users, organizations, labels, and workflow states. + +Mutations, webhooks, OAuth 2.0, and an inspector UI are follow up work. + +## Install + +```bash +npm install @pleaseai/emulate-linear +``` + +Usually you do not install this directly — run it through the `@pleaseai/emulate` CLI. + +## Start + +```bash +# Through the emulate CLI +npx @pleaseai/emulate --service linear + +# From the monorepo (after `bun install && bun run build`) +bun packages/emulate/dist/index.js --service linear +``` + +A single service starts on the base port (default `4000`); use `-p ` to change it. + +## Auth + +Use the raw Linear PAT header: + +```bash +curl http://localhost:4000/graphql \ + -H "Authorization: lin_api_test" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ issues { nodes { id title } } }"}' +``` + +## Seed Config + +```yaml +linear: + api_keys: [lin_api_test] + organizations: + - id: org-1 + name: My Org + teams: + - id: team-1 + name: Engineering + key: ENG + organization: org-1 + workflow_states: + - id: ws-1 + name: Todo + type: unstarted + team: team-1 + users: + - id: user-1 + name: Developer + email: dev@example.com + organization: org-1 + issues: + - id: issue-1 + title: First issue + team: team-1 + state: ws-1 + assignee: user-1 +``` diff --git a/packages/linear/package.json b/packages/linear/package.json new file mode 100644 index 0000000..18c2f91 --- /dev/null +++ b/packages/linear/package.json @@ -0,0 +1,41 @@ +{ + "name": "@pleaseai/emulate-linear", + "type": "module", + "version": "0.1.0", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/pleaseai/emulate.git", + "directory": "packages/linear" + }, + "exports": { + ".": { + "bun": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup --clean", + "dev": "tsup --watch", + "test": "bun test", + "clean": "rm -rf dist .turbo", + "type-check": "tsgo --noEmit" + }, + "dependencies": { + "@emulators/core": "^0.6.0", + "graphql": "^16.13.2" + }, + "devDependencies": { + "tsup": "^8", + "typescript": "^6" + } +} diff --git a/packages/linear/src/__tests__/linear.test.ts b/packages/linear/src/__tests__/linear.test.ts new file mode 100644 index 0000000..6a88450 --- /dev/null +++ b/packages/linear/src/__tests__/linear.test.ts @@ -0,0 +1,324 @@ +import type { AppEnv, Hono } from '@emulators/core' +import { createServer } from '@emulators/core' +import { beforeEach, describe, expect, it } from 'bun:test' +import { linearPlugin, seedFromConfig } from '../index.js' + +const base = 'http://localhost:4012' + +function createTestApp() { + const { app, store } = createServer(linearPlugin, { port: 4012 }) + linearPlugin.seed?.(store, base) + seedFromConfig(store, base, { + api_keys: ['lin_api_seeded'], + organizations: [{ id: 'org-acme', name: 'Acme', url_key: 'acme' }], + users: [ + { id: 'user-alice', name: 'Alice', email: 'alice@example.com', organization: 'org-acme', admin: true }, + { id: 'user-bob', name: 'Bob', email: 'bob@example.com', organization: 'org-acme' }, + ], + teams: [ + { id: 'team-eng', name: 'Engineering', key: 'ENG', organization: 'org-acme' }, + { id: 'team-ops', name: 'Operations', key: 'OPS', organization: 'org-acme' }, + ], + workflow_states: [ + { id: 'state-todo', name: 'Todo', type: 'unstarted', team: 'team-eng', position: 1 }, + { id: 'state-started', name: 'Started', type: 'started', team: 'team-eng', position: 2 }, + { id: 'state-done', name: 'Done', type: 'completed', team: 'team-eng', position: 3 }, + ], + labels: [ + { id: 'label-bug', name: 'Bug', team: 'team-eng' }, + { id: 'label-feature', name: 'Feature', team: 'team-eng' }, + ], + projects: [ + { id: 'project-api', name: 'API', slug_id: 'api', team: 'team-eng', lead: 'user-alice', state: 'started' }, + ], + issues: [ + { + id: 'issue-one', + title: 'First seeded issue', + team: 'team-eng', + state: 'state-todo', + assignee: 'user-alice', + creator: 'user-bob', + project: 'project-api', + labels: ['label-bug'], + }, + { + id: 'issue-two', + title: 'Second seeded issue', + team: 'team-eng', + state: 'state-started', + assignee: 'user-bob', + creator: 'user-alice', + project: 'project-api', + labels: ['label-feature'], + }, + { + id: 'issue-three', + title: 'Third seeded issue', + team: 'team-eng', + state: 'state-done', + assignee: 'user-alice', + creator: 'user-alice', + project: 'project-api', + labels: ['label-bug', 'label-feature'], + }, + ], + }) + + return { app, store } +} + +async function gql(app: Hono, query: string, variables?: Record, token = 'lin_api_seeded') { + return app.request(`${base}/graphql`, { + method: 'POST', + headers: { 'Authorization': token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }) +} + +describe('Linear GraphQL emulator', () => { + let app: Hono + + beforeEach(() => { + app = createTestApp().app + }) + + it('accepts the standard GraphQL HTTP body shape', async () => { + const res = await gql(app, 'query GetIssue($id: ID!) { issue(id: $id) { id title } }', { id: 'issue-one' }) + expect(res.status).toBe(200) + const body = (await res.json()) as any + expect(body.data.issue).toEqual({ id: 'issue-one', title: 'First seeded issue' }) + expect(body.errors).toEqual([]) + }) + + it('supports schema introspection', async () => { + const res = await gql(app, '{ __schema { queryType { name } types { name } } }') + const body = (await res.json()) as any + expect(body.data.__schema.queryType.name).toBe('Query') + expect(body.data.__schema.types.some((type: { name: string }) => type.name === 'Issue')).toBe(true) + }) + + it('returns organizations', async () => { + const res = await gql(app, '{ organizations { nodes { id name urlKey } } }') + const body = (await res.json()) as any + expect(body.data.organizations.nodes).toContainEqual({ id: 'org-acme', name: 'Acme', urlKey: 'acme' }) + }) + + it('returns one organization by id', async () => { + const res = await gql(app, '{ organization(id: "org-acme") { id name } }') + const body = (await res.json()) as any + expect(body.data.organization).toEqual({ id: 'org-acme', name: 'Acme' }) + }) + + it('returns users', async () => { + const res = await gql(app, '{ users { nodes { id name email admin active organization { id } } } }') + const body = (await res.json()) as any + expect(body.data.users.nodes).toContainEqual({ + id: 'user-alice', + name: 'Alice', + email: 'alice@example.com', + admin: true, + active: true, + organization: { id: 'org-acme' }, + }) + }) + + it('returns one user by id', async () => { + const res = await gql(app, '{ user(id: "user-bob") { id name email } }') + const body = (await res.json()) as any + expect(body.data.user).toEqual({ id: 'user-bob', name: 'Bob', email: 'bob@example.com' }) + }) + + it('returns teams', async () => { + const res = await gql(app, '{ teams { nodes { id key name organization { id } } } }') + const body = (await res.json()) as any + expect(body.data.teams.nodes).toContainEqual({ + id: 'team-eng', + key: 'ENG', + name: 'Engineering', + organization: { id: 'org-acme' }, + }) + }) + + it('returns one team by id', async () => { + const res = await gql(app, '{ team(id: "team-ops") { id key name } }') + const body = (await res.json()) as any + expect(body.data.team).toEqual({ id: 'team-ops', key: 'OPS', name: 'Operations' }) + }) + + it('returns workflow states', async () => { + const res = await gql(app, '{ workflowStates { nodes { id name type team { id } } } }') + const body = (await res.json()) as any + expect(body.data.workflowStates.nodes).toContainEqual({ + id: 'state-started', + name: 'Started', + type: 'started', + team: { id: 'team-eng' }, + }) + }) + + it('returns one workflow state by id', async () => { + const res = await gql(app, '{ workflowState(id: "state-done") { id name type } }') + const body = (await res.json()) as any + expect(body.data.workflowState).toEqual({ id: 'state-done', name: 'Done', type: 'completed' }) + }) + + it('returns labels', async () => { + const res = await gql(app, '{ labels { nodes { id name team { id } } } }') + const body = (await res.json()) as any + expect(body.data.labels.nodes).toContainEqual({ id: 'label-feature', name: 'Feature', team: { id: 'team-eng' } }) + }) + + it('returns one label by id', async () => { + const res = await gql(app, '{ label(id: "label-bug") { id name issues { nodes { id } } } }') + const body = (await res.json()) as any + expect(body.data.label.name).toBe('Bug') + expect(body.data.label.issues.nodes.map((issue: { id: string }) => issue.id)).toContain('issue-one') + }) + + it('returns projects', async () => { + const res = await gql(app, '{ projects { nodes { id name slugId state team { id } lead { id } } } }') + const body = (await res.json()) as any + expect(body.data.projects.nodes).toContainEqual({ + id: 'project-api', + name: 'API', + slugId: 'api', + state: 'started', + team: { id: 'team-eng' }, + lead: { id: 'user-alice' }, + }) + }) + + it('returns one project by id', async () => { + const res = await gql(app, '{ project(id: "project-api") { id name issues { nodes { id } } } }') + const body = (await res.json()) as any + expect(body.data.project.id).toBe('project-api') + expect(body.data.project.issues.nodes).toHaveLength(3) + }) + + it('returns issues with relationships', async () => { + const res = await gql( + app, + '{ issues { nodes { id identifier title team { key } state { name } assignee { id } creator { id } project { id } labels { nodes { id } } } } }', + ) + const body = (await res.json()) as any + expect(body.data.issues.nodes).toContainEqual({ + id: 'issue-one', + identifier: 'ENG-1', + title: 'First seeded issue', + team: { key: 'ENG' }, + state: { name: 'Todo' }, + assignee: { id: 'user-alice' }, + creator: { id: 'user-bob' }, + project: { id: 'project-api' }, + labels: { nodes: [{ id: 'label-bug' }] }, + }) + }) + + it('returns one issue by id', async () => { + const res = await gql(app, '{ issue(id: "issue-two") { id identifier title } }') + const body = (await res.json()) as any + expect(body.data.issue).toEqual({ id: 'issue-two', identifier: 'ENG-2', title: 'Second seeded issue' }) + }) + + it('returns one issue by identifier', async () => { + const res = await gql(app, '{ issue(identifier: "ENG-3") { id title } }') + const body = (await res.json()) as any + expect(body.data.issue).toEqual({ id: 'issue-three', title: 'Third seeded issue' }) + }) + + it('returns relay edges and page info', async () => { + const res = await gql( + app, + '{ issues(first: 2) { edges { cursor node { id } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }', + ) + const body = (await res.json()) as any + expect(body.data.issues.edges).toHaveLength(2) + expect(body.data.issues.edges[0].cursor).toBeTruthy() + expect(body.data.issues.pageInfo.hasNextPage).toBe(true) + expect(body.data.issues.pageInfo.hasPreviousPage).toBe(false) + }) + + it('paginates forward with after', async () => { + const firstRes = await gql( + app, + '{ issues(first: 2) { edges { cursor node { id } } pageInfo { endCursor } nodes { id } } }', + ) + const firstBody = (await firstRes.json()) as any + const secondRes = await gql( + app, + 'query($after: String!) { issues(first: 1, after: $after) { nodes { id } pageInfo { hasPreviousPage } } }', + { + after: firstBody.data.issues.edges[0].cursor, + }, + ) + const secondBody = (await secondRes.json()) as any + expect(secondBody.data.issues.nodes[0].id).toBe(firstBody.data.issues.edges[1].node.id) + expect(secondBody.data.issues.pageInfo.hasPreviousPage).toBe(true) + }) + + it('paginates backward with before and last', async () => { + const firstRes = await gql(app, '{ issues(first: 3) { edges { cursor node { id } } } }') + const firstBody = (await firstRes.json()) as any + const before = firstBody.data.issues.edges[2].cursor + const secondRes = await gql( + app, + 'query($before: String!) { issues(last: 1, before: $before) { nodes { id } pageInfo { hasNextPage } } }', + { + before, + }, + ) + const secondBody = (await secondRes.json()) as any + expect(secondBody.data.issues.nodes[0].id).toBe(firstBody.data.issues.edges[1].node.id) + expect(secondBody.data.issues.pageInfo.hasNextPage).toBe(true) + }) + + it('validates PAT auth with raw Authorization header', async () => { + const res = await gql(app, '{ viewer { id } }') + expect(res.status).toBe(200) + const body = (await res.json()) as any + expect(body.errors).toEqual([]) + }) + + it('accepts Bearer PAT auth for client compatibility', async () => { + const res = await gql(app, '{ viewer { id } }', undefined, 'Bearer lin_api_seeded') + expect(res.status).toBe(200) + }) + + it('rejects missing PAT auth', async () => { + const res = await app.request(`${base}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ viewer { id } }' }), + }) + const body = (await res.json()) as any + expect(res.status).toBe(401) + expect(body.errors[0].extensions.code).toBe('AUTHENTICATION_ERROR') + }) + + it('rejects unknown PAT auth', async () => { + const res = await gql(app, '{ viewer { id } }', undefined, 'lin_api_unknown') + const body = (await res.json()) as any + expect(res.status).toBe(401) + expect(body.errors[0].extensions.code).toBe('AUTHENTICATION_ERROR') + }) + + it('returns GraphQL errors with an extensions code', async () => { + const res = await gql(app, '{ missingField }') + const body = (await res.json()) as any + expect(res.status).toBe(200) + expect(body.data).toBeNull() + expect(body.errors[0].extensions.code).toBe('GRAPHQL_ERROR') + }) + + it('rejects non string query bodies with a Linear shaped error', async () => { + const res = await app.request(`${base}/graphql`, { + method: 'POST', + headers: { 'Authorization': 'lin_api_seeded', 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: 1 }), + }) + const body = (await res.json()) as any + expect(res.status).toBe(400) + expect(body.errors[0].extensions.code).toBe('BAD_REQUEST') + }) +}) diff --git a/packages/linear/src/entities.ts b/packages/linear/src/entities.ts new file mode 100644 index 0000000..3af30b0 --- /dev/null +++ b/packages/linear/src/entities.ts @@ -0,0 +1,76 @@ +import type { Entity } from '@emulators/core' + +export interface LinearApiKey extends Entity { + key: string +} + +export interface LinearOrganization extends Entity { + linear_id: string + name: string + url_key: string | null +} + +export interface LinearUser extends Entity { + linear_id: string + name: string + email: string + display_name: string | null + active: boolean + admin: boolean + organization_id: string | null +} + +export type LinearWorkflowStateType = 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled' + +export interface LinearTeam extends Entity { + linear_id: string + name: string + key: string + description: string | null + organization_id: string +} + +export interface LinearWorkflowState extends Entity { + linear_id: string + name: string + type: LinearWorkflowStateType + position: number + color: string + team_id: string +} + +export interface LinearLabel extends Entity { + linear_id: string + name: string + color: string + description: string | null + team_id: string | null +} + +export interface LinearProject extends Entity { + linear_id: string + name: string + description: string | null + slug_id: string + state: string + team_id: string | null + lead_id: string | null + target_date: string | null +} + +export interface LinearIssue extends Entity { + linear_id: string + identifier: string + number: number + title: string + description: string | null + priority: number + estimate: number | null + url: string + team_id: string + state_id: string | null + assignee_id: string | null + creator_id: string | null + project_id: string | null + label_ids: string[] +} diff --git a/packages/linear/src/helpers.ts b/packages/linear/src/helpers.ts new file mode 100644 index 0000000..f502086 --- /dev/null +++ b/packages/linear/src/helpers.ts @@ -0,0 +1,111 @@ +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { GraphQLError } from 'graphql' + +export interface ConnectionArgs { + first?: number | null + after?: string | null + last?: number | null + before?: string | null +} + +export interface PageInfo { + hasNextPage: boolean + hasPreviousPage: boolean + startCursor: string | null + endCursor: string | null +} + +export interface Edge { + node: T + cursor: string +} + +export interface Connection { + edges: Edge[] + nodes: T[] + pageInfo: PageInfo +} + +export function generateLinearId(): string { + return randomUUID() +} + +export function slugify(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return slug.length > 0 ? slug : 'item' +} + +export function linearError(message: string, code: string, type = 'graphql error'): GraphQLError { + return new GraphQLError(message, { + extensions: { code, type }, + }) +} + +function encodeCursor(index: number): string { + return Buffer.from(`linear:${index}`).toString('base64url') +} + +function decodeCursor(cursor: string): number | null { + try { + const decoded = Buffer.from(cursor, 'base64url').toString('utf8') + const match = decoded.match(/^linear:(\d+)$/) + return match ? Number(match[1]) : null + } + catch { + return null + } +} + +export function toConnection(items: T[], args: ConnectionArgs = {}): Connection { + let start = 0 + let end = items.length + + if (args.after) { + const afterIndex = decodeCursor(args.after) + if (afterIndex !== null) { + start = Math.max(start, afterIndex + 1) + } + } + + if (args.before) { + const beforeIndex = decodeCursor(args.before) + if (beforeIndex !== null) { + end = Math.min(end, beforeIndex) + } + } + + if (typeof args.first === 'number') { + if (args.first < 0) { + throw linearError('first must be greater than or equal to 0', 'BAD_USER_INPUT', 'validation error') + } + end = Math.min(end, start + args.first) + } + + if (typeof args.last === 'number') { + if (args.last < 0) { + throw linearError('last must be greater than or equal to 0', 'BAD_USER_INPUT', 'validation error') + } + start = Math.max(start, end - args.last) + } + + const sliced = items.slice(start, end) + const edges = sliced.map((node, offset) => ({ + node, + cursor: encodeCursor(start + offset), + })) + + return { + edges, + nodes: sliced, + pageInfo: { + hasNextPage: end < items.length, + hasPreviousPage: start > 0, + startCursor: edges[0]?.cursor ?? null, + endCursor: edges[edges.length - 1]?.cursor ?? null, + }, + } +} diff --git a/packages/linear/src/index.ts b/packages/linear/src/index.ts new file mode 100644 index 0000000..fae9dea --- /dev/null +++ b/packages/linear/src/index.ts @@ -0,0 +1,418 @@ +import type { AppEnv, Hono, RouteContext, ServicePlugin, Store, TokenMap, WebhookDispatcher } from '@emulators/core' +import type { LinearWorkflowStateType } from './entities.js' +import { generateLinearId, slugify } from './helpers.js' +import { graphqlRoutes } from './routes/graphql.js' +import { getLinearStore } from './store.js' + +export * from './entities.js' +export { getLinearStore, type LinearStore } from './store.js' + +export interface LinearSeedConfig { + port?: number + api_keys?: string[] + organizations?: Array<{ + id?: string + name: string + url_key?: string + }> + users?: Array<{ + id?: string + name: string + email: string + display_name?: string + active?: boolean + admin?: boolean + organization?: string + }> + teams?: Array<{ + id?: string + name: string + key: string + description?: string + organization?: string + }> + workflow_states?: Array<{ + id?: string + name: string + type?: LinearWorkflowStateType + position?: number + color?: string + team: string + }> + labels?: Array<{ + id?: string + name: string + color?: string + description?: string + team?: string + }> + projects?: Array<{ + id?: string + name: string + description?: string + slug_id?: string + state?: string + team?: string + lead?: string + target_date?: string + }> + issues?: Array<{ + id?: string + identifier?: string + number?: number + title: string + description?: string + priority?: number + estimate?: number + team: string + state?: string + assignee?: string + creator?: string + project?: string + labels?: string[] + }> +} + +function insertApiKey(store: Store, key: string): void { + const ls = getLinearStore(store) + if (!ls.apiKeys.findOneBy('key', key)) { + ls.apiKeys.insert({ key }) + } +} + +function findOrganization(store: Store, ref: string | undefined) { + const ls = getLinearStore(store) + if (!ref) { + return ls.organizations.all()[0] ?? null + } + return ( + ls.organizations.findOneBy('linear_id', ref) + ?? ls.organizations.findOneBy('url_key', ref) + ?? ls.organizations.all().find(org => org.name === ref) + ?? null + ) +} + +function findTeam(store: Store, ref: string | undefined) { + const ls = getLinearStore(store) + if (!ref) { + return ls.teams.all()[0] ?? null + } + return ( + ls.teams.findOneBy('linear_id', ref) + ?? ls.teams.findOneBy('key', ref) + ?? ls.teams.all().find(team => team.name === ref) + ?? null + ) +} + +function findUser(store: Store, ref: string | undefined) { + const ls = getLinearStore(store) + if (!ref) { + return null + } + return ( + ls.users.findOneBy('linear_id', ref) + ?? ls.users.findOneBy('email', ref) + ?? ls.users.all().find(user => user.name === ref) + ?? null + ) +} + +function findWorkflowState(store: Store, ref: string | undefined) { + const ls = getLinearStore(store) + if (!ref) { + return null + } + return ( + ls.workflowStates.findOneBy('linear_id', ref) ?? ls.workflowStates.all().find(state => state.name === ref) ?? null + ) +} + +function findProject(store: Store, ref: string | undefined) { + const ls = getLinearStore(store) + if (!ref) { + return null + } + return ( + ls.projects.findOneBy('linear_id', ref) + ?? ls.projects.findOneBy('slug_id', ref) + ?? ls.projects.all().find(project => project.name === ref) + ?? null + ) +} + +function resolveLabelIds(store: Store, labels: string[] | undefined): string[] { + if (!labels) { + return [] + } + const ls = getLinearStore(store) + return labels + .map(ref => ls.labels.findOneBy('linear_id', ref) ?? ls.labels.all().find(label => label.name === ref)) + .filter((label): label is NonNullable => Boolean(label)) + .map(label => label.linear_id) +} + +function issueNumberForTeam(store: Store, teamId: string, requested?: number): number { + if (requested !== undefined) { + return requested + } + const ls = getLinearStore(store) + const highest = ls.issues.findBy('team_id', teamId).reduce((max, issue) => Math.max(max, issue.number), 0) + return highest + 1 +} + +function insertDefaults(store: Store, baseUrl: string): void { + const ls = getLinearStore(store) + insertApiKey(store, 'lin_api_test') + + const orgId = 'org-1' + if (!ls.organizations.findOneBy('linear_id', orgId)) { + ls.organizations.insert({ linear_id: orgId, name: 'Emulate', url_key: 'emulate' }) + } + + const userId = 'user-1' + if (!ls.users.findOneBy('linear_id', userId)) { + ls.users.insert({ + linear_id: userId, + name: 'Developer', + email: 'dev@example.com', + display_name: 'Developer', + active: true, + admin: true, + organization_id: orgId, + }) + } + + const teamId = 'team-1' + if (!ls.teams.findOneBy('linear_id', teamId)) { + ls.teams.insert({ + linear_id: teamId, + name: 'Engineering', + key: 'ENG', + description: 'Engineering work', + organization_id: orgId, + }) + } + + const todoStateId = 'ws-1' + if (!ls.workflowStates.findOneBy('linear_id', todoStateId)) { + ls.workflowStates.insert({ + linear_id: todoStateId, + name: 'Todo', + type: 'unstarted', + position: 1, + color: '#e2e2e2', + team_id: teamId, + }) + } + + if (!ls.workflowStates.findOneBy('linear_id', 'ws-2')) { + ls.workflowStates.insert({ + linear_id: 'ws-2', + name: 'In Progress', + type: 'started', + position: 2, + color: '#f2c94c', + team_id: teamId, + }) + } + + const labelId = 'label-1' + if (!ls.labels.findOneBy('linear_id', labelId)) { + ls.labels.insert({ + linear_id: labelId, + name: 'Bug', + color: '#eb5757', + description: 'Something is not working', + team_id: teamId, + }) + } + + const projectId = 'project-1' + if (!ls.projects.findOneBy('linear_id', projectId)) { + ls.projects.insert({ + linear_id: projectId, + name: 'Launch', + description: 'Launch project', + slug_id: 'launch', + state: 'started', + team_id: teamId, + lead_id: userId, + target_date: null, + }) + } + + if (!ls.issues.findOneBy('linear_id', 'issue-1')) { + ls.issues.insert({ + linear_id: 'issue-1', + identifier: 'ENG-1', + number: 1, + title: 'First issue', + description: 'Seeded Linear issue', + priority: 0, + estimate: null, + url: `${baseUrl}/ENG/issue/ENG-1/first-issue`, + team_id: teamId, + state_id: todoStateId, + assignee_id: userId, + creator_id: userId, + project_id: projectId, + label_ids: [labelId], + }) + } +} + +export function seedFromConfig(store: Store, baseUrl: string, config: LinearSeedConfig): void { + const ls = getLinearStore(store) + + for (const key of config.api_keys ?? []) { + insertApiKey(store, key) + } + + for (const org of config.organizations ?? []) { + const linearId = org.id ?? generateLinearId() + if (ls.organizations.findOneBy('linear_id', linearId)) { + continue + } + ls.organizations.insert({ + linear_id: linearId, + name: org.name, + url_key: org.url_key ?? slugify(org.name), + }) + } + + for (const user of config.users ?? []) { + const linearId = user.id ?? generateLinearId() + if (ls.users.findOneBy('linear_id', linearId)) { + continue + } + const org = findOrganization(store, user.organization) + ls.users.insert({ + linear_id: linearId, + name: user.name, + email: user.email, + display_name: user.display_name ?? user.name, + active: user.active ?? true, + admin: user.admin ?? false, + organization_id: org?.linear_id ?? null, + }) + } + + for (const team of config.teams ?? []) { + const linearId = team.id ?? generateLinearId() + if (ls.teams.findOneBy('linear_id', linearId)) { + continue + } + const org = findOrganization(store, team.organization) + if (!org) { + continue + } + ls.teams.insert({ + linear_id: linearId, + name: team.name, + key: team.key, + description: team.description ?? null, + organization_id: org.linear_id, + }) + } + + for (const state of config.workflow_states ?? []) { + const linearId = state.id ?? generateLinearId() + if (ls.workflowStates.findOneBy('linear_id', linearId)) { + continue + } + const team = findTeam(store, state.team) + if (!team) { + continue + } + ls.workflowStates.insert({ + linear_id: linearId, + name: state.name, + type: state.type ?? 'unstarted', + position: state.position ?? ls.workflowStates.findBy('team_id', team.linear_id).length + 1, + color: state.color ?? '#e2e2e2', + team_id: team.linear_id, + }) + } + + for (const label of config.labels ?? []) { + const linearId = label.id ?? generateLinearId() + if (ls.labels.findOneBy('linear_id', linearId)) { + continue + } + const team = findTeam(store, label.team) + ls.labels.insert({ + linear_id: linearId, + name: label.name, + color: label.color ?? '#5e6ad2', + description: label.description ?? null, + team_id: team?.linear_id ?? null, + }) + } + + for (const project of config.projects ?? []) { + const linearId = project.id ?? generateLinearId() + if (ls.projects.findOneBy('linear_id', linearId)) { + continue + } + const team = findTeam(store, project.team) + const lead = findUser(store, project.lead) + ls.projects.insert({ + linear_id: linearId, + name: project.name, + description: project.description ?? null, + slug_id: project.slug_id ?? slugify(project.name), + state: project.state ?? 'planned', + team_id: team?.linear_id ?? null, + lead_id: lead?.linear_id ?? null, + target_date: project.target_date ?? null, + }) + } + + for (const issue of config.issues ?? []) { + const linearId = issue.id ?? generateLinearId() + if (ls.issues.findOneBy('linear_id', linearId)) { + continue + } + const team = findTeam(store, issue.team) + if (!team) { + continue + } + const state = findWorkflowState(store, issue.state) + const assignee = findUser(store, issue.assignee) + const creator = findUser(store, issue.creator) + const project = findProject(store, issue.project) + const number = issueNumberForTeam(store, team.linear_id, issue.number) + const identifier = issue.identifier ?? `${team.key}-${number}` + ls.issues.insert({ + linear_id: linearId, + identifier, + number, + title: issue.title, + description: issue.description ?? null, + priority: issue.priority ?? 0, + estimate: issue.estimate ?? null, + url: `${baseUrl}/${team.key}/issue/${identifier}/${slugify(issue.title)}`, + team_id: team.linear_id, + state_id: state?.linear_id ?? null, + assignee_id: assignee?.linear_id ?? null, + creator_id: creator?.linear_id ?? null, + project_id: project?.linear_id ?? null, + label_ids: resolveLabelIds(store, issue.labels), + }) + } +} + +export const linearPlugin: ServicePlugin = { + name: 'linear', + register(app: Hono, store: Store, webhooks: WebhookDispatcher, baseUrl: string, tokenMap?: TokenMap): void { + const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap } + graphqlRoutes(ctx) + }, + seed(store: Store, baseUrl: string): void { + insertDefaults(store, baseUrl) + }, +} + +export default linearPlugin diff --git a/packages/linear/src/resolvers.ts b/packages/linear/src/resolvers.ts new file mode 100644 index 0000000..4c22f94 --- /dev/null +++ b/packages/linear/src/resolvers.ts @@ -0,0 +1,309 @@ +import type { Store } from '@emulators/core' +import type { GraphQLFieldResolver, GraphQLResolveInfo } from 'graphql' +import type { + LinearIssue, + LinearLabel, + LinearOrganization, + LinearProject, + LinearTeam, + LinearUser, + LinearWorkflowState, +} from './entities.js' +import type { ConnectionArgs } from './helpers.js' +import type { LinearStore } from './store.js' +import { linearError, toConnection } from './helpers.js' +import { getLinearStore } from './store.js' + +export interface LinearGraphQLContext { + store: Store + authToken: string +} + +type LinearSource + = | LinearIssue + | LinearLabel + | LinearOrganization + | LinearProject + | LinearTeam + | LinearUser + | LinearWorkflowState + | Record + +function byCreatedAt(items: T[]): T[] { + return [...items].sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id - b.id) +} + +function findByLinearId(items: T[], id: string): T | null { + return items.find(item => item.linear_id === id) ?? null +} + +function list(items: T[], args: ConnectionArgs) { + return toConnection(byCreatedAt(items), args) +} + +function linearStore(context: LinearGraphQLContext): LinearStore { + return getLinearStore(context.store) +} + +function resolveQuery(fieldName: string, args: Record, context: LinearGraphQLContext) { + const ls = linearStore(context) + + switch (fieldName) { + case 'viewer': + return ls.users.all()[0] ?? null + case 'organization': + return typeof args.id === 'string' + ? (ls.organizations.findOneBy('linear_id', args.id) ?? null) + : (ls.organizations.all()[0] ?? null) + case 'organizations': + return list(ls.organizations.all(), args) + case 'user': + return findByLinearId(ls.users.all(), String(args.id)) + case 'users': + return list(ls.users.all(), args) + case 'team': + return findByLinearId(ls.teams.all(), String(args.id)) + case 'teams': + return list(ls.teams.all(), args) + case 'workflowState': + return findByLinearId(ls.workflowStates.all(), String(args.id)) + case 'workflowStates': + return list(ls.workflowStates.all(), args) + case 'label': + return findByLinearId(ls.labels.all(), String(args.id)) + case 'labels': + return list(ls.labels.all(), args) + case 'project': + return findByLinearId(ls.projects.all(), String(args.id)) + case 'projects': + return list(ls.projects.all(), args) + case 'issue': + if (typeof args.identifier === 'string') { + return ls.issues.findOneBy('identifier', args.identifier) ?? null + } + if (typeof args.id === 'string') { + return ls.issues.findOneBy('linear_id', args.id) ?? null + } + throw linearError('id or identifier is required', 'BAD_USER_INPUT', 'validation error') + case 'issues': + return list(ls.issues.all(), args) + default: + return undefined + } +} + +function directValue(source: Record, fieldName: string): unknown { + if (fieldName in source) { + return source[fieldName] + } + return undefined +} + +function resolveOrganization( + source: LinearOrganization, + fieldName: string, + args: ConnectionArgs, + ls: LinearStore, +): unknown { + switch (fieldName) { + case 'id': + return source.linear_id + case 'urlKey': + return source.url_key + case 'createdAt': + return source.created_at + case 'updatedAt': + return source.updated_at + case 'teams': + return list(ls.teams.findBy('organization_id', source.linear_id), args) + case 'users': + return list(ls.users.findBy('organization_id', source.linear_id), args) + default: + return directValue(source as unknown as Record, fieldName) + } +} + +function resolveUser(source: LinearUser, fieldName: string, args: ConnectionArgs, ls: LinearStore): unknown { + switch (fieldName) { + case 'id': + return source.linear_id + case 'displayName': + return source.display_name + case 'createdAt': + return source.created_at + case 'updatedAt': + return source.updated_at + case 'organization': + return source.organization_id ? (ls.organizations.findOneBy('linear_id', source.organization_id) ?? null) : null + case 'assignedIssues': + return list(ls.issues.findBy('assignee_id', source.linear_id), args) + case 'createdIssues': + return list(ls.issues.findBy('creator_id', source.linear_id), args) + case 'projectsLed': + return list(ls.projects.findBy('lead_id', source.linear_id), args) + default: + return directValue(source as unknown as Record, fieldName) + } +} + +function resolveTeam(source: LinearTeam, fieldName: string, args: ConnectionArgs, ls: LinearStore): unknown { + switch (fieldName) { + case 'id': + return source.linear_id + case 'createdAt': + return source.created_at + case 'updatedAt': + return source.updated_at + case 'organization': + return ls.organizations.findOneBy('linear_id', source.organization_id) ?? null + case 'issues': + return list(ls.issues.findBy('team_id', source.linear_id), args) + case 'labels': + return list(ls.labels.findBy('team_id', source.linear_id), args) + case 'workflowStates': + return list(ls.workflowStates.findBy('team_id', source.linear_id), args) + case 'projects': + return list(ls.projects.findBy('team_id', source.linear_id), args) + default: + return directValue(source as unknown as Record, fieldName) + } +} + +function resolveWorkflowState( + source: LinearWorkflowState, + fieldName: string, + args: ConnectionArgs, + ls: LinearStore, +): unknown { + switch (fieldName) { + case 'id': + return source.linear_id + case 'createdAt': + return source.created_at + case 'updatedAt': + return source.updated_at + case 'team': + return ls.teams.findOneBy('linear_id', source.team_id) ?? null + case 'issues': + return list(ls.issues.findBy('state_id', source.linear_id), args) + default: + return directValue(source as unknown as Record, fieldName) + } +} + +function resolveLabel(source: LinearLabel, fieldName: string, args: ConnectionArgs, ls: LinearStore): unknown { + switch (fieldName) { + case 'id': + return source.linear_id + case 'createdAt': + return source.created_at + case 'updatedAt': + return source.updated_at + case 'team': + return source.team_id ? (ls.teams.findOneBy('linear_id', source.team_id) ?? null) : null + case 'issues': + return list( + ls.issues.all().filter(issue => issue.label_ids.includes(source.linear_id)), + args, + ) + default: + return directValue(source as unknown as Record, fieldName) + } +} + +function resolveProject(source: LinearProject, fieldName: string, args: ConnectionArgs, ls: LinearStore): unknown { + switch (fieldName) { + case 'id': + return source.linear_id + case 'slugId': + return source.slug_id + case 'targetDate': + return source.target_date + case 'createdAt': + return source.created_at + case 'updatedAt': + return source.updated_at + case 'team': + return source.team_id ? (ls.teams.findOneBy('linear_id', source.team_id) ?? null) : null + case 'lead': + return source.lead_id ? (ls.users.findOneBy('linear_id', source.lead_id) ?? null) : null + case 'issues': + return list(ls.issues.findBy('project_id', source.linear_id), args) + default: + return directValue(source as unknown as Record, fieldName) + } +} + +function resolveIssue(source: LinearIssue, fieldName: string, args: ConnectionArgs, ls: LinearStore): unknown { + switch (fieldName) { + case 'id': + return source.linear_id + case 'createdAt': + return source.created_at + case 'updatedAt': + return source.updated_at + case 'team': + return ls.teams.findOneBy('linear_id', source.team_id) ?? null + case 'state': + return source.state_id ? (ls.workflowStates.findOneBy('linear_id', source.state_id) ?? null) : null + case 'assignee': + return source.assignee_id ? (ls.users.findOneBy('linear_id', source.assignee_id) ?? null) : null + case 'creator': + return source.creator_id ? (ls.users.findOneBy('linear_id', source.creator_id) ?? null) : null + case 'project': + return source.project_id ? (ls.projects.findOneBy('linear_id', source.project_id) ?? null) : null + case 'labels': + return list( + source.label_ids + .map(id => ls.labels.findOneBy('linear_id', id)) + .filter((label): label is LinearLabel => Boolean(label)), + args, + ) + default: + return directValue(source as unknown as Record, fieldName) + } +} + +function resolveObject( + source: LinearSource, + args: ConnectionArgs, + context: LinearGraphQLContext, + info: GraphQLResolveInfo, +): unknown { + const ls = linearStore(context) + + switch (info.parentType.name) { + case 'Organization': + return resolveOrganization(source as LinearOrganization, info.fieldName, args, ls) + case 'User': + return resolveUser(source as LinearUser, info.fieldName, args, ls) + case 'Team': + return resolveTeam(source as LinearTeam, info.fieldName, args, ls) + case 'WorkflowState': + return resolveWorkflowState(source as LinearWorkflowState, info.fieldName, args, ls) + case 'Label': + return resolveLabel(source as LinearLabel, info.fieldName, args, ls) + case 'Project': + return resolveProject(source as LinearProject, info.fieldName, args, ls) + case 'Issue': + return resolveIssue(source as LinearIssue, info.fieldName, args, ls) + default: + return directValue(source as Record, info.fieldName) + } +} + +export const linearFieldResolver: GraphQLFieldResolver = ( + source, + args, + context, + info, +) => { + if (info.parentType.name === 'Query') { + return resolveQuery(info.fieldName, args, context) + } + + if (!source) { + return undefined + } + return resolveObject(source, args, context, info) +} diff --git a/packages/linear/src/routes/graphql.ts b/packages/linear/src/routes/graphql.ts new file mode 100644 index 0000000..c55e23a --- /dev/null +++ b/packages/linear/src/routes/graphql.ts @@ -0,0 +1,88 @@ +import type { RouteContext } from '@emulators/core' +import { graphql } from 'graphql' +import { linearFieldResolver } from '../resolvers.js' +import { linearSchema } from '../schema.js' +import { getLinearStore } from '../store.js' + +interface GraphQLRequestBody { + query?: unknown + variables?: unknown + operationName?: unknown +} + +function tokenFromHeader(value: string | undefined): string | null { + if (!value) { + return null + } + const token = value.replace(/^(Bearer|token)\s+/i, '').trim() + return token.length > 0 ? token : null +} + +function errorPayload(message: string, code: string, type: string) { + return { + data: null, + errors: [ + { + message, + extensions: { code, type }, + }, + ], + } +} + +export function graphqlRoutes(ctx: RouteContext): void { + const { app, store } = ctx + + app.post('/graphql', async (c) => { + const token = tokenFromHeader(c.req.header('Authorization')) + const ls = getLinearStore(store) + const validPat = token ? Boolean(ls.apiKeys.findOneBy('key', token)) : false + + if (!token || !validPat) { + return c.json(errorPayload('Authentication required', 'AUTHENTICATION_ERROR', 'authentication error'), 401) + } + + let body: GraphQLRequestBody + try { + body = (await c.req.json()) as GraphQLRequestBody + } + catch { + return c.json(errorPayload('Request body must be JSON', 'BAD_REQUEST', 'request error'), 400) + } + + if (typeof body.query !== 'string') { + return c.json(errorPayload('query must be a string', 'BAD_REQUEST', 'request error'), 400) + } + + const result = await graphql({ + schema: linearSchema, + source: body.query, + contextValue: { store, authToken: token }, + variableValues: + body.variables && typeof body.variables === 'object' ? (body.variables as Record) : undefined, + operationName: typeof body.operationName === 'string' ? body.operationName : undefined, + fieldResolver: linearFieldResolver, + }) + + return c.json({ + data: result.data ?? null, + errors: (result.errors ?? []).map((error) => { + const json = error.toJSON() + return { + ...json, + extensions: { + code: 'GRAPHQL_ERROR', + type: 'graphql error', + ...json.extensions, + }, + } + }), + }) + }) + + // core's app router has no `all`; register 405 for the non-POST methods explicitly. + for (const register of [app.get, app.put, app.patch, app.delete] as const) { + register.call(app, '/graphql', c => + c.json(errorPayload('Only POST /graphql is supported', 'METHOD_NOT_ALLOWED', 'request error'), 405)) + } +} diff --git a/packages/linear/src/schema.ts b/packages/linear/src/schema.ts new file mode 100644 index 0000000..576ba63 --- /dev/null +++ b/packages/linear/src/schema.ts @@ -0,0 +1,146 @@ +import { buildSchema } from 'graphql' + +export const linearSchema = buildSchema(` + scalar DateTime + + type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + + type Organization { + id: ID! + name: String! + urlKey: String + createdAt: DateTime! + updatedAt: DateTime! + teams(first: Int, after: String, last: Int, before: String): TeamConnection! + users(first: Int, after: String, last: Int, before: String): UserConnection! + } + + type User { + id: ID! + name: String! + email: String! + displayName: String + active: Boolean! + admin: Boolean! + createdAt: DateTime! + updatedAt: DateTime! + organization: Organization + assignedIssues(first: Int, after: String, last: Int, before: String): IssueConnection! + createdIssues(first: Int, after: String, last: Int, before: String): IssueConnection! + projectsLed(first: Int, after: String, last: Int, before: String): ProjectConnection! + } + + type Team { + id: ID! + name: String! + key: String! + description: String + createdAt: DateTime! + updatedAt: DateTime! + organization: Organization! + issues(first: Int, after: String, last: Int, before: String): IssueConnection! + labels(first: Int, after: String, last: Int, before: String): LabelConnection! + workflowStates(first: Int, after: String, last: Int, before: String): WorkflowStateConnection! + projects(first: Int, after: String, last: Int, before: String): ProjectConnection! + } + + type WorkflowState { + id: ID! + name: String! + type: String! + position: Float! + color: String! + createdAt: DateTime! + updatedAt: DateTime! + team: Team! + issues(first: Int, after: String, last: Int, before: String): IssueConnection! + } + + type Label { + id: ID! + name: String! + color: String! + description: String + createdAt: DateTime! + updatedAt: DateTime! + team: Team + issues(first: Int, after: String, last: Int, before: String): IssueConnection! + } + + type Project { + id: ID! + name: String! + description: String + slugId: String! + state: String! + targetDate: DateTime + createdAt: DateTime! + updatedAt: DateTime! + team: Team + lead: User + issues(first: Int, after: String, last: Int, before: String): IssueConnection! + } + + type Issue { + id: ID! + identifier: String! + number: Float! + title: String! + description: String + priority: Float! + estimate: Float + url: String! + createdAt: DateTime! + updatedAt: DateTime! + team: Team! + state: WorkflowState + assignee: User + creator: User + project: Project + labels(first: Int, after: String, last: Int, before: String): LabelConnection! + } + + type OrganizationEdge { node: Organization!, cursor: String! } + type OrganizationConnection { edges: [OrganizationEdge!]!, nodes: [Organization!]!, pageInfo: PageInfo! } + + type UserEdge { node: User!, cursor: String! } + type UserConnection { edges: [UserEdge!]!, nodes: [User!]!, pageInfo: PageInfo! } + + type TeamEdge { node: Team!, cursor: String! } + type TeamConnection { edges: [TeamEdge!]!, nodes: [Team!]!, pageInfo: PageInfo! } + + type WorkflowStateEdge { node: WorkflowState!, cursor: String! } + type WorkflowStateConnection { edges: [WorkflowStateEdge!]!, nodes: [WorkflowState!]!, pageInfo: PageInfo! } + + type LabelEdge { node: Label!, cursor: String! } + type LabelConnection { edges: [LabelEdge!]!, nodes: [Label!]!, pageInfo: PageInfo! } + + type ProjectEdge { node: Project!, cursor: String! } + type ProjectConnection { edges: [ProjectEdge!]!, nodes: [Project!]!, pageInfo: PageInfo! } + + type IssueEdge { node: Issue!, cursor: String! } + type IssueConnection { edges: [IssueEdge!]!, nodes: [Issue!]!, pageInfo: PageInfo! } + + type Query { + viewer: User + organization(id: ID): Organization + organizations(first: Int, after: String, last: Int, before: String): OrganizationConnection! + user(id: ID!): User + users(first: Int, after: String, last: Int, before: String): UserConnection! + team(id: ID!): Team + teams(first: Int, after: String, last: Int, before: String): TeamConnection! + workflowState(id: ID!): WorkflowState + workflowStates(first: Int, after: String, last: Int, before: String): WorkflowStateConnection! + label(id: ID!): Label + labels(first: Int, after: String, last: Int, before: String): LabelConnection! + project(id: ID!): Project + projects(first: Int, after: String, last: Int, before: String): ProjectConnection! + issue(id: ID, identifier: String): Issue + issues(first: Int, after: String, last: Int, before: String): IssueConnection! + } +`) diff --git a/packages/linear/src/store.ts b/packages/linear/src/store.ts new file mode 100644 index 0000000..f1f93c6 --- /dev/null +++ b/packages/linear/src/store.ts @@ -0,0 +1,35 @@ +import type { Collection, Store } from '@emulators/core' +import type { + LinearApiKey, + LinearIssue, + LinearLabel, + LinearOrganization, + LinearProject, + LinearTeam, + LinearUser, + LinearWorkflowState, +} from './entities.js' + +export interface LinearStore { + apiKeys: Collection + organizations: Collection + users: Collection + teams: Collection + workflowStates: Collection + labels: Collection + projects: Collection + issues: Collection +} + +export function getLinearStore(store: Store): LinearStore { + return { + apiKeys: store.collection('linear.api_keys', ['key']), + organizations: store.collection('linear.organizations', ['linear_id', 'url_key']), + users: store.collection('linear.users', ['linear_id', 'email']), + teams: store.collection('linear.teams', ['linear_id', 'key']), + workflowStates: store.collection('linear.workflow_states', ['linear_id', 'team_id']), + labels: store.collection('linear.labels', ['linear_id', 'team_id']), + projects: store.collection('linear.projects', ['linear_id', 'slug_id', 'team_id']), + issues: store.collection('linear.issues', ['linear_id', 'identifier', 'team_id', 'project_id']), + } +} diff --git a/packages/linear/tsconfig.json b/packages/linear/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/packages/linear/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/linear/tsup.config.ts b/packages/linear/tsup.config.ts new file mode 100644 index 0000000..ff87ed4 --- /dev/null +++ b/packages/linear/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + // tsup injects a deprecated `baseUrl` into the dts build; silence it under TS 6 + dts: { compilerOptions: { ignoreDeprecations: '6.0' } }, +}) diff --git a/skills/linear/SKILL.md b/skills/linear/SKILL.md new file mode 100644 index 0000000..978b4e8 --- /dev/null +++ b/skills/linear/SKILL.md @@ -0,0 +1,92 @@ +--- +name: linear +description: Emulated Linear GraphQL API for local development and testing. Use when the user needs to test Linear API integrations locally, query issues or projects, validate GraphQL clients, or avoid hitting the real Linear API. +allowed-tools: Bash(bun:*), Bash(npx @pleaseai/emulate:*), Bash(curl:*) +--- + +# Linear API Emulator + +Phase 1 provides a read only Linear GraphQL emulator. + +Included now: + +- `POST /graphql` +- GraphQL schema introspection +- PAT authentication with `Authorization: ` +- Query resolvers for `Issue`, `Project`, `Team`, `User`, `Organization`, `Label`, and `WorkflowState` +- Relay style pagination with `edges`, `nodes`, and `pageInfo` + +Follow up work will add mutations, webhooks, OAuth 2.0, and an inspector UI. + +## Start + +```bash +# From this repo (after `bun install && bun run build`) +bun packages/emulate/dist/index.js --service linear + +# Or from the published package +npx @pleaseai/emulate --service linear +``` + +A single service starts on the base port (default `4000`). Use `-p ` to +change it, e.g. `--service linear -p 4012`. When started alongside other +services, ports are assigned sequentially from the base port. + +Default URL (linear alone): + +```text +http://localhost:4000 +``` + +## Auth + +Use a seeded Linear API key as the raw `Authorization` header value. + +```bash +curl http://localhost:4000/graphql \ + -H "Authorization: lin_api_test" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ viewer { id name email } }"}' +``` + +## Query Example + +```bash +curl http://localhost:4000/graphql \ + -H "Authorization: lin_api_test" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ issues(first: 10) { nodes { id identifier title state { name } team { key } } pageInfo { hasNextPage endCursor } } }"}' +``` + +## Seed Config + +Add a `linear:` section to `emulate.config.yaml` (or pass `--seed `): + +```yaml +linear: + api_keys: [lin_api_test] + organizations: + - id: org-1 + name: My Org + teams: + - id: team-1 + name: Engineering + key: ENG + organization: org-1 + workflow_states: + - id: ws-1 + name: Todo + type: unstarted + team: team-1 + users: + - id: user-1 + name: Developer + email: dev@example.com + organization: org-1 + issues: + - id: issue-1 + title: First issue + team: team-1 + state: ws-1 + assignee: user-1 +``` From 2457b38e9a4464de031b038404a28a5f280c0ef8 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 8 Jun 2026 19:17:47 +0900 Subject: [PATCH 2/2] chore(linear): apply code review suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store: index organization_id (users/teams), lead_id (projects), state_id/assignee_id/creator_id (issues) — all fields queried via findBy, per the fork's store convention (avoids full-scan fallback) - resolvers: use Object.hasOwn for directValue field access (own-property only) - tests: cover 405 responses for non-POST methods on /graphql --- packages/linear/src/__tests__/linear.test.ts | 9 +++++++++ packages/linear/src/resolvers.ts | 5 +---- packages/linear/src/store.ts | 8 ++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/linear/src/__tests__/linear.test.ts b/packages/linear/src/__tests__/linear.test.ts index 6a88450..86c3afc 100644 --- a/packages/linear/src/__tests__/linear.test.ts +++ b/packages/linear/src/__tests__/linear.test.ts @@ -321,4 +321,13 @@ describe('Linear GraphQL emulator', () => { expect(res.status).toBe(400) expect(body.errors[0].extensions.code).toBe('BAD_REQUEST') }) + + it('rejects non-POST methods on /graphql with a 405', async () => { + for (const method of ['GET', 'PUT', 'PATCH', 'DELETE']) { + const res = await app.request(`${base}/graphql`, { method }) + const body = (await res.json()) as any + expect(res.status).toBe(405) + expect(body.errors[0].extensions.code).toBe('METHOD_NOT_ALLOWED') + } + }) }) diff --git a/packages/linear/src/resolvers.ts b/packages/linear/src/resolvers.ts index 4c22f94..e130798 100644 --- a/packages/linear/src/resolvers.ts +++ b/packages/linear/src/resolvers.ts @@ -93,10 +93,7 @@ function resolveQuery(fieldName: string, args: Record, context: } function directValue(source: Record, fieldName: string): unknown { - if (fieldName in source) { - return source[fieldName] - } - return undefined + return Object.hasOwn(source, fieldName) ? source[fieldName] : undefined } function resolveOrganization( diff --git a/packages/linear/src/store.ts b/packages/linear/src/store.ts index f1f93c6..14ffff6 100644 --- a/packages/linear/src/store.ts +++ b/packages/linear/src/store.ts @@ -25,11 +25,11 @@ export function getLinearStore(store: Store): LinearStore { return { apiKeys: store.collection('linear.api_keys', ['key']), organizations: store.collection('linear.organizations', ['linear_id', 'url_key']), - users: store.collection('linear.users', ['linear_id', 'email']), - teams: store.collection('linear.teams', ['linear_id', 'key']), + users: store.collection('linear.users', ['linear_id', 'email', 'organization_id']), + teams: store.collection('linear.teams', ['linear_id', 'key', 'organization_id']), workflowStates: store.collection('linear.workflow_states', ['linear_id', 'team_id']), labels: store.collection('linear.labels', ['linear_id', 'team_id']), - projects: store.collection('linear.projects', ['linear_id', 'slug_id', 'team_id']), - issues: store.collection('linear.issues', ['linear_id', 'identifier', 'team_id', 'project_id']), + projects: store.collection('linear.projects', ['linear_id', 'slug_id', 'team_id', 'lead_id']), + issues: store.collection('linear.issues', ['linear_id', 'identifier', 'team_id', 'project_id', 'state_id', 'assignee_id', 'creator_id']), } }