daily-focus 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/SETUP.md +501 -0
- package/dist/audit-4AUZVELT.js +215 -0
- package/dist/chunk-2PLWVW5T.js +987 -0
- package/dist/chunk-6VWWJZKJ.js +27 -0
- package/dist/chunk-EXGTFXNV.js +77 -0
- package/dist/chunk-G4K24I4M.js +425 -0
- package/dist/chunk-NDWZUZJE.js +179 -0
- package/dist/chunk-QKT2FAB5.js +132 -0
- package/dist/chunk-V2UN3JYL.js +140 -0
- package/dist/cli.js +203 -0
- package/dist/init-ZJCI2723.js +122 -0
- package/dist/sample-M7WNKQOV.js +7 -0
- package/dist/seed-7DIDKXT7.js +50 -0
- package/dist/server-B4FWZHS2.js +4814 -0
- package/dist/service-ILBMIZB7.js +240 -0
- package/package.json +66 -0
- package/prompts/README.md +122 -0
- package/prompts/assistant.md +62 -0
- package/prompts/morning-brief-personal.md +442 -0
- package/prompts/morning-brief-work.md +630 -0
- package/public/app.js +5058 -0
- package/public/index.html +36 -0
- package/public/style.css +2515 -0
- package/schema/items.schema.json +99 -0
- package/tools/dfcal/Info.plist +16 -0
- package/tools/dfcal/build.sh +52 -0
- package/tools/dfcal/main.swift +133 -0
|
@@ -0,0 +1,4814 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FOCUS_TEMPLATE,
|
|
3
|
+
sourcesTemplate
|
|
4
|
+
} from "./chunk-V2UN3JYL.js";
|
|
5
|
+
import {
|
|
6
|
+
describeLink,
|
|
7
|
+
linkStore,
|
|
8
|
+
storeLinkWarning
|
|
9
|
+
} from "./chunk-QKT2FAB5.js";
|
|
10
|
+
import {
|
|
11
|
+
Store,
|
|
12
|
+
canonicalId,
|
|
13
|
+
foldActionLog,
|
|
14
|
+
reconcileSession,
|
|
15
|
+
startSession,
|
|
16
|
+
stopSession,
|
|
17
|
+
tempPathFor,
|
|
18
|
+
writeJsonAtomic
|
|
19
|
+
} from "./chunk-2PLWVW5T.js";
|
|
20
|
+
import {
|
|
21
|
+
CLI_NAMES,
|
|
22
|
+
LAUNCH_SETTINGS,
|
|
23
|
+
PROFILES,
|
|
24
|
+
briefWritingTools,
|
|
25
|
+
describeTime,
|
|
26
|
+
envSources,
|
|
27
|
+
layerEnv,
|
|
28
|
+
loadConfig,
|
|
29
|
+
nextScheduledRun,
|
|
30
|
+
readDotEnv,
|
|
31
|
+
resolveSchedule,
|
|
32
|
+
scheduledRunDue
|
|
33
|
+
} from "./chunk-G4K24I4M.js";
|
|
34
|
+
import {
|
|
35
|
+
PACKAGED,
|
|
36
|
+
command,
|
|
37
|
+
ranDirectly
|
|
38
|
+
} from "./chunk-6VWWJZKJ.js";
|
|
39
|
+
import {
|
|
40
|
+
laterISO,
|
|
41
|
+
parseISO
|
|
42
|
+
} from "./chunk-EXGTFXNV.js";
|
|
43
|
+
|
|
44
|
+
// src/server.ts
|
|
45
|
+
import { createServer } from "node:http";
|
|
46
|
+
import { createReadStream } from "node:fs";
|
|
47
|
+
import { stat as stat4 } from "node:fs/promises";
|
|
48
|
+
import { basename, extname, join as join4, normalize, resolve, sep } from "node:path";
|
|
49
|
+
import { fileURLToPath } from "node:url";
|
|
50
|
+
|
|
51
|
+
// src/editable.ts
|
|
52
|
+
import { createHash } from "node:crypto";
|
|
53
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
54
|
+
var ABSENT = "absent";
|
|
55
|
+
function versionOf(text) {
|
|
56
|
+
return text === null ? ABSENT : createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
57
|
+
}
|
|
58
|
+
async function readEditable(path) {
|
|
59
|
+
try {
|
|
60
|
+
const text = await readFile(path, "utf8");
|
|
61
|
+
return { text, version: versionOf(text) };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error.code === "ENOENT") return { text: null, version: ABSENT };
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
var saving = /* @__PURE__ */ new Map();
|
|
68
|
+
function saveEditable(path, text, expected) {
|
|
69
|
+
const next = (saving.get(path) ?? Promise.resolve()).catch(() => {
|
|
70
|
+
}).then(() => saveNow(path, text, expected));
|
|
71
|
+
saving.set(path, next);
|
|
72
|
+
void next.finally(() => {
|
|
73
|
+
if (saving.get(path) === next) saving.delete(path);
|
|
74
|
+
}).catch(() => {
|
|
75
|
+
});
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
78
|
+
async function saveNow(path, text, expected) {
|
|
79
|
+
const current = await readEditable(path);
|
|
80
|
+
if (current.version !== expected) return { saved: false, ...current };
|
|
81
|
+
const normalised = text.endsWith("\n") ? text : `${text}
|
|
82
|
+
`;
|
|
83
|
+
const temp = tempPathFor(path);
|
|
84
|
+
await writeFile(temp, normalised, "utf8");
|
|
85
|
+
await rename(temp, path);
|
|
86
|
+
return { saved: true, version: versionOf(normalised) };
|
|
87
|
+
}
|
|
88
|
+
var FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/;
|
|
89
|
+
function oneLine(value) {
|
|
90
|
+
return (value ?? "").replace(/\s*\r?\n\s*/g, " ").trim();
|
|
91
|
+
}
|
|
92
|
+
function setFocusFields(text, fields, template) {
|
|
93
|
+
const source = text ?? template;
|
|
94
|
+
const wanted = { objective: oneLine(fields.objective), blocker: oneLine(fields.blocker) };
|
|
95
|
+
const line = (key) => wanted[key] === "" ? `${key}:` : `${key}: ${wanted[key]}`;
|
|
96
|
+
const match = FRONTMATTER.exec(source);
|
|
97
|
+
if (!match) return `---
|
|
98
|
+
${line("objective")}
|
|
99
|
+
${line("blocker")}
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
${source}`;
|
|
103
|
+
const seen = /* @__PURE__ */ new Set();
|
|
104
|
+
const lines = (match[1] ?? "").split(/\r?\n/).map((existing) => {
|
|
105
|
+
const key = existing.slice(0, existing.indexOf(":")).trim().toLowerCase();
|
|
106
|
+
if (existing.includes(":") && (key === "objective" || key === "blocker")) {
|
|
107
|
+
seen.add(key);
|
|
108
|
+
return line(key);
|
|
109
|
+
}
|
|
110
|
+
return existing;
|
|
111
|
+
});
|
|
112
|
+
for (const key of ["objective", "blocker"]) {
|
|
113
|
+
if (!seen.has(key)) lines.push(line(key));
|
|
114
|
+
}
|
|
115
|
+
return `---
|
|
116
|
+
${lines.join("\n")}
|
|
117
|
+
---${match[2] ?? "\n"}${source.slice(match[0].length)}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/board.ts
|
|
121
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
122
|
+
|
|
123
|
+
// src/failure.ts
|
|
124
|
+
var MAX_DETAIL = 4e3;
|
|
125
|
+
function extend(streak, at = Date.now()) {
|
|
126
|
+
return streak ? { since: streak.since, attempts: streak.attempts + 1 } : { since: at, attempts: 1 };
|
|
127
|
+
}
|
|
128
|
+
function toReadFailure(failure, streak, retryAt, retrying) {
|
|
129
|
+
return {
|
|
130
|
+
message: failure.message,
|
|
131
|
+
detail: failure.detail,
|
|
132
|
+
since: new Date(streak?.since ?? Date.now()).toISOString(),
|
|
133
|
+
attempts: streak?.attempts ?? 1,
|
|
134
|
+
retryAt: retrying || retryAt === null ? null : new Date(retryAt).toISOString(),
|
|
135
|
+
retrying
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function errorDetail(err) {
|
|
139
|
+
const parts = [];
|
|
140
|
+
const own = err?.detail;
|
|
141
|
+
if (typeof own === "string" && own.trim() !== "") parts.push(own.trim());
|
|
142
|
+
let cause = err?.cause;
|
|
143
|
+
for (let depth = 0; cause !== void 0 && cause !== null && depth < 5; depth++) {
|
|
144
|
+
parts.push(`caused by ${describe(cause)}`);
|
|
145
|
+
cause = cause.cause;
|
|
146
|
+
}
|
|
147
|
+
return clip(parts.join("\n"));
|
|
148
|
+
}
|
|
149
|
+
function unexpectedDetail(err) {
|
|
150
|
+
const head = err instanceof Error ? err.stack ?? `${err.name}: ${err.message}` : String(err);
|
|
151
|
+
const rest = errorDetail(err);
|
|
152
|
+
return clip(rest ? `${head}
|
|
153
|
+
${rest}` : head);
|
|
154
|
+
}
|
|
155
|
+
var FACTS = ["code", "syscall", "hostname", "address", "port"];
|
|
156
|
+
function describe(value) {
|
|
157
|
+
if (!(value instanceof Error)) return String(value);
|
|
158
|
+
const record = value;
|
|
159
|
+
const facts = FACTS.flatMap((key) => {
|
|
160
|
+
const fact = record[key];
|
|
161
|
+
return typeof fact === "string" || typeof fact === "number" ? [`${key} ${fact}`] : [];
|
|
162
|
+
});
|
|
163
|
+
let line = `${value.name}: ${value.message}${facts.length > 0 ? ` (${facts.join(", ")})` : ""}`;
|
|
164
|
+
if (value instanceof AggregateError) line += value.errors.map((error) => `
|
|
165
|
+
- ${describe(error)}`).join("");
|
|
166
|
+
return line;
|
|
167
|
+
}
|
|
168
|
+
function clip(text) {
|
|
169
|
+
return text.length > MAX_DETAIL ? `${text.slice(0, MAX_DETAIL)}
|
|
170
|
+
\u2026` : text;
|
|
171
|
+
}
|
|
172
|
+
function unreachable(err, host) {
|
|
173
|
+
const error = err;
|
|
174
|
+
if (error?.name === "TimeoutError") return `${host} did not answer in time`;
|
|
175
|
+
if (error?.name === "AbortError") return `the request to ${host} was cancelled`;
|
|
176
|
+
const cause = error?.cause;
|
|
177
|
+
const code = typeof cause?.code === "string" ? cause.code : firstCode(cause?.errors);
|
|
178
|
+
const why = code ? reasonFor(code) : null;
|
|
179
|
+
const fallback = typeof cause?.message === "string" && cause.message ? cause.message : String(error?.message ?? err);
|
|
180
|
+
return `couldn't reach ${host} (${why ?? fallback})`;
|
|
181
|
+
}
|
|
182
|
+
function firstCode(errors) {
|
|
183
|
+
if (!Array.isArray(errors)) return void 0;
|
|
184
|
+
const code = errors[0]?.code;
|
|
185
|
+
return typeof code === "string" ? code : void 0;
|
|
186
|
+
}
|
|
187
|
+
function reasonFor(code) {
|
|
188
|
+
switch (code) {
|
|
189
|
+
case "ENOTFOUND":
|
|
190
|
+
case "EAI_AGAIN":
|
|
191
|
+
case "EAI_NONAME":
|
|
192
|
+
return "the DNS lookup failed \u2014 is this machine online?";
|
|
193
|
+
case "ENETUNREACH":
|
|
194
|
+
case "ENETDOWN":
|
|
195
|
+
case "EHOSTUNREACH":
|
|
196
|
+
case "EHOSTDOWN":
|
|
197
|
+
return "no network route to it \u2014 is this machine online?";
|
|
198
|
+
case "ETIMEDOUT":
|
|
199
|
+
case "UND_ERR_CONNECT_TIMEOUT":
|
|
200
|
+
return "the connection timed out";
|
|
201
|
+
case "ECONNREFUSED":
|
|
202
|
+
return "the connection was refused";
|
|
203
|
+
case "ECONNRESET":
|
|
204
|
+
case "EPIPE":
|
|
205
|
+
case "UND_ERR_SOCKET":
|
|
206
|
+
return "the connection was cut off";
|
|
207
|
+
}
|
|
208
|
+
if (/CERT|SELF_SIGNED|UNABLE_TO_(GET|VERIFY)|ERR_TLS/.test(code)) {
|
|
209
|
+
return `its certificate was refused, ${code} \u2014 a proxy in the way?`;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/github.ts
|
|
215
|
+
import { execFile } from "node:child_process";
|
|
216
|
+
import { promisify } from "node:util";
|
|
217
|
+
var run = promisify(execFile);
|
|
218
|
+
var GH_TIMEOUT_MS = 15e3;
|
|
219
|
+
var FETCH_TIMEOUT_MS = 3e4;
|
|
220
|
+
var GRAPHQL_URL = "https://api.github.com/graphql";
|
|
221
|
+
var PAGE_SIZE = 25;
|
|
222
|
+
var MAX_PAGES = 12;
|
|
223
|
+
var GH_ENV = { ...process.env, GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", NO_COLOR: "1" };
|
|
224
|
+
var GhMissingError = class extends Error {
|
|
225
|
+
constructor(ghPath) {
|
|
226
|
+
super(`could not run \`${ghPath}\` \u2014 install the GitHub CLI or set DAILY_FOCUS_GH to its path`);
|
|
227
|
+
this.name = "GhMissingError";
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
var GhAuthError = class extends Error {
|
|
231
|
+
detail;
|
|
232
|
+
constructor(message, detail = "") {
|
|
233
|
+
super(message);
|
|
234
|
+
this.name = "GhAuthError";
|
|
235
|
+
this.detail = detail;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
async function gh(ghPath, args) {
|
|
239
|
+
try {
|
|
240
|
+
const { stdout, stderr } = await run(ghPath, args, { env: GH_ENV, timeout: GH_TIMEOUT_MS, maxBuffer: 1 << 20 });
|
|
241
|
+
return { stdout, stderr, code: 0 };
|
|
242
|
+
} catch (err) {
|
|
243
|
+
const failure = err;
|
|
244
|
+
if (failure.code === "ENOENT" || failure.code === "EACCES") throw new GhMissingError(ghPath);
|
|
245
|
+
const stderr = failure.killed || failure.signal ? `gh did not answer within ${GH_TIMEOUT_MS / 1e3}s \u2014 a keychain prompt or a slow network?` : failure.stderr || failure.message;
|
|
246
|
+
return { stdout: failure.stdout ?? "", stderr, code: typeof failure.code === "number" ? failure.code : 1 };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function listGhAccounts(ghPath) {
|
|
250
|
+
const { stdout } = await gh(ghPath, ["auth", "status", "--hostname", "github.com", "--json", "hosts"]);
|
|
251
|
+
let parsed;
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(stdout);
|
|
254
|
+
} catch {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
const hosts = parsed?.hosts;
|
|
258
|
+
const entries = hosts?.["github.com"];
|
|
259
|
+
if (!Array.isArray(entries)) return [];
|
|
260
|
+
const accounts = [];
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
263
|
+
const { login, active } = entry;
|
|
264
|
+
if (typeof login !== "string" || login === "") continue;
|
|
265
|
+
accounts.push({ login, active: active === true });
|
|
266
|
+
}
|
|
267
|
+
return accounts;
|
|
268
|
+
}
|
|
269
|
+
async function ghToken(ghPath, login) {
|
|
270
|
+
const args = ["auth", "token", "--hostname", "github.com"];
|
|
271
|
+
if (login) args.push("--user", login);
|
|
272
|
+
const { stdout, stderr, code } = await gh(ghPath, args);
|
|
273
|
+
const token = stdout.trim();
|
|
274
|
+
if (code !== 0 || token === "") {
|
|
275
|
+
const lines = stderr.trim().split("\n");
|
|
276
|
+
const detail = lines[0] ?? "";
|
|
277
|
+
throw new GhAuthError(
|
|
278
|
+
login ? `gh has no login for ${login} on this machine (${detail || "no token"}) \u2014 run \`gh auth login\` as that account` : `gh is not logged in to github.com (${detail || "no token"}) \u2014 run \`gh auth login\``,
|
|
279
|
+
// The message quotes the first line; the rest is only worth sending when there is one.
|
|
280
|
+
lines.length > 1 ? stderr.trim() : ""
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return token;
|
|
284
|
+
}
|
|
285
|
+
var GitHubRequestError = class extends Error {
|
|
286
|
+
status;
|
|
287
|
+
detail;
|
|
288
|
+
constructor(message, status = null, { cause, detail = "" } = {}) {
|
|
289
|
+
super(message, cause === void 0 ? void 0 : { cause });
|
|
290
|
+
this.name = "GitHubRequestError";
|
|
291
|
+
this.status = status;
|
|
292
|
+
this.detail = detail;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
function isSamlError(error) {
|
|
296
|
+
if (error.extensions?.saml_failure === true) return true;
|
|
297
|
+
return /SAML enforcement/i.test(error.message);
|
|
298
|
+
}
|
|
299
|
+
async function graphql(token, query, variables, signal) {
|
|
300
|
+
const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
301
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
302
|
+
let res;
|
|
303
|
+
try {
|
|
304
|
+
res = await fetch(GRAPHQL_URL, {
|
|
305
|
+
method: "POST",
|
|
306
|
+
headers: {
|
|
307
|
+
authorization: `Bearer ${token}`,
|
|
308
|
+
"content-type": "application/json",
|
|
309
|
+
accept: "application/vnd.github+json",
|
|
310
|
+
"user-agent": "daily-focus"
|
|
311
|
+
},
|
|
312
|
+
body: JSON.stringify({ query, variables }),
|
|
313
|
+
signal: combined
|
|
314
|
+
});
|
|
315
|
+
} catch (err) {
|
|
316
|
+
throw new GitHubRequestError(unreachable(err, new URL(GRAPHQL_URL).host), null, { cause: err });
|
|
317
|
+
}
|
|
318
|
+
const remaining = Number(res.headers.get("x-ratelimit-remaining"));
|
|
319
|
+
const rateLimitRemaining = Number.isFinite(remaining) && res.headers.has("x-ratelimit-remaining") ? remaining : null;
|
|
320
|
+
if (res.status === 401) throw new GitHubRequestError("GitHub rejected the token (401) \u2014 run `gh auth login` again", 401);
|
|
321
|
+
if (!res.ok) {
|
|
322
|
+
const reply = await res.text().catch(() => "");
|
|
323
|
+
const text = reply.slice(0, 200);
|
|
324
|
+
const id = res.headers.get("x-github-request-id");
|
|
325
|
+
const detail = [id ? `x-github-request-id: ${id}` : "", reply.length > text.length ? reply.trim() : ""].filter(Boolean).join("\n\n");
|
|
326
|
+
throw new GitHubRequestError(`GitHub answered ${res.status}${text ? `: ${text}` : ""}`, res.status, { detail });
|
|
327
|
+
}
|
|
328
|
+
let body;
|
|
329
|
+
try {
|
|
330
|
+
body = await res.json();
|
|
331
|
+
} catch (err) {
|
|
332
|
+
throw new GitHubRequestError(`GitHub returned unreadable JSON: ${err.message}`, res.status, { cause: err });
|
|
333
|
+
}
|
|
334
|
+
return { data: body.data ?? null, errors: body.errors ?? [], rateLimitRemaining };
|
|
335
|
+
}
|
|
336
|
+
function buildSearchQuery(scope) {
|
|
337
|
+
return ["is:pr", "is:open", "archived:false", "author:@me", ...scope].join(" ");
|
|
338
|
+
}
|
|
339
|
+
var CONTEXT_PAGE_SIZE = 100;
|
|
340
|
+
var MAX_CONTEXT_PAGES = 16;
|
|
341
|
+
var MAX_CONTEXT_REQUESTS = 200;
|
|
342
|
+
var CHECKS_BATCH_SIZE = 6;
|
|
343
|
+
var CHECK_CONTEXTS = `
|
|
344
|
+
totalCount
|
|
345
|
+
pageInfo { hasNextPage endCursor }
|
|
346
|
+
nodes {
|
|
347
|
+
__typename
|
|
348
|
+
... on CheckRun {
|
|
349
|
+
databaseId name status conclusion detailsUrl startedAt completedAt
|
|
350
|
+
checkSuite { app { id } workflowRun { databaseId runNumber runAttempt workflow { id } } }
|
|
351
|
+
}
|
|
352
|
+
... on StatusContext { context state targetUrl createdAt }
|
|
353
|
+
}`;
|
|
354
|
+
var SEARCH_QUERY = `
|
|
355
|
+
query($q: String!, $first: Int!, $after: String) {
|
|
356
|
+
search(query: $q, type: ISSUE, first: $first, after: $after) {
|
|
357
|
+
pageInfo { hasNextPage endCursor }
|
|
358
|
+
nodes {
|
|
359
|
+
... on PullRequest {
|
|
360
|
+
id
|
|
361
|
+
number
|
|
362
|
+
title
|
|
363
|
+
url
|
|
364
|
+
isDraft
|
|
365
|
+
createdAt
|
|
366
|
+
updatedAt
|
|
367
|
+
headRefName
|
|
368
|
+
baseRefName
|
|
369
|
+
repository { nameWithOwner }
|
|
370
|
+
reviewDecision
|
|
371
|
+
mergeable
|
|
372
|
+
autoMergeRequest { enabledAt }
|
|
373
|
+
commits(last: 1) { nodes { commit { oid committedDate } } }
|
|
374
|
+
readyEvents: timelineItems(itemTypes: [READY_FOR_REVIEW_EVENT], last: 1) {
|
|
375
|
+
nodes { ... on ReadyForReviewEvent { createdAt } }
|
|
376
|
+
}
|
|
377
|
+
reviewRequests(first: 20) {
|
|
378
|
+
nodes { requestedReviewer { __typename ... on User { login } ... on Team { slug } } }
|
|
379
|
+
}
|
|
380
|
+
reviews(last: 50) {
|
|
381
|
+
nodes { author { __typename login } state submittedAt }
|
|
382
|
+
}
|
|
383
|
+
comments(last: 50) {
|
|
384
|
+
nodes { author { __typename login } createdAt }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}`;
|
|
390
|
+
var MERGE_STATE_QUERY = `
|
|
391
|
+
query($ids: [ID!]!) {
|
|
392
|
+
nodes(ids: $ids) {
|
|
393
|
+
... on PullRequest { id mergeStateStatus }
|
|
394
|
+
}
|
|
395
|
+
}`;
|
|
396
|
+
var CHECKS_QUERY = `
|
|
397
|
+
query($ids: [ID!]!) {
|
|
398
|
+
nodes(ids: $ids) {
|
|
399
|
+
... on PullRequest {
|
|
400
|
+
id
|
|
401
|
+
commits(last: 1) {
|
|
402
|
+
nodes {
|
|
403
|
+
commit {
|
|
404
|
+
oid
|
|
405
|
+
statusCheckRollup {
|
|
406
|
+
state
|
|
407
|
+
contexts(first: ${CONTEXT_PAGE_SIZE}) { ${CHECK_CONTEXTS} }
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}`;
|
|
415
|
+
async function fetchChecks(token, ids, signal) {
|
|
416
|
+
const heads = /* @__PURE__ */ new Map();
|
|
417
|
+
if (ids.length === 0) return { heads, rateLimitRemaining: null };
|
|
418
|
+
const result = await graphql(token, CHECKS_QUERY, { ids }, signal);
|
|
419
|
+
const fatal = result.errors.filter((error) => !isSamlError(error));
|
|
420
|
+
if (result.data?.nodes === void 0 && fatal.length > 0) {
|
|
421
|
+
throw new GitHubRequestError(`GitHub wouldn't list the checks: ${fatal.map((error) => error.message).join("; ")}`);
|
|
422
|
+
}
|
|
423
|
+
for (const node of result.data?.nodes ?? []) {
|
|
424
|
+
if (!node || typeof node.id !== "string" || node.id === "") continue;
|
|
425
|
+
const head = node.commits?.nodes?.[0]?.commit;
|
|
426
|
+
heads.set(node.id, { oid: head?.oid ?? null, rollup: head?.statusCheckRollup ?? null });
|
|
427
|
+
}
|
|
428
|
+
return { heads, rateLimitRemaining: result.rateLimitRemaining };
|
|
429
|
+
}
|
|
430
|
+
function applyChecks(pull, rollup) {
|
|
431
|
+
const ci = summarizeChecks(rollup);
|
|
432
|
+
pull.checks = ci.checks;
|
|
433
|
+
pull.failingChecks = ci.failing;
|
|
434
|
+
pull.pendingChecks = ci.pending;
|
|
435
|
+
pull.cancelledChecks = ci.cancelled;
|
|
436
|
+
}
|
|
437
|
+
function mergeStatesFromNodes(nodes) {
|
|
438
|
+
const states = /* @__PURE__ */ new Map();
|
|
439
|
+
for (const node of nodes) {
|
|
440
|
+
if (!node || typeof node.id !== "string" || node.id === "") continue;
|
|
441
|
+
states.set(node.id, toMergeState(node.mergeStateStatus));
|
|
442
|
+
}
|
|
443
|
+
return states;
|
|
444
|
+
}
|
|
445
|
+
function isBot(actor) {
|
|
446
|
+
if (!actor) return true;
|
|
447
|
+
if (actor.__typename === "Bot") return true;
|
|
448
|
+
return typeof actor.login === "string" && actor.login.endsWith("[bot]");
|
|
449
|
+
}
|
|
450
|
+
function sameLogin(a, b) {
|
|
451
|
+
return typeof a === "string" && a.toLowerCase() === b.toLowerCase();
|
|
452
|
+
}
|
|
453
|
+
function toCheckState(state) {
|
|
454
|
+
switch (state) {
|
|
455
|
+
case "SUCCESS":
|
|
456
|
+
return "success";
|
|
457
|
+
case "FAILURE":
|
|
458
|
+
case "ERROR":
|
|
459
|
+
return "failure";
|
|
460
|
+
case "PENDING":
|
|
461
|
+
case "EXPECTED":
|
|
462
|
+
return "pending";
|
|
463
|
+
default:
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
var CHECK_RANK = { success: 1, pending: 2, failure: 3 };
|
|
468
|
+
function worse(a, b) {
|
|
469
|
+
if (a === null) return b;
|
|
470
|
+
if (b === null) return a;
|
|
471
|
+
return CHECK_RANK[b] > CHECK_RANK[a] ? b : a;
|
|
472
|
+
}
|
|
473
|
+
var FAILED_CONCLUSIONS = /* @__PURE__ */ new Set(["FAILURE", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"]);
|
|
474
|
+
var PASSED_CONCLUSIONS = /* @__PURE__ */ new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
|
|
475
|
+
function safeUrl(url) {
|
|
476
|
+
return typeof url === "string" && /^https?:\/\//i.test(url) ? url : null;
|
|
477
|
+
}
|
|
478
|
+
function rollupTruncated(rollup) {
|
|
479
|
+
const more = rollup.contexts?.pageInfo?.hasNextPage;
|
|
480
|
+
if (typeof more === "boolean") return more;
|
|
481
|
+
const total = rollup.contexts?.totalCount;
|
|
482
|
+
return typeof total === "number" && total > (rollup.contexts?.nodes?.length ?? 0);
|
|
483
|
+
}
|
|
484
|
+
function checkSlot(node) {
|
|
485
|
+
if (node.__typename === "StatusContext") return `status\0${node.context ?? ""}`;
|
|
486
|
+
const workflow = node.checkSuite?.workflowRun?.workflow?.id;
|
|
487
|
+
if (typeof workflow === "string" && workflow !== "") return `workflow\0${workflow}\0${node.name ?? ""}`;
|
|
488
|
+
const app = node.checkSuite?.app?.id;
|
|
489
|
+
if (typeof app === "string" && app !== "") return `app\0${app}\0${node.name ?? ""}`;
|
|
490
|
+
return `check\0${node.name ?? ""}`;
|
|
491
|
+
}
|
|
492
|
+
function checkGeneration(node) {
|
|
493
|
+
const run6 = node.checkSuite?.workflowRun;
|
|
494
|
+
const sequence = run6?.runNumber ?? run6?.databaseId;
|
|
495
|
+
if (typeof sequence === "number") return [sequence, run6?.runAttempt ?? 0];
|
|
496
|
+
const at = node.completedAt ?? node.startedAt ?? node.createdAt ?? null;
|
|
497
|
+
const time = at ? Date.parse(at) : Number.NaN;
|
|
498
|
+
return [Number.isFinite(time) ? time : 0, 0];
|
|
499
|
+
}
|
|
500
|
+
function latestChecks(nodes) {
|
|
501
|
+
const slots = /* @__PURE__ */ new Map();
|
|
502
|
+
const order = [];
|
|
503
|
+
for (const node of nodes) {
|
|
504
|
+
if (!node) continue;
|
|
505
|
+
const slot = checkSlot(node);
|
|
506
|
+
const generation = checkGeneration(node);
|
|
507
|
+
const held = slots.get(slot);
|
|
508
|
+
if (!held) {
|
|
509
|
+
slots.set(slot, { generation, nodes: [node] });
|
|
510
|
+
order.push(slot);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (generation[0] === held.generation[0] && generation[1] === held.generation[1]) {
|
|
514
|
+
held.nodes.push(node);
|
|
515
|
+
} else if (generation[0] > held.generation[0] || generation[0] === held.generation[0] && generation[1] > held.generation[1]) {
|
|
516
|
+
slots.set(slot, { generation, nodes: [node] });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return order.flatMap((slot) => slots.get(slot)?.nodes ?? []);
|
|
520
|
+
}
|
|
521
|
+
function summarizeChecks(rollup) {
|
|
522
|
+
if (!rollup) return { checks: null, failing: [], pending: [], cancelled: [] };
|
|
523
|
+
const failing = [];
|
|
524
|
+
const pending = [];
|
|
525
|
+
const cancelled = [];
|
|
526
|
+
const read = rollup.contexts?.nodes ?? [];
|
|
527
|
+
const nodes = latestChecks(read);
|
|
528
|
+
let derived = null;
|
|
529
|
+
for (const node of nodes) {
|
|
530
|
+
if (node.__typename === "CheckRun") {
|
|
531
|
+
const name = node.name ?? "unnamed check";
|
|
532
|
+
const waiting = { name, kind: "check-run", detailsUrl: safeUrl(node.detailsUrl) };
|
|
533
|
+
if (typeof node.databaseId === "number") waiting.checkRunId = node.databaseId;
|
|
534
|
+
if (node.status !== "COMPLETED" || !node.conclusion) {
|
|
535
|
+
pending.push(waiting);
|
|
536
|
+
derived = worse(derived, "pending");
|
|
537
|
+
} else if (node.conclusion === "CANCELLED") {
|
|
538
|
+
cancelled.push(waiting);
|
|
539
|
+
} else if (FAILED_CONCLUSIONS.has(node.conclusion)) {
|
|
540
|
+
failing.push(name);
|
|
541
|
+
derived = worse(derived, "failure");
|
|
542
|
+
} else if (PASSED_CONCLUSIONS.has(node.conclusion)) {
|
|
543
|
+
derived = worse(derived, "success");
|
|
544
|
+
} else {
|
|
545
|
+
pending.push(waiting);
|
|
546
|
+
derived = worse(derived, "pending");
|
|
547
|
+
}
|
|
548
|
+
} else if (node.__typename === "StatusContext") {
|
|
549
|
+
const name = node.context ?? "unnamed status";
|
|
550
|
+
if (node.state === "FAILURE" || node.state === "ERROR") {
|
|
551
|
+
failing.push(name);
|
|
552
|
+
derived = worse(derived, "failure");
|
|
553
|
+
} else if (node.state === "SUCCESS") {
|
|
554
|
+
derived = worse(derived, "success");
|
|
555
|
+
} else {
|
|
556
|
+
pending.push({ name, kind: "status-context", detailsUrl: safeUrl(node.targetUrl) });
|
|
557
|
+
derived = worse(derived, "pending");
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const unread = read.length === 0 || rollupTruncated(rollup);
|
|
562
|
+
return { checks: unread ? worse(derived, toCheckState(rollup.state)) : derived, failing, pending, cancelled };
|
|
563
|
+
}
|
|
564
|
+
function toMergeable(state) {
|
|
565
|
+
return state === "MERGEABLE" || state === "CONFLICTING" ? state : "UNKNOWN";
|
|
566
|
+
}
|
|
567
|
+
var MERGE_STATES = [
|
|
568
|
+
"BEHIND",
|
|
569
|
+
"BLOCKED",
|
|
570
|
+
"CLEAN",
|
|
571
|
+
"DIRTY",
|
|
572
|
+
"HAS_HOOKS",
|
|
573
|
+
"UNKNOWN",
|
|
574
|
+
"UNSTABLE"
|
|
575
|
+
];
|
|
576
|
+
function toMergeState(state) {
|
|
577
|
+
if (typeof state !== "string" || state === "") return null;
|
|
578
|
+
return MERGE_STATES.includes(state) ? state : "UNKNOWN";
|
|
579
|
+
}
|
|
580
|
+
function toDecision(state) {
|
|
581
|
+
return state === "APPROVED" || state === "CHANGES_REQUESTED" || state === "REVIEW_REQUIRED" ? state : null;
|
|
582
|
+
}
|
|
583
|
+
function normalizePullRequest(raw, account) {
|
|
584
|
+
const repo = raw.repository?.nameWithOwner;
|
|
585
|
+
if (typeof raw.number !== "number" || typeof repo !== "string" || typeof raw.title !== "string") return null;
|
|
586
|
+
if (typeof raw.url !== "string" || typeof raw.createdAt !== "string") return null;
|
|
587
|
+
const head = raw.commits?.nodes?.[0]?.commit;
|
|
588
|
+
const ci = summarizeChecks(head?.statusCheckRollup);
|
|
589
|
+
const readyEvent = raw.readyEvents?.nodes?.find((node) => node?.createdAt)?.createdAt;
|
|
590
|
+
const readyAt = raw.isDraft ? raw.createdAt : readyEvent ?? raw.createdAt;
|
|
591
|
+
let lastByYou = raw.createdAt;
|
|
592
|
+
lastByYou = laterISO(lastByYou, readyAt);
|
|
593
|
+
lastByYou = laterISO(lastByYou, head?.committedDate);
|
|
594
|
+
let lastByOthers = null;
|
|
595
|
+
const consider = (actor, at, kind) => {
|
|
596
|
+
if (!at || isBot(actor)) return;
|
|
597
|
+
if (sameLogin(actor?.login, account)) {
|
|
598
|
+
lastByYou = laterISO(lastByYou, at);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (!lastByOthers || at > lastByOthers.at) lastByOthers = { at, login: actor?.login ?? "someone", kind };
|
|
602
|
+
};
|
|
603
|
+
const latestReview = /* @__PURE__ */ new Map();
|
|
604
|
+
for (const review of raw.reviews?.nodes ?? []) {
|
|
605
|
+
const at = review.submittedAt ?? null;
|
|
606
|
+
consider(review.author, at, "review");
|
|
607
|
+
if (!at || isBot(review.author) || sameLogin(review.author?.login, account)) continue;
|
|
608
|
+
const login = review.author?.login;
|
|
609
|
+
if (!login || typeof review.state !== "string") continue;
|
|
610
|
+
const verdict = review.state === "APPROVED" || review.state === "CHANGES_REQUESTED" || review.state === "DISMISSED";
|
|
611
|
+
if (verdict || !latestReview.has(login)) latestReview.set(login, { login, state: review.state, at });
|
|
612
|
+
}
|
|
613
|
+
for (const comment of raw.comments?.nodes ?? []) {
|
|
614
|
+
consider(comment.author, comment.createdAt ?? null, "comment");
|
|
615
|
+
}
|
|
616
|
+
const requestedReviewers = [];
|
|
617
|
+
for (const request of raw.reviewRequests?.nodes ?? []) {
|
|
618
|
+
const who = request.requestedReviewer;
|
|
619
|
+
const name = who?.login ?? who?.slug;
|
|
620
|
+
if (typeof name === "string" && name !== "") requestedReviewers.push(name);
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
id: `github:pr:${repo}#${raw.number}`,
|
|
624
|
+
account,
|
|
625
|
+
repo,
|
|
626
|
+
number: raw.number,
|
|
627
|
+
title: raw.title,
|
|
628
|
+
url: raw.url,
|
|
629
|
+
isDraft: raw.isDraft === true,
|
|
630
|
+
createdAt: raw.createdAt,
|
|
631
|
+
readyAt,
|
|
632
|
+
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : raw.createdAt,
|
|
633
|
+
headRef: typeof raw.headRefName === "string" ? raw.headRefName : "",
|
|
634
|
+
baseRef: typeof raw.baseRefName === "string" ? raw.baseRefName : "",
|
|
635
|
+
reviewDecision: toDecision(raw.reviewDecision),
|
|
636
|
+
checks: ci.checks,
|
|
637
|
+
failingChecks: ci.failing,
|
|
638
|
+
mergeable: toMergeable(raw.mergeable),
|
|
639
|
+
mergeStateStatus: toMergeState(raw.mergeStateStatus),
|
|
640
|
+
pendingChecks: ci.pending,
|
|
641
|
+
cancelledChecks: ci.cancelled,
|
|
642
|
+
autoMerge: Boolean(raw.autoMergeRequest?.enabledAt),
|
|
643
|
+
requestedReviewers,
|
|
644
|
+
reviews: [...latestReview.values()].filter((review) => review.state !== "DISMISSED"),
|
|
645
|
+
lastActivityByYou: lastByYou,
|
|
646
|
+
lastActivityByOthers: lastByOthers
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function buildProbeQuery(scope) {
|
|
650
|
+
const orgs = scope.filter((qualifier) => qualifier.startsWith("org:")).map((qualifier) => qualifier.slice("org:".length));
|
|
651
|
+
const fields = orgs.map(
|
|
652
|
+
(org, i) => `o${i}: repositoryOwner(login: ${JSON.stringify(org)}) { login repositories(first: 1) { totalCount } }`
|
|
653
|
+
);
|
|
654
|
+
return { query: `{ viewer { login } ${fields.join(" ")} }`, orgs };
|
|
655
|
+
}
|
|
656
|
+
function buildRemainingChecksQuery(batch) {
|
|
657
|
+
const fields = batch.map((walk, i) => {
|
|
658
|
+
const after = walk.cursor === null ? "null" : JSON.stringify(walk.cursor);
|
|
659
|
+
return `w${i}: node(id: ${JSON.stringify(walk.id)}) {
|
|
660
|
+
... on PullRequest {
|
|
661
|
+
commits(last: 1) {
|
|
662
|
+
nodes {
|
|
663
|
+
commit {
|
|
664
|
+
oid
|
|
665
|
+
statusCheckRollup { contexts(first: ${CONTEXT_PAGE_SIZE}, after: ${after}) { ${CHECK_CONTEXTS} } }
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}`;
|
|
671
|
+
});
|
|
672
|
+
return `{
|
|
673
|
+
${fields.join("\n ")}
|
|
674
|
+
}`;
|
|
675
|
+
}
|
|
676
|
+
async function walkRemainingChecks(token, walks, budget, signal) {
|
|
677
|
+
let requests = 0;
|
|
678
|
+
let rateLimitRemaining = null;
|
|
679
|
+
let failure = null;
|
|
680
|
+
let exhausted = false;
|
|
681
|
+
for (let round = 0; round < MAX_CONTEXT_PAGES && !exhausted; round++) {
|
|
682
|
+
const active = walks.filter((walk) => !walk.done);
|
|
683
|
+
if (active.length === 0) break;
|
|
684
|
+
for (let from = 0; from < active.length; from += CHECKS_BATCH_SIZE) {
|
|
685
|
+
if (requests >= budget) {
|
|
686
|
+
exhausted = true;
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
const batch = active.slice(from, from + CHECKS_BATCH_SIZE);
|
|
690
|
+
requests++;
|
|
691
|
+
let result;
|
|
692
|
+
try {
|
|
693
|
+
result = await graphql(token, buildRemainingChecksQuery(batch), {}, signal);
|
|
694
|
+
} catch (err) {
|
|
695
|
+
failure ??= err.message;
|
|
696
|
+
for (const walk of batch) walk.done = true;
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
rateLimitRemaining = result.rateLimitRemaining ?? rateLimitRemaining;
|
|
700
|
+
const fatal = result.errors.filter((error) => !isSamlError(error));
|
|
701
|
+
if (result.data === null && fatal.length > 0) {
|
|
702
|
+
failure ??= fatal.map((error) => error.message).join("; ");
|
|
703
|
+
for (const walk of batch) walk.done = true;
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
batch.forEach((walk, i) => {
|
|
707
|
+
const head = result.data?.[`w${i}`]?.commits?.nodes?.[0]?.commit;
|
|
708
|
+
if (walk.oid && head?.oid && head.oid !== walk.oid) {
|
|
709
|
+
walk.stale = true;
|
|
710
|
+
walk.done = true;
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const contexts = head?.statusCheckRollup?.contexts;
|
|
714
|
+
if (!contexts) {
|
|
715
|
+
walk.done = true;
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (typeof contexts.totalCount === "number") walk.totalCount = contexts.totalCount;
|
|
719
|
+
for (const node of contexts.nodes ?? []) if (node) walk.nodes.push(node);
|
|
720
|
+
if (contexts.pageInfo?.hasNextPage === true && contexts.pageInfo.endCursor) {
|
|
721
|
+
walk.cursor = contexts.pageInfo.endCursor;
|
|
722
|
+
} else {
|
|
723
|
+
walk.complete = true;
|
|
724
|
+
walk.done = true;
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return { requests, rateLimitRemaining, failure, exhausted };
|
|
730
|
+
}
|
|
731
|
+
async function fetchMergeStates(token, ids, signal) {
|
|
732
|
+
if (ids.length === 0) return { states: /* @__PURE__ */ new Map(), rateLimitRemaining: null };
|
|
733
|
+
const result = await graphql(token, MERGE_STATE_QUERY, { ids }, signal);
|
|
734
|
+
const fatal = result.errors.filter((error) => !isSamlError(error));
|
|
735
|
+
if (result.data?.nodes === void 0 && fatal.length > 0) {
|
|
736
|
+
throw new GitHubRequestError(fatal.map((error) => error.message).join("; "));
|
|
737
|
+
}
|
|
738
|
+
return { states: mergeStatesFromNodes(result.data?.nodes ?? []), rateLimitRemaining: result.rateLimitRemaining };
|
|
739
|
+
}
|
|
740
|
+
async function fetchPulls(token, scope, expectedLogin, signal) {
|
|
741
|
+
const warnings = [];
|
|
742
|
+
const probe = buildProbeQuery(scope);
|
|
743
|
+
const who = await graphql(token, probe.query, {}, signal);
|
|
744
|
+
const login = who.data?.viewer?.login ?? expectedLogin ?? "unknown";
|
|
745
|
+
const orgs = {};
|
|
746
|
+
let samlWarned = false;
|
|
747
|
+
probe.orgs.forEach((org, i) => {
|
|
748
|
+
const error = who.errors.find((candidate) => candidate.path?.[0] === `o${i}`);
|
|
749
|
+
if (error && isSamlError(error)) {
|
|
750
|
+
orgs[org] = "saml";
|
|
751
|
+
samlWarned = true;
|
|
752
|
+
warnings.push(
|
|
753
|
+
`${login} can't see ${org}: the token isn't authorised for that organisation's single sign-on. Run \`gh auth refresh\` as ${login}, or authorise the GitHub CLI app for ${org} under GitHub's SSO settings.`
|
|
754
|
+
);
|
|
755
|
+
} else if (!error && who.data?.[`o${i}`] === null) {
|
|
756
|
+
orgs[org] = "not-found";
|
|
757
|
+
} else {
|
|
758
|
+
orgs[org] = "ok";
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
const pulls = [];
|
|
762
|
+
let after = null;
|
|
763
|
+
let rateLimitRemaining = who.rateLimitRemaining;
|
|
764
|
+
let mergeStateWarned = false;
|
|
765
|
+
let contextsWarned = false;
|
|
766
|
+
let contextBudgetWarned = false;
|
|
767
|
+
let contextRequests = 0;
|
|
768
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
769
|
+
const result = await graphql(token, SEARCH_QUERY, { q: buildSearchQuery(scope), first: PAGE_SIZE, after }, signal);
|
|
770
|
+
rateLimitRemaining = result.rateLimitRemaining ?? rateLimitRemaining;
|
|
771
|
+
const fatal = result.errors.filter((error) => !isSamlError(error));
|
|
772
|
+
if (result.data?.search === void 0 && fatal.length > 0) {
|
|
773
|
+
throw new GitHubRequestError(`GitHub search failed: ${fatal.map((error) => error.message).join("; ")}`);
|
|
774
|
+
}
|
|
775
|
+
if (!samlWarned && result.errors.some(isSamlError)) {
|
|
776
|
+
samlWarned = true;
|
|
777
|
+
warnings.push(`${login}: some results were withheld by an organisation's single sign-on policy.`);
|
|
778
|
+
}
|
|
779
|
+
const found = /* @__PURE__ */ new Map();
|
|
780
|
+
for (const node of result.data?.search?.nodes ?? []) {
|
|
781
|
+
if (!node) continue;
|
|
782
|
+
const pull = normalizePullRequest(node, login);
|
|
783
|
+
if (!pull) continue;
|
|
784
|
+
if (typeof node.id !== "string" || node.id === "") continue;
|
|
785
|
+
pulls.push(pull);
|
|
786
|
+
found.set(node.id, pull);
|
|
787
|
+
}
|
|
788
|
+
try {
|
|
789
|
+
const states = await fetchMergeStates(token, [...found.keys()], signal);
|
|
790
|
+
rateLimitRemaining = states.rateLimitRemaining ?? rateLimitRemaining;
|
|
791
|
+
for (const [nodeId, pull] of found) {
|
|
792
|
+
if (states.states.has(nodeId)) pull.mergeStateStatus = states.states.get(nodeId) ?? null;
|
|
793
|
+
}
|
|
794
|
+
} catch (err) {
|
|
795
|
+
if (!mergeStateWarned) {
|
|
796
|
+
mergeStateWarned = true;
|
|
797
|
+
warnings.push(
|
|
798
|
+
`${login}: GitHub wouldn't say whether these pull requests can be merged (${err.message}). Ready to merge falls back to the reviews and checks until the next poll.`
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const partial = [];
|
|
803
|
+
const ids = [...found.keys()];
|
|
804
|
+
for (let from = 0; from < ids.length; from += CHECKS_BATCH_SIZE) {
|
|
805
|
+
const batch = ids.slice(from, from + CHECKS_BATCH_SIZE);
|
|
806
|
+
const checks = await fetchChecks(token, batch, signal);
|
|
807
|
+
rateLimitRemaining = checks.rateLimitRemaining ?? rateLimitRemaining;
|
|
808
|
+
for (const [nodeId, head] of checks.heads) {
|
|
809
|
+
const pull = found.get(nodeId);
|
|
810
|
+
if (!pull) continue;
|
|
811
|
+
applyChecks(pull, head.rollup);
|
|
812
|
+
if (head.rollup && rollupTruncated(head.rollup)) {
|
|
813
|
+
partial.push({ id: nodeId, pull, oid: head.oid, rollup: head.rollup });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (partial.length > 0) {
|
|
818
|
+
const walks = partial.map((entry) => ({
|
|
819
|
+
...entry,
|
|
820
|
+
nodes: [],
|
|
821
|
+
cursor: entry.rollup.contexts?.pageInfo?.endCursor ?? null,
|
|
822
|
+
totalCount: entry.rollup.contexts?.totalCount,
|
|
823
|
+
complete: false,
|
|
824
|
+
stale: false,
|
|
825
|
+
done: false
|
|
826
|
+
}));
|
|
827
|
+
const walked = await walkRemainingChecks(token, walks, MAX_CONTEXT_REQUESTS - contextRequests, signal);
|
|
828
|
+
contextRequests += walked.requests;
|
|
829
|
+
rateLimitRemaining = walked.rateLimitRemaining ?? rateLimitRemaining;
|
|
830
|
+
for (const walk of walks) {
|
|
831
|
+
if (walk.stale || walk.nodes.length === 0) continue;
|
|
832
|
+
applyChecks(walk.pull, {
|
|
833
|
+
state: walk.rollup.state,
|
|
834
|
+
contexts: {
|
|
835
|
+
totalCount: walk.totalCount,
|
|
836
|
+
// What the walk found out, said in the one field that outranks the
|
|
837
|
+
// count: complete means the checks themselves have the last word.
|
|
838
|
+
pageInfo: { hasNextPage: !walk.complete },
|
|
839
|
+
nodes: [...walk.rollup.contexts?.nodes ?? [], ...walk.nodes]
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
if (walked.failure && !contextsWarned) {
|
|
844
|
+
contextsWarned = true;
|
|
845
|
+
warnings.push(
|
|
846
|
+
`${login}: GitHub wouldn't list all the checks on some pull requests (${walked.failure}). Their CI state comes from GitHub's own summary until the next poll.`
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
if (walked.exhausted && !contextBudgetWarned) {
|
|
850
|
+
contextBudgetWarned = true;
|
|
851
|
+
warnings.push(
|
|
852
|
+
`${login}: some pull requests have more checks than the board reads in one poll, so their CI state comes from GitHub's own summary rather than the checks themselves.`
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const pageInfo = result.data?.search?.pageInfo;
|
|
857
|
+
if (!pageInfo?.hasNextPage || !pageInfo.endCursor) break;
|
|
858
|
+
after = pageInfo.endCursor;
|
|
859
|
+
if (page === MAX_PAGES - 1) {
|
|
860
|
+
warnings.push(`${login} has more open pull requests than the board reads (${MAX_PAGES * PAGE_SIZE}); narrow the scope.`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return { login, pulls, warnings, orgs, rateLimitRemaining };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/prs.ts
|
|
867
|
+
var NUDGE_AFTER_MS = 24 * 36e5;
|
|
868
|
+
var STALE_DRAFT_AFTER_MS = 14 * 864e5;
|
|
869
|
+
var COURT_ORDER = ["you", "ready", "reviewers", "gate", "blocked", "checks", "draft"];
|
|
870
|
+
function courtOrder(court) {
|
|
871
|
+
return COURT_ORDER.indexOf(court);
|
|
872
|
+
}
|
|
873
|
+
function ms(iso) {
|
|
874
|
+
const parsed = parseISO(iso);
|
|
875
|
+
return parsed ? parsed.getTime() : null;
|
|
876
|
+
}
|
|
877
|
+
function deriveDecision(pr) {
|
|
878
|
+
if (pr.reviewDecision) return pr.reviewDecision;
|
|
879
|
+
if (pr.reviews.some((review) => review.state === "CHANGES_REQUESTED")) return "CHANGES_REQUESTED";
|
|
880
|
+
if (pr.reviews.some((review) => review.state === "APPROVED")) return "APPROVED";
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
function matchMergeGates(pr, gates) {
|
|
884
|
+
if (gates.length === 0) return [];
|
|
885
|
+
return (pr.pendingChecks ?? []).filter((check) => gates.includes(check.name));
|
|
886
|
+
}
|
|
887
|
+
function newest(pr, state) {
|
|
888
|
+
return pr.reviews.filter((review) => review.state === state).sort((a, b) => a.at < b.at ? 1 : -1)[0];
|
|
889
|
+
}
|
|
890
|
+
function judge(pr, now, lastNoteAt, gates = []) {
|
|
891
|
+
const decision = deriveDecision(pr);
|
|
892
|
+
const you = pr.lastActivityByYou ?? null;
|
|
893
|
+
const others = pr.lastActivityByOthers ?? null;
|
|
894
|
+
const mergeState = pr.mergeStateStatus ?? null;
|
|
895
|
+
if (pr.isDraft) {
|
|
896
|
+
const touched = you ?? pr.createdAt;
|
|
897
|
+
const idle = now.getTime() - (ms(touched) ?? now.getTime());
|
|
898
|
+
return { court: "draft", decision, reasons: [], stale: idle >= STALE_DRAFT_AFTER_MS, nudge: false, since: touched };
|
|
899
|
+
}
|
|
900
|
+
const reasons = [];
|
|
901
|
+
if (decision === "CHANGES_REQUESTED") {
|
|
902
|
+
const request = newest(pr, "CHANGES_REQUESTED");
|
|
903
|
+
reasons.push({ kind: "changes-requested", login: request?.login, at: request?.at });
|
|
904
|
+
}
|
|
905
|
+
if (pr.checks === "failure") reasons.push({ kind: "ci-failing" });
|
|
906
|
+
if (pr.mergeable === "CONFLICTING" || mergeState === "DIRTY") reasons.push({ kind: "conflicts" });
|
|
907
|
+
if (mergeState === "BEHIND") reasons.push({ kind: "behind" });
|
|
908
|
+
const theirMove = others !== null && (you === null || others.at > you);
|
|
909
|
+
const approval = newest(pr, "APPROVED");
|
|
910
|
+
const afterApproval = others !== null && approval !== void 0 && others.at > approval.at;
|
|
911
|
+
const approved = decision === "APPROVED" && !afterApproval;
|
|
912
|
+
if (theirMove && others && !approved) {
|
|
913
|
+
reasons.push({ kind: "activity", login: others.login, at: others.at, activity: others.kind });
|
|
914
|
+
}
|
|
915
|
+
if (reasons.length > 0) {
|
|
916
|
+
const since = theirMove && others ? others.at : pr.updatedAt;
|
|
917
|
+
return { court: "you", decision, reasons, stale: false, nudge: false, since };
|
|
918
|
+
}
|
|
919
|
+
const waitingSince = laterISO(pr.readyAt, you) ?? pr.createdAt;
|
|
920
|
+
const settled = (court, reason) => ({
|
|
921
|
+
court,
|
|
922
|
+
decision,
|
|
923
|
+
reasons: [reason],
|
|
924
|
+
stale: false,
|
|
925
|
+
nudge: false,
|
|
926
|
+
since: waitingSince
|
|
927
|
+
});
|
|
928
|
+
if (decision !== "REVIEW_REQUIRED") {
|
|
929
|
+
const gate = matchMergeGates(pr, gates);
|
|
930
|
+
if (gate.length > 0) return settled("gate", { kind: "merge-gate", checks: gate });
|
|
931
|
+
const pending = pr.pendingChecks ?? [];
|
|
932
|
+
const running = { kind: "checks-pending" };
|
|
933
|
+
if (pending.length > 0) running.checks = pending;
|
|
934
|
+
if (mergeState === "BLOCKED" && pending.length === 0) {
|
|
935
|
+
return settled("blocked", { kind: "merge-blocked" });
|
|
936
|
+
}
|
|
937
|
+
if (mergeState === "BLOCKED" || mergeState === "UNSTABLE") return settled("checks", running);
|
|
938
|
+
if (mergeState === "UNKNOWN") return settled("checks", { kind: "mergeability-unknown" });
|
|
939
|
+
if (approved) {
|
|
940
|
+
if (mergeState === "CLEAN" || mergeState === "HAS_HOOKS") {
|
|
941
|
+
return { court: "ready", decision, reasons: [], stale: false, nudge: false, since: approval?.at ?? pr.updatedAt };
|
|
942
|
+
}
|
|
943
|
+
if (mergeState === null) {
|
|
944
|
+
const unfinished = pr.checks === "pending" || pending.length > 0;
|
|
945
|
+
if (!unfinished) {
|
|
946
|
+
return { court: "ready", decision, reasons: [], stale: false, nudge: false, since: approval?.at ?? pr.updatedAt };
|
|
947
|
+
}
|
|
948
|
+
return settled("checks", running);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const quietSince = laterISO(waitingSince, lastNoteAt) ?? waitingSince;
|
|
953
|
+
const waited = now.getTime() - (ms(quietSince) ?? now.getTime());
|
|
954
|
+
return { court: "reviewers", decision, reasons, stale: false, nudge: waited >= NUDGE_AFTER_MS, since: waitingSince };
|
|
955
|
+
}
|
|
956
|
+
function resolveBoard(pulls, actions, now, gates = []) {
|
|
957
|
+
const folded = foldActionLog(actions, now);
|
|
958
|
+
const rows = pulls.map((pr) => {
|
|
959
|
+
const log = folded.get(canonicalId(pr.id));
|
|
960
|
+
const notes = log?.notes ?? [];
|
|
961
|
+
const lastNoteAt = notes.length > 0 ? notes[notes.length - 1].at : null;
|
|
962
|
+
const verdict = judge(pr, now, lastNoteAt, gates);
|
|
963
|
+
const row = { ...pr, ...verdict, status: log?.status === "snoozed" ? "snoozed" : "open", notes };
|
|
964
|
+
if (row.status === "snoozed" && log?.snoozedUntil) row.snoozedUntil = log.snoozedUntil;
|
|
965
|
+
return row;
|
|
966
|
+
});
|
|
967
|
+
return rows.sort((a, b) => {
|
|
968
|
+
const court = courtOrder(a.court) - courtOrder(b.court);
|
|
969
|
+
if (court !== 0) return court;
|
|
970
|
+
if (a.since !== b.since) return a.since < b.since ? -1 : 1;
|
|
971
|
+
return a.id < b.id ? -1 : 1;
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
function countBoard(rows) {
|
|
975
|
+
const counts = {
|
|
976
|
+
you: 0,
|
|
977
|
+
ready: 0,
|
|
978
|
+
reviewers: 0,
|
|
979
|
+
gate: 0,
|
|
980
|
+
blocked: 0,
|
|
981
|
+
checks: 0,
|
|
982
|
+
draft: 0,
|
|
983
|
+
parked: 0
|
|
984
|
+
};
|
|
985
|
+
for (const row of rows) {
|
|
986
|
+
if (row.status === "snoozed") counts.parked++;
|
|
987
|
+
else counts[row.court]++;
|
|
988
|
+
}
|
|
989
|
+
return counts;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// src/board.ts
|
|
993
|
+
var MAX_BACKOFF_MS = 30 * 6e4;
|
|
994
|
+
var RATE_LIMIT_FLOOR = 100;
|
|
995
|
+
var realDeps = { listAccounts: listGhAccounts, token: ghToken, fetch: fetchPulls };
|
|
996
|
+
var Board = class {
|
|
997
|
+
#config;
|
|
998
|
+
#deps;
|
|
999
|
+
#onChange;
|
|
1000
|
+
/** The last successful fetch, as on disk. */
|
|
1001
|
+
#file = null;
|
|
1002
|
+
/**
|
|
1003
|
+
* What the last attempt found out, successful or not. Replaced when an attempt
|
|
1004
|
+
* ends rather than cleared when one starts, so a banner stays where it is
|
|
1005
|
+
* through a poll — and one somebody opened to press Retry stays open to show
|
|
1006
|
+
* how the retry went.
|
|
1007
|
+
*/
|
|
1008
|
+
#accounts = [];
|
|
1009
|
+
#accountDetails = /* @__PURE__ */ new Map();
|
|
1010
|
+
#attemptWarnings = [];
|
|
1011
|
+
/** Why nothing can be polled at all right now, when that's the case. */
|
|
1012
|
+
#reason = null;
|
|
1013
|
+
/** Polls in a row that read nothing at all, which also spaces the next one out. */
|
|
1014
|
+
#streak = null;
|
|
1015
|
+
/** Each failing account's own run, by lowercased login: one can fail while the other answers. */
|
|
1016
|
+
#accountStreaks = /* @__PURE__ */ new Map();
|
|
1017
|
+
#fetching = null;
|
|
1018
|
+
#timer = null;
|
|
1019
|
+
#audience = 0;
|
|
1020
|
+
#stopped = false;
|
|
1021
|
+
/** When the last poll finished, success or not. Zero until one has. */
|
|
1022
|
+
#lastPollAt = 0;
|
|
1023
|
+
/** When the rate limit says to leave GitHub alone until. */
|
|
1024
|
+
#holdUntil = 0;
|
|
1025
|
+
#abort = new AbortController();
|
|
1026
|
+
constructor(config, onChange, deps = realDeps) {
|
|
1027
|
+
this.#config = config;
|
|
1028
|
+
this.#deps = deps;
|
|
1029
|
+
this.#onChange = onChange;
|
|
1030
|
+
}
|
|
1031
|
+
get enabled() {
|
|
1032
|
+
return this.#config.github.enabled;
|
|
1033
|
+
}
|
|
1034
|
+
/** Load what the last run left on disk, then fetch once regardless of audience. */
|
|
1035
|
+
async start() {
|
|
1036
|
+
if (!this.enabled) return;
|
|
1037
|
+
this.#file = await readPullsFile(this.#config.pullsFile);
|
|
1038
|
+
await this.refresh();
|
|
1039
|
+
}
|
|
1040
|
+
/** Stop polling and cancel a fetch in flight, so nothing lands after close. */
|
|
1041
|
+
stop() {
|
|
1042
|
+
this.#stopped = true;
|
|
1043
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
1044
|
+
this.#timer = null;
|
|
1045
|
+
this.#abort.abort();
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* How many browsers are watching. Polling pauses at zero, and a tab opening on
|
|
1049
|
+
* a board that is already older than one interval fetches straight away rather
|
|
1050
|
+
* than waiting one out.
|
|
1051
|
+
*/
|
|
1052
|
+
setAudience(count) {
|
|
1053
|
+
this.#audience = count;
|
|
1054
|
+
if (count === 0) {
|
|
1055
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
1056
|
+
this.#timer = null;
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (this.#timer || this.#fetching) return;
|
|
1060
|
+
if (this.#dueIn() === 0) void this.refresh();
|
|
1061
|
+
else this.#schedule();
|
|
1062
|
+
}
|
|
1063
|
+
/** Fetch now. Joins a fetch already in flight rather than starting a second. Never rejects. */
|
|
1064
|
+
async refresh() {
|
|
1065
|
+
if (!this.enabled || this.#stopped) return;
|
|
1066
|
+
if (this.#fetching) return this.#fetching;
|
|
1067
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
1068
|
+
this.#timer = null;
|
|
1069
|
+
this.#fetching = this.#poll().catch((err) => {
|
|
1070
|
+
this.#reason = { message: `The board hit an unexpected error: ${err.message}`, detail: unexpectedDetail(err) };
|
|
1071
|
+
this.#streak = extend(this.#streak);
|
|
1072
|
+
console.error(`[daily-focus] pull request poll failed: ${err.stack ?? String(err)}`);
|
|
1073
|
+
}).finally(() => {
|
|
1074
|
+
this.#fetching = null;
|
|
1075
|
+
this.#lastPollAt = Date.now();
|
|
1076
|
+
this.#schedule();
|
|
1077
|
+
this.#onChange();
|
|
1078
|
+
});
|
|
1079
|
+
this.#onChange();
|
|
1080
|
+
return this.#fetching;
|
|
1081
|
+
}
|
|
1082
|
+
/** The board joined with the action log, ready to render. Never throws. */
|
|
1083
|
+
view(actions, now) {
|
|
1084
|
+
const warnings = this.enabled ? [...this.#attemptWarnings] : [];
|
|
1085
|
+
let rows = [];
|
|
1086
|
+
if (this.enabled) {
|
|
1087
|
+
try {
|
|
1088
|
+
rows = resolveBoard(this.#file?.pulls ?? [], actions, now, this.#config.github.mergeGateChecks);
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
warnings.push(`Could not judge the pull requests on file: ${err.message}`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
const accounts = this.#reason ? [] : this.#accounts.length > 0 ? this.#accounts : this.#file?.accounts ?? [];
|
|
1094
|
+
const retrying = this.#fetching !== null;
|
|
1095
|
+
const retryAt = this.#stopped ? null : Date.now() + this.#dueIn();
|
|
1096
|
+
return {
|
|
1097
|
+
enabled: this.enabled,
|
|
1098
|
+
reason: this.enabled && this.#reason ? toReadFailure(this.#reason, this.#streak, retryAt, retrying) : null,
|
|
1099
|
+
failures: this.enabled && !this.#reason ? this.#accountFailures(retryAt, retrying) : [],
|
|
1100
|
+
fetchedAt: this.#file?.fetchedAt ?? null,
|
|
1101
|
+
fetching: this.#fetching !== null,
|
|
1102
|
+
accounts: accounts.map((account) => ({ ...account })),
|
|
1103
|
+
scope: [...this.#config.github.scope],
|
|
1104
|
+
warnings,
|
|
1105
|
+
pollMinutes: this.#config.github.pollMinutes,
|
|
1106
|
+
rows,
|
|
1107
|
+
counts: countBoard(rows)
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* The accounts the last poll couldn't read, one failure per distinct error.
|
|
1112
|
+
* Two accounts cut off by the same outage are one problem, and two banners
|
|
1113
|
+
* saying so only make it look like two.
|
|
1114
|
+
*/
|
|
1115
|
+
#accountFailures(retryAt, retrying) {
|
|
1116
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1117
|
+
for (const account of this.#accounts) {
|
|
1118
|
+
if (account.ok || !account.error) continue;
|
|
1119
|
+
groups.set(account.error, [...groups.get(account.error) ?? [], account]);
|
|
1120
|
+
}
|
|
1121
|
+
return [...groups].map(([error, members]) => {
|
|
1122
|
+
const details = members.map((account) => this.#accountDetails.get(account.login) ?? "");
|
|
1123
|
+
const detail = new Set(details).size === 1 ? details[0] : members.map((account, i) => `${account.login}:
|
|
1124
|
+
${details[i] || "(nothing more)"}`).join("\n\n");
|
|
1125
|
+
const streaks = members.flatMap((account) => this.#accountStreaks.get(account.login.toLowerCase()) ?? []);
|
|
1126
|
+
const streak = streaks.length > 0 ? { since: Math.min(...streaks.map((s) => s.since)), attempts: Math.max(...streaks.map((s) => s.attempts)) } : null;
|
|
1127
|
+
const message = `${listNames(members.map((account) => account.login))}: ${error}`;
|
|
1128
|
+
return toReadFailure({ message, detail }, streak, retryAt, retrying);
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
/** Milliseconds until the next poll is due, from the last one, backoff and any hold. */
|
|
1132
|
+
#dueIn() {
|
|
1133
|
+
const base = this.#config.github.pollMinutes * 6e4;
|
|
1134
|
+
const backoff = Math.min(MAX_BACKOFF_MS, base * 2 ** (this.#streak?.attempts ?? 0));
|
|
1135
|
+
const due = Math.max(this.#lastPollAt + backoff, this.#holdUntil);
|
|
1136
|
+
return Math.max(0, due - Date.now());
|
|
1137
|
+
}
|
|
1138
|
+
#schedule() {
|
|
1139
|
+
if (this.#stopped || this.#audience === 0 || this.#timer || this.#fetching) return;
|
|
1140
|
+
this.#timer = setTimeout(() => {
|
|
1141
|
+
this.#timer = null;
|
|
1142
|
+
void this.refresh();
|
|
1143
|
+
}, this.#dueIn());
|
|
1144
|
+
this.#timer.unref();
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Which logins to poll as, and with what.
|
|
1148
|
+
*
|
|
1149
|
+
* Configured accounts are each named to gh explicitly; one that gh doesn't know
|
|
1150
|
+
* becomes a warning and the rest carry on. With nothing configured the active
|
|
1151
|
+
* account is used, and a second account being present is worth saying, since
|
|
1152
|
+
* "active" is whichever one was last switched to in a terminal.
|
|
1153
|
+
*/
|
|
1154
|
+
async #identities(attempt) {
|
|
1155
|
+
const { ghPath, accounts } = this.#config.github;
|
|
1156
|
+
if (accounts.length > 0) {
|
|
1157
|
+
const tokens = await Promise.allSettled(accounts.map((login) => this.#deps.token(ghPath, login)));
|
|
1158
|
+
const found = [];
|
|
1159
|
+
tokens.forEach((token2, i) => {
|
|
1160
|
+
const login = accounts[i];
|
|
1161
|
+
if (token2.status === "fulfilled") {
|
|
1162
|
+
const account2 = { login, ok: true, error: null };
|
|
1163
|
+
attempt.accounts.push(account2);
|
|
1164
|
+
found.push({ account: account2, token: token2.value });
|
|
1165
|
+
} else if (token2.reason instanceof GhMissingError) {
|
|
1166
|
+
throw token2.reason;
|
|
1167
|
+
} else {
|
|
1168
|
+
attempt.accounts.push({ login, ok: false, error: token2.reason.message });
|
|
1169
|
+
attempt.details.set(login, errorDetail(token2.reason));
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
return found;
|
|
1173
|
+
}
|
|
1174
|
+
const known = await this.#deps.listAccounts(ghPath);
|
|
1175
|
+
const active = known.find((account2) => account2.active) ?? known[0] ?? null;
|
|
1176
|
+
if (!active) {
|
|
1177
|
+
throw new Error("gh is not logged in to github.com \u2014 run `gh auth login`, or set DAILY_FOCUS_GITHUB=off");
|
|
1178
|
+
}
|
|
1179
|
+
if (known.length > 1) {
|
|
1180
|
+
attempt.warnings.push(
|
|
1181
|
+
`gh holds ${known.length} github.com accounts on this machine; polling only the active one, ${active.login}. Set DAILY_FOCUS_GITHUB_ACCOUNTS to poll more than one.`
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
const token = await this.#deps.token(ghPath, null);
|
|
1185
|
+
const account = { login: active.login, ok: true, error: null };
|
|
1186
|
+
attempt.accounts.push(account);
|
|
1187
|
+
return [{ account, token }];
|
|
1188
|
+
}
|
|
1189
|
+
async #poll() {
|
|
1190
|
+
if (Date.now() < this.#holdUntil) {
|
|
1191
|
+
const until = new Date(this.#holdUntil).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
1192
|
+
const notice = `Paused near GitHub's rate limit until ${until}.`;
|
|
1193
|
+
if (!this.#attemptWarnings.includes(notice)) this.#attemptWarnings.push(notice);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
const previous = this.#file;
|
|
1197
|
+
const attempt = { accounts: [], details: /* @__PURE__ */ new Map(), warnings: [] };
|
|
1198
|
+
const publish = (reason) => {
|
|
1199
|
+
this.#accounts = attempt.accounts;
|
|
1200
|
+
this.#accountDetails = attempt.details;
|
|
1201
|
+
this.#attemptWarnings = attempt.warnings;
|
|
1202
|
+
this.#reason = reason;
|
|
1203
|
+
};
|
|
1204
|
+
let identities;
|
|
1205
|
+
try {
|
|
1206
|
+
identities = await this.#identities(attempt);
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
publish({ message: err.message, detail: errorDetail(err) });
|
|
1209
|
+
this.#streak = extend(this.#streak);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const pulls = [];
|
|
1213
|
+
const warnings = [];
|
|
1214
|
+
const visibility = /* @__PURE__ */ new Map();
|
|
1215
|
+
let lowestRemaining = null;
|
|
1216
|
+
const results = await Promise.allSettled(
|
|
1217
|
+
identities.map(
|
|
1218
|
+
(identity2) => this.#deps.fetch(identity2.token, this.#config.github.scope, identity2.account.login, this.#abort.signal)
|
|
1219
|
+
)
|
|
1220
|
+
);
|
|
1221
|
+
results.forEach((result, i) => {
|
|
1222
|
+
const { account } = identities[i];
|
|
1223
|
+
if (result.status === "rejected") {
|
|
1224
|
+
account.ok = false;
|
|
1225
|
+
account.error = result.reason.message;
|
|
1226
|
+
attempt.details.set(account.login, errorDetail(result.reason));
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
account.login = result.value.login;
|
|
1230
|
+
pulls.push(...result.value.pulls);
|
|
1231
|
+
warnings.push(...result.value.warnings);
|
|
1232
|
+
for (const [org, seen] of Object.entries(result.value.orgs)) {
|
|
1233
|
+
visibility.set(org, [...visibility.get(org) ?? [], seen]);
|
|
1234
|
+
}
|
|
1235
|
+
if (result.value.rateLimitRemaining !== null) {
|
|
1236
|
+
lowestRemaining = Math.min(lowestRemaining ?? Infinity, result.value.rateLimitRemaining);
|
|
1237
|
+
}
|
|
1238
|
+
});
|
|
1239
|
+
const failed = new Set(attempt.accounts.filter((account) => !account.ok).map((account) => account.login.toLowerCase()));
|
|
1240
|
+
for (const pull of previous?.pulls ?? []) {
|
|
1241
|
+
if (failed.has(pull.account.toLowerCase())) pulls.push(pull);
|
|
1242
|
+
}
|
|
1243
|
+
for (const [org, seen] of visibility) {
|
|
1244
|
+
if (seen.length > 0 && seen.every((state) => state === "not-found")) {
|
|
1245
|
+
warnings.push(
|
|
1246
|
+
seen.length === 1 ? `Can't find an organisation or user called ${org} \u2014 check DAILY_FOCUS_GITHUB_SCOPE.` : `None of the polled accounts can find an organisation or user called ${org} \u2014 check DAILY_FOCUS_GITHUB_SCOPE.`
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
this.#accountStreaks = new Map(
|
|
1251
|
+
[...failed].map((login) => [login, extend(this.#accountStreaks.get(login) ?? null)])
|
|
1252
|
+
);
|
|
1253
|
+
attempt.warnings.push(...warnings);
|
|
1254
|
+
if (!attempt.accounts.some((account) => account.ok)) {
|
|
1255
|
+
publish(null);
|
|
1256
|
+
this.#streak = extend(this.#streak);
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if (lowestRemaining !== null && lowestRemaining < RATE_LIMIT_FLOOR) {
|
|
1260
|
+
this.#holdUntil = Date.now() + MAX_BACKOFF_MS;
|
|
1261
|
+
attempt.warnings.push("Close to GitHub's rate limit; pausing the board for half an hour.");
|
|
1262
|
+
}
|
|
1263
|
+
publish(null);
|
|
1264
|
+
this.#streak = null;
|
|
1265
|
+
this.#file = {
|
|
1266
|
+
version: 1,
|
|
1267
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1268
|
+
accounts: attempt.accounts.map((account) => ({ ...account })),
|
|
1269
|
+
scope: [...this.#config.github.scope],
|
|
1270
|
+
warnings,
|
|
1271
|
+
pulls
|
|
1272
|
+
};
|
|
1273
|
+
try {
|
|
1274
|
+
await writeJsonAtomic(this.#config.pullsFile, this.#file);
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
console.warn(`[daily-focus] could not write prs.json: ${err.message}`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
function listNames(names) {
|
|
1281
|
+
return names.length < 2 ? names[0] ?? "" : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
|
1282
|
+
}
|
|
1283
|
+
function isRecord(value) {
|
|
1284
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1285
|
+
}
|
|
1286
|
+
function str(value, fallback) {
|
|
1287
|
+
return typeof value === "string" ? value : fallback;
|
|
1288
|
+
}
|
|
1289
|
+
function strings(value) {
|
|
1290
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
1291
|
+
}
|
|
1292
|
+
function oneOf(value, allowed, fallback) {
|
|
1293
|
+
return typeof value === "string" && allowed.includes(value) ? value : fallback;
|
|
1294
|
+
}
|
|
1295
|
+
function namedChecks(value) {
|
|
1296
|
+
if (!Array.isArray(value)) return [];
|
|
1297
|
+
return value.flatMap((entry) => {
|
|
1298
|
+
if (!isRecord(entry) || typeof entry.name !== "string" || entry.name === "") return [];
|
|
1299
|
+
const check = {
|
|
1300
|
+
name: entry.name,
|
|
1301
|
+
kind: entry.kind === "status-context" ? "status-context" : "check-run",
|
|
1302
|
+
// Re-checked on the way in as well as on the way out: the file is ours, but
|
|
1303
|
+
// a link that reaches an anchor tag should never have been trusted twice.
|
|
1304
|
+
detailsUrl: typeof entry.detailsUrl === "string" && /^https?:\/\//i.test(entry.detailsUrl) ? entry.detailsUrl : null
|
|
1305
|
+
};
|
|
1306
|
+
if (typeof entry.checkRunId === "number") check.checkRunId = entry.checkRunId;
|
|
1307
|
+
return [check];
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
function normalizeStoredPull(raw) {
|
|
1311
|
+
if (!isRecord(raw)) return null;
|
|
1312
|
+
const { id, repo, number, title, url, createdAt } = raw;
|
|
1313
|
+
if (typeof id !== "string" || typeof repo !== "string" || typeof number !== "number") return null;
|
|
1314
|
+
if (typeof title !== "string" || typeof url !== "string" || typeof createdAt !== "string") return null;
|
|
1315
|
+
const reviews = Array.isArray(raw.reviews) ? raw.reviews.flatMap(
|
|
1316
|
+
(review) => isRecord(review) && typeof review.login === "string" && typeof review.state === "string" && typeof review.at === "string" ? [{ login: review.login, state: review.state, at: review.at }] : []
|
|
1317
|
+
) : [];
|
|
1318
|
+
let lastActivityByOthers = null;
|
|
1319
|
+
const others = raw.lastActivityByOthers;
|
|
1320
|
+
if (isRecord(others) && typeof others.at === "string" && typeof others.login === "string") {
|
|
1321
|
+
lastActivityByOthers = { at: others.at, login: others.login, kind: others.kind === "review" ? "review" : "comment" };
|
|
1322
|
+
}
|
|
1323
|
+
return {
|
|
1324
|
+
id,
|
|
1325
|
+
account: str(raw.account, ""),
|
|
1326
|
+
repo,
|
|
1327
|
+
number,
|
|
1328
|
+
title,
|
|
1329
|
+
url,
|
|
1330
|
+
isDraft: raw.isDraft === true,
|
|
1331
|
+
createdAt,
|
|
1332
|
+
readyAt: str(raw.readyAt, createdAt),
|
|
1333
|
+
updatedAt: str(raw.updatedAt, createdAt),
|
|
1334
|
+
headRef: str(raw.headRef, ""),
|
|
1335
|
+
baseRef: str(raw.baseRef, ""),
|
|
1336
|
+
reviewDecision: oneOf(raw.reviewDecision, ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"], null),
|
|
1337
|
+
checks: oneOf(raw.checks, ["success", "failure", "pending"], null),
|
|
1338
|
+
failingChecks: strings(raw.failingChecks),
|
|
1339
|
+
mergeable: oneOf(raw.mergeable, ["MERGEABLE", "CONFLICTING", "UNKNOWN"], "UNKNOWN") ?? "UNKNOWN",
|
|
1340
|
+
// Read through the same normaliser a fresh fetch uses, so the file and the API
|
|
1341
|
+
// can't come to different conclusions about what a merge state means: absent
|
|
1342
|
+
// is null and falls back to the older reading of ready, and a value the enum
|
|
1343
|
+
// doesn't have is `UNKNOWN` rather than anything resembling a clean merge.
|
|
1344
|
+
mergeStateStatus: toMergeState(raw.mergeStateStatus),
|
|
1345
|
+
pendingChecks: namedChecks(raw.pendingChecks),
|
|
1346
|
+
// Absent on a cache written before cancellations were told apart from
|
|
1347
|
+
// failures, where they were folded into `failingChecks` instead. Nothing
|
|
1348
|
+
// needs migrating: the next fetch overwrites both.
|
|
1349
|
+
cancelledChecks: namedChecks(raw.cancelledChecks),
|
|
1350
|
+
autoMerge: raw.autoMerge === true,
|
|
1351
|
+
requestedReviewers: strings(raw.requestedReviewers),
|
|
1352
|
+
reviews,
|
|
1353
|
+
lastActivityByYou: typeof raw.lastActivityByYou === "string" ? raw.lastActivityByYou : null,
|
|
1354
|
+
lastActivityByOthers
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
async function readPullsFile(path) {
|
|
1358
|
+
let text;
|
|
1359
|
+
try {
|
|
1360
|
+
text = await readFile2(path, "utf8");
|
|
1361
|
+
} catch {
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
try {
|
|
1365
|
+
const raw = JSON.parse(text);
|
|
1366
|
+
if (!isRecord(raw) || raw.version !== 1 || typeof raw.fetchedAt !== "string" || !Array.isArray(raw.pulls)) return null;
|
|
1367
|
+
const accounts = Array.isArray(raw.accounts) ? raw.accounts.flatMap(
|
|
1368
|
+
(account) => isRecord(account) && typeof account.login === "string" ? [{ login: account.login, ok: account.ok === true, error: typeof account.error === "string" ? account.error : null }] : []
|
|
1369
|
+
) : [];
|
|
1370
|
+
return {
|
|
1371
|
+
version: 1,
|
|
1372
|
+
fetchedAt: raw.fetchedAt,
|
|
1373
|
+
accounts,
|
|
1374
|
+
scope: strings(raw.scope),
|
|
1375
|
+
warnings: strings(raw.warnings),
|
|
1376
|
+
pulls: raw.pulls.flatMap((pull) => normalizeStoredPull(pull) ?? [])
|
|
1377
|
+
};
|
|
1378
|
+
} catch {
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// src/calendar.ts
|
|
1384
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
1385
|
+
import { readFile as readFile3, rm, stat } from "node:fs/promises";
|
|
1386
|
+
import { tmpdir } from "node:os";
|
|
1387
|
+
import { join } from "node:path";
|
|
1388
|
+
import { promisify as promisify2 } from "node:util";
|
|
1389
|
+
var run2 = promisify2(execFile2);
|
|
1390
|
+
var HELPER_TIMEOUT_MS = 2e4;
|
|
1391
|
+
var POLL_MS = 150;
|
|
1392
|
+
var DECLINED = 3;
|
|
1393
|
+
var AVAILABILITY_FREE = 1;
|
|
1394
|
+
function str2(value) {
|
|
1395
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
1396
|
+
}
|
|
1397
|
+
function num(value) {
|
|
1398
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1399
|
+
}
|
|
1400
|
+
function localDate(d) {
|
|
1401
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
1402
|
+
}
|
|
1403
|
+
function eventId(externalId, start, allDay) {
|
|
1404
|
+
if (allDay) return `calendar:event:${externalId}:${localDate(start)}`;
|
|
1405
|
+
const stamp = `${localDate(start).replace(/-/g, "")}T${String(start.getHours()).padStart(2, "0")}${String(start.getMinutes()).padStart(2, "0")}`;
|
|
1406
|
+
return `calendar:event:${externalId}:${stamp}`;
|
|
1407
|
+
}
|
|
1408
|
+
function parseCalendarFacts(raw) {
|
|
1409
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
1410
|
+
const obj = raw;
|
|
1411
|
+
if (str2(obj.error)) return null;
|
|
1412
|
+
const calendars = [];
|
|
1413
|
+
if (Array.isArray(obj.calendars)) {
|
|
1414
|
+
for (const entry of obj.calendars) {
|
|
1415
|
+
const id = str2(entry?.id);
|
|
1416
|
+
const title = str2(entry?.title);
|
|
1417
|
+
if (id && title) calendars.push({ id, title, source: str2(entry?.source) ?? "" });
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
return {
|
|
1421
|
+
generatedAt: str2(obj.generatedAt) ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1422
|
+
calendars,
|
|
1423
|
+
events: Array.isArray(obj.events) ? obj.events : []
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
function selectEvents(facts, calendarNames) {
|
|
1427
|
+
const wanted = calendarNames.map((n) => n.trim().toLowerCase()).filter((n) => n !== "");
|
|
1428
|
+
const idsByName = /* @__PURE__ */ new Map();
|
|
1429
|
+
for (const name of wanted) {
|
|
1430
|
+
idsByName.set(
|
|
1431
|
+
name,
|
|
1432
|
+
facts.calendars.filter((c) => c.title.trim().toLowerCase() === name).map((c) => c.id)
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
const unmatched = [...idsByName.entries()].filter(([, ids]) => ids.length === 0).map(([name]) => name);
|
|
1436
|
+
const keep = new Set([...idsByName.values()].flat());
|
|
1437
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1438
|
+
for (const raw of facts.events) {
|
|
1439
|
+
const calendarId = str2(raw.calendarId);
|
|
1440
|
+
if (!calendarId || !keep.has(calendarId)) continue;
|
|
1441
|
+
if (num(raw.selfStatus) === DECLINED) continue;
|
|
1442
|
+
const externalId = str2(raw.externalId);
|
|
1443
|
+
const startText = str2(raw.start);
|
|
1444
|
+
if (!externalId || !startText) continue;
|
|
1445
|
+
const start = new Date(startText);
|
|
1446
|
+
if (Number.isNaN(start.getTime())) continue;
|
|
1447
|
+
const allDay = raw.allDay === true;
|
|
1448
|
+
const id = eventId(externalId, start, allDay);
|
|
1449
|
+
if (byId.has(id)) continue;
|
|
1450
|
+
const item = {
|
|
1451
|
+
id,
|
|
1452
|
+
source: "calendar",
|
|
1453
|
+
kind: "event",
|
|
1454
|
+
title: str2(raw.title) ?? "(untitled)",
|
|
1455
|
+
start: allDay ? localDate(start) : start.toISOString()
|
|
1456
|
+
};
|
|
1457
|
+
const endText = str2(raw.end);
|
|
1458
|
+
if (endText && !allDay) {
|
|
1459
|
+
const end = new Date(endText);
|
|
1460
|
+
if (!Number.isNaN(end.getTime())) item.end = end.toISOString();
|
|
1461
|
+
}
|
|
1462
|
+
if (num(raw.availability) === AVAILABILITY_FREE) item.blocking = false;
|
|
1463
|
+
const url = str2(raw.url);
|
|
1464
|
+
if (url && /^https?:\/\//i.test(url)) item.url = url;
|
|
1465
|
+
byId.set(id, item);
|
|
1466
|
+
}
|
|
1467
|
+
return { events: [...byId.values()], unmatched, matched: keep.size };
|
|
1468
|
+
}
|
|
1469
|
+
var CalendarHelperError = class extends Error {
|
|
1470
|
+
detail;
|
|
1471
|
+
constructor(message, detail = "") {
|
|
1472
|
+
super(message);
|
|
1473
|
+
this.name = "CalendarHelperError";
|
|
1474
|
+
this.detail = detail;
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
async function runHelper(appPath, selfAddresses) {
|
|
1478
|
+
try {
|
|
1479
|
+
await stat(appPath);
|
|
1480
|
+
} catch {
|
|
1481
|
+
throw new CalendarHelperError(
|
|
1482
|
+
`the calendar helper isn't built at ${appPath} \u2014 run ${command("build-calendar")}, or point DAILY_FOCUS_CALENDAR_APP at an existing build`
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
const out = join(tmpdir(), `daily-focus-calendar-${process.pid}-${Date.now()}.json`);
|
|
1486
|
+
try {
|
|
1487
|
+
try {
|
|
1488
|
+
await run2("/usr/bin/open", ["-n", "-a", appPath, "--args", out, selfAddresses.join(",")], {
|
|
1489
|
+
timeout: HELPER_TIMEOUT_MS
|
|
1490
|
+
});
|
|
1491
|
+
} catch (err) {
|
|
1492
|
+
console.warn(`[daily-focus] could not launch the calendar helper: ${err.message}`);
|
|
1493
|
+
throw new CalendarHelperError(`macOS couldn't launch the calendar helper at ${appPath}`, err.message);
|
|
1494
|
+
}
|
|
1495
|
+
const deadline = Date.now() + HELPER_TIMEOUT_MS;
|
|
1496
|
+
for (; ; ) {
|
|
1497
|
+
let text = null;
|
|
1498
|
+
try {
|
|
1499
|
+
text = await readFile3(out, "utf8");
|
|
1500
|
+
} catch {
|
|
1501
|
+
text = null;
|
|
1502
|
+
}
|
|
1503
|
+
if (text !== null && text.trim() !== "") {
|
|
1504
|
+
const raw = JSON.parse(text);
|
|
1505
|
+
const facts = parseCalendarFacts(raw);
|
|
1506
|
+
if (facts) return facts;
|
|
1507
|
+
const detail = raw ?? {};
|
|
1508
|
+
throw new CalendarHelperError(str2(detail.detail) ?? str2(detail.error) ?? "the calendar helper refused", text.trim());
|
|
1509
|
+
}
|
|
1510
|
+
if (Date.now() > deadline) {
|
|
1511
|
+
throw new CalendarHelperError(
|
|
1512
|
+
`the calendar helper wrote nothing within ${HELPER_TIMEOUT_MS / 1e3}s \u2014 is ${appPath} built?`
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
await new Promise((resolve2) => setTimeout(resolve2, POLL_MS));
|
|
1516
|
+
}
|
|
1517
|
+
} finally {
|
|
1518
|
+
await rm(out, { force: true });
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// src/calendarboard.ts
|
|
1523
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
1524
|
+
var MAX_BACKOFF_MS2 = 15 * 6e4;
|
|
1525
|
+
var realDeps2 = { run: runHelper };
|
|
1526
|
+
var CalendarBoard = class {
|
|
1527
|
+
#config;
|
|
1528
|
+
#deps;
|
|
1529
|
+
#onChange;
|
|
1530
|
+
#facts = null;
|
|
1531
|
+
/** Why the last read failed, when it did. */
|
|
1532
|
+
#failure = null;
|
|
1533
|
+
/** Reads in a row that failed, which also spaces the next one out. */
|
|
1534
|
+
#streak = null;
|
|
1535
|
+
#warnings = [];
|
|
1536
|
+
#reading = null;
|
|
1537
|
+
#timer = null;
|
|
1538
|
+
#audience = 0;
|
|
1539
|
+
#stopped = false;
|
|
1540
|
+
#lastReadAt = 0;
|
|
1541
|
+
constructor(config, onChange, deps = realDeps2) {
|
|
1542
|
+
this.#config = config;
|
|
1543
|
+
this.#deps = deps;
|
|
1544
|
+
this.#onChange = onChange;
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Off when switched off, and off when no calendar has been named — an unnamed
|
|
1548
|
+
* calendar list isn't a broken setup, it's a feature nobody turned on, and it
|
|
1549
|
+
* must not produce a banner every morning.
|
|
1550
|
+
*/
|
|
1551
|
+
get enabled() {
|
|
1552
|
+
return this.#config.calendar.enabled && this.#config.calendar.names.length > 0;
|
|
1553
|
+
}
|
|
1554
|
+
/** Load the last good read off disk, then read once regardless of audience. */
|
|
1555
|
+
async start() {
|
|
1556
|
+
if (!this.enabled) return;
|
|
1557
|
+
this.#facts = await readCalendarFile(this.#config.calendarFile);
|
|
1558
|
+
await this.refresh();
|
|
1559
|
+
}
|
|
1560
|
+
stop() {
|
|
1561
|
+
this.#stopped = true;
|
|
1562
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
1563
|
+
this.#timer = null;
|
|
1564
|
+
}
|
|
1565
|
+
setAudience(count) {
|
|
1566
|
+
this.#audience = count;
|
|
1567
|
+
if (count === 0) {
|
|
1568
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
1569
|
+
this.#timer = null;
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
if (this.#timer || this.#reading) return;
|
|
1573
|
+
if (this.#dueIn() === 0) void this.refresh();
|
|
1574
|
+
else this.#schedule();
|
|
1575
|
+
}
|
|
1576
|
+
/** Read now. Joins a read already running rather than starting a second. Never rejects. */
|
|
1577
|
+
async refresh() {
|
|
1578
|
+
if (!this.enabled || this.#stopped) return;
|
|
1579
|
+
if (this.#reading) return this.#reading;
|
|
1580
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
1581
|
+
this.#timer = null;
|
|
1582
|
+
this.#reading = this.#read().catch((err) => {
|
|
1583
|
+
this.#failure = { message: `The calendar read hit an unexpected error: ${err.message}`, detail: unexpectedDetail(err) };
|
|
1584
|
+
this.#streak = extend(this.#streak);
|
|
1585
|
+
console.error(`[daily-focus] calendar read failed: ${err.stack ?? String(err)}`);
|
|
1586
|
+
}).finally(() => {
|
|
1587
|
+
this.#reading = null;
|
|
1588
|
+
this.#lastReadAt = Date.now();
|
|
1589
|
+
if (this.#audience > 0 && !this.#stopped) this.#schedule();
|
|
1590
|
+
this.#onChange();
|
|
1591
|
+
});
|
|
1592
|
+
this.#onChange();
|
|
1593
|
+
return this.#reading;
|
|
1594
|
+
}
|
|
1595
|
+
async #read() {
|
|
1596
|
+
const { appPath, addresses } = this.#config.calendar;
|
|
1597
|
+
try {
|
|
1598
|
+
const facts = await this.#deps.run(appPath, addresses);
|
|
1599
|
+
this.#facts = facts;
|
|
1600
|
+
this.#failure = null;
|
|
1601
|
+
this.#streak = null;
|
|
1602
|
+
await writeJsonAtomic(this.#config.calendarFile, facts);
|
|
1603
|
+
} catch (err) {
|
|
1604
|
+
this.#streak = extend(this.#streak);
|
|
1605
|
+
const message = err instanceof CalendarHelperError ? err.message : `could not read the calendar: ${err.message}`;
|
|
1606
|
+
this.#failure = {
|
|
1607
|
+
message: this.#facts ? `${message}. Showing the last good read.` : `${message}. Showing the events from this morning's brief instead.`,
|
|
1608
|
+
detail: errorDetail(err)
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
/** The agenda as it stands, derived on every read rather than stored. */
|
|
1613
|
+
state() {
|
|
1614
|
+
if (!this.enabled) {
|
|
1615
|
+
return { events: [], live: false, fetchedAt: null, problem: null, failure: null, warnings: [] };
|
|
1616
|
+
}
|
|
1617
|
+
const reading = this.#reading !== null;
|
|
1618
|
+
const failure = this.#failure ? toReadFailure(this.#failure, this.#streak, this.#stopped ? null : this.#lastReadAt + this.#intervalMs(), reading) : null;
|
|
1619
|
+
if (!this.#facts) {
|
|
1620
|
+
return {
|
|
1621
|
+
events: [],
|
|
1622
|
+
live: false,
|
|
1623
|
+
fetchedAt: null,
|
|
1624
|
+
// A failure says why there is nothing; with none, there just hasn't been a read.
|
|
1625
|
+
problem: failure ? null : "The calendar has not been read yet.",
|
|
1626
|
+
failure,
|
|
1627
|
+
warnings: this.#warnings
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
const { events, unmatched, matched } = selectEvents(this.#facts, this.#config.calendar.names);
|
|
1631
|
+
const warnings = [...this.#warnings];
|
|
1632
|
+
if (unmatched.length > 0) {
|
|
1633
|
+
warnings.push(
|
|
1634
|
+
`No calendar named ${unmatched.map((n) => `"${n}"`).join(", ")}. Check DAILY_FOCUS_CALENDARS against the names in Calendar.app.`
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
if (this.#config.calendar.addresses.length === 0) {
|
|
1638
|
+
warnings.push(
|
|
1639
|
+
"DAILY_FOCUS_CALENDAR_ADDRESSES is unset, so meetings you declined still show and still block time."
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
if (matched === 0) {
|
|
1643
|
+
return {
|
|
1644
|
+
events: [],
|
|
1645
|
+
live: false,
|
|
1646
|
+
fetchedAt: this.#facts.generatedAt,
|
|
1647
|
+
problem: "None of the configured calendars exist in Calendar.app, so the brief\u2019s events are being shown instead.",
|
|
1648
|
+
failure,
|
|
1649
|
+
warnings
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
return { events, live: true, fetchedAt: this.#facts.generatedAt, problem: null, failure, warnings };
|
|
1653
|
+
}
|
|
1654
|
+
#intervalMs() {
|
|
1655
|
+
const base = this.#config.calendar.pollMinutes * 6e4;
|
|
1656
|
+
const failures = this.#streak?.attempts ?? 0;
|
|
1657
|
+
if (failures === 0) return base;
|
|
1658
|
+
return Math.min(base * 2 ** Math.min(failures, 6), MAX_BACKOFF_MS2);
|
|
1659
|
+
}
|
|
1660
|
+
#dueIn() {
|
|
1661
|
+
if (this.#lastReadAt === 0) return 0;
|
|
1662
|
+
return Math.max(0, this.#lastReadAt + this.#intervalMs() - Date.now());
|
|
1663
|
+
}
|
|
1664
|
+
#schedule() {
|
|
1665
|
+
if (this.#timer || this.#stopped || this.#audience === 0) return;
|
|
1666
|
+
this.#timer = setTimeout(() => {
|
|
1667
|
+
this.#timer = null;
|
|
1668
|
+
void this.refresh();
|
|
1669
|
+
}, this.#dueIn() || this.#intervalMs());
|
|
1670
|
+
this.#timer.unref?.();
|
|
1671
|
+
}
|
|
1672
|
+
};
|
|
1673
|
+
async function readCalendarFile(path) {
|
|
1674
|
+
try {
|
|
1675
|
+
return parseCalendarFacts(JSON.parse(await readFile4(path, "utf8")));
|
|
1676
|
+
} catch {
|
|
1677
|
+
return null;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// src/ticketboard.ts
|
|
1682
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
1683
|
+
|
|
1684
|
+
// src/jira.ts
|
|
1685
|
+
import { execFile as execFile3 } from "node:child_process";
|
|
1686
|
+
import { promisify as promisify3 } from "node:util";
|
|
1687
|
+
var run3 = promisify3(execFile3);
|
|
1688
|
+
var ACLI_TIMEOUT_MS = 45e3;
|
|
1689
|
+
var PAGE_LIMIT = 200;
|
|
1690
|
+
var ACLI_ENV = { ...process.env, NO_COLOR: "1" };
|
|
1691
|
+
var AcliMissingError = class extends Error {
|
|
1692
|
+
constructor(acliPath) {
|
|
1693
|
+
super(
|
|
1694
|
+
`could not run \`${acliPath}\` \u2014 install the Atlassian CLI or set DAILY_FOCUS_ACLI to its path`
|
|
1695
|
+
);
|
|
1696
|
+
this.name = "AcliMissingError";
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
var AcliAuthError = class extends Error {
|
|
1700
|
+
detail;
|
|
1701
|
+
constructor(message, detail = "") {
|
|
1702
|
+
super(message);
|
|
1703
|
+
this.name = "AcliAuthError";
|
|
1704
|
+
this.detail = detail;
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
var JiraSearchError = class extends Error {
|
|
1708
|
+
detail;
|
|
1709
|
+
constructor(message, detail = "") {
|
|
1710
|
+
super(message);
|
|
1711
|
+
this.name = "JiraSearchError";
|
|
1712
|
+
this.detail = detail;
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
var JiraTransitionError = class extends Error {
|
|
1716
|
+
constructor(message) {
|
|
1717
|
+
super(message);
|
|
1718
|
+
this.name = "JiraTransitionError";
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
async function acli(acliPath, args) {
|
|
1722
|
+
try {
|
|
1723
|
+
const { stdout, stderr } = await run3(acliPath, args, {
|
|
1724
|
+
env: ACLI_ENV,
|
|
1725
|
+
timeout: ACLI_TIMEOUT_MS,
|
|
1726
|
+
// A hundred tickets of raw Jira issue JSON measured 135 KB; this is room
|
|
1727
|
+
// for a board several times that before the buffer becomes the limit.
|
|
1728
|
+
maxBuffer: 8 << 20
|
|
1729
|
+
});
|
|
1730
|
+
return { stdout, stderr, code: 0 };
|
|
1731
|
+
} catch (err) {
|
|
1732
|
+
const failure = err;
|
|
1733
|
+
if (failure.code === "ENOENT" || failure.code === "EACCES") throw new AcliMissingError(acliPath);
|
|
1734
|
+
const stderr = failure.killed || failure.signal ? `acli did not answer within ${ACLI_TIMEOUT_MS / 1e3}s \u2014 a slow network, or an OAuth refresh waiting on something?` : failure.stderr || failure.message;
|
|
1735
|
+
return { stdout: failure.stdout ?? "", stderr, code: typeof failure.code === "number" ? failure.code : 1 };
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
function transcript(acliPath, args, { stdout, stderr }) {
|
|
1739
|
+
const command3 = [acliPath, ...args].map((word) => /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, "'\\''")}'`);
|
|
1740
|
+
return [`$ ${command3.join(" ")}`, stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
1741
|
+
}
|
|
1742
|
+
function firstLine(stderr) {
|
|
1743
|
+
return stderr.split("\n").map((line) => line.replace(/^\s*[✗✓]\s*/u, "").replace(/^Error:\s*/i, "").trim()).find((line) => line !== "") ?? "";
|
|
1744
|
+
}
|
|
1745
|
+
async function acliIdentity(acliPath) {
|
|
1746
|
+
const args = ["jira", "auth", "status"];
|
|
1747
|
+
const { stdout, stderr, code } = await acli(acliPath, args);
|
|
1748
|
+
if (code !== 0) {
|
|
1749
|
+
const detail = firstLine(stderr) || firstLine(stdout) || "no Jira session";
|
|
1750
|
+
throw new AcliAuthError(
|
|
1751
|
+
`acli has no Jira session (${detail}) \u2014 run \`acli jira auth login\``,
|
|
1752
|
+
transcript(acliPath, args, { stdout, stderr })
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
const site = /^\s*Site:\s*(\S+)\s*$/mu.exec(stdout)?.[1] ?? null;
|
|
1756
|
+
const account = /^\s*Email:\s*(\S+)\s*$/mu.exec(stdout)?.[1] ?? null;
|
|
1757
|
+
return { site: site ?? null, account };
|
|
1758
|
+
}
|
|
1759
|
+
var ANY_PR = "development[pullrequests].all > 0";
|
|
1760
|
+
var OPEN_PR = "development[pullrequests].open > 0";
|
|
1761
|
+
function quoteJql(value) {
|
|
1762
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
1763
|
+
}
|
|
1764
|
+
function buildJql(projects, predicate) {
|
|
1765
|
+
const clauses = [
|
|
1766
|
+
"statusCategory != Done",
|
|
1767
|
+
"(assignee = currentUser() OR (reporter = currentUser() AND assignee IS EMPTY))"
|
|
1768
|
+
];
|
|
1769
|
+
if (projects.length > 0) clauses.push(`project IN (${projects.map(quoteJql).join(", ")})`);
|
|
1770
|
+
if (predicate) clauses.push(predicate);
|
|
1771
|
+
return `${clauses.join(" AND ")} ORDER BY updated ASC`;
|
|
1772
|
+
}
|
|
1773
|
+
var FIELDS = "key,status,issuetype,summary";
|
|
1774
|
+
async function search(acliPath, jql, { limit = PAGE_LIMIT, paginate = true } = {}) {
|
|
1775
|
+
const args = [
|
|
1776
|
+
"jira",
|
|
1777
|
+
"workitem",
|
|
1778
|
+
"search",
|
|
1779
|
+
"--jql",
|
|
1780
|
+
jql,
|
|
1781
|
+
"--fields",
|
|
1782
|
+
FIELDS,
|
|
1783
|
+
"--limit",
|
|
1784
|
+
String(limit),
|
|
1785
|
+
"--json"
|
|
1786
|
+
];
|
|
1787
|
+
if (paginate) args.push("--paginate");
|
|
1788
|
+
const { stdout, stderr, code } = await acli(acliPath, args);
|
|
1789
|
+
if (code !== 0) {
|
|
1790
|
+
throw new JiraSearchError(
|
|
1791
|
+
firstLine(stderr) || firstLine(stdout) || "the Jira search failed",
|
|
1792
|
+
transcript(acliPath, args, { stdout, stderr })
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
let parsed;
|
|
1796
|
+
try {
|
|
1797
|
+
parsed = JSON.parse(stdout);
|
|
1798
|
+
} catch {
|
|
1799
|
+
throw new JiraSearchError(
|
|
1800
|
+
`could not read acli's answer as JSON (${stdout.trim().slice(0, 120) || "empty"})`,
|
|
1801
|
+
transcript(acliPath, args, { stdout, stderr })
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
if (!Array.isArray(parsed)) throw new JiraSearchError("acli's answer was not a list of work items");
|
|
1805
|
+
return parsed;
|
|
1806
|
+
}
|
|
1807
|
+
function isRecord2(value) {
|
|
1808
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1809
|
+
}
|
|
1810
|
+
var CATEGORIES = ["new", "indeterminate", "done", "undefined"];
|
|
1811
|
+
function toCategory(value) {
|
|
1812
|
+
return typeof value === "string" && CATEGORIES.includes(value) ? value : "undefined";
|
|
1813
|
+
}
|
|
1814
|
+
function issueKey(raw) {
|
|
1815
|
+
if (!isRecord2(raw)) return null;
|
|
1816
|
+
if (typeof raw.key === "string" && raw.key !== "") return raw.key;
|
|
1817
|
+
const fields = isRecord2(raw.fields) ? raw.fields : null;
|
|
1818
|
+
return typeof fields?.key === "string" && fields.key !== "" ? fields.key : null;
|
|
1819
|
+
}
|
|
1820
|
+
function browseUrl(site, key) {
|
|
1821
|
+
return site ? `https://${site}/browse/${encodeURIComponent(key)}` : null;
|
|
1822
|
+
}
|
|
1823
|
+
function toTicket(raw, site, hasAnyPr, hasOpenPr, allPrsClosed = false) {
|
|
1824
|
+
const key = issueKey(raw);
|
|
1825
|
+
if (key === null || !isRecord2(raw)) return null;
|
|
1826
|
+
const fields = isRecord2(raw.fields) ? raw.fields : {};
|
|
1827
|
+
const issuetype = isRecord2(fields.issuetype) ? fields.issuetype : {};
|
|
1828
|
+
const level = typeof issuetype.hierarchyLevel === "number" ? issuetype.hierarchyLevel : 0;
|
|
1829
|
+
if (level > 0) return null;
|
|
1830
|
+
const status = isRecord2(fields.status) ? fields.status : {};
|
|
1831
|
+
const category = isRecord2(status.statusCategory) ? status.statusCategory : {};
|
|
1832
|
+
return {
|
|
1833
|
+
id: `jira:${key}`,
|
|
1834
|
+
key,
|
|
1835
|
+
summary: typeof fields.summary === "string" ? fields.summary : key,
|
|
1836
|
+
workflowStatus: typeof status.name === "string" ? status.name : "",
|
|
1837
|
+
statusCategory: toCategory(category.key),
|
|
1838
|
+
issueType: typeof issuetype.name === "string" ? issuetype.name : "",
|
|
1839
|
+
url: browseUrl(site, key),
|
|
1840
|
+
// An open pull request is a pull request, whatever the other count said.
|
|
1841
|
+
hasAnyPr: hasAnyPr || hasOpenPr,
|
|
1842
|
+
hasOpenPr,
|
|
1843
|
+
// Never inferred from the two above, which is the whole point of the field:
|
|
1844
|
+
// they cannot tell "all closed" from "could not find out".
|
|
1845
|
+
allPrsClosed: allPrsClosed && !hasOpenPr
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
var DEV_FIELD = "customfield_10000";
|
|
1849
|
+
var CLOSED_PR_STATES = ["MERGED", "DECLINED"];
|
|
1850
|
+
var DEV_CONCURRENCY = 5;
|
|
1851
|
+
function jsonObjectAt(text, from) {
|
|
1852
|
+
let depth = 0;
|
|
1853
|
+
let inString = false;
|
|
1854
|
+
let escaped = false;
|
|
1855
|
+
for (let i = from; i < text.length; i++) {
|
|
1856
|
+
const ch = text[i];
|
|
1857
|
+
if (inString) {
|
|
1858
|
+
if (escaped) escaped = false;
|
|
1859
|
+
else if (ch === "\\") escaped = true;
|
|
1860
|
+
else if (ch === '"') inString = false;
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
if (ch === '"') inString = true;
|
|
1864
|
+
else if (ch === "{") depth++;
|
|
1865
|
+
else if (ch === "}" && --depth === 0) return text.slice(from, i + 1);
|
|
1866
|
+
}
|
|
1867
|
+
return null;
|
|
1868
|
+
}
|
|
1869
|
+
function readDevPullRequests(field) {
|
|
1870
|
+
if (typeof field !== "string") return null;
|
|
1871
|
+
const marker = field.indexOf("json=");
|
|
1872
|
+
if (marker === -1) return null;
|
|
1873
|
+
const opens = field.indexOf("{", marker);
|
|
1874
|
+
if (opens === -1) return null;
|
|
1875
|
+
const body = jsonObjectAt(field, opens);
|
|
1876
|
+
if (body === null) return null;
|
|
1877
|
+
let parsed;
|
|
1878
|
+
try {
|
|
1879
|
+
parsed = JSON.parse(body);
|
|
1880
|
+
} catch {
|
|
1881
|
+
return null;
|
|
1882
|
+
}
|
|
1883
|
+
const cached = isRecord2(parsed) && isRecord2(parsed.cachedValue) ? parsed.cachedValue : null;
|
|
1884
|
+
if (!cached) return null;
|
|
1885
|
+
if (Array.isArray(cached.errors) && cached.errors.length > 0) return null;
|
|
1886
|
+
const summary = isRecord2(cached.summary) ? cached.summary : null;
|
|
1887
|
+
const pullrequest = summary && isRecord2(summary.pullrequest) ? summary.pullrequest : null;
|
|
1888
|
+
const overall = pullrequest && isRecord2(pullrequest.overall) ? pullrequest.overall : null;
|
|
1889
|
+
if (!overall) return null;
|
|
1890
|
+
const { count, stateCount, state } = overall;
|
|
1891
|
+
if (typeof count !== "number" || typeof stateCount !== "number" || typeof state !== "string") return null;
|
|
1892
|
+
return { count, state, stateCount };
|
|
1893
|
+
}
|
|
1894
|
+
function allPullRequestsClosed(dev) {
|
|
1895
|
+
if (dev === null || dev.count < 1) return false;
|
|
1896
|
+
if (dev.stateCount !== dev.count) return false;
|
|
1897
|
+
return CLOSED_PR_STATES.includes(dev.state.toUpperCase());
|
|
1898
|
+
}
|
|
1899
|
+
async function fetchDevPullRequests(acliPath, key) {
|
|
1900
|
+
const args = [
|
|
1901
|
+
"jira",
|
|
1902
|
+
"workitem",
|
|
1903
|
+
"view",
|
|
1904
|
+
key,
|
|
1905
|
+
"--fields",
|
|
1906
|
+
DEV_FIELD,
|
|
1907
|
+
"--json"
|
|
1908
|
+
];
|
|
1909
|
+
const { stdout, stderr, code } = await acli(acliPath, args);
|
|
1910
|
+
if (code !== 0) {
|
|
1911
|
+
throw new JiraSearchError(
|
|
1912
|
+
firstLine(stderr) || firstLine(stdout) || `could not read the development panel for ${key}`,
|
|
1913
|
+
transcript(acliPath, args, { stdout, stderr })
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
let parsed;
|
|
1917
|
+
try {
|
|
1918
|
+
parsed = JSON.parse(stdout);
|
|
1919
|
+
} catch {
|
|
1920
|
+
throw new JiraSearchError(`could not read acli's development panel answer for ${key} as JSON`);
|
|
1921
|
+
}
|
|
1922
|
+
const fields = isRecord2(parsed) && isRecord2(parsed.fields) ? parsed.fields : null;
|
|
1923
|
+
if (!fields) throw new JiraSearchError(`acli's answer for ${key} carried no fields`);
|
|
1924
|
+
return readDevPullRequests(fields[DEV_FIELD]);
|
|
1925
|
+
}
|
|
1926
|
+
async function mapLimit(items, limit, fn) {
|
|
1927
|
+
const out = new Array(items.length);
|
|
1928
|
+
let next = 0;
|
|
1929
|
+
const worker = async () => {
|
|
1930
|
+
for (; ; ) {
|
|
1931
|
+
const i = next++;
|
|
1932
|
+
if (i >= items.length) return;
|
|
1933
|
+
out[i] = await fn(items[i]);
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
1937
|
+
return out;
|
|
1938
|
+
}
|
|
1939
|
+
async function fetchTickets(acliPath, opts) {
|
|
1940
|
+
const [base, anyPr, openPr] = await Promise.all([
|
|
1941
|
+
search(acliPath, buildJql(opts.projects, null)),
|
|
1942
|
+
search(acliPath, buildJql(opts.projects, ANY_PR)),
|
|
1943
|
+
search(acliPath, buildJql(opts.projects, OPEN_PR))
|
|
1944
|
+
]);
|
|
1945
|
+
const keys = (issues, what) => {
|
|
1946
|
+
const set = /* @__PURE__ */ new Set();
|
|
1947
|
+
for (const issue of issues) {
|
|
1948
|
+
const key = issueKey(issue);
|
|
1949
|
+
if (key !== null) set.add(key);
|
|
1950
|
+
}
|
|
1951
|
+
if (issues.length > 0 && set.size === 0) {
|
|
1952
|
+
throw new JiraSearchError(`the ${what} search returned ${issues.length} work items and no readable keys`);
|
|
1953
|
+
}
|
|
1954
|
+
return set;
|
|
1955
|
+
};
|
|
1956
|
+
const withAny = keys(anyPr, "linked pull request");
|
|
1957
|
+
const withOpen = keys(openPr, "open pull request");
|
|
1958
|
+
const warnings = [];
|
|
1959
|
+
const tickets = [];
|
|
1960
|
+
let unreadable = 0;
|
|
1961
|
+
for (const raw of base) {
|
|
1962
|
+
const key = issueKey(raw);
|
|
1963
|
+
const ticket = toTicket(raw, opts.site, key !== null && withAny.has(key), key !== null && withOpen.has(key));
|
|
1964
|
+
if (ticket) tickets.push(ticket);
|
|
1965
|
+
else if (key === null) unreadable++;
|
|
1966
|
+
}
|
|
1967
|
+
if (unreadable > 0) {
|
|
1968
|
+
warnings.push(`Skipped ${unreadable} work ${unreadable === 1 ? "item" : "items"} Jira described in a way this board could not read.`);
|
|
1969
|
+
}
|
|
1970
|
+
const candidates = tickets.filter((ticket) => ticket.hasAnyPr && !ticket.hasOpenPr);
|
|
1971
|
+
const panels = await mapLimit(
|
|
1972
|
+
candidates,
|
|
1973
|
+
DEV_CONCURRENCY,
|
|
1974
|
+
(ticket) => fetchDevPullRequests(acliPath, ticket.key).then(
|
|
1975
|
+
(dev) => ({ asked: true, dev }),
|
|
1976
|
+
() => ({ asked: false, dev: null })
|
|
1977
|
+
)
|
|
1978
|
+
);
|
|
1979
|
+
const unanswered = [];
|
|
1980
|
+
const unaccounted = [];
|
|
1981
|
+
candidates.forEach((ticket, i) => {
|
|
1982
|
+
const { asked, dev } = panels[i];
|
|
1983
|
+
if (allPullRequestsClosed(dev)) {
|
|
1984
|
+
ticket.allPrsClosed = true;
|
|
1985
|
+
} else if (!asked) {
|
|
1986
|
+
unanswered.push(ticket.key);
|
|
1987
|
+
} else if (dev === null) {
|
|
1988
|
+
unaccounted.push(ticket.key);
|
|
1989
|
+
} else {
|
|
1990
|
+
ticket.hasOpenPr = true;
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1993
|
+
const listed = (keys2) => keys2.map((key) => {
|
|
1994
|
+
const url = browseUrl(opts.site, key);
|
|
1995
|
+
return url === null ? key : `[${key}](${url})`;
|
|
1996
|
+
}).join(", ");
|
|
1997
|
+
const heldOut = (keys2) => `${keys2.length === 1 ? "it is" : "they are"} held out of "no open pull requests left" rather than guessed at`;
|
|
1998
|
+
if (unanswered.length > 0) {
|
|
1999
|
+
warnings.push(
|
|
2000
|
+
`Could not read Jira's development panel for ${listed(unanswered)}, so ${heldOut(unanswered)}. The next read may well manage it.`
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
if (unaccounted.length > 0) {
|
|
2004
|
+
warnings.push(
|
|
2005
|
+
`Jira's search says ${listed(unaccounted)} ${unaccounted.length === 1 ? "has" : "have"} pull requests, but its own development panel names none, so ${heldOut(unaccounted)}. Jira is disagreeing with itself; the row comes back when one of its caches catches up.`
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
if (!opts.site) {
|
|
2009
|
+
warnings.push(
|
|
2010
|
+
"acli did not say which Atlassian site it is logged in to, so these rows have no links. Set DAILY_FOCUS_JIRA_SITE to your site host."
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
const projects = [...new Set(tickets.map((ticket) => projectOf(ticket.key)).filter((p) => p !== null))];
|
|
2014
|
+
const finished = await Promise.all(projects.map((project) => fetchDoneStatuses(acliPath, project).catch(() => null)));
|
|
2015
|
+
const unread = projects.filter((_, i) => finished[i] === null);
|
|
2016
|
+
if (unread.length > 0) {
|
|
2017
|
+
warnings.push(
|
|
2018
|
+
`Could not read how work gets finished in ${unread.join(", ")}, so the status menu there may be missing a way out of the workflow.`
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
return {
|
|
2022
|
+
tickets,
|
|
2023
|
+
statuses: statusesByProject(base, ...finished.map((batch) => batch ?? [])),
|
|
2024
|
+
warnings
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
async function fetchDoneStatuses(acliPath, project) {
|
|
2028
|
+
const jql = `statusCategory = Done AND project = ${quoteJql(project)} ORDER BY updated DESC`;
|
|
2029
|
+
return search(acliPath, jql, { limit: 25, paginate: false });
|
|
2030
|
+
}
|
|
2031
|
+
function projectOf(key) {
|
|
2032
|
+
return /^([A-Za-z][A-Za-z0-9_]*)-\d+$/.exec(key.trim())?.[1]?.toUpperCase() ?? null;
|
|
2033
|
+
}
|
|
2034
|
+
function statusesByProject(...batches) {
|
|
2035
|
+
const byProject = {};
|
|
2036
|
+
for (const batch of batches) {
|
|
2037
|
+
for (const raw of batch) {
|
|
2038
|
+
const key = issueKey(raw);
|
|
2039
|
+
if (key === null) continue;
|
|
2040
|
+
const project = projectOf(key);
|
|
2041
|
+
if (project === null) continue;
|
|
2042
|
+
const fields = isRecord2(raw) && isRecord2(raw.fields) ? raw.fields : {};
|
|
2043
|
+
const status = isRecord2(fields.status) && typeof fields.status.name === "string" ? fields.status.name : "";
|
|
2044
|
+
if (status === "") continue;
|
|
2045
|
+
const seen = byProject[project] ??= [];
|
|
2046
|
+
if (!seen.includes(status)) seen.push(status);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
for (const statuses of Object.values(byProject)) statuses.sort((a, b) => a.localeCompare(b));
|
|
2050
|
+
return byProject;
|
|
2051
|
+
}
|
|
2052
|
+
async function transitionTicket(acliPath, key, status) {
|
|
2053
|
+
const { stdout, stderr, code } = await acli(acliPath, [
|
|
2054
|
+
"jira",
|
|
2055
|
+
"workitem",
|
|
2056
|
+
"transition",
|
|
2057
|
+
"--key",
|
|
2058
|
+
key,
|
|
2059
|
+
"--status",
|
|
2060
|
+
status,
|
|
2061
|
+
"--yes",
|
|
2062
|
+
"--json"
|
|
2063
|
+
]);
|
|
2064
|
+
if (code !== 0) {
|
|
2065
|
+
throw new JiraTransitionError(firstLine(stderr) || firstLine(stdout) || `Jira would not move ${key} to ${status}`);
|
|
2066
|
+
}
|
|
2067
|
+
readTransitionReport(stdout, key, status);
|
|
2068
|
+
}
|
|
2069
|
+
function readTransitionReport(stdout, key, status) {
|
|
2070
|
+
let parsed;
|
|
2071
|
+
try {
|
|
2072
|
+
parsed = JSON.parse(stdout);
|
|
2073
|
+
} catch {
|
|
2074
|
+
throw new JiraTransitionError(
|
|
2075
|
+
`acli did not say whether ${key} moved (${stdout.trim().slice(0, 120) || "no output"}) \u2014 check Jira`
|
|
2076
|
+
);
|
|
2077
|
+
}
|
|
2078
|
+
const report = isRecord2(parsed) ? parsed : {};
|
|
2079
|
+
const results = Array.isArray(report.results) ? report.results : [];
|
|
2080
|
+
for (const entry of results) {
|
|
2081
|
+
if (!isRecord2(entry)) continue;
|
|
2082
|
+
const outcome = typeof entry.status === "string" ? entry.status.toUpperCase() : "";
|
|
2083
|
+
if (outcome === "SUCCESS") continue;
|
|
2084
|
+
const detail = typeof entry.message === "string" && entry.message !== "" ? entry.message : `Jira refused the move to ${status}`;
|
|
2085
|
+
throw new JiraTransitionError(detail);
|
|
2086
|
+
}
|
|
2087
|
+
const succeeded = typeof report.successCount === "number" ? report.successCount : results.length;
|
|
2088
|
+
if (succeeded < 1) {
|
|
2089
|
+
throw new JiraTransitionError(`acli reported no successful move for ${key} \u2014 check Jira`);
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// src/tickets.ts
|
|
2094
|
+
var COURT_ORDER2 = ["settled", "started", "idle"];
|
|
2095
|
+
function ticketCourtOrder(court) {
|
|
2096
|
+
return COURT_ORDER2.indexOf(court);
|
|
2097
|
+
}
|
|
2098
|
+
function inStatus(ticket, statuses) {
|
|
2099
|
+
const status = ticket.workflowStatus.trim().toLowerCase();
|
|
2100
|
+
if (status === "") return false;
|
|
2101
|
+
return statuses.some((listed) => listed.trim().toLowerCase() === status);
|
|
2102
|
+
}
|
|
2103
|
+
function judge2(ticket, holdStatuses = []) {
|
|
2104
|
+
if (ticket.statusCategory === "done") return null;
|
|
2105
|
+
if (ticket.hasAnyPr && !ticket.hasOpenPr && ticket.allPrsClosed) return "settled";
|
|
2106
|
+
if (ticket.statusCategory === "new" && ticket.hasOpenPr) return "started";
|
|
2107
|
+
if (ticket.statusCategory === "indeterminate" && !ticket.hasAnyPr && !inStatus(ticket, holdStatuses)) return "idle";
|
|
2108
|
+
return null;
|
|
2109
|
+
}
|
|
2110
|
+
function resolveTickets(tickets, actions, now, holdStatuses = []) {
|
|
2111
|
+
const folded = foldActionLog(actions, now);
|
|
2112
|
+
const rows = [];
|
|
2113
|
+
for (const ticket of tickets) {
|
|
2114
|
+
const court = judge2(ticket, holdStatuses);
|
|
2115
|
+
if (!court) continue;
|
|
2116
|
+
const log = folded.get(canonicalId(ticket.id));
|
|
2117
|
+
const row = {
|
|
2118
|
+
...ticket,
|
|
2119
|
+
court,
|
|
2120
|
+
status: log?.status === "snoozed" ? "snoozed" : "open",
|
|
2121
|
+
notes: log?.notes ?? []
|
|
2122
|
+
};
|
|
2123
|
+
if (row.status === "snoozed" && log?.snoozedUntil) row.snoozedUntil = log.snoozedUntil;
|
|
2124
|
+
rows.push(row);
|
|
2125
|
+
}
|
|
2126
|
+
return rows.sort((a, b) => ticketCourtOrder(a.court) - ticketCourtOrder(b.court));
|
|
2127
|
+
}
|
|
2128
|
+
function resolveInProgress(tickets, actions, now, inProgressStatuses = []) {
|
|
2129
|
+
const folded = foldActionLog(actions, now);
|
|
2130
|
+
return tickets.filter((ticket) => ticket.statusCategory === "indeterminate").filter((ticket) => inProgressStatuses.length === 0 || inStatus(ticket, inProgressStatuses)).map((ticket) => ({ ...ticket, notes: folded.get(canonicalId(ticket.id))?.notes ?? [] }));
|
|
2131
|
+
}
|
|
2132
|
+
function countTickets(rows) {
|
|
2133
|
+
const counts = { settled: 0, started: 0, idle: 0, parked: 0 };
|
|
2134
|
+
for (const row of rows) {
|
|
2135
|
+
if (row.status === "snoozed") counts.parked++;
|
|
2136
|
+
else counts[row.court]++;
|
|
2137
|
+
}
|
|
2138
|
+
return counts;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// src/ticketboard.ts
|
|
2142
|
+
var MAX_BACKOFF_MS3 = 30 * 6e4;
|
|
2143
|
+
var realDeps3 = { identity: acliIdentity, fetch: fetchTickets, transition: transitionTicket };
|
|
2144
|
+
var TicketBoard = class {
|
|
2145
|
+
#config;
|
|
2146
|
+
#deps;
|
|
2147
|
+
#onChange;
|
|
2148
|
+
/** The last successful read, as on disk. */
|
|
2149
|
+
#file = null;
|
|
2150
|
+
#warnings = [];
|
|
2151
|
+
/** Why the last read failed, when it did. */
|
|
2152
|
+
#reason = null;
|
|
2153
|
+
/** Reads in a row that failed, which also spaces the next one out. */
|
|
2154
|
+
#streak = null;
|
|
2155
|
+
#reading = null;
|
|
2156
|
+
#timer = null;
|
|
2157
|
+
#audience = 0;
|
|
2158
|
+
#stopped = false;
|
|
2159
|
+
#lastReadAt = 0;
|
|
2160
|
+
constructor(config, onChange, deps = realDeps3) {
|
|
2161
|
+
this.#config = config;
|
|
2162
|
+
this.#deps = deps;
|
|
2163
|
+
this.#onChange = onChange;
|
|
2164
|
+
}
|
|
2165
|
+
get enabled() {
|
|
2166
|
+
return this.#config.jira.enabled;
|
|
2167
|
+
}
|
|
2168
|
+
/** Load what the last run left on disk, then read once regardless of audience. */
|
|
2169
|
+
async start() {
|
|
2170
|
+
if (!this.enabled) return;
|
|
2171
|
+
this.#file = await readTicketsFile(this.#config.ticketsFile);
|
|
2172
|
+
await this.refresh();
|
|
2173
|
+
}
|
|
2174
|
+
stop() {
|
|
2175
|
+
this.#stopped = true;
|
|
2176
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
2177
|
+
this.#timer = null;
|
|
2178
|
+
}
|
|
2179
|
+
/** How many browsers are watching. Polling pauses at zero. */
|
|
2180
|
+
setAudience(count) {
|
|
2181
|
+
this.#audience = count;
|
|
2182
|
+
if (count === 0) {
|
|
2183
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
2184
|
+
this.#timer = null;
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
if (this.#timer || this.#reading) return;
|
|
2188
|
+
if (this.#dueIn() === 0) void this.refresh();
|
|
2189
|
+
else this.#schedule();
|
|
2190
|
+
}
|
|
2191
|
+
/** Read now. Joins a read already in flight rather than starting a second. Never rejects. */
|
|
2192
|
+
async refresh() {
|
|
2193
|
+
if (!this.enabled || this.#stopped) return;
|
|
2194
|
+
if (this.#reading) return this.#reading;
|
|
2195
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
2196
|
+
this.#timer = null;
|
|
2197
|
+
this.#reading = this.#read().catch((err) => {
|
|
2198
|
+
this.#reason = { message: `The ticket board hit an unexpected error: ${err.message}`, detail: unexpectedDetail(err) };
|
|
2199
|
+
this.#streak = extend(this.#streak);
|
|
2200
|
+
console.error(`[daily-focus] jira poll failed: ${err.stack ?? String(err)}`);
|
|
2201
|
+
}).finally(() => {
|
|
2202
|
+
this.#reading = null;
|
|
2203
|
+
this.#lastReadAt = Date.now();
|
|
2204
|
+
this.#schedule();
|
|
2205
|
+
this.#onChange();
|
|
2206
|
+
});
|
|
2207
|
+
this.#onChange();
|
|
2208
|
+
return this.#reading;
|
|
2209
|
+
}
|
|
2210
|
+
/** The board joined with the action log, ready to render. Never throws. */
|
|
2211
|
+
view(actions, now) {
|
|
2212
|
+
const warnings = this.enabled ? [...this.#warnings] : [];
|
|
2213
|
+
let rows = [];
|
|
2214
|
+
let inProgress = [];
|
|
2215
|
+
if (this.enabled) {
|
|
2216
|
+
const tickets = this.#file?.tickets ?? [];
|
|
2217
|
+
const { holdStatuses, inProgressStatuses } = this.#config.jira;
|
|
2218
|
+
try {
|
|
2219
|
+
rows = resolveTickets(tickets, actions, now, holdStatuses);
|
|
2220
|
+
inProgress = resolveInProgress(tickets, actions, now, inProgressStatuses);
|
|
2221
|
+
} catch (err) {
|
|
2222
|
+
warnings.push(`Could not judge the tickets on file: ${err.message}`);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
const reading = this.#reading !== null;
|
|
2226
|
+
return {
|
|
2227
|
+
enabled: this.enabled,
|
|
2228
|
+
reason: this.enabled && this.#reason ? toReadFailure(this.#reason, this.#streak, this.#stopped ? null : Date.now() + this.#dueIn(), reading) : null,
|
|
2229
|
+
fetchedAt: this.#file?.fetchedAt ?? null,
|
|
2230
|
+
fetching: reading,
|
|
2231
|
+
// Suppressed alongside a reason, as the board's account list is: an
|
|
2232
|
+
// account line beside "acli is not logged in" contradicts the banner.
|
|
2233
|
+
account: this.#reason ? null : this.#file?.account ?? null,
|
|
2234
|
+
projects: [...this.#config.jira.projects],
|
|
2235
|
+
warnings,
|
|
2236
|
+
pollMinutes: this.#config.jira.pollMinutes,
|
|
2237
|
+
rows,
|
|
2238
|
+
counts: countTickets(rows),
|
|
2239
|
+
inProgress,
|
|
2240
|
+
checked: this.#file?.tickets.length ?? 0,
|
|
2241
|
+
statuses: this.#file?.statuses ?? {}
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
/**
|
|
2245
|
+
* Move one ticket to a status, then read the board back.
|
|
2246
|
+
*
|
|
2247
|
+
* The only thing in this process that changes anything outside it, and it runs
|
|
2248
|
+
* only from a click. It deliberately does *not* touch `#file` itself: the row
|
|
2249
|
+
* on screen has to come from Jira having actually accepted the move, so the
|
|
2250
|
+
* refresh is the confirmation. A refresh already in flight is joined rather
|
|
2251
|
+
* than doubled, which is why this awaits `refresh` rather than `#read`.
|
|
2252
|
+
*
|
|
2253
|
+
* Unlike everything else on this class, it rejects — the caller is a request
|
|
2254
|
+
* handler with somebody waiting on the answer, and a transition that silently
|
|
2255
|
+
* did nothing is the one failure this must never present as success.
|
|
2256
|
+
*/
|
|
2257
|
+
async transition(key, status) {
|
|
2258
|
+
if (!this.enabled) throw new Error("the Jira ticket board is switched off (DAILY_FOCUS_JIRA=off)");
|
|
2259
|
+
if (this.#stopped) throw new Error("the server is shutting down");
|
|
2260
|
+
await this.#deps.transition(this.#config.jira.acliPath, key, status);
|
|
2261
|
+
await this.refresh();
|
|
2262
|
+
}
|
|
2263
|
+
#intervalMs() {
|
|
2264
|
+
const base = this.#config.jira.pollMinutes * 6e4;
|
|
2265
|
+
const failures = this.#streak?.attempts ?? 0;
|
|
2266
|
+
if (failures === 0) return base;
|
|
2267
|
+
return Math.min(base * 2 ** Math.min(failures, 6), MAX_BACKOFF_MS3);
|
|
2268
|
+
}
|
|
2269
|
+
#dueIn() {
|
|
2270
|
+
if (this.#lastReadAt === 0) return 0;
|
|
2271
|
+
return Math.max(0, this.#lastReadAt + this.#intervalMs() - Date.now());
|
|
2272
|
+
}
|
|
2273
|
+
#schedule() {
|
|
2274
|
+
if (this.#stopped || this.#audience === 0 || this.#timer || this.#reading) return;
|
|
2275
|
+
this.#timer = setTimeout(() => {
|
|
2276
|
+
this.#timer = null;
|
|
2277
|
+
void this.refresh();
|
|
2278
|
+
}, this.#dueIn());
|
|
2279
|
+
this.#timer.unref?.();
|
|
2280
|
+
}
|
|
2281
|
+
async #read() {
|
|
2282
|
+
const { acliPath, projects, site: configuredSite } = this.#config.jira;
|
|
2283
|
+
let identity2;
|
|
2284
|
+
try {
|
|
2285
|
+
identity2 = await this.#deps.identity(acliPath);
|
|
2286
|
+
} catch (err) {
|
|
2287
|
+
this.#reason = { message: `${err.message}, or set DAILY_FOCUS_JIRA=off`, detail: errorDetail(err) };
|
|
2288
|
+
this.#streak = extend(this.#streak);
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
const site = configuredSite ?? identity2.site;
|
|
2292
|
+
try {
|
|
2293
|
+
const { tickets, statuses, warnings } = await this.#deps.fetch(acliPath, { projects, site });
|
|
2294
|
+
this.#reason = null;
|
|
2295
|
+
this.#warnings = warnings;
|
|
2296
|
+
this.#streak = null;
|
|
2297
|
+
this.#file = {
|
|
2298
|
+
version: 1,
|
|
2299
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2300
|
+
account: identity2.account,
|
|
2301
|
+
projects: [...projects],
|
|
2302
|
+
warnings,
|
|
2303
|
+
tickets,
|
|
2304
|
+
statuses
|
|
2305
|
+
};
|
|
2306
|
+
try {
|
|
2307
|
+
await writeJsonAtomic(this.#config.ticketsFile, this.#file);
|
|
2308
|
+
} catch (err) {
|
|
2309
|
+
console.warn(`[daily-focus] could not write tickets.json: ${err.message}`);
|
|
2310
|
+
}
|
|
2311
|
+
} catch (err) {
|
|
2312
|
+
this.#streak = extend(this.#streak);
|
|
2313
|
+
const message = `could not read Jira: ${err.message}`;
|
|
2314
|
+
this.#reason = { message: this.#file ? `${message}. Showing the last good read.` : message, detail: errorDetail(err) };
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
};
|
|
2318
|
+
function isRecord3(value) {
|
|
2319
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2320
|
+
}
|
|
2321
|
+
function strings2(value) {
|
|
2322
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
2323
|
+
}
|
|
2324
|
+
function normalizeStoredTicket(raw) {
|
|
2325
|
+
if (!isRecord3(raw)) return null;
|
|
2326
|
+
const { key, url, workflowStatus, statusCategory, issueType, summary } = raw;
|
|
2327
|
+
if (typeof key !== "string" || key === "") return null;
|
|
2328
|
+
const site = typeof url === "string" ? /^https:\/\/([^/]+)\/browse\//.exec(url)?.[1] ?? null : null;
|
|
2329
|
+
return toTicket(
|
|
2330
|
+
{
|
|
2331
|
+
key,
|
|
2332
|
+
fields: {
|
|
2333
|
+
summary,
|
|
2334
|
+
status: { name: workflowStatus, statusCategory: { key: statusCategory } },
|
|
2335
|
+
issuetype: { name: issueType, hierarchyLevel: 0 }
|
|
2336
|
+
}
|
|
2337
|
+
},
|
|
2338
|
+
site,
|
|
2339
|
+
raw.hasAnyPr === true,
|
|
2340
|
+
raw.hasOpenPr === true,
|
|
2341
|
+
// Absent in a file written before the development panel was read — which
|
|
2342
|
+
// must come back as "not confirmed" rather than as confirmed, since those
|
|
2343
|
+
// files are exactly the ones whose settled court counted drafts as merges.
|
|
2344
|
+
raw.allPrsClosed === true
|
|
2345
|
+
);
|
|
2346
|
+
}
|
|
2347
|
+
async function readTicketsFile(path) {
|
|
2348
|
+
let text;
|
|
2349
|
+
try {
|
|
2350
|
+
text = await readFile5(path, "utf8");
|
|
2351
|
+
} catch {
|
|
2352
|
+
return null;
|
|
2353
|
+
}
|
|
2354
|
+
try {
|
|
2355
|
+
const raw = JSON.parse(text);
|
|
2356
|
+
if (!isRecord3(raw) || raw.version !== 1 || typeof raw.fetchedAt !== "string" || !Array.isArray(raw.tickets)) {
|
|
2357
|
+
return null;
|
|
2358
|
+
}
|
|
2359
|
+
const statuses = {};
|
|
2360
|
+
if (isRecord3(raw.statuses)) {
|
|
2361
|
+
for (const [project, names] of Object.entries(raw.statuses)) {
|
|
2362
|
+
const list = strings2(names);
|
|
2363
|
+
if (list.length > 0) statuses[project] = list;
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
return {
|
|
2367
|
+
version: 1,
|
|
2368
|
+
fetchedAt: raw.fetchedAt,
|
|
2369
|
+
account: typeof raw.account === "string" ? raw.account : null,
|
|
2370
|
+
projects: strings2(raw.projects),
|
|
2371
|
+
warnings: strings2(raw.warnings),
|
|
2372
|
+
tickets: raw.tickets.flatMap((ticket) => normalizeStoredTicket(ticket) ?? []),
|
|
2373
|
+
statuses
|
|
2374
|
+
};
|
|
2375
|
+
} catch {
|
|
2376
|
+
return null;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// src/agent.ts
|
|
2381
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2382
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2383
|
+
import { appendFile as appendFile2, mkdir as mkdir2, readFile as readFile7 } from "node:fs/promises";
|
|
2384
|
+
import { homedir } from "node:os";
|
|
2385
|
+
import { createInterface as createInterface2 } from "node:readline";
|
|
2386
|
+
|
|
2387
|
+
// src/assistant.ts
|
|
2388
|
+
import { spawn } from "node:child_process";
|
|
2389
|
+
import { randomUUID } from "node:crypto";
|
|
2390
|
+
import { appendFile, mkdir, readFile as readFile6 } from "node:fs/promises";
|
|
2391
|
+
import { createInterface } from "node:readline";
|
|
2392
|
+
var TURN_TIMEOUT_MS = 10 * 6e4;
|
|
2393
|
+
var TURNS_SHOWN = 20;
|
|
2394
|
+
var QUICK_ACTIONS = [
|
|
2395
|
+
{
|
|
2396
|
+
id: "assess-review",
|
|
2397
|
+
label: "Assess the review",
|
|
2398
|
+
sources: ["github"],
|
|
2399
|
+
request: "Read every review comment and requested change on this pull request against the diff. For each one say whether it is right, partly right or mistaken, with the evidence, and what I should do about it."
|
|
2400
|
+
},
|
|
2401
|
+
{
|
|
2402
|
+
id: "explain-ci",
|
|
2403
|
+
label: "Explain the CI failure",
|
|
2404
|
+
sources: ["github"],
|
|
2405
|
+
request: "Find the failing checks on this pull request and read their logs. Explain what is failing, why, and the smallest change that would fix it. Do not attempt the fix."
|
|
2406
|
+
},
|
|
2407
|
+
{
|
|
2408
|
+
id: "summarise-pr",
|
|
2409
|
+
label: "Summarise the change",
|
|
2410
|
+
sources: ["github"],
|
|
2411
|
+
request: "Summarise what this pull request changes and why, in a form I could paste as its description, and flag anything a reviewer is likely to push back on."
|
|
2412
|
+
},
|
|
2413
|
+
{
|
|
2414
|
+
id: "reply-reviewer",
|
|
2415
|
+
label: "Draft replies to reviewers",
|
|
2416
|
+
sources: ["github"],
|
|
2417
|
+
request: "Draft a reply to each open review comment on this pull request, in my voice, agreeing where the reviewer is right and explaining where they are not. Give me the text to post. Do not post it."
|
|
2418
|
+
},
|
|
2419
|
+
{
|
|
2420
|
+
id: "draft-reply",
|
|
2421
|
+
label: "Draft a reply",
|
|
2422
|
+
sources: ["email"],
|
|
2423
|
+
request: "Read the whole thread and draft a reply from me, in my voice, that answers what was asked. Create it as a Gmail draft on this thread so I can open it there. Never send it. If you cannot create drafts, give me the text instead and say so."
|
|
2424
|
+
},
|
|
2425
|
+
{
|
|
2426
|
+
id: "summarise-thread",
|
|
2427
|
+
label: "Summarise the thread",
|
|
2428
|
+
sources: ["email"],
|
|
2429
|
+
request: "Read the whole thread and summarise it: who wants what, what has been decided, and what is still open."
|
|
2430
|
+
},
|
|
2431
|
+
{
|
|
2432
|
+
id: "what-they-need",
|
|
2433
|
+
label: "What do they need from me?",
|
|
2434
|
+
sources: ["email", "slack", "messages"],
|
|
2435
|
+
request: "Read this and tell me exactly what is being asked of me, by whom, by when, and what the smallest adequate response would be."
|
|
2436
|
+
},
|
|
2437
|
+
{
|
|
2438
|
+
id: "summarise-ticket",
|
|
2439
|
+
label: "Summarise the ticket",
|
|
2440
|
+
sources: ["jira"],
|
|
2441
|
+
request: "Read this ticket, its comments and anything it links to, and summarise where it stands: what is done, what is left, and what is blocking it."
|
|
2442
|
+
},
|
|
2443
|
+
{
|
|
2444
|
+
id: "status-comment",
|
|
2445
|
+
label: "Draft a status comment",
|
|
2446
|
+
sources: ["jira"],
|
|
2447
|
+
request: "Read this ticket and its linked pull requests, and draft a short status comment from me for the ticket. Give me the text to post. Do not post it."
|
|
2448
|
+
},
|
|
2449
|
+
{
|
|
2450
|
+
id: "next-steps",
|
|
2451
|
+
label: "Break it into next steps",
|
|
2452
|
+
sources: null,
|
|
2453
|
+
request: "Read whatever this item points at and turn it into three to five concrete next steps I could start on today, smallest first."
|
|
2454
|
+
}
|
|
2455
|
+
];
|
|
2456
|
+
function foldAssistantLog(text) {
|
|
2457
|
+
const turns = /* @__PURE__ */ new Map();
|
|
2458
|
+
for (const line of text.split("\n")) {
|
|
2459
|
+
if (line.trim() === "") continue;
|
|
2460
|
+
let record;
|
|
2461
|
+
try {
|
|
2462
|
+
record = JSON.parse(line);
|
|
2463
|
+
} catch {
|
|
2464
|
+
continue;
|
|
2465
|
+
}
|
|
2466
|
+
if (typeof record !== "object" || record === null || typeof record.turn !== "string") continue;
|
|
2467
|
+
if (record.event === "started") {
|
|
2468
|
+
if (typeof record.item !== "string" || typeof record.request !== "string") continue;
|
|
2469
|
+
turns.set(record.turn, {
|
|
2470
|
+
id: record.turn,
|
|
2471
|
+
itemId: record.item,
|
|
2472
|
+
agent: record.agent,
|
|
2473
|
+
sessionId: null,
|
|
2474
|
+
request: record.request,
|
|
2475
|
+
action: typeof record.action === "string" ? record.action : null,
|
|
2476
|
+
startedAt: record.at,
|
|
2477
|
+
endedAt: null,
|
|
2478
|
+
status: "running",
|
|
2479
|
+
reply: "",
|
|
2480
|
+
error: null
|
|
2481
|
+
});
|
|
2482
|
+
continue;
|
|
2483
|
+
}
|
|
2484
|
+
const turn = turns.get(record.turn);
|
|
2485
|
+
if (!turn) continue;
|
|
2486
|
+
turn.sessionId = typeof record.sessionId === "string" ? record.sessionId : turn.sessionId;
|
|
2487
|
+
turn.reply = typeof record.reply === "string" ? record.reply : turn.reply;
|
|
2488
|
+
turn.endedAt = record.at;
|
|
2489
|
+
if (record.event === "finished") {
|
|
2490
|
+
turn.status = "done";
|
|
2491
|
+
} else if (record.event === "failed") {
|
|
2492
|
+
turn.status = "failed";
|
|
2493
|
+
turn.error = record.error;
|
|
2494
|
+
} else if (record.event === "aborted") {
|
|
2495
|
+
turn.status = "aborted";
|
|
2496
|
+
turn.error = record.reason;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
return turns;
|
|
2500
|
+
}
|
|
2501
|
+
function parseLine(line) {
|
|
2502
|
+
const trimmed = line.trim();
|
|
2503
|
+
if (!trimmed.startsWith("{")) return null;
|
|
2504
|
+
try {
|
|
2505
|
+
const parsed = JSON.parse(trimmed);
|
|
2506
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
2507
|
+
} catch {
|
|
2508
|
+
return null;
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
var claudeAdapter = {
|
|
2512
|
+
args({ prompt, sessionId, model, effort, tools, denyEdits }) {
|
|
2513
|
+
const args = [
|
|
2514
|
+
"-p",
|
|
2515
|
+
"--output-format",
|
|
2516
|
+
"stream-json",
|
|
2517
|
+
"--verbose",
|
|
2518
|
+
"--permission-mode",
|
|
2519
|
+
"dontAsk",
|
|
2520
|
+
"--permission-prompts",
|
|
2521
|
+
"none"
|
|
2522
|
+
];
|
|
2523
|
+
if (denyEdits) args.push("--disallowedTools", "Edit", "Write", "NotebookEdit");
|
|
2524
|
+
if (tools.length > 0) args.push("--allowedTools", ...tools);
|
|
2525
|
+
if (model) args.push("--model", model);
|
|
2526
|
+
if (effort) args.push("--effort", effort);
|
|
2527
|
+
if (sessionId) args.push("--resume", sessionId);
|
|
2528
|
+
args.push(prompt);
|
|
2529
|
+
return args;
|
|
2530
|
+
},
|
|
2531
|
+
parse(line) {
|
|
2532
|
+
const event = parseLine(line);
|
|
2533
|
+
if (!event) return null;
|
|
2534
|
+
const type = event.type;
|
|
2535
|
+
if (type === "system" && typeof event.session_id === "string") {
|
|
2536
|
+
return { sessionId: event.session_id };
|
|
2537
|
+
}
|
|
2538
|
+
if (type === "assistant") {
|
|
2539
|
+
const message = event.message;
|
|
2540
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
2541
|
+
const text = content.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n\n");
|
|
2542
|
+
return text ? { text } : null;
|
|
2543
|
+
}
|
|
2544
|
+
if (type === "result") {
|
|
2545
|
+
const ok = event.is_error !== true && event.subtype === "success";
|
|
2546
|
+
const reply = typeof event.result === "string" ? event.result : void 0;
|
|
2547
|
+
const parsed = { done: ok ? { ok, reply } : { ok, error: reply ?? String(event.subtype ?? "failed") } };
|
|
2548
|
+
if (typeof event.session_id === "string") parsed.sessionId = event.session_id;
|
|
2549
|
+
return parsed;
|
|
2550
|
+
}
|
|
2551
|
+
return null;
|
|
2552
|
+
}
|
|
2553
|
+
};
|
|
2554
|
+
var codexAdapter = {
|
|
2555
|
+
args({ prompt, sessionId, model, effort }) {
|
|
2556
|
+
const args = ["exec"];
|
|
2557
|
+
if (sessionId) args.push("resume");
|
|
2558
|
+
args.push("--json", "--skip-git-repo-check");
|
|
2559
|
+
args.push("-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true");
|
|
2560
|
+
if (model) args.push("-c", `model=${JSON.stringify(model)}`);
|
|
2561
|
+
if (effort) args.push("-c", `model_reasoning_effort=${JSON.stringify(effort)}`);
|
|
2562
|
+
if (sessionId) args.push(sessionId);
|
|
2563
|
+
args.push(prompt);
|
|
2564
|
+
return args;
|
|
2565
|
+
},
|
|
2566
|
+
parse(line) {
|
|
2567
|
+
const event = parseLine(line);
|
|
2568
|
+
if (!event) return null;
|
|
2569
|
+
const type = event.type;
|
|
2570
|
+
if (type === "thread.started" && typeof event.thread_id === "string") {
|
|
2571
|
+
return { sessionId: event.thread_id };
|
|
2572
|
+
}
|
|
2573
|
+
if (type === "item.completed") {
|
|
2574
|
+
const item = event.item;
|
|
2575
|
+
if (item?.type === "agent_message" && typeof item.text === "string") return { text: item.text };
|
|
2576
|
+
return null;
|
|
2577
|
+
}
|
|
2578
|
+
if (type === "turn.completed") return { done: { ok: true } };
|
|
2579
|
+
if (type === "turn.failed" || type === "error") {
|
|
2580
|
+
const error = event.error;
|
|
2581
|
+
const message = typeof event.message === "string" ? event.message : error?.message;
|
|
2582
|
+
return { done: { ok: false, error: message ?? String(type) } };
|
|
2583
|
+
}
|
|
2584
|
+
return null;
|
|
2585
|
+
}
|
|
2586
|
+
};
|
|
2587
|
+
var ADAPTERS = {
|
|
2588
|
+
claude: claudeAdapter,
|
|
2589
|
+
codex: codexAdapter
|
|
2590
|
+
};
|
|
2591
|
+
function composePrompt(instructions, context, request, profile) {
|
|
2592
|
+
const now = /* @__PURE__ */ new Date();
|
|
2593
|
+
const facts = [`- Profile: ${profile}`, `- Today: ${now.toISOString().slice(0, 10)}`];
|
|
2594
|
+
const parts = [instructions.trim(), "", "## Context", "", ...facts, ""];
|
|
2595
|
+
if (context.item) parts.push("### The item", "", "```json", JSON.stringify(context.item, null, 2), "```", "");
|
|
2596
|
+
if (context.pull) parts.push("### The pull request, as the board last saw it", "", "```json", JSON.stringify(context.pull, null, 2), "```", "");
|
|
2597
|
+
if (context.ticket) parts.push("### The ticket, as the board last saw it", "", "```json", JSON.stringify(context.ticket, null, 2), "```", "");
|
|
2598
|
+
parts.push("## Request", "", request.trim());
|
|
2599
|
+
return parts.join("\n");
|
|
2600
|
+
}
|
|
2601
|
+
var Assistant = class _Assistant {
|
|
2602
|
+
turns = /* @__PURE__ */ new Map();
|
|
2603
|
+
running = /* @__PURE__ */ new Map();
|
|
2604
|
+
loaded = false;
|
|
2605
|
+
config;
|
|
2606
|
+
hooks;
|
|
2607
|
+
spawnFn;
|
|
2608
|
+
constructor(config, hooks, spawnFn = spawn) {
|
|
2609
|
+
this.config = config;
|
|
2610
|
+
this.hooks = hooks;
|
|
2611
|
+
this.spawnFn = spawnFn;
|
|
2612
|
+
}
|
|
2613
|
+
get enabled() {
|
|
2614
|
+
return this.config.assistant.agent !== null;
|
|
2615
|
+
}
|
|
2616
|
+
/** Whether any turn has a process going right now. */
|
|
2617
|
+
get busy() {
|
|
2618
|
+
return this.running.size > 0;
|
|
2619
|
+
}
|
|
2620
|
+
/**
|
|
2621
|
+
* Read the log, and close whatever was left running when the last process
|
|
2622
|
+
* died. Cheap when the assistant is off: the log is read only if it exists.
|
|
2623
|
+
*/
|
|
2624
|
+
async start() {
|
|
2625
|
+
if (this.loaded) return;
|
|
2626
|
+
this.loaded = true;
|
|
2627
|
+
let text = "";
|
|
2628
|
+
try {
|
|
2629
|
+
text = await readFile6(this.config.assistantLogFile, "utf8");
|
|
2630
|
+
} catch {
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
for (const [id, turn] of foldAssistantLog(text)) this.turns.set(id, turn);
|
|
2634
|
+
for (const turn of this.turns.values()) {
|
|
2635
|
+
if (turn.status !== "running") continue;
|
|
2636
|
+
turn.status = "aborted";
|
|
2637
|
+
turn.error = "the dashboard restarted while this was running";
|
|
2638
|
+
turn.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2639
|
+
await this.append({
|
|
2640
|
+
event: "aborted",
|
|
2641
|
+
turn: turn.id,
|
|
2642
|
+
sessionId: turn.sessionId,
|
|
2643
|
+
reason: turn.error,
|
|
2644
|
+
reply: turn.reply,
|
|
2645
|
+
at: turn.endedAt
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
view() {
|
|
2650
|
+
const items = {};
|
|
2651
|
+
for (const turn of this.turns.values()) {
|
|
2652
|
+
const entry = items[turn.itemId] ??= { running: false, sessionId: null, turns: [] };
|
|
2653
|
+
entry.turns.push(turn);
|
|
2654
|
+
if (turn.status === "running") entry.running = true;
|
|
2655
|
+
if (turn.sessionId && turn.agent === this.config.assistant.agent) entry.sessionId = turn.sessionId;
|
|
2656
|
+
}
|
|
2657
|
+
for (const entry of Object.values(items)) {
|
|
2658
|
+
entry.turns.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
2659
|
+
entry.turns = entry.turns.slice(-TURNS_SHOWN);
|
|
2660
|
+
}
|
|
2661
|
+
return {
|
|
2662
|
+
enabled: this.enabled,
|
|
2663
|
+
agent: this.config.assistant.agent,
|
|
2664
|
+
quickActions: [...QUICK_ACTIONS],
|
|
2665
|
+
items
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
/** The request text an ask resolves to, or null when there is nothing to send. */
|
|
2669
|
+
static requestFor(ask) {
|
|
2670
|
+
const action = ask.action ? QUICK_ACTIONS.find((candidate) => candidate.id === ask.action) : void 0;
|
|
2671
|
+
if (ask.action && !action) return null;
|
|
2672
|
+
const typed = (ask.text ?? "").trim();
|
|
2673
|
+
if (!action && typed === "") return null;
|
|
2674
|
+
const request = action ? typed ? `${action.request}
|
|
2675
|
+
|
|
2676
|
+
${typed}` : action.request : typed;
|
|
2677
|
+
return { request, action: action?.id ?? null };
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* Start a turn. Returns once the process is running, not once it has answered;
|
|
2681
|
+
* the answer arrives through `onChange` and the next state.
|
|
2682
|
+
*/
|
|
2683
|
+
async ask(ask, context) {
|
|
2684
|
+
const agent = this.config.assistant.agent;
|
|
2685
|
+
if (!agent) throw new Error("the assistant is off; set DAILY_FOCUS_ASSISTANT to claude or codex");
|
|
2686
|
+
if (this.running.has(ask.itemId)) throw new Error("the assistant is already working on this item");
|
|
2687
|
+
const resolved = _Assistant.requestFor(ask);
|
|
2688
|
+
if (!resolved) throw new Error("nothing to ask: give a quick action or some text");
|
|
2689
|
+
await this.start();
|
|
2690
|
+
const previous = this.view().items[ask.itemId]?.sessionId ?? null;
|
|
2691
|
+
const turn = {
|
|
2692
|
+
id: randomUUID(),
|
|
2693
|
+
itemId: ask.itemId,
|
|
2694
|
+
agent,
|
|
2695
|
+
// Null until the CLI says: a resumed session that fails must not be
|
|
2696
|
+
// recorded as if this CLI had owned it.
|
|
2697
|
+
sessionId: null,
|
|
2698
|
+
request: resolved.request,
|
|
2699
|
+
action: resolved.action,
|
|
2700
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2701
|
+
endedAt: null,
|
|
2702
|
+
status: "running",
|
|
2703
|
+
reply: "",
|
|
2704
|
+
error: null
|
|
2705
|
+
};
|
|
2706
|
+
this.turns.set(turn.id, turn);
|
|
2707
|
+
await this.append({
|
|
2708
|
+
event: "started",
|
|
2709
|
+
turn: turn.id,
|
|
2710
|
+
item: turn.itemId,
|
|
2711
|
+
agent,
|
|
2712
|
+
request: turn.request,
|
|
2713
|
+
action: turn.action,
|
|
2714
|
+
at: turn.startedAt
|
|
2715
|
+
});
|
|
2716
|
+
const prompt = previous ? resolved.request : composePrompt(await this.instructions(), context, resolved.request, this.config.profile);
|
|
2717
|
+
await mkdir(this.config.assistantDir, { recursive: true });
|
|
2718
|
+
const adapter = ADAPTERS[agent];
|
|
2719
|
+
const args = adapter.args({
|
|
2720
|
+
prompt,
|
|
2721
|
+
sessionId: previous,
|
|
2722
|
+
model: this.config.assistant.model,
|
|
2723
|
+
effort: this.config.assistant.effort,
|
|
2724
|
+
tools: this.config.assistant.tools,
|
|
2725
|
+
denyEdits: true
|
|
2726
|
+
});
|
|
2727
|
+
let child;
|
|
2728
|
+
try {
|
|
2729
|
+
child = this.spawnFn(this.config.assistant.binPath, args, {
|
|
2730
|
+
cwd: this.config.assistantDir,
|
|
2731
|
+
// Stdin closed: both CLIs wait on it otherwise, one of them for three seconds.
|
|
2732
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2733
|
+
env: process.env
|
|
2734
|
+
});
|
|
2735
|
+
} catch (err) {
|
|
2736
|
+
await this.finish(turn, { ok: false, error: this.spawnError(err) });
|
|
2737
|
+
return turn;
|
|
2738
|
+
}
|
|
2739
|
+
const timer = setTimeout(() => this.stop(turn.itemId, "gave up after ten minutes"), TURN_TIMEOUT_MS);
|
|
2740
|
+
timer.unref();
|
|
2741
|
+
const run6 = { turn, child, timer, abortReason: null };
|
|
2742
|
+
this.running.set(turn.itemId, run6);
|
|
2743
|
+
this.hooks.onChange();
|
|
2744
|
+
let done;
|
|
2745
|
+
const stderr = [];
|
|
2746
|
+
child.stderr?.on("data", (chunk) => {
|
|
2747
|
+
stderr.push(chunk.toString("utf8"));
|
|
2748
|
+
if (stderr.length > 50) stderr.shift();
|
|
2749
|
+
});
|
|
2750
|
+
if (child.stdout) {
|
|
2751
|
+
const lines = createInterface({ input: child.stdout });
|
|
2752
|
+
lines.on("line", (line) => {
|
|
2753
|
+
const event = adapter.parse(line);
|
|
2754
|
+
if (!event) return;
|
|
2755
|
+
if (event.sessionId) turn.sessionId = event.sessionId;
|
|
2756
|
+
if (event.text) {
|
|
2757
|
+
turn.reply = turn.reply ? `${turn.reply}
|
|
2758
|
+
|
|
2759
|
+
${event.text}` : event.text;
|
|
2760
|
+
this.hooks.onChange();
|
|
2761
|
+
}
|
|
2762
|
+
if (event.done) done = event.done;
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
child.on("error", (err) => {
|
|
2766
|
+
done = { ok: false, error: this.spawnError(err) };
|
|
2767
|
+
});
|
|
2768
|
+
child.on("close", (code) => {
|
|
2769
|
+
clearTimeout(timer);
|
|
2770
|
+
this.running.delete(turn.itemId);
|
|
2771
|
+
const outcome = run6.abortReason ? { ok: false, error: run6.abortReason } : done ?? {
|
|
2772
|
+
ok: false,
|
|
2773
|
+
error: `${agent} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
|
|
2774
|
+
};
|
|
2775
|
+
void this.finish(turn, outcome, run6.abortReason !== null);
|
|
2776
|
+
});
|
|
2777
|
+
return turn;
|
|
2778
|
+
}
|
|
2779
|
+
/** Kill the turn running on an item, if any. */
|
|
2780
|
+
async stop(itemId, reason = "stopped") {
|
|
2781
|
+
const run6 = this.running.get(itemId);
|
|
2782
|
+
if (!run6) return false;
|
|
2783
|
+
run6.abortReason = reason;
|
|
2784
|
+
run6.child.kill("SIGTERM");
|
|
2785
|
+
return true;
|
|
2786
|
+
}
|
|
2787
|
+
/** Kill everything, for shutdown. The exit handlers write the aborted lines. */
|
|
2788
|
+
async close() {
|
|
2789
|
+
for (const itemId of [...this.running.keys()]) await this.stop(itemId, "the dashboard shut down");
|
|
2790
|
+
}
|
|
2791
|
+
async finish(turn, outcome, aborted = false) {
|
|
2792
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
2793
|
+
const reply = outcome.reply ?? turn.reply;
|
|
2794
|
+
turn.reply = reply;
|
|
2795
|
+
turn.endedAt = at;
|
|
2796
|
+
if (outcome.ok) {
|
|
2797
|
+
turn.status = "done";
|
|
2798
|
+
await this.append({ event: "finished", turn: turn.id, sessionId: turn.sessionId, reply, at });
|
|
2799
|
+
} else if (aborted) {
|
|
2800
|
+
turn.status = "aborted";
|
|
2801
|
+
turn.error = outcome.error ?? "stopped";
|
|
2802
|
+
await this.append({ event: "aborted", turn: turn.id, sessionId: turn.sessionId, reason: turn.error, reply, at });
|
|
2803
|
+
} else {
|
|
2804
|
+
turn.status = "failed";
|
|
2805
|
+
turn.error = outcome.error ?? "failed";
|
|
2806
|
+
await this.append({ event: "failed", turn: turn.id, sessionId: turn.sessionId, error: turn.error, reply, at });
|
|
2807
|
+
}
|
|
2808
|
+
this.hooks.onChange();
|
|
2809
|
+
}
|
|
2810
|
+
spawnError(err) {
|
|
2811
|
+
const code = err.code;
|
|
2812
|
+
const bin = this.config.assistant.binPath;
|
|
2813
|
+
if (code === "ENOENT") return `could not find ${bin}; install it or set DAILY_FOCUS_ASSISTANT_BIN to its path`;
|
|
2814
|
+
return `could not run ${bin}: ${err instanceof Error ? err.message : String(err)}`;
|
|
2815
|
+
}
|
|
2816
|
+
/** The store's copy, or the repo's before `npm run init` has linked it. */
|
|
2817
|
+
async instructions() {
|
|
2818
|
+
for (const path of [this.config.assistantPromptFile, this.config.assistantPromptSource]) {
|
|
2819
|
+
try {
|
|
2820
|
+
return await readFile6(path, "utf8");
|
|
2821
|
+
} catch {
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
return "You are helping with one item from a personal dashboard. Read only; never send, post or edit anything.";
|
|
2825
|
+
}
|
|
2826
|
+
/**
|
|
2827
|
+
* The log records what happened; the turn in memory is updated by the caller
|
|
2828
|
+
* first. Only this process writes the file, so the memory never has to be
|
|
2829
|
+
* re-read from it.
|
|
2830
|
+
*/
|
|
2831
|
+
async append(record) {
|
|
2832
|
+
await mkdir(this.config.dataDir, { recursive: true });
|
|
2833
|
+
await appendFile(this.config.assistantLogFile, `${JSON.stringify(record)}
|
|
2834
|
+
`, "utf8");
|
|
2835
|
+
}
|
|
2836
|
+
};
|
|
2837
|
+
|
|
2838
|
+
// src/agent.ts
|
|
2839
|
+
var RUN_TIMEOUT_MS = 60 * 6e4;
|
|
2840
|
+
var TURN_TIMEOUT_MS2 = 10 * 6e4;
|
|
2841
|
+
var RUNS_SHOWN = 30;
|
|
2842
|
+
function agentPrompt(promptPath) {
|
|
2843
|
+
return [
|
|
2844
|
+
"Run my morning brief for today.",
|
|
2845
|
+
"",
|
|
2846
|
+
"Your full instructions are in this file \u2014 read it now and follow it exactly,",
|
|
2847
|
+
"including every step it lists and the verification checks it ends with:",
|
|
2848
|
+
"",
|
|
2849
|
+
` ${promptPath}`,
|
|
2850
|
+
"",
|
|
2851
|
+
"That file is the source of truth and it changes over time. Re-read it on every",
|
|
2852
|
+
"run; never work from your memory of a previous run.",
|
|
2853
|
+
"",
|
|
2854
|
+
"Finish by reporting back as that file asks you to."
|
|
2855
|
+
].join("\n");
|
|
2856
|
+
}
|
|
2857
|
+
function followUpPrompt(question) {
|
|
2858
|
+
return [
|
|
2859
|
+
"A follow-up question about the brief you wrote in this session. Answer it here.",
|
|
2860
|
+
"Read whatever you need to, but do not write or change any file, and do not",
|
|
2861
|
+
"rewrite the brief: if something in it should change, say what and why, and I",
|
|
2862
|
+
"will run you again.",
|
|
2863
|
+
"",
|
|
2864
|
+
question.trim()
|
|
2865
|
+
].join("\n");
|
|
2866
|
+
}
|
|
2867
|
+
function displayPath(path, home = homedir()) {
|
|
2868
|
+
return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
2869
|
+
}
|
|
2870
|
+
function foldAgentLog(text) {
|
|
2871
|
+
const runs = /* @__PURE__ */ new Map();
|
|
2872
|
+
for (const line of text.split("\n")) {
|
|
2873
|
+
if (line.trim() === "") continue;
|
|
2874
|
+
let record;
|
|
2875
|
+
try {
|
|
2876
|
+
record = JSON.parse(line);
|
|
2877
|
+
} catch {
|
|
2878
|
+
continue;
|
|
2879
|
+
}
|
|
2880
|
+
if (typeof record !== "object" || record === null || typeof record.run !== "string") continue;
|
|
2881
|
+
if (record.event === "started") {
|
|
2882
|
+
runs.set(record.run, {
|
|
2883
|
+
id: record.run,
|
|
2884
|
+
cli: record.cli,
|
|
2885
|
+
// Runs logged before the dashboard had a clock were all started by hand.
|
|
2886
|
+
trigger: record.trigger === "schedule" ? "schedule" : "hand",
|
|
2887
|
+
sessionId: null,
|
|
2888
|
+
startedAt: record.at,
|
|
2889
|
+
endedAt: null,
|
|
2890
|
+
status: "running",
|
|
2891
|
+
report: "",
|
|
2892
|
+
messages: [],
|
|
2893
|
+
error: null,
|
|
2894
|
+
turns: [],
|
|
2895
|
+
resumable: false
|
|
2896
|
+
});
|
|
2897
|
+
continue;
|
|
2898
|
+
}
|
|
2899
|
+
const run6 = runs.get(record.run);
|
|
2900
|
+
if (!run6) continue;
|
|
2901
|
+
if (record.event === "asked") {
|
|
2902
|
+
if (typeof record.turn !== "string" || typeof record.question !== "string") continue;
|
|
2903
|
+
run6.turns.push({
|
|
2904
|
+
id: record.turn,
|
|
2905
|
+
question: record.question,
|
|
2906
|
+
startedAt: record.at,
|
|
2907
|
+
endedAt: null,
|
|
2908
|
+
status: "running",
|
|
2909
|
+
reply: "",
|
|
2910
|
+
error: null
|
|
2911
|
+
});
|
|
2912
|
+
continue;
|
|
2913
|
+
}
|
|
2914
|
+
if (record.event === "replied" || record.event === "turn-failed" || record.event === "turn-aborted") {
|
|
2915
|
+
const turn = run6.turns.find((candidate) => candidate.id === record.turn);
|
|
2916
|
+
if (!turn) continue;
|
|
2917
|
+
turn.reply = typeof record.reply === "string" ? record.reply : turn.reply;
|
|
2918
|
+
turn.endedAt = record.at;
|
|
2919
|
+
if (record.event === "replied") {
|
|
2920
|
+
turn.status = "done";
|
|
2921
|
+
} else if (record.event === "turn-failed") {
|
|
2922
|
+
turn.status = "failed";
|
|
2923
|
+
turn.error = record.error;
|
|
2924
|
+
} else {
|
|
2925
|
+
turn.status = "aborted";
|
|
2926
|
+
turn.error = record.reason;
|
|
2927
|
+
}
|
|
2928
|
+
continue;
|
|
2929
|
+
}
|
|
2930
|
+
run6.sessionId = typeof record.sessionId === "string" ? record.sessionId : run6.sessionId;
|
|
2931
|
+
run6.report = typeof record.report === "string" ? record.report : run6.report;
|
|
2932
|
+
run6.messages = Array.isArray(record.messages) ? record.messages.filter((message) => typeof message === "string") : run6.report ? [run6.report] : [];
|
|
2933
|
+
run6.endedAt = record.at;
|
|
2934
|
+
if (record.event === "finished") {
|
|
2935
|
+
run6.status = "done";
|
|
2936
|
+
} else if (record.event === "failed") {
|
|
2937
|
+
run6.status = "failed";
|
|
2938
|
+
run6.error = record.error;
|
|
2939
|
+
} else if (record.event === "aborted") {
|
|
2940
|
+
run6.status = "aborted";
|
|
2941
|
+
run6.error = record.reason;
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
return [...runs.values()].sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
2945
|
+
}
|
|
2946
|
+
var AgentRunner = class {
|
|
2947
|
+
/** Every run in the log, oldest first. */
|
|
2948
|
+
runs = [];
|
|
2949
|
+
running = null;
|
|
2950
|
+
loaded = false;
|
|
2951
|
+
config;
|
|
2952
|
+
hooks;
|
|
2953
|
+
spawnFn;
|
|
2954
|
+
constructor(config, hooks, spawnFn = spawn2) {
|
|
2955
|
+
this.config = config;
|
|
2956
|
+
this.hooks = hooks;
|
|
2957
|
+
this.spawnFn = spawnFn;
|
|
2958
|
+
}
|
|
2959
|
+
get enabled() {
|
|
2960
|
+
return this.config.agent.cli !== null;
|
|
2961
|
+
}
|
|
2962
|
+
/**
|
|
2963
|
+
* Read the log, and close whatever was left going when the last process died:
|
|
2964
|
+
* its child died with it, so a spinner would never stop.
|
|
2965
|
+
*/
|
|
2966
|
+
async start() {
|
|
2967
|
+
if (this.loaded) return;
|
|
2968
|
+
this.loaded = true;
|
|
2969
|
+
let text = "";
|
|
2970
|
+
try {
|
|
2971
|
+
text = await readFile7(this.config.agentLogFile, "utf8");
|
|
2972
|
+
} catch {
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
this.runs = foldAgentLog(text);
|
|
2976
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
2977
|
+
const reason = "the dashboard restarted while this was running";
|
|
2978
|
+
for (const run6 of this.runs) {
|
|
2979
|
+
if (run6.status === "running") {
|
|
2980
|
+
run6.status = "aborted";
|
|
2981
|
+
run6.error = reason;
|
|
2982
|
+
run6.endedAt = at;
|
|
2983
|
+
await this.append({ event: "aborted", run: run6.id, sessionId: run6.sessionId, reason, report: run6.report, messages: run6.messages, at });
|
|
2984
|
+
}
|
|
2985
|
+
for (const turn of run6.turns) {
|
|
2986
|
+
if (turn.status !== "running") continue;
|
|
2987
|
+
turn.status = "aborted";
|
|
2988
|
+
turn.error = reason;
|
|
2989
|
+
turn.endedAt = at;
|
|
2990
|
+
await this.append({ event: "turn-aborted", run: run6.id, turn: turn.id, reason, reply: turn.reply, at });
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
/** Whether a run, or a question about one, has a process going right now. */
|
|
2995
|
+
get busy() {
|
|
2996
|
+
return this.running !== null;
|
|
2997
|
+
}
|
|
2998
|
+
/** The newest run, whatever became of it. */
|
|
2999
|
+
get last() {
|
|
3000
|
+
return this.runs.at(-1) ?? null;
|
|
3001
|
+
}
|
|
3002
|
+
view(now = /* @__PURE__ */ new Date()) {
|
|
3003
|
+
const runs = this.runs.slice(-RUNS_SHOWN).reverse().map((run6) => ({ ...run6, resumable: this.resumable(run6) }));
|
|
3004
|
+
return {
|
|
3005
|
+
enabled: this.enabled,
|
|
3006
|
+
cli: this.config.agent.cli,
|
|
3007
|
+
schedule: this.scheduleView(now),
|
|
3008
|
+
last: runs[0] ?? null,
|
|
3009
|
+
runs
|
|
3010
|
+
};
|
|
3011
|
+
}
|
|
3012
|
+
scheduleView(now) {
|
|
3013
|
+
const at = this.config.agent.at;
|
|
3014
|
+
if (!at || !this.enabled) return null;
|
|
3015
|
+
return { at: describeTime(at), nextRunAt: this.nextRunAt?.toISOString() ?? null };
|
|
3016
|
+
}
|
|
3017
|
+
/** Worked out by the last tick, since it needs the log. */
|
|
3018
|
+
nextRunAt = null;
|
|
3019
|
+
/**
|
|
3020
|
+
* The clock. Called every so often by the server, and once at startup: if a
|
|
3021
|
+
* run is due and none has started today, start one.
|
|
3022
|
+
*/
|
|
3023
|
+
async tick(now = /* @__PURE__ */ new Date()) {
|
|
3024
|
+
const at = this.config.agent.at;
|
|
3025
|
+
if (!at || !this.enabled) return;
|
|
3026
|
+
await this.start();
|
|
3027
|
+
const input = {
|
|
3028
|
+
at,
|
|
3029
|
+
schedule: resolveSchedule(this.config),
|
|
3030
|
+
lastRunStartedAt: this.last?.startedAt ?? null,
|
|
3031
|
+
now
|
|
3032
|
+
};
|
|
3033
|
+
const next = nextScheduledRun(input);
|
|
3034
|
+
if ((next?.getTime() ?? null) !== (this.nextRunAt?.getTime() ?? null)) {
|
|
3035
|
+
this.nextRunAt = next;
|
|
3036
|
+
this.hooks.onChange();
|
|
3037
|
+
}
|
|
3038
|
+
if (!scheduledRunDue(input) || this.running) return;
|
|
3039
|
+
console.log(`[daily-focus] starting the morning agent, due ${describeTime(at)}`);
|
|
3040
|
+
await this.run("schedule", now);
|
|
3041
|
+
}
|
|
3042
|
+
/**
|
|
3043
|
+
* Start a run. Returns once the process is running, not once the brief is
|
|
3044
|
+
* written; the report arrives through `onChange`, the brief through the store.
|
|
3045
|
+
* `now` is the clock's, so a run it starts is stamped with the moment it
|
|
3046
|
+
* decided on.
|
|
3047
|
+
*/
|
|
3048
|
+
async run(trigger = "hand", now = /* @__PURE__ */ new Date()) {
|
|
3049
|
+
const cli = this.config.agent.cli;
|
|
3050
|
+
if (!cli) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
|
|
3051
|
+
if (this.running) {
|
|
3052
|
+
throw new Error(
|
|
3053
|
+
this.running.turn ? "the morning agent is still answering a question" : "the morning agent is already running"
|
|
3054
|
+
);
|
|
3055
|
+
}
|
|
3056
|
+
await this.start();
|
|
3057
|
+
const run6 = {
|
|
3058
|
+
id: randomUUID2(),
|
|
3059
|
+
cli,
|
|
3060
|
+
trigger,
|
|
3061
|
+
sessionId: null,
|
|
3062
|
+
startedAt: now.toISOString(),
|
|
3063
|
+
endedAt: null,
|
|
3064
|
+
status: "running",
|
|
3065
|
+
report: "",
|
|
3066
|
+
messages: [],
|
|
3067
|
+
error: null,
|
|
3068
|
+
turns: [],
|
|
3069
|
+
resumable: false
|
|
3070
|
+
};
|
|
3071
|
+
this.runs.push(run6);
|
|
3072
|
+
await this.append({ event: "started", run: run6.id, cli, trigger, at: run6.startedAt });
|
|
3073
|
+
const adapter = ADAPTERS[cli];
|
|
3074
|
+
const args = adapter.args({
|
|
3075
|
+
prompt: agentPrompt(displayPath(this.config.promptFile)),
|
|
3076
|
+
sessionId: null,
|
|
3077
|
+
model: this.config.agent.model,
|
|
3078
|
+
effort: this.config.agent.effort,
|
|
3079
|
+
tools: [...briefWritingTools(), ...this.config.agent.tools],
|
|
3080
|
+
denyEdits: false
|
|
3081
|
+
});
|
|
3082
|
+
await this.spawnInto(run6, null, args, RUN_TIMEOUT_MS, "gave up after an hour");
|
|
3083
|
+
return run6;
|
|
3084
|
+
}
|
|
3085
|
+
/**
|
|
3086
|
+
* Ask a finished run a question, in its own session. Returns once the process
|
|
3087
|
+
* is running; the answer arrives through `onChange` and the next state.
|
|
3088
|
+
*/
|
|
3089
|
+
async ask(runId, question) {
|
|
3090
|
+
const cli = this.config.agent.cli;
|
|
3091
|
+
if (!cli) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
|
|
3092
|
+
await this.start();
|
|
3093
|
+
const run6 = this.runs.find((candidate) => candidate.id === runId);
|
|
3094
|
+
if (!run6) throw new Error("no such run");
|
|
3095
|
+
if (this.running) {
|
|
3096
|
+
throw new Error(
|
|
3097
|
+
this.running.turn ? "the morning agent is still answering a question" : "the morning agent is still running"
|
|
3098
|
+
);
|
|
3099
|
+
}
|
|
3100
|
+
if (!this.resumable(run6)) {
|
|
3101
|
+
throw new Error(
|
|
3102
|
+
run6.sessionId === null ? "this run left no session to continue" : `this run was made by ${run6.cli}, and the agent is ${cli} now`
|
|
3103
|
+
);
|
|
3104
|
+
}
|
|
3105
|
+
const text = question.trim();
|
|
3106
|
+
if (text === "") throw new Error("nothing to ask");
|
|
3107
|
+
const turn = {
|
|
3108
|
+
id: randomUUID2(),
|
|
3109
|
+
question: text,
|
|
3110
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3111
|
+
endedAt: null,
|
|
3112
|
+
status: "running",
|
|
3113
|
+
reply: "",
|
|
3114
|
+
error: null
|
|
3115
|
+
};
|
|
3116
|
+
run6.turns.push(turn);
|
|
3117
|
+
await this.append({ event: "asked", run: run6.id, turn: turn.id, question: text, at: turn.startedAt });
|
|
3118
|
+
const adapter = ADAPTERS[cli];
|
|
3119
|
+
const args = adapter.args({
|
|
3120
|
+
prompt: followUpPrompt(text),
|
|
3121
|
+
sessionId: run6.sessionId,
|
|
3122
|
+
model: this.config.agent.model,
|
|
3123
|
+
effort: this.config.agent.effort,
|
|
3124
|
+
// The sources, and not the brief: a chat is given nothing to write.
|
|
3125
|
+
tools: this.config.agent.tools,
|
|
3126
|
+
denyEdits: true
|
|
3127
|
+
});
|
|
3128
|
+
await this.spawnInto(run6, turn, args, TURN_TIMEOUT_MS2, "gave up after ten minutes");
|
|
3129
|
+
return turn;
|
|
3130
|
+
}
|
|
3131
|
+
/**
|
|
3132
|
+
* Start the CLI in the store and read what it says into `turn`, or into the
|
|
3133
|
+
* run when there is none. The store in both cases: Claude Code files a
|
|
3134
|
+
* session under the directory it was started in, and looks for it there.
|
|
3135
|
+
*/
|
|
3136
|
+
async spawnInto(run6, turn, args, timeoutMs, timeoutReason) {
|
|
3137
|
+
const cli = run6.cli;
|
|
3138
|
+
const adapter = ADAPTERS[cli];
|
|
3139
|
+
await mkdir2(this.config.dataDir, { recursive: true });
|
|
3140
|
+
let child;
|
|
3141
|
+
try {
|
|
3142
|
+
child = this.spawnFn(this.config.agent.binPath, args, {
|
|
3143
|
+
cwd: this.config.dataDir,
|
|
3144
|
+
// Stdin closed: both CLIs wait on it otherwise.
|
|
3145
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3146
|
+
env: process.env
|
|
3147
|
+
});
|
|
3148
|
+
} catch (err) {
|
|
3149
|
+
await this.finish(run6, turn, { ok: false, error: this.spawnError(err) });
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
const timer = setTimeout(() => void this.stop(timeoutReason), timeoutMs);
|
|
3153
|
+
timer.unref();
|
|
3154
|
+
const running = { run: run6, turn, child, timer, abortReason: null };
|
|
3155
|
+
this.running = running;
|
|
3156
|
+
this.hooks.onChange();
|
|
3157
|
+
let done;
|
|
3158
|
+
const stderr = [];
|
|
3159
|
+
child.stderr?.on("data", (chunk) => {
|
|
3160
|
+
stderr.push(chunk.toString("utf8"));
|
|
3161
|
+
if (stderr.length > 50) stderr.shift();
|
|
3162
|
+
});
|
|
3163
|
+
if (child.stdout) {
|
|
3164
|
+
const lines = createInterface2({ input: child.stdout });
|
|
3165
|
+
lines.on("line", (line) => {
|
|
3166
|
+
const event = adapter.parse(line);
|
|
3167
|
+
if (!event) return;
|
|
3168
|
+
if (event.sessionId && !turn) run6.sessionId = event.sessionId;
|
|
3169
|
+
if (event.text) {
|
|
3170
|
+
if (turn) {
|
|
3171
|
+
turn.reply = turn.reply ? `${turn.reply}
|
|
3172
|
+
|
|
3173
|
+
${event.text}` : event.text;
|
|
3174
|
+
} else {
|
|
3175
|
+
run6.messages.push(event.text);
|
|
3176
|
+
run6.report = event.text;
|
|
3177
|
+
}
|
|
3178
|
+
this.hooks.onChange();
|
|
3179
|
+
}
|
|
3180
|
+
if (event.done) done = event.done;
|
|
3181
|
+
});
|
|
3182
|
+
}
|
|
3183
|
+
child.on("error", (err) => {
|
|
3184
|
+
done = { ok: false, error: this.spawnError(err) };
|
|
3185
|
+
});
|
|
3186
|
+
child.on("close", (code) => {
|
|
3187
|
+
clearTimeout(timer);
|
|
3188
|
+
this.running = null;
|
|
3189
|
+
const outcome = running.abortReason ? { ok: false, error: running.abortReason } : done ?? {
|
|
3190
|
+
ok: false,
|
|
3191
|
+
error: `${cli} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
|
|
3192
|
+
};
|
|
3193
|
+
void this.finish(run6, turn, outcome, running.abortReason !== null);
|
|
3194
|
+
});
|
|
3195
|
+
}
|
|
3196
|
+
/**
|
|
3197
|
+
* Kill whatever is going, run or follow-up. What a run already renamed into
|
|
3198
|
+
* place stays: the brief it replaced is gone either way, and the archive has it.
|
|
3199
|
+
*/
|
|
3200
|
+
async stop(reason = "stopped") {
|
|
3201
|
+
if (!this.running) return false;
|
|
3202
|
+
this.running.abortReason = reason;
|
|
3203
|
+
this.running.child.kill("SIGTERM");
|
|
3204
|
+
return true;
|
|
3205
|
+
}
|
|
3206
|
+
/** Kill the run, for shutdown. The exit handler writes the aborted line. */
|
|
3207
|
+
async close() {
|
|
3208
|
+
await this.stop("the dashboard shut down");
|
|
3209
|
+
}
|
|
3210
|
+
resumable(run6) {
|
|
3211
|
+
return run6.status !== "running" && run6.sessionId !== null && run6.cli === this.config.agent.cli;
|
|
3212
|
+
}
|
|
3213
|
+
async finish(run6, turn, outcome, aborted = false) {
|
|
3214
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
3215
|
+
if (turn) {
|
|
3216
|
+
const reply = outcome.reply ?? turn.reply;
|
|
3217
|
+
turn.reply = reply;
|
|
3218
|
+
turn.endedAt = at;
|
|
3219
|
+
if (outcome.ok) {
|
|
3220
|
+
await this.append({ event: "replied", run: run6.id, turn: turn.id, reply, at });
|
|
3221
|
+
turn.status = "done";
|
|
3222
|
+
} else if (aborted) {
|
|
3223
|
+
turn.error = outcome.error ?? "stopped";
|
|
3224
|
+
await this.append({ event: "turn-aborted", run: run6.id, turn: turn.id, reason: turn.error, reply, at });
|
|
3225
|
+
turn.status = "aborted";
|
|
3226
|
+
} else {
|
|
3227
|
+
turn.error = outcome.error ?? "failed";
|
|
3228
|
+
await this.append({ event: "turn-failed", run: run6.id, turn: turn.id, error: turn.error, reply, at });
|
|
3229
|
+
turn.status = "failed";
|
|
3230
|
+
}
|
|
3231
|
+
this.hooks.onChange();
|
|
3232
|
+
return;
|
|
3233
|
+
}
|
|
3234
|
+
const report = outcome.reply ?? run6.report;
|
|
3235
|
+
run6.report = report;
|
|
3236
|
+
run6.endedAt = at;
|
|
3237
|
+
const messages = run6.messages;
|
|
3238
|
+
if (outcome.ok) {
|
|
3239
|
+
await this.append({ event: "finished", run: run6.id, sessionId: run6.sessionId, report, messages, at });
|
|
3240
|
+
run6.status = "done";
|
|
3241
|
+
} else if (aborted) {
|
|
3242
|
+
run6.error = outcome.error ?? "stopped";
|
|
3243
|
+
await this.append({ event: "aborted", run: run6.id, sessionId: run6.sessionId, reason: run6.error, report, messages, at });
|
|
3244
|
+
run6.status = "aborted";
|
|
3245
|
+
} else {
|
|
3246
|
+
run6.error = outcome.error ?? "failed";
|
|
3247
|
+
await this.append({ event: "failed", run: run6.id, sessionId: run6.sessionId, error: run6.error, report, messages, at });
|
|
3248
|
+
run6.status = "failed";
|
|
3249
|
+
}
|
|
3250
|
+
this.hooks.onChange();
|
|
3251
|
+
}
|
|
3252
|
+
spawnError(err) {
|
|
3253
|
+
const code = err.code;
|
|
3254
|
+
const bin = this.config.agent.binPath;
|
|
3255
|
+
if (code === "ENOENT") return `could not find ${bin}; install it or set DAILY_FOCUS_AGENT_BIN to its path`;
|
|
3256
|
+
return `could not run ${bin}: ${err instanceof Error ? err.message : String(err)}`;
|
|
3257
|
+
}
|
|
3258
|
+
async append(record) {
|
|
3259
|
+
await mkdir2(this.config.dataDir, { recursive: true });
|
|
3260
|
+
await appendFile2(this.config.agentLogFile, `${JSON.stringify(record)}
|
|
3261
|
+
`, "utf8");
|
|
3262
|
+
}
|
|
3263
|
+
};
|
|
3264
|
+
|
|
3265
|
+
// src/watch.ts
|
|
3266
|
+
import { watch } from "node:fs";
|
|
3267
|
+
function watchDataDir(dir, onChange, { debounceMs = 120, ignore = [] } = {}) {
|
|
3268
|
+
let timer = null;
|
|
3269
|
+
const fire = (_event, filename) => {
|
|
3270
|
+
if (filename !== null && ignore.includes(String(filename))) return;
|
|
3271
|
+
if (timer) clearTimeout(timer);
|
|
3272
|
+
timer = setTimeout(() => {
|
|
3273
|
+
timer = null;
|
|
3274
|
+
onChange();
|
|
3275
|
+
}, debounceMs);
|
|
3276
|
+
timer.unref?.();
|
|
3277
|
+
};
|
|
3278
|
+
let watcher = null;
|
|
3279
|
+
try {
|
|
3280
|
+
watcher = watch(dir, { persistent: false }, fire);
|
|
3281
|
+
watcher.on("error", (err) => {
|
|
3282
|
+
console.warn(`[daily-focus] stopped watching ${dir}: ${err.message}`);
|
|
3283
|
+
});
|
|
3284
|
+
} catch (err) {
|
|
3285
|
+
console.warn(`[daily-focus] could not watch ${dir}: ${err.message}`);
|
|
3286
|
+
}
|
|
3287
|
+
return () => {
|
|
3288
|
+
if (timer) clearTimeout(timer);
|
|
3289
|
+
watcher?.close();
|
|
3290
|
+
};
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
// src/assets.ts
|
|
3294
|
+
import { readdir, stat as stat2 } from "node:fs/promises";
|
|
3295
|
+
import { join as join2 } from "node:path";
|
|
3296
|
+
async function computeAssetVersion(dir) {
|
|
3297
|
+
const parts = [];
|
|
3298
|
+
async function walk(current) {
|
|
3299
|
+
let entries;
|
|
3300
|
+
try {
|
|
3301
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
3302
|
+
} catch {
|
|
3303
|
+
return;
|
|
3304
|
+
}
|
|
3305
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
3306
|
+
const path = join2(current, entry.name);
|
|
3307
|
+
if (entry.isDirectory()) {
|
|
3308
|
+
await walk(path);
|
|
3309
|
+
} else {
|
|
3310
|
+
const info = await stat2(path);
|
|
3311
|
+
parts.push(`${entry.name}:${info.size}:${info.mtimeMs}`);
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
await walk(dir);
|
|
3316
|
+
let hash = 0;
|
|
3317
|
+
const joined = parts.join("|");
|
|
3318
|
+
for (let i = 0; i < joined.length; i++) {
|
|
3319
|
+
hash = Math.imul(31, hash) + joined.charCodeAt(i) | 0;
|
|
3320
|
+
}
|
|
3321
|
+
return (hash >>> 0).toString(36);
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
// src/favicon.ts
|
|
3325
|
+
var FAVICONS = {
|
|
3326
|
+
work: `<path d="M34 30V20a8 8 0 0 1 8-8h16a8 8 0 0 1 8 8v10" fill="none" stroke="#2a78d6" stroke-width="9"/><path fill="#2a78d6" fill-rule="evenodd" d="M16 30h68a10 10 0 0 1 10 10v38a10 10 0 0 1-10 10H16A10 10 0 0 1 6 78V40a10 10 0 0 1 10-10ZM40 46v14h20V46Z"/>`,
|
|
3327
|
+
personal: `<path fill="#1baf7a" fill-rule="evenodd" d="M50 8 96 48H84v44H16V48H4ZM41 92V66h18v26Z"/>`
|
|
3328
|
+
};
|
|
3329
|
+
function faviconSvg(profile) {
|
|
3330
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${FAVICONS[profile]}</svg>`;
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
// src/guard.ts
|
|
3334
|
+
import { isIP } from "node:net";
|
|
3335
|
+
var HOST_HEADER = /^(?:\[([0-9a-f:.]+)\]|([^\s:@/[\]]+))(?::\d{1,5})?$/i;
|
|
3336
|
+
function hostnameOf(header) {
|
|
3337
|
+
const match = HOST_HEADER.exec(header);
|
|
3338
|
+
if (!match) return null;
|
|
3339
|
+
return (match[1] ?? match[2]).toLowerCase().replace(/\.$/, "");
|
|
3340
|
+
}
|
|
3341
|
+
function isTrustedHostname(hostname, listenHost) {
|
|
3342
|
+
if (isIP(hostname) !== 0) return true;
|
|
3343
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
|
|
3344
|
+
if (hostname.endsWith(".ts.net")) return true;
|
|
3345
|
+
return hostname === listenHost.toLowerCase().replace(/\.$/, "");
|
|
3346
|
+
}
|
|
3347
|
+
function refusal(req, listenHost) {
|
|
3348
|
+
const hostname = hostnameOf(req.headers.host ?? "");
|
|
3349
|
+
if (hostname === null || !isTrustedHostname(hostname, listenHost)) {
|
|
3350
|
+
return {
|
|
3351
|
+
status: 403,
|
|
3352
|
+
error: `this dashboard answers only to localhost, IP addresses, Tailscale names and DAILY_FOCUS_HOST, and was reached as ${JSON.stringify(req.headers.host ?? "")}`
|
|
3353
|
+
};
|
|
3354
|
+
}
|
|
3355
|
+
if (req.method === "POST") {
|
|
3356
|
+
const type = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
|
|
3357
|
+
if (type !== "application/json") return { status: 415, error: "send this as application/json" };
|
|
3358
|
+
}
|
|
3359
|
+
return null;
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
// src/presence.ts
|
|
3363
|
+
import { execFile as execFile4 } from "node:child_process";
|
|
3364
|
+
import { platform } from "node:os";
|
|
3365
|
+
import { promisify as promisify4 } from "node:util";
|
|
3366
|
+
var run4 = promisify4(execFile4);
|
|
3367
|
+
var PROBE_TIMEOUT_MS = 4e3;
|
|
3368
|
+
var HID_IDLE = /"HIDIdleTime"\s*=\s*(\d+)/;
|
|
3369
|
+
var readIdleSeconds = async () => {
|
|
3370
|
+
if (platform() !== "darwin") return null;
|
|
3371
|
+
try {
|
|
3372
|
+
const { stdout } = await run4("ioreg", ["-c", "IOHIDSystem", "-d", "1", "-r"], {
|
|
3373
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
3374
|
+
maxBuffer: 1024 * 1024
|
|
3375
|
+
});
|
|
3376
|
+
return parseIdleSeconds(stdout);
|
|
3377
|
+
} catch {
|
|
3378
|
+
return null;
|
|
3379
|
+
}
|
|
3380
|
+
};
|
|
3381
|
+
function parseIdleSeconds(ioregOutput) {
|
|
3382
|
+
const match = HID_IDLE.exec(ioregOutput);
|
|
3383
|
+
if (!match) return null;
|
|
3384
|
+
const nanoseconds = Number(match[1]);
|
|
3385
|
+
if (!Number.isFinite(nanoseconds) || nanoseconds < 0) return null;
|
|
3386
|
+
return nanoseconds / 1e9;
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
// src/settings.ts
|
|
3390
|
+
var SETTING_GROUPS = [
|
|
3391
|
+
{ id: "instance", title: "This dashboard" },
|
|
3392
|
+
{ id: "agent", title: "Morning agent" },
|
|
3393
|
+
{ id: "assistant", title: "Assistant" },
|
|
3394
|
+
{ id: "github", title: "Pull requests" },
|
|
3395
|
+
{ id: "jira", title: "Jira tickets" },
|
|
3396
|
+
{ id: "calendar", title: "Calendar" },
|
|
3397
|
+
{ id: "day", title: "Working day" },
|
|
3398
|
+
{ id: "sessions", title: "Focus sessions" }
|
|
3399
|
+
];
|
|
3400
|
+
var OFF_OR_CLI = [
|
|
3401
|
+
{ value: "off", label: "Off" },
|
|
3402
|
+
{ value: "claude", label: "Claude Code" },
|
|
3403
|
+
{ value: "codex", label: "Codex" }
|
|
3404
|
+
];
|
|
3405
|
+
var EFFORTS = ["low", "medium", "high", "xhigh", "max"].map((value) => ({ value, label: value }));
|
|
3406
|
+
var always = (text) => () => text;
|
|
3407
|
+
var SETTINGS = [
|
|
3408
|
+
{
|
|
3409
|
+
key: "DAILY_FOCUS_PROFILE",
|
|
3410
|
+
group: "instance",
|
|
3411
|
+
label: "What this dashboard briefs",
|
|
3412
|
+
help: "Work or home. Picks the morning prompt and the defaults below: the personal profile switches off Jira, free time and away detection.",
|
|
3413
|
+
kind: "choice",
|
|
3414
|
+
choices: PROFILES.map((value) => ({ value, label: value === "work" ? "Work" : "Personal life" })),
|
|
3415
|
+
fallback: always("Work")
|
|
3416
|
+
},
|
|
3417
|
+
{
|
|
3418
|
+
key: "DAILY_FOCUS_AGENT",
|
|
3419
|
+
group: "agent",
|
|
3420
|
+
label: "Write the brief with",
|
|
3421
|
+
help: "The coding-agent CLI the dashboard runs each morning to write your brief. It has to be installed and logged in.",
|
|
3422
|
+
kind: "choice",
|
|
3423
|
+
choices: OFF_OR_CLI,
|
|
3424
|
+
fallback: always("Off")
|
|
3425
|
+
},
|
|
3426
|
+
{
|
|
3427
|
+
key: "DAILY_FOCUS_AGENT_AT",
|
|
3428
|
+
group: "agent",
|
|
3429
|
+
label: "Run it at",
|
|
3430
|
+
help: "Local time, such as 06:30, or off to run it only when you press refresh. If the dashboard was off at that time, it runs when it next starts that day.",
|
|
3431
|
+
kind: "time",
|
|
3432
|
+
fallback: always("07:00")
|
|
3433
|
+
},
|
|
3434
|
+
{
|
|
3435
|
+
key: "DAILY_FOCUS_AGENT_DAYS",
|
|
3436
|
+
group: "agent",
|
|
3437
|
+
label: "On these days",
|
|
3438
|
+
help: "Cron-style weekday numbers: 1-5 is Monday to Friday, 0-4 is Sunday to Thursday, 0,6 is the weekend.",
|
|
3439
|
+
kind: "text",
|
|
3440
|
+
fallback: always("1-5, Monday to Friday")
|
|
3441
|
+
},
|
|
3442
|
+
{
|
|
3443
|
+
key: "DAILY_FOCUS_AGENT_MODEL",
|
|
3444
|
+
group: "agent",
|
|
3445
|
+
label: "Model",
|
|
3446
|
+
help: "Passed to the CLI as it is, such as opus. Empty uses the CLI's own default.",
|
|
3447
|
+
kind: "text",
|
|
3448
|
+
fallback: always("The CLI's own")
|
|
3449
|
+
},
|
|
3450
|
+
{
|
|
3451
|
+
key: "DAILY_FOCUS_AGENT_EFFORT",
|
|
3452
|
+
group: "agent",
|
|
3453
|
+
label: "Effort",
|
|
3454
|
+
help: "A brief is worth a high effort.",
|
|
3455
|
+
kind: "choice",
|
|
3456
|
+
choices: EFFORTS,
|
|
3457
|
+
fallback: always("The CLI's own")
|
|
3458
|
+
},
|
|
3459
|
+
{
|
|
3460
|
+
key: "DAILY_FOCUS_AGENT_TOOLS",
|
|
3461
|
+
group: "agent",
|
|
3462
|
+
label: "Tools Claude Code may use",
|
|
3463
|
+
help: "In Claude Code's permission syntax, comma-separated. Add your calendar, chat and document connectors by name, or the brief reports them as unreachable. Ignored by Codex.",
|
|
3464
|
+
kind: "list",
|
|
3465
|
+
fallback: always("Bash(gh *), Bash(acli *), WebFetch, mcp__claude_ai_Gmail"),
|
|
3466
|
+
advanced: true
|
|
3467
|
+
},
|
|
3468
|
+
{
|
|
3469
|
+
key: "DAILY_FOCUS_AGENT_BIN",
|
|
3470
|
+
group: "agent",
|
|
3471
|
+
label: "Path to the CLI",
|
|
3472
|
+
help: "Only needed when the dashboard can't find the CLI on its own.",
|
|
3473
|
+
kind: "text",
|
|
3474
|
+
fallback: always("Found on PATH"),
|
|
3475
|
+
advanced: true
|
|
3476
|
+
},
|
|
3477
|
+
{
|
|
3478
|
+
key: "DAILY_FOCUS_STALE_AFTER_HOURS",
|
|
3479
|
+
group: "agent",
|
|
3480
|
+
label: "Expect a new brief every",
|
|
3481
|
+
help: "Hours, counted only on the days above. After that, and 45 minutes more, the page says the agent may not have run.",
|
|
3482
|
+
kind: "number",
|
|
3483
|
+
fallback: always("24 hours"),
|
|
3484
|
+
advanced: true
|
|
3485
|
+
},
|
|
3486
|
+
{
|
|
3487
|
+
key: "DAILY_FOCUS_ASSISTANT",
|
|
3488
|
+
group: "assistant",
|
|
3489
|
+
label: "Answer questions with",
|
|
3490
|
+
help: "Puts an assistant in every item's panel. It reads and drafts, and never sends, posts or edits.",
|
|
3491
|
+
kind: "choice",
|
|
3492
|
+
choices: OFF_OR_CLI,
|
|
3493
|
+
fallback: always("Off")
|
|
3494
|
+
},
|
|
3495
|
+
{
|
|
3496
|
+
key: "DAILY_FOCUS_ASSISTANT_MODEL",
|
|
3497
|
+
group: "assistant",
|
|
3498
|
+
label: "Model",
|
|
3499
|
+
help: "Passed to the CLI as it is. Empty uses the CLI's own default.",
|
|
3500
|
+
kind: "text",
|
|
3501
|
+
fallback: always("The CLI's own")
|
|
3502
|
+
},
|
|
3503
|
+
{
|
|
3504
|
+
key: "DAILY_FOCUS_ASSISTANT_EFFORT",
|
|
3505
|
+
group: "assistant",
|
|
3506
|
+
label: "Effort",
|
|
3507
|
+
help: "Passed to the CLI as it is.",
|
|
3508
|
+
kind: "choice",
|
|
3509
|
+
choices: EFFORTS,
|
|
3510
|
+
fallback: always("The CLI's own")
|
|
3511
|
+
},
|
|
3512
|
+
{
|
|
3513
|
+
key: "DAILY_FOCUS_ASSISTANT_TOOLS",
|
|
3514
|
+
group: "assistant",
|
|
3515
|
+
label: "Tools Claude Code may use",
|
|
3516
|
+
help: "In Claude Code's permission syntax, comma-separated. Editing tools are denied whatever this says. Ignored by Codex.",
|
|
3517
|
+
kind: "list",
|
|
3518
|
+
fallback: always("Bash(gh *), Bash(acli *), WebFetch, mcp__claude_ai_Gmail"),
|
|
3519
|
+
advanced: true
|
|
3520
|
+
},
|
|
3521
|
+
{
|
|
3522
|
+
key: "DAILY_FOCUS_ASSISTANT_BIN",
|
|
3523
|
+
group: "assistant",
|
|
3524
|
+
label: "Path to the CLI",
|
|
3525
|
+
help: "Only needed when the dashboard can't find the CLI on its own.",
|
|
3526
|
+
kind: "text",
|
|
3527
|
+
fallback: always("Found on PATH"),
|
|
3528
|
+
advanced: true
|
|
3529
|
+
},
|
|
3530
|
+
{
|
|
3531
|
+
key: "DAILY_FOCUS_GITHUB",
|
|
3532
|
+
group: "github",
|
|
3533
|
+
label: "Pull request board",
|
|
3534
|
+
help: "Reads your open pull requests through the GitHub CLI. Sign in with gh auth login.",
|
|
3535
|
+
kind: "flag",
|
|
3536
|
+
fallback: always("On")
|
|
3537
|
+
},
|
|
3538
|
+
{
|
|
3539
|
+
key: "DAILY_FOCUS_GITHUB_ACCOUNTS",
|
|
3540
|
+
group: "github",
|
|
3541
|
+
label: "Accounts",
|
|
3542
|
+
help: "GitHub logins to read as, comma-separated, when gh holds more than one. Empty uses the active one.",
|
|
3543
|
+
kind: "list",
|
|
3544
|
+
fallback: always("gh's active account")
|
|
3545
|
+
},
|
|
3546
|
+
{
|
|
3547
|
+
key: "DAILY_FOCUS_GITHUB_SCOPE",
|
|
3548
|
+
group: "github",
|
|
3549
|
+
label: "Only these organisations or repositories",
|
|
3550
|
+
help: "Comma-separated: acme, or acme/webapp for one repository. Empty counts every pull request you have open.",
|
|
3551
|
+
kind: "list",
|
|
3552
|
+
fallback: always("Everything")
|
|
3553
|
+
},
|
|
3554
|
+
{
|
|
3555
|
+
key: "DAILY_FOCUS_GITHUB_MERGE_GATE_CHECKS",
|
|
3556
|
+
group: "github",
|
|
3557
|
+
label: "Merge gate checks",
|
|
3558
|
+
help: "Exact names of checks that stand for a repository's whole merge policy, comma-separated. Pending ones get their own bucket.",
|
|
3559
|
+
kind: "list",
|
|
3560
|
+
fallback: always("None"),
|
|
3561
|
+
advanced: true
|
|
3562
|
+
},
|
|
3563
|
+
{
|
|
3564
|
+
key: "DAILY_FOCUS_GITHUB_POLL_MINUTES",
|
|
3565
|
+
group: "github",
|
|
3566
|
+
label: "Read every",
|
|
3567
|
+
help: "Minutes between reads while a tab is open.",
|
|
3568
|
+
kind: "number",
|
|
3569
|
+
fallback: always("5 minutes"),
|
|
3570
|
+
advanced: true
|
|
3571
|
+
},
|
|
3572
|
+
{
|
|
3573
|
+
key: "DAILY_FOCUS_GH",
|
|
3574
|
+
group: "github",
|
|
3575
|
+
label: "Path to gh",
|
|
3576
|
+
help: "Only needed when the dashboard can't find the GitHub CLI on its own.",
|
|
3577
|
+
kind: "text",
|
|
3578
|
+
fallback: always("Found on PATH"),
|
|
3579
|
+
advanced: true
|
|
3580
|
+
},
|
|
3581
|
+
{
|
|
3582
|
+
key: "DAILY_FOCUS_JIRA",
|
|
3583
|
+
group: "jira",
|
|
3584
|
+
label: "Jira ticket board",
|
|
3585
|
+
help: "Flags tickets whose status disagrees with their pull requests, through the Atlassian CLI. Sign in with acli jira auth login.",
|
|
3586
|
+
kind: "flag",
|
|
3587
|
+
fallback: (profile) => profile === "personal" ? "Off" : "On"
|
|
3588
|
+
},
|
|
3589
|
+
{
|
|
3590
|
+
key: "DAILY_FOCUS_JIRA_PROJECTS",
|
|
3591
|
+
group: "jira",
|
|
3592
|
+
label: "Only these projects",
|
|
3593
|
+
help: "Project keys, comma-separated. Empty means every project you can see.",
|
|
3594
|
+
kind: "list",
|
|
3595
|
+
fallback: always("Every project")
|
|
3596
|
+
},
|
|
3597
|
+
{
|
|
3598
|
+
key: "DAILY_FOCUS_JIRA_HOLD_STATUSES",
|
|
3599
|
+
group: "jira",
|
|
3600
|
+
label: "Statuses that mean on hold",
|
|
3601
|
+
help: "Spelled as your Jira spells them, comma-separated, such as Blocked, On Hold. Tickets in them are not flagged for having no code linked.",
|
|
3602
|
+
kind: "list",
|
|
3603
|
+
fallback: always("None")
|
|
3604
|
+
},
|
|
3605
|
+
{
|
|
3606
|
+
key: "DAILY_FOCUS_JIRA_IN_PROGRESS_STATUSES",
|
|
3607
|
+
group: "jira",
|
|
3608
|
+
label: "Statuses that mean working on it",
|
|
3609
|
+
help: "What the Working on view shows, comma-separated. Use In Progress to leave out In Review.",
|
|
3610
|
+
kind: "list",
|
|
3611
|
+
fallback: always("Everything Jira calls in progress")
|
|
3612
|
+
},
|
|
3613
|
+
{
|
|
3614
|
+
key: "DAILY_FOCUS_JIRA_SITE",
|
|
3615
|
+
group: "jira",
|
|
3616
|
+
label: "Atlassian site",
|
|
3617
|
+
help: "The host links are built from, such as acme.atlassian.net. Empty uses the one acli signed in to.",
|
|
3618
|
+
kind: "text",
|
|
3619
|
+
fallback: always("acli's own"),
|
|
3620
|
+
advanced: true
|
|
3621
|
+
},
|
|
3622
|
+
{
|
|
3623
|
+
key: "DAILY_FOCUS_JIRA_POLL_MINUTES",
|
|
3624
|
+
group: "jira",
|
|
3625
|
+
label: "Read every",
|
|
3626
|
+
help: "Minutes between reads while a tab is open.",
|
|
3627
|
+
kind: "number",
|
|
3628
|
+
fallback: always("15 minutes"),
|
|
3629
|
+
advanced: true
|
|
3630
|
+
},
|
|
3631
|
+
{
|
|
3632
|
+
key: "DAILY_FOCUS_ACLI",
|
|
3633
|
+
group: "jira",
|
|
3634
|
+
label: "Path to acli",
|
|
3635
|
+
help: "Only needed when the dashboard can't find the Atlassian CLI on its own.",
|
|
3636
|
+
kind: "text",
|
|
3637
|
+
fallback: always("Found on PATH"),
|
|
3638
|
+
advanced: true
|
|
3639
|
+
},
|
|
3640
|
+
{
|
|
3641
|
+
key: "DAILY_FOCUS_CALENDAR",
|
|
3642
|
+
group: "calendar",
|
|
3643
|
+
label: "Live agenda",
|
|
3644
|
+
help: "Reads today's events from Calendar.app on a Mac, so a meeting declined at eleven frees the slot. Off uses the events in the brief.",
|
|
3645
|
+
kind: "flag",
|
|
3646
|
+
fallback: always("On")
|
|
3647
|
+
},
|
|
3648
|
+
{
|
|
3649
|
+
key: "DAILY_FOCUS_CALENDARS",
|
|
3650
|
+
group: "calendar",
|
|
3651
|
+
label: "Calendars",
|
|
3652
|
+
help: "Names as Calendar.app shows them, comma-separated. Empty reads none, and the agenda uses the brief.",
|
|
3653
|
+
kind: "list",
|
|
3654
|
+
fallback: always("None")
|
|
3655
|
+
},
|
|
3656
|
+
{
|
|
3657
|
+
key: "DAILY_FOCUS_CALENDAR_ADDRESSES",
|
|
3658
|
+
group: "calendar",
|
|
3659
|
+
label: "Your email addresses",
|
|
3660
|
+
help: "Comma-separated. Used only to find your reply on an invitation, so a meeting you declined stops blocking your time.",
|
|
3661
|
+
kind: "list",
|
|
3662
|
+
fallback: always("None")
|
|
3663
|
+
},
|
|
3664
|
+
{
|
|
3665
|
+
key: "DAILY_FOCUS_CALENDAR_POLL_MINUTES",
|
|
3666
|
+
group: "calendar",
|
|
3667
|
+
label: "Read every",
|
|
3668
|
+
help: "Minutes between reads while a tab is open.",
|
|
3669
|
+
kind: "number",
|
|
3670
|
+
fallback: always("5 minutes"),
|
|
3671
|
+
advanced: true
|
|
3672
|
+
},
|
|
3673
|
+
{
|
|
3674
|
+
key: "DAILY_FOCUS_CALENDAR_APP",
|
|
3675
|
+
group: "calendar",
|
|
3676
|
+
label: "Path to the calendar helper",
|
|
3677
|
+
help: "Only needed when the helper is somewhere other than where it was built.",
|
|
3678
|
+
kind: "text",
|
|
3679
|
+
fallback: always("The built copy"),
|
|
3680
|
+
advanced: true
|
|
3681
|
+
},
|
|
3682
|
+
{
|
|
3683
|
+
key: "DAILY_FOCUS_FREE_WINDOWS",
|
|
3684
|
+
group: "day",
|
|
3685
|
+
label: "Show free time",
|
|
3686
|
+
help: "Find focus windows between meetings in your working hours. Off shows events only.",
|
|
3687
|
+
kind: "flag",
|
|
3688
|
+
fallback: (profile) => profile === "personal" ? "Off" : "On"
|
|
3689
|
+
},
|
|
3690
|
+
{
|
|
3691
|
+
key: "DAILY_FOCUS_WORK_START",
|
|
3692
|
+
group: "day",
|
|
3693
|
+
label: "Working day starts at",
|
|
3694
|
+
help: "Hour of the day, 0 to 23, when the brief doesn't say.",
|
|
3695
|
+
kind: "number",
|
|
3696
|
+
fallback: always("9")
|
|
3697
|
+
},
|
|
3698
|
+
{
|
|
3699
|
+
key: "DAILY_FOCUS_WORK_END",
|
|
3700
|
+
group: "day",
|
|
3701
|
+
label: "Working day ends at",
|
|
3702
|
+
help: "Hour of the day, 0 to 23, when the brief doesn't say.",
|
|
3703
|
+
kind: "number",
|
|
3704
|
+
fallback: always("17")
|
|
3705
|
+
},
|
|
3706
|
+
{
|
|
3707
|
+
key: "DAILY_FOCUS_MIN_FREE_WINDOW",
|
|
3708
|
+
group: "day",
|
|
3709
|
+
label: "Shortest focus window",
|
|
3710
|
+
help: "Minutes a gap has to last to count as time for focused work.",
|
|
3711
|
+
kind: "number",
|
|
3712
|
+
fallback: always("45 minutes")
|
|
3713
|
+
},
|
|
3714
|
+
{
|
|
3715
|
+
key: "DAILY_FOCUS_SESSION_MINUTES",
|
|
3716
|
+
group: "sessions",
|
|
3717
|
+
label: "Session length",
|
|
3718
|
+
help: "Minutes a focus session is set for when you start one.",
|
|
3719
|
+
kind: "number",
|
|
3720
|
+
fallback: always("25 minutes")
|
|
3721
|
+
},
|
|
3722
|
+
{
|
|
3723
|
+
key: "DAILY_FOCUS_AWAY_AFTER",
|
|
3724
|
+
group: "sessions",
|
|
3725
|
+
label: "End a session when away for",
|
|
3726
|
+
help: "Minutes without touching the Mac before a session is closed where you left. 0 turns it off.",
|
|
3727
|
+
kind: "number",
|
|
3728
|
+
fallback: (profile) => profile === "personal" ? "0, off" : "10 minutes"
|
|
3729
|
+
}
|
|
3730
|
+
];
|
|
3731
|
+
var BY_KEY = new Map(SETTINGS.map((def) => [def.key, def]));
|
|
3732
|
+
function present(record, key) {
|
|
3733
|
+
const value = record[key];
|
|
3734
|
+
return value === void 0 || value.trim() === "" ? null : value.trim();
|
|
3735
|
+
}
|
|
3736
|
+
function profileOf(sources, settings = sources.settings) {
|
|
3737
|
+
const raw = (present(layerEnv(sources, settings), "DAILY_FOCUS_PROFILE") ?? "work").toLowerCase();
|
|
3738
|
+
return PROFILES.includes(raw) ? raw : "work";
|
|
3739
|
+
}
|
|
3740
|
+
function describeSettings(sources) {
|
|
3741
|
+
const profile = profileOf(sources);
|
|
3742
|
+
const settings = SETTINGS.map((def) => {
|
|
3743
|
+
const fromEnv = present(sources.process, def.key);
|
|
3744
|
+
const fromSettings = present(sources.settings, def.key);
|
|
3745
|
+
const fromDotenv = present(sources.dotenv, def.key);
|
|
3746
|
+
const [value, source] = fromEnv !== null ? [fromEnv, "environment"] : fromSettings !== null ? [fromSettings, "settings"] : fromDotenv !== null ? [fromDotenv, "dotenv"] : [null, "default"];
|
|
3747
|
+
return {
|
|
3748
|
+
key: def.key,
|
|
3749
|
+
group: def.group,
|
|
3750
|
+
label: def.label,
|
|
3751
|
+
help: def.help,
|
|
3752
|
+
kind: def.kind,
|
|
3753
|
+
choices: def.choices ?? null,
|
|
3754
|
+
advanced: def.advanced === true,
|
|
3755
|
+
value,
|
|
3756
|
+
source,
|
|
3757
|
+
fallback: def.fallback(profile)
|
|
3758
|
+
};
|
|
3759
|
+
});
|
|
3760
|
+
return { groups: SETTING_GROUPS, settings, error: sources.settingsError };
|
|
3761
|
+
}
|
|
3762
|
+
function applySettingsChange(sources, change) {
|
|
3763
|
+
const next = { ...sources.settings };
|
|
3764
|
+
for (const [key, raw] of Object.entries(change)) {
|
|
3765
|
+
if (LAUNCH_SETTINGS.includes(key)) {
|
|
3766
|
+
return { error: `${key} says where the store is or how the server listens, so it is set in the environment, not here` };
|
|
3767
|
+
}
|
|
3768
|
+
if (!BY_KEY.has(key)) return { error: `${key} is not a setting` };
|
|
3769
|
+
if (raw !== null && typeof raw !== "string") return { error: `${key} must be text, or null for the default` };
|
|
3770
|
+
if (present(sources.process, key) !== null) {
|
|
3771
|
+
return { error: `${key} is set in the environment the dashboard was started with, which wins over this page` };
|
|
3772
|
+
}
|
|
3773
|
+
const value = raw?.trim() ?? "";
|
|
3774
|
+
if (value === "") delete next[key];
|
|
3775
|
+
else next[key] = value;
|
|
3776
|
+
}
|
|
3777
|
+
try {
|
|
3778
|
+
loadConfig(layerEnv(sources, next));
|
|
3779
|
+
} catch (error) {
|
|
3780
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
3781
|
+
}
|
|
3782
|
+
return { settings: next };
|
|
3783
|
+
}
|
|
3784
|
+
async function saveSettings(path, settings) {
|
|
3785
|
+
const sorted = {};
|
|
3786
|
+
for (const key of Object.keys(settings).sort()) sorted[key] = settings[key];
|
|
3787
|
+
await writeJsonAtomic(path, sorted);
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
// src/setup.ts
|
|
3791
|
+
import { execFile as execFile5 } from "node:child_process";
|
|
3792
|
+
import { access, constants, readdir as readdir2, readFile as readFile8, stat as stat3 } from "node:fs/promises";
|
|
3793
|
+
import { delimiter, isAbsolute, join as join3 } from "node:path";
|
|
3794
|
+
import { promisify as promisify5 } from "node:util";
|
|
3795
|
+
var run5 = promisify5(execFile5);
|
|
3796
|
+
var PLACEHOLDERS = [
|
|
3797
|
+
"Your Name",
|
|
3798
|
+
"you@example.com",
|
|
3799
|
+
"you@personal.example",
|
|
3800
|
+
"your-github-login",
|
|
3801
|
+
"Some Shared Calendar",
|
|
3802
|
+
"Some Noisy Calendar",
|
|
3803
|
+
"<where your colleagues are>",
|
|
3804
|
+
"<where you are>",
|
|
3805
|
+
"<where you live>",
|
|
3806
|
+
"<document id>"
|
|
3807
|
+
];
|
|
3808
|
+
function placeholdersIn(text) {
|
|
3809
|
+
return PLACEHOLDERS.filter((placeholder) => text.includes(placeholder)).length;
|
|
3810
|
+
}
|
|
3811
|
+
async function findExecutable(name, path = process.env.PATH ?? "") {
|
|
3812
|
+
const candidates = isAbsolute(name) ? [name] : path.split(delimiter).filter(Boolean).map((dir) => join3(dir, name));
|
|
3813
|
+
for (const candidate of candidates) {
|
|
3814
|
+
try {
|
|
3815
|
+
await access(candidate, constants.X_OK);
|
|
3816
|
+
if ((await stat3(candidate)).isFile()) return candidate;
|
|
3817
|
+
} catch {
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
return null;
|
|
3821
|
+
}
|
|
3822
|
+
async function findClis(config) {
|
|
3823
|
+
const found = {};
|
|
3824
|
+
for (const cli of CLI_NAMES) {
|
|
3825
|
+
const candidates = [
|
|
3826
|
+
config.agent.cli === cli ? config.agent.binPath : null,
|
|
3827
|
+
config.assistant.agent === cli ? config.assistant.binPath : null,
|
|
3828
|
+
cli
|
|
3829
|
+
].filter((candidate) => candidate !== null);
|
|
3830
|
+
found[cli] = null;
|
|
3831
|
+
for (const candidate of candidates) {
|
|
3832
|
+
found[cli] = await findExecutable(candidate);
|
|
3833
|
+
if (found[cli]) break;
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
return found;
|
|
3837
|
+
}
|
|
3838
|
+
async function hasArchive(config) {
|
|
3839
|
+
try {
|
|
3840
|
+
return (await readdir2(config.archiveDir)).some((name) => name.endsWith(".json"));
|
|
3841
|
+
} catch {
|
|
3842
|
+
return false;
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
async function exists(path) {
|
|
3846
|
+
try {
|
|
3847
|
+
await stat3(path);
|
|
3848
|
+
return true;
|
|
3849
|
+
} catch {
|
|
3850
|
+
return false;
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
async function firstBriefPending(config) {
|
|
3854
|
+
return !await exists(config.itemsFile) && !await hasArchive(config);
|
|
3855
|
+
}
|
|
3856
|
+
async function setupState(config, opts) {
|
|
3857
|
+
let sources = null;
|
|
3858
|
+
try {
|
|
3859
|
+
sources = await readFile8(config.sourcesFile, "utf8");
|
|
3860
|
+
} catch {
|
|
3861
|
+
sources = null;
|
|
3862
|
+
}
|
|
3863
|
+
const supported = process.platform === "darwin";
|
|
3864
|
+
return {
|
|
3865
|
+
// The file, not whether it parsed: a brief that is there but broken is a
|
|
3866
|
+
// problem for its banner to report, not a store to set up from scratch. The
|
|
3867
|
+
// agent's clock asks the same question.
|
|
3868
|
+
needed: await firstBriefPending(config),
|
|
3869
|
+
profile: config.profile,
|
|
3870
|
+
profileChosen: opts.profileChosen,
|
|
3871
|
+
sources: { exists: sources !== null, placeholders: sources === null ? 0 : placeholdersIn(sources) },
|
|
3872
|
+
clis: await findClis(config),
|
|
3873
|
+
calendar: {
|
|
3874
|
+
supported,
|
|
3875
|
+
built: supported && await exists(config.calendar.appPath),
|
|
3876
|
+
chosen: config.calendar.names.length,
|
|
3877
|
+
buildCommand: command("build-calendar")
|
|
3878
|
+
}
|
|
3879
|
+
};
|
|
3880
|
+
}
|
|
3881
|
+
var identity = null;
|
|
3882
|
+
function detectIdentity(config) {
|
|
3883
|
+
identity ??= (async () => {
|
|
3884
|
+
const [user, email] = await Promise.all([
|
|
3885
|
+
run5(config.github.ghPath, ["api", "user", "--jq", '[.login, .name // ""] | join("\\n")'], { timeout: 5e3 }).then(
|
|
3886
|
+
({ stdout }) => stdout.split("\n"),
|
|
3887
|
+
() => []
|
|
3888
|
+
),
|
|
3889
|
+
run5("git", ["config", "--global", "user.email"], { timeout: 2e3 }).then(
|
|
3890
|
+
({ stdout }) => stdout.trim() || null,
|
|
3891
|
+
() => null
|
|
3892
|
+
)
|
|
3893
|
+
]);
|
|
3894
|
+
return {
|
|
3895
|
+
login: config.github.accounts[0] ?? (user[0]?.trim() || null),
|
|
3896
|
+
name: user[1]?.trim() || null,
|
|
3897
|
+
email
|
|
3898
|
+
};
|
|
3899
|
+
})();
|
|
3900
|
+
return identity;
|
|
3901
|
+
}
|
|
3902
|
+
function fillIdentity(template, who) {
|
|
3903
|
+
let text = template;
|
|
3904
|
+
if (who.name) text = text.replaceAll("`Your Name`", `\`${who.name}\``);
|
|
3905
|
+
if (who.email) text = text.replaceAll("`you@example.com`", `\`${who.email}\``).replaceAll("`you@personal.example`", `\`${who.email}\``);
|
|
3906
|
+
if (who.login) text = text.replaceAll("`your-github-login`", `\`${who.login}\``);
|
|
3907
|
+
return text;
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
// src/shellpath.ts
|
|
3911
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
3912
|
+
import { userInfo } from "node:os";
|
|
3913
|
+
var START = "__DAILY_FOCUS_PATH_START__";
|
|
3914
|
+
var END = "__DAILY_FOCUS_PATH_END__";
|
|
3915
|
+
var SHELL_TIMEOUT_MS = 5e3;
|
|
3916
|
+
var defaultExec = (file, args, { timeout }) => new Promise((resolve2, reject) => {
|
|
3917
|
+
const child = spawn3(file, args, { detached: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
3918
|
+
let output = "";
|
|
3919
|
+
let settled = false;
|
|
3920
|
+
const finish = (error) => {
|
|
3921
|
+
if (settled) return;
|
|
3922
|
+
settled = true;
|
|
3923
|
+
clearTimeout(timer);
|
|
3924
|
+
if (error) reject(error);
|
|
3925
|
+
else resolve2(output);
|
|
3926
|
+
};
|
|
3927
|
+
const timer = setTimeout(() => {
|
|
3928
|
+
try {
|
|
3929
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
3930
|
+
} catch {
|
|
3931
|
+
}
|
|
3932
|
+
finish(new Error(`${file} took longer than ${timeout} ms`));
|
|
3933
|
+
}, timeout);
|
|
3934
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => output += chunk);
|
|
3935
|
+
child.on("error", (error) => finish(error));
|
|
3936
|
+
child.on("close", () => finish(null));
|
|
3937
|
+
});
|
|
3938
|
+
function shellCandidates(envShell, loginShell, platform2) {
|
|
3939
|
+
const fallback = platform2 === "darwin" ? "/bin/zsh" : platform2 === "linux" ? "/bin/bash" : void 0;
|
|
3940
|
+
const shells = [];
|
|
3941
|
+
for (const shell of [envShell, loginShell, fallback]) {
|
|
3942
|
+
const trimmed = shell?.trim();
|
|
3943
|
+
if (trimmed && !shells.includes(trimmed)) shells.push(trimmed);
|
|
3944
|
+
}
|
|
3945
|
+
return shells;
|
|
3946
|
+
}
|
|
3947
|
+
function pathBetweenMarkers(output) {
|
|
3948
|
+
const start = output.indexOf(START);
|
|
3949
|
+
if (start === -1) return null;
|
|
3950
|
+
const from = start + START.length;
|
|
3951
|
+
const end = output.indexOf(END, from);
|
|
3952
|
+
if (end === -1) return null;
|
|
3953
|
+
const value = output.slice(from, end).trim();
|
|
3954
|
+
return value === "" ? null : value;
|
|
3955
|
+
}
|
|
3956
|
+
function mergePaths(preferred, inherited) {
|
|
3957
|
+
const entries = [];
|
|
3958
|
+
for (const value of [preferred, inherited]) {
|
|
3959
|
+
for (const entry of (value ?? "").split(":")) {
|
|
3960
|
+
if (entry !== "" && !entries.includes(entry)) entries.push(entry);
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3963
|
+
return entries.join(":");
|
|
3964
|
+
}
|
|
3965
|
+
async function readLoginShellPath(shell, exec) {
|
|
3966
|
+
const command3 = `printf '%s\\n' '${START}'; printenv PATH || true; printf '%s\\n' '${END}'`;
|
|
3967
|
+
return pathBetweenMarkers(await exec(shell, ["-ilc", command3], { timeout: SHELL_TIMEOUT_MS }));
|
|
3968
|
+
}
|
|
3969
|
+
async function readLaunchctlPath(exec) {
|
|
3970
|
+
try {
|
|
3971
|
+
const value = (await exec("/bin/launchctl", ["getenv", "PATH"], { timeout: 2e3 })).trim();
|
|
3972
|
+
return value === "" ? null : value;
|
|
3973
|
+
} catch {
|
|
3974
|
+
return null;
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
function currentLoginShell() {
|
|
3978
|
+
try {
|
|
3979
|
+
return userInfo().shell ?? void 0;
|
|
3980
|
+
} catch {
|
|
3981
|
+
return void 0;
|
|
3982
|
+
}
|
|
3983
|
+
}
|
|
3984
|
+
async function hydratePath(env = process.env, platform2 = process.platform, exec = defaultExec, loginShell = currentLoginShell()) {
|
|
3985
|
+
if (platform2 === "win32") return null;
|
|
3986
|
+
let found = null;
|
|
3987
|
+
let from = null;
|
|
3988
|
+
for (const shell of shellCandidates(env.SHELL, loginShell, platform2)) {
|
|
3989
|
+
try {
|
|
3990
|
+
found = await readLoginShellPath(shell, exec);
|
|
3991
|
+
} catch {
|
|
3992
|
+
}
|
|
3993
|
+
if (found) {
|
|
3994
|
+
from = shell;
|
|
3995
|
+
break;
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
if (!found && platform2 === "darwin") {
|
|
3999
|
+
found = await readLaunchctlPath(exec);
|
|
4000
|
+
if (found) from = "launchctl";
|
|
4001
|
+
}
|
|
4002
|
+
const merged = mergePaths(found, env.PATH);
|
|
4003
|
+
if (merged !== "") env.PATH = merged;
|
|
4004
|
+
return from;
|
|
4005
|
+
}
|
|
4006
|
+
|
|
4007
|
+
// src/server.ts
|
|
4008
|
+
var PUBLIC_DIR = resolve(fileURLToPath(new URL("../public", import.meta.url)));
|
|
4009
|
+
var MIME = {
|
|
4010
|
+
".html": "text/html; charset=utf-8",
|
|
4011
|
+
".js": "text/javascript; charset=utf-8",
|
|
4012
|
+
".css": "text/css; charset=utf-8",
|
|
4013
|
+
".json": "application/json; charset=utf-8",
|
|
4014
|
+
".map": "application/json; charset=utf-8",
|
|
4015
|
+
".svg": "image/svg+xml",
|
|
4016
|
+
".ico": "image/x-icon",
|
|
4017
|
+
".woff2": "font/woff2"
|
|
4018
|
+
};
|
|
4019
|
+
var TEXT_FILES = {
|
|
4020
|
+
"/api/text/focus": { file: "focusFile", name: "focus.md" },
|
|
4021
|
+
"/api/text/sources": { file: "sourcesFile", name: "sources.md" }
|
|
4022
|
+
};
|
|
4023
|
+
var VALID_ACTIONS = /* @__PURE__ */ new Set([
|
|
4024
|
+
"done",
|
|
4025
|
+
"snooze",
|
|
4026
|
+
"dismiss",
|
|
4027
|
+
"reopen",
|
|
4028
|
+
"note"
|
|
4029
|
+
]);
|
|
4030
|
+
var MAX_BODY_BYTES = 64 * 1024;
|
|
4031
|
+
var PRESENCE_POLL_MS = 3e4;
|
|
4032
|
+
var AGENT_CLOCK_MS = 3e4;
|
|
4033
|
+
function sendJSON(res, status, body) {
|
|
4034
|
+
const payload = JSON.stringify(body);
|
|
4035
|
+
res.writeHead(status, {
|
|
4036
|
+
"content-type": "application/json; charset=utf-8",
|
|
4037
|
+
"content-length": Buffer.byteLength(payload),
|
|
4038
|
+
// The dashboard is a long-lived tab; never let it show a cached brief.
|
|
4039
|
+
"cache-control": "no-store"
|
|
4040
|
+
});
|
|
4041
|
+
res.end(payload);
|
|
4042
|
+
}
|
|
4043
|
+
function sendState(res, state) {
|
|
4044
|
+
sendJSON(res, 200, state);
|
|
4045
|
+
}
|
|
4046
|
+
async function readBody(req) {
|
|
4047
|
+
const chunks = [];
|
|
4048
|
+
let size = 0;
|
|
4049
|
+
for await (const chunk of req) {
|
|
4050
|
+
size += chunk.length;
|
|
4051
|
+
if (size > MAX_BODY_BYTES) throw new Error("request body too large");
|
|
4052
|
+
chunks.push(chunk);
|
|
4053
|
+
}
|
|
4054
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
4055
|
+
}
|
|
4056
|
+
async function serveStatic(res, urlPath) {
|
|
4057
|
+
const relative = normalize(decodeURIComponent(urlPath === "/" ? "/index.html" : urlPath));
|
|
4058
|
+
const filePath = join4(PUBLIC_DIR, relative);
|
|
4059
|
+
if (filePath !== PUBLIC_DIR && !filePath.startsWith(PUBLIC_DIR + sep)) {
|
|
4060
|
+
res.writeHead(403).end("Forbidden");
|
|
4061
|
+
return;
|
|
4062
|
+
}
|
|
4063
|
+
try {
|
|
4064
|
+
const info = await stat4(filePath);
|
|
4065
|
+
if (!info.isFile()) throw new Error("not a file");
|
|
4066
|
+
res.writeHead(200, {
|
|
4067
|
+
"content-type": MIME[extname(filePath)] ?? "application/octet-stream",
|
|
4068
|
+
"content-length": info.size,
|
|
4069
|
+
"cache-control": "no-cache"
|
|
4070
|
+
});
|
|
4071
|
+
createReadStream(filePath).pipe(res);
|
|
4072
|
+
} catch {
|
|
4073
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
async function readJsonObject(req, res) {
|
|
4077
|
+
let body;
|
|
4078
|
+
try {
|
|
4079
|
+
body = JSON.parse(await readBody(req));
|
|
4080
|
+
} catch (err) {
|
|
4081
|
+
sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
|
|
4082
|
+
return null;
|
|
4083
|
+
}
|
|
4084
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
4085
|
+
sendJSON(res, 400, { error: "body must be an object" });
|
|
4086
|
+
return null;
|
|
4087
|
+
}
|
|
4088
|
+
return body;
|
|
4089
|
+
}
|
|
4090
|
+
function configure(sources) {
|
|
4091
|
+
const warnings = [];
|
|
4092
|
+
if (sources.settingsError) {
|
|
4093
|
+
warnings.push(`${sources.settingsError}. The dashboard is ignoring it; saving on the settings page writes a fresh one.`);
|
|
4094
|
+
}
|
|
4095
|
+
try {
|
|
4096
|
+
return { config: loadConfig(layerEnv(sources)), warnings };
|
|
4097
|
+
} catch (error) {
|
|
4098
|
+
if (Object.keys(sources.settings).length === 0) throw error;
|
|
4099
|
+
warnings.push(
|
|
4100
|
+
`\`${sources.settingsPath}\` has a value the dashboard can't use, so it is ignoring the whole file: ${error.message}. Fix it on the settings page.`
|
|
4101
|
+
);
|
|
4102
|
+
return { config: loadConfig(layerEnv(sources, {})), warnings };
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
function statusOf(state, config) {
|
|
4106
|
+
const last = state.agentRun.last;
|
|
4107
|
+
return {
|
|
4108
|
+
dataDir: config.dataDir,
|
|
4109
|
+
profile: config.profile,
|
|
4110
|
+
setupNeeded: state.setup.needed,
|
|
4111
|
+
restartPending: state.restart !== null,
|
|
4112
|
+
brief: {
|
|
4113
|
+
generatedAt: state.brief.generatedAt,
|
|
4114
|
+
ageHours: state.brief.ageHours,
|
|
4115
|
+
stale: state.brief.stale,
|
|
4116
|
+
open: state.stats.open
|
|
4117
|
+
},
|
|
4118
|
+
agent: {
|
|
4119
|
+
enabled: state.agentRun.enabled,
|
|
4120
|
+
running: last?.status === "running",
|
|
4121
|
+
nextRunAt: state.agentRun.schedule?.nextRunAt ?? null,
|
|
4122
|
+
last: last ? { id: last.id, status: last.status, startedAt: last.startedAt, endedAt: last.endedAt, error: last.error } : null
|
|
4123
|
+
},
|
|
4124
|
+
waitingOnYou: state.board.enabled ? state.board.counts.you : 0
|
|
4125
|
+
};
|
|
4126
|
+
}
|
|
4127
|
+
async function startServer(env, options = {}) {
|
|
4128
|
+
const processEnv = env ?? process.env;
|
|
4129
|
+
const dotenv = env ? {} : readDotEnv();
|
|
4130
|
+
const readSources = () => envSources(processEnv, dotenv);
|
|
4131
|
+
const { config, warnings: configWarnings } = configure(readSources());
|
|
4132
|
+
for (const warning of configWarnings) console.warn(`[daily-focus] ${warning}`);
|
|
4133
|
+
const store = new Store(config);
|
|
4134
|
+
await store.ensureDataDir();
|
|
4135
|
+
for (const outcome of await linkStore(config)) {
|
|
4136
|
+
if (outcome.result === "linked") console.log(`[daily-focus] linked ${describeLink(config, outcome)}`);
|
|
4137
|
+
if (outcome.result === "failed") console.warn(`[daily-focus] could not link ${outcome.name}: ${outcome.detail}`);
|
|
4138
|
+
}
|
|
4139
|
+
let restart = null;
|
|
4140
|
+
let restarting = false;
|
|
4141
|
+
let settingsWrites = Promise.resolve();
|
|
4142
|
+
await stat4(join4(PUBLIC_DIR, "app.js")).catch(() => {
|
|
4143
|
+
console.warn(
|
|
4144
|
+
PACKAGED ? "[daily-focus] public/app.js is missing from the package: reinstall it" : "[daily-focus] public/app.js is missing: run `npm run build` (npm start does so on its own)"
|
|
4145
|
+
);
|
|
4146
|
+
});
|
|
4147
|
+
const subscribers = /* @__PURE__ */ new Set();
|
|
4148
|
+
let assetVersion = await computeAssetVersion(PUBLIC_DIR);
|
|
4149
|
+
const board = new Board(config, () => void broadcast());
|
|
4150
|
+
const calendar = new CalendarBoard(config, () => void broadcast());
|
|
4151
|
+
const tickets = new TicketBoard(config, () => void broadcast());
|
|
4152
|
+
const assistant = new Assistant(config, {
|
|
4153
|
+
onChange: () => {
|
|
4154
|
+
void broadcast();
|
|
4155
|
+
restartWhenIdle();
|
|
4156
|
+
}
|
|
4157
|
+
});
|
|
4158
|
+
const agent = new AgentRunner(config, {
|
|
4159
|
+
onChange: () => {
|
|
4160
|
+
void broadcast();
|
|
4161
|
+
restartWhenIdle();
|
|
4162
|
+
}
|
|
4163
|
+
});
|
|
4164
|
+
function requestRestart() {
|
|
4165
|
+
restart = options.onRestart ? "waiting" : "manual";
|
|
4166
|
+
restartWhenIdle();
|
|
4167
|
+
}
|
|
4168
|
+
function restartWhenIdle() {
|
|
4169
|
+
if (restart !== "waiting" || restarting || agent.busy || assistant.busy) return;
|
|
4170
|
+
restarting = true;
|
|
4171
|
+
setImmediate(() => options.onRestart?.());
|
|
4172
|
+
}
|
|
4173
|
+
async function buildState() {
|
|
4174
|
+
const actions = await store.readActions();
|
|
4175
|
+
const now = /* @__PURE__ */ new Date();
|
|
4176
|
+
const [state, linkWarning] = await Promise.all([
|
|
4177
|
+
store.getState(now, actions, calendar.state()),
|
|
4178
|
+
// A link that can't be read is no reason to withhold the page.
|
|
4179
|
+
storeLinkWarning(config).catch(() => null)
|
|
4180
|
+
]);
|
|
4181
|
+
const sources = readSources();
|
|
4182
|
+
const setup = await setupState(config, {
|
|
4183
|
+
profileChosen: Boolean(layerEnv(sources).DAILY_FOCUS_PROFILE?.trim())
|
|
4184
|
+
});
|
|
4185
|
+
return {
|
|
4186
|
+
...state,
|
|
4187
|
+
board: board.view(actions, now),
|
|
4188
|
+
tickets: tickets.view(actions, now),
|
|
4189
|
+
assistant: assistant.view(),
|
|
4190
|
+
agentRun: agent.view(now),
|
|
4191
|
+
setupWarnings: [...configWarnings, ...linkWarning ? [linkWarning] : []],
|
|
4192
|
+
restart,
|
|
4193
|
+
setup,
|
|
4194
|
+
assetVersion
|
|
4195
|
+
};
|
|
4196
|
+
}
|
|
4197
|
+
async function broadcast(prebuilt) {
|
|
4198
|
+
if (subscribers.size === 0) return;
|
|
4199
|
+
let payload;
|
|
4200
|
+
try {
|
|
4201
|
+
payload = JSON.stringify(prebuilt ?? await buildState());
|
|
4202
|
+
} catch (err) {
|
|
4203
|
+
console.error(`[daily-focus] could not build state: ${err.message}`);
|
|
4204
|
+
return;
|
|
4205
|
+
}
|
|
4206
|
+
const frame = `event: state
|
|
4207
|
+
data: ${payload}
|
|
4208
|
+
|
|
4209
|
+
`;
|
|
4210
|
+
for (const res of subscribers) res.write(frame);
|
|
4211
|
+
}
|
|
4212
|
+
await store.archiveCurrentBrief();
|
|
4213
|
+
const stopWatching = watchDataDir(
|
|
4214
|
+
config.dataDir,
|
|
4215
|
+
() => {
|
|
4216
|
+
void store.archiveCurrentBrief().then(() => broadcast());
|
|
4217
|
+
},
|
|
4218
|
+
// The timer re-stamps its own file every poll and the two boards rewrite
|
|
4219
|
+
// prs.json and tickets.json every fetch; that's this process talking to
|
|
4220
|
+
// itself, and all three already broadcast when something actually changed.
|
|
4221
|
+
{
|
|
4222
|
+
ignore: [
|
|
4223
|
+
basename(config.sessionFile),
|
|
4224
|
+
basename(tempPathFor(config.sessionFile)),
|
|
4225
|
+
basename(config.pullsFile),
|
|
4226
|
+
basename(tempPathFor(config.pullsFile)),
|
|
4227
|
+
basename(config.ticketsFile),
|
|
4228
|
+
basename(tempPathFor(config.ticketsFile)),
|
|
4229
|
+
// Appended by this process, which broadcasts on its own.
|
|
4230
|
+
basename(config.assistantLogFile),
|
|
4231
|
+
basename(config.agentLogFile)
|
|
4232
|
+
]
|
|
4233
|
+
}
|
|
4234
|
+
);
|
|
4235
|
+
const stopWatchingAssets = watchDataDir(PUBLIC_DIR, () => {
|
|
4236
|
+
void computeAssetVersion(PUBLIC_DIR).then((next) => {
|
|
4237
|
+
if (next === assetVersion) return;
|
|
4238
|
+
assetVersion = next;
|
|
4239
|
+
void broadcast();
|
|
4240
|
+
});
|
|
4241
|
+
});
|
|
4242
|
+
let sessionTick = null;
|
|
4243
|
+
function scheduleSessionTick(endsAt) {
|
|
4244
|
+
if (sessionTick) clearTimeout(sessionTick);
|
|
4245
|
+
sessionTick = null;
|
|
4246
|
+
if (!endsAt) return;
|
|
4247
|
+
const delay = new Date(endsAt).getTime() - Date.now();
|
|
4248
|
+
if (delay <= 0 || delay > 36e5) return;
|
|
4249
|
+
sessionTick = setTimeout(() => void broadcast(), delay);
|
|
4250
|
+
sessionTick.unref();
|
|
4251
|
+
}
|
|
4252
|
+
async function checkPresence() {
|
|
4253
|
+
let closed;
|
|
4254
|
+
try {
|
|
4255
|
+
closed = await reconcileSession(config, readIdleSeconds);
|
|
4256
|
+
} catch (err) {
|
|
4257
|
+
console.error(`[daily-focus] presence check failed: ${err.message}`);
|
|
4258
|
+
return;
|
|
4259
|
+
}
|
|
4260
|
+
if (!closed) return;
|
|
4261
|
+
console.log(
|
|
4262
|
+
`[daily-focus] stopped "${closed.title}" at ${closed.endedAt} after ${closed.actualMinutes} min \u2014 machine untouched since then`
|
|
4263
|
+
);
|
|
4264
|
+
scheduleSessionTick(null);
|
|
4265
|
+
await broadcast();
|
|
4266
|
+
}
|
|
4267
|
+
await checkPresence();
|
|
4268
|
+
await assistant.start();
|
|
4269
|
+
await agent.start();
|
|
4270
|
+
async function clockTick() {
|
|
4271
|
+
if (restart === "waiting" || await firstBriefPending(config)) return;
|
|
4272
|
+
await agent.tick();
|
|
4273
|
+
}
|
|
4274
|
+
void clockTick();
|
|
4275
|
+
const agentClock = setInterval(() => {
|
|
4276
|
+
void clockTick();
|
|
4277
|
+
}, AGENT_CLOCK_MS);
|
|
4278
|
+
agentClock.unref();
|
|
4279
|
+
scheduleSessionTick((await buildState()).session.active?.endsAt ?? null);
|
|
4280
|
+
void board.start().catch((err) => {
|
|
4281
|
+
console.error(`[daily-focus] pull request board failed to start: ${err.message}`);
|
|
4282
|
+
});
|
|
4283
|
+
void calendar.start().catch((err) => {
|
|
4284
|
+
console.error(`[daily-focus] live agenda failed to start: ${err.message}`);
|
|
4285
|
+
});
|
|
4286
|
+
void tickets.start().catch((err) => {
|
|
4287
|
+
console.error(`[daily-focus] jira ticket board failed to start: ${err.message}`);
|
|
4288
|
+
});
|
|
4289
|
+
const heartbeat = setInterval(() => {
|
|
4290
|
+
void broadcast();
|
|
4291
|
+
}, 6e4);
|
|
4292
|
+
heartbeat.unref();
|
|
4293
|
+
const presencePoll = setInterval(() => {
|
|
4294
|
+
void checkPresence();
|
|
4295
|
+
}, PRESENCE_POLL_MS);
|
|
4296
|
+
presencePoll.unref();
|
|
4297
|
+
const server = createServer((req, res) => {
|
|
4298
|
+
void handle(req, res).catch((err) => {
|
|
4299
|
+
console.error(`[daily-focus] ${req.method} ${req.url} failed:`, err);
|
|
4300
|
+
if (!res.headersSent) sendJSON(res, 500, { error: err.message });
|
|
4301
|
+
else res.end();
|
|
4302
|
+
});
|
|
4303
|
+
});
|
|
4304
|
+
async function handle(req, res) {
|
|
4305
|
+
const refused = refusal(req, config.host);
|
|
4306
|
+
if (refused) {
|
|
4307
|
+
sendJSON(res, refused.status, { error: refused.error });
|
|
4308
|
+
return;
|
|
4309
|
+
}
|
|
4310
|
+
const url2 = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
4311
|
+
const path = url2.pathname;
|
|
4312
|
+
if (restart === "waiting" && req.method === "POST" && ["/api/agent/run", "/api/agent/ask", "/api/assistant/ask"].includes(path)) {
|
|
4313
|
+
sendJSON(res, 409, { error: "the dashboard is restarting to apply new settings; try again in a moment" });
|
|
4314
|
+
return;
|
|
4315
|
+
}
|
|
4316
|
+
if (path === "/api/state" && req.method === "GET") {
|
|
4317
|
+
sendState(res, await buildState());
|
|
4318
|
+
return;
|
|
4319
|
+
}
|
|
4320
|
+
if (path === "/api/events" && req.method === "GET") {
|
|
4321
|
+
res.writeHead(200, {
|
|
4322
|
+
"content-type": "text/event-stream",
|
|
4323
|
+
"cache-control": "no-store",
|
|
4324
|
+
connection: "keep-alive",
|
|
4325
|
+
// Proxies aside, this stops any intermediary from buffering the stream.
|
|
4326
|
+
"x-accel-buffering": "no"
|
|
4327
|
+
});
|
|
4328
|
+
res.write(`event: state
|
|
4329
|
+
data: ${JSON.stringify(await buildState())}
|
|
4330
|
+
|
|
4331
|
+
`);
|
|
4332
|
+
subscribers.add(res);
|
|
4333
|
+
board.setAudience(subscribers.size);
|
|
4334
|
+
calendar.setAudience(subscribers.size);
|
|
4335
|
+
tickets.setAudience(subscribers.size);
|
|
4336
|
+
const keepAlive = setInterval(() => res.write(": ping\n\n"), 25e3);
|
|
4337
|
+
keepAlive.unref();
|
|
4338
|
+
req.on("close", () => {
|
|
4339
|
+
clearInterval(keepAlive);
|
|
4340
|
+
subscribers.delete(res);
|
|
4341
|
+
board.setAudience(subscribers.size);
|
|
4342
|
+
calendar.setAudience(subscribers.size);
|
|
4343
|
+
tickets.setAudience(subscribers.size);
|
|
4344
|
+
});
|
|
4345
|
+
return;
|
|
4346
|
+
}
|
|
4347
|
+
if (path === "/api/board/refresh" && req.method === "POST") {
|
|
4348
|
+
await board.refresh();
|
|
4349
|
+
sendState(res, await buildState());
|
|
4350
|
+
return;
|
|
4351
|
+
}
|
|
4352
|
+
if (path === "/api/tickets/refresh" && req.method === "POST") {
|
|
4353
|
+
await tickets.refresh();
|
|
4354
|
+
sendState(res, await buildState());
|
|
4355
|
+
return;
|
|
4356
|
+
}
|
|
4357
|
+
if (path === "/api/calendar/refresh" && req.method === "POST") {
|
|
4358
|
+
await calendar.refresh();
|
|
4359
|
+
sendState(res, await buildState());
|
|
4360
|
+
return;
|
|
4361
|
+
}
|
|
4362
|
+
if (path === "/api/tickets/transition" && req.method === "POST") {
|
|
4363
|
+
let body;
|
|
4364
|
+
try {
|
|
4365
|
+
body = JSON.parse(await readBody(req));
|
|
4366
|
+
} catch (err) {
|
|
4367
|
+
sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
|
|
4368
|
+
return;
|
|
4369
|
+
}
|
|
4370
|
+
if (typeof body !== "object" || body === null) {
|
|
4371
|
+
sendJSON(res, 400, { error: "body must be an object" });
|
|
4372
|
+
return;
|
|
4373
|
+
}
|
|
4374
|
+
const { key, status } = body;
|
|
4375
|
+
if (typeof key !== "string" || !/^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(key.trim())) {
|
|
4376
|
+
sendJSON(res, 400, { error: '"key" must be a single Jira work item key, e.g. PROJ-8842' });
|
|
4377
|
+
return;
|
|
4378
|
+
}
|
|
4379
|
+
if (typeof status !== "string" || status.trim() === "") {
|
|
4380
|
+
sendJSON(res, 400, { error: '"status" is required' });
|
|
4381
|
+
return;
|
|
4382
|
+
}
|
|
4383
|
+
try {
|
|
4384
|
+
await tickets.transition(key.trim(), status.trim());
|
|
4385
|
+
} catch (err) {
|
|
4386
|
+
sendJSON(res, 409, { error: err.message });
|
|
4387
|
+
return;
|
|
4388
|
+
}
|
|
4389
|
+
const state = await buildState();
|
|
4390
|
+
sendState(res, state);
|
|
4391
|
+
void broadcast(state);
|
|
4392
|
+
return;
|
|
4393
|
+
}
|
|
4394
|
+
if (path === "/api/assistant/ask" && req.method === "POST") {
|
|
4395
|
+
let body;
|
|
4396
|
+
try {
|
|
4397
|
+
body = JSON.parse(await readBody(req));
|
|
4398
|
+
} catch (err) {
|
|
4399
|
+
sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
|
|
4400
|
+
return;
|
|
4401
|
+
}
|
|
4402
|
+
if (typeof body !== "object" || body === null) {
|
|
4403
|
+
sendJSON(res, 400, { error: "body must be an object" });
|
|
4404
|
+
return;
|
|
4405
|
+
}
|
|
4406
|
+
const { id, action, text } = body;
|
|
4407
|
+
if (typeof id !== "string" || id === "") {
|
|
4408
|
+
sendJSON(res, 400, { error: '"id" is required' });
|
|
4409
|
+
return;
|
|
4410
|
+
}
|
|
4411
|
+
if (action !== void 0 && typeof action !== "string") {
|
|
4412
|
+
sendJSON(res, 400, { error: '"action" must be a quick action id' });
|
|
4413
|
+
return;
|
|
4414
|
+
}
|
|
4415
|
+
if (text !== void 0 && typeof text !== "string") {
|
|
4416
|
+
sendJSON(res, 400, { error: '"text" must be a string' });
|
|
4417
|
+
return;
|
|
4418
|
+
}
|
|
4419
|
+
if (!assistant.enabled) {
|
|
4420
|
+
sendJSON(res, 409, { error: "the assistant is off; set DAILY_FOCUS_ASSISTANT to claude or codex" });
|
|
4421
|
+
return;
|
|
4422
|
+
}
|
|
4423
|
+
const current = await buildState();
|
|
4424
|
+
const context = {
|
|
4425
|
+
item: current.items.find((item) => item.id === id),
|
|
4426
|
+
pull: current.board.rows.find((row) => row.id === id),
|
|
4427
|
+
ticket: current.tickets.rows.find((row) => row.id === id) ?? current.tickets.inProgress.find((row) => row.id === id)
|
|
4428
|
+
};
|
|
4429
|
+
if (!context.item && !context.pull && !context.ticket) {
|
|
4430
|
+
sendJSON(res, 404, { error: `no item with id ${id}` });
|
|
4431
|
+
return;
|
|
4432
|
+
}
|
|
4433
|
+
try {
|
|
4434
|
+
await assistant.ask({ itemId: id, action, text }, context);
|
|
4435
|
+
} catch (err) {
|
|
4436
|
+
sendJSON(res, 409, { error: err.message });
|
|
4437
|
+
return;
|
|
4438
|
+
}
|
|
4439
|
+
const state = await buildState();
|
|
4440
|
+
sendState(res, state);
|
|
4441
|
+
void broadcast(state);
|
|
4442
|
+
return;
|
|
4443
|
+
}
|
|
4444
|
+
if (path === "/api/agent/run" && req.method === "POST") {
|
|
4445
|
+
try {
|
|
4446
|
+
await agent.run();
|
|
4447
|
+
} catch (err) {
|
|
4448
|
+
sendJSON(res, 409, { error: err.message });
|
|
4449
|
+
return;
|
|
4450
|
+
}
|
|
4451
|
+
const state = await buildState();
|
|
4452
|
+
sendState(res, state);
|
|
4453
|
+
void broadcast(state);
|
|
4454
|
+
return;
|
|
4455
|
+
}
|
|
4456
|
+
if (path === "/api/agent/ask" && req.method === "POST") {
|
|
4457
|
+
let body;
|
|
4458
|
+
try {
|
|
4459
|
+
body = JSON.parse(await readBody(req));
|
|
4460
|
+
} catch (err) {
|
|
4461
|
+
sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
|
|
4462
|
+
return;
|
|
4463
|
+
}
|
|
4464
|
+
const { run: run6, text } = typeof body === "object" && body !== null ? body : {};
|
|
4465
|
+
if (typeof run6 !== "string" || run6 === "") {
|
|
4466
|
+
sendJSON(res, 400, { error: "run must be a run id" });
|
|
4467
|
+
return;
|
|
4468
|
+
}
|
|
4469
|
+
if (typeof text !== "string" || text.trim() === "") {
|
|
4470
|
+
sendJSON(res, 400, { error: "text must be the question to ask" });
|
|
4471
|
+
return;
|
|
4472
|
+
}
|
|
4473
|
+
try {
|
|
4474
|
+
await agent.ask(run6, text);
|
|
4475
|
+
} catch (err) {
|
|
4476
|
+
sendJSON(res, 409, { error: err.message });
|
|
4477
|
+
return;
|
|
4478
|
+
}
|
|
4479
|
+
const state = await buildState();
|
|
4480
|
+
sendState(res, state);
|
|
4481
|
+
void broadcast(state);
|
|
4482
|
+
return;
|
|
4483
|
+
}
|
|
4484
|
+
if (path === "/api/agent/stop" && req.method === "POST") {
|
|
4485
|
+
await agent.stop();
|
|
4486
|
+
sendState(res, await buildState());
|
|
4487
|
+
return;
|
|
4488
|
+
}
|
|
4489
|
+
if (path === "/api/assistant/stop" && req.method === "POST") {
|
|
4490
|
+
let body;
|
|
4491
|
+
try {
|
|
4492
|
+
body = JSON.parse(await readBody(req));
|
|
4493
|
+
} catch (err) {
|
|
4494
|
+
sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
|
|
4495
|
+
return;
|
|
4496
|
+
}
|
|
4497
|
+
const { id } = typeof body === "object" && body !== null ? body : {};
|
|
4498
|
+
if (typeof id !== "string" || id === "") {
|
|
4499
|
+
sendJSON(res, 400, { error: '"id" is required' });
|
|
4500
|
+
return;
|
|
4501
|
+
}
|
|
4502
|
+
await assistant.stop(id);
|
|
4503
|
+
sendState(res, await buildState());
|
|
4504
|
+
return;
|
|
4505
|
+
}
|
|
4506
|
+
if (path === "/api/actions" && req.method === "POST") {
|
|
4507
|
+
let body;
|
|
4508
|
+
try {
|
|
4509
|
+
body = JSON.parse(await readBody(req));
|
|
4510
|
+
} catch (err) {
|
|
4511
|
+
sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
|
|
4512
|
+
return;
|
|
4513
|
+
}
|
|
4514
|
+
if (typeof body !== "object" || body === null) {
|
|
4515
|
+
sendJSON(res, 400, { error: "body must be an object" });
|
|
4516
|
+
return;
|
|
4517
|
+
}
|
|
4518
|
+
const { id, action, until, text } = body;
|
|
4519
|
+
if (typeof id !== "string" || id === "") {
|
|
4520
|
+
sendJSON(res, 400, { error: '"id" is required' });
|
|
4521
|
+
return;
|
|
4522
|
+
}
|
|
4523
|
+
if (typeof action !== "string" || !VALID_ACTIONS.has(action)) {
|
|
4524
|
+
sendJSON(res, 400, { error: `"action" must be one of ${[...VALID_ACTIONS].join(", ")}` });
|
|
4525
|
+
return;
|
|
4526
|
+
}
|
|
4527
|
+
if (action === "snooze" && until !== void 0 && !/^\d{4}-\d{2}-\d{2}$/.test(String(until))) {
|
|
4528
|
+
sendJSON(res, 400, { error: '"until" must be YYYY-MM-DD' });
|
|
4529
|
+
return;
|
|
4530
|
+
}
|
|
4531
|
+
if (action === "note" && (typeof text !== "string" || text.trim() === "")) {
|
|
4532
|
+
sendJSON(res, 400, { error: '"text" is required for a note' });
|
|
4533
|
+
return;
|
|
4534
|
+
}
|
|
4535
|
+
const record = { id, action, at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
4536
|
+
if (action === "snooze" && typeof until === "string") record.until = until;
|
|
4537
|
+
if (action === "note" && typeof text === "string") record.text = text.trim();
|
|
4538
|
+
await store.appendAction(record);
|
|
4539
|
+
const state = await buildState();
|
|
4540
|
+
sendState(res, state);
|
|
4541
|
+
void broadcast(state);
|
|
4542
|
+
return;
|
|
4543
|
+
}
|
|
4544
|
+
if (path === "/api/session" && req.method === "POST") {
|
|
4545
|
+
let body;
|
|
4546
|
+
try {
|
|
4547
|
+
body = JSON.parse(await readBody(req));
|
|
4548
|
+
} catch (err) {
|
|
4549
|
+
sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
|
|
4550
|
+
return;
|
|
4551
|
+
}
|
|
4552
|
+
if (typeof body !== "object" || body === null) {
|
|
4553
|
+
sendJSON(res, 400, { error: "body must be an object" });
|
|
4554
|
+
return;
|
|
4555
|
+
}
|
|
4556
|
+
const { action, id, minutes } = body;
|
|
4557
|
+
if (action === "stop") {
|
|
4558
|
+
await stopSession(config);
|
|
4559
|
+
} else if (action === "start") {
|
|
4560
|
+
if (typeof id !== "string" || id === "") {
|
|
4561
|
+
sendJSON(res, 400, { error: '"id" is required to start a session' });
|
|
4562
|
+
return;
|
|
4563
|
+
}
|
|
4564
|
+
const { brief } = await store.readBrief();
|
|
4565
|
+
const item = brief?.items.find((candidate) => candidate.id === id);
|
|
4566
|
+
if (!item) {
|
|
4567
|
+
sendJSON(res, 404, { error: `no item with id ${id}` });
|
|
4568
|
+
return;
|
|
4569
|
+
}
|
|
4570
|
+
const requested = typeof minutes === "number" && Number.isFinite(minutes) ? Math.round(minutes) : config.sessionMinutes;
|
|
4571
|
+
if (requested < 1 || requested > 120) {
|
|
4572
|
+
sendJSON(res, 400, { error: '"minutes" must be between 1 and 120' });
|
|
4573
|
+
return;
|
|
4574
|
+
}
|
|
4575
|
+
await startSession(config, {
|
|
4576
|
+
id,
|
|
4577
|
+
title: item.title,
|
|
4578
|
+
minutes: requested,
|
|
4579
|
+
advancesObjective: item.advancesObjective === true
|
|
4580
|
+
});
|
|
4581
|
+
} else {
|
|
4582
|
+
sendJSON(res, 400, { error: '"action" must be "start" or "stop"' });
|
|
4583
|
+
return;
|
|
4584
|
+
}
|
|
4585
|
+
const state = await buildState();
|
|
4586
|
+
sendState(res, state);
|
|
4587
|
+
scheduleSessionTick(state.session.active?.endsAt ?? null);
|
|
4588
|
+
void broadcast(state);
|
|
4589
|
+
return;
|
|
4590
|
+
}
|
|
4591
|
+
if (path === "/api/settings" && req.method === "GET") {
|
|
4592
|
+
sendJSON(res, 200, { ...describeSettings(readSources()), restart });
|
|
4593
|
+
return;
|
|
4594
|
+
}
|
|
4595
|
+
if (path === "/api/settings" && req.method === "POST") {
|
|
4596
|
+
const body = await readJsonObject(req, res);
|
|
4597
|
+
if (!body) return;
|
|
4598
|
+
const values = body.values;
|
|
4599
|
+
if (typeof values !== "object" || values === null || Array.isArray(values)) {
|
|
4600
|
+
sendJSON(res, 400, { error: '"values" must be an object of settings' });
|
|
4601
|
+
return;
|
|
4602
|
+
}
|
|
4603
|
+
const saved = settingsWrites.then(async () => {
|
|
4604
|
+
const sources = readSources();
|
|
4605
|
+
const applied = applySettingsChange(sources, values);
|
|
4606
|
+
if ("error" in applied) return applied.error;
|
|
4607
|
+
const changed = JSON.stringify(Object.entries(applied.settings).sort()) !== JSON.stringify(Object.entries(sources.settings).sort());
|
|
4608
|
+
if (changed) {
|
|
4609
|
+
await saveSettings(sources.settingsPath, applied.settings);
|
|
4610
|
+
requestRestart();
|
|
4611
|
+
}
|
|
4612
|
+
return null;
|
|
4613
|
+
});
|
|
4614
|
+
settingsWrites = saved.catch(() => {
|
|
4615
|
+
});
|
|
4616
|
+
const refused2 = await saved;
|
|
4617
|
+
if (refused2) {
|
|
4618
|
+
sendJSON(res, 400, { error: refused2 });
|
|
4619
|
+
return;
|
|
4620
|
+
}
|
|
4621
|
+
sendJSON(res, 200, { ...describeSettings(readSources()), restart });
|
|
4622
|
+
void broadcast();
|
|
4623
|
+
return;
|
|
4624
|
+
}
|
|
4625
|
+
const textFile = TEXT_FILES[path];
|
|
4626
|
+
if (textFile && req.method === "GET") {
|
|
4627
|
+
const { text, version } = await readEditable(config[textFile.file]);
|
|
4628
|
+
const template = textFile.file === "focusFile" ? FOCUS_TEMPLATE : text === null ? fillIdentity(sourcesTemplate(config.profile), await detectIdentity(config)) : sourcesTemplate(config.profile);
|
|
4629
|
+
sendJSON(res, 200, { text, version, template });
|
|
4630
|
+
return;
|
|
4631
|
+
}
|
|
4632
|
+
if (textFile && req.method === "POST") {
|
|
4633
|
+
const body = await readJsonObject(req, res);
|
|
4634
|
+
if (!body) return;
|
|
4635
|
+
const { text, version } = body;
|
|
4636
|
+
if (typeof text !== "string" || typeof version !== "string") {
|
|
4637
|
+
sendJSON(res, 400, { error: '"text" and "version" are required: the text, and the version it was edited from' });
|
|
4638
|
+
return;
|
|
4639
|
+
}
|
|
4640
|
+
const result = await saveEditable(config[textFile.file], text, version);
|
|
4641
|
+
if (!result.saved) {
|
|
4642
|
+
sendJSON(res, 409, {
|
|
4643
|
+
error: `${textFile.name} changed ${result.version === ABSENT ? "on disk: it was deleted" : "on disk"} since you opened it`,
|
|
4644
|
+
text: result.text,
|
|
4645
|
+
version: result.version
|
|
4646
|
+
});
|
|
4647
|
+
return;
|
|
4648
|
+
}
|
|
4649
|
+
sendJSON(res, 200, { version: result.version });
|
|
4650
|
+
void broadcast();
|
|
4651
|
+
return;
|
|
4652
|
+
}
|
|
4653
|
+
if (path === "/api/focus/objective" && req.method === "POST") {
|
|
4654
|
+
const body = await readJsonObject(req, res);
|
|
4655
|
+
if (!body) return;
|
|
4656
|
+
const { objective, blocker } = body;
|
|
4657
|
+
for (const [name, value] of [["objective", objective], ["blocker", blocker]]) {
|
|
4658
|
+
if (value !== void 0 && value !== null && typeof value !== "string") {
|
|
4659
|
+
sendJSON(res, 400, { error: `"${name}" must be text, or null to clear it` });
|
|
4660
|
+
return;
|
|
4661
|
+
}
|
|
4662
|
+
}
|
|
4663
|
+
const current = await readEditable(config.focusFile);
|
|
4664
|
+
const next = setFocusFields(
|
|
4665
|
+
current.text,
|
|
4666
|
+
{ objective: objective ?? null, blocker: blocker ?? null },
|
|
4667
|
+
FOCUS_TEMPLATE
|
|
4668
|
+
);
|
|
4669
|
+
const result = await saveEditable(config.focusFile, next, current.version);
|
|
4670
|
+
if (!result.saved) {
|
|
4671
|
+
sendJSON(res, 409, { error: "focus.md changed on disk while saving; try again" });
|
|
4672
|
+
return;
|
|
4673
|
+
}
|
|
4674
|
+
const state = await buildState();
|
|
4675
|
+
sendState(res, state);
|
|
4676
|
+
void broadcast(state);
|
|
4677
|
+
return;
|
|
4678
|
+
}
|
|
4679
|
+
if (path === "/api/calendars/list" && req.method === "POST") {
|
|
4680
|
+
try {
|
|
4681
|
+
const facts = await runHelper(config.calendar.appPath, []);
|
|
4682
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4683
|
+
const calendars = facts.calendars.filter((entry) => !seen.has(`${entry.title}
|
|
4684
|
+
${entry.source}`) && seen.add(`${entry.title}
|
|
4685
|
+
${entry.source}`)).map(({ title, source }) => ({ title, source })).sort((a, b) => a.title.localeCompare(b.title));
|
|
4686
|
+
sendJSON(res, 200, { calendars });
|
|
4687
|
+
} catch (err) {
|
|
4688
|
+
sendJSON(res, 409, { error: err.message });
|
|
4689
|
+
}
|
|
4690
|
+
return;
|
|
4691
|
+
}
|
|
4692
|
+
if (path === "/api/status" && req.method === "GET") {
|
|
4693
|
+
sendJSON(res, 200, statusOf(await buildState(), config));
|
|
4694
|
+
return;
|
|
4695
|
+
}
|
|
4696
|
+
if (path === "/api/health" && req.method === "GET") {
|
|
4697
|
+
sendJSON(res, 200, { ok: true, dataDir: config.dataDir });
|
|
4698
|
+
return;
|
|
4699
|
+
}
|
|
4700
|
+
if (path === "/favicon.svg" && (req.method === "GET" || req.method === "HEAD")) {
|
|
4701
|
+
const svg = faviconSvg(config.profile);
|
|
4702
|
+
res.writeHead(200, {
|
|
4703
|
+
"content-type": "image/svg+xml",
|
|
4704
|
+
"content-length": Buffer.byteLength(svg),
|
|
4705
|
+
"cache-control": "no-cache"
|
|
4706
|
+
});
|
|
4707
|
+
res.end(req.method === "HEAD" ? void 0 : svg);
|
|
4708
|
+
return;
|
|
4709
|
+
}
|
|
4710
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
4711
|
+
await serveStatic(res, path);
|
|
4712
|
+
return;
|
|
4713
|
+
}
|
|
4714
|
+
sendJSON(res, 405, { error: "method not allowed" });
|
|
4715
|
+
}
|
|
4716
|
+
async function stopEverything() {
|
|
4717
|
+
clearInterval(heartbeat);
|
|
4718
|
+
clearInterval(presencePoll);
|
|
4719
|
+
clearInterval(agentClock);
|
|
4720
|
+
if (sessionTick) clearTimeout(sessionTick);
|
|
4721
|
+
board.stop();
|
|
4722
|
+
calendar.stop();
|
|
4723
|
+
tickets.stop();
|
|
4724
|
+
await assistant.close();
|
|
4725
|
+
await agent.close();
|
|
4726
|
+
stopWatching();
|
|
4727
|
+
stopWatchingAssets();
|
|
4728
|
+
for (const res of subscribers) res.end();
|
|
4729
|
+
subscribers.clear();
|
|
4730
|
+
}
|
|
4731
|
+
try {
|
|
4732
|
+
await new Promise((resolvePromise, reject) => {
|
|
4733
|
+
server.once("error", reject);
|
|
4734
|
+
server.listen(config.port, config.host, () => {
|
|
4735
|
+
server.off("error", reject);
|
|
4736
|
+
resolvePromise();
|
|
4737
|
+
});
|
|
4738
|
+
});
|
|
4739
|
+
} catch (err) {
|
|
4740
|
+
await stopEverything();
|
|
4741
|
+
if (err.code === "EADDRINUSE") {
|
|
4742
|
+
throw new PortInUseError(config.host, config.port);
|
|
4743
|
+
}
|
|
4744
|
+
throw err;
|
|
4745
|
+
}
|
|
4746
|
+
const address = server.address();
|
|
4747
|
+
const port = typeof address === "object" && address !== null ? address.port : config.port;
|
|
4748
|
+
const shown = config.host === "0.0.0.0" ? "127.0.0.1" : config.host === "::" ? "[::1]" : config.host.includes(":") ? `[${config.host}]` : config.host;
|
|
4749
|
+
const url = `http://${shown}:${port}`;
|
|
4750
|
+
console.log(`[daily-focus] dashboard ${url}`);
|
|
4751
|
+
console.log(`[daily-focus] store ${config.dataDir}`);
|
|
4752
|
+
return {
|
|
4753
|
+
url,
|
|
4754
|
+
async close() {
|
|
4755
|
+
await stopEverything();
|
|
4756
|
+
await new Promise((done, fail) => {
|
|
4757
|
+
server.close((err) => err ? fail(err) : done());
|
|
4758
|
+
server.closeIdleConnections();
|
|
4759
|
+
setTimeout(() => server.closeAllConnections(), 1e3).unref();
|
|
4760
|
+
});
|
|
4761
|
+
}
|
|
4762
|
+
};
|
|
4763
|
+
}
|
|
4764
|
+
var PortInUseError = class extends Error {
|
|
4765
|
+
constructor(host, port) {
|
|
4766
|
+
super(
|
|
4767
|
+
`port ${port} on ${host} is already in use. If that is this dashboard, running as a service or in another terminal, open http://${host.includes(":") ? `[${host}]` : host}:${port}. To run this one beside it, give it another port with DAILY_FOCUS_PORT or --port.`
|
|
4768
|
+
);
|
|
4769
|
+
this.name = "PortInUseError";
|
|
4770
|
+
}
|
|
4771
|
+
};
|
|
4772
|
+
async function runServer(options = {}) {
|
|
4773
|
+
await hydratePath();
|
|
4774
|
+
let current = null;
|
|
4775
|
+
let stopping = false;
|
|
4776
|
+
async function start() {
|
|
4777
|
+
current = await startServer(void 0, { onRestart: () => void restart() });
|
|
4778
|
+
}
|
|
4779
|
+
async function restart() {
|
|
4780
|
+
console.log("[daily-focus] restarting to apply new settings");
|
|
4781
|
+
const previous = current;
|
|
4782
|
+
current = null;
|
|
4783
|
+
await previous?.close();
|
|
4784
|
+
if (stopping) return;
|
|
4785
|
+
try {
|
|
4786
|
+
await start();
|
|
4787
|
+
} catch (err) {
|
|
4788
|
+
console.error(`[daily-focus] could not restart: ${err.message}`);
|
|
4789
|
+
process.exit(1);
|
|
4790
|
+
}
|
|
4791
|
+
}
|
|
4792
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
4793
|
+
process.on(signal, () => {
|
|
4794
|
+
stopping = true;
|
|
4795
|
+
void (current?.close() ?? Promise.resolve()).then(() => process.exit(0));
|
|
4796
|
+
});
|
|
4797
|
+
}
|
|
4798
|
+
try {
|
|
4799
|
+
await start();
|
|
4800
|
+
} catch (err) {
|
|
4801
|
+
if (!(err instanceof PortInUseError)) throw err;
|
|
4802
|
+
console.error(`[daily-focus] ${err.message}`);
|
|
4803
|
+
process.exit(1);
|
|
4804
|
+
}
|
|
4805
|
+
options.onStarted?.(current?.url ?? "");
|
|
4806
|
+
}
|
|
4807
|
+
if (ranDirectly(import.meta.url)) {
|
|
4808
|
+
await runServer();
|
|
4809
|
+
}
|
|
4810
|
+
export {
|
|
4811
|
+
PortInUseError,
|
|
4812
|
+
runServer,
|
|
4813
|
+
startServer
|
|
4814
|
+
};
|