daily-focus 1.2.0-dev.5 → 1.2.0-dev.7
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/README.md +12 -2
- package/SETUP.md +6 -1
- package/dist/{audit-ITRXIX6T.js → audit-7YAJYHZF.js} +5 -4
- package/dist/{chunk-AIQO2YAI.js → chunk-4DCBCR4D.js} +1 -1
- package/dist/{chunk-26JG3D5C.js → chunk-JQGYCWRX.js} +106 -97
- package/dist/cli.js +4 -4
- package/dist/{demo-KVFAVGO7.js → demo-5I4BNHW2.js} +31 -3
- package/dist/{seed-VALN2TCQ.js → seed-BLFBCHIA.js} +1 -1
- package/dist/{server-SOCS3SZJ.js → server-UI6WYRTL.js} +251 -36
- package/package.json +1 -1
- package/prompts/morning-brief-personal.md +1 -0
- package/prompts/morning-brief-work.md +8 -5
- package/public/app.js +41 -11
- package/public/style.css +7 -1
package/README.md
CHANGED
|
@@ -90,8 +90,10 @@ The second tab shows your open pull requests in seven buckets:
|
|
|
90
90
|
| Drafts | Pull requests you haven't marked ready for review. |
|
|
91
91
|
|
|
92
92
|
Park a pull request until a date, leave a note, or click **Nudged** after asking
|
|
93
|
-
for a review. Nudged records
|
|
94
|
-
|
|
93
|
+
for a review. Nudged records that you asked; it doesn't send a message. The pull
|
|
94
|
+
request moves to a Nudged section for a day, then goes back to Waiting on reviewers
|
|
95
|
+
with "time to ask" if nobody has reviewed it. Completing a task about a pull
|
|
96
|
+
request in the brief doesn't remove that pull request from the board.
|
|
95
97
|
|
|
96
98
|
Cancelled checks appear as "cancelled, no verdict" rather than failures.
|
|
97
99
|
When a check is rerun, the board uses its newest workflow run or attempt.
|
|
@@ -111,6 +113,14 @@ Draft pull requests count as open. Closed pull requests don't prove that a ticke
|
|
|
111
113
|
is finished, so the board leaves that decision to you. Park a reminder or leave a
|
|
112
114
|
note when there's more work planned.
|
|
113
115
|
|
|
116
|
+
Jira can take days to hear that a pull request merged, so the board also checks
|
|
117
|
+
GitHub. It searches your open pull requests and the ones closed in the last 60
|
|
118
|
+
days, and matches them to tickets by the key in the title, branch, description
|
|
119
|
+
or commit messages. An open pull request on GitHub always counts. GitHub can
|
|
120
|
+
also mark a ticket's pull requests as all closed, but only when it found at least
|
|
121
|
+
as many as Jira has linked. A row that GitHub changed lists those pull requests
|
|
122
|
+
under **GitHub, ahead of Jira**.
|
|
123
|
+
|
|
114
124
|
Switch to **Working on** to see your in-progress tickets grouped by status,
|
|
115
125
|
including ones that also need a status check. You can narrow this view in
|
|
116
126
|
[SETUP.md](SETUP.md#jira).
|
package/SETUP.md
CHANGED
|
@@ -237,6 +237,11 @@ Clicking a status in the dashboard writes one transition to Jira. All other Jira
|
|
|
237
237
|
access is read-only. See the [ticket board guide](README.md#the-jira-ticket-board)
|
|
238
238
|
for workflow limits and undo behavior.
|
|
239
239
|
|
|
240
|
+
The board also asks GitHub about your pull requests, because Jira can learn of
|
|
241
|
+
them days late. It uses the pull request board's accounts and scope, and nothing
|
|
242
|
+
else needs to be configured. With `DAILY_FOCUS_GITHUB=off`, the ticket board uses
|
|
243
|
+
Jira alone.
|
|
244
|
+
|
|
240
245
|
Set `DAILY_FOCUS_JIRA=off` to disable the board.
|
|
241
246
|
|
|
242
247
|
### Live calendar on macOS
|
|
@@ -519,7 +524,7 @@ matters when `DAILY_FOCUS_FREE_WINDOWS` is off.
|
|
|
519
524
|
| `DAILY_FOCUS_AGENT_DAYS` | `1-5` | Weekdays the dashboard runs the agent on, cron-style and cron-numbered: `1-5` for Monday to Friday, `0-4` for Sunday to Thursday, `0,6` for a weekend-only run |
|
|
520
525
|
| `DAILY_FOCUS_SESSION_MINUTES` | `25` | Default focus session length |
|
|
521
526
|
| `DAILY_FOCUS_AWAY_AFTER` | `10`; `0` for personal | Minutes of an untouched machine before a session is closed at the last sign of life. `0` turns it off |
|
|
522
|
-
| `DAILY_FOCUS_GITHUB` | `on` | `off` disables the pull request board; nothing is polled |
|
|
527
|
+
| `DAILY_FOCUS_GITHUB` | `on` | `off` disables the pull request board, and the ticket board stops checking GitHub; nothing is polled |
|
|
523
528
|
| `DAILY_FOCUS_GITHUB_ACCOUNTS` | *gh's active account* | Logins to poll as, comma-separated, each resolved with `gh auth token --user` |
|
|
524
529
|
| `DAILY_FOCUS_GITHUB_SCOPE` | *everything* | Organisations or `owner/repo` entries to limit the board to, comma-separated |
|
|
525
530
|
| `DAILY_FOCUS_GITHUB_MERGE_GATE_CHECKS` | *none* | Check names that stand for a repository's whole merge policy, comma-separated and matched exactly. Their rows get the *Waiting on merge gate* bucket |
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
readSystemZone,
|
|
3
3
|
startingZone
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-4DCBCR4D.js";
|
|
5
5
|
import {
|
|
6
6
|
linkState
|
|
7
7
|
} from "./chunk-QKT2FAB5.js";
|
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
parseBrief,
|
|
12
12
|
readArchiveIndex,
|
|
13
13
|
resolveItems,
|
|
14
|
-
runContractChecks
|
|
15
|
-
|
|
14
|
+
runContractChecks,
|
|
15
|
+
setsStatus
|
|
16
|
+
} from "./chunk-JQGYCWRX.js";
|
|
16
17
|
import {
|
|
17
18
|
BRIEF_REFRESH_GRACE_HOURS,
|
|
18
19
|
describeSchedule,
|
|
@@ -180,7 +181,7 @@ if (!previous) {
|
|
|
180
181
|
const reproduced = [...previousIds].filter((id) => currentIds.has(id));
|
|
181
182
|
const dropped = [...previousIds].filter((id) => !currentIds.has(id));
|
|
182
183
|
const added = [...currentIds].filter((id) => !previousIds.has(id));
|
|
183
|
-
const actedOn = new Set(actions.filter(
|
|
184
|
+
const actedOn = new Set(actions.filter(setsStatus).map((a) => a.id));
|
|
184
185
|
const addedFingerprints = new Map(added.map((id) => [fingerprintId(id), id]));
|
|
185
186
|
const handled = dropped.filter((id) => actedOn.has(id));
|
|
186
187
|
const drifted = dropped.filter((id) => !actedOn.has(id) && addedFingerprints.has(fingerprintId(id)));
|
|
@@ -38,101 +38,6 @@ function fingerprintId(id) {
|
|
|
38
38
|
return id.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// src/archive.ts
|
|
42
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
43
|
-
import { resolve } from "node:path";
|
|
44
|
-
var ARCHIVE_FILE = /^items-(\d{4}-\d{2}-\d{2})\.json$/;
|
|
45
|
-
var MAX_ARCHIVE_FILES = 130;
|
|
46
|
-
var RECENT_WINDOW_DAYS = 14;
|
|
47
|
-
function archivePath(config, dateKey) {
|
|
48
|
-
return resolve(config.archiveDir, `items-${dateKey}.json`);
|
|
49
|
-
}
|
|
50
|
-
function briefDateKey(brief) {
|
|
51
|
-
if (brief.date && /^\d{4}-\d{2}-\d{2}$/.test(brief.date)) return brief.date;
|
|
52
|
-
const generated = parseISO(brief.generatedAt);
|
|
53
|
-
return localDateKey(generated ?? /* @__PURE__ */ new Date());
|
|
54
|
-
}
|
|
55
|
-
async function archiveBrief(config, brief) {
|
|
56
|
-
const dateKey = briefDateKey(brief);
|
|
57
|
-
const target = archivePath(config, dateKey);
|
|
58
|
-
try {
|
|
59
|
-
const existing = JSON.parse(await readFile(target, "utf8"));
|
|
60
|
-
const existingAt = parseISO(existing.generatedAt)?.getTime() ?? 0;
|
|
61
|
-
const incomingAt = parseISO(brief.generatedAt)?.getTime() ?? 0;
|
|
62
|
-
if (existingAt >= incomingAt) return false;
|
|
63
|
-
} catch {
|
|
64
|
-
}
|
|
65
|
-
await writeJsonAtomic(target, brief);
|
|
66
|
-
return true;
|
|
67
|
-
}
|
|
68
|
-
async function listArchivedDates(config) {
|
|
69
|
-
let names;
|
|
70
|
-
try {
|
|
71
|
-
names = await readdir(config.archiveDir);
|
|
72
|
-
} catch {
|
|
73
|
-
return [];
|
|
74
|
-
}
|
|
75
|
-
return names.map((name) => ARCHIVE_FILE.exec(name)?.[1]).filter((date) => date !== void 0).sort();
|
|
76
|
-
}
|
|
77
|
-
async function readArchiveIndex(config) {
|
|
78
|
-
const index = /* @__PURE__ */ new Map();
|
|
79
|
-
const dated = (await listArchivedDates(config)).reverse().slice(0, MAX_ARCHIVE_FILES);
|
|
80
|
-
for (const date of dated) {
|
|
81
|
-
let brief;
|
|
82
|
-
try {
|
|
83
|
-
brief = JSON.parse(await readFile(archivePath(config, date), "utf8"));
|
|
84
|
-
} catch {
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
for (const item of brief.items ?? []) {
|
|
88
|
-
if (!item?.id) continue;
|
|
89
|
-
const existing = index.get(item.id);
|
|
90
|
-
if (!existing) {
|
|
91
|
-
index.set(item.id, {
|
|
92
|
-
title: item.title ?? item.id,
|
|
93
|
-
advancesObjective: item.advancesObjective === true,
|
|
94
|
-
firstSeen: item.firstSeen
|
|
95
|
-
});
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (item.firstSeen && (!existing.firstSeen || item.firstSeen < existing.firstSeen)) {
|
|
99
|
-
existing.firstSeen = item.firstSeen;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return index;
|
|
104
|
-
}
|
|
105
|
-
function computeObjectiveProgress(index, actions, now, counts = isWorkingDay) {
|
|
106
|
-
const lastAction = /* @__PURE__ */ new Map();
|
|
107
|
-
for (const action of actions) {
|
|
108
|
-
if (action.action === "note") continue;
|
|
109
|
-
lastAction.set(canonicalId(action.id), action);
|
|
110
|
-
}
|
|
111
|
-
const byCanonical = /* @__PURE__ */ new Map();
|
|
112
|
-
for (const [id, entry] of index) byCanonical.set(canonicalId(id), entry);
|
|
113
|
-
let lastAt = null;
|
|
114
|
-
let lastTitle = null;
|
|
115
|
-
let recentCount = 0;
|
|
116
|
-
for (const [id, action] of lastAction) {
|
|
117
|
-
if (action.action !== "done") continue;
|
|
118
|
-
if (!byCanonical.get(id)?.advancesObjective) continue;
|
|
119
|
-
const at = parseISO(action.at);
|
|
120
|
-
if (!at) continue;
|
|
121
|
-
if (workingDaysSince(at, now, counts) <= RECENT_WINDOW_DAYS) recentCount++;
|
|
122
|
-
if (!lastAt || at.getTime() > lastAt.getTime()) {
|
|
123
|
-
lastAt = at;
|
|
124
|
-
lastTitle = byCanonical.get(id)?.title ?? null;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return {
|
|
128
|
-
workingDaysSince: lastAt ? workingDaysSince(lastAt, now, counts) : null,
|
|
129
|
-
lastAt: lastAt ? lastAt.toISOString() : null,
|
|
130
|
-
lastTitle,
|
|
131
|
-
recentCount,
|
|
132
|
-
windowDays: RECENT_WINDOW_DAYS
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
41
|
// src/validate.ts
|
|
137
42
|
var SOURCES = /* @__PURE__ */ new Set([
|
|
138
43
|
"github",
|
|
@@ -300,6 +205,10 @@ function parseBrief(text) {
|
|
|
300
205
|
if (raw.dayEnd !== void 0 && !dayEnd) warnings.push('items.json has an unreadable "dayEnd"; using the default');
|
|
301
206
|
return { brief, error: null, warnings };
|
|
302
207
|
}
|
|
208
|
+
var ACTION_TYPES = ["done", "snooze", "dismiss", "reopen", "note", "nudge"];
|
|
209
|
+
function setsStatus(action) {
|
|
210
|
+
return action.action !== "note" && action.action !== "nudge";
|
|
211
|
+
}
|
|
303
212
|
function parseActionLine(line) {
|
|
304
213
|
const trimmed = line.trim();
|
|
305
214
|
if (trimmed === "") return null;
|
|
@@ -313,7 +222,7 @@ function parseActionLine(line) {
|
|
|
313
222
|
const id = optionalString(raw.id);
|
|
314
223
|
const action = optionalString(raw.action);
|
|
315
224
|
if (!id || !action) return null;
|
|
316
|
-
if (!
|
|
225
|
+
if (!ACTION_TYPES.includes(action)) return null;
|
|
317
226
|
const result = {
|
|
318
227
|
id,
|
|
319
228
|
action,
|
|
@@ -326,6 +235,101 @@ function parseActionLine(line) {
|
|
|
326
235
|
return result;
|
|
327
236
|
}
|
|
328
237
|
|
|
238
|
+
// src/archive.ts
|
|
239
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
240
|
+
import { resolve } from "node:path";
|
|
241
|
+
var ARCHIVE_FILE = /^items-(\d{4}-\d{2}-\d{2})\.json$/;
|
|
242
|
+
var MAX_ARCHIVE_FILES = 130;
|
|
243
|
+
var RECENT_WINDOW_DAYS = 14;
|
|
244
|
+
function archivePath(config, dateKey) {
|
|
245
|
+
return resolve(config.archiveDir, `items-${dateKey}.json`);
|
|
246
|
+
}
|
|
247
|
+
function briefDateKey(brief) {
|
|
248
|
+
if (brief.date && /^\d{4}-\d{2}-\d{2}$/.test(brief.date)) return brief.date;
|
|
249
|
+
const generated = parseISO(brief.generatedAt);
|
|
250
|
+
return localDateKey(generated ?? /* @__PURE__ */ new Date());
|
|
251
|
+
}
|
|
252
|
+
async function archiveBrief(config, brief) {
|
|
253
|
+
const dateKey = briefDateKey(brief);
|
|
254
|
+
const target = archivePath(config, dateKey);
|
|
255
|
+
try {
|
|
256
|
+
const existing = JSON.parse(await readFile(target, "utf8"));
|
|
257
|
+
const existingAt = parseISO(existing.generatedAt)?.getTime() ?? 0;
|
|
258
|
+
const incomingAt = parseISO(brief.generatedAt)?.getTime() ?? 0;
|
|
259
|
+
if (existingAt >= incomingAt) return false;
|
|
260
|
+
} catch {
|
|
261
|
+
}
|
|
262
|
+
await writeJsonAtomic(target, brief);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
async function listArchivedDates(config) {
|
|
266
|
+
let names;
|
|
267
|
+
try {
|
|
268
|
+
names = await readdir(config.archiveDir);
|
|
269
|
+
} catch {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
return names.map((name) => ARCHIVE_FILE.exec(name)?.[1]).filter((date) => date !== void 0).sort();
|
|
273
|
+
}
|
|
274
|
+
async function readArchiveIndex(config) {
|
|
275
|
+
const index = /* @__PURE__ */ new Map();
|
|
276
|
+
const dated = (await listArchivedDates(config)).reverse().slice(0, MAX_ARCHIVE_FILES);
|
|
277
|
+
for (const date of dated) {
|
|
278
|
+
let brief;
|
|
279
|
+
try {
|
|
280
|
+
brief = JSON.parse(await readFile(archivePath(config, date), "utf8"));
|
|
281
|
+
} catch {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
for (const item of brief.items ?? []) {
|
|
285
|
+
if (!item?.id) continue;
|
|
286
|
+
const existing = index.get(item.id);
|
|
287
|
+
if (!existing) {
|
|
288
|
+
index.set(item.id, {
|
|
289
|
+
title: item.title ?? item.id,
|
|
290
|
+
advancesObjective: item.advancesObjective === true,
|
|
291
|
+
firstSeen: item.firstSeen
|
|
292
|
+
});
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (item.firstSeen && (!existing.firstSeen || item.firstSeen < existing.firstSeen)) {
|
|
296
|
+
existing.firstSeen = item.firstSeen;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return index;
|
|
301
|
+
}
|
|
302
|
+
function computeObjectiveProgress(index, actions, now, counts = isWorkingDay) {
|
|
303
|
+
const lastAction = /* @__PURE__ */ new Map();
|
|
304
|
+
for (const action of actions) {
|
|
305
|
+
if (!setsStatus(action)) continue;
|
|
306
|
+
lastAction.set(canonicalId(action.id), action);
|
|
307
|
+
}
|
|
308
|
+
const byCanonical = /* @__PURE__ */ new Map();
|
|
309
|
+
for (const [id, entry] of index) byCanonical.set(canonicalId(id), entry);
|
|
310
|
+
let lastAt = null;
|
|
311
|
+
let lastTitle = null;
|
|
312
|
+
let recentCount = 0;
|
|
313
|
+
for (const [id, action] of lastAction) {
|
|
314
|
+
if (action.action !== "done") continue;
|
|
315
|
+
if (!byCanonical.get(id)?.advancesObjective) continue;
|
|
316
|
+
const at = parseISO(action.at);
|
|
317
|
+
if (!at) continue;
|
|
318
|
+
if (workingDaysSince(at, now, counts) <= RECENT_WINDOW_DAYS) recentCount++;
|
|
319
|
+
if (!lastAt || at.getTime() > lastAt.getTime()) {
|
|
320
|
+
lastAt = at;
|
|
321
|
+
lastTitle = byCanonical.get(id)?.title ?? null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
workingDaysSince: lastAt ? workingDaysSince(lastAt, now, counts) : null,
|
|
326
|
+
lastAt: lastAt ? lastAt.toISOString() : null,
|
|
327
|
+
lastTitle,
|
|
328
|
+
recentCount,
|
|
329
|
+
windowDays: RECENT_WINDOW_DAYS
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
329
333
|
// src/checks.ts
|
|
330
334
|
var GENEROUS_FLAG_THRESHOLD = 5;
|
|
331
335
|
function runContractChecks({ brief, items, focus, history }) {
|
|
@@ -931,6 +935,9 @@ function foldActionLog(actions, now) {
|
|
|
931
935
|
case "note":
|
|
932
936
|
if (action.text) entry.notes.push({ text: action.text, at: action.at });
|
|
933
937
|
break;
|
|
938
|
+
case "nudge":
|
|
939
|
+
entry.nudgedAt = action.at;
|
|
940
|
+
break;
|
|
934
941
|
case "done":
|
|
935
942
|
entry.status = "done";
|
|
936
943
|
delete entry.snoozedUntil;
|
|
@@ -986,8 +993,10 @@ export {
|
|
|
986
993
|
tempPathFor,
|
|
987
994
|
canonicalId,
|
|
988
995
|
fingerprintId,
|
|
989
|
-
readArchiveIndex,
|
|
990
996
|
parseBrief,
|
|
997
|
+
ACTION_TYPES,
|
|
998
|
+
setsStatus,
|
|
999
|
+
readArchiveIndex,
|
|
991
1000
|
runContractChecks,
|
|
992
1001
|
startSession,
|
|
993
1002
|
stopSession,
|
package/dist/cli.js
CHANGED
|
@@ -49,7 +49,7 @@ function shouldOpen(flag) {
|
|
|
49
49
|
return flag && process.stdout.isTTY === true && !process.env.CI;
|
|
50
50
|
}
|
|
51
51
|
async function demo(open, profile) {
|
|
52
|
-
const { demoEnv, demoIntegrations, demoProfile, writeDemoStore } = await import("./demo-
|
|
52
|
+
const { demoEnv, demoIntegrations, demoProfile, writeDemoStore } = await import("./demo-5I4BNHW2.js");
|
|
53
53
|
const chosen = demoProfile(profile);
|
|
54
54
|
const store = await mkdtemp(join(tmpdir(), "daily-focus-demo-"));
|
|
55
55
|
process.on("exit", () => rmSync(store, { recursive: true, force: true }));
|
|
@@ -65,7 +65,7 @@ async function demo(open, profile) {
|
|
|
65
65
|
);
|
|
66
66
|
}
|
|
67
67
|
async function start(open, onStarted, options = {}) {
|
|
68
|
-
const { runServer } = await import("./server-
|
|
68
|
+
const { runServer } = await import("./server-UI6WYRTL.js");
|
|
69
69
|
await runServer({
|
|
70
70
|
...options,
|
|
71
71
|
onStarted: (url) => {
|
|
@@ -172,10 +172,10 @@ async function main(argv) {
|
|
|
172
172
|
await import("./init-57WHZZNG.js");
|
|
173
173
|
return;
|
|
174
174
|
case "audit":
|
|
175
|
-
await import("./audit-
|
|
175
|
+
await import("./audit-7YAJYHZF.js");
|
|
176
176
|
return;
|
|
177
177
|
case "seed":
|
|
178
|
-
await import("./seed-
|
|
178
|
+
await import("./seed-BLFBCHIA.js");
|
|
179
179
|
return;
|
|
180
180
|
case "build-calendar":
|
|
181
181
|
return buildCalendar();
|
|
@@ -156,7 +156,7 @@ function demoPulls(profile, now = /* @__PURE__ */ new Date()) {
|
|
|
156
156
|
lastActivityByYou: daysAgo(now, 1),
|
|
157
157
|
lastActivityByOthers: { at: hoursAgo(now, 2), login: "chris", kind: "review" }
|
|
158
158
|
}),
|
|
159
|
-
// Waiting on reviewers: one long enough to ask, one fresh.
|
|
159
|
+
// Waiting on reviewers: one long enough to ask, one fresh, and one nudged this morning.
|
|
160
160
|
pull({
|
|
161
161
|
repo: "acme/webapp",
|
|
162
162
|
number: 3395,
|
|
@@ -175,6 +175,15 @@ function demoPulls(profile, now = /* @__PURE__ */ new Date()) {
|
|
|
175
175
|
requestedReviewers: ["dana", "eli"],
|
|
176
176
|
mergeStateStatus: "BLOCKED"
|
|
177
177
|
}),
|
|
178
|
+
pull({
|
|
179
|
+
repo: "acme/webapp",
|
|
180
|
+
number: 3405,
|
|
181
|
+
title: "Sample session replays by route",
|
|
182
|
+
createdAt: daysAgo(now, 2),
|
|
183
|
+
reviewDecision: "REVIEW_REQUIRED",
|
|
184
|
+
requestedReviewers: ["chris"],
|
|
185
|
+
mergeStateStatus: "BLOCKED"
|
|
186
|
+
}),
|
|
178
187
|
// Waiting on the merge gate: approved, and the repository's own policy check still pending.
|
|
179
188
|
pull({
|
|
180
189
|
repo: "acme/webapp",
|
|
@@ -321,12 +330,26 @@ function ticket(key, summary, workflowStatus, issueType, prs) {
|
|
|
321
330
|
allPrsClosed: prs === "closed"
|
|
322
331
|
};
|
|
323
332
|
}
|
|
324
|
-
function demoTickets() {
|
|
333
|
+
function demoTickets(now = /* @__PURE__ */ new Date()) {
|
|
325
334
|
return {
|
|
326
335
|
tickets: [
|
|
327
336
|
// Every pull request closed, and the ticket not Done.
|
|
328
337
|
ticket("PROJ-8842", "Add trace context to outgoing fetches", "In Review", "Task", "closed"),
|
|
329
|
-
|
|
338
|
+
// The same, known only from GitHub: Jira still has the pull request open.
|
|
339
|
+
{
|
|
340
|
+
...ticket("PLAT-311", "Remove the legacy beacon endpoint", "In Progress", "Task", "closed"),
|
|
341
|
+
pulls: [
|
|
342
|
+
{
|
|
343
|
+
repo: "acme/infra",
|
|
344
|
+
number: 514,
|
|
345
|
+
title: "PLAT-311: drop the /v1/beacon route",
|
|
346
|
+
url: "https://github.com/acme/infra/pull/514",
|
|
347
|
+
state: "merged",
|
|
348
|
+
closedAt: hoursAgo(now, 5)
|
|
349
|
+
}
|
|
350
|
+
],
|
|
351
|
+
fromGitHub: true
|
|
352
|
+
},
|
|
330
353
|
// A pull request open while the status says the work has not begun.
|
|
331
354
|
ticket("PROJ-8871", "Instrument long tasks on the main thread", "To Do", "Story", "open"),
|
|
332
355
|
ticket("PROJ-8877", "Document the sampling knobs", "To Do", "Task", "open"),
|
|
@@ -366,6 +389,10 @@ function demoIntegrations(profile) {
|
|
|
366
389
|
async fetch() {
|
|
367
390
|
return { tickets: world.tickets.map((entry) => ({ ...entry })), statuses: world.statuses, warnings: [] };
|
|
368
391
|
},
|
|
392
|
+
// The sample tickets already carry what GitHub would have added.
|
|
393
|
+
async linked() {
|
|
394
|
+
return { byKey: /* @__PURE__ */ new Map(), complete: true, warnings: [] };
|
|
395
|
+
},
|
|
369
396
|
async transition(_acli, key, status) {
|
|
370
397
|
const found = world.tickets.find((entry) => entry.key === key);
|
|
371
398
|
if (!found) throw new Error(`no work item ${key} in the sample data`);
|
|
@@ -551,6 +578,7 @@ function demoActions(profile, now) {
|
|
|
551
578
|
{ id: "github:pr:acme/webapp#3360", action: "note", text: "Revisit after the v6 cut", at: daysAgo(now, 1) },
|
|
552
579
|
{ id: "github:pr:acme/webapp#3360", action: "snooze", until: until(30), at: daysAgo(now, 1) },
|
|
553
580
|
{ id: "jira:PROJ-8865", action: "note", text: "Docs only \u2014 no pull request expected", at: daysAgo(now, 1) },
|
|
581
|
+
{ id: "github:pr:acme/webapp#3405", action: "nudge", at: hoursAgo(now, 3) },
|
|
554
582
|
{ id: "github:pr:acme/webapp#3430", action: "done", at: minutesAgo(now, 140) },
|
|
555
583
|
{ id: "email:thread:18f31c0d9e", action: "note", text: "Registration runs to the 28th; no rush", at: minutesAgo(now, 131) },
|
|
556
584
|
{ id: "email:thread:18f31c0d9e", action: "snooze", until: until(7), at: minutesAgo(now, 130) },
|
|
@@ -11,13 +11,14 @@ import {
|
|
|
11
11
|
import {
|
|
12
12
|
TimeZoneKeeper,
|
|
13
13
|
validZone
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-4DCBCR4D.js";
|
|
15
15
|
import {
|
|
16
16
|
describeLink,
|
|
17
17
|
linkStore,
|
|
18
18
|
storeLinkWarning
|
|
19
19
|
} from "./chunk-QKT2FAB5.js";
|
|
20
20
|
import {
|
|
21
|
+
ACTION_TYPES,
|
|
21
22
|
Store,
|
|
22
23
|
canonicalId,
|
|
23
24
|
foldActionLog,
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
stopSession,
|
|
27
28
|
tempPathFor,
|
|
28
29
|
writeJsonAtomic
|
|
29
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-JQGYCWRX.js";
|
|
30
31
|
import {
|
|
31
32
|
CLI_NAMES,
|
|
32
33
|
LAUNCH_SETTINGS,
|
|
@@ -871,6 +872,85 @@ async function fetchPulls(token, scope, expectedLogin, signal) {
|
|
|
871
872
|
}
|
|
872
873
|
return { login, pulls, warnings, orgs, rateLimitRemaining };
|
|
873
874
|
}
|
|
875
|
+
var TICKET_PAGE_SIZE = 25;
|
|
876
|
+
var TICKET_MAX_PAGES = 12;
|
|
877
|
+
var TICKET_COMMITS = 100;
|
|
878
|
+
var TICKET_SEARCH_QUERY = `
|
|
879
|
+
query($q: String!, $first: Int!, $after: String) {
|
|
880
|
+
search(query: $q, type: ISSUE, first: $first, after: $after) {
|
|
881
|
+
pageInfo { hasNextPage endCursor }
|
|
882
|
+
nodes {
|
|
883
|
+
... on PullRequest {
|
|
884
|
+
number
|
|
885
|
+
title
|
|
886
|
+
url
|
|
887
|
+
state
|
|
888
|
+
isDraft
|
|
889
|
+
closedAt
|
|
890
|
+
mergedAt
|
|
891
|
+
headRefName
|
|
892
|
+
body
|
|
893
|
+
repository { nameWithOwner }
|
|
894
|
+
commits(first: ${TICKET_COMMITS}) { nodes { commit { message } } }
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}`;
|
|
899
|
+
function buildTicketSearchQueries(scope, closedSince) {
|
|
900
|
+
return [
|
|
901
|
+
["is:pr", "author:@me", "is:open", ...scope].join(" "),
|
|
902
|
+
["is:pr", "author:@me", "is:closed", `closed:>=${closedSince}`, ...scope].join(" ")
|
|
903
|
+
];
|
|
904
|
+
}
|
|
905
|
+
function toLinkedState(state, isDraft) {
|
|
906
|
+
if (state === "MERGED") return "merged";
|
|
907
|
+
if (state === "CLOSED") return "closed";
|
|
908
|
+
if (state === "OPEN") return isDraft === true ? "draft" : "open";
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
function normalizeTicketPull(raw) {
|
|
912
|
+
const repo = raw.repository?.nameWithOwner;
|
|
913
|
+
const url = safeUrl(raw.url);
|
|
914
|
+
const state = toLinkedState(raw.state, raw.isDraft);
|
|
915
|
+
if (typeof raw.number !== "number" || typeof repo !== "string" || typeof raw.title !== "string" || !url || !state) {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
const closedAt = state === "merged" ? raw.mergedAt ?? raw.closedAt ?? null : state === "closed" ? raw.closedAt ?? null : null;
|
|
919
|
+
const text = [raw.title, raw.headRefName ?? "", raw.body ?? ""];
|
|
920
|
+
for (const node of raw.commits?.nodes ?? []) {
|
|
921
|
+
const message = node?.commit?.message;
|
|
922
|
+
if (typeof message === "string") text.push(message);
|
|
923
|
+
}
|
|
924
|
+
return { pull: { repo, number: raw.number, title: raw.title, url, state, closedAt }, text };
|
|
925
|
+
}
|
|
926
|
+
async function fetchTicketPulls(token, scope, closedSince, signal) {
|
|
927
|
+
const pulls = [];
|
|
928
|
+
let complete = true;
|
|
929
|
+
for (const q of buildTicketSearchQueries(scope, closedSince)) {
|
|
930
|
+
let after = null;
|
|
931
|
+
for (let page = 0; ; page++) {
|
|
932
|
+
const result = await graphql(token, TICKET_SEARCH_QUERY, { q, first: TICKET_PAGE_SIZE, after }, signal);
|
|
933
|
+
const fatal = result.errors.filter((error) => !isSamlError(error));
|
|
934
|
+
if (result.data?.search === void 0 && fatal.length > 0) {
|
|
935
|
+
throw new GitHubRequestError(`GitHub search failed: ${fatal.map((error) => error.message).join("; ")}`);
|
|
936
|
+
}
|
|
937
|
+
if (result.errors.length > 0) complete = false;
|
|
938
|
+
for (const node of result.data?.search?.nodes ?? []) {
|
|
939
|
+
const found = node ? normalizeTicketPull(node) : null;
|
|
940
|
+
if (found) pulls.push(found);
|
|
941
|
+
else complete = false;
|
|
942
|
+
}
|
|
943
|
+
const pageInfo = result.data?.search?.pageInfo;
|
|
944
|
+
if (!pageInfo?.hasNextPage || !pageInfo.endCursor) break;
|
|
945
|
+
if (page === TICKET_MAX_PAGES - 1) {
|
|
946
|
+
complete = false;
|
|
947
|
+
break;
|
|
948
|
+
}
|
|
949
|
+
after = pageInfo.endCursor;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return { pulls, complete };
|
|
953
|
+
}
|
|
874
954
|
|
|
875
955
|
// src/prs.ts
|
|
876
956
|
var NUDGE_AFTER_MS = 24 * 36e5;
|
|
@@ -896,7 +976,7 @@ function matchMergeGates(pr, gates) {
|
|
|
896
976
|
function newest(pr, state) {
|
|
897
977
|
return pr.reviews.filter((review) => review.state === state).sort((a, b) => a.at < b.at ? 1 : -1)[0];
|
|
898
978
|
}
|
|
899
|
-
function judge(pr, now,
|
|
979
|
+
function judge(pr, now, nudgedAt, gates = []) {
|
|
900
980
|
const decision = deriveDecision(pr);
|
|
901
981
|
const you = pr.lastActivityByYou ?? null;
|
|
902
982
|
const others = pr.lastActivityByOthers ?? null;
|
|
@@ -958,18 +1038,22 @@ function judge(pr, now, lastNoteAt, gates = []) {
|
|
|
958
1038
|
}
|
|
959
1039
|
}
|
|
960
1040
|
}
|
|
961
|
-
const quietSince = laterISO(waitingSince,
|
|
1041
|
+
const quietSince = laterISO(waitingSince, nudgedAt) ?? waitingSince;
|
|
962
1042
|
const waited = now.getTime() - (ms(quietSince) ?? now.getTime());
|
|
963
|
-
|
|
1043
|
+
const verdict = { court: "reviewers", decision, reasons, stale: false, nudge: waited >= NUDGE_AFTER_MS, since: waitingSince };
|
|
1044
|
+
const nudged = ms(nudgedAt);
|
|
1045
|
+
if (nudgedAt && nudged !== null && quietSince === nudgedAt && !verdict.nudge) {
|
|
1046
|
+
verdict.nudgedAt = nudgedAt;
|
|
1047
|
+
verdict.nudgedUntil = new Date(nudged + NUDGE_AFTER_MS).toISOString();
|
|
1048
|
+
}
|
|
1049
|
+
return verdict;
|
|
964
1050
|
}
|
|
965
1051
|
function resolveBoard(pulls, actions, now, gates = []) {
|
|
966
1052
|
const folded = foldActionLog(actions, now);
|
|
967
1053
|
const rows = pulls.map((pr) => {
|
|
968
1054
|
const log = folded.get(canonicalId(pr.id));
|
|
969
|
-
const
|
|
970
|
-
const
|
|
971
|
-
const verdict = judge(pr, now, lastNoteAt, gates);
|
|
972
|
-
const row = { ...pr, ...verdict, status: log?.status === "snoozed" ? "snoozed" : "open", notes };
|
|
1055
|
+
const verdict = judge(pr, now, log?.nudgedAt ?? null, gates);
|
|
1056
|
+
const row = { ...pr, ...verdict, status: log?.status === "snoozed" ? "snoozed" : "open", notes: log?.notes ?? [] };
|
|
973
1057
|
if (row.status === "snoozed" && log?.snoozedUntil) row.snoozedUntil = log.snoozedUntil;
|
|
974
1058
|
return row;
|
|
975
1059
|
});
|
|
@@ -1694,6 +1778,87 @@ import { readFile as readFile5 } from "node:fs/promises";
|
|
|
1694
1778
|
// src/jira.ts
|
|
1695
1779
|
import { execFile as execFile3 } from "node:child_process";
|
|
1696
1780
|
import { promisify as promisify3 } from "node:util";
|
|
1781
|
+
|
|
1782
|
+
// src/linked.ts
|
|
1783
|
+
var CLOSED_LOOKBACK_DAYS = 60;
|
|
1784
|
+
var KEYISH = /(?<![A-Za-z0-9])([A-Za-z][A-Za-z0-9_]*)-(\d+)/g;
|
|
1785
|
+
function issueKeysIn(text) {
|
|
1786
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1787
|
+
for (const match of text.matchAll(KEYISH)) {
|
|
1788
|
+
const parts = match[1].split("_");
|
|
1789
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1790
|
+
const project = parts.slice(i).join("_");
|
|
1791
|
+
if (/^[A-Za-z]/.test(project)) keys.add(`${project.toUpperCase()}-${match[2]}`);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
return keys;
|
|
1795
|
+
}
|
|
1796
|
+
var realDeps3 = { token: ghToken, search: fetchTicketPulls };
|
|
1797
|
+
var CONSEQUENCE = "so a merged pull request counts only once Jira has it";
|
|
1798
|
+
async function readLinkedPulls(github, now, signal, deps = realDeps3) {
|
|
1799
|
+
const closedSince = new Date(now.getTime() - CLOSED_LOOKBACK_DAYS * 864e5).toISOString().slice(0, 10);
|
|
1800
|
+
const logins = github.accounts.length > 0 ? [...github.accounts] : [null];
|
|
1801
|
+
const results = await Promise.allSettled(
|
|
1802
|
+
logins.map(async (login) => deps.search(await deps.token(github.ghPath, login), github.scope, closedSince, signal))
|
|
1803
|
+
);
|
|
1804
|
+
if (results.every((result) => result.status === "rejected")) {
|
|
1805
|
+
throw results[0].reason;
|
|
1806
|
+
}
|
|
1807
|
+
const warnings = [];
|
|
1808
|
+
let complete = true;
|
|
1809
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1810
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1811
|
+
results.forEach((result, i) => {
|
|
1812
|
+
const who = logins[i] ? ` as ${logins[i]}` : "";
|
|
1813
|
+
if (result.status === "rejected") {
|
|
1814
|
+
complete = false;
|
|
1815
|
+
warnings.push(`Could not ask GitHub${who} about these tickets' pull requests (${result.reason.message}), ${CONSEQUENCE}.`);
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
if (!result.value.complete) {
|
|
1819
|
+
complete = false;
|
|
1820
|
+
warnings.push(`GitHub did not list every pull request${who}, ${CONSEQUENCE}.`);
|
|
1821
|
+
}
|
|
1822
|
+
for (const { pull, text } of result.value.pulls) {
|
|
1823
|
+
if (seen.has(pull.url)) continue;
|
|
1824
|
+
seen.add(pull.url);
|
|
1825
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1826
|
+
for (const piece of text) for (const key of issueKeysIn(piece)) keys.add(key);
|
|
1827
|
+
for (const key of keys) byKey.set(key, [...byKey.get(key) ?? [], pull]);
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
return { byKey, complete, warnings };
|
|
1831
|
+
}
|
|
1832
|
+
function isOpen(pull) {
|
|
1833
|
+
return pull.state === "open" || pull.state === "draft";
|
|
1834
|
+
}
|
|
1835
|
+
function byRecency(a, b) {
|
|
1836
|
+
if (isOpen(a) !== isOpen(b)) return isOpen(a) ? -1 : 1;
|
|
1837
|
+
return (b.closedAt ?? "").localeCompare(a.closedAt ?? "");
|
|
1838
|
+
}
|
|
1839
|
+
function linkedTo(ticket, linked) {
|
|
1840
|
+
return linked?.byKey.get(ticket.key.toUpperCase()) ?? [];
|
|
1841
|
+
}
|
|
1842
|
+
function needsJiraCount(ticket, linked) {
|
|
1843
|
+
const found = linkedTo(ticket, linked);
|
|
1844
|
+
return Boolean(linked?.complete) && found.length > 0 && !found.some(isOpen) && ticket.hasAnyPr && !ticket.allPrsClosed;
|
|
1845
|
+
}
|
|
1846
|
+
function reconcile(ticket, linked, jiraCount) {
|
|
1847
|
+
const found = [...linkedTo(ticket, linked)].sort(byRecency);
|
|
1848
|
+
if (found.length === 0) return ticket;
|
|
1849
|
+
const jira = { hasAnyPr: ticket.hasAnyPr, hasOpenPr: ticket.hasOpenPr, allPrsClosed: ticket.allPrsClosed };
|
|
1850
|
+
let reading = jira;
|
|
1851
|
+
if (found.some(isOpen)) {
|
|
1852
|
+
reading = { hasAnyPr: true, hasOpenPr: true, allPrsClosed: false };
|
|
1853
|
+
} else if (linked?.complete && !ticket.allPrsClosed) {
|
|
1854
|
+
const known = ticket.hasAnyPr ? jiraCount : 0;
|
|
1855
|
+
if (known !== null && found.length >= known) reading = { hasAnyPr: true, hasOpenPr: false, allPrsClosed: true };
|
|
1856
|
+
}
|
|
1857
|
+
const changed = reading.hasAnyPr !== jira.hasAnyPr || reading.hasOpenPr !== jira.hasOpenPr || reading.allPrsClosed !== jira.allPrsClosed;
|
|
1858
|
+
return { ...ticket, ...reading, pulls: found, ...changed ? { fromGitHub: true } : {} };
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// src/jira.ts
|
|
1697
1862
|
var run3 = promisify3(execFile3);
|
|
1698
1863
|
var ACLI_TIMEOUT_MS = 45e3;
|
|
1699
1864
|
var PAGE_LIMIT = 200;
|
|
@@ -1977,22 +2142,23 @@ async function fetchTickets(acliPath, opts) {
|
|
|
1977
2142
|
if (unreadable > 0) {
|
|
1978
2143
|
warnings.push(`Skipped ${unreadable} work ${unreadable === 1 ? "item" : "items"} Jira described in a way this board could not read.`);
|
|
1979
2144
|
}
|
|
1980
|
-
const
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
DEV_CONCURRENCY,
|
|
1984
|
-
(ticket) => fetchDevPullRequests(acliPath, ticket.key).then(
|
|
1985
|
-
(dev) => ({ asked: true, dev }),
|
|
1986
|
-
() => ({ asked: false, dev: null })
|
|
1987
|
-
)
|
|
2145
|
+
const readPanel = (ticket) => fetchDevPullRequests(acliPath, ticket.key).then(
|
|
2146
|
+
(dev) => ({ asked: true, dev }),
|
|
2147
|
+
() => ({ asked: false, dev: null })
|
|
1988
2148
|
);
|
|
2149
|
+
const candidates = tickets.filter((ticket) => ticket.hasAnyPr && !ticket.hasOpenPr);
|
|
2150
|
+
const reading = mapLimit(candidates, DEV_CONCURRENCY, readPanel);
|
|
2151
|
+
const linked = await opts.linked ?? null;
|
|
2152
|
+
const panels = await reading;
|
|
2153
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1989
2154
|
const unanswered = [];
|
|
1990
2155
|
const unaccounted = [];
|
|
1991
2156
|
candidates.forEach((ticket, i) => {
|
|
1992
|
-
const { asked, dev } = panels[i];
|
|
2157
|
+
const { asked: asked2, dev } = panels[i];
|
|
2158
|
+
if (dev) counts.set(ticket.key, dev.count);
|
|
1993
2159
|
if (allPullRequestsClosed(dev)) {
|
|
1994
2160
|
ticket.allPrsClosed = true;
|
|
1995
|
-
} else if (!
|
|
2161
|
+
} else if (!asked2) {
|
|
1996
2162
|
unanswered.push(ticket.key);
|
|
1997
2163
|
} else if (dev === null) {
|
|
1998
2164
|
unaccounted.push(ticket.key);
|
|
@@ -2000,19 +2166,30 @@ async function fetchTickets(acliPath, opts) {
|
|
|
2000
2166
|
ticket.hasOpenPr = true;
|
|
2001
2167
|
}
|
|
2002
2168
|
});
|
|
2169
|
+
const asked = new Set(candidates);
|
|
2170
|
+
const behind = tickets.filter((ticket) => !asked.has(ticket) && needsJiraCount(ticket, linked));
|
|
2171
|
+
const extra = await mapLimit(behind, DEV_CONCURRENCY, readPanel);
|
|
2172
|
+
behind.forEach((ticket, i) => {
|
|
2173
|
+
const dev = extra[i].dev;
|
|
2174
|
+
if (dev) counts.set(ticket.key, dev.count);
|
|
2175
|
+
});
|
|
2176
|
+
const reconciled = tickets.map((ticket) => reconcile(ticket, linked, counts.get(ticket.key) ?? null));
|
|
2177
|
+
const undecided = new Set(reconciled.filter((ticket) => !ticket.hasOpenPr && !ticket.allPrsClosed).map((ticket) => ticket.key));
|
|
2178
|
+
const unansweredNow = unanswered.filter((key) => undecided.has(key));
|
|
2179
|
+
const unaccountedNow = unaccounted.filter((key) => undecided.has(key));
|
|
2003
2180
|
const listed = (keys2) => keys2.map((key) => {
|
|
2004
2181
|
const url = browseUrl(opts.site, key);
|
|
2005
2182
|
return url === null ? key : `[${key}](${url})`;
|
|
2006
2183
|
}).join(", ");
|
|
2007
2184
|
const heldOut = (keys2) => `${keys2.length === 1 ? "it is" : "they are"} held out of "no open pull requests left" rather than guessed at`;
|
|
2008
|
-
if (
|
|
2185
|
+
if (unansweredNow.length > 0) {
|
|
2009
2186
|
warnings.push(
|
|
2010
|
-
`Could not read Jira's development panel for ${listed(
|
|
2187
|
+
`Could not read Jira's development panel for ${listed(unansweredNow)}, so ${heldOut(unansweredNow)}. The next read may well manage it.`
|
|
2011
2188
|
);
|
|
2012
2189
|
}
|
|
2013
|
-
if (
|
|
2190
|
+
if (unaccountedNow.length > 0) {
|
|
2014
2191
|
warnings.push(
|
|
2015
|
-
`Jira's search says ${listed(
|
|
2192
|
+
`Jira's search says ${listed(unaccountedNow)} ${unaccountedNow.length === 1 ? "has" : "have"} pull requests, but its own development panel names none, so ${heldOut(unaccountedNow)}. Jira is disagreeing with itself; the row comes back when one of its caches catches up.`
|
|
2016
2193
|
);
|
|
2017
2194
|
}
|
|
2018
2195
|
if (!opts.site) {
|
|
@@ -2029,7 +2206,7 @@ async function fetchTickets(acliPath, opts) {
|
|
|
2029
2206
|
);
|
|
2030
2207
|
}
|
|
2031
2208
|
return {
|
|
2032
|
-
tickets,
|
|
2209
|
+
tickets: reconciled,
|
|
2033
2210
|
statuses: statusesByProject(base, ...finished.map((batch) => batch ?? [])),
|
|
2034
2211
|
warnings
|
|
2035
2212
|
};
|
|
@@ -2150,7 +2327,13 @@ function countTickets(rows) {
|
|
|
2150
2327
|
|
|
2151
2328
|
// src/ticketboard.ts
|
|
2152
2329
|
var MAX_BACKOFF_MS3 = 30 * 6e4;
|
|
2153
|
-
var
|
|
2330
|
+
var GITHUB_DEADLINE_MS = 9e4;
|
|
2331
|
+
var realDeps4 = {
|
|
2332
|
+
identity: acliIdentity,
|
|
2333
|
+
fetch: fetchTickets,
|
|
2334
|
+
transition: transitionTicket,
|
|
2335
|
+
linked: (github, now, signal) => readLinkedPulls(github, now, signal)
|
|
2336
|
+
};
|
|
2154
2337
|
var TicketBoard = class {
|
|
2155
2338
|
#config;
|
|
2156
2339
|
#deps;
|
|
@@ -2167,7 +2350,8 @@ var TicketBoard = class {
|
|
|
2167
2350
|
#audience = 0;
|
|
2168
2351
|
#stopped = false;
|
|
2169
2352
|
#lastReadAt = 0;
|
|
2170
|
-
|
|
2353
|
+
#abort = new AbortController();
|
|
2354
|
+
constructor(config, onChange, deps = realDeps4) {
|
|
2171
2355
|
this.#config = config;
|
|
2172
2356
|
this.#deps = deps;
|
|
2173
2357
|
this.#onChange = onChange;
|
|
@@ -2181,10 +2365,12 @@ var TicketBoard = class {
|
|
|
2181
2365
|
this.#file = await readTicketsFile(this.#config.ticketsFile);
|
|
2182
2366
|
await this.refresh();
|
|
2183
2367
|
}
|
|
2368
|
+
/** Stop reading, and cancel a GitHub request in flight so nothing lands after close. */
|
|
2184
2369
|
stop() {
|
|
2185
2370
|
this.#stopped = true;
|
|
2186
2371
|
if (this.#timer) clearTimeout(this.#timer);
|
|
2187
2372
|
this.#timer = null;
|
|
2373
|
+
this.#abort.abort();
|
|
2188
2374
|
}
|
|
2189
2375
|
/** How many browsers are watching. Polling pauses at zero. */
|
|
2190
2376
|
setAudience(count) {
|
|
@@ -2299,8 +2485,24 @@ var TicketBoard = class {
|
|
|
2299
2485
|
return;
|
|
2300
2486
|
}
|
|
2301
2487
|
const site = configuredSite ?? identity2.site;
|
|
2488
|
+
const githubWarnings = [];
|
|
2489
|
+
const linked = this.#config.github.enabled ? this.#deps.linked(this.#config.github, /* @__PURE__ */ new Date(), AbortSignal.any([this.#abort.signal, AbortSignal.timeout(GITHUB_DEADLINE_MS)])).then(
|
|
2490
|
+
(read) => {
|
|
2491
|
+
githubWarnings.push(...read.warnings);
|
|
2492
|
+
return read;
|
|
2493
|
+
},
|
|
2494
|
+
(err) => {
|
|
2495
|
+
githubWarnings.push(
|
|
2496
|
+
`Could not ask GitHub about these tickets' pull requests (${err.message}), so they are as Jira has them, which can be days behind.`
|
|
2497
|
+
);
|
|
2498
|
+
return null;
|
|
2499
|
+
}
|
|
2500
|
+
) : Promise.resolve(null);
|
|
2302
2501
|
try {
|
|
2303
|
-
const
|
|
2502
|
+
const fetched = await this.#deps.fetch(acliPath, { projects, site, linked });
|
|
2503
|
+
await linked;
|
|
2504
|
+
const { tickets, statuses } = fetched;
|
|
2505
|
+
const warnings = [...fetched.warnings, ...githubWarnings];
|
|
2304
2506
|
this.#reason = null;
|
|
2305
2507
|
this.#warnings = warnings;
|
|
2306
2508
|
this.#streak = null;
|
|
@@ -2336,7 +2538,7 @@ function normalizeStoredTicket(raw) {
|
|
|
2336
2538
|
const { key, url, workflowStatus, statusCategory, issueType, summary } = raw;
|
|
2337
2539
|
if (typeof key !== "string" || key === "") return null;
|
|
2338
2540
|
const site = typeof url === "string" ? /^https:\/\/([^/]+)\/browse\//.exec(url)?.[1] ?? null : null;
|
|
2339
|
-
|
|
2541
|
+
const ticket = toTicket(
|
|
2340
2542
|
{
|
|
2341
2543
|
key,
|
|
2342
2544
|
fields: {
|
|
@@ -2353,6 +2555,25 @@ function normalizeStoredTicket(raw) {
|
|
|
2353
2555
|
// files are exactly the ones whose settled court counted drafts as merges.
|
|
2354
2556
|
raw.allPrsClosed === true
|
|
2355
2557
|
);
|
|
2558
|
+
if (!ticket) return null;
|
|
2559
|
+
const pulls = storedPulls(raw.pulls);
|
|
2560
|
+
if (pulls.length > 0) {
|
|
2561
|
+
ticket.pulls = pulls;
|
|
2562
|
+
if (raw.fromGitHub === true) ticket.fromGitHub = true;
|
|
2563
|
+
}
|
|
2564
|
+
return ticket;
|
|
2565
|
+
}
|
|
2566
|
+
var PULL_STATES = ["open", "draft", "merged", "closed"];
|
|
2567
|
+
function storedPulls(value) {
|
|
2568
|
+
if (!Array.isArray(value)) return [];
|
|
2569
|
+
return value.flatMap((entry) => {
|
|
2570
|
+
if (!isRecord3(entry)) return [];
|
|
2571
|
+
const { repo, number, title, url, state, closedAt } = entry;
|
|
2572
|
+
if (typeof repo !== "string" || typeof number !== "number" || typeof title !== "string") return [];
|
|
2573
|
+
if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return [];
|
|
2574
|
+
if (typeof state !== "string" || !PULL_STATES.includes(state)) return [];
|
|
2575
|
+
return [{ repo, number, title, url, state, closedAt: typeof closedAt === "string" ? closedAt : null }];
|
|
2576
|
+
});
|
|
2356
2577
|
}
|
|
2357
2578
|
async function readTicketsFile(path) {
|
|
2358
2579
|
let text;
|
|
@@ -3108,7 +3329,7 @@ var SETTINGS = [
|
|
|
3108
3329
|
key: "DAILY_FOCUS_GITHUB",
|
|
3109
3330
|
group: "github",
|
|
3110
3331
|
label: "Pull request board",
|
|
3111
|
-
help: "Reads your open pull requests through the GitHub CLI. Sign in with gh auth login.",
|
|
3332
|
+
help: "Reads your open pull requests through the GitHub CLI. Sign in with gh auth login. The Jira ticket board uses it too, to catch merges Jira hasn't heard about yet.",
|
|
3112
3333
|
kind: "flag",
|
|
3113
3334
|
fallback: always("On")
|
|
3114
3335
|
},
|
|
@@ -3598,13 +3819,7 @@ var TEXT_FILES = {
|
|
|
3598
3819
|
"/api/text/focus": { file: "focusFile", name: "focus.md" },
|
|
3599
3820
|
"/api/text/sources": { file: "sourcesFile", name: "sources.md" }
|
|
3600
3821
|
};
|
|
3601
|
-
var VALID_ACTIONS =
|
|
3602
|
-
"done",
|
|
3603
|
-
"snooze",
|
|
3604
|
-
"dismiss",
|
|
3605
|
-
"reopen",
|
|
3606
|
-
"note"
|
|
3607
|
-
]);
|
|
3822
|
+
var VALID_ACTIONS = new Set(ACTION_TYPES);
|
|
3608
3823
|
var MAX_BODY_BYTES = 64 * 1024;
|
|
3609
3824
|
var PRESENCE_POLL_MS = 3e4;
|
|
3610
3825
|
var AGENT_CLOCK_MS = 3e4;
|
package/package.json
CHANGED
|
@@ -73,6 +73,7 @@ by `id` and take the **last** entry for each id:
|
|
|
73
73
|
| `snooze` | Not today | Leave it out until `until` (a `YYYY-MM-DD`) has arrived. If `until` is absent, use your judgement about when it's worth resurfacing. |
|
|
74
74
|
| `reopen` | They undid a previous action | Treat as though nothing had been recorded — it is live again. |
|
|
75
75
|
| `note` | Free text for you; **does not** change status | Read it. It usually explains *why*, and often tells you something the sources can't. |
|
|
76
|
+
| `nudge` | The user asked a pull request's reviewers for a review, somewhere you can't see (Slack, in person); **does not** change status | Treat the review as asked for at `at`. Don't suggest asking again until a day after it. |
|
|
76
77
|
|
|
77
78
|
**This is the most important instruction in this prompt.** A `done` in the log
|
|
78
79
|
outranks everything you can observe upstream. If the log says the user handled an
|
|
@@ -36,11 +36,13 @@ knows how long it has been since the objective moved.
|
|
|
36
36
|
- `tickets.json` — **you only ever read this.** The dashboard's own cache of the Jira
|
|
37
37
|
tickets whose status doesn't match their pull requests, refreshed while the
|
|
38
38
|
dashboard is in use. Facts only: each entry's `workflowStatus`, its
|
|
39
|
-
`statusCategory`, and whether
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
`statusCategory`, and whether it has any pull request (`hasAnyPr`) and any still
|
|
40
|
+
open (`hasOpenPr`). Those come from Jira, or from GitHub where Jira hasn't caught
|
|
41
|
+
up yet (`fromGitHub`, with the pull requests in `pulls`), so trust them over
|
|
42
|
+
Jira's own development panel. The dashboard shows this list on a tab of its own
|
|
43
|
+
and the user fixes the statuses there, **so do not copy it into the brief** — see
|
|
44
|
+
the Jira section of step 2. Use it if it helps, never write it, and never treat
|
|
45
|
+
its absence as meaning anything.
|
|
44
46
|
|
|
45
47
|
Work through the five steps below in order. Do not start gathering before step 1 is
|
|
46
48
|
done — what you find in step 1 changes what counts as worth raising.
|
|
@@ -84,6 +86,7 @@ by `id` and take the **last** entry for each id:
|
|
|
84
86
|
| `snooze` | Not today | Leave it out until `until` (a `YYYY-MM-DD`) has arrived. If `until` is absent, use your judgement about when it's worth resurfacing. |
|
|
85
87
|
| `reopen` | They undid a previous action | Treat as though nothing had been recorded — it is live again. |
|
|
86
88
|
| `note` | Free text for you; **does not** change status | Read it. It usually explains *why*, and often tells you something the sources can't. |
|
|
89
|
+
| `nudge` | The user asked a pull request's reviewers for a review, somewhere you can't see (Slack, in person); **does not** change status | Treat the review as asked for at `at`. Don't suggest asking again until a day after it. |
|
|
87
90
|
|
|
88
91
|
**This is the most important instruction in this prompt.** A `done` in the log
|
|
89
92
|
outranks everything you can observe upstream. If the log says the user handled a Gmail
|
package/public/app.js
CHANGED
|
@@ -1593,7 +1593,8 @@ var PAST_TENSE = {
|
|
|
1593
1593
|
dismiss: "Dismissed",
|
|
1594
1594
|
snooze: "Snoozed",
|
|
1595
1595
|
reopen: "Restored",
|
|
1596
|
-
note: "Note saved"
|
|
1596
|
+
note: "Note saved",
|
|
1597
|
+
nudge: "Nudged, back tomorrow"
|
|
1597
1598
|
};
|
|
1598
1599
|
var lastAction = null;
|
|
1599
1600
|
var parkedFrom = /* @__PURE__ */ new Map();
|
|
@@ -1605,6 +1606,9 @@ function unpark(id) {
|
|
|
1605
1606
|
function undoLast() {
|
|
1606
1607
|
if (lastAction && lastAction.action !== "reopen") void unpark(lastAction.id);
|
|
1607
1608
|
}
|
|
1609
|
+
function isRemark(action) {
|
|
1610
|
+
return action === "note" || action === "nudge";
|
|
1611
|
+
}
|
|
1608
1612
|
function optimistic(current, id, action, extra) {
|
|
1609
1613
|
const status = action === "reopen" ? "open" : action === "dismiss" ? "dismissed" : action === "done" ? "done" : "snoozed";
|
|
1610
1614
|
const statusAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1631,12 +1635,12 @@ async function applyAction(id, action, extra = {}) {
|
|
|
1631
1635
|
if (row && action === "snooze" && item && (item.status === "done" || item.status === "dismissed")) {
|
|
1632
1636
|
parkedFrom.set(id, item.status === "done" ? "done" : "dismiss");
|
|
1633
1637
|
}
|
|
1634
|
-
if (action
|
|
1638
|
+
if (!isRemark(action)) state.value = optimistic(current, id, action, extra);
|
|
1635
1639
|
});
|
|
1636
1640
|
try {
|
|
1637
1641
|
state.value = await postAction({ id, action, ...extra });
|
|
1638
1642
|
removePending(id);
|
|
1639
|
-
if (action
|
|
1643
|
+
if (isRemark(action)) {
|
|
1640
1644
|
showToast(PAST_TENSE[action]);
|
|
1641
1645
|
return;
|
|
1642
1646
|
}
|
|
@@ -2065,9 +2069,9 @@ var handlers = {
|
|
|
2065
2069
|
ui.unattendedSeen.value = endedAt;
|
|
2066
2070
|
remember(UNATTENDED_SEEN_KEY, endedAt);
|
|
2067
2071
|
},
|
|
2068
|
-
// A nudge on Slack is invisible to GitHub, so it's recorded
|
|
2069
|
-
//
|
|
2070
|
-
nudge: (id) => void applyAction(id, "
|
|
2072
|
+
// A nudge on Slack is invisible to GitHub, so it's recorded: the board's nudge
|
|
2073
|
+
// timer starts over from it, and the agent reads that the user asked.
|
|
2074
|
+
nudge: (id) => void applyAction(id, "nudge"),
|
|
2071
2075
|
unpark: (id) => void unpark(id),
|
|
2072
2076
|
refreshBoard: () => void refreshBoard(),
|
|
2073
2077
|
refreshTickets: () => void refreshTickets(),
|
|
@@ -2905,8 +2909,10 @@ function renderBoard(state2, ui2, handlers2) {
|
|
|
2905
2909
|
const open = board.rows.filter((row) => row.status === "open");
|
|
2906
2910
|
for (const court of COURT_ORDER) {
|
|
2907
2911
|
const rows = open.filter((row) => row.court === court);
|
|
2908
|
-
|
|
2909
|
-
|
|
2912
|
+
const nudged = court === "reviewers" ? rows.filter((row) => row.nudgedAt) : [];
|
|
2913
|
+
const waiting = rows.filter((row) => !row.nudgedAt);
|
|
2914
|
+
if (waiting.length > 0) parts.push(section(COURT_TITLE[court], waiting, state2, ui2, handlers2, null, renderPullRow));
|
|
2915
|
+
if (nudged.length > 0) parts.push(section("Nudged", nudged, state2, ui2, handlers2, null, renderPullRow));
|
|
2910
2916
|
}
|
|
2911
2917
|
if (open.length === 0 && !board.reason) {
|
|
2912
2918
|
parts.push(
|
|
@@ -2963,6 +2969,7 @@ function PullRow({ item: row, state: state2, ui: ui2, handlers: handlers2 }) {
|
|
|
2963
2969
|
"data-id": row.id,
|
|
2964
2970
|
"data-status": row.status,
|
|
2965
2971
|
"data-court": row.court,
|
|
2972
|
+
"data-nudged": String(Boolean(row.nudgedAt)),
|
|
2966
2973
|
"data-selected": String(selected),
|
|
2967
2974
|
"data-pending": String(ui2.pending.value.has(row.id)),
|
|
2968
2975
|
"data-detail": String(ui2.detailFor.value === row.id),
|
|
@@ -3010,6 +3017,9 @@ function renderPullMeta(row, state2, now) {
|
|
|
3010
3017
|
if (row.mergeable === "CONFLICTING") pill("pill pill--overdue", "conflicts");
|
|
3011
3018
|
if (row.autoMerge) pill("pill pill--good", "auto-merge on");
|
|
3012
3019
|
if (row.nudge) pill("pill pill--age", "time to ask");
|
|
3020
|
+
if (row.nudgedAt && row.nudgedUntil) {
|
|
3021
|
+
pill("pill", `nudged ${relativeTime(row.nudgedAt, now)}, back ${relativeDay(row.nudgedUntil, now)} ${formatTime(row.nudgedUntil)}`);
|
|
3022
|
+
}
|
|
3013
3023
|
if (row.court === "blocked") {
|
|
3014
3024
|
pill("pill", `blocked ${waitedFor(row.since, now)}`);
|
|
3015
3025
|
} else if (row.court === "gate" || row.court === "checks") {
|
|
@@ -3114,7 +3124,7 @@ function renderPullActions(row, ui2, handlers2) {
|
|
|
3114
3124
|
)
|
|
3115
3125
|
);
|
|
3116
3126
|
if (row.status === "open") {
|
|
3117
|
-
if (row.court === "reviewers") {
|
|
3127
|
+
if (row.court === "reviewers" && !row.nudgedAt) {
|
|
3118
3128
|
buttons.push(
|
|
3119
3129
|
el(
|
|
3120
3130
|
"button",
|
|
@@ -3151,7 +3161,7 @@ var TICKET_COURT_TITLE = {
|
|
|
3151
3161
|
};
|
|
3152
3162
|
var TICKET_COURT_ORDER = ["settled", "started", "idle"];
|
|
3153
3163
|
var TICKET_COURT_HINT = {
|
|
3154
|
-
settled: "Every pull request
|
|
3164
|
+
settled: "Every pull request linked to these is closed. Move them on, or say what is still to come \u2014 a pull request nobody has written yet is invisible from here.",
|
|
3155
3165
|
started: "A pull request is open, so the work has begun. The status still says it has not.",
|
|
3156
3166
|
idle: "Nothing in Jira links any code to these. Fine for work that is not code; worth a look otherwise."
|
|
3157
3167
|
};
|
|
@@ -3362,7 +3372,8 @@ function TicketRowView({ item: row, state: state2, ui: ui2, handlers: handlers2
|
|
|
3362
3372
|
{ class: "item__body" },
|
|
3363
3373
|
renderTicketStatus(row, state2, ui2, handlers2),
|
|
3364
3374
|
el("p", { class: "item__title" }, title),
|
|
3365
|
-
renderTicketMeta(row, state2, handlers2)
|
|
3375
|
+
renderTicketMeta(row, state2, handlers2),
|
|
3376
|
+
renderGitHubEvidence(row, now)
|
|
3366
3377
|
),
|
|
3367
3378
|
renderTicketActions(row, ui2, handlers2),
|
|
3368
3379
|
ui2.menuFor.value === row.id ? renderSnoozeMenu(row, now, handlers2, PARK_MENU) : null
|
|
@@ -3436,6 +3447,25 @@ function renderTicketMeta(row, state2, handlers2) {
|
|
|
3436
3447
|
pills.push(...rowPills(row, state2));
|
|
3437
3448
|
return pills.length > 0 ? el("div", { class: "item__meta item__meta--extra" }, pills) : null;
|
|
3438
3449
|
}
|
|
3450
|
+
var EVIDENCE_SHOWN = 3;
|
|
3451
|
+
function renderGitHubEvidence(row, now) {
|
|
3452
|
+
const pulls = row.pulls ?? [];
|
|
3453
|
+
if (!row.fromGitHub || pulls.length === 0) return null;
|
|
3454
|
+
const parts = ["GitHub, ahead of Jira: "];
|
|
3455
|
+
pulls.slice(0, EVIDENCE_SHOWN).forEach((pull, i4) => {
|
|
3456
|
+
if (i4 > 0) parts.push(" \xB7 ");
|
|
3457
|
+
parts.push(
|
|
3458
|
+
el("a", { href: pull.url, target: "_blank", rel: "noopener noreferrer", title: pull.title }, `${pull.repo}#${pull.number}`),
|
|
3459
|
+
` ${describePull(pull, now)}`
|
|
3460
|
+
);
|
|
3461
|
+
});
|
|
3462
|
+
if (pulls.length > EVIDENCE_SHOWN) parts.push(` \xB7 and ${pulls.length - EVIDENCE_SHOWN} more`);
|
|
3463
|
+
return el("p", { class: "item__reason item__reason--waiting" }, parts);
|
|
3464
|
+
}
|
|
3465
|
+
function describePull(pull, now) {
|
|
3466
|
+
if (pull.state === "open" || pull.state === "draft") return pull.state;
|
|
3467
|
+
return pull.closedAt ? `${pull.state} ${relativeTime(pull.closedAt, now)}` : pull.state;
|
|
3468
|
+
}
|
|
3439
3469
|
function outOfSyncFlag(row, state2, handlers2) {
|
|
3440
3470
|
const hit = state2.tickets?.rows?.find((candidate) => candidate.id === row.id && candidate.status === "open");
|
|
3441
3471
|
if (!hit) return null;
|
package/public/style.css
CHANGED
|
@@ -1629,6 +1629,7 @@ body[data-view='settings'] .layout {
|
|
|
1629
1629
|
}
|
|
1630
1630
|
|
|
1631
1631
|
.item--pull[data-status='snoozed'] .item__title,
|
|
1632
|
+
.item--pull[data-nudged='true'] .item__title,
|
|
1632
1633
|
.item--ticket[data-status='snoozed'] .item__title {
|
|
1633
1634
|
color: var(--ink-3);
|
|
1634
1635
|
}
|
|
@@ -1710,12 +1711,17 @@ body[data-view='settings'] .layout {
|
|
|
1710
1711
|
padding-right: 104px;
|
|
1711
1712
|
font-size: 13.5px;
|
|
1712
1713
|
}
|
|
1713
|
-
/* The exceptions, the notes and the note form all span both columns. */
|
|
1714
|
+
/* The exceptions, GitHub's evidence, the notes and the note form all span both columns. */
|
|
1714
1715
|
#tickets .item__meta--extra,
|
|
1716
|
+
#tickets .item__reason,
|
|
1715
1717
|
#tickets .item__notes,
|
|
1716
1718
|
#tickets .note-form {
|
|
1717
1719
|
grid-column: 1 / -1;
|
|
1718
1720
|
}
|
|
1721
|
+
/* The grid's gap already spaces it from the line above. */
|
|
1722
|
+
#tickets .item__reason {
|
|
1723
|
+
margin-top: 0;
|
|
1724
|
+
}
|
|
1719
1725
|
|
|
1720
1726
|
/*
|
|
1721
1727
|
* The switch between the ticket tab's two views: what is out of sync, and what
|