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/config.mjs CHANGED
@@ -2,11 +2,13 @@
2
2
  import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
+ import { isObject } from './text.mjs';
5
6
  import { isHex, derivedSeeds } from './palette.mjs';
6
7
  import { statusGroups } from './facts.mjs';
7
8
  import { tableProblems, tableWarnings } from './table.mjs';
8
9
  import { DEFAULT_LIMITS } from './health.mjs';
9
10
  import { CONNECTORS } from '../connectors/index.mjs';
11
+ import { HIDDEN_MODES } from './backoff.mjs';
10
12
 
11
13
  export const DEFAULT_RULES = {
12
14
  release_branch_prefix: 'release/',
@@ -18,6 +20,7 @@ export const DEFAULT_RULES = {
18
20
  passed: ['passed'],
19
21
  code_review: ['code review', 'in review'],
20
22
  qa: ['ready for qa', 'in qa', 'testing'],
23
+ release: ['ready for release', 'ready to release', 'awaiting release'],
21
24
  on_hold: ['on hold', 'blocked'],
22
25
  backlog: ['backlog'],
23
26
  },
@@ -39,6 +42,7 @@ export const WORKSPACE_FIELDS = {
39
42
  theme: [
40
43
  { key: 'seed', label: 'Seed colour', hint: 'the scheme is derived from it; empty derives one from the workspace id', colour: true },
41
44
  { key: 'accent', label: 'Accent colour', hint: 'optional second hue', colour: true },
45
+ { key: 'backdrop', label: 'Backdrop animal', hint: 'draw the dotted animal behind the window', boolean: true, default: true },
42
46
  ],
43
47
  rules: [
44
48
  { key: 'approved_next', label: 'Next step when the PR is approved', hint: 'shown when a PR under review is approved' },
@@ -53,11 +57,37 @@ export const WORKSPACE_FIELDS = {
53
57
  { key: 'code_review', label: 'Review statuses', list: true },
54
58
  { key: 'qa', label: 'Testing statuses', list: true },
55
59
  { key: 'passed', label: 'Passed statuses', list: true },
60
+ { key: 'release', label: 'Release statuses', list: true },
56
61
  { key: 'on_hold', label: 'On hold statuses', list: true },
57
62
  { key: 'backlog', label: 'Backlog statuses', list: true },
58
63
  ],
59
64
  };
60
65
 
66
+ /** The monitor-wide numbers the settings sheet and Claude may edit, with their defaults: how often to poll, how far back the feed reaches, and when a health alert fires. */
67
+ export const GLOBAL_SETTINGS = {
68
+ poll_seconds: { fallback: 60, label: 'Poll every (seconds)', hint: 'how often every workspace is polled' },
69
+ history_days: { fallback: 14, label: 'History (days)', hint: 'how far back the feed and the mentions reach', whole: true },
70
+ stale_minutes: { fallback: DEFAULT_LIMITS.stale_minutes, label: 'Stale after (minutes)', hint: 'alert when no source has answered for this long' },
71
+ failing_polls: { fallback: DEFAULT_LIMITS.failing_polls, label: 'Failing polls in a row', hint: 'alert when one source fails this many polls in a row', whole: true },
72
+ };
73
+
74
+ /** Field violations for a `settings` object: unknown keys, and values that are not positive numbers (whole numbers where the key says so). */
75
+ export const settingsViolations = (settings) => {
76
+ if (!isObject(settings)) {
77
+ return [{ field: 'settings', description: 'settings must be an object.' }];
78
+ }
79
+ return Object.entries(settings).flatMap(([key, value]) => {
80
+ const spec = GLOBAL_SETTINGS[key];
81
+ if (!spec) {
82
+ return [{ field: `settings.${key}`, description: `${key} is not a setting; the settings are ${Object.keys(GLOBAL_SETTINGS).join(', ')}.` }];
83
+ }
84
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || (spec.whole && !Number.isInteger(value))) {
85
+ return [{ field: `settings.${key}`, description: `${key} must be a positive ${spec.whole ? 'whole ' : ''}number.` }];
86
+ }
87
+ return [];
88
+ });
89
+ };
90
+
61
91
  /** The per-platform directory that holds config.json and the state, unless WHOSE_TURN_DATA overrides it. */
62
92
  export const defaultDataDir = () => {
63
93
  if (process.platform === 'darwin') {
@@ -115,8 +145,6 @@ const legacyWorkspace = (raw) => {
115
145
  return { id: 'default', name: 'Default', connectors, rules: raw.rules, key_pattern: raw.jira?.key_pattern };
116
146
  };
117
147
 
118
- const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
119
-
120
148
  const connectorEntries = (connectors) => {
121
149
  const list = Array.isArray(connectors) ? connectors : Object.entries(connectors || {}).map(([type, settings]) => ({ type, ...settings }));
122
150
  return list.filter(isObject).map((entry) => ({ ...entry, id: String(entry.id || entry.type) }));
@@ -126,14 +154,21 @@ const normaliseTheme = (theme, problems, workspaceId) => {
126
154
  if (!theme) {
127
155
  return null;
128
156
  }
157
+ const backdrop = theme.backdrop === false ? { backdrop: false } : {};
158
+ if (theme.backdrop !== undefined && typeof theme.backdrop !== 'boolean') {
159
+ problems.push(`Workspace "${workspaceId}": theme.backdrop must be true or false; the backdrop stays on.`);
160
+ }
161
+ if (theme.seed === undefined && theme.accent === undefined) {
162
+ return Object.keys(backdrop).length ? backdrop : null;
163
+ }
129
164
  if (!isHex(theme.seed)) {
130
165
  problems.push(`Workspace "${workspaceId}": theme.seed must be a hex colour like #3d7a5c; the theme is ignored.`);
131
- return null;
166
+ return Object.keys(backdrop).length ? backdrop : null;
132
167
  }
133
168
  if (theme.accent !== undefined && !isHex(theme.accent)) {
134
169
  problems.push(`Workspace "${workspaceId}": theme.accent must be a hex colour like #c27a2c; the accent is ignored.`);
135
170
  }
136
- return { seed: theme.seed.toLowerCase(), ...(isHex(theme.accent) ? { accent: theme.accent.toLowerCase() } : {}) };
171
+ return { seed: theme.seed.toLowerCase(), ...(isHex(theme.accent) ? { accent: theme.accent.toLowerCase() } : {}), ...backdrop };
137
172
  };
138
173
 
139
174
  const normaliseLinks = (links) => (Array.isArray(links) ? links : []).map((entry) => {
@@ -159,6 +194,9 @@ const fieldProblems = (workspace, id, index) => {
159
194
  problems.push(violation(`${at}.key_pattern`, `${where}: key_pattern is not a valid regular expression: ${error.message}`));
160
195
  }
161
196
  }
197
+ if (workspace.hidden !== undefined && !HIDDEN_MODES.includes(workspace.hidden)) {
198
+ problems.push(violation(`${at}.hidden`, `${where}: hidden must be one of ${HIDDEN_MODES.join(', ')}, or left out.`));
199
+ }
162
200
  const rules = workspace.rules || {};
163
201
  const backlogChoices = WORKSPACE_FIELDS.rules.find((field) => field.key === 'backlog_rows').choices;
164
202
  if (rules.backlog_rows !== undefined && !backlogChoices.includes(rules.backlog_rows)) {
@@ -210,7 +248,7 @@ const normaliseWorkspace = (workspace, index, problems, warnings) => {
210
248
  if (tableNotes.length) {
211
249
  warnings[id] = tableNotes;
212
250
  }
213
- return {
251
+ return /** @type {any} */ ({
214
252
  id,
215
253
  links: normaliseLinks(workspace.links),
216
254
  name: String(workspace.name || workspace.id || `Workspace ${index + 1}`),
@@ -218,7 +256,8 @@ const normaliseWorkspace = (workspace, index, problems, warnings) => {
218
256
  rules,
219
257
  key_pattern: workspace.key_pattern || DEFAULT_KEY_PATTERN,
220
258
  theme: normaliseTheme(workspace.theme, problems, id),
221
- };
259
+ ...(HIDDEN_MODES.includes(workspace.hidden) ? { hidden: workspace.hidden } : {}),
260
+ });
222
261
  };
223
262
 
224
263
  export const loadConfig = (env = process.env) => {
@@ -227,7 +266,7 @@ export const loadConfig = (env = process.env) => {
227
266
  const raw = readJson(configFile);
228
267
  const problems = [];
229
268
  if (raw === null) {
230
- problems.push(`No workspace config yet: open Settings (the gear) to add a source. It will be written to ${configFile}.`);
269
+ problems.push(`No workspace config yet: add a source in the workspace settings (top right of the workspace). It will be written to ${configFile}.`);
231
270
  } else if (raw.parse_error) {
232
271
  problems.push(`Cannot parse ${configFile}: ${raw.parse_error}`);
233
272
  }
@@ -236,14 +275,19 @@ export const loadConfig = (env = process.env) => {
236
275
  const { workspaces: normalised, problems: shapeProblems, warnings } = normaliseWorkspaces(rawWorkspaces);
237
276
  const warningLines = Object.entries(warnings).flatMap(([id, lines]) => lines.map((line) => `Workspace "${id}": ${line}.`));
238
277
  const workspaces = normalised.filter((workspace) => WORKSPACE_ID.test(workspace.id));
278
+ const health = normaliseHealth(source, problems);
279
+ const pollSeconds = Number(source.poll_seconds || GLOBAL_SETTINGS.poll_seconds.fallback);
280
+ const historyDays = Number(source.history_days || GLOBAL_SETTINGS.history_days.fallback);
239
281
  return {
240
282
  dataDir,
241
283
  configFile,
242
284
  port: resolvePort(dataDir, env),
243
- pollSeconds: Number(source.poll_seconds || 60),
244
- historyDays: Number(source.history_days || 14),
245
- health: normaliseHealth(source, problems),
246
- feedback: source.feedback && typeof source.feedback === 'object' ? source.feedback : null,
285
+ pollSeconds,
286
+ historyDays,
287
+ health,
288
+ settings: { poll_seconds: pollSeconds, history_days: historyDays, ...health },
289
+ feedback: withoutRetiredFeedbackKeys(source.feedback),
290
+ notifications: normaliseNotifications(source.notifications, problems),
247
291
  workspaces,
248
292
  rawWorkspaces,
249
293
  problems: [...problems, ...shapeProblems, ...warningLines],
@@ -259,6 +303,20 @@ const normaliseHealth = (source, problems) => Object.fromEntries(Object.entries(
259
303
  return [key, value];
260
304
  }));
261
305
 
306
+ /** The boolean `desktop` of a notifications block, or undefined. */
307
+ export const desktopOf = (notifications) => (isObject(notifications) && typeof notifications.desktop === 'boolean' ? notifications.desktop : undefined);
308
+
309
+ const normaliseNotifications = (notifications, problems) => {
310
+ if (notifications === undefined) {
311
+ return { desktop: true };
312
+ }
313
+ const desktop = desktopOf(notifications);
314
+ if (desktop === undefined) {
315
+ problems.push('notifications.desktop must be true or false; desktop notifications stay on.');
316
+ }
317
+ return { desktop: desktop ?? true };
318
+ };
319
+
262
320
  /** Field violations for entries that are not objects: a workspace, or a connector in a workspace's connectors list. Checked before normalising, which assumes objects. */
263
321
  export const entryShapeViolations = (rawWorkspaces) => (Array.isArray(rawWorkspaces) ? rawWorkspaces : []).flatMap((workspace, index) => {
264
322
  if (!isObject(workspace)) {
@@ -293,18 +351,47 @@ export const normaliseWorkspaces = (rawWorkspaces) => {
293
351
  problems.push(`Workspace "${workspace.id}" has two connectors with the same id; give each an "id".`);
294
352
  }
295
353
  }
354
+ if (workspaces.length && workspaces.every((workspace) => workspace.hidden)) {
355
+ problems.push('Keep at least one workspace in the rail: parked and paused workspaces are out of it.');
356
+ }
296
357
  const violations = problems.map(toViolation);
297
358
  return { workspaces, problems: violations.map((entry) => entry.description), violations, warnings };
298
359
  };
299
360
 
300
- /** Writes the workspace list into the config file, keeping its other top-level keys and dropping the flat pre-workspace keys; the previous file is kept as config.json.bak. */
301
- export const saveWorkspaces = (configFile, rawWorkspaces) => {
361
+ const RETIRED_FEEDBACK_KEYS = ['workspace', 'team', 'project', 'token_service', 'assign'];
362
+
363
+ const withoutRetiredFeedbackKeys = (feedback) => (isObject(feedback) ? Object.fromEntries(Object.entries(feedback).filter(([key]) => !RETIRED_FEEDBACK_KEYS.includes(key))) : {});
364
+
365
+ const storedConfig = (configFile) => {
302
366
  const current = readJson(configFile);
303
- const source = current && !current.parse_error ? current : {};
367
+ return current && !current.parse_error ? current : {};
368
+ };
369
+
370
+ /** Writes `patch` over the config file's top-level keys and leaves every other key as it is; an unparsable file is replaced, the previous one stays as config.json.bak. */
371
+ const mergeIntoConfig = (configFile, patch) => writeConfig(configFile, { ...storedConfig(configFile), ...patch });
372
+
373
+ /** Writes the workspace list into the config file; the previous file stays as config.json.bak. */
374
+ export const saveWorkspaces = (configFile, rawWorkspaces) => {
375
+ const source = storedConfig(configFile);
304
376
  delete source.github;
305
377
  delete source.jira;
306
378
  delete source.rules;
307
- const next = { ...source, workspaces: rawWorkspaces };
379
+ const feedback = withoutRetiredFeedbackKeys(source.feedback);
380
+ if (Object.keys(feedback).length) {
381
+ source.feedback = feedback;
382
+ } else {
383
+ delete source.feedback;
384
+ }
385
+ writeConfig(configFile, { ...source, workspaces: rawWorkspaces });
386
+ };
387
+
388
+ /** Writes the validated notifications block into the config file. */
389
+ export const saveNotifications = (configFile, notifications) => mergeIntoConfig(configFile, { notifications });
390
+
391
+ /** Writes the validated monitor-wide numbers (poll_seconds, history_days, stale_minutes, failing_polls) into the config file, leaving every other key as it is. */
392
+ export const saveSettings = (configFile, settings) => mergeIntoConfig(configFile, settings);
393
+
394
+ const writeConfig = (configFile, next) => {
308
395
  mkdirSync(dirname(configFile), { recursive: true });
309
396
  if (existsSync(configFile)) {
310
397
  writeFileSync(`${configFile}.bak.tmp`, readFileSync(configFile));
@@ -44,6 +44,7 @@ export const defaultTable = (rules) => [
44
44
  },
45
45
  { when: { 'ticket.status': 'code_review' }, then: { whose: 'reviewer', next: 'Code review' } },
46
46
  { when: { 'ticket.status': 'qa' }, then: { whose: 'them', next: 'Testing' } },
47
+ { when: { 'ticket.status': 'release' }, then: { whose: 'release', next: 'the next release' } },
47
48
  { when: { 'ticket.status': 'on_hold', 'ticket.hold_reason': { present: true } }, then: { whose: 'them', next: 'On hold: {ticket.hold_reason}', hold: true } },
48
49
  { when: { 'ticket.status': 'on_hold' }, then: { whose: 'them', next: 'On hold' } },
49
50
  {
package/lib/errors.mjs CHANGED
@@ -9,8 +9,7 @@ const REASONS = {
9
9
  INVALID_BODY: { code: 400, status: 'INVALID_ARGUMENT', message: 'The request body is not valid JSON.' },
10
10
  INVALID_ARGUMENT: { code: 400, status: 'INVALID_ARGUMENT', message: 'A required value is missing or has the wrong shape.' },
11
11
  INVALID_CONFIG: { code: 400, status: 'INVALID_ARGUMENT', message: 'The workspace list has problems; nothing was saved.' },
12
- NO_WORKSPACE: { code: 412, status: 'FAILED_PRECONDITION', message: 'No workspace is configured; open Settings to add one.' },
13
- NOT_CONFIGURED: { code: 412, status: 'FAILED_PRECONDITION', message: 'Feedback is not configured: set feedback.workspace, feedback.team and feedback.project in the config.' },
12
+ NO_WORKSPACE: { code: 412, status: 'FAILED_PRECONDITION', message: 'No workspace is configured; add one with + in the rail.' },
14
13
  UPSTREAM_FAILED: { code: 502, status: 'UNAVAILABLE', message: 'The tracker did not answer; try again in a minute.' },
15
14
  RATE_LIMITED: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'The source is rate-limited; try again later.' },
16
15
  OPEN_TOO_SOON: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'The window was just opened; wait a few seconds before opening it again.' },
package/lib/facts.mjs CHANGED
@@ -4,7 +4,7 @@ import { DAY, oneline, newestFirst } from './text.mjs';
4
4
  export const shortDate = (iso) => new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
5
5
 
6
6
  const CATEGORY_GROUPS = { new: 'todo', indeterminate: 'in_progress', done: 'done' };
7
- const CHAIN_GROUPS = ['backlog', 'passed', 'code_review', 'qa', 'on_hold'];
7
+ const CHAIN_GROUPS = ['backlog', 'passed', 'code_review', 'qa', 'release', 'on_hold'];
8
8
 
9
9
  /** The closed fact dictionary a table row may test or quote, each with its plain words.
10
10
  * Rules: docs/mechanisms/rules-table.md, Facts. */
package/lib/feedback.mjs CHANGED
@@ -1,147 +1,176 @@
1
- /** Files a bug or an improvement about the monitor itself into its own Linear project, with the context the sender attaches. RMA-104. */
2
- import { join } from 'node:path';
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 DEFAULT_SERVICE = 'whose-turn-feedback';
10
- const KINDS = { bug: 'Bug', improvement: 'Improvement', idea: 'Feature' };
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
+ export const NEWS_EVERY_MS = 10 * 60 * 1000;
14
+ const STATE_NAMES = { open: 'Open', ready: 'Fixed, coming in the next release', done: 'Done', dropped: 'Dropped' };
11
15
 
12
- const LOOKUP = `
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) => {
16
+ const call = async (http, url, options) => {
36
17
  try {
37
- return await linearQuery(http, key, query, variables);
18
+ return await http.fetchJson(url, { ...options, useEtag: false });
38
19
  } catch (error) {
39
- const failure = error instanceof RateLimited ? new ApiError('RATE_LIMITED') : new ApiError('UPSTREAM_FAILED', error.upstreamStatus ? { upstream_status: error.upstreamStatus } : {});
20
+ const failure = error instanceof RateLimited
21
+ ? new ApiError('RATE_LIMITED')
22
+ : error.upstreamStatus === 400
23
+ ? new ApiError('INVALID_ARGUMENT', { detail: 'the tracker refused the report' })
24
+ : new ApiError('UPSTREAM_FAILED', error.upstreamStatus ? { upstream_status: error.upstreamStatus } : {});
40
25
  failure.debug = String(error.message);
41
26
  throw failure;
42
27
  }
43
28
  };
44
29
 
45
- const describe = ({ kind, body, context, from }) => [
46
- body ? body.trim() : '',
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');
30
+ const isSize = (value) => Number.isInteger(value) && value >= 1 && value <= 20000;
31
+ const BROWSERS = ['Chrome', 'Edge', 'Safari', 'Firefox', 'Opera', 'Brave', 'Arc', 'Chromium', 'Other'];
32
+ const exactly = (value, keys) => isObject(value) && Object.keys(value).sort().join() === keys;
52
33
 
53
- const serviceOf = (settings) => settings.token_service || DEFAULT_SERVICE;
54
- const keyFor = async (settings, secrets) => (settings && settings.workspace ? secrets.get(serviceOf(settings), settings.workspace) : '');
55
-
56
- /** Which way an issue went: open, done, or dropped (canceled); the window and the report page see no Linear vocabulary. */
57
- const outcomeOf = (state) => ({ completed: 'done', canceled: 'dropped' }[state.type] || 'open');
34
+ const CLIENT_FIELDS = {
35
+ 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),
36
+ display: (value) => {
37
+ 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);
38
+ return sane ? { width: value.width, height: value.height, scale: Math.round(value.scale * 100) / 100, viewport: [...value.viewport] } : undefined;
39
+ },
40
+ locale: (value) => (typeof value === 'string' && value.length <= 35 && /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/.test(value) ? value : undefined),
41
+ timezone: (value) => (typeof value === 'string' && value.length <= 64 && /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/.test(value) ? value : undefined),
42
+ theme: (value) => (value === 'light' || value === 'dark' ? value : undefined),
43
+ mode: (value) => (value === 'app' || value === 'tab' ? value : undefined),
44
+ };
58
45
 
59
- const askLinear = async ({ settings, secrets, http }, query, variables) => {
60
- const key = await keyFor(settings, secrets);
61
- if (!key || !settings.team) {
62
- return null;
63
- }
64
- try {
65
- return await call(http, key, query, variables);
66
- } catch {
67
- return null;
46
+ /** 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. */
47
+ export const clientOf = (client) => {
48
+ const clean = {};
49
+ for (const [key, check] of Object.entries(CLIENT_FIELDS)) {
50
+ const value = isObject(client) ? check(client[key]) : undefined;
51
+ if (value !== undefined) {
52
+ clean[key] = value;
53
+ }
68
54
  }
55
+ return clean;
69
56
  };
70
57
 
71
- /** What this machine filed: the last hundred issues in <data>/reports.json. states() reads their Linear state, detail() one issue's comments as answers; both cached for a minute. RMA-120, RMA-123. */
72
- export const openReports = (dataDir) => {
58
+ const environmentOf = ({ sources, install, client }) => ({ os: process.platform, osVersion: release(), arch: process.arch, node: process.version, install, sources: [...new Set(sources)].sort(), ...clientOf(client) });
59
+
60
+ /** 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. When adminToken() yields a token the relay lists every user report instead, each marked `mine` when this machine filed it; no token, or a 401, leaves everything as for any user. News: a report whose state or answer count differs from <data>/reports-seen.json, which markSeen() rewrites when the sheet is read; checkNews() reads the relay at most every ten minutes. RMA-120, RMA-123, RMA-306, RMA-311. */
61
+ export const openReports = (dataDir, { http, relayUrl, adminToken = async () => '' }) => {
73
62
  const file = join(dataDir, 'reports.json');
74
63
  let items = readJson(file, []);
75
- const cache = new Map();
76
- const remembered = async (name, read) => {
77
- const hit = cache.get(name);
78
- if (hit && Date.now() - hit.at < 60000) {
79
- return hit.value;
64
+ const seenFile = join(dataDir, 'reports-seen.json');
65
+ let seen = readJson(seenFile, null);
66
+ let cached = null;
67
+ let news = false;
68
+ const listeners = new Set();
69
+ /** The admin token is looked up once per run, a miss included (the secret store can cost a process spawn); a 401 drops it so the next read looks again. */
70
+ let lookup = null;
71
+ const readAll = async () => {
72
+ lookup ||= adminToken();
73
+ const token = await lookup;
74
+ if (!token) {
75
+ return null;
76
+ }
77
+ try {
78
+ const answer = await http.fetchJson(`${relayUrl}?product=${PRODUCT}&all=1`, { useEtag: false, headers: { Authorization: `Bearer ${token}` } });
79
+ return answer.issues || [];
80
+ } catch (error) {
81
+ if (error.upstreamStatus === 401) {
82
+ lookup = null;
83
+ }
84
+ return null;
80
85
  }
81
- const value = await read();
82
- cache.set(name, { at: Date.now(), value });
83
- return value;
84
86
  };
85
- const states = (deps) => remembered('states', async () => {
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
- });
87
+ const readOwn = async () => {
88
+ if (!items.length) {
89
+ return [];
90
+ }
91
+ try {
92
+ const ids = items.slice(0, MAX_IDS).map((report) => report.id).join(',');
93
+ return (await http.fetchJson(`${relayUrl}?product=${PRODUCT}&ids=${encodeURIComponent(ids)}`, { useEtag: false })).issues || [];
94
+ } catch {
95
+ return [];
96
+ }
97
+ };
98
+ const live = async (maxAge = 60000) => {
99
+ if (cached && Date.now() - cached.at < maxAge) {
100
+ return cached.value;
101
+ }
102
+ const all = await readAll();
103
+ const issues = all || await readOwn();
104
+ cached = { at: Date.now(), value: { admin: Boolean(all), issues: Object.fromEntries(issues.map((issue) => [issue.identifier, issue])), order: issues.map((issue) => issue.identifier) } };
105
+ return cached.value;
106
+ };
107
+ const shape = (issues) => Object.fromEntries(Object.values(issues).map((issue) => [issue.identifier, { state: issue.outcome, answers: (issue.answers || []).length }]));
108
+ const differs = (now) => Object.entries(now).some(([id, shown]) => !seen[id] || seen[id].state !== shown.state || seen[id].answers !== shown.answers);
109
+ const setNews = (value) => {
110
+ if (value !== news) {
111
+ news = value;
112
+ listeners.forEach((listener) => listener({ report_news: news }));
113
+ }
114
+ };
115
+ const merged = (report, issue) => (issue ? { ...report, title: issue.title, state: STATE_NAMES[issue.outcome] || issue.outcome, outcome: issue.outcome, changed_at: issue.updatedAt } : report);
116
+ const strangers = (id, issue) => merged({ id, title: issue.title, filed_at: issue.updatedAt }, issue);
93
117
  return {
118
+ relayUrl,
94
119
  list: () => items.slice(),
95
- add: (report) => { items = [report, ...items].slice(0, 100); cache.clear(); writeJson(file, items); return report; },
96
- states,
97
- detail: async (deps, id) => {
98
- const report = (await states(deps)).find((item) => item.id === id);
120
+ hasNews: () => news,
121
+ subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
122
+ /** The background look: reuses a read younger than ten minutes, so a closed sheet costs the relay one call per ten minutes; the first look only records what is there. */
123
+ checkNews: async () => {
124
+ const now = shape((await live(NEWS_EVERY_MS)).issues);
125
+ if (!seen) {
126
+ seen = now;
127
+ writeJson(seenFile, seen);
128
+ }
129
+ setNews(differs(now));
130
+ return news;
131
+ },
132
+ /** The sheet showed these reports: remember their state and answer count, and calm the button down. A failed read keeps what was remembered. */
133
+ markSeen: async () => {
134
+ const next = { ...seen, ...shape((await live()).issues) };
135
+ if (JSON.stringify(next) !== JSON.stringify(seen)) {
136
+ seen = next;
137
+ writeJson(seenFile, seen);
138
+ }
139
+ setNews(false);
140
+ },
141
+ add: (report) => { items = [report, ...items].slice(0, 100); cached = null; writeJson(file, items); return report; },
142
+ states: async () => {
143
+ const { admin, issues, order } = await live();
144
+ if (!admin) {
145
+ return items.map((report) => merged(report, issues[report.id]));
146
+ }
147
+ const own = new Map(items.map((report) => [report.id, report]));
148
+ return order.map((id) => ({ ...(own.has(id) ? merged(own.get(id), issues[id]) : strangers(id, issues[id])), mine: own.has(id) }));
149
+ },
150
+ detail: async (id) => {
151
+ const { admin, issues } = await live();
152
+ const issue = issues[id];
153
+ const report = items.find((item) => item.id === id) || (admin && issue ? strangers(id, issue) : null);
99
154
  if (!report) {
100
155
  return null;
101
156
  }
102
- const answers = await remembered(`answers:${id}`, async () => {
103
- const data = await askLinear(deps, ANSWERS, { id });
104
- return (data && data.issue ? data.issue.comments.nodes : []).map((comment) => ({ at: comment.createdAt, by: comment.user ? comment.user.name : '', text: comment.body })).sort((a, b) => a.at.localeCompare(b.at));
105
- });
106
- return { ...report, answers, unanswered: Boolean(report.outcome) && report.outcome !== 'open' && !answers.length };
157
+ const answers = (issue ? issue.answers : []).map((answer) => ({ at: answer.at, by: answer.author || '', text: answer.body })).sort((a, b) => a.at.localeCompare(b.at));
158
+ const shown = merged(report, issue);
159
+ return { ...shown, answers, unanswered: (shown.outcome === 'done' || shown.outcome === 'dropped') && !answers.length };
107
160
  },
108
161
  };
109
162
  };
110
163
 
111
- /** True when the config names the Linear workspace, team and project reports go to. */
112
- export const isConfigured = (settings) => Boolean(settings && settings.workspace && settings.team && settings.project);
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);
164
+ /** Files a report through the relay; returns {identifier, url, title}. */
165
+ export const createFeedback = async ({ http, relayUrl, reports, version, sources = [], install = 'source', client = null }, { kind = 'improvement', title, body = '' }) => {
166
+ const cleanTitle = String(title || '').trim().slice(0, 140);
119
167
  if (!cleanTitle) {
120
168
  throw new ApiError('INVALID_ARGUMENT', { detail: 'a title is required' }, [{ field: 'title', description: 'A report needs a title.' }]);
121
169
  }
122
- const key = await keyFor(settings, secrets);
123
- if (!key) {
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;
170
+ const text = String(body || '').trim().slice(0, 6000);
171
+ 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
172
  if (reports) {
144
- reports.add({ id: issue.identifier, number: issue.number, url: issue.url, title: issue.title, kind, body: String(body || '').trim(), filed_at: nowIso() });
173
+ reports.add({ id: issue.identifier, url: issue.url, title: cleanTitle, kind, body: text, filed_at: nowIso() });
145
174
  }
146
- return issue;
175
+ return { identifier: issue.identifier, url: issue.url, title: cleanTitle };
147
176
  };
@@ -1,4 +1,4 @@
1
- /** The one Linear GraphQL client, shared by the Linear connector and the feedback composer. */
1
+ /** The one Linear GraphQL client, used by the Linear connector. */
2
2
 
3
3
  const API = 'https://api.linear.app/graphql';
4
4
 
package/lib/notify.mjs CHANGED
@@ -26,18 +26,17 @@ const COMMANDS = {
26
26
  ],
27
27
  };
28
28
 
29
- /** True under a test runner (node:test passes NODE_TEST_CONTEXT to child processes, Vitest sets VITEST) or when WHOSE_TURN_NOTIFY=off. */
30
- export const muted = (env = process.env) => Boolean(env.NODE_TEST_CONTEXT || env.VITEST) || env.WHOSE_TURN_NOTIFY === 'off';
31
-
32
29
  const launch = (file, args, env) => {
33
- if (!muted()) {
34
- execFile(file, args, { env: { ...process.env, ...env } }, () => {});
35
- }
30
+ execFile(file, args, { env: { ...process.env, ...env } }, () => {});
36
31
  };
37
32
 
38
- export const notify = (title, body, { platform = process.platform, run = launch } = {}) => {
33
+ /** Sends one desktop notification; `enabled: false` stays silent. */
34
+ export const notify = (title, body, { platform = process.platform, run = launch, enabled = true } = {}) => {
39
35
  const build = COMMANDS[platform];
40
- if (build) {
36
+ if (build && enabled) {
41
37
  run(...build(clean(title, 120), clean(body, 240)));
42
38
  }
43
39
  };
40
+
41
+ /** A notify function that asks `isOn()` on every call; `options` pass through to `notify`. */
42
+ export const notifier = (isOn, options = {}) => (title, body) => notify(title, body, { ...options, enabled: isOn() });