@workit-dev/core 0.0.1 → 1.0.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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,994 @@
1
+ // `workit pr status` / `ci wait` / `ci rerun` logic (design §2.1, S10): one
2
+ // forge-neutral status document, the `next` priority, the CI-wait verdict,
3
+ // and rerun planning with the rerun-once rule.
4
+ import { aheadBehind, currentBranch, fetchRefs, hasCommit, headSha, resolveRef } from "../git/rev";
5
+ import { appendObserved, readLedger, type LedgerActor } from "../ledger";
6
+ import { loadCheckConfig, gateCheckNames } from "../check-config";
7
+ import { branchLane } from "../core/policy/lane";
8
+ import {
9
+ checkEvidence,
10
+ type CheckEvidenceDoc,
11
+ type CiCheck,
12
+ localCheckRows,
13
+ } from "../core/policy/check-evidence";
14
+ import type { ResolvedForge } from "./resolve";
15
+ import { rerunCounts, recordReruns, withRerunLock, type RerunRow } from "./reruns";
16
+ import {
17
+ failure,
18
+ success,
19
+ type Forge,
20
+ type ForgeCheck,
21
+ type ForgePrStatus,
22
+ type ForgeResult,
23
+ type ForgeThread,
24
+ type PrState,
25
+ type RefChecks,
26
+ type RerunTarget,
27
+ } from "./types";
28
+
29
+ /**
30
+ * What to do next, in priority order: conflicts > required rebase > threads
31
+ * > CI > review > draft > merge queue > other merge blockers > READY.
32
+ */
33
+ export type NextAction =
34
+ | "MERGED"
35
+ | "CLOSED"
36
+ | "RESOLVE_CONFLICTS"
37
+ | "REBASE"
38
+ | "RESOLVE_THREADS"
39
+ | "FIX_CI"
40
+ | "WAITING_CI"
41
+ | "ADDRESS_REVIEW"
42
+ | "REVIEW"
43
+ | "MARK_READY"
44
+ | "IN_MERGE_QUEUE"
45
+ | "NOT_MERGEABLE"
46
+ | "READY";
47
+
48
+ /**
49
+ * The next babysitting step for a `green`/`merged` endpoint, coarser than
50
+ * `next`: `wait` is CI running (`workit ci wait`); `wait-forge` is CI done
51
+ * but the forge still deciding (merge queue, mergeability computing);
52
+ * `ready` means nothing is left for the agent (a human approval may still be
53
+ * pending); null for a closed, unmerged PR.
54
+ */
55
+ export type BabysitAction =
56
+ | "wait"
57
+ | "wait-forge"
58
+ | "mark-ready"
59
+ | "fix-ci"
60
+ | "address-threads"
61
+ | "update-branch"
62
+ | "ready"
63
+ | "merged";
64
+
65
+ export type FailingCheck = {
66
+ name: string;
67
+ url: string | null;
68
+ runId: number | null;
69
+ jobId: number | null;
70
+ conclusion: string | null;
71
+ /** Required by branch protection; an optional failure does not block the merge. */
72
+ required: boolean | null;
73
+ logTail: string[];
74
+ logError?: string;
75
+ rerunsOnHead: number;
76
+ };
77
+
78
+ export type BehindBase = {
79
+ behind: number | null;
80
+ ahead: number | null;
81
+ baseSha: string | null;
82
+ upToDate: boolean | null;
83
+ /** Why the counts are null (fetch failed, commit not available). */
84
+ error?: string;
85
+ };
86
+
87
+ export type ChecksState = "failing" | "pending" | "passing" | "none";
88
+
89
+ export type PrStatusDoc = {
90
+ forge: "github" | "gitlab";
91
+ repo: string;
92
+ /** The push repository when it is a fork of `repo`. */
93
+ headRepo: string | null;
94
+ number: number;
95
+ url: string;
96
+ state: PrState;
97
+ draft: boolean;
98
+ base: string;
99
+ head: { branch: string; sha: string; localSha: string | null; pushed: boolean | null };
100
+ mergeable: "yes" | "no" | "unknown";
101
+ mergeState: string | null;
102
+ conflicts: boolean;
103
+ rebaseRequired: boolean;
104
+ inMergeQueue: boolean;
105
+ behindBase: BehindBase | null;
106
+ checks: {
107
+ /** Over the checks that gate the merge (required ones when protection names them). */
108
+ state: ChecksState;
109
+ failing: FailingCheck[];
110
+ pending: string[];
111
+ passing: number;
112
+ /** Required contexts that have not reported on this head; null when unknown. */
113
+ missingRequired: string[] | null;
114
+ };
115
+ /** The configured checks this head's review lane needs and their fresh evidence (#509). */
116
+ evidence: CheckEvidenceDoc;
117
+ reviews: { decision: string | null; unresolvedThreads: ForgeThread[] };
118
+ /** Every reason the PR cannot merge right now (empty when READY). */
119
+ blockers: string[];
120
+ identity: { login: string | null; credential: string; note?: string } | null;
121
+ truncated: boolean;
122
+ next: NextAction;
123
+ babysit: BabysitAction | null;
124
+ };
125
+
126
+ /** Failing checks whose log tail is fetched; the rest list without a tail. */
127
+ export const MAX_LOG_TAILS = 5;
128
+
129
+ /**
130
+ * The checks that gate the merge: the required ones when branch protection
131
+ * names any; otherwise every check (an unprotected or unreadable branch is
132
+ * treated conservatively).
133
+ */
134
+ export function gatingChecks(status: ForgePrStatus): {
135
+ checks: ForgeCheck[];
136
+ /** True when branch protection decides which checks gate. */
137
+ protected: boolean;
138
+ } {
139
+ const required = status.checks.filter((check) => check.required === true);
140
+ const protectionNamesChecks = (status.requiredContexts?.length ?? 0) > 0;
141
+ return required.length > 0 || protectionNamesChecks
142
+ ? { checks: required, protected: true }
143
+ : { checks: status.checks, protected: false };
144
+ }
145
+
146
+ export function missingRequired(status: ForgePrStatus): string[] | null {
147
+ if (status.requiredContexts === null) return null;
148
+ const seen = new Set(status.checks.map((check) => check.context));
149
+ return status.requiredContexts.filter((context) => !seen.has(context));
150
+ }
151
+
152
+ export function checksState(
153
+ checks: readonly ForgeCheck[],
154
+ missing: readonly string[] | null = [],
155
+ ): ChecksState {
156
+ if (checks.some((check) => check.state === "failing")) return "failing";
157
+ if (checks.some((check) => check.state === "pending") || (missing?.length ?? 0) > 0)
158
+ return "pending";
159
+ return checks.length > 0 ? "passing" : "none";
160
+ }
161
+
162
+ // GitLab detailed_merge_status values that mean "mergeable now".
163
+ const GITLAB_MERGEABLE = new Set(["mergeable"]);
164
+ // GitHub mergeStateStatus values that allow a merge (UNSTABLE: only
165
+ // non-required checks fail; HAS_HOOKS: pre-receive hooks will run).
166
+ const GITHUB_MERGEABLE = new Set(["clean", "unstable", "has_hooks"]);
167
+
168
+ export type NextInput = {
169
+ forge: "github" | "gitlab";
170
+ state: PrState;
171
+ draft: boolean;
172
+ conflicts: boolean;
173
+ rebaseRequired: boolean;
174
+ mergeable: "yes" | "no" | "unknown";
175
+ mergeState: string | null;
176
+ inMergeQueue: boolean;
177
+ reviewDecision: string | null;
178
+ threads: number;
179
+ checks: ChecksState;
180
+ /** Branch protection names the gating checks (else every check gates). */
181
+ checksRequired?: boolean;
182
+ };
183
+
184
+ /** The ordered merge blockers; the first one is `next`. */
185
+ export function blockersOf(input: NextInput): Array<{ next: NextAction; reason: string }> {
186
+ if (input.state === "merged") return [{ next: "MERGED", reason: "merged" }];
187
+ if (input.state === "closed") return [{ next: "CLOSED", reason: "closed" }];
188
+ const out: Array<{ next: NextAction; reason: string }> = [];
189
+ const ms = input.mergeState ?? "";
190
+ if (input.conflicts) out.push({ next: "RESOLVE_CONFLICTS", reason: "conflicts" });
191
+ if (input.rebaseRequired) out.push({ next: "REBASE", reason: "behind_base_required" });
192
+ if (input.threads > 0 || ms === "discussions_not_resolved")
193
+ out.push({ next: "RESOLVE_THREADS", reason: "unresolved_threads" });
194
+ if (input.checks === "failing")
195
+ out.push({
196
+ next: "FIX_CI",
197
+ reason: input.checksRequired ? "required_checks_failing" : "checks_failing",
198
+ });
199
+ else if (
200
+ input.checks === "pending" ||
201
+ ms === "ci_still_running" ||
202
+ (ms === "ci_must_pass" && input.checks !== "passing")
203
+ )
204
+ out.push({ next: "WAITING_CI", reason: "checks_pending" });
205
+ if (input.reviewDecision === "changes_requested" || ms === "requested_changes")
206
+ out.push({ next: "ADDRESS_REVIEW", reason: "changes_requested" });
207
+ else if (input.reviewDecision === "review_required" || ms === "not_approved")
208
+ out.push({ next: "REVIEW", reason: "review_required" });
209
+ if (input.draft || ms === "draft" || ms === "draft_status")
210
+ out.push({ next: "MARK_READY", reason: "draft" });
211
+ if (input.inMergeQueue) out.push({ next: "IN_MERGE_QUEUE", reason: "in_merge_queue" });
212
+ if (out.length === 0) {
213
+ const allowed = input.forge === "github" ? GITHUB_MERGEABLE.has(ms) : GITLAB_MERGEABLE.has(ms);
214
+ if (input.mergeable === "unknown")
215
+ out.push({ next: "NOT_MERGEABLE", reason: "mergeability_unknown" });
216
+ else if (!allowed)
217
+ out.push({ next: "NOT_MERGEABLE", reason: `merge_state_${ms || "unknown"}` });
218
+ }
219
+ return out;
220
+ }
221
+
222
+ export function nextAction(input: NextInput): NextAction {
223
+ return blockersOf(input)[0]?.next ?? "READY";
224
+ }
225
+
226
+ /**
227
+ * Map `next` (plus the first blocker's reason) to one babysitting step. The
228
+ * branch is updated only when the forge requires it (conflicts, a required
229
+ * rebase): merely behind its base is still `ready`, so a busy base never
230
+ * starts a rebase/CI loop or drops approvals. A draft reads `mark-ready` once
231
+ * nothing else is open, even when a review is also pending.
232
+ */
233
+ export function babysitAction(
234
+ next: NextAction,
235
+ reason: string | null,
236
+ draft: boolean,
237
+ ): BabysitAction | null {
238
+ switch (next) {
239
+ case "MERGED":
240
+ return "merged";
241
+ case "CLOSED":
242
+ return null;
243
+ case "RESOLVE_CONFLICTS":
244
+ case "REBASE":
245
+ return "update-branch";
246
+ case "RESOLVE_THREADS":
247
+ case "ADDRESS_REVIEW":
248
+ return "address-threads";
249
+ case "FIX_CI":
250
+ return "fix-ci";
251
+ case "WAITING_CI":
252
+ return "wait";
253
+ case "IN_MERGE_QUEUE":
254
+ return "wait-forge";
255
+ default:
256
+ if (draft) return "mark-ready";
257
+ if (next === "NOT_MERGEABLE" && reason === "mergeability_unknown") return "wait-forge";
258
+ return "ready";
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Count head against the base tip the API reported, locally. A pure read:
264
+ * missing commits are fetched by id (no destination ref, no FETCH_HEAD), so
265
+ * no ref in the shared repository moves.
266
+ */
267
+ export function computeBehindBase(
268
+ cwd: string,
269
+ remotes: { base: string; head: string },
270
+ baseSha: string | null,
271
+ headSha_: string,
272
+ options: { timeoutMs?: number } = {},
273
+ ): ForgeResult<BehindBase> {
274
+ const empty = { behind: null, ahead: null, upToDate: null };
275
+ if (!baseSha)
276
+ return success({ ...empty, baseSha: null, error: "the forge reported no base tip" });
277
+ for (const [sha, remote] of [
278
+ [baseSha, remotes.base],
279
+ [headSha_, remotes.head],
280
+ ] as const) {
281
+ if (hasCommit(cwd, sha)) continue;
282
+ const fetched = fetchRefs(cwd, remote, [sha], options);
283
+ if (!fetched.ok && remote !== remotes.base) fetchRefs(cwd, remotes.base, [sha], options);
284
+ if (!hasCommit(cwd, sha))
285
+ return success({
286
+ ...empty,
287
+ baseSha,
288
+ error: fetched.ok ? `${sha.slice(0, 12)} is not available locally` : fetched.error,
289
+ });
290
+ }
291
+ const counts = aheadBehind(cwd, baseSha, headSha_);
292
+ return success(
293
+ counts ? { ...counts, baseSha } : { ...empty, baseSha, error: "ahead/behind did not resolve" },
294
+ );
295
+ }
296
+
297
+ export type ReportOptions = {
298
+ pr?: number | null;
299
+ branch?: string | null;
300
+ logLines?: number;
301
+ /** Skip the behind-base count (ci wait polls). */
302
+ behind?: boolean;
303
+ };
304
+
305
+ /** The PR/MR number for `--pr`, `--branch`, or the current branch. */
306
+ export function selectPr(
307
+ cwd: string,
308
+ resolved: ResolvedForge,
309
+ options: ReportOptions,
310
+ ): ForgeResult<number> {
311
+ if (options.pr) return success(options.pr);
312
+ const branch = options.branch ?? currentBranch(cwd);
313
+ if (!branch)
314
+ return failure("invalid_input", "HEAD is detached; pass --pr <n> or --branch <name>");
315
+ const localSha = resolveRef(cwd, `refs/heads/${branch}`);
316
+ const found = resolved.forge.findPr(branch, {
317
+ owner: resolved.headRepo.split("/")[0] ?? null,
318
+ projectId: resolved.headProjectId,
319
+ sha: localSha,
320
+ });
321
+ if (!found.ok) return found;
322
+ if (!found.data) {
323
+ const noun = resolved.forge.kind === "github" ? "pull request" : "merge request";
324
+ return failure(
325
+ "not_found",
326
+ `no ${noun} for branch ${branch} from ${resolved.headRepo} in ${resolved.forge.repo}`,
327
+ "workit git push && workit pr create --fill # or pass --pr <n>",
328
+ );
329
+ }
330
+ return success(found.data.number);
331
+ }
332
+
333
+ /** Local runs observed on this branch, and CI's checks, folded into the lane's evidence doc. */
334
+ export function evidenceFor(
335
+ cwd: string,
336
+ status: ForgePrStatus,
337
+ ci: readonly ForgeCheck[] = status.checks,
338
+ ): CheckEvidenceDoc {
339
+ const lane = branchLane(cwd, { base: status.base, head: status.head.sha });
340
+ const configured = gateCheckNames(loadCheckConfig(cwd), "verification");
341
+ const read = readLedger(cwd);
342
+ const local = read.ok ? localCheckRows(read.value.rows) : [];
343
+ const ciChecks: CiCheck[] = ci.flatMap((check) =>
344
+ check.state === "skipped" ? [] : [{ name: check.name, state: check.state }],
345
+ );
346
+ return checkEvidence({ lane: lane.lane, head: status.head.sha, configured, local, ci: ciChecks });
347
+ }
348
+
349
+ export function buildStatusDoc(
350
+ cwd: string,
351
+ resolved: ResolvedForge,
352
+ status: ForgePrStatus,
353
+ options: {
354
+ logLines: number;
355
+ behind: boolean;
356
+ behindTimeoutMs?: number;
357
+ identity?: PrStatusDoc["identity"];
358
+ },
359
+ ): ForgeResult<PrStatusDoc> {
360
+ const { forge } = resolved;
361
+ const reruns = rerunCounts(cwd, { repo: forge.repo, head: status.head.sha });
362
+ const gating = gatingChecks(status);
363
+ const missing = missingRequired(status);
364
+ const failing = status.checks.filter((check) => check.state === "failing");
365
+ const failingDocs = failing.map((check, index): FailingCheck => {
366
+ const doc: FailingCheck = {
367
+ name: check.name,
368
+ url: check.url,
369
+ runId: check.runId,
370
+ jobId: check.jobId,
371
+ conclusion: check.conclusion,
372
+ required: check.required,
373
+ logTail: [],
374
+ rerunsOnHead: reruns.get(check.name) ?? 0,
375
+ };
376
+ if (check.jobId !== null && options.logLines > 0 && index < MAX_LOG_TAILS) {
377
+ const tail = forge.jobLogTail({ jobId: check.jobId, scope: check.scope }, options.logLines);
378
+ if (tail.ok) doc.logTail = tail.data;
379
+ else doc.logError = tail.error;
380
+ }
381
+ return doc;
382
+ });
383
+ const local =
384
+ currentBranch(cwd) === status.head.branch
385
+ ? headSha(cwd)
386
+ : resolveRef(cwd, `refs/heads/${status.head.branch}`);
387
+ let behindBase: BehindBase | null = null;
388
+ if (options.behind && status.state === "open") {
389
+ const counted = computeBehindBase(
390
+ cwd,
391
+ { base: resolved.baseRemote, head: resolved.remote },
392
+ status.baseSha,
393
+ status.head.sha,
394
+ options.behindTimeoutMs === undefined ? {} : { timeoutMs: options.behindTimeoutMs },
395
+ );
396
+ if (!counted.ok) return counted;
397
+ behindBase = counted.data;
398
+ }
399
+ const state = checksState(gating.checks, missing);
400
+ const blockers = blockersOf({
401
+ forge: forge.kind,
402
+ state: status.state,
403
+ draft: status.draft,
404
+ conflicts: status.conflicts,
405
+ rebaseRequired: status.rebaseRequired,
406
+ mergeable: status.mergeable,
407
+ mergeState: status.mergeState,
408
+ inMergeQueue: status.inMergeQueue,
409
+ reviewDecision: status.reviewDecision,
410
+ threads: status.threads.length,
411
+ checks: state,
412
+ checksRequired: gating.protected,
413
+ });
414
+ return success({
415
+ forge: forge.kind,
416
+ repo: forge.repo,
417
+ headRepo: resolved.fork ? resolved.headRepo : null,
418
+ number: status.number,
419
+ url: status.url,
420
+ state: status.state,
421
+ draft: status.draft,
422
+ base: status.base,
423
+ head: {
424
+ branch: status.head.branch,
425
+ sha: status.head.sha,
426
+ localSha: local,
427
+ pushed: local === null ? null : local === status.head.sha,
428
+ },
429
+ mergeable: status.mergeable,
430
+ mergeState: status.mergeState,
431
+ conflicts: status.conflicts,
432
+ rebaseRequired: status.rebaseRequired,
433
+ inMergeQueue: status.inMergeQueue,
434
+ behindBase,
435
+ checks: {
436
+ state,
437
+ failing: failingDocs,
438
+ pending: status.checks
439
+ .filter((check) => check.state === "pending")
440
+ .map((check) => check.name),
441
+ passing: status.checks.filter((check) => check.state === "passing").length,
442
+ missingRequired: missing,
443
+ },
444
+ evidence: evidenceFor(cwd, status),
445
+ reviews: { decision: status.reviewDecision, unresolvedThreads: status.threads },
446
+ blockers: status.state === "open" ? blockers.map((blocker) => blocker.reason) : [],
447
+ identity: options.identity ?? null,
448
+ truncated: status.truncated,
449
+ next: blockers[0]?.next ?? "READY",
450
+ babysit: babysitAction(
451
+ blockers[0]?.next ?? "READY",
452
+ blockers[0]?.reason ?? null,
453
+ status.draft || blockers.some((blocker) => blocker.next === "MARK_READY"),
454
+ ),
455
+ });
456
+ }
457
+
458
+ /**
459
+ * Record what the forge said about a PR as a CLI-observed `pr.status` row
460
+ * (pr, branch, head, base, state: open|merged|closed, checks), only when it
461
+ * differs from the newest row for that PR, so polling never floods the
462
+ * ledger. Hooks read it without calling the forge: an open PR's checks
463
+ * (passing|failing|pending; one without gating checks is not recorded), and
464
+ * a PR merged or closed outside workit (the forge UI). Never fails the verb:
465
+ * a ledger error only loses the row.
466
+ */
467
+ export function recordPrStatus(cwd: string, doc: PrStatusDoc, actor: LedgerActor): void {
468
+ const checks = doc.checks.state;
469
+ const state = doc.state;
470
+ if (state !== "open" && state !== "merged" && state !== "closed") return;
471
+ if (state === "open" && checks !== "passing" && checks !== "failing" && checks !== "pending")
472
+ return;
473
+ try {
474
+ const ledger = readLedger(cwd);
475
+ if (!ledger.ok) return;
476
+ const last = ledger.value.rows.findLast(
477
+ (row) =>
478
+ row.type === "pr.status" &&
479
+ row.observer === "workit_cli" &&
480
+ row.pr === doc.number &&
481
+ row.repo === doc.repo,
482
+ );
483
+ if (
484
+ last &&
485
+ last.head === doc.head.sha &&
486
+ last.checks === checks &&
487
+ (last.state ?? "open") === state &&
488
+ (last.base ?? null) === (doc.base ?? null)
489
+ )
490
+ return;
491
+ appendObserved(cwd, {
492
+ type: "pr.status",
493
+ actor,
494
+ branch: doc.head.branch,
495
+ head: doc.head.sha,
496
+ pr: doc.number,
497
+ repo: doc.repo,
498
+ forge: doc.forge,
499
+ state,
500
+ checks,
501
+ base: doc.base,
502
+ });
503
+ } catch {
504
+ // The row is an observation for hooks; the verb's answer stands without it.
505
+ }
506
+ }
507
+
508
+ /** Select, read and document one PR/MR (recorded as a `pr.status` row when `actor` is given). */
509
+ export function prStatusReport(
510
+ cwd: string,
511
+ resolved: ResolvedForge,
512
+ options: ReportOptions & { identity?: PrStatusDoc["identity"]; actor?: LedgerActor } = {},
513
+ ): ForgeResult<{ doc: PrStatusDoc; raw: ForgePrStatus }> {
514
+ const number = selectPr(cwd, resolved, options);
515
+ if (!number.ok) return number;
516
+ const status = resolved.forge.prStatus(number.data);
517
+ if (!status.ok) return status;
518
+ const doc = buildStatusDoc(cwd, resolved, status.data, {
519
+ logLines: options.logLines ?? 60,
520
+ behind: options.behind ?? true,
521
+ identity: options.identity,
522
+ });
523
+ if (!doc.ok) return doc;
524
+ if (options.actor) recordPrStatus(cwd, doc.data, options.actor);
525
+ return success({ doc: doc.data, raw: status.data });
526
+ }
527
+
528
+ // ---------------------------------------------------------------------------
529
+ // ci wait
530
+
531
+ export type WaitState = "ready" | "waiting" | "failed" | "blocked";
532
+
533
+ export type WaitVerdict = { state: WaitState; reason: string; unblock?: string };
534
+
535
+ /**
536
+ * Checks never appeared: after this grace a PR with no missing required check
537
+ * reads "none" (no CI configured). A forge is slow to register the runs of a
538
+ * freshly pushed head, so the grace is generous (#510).
539
+ */
540
+ export const NO_CHECKS_GRACE_MS = 120_000;
541
+
542
+ /**
543
+ * CI verdict for the PR head. Only gating checks count; a closed or merged
544
+ * PR is never "ready". No checks at all is ready only after the grace and
545
+ * only when the forge says nothing is required.
546
+ */
547
+ export function waitVerdict(
548
+ doc: PrStatusDoc,
549
+ options: {
550
+ head?: string | null;
551
+ elapsedMs: number;
552
+ noChecksGraceMs?: number;
553
+ /**
554
+ * Checks the base branch ran that this head has not registered yet. Only
555
+ * set when branch protection is unreadable, so required names are unknown (#510).
556
+ */
557
+ expectedMissing?: readonly string[];
558
+ },
559
+ ): WaitVerdict {
560
+ if (doc.state !== "open")
561
+ return {
562
+ state: "blocked",
563
+ reason: `pr_${doc.state}`,
564
+ unblock: `the ${doc.forge === "github" ? "PR" : "MR"} is ${doc.state}; nothing to wait for`,
565
+ };
566
+ if (options.head && !doc.head.sha.startsWith(options.head))
567
+ return { state: "waiting", reason: "head_mismatch" };
568
+ const checks = doc.checks.state;
569
+ // Fail-fast on purpose: a failing gating check ends the wait even while
570
+ // other checks still run (`checks.pending` in the payload lists them).
571
+ if (checks === "failing") return { state: "failed", reason: "checks_failing" };
572
+ // Before "pending": CI never runs on a conflicting PR, so waiting cannot help (M8).
573
+ if (doc.conflicts && checks !== "passing")
574
+ return {
575
+ state: "blocked",
576
+ reason: "conflicts",
577
+ unblock: `rebase onto ${doc.base} and push; CI does not run on a conflicting PR`,
578
+ };
579
+ if (checks === "pending") return { state: "waiting", reason: "checks_pending" };
580
+ // Gating checks passed but another check on the head is still queued or
581
+ // running (a non-required workflow): never green while anything runs (#440).
582
+ if (doc.checks.pending.length > 0) return { state: "waiting", reason: "checks_pending" };
583
+ // Small workflows register and pass in seconds while the main CI registers
584
+ // later: a head still missing a check the base ran is not done within the grace.
585
+ const grace = options.noChecksGraceMs ?? NO_CHECKS_GRACE_MS;
586
+ if ((options.expectedMissing?.length ?? 0) > 0 && options.elapsedMs < grace)
587
+ return { state: "waiting", reason: "checks_not_registered" };
588
+ if (checks === "passing") return { state: "ready", reason: "checks_passing" };
589
+ // Unknown protection (null) is not "required": the grace bounds the wait.
590
+ const ciRequired =
591
+ (doc.checks.missingRequired?.length ?? 0) > 0 ||
592
+ doc.mergeState === "ci_must_pass" ||
593
+ doc.mergeState === "ci_still_running";
594
+ if (ciRequired) return { state: "waiting", reason: "no_checks_yet" };
595
+ if (options.elapsedMs < grace) return { state: "waiting", reason: "no_checks_yet" };
596
+ return { state: "ready", reason: "no_checks" };
597
+ }
598
+
599
+ /**
600
+ * Poll delays: `interval`, growing 1.5x per poll up to max(interval, 2 min).
601
+ * Deterministic, so a wait's poll count is predictable from its timeout.
602
+ */
603
+ export function pollDelay(intervalMs: number, poll: number): number {
604
+ const cap = Math.max(intervalMs, 120_000);
605
+ return Math.min(cap, Math.round(intervalMs * 1.5 ** poll));
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // ci rerun
610
+
611
+ export type RerunPlan = {
612
+ checks: ForgeCheck[];
613
+ targets: RerunTarget[];
614
+ /** Selected checks the forge cannot rerun (external statuses). */
615
+ skipped: string[];
616
+ };
617
+
618
+ export function planRerun(
619
+ status: ForgePrStatus,
620
+ selection: { failed: boolean; names: readonly string[] },
621
+ ): ForgeResult<RerunPlan> {
622
+ return planRerunChecks(status.checks, status.head.sha, selection);
623
+ }
624
+
625
+ function planRerunChecks(
626
+ all: readonly ForgeCheck[],
627
+ head: string,
628
+ selection: { failed: boolean; names: readonly string[] },
629
+ ): ForgeResult<RerunPlan> {
630
+ let checks: ForgeCheck[];
631
+ if (selection.names.length > 0) {
632
+ const unknown = selection.names.filter((name) => !all.some((check) => check.name === name));
633
+ if (unknown.length)
634
+ return failure(
635
+ "invalid_input",
636
+ `unknown check ${unknown.map((name) => JSON.stringify(name)).join(", ")}; known: ${all.map((check) => check.name).join(", ") || "none"}`,
637
+ );
638
+ checks = all.filter((check) => selection.names.includes(check.name));
639
+ const running = checks.filter((check) => check.state === "pending");
640
+ if (running.length)
641
+ return failure(
642
+ "busy",
643
+ `still running: ${running.map((check) => check.name).join(", ")}`,
644
+ "workit ci wait",
645
+ );
646
+ } else {
647
+ checks = all.filter((check) => check.state === "failing");
648
+ }
649
+ if (checks.length === 0)
650
+ return failure("not_found", `no failing checks on head ${head.slice(0, 12)}`);
651
+ const rerunnable = checks.filter((check) => check.jobId !== null && check.runId !== null);
652
+ const skipped = checks.filter((check) => !rerunnable.includes(check)).map((check) => check.name);
653
+ const targets: RerunTarget[] = [];
654
+ if (selection.names.length === 0) {
655
+ // One "rerun failed jobs" per workflow run / pipeline. The forge refuses
656
+ // it while the run still has jobs in progress (GitHub: HTTP 403).
657
+ const busy = all.filter(
658
+ (check) =>
659
+ check.state === "pending" &&
660
+ check.runId !== null &&
661
+ rerunnable.some((failing) => failing.runId === check.runId),
662
+ );
663
+ if (busy.length)
664
+ return failure(
665
+ "busy",
666
+ `the failed jobs' run is still in progress (${busy.map((check) => check.name).join(", ")})`,
667
+ "workit ci wait, then rerun",
668
+ );
669
+ const seen = new Set<number>();
670
+ for (const check of rerunnable)
671
+ if (!seen.has(check.runId!)) {
672
+ seen.add(check.runId!);
673
+ targets.push({ kind: "failed_in_run", runId: check.runId!, scope: check.scope });
674
+ }
675
+ } else {
676
+ for (const check of rerunnable)
677
+ targets.push({ kind: "job", jobId: check.jobId!, scope: check.scope });
678
+ }
679
+ if (targets.length === 0)
680
+ return failure(
681
+ "unavailable",
682
+ `${skipped.join(", ")} cannot be rerun from here (not a CI job); rerun it on the forge`,
683
+ );
684
+ return success({ checks: rerunnable, targets, skipped });
685
+ }
686
+
687
+ export type RerunOutcome = {
688
+ /** The PR rerun; null for a branch rerun. */
689
+ pr: number | null;
690
+ /** The branch whose latest run was rerun; null for a PR rerun. */
691
+ branch: string | null;
692
+ head: string;
693
+ reason: string;
694
+ forced: boolean;
695
+ rerun: Array<{ name: string; runId: number | null; jobId: number | null }>;
696
+ skipped: string[];
697
+ recorded: boolean;
698
+ };
699
+
700
+ type RerunOptions = { failed: boolean; names: readonly string[]; reason: string; force: boolean };
701
+
702
+ /** What a rerun acts on: a PR head, or the head of a branch (a red base). */
703
+ type RerunSubject = { pr: number | null; branch: string | null; head: string };
704
+
705
+ /**
706
+ * Apply the rerun-once rule, trigger the reruns, and record one row per check
707
+ * whose rerun the forge accepted.
708
+ */
709
+ export function executeRerun(
710
+ cwd: string,
711
+ resolved: ResolvedForge,
712
+ status: ForgePrStatus,
713
+ options: RerunOptions,
714
+ ): ForgeResult<RerunOutcome> {
715
+ if (status.state !== "open")
716
+ return failure("blocked", `${status.state} PR: CI cannot be rerun`, "nothing to do");
717
+ const plan = planRerun(status, options);
718
+ if (!plan.ok) return plan;
719
+ return lockedRerun(
720
+ cwd,
721
+ resolved,
722
+ { pr: status.number, branch: null, head: status.head.sha },
723
+ plan.data,
724
+ options,
725
+ );
726
+ }
727
+
728
+ /**
729
+ * `ci rerun --ref`: the same rule for the latest CI run on a branch's head
730
+ * (GitHub: its workflow runs; GitLab: its latest pipeline), so a base that
731
+ * went red on a flake can be rerun without a PR.
732
+ */
733
+ export function executeBranchRerun(
734
+ cwd: string,
735
+ resolved: ResolvedForge,
736
+ branch: string,
737
+ ref: RefChecks,
738
+ options: RerunOptions,
739
+ ): ForgeResult<RerunOutcome> {
740
+ if (!ref.sha)
741
+ return failure("unavailable", `the forge did not report the head commit of ${branch}`);
742
+ if (ref.checks.length === 0)
743
+ return failure("not_found", `no CI has reported for ${branch} at ${ref.sha.slice(0, 12)}`);
744
+ const plan = planRerunChecks(ref.checks, ref.sha, options);
745
+ if (!plan.ok) return plan;
746
+ return lockedRerun(cwd, resolved, { pr: null, branch, head: ref.sha }, plan.data, options);
747
+ }
748
+
749
+ function lockedRerun(
750
+ cwd: string,
751
+ resolved: ResolvedForge,
752
+ subject: RerunSubject,
753
+ plan: RerunPlan,
754
+ options: RerunOptions,
755
+ ): ForgeResult<RerunOutcome> {
756
+ const locked = withRerunLock(cwd, () => rerunLocked(cwd, resolved, subject, plan, options));
757
+ return (
758
+ locked ??
759
+ failure("busy", "another ci rerun is in progress for this repository", "retry in a moment")
760
+ );
761
+ }
762
+
763
+ function rerunLocked(
764
+ cwd: string,
765
+ resolved: ResolvedForge,
766
+ subject: RerunSubject,
767
+ plan: RerunPlan,
768
+ options: RerunOptions,
769
+ ): ForgeResult<RerunOutcome> {
770
+ const { forge } = resolved;
771
+ const counts = rerunCounts(cwd, { repo: forge.repo, head: subject.head });
772
+ const repeated = plan.checks.filter((check) => (counts.get(check.name) ?? 0) > 0);
773
+ if (repeated.length && !options.force)
774
+ return failure(
775
+ "blocked",
776
+ `rerun_limit: ${repeated.map((check) => check.name).join(", ")} already rerun once on head ${subject.head.slice(0, 12)}`,
777
+ "treat it as a real failure and fix it; if it is a confirmed flake, rerun with --force",
778
+ );
779
+ const accepted: ForgeCheck[] = [];
780
+ for (const target of plan.targets) {
781
+ const done = forge.rerun(target);
782
+ if (!done.ok) {
783
+ recordAccepted(cwd, resolved, subject, accepted, options);
784
+ return failure(
785
+ done.code,
786
+ accepted.length
787
+ ? `${done.error} (already rerun: ${accepted.map((check) => check.name).join(", ")})`
788
+ : done.error,
789
+ done.unblock,
790
+ );
791
+ }
792
+ accepted.push(
793
+ ...plan.checks.filter((check) =>
794
+ target.kind === "failed_in_run"
795
+ ? check.runId === target.runId
796
+ : check.jobId === target.jobId,
797
+ ),
798
+ );
799
+ }
800
+ const recorded = recordAccepted(cwd, resolved, subject, accepted, options);
801
+ return success({
802
+ pr: subject.pr,
803
+ branch: subject.branch,
804
+ head: subject.head,
805
+ reason: options.reason,
806
+ forced: options.force,
807
+ rerun: accepted.map((check) => ({ name: check.name, runId: check.runId, jobId: check.jobId })),
808
+ skipped: plan.skipped,
809
+ recorded,
810
+ });
811
+ }
812
+
813
+ function recordAccepted(
814
+ cwd: string,
815
+ resolved: ResolvedForge,
816
+ subject: RerunSubject,
817
+ accepted: readonly ForgeCheck[],
818
+ options: { reason: string; force: boolean },
819
+ ): boolean {
820
+ const at = new Date().toISOString();
821
+ const rows: RerunRow[] = accepted.map((check) => ({
822
+ v: 1,
823
+ at,
824
+ type: "ci.rerun",
825
+ forge: resolved.forge.kind,
826
+ repo: resolved.forge.repo,
827
+ pr: subject.pr,
828
+ branch: subject.branch,
829
+ head: subject.head,
830
+ runId: check.runId,
831
+ check: check.name,
832
+ reason: options.reason,
833
+ forced: options.force,
834
+ }));
835
+ return recordReruns(cwd, rows);
836
+ }
837
+
838
+ // ---------------------------------------------------------------------------
839
+ // merge gate: optional-check waivers (#269 D1) and transient mergeability (D2)
840
+
841
+ /** `pr merge --allow-failing <check>… --reason <why>`. */
842
+ export type CheckWaiver = { checks: readonly string[]; reason: string };
843
+
844
+ /** A check a waiver passed on a merged PR: its `merge.waived` row, or why it could not be written. */
845
+ export type WaivedCheck = { check: string; recorded: string } | { check: string; error: string };
846
+
847
+ /**
848
+ * `status` with the failing checks `waiver` names marked waived (state
849
+ * `skipped`, conclusion `waived_<conclusion>`), so the merge gate reads them
850
+ * as not gating. A required check is never waived: naming one refuses, and
851
+ * so does one whose required flag the forge could not read. A name that
852
+ * matches no check on the head is a usage error (a typo never merges).
853
+ */
854
+ export function waiveChecks(
855
+ status: ForgePrStatus,
856
+ waiver: CheckWaiver,
857
+ /** `stack land` (#739): a name this head does not run is skipped, not refused. */
858
+ options: { perPr?: boolean } = {},
859
+ ): ForgeResult<{ status: ForgePrStatus; waived: ForgeCheck[] }> {
860
+ const required = (check: ForgeCheck): boolean | null =>
861
+ check.required === true || status.requiredContexts?.includes(check.context) === true
862
+ ? true
863
+ : check.required === false
864
+ ? false
865
+ : null;
866
+ const named = new Set<ForgeCheck>();
867
+ for (const name of waiver.checks) {
868
+ const matches = status.checks.filter((check) => check.name === name || check.context === name);
869
+ if (!matches.length && options.perPr) continue;
870
+ if (!matches.length)
871
+ return failure(
872
+ "invalid_input",
873
+ `unknown_check: --allow-failing ${JSON.stringify(name)} matches no check on the head; checks: ${status.checks.map((check) => check.name).join(", ") || "none"}`,
874
+ "workit pr status # copy the check name exactly",
875
+ );
876
+ for (const check of matches) named.add(check);
877
+ }
878
+ const hard = [...named].filter((check) => required(check) !== false);
879
+ if (hard.length)
880
+ return failure(
881
+ "blocked",
882
+ `waiver_refused: ${hard.map((check) => `${check.name} (${required(check) ? "required by branch protection" : "the forge cannot say it is optional"})`).join(", ")}; --allow-failing only passes a check branch protection does not require`,
883
+ "fix the check (workit pr status shows its log tail), push, then workit ci wait",
884
+ );
885
+ const waived = [...named].filter((check) => check.state === "failing");
886
+ return success({
887
+ status: {
888
+ ...status,
889
+ checks: status.checks.map((check) =>
890
+ waived.includes(check)
891
+ ? { ...check, state: "skipped", conclusion: `waived_${check.conclusion ?? "failure"}` }
892
+ : check,
893
+ ),
894
+ },
895
+ waived,
896
+ });
897
+ }
898
+
899
+ /** Waits between re-reads while the forge reports mergeability UNKNOWN (3.5 s in all). */
900
+ export const MERGEABILITY_RETRY_MS: readonly number[] = [500, 1000, 2000];
901
+
902
+ /** Tests replace `sleep` (the forge reads are synchronous). */
903
+ export const mergeGateDeps = {
904
+ sleep: (ms: number): void => {
905
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
906
+ },
907
+ };
908
+
909
+ /**
910
+ * `forge` as `pr merge` and `stack land` read it until the merge call: an
911
+ * open PR whose mergeability is UNKNOWN (the forge still computing it) is
912
+ * re-read with backoff, `MERGEABILITY_RETRY_MS`, before the gate decides (it
913
+ * stops at `wait-forge` when it stays unknown); and a waiver passes the
914
+ * failing optional checks it names. Each waived check is recorded as a
915
+ * `merge.waived` row only once the forge accepts the merge: a refusal before
916
+ * the merge call (another author, a verdict, a dry run) or a failed merge
917
+ * records nothing. After the merge call the forge is read as is. `waived`
918
+ * lists the checks a waiver passed on the merged PR, each with its row id,
919
+ * or the error when the row could not be written after the merge.
920
+ *
921
+ * `perPr` (`stack land`, #739): the waiver applies to each PR whose head runs
922
+ * the named check, and a PR without it is not refused for it. `matched` then
923
+ * collects the names some read head ran, so the caller can refuse a name no
924
+ * PR runs (`unknown_check`); `waives(n)` names the failing checks the waiver
925
+ * passes at PR n's latest read.
926
+ */
927
+ export function mergeGateForge(
928
+ forge: Forge,
929
+ cwd: string,
930
+ options: { waiver: CheckWaiver | null; actor: LedgerActor; perPr?: boolean },
931
+ ): Forge & {
932
+ waived: WaivedCheck[];
933
+ matched: Set<string>;
934
+ waives: (number: number) => string[];
935
+ } {
936
+ const waivedRows: WaivedCheck[] = [];
937
+ const matched = new Set<string>();
938
+ let merging = false;
939
+ // The waiver decided at the latest read of each PR; recorded on its merge.
940
+ const pending = new Map<number, { status: ForgePrStatus; checks: ForgeCheck[] }>();
941
+ const read = (number: number): ForgeResult<ForgePrStatus> => {
942
+ let status = forge.prStatus(number);
943
+ for (const delay of MERGEABILITY_RETRY_MS) {
944
+ if (!status.ok || status.data.state !== "open" || status.data.mergeable !== "unknown") break;
945
+ mergeGateDeps.sleep(delay);
946
+ status = forge.prStatus(number);
947
+ }
948
+ return status;
949
+ };
950
+ const record = (status: ForgePrStatus, check: ForgeCheck, reason: string): WaivedCheck => {
951
+ const row = appendObserved(cwd, {
952
+ type: "merge.waived",
953
+ actor: options.actor,
954
+ branch: status.head.branch,
955
+ head: status.head.sha,
956
+ pr: status.number,
957
+ base: status.base,
958
+ check: check.name,
959
+ conclusion: check.conclusion,
960
+ reason,
961
+ what: `#${status.number} failing optional check ${check.name} waived at ${status.head.sha.slice(0, 12)} (reason: ${reason})`,
962
+ });
963
+ return row.ok
964
+ ? { check: check.name, recorded: String(row.value.id) }
965
+ : { check: check.name, error: row.error };
966
+ };
967
+ return {
968
+ ...forge,
969
+ prStatus(number) {
970
+ if (merging) return forge.prStatus(number);
971
+ const status = read(number);
972
+ if (!status.ok || !options.waiver || status.data.state !== "open") return status;
973
+ for (const name of options.waiver.checks)
974
+ if (status.data.checks.some((check) => check.name === name || check.context === name))
975
+ matched.add(name);
976
+ const waived = waiveChecks(status.data, options.waiver, { perPr: options.perPr });
977
+ if (!waived.ok) return waived;
978
+ pending.set(number, { status: status.data, checks: waived.data.waived });
979
+ return success(waived.data.status);
980
+ },
981
+ merge(number, mergeOptions) {
982
+ merging = true;
983
+ const merged = forge.merge(number, mergeOptions);
984
+ const decided = pending.get(number);
985
+ if (merged.ok && decided && options.waiver)
986
+ for (const check of decided.checks)
987
+ waivedRows.push(record(decided.status, check, options.waiver.reason));
988
+ return merged;
989
+ },
990
+ waived: waivedRows,
991
+ matched,
992
+ waives: (number) => pending.get(number)?.checks.map((check) => check.name) ?? [],
993
+ };
994
+ }