@andrioid/paseo-attention-span 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,448 @@
1
+ import { type ActionId, type Attention, type Chip, type Item, type NextUpIssue, type Person, SECTION_ORDER, type SectionId, type SubRow, type Target } from "../shared/attention";
2
+ import type { AttentionConfig, MergeMethod } from "../shared/config";
3
+ import { ago, daysSince } from "../shared/format";
4
+ import type { Actor, Comment, Details, HistoryResult, LiveResult, MentionNode, PickableNode, PullRequestNode } from "./github-types";
5
+ import { excerpt, plainText } from "./markdown";
6
+ import { prKey } from "./queries";
7
+
8
+ // Turns one refresh worth of GitHub data into what needs me. Pure, so every rule is testable.
9
+
10
+ export interface ClassifyInput {
11
+ data: LiveResult;
12
+ details: Details;
13
+ config: AttentionConfig;
14
+ now: Date;
15
+ }
16
+
17
+ const EPOCH = "1970-01-01T00:00:00Z";
18
+ const rank = (section: SectionId) => SECTION_ORDER.indexOf(section);
19
+ // Section order, then newest first.
20
+ const order = (a: Item, b: Item) => rank(a.section) - rank(b.section) || (b.closedAt ?? b.othersActivityAt).localeCompare(a.closedAt ?? a.othersActivityAt);
21
+ const latest = (...dates: (string | null | undefined)[]) => dates.reduce<string>((max, date) => (date && date > max ? date : max), EPOCH);
22
+ const chip = (text: string, tone: Chip["tone"] = "neutral"): Chip => ({ text, tone });
23
+ // Behind is shown on rows that appear anyway, neutral and never a reason by itself; conflicts are what's flagged [UPS-07].
24
+ const behindChip = (count: number, base: string) => chip(`${count} behind ${base}`);
25
+ const person = (actor: Actor | null | undefined, since: string): Person | undefined =>
26
+ actor ? { login: actor.login, avatarUrl: actor.avatarUrl, bot: actor.__typename === "Bot", since } : undefined;
27
+
28
+ // Search returns empty objects for result types a fragment doesn't cover.
29
+ const compact = <T>(nodes: (T | null | undefined)[]) => nodes.filter((node): node is T => !!node && Object.keys(node).length > 0);
30
+
31
+ export function classify({ data, details, config, now }: ClassifyInput): Attention {
32
+ const me = data.viewer.login.toLowerCase();
33
+ const isMe = (actor: { login: string } | null | undefined) => actor?.login.toLowerCase() === me;
34
+ const isOther = (actor: { login: string } | null | undefined) => !!actor && !isMe(actor);
35
+
36
+ const mentions = new Map<string, Person>();
37
+ for (const node of compact(data.mentions.nodes)) {
38
+ const waiting = waitingMention(node, me);
39
+ if (waiting) mentions.set(prKey(node), waiting);
40
+ }
41
+
42
+ // My PRs, minus those handed over to someone else [PRS-08].
43
+ const mine = compact(data.mine.nodes).filter((pr) => pr.assignees.nodes.length === 0 || pr.assignees.nodes.some(isMe));
44
+
45
+ // Stacks, followed through base/head branch names [ATT-04].
46
+ const byHead = new Map(mine.map((pr) => [`${pr.repository.nameWithOwner}\0${pr.headRefName}`, pr]));
47
+ const parentOf = (pr: PullRequestNode) => {
48
+ const parent = byHead.get(`${pr.repository.nameWithOwner}\0${pr.baseRefName}`);
49
+ return parent === pr ? undefined : parent;
50
+ };
51
+ const stacks: PullRequestNode[][] = [];
52
+ const seen = new Set<PullRequestNode>();
53
+ const walk = (pr: PullRequestNode, stack: PullRequestNode[]) => {
54
+ if (seen.has(pr)) return;
55
+ seen.add(pr);
56
+ stack.push(pr);
57
+ for (const child of mine.filter((candidate) => parentOf(candidate) === pr)) walk(child, stack);
58
+ };
59
+ const byNumber = [...mine].sort((a, b) => a.number - b.number);
60
+ for (const pr of byNumber) if (!parentOf(pr)) stacks.push([]), walk(pr, stacks[stacks.length - 1]);
61
+ for (const pr of byNumber) if (!seen.has(pr)) stacks.push([pr]); // cycles, in theory
62
+
63
+ const state = (pr: PullRequestNode) => {
64
+ const commit = pr.commits.nodes[0]?.commit;
65
+ const ci = commit?.statusCheckRollup?.state;
66
+ const unresolved = pr.reviewThreads.nodes.filter((thread) => !thread.isResolved);
67
+ const last = (thread: (typeof unresolved)[number]) => thread.comments.nodes.at(-1);
68
+ const onMe = unresolved.filter((thread) => !isMe(last(thread)?.author));
69
+ const lastCommitAt = commit?.committedDate ?? pr.createdAt;
70
+ const changesRequestedAt = latest(...pr.latestReviews.nodes.filter((r) => r.state === "CHANGES_REQUESTED").map((r) => r.submittedAt));
71
+ const behindBy = details.behind[prKey(pr)] ?? 0;
72
+ return {
73
+ ciFailing: ci === "FAILURE" || ci === "ERROR",
74
+ ciPending: ci === "PENDING" || ci === "EXPECTED",
75
+ conflicts: pr.mergeable === "CONFLICTING",
76
+ approved: pr.reviewDecision === "APPROVED",
77
+ autoMerge: !!pr.autoMergeRequest,
78
+ threadsOnMe: onMe.length,
79
+ botThreadsOnly: onMe.length > 0 && onMe.every((thread) => (thread.opener?.nodes[0] ?? last(thread))?.author?.__typename === "Bot"),
80
+ // Requested changes stay mine until I push after them.
81
+ changes: onMe.length > 0 || (pr.reviewDecision === "CHANGES_REQUESTED" && !(lastCommitAt > changesRequestedAt)),
82
+ repliedAt: latest(...unresolved.filter((thread) => isMe(last(thread)?.author)).map((thread) => last(thread)?.createdAt)),
83
+ lastCommitAt,
84
+ behindBy,
85
+ // A stacked PR no longer builds on its parent's latest version [UPS-02].
86
+ outOfSync: !!parentOf(pr) && behindBy > 0,
87
+ stale: daysSince(pr.updatedAt, now) >= config.staleDraftDays,
88
+ othersActivityAt: latest(
89
+ ...pr.comments.nodes.filter((c) => isOther(c.author)).map((c) => c.createdAt),
90
+ ...pr.reviewThreads.nodes.flatMap((t) => t.comments.nodes).filter((c) => isOther(c.author)).map((c) => c.createdAt),
91
+ ...pr.latestReviews.nodes.filter((r) => isOther(r.author)).map((r) => r.submittedAt),
92
+ ),
93
+ };
94
+ };
95
+
96
+ const items: Item[] = [];
97
+ const listed = new Set<string>();
98
+
99
+ // One unit per stack (or single PR) of mine.
100
+ const units = stacks.map((stack) => {
101
+ const bottom = stack[0];
102
+ const s = state(bottom);
103
+ const outOfSync = stack.slice(1).some((pr) => state(pr).outOfSync || state(pr).conflicts);
104
+ const waiting = stack.map((pr) => mentions.get(prKey(pr))).find(Boolean);
105
+ const base = bottom.baseRefName;
106
+
107
+ const reasons: SectionId[] = [];
108
+ if (waiting) reasons.push("reply");
109
+ if (bottom.isDraft) reasons.push("drafts");
110
+ else {
111
+ if (s.ciFailing) reasons.push("ci");
112
+ if (s.changes) reasons.push("changes");
113
+ // Auto-merge waiting on CI leaves nothing for me to do.
114
+ if (s.approved && !s.conflicts && !s.ciFailing && !s.changes && !(s.autoMerge && s.ciPending)) reasons.push("ready");
115
+ if (s.conflicts || outOfSync) reasons.push("upstream");
116
+ }
117
+ // Otherwise it waits on others: CI, the reviewer after my reply, or a review [WAI-01].
118
+ const waitingSince = latest(s.lastCommitAt, s.repliedAt);
119
+ const replied = s.repliedAt > EPOCH || bottom.reviewDecision === "CHANGES_REQUESTED";
120
+ const waitingFor: SectionId = s.ciPending ? "wait_ci" : replied ? "wait_reviewer" : "wait_review";
121
+ const waitingLabel = { wait_ci: "CI running", wait_reviewer: "waiting for the reviewer", wait_review: "waiting for review" }[waitingFor as "wait_ci"];
122
+ // After too long it's mine to nudge [ATT-11].
123
+ if (!bottom.isDraft && reasons.length === 0) reasons.push(daysSince(waitingSince, now) >= config.waitingTooLongDays ? "too_long" : waitingFor);
124
+ const section = reasons.sort((a, b) => rank(a) - rank(b))[0];
125
+ const reviewer = replied
126
+ ? bottom.latestReviews.nodes.find((review) => review.state === "CHANGES_REQUESTED")?.author
127
+ : bottom.reviewRequests.nodes.find((request) => request.requestedReviewer?.login)?.requestedReviewer;
128
+
129
+ const actions: ActionId[] = [];
130
+ if (section === "reply") actions.push(...replyActions(waiting));
131
+ // Rerun is for flaky checks, without an agent [PRS-09].
132
+ if (section === "ci") actions.push("fix", "rerun");
133
+ if (section === "changes") actions.push("address");
134
+ // Merge only once CI passed against the current base [PRS-04, UPS-07]. Until then, auto-merge if the repo allows it [PRS-10].
135
+ if (section === "ready")
136
+ actions.push(...(s.behindBy > 0 ? [stack.length === 1 ? "update" : "rebase"] : s.ciPending ? (bottom.repository.autoMergeAllowed ? ["automerge"] : []) : ["merge"]) as ActionId[]);
137
+ if (section === "upstream") actions.push("rebase");
138
+ // A stale draft offers Refresh and Close instead of Rebase [PRS-07]; a healthy one, Ready for review [PRS-11].
139
+ if (section === "drafts")
140
+ actions.push(...(s.stale ? ["refresh", "close"] : s.conflicts || outOfSync ? ["rebase"] : s.ciFailing ? ["fix", "rerun"] : ["ready"]) as ActionId[]);
141
+
142
+ const chips: Chip[] = [];
143
+ if (section === "too_long") chips.push(chip(`${waitingLabel} · ${ago(waitingSince, now)}`, "warn"));
144
+ if (bottom.isDraft && s.stale) chips.push(chip(`untouched ${ago(bottom.updatedAt, now)}`, "warn"));
145
+ if (s.approved && !bottom.isDraft) chips.push(chip("approved", "good"));
146
+ if (s.autoMerge) chips.push(chip("auto-merge on", "good"));
147
+ if (bottom.reviewDecision === "CHANGES_REQUESTED") chips.push(chip("changes requested", "warn"));
148
+ if (s.threadsOnMe > 0) chips.push(chip(`${s.threadsOnMe} unresolved`, "warn"));
149
+ if (s.ciFailing) chips.push(chip("CI failing", "bad"));
150
+ else if (s.ciPending) chips.push(chip("CI running", "warn"));
151
+ else if (section === "ready" && s.behindBy === 0) chips.push(chip("CI passed", "good"));
152
+ if (s.conflicts) chips.push(chip("conflicts", "bad"));
153
+ if (s.behindBy > 0) chips.push(behindChip(s.behindBy, base));
154
+ if (stack.length > 1) chips.push(chip(`stack of ${stack.length}`));
155
+
156
+ return {
157
+ stack,
158
+ section,
159
+ actions,
160
+ chips,
161
+ // How long it has waited, shown only while it sits in a Waiting section.
162
+ waited: section.startsWith("wait_") ? chip(ago(waitingSince, now)) : undefined,
163
+ person:
164
+ waiting ??
165
+ (section === "changes" && s.botThreadsOnly ? { login: "review bot", bot: true, since: s.lastCommitAt } : undefined) ??
166
+ (section === "too_long" || section.startsWith("wait_") ? person(reviewer, waitingSince) : undefined),
167
+ othersActivityAt: latest(...stack.map((pr) => state(pr).othersActivityAt), waiting?.since),
168
+ };
169
+ });
170
+ type Unit = (typeof units)[number];
171
+
172
+ const subRow = (pr: PullRequestNode, index: number, stack: PullRequestNode[]): SubRow => {
173
+ const s = state(pr);
174
+ const parent = parentOf(pr);
175
+ const chips: Chip[] = [];
176
+ if (stack.length > 1) chips.push(chip(`${index + 1}/${stack.length}`));
177
+ if (s.approved) chips.push(chip("approved", "good"));
178
+ if (s.ciFailing) chips.push(chip("CI failing", "bad"));
179
+ if (s.conflicts) chips.push(chip("conflicts", "bad"));
180
+ if (s.behindBy > 0) chips.push(behindChip(s.behindBy, parent ? `#${parent.number}` : pr.baseRefName));
181
+ return { key: prKey(pr), number: pr.number, title: pr.title, url: pr.url, draft: pr.isDraft, chips, attention: s.outOfSync || s.conflicts || s.ciFailing };
182
+ };
183
+
184
+ const target = (pr: PullRequestNode, stack: PullRequestNode[]): Target => ({
185
+ kind: "pr",
186
+ repo: pr.repository.nameWithOwner,
187
+ number: pr.number,
188
+ title: pr.title,
189
+ url: pr.url,
190
+ id: pr.id,
191
+ headRefName: pr.headRefName,
192
+ baseRefName: pr.baseRefName,
193
+ headRefOid: pr.headRefOid,
194
+ defaultBranch: pr.repository.defaultBranchRef?.name,
195
+ mergeMethod: mergeMethodFor(pr, config),
196
+ stack: stack.map((p) => ({ number: p.number, head: p.headRefName, base: p.baseRefName })),
197
+ });
198
+
199
+ // One item per issue: PRs grouped under the issue they close [ATT-03]. Otherwise one per unit.
200
+ const groups = new Map<string, { lead?: PullRequestNode["closingIssuesReferences"]["nodes"][number]; units: Unit[] }>();
201
+ for (const unit of units) {
202
+ const issue = unit.stack[0].closingIssuesReferences.nodes[0];
203
+ const key = issue ? `${issue.repository.nameWithOwner}#${issue.number}` : prKey(unit.stack[0]);
204
+ groups.set(key, { lead: issue, units: [...(groups.get(key)?.units ?? []), unit] });
205
+ }
206
+ for (const [key, { lead, units: grouped }] of groups) {
207
+ for (const unit of grouped) for (const pr of unit.stack) listed.add(prKey(pr));
208
+ listed.add(key);
209
+ const issueWaiting = lead ? mentions.get(key) : undefined;
210
+ const sections = grouped.map((unit) => unit.section);
211
+ if (issueWaiting) sections.push("reply");
212
+ const section = sections.sort((a, b) => rank(a) - rank(b))[0];
213
+ const unit = grouped.find((candidate) => candidate.section === section) ?? grouped[0];
214
+ const bottom = unit.stack[0];
215
+ items.push({
216
+ key,
217
+ section,
218
+ kind: lead ? "issue" : "pr",
219
+ draft: !lead && bottom.isDraft,
220
+ repo: lead?.repository.nameWithOwner ?? bottom.repository.nameWithOwner,
221
+ number: lead?.number ?? bottom.number,
222
+ title: lead?.title ?? bottom.title,
223
+ url: lead?.url ?? bottom.url,
224
+ chips: [...(lead ? [chip(`PR #${bottom.number}`)] : []), ...unit.chips, ...(unit.section === section && unit.waited ? [unit.waited] : [])],
225
+ person: issueWaiting ?? unit.person,
226
+ actions: issueWaiting ? replyActions(issueWaiting) : unit.actions,
227
+ children: lead ? grouped.flatMap((u) => u.stack.map((pr, i) => subRow(pr, i, u.stack))) : unit.stack.slice(1).map((pr, i) => subRow(pr, i + 1, unit.stack)),
228
+ target: target(bottom, unit.stack),
229
+ othersActivityAt: latest(...grouped.map((u) => u.othersActivityAt), issueWaiting?.since),
230
+ });
231
+ }
232
+
233
+ // Review requests [REV-01].
234
+ for (const pr of compact(data.reviews.nodes)) {
235
+ const key = prKey(pr);
236
+ if (listed.has(key)) continue;
237
+ listed.add(key);
238
+ const s = state(pr);
239
+ const waiting = mentions.get(key);
240
+ items.push({
241
+ key,
242
+ section: waiting ? "reply" : "review",
243
+ kind: "pr",
244
+ draft: pr.isDraft,
245
+ repo: pr.repository.nameWithOwner,
246
+ number: pr.number,
247
+ title: pr.title,
248
+ url: pr.url,
249
+ chips: s.ciFailing ? [chip("CI failing", "bad")] : [],
250
+ person: waiting ?? person(pr.author, s.lastCommitAt),
251
+ actions: waiting ? [...replyActions(waiting), "review"] : ["review"],
252
+ children: [],
253
+ target: target(pr, [pr]),
254
+ othersActivityAt: latest(s.othersActivityAt, s.lastCommitAt, waiting?.since),
255
+ });
256
+ }
257
+
258
+ // Remaining mentions, including other people's drafts [MEN-03].
259
+ for (const node of compact(data.mentions.nodes)) {
260
+ const key = prKey(node);
261
+ const waiting = mentions.get(key);
262
+ if (!waiting || listed.has(key)) continue;
263
+ listed.add(key);
264
+ const kind = node.__typename === "PullRequest" ? "pr" : "issue";
265
+ const repo = node.repository.nameWithOwner;
266
+ items.push({
267
+ key,
268
+ section: "reply",
269
+ kind,
270
+ draft: false,
271
+ repo,
272
+ number: node.number,
273
+ title: node.title,
274
+ url: node.url,
275
+ chips: [],
276
+ person: waiting,
277
+ actions: replyActions(waiting),
278
+ children: [],
279
+ target: { kind, repo, number: node.number, title: node.title, url: node.url },
280
+ othersActivityAt: waiting.since,
281
+ });
282
+ }
283
+
284
+ // Issues where I commented last and nobody answered: waiting, then mine to nudge [WAI-01, ATT-11].
285
+ for (const issue of compact(data.commented.nodes)) {
286
+ const key = prKey(issue);
287
+ const last = issue.comments.nodes.at(-1);
288
+ if (listed.has(key) || !last || !isMe(last.author)) continue;
289
+ listed.add(key);
290
+ const tooLong = daysSince(last.createdAt, now) >= config.waitingTooLongDays;
291
+ items.push({
292
+ ...plain(issue, "issue", tooLong ? "too_long" : "wait_answer"),
293
+ chips: [chip(tooLong ? `waiting for an answer · ${ago(last.createdAt, now)}` : ago(last.createdAt, now), tooLong ? "warn" : "neutral")],
294
+ });
295
+ }
296
+
297
+ items.sort(order);
298
+ return {
299
+ login: data.viewer.login,
300
+ items,
301
+ // Assigned issues not already in progress come before unclaimed ones [NXT-06].
302
+ assigned: rankIssues(compact(data.assigned.nodes).filter((issue) => !listed.has(prKey(issue))), details, config),
303
+ unclaimed: rankIssues(compact(data.unclaimed.nodes), details, config),
304
+ };
305
+ }
306
+
307
+ // My merged PRs under the issues they closed, closed issues, and reviews [HIS-01, HIS-02].
308
+ export function classifyHistory(data: HistoryResult): Item[] {
309
+ const items: Item[] = [];
310
+ const closed = new Map<string, Item>();
311
+ for (const issue of compact(data.closed.nodes)) {
312
+ const notPlanned = issue.stateReason === "NOT_PLANNED";
313
+ const item = { ...plain(issue, "issue", "done"), closedAt: issue.closedAt, chips: notPlanned ? [chip("not planned")] : [] };
314
+ closed.set(prKey(issue), item);
315
+ items.push(item);
316
+ }
317
+ for (const pr of compact(data.merged.nodes)) {
318
+ const issue = closed.get(pr.closingIssuesReferences.nodes.map(prKey).find((key) => closed.has(key)) ?? "");
319
+ if (issue) issue.children.push({ key: prKey(pr), number: pr.number, title: pr.title, url: pr.url, draft: false, chips: [], attention: false });
320
+ else items.push({ ...plain(pr, "pr", "done"), closedAt: pr.mergedAt });
321
+ }
322
+ for (const pr of compact(data.reviewed.nodes)) items.push({ ...plain(pr, "pr", "reviewed"), closedAt: pr.mergedAt, person: person(pr.author, pr.mergedAt) });
323
+ return items.sort(order);
324
+ }
325
+
326
+ // An item with nothing to act on: waiting, history.
327
+ function plain(node: { repository: { nameWithOwner: string }; number: number; title: string; url: string }, kind: "pr" | "issue", section: SectionId): Item {
328
+ const repo = node.repository.nameWithOwner;
329
+ return {
330
+ key: prKey(node),
331
+ section,
332
+ kind,
333
+ draft: false,
334
+ repo,
335
+ number: node.number,
336
+ title: node.title,
337
+ url: node.url,
338
+ chips: [],
339
+ actions: [],
340
+ children: [],
341
+ target: { kind, repo, number: node.number, title: node.title, url: node.url },
342
+ othersActivityAt: EPOCH,
343
+ };
344
+ }
345
+
346
+ export function mergeMethodFor(pr: PullRequestNode, config: AttentionConfig): MergeMethod {
347
+ // The configured method, else the first the repository allows of squash, merge, rebase [PRS-04].
348
+ return config.mergeMethods[pr.repository.nameWithOwner] ?? (pr.repository.squashMergeAllowed !== false ? "squash" : pr.repository.mergeCommitAllowed ? "merge" : "rebase");
349
+ }
350
+
351
+ // ---- Next up: issues by priority, effort, then age [NXT-02] ----
352
+
353
+ function rankIssues(nodes: PickableNode[], details: Details, config: AttentionConfig): NextUpIssue[] {
354
+ const { priority, effort, scope } = config.nextUp;
355
+ const valueOf = (node: PickableNode, field: string | undefined) =>
356
+ field ? node.issueFieldValues.nodes.find((value) => value.field?.name === field)?.name : undefined;
357
+ // Position in the configured order (matched by name), else in the field's own options; missing goes last.
358
+ const position = (value: string | undefined, ranked: { field: string; order?: string[] } | undefined) => {
359
+ if (!value || !ranked) return Infinity;
360
+ const order = ranked.order ?? details.fieldOptions[ranked.field] ?? [];
361
+ const index = order.findIndex((name) => value.toLowerCase().includes(name.toLowerCase()));
362
+ return index < 0 ? Infinity : index;
363
+ };
364
+ return nodes
365
+ .map((node) => ({
366
+ key: prKey(node),
367
+ repo: node.repository.nameWithOwner,
368
+ number: node.number,
369
+ title: node.title,
370
+ url: node.url,
371
+ createdAt: node.createdAt,
372
+ type: node.issueType?.name,
373
+ priority: valueOf(node, priority?.field),
374
+ effort: valueOf(node, effort?.field),
375
+ scope: valueOf(node, scope?.field),
376
+ defaultBranch: node.repository.defaultBranchRef?.name,
377
+ }))
378
+ .sort(
379
+ (a, b) =>
380
+ position(a.priority, priority) - position(b.priority, priority) ||
381
+ position(a.effort, effort) - position(b.effort, effort) ||
382
+ a.createdAt.localeCompare(b.createdAt),
383
+ );
384
+ }
385
+
386
+ // ---- Mentions still waiting on me [MEN-01..06] ----
387
+
388
+ // Reply on GitHub, or 👍 the waiting comments, which clears them too [MEN-02].
389
+ function replyActions(waiting: Person | undefined): ActionId[] {
390
+ return waiting?.comments?.length ? ["reply", "acknowledge"] : ["reply"];
391
+ }
392
+
393
+ function mentionsMe(body: string | undefined, me: string): number {
394
+ if (!body) return -1;
395
+ // Ignore quoted replies; a team mention (@org/team) never matches a login [MEN-04].
396
+ const text = body.replace(/^\s*>.*$/gm, "");
397
+ const match = new RegExp(`(^|[^\\w/@-])@${me.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/-])`, "i").exec(text);
398
+ return match ? match.index + match[1].length : -1;
399
+ }
400
+
401
+ function snippetAround(body: string, index: number): string {
402
+ const flat = body.replace(/\s+/g, " ");
403
+ const at = body.slice(0, index).replace(/\s+/g, " ").length;
404
+ const start = Math.max(0, at - 40);
405
+ const end = Math.min(flat.length, at + 80);
406
+ return `${start > 0 ? "…" : ""}${flat.slice(start, end).trim()}${end < flat.length ? "…" : ""}`;
407
+ }
408
+
409
+ export function waitingMention(node: MentionNode, me: string): Person | null {
410
+ const isMe = (actor: Actor | null) => actor?.login.toLowerCase() === me;
411
+ const threads = node.reviewThreads?.nodes ?? [];
412
+ const opened: Comment = { id: node.id, author: node.author, body: node.body, createdAt: node.createdAt, url: node.url, reactionGroups: node.reactionGroups };
413
+ const everything = [opened, ...node.comments.nodes, ...threads.flatMap((thread) => thread.comments.nodes)];
414
+ // Replying clears it, and so does any review I submit afterwards [MEN-02].
415
+ const myLastAt = latest(
416
+ ...everything.filter((c) => isMe(c.author)).map((c) => c.createdAt),
417
+ ...(node.reviews?.nodes ?? []).filter((r) => isMe(r.author)).map((r) => r.submittedAt),
418
+ );
419
+ // A bot counts only when it opened a thread I need to resolve [MEN-05].
420
+ const botOpeners = new Set(threads.filter((thread) => !thread.isResolved).map((thread) => thread.comments.nodes[0]));
421
+
422
+ const waiting = everything
423
+ .filter(
424
+ (c) =>
425
+ c.author &&
426
+ !isMe(c.author) &&
427
+ (c.author.__typename !== "Bot" || botOpeners.has(c)) &&
428
+ mentionsMe(c.body, me) >= 0 &&
429
+ !c.reactionGroups?.some((group) => group.viewerHasReacted) && // so does reacting [MEN-02]
430
+ c.createdAt > myLastAt,
431
+ )
432
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
433
+ const last = waiting.at(-1);
434
+ if (!last?.author) return null;
435
+ // The quote is cut around the mention, so it's plain text; the 👍 picker renders whole comments as Markdown.
436
+ const plain = plainText(last.body ?? "");
437
+ return {
438
+ login: last.author.login,
439
+ avatarUrl: last.author.avatarUrl,
440
+ bot: waiting.every((c) => c.author?.__typename === "Bot"),
441
+ since: waiting[0].createdAt,
442
+ snippet: snippetAround(plain, Math.max(0, mentionsMe(plain, me))),
443
+ url: last.url ?? node.url,
444
+ comments: waiting
445
+ .filter((c) => c.id)
446
+ .map((c) => ({ id: c.id!, author: c.author!.login, body: excerpt(c.body, 4000) ?? "", createdAt: c.createdAt, url: c.url })),
447
+ };
448
+ }
@@ -0,0 +1,87 @@
1
+ import type { Target } from "../shared/attention";
2
+ import type { SessionKind } from "../shared/config";
3
+ import { graphql, runGh } from "./gh";
4
+
5
+ // Context for a session prompt, fetched deterministically when a session starts [SES-04].
6
+
7
+ const clip = (text: string | null | undefined, max = 4000) => {
8
+ const value = (text ?? "").trim();
9
+ return value.length > max ? `${value.slice(0, max)}\n…(truncated)` : value;
10
+ };
11
+
12
+ async function ghJson<T>(args: string[]): Promise<T> {
13
+ // `gh pr checks` exits non-zero when checks fail but still prints the JSON.
14
+ const { stdout, stderr } = await runGh(args);
15
+ try {
16
+ return JSON.parse(stdout) as T;
17
+ } catch {
18
+ throw new Error(stderr.trim() || `gh ${args.join(" ")} failed`);
19
+ }
20
+ }
21
+
22
+ export async function sessionContext(kind: SessionKind, target: Target): Promise<string> {
23
+ const ref = [String(target.number), "--repo", target.repo];
24
+ if (kind === "rebase" || kind === "refresh") {
25
+ const stack = target.stack ?? [];
26
+ return `Branches, bottom first:\n${stack.map((pr) => `- #${pr.number}: ${pr.head} (on ${pr.base})`).join("\n")}`;
27
+ }
28
+ if (kind === "fix") return failingChecks(ref, target.repo);
29
+ if (kind === "address") return reviewFeedback(target);
30
+
31
+ // review, start: the description and the latest comments.
32
+ type View = { body: string; comments: { author: { login: string }; body: string }[] };
33
+ const view = await ghJson<View>([target.kind === "pr" ? "pr" : "issue", "view", ...ref, "--json", "body,comments"]);
34
+ const comments = view.comments.slice(-5).map((c) => `@${c.author.login}: ${clip(c.body, 800)}`);
35
+ return [`Description:\n${clip(view.body) || "(empty)"}`, comments.length ? `Recent comments:\n${comments.join("\n\n")}` : ""].filter(Boolean).join("\n\n");
36
+ }
37
+
38
+ export async function defaultBranch(repo: string): Promise<string | null> {
39
+ const view = await ghJson<{ defaultBranchRef: { name: string } | null }>(["repo", "view", repo, "--json", "defaultBranchRef"]);
40
+ return view.defaultBranchRef?.name ?? null;
41
+ }
42
+
43
+ async function failingChecks(ref: string[], repo: string): Promise<string> {
44
+ const checks = await ghJson<{ name: string; bucket: string; link: string }[]>(["pr", "checks", ...ref, "--json", "name,bucket,link"]);
45
+ const failed = checks.filter((check) => check.bucket === "fail");
46
+ if (failed.length === 0) return "No failing checks were found on the latest commit.";
47
+ const runs = [...new Set(failed.map((check) => /\/actions\/runs\/(\d+)/.exec(check.link)?.[1]).filter(Boolean))].slice(0, 2);
48
+ const logs: string[] = [];
49
+ for (const run of runs) {
50
+ const { code, stdout } = await runGh(["run", "view", run!, "--repo", repo, "--log-failed"]);
51
+ if (code === 0 && stdout.trim()) logs.push(`Failed log of run ${run} (last 120 lines):\n${stdout.trim().split("\n").slice(-120).join("\n")}`);
52
+ }
53
+ return [`Failing checks:\n${failed.map((check) => `- ${check.name} ${check.link}`).join("\n")}`, ...logs].join("\n\n");
54
+ }
55
+
56
+ // gh has no command for unresolved review threads, so this one is GraphQL.
57
+ async function reviewFeedback(target: Target): Promise<string> {
58
+ type Author = { login: string } | null;
59
+ const [owner, name] = target.repo.split("/");
60
+ const data = await graphql<{
61
+ repository: {
62
+ pullRequest: {
63
+ reviews: { nodes: { author: Author; state: string; body: string }[] };
64
+ reviewThreads: { nodes: { isResolved: boolean; path: string; line: number | null; comments: { nodes: { author: Author; body: string }[] } }[] };
65
+ };
66
+ };
67
+ }>(
68
+ `query($owner: String!, $name: String!, $number: Int!) {
69
+ repository(owner: $owner, name: $name) {
70
+ pullRequest(number: $number) {
71
+ reviews(last: 20, states: [CHANGES_REQUESTED, COMMENTED]) { nodes { author { login } state body } }
72
+ reviewThreads(first: 100) { nodes { isResolved path line comments(first: 20) { nodes { author { login } body } } } }
73
+ }
74
+ }
75
+ }`,
76
+ { owner, name, number: target.number },
77
+ );
78
+ const { reviews, reviewThreads } = data.repository.pullRequest;
79
+ const who = (author: Author) => `@${author?.login ?? "ghost"}`;
80
+ const summaries = reviews.nodes.filter((r) => r.body.trim()).map((r) => `- ${who(r.author)} (${r.state.toLowerCase()}): ${clip(r.body, 1500)}`);
81
+ const threads = reviewThreads.nodes
82
+ .filter((t) => !t.isResolved)
83
+ .map((t) => `- ${t.path}${t.line ? `:${t.line}` : ""}\n${t.comments.nodes.map((c) => ` ${who(c.author)}: ${clip(c.body, 1500).replace(/\n/g, "\n ")}`).join("\n")}`);
84
+ return [summaries.length ? `Review summaries:\n${summaries.join("\n")}` : "", threads.length ? `Unresolved review threads:\n${threads.join("\n")}` : "No unresolved review threads."]
85
+ .filter(Boolean)
86
+ .join("\n\n");
87
+ }
@@ -0,0 +1,76 @@
1
+ import type { FailingChecks, ItemDetails, Target } from "../shared/attention";
2
+ import { graphql, runGh } from "./gh";
3
+
4
+ // What an item's sheet shows beyond the card, fetched when it opens [ATT-06, GH-01].
5
+
6
+ export interface Subject {
7
+ kind: "pr" | "issue";
8
+ repo: string;
9
+ number: number;
10
+ }
11
+
12
+ type Linked = { nodes: { number: number; title: string; url: string; state: string }[] };
13
+ type Context = { __typename: string; name?: string; conclusion?: string | null; checkSuite?: { workflowRun: { databaseId: number } | null } | null; context?: string; state?: string };
14
+ type PullRequest = {
15
+ number: number;
16
+ additions: number;
17
+ deletions: number;
18
+ changedFiles: number;
19
+ commits?: { nodes: { commit: { statusCheckRollup: { contexts: { nodes: Context[] } } | null } }[] };
20
+ };
21
+ type Result = {
22
+ s: { issueOrPullRequest: { closedByPullRequestsReferences?: Linked; closingIssuesReferences?: Linked } | null } | null;
23
+ } & Record<string, { pullRequest: PullRequest | null } | null>;
24
+
25
+ const FAILED = new Set(["FAILURE", "TIMED_OUT", "STARTUP_FAILURE", "ERROR"]);
26
+
27
+ const repository = (repo: string) => {
28
+ const [owner, name] = repo.split("/");
29
+ return `repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)})`;
30
+ };
31
+
32
+ // One GraphQL request, plus one REST call per failing workflow run for its attempt number.
33
+ export async function itemDetails(subject: Subject, target: Target | undefined, checks: boolean): Promise<ItemDetails> {
34
+ const pr = target?.kind === "pr" ? target : undefined;
35
+ const numbers = pr ? (checks ? (pr.stack ?? [{ number: pr.number }]).map((p) => p.number) : [pr.number]) : [];
36
+ const contexts = `commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes {
37
+ __typename
38
+ ... on CheckRun { name conclusion checkSuite { workflowRun { databaseId } } }
39
+ ... on StatusContext { context state }
40
+ } } } } } }`;
41
+ const linked = "nodes { number title url state }";
42
+ const query = `query {
43
+ s: ${repository(subject.repo)} { issueOrPullRequest(number: ${subject.number}) {
44
+ ... on Issue { closedByPullRequestsReferences(first: 5, includeClosedPrs: true) { ${linked} } }
45
+ ... on PullRequest { closingIssuesReferences(first: 5) { ${linked} } }
46
+ } }
47
+ ${numbers.map((number, index) => ` p${index}: ${repository(pr!.repo)} { pullRequest(number: ${number}) { number additions deletions changedFiles ${checks ? contexts : ""} } }`).join("\n")}
48
+ }`;
49
+ const result = await graphql<Result>(query);
50
+ const node = result.s?.issueOrPullRequest;
51
+ const prs = numbers.map((_, index) => result[`p${index}`]?.pullRequest).filter((p): p is PullRequest => !!p);
52
+ const own = prs.find((p) => p.number === pr?.number);
53
+
54
+ const failing: FailingChecks[] = [];
55
+ for (const p of checks ? prs : []) {
56
+ const failed = (p.commits?.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []).filter((c) => FAILED.has(c.conclusion ?? c.state ?? ""));
57
+ if (failed.length === 0) continue;
58
+ const runIds = [...new Set(failed.map((c) => c.checkSuite?.workflowRun?.databaseId).filter((id): id is number => !!id))];
59
+ failing.push({
60
+ pr: p.number,
61
+ checks: failed.map((c) => ({ name: c.name ?? c.context ?? "check", rerunnable: !!c.checkSuite?.workflowRun })),
62
+ runs: await Promise.all(runIds.map(async (id) => ({ id, attempt: await runAttempt(pr!.repo, id) }))),
63
+ });
64
+ }
65
+
66
+ return {
67
+ size: own ? { additions: own.additions, deletions: own.deletions, files: own.changedFiles } : undefined,
68
+ linked: (node?.closingIssuesReferences ?? node?.closedByPullRequestsReferences)?.nodes ?? [],
69
+ failing,
70
+ };
71
+ }
72
+
73
+ async function runAttempt(repo: string, id: number): Promise<number> {
74
+ const { code, stdout } = await runGh(["api", `repos/${repo}/actions/runs/${id}`, "--jq", ".run_attempt"]);
75
+ return code === 0 ? Number(stdout.trim()) || 1 : 1;
76
+ }