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 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 a note; it doesn't send a message. Completing a task
94
- about a pull request in the brief doesn't remove that pull request from the board.
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-AIQO2YAI.js";
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
- } from "./chunk-26JG3D5C.js";
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((a) => a.action !== "note").map((a) => a.id));
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)));
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  writeJsonAtomic
3
- } from "./chunk-26JG3D5C.js";
3
+ } from "./chunk-JQGYCWRX.js";
4
4
  import {
5
5
  localTimeZone
6
6
  } from "./chunk-RNE27FQY.js";
@@ -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 (!["done", "snooze", "dismiss", "reopen", "note"].includes(action)) return null;
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-KVFAVGO7.js");
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-SOCS3SZJ.js");
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-ITRXIX6T.js");
175
+ await import("./audit-7YAJYHZF.js");
176
176
  return;
177
177
  case "seed":
178
- await import("./seed-VALN2TCQ.js");
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
- ticket("PLAT-311", "Remove the legacy beacon endpoint", "In Progress", "Task", "closed"),
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) },
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-UY6HQLGE.js";
4
4
  import {
5
5
  Store
6
- } from "./chunk-26JG3D5C.js";
6
+ } from "./chunk-JQGYCWRX.js";
7
7
  import {
8
8
  loadConfig
9
9
  } from "./chunk-RNE27FQY.js";
@@ -11,13 +11,14 @@ import {
11
11
  import {
12
12
  TimeZoneKeeper,
13
13
  validZone
14
- } from "./chunk-AIQO2YAI.js";
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-26JG3D5C.js";
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, lastNoteAt, gates = []) {
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, lastNoteAt) ?? waitingSince;
1041
+ const quietSince = laterISO(waitingSince, nudgedAt) ?? waitingSince;
962
1042
  const waited = now.getTime() - (ms(quietSince) ?? now.getTime());
963
- return { court: "reviewers", decision, reasons, stale: false, nudge: waited >= NUDGE_AFTER_MS, since: waitingSince };
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 notes = log?.notes ?? [];
970
- const lastNoteAt = notes.length > 0 ? notes[notes.length - 1].at : null;
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 candidates = tickets.filter((ticket) => ticket.hasAnyPr && !ticket.hasOpenPr);
1981
- const panels = await mapLimit(
1982
- candidates,
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 (!asked) {
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 (unanswered.length > 0) {
2185
+ if (unansweredNow.length > 0) {
2009
2186
  warnings.push(
2010
- `Could not read Jira's development panel for ${listed(unanswered)}, so ${heldOut(unanswered)}. The next read may well manage it.`
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 (unaccounted.length > 0) {
2190
+ if (unaccountedNow.length > 0) {
2014
2191
  warnings.push(
2015
- `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.`
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 realDeps3 = { identity: acliIdentity, fetch: fetchTickets, transition: transitionTicket };
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
- constructor(config, onChange, deps = realDeps3) {
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 { tickets, statuses, warnings } = await this.#deps.fetch(acliPath, { projects, site });
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
- return toTicket(
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 = /* @__PURE__ */ new Set([
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "daily-focus",
3
- "version": "1.2.0-dev.5",
3
+ "version": "1.2.0-dev.7",
4
4
  "description": "An interactive dashboard of what you should focus on today at work",
5
5
  "keywords": [
6
6
  "dashboard",
@@ -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 Jira has any pull request for it (`hasAnyPr`) and any
40
- still open (`hasOpenPr`). The dashboard shows this list on a tab of its own and the
41
- user fixes the statuses there, **so do not copy it into the brief** — see the Jira
42
- section of step 2. Use it if it helps, never write it, and never treat its absence
43
- as meaning anything.
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 !== "note") state.value = optimistic(current, id, action, extra);
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 === "note") {
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 as a note: the agent
2069
- // reads it as free text, and the board's nudge timer starts over from it.
2070
- nudge: (id) => void applyAction(id, "note", { text: "Nudged reviewers" }),
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
- if (rows.length === 0) continue;
2909
- parts.push(section(COURT_TITLE[court], rows, state2, ui2, handlers2, null, renderPullRow));
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 Jira has for 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.",
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