pstack-claude-mirror 0.9.133 → 0.9.135

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 (37) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGES.md +16 -0
  3. package/README.md +23 -7
  4. package/UPSTREAM-README.md +7 -1
  5. package/bin/pstack-claude.mjs +278 -130
  6. package/docs/reference.md +4 -4
  7. package/package.json +27 -9
  8. package/plugins/pstack/.antigravity-plugin/plugin.json +1 -1
  9. package/plugins/pstack/.claude-plugin/plugin.json +1 -1
  10. package/plugins/pstack/.codex-plugin/plugin.json +1 -1
  11. package/plugins/pstack/.github/plugin/plugin.json +1 -1
  12. package/plugins/pstack/.grok-plugin/plugin.json +1 -1
  13. package/plugins/pstack/.muse-plugin/plugin.json +1 -1
  14. package/plugins/pstack/LICENSE +22 -0
  15. package/plugins/pstack/hooks/copilot-hooks.json +4 -2
  16. package/plugins/pstack/hooks/session-start.ps1 +76 -12
  17. package/plugins/pstack/hooks/session-start.sh +6 -0
  18. package/plugins/pstack/models.json +1 -1
  19. package/plugins/pstack/package.json +1 -1
  20. package/plugins/pstack/pi/slash.ts +19 -5
  21. package/plugins/pstack/skills/poteto-mode/SKILL.md +1 -0
  22. package/plugins/pstack/skills/poteto-mode/playbooks/bug-fix.md +17 -0
  23. package/plugins/pstack/skills/poteto-mode/references/agy-tools.md +5 -4
  24. package/plugins/pstack/skills/poteto-mode/references/codex-tools.md +3 -2
  25. package/plugins/pstack/skills/poteto-mode/references/copilot-tools.md +2 -1
  26. package/plugins/pstack/skills/poteto-mode/references/pi-tools.md +1 -1
  27. package/plugins/pstack/skills/poteto-mode/scripts/.prettierignore +1 -1
  28. package/plugins/pstack/skills/poteto-mode/scripts/codex-session-effort.mjs +35 -7
  29. package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.port.test.ts +427 -0
  30. package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts +3 -399
  31. package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.port.test.ts +708 -0
  32. package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts +7 -666
  33. package/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.mjs +42 -1
  34. package/plugins/pstack/skills/reflect/scripts/find-transcript.mjs +2 -2
  35. package/plugins/pstack/skills/setup-pstack/SKILL.md +6 -4
  36. package/plugins/pstack/skills/setup-pstack/references/runtimes.md +1 -1
  37. package/tools/forks.json +5 -5
@@ -7,7 +7,6 @@ import {
7
7
  classifyPr,
8
8
  createQueueState,
9
9
  evaluateQueue,
10
- noChecksConfirmer,
11
10
  planQueue,
12
11
  queryBackoffSeconds,
13
12
  readSnapshot,
@@ -66,7 +65,7 @@ describe("readiness truth table", () => {
66
65
  ];
67
66
  for (const [mergeStateStatus, headRollupState, expected] of cases) {
68
67
  expect(
69
- assessGitHubMerge({ mergeStateStatus, headRollupState }).kind,
68
+ assessGitHubMerge({ mergeStateStatus, headRollupState }).kind
70
69
  ).toBe(expected);
71
70
  }
72
71
  });
@@ -149,7 +148,7 @@ describe("snapshot query planning", () => {
149
148
  pendingHistory: "include",
150
149
  allowDraft: false,
151
150
  })
152
- ).kind,
151
+ ).kind
153
152
  ).toBe("merged");
154
153
  expect(reader.calls).toEqual(["pullRequest"]);
155
154
  });
@@ -326,7 +325,7 @@ describe("queued-stack cadence", () => {
326
325
  state,
327
326
  await openSnapshot(queue[0]),
328
327
  0,
329
- options,
328
+ options
330
329
  );
331
330
  expect(first.completedSweepRows).toBeNull();
332
331
  state = first.state;
@@ -334,10 +333,10 @@ describe("queued-stack cadence", () => {
334
333
  state,
335
334
  await openSnapshot(queue[1]),
336
335
  5,
337
- options,
336
+ options
338
337
  );
339
338
  expect(
340
- second.completedSweepRows?.map((row) => Number(row.context.number)),
339
+ second.completedSweepRows?.map((row) => Number(row.context.number))
341
340
  ).toEqual([30, 31]);
342
341
  expect(second.state.nextSweepAt).toBe(305);
343
342
  });
@@ -409,7 +408,7 @@ describe("queued-stack cadence", () => {
409
408
  state,
410
409
  await openSnapshot(queue[0]),
411
410
  0,
412
- options,
411
+ options
413
412
  ).state;
414
413
  const first = evaluateQueue(state, options);
415
414
  expect(first.kind).toBe("waiting");
@@ -429,665 +428,6 @@ it("uses the specified retry floor and cap", () => {
429
428
  expect(queryBackoffSeconds(60, 4)).toBe(300);
430
429
  });
431
430
 
432
- describe("review gate", () => {
433
- it("blocks on a required review instead of reporting a blocked PR ready", async () => {
434
- const snapshot = await readSnapshot({
435
- reader: fakeReader({
436
- facts: { reviewDecision: "REVIEW_REQUIRED", mergeStateStatus: "BLOCKED" },
437
- }),
438
- context: context(23),
439
- pendingHistory: "omit",
440
- allowDraft: false,
441
- });
442
- expect(classifyPr(snapshot)).toEqual({
443
- kind: "blocker",
444
- blocker: { kind: "merge-gate", pr: context(23), reason: "review-required" },
445
- });
446
- expect(
447
- selectTierMajorStackDecision([snapshot] as NonEmpty<typeof snapshot>),
448
- ).toMatchObject({
449
- kind: "blocker",
450
- blocker: { kind: "merge-gate", reason: "review-required" },
451
- });
452
- });
453
-
454
- it("blocks when branch protection holds an approved PR with clean CI", async () => {
455
- const snapshot = await readSnapshot({
456
- reader: fakeReader({ facts: { mergeStateStatus: "BLOCKED" } }),
457
- context: context(24),
458
- pendingHistory: "omit",
459
- allowDraft: false,
460
- });
461
- expect(classifyPr(snapshot)).toMatchObject({
462
- kind: "blocker",
463
- blocker: { kind: "merge-gate", reason: "merge-blocked" },
464
- });
465
- });
466
-
467
- it("waits for pending checks before reporting the review gate", async () => {
468
- const snapshot = await readSnapshot({
469
- reader: fakeReader({
470
- facts: { reviewDecision: "REVIEW_REQUIRED", mergeStateStatus: "BLOCKED" },
471
- fastPath: { kind: "checks", checks: [pendingCheck()] },
472
- }),
473
- context: context(26),
474
- pendingHistory: "omit",
475
- allowDraft: false,
476
- });
477
- expect(classifyPr(snapshot)).toMatchObject({ kind: "waiting" });
478
- });
479
-
480
- it("still reports changes requested as a merge-gate blocker", async () => {
481
- const snapshot = await readSnapshot({
482
- reader: fakeReader({
483
- facts: { reviewDecision: "CHANGES_REQUESTED", mergeStateStatus: "BLOCKED" },
484
- }),
485
- context: context(25),
486
- pendingHistory: "omit",
487
- allowDraft: false,
488
- });
489
- expect(classifyPr(snapshot)).toMatchObject({
490
- kind: "blocker",
491
- blocker: { kind: "merge-gate", reason: "changes-requested" },
492
- });
493
- });
494
- });
495
-
496
- describe("facts that change while the snapshot is read", () => {
497
- const read = (options: Parameters<typeof fakeReader>[0]) =>
498
- readSnapshot({
499
- reader: fakeReader(options),
500
- context: context(29),
501
- pendingHistory: "include",
502
- allowDraft: false,
503
- });
504
-
505
- it("retries instead of reporting a merge gate the checks read has already cleared", async () => {
506
- await expect(
507
- read({
508
- facts: { mergeStateStatus: "BLOCKED" },
509
- factsOnReread: { mergeStateStatus: "CLEAN" },
510
- }),
511
- ).rejects.toMatchObject({
512
- failure: { kind: "snapshot-changed", retryable: true },
513
- });
514
- });
515
-
516
- it("retries instead of reporting ready when a review lands after the facts read", async () => {
517
- for (const factsOnReread of [
518
- { reviewDecision: "CHANGES_REQUESTED" },
519
- { mergeable: "CONFLICTING" },
520
- { mergeable: "UNKNOWN" },
521
- { isDraft: true },
522
- ] as const)
523
- await expect(read({ factsOnReread })).rejects.toMatchObject({
524
- failure: { kind: "snapshot-changed", retryable: true },
525
- });
526
- });
527
-
528
- it("retries when the PR closes, merges, or renames its head branch between the two reads", async () => {
529
- for (const factsOnReread of [
530
- { state: "CLOSED" },
531
- { mergedAt: "2026-07-26T00:00:00Z" },
532
- { headRefName: "renamed" },
533
- ] as const)
534
- await expect(read({ factsOnReread })).rejects.toMatchObject({
535
- failure: { kind: "snapshot-changed", retryable: true },
536
- });
537
- });
538
-
539
- it("keeps an unknown first read when GitHub computes mergeability before the re-read", async () => {
540
- const unknown = {
541
- mergeable: "UNKNOWN",
542
- mergeStateStatus: "UNKNOWN",
543
- } as const;
544
- expect(
545
- await read({
546
- facts: unknown,
547
- factsOnReread: { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" },
548
- }),
549
- ).toMatchObject({ kind: "open", facts: unknown });
550
- await expect(
551
- read({
552
- facts: unknown,
553
- factsOnReread: { reviewDecision: "CHANGES_REQUESTED" },
554
- }),
555
- ).rejects.toMatchObject({ failure: { kind: "snapshot-changed" } });
556
- });
557
- });
558
-
559
- const ticking = (onSleep = () => {}) => {
560
- let now = 0;
561
- return {
562
- now: () => now,
563
- async sleep(seconds: number) {
564
- now += seconds;
565
- // A wait that never ends would spin on this clock without yielding to
566
- // the test runner's own timeout.
567
- if (now > 3600) throw new Error("no verdict within an hour of polling");
568
- onSleep();
569
- },
570
- };
571
- };
572
- const dependencies = (
573
- reader: GitHubReader,
574
- clock: { now: () => number; sleep: (seconds: number) => Promise<void> },
575
- emitted: ProgressVerdict[],
576
- timeout = 0,
577
- ) => ({
578
- reader,
579
- emit: (verdict: ProgressVerdict) => {
580
- emitted.push(verdict);
581
- },
582
- clock: { ...clock, observedAt: () => "2026-07-26T00:00:00.000Z" },
583
- deadline: new WatchDeadline(timeout, clock.now),
584
- });
585
- const kinds = (emitted: readonly ProgressVerdict[]) =>
586
- emitted.map((verdict) =>
587
- verdict.kind === "WAITING"
588
- ? `${verdict.kind}:${verdict.reason.kind}`
589
- : verdict.kind,
590
- );
591
-
592
- describe("mergeability", () => {
593
- const unknown = {
594
- mergeable: "UNKNOWN",
595
- mergeStateStatus: "UNKNOWN",
596
- } as const;
597
- const read = (options: Parameters<typeof fakeReader>[0]) =>
598
- readSnapshot({
599
- reader: fakeReader(options),
600
- context: context(27),
601
- pendingHistory: "include",
602
- allowDraft: false,
603
- });
604
- // One query error ends the run, so a wait that spent the budget would exit 7.
605
- const watch = async (
606
- reader: GitHubReader,
607
- polling: Partial<PollingOptions> = {},
608
- statusOnly = false,
609
- ) => {
610
- const clock = ticking();
611
- const emitted: ProgressVerdict[] = [];
612
- const verdict = await runSimple({
613
- dependencies: dependencies(reader, clock, emitted, polling.timeout),
614
- contexts: [context(27)],
615
- mode: "single",
616
- statusOnly,
617
- options: { ...options, maxQueryErrors: 1, ...polling },
618
- });
619
- return { verdict, emitted: kinds(emitted), elapsed: clock.now() };
620
- };
621
-
622
- it("waits while GitHub has not computed mergeability instead of reporting ready", async () => {
623
- for (const facts of [
624
- { mergeable: "UNKNOWN" },
625
- { mergeStateStatus: "UNKNOWN" },
626
- ] as const) {
627
- const snapshot = await read({ facts });
628
- const waiting = {
629
- kind: "waiting",
630
- frontier: context(27),
631
- reason: { kind: "mergeability-unknown" },
632
- } as const;
633
- expect(classifyPr(snapshot)).toEqual(waiting);
634
- expect(selectTierMajorStackDecision([snapshot])).toEqual(waiting);
635
- }
636
- });
637
-
638
- it("re-polls at the interval after an unknown first read, without spending the query-error budget", async () => {
639
- const { verdict, emitted, elapsed } = await watch(
640
- fakeReader({
641
- facts: unknown,
642
- factsOnReread: { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" },
643
- }),
644
- );
645
- expect(emitted).toEqual(["WAITING:mergeability-unknown"]);
646
- expect(elapsed).toBe(options.interval);
647
- expect(verdict).toMatchObject({ kind: "READY", exitCode: 0 });
648
- });
649
-
650
- it("times out at the caller's deadline when GitHub never computes it", async () => {
651
- const { verdict, emitted, elapsed } = await watch(
652
- fakeReader({ facts: unknown }),
653
- { timeout: 25 },
654
- );
655
- expect(emitted).toEqual([
656
- "WAITING:mergeability-unknown",
657
- "WAITING:mergeability-unknown",
658
- "WAITING:mergeability-unknown",
659
- ]);
660
- expect(elapsed).toBe(25);
661
- expect(verdict).toMatchObject({
662
- kind: "TIMEOUT",
663
- exitCode: 5,
664
- reason: { kind: "mergeability-unknown" },
665
- });
666
- });
667
-
668
- it("reports an unknown row on a status-only pass and exits 0 without waiting", async () => {
669
- const { verdict, emitted, elapsed } = await watch(
670
- fakeReader({ facts: unknown }),
671
- {},
672
- true,
673
- );
674
- expect(emitted).toEqual([]);
675
- expect(elapsed).toBe(0);
676
- expect(verdict).toMatchObject({
677
- kind: "STATUS",
678
- exitCode: 0,
679
- rows: [{ kind: "open", facts: unknown, ci: { kind: "ci-clean" } }],
680
- });
681
- });
682
-
683
- it("still stops at once on a blocker that does not depend on mergeability", async () => {
684
- const thread = {
685
- id: "thread",
686
- firstComment: null,
687
- isBugbot: false,
688
- bugbotReviewPasses: 0,
689
- };
690
- const cases = [
691
- [{ threads: [thread] }, { kind: "review-threads" }],
692
- [
693
- {
694
- fastPath: { kind: "checks", checks: [failedCheck()] },
695
- commitRollups: [{ oid: "head", state: "FAILURE" }],
696
- },
697
- { kind: "failing-checks" },
698
- ],
699
- [
700
- { facts: { ...unknown, reviewDecision: "CHANGES_REQUESTED" } },
701
- { kind: "merge-gate", reason: "changes-requested" },
702
- ],
703
- ] as const;
704
- for (const [overrides, blocker] of cases)
705
- expect(
706
- classifyPr(await read({ facts: unknown, ...overrides })),
707
- ).toMatchObject({ kind: "blocker", blocker });
708
- });
709
-
710
- it("defers a required review, and names pending checks first, while mergeability is unknown", async () => {
711
- expect(
712
- classifyPr(
713
- await read({ facts: { ...unknown, reviewDecision: "REVIEW_REQUIRED" } }),
714
- ),
715
- ).toMatchObject({
716
- kind: "waiting",
717
- reason: { kind: "mergeability-unknown" },
718
- });
719
- expect(
720
- classifyPr(
721
- await read({
722
- facts: unknown,
723
- fastPath: { kind: "checks", checks: [pendingCheck()] },
724
- }),
725
- ),
726
- ).toMatchObject({ kind: "waiting", reason: { kind: "pending-checks" } });
727
- });
728
-
729
- it("does not report a queued frontier blocker-free while its mergeability is unknown", async () => {
730
- let sleeps = 0;
731
- const emitted: ProgressVerdict[] = [];
732
- const running = runQueued({
733
- dependencies: dependencies(
734
- fakeReader({ facts: unknown }),
735
- ticking(() => {
736
- if (++sleeps === 2) throw new Error("stop after two polls");
737
- }),
738
- emitted,
739
- ),
740
- contexts: [context(27)],
741
- options,
742
- });
743
- await expect(running).rejects.toThrow("stop after two polls");
744
- expect(kinds(emitted)).toEqual([
745
- "QUEUE",
746
- "STATUS",
747
- "WAITING:mergeability-unknown",
748
- ]);
749
- });
750
-
751
- it("gates a branch that is behind its base instead of reporting it ready", async () => {
752
- const snapshot = await readSnapshot({
753
- reader: fakeReader({ facts: { mergeStateStatus: "BEHIND" } }),
754
- context: context(28),
755
- pendingHistory: "include",
756
- allowDraft: false,
757
- });
758
- expect(classifyPr(snapshot)).toEqual({
759
- kind: "blocker",
760
- blocker: { kind: "merge-gate", pr: context(28), reason: "behind-base" },
761
- });
762
- });
763
-
764
- it("stops at once on a branch behind its base, without waiting for checks that are pending or unreported", async () => {
765
- for (const checks of [
766
- { fastPath: { kind: "checks", checks: [pendingCheck()] } },
767
- {
768
- fastPath: { kind: "none-reported" },
769
- rollupPages: [{ kind: "no-rollup" }],
770
- commitRollups: [{ oid: "head", state: null }],
771
- },
772
- ] as const)
773
- expect(
774
- classifyPr(
775
- await read({ facts: { mergeStateStatus: "BEHIND" }, ...checks }),
776
- ),
777
- ).toEqual({
778
- kind: "blocker",
779
- blocker: { kind: "merge-gate", pr: context(27), reason: "behind-base" },
780
- });
781
- });
782
-
783
- it("reports requested changes before a behind base, and a behind base before a required review", async () => {
784
- for (const [reviewDecision, reason] of [
785
- ["CHANGES_REQUESTED", "changes-requested"],
786
- ["REVIEW_REQUIRED", "behind-base"],
787
- ] as const)
788
- expect(
789
- classifyPr(
790
- await read({
791
- facts: { mergeStateStatus: "BEHIND", reviewDecision },
792
- fastPath: { kind: "checks", checks: [pendingCheck()] },
793
- }),
794
- ),
795
- ).toMatchObject({ kind: "blocker", blocker: { reason } });
796
- });
797
- });
798
-
799
- describe("a PR with no checks configured", () => {
800
- const noChecks = {
801
- fastPath: { kind: "none-reported" },
802
- rollupPages: [{ kind: "no-rollup" }],
803
- commitRollups: [{ oid: "head", state: null }],
804
- } as const;
805
- const read = (
806
- overrides: Parameters<typeof fakeReader>[0] = {},
807
- confirmNoChecks = () => true,
808
- ) =>
809
- readSnapshot({
810
- reader: fakeReader({ ...noChecks, ...overrides }),
811
- context: context(30),
812
- pendingHistory: "omit",
813
- allowDraft: false,
814
- confirmNoChecks,
815
- });
816
- const run = (...args: Parameters<typeof dependencies>) =>
817
- runSimple({
818
- dependencies: dependencies(...args),
819
- contexts: [context(30)],
820
- mode: "single",
821
- statusOnly: false,
822
- options,
823
- });
824
- // The suite polls every 10 seconds, so the 60 second floor is six polls.
825
- const sixPolls = (...perPoll: string[]) =>
826
- Array.from({ length: 6 }, () => perPoll).flat();
827
-
828
- it("is ready once the no-checks reading is confirmed and GitHub reports it mergeable", async () => {
829
- const snapshot = await read({ facts: { reviewDecision: null } });
830
- expect(classifyPr(snapshot)).toMatchObject({
831
- kind: "ready",
832
- pr: { proof: { ci: { kind: "ci-none" } } },
833
- });
834
- });
835
-
836
- it("waits on a first sighting instead of reporting ready or a merge gate", async () => {
837
- for (const facts of [
838
- { reviewDecision: null },
839
- { mergeStateStatus: "BLOCKED" },
840
- { reviewDecision: "REVIEW_REQUIRED" },
841
- ] as const) {
842
- const snapshot = await read({ facts }, () => false);
843
- expect(snapshot).toMatchObject({
844
- kind: "open",
845
- ci: { kind: "ci-unreported" },
846
- });
847
- expect(classifyPr(snapshot)).toEqual({
848
- kind: "waiting",
849
- frontier: context(30),
850
- reason: { kind: "checks-unreported" },
851
- });
852
- }
853
- });
854
-
855
- it("confirms no checks only once the same head has shown none for 60 seconds", () => {
856
- let now = 0;
857
- const confirm = noChecksConfirmer({ now: () => now });
858
- const head = { context: context(30), headRefOid: "head" };
859
- expect(confirm(head)).toBe(false);
860
- now = 59;
861
- expect(confirm(head)).toBe(false);
862
- now = 60;
863
- expect(confirm(head)).toBe(true);
864
- expect(confirm({ ...head, headRefOid: "pushed" })).toBe(false);
865
- now = 119;
866
- expect(confirm({ ...head, headRefOid: "pushed" })).toBe(false);
867
- now = 120;
868
- expect(confirm({ ...head, headRefOid: "pushed" })).toBe(true);
869
- });
870
-
871
- it("times each PR from its own first sighting, not from another PR's", () => {
872
- let now = 0;
873
- const confirm = noChecksConfirmer({ now: () => now });
874
- const first = { context: context(30), headRefOid: "head" };
875
- const second = { context: context(31), headRefOid: "head" };
876
- expect(confirm(first)).toBe(false);
877
- now = 60;
878
- expect(confirm(second)).toBe(false);
879
- expect(confirm(first)).toBe(true);
880
- now = 120;
881
- expect(confirm(second)).toBe(true);
882
- });
883
-
884
- it("reads no checks as unreported when the caller supplies no confirmation", async () => {
885
- expect(
886
- await readSnapshot({
887
- reader: fakeReader(noChecks),
888
- context: context(30),
889
- pendingHistory: "omit",
890
- allowDraft: false,
891
- }),
892
- ).toMatchObject({ kind: "open", ci: { kind: "ci-unreported" } });
893
- });
894
-
895
- it("reports a repository with no CI ready 60 seconds after the first sighting, however short the interval", async () => {
896
- const clock = ticking();
897
- const emitted: ProgressVerdict[] = [];
898
- const verdict = await run(
899
- fakeReader({ ...noChecks, facts: { reviewDecision: null } }),
900
- clock,
901
- emitted,
902
- );
903
- expect(kinds(emitted)).toEqual(sixPolls("WAITING:checks-unreported"));
904
- expect(clock.now()).toBe(60);
905
- expect(verdict).toMatchObject({
906
- kind: "READY",
907
- scope: { pr: { proof: { ci: { kind: "ci-none" } } } },
908
- });
909
- });
910
-
911
- it("stops at the merge gate only once a blocked PR has shown no checks for 60 seconds", async () => {
912
- const clock = ticking();
913
- const emitted: ProgressVerdict[] = [];
914
- const verdict = await run(
915
- fakeReader({
916
- ...noChecks,
917
- facts: { mergeStateStatus: "BLOCKED", reviewDecision: null },
918
- }),
919
- clock,
920
- emitted,
921
- );
922
- expect(kinds(emitted)).toEqual(sixPolls("WAITING:checks-unreported"));
923
- expect(clock.now()).toBe(60);
924
- expect(verdict).toMatchObject({
925
- kind: "BLOCKER",
926
- exitCode: 6,
927
- blocker: { kind: "merge-gate", reason: "merge-blocked" },
928
- });
929
- });
930
-
931
- it("waits through a first sighting and honours checks that register on the next poll", async () => {
932
- const base = fakeReader({
933
- ...noChecks,
934
- facts: { mergeStateStatus: "BLOCKED", reviewDecision: null },
935
- });
936
- let polls = 0;
937
- const reader = {
938
- ...base,
939
- async checksFastPath() {
940
- polls += 1;
941
- return polls === 1
942
- ? { kind: "none-reported" as const }
943
- : { kind: "checks" as const, checks: [pendingCheck("required-ci")] };
944
- },
945
- } satisfies GitHubReader;
946
- let sleeps = 0;
947
- const emitted: ProgressVerdict[] = [];
948
- const running = run(
949
- reader,
950
- ticking(() => {
951
- if (++sleeps === 2) throw new Error("stop after two polls");
952
- }),
953
- emitted,
954
- );
955
- await expect(running).rejects.toThrow("stop after two polls");
956
- expect(kinds(emitted)).toEqual([
957
- "WAITING:checks-unreported",
958
- "WAITING:pending-checks",
959
- ]);
960
- });
961
-
962
- it("times out unconfirmed when the deadline is shorter than the interval or than the 60 seconds", async () => {
963
- for (const [timeout, polls] of [
964
- [options.interval / 2, 1],
965
- [30, 3],
966
- ] as const) {
967
- const emitted: ProgressVerdict[] = [];
968
- const verdict = await run(
969
- fakeReader({ ...noChecks, facts: { reviewDecision: null } }),
970
- ticking(),
971
- emitted,
972
- timeout,
973
- );
974
- expect(kinds(emitted)).toEqual(
975
- Array.from({ length: polls }, () => "WAITING:checks-unreported"),
976
- );
977
- expect(verdict).toMatchObject({
978
- kind: "TIMEOUT",
979
- exitCode: 5,
980
- reason: { kind: "checks-unreported" },
981
- });
982
- }
983
- });
984
-
985
- it("confirms each PR of a stack on its own sightings", async () => {
986
- const clock = ticking();
987
- const emitted: ProgressVerdict[] = [];
988
- const verdict = await runSimple({
989
- dependencies: dependencies(
990
- fakeReader({ ...noChecks, facts: { reviewDecision: null } }),
991
- clock,
992
- emitted,
993
- ),
994
- contexts: [context(30), context(31)],
995
- mode: "stack",
996
- statusOnly: false,
997
- options,
998
- });
999
- expect(kinds(emitted)).toEqual([
1000
- ...sixPolls("STATUS", "WAITING:checks-unreported"),
1001
- "STATUS",
1002
- ]);
1003
- expect(clock.now()).toBe(60);
1004
- expect(verdict).toMatchObject({
1005
- kind: "READY",
1006
- scope: {
1007
- kind: "stack",
1008
- prs: [
1009
- { context: context(30), proof: { ci: { kind: "ci-none" } } },
1010
- { context: context(31), proof: { ci: { kind: "ci-none" } } },
1011
- ],
1012
- },
1013
- });
1014
- });
1015
-
1016
- it("holds a queued frontier for the 60 seconds before reporting it blocker-free", async () => {
1017
- const emitted: ProgressVerdict[] = [];
1018
- const emittedAt: number[] = [];
1019
- const clock = ticking(() => {
1020
- if (clock.now() > 60) throw new Error("stop after the confirmation");
1021
- });
1022
- const queued = dependencies(
1023
- fakeReader({ ...noChecks, facts: { reviewDecision: null } }),
1024
- clock,
1025
- emitted,
1026
- );
1027
- const running = runQueued({
1028
- dependencies: {
1029
- ...queued,
1030
- emit(verdict) {
1031
- emittedAt.push(clock.now());
1032
- queued.emit(verdict);
1033
- },
1034
- },
1035
- contexts: [context(30)],
1036
- options,
1037
- });
1038
- await expect(running).rejects.toThrow("stop after the confirmation");
1039
- expect(kinds(emitted)).toEqual([
1040
- "QUEUE",
1041
- "STATUS",
1042
- "WAITING:checks-unreported",
1043
- "WAITING:merge-queue",
1044
- ]);
1045
- expect(emittedAt).toEqual([0, 0, 0, 60]);
1046
- });
1047
-
1048
- it("still stops on conflicts, review threads, and merge gates", async () => {
1049
- const thread = {
1050
- id: "thread",
1051
- firstComment: null,
1052
- isBugbot: false,
1053
- bugbotReviewPasses: 0,
1054
- };
1055
- const cases = [
1056
- [{ facts: { mergeable: "CONFLICTING" } }, { kind: "merge-conflicts" }],
1057
- [{ threads: [thread] }, { kind: "review-threads" }],
1058
- [
1059
- { facts: { reviewDecision: "REVIEW_REQUIRED" } },
1060
- { kind: "merge-gate", reason: "review-required" },
1061
- ],
1062
- [
1063
- { facts: { mergeStateStatus: "BLOCKED" } },
1064
- { kind: "merge-gate", reason: "merge-blocked" },
1065
- ],
1066
- [{ facts: { isDraft: true } }, { kind: "merge-gate", reason: "draft-pr" }],
1067
- ] as const;
1068
- for (const [overrides, blocker] of cases)
1069
- expect(classifyPr(await read(overrides))).toMatchObject({
1070
- kind: "blocker",
1071
- blocker,
1072
- });
1073
- });
1074
-
1075
- it("fails closed while the head may not have reported yet", async () => {
1076
- const unsettled = [
1077
- {
1078
- commitRollups: [
1079
- { oid: "earlier", state: "SUCCESS" },
1080
- { oid: "head", state: null },
1081
- ],
1082
- },
1083
- { commitRollups: [{ oid: "head", state: "PENDING" }] },
1084
- ] as const;
1085
- for (const overrides of unsettled)
1086
- await expect(read(overrides)).rejects.toMatchObject({
1087
- failure: { kind: "checks-unavailable", retryable: true },
1088
- });
1089
- });
1090
- });
1091
431
 
1092
432
  describe("polling deadline sleeps", () => {
1093
433
  for (const mode of ["single", "queued-stack", "query-retry"] as const) {
@@ -1148,4 +488,5 @@ describe("polling deadline sleeps", () => {
1148
488
  expect(result.kind).toBe("READY");
1149
489
  expect(sleeps).toEqual([60]);
1150
490
  });
491
+
1151
492
  });