whose-turn 0.0.0-stage → 1.0.0-alpha.3

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 (55) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +244 -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-COKTjsky.js +1 -0
  13. package/dist/assets/index-CuyWocyb.js +3 -0
  14. package/dist/assets/index-p8D7awn0.css +1 -0
  15. package/dist/assets/report-BtGnm9ax.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 +406 -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 +55 -0
  32. package/lib/facts.mjs +128 -0
  33. package/lib/feedback.mjs +148 -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/update.mjs +134 -0
  51. package/lib/window.mjs +33 -0
  52. package/lib/workspace.mjs +294 -0
  53. package/open.sh +40 -0
  54. package/package.json +67 -4
  55. package/server.mjs +96 -0
@@ -0,0 +1,213 @@
1
+ /** Derives a Material-3-style light and dark scheme from one seed colour, as the CSS variables style.css defines. RMA-93. */
2
+
3
+ const HEX = /^#[0-9a-f]{6}$/i;
4
+
5
+ export const isHex = (value) => typeof value === 'string' && HEX.test(value);
6
+
7
+ const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
8
+
9
+ const toLinear = (channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
10
+ const fromLinear = (channel) => (channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055);
11
+
12
+ const hexToRgb = (hex) => [1, 3, 5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255);
13
+ const rgbToHex = (rgb) => `#${rgb.map((channel) => Math.round(clamp(channel, 0, 1) * 255).toString(16).padStart(2, '0')).join('')}`;
14
+
15
+ const rgbToOklch = (rgb) => {
16
+ const [red, green, blue] = rgb.map(toLinear);
17
+ const long = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue);
18
+ const medium = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue);
19
+ const short = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue);
20
+ const lightness = 0.2104542553 * long + 0.793617785 * medium - 0.0040720468 * short;
21
+ const a = 1.9779984951 * long - 2.428592205 * medium + 0.4505937099 * short;
22
+ const b = 0.0259040371 * long + 0.7827717662 * medium - 0.808675766 * short;
23
+ return { lightness, chroma: Math.hypot(a, b), hue: (Math.atan2(b, a) * 180 / Math.PI + 360) % 360 };
24
+ };
25
+
26
+ const oklchToLinear = ({ lightness, chroma, hue }) => {
27
+ const a = chroma * Math.cos(hue * Math.PI / 180);
28
+ const b = chroma * Math.sin(hue * Math.PI / 180);
29
+ const long = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
30
+ const medium = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
31
+ const short = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
32
+ return [
33
+ 4.0767416621 * long - 3.3077115913 * medium + 0.2309699292 * short,
34
+ -1.2684380046 * long + 2.6097574011 * medium - 0.3413193965 * short,
35
+ -0.0041960863 * long - 0.7034186147 * medium + 1.707614701 * short,
36
+ ];
37
+ };
38
+
39
+ const inGamut = (linear) => linear.every((channel) => channel >= -0.0001 && channel <= 1.0001);
40
+
41
+ const oklchToRgb = (colour) => {
42
+ let low = 0;
43
+ let high = colour.chroma;
44
+ if (inGamut(oklchToLinear(colour))) {
45
+ return oklchToLinear(colour).map(fromLinear);
46
+ }
47
+ for (let step = 0; step < 24; step += 1) {
48
+ const middle = (low + high) / 2;
49
+ if (inGamut(oklchToLinear({ ...colour, chroma: middle }))) {
50
+ low = middle;
51
+ } else {
52
+ high = middle;
53
+ }
54
+ }
55
+ return oklchToLinear({ ...colour, chroma: low }).map(fromLinear);
56
+ };
57
+
58
+ const tone = (hue, chroma, lightness) => rgbToHex(oklchToRgb({ hue, chroma, lightness: clamp(lightness, 0, 1) }));
59
+
60
+ const GOLDEN_ANGLE = 137.508;
61
+
62
+ const DERIVED_CHROMA = 0.13;
63
+ const DERIVED_LIGHTNESS = 0.5;
64
+ const MIN_HUE_GAP = 40;
65
+
66
+ const hueOfId = (id) => {
67
+ let hash = 2166136261;
68
+ for (const char of String(id)) {
69
+ hash = Math.imul(hash ^ char.codePointAt(0), 16777619) >>> 0;
70
+ }
71
+ return (hash * GOLDEN_ANGLE) % 360;
72
+ };
73
+
74
+ /**
75
+ * Seeds for the workspaces that set none, keyed by id: each id hashes to a hue, and a hue that lands within
76
+ * MIN_HUE_GAP of a hue already taken (a configured seed, or an earlier workspace in the list) steps on by the
77
+ * golden angle until it is clear. Earlier entries never move when a later one is added. RMA-212.
78
+ */
79
+ export const derivedSeeds = (entries) => {
80
+ const taken = entries.filter((entry) => isHex(entry.seed)).map((entry) => rgbToOklch(hexToRgb(entry.seed)).hue);
81
+ const seeds = {};
82
+ for (const entry of entries) {
83
+ if (isHex(entry.seed)) {
84
+ continue;
85
+ }
86
+ let hue = hueOfId(entry.id);
87
+ for (let step = 0; step < entries.length && taken.some((other) => Math.abs(hueDistance(hue, other)) < MIN_HUE_GAP); step += 1) {
88
+ hue = (hue + GOLDEN_ANGLE) % 360;
89
+ }
90
+ taken.push(hue);
91
+ seeds[entry.id] = tone(hue, DERIVED_CHROMA, DERIVED_LIGHTNESS);
92
+ }
93
+ return seeds;
94
+ };
95
+
96
+ /** The seed one workspace gets on its own, with nothing else to keep clear of. */
97
+ export const derivedSeed = (id) => derivedSeeds([{ id }])[id];
98
+
99
+ const luminance = (hex) => {
100
+ const [red, green, blue] = hexToRgb(hex).map(toLinear);
101
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
102
+ };
103
+
104
+ export const contrastRatio = (first, second) => {
105
+ const [lighter, darker] = [luminance(first), luminance(second)].sort((x, y) => y - x);
106
+ return (lighter + 0.05) / (darker + 0.05);
107
+ };
108
+
109
+ const MIN_CONTRAST = 4.5;
110
+
111
+ const readable = (ink, ground) => {
112
+ const towardLight = ground.lightness < 0.6;
113
+ const step = towardLight ? 0.01 : -0.01;
114
+ let inkLightness = ink.lightness;
115
+ let groundLightness = ground.lightness;
116
+ const hexes = () => [tone(ink.hue, ink.chroma, inkLightness), tone(ground.hue, ground.chroma, groundLightness)];
117
+ for (let attempt = 0; attempt < 200 && contrastRatio(...hexes()) < MIN_CONTRAST; attempt += 1) {
118
+ if (inkLightness > 0 && inkLightness < 1) {
119
+ inkLightness = clamp(inkLightness + step, 0, 1);
120
+ } else {
121
+ groundLightness = clamp(groundLightness - step, 0, 1);
122
+ }
123
+ }
124
+ const [inkHex, groundHex] = hexes();
125
+ return { ink: inkHex, ground: groundHex };
126
+ };
127
+
128
+ const channels = (hex) => hexToRgb(hex).map((channel) => Math.round(channel * 255)).join(', ');
129
+ const rgba = (hex, alpha) => `rgba(${channels(hex)}, ${alpha})`;
130
+
131
+ const LADDERS = {
132
+ light: {
133
+ primary: [0.5, 0.15], onPrimary: [1, 0], primaryContainer: [0.92, 0.05], onPrimaryContainer: [0.26, 0.09],
134
+ secondaryContainer: [0.92, 0.025], onSecondaryContainer: [0.26, 0.03],
135
+ tertiaryContainer: [0.92, 0.05], onTertiaryContainer: [0.26, 0.07],
136
+ surface: [0.985, 0.006], sLow: [1, 0], s: [0.965, 0.008], sHigh: [0.935, 0.01], sHighest: [0.915, 0.012],
137
+ onSurface: [0.22, 0.01], onSurfaceVariant: [0.42, 0.018], outline: [0.58, 0.018], outlineVariant: [0.87, 0.014],
138
+ glass: [0.995, 0.004],
139
+ blobs: [[0.52, 0.16, 0.3], [0.62, 0.14, 0.22], [0.78, 0.12, 0.16]],
140
+ error: [0.5, 0.19], errorContainer: [0.92, 0.05], onErrorContainer: [0.22, 0.1],
141
+ },
142
+ dark: {
143
+ primary: [0.8, 0.1], onPrimary: [0.3, 0.08], primaryContainer: [0.38, 0.09], onPrimaryContainer: [0.92, 0.04],
144
+ secondaryContainer: [0.37, 0.03], onSecondaryContainer: [0.92, 0.025],
145
+ tertiaryContainer: [0.37, 0.06], onTertiaryContainer: [0.92, 0.05],
146
+ surface: [0.17, 0.01], sLow: [0.215, 0.011], s: [0.23, 0.011], sHigh: [0.27, 0.012], sHighest: [0.31, 0.012],
147
+ onSurface: [0.92, 0.008], onSurfaceVariant: [0.83, 0.015], outline: [0.65, 0.016], outlineVariant: [0.35, 0.014],
148
+ glass: [0.235, 0.016],
149
+ blobs: [[0.45, 0.13, 0.55], [0.58, 0.13, 0.28], [0.72, 0.11, 0.14]],
150
+ error: [0.8, 0.12], errorContainer: [0.38, 0.12], onErrorContainer: [0.92, 0.05],
151
+ },
152
+ };
153
+
154
+ const ERROR_HUE = 27;
155
+ const ERROR_CLEARANCE = 45;
156
+ const COOLEST_ERROR_HUE = 12;
157
+ const CROWDED_ERROR = { light: { lightness: -0.1, chroma: 0.03 }, dark: { lightness: -0.14, chroma: 0.09 } };
158
+
159
+ const hueDistance = (from, to) => ((to - from + 540) % 360) - 180;
160
+
161
+ const scheme = (ladder, hues, crowding) => {
162
+ const at = (hue, [lightness, chroma]) => ({ hue, chroma: Math.min(chroma, hues.chroma * 1.4 + 0.004), lightness });
163
+ const pair = (hue, inkStep, groundStep) => readable(at(hue, inkStep), at(hue, groundStep));
164
+ const primary = pair(hues.primary, ladder.onPrimary, ladder.primary);
165
+ const primaryContainer = pair(hues.primary, ladder.onPrimaryContainer, ladder.primaryContainer);
166
+ const secondaryContainer = pair(hues.primary, ladder.onSecondaryContainer, ladder.secondaryContainer);
167
+ const tertiaryContainer = pair(hues.tertiary, ladder.onTertiaryContainer, ladder.tertiaryContainer);
168
+ const surface = pair(hues.primary, ladder.onSurface, ladder.surface);
169
+ const surfaceVariant = pair(hues.primary, ladder.onSurfaceVariant, ladder.sLow);
170
+ const hueOf = ([lightness, chroma]) => ({ hue: hues.error, chroma, lightness });
171
+ const [errorLightness, errorChroma] = ladder.error;
172
+ const crowd = hues.crowded ? crowding : { lightness: 0, chroma: 0 };
173
+ const error = tone(hues.error, errorChroma + crowd.chroma, errorLightness + crowd.lightness);
174
+ const errorContainer = readable(hueOf(ladder.onErrorContainer), hueOf(ladder.errorContainer));
175
+ const plain = (step) => tone(hues.primary, at(hues.primary, step).chroma, step[0]);
176
+ const blobHues = [hues.primary, hues.blend, hues.tertiary];
177
+ return {
178
+ '--md-primary': primary.ground, '--md-on-primary': primary.ink,
179
+ '--md-primary-container': primaryContainer.ground, '--md-on-primary-container': primaryContainer.ink,
180
+ '--md-secondary-container': secondaryContainer.ground, '--md-on-secondary-container': secondaryContainer.ink,
181
+ '--md-tertiary-container': tertiaryContainer.ground, '--md-on-tertiary-container': tertiaryContainer.ink,
182
+ '--md-surface': surface.ground, '--md-s-low': surfaceVariant.ground, '--md-s': plain(ladder.s),
183
+ '--md-s-high': plain(ladder.sHigh), '--md-s-highest': plain(ladder.sHighest),
184
+ '--md-on-surface': surface.ink, '--md-on-surface-variant': surfaceVariant.ink,
185
+ '--md-outline': plain(ladder.outline), '--md-outline-variant': plain(ladder.outlineVariant),
186
+ '--md-error': error, '--md-error-container': errorContainer.ground, '--md-on-error-container': errorContainer.ink,
187
+ '--md-state': channels(surface.ink),
188
+ '--glass': channels(plain(ladder.glass)),
189
+ ...Object.fromEntries(ladder.blobs.map(([lightness, chroma, alpha], index) => [`--blob-${index + 1}`, rgba(tone(blobHues[index], chroma, lightness), alpha)])),
190
+ };
191
+ };
192
+
193
+ /** The error hue: red, turned cooler to crimson once the primary hue would sit within the clearance of it. */
194
+ const errorHueFor = (primaryHue) => (Math.abs(hueDistance(primaryHue, ERROR_HUE)) < ERROR_CLEARANCE ? (Math.max(hueDistance(ERROR_HUE, primaryHue) + ERROR_HUE - ERROR_CLEARANCE, COOLEST_ERROR_HUE) + 360) % 360 : ERROR_HUE);
195
+
196
+ /** The light and dark variable maps for a seed hex and an optional accent hex that sets the tertiary hue. */
197
+ export const paletteFrom = (seedHex, accentHex) => {
198
+ if (!isHex(seedHex)) {
199
+ return null;
200
+ }
201
+ const seed = rgbToOklch(hexToRgb(seedHex));
202
+ const accent = isHex(accentHex) ? rgbToOklch(hexToRgb(accentHex)) : null;
203
+ const tertiary = accent ? accent.hue : (seed.hue + 80) % 360;
204
+ const hues = {
205
+ primary: seed.hue,
206
+ error: errorHueFor(seed.hue),
207
+ crowded: Math.abs(hueDistance(seed.hue, ERROR_HUE)) < ERROR_CLEARANCE,
208
+ tertiary,
209
+ blend: (seed.hue + hueDistance(seed.hue, tertiary) / 2 + 360) % 360,
210
+ chroma: Math.max(seed.chroma, 0.02),
211
+ };
212
+ return { light: scheme(LADDERS.light, hues, CROWDED_ERROR.light), dark: scheme(LADDERS.dark, hues, CROWDED_ERROR.dark) };
213
+ };
@@ -0,0 +1,84 @@
1
+ # Windows Credential Manager access for lib/secrets.mjs. Usage: credential.ps1 get|set <service> <account>; `set` reads the value from stdin.
2
+ param([string]$Action, [string]$Service, [string]$Account)
3
+
4
+ Add-Type -TypeDefinition @'
5
+ using System;
6
+ using System.Runtime.InteropServices;
7
+ using System.Runtime.InteropServices.ComTypes;
8
+
9
+ public static class WhoseTurnCredential {
10
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
11
+ struct CREDENTIAL {
12
+ public UInt32 Flags;
13
+ public UInt32 Type;
14
+ public string TargetName;
15
+ public string Comment;
16
+ public FILETIME LastWritten;
17
+ public UInt32 CredentialBlobSize;
18
+ public IntPtr CredentialBlob;
19
+ public UInt32 Persist;
20
+ public UInt32 AttributeCount;
21
+ public IntPtr Attributes;
22
+ public string TargetAlias;
23
+ public string UserName;
24
+ }
25
+
26
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
27
+ static extern bool CredRead(string target, UInt32 type, UInt32 flags, out IntPtr credential);
28
+
29
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
30
+ static extern bool CredWrite(ref CREDENTIAL credential, UInt32 flags);
31
+
32
+ [DllImport("advapi32.dll")]
33
+ static extern void CredFree(IntPtr buffer);
34
+
35
+ const UInt32 CRED_TYPE_GENERIC = 1;
36
+ const UInt32 CRED_PERSIST_LOCAL_MACHINE = 2;
37
+
38
+ public static string Read(string target) {
39
+ IntPtr pointer;
40
+ if (!CredRead(target, CRED_TYPE_GENERIC, 0, out pointer)) {
41
+ return null;
42
+ }
43
+ try {
44
+ CREDENTIAL credential = (CREDENTIAL)Marshal.PtrToStructure(pointer, typeof(CREDENTIAL));
45
+ return Marshal.PtrToStringUni(credential.CredentialBlob, (int)credential.CredentialBlobSize / 2);
46
+ } finally {
47
+ CredFree(pointer);
48
+ }
49
+ }
50
+
51
+ public static bool Write(string target, string user, string value) {
52
+ IntPtr blob = Marshal.StringToCoTaskMemUni(value);
53
+ try {
54
+ CREDENTIAL credential = new CREDENTIAL();
55
+ credential.Type = CRED_TYPE_GENERIC;
56
+ credential.TargetName = target;
57
+ credential.UserName = user;
58
+ credential.CredentialBlob = blob;
59
+ credential.CredentialBlobSize = (UInt32)(value.Length * 2);
60
+ credential.Persist = CRED_PERSIST_LOCAL_MACHINE;
61
+ return CredWrite(ref credential, 0);
62
+ } finally {
63
+ Marshal.ZeroFreeCoTaskMemUnicode(blob);
64
+ }
65
+ }
66
+ }
67
+ '@
68
+
69
+ $target = "$Service@$Account"
70
+
71
+ if ($Action -eq 'get') {
72
+ $value = [WhoseTurnCredential]::Read($target)
73
+ if ($null -eq $value) { exit 1 }
74
+ [Console]::Out.Write($value)
75
+ exit 0
76
+ }
77
+
78
+ if ($Action -eq 'set') {
79
+ $value = [Console]::In.ReadToEnd()
80
+ if ([WhoseTurnCredential]::Write($target, $Account, $value)) { exit 0 }
81
+ exit 1
82
+ }
83
+
84
+ exit 2
@@ -0,0 +1,61 @@
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
+ import { ApiError } from './errors.mjs';
3
+
4
+ export const createRegistry = ({ load, save, build, normalise = (raw) => raw }) => {
5
+ const listeners = new Set();
6
+ const registry = { workspaces: [], rawWorkspaces: [], problems: [], configFile: '', feedback: null, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); } };
7
+
8
+ const apply = (config, prebuilt = new Map()) => {
9
+ const previous = new Map(registry.workspaces.map((workspace) => [workspace.id, workspace]));
10
+ const signatures = new Map(registry.workspaces.map((workspace) => [workspace.id, workspace.signature]));
11
+ const created = [];
12
+ const dropped = [];
13
+ registry.workspaces = config.workspaces.map((definition) => {
14
+ const signature = JSON.stringify(definition);
15
+ if (signatures.get(definition.id) === signature) {
16
+ return previous.get(definition.id);
17
+ }
18
+ let workspace;
19
+ try {
20
+ workspace = prebuilt.get(signature) || build(definition, config);
21
+ } catch (error) {
22
+ dropped.push(`Workspace "${definition.id}" was dropped: ${error.message}`);
23
+ return null;
24
+ }
25
+ workspace.signature = signature;
26
+ workspace.subscribe((event) => listeners.forEach((listener) => listener(event)));
27
+ created.push(workspace);
28
+ return workspace;
29
+ }).filter(Boolean);
30
+ registry.rawWorkspaces = config.rawWorkspaces;
31
+ registry.feedback = config.feedback;
32
+ registry.problems = [...config.problems, ...dropped];
33
+ registry.configFile = config.configFile;
34
+ return created;
35
+ };
36
+
37
+ registry.reload = () => apply(load());
38
+ const tryBuild = (rawWorkspaces) => {
39
+ const config = load();
40
+ const known = new Set(registry.workspaces.map((workspace) => workspace.signature));
41
+ const prebuilt = new Map();
42
+ try {
43
+ for (const definition of normalise(rawWorkspaces)) {
44
+ const signature = JSON.stringify(definition);
45
+ if (!known.has(signature)) {
46
+ prebuilt.set(signature, build(definition, config));
47
+ }
48
+ }
49
+ return prebuilt;
50
+ } catch (error) {
51
+ throw new ApiError('INVALID_CONFIG', { count: '1' }, [error.message]);
52
+ }
53
+ };
54
+ registry.save = async (rawWorkspaces) => {
55
+ const prebuilt = tryBuild(rawWorkspaces);
56
+ await save(rawWorkspaces);
57
+ return apply(load(), prebuilt);
58
+ };
59
+ registry.reload();
60
+ return registry;
61
+ };
package/lib/rules.mjs ADDED
@@ -0,0 +1,241 @@
1
+ /** The "whose turn" rules and the feed, evaluated once on the server over a workspace's merged sources. Ticket rows and workflow run rows come from the rules table, see docs/mechanisms/rules-table.md. */
2
+ import { toIsoUtc, nowIso, daysAgoIso, oneline, escapeRegex, wordsRegex, newestFirst } from './text.mjs';
3
+ import { ticketFacts, runFacts, shortDate, statusGroupOrder, chainGroup } from './facts.mjs';
4
+ import { WHOSE, firstMatch, fillText, expandTable } from './table.mjs';
5
+ import { defaultTable } from './default-table.mjs';
6
+
7
+
8
+ export const compileRules = (rules) => {
9
+ const statusWords = Object.fromEntries(Object.entries(rules.statuses).map(([group, words]) => [group, wordsRegex(words)]));
10
+ const automation = wordsRegex(rules.automation_authors);
11
+ const question = new RegExp(`\\?|\\b(${rules.question_words.map(escapeRegex).join('|')})\\b`, 'i');
12
+ return {
13
+ ...rules,
14
+ table: expandTable(rules.table, defaultTable(rules)),
15
+ statusOrder: statusGroupOrder(rules.statuses),
16
+ statusIs: (group, status) => statusWords[group].test(status || ''),
17
+ isAutomation: (author) => automation.test(author || ''),
18
+ isQuestion: (body) => question.test(body.replace(/https?:\/\/\S+/g, '')),
19
+ };
20
+ };
21
+
22
+ const BLANK_ROW = {
23
+ group: 'you', whose: 'You', done: false, started: false, rank: 3, why: '', hold_url: '', hold_full: '', status_since: '', status_group: '',
24
+ pr: '', pr_url: '', pr_state: '', checks: '', comment: '', comment_url: '', ticket_url: '', url: '',
25
+ };
26
+
27
+ const row = (fields) => {
28
+ const built = { ...BLANK_ROW, ...fields };
29
+ built.url = built.url || built.ticket_url || built.pr_url || built.comment_url;
30
+ return built;
31
+ };
32
+
33
+ const prCells = (pr, withComment) => ({
34
+ pr: `#${pr.pr}`, pr_url: pr.url, pr_state: pr.state, checks: pr.checks,
35
+ comment: withComment && pr.last_comment_by ? `${pr.last_comment_by}: ${pr.last_comment}` : '', comment_url: withComment ? pr.last_comment_url : '',
36
+ });
37
+
38
+ const pickPr = (list = []) => list.reduce((best, pr) => {
39
+ if (!best) {
40
+ return pr;
41
+ }
42
+ if ((pr.state === 'OPEN') !== (best.state === 'OPEN')) {
43
+ return pr.state === 'OPEN' ? pr : best;
44
+ }
45
+ return pr.updated_at > best.updated_at ? pr : best;
46
+ }, null);
47
+
48
+ const groupBy = (list, pick) => {
49
+ const map = new Map();
50
+ for (const item of list) {
51
+ const key = pick(item);
52
+ if (key) {
53
+ (map.get(key) || map.set(key, []).get(key)).push(item);
54
+ }
55
+ }
56
+ return map;
57
+ };
58
+
59
+ const openMentions = (comments, me, rules, cutoff) => {
60
+ const asks = new Map();
61
+ for (const comment of comments.filter((candidate) => candidate.at >= cutoff).sort((a, b) => String(a.at).localeCompare(String(b.at)))) {
62
+ const myName = me[comment.source];
63
+ if (!myName) {
64
+ continue;
65
+ }
66
+ if (comment.author === myName) {
67
+ const open = asks.get(comment.key);
68
+ if (open) {
69
+ asks.set(comment.key, open.filter((ask) => !comment.body.includes(`@${ask.comment.author}`)));
70
+ }
71
+ continue;
72
+ }
73
+ if (rules.isAutomation(comment.author) || !comment.body.includes(`@${myName}`)) {
74
+ continue;
75
+ }
76
+ (asks.get(comment.key) || asks.set(comment.key, []).get(comment.key)).push({ comment, question: rules.isQuestion(comment.body) });
77
+ }
78
+ return asks;
79
+ };
80
+
81
+ const applyThen = (built, then, facts, reason) => {
82
+ const { label, group } = WHOSE[then.whose];
83
+ Object.assign(built, {
84
+ group: group || built.group,
85
+ whose: label || built.whose,
86
+ started: Boolean(then.started),
87
+ rank: then.rank ?? built.rank,
88
+ next: then.next ? fillText(then.next, facts) : built.next,
89
+ why: fillText(then.why, facts),
90
+ ...(then.hold && reason ? { hold_url: reason.url, hold_full: reason.full } : {}),
91
+ });
92
+ };
93
+
94
+ const decided = (built, facts, rules, reason) => {
95
+ const matched = firstMatch(rules.table, facts);
96
+ if (matched) {
97
+ applyThen(built, matched.then, facts, reason);
98
+ }
99
+ return built;
100
+ };
101
+
102
+ const ticketRow = (ticket, pr, context) => {
103
+ const { facts, reason } = ticketFacts(ticket, pr, context);
104
+ const open = facts['pr.open'];
105
+ const done = facts['ticket.done'];
106
+ const built = row({
107
+ key: ticket.key, row_id: ticket.key, source: ticket.source, summary: ticket.summary, status: ticket.status, whose: ticket.assignee, next: 'Not yours now',
108
+ group: done ? 'done' : 'others', done, ticket_url: ticket.url, updated: ticket.updated, status_since: ticket.status_change ? ticket.status_change.since : '',
109
+ status_group: chainGroup(facts['ticket.status']),
110
+ activity_at: [toIsoUtc(ticket.updated), pr ? pr.updated_at : ''].filter(Boolean).sort().pop(),
111
+ ...(pr ? prCells(pr, open) : {}),
112
+ });
113
+ return decided(built, facts, context.rules, reason);
114
+ };
115
+
116
+ /** 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. */
117
+ const runRow = (run, rules) => decided(row({
118
+ key: run.ref, row_id: `run:${run.source}:${run.ref}:${run.streak}`, source: run.source, summary: run.title || run.workflow, status: 'Run failed', whose: run.actor, next: 'Not yours now',
119
+ group: 'others', updated: run.at, activity_at: run.at, url: run.url,
120
+ }), runFacts(run), rules, null);
121
+
122
+ const orphanPrRow = (key, pr, ticketUrl) => row({
123
+ key, row_id: key, source: pr.source, summary: pr.title, status: 'Not in your ticket list', next: 'Check the ticket for this open PR',
124
+ why: `PR #${pr.pr} is open, but its ticket is not among your tickets.`, ticket_url: ticketUrl(key), updated: pr.updated_at, activity_at: pr.updated_at,
125
+ ...prCells(pr, true),
126
+ });
127
+
128
+ const askRow = (ask, ticketUrl) => {
129
+ const review = ask.kind === 'review requested';
130
+ return row({
131
+ key: ask.key || ask.ref, row_id: ask.id, source: ask.source, summary: ask.title, status: review ? 'Review requested' : 'Mentioned',
132
+ next: review ? `Review PR ${ask.ref}` : `Answer ${ask.who} on ${ask.ref}`,
133
+ why: review ? `${ask.who} asked you to review ${ask.ref} and you are still in its reviewer list.` : `${ask.who} @-mentioned you on ${ask.ref}; you have not commented there since.`,
134
+ ticket_url: ask.key ? ticketUrl(ask.key) : '', pr: ask.ref, pr_url: ask.url, comment: review ? '' : `${ask.who}: ${ask.text}`, comment_url: ask.url,
135
+ updated: ask.at, activity_at: ask.at, url: ask.url,
136
+ });
137
+ };
138
+
139
+ const mentionRow = (ticketRowValue, { comment, question }) => row({
140
+ key: ticketRowValue.key, row_id: `${ticketRowValue.key}:${comment.id}`, source: comment.source, summary: ticketRowValue.summary, status: ticketRowValue.status, status_group: ticketRowValue.status_group,
141
+ next: question ? `Answer ${comment.author} in the ticket` : `Read ${comment.author}’s comment in the ticket`,
142
+ why: `${comment.author}${question ? ' asked you a question' : ' mentioned you'} on ${shortDate(comment.at)}; you have not @-mentioned them in a reply since.`,
143
+ ticket_url: ticketRowValue.ticket_url, comment: `${comment.author}: ${oneline(comment.body, 160)}`, comment_url: comment.url,
144
+ updated: comment.at, activity_at: comment.at, url: comment.url,
145
+ });
146
+
147
+ export const turnRows = ({ tickets = [], prs = [], comments = [], asks = [], runs = [] }, { me = {}, rules, historyDays = 14, ticketUrl = () => '' }) => {
148
+ const prsByKey = groupBy(prs, (pr) => pr.key);
149
+ const commentsByKey = groupBy(comments, (comment) => comment.key);
150
+ const ticketKeys = new Set(tickets.map((ticket) => ticket.key));
151
+ const mentions = openMentions(comments, me, rules, daysAgoIso(historyDays));
152
+ const rows = tickets.map((ticket) => ticketRow(ticket, pickPr(prsByKey.get(ticket.key)), {
153
+ myName: me[ticket.source], rules, comments: commentsByKey.get(ticket.key) || [], mentions: mentions.get(ticket.key) || [],
154
+ }));
155
+ for (const [key, list] of prsByKey) {
156
+ const pr = pickPr(list);
157
+ if (!ticketKeys.has(key) && pr.state === 'OPEN') {
158
+ rows.push(orphanPrRow(key, pr, ticketUrl));
159
+ }
160
+ }
161
+ for (const ask of asks) {
162
+ rows.push(askRow(ask, ticketUrl));
163
+ }
164
+ for (const run of runs) {
165
+ rows.push(runRow(run, rules));
166
+ }
167
+ for (const ticketRowValue of rows.slice()) {
168
+ if (!ticketRowValue.done) {
169
+ for (const mention of mentions.get(ticketRowValue.key) || []) {
170
+ rows.push(mentionRow(ticketRowValue, mention));
171
+ }
172
+ }
173
+ }
174
+ return rows;
175
+ };
176
+
177
+ const feedItem = (fields) => ({ who: '', text: '', body: '', key: '', title: '', ...fields });
178
+
179
+ export const feedRows = ({ activity = [], events = [], tickets = [], comments = [], asks = [] }, { me = {}, rules, historyDays = 14 }) => {
180
+ const cutoff = daysAgoIso(historyDays);
181
+ const recentComments = comments.filter((comment) => comment.at >= cutoff);
182
+ const rows = [
183
+ ...asks.map((ask) => feedItem({ id: ask.id, at: ask.at, source: ask.source, ref: ask.ref, key: ask.key, title: ask.title, kind: ask.kind, who: ask.who, text: ask.text, body: ask.body, url: ask.url })),
184
+ ...activity.map((item) => feedItem({ id: `act:${item.url}:${item.at}`, at: item.at, source: item.source, ref: item.pr, key: item.key, title: item.title, kind: item.kind, who: item.who, text: item.text, body: item.body || item.text, url: item.url })),
185
+ ...recentComments.map((comment) => {
186
+ const fromMe = Boolean(me[comment.source]) && comment.author === me[comment.source];
187
+ return feedItem({
188
+ id: `comment:${comment.source}:${comment.id}`, at: comment.at, source: comment.source, ref: comment.key, key: comment.key, kind: 'comment', who: fromMe ? 'you' : comment.author,
189
+ mention: !fromMe && !rules.isAutomation(comment.author) && Boolean(me[comment.source]) && comment.body.includes(`@${me[comment.source]}`),
190
+ text: oneline(comment.body), body: comment.body, url: comment.url,
191
+ });
192
+ }),
193
+ ...events.map((event) => feedItem({ id: `event:${event.key}:${event.at}:${event.kind}`, at: event.at, source: event.source, ref: event.key, key: event.key, kind: event.kind, text: event.text, url: event.url })),
194
+ ];
195
+ const timesByKey = groupBy([...events, ...recentComments], (item) => item.key);
196
+ for (const ticket of tickets) {
197
+ const explained = (timesByKey.get(ticket.key) || []).some((item) => Math.abs(new Date(item.at).getTime() - new Date(ticket.updated).getTime()) < 5 * 60000);
198
+ if (!explained) {
199
+ rows.push(feedItem({ id: `ticket:${ticket.source}:${ticket.key}:${ticket.updated}`, at: toIsoUtc(ticket.updated), source: ticket.source, ref: ticket.key, key: ticket.key, kind: 'updated', text: `${ticket.status} · ${ticket.assignee}`, url: ticket.url }));
200
+ }
201
+ }
202
+ return newestFirst(rows);
203
+ };
204
+
205
+ export const isHandled = (candidate, marks) => {
206
+ const handledAt = marks.handled[candidate.row_id];
207
+ return Boolean(handledAt) && String(candidate.activity_at || '') <= handledAt;
208
+ };
209
+
210
+ export const myMoves = (rows, marks) => rows.filter((candidate) => candidate.group === 'you' && !isHandled(candidate, marks));
211
+
212
+ /** The rows as the window gets them: each with its handled flag from the marks. */
213
+ export const withHandled = (rows, marks) => rows.map((candidate) => ({ ...candidate, handled: isHandled(candidate, marks) }));
214
+
215
+ /** The four header tiles: your open moves, waiting on others, open PRs with failing checks, done. */
216
+ export const turnCounts = (rows, marks) => ({
217
+ mine: myMoves(rows, marks).length,
218
+ others: rows.filter((candidate) => candidate.group === 'others').length,
219
+ failing: rows.filter((candidate) => candidate.pr_state === 'OPEN' && candidate.checks === 'failing').length,
220
+ done: rows.filter((candidate) => candidate.group === 'done').length,
221
+ });
222
+
223
+ export const ticketEvents = (before, after) => {
224
+ const previous = new Map(before.map((ticket) => [`${ticket.source}:${ticket.key}`, ticket]));
225
+ const out = [];
226
+ for (const ticket of after) {
227
+ const old = previous.get(`${ticket.source}:${ticket.key}`);
228
+ if (!old) {
229
+ continue;
230
+ }
231
+ if (old.status !== ticket.status) {
232
+ out.push({ at: nowIso(), source: ticket.source, key: ticket.key, kind: 'status', text: `${old.status} → ${ticket.status}`, url: ticket.url });
233
+ }
234
+ if (old.assignee !== ticket.assignee) {
235
+ out.push({ at: nowIso(), source: ticket.source, key: ticket.key, kind: 'reassigned', text: `${old.assignee} → ${ticket.assignee}`, url: ticket.url });
236
+ }
237
+ }
238
+ return out;
239
+ };
240
+
241
+
@@ -0,0 +1,69 @@
1
+ /** Secret store: the platform's own (Keychain, libsecret, Credential Manager) by service and account, else a 600-mode file <data>/secrets/<service>@<account>. */
2
+ import { execFile } from 'node:child_process';
3
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const CREDENTIAL_SCRIPT = fileURLToPath(new URL('./platform/credential.ps1', import.meta.url));
8
+ const POWERSHELL = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', CREDENTIAL_SCRIPT];
9
+
10
+ // Each store builds argv as [file, args, stdin?]; the value reaches the tool on stdin wherever the tool allows it.
11
+ const STORES = {
12
+ darwin: {
13
+ get: (service, account) => ['security', ['find-generic-password', '-s', service, '-a', account, '-w']],
14
+ set: (service, account, value) => ['security', ['add-generic-password', '-U', '-a', account, '-s', service, '-w', value]],
15
+ },
16
+ linux: {
17
+ get: (service, account) => ['secret-tool', ['lookup', 'service', service, 'account', account]],
18
+ set: (service, account, value) => ['secret-tool', ['store', `--label=whose-turn ${service}`, 'service', service, 'account', account], value],
19
+ },
20
+ win32: {
21
+ get: (service, account) => ['powershell', [...POWERSHELL, 'get', service, account]],
22
+ set: (service, account, value) => ['powershell', [...POWERSHELL, 'set', service, account], value],
23
+ },
24
+ };
25
+
26
+ const execute = (file, args, input = '') =>
27
+ new Promise((resolve, reject) => {
28
+ const child = execFile(file, args, (error, stdout) => (error ? reject(error) : resolve(stdout)));
29
+ child.stdin.on('error', () => {});
30
+ child.stdin.end(input);
31
+ });
32
+
33
+ const fileName = (service, account) => `${service}@${account}`.replace(/[^\w.@-]+/g, '_');
34
+
35
+ const fromFile = async (dataDir, service, account) => {
36
+ try {
37
+ return (await readFile(join(dataDir, 'secrets', fileName(service, account)), 'utf8')).trim();
38
+ } catch {
39
+ return '';
40
+ }
41
+ };
42
+
43
+ const toFile = async (dataDir, service, account, value) => {
44
+ await mkdir(join(dataDir, 'secrets'), { recursive: true, mode: 0o700 });
45
+ await writeFile(join(dataDir, 'secrets', fileName(service, account)), value, { mode: 0o600 });
46
+ };
47
+
48
+ export const createSecrets = (dataDir, { platform = process.platform, run = execute } = {}) => {
49
+ const store = STORES[platform];
50
+ const attempt = async (command) => {
51
+ try {
52
+ return await run(...command);
53
+ } catch {
54
+ return null;
55
+ }
56
+ };
57
+ return {
58
+ get: async (service, account) => {
59
+ const stdout = store ? await attempt(store.get(service, account)) : null;
60
+ return (stdout || '').trim() || (await fromFile(dataDir, service, account));
61
+ },
62
+ set: async (service, account, value) => {
63
+ if (store && (await attempt(store.set(service, account, value))) !== null) {
64
+ return;
65
+ }
66
+ await toFile(dataDir, service, account, value);
67
+ },
68
+ };
69
+ };