whose-turn 1.0.0-alpha.7 → 1.0.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +28 -16
  3. package/config.example.json +0 -4
  4. package/connectors/github.mjs +24 -0
  5. package/dist/assets/_plugin-vue_export-helper-BbgK5wvS.js +18 -0
  6. package/dist/assets/_plugin-vue_export-helper-BneMU3KP.css +1 -0
  7. package/dist/assets/bem-OtK9ToP0.js +1 -0
  8. package/dist/assets/index-B6grE8kv.css +1 -0
  9. package/dist/assets/index-C3jZLqLJ.js +15 -0
  10. package/dist/assets/report-DUlemvzD.js +1 -0
  11. package/dist/assets/scheme-DCqBrLhv.js +1 -0
  12. package/dist/assets/sheet-Ctd_6q4a.js +1 -0
  13. package/dist/index.html +6 -6
  14. package/dist/report.html +4 -4
  15. package/dist/sheet.html +4 -4
  16. package/lib/api.mjs +70 -24
  17. package/lib/backoff.mjs +13 -0
  18. package/lib/compose.mjs +6 -8
  19. package/lib/config.mjs +102 -15
  20. package/lib/default-table.mjs +1 -0
  21. package/lib/errors.mjs +1 -2
  22. package/lib/facts.mjs +1 -1
  23. package/lib/feedback.mjs +140 -111
  24. package/lib/linear-api.mjs +1 -1
  25. package/lib/notify.mjs +7 -8
  26. package/lib/registry.mjs +32 -5
  27. package/lib/rules.mjs +16 -9
  28. package/lib/table.mjs +1 -0
  29. package/lib/text.mjs +3 -0
  30. package/lib/trace.mjs +6 -6
  31. package/lib/workspace.mjs +63 -15
  32. package/package.json +2 -1
  33. package/server.mjs +17 -8
  34. package/dist/assets/_plugin-vue_export-helper-DeQjDyQ9.js +0 -18
  35. package/dist/assets/_plugin-vue_export-helper-aQHODzfZ.css +0 -1
  36. package/dist/assets/bem-zV6iDqYN.js +0 -1
  37. package/dist/assets/index-BLFgmJcy.js +0 -7
  38. package/dist/assets/index-CTrTp-xu.css +0 -1
  39. package/dist/assets/report-BJ_aC1A7.js +0 -1
  40. package/dist/assets/scheme-BdlolNvz.js +0 -1
  41. package/dist/assets/sheet-q2RQEgOf.js +0 -1
package/lib/registry.mjs CHANGED
@@ -1,9 +1,16 @@
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
+ /** A workspace's identity for rebuilding: everything but how it is hidden, which a running workspace reads from the registry (`hiddenOf`) without a rebuild. */
5
+ const signatureOf = (definition) => JSON.stringify({ ...definition, hidden: undefined });
6
+
7
+ /** One raw workspace entry with its hide mode set, or without the key when shown. */
8
+ export const withHidden = ({ hidden: _was, ...entry }, hidden) => (hidden ? { ...entry, hidden } : entry);
9
+
10
+ export const createRegistry = ({ load, save, saveNotifications, saveSettings = /** @type {(settings: object) => unknown} */ (() => {}), build, normalise = (raw) => raw }) => {
5
11
  const listeners = new Set();
6
- const registry = { workspaces: [], rawWorkspaces: [], problems: [], configFile: '', feedback: null, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); } };
12
+ let hiddenModes = new Map();
13
+ const registry = { workspaces: [], hiddenOf: (id) => hiddenModes.get(id) || '', rawWorkspaces: [], problems: [], configFile: '', feedback: null, notifications: { desktop: true }, settings: {}, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); } };
7
14
 
8
15
  const apply = (config, prebuilt = new Map()) => {
9
16
  const previous = new Map(registry.workspaces.map((workspace) => [workspace.id, workspace]));
@@ -11,9 +18,13 @@ export const createRegistry = ({ load, save, build, normalise = (raw) => raw })
11
18
  const created = [];
12
19
  const dropped = [];
13
20
  registry.workspaces = config.workspaces.map((definition) => {
14
- const signature = JSON.stringify(definition);
21
+ const signature = signatureOf(definition);
15
22
  if (signatures.get(definition.id) === signature) {
16
- return previous.get(definition.id);
23
+ const kept = previous.get(definition.id);
24
+ if (registry.hiddenOf(definition.id) && !definition.hidden) {
25
+ created.push(kept);
26
+ }
27
+ return kept;
17
28
  }
18
29
  let workspace;
19
30
  try {
@@ -27,8 +38,11 @@ export const createRegistry = ({ load, save, build, normalise = (raw) => raw })
27
38
  created.push(workspace);
28
39
  return workspace;
29
40
  }).filter(Boolean);
41
+ hiddenModes = new Map(config.workspaces.map((definition) => [definition.id, definition.hidden || '']));
30
42
  registry.rawWorkspaces = config.rawWorkspaces;
31
43
  registry.feedback = config.feedback;
44
+ registry.notifications = config.notifications;
45
+ registry.settings = config.settings || {};
32
46
  registry.problems = [...config.problems, ...dropped];
33
47
  registry.configFile = config.configFile;
34
48
  return created;
@@ -41,7 +55,7 @@ export const createRegistry = ({ load, save, build, normalise = (raw) => raw })
41
55
  const prebuilt = new Map();
42
56
  try {
43
57
  for (const definition of normalise(rawWorkspaces)) {
44
- const signature = JSON.stringify(definition);
58
+ const signature = signatureOf(definition);
45
59
  if (!known.has(signature)) {
46
60
  prebuilt.set(signature, build(definition, config));
47
61
  }
@@ -56,6 +70,19 @@ export const createRegistry = ({ load, save, build, normalise = (raw) => raw })
56
70
  await save(rawWorkspaces);
57
71
  return apply(load(), prebuilt);
58
72
  };
73
+ /** Parks, pauses or shows one workspace by rewriting its entry in the config; everything else about it stays. */
74
+ registry.setHidden = async (id, hidden) => {
75
+ await save(registry.rawWorkspaces.map((entry) => (entry.id === id ? withHidden(entry, hidden) : entry)));
76
+ return registry.reload();
77
+ };
78
+ registry.saveSettings = async (settings) => {
79
+ await saveSettings(settings);
80
+ registry.reload();
81
+ };
82
+ registry.saveNotifications = async (notifications) => {
83
+ await saveNotifications(notifications);
84
+ registry.reload();
85
+ };
59
86
  registry.reload();
60
87
  return registry;
61
88
  };
package/lib/rules.mjs CHANGED
@@ -19,11 +19,11 @@ export const compileRules = (rules) => {
19
19
  };
20
20
  };
21
21
 
22
- export const youOr = (name, myName) => (myName && name === myName ? 'you' : name);
22
+ 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: '',
26
- pr: '', pr_url: '', pr_state: '', checks: '', comment: '', comment_url: '', ticket_url: '', url: '',
26
+ reviewers: [], pr: '', pr_url: '', pr_state: '', checks: '', comment: '', comment_url: '', ticket_url: '', url: '',
27
27
  };
28
28
 
29
29
  const row = (fields) => {
@@ -80,11 +80,18 @@ const openMentions = (comments, me, rules, cutoff) => {
80
80
  return asks;
81
81
  };
82
82
 
83
- const applyThen = (built, then, facts, reason) => {
83
+ const REVIEW_MARK = { approved: '✓ ', changes: '✗ ' };
84
+
85
+ /** A PR's reviewers as the user knows them: their own login reads `you`. */
86
+ const namedReviewers = (reviewers, myName) => reviewers.map(({ name, state }) => ({ name: youOr(name, myName), state }));
87
+
88
+ const applyThen = (built, then, facts, reason, reviewers) => {
84
89
  const { label, group } = WHOSE[then.whose];
90
+ const listed = then.whose === 'reviewer' ? reviewers : [];
85
91
  Object.assign(built, {
86
92
  group: group || built.group,
87
- whose: label || built.whose,
93
+ whose: listed.length ? listed.map(({ name, state }) => `${REVIEW_MARK[state] || ''}${name}`).join(', ') : label || built.whose,
94
+ reviewers: listed,
88
95
  started: Boolean(then.started),
89
96
  rank: then.rank ?? built.rank,
90
97
  next: then.next ? fillText(then.next, facts) : built.next,
@@ -93,10 +100,10 @@ const applyThen = (built, then, facts, reason) => {
93
100
  });
94
101
  };
95
102
 
96
- const decided = (built, facts, rules, reason) => {
103
+ const decided = (built, facts, rules, reason, reviewers = []) => {
97
104
  const matched = firstMatch(rules.table, facts);
98
105
  if (matched) {
99
- applyThen(built, matched.then, facts, reason);
106
+ applyThen(built, matched.then, facts, reason, reviewers);
100
107
  }
101
108
  return built;
102
109
  };
@@ -112,7 +119,7 @@ const ticketRow = (ticket, pr, context) => {
112
119
  activity_at: [toIsoUtc(ticket.updated), pr ? pr.updated_at : ''].filter(Boolean).sort().pop(),
113
120
  ...(pr ? prCells(pr, open) : {}),
114
121
  });
115
- return decided(built, facts, context.rules, reason);
122
+ return decided(built, facts, context.rules, reason, namedReviewers(pr?.reviewers || [], pr && context.me[pr.source]));
116
123
  };
117
124
 
118
125
  /** A failing workflow run on the default branch, decided by the table; keyed by repository, workflow and branch, and by its streak so a new breakage after a fix is a new move. */
@@ -152,7 +159,7 @@ export const turnRows = ({ tickets = [], prs = [], comments = [], asks = [], run
152
159
  const ticketKeys = new Set(tickets.map((ticket) => ticket.key));
153
160
  const mentions = openMentions(comments, me, rules, daysAgoIso(historyDays));
154
161
  const rows = tickets.map((ticket) => ticketRow(ticket, pickPr(prsByKey.get(ticket.key)), {
155
- myName: me[ticket.source], rules, comments: commentsByKey.get(ticket.key) || [], mentions: mentions.get(ticket.key) || [],
162
+ me, myName: me[ticket.source], rules, comments: commentsByKey.get(ticket.key) || [], mentions: mentions.get(ticket.key) || [],
156
163
  }));
157
164
  for (const [key, list] of prsByKey) {
158
165
  const pr = pickPr(list);
@@ -195,7 +202,7 @@ export const feedRows = ({ activity = [], events = [], tickets = [], comments =
195
202
  text: oneline(comment.body), body: comment.body, url: comment.url,
196
203
  });
197
204
  }),
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 })),
205
+ ...events.map((event) => feedItem({ id: eventId(event), at: event.at, source: event.source, ref: event.key, key: event.key, kind: event.kind, text: event.kind === 'reassigned' ? event.text.split(' → ').map((name) => youOr(name.trim(), me[event.source])).join(' → ') : event.text, url: event.url })),
199
206
  ...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 })),
200
207
  ];
201
208
  const timesByKey = groupBy([...events, ...recentComments], (item) => item.key);
package/lib/table.mjs CHANGED
@@ -6,6 +6,7 @@ export const WHOSE = {
6
6
  you: { label: 'You', group: 'you' },
7
7
  reviewer: { label: 'Reviewer', group: null },
8
8
  qa: { label: 'QA', group: null },
9
+ release: { label: 'Release', group: null },
9
10
  them: { label: null, group: null },
10
11
  hidden: { label: null, group: 'gone' },
11
12
  };
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 CHANGED
@@ -1,5 +1,5 @@
1
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';
2
+ import { eventId } from './rules.mjs';
3
3
  import { DAY, oneline } from './text.mjs';
4
4
 
5
5
  const lastName = (text) => String(text).split(' → ').pop().trim();
@@ -14,14 +14,14 @@ export const passedRows = (before, after) => {
14
14
 
15
15
  const mergedNow = ({ before, after }) => before.pr_state !== 'MERGED' && after.pr_state === 'MERGED';
16
16
 
17
- /** The newest comment on the ticket from the day before the move, as `{author, text}`, or null. */
17
+ /** The newest comment on the ticket from the day before the move, as `{id, text}` (the feed id and a short excerpt), or null. */
18
18
  const commentBefore = (feed, key, at) => {
19
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;
20
+ return found ? { id: found.id, text: oneline(found.body || found.text) } : null;
21
21
  };
22
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 = {}) => {
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 and already shows your name as you. */
24
+ export const traceOf = (pair, events, feed) => {
25
25
  const own = events.filter((event) => event.key === pair.after.key && event.source === pair.after.source);
26
26
  const reassigned = own.find((event) => event.kind === 'reassigned');
27
27
  const status = own.find((event) => event.kind === 'status');
@@ -38,7 +38,7 @@ export const traceOf = (pair, events, feed, me = {}) => {
38
38
  return {
39
39
  outcome: comment ? `${head} · ${comment.text}` : head,
40
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 } : {}) },
41
+ detail: { what: head, assignee: feed.find((item) => item.id === eventId(reassigned))?.text ?? reassigned.text, ...(status ? { status: status.text } : {}), ...(comment?.id ? { comment: comment.id } : {}) },
42
42
  };
43
43
  }
44
44
  return status ? { outcome: `Moved to ${lastName(status.text)}`, event: eventId(status), detail: { status: status.text } } : null;
package/lib/workspace.mjs CHANGED
@@ -4,11 +4,11 @@ import { compileRules, turnRows, feedRows, myMoves, isHandled, withHandled, turn
4
4
  import { makeKeyFinder, nowIso, oneline, daysAgoIso, unionCapped, DAY } from './text.mjs';
5
5
  import { passedRows, traceOf } from './trace.mjs';
6
6
  import { buildHandOff, snapshotOf, renderHandOff, frameHandOff } from './handoff.mjs';
7
- import { notify as desktopNotify } from './notify.mjs';
8
7
  import { DEFAULT_LIMITS, nextFails, assess, transition } from './health.mjs';
9
8
  import { paletteFrom, derivedSeed } from './palette.mjs';
10
9
  import { CONNECTORS, connectorSummaries } from '../connectors/index.mjs';
11
10
  import { RateLimited } from './http-client.mjs';
11
+ import { isDue } from './backoff.mjs';
12
12
  const KINDS = ['tickets', 'comments', 'prs', 'activity', 'asks', 'runs'];
13
13
 
14
14
  const stamp = (result, sourceId) => {
@@ -35,8 +35,12 @@ export const mergeLinks = (own, derived) => {
35
35
  return [...own, ...derived.filter((link) => !ownHosts.has(hostOf(link.url)))].filter((link) => !seen.has(link.url) && seen.add(link.url));
36
36
  };
37
37
 
38
- export const createWorkspace = (definition, { dataDir, historyDays, http, secrets, limits = () => DEFAULT_LIMITS, notify = desktopNotify }) => {
38
+ export const createWorkspace = (definition, { dataDir, historyDays, http, secrets, limits = () => DEFAULT_LIMITS, notify, hidden = () => definition.hidden || '', unpark = /** @type {(id: string) => Promise<unknown>} */ (async () => {}) }) => {
39
+ if (typeof notify !== 'function') {
40
+ throw new TypeError('createWorkspace needs a notify function');
41
+ }
39
42
  const store = openStore(dataDir, definition.id);
43
+ const days = () => (typeof historyDays === 'function' ? historyDays() : historyDays);
40
44
  const rules = compileRules(definition.rules);
41
45
  const keyOf = makeKeyFinder(definition.key_pattern);
42
46
  const seed = definition.theme?.seed || definition.derived_seed || derivedSeed(definition.id);
@@ -48,6 +52,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
48
52
  let marksVersion = 0;
49
53
  let freshAt = '';
50
54
  const startedAt = nowIso();
55
+ let lastPollAt = Date.parse(store.state.polled_at) || 0;
51
56
 
52
57
  const sources = () => Object.values(store.state.sources);
53
58
  const merged = () => Object.fromEntries(KINDS.map((kind) => [kind, sources().flatMap((source) => source[kind] || [])]));
@@ -66,7 +71,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
66
71
  if (!derived || derived.version !== store.state.version || derived.day !== day || derived.marksVersion !== marksVersion) {
67
72
  const sources = merged();
68
73
  const state = { ...sources, events: store.state.events, notes: noteEvents() };
69
- const context = { me: me(), rules, historyDays, ticketUrl };
74
+ const context = { me: me(), rules, historyDays: days(), ticketUrl };
70
75
  derived = { version: store.state.version, marksVersion, day, turn: turnRows(sources, context), feed: feedRows(state, context) };
71
76
  }
72
77
  return derived;
@@ -86,7 +91,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
86
91
  };
87
92
 
88
93
  const emit = () => {
89
- const message = { ws: definition.id, version: store.state.version, polled_at: store.state.polled_at, open_moves: myMoves(rows().turn, store.marks).length };
94
+ const message = { ws: definition.id, version: store.state.version, polled_at: store.state.polled_at, open_moves: myMoves(rows().turn, store.marks).length, hidden: self.hidden, returned: returned() };
90
95
  for (const listener of listeners) {
91
96
  listener(message);
92
97
  }
@@ -107,12 +112,18 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
107
112
  store.saveMarks();
108
113
  };
109
114
 
110
- const health = () => assess({ fails: store.state.fails, freshAt: freshAt || startedAt, now: Date.now(), limits: limits(), sourceCount: definition.connectors.length });
115
+ /** The health of a shown workspace; null while it is parked or paused, which is neither watched nor alerted about. */
116
+ const assessed = () => (self.hidden ? null : assess({ fails: store.state.fails, freshAt: freshAt || startedAt, now: Date.now(), limits: limits(), sourceCount: definition.connectors.length }));
117
+ const health = () => assessed() || { ok: true, stale: false, fresh_at: freshAt, failing: [], alerts: [] };
111
118
 
112
119
  let shownStreaks = '';
113
120
 
114
121
  const checkHealth = () => {
115
- const { alerts } = health();
122
+ const watched = assessed();
123
+ if (!watched) {
124
+ return;
125
+ }
126
+ const { alerts } = watched;
116
127
  const { raised, recovered, alerted } = transition(store.marks.health_alerted, alerts);
117
128
  const streaks = alerts.filter((alert) => alert.id.startsWith('failing:')).map((alert) => alert.text).join('\n');
118
129
  if (!raised.length && !recovered.length) {
@@ -142,7 +153,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
142
153
  }
143
154
  caches[entry.id] = caches[entry.id] || store.loadCache(entry.id);
144
155
  try {
145
- const result = await connector.poll({ settings: entry, http, secrets, cache: caches[entry.id], sinceIso: daysAgoIso(historyDays), historyDays, keyOf });
156
+ const result = await connector.poll({ settings: entry, http, secrets, cache: caches[entry.id], sinceIso: daysAgoIso(days()), historyDays: days(), keyOf });
146
157
  const errors = Object.fromEntries(Object.entries(result.errors || {}).map(([part, message]) => [`${entry.id}.${part}`, message]));
147
158
  return { data: stamp(result, entry.id), errors };
148
159
  } catch (error) {
@@ -157,12 +168,16 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
157
168
  };
158
169
 
159
170
  const poll = async () => {
171
+ lastPollAt = Date.now();
172
+ const checkedBefore = Boolean(store.state.polled_at);
160
173
  expireHandled();
161
174
  const outcomes = await Promise.all(definition.connectors.map(pollSource));
162
175
  const state = store.state;
163
176
  const errors = {};
164
177
  const ticketsBefore = merged().tickets;
165
178
  const turnBefore = rows().turn;
179
+ const parked = self.hidden === 'parked' && checkedBefore;
180
+ const movesBefore = parked ? new Set(myMoves(turnBefore, store.marks).map((move) => move.row_id)) : null;
166
181
  definition.connectors.forEach((entry, index) => {
167
182
  const outcome = outcomes[index];
168
183
  Object.assign(errors, outcome.errors);
@@ -202,11 +217,39 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
202
217
  if (outcomes.some((outcome) => outcome.data)) {
203
218
  saveCaches();
204
219
  }
220
+ if (parked && outcomes.some((outcome) => outcome.data)) {
221
+ await comeBack(myMoves(rows().turn, store.marks).filter((move) => !movesBefore.has(move.row_id)));
222
+ }
205
223
  checkHealth();
206
224
  emit();
207
225
  return { errors, rateLimited: outcomes.some((outcome) => outcome.rateLimited) };
208
226
  };
209
227
 
228
+ /** A parked workspace whose check found new moves for you goes back into the rail, with a feed line that says why. */
229
+ const comeBack = async (arrived) => {
230
+ if (!arrived.length) {
231
+ return;
232
+ }
233
+ const [first] = arrived;
234
+ const text = arrived.length === 1 ? 'Back in the rail: this needs you' : `Back in the rail: ${arrived.length} items need you`;
235
+ store.state.events = [{ at: nowIso(), source: first.source, key: first.key, kind: 'returned', text, url: first.ticket_url || first.url }, ...store.state.events].slice(0, 500);
236
+ store.state.version += 1;
237
+ store.saveState();
238
+ await unpark(definition.id).catch(() => {});
239
+ };
240
+
241
+ /** The feed items you have not seen: not in the seen marks and newer than the base. */
242
+ const unseen = (items) => {
243
+ const seen = new Set(store.marks.seen);
244
+ return items.filter((item) => !seen.has(item.id) && new Date(item.at).getTime() > store.marks.base);
245
+ };
246
+
247
+ /** Whether the feed holds a line about the workspace coming back that you have not seen yet: the rail's dot. */
248
+ const returned = () => {
249
+ const lines = rows().feed.filter((item) => item.kind === 'returned');
250
+ return lines.length > 0 && unseen(lines).length > 0;
251
+ };
252
+
210
253
  const rowRef = (row) => ({ key: row.key, source: row.source, url: row.ticket_url || row.url });
211
254
 
212
255
  /** Adds the auto Handled mark to rows whose turn passed on in this poll and drops it from rows whose turn came back. */
@@ -216,7 +259,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
216
259
  let changed = false;
217
260
  let notesMoved = false;
218
261
  for (const pair of passedRows(turnBefore, turn)) {
219
- const trace = traceOf(pair, fresh, feed, me());
262
+ const trace = traceOf(pair, fresh, feed);
220
263
  if (trace) {
221
264
  const rowId = pair.after.row_id;
222
265
  notesMoved ||= Boolean(info[rowId]?.note);
@@ -256,10 +299,10 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
256
299
  const identity = () => ({
257
300
  id: definition.id, name: definition.name, key_pattern: definition.key_pattern, links: quickLinks(),
258
301
  connectors: connectorSummaries(definition.connectors),
259
- open_moves: myMoves(rows().turn, store.marks).length,
302
+ open_moves: myMoves(rows().turn, store.marks).length, hidden: self.hidden,
260
303
  });
261
304
 
262
- const describe = () => ({ ...identity(), theme, theme_seed: seed, failing_sources: Object.keys(store.state.errors || {}).length });
305
+ const describe = () => ({ ...identity(), theme, theme_seed: seed, backdrop: definition.theme?.backdrop !== false, failing_sources: Object.keys(store.state.errors || {}).length, returned: returned() });
263
306
 
264
307
  const todosWithLinks = () => store.todos.map((todo) => ({ ...todo, ticket_url: todo.key ? ticketUrl(todo.key) : '' }));
265
308
 
@@ -289,9 +332,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
289
332
  const handOffSnapshot = (kind, ref) => snapshotOf(kind, ref, handOffSource());
290
333
 
291
334
  const summary = () => {
292
- const seen = new Set(store.marks.seen);
293
- const unread = rows().feed.filter((item) => !seen.has(item.id) && new Date(item.at).getTime() > store.marks.base).length;
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() };
335
+ return { ...identity(), moves: myMoves(rows().turn, store.marks), new_events: unseen(rows().feed).length, todos: todosWithLinks().filter((todo) => !todo.done), polled_at: store.state.polled_at, errors: store.state.errors, health: health() };
295
336
  };
296
337
 
297
338
  const noteOn = (rowId, text) => {
@@ -365,8 +406,15 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
365
406
  emit();
366
407
  };
367
408
 
368
- return {
369
- id: definition.id, name: definition.name, rulesTable: rules.table, store, poll, snapshot, summary, describe, checkHealth, handOff, handOffBody, handOffText, handOffSnapshot, updateMarks, addTodo, changeTodo, saveTodos,
409
+ /** Polls when the round is due for this workspace: every round when shown, every 15 minutes when parked, never when paused. */
410
+ const pollIfDue = () => (isDue(self.hidden, lastPollAt, Date.now()) ? poll() : Promise.resolve({ errors: {}, rateLimited: false }));
411
+
412
+ const self = {
413
+ get hidden() {
414
+ return hidden();
415
+ },
416
+ id: definition.id, name: definition.name, sourceTypes: [...new Set(definition.connectors.map((entry) => entry.type))], rulesTable: rules.table, store, poll, pollIfDue, snapshot, summary, describe, checkHealth, handOff, handOffBody, handOffText, handOffSnapshot, updateMarks, addTodo, changeTodo, saveTodos,
370
417
  subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
371
418
  };
419
+ return self;
372
420
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whose-turn",
3
- "version": "1.0.0-alpha.7",
3
+ "version": "1.0.0-alpha.9",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,7 @@
56
56
  "eslint-config-prettier": "10.1.8",
57
57
  "eslint-plugin-vue": "10.11.1",
58
58
  "happy-dom": "21.0.4",
59
+ "motion-v": "2.9.0",
59
60
  "postcss-html": "2.0.0",
60
61
  "prettier": "3.9.10",
61
62
  "stylelint": "17.16.0",
package/server.mjs CHANGED
@@ -3,16 +3,16 @@
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { loadConfig, saveWorkspaces, normaliseWorkspaces } from './lib/config.mjs';
6
+ import { loadConfig, saveWorkspaces, saveNotifications, saveSettings, normaliseWorkspaces } from './lib/config.mjs';
7
7
  import { createHttpClient } from './lib/http-client.mjs';
8
8
  import { nextDelay } from './lib/backoff.mjs';
9
9
  import { createSecrets } from './lib/secrets.mjs';
10
10
  import { createWorkspace } from './lib/workspace.mjs';
11
11
  import { createRegistry } from './lib/registry.mjs';
12
12
  import { openInbox } from './lib/inbox.mjs';
13
- import { openReports } from './lib/feedback.mjs';
13
+ import { openReports, RELAY_URL, NEWS_EVERY_MS } from './lib/feedback.mjs';
14
14
  import { startApi } from './lib/api.mjs';
15
- import { notify } from './lib/notify.mjs';
15
+ import { notifier } from './lib/notify.mjs';
16
16
  import { loadChangelog } from './lib/changelog.mjs';
17
17
  import { createUpdater, restartServer, runsInstalled } from './lib/update.mjs';
18
18
 
@@ -21,9 +21,10 @@ const http = createHttpClient();
21
21
  const first = loadConfig();
22
22
  const secrets = createSecrets(first.dataDir);
23
23
  const inbox = openInbox(first.dataDir);
24
- const reports = openReports(first.dataDir);
24
+ const reports = openReports(first.dataDir, { http, relayUrl: process.env.WHOSE_TURN_FEEDBACK_URL || RELAY_URL, adminToken: () => secrets.get('whose-turn-feedback-admin', 'relay') });
25
25
  const { version } = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8'));
26
26
  let server;
27
+ const notify = notifier(() => process.env.WHOSE_TURN_NOTIFY !== 'off' && registry.notifications.desktop);
27
28
  const updater = createUpdater({
28
29
  http,
29
30
  dataDir: first.dataDir,
@@ -35,6 +36,7 @@ const updater = createUpdater({
35
36
  restart: () => restartServer({ server, root: appDir, dataDir: first.dataDir }),
36
37
  });
37
38
  let pollSeconds = first.pollSeconds;
39
+ let historyDays = first.historyDays;
38
40
  /** @type {{ stale_minutes: number, failing_polls: number }} */
39
41
  let healthLimits = /** @type {any} */ (first.health);
40
42
 
@@ -42,12 +44,15 @@ const registry = createRegistry({
42
44
  load: () => {
43
45
  const config = loadConfig();
44
46
  pollSeconds = config.pollSeconds;
47
+ historyDays = config.historyDays;
45
48
  healthLimits = /** @type {any} */ (config.health);
46
49
  return config;
47
50
  },
48
51
  save: (rawWorkspaces) => saveWorkspaces(first.configFile, rawWorkspaces),
52
+ saveNotifications: (notifications) => saveNotifications(first.configFile, notifications),
53
+ saveSettings: (settings) => saveSettings(first.configFile, settings),
49
54
  normalise: (rawWorkspaces) => normaliseWorkspaces(rawWorkspaces).workspaces,
50
- build: (definition, config) => createWorkspace(definition, { dataDir: config.dataDir, historyDays: config.historyDays, http, secrets, limits: () => healthLimits }),
55
+ build: (definition, config) => createWorkspace(definition, { dataDir: config.dataDir, historyDays: () => historyDays, http, secrets, limits: () => healthLimits, notify, hidden: () => registry.hiddenOf(definition.id), unpark: (id) => registry.setHidden(id, '') }),
51
56
  });
52
57
 
53
58
  let wake = null;
@@ -60,7 +65,7 @@ const sleep = (seconds) => new Promise((resolve) => {
60
65
  const pollLoop = async () => {
61
66
  let delay = pollSeconds;
62
67
  for (;;) {
63
- const outcomes = await Promise.all(registry.workspaces.map((workspace) => workspace.poll()));
68
+ const outcomes = await Promise.all(registry.workspaces.map((workspace) => workspace.pollIfDue()));
64
69
  delay = nextDelay(outcomes, delay, pollSeconds);
65
70
  await sleep(delay);
66
71
  }
@@ -68,11 +73,11 @@ const pollLoop = async () => {
68
73
 
69
74
  const saveConfig = async (rawWorkspaces) => {
70
75
  const created = await registry.save(rawWorkspaces);
71
- await Promise.all(created.map((workspace) => workspace.poll()));
76
+ await Promise.all(created.map((workspace) => workspace.pollIfDue()));
72
77
  };
73
78
 
74
79
  try {
75
- server = await startApi({ port: first.port, appDir, registry, requestPoll, saveConfig, secrets, inbox, http, reports, dataDir: first.dataDir, updater, changelog: loadChangelog(appDir, version) });
80
+ server = await startApi({ port: first.port, appDir, registry, requestPoll, saveConfig, secrets, inbox, http, reports, dataDir: first.dataDir, updater, changelog: loadChangelog(appDir, version), version });
76
81
  } catch (error) {
77
82
  console.error(error.message);
78
83
  process.exit(1);
@@ -83,6 +88,10 @@ for (const problem of registry.problems) {
83
88
  }
84
89
  pollLoop();
85
90
  updater.start();
91
+ const FIRST_NEWS_MS = 30000;
92
+ const checkReports = () => reports.checkNews().catch(() => {});
93
+ setTimeout(checkReports, FIRST_NEWS_MS).unref();
94
+ setInterval(checkReports, NEWS_EVERY_MS).unref();
86
95
 
87
96
  const HEALTH_TICK = 30000;
88
97
  let lastTick = Date.now();