Files

513 lines
17 KiB
JavaScript
Raw Permalink Normal View History

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { setLinearAuth, clearLinearAuth } from './auth.js';
import { getLinearIssue, listLinearIssues, listLinearIssueStates, parseLinearIssueRef, createLinearIssueComment, updateLinearIssue } from './issues.js';
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-issues-'));
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
const issueNode = {
id: 'issue-uuid-1',
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
priority: 1,
state: { id: 'state-started', name: 'In Progress', type: 'started' },
assignee: { name: 'Ada', displayName: 'Ada Lovelace', avatarUrl: 'https://example.com/a.png' },
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
labels: { nodes: [{ id: 'label-bug', name: 'Bug', color: 'EB5757' }] },
};
describe('parseLinearIssueRef', () => {
it('reads identifiers, URLs, and UUIDs', () => {
expect(parseLinearIssueRef('eng-12')).toEqual({ kind: 'identifier', value: 'ENG-12' });
expect(parseLinearIssueRef('https://linear.app/openchamber/issue/ENG-12/broken-login'))
.toEqual({ kind: 'identifier', value: 'ENG-12' });
expect(parseLinearIssueRef('11111111-2222-3333-4444-555555555555'))
.toEqual({ kind: 'id', value: '11111111-2222-3333-4444-555555555555' });
expect(parseLinearIssueRef('login redirect')).toBeNull();
});
});
describe('Linear issue list/get', () => {
let dataDir;
let previousDataDir;
beforeEach(() => {
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
dataDir = makeTempDir();
process.env.OPENCHAMBER_DATA_DIR = dataDir;
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
expiresAt: Date.now() + 86_400_000,
scope: 'read,write,comments:create',
});
});
afterEach(() => {
vi.unstubAllGlobals();
clearLinearAuth();
if (previousDataDir === undefined) {
delete process.env.OPENCHAMBER_DATA_DIR;
} else {
process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
}
fs.rmSync(dataDir, { recursive: true, force: true });
});
it('returns disconnected without calling Linear when there is no auth', async () => {
clearLinearAuth();
const graphql = vi.fn();
vi.stubGlobal('fetch', graphql);
await expect(listLinearIssues()).resolves.toEqual({ connected: false });
expect(graphql).not.toHaveBeenCalled();
});
it('lists incomplete issues and never returns the token', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.query).toContain('query ListLinearIssues');
expect(body.variables.filter.state.type.nin).toEqual(['completed', 'canceled', 'duplicate']);
expect(options.headers.Authorization).toBe('Bearer access-1');
expect(options.headers['public-file-urls-expire-in']).toBe('3600');
return jsonResponse({
data: {
issues: {
nodes: [issueNode],
pageInfo: { hasNextPage: true, endCursor: 'cursor-2' },
},
},
});
}));
const result = await listLinearIssues();
expect(result).toEqual({
connected: true,
issues: [{
id: 'issue-uuid-1',
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
state: { id: 'state-started', name: 'In Progress', type: 'started' },
assignee: { name: 'Ada', displayName: 'Ada Lovelace', avatarUrl: 'https://example.com/a.png' },
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
priority: 1,
labels: [{ id: 'label-bug', name: 'Bug', color: '#eb5757' }],
}],
cursor: 'cursor-2',
hasMore: true,
});
expect(JSON.stringify(result)).not.toContain('access-1');
});
it('includes priority and labels and drops invalid values', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
data: {
issues: {
nodes: [{
...issueNode,
priority: 9,
labels: {
nodes: [
{ id: 'label-ok', name: 'Bug', color: '#EB5757' },
{ id: 'label-bad-color', name: 'Nope', color: 'red' },
{ id: '', name: 'Missing id' },
],
},
}],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
})));
const result = await listLinearIssues();
expect(result.issues?.[0]?.priority).toBeNull();
expect(result.issues?.[0]?.labels).toEqual([
{ id: 'label-ok', name: 'Bug', color: '#eb5757' },
{ id: 'label-bad-color', name: 'Nope', color: null },
]);
});
it('searches by text and looks up an identifier directly', async () => {
const graphql = vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
if (body.query.includes('SearchLinearIssues')) {
expect(body.variables.term).toBe('login');
return jsonResponse({
data: {
searchIssues: {
nodes: [issueNode],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}
expect(body.variables.id).toBe('ENG-12');
return jsonResponse({
data: {
issue: {
...issueNode,
description: 'Users cannot sign in.',
comments: {
nodes: [{
id: 'comment-1',
body: 'Still broken',
createdAt: '2026-08-24T10:00:00.000Z',
user: { name: 'Ada', displayName: 'Ada Lovelace' },
}],
},
},
},
});
});
vi.stubGlobal('fetch', graphql);
const search = await listLinearIssues({ query: 'login' });
expect(search.issues).toHaveLength(1);
expect(search.hasMore).toBe(false);
const byId = await listLinearIssues({ query: 'https://linear.app/openchamber/issue/ENG-12' });
expect(byId.issues?.[0]?.identifier).toBe('ENG-12');
expect(byId.hasMore).toBe(false);
});
it('applies status, assignee, team, and priority list filters', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.variables.filter).toEqual({
state: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } },
assignee: { isMe: { eq: true } },
team: { id: { eq: 'team-eng' } },
priority: { eq: 1 },
});
return jsonResponse({
data: {
issues: {
nodes: [issueNode],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}));
const result = await listLinearIssues({
status: 'started',
assignee: 'me',
teamId: 'team-eng',
priority: 'urgent',
});
expect(result.issues).toHaveLength(1);
});
it('filters each panel status to a Linear state type or name', async () => {
const filters = [];
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
filters.push(JSON.parse(options.body).variables.filter);
return jsonResponse({
data: {
issues: {
nodes: [issueNode],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}));
await listLinearIssues({ status: 'todo' });
await listLinearIssues({ status: 'backlog' });
await listLinearIssues({ status: 'started' });
await listLinearIssues({ status: 'inReview' });
await listLinearIssues({ status: 'completed' });
await listLinearIssues({ status: 'canceled' });
await listLinearIssues({ status: 'duplicate' });
expect(filters).toEqual([
{ state: { type: { eq: 'unstarted' } } },
{ state: { type: { eq: 'backlog' } } },
{ state: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } } },
{ state: { name: { eqIgnoreCase: 'In Review' } } },
{ state: { type: { eq: 'completed' } } },
{ state: { type: { eq: 'canceled' }, name: { neqIgnoreCase: 'Duplicate' } } },
{ state: { or: [{ type: { eq: 'duplicate' } }, { name: { eqIgnoreCase: 'Duplicate' } }] } },
]);
});
it('omits the state filter when listing all issues', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.variables.filter).toBeUndefined();
return jsonResponse({
data: {
issues: {
nodes: [issueNode],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}));
await listLinearIssues({ status: 'all' });
});
it('filters no-priority issues as Linear priority 0', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.variables.filter).toEqual({
priority: { eq: 0 },
});
return jsonResponse({
data: {
issues: {
nodes: [issueNode],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}));
await listLinearIssues({ status: 'all', priority: 'none' });
});
it('looks up an identifier without applying list filters', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.query).toContain('GetLinearIssue');
expect(body.variables.id).toBe('ENG-12');
expect(body.variables.filter).toBeUndefined();
return jsonResponse({ data: { issue: issueNode } });
}));
const result = await listLinearIssues({
query: 'ENG-12',
status: 'completed',
assignee: 'me',
teamId: 'team-eng',
priority: 'urgent',
});
expect(result.issues?.[0]?.identifier).toBe('ENG-12');
});
it('loads one issue with comments', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
data: {
issue: {
...issueNode,
description: 'Users cannot sign in.',
comments: { nodes: [{ id: 'comment-1', body: 'Still broken', createdAt: '2026-08-24T10:00:00.000Z', user: { name: 'Ada', displayName: null, avatarUrl: 'https://linear.app/avatar/ada.png' } }] },
},
},
})));
const result = await getLinearIssue('ENG-12');
expect(result.connected).toBe(true);
expect(result.issue?.description).toBe('Users cannot sign in.');
expect(result.issue?.priority).toBe(1);
expect(result.issue?.labels).toEqual([{ id: 'label-bug', name: 'Bug', color: '#eb5757' }]);
expect(result.issue?.comments).toEqual([{
id: 'comment-1',
body: 'Still broken',
createdAt: '2026-08-24T10:00:00.000Z',
user: { name: 'Ada', displayName: null, avatarUrl: 'https://linear.app/avatar/ada.png' },
}]);
});
it('creates a comment on the resolved issue UUID', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
if (body.query.includes('query GetLinearIssue')) {
expect(body.variables.id).toBe('ENG-12');
return jsonResponse({
data: {
issue: {
...issueNode,
description: null,
comments: { nodes: [] },
},
},
});
}
expect(body.query).toContain('mutation CommentCreate');
expect(body.variables.input).toEqual({
issueId: 'issue-uuid-1',
body: 'OpenChamber session started.',
});
expect(options.headers.Authorization).toBe('Bearer access-1');
return jsonResponse({
data: {
commentCreate: {
success: true,
comment: { id: 'comment-9' },
},
},
});
}));
const result = await createLinearIssueComment({
issueId: 'ENG-12',
body: 'OpenChamber session started.',
});
expect(result).toEqual({ connected: true, comment: { id: 'comment-9' } });
expect(JSON.stringify(result)).not.toContain('access-1');
});
it('clears auth and reports disconnected after a GraphQL 401', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ errors: [{ message: 'Unauthorized' }] }, 401)));
await expect(listLinearIssues()).resolves.toEqual({ connected: false });
await expect(listLinearIssues()).resolves.toEqual({ connected: false });
});
it('lists team workflow states in Linear workflow order', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.query).toContain('query TeamWorkflowStates');
expect(body.variables.id).toBe('team-eng');
expect(options.headers.Authorization).toBe('Bearer access-1');
return jsonResponse({
data: {
team: {
states: {
nodes: [
{ id: 'state-done', name: 'Done', type: 'completed', position: 0 },
{ id: 'state-review', name: 'In Review', type: 'started', position: 1 },
{ id: 'state-todo', name: 'Todo', type: 'unstarted', position: 0 },
{ id: 'state-dup', name: 'Duplicate', type: 'canceled', position: 1 },
{ id: 'state-progress', name: 'In Progress', type: 'started', position: 0 },
{ id: 'state-backlog', name: 'Backlog', type: 'backlog', position: 0 },
{ id: 'state-canceled', name: 'Canceled', type: 'canceled', position: 0 },
],
},
},
},
});
}));
const result = await listLinearIssueStates('team-eng');
expect(result.states?.map((state) => state.name)).toEqual([
'Backlog',
'Todo',
'In Progress',
'In Review',
'Done',
'Canceled',
'Duplicate',
]);
});
it('rejects workflow states without a team id', async () => {
await expect(listLinearIssueStates('')).rejects.toMatchObject({
message: 'teamId is required',
code: 'INVALID',
});
});
it('updates an issue state and returns the issue', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.query).toContain('mutation IssueUpdate');
expect(body.variables).toEqual({
id: 'issue-uuid-1',
input: { stateId: 'state-done' },
});
expect(options.headers.Authorization).toBe('Bearer access-1');
return jsonResponse({
data: {
issueUpdate: {
success: true,
issue: {
...issueNode,
state: { id: 'state-done', name: 'Done', type: 'completed' },
description: null,
comments: { nodes: [] },
},
},
},
});
}));
const result = await updateLinearIssue({ id: 'issue-uuid-1', stateId: 'state-done' });
expect(result.connected).toBe(true);
expect(result.issue?.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' });
expect(JSON.stringify(result)).not.toContain('access-1');
});
it('rejects an issue update without id or stateId', async () => {
await expect(updateLinearIssue({ id: 'issue-uuid-1' })).rejects.toMatchObject({
message: 'id and stateId are required',
code: 'INVALID',
});
});
it('resolves an issue identifier before issueUpdate', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
if (body.query.includes('query GetLinearIssue')) {
expect(body.variables.id).toBe('ENG-12');
return jsonResponse({
data: {
issue: {
...issueNode,
description: null,
comments: { nodes: [] },
},
},
});
}
expect(body.query).toContain('mutation IssueUpdate');
expect(body.variables).toEqual({
id: 'issue-uuid-1',
input: { stateId: 'state-done' },
});
return jsonResponse({
data: {
issueUpdate: {
success: true,
issue: {
...issueNode,
state: { id: 'state-done', name: 'Done', type: 'completed' },
description: null,
comments: { nodes: [] },
},
},
},
});
}));
const result = await updateLinearIssue({ id: 'ENG-12', stateId: 'state-done' });
expect(result.connected).toBe(true);
expect(result.issue?.id).toBe('issue-uuid-1');
expect(result.issue?.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' });
});
it('surfaces Linear validation constraints from GraphQL errors', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
data: null,
errors: [{
message: 'Argument Validation Error',
extensions: {
code: 'INVALID_INPUT',
userError: true,
userPresentableMessage: 'stateId must be a UUID.',
validationErrors: [{
property: 'stateId',
constraints: { isUuid: 'stateId must be a UUID.' },
}],
},
}],
})));
await expect(updateLinearIssue({ id: 'issue-uuid-1', stateId: 'not-a-uuid' })).rejects.toMatchObject({
name: 'LinearApiError',
message: 'stateId must be a UUID.',
status: 400,
userError: true,
});
});
});