@saadnvd1/agent-os 0.3.459 → 0.3.461

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +53 -1
  2. package/app/api/away/route.ts +56 -0
  3. package/app/api/away/seen/route.ts +12 -0
  4. package/app/api/health/route.ts +9 -0
  5. package/app/api/workspaces/[id]/route.ts +8 -1
  6. package/app/page.tsx +2 -0
  7. package/components/Away/AwayReport.tsx +16 -0
  8. package/components/Away/AwayReportDialog.tsx +193 -0
  9. package/components/Away/AwayToggle.tsx +91 -0
  10. package/components/Away/time.test.ts +20 -0
  11. package/components/Away/time.ts +32 -0
  12. package/components/Away/useAwayCommands.ts +43 -0
  13. package/components/Chat/Messages.tsx +1 -0
  14. package/components/CommandPalette/useAppCommands.ts +2 -0
  15. package/components/Pane/MobileTabBar.tsx +14 -1
  16. package/components/SessionList/SidebarHeader.tsx +2 -0
  17. package/components/Workspaces/WorkspaceSettings.tsx +9 -1
  18. package/data/away/index.ts +73 -0
  19. package/data/push/topics.ts +4 -0
  20. package/data/workspaces/index.ts +6 -1
  21. package/lib/away/brief.ts +23 -0
  22. package/lib/away/deploy.ts +173 -0
  23. package/lib/away/report.ts +398 -0
  24. package/lib/away/run.ts +445 -0
  25. package/lib/away/state.ts +201 -0
  26. package/lib/away/sweep.ts +216 -0
  27. package/lib/away/view.ts +51 -0
  28. package/lib/chat/events.ts +1 -1
  29. package/lib/db/changes.ts +3 -0
  30. package/lib/db/migrations.ts +101 -0
  31. package/lib/db/types.ts +9 -0
  32. package/lib/notify/index.ts +7 -0
  33. package/lib/orchestrator/brief.ts +2 -0
  34. package/lib/orchestrator/conditions.ts +10 -3
  35. package/lib/orchestrator/digest.ts +11 -8
  36. package/lib/orchestrator/notes.ts +8 -1
  37. package/lib/orchestrator/pr-wake.ts +385 -0
  38. package/lib/orchestrator/watcher.ts +12 -2
  39. package/lib/project-config/agentos.schema.json +24 -0
  40. package/lib/project-config/schema.ts +17 -0
  41. package/lib/tasks/gh.ts +2 -0
  42. package/lib/tasks/pr-activity.ts +295 -0
  43. package/lib/tasks/state.ts +9 -1
  44. package/lib/workspaces.ts +8 -2
  45. package/package.json +1 -1
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Wakes a task's own agent when its PR needs it, so the orchestrator
3
+ * doesn't have to pass the news on. Runs in the watcher's merge pipeline,
4
+ * off the PR poll AgentOS already makes (no poller of its own):
5
+ *
6
+ * - A failing check on the PR's head: the task hears it once per head,
7
+ * with the check's name, the failing step and the log around the error
8
+ * (`gh run view --log-failed`, trimmed). Read from the rollup the poll
9
+ * already has, so only the log costs a call.
10
+ * - A review or conversation comment from a collaborator other than the
11
+ * account gh runs as (which is the task's own): one GraphQL query per
12
+ * repository a minute fingerprints every watched PR, and a PR's reviews
13
+ * and comments are read only when its fingerprint moved. Comment-only
14
+ * wakes stop at COMMENT_WAKE_CAP a task; changes requested always wake.
15
+ *
16
+ * Each event is claimed in task_pr_wakes before its message goes, so it's
17
+ * sent once across restarts. Messages go through the bus like any other:
18
+ * a task mid-turn gets it queued, never interrupted. The orchestrator hears
19
+ * one line per wake. Only open PRs of running tasks are watched: merged,
20
+ * closed, dropped or done stops it. Off per workspace in Settings.
21
+ */
22
+
23
+ import { db, type Session } from "../db";
24
+ import { sendMessage } from "../bus";
25
+ import { getWorkspace } from "../workspaces";
26
+ import { OPEN_TTL_MS } from "../tasks/pr-poll";
27
+ import {
28
+ failedLog,
29
+ fingerprints,
30
+ prActivity,
31
+ repoOfPR,
32
+ type FailedLog,
33
+ type Fingerprint,
34
+ type PREvent,
35
+ type Repo,
36
+ } from "../tasks/pr-activity";
37
+ import type { TaskPR } from "../tasks/state";
38
+ import { ciKey, conditionKey } from "./conditions";
39
+ import { markTold, queueEvent } from "./events";
40
+ import type { SessionFacts } from "./facts";
41
+ import { isPaused } from "./pause";
42
+ import { repoOf } from "./repo";
43
+ import { untrusted, UNTRUSTED_RULE } from "./untrusted";
44
+
45
+ export const COMMENT_WAKE_CAP = 10;
46
+ // A first look at a PR this young takes its whole history as new.
47
+ const YOUNG_PR_MS = 10 * 60_000;
48
+ // Collaborators only: anyone can comment on a public repository's PR.
49
+ const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
50
+ const BODY_MAX = 800;
51
+ const NOTE_MAX = 300;
52
+ const NOTES_MAX = 10;
53
+ const SEND_TRIES = 3;
54
+
55
+ export interface WakeDeps {
56
+ fingerprints: typeof fingerprints;
57
+ activity: typeof prActivity;
58
+ failedLog: typeof failedLog;
59
+ // Message the task's agent through the bus; throws when it failed.
60
+ send: (task: Session, body: string) => Promise<unknown>;
61
+ }
62
+
63
+ // As an AgentOS event, through the bus: queued while the agent is mid-turn.
64
+ export async function sendWake(task: Session, body: string) {
65
+ const { delivery } = await sendMessage({
66
+ fromId: null,
67
+ to: task.id,
68
+ body,
69
+ fromLabel: "AgentOS",
70
+ origin: { kind: "event", label: "AgentOS", body },
71
+ // A chat agent reads it whole; a terminal's is told to read its inbox.
72
+ line: task.view === "chat" ? `[AgentOS event] ${body}` : undefined,
73
+ });
74
+ if (delivery.state === "failed") throw new Error(delivery.why);
75
+ return delivery.state;
76
+ }
77
+
78
+ const defaults: WakeDeps = {
79
+ fingerprints,
80
+ activity: prActivity,
81
+ failedLog,
82
+ send: sendWake,
83
+ };
84
+
85
+ const short = (sha: string) => sha.slice(0, 7);
86
+ const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s);
87
+
88
+ // The fingerprint each task's PR was last handled at, and when each
89
+ // repository was last fingerprinted.
90
+ const handled = new Map<string, string>();
91
+ const polled = new Map<string, number>();
92
+ // Sends that failed, per task and key, so a broken one stops retrying.
93
+ const failures = new Map<string, number>();
94
+
95
+ const sessionRow = (id: string) =>
96
+ db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
97
+ | Session
98
+ | undefined;
99
+
100
+ // True when this call took the key; false when it was taken before.
101
+ const claim = (task: string, key: string, kind: string) =>
102
+ db
103
+ .prepare(
104
+ `INSERT OR IGNORE INTO task_pr_wakes (session_id, key, kind) VALUES (?, ?, ?)`
105
+ )
106
+ .run(task, key, kind).changes > 0;
107
+
108
+ const taken = (task: string, key: string) =>
109
+ !!db
110
+ .prepare(`SELECT 1 FROM task_pr_wakes WHERE session_id = ? AND key = ?`)
111
+ .get(task, key);
112
+
113
+ const unclaim = (task: string, keys: string[]) => {
114
+ const del = db.prepare(
115
+ `DELETE FROM task_pr_wakes WHERE session_id = ? AND key = ?`
116
+ );
117
+ for (const key of keys) del.run(task, key);
118
+ };
119
+
120
+ export const commentWakes = (task: string) =>
121
+ (
122
+ db
123
+ .prepare(
124
+ `SELECT COUNT(*) AS n FROM task_pr_wakes WHERE session_id = ? AND kind = 'comment'`
125
+ )
126
+ .get(task) as { n: number }
127
+ ).n;
128
+
129
+ interface Watched {
130
+ task: Session;
131
+ pr: TaskPR & { head: string };
132
+ repo: Repo;
133
+ dir: string;
134
+ }
135
+
136
+ export async function wakeTasks(
137
+ workspaceId: string,
138
+ facts: SessionFacts[],
139
+ deps: WakeDeps = defaults,
140
+ now = Date.now()
141
+ ): Promise<void> {
142
+ if (!getWorkspace(workspaceId)?.wake_tasks_on_pr || isPaused(workspaceId))
143
+ return;
144
+ const watched: Watched[] = [];
145
+ for (const f of facts) {
146
+ const pr = f.task?.pr;
147
+ const task = pr?.state === "OPEN" && pr.head ? sessionRow(f.id) : null;
148
+ const repo = pr && repoOfPR(pr.url);
149
+ if (!task || task.task_status !== "running" || !repo || !pr?.head) {
150
+ handled.delete(f.id);
151
+ continue;
152
+ }
153
+ let dir: string;
154
+ try {
155
+ dir = repoOf(task);
156
+ } catch {
157
+ continue;
158
+ }
159
+ watched.push({ task, pr: { ...pr, head: pr.head }, repo, dir });
160
+ }
161
+
162
+ for (const w of watched) {
163
+ if (isPaused(workspaceId)) return;
164
+ if (w.pr.checks !== "fail" || !w.pr.failing) continue;
165
+ await wakeOnCheck(workspaceId, w, deps).catch((error) =>
166
+ console.error(`Waking ${w.task.name} on CI:`, error)
167
+ );
168
+ }
169
+
170
+ const byRepo = new Map<string, Watched[]>();
171
+ for (const w of watched) {
172
+ const slug = `${w.repo.owner}/${w.repo.name}`;
173
+ byRepo.set(slug, [...(byRepo.get(slug) ?? []), w]);
174
+ }
175
+ for (const [slug, group] of byRepo) {
176
+ if (isPaused(workspaceId)) return;
177
+ if (now - (polled.get(slug) ?? 0) < OPEN_TTL_MS) continue;
178
+ polled.set(slug, now);
179
+ let prints: Map<number, Fingerprint>;
180
+ try {
181
+ prints = await deps.fingerprints(
182
+ group[0].dir,
183
+ group[0].repo,
184
+ group.map((w) => w.pr.number)
185
+ );
186
+ } catch (error) {
187
+ console.error(`Fingerprinting PRs in ${slug}:`, error);
188
+ continue;
189
+ }
190
+ for (const w of group) {
191
+ const print = prints.get(w.pr.number);
192
+ if (!print || handled.get(w.task.id) === print.print) continue;
193
+ try {
194
+ if (await wakeOnReviews(workspaceId, w, print, deps, now))
195
+ handled.set(w.task.id, print.print);
196
+ } catch (error) {
197
+ console.error(`Waking ${w.task.name} on reviews:`, error);
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ // Whether the task is still to be woken now, after the gh calls since it
204
+ // was read: not paused or turned off, and the task still running.
205
+ const stillWatched = (workspaceId: string, taskId: string) =>
206
+ !isPaused(workspaceId) &&
207
+ !!getWorkspace(workspaceId)?.wake_tasks_on_pr &&
208
+ sessionRow(taskId)?.task_status === "running";
209
+
210
+ // Sends after claiming `keys`; a failed send gives them back for the next
211
+ // poll, SEND_TRIES times. False when it didn't go.
212
+ async function wake(
213
+ workspaceId: string,
214
+ w: Watched,
215
+ keys: string[],
216
+ body: string,
217
+ deps: WakeDeps
218
+ ): Promise<boolean> {
219
+ if (!stillWatched(workspaceId, w.task.id)) {
220
+ unclaim(w.task.id, keys);
221
+ return false;
222
+ }
223
+ try {
224
+ await deps.send(w.task, body);
225
+ return true;
226
+ } catch (error) {
227
+ const id = `${w.task.id}\0${keys[0]}`;
228
+ const n = (failures.get(id) ?? 0) + 1;
229
+ failures.set(id, n);
230
+ console.error(`Messaging ${w.task.name} (try ${n}):`, error);
231
+ if (n < SEND_TRIES) unclaim(w.task.id, keys);
232
+ return false;
233
+ }
234
+ }
235
+
236
+ async function wakeOnCheck(
237
+ workspaceId: string,
238
+ w: Watched,
239
+ deps: WakeDeps
240
+ ): Promise<void> {
241
+ const { task, pr } = w;
242
+ const key = `check:${pr.head}`;
243
+ if (taken(task.id, key)) return;
244
+ const log: FailedLog | null = await deps
245
+ .failedLog(w.dir, w.repo, pr.failingUrl)
246
+ .catch((error: unknown) => {
247
+ console.error(`Reading ${task.name}'s CI log:`, error);
248
+ return null;
249
+ });
250
+ // Claimed only now, next to the send, so a restart while the log is
251
+ // read doesn't lose the wake.
252
+ if (!claim(task.id, key, "check")) return;
253
+ if (!(await wake(workspaceId, w, [key], checkMessage(pr, log), deps))) return;
254
+ queueEvent(
255
+ workspaceId,
256
+ `woke:${task.id}:${key}`,
257
+ task.id,
258
+ `task ${task.name} woken: CI ${untrusted("CI", pr.failing!)} failed at ${short(pr.head)}`
259
+ );
260
+ // The orchestrator's own "CI failed" line for this head would only
261
+ // repeat it.
262
+ markTold(workspaceId, conditionKey(ciKey(pr), task.id), task.id);
263
+ }
264
+
265
+ export function checkMessage(
266
+ pr: TaskPR & { head: string },
267
+ log: FailedLog | null
268
+ ): string {
269
+ const step = log?.step ? ` at step ${untrusted("CI", log.step)}` : "";
270
+ const tail = log?.tail
271
+ ? `\n\nThe log around the error:\n${untrusted("CI log", log.tail)}`
272
+ : pr.failingUrl
273
+ ? `\n\nIts details: ${untrusted("CI", pr.failingUrl)}`
274
+ : "";
275
+ return `CI check ${untrusted("CI", pr.failing ?? "")} failed on your PR #${pr.number} at ${short(pr.head)}${step}.${tail}\n\n${UNTRUSTED_RULE}\n\nFix the root cause (not the check), re-run your checks, push, then run /do-code-review again and update the Reviewed: line in the PR body for the new head.`;
276
+ }
277
+
278
+ // True once the PR's activity at this fingerprint has been handled.
279
+ async function wakeOnReviews(
280
+ workspaceId: string,
281
+ w: Watched,
282
+ print: Fingerprint,
283
+ deps: WakeDeps,
284
+ now: number
285
+ ): Promise<boolean> {
286
+ const { task, pr } = w;
287
+ const since = Date.parse(watchSince(task, print, now));
288
+ const activity = await deps.activity(w.dir, w.repo, pr.number);
289
+ const fresh = activity.events.filter(
290
+ (e) =>
291
+ e.author !== activity.viewer &&
292
+ TRUSTED.has(e.association) &&
293
+ Date.parse(e.at) >= since &&
294
+ worthWaking(e) &&
295
+ !taken(task.id, `event:${e.id}`)
296
+ );
297
+ if (!fresh.length) return true;
298
+ const changes = fresh.some((e) => e.state === "CHANGES_REQUESTED");
299
+ const kind = changes ? "review" : "comment";
300
+ const keys = fresh.map((e) => `event:${e.id}`);
301
+ if (!changes && commentWakes(task.id) >= COMMENT_WAKE_CAP) {
302
+ for (const key of keys) claim(task.id, key, "event");
303
+ if (claim(task.id, "capped", "capped"))
304
+ queueEvent(
305
+ workspaceId,
306
+ `woke:${task.id}:capped`,
307
+ task.id,
308
+ `task ${task.name}: ${COMMENT_WAKE_CAP} comment wakes on PR #${pr.number} reached; further comments won't wake it`
309
+ );
310
+ return true;
311
+ }
312
+ const wakeKey = `wake:${fresh[0].id}`;
313
+ const claimed = [wakeKey, ...keys].filter((key, i) =>
314
+ claim(task.id, key, i === 0 ? kind : "event")
315
+ );
316
+ if (!claimed.includes(wakeKey)) return true;
317
+ if (!(await wake(workspaceId, w, claimed, reviewMessage(pr, fresh), deps)))
318
+ return false;
319
+ const who = [...new Set(fresh.map((e) => e.author))].join(", ");
320
+ queueEvent(
321
+ workspaceId,
322
+ `woke:${task.id}:${wakeKey}`,
323
+ task.id,
324
+ `task ${task.name} woken: ${changes ? "changes requested" : fresh.length === 1 ? "a review comment" : `${fresh.length} review comments`} on PR #${pr.number} from ${who}`
325
+ );
326
+ return true;
327
+ }
328
+
329
+ // An approval or a dismissed review with nothing to say asks for nothing.
330
+ const worthWaking = (e: PREvent) =>
331
+ e.state !== "PENDING" &&
332
+ e.state !== "DISMISSED" &&
333
+ (e.state === "CHANGES_REQUESTED" || !!e.body.trim() || e.notes.length > 0);
334
+
335
+ // When this PR's reviews started being watched, set on the first look:
336
+ // a young PR from its start, an older one from now, so what was said
337
+ // before this watched it isn't news.
338
+ function watchSince(task: Session, print: Fingerprint, now: number): string {
339
+ if (task.pr_wake_since) return task.pr_wake_since;
340
+ const created = Date.parse(print.createdAt);
341
+ const since =
342
+ Number.isFinite(created) && now - created < YOUNG_PR_MS
343
+ ? new Date(created).toISOString()
344
+ : new Date(now).toISOString();
345
+ db.prepare(`UPDATE sessions SET pr_wake_since = ? WHERE id = ?`).run(
346
+ since,
347
+ task.id
348
+ );
349
+ task.pr_wake_since = since;
350
+ return since;
351
+ }
352
+
353
+ export function reviewMessage(pr: TaskPR, events: PREvent[]): string {
354
+ const items = events.map((e) => {
355
+ const what =
356
+ e.state === "CHANGES_REQUESTED"
357
+ ? "requested changes"
358
+ : e.kind === "review"
359
+ ? "reviewed"
360
+ : "commented";
361
+ const body = e.body.trim()
362
+ ? `: ${untrusted(e.author, clip(e.body.trim(), BODY_MAX))}`
363
+ : "";
364
+ const notes = e.notes
365
+ .slice(0, NOTES_MAX)
366
+ .map(
367
+ (n) =>
368
+ `\n - ${n.path}${n.line ? `:${n.line}` : ""}: ${untrusted(e.author, clip(n.body.trim(), NOTE_MAX))}`
369
+ )
370
+ .join("");
371
+ const more =
372
+ e.notes.length > NOTES_MAX
373
+ ? `\n - and ${e.notes.length - NOTES_MAX} more on the PR`
374
+ : "";
375
+ return `- ${e.author} ${what}${body}${notes}${more}`;
376
+ });
377
+ return `New review activity on your PR #${pr.number}:\n${items.join("\n")}\n\n${UNTRUSTED_RULE} Address each point: fix what's right, re-run your checks, push, then run /do-code-review again and update the Reviewed: line for the new head. Where you disagree, reply on the PR (\`gh pr comment ${pr.number}\`) saying why.`;
378
+ }
379
+
380
+ // Tests only.
381
+ export function resetPRWake(): void {
382
+ handled.clear();
383
+ polled.clear();
384
+ failures.clear();
385
+ }
@@ -17,6 +17,8 @@ import { settleStaleAsks } from "./ask-settle";
17
17
  import { brakesOn, liftBrake } from "./brakes";
18
18
  import { anyArmed, mergeArmed } from "./armed";
19
19
  import { rebaseConflicting } from "./rebase";
20
+ import { wakeTasks } from "./pr-wake";
21
+ import { awayTick } from "../away/run";
20
22
 
21
23
  // Asks that stopped needing Saad: their task or PR finished some other
22
24
  // way, or brakes lifted with nothing trying to start.
@@ -61,8 +63,8 @@ export async function diffWorkspace(
61
63
  return nextDiffAt(now, facts, working);
62
64
  }
63
65
 
64
- // Rebases what conflicts with its base, then merges what's armed and
65
- // passes. In the background, one run per workspace at a time, so a git
66
+ // Wakes tasks whose PRs failed CI or got reviews, rebases what conflicts
67
+ // with its base, then merges what's armed and passes. In the background, one run per workspace at a time, so a git
66
68
  // fetch or a merge never holds up event delivery.
67
69
  const piping = new Set<string>();
68
70
 
@@ -70,6 +72,9 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
70
72
  if (piping.has(workspaceId)) return;
71
73
  piping.add(workspaceId);
72
74
  void (async () => {
75
+ await wakeTasks(workspaceId, facts).catch((error) =>
76
+ console.error(`Waking tasks in ${workspaceId}:`, error)
77
+ );
73
78
  await rebaseConflicting(workspaceId, facts);
74
79
  await mergeArmed(workspaceId, facts);
75
80
  })()
@@ -97,6 +102,11 @@ async function tick(): Promise<void> {
97
102
  if (busy) return;
98
103
  busy = true;
99
104
  try {
105
+ try {
106
+ awayTick();
107
+ } catch (error) {
108
+ console.error("Away mode:", error);
109
+ }
100
110
  for (const orch of listOrchestrators()) {
101
111
  const workspaceId = orch.workspace_id;
102
112
  if (!workspaceId || !getWorkspace(workspaceId)) continue;
@@ -73,6 +73,30 @@
73
73
  },
74
74
  "additionalProperties": false
75
75
  },
76
+ "deploy": {
77
+ "type": "object",
78
+ "properties": {
79
+ "deployed": {
80
+ "type": "string",
81
+ "minLength": 1
82
+ },
83
+ "failed": {
84
+ "type": "string",
85
+ "minLength": 1
86
+ },
87
+ "health": {
88
+ "type": "string",
89
+ "minLength": 1
90
+ },
91
+ "within_minutes": {
92
+ "type": "integer",
93
+ "minimum": 1,
94
+ "maximum": 1440
95
+ }
96
+ },
97
+ "required": ["deployed"],
98
+ "additionalProperties": false
99
+ },
76
100
  "test": {
77
101
  "type": "string",
78
102
  "minLength": 1
@@ -38,6 +38,22 @@ const ready = z.strictObject({
38
38
  contains: z.string().optional(),
39
39
  });
40
40
 
41
+ // How Away mode checks a merge went live (lib/away/deploy.ts): a file the
42
+ // deploy writes the deployed commit to, one it writes a commit whose deploy
43
+ // failed to, and a URL that answers 2xx when the server is healthy (a path
44
+ // alone is this AgentOS server's own).
45
+ const deploy = z.strictObject({
46
+ deployed: z.string().min(1),
47
+ failed: z.string().min(1).optional(),
48
+ health: z.string().min(1).optional(),
49
+ within_minutes: z
50
+ .number()
51
+ .int()
52
+ .min(1)
53
+ .max(24 * 60)
54
+ .optional(),
55
+ });
56
+
41
57
  const browse = z.strictObject({
42
58
  login: z.string().min(1).optional(),
43
59
  map: projectPath.optional(),
@@ -89,6 +105,7 @@ const shape = {
89
105
  clone: z.array(projectPath).optional(),
90
106
  dev: z.string().min(1).optional(),
91
107
  ready: ready.optional(),
108
+ deploy: deploy.optional(),
92
109
  test: z.string().min(1).optional(),
93
110
  notes: notes.optional(),
94
111
  browse: browse.optional(),
package/lib/tasks/gh.ts CHANGED
@@ -6,6 +6,7 @@ import { assertGhAllowed, noteGhFailure, noteGhSuccess } from "./gh-limit";
6
6
  import {
7
7
  checksVerdict,
8
8
  failingCheck,
9
+ failingUrl,
9
10
  rerunsToLookUp,
10
11
  settleReruns,
11
12
  type ActionsRun,
@@ -106,6 +107,7 @@ export async function toTaskPR(repoDir: string, pr: ListedPR): Promise<TaskPR> {
106
107
  checks: checksVerdict(rollup),
107
108
  head: pr.headRefOid,
108
109
  failing: failingCheck(rollup),
110
+ failingUrl: failingUrl(rollup),
109
111
  checkCount: (pr.statusCheckRollup ?? []).length,
110
112
  codeReview: pr.body === undefined ? undefined : parseCodeReview(pr.body),
111
113
  scopeChange: parseScopeChange(pr.body),