whose-turn 0.0.0-stage → 1.0.0-alpha.2
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 +18 -0
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/config.example.json +144 -0
- package/connectors/demo.mjs +146 -0
- package/connectors/github.mjs +316 -0
- package/connectors/index.mjs +38 -0
- package/connectors/jira.mjs +155 -0
- package/connectors/linear.mjs +113 -0
- package/dist/assets/_plugin-vue_export-helper-BKveAJb2.css +1 -0
- package/dist/assets/_plugin-vue_export-helper-DHr5RHTx.js +18 -0
- package/dist/assets/bem-puYdOas_.js +1 -0
- package/dist/assets/index-DR8StBp8.js +3 -0
- package/dist/assets/index-d0wlPvUz.css +1 -0
- package/dist/assets/report-Xynr6yGL.js +1 -0
- package/dist/assets/report-xVOo7qip.css +1 -0
- package/dist/assets/scheme-DJ3WNw60.js +1 -0
- package/dist/assets/sheet-CBYGM-FG.js +1 -0
- package/dist/assets/sheet-DYpaJaXO.css +1 -0
- package/dist/index.html +19 -0
- package/dist/report.html +16 -0
- package/dist/sheet.html +16 -0
- package/dist/vendor/popper.min.js +6 -0
- package/dist/vendor/tippy-bundle.umd.min.js +2 -0
- package/install.sh +69 -0
- package/lib/api.mjs +399 -0
- package/lib/backoff.mjs +2 -0
- package/lib/compose.mjs +67 -0
- package/lib/config.mjs +290 -0
- package/lib/default-table.mjs +58 -0
- package/lib/errors.mjs +53 -0
- package/lib/facts.mjs +128 -0
- package/lib/feedback.mjs +145 -0
- package/lib/handoff.mjs +127 -0
- package/lib/health.mjs +35 -0
- package/lib/http-client.mjs +44 -0
- package/lib/inbox.mjs +60 -0
- package/lib/linear-api.mjs +12 -0
- package/lib/notify.mjs +36 -0
- package/lib/palette.mjs +213 -0
- package/lib/platform/credential.ps1 +84 -0
- package/lib/registry.mjs +61 -0
- package/lib/rules.mjs +241 -0
- package/lib/secrets.mjs +69 -0
- package/lib/static.mjs +36 -0
- package/lib/store.mjs +61 -0
- package/lib/table.mjs +148 -0
- package/lib/text.mjs +46 -0
- package/lib/token-cache.mjs +12 -0
- package/lib/window.mjs +33 -0
- package/lib/workspace.mjs +294 -0
- package/open.sh +40 -0
- package/package.json +67 -4
- package/server.mjs +80 -0
package/lib/static.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Serves the built window from dist/ (ADR 0001): hashed assets cached by the browser for good, the pages never cached, files read once per build. A checkout without a build gets a plain notice. */
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, extname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const TYPES = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.map': 'application/json' };
|
|
6
|
+
const PAGES = { '/': 'index.html', '/report': 'report.html', '/sheet': 'sheet.html' };
|
|
7
|
+
const UNBUILT = Buffer.from('<!doctype html><meta charset="utf-8"><title>Whose Turn</title><p style="font: 15px system-ui; margin: 40px">The window is not built. Run <code>npm run build</code> in the whose-turn directory, then reload. The API on this port works without it.</p>');
|
|
8
|
+
|
|
9
|
+
export const createStatic = (distDir) => {
|
|
10
|
+
const cache = new Map();
|
|
11
|
+
const read = async (relative) => {
|
|
12
|
+
const file = join(distDir, relative);
|
|
13
|
+
const { mtimeMs } = await stat(file);
|
|
14
|
+
const hit = cache.get(relative);
|
|
15
|
+
if (hit && hit.mtimeMs === mtimeMs) {
|
|
16
|
+
return hit.body;
|
|
17
|
+
}
|
|
18
|
+
const body = await readFile(file);
|
|
19
|
+
cache.set(relative, { mtimeMs, body });
|
|
20
|
+
return body;
|
|
21
|
+
};
|
|
22
|
+
/** The file for a path, with its type and cache header, or null when the path is not a window file; the unbuilt notice stands in for a missing page. */
|
|
23
|
+
return async (pathname) => {
|
|
24
|
+
const relative = PAGES[pathname] || pathname.replace(/^\/+/, '');
|
|
25
|
+
const type = TYPES[extname(relative)];
|
|
26
|
+
if (!type || relative.split('/').includes('..')) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const page = Boolean(PAGES[pathname]);
|
|
30
|
+
try {
|
|
31
|
+
return { body: await read(relative), type, cacheControl: page ? 'no-store' : relative.startsWith('assets/') ? 'public, max-age=31536000, immutable' : 'public, max-age=3600' };
|
|
32
|
+
} catch {
|
|
33
|
+
return page ? { body: UNBUILT, type: 'text/html', cacheControl: 'no-store' } : null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
};
|
package/lib/store.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
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). */
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
|
+
import { join, resolve, sep } from 'node:path';
|
|
4
|
+
import { DAY } from './text.mjs';
|
|
5
|
+
|
|
6
|
+
export const readJson = (path, fallback) => {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
9
|
+
} catch {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const writeJson = (path, value) => {
|
|
15
|
+
writeFileSync(`${path}.tmp`, JSON.stringify(value));
|
|
16
|
+
renameSync(`${path}.tmp`, path);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const CACHE_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
const encodeCache = (cache, keys) => Object.fromEntries(keys.filter((key) => cache[key] instanceof Map).map((key) => [key, [...cache[key]]]));
|
|
22
|
+
|
|
23
|
+
const decodeCache = (saved) => Object.fromEntries(Object.entries(saved || {}).filter(([, entries]) => Array.isArray(entries)).map(([key, entries]) => [key, new Map(entries)]));
|
|
24
|
+
|
|
25
|
+
export const openStore = (dataDir, workspaceId) => {
|
|
26
|
+
const root = resolve(dataDir, 'workspaces');
|
|
27
|
+
const dir = resolve(root, workspaceId);
|
|
28
|
+
if (!dir.startsWith(root + sep)) {
|
|
29
|
+
throw new Error(`Workspace id "${workspaceId}" resolves outside the data directory.`);
|
|
30
|
+
}
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
const files = { state: join(dir, 'state.json'), marks: join(dir, 'marks.json'), todos: join(dir, 'todos.json'), cache: join(dir, 'cache.json') };
|
|
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, {}) };
|
|
35
|
+
if (!marks.base) {
|
|
36
|
+
marks.base = Date.now() - 3 * DAY;
|
|
37
|
+
}
|
|
38
|
+
const todos = readJson(files.todos, []);
|
|
39
|
+
const saved = readJson(files.cache, {});
|
|
40
|
+
const savedCaches = saved.version === CACHE_VERSION && saved.entries ? saved.entries : {};
|
|
41
|
+
let savedText = '';
|
|
42
|
+
return {
|
|
43
|
+
state,
|
|
44
|
+
marks,
|
|
45
|
+
todos,
|
|
46
|
+
saveState: () => writeJson(files.state, state),
|
|
47
|
+
saveMarks: () => writeJson(files.marks, marks),
|
|
48
|
+
saveTodos: () => writeJson(files.todos, todos),
|
|
49
|
+
loadCache: (entryId) => decodeCache(savedCaches[entryId]),
|
|
50
|
+
/** keysByEntry: entry id → the cache keys that are data; anything else (the token) never reaches disk. */
|
|
51
|
+
saveCaches: (caches, keysByEntry) => {
|
|
52
|
+
const entries = Object.fromEntries(Object.entries(keysByEntry).map(([entryId, keys]) => [entryId, encodeCache(caches[entryId] || {}, keys)]));
|
|
53
|
+
const file = { version: CACHE_VERSION, entries };
|
|
54
|
+
const text = JSON.stringify(file);
|
|
55
|
+
if (text !== savedText) {
|
|
56
|
+
savedText = text;
|
|
57
|
+
writeJson(files.cache, file);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
};
|
package/lib/table.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** The rules table: validating rows, expanding the "default" marker, finding the first row whose conditions hold, filling {fact} placeholders. See docs/mechanisms/rules-table.md. */
|
|
2
|
+
import { FACTS } from './facts.mjs';
|
|
3
|
+
|
|
4
|
+
/** `then.whose` values: the label the row shows, and the feed group it forces (none keeps the seed row's group). */
|
|
5
|
+
export const WHOSE = {
|
|
6
|
+
you: { label: 'You', group: 'you' },
|
|
7
|
+
reviewer: { label: 'Reviewer', group: null },
|
|
8
|
+
qa: { label: 'QA', group: null },
|
|
9
|
+
them: { label: null, group: null },
|
|
10
|
+
hidden: { label: null, group: 'gone' },
|
|
11
|
+
};
|
|
12
|
+
const THEN_KEYS = ['whose', 'next', 'why', 'rank', 'started', 'hold'];
|
|
13
|
+
const DEFAULT_MARKER = 'default';
|
|
14
|
+
|
|
15
|
+
const present = (value) => value !== null && value !== undefined;
|
|
16
|
+
const NUMBER_OPERATORS = {
|
|
17
|
+
gt: (value, operand) => present(value) && value > operand,
|
|
18
|
+
gte: (value, operand) => present(value) && value >= operand,
|
|
19
|
+
lt: (value, operand) => present(value) && value < operand,
|
|
20
|
+
lte: (value, operand) => present(value) && value <= operand,
|
|
21
|
+
};
|
|
22
|
+
const OPERATORS = {
|
|
23
|
+
...NUMBER_OPERATORS,
|
|
24
|
+
prefix: (value, operand) => present(value) && String(value).startsWith(operand),
|
|
25
|
+
present: (value, operand) => present(value) === operand,
|
|
26
|
+
};
|
|
27
|
+
const PLACEHOLDER = /\{([\w.]+)\}/g;
|
|
28
|
+
|
|
29
|
+
const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
|
|
31
|
+
const conditionHolds = (value, condition) => {
|
|
32
|
+
if (Array.isArray(condition)) {
|
|
33
|
+
return condition.includes(value);
|
|
34
|
+
}
|
|
35
|
+
if (isPlainObject(condition)) {
|
|
36
|
+
return Object.entries(condition).every(([operator, operand]) => OPERATORS[operator](value, operand));
|
|
37
|
+
}
|
|
38
|
+
return value === condition;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const firstMatch = (table, facts) => table.find((row) => Object.entries(row.when).every(([fact, condition]) => conditionHolds(facts[fact], condition)));
|
|
42
|
+
|
|
43
|
+
/** Replaces each {fact} with its value; a missing fact becomes empty text, an unknown name is left as written. */
|
|
44
|
+
export const fillText = (text, facts) => String(text || '').replace(PLACEHOLDER, (placeholder, name) => (name in FACTS ? String(facts[name] ?? '') : placeholder));
|
|
45
|
+
|
|
46
|
+
export const expandTable = (table, defaults) => (Array.isArray(table) ? table.flatMap((row) => (row === DEFAULT_MARKER ? defaults : [row])) : defaults);
|
|
47
|
+
|
|
48
|
+
const closedValues = (name, groups) => {
|
|
49
|
+
const values = FACTS[name].values;
|
|
50
|
+
return values === 'status groups' ? groups : values;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const valueProblem = (name, value, groups) => {
|
|
54
|
+
const fact = FACTS[name];
|
|
55
|
+
if (typeof value !== fact.type) {
|
|
56
|
+
return `"${name}" is a ${fact.type}, got ${JSON.stringify(value)}`;
|
|
57
|
+
}
|
|
58
|
+
const values = closedValues(name, groups);
|
|
59
|
+
return values && !values.includes(value) ? `"${name}" is one of ${values.join(', ')}, got ${JSON.stringify(value)}` : null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const operatorProblem = (name, operator, operand) => {
|
|
63
|
+
const type = FACTS[name].type;
|
|
64
|
+
if (operator in NUMBER_OPERATORS) {
|
|
65
|
+
return type === 'number' && typeof operand === 'number' ? null : `"${operator}" needs a number fact and a number, "${name}" is a ${type}`;
|
|
66
|
+
}
|
|
67
|
+
if (operator === 'prefix') {
|
|
68
|
+
return type === 'string' && typeof operand === 'string' ? null : `"prefix" needs a text fact and a text, "${name}" is a ${type}`;
|
|
69
|
+
}
|
|
70
|
+
if (operator === 'present') {
|
|
71
|
+
return typeof operand === 'boolean' ? null : `"present" needs true or false, got ${JSON.stringify(operand)}`;
|
|
72
|
+
}
|
|
73
|
+
return `unknown operator "${operator}" on "${name}"; use gt, gte, lt, lte, prefix or present`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const conditionProblems = (name, condition, groups) => {
|
|
77
|
+
if (!(name in FACTS)) {
|
|
78
|
+
return [`unknown fact "${name}"; GET /api/config lists the facts`];
|
|
79
|
+
}
|
|
80
|
+
if (Array.isArray(condition)) {
|
|
81
|
+
return condition.length ? condition.map((value) => valueProblem(name, value, groups)).filter(Boolean) : [`the list for "${name}" is empty`];
|
|
82
|
+
}
|
|
83
|
+
if (isPlainObject(condition)) {
|
|
84
|
+
const entries = Object.entries(condition);
|
|
85
|
+
return entries.length ? entries.map(([operator, operand]) => operatorProblem(name, operator, operand)).filter(Boolean) : [`the condition on "${name}" is an empty object`];
|
|
86
|
+
}
|
|
87
|
+
const problem = valueProblem(name, condition, groups);
|
|
88
|
+
return problem ? [problem] : [];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const placeholderProblems = (text) => [...String(text).matchAll(PLACEHOLDER)].filter(([, name]) => !(name in FACTS)).map(([, name]) => `unknown fact "{${name}}" in the text`);
|
|
92
|
+
|
|
93
|
+
const thenProblems = (then) => {
|
|
94
|
+
if (!isPlainObject(then)) {
|
|
95
|
+
return ['"then" must be an object { whose, next, why?, rank?, started?, hold? }'];
|
|
96
|
+
}
|
|
97
|
+
const problems = Object.keys(then).filter((key) => !THEN_KEYS.includes(key)).map((key) => `unknown key "then.${key}"`);
|
|
98
|
+
if (!(then.whose in WHOSE)) {
|
|
99
|
+
problems.push(`"then.whose" is one of ${Object.keys(WHOSE).join(', ')}, got ${JSON.stringify(then.whose)}`);
|
|
100
|
+
}
|
|
101
|
+
for (const key of ['started', 'hold']) {
|
|
102
|
+
if (then[key] !== undefined && typeof then[key] !== 'boolean') {
|
|
103
|
+
problems.push(`"then.${key}" must be true or false`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (then.rank !== undefined && !(Number.isInteger(then.rank) && then.rank >= 0)) {
|
|
107
|
+
problems.push('"then.rank" must be a whole number, 0 or more');
|
|
108
|
+
}
|
|
109
|
+
for (const key of ['next', 'why']) {
|
|
110
|
+
if (then[key] !== undefined && typeof then[key] !== 'string') {
|
|
111
|
+
problems.push(`"then.${key}" must be text`);
|
|
112
|
+
} else if (then[key]) {
|
|
113
|
+
problems.push(...placeholderProblems(then[key]));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (then.whose !== 'hidden' && !then.next) {
|
|
117
|
+
problems.push('"then.next" is required unless whose is hidden');
|
|
118
|
+
}
|
|
119
|
+
return problems;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const rowProblems = (row, groups) => {
|
|
123
|
+
if (!isPlainObject(row)) {
|
|
124
|
+
return [`a row is { when, then } or the text "${DEFAULT_MARKER}"`];
|
|
125
|
+
}
|
|
126
|
+
const problems = Object.keys(row).filter((key) => !['when', 'then'].includes(key)).map((key) => `unknown key "${key}"`);
|
|
127
|
+
if (!isPlainObject(row.when)) {
|
|
128
|
+
problems.push('"when" must be an object of { fact: condition }');
|
|
129
|
+
} else {
|
|
130
|
+
problems.push(...Object.entries(row.when).flatMap(([name, condition]) => conditionProblems(name, condition, groups)));
|
|
131
|
+
}
|
|
132
|
+
return [...problems, ...thenProblems(row.then)];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** What is wrong with a workspace table, one line per problem, each naming its row (1-based); empty when the table is fine or absent. */
|
|
136
|
+
export const tableProblems = (table, groups) => {
|
|
137
|
+
if (table === undefined) {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
if (!Array.isArray(table)) {
|
|
141
|
+
return ['rules.table must be a list of rows'];
|
|
142
|
+
}
|
|
143
|
+
const problems = table.flatMap((row, index) => (row === DEFAULT_MARKER ? [] : rowProblems(row, groups).map((problem) => `rules.table row ${index + 1}: ${problem}`)));
|
|
144
|
+
if (table.filter((row) => row === DEFAULT_MARKER).length > 1) {
|
|
145
|
+
problems.push(`rules.table: "${DEFAULT_MARKER}" may appear once`);
|
|
146
|
+
}
|
|
147
|
+
return problems;
|
|
148
|
+
};
|
package/lib/text.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Small text and time helpers shared by connectors, rules and the store. */
|
|
2
|
+
|
|
3
|
+
export const DAY = 86400000;
|
|
4
|
+
|
|
5
|
+
export const toIsoUtc = (value) => {
|
|
6
|
+
const time = new Date(value || '').getTime();
|
|
7
|
+
return Number.isFinite(time) ? new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z') : '';
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const nowIso = () => toIsoUtc(Date.now());
|
|
11
|
+
|
|
12
|
+
export const daysAgoIso = (days) => toIsoUtc(Date.now() - days * DAY);
|
|
13
|
+
|
|
14
|
+
export const oneline = (text, limit = 140) => {
|
|
15
|
+
const flat = String(text || '').replace(/\s+/g, ' ').trim();
|
|
16
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const escapeRegex = (text) => String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
|
+
|
|
21
|
+
export const wordsRegex = (words, flags = 'i') => new RegExp(words.length ? words.map(escapeRegex).join('|') : '(?!)', flags);
|
|
22
|
+
|
|
23
|
+
export const makeKeyFinder = (pattern) => {
|
|
24
|
+
const regex = new RegExp(pattern);
|
|
25
|
+
return (text) => {
|
|
26
|
+
const match = regex.exec(text || '');
|
|
27
|
+
return match ? match[0] : '';
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const newestFirst = (list) => list.slice().sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
32
|
+
|
|
33
|
+
export const unionCapped = (list, additions, cap = 5000) => [...new Set([...list, ...additions])].slice(-cap);
|
|
34
|
+
|
|
35
|
+
export const mapLimit = async (items, limit, worker) => {
|
|
36
|
+
const results = new Array(items.length);
|
|
37
|
+
let next = 0;
|
|
38
|
+
const lane = async () => {
|
|
39
|
+
while (next < items.length) {
|
|
40
|
+
const index = next++;
|
|
41
|
+
results[index] = await worker(items[index], index);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
|
|
45
|
+
return results;
|
|
46
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The one token cache of the connectors: a token read once and kept for ten minutes. */
|
|
2
|
+
|
|
3
|
+
const TOKEN_TTL = 10 * 60 * 1000;
|
|
4
|
+
|
|
5
|
+
/** The token `read` resolves to, kept in `cache.token` for ten minutes; an empty token is read again next time. */
|
|
6
|
+
export const cachedToken = async (cache, read) => {
|
|
7
|
+
if (!cache.token || Date.now() - cache.tokenAt > TOKEN_TTL) {
|
|
8
|
+
cache.token = await read();
|
|
9
|
+
cache.tokenAt = Date.now();
|
|
10
|
+
}
|
|
11
|
+
return cache.token;
|
|
12
|
+
};
|
package/lib/window.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The app window's remembered size in <data>/window.json, and the launcher behind the window's "Open as app" button. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ApiError } from './errors.mjs';
|
|
5
|
+
import { readJson, writeJson } from './store.mjs';
|
|
6
|
+
|
|
7
|
+
const MODES = ['app', 'browser'];
|
|
8
|
+
const inRange = (value) => Number.isInteger(value) && value >= 300 && value <= 10000;
|
|
9
|
+
|
|
10
|
+
export const readSize = (dataDir) => {
|
|
11
|
+
const { width, height } = readJson(join(dataDir, 'window.json'), {});
|
|
12
|
+
return inRange(width) && inRange(height) ? { width, height } : {};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const saveSize = (dataDir, { width, height }) => {
|
|
16
|
+
if (!inRange(width) || !inRange(height)) {
|
|
17
|
+
throw new ApiError('INVALID_ARGUMENT', { detail: 'width and height are whole pixels between 300 and 10000' });
|
|
18
|
+
}
|
|
19
|
+
writeJson(join(dataDir, 'window.json'), { width, height });
|
|
20
|
+
return { width, height };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const checkMode = (mode) => {
|
|
24
|
+
if (!MODES.includes(mode)) {
|
|
25
|
+
throw new ApiError('INVALID_ARGUMENT', { detail: `mode is one of ${MODES.join(', ')}` });
|
|
26
|
+
}
|
|
27
|
+
return mode;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const spawnOpener = (appDir) => ({ mode, port }) => {
|
|
31
|
+
const args = mode === 'browser' ? ['--browser'] : [];
|
|
32
|
+
spawn(join(appDir, 'open.sh'), args, { env: { ...process.env, WHOSE_TURN_PORT: String(port) }, detached: true, stdio: 'ignore' }).unref();
|
|
33
|
+
};
|
|
@@ -0,0 +1,294 @@
|
|
|
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
|
+
import { openStore } from './store.mjs';
|
|
3
|
+
import { compileRules, turnRows, feedRows, myMoves, isHandled, withHandled, turnCounts, ticketEvents } from './rules.mjs';
|
|
4
|
+
import { makeKeyFinder, nowIso, daysAgoIso, unionCapped, DAY } from './text.mjs';
|
|
5
|
+
import { buildHandOff, snapshotOf, renderHandOff, frameHandOff } from './handoff.mjs';
|
|
6
|
+
import { notify as desktopNotify } from './notify.mjs';
|
|
7
|
+
import { DEFAULT_LIMITS, nextFails, assess, transition } from './health.mjs';
|
|
8
|
+
import { paletteFrom, derivedSeed } from './palette.mjs';
|
|
9
|
+
import { CONNECTORS, connectorSummaries } from '../connectors/index.mjs';
|
|
10
|
+
import { RateLimited } from './http-client.mjs';
|
|
11
|
+
const KINDS = ['tickets', 'comments', 'prs', 'activity', 'asks', 'runs'];
|
|
12
|
+
|
|
13
|
+
const stamp = (result, sourceId) => {
|
|
14
|
+
const stamped = { ...result };
|
|
15
|
+
for (const kind of KINDS) {
|
|
16
|
+
stamped[kind] = (result[kind] || []).map((item) => ({ ...item, source: sourceId }));
|
|
17
|
+
}
|
|
18
|
+
return stamped;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const createWorkspace = (definition, { dataDir, historyDays, http, secrets, limits = () => DEFAULT_LIMITS, notify = desktopNotify }) => {
|
|
22
|
+
const store = openStore(dataDir, definition.id);
|
|
23
|
+
const rules = compileRules(definition.rules);
|
|
24
|
+
const keyOf = makeKeyFinder(definition.key_pattern);
|
|
25
|
+
const seed = definition.theme?.seed || definition.derived_seed || derivedSeed(definition.id);
|
|
26
|
+
const theme = paletteFrom(seed, definition.theme?.accent);
|
|
27
|
+
const caches = {};
|
|
28
|
+
const listeners = new Set();
|
|
29
|
+
let digest = '';
|
|
30
|
+
let derived = null;
|
|
31
|
+
let freshAt = '';
|
|
32
|
+
const startedAt = nowIso();
|
|
33
|
+
|
|
34
|
+
const sources = () => Object.values(store.state.sources);
|
|
35
|
+
const merged = () => Object.fromEntries(KINDS.map((kind) => [kind, sources().flatMap((source) => source[kind] || [])]));
|
|
36
|
+
const me = () => Object.fromEntries(Object.entries(store.state.sources).map(([id, source]) => [id, source.me || '']));
|
|
37
|
+
const ticketUrl = (key) => {
|
|
38
|
+
const base = sources().map((source) => source.ticket_base).find(Boolean);
|
|
39
|
+
return base ? `${base}${key}` : '';
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const rows = () => {
|
|
43
|
+
const day = nowIso().slice(0, 10);
|
|
44
|
+
if (!derived || derived.version !== store.state.version || derived.day !== day) {
|
|
45
|
+
const sources = merged();
|
|
46
|
+
const state = { ...sources, events: store.state.events };
|
|
47
|
+
const context = { me: me(), rules, historyDays, ticketUrl };
|
|
48
|
+
derived = { version: store.state.version, day, turn: turnRows(sources, context), feed: feedRows(state, context) };
|
|
49
|
+
}
|
|
50
|
+
return derived;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const emit = () => {
|
|
54
|
+
const message = { ws: definition.id, version: store.state.version, polled_at: store.state.polled_at, open_moves: myMoves(rows().turn, store.marks).length };
|
|
55
|
+
for (const listener of listeners) {
|
|
56
|
+
listener(message);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const notifyNewMoves = () => {
|
|
61
|
+
const moves = myMoves(rows().turn, store.marks);
|
|
62
|
+
const known = new Set(store.marks.notified);
|
|
63
|
+
const fresh = moves.filter((move) => !known.has(move.row_id));
|
|
64
|
+
if (!fresh.length) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (store.marks.notified_seeded) {
|
|
68
|
+
const title = fresh.length === 1 ? `${definition.name}: your move on ${fresh[0].key}` : `${definition.name}: ${fresh.length} new items are your move`;
|
|
69
|
+
notify(title, fresh.length === 1 ? fresh[0].next : fresh.slice(0, 3).map((move) => `${move.key} — ${move.next}`).join('; '));
|
|
70
|
+
}
|
|
71
|
+
store.marks.notified = unionCapped(store.marks.notified, fresh.map((move) => move.row_id));
|
|
72
|
+
store.saveMarks();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const health = () => assess({ fails: store.state.fails, freshAt: freshAt || startedAt, now: Date.now(), limits: limits(), sourceCount: definition.connectors.length });
|
|
76
|
+
|
|
77
|
+
let shownStreaks = '';
|
|
78
|
+
|
|
79
|
+
const checkHealth = () => {
|
|
80
|
+
const { alerts } = health();
|
|
81
|
+
const { raised, recovered, alerted } = transition(store.marks.health_alerted, alerts);
|
|
82
|
+
const streaks = alerts.filter((alert) => alert.id.startsWith('failing:')).map((alert) => alert.text).join('\n');
|
|
83
|
+
if (!raised.length && !recovered.length) {
|
|
84
|
+
if (streaks !== shownStreaks) {
|
|
85
|
+
shownStreaks = streaks;
|
|
86
|
+
store.state.version += 1;
|
|
87
|
+
emit();
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
shownStreaks = streaks;
|
|
92
|
+
if (raised.length) {
|
|
93
|
+
notify(`${definition.name}: monitor needs attention`, raised.map((alert) => alert.text).join('; '));
|
|
94
|
+
} else if (!alerted.length) {
|
|
95
|
+
notify(`${definition.name}: monitor recovered`, 'Data is flowing again');
|
|
96
|
+
}
|
|
97
|
+
store.marks.health_alerted = alerted;
|
|
98
|
+
store.saveMarks();
|
|
99
|
+
store.state.version += 1;
|
|
100
|
+
emit();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const pollSource = async (entry) => {
|
|
104
|
+
const connector = CONNECTORS[entry.type];
|
|
105
|
+
if (!connector) {
|
|
106
|
+
return { errors: { [entry.id]: `Unknown connector type "${entry.type}"` } };
|
|
107
|
+
}
|
|
108
|
+
caches[entry.id] = caches[entry.id] || store.loadCache(entry.id);
|
|
109
|
+
try {
|
|
110
|
+
const result = await connector.poll({ settings: entry, http, secrets, cache: caches[entry.id], sinceIso: daysAgoIso(historyDays), historyDays, keyOf });
|
|
111
|
+
const errors = Object.fromEntries(Object.entries(result.errors || {}).map(([part, message]) => [`${entry.id}.${part}`, message]));
|
|
112
|
+
return { data: stamp(result, entry.id), errors };
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const rateLimited = error instanceof RateLimited;
|
|
115
|
+
return { errors: { [entry.id]: `${rateLimited ? 'rate limited' : error.name}: ${error.message}` }, rateLimited };
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const saveCaches = () => {
|
|
120
|
+
const keysByEntry = Object.fromEntries(definition.connectors.map((entry) => [entry.id, CONNECTORS[entry.type]?.persistedCache || []]));
|
|
121
|
+
store.saveCaches(caches, keysByEntry);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const poll = async () => {
|
|
125
|
+
expireHandled();
|
|
126
|
+
const outcomes = await Promise.all(definition.connectors.map(pollSource));
|
|
127
|
+
const state = store.state;
|
|
128
|
+
const errors = {};
|
|
129
|
+
const ticketsBefore = merged().tickets;
|
|
130
|
+
definition.connectors.forEach((entry, index) => {
|
|
131
|
+
const outcome = outcomes[index];
|
|
132
|
+
Object.assign(errors, outcome.errors);
|
|
133
|
+
if (outcome.data) {
|
|
134
|
+
state.sources[entry.id] = outcome.data;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
for (const id of Object.keys(state.sources)) {
|
|
138
|
+
if (!definition.connectors.some((entry) => entry.id === id)) {
|
|
139
|
+
delete state.sources[id];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (ticketsBefore.length) {
|
|
143
|
+
state.events = [...ticketEvents(ticketsBefore, merged().tickets), ...state.events].slice(0, 500);
|
|
144
|
+
}
|
|
145
|
+
state.errors = errors;
|
|
146
|
+
state.polled_at = nowIso();
|
|
147
|
+
if (outcomes.some((outcome) => outcome.data)) {
|
|
148
|
+
freshAt = state.polled_at;
|
|
149
|
+
}
|
|
150
|
+
const fails = nextFails(state.fails, definition.connectors.map((entry, index) => ({ id: entry.id, failed: Object.keys(outcomes[index].errors).length > 0 })));
|
|
151
|
+
const failsChanged = JSON.stringify(fails) !== JSON.stringify(state.fails);
|
|
152
|
+
state.fails = fails;
|
|
153
|
+
const next = JSON.stringify([state.sources, state.errors]);
|
|
154
|
+
if (next !== digest) {
|
|
155
|
+
digest = next;
|
|
156
|
+
state.version += 1;
|
|
157
|
+
store.saveState();
|
|
158
|
+
if (outcomes.some((outcome) => outcome.data)) {
|
|
159
|
+
notifyNewMoves();
|
|
160
|
+
store.marks.notified_seeded = true;
|
|
161
|
+
store.saveMarks();
|
|
162
|
+
}
|
|
163
|
+
} else if (failsChanged) {
|
|
164
|
+
store.saveState();
|
|
165
|
+
}
|
|
166
|
+
if (outcomes.some((outcome) => outcome.data)) {
|
|
167
|
+
saveCaches();
|
|
168
|
+
}
|
|
169
|
+
checkHealth();
|
|
170
|
+
emit();
|
|
171
|
+
return { errors, rateLimited: outcomes.some((outcome) => outcome.rateLimited) };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const quickLinks = () => {
|
|
175
|
+
const derived = definition.connectors.flatMap((entry) => (CONNECTORS[entry.type]?.links ? CONNECTORS[entry.type].links(entry) : []));
|
|
176
|
+
const own = definition.links || [];
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
return [...own, ...derived].filter((link) => !seen.has(link.url) && seen.add(link.url));
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const expireHandled = () => {
|
|
182
|
+
const limit = Date.now() - 7 * DAY;
|
|
183
|
+
let changed = false;
|
|
184
|
+
for (const [rowId, at] of Object.entries(store.marks.handled)) {
|
|
185
|
+
if (new Date(at).getTime() < limit) {
|
|
186
|
+
delete store.marks.handled[rowId];
|
|
187
|
+
changed = true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (changed) {
|
|
191
|
+
store.saveMarks();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const identity = () => ({
|
|
196
|
+
id: definition.id, name: definition.name, key_pattern: definition.key_pattern, links: quickLinks(),
|
|
197
|
+
connectors: connectorSummaries(definition.connectors),
|
|
198
|
+
open_moves: myMoves(rows().turn, store.marks).length,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const describe = () => ({ ...identity(), theme, theme_seed: seed });
|
|
202
|
+
|
|
203
|
+
const todosWithLinks = () => store.todos.map((todo) => ({ ...todo, ticket_url: todo.key ? ticketUrl(todo.key) : '' }));
|
|
204
|
+
|
|
205
|
+
/** The part of the snapshot a marks write changes: the marks, the rows with their handled flags and the counts. */
|
|
206
|
+
const marked = () => ({ marks: store.marks, turn: withHandled(rows().turn, store.marks), counts: turnCounts(rows().turn, store.marks) });
|
|
207
|
+
|
|
208
|
+
const snapshot = (full) => ({
|
|
209
|
+
version: store.state.version, polled_at: store.state.polled_at, errors: store.state.errors, health: health(), me: me(),
|
|
210
|
+
...marked(), feed: rows().feed, todos: todosWithLinks(), workspace: describe(),
|
|
211
|
+
...(full ? { ...merged(), events: store.state.events } : {}),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const handOffSource = () => ({
|
|
215
|
+
data: merged(), turn: rows().turn, feed: rows().feed, workspace: describe(), rules: definition.rules,
|
|
216
|
+
moves: myMoves(rows().turn, store.marks), todos: todosWithLinks().filter((todo) => !todo.done), errors: store.state.errors,
|
|
217
|
+
handled: (row) => isHandled(row, store.marks),
|
|
218
|
+
});
|
|
219
|
+
const handOff = (kind, ref) => buildHandOff(kind, ref, handOffSource());
|
|
220
|
+
const handOffBody = (kind, ref) => {
|
|
221
|
+
const context = handOff(kind, ref);
|
|
222
|
+
return context ? renderHandOff(kind, context) : null;
|
|
223
|
+
};
|
|
224
|
+
const handOffText = (kind, ref) => {
|
|
225
|
+
const body = handOffBody(kind, ref);
|
|
226
|
+
return body && frameHandOff(definition.name, [body]);
|
|
227
|
+
};
|
|
228
|
+
const handOffSnapshot = (kind, ref) => snapshotOf(kind, ref, handOffSource());
|
|
229
|
+
|
|
230
|
+
const summary = () => {
|
|
231
|
+
const seen = new Set(store.marks.seen);
|
|
232
|
+
const unread = rows().feed.filter((item) => !seen.has(item.id) && new Date(item.at).getTime() > store.marks.base).length;
|
|
233
|
+
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() };
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const updateMarks = (body) => {
|
|
237
|
+
const marks = store.marks;
|
|
238
|
+
if (body.seen) {
|
|
239
|
+
marks.seen = unionCapped(marks.seen, body.seen);
|
|
240
|
+
}
|
|
241
|
+
if ('base' in body) {
|
|
242
|
+
marks.base = body.base;
|
|
243
|
+
}
|
|
244
|
+
for (const [rowId, value] of Object.entries(body.handled || {})) {
|
|
245
|
+
if (value === null) {
|
|
246
|
+
delete marks.handled[rowId];
|
|
247
|
+
} else {
|
|
248
|
+
marks.handled[rowId] = value;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
store.saveMarks();
|
|
252
|
+
emit();
|
|
253
|
+
return marked();
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const addTodo = (body) => {
|
|
257
|
+
const text = String(body.text).trim().slice(0, 500);
|
|
258
|
+
store.todos.unshift({ id: `t${Date.now()}`, text, key: body.key || keyOf(text), created: nowIso(), by: String(body.by || 'you').slice(0, 20), done: null });
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const changeTodo = (todo, body) => {
|
|
262
|
+
switch (body.action) {
|
|
263
|
+
case 'done':
|
|
264
|
+
todo.done = nowIso();
|
|
265
|
+
return true;
|
|
266
|
+
case 'undo':
|
|
267
|
+
todo.done = null;
|
|
268
|
+
return true;
|
|
269
|
+
case 'delete':
|
|
270
|
+
store.todos.splice(store.todos.indexOf(todo), 1);
|
|
271
|
+
return true;
|
|
272
|
+
case 'edit':
|
|
273
|
+
if (!String(body.text || '').trim()) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
todo.text = String(body.text).trim().slice(0, 500);
|
|
277
|
+
todo.key = keyOf(todo.text) || todo.key || '';
|
|
278
|
+
return true;
|
|
279
|
+
default:
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const saveTodos = () => {
|
|
285
|
+
store.saveTodos();
|
|
286
|
+
store.state.version += 1;
|
|
287
|
+
emit();
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
id: definition.id, name: definition.name, rulesTable: rules.table, store, poll, snapshot, summary, describe, checkHealth, handOff, handOffBody, handOffText, handOffSnapshot, updateMarks, addTodo, changeTodo, saveTodos,
|
|
292
|
+
subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
|
|
293
|
+
};
|
|
294
|
+
};
|
package/open.sh
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Opens the monitor as its own Chrome or Edge app window at the remembered size, or in a browser tab with --browser or when neither is installed.
|
|
3
|
+
port=${WHOSE_TURN_PORT:-8765}
|
|
4
|
+
base=http://127.0.0.1:$port
|
|
5
|
+
width=1500
|
|
6
|
+
height=950
|
|
7
|
+
|
|
8
|
+
size=$(curl -fs --max-time 1 "$base/api/window" 2>/dev/null)
|
|
9
|
+
[[ $size =~ \"width\":([0-9]+) ]] && width=${BASH_REMATCH[1]}
|
|
10
|
+
[[ $size =~ \"height\":([0-9]+) ]] && height=${BASH_REMATCH[1]}
|
|
11
|
+
|
|
12
|
+
tab() {
|
|
13
|
+
if [[ $(uname) == Darwin ]]; then open "$base/"; else xdg-open "$base/"; fi
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if [[ $1 == --browser ]]; then
|
|
17
|
+
tab
|
|
18
|
+
exit
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
url="$base/?mode=app"
|
|
22
|
+
size_flag=--window-size=$width,$height
|
|
23
|
+
|
|
24
|
+
if [[ $(uname) == Darwin ]]; then
|
|
25
|
+
if open -Ra "Google Chrome" 2>/dev/null; then
|
|
26
|
+
open -na "Google Chrome" --args --app="$url" "$size_flag"
|
|
27
|
+
else
|
|
28
|
+
tab
|
|
29
|
+
fi
|
|
30
|
+
exit
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
for browser in google-chrome chromium chromium-browser microsoft-edge; do
|
|
34
|
+
if command -v "$browser" >/dev/null; then
|
|
35
|
+
"$browser" --app="$url" "$size_flag" >/dev/null 2>&1 &
|
|
36
|
+
disown
|
|
37
|
+
exit
|
|
38
|
+
fi
|
|
39
|
+
done
|
|
40
|
+
tab
|