whose-turn 1.0.0-alpha.7 → 1.0.0-alpha.9
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 +44 -0
- package/README.md +28 -16
- package/config.example.json +0 -4
- package/connectors/github.mjs +24 -0
- package/dist/assets/_plugin-vue_export-helper-BbgK5wvS.js +18 -0
- package/dist/assets/_plugin-vue_export-helper-BneMU3KP.css +1 -0
- package/dist/assets/bem-OtK9ToP0.js +1 -0
- package/dist/assets/index-B6grE8kv.css +1 -0
- package/dist/assets/index-C3jZLqLJ.js +15 -0
- package/dist/assets/report-DUlemvzD.js +1 -0
- package/dist/assets/scheme-DCqBrLhv.js +1 -0
- package/dist/assets/sheet-Ctd_6q4a.js +1 -0
- package/dist/index.html +6 -6
- package/dist/report.html +4 -4
- package/dist/sheet.html +4 -4
- package/lib/api.mjs +70 -24
- package/lib/backoff.mjs +13 -0
- package/lib/compose.mjs +6 -8
- package/lib/config.mjs +102 -15
- package/lib/default-table.mjs +1 -0
- package/lib/errors.mjs +1 -2
- package/lib/facts.mjs +1 -1
- package/lib/feedback.mjs +140 -111
- package/lib/linear-api.mjs +1 -1
- package/lib/notify.mjs +7 -8
- package/lib/registry.mjs +32 -5
- package/lib/rules.mjs +16 -9
- package/lib/table.mjs +1 -0
- package/lib/text.mjs +3 -0
- package/lib/trace.mjs +6 -6
- package/lib/workspace.mjs +63 -15
- package/package.json +2 -1
- package/server.mjs +17 -8
- package/dist/assets/_plugin-vue_export-helper-DeQjDyQ9.js +0 -18
- package/dist/assets/_plugin-vue_export-helper-aQHODzfZ.css +0 -1
- 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/dist/assets/report-BJ_aC1A7.js +0 -1
- package/dist/assets/scheme-BdlolNvz.js +0 -1
- package/dist/assets/sheet-q2RQEgOf.js +0 -1
package/lib/config.mjs
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
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';
|
|
8
9
|
import { DEFAULT_LIMITS } from './health.mjs';
|
|
9
10
|
import { CONNECTORS } from '../connectors/index.mjs';
|
|
11
|
+
import { HIDDEN_MODES } from './backoff.mjs';
|
|
10
12
|
|
|
11
13
|
export const DEFAULT_RULES = {
|
|
12
14
|
release_branch_prefix: 'release/',
|
|
@@ -18,6 +20,7 @@ export const DEFAULT_RULES = {
|
|
|
18
20
|
passed: ['passed'],
|
|
19
21
|
code_review: ['code review', 'in review'],
|
|
20
22
|
qa: ['ready for qa', 'in qa', 'testing'],
|
|
23
|
+
release: ['ready for release', 'ready to release', 'awaiting release'],
|
|
21
24
|
on_hold: ['on hold', 'blocked'],
|
|
22
25
|
backlog: ['backlog'],
|
|
23
26
|
},
|
|
@@ -39,6 +42,7 @@ export const WORKSPACE_FIELDS = {
|
|
|
39
42
|
theme: [
|
|
40
43
|
{ key: 'seed', label: 'Seed colour', hint: 'the scheme is derived from it; empty derives one from the workspace id', colour: true },
|
|
41
44
|
{ key: 'accent', label: 'Accent colour', hint: 'optional second hue', colour: true },
|
|
45
|
+
{ key: 'backdrop', label: 'Backdrop animal', hint: 'draw the dotted animal behind the window', boolean: true, default: true },
|
|
42
46
|
],
|
|
43
47
|
rules: [
|
|
44
48
|
{ key: 'approved_next', label: 'Next step when the PR is approved', hint: 'shown when a PR under review is approved' },
|
|
@@ -53,11 +57,37 @@ export const WORKSPACE_FIELDS = {
|
|
|
53
57
|
{ key: 'code_review', label: 'Review statuses', list: true },
|
|
54
58
|
{ key: 'qa', label: 'Testing statuses', list: true },
|
|
55
59
|
{ key: 'passed', label: 'Passed statuses', list: true },
|
|
60
|
+
{ key: 'release', label: 'Release statuses', list: true },
|
|
56
61
|
{ key: 'on_hold', label: 'On hold statuses', list: true },
|
|
57
62
|
{ key: 'backlog', label: 'Backlog statuses', list: true },
|
|
58
63
|
],
|
|
59
64
|
};
|
|
60
65
|
|
|
66
|
+
/** The monitor-wide numbers the settings sheet and Claude may edit, with their defaults: how often to poll, how far back the feed reaches, and when a health alert fires. */
|
|
67
|
+
export const GLOBAL_SETTINGS = {
|
|
68
|
+
poll_seconds: { fallback: 60, label: 'Poll every (seconds)', hint: 'how often every workspace is polled' },
|
|
69
|
+
history_days: { fallback: 14, label: 'History (days)', hint: 'how far back the feed and the mentions reach', whole: true },
|
|
70
|
+
stale_minutes: { fallback: DEFAULT_LIMITS.stale_minutes, label: 'Stale after (minutes)', hint: 'alert when no source has answered for this long' },
|
|
71
|
+
failing_polls: { fallback: DEFAULT_LIMITS.failing_polls, label: 'Failing polls in a row', hint: 'alert when one source fails this many polls in a row', whole: true },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Field violations for a `settings` object: unknown keys, and values that are not positive numbers (whole numbers where the key says so). */
|
|
75
|
+
export const settingsViolations = (settings) => {
|
|
76
|
+
if (!isObject(settings)) {
|
|
77
|
+
return [{ field: 'settings', description: 'settings must be an object.' }];
|
|
78
|
+
}
|
|
79
|
+
return Object.entries(settings).flatMap(([key, value]) => {
|
|
80
|
+
const spec = GLOBAL_SETTINGS[key];
|
|
81
|
+
if (!spec) {
|
|
82
|
+
return [{ field: `settings.${key}`, description: `${key} is not a setting; the settings are ${Object.keys(GLOBAL_SETTINGS).join(', ')}.` }];
|
|
83
|
+
}
|
|
84
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || (spec.whole && !Number.isInteger(value))) {
|
|
85
|
+
return [{ field: `settings.${key}`, description: `${key} must be a positive ${spec.whole ? 'whole ' : ''}number.` }];
|
|
86
|
+
}
|
|
87
|
+
return [];
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
61
91
|
/** The per-platform directory that holds config.json and the state, unless WHOSE_TURN_DATA overrides it. */
|
|
62
92
|
export const defaultDataDir = () => {
|
|
63
93
|
if (process.platform === 'darwin') {
|
|
@@ -115,8 +145,6 @@ const legacyWorkspace = (raw) => {
|
|
|
115
145
|
return { id: 'default', name: 'Default', connectors, rules: raw.rules, key_pattern: raw.jira?.key_pattern };
|
|
116
146
|
};
|
|
117
147
|
|
|
118
|
-
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
119
|
-
|
|
120
148
|
const connectorEntries = (connectors) => {
|
|
121
149
|
const list = Array.isArray(connectors) ? connectors : Object.entries(connectors || {}).map(([type, settings]) => ({ type, ...settings }));
|
|
122
150
|
return list.filter(isObject).map((entry) => ({ ...entry, id: String(entry.id || entry.type) }));
|
|
@@ -126,14 +154,21 @@ const normaliseTheme = (theme, problems, workspaceId) => {
|
|
|
126
154
|
if (!theme) {
|
|
127
155
|
return null;
|
|
128
156
|
}
|
|
157
|
+
const backdrop = theme.backdrop === false ? { backdrop: false } : {};
|
|
158
|
+
if (theme.backdrop !== undefined && typeof theme.backdrop !== 'boolean') {
|
|
159
|
+
problems.push(`Workspace "${workspaceId}": theme.backdrop must be true or false; the backdrop stays on.`);
|
|
160
|
+
}
|
|
161
|
+
if (theme.seed === undefined && theme.accent === undefined) {
|
|
162
|
+
return Object.keys(backdrop).length ? backdrop : null;
|
|
163
|
+
}
|
|
129
164
|
if (!isHex(theme.seed)) {
|
|
130
165
|
problems.push(`Workspace "${workspaceId}": theme.seed must be a hex colour like #3d7a5c; the theme is ignored.`);
|
|
131
|
-
return null;
|
|
166
|
+
return Object.keys(backdrop).length ? backdrop : null;
|
|
132
167
|
}
|
|
133
168
|
if (theme.accent !== undefined && !isHex(theme.accent)) {
|
|
134
169
|
problems.push(`Workspace "${workspaceId}": theme.accent must be a hex colour like #c27a2c; the accent is ignored.`);
|
|
135
170
|
}
|
|
136
|
-
return { seed: theme.seed.toLowerCase(), ...(isHex(theme.accent) ? { accent: theme.accent.toLowerCase() } : {}) };
|
|
171
|
+
return { seed: theme.seed.toLowerCase(), ...(isHex(theme.accent) ? { accent: theme.accent.toLowerCase() } : {}), ...backdrop };
|
|
137
172
|
};
|
|
138
173
|
|
|
139
174
|
const normaliseLinks = (links) => (Array.isArray(links) ? links : []).map((entry) => {
|
|
@@ -159,6 +194,9 @@ const fieldProblems = (workspace, id, index) => {
|
|
|
159
194
|
problems.push(violation(`${at}.key_pattern`, `${where}: key_pattern is not a valid regular expression: ${error.message}`));
|
|
160
195
|
}
|
|
161
196
|
}
|
|
197
|
+
if (workspace.hidden !== undefined && !HIDDEN_MODES.includes(workspace.hidden)) {
|
|
198
|
+
problems.push(violation(`${at}.hidden`, `${where}: hidden must be one of ${HIDDEN_MODES.join(', ')}, or left out.`));
|
|
199
|
+
}
|
|
162
200
|
const rules = workspace.rules || {};
|
|
163
201
|
const backlogChoices = WORKSPACE_FIELDS.rules.find((field) => field.key === 'backlog_rows').choices;
|
|
164
202
|
if (rules.backlog_rows !== undefined && !backlogChoices.includes(rules.backlog_rows)) {
|
|
@@ -210,7 +248,7 @@ const normaliseWorkspace = (workspace, index, problems, warnings) => {
|
|
|
210
248
|
if (tableNotes.length) {
|
|
211
249
|
warnings[id] = tableNotes;
|
|
212
250
|
}
|
|
213
|
-
return {
|
|
251
|
+
return /** @type {any} */ ({
|
|
214
252
|
id,
|
|
215
253
|
links: normaliseLinks(workspace.links),
|
|
216
254
|
name: String(workspace.name || workspace.id || `Workspace ${index + 1}`),
|
|
@@ -218,7 +256,8 @@ const normaliseWorkspace = (workspace, index, problems, warnings) => {
|
|
|
218
256
|
rules,
|
|
219
257
|
key_pattern: workspace.key_pattern || DEFAULT_KEY_PATTERN,
|
|
220
258
|
theme: normaliseTheme(workspace.theme, problems, id),
|
|
221
|
-
|
|
259
|
+
...(HIDDEN_MODES.includes(workspace.hidden) ? { hidden: workspace.hidden } : {}),
|
|
260
|
+
});
|
|
222
261
|
};
|
|
223
262
|
|
|
224
263
|
export const loadConfig = (env = process.env) => {
|
|
@@ -227,7 +266,7 @@ export const loadConfig = (env = process.env) => {
|
|
|
227
266
|
const raw = readJson(configFile);
|
|
228
267
|
const problems = [];
|
|
229
268
|
if (raw === null) {
|
|
230
|
-
problems.push(`No workspace config yet:
|
|
269
|
+
problems.push(`No workspace config yet: add a source in the workspace settings (top right of the workspace). It will be written to ${configFile}.`);
|
|
231
270
|
} else if (raw.parse_error) {
|
|
232
271
|
problems.push(`Cannot parse ${configFile}: ${raw.parse_error}`);
|
|
233
272
|
}
|
|
@@ -236,14 +275,19 @@ export const loadConfig = (env = process.env) => {
|
|
|
236
275
|
const { workspaces: normalised, problems: shapeProblems, warnings } = normaliseWorkspaces(rawWorkspaces);
|
|
237
276
|
const warningLines = Object.entries(warnings).flatMap(([id, lines]) => lines.map((line) => `Workspace "${id}": ${line}.`));
|
|
238
277
|
const workspaces = normalised.filter((workspace) => WORKSPACE_ID.test(workspace.id));
|
|
278
|
+
const health = normaliseHealth(source, problems);
|
|
279
|
+
const pollSeconds = Number(source.poll_seconds || GLOBAL_SETTINGS.poll_seconds.fallback);
|
|
280
|
+
const historyDays = Number(source.history_days || GLOBAL_SETTINGS.history_days.fallback);
|
|
239
281
|
return {
|
|
240
282
|
dataDir,
|
|
241
283
|
configFile,
|
|
242
284
|
port: resolvePort(dataDir, env),
|
|
243
|
-
pollSeconds
|
|
244
|
-
historyDays
|
|
245
|
-
health
|
|
246
|
-
|
|
285
|
+
pollSeconds,
|
|
286
|
+
historyDays,
|
|
287
|
+
health,
|
|
288
|
+
settings: { poll_seconds: pollSeconds, history_days: historyDays, ...health },
|
|
289
|
+
feedback: withoutRetiredFeedbackKeys(source.feedback),
|
|
290
|
+
notifications: normaliseNotifications(source.notifications, problems),
|
|
247
291
|
workspaces,
|
|
248
292
|
rawWorkspaces,
|
|
249
293
|
problems: [...problems, ...shapeProblems, ...warningLines],
|
|
@@ -259,6 +303,20 @@ const normaliseHealth = (source, problems) => Object.fromEntries(Object.entries(
|
|
|
259
303
|
return [key, value];
|
|
260
304
|
}));
|
|
261
305
|
|
|
306
|
+
/** The boolean `desktop` of a notifications block, or undefined. */
|
|
307
|
+
export const desktopOf = (notifications) => (isObject(notifications) && typeof notifications.desktop === 'boolean' ? notifications.desktop : undefined);
|
|
308
|
+
|
|
309
|
+
const normaliseNotifications = (notifications, problems) => {
|
|
310
|
+
if (notifications === undefined) {
|
|
311
|
+
return { desktop: true };
|
|
312
|
+
}
|
|
313
|
+
const desktop = desktopOf(notifications);
|
|
314
|
+
if (desktop === undefined) {
|
|
315
|
+
problems.push('notifications.desktop must be true or false; desktop notifications stay on.');
|
|
316
|
+
}
|
|
317
|
+
return { desktop: desktop ?? true };
|
|
318
|
+
};
|
|
319
|
+
|
|
262
320
|
/** 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
321
|
export const entryShapeViolations = (rawWorkspaces) => (Array.isArray(rawWorkspaces) ? rawWorkspaces : []).flatMap((workspace, index) => {
|
|
264
322
|
if (!isObject(workspace)) {
|
|
@@ -293,18 +351,47 @@ export const normaliseWorkspaces = (rawWorkspaces) => {
|
|
|
293
351
|
problems.push(`Workspace "${workspace.id}" has two connectors with the same id; give each an "id".`);
|
|
294
352
|
}
|
|
295
353
|
}
|
|
354
|
+
if (workspaces.length && workspaces.every((workspace) => workspace.hidden)) {
|
|
355
|
+
problems.push('Keep at least one workspace in the rail: parked and paused workspaces are out of it.');
|
|
356
|
+
}
|
|
296
357
|
const violations = problems.map(toViolation);
|
|
297
358
|
return { workspaces, problems: violations.map((entry) => entry.description), violations, warnings };
|
|
298
359
|
};
|
|
299
360
|
|
|
300
|
-
|
|
301
|
-
|
|
361
|
+
const RETIRED_FEEDBACK_KEYS = ['workspace', 'team', 'project', 'token_service', 'assign'];
|
|
362
|
+
|
|
363
|
+
const withoutRetiredFeedbackKeys = (feedback) => (isObject(feedback) ? Object.fromEntries(Object.entries(feedback).filter(([key]) => !RETIRED_FEEDBACK_KEYS.includes(key))) : {});
|
|
364
|
+
|
|
365
|
+
const storedConfig = (configFile) => {
|
|
302
366
|
const current = readJson(configFile);
|
|
303
|
-
|
|
367
|
+
return current && !current.parse_error ? current : {};
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** Writes `patch` over the config file's top-level keys and leaves every other key as it is; an unparsable file is replaced, the previous one stays as config.json.bak. */
|
|
371
|
+
const mergeIntoConfig = (configFile, patch) => writeConfig(configFile, { ...storedConfig(configFile), ...patch });
|
|
372
|
+
|
|
373
|
+
/** Writes the workspace list into the config file; the previous file stays as config.json.bak. */
|
|
374
|
+
export const saveWorkspaces = (configFile, rawWorkspaces) => {
|
|
375
|
+
const source = storedConfig(configFile);
|
|
304
376
|
delete source.github;
|
|
305
377
|
delete source.jira;
|
|
306
378
|
delete source.rules;
|
|
307
|
-
const
|
|
379
|
+
const feedback = withoutRetiredFeedbackKeys(source.feedback);
|
|
380
|
+
if (Object.keys(feedback).length) {
|
|
381
|
+
source.feedback = feedback;
|
|
382
|
+
} else {
|
|
383
|
+
delete source.feedback;
|
|
384
|
+
}
|
|
385
|
+
writeConfig(configFile, { ...source, workspaces: rawWorkspaces });
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** Writes the validated notifications block into the config file. */
|
|
389
|
+
export const saveNotifications = (configFile, notifications) => mergeIntoConfig(configFile, { notifications });
|
|
390
|
+
|
|
391
|
+
/** Writes the validated monitor-wide numbers (poll_seconds, history_days, stale_minutes, failing_polls) into the config file, leaving every other key as it is. */
|
|
392
|
+
export const saveSettings = (configFile, settings) => mergeIntoConfig(configFile, settings);
|
|
393
|
+
|
|
394
|
+
const writeConfig = (configFile, next) => {
|
|
308
395
|
mkdirSync(dirname(configFile), { recursive: true });
|
|
309
396
|
if (existsSync(configFile)) {
|
|
310
397
|
writeFileSync(`${configFile}.bak.tmp`, readFileSync(configFile));
|
package/lib/default-table.mjs
CHANGED
|
@@ -44,6 +44,7 @@ export const defaultTable = (rules) => [
|
|
|
44
44
|
},
|
|
45
45
|
{ when: { 'ticket.status': 'code_review' }, then: { whose: 'reviewer', next: 'Code review' } },
|
|
46
46
|
{ when: { 'ticket.status': 'qa' }, then: { whose: 'them', next: 'Testing' } },
|
|
47
|
+
{ when: { 'ticket.status': 'release' }, then: { whose: 'release', next: 'the next release' } },
|
|
47
48
|
{ when: { 'ticket.status': 'on_hold', 'ticket.hold_reason': { present: true } }, then: { whose: 'them', next: 'On hold: {ticket.hold_reason}', hold: true } },
|
|
48
49
|
{ when: { 'ticket.status': 'on_hold' }, then: { whose: 'them', next: 'On hold' } },
|
|
49
50
|
{
|
package/lib/errors.mjs
CHANGED
|
@@ -9,8 +9,7 @@ const REASONS = {
|
|
|
9
9
|
INVALID_BODY: { code: 400, status: 'INVALID_ARGUMENT', message: 'The request body is not valid JSON.' },
|
|
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
|
-
NO_WORKSPACE: { code: 412, status: 'FAILED_PRECONDITION', message: 'No workspace is configured;
|
|
13
|
-
NOT_CONFIGURED: { code: 412, status: 'FAILED_PRECONDITION', message: 'Feedback is not configured: set feedback.workspace, feedback.team and feedback.project in the config.' },
|
|
12
|
+
NO_WORKSPACE: { code: 412, status: 'FAILED_PRECONDITION', message: 'No workspace is configured; add one with + in the rail.' },
|
|
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/facts.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { DAY, oneline, newestFirst } from './text.mjs';
|
|
|
4
4
|
export const shortDate = (iso) => new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
5
5
|
|
|
6
6
|
const CATEGORY_GROUPS = { new: 'todo', indeterminate: 'in_progress', done: 'done' };
|
|
7
|
-
const CHAIN_GROUPS = ['backlog', 'passed', 'code_review', 'qa', 'on_hold'];
|
|
7
|
+
const CHAIN_GROUPS = ['backlog', 'passed', 'code_review', 'qa', 'release', 'on_hold'];
|
|
8
8
|
|
|
9
9
|
/** The closed fact dictionary a table row may test or quote, each with its plain words.
|
|
10
10
|
* Rules: docs/mechanisms/rules-table.md, Facts. */
|
package/lib/feedback.mjs
CHANGED
|
@@ -1,147 +1,176 @@
|
|
|
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
|
+
export const NEWS_EVERY_MS = 10 * 60 * 1000;
|
|
14
|
+
const STATE_NAMES = { open: 'Open', ready: 'Fixed, coming in the next release', done: 'Done', dropped: 'Dropped' };
|
|
11
15
|
|
|
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) => {
|
|
16
|
+
const call = async (http, url, options) => {
|
|
36
17
|
try {
|
|
37
|
-
return await
|
|
18
|
+
return await http.fetchJson(url, { ...options, useEtag: false });
|
|
38
19
|
} catch (error) {
|
|
39
|
-
const failure = error instanceof RateLimited
|
|
20
|
+
const failure = error instanceof RateLimited
|
|
21
|
+
? new ApiError('RATE_LIMITED')
|
|
22
|
+
: error.upstreamStatus === 400
|
|
23
|
+
? new ApiError('INVALID_ARGUMENT', { detail: 'the tracker refused the report' })
|
|
24
|
+
: new ApiError('UPSTREAM_FAILED', error.upstreamStatus ? { upstream_status: error.upstreamStatus } : {});
|
|
40
25
|
failure.debug = String(error.message);
|
|
41
26
|
throw failure;
|
|
42
27
|
}
|
|
43
28
|
};
|
|
44
29
|
|
|
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');
|
|
30
|
+
const isSize = (value) => Number.isInteger(value) && value >= 1 && value <= 20000;
|
|
31
|
+
const BROWSERS = ['Chrome', 'Edge', 'Safari', 'Firefox', 'Opera', 'Brave', 'Arc', 'Chromium', 'Other'];
|
|
32
|
+
const exactly = (value, keys) => isObject(value) && Object.keys(value).sort().join() === keys;
|
|
52
33
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
34
|
+
const CLIENT_FIELDS = {
|
|
35
|
+
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),
|
|
36
|
+
display: (value) => {
|
|
37
|
+
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);
|
|
38
|
+
return sane ? { width: value.width, height: value.height, scale: Math.round(value.scale * 100) / 100, viewport: [...value.viewport] } : undefined;
|
|
39
|
+
},
|
|
40
|
+
locale: (value) => (typeof value === 'string' && value.length <= 35 && /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/.test(value) ? value : undefined),
|
|
41
|
+
timezone: (value) => (typeof value === 'string' && value.length <= 64 && /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/.test(value) ? value : undefined),
|
|
42
|
+
theme: (value) => (value === 'light' || value === 'dark' ? value : undefined),
|
|
43
|
+
mode: (value) => (value === 'app' || value === 'tab' ? value : undefined),
|
|
44
|
+
};
|
|
58
45
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return null;
|
|
46
|
+
/** 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. */
|
|
47
|
+
export const clientOf = (client) => {
|
|
48
|
+
const clean = {};
|
|
49
|
+
for (const [key, check] of Object.entries(CLIENT_FIELDS)) {
|
|
50
|
+
const value = isObject(client) ? check(client[key]) : undefined;
|
|
51
|
+
if (value !== undefined) {
|
|
52
|
+
clean[key] = value;
|
|
53
|
+
}
|
|
68
54
|
}
|
|
55
|
+
return clean;
|
|
69
56
|
};
|
|
70
57
|
|
|
71
|
-
|
|
72
|
-
|
|
58
|
+
const environmentOf = ({ sources, install, client }) => ({ os: process.platform, osVersion: release(), arch: process.arch, node: process.version, install, sources: [...new Set(sources)].sort(), ...clientOf(client) });
|
|
59
|
+
|
|
60
|
+
/** 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. When adminToken() yields a token the relay lists every user report instead, each marked `mine` when this machine filed it; no token, or a 401, leaves everything as for any user. News: a report whose state or answer count differs from <data>/reports-seen.json, which markSeen() rewrites when the sheet is read; checkNews() reads the relay at most every ten minutes. RMA-120, RMA-123, RMA-306, RMA-311. */
|
|
61
|
+
export const openReports = (dataDir, { http, relayUrl, adminToken = async () => '' }) => {
|
|
73
62
|
const file = join(dataDir, 'reports.json');
|
|
74
63
|
let items = readJson(file, []);
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
64
|
+
const seenFile = join(dataDir, 'reports-seen.json');
|
|
65
|
+
let seen = readJson(seenFile, null);
|
|
66
|
+
let cached = null;
|
|
67
|
+
let news = false;
|
|
68
|
+
const listeners = new Set();
|
|
69
|
+
/** The admin token is looked up once per run, a miss included (the secret store can cost a process spawn); a 401 drops it so the next read looks again. */
|
|
70
|
+
let lookup = null;
|
|
71
|
+
const readAll = async () => {
|
|
72
|
+
lookup ||= adminToken();
|
|
73
|
+
const token = await lookup;
|
|
74
|
+
if (!token) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const answer = await http.fetchJson(`${relayUrl}?product=${PRODUCT}&all=1`, { useEtag: false, headers: { Authorization: `Bearer ${token}` } });
|
|
79
|
+
return answer.issues || [];
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.upstreamStatus === 401) {
|
|
82
|
+
lookup = null;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
80
85
|
}
|
|
81
|
-
const value = await read();
|
|
82
|
-
cache.set(name, { at: Date.now(), value });
|
|
83
|
-
return value;
|
|
84
86
|
};
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
const readOwn = async () => {
|
|
88
|
+
if (!items.length) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const ids = items.slice(0, MAX_IDS).map((report) => report.id).join(',');
|
|
93
|
+
return (await http.fetchJson(`${relayUrl}?product=${PRODUCT}&ids=${encodeURIComponent(ids)}`, { useEtag: false })).issues || [];
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const live = async (maxAge = 60000) => {
|
|
99
|
+
if (cached && Date.now() - cached.at < maxAge) {
|
|
100
|
+
return cached.value;
|
|
101
|
+
}
|
|
102
|
+
const all = await readAll();
|
|
103
|
+
const issues = all || await readOwn();
|
|
104
|
+
cached = { at: Date.now(), value: { admin: Boolean(all), issues: Object.fromEntries(issues.map((issue) => [issue.identifier, issue])), order: issues.map((issue) => issue.identifier) } };
|
|
105
|
+
return cached.value;
|
|
106
|
+
};
|
|
107
|
+
const shape = (issues) => Object.fromEntries(Object.values(issues).map((issue) => [issue.identifier, { state: issue.outcome, answers: (issue.answers || []).length }]));
|
|
108
|
+
const differs = (now) => Object.entries(now).some(([id, shown]) => !seen[id] || seen[id].state !== shown.state || seen[id].answers !== shown.answers);
|
|
109
|
+
const setNews = (value) => {
|
|
110
|
+
if (value !== news) {
|
|
111
|
+
news = value;
|
|
112
|
+
listeners.forEach((listener) => listener({ report_news: news }));
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const merged = (report, issue) => (issue ? { ...report, title: issue.title, state: STATE_NAMES[issue.outcome] || issue.outcome, outcome: issue.outcome, changed_at: issue.updatedAt } : report);
|
|
116
|
+
const strangers = (id, issue) => merged({ id, title: issue.title, filed_at: issue.updatedAt }, issue);
|
|
93
117
|
return {
|
|
118
|
+
relayUrl,
|
|
94
119
|
list: () => items.slice(),
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
hasNews: () => news,
|
|
121
|
+
subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
|
|
122
|
+
/** The background look: reuses a read younger than ten minutes, so a closed sheet costs the relay one call per ten minutes; the first look only records what is there. */
|
|
123
|
+
checkNews: async () => {
|
|
124
|
+
const now = shape((await live(NEWS_EVERY_MS)).issues);
|
|
125
|
+
if (!seen) {
|
|
126
|
+
seen = now;
|
|
127
|
+
writeJson(seenFile, seen);
|
|
128
|
+
}
|
|
129
|
+
setNews(differs(now));
|
|
130
|
+
return news;
|
|
131
|
+
},
|
|
132
|
+
/** The sheet showed these reports: remember their state and answer count, and calm the button down. A failed read keeps what was remembered. */
|
|
133
|
+
markSeen: async () => {
|
|
134
|
+
const next = { ...seen, ...shape((await live()).issues) };
|
|
135
|
+
if (JSON.stringify(next) !== JSON.stringify(seen)) {
|
|
136
|
+
seen = next;
|
|
137
|
+
writeJson(seenFile, seen);
|
|
138
|
+
}
|
|
139
|
+
setNews(false);
|
|
140
|
+
},
|
|
141
|
+
add: (report) => { items = [report, ...items].slice(0, 100); cached = null; writeJson(file, items); return report; },
|
|
142
|
+
states: async () => {
|
|
143
|
+
const { admin, issues, order } = await live();
|
|
144
|
+
if (!admin) {
|
|
145
|
+
return items.map((report) => merged(report, issues[report.id]));
|
|
146
|
+
}
|
|
147
|
+
const own = new Map(items.map((report) => [report.id, report]));
|
|
148
|
+
return order.map((id) => ({ ...(own.has(id) ? merged(own.get(id), issues[id]) : strangers(id, issues[id])), mine: own.has(id) }));
|
|
149
|
+
},
|
|
150
|
+
detail: async (id) => {
|
|
151
|
+
const { admin, issues } = await live();
|
|
152
|
+
const issue = issues[id];
|
|
153
|
+
const report = items.find((item) => item.id === id) || (admin && issue ? strangers(id, issue) : null);
|
|
99
154
|
if (!report) {
|
|
100
155
|
return null;
|
|
101
156
|
}
|
|
102
|
-
const answers =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
});
|
|
106
|
-
return { ...report, answers, unanswered: Boolean(report.outcome) && report.outcome !== 'open' && !answers.length };
|
|
157
|
+
const answers = (issue ? issue.answers : []).map((answer) => ({ at: answer.at, by: answer.author || '', text: answer.body })).sort((a, b) => a.at.localeCompare(b.at));
|
|
158
|
+
const shown = merged(report, issue);
|
|
159
|
+
return { ...shown, answers, unanswered: (shown.outcome === 'done' || shown.outcome === 'dropped') && !answers.length };
|
|
107
160
|
},
|
|
108
161
|
};
|
|
109
162
|
};
|
|
110
163
|
|
|
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);
|
|
164
|
+
/** Files a report through the relay; returns {identifier, url, title}. */
|
|
165
|
+
export const createFeedback = async ({ http, relayUrl, reports, version, sources = [], install = 'source', client = null }, { kind = 'improvement', title, body = '' }) => {
|
|
166
|
+
const cleanTitle = String(title || '').trim().slice(0, 140);
|
|
119
167
|
if (!cleanTitle) {
|
|
120
168
|
throw new ApiError('INVALID_ARGUMENT', { detail: 'a title is required' }, [{ field: 'title', description: 'A report needs a title.' }]);
|
|
121
169
|
}
|
|
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;
|
|
170
|
+
const text = String(body || '').trim().slice(0, 6000);
|
|
171
|
+
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
172
|
if (reports) {
|
|
144
|
-
reports.add({ id: issue.identifier,
|
|
173
|
+
reports.add({ id: issue.identifier, url: issue.url, title: cleanTitle, kind, body: text, filed_at: nowIso() });
|
|
145
174
|
}
|
|
146
|
-
return issue;
|
|
175
|
+
return { identifier: issue.identifier, url: issue.url, title: cleanTitle };
|
|
147
176
|
};
|
package/lib/linear-api.mjs
CHANGED
package/lib/notify.mjs
CHANGED
|
@@ -26,18 +26,17 @@ const COMMANDS = {
|
|
|
26
26
|
],
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
/** True under a test runner (node:test passes NODE_TEST_CONTEXT to child processes, Vitest sets VITEST) or when WHOSE_TURN_NOTIFY=off. */
|
|
30
|
-
export const muted = (env = process.env) => Boolean(env.NODE_TEST_CONTEXT || env.VITEST) || env.WHOSE_TURN_NOTIFY === 'off';
|
|
31
|
-
|
|
32
29
|
const launch = (file, args, env) => {
|
|
33
|
-
|
|
34
|
-
execFile(file, args, { env: { ...process.env, ...env } }, () => {});
|
|
35
|
-
}
|
|
30
|
+
execFile(file, args, { env: { ...process.env, ...env } }, () => {});
|
|
36
31
|
};
|
|
37
32
|
|
|
38
|
-
|
|
33
|
+
/** Sends one desktop notification; `enabled: false` stays silent. */
|
|
34
|
+
export const notify = (title, body, { platform = process.platform, run = launch, enabled = true } = {}) => {
|
|
39
35
|
const build = COMMANDS[platform];
|
|
40
|
-
if (build) {
|
|
36
|
+
if (build && enabled) {
|
|
41
37
|
run(...build(clean(title, 120), clean(body, 240)));
|
|
42
38
|
}
|
|
43
39
|
};
|
|
40
|
+
|
|
41
|
+
/** A notify function that asks `isOn()` on every call; `options` pass through to `notify`. */
|
|
42
|
+
export const notifier = (isOn, options = {}) => (title, body) => notify(title, body, { ...options, enabled: isOn() });
|