* feat(linear): start sessions from Linear issues Authorize a Linear workspace on this OpenChamber server, map teams to projects, attach an issue from chat, start a session or worktree from an issue, and post started/completed/failed comments that open the session. Hidden in VS Code. * feat(linear): connect more than one Linear workspace Store each OAuth grant on this OpenChamber server and keep one current, so Settings can add and switch workspaces without dropping the others. Project mapping is per workspace. Remove the Linear button next to New Chat; start-from-issue stays on New Worktree. * feat(linear): add a right-hand issues panel Browse and filter issues in the rail, open a card to change status or start a session, and collapse search plus most filters to icons on a narrow panel. * feat(linear): open issues in the rail and filter by Linear status The rail icon only shows after Linear is connected. Clicking a Linear row on work status opens the panel. Status options match the card, including Done, Canceled, and Duplicate. The Integrations experimental warning sits under Third-party integrations. * fix(linear): use stable OAuth callback broker * fix(chat): preview Linear issue attachments The context switch missed linear-issue, so tsc treated the preview helpers as incomplete. * fix(ui): restore Linear i18n parity and the #2903 sync harness Turkish was missing the Linear dictionaries, and the subagent test still wrapped only SyncContext after reads moved to SyncRuntimeContext. * fix(linear): drop changelog hunks and close review races Keep changelogs out of this PR, restore CodeMirror ranges, ignore stale Linear list pages, and leave a persisted Linear tab open until auth has actually resolved. * fix(linear): tint active issue filters and clear them in one click * fix(markdown): read escaped brackets as text, not display math `\[...\]` is display math in LaTeX and an escaped bracket pair in CommonMark. The block tokenizer claimed every `\[`, so prose like `[title \[Bug\] more](url)` was handed to KaTeX: "Bug" rendered as a centered formula and the block token split the paragraph, tearing the link into three pieces. Linear, GitHub and any other source that escapes brackets the way CommonMark requires hit this. Display math now has to own its line — `\[` starts one and `\]` ends one. A formula on its own line still renders; `\[` mid-sentence stays an escape, which is what CommonMark says it is and what prose almost always means. Inline `\(...\)` keeps the same ambiguity, but inline math is legitimately mid-sentence, so there is no position to judge it by. Covered by regression tests, including the verbatim comment body that surfaced this. * feat(linear): make session status comments opt-in and public-only A status comment lands in a Linear workspace the whole team reads, and the link it carried pointed at whatever origin started the session — usually loopback or a LAN address. Everyone but its author got a dead link, and nobody had agreed to the comments in the first place. Comments are now off until the user turns them on in Settings -> Integrations -> Linear, and the check lives on the server: the event hub posts completed and failure without going through the interface, so a client-side gate would not hold. When the resolved origin is not publicly reachable the server posts nothing at all rather than a link only its author can open; `isPublicSessionOrigin` rejects loopback, private LAN, carrier-grade NAT, link-local and single-label hosts. The desktop deep-link origin is gone with it, since no one else can follow one either. The comment body also dropped the session title. It repeated the issue the comment already sits on, and issue titles routinely carry brackets ("[Bug] ...") that broke the markdown link. The body is now one short link, and `sessionTitle` is gone from the route, client and types. Also caps the dedupe file at the newest 500 sessions; it grew forever. * fix(linear): match the pull request panel and clear review findings Comments in the Linear panel now render as the same avatar timeline the pull request panel uses, with the shared time-format preference instead of a raw locale string. Comment authors carry `avatarUrl`, which the GraphQL selection was not requesting. Review findings from the same pass: - `status-runtime.js` hand-rolled `typeof` narrowing and failed the vendored anti-slop lint; it now parses through `parse.js` like every other file in the module. - `useLinearAuthStore` turned any failed request into `connected: false` with `hasChecked: true`. Since the rail icon, the composer entry and the worktree option all gate on `connected === true`, one network blip hid Linear for the rest of the session, and Settings only re-checked when it had never checked. It now keeps the last known status and leaves `hasChecked` false so the next caller retries. - `LinearIssuesView` (1096 lines) was a static import in `ContextPanel`, shipping in the main bundle although its rail icon stays hidden until a workspace is connected. It is lazy now, like `GitView`. - Dropped dead code: the unused port helpers left over from the loopback callback, two re-exported default values nothing read, and a redundant export in `linkedIssues`. - Integrations is no longer badged beta.
662 lines
24 KiB
JavaScript
662 lines
24 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { registerLinearRoutes } from './routes.js';
|
|
import { setLinearAuth, setLinearSessionCommentsEnabled } from './auth.js';
|
|
|
|
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-routes-'));
|
|
|
|
const createApp = () => {
|
|
const app = express();
|
|
registerLinearRoutes(app);
|
|
return app;
|
|
};
|
|
|
|
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
describe('Linear auth routes', () => {
|
|
let dataDir;
|
|
let previousDataDir;
|
|
|
|
beforeEach(() => {
|
|
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
|
|
dataDir = makeTempDir();
|
|
process.env.OPENCHAMBER_DATA_DIR = dataDir;
|
|
process.env.OPENCHAMBER_PORT = '3001';
|
|
process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://127.0.0.1:3001/linear/oauth/callback';
|
|
delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
if (previousDataDir === undefined) {
|
|
delete process.env.OPENCHAMBER_DATA_DIR;
|
|
} else {
|
|
process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
|
|
}
|
|
delete process.env.OPENCHAMBER_PORT;
|
|
delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
|
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('starts authorization and completes it from the public callback', async () => {
|
|
const app = createApp();
|
|
const start = await request(app)
|
|
.post('/api/linear/auth/start')
|
|
.send({ origin: 'desktop' })
|
|
.expect(200);
|
|
|
|
expect(start.body.authorizationUrl).toContain('https://linear.app/oauth/authorize');
|
|
const state = new URL(start.body.authorizationUrl).searchParams.get('state');
|
|
|
|
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
|
const target = String(url);
|
|
if (target === 'https://api.linear.app/oauth/token') {
|
|
return jsonResponse({
|
|
access_token: 'access-1',
|
|
refresh_token: 'refresh-1',
|
|
token_type: 'Bearer',
|
|
expires_in: 86399,
|
|
scope: 'read,write,comments:create',
|
|
});
|
|
}
|
|
if (target === 'https://api.linear.app/graphql') {
|
|
return jsonResponse({
|
|
data: {
|
|
viewer: {
|
|
id: 'user-1',
|
|
name: 'Ada',
|
|
displayName: 'Ada Lovelace',
|
|
email: 'ada@example.com',
|
|
avatarUrl: 'https://example.com/a.png',
|
|
},
|
|
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
|
|
},
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch: ${target}`);
|
|
}));
|
|
|
|
const callback = await request(app)
|
|
.get('/linear/oauth/callback')
|
|
.query({ state, code: 'auth-code' })
|
|
.expect(200);
|
|
|
|
expect(callback.text).toContain('Authorization Complete');
|
|
expect(callback.text).toContain('openchamber://focus/linear-auth');
|
|
|
|
const status = await request(app).get('/api/linear/auth/status').expect(200);
|
|
expect(status.body.connected).toBe(true);
|
|
expect(status.body.user).toEqual({
|
|
id: 'user-1',
|
|
name: 'Ada',
|
|
displayName: 'Ada Lovelace',
|
|
email: 'ada@example.com',
|
|
avatarUrl: 'https://example.com/a.png',
|
|
});
|
|
expect(status.body.organization).toEqual({ id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' });
|
|
expect(status.body.scope).toBe('read,write,comments:create');
|
|
expect(status.body.workspaces).toEqual([{
|
|
id: 'org-1',
|
|
name: 'OpenChamber',
|
|
urlKey: 'openchamber',
|
|
current: true,
|
|
user: {
|
|
id: 'user-1',
|
|
name: 'Ada',
|
|
displayName: 'Ada Lovelace',
|
|
email: 'ada@example.com',
|
|
avatarUrl: 'https://example.com/a.png',
|
|
},
|
|
authorizedAt: expect.any(Number),
|
|
}]);
|
|
expect(JSON.stringify(status.body)).not.toContain('access-1');
|
|
expect(JSON.stringify(status.body)).not.toContain('refresh-1');
|
|
|
|
const again = await request(app).get('/api/linear/auth/status').expect(200);
|
|
expect(again.body.workspaces[0].authorizedAt).toBe(status.body.workspaces[0].authorizedAt);
|
|
});
|
|
|
|
it('never exchanges a code whose state is unknown', async () => {
|
|
const tokenFetch = vi.fn();
|
|
vi.stubGlobal('fetch', tokenFetch);
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.get('/linear/oauth/callback')
|
|
.query({ state: 'forged', code: 'attacker-code' })
|
|
.expect(400);
|
|
expect(tokenFetch).not.toHaveBeenCalled();
|
|
expect(response.text).toContain('Authorization Failed');
|
|
expect(response.text).not.toContain('openchamber://');
|
|
});
|
|
|
|
it('omits the desktop deep link for flows started outside the desktop shell', async () => {
|
|
const app = createApp();
|
|
const start = await request(app)
|
|
.post('/api/linear/auth/start')
|
|
.send({ origin: 'web' })
|
|
.expect(200);
|
|
const state = new URL(start.body.authorizationUrl).searchParams.get('state');
|
|
|
|
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
|
const target = String(url);
|
|
if (target.includes('/oauth/token')) {
|
|
return jsonResponse({
|
|
access_token: 'access-1',
|
|
refresh_token: 'refresh-1',
|
|
expires_in: 86399,
|
|
});
|
|
}
|
|
return jsonResponse({
|
|
data: { viewer: { id: 'user-1', name: 'Ada' }, organization: null },
|
|
});
|
|
}));
|
|
|
|
const response = await request(app)
|
|
.get('/linear/oauth/callback')
|
|
.query({ state, code: 'auth-code' })
|
|
.expect(200);
|
|
expect(response.text).not.toContain('openchamber://');
|
|
});
|
|
|
|
it('disconnects and revokes the refresh token', async () => {
|
|
const app = createApp();
|
|
const start = await request(app).post('/api/linear/auth/start').send({}).expect(200);
|
|
const state = new URL(start.body.authorizationUrl).searchParams.get('state');
|
|
const fetchMock = vi.fn(async (url) => {
|
|
const target = String(url);
|
|
if (target.includes('/oauth/token')) {
|
|
return jsonResponse({
|
|
access_token: 'access-1',
|
|
refresh_token: 'refresh-1',
|
|
expires_in: 86399,
|
|
});
|
|
}
|
|
if (target.includes('/graphql')) {
|
|
return jsonResponse({ data: { viewer: { id: 'user-1', name: 'Ada' } } });
|
|
}
|
|
if (target.includes('/oauth/revoke')) {
|
|
return new Response('', { status: 200 });
|
|
}
|
|
throw new Error(`unexpected fetch: ${target}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await request(app).get('/linear/oauth/callback').query({ state, code: 'auth-code' }).expect(200);
|
|
await request(app).delete('/api/linear/auth').expect(200);
|
|
|
|
const revokeCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/oauth/revoke'));
|
|
expect(revokeCall).toBeTruthy();
|
|
const body = new URLSearchParams(revokeCall[1].body);
|
|
expect(body.get('token')).toBe('refresh-1');
|
|
expect(body.get('token_type_hint')).toBe('refresh_token');
|
|
|
|
const status = await request(app).get('/api/linear/auth/status').expect(200);
|
|
expect(status.body).toEqual({ connected: false });
|
|
});
|
|
|
|
it('stores a second workspace, switches current, and disconnects only that one', async () => {
|
|
const app = createApp();
|
|
|
|
const startA = await request(app).post('/api/linear/auth/start').send({}).expect(200);
|
|
const stateA = new URL(startA.body.authorizationUrl).searchParams.get('state');
|
|
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
|
const target = String(url);
|
|
if (target.includes('/oauth/token')) {
|
|
return jsonResponse({
|
|
access_token: 'access-a',
|
|
refresh_token: 'refresh-a',
|
|
expires_in: 86399,
|
|
scope: 'read,write,comments:create',
|
|
});
|
|
}
|
|
if (target.includes('/graphql')) {
|
|
return jsonResponse({
|
|
data: {
|
|
viewer: { id: 'user-a', name: 'Ada' },
|
|
organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' },
|
|
},
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch: ${target}`);
|
|
}));
|
|
await request(app).get('/linear/oauth/callback').query({ state: stateA, code: 'code-a' }).expect(200);
|
|
|
|
const startB = await request(app).post('/api/linear/auth/start').send({}).expect(200);
|
|
const stateB = new URL(startB.body.authorizationUrl).searchParams.get('state');
|
|
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
|
const target = String(url);
|
|
if (target.includes('/oauth/token')) {
|
|
return jsonResponse({
|
|
access_token: 'access-b',
|
|
refresh_token: 'refresh-b',
|
|
expires_in: 86399,
|
|
scope: 'read,write,comments:create',
|
|
});
|
|
}
|
|
if (target.includes('/graphql')) {
|
|
return jsonResponse({
|
|
data: {
|
|
viewer: { id: 'user-b', name: 'Ben' },
|
|
organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' },
|
|
},
|
|
});
|
|
}
|
|
if (target.includes('/oauth/revoke')) {
|
|
return new Response('', { status: 200 });
|
|
}
|
|
throw new Error(`unexpected fetch: ${target}`);
|
|
}));
|
|
await request(app).get('/linear/oauth/callback').query({ state: stateB, code: 'code-b' }).expect(200);
|
|
|
|
const both = await request(app).get('/api/linear/auth/status').expect(200);
|
|
expect(both.body.organization.id).toBe('org-b');
|
|
expect(both.body.workspaces).toHaveLength(2);
|
|
|
|
await request(app).post('/api/linear/auth/activate').send({}).expect(400);
|
|
await request(app).post('/api/linear/auth/activate').send({ organizationId: 'missing' }).expect(404);
|
|
|
|
const activated = await request(app)
|
|
.post('/api/linear/auth/activate')
|
|
.send({ organizationId: 'org-a' })
|
|
.expect(200);
|
|
expect(activated.body.organization.id).toBe('org-a');
|
|
expect(activated.body.workspaces.find((entry) => entry.id === 'org-a').current).toBe(true);
|
|
expect(activated.body.workspaces.find((entry) => entry.id === 'org-b').current).toBe(false);
|
|
|
|
await request(app).delete('/api/linear/auth').expect(200);
|
|
const remaining = await request(app).get('/api/linear/auth/status').expect(200);
|
|
expect(remaining.body.connected).toBe(true);
|
|
expect(remaining.body.organization.id).toBe('org-b');
|
|
expect(remaining.body.workspaces).toHaveLength(1);
|
|
expect(remaining.body.workspaces[0].id).toBe('org-b');
|
|
});
|
|
|
|
it('lists and gets issues through authenticated routes without leaking tokens', async () => {
|
|
setLinearAuth({
|
|
accessToken: 'access-1',
|
|
refreshToken: 'refresh-1',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 86_400_000,
|
|
scope: 'read',
|
|
});
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
|
const body = JSON.parse(options.body);
|
|
if (body.query.includes('GetLinearIssue')) {
|
|
return jsonResponse({
|
|
data: {
|
|
issue: {
|
|
id: 'issue-1',
|
|
identifier: 'ENG-12',
|
|
title: 'Broken login',
|
|
url: 'https://linear.app/openchamber/issue/ENG-12',
|
|
state: { name: 'Todo', type: 'unstarted' },
|
|
assignee: null,
|
|
description: 'Users cannot sign in.',
|
|
comments: { nodes: [] },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
return jsonResponse({
|
|
data: {
|
|
issues: {
|
|
nodes: [{
|
|
id: 'issue-1',
|
|
identifier: 'ENG-12',
|
|
title: 'Broken login',
|
|
url: 'https://linear.app/openchamber/issue/ENG-12',
|
|
state: { name: 'Todo', type: 'unstarted' },
|
|
assignee: null,
|
|
}],
|
|
pageInfo: { hasNextPage: false, endCursor: null },
|
|
},
|
|
},
|
|
});
|
|
}));
|
|
|
|
const app = createApp();
|
|
const list = await request(app).get('/api/linear/issues/list').expect(200);
|
|
expect(list.body.connected).toBe(true);
|
|
expect(list.body.issues).toHaveLength(1);
|
|
expect(JSON.stringify(list.body)).not.toContain('access-1');
|
|
|
|
const missing = await request(app).get('/api/linear/issues/get').expect(400);
|
|
expect(missing.body.error).toBe('id is required');
|
|
|
|
const got = await request(app).get('/api/linear/issues/get').query({ id: 'ENG-12' }).expect(200);
|
|
expect(got.body.issue.identifier).toBe('ENG-12');
|
|
expect(got.body.issue.description).toBe('Users cannot sign in.');
|
|
expect(got.body.issue.state).toEqual({ id: null, name: 'Todo', type: 'unstarted' });
|
|
});
|
|
|
|
it('passes list filters from query params to Linear', async () => {
|
|
setLinearAuth({
|
|
accessToken: 'access-1',
|
|
refreshToken: 'refresh-1',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 86_400_000,
|
|
scope: 'read',
|
|
});
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
|
const body = JSON.parse(options.body);
|
|
expect(body.variables.filter).toEqual({
|
|
state: { type: { eq: 'completed' } },
|
|
assignee: { isMe: { eq: true } },
|
|
team: { id: { eq: 'team-eng' } },
|
|
priority: { eq: 1 },
|
|
});
|
|
return jsonResponse({
|
|
data: {
|
|
issues: {
|
|
nodes: [],
|
|
pageInfo: { hasNextPage: false, endCursor: null },
|
|
},
|
|
},
|
|
});
|
|
}));
|
|
|
|
const app = createApp();
|
|
const list = await request(app).get('/api/linear/issues/list').query({
|
|
status: 'completed',
|
|
assignee: 'me',
|
|
teamId: 'team-eng',
|
|
priority: 'urgent',
|
|
}).expect(200);
|
|
expect(list.body.connected).toBe(true);
|
|
expect(list.body.issues).toEqual([]);
|
|
});
|
|
|
|
it('lists workflow states and updates issue status without leaking tokens', async () => {
|
|
setLinearAuth({
|
|
accessToken: 'access-1',
|
|
refreshToken: 'refresh-1',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 86_400_000,
|
|
scope: 'write',
|
|
});
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
|
const body = JSON.parse(options.body);
|
|
if (body.query.includes('TeamWorkflowStates')) {
|
|
expect(body.variables.id).toBe('team-eng');
|
|
expect(options.headers.Authorization).toBe('Bearer access-1');
|
|
return jsonResponse({
|
|
data: {
|
|
team: {
|
|
states: {
|
|
nodes: [
|
|
{ id: 'state-todo', name: 'Todo', type: 'unstarted', position: 1 },
|
|
{ id: 'state-done', name: 'Done', type: 'completed', position: 2 },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
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: {
|
|
id: 'issue-uuid-1',
|
|
identifier: 'ENG-12',
|
|
title: 'Broken login',
|
|
url: 'https://linear.app/openchamber/issue/ENG-12',
|
|
state: { id: 'state-done', name: 'Done', type: 'completed' },
|
|
assignee: null,
|
|
description: null,
|
|
comments: { nodes: [] },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}));
|
|
|
|
const app = createApp();
|
|
const missingTeam = await request(app).get('/api/linear/issues/states').expect(400);
|
|
expect(missingTeam.body.error).toBe('teamId is required');
|
|
|
|
const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'team-eng' }).expect(200);
|
|
expect(states.body.connected).toBe(true);
|
|
expect(states.body.states).toEqual([
|
|
{ id: 'state-todo', name: 'Todo', type: 'unstarted', position: 1 },
|
|
{ id: 'state-done', name: 'Done', type: 'completed', position: 2 },
|
|
]);
|
|
expect(JSON.stringify(states.body)).not.toContain('access-1');
|
|
|
|
const missingBody = await request(app).post('/api/linear/issues/update').send({}).expect(400);
|
|
expect(missingBody.body.error).toBe('id and stateId are required');
|
|
|
|
const updated = await request(app).post('/api/linear/issues/update').send({
|
|
id: 'issue-uuid-1',
|
|
stateId: 'state-done',
|
|
}).expect(200);
|
|
expect(updated.body.connected).toBe(true);
|
|
expect(updated.body.issue.identifier).toBe('ENG-12');
|
|
expect(updated.body.issue.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' });
|
|
expect(JSON.stringify(updated.body)).not.toContain('access-1');
|
|
});
|
|
|
|
it('returns 400 for Linear validation and not-found GraphQL errors', async () => {
|
|
setLinearAuth({
|
|
accessToken: 'access-1',
|
|
refreshToken: 'refresh-1',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 86_400_000,
|
|
scope: 'write',
|
|
});
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
|
const body = JSON.parse(options.body);
|
|
if (body.query.includes('TeamWorkflowStates')) {
|
|
return jsonResponse({
|
|
data: null,
|
|
errors: [{
|
|
message: 'Entity not found: Team',
|
|
extensions: {
|
|
code: 'INPUT_ERROR',
|
|
userError: true,
|
|
userPresentableMessage: 'Could not find referenced Team.',
|
|
},
|
|
}],
|
|
});
|
|
}
|
|
return jsonResponse({
|
|
data: null,
|
|
errors: [{
|
|
message: 'Argument Validation Error',
|
|
extensions: {
|
|
code: 'INVALID_INPUT',
|
|
userError: true,
|
|
userPresentableMessage: 'stateId must be a UUID.',
|
|
},
|
|
}],
|
|
});
|
|
}));
|
|
|
|
const app = createApp();
|
|
const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'missing-team' }).expect(400);
|
|
expect(states.body.error).toBe('Could not find referenced Team.');
|
|
|
|
const updated = await request(app).post('/api/linear/issues/update').send({
|
|
id: 'issue-uuid-1',
|
|
stateId: 'not-a-uuid',
|
|
}).expect(400);
|
|
expect(updated.body.error).toBe('stateId must be a UUID.');
|
|
});
|
|
|
|
it('returns disconnected for issue routes when Linear is not connected', async () => {
|
|
const app = createApp();
|
|
const list = await request(app).get('/api/linear/issues/list').expect(200);
|
|
expect(list.body).toEqual({ connected: false });
|
|
const got = await request(app).get('/api/linear/issues/get').query({ id: 'ENG-12' }).expect(200);
|
|
expect(got.body).toEqual({ connected: false });
|
|
const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'team-eng' }).expect(200);
|
|
expect(states.body).toEqual({ connected: false });
|
|
const updated = await request(app).post('/api/linear/issues/update').send({
|
|
id: 'issue-1',
|
|
stateId: 'state-done',
|
|
}).expect(200);
|
|
expect(updated.body).toEqual({ connected: false });
|
|
});
|
|
|
|
it('returns disconnected mapping when Linear is not connected', async () => {
|
|
const app = createApp();
|
|
const mapping = await request(app).get('/api/linear/mapping').expect(200);
|
|
expect(mapping.body).toEqual({ connected: false });
|
|
const saved = await request(app).put('/api/linear/mapping').send({
|
|
defaultProjectPath: '/tmp/project',
|
|
teamProjectPaths: {},
|
|
}).expect(200);
|
|
expect(saved.body).toEqual({ connected: false });
|
|
});
|
|
|
|
it('saves and reads Linear team-to-project mapping without leaking tokens', async () => {
|
|
setLinearAuth({
|
|
accessToken: 'access-1',
|
|
refreshToken: 'refresh-1',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 86_400_000,
|
|
scope: 'read',
|
|
});
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
|
const body = JSON.parse(options.body);
|
|
expect(body.query).toContain('query ListLinearTeams');
|
|
expect(options.headers.Authorization).toBe('Bearer access-1');
|
|
return jsonResponse({
|
|
data: {
|
|
teams: {
|
|
nodes: [
|
|
{ id: 'team-eng', key: 'ENG', name: 'Engineering' },
|
|
{ id: 'team-des', key: 'DES', name: 'Design' },
|
|
],
|
|
pageInfo: { hasNextPage: false, endCursor: null },
|
|
},
|
|
},
|
|
});
|
|
}));
|
|
|
|
const app = createApp();
|
|
const empty = await request(app).get('/api/linear/mapping').expect(200);
|
|
expect(empty.body).toEqual({
|
|
connected: true,
|
|
defaultProjectPath: null,
|
|
teams: [
|
|
{ id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: null },
|
|
{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null },
|
|
],
|
|
});
|
|
expect(JSON.stringify(empty.body)).not.toContain('access-1');
|
|
|
|
const saved = await request(app).put('/api/linear/mapping').send({
|
|
defaultProjectPath: '/Users/ada/openchamber',
|
|
teamProjectPaths: { 'team-eng': '/Users/ada/eng' },
|
|
}).expect(200);
|
|
expect(saved.body).toEqual({
|
|
connected: true,
|
|
defaultProjectPath: '/Users/ada/openchamber',
|
|
teams: [
|
|
{ id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/Users/ada/eng' },
|
|
{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null },
|
|
],
|
|
});
|
|
expect(JSON.stringify(saved.body)).not.toContain('access-1');
|
|
|
|
const reread = await request(app).get('/api/linear/mapping').expect(200);
|
|
expect(reread.body.defaultProjectPath).toBe('/Users/ada/openchamber');
|
|
expect(reread.body.teams[0].projectPath).toBe('/Users/ada/eng');
|
|
});
|
|
|
|
it('posts a session status comment and never leaks the token', async () => {
|
|
setLinearSessionCommentsEnabled(true);
|
|
setLinearAuth({
|
|
accessToken: 'access-1',
|
|
refreshToken: 'refresh-1',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 86_400_000,
|
|
scope: 'read,write,comments:create',
|
|
});
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
|
const body = JSON.parse(options.body);
|
|
if (body.query.includes('query GetLinearIssue')) {
|
|
return jsonResponse({
|
|
data: {
|
|
issue: {
|
|
id: 'issue-1',
|
|
identifier: 'ENG-12',
|
|
title: 'Broken login',
|
|
url: 'https://linear.app/openchamber/issue/ENG-12',
|
|
state: { name: 'Todo', type: 'unstarted' },
|
|
assignee: null,
|
|
description: null,
|
|
comments: { nodes: [] },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
expect(body.query).toContain('mutation CommentCreate');
|
|
return jsonResponse({
|
|
data: {
|
|
commentCreate: {
|
|
success: true,
|
|
comment: { id: 'comment-1' },
|
|
},
|
|
},
|
|
});
|
|
}));
|
|
|
|
const app = createApp();
|
|
const missing = await request(app).post('/api/linear/session-status').send({
|
|
kind: 'started',
|
|
}).expect(400);
|
|
expect(missing.body.error).toBe('kind and sessionId are required');
|
|
|
|
const posted = await request(app).post('/api/linear/session-status').send({
|
|
kind: 'started',
|
|
sessionId: 'ses_1',
|
|
issueIdentifier: 'ENG-12',
|
|
sessionOrigin: 'https://app.example.com',
|
|
}).expect(200);
|
|
expect(posted.body).toEqual({
|
|
connected: true,
|
|
posted: true,
|
|
commentId: 'comment-1',
|
|
});
|
|
expect(JSON.stringify(posted.body)).not.toContain('access-1');
|
|
});
|
|
|
|
it('reads and writes the session-comment preference', async () => {
|
|
const app = createApp();
|
|
const initial = await request(app).get('/api/linear/preferences').expect(200);
|
|
expect(initial.body).toEqual({ sessionComments: false });
|
|
|
|
const invalid = await request(app).put('/api/linear/preferences').send({ sessionComments: 'yes' }).expect(400);
|
|
expect(invalid.body.error).toBe('sessionComments must be a boolean');
|
|
|
|
const enabled = await request(app).put('/api/linear/preferences').send({ sessionComments: true }).expect(200);
|
|
expect(enabled.body).toEqual({ sessionComments: true });
|
|
const reread = await request(app).get('/api/linear/preferences').expect(200);
|
|
expect(reread.body).toEqual({ sessionComments: true });
|
|
});
|
|
|
|
it('returns disconnected session-status when Linear is not connected', async () => {
|
|
const app = createApp();
|
|
const response = await request(app).post('/api/linear/session-status').send({
|
|
kind: 'started',
|
|
sessionId: 'ses_1',
|
|
issueIdentifier: 'ENG-12',
|
|
}).expect(200);
|
|
expect(response.body).toEqual({ connected: false });
|
|
});
|
|
});
|