whose-turn 1.0.0-alpha.7 → 1.0.0-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +6 -6
- package/config.example.json +0 -4
- package/dist/assets/bem-CST_YFrc.js +1 -0
- package/dist/assets/index-CEB3_bQv.css +1 -0
- package/dist/assets/index-DsQ_5Y-x.js +7 -0
- package/dist/assets/{report-BJ_aC1A7.js → report-Bar3ULi6.js} +1 -1
- package/dist/index.html +3 -3
- package/dist/report.html +2 -2
- package/lib/api.mjs +28 -23
- package/lib/compose.mjs +6 -8
- package/lib/config.mjs +38 -5
- package/lib/errors.mjs +0 -1
- package/lib/feedback.mjs +78 -110
- package/lib/linear-api.mjs +1 -1
- package/lib/notify.mjs +6 -2
- package/lib/registry.mjs +7 -2
- package/lib/text.mjs +3 -0
- package/lib/workspace.mjs +1 -1
- package/package.json +1 -1
- package/server.mjs +8 -6
- package/dist/assets/bem-zV6iDqYN.js +0 -1
- package/dist/assets/index-BLFgmJcy.js +0 -7
- package/dist/assets/index-CTrTp-xu.css +0 -1
package/lib/api.mjs
CHANGED
|
@@ -4,9 +4,9 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { createStatic } from './static.mjs';
|
|
5
5
|
import { ApiError, toApiError } from './errors.mjs';
|
|
6
6
|
import { connectorCatalogue, secretTarget, secretsRequired } from '../connectors/index.mjs';
|
|
7
|
-
import { entryShapeViolations, normaliseWorkspaces, WORKSPACE_FIELDS } from './config.mjs';
|
|
7
|
+
import { desktopOf, entryShapeViolations, normaliseWorkspaces, WORKSPACE_FIELDS } from './config.mjs';
|
|
8
8
|
import { FACTS } from './facts.mjs';
|
|
9
|
-
import { createFeedback,
|
|
9
|
+
import { createFeedback, installOf } from './feedback.mjs';
|
|
10
10
|
import { composeIssue, DEFAULT_COMPOSER } from './compose.mjs';
|
|
11
11
|
import { readSize, saveSize, checkMode, spawnOpener } from './window.mjs';
|
|
12
12
|
import { frameHandOff } from './handoff.mjs';
|
|
@@ -109,9 +109,8 @@ const aggregateSummary = (workspaces) => {
|
|
|
109
109
|
|
|
110
110
|
const OPEN_COOLDOWN_MS = 5000;
|
|
111
111
|
|
|
112
|
-
export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig, secrets, inbox, http, reports, dataDir, opener = spawnOpener(appDir), updater = noUpdates(), changelog = null }) => {
|
|
112
|
+
export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig, secrets, inbox, http, reports, dataDir, opener = spawnOpener(appDir), updater = noUpdates(), changelog = null, version = '' }) => {
|
|
113
113
|
const windowFile = createStatic(join(appDir, 'dist'));
|
|
114
|
-
const feedbackCache = {};
|
|
115
114
|
const pick = (id) => {
|
|
116
115
|
const workspaces = registry.workspaces;
|
|
117
116
|
if (!workspaces.length) {
|
|
@@ -164,14 +163,14 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
164
163
|
return send(response, 200, { workspaces: [], problems: registry.problems, update: updater.status() });
|
|
165
164
|
}
|
|
166
165
|
const workspace = pick(url.searchParams.get('ws'));
|
|
167
|
-
return send(response, 200, { ...workspace.snapshot(url.searchParams.get('full') === '1'), workspaces: registry.workspaces.map((item) => item.describe()), problems: registry.problems, update: updater.status() });
|
|
166
|
+
return send(response, 200, { ...workspace.snapshot(url.searchParams.get('full') === '1'), workspaces: registry.workspaces.map((item) => item.describe()), problems: registry.problems, notifications: registry.notifications, update: updater.status() });
|
|
168
167
|
}
|
|
169
168
|
if (url.pathname === '/api/summary') {
|
|
170
|
-
return send(response, 200, { ...aggregateSummary(registry.workspaces), problems: registry.problems, handed: inbox.pending().length, update: updater.status() });
|
|
169
|
+
return send(response, 200, { ...aggregateSummary(registry.workspaces), problems: registry.problems, handed: inbox.pending().length, notifications: registry.notifications, update: updater.status() });
|
|
171
170
|
}
|
|
172
171
|
if (url.pathname === '/api/config') {
|
|
173
172
|
const tables = Object.fromEntries(registry.workspaces.map((workspace) => [workspace.id, workspace.rulesTable]));
|
|
174
|
-
return send(response, 200, { workspaces: registry.rawWorkspaces, problems: registry.problems, connectors: connectorCatalogue(), fields: WORKSPACE_FIELDS, facts: FACTS, tables, table_warnings: normaliseWorkspaces(registry.rawWorkspaces).warnings, config_file: registry.configFile });
|
|
173
|
+
return send(response, 200, { workspaces: registry.rawWorkspaces, notifications: registry.notifications, problems: registry.problems, connectors: connectorCatalogue(), fields: WORKSPACE_FIELDS, facts: FACTS, tables, table_warnings: normaliseWorkspaces(registry.rawWorkspaces).warnings, config_file: registry.configFile });
|
|
175
174
|
}
|
|
176
175
|
if (url.pathname === '/api/changelog') {
|
|
177
176
|
if (!changelog) {
|
|
@@ -185,10 +184,10 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
185
184
|
if (url.pathname === '/api/feedback') {
|
|
186
185
|
const id = url.searchParams.get('id');
|
|
187
186
|
if (id) {
|
|
188
|
-
return send(response, 200, { report: await reports.detail(
|
|
187
|
+
return send(response, 200, { report: await reports.detail(id) });
|
|
189
188
|
}
|
|
190
|
-
const list = await reports.states(
|
|
191
|
-
return send(response, 200, {
|
|
189
|
+
const list = await reports.states();
|
|
190
|
+
return send(response, 200, { reports: list });
|
|
192
191
|
}
|
|
193
192
|
if (url.pathname === '/api/handoff') {
|
|
194
193
|
if (url.searchParams.get('all') === '1') {
|
|
@@ -240,6 +239,15 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
240
239
|
return { ...target, value: String(item.value).trim() };
|
|
241
240
|
});
|
|
242
241
|
|
|
242
|
+
const handleNotifications = async (body) => {
|
|
243
|
+
const desktop = desktopOf(body);
|
|
244
|
+
if (desktop === undefined) {
|
|
245
|
+
throw new ApiError('INVALID_ARGUMENT', { detail: 'desktop must be true or false' }, [{ field: 'desktop', description: 'Send {desktop: true} or {desktop: false}.' }]);
|
|
246
|
+
}
|
|
247
|
+
await registry.saveNotifications({ desktop });
|
|
248
|
+
return { notifications: registry.notifications };
|
|
249
|
+
};
|
|
250
|
+
|
|
243
251
|
const handleConfig = async (body) => {
|
|
244
252
|
if (!Array.isArray(body.workspaces)) {
|
|
245
253
|
throw new ApiError('INVALID_ARGUMENT', { detail: 'workspaces must be a list' });
|
|
@@ -275,7 +283,7 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
275
283
|
await secrets.set(secret.service, secret.account, secret.value);
|
|
276
284
|
}
|
|
277
285
|
await saveConfig(body.workspaces);
|
|
278
|
-
return { workspaces: registry.workspaces.map((workspace) => workspace.describe()), problems: registry.problems, secrets_stored: stored.map(({ service, account }) => ({ service, account })) };
|
|
286
|
+
return { workspaces: registry.workspaces.map((workspace) => workspace.describe()), notifications: registry.notifications, problems: registry.problems, secrets_stored: stored.map(({ service, account }) => ({ service, account })) };
|
|
279
287
|
};
|
|
280
288
|
|
|
281
289
|
const handleSecret = async (body) => {
|
|
@@ -321,12 +329,10 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
321
329
|
return { item: inbox.add({ ws: workspace.id, ws_name: workspace.name, kind, ref, snapshot }) };
|
|
322
330
|
};
|
|
323
331
|
|
|
324
|
-
const reportDeps = () => ({ settings: registry.feedback, secrets, http });
|
|
325
|
-
|
|
326
332
|
let lastRemark = { text: '', at: 0 };
|
|
327
333
|
const handleFeedback = async (body) => {
|
|
328
|
-
if (
|
|
329
|
-
throw new ApiError('
|
|
334
|
+
if ('context' in body) {
|
|
335
|
+
throw new ApiError('INVALID_ARGUMENT', { detail: 'context is no longer accepted: a report carries only its text and the monitor version' }, [{ field: 'context', description: 'Remove it; nothing but what the user wrote leaves this machine.' }]);
|
|
330
336
|
}
|
|
331
337
|
const remark = typeof body.text === 'string' ? body.text.trim() : '';
|
|
332
338
|
if (!remark && !String(body.title || '').trim()) {
|
|
@@ -338,16 +344,12 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
338
344
|
if (remark) {
|
|
339
345
|
lastRemark = { text: remark, at: Date.now() };
|
|
340
346
|
}
|
|
341
|
-
|
|
342
|
-
if (body.ws && body.kind_ref) {
|
|
343
|
-
const workspace = pick(body.ws);
|
|
344
|
-
context = workspace.handOffText(body.kind_ref.kind, body.kind_ref.ref);
|
|
345
|
-
}
|
|
347
|
+
const sources = (body.ws ? [pick(body.ws)] : registry.workspaces).flatMap((workspace) => workspace.sourceTypes);
|
|
346
348
|
try {
|
|
347
349
|
const composer = registry.feedback && registry.feedback.composer && registry.feedback.composer.command ? registry.feedback.composer : DEFAULT_COMPOSER;
|
|
348
|
-
const shaped = remark ? await composeIssue(remark, {
|
|
349
|
-
const issue = await createFeedback({
|
|
350
|
-
return { issue, composed_by: shaped.composed_by, reports: await reports.states(
|
|
350
|
+
const shaped = remark ? await composeIssue(remark, { composer, bugWords: registry.feedback && registry.feedback.bug_words }) : { kind: body.kind, title: body.title, body: body.body, composed_by: body.from || 'caller' };
|
|
351
|
+
const issue = await createFeedback({ http, reports, relayUrl: reports.relayUrl, version, sources, install: installOf(appDir), client: body.client }, { kind: shaped.kind, title: shaped.title, body: shaped.body });
|
|
352
|
+
return { issue, composed_by: shaped.composed_by, reports: await reports.states() };
|
|
351
353
|
} catch (error) {
|
|
352
354
|
lastRemark = { text: '', at: 0 };
|
|
353
355
|
throw error;
|
|
@@ -367,6 +369,9 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
367
369
|
if (url.pathname === '/api/config') {
|
|
368
370
|
return send(response, 200, await handleConfig(body));
|
|
369
371
|
}
|
|
372
|
+
if (url.pathname === '/api/notifications') {
|
|
373
|
+
return send(response, 200, await handleNotifications(body));
|
|
374
|
+
}
|
|
370
375
|
if (url.pathname === '/api/secrets') {
|
|
371
376
|
return send(response, 200, await handleSecret(body));
|
|
372
377
|
}
|
package/lib/compose.mjs
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
/** Turns a remark into an issue (title, kind, body) with the user's own agent CLI, `claude -p` unless the config names another; falls back to the first line as the title. RMA-109. */
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { escapeRegex } from './text.mjs';
|
|
4
|
-
import { untrustedBlock } from './handoff.mjs';
|
|
5
4
|
|
|
6
5
|
const KINDS = ['bug', 'improvement', 'idea'];
|
|
7
6
|
export const DEFAULT_COMPOSER = { command: 'claude', args: ['-p', '--output-format', 'json', '--model', 'claude-haiku-5-5'] };
|
|
8
7
|
|
|
9
|
-
const PROMPT = (text
|
|
8
|
+
const PROMPT = (text) => `You turn a user's remark about a tool called Whose Turn (a local monitor of tickets and pull requests) into an issue for its backlog.
|
|
10
9
|
Answer with one JSON object only, no prose, no code fence: {"title": string, "kind": "bug" | "improvement" | "idea", "body": string}.
|
|
11
|
-
Rules: the title and the body are in English whatever language the remark is in; translate faithfully, keep every detail the user gave, add nothing the user did not say. The title is one line under 80 characters; kind is bug when something misbehaves, improvement when something that exists should be better, idea when it is new; the body restates the remark clearly and
|
|
10
|
+
Rules: the title and the body are in English whatever language the remark is in; translate faithfully, keep every detail the user gave, add nothing the user did not say. The title is one line under 80 characters; kind is bug when something misbehaves, improvement when something that exists should be better, idea when it is new; the body restates the remark clearly and ends, when the remark was not in English, with one line "Original: …" quoting it as written.
|
|
12
11
|
Remark:
|
|
13
|
-
${text}
|
|
14
|
-
${about ? `\nContext of the window at that moment:\n${untrustedBlock(about.slice(0, 3000))}` : ''}`;
|
|
12
|
+
${text}`;
|
|
15
13
|
|
|
16
14
|
const DEFAULT_BUG_WORDS = ['bug', 'broken', 'crash', 'error', 'fail', 'wrong'];
|
|
17
15
|
const fallback = (text, bugWords) => {
|
|
@@ -50,8 +48,8 @@ const runCommand = (composer) => (prompt, timeoutMs) => new Promise((resolve, re
|
|
|
50
48
|
child.stdin.end(prompt);
|
|
51
49
|
});
|
|
52
50
|
|
|
53
|
-
/** @param {string} text @param {{
|
|
54
|
-
export const composeIssue = async (text, {
|
|
51
|
+
/** @param {string} text @param {{ composer?: object, bugWords?: string[], run?: Function, timeoutMs?: number }} [options] */
|
|
52
|
+
export const composeIssue = async (text, { composer = DEFAULT_COMPOSER, bugWords = [], run, timeoutMs = 45000 } = {}) => {
|
|
55
53
|
const clean = String(text || '').trim();
|
|
56
54
|
if (!clean) {
|
|
57
55
|
throw new Error('Write at least one line');
|
|
@@ -60,7 +58,7 @@ export const composeIssue = async (text, { about = '', composer = DEFAULT_COMPOS
|
|
|
60
58
|
const model = args[args.indexOf('--model') + 1];
|
|
61
59
|
const label = composer.label || (args.includes('--model') && model ? `${composer.command} ${model}` : composer.command);
|
|
62
60
|
try {
|
|
63
|
-
return parse(await (run || runCommand(composer))(PROMPT(clean
|
|
61
|
+
return parse(await (run || runCommand(composer))(PROMPT(clean), timeoutMs), label);
|
|
64
62
|
} catch {
|
|
65
63
|
return fallback(clean, bugWords);
|
|
66
64
|
}
|
package/lib/config.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
+
import { isObject } from './text.mjs';
|
|
5
6
|
import { isHex, derivedSeeds } from './palette.mjs';
|
|
6
7
|
import { statusGroups } from './facts.mjs';
|
|
7
8
|
import { tableProblems, tableWarnings } from './table.mjs';
|
|
@@ -115,8 +116,6 @@ const legacyWorkspace = (raw) => {
|
|
|
115
116
|
return { id: 'default', name: 'Default', connectors, rules: raw.rules, key_pattern: raw.jira?.key_pattern };
|
|
116
117
|
};
|
|
117
118
|
|
|
118
|
-
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
119
|
-
|
|
120
119
|
const connectorEntries = (connectors) => {
|
|
121
120
|
const list = Array.isArray(connectors) ? connectors : Object.entries(connectors || {}).map(([type, settings]) => ({ type, ...settings }));
|
|
122
121
|
return list.filter(isObject).map((entry) => ({ ...entry, id: String(entry.id || entry.type) }));
|
|
@@ -243,7 +242,8 @@ export const loadConfig = (env = process.env) => {
|
|
|
243
242
|
pollSeconds: Number(source.poll_seconds || 60),
|
|
244
243
|
historyDays: Number(source.history_days || 14),
|
|
245
244
|
health: normaliseHealth(source, problems),
|
|
246
|
-
feedback: source.feedback
|
|
245
|
+
feedback: withoutRetiredFeedbackKeys(source.feedback),
|
|
246
|
+
notifications: normaliseNotifications(source.notifications, problems),
|
|
247
247
|
workspaces,
|
|
248
248
|
rawWorkspaces,
|
|
249
249
|
problems: [...problems, ...shapeProblems, ...warningLines],
|
|
@@ -259,6 +259,20 @@ const normaliseHealth = (source, problems) => Object.fromEntries(Object.entries(
|
|
|
259
259
|
return [key, value];
|
|
260
260
|
}));
|
|
261
261
|
|
|
262
|
+
/** The boolean `desktop` of a notifications block, or undefined. */
|
|
263
|
+
export const desktopOf = (notifications) => (isObject(notifications) && typeof notifications.desktop === 'boolean' ? notifications.desktop : undefined);
|
|
264
|
+
|
|
265
|
+
const normaliseNotifications = (notifications, problems) => {
|
|
266
|
+
if (notifications === undefined) {
|
|
267
|
+
return { desktop: true };
|
|
268
|
+
}
|
|
269
|
+
const desktop = desktopOf(notifications);
|
|
270
|
+
if (desktop === undefined) {
|
|
271
|
+
problems.push('notifications.desktop must be true or false; desktop notifications stay on.');
|
|
272
|
+
}
|
|
273
|
+
return { desktop: desktop ?? true };
|
|
274
|
+
};
|
|
275
|
+
|
|
262
276
|
/** Field violations for entries that are not objects: a workspace, or a connector in a workspace's connectors list. Checked before normalising, which assumes objects. */
|
|
263
277
|
export const entryShapeViolations = (rawWorkspaces) => (Array.isArray(rawWorkspaces) ? rawWorkspaces : []).flatMap((workspace, index) => {
|
|
264
278
|
if (!isObject(workspace)) {
|
|
@@ -297,14 +311,33 @@ export const normaliseWorkspaces = (rawWorkspaces) => {
|
|
|
297
311
|
return { workspaces, problems: violations.map((entry) => entry.description), violations, warnings };
|
|
298
312
|
};
|
|
299
313
|
|
|
300
|
-
|
|
314
|
+
const RETIRED_FEEDBACK_KEYS = ['workspace', 'team', 'project', 'token_service', 'assign'];
|
|
315
|
+
|
|
316
|
+
const withoutRetiredFeedbackKeys = (feedback) => (isObject(feedback) ? Object.fromEntries(Object.entries(feedback).filter(([key]) => !RETIRED_FEEDBACK_KEYS.includes(key))) : {});
|
|
317
|
+
|
|
318
|
+
/** Writes the workspace list into the config file; the previous file stays as config.json.bak. */
|
|
301
319
|
export const saveWorkspaces = (configFile, rawWorkspaces) => {
|
|
302
320
|
const current = readJson(configFile);
|
|
303
321
|
const source = current && !current.parse_error ? current : {};
|
|
304
322
|
delete source.github;
|
|
305
323
|
delete source.jira;
|
|
306
324
|
delete source.rules;
|
|
307
|
-
const
|
|
325
|
+
const feedback = withoutRetiredFeedbackKeys(source.feedback);
|
|
326
|
+
if (Object.keys(feedback).length) {
|
|
327
|
+
source.feedback = feedback;
|
|
328
|
+
} else {
|
|
329
|
+
delete source.feedback;
|
|
330
|
+
}
|
|
331
|
+
writeConfig(configFile, { ...source, workspaces: rawWorkspaces });
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** Writes the validated notifications block into the config file. */
|
|
335
|
+
export const saveNotifications = (configFile, notifications) => {
|
|
336
|
+
const current = readJson(configFile);
|
|
337
|
+
writeConfig(configFile, { ...(current && !current.parse_error ? current : {}), notifications });
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const writeConfig = (configFile, next) => {
|
|
308
341
|
mkdirSync(dirname(configFile), { recursive: true });
|
|
309
342
|
if (existsSync(configFile)) {
|
|
310
343
|
writeFileSync(`${configFile}.bak.tmp`, readFileSync(configFile));
|
package/lib/errors.mjs
CHANGED
|
@@ -10,7 +10,6 @@ const REASONS = {
|
|
|
10
10
|
INVALID_ARGUMENT: { code: 400, status: 'INVALID_ARGUMENT', message: 'A required value is missing or has the wrong shape.' },
|
|
11
11
|
INVALID_CONFIG: { code: 400, status: 'INVALID_ARGUMENT', message: 'The workspace list has problems; nothing was saved.' },
|
|
12
12
|
NO_WORKSPACE: { code: 412, status: 'FAILED_PRECONDITION', message: 'No workspace is configured; open Settings to add one.' },
|
|
13
|
-
NOT_CONFIGURED: { code: 412, status: 'FAILED_PRECONDITION', message: 'Feedback is not configured: set feedback.workspace, feedback.team and feedback.project in the config.' },
|
|
14
13
|
UPSTREAM_FAILED: { code: 502, status: 'UNAVAILABLE', message: 'The tracker did not answer; try again in a minute.' },
|
|
15
14
|
RATE_LIMITED: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'The source is rate-limited; try again later.' },
|
|
16
15
|
OPEN_TOO_SOON: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'The window was just opened; wait a few seconds before opening it again.' },
|
package/lib/feedback.mjs
CHANGED
|
@@ -1,147 +1,115 @@
|
|
|
1
|
-
/** Files a bug or an improvement about the monitor itself
|
|
2
|
-
import {
|
|
1
|
+
/** Files a bug or an improvement about the monitor itself through the maintainer's relay, and reads back what became of it. A report carries the kind, the title, the text, the version and a technical environment built here, nothing else. RMA-104, RMA-280, RMA-291. */
|
|
2
|
+
import { release } from 'node:os';
|
|
3
|
+
import { join, sep } from 'node:path';
|
|
3
4
|
import { readJson, writeJson } from './store.mjs';
|
|
4
|
-
import { nowIso } from './text.mjs';
|
|
5
|
+
import { isObject, nowIso } from './text.mjs';
|
|
5
6
|
import { ApiError } from './errors.mjs';
|
|
6
7
|
import { RateLimited } from './http-client.mjs';
|
|
7
|
-
import { linearQuery } from './linear-api.mjs';
|
|
8
8
|
|
|
9
|
-
export const
|
|
10
|
-
const
|
|
9
|
+
export const RELAY_URL = 'https://6cd7q2274i726zmnyav22ar2lu0fangt.lambda-url.eu-central-1.on.aws/';
|
|
10
|
+
const PRODUCT = 'whose-turn';
|
|
11
|
+
export const installOf = (appDir) => (appDir.split(sep).includes('node_modules') ? 'npm' : 'source');
|
|
12
|
+
const MAX_IDS = 50;
|
|
13
|
+
const STATE_NAMES = { open: 'Open', done: 'Done', dropped: 'Dropped' };
|
|
11
14
|
|
|
12
|
-
const
|
|
13
|
-
query Lookup($team: String!, $project: String!) {
|
|
14
|
-
viewer { id }
|
|
15
|
-
teams(filter: { key: { eq: $team } }) { nodes { id key labels { nodes { id name } } } }
|
|
16
|
-
projects(filter: { name: { eqIgnoreCase: $project } }) { nodes { id name } }
|
|
17
|
-
}`;
|
|
18
|
-
|
|
19
|
-
const STATES = `
|
|
20
|
-
query States($team: String!, $numbers: [Float!]!) {
|
|
21
|
-
issues(first: 100, filter: { team: { key: { eq: $team } }, number: { in: $numbers } }) { nodes { identifier title url updatedAt state { name type } } }
|
|
22
|
-
}`;
|
|
23
|
-
|
|
24
|
-
const ANSWERS = `
|
|
25
|
-
query Answers($id: String!) {
|
|
26
|
-
issue(id: $id) { comments(first: 50) { nodes { body createdAt user { name } } } }
|
|
27
|
-
}`;
|
|
28
|
-
|
|
29
|
-
const CREATE = `
|
|
30
|
-
mutation Create($input: IssueCreateInput!) {
|
|
31
|
-
issueCreate(input: $input) { success issue { id identifier number url title } }
|
|
32
|
-
}`;
|
|
33
|
-
|
|
34
|
-
/** A Linear call; a rate limit, a failing answer or a GraphQL error comes out as the matching ApiError, the raw text only in its debug field. */
|
|
35
|
-
const call = async (http, key, query, variables) => {
|
|
15
|
+
const call = async (http, url, options) => {
|
|
36
16
|
try {
|
|
37
|
-
return await
|
|
17
|
+
return await http.fetchJson(url, { ...options, useEtag: false });
|
|
38
18
|
} catch (error) {
|
|
39
|
-
const failure = error instanceof RateLimited
|
|
19
|
+
const failure = error instanceof RateLimited
|
|
20
|
+
? new ApiError('RATE_LIMITED')
|
|
21
|
+
: error.upstreamStatus === 400
|
|
22
|
+
? new ApiError('INVALID_ARGUMENT', { detail: 'the tracker refused the report' })
|
|
23
|
+
: new ApiError('UPSTREAM_FAILED', error.upstreamStatus ? { upstream_status: error.upstreamStatus } : {});
|
|
40
24
|
failure.debug = String(error.message);
|
|
41
25
|
throw failure;
|
|
42
26
|
}
|
|
43
27
|
};
|
|
44
28
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
'---',
|
|
49
|
-
`Filed from Whose Turn by ${from || 'the window'} on ${nowIso()} as ${KINDS[kind] || kind}.`,
|
|
50
|
-
...(context ? ['', typeof context === 'string' ? context.slice(0, 6000) : `\`\`\`json\n${JSON.stringify(context, null, 1).slice(0, 6000)}\n\`\`\``] : []),
|
|
51
|
-
].join('\n');
|
|
52
|
-
|
|
53
|
-
const serviceOf = (settings) => settings.token_service || DEFAULT_SERVICE;
|
|
54
|
-
const keyFor = async (settings, secrets) => (settings && settings.workspace ? secrets.get(serviceOf(settings), settings.workspace) : '');
|
|
29
|
+
const isSize = (value) => Number.isInteger(value) && value >= 1 && value <= 20000;
|
|
30
|
+
const BROWSERS = ['Chrome', 'Edge', 'Safari', 'Firefox', 'Opera', 'Brave', 'Arc', 'Chromium', 'Other'];
|
|
31
|
+
const exactly = (value, keys) => isObject(value) && Object.keys(value).sort().join() === keys;
|
|
55
32
|
|
|
56
|
-
|
|
57
|
-
|
|
33
|
+
const CLIENT_FIELDS = {
|
|
34
|
+
browser: (value) => (exactly(value, 'name,version') && BROWSERS.includes(value.name) && typeof value.version === 'string' && /^\d{1,6}$/.test(value.version) ? { name: value.name, version: value.version } : undefined),
|
|
35
|
+
display: (value) => {
|
|
36
|
+
const sane = exactly(value, 'height,scale,viewport,width') && isSize(value.width) && isSize(value.height) && typeof value.scale === 'number' && value.scale >= 0.5 && value.scale <= 8 && Array.isArray(value.viewport) && value.viewport.length === 2 && value.viewport.every(isSize);
|
|
37
|
+
return sane ? { width: value.width, height: value.height, scale: Math.round(value.scale * 100) / 100, viewport: [...value.viewport] } : undefined;
|
|
38
|
+
},
|
|
39
|
+
locale: (value) => (typeof value === 'string' && value.length <= 35 && /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/.test(value) ? value : undefined),
|
|
40
|
+
timezone: (value) => (typeof value === 'string' && value.length <= 64 && /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/.test(value) ? value : undefined),
|
|
41
|
+
theme: (value) => (value === 'light' || value === 'dark' ? value : undefined),
|
|
42
|
+
mode: (value) => (value === 'app' || value === 'tab' ? value : undefined),
|
|
43
|
+
};
|
|
58
44
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return null;
|
|
45
|
+
/** What the window reported about itself, keeping only the fields that are exactly the agreed shape; a bad or missing one is left out, never an error. RMA-291. */
|
|
46
|
+
export const clientOf = (client) => {
|
|
47
|
+
const clean = {};
|
|
48
|
+
for (const [key, check] of Object.entries(CLIENT_FIELDS)) {
|
|
49
|
+
const value = isObject(client) ? check(client[key]) : undefined;
|
|
50
|
+
if (value !== undefined) {
|
|
51
|
+
clean[key] = value;
|
|
52
|
+
}
|
|
68
53
|
}
|
|
54
|
+
return clean;
|
|
69
55
|
};
|
|
70
56
|
|
|
71
|
-
|
|
72
|
-
|
|
57
|
+
const environmentOf = ({ sources, install, client }) => ({ os: process.platform, osVersion: release(), arch: process.arch, node: process.version, install, sources: [...new Set(sources)].sort(), ...clientOf(client) });
|
|
58
|
+
|
|
59
|
+
/** What this machine filed: the last hundred reports in <data>/reports.json. states() reads their state from the relay, detail() one report with its answers; one relay read serves both, cached for a minute. RMA-120, RMA-123. */
|
|
60
|
+
export const openReports = (dataDir, { http, relayUrl }) => {
|
|
73
61
|
const file = join(dataDir, 'reports.json');
|
|
74
62
|
let items = readJson(file, []);
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return hit.value;
|
|
63
|
+
let cached = null;
|
|
64
|
+
const live = async () => {
|
|
65
|
+
if (cached && Date.now() - cached.at < 60000) {
|
|
66
|
+
return cached.value;
|
|
80
67
|
}
|
|
81
|
-
|
|
82
|
-
|
|
68
|
+
let value = {};
|
|
69
|
+
if (items.length) {
|
|
70
|
+
try {
|
|
71
|
+
const ids = items.slice(0, MAX_IDS).map((report) => report.id).join(',');
|
|
72
|
+
const answer = await http.fetchJson(`${relayUrl}?product=${PRODUCT}&ids=${encodeURIComponent(ids)}`, { useEtag: false });
|
|
73
|
+
value = Object.fromEntries((answer.issues || []).map((issue) => [issue.identifier, issue]));
|
|
74
|
+
} catch {
|
|
75
|
+
value = {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
cached = { at: Date.now(), value };
|
|
83
79
|
return value;
|
|
84
80
|
};
|
|
85
|
-
const
|
|
86
|
-
const data = items.length ? await askLinear(deps, STATES, { team: deps.settings.team, numbers: items.map((report) => report.number).filter(Number.isFinite) }) : null;
|
|
87
|
-
const live = Object.fromEntries((data ? data.issues.nodes : []).map((node) => [node.identifier, node]));
|
|
88
|
-
return items.map((report) => {
|
|
89
|
-
const issue = live[report.id];
|
|
90
|
-
return issue ? { ...report, title: issue.title, state: issue.state.name, outcome: outcomeOf(issue.state), changed_at: issue.updatedAt } : report;
|
|
91
|
-
});
|
|
92
|
-
});
|
|
81
|
+
const merged = (report, issue) => (issue ? { ...report, title: issue.title, state: STATE_NAMES[issue.outcome] || issue.outcome, outcome: issue.outcome, changed_at: issue.updatedAt } : report);
|
|
93
82
|
return {
|
|
83
|
+
relayUrl,
|
|
94
84
|
list: () => items.slice(),
|
|
95
|
-
add: (report) => { items = [report, ...items].slice(0, 100);
|
|
96
|
-
states
|
|
97
|
-
|
|
98
|
-
|
|
85
|
+
add: (report) => { items = [report, ...items].slice(0, 100); cached = null; writeJson(file, items); return report; },
|
|
86
|
+
states: async () => {
|
|
87
|
+
const issues = await live();
|
|
88
|
+
return items.map((report) => merged(report, issues[report.id]));
|
|
89
|
+
},
|
|
90
|
+
detail: async (id) => {
|
|
91
|
+
const report = items.find((item) => item.id === id);
|
|
99
92
|
if (!report) {
|
|
100
93
|
return null;
|
|
101
94
|
}
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
return { ...report, answers, unanswered: Boolean(report.outcome) && report.outcome !== 'open' && !answers.length };
|
|
95
|
+
const issue = (await live())[id];
|
|
96
|
+
const answers = (issue ? issue.answers : []).map((answer) => ({ at: answer.at, by: answer.author || '', text: answer.body })).sort((a, b) => a.at.localeCompare(b.at));
|
|
97
|
+
const shown = merged(report, issue);
|
|
98
|
+
return { ...shown, answers, unanswered: Boolean(shown.outcome) && shown.outcome !== 'open' && !answers.length };
|
|
107
99
|
},
|
|
108
100
|
};
|
|
109
101
|
};
|
|
110
102
|
|
|
111
|
-
/**
|
|
112
|
-
export const
|
|
113
|
-
|
|
114
|
-
export const createFeedback = async ({ settings, secrets, http, cache, reports }, { kind = 'improvement', title, body = '', context = null, from = '' }) => {
|
|
115
|
-
if (!isConfigured(settings)) {
|
|
116
|
-
throw new ApiError('NOT_CONFIGURED');
|
|
117
|
-
}
|
|
118
|
-
const cleanTitle = String(title || '').trim().slice(0, 200);
|
|
103
|
+
/** Files a report through the relay; returns {identifier, url, title}. */
|
|
104
|
+
export const createFeedback = async ({ http, relayUrl, reports, version, sources = [], install = 'source', client = null }, { kind = 'improvement', title, body = '' }) => {
|
|
105
|
+
const cleanTitle = String(title || '').trim().slice(0, 140);
|
|
119
106
|
if (!cleanTitle) {
|
|
120
107
|
throw new ApiError('INVALID_ARGUMENT', { detail: 'a title is required' }, [{ field: 'title', description: 'A report needs a title.' }]);
|
|
121
108
|
}
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
throw new ApiError('NOT_CONFIGURED', { detail: `no Linear key for feedback in the secret store (service ${serviceOf(settings)}, account ${settings.workspace})` });
|
|
125
|
-
}
|
|
126
|
-
if (!cache.lookup) {
|
|
127
|
-
const data = await call(http, key, LOOKUP, { team: settings.team, project: settings.project });
|
|
128
|
-
const team = data.teams.nodes[0];
|
|
129
|
-
const project = data.projects.nodes[0];
|
|
130
|
-
if (!team || !project) {
|
|
131
|
-
throw new ApiError('NOT_CONFIGURED', { detail: `Linear team ${settings.team} or project ${settings.project} not found` });
|
|
132
|
-
}
|
|
133
|
-
cache.lookup = { teamId: team.id, projectId: project.id, assigneeId: data.viewer?.id || '', labels: Object.fromEntries(team.labels.nodes.map((label) => [label.name.toLowerCase(), label.id])) };
|
|
134
|
-
}
|
|
135
|
-
const labelId = cache.lookup.labels[(KINDS[kind] || kind).toLowerCase()];
|
|
136
|
-
const assigneeId = settings.assign === false ? '' : cache.lookup.assigneeId;
|
|
137
|
-
const input = { teamId: cache.lookup.teamId, projectId: cache.lookup.projectId, title: cleanTitle, description: describe({ kind, body, context, from }), ...(labelId ? { labelIds: [labelId] } : {}), ...(assigneeId ? { assigneeId } : {}) };
|
|
138
|
-
const data = await call(http, key, CREATE, { input });
|
|
139
|
-
if (!data.issueCreate?.success) {
|
|
140
|
-
throw new ApiError('UPSTREAM_FAILED');
|
|
141
|
-
}
|
|
142
|
-
const issue = data.issueCreate.issue;
|
|
109
|
+
const text = String(body || '').trim().slice(0, 6000);
|
|
110
|
+
const issue = await call(http, relayUrl, { method: 'POST', body: { product: PRODUCT, kind, title: cleanTitle, body: text || cleanTitle, version: version || undefined, environment: environmentOf({ sources, install, client }) } });
|
|
143
111
|
if (reports) {
|
|
144
|
-
reports.add({ id: issue.identifier,
|
|
112
|
+
reports.add({ id: issue.identifier, url: issue.url, title: cleanTitle, kind, body: text, filed_at: nowIso() });
|
|
145
113
|
}
|
|
146
|
-
return issue;
|
|
114
|
+
return { identifier: issue.identifier, url: issue.url, title: cleanTitle };
|
|
147
115
|
};
|
package/lib/linear-api.mjs
CHANGED
package/lib/notify.mjs
CHANGED
|
@@ -35,9 +35,13 @@ const launch = (file, args, env) => {
|
|
|
35
35
|
}
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/** Sends one desktop notification; `enabled: false` stays silent. */
|
|
39
|
+
export const notify = (title, body, { platform = process.platform, run = launch, enabled = true } = {}) => {
|
|
39
40
|
const build = COMMANDS[platform];
|
|
40
|
-
if (build) {
|
|
41
|
+
if (build && enabled) {
|
|
41
42
|
run(...build(clean(title, 120), clean(body, 240)));
|
|
42
43
|
}
|
|
43
44
|
};
|
|
45
|
+
|
|
46
|
+
/** A notify function that asks `isOn()` on every call. */
|
|
47
|
+
export const notifier = (isOn) => (title, body) => notify(title, body, { enabled: isOn() });
|
package/lib/registry.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** The live set of workspaces: built from the config, rebuilt on save with unchanged workspaces kept, events fanned in for the SSE stream. */
|
|
2
2
|
import { ApiError } from './errors.mjs';
|
|
3
3
|
|
|
4
|
-
export const createRegistry = ({ load, save, build, normalise = (raw) => raw }) => {
|
|
4
|
+
export const createRegistry = ({ load, save, saveNotifications, build, normalise = (raw) => raw }) => {
|
|
5
5
|
const listeners = new Set();
|
|
6
|
-
const registry = { workspaces: [], rawWorkspaces: [], problems: [], configFile: '', feedback: null, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); } };
|
|
6
|
+
const registry = { workspaces: [], rawWorkspaces: [], problems: [], configFile: '', feedback: null, notifications: { desktop: true }, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); } };
|
|
7
7
|
|
|
8
8
|
const apply = (config, prebuilt = new Map()) => {
|
|
9
9
|
const previous = new Map(registry.workspaces.map((workspace) => [workspace.id, workspace]));
|
|
@@ -29,6 +29,7 @@ export const createRegistry = ({ load, save, build, normalise = (raw) => raw })
|
|
|
29
29
|
}).filter(Boolean);
|
|
30
30
|
registry.rawWorkspaces = config.rawWorkspaces;
|
|
31
31
|
registry.feedback = config.feedback;
|
|
32
|
+
registry.notifications = config.notifications;
|
|
32
33
|
registry.problems = [...config.problems, ...dropped];
|
|
33
34
|
registry.configFile = config.configFile;
|
|
34
35
|
return created;
|
|
@@ -56,6 +57,10 @@ export const createRegistry = ({ load, save, build, normalise = (raw) => raw })
|
|
|
56
57
|
await save(rawWorkspaces);
|
|
57
58
|
return apply(load(), prebuilt);
|
|
58
59
|
};
|
|
60
|
+
registry.saveNotifications = async (notifications) => {
|
|
61
|
+
await saveNotifications(notifications);
|
|
62
|
+
registry.reload();
|
|
63
|
+
};
|
|
59
64
|
registry.reload();
|
|
60
65
|
return registry;
|
|
61
66
|
};
|
package/lib/text.mjs
CHANGED
|
@@ -16,6 +16,9 @@ export const oneline = (text, limit = 140) => {
|
|
|
16
16
|
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
/** True for a plain object: not null, not an array. */
|
|
20
|
+
export const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
|
|
19
22
|
export const escapeRegex = (text) => String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
23
|
|
|
21
24
|
export const wordsRegex = (words, flags = 'i') => new RegExp(words.length ? words.map(escapeRegex).join('|') : '(?!)', flags);
|
package/lib/workspace.mjs
CHANGED
|
@@ -366,7 +366,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
366
366
|
};
|
|
367
367
|
|
|
368
368
|
return {
|
|
369
|
-
id: definition.id, name: definition.name, rulesTable: rules.table, store, poll, snapshot, summary, describe, checkHealth, handOff, handOffBody, handOffText, handOffSnapshot, updateMarks, addTodo, changeTodo, saveTodos,
|
|
369
|
+
id: definition.id, name: definition.name, sourceTypes: [...new Set(definition.connectors.map((entry) => entry.type))], rulesTable: rules.table, store, poll, snapshot, summary, describe, checkHealth, handOff, handOffBody, handOffText, handOffSnapshot, updateMarks, addTodo, changeTodo, saveTodos,
|
|
370
370
|
subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
|
|
371
371
|
};
|
|
372
372
|
};
|
package/package.json
CHANGED
package/server.mjs
CHANGED
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { loadConfig, saveWorkspaces, normaliseWorkspaces } from './lib/config.mjs';
|
|
6
|
+
import { loadConfig, saveWorkspaces, saveNotifications, normaliseWorkspaces } from './lib/config.mjs';
|
|
7
7
|
import { createHttpClient } from './lib/http-client.mjs';
|
|
8
8
|
import { nextDelay } from './lib/backoff.mjs';
|
|
9
9
|
import { createSecrets } from './lib/secrets.mjs';
|
|
10
10
|
import { createWorkspace } from './lib/workspace.mjs';
|
|
11
11
|
import { createRegistry } from './lib/registry.mjs';
|
|
12
12
|
import { openInbox } from './lib/inbox.mjs';
|
|
13
|
-
import { openReports } from './lib/feedback.mjs';
|
|
13
|
+
import { openReports, RELAY_URL } from './lib/feedback.mjs';
|
|
14
14
|
import { startApi } from './lib/api.mjs';
|
|
15
|
-
import {
|
|
15
|
+
import { notifier } from './lib/notify.mjs';
|
|
16
16
|
import { loadChangelog } from './lib/changelog.mjs';
|
|
17
17
|
import { createUpdater, restartServer, runsInstalled } from './lib/update.mjs';
|
|
18
18
|
|
|
@@ -21,9 +21,10 @@ const http = createHttpClient();
|
|
|
21
21
|
const first = loadConfig();
|
|
22
22
|
const secrets = createSecrets(first.dataDir);
|
|
23
23
|
const inbox = openInbox(first.dataDir);
|
|
24
|
-
const reports = openReports(first.dataDir);
|
|
24
|
+
const reports = openReports(first.dataDir, { http, relayUrl: process.env.WHOSE_TURN_FEEDBACK_URL || RELAY_URL });
|
|
25
25
|
const { version } = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8'));
|
|
26
26
|
let server;
|
|
27
|
+
const notify = notifier(() => registry.notifications.desktop);
|
|
27
28
|
const updater = createUpdater({
|
|
28
29
|
http,
|
|
29
30
|
dataDir: first.dataDir,
|
|
@@ -46,8 +47,9 @@ const registry = createRegistry({
|
|
|
46
47
|
return config;
|
|
47
48
|
},
|
|
48
49
|
save: (rawWorkspaces) => saveWorkspaces(first.configFile, rawWorkspaces),
|
|
50
|
+
saveNotifications: (notifications) => saveNotifications(first.configFile, notifications),
|
|
49
51
|
normalise: (rawWorkspaces) => normaliseWorkspaces(rawWorkspaces).workspaces,
|
|
50
|
-
build: (definition, config) => createWorkspace(definition, { dataDir: config.dataDir, historyDays: config.historyDays, http, secrets, limits: () => healthLimits }),
|
|
52
|
+
build: (definition, config) => createWorkspace(definition, { dataDir: config.dataDir, historyDays: config.historyDays, http, secrets, limits: () => healthLimits, notify }),
|
|
51
53
|
});
|
|
52
54
|
|
|
53
55
|
let wake = null;
|
|
@@ -72,7 +74,7 @@ const saveConfig = async (rawWorkspaces) => {
|
|
|
72
74
|
};
|
|
73
75
|
|
|
74
76
|
try {
|
|
75
|
-
server = await startApi({ port: first.port, appDir, registry, requestPoll, saveConfig, secrets, inbox, http, reports, dataDir: first.dataDir, updater, changelog: loadChangelog(appDir, version) });
|
|
77
|
+
server = await startApi({ port: first.port, appDir, registry, requestPoll, saveConfig, secrets, inbox, http, reports, dataDir: first.dataDir, updater, changelog: loadChangelog(appDir, version), version });
|
|
76
78
|
} catch (error) {
|
|
77
79
|
console.error(error.message);
|
|
78
80
|
process.exit(1);
|