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.
Files changed (54) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -2
  4. package/config.example.json +144 -0
  5. package/connectors/demo.mjs +146 -0
  6. package/connectors/github.mjs +316 -0
  7. package/connectors/index.mjs +38 -0
  8. package/connectors/jira.mjs +155 -0
  9. package/connectors/linear.mjs +113 -0
  10. package/dist/assets/_plugin-vue_export-helper-BKveAJb2.css +1 -0
  11. package/dist/assets/_plugin-vue_export-helper-DHr5RHTx.js +18 -0
  12. package/dist/assets/bem-puYdOas_.js +1 -0
  13. package/dist/assets/index-DR8StBp8.js +3 -0
  14. package/dist/assets/index-d0wlPvUz.css +1 -0
  15. package/dist/assets/report-Xynr6yGL.js +1 -0
  16. package/dist/assets/report-xVOo7qip.css +1 -0
  17. package/dist/assets/scheme-DJ3WNw60.js +1 -0
  18. package/dist/assets/sheet-CBYGM-FG.js +1 -0
  19. package/dist/assets/sheet-DYpaJaXO.css +1 -0
  20. package/dist/index.html +19 -0
  21. package/dist/report.html +16 -0
  22. package/dist/sheet.html +16 -0
  23. package/dist/vendor/popper.min.js +6 -0
  24. package/dist/vendor/tippy-bundle.umd.min.js +2 -0
  25. package/install.sh +69 -0
  26. package/lib/api.mjs +399 -0
  27. package/lib/backoff.mjs +2 -0
  28. package/lib/compose.mjs +67 -0
  29. package/lib/config.mjs +290 -0
  30. package/lib/default-table.mjs +58 -0
  31. package/lib/errors.mjs +53 -0
  32. package/lib/facts.mjs +128 -0
  33. package/lib/feedback.mjs +145 -0
  34. package/lib/handoff.mjs +127 -0
  35. package/lib/health.mjs +35 -0
  36. package/lib/http-client.mjs +44 -0
  37. package/lib/inbox.mjs +60 -0
  38. package/lib/linear-api.mjs +12 -0
  39. package/lib/notify.mjs +36 -0
  40. package/lib/palette.mjs +213 -0
  41. package/lib/platform/credential.ps1 +84 -0
  42. package/lib/registry.mjs +61 -0
  43. package/lib/rules.mjs +241 -0
  44. package/lib/secrets.mjs +69 -0
  45. package/lib/static.mjs +36 -0
  46. package/lib/store.mjs +61 -0
  47. package/lib/table.mjs +148 -0
  48. package/lib/text.mjs +46 -0
  49. package/lib/token-cache.mjs +12 -0
  50. package/lib/window.mjs +33 -0
  51. package/lib/workspace.mjs +294 -0
  52. package/open.sh +40 -0
  53. package/package.json +67 -4
  54. package/server.mjs +80 -0
package/lib/config.mjs ADDED
@@ -0,0 +1,290 @@
1
+ /** Loads the workspace config: data directory, defaults, and the list of workspaces, each with its connector entries and rules. */
2
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { isHex, derivedSeeds } from './palette.mjs';
6
+ import { statusGroups } from './facts.mjs';
7
+ import { tableProblems } from './table.mjs';
8
+ import { DEFAULT_LIMITS } from './health.mjs';
9
+ import { CONNECTORS } from '../connectors/index.mjs';
10
+
11
+ export const DEFAULT_RULES = {
12
+ release_branch_prefix: 'release/',
13
+ approved_next: 'Merge it',
14
+ approved_release_next: 'Hand over to QA',
15
+ passed_next: 'Merge it and close the ticket',
16
+ merged_next: 'Close the ticket',
17
+ statuses: {
18
+ passed: ['passed'],
19
+ code_review: ['code review', 'in review'],
20
+ qa: ['ready for qa', 'in qa', 'testing'],
21
+ on_hold: ['on hold', 'blocked'],
22
+ backlog: ['backlog'],
23
+ },
24
+ automation_authors: ['automation'],
25
+ backlog_rows: 'hide',
26
+ question_words: ['please', 'pls', 'can you', 'could you', 'would you', 'let me know', 'need you', 'need your', 'thoughts', 'recommend', 'advise', 'confirm', 'clarify'],
27
+ };
28
+
29
+ export const DEFAULT_KEY_PATTERN = '\\b[A-Z][A-Z0-9_]+-\\d+\\b';
30
+
31
+ /** What a settings form or Claude may edit on a workspace, beyond its connectors: one entry per field, neutral wording. */
32
+ export const WORKSPACE_FIELDS = {
33
+ workspace: [
34
+ { key: 'id', label: 'Id', hint: 'short, used in the API and on disk', required: true },
35
+ { key: 'name', label: 'Name' },
36
+ { key: 'key_pattern', label: 'Ticket key pattern', hint: 'regex for keys in PR titles and to-dos; empty matches any Jira-style key' },
37
+ { key: 'links', label: 'Quick links', hint: 'one per entry as "Label https://…", comma-separated; the repository, Jira site and Linear project come by themselves', list: true },
38
+ ],
39
+ theme: [
40
+ { key: 'seed', label: 'Seed colour', hint: 'the scheme is derived from it; empty derives one from the workspace id', colour: true },
41
+ { key: 'accent', label: 'Accent colour', hint: 'optional second hue', colour: true },
42
+ ],
43
+ rules: [
44
+ { key: 'approved_next', label: 'Approved PR', hint: 'next step when a PR under review is approved' },
45
+ { key: 'approved_release_next', label: 'Approved release PR', hint: 'same, for a PR into a branch with the release prefix' },
46
+ { key: 'release_branch_prefix', label: 'Release branch prefix' },
47
+ { key: 'passed_next', label: 'Ticket passed', hint: 'next step when the ticket reached a passed status' },
48
+ { key: 'merged_next', label: 'PR merged', hint: 'next step when the PR is merged and the ticket is still on you' },
49
+ { key: 'automation_authors', label: 'Automation authors', hint: 'comment authors to ignore', list: true },
50
+ { key: 'backlog_rows', label: 'Backlog tickets', hint: 'hide, or list: show them under Waiting on others as a plain task list, for a project you run alone' },
51
+ ],
52
+ statuses: [
53
+ { key: 'code_review', label: 'Review statuses', list: true },
54
+ { key: 'qa', label: 'Verification statuses', list: true },
55
+ { key: 'passed', label: 'Passed statuses', list: true },
56
+ { key: 'on_hold', label: 'On hold statuses', list: true },
57
+ { key: 'backlog', label: 'Backlog statuses', list: true },
58
+ ],
59
+ };
60
+
61
+ /** The per-platform directory that holds config.json and the state, unless WHOSE_TURN_DATA overrides it. */
62
+ export const defaultDataDir = () => {
63
+ if (process.platform === 'darwin') {
64
+ return join(homedir(), 'Library', 'Application Support', 'whose-turn');
65
+ }
66
+ if (process.platform === 'win32') {
67
+ return join(process.env.APPDATA || homedir(), 'whose-turn');
68
+ }
69
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'whose-turn');
70
+ };
71
+
72
+ const readJson = (path) => {
73
+ try {
74
+ return JSON.parse(readFileSync(path, 'utf8'));
75
+ } catch (error) {
76
+ return error.code === 'ENOENT' ? null : { parse_error: error.message };
77
+ }
78
+ };
79
+
80
+ /** The monitor's port: WHOSE_TURN_PORT, then `port` in the config file, then 8765. */
81
+ export const resolvePort = (dataDir, env = process.env) => {
82
+ if (env.WHOSE_TURN_PORT) {
83
+ return Number(env.WHOSE_TURN_PORT);
84
+ }
85
+ const raw = readJson(env.WHOSE_TURN_CONFIG || join(dataDir, 'config.json'));
86
+ return Number(raw?.port || 8765);
87
+ };
88
+
89
+ const mergeRules = (rules = {}) => ({
90
+ ...DEFAULT_RULES,
91
+ ...rules,
92
+ statuses: { ...DEFAULT_RULES.statuses, ...(rules.statuses || {}) },
93
+ });
94
+
95
+ const normaliseRules = (rules, problems, workspaceId) => {
96
+ const merged = /** @type {any} */ (mergeRules(rules));
97
+ const issues = tableProblems(merged.table, statusGroups(merged.statuses));
98
+ if (issues.length) {
99
+ problems.push(...issues.map((issue) => `Workspace "${workspaceId}": ${issue}; the table is ignored.`));
100
+ delete merged.table;
101
+ }
102
+ return merged;
103
+ };
104
+
105
+ const legacyWorkspace = (raw) => {
106
+ const connectors = [];
107
+ if (raw.github?.repo) {
108
+ connectors.push({ type: 'github', ...raw.github });
109
+ }
110
+ if (raw.jira?.base) {
111
+ connectors.push({ type: 'jira', ...raw.jira });
112
+ }
113
+ return { id: 'default', name: 'Default', connectors, rules: raw.rules, key_pattern: raw.jira?.key_pattern };
114
+ };
115
+
116
+ const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
117
+
118
+ const connectorEntries = (connectors) => {
119
+ const list = Array.isArray(connectors) ? connectors : Object.entries(connectors || {}).map(([type, settings]) => ({ type, ...settings }));
120
+ return list.filter(isObject).map((entry) => ({ ...entry, id: String(entry.id || entry.type) }));
121
+ };
122
+
123
+ const normaliseTheme = (theme, problems, workspaceId) => {
124
+ if (!theme) {
125
+ return null;
126
+ }
127
+ if (!isHex(theme.seed)) {
128
+ problems.push(`Workspace "${workspaceId}": theme.seed must be a hex colour like #3d7a5c; the theme is ignored.`);
129
+ return null;
130
+ }
131
+ if (theme.accent !== undefined && !isHex(theme.accent)) {
132
+ problems.push(`Workspace "${workspaceId}": theme.accent must be a hex colour like #c27a2c; the accent is ignored.`);
133
+ }
134
+ return { seed: theme.seed.toLowerCase(), ...(isHex(theme.accent) ? { accent: theme.accent.toLowerCase() } : {}) };
135
+ };
136
+
137
+ const normaliseLinks = (links) => (Array.isArray(links) ? links : []).map((entry) => {
138
+ if (typeof entry === 'string') {
139
+ const match = /^(.*?)\s*(https?:\/\/\S+)$/.exec(entry.trim());
140
+ return match ? { label: match[1].trim() || match[2].replace(/^https?:\/\//, ''), url: match[2] } : null;
141
+ }
142
+ return entry && /^https?:\/\//.test(String(entry.url || '')) ? { label: String(entry.label || entry.url), url: String(entry.url) } : null;
143
+ }).filter(Boolean);
144
+
145
+ const isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
146
+
147
+ const fieldProblems = (workspace, id) => {
148
+ const problems = [];
149
+ const where = `Workspace "${id}"`;
150
+ if (workspace.key_pattern) {
151
+ try {
152
+ new RegExp(workspace.key_pattern);
153
+ } catch (error) {
154
+ problems.push(`${where}: key_pattern is not a valid regular expression: ${error.message}`);
155
+ }
156
+ }
157
+ const rules = workspace.rules || {};
158
+ const statuses = rules.statuses;
159
+ if (statuses !== undefined && (!statuses || typeof statuses !== 'object' || Array.isArray(statuses))) {
160
+ problems.push(`${where}: rules.statuses must be an object of lists.`);
161
+ } else {
162
+ for (const [group, list] of Object.entries(statuses || {})) {
163
+ if (!isStringList(list) || list.some((item) => !item.trim())) {
164
+ problems.push(`${where}: rules.statuses.${group} must be a list of non-empty strings.`);
165
+ }
166
+ }
167
+ }
168
+ for (const key of ['automation_authors', 'question_words']) {
169
+ if (rules[key] !== undefined && !isStringList(rules[key])) {
170
+ problems.push(`${where}: rules.${key} must be a list of strings.`);
171
+ }
172
+ }
173
+ for (const entry of connectorEntries(workspace.connectors)) {
174
+ for (const field of CONNECTORS[entry.type]?.fields || []) {
175
+ if (field.boolean && entry[field.key] !== undefined && typeof entry[field.key] !== 'boolean') {
176
+ problems.push(`${where}: connector "${entry.id}": ${field.key} must be true or false.`);
177
+ }
178
+ }
179
+ }
180
+ return problems;
181
+ };
182
+
183
+ const WORKSPACE_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
184
+
185
+ const normaliseWorkspace = (workspace, index, problems) => {
186
+ const missing = workspace.id === undefined || workspace.id === null;
187
+ const id = String(missing ? `ws${index + 1}` : workspace.id);
188
+ if (!missing && !(typeof workspace.id === 'string' && WORKSPACE_ID.test(id))) {
189
+ problems.push(`Workspace ${index + 1}: the id "${id}" must be 1 to 40 lowercase letters, digits or hyphens, starting with a letter or digit.`);
190
+ }
191
+ problems.push(...fieldProblems(workspace, id));
192
+ return {
193
+ id,
194
+ links: normaliseLinks(workspace.links),
195
+ name: String(workspace.name || workspace.id || `Workspace ${index + 1}`),
196
+ connectors: connectorEntries(workspace.connectors),
197
+ rules: normaliseRules(workspace.rules, problems, id),
198
+ key_pattern: workspace.key_pattern || DEFAULT_KEY_PATTERN,
199
+ theme: normaliseTheme(workspace.theme, problems, id),
200
+ };
201
+ };
202
+
203
+ export const loadConfig = (env = process.env) => {
204
+ const dataDir = env.WHOSE_TURN_DATA || defaultDataDir();
205
+ const configFile = env.WHOSE_TURN_CONFIG || join(dataDir, 'config.json');
206
+ const raw = readJson(configFile);
207
+ const problems = [];
208
+ if (raw === null) {
209
+ problems.push(`No workspace config yet: open Settings (the gear) to add a source, or try the demo workspace. It will be written to ${configFile}.`);
210
+ } else if (raw.parse_error) {
211
+ problems.push(`Cannot parse ${configFile}: ${raw.parse_error}`);
212
+ }
213
+ const source = raw && !raw.parse_error ? raw : {};
214
+ const rawWorkspaces = Array.isArray(source.workspaces) ? source.workspaces : [legacyWorkspace(source)];
215
+ const { workspaces: normalised, problems: shapeProblems } = normaliseWorkspaces(rawWorkspaces);
216
+ const workspaces = normalised.filter((workspace) => WORKSPACE_ID.test(workspace.id));
217
+ return {
218
+ dataDir,
219
+ configFile,
220
+ port: resolvePort(dataDir, env),
221
+ pollSeconds: Number(source.poll_seconds || 60),
222
+ historyDays: Number(source.history_days || 14),
223
+ health: normaliseHealth(source, problems),
224
+ feedback: source.feedback && typeof source.feedback === 'object' ? source.feedback : null,
225
+ workspaces,
226
+ rawWorkspaces,
227
+ problems: [...problems, ...shapeProblems],
228
+ };
229
+ };
230
+
231
+ const normaliseHealth = (source, problems) => Object.fromEntries(Object.entries(DEFAULT_LIMITS).map(([key, fallback]) => {
232
+ const value = source[key] === undefined ? fallback : Number(source[key]);
233
+ if (!(value > 0)) {
234
+ problems.push(`${key} must be a positive number; using ${fallback}.`);
235
+ return [key, fallback];
236
+ }
237
+ return [key, value];
238
+ }));
239
+
240
+ /** 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. */
241
+ export const entryShapeViolations = (rawWorkspaces) => (Array.isArray(rawWorkspaces) ? rawWorkspaces : []).flatMap((workspace, index) => {
242
+ if (!isObject(workspace)) {
243
+ return [{ field: `workspaces[${index}]`, description: 'Every workspace must be an object.' }];
244
+ }
245
+ return (Array.isArray(workspace.connectors) ? workspace.connectors : []).flatMap((entry, at) => (isObject(entry)
246
+ ? []
247
+ : [{ field: `workspaces[${index}].connectors[${at}]`, description: 'Every connector must be an object.' }]));
248
+ });
249
+
250
+ /** Normalises a raw workspace list and reports what is wrong with it, without touching disk. */
251
+ export const normaliseWorkspaces = (rawWorkspaces) => {
252
+ const problems = [];
253
+ const list = Array.isArray(rawWorkspaces) ? rawWorkspaces : [];
254
+ const workspaces = list.map((workspace, index) => normaliseWorkspace(isObject(workspace) ? workspace : {}, index, problems));
255
+ const seeds = derivedSeeds(workspaces.map((workspace) => ({ id: workspace.id, seed: workspace.theme?.seed })));
256
+ for (const workspace of workspaces) {
257
+ if (seeds[workspace.id]) {
258
+ workspace.derived_seed = seeds[workspace.id];
259
+ }
260
+ }
261
+ const seen = new Set();
262
+ for (const workspace of workspaces) {
263
+ if (seen.has(workspace.id)) {
264
+ problems.push(`Two workspaces share the id "${workspace.id}".`);
265
+ }
266
+ seen.add(workspace.id);
267
+ const connectorIds = workspace.connectors.map((entry) => entry.id);
268
+ if (new Set(connectorIds).size !== connectorIds.length) {
269
+ problems.push(`Workspace "${workspace.id}" has two connectors with the same id; give each an "id".`);
270
+ }
271
+ }
272
+ return { workspaces, problems };
273
+ };
274
+
275
+ /** 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. */
276
+ export const saveWorkspaces = (configFile, rawWorkspaces) => {
277
+ const current = readJson(configFile);
278
+ const source = current && !current.parse_error ? current : {};
279
+ delete source.github;
280
+ delete source.jira;
281
+ delete source.rules;
282
+ const next = { ...source, workspaces: rawWorkspaces };
283
+ mkdirSync(dirname(configFile), { recursive: true });
284
+ if (existsSync(configFile)) {
285
+ writeFileSync(`${configFile}.bak.tmp`, readFileSync(configFile));
286
+ renameSync(`${configFile}.bak.tmp`, `${configFile}.bak`);
287
+ }
288
+ writeFileSync(`${configFile}.tmp`, `${JSON.stringify(next, null, 2)}\n`);
289
+ renameSync(`${configFile}.tmp`, configFile);
290
+ };
@@ -0,0 +1,58 @@
1
+ /** The plugin's default rules table, built from a workspace's rule texts; a workspace table replaces it or places it with the "default" marker. See docs/mechanisms/rules-table.md. */
2
+
3
+ export const defaultTable = (rules) => [
4
+ {
5
+ when: { 'run.failed': true, 'run.mine': true },
6
+ then: { whose: 'you', next: 'Look at the failed run of {run.workflow} on {run.branch}', why: 'The latest {run.workflow} run on {run.branch} failed after your push.', rank: 0 },
7
+ },
8
+ { when: { 'run.failed': true }, then: { whose: 'hidden' } },
9
+ {
10
+ when: { 'ticket.status': 'backlog', 'ticket.done': false, 'pr.open': false },
11
+ then: rules.backlog_rows === 'list' ? { whose: 'them', next: '{ticket.status_name}' } : { whose: 'hidden' },
12
+ },
13
+ {
14
+ when: { 'pr.open': true, 'pr.checks': 'failing' },
15
+ then: { whose: 'you', next: 'Look at the failing checks', why: 'PR #{pr.number} has failing checks.', rank: 0 },
16
+ },
17
+ {
18
+ when: { 'pr.open': true, 'pr.review': 'changes_requested' },
19
+ then: { whose: 'you', next: 'Address the requested changes', why: 'A reviewer requested changes on PR #{pr.number}.', rank: 1 },
20
+ },
21
+ {
22
+ when: { 'pr.open': true, 'pr.unanswered': true },
23
+ then: { whose: 'you', next: 'Read and answer the new comment', why: '{pr.commenter} commented on PR #{pr.number} after your last reply there.' },
24
+ },
25
+ {
26
+ when: { 'ticket.status': 'passed' },
27
+ then: { whose: 'you', next: rules.passed_next, why: 'Ticket status is {ticket.status_name}: QA passed, the PR can go in.' },
28
+ },
29
+ {
30
+ when: { 'ticket.done': true, 'pr.open': true },
31
+ then: { whose: 'you', next: 'Ticket is Done but the PR is open: merge it', why: 'The ticket is {ticket.status_name} while PR #{pr.number} is still open.' },
32
+ },
33
+ {
34
+ when: { 'ticket.done': false, 'pr.merged': true, 'ticket.assignee': 'me' },
35
+ then: { whose: 'you', next: rules.merged_next, why: 'PR #{pr.number} is merged but the ticket is still {ticket.status_name} and on you.' },
36
+ },
37
+ {
38
+ when: { 'ticket.status': 'code_review', 'pr.open': true, 'pr.review': 'approved', 'pr.base': { prefix: rules.release_branch_prefix } },
39
+ then: { whose: 'you', next: rules.approved_release_next, why: 'PR #{pr.number} into {pr.base} is approved; the ticket is still in {ticket.status_name}.' },
40
+ },
41
+ {
42
+ when: { 'ticket.status': 'code_review', 'pr.open': true, 'pr.review': 'approved' },
43
+ then: { whose: 'you', next: rules.approved_next, why: 'PR #{pr.number} into {pr.base} is approved; the ticket is still in {ticket.status_name}.' },
44
+ },
45
+ { when: { 'ticket.status': 'code_review' }, then: { whose: 'reviewer', next: 'Code review' } },
46
+ { when: { 'ticket.status': 'qa' }, then: { whose: 'them', next: 'QA' } },
47
+ { when: { 'ticket.status': 'on_hold', 'ticket.hold_reason': { present: true } }, then: { whose: 'them', next: 'On hold: {ticket.hold_reason}', hold: true } },
48
+ { when: { 'ticket.status': 'on_hold' }, then: { whose: 'them', next: 'On hold' } },
49
+ {
50
+ when: { 'ticket.done': false, 'ticket.assignee': 'me', 'ticket.started': true },
51
+ then: { whose: 'you', next: 'Continue the work', why: 'Assigned to you and in progress, status {ticket.status_name}, no PR step pending.', rank: 2, started: true },
52
+ },
53
+ {
54
+ when: { 'ticket.done': false, 'ticket.assignee': 'me' },
55
+ then: { whose: 'you', next: 'Pick it up', why: 'Assigned to you, status {ticket.status_name}, no PR step pending.' },
56
+ },
57
+ { when: {}, then: { whose: 'them', next: 'Not yours now' } },
58
+ ];
package/lib/errors.mjs ADDED
@@ -0,0 +1,53 @@
1
+ /** Error envelope (google.rpc.Status) and the reason registry for the local API. */
2
+
3
+ const REASONS = {
4
+ NOT_FOUND: { code: 404, status: 'NOT_FOUND', message: 'No such path.' },
5
+ NO_SUCH_WORKSPACE: { code: 404, status: 'NOT_FOUND', message: 'No such workspace.' },
6
+ NO_SUCH_TODO: { code: 404, status: 'NOT_FOUND', message: 'No such to-do.' },
7
+ METHOD_NOT_ALLOWED: { code: 405, status: 'UNIMPLEMENTED', message: 'This path answers GET and POST only.' },
8
+ UNKNOWN_ACTION: { code: 400, status: 'INVALID_ARGUMENT', message: 'Unknown action.' },
9
+ INVALID_BODY: { code: 400, status: 'INVALID_ARGUMENT', message: 'The request body is not valid JSON.' },
10
+ INVALID_ARGUMENT: { code: 400, status: 'INVALID_ARGUMENT', message: 'A required value is missing or has the wrong shape.' },
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.' },
14
+ UPSTREAM_FAILED: { code: 502, status: 'UNAVAILABLE', message: 'The tracker did not answer; try again in a minute.' },
15
+ RATE_LIMITED: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'The source is rate-limited; try again later.' },
16
+ OPEN_TOO_SOON: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'The window was just opened; wait a few seconds before opening it again.' },
17
+ FORBIDDEN_ORIGIN: { code: 403, status: 'PERMISSION_DENIED', message: 'Only the local window may call this API.' },
18
+ INTERNAL: { code: 500, status: 'INTERNAL', message: 'The local server failed on this request; the details are in its log.' },
19
+ };
20
+
21
+ export class ApiError extends Error {
22
+ constructor(reason, metadata = {}, violations = []) {
23
+ const entry = REASONS[reason];
24
+ super(entry.message);
25
+ this.reason = reason;
26
+ this.code = entry.code;
27
+ this.status = entry.status;
28
+ this.metadata = metadata;
29
+ this.violations = violations;
30
+ this.debug = '';
31
+ }
32
+
33
+ toEnvelope() {
34
+ const details = /** @type {object[]} */ ([{ '@type': 'ErrorInfo', reason: this.reason, domain: 'whose-turn', metadata: this.metadata }]);
35
+ if (this.violations.length) {
36
+ details.push({ '@type': 'BadRequest', field_violations: this.violations.map((violation) => (typeof violation === 'string' ? { field: 'workspaces', description: violation } : violation)) });
37
+ }
38
+ if (this.debug && process.env.WHOSE_TURN_DEBUG === '1') {
39
+ details.push({ '@type': 'DebugInfo', detail: this.debug });
40
+ }
41
+ return { error: { code: this.code, status: this.status, message: this.message, details } };
42
+ }
43
+ }
44
+
45
+ /** The envelope of any failure: an ApiError as it is, anything else as INTERNAL with the raw text only under DebugInfo. */
46
+ export const toApiError = (error) => {
47
+ if (error instanceof ApiError) {
48
+ return error;
49
+ }
50
+ const internal = new ApiError('INTERNAL');
51
+ internal.debug = String((error && error.message) || error);
52
+ return internal;
53
+ };
package/lib/facts.mjs ADDED
@@ -0,0 +1,128 @@
1
+ /** The closed fact dictionary the rules table reads: one flat object per ticket, computed from the normalized connector output. See docs/mechanisms/rules-table.md. */
2
+ import { DAY, oneline, newestFirst } from './text.mjs';
3
+
4
+ export const shortDate = (iso) => new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
5
+
6
+ const CATEGORY_GROUPS = { new: 'todo', indeterminate: 'in_progress', done: 'done' };
7
+ const CHAIN_GROUPS = ['backlog', 'passed', 'code_review', 'qa', 'on_hold'];
8
+
9
+ /** Every fact a table row may test or quote: its type, the closed values when there are any, and where it comes from. */
10
+ export const FACTS = {
11
+ connector: { type: 'string', from: 'the connector entry id the ticket came from' },
12
+ 'ticket.key': { type: 'string', from: 'tracker connectors (jira, linear, demo)' },
13
+ 'ticket.status': { type: 'string', values: 'status groups', from: 'the ticket status matched against rules.statuses; else todo, in_progress or done from the status category' },
14
+ 'ticket.status_name': { type: 'string', from: 'the status as the tracker names it' },
15
+ 'ticket.assignee': { type: 'string', from: '"me" when the ticket is on you, else the assignee name' },
16
+ 'ticket.done': { type: 'boolean', from: 'the status category is done' },
17
+ 'ticket.started': { type: 'boolean', from: 'the status category is in progress' },
18
+ 'ticket.hold_reason': { type: 'string', from: 'the comment that explains an on_hold status; missing when none does' },
19
+ 'pr.open': { type: 'boolean', from: 'github: the ticket has an open PR; false when it has none' },
20
+ 'pr.merged': { type: 'boolean', from: 'github: the ticket PR is merged; false when it has none' },
21
+ 'pr.number': { type: 'number', from: 'github' },
22
+ 'pr.checks': { type: 'string', values: ['passing', 'failing', 'running', 'none'], from: 'github' },
23
+ 'pr.review': { type: 'string', values: ['approved', 'changes_requested', 'none'], from: 'github' },
24
+ 'pr.base': { type: 'string', from: 'github: the branch the PR goes into' },
25
+ 'pr.unanswered': { type: 'boolean', from: 'github: a human commented after your last comment and it is not an approval' },
26
+ 'pr.commenter': { type: 'string', from: 'github: the author of that last comment' },
27
+ 'comment.mentions_me': { type: 'boolean', from: 'tracker comments: an unanswered @-mention of you on the ticket' },
28
+ 'comment.is_question': { type: 'boolean', from: 'the newest unanswered mention reads as a question' },
29
+ 'comment.author': { type: 'string', from: 'the author of the newest unanswered mention' },
30
+ unanswered_count: { type: 'number', from: 'how many @-mentions of you on the ticket are unanswered' },
31
+ 'run.failed': { type: 'boolean', from: 'github: the latest completed run of a workflow on the default branch failed; missing on tickets' },
32
+ 'run.workflow': { type: 'string', from: 'github: the name of that workflow' },
33
+ 'run.branch': { type: 'string', from: 'github: the branch it ran on, the default branch' },
34
+ 'run.mine': { type: 'boolean', from: 'github: you pushed the commit or started the run' },
35
+ };
36
+
37
+ /** The order status words are tried in: the chain's groups first, then any extra group in rules.statuses. */
38
+ export const statusGroupOrder = (statuses) => [...new Set([...CHAIN_GROUPS.filter((group) => group in statuses), ...Object.keys(statuses)])];
39
+
40
+ /** Every value ticket.status can take: the configured groups, then the category fallbacks. */
41
+ export const statusGroups = (statuses) => [...new Set([...statusGroupOrder(statuses), ...Object.values(CATEGORY_GROUPS)])];
42
+
43
+ /** The chain group a row carries as status_group for the window's pill tone: one of the chain's groups, else ''. */
44
+ export const chainGroup = (group) => (CHAIN_GROUPS.includes(group) ? group : '');
45
+
46
+ const statusGroupOf = (ticket, rules) => rules.statusOrder.find((group) => rules.statusIs(group, ticket.status)) || CATEGORY_GROUPS[ticket.status_category] || null;
47
+
48
+ const firstSentence = (body) => {
49
+ const line = body.split('\n').map((candidate) => candidate.trim()).find((candidate) => candidate && !/^\[image\]$/.test(candidate)) || '';
50
+ const stripped = line.replace(/^@[\w .'-]+?\s+[—–-]\s+/, '').replace(/^.*?on hold\s*[:—–-]\s*/i, '').trim() || line;
51
+ return oneline(stripped.split(/(?<=[.!?])\s/)[0].replace(/\*\*|`/g, ''), 140);
52
+ };
53
+
54
+ export const holdReason = (ticketComments, ticket) => {
55
+ const change = ticket.status_change;
56
+ let chosen = null;
57
+ if (change && change.since) {
58
+ const holdTime = new Date(change.since).getTime();
59
+ const byMover = ticketComments.filter((comment) => comment.author === change.by).sort((a, b) => Math.abs(new Date(a.at).getTime() - holdTime) - Math.abs(new Date(b.at).getTime() - holdTime))[0];
60
+ if (byMover && Math.abs(new Date(byMover.at).getTime() - holdTime) < 2 * DAY) {
61
+ chosen = byMover;
62
+ }
63
+ if (!chosen) {
64
+ chosen = newestFirst(ticketComments.filter((comment) => new Date(comment.at).getTime() <= holdTime + 10 * 60000))[0] || null;
65
+ }
66
+ }
67
+ if (!chosen) {
68
+ chosen = newestFirst(ticketComments.filter((comment) => /on hold/i.test(comment.body)))[0] || null;
69
+ }
70
+ if (!chosen) {
71
+ return null;
72
+ }
73
+ return { text: firstSentence(chosen.body), full: `${chosen.author} · ${shortDate(chosen.at)}\n\n${chosen.body.trim().slice(0, 1500)}`, url: chosen.url };
74
+ };
75
+
76
+ const prFacts = (pr) => {
77
+ if (!pr) {
78
+ return { 'pr.open': false, 'pr.merged': false };
79
+ }
80
+ return {
81
+ 'pr.open': pr.state === 'OPEN',
82
+ 'pr.merged': pr.state === 'MERGED',
83
+ 'pr.number': pr.pr,
84
+ 'pr.checks': pr.checks || null,
85
+ 'pr.review': pr.review ? pr.review.toLowerCase() : 'none',
86
+ 'pr.base': pr.base || null,
87
+ 'pr.unanswered': Boolean(pr.unanswered),
88
+ 'pr.commenter': pr.last_comment_by || null,
89
+ };
90
+ };
91
+
92
+ const commentFacts = (mentions) => {
93
+ const newest = mentions[mentions.length - 1];
94
+ return {
95
+ 'comment.mentions_me': Boolean(newest),
96
+ 'comment.is_question': newest ? newest.question : null,
97
+ 'comment.author': newest ? newest.comment.author : null,
98
+ unanswered_count: mentions.length,
99
+ };
100
+ };
101
+
102
+ /** The facts of one failing workflow run (RMA-152); the ticket and PR facts are missing, so ticket rows never match it. */
103
+ export const runFacts = (run) => ({
104
+ connector: run.source || null,
105
+ 'run.failed': Boolean(run.failed),
106
+ 'run.workflow': run.workflow,
107
+ 'run.branch': run.branch,
108
+ 'run.mine': Boolean(run.mine),
109
+ });
110
+
111
+ /** The facts of one ticket with its chosen PR, its comments and its open mentions of you (oldest first); the hold reason is returned whole for the row's link and tooltip. */
112
+ export const ticketFacts = (ticket, pr, { myName, rules, comments = [], mentions = [] }) => {
113
+ const group = statusGroupOf(ticket, rules);
114
+ const reason = group === 'on_hold' ? holdReason(comments, ticket) : null;
115
+ const facts = {
116
+ connector: ticket.source || null,
117
+ 'ticket.key': ticket.key,
118
+ 'ticket.status': group,
119
+ 'ticket.status_name': ticket.status,
120
+ 'ticket.assignee': myName && ticket.assignee === myName ? 'me' : ticket.assignee || null,
121
+ 'ticket.done': ticket.status_category === 'done',
122
+ 'ticket.started': ticket.status_category === 'indeterminate',
123
+ 'ticket.hold_reason': reason ? reason.text : null,
124
+ ...prFacts(pr),
125
+ ...commentFacts(mentions),
126
+ };
127
+ return { facts, reason };
128
+ };