@zenspc/pi-pstack 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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/agents/comment-sicko.md +34 -0
  4. package/agents/poteto-agent.md +13 -0
  5. package/extensions/pstack/config.ts +228 -0
  6. package/extensions/pstack/index.ts +211 -0
  7. package/package.json +57 -0
  8. package/skills/architect/SKILL.md +82 -0
  9. package/skills/architect/references/design-red-flags.md +33 -0
  10. package/skills/architect/references/rationale-template.md +35 -0
  11. package/skills/architect/references/runner-prompt.md +20 -0
  12. package/skills/arena/SKILL.md +70 -0
  13. package/skills/automate-me/SKILL.md +114 -0
  14. package/skills/blast-radius/SKILL.md +49 -0
  15. package/skills/bro/SKILL.md +6 -0
  16. package/skills/create-verification-skill/SKILL.md +43 -0
  17. package/skills/create-verification-skill/references/feature-map-example/README.md +47 -0
  18. package/skills/create-verification-skill/references/feature-map-example/create-note.md +39 -0
  19. package/skills/create-verification-skill/references/feature-map-example/search.md +45 -0
  20. package/skills/figure-it-out/SKILL.md +54 -0
  21. package/skills/how/SKILL.md +134 -0
  22. package/skills/how/references/critic-prompt.md +59 -0
  23. package/skills/how/references/critique-rubric.md +58 -0
  24. package/skills/how/references/explainer-prompt.md +55 -0
  25. package/skills/how/references/explorer-prompt.md +52 -0
  26. package/skills/interrogate/SKILL.md +109 -0
  27. package/skills/interrogate/references/code-quality-review.md +47 -0
  28. package/skills/interrogate/references/lead-judgment.md +58 -0
  29. package/skills/interrogate/references/reviewer-prompt.md +72 -0
  30. package/skills/interrogate/references/rubric.md +77 -0
  31. package/skills/maintain-verification-skill/SKILL.md +38 -0
  32. package/skills/no-comments/SKILL.md +23 -0
  33. package/skills/poteto-mode/SKILL.md +140 -0
  34. package/skills/poteto-mode/playbooks/authoring-a-skill.md +12 -0
  35. package/skills/poteto-mode/playbooks/autonomous-run.md +13 -0
  36. package/skills/poteto-mode/playbooks/autopilot-full.md +13 -0
  37. package/skills/poteto-mode/playbooks/autopilot-stack.md +31 -0
  38. package/skills/poteto-mode/playbooks/babysit.md +27 -0
  39. package/skills/poteto-mode/playbooks/bug-fix.md +17 -0
  40. package/skills/poteto-mode/playbooks/eval.md +27 -0
  41. package/skills/poteto-mode/playbooks/feature.md +21 -0
  42. package/skills/poteto-mode/playbooks/hillclimb.md +21 -0
  43. package/skills/poteto-mode/playbooks/investigation.md +14 -0
  44. package/skills/poteto-mode/playbooks/multi-phase-plan.md +3 -0
  45. package/skills/poteto-mode/playbooks/opening-a-pr.md +35 -0
  46. package/skills/poteto-mode/playbooks/orchestrate.md +113 -0
  47. package/skills/poteto-mode/playbooks/pause-safely.md +10 -0
  48. package/skills/poteto-mode/playbooks/perf-issue.md +24 -0
  49. package/skills/poteto-mode/playbooks/prototype.md +14 -0
  50. package/skills/poteto-mode/playbooks/refactoring.md +16 -0
  51. package/skills/poteto-mode/playbooks/runtime-forensics.md +11 -0
  52. package/skills/poteto-mode/playbooks/session-pickup.md +13 -0
  53. package/skills/poteto-mode/playbooks/shipping.md +40 -0
  54. package/skills/poteto-mode/playbooks/trace-forensics.md +14 -0
  55. package/skills/poteto-mode/playbooks/visual-parity.md +11 -0
  56. package/skills/poteto-mode/playbooks/worktree-cleanup.md +14 -0
  57. package/skills/poteto-mode/references/bugbot-triage.md +142 -0
  58. package/skills/poteto-mode/references/plan.md +105 -0
  59. package/skills/poteto-mode/scripts/bootstrap.ts +62 -0
  60. package/skills/poteto-mode/scripts/bun.lock +67 -0
  61. package/skills/poteto-mode/scripts/orch/orch.test.ts +634 -0
  62. package/skills/poteto-mode/scripts/orch/orch.ts +578 -0
  63. package/skills/poteto-mode/scripts/orch/store.ts +1607 -0
  64. package/skills/poteto-mode/scripts/package.json +16 -0
  65. package/skills/poteto-mode/scripts/watch-pr/cli.test.ts +224 -0
  66. package/skills/poteto-mode/scripts/watch-pr/cli.ts +223 -0
  67. package/skills/poteto-mode/scripts/watch-pr/fakes.test-helper.ts +118 -0
  68. package/skills/poteto-mode/scripts/watch-pr/github.test.ts +306 -0
  69. package/skills/poteto-mode/scripts/watch-pr/github.ts +699 -0
  70. package/skills/poteto-mode/scripts/watch-pr/policy.test.ts +420 -0
  71. package/skills/poteto-mode/scripts/watch-pr/policy.ts +832 -0
  72. package/skills/poteto-mode/scripts/watch-pr/render.ts +169 -0
  73. package/skills/poteto-mode/scripts/watch-pr/tsconfig.json +13 -0
  74. package/skills/poteto-mode/scripts/watch-pr/types.compile.ts +93 -0
  75. package/skills/poteto-mode/scripts/watch-pr/types.ts +401 -0
  76. package/skills/poteto-mode/scripts/watch-pr/watch-pr +6 -0
  77. package/skills/poteto-mode/scripts/worktree-audit.sh +85 -0
  78. package/skills/principle-boundary-discipline/SKILL.md +33 -0
  79. package/skills/principle-build-the-lever/SKILL.md +22 -0
  80. package/skills/principle-encode-lessons-in-structure/SKILL.md +30 -0
  81. package/skills/principle-exhaust-the-design-space/SKILL.md +20 -0
  82. package/skills/principle-experience-first/SKILL.md +18 -0
  83. package/skills/principle-fix-root-causes/SKILL.md +22 -0
  84. package/skills/principle-foundational-thinking/SKILL.md +20 -0
  85. package/skills/principle-guard-the-context-window/SKILL.md +16 -0
  86. package/skills/principle-laziness-protocol/SKILL.md +17 -0
  87. package/skills/principle-make-operations-idempotent/SKILL.md +23 -0
  88. package/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +21 -0
  89. package/skills/principle-minimize-reader-load/SKILL.md +22 -0
  90. package/skills/principle-model-the-domain/SKILL.md +25 -0
  91. package/skills/principle-never-block-on-the-human/SKILL.md +22 -0
  92. package/skills/principle-outcome-oriented-execution/SKILL.md +21 -0
  93. package/skills/principle-prove-it-works/SKILL.md +32 -0
  94. package/skills/principle-redesign-from-first-principles/SKILL.md +15 -0
  95. package/skills/principle-separate-before-serializing-shared-state/SKILL.md +15 -0
  96. package/skills/principle-sequence-verifiable-units/SKILL.md +21 -0
  97. package/skills/principle-subtract-before-you-add/SKILL.md +21 -0
  98. package/skills/principle-type-system-discipline/SKILL.md +30 -0
  99. package/skills/recall/SKILL.md +40 -0
  100. package/skills/reflect/SKILL.md +77 -0
  101. package/skills/reflect/references/divergent-reviewer.md +43 -0
  102. package/skills/reflect/references/judgment-reviewer.md +42 -0
  103. package/skills/reflect/references/synthesizer.md +56 -0
  104. package/skills/reflect/references/tooling-reviewer.md +57 -0
  105. package/skills/setup-pstack/SKILL.md +28 -0
  106. package/skills/show-me-your-work/SKILL.md +89 -0
  107. package/skills/show-me-your-work/references/decision-log-template.tsv +1 -0
  108. package/skills/show-me-your-work/scripts/log.sh +40 -0
  109. package/skills/swarm/SKILL.md +45 -0
  110. package/skills/tdd/SKILL.md +43 -0
  111. package/skills/teach/SKILL.md +20 -0
  112. package/skills/technical-writing/SKILL.md +129 -0
  113. package/skills/typescript-best-practices/SKILL.md +28 -0
  114. package/skills/typescript-best-practices/references/patterns.md +292 -0
  115. package/skills/unslop/SKILL.md +80 -0
  116. package/skills/why/SKILL.md +229 -0
  117. package/skills/why/references/epistemics.md +144 -0
  118. package/skills/why/references/investigator-prompt.md +103 -0
  119. package/skills/why/references/source-playbook.md +17 -0
  120. package/skills/why/references/sources/code-archaeology.md +88 -0
  121. package/skills/why/references/sources/databricks.md +70 -0
  122. package/skills/why/references/sources/datadog.md +99 -0
  123. package/skills/why/references/sources/incident-postmortem.md +15 -0
  124. package/skills/why/references/sources/linear.md +48 -0
  125. package/skills/why/references/sources/notion.md +55 -0
  126. package/skills/why/references/sources/sentry.md +100 -0
  127. package/skills/why/references/sources/slack.md +54 -0
  128. package/skills/why/references/synthesizer-prompt.md +135 -0
@@ -0,0 +1,699 @@
1
+ import { spawn } from "node:child_process";
2
+ import type * as T from "./types.ts";
3
+ import { nonEmpty, parsePrNumber } from "./types.ts";
4
+ export const REVIEW_THREADS_QUERY =
5
+ "\nquery ReviewThreads($owner: String!, $repo: String!, $pr: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $pr) {\n reviewThreads(first: 100) {\n nodes {\n id\n isResolved\n comments(first: 10) {\n nodes {\n body\n createdAt\n path\n line\n author { login }\n }\n }\n }\n }\n }\n }\n}\n";
6
+ export const PR_COMMIT_STATUS_QUERY =
7
+ "\nquery PrCommitStatuses($owner: String!, $repo: String!, $pr: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $pr) {\n commits(last: 50) {\n nodes {\n commit {\n oid\n statusCheckRollup {\n state\n }\n }\n }\n }\n }\n }\n}\n";
8
+ export const PR_CHECK_ROLLUP_QUERY =
9
+ "\nquery PrCheckRollup($owner: String!, $repo: String!, $pr: Int!, $after: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $pr) {\n commits(last: 1) {\n nodes {\n commit {\n statusCheckRollup {\n contexts(first: 100, after: $after) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n __typename\n ... on CheckRun {\n name\n status\n conclusion\n detailsUrl\n }\n ... on StatusContext {\n context\n state\n targetUrl\n }\n }\n }\n }\n }\n }\n }\n }\n }\n}\n";
10
+
11
+ interface CommandResult {
12
+ readonly code: number;
13
+ readonly stdout: string;
14
+ readonly stderr: string;
15
+ }
16
+ export class WatcherQueryError extends Error {
17
+ readonly failure: T.QueryFailure;
18
+ constructor(failure: T.QueryFailure) {
19
+ super(failure.detail);
20
+ this.name = "WatcherQueryError";
21
+ this.failure = failure;
22
+ }
23
+ }
24
+ export class ChecksUnavailable extends WatcherQueryError {
25
+ constructor(detail: string) {
26
+ super({ kind: "checks-unavailable", retryable: true, detail });
27
+ this.name = "ChecksUnavailable";
28
+ }
29
+ }
30
+ const firstLine = (value: string): string =>
31
+ value.trim().split(/\r?\n/, 1)[0]?.slice(0, 240) ?? "";
32
+ function run(argv: readonly [string, ...string[]]): Promise<CommandResult> {
33
+ return new Promise((resolve, reject) => {
34
+ const child = spawn(argv[0], argv.slice(1), {
35
+ stdio: ["ignore", "pipe", "pipe"],
36
+ });
37
+ let stdout = "";
38
+ let stderr = "";
39
+ child.stdout.setEncoding("utf8");
40
+ child.stderr.setEncoding("utf8");
41
+ child.stdout.on("data", (chunk: string) => {
42
+ stdout += chunk;
43
+ });
44
+ child.stderr.on("data", (chunk: string) => {
45
+ stderr += chunk;
46
+ });
47
+ child.on("error", reject);
48
+ child.on("close", (code) => resolve({ code: code ?? -1, stdout, stderr }));
49
+ });
50
+ }
51
+ function parseJson(text: string, label: string): unknown {
52
+ try {
53
+ return JSON.parse(text);
54
+ } catch (error) {
55
+ throw new WatcherQueryError({
56
+ kind: "json-parse",
57
+ retryable: true,
58
+ detail: `${label}: ${error instanceof Error ? error.message : String(error)}`,
59
+ });
60
+ }
61
+ }
62
+ async function runJson(argv: readonly [string, ...string[]]): Promise<unknown> {
63
+ const result = await run(argv);
64
+ if (result.code !== 0)
65
+ throw new WatcherQueryError({
66
+ kind: "command-exit",
67
+ retryable: true,
68
+ code: result.code,
69
+ detail:
70
+ firstLine(result.stderr) || `${argv.join(" ")} exited ${result.code}`,
71
+ });
72
+ return parseJson(result.stdout, argv.join(" "));
73
+ }
74
+ function raw(value: unknown): string {
75
+ try {
76
+ return JSON.stringify(value);
77
+ } catch {
78
+ return String(value);
79
+ }
80
+ }
81
+ function missing(path: string, value?: unknown): never {
82
+ throw new WatcherQueryError({
83
+ kind: "missing-key",
84
+ retryable: true,
85
+ detail:
86
+ value === undefined
87
+ ? `missing ${path}`
88
+ : `invalid ${path}: ${raw(value)}`,
89
+ ...(value === undefined ? {} : { rawValue: raw(value) }),
90
+ });
91
+ }
92
+ function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === "object" && value !== null && !Array.isArray(value);
94
+ }
95
+ function record(value: unknown, path: string): Record<string, unknown> {
96
+ if (!isRecord(value)) missing(path, value);
97
+ return value;
98
+ }
99
+ function list(value: unknown, path: string): readonly unknown[] {
100
+ if (!Array.isArray(value)) missing(path, value);
101
+ return value;
102
+ }
103
+ function at(value: unknown, path: readonly string[]): unknown {
104
+ let current = value;
105
+ for (const key of path) {
106
+ const object = record(current, path.join("."));
107
+ if (!(key in object)) missing(path.join("."));
108
+ current = object[key];
109
+ }
110
+ return current;
111
+ }
112
+ function string(value: unknown, path: string): string {
113
+ if (typeof value !== "string") missing(path, value);
114
+ return value;
115
+ }
116
+ const optionalString = (value: unknown, path: string): string | null =>
117
+ value === null ? null : string(value, path);
118
+ function enumValue<const V extends readonly string[]>(
119
+ value: unknown,
120
+ values: V,
121
+ path: string
122
+ ): V[number] {
123
+ if (typeof value === "string")
124
+ for (const candidate of values) if (candidate === value) return candidate;
125
+ return missing(path, value);
126
+ }
127
+ const nullableEnum = <const V extends readonly string[]>(
128
+ value: unknown,
129
+ values: V,
130
+ path: string
131
+ ): V[number] | null => (value === null ? null : enumValue(value, values, path));
132
+ const MERGE_STATES = [
133
+ "BEHIND",
134
+ "BLOCKED",
135
+ "CLEAN",
136
+ "CONFLICTING",
137
+ "DIRTY",
138
+ "DRAFT",
139
+ "HAS_HOOKS",
140
+ "UNKNOWN",
141
+ "UNSTABLE",
142
+ ] as const satisfies readonly T.MergeStateStatus[];
143
+ const ROLLUP_STATES = [
144
+ "ERROR",
145
+ "EXPECTED",
146
+ "FAILURE",
147
+ "PENDING",
148
+ "SUCCESS",
149
+ ] as const;
150
+ const REVIEW_DECISIONS = [
151
+ "APPROVED",
152
+ "CHANGES_REQUESTED",
153
+ "REVIEW_REQUIRED",
154
+ ] as const;
155
+ // `gh pr view` reports no review decision as "", not null. Only this field does
156
+ // it, so the normalization stays here rather than in nullableEnum, where it
157
+ // would stop a genuinely unexpected rollup state from failing closed.
158
+ const reviewDecision = (value: unknown): T.ReviewDecision =>
159
+ nullableEnum(
160
+ value === "" ? null : value,
161
+ REVIEW_DECISIONS,
162
+ "pull request.reviewDecision"
163
+ );
164
+ function parseRemote(value: string): T.Repository | null {
165
+ let normalized = value.trim();
166
+ if (normalized.startsWith("git@github.com:"))
167
+ normalized = `https://github.com/${normalized.slice(15)}`;
168
+ if (normalized.startsWith("ssh://git@github.com/"))
169
+ normalized = `https://github.com/${normalized.slice(21)}`;
170
+ try {
171
+ const url = new URL(normalized);
172
+ const parts = url.pathname
173
+ .replace(/\.git$/, "")
174
+ .split("/")
175
+ .filter(Boolean);
176
+ if (
177
+ url.protocol !== "https:" ||
178
+ url.hostname !== "github.com" ||
179
+ url.port ||
180
+ url.username ||
181
+ url.password ||
182
+ url.search ||
183
+ url.hash ||
184
+ parts.length !== 2
185
+ )
186
+ return null;
187
+ return { owner: parts[0], repo: parts[1] };
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+ function parsePrUrl(value: string): T.PrContext {
193
+ try {
194
+ const url = new URL(value);
195
+ const parts = url.pathname.split("/").filter(Boolean);
196
+ if (
197
+ url.protocol !== "https:" ||
198
+ url.hostname !== "github.com" ||
199
+ url.port ||
200
+ url.username ||
201
+ url.password ||
202
+ url.search ||
203
+ url.hash ||
204
+ parts.length !== 4 ||
205
+ parts[2] !== "pull"
206
+ )
207
+ throw new Error("not a canonical GitHub pull URL");
208
+ return {
209
+ owner: parts[0],
210
+ repo: parts[1],
211
+ number: parsePrNumber(Number(parts[3])),
212
+ };
213
+ } catch (error) {
214
+ throw new WatcherQueryError({
215
+ kind: "invalid-context-url",
216
+ retryable: false,
217
+ rawValue: value,
218
+ detail: `could not infer owner/repo from PR URL: ${value} (${error instanceof Error ? error.message : String(error)})`,
219
+ });
220
+ }
221
+ }
222
+ function checkDetails(value: Record<string, unknown>, nameKey: string) {
223
+ return {
224
+ name: string(value[nameKey], nameKey),
225
+ description: typeof value.description === "string" ? value.description : "",
226
+ link:
227
+ typeof value.link === "string"
228
+ ? value.link
229
+ : typeof value.detailsUrl === "string"
230
+ ? value.detailsUrl
231
+ : "",
232
+ workflow: typeof value.workflow === "string" ? value.workflow : "",
233
+ };
234
+ }
235
+ export function parseFastCheck(value: unknown): T.Check {
236
+ const object = record(value, "check");
237
+ const details = checkDetails(object, "name");
238
+ const state = string(object.state, "check.state").toUpperCase();
239
+ const bucket = string(object.bucket, "check.bucket");
240
+ if (
241
+ bucket === "fail" ||
242
+ ["FAILURE", "ERROR", "ACTION_REQUIRED"].includes(state)
243
+ )
244
+ return { ...details, kind: "failed", reportedState: state };
245
+ if (bucket === "pending") return pendingOrGate(details, state);
246
+ if (bucket === "pass")
247
+ return { ...details, kind: "passed", reportedState: state };
248
+ if (bucket === "skipping")
249
+ return { ...details, kind: "skipped", reportedState: state };
250
+ return { ...details, kind: "failed", reportedState: state };
251
+ }
252
+ // The owner-approval gate is excluded from pending everywhere, so the rule has
253
+ // one home. Classifying it as pending on either read path makes the watcher
254
+ // wait on a human, which is the behaviour #172004 removed from the Python.
255
+ function pendingOrGate(
256
+ details: {
257
+ readonly name: string;
258
+ readonly description: string;
259
+ readonly link: string;
260
+ readonly workflow: string;
261
+ },
262
+ reportedState: string
263
+ ): T.Check {
264
+ return details.name === "Code Review Gate"
265
+ ? {
266
+ ...details,
267
+ kind: "code-review-gate",
268
+ name: "Code Review Gate",
269
+ reportedState,
270
+ }
271
+ : { ...details, kind: "pending", reportedState };
272
+ }
273
+ export function mapRollupNode(value: unknown): T.Check | null {
274
+ const object = record(value, "rollup node");
275
+ const typename = object.__typename;
276
+ if (typename !== "CheckRun" && typename !== "StatusContext") return null;
277
+ const details = checkDetails(
278
+ object,
279
+ typename === "CheckRun" ? "name" : "context"
280
+ );
281
+ const link =
282
+ typeof object.targetUrl === "string" ? object.targetUrl : details.link;
283
+ if (typename === "CheckRun") {
284
+ const status =
285
+ typeof object.status === "string" ? object.status.toUpperCase() : "";
286
+ const conclusion =
287
+ typeof object.conclusion === "string"
288
+ ? object.conclusion.toUpperCase()
289
+ : "";
290
+ if (status !== "COMPLETED")
291
+ return pendingOrGate({ ...details, link }, "PENDING");
292
+ if (conclusion === "SUCCESS")
293
+ return { ...details, link, kind: "passed", reportedState: "SUCCESS" };
294
+ if (conclusion === "NEUTRAL" || conclusion === "SKIPPED")
295
+ return { ...details, link, kind: "skipped", reportedState: conclusion };
296
+ return {
297
+ ...details,
298
+ link,
299
+ kind: "failed",
300
+ reportedState: conclusion === "ACTION_REQUIRED" ? conclusion : "FAILURE",
301
+ };
302
+ }
303
+ const state =
304
+ typeof object.state === "string" ? object.state.toUpperCase() : "";
305
+ if (state === "PENDING" || state === "EXPECTED")
306
+ return pendingOrGate({ ...details, link }, "PENDING");
307
+ return state === "SUCCESS"
308
+ ? { ...details, link, kind: "passed", reportedState: state }
309
+ : { ...details, link, kind: "failed", reportedState: state || "FAILURE" };
310
+ }
311
+ function parseComment(value: unknown): T.ReviewComment {
312
+ const object = record(value, "review comment");
313
+ const author =
314
+ object.author === null
315
+ ? null
316
+ : record(object.author, "review comment.author");
317
+ return {
318
+ authorLogin:
319
+ author === null
320
+ ? null
321
+ : optionalString(author.login, "review comment.author.login"),
322
+ body: string(object.body, "review comment.body"),
323
+ path: optionalString(object.path, "review comment.path"),
324
+ line:
325
+ object.line === null
326
+ ? null
327
+ : Number.isInteger(object.line)
328
+ ? Number(object.line)
329
+ : missing("review comment.line", object.line),
330
+ createdAt: string(object.createdAt, "review comment.createdAt"),
331
+ };
332
+ }
333
+ function isBugbot(comment: T.ReviewComment | null): boolean {
334
+ if (comment === null) return false;
335
+ const author = (comment.authorLogin ?? "").toLowerCase();
336
+ const body = comment.body.toLowerCase();
337
+ return (
338
+ author.includes("bugbot") ||
339
+ (author === "cursor" &&
340
+ [
341
+ "bugbot",
342
+ "cursor_automation_id",
343
+ "agentic security review",
344
+ "description start",
345
+ "severity",
346
+ ].some((token) => body.includes(token)))
347
+ );
348
+ }
349
+ function passKey(comment: T.ReviewComment | null): string | null {
350
+ if (comment === null) return null;
351
+ for (const pattern of [
352
+ /RUN_ID:\s*([a-zA-Z0-9_.:-]+)/,
353
+ /CURSOR_AUTOMATION_ID:\s*([a-zA-Z0-9_.:-]+)/,
354
+ ]) {
355
+ const match = pattern.exec(comment.body);
356
+ if (match?.[1]) return match[1];
357
+ }
358
+ return null;
359
+ }
360
+ export function parseReviewThreads(value: unknown): readonly T.ReviewThread[] {
361
+ const nodes = list(
362
+ at(value, ["data", "repository", "pullRequest", "reviewThreads", "nodes"]),
363
+ "reviewThreads.nodes"
364
+ );
365
+ const threads: {
366
+ readonly id: string;
367
+ readonly firstComment: T.ReviewComment | null;
368
+ readonly resolved: boolean;
369
+ }[] = [];
370
+ for (const node of nodes) {
371
+ const thread = record(node, "review thread");
372
+ if (typeof thread.isResolved !== "boolean")
373
+ missing("review thread.isResolved", thread.isResolved);
374
+ const comments = list(
375
+ at(thread, ["comments", "nodes"]),
376
+ "review thread.comments.nodes"
377
+ );
378
+ threads.push({
379
+ id: string(thread.id, "review thread.id"),
380
+ firstComment: comments.length === 0 ? null : parseComment(comments[0]),
381
+ resolved: thread.isResolved,
382
+ });
383
+ }
384
+ const keys = new Set<string>();
385
+ let keyless = false;
386
+ for (const thread of threads) {
387
+ if (!isBugbot(thread.firstComment)) continue;
388
+ const key = passKey(thread.firstComment);
389
+ if (key === null) keyless = true;
390
+ else keys.add(key);
391
+ }
392
+ const passes = keys.size > 0 ? keys.size : keyless ? 1 : 0;
393
+ return threads
394
+ .filter((thread) => !thread.resolved)
395
+ .map(({ id, firstComment }) => ({
396
+ id,
397
+ firstComment,
398
+ isBugbot: isBugbot(firstComment),
399
+ bugbotReviewPasses: passes,
400
+ }));
401
+ }
402
+ export function parsePullRequest(
403
+ value: unknown,
404
+ context: T.PrContext
405
+ ): T.PullRequestFacts {
406
+ const object = record(value, "pull request");
407
+ if (typeof object.isDraft !== "boolean")
408
+ missing("pull request.isDraft", object.isDraft);
409
+ return {
410
+ context,
411
+ mergeable: enumValue(
412
+ object.mergeable,
413
+ ["MERGEABLE", "CONFLICTING", "UNKNOWN"] as const,
414
+ "pull request.mergeable"
415
+ ),
416
+ mergeStateStatus: enumValue(
417
+ object.mergeStateStatus,
418
+ MERGE_STATES,
419
+ "pull request.mergeStateStatus"
420
+ ),
421
+ reviewDecision: reviewDecision(object.reviewDecision),
422
+ headRefOid: optionalString(object.headRefOid, "pull request.headRefOid"),
423
+ headRefName: string(object.headRefName, "pull request.headRefName"),
424
+ baseRefName: string(object.baseRefName, "pull request.baseRefName"),
425
+ state: enumValue(
426
+ object.state,
427
+ ["OPEN", "CLOSED", "MERGED"] as const,
428
+ "pull request.state"
429
+ ),
430
+ mergedAt: optionalString(object.mergedAt, "pull request.mergedAt"),
431
+ isDraft: object.isDraft,
432
+ };
433
+ }
434
+ function graphqlArgs(
435
+ query: string,
436
+ context: T.PrContext
437
+ ): [string, ...string[]] {
438
+ return [
439
+ "gh",
440
+ "api",
441
+ "graphql",
442
+ "-f",
443
+ `query=${query}`,
444
+ "-f",
445
+ `owner=${context.owner}`,
446
+ "-f",
447
+ `repo=${context.repo}`,
448
+ "-F",
449
+ `pr=${context.number}`,
450
+ ];
451
+ }
452
+
453
+ export class GhGitHubReader implements T.GitHubReader {
454
+ async originRepo(): Promise<T.Repository | null> {
455
+ const result = await run(["git", "remote", "get-url", "origin"]);
456
+ return result.code === 0 ? parseRemote(result.stdout) : null;
457
+ }
458
+ async currentPr(pr: T.PrNumber | null): Promise<T.PrContext> {
459
+ const argv: [string, ...string[]] = ["gh", "pr", "view"];
460
+ if (pr !== null) argv.push(String(pr));
461
+ argv.push("--json", "number,url");
462
+ const object = record(await runJson(argv), "current PR");
463
+ const parsed = parsePrUrl(string(object.url, "current PR.url"));
464
+ return {
465
+ ...parsed,
466
+ number: pr ?? parsePrNumber(object.number, "current PR.number"),
467
+ };
468
+ }
469
+ async pullRequest(context: T.PrContext): Promise<T.PullRequestFacts> {
470
+ return parsePullRequest(
471
+ await runJson([
472
+ "gh",
473
+ "pr",
474
+ "view",
475
+ String(context.number),
476
+ "--repo",
477
+ `${context.owner}/${context.repo}`,
478
+ "--json",
479
+ "mergeable,mergeStateStatus,reviewDecision,headRefOid,headRefName,baseRefName,state,mergedAt,isDraft",
480
+ ]),
481
+ context
482
+ );
483
+ }
484
+ async openPullRequests(
485
+ repository: T.Repository
486
+ ): Promise<readonly T.OpenPullRequest[]> {
487
+ const value = await runJson([
488
+ "gh",
489
+ "pr",
490
+ "list",
491
+ "--repo",
492
+ `${repository.owner}/${repository.repo}`,
493
+ "--state",
494
+ "open",
495
+ "--limit",
496
+ "300",
497
+ "--json",
498
+ "number,headRefName,baseRefName",
499
+ ]);
500
+ return list(value, "open PRs").map((item, index) => {
501
+ const object = record(item, `open PRs[${index}]`);
502
+ return {
503
+ number: parsePrNumber(object.number, `open PRs[${index}].number`),
504
+ headRefName: string(
505
+ object.headRefName,
506
+ `open PRs[${index}].headRefName`
507
+ ),
508
+ baseRefName: string(
509
+ object.baseRefName,
510
+ `open PRs[${index}].baseRefName`
511
+ ),
512
+ };
513
+ });
514
+ }
515
+ async checksFastPath(context: T.PrContext): Promise<T.ChecksFastPath> {
516
+ const result = await run([
517
+ "gh",
518
+ "pr",
519
+ "checks",
520
+ String(context.number),
521
+ "--repo",
522
+ `${context.owner}/${context.repo}`,
523
+ "--json",
524
+ "name,state,description,link,workflow,bucket",
525
+ ]);
526
+ if ([0, 1, 8].includes(result.code) && result.stdout.trim()) {
527
+ try {
528
+ const value = parseJson(result.stdout, "gh pr checks");
529
+ if (Array.isArray(value))
530
+ return { kind: "checks", checks: value.map(parseFastCheck) };
531
+ } catch (error) {
532
+ if (!(error instanceof WatcherQueryError)) throw error;
533
+ }
534
+ }
535
+ return { kind: "unusable", exitCode: result.code, stderr: result.stderr };
536
+ }
537
+ async checkRollupPage(
538
+ context: T.PrContext,
539
+ after: string | null
540
+ ): Promise<T.RollupPage> {
541
+ const argv = graphqlArgs(PR_CHECK_ROLLUP_QUERY, context);
542
+ if (after !== null) argv.push("-f", `after=${after}`);
543
+ const value = await runJson(argv);
544
+ const commits = list(
545
+ at(value, ["data", "repository", "pullRequest", "commits", "nodes"]),
546
+ "commits.nodes"
547
+ );
548
+ if (commits.length === 0) return { checks: [], endCursor: null };
549
+ const commit = record(
550
+ at(commits[commits.length - 1], ["commit"]),
551
+ "commit"
552
+ );
553
+ if (commit.statusCheckRollup === null)
554
+ return { checks: [], endCursor: null };
555
+ const contexts = record(
556
+ at(commit, ["statusCheckRollup", "contexts"]),
557
+ "contexts"
558
+ );
559
+ const checks = list(contexts.nodes, "contexts.nodes")
560
+ .map(mapRollupNode)
561
+ .filter((check): check is T.Check => check !== null);
562
+ const page = record(contexts.pageInfo, "contexts.pageInfo");
563
+ if (typeof page.hasNextPage !== "boolean")
564
+ missing("contexts.pageInfo.hasNextPage", page.hasNextPage);
565
+ const cursor = optionalString(
566
+ page.endCursor,
567
+ "contexts.pageInfo.endCursor"
568
+ );
569
+ return { checks, endCursor: page.hasNextPage && cursor ? cursor : null };
570
+ }
571
+ async reviewThreads(
572
+ context: T.PrContext
573
+ ): Promise<readonly T.ReviewThread[]> {
574
+ return parseReviewThreads(
575
+ await runJson(graphqlArgs(REVIEW_THREADS_QUERY, context))
576
+ );
577
+ }
578
+ async commitRollups(
579
+ context: T.PrContext
580
+ ): Promise<readonly T.CommitRollup[]> {
581
+ const value = await runJson(graphqlArgs(PR_COMMIT_STATUS_QUERY, context));
582
+ const commits = list(
583
+ at(value, ["data", "repository", "pullRequest", "commits", "nodes"]),
584
+ "commits.nodes"
585
+ );
586
+ return commits.map((item, index) => {
587
+ const commit = record(at(item, ["commit"]), `commits[${index}].commit`);
588
+ const rollup = commit.statusCheckRollup;
589
+ return {
590
+ oid: string(commit.oid, `commits[${index}].oid`),
591
+ state:
592
+ rollup === null
593
+ ? null
594
+ : nullableEnum(
595
+ at(rollup, ["state"]),
596
+ ROLLUP_STATES,
597
+ `commits[${index}].statusCheckRollup.state`
598
+ ),
599
+ };
600
+ });
601
+ }
602
+ }
603
+
604
+ export async function resolveChecks(
605
+ reader: T.GitHubReader,
606
+ context: T.PrContext
607
+ ): Promise<T.CheckRead> {
608
+ const fast = await reader.checksFastPath(context);
609
+ const direct = fast.kind === "checks" ? nonEmpty(fast.checks) : null;
610
+ if (direct !== null) return { source: "gh-pr-checks", checks: direct };
611
+ const checks: T.Check[] = [];
612
+ let after: string | null = null;
613
+ do {
614
+ const page = await reader.checkRollupPage(context, after);
615
+ checks.push(...page.checks);
616
+ after = page.endCursor;
617
+ } while (after !== null);
618
+ const fallback = nonEmpty(checks);
619
+ if (fallback !== null) return { source: "graphql-rollup", checks: fallback };
620
+ const suffix =
621
+ fast.kind === "unusable"
622
+ ? `fast path exit=${fast.exitCode}; GraphQL rollup was empty${firstLine(fast.stderr) ? `; ${firstLine(fast.stderr)}` : ""}`
623
+ : "fast path and GraphQL rollup were empty";
624
+ throw new ChecksUnavailable(`could not read PR checks: ${suffix}`);
625
+ }
626
+ export async function resolveContext(args: {
627
+ readonly reader: T.GitHubReader;
628
+ readonly owner: string | null;
629
+ readonly repo: string | null;
630
+ readonly pr: T.PrNumber | null;
631
+ }): Promise<T.PrContext> {
632
+ if (args.pr !== null && args.owner !== null && args.repo !== null)
633
+ return { owner: args.owner, repo: args.repo, number: args.pr };
634
+ if (args.pr !== null) {
635
+ const origin = await args.reader.originRepo();
636
+ if (origin !== null)
637
+ return {
638
+ owner: args.owner ?? origin.owner,
639
+ repo: args.repo ?? origin.repo,
640
+ number: args.pr,
641
+ };
642
+ }
643
+ const inferred = await args.reader.currentPr(args.pr);
644
+ return {
645
+ owner: args.owner ?? inferred.owner,
646
+ repo: args.repo ?? inferred.repo,
647
+ number: args.pr ?? inferred.number,
648
+ };
649
+ }
650
+ export function orderStack(
651
+ context: T.PrContext,
652
+ open: readonly T.OpenPullRequest[]
653
+ ): T.NonEmpty<T.PrContext> {
654
+ const byNumber = new Map(open.map((pr) => [pr.number, pr]));
655
+ const byHead = new Map(open.map((pr) => [pr.headRefName, pr]));
656
+ const children = new Map<string, T.OpenPullRequest[]>();
657
+ for (const pr of open)
658
+ children.set(pr.baseRefName, [...(children.get(pr.baseRefName) ?? []), pr]);
659
+ for (const values of children.values())
660
+ values.sort((a, b) => a.number - b.number);
661
+ const start = byNumber.get(context.number);
662
+ if (start === undefined) return [context];
663
+ const down: T.OpenPullRequest[] = [];
664
+ let current = start;
665
+ while (byHead.has(current.baseRefName)) {
666
+ const parent = byHead.get(current.baseRefName);
667
+ if (parent === undefined) break;
668
+ down.push(parent);
669
+ current = parent;
670
+ }
671
+ const seen = new Set<T.PrNumber>([
672
+ ...down.map((pr) => pr.number),
673
+ start.number,
674
+ ]);
675
+ const up: T.OpenPullRequest[] = [];
676
+ const visit = (parent: T.OpenPullRequest): void => {
677
+ for (const child of children.get(parent.headRefName) ?? []) {
678
+ if (seen.has(child.number)) continue;
679
+ seen.add(child.number);
680
+ up.push(child);
681
+ visit(child);
682
+ }
683
+ };
684
+ visit(start);
685
+ return (
686
+ nonEmpty(
687
+ [...down.reverse(), start, ...up].map((pr) => ({
688
+ ...context,
689
+ number: pr.number,
690
+ }))
691
+ ) ?? [context]
692
+ );
693
+ }
694
+ export async function discoverStack(
695
+ reader: T.GitHubReader,
696
+ context: T.PrContext
697
+ ): Promise<T.NonEmpty<T.PrContext>> {
698
+ return orderStack(context, await reader.openPullRequests(context));
699
+ }