whose-turn 1.0.0-alpha.6 → 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 +51 -33
- package/README.md +14 -13
- package/config.example.json +0 -4
- package/connectors/linear.mjs +5 -5
- package/dist/assets/_plugin-vue_export-helper-DeQjDyQ9.js +18 -0
- 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-GgVB1dt8.js → report-Bar3ULi6.js} +1 -1
- package/dist/assets/scheme-BdlolNvz.js +1 -0
- package/dist/assets/{sheet-CXZ1EA3c.js → sheet-q2RQEgOf.js} +1 -1
- package/dist/index.html +5 -5
- package/dist/report.html +3 -3
- package/dist/sheet.html +3 -3
- package/lib/api.mjs +43 -23
- package/lib/changelog.mjs +74 -0
- package/lib/compose.mjs +6 -8
- package/lib/config.mjs +38 -5
- package/lib/errors.mjs +1 -1
- package/lib/facts.mjs +26 -25
- package/lib/feedback.mjs +78 -110
- package/lib/linear-api.mjs +1 -1
- package/lib/notify.mjs +14 -3
- package/lib/registry.mjs +7 -2
- package/lib/rules.mjs +7 -3
- package/lib/store.mjs +2 -2
- package/lib/text.mjs +3 -0
- package/lib/trace.mjs +45 -0
- package/lib/workspace.mjs +75 -12
- package/package.json +11 -2
- package/server.mjs +9 -6
- package/dist/assets/_plugin-vue_export-helper-BxUUR8De.js +0 -18
- package/dist/assets/bem-HybmjowO.js +0 -1
- package/dist/assets/index-C4WWZEBZ.css +0 -1
- package/dist/assets/index-oEi3SjBF.js +0 -3
- package/dist/assets/scheme-BtoCLyX6.js +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';
|
|
@@ -66,6 +66,15 @@ const marksProblem = (body) => {
|
|
|
66
66
|
if ('base' in body && !(Number.isFinite(body.base) && body.base <= Date.now() + CLOCK_SKEW_MS)) {
|
|
67
67
|
return 'base must be a number of milliseconds, not in the future';
|
|
68
68
|
}
|
|
69
|
+
if (body.notes !== undefined) {
|
|
70
|
+
if (!body.notes || typeof body.notes !== 'object' || Array.isArray(body.notes)) {
|
|
71
|
+
return 'notes must map row ids to a line of text';
|
|
72
|
+
}
|
|
73
|
+
const wrongNote = Object.entries(body.notes).find(([, value]) => typeof value !== 'string' || value.length > 200);
|
|
74
|
+
if (wrongNote) {
|
|
75
|
+
return `notes.${wrongNote[0]} must be text of at most 200 characters`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
69
78
|
if (body.handled === undefined) {
|
|
70
79
|
return '';
|
|
71
80
|
}
|
|
@@ -100,9 +109,8 @@ const aggregateSummary = (workspaces) => {
|
|
|
100
109
|
|
|
101
110
|
const OPEN_COOLDOWN_MS = 5000;
|
|
102
111
|
|
|
103
|
-
export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig, secrets, inbox, http, reports, dataDir, opener = spawnOpener(appDir), updater = noUpdates() }) => {
|
|
112
|
+
export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig, secrets, inbox, http, reports, dataDir, opener = spawnOpener(appDir), updater = noUpdates(), changelog = null, version = '' }) => {
|
|
104
113
|
const windowFile = createStatic(join(appDir, 'dist'));
|
|
105
|
-
const feedbackCache = {};
|
|
106
114
|
const pick = (id) => {
|
|
107
115
|
const workspaces = registry.workspaces;
|
|
108
116
|
if (!workspaces.length) {
|
|
@@ -155,14 +163,20 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
155
163
|
return send(response, 200, { workspaces: [], problems: registry.problems, update: updater.status() });
|
|
156
164
|
}
|
|
157
165
|
const workspace = pick(url.searchParams.get('ws'));
|
|
158
|
-
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() });
|
|
159
167
|
}
|
|
160
168
|
if (url.pathname === '/api/summary') {
|
|
161
|
-
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() });
|
|
162
170
|
}
|
|
163
171
|
if (url.pathname === '/api/config') {
|
|
164
172
|
const tables = Object.fromEntries(registry.workspaces.map((workspace) => [workspace.id, workspace.rulesTable]));
|
|
165
|
-
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 });
|
|
174
|
+
}
|
|
175
|
+
if (url.pathname === '/api/changelog') {
|
|
176
|
+
if (!changelog) {
|
|
177
|
+
throw new ApiError('CHANGELOG_UNAVAILABLE');
|
|
178
|
+
}
|
|
179
|
+
return send(response, 200, changelog);
|
|
166
180
|
}
|
|
167
181
|
if (url.pathname === '/api/inbox') {
|
|
168
182
|
return send(response, 200, { items: inbox.pending(url.searchParams.get('consumer') || '') });
|
|
@@ -170,10 +184,10 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
170
184
|
if (url.pathname === '/api/feedback') {
|
|
171
185
|
const id = url.searchParams.get('id');
|
|
172
186
|
if (id) {
|
|
173
|
-
return send(response, 200, { report: await reports.detail(
|
|
187
|
+
return send(response, 200, { report: await reports.detail(id) });
|
|
174
188
|
}
|
|
175
|
-
const list = await reports.states(
|
|
176
|
-
return send(response, 200, {
|
|
189
|
+
const list = await reports.states();
|
|
190
|
+
return send(response, 200, { reports: list });
|
|
177
191
|
}
|
|
178
192
|
if (url.pathname === '/api/handoff') {
|
|
179
193
|
if (url.searchParams.get('all') === '1') {
|
|
@@ -225,6 +239,15 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
225
239
|
return { ...target, value: String(item.value).trim() };
|
|
226
240
|
});
|
|
227
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
|
+
|
|
228
251
|
const handleConfig = async (body) => {
|
|
229
252
|
if (!Array.isArray(body.workspaces)) {
|
|
230
253
|
throw new ApiError('INVALID_ARGUMENT', { detail: 'workspaces must be a list' });
|
|
@@ -260,7 +283,7 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
260
283
|
await secrets.set(secret.service, secret.account, secret.value);
|
|
261
284
|
}
|
|
262
285
|
await saveConfig(body.workspaces);
|
|
263
|
-
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 })) };
|
|
264
287
|
};
|
|
265
288
|
|
|
266
289
|
const handleSecret = async (body) => {
|
|
@@ -306,12 +329,10 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
306
329
|
return { item: inbox.add({ ws: workspace.id, ws_name: workspace.name, kind, ref, snapshot }) };
|
|
307
330
|
};
|
|
308
331
|
|
|
309
|
-
const reportDeps = () => ({ settings: registry.feedback, secrets, http });
|
|
310
|
-
|
|
311
332
|
let lastRemark = { text: '', at: 0 };
|
|
312
333
|
const handleFeedback = async (body) => {
|
|
313
|
-
if (
|
|
314
|
-
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.' }]);
|
|
315
336
|
}
|
|
316
337
|
const remark = typeof body.text === 'string' ? body.text.trim() : '';
|
|
317
338
|
if (!remark && !String(body.title || '').trim()) {
|
|
@@ -323,16 +344,12 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
323
344
|
if (remark) {
|
|
324
345
|
lastRemark = { text: remark, at: Date.now() };
|
|
325
346
|
}
|
|
326
|
-
|
|
327
|
-
if (body.ws && body.kind_ref) {
|
|
328
|
-
const workspace = pick(body.ws);
|
|
329
|
-
context = workspace.handOffText(body.kind_ref.kind, body.kind_ref.ref);
|
|
330
|
-
}
|
|
347
|
+
const sources = (body.ws ? [pick(body.ws)] : registry.workspaces).flatMap((workspace) => workspace.sourceTypes);
|
|
331
348
|
try {
|
|
332
349
|
const composer = registry.feedback && registry.feedback.composer && registry.feedback.composer.command ? registry.feedback.composer : DEFAULT_COMPOSER;
|
|
333
|
-
const shaped = remark ? await composeIssue(remark, {
|
|
334
|
-
const issue = await createFeedback({
|
|
335
|
-
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() };
|
|
336
353
|
} catch (error) {
|
|
337
354
|
lastRemark = { text: '', at: 0 };
|
|
338
355
|
throw error;
|
|
@@ -352,6 +369,9 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
352
369
|
if (url.pathname === '/api/config') {
|
|
353
370
|
return send(response, 200, await handleConfig(body));
|
|
354
371
|
}
|
|
372
|
+
if (url.pathname === '/api/notifications') {
|
|
373
|
+
return send(response, 200, await handleNotifications(body));
|
|
374
|
+
}
|
|
355
375
|
if (url.pathname === '/api/secrets') {
|
|
356
376
|
return send(response, 200, await handleSecret(body));
|
|
357
377
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Parses CHANGELOG.md (Keep a Changelog) into versions, newest first as written, and reads it next to server.mjs. */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const VERSION_HEADING = /^##\s+\[([^\]]+)\](?:\s+-\s+(\S.*?))?\s*$/;
|
|
6
|
+
const SECTION_HEADING = /^###\s+(\S.*?)\s*$/;
|
|
7
|
+
const ISSUE_ID = String.raw`[A-Z][A-Z0-9]+-\d+`;
|
|
8
|
+
const TRAILING_IDS = new RegExp(String.raw`(?<=[.!?])\s+${ISSUE_ID}(?:,\s*${ISSUE_ID})*\.?$`);
|
|
9
|
+
const BRACKETED_IDS = new RegExp(String.raw`\s*\((?:${ISSUE_ID})(?:,\s*${ISSUE_ID})*\)`, 'g');
|
|
10
|
+
|
|
11
|
+
/** An entry as a user reads it: the internal tracker ids at its end or in brackets are dropped. */
|
|
12
|
+
export const withoutIds = (entry) => entry.replace(BRACKETED_IDS, '').replace(TRAILING_IDS, '').trim();
|
|
13
|
+
|
|
14
|
+
/** [{version, date, sections: [{kind, items: [markdown]}]}]; a malformed `##` heading ends the version above it and its body is dropped. */
|
|
15
|
+
export const parseChangelog = (text) => {
|
|
16
|
+
const versions = [];
|
|
17
|
+
let version = null;
|
|
18
|
+
let section = null;
|
|
19
|
+
let item = null;
|
|
20
|
+
const closeItem = () => {
|
|
21
|
+
if (item !== null && section) {
|
|
22
|
+
section.items.push(withoutIds(item.trim()));
|
|
23
|
+
}
|
|
24
|
+
item = null;
|
|
25
|
+
};
|
|
26
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
27
|
+
if (line.startsWith('## ') || line === '##') {
|
|
28
|
+
closeItem();
|
|
29
|
+
const heading = line.match(VERSION_HEADING);
|
|
30
|
+
version = heading ? { version: heading[1], date: heading[2] || '', sections: [] } : null;
|
|
31
|
+
section = null;
|
|
32
|
+
if (version) {
|
|
33
|
+
versions.push(version);
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!version) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const kind = line.match(SECTION_HEADING);
|
|
41
|
+
if (kind) {
|
|
42
|
+
closeItem();
|
|
43
|
+
section = { kind: kind[1], items: [] };
|
|
44
|
+
version.sections.push(section);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!section) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (line.startsWith('- ')) {
|
|
51
|
+
closeItem();
|
|
52
|
+
item = line.slice(2);
|
|
53
|
+
} else if (item !== null && /^\s+\S/.test(line)) {
|
|
54
|
+
item += '\n' + line.trim();
|
|
55
|
+
} else {
|
|
56
|
+
closeItem();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
closeItem();
|
|
60
|
+
versions.forEach((entry) => {
|
|
61
|
+
entry.sections = entry.sections.filter((one) => one.items.length);
|
|
62
|
+
});
|
|
63
|
+
return versions;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The reply of GET /api/changelog: the released versions only, or null when the file cannot be read. */
|
|
67
|
+
export const loadChangelog = (appDir, current) => {
|
|
68
|
+
try {
|
|
69
|
+
const versions = parseChangelog(readFileSync(join(appDir, 'CHANGELOG.md'), 'utf8'));
|
|
70
|
+
return { current, versions: versions.filter((entry) => entry.version !== 'Unreleased') };
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
};
|
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,12 +10,12 @@ 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.' },
|
|
17
16
|
UPDATE_IN_PROGRESS: { code: 409, status: 'ABORTED', message: 'An update is already installing; the server restarts when it is done.' },
|
|
18
17
|
UPDATE_UNAVAILABLE: { code: 412, status: 'FAILED_PRECONDITION', message: 'There is no update to apply: the server runs from a checkout or already runs the latest version.' },
|
|
18
|
+
CHANGELOG_UNAVAILABLE: { code: 404, status: 'NOT_FOUND', message: 'This install has no readable CHANGELOG.md.' },
|
|
19
19
|
FORBIDDEN_ORIGIN: { code: 403, status: 'PERMISSION_DENIED', message: 'Only the local window may call this API.' },
|
|
20
20
|
INTERNAL: { code: 500, status: 'INTERNAL', message: 'The local server failed on this request; the details are in its log.' },
|
|
21
21
|
};
|
package/lib/facts.mjs
CHANGED
|
@@ -6,32 +6,33 @@ export const shortDate = (iso) => new Date(iso).toLocaleDateString('en-US', { mo
|
|
|
6
6
|
const CATEGORY_GROUPS = { new: 'todo', indeterminate: 'in_progress', done: 'done' };
|
|
7
7
|
const CHAIN_GROUPS = ['backlog', 'passed', 'code_review', 'qa', 'on_hold'];
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/** The closed fact dictionary a table row may test or quote, each with its plain words.
|
|
10
|
+
* Rules: docs/mechanisms/rules-table.md, Facts. */
|
|
10
11
|
export const FACTS = {
|
|
11
|
-
connector: { type: 'string', from: 'the connector entry id the ticket came from' },
|
|
12
|
-
'ticket.key': { type: 'string', from: 'tracker connectors (jira, linear)' },
|
|
13
|
-
'ticket.status': { type: 'string', values: 'status groups', from: 'the ticket status matched against rules.statuses; else todo, in_progress or done from the status category' },
|
|
14
|
-
'ticket.status_name': { type: 'string', from: 'the status as the tracker names it' },
|
|
15
|
-
'ticket.assignee': { type: 'string', from: '"me" when the ticket is on you, else the assignee name' },
|
|
16
|
-
'ticket.done': { type: 'boolean', from: 'the status category is done' },
|
|
17
|
-
'ticket.started': { type: 'boolean', from: 'the status category is in progress' },
|
|
18
|
-
'ticket.hold_reason': { type: 'string', from: 'the comment that explains an on_hold status; missing when none does' },
|
|
19
|
-
'pr.open': { type: 'boolean', from: 'github: the ticket has an open PR; false when it has none' },
|
|
20
|
-
'pr.merged': { type: 'boolean', from: 'github: the ticket PR is merged; false when it has none' },
|
|
21
|
-
'pr.number': { type: 'number', from: 'github' },
|
|
22
|
-
'pr.checks': { type: 'string', values: ['passing', 'failing', 'running', 'none'], from: 'github' },
|
|
23
|
-
'pr.review': { type: 'string', values: ['approved', 'changes_requested', 'none'], from: 'github' },
|
|
24
|
-
'pr.base': { type: 'string', from: 'github: the branch the PR goes into' },
|
|
25
|
-
'pr.unanswered': { type: 'boolean', from: 'github: a human commented after your last comment and it is not an approval' },
|
|
26
|
-
'pr.commenter': { type: 'string', from: 'github: the author of that last comment' },
|
|
27
|
-
'comment.mentions_me': { type: 'boolean', from: 'tracker comments: an unanswered @-mention of you on the ticket' },
|
|
28
|
-
'comment.is_question': { type: 'boolean', from: 'the newest unanswered mention reads as a question' },
|
|
29
|
-
'comment.author': { type: 'string', from: 'the author of the newest unanswered mention' },
|
|
30
|
-
unanswered_count: { type: 'number', from: 'how many @-mentions of you on the ticket are unanswered' },
|
|
31
|
-
'run.failed': { type: 'boolean', from: 'github: the latest completed run of a workflow on the default branch failed; missing on tickets' },
|
|
32
|
-
'run.workflow': { type: 'string', from: 'github: the name of that workflow' },
|
|
33
|
-
'run.branch': { type: 'string', from: 'github: the branch it ran on, the default branch' },
|
|
34
|
-
'run.mine': { type: 'boolean', from: 'github: you pushed the commit or started the run' },
|
|
12
|
+
connector: { type: 'string', name: 'the source', from: 'the connector entry id the ticket came from' },
|
|
13
|
+
'ticket.key': { type: 'string', name: 'the ticket key', from: 'tracker connectors (jira, linear)' },
|
|
14
|
+
'ticket.status': { type: 'string', name: 'the ticket status', values: 'status groups', from: 'the ticket status matched against rules.statuses; else todo, in_progress or done from the status category' },
|
|
15
|
+
'ticket.status_name': { type: 'string', name: 'the status', from: 'the status as the tracker names it' },
|
|
16
|
+
'ticket.assignee': { type: 'string', name: 'the assignee', from: '"me" when the ticket is on you, else the assignee name' },
|
|
17
|
+
'ticket.done': { type: 'boolean', says: ['the ticket is done', 'the ticket is not done'], from: 'the status category is done' },
|
|
18
|
+
'ticket.started': { type: 'boolean', says: ['the ticket is started', 'the ticket is not started'], from: 'the status category is in progress' },
|
|
19
|
+
'ticket.hold_reason': { type: 'string', name: 'the hold reason', from: 'the comment that explains an on_hold status; missing when none does' },
|
|
20
|
+
'pr.open': { type: 'boolean', says: ['the PR is open', 'no PR is open'], from: 'github: the ticket has an open PR; false when it has none' },
|
|
21
|
+
'pr.merged': { type: 'boolean', says: ['the PR is merged', 'the PR is not merged'], from: 'github: the ticket PR is merged; false when it has none' },
|
|
22
|
+
'pr.number': { type: 'number', name: 'the PR number', from: 'github' },
|
|
23
|
+
'pr.checks': { type: 'string', name: 'the PR checks', values: ['passing', 'failing', 'running', 'none'], from: 'github' },
|
|
24
|
+
'pr.review': { type: 'string', name: 'the PR review', values: ['approved', 'changes_requested', 'none'], from: 'github' },
|
|
25
|
+
'pr.base': { type: 'string', name: 'the target branch', from: 'github: the branch the PR goes into' },
|
|
26
|
+
'pr.unanswered': { type: 'boolean', says: ['a comment on the PR waits for your answer', 'no comment on the PR waits for your answer'], from: 'github: a human commented after your last comment and it is not an approval' },
|
|
27
|
+
'pr.commenter': { type: 'string', name: 'the commenter', from: 'github: the author of that last comment' },
|
|
28
|
+
'comment.mentions_me': { type: 'boolean', says: ['you are mentioned and have not answered', 'no mention of you waits for an answer'], from: 'tracker comments: an unanswered @-mention of you on the ticket' },
|
|
29
|
+
'comment.is_question': { type: 'boolean', says: ['the mention is a question', 'the mention is not a question'], from: 'the newest unanswered mention reads as a question' },
|
|
30
|
+
'comment.author': { type: 'string', name: 'the author', from: 'the author of the newest unanswered mention' },
|
|
31
|
+
unanswered_count: { type: 'number', name: 'the number of mentions of you', from: 'how many @-mentions of you on the ticket are unanswered' },
|
|
32
|
+
'run.failed': { type: 'boolean', says: ['a workflow run failed', 'no workflow run failed'], from: 'github: the latest completed run of a workflow on the default branch failed; missing on tickets' },
|
|
33
|
+
'run.workflow': { type: 'string', name: 'the workflow', from: 'github: the name of that workflow' },
|
|
34
|
+
'run.branch': { type: 'string', name: 'the branch', from: 'github: the branch it ran on, the default branch' },
|
|
35
|
+
'run.mine': { type: 'boolean', says: ['the run is yours', 'the run is not yours'], from: 'github: you pushed the commit or started the run' },
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
/** The order status words are tried in: the chain's groups first, then any extra group in rules.statuses. */
|