OPE-296: Add linear integration for starting sessions from issues (#3235)

* feat(linear): start sessions from Linear issues
Authorize a Linear workspace on this OpenChamber server, map teams to
projects, attach an issue from chat, start a session or worktree from an
issue, and post started/completed/failed comments that open the session.
Hidden in VS Code.

* feat(linear): connect more than one Linear workspace

Store each OAuth grant on this OpenChamber server and keep one current, so Settings can add and switch workspaces without dropping the others. Project mapping is per workspace. Remove the Linear button next to New Chat; start-from-issue stays on New Worktree.

* feat(linear): add a right-hand issues panel

Browse and filter issues in the rail, open a card to change status or start a session, and collapse search plus most filters to icons on a narrow panel.

* feat(linear): open issues in the rail and filter by Linear status

The rail icon only shows after Linear is connected. Clicking a Linear row on work status opens the panel. Status options match the card, including Done, Canceled, and Duplicate. The Integrations experimental warning sits under Third-party integrations.

* fix(linear): use stable OAuth callback broker

* fix(chat): preview Linear issue attachments

The context switch missed linear-issue, so tsc treated the preview helpers as incomplete.

* fix(ui): restore Linear i18n parity and the #2903 sync harness

Turkish was missing the Linear dictionaries, and the subagent test still wrapped only SyncContext after reads moved to SyncRuntimeContext.

* fix(linear): drop changelog hunks and close review races

Keep changelogs out of this PR, restore CodeMirror ranges, ignore stale Linear list pages, and leave a persisted Linear tab open until auth has actually resolved.

* fix(linear): tint active issue filters and clear them in one click

* fix(markdown): read escaped brackets as text, not display math

`\[...\]` is display math in LaTeX and an escaped bracket pair in
CommonMark. The block tokenizer claimed every `\[`, so prose like
`[title \[Bug\] more](url)` was handed to KaTeX: "Bug" rendered as a
centered formula and the block token split the paragraph, tearing the
link into three pieces. Linear, GitHub and any other source that escapes
brackets the way CommonMark requires hit this.

Display math now has to own its line — `\[` starts one and `\]` ends
one. A formula on its own line still renders; `\[` mid-sentence stays an
escape, which is what CommonMark says it is and what prose almost always
means. Inline `\(...\)` keeps the same ambiguity, but inline math is
legitimately mid-sentence, so there is no position to judge it by.

Covered by regression tests, including the verbatim comment body that
surfaced this.

* feat(linear): make session status comments opt-in and public-only

A status comment lands in a Linear workspace the whole team reads, and
the link it carried pointed at whatever origin started the session —
usually loopback or a LAN address. Everyone but its author got a dead
link, and nobody had agreed to the comments in the first place.

Comments are now off until the user turns them on in Settings ->
Integrations -> Linear, and the check lives on the server: the event hub
posts completed and failure without going through the interface, so a
client-side gate would not hold. When the resolved origin is not
publicly reachable the server posts nothing at all rather than a link
only its author can open; `isPublicSessionOrigin` rejects loopback,
private LAN, carrier-grade NAT, link-local and single-label hosts. The
desktop deep-link origin is gone with it, since no one else can follow
one either.

The comment body also dropped the session title. It repeated the issue
the comment already sits on, and issue titles routinely carry brackets
("[Bug] ...") that broke the markdown link. The body is now one short
link, and `sessionTitle` is gone from the route, client and types.

Also caps the dedupe file at the newest 500 sessions; it grew forever.

* fix(linear): match the pull request panel and clear review findings

Comments in the Linear panel now render as the same avatar timeline the
pull request panel uses, with the shared time-format preference instead
of a raw locale string. Comment authors carry `avatarUrl`, which the
GraphQL selection was not requesting.

Review findings from the same pass:

- `status-runtime.js` hand-rolled `typeof` narrowing and failed the
  vendored anti-slop lint; it now parses through `parse.js` like every
  other file in the module.
- `useLinearAuthStore` turned any failed request into `connected: false`
  with `hasChecked: true`. Since the rail icon, the composer entry and
  the worktree option all gate on `connected === true`, one network blip
  hid Linear for the rest of the session, and Settings only re-checked
  when it had never checked. It now keeps the last known status and
  leaves `hasChecked` false so the next caller retries.
- `LinearIssuesView` (1096 lines) was a static import in `ContextPanel`,
  shipping in the main bundle although its rail icon stays hidden until
  a workspace is connected. It is lazy now, like `GitView`.
- Dropped dead code: the unused port helpers left over from the loopback
  callback, two re-exported default values nothing read, and a redundant
  export in `linkedIssues`.
- Integrations is no longer badged beta.
This commit is contained in:
Alex Kutas
2026-08-30 02:18:40 +03:00
committed by GitHub
parent 1fdb78dbbe
commit 49f0a9e62f
118 changed files with 11888 additions and 143 deletions
+4
View File
@@ -77,6 +77,7 @@ import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
import { createSessionAssistRuntime } from './lib/session-assist/runtime.js';
import { createSessionGoalRuntime } from './lib/session-goal/runtime.js';
import { createContextObligatoryRuntime } from './lib/context-obligatory/runtime.js';
import { createLinearSessionStatusRuntime } from './lib/linear/status-runtime.js';
import { createSessionKnowledgeRuntime } from './lib/session-knowledge/runtime.js';
import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js';
import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js';
@@ -856,6 +857,8 @@ const contextObligatoryRuntime = createContextObligatoryRuntime({
sessionKnowledgeRuntime,
});
const linearSessionStatusRuntime = createLinearSessionStatusRuntime();
const globalMessageStreamHub = createGlobalMessageStreamHub({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
@@ -901,6 +904,7 @@ globalMessageStreamHub.subscribeEvent((event) => {
sessionAssistRuntime.processPayload(payload, directory);
sessionGoalRuntime.processPayload(payload, directory);
contextObligatoryRuntime.processPayload(payload, directory);
linearSessionStatusRuntime.processPayload(payload);
});
const processForwardedEventPayload = (payload, emitSyntheticEvent) => {
@@ -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` (04), 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
```
+436
View File
@@ -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;
+242
View File
@@ -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;
}
+191
View File
@@ -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;
}
+61
View File
@@ -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';
+499
View File
@@ -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,
});
});
});
+188
View File
@@ -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' },
});
});
});
+345
View File
@@ -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;
}
+23
View File
@@ -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() : '';
}
+432
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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();
});
});
+280
View File
@@ -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)');
});
});
+72
View File
@@ -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 { registerGitRoutes } from '../git/routes.js';
import { registerDevServerRoutes } from '../dev-servers/routes.js';
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
@@ -300,6 +301,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
registerWalkthroughRoutes(app, { getWalkthroughService });
registerSessionGoalRoutes(app);
registerGitHubRoutes(app);
registerLinearRoutes(app);
registerGitRoutes(app);
registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts });
registerMagicPromptRoutes(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: () => {
+2
View File
@@ -15,6 +15,7 @@ import { createWebNotificationsAPI } from './notifications';
import { createWebToolsAPI } from './tools';
import { createWebPushAPI } from './push';
import { createWebGitHubAPI } from './github';
import { createWebLinearAPI } from './linear';
import { createWebClientAuthAPI } from './clientAuth';
export interface WebAPIsOptions {
@@ -45,6 +46,7 @@ export const createWebAPIs = (options: WebAPIsOptions = {}): RuntimeAPIs => {
permissions: createWebPermissionsAPI(),
notifications: createWebNotificationsAPI(),
github: createWebGitHubAPI({ urls: activeUrls }),
linear: createWebLinearAPI(),
push: createWebPushAPI(),
clientAuth: createWebClientAuthAPI(),
tools: createWebToolsAPI(),
+609
View File
@@ -0,0 +1,609 @@
import type {
LinearAPI,
LinearAuthOrigin,
LinearAuthStart,
LinearAuthStatus,
LinearIssue,
LinearIssueAssignee,
LinearIssueComment,
LinearIssueLabel,
LinearIssuePriority,
LinearIssueGetResult,
LinearIssueState,
LinearIssueStatesResult,
LinearIssueUpdateInput,
LinearIssueUpdateResult,
LinearIssueSummary,
LinearIssueTeam,
LinearIssuesListOptions,
LinearIssuesListResult,
LinearMappingResult,
LinearMappingWrite,
LinearOrganizationSummary,
LinearPreferences,
LinearSessionStatusPostInput,
LinearSessionStatusPostResult,
LinearTeamMapping,
LinearWorkflowState,
LinearUserSummary,
LinearWorkspaceSummary,
} from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
type LinearJson = {
connected?: boolean;
user?: LinearUserSummary | null;
organization?: LinearOrganizationSummary | null;
scope?: string;
workspaces?: LinearWorkspaceSummary[];
authorizationUrl?: string;
expiresIn?: number;
removed?: boolean;
error?: string;
issues?: LinearIssueSummary[];
cursor?: string | null;
hasMore?: boolean;
issue?: LinearIssue | null;
states?: LinearWorkflowState[];
defaultProjectPath?: string | null;
teams?: LinearTeamMapping[];
posted?: boolean;
skipped?: string;
commentId?: string | null;
sessionComments?: boolean;
};
async function readLinearJson(response: Response): Promise<LinearJson | null> {
try {
return await response.json();
} catch {
return null;
}
}
function readErrorMessage(payload: LinearJson | null, fallback: string): string {
const error = payload?.error?.trim();
return error || fallback;
}
function readFiniteNumber(value: number | null | undefined): number | null {
return Number.isFinite(value) ? (value ?? null) : null;
}
function readRawString(value: string | null | undefined): string | null {
return Object.prototype.toString.call(value) === '[object String]' ? `${value}` : null;
}
function parseUser(payload: LinearUserSummary | null | undefined): LinearUserSummary | null {
const id = payload?.id?.trim();
if (!id) return null;
return {
id,
name: payload?.name?.trim() || null,
displayName: payload?.displayName?.trim() || null,
email: payload?.email?.trim() || null,
avatarUrl: payload?.avatarUrl?.trim() || null,
};
}
function parseOrganization(payload: LinearOrganizationSummary | null | undefined): LinearOrganizationSummary | null {
const id = payload?.id?.trim();
const name = payload?.name?.trim();
if (!id || !name) return null;
return {
id,
name,
urlKey: payload?.urlKey?.trim() || null,
};
}
function parseWorkspace(payload: LinearWorkspaceSummary | null | undefined): LinearWorkspaceSummary | null {
const id = payload?.id?.trim();
if (!id) return null;
const authorizedAt = payload?.authorizedAt;
return {
id,
name: payload?.name?.trim() || null,
urlKey: payload?.urlKey?.trim() || null,
current: payload?.current === true,
user: parseUser(payload?.user),
authorizedAt: readFiniteNumber(authorizedAt),
};
}
function toAuthStatus(payload: LinearJson | null): LinearAuthStatus | null {
if (payload?.connected !== true && payload?.connected !== false) {
return null;
}
const workspaces = Array.isArray(payload.workspaces)
? payload.workspaces.map(parseWorkspace).filter((entry): entry is LinearWorkspaceSummary => entry != null)
: [];
return {
connected: payload.connected,
user: parseUser(payload.user),
organization: parseOrganization(payload.organization),
scope: payload.scope?.trim() || undefined,
workspaces: payload.connected ? workspaces : undefined,
};
}
function toAuthStart(payload: LinearJson | null): LinearAuthStart | null {
const authorizationUrl = payload?.authorizationUrl?.trim();
const expiresIn = payload?.expiresIn;
const scope = payload?.scope?.trim();
if (!authorizationUrl || !Number.isFinite(expiresIn) || expiresIn == null || !scope) {
return null;
}
return { authorizationUrl, expiresIn, scope };
}
function parseState(payload: LinearIssueState | null | undefined): LinearIssueState | null {
const id = payload?.id?.trim() || null;
const name = payload?.name?.trim() || null;
const type = payload?.type?.trim() || null;
if (!id && !name && !type) return null;
return { id, name, type };
}
function parseWorkflowState(payload: LinearWorkflowState | null | undefined): LinearWorkflowState | null {
const id = payload?.id?.trim();
const name = payload?.name?.trim();
if (!id || !name) return null;
const position = payload?.position;
return {
id,
name,
type: payload?.type?.trim() || null,
position: readFiniteNumber(position) ?? 0,
};
}
function parseAssignee(payload: LinearIssueAssignee | null | undefined): LinearIssueAssignee | null {
const name = payload?.name?.trim() || null;
const displayName = payload?.displayName?.trim() || null;
const avatarUrl = payload?.avatarUrl?.trim() || null;
if (!name && !displayName && !avatarUrl) return null;
return { name, displayName, avatarUrl };
}
function parseTeam(payload: LinearIssueTeam | null | undefined): LinearIssueTeam | null {
const id = payload?.id?.trim();
const key = payload?.key?.trim();
const name = payload?.name?.trim();
if (!id || !key || !name) return null;
return { id, key, name };
}
function parsePriority(value: LinearIssueSummary['priority']): LinearIssuePriority | null {
if (value !== 0 && value !== 1 && value !== 2 && value !== 3 && value !== 4) {
return null;
}
return value;
}
function parseLabelColor(value: string | null | undefined): string | null {
const raw = value?.trim();
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 parseLabel(payload: LinearIssueLabel | null | undefined): LinearIssueLabel | null {
if (!payload) return null;
const id = payload?.id?.trim();
const name = payload?.name?.trim();
if (!id || !name) return null;
return {
id,
name,
color: parseLabelColor(payload.color),
};
}
function parseLabels(payload: LinearIssueSummary['labels']): LinearIssueLabel[] {
if (!Array.isArray(payload)) return [];
return payload.map(parseLabel).filter((label): label is LinearIssueLabel => label != null);
}
function parseIssueSummary(payload: LinearIssueSummary | null | undefined): LinearIssueSummary | null {
if (!payload) return null;
const id = payload?.id?.trim();
const identifier = payload?.identifier?.trim();
const title = payload?.title?.trim();
const url = payload?.url?.trim();
if (!id || !identifier || !title || !url) return null;
return {
id,
identifier,
title,
url,
state: parseState(payload.state),
assignee: parseAssignee(payload.assignee),
team: parseTeam(payload.team),
priority: parsePriority(payload.priority),
labels: parseLabels(payload.labels),
};
}
function parseComment(payload: LinearIssueComment | null | undefined): LinearIssueComment | null {
const id = payload?.id?.trim();
if (!id) return null;
const body = payload?.body;
return {
id,
body: readRawString(body) ?? '',
createdAt: payload?.createdAt?.trim() || null,
user: payload?.user
? {
name: payload.user.name?.trim() || null,
displayName: payload.user.displayName?.trim() || null,
avatarUrl: payload.user.avatarUrl?.trim() || null,
}
: null,
};
}
function parseIssue(payload: LinearIssue | null | undefined): LinearIssue | null {
const summary = parseIssueSummary(payload);
if (!summary) return null;
const comments = Array.isArray(payload?.comments)
? payload.comments.map(parseComment).filter((comment): comment is LinearIssueComment => comment != null)
: [];
const description = payload?.description;
return {
...summary,
description: readRawString(description),
comments,
};
}
function toIssuesList(payload: LinearJson | null): LinearIssuesListResult | null {
if (payload?.connected !== true && payload?.connected !== false) {
return null;
}
if (payload.connected === false) {
return { connected: false };
}
const issues = Array.isArray(payload.issues)
? payload.issues.map(parseIssueSummary).filter((issue): issue is LinearIssueSummary => issue != null)
: [];
return {
connected: true,
issues,
cursor: payload.cursor?.trim() || null,
hasMore: payload.hasMore === true,
};
}
function toIssueGet(payload: LinearJson | null): LinearIssueGetResult | null {
if (payload?.connected !== true && payload?.connected !== false) {
return null;
}
if (payload.connected === false) {
return { connected: false };
}
return {
connected: true,
issue: parseIssue(payload.issue),
};
}
function toIssueStates(payload: LinearJson | null): LinearIssueStatesResult | null {
if (payload?.connected !== true && payload?.connected !== false) {
return null;
}
if (payload.connected === false) {
return { connected: false };
}
const states = Array.isArray(payload.states)
? payload.states.map(parseWorkflowState).filter((state): state is LinearWorkflowState => state != null)
: [];
return { connected: true, states };
}
function toIssueUpdate(payload: LinearJson | null): LinearIssueUpdateResult | null {
if (payload?.connected !== true && payload?.connected !== false) {
return null;
}
if (payload.connected === false) {
return { connected: false };
}
return {
connected: true,
issue: parseIssue(payload.issue),
};
}
function parseTeamMapping(payload: LinearTeamMapping | null | undefined): LinearTeamMapping | null {
const id = payload?.id?.trim();
const key = payload?.key?.trim();
const name = payload?.name?.trim();
if (!id || !key || !name) return null;
const projectPath = payload?.projectPath?.trim() || null;
return { id, key, name, projectPath };
}
function toMapping(payload: LinearJson | null): LinearMappingResult | null {
if (payload?.connected !== true && payload?.connected !== false) {
return null;
}
if (payload.connected === false) {
return { connected: false };
}
const teams = Array.isArray(payload.teams)
? payload.teams.map(parseTeamMapping).filter((team): team is LinearTeamMapping => team != null)
: [];
return {
connected: true,
defaultProjectPath: payload.defaultProjectPath?.trim() || null,
teams,
};
}
type LinearSessionStatusSkipped = Extract<
LinearSessionStatusPostResult,
{ posted: false }
>['skipped'];
const SESSION_STATUS_SKIPPED: readonly LinearSessionStatusSkipped[] = [
'already-posted',
'issue-not-found',
'not-started',
'disabled',
'origin-not-public',
];
function parseSkipped(value: string | undefined): LinearSessionStatusSkipped | null {
return SESSION_STATUS_SKIPPED.find((entry) => entry === value) ?? null;
}
function toPreferences(payload: LinearJson | null): LinearPreferences | null {
if (payload?.sessionComments !== true && payload?.sessionComments !== false) {
return null;
}
return { sessionComments: payload.sessionComments };
}
function toSessionStatusPost(payload: LinearJson | null): LinearSessionStatusPostResult | null {
if (payload?.connected !== true && payload?.connected !== false) {
return null;
}
if (payload.connected === false) {
return { connected: false };
}
if (payload.posted === true) {
return {
connected: true,
posted: true,
commentId: payload.commentId?.trim() || null,
};
}
const skipped = parseSkipped(payload.skipped);
if (payload.posted === false && skipped) {
return { connected: true, posted: false, skipped };
}
return null;
}
export const createWebLinearAPI = (): LinearAPI => ({
async authStatus(): Promise<LinearAuthStatus> {
const response = await runtimeFetch('/api/linear/auth/status', {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = await readLinearJson(response);
const status = toAuthStatus(payload);
if (!response.ok || !status) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear status'));
}
return status;
},
async authStart(origin?: LinearAuthOrigin): Promise<LinearAuthStart> {
const response = await runtimeFetch('/api/linear/auth/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(origin ? { origin } : {}),
});
const payload = await readLinearJson(response);
const started = toAuthStart(payload);
if (!response.ok || !started) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to start Linear auth'));
}
return started;
},
async authDisconnect(): Promise<{ removed: boolean }> {
const response = await runtimeFetch('/api/linear/auth', {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
const payload = await readLinearJson(response);
if (!response.ok) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to disconnect Linear'));
}
return { removed: payload?.removed === true };
},
async authActivate(organizationId: string): Promise<LinearAuthStatus> {
const response = await runtimeFetch('/api/linear/auth/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ organizationId }),
});
const payload = await readLinearJson(response);
const status = toAuthStatus(payload);
if (!response.ok || !status) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to switch Linear workspace'));
}
return status;
},
async issuesList(options?: LinearIssuesListOptions): Promise<LinearIssuesListResult> {
const params = new URLSearchParams();
const query = options?.query?.trim();
const cursor = options?.cursor?.trim();
const status = options?.status?.trim();
const assignee = options?.assignee?.trim();
const teamId = options?.teamId?.trim();
const priority = options?.priority?.trim();
if (query) params.set('query', query);
if (cursor) params.set('cursor', cursor);
if (status) params.set('status', status);
if (assignee) params.set('assignee', assignee);
if (teamId) params.set('teamId', teamId);
if (priority) params.set('priority', priority);
const queryString = params.toString();
const suffix = queryString ? `?${queryString}` : '';
const response = await runtimeFetch(`/api/linear/issues/list${suffix}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = await readLinearJson(response);
const result = toIssuesList(payload);
if (!response.ok || !result) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear issues'));
}
return result;
},
async issueGet(id: string): Promise<LinearIssueGetResult> {
const params = new URLSearchParams({ id });
const response = await runtimeFetch(`/api/linear/issues/get?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = await readLinearJson(response);
const result = toIssueGet(payload);
if (!response.ok || !result) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear issue'));
}
return result;
},
async issueStates(teamId: string): Promise<LinearIssueStatesResult> {
const params = new URLSearchParams({ teamId });
const response = await runtimeFetch(`/api/linear/issues/states?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = await readLinearJson(response);
const result = toIssueStates(payload);
if (!response.ok || !result) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear workflow states'));
}
return result;
},
async issueUpdate(input: LinearIssueUpdateInput): Promise<LinearIssueUpdateResult> {
const response = await runtimeFetch('/api/linear/issues/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
id: input.id,
stateId: input.stateId,
}),
});
const payload = await readLinearJson(response);
const result = toIssueUpdate(payload);
if (!response.ok || !result) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to update Linear issue'));
}
return result;
},
async mappingGet(): Promise<LinearMappingResult> {
const response = await runtimeFetch('/api/linear/mapping', {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = await readLinearJson(response);
const result = toMapping(payload);
if (!response.ok || !result) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear mapping'));
}
return result;
},
async mappingSet(mapping: LinearMappingWrite): Promise<LinearMappingResult> {
const response = await runtimeFetch('/api/linear/mapping', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
defaultProjectPath: mapping.defaultProjectPath,
teamProjectPaths: mapping.teamProjectPaths,
}),
});
const payload = await readLinearJson(response);
const result = toMapping(payload);
if (!response.ok || !result) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to save Linear mapping'));
}
return result;
},
async sessionStatusPost(input: LinearSessionStatusPostInput): Promise<LinearSessionStatusPostResult> {
const response = await runtimeFetch('/api/linear/session-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
kind: input.kind,
sessionId: input.sessionId,
issueIdentifier: input.issueIdentifier,
sessionOrigin: input.sessionOrigin,
}),
});
const payload = await readLinearJson(response);
const result = toSessionStatusPost(payload);
if (!response.ok || !result) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to post Linear session status'));
}
return result;
},
async preferencesGet(): Promise<LinearPreferences> {
const response = await runtimeFetch('/api/linear/preferences', {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = await readLinearJson(response);
const result = toPreferences(payload);
if (!response.ok || !result) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear preferences'));
}
return result;
},
async preferencesSet(preferences: LinearPreferences): Promise<LinearPreferences> {
const response = await runtimeFetch('/api/linear/preferences', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ sessionComments: preferences.sessionComments }),
});
const payload = await readLinearJson(response);
const result = toPreferences(payload);
if (!response.ok || !result) {
throw new Error(readErrorMessage(payload, response.statusText || 'Failed to save Linear preferences'));
}
return result;
},
});
+4
View File
@@ -115,6 +115,10 @@ export default defineConfig({
target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`,
changeOrigin: true,
},
'/linear': {
target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`,
changeOrigin: true,
},
'/api': {
target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`,
changeOrigin: true,