Merge remote-tracking branch 'origin/release/v1.22.0' into custom
# Conflicts: # bun.lock # packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts # packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx # packages/ui/src/components/layout/ContextPanelRail.tsx # packages/ui/src/hooks/useKeyboardShortcuts.ts # packages/ui/src/lib/i18n/messages/de.ts # packages/ui/src/lib/surfaces/DOCUMENTATION.md # packages/web/server/lib/fs/routes.test.js
This commit is contained in:
@@ -11,12 +11,25 @@ live transcript costs O(n^2) work for a result the final decode replaces. The
|
||||
composer shows no text while recording and inserts the full transcript on
|
||||
stop.
|
||||
|
||||
Local TTS (Kokoro via sherpa-onnx OfflineTts) runs in the same worker process
|
||||
and is exposed as `POST /api/dictation/tts/speak` (JSON `{text, speakerId?,
|
||||
speed?, model?}` → WAV bytes; 503 with `reasonCode` while the model is
|
||||
downloading). TTS models live in the same catalog/downloader as STT models
|
||||
(`local/model-catalog.js` `LOCAL_TTS_MODEL_CATALOG`) and are managed by the
|
||||
same status/download/delete routes.
|
||||
Local TTS (Kokoro and Piper/VITS via sherpa-onnx OfflineTts) runs in the same
|
||||
worker process and is exposed as `POST /api/dictation/tts/speak` (JSON
|
||||
`{text, speakerId?, speed?, model?, language?, languageSample?}` → WAV bytes; 503 with
|
||||
`reasonCode` while the model is downloading). TTS models live in the same
|
||||
catalog/downloader as STT models (`local/model-catalog.js`
|
||||
`LOCAL_TTS_MODEL_CATALOG`) and are managed by the same status/download/delete
|
||||
routes.
|
||||
|
||||
Each TTS catalog entry declares the `languages` it speaks. With
|
||||
`language: 'auto'` the service detects the language of `languageSample` — the
|
||||
whole message the chunk belongs to, sent by the client with every chunk — or
|
||||
of `text` when no sample is given
|
||||
(`../tts/language-detect.js`, script plus function-word scoring, no
|
||||
dependencies) and keeps the caller's model when it speaks that language;
|
||||
otherwise it switches to the catalog model for the language, downloading it on
|
||||
first use like any other model, and starts from that model's default speaker
|
||||
(`defaultSpeakerByLanguage`) instead of the caller's speaker id. A language no
|
||||
catalog model covers keeps the caller's model, so text is always spoken. The
|
||||
response carries `X-Speech-Model` and `X-Speech-Language`.
|
||||
|
||||
## Ownership
|
||||
|
||||
|
||||
@@ -68,9 +68,19 @@ export const LOCAL_STT_MODEL_CATALOG = {
|
||||
* Local text-to-speech models (sherpa-onnx OfflineTts). Downloaded and
|
||||
* managed through the same pipeline as the STT models.
|
||||
*/
|
||||
/**
|
||||
* Local text-to-speech models (sherpa-onnx OfflineTts). Downloaded and
|
||||
* managed through the same pipeline as the STT models.
|
||||
*
|
||||
* `languages` lists the languages a model speaks well; the speech service
|
||||
* uses it to pick a model for the language a text is written in. Kokoro
|
||||
* models carry speaker ids (`voices`); a Piper model is one voice for one
|
||||
* language. `lexicon` entries are joined with commas for sherpa-onnx.
|
||||
*/
|
||||
export const LOCAL_TTS_MODEL_CATALOG = {
|
||||
'kokoro-en-v0_19': {
|
||||
type: 'kokoro',
|
||||
languages: ['en'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2',
|
||||
extractedDir: 'kokoro-en-v0_19',
|
||||
@@ -82,6 +92,188 @@ export const LOCAL_TTS_MODEL_CATALOG = {
|
||||
},
|
||||
description: 'Kokoro TTS (English, natural voices)',
|
||||
},
|
||||
'kokoro-multi-lang-v1_1': {
|
||||
type: 'kokoro',
|
||||
languages: ['zh', 'en'],
|
||||
// sherpa-onnx wires this Kokoro build for Chinese and English only;
|
||||
// speakers 0-2 are English, 3-102 Chinese.
|
||||
defaultSpeakerByLanguage: { en: 0, zh: 3 },
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-multi-lang-v1_1.tar.bz2',
|
||||
extractedDir: 'kokoro-multi-lang-v1_1',
|
||||
files: {
|
||||
model: 'model.onnx',
|
||||
voices: 'voices.bin',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
lexiconEnglish: 'lexicon-us-en.txt',
|
||||
lexiconChinese: 'lexicon-zh.txt',
|
||||
},
|
||||
lexicon: ['lexiconEnglish', 'lexiconChinese'],
|
||||
description: 'Kokoro TTS (Chinese and English, 103 voices)',
|
||||
},
|
||||
// The larger `ukrainian_tts-medium` build is a character-level model
|
||||
// (`phoneme_type: text`); sherpa-onnx phonemizes every Piper model through
|
||||
// espeak-ng, which turns that one into noise. `vits-coqui-uk-mai` sounds
|
||||
// better but reads Cyrillic only and drops every Latin word (file names,
|
||||
// product names), which is unusable in a coding chat. Lada is an espeak
|
||||
// model: small, but it reads mixed text.
|
||||
'piper-uk_UA-lada-x_low': {
|
||||
type: 'vits',
|
||||
languages: ['uk'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-uk_UA-lada-x_low.tar.bz2',
|
||||
extractedDir: 'vits-piper-uk_UA-lada-x_low',
|
||||
files: {
|
||||
model: 'uk_UA-lada-x_low.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Piper TTS (Ukrainian)',
|
||||
},
|
||||
'piper-de_DE-thorsten-medium': {
|
||||
type: 'vits',
|
||||
languages: ['de'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-de_DE-thorsten-medium.tar.bz2',
|
||||
extractedDir: 'vits-piper-de_DE-thorsten-medium',
|
||||
files: {
|
||||
model: 'de_DE-thorsten-medium.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Piper TTS (German)',
|
||||
},
|
||||
'piper-fr_FR-siwis-medium': {
|
||||
type: 'vits',
|
||||
languages: ['fr'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-fr_FR-siwis-medium.tar.bz2',
|
||||
extractedDir: 'vits-piper-fr_FR-siwis-medium',
|
||||
files: {
|
||||
model: 'fr_FR-siwis-medium.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Piper TTS (French)',
|
||||
},
|
||||
'piper-es_ES-davefx-medium': {
|
||||
type: 'vits',
|
||||
languages: ['es'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-es_ES-davefx-medium.tar.bz2',
|
||||
extractedDir: 'vits-piper-es_ES-davefx-medium',
|
||||
files: {
|
||||
model: 'es_ES-davefx-medium.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Piper TTS (Spanish)',
|
||||
},
|
||||
'piper-it_IT-paola-medium': {
|
||||
type: 'vits',
|
||||
languages: ['it'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-it_IT-paola-medium.tar.bz2',
|
||||
extractedDir: 'vits-piper-it_IT-paola-medium',
|
||||
files: {
|
||||
model: 'it_IT-paola-medium.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Piper TTS (Italian)',
|
||||
},
|
||||
'piper-pt_BR-faber-medium': {
|
||||
type: 'vits',
|
||||
languages: ['pt'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-pt_BR-faber-medium.tar.bz2',
|
||||
extractedDir: 'vits-piper-pt_BR-faber-medium',
|
||||
files: {
|
||||
model: 'pt_BR-faber-medium.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Piper TTS (Portuguese (Brazil))',
|
||||
},
|
||||
'piper-pl_PL-gosia-medium': {
|
||||
type: 'vits',
|
||||
languages: ['pl'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-pl_PL-gosia-medium.tar.bz2',
|
||||
extractedDir: 'vits-piper-pl_PL-gosia-medium',
|
||||
files: {
|
||||
model: 'pl_PL-gosia-medium.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Piper TTS (Polish)',
|
||||
},
|
||||
'piper-ru_RU-irina-medium': {
|
||||
type: 'vits',
|
||||
languages: ['ru'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-ru_RU-irina-medium.tar.bz2',
|
||||
extractedDir: 'vits-piper-ru_RU-irina-medium',
|
||||
files: {
|
||||
model: 'ru_RU-irina-medium.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Piper TTS (Russian)',
|
||||
},
|
||||
'piper-nl_NL-pim-medium': {
|
||||
type: 'vits',
|
||||
languages: ['nl'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-nl_NL-pim-medium.tar.bz2',
|
||||
extractedDir: 'vits-piper-nl_NL-pim-medium',
|
||||
files: {
|
||||
model: 'nl_NL-pim-medium.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Piper TTS (Dutch)',
|
||||
},
|
||||
'piper-cs_CZ-jirka-medium': {
|
||||
type: 'vits',
|
||||
languages: ['cs'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-cs_CZ-jirka-medium.tar.bz2',
|
||||
extractedDir: 'vits-piper-cs_CZ-jirka-medium',
|
||||
files: {
|
||||
model: 'cs_CZ-jirka-medium.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Piper TTS (Czech)',
|
||||
},
|
||||
'piper-tr_TR-dfki-medium': {
|
||||
type: 'vits',
|
||||
languages: ['tr'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-tr_TR-dfki-medium.tar.bz2',
|
||||
extractedDir: 'vits-piper-tr_TR-dfki-medium',
|
||||
files: {
|
||||
model: 'tr_TR-dfki-medium.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Piper TTS (Turkish)',
|
||||
},
|
||||
'piper-sv_SE-nst-medium': {
|
||||
type: 'vits',
|
||||
languages: ['sv'],
|
||||
archiveUrl:
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-sv_SE-nst-medium.tar.bz2',
|
||||
extractedDir: 'vits-piper-sv_SE-nst-medium',
|
||||
files: {
|
||||
model: 'sv_SE-nst-medium.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
espeakData: 'espeak-ng-data',
|
||||
},
|
||||
description: 'Piper TTS (Swedish)',
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_LOCAL_STT_MODEL = 'parakeet-tdt-0.6b-v2-int8';
|
||||
@@ -131,6 +323,34 @@ export function getLocalSttModelSpec(modelId) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The local TTS model to use for a language, preferring the model the user
|
||||
* selected when it speaks that language. Returns null when no catalog model
|
||||
* covers the language, in which case callers keep the selected model.
|
||||
* @param {string} language BCP-47 primary subtag (`uk`, `zh`...)
|
||||
* @param {string} [preferredModelId]
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function resolveLocalTtsModelForLanguage(language, preferredModelId) {
|
||||
const speaks = (modelId) => LOCAL_TTS_MODEL_CATALOG[modelId]?.languages?.includes(language) === true;
|
||||
if (preferredModelId && speaks(preferredModelId)) return preferredModelId;
|
||||
const candidate = LOCAL_TTS_MODEL_IDS.find(speaks);
|
||||
return candidate ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The speaker id a model should use for a language when the caller's
|
||||
* speaker was chosen for another language. `undefined` keeps the caller's
|
||||
* speaker.
|
||||
* @param {string} modelId
|
||||
* @param {string} language
|
||||
* @returns {number | undefined}
|
||||
*/
|
||||
export function getLocalTtsDefaultSpeaker(modelId, language) {
|
||||
const speaker = LOCAL_TTS_MODEL_CATALOG[modelId]?.defaultSpeakerByLanguage?.[language];
|
||||
return Number.isInteger(speaker) ? speaker : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} modelsDir
|
||||
* @param {string} modelId
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_LOCAL_TTS_MODEL,
|
||||
LOCAL_TTS_MODEL_CATALOG,
|
||||
getLocalSttModelSpec,
|
||||
getLocalTtsDefaultSpeaker,
|
||||
resolveLocalTtsModelForLanguage,
|
||||
} from './model-catalog.js';
|
||||
|
||||
describe('local TTS catalog', () => {
|
||||
it('keeps the selected model when it speaks the language', () => {
|
||||
expect(resolveLocalTtsModelForLanguage('en', DEFAULT_LOCAL_TTS_MODEL)).toBe(DEFAULT_LOCAL_TTS_MODEL);
|
||||
expect(resolveLocalTtsModelForLanguage('zh', 'kokoro-multi-lang-v1_1')).toBe('kokoro-multi-lang-v1_1');
|
||||
});
|
||||
|
||||
it('picks a catalog model for a language the selected model lacks', () => {
|
||||
expect(resolveLocalTtsModelForLanguage('uk', DEFAULT_LOCAL_TTS_MODEL)).toBe('piper-uk_UA-lada-x_low');
|
||||
expect(resolveLocalTtsModelForLanguage('zh', DEFAULT_LOCAL_TTS_MODEL)).toBe('kokoro-multi-lang-v1_1');
|
||||
});
|
||||
|
||||
it('returns null for a language no model covers', () => {
|
||||
expect(resolveLocalTtsModelForLanguage('xx', DEFAULT_LOCAL_TTS_MODEL)).toBeNull();
|
||||
});
|
||||
|
||||
it('gives Chinese a Chinese speaker on the multi-language Kokoro', () => {
|
||||
expect(getLocalTtsDefaultSpeaker('kokoro-multi-lang-v1_1', 'zh')).toBe(3);
|
||||
expect(getLocalTtsDefaultSpeaker('kokoro-multi-lang-v1_1', 'en')).toBe(0);
|
||||
expect(getLocalTtsDefaultSpeaker('piper-uk_UA-lada-x_low', 'uk')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('every TTS entry declares its languages and installable files', () => {
|
||||
for (const [id, spec] of Object.entries(LOCAL_TTS_MODEL_CATALOG)) {
|
||||
expect(spec.languages.length, id).toBeGreaterThan(0);
|
||||
expect(spec.archiveUrl, id).toMatch(/^https:\/\/github\.com\/k2-fsa\/sherpa-onnx\/releases\/download\/tts-models\//);
|
||||
const resolved = getLocalSttModelSpec(id);
|
||||
expect(resolved.requiredFiles, id).toContain(spec.files.model);
|
||||
for (const key of spec.lexicon ?? []) {
|
||||
expect(spec.files[key], `${id} lexicon ${key}`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Sherpa-onnx offline TTS (Kokoro). Runs inside the dictation worker process
|
||||
* Sherpa-onnx offline TTS (Kokoro and Piper/VITS). Runs inside the dictation worker process
|
||||
* only — never load the native addon in the main server process.
|
||||
*/
|
||||
|
||||
@@ -23,20 +23,49 @@ function float32ToPcm16le(samples) {
|
||||
return Buffer.from(out.buffer, out.byteOffset, out.byteLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* sherpa-onnx model config for one catalog entry. Kokoro carries a voices
|
||||
* bank (speaker ids) and optional lexicons; a Piper/VITS model is a single
|
||||
* voice with espeak-ng phonemization.
|
||||
* @param {{ modelDir: string, type?: string, files: Record<string, string>, lexicon?: string[] }} config
|
||||
*/
|
||||
function buildModelConfig(config) {
|
||||
const file = (key, label) => {
|
||||
const filePath = path.join(config.modelDir, config.files[key]);
|
||||
assertFileExists(filePath, label);
|
||||
return filePath;
|
||||
};
|
||||
const modelPath = file('model', 'TTS model');
|
||||
const tokensPath = file('tokens', 'TTS tokens');
|
||||
|
||||
if (config.type === 'vits') {
|
||||
// Piper models phonemize through espeak-ng (`espeakData`); character
|
||||
// models (Coqui) read the text directly and carry no espeak data.
|
||||
const dataDir = config.files.espeakData ? file('espeakData', 'TTS espeak-ng dataDir') : '';
|
||||
return { vits: { model: modelPath, tokens: tokensPath, ...(dataDir ? { dataDir } : {}), lengthScale: 1.0 } };
|
||||
}
|
||||
|
||||
const dataDir = file('espeakData', 'TTS espeak-ng dataDir');
|
||||
const voicesPath = file('voices', 'TTS voices');
|
||||
const lexicon = (config.lexicon ?? []).map((key) => file(key, 'TTS lexicon')).join(',');
|
||||
return {
|
||||
kokoro: {
|
||||
model: modelPath,
|
||||
voices: voicesPath,
|
||||
tokens: tokensPath,
|
||||
dataDir,
|
||||
lengthScale: 1.0,
|
||||
...(lexicon ? { lexicon } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class SherpaTtsEngine {
|
||||
/**
|
||||
* @param {{ modelDir: string, files: { model: string, voices: string, tokens: string, espeakData: string }, numThreads?: number }} config
|
||||
* @param {{ modelDir: string, type?: string, files: Record<string, string>, lexicon?: string[], numThreads?: number }} config
|
||||
*/
|
||||
constructor(config) {
|
||||
const modelPath = path.join(config.modelDir, config.files.model);
|
||||
const voicesPath = path.join(config.modelDir, config.files.voices);
|
||||
const tokensPath = path.join(config.modelDir, config.files.tokens);
|
||||
const dataDir = path.join(config.modelDir, config.files.espeakData);
|
||||
|
||||
assertFileExists(modelPath, 'TTS model');
|
||||
assertFileExists(voicesPath, 'TTS voices');
|
||||
assertFileExists(tokensPath, 'TTS tokens');
|
||||
assertFileExists(dataDir, 'TTS espeak-ng dataDir');
|
||||
const model = buildModelConfig(config);
|
||||
|
||||
const sherpa = loadSherpaOnnxNode();
|
||||
if (typeof sherpa.OfflineTts !== 'function') {
|
||||
@@ -44,15 +73,7 @@ export class SherpaTtsEngine {
|
||||
}
|
||||
|
||||
this.tts = new sherpa.OfflineTts({
|
||||
model: {
|
||||
kokoro: {
|
||||
model: modelPath,
|
||||
voices: voicesPath,
|
||||
tokens: tokensPath,
|
||||
dataDir,
|
||||
lengthScale: 1.0,
|
||||
},
|
||||
},
|
||||
model,
|
||||
numThreads: config.numThreads ?? 2,
|
||||
provider: 'cpu',
|
||||
maxNumSentences: 1,
|
||||
|
||||
@@ -102,7 +102,9 @@ function getTtsEngine(modelsDir, modelId) {
|
||||
const spec = getLocalSttModelSpec(modelId);
|
||||
const created = new SherpaTtsEngine({
|
||||
modelDir: getLocalSttModelDir(modelsDir, modelId),
|
||||
type: spec.type,
|
||||
files: spec.files,
|
||||
lexicon: spec.lexicon,
|
||||
numThreads: 2,
|
||||
});
|
||||
ttsEngines.set(key, created);
|
||||
|
||||
@@ -63,6 +63,8 @@ export function createDictationRuntime({
|
||||
model: typeof req.body?.model === 'string' ? req.body.model : undefined,
|
||||
speakerId: Number.isInteger(req.body?.speakerId) ? req.body.speakerId : undefined,
|
||||
speed: typeof req.body?.speed === 'number' ? req.body.speed : undefined,
|
||||
language: req.body?.language === 'auto' ? 'auto' : undefined,
|
||||
languageSample: typeof req.body?.languageSample === 'string' ? req.body.languageSample.slice(0, 4000) : undefined,
|
||||
});
|
||||
if (result.error) {
|
||||
res.status(503).json({
|
||||
@@ -73,6 +75,8 @@ export function createDictationRuntime({
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', result.format || 'audio/wav');
|
||||
res.setHeader('X-Speech-Model', result.modelId);
|
||||
if (result.language) res.setHeader('X-Speech-Language', result.language);
|
||||
res.send(result.audio);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message || 'Failed to synthesize speech' });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { detectTextLanguage } from '../tts/language-detect.js';
|
||||
/**
|
||||
* Dictation service: resolves STT providers, tracks local model download
|
||||
* state, and exposes a readiness snapshot for the status route.
|
||||
@@ -16,6 +17,8 @@ import { OpenAICompatibleTranscriptionSession } from './openai-compatible-sessio
|
||||
import {
|
||||
DEFAULT_LOCAL_STT_MODEL,
|
||||
DEFAULT_LOCAL_TTS_MODEL,
|
||||
getLocalTtsDefaultSpeaker,
|
||||
resolveLocalTtsModelForLanguage,
|
||||
LOCAL_STT_MODEL_CATALOG,
|
||||
LOCAL_STT_MODEL_IDS,
|
||||
LOCAL_TTS_MODEL_CATALOG,
|
||||
@@ -220,10 +223,30 @@ export function createDictationService({ modelsDir }) {
|
||||
/**
|
||||
* Synthesize speech with the local TTS model. Returns WAV bytes, or a
|
||||
* readiness error while the model is missing/downloading.
|
||||
* @param {{ text: string, model?: string, speakerId?: number, speed?: number }} options
|
||||
*
|
||||
* With `language: 'auto'` the text's language decides the model: the
|
||||
* caller's model when it speaks that language, otherwise the catalog
|
||||
* model for it (downloaded on first use, reported as in-progress until it
|
||||
* lands). The caller's speaker id is kept only on the caller's model; a
|
||||
* substitute model starts from its own default speaker for the language.
|
||||
* A language no catalog model covers keeps the caller's model, so text is
|
||||
* never silently dropped.
|
||||
* `languageSample` is the whole message the chunk belongs to (or a prefix
|
||||
* of it): the language is judged on that, never on a short chunk alone.
|
||||
* @param {{ text: string, model?: string, speakerId?: number, speed?: number, language?: string, languageSample?: string }} options
|
||||
*/
|
||||
const synthesizeSpeech = async ({ text, model, speakerId, speed }) => {
|
||||
const modelId = isLocalTtsModelId(model) ? model : DEFAULT_LOCAL_TTS_MODEL;
|
||||
const synthesizeSpeech = async ({ text, model, speakerId, speed, language, languageSample }) => {
|
||||
const requestedModelId = isLocalTtsModelId(model) ? model : DEFAULT_LOCAL_TTS_MODEL;
|
||||
let modelId = requestedModelId;
|
||||
let resolvedLanguage = null;
|
||||
if (language === 'auto') {
|
||||
resolvedLanguage = detectTextLanguage(languageSample || text).language;
|
||||
const forLanguage = resolveLocalTtsModelForLanguage(resolvedLanguage, requestedModelId);
|
||||
if (forLanguage && forLanguage !== requestedModelId) {
|
||||
modelId = forLanguage;
|
||||
speakerId = getLocalTtsDefaultSpeaker(modelId, resolvedLanguage);
|
||||
}
|
||||
}
|
||||
const installed = await isLocalSttModelInstalled(modelsDir, modelId);
|
||||
if (!installed) {
|
||||
const state = downloadStates.get(modelId);
|
||||
@@ -251,7 +274,7 @@ export function createDictationService({ modelsDir }) {
|
||||
speakerId,
|
||||
speed,
|
||||
});
|
||||
return { audio: result.audio, format: result.format };
|
||||
return { audio: result.audio, format: result.format, modelId, language: resolvedLanguage };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,10 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
|
||||
- `POST /api/fs/exec`
|
||||
- `GET /api/fs/exec/:jobId`
|
||||
- `GET /api/fs/list`
|
||||
- `GET /api/fs/git-dirs` — shallow nested git repository discovery for the
|
||||
Git tab (depth- and visit-capped readdir walk; `.git` directory, file, or
|
||||
symlink marks a repository boundary; junk directories and symlinks are
|
||||
never descended into)
|
||||
- Owns exec job queue state (`execJobs`) and lifecycle/TTL pruning.
|
||||
- Enforces workspace boundary checks with active project + worktree fallback support.
|
||||
- The active project directory is validated with `fs.realpath`, so when the project root is itself a symlink the workspace base no longer matches the paths the client sends. Workspace resolution therefore retries against the raw directory the client requested (`requestedDirectory` from `resolveProjectDirectory`) before falling back to worktree roots. Symlinks are still resolved afterwards, and write/exec routes keep their canonical containment check against the resolved base.
|
||||
|
||||
@@ -297,6 +297,79 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject
|
||||
});
|
||||
};
|
||||
|
||||
// Nested repository discovery bounds: only shallow walks are useful for the
|
||||
// Git tab's "pick a repository" picker, and deep/monorepo trees can explode
|
||||
// otherwise. Directories deeper than maxDepth or beyond the visit cap are
|
||||
// silently not searched.
|
||||
const GIT_DIRS_MAX_DEPTH = 3;
|
||||
const GIT_DIRS_MAX_DIRS = 100;
|
||||
const GIT_DIRS_SKIP_LIST = new Set(['node_modules', 'dist', 'build', '.venv', 'target', '.next']);
|
||||
|
||||
// Walks rootPath and returns every nested git repository path (a directory
|
||||
// containing a `.git` entry — a directory, a worktree pointer file, or a
|
||||
// symlink). A repository boundary stops descent: nested repos inside repos
|
||||
// are not reported. The root itself, when it is a repo, yields no results.
|
||||
const findGitDirectories = async ({ rootPath, fsPromises, path: pathModule, maxDepth, maxDirs }) => {
|
||||
const results = [];
|
||||
let visited = 0;
|
||||
|
||||
const walk = async (dir, depth) => {
|
||||
if (visited >= maxDirs) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dirents;
|
||||
try {
|
||||
dirents = await fsPromises.readdir(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
// Unreadable subtree — skip it unless it is the root itself, which the
|
||||
// route maps to 403/404/500 through the shared error handling.
|
||||
if (dir === rootPath) {
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
visited += 1;
|
||||
|
||||
let isRepoBoundary = false;
|
||||
const subdirectories = [];
|
||||
for (const dirent of dirents) {
|
||||
if (dirent.name === '.git') {
|
||||
isRepoBoundary = true;
|
||||
continue;
|
||||
}
|
||||
if (!dirent.isDirectory() || dirent.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (GIT_DIRS_SKIP_LIST.has(dirent.name)) {
|
||||
continue;
|
||||
}
|
||||
if (depth >= maxDepth) {
|
||||
continue;
|
||||
}
|
||||
subdirectories.push(dirent.name);
|
||||
}
|
||||
|
||||
if (isRepoBoundary) {
|
||||
if (dir !== rootPath) {
|
||||
results.push(dir);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
subdirectories.sort();
|
||||
for (const name of subdirectories) {
|
||||
if (visited >= maxDirs) {
|
||||
break;
|
||||
}
|
||||
await walk(pathModule.join(dir, name), depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
await walk(rootPath, 0);
|
||||
return results;
|
||||
};
|
||||
|
||||
const deriveCloneDirectoryName = (remoteUrl) => {
|
||||
const remote = typeof remoteUrl === 'string' ? remoteUrl.trim() : '';
|
||||
if (!remote) return '';
|
||||
@@ -1607,4 +1680,60 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/fs/git-dirs', async (req, res) => {
|
||||
const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0
|
||||
? req.query.path.trim()
|
||||
: '';
|
||||
if (!rawPath) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveWorkspacePathFromContext({
|
||||
req,
|
||||
targetPath: rawPath,
|
||||
resolveProjectDirectory,
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
const stats = await fsPromises.stat(resolved.resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' });
|
||||
}
|
||||
|
||||
const repositories = await findGitDirectories({
|
||||
rootPath: resolved.resolved,
|
||||
fsPromises,
|
||||
path,
|
||||
maxDepth: GIT_DIRS_MAX_DEPTH,
|
||||
maxDirs: GIT_DIRS_MAX_DIRS,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
path: resolved.resolved,
|
||||
repositories: repositories.map((repoPath) => ({
|
||||
path: repoPath,
|
||||
name: path.basename(repoPath),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error;
|
||||
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
|
||||
if (code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'Directory not found', reason: 'not-found' });
|
||||
}
|
||||
if (isOsPermissionError(err)) {
|
||||
return sendOsPermissionDenied(res, 'Access to directory denied');
|
||||
}
|
||||
console.error('Failed to find git directories:', error);
|
||||
return res.status(500).json({ error: (error && error.message) || 'Failed to find git directories' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -796,6 +796,8 @@ describe('fs read', () => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('fs stat', () => {
|
||||
it('returns exists:false for an outside-workspace path when optional', async () => {
|
||||
const fsPromises = {
|
||||
@@ -1202,6 +1204,225 @@ describe('fs list symlink path space (issue 2627)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('fs git-dirs', () => {
|
||||
const createDirent = (name, type) => ({
|
||||
name,
|
||||
isDirectory: () => type === 'dir',
|
||||
isFile: () => type === 'file',
|
||||
isSymbolicLink: () => type === 'symlink',
|
||||
});
|
||||
|
||||
// tree maps directory path -> [[name, type], ...]
|
||||
const registerGitDirs = (tree, { stat, readdir: readdirOverride } = {}) => {
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
const readdir = readdirOverride ?? vi.fn(async (dirPath) => (tree[dirPath] ?? []).map(([name, type]) => createDirent(name, type)));
|
||||
registerFsRoutes(app, {
|
||||
os: { homedir: () => '/home/user' },
|
||||
path: path.posix,
|
||||
fsPromises: {
|
||||
realpath: async (targetPath) => targetPath,
|
||||
stat: stat ?? vi.fn(async (targetPath) => ({ isDirectory: () => Boolean(tree[targetPath]) })),
|
||||
readdir,
|
||||
},
|
||||
spawn: vi.fn(),
|
||||
crypto: { randomUUID: () => 'job-0' },
|
||||
normalizeDirectoryPath: (p) => p,
|
||||
resolveProjectDirectory: async () => ({ directory: '/workspace' }),
|
||||
buildAugmentedPath: () => '/usr/bin',
|
||||
resolveGitBinaryForSpawn: () => 'git',
|
||||
openchamberUserConfigRoot: '/home/user/.config',
|
||||
});
|
||||
return { handler: getRoute('GET', '/api/fs/git-dirs'), readdir };
|
||||
};
|
||||
|
||||
const callGitDirs = async (handler, query) => {
|
||||
const res = createMockResponse();
|
||||
await handler({ query: query ?? {} }, res);
|
||||
return res;
|
||||
};
|
||||
|
||||
it('returns an empty list when the root itself is a repository', async () => {
|
||||
const { handler, readdir } = registerGitDirs({
|
||||
'/workspace': [['.git', 'dir'], ['proj-a', 'dir']],
|
||||
'/workspace/proj-a': [['.git', 'dir']],
|
||||
});
|
||||
|
||||
const res = await callGitDirs(handler, { path: '/workspace' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ path: '/workspace', repositories: [] });
|
||||
expect(readdir).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('finds nested repositories with a .git directory', async () => {
|
||||
const { handler } = registerGitDirs({
|
||||
'/workspace': [['proj-a', 'dir'], ['proj-b', 'dir']],
|
||||
'/workspace/proj-a': [['.git', 'dir'], ['src', 'dir']],
|
||||
'/workspace/proj-a/src': [['index.ts', 'file']],
|
||||
'/workspace/proj-b': [['.git', 'dir']],
|
||||
});
|
||||
|
||||
const res = await callGitDirs(handler, { path: '/workspace' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.repositories).toEqual([
|
||||
{ path: '/workspace/proj-a', name: 'proj-a' },
|
||||
{ path: '/workspace/proj-b', name: 'proj-b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a .git file (linked worktree) as a repository boundary', async () => {
|
||||
const { handler } = registerGitDirs({
|
||||
'/workspace': [['worktree', 'dir']],
|
||||
'/workspace/worktree': [['.git', 'file']],
|
||||
});
|
||||
|
||||
const res = await callGitDirs(handler, { path: '/workspace' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.repositories).toEqual([{ path: '/workspace/worktree', name: 'worktree' }]);
|
||||
});
|
||||
|
||||
it('stops descending at repository boundaries', async () => {
|
||||
const { handler, readdir } = registerGitDirs({
|
||||
'/workspace': [['outer', 'dir']],
|
||||
'/workspace/outer': [['.git', 'dir'], ['inner', 'dir']],
|
||||
'/workspace/outer/inner': [['.git', 'dir']],
|
||||
});
|
||||
|
||||
const res = await callGitDirs(handler, { path: '/workspace' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.repositories).toEqual([{ path: '/workspace/outer', name: 'outer' }]);
|
||||
expect(readdir).not.toHaveBeenCalledWith('/workspace/outer/inner', { withFileTypes: true });
|
||||
});
|
||||
|
||||
it('does not descend past the depth cap', async () => {
|
||||
const { handler } = registerGitDirs({
|
||||
'/workspace': [['a', 'dir']],
|
||||
'/workspace/a': [['b', 'dir']],
|
||||
'/workspace/a/b': [['c', 'dir']],
|
||||
'/workspace/a/b/c': [['.git', 'dir'], ['d', 'dir']],
|
||||
'/workspace/a/b/c/d': [['.git', 'dir']],
|
||||
});
|
||||
|
||||
const res = await callGitDirs(handler, { path: '/workspace' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.repositories).toEqual([{ path: '/workspace/a/b/c', name: 'c' }]);
|
||||
});
|
||||
|
||||
it('skips junk directories', async () => {
|
||||
const { handler, readdir } = registerGitDirs({
|
||||
'/workspace': [['node_modules', 'dir'], ['dist', 'dir'], ['real', 'dir']],
|
||||
'/workspace/node_modules': [['dep', 'dir']],
|
||||
'/workspace/node_modules/dep': [['.git', 'dir']],
|
||||
'/workspace/dist': [['.git', 'dir']],
|
||||
'/workspace/real': [['.git', 'dir']],
|
||||
});
|
||||
|
||||
const res = await callGitDirs(handler, { path: '/workspace' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.repositories).toEqual([{ path: '/workspace/real', name: 'real' }]);
|
||||
expect(readdir).not.toHaveBeenCalledWith('/workspace/node_modules', { withFileTypes: true });
|
||||
});
|
||||
|
||||
it('never descends into symbolic links', async () => {
|
||||
const { handler } = registerGitDirs({
|
||||
'/workspace': [['link', 'symlink'], ['real', 'dir']],
|
||||
'/workspace/real': [['.git', 'dir']],
|
||||
});
|
||||
|
||||
const res = await callGitDirs(handler, { path: '/workspace' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.repositories).toEqual([{ path: '/workspace/real', name: 'real' }]);
|
||||
});
|
||||
|
||||
it('returns repositories in deterministic order', async () => {
|
||||
const { handler } = registerGitDirs({
|
||||
'/workspace': [['zebra', 'dir'], ['alpha', 'dir']],
|
||||
'/workspace/zebra': [['.git', 'dir']],
|
||||
'/workspace/alpha': [['.git', 'dir']],
|
||||
});
|
||||
|
||||
const res = await callGitDirs(handler, { path: '/workspace' });
|
||||
|
||||
expect(res.body.repositories.map((repo) => repo.name)).toEqual(['alpha', 'zebra']);
|
||||
});
|
||||
|
||||
it('returns 400 when path is missing', async () => {
|
||||
const { handler } = registerGitDirs({});
|
||||
|
||||
const res = await callGitDirs(handler, {});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('Path is required');
|
||||
});
|
||||
|
||||
it('returns 400 when the path is not a directory', async () => {
|
||||
const { handler } = registerGitDirs({
|
||||
'/workspace': [['file.txt', 'file']],
|
||||
}, {
|
||||
stat: vi.fn(async (targetPath) => ({ isDirectory: () => targetPath !== '/workspace/file.txt' })),
|
||||
});
|
||||
|
||||
const res = await callGitDirs(handler, { path: '/workspace/file.txt' });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Specified path is not a directory', reason: 'not-directory' });
|
||||
});
|
||||
|
||||
it('returns 404 when the directory does not exist', async () => {
|
||||
const error = Object.assign(new Error('missing'), { code: 'ENOENT' });
|
||||
const { handler } = registerGitDirs({}, {
|
||||
stat: vi.fn(async () => { throw error; }),
|
||||
});
|
||||
|
||||
const res = await callGitDirs(handler, { path: '/workspace/missing' });
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Directory not found', reason: 'not-found' });
|
||||
});
|
||||
|
||||
for (const code of ['EACCES', 'EPERM']) {
|
||||
it(`maps root ${code} to the os-permission contract`, async () => {
|
||||
const error = Object.assign(new Error('denied'), { code });
|
||||
const { handler } = registerGitDirs({}, {
|
||||
stat: vi.fn(async () => ({ isDirectory: () => true })),
|
||||
readdir: vi.fn(async () => { throw error; }),
|
||||
});
|
||||
|
||||
const res = await callGitDirs(handler, { path: '/workspace' });
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' });
|
||||
});
|
||||
}
|
||||
|
||||
it('skips unreadable subtrees without failing the scan', async () => {
|
||||
const tree = {
|
||||
'/workspace': [['blocked', 'dir'], ['open', 'dir']],
|
||||
'/workspace/open': [['.git', 'dir']],
|
||||
};
|
||||
const blockedError = Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
const { handler } = registerGitDirs(tree, {
|
||||
readdir: vi.fn(async (dirPath) => {
|
||||
if (dirPath === '/workspace/blocked') {
|
||||
throw blockedError;
|
||||
}
|
||||
return (tree[dirPath] ?? []).map(([name, type]) => createDirent(name, type));
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await callGitDirs(handler, { path: '/workspace' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.repositories).toEqual([{ path: '/workspace/open', name: 'open' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fs stat directory scope (issue 3019)', () => {
|
||||
// Wires the real project-directory runtime so the stat route resolves the
|
||||
// workspace exactly as the server does: explicit x-opencode-directory header
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Linear Module Documentation
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns Linear OAuth, issue lookup, Linear-team-to-project mapping, issue status updates, and session status comments on Linear issues. Credentials live on the OpenChamber server, so web, desktop, and a phone paired to that host share them. You can store more than one Linear workspace; exactly one is current. Issue list, mapping, and new OAuth default to the current workspace. Session status comments use the workspace that started the session. The right-hand context panel lists issues for the current workspace, can switch workspace, filters the list, shows a read-only card, changes status or closes the issue, and starts a session or worktree. Start session stays visible in a footer while the issue card scrolls. The chat picker lists issues and attaches them to a message. New Worktree can also start from a Linear issue in the currently active project. A session started from a Linear issue can post started/completed/failure comments, each with an OpenChamber session link. Those comments are opt-in and only appear when this server has a publicly reachable address.
|
||||
|
||||
VS Code omits Linear (`RuntimeAPIs.linear` is optional). Hide Linear UI when the API is missing.
|
||||
|
||||
## Entrypoints and structure
|
||||
|
||||
- `packages/web/server/lib/linear/index.js`: public server entrypoint. `routes.js` loads it lazily with `await import('./index.js')`.
|
||||
- `packages/web/server/lib/linear/routes.js`: Express registration for the public callback, `/api/linear/auth/*`, `/api/linear/issues/*`, `/api/linear/mapping`, and `/api/linear/session-status`.
|
||||
- `packages/web/server/lib/linear/auth.js`: auth file, client id, scopes, redirect URI.
|
||||
- `packages/web/server/lib/linear/oauth.js`: authorization-code + PKCE S256, public callback broker handoff, refresh, revoke.
|
||||
- `packages/web/server/lib/linear/client.js`: GraphQL helper, viewer/organization lookup, and access-token refresh. GraphQL errors prefer `extensions.userPresentableMessage` / validation constraints over the generic `Argument Validation Error` label. User-facing Linear errors set `LinearApiError.userError`. Requests send `public-file-urls-expire-in: 3600` so file URLs in issue descriptions and comments are temporarily readable in the panel.
|
||||
- `packages/web/server/lib/linear/issues.js`: list/search/get issues, team workflow states, `issueUpdate`, and `commentCreate`. Parses identifiers and Linear URLs. `issueUpdate` resolves identifiers to UUIDs first because Linear's mutation does not accept `ENG-12`. List/get include `state.id`, `priority` (0–4), and labels (`id`, `name`, sanitized hex `color`) so the panel can show them and update status.
|
||||
- `packages/web/server/lib/linear/teams.js`: list Linear teams for mapping UI.
|
||||
- `packages/web/server/lib/linear/mapping.js`: persist default and per-team OpenChamber project paths. Separate from the auth file so disconnect does not wipe maps.
|
||||
- `packages/web/server/lib/linear/status.js`: persist per-session started/completed/failure flags and post the matching Linear comment with an open-session URL. Posts nothing unless the user opted in and the session origin is public; `isPublicSessionOrigin` rejects loopback, private LAN, carrier-grade NAT, link-local and single-label hosts. The dedupe file keeps the newest 500 sessions.
|
||||
- `packages/web/server/lib/linear/status-runtime.js`: on the OpenCode event hub, first `session.status` idle after started posts completed once; `session.error` (except abort) posts failure once.
|
||||
- `packages/web/src/api/linear.ts`: web client wrapper. Electron and hosted/Capacitor mobile reuse it. VS Code omits `linear`.
|
||||
|
||||
## Public routes
|
||||
|
||||
- `GET /linear/oauth/callback`: public fallback for an explicitly configured direct redirect URI. The built-in flow uses the stable callback broker instead, because desktop and self-hosted instances may have private or dynamic addresses.
|
||||
- `GET /api/linear/auth/status`: connected flag, current user/organization/scope, and `workspaces` (id, name, current, user, authorizedAt). Never returns tokens. A 401 on the current workspace drops that workspace only; if another remains, status returns that one instead of disconnected. Identity refresh does not bump `authorizedAt`.
|
||||
- `POST /api/linear/auth/start`: returns `{ authorizationUrl, expiresIn, scope }`. Body may include `origin: "desktop"` so the callback page can raise the desktop window. The authorize URL uses `prompt=consent` so Add workspace can pick a different Linear org. Completing OAuth stores or replaces that org and makes it current.
|
||||
- `POST /api/linear/auth/activate`: body `{ organizationId }`. Makes that stored workspace current. 400 if the id is missing, 404 if it is not stored.
|
||||
- `DELETE /api/linear/auth`: revokes the current workspace refresh token when present, then drops that workspace only. Other stored workspaces stay. Mapping is kept.
|
||||
- `GET /api/linear/issues/list?query=&cursor=&status=&assignee=&teamId=&priority=`: issues from the current workspace. Omitted `status` is incomplete states (same as the chat picker). The panel sends `all`, `backlog`, `todo` (Linear `unstarted`), `started` (In Progress, excluding the In Review name), `inReview` (state name In Review), `completed` (Done), `canceled` (excluding the Duplicate name), or `duplicate` (state type or name Duplicate). `assignee` is `any` (default) or `me`. `teamId` limits the list to that Linear team. `priority` is `all` (default), `none`, `urgent`, `high`, `medium`, or `low`. An identifier or Linear URL returns that issue even if it is completed and ignores the other filters. Each issue includes `state.id` when Linear sends it, plus `priority` (0 none through 4 low) and `labels`. Never returns tokens.
|
||||
- `GET /api/linear/issues/get?id=`: one issue by UUID or identifier, including description, comments, team, `state.id`, priority, and labels.
|
||||
- `GET /api/linear/issues/states?teamId=`: workflow states for that Linear team (`id`, `name`, `type`, `position`), ordered like Linear's workflow: type (backlog, unstarted, started, completed, canceled) then position. Missing `teamId` is 400. Linear not-found or validation errors are 400 with Linear's presentable message. Disconnected is `{ connected: false }` with HTTP 200.
|
||||
- `POST /api/linear/issues/update`: body `{ id, stateId }`. `id` may be a UUID, identifier, or Linear URL; identifiers are resolved before `issueUpdate` because Linear's mutation requires a UUID. Returns the updated issue. Closing an issue is this same call with the team's first `type: completed` state. Missing `id` or `stateId` is 400. Linear validation (for example a non-UUID `stateId`) is 400 with Linear's presentable message. A GraphQL 401 clears that workspace only. Disconnected is `{ connected: false }` with HTTP 200.
|
||||
- `GET /api/linear/mapping`: stored default project plus live Linear teams with their mapped paths. Missing file is empty mapping. Malformed file is 500, not empty success. Disconnected is `{ connected: false }` with HTTP 200.
|
||||
- `PUT /api/linear/mapping`: replace default project and per-team paths. Body `{ defaultProjectPath, teamProjectPaths }`. Failed write does not touch tokens. Disconnected is `{ connected: false }` and does not save.
|
||||
- `GET /api/linear/preferences`: `{ sessionComments }`. `PUT /api/linear/preferences` with body `{ sessionComments: boolean }` replaces it and returns the stored value. A non-boolean body is 400. The preference is server-side because the event hub posts completed/failure without going through the interface.
|
||||
- `POST /api/linear/session-status`: post a started/completed/failure comment on the linked Linear issue. Body `{ kind, sessionId, issueIdentifier?, sessionOrigin? }`. `started` requires `issueIdentifier`. `completed` and `failure` reuse the stored issue and open URL from `started`. Each kind posts at most once per session. Answers in this order: disconnected is `{ connected: false }` with HTTP 200; comments turned off is `skipped: 'disabled'`; a `sessionOrigin` nobody else can reach is `skipped: 'origin-not-public'`. `sessionOrigin` must be `http` or `https` with no path, and must resolve to a public host — loopback, private LAN and desktop deep links post no comment at all rather than a link only its author can open. Comment bodies are one markdown link: `[OpenChamber session started](url)` so Linear keeps the `?session=` query. The comment carries no issue or session title: it already sits on the issue, and titles routinely contain brackets that would break the link. Invalid body is 400.
|
||||
|
||||
`POST /api/linear/auth/start`, `PUT /api/linear/mapping`, `POST /api/linear/issues/update`, and `POST /api/linear/session-status` parse JSON on the route (`16kb`). They are not on the `/api` 50mb allowlist.
|
||||
|
||||
Disconnected list/get/states/update/mapping/session-status return `{ connected: false }` with HTTP 200 so the picker and panel can show an empty state. Missing `id` on get is 400. Missing `teamId` on states is 400.
|
||||
|
||||
## Auth storage and config
|
||||
|
||||
- Auth storage: `~/.config/openchamber/linear-auth.json` (or `$OPENCHAMBER_DATA_DIR/linear-auth.json`). Shape is `{ workspaces: [ { accessToken, refreshToken, user, organization, workspaceId, current, authorizedAt, ... } ] }`. `workspaceId` is the Linear organization id, or `user:<id>` when there is no org, or `legacy` for a migrated token with neither. A legacy single-object file is rewritten to this list on read. Reconnecting the same org replaces that slot.
|
||||
- Mapping storage: `~/.config/openchamber/linear-mapping.json` (same data dir). Shape is `{ workspaces: { [workspaceId]: { defaultProjectPath, teamProjectPaths } } }`. Reads and writes use the current workspace slice. A legacy flat file is wrapped under the current workspace id on read. Disconnect does not wipe maps. Writes are atomic and file mode is `0o600`.
|
||||
- Session status storage: `~/.config/openchamber/linear-session-status.json` (same data dir). Writes are atomic and file mode is `0o600`. Dedupes started/completed/failure per OpenChamber session id.
|
||||
- Writes are atomic and file mode is `0o600`.
|
||||
- Client ID: `OPENCHAMBER_LINEAR_CLIENT_ID` -> `settings.json` `linearClientId` -> baked-in public default.
|
||||
- Client secret: `OPENCHAMBER_LINEAR_CLIENT_SECRET` -> `settings.json` `linearClientSecret`. Optional with PKCE. Do not commit a secret.
|
||||
- Scopes: `OPENCHAMBER_LINEAR_SCOPES` -> `settings.json` `linearScopes` -> `read,write,comments:create`.
|
||||
- Session comments: `settings.json` `linearSessionComments`, boolean, absent means off. Written only through `PUT /api/linear/preferences`.
|
||||
- Broker URL: `OPENCHAMBER_LINEAR_BROKER_URL` -> `settings.json` `linearBrokerUrl` -> `https://api.openchamber.dev/v1/oauth/linear`.
|
||||
- Redirect URI: `OPENCHAMBER_LINEAR_REDIRECT_URI` -> `settings.json` `linearRedirectUri` -> `<broker-url>/callback`. Setting an explicit redirect URI bypasses the broker for custom/self-hosted OAuth applications.
|
||||
|
||||
Linear requires an exact callback match. The built-in application registers `https://api.openchamber.dev/v1/oauth/linear/callback`; the broker holds only the short-lived authorization code. The local OpenChamber server keeps the claim secret and PKCE verifier, exchanges the code for tokens locally, then acknowledges the handoff. Custom brokers must expose `/start`, `/callback`, `/poll`, and `/complete` with the same contract.
|
||||
|
||||
## OAuth contract
|
||||
|
||||
- Authorization code + PKCE S256. Linear has no device flow.
|
||||
- The broker stores hashes of OAuth state and a separate claim secret for ten minutes. It never receives the PKCE verifier or Linear tokens. The local status polling path claims a completed broker result and persists tokens on the OpenChamber server.
|
||||
- Access tokens expire in 24 hours. Refresh tokens rotate; persist the new refresh token from every successful refresh. Concurrent refreshes share one in-flight promise per workspace.
|
||||
- `invalid_grant` / 401 on refresh clears that workspace only so a dead token cannot loop. If it was the last workspace, status becomes disconnected.
|
||||
- A GraphQL 401 after a valid-looking token also clears that workspace. A network failure while a token is stored does not: status stays connected with the last known user.
|
||||
|
||||
## Project mapping
|
||||
|
||||
OpenChamber has projects (directories), not accounts or organizations. Mapping is how create-session (picker and the right-hand panel) picks a directory:
|
||||
|
||||
1. If the issue's Linear team has a project path, use that.
|
||||
2. Otherwise use the default project path.
|
||||
3. If neither is set, the UI tells the user to map the team in Settings → Integrations. It does not fall back to the currently active project.
|
||||
|
||||
A worktree started from the panel or picker is created in that mapped project. New Worktree from Git is different: it stays in the currently active project.
|
||||
|
||||
## Shared UI
|
||||
|
||||
- `RuntimeAPIs.linear` is optional. Hide Linear settings, the chat picker, and the panel when it is missing (VS Code).
|
||||
- Store: `packages/ui/src/stores/useLinearAuthStore.ts`. App start refreshes it from `App.tsx` and `MobileApp.tsx`, not `VSCodeApp`.
|
||||
- Settings: first-party section on the Integrations page. Connect opens the authorization URL and polls status until the workspace list or current `authorizedAt` changes, so Add workspace is not treated as done just because a workspace was already connected. When connected, map a default project and optional per-team projects for the current workspace. Other stored workspaces appear in a list with Switch to. Disconnect removes the current workspace only. The panel can also switch the current workspace when more than one is stored.
|
||||
- Context panel: desktop/web right-hand rail surface `linear` (`packages/ui/src/components/views/LinearIssuesView.tsx`). Singleton like git/pr. The rail icon is hidden until a Linear workspace is connected; disconnecting while the panel is open closes it. List/search defaults to all issues; the status filter is All, Backlog, To Do, In Progress, In Review, Done, Canceled, and Duplicate, matching the card status order. Identifier/URL still finds completed. Status, assignee, team, and priority filters persist in `useUIStore` so they survive rail switches. Non-default list filters and search tint the filter icon `text-primary`, same as the context rail; one control clears them, not the workspace switch. Changing those filters keeps the previous list until the next page arrives and does not disable the filter row. On a narrow panel search and the filters other than status drop to icons; status keeps its label. The card shows priority and labels. Comments render as an avatar timeline matching the pull request panel, so both context surfaces read alike; comment authors carry `avatarUrl`. The card is read-only except status (`issueUpdate`) and Close (first completed workflow state). Start session stays in a footer while the description and comments scroll. Start session / worktree share `startLinearIssueSession` with the picker. No create-issue, no writing comments, no polling. VS Code and the mobile workspace drawer omit this rail.
|
||||
- Chat: composer attach menu "Link Linear Issue" attaches body and comments as `linear-issue` context on the next send. Exclusive with a linked GitHub issue or PR. The attached issue is stored on session metadata (`kind: 'linear'`) so work status can show it. Clicking that work-status row opens the Linear rail when Linear is connected on desktop/web; otherwise the Linear URL. Managed Chats do not offer start-from-issue; those sessions have no project directory.
|
||||
- Worktree: New Worktree can start from a Linear issue. It uses the currently active project and does not consult team-to-project mapping. GitHub issue/PR and Linear issue are exclusive on that form.
|
||||
- Status comments: off until the user turns them on in Settings -> Integrations -> Linear (`LinearSessionComments.tsx`). When on, create-session and worktree-from-Linear post `started` after the session exists. The event hub posts `completed` on the first idle after that, and `failure` on `session.error` except `MessageAbortedError`. Failed comments must not fail session create. Comment bodies are English (they live on Linear) and are one markdown link named `OpenChamber session started` (or completed/failed). Web uses `/?session=<id>` on the current origin; desktop reports the loopback origin its own server listens on, not `openchamber-ui://`. A Linear comment is read by the whole team, so the server posts nothing when that origin is not publicly reachable rather than publishing a link only its author could open. Opening `/?session=` selects that session after the global session list can resolve its directory.
|
||||
- Magic prompts: `linear.issue.review.visible` / `.instructions`. Do not reuse the GitHub issue-review templates for Linear.
|
||||
|
||||
## Notes for contributors
|
||||
|
||||
The implementation and deployment hand-off for the stable callback broker is
|
||||
in [`OAUTH-BROKER-HANDOFF.md`](./OAUTH-BROKER-HANDOFF.md). It records the exact
|
||||
Linear redirect URI that must be registered and why the original loopback
|
||||
callback could not support packaged desktop or arbitrary self-hosted servers.
|
||||
|
||||
- Do not log tokens, codes, verifiers, or the client secret.
|
||||
- Do not add Linear under Git or as a third-party plugin row.
|
||||
- Actor is `user`. Do not enable Linear client-credentials tokens for this flow.
|
||||
- One OAuth grant is still one Linear organization. The server stores many grants and keeps one current. Webhooks and inbound Linear issue actions are out of scope until a later change.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Linear OAuth broker hand-off
|
||||
|
||||
## Required Linear application change
|
||||
|
||||
Register this exact redirect URI in the Linear OAuth application used by the
|
||||
baked-in client ID:
|
||||
|
||||
`https://api.openchamber.dev/v1/oauth/linear/callback`
|
||||
|
||||
Linear compares the full redirect URI, including scheme, host, path, and port.
|
||||
Deploy the API broker and apply its D1 migration before testing this branch.
|
||||
|
||||
## Why the original callback failed
|
||||
|
||||
The first implementation redirected Linear back to the OpenChamber server:
|
||||
|
||||
`http://127.0.0.1:<listen-port>/linear/oauth/callback`
|
||||
|
||||
That address is not stable across OpenChamber runtimes:
|
||||
|
||||
- packaged desktop prefers its stored local port and can select another free
|
||||
port when needed;
|
||||
- local development and the CLI use different ports;
|
||||
- self-hosted servers may sit behind a reverse proxy or have no public inbound
|
||||
address at all.
|
||||
|
||||
Linear requires an exact pre-registered callback. Registering every possible
|
||||
desktop or self-hosted address is impossible, and forcing desktop onto one port
|
||||
would make startup fail whenever another process owns that port.
|
||||
|
||||
## New flow
|
||||
|
||||
The built-in Linear client now uses the stable callback broker in
|
||||
`openchamber-website/apps/api`:
|
||||
|
||||
1. The OpenChamber server generates OAuth state, a PKCE verifier, and a separate
|
||||
claim secret.
|
||||
2. The broker stores only hashes of state and the claim secret for ten minutes.
|
||||
3. Linear sends its authorization code to the stable public callback.
|
||||
4. The OpenChamber server polls the broker with state and the claim secret.
|
||||
5. The OpenChamber server exchanges the code using the PKCE verifier and stores
|
||||
the Linear tokens locally.
|
||||
6. After persistence succeeds, OpenChamber acknowledges the hand-off and the
|
||||
broker marks it consumed.
|
||||
|
||||
The broker never receives the PKCE verifier, access token, or refresh token.
|
||||
Private Relay is not involved; the local server only needs outbound HTTPS.
|
||||
|
||||
## Compatibility and configuration
|
||||
|
||||
- `OPENCHAMBER_LINEAR_BROKER_URL` or `settings.json` `linearBrokerUrl` selects a
|
||||
self-hosted broker. The default is
|
||||
`https://api.openchamber.dev/v1/oauth/linear`.
|
||||
- `OPENCHAMBER_LINEAR_REDIRECT_URI` or `settings.json` `linearRedirectUri`
|
||||
bypasses the broker and preserves the direct callback flow for a custom
|
||||
Linear OAuth application.
|
||||
|
||||
## Owning files
|
||||
|
||||
OpenChamber:
|
||||
|
||||
- `auth.js`: broker and redirect configuration.
|
||||
- `oauth.js`: PKCE, broker registration/poll/acknowledgement, token exchange.
|
||||
- `routes.js`: starts authorization and completes broker results during status
|
||||
polling.
|
||||
|
||||
Hosted API, in the `openchamber-website` repository:
|
||||
|
||||
- `apps/api/src/routes/linear-oauth.ts`
|
||||
- `apps/api/migrations/0010_linear_oauth_transactions.sql`
|
||||
- `apps/api/LINEAR-OAUTH.md`
|
||||
|
||||
## Validation
|
||||
|
||||
OpenChamber focused tests:
|
||||
|
||||
```sh
|
||||
bunx vitest run \
|
||||
packages/web/server/lib/linear/oauth.test.js \
|
||||
packages/web/server/lib/linear/auth.test.js \
|
||||
packages/web/server/lib/linear/routes.test.js
|
||||
```
|
||||
|
||||
Hosted API checks:
|
||||
|
||||
```sh
|
||||
cd apps/api
|
||||
bun test src/routes/linear-oauth.test.ts
|
||||
bun run check
|
||||
bun run build
|
||||
```
|
||||
@@ -0,0 +1,436 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { isPlainObject, readEnv, readFiniteNumber, readTrimmedString } from './parse.js';
|
||||
|
||||
const DEFAULT_LINEAR_CLIENT_ID = '91bbe26a69a2c8568d3683f1e01e776c';
|
||||
const DEFAULT_LINEAR_SCOPES = 'read,write,comments:create';
|
||||
const DEFAULT_LINEAR_BROKER_URL = 'https://api.openchamber.dev/v1/oauth/linear';
|
||||
const ACCESS_TOKEN_REFRESH_SKEW_MS = 2 * 60_000;
|
||||
const LEGACY_WORKSPACE_ID = 'legacy';
|
||||
const SESSION_COMMENTS_SETTING_KEY = 'linearSessionComments';
|
||||
|
||||
function resolveDataDir() {
|
||||
const fromEnv = readEnv('OPENCHAMBER_DATA_DIR');
|
||||
if (fromEnv) {
|
||||
return path.resolve(fromEnv);
|
||||
}
|
||||
return path.join(os.homedir(), '.config', 'openchamber');
|
||||
}
|
||||
|
||||
function storageFile() {
|
||||
return path.join(resolveDataDir(), 'linear-auth.json');
|
||||
}
|
||||
|
||||
function settingsFile() {
|
||||
return path.join(resolveDataDir(), 'settings.json');
|
||||
}
|
||||
|
||||
function ensureStorageDir() {
|
||||
const dir = resolveDataDir();
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!isPlainObject(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error('Failed to read Linear auth file:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonFile(filePath, payload) {
|
||||
ensureStorageDir();
|
||||
const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
|
||||
try {
|
||||
fs.chmodSync(tmpFile, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
fs.renameSync(tmpFile, filePath);
|
||||
try {
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUser(user) {
|
||||
if (!isPlainObject(user)) {
|
||||
return null;
|
||||
}
|
||||
const id = readTrimmedString(user.id);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name: readTrimmedString(user.name) || null,
|
||||
displayName: readTrimmedString(user.displayName) || null,
|
||||
email: readTrimmedString(user.email) || null,
|
||||
avatarUrl: readTrimmedString(user.avatarUrl) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOrganization(organization) {
|
||||
if (!isPlainObject(organization)) {
|
||||
return null;
|
||||
}
|
||||
const id = readTrimmedString(organization.id);
|
||||
const name = readTrimmedString(organization.name);
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
urlKey: readTrimmedString(organization.urlKey) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLinearWorkspaceId({ organization, user, workspaceId } = {}) {
|
||||
const explicit = readTrimmedString(workspaceId);
|
||||
if (explicit) return explicit;
|
||||
const organizationId = organization ? readTrimmedString(organization.id) : '';
|
||||
if (organizationId) return organizationId;
|
||||
const userId = user ? readTrimmedString(user.id) : '';
|
||||
if (userId) return `user:${userId}`;
|
||||
return LEGACY_WORKSPACE_ID;
|
||||
}
|
||||
|
||||
function normalizeAuthEntry(raw) {
|
||||
if (!isPlainObject(raw)) {
|
||||
return null;
|
||||
}
|
||||
const accessToken = readTrimmedString(raw.accessToken);
|
||||
if (!accessToken) {
|
||||
return null;
|
||||
}
|
||||
const user = normalizeUser(raw.user);
|
||||
const organization = normalizeOrganization(raw.organization);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: readTrimmedString(raw.refreshToken) || null,
|
||||
tokenType: readTrimmedString(raw.tokenType) || 'bearer',
|
||||
expiresAt: readFiniteNumber(raw.expiresAt),
|
||||
scope: readTrimmedString(raw.scope),
|
||||
createdAt: readFiniteNumber(raw.createdAt),
|
||||
authorizedAt: readFiniteNumber(raw.authorizedAt) || readFiniteNumber(raw.createdAt),
|
||||
user,
|
||||
organization,
|
||||
current: Boolean(raw.current),
|
||||
workspaceId: resolveLinearWorkspaceId({
|
||||
organization,
|
||||
user,
|
||||
workspaceId: raw.workspaceId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAuthList(raw) {
|
||||
const source = Array.isArray(raw?.workspaces)
|
||||
? raw.workspaces
|
||||
: (raw?.accessToken ? [raw] : []);
|
||||
const list = source.map((entry) => normalizeAuthEntry(entry)).filter(Boolean);
|
||||
|
||||
if (!list.length) {
|
||||
return { list: [], changed: Boolean(raw && (raw.accessToken || Array.isArray(raw.workspaces))) };
|
||||
}
|
||||
|
||||
let changed = Array.isArray(raw?.workspaces) === false && Boolean(raw?.accessToken);
|
||||
const seen = new Set();
|
||||
const deduped = [];
|
||||
for (const entry of list) {
|
||||
if (seen.has(entry.workspaceId)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
seen.add(entry.workspaceId);
|
||||
deduped.push(entry);
|
||||
}
|
||||
|
||||
let currentFound = false;
|
||||
deduped.forEach((entry) => {
|
||||
if (entry.current && !currentFound) {
|
||||
currentFound = true;
|
||||
} else if (entry.current && currentFound) {
|
||||
entry.current = false;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!currentFound && deduped[0]) {
|
||||
deduped[0].current = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return { list: deduped, changed };
|
||||
}
|
||||
|
||||
function readAuthList() {
|
||||
const data = readJsonFile(storageFile());
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
const { list, changed } = normalizeAuthList(data);
|
||||
if (changed) {
|
||||
writeAuthList(list);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function writeAuthList(list) {
|
||||
if (!list.length) {
|
||||
const filePath = storageFile();
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
writeJsonFile(storageFile(), { workspaces: list });
|
||||
}
|
||||
|
||||
function readSettings() {
|
||||
return readJsonFile(settingsFile()) || {};
|
||||
}
|
||||
|
||||
function writeSettings(settings) {
|
||||
writeJsonFile(settingsFile(), settings);
|
||||
}
|
||||
|
||||
function readSettingString(key) {
|
||||
const stored = readSettings()[key];
|
||||
return readTrimmedString(stored);
|
||||
}
|
||||
|
||||
export function getLinearAuth() {
|
||||
const list = readAuthList();
|
||||
if (!list.length) {
|
||||
return null;
|
||||
}
|
||||
return list.find((entry) => entry.current) || list[0];
|
||||
}
|
||||
|
||||
export function getLinearAuthByWorkspaceId(workspaceId) {
|
||||
const id = readTrimmedString(workspaceId);
|
||||
if (!id) {
|
||||
return getLinearAuth();
|
||||
}
|
||||
return readAuthList().find((entry) => entry.workspaceId === id) || null;
|
||||
}
|
||||
|
||||
export function getLinearAuthWorkspaces() {
|
||||
return readAuthList().map((entry) => ({
|
||||
id: entry.workspaceId,
|
||||
name: entry.organization?.name || null,
|
||||
urlKey: entry.organization?.urlKey || null,
|
||||
current: Boolean(entry.current),
|
||||
user: entry.user || null,
|
||||
authorizedAt: entry.authorizedAt || entry.createdAt || null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function setLinearAuth(input, options = {}) {
|
||||
const accessToken = readTrimmedString(input?.accessToken);
|
||||
if (!accessToken) {
|
||||
throw new Error('accessToken is required');
|
||||
}
|
||||
const activate = options.activate !== false;
|
||||
const list = readAuthList();
|
||||
const current = list.find((entry) => entry.current) || list[0] || null;
|
||||
|
||||
const nextUser = Object.prototype.hasOwnProperty.call(input, 'user')
|
||||
? normalizeUser(input.user)
|
||||
: current?.user || null;
|
||||
const nextOrganization = Object.prototype.hasOwnProperty.call(input, 'organization')
|
||||
? normalizeOrganization(input.organization)
|
||||
: current?.organization || null;
|
||||
const workspaceId = resolveLinearWorkspaceId({
|
||||
organization: nextOrganization,
|
||||
user: nextUser,
|
||||
workspaceId: input?.workspaceId || (nextOrganization || nextUser ? '' : current?.workspaceId),
|
||||
});
|
||||
|
||||
const existingIndex = list.findIndex((entry) => entry.workspaceId === workspaceId);
|
||||
const previous = existingIndex >= 0 ? list[existingIndex] : (
|
||||
nextOrganization || nextUser ? null : current
|
||||
);
|
||||
const targetIndex = existingIndex >= 0
|
||||
? existingIndex
|
||||
: (previous && !nextOrganization && !nextUser ? list.indexOf(previous) : -1);
|
||||
const wasCurrent = previous?.current === true;
|
||||
|
||||
const next = {
|
||||
accessToken,
|
||||
refreshToken: Object.prototype.hasOwnProperty.call(input, 'refreshToken')
|
||||
? (readTrimmedString(input.refreshToken) || null)
|
||||
: previous?.refreshToken || null,
|
||||
tokenType: readTrimmedString(input?.tokenType) || previous?.tokenType || 'bearer',
|
||||
expiresAt: readFiniteNumber(input?.expiresAt) ?? previous?.expiresAt ?? null,
|
||||
scope: readTrimmedString(input?.scope) || previous?.scope || '',
|
||||
createdAt: previous?.createdAt || Date.now(),
|
||||
authorizedAt: Object.prototype.hasOwnProperty.call(input, 'authorizedAt')
|
||||
? (readFiniteNumber(input.authorizedAt) || Date.now())
|
||||
: (activate ? Date.now() : (previous?.authorizedAt || previous?.createdAt || Date.now())),
|
||||
user: nextUser,
|
||||
organization: nextOrganization,
|
||||
current: false,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
if (targetIndex >= 0) {
|
||||
list[targetIndex] = next;
|
||||
} else {
|
||||
list.push(next);
|
||||
}
|
||||
|
||||
const writtenIndex = targetIndex >= 0 ? targetIndex : list.length - 1;
|
||||
if (activate || !list.some((entry) => entry.current)) {
|
||||
list.forEach((entry, index) => {
|
||||
entry.current = index === writtenIndex;
|
||||
});
|
||||
} else {
|
||||
list[writtenIndex].current = wasCurrent;
|
||||
}
|
||||
|
||||
writeAuthList(list);
|
||||
return list[writtenIndex];
|
||||
}
|
||||
|
||||
export function activateLinearAuth(workspaceId) {
|
||||
const id = readTrimmedString(workspaceId);
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
const list = readAuthList();
|
||||
const index = list.findIndex((entry) => entry.workspaceId === id);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
list.forEach((entry, idx) => {
|
||||
entry.current = idx === index;
|
||||
});
|
||||
writeAuthList(list);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearLinearAuth(workspaceId) {
|
||||
try {
|
||||
const list = readAuthList();
|
||||
if (!list.length) {
|
||||
return true;
|
||||
}
|
||||
const id = readTrimmedString(workspaceId);
|
||||
const remaining = id
|
||||
? list.filter((entry) => entry.workspaceId !== id)
|
||||
: list.filter((entry) => !entry.current);
|
||||
if (!remaining.length) {
|
||||
writeAuthList([]);
|
||||
return true;
|
||||
}
|
||||
if (!remaining.some((entry) => entry.current)) {
|
||||
remaining[0].current = true;
|
||||
}
|
||||
writeAuthList(remaining);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to clear Linear auth file:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLinearAccessTokenStale(expiresAt, now = Date.now()) {
|
||||
const expiry = readFiniteNumber(expiresAt);
|
||||
if (expiry == null) {
|
||||
return true;
|
||||
}
|
||||
return expiry - ACCESS_TOKEN_REFRESH_SKEW_MS <= now;
|
||||
}
|
||||
|
||||
export function toLinearPublicStatus(auth, workspaces = getLinearAuthWorkspaces()) {
|
||||
if (!auth?.accessToken) {
|
||||
return { connected: false };
|
||||
}
|
||||
return {
|
||||
connected: true,
|
||||
user: auth.user || null,
|
||||
organization: auth.organization || null,
|
||||
scope: auth.scope || undefined,
|
||||
workspaces,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLinearClientId() {
|
||||
const fromEnv = readEnv('OPENCHAMBER_LINEAR_CLIENT_ID');
|
||||
if (fromEnv) return fromEnv;
|
||||
const stored = readSettingString('linearClientId');
|
||||
if (stored) return stored;
|
||||
return DEFAULT_LINEAR_CLIENT_ID;
|
||||
}
|
||||
|
||||
export function getLinearClientSecret() {
|
||||
const fromEnv = readEnv('OPENCHAMBER_LINEAR_CLIENT_SECRET');
|
||||
if (fromEnv) return fromEnv;
|
||||
return readSettingString('linearClientSecret');
|
||||
}
|
||||
|
||||
export function getLinearScopes() {
|
||||
const fromEnv = readEnv('OPENCHAMBER_LINEAR_SCOPES');
|
||||
if (fromEnv) return fromEnv;
|
||||
const stored = readSettingString('linearScopes');
|
||||
if (stored) return stored;
|
||||
return DEFAULT_LINEAR_SCOPES;
|
||||
}
|
||||
|
||||
export function getLinearBrokerUrl() {
|
||||
const fromEnv = readEnv('OPENCHAMBER_LINEAR_BROKER_URL');
|
||||
if (fromEnv) return fromEnv.replace(/\/+$/, '');
|
||||
const stored = readSettingString('linearBrokerUrl');
|
||||
if (stored) return stored.replace(/\/+$/, '');
|
||||
return DEFAULT_LINEAR_BROKER_URL;
|
||||
}
|
||||
|
||||
export function getLinearRedirectUri() {
|
||||
const fromEnv = readEnv('OPENCHAMBER_LINEAR_REDIRECT_URI');
|
||||
if (fromEnv) return fromEnv;
|
||||
const stored = readSettingString('linearRedirectUri');
|
||||
if (stored) return stored;
|
||||
return `${getLinearBrokerUrl()}/callback`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status comments are opt-in: they are written into a Linear workspace other
|
||||
* people read, so nothing is posted until the user turns them on.
|
||||
*/
|
||||
export function getLinearSessionCommentsEnabled() {
|
||||
return readSettings()[SESSION_COMMENTS_SETTING_KEY] === true;
|
||||
}
|
||||
|
||||
export function setLinearSessionCommentsEnabled(enabled) {
|
||||
const next = enabled === true;
|
||||
const settings = readSettings();
|
||||
settings[SESSION_COMMENTS_SETTING_KEY] = next;
|
||||
writeSettings(settings);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getLinearAuthFilePath() {
|
||||
return storageFile();
|
||||
}
|
||||
export const DEFAULT_LINEAR_CLIENT_ID_VALUE = DEFAULT_LINEAR_CLIENT_ID;
|
||||
@@ -0,0 +1,242 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
getLinearAuth,
|
||||
getLinearAuthWorkspaces,
|
||||
setLinearAuth,
|
||||
activateLinearAuth,
|
||||
clearLinearAuth,
|
||||
toLinearPublicStatus,
|
||||
getLinearClientId,
|
||||
getLinearRedirectUri,
|
||||
isLinearAccessTokenStale,
|
||||
getLinearAuthFilePath,
|
||||
DEFAULT_LINEAR_CLIENT_ID_VALUE,
|
||||
} from './auth.js';
|
||||
|
||||
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-auth-'));
|
||||
|
||||
describe('Linear auth storage', () => {
|
||||
let dataDir;
|
||||
let previousDataDir;
|
||||
let previousPort;
|
||||
let previousClientId;
|
||||
let previousRedirect;
|
||||
|
||||
beforeEach(() => {
|
||||
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
|
||||
previousPort = process.env.OPENCHAMBER_PORT;
|
||||
previousClientId = process.env.OPENCHAMBER_LINEAR_CLIENT_ID;
|
||||
previousRedirect = process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
|
||||
dataDir = makeTempDir();
|
||||
process.env.OPENCHAMBER_DATA_DIR = dataDir;
|
||||
delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID;
|
||||
delete process.env.OPENCHAMBER_LINEAR_SCOPES;
|
||||
delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
|
||||
delete process.env.OPENCHAMBER_PORT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('OPENCHAMBER_DATA_DIR', previousDataDir);
|
||||
restoreEnv('OPENCHAMBER_PORT', previousPort);
|
||||
restoreEnv('OPENCHAMBER_LINEAR_CLIENT_ID', previousClientId);
|
||||
restoreEnv('OPENCHAMBER_LINEAR_REDIRECT_URI', previousRedirect);
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns disconnected when no auth file exists', () => {
|
||||
expect(getLinearAuth()).toBeNull();
|
||||
expect(toLinearPublicStatus(null)).toEqual({ connected: false });
|
||||
});
|
||||
|
||||
it('persists tokens without exposing them on the public status', () => {
|
||||
setLinearAuth({
|
||||
accessToken: 'lin_oauth_access',
|
||||
refreshToken: 'lin_oauth_refresh',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
scope: 'read,write',
|
||||
user: { 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' },
|
||||
});
|
||||
|
||||
const stored = getLinearAuth();
|
||||
expect(stored.accessToken).toBe('lin_oauth_access');
|
||||
expect(stored.refreshToken).toBe('lin_oauth_refresh');
|
||||
expect(stored.workspaceId).toBe('org-1');
|
||||
const publicStatus = toLinearPublicStatus(stored);
|
||||
expect(publicStatus).toEqual({
|
||||
connected: true,
|
||||
user: {
|
||||
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' },
|
||||
scope: 'read,write',
|
||||
workspaces: [{
|
||||
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: stored.authorizedAt,
|
||||
}],
|
||||
});
|
||||
expect(JSON.stringify(publicStatus)).not.toContain('lin_oauth');
|
||||
const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8'));
|
||||
expect(file.accessToken).toBeUndefined();
|
||||
expect(file.workspaces).toHaveLength(1);
|
||||
expect(file.workspaces[0].accessToken).toBe('lin_oauth_access');
|
||||
});
|
||||
|
||||
it('keeps the previous refresh token when a later write omits it', () => {
|
||||
setLinearAuth({
|
||||
accessToken: 'access-1',
|
||||
refreshToken: 'refresh-1',
|
||||
expiresAt: 1,
|
||||
});
|
||||
setLinearAuth({
|
||||
accessToken: 'access-2',
|
||||
expiresAt: 2,
|
||||
});
|
||||
expect(getLinearAuth().refreshToken).toBe('refresh-1');
|
||||
expect(getLinearAuth().accessToken).toBe('access-2');
|
||||
});
|
||||
|
||||
it('rotates the refresh token when a new one is provided', () => {
|
||||
setLinearAuth({
|
||||
accessToken: 'access-1',
|
||||
refreshToken: 'refresh-1',
|
||||
});
|
||||
setLinearAuth({
|
||||
accessToken: 'access-2',
|
||||
refreshToken: 'refresh-2',
|
||||
});
|
||||
expect(getLinearAuth().refreshToken).toBe('refresh-2');
|
||||
});
|
||||
|
||||
it('rejects a write without an access token', () => {
|
||||
expect(() => setLinearAuth({ refreshToken: 'refresh-1' })).toThrow('accessToken is required');
|
||||
});
|
||||
|
||||
it('treats a missing or past expiry as stale', () => {
|
||||
expect(isLinearAccessTokenStale(null)).toBe(true);
|
||||
expect(isLinearAccessTokenStale(Date.now() - 1)).toBe(true);
|
||||
expect(isLinearAccessTokenStale(Date.now() + 10 * 60_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the baked-in client id unless env or settings override it', () => {
|
||||
expect(getLinearClientId()).toBe(DEFAULT_LINEAR_CLIENT_ID_VALUE);
|
||||
process.env.OPENCHAMBER_LINEAR_CLIENT_ID = 'env-client';
|
||||
expect(getLinearClientId()).toBe('env-client');
|
||||
});
|
||||
|
||||
it('uses the stable public broker callback by default', () => {
|
||||
process.env.OPENCHAMBER_PORT = '3001';
|
||||
expect(getLinearRedirectUri()).toBe('https://api.openchamber.dev/v1/oauth/linear/callback');
|
||||
process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://localhost:3000/linear/oauth/callback';
|
||||
expect(getLinearRedirectUri()).toBe('http://localhost:3000/linear/oauth/callback');
|
||||
});
|
||||
|
||||
it('deletes the auth file on clear', () => {
|
||||
setLinearAuth({ accessToken: 'access-1', refreshToken: 'refresh-1' });
|
||||
expect(fs.existsSync(getLinearAuthFilePath())).toBe(true);
|
||||
expect(clearLinearAuth()).toBe(true);
|
||||
expect(fs.existsSync(getLinearAuthFilePath())).toBe(false);
|
||||
expect(getLinearAuth()).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates a legacy single-workspace file', () => {
|
||||
fs.writeFileSync(getLinearAuthFilePath(), JSON.stringify({
|
||||
accessToken: 'legacy-access',
|
||||
refreshToken: 'legacy-refresh',
|
||||
user: { id: 'user-1', name: 'Ada' },
|
||||
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
|
||||
}), 'utf8');
|
||||
|
||||
const stored = getLinearAuth();
|
||||
expect(stored.accessToken).toBe('legacy-access');
|
||||
expect(stored.workspaceId).toBe('org-1');
|
||||
expect(stored.current).toBe(true);
|
||||
const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8'));
|
||||
expect(file.workspaces).toHaveLength(1);
|
||||
expect(file.accessToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores a second workspace and activates it without dropping the first', () => {
|
||||
setLinearAuth({
|
||||
accessToken: 'access-a',
|
||||
refreshToken: 'refresh-a',
|
||||
user: { id: 'user-a', name: 'Ada' },
|
||||
organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' },
|
||||
});
|
||||
setLinearAuth({
|
||||
accessToken: 'access-b',
|
||||
refreshToken: 'refresh-b',
|
||||
user: { id: 'user-b', name: 'Ben' },
|
||||
organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' },
|
||||
});
|
||||
|
||||
expect(getLinearAuth().workspaceId).toBe('org-b');
|
||||
expect(getLinearAuthWorkspaces().map((entry) => entry.id).sort()).toEqual(['org-a', 'org-b']);
|
||||
expect(activateLinearAuth('org-a')).toBe(true);
|
||||
expect(getLinearAuth().workspaceId).toBe('org-a');
|
||||
expect(getLinearAuth().accessToken).toBe('access-a');
|
||||
expect(getLinearAuthWorkspaces().find((entry) => entry.id === 'org-b').current).toBe(false);
|
||||
});
|
||||
|
||||
it('drops only the current workspace on unscoped clear', () => {
|
||||
setLinearAuth({
|
||||
accessToken: 'access-a',
|
||||
organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' },
|
||||
user: { id: 'user-a', name: 'Ada' },
|
||||
});
|
||||
setLinearAuth({
|
||||
accessToken: 'access-b',
|
||||
organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' },
|
||||
user: { id: 'user-b', name: 'Ben' },
|
||||
});
|
||||
expect(clearLinearAuth()).toBe(true);
|
||||
expect(getLinearAuth().workspaceId).toBe('org-a');
|
||||
expect(getLinearAuth().accessToken).toBe('access-a');
|
||||
expect(getLinearAuthWorkspaces()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not bump authorizedAt when a later write opts out of activate', () => {
|
||||
setLinearAuth({
|
||||
accessToken: 'access-1',
|
||||
user: { id: 'user-1', name: 'Ada' },
|
||||
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
|
||||
});
|
||||
const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8'));
|
||||
file.workspaces[0].authorizedAt = 111;
|
||||
fs.writeFileSync(getLinearAuthFilePath(), JSON.stringify(file, null, 2), 'utf8');
|
||||
|
||||
setLinearAuth({
|
||||
accessToken: 'access-1',
|
||||
user: { id: 'user-1', name: 'Ada' },
|
||||
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
|
||||
workspaceId: 'org-1',
|
||||
}, { activate: false });
|
||||
expect(getLinearAuth().authorizedAt).toBe(111);
|
||||
expect(getLinearAuth().current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function restoreEnv(name, previous) {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
return;
|
||||
}
|
||||
process.env[name] = previous;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
getLinearAuth,
|
||||
getLinearAuthByWorkspaceId,
|
||||
setLinearAuth,
|
||||
clearLinearAuth,
|
||||
isLinearAccessTokenStale,
|
||||
} from './auth.js';
|
||||
import { refreshAccessToken } from './oauth.js';
|
||||
import { isPlainObject, readTrimmedString } from './parse.js';
|
||||
|
||||
const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql';
|
||||
const VIEWER_QUERY = '{ viewer { id name displayName email avatarUrl } organization { id name urlKey } }';
|
||||
// Linear file URLs in GraphQL need this header or the browser cannot load
|
||||
// uploads.linear.app images (comment screenshots, description images).
|
||||
const LINEAR_PUBLIC_FILE_URL_TTL_SECONDS = '3600';
|
||||
|
||||
export class LinearApiError extends Error {
|
||||
constructor(message, status, options = {}) {
|
||||
super(message);
|
||||
this.name = 'LinearApiError';
|
||||
this.status = status;
|
||||
this.userError = options.userError === true;
|
||||
}
|
||||
}
|
||||
|
||||
function readGraphqlError(payload) {
|
||||
const errors = Array.isArray(payload.errors) ? payload.errors : [];
|
||||
const first = errors.length > 0 && isPlainObject(errors[0]) ? errors[0] : null;
|
||||
if (!first) {
|
||||
return { message: '', userError: false, status: 502 };
|
||||
}
|
||||
const extensions = isPlainObject(first.extensions) ? first.extensions : null;
|
||||
const presentable = extensions ? readTrimmedString(extensions.userPresentableMessage) : '';
|
||||
let constraint = '';
|
||||
const validationErrors = extensions && Array.isArray(extensions.validationErrors)
|
||||
? extensions.validationErrors
|
||||
: [];
|
||||
for (const entry of validationErrors) {
|
||||
if (!isPlainObject(entry) || !isPlainObject(entry.constraints)) continue;
|
||||
for (const value of Object.values(entry.constraints)) {
|
||||
const text = readTrimmedString(value);
|
||||
if (text) {
|
||||
constraint = text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (constraint) break;
|
||||
}
|
||||
const message = presentable || constraint || readTrimmedString(first.message);
|
||||
const code = extensions ? readTrimmedString(extensions.code) : '';
|
||||
const userError = extensions?.userError === true
|
||||
|| code === 'INVALID_INPUT'
|
||||
|| code === 'INPUT_ERROR'
|
||||
|| /^entity not found/i.test(message)
|
||||
|| /^argument validation/i.test(message);
|
||||
return {
|
||||
message,
|
||||
userError,
|
||||
status: userError ? 400 : 502,
|
||||
};
|
||||
}
|
||||
|
||||
function readIdentity(payload) {
|
||||
const data = isPlainObject(payload) ? payload.data : null;
|
||||
const viewer = isPlainObject(data) ? data.viewer : null;
|
||||
if (!isPlainObject(viewer) || !readTrimmedString(viewer.id)) {
|
||||
return null;
|
||||
}
|
||||
const organization = isPlainObject(data) ? data.organization : null;
|
||||
const organizationId = isPlainObject(organization) ? readTrimmedString(organization.id) : '';
|
||||
const organizationName = isPlainObject(organization) ? readTrimmedString(organization.name) : '';
|
||||
return {
|
||||
user: {
|
||||
id: viewer.id.trim(),
|
||||
name: readTrimmedString(viewer.name) || null,
|
||||
displayName: readTrimmedString(viewer.displayName) || null,
|
||||
email: readTrimmedString(viewer.email) || null,
|
||||
avatarUrl: readTrimmedString(viewer.avatarUrl) || null,
|
||||
},
|
||||
organization: organizationId && organizationName
|
||||
? {
|
||||
id: organizationId,
|
||||
name: organizationName,
|
||||
urlKey: readTrimmedString(organization.urlKey) || null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLinearGraphql(accessToken, query, variables) {
|
||||
const token = readTrimmedString(accessToken);
|
||||
if (!token) {
|
||||
throw new LinearApiError('Linear is not connected', 401);
|
||||
}
|
||||
|
||||
const body = { query };
|
||||
if (isPlainObject(variables)) {
|
||||
body.variables = variables;
|
||||
}
|
||||
|
||||
const response = await fetch(LINEAR_GRAPHQL_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'public-file-urls-expire-in': LINEAR_PUBLIC_FILE_URL_TTL_SECONDS,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (response.status === 401) {
|
||||
throw new LinearApiError('Linear token expired or revoked', 401);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new LinearApiError(`Linear GraphQL request failed (${response.status})`, response.status);
|
||||
}
|
||||
if (!isPlainObject(payload)) {
|
||||
throw new LinearApiError('Linear GraphQL response was not JSON', 502);
|
||||
}
|
||||
const data = isPlainObject(payload.data) ? payload.data : null;
|
||||
if (!data) {
|
||||
const graphqlError = readGraphqlError(payload);
|
||||
throw new LinearApiError(
|
||||
graphqlError.message || 'Linear GraphQL response did not include data',
|
||||
graphqlError.status,
|
||||
{ userError: graphqlError.userError },
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchLinearIdentity(accessToken) {
|
||||
const data = await fetchLinearGraphql(accessToken, VIEWER_QUERY);
|
||||
const identity = readIdentity({ data });
|
||||
if (!identity) {
|
||||
throw new LinearApiError('Linear GraphQL response did not include a viewer', 502);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
const inFlightRefreshByWorkspace = new Map();
|
||||
|
||||
async function refreshWorkspaceAuth(auth) {
|
||||
const tokens = await refreshAccessToken(auth.refreshToken);
|
||||
const next = setLinearAuth({
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken || auth.refreshToken,
|
||||
tokenType: tokens.tokenType,
|
||||
expiresAt: tokens.expiresAt,
|
||||
scope: tokens.scope || auth.scope,
|
||||
user: auth.user,
|
||||
organization: auth.organization,
|
||||
workspaceId: auth.workspaceId,
|
||||
}, { activate: false });
|
||||
return next.accessToken;
|
||||
}
|
||||
|
||||
export async function getValidLinearAccessToken(workspaceId) {
|
||||
const auth = workspaceId
|
||||
? getLinearAuthByWorkspaceId(workspaceId)
|
||||
: getLinearAuth();
|
||||
if (!auth?.accessToken) {
|
||||
return null;
|
||||
}
|
||||
if (!isLinearAccessTokenStale(auth.expiresAt)) {
|
||||
return auth.accessToken;
|
||||
}
|
||||
if (!auth.refreshToken) {
|
||||
clearLinearAuth(auth.workspaceId);
|
||||
return null;
|
||||
}
|
||||
const key = auth.workspaceId;
|
||||
const pending = inFlightRefreshByWorkspace.get(key);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
const promise = refreshWorkspaceAuth(auth)
|
||||
.catch((error) => {
|
||||
if (error?.code === 'INVALID_GRANT' || error?.status === 400 || error?.status === 401) {
|
||||
clearLinearAuth(auth.workspaceId);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightRefreshByWorkspace.delete(key);
|
||||
});
|
||||
inFlightRefreshByWorkspace.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export {
|
||||
getLinearAuth,
|
||||
getLinearAuthByWorkspaceId,
|
||||
getLinearAuthWorkspaces,
|
||||
setLinearAuth,
|
||||
activateLinearAuth,
|
||||
clearLinearAuth,
|
||||
toLinearPublicStatus,
|
||||
getLinearClientId,
|
||||
getLinearClientSecret,
|
||||
getLinearScopes,
|
||||
getLinearBrokerUrl,
|
||||
getLinearRedirectUri,
|
||||
isLinearAccessTokenStale,
|
||||
getLinearAuthFilePath,
|
||||
getLinearSessionCommentsEnabled,
|
||||
setLinearSessionCommentsEnabled,
|
||||
DEFAULT_LINEAR_CLIENT_ID_VALUE,
|
||||
} from './auth.js';
|
||||
|
||||
export {
|
||||
startAuthorization,
|
||||
consumeAuthorizationCallback,
|
||||
pollAuthorizationBroker,
|
||||
completeAuthorizationBroker,
|
||||
refreshAccessToken,
|
||||
revokeToken,
|
||||
LinearOAuthError,
|
||||
} from './oauth.js';
|
||||
|
||||
export {
|
||||
fetchLinearIdentity,
|
||||
getValidLinearAccessToken,
|
||||
LinearApiError,
|
||||
} from './client.js';
|
||||
|
||||
export {
|
||||
listLinearIssues,
|
||||
getLinearIssue,
|
||||
listLinearIssueStates,
|
||||
updateLinearIssue,
|
||||
} from './issues.js';
|
||||
|
||||
export {
|
||||
listLinearTeams,
|
||||
} from './teams.js';
|
||||
|
||||
export {
|
||||
LinearMappingError,
|
||||
getLinearMappingFilePath,
|
||||
mergeLinearMappingView,
|
||||
readStoredLinearMapping,
|
||||
resolveMappedProjectPath,
|
||||
setStoredLinearMapping,
|
||||
} from './mapping.js';
|
||||
|
||||
export {
|
||||
LinearSessionStatusError,
|
||||
isPublicSessionOrigin,
|
||||
postLinearSessionStatus,
|
||||
} from './status.js';
|
||||
@@ -0,0 +1,499 @@
|
||||
import { clearLinearAuth, getLinearAuth, getLinearAuthByWorkspaceId } from './auth.js';
|
||||
import { fetchLinearGraphql, getValidLinearAccessToken } from './client.js';
|
||||
import { isPlainObject, isString, readFiniteNumber, readTrimmedString } from './parse.js';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const LIST_STATUS_STATE = {
|
||||
open: { type: { nin: ['completed', 'canceled', 'duplicate'] } },
|
||||
backlog: { type: { eq: 'backlog' } },
|
||||
todo: { type: { eq: 'unstarted' } },
|
||||
started: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } },
|
||||
inReview: { name: { eqIgnoreCase: 'In Review' } },
|
||||
completed: { type: { eq: 'completed' } },
|
||||
canceled: { type: { eq: 'canceled' }, name: { neqIgnoreCase: 'Duplicate' } },
|
||||
duplicate: { or: [{ type: { eq: 'duplicate' } }, { name: { eqIgnoreCase: 'Duplicate' } }] },
|
||||
};
|
||||
|
||||
function readListStatus(value) {
|
||||
const status = readTrimmedString(value);
|
||||
if (status === 'all' || Object.hasOwn(LIST_STATUS_STATE, status)) {
|
||||
return status;
|
||||
}
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function readListAssignee(value) {
|
||||
const assignee = readTrimmedString(value);
|
||||
if (assignee === 'me' || assignee === 'any') {
|
||||
return assignee;
|
||||
}
|
||||
return 'any';
|
||||
}
|
||||
|
||||
const LIST_PRIORITY_EQ = {
|
||||
none: 0,
|
||||
urgent: 1,
|
||||
high: 2,
|
||||
medium: 3,
|
||||
low: 4,
|
||||
};
|
||||
|
||||
function readListPriority(value) {
|
||||
const priority = readTrimmedString(value);
|
||||
if (priority === 'none' || priority === 'urgent' || priority === 'high' || priority === 'medium' || priority === 'low') {
|
||||
return priority;
|
||||
}
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function buildIssueListFilter({ status, assignee, teamId, priority } = {}) {
|
||||
const filter = {};
|
||||
const resolvedStatus = readListStatus(status);
|
||||
const resolvedAssignee = readListAssignee(assignee);
|
||||
const resolvedPriority = readListPriority(priority);
|
||||
const team = readTrimmedString(teamId);
|
||||
if (resolvedStatus !== 'all') {
|
||||
filter.state = LIST_STATUS_STATE[resolvedStatus];
|
||||
}
|
||||
if (resolvedAssignee === 'me') {
|
||||
filter.assignee = { isMe: { eq: true } };
|
||||
}
|
||||
if (team) {
|
||||
filter.team = { id: { eq: team } };
|
||||
}
|
||||
if (resolvedPriority !== 'all') {
|
||||
filter.priority = { eq: LIST_PRIORITY_EQ[resolvedPriority] };
|
||||
}
|
||||
return Object.keys(filter).length > 0 ? filter : undefined;
|
||||
}
|
||||
|
||||
const ISSUE_SUMMARY_FIELDS = `
|
||||
id
|
||||
identifier
|
||||
title
|
||||
url
|
||||
priority
|
||||
state { id name type }
|
||||
assignee { name displayName avatarUrl }
|
||||
team { id key name }
|
||||
labels { nodes { id name color } }
|
||||
`;
|
||||
const LIST_QUERY = `
|
||||
query ListLinearIssues($first: Int!, $after: String, $filter: IssueFilter) {
|
||||
issues(first: $first, after: $after, filter: $filter, orderBy: updatedAt) {
|
||||
nodes { ${ISSUE_SUMMARY_FIELDS} }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const SEARCH_QUERY = `
|
||||
query SearchLinearIssues($term: String!, $first: Int!, $after: String, $filter: IssueFilter) {
|
||||
searchIssues(term: $term, first: $first, after: $after, filter: $filter) {
|
||||
nodes { ${ISSUE_SUMMARY_FIELDS} }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const GET_QUERY = `
|
||||
query GetLinearIssue($id: String!) {
|
||||
issue(id: $id) {
|
||||
${ISSUE_SUMMARY_FIELDS}
|
||||
description
|
||||
comments(first: 50) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
user { name displayName avatarUrl }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const COMMENT_CREATE = `
|
||||
mutation CommentCreate($input: CommentCreateInput!) {
|
||||
commentCreate(input: $input) {
|
||||
success
|
||||
comment { id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const STATES_QUERY = `
|
||||
query TeamWorkflowStates($id: String!) {
|
||||
team(id: $id) {
|
||||
states(first: 50) {
|
||||
nodes { id name type position }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const ISSUE_UPDATE = `
|
||||
mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
|
||||
issueUpdate(id: $id, input: $input) {
|
||||
success
|
||||
issue {
|
||||
${ISSUE_SUMMARY_FIELDS}
|
||||
description
|
||||
comments(first: 50) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
user { name displayName avatarUrl }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const IDENTIFIER_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const URL_IDENTIFIER_RE = /linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i;
|
||||
|
||||
export function parseLinearIssueRef(value) {
|
||||
const trimmed = readTrimmedString(value);
|
||||
if (!trimmed) return null;
|
||||
const urlMatch = trimmed.match(URL_IDENTIFIER_RE);
|
||||
if (urlMatch) {
|
||||
return { kind: 'identifier', value: urlMatch[1].toUpperCase() };
|
||||
}
|
||||
if (IDENTIFIER_RE.test(trimmed)) {
|
||||
return { kind: 'identifier', value: trimmed.toUpperCase() };
|
||||
}
|
||||
if (UUID_RE.test(trimmed)) {
|
||||
return { kind: 'id', value: trimmed.toLowerCase() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readState(value) {
|
||||
if (!isPlainObject(value)) return null;
|
||||
const id = readTrimmedString(value.id) || null;
|
||||
const name = readTrimmedString(value.name) || null;
|
||||
const type = readTrimmedString(value.type) || null;
|
||||
if (!id && !name && !type) return null;
|
||||
return { id, name, type };
|
||||
}
|
||||
|
||||
const WORKFLOW_TYPE_ORDER = {
|
||||
triage: 0,
|
||||
backlog: 1,
|
||||
unstarted: 2,
|
||||
started: 3,
|
||||
completed: 4,
|
||||
canceled: 5,
|
||||
};
|
||||
|
||||
function workflowTypeRank(type) {
|
||||
if (type === 'triage' || type === 'backlog' || type === 'unstarted' || type === 'started' || type === 'completed' || type === 'canceled') {
|
||||
return WORKFLOW_TYPE_ORDER[type];
|
||||
}
|
||||
return 99;
|
||||
}
|
||||
|
||||
function compareWorkflowStates(left, right) {
|
||||
const typeDelta = workflowTypeRank(left.type) - workflowTypeRank(right.type);
|
||||
if (typeDelta !== 0) return typeDelta;
|
||||
if (left.position !== right.position) return left.position - right.position;
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function readWorkflowState(value) {
|
||||
if (!isPlainObject(value)) return null;
|
||||
const id = readTrimmedString(value.id);
|
||||
const name = readTrimmedString(value.name);
|
||||
if (!id || !name) return null;
|
||||
const position = readFiniteNumber(value.position);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: readTrimmedString(value.type) || null,
|
||||
position: position ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function readAssignee(value) {
|
||||
if (!isPlainObject(value)) return null;
|
||||
const name = readTrimmedString(value.name) || null;
|
||||
const displayName = readTrimmedString(value.displayName) || null;
|
||||
const avatarUrl = readTrimmedString(value.avatarUrl) || null;
|
||||
if (!name && !displayName && !avatarUrl) return null;
|
||||
return { name, displayName, avatarUrl };
|
||||
}
|
||||
|
||||
function readTeam(value) {
|
||||
if (!isPlainObject(value)) return null;
|
||||
const id = readTrimmedString(value.id);
|
||||
const key = readTrimmedString(value.key);
|
||||
const name = readTrimmedString(value.name);
|
||||
if (!id || !key || !name) return null;
|
||||
return { id, key, name };
|
||||
}
|
||||
|
||||
function readPriority(value) {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 4) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function readLabelColor(value) {
|
||||
const raw = readTrimmedString(value);
|
||||
if (!raw) return null;
|
||||
const hex = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||
if (!/^[0-9A-Fa-f]{6}$/.test(hex)) return null;
|
||||
return `#${hex.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function readLabel(value) {
|
||||
if (!isPlainObject(value)) return null;
|
||||
const id = readTrimmedString(value.id);
|
||||
const name = readTrimmedString(value.name);
|
||||
if (!id || !name) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
color: readLabelColor(value.color),
|
||||
};
|
||||
}
|
||||
|
||||
function readLabels(value) {
|
||||
const nodes = isPlainObject(value) && Array.isArray(value.nodes)
|
||||
? value.nodes
|
||||
: Array.isArray(value)
|
||||
? value
|
||||
: [];
|
||||
return nodes.map(readLabel).filter(Boolean);
|
||||
}
|
||||
|
||||
function readIssueSummary(node) {
|
||||
if (!isPlainObject(node)) return null;
|
||||
const id = readTrimmedString(node.id);
|
||||
const identifier = readTrimmedString(node.identifier);
|
||||
const title = readTrimmedString(node.title);
|
||||
const url = readTrimmedString(node.url);
|
||||
if (!id || !identifier || !title || !url) return null;
|
||||
return {
|
||||
id,
|
||||
identifier,
|
||||
title,
|
||||
url,
|
||||
state: readState(node.state),
|
||||
assignee: readAssignee(node.assignee),
|
||||
team: readTeam(node.team),
|
||||
priority: readPriority(node.priority),
|
||||
labels: readLabels(node.labels),
|
||||
};
|
||||
}
|
||||
|
||||
function readComment(node) {
|
||||
if (!isPlainObject(node)) return null;
|
||||
const id = readTrimmedString(node.id);
|
||||
if (!id) return null;
|
||||
const body = isString(node.body) ? node.body : '';
|
||||
const user = isPlainObject(node.user)
|
||||
? {
|
||||
name: readTrimmedString(node.user.name) || null,
|
||||
displayName: readTrimmedString(node.user.displayName) || null,
|
||||
avatarUrl: readTrimmedString(node.user.avatarUrl) || null,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
id,
|
||||
body,
|
||||
createdAt: readTrimmedString(node.createdAt) || null,
|
||||
user: user && (user.name || user.displayName) ? user : null,
|
||||
};
|
||||
}
|
||||
|
||||
function readIssue(node) {
|
||||
const summary = readIssueSummary(node);
|
||||
if (!summary) return null;
|
||||
const commentsPayload = isPlainObject(node.comments) ? node.comments.nodes : null;
|
||||
const comments = Array.isArray(commentsPayload)
|
||||
? commentsPayload.map(readComment).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
...summary,
|
||||
description: isString(node.description) ? node.description : null,
|
||||
comments,
|
||||
};
|
||||
}
|
||||
|
||||
function readPageInfo(connection) {
|
||||
const pageInfo = isPlainObject(connection) ? connection.pageInfo : null;
|
||||
if (!isPlainObject(pageInfo)) {
|
||||
return { hasMore: false, cursor: null };
|
||||
}
|
||||
return {
|
||||
hasMore: pageInfo.hasNextPage === true,
|
||||
cursor: readTrimmedString(pageInfo.endCursor) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function readIssueNodes(connection) {
|
||||
const nodes = isPlainObject(connection) ? connection.nodes : null;
|
||||
if (!Array.isArray(nodes)) return [];
|
||||
return nodes.map(readIssueSummary).filter(Boolean);
|
||||
}
|
||||
|
||||
async function withLinearToken(run, workspaceId) {
|
||||
try {
|
||||
const token = await getValidLinearAccessToken(workspaceId);
|
||||
if (!token) {
|
||||
return { connected: false };
|
||||
}
|
||||
return await run(token);
|
||||
} catch (error) {
|
||||
if (error?.status === 401) {
|
||||
const failed = workspaceId
|
||||
? getLinearAuthByWorkspaceId(workspaceId)
|
||||
: getLinearAuth();
|
||||
clearLinearAuth(failed?.workspaceId || workspaceId);
|
||||
return { connected: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchIssueByRef(token, ref) {
|
||||
const data = await fetchLinearGraphql(token, GET_QUERY, { id: ref.value });
|
||||
return readIssue(data.issue);
|
||||
}
|
||||
|
||||
export async function listLinearIssues({ query, cursor, status, assignee, teamId, priority } = {}) {
|
||||
return withLinearToken(async (token) => {
|
||||
const ref = parseLinearIssueRef(query);
|
||||
if (ref) {
|
||||
const issue = await fetchIssueByRef(token, ref);
|
||||
return {
|
||||
connected: true,
|
||||
issues: issue ? [issue] : [],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
const after = readTrimmedString(cursor) || null;
|
||||
const term = readTrimmedString(query);
|
||||
const filter = buildIssueListFilter({ status, assignee, teamId, priority });
|
||||
const variables = {
|
||||
first: PAGE_SIZE,
|
||||
};
|
||||
if (filter) {
|
||||
variables.filter = filter;
|
||||
}
|
||||
if (after) {
|
||||
variables.after = after;
|
||||
}
|
||||
|
||||
if (term) {
|
||||
variables.term = term;
|
||||
const data = await fetchLinearGraphql(token, SEARCH_QUERY, variables);
|
||||
const connection = isPlainObject(data.searchIssues) ? data.searchIssues : null;
|
||||
const page = readPageInfo(connection);
|
||||
return {
|
||||
connected: true,
|
||||
issues: readIssueNodes(connection),
|
||||
cursor: page.cursor,
|
||||
hasMore: page.hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await fetchLinearGraphql(token, LIST_QUERY, variables);
|
||||
const connection = isPlainObject(data.issues) ? data.issues : null;
|
||||
const page = readPageInfo(connection);
|
||||
return {
|
||||
connected: true,
|
||||
issues: readIssueNodes(connection),
|
||||
cursor: page.cursor,
|
||||
hasMore: page.hasMore,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLinearIssue(id) {
|
||||
const ref = parseLinearIssueRef(id) || (readTrimmedString(id) ? { kind: 'id', value: readTrimmedString(id) } : null);
|
||||
if (!ref) {
|
||||
return { connected: true, issue: null };
|
||||
}
|
||||
return withLinearToken(async (token) => {
|
||||
const issue = await fetchIssueByRef(token, ref);
|
||||
return { connected: true, issue };
|
||||
});
|
||||
}
|
||||
|
||||
export async function listLinearIssueStates(teamId) {
|
||||
const id = readTrimmedString(teamId);
|
||||
if (!id) {
|
||||
const error = new Error('teamId is required');
|
||||
error.code = 'INVALID';
|
||||
throw error;
|
||||
}
|
||||
return withLinearToken(async (token) => {
|
||||
const data = await fetchLinearGraphql(token, STATES_QUERY, { id });
|
||||
const team = isPlainObject(data.team) ? data.team : null;
|
||||
const connection = isPlainObject(team) ? team.states : null;
|
||||
const nodes = isPlainObject(connection) && Array.isArray(connection.nodes)
|
||||
? connection.nodes
|
||||
: [];
|
||||
const states = nodes
|
||||
.map(readWorkflowState)
|
||||
.filter(Boolean)
|
||||
.sort(compareWorkflowStates);
|
||||
return { connected: true, states };
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateLinearIssue({ id, stateId } = {}) {
|
||||
const issueId = readTrimmedString(id);
|
||||
const nextStateId = readTrimmedString(stateId);
|
||||
if (!issueId || !nextStateId) {
|
||||
const error = new Error('id and stateId are required');
|
||||
error.code = 'INVALID';
|
||||
throw error;
|
||||
}
|
||||
const ref = parseLinearIssueRef(issueId) || { kind: 'id', value: issueId };
|
||||
return withLinearToken(async (token) => {
|
||||
const resolved = ref.kind === 'identifier'
|
||||
? await fetchIssueByRef(token, ref)
|
||||
: null;
|
||||
const resolvedId = resolved?.id || (ref.kind === 'id' ? ref.value : '');
|
||||
if (!resolvedId) {
|
||||
return { connected: true, issue: null };
|
||||
}
|
||||
const data = await fetchLinearGraphql(token, ISSUE_UPDATE, {
|
||||
id: resolvedId,
|
||||
input: { stateId: nextStateId },
|
||||
});
|
||||
const payload = isPlainObject(data.issueUpdate) ? data.issueUpdate : null;
|
||||
return {
|
||||
connected: true,
|
||||
issue: payload ? readIssue(payload.issue) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function createLinearIssueComment({ issueId, body, organizationId } = {}) {
|
||||
const text = isString(body) ? body : '';
|
||||
const ref = parseLinearIssueRef(issueId)
|
||||
|| (readTrimmedString(issueId) ? { kind: 'id', value: readTrimmedString(issueId) } : null);
|
||||
if (!ref || !text.trim()) {
|
||||
return { connected: true, comment: null };
|
||||
}
|
||||
return withLinearToken(async (token) => {
|
||||
const issue = await fetchIssueByRef(token, ref);
|
||||
if (!issue) {
|
||||
return { connected: true, comment: null };
|
||||
}
|
||||
const data = await fetchLinearGraphql(token, COMMENT_CREATE, {
|
||||
input: { issueId: issue.id, body: text },
|
||||
});
|
||||
const payload = isPlainObject(data.commentCreate) ? data.commentCreate : null;
|
||||
const comment = isPlainObject(payload?.comment) ? payload.comment : null;
|
||||
const id = comment ? readTrimmedString(comment.id) : '';
|
||||
return {
|
||||
connected: true,
|
||||
comment: id ? { id } : null,
|
||||
};
|
||||
}, organizationId);
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getLinearAuth, getLinearAuthFilePath } from './auth.js';
|
||||
import { isPlainObject, readTrimmedString } from './parse.js';
|
||||
|
||||
export class LinearMappingError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'LinearMappingError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function mappingFile() {
|
||||
return path.join(path.dirname(getLinearAuthFilePath()), 'linear-mapping.json');
|
||||
}
|
||||
|
||||
const UNSCOPED_MAPPING_KEY = '__unscoped__';
|
||||
|
||||
function mappingOrgKey() {
|
||||
const auth = getLinearAuth();
|
||||
return readTrimmedString(auth?.workspaceId) || UNSCOPED_MAPPING_KEY;
|
||||
}
|
||||
|
||||
function emptyMapping() {
|
||||
return {
|
||||
defaultProjectPath: null,
|
||||
teamProjectPaths: {},
|
||||
};
|
||||
}
|
||||
|
||||
function readTeamProjectPaths(value) {
|
||||
if (!isPlainObject(value)) {
|
||||
return {};
|
||||
}
|
||||
const next = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
const teamId = readTrimmedString(key);
|
||||
const projectPath = readTrimmedString(value[key]);
|
||||
if (teamId && projectPath) {
|
||||
next[teamId] = projectPath;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeMappingSlice(raw) {
|
||||
if (!isPlainObject(raw)) {
|
||||
return emptyMapping();
|
||||
}
|
||||
return {
|
||||
defaultProjectPath: readTrimmedString(raw.defaultProjectPath) || null,
|
||||
teamProjectPaths: readTeamProjectPaths(raw.teamProjectPaths),
|
||||
};
|
||||
}
|
||||
|
||||
function readMappingDocument(raw) {
|
||||
if (!isPlainObject(raw)) {
|
||||
return { workspaces: {} };
|
||||
}
|
||||
if (isPlainObject(raw.workspaces)) {
|
||||
const workspaces = {};
|
||||
for (const key of Object.keys(raw.workspaces)) {
|
||||
const orgKey = readTrimmedString(key);
|
||||
if (!orgKey) continue;
|
||||
workspaces[orgKey] = normalizeMappingSlice(raw.workspaces[key]);
|
||||
}
|
||||
return { workspaces };
|
||||
}
|
||||
return {
|
||||
workspaces: {
|
||||
[mappingOrgKey()]: normalizeMappingSlice(raw),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeJsonFile(filePath, payload) {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
|
||||
try {
|
||||
fs.chmodSync(tmpFile, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
fs.renameSync(tmpFile, filePath);
|
||||
try {
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function getLinearMappingFilePath() {
|
||||
return mappingFile();
|
||||
}
|
||||
|
||||
export function readStoredLinearMapping() {
|
||||
const filePath = mappingFile();
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return emptyMapping();
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return emptyMapping();
|
||||
}
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED');
|
||||
}
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED');
|
||||
}
|
||||
const document = readMappingDocument(parsed);
|
||||
return document.workspaces[mappingOrgKey()] || emptyMapping();
|
||||
}
|
||||
|
||||
export function setStoredLinearMapping(input) {
|
||||
if (!isPlainObject(input)) {
|
||||
throw new LinearMappingError('Mapping body must be an object', 'INVALID');
|
||||
}
|
||||
const filePath = mappingFile();
|
||||
let document = { workspaces: {} };
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed) {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED');
|
||||
}
|
||||
document = readMappingDocument(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LinearMappingError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED');
|
||||
}
|
||||
}
|
||||
const next = {
|
||||
defaultProjectPath: readTrimmedString(input.defaultProjectPath) || null,
|
||||
teamProjectPaths: readTeamProjectPaths(input.teamProjectPaths),
|
||||
};
|
||||
document.workspaces[mappingOrgKey()] = next;
|
||||
writeJsonFile(filePath, document);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function mergeLinearMappingView(stored, teams) {
|
||||
const mapping = stored || emptyMapping();
|
||||
const nodes = Array.isArray(teams) ? teams : [];
|
||||
return {
|
||||
defaultProjectPath: mapping.defaultProjectPath,
|
||||
teams: nodes.map((team) => ({
|
||||
id: team.id,
|
||||
key: team.key,
|
||||
name: team.name,
|
||||
projectPath: mapping.teamProjectPaths[team.id] || null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMappedProjectPath(view, team) {
|
||||
const teams = Array.isArray(view?.teams) ? view.teams : [];
|
||||
const teamId = team ? readTrimmedString(team.id) : '';
|
||||
if (teamId) {
|
||||
const row = teams.find((entry) => entry.id === teamId);
|
||||
if (row?.projectPath) {
|
||||
return row.projectPath;
|
||||
}
|
||||
}
|
||||
const teamKey = team ? readTrimmedString(team.key) : '';
|
||||
if (teamKey) {
|
||||
const row = teams.find((entry) => entry.key === teamKey);
|
||||
if (row?.projectPath) {
|
||||
return row.projectPath;
|
||||
}
|
||||
}
|
||||
return view?.defaultProjectPath || null;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { activateLinearAuth, getLinearAuth, setLinearAuth } from './auth.js';
|
||||
import {
|
||||
getLinearMappingFilePath,
|
||||
mergeLinearMappingView,
|
||||
readStoredLinearMapping,
|
||||
resolveMappedProjectPath,
|
||||
setStoredLinearMapping,
|
||||
} from './mapping.js';
|
||||
|
||||
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-mapping-'));
|
||||
|
||||
describe('Linear project mapping storage', () => {
|
||||
let dataDir;
|
||||
let previousDataDir;
|
||||
|
||||
beforeEach(() => {
|
||||
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
|
||||
dataDir = makeTempDir();
|
||||
process.env.OPENCHAMBER_DATA_DIR = dataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousDataDir === undefined) {
|
||||
delete process.env.OPENCHAMBER_DATA_DIR;
|
||||
} else {
|
||||
process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
|
||||
}
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('treats a missing file as empty mapping, not a failure', () => {
|
||||
expect(fs.existsSync(getLinearMappingFilePath())).toBe(false);
|
||||
expect(readStoredLinearMapping()).toEqual({
|
||||
defaultProjectPath: null,
|
||||
teamProjectPaths: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips a default project and per-team paths', () => {
|
||||
const written = setStoredLinearMapping({
|
||||
defaultProjectPath: '/Users/ada/openchamber',
|
||||
teamProjectPaths: {
|
||||
'team-eng': '/Users/ada/eng',
|
||||
'team-empty': ' ',
|
||||
},
|
||||
});
|
||||
expect(written).toEqual({
|
||||
defaultProjectPath: '/Users/ada/openchamber',
|
||||
teamProjectPaths: { 'team-eng': '/Users/ada/eng' },
|
||||
});
|
||||
expect(readStoredLinearMapping()).toEqual(written);
|
||||
expect(fs.statSync(getLinearMappingFilePath()).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('replaces the previous mapping on write', () => {
|
||||
setStoredLinearMapping({
|
||||
defaultProjectPath: '/old',
|
||||
teamProjectPaths: { 'team-eng': '/eng' },
|
||||
});
|
||||
const next = setStoredLinearMapping({
|
||||
defaultProjectPath: null,
|
||||
teamProjectPaths: {},
|
||||
});
|
||||
expect(next).toEqual({ defaultProjectPath: null, teamProjectPaths: {} });
|
||||
expect(readStoredLinearMapping()).toEqual(next);
|
||||
});
|
||||
|
||||
it('keeps tokens when a mapping write is rejected', () => {
|
||||
setLinearAuth({
|
||||
accessToken: 'access-keep',
|
||||
refreshToken: 'refresh-keep',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
});
|
||||
setStoredLinearMapping({
|
||||
defaultProjectPath: '/keep',
|
||||
teamProjectPaths: { 'team-eng': '/eng' },
|
||||
});
|
||||
expect(() => setStoredLinearMapping(null)).toThrow(/object/);
|
||||
expect(readStoredLinearMapping()).toEqual({
|
||||
defaultProjectPath: '/keep',
|
||||
teamProjectPaths: { 'team-eng': '/eng' },
|
||||
});
|
||||
expect(getLinearAuth().accessToken).toBe('access-keep');
|
||||
});
|
||||
|
||||
it('rejects a malformed mapping file instead of treating it as empty', () => {
|
||||
fs.writeFileSync(getLinearMappingFilePath(), '{not-json', 'utf8');
|
||||
expect(() => readStoredLinearMapping()).toThrow(/malformed/);
|
||||
});
|
||||
|
||||
it('merges live teams onto stored paths and resolves team then default', () => {
|
||||
const stored = {
|
||||
defaultProjectPath: '/default',
|
||||
teamProjectPaths: { 'team-eng': '/eng' },
|
||||
};
|
||||
const view = mergeLinearMappingView(stored, [
|
||||
{ id: 'team-eng', key: 'ENG', name: 'Engineering' },
|
||||
{ id: 'team-des', key: 'DES', name: 'Design' },
|
||||
]);
|
||||
expect(view).toEqual({
|
||||
defaultProjectPath: '/default',
|
||||
teams: [
|
||||
{ id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/eng' },
|
||||
{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null },
|
||||
],
|
||||
});
|
||||
expect(resolveMappedProjectPath(view, { id: 'team-eng', key: 'ENG' })).toBe('/eng');
|
||||
expect(resolveMappedProjectPath(view, { id: 'team-des', key: 'DES' })).toBe('/default');
|
||||
expect(resolveMappedProjectPath(view, null)).toBe('/default');
|
||||
});
|
||||
|
||||
it('keeps mapping slices isolated per workspace', () => {
|
||||
setLinearAuth({
|
||||
accessToken: 'access-a',
|
||||
user: { id: 'user-a', name: 'Ada' },
|
||||
organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' },
|
||||
});
|
||||
setStoredLinearMapping({
|
||||
defaultProjectPath: '/alpha',
|
||||
teamProjectPaths: { 'team-a': '/alpha-eng' },
|
||||
});
|
||||
|
||||
setLinearAuth({
|
||||
accessToken: 'access-b',
|
||||
user: { id: 'user-b', name: 'Ben' },
|
||||
organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' },
|
||||
});
|
||||
setStoredLinearMapping({
|
||||
defaultProjectPath: '/beta',
|
||||
teamProjectPaths: {},
|
||||
});
|
||||
expect(readStoredLinearMapping()).toEqual({
|
||||
defaultProjectPath: '/beta',
|
||||
teamProjectPaths: {},
|
||||
});
|
||||
|
||||
expect(activateLinearAuth('org-a')).toBe(true);
|
||||
expect(readStoredLinearMapping()).toEqual({
|
||||
defaultProjectPath: '/alpha',
|
||||
teamProjectPaths: { 'team-a': '/alpha-eng' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
getLinearClientId,
|
||||
getLinearClientSecret,
|
||||
getLinearBrokerUrl,
|
||||
getLinearRedirectUri,
|
||||
getLinearScopes,
|
||||
} from './auth.js';
|
||||
import { isPlainObject, isString, readFiniteNumber, readTrimmedString } from './parse.js';
|
||||
|
||||
export const LINEAR_AUTHORIZE_URL = 'https://linear.app/oauth/authorize';
|
||||
export const LINEAR_TOKEN_URL = 'https://api.linear.app/oauth/token';
|
||||
export const LINEAR_REVOKE_URL = 'https://api.linear.app/oauth/revoke';
|
||||
export const PENDING_AUTHORIZATION_TTL_MS = 10 * 60_000;
|
||||
|
||||
const pendingByState = new Map();
|
||||
const brokerPollsByState = new Map();
|
||||
|
||||
export class LinearOAuthError extends Error {
|
||||
constructor(message, code = 'LINEAR_OAUTH_FAILED') {
|
||||
super(message);
|
||||
this.name = 'LinearOAuthError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPkcePair() {
|
||||
const verifier = crypto.randomBytes(32).toString('base64url');
|
||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function pruneExpiredPending(now = Date.now()) {
|
||||
for (const [state, entry] of pendingByState.entries()) {
|
||||
if (!entry || entry.expiresAt <= now) {
|
||||
pendingByState.delete(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeScope(scope) {
|
||||
if (isString(scope)) {
|
||||
return scope.trim();
|
||||
}
|
||||
if (Array.isArray(scope)) {
|
||||
return scope.filter((item) => isString(item) && item.trim()).join(',');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function readExpiresAt(expiresIn, now = Date.now()) {
|
||||
const seconds = readFiniteNumber(expiresIn);
|
||||
if (seconds == null || seconds <= 0) {
|
||||
return now + 24 * 60 * 60 * 1000;
|
||||
}
|
||||
return now + Math.floor(seconds) * 1000;
|
||||
}
|
||||
|
||||
function parseTokenPayload(payload) {
|
||||
if (!isPlainObject(payload)) {
|
||||
throw new LinearOAuthError('Linear token response was empty');
|
||||
}
|
||||
if (readTrimmedString(payload.error)) {
|
||||
throw new LinearOAuthError(
|
||||
readTrimmedString(payload.error_description) || readTrimmedString(payload.error),
|
||||
readTrimmedString(payload.error).toUpperCase(),
|
||||
);
|
||||
}
|
||||
const accessToken = readTrimmedString(payload.access_token);
|
||||
if (!accessToken) {
|
||||
throw new LinearOAuthError('Linear token response was missing access_token');
|
||||
}
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: readTrimmedString(payload.refresh_token) || null,
|
||||
tokenType: readTrimmedString(payload.token_type) || 'bearer',
|
||||
expiresAt: readExpiresAt(payload.expires_in),
|
||||
scope: normalizeScope(payload.scope),
|
||||
};
|
||||
}
|
||||
|
||||
async function postForm(url, body) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(body).toString(),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const description = isPlainObject(payload)
|
||||
? (readTrimmedString(payload.error_description) || readTrimmedString(payload.error))
|
||||
: '';
|
||||
const error = new LinearOAuthError(
|
||||
description || `Linear token request failed (${response.status})`,
|
||||
readTrimmedString(payload?.error).toUpperCase() || 'LINEAR_OAUTH_FAILED',
|
||||
);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return parseTokenPayload(payload);
|
||||
}
|
||||
|
||||
async function readJsonResponse(response, fallbackMessage) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = isPlainObject(payload) && readTrimmedString(payload.error)
|
||||
? readTrimmedString(payload.error)
|
||||
: `${fallbackMessage} (${response.status})`;
|
||||
const error = new LinearOAuthError(message, 'LINEAR_BROKER_FAILED');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
if (!isPlainObject(payload)) {
|
||||
throw new LinearOAuthError(`${fallbackMessage}: invalid response`, 'LINEAR_BROKER_FAILED');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function brokerCallbackUrl(brokerUrl) {
|
||||
return `${brokerUrl.replace(/\/+$/, '')}/callback`;
|
||||
}
|
||||
|
||||
async function registerBrokerTransaction({ brokerUrl, state, claimSecret }) {
|
||||
const response = await fetch(`${brokerUrl}/start`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state, claimSecret }),
|
||||
});
|
||||
const payload = await readJsonResponse(response, 'Could not start Linear authorization broker');
|
||||
const redirectUri = readTrimmedString(payload.redirectUri);
|
||||
if (!redirectUri || redirectUri !== brokerCallbackUrl(brokerUrl)) {
|
||||
throw new LinearOAuthError('Linear authorization broker returned an unexpected callback URL', 'LINEAR_BROKER_FAILED');
|
||||
}
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
export async function startAuthorization({ origin } = {}) {
|
||||
const clientId = getLinearClientId();
|
||||
if (!clientId) {
|
||||
throw new LinearOAuthError(
|
||||
'Linear OAuth client not configured. Set OPENCHAMBER_LINEAR_CLIENT_ID.',
|
||||
'LINEAR_CLIENT_ID_MISSING',
|
||||
);
|
||||
}
|
||||
|
||||
pruneExpiredPending();
|
||||
const { verifier, challenge } = createPkcePair();
|
||||
const state = crypto.randomBytes(32).toString('base64url');
|
||||
const brokerUrl = getLinearBrokerUrl();
|
||||
const configuredRedirectUri = getLinearRedirectUri();
|
||||
const usesBroker = configuredRedirectUri === brokerCallbackUrl(brokerUrl);
|
||||
const claimSecret = usesBroker ? crypto.randomBytes(32).toString('base64url') : null;
|
||||
const redirectUri = usesBroker
|
||||
? await registerBrokerTransaction({ brokerUrl, state, claimSecret })
|
||||
: configuredRedirectUri;
|
||||
const scope = getLinearScopes();
|
||||
pendingByState.set(state, {
|
||||
codeVerifier: verifier,
|
||||
redirectUri,
|
||||
origin: origin === 'desktop' ? 'desktop' : 'web',
|
||||
broker: usesBroker ? { url: brokerUrl, claimSecret } : null,
|
||||
expiresAt: Date.now() + PENDING_AUTHORIZATION_TTL_MS,
|
||||
});
|
||||
|
||||
const url = new URL(LINEAR_AUTHORIZE_URL);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('redirect_uri', redirectUri);
|
||||
url.searchParams.set('scope', scope);
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('code_challenge', challenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
url.searchParams.set('actor', 'user');
|
||||
url.searchParams.set('prompt', 'consent');
|
||||
|
||||
return {
|
||||
authorizationUrl: url.toString(),
|
||||
expiresIn: Math.floor(PENDING_AUTHORIZATION_TTL_MS / 1000),
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
async function pollBrokerState(state, pending) {
|
||||
const response = await fetch(`${pending.broker.url}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state, claimSecret: pending.broker.claimSecret }),
|
||||
});
|
||||
if (response.status === 202) {
|
||||
return null;
|
||||
}
|
||||
const payload = await readJsonResponse(response, 'Could not read Linear authorization result');
|
||||
const status = readTrimmedString(payload.status);
|
||||
if (status === 'complete') {
|
||||
const result = await consumeAuthorizationCallback({ code: payload.code, state });
|
||||
return {
|
||||
...result,
|
||||
brokerReceipt: { state, ...pending.broker },
|
||||
};
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return consumeAuthorizationCallback({
|
||||
state,
|
||||
error: payload.error,
|
||||
errorDescription: payload.errorDescription,
|
||||
});
|
||||
}
|
||||
throw new LinearOAuthError('Linear authorization broker returned an unexpected result', 'LINEAR_BROKER_FAILED');
|
||||
}
|
||||
|
||||
export async function completeAuthorizationBroker(receipt) {
|
||||
if (!receipt?.url || !receipt?.state || !receipt?.claimSecret) return false;
|
||||
const response = await fetch(`${receipt.url}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state: receipt.state, claimSecret: receipt.claimSecret }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new LinearOAuthError(`Could not acknowledge Linear authorization result (${response.status})`, 'LINEAR_BROKER_FAILED');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function pollAuthorizationBroker() {
|
||||
pruneExpiredPending();
|
||||
for (const [state, pending] of pendingByState.entries()) {
|
||||
if (!pending?.broker) continue;
|
||||
let poll = brokerPollsByState.get(state);
|
||||
if (!poll) {
|
||||
poll = pollBrokerState(state, pending).finally(() => brokerPollsByState.delete(state));
|
||||
brokerPollsByState.set(state, poll);
|
||||
}
|
||||
const result = await poll;
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function failAuthorization(message, code, origin) {
|
||||
const error = new LinearOAuthError(message, code);
|
||||
if (origin) {
|
||||
error.origin = origin;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function consumeAuthorizationCallback({ code, state, error, errorDescription }) {
|
||||
pruneExpiredPending();
|
||||
const pending = readTrimmedString(state) ? pendingByState.get(state) : null;
|
||||
|
||||
if (readTrimmedString(error)) {
|
||||
if (readTrimmedString(state)) pendingByState.delete(state);
|
||||
throw failAuthorization(
|
||||
readTrimmedString(errorDescription) || readTrimmedString(error),
|
||||
readTrimmedString(error).toUpperCase(),
|
||||
pending?.origin,
|
||||
);
|
||||
}
|
||||
if (!readTrimmedString(code)) {
|
||||
if (readTrimmedString(state)) pendingByState.delete(state);
|
||||
throw failAuthorization(
|
||||
'Linear did not return an authorization code.',
|
||||
'MISSING_CODE',
|
||||
pending?.origin,
|
||||
);
|
||||
}
|
||||
if (!pending?.codeVerifier) {
|
||||
throw failAuthorization(
|
||||
'This authorization session has expired or is unknown to the running app. Return to OpenChamber and click Connect again.',
|
||||
'UNKNOWN_STATE',
|
||||
);
|
||||
}
|
||||
|
||||
const body = {
|
||||
grant_type: 'authorization_code',
|
||||
code: code.trim(),
|
||||
redirect_uri: pending.redirectUri,
|
||||
client_id: getLinearClientId(),
|
||||
code_verifier: pending.codeVerifier,
|
||||
};
|
||||
const clientSecret = getLinearClientSecret();
|
||||
if (clientSecret) {
|
||||
body.client_secret = clientSecret;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await postForm(LINEAR_TOKEN_URL, body);
|
||||
pendingByState.delete(state);
|
||||
return {
|
||||
...tokens,
|
||||
origin: pending.origin,
|
||||
};
|
||||
} catch (caught) {
|
||||
if (caught instanceof Error) {
|
||||
caught.origin = pending.origin;
|
||||
}
|
||||
throw caught;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken) {
|
||||
const token = readTrimmedString(refreshToken);
|
||||
if (!token) {
|
||||
throw new LinearOAuthError('refresh_token is required', 'MISSING_REFRESH_TOKEN');
|
||||
}
|
||||
const body = {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: token,
|
||||
client_id: getLinearClientId(),
|
||||
};
|
||||
const clientSecret = getLinearClientSecret();
|
||||
if (clientSecret) {
|
||||
body.client_secret = clientSecret;
|
||||
}
|
||||
return postForm(LINEAR_TOKEN_URL, body);
|
||||
}
|
||||
|
||||
export async function revokeToken(token, tokenTypeHint) {
|
||||
const value = readTrimmedString(token);
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const body = { token: value };
|
||||
if (tokenTypeHint === 'access_token' || tokenTypeHint === 'refresh_token') {
|
||||
body.token_type_hint = tokenTypeHint;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(LINEAR_REVOKE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(body).toString(),
|
||||
});
|
||||
return response.status === 200;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingAuthorizationsForTests() {
|
||||
pendingByState.clear();
|
||||
brokerPollsByState.clear();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
startAuthorization,
|
||||
consumeAuthorizationCallback,
|
||||
pollAuthorizationBroker,
|
||||
completeAuthorizationBroker,
|
||||
refreshAccessToken,
|
||||
clearPendingAuthorizationsForTests,
|
||||
} from './oauth.js';
|
||||
|
||||
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-oauth-'));
|
||||
|
||||
describe('Linear OAuth PKCE', () => {
|
||||
let dataDir;
|
||||
let previousDataDir;
|
||||
let previousPort;
|
||||
let previousRedirect;
|
||||
|
||||
beforeEach(() => {
|
||||
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
|
||||
previousPort = process.env.OPENCHAMBER_PORT;
|
||||
previousRedirect = process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
|
||||
dataDir = makeTempDir();
|
||||
process.env.OPENCHAMBER_DATA_DIR = dataDir;
|
||||
process.env.OPENCHAMBER_PORT = '3001';
|
||||
delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID;
|
||||
process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://127.0.0.1:3001/linear/oauth/callback';
|
||||
clearPendingAuthorizationsForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
clearPendingAuthorizationsForTests();
|
||||
restoreEnv('OPENCHAMBER_DATA_DIR', previousDataDir);
|
||||
restoreEnv('OPENCHAMBER_PORT', previousPort);
|
||||
restoreEnv('OPENCHAMBER_LINEAR_REDIRECT_URI', previousRedirect);
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates an S256 authorize URL and stores a pending verifier', async () => {
|
||||
const started = await startAuthorization({ origin: 'desktop' });
|
||||
const url = new URL(started.authorizationUrl);
|
||||
expect(url.origin + url.pathname).toBe('https://linear.app/oauth/authorize');
|
||||
expect(url.searchParams.get('client_id')).toBe('91bbe26a69a2c8568d3683f1e01e776c');
|
||||
expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:3001/linear/oauth/callback');
|
||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(url.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(url.searchParams.get('actor')).toBe('user');
|
||||
expect(url.searchParams.get('prompt')).toBe('consent');
|
||||
expect(started.scope).toBe('read,write,comments:create');
|
||||
expect(started.expiresIn).toBe(600);
|
||||
});
|
||||
|
||||
it('refuses a callback whose state was never started', async () => {
|
||||
const tokenFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', tokenFetch);
|
||||
await expect(consumeAuthorizationCallback({
|
||||
code: 'attacker-code',
|
||||
state: 'forged',
|
||||
})).rejects.toMatchObject({ code: 'UNKNOWN_STATE' });
|
||||
expect(tokenFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exchanges a matching code with the original PKCE verifier', async () => {
|
||||
const started = await startAuthorization({ origin: 'web' });
|
||||
const state = new URL(started.authorizationUrl).searchParams.get('state');
|
||||
const tokenFetch = vi.fn(async () => new Response(JSON.stringify({
|
||||
access_token: 'access-1',
|
||||
refresh_token: 'refresh-1',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 86399,
|
||||
scope: 'read,write,comments:create',
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal('fetch', tokenFetch);
|
||||
|
||||
const result = await consumeAuthorizationCallback({ code: 'auth-code', state });
|
||||
expect(result.accessToken).toBe('access-1');
|
||||
expect(result.refreshToken).toBe('refresh-1');
|
||||
expect(result.origin).toBe('web');
|
||||
|
||||
expect(tokenFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = tokenFetch.mock.calls[0];
|
||||
expect(String(url)).toBe('https://api.linear.app/oauth/token');
|
||||
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
const body = new URLSearchParams(init.body);
|
||||
expect(body.get('grant_type')).toBe('authorization_code');
|
||||
expect(body.get('code')).toBe('auth-code');
|
||||
expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(body.get('client_secret')).toBeNull();
|
||||
|
||||
await expect(consumeAuthorizationCallback({ code: 'auth-code', state })).rejects.toMatchObject({
|
||||
code: 'UNKNOWN_STATE',
|
||||
});
|
||||
});
|
||||
|
||||
it('persists a rotated refresh token from Linear', async () => {
|
||||
const tokenFetch = vi.fn(async () => new Response(JSON.stringify({
|
||||
access_token: 'access-2',
|
||||
refresh_token: 'refresh-2',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 86399,
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal('fetch', tokenFetch);
|
||||
const tokens = await refreshAccessToken('refresh-1');
|
||||
expect(tokens.accessToken).toBe('access-2');
|
||||
expect(tokens.refreshToken).toBe('refresh-2');
|
||||
const body = new URLSearchParams(tokenFetch.mock.calls[0][1].body);
|
||||
expect(body.get('grant_type')).toBe('refresh_token');
|
||||
expect(body.get('refresh_token')).toBe('refresh-1');
|
||||
});
|
||||
|
||||
it('claims a broker callback and exchanges it locally with PKCE', async () => {
|
||||
delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
|
||||
const brokerAndTokenFetch = vi.fn(async (url, init) => {
|
||||
const target = String(url);
|
||||
if (target.endsWith('/start')) {
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.state).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(body.claimSecret).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
return new Response(JSON.stringify({
|
||||
redirectUri: 'https://api.openchamber.dev/v1/oauth/linear/callback',
|
||||
expiresIn: 600,
|
||||
}), { status: 200 });
|
||||
}
|
||||
if (target.endsWith('/poll')) {
|
||||
return new Response(JSON.stringify({ status: 'complete', code: 'broker-code' }), { status: 200 });
|
||||
}
|
||||
if (target.endsWith('/complete')) {
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}
|
||||
if (target === 'https://api.linear.app/oauth/token') {
|
||||
const body = new URLSearchParams(init.body);
|
||||
expect(body.get('code')).toBe('broker-code');
|
||||
expect(body.get('redirect_uri')).toBe('https://api.openchamber.dev/v1/oauth/linear/callback');
|
||||
expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
return new Response(JSON.stringify({
|
||||
access_token: 'broker-access',
|
||||
refresh_token: 'broker-refresh',
|
||||
expires_in: 86399,
|
||||
}), { status: 200 });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', brokerAndTokenFetch);
|
||||
|
||||
const started = await startAuthorization({ origin: 'desktop' });
|
||||
const authorizationUrl = new URL(started.authorizationUrl);
|
||||
expect(authorizationUrl.searchParams.get('redirect_uri')).toBe('https://api.openchamber.dev/v1/oauth/linear/callback');
|
||||
|
||||
const result = await pollAuthorizationBroker();
|
||||
expect(result).toMatchObject({ accessToken: 'broker-access', origin: 'desktop' });
|
||||
await expect(completeAuthorizationBroker(result.brokerReceipt)).resolves.toBe(true);
|
||||
expect(brokerAndTokenFetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
function restoreEnv(name, previous) {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
return;
|
||||
}
|
||||
process.env[name] = previous;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export function isString(value) {
|
||||
return Object.prototype.toString.call(value) === '[object String]';
|
||||
}
|
||||
|
||||
export function isPlainObject(value) {
|
||||
if (value == null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return Object.getPrototypeOf(value) === Object.prototype;
|
||||
}
|
||||
|
||||
export function readTrimmedString(value) {
|
||||
return isString(value) && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function readFiniteNumber(value) {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function readEnv(name) {
|
||||
const raw = process.env[name];
|
||||
return raw ? raw.trim() : '';
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import express from 'express';
|
||||
import { readTrimmedString } from './parse.js';
|
||||
|
||||
const PENDING_JSON_LIMIT = '16kb';
|
||||
const parseJsonBody = express.json({ limit: PENDING_JSON_LIMIT });
|
||||
|
||||
function queryValue(req, key) {
|
||||
const raw = req.query?.[key];
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
return readTrimmedString(value);
|
||||
}
|
||||
|
||||
function isLinearUserError(error) {
|
||||
return error?.code === 'INVALID' || error?.userError === true;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderLinearOAuthCallbackPage({ title, message, desktopReturn }) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)} — OpenChamber</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: Canvas; color: CanvasText; }
|
||||
main { max-width: 34rem; padding: 2.5rem 2rem; text-align: center; }
|
||||
h1 { font-size: 1.25rem; margin: 0 0 0.75rem; }
|
||||
p { margin: 0; line-height: 1.5; opacity: 0.85; }
|
||||
a.return { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.25rem; border-radius: 0.5rem;
|
||||
border: 1px solid color-mix(in srgb, CanvasText 25%, transparent); color: inherit; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
${desktopReturn ? `<a class="return" href="openchamber://focus/linear-auth">Return to OpenChamber</a>
|
||||
<script>window.location.href = 'openchamber://focus/linear-auth';</script>` : ''}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function storeAuthorizationResult(libraries, result) {
|
||||
const { setLinearAuth, fetchLinearIdentity } = libraries;
|
||||
let user = null;
|
||||
let organization = null;
|
||||
try {
|
||||
const identity = await fetchLinearIdentity(result.accessToken);
|
||||
user = identity.user;
|
||||
organization = identity.organization;
|
||||
} catch (error) {
|
||||
console.error('Failed to load Linear identity after OAuth:', error);
|
||||
}
|
||||
return setLinearAuth({
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
tokenType: result.tokenType,
|
||||
expiresAt: result.expiresAt,
|
||||
scope: result.scope,
|
||||
user,
|
||||
organization,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLinearRoutes(app) {
|
||||
let linearLibraries = null;
|
||||
const getLinearLibraries = async () => {
|
||||
if (!linearLibraries) {
|
||||
linearLibraries = await import('./index.js');
|
||||
}
|
||||
return linearLibraries;
|
||||
};
|
||||
|
||||
app.get('/linear/oauth/callback', async (req, res) => {
|
||||
const finish = (status, { title, message, desktopReturn = false }) => {
|
||||
res.status(status).type('html').send(renderLinearOAuthCallbackPage({ title, message, desktopReturn }));
|
||||
};
|
||||
|
||||
try {
|
||||
const libraries = await getLinearLibraries();
|
||||
const { consumeAuthorizationCallback } = libraries;
|
||||
const result = await consumeAuthorizationCallback({
|
||||
code: queryValue(req, 'code'),
|
||||
state: queryValue(req, 'state'),
|
||||
error: queryValue(req, 'error'),
|
||||
errorDescription: queryValue(req, 'error_description'),
|
||||
});
|
||||
|
||||
await storeAuthorizationResult(libraries, result);
|
||||
|
||||
return finish(200, {
|
||||
title: 'Authorization Complete',
|
||||
message: 'You can close this tab and return to OpenChamber.',
|
||||
desktopReturn: result.origin === 'desktop',
|
||||
});
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.code : '';
|
||||
const status = code === 'UNKNOWN_STATE' || code === 'MISSING_CODE' || code === 'ACCESS_DENIED'
|
||||
? 400
|
||||
: 502;
|
||||
return finish(status, {
|
||||
title: 'Authorization Failed',
|
||||
message: error instanceof Error ? error.message : 'Linear authorization failed. Return to OpenChamber and click Connect again.',
|
||||
desktopReturn: error?.origin === 'desktop',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/linear/auth/status', async (_req, res) => {
|
||||
try {
|
||||
const libraries = await getLinearLibraries();
|
||||
const {
|
||||
getLinearAuth,
|
||||
getLinearAuthWorkspaces,
|
||||
getValidLinearAccessToken,
|
||||
fetchLinearIdentity,
|
||||
setLinearAuth,
|
||||
clearLinearAuth,
|
||||
toLinearPublicStatus,
|
||||
pollAuthorizationBroker,
|
||||
completeAuthorizationBroker,
|
||||
} = libraries;
|
||||
|
||||
try {
|
||||
const result = await pollAuthorizationBroker();
|
||||
if (result) {
|
||||
await storeAuthorizationResult(libraries, result);
|
||||
await completeAuthorizationBroker(result.brokerReceipt).catch((error) => {
|
||||
console.warn('Failed to acknowledge Linear authorization broker result:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to complete Linear authorization through broker:', error);
|
||||
}
|
||||
|
||||
const accessToken = await getValidLinearAccessToken();
|
||||
if (!accessToken) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const auth = getLinearAuth();
|
||||
try {
|
||||
const identity = await fetchLinearIdentity(accessToken);
|
||||
const next = setLinearAuth({
|
||||
accessToken,
|
||||
refreshToken: auth?.refreshToken,
|
||||
tokenType: auth?.tokenType,
|
||||
expiresAt: auth?.expiresAt,
|
||||
scope: auth?.scope,
|
||||
user: identity.user,
|
||||
organization: identity.organization,
|
||||
workspaceId: auth?.workspaceId,
|
||||
}, { activate: false });
|
||||
return res.json(toLinearPublicStatus(next, getLinearAuthWorkspaces()));
|
||||
} catch (error) {
|
||||
if (error?.status === 401) {
|
||||
clearLinearAuth(auth?.workspaceId);
|
||||
const remaining = getLinearAuth();
|
||||
if (!remaining) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
return res.json(toLinearPublicStatus(remaining, getLinearAuthWorkspaces()));
|
||||
}
|
||||
if (auth) {
|
||||
return res.json(toLinearPublicStatus(auth, getLinearAuthWorkspaces()));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get Linear auth status:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to get Linear auth status' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/linear/auth/start', parseJsonBody, async (req, res) => {
|
||||
try {
|
||||
const { startAuthorization } = await getLinearLibraries();
|
||||
const origin = req.body?.origin === 'desktop' ? 'desktop' : 'web';
|
||||
const payload = await startAuthorization({ origin });
|
||||
return res.json(payload);
|
||||
} catch (error) {
|
||||
const status = error?.code === 'LINEAR_CLIENT_ID_MISSING' ? 400 : 500;
|
||||
console.error('Failed to start Linear authorization:', error);
|
||||
return res.status(status).json({ error: error.message || 'Failed to start Linear authorization' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/linear/issues/list', async (req, res) => {
|
||||
try {
|
||||
const { listLinearIssues } = await getLinearLibraries();
|
||||
const result = await listLinearIssues({
|
||||
query: queryValue(req, 'query'),
|
||||
cursor: queryValue(req, 'cursor'),
|
||||
status: queryValue(req, 'status'),
|
||||
assignee: queryValue(req, 'assignee'),
|
||||
teamId: queryValue(req, 'teamId'),
|
||||
priority: queryValue(req, 'priority'),
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to list Linear issues:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to list Linear issues' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/linear/issues/get', async (req, res) => {
|
||||
try {
|
||||
const id = queryValue(req, 'id');
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'id is required' });
|
||||
}
|
||||
const { getLinearIssue } = await getLinearLibraries();
|
||||
const result = await getLinearIssue(id);
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to load Linear issue:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to load Linear issue' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/linear/issues/states', async (req, res) => {
|
||||
try {
|
||||
const teamId = queryValue(req, 'teamId');
|
||||
if (!teamId) {
|
||||
return res.status(400).json({ error: 'teamId is required' });
|
||||
}
|
||||
const { listLinearIssueStates } = await getLinearLibraries();
|
||||
const result = await listLinearIssueStates(teamId);
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
if (isLinearUserError(error)) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
console.error('Failed to load Linear workflow states:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to load Linear workflow states' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/linear/issues/update', parseJsonBody, async (req, res) => {
|
||||
try {
|
||||
const { updateLinearIssue } = await getLinearLibraries();
|
||||
const result = await updateLinearIssue({
|
||||
id: req.body?.id,
|
||||
stateId: req.body?.stateId,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
if (isLinearUserError(error)) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
console.error('Failed to update Linear issue:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to update Linear issue' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/linear/mapping', async (_req, res) => {
|
||||
try {
|
||||
const {
|
||||
listLinearTeams,
|
||||
readStoredLinearMapping,
|
||||
mergeLinearMappingView,
|
||||
LinearMappingError,
|
||||
} = await getLinearLibraries();
|
||||
const teamsResult = await listLinearTeams();
|
||||
if (teamsResult.connected === false) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
let stored;
|
||||
try {
|
||||
stored = readStoredLinearMapping();
|
||||
} catch (error) {
|
||||
if (error instanceof LinearMappingError && error.code === 'MALFORMED') {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return res.json({
|
||||
connected: true,
|
||||
...mergeLinearMappingView(stored, teamsResult.teams),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load Linear mapping:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to load Linear mapping' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/linear/mapping', parseJsonBody, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
getValidLinearAccessToken,
|
||||
listLinearTeams,
|
||||
setStoredLinearMapping,
|
||||
mergeLinearMappingView,
|
||||
LinearMappingError,
|
||||
} = await getLinearLibraries();
|
||||
const accessToken = await getValidLinearAccessToken();
|
||||
if (!accessToken) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
let stored;
|
||||
try {
|
||||
stored = setStoredLinearMapping(req.body);
|
||||
} catch (error) {
|
||||
if (error instanceof LinearMappingError && error.code === 'INVALID') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const teamsResult = await listLinearTeams();
|
||||
if (teamsResult.connected === false) {
|
||||
return res.json({
|
||||
connected: true,
|
||||
...mergeLinearMappingView(stored, []),
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
connected: true,
|
||||
...mergeLinearMappingView(stored, teamsResult.teams),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save Linear mapping:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to save Linear mapping' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/linear/session-status', parseJsonBody, async (req, res) => {
|
||||
try {
|
||||
const { postLinearSessionStatus, LinearSessionStatusError } = await getLinearLibraries();
|
||||
try {
|
||||
const result = await postLinearSessionStatus({
|
||||
kind: req.body?.kind,
|
||||
sessionId: req.body?.sessionId,
|
||||
issueIdentifier: req.body?.issueIdentifier,
|
||||
sessionOrigin: req.body?.sessionOrigin,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof LinearSessionStatusError && error.code === 'INVALID') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
if (error instanceof LinearSessionStatusError && error.code === 'MALFORMED') {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to post Linear session status:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to post Linear session status' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/linear/preferences', async (_req, res) => {
|
||||
try {
|
||||
const { getLinearSessionCommentsEnabled } = await getLinearLibraries();
|
||||
return res.json({ sessionComments: getLinearSessionCommentsEnabled() });
|
||||
} catch (error) {
|
||||
console.error('Failed to load Linear preferences:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to load Linear preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/linear/preferences', parseJsonBody, async (req, res) => {
|
||||
try {
|
||||
const sessionComments = req.body?.sessionComments;
|
||||
if (sessionComments !== true && sessionComments !== false) {
|
||||
return res.status(400).json({ error: 'sessionComments must be a boolean' });
|
||||
}
|
||||
const { setLinearSessionCommentsEnabled } = await getLinearLibraries();
|
||||
return res.json({ sessionComments: setLinearSessionCommentsEnabled(sessionComments) });
|
||||
} catch (error) {
|
||||
console.error('Failed to save Linear preferences:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to save Linear preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/linear/auth/activate', parseJsonBody, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
activateLinearAuth,
|
||||
getLinearAuth,
|
||||
getLinearAuthWorkspaces,
|
||||
toLinearPublicStatus,
|
||||
} = await getLinearLibraries();
|
||||
const organizationId = readTrimmedString(req.body?.organizationId);
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'organizationId is required' });
|
||||
}
|
||||
const activated = activateLinearAuth(organizationId);
|
||||
if (!activated) {
|
||||
return res.status(404).json({ error: 'Linear workspace not found' });
|
||||
}
|
||||
const auth = getLinearAuth();
|
||||
if (!auth) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
return res.json(toLinearPublicStatus(auth, getLinearAuthWorkspaces()));
|
||||
} catch (error) {
|
||||
console.error('Failed to switch Linear workspace:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to switch Linear workspace' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/linear/auth', async (_req, res) => {
|
||||
try {
|
||||
const { getLinearAuth, clearLinearAuth, revokeToken } = await getLinearLibraries();
|
||||
const auth = getLinearAuth();
|
||||
if (auth?.refreshToken) {
|
||||
await revokeToken(auth.refreshToken, 'refresh_token');
|
||||
} else if (auth?.accessToken) {
|
||||
await revokeToken(auth.accessToken, 'access_token');
|
||||
}
|
||||
const removed = clearLinearAuth(auth?.workspaceId);
|
||||
return res.json({ success: true, removed });
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect Linear:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to disconnect Linear' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { isPlainObject, readTrimmedString } from './parse.js';
|
||||
import { postLinearSessionStatus } from './status.js';
|
||||
|
||||
function readProperties(payload) {
|
||||
if (!isPlainObject(payload)) return {};
|
||||
return isPlainObject(payload.properties) ? payload.properties : {};
|
||||
}
|
||||
|
||||
function readNested(properties, key) {
|
||||
return isPlainObject(properties[key]) ? properties[key] : {};
|
||||
}
|
||||
|
||||
function extractSessionId(payload) {
|
||||
const properties = readProperties(payload);
|
||||
const info = readNested(properties, 'info');
|
||||
return readTrimmedString(info.sessionID)
|
||||
|| readTrimmedString(info.sessionId)
|
||||
|| readTrimmedString(properties.sessionID)
|
||||
|| readTrimmedString(properties.sessionId)
|
||||
|| readTrimmedString(properties.session);
|
||||
}
|
||||
|
||||
function extractStatusType(payload) {
|
||||
if (!isPlainObject(payload) || payload.type !== 'session.status') return '';
|
||||
const properties = readProperties(payload);
|
||||
const status = readNested(properties, 'status');
|
||||
const info = readNested(properties, 'info');
|
||||
return readTrimmedString(status.type) || readTrimmedString(info.type);
|
||||
}
|
||||
|
||||
function extractErrorName(payload) {
|
||||
if (!isPlainObject(payload) || payload.type !== 'session.error') return '';
|
||||
const properties = readProperties(payload);
|
||||
return readTrimmedString(readNested(properties, 'error').name);
|
||||
}
|
||||
|
||||
export function createLinearSessionStatusRuntime() {
|
||||
let stopped = false;
|
||||
|
||||
const processPayload = (payload) => {
|
||||
if (stopped) return;
|
||||
const sessionId = extractSessionId(payload);
|
||||
if (!sessionId) return;
|
||||
|
||||
if (isPlainObject(payload) && payload.type === 'session.error') {
|
||||
if (extractErrorName(payload) === 'MessageAbortedError') return;
|
||||
void postLinearSessionStatus({ kind: 'failure', sessionId }).catch((error) => {
|
||||
console.warn('[linear] failed to post session failure comment:', error?.message || error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (extractStatusType(payload) !== 'idle') return;
|
||||
void postLinearSessionStatus({ kind: 'completed', sessionId }).catch((error) => {
|
||||
console.warn('[linear] failed to post session completed comment:', error?.message || error);
|
||||
});
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
};
|
||||
|
||||
return { processPayload, stop };
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { setLinearAuth, clearLinearAuth, setLinearSessionCommentsEnabled } from './auth.js';
|
||||
import { createLinearSessionStatusRuntime } from './status-runtime.js';
|
||||
|
||||
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-status-runtime-'));
|
||||
|
||||
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',
|
||||
state: { name: 'In Progress', type: 'started' },
|
||||
assignee: null,
|
||||
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
|
||||
description: null,
|
||||
comments: { nodes: [] },
|
||||
};
|
||||
|
||||
function stubLinearGraphql({ commentId = 'comment-1' } = {}) {
|
||||
return vi.fn(async (_url, options) => {
|
||||
const body = JSON.parse(options.body);
|
||||
if (body.query.includes('query GetLinearIssue')) {
|
||||
return jsonResponse({ data: { issue: issueNode } });
|
||||
}
|
||||
if (body.query.includes('mutation CommentCreate')) {
|
||||
return jsonResponse({
|
||||
data: {
|
||||
commentCreate: {
|
||||
success: true,
|
||||
comment: { id: commentId },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected query: ${body.query}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe('Linear session status runtime', () => {
|
||||
let dataDir;
|
||||
let previousDataDir;
|
||||
let previousPort;
|
||||
|
||||
beforeEach(() => {
|
||||
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
|
||||
previousPort = process.env.OPENCHAMBER_PORT;
|
||||
dataDir = makeTempDir();
|
||||
process.env.OPENCHAMBER_DATA_DIR = dataDir;
|
||||
process.env.OPENCHAMBER_PORT = '3001';
|
||||
setLinearAuth({
|
||||
accessToken: 'access-1',
|
||||
refreshToken: 'refresh-1',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 86_400_000,
|
||||
scope: 'read,write,comments:create',
|
||||
});
|
||||
setLinearSessionCommentsEnabled(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
clearLinearAuth();
|
||||
if (previousDataDir === undefined) {
|
||||
delete process.env.OPENCHAMBER_DATA_DIR;
|
||||
} else {
|
||||
process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
|
||||
}
|
||||
if (previousPort === undefined) {
|
||||
delete process.env.OPENCHAMBER_PORT;
|
||||
} else {
|
||||
process.env.OPENCHAMBER_PORT = previousPort;
|
||||
}
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('posts completed on the first idle after started, then ignores later idles', async () => {
|
||||
const { postLinearSessionStatus } = await import('./status.js');
|
||||
vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'started' }));
|
||||
await postLinearSessionStatus({
|
||||
kind: 'started',
|
||||
sessionId: 'ses_1',
|
||||
issueIdentifier: 'ENG-12',
|
||||
sessionOrigin: 'https://app.example.com',
|
||||
});
|
||||
|
||||
const graphql = stubLinearGraphql({ commentId: 'done' });
|
||||
vi.stubGlobal('fetch', graphql);
|
||||
const runtime = createLinearSessionStatusRuntime();
|
||||
runtime.processPayload({
|
||||
type: 'session.status',
|
||||
properties: { sessionID: 'ses_1', status: { type: 'idle' } },
|
||||
});
|
||||
runtime.processPayload({
|
||||
type: 'session.status',
|
||||
properties: { sessionID: 'ses_1', status: { type: 'idle' } },
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
const commentCalls = graphql.mock.calls.filter(([, options]) => {
|
||||
return JSON.parse(options.body).query.includes('mutation CommentCreate');
|
||||
});
|
||||
expect(commentCalls).toHaveLength(1);
|
||||
});
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
it('posts failure on session.error and skips user abort', async () => {
|
||||
const { postLinearSessionStatus } = await import('./status.js');
|
||||
vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'started' }));
|
||||
await postLinearSessionStatus({
|
||||
kind: 'started',
|
||||
sessionId: 'ses_1',
|
||||
issueIdentifier: 'ENG-12',
|
||||
sessionOrigin: 'https://app.example.com',
|
||||
});
|
||||
|
||||
const graphql = stubLinearGraphql({ commentId: 'fail' });
|
||||
vi.stubGlobal('fetch', graphql);
|
||||
const runtime = createLinearSessionStatusRuntime();
|
||||
runtime.processPayload({
|
||||
type: 'session.error',
|
||||
properties: {
|
||||
sessionID: 'ses_1',
|
||||
error: { name: 'MessageAbortedError', message: 'stopped' },
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(graphql).not.toHaveBeenCalled();
|
||||
|
||||
runtime.processPayload({
|
||||
type: 'session.error',
|
||||
properties: {
|
||||
sessionID: 'ses_1',
|
||||
error: { name: 'ProviderError', message: 'boom' },
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
const commentCalls = graphql.mock.calls.filter(([, options]) => {
|
||||
return JSON.parse(options.body).query.includes('mutation CommentCreate');
|
||||
});
|
||||
expect(commentCalls).toHaveLength(1);
|
||||
const body = JSON.parse(commentCalls[0][1].body).variables.input.body;
|
||||
expect(body).toContain('OpenChamber session failed');
|
||||
});
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
it('does not treat busy as completed', async () => {
|
||||
const graphql = stubLinearGraphql();
|
||||
vi.stubGlobal('fetch', graphql);
|
||||
const runtime = createLinearSessionStatusRuntime();
|
||||
runtime.processPayload({
|
||||
type: 'session.status',
|
||||
properties: { sessionID: 'ses_1', status: { type: 'busy' } },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(graphql).not.toHaveBeenCalled();
|
||||
runtime.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getLinearAuth, getLinearAuthFilePath, getLinearSessionCommentsEnabled } from './auth.js';
|
||||
import { createLinearIssueComment } from './issues.js';
|
||||
import { isPlainObject, readTrimmedString } from './parse.js';
|
||||
|
||||
const LINEAR_SESSION_STATUS_KINDS = ['started', 'completed', 'failure'];
|
||||
const MAX_SESSION_STATUS_RECORDS = 500;
|
||||
|
||||
export class LinearSessionStatusError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'LinearSessionStatusError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const inflight = new Map();
|
||||
|
||||
function statusFile() {
|
||||
return path.join(path.dirname(getLinearAuthFilePath()), 'linear-session-status.json');
|
||||
}
|
||||
|
||||
function writeJsonFile(filePath, payload) {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
|
||||
try {
|
||||
fs.chmodSync(tmpFile, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
fs.renameSync(tmpFile, filePath);
|
||||
try {
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
const PRIVATE_HOST_SUFFIXES = ['.local', '.localhost', '.internal', '.lan', '.home.arpa'];
|
||||
|
||||
function isPrivateIpv4(hostname) {
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
const octets = parts.map((part) => (/^\d{1,3}$/.test(part) ? Number(part) : -1));
|
||||
if (octets.some((octet) => octet < 0 || octet > 255)) return false;
|
||||
const [a, b] = octets;
|
||||
if (a === 0 || a === 10 || a === 127) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
// 100.64.0.0/10 is carrier-grade NAT, which Tailscale and similar overlays use.
|
||||
if (a === 100 && b >= 64 && b <= 127) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateIpv6(hostname) {
|
||||
const address = hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
|
||||
if (address === '::1' || address === '::') return true;
|
||||
// fc00::/7 (unique local) and fe80::/10 (link local).
|
||||
return /^f[cd]/.test(address) || /^fe[89ab]/.test(address);
|
||||
}
|
||||
|
||||
/**
|
||||
* A session link is only worth writing into Linear when somebody other than the
|
||||
* person who started the session can open it. Loopback, private LAN and
|
||||
* overlay-network addresses reach nobody else, so they do not qualify.
|
||||
*/
|
||||
export function isPublicSessionOrigin(value) {
|
||||
const origin = readSessionOrigin(value);
|
||||
if (!origin) return false;
|
||||
let hostname;
|
||||
try {
|
||||
hostname = new URL(origin).hostname.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!hostname || hostname === 'localhost') return false;
|
||||
if (PRIVATE_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) return false;
|
||||
if (hostname.includes(':') || hostname.startsWith('[')) return !isPrivateIpv6(hostname);
|
||||
if (/^[\d.]+$/.test(hostname)) return !isPrivateIpv4(hostname);
|
||||
// A bare single-label host is a LAN machine name, not a routable address.
|
||||
return hostname.includes('.');
|
||||
}
|
||||
|
||||
export function readSessionOrigin(value) {
|
||||
const trimmed = readTrimmedString(value);
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
|
||||
if (url.username || url.password) return '';
|
||||
if (url.search || url.hash) return '';
|
||||
if (url.pathname && url.pathname !== '/') return '';
|
||||
return url.origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLinearSessionOpenUrl(sessionId, sessionOrigin) {
|
||||
const id = readTrimmedString(sessionId);
|
||||
const origin = readSessionOrigin(sessionOrigin);
|
||||
if (!origin) return '';
|
||||
return `${origin}/?session=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
function statusWord(kind) {
|
||||
if (kind === 'started') return 'started';
|
||||
if (kind === 'completed') return 'completed';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
export function buildLinearSessionStatusComment({ kind, sessionUrl }) {
|
||||
const url = readTrimmedString(sessionUrl);
|
||||
const label = `OpenChamber session ${statusWord(kind)}`;
|
||||
if (!url) return label;
|
||||
// The comment already lives on the issue, so it says only what happened and
|
||||
// links to the session. Issue titles routinely contain brackets ("[Bug] …"),
|
||||
// which would break this markdown link if they were repeated in the label.
|
||||
return `[${label}](${url})`;
|
||||
}
|
||||
|
||||
function readBooleanFlag(value) {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function readRecord(value) {
|
||||
if (!isPlainObject(value)) return null;
|
||||
const issueIdentifier = readTrimmedString(value.issueIdentifier);
|
||||
if (!issueIdentifier) return null;
|
||||
return {
|
||||
issueIdentifier,
|
||||
sessionOrigin: readSessionOrigin(value.sessionOrigin) || null,
|
||||
organizationId: readTrimmedString(value.organizationId) || null,
|
||||
started: readBooleanFlag(value.started),
|
||||
completed: readBooleanFlag(value.completed),
|
||||
failure: readBooleanFlag(value.failure),
|
||||
};
|
||||
}
|
||||
|
||||
function readRecords() {
|
||||
const filePath = statusFile();
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new LinearSessionStatusError('Linear session status file is malformed', 'MALFORMED');
|
||||
}
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new LinearSessionStatusError('Linear session status file is malformed', 'MALFORMED');
|
||||
}
|
||||
const next = {};
|
||||
for (const key of Object.keys(parsed)) {
|
||||
const sessionId = readTrimmedString(key);
|
||||
const record = readRecord(parsed[key]);
|
||||
if (sessionId && record) {
|
||||
next[sessionId] = record;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The file only exists to dedupe comments, so it does not need to remember
|
||||
* every session ever started. Keep the newest entries and drop the tail.
|
||||
*/
|
||||
export function pruneSessionStatusRecords(records, limit = MAX_SESSION_STATUS_RECORDS) {
|
||||
const keys = Object.keys(records);
|
||||
if (keys.length <= limit) {
|
||||
return records;
|
||||
}
|
||||
const kept = {};
|
||||
for (const key of keys.slice(keys.length - limit)) {
|
||||
kept[key] = records[key];
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
function writeRecords(records) {
|
||||
writeJsonFile(statusFile(), pruneSessionStatusRecords(records));
|
||||
}
|
||||
|
||||
async function postOnce(input) {
|
||||
const kind = readTrimmedString(input?.kind);
|
||||
const sessionId = readTrimmedString(input?.sessionId);
|
||||
if (!LINEAR_SESSION_STATUS_KINDS.includes(kind) || !sessionId) {
|
||||
throw new LinearSessionStatusError('kind and sessionId are required', 'INVALID');
|
||||
}
|
||||
|
||||
// Disconnected answers first so the picker and panel keep showing their
|
||||
// "connect Linear" state whatever the comment preference says.
|
||||
if (!getLinearAuth()) {
|
||||
return { connected: false };
|
||||
}
|
||||
if (!getLinearSessionCommentsEnabled()) {
|
||||
return { connected: true, posted: false, skipped: 'disabled' };
|
||||
}
|
||||
|
||||
const records = readRecords();
|
||||
const existing = records[sessionId] || null;
|
||||
if (existing?.[kind] === true) {
|
||||
return { connected: true, posted: false, skipped: 'already-posted' };
|
||||
}
|
||||
if (kind !== 'started' && existing?.started !== true) {
|
||||
return { connected: true, posted: false, skipped: 'not-started' };
|
||||
}
|
||||
|
||||
const issueIdentifier = readTrimmedString(input?.issueIdentifier)
|
||||
|| readTrimmedString(existing?.issueIdentifier);
|
||||
if (!issueIdentifier) {
|
||||
throw new LinearSessionStatusError('issueIdentifier is required', 'INVALID');
|
||||
}
|
||||
|
||||
const sessionOrigin = readSessionOrigin(input?.sessionOrigin)
|
||||
|| readTrimmedString(existing?.sessionOrigin);
|
||||
// Without an origin other people can reach, the comment would carry a link
|
||||
// only its author could open. Say nothing rather than publish a dead link.
|
||||
if (!isPublicSessionOrigin(sessionOrigin)) {
|
||||
return { connected: true, posted: false, skipped: 'origin-not-public' };
|
||||
}
|
||||
const sessionUrl = buildLinearSessionOpenUrl(sessionId, sessionOrigin);
|
||||
const organizationId = readTrimmedString(input?.organizationId)
|
||||
|| readTrimmedString(existing?.organizationId)
|
||||
|| readTrimmedString(getLinearAuth()?.workspaceId);
|
||||
const body = buildLinearSessionStatusComment({ kind, sessionUrl });
|
||||
const commentResult = await createLinearIssueComment({
|
||||
issueId: issueIdentifier,
|
||||
body,
|
||||
organizationId,
|
||||
});
|
||||
if (commentResult.connected === false) {
|
||||
return { connected: false };
|
||||
}
|
||||
if (!commentResult.comment) {
|
||||
return { connected: true, posted: false, skipped: 'issue-not-found' };
|
||||
}
|
||||
|
||||
records[sessionId] = {
|
||||
issueIdentifier,
|
||||
sessionOrigin: sessionOrigin || null,
|
||||
organizationId: organizationId || null,
|
||||
started: existing?.started === true || kind === 'started',
|
||||
completed: existing?.completed === true || kind === 'completed',
|
||||
failure: existing?.failure === true || kind === 'failure',
|
||||
};
|
||||
writeRecords(records);
|
||||
return {
|
||||
connected: true,
|
||||
posted: true,
|
||||
commentId: commentResult.comment.id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function postLinearSessionStatus(input) {
|
||||
const kind = readTrimmedString(input?.kind);
|
||||
const sessionId = readTrimmedString(input?.sessionId);
|
||||
const key = `${sessionId}:${kind}`;
|
||||
const pending = inflight.get(key);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
const promise = postOnce(input).finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { setLinearAuth, clearLinearAuth, setLinearSessionCommentsEnabled } from './auth.js';
|
||||
import {
|
||||
buildLinearSessionOpenUrl,
|
||||
buildLinearSessionStatusComment,
|
||||
isPublicSessionOrigin,
|
||||
postLinearSessionStatus,
|
||||
pruneSessionStatusRecords,
|
||||
readSessionOrigin,
|
||||
} from './status.js';
|
||||
|
||||
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-status-'));
|
||||
|
||||
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',
|
||||
state: { name: 'In Progress', type: 'started' },
|
||||
assignee: null,
|
||||
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
|
||||
description: null,
|
||||
comments: { nodes: [] },
|
||||
};
|
||||
|
||||
function stubLinearGraphql({ commentId = 'comment-1' } = {}) {
|
||||
return vi.fn(async (_url, options) => {
|
||||
const body = JSON.parse(options.body);
|
||||
if (body.query.includes('query GetLinearIssue')) {
|
||||
return jsonResponse({ data: { issue: issueNode } });
|
||||
}
|
||||
if (body.query.includes('mutation CommentCreate')) {
|
||||
expect(body.variables.input.issueId).toBe('issue-uuid-1');
|
||||
expect(body.variables.input.body).toContain('/?session=ses_1');
|
||||
return jsonResponse({
|
||||
data: {
|
||||
commentCreate: {
|
||||
success: true,
|
||||
comment: { id: commentId },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected query: ${body.query}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe('Linear session status comments', () => {
|
||||
let dataDir;
|
||||
let previousDataDir;
|
||||
let previousPort;
|
||||
|
||||
beforeEach(() => {
|
||||
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
|
||||
previousPort = process.env.OPENCHAMBER_PORT;
|
||||
dataDir = makeTempDir();
|
||||
process.env.OPENCHAMBER_DATA_DIR = dataDir;
|
||||
process.env.OPENCHAMBER_PORT = '3001';
|
||||
setLinearAuth({
|
||||
accessToken: 'access-1',
|
||||
refreshToken: 'refresh-1',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 86_400_000,
|
||||
scope: 'read,write,comments:create',
|
||||
});
|
||||
setLinearSessionCommentsEnabled(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
clearLinearAuth();
|
||||
if (previousDataDir === undefined) {
|
||||
delete process.env.OPENCHAMBER_DATA_DIR;
|
||||
} else {
|
||||
process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
|
||||
}
|
||||
if (previousPort === undefined) {
|
||||
delete process.env.OPENCHAMBER_PORT;
|
||||
} else {
|
||||
process.env.OPENCHAMBER_PORT = previousPort;
|
||||
}
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads http(s) origins and rejects other URLs', () => {
|
||||
expect(readSessionOrigin('https://app.example.com')).toBe('https://app.example.com');
|
||||
expect(readSessionOrigin('http://127.0.0.1:3001/')).toBe('http://127.0.0.1:3001');
|
||||
expect(readSessionOrigin('javascript:alert(1)')).toBe('');
|
||||
expect(readSessionOrigin('https://app.example.com/secret')).toBe('');
|
||||
expect(readSessionOrigin('openchamber:')).toBe('');
|
||||
expect(buildLinearSessionOpenUrl('ses_1', 'https://app.example.com'))
|
||||
.toBe('https://app.example.com/?session=ses_1');
|
||||
expect(buildLinearSessionOpenUrl('ses_1', '')).toBe('');
|
||||
});
|
||||
|
||||
it('treats only externally reachable origins as public', () => {
|
||||
expect(isPublicSessionOrigin('https://chamber.example.com')).toBe(true);
|
||||
expect(isPublicSessionOrigin('http://chamber.example.com:8080')).toBe(true);
|
||||
expect(isPublicSessionOrigin('https://203.0.113.10')).toBe(true);
|
||||
|
||||
expect(isPublicSessionOrigin('http://localhost:3001')).toBe(false);
|
||||
expect(isPublicSessionOrigin('http://127.0.0.1:3001')).toBe(false);
|
||||
expect(isPublicSessionOrigin('http://[::1]:3001')).toBe(false);
|
||||
expect(isPublicSessionOrigin('http://192.168.1.20:3001')).toBe(false);
|
||||
expect(isPublicSessionOrigin('http://10.0.0.5:3001')).toBe(false);
|
||||
expect(isPublicSessionOrigin('http://172.20.1.4:3001')).toBe(false);
|
||||
expect(isPublicSessionOrigin('http://169.254.10.1:3001')).toBe(false);
|
||||
expect(isPublicSessionOrigin('http://100.101.102.103:3001')).toBe(false);
|
||||
expect(isPublicSessionOrigin('http://macbook.local:3001')).toBe(false);
|
||||
expect(isPublicSessionOrigin('http://macbook:3001')).toBe(false);
|
||||
expect(isPublicSessionOrigin('http://[fd00::1]:3001')).toBe(false);
|
||||
expect(isPublicSessionOrigin('openchamber:')).toBe(false);
|
||||
expect(isPublicSessionOrigin('')).toBe(false);
|
||||
});
|
||||
|
||||
it('posts nothing while session comments are turned off', async () => {
|
||||
setLinearSessionCommentsEnabled(false);
|
||||
const graphql = vi.fn();
|
||||
vi.stubGlobal('fetch', graphql);
|
||||
await expect(postLinearSessionStatus({
|
||||
kind: 'started',
|
||||
sessionId: 'ses_1',
|
||||
issueIdentifier: 'ENG-12',
|
||||
sessionOrigin: 'https://app.example.com',
|
||||
})).resolves.toEqual({ connected: true, posted: false, skipped: 'disabled' });
|
||||
expect(graphql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts nothing when the session origin only the author can reach', async () => {
|
||||
const graphql = vi.fn();
|
||||
vi.stubGlobal('fetch', graphql);
|
||||
await expect(postLinearSessionStatus({
|
||||
kind: 'started',
|
||||
sessionId: 'ses_1',
|
||||
issueIdentifier: 'ENG-12',
|
||||
sessionOrigin: 'http://127.0.0.1:3001',
|
||||
})).resolves.toEqual({ connected: true, posted: false, skipped: 'origin-not-public' });
|
||||
await expect(postLinearSessionStatus({
|
||||
kind: 'started',
|
||||
sessionId: 'ses_2',
|
||||
issueIdentifier: 'ENG-12',
|
||||
})).resolves.toEqual({ connected: true, posted: false, skipped: 'origin-not-public' });
|
||||
expect(graphql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the newest dedupe records and drops the oldest', () => {
|
||||
const records = {};
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
records[`ses_${index}`] = { issueIdentifier: 'ENG-12', started: true };
|
||||
}
|
||||
expect(Object.keys(pruneSessionStatusRecords(records, 3))).toEqual(['ses_2', 'ses_3', 'ses_4']);
|
||||
expect(Object.keys(pruneSessionStatusRecords(records, 10))).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('makes the whole status line one link and carries no title', () => {
|
||||
expect(buildLinearSessionStatusComment({
|
||||
kind: 'started',
|
||||
sessionUrl: 'https://app.example.com/?session=ses_1',
|
||||
})).toBe('[OpenChamber session started](https://app.example.com/?session=ses_1)');
|
||||
expect(buildLinearSessionStatusComment({
|
||||
kind: 'completed',
|
||||
sessionUrl: 'https://app.example.com/?session=ses_1',
|
||||
})).toBe('[OpenChamber session completed](https://app.example.com/?session=ses_1)');
|
||||
expect(buildLinearSessionStatusComment({
|
||||
kind: 'failure',
|
||||
sessionUrl: 'https://app.example.com/?session=ses_1',
|
||||
})).toBe('[OpenChamber session failed](https://app.example.com/?session=ses_1)');
|
||||
});
|
||||
|
||||
it('cannot be broken by brackets in the issue title', async () => {
|
||||
const graphql = stubLinearGraphql();
|
||||
vi.stubGlobal('fetch', graphql);
|
||||
await postLinearSessionStatus({
|
||||
kind: 'started',
|
||||
sessionId: 'ses_1',
|
||||
issueIdentifier: 'ENG-12',
|
||||
sessionOrigin: 'https://app.example.com',
|
||||
});
|
||||
const commentCalls = graphql.mock.calls.filter(([, options]) => {
|
||||
return JSON.parse(options.body).query.includes('mutation CommentCreate');
|
||||
});
|
||||
const body = JSON.parse(commentCalls[0][1].body).variables.input.body;
|
||||
// One balanced pair of brackets, so a title like "[Bug] …" can never leak in
|
||||
// and split the link across the renderer.
|
||||
expect(body.match(/\[/g)).toHaveLength(1);
|
||||
expect(body.match(/\]/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns disconnected without calling Linear when there is no auth', async () => {
|
||||
clearLinearAuth();
|
||||
const graphql = vi.fn();
|
||||
vi.stubGlobal('fetch', graphql);
|
||||
await expect(postLinearSessionStatus({
|
||||
kind: 'started',
|
||||
sessionId: 'ses_1',
|
||||
issueIdentifier: 'ENG-12',
|
||||
})).resolves.toEqual({ connected: false });
|
||||
expect(graphql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts a started comment once and skips repeats', async () => {
|
||||
const graphql = stubLinearGraphql();
|
||||
vi.stubGlobal('fetch', graphql);
|
||||
|
||||
const first = await postLinearSessionStatus({
|
||||
kind: 'started',
|
||||
sessionId: 'ses_1',
|
||||
issueIdentifier: 'ENG-12',
|
||||
sessionOrigin: 'https://app.example.com',
|
||||
});
|
||||
expect(first).toEqual({ connected: true, posted: true, commentId: 'comment-1' });
|
||||
|
||||
const second = await postLinearSessionStatus({
|
||||
kind: 'started',
|
||||
sessionId: 'ses_1',
|
||||
issueIdentifier: 'ENG-12',
|
||||
sessionOrigin: 'https://app.example.com',
|
||||
});
|
||||
expect(second).toEqual({ connected: true, posted: false, skipped: 'already-posted' });
|
||||
|
||||
const commentCalls = graphql.mock.calls.filter(([, options]) => {
|
||||
return JSON.parse(options.body).query.includes('mutation CommentCreate');
|
||||
});
|
||||
expect(commentCalls).toHaveLength(1);
|
||||
const body = JSON.parse(commentCalls[0][1].body).variables.input.body;
|
||||
expect(body).toBe('[OpenChamber session started](https://app.example.com/?session=ses_1)');
|
||||
expect(JSON.stringify(first)).not.toContain('access-1');
|
||||
});
|
||||
|
||||
it('skips completed until started has been posted', async () => {
|
||||
const graphql = stubLinearGraphql();
|
||||
vi.stubGlobal('fetch', graphql);
|
||||
await expect(postLinearSessionStatus({
|
||||
kind: 'completed',
|
||||
sessionId: 'ses_1',
|
||||
})).resolves.toEqual({ connected: true, posted: false, skipped: 'not-started' });
|
||||
expect(graphql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts completed once after started, reusing the stored open URL', async () => {
|
||||
vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'comment-started' }));
|
||||
await postLinearSessionStatus({
|
||||
kind: 'started',
|
||||
sessionId: 'ses_1',
|
||||
issueIdentifier: 'ENG-12',
|
||||
sessionOrigin: 'https://app.example.com',
|
||||
});
|
||||
|
||||
const graphql = stubLinearGraphql({ commentId: 'comment-done' });
|
||||
vi.stubGlobal('fetch', graphql);
|
||||
const first = await postLinearSessionStatus({ kind: 'completed', sessionId: 'ses_1' });
|
||||
expect(first).toEqual({ connected: true, posted: true, commentId: 'comment-done' });
|
||||
const second = await postLinearSessionStatus({ kind: 'completed', sessionId: 'ses_1' });
|
||||
expect(second).toEqual({ connected: true, posted: false, skipped: 'already-posted' });
|
||||
|
||||
const commentCalls = graphql.mock.calls.filter(([, options]) => {
|
||||
return JSON.parse(options.body).query.includes('mutation CommentCreate');
|
||||
});
|
||||
expect(commentCalls).toHaveLength(1);
|
||||
const body = JSON.parse(commentCalls[0][1].body).variables.input.body;
|
||||
expect(body).toBe('[OpenChamber session completed](https://app.example.com/?session=ses_1)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { clearLinearAuth, getLinearAuth } from './auth.js';
|
||||
import { fetchLinearGraphql, getValidLinearAccessToken } from './client.js';
|
||||
import { isPlainObject, readTrimmedString } from './parse.js';
|
||||
|
||||
const TEAMS_QUERY = `
|
||||
query ListLinearTeams($first: Int!, $after: String) {
|
||||
teams(first: $first, after: $after) {
|
||||
nodes { id key name }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const PAGE_SIZE = 50;
|
||||
const MAX_PAGES = 20;
|
||||
|
||||
function readTeam(node) {
|
||||
if (!isPlainObject(node)) {
|
||||
return null;
|
||||
}
|
||||
const id = readTrimmedString(node.id);
|
||||
const key = readTrimmedString(node.key);
|
||||
const name = readTrimmedString(node.name);
|
||||
if (!id || !key || !name) {
|
||||
return null;
|
||||
}
|
||||
return { id, key, name };
|
||||
}
|
||||
|
||||
export async function listLinearTeams() {
|
||||
try {
|
||||
const token = await getValidLinearAccessToken();
|
||||
if (!token) {
|
||||
return { connected: false };
|
||||
}
|
||||
|
||||
const teams = [];
|
||||
let after = null;
|
||||
for (let page = 0; page < MAX_PAGES; page += 1) {
|
||||
const variables = { first: PAGE_SIZE };
|
||||
if (after) {
|
||||
variables.after = after;
|
||||
}
|
||||
const data = await fetchLinearGraphql(token, TEAMS_QUERY, variables);
|
||||
const connection = isPlainObject(data.teams) ? data.teams : null;
|
||||
const nodes = isPlainObject(connection) && Array.isArray(connection.nodes)
|
||||
? connection.nodes
|
||||
: [];
|
||||
for (const node of nodes) {
|
||||
const team = readTeam(node);
|
||||
if (team) {
|
||||
teams.push(team);
|
||||
}
|
||||
}
|
||||
const pageInfo = isPlainObject(connection) ? connection.pageInfo : null;
|
||||
if (!isPlainObject(pageInfo) || pageInfo.hasNextPage !== true) {
|
||||
break;
|
||||
}
|
||||
after = readTrimmedString(pageInfo.endCursor);
|
||||
if (!after) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { connected: true, teams };
|
||||
} catch (error) {
|
||||
if (error?.status === 401) {
|
||||
clearLinearAuth(getLinearAuth()?.workspaceId);
|
||||
return { connected: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { clearLinearAuth, setLinearAuth } from './auth.js';
|
||||
import { listLinearTeams } from './teams.js';
|
||||
|
||||
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-teams-'));
|
||||
|
||||
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
describe('Linear teams list', () => {
|
||||
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(listLinearTeams()).resolves.toEqual({ connected: false });
|
||||
expect(graphql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists teams across pages and never returns the token', async () => {
|
||||
const graphql = 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');
|
||||
if (!body.variables.after) {
|
||||
return jsonResponse({
|
||||
data: {
|
||||
teams: {
|
||||
nodes: [{ id: 'team-eng', key: 'ENG', name: 'Engineering' }],
|
||||
pageInfo: { hasNextPage: true, endCursor: 'cursor-2' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(body.variables.after).toBe('cursor-2');
|
||||
return jsonResponse({
|
||||
data: {
|
||||
teams: {
|
||||
nodes: [{ id: 'team-des', key: 'DES', name: 'Design' }],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', graphql);
|
||||
|
||||
const result = await listLinearTeams();
|
||||
expect(result).toEqual({
|
||||
connected: true,
|
||||
teams: [
|
||||
{ id: 'team-eng', key: 'ENG', name: 'Engineering' },
|
||||
{ id: 'team-des', key: 'DES', name: 'Design' },
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('access-1');
|
||||
expect(graphql).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('clears auth and reports disconnected after a GraphQL 401', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ errors: [{ message: 'Unauthorized' }] }, 401)));
|
||||
await expect(listLinearTeams()).resolves.toEqual({ connected: false });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { registerSmallModelRoutes } from '../small-model/routes.js';
|
||||
import { registerWalkthroughRoutes } from '../walkthrough/routes.js';
|
||||
import { registerSessionGoalRoutes } from '../session-goal/routes.js';
|
||||
import { registerGitHubRoutes } from '../github/routes.js';
|
||||
import { registerLinearRoutes } from '../linear/routes.js';
|
||||
import { registerGitLabRoutes } from '../gitlab/routes.js';
|
||||
import { registerGiteaRoutes } from '../gitea/routes.js';
|
||||
import { registerGitRoutes } from '../git/routes.js';
|
||||
@@ -303,6 +304,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
registerWalkthroughRoutes(app, { getWalkthroughService });
|
||||
registerSessionGoalRoutes(app);
|
||||
registerGitHubRoutes(app);
|
||||
registerLinearRoutes(app);
|
||||
registerGitLabRoutes(app);
|
||||
registerGiteaRoutes(app);
|
||||
registerGitProviderRoutes(app);
|
||||
|
||||
@@ -47,20 +47,20 @@ export const createStaticRoutesRuntime = (dependencies) => {
|
||||
normalizePwaOrientation,
|
||||
});
|
||||
|
||||
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
|
||||
app.get(/^(?!\/api|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`Warning: ${distPath} not found, static files will not be served`);
|
||||
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
|
||||
app.get(/^(?!\/api|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
|
||||
res.status(404).send('Static files not found. Please build the application first.');
|
||||
});
|
||||
};
|
||||
|
||||
const registerApiOnlyFallbackRoutes = (app) => {
|
||||
app.get(/^(?!\/api|\/auth|\/health|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
|
||||
app.get(/^(?!\/api|\/auth|\/health|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
|
||||
const command = 'openchamber connect-url --help';
|
||||
res.status(200).format({
|
||||
html: () => {
|
||||
|
||||
@@ -499,11 +499,20 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Normalized tasks for reading, plus the raw on-disk record of each one for
|
||||
// writing back. Normalization only keeps the fields THIS build knows, so a
|
||||
// write that re-serialized normalized tasks would strip every field added
|
||||
// by a newer build (or a newer UI) the moment an older server touched the
|
||||
// file — a goal or auto-accept setting silently lost after a task ran.
|
||||
// Writers therefore persist untouched tasks from `rawTasksByID` verbatim and
|
||||
// only serialize a normalized task where the task itself was deliberately
|
||||
// replaced.
|
||||
const readProjectConfigFromDisk = async (projectID) => {
|
||||
const parsed = await readRawProjectConfigFromDisk(projectID);
|
||||
const tasksRaw = Array.isArray(parsed.scheduledTasks) ? parsed.scheduledTasks : [];
|
||||
const now = Date.now();
|
||||
const scheduledTasks = [];
|
||||
const rawTasksByID = new Map();
|
||||
for (const task of tasksRaw) {
|
||||
try {
|
||||
const normalized = normalizeTaskForStorage(task, {
|
||||
@@ -514,15 +523,31 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
refreshUpdatedAt: false,
|
||||
});
|
||||
scheduledTasks.push(normalized);
|
||||
rawTasksByID.set(normalized.id, task);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks,
|
||||
rawTasksByID,
|
||||
};
|
||||
};
|
||||
|
||||
// The list to write: tasks this write replaced go out normalized; every
|
||||
// other task goes out exactly as stored, fields unknown to this build
|
||||
// included. A state-only update counts as untouched — only its `state` is
|
||||
// swapped onto the stored record. Callers keep working with (and returning)
|
||||
// the normalized tasks; only the bytes on disk differ.
|
||||
const toStoredTasks = (config, tasks, { replacedIDs = new Set(), stateUpdatedID = null } = {}) => (
|
||||
tasks.map((task) => {
|
||||
if (replacedIDs.has(task.id)) return task;
|
||||
const stored = config.rawTasksByID.get(task.id);
|
||||
if (!stored) return task;
|
||||
return task.id === stateUpdatedID ? { ...stored, state: task.state } : stored;
|
||||
})
|
||||
);
|
||||
|
||||
const writeProjectConfigToDisk = async (projectID, config) => {
|
||||
const filePath = resolveProjectConfigPath(projectID);
|
||||
const parentDirectory = path.dirname(filePath);
|
||||
@@ -607,7 +632,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
|
||||
const nextConfig = {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
scheduledTasks: toStoredTasks(current, nextTasks, { replacedIDs: new Set([normalizedTask.id]) }),
|
||||
};
|
||||
await writeProjectConfigToDisk(projectID, nextConfig);
|
||||
|
||||
@@ -633,7 +658,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
if (deleted) {
|
||||
await writeProjectConfigToDisk(projectID, {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
scheduledTasks: toStoredTasks(current, nextTasks),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -676,7 +701,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
|
||||
await writeProjectConfigToDisk(projectID, {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
scheduledTasks: toStoredTasks(current, nextTasks, { stateUpdatedID: nextTask.id }),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -737,7 +762,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
|
||||
await writeProjectConfigToDisk(projectID, {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
scheduledTasks: toStoredTasks(current, nextTasks, { stateUpdatedID: nextTask.id }),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -797,6 +822,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
|
||||
const consumedLoopPaths = new Set();
|
||||
const nextTasks = [];
|
||||
const replacedIDs = new Set();
|
||||
for (const task of tasks) {
|
||||
if (task.loopFile && !activeLoopFilePaths.has(task.loopFile)) {
|
||||
// The driving loop file was removed (or renamed) — unschedule.
|
||||
@@ -828,6 +854,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
},
|
||||
);
|
||||
nextTasks.push(adopted);
|
||||
replacedIDs.add(adopted.id);
|
||||
pendingLoops.delete(loop.definition.name);
|
||||
if (task.loopFile) {
|
||||
consumedLoopPaths.add(task.loopFile);
|
||||
@@ -863,6 +890,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
},
|
||||
);
|
||||
nextTasks.push(created);
|
||||
replacedIDs.add(created.id);
|
||||
} catch (error) {
|
||||
console.warn(`[scheduled-tasks] skipped loop ${loop.filePath}:`, error?.message ?? error);
|
||||
}
|
||||
@@ -870,7 +898,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
|
||||
await writeProjectConfigToDisk(projectID, {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
scheduledTasks: toStoredTasks(current, nextTasks, { replacedIDs }),
|
||||
});
|
||||
|
||||
return nextTasks;
|
||||
|
||||
@@ -14,6 +14,7 @@ const createRuntime = async () => {
|
||||
});
|
||||
return {
|
||||
runtime,
|
||||
tempRoot,
|
||||
cleanup: async () => {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
},
|
||||
@@ -472,6 +473,90 @@ describe('project-config loop reconciliation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('fields this build does not know', () => {
|
||||
// Simulates a config written by a newer build (or a newer UI): the task
|
||||
// carries execution and state fields normalization here has never heard of.
|
||||
const seedForeignTask = async (runtime, tempRoot) => {
|
||||
const created = await runtime.upsertScheduledTask('project-test', {
|
||||
name: 'Nightly digest',
|
||||
enabled: true,
|
||||
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
|
||||
execution: { prompt: 'Summarize', providerID: 'openai', modelID: 'gpt-4.1', goalEnabled: true },
|
||||
});
|
||||
const filePath = path.join(tempRoot, 'project-test.json');
|
||||
const stored = JSON.parse(await readFile(filePath, 'utf8'));
|
||||
stored.scheduledTasks[0].execution.futureExecutionField = 'keep me';
|
||||
stored.scheduledTasks[0].state.futureStateField = 42;
|
||||
stored.scheduledTasks[0].futureTopLevelField = true;
|
||||
await writeFile(filePath, JSON.stringify(stored, null, 2), 'utf8');
|
||||
return { id: created.task.id, filePath };
|
||||
};
|
||||
|
||||
const readStoredTask = async (filePath, id) => {
|
||||
const stored = JSON.parse(await readFile(filePath, 'utf8'));
|
||||
return stored.scheduledTasks.find((task) => task.id === id);
|
||||
};
|
||||
|
||||
it('survive a state update after a run, and the claim update', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
const { id, filePath } = await seedForeignTask(runtime, tempRoot);
|
||||
|
||||
await runtime.updateScheduledTaskState('project-test', id, { lastStatus: 'success', lastRunAt: 1000 });
|
||||
let stored = await readStoredTask(filePath, id);
|
||||
expect(stored.execution.futureExecutionField).toBe('keep me');
|
||||
expect(stored.execution.goalEnabled).toBe(true);
|
||||
expect(stored.futureTopLevelField).toBe(true);
|
||||
expect(stored.state.lastStatus).toBe('success');
|
||||
|
||||
await runtime.updateScheduledTaskStateIf('project-test', id, () => true, { lastScheduledFor: 5000 });
|
||||
stored = await readStoredTask(filePath, id);
|
||||
expect(stored.execution.futureExecutionField).toBe('keep me');
|
||||
expect(stored.state.lastScheduledFor).toBe(5000);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('survive writes that replace or delete a different task, and a loop sync', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
const { id, filePath } = await seedForeignTask(runtime, tempRoot);
|
||||
|
||||
const other = await runtime.upsertScheduledTask('project-test', {
|
||||
id: 'other-task',
|
||||
name: 'Other',
|
||||
enabled: true,
|
||||
schedule: { kind: 'daily', time: '10:00', timezone: 'UTC' },
|
||||
execution: { prompt: 'Other', providerID: 'openai', modelID: 'gpt-4.1' },
|
||||
});
|
||||
expect((await readStoredTask(filePath, id)).execution.futureExecutionField).toBe('keep me');
|
||||
|
||||
await runtime.deleteScheduledTask('project-test', other.task.id);
|
||||
expect((await readStoredTask(filePath, id)).execution.futureExecutionField).toBe('keep me');
|
||||
|
||||
await runtime.reconcileLoopTasks('project-test', []);
|
||||
expect((await readStoredTask(filePath, id)).execution.futureExecutionField).toBe('keep me');
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('are dropped only when the task itself is deliberately saved', async () => {
|
||||
const { runtime, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
const { id, filePath } = await seedForeignTask(runtime, tempRoot);
|
||||
const [task] = await runtime.listScheduledTasks('project-test');
|
||||
await runtime.upsertScheduledTask('project-test', { ...task, name: 'Renamed' });
|
||||
const stored = await readStoredTask(filePath, id);
|
||||
expect(stored.name).toBe('Renamed');
|
||||
expect(stored.execution.futureExecutionField).toBeUndefined();
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('conditionally updates state only when the predicate passes (occurrence claim)', async () => {
|
||||
const { runtime, cleanup } = await createRuntime();
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,13 @@ in shared project config under the project write lock:
|
||||
from the winner's persisted `nextRunAt`.
|
||||
- Project config writes also take a cross-process `.json.lock` file so the
|
||||
read-modify-write is serialized across processes, not only within one process.
|
||||
- The sharing processes may run different OpenChamber versions. Normalization
|
||||
keeps only the fields a build knows, so every writer persists tasks it did
|
||||
not change verbatim from disk and swaps only `state` onto a task whose state
|
||||
it updated; a task goes out normalized only when it was deliberately
|
||||
replaced (upsert, loop adoption). An older server touching the file after a
|
||||
run therefore cannot strip fields a newer build added, such as a task's goal
|
||||
or auto-accept settings.
|
||||
- Lock timeout / filesystem errors on claim, manual-start, or completion state
|
||||
writes always release the in-process running slot (via `finally`) and best-effort
|
||||
re-arm the **next future** occurrence; they must not leave the task permanently
|
||||
|
||||
@@ -11,6 +11,7 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API.
|
||||
- `packages/web/server/lib/text/summarization.js`: Shared text summarization stub and sanitization utilities. It performs no external Zen calls.
|
||||
- `packages/web/server/lib/tts/stt.js`: STT proxy for OpenAI-compatible transcription endpoints.
|
||||
- `packages/web/server/lib/tts/base-url.js`: shared base URL validation and normalization for custom OpenAI-compatible endpoints.
|
||||
- `packages/web/server/lib/tts/language-detect.js`: dependency-free language detection for voice selection (`detectTextLanguage`, `pickVoiceForLanguage`, `languageOfLocale`). Used by the macOS `say` route (`language: 'auto'` switches to an installed voice whose locale matches the text; the response carries `X-Speech-Voice` and `X-Speech-Language`) and by the dictation module's local TTS model choice.
|
||||
|
||||
## Public exports
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Language detection for text-to-speech voice selection.
|
||||
*
|
||||
* Picks the language a piece of chat text is written in so a TTS provider
|
||||
* can choose a matching voice or model. Deliberately small and dependency
|
||||
* free: the writing system decides most cases outright, and Latin-script
|
||||
* languages are told apart by function words and characteristic letters.
|
||||
* The answer is a best effort for voice selection, not a linguistic claim —
|
||||
* an unknown language falls back to English rather than failing.
|
||||
*/
|
||||
|
||||
const SCRIPT_RANGES = [
|
||||
['hangul', /[가-ᄀ-ᇿ-]/g],
|
||||
['kana', /[-ヿ]/g],
|
||||
['han', /[一-鿿㐀-䶿]/g],
|
||||
['cyrillic', /[Ѐ-ӿ]/g],
|
||||
['greek', /[Ͱ-Ͽ]/g],
|
||||
['arabic', /[-ۿ]/g],
|
||||
['hebrew', /[-]/g],
|
||||
['thai', /[-]/g],
|
||||
['devanagari', /[ऀ-ॿ]/g],
|
||||
['latin', /[A-Za-zÀ-ɏ]/g],
|
||||
];
|
||||
|
||||
const SCRIPT_LANGUAGE = {
|
||||
hangul: 'ko',
|
||||
greek: 'el',
|
||||
arabic: 'ar',
|
||||
hebrew: 'he',
|
||||
thai: 'th',
|
||||
devanagari: 'hi',
|
||||
};
|
||||
|
||||
// Letters that only (or overwhelmingly) occur in one language of a script.
|
||||
const LATIN_MARKERS = {
|
||||
pl: /[łęąńśźż]/i,
|
||||
cs: /[řěůťďň]/i,
|
||||
tr: /[ğışİ]/,
|
||||
pt: /[ãõ]/i,
|
||||
es: /[ñ¿¡]/,
|
||||
de: /[ß]/,
|
||||
fr: /[œ]/i,
|
||||
sv: /[å]/i,
|
||||
};
|
||||
|
||||
// Frequent function words per language. Scored by whole-word hits; every
|
||||
// list has the same length so scores stay comparable.
|
||||
const STOPWORDS = {
|
||||
en: ['the', 'and', 'is', 'to', 'of', 'that', 'you', 'with', 'for', 'this', 'are', 'it', 'not', 'have', 'can', 'will', 'your', 'from', 'which', 'when'],
|
||||
de: ['und', 'der', 'die', 'das', 'ist', 'nicht', 'mit', 'ein', 'eine', 'auch', 'sich', 'auf', 'für', 'wird', 'werden', 'oder', 'aber', 'wenn', 'sind', 'kann'],
|
||||
fr: ['le', 'la', 'les', 'et', 'est', 'une', 'des', 'pour', 'que', 'qui', 'dans', 'pas', 'vous', 'sur', 'avec', 'sont', 'nous', 'cette', 'mais', 'plus'],
|
||||
es: ['el', 'la', 'los', 'las', 'que', 'es', 'una', 'por', 'para', 'con', 'del', 'como', 'pero', 'más', 'este', 'esta', 'son', 'tiene', 'puede', 'también'],
|
||||
it: ['il', 'la', 'che', 'di', 'è', 'una', 'per', 'non', 'con', 'del', 'della', 'come', 'sono', 'anche', 'questo', 'questa', 'gli', 'nel', 'più', 'essere'],
|
||||
pt: ['o', 'a', 'os', 'as', 'que', 'é', 'uma', 'para', 'com', 'não', 'do', 'da', 'como', 'mas', 'também', 'este', 'esta', 'são', 'você', 'pode'],
|
||||
pl: ['i', 'nie', 'jest', 'się', 'na', 'to', 'że', 'jak', 'ale', 'dla', 'oraz', 'przez', 'czy', 'tym', 'jego', 'można', 'jeśli', 'tego', 'które', 'także'],
|
||||
nl: ['de', 'het', 'een', 'en', 'van', 'is', 'niet', 'dat', 'met', 'voor', 'ook', 'zijn', 'maar', 'als', 'wordt', 'deze', 'kan', 'naar', 'bij', 'dan'],
|
||||
cs: ['a', 'je', 'se', 'na', 'to', 'že', 'jak', 'ale', 'pro', 'nebo', 'jsou', 'může', 'také', 'tento', 'když', 'jeho', 'které', 'být', 'aby', 'ještě'],
|
||||
tr: ['ve', 'bir', 'bu', 'için', 'ile', 'de', 'da', 'ama', 'gibi', 'daha', 'var', 'olarak', 'çok', 'ne', 'her', 'kadar', 'sonra', 'değil', 'olan', 'ise'],
|
||||
sv: ['och', 'att', 'det', 'är', 'en', 'som', 'för', 'inte', 'med', 'till', 'den', 'kan', 'har', 'ett', 'men', 'också', 'eller', 'från', 'när', 'vara'],
|
||||
uk: ['і', 'та', 'що', 'це', 'не', 'як', 'для', 'він', 'вона', 'але', 'або', 'також', 'тільки', 'вже', 'якщо', 'його', 'цей', 'ця', 'бути', 'коли'],
|
||||
ru: ['и', 'что', 'это', 'не', 'как', 'для', 'он', 'она', 'но', 'или', 'также', 'только', 'уже', 'если', 'его', 'этот', 'эта', 'быть', 'когда', 'чтобы'],
|
||||
};
|
||||
|
||||
const LATIN_LANGUAGES = ['en', 'de', 'fr', 'es', 'it', 'pt', 'pl', 'nl', 'cs', 'tr', 'sv'];
|
||||
const CYRILLIC_LANGUAGES = ['uk', 'ru'];
|
||||
|
||||
const countMatches = (text, pattern) => {
|
||||
const matches = text.match(pattern);
|
||||
return matches ? matches.length : 0;
|
||||
};
|
||||
|
||||
const scoreStopwords = (words, languages) => {
|
||||
const scores = {};
|
||||
for (const language of languages) {
|
||||
const list = new Set(STOPWORDS[language]);
|
||||
let hits = 0;
|
||||
for (const word of words) {
|
||||
if (list.has(word)) hits += 1;
|
||||
}
|
||||
scores[language] = hits;
|
||||
}
|
||||
return scores;
|
||||
};
|
||||
|
||||
const bestOf = (scores, fallback) => {
|
||||
let best = fallback;
|
||||
let bestScore = 0;
|
||||
for (const [language, score] of Object.entries(scores)) {
|
||||
if (score > bestScore) {
|
||||
best = language;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const pickByMarkers = (text, markers) => {
|
||||
for (const [language, pattern] of Object.entries(markers)) {
|
||||
if (pattern.test(text)) return language;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {{ language: string, script: string }} BCP-47 primary language subtag and the dominant script.
|
||||
*/
|
||||
export function detectTextLanguage(text) {
|
||||
const source = typeof text === 'string' ? text : '';
|
||||
const counts = SCRIPT_RANGES.map(([script, pattern]) => [script, countMatches(source, pattern)]);
|
||||
const letters = counts.reduce((sum, [, count]) => sum + count, 0);
|
||||
if (letters === 0) return { language: 'en', script: 'latin' };
|
||||
|
||||
// Kana settles Japanese even when Han dominates the character count.
|
||||
const kana = counts.find(([script]) => script === 'kana')?.[1] ?? 0;
|
||||
const han = counts.find(([script]) => script === 'han')?.[1] ?? 0;
|
||||
if (kana > 0 && kana + han >= letters * 0.3) return { language: 'ja', script: 'kana' };
|
||||
if (han > 0 && han >= letters * 0.3) return { language: 'zh', script: 'han' };
|
||||
|
||||
const [script] = counts.reduce((best, entry) => (entry[1] > best[1] ? entry : best));
|
||||
|
||||
if (script in SCRIPT_LANGUAGE) return { language: SCRIPT_LANGUAGE[script], script };
|
||||
|
||||
const words = source.toLowerCase().split(/[^\p{L}\p{M}']+/u).filter(Boolean);
|
||||
|
||||
if (script === 'cyrillic') {
|
||||
const scores = scoreStopwords(words, CYRILLIC_LANGUAGES);
|
||||
const ukMarkers = countMatches(source, /[іїєґ]/gi);
|
||||
const ruMarkers = countMatches(source, /[ыэъё]/gi);
|
||||
// Letters decide: the two alphabets differ in letters that occur in
|
||||
// nearly every sentence. Function words only settle a text that shows
|
||||
// neither set, and a text with no Russian-only letters is far more
|
||||
// likely Ukrainian than the reverse, so that tie goes to Ukrainian.
|
||||
if (ukMarkers !== ruMarkers) return { language: ukMarkers > ruMarkers ? 'uk' : 'ru', script };
|
||||
if (scores.uk !== scores.ru) return { language: scores.uk > scores.ru ? 'uk' : 'ru', script };
|
||||
return { language: ruMarkers > 0 ? 'ru' : 'uk', script };
|
||||
}
|
||||
|
||||
const scores = scoreStopwords(words, LATIN_LANGUAGES);
|
||||
const marked = pickByMarkers(source, LATIN_MARKERS);
|
||||
// A characteristic letter outranks stopword counts unless another language
|
||||
// clearly dominates the function words (a German text quoting "façade").
|
||||
if (marked && scores[marked] * 2 >= scores[bestOf(scores, marked)]) {
|
||||
return { language: marked, script };
|
||||
}
|
||||
return { language: bestOf(scores, 'en'), script };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a detected language onto the locales a voice list uses (`uk_UA`,
|
||||
* `en_US`...). Returns the preferred locale prefixes in order.
|
||||
* @param {string} language
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function localePrefixesForLanguage(language) {
|
||||
const table = {
|
||||
en: ['en_US', 'en_GB', 'en'],
|
||||
uk: ['uk_UA', 'uk'],
|
||||
ru: ['ru_RU', 'ru'],
|
||||
de: ['de_DE', 'de'],
|
||||
fr: ['fr_FR', 'fr_CA', 'fr'],
|
||||
es: ['es_ES', 'es_MX', 'es'],
|
||||
it: ['it_IT', 'it'],
|
||||
pt: ['pt_BR', 'pt_PT', 'pt'],
|
||||
pl: ['pl_PL', 'pl'],
|
||||
nl: ['nl_NL', 'nl_BE', 'nl'],
|
||||
cs: ['cs_CZ', 'cs'],
|
||||
tr: ['tr_TR', 'tr'],
|
||||
sv: ['sv_SE', 'sv'],
|
||||
zh: ['zh_CN', 'zh_TW', 'zh_HK', 'zh'],
|
||||
ja: ['ja_JP', 'ja'],
|
||||
ko: ['ko_KR', 'ko'],
|
||||
el: ['el_GR', 'el'],
|
||||
ar: ['ar_001', 'ar_SA', 'ar'],
|
||||
he: ['he_IL', 'he'],
|
||||
th: ['th_TH', 'th'],
|
||||
hi: ['hi_IN', 'hi'],
|
||||
};
|
||||
return table[language] ?? [language];
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose a voice for a language from a `say`-style voice list.
|
||||
* Prefers an enhanced/premium variant of a matching voice, then any voice of
|
||||
* the exact locale, then any voice of the language. Returns null when the
|
||||
* list has no voice for that language.
|
||||
* @param {string} language
|
||||
* @param {ReadonlyArray<{ name: string, locale: string }>} voices
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function pickVoiceForLanguage(language, voices) {
|
||||
const prefixes = localePrefixesForLanguage(language);
|
||||
for (const prefix of prefixes) {
|
||||
const matching = voices.filter((voice) => voice.locale === prefix || voice.locale.startsWith(`${prefix}_`) || (prefix === language && voice.locale.startsWith(`${language}_`)));
|
||||
if (matching.length === 0) continue;
|
||||
const enhanced = matching.find((voice) => /\((Enhanced|Premium)\)/i.test(voice.name));
|
||||
return (enhanced ?? matching[0]).name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Language of a voice, from its locale (`uk_UA` → `uk`).
|
||||
* @param {string | null | undefined} locale
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function languageOfLocale(locale) {
|
||||
if (typeof locale !== 'string' || !locale) return null;
|
||||
return locale.split(/[_-]/)[0].toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { detectTextLanguage, languageOfLocale, pickVoiceForLanguage } from './language-detect.js';
|
||||
|
||||
describe('detectTextLanguage', () => {
|
||||
it.each([
|
||||
['en', 'The build is green and the tests pass, so you can merge this now.'],
|
||||
['uk', 'Привіт! Це тестове повідомлення, і воно написане українською мовою.'],
|
||||
['ru', 'Привет! Это тестовое сообщение, и оно написано на русском языке.'],
|
||||
['de', 'Die Änderung ist fertig und die Tests laufen ohne Fehler durch.'],
|
||||
['fr', 'La modification est prête et les tests passent sans erreur.'],
|
||||
['es', 'El cambio está listo y las pruebas pasan sin errores.'],
|
||||
['it', 'La modifica è pronta e i test passano senza errori.'],
|
||||
['pt', 'A alteração está pronta e os testes passam sem erros, você pode continuar.'],
|
||||
['pl', 'Zmiana jest gotowa i testy przechodzą bez błędów.'],
|
||||
['nl', 'De wijziging is klaar en de tests slagen zonder fouten.'],
|
||||
['cs', 'Změna je hotová a testy procházejí bez chyb.'],
|
||||
['tr', 'Değişiklik hazır ve testler hatasız geçiyor.'],
|
||||
['sv', 'Ändringen är klar och testerna går igenom utan fel.'],
|
||||
['zh', '修改已经完成,所有测试都通过了。'],
|
||||
['ja', '変更が完了し、すべてのテストに合格しました。'],
|
||||
['ko', '변경이 완료되었고 모든 테스트를 통과했습니다.'],
|
||||
])('detects %s', (language, text) => {
|
||||
expect(detectTextLanguage(text).language).toBe(language);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['uk', 'Готово. Запушено.'],
|
||||
['uk', 'Все ок'],
|
||||
['uk', 'Добре, давай так зробимо'],
|
||||
['ru', 'Хорошо, давай так и сделаем'],
|
||||
['ru', 'Готово, всё запушено.'],
|
||||
])('tells short %s phrases apart by letters', (language, text) => {
|
||||
expect(detectTextLanguage(text).language).toBe(language);
|
||||
});
|
||||
|
||||
it('falls back to English for text without letters', () => {
|
||||
expect(detectTextLanguage('1234 ... !!!').language).toBe('en');
|
||||
expect(detectTextLanguage('').language).toBe('en');
|
||||
});
|
||||
|
||||
it('does not let a single quoted foreign word flip an English paragraph', () => {
|
||||
const text = 'The façade of the building is the part that you see from the street, and it is not the same as the interior.';
|
||||
expect(detectTextLanguage(text).language).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickVoiceForLanguage', () => {
|
||||
const voices = [
|
||||
{ name: 'Samantha', locale: 'en_US' },
|
||||
{ name: 'Daniel', locale: 'en_GB' },
|
||||
{ name: 'Lesya', locale: 'uk_UA' },
|
||||
{ name: 'Lesya (Enhanced)', locale: 'uk_UA' },
|
||||
{ name: 'Milena', locale: 'ru_RU' },
|
||||
{ name: 'Anna', locale: 'de_DE' },
|
||||
];
|
||||
|
||||
it('prefers the enhanced variant of a matching voice', () => {
|
||||
expect(pickVoiceForLanguage('uk', voices)).toBe('Lesya (Enhanced)');
|
||||
});
|
||||
|
||||
it('prefers the primary locale of a language', () => {
|
||||
expect(pickVoiceForLanguage('en', voices)).toBe('Samantha');
|
||||
});
|
||||
|
||||
it('returns null when no voice speaks the language', () => {
|
||||
expect(pickVoiceForLanguage('ja', voices)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('languageOfLocale', () => {
|
||||
it('reads the language subtag', () => {
|
||||
expect(languageOfLocale('uk_UA')).toBe('uk');
|
||||
expect(languageOfLocale('en-GB')).toBe('en');
|
||||
expect(languageOfLocale(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import express from 'express';
|
||||
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
|
||||
import { summarizeText, sanitizeForTTS, sanitizeForNote } from '../text/summarization.js';
|
||||
|
||||
import { detectTextLanguage, languageOfLocale, pickVoiceForLanguage } from './language-detect.js';
|
||||
|
||||
export function registerTtsRoutes(app, { sayTTSCapability }) {
|
||||
let ttsModulePromise = null;
|
||||
const getTtsModule = async () => {
|
||||
@@ -154,7 +156,8 @@ export function registerTtsRoutes(app, { sayTTSCapability }) {
|
||||
// macOS 'say' command TTS speak endpoint
|
||||
app.post('/api/tts/say/speak', async (req, res) => {
|
||||
try {
|
||||
const { text, voice = 'Samantha', rate = 200 } = req.body || {};
|
||||
const { text, rate = 200, language, languageSample } = req.body || {};
|
||||
let voice = typeof req.body?.voice === 'string' && req.body.voice.trim() ? req.body.voice.trim() : 'Samantha';
|
||||
|
||||
if (!text || typeof text !== 'string' || !text.trim()) {
|
||||
return res.status(400).json({ error: 'Text is required' });
|
||||
@@ -164,6 +167,23 @@ export function registerTtsRoutes(app, { sayTTSCapability }) {
|
||||
if (process.platform !== 'darwin') {
|
||||
return res.status(503).json({ error: 'macOS say command not available on this platform' });
|
||||
}
|
||||
|
||||
// `language: 'auto'`: keep the chosen voice while it speaks the text's
|
||||
// language, otherwise switch to an installed voice that does. A
|
||||
// language with no installed voice keeps the chosen voice — say still
|
||||
// reads the text, just with an accent — rather than failing.
|
||||
let resolvedLanguage = null;
|
||||
if (language === 'auto') {
|
||||
const capability = await sayTTSCapability;
|
||||
const voices = Array.isArray(capability?.voices) ? capability.voices : [];
|
||||
const sample = typeof languageSample === 'string' && languageSample.trim() ? languageSample.slice(0, 4000) : text;
|
||||
resolvedLanguage = detectTextLanguage(sample).language;
|
||||
const chosen = voices.find((entry) => entry.name === voice);
|
||||
if (languageOfLocale(chosen?.locale) !== resolvedLanguage) {
|
||||
const match = pickVoiceForLanguage(resolvedLanguage, voices);
|
||||
if (match) voice = match;
|
||||
}
|
||||
}
|
||||
|
||||
const { exec } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
@@ -195,6 +215,8 @@ export function registerTtsRoutes(app, { sayTTSCapability }) {
|
||||
|
||||
// Send audio response
|
||||
res.setHeader('Content-Type', 'audio/mp4');
|
||||
res.setHeader('X-Speech-Voice', voice);
|
||||
if (resolvedLanguage) res.setHeader('X-Speech-Language', resolvedLanguage);
|
||||
res.setHeader('Content-Length', audioBuffer.length);
|
||||
res.send(audioBuffer);
|
||||
|
||||
|
||||
@@ -33,6 +33,32 @@ describe('tts routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('switches the say voice to the language of the text when asked to', async () => {
|
||||
const capability = Promise.resolve({
|
||||
available: true,
|
||||
voices: [
|
||||
{ name: 'Samantha', locale: 'en_US' },
|
||||
{ name: 'Lesya', locale: 'uk_UA' },
|
||||
{ name: 'Lesya (Enhanced)', locale: 'uk_UA' },
|
||||
],
|
||||
});
|
||||
const app = createApp(capability);
|
||||
const response = await request(app)
|
||||
.post('/api/tts/say/speak')
|
||||
.send({ text: 'Привіт! Це відповідь українською мовою, і вона досить довга.', voice: 'Samantha', language: 'auto' });
|
||||
|
||||
// On macOS the route synthesizes; elsewhere it refuses before running say.
|
||||
// Either way the chosen voice must be the Ukrainian one when the platform
|
||||
// allows the request to proceed.
|
||||
if (process.platform === 'darwin') {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['x-speech-voice']).toBe('Lesya (Enhanced)');
|
||||
expect(response.headers['x-speech-language']).toBe('uk');
|
||||
} else {
|
||||
expect(response.status).toBe(503);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns local note fallback while model summarization is retired', async () => {
|
||||
const response = await request(createApp())
|
||||
.post('/api/text/summarize')
|
||||
|
||||
Reference in New Issue
Block a user