patchrelay 0.12.2 → 0.12.4

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.12.2",
4
- "commit": "a39d9226ae29",
5
- "builtAt": "2026-03-24T13:32:47.905Z"
3
+ "version": "0.12.4",
4
+ "commit": "dea4ce43848b",
5
+ "builtAt": "2026-03-24T18:27:01.637Z"
6
6
  }
@@ -10,52 +10,98 @@ export const TERMINAL_STATES = new Set([
10
10
  "done",
11
11
  "escalated",
12
12
  ]);
13
- export const ALLOWED_TRANSITIONS = {
14
- delegated: ["preparing", "failed"],
15
- preparing: ["implementing", "failed"],
16
- implementing: ["pr_open", "awaiting_input", "failed", "escalated"],
17
- pr_open: ["awaiting_review", "awaiting_queue", "changes_requested", "repairing_ci", "failed"],
18
- awaiting_review: ["changes_requested", "awaiting_queue", "repairing_ci"],
19
- changes_requested: ["implementing", "awaiting_input", "escalated"],
20
- repairing_ci: ["pr_open", "awaiting_review", "escalated", "failed"],
21
- awaiting_queue: ["done", "repairing_queue", "repairing_ci", "changes_requested"],
22
- repairing_queue: ["pr_open", "awaiting_review", "awaiting_queue", "escalated", "failed"],
23
- awaiting_input: ["implementing", "delegated", "escalated"],
24
- escalated: [],
25
- done: [],
26
- failed: ["delegated"],
27
- };
28
- export function resolveFactoryStateFromGitHub(triggerEvent, current) {
29
- switch (triggerEvent) {
30
- case "pr_opened":
31
- return current === "implementing" ? "pr_open" : undefined;
32
- case "pr_synchronize":
33
- return undefined; // just resets repair counters, no state change
34
- case "review_approved":
35
- return current === "awaiting_review" || current === "pr_open" ? "awaiting_queue" : undefined;
36
- case "review_changes_requested":
37
- return current === "awaiting_review" || current === "pr_open" || current === "awaiting_queue"
38
- ? "changes_requested"
39
- : undefined;
40
- case "review_commented":
41
- return undefined; // informational only
42
- case "check_passed":
43
- if (current === "repairing_queue")
44
- return "awaiting_queue";
45
- return current === "repairing_ci" ? "pr_open" : undefined;
46
- case "check_failed":
47
- return current === "pr_open" || current === "awaiting_review" || current === "awaiting_queue"
48
- ? "repairing_ci"
49
- : undefined;
50
- case "pr_merged":
51
- return "done";
52
- case "pr_closed":
53
- return "failed";
54
- case "merge_group_passed":
55
- return undefined; // merge event will follow
56
- case "merge_group_failed":
57
- return current === "awaiting_queue" ? "repairing_queue" : undefined;
58
- default:
59
- return undefined;
13
+ // ─── Semantic guards ─────────────────────────────────────────────
14
+ //
15
+ // Guards express INTENT rather than enumerating states. Adding a new
16
+ // state automatically participates in transitions whose guard it
17
+ // satisfies no per-event maintenance required.
18
+ const isOpen = (s) => !TERMINAL_STATES.has(s);
19
+ const TRANSITION_RULES = [
20
+ // ── Terminal events ────────────────────────────────────────────
21
+ // pr_merged is unconditional — PR is merged, we're done.
22
+ { event: "pr_merged",
23
+ to: "done" },
24
+ // pr_closed during an active run is suppressed — Codex may reopen.
25
+ // Without a guard match, the event produces no transition (undefined).
26
+ { event: "pr_closed",
27
+ guard: (_, ctx) => ctx.activeRunId === undefined,
28
+ to: "failed" },
29
+ // ── PR lifecycle ───────────────────────────────────────────────
30
+ { event: "pr_opened",
31
+ guard: (s) => s === "implementing",
32
+ to: "pr_open" },
33
+ // ── Review events apply when no Codex run is actively executing ──
34
+ // Uses activeRunId (runtime state) rather than the state name, because
35
+ // states like changes_requested are "run states" only while the run is
36
+ // active — once the run completes, reviews should be accepted.
37
+ { event: "review_approved",
38
+ guard: (s, ctx) => isOpen(s) && ctx.activeRunId === undefined,
39
+ to: "awaiting_queue" },
40
+ { event: "review_changes_requested",
41
+ guard: (s, ctx) => isOpen(s) && ctx.activeRunId === undefined,
42
+ to: "changes_requested" },
43
+ // review_commented: no rule → no transition (informational only)
44
+ // ── CI check events ────────────────────────────────────────────
45
+ // After queue repair, return to the merge queue.
46
+ { event: "check_passed",
47
+ guard: (s) => s === "repairing_queue",
48
+ to: "awaiting_queue" },
49
+ // After CI repair, return to merge queue if already approved,
50
+ // otherwise to pr_open for review.
51
+ { event: "check_passed",
52
+ guard: (s) => s === "repairing_ci",
53
+ to: (_, ctx) => ctx.prReviewState === "approved" ? "awaiting_queue" : "pr_open" },
54
+ // CI failure when no run is active triggers repair.
55
+ { event: "check_failed",
56
+ guard: (s, ctx) => isOpen(s) && ctx.activeRunId === undefined,
57
+ to: "repairing_ci" },
58
+ // ── Merge queue events ─────────────────────────────────────────
59
+ { event: "merge_group_failed",
60
+ guard: (s) => s === "awaiting_queue",
61
+ to: "repairing_queue" },
62
+ // merge_group_passed: no rule → no transition (merge event follows)
63
+ // pr_synchronize: no rule → no transition (resets counters only)
64
+ ];
65
+ /**
66
+ * Resolve the next factory state from a GitHub webhook event.
67
+ *
68
+ * Returns `undefined` when no rule matches — the event is a no-op
69
+ * for the current state (e.g. check_passed while implementing).
70
+ */
71
+ export function resolveFactoryStateFromGitHub(triggerEvent, current, ctx = {}) {
72
+ for (const rule of TRANSITION_RULES) {
73
+ if (rule.event !== triggerEvent)
74
+ continue;
75
+ if (rule.guard && !rule.guard(current, ctx))
76
+ continue;
77
+ return typeof rule.to === "function" ? rule.to(current, ctx) : rule.to;
60
78
  }
79
+ return undefined;
80
+ }
81
+ /**
82
+ * Derive the allowed transitions table from the rules for documentation
83
+ * and test validation. Not used at runtime.
84
+ */
85
+ export function deriveAllowedTransitions(states, events) {
86
+ const result = {};
87
+ for (const state of states) {
88
+ result[state] = new Set();
89
+ }
90
+ // Sample with common review states to catch dynamic targets
91
+ const contexts = [
92
+ {},
93
+ { prReviewState: "approved" },
94
+ { prReviewState: "changes_requested" },
95
+ ];
96
+ for (const state of states) {
97
+ for (const event of events) {
98
+ for (const ctx of contexts) {
99
+ const target = resolveFactoryStateFromGitHub(event, state, ctx);
100
+ if (target !== undefined && target !== state) {
101
+ result[state].add(target);
102
+ }
103
+ }
104
+ }
105
+ }
106
+ return result;
61
107
  }
@@ -15,25 +15,6 @@ function isMetadataOnlyCheckEvent(event) {
15
15
  return event.eventSource === "check_run"
16
16
  && (event.triggerEvent === "check_passed" || event.triggerEvent === "check_failed");
17
17
  }
18
- /**
19
- * Codex sometimes closes and immediately reopens a PR (e.g. to change the
20
- * base branch or fix the title). A pr_closed event during an active run
21
- * should not transition to "failed" — the reopened event will follow.
22
- * Without this guard, the state gets stuck at "failed" because
23
- * failed → pr_open is not an allowed transition.
24
- */
25
- function shouldSuppressCloseTransition(newState, event, issue) {
26
- return newState === "failed" && event.triggerEvent === "pr_closed" && issue.activeRunId !== undefined;
27
- }
28
- /**
29
- * After a CI repair succeeds and CI passes, the resolver returns pr_open.
30
- * If the PR is already approved, fast-track to awaiting_queue so the merge
31
- * queue picks it up again. This avoids a dead state where the PR is approved
32
- * and CI-green but nobody advances the merge queue.
33
- */
34
- function shouldFastTrackToQueue(newState, issue) {
35
- return newState === "pr_open" && issue.prReviewState === "approved";
36
- }
37
18
  export class GitHubWebhookHandler {
38
19
  config;
39
20
  db;
@@ -141,15 +122,12 @@ export class GitHubWebhookHandler {
141
122
  ...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
142
123
  });
143
124
  if (!isMetadataOnlyCheckEvent(event)) {
144
- // Re-read issue after PR metadata upsert so fast-track sees fresh prReviewState
125
+ // Re-read issue after PR metadata upsert so guards see fresh prReviewState
145
126
  const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
146
- let newState = resolveFactoryStateFromGitHub(event.triggerEvent, afterMetadata.factoryState);
147
- if (shouldSuppressCloseTransition(newState, event, afterMetadata)) {
148
- newState = undefined;
149
- }
150
- if (shouldFastTrackToQueue(newState, afterMetadata)) {
151
- newState = "awaiting_queue";
152
- }
127
+ const newState = resolveFactoryStateFromGitHub(event.triggerEvent, afterMetadata.factoryState, {
128
+ prReviewState: afterMetadata.prReviewState,
129
+ activeRunId: afterMetadata.activeRunId,
130
+ });
153
131
  // Only transition and notify when the state actually changes.
154
132
  // Multiple check_suite events can arrive for the same outcome.
155
133
  if (newState && newState !== afterMetadata.factoryState) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {