whose-turn 1.0.0-alpha.5 → 1.0.0-alpha.7
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 +42 -30
- package/README.md +11 -10
- package/connectors/linear.mjs +5 -5
- package/dist/assets/_plugin-vue_export-helper-DeQjDyQ9.js +18 -0
- package/dist/assets/bem-zV6iDqYN.js +1 -0
- package/dist/assets/index-BLFgmJcy.js +7 -0
- package/dist/assets/index-CTrTp-xu.css +1 -0
- package/dist/assets/report-BJ_aC1A7.js +1 -0
- package/dist/assets/scheme-BdlolNvz.js +1 -0
- package/dist/assets/sheet-q2RQEgOf.js +1 -0
- package/dist/index.html +5 -5
- package/dist/report.html +3 -3
- package/dist/sheet.html +3 -3
- package/lib/api.mjs +22 -2
- package/lib/changelog.mjs +74 -0
- package/lib/config.mjs +0 -1
- package/lib/errors.mjs +1 -0
- package/lib/facts.mjs +26 -25
- package/lib/notify.mjs +8 -1
- package/lib/rules.mjs +7 -4
- package/lib/secrets.mjs +7 -0
- package/lib/store.mjs +2 -2
- package/lib/trace.mjs +45 -0
- package/lib/workspace.mjs +74 -11
- package/package.json +11 -2
- package/server.mjs +2 -1
- package/dist/assets/_plugin-vue_export-helper-CeaKqebY.js +0 -18
- package/dist/assets/bem-HybmjowO.js +0 -1
- package/dist/assets/index-D_knX0Z-.js +0 -3
- package/dist/assets/index-q5Rj03LQ.css +0 -1
- package/dist/assets/report-B58sXh8K.js +0 -1
- package/dist/assets/scheme-CRAXq4Co.js +0 -1
- package/dist/assets/sheet-BYuZfjzp.js +0 -1
package/lib/api.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { composeIssue, DEFAULT_COMPOSER } from './compose.mjs';
|
|
|
11
11
|
import { readSize, saveSize, checkMode, spawnOpener } from './window.mjs';
|
|
12
12
|
import { frameHandOff } from './handoff.mjs';
|
|
13
13
|
import { noUpdates } from './update.mjs';
|
|
14
|
+
import { LINE_BREAK } from './secrets.mjs';
|
|
14
15
|
|
|
15
16
|
const send = (response, code, body, contentType = 'application/json', cacheControl = 'no-store') => {
|
|
16
17
|
const data = Buffer.isBuffer(body) ? body : Buffer.from(contentType === 'application/json' ? JSON.stringify(body) : body);
|
|
@@ -47,7 +48,6 @@ const assertLocal = (request, port) => {
|
|
|
47
48
|
|
|
48
49
|
const WINDOW_PAGES = ['/', '/report', '/sheet'];
|
|
49
50
|
|
|
50
|
-
/** What a browser sees when a window page is opened under another host name: where to go, instead of the API's JSON refusal. */
|
|
51
51
|
const wrongAddressPage = (port) => {
|
|
52
52
|
const address = `http://127.0.0.1:${port}/`;
|
|
53
53
|
return `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark"><title>Whose Turn</title><p style="font: 15px system-ui; margin: 40px">The window opens only at the local address: <a href="${address}">${address}</a></p>`;
|
|
@@ -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,7 +109,7 @@ 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 }) => {
|
|
104
113
|
const windowFile = createStatic(join(appDir, 'dist'));
|
|
105
114
|
const feedbackCache = {};
|
|
106
115
|
const pick = (id) => {
|
|
@@ -164,6 +173,12 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
164
173
|
const tables = Object.fromEntries(registry.workspaces.map((workspace) => [workspace.id, workspace.rulesTable]));
|
|
165
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 });
|
|
166
175
|
}
|
|
176
|
+
if (url.pathname === '/api/changelog') {
|
|
177
|
+
if (!changelog) {
|
|
178
|
+
throw new ApiError('CHANGELOG_UNAVAILABLE');
|
|
179
|
+
}
|
|
180
|
+
return send(response, 200, changelog);
|
|
181
|
+
}
|
|
167
182
|
if (url.pathname === '/api/inbox') {
|
|
168
183
|
return send(response, 200, { items: inbox.pending(url.searchParams.get('consumer') || '') });
|
|
169
184
|
}
|
|
@@ -267,6 +282,11 @@ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig
|
|
|
267
282
|
const service = String(body.service || '').trim();
|
|
268
283
|
const account = String(body.account || '').trim();
|
|
269
284
|
const value = String(body.value || '').trim();
|
|
285
|
+
for (const [field, text] of Object.entries({ service, account, value })) {
|
|
286
|
+
if (LINE_BREAK.test(text)) {
|
|
287
|
+
throw new ApiError('INVALID_ARGUMENT', { detail: `${field} must not contain a line break` });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
270
290
|
if (!/^[\w.-]{1,80}$/.test(service) || !account || !value) {
|
|
271
291
|
throw new ApiError('INVALID_ARGUMENT', { detail: 'service, account and value are required' });
|
|
272
292
|
}
|
|
@@ -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/config.mjs
CHANGED
|
@@ -146,7 +146,6 @@ const normaliseLinks = (links) => (Array.isArray(links) ? links : []).map((entry
|
|
|
146
146
|
|
|
147
147
|
const isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
148
148
|
|
|
149
|
-
/** A problem tied to one field of the saved list, as the settings form shows it under that field. */
|
|
150
149
|
const violation = (field, description) => ({ field, description });
|
|
151
150
|
|
|
152
151
|
const fieldProblems = (workspace, id, index) => {
|
package/lib/errors.mjs
CHANGED
|
@@ -16,6 +16,7 @@ const REASONS = {
|
|
|
16
16
|
OPEN_TOO_SOON: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'The window was just opened; wait a few seconds before opening it again.' },
|
|
17
17
|
UPDATE_IN_PROGRESS: { code: 409, status: 'ABORTED', message: 'An update is already installing; the server restarts when it is done.' },
|
|
18
18
|
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.' },
|
|
19
|
+
CHANGELOG_UNAVAILABLE: { code: 404, status: 'NOT_FOUND', message: 'This install has no readable CHANGELOG.md.' },
|
|
19
20
|
FORBIDDEN_ORIGIN: { code: 403, status: 'PERMISSION_DENIED', message: 'Only the local window may call this API.' },
|
|
20
21
|
INTERNAL: { code: 500, status: 'INTERNAL', message: 'The local server failed on this request; the details are in its log.' },
|
|
21
22
|
};
|
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. */
|
package/lib/notify.mjs
CHANGED
|
@@ -26,7 +26,14 @@ const COMMANDS = {
|
|
|
26
26
|
],
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
|
|
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
|
+
const launch = (file, args, env) => {
|
|
33
|
+
if (!muted()) {
|
|
34
|
+
execFile(file, args, { env: { ...process.env, ...env } }, () => {});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
30
37
|
|
|
31
38
|
export const notify = (title, body, { platform = process.platform, run = launch } = {}) => {
|
|
32
39
|
const build = COMMANDS[platform];
|
package/lib/rules.mjs
CHANGED
|
@@ -19,8 +19,7 @@ export const compileRules = (rules) => {
|
|
|
19
19
|
};
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
const youOr = (name, myName) => (myName && name === myName ? 'you' : name);
|
|
22
|
+
export const youOr = (name, myName) => (myName && name === myName ? 'you' : name);
|
|
24
23
|
|
|
25
24
|
const BLANK_ROW = {
|
|
26
25
|
group: 'you', whose: 'You', done: false, started: false, rank: 3, why: '', hold_url: '', hold_full: '', status_since: '', status_group: '',
|
|
@@ -177,9 +176,12 @@ export const turnRows = ({ tickets = [], prs = [], comments = [], asks = [], run
|
|
|
177
176
|
return rows;
|
|
178
177
|
};
|
|
179
178
|
|
|
179
|
+
/** The feed id of a ticket event the monitor itself recorded. */
|
|
180
|
+
export const eventId = (event) => `event:${event.key}:${event.at}:${event.kind}`;
|
|
181
|
+
|
|
180
182
|
const feedItem = (fields) => ({ who: '', text: '', body: '', key: '', title: '', ...fields });
|
|
181
183
|
|
|
182
|
-
export const feedRows = ({ activity = [], events = [], tickets = [], comments = [], asks = [] }, { me = {}, rules, historyDays = 14 }) => {
|
|
184
|
+
export const feedRows = ({ activity = [], events = [], tickets = [], comments = [], asks = [], notes = [] }, { me = {}, rules, historyDays = 14 }) => {
|
|
183
185
|
const cutoff = daysAgoIso(historyDays);
|
|
184
186
|
const recentComments = comments.filter((comment) => comment.at >= cutoff);
|
|
185
187
|
const rows = [
|
|
@@ -193,7 +195,8 @@ export const feedRows = ({ activity = [], events = [], tickets = [], comments =
|
|
|
193
195
|
text: oneline(comment.body), body: comment.body, url: comment.url,
|
|
194
196
|
});
|
|
195
197
|
}),
|
|
196
|
-
...events.map((event) => feedItem({ id:
|
|
198
|
+
...events.map((event) => feedItem({ id: eventId(event), at: event.at, source: event.source, ref: event.key, key: event.key, kind: event.kind, text: event.text, url: event.url })),
|
|
199
|
+
...notes.map((note) => feedItem({ id: note.id, at: note.at, source: note.source, ref: note.key, key: note.key, kind: 'note', who: 'you', text: note.text, body: note.text, url: note.url })),
|
|
197
200
|
];
|
|
198
201
|
const timesByKey = groupBy([...events, ...recentComments], (item) => item.key);
|
|
199
202
|
for (const ticket of tickets) {
|
package/lib/secrets.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
const CREDENTIAL_SCRIPT = fileURLToPath(new URL('./platform/credential.ps1', import.meta.url));
|
|
8
8
|
const POWERSHELL = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', CREDENTIAL_SCRIPT];
|
|
9
9
|
|
|
10
|
+
export const LINE_BREAK = /[\r\n]/;
|
|
11
|
+
|
|
10
12
|
const quote = (text) => `"${text.replace(/[\\"]/g, '\\$&')}"`;
|
|
11
13
|
|
|
12
14
|
const STORES = {
|
|
@@ -61,6 +63,11 @@ export const createSecrets = (dataDir, { platform = process.platform, run = exec
|
|
|
61
63
|
return (stdout || '').trim() || (await fromFile(dataDir, service, account));
|
|
62
64
|
},
|
|
63
65
|
set: async (service, account, value) => {
|
|
66
|
+
for (const [field, text] of Object.entries({ service, account, value })) {
|
|
67
|
+
if (LINE_BREAK.test(text)) {
|
|
68
|
+
throw new Error(`The secret ${field} must not contain a line break.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
64
71
|
if (store && (await attempt(store.set(service, account, value))) !== null) {
|
|
65
72
|
return;
|
|
66
73
|
}
|
package/lib/store.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** One workspace's files on disk: state.json (last poll per source), marks.json (seen, handled, notified, health alerts already announced), todos.json and cache.json (connector caches that are data). */
|
|
1
|
+
/** One workspace's files on disk: state.json (last poll per source), marks.json (seen, handled with its info, notified, health alerts already announced), todos.json and cache.json (connector caches that are data). */
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
3
|
import { join, resolve, sep } from 'node:path';
|
|
4
4
|
import { DAY } from './text.mjs';
|
|
@@ -31,7 +31,7 @@ export const openStore = (dataDir, workspaceId) => {
|
|
|
31
31
|
mkdirSync(dir, { recursive: true });
|
|
32
32
|
const files = { state: join(dir, 'state.json'), marks: join(dir, 'marks.json'), todos: join(dir, 'todos.json'), cache: join(dir, 'cache.json') };
|
|
33
33
|
const state = { version: 0, errors: {}, polled_at: '', fails: {}, sources: {}, events: [], ...readJson(files.state, {}) };
|
|
34
|
-
const marks = { seen: [], base: null, handled: {}, notified: [], notified_seeded: false, health_alerted: [], ...readJson(files.marks, {}) };
|
|
34
|
+
const marks = { seen: [], base: null, handled: {}, info: {}, notified: [], notified_seeded: false, health_alerted: [], ...readJson(files.marks, {}) };
|
|
35
35
|
if (!marks.base) {
|
|
36
36
|
marks.base = Date.now() - 3 * DAY;
|
|
37
37
|
}
|
package/lib/trace.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** The trace a row leaves in Handled when its turn passes to someone else: one phrase and the feed event behind it. */
|
|
2
|
+
import { eventId, youOr } from './rules.mjs';
|
|
3
|
+
import { DAY, oneline } from './text.mjs';
|
|
4
|
+
|
|
5
|
+
const lastName = (text) => String(text).split(' → ').pop().trim();
|
|
6
|
+
|
|
7
|
+
/** Ticket rows that were on you before a poll and are on someone else after it. */
|
|
8
|
+
export const passedRows = (before, after) => {
|
|
9
|
+
const later = new Map(after.map((row) => [row.row_id, row]));
|
|
10
|
+
return before
|
|
11
|
+
.filter((row) => row.group === 'you' && row.row_id === row.key && later.get(row.row_id)?.group === 'others')
|
|
12
|
+
.map((row) => ({ before: row, after: later.get(row.row_id) }));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const mergedNow = ({ before, after }) => before.pr_state !== 'MERGED' && after.pr_state === 'MERGED';
|
|
16
|
+
|
|
17
|
+
/** The newest comment on the ticket from the day before the move, as `{author, text}`, or null. */
|
|
18
|
+
const commentBefore = (feed, key, at) => {
|
|
19
|
+
const found = feed.find((item) => item.kind === 'comment' && item.key === key && Date.parse(at) - Date.parse(item.at) < DAY);
|
|
20
|
+
return found ? { author: found.who, text: oneline(found.body || found.text, 4000) } : null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** `{outcome, event, detail}` built from what moved the row, or null when no event explains it. `events` are this poll's ticket events, `feed` is newest first, `me` is your name per source. */
|
|
24
|
+
export const traceOf = (pair, events, feed, me = {}) => {
|
|
25
|
+
const own = events.filter((event) => event.key === pair.after.key && event.source === pair.after.source);
|
|
26
|
+
const reassigned = own.find((event) => event.kind === 'reassigned');
|
|
27
|
+
const status = own.find((event) => event.kind === 'status');
|
|
28
|
+
const merge = mergedNow(pair) && feed.find((item) => item.kind === 'merged' && item.key === pair.after.key);
|
|
29
|
+
if (merge) {
|
|
30
|
+
return { outcome: 'Merged', event: merge.id, detail: { summary: 'Pull request merged' } };
|
|
31
|
+
}
|
|
32
|
+
if (reassigned) {
|
|
33
|
+
const names = reassigned.text.split(' → ');
|
|
34
|
+
const name = names.at(-1).trim();
|
|
35
|
+
const base = name ? `Passed to ${name}` : 'Passed on';
|
|
36
|
+
const comment = commentBefore(feed, pair.after.key, reassigned.at);
|
|
37
|
+
const head = status ? `${base} · ${lastName(status.text)}` : base;
|
|
38
|
+
return {
|
|
39
|
+
outcome: comment ? `${head} · ${comment.text}` : head,
|
|
40
|
+
event: eventId(reassigned),
|
|
41
|
+
detail: { what: head, assignee: names.map((name) => youOr(name.trim(), me[pair.after.source])).join(' → '), ...(status ? { status: status.text } : {}), ...(comment ? { comment } : {}) },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return status ? { outcome: `Moved to ${lastName(status.text)}`, event: eventId(status), detail: { status: status.text } } : null;
|
|
45
|
+
};
|
package/lib/workspace.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** One workspace: polls its connector entries, stores each result by source, merges them by kind, evaluates the rules once per version and notifies. */
|
|
2
2
|
import { openStore } from './store.mjs';
|
|
3
3
|
import { compileRules, turnRows, feedRows, myMoves, isHandled, withHandled, turnCounts, ticketEvents } from './rules.mjs';
|
|
4
|
-
import { makeKeyFinder, nowIso, daysAgoIso, unionCapped, DAY } from './text.mjs';
|
|
4
|
+
import { makeKeyFinder, nowIso, oneline, daysAgoIso, unionCapped, DAY } from './text.mjs';
|
|
5
|
+
import { passedRows, traceOf } from './trace.mjs';
|
|
5
6
|
import { buildHandOff, snapshotOf, renderHandOff, frameHandOff } from './handoff.mjs';
|
|
6
7
|
import { notify as desktopNotify } from './notify.mjs';
|
|
7
8
|
import { DEFAULT_LIMITS, nextFails, assess, transition } from './health.mjs';
|
|
@@ -44,6 +45,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
44
45
|
const listeners = new Set();
|
|
45
46
|
let digest = '';
|
|
46
47
|
let derived = null;
|
|
48
|
+
let marksVersion = 0;
|
|
47
49
|
let freshAt = '';
|
|
48
50
|
const startedAt = nowIso();
|
|
49
51
|
|
|
@@ -55,17 +57,34 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
55
57
|
return base ? `${base}${key}` : '';
|
|
56
58
|
};
|
|
57
59
|
|
|
60
|
+
const noteEvents = () => Object.entries(store.marks.info)
|
|
61
|
+
.filter(([rowId, detail]) => detail.note && store.marks.handled[rowId])
|
|
62
|
+
.map(([rowId, detail]) => ({ id: `note:${rowId}:${store.marks.handled[rowId]}`, at: store.marks.handled[rowId], source: detail.source, key: detail.key, text: detail.note, url: detail.url }));
|
|
63
|
+
|
|
58
64
|
const rows = () => {
|
|
59
65
|
const day = nowIso().slice(0, 10);
|
|
60
|
-
if (!derived || derived.version !== store.state.version || derived.day !== day) {
|
|
66
|
+
if (!derived || derived.version !== store.state.version || derived.day !== day || derived.marksVersion !== marksVersion) {
|
|
61
67
|
const sources = merged();
|
|
62
|
-
const state = { ...sources, events: store.state.events };
|
|
68
|
+
const state = { ...sources, events: store.state.events, notes: noteEvents() };
|
|
63
69
|
const context = { me: me(), rules, historyDays, ticketUrl };
|
|
64
|
-
derived = { version: store.state.version, day, turn: turnRows(sources, context), feed: feedRows(state, context) };
|
|
70
|
+
derived = { version: store.state.version, marksVersion, day, turn: turnRows(sources, context), feed: feedRows(state, context) };
|
|
65
71
|
}
|
|
66
72
|
return derived;
|
|
67
73
|
};
|
|
68
74
|
|
|
75
|
+
/** Saves the marks and, when what rows() derives from them (a note on a handled row) may have moved, drops the derived rows. */
|
|
76
|
+
const marksChanged = (derivedMoved = true) => {
|
|
77
|
+
if (derivedMoved) {
|
|
78
|
+
marksVersion += 1;
|
|
79
|
+
}
|
|
80
|
+
store.saveMarks();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const unmark = (rowId) => {
|
|
84
|
+
delete store.marks.handled[rowId];
|
|
85
|
+
delete store.marks.info[rowId];
|
|
86
|
+
};
|
|
87
|
+
|
|
69
88
|
const emit = () => {
|
|
70
89
|
const message = { ws: definition.id, version: store.state.version, polled_at: store.state.polled_at, open_moves: myMoves(rows().turn, store.marks).length };
|
|
71
90
|
for (const listener of listeners) {
|
|
@@ -143,6 +162,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
143
162
|
const state = store.state;
|
|
144
163
|
const errors = {};
|
|
145
164
|
const ticketsBefore = merged().tickets;
|
|
165
|
+
const turnBefore = rows().turn;
|
|
146
166
|
definition.connectors.forEach((entry, index) => {
|
|
147
167
|
const outcome = outcomes[index];
|
|
148
168
|
Object.assign(errors, outcome.errors);
|
|
@@ -155,9 +175,8 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
155
175
|
delete state.sources[id];
|
|
156
176
|
}
|
|
157
177
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
178
|
+
const fresh = ticketsBefore.length ? ticketEvents(ticketsBefore, merged().tickets) : [];
|
|
179
|
+
state.events = [...fresh, ...state.events].slice(0, 500);
|
|
161
180
|
state.errors = errors;
|
|
162
181
|
state.polled_at = nowIso();
|
|
163
182
|
if (outcomes.some((outcome) => outcome.data)) {
|
|
@@ -171,6 +190,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
171
190
|
digest = next;
|
|
172
191
|
state.version += 1;
|
|
173
192
|
store.saveState();
|
|
193
|
+
recordTrace(turnBefore, fresh);
|
|
174
194
|
if (outcomes.some((outcome) => outcome.data)) {
|
|
175
195
|
notifyNewMoves();
|
|
176
196
|
store.marks.notified_seeded = true;
|
|
@@ -187,6 +207,36 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
187
207
|
return { errors, rateLimited: outcomes.some((outcome) => outcome.rateLimited) };
|
|
188
208
|
};
|
|
189
209
|
|
|
210
|
+
const rowRef = (row) => ({ key: row.key, source: row.source, url: row.ticket_url || row.url });
|
|
211
|
+
|
|
212
|
+
/** Adds the auto Handled mark to rows whose turn passed on in this poll and drops it from rows whose turn came back. */
|
|
213
|
+
const recordTrace = (turnBefore, fresh) => {
|
|
214
|
+
const { turn, feed } = rows();
|
|
215
|
+
const { handled, info } = store.marks;
|
|
216
|
+
let changed = false;
|
|
217
|
+
let notesMoved = false;
|
|
218
|
+
for (const pair of passedRows(turnBefore, turn)) {
|
|
219
|
+
const trace = traceOf(pair, fresh, feed, me());
|
|
220
|
+
if (trace) {
|
|
221
|
+
const rowId = pair.after.row_id;
|
|
222
|
+
notesMoved ||= Boolean(info[rowId]?.note);
|
|
223
|
+
handled[rowId] = [handled[rowId] || '', nowIso(), pair.after.activity_at || ''].sort().pop();
|
|
224
|
+
info[rowId] = { ...info[rowId], ...rowRef(pair.after), auto: true, outcome: trace.outcome, event: trace.event, detail: trace.detail };
|
|
225
|
+
changed = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const [rowId, detail] of Object.entries(info)) {
|
|
229
|
+
if (detail.auto && turn.some((row) => row.row_id === rowId && row.group === 'you')) {
|
|
230
|
+
notesMoved ||= Boolean(detail.note);
|
|
231
|
+
unmark(rowId);
|
|
232
|
+
changed = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (changed) {
|
|
236
|
+
marksChanged(notesMoved);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
190
240
|
const quickLinks = () => mergeLinks(definition.links || [], definition.connectors.flatMap((entry) => (CONNECTORS[entry.type]?.links ? CONNECTORS[entry.type].links(entry) : [])));
|
|
191
241
|
|
|
192
242
|
const expireHandled = () => {
|
|
@@ -194,12 +244,12 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
194
244
|
let changed = false;
|
|
195
245
|
for (const [rowId, at] of Object.entries(store.marks.handled)) {
|
|
196
246
|
if (new Date(at).getTime() < limit) {
|
|
197
|
-
|
|
247
|
+
unmark(rowId);
|
|
198
248
|
changed = true;
|
|
199
249
|
}
|
|
200
250
|
}
|
|
201
251
|
if (changed) {
|
|
202
|
-
|
|
252
|
+
marksChanged();
|
|
203
253
|
}
|
|
204
254
|
};
|
|
205
255
|
|
|
@@ -244,6 +294,16 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
244
294
|
return { ...identity(), moves: myMoves(rows().turn, store.marks), new_events: unread, todos: todosWithLinks().filter((todo) => !todo.done), polled_at: store.state.polled_at, errors: store.state.errors, health: health() };
|
|
245
295
|
};
|
|
246
296
|
|
|
297
|
+
const noteOn = (rowId, text) => {
|
|
298
|
+
const line = oneline(text || '', 200);
|
|
299
|
+
const row = rows().turn.find((candidate) => candidate.row_id === rowId);
|
|
300
|
+
if (!store.marks.handled[rowId] || (!line && !store.marks.info[rowId]?.note)) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const { note, ...rest } = { ...(row ? rowRef(row) : {}), ...store.marks.info[rowId] };
|
|
304
|
+
store.marks.info[rowId] = line ? { ...rest, note: line } : rest;
|
|
305
|
+
};
|
|
306
|
+
|
|
247
307
|
const updateMarks = (body) => {
|
|
248
308
|
const marks = store.marks;
|
|
249
309
|
if (body.seen) {
|
|
@@ -258,12 +318,15 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
258
318
|
}
|
|
259
319
|
for (const [rowId, value] of Object.entries(body.handled || {})) {
|
|
260
320
|
if (value === null) {
|
|
261
|
-
|
|
321
|
+
unmark(rowId);
|
|
262
322
|
} else {
|
|
263
323
|
marks.handled[rowId] = value;
|
|
264
324
|
}
|
|
265
325
|
}
|
|
266
|
-
|
|
326
|
+
for (const [rowId, text] of Object.entries(body.notes || {})) {
|
|
327
|
+
noteOn(rowId, text);
|
|
328
|
+
}
|
|
329
|
+
marksChanged(Object.keys(body.handled || {}).length + Object.keys(body.notes || {}).length > 0);
|
|
267
330
|
emit();
|
|
268
331
|
return marked();
|
|
269
332
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whose-turn",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.7",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,13 +37,15 @@
|
|
|
37
37
|
"lint:style": "stylelint \"web/src/**/*.{vue,css}\"",
|
|
38
38
|
"format": "prettier --write web/src",
|
|
39
39
|
"check": "npm run typecheck && npm run lint && npm run lint:style && npm test && npm run test:web",
|
|
40
|
-
"test:web": "vitest run"
|
|
40
|
+
"test:web": "vitest run",
|
|
41
|
+
"mutation": "stryker run"
|
|
41
42
|
},
|
|
42
43
|
"bin": {
|
|
43
44
|
"whose-turn": "server.mjs"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"@eslint/js": "10.0.1",
|
|
48
|
+
"@stryker-mutator/core": "^10.1.1",
|
|
47
49
|
"@testing-library/dom": "10.4.2",
|
|
48
50
|
"@testing-library/vue": "8.1.0",
|
|
49
51
|
"@types/node": "22.19.19",
|
|
@@ -66,5 +68,12 @@
|
|
|
66
68
|
"vue": "^3.5.0",
|
|
67
69
|
"vue-eslint-parser": "10.4.1",
|
|
68
70
|
"vue-tsc": "3.3.13"
|
|
71
|
+
},
|
|
72
|
+
"overrides": {
|
|
73
|
+
"qs": "^6.16.1",
|
|
74
|
+
"fast-uri": "^3.1.9",
|
|
75
|
+
"zod": "^4.6.7",
|
|
76
|
+
"minimatch": "^11.0.4",
|
|
77
|
+
"glob": "^14.0.3"
|
|
69
78
|
}
|
|
70
79
|
}
|
package/server.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { openInbox } from './lib/inbox.mjs';
|
|
|
13
13
|
import { openReports } from './lib/feedback.mjs';
|
|
14
14
|
import { startApi } from './lib/api.mjs';
|
|
15
15
|
import { notify } from './lib/notify.mjs';
|
|
16
|
+
import { loadChangelog } from './lib/changelog.mjs';
|
|
16
17
|
import { createUpdater, restartServer, runsInstalled } from './lib/update.mjs';
|
|
17
18
|
|
|
18
19
|
const appDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -71,7 +72,7 @@ const saveConfig = async (rawWorkspaces) => {
|
|
|
71
72
|
};
|
|
72
73
|
|
|
73
74
|
try {
|
|
74
|
-
server = await startApi({ port: first.port, appDir, registry, requestPoll, saveConfig, secrets, inbox, http, reports, dataDir: first.dataDir, updater });
|
|
75
|
+
server = await startApi({ port: first.port, appDir, registry, requestPoll, saveConfig, secrets, inbox, http, reports, dataDir: first.dataDir, updater, changelog: loadChangelog(appDir, version) });
|
|
75
76
|
} catch (error) {
|
|
76
77
|
console.error(error.message);
|
|
77
78
|
process.exit(1);
|