281 lines
9.2 KiB
JavaScript
281 lines
9.2 KiB
JavaScript
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;
|
||
|
|
}
|