pstack-claude-mirror 0.9.133-mirror.1 → 0.9.134

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 (34) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGES.md +8 -0
  3. package/README.md +4 -4
  4. package/UPSTREAM-README.md +6 -0
  5. package/docs/reference.md +4 -4
  6. package/package.json +7 -6
  7. package/plugins/pstack/.antigravity-plugin/plugin.json +1 -1
  8. package/plugins/pstack/.claude-plugin/plugin.json +1 -1
  9. package/plugins/pstack/.codex-plugin/plugin.json +1 -1
  10. package/plugins/pstack/.github/plugin/plugin.json +1 -1
  11. package/plugins/pstack/.grok-plugin/plugin.json +1 -1
  12. package/plugins/pstack/.muse-plugin/plugin.json +1 -1
  13. package/plugins/pstack/LICENSE +22 -0
  14. package/plugins/pstack/hooks/copilot-hooks.json +4 -2
  15. package/plugins/pstack/hooks/session-start.ps1 +76 -12
  16. package/plugins/pstack/hooks/session-start.sh +6 -0
  17. package/plugins/pstack/models.json +1 -1
  18. package/plugins/pstack/package.json +1 -1
  19. package/plugins/pstack/pi/slash.ts +19 -5
  20. package/plugins/pstack/skills/poteto-mode/SKILL.md +1 -0
  21. package/plugins/pstack/skills/poteto-mode/playbooks/bug-fix.md +17 -0
  22. package/plugins/pstack/skills/poteto-mode/references/agy-tools.md +5 -4
  23. package/plugins/pstack/skills/poteto-mode/references/codex-tools.md +3 -2
  24. package/plugins/pstack/skills/poteto-mode/references/copilot-tools.md +2 -1
  25. package/plugins/pstack/skills/poteto-mode/references/pi-tools.md +1 -1
  26. package/plugins/pstack/skills/poteto-mode/scripts/.prettierignore +1 -1
  27. package/plugins/pstack/skills/poteto-mode/scripts/codex-session-effort.mjs +35 -7
  28. package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.port.test.ts +427 -0
  29. package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts +3 -399
  30. package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.port.test.ts +708 -0
  31. package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts +7 -666
  32. package/plugins/pstack/skills/setup-pstack/SKILL.md +6 -4
  33. package/plugins/pstack/skills/setup-pstack/references/runtimes.md +1 -1
  34. package/tools/forks.json +5 -5
@@ -27,6 +27,7 @@ pstack skills are written in Claude Code tool language (the `Skill` tool, the `A
27
27
  poteto-mode's Subagents section sets Claude-specific defaults (`subagent_type: "pstack:poteto-agent"`, `run_in_background: true`). On GitHub Copilot:
28
28
 
29
29
  - The plugin's agents load as `task` agent types under their namespaced names, `pstack:poteto-agent`, `pstack:comment-sicko`, `pstack:thermo-nuclear-review-subagent`, and `pstack:thermo-nuclear-code-quality-review-subagent`. Pass the skill's `subagent_type` as `agent_type` unchanged.
30
+ - The VS Code agents window rejects those agent types as unknown. There, dispatch `agent_type: "general-purpose"` and tell it in the prompt to read its instructions in full first: the `poteto-mode` skill for `pstack:poteto-agent`, `poteto-mode/references/agents/comment-sicko.md` for `pstack:comment-sicko`, and the `thermo-nuclear-review` or `thermo-nuclear-code-quality-review` skill for the two reviewers.
30
31
  - `subagent_type: "general-purpose"` maps to `agent_type: "general-purpose"`. Where a skill leaves the type unset, use `pstack:poteto-agent`.
31
32
  - `readonly: true` (reviewers, explorers, critics) maps to a read-only agent type: `explore` for code reading, `code-review` for diff review. When a read-only role must run commands or reach MCP tools, use `general-purpose` and tell it in the prompt not to write files; review its diff anyway.
32
33
  - `readonly: false`, or a role that needs MCP tools, maps to `general-purpose` or `pstack:poteto-agent`.
@@ -120,7 +121,7 @@ Affected skill entry points point here. Most skills need only the tables above.
120
121
  | `swarm` | Workers are `task` calls with `mode: "background"`, each on its own worktree; in the app, `create_session` per worker. |
121
122
  | `teach` | Running `how` and `why` in parallel maps to `task` fan-out; image generation uses whatever image tool you have, or skip it and say so. |
122
123
  | `why` | Available MCP servers appear in your tool list; there is no `mcp__` prefix convention to scan for. |
123
- | `reflect` | Run `find-transcript.mjs <session-state directory> '<opening prompt fragment>' <workspace directory>` with the absolute session-state path under `${COPILOT_HOME:-~/.copilot}`. The workspace defaults to the current directory when omitted. The finder checks the session header's `cwd` against that workspace before reading Copilot's `user.message` records, so matching prompts in other projects are skipped. For a conversation in a related worktree, pass that worktree's directory explicitly. Reviewer references that name `.claude/skills/` and `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/` paths mean the Copilot skill locations below. |
124
+ | `reflect` | Run `find-transcript.mjs <session-state directory> '<opening prompt fragment>' <workspace directory>` with the absolute session-state path under `${COPILOT_HOME:-~/.copilot}`. That directory is outside the workspace, so the call takes the normal permission flow. The workspace defaults to the current directory when omitted. The finder checks the session header's `cwd` against that workspace before reading Copilot's `user.message` records, so matching prompts in other projects are skipped. For a conversation in a related worktree, pass that worktree's directory explicitly. Reviewer references that name `.claude/skills/` and `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/` paths mean the Copilot skill locations below. |
124
125
  | `recall` | Search Copilot session state scoped to the current workspace (Transcripts above), not `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/`. |
125
126
  | `show-me-your-work` | The end-of-run check reads this session's `events.jsonl` (Transcripts above). |
126
127
  | `automate-me` | Mode skills live in the Copilot skill locations below; transcripts come from Transcripts above; `plugin-dev:skill-development` resolves through the skills table. |
@@ -54,7 +54,7 @@ Skills name models by the Claude aliases in their Models sections. On Pi, pass t
54
54
  | Alias | `anthropic` | `openai` | `openai-codex` |
55
55
  | --- | --- | --- | --- |
56
56
  | `opus` | `anthropic/claude-opus-5-5` | `openai/gpt-6.1-sol` | `openai-codex/gpt-6.1-sol` |
57
- | `fable` | `anthropic/claude-fable-5-1` | `openai/gpt-6-astra` | `openai-codex/gpt-6-astra` |
57
+ | `fable` | `anthropic/claude-fable-5-2` | `openai/gpt-6-astra` | `openai-codex/gpt-6-astra` |
58
58
  | `sonnet` | `anthropic/claude-sonnet-5-5` | `openai/gpt-6.1-sol` | `openai-codex/gpt-6.1-sol` |
59
59
  | `haiku` | `anthropic/claude-haiku-5-5` | `openai/gpt-6-luna` | `openai-codex/gpt-6-luna` |
60
60
 
@@ -1,10 +1,10 @@
1
1
  # Upstream's copies are not Prettier output, so formatting the port's copies
2
2
  # would add lines to the fork.
3
3
  bootstrap.ts
4
+ watch-pr/policy.test.ts
4
5
 
5
6
  # The port added lines here that run past 80 columns; wrapping them grows the
6
7
  # forked-line count.
7
- watch-pr/policy.test.ts
8
8
  watch-pr/types.compile.ts
9
9
 
10
10
  # The check covers TypeScript. Upstream's check-plan.mjs uses tabs, and the
@@ -3,7 +3,7 @@
3
3
  // defaults, another thread, or a completed turn. Output contains no transcript.
4
4
  import { createReadStream, existsSync, readdirSync, realpathSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
- import { join } from "node:path";
6
+ import { isAbsolute, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
9
  const levels = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
@@ -40,20 +40,48 @@ export async function activeEffort(stream, threadId) {
40
40
  return effort;
41
41
  }
42
42
 
43
- export async function sessionEffort(threadId = process.env.CODEX_THREAD_ID, codexHome = process.env.CODEX_HOME) {
44
- if (!threadId || !/^[a-zA-Z0-9_-]+$/.test(threadId)) throw new Error("CODEX_THREAD_ID is unavailable or invalid");
45
- const root = join(codexHome || join(homedir(), ".codex"), "sessions");
43
+ async function currentRollout(threadId, home) {
44
+ const databases = readdirSync(home).filter((name) => /^state_\d+\.sqlite$/.test(name));
45
+ if (databases.length > 1) throw new Error("current thread state database is ambiguous");
46
+ const sqlite = databases.length === 1 ? await import("node:sqlite").catch(() => null) : null;
47
+ if (sqlite) {
48
+ const db = new sqlite.DatabaseSync(join(home, databases[0]), { readOnly: true });
49
+ try {
50
+ const row = db.prepare("SELECT rollout_path FROM threads WHERE id = ?").get(threadId);
51
+ if (row) {
52
+ const path = row.rollout_path;
53
+ if (typeof path !== "string" || !isAbsolute(path) || !path.endsWith(".jsonl")) {
54
+ throw new Error("current thread runtime log is unavailable");
55
+ }
56
+ return path;
57
+ }
58
+ } finally {
59
+ db.close();
60
+ }
61
+ }
62
+
63
+ const root = join(home, "sessions");
46
64
  const matches = [];
65
+ const suffix = new RegExp(`-${threadId}(?:_[a-zA-Z0-9-]+)?\\.jsonl(?:\\.zst)?$`);
47
66
  const walk = (directory, depth = 0) => {
48
67
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
49
68
  const path = join(directory, entry.name);
50
- if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(`-${threadId}.jsonl`)) matches.push(path);
69
+ if (entry.isFile() && entry.name.startsWith("rollout-") && suffix.test(entry.name)) matches.push(path);
51
70
  else if (entry.isDirectory() && depth < 3) walk(path, depth + 1);
52
71
  }
53
72
  };
54
73
  walk(root);
55
- if (matches.length !== 1) throw new Error("current thread runtime log is missing or ambiguous");
56
- return activeEffort(createReadStream(matches[0], { encoding: "utf8" }), threadId);
74
+ if (matches.length !== 1 || !matches[0].endsWith(`-${threadId}.jsonl`)) {
75
+ throw new Error("current thread runtime log is missing or ambiguous");
76
+ }
77
+ return matches[0];
78
+ }
79
+
80
+ export async function sessionEffort(threadId = process.env.CODEX_THREAD_ID, codexHome = process.env.CODEX_HOME) {
81
+ if (!threadId || !/^[a-zA-Z0-9_-]+$/.test(threadId)) throw new Error("CODEX_THREAD_ID is unavailable or invalid");
82
+ const home = codexHome || join(homedir(), ".codex");
83
+ const path = await currentRollout(threadId, home);
84
+ return activeEffort(createReadStream(path, { encoding: "utf8" }), threadId);
57
85
  }
58
86
 
59
87
  if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
@@ -0,0 +1,427 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { WatchDeadline } from "./deadline.ts";
3
+ import {
4
+ ChecksUnavailable,
5
+ WatcherQueryError,
6
+ discoverStack,
7
+ orderStack,
8
+ parseFastCheck,
9
+ parsePullRequest,
10
+ parseReviewThreadPage,
11
+ PR_FACTS_QUERY,
12
+ resolveChecks,
13
+ resolveContext,
14
+ resolveReviewThreads,
15
+ runJson,
16
+ } from "./github.ts";
17
+ import { fakeReader } from "./fakes.test-helper.ts";
18
+ import type { PrContext } from "./types.ts";
19
+ import { parsePrNumber } from "./types.ts";
20
+
21
+ const context = {
22
+ owner: "owner",
23
+ repo: "repo",
24
+ number: parsePrNumber(42),
25
+ };
26
+
27
+ const numbers = (stack: readonly PrContext[]): number[] =>
28
+ stack.map((pr) => Number(pr.number));
29
+
30
+ describe("checks fallback chain", () => {
31
+ it("reads no checks when gh reports none and the head commit has no rollup", async () => {
32
+ const reader = fakeReader({
33
+ fastPath: { kind: "none-reported" },
34
+ rollupPages: [{ kind: "no-rollup" }],
35
+ });
36
+ expect(await resolveChecks(reader, context)).toEqual({
37
+ kind: "no-checks",
38
+ checks: [],
39
+ });
40
+ expect(reader.calls).toEqual(["checksFastPath", "checkRollupPage:null"]);
41
+ });
42
+
43
+ it("fails closed when only one read says there are no checks", async () => {
44
+ const oneSided = [
45
+ {
46
+ fastPath: { kind: "unusable", exitCode: 1, stderr: "HTTP 401" },
47
+ rollupPages: [{ kind: "no-rollup" }],
48
+ },
49
+ {
50
+ fastPath: { kind: "checks", checks: [] },
51
+ rollupPages: [{ kind: "no-rollup" }],
52
+ },
53
+ {
54
+ fastPath: { kind: "none-reported" },
55
+ rollupPages: [{ kind: "contexts", checks: [], endCursor: null }],
56
+ },
57
+ ] as const;
58
+ for (const options of oneSided)
59
+ await expect(
60
+ resolveChecks(fakeReader(options), context)
61
+ ).rejects.toBeInstanceOf(ChecksUnavailable);
62
+ });
63
+
64
+ it("rejects a rollup cursor that does not advance instead of paging on", async () => {
65
+ const stuck = { kind: "contexts", checks: [], endCursor: "same" } as const;
66
+ const reader = fakeReader({
67
+ fastPath: { kind: "checks", checks: [] },
68
+ rollupPages: [
69
+ stuck,
70
+ stuck,
71
+ { kind: "contexts", checks: [], endCursor: null },
72
+ ],
73
+ });
74
+ await expect(resolveChecks(reader, context)).rejects.toMatchObject({
75
+ failure: {
76
+ kind: "missing-field",
77
+ retryable: true,
78
+ detail: expect.stringContaining("must advance"),
79
+ },
80
+ });
81
+ expect(reader.calls).toEqual([
82
+ "checksFastPath",
83
+ "checkRollupPage:null",
84
+ "checkRollupPage:same",
85
+ ]);
86
+ });
87
+
88
+ it("rejects a rollup cursor that returns to an earlier page after moving on", async () => {
89
+ const page = (endCursor: string | null) =>
90
+ ({ kind: "contexts", checks: [], endCursor }) as const;
91
+ const reader = fakeReader({
92
+ fastPath: { kind: "checks", checks: [] },
93
+ rollupPages: [page("a"), page("b"), page("a"), page(null)],
94
+ });
95
+ await expect(resolveChecks(reader, context)).rejects.toMatchObject({
96
+ failure: {
97
+ kind: "missing-field",
98
+ detail: expect.stringContaining("must advance"),
99
+ },
100
+ });
101
+ expect(reader.calls).toEqual([
102
+ "checksFastPath",
103
+ "checkRollupPage:null",
104
+ "checkRollupPage:a",
105
+ "checkRollupPage:b",
106
+ ]);
107
+ });
108
+
109
+ it("propagates a failed rollup query instead of reading it as no checks", async () => {
110
+ const reader = fakeReader({ fastPath: { kind: "none-reported" } });
111
+ const failure = new WatcherQueryError({
112
+ kind: "command-exit",
113
+ retryable: true,
114
+ code: 1,
115
+ detail: "HTTP 502",
116
+ });
117
+ reader.checkRollupPage = async () => {
118
+ throw failure;
119
+ };
120
+ await expect(resolveChecks(reader, context)).rejects.toBe(failure);
121
+ });
122
+ });
123
+
124
+ it("reports a binary that cannot be spawned as a query failure that does not retry", async () => {
125
+ await expect(
126
+ runJson(
127
+ ["/nonexistent/watch-pr-missing-gh", "pr", "view"],
128
+ new WatchDeadline(0, () => 0)
129
+ )
130
+ ).rejects.toMatchObject({
131
+ failure: {
132
+ kind: "spawn-failed",
133
+ retryable: false,
134
+ detail: expect.stringContaining("ENOENT"),
135
+ },
136
+ });
137
+ });
138
+
139
+ describe("fast-path check mapping", () => {
140
+ // gh's aggregate.go files every state it does not name into the pending
141
+ // bucket, including completed conclusions.
142
+ it("fails completed conclusions that gh buckets as pending", () => {
143
+ for (const state of ["STARTUP_FAILURE", "STALE"]) {
144
+ expect(
145
+ parseFastCheck({ name: "ci", state, bucket: "pending" })
146
+ ).toMatchObject({ kind: "failed", reportedState: state });
147
+ }
148
+ });
149
+
150
+ it("keeps in-flight states pending", () => {
151
+ for (const state of [
152
+ "EXPECTED",
153
+ "REQUESTED",
154
+ "WAITING",
155
+ "QUEUED",
156
+ "PENDING",
157
+ "IN_PROGRESS",
158
+ ]) {
159
+ expect(
160
+ parseFastCheck({ name: "ci", state, bucket: "pending" })
161
+ ).toMatchObject({ kind: "pending", reportedState: state });
162
+ }
163
+ });
164
+ });
165
+
166
+ describe("closed enum parsing", () => {
167
+ const rawPullRequest = {
168
+ mergeable: "MERGEABLE",
169
+ mergeStateStatus: "CLEAN",
170
+ reviewDecision: "APPROVED",
171
+ headRefOid: "head",
172
+ baseRef: { target: { oid: "base" } },
173
+ headRefName: "feature",
174
+ baseRefName: "main",
175
+ state: "OPEN",
176
+ mergedAt: null,
177
+ isDraft: false,
178
+ };
179
+
180
+ it("uses the current base target when the scalar base OID is stale", () => {
181
+ expect(
182
+ parsePullRequest(
183
+ {
184
+ ...rawPullRequest,
185
+ baseRefOid: "stale-base",
186
+ baseRef: { target: { oid: "current-base" } },
187
+ },
188
+ context
189
+ ).baseRefOid
190
+ ).toBe("current-base");
191
+ });
192
+
193
+ it("queries the current base target with the other PR facts", () => {
194
+ expect(PR_FACTS_QUERY).toContain("baseRef { target { oid } }");
195
+ expect(PR_FACTS_QUERY).not.toContain("baseRefOid");
196
+ });
197
+
198
+ it("fails closed when the current base ref is null or missing", () => {
199
+ const missingBaseRef = Object.fromEntries(
200
+ Object.entries(rawPullRequest).filter(([key]) => key !== "baseRef")
201
+ );
202
+ for (const value of [{ ...rawPullRequest, baseRef: null }, missingBaseRef])
203
+ expect(() => parsePullRequest(value, context)).toThrow(WatcherQueryError);
204
+ });
205
+ });
206
+
207
+ describe("review thread pagination", () => {
208
+ it("rejects a page that promises more pages without a cursor", () => {
209
+ for (const endCursor of [null, ""]) {
210
+ const response = {
211
+ data: {
212
+ repository: {
213
+ pullRequest: {
214
+ reviewThreads: {
215
+ pageInfo: { hasNextPage: true, endCursor },
216
+ nodes: [],
217
+ },
218
+ },
219
+ },
220
+ },
221
+ };
222
+ expect(() => parseReviewThreadPage(response)).toThrow(WatcherQueryError);
223
+ }
224
+ });
225
+
226
+ it("rejects a thread cursor that does not advance instead of paging on", async () => {
227
+ const stuck = { threads: [], endCursor: "same" };
228
+ const reader = fakeReader({
229
+ threadPages: [stuck, stuck, { threads: [], endCursor: null }],
230
+ });
231
+ await expect(resolveReviewThreads(reader, context)).rejects.toMatchObject({
232
+ failure: {
233
+ kind: "missing-field",
234
+ retryable: true,
235
+ detail: expect.stringContaining(
236
+ "reviewThreads.pageInfo.endCursor must advance"
237
+ ),
238
+ },
239
+ });
240
+ expect(reader.calls).toEqual([
241
+ "reviewThreadPage:null",
242
+ "reviewThreadPage:same",
243
+ ]);
244
+ });
245
+ });
246
+
247
+ describe("context and stack discovery", () => {
248
+ it("refuses to pair the checkout's PR number with a different explicit repository", async () => {
249
+ const reader = fakeReader({
250
+ current: { owner: "acme", repo: "web", number: parsePrNumber(57) },
251
+ });
252
+ const resolved = resolveContext({
253
+ reader,
254
+ owner: "acme",
255
+ repo: "api",
256
+ pr: null,
257
+ });
258
+ await expect(resolved).rejects.toBeInstanceOf(WatcherQueryError);
259
+ await expect(resolved).rejects.toMatchObject({
260
+ failure: { retryable: false },
261
+ });
262
+ });
263
+
264
+ it("accepts an explicit repository that matches the checkout's PR", async () => {
265
+ const reader = fakeReader({
266
+ current: { owner: "acme", repo: "web", number: parsePrNumber(57) },
267
+ });
268
+ expect(
269
+ await resolveContext({ reader, owner: "ACME", repo: "Web", pr: null })
270
+ ).toEqual({ owner: "ACME", repo: "Web", number: parsePrNumber(57) });
271
+ });
272
+
273
+ it("keeps a PR whose head is the default branch out of every stack", () => {
274
+ const repo = { owner: "owner", repo: "repo" };
275
+ const backport = parsePrNumber(2);
276
+ const open = [
277
+ {
278
+ number: backport,
279
+ headRepository: repo,
280
+ headRefName: "main",
281
+ baseRefName: "release",
282
+ },
283
+ {
284
+ number: context.number,
285
+ headRepository: repo,
286
+ headRefName: "feature",
287
+ baseRefName: "main",
288
+ },
289
+ {
290
+ number: parsePrNumber(43),
291
+ headRepository: repo,
292
+ headRefName: "upstack",
293
+ baseRefName: "feature",
294
+ },
295
+ ];
296
+ expect(numbers(orderStack(context, "main", open))).toEqual([42, 43]);
297
+ expect(
298
+ numbers(orderStack({ ...context, number: backport }, "main", open))
299
+ ).toEqual([2]);
300
+ });
301
+
302
+ it("keeps a PR that brings the default branch into a feature branch out of that feature's stack", () => {
303
+ const repo = { owner: "owner", repo: "repo" };
304
+ const sync = parsePrNumber(7);
305
+ const open = [
306
+ {
307
+ number: context.number,
308
+ headRepository: repo,
309
+ headRefName: "feature",
310
+ baseRefName: "main",
311
+ },
312
+ {
313
+ number: sync,
314
+ headRepository: repo,
315
+ headRefName: "main",
316
+ baseRefName: "feature",
317
+ },
318
+ ];
319
+ expect(numbers(orderStack(context, "main", open))).toEqual([42]);
320
+ expect(
321
+ numbers(orderStack({ ...context, number: sync }, "main", open))
322
+ ).toEqual([7]);
323
+ });
324
+
325
+ it("still stacks a fork PR whose head branch has the default branch's name", () => {
326
+ const fork = parsePrNumber(50);
327
+ const open = [
328
+ {
329
+ number: context.number,
330
+ headRepository: { owner: "owner", repo: "repo" },
331
+ headRefName: "feature",
332
+ baseRefName: "main",
333
+ },
334
+ {
335
+ number: fork,
336
+ headRepository: { owner: "contributor", repo: "repo" },
337
+ headRefName: "main",
338
+ baseRefName: "feature",
339
+ },
340
+ ];
341
+ expect(numbers(orderStack(context, "main", open))).toEqual([42, 50]);
342
+ expect(
343
+ numbers(orderStack({ ...context, number: fork }, "main", open))
344
+ ).toEqual([42, 50]);
345
+ });
346
+
347
+ it("stops at the default branch when it is an integration branch with a release PR", () => {
348
+ const repo = { owner: "owner", repo: "repo" };
349
+ const open = [
350
+ {
351
+ number: parsePrNumber(500),
352
+ headRepository: repo,
353
+ headRefName: "develop",
354
+ baseRefName: "main",
355
+ },
356
+ {
357
+ number: parsePrNumber(612),
358
+ headRepository: repo,
359
+ headRefName: "feature/login",
360
+ baseRefName: "develop",
361
+ },
362
+ {
363
+ number: parsePrNumber(613),
364
+ headRepository: repo,
365
+ headRefName: "fix/typo",
366
+ baseRefName: "develop",
367
+ },
368
+ ];
369
+ expect(
370
+ numbers(
371
+ orderStack({ ...repo, number: parsePrNumber(612) }, "develop", open)
372
+ )
373
+ ).toEqual([612]);
374
+ expect(
375
+ numbers(
376
+ orderStack({ ...repo, number: parsePrNumber(500) }, "develop", open)
377
+ )
378
+ ).toEqual([500]);
379
+ });
380
+
381
+ it("reads the default branch once while discovering a stack", async () => {
382
+ const reader = fakeReader({ defaultBranch: "trunk" });
383
+ expect(await discoverStack(reader, context)).toEqual([context]);
384
+ expect(reader.calls).toEqual(["openPullRequests", "defaultBranch"]);
385
+ });
386
+
387
+ it("orders a discovered stack around the default branch the repository reports", async () => {
388
+ const repo = { owner: "owner", repo: "repo" };
389
+ const reader = fakeReader({
390
+ defaultBranch: "develop",
391
+ openPullRequests: [
392
+ {
393
+ number: parsePrNumber(500),
394
+ headRepository: repo,
395
+ headRefName: "develop",
396
+ baseRefName: "main",
397
+ },
398
+ {
399
+ number: context.number,
400
+ headRepository: repo,
401
+ headRefName: "feature",
402
+ baseRefName: "develop",
403
+ },
404
+ ],
405
+ });
406
+ expect(numbers(await discoverStack(reader, context))).toEqual([42]);
407
+ });
408
+
409
+ it("refuses a full open-PR page, which may have cut the stack", async () => {
410
+ const openPrs = (count: number) =>
411
+ Array.from({ length: count }, (_, index) => ({
412
+ number: parsePrNumber(index + 1),
413
+ headRepository: { owner: "owner", repo: "repo" },
414
+ headRefName: `branch-${index + 1}`,
415
+ baseRefName: "main",
416
+ }));
417
+ await expect(
418
+ discoverStack(fakeReader({ openPullRequests: openPrs(300) }), context)
419
+ ).rejects.toMatchObject({ failure: { kind: "invalid-stack" } });
420
+ expect(
421
+ await discoverStack(
422
+ fakeReader({ openPullRequests: openPrs(299) }),
423
+ context
424
+ )
425
+ ).toEqual([context]);
426
+ });
427
+ });