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/feedback.mjs
CHANGED
|
@@ -1,147 +1,115 @@
|
|
|
1
|
-
/** Files a bug or an improvement about the monitor itself
|
|
2
|
-
import {
|
|
1
|
+
/** Files a bug or an improvement about the monitor itself through the maintainer's relay, and reads back what became of it. A report carries the kind, the title, the text, the version and a technical environment built here, nothing else. RMA-104, RMA-280, RMA-291. */
|
|
2
|
+
import { release } from 'node:os';
|
|
3
|
+
import { join, sep } from 'node:path';
|
|
3
4
|
import { readJson, writeJson } from './store.mjs';
|
|
4
|
-
import { nowIso } from './text.mjs';
|
|
5
|
+
import { isObject, nowIso } from './text.mjs';
|
|
5
6
|
import { ApiError } from './errors.mjs';
|
|
6
7
|
import { RateLimited } from './http-client.mjs';
|
|
7
|
-
import { linearQuery } from './linear-api.mjs';
|
|
8
8
|
|
|
9
|
-
export const
|
|
10
|
-
const
|
|
9
|
+
export const RELAY_URL = 'https://6cd7q2274i726zmnyav22ar2lu0fangt.lambda-url.eu-central-1.on.aws/';
|
|
10
|
+
const PRODUCT = 'whose-turn';
|
|
11
|
+
export const installOf = (appDir) => (appDir.split(sep).includes('node_modules') ? 'npm' : 'source');
|
|
12
|
+
const MAX_IDS = 50;
|
|
13
|
+
const STATE_NAMES = { open: 'Open', done: 'Done', dropped: 'Dropped' };
|
|
11
14
|
|
|
12
|
-
const
|
|
13
|
-
query Lookup($team: String!, $project: String!) {
|
|
14
|
-
viewer { id }
|
|
15
|
-
teams(filter: { key: { eq: $team } }) { nodes { id key labels { nodes { id name } } } }
|
|
16
|
-
projects(filter: { name: { eqIgnoreCase: $project } }) { nodes { id name } }
|
|
17
|
-
}`;
|
|
18
|
-
|
|
19
|
-
const STATES = `
|
|
20
|
-
query States($team: String!, $numbers: [Float!]!) {
|
|
21
|
-
issues(first: 100, filter: { team: { key: { eq: $team } }, number: { in: $numbers } }) { nodes { identifier title url updatedAt state { name type } } }
|
|
22
|
-
}`;
|
|
23
|
-
|
|
24
|
-
const ANSWERS = `
|
|
25
|
-
query Answers($id: String!) {
|
|
26
|
-
issue(id: $id) { comments(first: 50) { nodes { body createdAt user { name } } } }
|
|
27
|
-
}`;
|
|
28
|
-
|
|
29
|
-
const CREATE = `
|
|
30
|
-
mutation Create($input: IssueCreateInput!) {
|
|
31
|
-
issueCreate(input: $input) { success issue { id identifier number url title } }
|
|
32
|
-
}`;
|
|
33
|
-
|
|
34
|
-
/** A Linear call; a rate limit, a failing answer or a GraphQL error comes out as the matching ApiError, the raw text only in its debug field. */
|
|
35
|
-
const call = async (http, key, query, variables) => {
|
|
15
|
+
const call = async (http, url, options) => {
|
|
36
16
|
try {
|
|
37
|
-
return await
|
|
17
|
+
return await http.fetchJson(url, { ...options, useEtag: false });
|
|
38
18
|
} catch (error) {
|
|
39
|
-
const failure = error instanceof RateLimited
|
|
19
|
+
const failure = error instanceof RateLimited
|
|
20
|
+
? new ApiError('RATE_LIMITED')
|
|
21
|
+
: error.upstreamStatus === 400
|
|
22
|
+
? new ApiError('INVALID_ARGUMENT', { detail: 'the tracker refused the report' })
|
|
23
|
+
: new ApiError('UPSTREAM_FAILED', error.upstreamStatus ? { upstream_status: error.upstreamStatus } : {});
|
|
40
24
|
failure.debug = String(error.message);
|
|
41
25
|
throw failure;
|
|
42
26
|
}
|
|
43
27
|
};
|
|
44
28
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
'---',
|
|
49
|
-
`Filed from Whose Turn by ${from || 'the window'} on ${nowIso()} as ${KINDS[kind] || kind}.`,
|
|
50
|
-
...(context ? ['', typeof context === 'string' ? context.slice(0, 6000) : `\`\`\`json\n${JSON.stringify(context, null, 1).slice(0, 6000)}\n\`\`\``] : []),
|
|
51
|
-
].join('\n');
|
|
52
|
-
|
|
53
|
-
const serviceOf = (settings) => settings.token_service || DEFAULT_SERVICE;
|
|
54
|
-
const keyFor = async (settings, secrets) => (settings && settings.workspace ? secrets.get(serviceOf(settings), settings.workspace) : '');
|
|
29
|
+
const isSize = (value) => Number.isInteger(value) && value >= 1 && value <= 20000;
|
|
30
|
+
const BROWSERS = ['Chrome', 'Edge', 'Safari', 'Firefox', 'Opera', 'Brave', 'Arc', 'Chromium', 'Other'];
|
|
31
|
+
const exactly = (value, keys) => isObject(value) && Object.keys(value).sort().join() === keys;
|
|
55
32
|
|
|
56
|
-
|
|
57
|
-
|
|
33
|
+
const CLIENT_FIELDS = {
|
|
34
|
+
browser: (value) => (exactly(value, 'name,version') && BROWSERS.includes(value.name) && typeof value.version === 'string' && /^\d{1,6}$/.test(value.version) ? { name: value.name, version: value.version } : undefined),
|
|
35
|
+
display: (value) => {
|
|
36
|
+
const sane = exactly(value, 'height,scale,viewport,width') && isSize(value.width) && isSize(value.height) && typeof value.scale === 'number' && value.scale >= 0.5 && value.scale <= 8 && Array.isArray(value.viewport) && value.viewport.length === 2 && value.viewport.every(isSize);
|
|
37
|
+
return sane ? { width: value.width, height: value.height, scale: Math.round(value.scale * 100) / 100, viewport: [...value.viewport] } : undefined;
|
|
38
|
+
},
|
|
39
|
+
locale: (value) => (typeof value === 'string' && value.length <= 35 && /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/.test(value) ? value : undefined),
|
|
40
|
+
timezone: (value) => (typeof value === 'string' && value.length <= 64 && /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/.test(value) ? value : undefined),
|
|
41
|
+
theme: (value) => (value === 'light' || value === 'dark' ? value : undefined),
|
|
42
|
+
mode: (value) => (value === 'app' || value === 'tab' ? value : undefined),
|
|
43
|
+
};
|
|
58
44
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return null;
|
|
45
|
+
/** What the window reported about itself, keeping only the fields that are exactly the agreed shape; a bad or missing one is left out, never an error. RMA-291. */
|
|
46
|
+
export const clientOf = (client) => {
|
|
47
|
+
const clean = {};
|
|
48
|
+
for (const [key, check] of Object.entries(CLIENT_FIELDS)) {
|
|
49
|
+
const value = isObject(client) ? check(client[key]) : undefined;
|
|
50
|
+
if (value !== undefined) {
|
|
51
|
+
clean[key] = value;
|
|
52
|
+
}
|
|
68
53
|
}
|
|
54
|
+
return clean;
|
|
69
55
|
};
|
|
70
56
|
|
|
71
|
-
|
|
72
|
-
|
|
57
|
+
const environmentOf = ({ sources, install, client }) => ({ os: process.platform, osVersion: release(), arch: process.arch, node: process.version, install, sources: [...new Set(sources)].sort(), ...clientOf(client) });
|
|
58
|
+
|
|
59
|
+
/** What this machine filed: the last hundred reports in <data>/reports.json. states() reads their state from the relay, detail() one report with its answers; one relay read serves both, cached for a minute. RMA-120, RMA-123. */
|
|
60
|
+
export const openReports = (dataDir, { http, relayUrl }) => {
|
|
73
61
|
const file = join(dataDir, 'reports.json');
|
|
74
62
|
let items = readJson(file, []);
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return hit.value;
|
|
63
|
+
let cached = null;
|
|
64
|
+
const live = async () => {
|
|
65
|
+
if (cached && Date.now() - cached.at < 60000) {
|
|
66
|
+
return cached.value;
|
|
80
67
|
}
|
|
81
|
-
|
|
82
|
-
|
|
68
|
+
let value = {};
|
|
69
|
+
if (items.length) {
|
|
70
|
+
try {
|
|
71
|
+
const ids = items.slice(0, MAX_IDS).map((report) => report.id).join(',');
|
|
72
|
+
const answer = await http.fetchJson(`${relayUrl}?product=${PRODUCT}&ids=${encodeURIComponent(ids)}`, { useEtag: false });
|
|
73
|
+
value = Object.fromEntries((answer.issues || []).map((issue) => [issue.identifier, issue]));
|
|
74
|
+
} catch {
|
|
75
|
+
value = {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
cached = { at: Date.now(), value };
|
|
83
79
|
return value;
|
|
84
80
|
};
|
|
85
|
-
const
|
|
86
|
-
const data = items.length ? await askLinear(deps, STATES, { team: deps.settings.team, numbers: items.map((report) => report.number).filter(Number.isFinite) }) : null;
|
|
87
|
-
const live = Object.fromEntries((data ? data.issues.nodes : []).map((node) => [node.identifier, node]));
|
|
88
|
-
return items.map((report) => {
|
|
89
|
-
const issue = live[report.id];
|
|
90
|
-
return issue ? { ...report, title: issue.title, state: issue.state.name, outcome: outcomeOf(issue.state), changed_at: issue.updatedAt } : report;
|
|
91
|
-
});
|
|
92
|
-
});
|
|
81
|
+
const merged = (report, issue) => (issue ? { ...report, title: issue.title, state: STATE_NAMES[issue.outcome] || issue.outcome, outcome: issue.outcome, changed_at: issue.updatedAt } : report);
|
|
93
82
|
return {
|
|
83
|
+
relayUrl,
|
|
94
84
|
list: () => items.slice(),
|
|
95
|
-
add: (report) => { items = [report, ...items].slice(0, 100);
|
|
96
|
-
states
|
|
97
|
-
|
|
98
|
-
|
|
85
|
+
add: (report) => { items = [report, ...items].slice(0, 100); cached = null; writeJson(file, items); return report; },
|
|
86
|
+
states: async () => {
|
|
87
|
+
const issues = await live();
|
|
88
|
+
return items.map((report) => merged(report, issues[report.id]));
|
|
89
|
+
},
|
|
90
|
+
detail: async (id) => {
|
|
91
|
+
const report = items.find((item) => item.id === id);
|
|
99
92
|
if (!report) {
|
|
100
93
|
return null;
|
|
101
94
|
}
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
return { ...report, answers, unanswered: Boolean(report.outcome) && report.outcome !== 'open' && !answers.length };
|
|
95
|
+
const issue = (await live())[id];
|
|
96
|
+
const answers = (issue ? issue.answers : []).map((answer) => ({ at: answer.at, by: answer.author || '', text: answer.body })).sort((a, b) => a.at.localeCompare(b.at));
|
|
97
|
+
const shown = merged(report, issue);
|
|
98
|
+
return { ...shown, answers, unanswered: Boolean(shown.outcome) && shown.outcome !== 'open' && !answers.length };
|
|
107
99
|
},
|
|
108
100
|
};
|
|
109
101
|
};
|
|
110
102
|
|
|
111
|
-
/**
|
|
112
|
-
export const
|
|
113
|
-
|
|
114
|
-
export const createFeedback = async ({ settings, secrets, http, cache, reports }, { kind = 'improvement', title, body = '', context = null, from = '' }) => {
|
|
115
|
-
if (!isConfigured(settings)) {
|
|
116
|
-
throw new ApiError('NOT_CONFIGURED');
|
|
117
|
-
}
|
|
118
|
-
const cleanTitle = String(title || '').trim().slice(0, 200);
|
|
103
|
+
/** Files a report through the relay; returns {identifier, url, title}. */
|
|
104
|
+
export const createFeedback = async ({ http, relayUrl, reports, version, sources = [], install = 'source', client = null }, { kind = 'improvement', title, body = '' }) => {
|
|
105
|
+
const cleanTitle = String(title || '').trim().slice(0, 140);
|
|
119
106
|
if (!cleanTitle) {
|
|
120
107
|
throw new ApiError('INVALID_ARGUMENT', { detail: 'a title is required' }, [{ field: 'title', description: 'A report needs a title.' }]);
|
|
121
108
|
}
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
throw new ApiError('NOT_CONFIGURED', { detail: `no Linear key for feedback in the secret store (service ${serviceOf(settings)}, account ${settings.workspace})` });
|
|
125
|
-
}
|
|
126
|
-
if (!cache.lookup) {
|
|
127
|
-
const data = await call(http, key, LOOKUP, { team: settings.team, project: settings.project });
|
|
128
|
-
const team = data.teams.nodes[0];
|
|
129
|
-
const project = data.projects.nodes[0];
|
|
130
|
-
if (!team || !project) {
|
|
131
|
-
throw new ApiError('NOT_CONFIGURED', { detail: `Linear team ${settings.team} or project ${settings.project} not found` });
|
|
132
|
-
}
|
|
133
|
-
cache.lookup = { teamId: team.id, projectId: project.id, assigneeId: data.viewer?.id || '', labels: Object.fromEntries(team.labels.nodes.map((label) => [label.name.toLowerCase(), label.id])) };
|
|
134
|
-
}
|
|
135
|
-
const labelId = cache.lookup.labels[(KINDS[kind] || kind).toLowerCase()];
|
|
136
|
-
const assigneeId = settings.assign === false ? '' : cache.lookup.assigneeId;
|
|
137
|
-
const input = { teamId: cache.lookup.teamId, projectId: cache.lookup.projectId, title: cleanTitle, description: describe({ kind, body, context, from }), ...(labelId ? { labelIds: [labelId] } : {}), ...(assigneeId ? { assigneeId } : {}) };
|
|
138
|
-
const data = await call(http, key, CREATE, { input });
|
|
139
|
-
if (!data.issueCreate?.success) {
|
|
140
|
-
throw new ApiError('UPSTREAM_FAILED');
|
|
141
|
-
}
|
|
142
|
-
const issue = data.issueCreate.issue;
|
|
109
|
+
const text = String(body || '').trim().slice(0, 6000);
|
|
110
|
+
const issue = await call(http, relayUrl, { method: 'POST', body: { product: PRODUCT, kind, title: cleanTitle, body: text || cleanTitle, version: version || undefined, environment: environmentOf({ sources, install, client }) } });
|
|
143
111
|
if (reports) {
|
|
144
|
-
reports.add({ id: issue.identifier,
|
|
112
|
+
reports.add({ id: issue.identifier, url: issue.url, title: cleanTitle, kind, body: text, filed_at: nowIso() });
|
|
145
113
|
}
|
|
146
|
-
return issue;
|
|
114
|
+
return { identifier: issue.identifier, url: issue.url, title: cleanTitle };
|
|
147
115
|
};
|
package/lib/linear-api.mjs
CHANGED
package/lib/notify.mjs
CHANGED
|
@@ -26,11 +26,22 @@ 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';
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
const launch = (file, args, env) => {
|
|
33
|
+
if (!muted()) {
|
|
34
|
+
execFile(file, args, { env: { ...process.env, ...env } }, () => {});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Sends one desktop notification; `enabled: false` stays silent. */
|
|
39
|
+
export const notify = (title, body, { platform = process.platform, run = launch, enabled = true } = {}) => {
|
|
32
40
|
const build = COMMANDS[platform];
|
|
33
|
-
if (build) {
|
|
41
|
+
if (build && enabled) {
|
|
34
42
|
run(...build(clean(title, 120), clean(body, 240)));
|
|
35
43
|
}
|
|
36
44
|
};
|
|
45
|
+
|
|
46
|
+
/** A notify function that asks `isOn()` on every call. */
|
|
47
|
+
export const notifier = (isOn) => (title, body) => notify(title, body, { enabled: isOn() });
|
package/lib/registry.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** The live set of workspaces: built from the config, rebuilt on save with unchanged workspaces kept, events fanned in for the SSE stream. */
|
|
2
2
|
import { ApiError } from './errors.mjs';
|
|
3
3
|
|
|
4
|
-
export const createRegistry = ({ load, save, build, normalise = (raw) => raw }) => {
|
|
4
|
+
export const createRegistry = ({ load, save, saveNotifications, build, normalise = (raw) => raw }) => {
|
|
5
5
|
const listeners = new Set();
|
|
6
|
-
const registry = { workspaces: [], rawWorkspaces: [], problems: [], configFile: '', feedback: null, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); } };
|
|
6
|
+
const registry = { workspaces: [], rawWorkspaces: [], problems: [], configFile: '', feedback: null, notifications: { desktop: true }, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); } };
|
|
7
7
|
|
|
8
8
|
const apply = (config, prebuilt = new Map()) => {
|
|
9
9
|
const previous = new Map(registry.workspaces.map((workspace) => [workspace.id, workspace]));
|
|
@@ -29,6 +29,7 @@ export const createRegistry = ({ load, save, build, normalise = (raw) => raw })
|
|
|
29
29
|
}).filter(Boolean);
|
|
30
30
|
registry.rawWorkspaces = config.rawWorkspaces;
|
|
31
31
|
registry.feedback = config.feedback;
|
|
32
|
+
registry.notifications = config.notifications;
|
|
32
33
|
registry.problems = [...config.problems, ...dropped];
|
|
33
34
|
registry.configFile = config.configFile;
|
|
34
35
|
return created;
|
|
@@ -56,6 +57,10 @@ export const createRegistry = ({ load, save, build, normalise = (raw) => raw })
|
|
|
56
57
|
await save(rawWorkspaces);
|
|
57
58
|
return apply(load(), prebuilt);
|
|
58
59
|
};
|
|
60
|
+
registry.saveNotifications = async (notifications) => {
|
|
61
|
+
await saveNotifications(notifications);
|
|
62
|
+
registry.reload();
|
|
63
|
+
};
|
|
59
64
|
registry.reload();
|
|
60
65
|
return registry;
|
|
61
66
|
};
|
package/lib/rules.mjs
CHANGED
|
@@ -19,7 +19,7 @@ export const compileRules = (rules) => {
|
|
|
19
19
|
};
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
const youOr = (name, myName) => (myName && name === myName ? 'you' : name);
|
|
22
|
+
export const youOr = (name, myName) => (myName && name === myName ? 'you' : name);
|
|
23
23
|
|
|
24
24
|
const BLANK_ROW = {
|
|
25
25
|
group: 'you', whose: 'You', done: false, started: false, rank: 3, why: '', hold_url: '', hold_full: '', status_since: '', status_group: '',
|
|
@@ -176,9 +176,12 @@ export const turnRows = ({ tickets = [], prs = [], comments = [], asks = [], run
|
|
|
176
176
|
return rows;
|
|
177
177
|
};
|
|
178
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
|
+
|
|
179
182
|
const feedItem = (fields) => ({ who: '', text: '', body: '', key: '', title: '', ...fields });
|
|
180
183
|
|
|
181
|
-
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 }) => {
|
|
182
185
|
const cutoff = daysAgoIso(historyDays);
|
|
183
186
|
const recentComments = comments.filter((comment) => comment.at >= cutoff);
|
|
184
187
|
const rows = [
|
|
@@ -192,7 +195,8 @@ export const feedRows = ({ activity = [], events = [], tickets = [], comments =
|
|
|
192
195
|
text: oneline(comment.body), body: comment.body, url: comment.url,
|
|
193
196
|
});
|
|
194
197
|
}),
|
|
195
|
-
...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 })),
|
|
196
200
|
];
|
|
197
201
|
const timesByKey = groupBy([...events, ...recentComments], (item) => item.key);
|
|
198
202
|
for (const ticket of tickets) {
|
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/text.mjs
CHANGED
|
@@ -16,6 +16,9 @@ export const oneline = (text, limit = 140) => {
|
|
|
16
16
|
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
/** True for a plain object: not null, not an array. */
|
|
20
|
+
export const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
|
|
19
22
|
export const escapeRegex = (text) => String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
23
|
|
|
21
24
|
export const wordsRegex = (words, flags = 'i') => new RegExp(words.length ? words.map(escapeRegex).join('|') : '(?!)', flags);
|
package/lib/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
|
};
|
|
@@ -303,7 +366,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
|
|
|
303
366
|
};
|
|
304
367
|
|
|
305
368
|
return {
|
|
306
|
-
id: definition.id, name: definition.name, rulesTable: rules.table, store, poll, snapshot, summary, describe, checkHealth, handOff, handOffBody, handOffText, handOffSnapshot, updateMarks, addTodo, changeTodo, saveTodos,
|
|
369
|
+
id: definition.id, name: definition.name, sourceTypes: [...new Set(definition.connectors.map((entry) => entry.type))], rulesTable: rules.table, store, poll, snapshot, summary, describe, checkHealth, handOff, handOffBody, handOffText, handOffSnapshot, updateMarks, addTodo, changeTodo, saveTodos,
|
|
307
370
|
subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
|
|
308
371
|
};
|
|
309
372
|
};
|
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.8",
|
|
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
|
}
|